Cairn
algorithms · approximate match · O(m·n)

back to Approximate Match

Smith–Waterman Algorithm

The site's eleventh approximate match entry, and the first to ask a genuinely different question from every DP-table entry before it. Edit Distance, Damerau–Levenshtein, Banded Edit Distance, and Myers Diff all compare two whole strings — every character on both sides has to be accounted for, even the parts that don't resemble each other at all. Smith–Waterman asks instead: is there a region inside sequence A that closely resembles a region inside sequence B, no matter what the rest of either sequence looks like? Published by Temple Smith and Michael Waterman in 1981, it's the algorithm behind BLAST and every DNA/protein "local alignment" search in bioinformatics — finding a shared gene fragment between two reads that otherwise share nothing, or a conserved motif buried in unrelated flanking sequence.

Try it

Fixed pair A = "CCATCTAGA", B = "TTCATAGGT" — two short DNA-style reads that share almost nothing at their edges (A starts CCA…, B starts TT…; A ends …A, B ends …GT) but share a real conserved region in the middle, with one small insertion in B. Scoring: match +2, mismatch −1, gap −1. Press Step or Run to fill the table in Local (Smith–Waterman) mode — watch the best score so far update as filling proceeds, then watch the dotted-border cell (the table's true maximum, wherever it ends up) get picked out once the fill finishes, and the alignment trace back from there. Switch to Global (no floor) mode and rerun on the identical pair to see Pitfall two below happen live: the same recurrence, minus one clamp, finds a lower score using the exact same cell.

step 0
Press Step or Run.

Why it works

The table fills with almost the same recurrence as Edit Distance's, but scoring similarity instead of counting edits, and with one extra option at every cell — bail out to zero:

dp[i][j] = max(
  0,                                                    // give up and restart here
  dp[i-1][j-1] + (A[i-1] === B[j-1] ? match : mismatch), // align A[i-1] with B[j-1]
  dp[i-1][j] + gap,                                      // A[i-1] aligns to a gap
  dp[i][j-1] + gap                                       // B[j-1] aligns to a gap
)

That leading 0 is the entire mechanism that turns a global comparison into a local one. Whenever continuing an alignment would cost more than it's worth — the running score would go negative — the table simply resets to 0 instead, exactly the same "give up and start fresh" move Kadane's Algorithm makes for maximum subarray sum, one dimension down. A run of mismatches or gaps can never drag a later, genuinely strong local match down with it, because the table never carries a negative balance forward — it forgets and restarts instead.

The second consequence is where the answer lives. Every other DP table on this site reports its final answer in the bottom-right corner, dp[m][n], because every other entry demands an accounting of all of both strings. Smith–Waterman's answer is the largest value anywhere in the table — the corner is just one cell among many, and on this page's own example it isn't even close (see Pitfalls). Once that maximum cell is found, tracing back follows whichever neighbor produced it, exactly like Edit Distance's traceback, with one difference: it stops the moment it hits a 0, not when it reaches row/column zero. A local alignment can start and end anywhere inside either string.

Reference implementation

function smithWaterman(a, b, match = 2, mismatch = -1, gap = -1) {
  const m = a.length, n = b.length;
  const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
  let best = 0, bestI = 0, bestJ = 0;

  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      const diag = dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? match : mismatch);
      const up = dp[i - 1][j] + gap;
      const left = dp[i][j - 1] + gap;
      dp[i][j] = Math.max(0, diag, up, left);          // the floor — see Pitfalls
      if (dp[i][j] > best) { best = dp[i][j]; bestI = i; bestJ = j; }  // scan, don't assume dp[m][n]
    }
  }

  let i = bestI, j = bestJ;
  let alignedA = '', alignedB = '';
  while (i > 0 && j > 0 && dp[i][j] > 0) {              // stop at 0, not at row/col 0
    const score = dp[i][j];
    if (score === dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? match : mismatch)) {
      alignedA = a[i - 1] + alignedA; alignedB = b[j - 1] + alignedB; i--; j--;
    } else if (score === dp[i - 1][j] + gap) {
      alignedA = a[i - 1] + alignedA; alignedB = '-' + alignedB; i--;
    } else {
      alignedA = '-' + alignedA; alignedB = b[j - 1] + alignedB; j--;
    }
  }
  return { score: best, alignedA, alignedB };
}

On this page's A/B pair, that function returns 9, aligning A's TCTAG (positions 4–8) against B's TCATAG (positions 2–7) — five matched characters plus one gap where B has an extra A that A doesn't. Five matches at +2 each, minus one gap at −1, is 10 − 1 = 9. Every character before and after that window — three in A, one in B on that side, plus one more in A, two more in B on the other — is never touched by the alignment at all, exactly the point.

Pitfalls

Reading the answer out of the bottom-right corner, the way every other DP table on this site works, gives a real wrong number here — not a hypothetical one. On this page's own A/B pair, dp[9][9] (the corner) is 7. The table's true maximum, at dp[8][7], is 9. Both numbers are sitting in the same table at the same time; only one of them is the actual answer, and the wrong one is the one every visitor's eye lands on first out of habit from Edit Distance and its relatives. Verified directly against the reference implementation above: it scans every cell and keeps the running maximum rather than reading dp[m][n], specifically because of this.

Dropping the max(0, …) floor — filling the table the way every other DP table here does, with no reset — doesn't just remove the "local" behavior, it silently changes the score even at the cell that would otherwise be correct. Rerun this page's own scoring without the floor (select Global (no floor) above) and the table's maximum anywhere drops to 6, at the identical cell (8, 7) — a real, checked 33% drop, not a rounding difference. The reason: without the reset, every cell's score still carries forward the accumulated penalty from A and B's completely unrelated opening characters, even deep inside a region that has nothing to do with that opening. A bad flank can drag down a good match arbitrarily far away; the floor is what makes "ignore the flanks" actually true rather than aspirational. (The corner cell moves too, from 7 down to 4 — worth toggling the mode and comparing both numbers side by side.)

The scoring scheme is a free parameter, and it changes which alignment is optimal — not just by how much. This page uses match +2, mismatch −1, gap −1, a simple, commonly taught scheme. Real bioinformatics tools almost never use it: BLAST and similar tools score amino-acid or nucleotide substitutions with a matrix (BLOSUM, PAM) that reflects how biologically likely each specific substitution is, not a flat −1 for every mismatch, and often charge a steep penalty to open a gap plus a cheaper one to extend it (an "affine" gap penalty), rather than this page's flat per-position −1. Neither is built here — the recurrence above is the real algorithm, but production alignment tools spend most of their engineering on exactly this scoring-scheme question, which this page's fixed numbers sidestep entirely.

Complexity

Time: O(m·n) — the same single pass over every cell as Edit Distance's table; the floor and the max-tracking each add only constant work per cell. Space: O(m·n) as shown (needed for traceback); the score alone could be found in O(min(m, n)) with a rolling two-row window, the same trade Edit Distance's own space-optimized mode makes, but then no alignment could be recovered afterward.

Smith–Waterman is the right tool specifically when the two sequences are expected to share only a region, not their entirety — DNA/protein local alignment (BLAST), finding a quoted excerpt inside a much longer unrelated document, or matching a short recognizable fragment against a longer noisy recording. When the two inputs are meant to correspond end-to-end instead, reach for this site's global entries instead: Damerau–Levenshtein Distance for whole-string typo tolerance, or Myers Diff for a literal edit script between two full sequences. For a side-by-side comparison across all eleven approximate-match entries, see Choosing an Approximate String Matcher.