Needleman-Wunsch finds the best global alignment of two sequences, but the traceback table it needs has one cell per pair of positions. Two 100,000-symbol sequences need ten billion cells, which is 40 GB at four bytes a cell, before anything has been aligned. Hirschberg's algorithm, published in 1975 for the longest common subsequence problem, removes that wall. It returns an optimal alignment using memory proportional to the sum of the lengths, and in exchange it roughly doubles the arithmetic.

This page works through it for scored alignment with match, mismatch and gap costs, the form people use for DNA, proteins and line-based diffs. It covers the observation that makes it work, a tested Python implementation, a worked example traced by hand, why the running time stays quadratic, and where it breaks in practice. The LCS special case is covered in the longest common subsequence article, and the full-table method in the Needleman-Wunsch article; here you only need to know how an alignment table is filled row by row.

The memory wall in sequence alignment

Global alignment scores every way of lining up a against b with gaps inserted. With a match worth +2, a mismatch -1 and each gap -2, the recurrence over the table S is S[i][j] = max(S[i-1][j-1] + sub(a[i], b[j]), S[i-1][j] + gap, S[i][j-1] + gap), with the first row and column holding multiples of the gap cost. The score in the bottom-right corner is optimal, and the alignment is recovered by walking back from that corner to the origin, choosing at each cell a predecessor that produced its value.

The score alone is cheap in memory: row i reads only row i-1, so two rows of length m + 1 suffice. The traceback is the problem. It needs to know, for every cell on the optimal path, which of the three moves produced it, and that information lives in the rows you threw away. Storing a direction byte per cell cuts the table to a quarter of the integer version but leaves it quadratic. Hirschberg's insight is that you do not need the whole path at once. You only need one point on it, and then two smaller problems.

The midpoint observation

One Hirschberg step: two score passes meet at the middle rowmiddle row i = n/2forward passtop half of a vs breverse passbottom half, both reversedsplit column k = argmax F[j] + R[m - j]recurse: a[:mid] vs b[:k]recurse: a[mid:] vs b[k:]0nj = 0j = mWhite regions are never revisited: total work is mn + mn/2 + mn/4 + ... which is under 2mn cells.
Forward scores for the top half meet reverse scores for the bottom half on the middle row; the best column splits the problem into two shaded subproblems.

Fix the middle row of a, mid = n / 2. Every alignment path from the top-left corner to the bottom-right corner crosses that row at some column k. Splitting the path there splits the alignment into a[:mid] aligned with b[:k] and a[mid:] aligned with b[k:], and because scores add along the path, the best alignment through column k scores F[k] + R[m - k], where F is the last row of the score table for the top half against b, and R is the last row for the bottom half against b with both sequences reversed. Reversal turns "best way to finish from (mid, k)" into an ordinary forward problem whose answer for every k falls out of a single row.

So one forward pass over the top half and one reverse pass over the bottom half, each in linear space, find the best crossing column. Pick k maximising F[k] + R[m - k], then solve the two subproblems recursively. The maximum of that sum is exactly the optimal score of the whole alignment, which gives you a free consistency check at every level of the recursion.

A tested implementation

The implementation below is the whole algorithm. nw_full is the ordinary quadratic Needleman-Wunsch with traceback; inside Hirschberg it is only called when one side has at most one symbol, where its table is a single row or column and therefore linear. Getting that base case right is what most broken implementations miss: a 1 by m subproblem must still choose between matching the single symbol somewhere and gapping it out entirely, and the full DP makes that choice correctly for any scoring scheme.

MATCH, MISMATCH, GAP = 2, -1, -2

def sub(x, y):
    return MATCH if x == y else MISMATCH

def nw_score_row(a, b):
    """Last row of the Needleman-Wunsch table for a vs every prefix of b."""
    prev = [j * GAP for j in range(len(b) + 1)]
    for x in a:
        cur = [prev[0] + GAP] + [0] * len(b)
        for j, y in enumerate(b, 1):
            cur[j] = max(prev[j - 1] + sub(x, y), prev[j] + GAP, cur[j - 1] + GAP)
        prev = cur
    return prev

def nw_full(a, b):
    """Quadratic Needleman-Wunsch with traceback: the base case and the test oracle."""
    n, m = len(a), len(b)
    S = [[(i + j) * GAP if i == 0 or j == 0 else 0 for j in range(m + 1)] for i in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            S[i][j] = max(S[i-1][j-1] + sub(a[i-1], b[j-1]), S[i-1][j] + GAP, S[i][j-1] + GAP)
    top, bot, i, j = [], [], n, m
    while i or j:
        if i and j and S[i][j] == S[i-1][j-1] + sub(a[i-1], b[j-1]):
            top.append(a[i-1]); bot.append(b[j-1]); i -= 1; j -= 1
        elif i and S[i][j] == S[i-1][j] + GAP:
            top.append(a[i-1]); bot.append("-"); i -= 1
        else:
            top.append("-"); bot.append(b[j-1]); j -= 1
    return "".join(reversed(top)), "".join(reversed(bot)), S[n][m]

def hirschberg(a, b):
    """Optimal global alignment in O(len(a) * len(b)) time and linear space."""
    if len(a) <= 1 or len(b) <= 1:
        top, bot, _ = nw_full(a, b)      # tiny: the quadratic method is linear here
        return top, bot
    mid = len(a) // 2
    left = nw_score_row(a[:mid], b)
    right = nw_score_row(a[mid:][::-1], b[::-1])
    m = len(b)
    k = max(range(m + 1), key=lambda j: left[j] + right[m - j])
    t1, b1 = hirschberg(a[:mid], b[:k])
    t2, b2 = hirschberg(a[mid:], b[k:])
    return t1 + t2, b1 + b2

It was fuzzed against nw_full on 3,000 random DNA pairs of length 0 to 14. For each pair the test checks that removing gaps gives back both inputs, that no column pairs two gaps, and that the re-scored alignment equals the full-table optimum and the two-row score. Comparing alignments character by character would be wrong, because ties mean several optimal alignments exist; compare scores, and re-score what you return rather than trusting a value the function reports about itself.

Worked example

Align a = AGTACGCA (n = 8) with b = TATGC (m = 5) under +2 / -1 / -2. Split a at mid = 4 into AGTA and CGCA. The forward pass of AGTA against every prefix of TATGC gives F = [-8, -4, 0, -2, -1, -3]. The reverse pass of ACGC (CGCA reversed) against CGTAT (TATGC reversed) gives R = [-8, -4, 0, 1, -1, -3].

Now combine F[k] + R[5 - k] for k = 0..5: -8 + -3 = -11, -4 + -1 = -5, 0 + 1 = 1, -2 + 0 = -2, -1 + -4 = -5, -3 + -8 = -11. The maximum is 1 at k = 2, so the optimal path crosses the middle row after consuming TA from b. The total optimal score is therefore 1, before any traceback has happened.

The recursion then aligns AGTA with TA and CGCA with TGC. The first gives AGTA over --TA (two gaps at -4, then T-T, A-A at +4, score 0). The second gives CGCA over TGC- (C-T mismatch -1, G-G +2, C-C +2, A against a gap -2, score 1). Concatenated, the answer is AGTACGCA over --TATGC-, which re-scores to 1 and matches the full table.

Why time stays quadratic and memory linear

Memory is easy: each level holds two score rows of length at most m + 1, and the recursion is about log2(n) deep, so the working set is O(m) for rows plus O(log n) for the stack, plus the O(n + m) output. Slicing in Python copies, which still keeps memory linear; in C, pass index ranges instead.

Time is the classic geometric argument. The top call computes about n/2 times m cells in each of its two passes, so mn in total. The two children together cover the shaded rectangles in the diagram, whose areas are mid times k and (n - mid) times (m - k). Whatever k is, their sum is at most mn / 2, because each child has half the rows and the columns are split between them. The next level is at most mn / 4, and so on, so the whole computation is under 2mn cells, roughly double the full table's mn.

The constant shows up clearly in practice. On CPython 3.13, aligning two random 2,000-symbol DNA strings took 1.45 s with the full table and 4.57 s with Hirschberg. That 3x gap is larger than the 2x cell count because the interpreter pays for list slicing, reversal and the per-level argmax. In compiled code the ratio usually sits nearer 2. The full table for that pair already held four million Python integers; at 100,000 symbols it would not fit in memory at all, while Hirschberg needs two rows.

Extensions: affine gaps, bands and local alignment

  • Affine gaps. Real aligners charge an opening penalty plus a smaller extension penalty, so one long gap beats many short ones. That needs three score matrices (Gotoh), and the split becomes subtle because the optimal path can cross the middle row inside a gap. Myers and Miller (1988) handle this by also tracking whether the crossing is in a vertical gap, so the opening penalty is not charged twice. Do not bolt affine costs onto the code above; implement their version.
  • Banded alignment. If the sequences are known to be similar, restrict every pass to a diagonal band of width w. Time and memory drop to O(w max(n, m)), and the Hirschberg split still works inside the band.
  • Local alignment. For Smith-Waterman, first run linear-space passes to find where the best local alignment starts and ends, then run global Hirschberg on that window.
  • Edit distance and diffs. Unit costs with minimisation instead of maximisation give a linear-space edit script; the edit distance article covers the recurrence. Myers' O(ND) diff uses the same middle-snake idea for line diffs.

Operational guidance

Use Hirschberg when you need the alignment itself, not just the score, and the table would not fit in memory or would evict everything else from cache. If you only need a score for ranking or filtering, the two-row pass is half the work. If the sequences are short, say a few thousand symbols, the full table is faster and simpler; a common hybrid recurses with Hirschberg until a subproblem's area drops below a threshold, then switches to the full table with traceback. A threshold of a few hundred thousand cells keeps the leaf tables in cache.

The two passes at each level are independent, so they run in parallel on two cores, and the recursive subproblems are independent too, which makes the algorithm a natural fit for task-parallel runtimes. Inside each pass, cells on the same anti-diagonal are independent, which is how GPU aligners vectorise the fill. The general pattern, split, solve and combine, is the subject of the divide and conquer article.

Failure modes

  • Wrong base case. Returning "match if the symbol occurs in b" is correct for LCS but wrong for scored alignment, where gapping the symbol out and choosing which copy to match both depend on the costs. Use the full DP on the base case.
  • Off-by-one in the reverse row. F[k] pairs with R[m - k], not R[k]. Symmetric test inputs can hide it; the random fuzz test catches it quickly.
  • Tie-breaking drift. Different k at a tie gives a different but equally optimal alignment. Tests that compare strings rather than scores then fail spuriously, and downstream code that assumes a canonical alignment breaks.
  • Score overflow. For megabase sequences with 16-bit SIMD lanes, scores overflow; widen the type or use difference encoding between adjacent cells.
  • Quadratic memory by accident. Keeping every level's F and R rows for debugging, or memoising the score pass, quietly brings the full table back.

Trade-offs

MethodTimeMemoryUse when
Full table with tracebackmnO(mn)Short to medium sequences, simplest code
Two-row score onlymnO(m)You need the score, not the alignment
Hirschbergunder 2mnO(n + m)Long sequences and you need the alignment
Myers-Millerunder 2mnO(n + m)Affine gap costs in linear space
Banded plus Hirschbergabout 2w max(n, m)O(w)Similar sequences with known divergence

What to do next

  1. Implement nw_score_row and nw_full first, and keep the full version as your test oracle.
  2. Add Hirschberg with the full-DP base case, then fuzz it against the oracle on thousands of random pairs, comparing re-scored alignments rather than strings.
  3. Hand-trace AGTACGCA against TATGC and check that you get split k = 2 and score 1.
  4. Add a leaf threshold that switches to the full table, and time both on your real sequence lengths.
  5. If you need affine gaps, read Myers and Miller's paper and port their split; do not improvise one.
  6. Revisit the dynamic programming overview to spot other tables where only the last row is needed for the score.
Key takeaway: Hirschberg's algorithm recovers an optimal alignment in linear space by finding one point on the optimal path, the column where it crosses the middle row, from a forward and a reverse score pass, then recursing on the two halves. The total work stays under twice the full table, the memory drops from quadratic to linear, and the base case must be a real alignment, not a shortcut. Use it when you need the alignment and the table would not fit, and use Myers-Miller when gaps have affine costs.