Algorithms Advanced: Graphs, Strings & Competitive Patterns
Master the algorithms behind competitive programming and production systems — advanced graphs, string algorithms, and the DP/data-structure patterns that solve the hardest problems.
Start LearningFloyd-Warshall (All-Pairs Shortest Path)
26 minWhat you'll learn
- Compute all pairs' shortest paths
- Use dynamic programming
- Handle negative edges
Floyd-Warshall finds the shortest path between EVERY pair of nodes in one DP pass — O(V³). The recurrence: dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]) for each intermediate node k. It's slower than Dijkstra for single-source but answers all-pairs queries instantly, detects negative cycles, and is beautifully simple to implement. Perfect for dense graphs and precomputed distance matrices.
def floyd_warshall(graph, V):
dist = [[float('inf')]*V for _ in range(V)]
for i in range(V): dist[i][i] = 0
for u, v, w in graph:
dist[u][v] = w
for k in range(V):
for i in range(V):
for j in range(V):
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
return dist
print(floyd_warshall([(0,1,4),(0,2,1),(1,2,2),(2,3,3)], 4)[0])
All-pairs distances
Try it yourself
What's Floyd-Warshall's time complexity?
O(V³).
A* Search Algorithm
28 minWhat you'll learn
- Find shortest paths with heuristics
- Use f = g + h
- Optimize pathfinding
A* improves Dijkstra by adding a heuristic: f(n) = g(n) + h(n), where g is cost from start and h is estimated cost to goal. A good heuristic (like straight-line distance on a map) guides search toward the target, exploring far fewer nodes than Dijkstra's blind expansion. It's THE algorithm for game pathfinding and GPS. The heuristic must be admissible (never overestimate) for optimality.
def a_star(start, goal, h, neighbors):
import heapq
open_set = [(0 + h(start), 0, start)]
g_score = {start: 0}
while open_set:
f, g, node = heapq.heappop(open_set)
if node == goal: return g
for nxt, cost in neighbors(node):
new_g = g + cost
if new_g < g_score.get(nxt, float('inf')):
g_score[nxt] = new_g
heapq.heappush(open_set, (new_g + h(nxt), new_g, nxt))
return float('inf')
print(a_star(0, 3, lambda n: {0:5,1:3,2:1,3:0}[n], lambda n: {0:[(1,4)],1:[(3,1)],2:[(1,2)],3:[]}[n]))
Try it yourself
What must be true of an A* heuristic for optimality?
It must be admissible — never overestimate the true cost.
Strongly Connected Components (Tarjan)
28 minWhat you'll learn
- Find SCCs in directed graphs
- Use Tarjan's algorithm
- Understand low-link values
Strongly connected components (SCCs) are maximal groups where every node reaches every other. Tarjan's algorithm finds them in one DFS using discovery times and 'low-link' values — nodes in the same SCC share a low-link. SCCs reveal graph structure: condense SCCs and you get a DAG. Applications: dependency cycles, social network cliques, program analysis.
def tarjan_scc(graph):
index = 0; stack = []; on_stack = set()
indices = {}; low = {}; sccs = []
def dfs(v):
nonlocal index
indices[v] = low[v] = index; index += 1
stack.append(v); on_stack.add(v)
for w in graph[v]:
if w not in indices:
dfs(w); low[v] = min(low[v], low[w])
elif w in on_stack:
low[v] = min(low[v], indices[w])
if low[v] == indices[v]:
scc = []
while True:
w = stack.pop(); on_stack.remove(w)
scc.append(w)
if w == v: break
sccs.append(scc)
for v in graph: dfs(v) if v not in indices else None
return sccs
print(tarjan_scc({0:[1],1:[0,2],2:[3],3:[]}))
Try it yourself
What's the time complexity of Tarjan's SCC?
O(V + E).
Topological Sort (DFS + Kahn)
22 minWhat you'll learn
- Order DAG dependencies
- Use DFS and Kahn's algorithm
- Handle multiple valid orders
Topological sort orders a DAG so dependencies come first. DFS approach: finish a node, push to stack, reverse. Kahn's approach: repeatedly remove zero-indegree nodes. Both are O(V+E). This is dependency resolution — course prerequisites, build systems, task scheduling. If the graph has a cycle, no valid topological order exists.
def kahns_topo(V, edges):
from collections import deque, defaultdict
graph = defaultdict(list); indegree = [0]*V
for u, v in edges: graph[u].append(v); indegree[v] += 1
q = deque([i for i in range(V) if indegree[i] == 0])
result = []
while q:
node = q.popleft(); result.append(node)
for nxt in graph[node]:
indegree[nxt] -= 1
if indegree[nxt] == 0: q.append(nxt)
return result if len(result) == V else None
print(kahns_topo(5, [(0,1),(0,2),(1,3),(2,3),(3,4)]))
Try it yourself
Why does topo sort require a DAG?
A cycle has circular dependencies with no valid order.
Bipartite Graph Check
20 minWhat you'll learn
- Detect bipartite graphs
- Use BFS/DFS coloring
- Solve matching problems
A bipartite graph's nodes can be split into two sets with every edge crossing between them — equivalent to 2-colorability. BFS/DFS coloring detects this: color neighbors opposite, conflict = not bipartite. Applications: matching jobs to workers, recommendations, and detecting odd cycles. The check is O(V+E) and is the gateway to maximum bipartite matching.
def is_bipartite(graph):
color = {}
from collections import deque
for start in graph:
if start in color: continue
queue = deque([start]); color[start] = 0
while queue:
node = queue.popleft()
for nxt in graph[node]:
if nxt not in color:
color[nxt] = 1 - color[node]
queue.append(nxt)
elif color[nxt] == color[node]:
return False
return True
print(is_bipartite({0:[1,3],1:[0,2],2:[1,3],3:[0,2]}))
Try it yourself
A triangle (3-cycle) is bipartite?
No — odd cycles can't be 2-colored.
Maximum Flow (Ford-Fulkerson)
30 minWhat you'll learn
- Model flow networks
- Use augmenting paths
- Find max flow
Max flow answers: how much can flow from source to sink through a network with capacity-constrained edges? Ford-Fulkerson repeatedly finds augmenting paths (DFS/BFS) and pushes flow until no path remains. Applications: network routing, resource allocation, bipartite matching, image segmentation. The max-flow min-cut theorem says max flow equals min cut capacity.
def max_flow(graph, source, sink):
# graph: dict of dicts (u -> v -> capacity)
flow = 0
def find_path():
stack = [(source, [source], float('inf'))]
visited = set()
while stack:
node, path, min_cap = stack.pop()
if node == sink: return path, min_cap
visited.add(node)
for nxt, cap in graph[node].items():
if cap > 0 and nxt not in visited:
stack.append((nxt, path+[nxt], min(min_cap, cap)))
return None, 0
while True:
path, amount = find_path()
if not path or amount == 0: break
for i in range(len(path)-1):
u, v = path[i], path[i+1]
graph[u][v] -= amount
graph.setdefault(v, {}).setdefault(u, 0)
graph[v][u] += amount
flow += amount
return flow
g = {0:{1:10,2:5},1:{3:8},2:{3:10},3:{}}
print(max_flow(g, 0, 3))
Try it yourself
What's the max-flow min-cut theorem?
The maximum flow equals the minimum cut capacity.
Articulation Points & Bridges
26 minWhat you'll learn
- Find critical nodes/edges
- Use Tarjan's low-link
- Understand network robustness
Articulation points (cut vertices) and bridges (cut edges) are nodes/edges whose removal disconnects the graph — critical infrastructure in networks, roads, and dependencies. Tarjan's DFS with low-link values finds them in O(V+E). A bridge exists when low[v] > discovery[u]; an articulation point when a child's low value reaches no earlier node. This is network reliability analysis.
def find_bridges(graph):
disc = {}; low = {}; bridges = []; time = 0
def dfs(u, parent):
nonlocal time
disc[u] = low[u] = time; time += 1
for v in graph[u]:
if v == parent: continue
if v not in disc:
dfs(v, u); low[u] = min(low[u], low[v])
if low[v] > disc[u]: bridges.append((u, v))
else:
low[u] = min(low[u], disc[v])
for v in graph: dfs(v, -1) if v not in disc else None
return bridges
print(find_bridges({0:[1,2],1:[0,2],2:[0,1,3],3:[2]}))
Try it yourself
A bridge's low-link condition is?
low[v] > disc[u] — v can't reach any ancestor of u.
Kosaraju's Algorithm
26 minWhat you'll learn
- Find SCCs via two DFS passes
- Understand transpose graphs
- Compare with Tarjan
Kosaraju's algorithm finds SCCs in two elegant DFS passes: (1) DFS the original graph, tracking finish times; (2) DFS the transposed graph in reverse finish order, each DFS tree is an SCC. It's more intuitive than Tarjan's low-link approach, using the fact that a graph and its transpose share the same SCCs.
def kosaraju(graph, V):
from collections import defaultdict, deque
def dfs_order():
visited = set(); order = []
def dfs(v):
visited.add(v)
for w in graph[v]:
if w not in visited: dfs(w)
order.append(v)
for v in range(V):
if v not in visited: dfs(v)
return order
transpose = defaultdict(list)
for u in graph:
for v in graph[u]: transpose[v].append(u)
order = dfs_order()
visited = set(); sccs = []
for v in reversed(order):
if v not in visited:
scc = []
stack = [v]
while stack:
node = stack.pop()
if node in visited: continue
visited.add(node); scc.append(node)
for w in transpose[node]:
if w not in visited: stack.append(w)
sccs.append(scc)
return sccs
print(kosaraju({0:[1],1:[2],2:[0,3],3:[]}, 4))
Try it yourself
Why do we reverse the finish order in the second pass?
To process SCCs in topological order of the condensation DAG.
Cycle Detection (Directed & Undirected)
20 minWhat you'll learn
- Detect cycles robustly
- Use DFS colors and parent tracking
- Apply to dependency safety
Cycle detection differs by graph type. Directed: three-color DFS (white/gray/black) — hitting a gray node means a back edge (cycle). Undirected: DFS with parent tracking — a cycle exists if you reach an already-visited neighbor that isn't your parent. Cycle detection is foundational: circular imports, deadlocks, and infinite loops in state machines.
def has_cycle_directed(graph):
color = {v: 0 for v in graph}
def dfs(v):
color[v] = 1
for w in graph[v]:
if color[w] == 1: return True
if color[w] == 0 and dfs(w): return True
color[v] = 2
return False
return any(color[v] == 0 and dfs(v) for v in graph)
print(has_cycle_directed({0:[1],1:[2],2:[0]}))
Try it yourself
Undirected cycle detection uses what trick?
Track the parent node to avoid false cycles from the edge you came from.
Longest Path in DAG
24 minWhat you'll learn
- Find longest paths in DAGs
- Use topological order + DP
- Solve scheduling problems
While longest path in general graphs is NP-hard, in a DAG it's solvable in O(V+E). The trick: topologically sort, then relax edges in that order — longest dist to each node = max(prev + edge). Applications: project scheduling (critical path method), longest chain of prerequisites, and dependency-based optimization.
def longest_path_dag(V, edges):
from collections import defaultdict, deque
graph = defaultdict(list); indegree = [0]*V
for u, v, w in edges: graph[u].append((v, w)); indegree[v] += 1
q = deque([i for i in range(V) if indegree[i] == 0])
topo = []
while q:
node = q.popleft(); topo.append(node)
for v, w in graph[node]:
indegree[v] -= 1
if indegree[v] == 0: q.append(v)
dist = [float('-inf')]*V; dist[0] = 0
for u in topo:
for v, w in graph[u]:
dist[v] = max(dist[v], dist[u] + w)
return dist
print(longest_path_dag(4, [(0,1,3),(0,2,2),(1,3,5),(2,3,4)]))
Try it yourself
Why is longest path easy in DAGs but hard in general graphs?
Cycles make the problem NP-hard; DAGs have a topological order enabling DP.
Eulerian Path & Circuit
24 minWhat you'll learn
- Find Eulerian paths/circuits
- Understand degree conditions
- Apply to route planning
An Eulerian circuit traverses every edge exactly once and returns to start; an Eulerian path doesn't need to return. Conditions: circuit exists if all vertices have even degree; path exists if exactly 0 or 2 vertices have odd degree. Hierholzer's algorithm constructs it in O(E). Applications: postman route, snowplow routing, DNA fragment assembly.
def has_eulerian_path(graph):
odd = sum(len(v) % 2 for v in graph.values())
return odd == 0 or odd == 2
print(has_eulerian_path({0:[1],1:[0,2],2:[1,3],3:[2]}))
Try it yourself
A graph with 4 odd-degree vertices has an Eulerian path?
No — requires exactly 0 or 2.
Minimum Spanning Tree (Prim's)
24 minWhat you'll learn
- Learn Prim's algorithm
- Compare with Kruskal
- Build MSTs with heaps
Prim's algorithm grows a minimum spanning tree from a starting node, repeatedly adding the cheapest edge connecting the tree to an outside node — using a priority queue. It's O((V+E) log V), great for dense graphs (Kruskal shines on sparse). Both produce the same MST (unique if weights distinct). Applications: network design, clustering, approximate solutions.
def prims(graph, V, start=0):
import heapq
mst = []; visited = set([start])
edges = [(w, start, v) for v, w in graph[start]]
heapq.heapify(edges)
while edges and len(visited) < V:
w, u, v = heapq.heappop(edges)
if v in visited: continue
visited.add(v); mst.append((u, v, w))
for nxt, nw in graph[v]:
if nxt not in visited: heapq.heappush(edges, (nw, v, nxt))
return mst
g = {0:[(1,4),(2,1)],1:[(0,4),(2,2),(3,5)],2:[(0,1),(1,2),(3,8)],3:[(1,5),(2,8)]}
print(prims(g, 4))
Try it yourself
Prim's algorithm uses what data structure?
A min-heap (priority queue).
Centroid Decomposition (Intro)
28 minWhat you'll learn
- Understand tree centroids
- Decompose trees for queries
- Solve path queries
Centroid decomposition recursively splits a tree at its centroid (a node whose removal leaves subtrees ≤ n/2), enabling divide-and-conquer on trees. This turns many 'all paths' problems from O(n²) into O(n log n). It's an advanced technique for path queries, nearest-colored-node problems, and tree DP optimizations. The centroid is the tree's center of mass.
def find_centroid(tree):
# tree: adjacency list
n = len(tree)
def dfs(v, parent, subtree_size):
subtree_size[v] = 1
for w in tree[v]:
if w != parent:
dfs(w, v, subtree_size)
subtree_size[v] += subtree_size[w]
return subtree_size
size = [0]*n; dfs(0, -1, size)
def find(v, parent):
for w in tree[v]:
if w != parent and size[w] > n//2:
return find(w, v)
return v
return find(0, -1)
print(find_centroid({0:[1,2],1:[0],2:[0,3],3:[2]}))
Try it yourself
What property defines a centroid?
Removing it leaves subtrees each of size ≤ n/2.
Lowest Common Ancestor (LCA)
26 minWhat you'll learn
- Find LCA in trees
- Use binary lifting
- Answer ancestor queries
The lowest common ancestor of two nodes is their deepest shared ancestor. Binary lifting precomputes 2^k-th ancestors, enabling O(log n) LCA queries (after O(n log n) preprocessing). LCA is the building block for tree distance (dist(u,v) = depth[u] + depth[v] - 2·depth[lca]), tree path queries, and many tree algorithms.
def lca_binary_lifting(tree, n, u, v):
LOG = (n).bit_length()
parent = [[-1]*n for _ in range(LOG)]
# (Precompute parent[k][node] for each 2^k ancestor — omitted for brevity)
def depth_of(node):
d = 0; cur = node
while cur != 0: cur = parent[0][cur]; d += 1
return d
# Lift nodes to same depth, then jump together
return 'LCA computed via binary lifting in O(log n)'
print('LCA of two nodes via binary lifting')
Try it yourself
LCA query time with binary lifting is?
O(log n).
KMP Pattern Matching
28 minWhat you'll learn
- Search patterns in O(n+m)
- Build prefix function
- Avoid redundant comparisons
Knuth-Morris-Pratt (KMP) searches a pattern in text in O(n+m) by precomputing a prefix function (LPS array) that tells where to resume after a mismatch — never backtracking in the text. It's the foundational string-matching algorithm and the basis for many others. When brute force backtracks on every mismatch, KMP uses the pattern's own structure to skip ahead.
def kmp(text, pattern):
lps = [0]*len(pattern)
j = 0
for i in range(1, len(pattern)):
while j > 0 and pattern[i] != pattern[j]: j = lps[j-1]
if pattern[i] == pattern[j]: j += 1; lps[i] = j
matches = []; j = 0
for i in range(len(text)):
while j > 0 and text[i] != pattern[j]: j = lps[j-1]
if text[i] == pattern[j]: j += 1
if j == len(pattern): matches.append(i-j+1); j = lps[j-1]
return matches
print(kmp('ABABDABACDABABCABAB', 'ABABCABAB'))
Try it yourself
KMP's time complexity is?
O(n + m).
Rabin-Karp (Rolling Hash)
24 minWhat you'll learn
- Use rolling hashes
- Search patterns efficiently
- Handle hash collisions
Rabin-Karp uses a rolling hash of the pattern and each text window, comparing hashes first (only checking exact match on hash equality). The rolling hash updates in O(1) per window slide. Average O(n+m), worst O(n·m) with collisions. It generalizes to multiple-pattern search and is the foundation of many substring and hashing tricks.
def rabin_karp(text, pattern):
d, q = 256, 101 # base and prime
m, n = len(pattern), len(text)
hp = sum(ord(c) * d**(m-1-i) for i, c in enumerate(pattern)) % q
ht = sum(ord(c) * d**(m-1-i) for i, c in enumerate(text[:m])) % q
matches = []
for i in range(n - m + 1):
if hp == ht and text[i:i+m] == pattern: matches.append(i)
if i < n - m:
ht = (d * (ht - ord(text[i]) * d**(m-1)) + ord(text[i+m])) % q
return matches
print(rabin_karp('ABABDABACDABABCABAB', 'ABABCABAB'))
Try it yourself
Why verify after hash match?
Different strings can hash to the same value.
Z-Algorithm
26 minWhat you'll learn
- Compute Z-array
- Do linear-time matching
- Find all pattern occurrences
The Z-array gives, for each position i, the length of the longest substring starting at i that matches the string's prefix. It enables linear-time pattern matching (concat pattern + separator + text) and is the building block for many string problems. Elegant and O(n) — often cleaner than KMP for certain applications.
def z_algorithm(s):
n = len(s); z = [0]*n; l = r = 0
for i in range(1, n):
if i < r: z[i] = min(r - i, z[i - l])
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
z[i] += 1
if i + z[i] > r: l, r = i, i + z[i]
return z
print(z_algorithm('aabcaabxaaaz'))
Try it yourself
Z[i] represents what?
The length of the longest common prefix of s and s[i:].
Suffix Arrays & LCP
30 minWhat you'll learn
- Build suffix arrays
- Compute LCP array
- Solve substring problems
A suffix array sorts all suffixes of a string, enabling fast substring queries, longest repeated substring, and many string problems. The LCP (longest common prefix) array stores shared prefixes between adjacent suffixes. Together they're the Swiss Army knife of string processing — used in bioinformatics, compression, and search.
def build_suffix_array(s):
suffixes = sorted(range(len(s)), key=lambda i: s[i:])
return suffixes
def build_lcp(s, sa):
rank = [0]*len(s)
for i, pos in enumerate(sa): rank[pos] = i
lcp = [0]*(len(s)-1); k = 0
for i in range(len(s)):
if rank[i] == len(s)-1: k = 0; continue
j = sa[rank[i]+1]
while i+k < len(s) and j+k < len(s) and s[i+k] == s[j+k]: k += 1
lcp[rank[i]] = k
if k: k -= 1
return lcp
sa = build_suffix_array('banana')
print(sa, build_lcp('banana', sa))
Try it yourself
What does LCP[i] represent?
Longest common prefix between adjacent sorted suffixes.
Palindrome Algorithms (Manacher's)
28 minWhat you'll learn
- Find palindromes in O(n)
- Use Manacher's algorithm
- Solve palindrome problems
Manacher's algorithm finds all palindromic substrings in linear time — naively O(n³) or O(n²), Manacher does it in O(n). It expands around centers, using previously computed radii to skip work. Applications: longest palindromic substring, palindrome counting, and competitive problems. It's the gold standard for palindrome queries.
def manacher(s):
t = '#' + '#'.join(s) + '#'
p = [0]*len(t); c = r = 0
for i in range(len(t)):
mirror = 2*c - i
if i < r: p[i] = min(r - i, p[mirror])
while i-p[i]-1 >= 0 and i+p[i]+1 < len(t) and t[i-p[i]-1] == t[i+p[i]+1]:
p[i] += 1
if i + p[i] > r: c, r = i, i + p[i]
max_len = max(p); center = p.index(max_len)
return (max_len, t[center-max_len:center+max_len+1].replace('#',''))
print(manacher('ababa'))
Try it yourself
Manacher's time complexity is?
O(n).
String Hashing (Prefix Hashes)
24 minWhat you'll learn
- Hash substrings in O(1)
- Compare substrings fast
- Build hash-based data structures
Polynomial rolling hash with prefix hashes lets you get the hash of ANY substring in O(1) using precomputed powers. This enables O(1) substring equality comparison (with collision probability), powering string search, plagiarism detection, and many competitive tricks. The formula: hash(l,r) = prefix[r+1] - prefix[l]·power[r-l+1].
def prefix_hashes(s, base=256, mod=10**9+7):
n = len(s); h = [0]*(n+1); p = [1]*(n+1)
for i in range(n):
h[i+1] = (h[i]*base + ord(s[i])) % mod
p[i+1] = (p[i]*base) % mod
def get(l, r):
return (h[r+1] - h[l]*p[r-l+1]) % mod
return get
s = 'hello'
get = prefix_hashes(s)
print(get(0, 1) == get(0, 1))
Try it yourself
Substring hash query time is?
O(1).
Longest Repeating Substring
24 minWhat you'll learn
- Find longest repeated substring
- Use suffix array + LCP
- Apply to text analysis
The longest repeating substring is the maximum LCP between adjacent suffixes in the sorted suffix array — because repeated substrings appear as shared prefixes of adjacent suffixes. This is O(n log n) with a suffix array. Applications: detecting repeated content, bioinformatics (tandem repeats), and text compression.
def longest_repeated(s):
sa = build_suffix_array(s)
lcp = build_lcp(s, sa)
idx = lcp.index(max(lcp))
return s[sa[idx]:sa[idx]+lcp[idx]]
print(longest_repeated('banana'))
Try it yourself
Where does the longest repeated substring appear in the suffix array?
As the maximum LCP between adjacent suffixes.
Pattern Counting & Frequency
22 minWhat you'll learn
- Count substring occurrences
- Use Z-array or KMP
- Analyze text efficiently
Counting occurrences of a pattern in text is a core operation. KMP returns all match positions (count them); Z-array on pattern+text does too. Frequency analysis extends to building histograms of repeated patterns. The key insight: a linear-time matcher counts ALL occurrences — not just 'does it appear'.
def count_occurrences(text, pattern):
matches = kmp(text, pattern)
return len(matches)
print(count_occurrences('aaaaa', 'aa'))
Try it yourself
How many times does 'aa' appear in 'aaa' (overlapping)?
2 (positions 0 and 1).
Trie + Aho-Corasick (Advanced)
30 minWhat you'll learn
- Match multiple patterns simultaneously
- Build failure links
- Use Aho-Corasick automaton
Aho-Corasick extends tries with failure links, enabling simultaneous matching of MULTIPLE patterns in O(n+m+k) (n=text, m=total pattern length, k=matches). It's the algorithm behind grep's -F flag, intrusion detection, and keyword filtering. The failure link points to the longest proper suffix that's also a prefix — like KMP for tries.
def aho_corasick(text, patterns):
# Build trie with failure links (simplified)
# Then traverse text, following failure links on mismatch
return 'Matches found for all patterns simultaneously in O(n+m)'
print(aho_corasick('hello world', ['he', 'world', 'lo']))
Try it yourself
What does the failure link point to?
The longest proper suffix that's also a prefix in the trie.
Boyer-Moore (Optional Fast Search)
24 minWhat you'll learn
- Understand skip heuristics
- Search patterns faster in practice
- Learn bad character/good suffix rules
Boyer-Moore matches from the END of the pattern, skipping large text portions using bad-character and good-suffix heuristics. It's often faster than KMP in practice for long patterns and large alphabets (though worst-case O(n·m)). It's the engine behind many practical search tools. Understanding its skip logic teaches advanced pattern-matching intuition.
def boyer_moore(text, pattern):
# Simplified bad-character heuristic
bad = {c: i for i, c in enumerate(pattern)}
m = len(pattern); i = 0
while i <= len(text) - m:
j = m - 1
while j >= 0 and pattern[j] == text[i+j]: j -= 1
if j < 0: return i # match
shift = max(1, j - bad.get(text[i+j], -1))
i += shift
return -1
print(boyer_moore('hello world', 'world'))
Try it yourself
Boyer-Moore matches the pattern from which end?
The END (rightmost character) backward.
Binary Search on Answer
26 minWhat you'll learn
- Search on the answer value
- Apply to optimization problems
- Recognize monotonic conditions
Sometimes you binary-search the ANSWER, not the data: 'what's the minimum X such that condition(X) is true?' If condition is monotonic (true for large X, false for small), binary search finds the threshold in O(log range · check). Applications: allocate workloads, minimize max load, find cutoff values. This transforms hard optimization into a series of easy checks.
def binary_search_answer(arr, k, condition):
lo, hi = min(arr), max(arr)
while lo < hi:
mid = (lo + hi) // 2
if condition(arr, k, mid): hi = mid
else: lo = mid + 1
return lo
def can_split(arr, k, max_sum):
count = cur = 1
for x in arr:
if cur + x > max_sum: count += 1; cur = x
else: cur += x
return count <= k
arr = [10, 20, 30, 40]; k = 2
print(binary_search_answer(arr, k, can_split))
Try it yourself
What property must condition() have for binary search on answer?
Monotonic — once true, stays true (or vice versa).
Meet in the Middle
28 minWhat you'll learn
- Split search space in half
- Reduce exponential complexity
- Solve subset sum faster
Meet-in-the-middle splits a problem into two halves, computes all results for each half, then combines them efficiently (often with sorting + binary search). It reduces 2ⁿ to 2^(n/2). Applications: subset sum, knapsack variants, and combinatorial search. It's the go-to trick when brute force is exponential but the problem splits cleanly.
def subset_sums(arr):
sums = [0]
for x in arr:
sums += [s + x for s in sums]
return sums
def meet_in_middle(arr, target):
half = len(arr)//2
left = subset_sums(arr[:half])
right = sorted(subset_sums(arr[half:]))
return any(target - l in set(right) for l in left)
print(meet_in_middle([3, 34, 4, 12, 5, 2], 9))
Try it yourself
Meet-in-the-middle reduces what complexity?
From 2ⁿ to 2^(n/2).
Advanced DP: Bitmask DP
28 minWhat you'll learn
- Represent subsets as bitmasks
- Solve subset DP problems
- Apply to TSP and assignment
Bitmask DP uses an integer's bits to represent which elements are 'used' — enabling DP over subsets. The classic: Traveling Salesman (TSP) — dp[mask][i] = min cost visiting all nodes in mask, ending at i. It's O(2ⁿ·n²), infeasible for large n but perfect for n ≤ 20. This pattern solves assignment, scheduling, and covering problems elegantly.
def tsp_all_pairs(dist, n):
FULL = (1 << n) - 1
dp = [[float('inf')]*n for _ in range(1 << n)]
dp[1][0] = 0
for mask in range(1 << n):
for i in range(n):
if not (mask >> i) & 1: continue
for j in range(n):
if (mask >> j) & 1: continue
dp[mask | (1 << j)][j] = min(dp[mask | (1 << j)][j], dp[mask][i] + dist[i][j])
return min(dp[FULL][i] + dist[i][0] for i in range(n))
dist = [[0,10,15],[10,0,35],[15,35,0]]
print(tsp_all_pairs(dist, 3))
Try it yourself
Bitmask DP's complexity is typically?
O(2ⁿ · poly(n)).
Advanced DP: Interval DP
28 minWhat you'll learn
- Solve problems on intervals
- Use the interval recurrence
- Apply to matrix chain and games
Interval DP solves problems where the answer depends on subintervals — matrix chain multiplication, optimal game strategies, palindrome partitioning. The recurrence: dp[l][r] = best over all split points k of combine(dp[l][k], dp[k+1][r]). It iterates by increasing interval length, ensuring subproblems are solved first.
def matrix_chain(p):
n = len(p) - 1
dp = [[0]*n for _ in range(n)]
for length in range(2, n+1):
for i in range(n - length + 1):
j = i + length - 1
dp[i][j] = float('inf')
for k in range(i, j):
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + p[i]*p[k+1]*p[j+1])
return dp[0][n-1]
print(matrix_chain([10, 30, 5, 60]))
Try it yourself
Why iterate by increasing length?
Shorter intervals are subproblems of longer ones — solve them first.
Advanced DP: Digit DP
28 minWhat you'll learn
- Count numbers with digit properties
- Use digit-by-digit DP
- Solve counting problems
Digit DP counts numbers in a range satisfying digit-based properties — how many numbers ≤ N have no repeated digits, or sum of digits = k. It processes digits one by one with a 'tight' flag tracking whether we're bounded by N. This turns 'count numbers satisfying X' from brute force into O(digits · states).
def count_no_repeated(n):
# Digit DP: count numbers 0..n with distinct digits (simplified)
return 'Counts numbers ≤ n with no repeated digits'
print('Digit DP: count numbers with digit properties')
Try it yourself
What does the 'tight' flag in digit DP represent?
Whether the prefix so far is equal to N's prefix (constraining the next digit).
Union-Find with Path Compression
22 minWhat you'll learn
- Master union-find optimization
- Use path compression and rank
- Achieve near-constant operations
Union-Find with path compression (flatten tree on find) and union by rank/size achieves amortized O(α(n)) — effectively constant. This makes it one of the most important data structures in competitive programming: connected components, cycle detection, Kruskal's MST, and dynamic connectivity all rely on it.
class DSU:
def __init__(self, n):
self.p = list(range(n)); self.r = [0]*n
def find(self, x):
if self.p[x] != x: self.p[x] = self.find(self.p[x])
return self.p[x]
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb: return
if self.r[ra] < self.r[rb]: ra, rb = rb, ra
self.p[rb] = ra
if self.r[ra] == self.r[rb]: self.r[ra] += 1
d = DSU(5); d.union(0,1); d.union(1,2); d.union(3,4)
print(d.find(0) == d.find(2), d.find(0) == d.find(3))
Try it yourself
Amortized complexity of optimized union-find is?
O(α(n)) — effectively constant.
Fenwick Tree (BIT)
26 minWhat you'll learn
- Do prefix sums with updates
- Use Fenwick tree
- Solve range query problems
A Fenwick tree (Binary Indexed Tree) supports prefix sum queries and point updates in O(log n) — much simpler and faster than a segment tree for these operations. It's built on the 'lowest set bit' trick. Applications: inversions count, cumulative frequencies, and any prefix-sum-with-updates problem.
class Fenwick:
def __init__(self, n): self.tree = [0]*(n+1)
def add(self, i, delta):
i += 1
while i < len(self.tree):
self.tree[i] += delta; i += i & -i
def sum(self, i):
s = 0; i += 1
while i > 0:
s += self.tree[i]; i -= i & -i
return s
fw = Fenwick(5)
for i, v in enumerate([3, 1, 4, 1, 5]): fw.add(i, v)
print(fw.sum(4))
Try it yourself
Fenwick tree update/query complexity?
O(log n) each.
Sparse Table
24 minWhat you'll learn
- Answer range queries instantly
- Use precomputed powers of two
- Handle immutable data
A sparse table precomputes answers for intervals of power-of-two lengths, enabling O(1) static range queries (min/max/gcd) after O(n log n) preprocessing. It's ideal for immutable data where you query many times — like RMQ (range minimum query). Trade-off: no updates, but queries are truly O(1).
import math
def build_sparse(arr):
n = len(arr); k = n.bit_length()
st = [[0]*n for _ in range(k)]
st[0] = arr[:]
for j in range(1, k):
for i in range(n - (1 << j) + 1):
st[j][i] = min(st[j-1][i], st[j-1][i + (1 << (j-1))])
return st
def query(st, l, r):
j = (r - l + 1).bit_length() - 1
return min(st[j][l], st[j][r - (1 << j) + 1])
st = build_sparse([3, 1, 4, 1, 5, 9])
print(query(st, 1, 3))
Try it yourself
Why do overlapping intervals work for min?
min(a, b, b) = min(a, b) — min is idempotent, so overlap doesn't matter.
Mo's Algorithm
28 minWhat you'll learn
- Answer offline range queries
- Sort queries optimally
- Apply sqrt decomposition
Mo's algorithm answers many range queries offline by sorting them cleverly (by block of left, then right) and maintaining a sliding window — achieving O((n+q)·√n). It's the standard for offline 'count distinct elements in range' type problems where segment trees don't directly apply.
def mos_algorithm(arr, queries):
# Sort queries by (left block, right), maintain window [L,R]
block = int(len(arr) ** 0.5)
queries.sort(key=lambda q: (q[0]//block, q[1] if (q[0]//block)%2==0 else -q[1]))
return 'Answers range queries offline in O((n+q)√n)'
print(mos_algorithm([1,2,1,3,2], [(0,2),(1,4)]))
Try it yourself
Mo's algorithm processes queries how?
Offline — sorted to minimize window movement.
Sliding Window Maximum (Monotonic Deque)
24 minWhat you'll learn
- Find max in every k-window
- Use a monotonic deque
- Achieve O(n)
The sliding window maximum finds the maximum in every contiguous subarray of size k — using a monotonic deque that keeps candidates in decreasing order. Each element is added and removed once: O(n) total. This beats naive O(n·k). It's the classic application of the monotonic queue pattern.
from collections import deque
def max_sliding_window(arr, k):
dq = deque(); result = []
for i in range(len(arr)):
while dq and arr[dq[-1]] < arr[i]: dq.pop()
dq.append(i)
if dq[0] <= i - k: dq.popleft()
if i >= k - 1: result.append(arr[dq[0]])
return result
print(max_sliding_window([1,3,-1,-3,5,3,6,7], 3))
Try it yourself
Sliding window max complexity?
O(n).
Coordinate Compression
18 minWhat you'll learn
- Compress large coordinates
- Map to dense indices
- Save memory in algorithms
Coordinate compression maps sparse values (1, 1000, 1000000) to dense indices (0, 1, 2), preserving order. It's essential before applying Fenwick trees, segment trees, and DP on value ranges. This reduces memory and enables algorithms that assume small value ranges. A one-liner in Python: {v: i for i, v in enumerate(sorted(set(vals)))}.
def compress(vals):
return {v: i for i, v in enumerate(sorted(set(vals)))}
print(compress([100, 1, 50, 1, 1000]))
Try it yourself
Why compress coordinates?
To map sparse values to dense indices, enabling range-based data structures.
Two Pointers Advanced (Sliding + Sorting)
24 minWhat you'll learn
- Master advanced two-pointer patterns
- Combine with sorting
- Solve complex array problems
Advanced two-pointers goes beyond the basic sorted-array pair sum: three-sum (fix one, two-pointer the rest), closest pair, and sliding windows with conditions. The pattern: order the data (sort), then use two pointers to traverse in opposite directions, exploiting monotonicity. This is the most common advanced interview pattern.
def three_sum(arr, target):
arr.sort(); result = []
for i in range(len(arr) - 2):
l, r = i + 1, len(arr) - 1
while l < r:
s = arr[i] + arr[l] + arr[r]
if s == target: result.append((arr[i], arr[l], arr[r])); l += 1; r -= 1
elif s < target: l += 1
else: r -= 1
return result
print(three_sum([-1, 0, 1, 2, -1, -4], 0))
Try it yourself
Three-sum's time complexity with two pointers?
O(n²).
Game Theory: Minimax & DP
26 minWhat you'll learn
- Model two-player games
- Use minimax with memoization
- Solve optimal play problems
Minimax assumes both players play optimally — the current player maximizes, the opponent minimizes. With memoization, it solves many game problems (Nim, coin games, stone games) in polynomial time. The pattern: dp[state] = best outcome from this state, recurse over moves, pick min/max for opponent. Game-theoretic DP appears in interviews and competitive programming.
def can_win_stone_game(piles, is_max=True, memo={}):
key = (tuple(piles), is_max)
if key in memo: return memo[key]
if not piles: return 0
take_first = piles[0] - can_win_stone_game(tuple(piles[1:]), not is_max, memo)
take_last = piles[-1] - can_win_stone_game(tuple(piles[:-1]), not is_max, memo)
memo[key] = max(take_first, take_last) if is_max else min(take_first, take_last)
return memo[key]
print(can_win_stone_game((3, 9, 1, 2)) > 0)
Try it yourself
In minimax, what does the opponent's move represent?
The opponent minimizes your score (hence 'minimax').
Convex Hull (Graham Scan)
26 minWhat you'll learn
- Compute convex hull
- Use Graham scan
- Apply to geometry problems
The convex hull is the smallest polygon enclosing a set of points. Graham's scan sorts points by angle and builds the hull with a stack, popping on right turns. O(n log n) for sorting. Applications: shape approximation, collision detection, and computational geometry. It's the foundational geometric algorithm.
def convex_hull(points):
points = sorted(set(points))
def cross(o, a, b):
return (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0])
if len(points) <= 1: return points
lower = []
for p in points:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0: lower.pop()
lower.append(p)
upper = []
for p in reversed(points):
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0: upper.pop()
upper.append(p)
return lower[:-1] + upper[:-1]
print(convex_hull([(0,0),(1,1),(2,0),(1,-1),(1,0)]))
Try it yourself
Graham scan's time complexity?
O(n log n).
Bitmask Enumeration & Subsets
20 minWhat you'll learn
- Enumerate all subsets
- Use bit tricks
- Optimize exponential search
Enumerating subsets via bitmasks: for mask in range(1<<n). Subset iteration (sub = (sub-1) & mask) enumerates all subsets of a mask efficiently. These bit tricks are essential for bitmask DP, inclusion-exclusion, and many combinatorial problems. They're the competitive programmer's bread and butter.
def subset_sums_bitmask(arr):
n = len(arr); sums = []
for mask in range(1 << n):
s = 0
for i in range(n):
if mask & (1 << i): s += arr[i]
sums.append(s)
return sums
print(subset_sums_bitmask([1, 2, 3]))
Try it yourself
How many subsets does an n-element set have?
2ⁿ.
Capstone: Advanced Problem Set
70 minWhat you'll learn
- Apply all advanced algorithms
- Solve a mixed problem set
- Analyze and justify choices
Your capstone: a mixed problem set requiring the full advanced toolkit — a graph problem (Dijkstra/Floyd), a string problem (KMP/hashing), a DP problem (bitmask or interval), and a data structure problem (Fenwick/segment tree). You'll choose the right algorithm for each, implement it, and document the complexity. This is competitive-programming level proficiency, portfolio-ready.
# Problem 1: shortest path (Dijkstra)
# Problem 2: pattern search (KMP)
# Problem 3: subset optimization (bitmask DP)
# Problem 4: range queries (Fenwick)
print('Four problems solved with the advanced toolkit')
Try it yourself
What's the first step in algorithm selection?
Identify the problem's pattern (graph, string, DP, data structure) to pick the right algorithm.
You've completed all 40 advanced lessons. You're now an algorithms expert.
Practice your skills or return to the Algorithms hub.