Three DPs on sequences with a lot of structure: LIS has an greedy-DP, LCS reduces to LIS for permutations and runs in bit-parallel, and edit distance has banded and variants. All three can produce the actual answer in linear memory with Hirschberg’s divide and conquer. For LIS of every subarray see Range LIS.
LIS in
tail[k] = smallest possible last element of an increasing subsequence of length . It is strictly increasing, so each new element replaces the first tail (strict LIS) or (non-decreasing). A mental model: this is literally the card game patience — deal each card onto the leftmost pile whose top is it; the number of piles is the LIS length, which is why the greedy replacement never loses an achievable length.
| Want | Search |
|---|---|
| strictly increasing | lower_bound |
| non-decreasing | upper_bound |
| strictly decreasing | reverse the array (or negate values), then strictly increasing |
| non-increasing | reverse, then non-decreasing |
tailis not an LIS
tailholds the best last-value per length, not an actual achievable subsequence — the values at different indices were never simultaneously the tail at the same time. Reconstructing a real LIS needs the separate predecessor/length bookkeeping the code above already does (par, replayed fromtailIdx.back()).
vector<int> lis(const vector<int>& a) { // returns indices of one strictly increasing LIS
vector<int> tailVal, tailIdx, par(sz(a), -1);
for (int i = 0; i < sz(a); i++) {
int k = lower_bound(all(tailVal), a[i]) - tailVal.begin(); // upper_bound: non-decreasing
if (k) par[i] = tailIdx[k - 1];
if (k == sz(tailVal)) tailVal.push_back(a[i]), tailIdx.push_back(i);
else tailVal[k] = a[i], tailIdx[k] = i;
}
vector<int> res;
for (int i = sz(tailIdx) ? tailIdx.back() : -1; i != -1; i = par[i]) res.push_back(i);
reverse(all(res)); return res;
}Proof:
tailstays sorted and its length is the LISInvariant after processing a prefix: is the minimum last element over all increasing subsequences of length in the prefix. Monotonicity: from an increasing subsequence of length ending in , dropping its last element gives one of length ending in something , so . When arrives, an increasing subsequence ending at has length iff the best one of length ends below , i.e. ; the largest such is the lower-bound position. Extending makes a candidate for and it is smaller than the old value (which was ), while no other can improve, because subsequences ending at of length have last element for . The number of tails is therefore the length of the longest one.
| Variant | How |
|---|---|
| number of LIS | Fenwick over values storing (best length, count), merged by max then sum |
| weighted LIS (max sum increasing) | Fenwick over values storing max |
| LIS with | segment tree max over a value window |
| longest bitonic | LIS ending at from the left plus from the right |
| 2D chains (envelopes) | sort by ascending, ties by descending, then strict LIS on |
| 3D chains | CDQ (CDQ), |
| LCS of two permutations | map to positions in , then LIS |
| min deletions to sort | |
| min number of increasing sequences covering | longest non-increasing subsequence |
| LIS of all cyclic shifts / all subarrays | seaweed / unit-Monge (Range LIS) |
// number of strictly increasing subsequences of maximum length (values compressed to [0, V))
struct Info { int len = 0; mint cnt = 0; };
Info comb(Info a, Info b) { if (a.len != b.len) return a.len > b.len ? a : b; return {a.len, a.cnt + b.cnt}; }
// for each i in order: Info best = prefixQuery(a[i]) (values < a[i]); if (!best.len) best.cnt = 1;
// update(a[i], {best.len + 1, best.cnt}); the answer is the query over all valuesProof: covering by monotone sequences (Dilworth/Mirsky for sequences) and Erdős–Szekeres
Cover: the minimum number of non-increasing subsequences covering the sequence equals the LIS length . At least are needed because two elements of an increasing subsequence can’t share a non-increasing one. Conversely, label each element by the length of the longest increasing subsequence ending at it; elements with equal labels form a non-increasing sequence (if had equal labels and , the label of would be larger). That’s classes.
Erdős–Szekeres: any sequence of distinct numbers has an increasing subsequence of length or a decreasing one of length : if every increasing subsequence has length , the labels take values, so some label class has elements, and it is decreasing.
LCS
if , else . ; reconstruct by walking back.
Counting the number of maximum-length LCSs (not distinct LCS strings, the simpler “how many ways” question): carry a count alongside the length, but guard against double-counting when and , tie — both branches may share alignments already counted in , so subtract cnt[i-1][j-1] by inclusion–exclusion in that case.
Bit-parallel LCS (, about 0.2 s for ): the row differences are 0/1, so the row is a bit vector, and one row update is a few word operations with a carry (Hyyrö / Crochemore et al.).
struct BitLCS { // b fixed; stream a; LCS(a, b[0, j)) queries
int m, W; map<int, vector<ull>> mask; vector<ull> v; // v bit j = 0 iff the row increases at j
BitLCS(const vector<int>& b) : m(sz(b)), W((sz(b) + 63) / 64), v(W, ~0ULL) {
for (int j = 0; j < m; j++) { auto& mk = mask[b[j]]; if (mk.empty()) mk.assign(W, 0); mk[j >> 6] |= 1ULL << (j & 63); }
}
void push(int c) {
auto it = mask.find(c); if (it == mask.end()) return;
ull carry = 0;
for (int w = 0; w < W; w++) { // v = (v + (v & M)) | (v & ~M)
ull x = v[w], y = x & it->second[w], s = x + y, over = s < x;
s += carry; carry = over | (s < carry);
v[w] = s | (x & ~it->second[w]);
}
}
int prefix(int j) const {
int r = 0;
for (int w = 0; w < j >> 6; w++) r += 64 - __builtin_popcountll(v[w]);
if (j & 63) r += (j & 63) - __builtin_popcountll(v[j >> 6] & ((1ULL << (j & 63)) - 1));
return r;
}
};Proof sketch: the bit-parallel row update
Let and store . Processing a new character of , a match at column () can raise the row from column on, but only up to the next position where the old row already increased. In bit terms, each run of 1s in (columns without an increase) that contains a match has its lowest matching 1 turned into a 0 (new increase), and the old increase at the run’s end is “absorbed” (turned into a 1). Adding to does exactly this: the carry starting at the lowest matched 1 of a run ripples through the run and stops at the 0 that ends it, setting it to 1. Or-ing restores the unmatched 1s cleared by the carry. The number of increases (zeros) up to is the LCS with .
Other LCS facts:
- Hunt–Szymanski: where = number of matching pairs, via LIS over match lists processed right-to-left within a row.
- LCS of strings is (NP-hard for unbounded ); no for two strings under SETH.
- Longest common substring is a different problem: suffix automaton or suffix array (Suffix Array).
- Longest palindromic subsequence = LCS, or interval DP.
- Shortest common supersequence = .
- Longest common increasing subsequence: for each , sweep keeping the best with : .
- Count distinct LCS strings: next-occurrence DP from each over the alphabet, .
Edit distance
— this is the Levenshtein distance. Applications: spell checking, DNA alignment, diff/version control, OCR post-correction, fuzzy search, plagiarism detection, speech-recognition scoring.
| Variant | Change |
|---|---|
| insert/delete only (LCS distance) | |
| Hamming (replace only) | requires , no insert/delete branches |
| weighted operations | replace the 1s by costs |
| alphabet-dependent costs | cost[a[i]][b[j]] instead of a flat |
| Damerau (adjacent transpositions) | extra branch if |
| affine gaps (Gotoh) | three layers: match, gap in , gap in |
| local alignment (Smith–Waterman) | clamp at 0, take the global max |
| pattern occurrences with errors | free start in the text: |
| answer known | band : |
| distance small, long strings | diagonal furthest-reaching (Ukkonen/Myers ; with LCP queries, Landau–Vishkin ) |
| need the alignment in linear memory | Hirschberg |
Proof: banding and furthest-reaching diagonals
Every step that changes is an insertion or deletion and costs 1, so an alignment of cost never leaves the band ; cells outside can be treated as . For the diagonal method: on diagonal , the cells with value form a prefix (values are nondecreasing along a diagonal: , which follows by induction from the recurrence). So it suffices to store, for each and , the furthest row reached with cost , extend it greedily by matching characters (a slide, free), and derive cost from the three neighbouring diagonals. Each touches diagonals, and the slides cost per diagonal naively () or with an LCP structure ().
Hirschberg: the alignment in memory
The same divide-at-the-midpoint skeleton, reused for speed rather than memory, is exactly Knuth’s optimization and D&C DP optimization — worth noticing once you’ve internalised this version.
Split at , compute the last row of the forward DP for against all prefixes of and of the backward DP for against all suffixes, pick the column maximising the sum, and recurse on both halves.
vector<int> lcsRow(const string& a, const string& b) {
vector<int> p(sz(b) + 1), c(sz(b) + 1);
for (char x : a) { for (int j = 1; j <= sz(b); j++) c[j] = x == b[j - 1] ? p[j - 1] + 1 : max(p[j], c[j - 1]); swap(p, c); }
return p;
}
string hirschberg(const string& a, const string& b) {
if (a.empty() || b.empty()) return "";
if (sz(a) == 1) return b.find(a[0]) != string::npos ? a : "";
int i = sz(a) / 2;
string ra(a.rbegin(), a.rend() - i), rb(b.rbegin(), b.rend());
auto F = lcsRow(a.substr(0, i), b), G = lcsRow(ra, rb);
int k = 0;
for (int j = 0; j <= sz(b); j++) if (F[j] + G[sz(b) - j] > F[k] + G[sz(b) - k]) k = j;
return hirschberg(a.substr(0, i), b.substr(0, k)) + hirschberg(a.substr(i), b.substr(k));
}Proof: correctness and time
Any alignment path through the DP grid crosses row at some column , and splits into an alignment of with and one of with ; conversely any such pair concatenates. So the optimum is and any maximiser is a valid split. Time: the top level costs ; the two subproblems have sizes and , totalling ; each level halves the total, so the overall time is . Memory is for the rows plus the recursion.