| Problem Signal | Technique |
|---|---|
| Longest increasing/non-decreasing subsequence | LIS O(n²) DP or O(n log n) binary search |
| Longest arithmetic subsequence (any diff) | O(n²) DP with dict per position |
| Longest arithmetic subsequence (fixed diff) | O(n) hash table sweep |
| Longest subsequence with custom constraint | LIS variant with modified transition |
| Longest common subsequence (LCS) | O(mn) 2D DP |
| Edit distance / Levenshtein distance | O(mn) 2D DP (LCS variant) |
| Shortest common supersequence | Build LCS, then merge remaining chars |
| Russian doll envelopes / 2D sorting | Sort + LIS on second dimension |
| Return actual subsequence (not just length) | Store actual arrays or backtrack |
Use when n <= 1000 or when you need to easily extend the transition logic.
dp = [1] * len(A)
for i in range(1, len(A)):
for j in range(i):
if A[j] < A[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)@cache
def dp(i):
ans = 1
for j in range(i):
if A[j] < A[i]:
ans = max(ans, 1 + dp(j))
return ans
return max(dp(i) for i in range(len(A)))Use when n <= 10^5. Maintains an array vals where vals[i] is the smallest tail element of all increasing subsequences of length i+1.
LC 300, 354, 1964
Strictly increasing (bisect_left):
vals = []
for x in A:
k = bisect_left(vals, x)
if k == len(vals):
vals.append(x)
else:
vals[k] = x
return len(vals)Non-decreasing (bisect_right):
vals = []
ans = []
for x in A:
k = bisect_right(vals, x)
ans.append(k + 1) # length of LIS ending at x
if k == len(vals):
vals.append(x)
else:
vals[k] = x
return ans # or len(vals)Replace the comparison A[j] < A[i] with your custom condition.
Divisible subset (LC 368): sort first, then A[i] % A[j] == 0
Ideal subsequence (LC 2370): abs(A[i] - prev) <= k, track dp per character
dp = [0] * 26
for x in A:
char_idx = ord(x) - ord('a')
mx = max(dp[y] for y in range(max(char_idx - k, 0), min(char_idx + k + 1, 26)))
dp[char_idx] = mx + 1
return max(dp)Store arrays instead of lengths:
dp = [[A[i]] for i in range(len(A))]
for i in range(len(A)):
for j in range(i):
if A[i] % A[j] == 0:
dp[i] = max(dp[i], dp[j] + [A[i]], key=len)
return max(dp, key=len)LC 1027
For each position, track the longest arithmetic subsequence ending at that position for each possible difference.
dp = [defaultdict(lambda: 1) for _ in range(len(A))]
ans = 0
for i in range(1, len(A)):
for j in range(i):
diff = A[i] - A[j]
dp[i][diff] = dp[j][diff] + 1
ans = max(ans, max(dp[i].values()))
return ansLC 1218
dp = Counter()
for x in A:
dp[x] = dp[x - diff] + 1
return max(dp.values())LC 1143, 583, 712
@cache
def dp(i, j):
if i == len(A) or j == len(B):
return 0
if A[i] == B[j]:
return 1 + dp(i + 1, j + 1)
return max(dp(i + 1, j), dp(i, j + 1))
return dp(0, 0)Bottom-up 2D DP:
m, n = len(A), len(B)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m):
for j in range(n):
if A[i] == B[j]:
dp[i + 1][j + 1] = dp[i][j] + 1
else:
dp[i + 1][j + 1] = max(dp[i][j + 1], dp[i + 1][j])
return dp[m][n]LC 1092
Build LCS, then merge both strings by including all characters. When characters match (part of LCS), include once. Otherwise, include from both.
LC 72, 583, 712, 1143
Transform string A into string B using insertions, deletions, and substitutions.
@cache
def dp(i, j):
if i == len(A): return len(B) - j # insert remaining chars of B
if j == len(B): return len(A) - i # delete remaining chars of A
if A[i] == B[j]:
return dp(i + 1, j + 1)
return 1 + min(
dp(i + 1, j), # delete A[i]
dp(i, j + 1), # insert B[j]
dp(i + 1, j + 1) # replace A[i] with B[j]
)
return dp(0, 0)Bottom-up 2D DP:
m, n = len(A), len(B)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if A[i - 1] == B[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
return dp[m][n]Variants:
- Delete only (LC 583):
min(dp(i+1, j), dp(i, j+1))instead of all three ops - Cost per operation differs: use weighted min
- Minimum ASCII delete sum (LC 712): track sum instead of count
LC 354
Sort by first dimension ascending, second dimension descending, then run LIS on second dimension. The descending sort on the second dimension ensures that envelopes with the same width don't incorrectly nest.
A.sort(key=lambda x: (x[0], -x[1]))
vals = []
for w, h in A:
k = bisect_left(vals, h)
if k == len(vals):
vals.append(h)
else:
vals[k] = h
return len(vals)-
LIS binary search intuition:
vals[i]stores the smallest tail of all subsequences of lengthi+1. Replacing larger tails with smaller ones keeps future extensions possible. -
Strictly vs non-decreasing: use
bisect_leftfor strictly increasing,bisect_rightfor non-decreasing. -
LCS to edit distance: LCS counts matches, edit distance counts mismatches. They're duals.
edit_distance = m + n - 2*LCSfor delete-only operations. -
2D sorting trick: when one dimension must be strictly increasing, sort that dimension ascending and the other descending to prevent ties from incorrectly extending the sequence.
-
Hash table optimization: when the transition only depends on a specific previous value (like
x - diff), replace O(n²) with O(n) using a hash table.
- LIS (binary search): 300, 354, 1964
- LIS (custom constraint): 368 (divisible), 2370 (ideal), 2713 (matrix)
- Arithmetic subsequence: 1027 (any diff), 1218 (fixed diff)
- LCS: 1143, 583, 712
- Edit distance: 72, 583, 712
- Shortest common supersequence: 1092
- Hash table optimization: 1218, 2370