DSA — Data Structures & Algorithms
A zero-to-hero reference for coding interviews: complexity analysis, the C++ STL you'll actually use under time pressure, every core data structure and algorithmic pattern, and the rare-but-real advanced topics that separate a good round from a great one. Every code sample is idiomatic modern C++ (C++17/20) — read top to bottom the first time, then use the sidebar to drill back in before an interview.
1. Complexity Analysis
Big-O, Big-Theta, Big-Omega Basic
Big-O (O) describes an upper bound on growth rate — "this algorithm never does more than this much work, asymptotically." Big-Omega (Ω) describes a lower bound — the best case guarantee. Big-Theta (Θ) is used when upper and lower bounds coincide — a tight bound. In interviews, people say "Big-O" loosely to mean Θ (the typical/expected running time), which is technically imprecise but universally understood.
Formally, f(n) = O(g(n)) if there exist constants c > 0 and n₀ such that f(n) ≤ c·g(n) for all n ≥ n₀. Constants and lower-order terms are dropped: O(3n² + 5n + 7) = O(n²).
When you state a complexity out loud, state both time and space, and mention whether it's worst-case, average-case, or amortized. "This is O(n log n) time worst-case, O(n) extra space" reads as far more senior than just "it's n log n".
Amortized Analysis Intermediate
Amortized complexity averages the cost of an operation over a sequence of operations, even if individual operations occasionally cost more. The classic example is std::vector::push_back: most calls are O(1), but occasionally the vector must reallocate and copy all existing elements, costing O(n). Because reallocation doubles capacity (geometric growth), the total cost of n pushes is O(n), so the amortized cost per push is O(1).
Three standard techniques prove amortized bounds:
- Aggregate method — bound total cost of n operations, divide by n.
- Accounting method — assign each operation a "credit"; cheap operations bank credit, expensive ones spend it.
- Potential method — define a potential function Φ over the data structure's state; amortized cost = actual cost + ΔΦ.
// vector push_back: amortized O(1) despite occasional O(n) reallocation
std::vector<int> v;
for (int i = 0; i < n; ++i) {
v.push_back(i); // amortized O(1); capacity roughly doubles when full
}
// Total work for n pushes: 1+2+4+8+...+n ~ 2n = O(n) => O(1) amortized/op
Space Complexity Basic
Space complexity counts extra memory used beyond the input (auxiliary space), though some definitions include input space too — always clarify which you mean. Watch out for:
- Recursion stack space — a recursive function with depth d uses O(d) stack space even if it allocates no heap memory. A naive recursive Fibonacci uses O(n) stack space; an iterative version uses O(1).
- In-place vs out-of-place — "in-place" typically means O(1) or O(log n) extra space (O(log n) is often allowed for recursion in in-place quicksort).
- Hidden allocations — building a new string in a loop with
+=can be O(n²) if the string reallocates repeatedly without reserved capacity (mitigated by SSO/growth strategy, but still worth mentioning).
Master Theorem Intermediate
The Master Theorem gives the asymptotic complexity of divide-and-conquer recurrences of the form:
T(n) = a * T(n / b) + f(n) where a >= 1, b > 1
Compare f(n) against n^(log_b a):
| Case | Condition | Result |
|---|---|---|
| 1 | f(n) = O(n^(log_b a − ε)) for some ε > 0 | T(n) = Θ(n^(log_b a)) |
| 2 | f(n) = Θ(n^(log_b a) · logᵏ n), k ≥ 0 | T(n) = Θ(n^(log_b a) · log^(k+1) n) |
| 3 | f(n) = Ω(n^(log_b a + ε)) and a·f(n/b) ≤ c·f(n) (regularity) | T(n) = Θ(f(n)) |
Worked examples:
- Merge sort: T(n) = 2T(n/2) + O(n). a=2, b=2, log_b a = 1, f(n) = Θ(n¹·log⁰n) → Case 2 with k=0 → T(n) = Θ(n log n).
- Binary search: T(n) = T(n/2) + O(1). a=1, b=2, log_b a = 0, f(n) = Θ(n⁰) → Case 2 → T(n) = Θ(log n).
- Naive matrix multiplication (divide-and-conquer, 8 sub-multiplications): T(n) = 8T(n/2) + O(n²). log_b a = log₂8 = 3, f(n) = n² = O(n^(3−ε)) → Case 1 → T(n) = Θ(n³).
- Strassen's algorithm: T(n) = 7T(n/2) + O(n²). log_b a = log₂7 ≈ 2.807, f(n)=n² is polynomially smaller → Case 1 → T(n) = Θ(n^2.807).
The Master Theorem does not apply to every recurrence — e.g., T(n) = 2T(n/2) + n/log n falls in a gap between cases 1 and 2 and needs the more general Akra–Bazzi method. Don't force-fit it in an interview; say "this doesn't cleanly fit Master Theorem" if it doesn't.
Common Complexity Classes Basic
| Complexity | Name | Typical example | n = 10⁸ feasible? |
|---|---|---|---|
| O(1) | Constant | Array index, hash lookup (avg) | Yes |
| O(log n) | Logarithmic | Binary search | Yes |
| O(n) | Linear | Single pass / linear scan | Yes |
| O(n log n) | Linearithmic | Sorting (merge/heap/quick) | Usually (n≈10⁷–10⁸) |
| O(n²) | Quadratic | Nested loops, DP over pairs | Only up to n≈10⁴ |
| O(n³) | Cubic | Floyd-Warshall, 3D DP | Only up to n≈500 |
| O(2ⁿ) | Exponential | Subset enumeration, naive backtracking | Only up to n≈20–25 |
| O(n!) | Factorial | Permutation enumeration | Only up to n≈10–11 |
Use this table backwards in interviews: given n and a time limit (usually ~1–2s, ~10⁸ simple ops/sec), infer the expected complexity. n ≤ 20 screams bitmask DP or brute-force subsets; n ≤ 10⁴ allows O(n²); n ≤ 10⁶–10⁷ demands O(n) or O(n log n).
Eyeballing Complexity From Code Basic
- Sequential statements: add complexities, keep the dominant term.
- Nested loops: multiply — a loop over n inside a loop over m is O(n·m), even if independent.
- A loop that halves its range each iteration (
i *= 2ori /= 2) contributes a factor of O(log n), not O(n). - Recursive calls: draw the recursion tree, or apply Master Theorem if it's divide-and-conquer; otherwise count total number of function invocations × work per invocation.
- A loop whose body does decreasing work (e.g., inner loop from i to n) is still O(n²) — the sum 1+2+...+n = n(n+1)/2 is Θ(n²), not Θ(n).
// O(n log n): outer loop n times, inner loop halves each time
for (int i = 0; i < n; ++i) {
for (int j = 1; j < n; j *= 2) {
// O(1) work
}
}
Q: Why is std::vector's push_back amortized O(1) and not just O(1)?
Because any single call can trigger a full reallocation and copy of all existing elements (O(n) in the worst case). It is only O(1) when averaged over a long sequence of pushes, thanks to geometric (doubling) capacity growth. If you say "push_back is O(1)" without qualification in an interview, expect a follow-up question.
Q: What's the difference between worst-case and amortized complexity in practice?
Worst-case bounds every single operation individually — useful for real-time systems where one slow operation can't be tolerated. Amortized bounds the average over a sequence — useful when occasional spikes are fine as long as the total stays low, e.g. a UI that occasionally stutters during a vector resize but is smooth overall.
Q: Given n ≤ 10⁵ and a 2-second time limit, what complexity should you target?
With ~10⁸–10⁹ simple operations per second as a rule of thumb, O(n log n) (~1.7×10⁶ ops) is comfortably fast, and even O(n√n) (~3×10⁷) is fine. O(n²) (10¹⁰ ops) would almost certainly TLE (time limit exceeded), so it signals you need a smarter approach — sorting, hashing, two pointers, or a different data structure.
Q: Is O(n) always better than O(n log n)?
Asymptotically yes, but only for large n and only if constants are comparable. For small n, or if the O(n) algorithm has heavy constant factors (e.g., multiple passes, cache-unfriendly access), an O(n log n) algorithm with tight constants can be faster in practice. Big-O ignores constants — real performance sometimes doesn't.
2. C++ STL Deep Dive
Fluency with the STL is non-negotiable in C++ interviews, especially at companies that test low-level performance understanding. Knowing what a container does is table stakes; knowing how it's implemented and its complexity guarantees is what separates candidates.
std::vector — Growth Factor, Capacity vs Size Basic
std::vector is a dynamic array: contiguous storage, O(1) random access, O(1) amortized push_back/pop_back, O(n) insert/erase in the middle. size() is the number of elements; capacity() is how many elements fit before reallocation. Most implementations (libstdc++, MSVC) grow capacity by roughly 1.5×–2× when full — libstdc++ doubles, MSVC uses 1.5×. Growth factor matters: too small wastes time on frequent reallocation; too large wastes memory.
std::vector<int> v;
std::cout << v.capacity() << "\n"; // 0
for (int i = 0; i < 10; ++i) {
v.push_back(i);
std::cout << "size=" << v.size() << " cap=" << v.capacity() << "\n";
}
// Reserve up front to avoid reallocations when the final size is known:
std::vector<int> v2;
v2.reserve(1000); // capacity = 1000, size = 0, no reallocation for 1000 pushes
v2.shrink_to_fit(); // non-binding request to release unused capacity
Reallocation invalidates all iterators, pointers, and references into the vector. Holding a reference to v[0] across a push_back that triggers reallocation is undefined behavior — a classic interview gotcha.
pair / tuple + Structured Bindings Basic
std::pair<A,B> holds two heterogeneous values (.first, .second); std::tuple<A,B,C,...> generalizes to N values, accessed via std::get<i>. Since C++17, structured bindings let you unpack either into named variables directly.
std::pair<int, std::string> p = {1, "one"};
auto [id, name] = p; // structured binding (C++17)
std::tuple<int, double, char> t{1, 2.5, 'x'};
auto [a, b, c] = t;
for (auto& [key, value] : someMap) { // works on any std::pair-like element
// key, value bound directly, no .first/.second
}
// pair/tuple compare lexicographically by default — handy for custom sort keys
std::vector<std::pair<int,int>> v = {{2,1},{1,5},{1,2}};
std::sort(v.begin(), v.end()); // sorts by .first, then .second
std::string — SSO & Common Ops Basic
std::string is a contiguous, mutable, null-terminated-compatible character buffer. Most implementations use Small String Optimization (SSO): strings under ~15–22 bytes (implementation-defined, libstdc++ is 15 chars) are stored inline in the string object itself, avoiding heap allocation entirely. This makes short-string copies and constructions very cheap — no new/delete involved.
| Operation | Complexity |
|---|---|
s[i], s.size() | O(1) |
s += c (push_back) | Amortized O(1) |
s + t (concatenation) | O(|s| + |t|) |
s.substr(pos, len) | O(len) — always a copy |
s.find(t) | O(|s| · |t|) naive (libstdc++'s is roughly this) |
std::stoi / to_string | O(digits) |
Building a large string with repeated s += x in a loop without reserve() can cause multiple reallocations, degrading toward O(n²) in adversarial growth patterns. Prefer s.reserve(expectedSize) or building into a std::vector<char> when the final size is known.
stack, queue, deque Basic
std::stack and std::queue are container adapters — they wrap an underlying container (default std::deque) and expose a restricted interface (LIFO for stack: push/pop/top; FIFO for queue: push/pop/front/back). std::deque (double-ended queue) itself supports O(1) push/pop at both ends and O(1) random access, implemented as a sequence of fixed-size chunks (not one contiguous block like vector), so it doesn't invalidate references on push_front/push_back the way vector does.
std::deque<int> dq;
dq.push_back(1);
dq.push_front(2); // O(1), unlike vector's O(n) insert-at-front
std::stack<int> st; // LIFO, default backing = deque
std::queue<int> q; // FIFO, default backing = deque
std::stack<int, std::vector<int>> stV; // can swap backing container
priority_queue — Custom Comparators Intermediate
std::priority_queue is a max-heap by default, backed by std::vector and heap algorithms (push_heap/pop_heap). push/pop are O(log n); top is O(1). For a min-heap, either negate values, use std::greater<T>, or supply a custom comparator.
// Min-heap of ints
std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap;
// Custom comparator via lambda (needs decltype since lambda types are unique)
auto cmp = [](const std::pair<int,int>& a, const std::pair<int,int>& b) {
return a.second > b.second; // min-heap by .second
};
std::priority_queue<std::pair<int,int>, std::vector<std::pair<int,int>>, decltype(cmp)> pq(cmp);
// Custom comparator via struct (reusable, often cleaner)
struct Cmp {
bool operator()(int a, int b) const { return a > b; } // min-heap
};
std::priority_queue<int, std::vector<int>, Cmp> pq2;
Remember: the comparator you pass answers "is a lower priority than b?" — returning true means a should come out later. This is the same convention as std::sort's "less-than" comparator, but it's easy to get backwards for a min-heap under pressure. Say it out loud and double check.
set / multiset Intermediate
Both are typically implemented as a self-balancing red-black tree, keeping elements sorted. set disallows duplicates, multiset allows them. Insert/erase/find are all O(log n). Because elements are sorted, you get ordered iteration and can use lower_bound/upper_bound (O(log n) member functions, distinct from the free functions in <algorithm> which require random access and would be O(log n) on a tree but the tree's own member versions are the correct ones to use).
std::set<int> s = {5, 1, 3};
s.insert(2);
auto it = s.lower_bound(3); // first element >= 3 -> iterator to 3
auto it2 = s.upper_bound(3); // first element > 3 -> iterator to 5
s.erase(1); // O(log n)
bool found = s.count(3) > 0; // O(log n); count is 0 or 1 for set
std::multiset<int> ms = {1, 1, 2, 2, 2};
ms.erase(ms.find(2)); // erase ONE instance (erase(2) would remove ALL of them!)
multiset.erase(value) removes every element equal to value — O(log n + k) where k is the count removed. To remove a single instance, erase via an iterator: ms.erase(ms.find(value)).
map / multimap — Red-Black Tree Backing Intermediate
std::map stores sorted key-value pairs in a red-black tree (a self-balancing BST guaranteeing O(log n) height). All core operations — insert, erase, find, operator[] — are O(log n). Iteration is in sorted key order. multimap allows duplicate keys.
std::map<std::string, int> freq;
freq["apple"]++; // operator[] default-constructs 0 if missing, then increments; O(log n)
for (auto& [word, count] : freq) { /* sorted by key */ }
auto it = freq.find("apple"); // O(log n), returns end() if missing
if (it != freq.end()) { /* found */ }
// operator[] vs find: operator[] INSERTS a default value if key is missing —
// dangerous for a read-only lookup, use find() or count() to avoid pollution
if (freq.count("banana")) { /* safe check, no insertion */ }
unordered_set / unordered_map — Hashing & Collisions Intermediate
Backed by a hash table (array of buckets + chaining for collision resolution in standard implementations). Average-case O(1) for insert/erase/find; worst-case O(n) if many keys collide into the same bucket (e.g., an adversarial input crafted against a known hash function, or a poor custom hash). Rehashing (growing the bucket array and re-distributing elements) happens automatically when the load factor exceeds a threshold, amortized similar to vector's growth.
std::unordered_map<std::string, int> m;
m.reserve(1000); // pre-size bucket array to reduce rehashing
m["x"] = 1; // average O(1)
// Custom hash for a struct/pair key (unordered_map has no default hash for pair)
struct PairHash {
size_t operator()(const std::pair<int,int>& p) const {
return std::hash<long long>()(((long long)p.first << 32) ^ (unsigned int)p.second);
}
};
std::unordered_map<std::pair<int,int>, int, PairHash> grid;
// Combining hashes (boost-style hash_combine idiom)
size_t combined = std::hash<int>()(a);
combined ^= std::hash<int>()(b) + 0x9e3779b9 + (combined << 6) + (combined >> 2);
Competitive programmers routinely anti-hash unordered_map<int,int> using known weaknesses of the default hash to force O(n) worst-case behavior (a classic Codeforces trick). Mitigate with a randomized custom hash (e.g., splitmix64 seeded with chrono::steady_clock) if this matters for your use case.
std::bitset Intermediate
A fixed-size sequence of bits with compile-time size, offering O(1) (technically O(N/64) but treated as a constant) bitwise operations, and useful helpers.
std::bitset<32> b(13); // 00000000000000000000000000001101
b.set(4); // set bit 4
b.reset(0); // clear bit 0
bool has = b.test(2); // check bit 2
size_t ones = b.count(); // number of set bits
std::string s = b.to_string();
unsigned long v = b.to_ulong();
std::bitset<32> c = b & std::bitset<32>(0xFF); // bitwise AND
<algorithm> Essentials Intermediate
std::vector<int> v = {5, 3, 1, 4, 1, 5, 9, 2, 6};
std::sort(v.begin(), v.end()); // O(n log n), not stable
std::stable_sort(v.begin(), v.end()); // O(n log n), stable (preserves equal-key order)
std::sort(v.begin(), v.end(), std::greater<>()); // descending
bool found = std::binary_search(v.begin(), v.end(), 4); // O(log n), needs sorted range
auto lo = std::lower_bound(v.begin(), v.end(), 4); // first element >= 4
auto hi = std::upper_bound(v.begin(), v.end(), 4); // first element > 4
// [lo, hi) is the range of elements equal to 4
do {
// visits all n! permutations in lexicographic order
} while (std::next_permutation(v.begin(), v.end()));
int sum = std::accumulate(v.begin(), v.end(), 0); // fold with initial value
int product = std::accumulate(v.begin(), v.end(), 1, std::multiplies<int>());
// Bit tricks (GCC/Clang builtins, extremely useful and fast)
int x = 44; // 0b101100
int popcount = __builtin_popcount(x); // number of set bits -> 3
int leadingZeros = __builtin_clz(x); // count leading zeros (32-bit)
int trailingZeros = __builtin_ctz(x); // count trailing zeros -> 2
// C++17
int g = std::gcd(12, 18); // 6
int l = std::lcm(4, 6); // 12
std::reverse(v.begin(), v.end());
auto maxIt = std::max_element(v.begin(), v.end());
auto [minIt, maxIt2] = std::minmax_element(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end()); // dedupe ADJACENT equal elements (sort first!)
std::unique only removes consecutive duplicates — you must sort first if you want global dedupe. Also, it doesn't actually shrink the container; it moves unique elements to the front and returns an iterator to the new logical end — you must call erase with that iterator to actually shrink.
Container → Complexity Cheat Sheet Basic
| Container | Access | Search | Insert | Erase | Backing structure |
|---|---|---|---|---|---|
| vector | O(1) | O(n) | O(1) amortized end / O(n) middle | O(n) | Dynamic array |
| deque | O(1) | O(n) | O(1) ends / O(n) middle | O(1) ends / O(n) middle | Chunked array |
| list (doubly linked) | O(n) | O(n) | O(1) given iterator | O(1) given iterator | Doubly linked list |
| set / map | — | O(log n) | O(log n) | O(log n) | Red-black tree |
| unordered_set / map | — | O(1) avg, O(n) worst | O(1) avg, O(n) worst | O(1) avg, O(n) worst | Hash table + chaining |
| priority_queue | O(1) top | — | O(log n) | O(log n) pop | Binary heap (vector) |
| stack / queue | O(1) top/front | — | O(1) | O(1) | deque (default) |
Q: Why does std::map use a red-black tree instead of a hash table?
Because std::map guarantees sorted iteration order and O(log n) worst-case (not just average-case) operations — properties a hash table can't offer. If you don't need ordering and want faster average lookups, unordered_map is the right choice; if you need guaranteed worst-case bounds or sorted traversal / range queries (lower_bound, upper_bound), map wins.
Q: When would you prefer std::list over std::vector?
Rarely, in practice — vector's cache locality usually wins even for "frequent middle insertion" workloads unless the list is huge. list is justified when you need O(1) insertion/deletion at arbitrary positions given an existing iterator, without invalidating other iterators (e.g., an LRU cache's doubly-linked list of nodes, or splicing large sublists between containers).
Q: What happens if you use a poorly-designed hash function with unordered_map?
All (or many) keys collide into the same bucket(s), degrading operations from average O(1) to worst-case O(n) — effectively turning the hash table into a linked list. This is exploitable: some online judges intentionally test with anti-hash inputs against int keys, which is why competitive programmers add a random seed to their custom hash.
Q: What's the difference between vector::size() and vector::capacity()?
size() is the number of elements currently stored; capacity() is how many elements can be held in the currently allocated buffer before a reallocation is needed. size() ≤ capacity() always. resize() changes size() (and default-constructs/destroys elements); reserve() changes capacity() without changing size().
Q: Why is operator[] on std::map dangerous for read-only lookups?
If the key doesn't exist, operator[] silently default-constructs a value for it and inserts it into the map — a read turns into a mutation, which can be a subtle bug (e.g., polluting a map you're iterating, or masking a "key not found" condition). Use find() or count() when you only want to check/read.
3. Arrays & Strings
Two Pointers Basic
Two indices move through a (usually sorted) array toward or away from each other, avoiding an O(n²) nested loop. Classic use: find a pair summing to a target in a sorted array.
bool twoSumSorted(std::vector<int>& a, int target) {
int lo = 0, hi = (int)a.size() - 1;
while (lo < hi) {
int sum = a[lo] + a[hi];
if (sum == target) return true;
else if (sum < target) ++lo;
else --hi;
}
return false;
}
// O(n) time, O(1) space
Sliding Window (Fixed & Variable Size) Basic
Maintain a contiguous window [left, right] and slide it across the array, updating window state incrementally instead of recomputing from scratch — turns an O(n·k) brute force into O(n).
// Fixed-size window: max sum of any subarray of size k
int maxSumFixedWindow(std::vector<int>& a, int k) {
int sum = 0;
for (int i = 0; i < k; ++i) sum += a[i];
int best = sum;
for (int i = k; i < (int)a.size(); ++i) {
sum += a[i] - a[i - k]; // add new, remove leftmost
best = std::max(best, sum);
}
return best;
}
// Variable-size window: smallest subarray with sum >= target
int minSubarrayLen(int target, std::vector<int>& a) {
int left = 0, sum = 0, best = INT_MAX;
for (int right = 0; right < (int)a.size(); ++right) {
sum += a[right];
while (sum >= target) {
best = std::min(best, right - left + 1);
sum -= a[left++]; // shrink from left
}
}
return best == INT_MAX ? 0 : best;
}
// Both O(n) time, O(1) space — each pointer moves forward at most n times total
Prefix Sums (1D & 2D) Basic
Precompute cumulative sums so any range-sum query becomes O(1). The 2D version extends via inclusion-exclusion.
// 1D: prefix[i] = sum of a[0..i-1]
std::vector<long long> buildPrefix(std::vector<int>& a) {
std::vector<long long> prefix(a.size() + 1, 0);
for (int i = 0; i < (int)a.size(); ++i) prefix[i + 1] = prefix[i] + a[i];
return prefix;
}
long long rangeSum(std::vector<long long>& prefix, int l, int r) { // sum a[l..r] inclusive
return prefix[r + 1] - prefix[l];
}
// 2D: prefix[i][j] = sum of rectangle (0,0) to (i-1,j-1)
std::vector<std::vector<long long>> build2D(std::vector<std::vector<int>>& grid) {
int n = grid.size(), m = grid[0].size();
std::vector<std::vector<long long>> pre(n + 1, std::vector<long long>(m + 1, 0));
for (int i = 0; i < n; ++i)
for (int j = 0; j < m; ++j)
pre[i+1][j+1] = grid[i][j] + pre[i][j+1] + pre[i+1][j] - pre[i][j];
return pre;
}
// Query sum of rectangle (r1,c1)-(r2,c2) inclusive, using inclusion-exclusion:
// pre[r2+1][c2+1] - pre[r1][c2+1] - pre[r2+1][c1] + pre[r1][c1]
Kadane's Algorithm Basic
Finds the maximum sum contiguous subarray in O(n). Key insight: at each position, the best subarray ending here is either "extend the previous best" or "start fresh here" — whichever is larger.
int maxSubArray(std::vector<int>& a) {
int best = a[0], curr = a[0];
for (int i = 1; i < (int)a.size(); ++i) {
curr = std::max(a[i], curr + a[i]); // extend or restart
best = std::max(best, curr);
}
return best;
}
// O(n) time, O(1) space
Dutch National Flag Intermediate
Sort an array of 3 distinct values (e.g., 0s, 1s, 2s) in a single O(n) pass using three pointers — no extra space, no comparison sort needed. Also the core partitioning idea behind 3-way quicksort.
void sortColors(std::vector<int>& a) { // values are 0, 1, 2
int low = 0, mid = 0, high = (int)a.size() - 1;
while (mid <= high) {
if (a[mid] == 0) std::swap(a[low++], a[mid++]);
else if (a[mid] == 1) ++mid;
else std::swap(a[mid], a[high--]); // don't advance mid: re-check swapped-in value
}
}
// O(n) time, O(1) space, single pass
String Hashing Intermediate
Polynomial rolling hash maps a string to a number, enabling O(1) substring-equality checks after O(n) precomputation. Use a large prime modulus and base to minimize collisions; double-hashing (two different mod/base pairs) nearly eliminates collision risk in practice.
const long long MOD = 1'000'000'007, BASE = 131;
struct StringHash {
std::vector<long long> h, p; // h[i] = hash of prefix[0..i), p[i] = BASE^i mod MOD
StringHash(const std::string& s) {
int n = s.size();
h.assign(n + 1, 0); p.assign(n + 1, 1);
for (int i = 0; i < n; ++i) {
h[i+1] = (h[i] * BASE + s[i]) % MOD;
p[i+1] = (p[i] * BASE) % MOD;
}
}
// hash of s[l..r) in O(1)
long long get(int l, int r) {
long long res = (h[r] - h[l] * p[r - l]) % MOD;
return res < 0 ? res + MOD : res;
}
};
KMP (Knuth-Morris-Pratt) Advanced
Finds all occurrences of a pattern in a text in O(n + m), avoiding re-scanning already-matched characters by precomputing a "longest proper prefix that is also a suffix" (LPS / failure function) array for the pattern.
std::vector<int> buildLPS(const std::string& pat) {
int m = pat.size();
std::vector<int> lps(m, 0);
int len = 0, i = 1;
while (i < m) {
if (pat[i] == pat[len]) lps[i++] = ++len;
else if (len) len = lps[len - 1];
else lps[i++] = 0;
}
return lps;
}
std::vector<int> kmpSearch(const std::string& text, const std::string& pat) {
std::vector<int> lps = buildLPS(pat), matches;
int n = text.size(), m = pat.size(), i = 0, j = 0;
while (i < n) {
if (text[i] == pat[j]) { ++i; ++j; }
if (j == m) { matches.push_back(i - j); j = lps[j - 1]; }
else if (i < n && text[i] != pat[j]) {
if (j) j = lps[j - 1]; else ++i;
}
}
return matches;
}
// O(n + m) time, O(m) space
Z-Function Advanced
z[i] = length of the longest substring starting at i that matches a prefix of the string. Computed in O(n) using a maintained [l, r) "Z-box" window. Used for pattern matching (concatenate pattern + separator + text) and many string structure problems.
std::vector<int> zFunction(const std::string& s) {
int n = s.size();
std::vector<int> z(n, 0);
int l = 0, r = 0;
for (int i = 1; i < n; ++i) {
if (i < r) z[i] = std::min(r - i, z[i - l]);
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) ++z[i];
if (i + z[i] > r) { l = i; r = i + z[i]; }
}
return z;
}
// O(n) time, O(n) space. z[0] is conventionally left as 0 or n by convention.
Rabin-Karp Intermediate
Uses rolling hash to find a pattern in a text: compute the pattern's hash and the hash of every window of the text in O(1) amortized per shift, only comparing characters directly when hashes match (to guard against collisions). Average O(n + m), worst-case O(n·m) under hash collisions.
std::vector<int> rabinKarp(const std::string& text, const std::string& pat) {
const long long MOD = 1'000'000'007, BASE = 131;
int n = text.size(), m = pat.size();
std::vector<int> matches;
if (m > n) return matches;
long long patHash = 0, curHash = 0, power = 1;
for (int i = 0; i < m; ++i) {
patHash = (patHash * BASE + pat[i]) % MOD;
curHash = (curHash * BASE + text[i]) % MOD;
if (i) power = (power * BASE) % MOD;
}
for (int i = 0; ; ++i) {
if (curHash == patHash && text.substr(i, m) == pat) matches.push_back(i);
if (i + m == n) break;
curHash = ((curHash - (long long)text[i] * power % MOD + MOD) % MOD * BASE + text[i + m]) % MOD;
}
return matches;
}
Manacher's Algorithm Advanced
Finds the longest palindromic substring in O(n), versus the naive O(n²) expand-around-center approach. The trick: transform the string by inserting separators (so all palindromes become odd-length), then use a maintained palindrome "box" [center, right] to reuse previously computed radii via mirroring.
std::string longestPalindrome(const std::string& s) {
std::string t = "^#";
for (char c : s) { t += c; t += '#'; }
t += '$';
int n = t.size();
std::vector<int> p(n, 0);
int center = 0, right = 0;
for (int i = 1; i < n - 1; ++i) {
if (i < right) p[i] = std::min(right - i, p[2 * center - i]);
while (t[i + p[i] + 1] == t[i - p[i] - 1]) ++p[i];
if (i + p[i] > right) { center = i; right = i + p[i]; }
}
int maxLen = 0, centerIdx = 0;
for (int i = 1; i < n - 1; ++i)
if (p[i] > maxLen) { maxLen = p[i]; centerIdx = i; }
int start = (centerIdx - maxLen) / 2;
return s.substr(start, maxLen);
}
// O(n) time, O(n) space
Q: When should you reach for sliding window vs two pointers?
They overlap conceptually. "Two pointers" is the general umbrella (converging pointers on sorted data, or a fast/slow pattern). "Sliding window" specifically maintains a contiguous range with incrementally-updated aggregate state (sum, count, frequency map) — reach for it when the problem asks for a "subarray"/"substring" satisfying some condition.
Q: Why does KMP achieve O(n + m) instead of the naive O(n·m)?
On a mismatch, naive matching restarts the text pointer from scratch. KMP's LPS array lets it "remember" the longest prefix-suffix overlap of the pattern matched so far, so the pattern pointer jumps back intelligently instead of the text pointer ever moving backward — each text character is examined a bounded number of times overall (amortized O(1) per position).
Q: Why might you double-hash a string instead of using one hash function?
A single polynomial hash mod a fixed prime can be adversarially defeated (crafted collisions), which matters on judges like Codeforces that specifically test against common hash parameters. Using two independent (base, mod) pairs and combining the results (e.g., as a pair) makes deliberate collisions astronomically unlikely.
Q: How would you find the maximum sum of a subarray with at most one deletion allowed?
Extend Kadane's with two DP states per index: best sum ending here with 0 deletions used, and best sum ending here with exactly 1 deletion used. Transition: noDelete[i] = max(a[i], noDelete[i-1] + a[i]); withDelete[i] = max(noDelete[i-1], withDelete[i-1] + a[i]) — either delete a[i] (carry forward the no-delete state) or include it after already having deleted once earlier.
Q: What's the difference between the Z-function and the KMP failure function?
KMP's LPS[i] is the longest proper prefix of pat[0..i] that is also a suffix of it — a local, backward-looking property. The Z-function z[i] is the length of the longest substring starting at i that matches the string's prefix — comparing every position directly against the start. They solve overlapping problems (pattern matching) but z-function generalizes more easily to problems needing prefix-match lengths at every position.
4. Recursion & Backtracking
Backtracking is DFS over a decision tree: make a choice, recurse, then undo the choice ("backtrack") before trying the next one. The undo step is what distinguishes it from plain recursion — it lets you reuse a single mutable data structure (a path, a board) across all branches instead of copying it.
Subsets Basic
void backtrack(int idx, std::vector<int>& nums, std::vector<int>& path,
std::vector<std::vector<int>>& result) {
result.push_back(path); // every node in the recursion tree is a valid subset
for (int i = idx; i < (int)nums.size(); ++i) {
path.push_back(nums[i]); // choose
backtrack(i + 1, nums, path, result); // explore
path.pop_back(); // un-choose (backtrack)
}
}
// Generates all 2^n subsets. Time: O(2^n * n) (n to copy each subset)
Permutations Basic
void permute(std::vector<int>& nums, std::vector<bool>& used,
std::vector<int>& path, std::vector<std::vector<int>>& result) {
if (path.size() == nums.size()) { result.push_back(path); return; }
for (int i = 0; i < (int)nums.size(); ++i) {
if (used[i]) continue;
used[i] = true; path.push_back(nums[i]);
permute(nums, used, path, result);
path.pop_back(); used[i] = false;
}
}
// n! permutations, O(n! * n) time overall
N-Queens Advanced
Place n queens on an n×n board so no two attack each other. Track occupied columns and both diagonals (using the invariants row-col and row+col are constant along a diagonal) for O(1) conflict checks.
int totalNQueens(int n) {
std::vector<bool> cols(n), diag1(2 * n), diag2(2 * n); // diag1: r-c+n, diag2: r+c
int count = 0;
std::function<void(int)> solve = [&](int row) {
if (row == n) { ++count; return; }
for (int col = 0; col < n; ++col) {
int d1 = row - col + n, d2 = row + col;
if (cols[col] || diag1[d1] || diag2[d2]) continue;
cols[col] = diag1[d1] = diag2[d2] = true;
solve(row + 1);
cols[col] = diag1[d1] = diag2[d2] = false; // backtrack
}
};
solve(0);
return count;
}
Sudoku Solver Advanced
bool isValid(std::vector<std::vector<char>>& b, int r, int c, char val) {
for (int i = 0; i < 9; ++i) {
if (b[r][i] == val || b[i][c] == val) return false;
int br = 3 * (r / 3) + i / 3, bc = 3 * (c / 3) + i % 3;
if (b[br][bc] == val) return false;
}
return true;
}
bool solveSudoku(std::vector<std::vector<char>>& b) {
for (int r = 0; r < 9; ++r) {
for (int c = 0; c < 9; ++c) {
if (b[r][c] != '.') continue;
for (char val = '1'; val <= '9'; ++val) {
if (!isValid(b, r, c, val)) continue;
b[r][c] = val;
if (solveSudoku(b)) return true; // propagate success up the call stack
b[r][c] = '.'; // backtrack
}
return false; // no digit worked here -> this whole branch fails
}
}
return true; // filled every cell
}
Combination Sum Intermediate
Find all combinations of candidates that sum to a target, allowing reuse of each candidate unlimited times.
void combSum(std::vector<int>& cand, int idx, int remain,
std::vector<int>& path, std::vector<std::vector<int>>& result) {
if (remain == 0) { result.push_back(path); return; }
if (remain < 0 || idx == (int)cand.size()) return;
path.push_back(cand[idx]);
combSum(cand, idx, remain - cand[idx], path, result); // reuse cand[idx] -> don't advance idx
path.pop_back();
combSum(cand, idx + 1, remain, path, result); // skip cand[idx]
}
// Sort candidates first and prune (remain < 0) for efficiency
Recursion Tree & Time Complexity of Backtracking Intermediate
Backtracking complexity = (branching factor)^depth × (work per node), pruned by constraints. For subsets: branching factor 2, depth n → O(2ⁿ). For permutations: n choices, then n-1, then n-2... → O(n!). For N-Queens, naive is O(nⁿ) but column/diagonal pruning cuts the effective search space drastically in practice, though the worst-case bound is still exponential.
When asked for the complexity of a backtracking solution, describe the recursion tree explicitly: "the branching factor is k at each of d levels, so the tree has O(kᵈ) nodes, and each node does O(w) work, giving O(kᵈ · w)". Interviewers care more that you can derive this than that you recite a memorized bound.
Q: What's the actual difference between recursion and backtracking?
Backtracking is a specific recursive pattern used for exhaustive search over a space of candidate solutions: choose → explore → un-choose. Not all recursion backtracks (e.g., a simple recursive factorial doesn't "undo" anything), but all backtracking is recursion. The undo step matters because it lets a single shared state object represent every branch of the search without copying it.
Q: How do you avoid duplicate subsets/permutations when the input has duplicate values?
Sort the array first, then at each recursion level skip an element if it equals the previous element and the previous one was not used in the current path (for permutations) or was already considered at this recursion depth (for subsets): if (i > idx && nums[i] == nums[i-1]) continue;. This ensures duplicates are only ever chosen in their original left-to-right order.
Q: Why does the N-Queens diagonal check use row-col+n and row+col instead of tracking diagonals directly?
All cells on the same "/" diagonal share a constant value of row+col, and all cells on the same "\" diagonal share a constant value of row-col. Since row-col can be negative, adding n shifts it into a valid array index range [0, 2n). This gives O(1) conflict checks instead of O(n) diagonal scans.
Q: How would you optimize backtracking when the search space is too large to brute-force?
Add pruning (cut a branch early once it can't possibly succeed — e.g., remaining sum can't reach target), reorder choices to fail fast (most constrained variable first, as in Sudoku's "try the cell with fewest valid candidates first"), or memoize if overlapping subproblems exist (which turns it into DP). Constraint propagation (like Sudoku's naked singles) also drastically shrinks the tree before you even branch.
5. Sorting
Merge Sort Intermediate
Divide-and-conquer: split the array in half, recursively sort each half, merge the two sorted halves. Stable, O(n log n) worst-case guaranteed (unlike quicksort), but needs O(n) auxiliary space.
void merge(std::vector<int>& a, int l, int m, int r) {
std::vector<int> tmp(r - l + 1);
int i = l, j = m + 1, k = 0;
while (i <= m && j <= r) tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++]; // <= keeps it stable
while (i <= m) tmp[k++] = a[i++];
while (j <= r) tmp[k++] = a[j++];
for (int t = 0; t < k; ++t) a[l + t] = tmp[t];
}
void mergeSort(std::vector<int>& a, int l, int r) {
if (l >= r) return;
int m = l + (r - l) / 2;
mergeSort(a, l, m);
mergeSort(a, m + 1, r);
merge(a, l, m, r);
}
// Time: O(n log n) always. Space: O(n) auxiliary + O(log n) recursion stack. Stable.
Quick Sort Intermediate
Pick a pivot, partition the array so smaller elements go left and larger go right, recurse on both sides. In-place (O(log n) stack), average O(n log n), but worst-case O(n²) on adversarial or already-sorted input with a poor pivot choice — mitigate with random pivot selection or median-of-three.
int partition(std::vector<int>& a, int lo, int hi) {
int pivotIdx = lo + rand() % (hi - lo + 1); // randomized pivot avoids worst-case on sorted input
std::swap(a[pivotIdx], a[hi]);
int pivot = a[hi], i = lo;
for (int j = lo; j < hi; ++j) {
if (a[j] < pivot) std::swap(a[i++], a[j]);
}
std::swap(a[i], a[hi]);
return i;
}
void quickSort(std::vector<int>& a, int lo, int hi) {
if (lo >= hi) return;
int p = partition(a, lo, hi);
quickSort(a, lo, p - 1);
quickSort(a, p + 1, hi);
}
// Time: O(n log n) average, O(n²) worst case. Space: O(log n) stack average. Not stable.
Heap Sort Intermediate
Build a max-heap in O(n) (heapify bottom-up), then repeatedly swap the root (max) with the last element and sift down — O(log n) per extraction, n extractions. In-place, but not stable (equal elements can be reordered by heap swaps).
void siftDown(std::vector<int>& a, int n, int i) {
while (true) {
int largest = i, l = 2*i+1, r = 2*i+2;
if (l < n && a[l] > a[largest]) largest = l;
if (r < n && a[r] > a[largest]) largest = r;
if (largest == i) break;
std::swap(a[i], a[largest]);
i = largest;
}
}
void heapSort(std::vector<int>& a) {
int n = a.size();
for (int i = n / 2 - 1; i >= 0; --i) siftDown(a, n, i); // build heap: O(n)
for (int i = n - 1; i > 0; --i) {
std::swap(a[0], a[i]); // move max to the end
siftDown(a, i, 0); // restore heap on the shrunk range
}
}
// Time: O(n log n) always. Space: O(1). Not stable.
Counting Sort Intermediate
For integers in a known small range [0, k], count occurrences of each value, then compute prefix sums to place each element in its final position. Linear time when k = O(n), and stable if implemented via the prefix-sum placement (right-to-left).
std::vector<int> countingSort(std::vector<int>& a, int maxVal) {
std::vector<int> count(maxVal + 1, 0);
for (int x : a) count[x]++;
for (int i = 1; i <= maxVal; ++i) count[i] += count[i - 1]; // prefix sum -> final positions
std::vector<int> out(a.size());
for (int i = (int)a.size() - 1; i >= 0; --i) // right-to-left keeps it stable
out[--count[a[i]]] = a[i];
return out;
}
// Time: O(n + k). Space: O(n + k). Stable.
Radix Sort Advanced
Sorts integers digit by digit (LSD - least significant digit first), using a stable sort (typically counting sort) as a subroutine per digit. Total time O(d·(n+b)) where d is the number of digits and b is the base.
void radixSort(std::vector<int>& a) {
int maxVal = *std::max_element(a.begin(), a.end());
for (int exp = 1; maxVal / exp > 0; exp *= 10) {
std::vector<int> out(a.size());
int count[10] = {0};
for (int x : a) count[(x / exp) % 10]++;
for (int i = 1; i < 10; ++i) count[i] += count[i - 1];
for (int i = (int)a.size() - 1; i >= 0; --i) {
int digit = (a[i] / exp) % 10;
out[--count[digit]] = a[i];
}
a = out; // stable per-digit sort composed d times -> overall stable & correct
}
}
// Time: O(d * (n + 10)) ~ O(n) for fixed-width integers. Space: O(n).
Bucket Sort Intermediate
Distribute elements into k buckets by value range, sort each bucket individually (typically with insertion sort for small buckets), then concatenate. Average O(n + k) when input is uniformly distributed; degrades to O(n²) if all elements land in one bucket.
std::vector<double> bucketSort(std::vector<double>& a) { // values in [0, 1)
int n = a.size();
std::vector<std::vector<double>> buckets(n);
for (double x : a) buckets[(int)(x * n)].push_back(x);
std::vector<double> result;
for (auto& b : buckets) {
std::sort(b.begin(), b.end());
result.insert(result.end(), b.begin(), b.end());
}
return result;
}
Stability & When Interviewers Ask "Implement X Sort" Basic
A sort is stable if elements with equal keys retain their relative input order. This matters when sorting by a secondary key after already sorting by a primary one (or sorting objects where "equal" keys still have meaningfully different payloads). When an interviewer says "implement quicksort/mergesort/heapsort from scratch," they're testing whether you actually understand the mechanism (partition scheme, merge step, heapify) — not whether you can call std::sort. Be ready to trace through a small example by hand and state the recurrence/complexity as you go.
Sorting Algorithms Comparison Basic
| Algorithm | Best | Average | Worst | Space | Stable? | In-place? |
|---|---|---|---|---|---|---|
| Merge Sort | O(n log n) | O(n log n) | O(n log n) | O(n) | Yes | No |
| Quick Sort | O(n log n) | O(n log n) | O(n²) | O(log n) | No | Yes |
| Heap Sort | O(n log n) | O(n log n) | O(n log n) | O(1) | No | Yes |
| Insertion Sort | O(n) | O(n²) | O(n²) | O(1) | Yes | Yes |
| Bubble Sort | O(n) | O(n²) | O(n²) | O(1) | Yes | Yes |
| Counting Sort | O(n+k) | O(n+k) | O(n+k) | O(n+k) | Yes | No |
| Radix Sort | O(d(n+b)) | O(d(n+b)) | O(d(n+b)) | O(n+b) | Yes | No |
| Bucket Sort | O(n+k) | O(n+k) | O(n²) | O(n) | Yes* | No |
std::sort in libstdc++ is actually introsort: quicksort that falls back to heapsort if recursion depth exceeds a threshold (guarding against O(n²) worst case) and switches to insertion sort for small partitions.
Q: Why is std::sort not stable but std::stable_sort is?
std::sort uses introsort (quicksort/heapsort/insertion sort hybrid), and quicksort's swapping partition doesn't preserve relative order of equal elements. std::stable_sort uses a merge-sort-based algorithm (typically in-place merge or with O(n) auxiliary buffer when available), which naturally preserves order — at the cost of needing O(n) extra space in the common case (it degrades to O(n log² n) if allocation fails, guaranteeing O(n log n) is not always possible without space).
Q: Why does quicksort degrade to O(n²) on already-sorted input with a naive pivot?
If you always pick the first or last element as pivot, a sorted (or reverse-sorted) array causes every partition to split into a size-0 and size-(n-1) piece — the recursion depth becomes O(n) instead of O(log n), and total work becomes O(n) + O(n-1) + ... = O(n²). Randomized pivot selection or median-of-three defeats this specific adversarial case (though a sufficiently adversarial input can still be constructed against any fixed strategy — hence introsort's fallback to heapsort).
Q: When would counting sort beat comparison-based sorts?
When the value range k is O(n) or smaller (e.g., sorting exam scores 0–100, or ages), counting sort's O(n+k) beats any comparison sort's Ω(n log n) lower bound. It breaks down when k is very large relative to n (e.g., sorting arbitrary 32-bit integers) — radix sort handles that case by decomposing into digit-sized counting sort passes.
Q: What is the theoretical lower bound for comparison-based sorting, and why?
Ω(n log n). Any comparison sort can be modeled as a binary decision tree with n! leaves (one per possible ordering); a binary tree with n! leaves needs height at least log₂(n!) = Θ(n log n) by Stirling's approximation. This is why counting/radix/bucket sort (which don't compare elements, they use their values directly) can beat it — they operate under different assumptions about the input.
6. Searching
Binary Search Template That Avoids Infinite Loops Basic
The most common source of bugs is inconsistent invariants between lo, hi, and how they update. Below is a robust template: [lo, hi] inclusive, loop while lo <= hi, and every branch strictly shrinks the range.
int binarySearch(std::vector<int>& a, int target) {
int lo = 0, hi = (int)a.size() - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2; // avoids overflow vs (lo+hi)/2
if (a[mid] == target) return mid;
else if (a[mid] < target) lo = mid + 1;
else hi = mid - 1;
}
return -1; // not found
}
// "Find first index where predicate(x) is true" template (predicate is monotonic: F F F T T T)
int findFirstTrue(int lo, int hi, std::function<bool(int)> predicate) {
// invariant: predicate(hi+1) would be true (or hi+1 is out of range)
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (predicate(mid)) hi = mid; // mid could be the answer, keep it in range
else lo = mid + 1; // mid is definitely not the answer
}
return lo; // lo == hi: first index where predicate holds
}
Mixing an inclusive hi (size-1) with an exclusive update style (lo = mid instead of mid+1) is the #1 cause of infinite loops. Pick one convention — inclusive [lo,hi] with lo <= hi, or half-open [lo,hi) with lo < hi — and be consistent about how each branch updates the bound.
"Search on the Answer" Pattern Intermediate
When a problem asks to minimize/maximize some value X, and you can write a fast (usually O(n) or O(n log n)) function canAchieve(X) that is monotonic (true for all X ≥ some threshold, or false beyond it), binary search directly on X instead of the original combinatorial search space. Classic examples: "minimum days to make m bouquets," "split array into k parts minimizing the largest sum," "Koko eating bananas."
// Split array into k subarrays minimizing the largest subarray sum
bool canSplit(std::vector<int>& a, int k, long long maxSum) {
int parts = 1; long long curr = 0;
for (int x : a) {
if (curr + x > maxSum) { ++parts; curr = x; }
else curr += x;
}
return parts <= k;
}
long long minimizeLargestSum(std::vector<int>& a, int k) {
long long lo = *std::max_element(a.begin(), a.end());
long long hi = std::accumulate(a.begin(), a.end(), 0LL);
while (lo < hi) {
long long mid = lo + (hi - lo) / 2;
if (canSplit(a, k, mid)) hi = mid; else lo = mid + 1;
}
return lo;
}
// O(n log(sum)) instead of exponential enumeration of all splits
The signal for this pattern is the phrase "minimize the maximum" or "maximize the minimum" combined with a value range that's large but a cheap feasibility check. If you can answer "is X achievable?" in less time than trying every X, binary search on the answer.
Ternary Search for Unimodal Functions Advanced
For a strictly unimodal function (increases then decreases, or vice versa — one peak/valley, no plateaus), ternary search finds the extremum in O(log n) by evaluating two interior points per iteration and discarding a third of the range.
double ternarySearchMax(double lo, double hi, std::function<double(double)> f) {
for (int iter = 0; iter < 200; ++iter) { // fixed iterations for floating point
double m1 = lo + (hi - lo) / 3;
double m2 = hi - (hi - lo) / 3;
if (f(m1) < f(m2)) lo = m1; else hi = m2;
}
return (lo + hi) / 2;
}
// O(log((hi-lo)/eps)) — each iteration shrinks the range by 1/3
Ternary search requires strict unimodality — a flat plateau at the peak breaks the comparison logic and can converge to the wrong point. For integer domains with plateaus, prefer binary search on the derivative's sign (find where f(x+1) - f(x) changes sign) instead.
Q: How do you decide whether to binary search on the array index or on the answer's value range?
Binary search on index when the array itself is sorted and you're locating a specific element or boundary within it. Binary search on the answer's value range when the array isn't necessarily sorted by the quantity you care about, but you can write a monotonic yes/no feasibility check for any candidate answer value — the search space becomes the answer domain, not the input.
Q: Why use lo + (hi - lo) / 2 instead of (lo + hi) / 2?
If lo and hi are both large (close to INT_MAX), lo + hi can overflow a 32-bit int before the division happens. lo + (hi - lo) / 2 avoids the intermediate overflow since (hi - lo) is bounded by the array size, not by the magnitude of lo/hi.
Q: Can you binary search on a rotated sorted array?
Yes. At each step, at least one half [lo, mid] or [mid, hi] is guaranteed to be normally sorted (compare a[lo] vs a[mid]). Determine which half is sorted, check if the target lies within that half's range, and recurse into the appropriate half — still O(log n).
7. Linked Lists
Reverse a Linked List (Iterative & Recursive) Basic
struct ListNode { int val; ListNode* next; ListNode(int v): val(v), next(nullptr) {} };
// Iterative: O(n) time, O(1) space
ListNode* reverseIterative(ListNode* head) {
ListNode* prev = nullptr;
while (head) {
ListNode* next = head->next;
head->next = prev;
prev = head;
head = next;
}
return prev;
}
// Recursive: O(n) time, O(n) space (call stack)
ListNode* reverseRecursive(ListNode* head) {
if (!head || !head->next) return head;
ListNode* newHead = reverseRecursive(head->next);
head->next->next = head;
head->next = nullptr;
return newHead;
}
Fast & Slow Pointer Basic
Two pointers move through the list at different speeds (typically 1x and 2x). Used to find the middle node, detect cycles, and find the k-th node from the end — all in a single pass without knowing the list length up front.
ListNode* findMiddle(ListNode* head) {
ListNode* slow = head; ListNode* fast = head;
while (fast && fast->next) { slow = slow->next; fast = fast->next->next; }
return slow; // second middle node for even-length lists
}
Floyd's Cycle Detection + Finding the Cycle Start Intermediate
If a fast pointer (2 steps) and slow pointer (1 step) ever meet, there's a cycle. To find the cycle's start: after they meet, reset one pointer to head and advance both one step at a time — they meet exactly at the cycle's entry point. This works due to the modular arithmetic relationship between the distance to the cycle start and the cycle length.
ListNode* detectCycleStart(ListNode* head) {
ListNode* slow = head; ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) { // cycle detected
ListNode* ptr = head;
while (ptr != slow) { ptr = ptr->next; slow = slow->next; }
return ptr; // cycle entry point
}
}
return nullptr; // no cycle
}
// O(n) time, O(1) space
Why does resetting one pointer to head work? Let distance from head to cycle start = a, cycle length = c, and the meeting point be b steps into the cycle. When slow and fast meet, slow has traveled a+b, and fast has traveled 2(a+b) = a+b+n·c for some integer n, giving a+b = n·c, so a = n·c − b. Walking b more steps from the meeting point plus a steps lands exactly on the cycle start, which is why walking 'a' from head and 'a' from meeting point (mod c) sync up.
Merge K Sorted Lists Advanced
Use a min-heap holding the current head of each list. Repeatedly pop the smallest, append to result, push its successor. O(N log k) where N is total nodes across all lists and k is the number of lists.
ListNode* mergeKLists(std::vector<ListNode*>& lists) {
auto cmp = [](ListNode* a, ListNode* b) { return a->val > b->val; }; // min-heap
std::priority_queue<ListNode*, std::vector<ListNode*>, decltype(cmp)> pq(cmp);
for (auto* node : lists) if (node) pq.push(node);
ListNode dummy(0);
ListNode* tail = &dummy;
while (!pq.empty()) {
ListNode* node = pq.top(); pq.pop();
tail->next = node;
tail = node;
if (node->next) pq.push(node->next);
}
return dummy.next;
}
// O(N log k) time, O(k) space for the heap
LRU Cache — list + hashmap (Full Implementation) Advanced
Combine a doubly-linked list (tracks recency order, most-recently-used at front) with a hashmap (key → list iterator, for O(1) lookup). Every get/put moves the accessed node to the front; eviction removes the tail.
class LRUCache {
int capacity;
std::list<std::pair<int, int>> order; // {key, value}, front = most recently used
std::unordered_map<int, std::list<std::pair<int,int>>::iterator> index;
public:
LRUCache(int cap) : capacity(cap) {}
int get(int key) {
auto it = index.find(key);
if (it == index.end()) return -1;
order.splice(order.begin(), order, it->second); // move to front, O(1)
return it->second->second;
}
void put(int key, int value) {
auto it = index.find(key);
if (it != index.end()) {
it->second->second = value;
order.splice(order.begin(), order, it->second);
return;
}
if ((int)order.size() == capacity) {
auto last = order.back();
index.erase(last.first);
order.pop_back();
}
order.emplace_front(key, value);
index[key] = order.begin();
}
};
// get/put: O(1) average time (hashmap lookup + O(1) list splice), O(capacity) space
std::list::splice moves a node between positions (or within the same list) in O(1) without invalidating any iterators — this is exactly why list+hashmap is the canonical LRU implementation rather than vector+hashmap (which would need O(n) shifting).
Q: Why is a sentinel/dummy node useful when manipulating linked lists?
It eliminates special-casing the head: without a dummy, inserting/deleting at the head requires separate logic from inserting/deleting elsewhere. With a dummy node pointing to the real head, every operation (including modifying the "head") is just a normal next-pointer update, simplifying code and reducing edge-case bugs.
Q: How do you find the k-th node from the end of a linked list in one pass?
Advance a lead pointer k steps first, then move both lead and a trail pointer together until lead reaches the end — trail is now at the k-th-from-last node. This avoids needing to know the list length up front (which would require two passes).
Q: Why does merging k sorted lists with a heap beat merging them pairwise?
Naive pairwise merging (merge list 1&2, then merge result with list 3, etc.) does O(N) work k-1 times in the worst case (each merge touches nearly all nodes seen so far), giving O(N·k). The heap approach does O(log k) work per node exactly once across all N nodes, giving O(N log k) — better when k is large relative to per-list length.
Q: Why not implement LRU cache with just an unordered_map storing timestamps?
You'd need to find the minimum timestamp to evict, which is O(n) per eviction without an auxiliary ordered structure (a sorted map of timestamps would bring it to O(log n), still worse than the O(1) list+hashmap approach). The linked list turns "find and remove the least-recently-used item" into an O(1) operation by physically maintaining recency order.
8. Stacks & Queues
Monotonic Stack — Next Greater / Smaller Element Intermediate
A monotonic stack keeps its elements in strictly increasing or decreasing order. When a new element would violate that order, pop elements until it doesn't — each pop resolves a "next greater/smaller" answer for the popped element. Every element is pushed and popped at most once, giving O(n) total despite the nested-looking while loop.
std::vector<int> nextGreaterElement(std::vector<int>& a) {
int n = a.size();
std::vector<int> result(n, -1);
std::stack<int> st; // stores indices, values decreasing bottom-to-top
for (int i = 0; i < n; ++i) {
while (!st.empty() && a[st.top()] < a[i]) {
result[st.top()] = a[i]; // a[i] is the next greater element for st.top()
st.pop();
}
st.push(i);
}
return result;
}
// O(n) time (amortized, each index pushed/popped once), O(n) space
Sliding Window Maximum via Deque Advanced
Maintain a deque of indices whose corresponding values are in decreasing order — the front is always the current window's maximum. On each step: pop from the back while the new element is larger (they can never be the max while the new element is in the window), pop from the front if it's fallen out of the window.
std::vector<int> maxSlidingWindow(std::vector<int>& a, int k) {
std::deque<int> dq; // stores indices, values decreasing front-to-back
std::vector<int> result;
for (int i = 0; i < (int)a.size(); ++i) {
while (!dq.empty() && a[dq.back()] < a[i]) dq.pop_back();
dq.push_back(i);
if (dq.front() <= i - k) dq.pop_front(); // out of window
if (i >= k - 1) result.push_back(a[dq.front()]);
}
return result;
}
// O(n) time — each index enters/leaves the deque at most once
Implement Queue Using Two Stacks (and Vice Versa) Intermediate
class QueueUsingStacks {
std::stack<int> inStack, outStack;
void transfer() {
if (outStack.empty())
while (!inStack.empty()) { outStack.push(inStack.top()); inStack.pop(); }
}
public:
void push(int x) { inStack.push(x); } // O(1)
int pop() { transfer(); int v = outStack.top(); outStack.pop(); return v; } // amortized O(1)
int peek() { transfer(); return outStack.top(); } // amortized O(1)
bool empty() { return inStack.empty() && outStack.empty(); }
};
// Each element is moved between stacks at most once -> amortized O(1) per op
class StackUsingQueues {
std::queue<int> q;
public:
void push(int x) {
q.push(x);
for (int i = 0; i < (int)q.size() - 1; ++i) { q.push(q.front()); q.pop(); } // rotate
} // O(n) push, O(1) pop/top -- makes the most recent element always the front
int pop() { int v = q.front(); q.pop(); return v; }
int top() { return q.front(); }
};
Min Stack — O(1) getMin Intermediate
Maintain an auxiliary stack that tracks the minimum "so far" at each level, pushed/popped in lockstep with the main stack.
class MinStack {
std::stack<int> st, minSt;
public:
void push(int x) {
st.push(x);
minSt.push(minSt.empty() ? x : std::min(x, minSt.top()));
}
void pop() { st.pop(); minSt.pop(); }
int top() { return st.top(); }
int getMin() { return minSt.top(); } // O(1)
};
// Space-optimized variant: push only when x <= current min, store a counter for duplicates
Q: Why is the monotonic stack's total complexity O(n) despite the nested while loop?
Each element is pushed onto the stack exactly once and popped at most once across the entire algorithm's execution — so total push+pop operations are bounded by 2n, regardless of how the while loop is nested inside the for loop. This is the standard amortized analysis argument (aggregate method).
Q: When would you use a monotonic stack vs a sliding window deque?
Monotonic stack: "next greater/smaller element," histogram-style problems (largest rectangle in histogram), problems about relative order without a fixed window size. Sliding window deque: when you specifically need the max/min within a fixed-size or shrinking/growing contiguous window — the deque additionally tracks positional validity (elements falling out of the window), which a plain stack doesn't do.
Q: In the min stack, why not just track a single "current minimum" variable instead of a second stack?
Because pop() can remove the current minimum, and a single variable has no way to recover what the minimum was before that element was pushed. The auxiliary stack effectively remembers the minimum "at every historical depth," so popping the main stack and popping the min-stack in lockstep always restores the correct prior minimum.
Q: What's the amortized cost of push in the two-stacks queue implementation?
push itself is O(1) (just push onto inStack). The transfer from inStack to outStack only happens when outStack is empty, and each element is moved at most once total across its lifetime in the queue — so pop/peek are amortized O(1) even though a single call can occasionally cost O(n) during a transfer.
9. Trees
Traversals — Recursive, Iterative, Morris (O(1) space) Basic
struct TreeNode { int val; TreeNode *left, *right; TreeNode(int v): val(v), left(nullptr), right(nullptr) {} };
// Recursive inorder: O(n) time, O(h) space (call stack, h = height)
void inorderRec(TreeNode* root, std::vector<int>& out) {
if (!root) return;
inorderRec(root->left, out);
out.push_back(root->val);
inorderRec(root->right, out);
}
// Iterative inorder with explicit stack: O(n) time, O(h) space
std::vector<int> inorderIterative(TreeNode* root) {
std::vector<int> out;
std::stack<TreeNode*> st;
TreeNode* curr = root;
while (curr || !st.empty()) {
while (curr) { st.push(curr); curr = curr->left; }
curr = st.top(); st.pop();
out.push_back(curr->val);
curr = curr->right;
}
return out;
}
Morris Traversal achieves O(1) auxiliary space by temporarily threading the tree: for each node with a left subtree, find its inorder predecessor (rightmost node of the left subtree) and link its right pointer back to the current node, using that thread to return without a stack, then removing the thread once used.
std::vector<int> morrisInorder(TreeNode* root) {
std::vector<int> out;
TreeNode* curr = root;
while (curr) {
if (!curr->left) {
out.push_back(curr->val);
curr = curr->right;
} else {
TreeNode* pred = curr->left;
while (pred->right && pred->right != curr) pred = pred->right;
if (!pred->right) {
pred->right = curr; // create the thread
curr = curr->left;
} else {
pred->right = nullptr; // remove the thread (restore tree)
out.push_back(curr->val);
curr = curr->right;
}
}
}
return out;
}
// O(n) time (each edge traversed at most twice), O(1) extra space
BST Operations Basic
TreeNode* search(TreeNode* root, int key) {
while (root && root->val != key)
root = key < root->val ? root->left : root->right;
return root; // O(h): O(log n) balanced, O(n) worst case (degenerate/skewed)
}
TreeNode* insert(TreeNode* root, int key) {
if (!root) return new TreeNode(key);
if (key < root->val) root->left = insert(root->left, key);
else if (key > root->val) root->right = insert(root->right, key);
return root;
}
TreeNode* deleteNode(TreeNode* root, int key) {
if (!root) return nullptr;
if (key < root->val) root->left = deleteNode(root->left, key);
else if (key > root->val) root->right = deleteNode(root->right, key);
else { // found the node to delete
if (!root->left) return root->right;
if (!root->right) return root->left;
TreeNode* succ = root->right; // inorder successor = min of right subtree
while (succ->left) succ = succ->left;
root->val = succ->val;
root->right = deleteNode(root->right, succ->val);
}
return root;
}
Balanced Trees — AVL Rotations & Red-Black Properties (Conceptual) Intermediate
A plain BST degrades to O(n) height on sorted/adversarial insertions. Self-balancing trees restore O(log n) height guarantees:
AVL Tree: maintains the invariant that for every node, the heights of its left and right subtrees differ by at most 1. After every insert/delete, rebalance via rotations:
- Right rotation — fixes a left-heavy imbalance (left-left case).
- Left rotation — fixes a right-heavy imbalance (right-right case).
- Left-Right / Right-Left rotations — composite fixes for zig-zag imbalances.
// Right rotation around node y (y is left-heavy)
// y x
// / \ / \
// x T3 -> T1 y
// / \ / \
// T1 T2 T2 T3
Node* rotateRight(Node* y) {
Node* x = y->left;
Node* T2 = x->right;
x->right = y;
y->left = T2;
updateHeight(y); updateHeight(x); // heights must be recomputed bottom-up
return x; // new subtree root
}
Red-Black Tree: the backing structure for std::map/std::set. Every node is colored red or black, satisfying: (1) root is black, (2) red nodes never have red children, (3) every root-to-null path has the same number of black nodes. These invariants bound the height at 2·log₂(n+1), giving O(log n) guarantees with fewer rotations per insert/delete than AVL (AVL is more rigidly balanced — faster lookups, but more rebalancing on writes; red-black trees favor faster average insert/delete, which is why STL prefers them for general-purpose ordered containers).
Segment Tree (Range Sum / Min, Lazy Propagation) Advanced
A binary tree over array indices where each node stores an aggregate (sum, min, max, etc.) of a range. Supports O(log n) range queries and point/range updates. Lazy propagation defers pending range updates to children until they're actually needed, enabling O(log n) range updates too (instead of O(n)).
class SegmentTree { // range sum with range-add lazy propagation
int n;
std::vector<long long> tree, lazy;
void push_down(int node, int l, int r) {
if (lazy[node] == 0) return;
int mid = (l + r) / 2;
apply(2*node, l, mid, lazy[node]);
apply(2*node+1, mid+1, r, lazy[node]);
lazy[node] = 0;
}
void apply(int node, int l, int r, long long val) {
tree[node] += val * (r - l + 1);
lazy[node] += val;
}
public:
SegmentTree(int size) : n(size), tree(4*size, 0), lazy(4*size, 0) {}
void update(int node, int l, int r, int ql, int qr, long long val) {
if (qr < l || r < ql) return;
if (ql <= l && r <= qr) { apply(node, l, r, val); return; }
push_down(node, l, r);
int mid = (l + r) / 2;
update(2*node, l, mid, ql, qr, val);
update(2*node+1, mid+1, r, ql, qr, val);
tree[node] = tree[2*node] + tree[2*node+1];
}
long long query(int node, int l, int r, int ql, int qr) {
if (qr < l || r < ql) return 0;
if (ql <= l && r <= qr) return tree[node];
push_down(node, l, r);
int mid = (l + r) / 2;
return query(2*node, l, mid, ql, qr) + query(2*node+1, mid+1, r, ql, qr);
}
};
// Build: O(n). Range update / range query: O(log n) each. Space: O(n)
Fenwick Tree / Binary Indexed Tree Advanced
A more compact alternative to segment trees for prefix-sum-style queries, using the binary representation of indices (i & -i isolates the lowest set bit) to jump between "responsible ranges" in O(log n). Simpler to code than a segment tree, but limited to invertible operations (sum, XOR) unless extended.
class Fenwick {
std::vector<long long> tree;
int n;
public:
Fenwick(int size) : n(size), tree(size + 1, 0) {}
void update(int i, long long delta) { // add delta at index i (1-indexed)
for (; i <= n; i += i & (-i)) tree[i] += delta;
}
long long prefixSum(int i) { // sum of [1, i]
long long sum = 0;
for (; i > 0; i -= i & (-i)) sum += tree[i];
return sum;
}
long long rangeSum(int l, int r) { return prefixSum(r) - prefixSum(l - 1); }
};
// update / prefixSum: O(log n) each. Space: O(n). Build from array: O(n log n) or O(n) with a trick.
Trie — Insert / Search / Prefix / Word Break Intermediate
A tree where each edge represents a character; paths from root spell out inserted strings. Insert/search are O(L) where L is the string length, independent of how many strings are stored — much faster than hashing every string for prefix queries.
struct TrieNode {
TrieNode* children[26] = {};
bool isEnd = false;
};
class Trie {
TrieNode* root = new TrieNode();
public:
void insert(const std::string& word) {
TrieNode* node = root;
for (char c : word) {
int idx = c - 'a';
if (!node->children[idx]) node->children[idx] = new TrieNode();
node = node->children[idx];
}
node->isEnd = true;
}
bool search(const std::string& word) {
TrieNode* node = find(word);
return node && node->isEnd;
}
bool startsWith(const std::string& prefix) { return find(prefix) != nullptr; }
private:
TrieNode* find(const std::string& s) {
TrieNode* node = root;
for (char c : s) {
int idx = c - 'a';
if (!node->children[idx]) return nullptr;
node = node->children[idx];
}
return node;
}
};
// Word Break: can s be segmented into dictionary words? DP + trie/set, O(n^2)
bool wordBreak(const std::string& s, std::unordered_set<std::string>& dict) {
int n = s.size();
std::vector<bool> dp(n + 1, false);
dp[0] = true;
for (int i = 1; i <= n; ++i)
for (int j = 0; j < i; ++j)
if (dp[j] && dict.count(s.substr(j, i - j))) { dp[i] = true; break; }
return dp[n];
}
Lowest Common Ancestor — Binary Lifting & Euler Tour / Sparse Table Advanced
Binary lifting: precompute up[k][v] = the 2^k-th ancestor of v, in O(n log n). To find LCA(u,v): lift the deeper node to the same depth, then simultaneously lift both nodes by decreasing powers of 2 until they're about to meet. Each query is O(log n).
const int LOG = 20;
std::vector<std::vector<int>> up; // up[k][v] = 2^k-th ancestor of v
std::vector<int> depth;
void dfs(int v, int parent, std::vector<std::vector<int>>& adj) {
up[0][v] = parent;
for (int k = 1; k < LOG; ++k)
up[k][v] = (up[k-1][v] == -1) ? -1 : up[k-1][up[k-1][v]];
for (int u : adj[v]) if (u != parent) { depth[u] = depth[v] + 1; dfs(u, v, adj); }
}
int lca(int u, int v) {
if (depth[u] < depth[v]) std::swap(u, v);
int diff = depth[u] - depth[v];
for (int k = 0; k < LOG; ++k) if (diff & (1 << k)) u = up[k][u];
if (u == v) return u;
for (int k = LOG - 1; k >= 0; --k)
if (up[k][u] != up[k][v]) { u = up[k][u]; v = up[k][v]; }
return up[0][u];
}
// Preprocess: O(n log n). Query: O(log n).
Euler Tour + Sparse Table (RMQ method): flatten the tree via a DFS Euler tour (recording each node every time it's visited/re-visited), reducing LCA to a Range Minimum Query over the tour's depth array. With a sparse table for O(1) RMQ (see Advanced Topics), this gives O(n log n) preprocessing and O(1) per query — faster queries than binary lifting at the cost of more memory.
Diameter of a Tree Intermediate
The diameter (longest path between any two nodes) can be computed in a single DFS: for each node, the longest path through it is the sum of the two largest depths among its children's subtrees; track the maximum such sum globally while also returning the single longest depth to the parent.
int diameter = 0;
int height(TreeNode* node) {
if (!node) return 0;
int lh = height(node->left);
int rh = height(node->right);
diameter = std::max(diameter, lh + rh); // path through this node
return 1 + std::max(lh, rh); // height to report upward
}
// O(n) time, O(h) space — a single post-order pass, not O(n^2) recomputation
For a general (non-binary) tree, diameter can also be found without DP using two BFS/DFS passes: BFS from any node to find the farthest node A, then BFS from A to find the farthest node B — the distance A→B is the diameter. This works because of a graph-theoretic property of trees (works for weighted trees too, with Dijkstra/BFS as appropriate), but only for trees, not general graphs.
Serialize / Deserialize Binary Tree Intermediate
std::string serialize(TreeNode* root) {
if (!root) return "#,";
return std::to_string(root->val) + "," + serialize(root->left) + serialize(root->right);
}
TreeNode* deserializeHelper(std::istringstream& iss) {
std::string token;
std::getline(iss, token, ',');
if (token == "#") return nullptr;
TreeNode* node = new TreeNode(std::stoi(token));
node->left = deserializeHelper(iss);
node->right = deserializeHelper(iss);
return node;
}
TreeNode* deserialize(const std::string& data) {
std::istringstream iss(data);
return deserializeHelper(iss);
}
// Preorder + null markers uniquely determines the tree shape. O(n) time and space.
Q: Why does Morris traversal achieve O(1) space, and what's the tradeoff?
It temporarily mutates the tree's right pointers to create "threads" back to ancestor nodes, replacing what a stack or recursion would otherwise track. The tradeoff: it temporarily modifies the tree structure (restored by the end), which is unsafe for concurrent reads and slightly harder to reason about/debug than the stack-based version — most interviewers accept O(h) stack space as "good enough" unless O(1) is explicitly required.
Q: Why does std::map use red-black trees instead of AVL trees?
Red-black trees require fewer rotations on average for insertions and deletions (AVL's stricter balance factor means more frequent rebalancing), which favors write-heavy general-purpose workloads. AVL trees are more strictly balanced, giving slightly faster lookups — better for read-heavy, write-rare workloads. STL containers are designed for general use, so red-black trees are the standard choice.
Q: When would you choose a Fenwick tree over a segment tree?
When your operation is invertible (sum, XOR) and you only need point updates + prefix/range queries — Fenwick trees are simpler to code, use less memory, and have smaller constant factors. Choose a segment tree when you need range updates with lazy propagation, non-invertible aggregates (min/max/gcd), or more complex queries — Fenwick trees can be extended for range updates but the code gets less natural than a segment tree's.
Q: How would you find the LCA of two nodes if the tree were a general DAG rather than a tree?
LCA is only well-defined for trees (or forests) where there's a unique path between any two nodes. In a DAG, a node can have multiple parents, so "lowest common ancestor" needs redefinition (e.g., "lowest single common ancestor" isn't guaranteed to be unique) — you'd typically compute all common ancestors via reachability and then pick one by some tie-breaking rule, which is a fundamentally different and harder problem.
Q: What invariant does a Trie exploit to make prefix search faster than a hash set of strings?
A hash set can only tell you if an exact string exists — checking "does any word start with prefix X" requires iterating all stored words, O(total characters). A Trie shares common prefixes as shared paths, so checking a prefix is just O(L) — walk L characters down the tree — regardless of how many words are stored, because the "search" is structural, not a scan.
10. Heaps
Priority Queue Applications Basic
A heap is the go-to structure whenever you repeatedly need "the current min/max" while the set of candidates keeps changing — scheduling (earliest deadline first), Dijkstra's shortest path, Huffman coding, event simulation, and any "top-K" style problem.
K-Way Merge Intermediate
Generalizes merging two sorted sequences to k of them: keep one min-heap of size k holding the current frontier element of each sequence. Pop the min, push the next element from that same sequence. See the "Merge K Sorted Lists" implementation in the Linked Lists section — the identical idea applies to k sorted arrays, k sorted files (external sort), or k sorted streams.
Running Median with Two Heaps Advanced
Maintain a max-heap for the lower half of numbers seen so far and a min-heap for the upper half, keeping their sizes balanced (differing by at most 1). The median is then O(1) to read from the top(s); each insertion is O(log n).
class MedianFinder {
std::priority_queue<int> lower; // max-heap, lower half
std::priority_queue<int, std::vector<int>, std::greater<int>> upper; // min-heap, upper half
public:
void addNum(int num) {
if (lower.empty() || num <= lower.top()) lower.push(num);
else upper.push(num);
// rebalance so sizes differ by at most 1
if (lower.size() > upper.size() + 1) { upper.push(lower.top()); lower.pop(); }
else if (upper.size() > lower.size() + 1) { lower.push(upper.top()); upper.pop(); }
}
double findMedian() {
if (lower.size() == upper.size()) return (lower.top() + upper.top()) / 2.0;
return lower.size() > upper.size() ? lower.top() : upper.top();
}
};
// addNum: O(log n). findMedian: O(1).
Top-K Elements Intermediate
Maintain a heap of size k (min-heap for "top-k largest," max-heap for "top-k smallest"). For each new element: if the heap has fewer than k elements, push it; otherwise, push it and pop the extreme only if the new element improves on the current worst-of-the-top-k. This gives O(n log k) instead of O(n log n) full sort — significant when k ≪ n.
std::vector<int> topKLargest(std::vector<int>& nums, int k) {
std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap; // size-k min-heap
for (int x : nums) {
minHeap.push(x);
if ((int)minHeap.size() > k) minHeap.pop(); // discard the smallest
}
std::vector<int> result;
while (!minHeap.empty()) { result.push_back(minHeap.top()); minHeap.pop(); }
return result; // O(n log k) time, O(k) space
}
// Alternative: quickselect gives average O(n) if you only need the values, not sorted order
If asked for O(n) average-case "top-K" (or "k-th largest element") rather than O(n log k), mention quickselect (partition-based selection, same partitioning idea as quicksort but recursing into only one side) — it's the textbook follow-up when an interviewer pushes for a faster bound.
Q: Why is top-K with a heap O(n log k) instead of O(n log n)?
Because the heap never grows beyond size k — every push/pop operates on a heap of at most k elements, costing O(log k), and you do this for each of the n input elements. Sorting the entire array costs O(n log n) regardless of k, which is worse when k is much smaller than n.
Q: Why use a max-heap for the lower half and min-heap for the upper half in the running median, not the other way around?
You need O(1) access to the boundary values closest to the median: the largest of the lower half and the smallest of the upper half. A max-heap gives O(1) access to its largest element (the top), and a min-heap gives O(1) access to its smallest — exactly the two values needed to compute the median, whichever heap is larger (odd count) or averaging both tops (even count).
Q: How would you find the K closest points to the origin?
Same top-K pattern: maintain a max-heap of size k keyed on squared distance (avoid sqrt for speed/precision). Push each point's distance; if heap size exceeds k, pop the farthest. At the end, the heap holds the k closest points, in O(n log k) time.
11. Graphs
BFS / DFS (Adjacency List) Basic
std::vector<std::vector<int>> adj; // adj[u] = list of neighbors
std::vector<int> bfs(int start, int n) {
std::vector<int> dist(n, -1);
std::queue<int> q;
dist[start] = 0; q.push(start);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) { dist[v] = dist[u] + 1; q.push(v); }
}
}
return dist; // shortest distances in an UNWEIGHTED graph
}
std::vector<bool> visited;
void dfs(int u) {
visited[u] = true;
for (int v : adj[u]) if (!visited[v]) dfs(v);
}
// Both: O(V + E) time, O(V) space
Topological Sort — Kahn's & DFS-Based Intermediate
// Kahn's algorithm (BFS on in-degrees)
std::vector<int> topoSortKahn(int n, std::vector<std::vector<int>>& adj) {
std::vector<int> indeg(n, 0);
for (int u = 0; u < n; ++u) for (int v : adj[u]) indeg[v]++;
std::queue<int> q;
for (int u = 0; u < n; ++u) if (indeg[u] == 0) q.push(u);
std::vector<int> order;
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : adj[u]) if (--indeg[v] == 0) q.push(v);
}
if ((int)order.size() != n) return {}; // cycle detected -> no valid topo order
return order;
}
// DFS-based (post-order, then reverse)
void dfsTopo(int u, std::vector<std::vector<int>>& adj, std::vector<bool>& visited, std::vector<int>& order) {
visited[u] = true;
for (int v : adj[u]) if (!visited[v]) dfsTopo(v, adj, visited, order);
order.push_back(u); // post-order
}
std::vector<int> topoSortDFS(int n, std::vector<std::vector<int>>& adj) {
std::vector<bool> visited(n, false);
std::vector<int> order;
for (int u = 0; u < n; ++u) if (!visited[u]) dfsTopo(u, adj, visited, order);
std::reverse(order.begin(), order.end());
return order;
}
// Both: O(V + E) time
Union-Find / DSU (Path Compression + Union by Rank) Intermediate
Maintains a partition of elements into disjoint sets, supporting near-O(1) find and union. Path compression flattens the tree during find; union by rank/size attaches the smaller tree under the larger one's root. Together they give O(α(n)) amortized per operation, where α is the inverse Ackermann function — effectively constant for any realistic n.
class DSU {
std::vector<int> parent, rank_;
public:
DSU(int n) : parent(n), rank_(n, 0) {
std::iota(parent.begin(), parent.end(), 0); // parent[i] = i
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // path compression
return parent[x];
}
bool unite(int a, int b) {
a = find(a); b = find(b);
if (a == b) return false; // already in the same set (would form a cycle)
if (rank_[a] < rank_[b]) std::swap(a, b);
parent[b] = a;
if (rank_[a] == rank_[b]) rank_[a]++;
return true;
}
};
// find/unite: O(alpha(n)) amortized, effectively O(1). Space: O(n)
Minimum Spanning Tree — Kruskal & Prim Intermediate
// Kruskal's: sort edges, greedily add if it connects two different components (via DSU)
long long kruskal(int n, std::vector<std::array<int,3>>& edges) { // {weight, u, v}
std::sort(edges.begin(), edges.end());
DSU dsu(n);
long long total = 0;
for (auto& [w, u, v] : edges)
if (dsu.unite(u, v)) total += w;
return total;
}
// O(E log E) for sorting + O(E * alpha(n)) for DSU ops
// Prim's: grow the tree one cheapest crossing edge at a time, via a min-heap
long long prim(int n, std::vector<std::vector<std::pair<int,int>>>& adj) { // adj[u] = {v, weight}
std::vector<bool> inMST(n, false);
std::priority_queue<std::pair<int,int>, std::vector<std::pair<int,int>>, std::greater<>> pq; // {weight, node}
pq.push({0, 0});
long long total = 0;
int count = 0;
while (!pq.empty() && count < n) {
auto [w, u] = pq.top(); pq.pop();
if (inMST[u]) continue;
inMST[u] = true; total += w; ++count;
for (auto& [v, wt] : adj[u]) if (!inMST[v]) pq.push({wt, v});
}
return total;
}
// O(E log V) with a binary heap
Kruskal's is typically preferred for sparse graphs (edge-list friendly, easy with DSU); Prim's is typically preferred for dense graphs (adjacency-matrix friendly, or when you're already doing Dijkstra-style exploration). Both are greedy algorithms whose correctness relies on the Cut Property of MSTs.
Dijkstra's Shortest Path (priority_queue) Intermediate
Greedily finalizes the shortest distance to the closest unvisited node at each step, using a min-heap keyed on tentative distance. Requires non-negative edge weights.
std::vector<long long> dijkstra(int start, int n, std::vector<std::vector<std::pair<int,int>>>& adj) {
std::vector<long long> dist(n, LLONG_MAX);
std::priority_queue<std::pair<long long,int>, std::vector<std::pair<long long,int>>, std::greater<>> pq;
dist[start] = 0;
pq.push({0, start});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue; // stale entry, skip (lazy deletion)
for (auto& [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
return dist;
}
// O((V + E) log V) with a binary heap and lazy deletion
Bellman-Ford (Negative Edges) Intermediate
Relaxes every edge V-1 times; guaranteed to converge because the shortest simple path has at most V-1 edges. Handles negative edge weights (unlike Dijkstra), and a V-th relaxation pass that still improves any distance indicates a negative-weight cycle reachable from the source.
struct Edge { int u, v, w; };
std::pair<std::vector<long long>, bool> bellmanFord(int start, int n, std::vector<Edge>& edges) {
std::vector<long long> dist(n, LLONG_MAX);
dist[start] = 0;
for (int i = 0; i < n - 1; ++i)
for (auto& [u, v, w] : edges)
if (dist[u] != LLONG_MAX && dist[u] + w < dist[v])
dist[v] = dist[u] + w;
bool hasNegCycle = false;
for (auto& [u, v, w] : edges) // one extra pass to detect negative cycles
if (dist[u] != LLONG_MAX && dist[u] + w < dist[v]) { hasNegCycle = true; break; }
return {dist, hasNegCycle};
}
// O(V * E) time, O(V) space
Floyd-Warshall (All-Pairs Shortest Paths) Intermediate
Dynamic programming over "allowed intermediate nodes": dist[i][j] is progressively improved by considering each node k as a possible waypoint. Handles negative edges (but not negative cycles reachable between the pair being queried).
void floydWarshall(std::vector<std::vector<long long>>& dist, int n) {
// dist[i][j] pre-initialized to edge weight or INF (0 if i==j)
for (int k = 0; k < n; ++k)
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j)
if (dist[i][k] < LLONG_MAX && dist[k][j] < LLONG_MAX)
dist[i][j] = std::min(dist[i][j], dist[i][k] + dist[k][j]);
}
// O(V^3) time, O(V^2) space -- only practical for V up to a few hundred
Strongly Connected Components — Kosaraju's & Tarjan's Advanced
Kosaraju's: (1) DFS the graph, recording finish order (post-order). (2) Reverse all edges. (3) DFS the reversed graph in decreasing finish-order; each DFS tree is one SCC.
void dfs1(int u, std::vector<std::vector<int>>& adj, std::vector<bool>& vis, std::vector<int>& order) {
vis[u] = true;
for (int v : adj[u]) if (!vis[v]) dfs1(v, adj, vis, order);
order.push_back(u);
}
void dfs2(int u, std::vector<std::vector<int>>& radj, std::vector<bool>& vis, std::vector<int>& comp) {
vis[u] = true; comp.push_back(u);
for (int v : radj[u]) if (!vis[v]) dfs2(v, radj, vis, comp);
}
std::vector<std::vector<int>> kosaraju(int n, std::vector<std::vector<int>>& adj, std::vector<std::vector<int>>& radj) {
std::vector<bool> vis(n, false);
std::vector<int> order;
for (int i = 0; i < n; ++i) if (!vis[i]) dfs1(i, adj, vis, order);
std::fill(vis.begin(), vis.end(), false);
std::vector<std::vector<int>> sccs;
for (int i = n - 1; i >= 0; --i) {
int u = order[i];
if (!vis[u]) { std::vector<int> comp; dfs2(u, radj, vis, comp); sccs.push_back(comp); }
}
return sccs;
}
// O(V + E) time, two DFS passes plus building the reverse graph
Tarjan's: a single DFS pass using discovery times and "low-link" values (the lowest discovery time reachable from a node via its subtree, including at most one back edge). A node is the root of an SCC when its low-link equals its own discovery time; maintain an explicit stack of nodes currently "in progress" to pop off as each SCC completes. Also O(V + E), single pass — often preferred in competitive programming for being one DFS instead of two.
Bridges & Articulation Points Advanced
Both use the same discovery-time/low-link DFS machinery as Tarjan's SCC. An edge (u,v) is a bridge if removing it disconnects the graph — true when low[v] > disc[u] (v's subtree can't reach u or anything before u without this edge). A node u is an articulation point if removing it disconnects the graph — true when it's the DFS root with ≥2 children, or a non-root with some child v where low[v] >= disc[u].
int timer = 0;
std::vector<int> disc, low;
std::vector<bool> visited;
std::vector<std::pair<int,int>> bridges;
void dfsBridge(int u, int parent, std::vector<std::vector<int>>& adj) {
visited[u] = true;
disc[u] = low[u] = timer++;
for (int v : adj[u]) {
if (v == parent) continue; // skip the edge back to immediate parent (not a "back edge")
if (visited[v]) { low[u] = std::min(low[u], disc[v]); continue; } // back edge
dfsBridge(v, u, adj);
low[u] = std::min(low[u], low[v]);
if (low[v] > disc[u]) bridges.push_back({u, v}); // bridge found
}
}
// O(V + E) time
Bipartite Check Basic
2-color the graph via BFS/DFS; if any edge connects two same-colored nodes, the graph isn't bipartite. Equivalent to "the graph has no odd-length cycle."
bool isBipartite(int n, std::vector<std::vector<int>>& adj) {
std::vector<int> color(n, -1);
for (int start = 0; start < n; ++start) {
if (color[start] != -1) continue;
color[start] = 0;
std::queue<int> q; q.push(start);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (color[v] == -1) { color[v] = 1 - color[u]; q.push(v); }
else if (color[v] == color[u]) return false;
}
}
}
return true;
}
// O(V + E) time
Cycle Detection — Directed & Undirected Intermediate
// Directed graph: track nodes currently "in the recursion stack" (color: white/gray/black)
bool hasCycleDirected(int u, std::vector<std::vector<int>>& adj, std::vector<int>& state) {
state[u] = 1; // gray = in progress
for (int v : adj[u]) {
if (state[v] == 1) return true; // back edge to a gray node -> cycle
if (state[v] == 0 && hasCycleDirected(v, adj, state)) return true;
}
state[u] = 2; // black = fully explored
return false;
}
// Undirected graph: a visited neighbor that isn't the immediate parent means a cycle
bool hasCycleUndirected(int u, int parent, std::vector<std::vector<int>>& adj, std::vector<bool>& visited) {
visited[u] = true;
for (int v : adj[u]) {
if (!visited[v]) { if (hasCycleUndirected(v, u, adj, visited)) return true; }
else if (v != parent) return true;
}
return false;
}
// Both O(V + E) time. Alternative for undirected: DSU -- a cycle exists the moment
// union(u, v) is attempted but find(u) == find(v) already.
For directed graphs, a "visited" boolean array alone is not enough to detect cycles — you need the 3-state (white/gray/black) distinction, because a node can be reached again through a different path without that being a cycle (it's only a cycle if you reach a node that's still on the current DFS stack, i.e. gray).
Q: Why doesn't Dijkstra's algorithm work with negative edge weights?
Dijkstra greedily finalizes a node's shortest distance the moment it's popped from the heap, assuming no future relaxation could ever improve it — true only if all remaining edge weights are non-negative. A negative edge encountered later could retroactively shorten a path to an already-finalized node, which Dijkstra has no mechanism to revisit. Bellman-Ford handles this by relaxing all edges repeatedly instead of finalizing early.
Q: How does Union-Find detect a cycle while building an MST with Kruskal's algorithm?
Before adding an edge (u, v), check find(u) and find(v). If they're already equal, u and v are already connected through some other path, so adding this edge would create a cycle — skip it. If they differ, the edge safely connects two separate components; add it and union the sets.
Q: What's the difference between a bridge and an articulation point?
A bridge is an edge whose removal increases the number of connected components. An articulation point (cut vertex) is a node whose removal increases the number of connected components. A graph can have articulation points without bridges (e.g., two triangles sharing exactly one vertex) — removing the shared vertex disconnects the triangles, but no single edge removal would.
Q: Why is path compression alone not enough for near-O(1) DSU, and why is union by rank alone not enough either?
Path compression alone still allows the tree to temporarily grow tall across a sequence of unions before subsequent finds flatten it — worst case can approach O(log n) amortized on its own. Union by rank alone bounds the tree height at O(log n) but doesn't flatten paths for future queries. Combined, they give the famously tiny O(α(n)) bound — the inverse Ackermann function grows so slowly it's under 5 for any n you could ever construct in practice.
Q: When would you choose Floyd-Warshall over running Dijkstra from every node?
Floyd-Warshall is O(V³) regardless of edge count, and its code is trivially simple (three nested loops). Running Dijkstra from every node is O(V · E log V), which wins for sparse graphs (E ≈ V) but loses for dense graphs (E ≈ V²) where V³ and V² · log V are comparable but Floyd-Warshall's tiny constant factor and simplicity make it the practical choice. Floyd-Warshall also handles negative edges (not negative cycles) directly, which plain Dijkstra cannot.
12. Dynamic Programming
DP solves problems with overlapping subproblems and optimal substructure (the optimal solution can be built from optimal solutions to subproblems) by caching results instead of recomputing them.
Memoization vs Tabulation Basic
Memoization (top-down): write the natural recursion, cache each unique call's result. Easier to derive from the recursive definition, only computes states actually needed, but has recursion overhead and stack depth risk. Tabulation (bottom-up): iteratively fill a table in dependency order. Avoids recursion overhead/stack overflow, easier to space-optimize, but computes all states even if some are unreachable, and requires figuring out a valid iteration order up front.
// Fibonacci: memoization
std::vector<long long> memo;
long long fibMemo(int n) {
if (n <= 1) return n;
if (memo[n] != -1) return memo[n];
return memo[n] = fibMemo(n - 1) + fibMemo(n - 2);
}
// Fibonacci: tabulation, space-optimized to O(1)
long long fibTab(int n) {
if (n <= 1) return n;
long long prev2 = 0, prev1 = 1;
for (int i = 2; i <= n; ++i) { long long curr = prev1 + prev2; prev2 = prev1; prev1 = curr; }
return prev1;
}
0/1 Knapsack Intermediate
Each item can be taken at most once. State: dp[i][w] = best value using the first i items with capacity w.
int knapsack01(std::vector<int>& wt, std::vector<int>& val, int W) {
int n = wt.size();
std::vector<int> dp(W + 1, 0); // space-optimized: 1D array
for (int i = 0; i < n; ++i)
for (int w = W; w >= wt[i]; --w) // iterate DOWN to avoid reusing item i twice
dp[w] = std::max(dp[w], dp[w - wt[i]] + val[i]);
return dp[W];
}
// O(n * W) time, O(W) space
Unbounded Knapsack Intermediate
Each item can be used unlimited times. Only difference from 0/1: iterate w upward, allowing the same item to be reused within one pass.
int unboundedKnapsack(std::vector<int>& wt, std::vector<int>& val, int W) {
std::vector<int> dp(W + 1, 0);
for (int w = 1; w <= W; ++w)
for (int i = 0; i < (int)wt.size(); ++i)
if (wt[i] <= w) dp[w] = std::max(dp[w], dp[w - wt[i]] + val[i]);
return dp[W];
}
// O(n * W) time, O(W) space
Longest Common Subsequence Intermediate
int lcs(const std::string& a, const std::string& b) {
int n = a.size(), m = b.size();
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1, 0));
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
dp[i][j] = (a[i-1] == b[j-1]) ? dp[i-1][j-1] + 1 : std::max(dp[i-1][j], dp[i][j-1]);
return dp[n][m];
}
// O(n * m) time, O(n * m) space (reducible to O(min(n,m)) if only the length is needed)
Longest Increasing Subsequence — O(n log n) Advanced
The O(n²) DP (dp[i] = LIS ending at i) is the natural first answer. The O(n log n) trick: maintain an array tails where tails[k] = the smallest possible tail value of an increasing subsequence of length k+1. For each new number, binary search for its position in tails and replace (or append).
int lengthOfLIS(std::vector<int>& nums) {
std::vector<int> tails; // tails[k] = smallest tail of any increasing subsequence of length k+1
for (int x : nums) {
auto it = std::lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) tails.push_back(x);
else *it = x;
}
return tails.size();
}
// O(n log n) time, O(n) space. NOTE: tails does NOT hold an actual valid subsequence,
// only correct lengths -- reconstruct the actual LIS via parent pointers if needed.
Matrix Chain Multiplication Advanced
Given a chain of matrices, find the cheapest parenthesization (order of multiplication) to minimize scalar multiplications. Classic interval DP: dp[i][j] = min cost to multiply matrices i..j, trying every split point k.
int matrixChainOrder(std::vector<int>& p) { // p[i-1] x p[i] is the dimension of matrix i
int n = p.size() - 1;
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(n + 1, 0));
for (int len = 2; len <= n; ++len) {
for (int i = 1; i <= n - len + 1; ++i) {
int j = i + len - 1;
dp[i][j] = INT_MAX;
for (int k = i; k < j; ++k) {
int cost = dp[i][k] + dp[k+1][j] + p[i-1] * p[k] * p[j];
dp[i][j] = std::min(dp[i][j], cost);
}
}
}
return dp[1][n];
}
// O(n^3) time, O(n^2) space
Edit Distance (Levenshtein) Intermediate
int editDistance(const std::string& a, const std::string& b) {
int n = a.size(), m = b.size();
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1));
for (int i = 0; i <= n; ++i) dp[i][0] = i; // delete all of a[0..i)
for (int j = 0; j <= m; ++j) dp[0][j] = j; // insert all of b[0..j)
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
if (a[i-1] == b[j-1]) dp[i][j] = dp[i-1][j-1];
else dp[i][j] = 1 + std::min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]}); // del, insert, replace
}
}
return dp[n][m];
}
// O(n * m) time and space
Coin Change Basic
int coinChangeMinCoins(std::vector<int>& coins, int amount) { // unbounded knapsack shape
std::vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int a = 1; a <= amount; ++a)
for (int c : coins)
if (c <= a && dp[a - c] != INT_MAX) dp[a] = std::min(dp[a], dp[a - c] + 1);
return dp[amount] == INT_MAX ? -1 : dp[amount];
}
long long coinChangeCountWays(std::vector<int>& coins, int amount) { // order doesn't matter -> combinations
std::vector<long long> dp(amount + 1, 0);
dp[0] = 1;
for (int c : coins) // coin in OUTER loop avoids counting permutations as distinct
for (int a = c; a <= amount; ++a)
dp[a] += dp[a - c];
return dp[amount];
}
// Both O(amount * coins.size()) time, O(amount) space
Swapping the loop order in "count ways" (amount outer, coin inner) counts permutations instead of combinations — e.g., {1,2} and {2,1} would be counted as two different ways to make 3, when the problem usually wants them counted once. Coin-outer, amount-inner is the fix.
Digit DP Advanced
Counts numbers in a range [0, N] satisfying some digit-based property (e.g., "digit sum divisible by k," "no two adjacent equal digits") without enumerating every number. State: position in the number, a "tight" flag (whether the prefix so far equals N's prefix, constraining future digit choices), plus whatever property-specific state is needed.
// Count numbers in [0, N] whose digit sum equals targetSum
std::string numStr;
int memo_dp[20][200][2]; bool computed[20][200][2];
int solve(int pos, int sumSoFar, int tight) {
if (pos == (int)numStr.size()) return sumSoFar == 0 ? 1 : 0;
if (computed[pos][sumSoFar][tight]) return memo_dp[pos][sumSoFar][tight];
computed[pos][sumSoFar][tight] = true;
int limit = tight ? (numStr[pos] - '0') : 9;
int result = 0;
for (int d = 0; d <= limit; ++d) {
if (d > sumSoFar) break; // pruning for this specific "remaining sum" style problem
int newTight = (tight && d == limit) ? 1 : 0;
result += solve(pos + 1, sumSoFar - d, newTight);
}
return memo_dp[pos][sumSoFar][tight] = result;
}
// Complexity: O(digits * targetSum * 2) states, each O(10) transition -> very fast
// even when N is astronomically large (up to 10^18), because we never enumerate numbers directly
Bitmask DP — Traveling Salesman Advanced
When n is small (≤ ~20), represent "the set of visited cities" as a bitmask. State: dp[mask][i] = minimum cost to have visited exactly the cities in mask, currently at city i.
int tsp(std::vector<std::vector<int>>& dist, int n) {
std::vector<std::vector<int>> dp(1 << n, std::vector<int>(n, INT_MAX));
dp[1][0] = 0; // start at city 0, only city 0 visited
for (int mask = 1; mask < (1 << n); ++mask) {
for (int u = 0; u < n; ++u) {
if (!(mask & (1 << u)) || dp[mask][u] == INT_MAX) continue;
for (int v = 0; v < n; ++v) {
if (mask & (1 << v)) continue; // already visited
int newMask = mask | (1 << v);
dp[newMask][v] = std::min(dp[newMask][v], dp[mask][u] + dist[u][v]);
}
}
}
int best = INT_MAX;
int fullMask = (1 << n) - 1;
for (int u = 1; u < n; ++u)
if (dp[fullMask][u] != INT_MAX) best = std::min(best, dp[fullMask][u] + dist[u][0]);
return best;
}
// O(2^n * n^2) time, O(2^n * n) space -- feasible up to n ~ 18-20
Tree DP Advanced
DP over a tree structure, computing each node's DP value from its children's (post-order). Classic example: maximum independent set on a tree (e.g., "house robber III" — can't pick both a node and its direct child).
// Returns {best sum WITHOUT robbing this node, best sum robbing this node}
std::pair<int,int> robTree(TreeNode* node) {
if (!node) return {0, 0};
auto [lWithout, lWith] = robTree(node->left);
auto [rWithout, rWith] = robTree(node->right);
int without = std::max(lWithout, lWith) + std::max(rWithout, rWith); // children free to be robbed or not
int with = node->val + lWithout + rWithout; // children can't be robbed if this node is
return {without, with};
}
int rob(TreeNode* root) { auto [w, r] = robTree(root); return std::max(w, r); }
// O(n) time, O(h) space
Interval DP Advanced
State is defined over a contiguous range [i, j], typically iterating by increasing interval length and trying every split point — matrix chain multiplication above is a canonical example. Another classic: "burst balloons" (maximize coins from popping balloons in some order), and "minimum cost to merge stones."
// Palindrome partitioning: min cuts to partition s into palindromic substrings
int minCut(const std::string& s) {
int n = s.size();
std::vector<std::vector<bool>> isPal(n, std::vector<bool>(n, false));
for (int i = 0; i < n; ++i) isPal[i][i] = true;
for (int len = 2; len <= n; ++len)
for (int i = 0; i + len - 1 < n; ++i) {
int j = i + len - 1;
if (s[i] == s[j] && (len == 2 || isPal[i+1][j-1])) isPal[i][j] = true;
}
std::vector<int> dp(n, INT_MAX);
for (int j = 0; j < n; ++j) {
if (isPal[0][j]) { dp[j] = 0; continue; }
for (int i = 1; i <= j; ++i)
if (isPal[i][j] && dp[i-1] != INT_MAX) dp[j] = std::min(dp[j], dp[i-1] + 1);
}
return dp[n-1];
}
// O(n^2) time and space
How to Identify a DP Problem in an Interview Basic
- The problem asks for an optimum (min/max) or a count of ways, over choices made sequentially.
- A greedy choice doesn't obviously work — the best local choice may depend on future consequences.
- You can describe a recursive brute-force solution, and notice the same
(state)gets recomputed many times — that's the "overlapping subproblems" signal. - The problem has natural "prefix" or "range" structure: "first i elements," "substring [i,j]," "subset represented as a bitmask," "current node in a tree."
A reliable process: (1) define the state precisely in words ("dp[i][j] = the answer considering only the first i items with capacity j"), (2) write the recurrence/transition, (3) identify the base case(s), (4) pick an iteration order that respects dependencies, (5) only then worry about space optimization. Skipping step 1 is the most common way candidates get stuck mid-derivation.
Space Optimization Techniques Intermediate
Many DPs only need the previous row/layer to compute the current one — collapse a 2D table to two 1D rows (or one row, updated carefully in the right direction, as in 0/1 knapsack). For tree/graph DP, you often only need direct children's values, so no large table is needed at all — just local variables per recursive call.
Q: What's the actual difference between "optimal substructure" and "overlapping subproblems," and why do you need both for DP?
Optimal substructure means an optimal solution to the full problem can be constructed from optimal solutions to its subproblems (true of many divide-and-conquer problems too, like merge sort). Overlapping subproblems means the same subproblem recurs many times during naive recursion. You need both: optimal substructure alone just gives you correct recursion (e.g., merge sort has it but no DP benefit, since subproblems never repeat); overlapping subproblems alone with no optimal substructure means caching wouldn't even produce a correct answer.
Q: Why does 0/1 knapsack's 1D DP array need to iterate the weight dimension in decreasing order, but unbounded knapsack iterates it increasing?
In 0/1 knapsack, dp[w] must reference values from the previous item's row (i.e., before item i was considered) to avoid using item i more than once. Iterating w downward ensures dp[w - wt[i]] hasn't been updated yet in this pass. In unbounded knapsack, you explicitly WANT to reuse the same item multiple times within the current pass, so iterating upward — letting dp[w - wt[i]] already reflect a possible reuse of item i — is exactly the desired behavior.
Q: Why is the O(n log n) LIS algorithm correct even though the `tails` array isn't a real subsequence?
The invariant maintained is: tails[k] is always the smallest possible tail value achievable by SOME valid increasing subsequence of length k+1, using only elements processed so far. That's sufficient to correctly determine, for any new element, the longest subsequence it can extend (via binary search) — even though the specific subsequence that produced tails[k] might have since been "replaced" by a different, better one with the same length but smaller tail.
Q: When is a bitmask DP approach appropriate instead of a "normal" DP?
When part of your state is "which subset of a small set of items has been used/visited/chosen" and the set size is small (roughly n ≤ 20, since 2^20 ≈ 10^6), representing that subset as a bitmask integer lets you use it directly as an array/table index. It's the standard technique for TSP-like problems, assignment problems, and "can this set be partitioned into groups satisfying X" problems.
Q: How would you explain digit DP's "tight" flag to an interviewer who hasn't seen it?
When constructing a number digit-by-digit that must not exceed some bound N, most digit positions can freely be 0-9 — but as long as every digit chosen so far exactly matches N's corresponding digit, the current position is still constrained by N's digit at that position (you can't exceed it, or the number becomes bigger than N). The "tight" flag tracks whether you're still "hugging" N's prefix; the moment you choose a strictly smaller digit than N's, all future positions become fully free (0-9), which is why tight transitions from 1 to 0 and never back.
13. Greedy Algorithms
A greedy algorithm makes the locally optimal choice at each step, hoping (and, when correct, provably guaranteeing via an exchange argument or matroid structure) that this leads to a globally optimal solution. Greedy is much cheaper than DP when it applies — but proving it applies is the hard part.
Activity Selection Basic
Given activities with start/end times, select the maximum number that don't overlap. Greedy rule: always pick the activity that finishes earliest among remaining valid options.
int activitySelection(std::vector<std::pair<int,int>>& activities) { // {start, end}
std::sort(activities.begin(), activities.end(),
[](auto& a, auto& b) { return a.second < b.second; }); // sort by end time
int count = 0, lastEnd = INT_MIN;
for (auto& [start, end] : activities) {
if (start >= lastEnd) { ++count; lastEnd = end; }
}
return count;
}
// O(n log n) time (dominated by the sort)
Interval Scheduling / Merging Basic
std::vector<std::pair<int,int>> mergeIntervals(std::vector<std::pair<int,int>>& intervals) {
std::sort(intervals.begin(), intervals.end());
std::vector<std::pair<int,int>> merged;
for (auto& [s, e] : intervals) {
if (!merged.empty() && s <= merged.back().second)
merged.back().second = std::max(merged.back().second, e); // overlap -> extend
else
merged.push_back({s, e});
}
return merged;
}
// O(n log n) time
Huffman Coding (Concept) Intermediate
Builds an optimal prefix-free binary encoding, minimizing expected code length given character frequencies. Greedy rule: repeatedly merge the two least-frequent nodes into a new node (frequency = sum), until one tree remains. Implemented with a min-heap: O(n log n) for n distinct symbols.
struct HuffNode {
int freq; char ch; HuffNode *left = nullptr, *right = nullptr;
};
struct Cmp { bool operator()(HuffNode* a, HuffNode* b) { return a->freq > b->freq; } };
HuffNode* buildHuffmanTree(std::vector<std::pair<char,int>>& freqs) {
std::priority_queue<HuffNode*, std::vector<HuffNode*>, Cmp> pq;
for (auto& [ch, f] : freqs) pq.push(new HuffNode{f, ch});
while (pq.size() > 1) {
HuffNode* a = pq.top(); pq.pop();
HuffNode* b = pq.top(); pq.pop();
HuffNode* merged = new HuffNode{a->freq + b->freq, '\0', a, b};
pq.push(merged);
}
return pq.top(); // root; walk left=0/right=1 to get each character's code
}
// O(n log n) time — why it's optimal: merging the two smallest first guarantees
// the least-frequent symbols end up deepest (longest codes), which minimizes
// sum(freq * depth) -- provable via an exchange argument.
When Greedy Works vs Fails — Exchange Argument Intuition Intermediate
To prove a greedy strategy is correct, the standard technique is the exchange argument: take any optimal solution that differs from the greedy choice, show you can "exchange" a piece of it to match the greedy choice without making the solution worse — implying greedy is at least as good as any optimal solution, hence itself optimal.
When greedy fails: 0/1 Knapsack is the classic counterexample — "always take the item with the best value/weight ratio" can strictly lose to a DP solution, because taking a high-ratio item might block room for two lower-ratio items whose combined value is higher. Greedy fails whenever a locally-best choice can foreclose a globally-better combination — the hallmark that you need DP instead.
| Problem | Greedy works? | Why |
|---|---|---|
| Activity selection | Yes | Earliest finish time never blocks a better future choice (exchange argument holds) |
| Fractional knapsack | Yes | Items divisible — always fill with best ratio first, no combinatorial blocking |
| 0/1 Knapsack | No | Indivisible items — best ratio can block a better combination; needs DP |
| Huffman coding | Yes | Merging smallest-first is provably optimal via exchange argument |
| Coin change (arbitrary denominations) | No (in general) | Greedy "largest coin first" fails for denominations like {1, 3, 4} making 6 (greedy gives 4+1+1=3 coins, optimal is 3+3=2) |
| Dijkstra's shortest path | Yes | Non-negative weights guarantee the next-closest node's distance is already final |
Q: How do you decide in an interview whether a problem needs greedy or DP?
Try to construct a counterexample to the obvious greedy rule. If you can find an input where the locally-best choice provably leads to a worse global outcome, greedy is wrong and you need DP (or another technique). If you can sketch an exchange argument showing any deviation from the greedy choice can only be as good or worse, greedy is likely correct — and much simpler/faster than DP.
Q: Why does "always pick the interval with the earliest finish time" work for activity selection but "earliest start time" doesn't?
Picking earliest finish time leaves the maximum possible remaining time for future activities — it can never do worse than any other valid first choice. Earliest start time can pick a very long activity that blocks many short, non-overlapping activities that would collectively outnumber it — a concrete counterexample breaks it immediately (e.g., a start-time-first activity spanning the whole day vs. five short activities that fit in the same window).
Q: Is Kruskal's/Prim's algorithm for MST greedy? Why does greedy work there?
Yes — both greedily add the cheapest available edge that doesn't violate a constraint (doesn't form a cycle for Kruskal's, connects to the growing tree for Prim's). Correctness follows from the Cut Property: for any cut (partition of vertices into two sets), the minimum-weight edge crossing the cut must be in some MST — which is exactly the guarantee both greedy rules exploit at every step.
14. Bit Manipulation
XOR Tricks Intermediate
XOR's key properties: x ^ x = 0, x ^ 0 = x, commutative and associative. This makes it powerful for "find the odd one out" problems and swapping without a temp variable.
// Swap without a temp variable (rarely used in practice, but a classic trick)
a ^= b; b ^= a; a ^= b;
// Find the single number that appears once while all others appear twice
int singleNumber(std::vector<int>& nums) {
int result = 0;
for (int x : nums) result ^= x; // pairs cancel out to 0, leaving the unique one
return result;
}
Checking, Setting, Clearing Bits Basic
bool isSet = (x >> i) & 1; // check bit i
int setBit = x | (1 << i); // set bit i
int clrBit = x & ~(1 << i); // clear bit i
int togBit = x ^ (1 << i); // toggle bit i
int lowBit = x & (-x); // isolate lowest set bit
int clrLow = x & (x - 1); // clear lowest set bit
bool isPow2 = x > 0 && (x & (x - 1)) == 0; // power-of-two check
Counting Set Bits Basic
int popcountBuiltin(int x) { return __builtin_popcount(x); } // fastest, use this in practice
int popcountManual(int x) { // Brian Kernighan's algorithm: O(number of set bits)
int count = 0;
while (x) { x &= (x - 1); ++count; } // clears lowest set bit each iteration
return count;
}
// Precompute popcount for all numbers 0..n via DP: O(n)
std::vector<int> popcountDP(int n) {
std::vector<int> dp(n + 1, 0);
for (int i = 1; i <= n; ++i) dp[i] = dp[i >> 1] + (i & 1);
return dp;
}
Subset Generation via Bitmasks Intermediate
void enumerateSubsets(std::vector<int>& a) {
int n = a.size();
for (int mask = 0; mask < (1 << n); ++mask) {
std::vector<int> subset;
for (int i = 0; i < n; ++i)
if (mask & (1 << i)) subset.push_back(a[i]);
// process subset
}
}
// O(2^n * n) time -- fine for n <= ~20
// Iterate all submasks of a given mask (useful in SOS DP / subset-sum-over-subsets)
void iterateSubmasks(int mask) {
for (int sub = mask; ; sub = (sub - 1) & mask) {
// process sub
if (sub == 0) break;
}
}
// Total submask-iteration work across ALL masks of n bits is O(3^n), not O(4^n) --
// each element is independently "not in mask" / "in mask, not in submask" / "in submask"
Single Number Problem Variants Advanced
The family of "find the unique element(s) among duplicates" problems is a favorite bit-manipulation interview staple because the simple XOR trick generalizes with cleverness.
// Every element appears 3 times except one that appears once: track bits appearing
// a multiple-of-3 count via two accumulator variables (ones/twos state machine)
int singleNumberII(std::vector<int>& nums) {
int ones = 0, twos = 0;
for (int x : nums) {
ones = (ones ^ x) & ~twos;
twos = (twos ^ x) & ~ones;
}
return ones;
}
// Exactly two elements appear once, rest appear twice: XOR all -> get a^b,
// then split by any set bit in (a^b) to separate the two groups
std::pair<int,int> singleNumberIII(std::vector<int>& nums) {
int xorAll = 0;
for (int x : nums) xorAll ^= x;
int diffBit = xorAll & (-xorAll); // any bit where a and b differ
int a = 0;
for (int x : nums) if (x & diffBit) a ^= x;
return {a, xorAll ^ a};
}
// Both O(n) time, O(1) space
Q: Why does x & (x - 1) clear the lowest set bit?
Subtracting 1 from x flips the lowest set bit to 0 and flips every bit below it (which were 0) to 1. ANDing with the original x keeps only bits that were 1 in both — every bit below the lowest set bit becomes 0 (0 in x AND 1 in x-1 → 0), the lowest set bit itself becomes 0 (1 in x AND 0 in x-1 → 0), and everything above is unchanged (identical in both). Net effect: exactly the lowest set bit is cleared.
Q: How would you count the number of bits needed to convert integer A to B?
Compute C = A ^ B — every bit where A and B differ is a 1 in C. The answer is popcount(C), since each differing bit needs exactly one flip and XOR isolates exactly those bits.
Q: Why does the submask iteration trick achieve O(3^n) total instead of O(4^n)?
For a fixed n-bit universe, each of the n bits independently falls into exactly one of three categories across all (mask, submask) pairs being iterated: (1) not part of mask at all, (2) part of mask but not part of this particular submask, (3) part of both mask and submask. Since each bit has 3 independent states rather than 4, the total number of (mask, submask) pairs summed over every possible mask is 3^n, not 4^n (which would be the naive "every bit independently in/out of mask, in/out of submask" count without the submask ⊆ mask constraint).
Q: What's the intuition behind the ones/twos state machine for "every element appears 3 times except one"?
ones and twos together track, per bit position, a 2-bit counter (mod 3) of how many times that bit has appeared: 00 -> 01 (ones) -> 10 (twos) -> 00 (back to start, since 3 occurrences should reset to 0). The update rules implement this mod-3 counter across all 32 bit positions in parallel using bitwise ops, so at the end, `ones` holds exactly the bits belonging to the element that appears a non-multiple-of-3 number of times.
15. Advanced & Rare Topics
These rarely show up in a standard product-company loop, but are fair game at quant funds, systems-heavy roles, or a senior/staff bar raiser round. Knowing they exist — and roughly how they work — often matters more than being able to code them from scratch under time pressure.
Sqrt Decomposition Advanced
Split an array into ~√n blocks of size ~√n. Precompute an aggregate per block. Range queries touch at most O(√n) full blocks plus two partial blocks, giving O(√n) per query/update — worse than a segment tree's O(log n) asymptotically, but simpler to implement and often faster in practice for problems combining range queries with expensive per-element updates (e.g., "assign a whole range to a value" style problems, or offline query batching like Mo's algorithm below).
class SqrtDecomp {
std::vector<long long> a, blockSum;
int blockSize;
public:
SqrtDecomp(std::vector<long long>& arr) : a(arr) {
blockSize = std::max(1, (int)sqrt(a.size()));
blockSum.assign((a.size() + blockSize - 1) / blockSize, 0);
for (int i = 0; i < (int)a.size(); ++i) blockSum[i / blockSize] += a[i];
}
void update(int i, long long val) {
blockSum[i / blockSize] += val - a[i];
a[i] = val;
}
long long query(int l, int r) { // sum a[l..r], O(sqrt n)
long long sum = 0;
while (l <= r) {
if (l % blockSize == 0 && l + blockSize - 1 <= r) {
sum += blockSum[l / blockSize]; l += blockSize;
} else { sum += a[l]; ++l; }
}
return sum;
}
};
Sparse Table for O(1) RMQ Advanced
For static arrays (no updates), precompute answers for all ranges of length 2^k using DP: table[k][i] = min (or max/gcd/etc.) of a[i..i+2^k). Any range query can then be answered by combining two overlapping power-of-two ranges in O(1) — overlap is fine for idempotent operations like min/max/gcd (though not for sum, which would double-count).
class SparseTable {
std::vector<std::vector<int>> table;
std::vector<int> logTable;
public:
SparseTable(std::vector<int>& a) {
int n = a.size();
int LOG = std::__lg(n) + 1;
table.assign(LOG, std::vector<int>(n));
table[0] = a;
for (int k = 1; k < LOG; ++k)
for (int i = 0; i + (1 << k) <= n; ++i)
table[k][i] = std::min(table[k-1][i], table[k-1][i + (1 << (k-1))]);
logTable.assign(n + 1, 0);
for (int i = 2; i <= n; ++i) logTable[i] = logTable[i/2] + 1;
}
int queryMin(int l, int r) { // inclusive, O(1)
int k = logTable[r - l + 1];
return std::min(table[k][l], table[k][r - (1 << k) + 1]);
}
};
// Build: O(n log n) time and space. Query: O(1). No updates supported.
Heavy-Light Decomposition (Concept) Advanced
Decomposes a tree into O(log n) vertical "heavy chains" such that any root-to-node path crosses at most O(log n) chain boundaries. Each chain is mapped to a contiguous range in an array, backed by a segment tree — this lets you answer arbitrary path queries (e.g., "max edge weight on the path between u and v") in O(log² n) by walking chain-to-chain and querying the segment tree per chain. The "heavy" edge from each node is the one to its largest subtree, guaranteeing the O(log n) chain-crossing bound.
Centroid Decomposition (Concept) Advanced
Recursively find the centroid of a tree (a node whose removal splits the tree into pieces each ≤ n/2 size), process paths through that centroid, then recurse into each resulting piece. Because each recursion level halves the problem size, the recursion depth is O(log n), enabling O(n log n) or O(n log² n) algorithms for problems like "count/find paths in a tree satisfying property X" that would otherwise be O(n²).
Persistent Segment Tree (Concept) Advanced
A segment tree where every update creates a new version instead of mutating in place, while sharing all unchanged nodes with the previous version (path copying — only O(log n) new nodes per update, not a full O(n) copy). This lets you query "what did the structure look like after update k?" for any historical k, in O(log n) per query. Used for problems like "k-th smallest in range [l, r]" (via persistent count arrays) and version-controlled data structures.
Suffix Array & Suffix Automaton (Concept & Use Cases) Advanced
Suffix array: a sorted array of all suffixes of a string (represented as starting indices), built in O(n log n) via radix-sort-based doubling (or O(n) with more advanced algorithms like SA-IS). Combined with the LCP (longest common prefix) array, it supports powerful string queries: pattern matching in O(m log n), counting distinct substrings, finding the longest repeated substring — all without building a suffix tree.
Suffix automaton: the smallest DFA that recognizes exactly the set of suffixes of a string, built online in O(n) amortized. Every state corresponds to an equivalence class of substrings (those with identical sets of ending positions); it can answer "does substring X occur" in O(|X|), count distinct substrings, and count occurrences of every substring — often with better complexity guarantees than a suffix array for these tasks, at the cost of being considerably trickier to implement correctly.
Treap Advanced
A randomized balanced BST: each node has both a BST key and a randomly-assigned heap priority. The tree maintains BST order on keys and heap order on priorities simultaneously — because priorities are random, the expected tree height is O(log n) with high probability, without needing explicit rotation-balancing logic like AVL/red-black trees. Supports split/merge in O(log n) expected, which makes it a natural fit for implementing implicit-key sequences (array-like structures with O(log n) insert/erase/reverse-a-range).
Mo's Algorithm Advanced
An offline technique for answering many range queries [l, r] efficiently when queries can be reordered: sort queries by block of l (√n block size) and then by r (alternating direction per block for a further constant-factor speedup), then move a pair of pointers incrementally between consecutive queries, adding/removing one element at a time to update a running answer. Achieves O((n + q)·√n) total, versus O(n·q) naive recomputation — used for problems like "count distinct elements in range" that don't have an easy segment-tree-mergeable structure.
struct Query { int l, r, idx; };
int blockSize;
bool moCompare(const Query& a, const Query& b) {
int blockA = a.l / blockSize, blockB = b.l / blockSize;
if (blockA != blockB) return blockA < blockB;
return (blockA & 1) ? a.r > b.r : a.r < b.r; // alternate direction per block
}
// After sorting queries with moCompare, maintain [curL, curR] with add(x)/remove(x)
// callbacks, moving one boundary at a time between consecutive queries -- total
// pointer movement across all queries is bounded by O((n + q) * sqrt(n)).
Matrix Exponentiation — Fast Fibonacci Advanced
Any linear recurrence can be expressed as matrix multiplication; repeated squaring computes the n-th term in O(k³ log n) instead of O(n), where k is the recurrence order. Fibonacci: [[F(n+1)],[F(n)]] = [[1,1],[1,0]]^n * [[F(1)],[F(0)]].
using Matrix = std::array<std::array<long long,2>,2>;
const long long MOD = 1'000'000'007;
Matrix multiply(const Matrix& a, const Matrix& b) {
Matrix c{};
for (int i = 0; i < 2; ++i)
for (int j = 0; j < 2; ++j)
for (int k = 0; k < 2; ++k)
c[i][j] = (c[i][j] + a[i][k] * b[k][j]) % MOD;
return c;
}
Matrix matPow(Matrix base, long long exp) {
Matrix result = {{{1,0},{0,1}}}; // identity
while (exp) {
if (exp & 1) result = multiply(result, base);
base = multiply(base, base);
exp >>= 1;
}
return result;
}
long long fibFast(long long n) {
if (n == 0) return 0;
Matrix base = {{{1,1},{1,0}}};
Matrix result = matPow(base, n - 1);
return result[0][0];
}
// O(log n) time (each of the ~log n squarings does O(1) 2x2 matrix multiplies)
Modular Arithmetic Intermediate
const long long MOD = 1'000'000'007;
long long power(long long base, long long exp, long long mod) { // fast exponentiation
long long result = 1; base %= mod;
while (exp > 0) {
if (exp & 1) result = result * base % mod;
base = base * base % mod;
exp >>= 1;
}
return result;
}
// O(log exp) time
// Modular inverse via Fermat's Little Theorem (requires mod to be PRIME):
// a^(mod-1) === 1 (mod p) => a^(mod-2) is the modular inverse of a
long long modInverse(long long a, long long mod) {
return power(a, mod - 2, mod);
}
// Sieve of Eratosthenes: all primes up to n in O(n log log n)
std::vector<bool> sieve(int n) {
std::vector<bool> isPrime(n + 1, true);
isPrime[0] = isPrime[1] = false;
for (int i = 2; (long long)i * i <= n; ++i)
if (isPrime[i])
for (int j = i * i; j <= n; j += i) isPrime[j] = false;
return isPrime;
}
// Euler's Totient Function: count of integers in [1, n] coprime to n
long long eulerTotient(long long n) {
long long result = n;
for (long long p = 2; p * p <= n; ++p) {
if (n % p == 0) {
while (n % p == 0) n /= p;
result -= result / p;
}
}
if (n > 1) result -= result / n;
return result;
}
// O(sqrt(n)) time
Combinatorics — nCr mod p with Factorials Intermediate
Precompute factorials and their modular inverses once (O(n log p) via Fermat's little theorem, or O(n) with a smarter inverse-factorial recurrence), then answer any nCr mod p query in O(1).
const int MAXN = 1'000'001;
std::vector<long long> fact(MAXN), invFact(MAXN);
void precomputeFactorials() {
fact[0] = 1;
for (int i = 1; i < MAXN; ++i) fact[i] = fact[i-1] * i % MOD;
invFact[MAXN - 1] = power(fact[MAXN - 1], MOD - 2, MOD); // Fermat's little theorem
for (int i = MAXN - 2; i >= 0; --i) invFact[i] = invFact[i+1] * (i+1) % MOD; // O(1) per step
}
long long nCr(int n, int r) {
if (r < 0 || r > n) return 0;
return fact[n] * invFact[r] % MOD * invFact[n-r] % MOD;
}
// Precompute: O(n). Each query: O(1).
Q: When would you prefer sqrt decomposition over a segment tree despite its worse asymptotic complexity?
When the update operation is expensive or awkward to express as a segment-tree "combine" (e.g., "reassign a whole range to a single value with lazy propagation" is easy on a sqrt-decomposed block but can get fiddly with certain non-associative aggregate combinations), or simply because it's faster to write correctly under interview/contest time pressure and its larger constant factor doesn't matter for the given n. It also generalizes very naturally to "offline batch" techniques like Mo's algorithm.
Q: Why does Fermat's Little Theorem require the modulus to be prime?
Fermat's Little Theorem states a^(p-1) ≡ 1 (mod p) specifically when p is prime and a is not a multiple of p — it's a consequence of the multiplicative group of integers mod p (which only forms a group of order p-1 when p is prime, since every nonzero residue then has a multiplicative inverse). For composite moduli, you'd instead need the generalization via Euler's theorem (using Euler's totient function φ(n) instead of n-1), which requires gcd(a, n) = 1.
Q: What's the core idea that makes a persistent segment tree memory-efficient despite creating a new "version" per update?
Path copying: an update only needs to create new copies of the O(log n) nodes along the root-to-leaf path being modified. Every other node — the vast majority of the tree — is shared by reference between the old and new versions, since it's structurally identical. This turns what would naively be an O(n)-per-update memory cost into O(log n) per update.
Q: Why does a treap achieve O(log n) expected height without explicit rebalancing logic?
Because the heap-priority values are assigned uniformly at random, the resulting tree shape is equivalent (in distribution) to the tree you'd get from inserting keys into a BST in a uniformly random order — a well-known result (from randomized BST analysis) is that such a tree has O(log n) expected height with high probability, regardless of the actual key insertion order chosen by an adversary, since the priorities (not the insertion order) determine the shape.
Q: In what kind of interview would you realistically be expected to know suffix automata or heavy-light decomposition?
Almost never in a standard product-company software engineering loop — these are competitive-programming-tier topics. They're more likely to surface at quant trading firms that run algorithmic/technical rounds resembling contest problems, research-oriented roles, or explicitly "hard" onsite rounds designed to differentiate among already-strong candidates. Knowing the concept and when it would apply is usually sufficient; being asked to implement one from scratch on a whiteboard would be an unusually aggressive bar.
16. Problem-Solving Pattern Cheat Sheet
Most interview problems are a known pattern wearing a costume. Reading the problem statement for these signal words shortcuts a lot of blank-stare time.
Pattern → Signal Words → Canonical Example Basic
| Pattern | Signal words in the problem | Canonical example |
|---|---|---|
| Two Pointers | "sorted array", "pair that sums to", "palindrome check" | Two Sum II (sorted input) |
| Sliding Window | "contiguous subarray/substring", "longest/shortest/max/min window", "at most k distinct" | Longest Substring Without Repeating Characters |
| Fast & Slow Pointers | "linked list", "cycle", "middle of the list", "happy number" | Linked List Cycle II |
| Merge Intervals | "overlapping intervals", "schedule", "meeting rooms" | Merge Intervals |
| Cyclic Sort | "array contains numbers in range [1, n]", "find missing/duplicate number" | Find the Duplicate Number |
| In-place Linked List Reversal | "reverse a linked list", "reverse in groups of k" | Reverse Nodes in k-Group |
| Tree BFS | "level order", "minimum depth", "connect nodes at same level" | Binary Tree Level Order Traversal |
| Tree DFS | "root-to-leaf path", "path sum", "all paths" | Path Sum II |
| Two Heaps | "median", "schedule tasks with two priorities", "balance two halves" | Find Median from Data Stream |
| Subsets / Backtracking | "all combinations/permutations/subsets", "generate all" | Subsets, Permutations, N-Queens |
| Modified Binary Search | "sorted (or rotated sorted) array", "find in O(log n)" | Search in Rotated Sorted Array |
| Top-K Elements (Heap) | "k largest/smallest/most frequent" | Kth Largest Element in an Array |
| K-Way Merge | "k sorted lists/arrays", "merge all" | Merge k Sorted Lists |
| Topological Sort | "dependencies", "prerequisites", "build order", "course schedule" | Course Schedule |
| 0/1 BFS | "weighted graph with only 0/1 edge weights", "minimum flips/cost path" | 0-1 Matrix / Minimum obstacle removal |
0/1 BFS is a lesser-known specialization: when a graph's edges only have weight 0 or 1, use a deque instead of a priority queue — push 0-weight neighbors to the front and 1-weight neighbors to the back, preserving a sorted order without any log factor, giving O(V + E) instead of Dijkstra's O((V+E) log V).
Q: How should you use a pattern cheat sheet without becoming a "pattern matcher" who fails on novel problems?
Use it as a first hypothesis generator, not a lookup table — the goal is to quickly narrow the space of approaches worth trying, then verify the pattern actually fits by reasoning about the problem's specific constraints (why would sliding window be correct here? what invariant does the window maintain?). Interviewers can tell the difference between "I recognize this shape and here's why it applies" and "this looks like problem X I've seen before" — the former survives follow-up questions and problem variations, the latter doesn't.
Q: A problem mentions both "sorted array" and "find a pair/triplet summing to a target" — which pattern, and why not just use a hashmap?
Two pointers — sorted input is the strongest possible signal. A hashmap-based approach (O(n) time, O(n) space) works too and doesn't require sorted input, but once the array is already sorted, two pointers achieves the same O(n) time with O(1) extra space, which is strictly better when you don't need the hashmap for anything else. If the array isn't sorted and sorting it would cost more than it saves (e.g., you need original indices preserved), the hashmap approach may still win.
17. Interview Strategy
How to Approach an Unseen Problem Out Loud Basic
- Clarify. Restate the problem in your own words. Ask about input size/constraints (they hint at required complexity), edge cases (empty input, duplicates, negative numbers), and whether the input is sorted/has any known structure. Never start coding on assumptions.
- Brute force first. State an obvious, correct (even if slow) solution and its complexity out loud. This proves you understand the problem, gives you a correctness baseline to test against, and often reveals the structure that leads to the optimization.
- Optimize. Identify the bottleneck in the brute force (usually a repeated computation, an unnecessary nested loop, or an O(n) search that could be O(log n) or O(1)). Map it to a pattern (see the cheat sheet) — sorting, hashing, two pointers, a different data structure, DP.
- Code. Write clean, incremental code — narrate what you're doing. Use meaningful variable names even under pressure. Handle edge cases explicitly rather than hoping they fall out naturally.
- Test. Trace through a small example by hand, including at least one edge case (empty input, single element, all-duplicates). This is where most remaining bugs surface — do it before declaring you're done, not after the interviewer points at your code.
Narrate your thinking continuously, especially during the "thinking" gaps that feel silent and awkward. Interviewers are evaluating your problem-solving process, not just the final code — silence reads as being stuck even when you're making real progress internally.
Discussing Complexity Trade-offs Intermediate
Senior-leaning interviews expect you to proactively discuss trade-offs, not just state a final complexity:
- Time vs space: "I can cache results in a hashmap for O(1) lookup at the cost of O(n) extra space, or recompute each time for O(1) space but O(n) time per query — which matters more here depends on whether reads or memory are the bottleneck."
- Preprocessing vs per-query cost: "If this function will be called many times on the same data, it's worth O(n log n) preprocessing (e.g., a sparse table) to get O(1) per query, rather than O(n) per query with no preprocessing."
- Worst-case vs average-case: "A hashmap gives average O(1) lookup, but if adversarial input is a concern, a balanced tree guarantees O(log n) worst-case."
- Simplicity vs optimality: "The O(n log n) approach is simpler to implement correctly under time pressure; the O(n) approach exists but has more edge cases — I'd start with the simpler one and mention the optimization is possible."
Notes on Top-Tier Quant / Product Company Bars Intermediate
General, honest patterns observed across demanding technical interview loops (not tied to any specific company's actual current process, which changes over time and should always be verified from primary sources like the company's own careers page or Glassdoor/Blind for recent, specific reports):
- Quant/trading-firm-style bars often go deeper on raw C++/systems fundamentals than typical product companies: expect questions on STL internals (how does
unordered_maphandle collisions? what's the actual growth factor ofvector?), cache behavior and memory layout, and sometimes pure math/probability/brainteaser-style puzzles alongside DSA. - Product companies (in the LeetCode-medium-heavy style) tend to weight problem-solving process, communication, and code cleanliness roughly as heavily as raw optimality — a correct O(n log n) solution, clearly explained and cleanly coded, often outperforms a barely-working O(n) one.
- Across the board, being unable to explain why your solution is correct (not just that it passed test cases) is a common way strong-seeming candidates get rejected — practice articulating invariants and correctness arguments, not just reciting complexity.
- Don't fabricate confidence about a specific company's current interview format from secondhand rumor — it changes, and overfitting your prep to outdated specifics wastes time better spent on fundamentals that transfer everywhere.
Treat any specific "company X always asks Y" claim (including ones on this page) as a soft prior, not gospel — interview formats change, and over-indexing on rumored specifics at the expense of general fundamentals is a common and avoidable mistake.
Q: What should you do if you're stuck and can't see the optimization after stating the brute force?
Say so explicitly rather than going silent: "I have a working O(n²) brute force; let me think about what's redundant here." Walk through a concrete small example by hand looking for repeated work or unnecessary comparisons — verbalizing the search for a pattern is itself valuable signal to the interviewer, and many will offer a hint if you've clearly demonstrated understanding of the brute force and are visibly reasoning productively.
Q: Is it ever fine to submit a suboptimal solution?
Yes — a correct, well-explained suboptimal solution with clearly identified further optimization opportunities is generally viewed better than an incorrect or incomplete attempt at the optimal one, especially under time pressure. Always state the complexity of what you have, explicitly name what the theoretical optimum would be, and briefly explain (even without full implementation) how you'd get there given more time.
Q: How much should you focus on memorizing solutions to specific well-known problems vs understanding patterns?
Pattern understanding transfers to novel problems; memorized solutions don't — and interviewers routinely tweak well-known problems specifically to catch memorization without understanding. Practice enough problems per pattern (the cheat sheet in this page groups them) to internalize the reasoning, but treat any specific problem's exact solution as disposable — what should stick is "why does this technique apply here."
References & Further Reading
- cp-algorithms.com — the single best free reference for algorithm implementations and theory, maintained by the competitive programming community.
- cppreference.com — the canonical C++ / STL reference; check container complexity guarantees here, not from memory.
- GeeksforGeeks — Data Structures — huge breadth of worked examples and interview-style explanations.
- LeetCode — the standard practice ground for interview-style DSA problems; use the pattern cheat sheet in this page to pick problems deliberately.
- USACO Guide — a free, structured competitive programming curriculum from bronze to platinum, excellent for advanced topics like segment trees, DSU, and graph algorithms.
- Competitive Programmer's Handbook (Antti Laaksonen, PDF) — a free, dense, superbly organized book covering almost everything on this page in more depth.
- NeetCode — curated problem lists (NeetCode 150 / Blind 75) mapped to patterns, with video walkthroughs.
- CSES Problem Set — a tight, well-designed problem set that pairs directly with the Competitive Programmer's Handbook chapters.
- OI Wiki — another deep algorithms reference, strong on advanced/rare topics like persistent data structures and heavy-light decomposition.
CS Prep Hub