2026
Project goal: to compile the most representative DSA coding interview questions at Big Tech companies, with Python 3 solutions, complexity analysis, common interview pitfalls, and related practice problems so that readers can train systematically by pattern, not by memorising individual problems.
Scope: 288 problems (267 fully solved + 21 cross-reference recaps across chapters) · 44 patterns · Python 3 solutions matching the LeetCode style.
📚 This book is based on the curriculum of the DSA Coding Interview course currently taught at EngineerPro.
However, a book cannot replace the deeper training in algorithmic thinking and the way you absorb algorithms that you get by attending the course directly at EngineerPro.
If you are interested in the course, please message our fanpage for a consultation:
Before diving into the main content, please watch a sample coding interview session produced by EngineerPro. The video lets you visualise the real flow of an interview round: how the interviewer asks questions, how the candidate clarifies, analyses, codes, and discusses follow-ups.
▶ Watch the full playlist — other sample interviews on the EngineerPro YouTube channel.
Viewing tip: the first time you watch, focus on how the candidate communicates, not on understanding every algorithm. Come back for a second viewing after finishing Chapters 1–5 and you will recognise many familiar patterns.
Welcome to Coding DSA Interview At Big Tech — with Full Solutions.
This book is written with one simple belief: a Big Tech coding interview is not a trivia contest — it is a learnable skill that can be trained systematically.
Most existing interview materials fall into one of two extremes: - Too academic (textbook DSA), with little real-world interview context. - Too “tips and tricks” (300 LeetCode lists), without a systematic thinking framework.
This book aims to sit between the two: learn PATTERNS, not problems. Each chapter focuses on one pattern with 5–18 representative problems, including whiteboard presentation guidance and common pitfalls from real interviews. Once you internalise the pattern, new problems become variations.
Who this book is for: - CS students preparing for internships / new-grad roles at Big Tech. - Working engineers looking to switch into FAANG-tier companies. - Self-learners on LeetCode who feel stuck without a systematic approach.
We wish you success in every interview round!
There are three reading paths depending on your time and experience:
Sequential reading (recommended for beginners): chapters 1 → 44 in order. For each chapter you must code the problems yourself before peeking at the solutions. After finishing Level 1 (Chapters 1–20) you will have a solid foundation for entry-level Big Tech interviews.
Pattern-based reading (if you already have a foundation): skim the table of contents and dive into chapters where you feel weak. Each chapter stands alone and includes clear cross-references to related material.
Cramming before an onsite: see the Learning Roadmap below.
1-week roadmap (last-minute onsite prep): - Day 1–2: Frontmatter (0.1–0.5) + Appendix D (50 must-do problems). - Day 3: Review Chapters 1, 2, 5, 6 (Array, String, Binary Search, Hash) — all Easy/Medium tier. - Day 4: Chapters 7, 9, 10, 11 (Linked List, Graph, BFS, DFS). - Day 5: Chapters 17, 18, 27 (D&C, Monotonic, Sliding Window). - Day 6: Chapters 28, 29 (Backtracking, DP) — the two longest, most important chapters. - Day 7: Mock interviews + read Appendix E (Behavioral).
2-week roadmap (already know DSA, refresher): - Week 1: Level 1 (Chapters 1–20) — 3 chapters/day. - Week 2: Level 2 (Chapters 21–32) — 2 chapters/day + mock interviews at the weekend.
6-week roadmap (beginners): - Weeks 1–2: Frontmatter + Level 1 (Ch 1–10) — 1 chapter/day, code every problem yourself. - Weeks 3–4: Level 1 (Ch 11–20) — 1 chapter/day. - Week 5: Level 2 (Ch 21–32) — 2 chapters/day, read patterns carefully. - Week 6: Level 3 (Ch 33–44) — 2 chapters/day, no need to memorise — just know they exist.
Skip if short on time (priority chapters): skip Ch 20 (Prime), Ch 31 (Game Theory), Ch 33–36 (MST / Hash / KMP / Z) — niche patterns, rare in entry/mid-level interviews.
Self-assessment: - Solve Easy in < 30 min each → Level 1 is enough. - Medium in 30–60 min → read up to Level 2. - Hard takes > 60 min or you get stuck often → read all three levels.
⚠️ Do not read the code before planning yourself. The book intentionally places the solution after the “Approach” section — you must struggle with the problem before peeking at the answer; that is how the brain absorbs patterns most effectively.
UMPIRE = a 6-step framework that keeps you from “freezing” when you receive a new problem:
n be?O(?).left, right
instead of i, j).Mindset tip: the interviewer wants to see your thought process, not a perfect solution on the first try. Think out loud.
f(n) = O(g(n)) ↔︎ there exists c > 0, n₀
such that f(n) <= c·g(n) for every
n >= n₀.
In interviews: drop constants, drop lower-order
terms. 3n² + 100n + 5 = O(n²).
| Notation | Name | Example |
|---|---|---|
O(1) |
Constant | Hash lookup, push/pop on a stack |
O(log n) |
Logarithmic | Binary search |
O(n) |
Linear | Single-array scan |
O(n log n) |
Linearithmic | Sort, segment-tree build |
O(n²) |
Quadratic | Brute-force double loop |
O(2^n) |
Exponential | Subset brute force |
O(n!) |
Factorial | Permutation brute force |
a(); b();):
O(a + b), take the max.O(n × n) = O(n²).O(log n).O(n) work per level
(merge sort) → O(n log n).O(2^n).T(n) = aT(n/b) + f(n):
a = b, f = n → O(n log n).a = 1, b = 2, f = 1 → O(log n).a = 2, b = 2, f = 1 → O(n).Some operations are occasionally slow but on
average fast: - Python list.append():
O(1) amortised (despite occasional dynamic resizing). -
Hash table with open addressing: O(1) amortised.
O(n) with a 1000×
constant may be slower than O(n²) for small
n.n |
Acceptable |
|---|---|
n ≤ 10 |
O(n!) brute force |
n ≤ 20 |
O(2^n) bitmask |
n ≤ 5000 |
O(n²) is fine |
n ≤ 10^5 |
O(n log n) or O(n) |
n ≤ 10^7 |
strict O(n) |
n ≤ 10^9 |
O(log n) (search on the answer) |
# List
lst = [1, 2, 3]
lst.append(x); lst.pop() # O(1) both
lst.insert(0, x); lst.pop(0) # O(n) — avoid!
sorted_lst = sorted(lst) # O(n log n), returns a new list
lst.sort() # in-place
lst[::-1] # reverse, O(n)
lst[a:b] # slicing, O(b-a) copy
# Dict
d = {}; d[k] = v # O(1) amortised
from collections import defaultdict, Counter
dd = defaultdict(list)
cnt = Counter("anagram") # {'a':3, 'n':1, 'g':1, 'r':1, 'm':1}
cnt.most_common(2) # [('a',3), ('n',1)]
# Set
s = {1, 2, 3}
s.add(x); s.discard(x) # O(1)
s & t; s | t; s - t # intersection / union / difference
# Deque (double-ended queue) — used for BFS, sliding window
from collections import deque
dq = deque()
dq.append(x); dq.appendleft(x)
dq.pop(); dq.popleft() # all O(1)
# Heap (min-heap)
import heapq
h = []
heapq.heappush(h, x)
heapq.heappop(h) # O(log n)
heapq.heapify(lst) # O(n)
heapq.nsmallest(k, lst) # O(n log k)# Enumerate
for i, x in enumerate(lst):
pass
# Zip
for a, b in zip(lst1, lst2):
pass
# Comprehension
[x*2 for x in lst if x > 0]
{x: i for i, x in enumerate(lst)}
# Bisect
from bisect import bisect_left, bisect_right
idx = bisect_left(sorted_lst, x)
# Functools
from functools import cache, reduce
@cache
def f(n):
...
reduce(lambda a, b: a + b, lst, 0)
# Itertools
from itertools import combinations, permutations, product
list(combinations([1,2,3], 2)) # [(1,2),(1,3),(2,3)]Arithmetic & division: - dict[k]
raises KeyError if k is missing → use
dict.get(k, default) or defaultdict. -
int / int = float; use // for integer
division. - -7 // 2 = -4 (floor), not -3
(truncate toward 0). Truncate via int(-7/2). -
-7 % 2 = 1 in Python (always ≥ 0), different from
C/C++/Java. Be careful when taking modulo of negative numbers in prefix
sums.
Heap & comparison: - Python heapq
is only a min-heap. For max-heap, push -x.
- Heap compares tuples element-by-element:
(priority, idx, payload) — use idx as a
tiebreaker when priorities are equal (the payload may not
be hashable or comparable, e.g. ListNode). -
heappush-ing a tuple whose payload is not comparable (such
as ListNode) raises TypeError when priorities
collide.
Recursion & cache: - Python’s default recursion
limit is around 1000. Use sys.setrecursionlimit(10**6) for
large graphs/trees. - Python has no tail-call
optimisation → deep recursion can blow the stack. Switch to iterative
when n > 10^5. - @functools.cache only
works for hashable arguments. list /
dict / set cannot be cached; wrap with
tuple(...) / frozenset(...). -
@cache on a self.method shares a
global cache across instances. Use
@cached_property if you want per-instance caching.
Mutable defaults & references: - Default mutable
argument: def f(x=[]) is a serious bug (every call shares
the same list). Use def f(x=None): x = x or []. -
result.append(path) appends a reference, not a
copy. Use result.append(path.copy()) or
result.append(path[:]).
Sort & stability: - Python’s
sorted() is Timsort, stable,
O(n log n). Take advantage of stability to sort multi-key
by sorting multiple times in reverse order. - Custom sort: Python 3 has
no cmp= argument. Use key=... or
functools.cmp_to_key(...).
Integers & overflow: - Python int
is unbounded → no need to worry about overflow like in Java/C++. But
explicitly truncate (32-bit) when the problem requires it (LC 7, LC 8,
LC 50).
def solve(...) first, call helper functions, then implement
the helpers afterwards.left, right, slow,
fast, prev, curr.a, b, c, x,
tmp.O(n²) works but will TLE; I’m looking for a better
approach…”n = 3
instead of n = 100.O(n log n) because the sort step dominates.”O(n) and answer each query in
O(1)…”| Stage | Sample phrase |
|---|---|
| Clarify | “Let me confirm the problem: input is …, output is …, any additional constraints?” |
| Brute force | “To start, here is the direct approach: enumerate every pair —
O(n²)…” |
| Optimise | “I think we can use a hash map to reduce each lookup to
O(1)…” |
| Stuck | “I am torn between a hash map and a sorted array. Do you have any hint?” |
| Done | “The solution runs in O(n) time and
O(n) space. Let me try a few edge cases…” |
Array is the most fundamental data structure and also the pattern that shows up most often in Big Tech coding interviews. Most techniques in later chapters (two pointers, sliding window, prefix sum, monotonic stack, …) originate from array manipulation. The goal of this chapter is to master in-place and two-pass operations and to start cultivating the habit of “thinking in terms of indices, not values”.
After this chapter, you will be able to:
O(1) extra space is required.from typing import List
def two_pass_pattern(nums: List[int]) -> List[int]:
"""Two-pass template: pass 1 collects information, pass 2 uses it."""
n = len(nums)
aux = [0] * n
# pass 1: compute prefix / suffix / count
for i in range(n):
aux[i] = ... # problem-specific
# pass 2: use aux to produce the result
out = [0] * n
for i in range(n):
out[i] = ... # problem-specific
return out
def two_pointers_in_place(nums: List[int]) -> int:
"""Two-pointer in-place: slow = write position, fast = read position."""
slow = 0
for fast in range(len(nums)):
if condition(nums[fast]):
nums[slow] = nums[fast]
slow += 1
return slow # length of the "valid" compacted prefixYou are given an integer array nums and an integer
target. Return the indices of the two
elements in nums whose sum equals target.
You may assume each input has exactly one solution, and you cannot use the same element twice.
Input: nums = [2, 7, 11, 15], target = 9
Output: [0, 1]
Explanation: nums[0] + nums[1] == 9.
2 <= len(nums) <= 10^4-10^9 <= nums[i] <= 10^9-10^9 <= target <= 10^9Brute force — O(n²). Enumerate every
pair (i, j) with i < j and check
nums[i] + nums[j] == target. Easy to write but TLEs for
large n.
Optimal — one-pass hash map — O(n).
When we are at index i, we need to know whether some
j < i satisfies
nums[j] == target - nums[i]. Use a dict
storing {value: index} of elements we have already
seen.
Presentation tip: Always start with brute force, state its complexity explicitly, then say: “I think we can replace the linear
O(n)lookup with anO(1)hash-map lookup, which brings the total cost down fromO(n²)toO(n)…” — interviewers love this flow of reasoning.
from typing import List
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
seen: dict[int, int] = {}
for i, x in enumerate(nums):
complement = target - x
if complement in seen:
return [seen[complement], i]
seen[x] = i
return [] # per the problem statement this line never executesO(n) — one pass; each dict
look-up is O(1) average.O(n) — the dict stores up to
n (value, index) pairs.seen[x] = i
before checking complement — this breaks the case
nums = [3, 3] with target = 6 (now
complement == x and we would reuse the same element
twice).O(n) time,
O(1) extra space (LC 167).Given an array prices where prices[i] is
the stock price on day i, you are allowed to buy
once then sell once afterwards (you cannot
sell before you buy). Return the maximum profit, or
0 if no transaction is profitable.
Input: prices = [7, 1, 5, 3, 6, 4]
Output: 5
Explanation: buy on day 2 (price 1), sell on day 5 (price 6); profit = 6 - 1 = 5.
Input: prices = [7, 6, 4, 3, 1]
Output: 0
Explanation: prices only decrease; no profitable transaction exists.
1 <= len(prices) <= 10^50 <= prices[i] <= 10^40.Brute force — O(n²). Try every
(i, j) with i < j and take
max(prices[j] - prices[i]). TLE at
n = 10^5.
Optimal — one pass, O(n). If we decide
“we will sell today on day i”, the best profit is
prices[i] - min(prices[0..i-1]). So we just maintain
min_so_far as we sweep and update best at
every step.
Illustration for
prices = [7, 1, 5, 3, 6, 4]:
day : 0 1 2 3 4 5
prices : 7 1 5 3 6 4
│ │
│ buy here │ sell here
▼ ▼
min_so_far : 7 1 1 1 1 1
profit_now : 0 0 4 2 5 3 (= prices[i] - min_so_far)
best : 0 0 4 4 5 5 ← answer = 5
▲
unchanged since 2 < 4
Mindset: this is a DP in one variable. The state
min_so_faris theO(1)-space compression ofdp[i] = min(prices[0..i])— a technique you will see repeatedly in the DP chapters.
from typing import List
import math
class Solution:
def maxProfit(self, prices: List[int]) -> int:
min_so_far = math.inf
best = 0
for p in prices:
min_so_far = min(min_so_far, p)
best = max(best, p - min_so_far)
return bestO(n) — one pass.O(1) — two variables.best = -inf
and forgetting to handle the fully-decreasing case — you would return a
negative number. Initialise best = 0 for safety.Given an array nums of n integers, return
an array answer of the same length such that
answer[i] is the product of all elements
of nums except nums[i].
Special constraints: - Division is not
allowed. - The algorithm must run in O(n)
time.
Input: nums = [1, 2, 3, 4]
Output: [24, 12, 8, 6]
Explanation:
answer[0] = 2*3*4 = 24
answer[1] = 1*3*4 = 12
answer[2] = 1*2*4 = 8
answer[3] = 1*2*3 = 6
Input: nums = [-1, 1, 0, -3, 3]
Output: [0, 0, 9, 0, 0]
2 <= len(nums) <= 10^5-30 <= nums[i] <= 30O(1) extra
space.nums in
place? → A new array.Brute force — O(n²). For each
i, recompute the product of the rest of the array. The
problem already bans this.
Division — O(n) but not allowed.
Compute total = product(nums) then
answer[i] = total / nums[i]. Forbidden because
nums[i] = 0 breaks it; and many languages lack exact
integer division.
Optimal — Prefix × Suffix products — O(n) time,
O(1) extra space (output excluded).
Observation:
answer[i] = (∏ nums[0..i-1]) * (∏ nums[i+1..n-1]). Define:
- left[i] = product of nums[0..i-1] (left
product, left[0] = 1). - right[i] = product of
nums[i+1..n-1] (right product,
right[n-1] = 1). Then
answer[i] = left[i] * right[i].
To reach O(1) extra space, reuse answer: -
Pass 1 (left → right): fill
answer[i] = left[i]. - Pass 2 (right →
left): multiply answer[i] *= right, updating the rolling
variable right as we go.
Illustration for
nums = [1, 2, 3, 4]:
i=0 i=1 i=2 i=3
nums : [ 1 , 2 , 3 , 4 ]
┌─────────────┐
│ prefix → │ (product of elements LEFT of i)
▼ ▼
left[i] : [ 1 , 1 , 2 , 6 ]
(empty)(1) (1·2) (1·2·3)
┌─────────────┐
│ ← suffix │ (product of elements RIGHT of i)
▼ ▼
right[i] : [ 24 , 12 , 4 , 1 ]
(2·3·4)(3·4) (4) (empty)
↓ pointwise multiplication ↓
answer[i] : [ 24 , 12 , 8 , 6 ]
1·24 1·12 2·4 6·1
In real code we do not keep both left
and right arrays — we use answer for the
prefix pass, then a single rolling right variable swept
right-to-left to multiply into answer in place.
from typing import List
class Solution:
def productExceptSelf(self, nums: List[int]) -> List[int]:
n = len(nums)
answer = [1] * n
# Pass 1: answer[i] = product of elements left of i.
left = 1
for i in range(n):
answer[i] = left
left *= nums[i]
# Pass 2: multiply by the right-side product, using a rolling variable.
right = 1
for i in range(n - 1, -1, -1):
answer[i] *= right
right *= nums[i]
return answerO(n) — exactly two passes over
the array.O(1) extra (output
excluded).zero_count). If zero_count >= 2, the
answer is all zeros. If == 1, only that index receives the
product_non_zero. If == 0, divide
normally.i because of negatives.Given an array nums, move all zeros to the
end of the array while preserving the relative order of the
non-zero elements. Do it in place; you may not create
an auxiliary array.
Input: nums = [0, 1, 0, 3, 12]
Output: [1, 3, 12, 0, 0]
Input: nums = [0]
Output: [0]
1 <= len(nums) <= 10^4-2^31 <= nums[i] <= 2^31 - 1nums in place and not
return anything.0 itself is “pushed” to the end.Brute force — O(n) time, O(n) extra
space. Build an auxiliary array of non-zero values and pad with
zeros to length n. The problem forbids auxiliary arrays —
rejected.
Optimal — Two pointers — O(n) time,
O(1) extra space. Use two pointers: -
slow = the next index to write a non-zero
value. - fast = the index currently being
read.
Pass 1: for each fast, if nums[fast] != 0 →
nums[slow] = nums[fast], then increment slow.
Pass 2: from slow to the end of the array, write
0.
Illustration for
nums = [0, 1, 0, 3, 12]:
Pass 1: pack non-zero values to the front
──────────────────────────────────────────
Initial : [ 0 , 1 , 0 , 3 , 12] slow=0 fast=0
S
F
fast=0: nums[0]=0, skip
[ 0 , 1 , 0 , 3 , 12] slow=0 fast=1
S
F
fast=1: nums[1]=1 != 0 → write nums[0]=1, slow++
[ 1 , 1 , 0 , 3 , 12] slow=1 fast=2
S
F
fast=2: nums[2]=0, skip slow=1 fast=3
fast=3: nums[3]=3 != 0 → write nums[1]=3, slow++
[ 1 , 3 , 0 , 3 , 12] slow=2 fast=4
S
F
fast=4: nums[4]=12 != 0 → write nums[2]=12, slow++
[ 1 , 3 ,12 , 3 , 12] slow=3 fast=done
Pass 2: from slow to end, write 0
──────────────────────────────────────────
[ 1 , 3 ,12 , 0 , 0 ] ← answer
▲ ▲
write 0 write 0
This approach minimises the number of writes to exactly
n(each cell is written at most once). A swap-as-you-go variant exists with shorter code but twice as many writes.
from typing import List
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
slow = 0
# Pass 1: pack non-zero values to the front.
for fast in range(len(nums)):
if nums[fast] != 0:
nums[slow] = nums[fast]
slow += 1
# Pass 2: fill the tail with zeros.
for i in range(slow, len(nums)):
nums[i] = 0O(n) — two consecutive passes,
total still O(n).O(1).slow = 0
for fast in range(len(nums)):
if nums[fast] != 0:
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1Given an array height where height[i] is
the height of the i-th pole, choose two poles
i < j such that the water held between them is
maximal.
Water volume = min(height[i], height[j]) * (j - i).
Input: height = [1, 8, 6, 2, 5, 4, 8, 3, 7]
Output: 49
Explanation: choose poles 1 (height 8) and 8 (height 7) → 7 * (8-1) = 49.
Input: height = [1, 1]
Output: 1
2 <= len(height) <= 10^50 <= height[i] <= 10^4j - i matters).Brute force — O(n²). Try every
(i, j) and take the max. TLE for n = 10^5.
Optimal — Two pointers, O(n). Set
l = 0, r = n - 1. The current area is
min(height[l], height[r]) * (r - l).
Core question: which pointer do we move? — Move the pointer at the shorter side.
Why? The area is bounded by the shorter pole. Moving the taller pointer inward shrinks the width while the shorter pole is still the bottleneck → the area can only stay the same or decrease. Moving the shorter pointer at least gives us a chance (no guarantee) to find a taller pole, which can lift the bottleneck.
“Elimination” proof: Fixing the shorter pole (say the left one) and moving the right pointer inward, every pair
(l, r' < r)has area ≤height[l] * (r - l). None of those pairs can beat the current area unless they were already going to lose to the current best — we can “eliminate” all of them at once and only need to movel.
Illustration for
height = [1, 8, 6, 2, 5, 4, 8, 3, 7]:
▓ ▓
8 ▓ ▓ | ← height 8
7 ▓ ▓ ▓
6 ▓ ▓ ▓ ▓ ▓
5 ▓ ▓ ▓ ▓ ▓ ▓
4 ▓ ▓ ▓ ▓ ▓ ▓ ▓
3 ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓
2 ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓
1 ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓
└─┴───┴───┴───┴───┴───┴───┴────┴───┴─
index: 0 1 2 3 4 5 6 7 8
L R
Trace (★ = best so far):
step │ L R │ min(h[L], h[R]) │ width │ area │ move
─────┼────────┼─────────────────┼───────┼───────┼──────────────
1 │ 0 8 │ 1 │ 8 │ 8 │ h[L]<h[R] → L++
2 │ 1 8 │ 7 │ 7 │ 49 ★ │ h[L]>=h[R] → R--
3 │ 1 7 │ 3 │ 6 │ 18 │ → R--
4 │ 1 6 │ 8 │ 5 │ 40 │ → R--
5 │ 1 5 │ 4 │ 4 │ 16 │ → R--
6 │ 1 4 │ 5 │ 3 │ 15 │ → R--
7 │ 1 3 │ 2 │ 2 │ 4 │ → R--
8 │ 1 2 │ 6 │ 1 │ 6 │ → R--
─── │ 1 1 │ stop │ │ │
Answer: 49 (pair of pole index 1 and 8, heights 8 and 7).
from typing import List
class Solution:
def maxArea(self, height: List[int]) -> int:
l, r = 0, len(height) - 1
best = 0
while l < r:
h = min(height[l], height[r])
best = max(best, h * (r - l))
# Always move the pointer on the shorter side.
if height[l] < height[r]:
l += 1
else:
r -= 1
return bestO(n) — each iteration moves a
pointer, at most n - 1 total moves.O(1).height[l] == height[r]: moving
either pointer is fine; the (l, r) pair with equal heights
has already been measured, and any subsequent pair must shrink at least
one side ≤ h → cannot improve.Given an array nums and a non-negative integer
k, rotate the array to the right by
k steps.
Input: nums = [1, 2, 3, 4, 5, 6, 7], k = 3
Output: [5, 6, 7, 1, 2, 3, 4]
Explanation: 1-step right → [7,1,2,3,4,5,6]; 2-step → [6,7,1,2,3,4,5]; 3-step → [5,6,7,1,2,3,4].
Input: nums = [-1, -100, 3, 99], k = 2
Output: [3, 99, -1, -100]
1 <= len(nums) <= 10^5-2^31 <= nums[i] <= 2^31 - 10 <= k <= 10^5O(1) extra space (follow-up).k be larger than n? → Yes —
normalise with k %= n first.k = 0 allowed? → Yes; output equals
input.Brute force — rotate by one step, repeated k
times — O(n·k). TLE.
Auxiliary array — O(n) time, O(n)
space. new[(i + k) % n] = nums[i], then copy
new back to nums. Simple but violates the
O(1)-space follow-up.
Optimal — Three reverses, O(n) time,
O(1) extra space. Look at
n = 7, k = 3: - Reverse the whole array:
[7, 6, 5, 4, 3, 2, 1]. - Reverse [0..k-1]:
[5, 6, 7, 4, 3, 2, 1]. - Reverse [k..n-1]:
[5, 6, 7, 1, 2, 3, 4]. ✓
Intuition: the full reverse brings the elements that “should end up at the beginning” to the front, but each block is locally backwards. The two inner reverses fix the order inside each block.
Illustration for n = 7, k = 3:
Input : [ 1 2 3 4 │ 5 6 7 ]
▲
the last k=3 elements must "jump" to the front
──────────────────────────────────────────────────
Step 1: reverse the whole array [0..6]
◀═══════════════════════════▶
[ 7 6 5 4 3 2 1 ]
↑ ↑
(5,6,7 reversed) (1,2,3,4 reversed)
Step 2: reverse [0..k-1] = [0..2] (fix the first 3 elements)
◀═══════▶
[ 5 6 7 │ 4 3 2 1 ]
↑
first 3 fixed; last 4 still backwards
Step 3: reverse [k..n-1] = [3..6] (fix the last 4 elements)
◀═══════════════▶
[ 5 6 7 │ 1 2 3 4 ] ← answer ✓
from typing import List
class Solution:
def rotate(self, nums: List[int], k: int) -> None:
n = len(nums)
k %= n # always normalise first
def reverse(left: int, right: int) -> None:
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
reverse(0, n - 1) # reverse everything
reverse(0, k - 1) # reverse the first k elements
reverse(k, n - 1) # reverse the restO(n) — every element is swapped
at most twice.O(1).k %= n → for k > n the inner
reverses get negative or out of bounds indices.k: reverse [0..k-1] first,
then [k..n-1], then the whole array (or equivalently:
rotate right by n - k).gcd(n, k) independent cycles, each cycle pushing elements
by k positions. Slightly more complex code, also
O(n) / O(1). Worth knowing for the
follow-up.| Question | If YES | If NO |
|---|---|---|
| May we mutate the input? | In-place (Move Zeroes, Rotate) | Allocate a result array |
| Must we preserve the original order? | Same-direction two-pointer | Free to swap anywhere |
| Return index or value? | Be careful when sorting: keep (value, index) |
— |
| Are there zeros / negatives? | Product-Except-Self cannot use division | Plain prefix × suffix is fine |
| Need O(1) memory? | 3-reverse trick, in-place markers | Hashes / aux arrays are fine |
| Approach | Time | Space | When to choose |
|---|---|---|---|
| Extra array | O(n) | O(n) | Easiest to write, fewest bugs; when RAM is plentiful |
| 3-reverse | O(n) | O(1) | Default for interviews — elegant and short |
| Cyclic replacement (GCD) | O(n) | O(1) | When the interviewer asks for a “no-reverse” follow-up |
A string is really an array of characters — every technique from Chapter 1 (two pointers, in-place, prefix) applies. Strings, however, have two specific quirks: (i) you must handle the character set (ASCII or full Unicode?) and (ii) in Python, strings are immutable — you cannot mutate them in place; every “character swap” must go through a
listfollowed by''.join.
After this chapter, you will be able to:
list.from collections import Counter
from typing import List
def two_pointers_in_string(s: str) -> bool:
"""Two-pointer template: check a symmetric / pairwise condition."""
l, r = 0, len(s) - 1
while l < r:
if not check(s[l], s[r]):
return False
l += 1
r -= 1
return True
def count_chars(s: str) -> dict[str, int]:
"""Character frequency table — used by almost every string problem."""
return Counter(s)Given two strings s and t, return
True if t is an anagram of
s (same characters with the same counts, possibly in
different order), otherwise False.
Input: s = "anagram", t = "nagaram"
Output: True
Input: s = "rat", t = "car"
Output: False
1 <= len(s), len(t) <= 5·10^4s, t contain only lowercase English
letters.[26] array must be replaced with a
Counter.Brute force — sort, O(n log n).
sorted(s) == sorted(t). One-liner, but
O(n log n) time and O(n) space (because
sorted returns a list).
Optimal — single-pass Counter, O(n).
Count the characters of s, then sweep t
decrementing. If any count goes negative — not an anagram. Final state
must be all zeros.
Even faster — fixed 26-element table, O(1) extra
space (alphabet dependent). With only 26 letters we can use
int[26] (or a length-26 list) instead of a dict. Memory is
genuinely O(1) (independent of n).
from collections import Counter
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
return Counter(s) == Counter(t)
class SolutionFast:
"""Fixed 26-letter table — true O(1) memory."""
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
count = [0] * 26
for ch in s:
count[ord(ch) - ord('a')] += 1
for ch in t:
count[ord(ch) - ord('a')] -= 1
if count[ord(ch) - ord('a')] < 0:
return False
return TrueO(n) for both Counter and the
[26] table.O(1) (technically
O(k) where k is the alphabet size).O(n log n) time, O(n)
space.True incorrectly when len(s) != len(t).set(s) == set(t) is wrong! Sets
drop counts; "aab" and "ab" would compare
equal.Counter, you cannot stick with the
[26] table.Counter
solution first (concise, one line), then mention the [26]
array only if the interviewer asks about memory optimisation.Given a string s, consider it a
palindrome if, after converting all letters to
lowercase and removing every non-alphanumeric
character, it reads the same forwards and backwards. Return
True / False.
Input: s = "A man, a plan, a canal: Panama"
Output: True
Explanation: filtered → "amanaplanacanalpanama" — reads identically both ways.
Input: s = "race a car"
Output: False
Explanation: filtered → "raceacar" — not a palindrome.
Input: s = " "
Output: True
Explanation: an empty filtered string is considered a palindrome.
1 <= len(s) <= 2·10^5s may contain upper/lower-case letters, digits, and
arbitrary other characters.Brute force — filter then compare reversed —
O(n) time, O(n) space.
filtered = ''.join(ch.lower() for ch in s if ch.isalnum()),
then filtered == filtered[::-1]. Simple but uses
O(n) extra memory.
Optimal — Two pointers in place — O(n) time,
O(1) space. Use two pointers l (left)
and r (right). On each side, skip non-alphanumeric
characters, then compare s[l].lower() == s[r].lower().
Mismatch → return False.
class Solution:
def isPalindrome(self, s: str) -> bool:
l, r = 0, len(s) - 1
while l < r:
while l < r and not s[l].isalnum():
l += 1
while l < r and not s[r].isalnum():
r -= 1
if s[l].lower() != s[r].lower():
return False
l += 1
r -= 1
return TrueO(n) — each character is visited
at most once.O(1).l < r inside the inner
while loops → index out of range..lower() when comparing → "Aa"
would fail.isalpha() instead of isalnum() →
misses digits.s[l] or s[r] and check the
remainder.l < r carefully inside the
inner skip loops.Given an array of strings strs, return the
longest common prefix. If none exists, return
"".
Input: strs = ["flower", "flow", "flight"]
Output: "fl"
Input: strs = ["dog", "racecar", "car"]
Output: ""
Explanation: no character is shared at position 0.
1 <= len(strs) <= 2000 <= len(strs[i]) <= 200strs[i] contains only lowercase letters."".Approach 1 — Vertical scan, O(S) where
S is the total length. Iterate column-by-column
i = 0, 1, 2, .... At each i, check whether
strs[0][i] equals strs[j][i] for every
j. If a string runs out or a mismatch occurs → return
strs[0][:i].
Approach 2 — Horizontal scan. Take
prefix = strs[0], then for each subsequent string, shrink
prefix until it is a prefix of that string.
Approach 3 — Sort + compare endpoints,
O(n log n · L). Sort lexicographically. The
longest common prefix equals the common prefix of strs[0]
and strs[-1]. Cute but not time-optimal.
We recommend vertical scan because it is easiest to present on a whiteboard and supports early-exit on the first mismatch.
from typing import List
class Solution:
def longestCommonPrefix(self, strs: List[str]) -> str:
if not strs:
return ""
for i, ch in enumerate(strs[0]):
for s in strs[1:]:
if i >= len(s) or s[i] != ch:
return strs[0][:i]
return strs[0] # all of strs[0] is a common prefixO(S) where
S = Σ len(strs[i]) in the worst case.O(1).i >= len(s) → IndexError when a string is
shorter than strs[0].strs[0] when the answer is actually a shorter
prefix.strs = [""] → "".strs = ["a"] → "a".strs = ["abc", "abc"] → "abc".Implement atoi (ASCII to Integer), converting a string
into a signed 32-bit integer. Rules:
+ or - sign.int 32-bit:
[-2^31, 2^31 - 1].0 if no digits were read (e.g. the string is
entirely letters).Input: s = "42"
Output: 42
Input: s = " -42"
Output: -42 (skip leading spaces, read '-', then "42")
Input: s = "4193 with words"
Output: 4193 (stop at the whitespace after "4193")
Input: s = "words and 987"
Output: 0 (we hit 'w' immediately — no digits read)
Input: s = "-91283472332"
Output: -2147483648 (= INT_MIN, clamped because the number is too small)
Input: s = "+-12"
Output: 0 (already consumed '+', then '-' is non-digit → fail)
0 <= len(s) <= 200s contains letters, digits, ’ ‘,’+‘,’-‘,’.’.INT_MIN /
INT_MAX. Do not raise.Approach 1 — Sequential procedure with an index
walker. Four clear steps: skip space → read sign → read digits
→ clamp. Each step maintains its own state variables.
Approach 2 — Finite State Machine (FSM). A state machine yields concise code and is easy to extend when the problem adds requirements (decimals, scientific notation, …). Highly worth learning since it is the canonical pattern for any parser problem (Chapter 32).
FSM diagram:
blank sign digit other
┌───────────────────────────────────────────────┐
S │ start → start signed in_number end │
T │ signed → end end in_number end │
A │ in_num → end end in_number end │
T │ end → end end end end │
E └───────────────────────────────────────────────┘
States:
start : still skipping leading spaces
signed : sign already read, awaiting digits
in_number : currently consuming digits
end : finished; remaining characters are ignored
INT_MAX = 2**31 - 1 # 2147483647
INT_MIN = -2**31 # -2147483648
class Solution:
"""Approach 1 — sequential procedure."""
def myAtoi(self, s: str) -> int:
i, n = 0, len(s)
# 1. Skip leading whitespace.
while i < n and s[i] == ' ':
i += 1
# 2. Optional sign.
sign = 1
if i < n and s[i] in '+-':
sign = -1 if s[i] == '-' else 1
i += 1
# 3. Read digits.
result = 0
while i < n and s[i].isdigit():
result = result * 10 + (ord(s[i]) - ord('0'))
# Optimisation: clamp early to avoid running too long.
if result > 2**31:
break
i += 1
# 4. Apply sign and clamp.
result *= sign
return max(INT_MIN, min(INT_MAX, result))
class SolutionFSM:
"""Approach 2 — Finite State Machine. Easy to extend later."""
table = {
'start': {'blank': 'start', 'sign': 'signed', 'digit': 'in_num', 'other': 'end'},
'signed': {'blank': 'end', 'sign': 'end', 'digit': 'in_num', 'other': 'end'},
'in_num': {'blank': 'end', 'sign': 'end', 'digit': 'in_num', 'other': 'end'},
'end': {'blank': 'end', 'sign': 'end', 'digit': 'end', 'other': 'end'},
}
@staticmethod
def _kind(ch: str) -> str:
if ch == ' ': return 'blank'
if ch in '+-': return 'sign'
if ch.isdigit(): return 'digit'
return 'other'
def myAtoi(self, s: str) -> int:
state = 'start'
sign = 1
result = 0
for ch in s:
state = self.table[state][self._kind(ch)]
if state == 'in_num':
result = result * 10 + int(ch)
result = min(result, INT_MAX + 1) # early-clamp
elif state == 'signed':
sign = -1 if ch == '-' else 1
elif state == 'end':
break
return max(INT_MIN, min(INT_MAX, sign * result))O(n) — one pass through the
string.O(1).int is unbounded so you won’t crash, but you still
must clamp per the problem.+-12 or
++12 must terminate immediately after the second sign
character." 1 2 3" → result is 1.s.strip() is technically wrong — it removes trailing
whitespace too; in this problem it does not break correctness, but be
aware that you should s.lstrip() and not
strip() if you choose to pre-process.., e, signs, … → FSM is mandatory (Chapter
32).0b, 0x).Given an array of strings strs, group strings that are
anagrams of one another. Return the list of groups (the
order of groups and the order within each group does not matter).
Input: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
Output: [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]]
1 <= len(strs) <= 10^40 <= len(strs[i]) <= 100strs[i] contains only lowercase letters.Core idea: two strings are anagrams ↔︎ they share the
same “signature”. Use a dict
{signature: [strings]} to bucket them.
Approach 1 — signature = sorted(s),
O(n · k log k).
key = ''.join(sorted(s)). Anagrams share the same sorted
form.
Approach 2 — signature = tuple of 26 counts,
O(n · k).
key = tuple(Counter(s)[ch] for ch in 'abcdefghijklmnopqrstuvwxyz').
Avoids the O(k log k) sort, at the cost of a length-26
tuple overhead.
Illustration for
["eat", "tea", "tan", "ate", "nat", "bat"]:
str sorted_key bucket
───── ─────────── ─────────────────────
"eat" "aet" ──┐
"tea" "aet" ──┤───► bucket "aet" = ["eat", "tea", "ate"]
"ate" "aet" ──┘
"tan" "ant" ──┐
"nat" "ant" ──┤───► bucket "ant" = ["tan", "nat"]
"bat" "abt" ──────► bucket "abt" = ["bat"]
from collections import defaultdict
from typing import List
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
groups: dict[str, list[str]] = defaultdict(list)
for s in strs:
key = ''.join(sorted(s))
groups[key].append(s)
return list(groups.values())
class SolutionCount:
"""Signature = tuple of 26 counts — no sort needed."""
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
groups: dict[tuple, list[str]] = defaultdict(list)
for s in strs:
count = [0] * 26
for ch in s:
count[ord(ch) - ord('a')] += 1
groups[tuple(count)].append(s)
return list(groups.values())| Approach | Time | Space |
|---|---|---|
| sorted-key | O(n · k log k) |
O(n·k) |
| count-key | O(n · k) |
O(n·k) |
where n = number of strings, k = string
length.
k very small (≤ 100 as on LC) → both work, sorted-key
is cleaner.k large (≥ 10^4) → count-key wins because
O(k) < O(k log k).Counter, avoid a
1000-element tuple.''.join after
sorted() — returns a list, which is not hashable.Given a string s containing multiple
words separated by at least one space, reverse the
order of words and return the result such that:
Input: s = "the sky is blue"
Output: "blue is sky the"
Input: s = " hello world "
Output: "hello world" (compress leading/trailing + inner)
Input: s = "a good example"
Output: "example good a" (collapse multiple inner spaces into one)
1 <= len(s) <= 10^4s contains letters, digits, and spaces
' '.s contains at least one word.O(1) extra space (only
relevant if the input is a mutable character array, as in C/C++).s.split()? → Yes — it is the
Pythonic shortcut. The follow-up on a character array, however, requires
the 3-reverse trick.Approach 1 — Pythonic split-reverse-join,
O(n).
return ' '.join(reversed(s.split())).
s.split() with no argument automatically collapses runs of
whitespace and drops leading / trailing spaces — exactly what we
need.
Approach 2 — Three Reverses (in place on a character array). Apply the same idea as Rotate Array (problem 1.6): 1. Reverse the entire string. 2. Reverse each “word” inside the reversed string. 3. Clean up spaces (keep only one space between words; remove leading / trailing).
Illustration for
s = "the sky is blue":
Input : "the sky is blue"
Step 1: reverse the whole string
"eulb si yks eht"
Step 2: reverse each word inside the reversed string
"blue is sky the" ← answer ✓
Comparison with Rotate Array: Rotate Array reverses at the granularity of individual elements; Reverse Words reverses at the granularity of words (substrings between spaces). Same idea, different abstraction level.
class Solution:
"""Approach 1 — Pythonic, shortest."""
def reverseWords(self, s: str) -> str:
return ' '.join(reversed(s.split()))
class SolutionInPlace:
"""Approach 2 — three reverses, in place on a character list."""
def reverseWords(self, s: str) -> str:
chars = list(s.strip()) # Python strings are immutable → convert to list
# 1. Reverse the entire array.
self._reverse(chars, 0, len(chars) - 1)
# 2. Reverse each word.
start = 0
for i in range(len(chars) + 1):
if i == len(chars) or chars[i] == ' ':
self._reverse(chars, start, i - 1)
start = i + 1
# 3. Collapse multiple spaces between words.
return self._collapse_spaces(chars)
@staticmethod
def _reverse(arr: list, l: int, r: int) -> None:
while l < r:
arr[l], arr[r] = arr[r], arr[l]
l += 1
r -= 1
@staticmethod
def _collapse_spaces(chars: list) -> str:
out, prev_space = [], False
for ch in chars:
if ch == ' ':
if not prev_space and out:
out.append(' ')
prev_space = True
else:
out.append(ch)
prev_space = False
if out and out[-1] == ' ':
out.pop()
return ''.join(out)O(n) time,
O(n) space (Python builds a new string).O(n) time,
O(n) space for chars (Python strings are
immutable). If the input is a list[str] (as in C/C++ char
arrays), then O(1) extra space..strip() → leading/trailing spaces
remain.start = i instead of
i + 1 — characters get counted twice.char[], do it in place with O(1)
space — exactly the second approach above.a–z (26)? ASCII
128? Unicode? — The [26] counting array only works when the
alphabet is exactly 26 letters."Aa" a
palindrome? LC 125 lowercases first; LC 5 does not.isalnum(), or does the problem guarantee a clean
input?strip() before parsing numbers.| Pattern | When to use | Chapter |
|---|---|---|
Counting (Counter, [26]) |
Anagrams, frequency | 02, 06 |
| Two pointers (in / out) | Palindrome, reverse | 02, 26 |
| Sliding window | Substring under a dynamic constraint | 27 |
| Parsing with stack / FSM | atoi, calculator, Decode | 08, 32 |
| Pattern matching | strStr, anagrams in text | 35, 36, 34 |
| String hashing | Rabin-Karp, distinct substrings | 34 |
"eat" → "aet":
2-line code, O(n·k log k).(0,0,1,...,1,...):
O(n·k), wins when k is large and the alphabet
is small.LC 8 (atoi) is a small FSM with 4 states: start, sign, digits, overflow. When problems get more complex (Valid Number, Calculator) → see Chapter 32.
Recursion is the natural language for problems with a self-similar structure — solving a large problem by combining the solutions of a few smaller subproblems. This chapter teaches you to feel the three components of a recursive solution: (i) the base case (stopping condition), (ii) the recursive case (calling smaller subproblems), (iii) the combination of subproblem results. Once these three click, you will see DP, Backtracking, Trees, and Graph DFS as dialects of the same language.
After this chapter, you will be able to:
choose → explore → unchoose mantra for
backtracking.@cache.RecursionError traps.f(n) = ... f(n-1) ...
or f(L,R) = ... f(L,M) + f(M+1,R) ....Three questions to answer before coding any recursion: 1. What variables make up the state of the function? (Enough to fully define a subproblem.) 2. What is the base case? (When do we return immediately?) 3. What does the recursive step look like — how do we split the problem and combine the results?
from functools import cache
# 1) "Pure" recursion (can be slow because subproblems repeat).
def recurse(state):
if base_condition(state):
return base_value
result = combine(recurse(subproblem_1(state)),
recurse(subproblem_2(state)))
return result
# 2) Top-down DP: cache for O(#states) total work.
@cache
def f(*state):
if base_condition(*state):
return base_value
return combine(f(*sub1(*state)), f(*sub2(*state)))
# 3) Backtracking: enumerate + undo.
def backtrack(path, choices):
if is_solution(path):
results.append(path.copy())
return
for c in choices:
if not valid(c, path):
continue
path.append(c)
backtrack(path, next_choices(choices, c))
path.pop() # undo — the signature of backtrackingCompute the n-th Fibonacci number, defined by
F(0) = 0, F(1) = 1, and
F(n) = F(n-1) + F(n-2) for n >= 2.
Input: n = 2 → 1
Input: n = 3 → 2
Input: n = 10 → 55
0 <= n <= 30 on LC; follow-ups often go up to
n <= 10^6 or n <= 10^18.n be? → Drives the choice of pure
recursion / DP / matrix exponentiation.n (10^18)
typically modulo 10^9 + 7.Approach 1 — Pure recursion, O(2^n).
Each F(n) triggers two recursive calls. The call tree has
~2^n nodes → very slow.
Approach 2 — Memoisation (top-down DP), O(n)
time, O(n) space. Cache results → each
F(k) is computed exactly once.
Approach 3 — Iterative (bottom-up), O(n) time,
O(1) space. Keep two rolling variables
prev, curr.
Approach 4 — Matrix exponentiation,
O(log n). When n reaches
10^18.
Illustration — call tree for F(5):
F(5)
/ \
F(4) F(3)
/ \ / \
F(3) F(2) F(2) F(1)
/ \ / \ / \
F(2) F(1)... ... ← F(3), F(2), F(1) are RECOMPUTED many times
→ With memoisation, only n+1 unique calls are made (n=5 → 6 calls).
→ Without memoisation, total calls ~ Fibonacci(n+1) ~ φ^n (exponential).
from functools import cache
class Solution:
"""Approach 3 — iterative O(1) space, the production answer."""
def fib(self, n: int) -> int:
if n < 2:
return n
prev, curr = 0, 1
for _ in range(2, n + 1):
prev, curr = curr, prev + curr
return curr
class SolutionMemo:
"""Approach 2 — top-down DP."""
@cache
def fib(self, n: int) -> int:
if n < 2:
return n
return self.fib(n - 1) + self.fib(n - 2)
class SolutionMatrix:
"""Approach 4 — matrix exponentiation, O(log n)."""
MOD = 10**9 + 7
def fib(self, n: int) -> int:
if n < 2:
return n
# [[F(n+1), F(n)], [F(n), F(n-1)]] = [[1,1],[1,0]] ^ n
result, base = [[1, 0], [0, 1]], [[1, 1], [1, 0]]
while n > 0:
if n & 1:
result = self._mul(result, base)
base = self._mul(base, base)
n >>= 1
return result[0][1] # F(n)
def _mul(self, a, b):
return [[(a[0][0]*b[0][0] + a[0][1]*b[1][0]) % self.MOD,
(a[0][0]*b[0][1] + a[0][1]*b[1][1]) % self.MOD],
[(a[1][0]*b[0][0] + a[1][1]*b[1][0]) % self.MOD,
(a[1][0]*b[0][1] + a[1][1]*b[1][1]) % self.MOD]]O(n) time, O(1) space —
best for every common case.O(n) time, O(n) space
(stack + cache).O(log n) time — when
n is huge.n >= 40.n < 2 → infinite
recursion.F(0) = F(1) = 1.Implement a function that computes x^n where
x is a real number and n is an integer
(possibly negative).
Input: x = 2.00000, n = 10 → 1024.00000
Input: x = 2.10000, n = 3 → 9.26100
Input: x = 2.00000, n = -2 → 0.25
-100.0 < x < 100.0-2^31 <= n <= 2^31 - 1n, is the result
1 / x^|n|? → Yes.x = 0 and n = 0? →
Convention 0^0 = 1 (per LC).Approach 1 — Multiply by hand, O(n).
Loop n times. TLEs at n = 2^31.
Approach 2 — Fast Power (recursive / iterative),
O(log n).
Recursive observation: - If n == 0: return
1. - If n is even:
x^n = (x^(n/2))^2. - If n is odd:
x^n = x · x^(n-1).
Handling negative n: recurse with -n then
invert.
Illustration — call tree for x^10:
x^10
│ even → (x^5)^2
▼
x^5
│ odd → x · x^4
▼
x^4
│ even → (x^2)^2
▼
x^2
│ even → (x^1)^2
▼
x^1
│ odd → x · x^0
▼
x^0 = 1
Total multiplications: ~ 2 log₂(10) ≈ 8 (vs. 10 for brute force)
class Solution:
"""Recursive fast power."""
def myPow(self, x: float, n: int) -> float:
if n == 0:
return 1.0
if n < 0:
return 1.0 / self.myPow(x, -n)
half = self.myPow(x, n // 2)
return half * half if n % 2 == 0 else half * half * x
class SolutionIter:
"""Iterative — avoids recursion depth. Reads bits from low to high."""
def myPow(self, x: float, n: int) -> float:
if n < 0:
x, n = 1.0 / x, -n
result = 1.0
base = x
while n > 0:
if n & 1:
result *= base
base *= base
n >>= 1
return resultO(log n) — each step halves
n.O(log n) (call
stack); iterative O(1).n = -2^31 negated becomes
2^31 which overflows. Python’s int is
unbounded so it is safe, but be aware.n // 2 (integer division) → wrong result for
odd n.half → loses the O(log n) guarantee.x^n mod m —
replace *= with * % m.n is
huge, represented as a digit array.Given the head of a singly linked list, reverse the list
and return the new head. (There are two classic approaches: iterative
and recursive. This chapter focuses on the recursive
version; the iterative version returns in Chapter 7.)
Input: head = 1 → 2 → 3 → 4 → 5 → None (singly linked list)
Output: 5 → 4 → 3 → 2 → 1 → None
Input: head = None (empty list)
Output: None
0 <= number of nodes <= 5000-5000 <= node.val <= 5000.next)?
→ Yes, that is the core requirement.Recursive idea: - Base case: if
head is None or head.next is
None → return head. - Recursive
step: call reverseList(head.next) to reverse the
tail; we receive the new last node (which is the original tail → the new
head of the reversed list). - Combination: at this
point head.next still points to the original node right
after head (recursion only operated on the tail). Set
head.next.next = head and head.next = None to
stitch head to the end of the reversed list.
Illustration with 1 → 2 → 3 → None:
Call reverseList(1):
reverseList(2):
reverseList(3):
base case → return 3 # 3 → None
# here head=2, head.next=3
# tail is reversed: 3 → None
# stitch 2 after 3:
head.next.next = head # 3 → 2
head.next = None # 2 → None
# chain so far: 3 → 2 → None, new head = 3
return 3
# here head=1, head.next=2
# tail is reversed: 3 → 2 → None
# stitch 1 after 2:
head.next.next = head # 2 → 1
head.next = None # 1 → None
# chain: 3 → 2 → 1 → None
return 3
class ListNode:
def __init__(self, val: int = 0, next: 'ListNode | None' = None):
self.val = val
self.next = next
class Solution:
def reverseList(self, head: ListNode | None) -> ListNode | None:
if head is None or head.next is None:
return head
new_head = self.reverseList(head.next)
head.next.next = head
head.next = None
return new_headO(n) — each node is visited
exactly once.O(n) call stack (Python has no
tail-call optimisation).head.next = None → infinite
cycle (1 → 2 → 1 → 2 …).head instead of
new_head → losing the tail.RecursionError. Switch to
iterative:prev = None
while head:
nxt = head.next
head.next = prev
prev = head
head = nxt
return prev[left, right].Given an integer n, generate all
well-formed parenthesis strings of length 2n.
Input: n = 3
Output: ["((()))", "(()())", "(())()", "()(())", "()()()"]
Input: n = 1
Output: ["()"]
1 <= n <= 8C(n) = (2n)! / (n!(n+1)!). But this problem requires
enumeration.Idea: generate the characters ( and
) one at a time. Each step has 2 choices, constrained by
validity: - Number of ( placed must
not exceed n. - Number of )
placed must not exceed the number of (
placed (otherwise an unmatched close-paren appears).
Recurse with two counters: open_count,
close_count. When len(path) == 2n → push to
results.
Illustration — decision tree for
n = 2:
""
/ \
( / \ ) ✗ (close > open)
"("
/ \
( / \ )
"((" "()"
│ │
) ▼ ( / \ ) ✗
"(()" "()("
│ │
) ▼ ) ▼
"(())" ★ "()()" ★
Answer: ["(())", "()()"]
(✗ = branch pruned because invalid)
from typing import List
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
result: list[str] = []
def backtrack(path: list[str], open_cnt: int, close_cnt: int) -> None:
if len(path) == 2 * n:
result.append(''.join(path))
return
if open_cnt < n:
path.append('(')
backtrack(path, open_cnt + 1, close_cnt)
path.pop()
if close_cnt < open_cnt:
path.append(')')
backtrack(path, open_cnt, close_cnt + 1)
path.pop()
backtrack([], 0, 0)
return resultO(C(n) · n) where
C(n) is the n-th Catalan number
(2n)! / (n!(n+1)!). Building each string costs
O(n).O(n) call stack +
O(C(n) · n) for the output.) when close_cnt >= open_cnt →
produces invalid strings such as ())).path.pop() after recursion → state leaks
into sibling branches.Given an array nums of distinct
integers, return all possible permutations of them.
Input: nums = [1, 2, 3]
Output: [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]
1 <= len(nums) <= 6-10 <= nums[i] <= 10nums are distinct.Idea: at each step, pick an unused
number and push it into path. When path
reaches n elements → record one complete permutation.
Two ways to track “used”: - A
used: bool[n] array. - A set of used
indices.
Illustration — decision tree for
[1, 2, 3]:
[ ]
┌────────────┼────────────┐
[1] [2] [3]
/ \ / \ / \
[1,2] [1,3] [2,1] [2,3] [3,1] [3,2]
│ │ │ │ │ │
[1,2,3][1,3,2] [2,1,3][2,3,1][3,1,2][3,2,1]
Total: 3 · 2 · 1 = 6 permutations.
from typing import List
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
result: list[list[int]] = []
n = len(nums)
used = [False] * n
path: list[int] = []
def backtrack() -> None:
if len(path) == n:
result.append(path.copy())
return
for i in range(n):
if used[i]:
continue
used[i] = True
path.append(nums[i])
backtrack()
path.pop()
used[i] = False
backtrack()
return result
class SolutionSwap:
"""Approach 2 — swap in place, no used array required."""
def permute(self, nums: List[int]) -> List[List[int]]:
result: list[list[int]] = []
def backtrack(start: int) -> None:
if start == len(nums):
result.append(nums.copy())
return
for i in range(start, len(nums)):
nums[start], nums[i] = nums[i], nums[start]
backtrack(start + 1)
nums[start], nums[i] = nums[i], nums[start] # undo
backtrack(0)
return resultO(n · n!) — there are
n! permutations, each built in O(n).O(n) for the call stack +
path (output excluded).path.copy() → every entry in
result references the same list (mutated later).used[i] = False on undo → permutations are
missing.if i > 0 and nums[i] == nums[i-1] and not used[i-1]: continue.k-th permutation without enumerating all of them.Given an array nums of distinct
integers, return all possible subsets of
nums (including the empty set and the full set), totalling
2^n subsets.
Input: nums = [1, 2, 3]
Output: [[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]]
Input: nums = [0]
Output: [[], [0]]
1 <= len(nums) <= 10-10 <= nums[i] <= 10There are three classic approaches, all worth knowing.
Approach 1 — Backtracking “pick / skip”. At each
index i, branch into two: include nums[i] in
path or skip it.
Approach 2 — Backtracking “start-index”. Every node
in the recursion tree calls result.append(path.copy())
(every prefix is a valid subset), then loops
for i in range(start, n).
Approach 3 — Bitmask iteration. Each number from
0 to 2^n - 1 represents one subset: bit
i set ↔︎ nums[i] is in the subset. (See Chapter
21.)
Illustration — “pick / skip” tree for
[1, 2, 3]:
[ ]
skip 1 / \ pick 1
[ ] [1]
skip 2 / \ pick 2 skip 2 / \ pick 2
[ ] [2] [1] [1,2]
skip3/\ ... ... ... ...
[ ] [3]
Each LEAF = one subset. The tree has 2^3 = 8 leaves.
from typing import List
class Solution:
"""Approach 2 — every prefix is a subset."""
def subsets(self, nums: List[int]) -> List[List[int]]:
result: list[list[int]] = []
path: list[int] = []
def backtrack(start: int) -> None:
result.append(path.copy()) # every state is a subset
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return result
class SolutionBitmask:
"""Approach 3 — iterate through 2^n bitmasks."""
def subsets(self, nums: List[int]) -> List[List[int]]:
n = len(nums)
result = []
for mask in range(1 << n):
subset = [nums[i] for i in range(n) if mask & (1 << i)]
result.append(subset)
return result
class SolutionPick:
"""Approach 1 — pick / skip backtracking."""
def subsets(self, nums: List[int]) -> List[List[int]]:
result: list[list[int]] = []
path: list[int] = []
def backtrack(i: int) -> None:
if i == len(nums):
result.append(path.copy())
return
# branch: skip nums[i]
backtrack(i + 1)
# branch: pick nums[i]
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return resultO(n · 2^n) — 2^n
subsets, each copied in O(n).O(n) call stack (output
excluded).path.copy() in
result.append(path) — every entry will reference the same
list.result.append(path.copy())
before the loop (every prefix is a subset). If you
append after the loop you will miss the “leaf” subsets.if i > start and nums[i] == nums[i-1]: continue.len(path) == k).| Property | Recursion | DFS | Backtracking | Top-down DP |
|---|---|---|---|---|
| Goal | Self-call to solve a subproblem | Traverse a graph/tree | Enumerate every solution | Optimal value / count |
| Need undo? | Optional | Rare | Required (choose/unchoose) | No |
| Need memo? | Sometimes | Rarely | Rarely (state depends on path) | Required |
| Examples | Factorial, Fibonacci | Number of Islands | Permutations, N-Queens | LCS, Coin Change |
def backtrack(path, choices):
if is_goal(path):
record(path); return
for c in choices:
if not feasible(path, c): continue
path.append(c) # choose
backtrack(path, ...) # explore
path.pop() # unchoose
1000; for trees / lists with depth >
1000 use sys.setrecursionlimit(10**6) and
raise the OS stack (threading.stack_size).def f(n): return f(n-1) still blows the stack.Sort is itself a solved problem. This chapter does not teach you to implement quicksort — Python already ships an excellent
sorted()(Timsort, worst-caseO(n log n), stable). What you really need to learn is when sorting is the enabler that solves the problem, and the secret lies in custom comparators and the techniques that scan a sorted array with two pointers / sweep line.
After this chapter, you will be able to:
cmp_to_key).O(n log n) is just a setup cost)."33" vs "3"
should compare via string concatenation (problem 4.3 Largest
Number).Three golden questions: 1. Sort by which
key? start, end, length, frequency, ratio? 2. How do we
sweep after sorting? one-pass / two pointers / sweep line /
heap? 3. Do we need stability? Python’s
sorted is stable by default — a valuable asset.
from functools import cmp_to_key
from typing import List
# 1) Sort by a simple key
nums.sort(key=lambda x: x[0])
# 2) Sort by multiple keys (tie-breaker)
nums.sort(key=lambda x: (x[0], -x[1])) # x[0] ascending, x[1] descending
# 3) Sort with a custom comparator
def cmp(a, b) -> int:
if a + b > b + a: return -1 # a comes first
if a + b < b + a: return 1 # b comes first
return 0
arr.sort(key=cmp_to_key(cmp))
# 4) Sweep line over an event array
events = [(start, +1), (end, -1)]
events.sort()Given an array nums containing only the values
0, 1, and 2
(representing 3 colours), rearrange it so equal colours are adjacent in
the order 0 → 1 → 2. You must do it in place,
without using the language’s sort function.
Input: nums = [2, 0, 2, 1, 1, 0]
Output: [0, 0, 1, 1, 2, 2]
Input: nums = [2, 0, 1]
Output: [0, 1, 2]
1 <= len(nums) <= 300nums[i] ∈ {0, 1, 2}O(1)
extra space.{0, 1, 2}? → No
per the problem.Approach 1 — Two-pass counting sort,
O(n). Count the number of 0s, 1s, 2s, then
overwrite. Simple but two passes.
Approach 2 — Dutch National Flag (Edsger Dijkstra), one pass,
O(n).
Maintain 3 pointers: - lo = the right boundary of the
0s region (everything in [0..lo-1] is
0). - hi = the left boundary of the
2s region (everything in [hi+1..n-1] is
2). - mid = the scanning pointer between the
two regions.
Invariant: [0..lo-1] = 0, [lo..mid-1] = 1,
[mid..hi] unprocessed, [hi+1..n-1] = 2.
At each step: - nums[mid] == 0 → swap
with nums[lo], lo++, mid++. -
nums[mid] == 1 → already in the correct region,
mid++. - nums[mid] == 2 → swap with
nums[hi], hi-- (do not
increment mid because the value that just came from
hi has not been processed).
Illustration for
nums = [2, 0, 2, 1, 1, 0]:
lo mid hi
Init : [ 2, 0, 2, 1, 1, 0 ]
↑ ↑ ↑
lo=0 mid=0 hi=5
mid=0: nums[0]=2 → swap(0,5), hi--
[ 0, 0, 2, 1, 1, 2 ]
↑ ↑ ↑
lo=0 mid=0 hi=4
mid=0: nums[0]=0 → swap(lo,mid)=swap(0,0), lo++, mid++
[ 0, 0, 2, 1, 1, 2 ]
↑ ↑ ↑
lo=1 mid=1 hi=4
mid=1: nums[1]=0 → swap(1,1), lo++, mid++
[ 0, 0, 2, 1, 1, 2 ]
↑ ↑ ↑
lo=2 mid=2 hi=4
mid=2: nums[2]=2 → swap(2,4), hi--
[ 0, 0, 1, 1, 2, 2 ]
↑ ↑ ↑
lo=2 mid=2 hi=3
mid=2: nums[2]=1 → mid++
[ 0, 0, 1, 1, 2, 2 ]
↑ ↑
lo=2 mid=3 hi=3
mid=3: nums[3]=1 → mid++
[ 0, 0, 1, 1, 2, 2 ]
↑ ↑
lo=2 mid=4 hi=3 ← mid > hi → stop
Result : [0, 0, 1, 1, 2, 2] ✓
from typing import List
class Solution:
def sortColors(self, nums: List[int]) -> None:
lo, mid, hi = 0, 0, len(nums) - 1
while mid <= hi:
if nums[mid] == 0:
nums[lo], nums[mid] = nums[mid], nums[lo]
lo += 1
mid += 1
elif nums[mid] == 1:
mid += 1
else: # nums[mid] == 2
nums[mid], nums[hi] = nums[hi], nums[mid]
hi -= 1
# DO NOT increment mid — we have not seen what just came from hiO(n) — each iteration advances
mid or decrements hi at least once.O(1).mid after swapping with hi —
skips the freshly arrived element.mid < hi instead of
mid <= hi — misses the last cell.O(n log n) on average, but with many repeated elements
3-way partition avoids the O(n²) degenerate case.k colours (k > 3)?” →
Counting sort, O(n + k).Given an array of intervals
intervals[i] = [start_i, end_i], merge all
overlapping intervals into non-overlapping ones and
return the result.
Input: intervals = [[1,3], [2,6], [8,10], [15,18]]
Output: [[1,6], [8,10], [15,18]]
Explanation: [1,3] and [2,6] overlap → merged into [1,6].
Input: intervals = [[1,4], [4,5]]
Output: [[1,5]]
Explanation: [1,4] and [4,5] touch at point 4 → counted as overlapping.
1 <= len(intervals) <= 10^4intervals[i].length == 20 <= start_i <= end_i <= 10^4[1,4] and [4,5] merge).Brute force. Repeatedly find an overlapping pair and
merge. O(n²) or worse.
Optimal — Sort + one pass —
O(n log n).
Sort by start ascending. Sweep through, keeping
last = the most recently appended interval. For each new
cur: - If cur.start <= last.end → overlap;
extend last.end = max(last.end, cur.end). - Otherwise →
push cur as a new interval.
Illustration for
[[1,3], [2,6], [8,10], [15,18]]:
Number line:
1 3 5 7 9 11 13 15 17 19
| | | | | | | | | |
├───┤ [1,3]
├──────────┤ [2,6]
├───┤ [8,10]
├───┤ [15,18]
After sorting by start: [[1,3], [2,6], [8,10], [15,18]]
Sweep:
Push [1,3] result = [[1,3]]
cur=[2,6], 2 <= 3 → extend [1, max(3,6)] = [1,6]
result = [[1,6]]
cur=[8,10], 8 > 6 → push result = [[1,6], [8,10]]
cur=[15,18], 15 > 10 → push result = [[1,6], [8,10], [15,18]]
Result: [[1,6], [8,10], [15,18]]
from typing import List
class Solution:
def merge(self, intervals: List[List[int]]) -> List[List[int]]:
intervals.sort(key=lambda x: x[0])
result: list[list[int]] = []
for cur in intervals:
if result and cur[0] <= result[-1][1]:
result[-1][1] = max(result[-1][1], cur[1])
else:
result.append(cur[:]) # copy so we don't alias the input
return resultO(n log n) — sorting
dominates.O(n) for the output (or
O(log n) for sort’s stack).< instead of <= for the
overlap check → misses the “touch at a point” case.cur and pushing it
directly → later mutations to result[-1][1] may
accidentally mutate the input.Given an array of non-negative integers nums, arrange
them (in some order) to form a single concatenated string that
represents the largest possible number. Return the
result as a string (because the number can be huge).
Input: nums = [10, 2]
Output: "210"
Input: nums = [3, 30, 34, 5, 9]
Output: "9534330"
Input: nums = [0, 0]
Output: "0" (not "00")
1 <= len(nums) <= 1000 <= nums[i] <= 10^90, return "0" or
"000...0"? → "0"."0" itself).Brute force — try every permutation,
O(n! · n). TLE for any non-trivial
n.
Optimal — Sort with a custom comparator —
O(n log n · L) where L is the max string
length.
Insight: to decide whether a should
precede b, compare the two possible
concatenations: str(a) + str(b) vs
str(b) + str(a) — the one that is lexicographically larger
wins.
Why is it valid? “Concatenation-bigger” is a transitive relation — provable rigorously via the lexicographic ordering of concatenations, which guarantees a consistent sort order exists.
Example: a = 3, b = 30 → "330"
> "303" → 3 comes before
30.
from functools import cmp_to_key
from typing import List
class Solution:
def largestNumber(self, nums: List[int]) -> str:
strs = [str(x) for x in nums]
def cmp(a: str, b: str) -> int:
if a + b > b + a: return -1 # a first
if a + b < b + a: return 1 # b first
return 0
strs.sort(key=cmp_to_key(cmp))
result = ''.join(strs)
# edge case: [0, 0, 0] → avoid returning "000"
return '0' if result[0] == '0' else resultO(n log n · L) — each comparison
costs O(L), with O(n log n) comparisons.O(n · L) for the string
list."000" instead of "0".-1 / 1) —
always sanity-check with a tiny example.cmp_to_key? Python
3 dropped the cmp= argument from sort() — only
key= remains. For a custom comparator we pipe it through
functools.cmp_to_key() to turn it into a “key
function”.cmp_to_key)?
strs.sort(key=lambda s: s * 10, reverse=True) (since max
length ~ 10).Given an array of intervals
intervals[i] = [start_i, end_i] representing meetings, find
the minimum number of rooms required to host them
all.
(That is, at any point in time, what is the maximum number of simultaneous meetings?)
Input: intervals = [[0,30], [5,10], [15,20]]
Output: 2
Explanation: at t=5, [0,30] and [5,10] overlap → 2 rooms needed.
Input: intervals = [[7,10], [2,4]]
Output: 1
Explanation: 2 meetings don't overlap, 1 room suffices.
1 <= len(intervals) <= 10^40 <= start_i < end_i <= 10^6[1,4] and
[4,5]) overlap? → Per LC convention:
no (end is exclusive, or end == start means the next
meeting starts immediately after the previous one ends).There are three solid approaches, all worth knowing:
Approach 1 — Heap (priority queue) —
O(n log n).
Sort by start. Scan each meeting and maintain a min-heap
of end_times for currently open meetings. When a new
meeting arrives at start: - If the heap top has
end <= start → the old meeting is over → pop it (reuse
the room). - Push the new end onto the heap.
The heap size at any moment = number of rooms currently in use → the max of that size over time is the answer.
Approach 2 — Sweep line / chronological —
O(n log n).
Build two arrays: starts (sorted) and ends
(sorted). Use two pointers: if starts[i] < ends[j] → a
new meeting begins before the oldest ends → need a room
(rooms++, i++); otherwise → an old meeting
ends, advance j.
Approach 3 — Event-driven,
O(n log n).
Each meeting emits two events: (start, +1) and
(end, -1). Sort all events (prefer -1 before
+1 at the same time → close before open). Sweep, tracking
cur and peak.
Illustration for
[[0,30], [5,10], [15,20]] — heap-based:
Sort by start: [[0,30], [5,10], [15,20]]
Step 1: meeting [0,30] heap = [30] → rooms = 1
Step 2: meeting [5,10] top=30 > 5 → keep; push 10 heap = [10, 30] → rooms = 2 ★
Step 3: meeting [15,20] top=10 <= 15 → pop 10; push 20 heap = [20, 30] → rooms = 2
Number line:
0 5 10 15 20 25 30
| | | | | | |
├───────────────────────┤ [0, 30] uses room A
├───┤ [5, 10] uses room B
├───┤ [15, 20] reuses room B
import heapq
from typing import List
class Solution:
def minMeetingRooms(self, intervals: List[List[int]]) -> int:
if not intervals:
return 0
intervals.sort(key=lambda x: x[0])
heap: list[int] = [] # min-heap of end times
for start, end in intervals:
if heap and heap[0] <= start:
heapq.heappop(heap)
heapq.heappush(heap, end)
return len(heap)
class SolutionEvents:
"""Event-driven — clean for follow-ups."""
def minMeetingRooms(self, intervals: List[List[int]]) -> int:
events = []
for s, e in intervals:
events.append((s, +1))
events.append((e, -1))
# On a tie: -1 before +1 (close room before opening a new one)
events.sort(key=lambda x: (x[0], x[1]))
cur = peak = 0
for _, delta in events:
cur += delta
peak = max(peak, cur)
return peakO(n log n).O(n).(end == start) you process the order wrong.heap[0] <= start (the <=)
— using < treats adjacent meetings as overlapping.Given two strings order (containing
distinct characters) and s, rearrange
s so that the order of characters appearing in
order is respected. Characters of s not
present in order may be placed anywhere in the result.
Input: order = "cba", s = "abcd"
Output: "cbad"
Explanation: in order, c < b < a. The character 'd' does not appear in order
and can be placed anywhere.
Input: order = "bcafg", s = "abcd"
Output: "bcad"
1 <= len(order) <= 26, characters in
order are distinct.1 <= len(s) <= 200order go? →
Anywhere. We conventionally append them at the end for cleanliness.Approach 1 — Sort by an index table comparator,
O(|s| log |s|).
Build a dict
priority = {ch: i for i, ch in enumerate(order)}.
Characters not in order get a very large priority
(e.g. 26). Sort s by this priority.
Approach 2 — Counter + emit in order,
O(|s|).
Compute Counter(s), then iterate over each character in
order and “emit” it the right number of times. Finally
append the remaining characters (those not in order).
Approach 2 needs no sort, is faster, and feels very natural — interviewers typically expect this version.
from collections import Counter
class Solution:
"""Approach 2 — Counter + emit in order."""
def customSortString(self, order: str, s: str) -> str:
cnt = Counter(s)
parts: list[str] = []
# 1. Characters that belong to `order`, in the exact order.
for ch in order:
if ch in cnt:
parts.append(ch * cnt.pop(ch))
# 2. Remaining characters (not in `order`) — order doesn't matter.
for ch, c in cnt.items():
parts.append(ch * c)
return ''.join(parts)
class SolutionSort:
"""Approach 1 — comparator by index table."""
def customSortString(self, order: str, s: str) -> str:
priority = {ch: i for i, ch in enumerate(order)}
return ''.join(sorted(s, key=lambda ch: priority.get(ch, 26)))| Approach | Time | Space |
|---|---|---|
| Counter | O(|s| + |order|) |
O(1) |
| Sort key | O(|s| log |s|) |
O(|s|) |
priority[ch] instead of
priority.get(ch, 26) → KeyError for characters not in
order.cnt[ch] without popping → the
remaining loop will reprint them. Use cnt.pop(ch).order contains duplicates?” → The
problem excludes this, but otherwise use the first occurrence.s is huge (10^9 characters)?” →
Counter is still O(|s|), but you must stream.Given nums, rearrange it so that:
nums[0] <= nums[1] >= nums[2] <= nums[3] >= nums[4] <= ...
Odd indices are always >= their left and right
neighbours.
Input: nums = [3, 5, 2, 1, 6, 4]
Output: [3, 5, 1, 6, 2, 4] (one of many valid answers)
Input: nums = [6, 6, 5, 6, 3, 8]
Output: [6, 6, 5, 6, 3, 8] (already satisfies)
1 <= len(nums) <= 5·10^40 <= nums[i] <= 10^4<= and >= handle duplicates
naturally.Approach 1 — Sort then swap pairs,
O(n log n). Sort ascending, then swap each pair
(i, i+1) for odd i. Correct but
suboptimal.
Approach 2 — Greedy one pass, O(n).
Observation: at each index i, - If i is odd
(1, 3, 5, …): nums[i] >= nums[i-1]. - If i
is even (2, 4, 6, …): nums[i] <= nums[i-1].
Sweep from i = 1. On violation, swap
nums[i] and nums[i-1]. Why does the swap not
break the previous relation? We only modify the element at index
i-1 (making it smaller or larger), and the relation between
nums[i-2] and nums[i-1] from the previous step
already enforces a “safe boundary”.
Illustration for
nums = [3, 5, 2, 1, 6, 4]:
Index : 0 1 2 3 4 5
Input : [3, 5, 2, 1, 6, 4]
odd even odd even odd
≥ ≤ ≥ ≤ ≥
i=1 (odd) : want nums[1] >= nums[0] 5 >= 3 ✓
i=2 (even): want nums[2] <= nums[1] 2 <= 5 ✓
i=3 (odd) : want nums[3] >= nums[2] 1 >= 2 ✗ → swap
[3, 5, 1, 2, 6, 4]
i=4 (even): want nums[4] <= nums[3] 6 <= 2 ✗ → swap
[3, 5, 1, 6, 2, 4]
i=5 (odd) : want nums[5] >= nums[4] 4 >= 2 ✓
Result : [3, 5, 1, 6, 2, 4] ✓
from typing import List
class Solution:
def wiggleSort(self, nums: List[int]) -> None:
for i in range(1, len(nums)):
should_be_greater = (i % 2 == 1)
if (should_be_greater and nums[i] < nums[i - 1]) or \
(not should_be_greater and nums[i] > nums[i - 1]):
nums[i], nums[i - 1] = nums[i - 1], nums[i]O(n) — single pass.O(1).nums[i] (swapping if needed), the property for indices
0..i is preserved. During the swap only
nums[i-1] changes — and that only affects the pair
(i-2, i-1), which is designed to still
hold after the swap (if nums[i] < nums[i-1]
while we need nums[i] >= nums[i-1], swapping makes
nums[i-1] smaller, which still satisfies
nums[i-1] <= nums[i-2] from the previous step).< / > instead of
<= / >= — fails when there are
duplicates.< and >) → much harder, requires sort +
interleave.Buys: - Brings order to monotonic, unlocking two-pointer, binary search, sweep. - Groups “similar” elements together (anagram, intervals).
Costs: - Loses the original index →
if the output requires indices, store (value, idx) first. -
Mutates the input — clarify with the interviewer before sorting. -
O(n log n), not free.
(a+b) vs (b+a) is transitive
— provable, so it is safe with cmp_to_key.cmp parameter. Use
from functools import cmp_to_key."00...0" → strip leading zeros after
joining.| Heap | Sweep line | |
|---|---|---|
| Mental model | Which room frees up earliest → reuse | Count overlap at each timestamp |
| Code | heapq + sort by start |
Sort events (time, ±1) |
| Output | Max rooms | Max rooms |
| Extending | Easy to also return the schedule (which room when) | Hard to return the schedule |
nums[0] ≤ nums[1] ≥ nums[2] ≤ ... — just swap a bad
neighbour → O(n).nums[0] < nums[1] > nums[2] < ... —
strict, requires sort + interleave → O(n log n) or the
median trick.Binary Search looks simple — “halve a sorted array” — yet in interviews it is the number-one bug magnet. Knuth famously wrote: “although the idea is simple, getting it right is harder than it looks”. This chapter teaches you one single template that applies to every variant: find equal, find boundary, search on rotated, search on the answer, … — we return to it in Chapter 25 (Advanced Binary Search).
After this chapter, you will be able to:
[lo, hi)).O(log n) or hints at search.[lo, hi] and the problem splits into two halves “has
answer” / “no answer”.Standard thought template: 1. What is the
search space? (index, value, the answer itself). 2.
What does check(mid) return as
True/False? Is it monotonic? 3.
Which boundary is the answer? First True
or last False?
def lower_bound(nums: list[int], target: int) -> int:
"""Return the first index where nums[i] >= target. Returns len(nums) if absent."""
lo, hi = 0, len(nums) # [lo, hi) — half-open
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
def upper_bound(nums: list[int], target: int) -> int:
"""Return the first index where nums[i] > target."""
lo, hi = 0, len(nums)
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] <= target:
lo = mid + 1
else:
hi = mid
return lo
def binary_search_answer(check, lo: int, hi: int) -> int:
"""Find the smallest value in [lo, hi] for which check(x)=True (check is F→T monotonic)."""
while lo < hi:
mid = (lo + hi) // 2
if check(mid):
hi = mid
else:
lo = mid + 1
return loFinal tip: I always use the half-open
[lo, hi)interval and the loop conditionlo < hi. This convention matches Python’sbisectand has fewer off-by-one bugs thanlo <= hi.
Given a sorted (ascending) array nums and a number
target, return the index of
target in nums, or -1 if absent.
Must run in O(log n).
Input: nums = [-1, 0, 3, 5, 9, 12], target = 9
Output: 4
Input: nums = [-1, 0, 3, 5, 9, 12], target = 2
Output: -1
1 <= len(nums) <= 10^4-10^4 < nums[i], target < 10^4nums[i] are distinct and sorted in ascending
order.Brute force — O(n). Linear scan — fails
the O(log n) requirement.
Optimal — half-open Binary Search,
O(log n).
Maintain [lo, hi). Each iteration: -
mid = (lo + hi) // 2. - If nums[mid] == target
→ return mid. - If nums[mid] < target →
answer (if any) is in [mid+1, hi) →
lo = mid + 1. - If nums[mid] > target →
answer is in [lo, mid) → hi = mid.
Illustration for
nums = [-1, 0, 3, 5, 9, 12], target = 9:
index : 0 1 2 3 4 5
nums : [ -1, 0, 3, 5, 9, 12 ]
Iter 1: lo=0, hi=6 → mid=3, nums[3]=5 < 9 → lo = mid+1 = 4
Iter 2: lo=4, hi=6 → mid=5, nums[5]=12 > 9 → hi = mid = 5
Iter 3: lo=4, hi=5 → mid=4, nums[4]=9 == 9 → return 4 ✓
from typing import List
class Solution:
def search(self, nums: List[int], target: int) -> int:
lo, hi = 0, len(nums) # [lo, hi)
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] == target:
return mid
if nums[mid] < target:
lo = mid + 1
else:
hi = mid
return -1O(log n).O(1).(lo + hi) // 2 — Python
ints can’t overflow, but in C/C++/Java use
lo + (hi - lo) // 2.[lo, hi]
(closed-closed) and [lo, hi) (closed-open) — pick one
convention and stick to it everywhere.hi is always exclusive — matching Python’s
range() and bisect.Given a sorted (no duplicates) array nums and
target, return: - The index of target if
present. - The position where target would be inserted to
keep nums sorted.
Must run in O(log n).
Input: nums = [1, 3, 5, 6], target = 5 → 2
Input: nums = [1, 3, 5, 6], target = 2 → 1
Input: nums = [1, 3, 5, 6], target = 7 → 4 (append at end)
Input: nums = [1, 3, 5, 6], target = 0 → 0 (prepend)
1 <= len(nums) <= 10^4-10^4 <= nums[i], target <= 10^4nums is strictly increasing.lower_bound position.This is exactly lower_bound. The first
index i such that nums[i] >= target —
semantically: “this is where target would sit on
insertion”.
from typing import List
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
lo, hi = 0, len(nums)
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] < target:
lo = mid + 1
else:
hi = mid
return loO(log n).O(1).lower_bound
template. Problem 5.1 returns -1 if not found; this one
always returns a position — that is the only difference.return bisect.bisect_left(nums, target). In an interview,
hand-code the template first, then mention bisect.target smaller than everything → 0.target larger than everything →
len(nums).You have n versions labelled 1 to
n. There is a “bad” version, and every version
after it is also bad. You can call an API
isBadVersion(int) returning True/False. Find
the first bad version with the fewest API calls.
n = 5, bad version = 4
call isBadVersion(3) → False
call isBadVersion(5) → True
call isBadVersion(4) → True
→ return 4
1 <= bad <= n <= 2^31 - 1O(log n) calls.[False, ..., False, True, ..., True] is
monotonic → binary search applies.This is search on a monotonic predicate. Perfect for
the binary_search_answer template:
check(v) = isBadVersion(v) — monotonic
False → True.check flips to
True.Illustration for n = 7, bad = 4:
version : 1 2 3 4 5 6 7
check : F F F T T T T
↑
want this position
Iter 1: lo=1, hi=7 → mid=4, check(4)=T → hi=4
Iter 2: lo=1, hi=4 → mid=2, check(2)=F → lo=3
Iter 3: lo=3, hi=4 → mid=3, check(3)=F → lo=4
lo == hi → return lo = 4 ✓
def isBadVersion(v: int) -> bool: ... # provided API
class Solution:
def firstBadVersion(self, n: int) -> int:
lo, hi = 1, n # closed interval [lo, hi]
while lo < hi:
mid = lo + (hi - lo) // 2 # overflow-safe in other languages
if isBadVersion(mid):
hi = mid
else:
lo = mid + 1
return loO(log n) API calls.O(1).lo + (hi - lo) // 2? In Java/C++,
lo + hi may exceed INT_MAX. Python int can’t
overflow, but this is a good habit since you might interview in
another language.hi = n + 1 (as in half-open) — would call
isBadVersion(n + 1) → out of range. Use closed
[1, n].True case (bad from version 1) or
all-False (excluded by the problem, but still worth a
sanity check).check function — the
core technique of Chapter 25.Given a sorted (ascending, with possible duplicates) array
nums and target, return
[first, last] — the first and last positions of
target in nums. Return [-1, -1]
if absent. Must run in O(log n).
Input: nums = [5, 7, 7, 8, 8, 10], target = 8
Output: [3, 4]
Input: nums = [5, 7, 7, 8, 8, 10], target = 6
Output: [-1, -1]
Input: nums = [], target = 0
Output: [-1, -1]
0 <= len(nums) <= 10^5-10^9 <= nums[i] <= 10^9-10^9 <= target <= 10^9[-1, -1].Use two binary searches: - first =
lower_bound(target) — first index
>= target. - last =
upper_bound(target) - 1 — last index
<= target.
Then sanity-check first is valid and
nums[first] == target.
Illustration for
nums = [5, 7, 7, 8, 8, 10], target = 8:
index : 0 1 2 3 4 5
nums : [ 5, 7, 7, 8, 8, 10 ]
↑ ↑
first last
lower_bound(8) = 3 (first index >= 8)
upper_bound(8) = 5 (first index > 8)
last = 4 (= upper - 1)
Return [3, 4]
from typing import List
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
def lower_bound(t: int) -> int:
lo, hi = 0, len(nums)
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] < t:
lo = mid + 1
else:
hi = mid
return lo
first = lower_bound(target)
if first == len(nums) or nums[first] != target:
return [-1, -1]
last = lower_bound(target + 1) - 1
return [first, last]O(log n) — two independent
binary searches.O(1).upper_bound(t) == lower_bound(t + 1) for integer arrays —
so we only need one lower_bound helper.first == len(nums) → IndexError when target
is larger than every element.nums[first] == target → returns
[first, first - 1] incorrectly when target is absent.last - first + 1 (if first is valid).Given nums, originally sorted ascending with
distinct values, then rotated at an
unknown pivot (rotated right k times, k
unknown). Given target, return its index, or
-1. Must run in O(log n).
Input: nums = [4, 5, 6, 7, 0, 1, 2], target = 0
Output: 4
Input: nums = [4, 5, 6, 7, 0, 1, 2], target = 3
Output: -1
Input: nums = [1], target = 0
Output: -1
1 <= len(nums) <= 5000-10^4 <= nums[i] <= 10^4nums has been rotated at a hidden pivot.Key observation: splitting a rotated array at
mid, at least one half
([lo, mid] or [mid, hi]) is truly
sorted (not rotated).
Each step: 1. Compute mid. 2. If
nums[mid] == target → return mid. 3. Identify
the sorted half: - If nums[lo] <= nums[mid] → left half
is sorted. - Otherwise → right half is sorted. 4. Check whether
target lies in the sorted half (compare with
<, >): - Yes → search the sorted half. -
No → search the other half.
Illustration for
nums = [4, 5, 6, 7, 0, 1, 2], target = 0:
index : 0 1 2 3 4 5 6
nums : [4, 5, 6, 7, 0, 1, 2]
lo hi
Iter 1: lo=0, hi=6, mid=3, nums[mid]=7
nums[lo]=4 <= nums[mid]=7 → left [0..3] = [4,5,6,7] sorted.
Is target=0 in [4..7]? No (0 < 4) → go right.
→ lo = mid + 1 = 4
Iter 2: lo=4, hi=6, mid=5, nums[mid]=1
nums[lo]=0 <= nums[mid]=1 → left [4..5] = [0,1] sorted.
Is target=0 in [0..1]? Yes → go left.
→ hi = mid - 1 = 4
Iter 3: lo=4, hi=4, mid=4, nums[mid]=0 == target → return 4 ✓
from typing import List
class Solution:
def search(self, nums: List[int], target: int) -> int:
lo, hi = 0, len(nums) - 1 # closed interval
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target:
return mid
# Is the left half [lo..mid] sorted?
if nums[lo] <= nums[mid]:
if nums[lo] <= target < nums[mid]:
hi = mid - 1 # target in left half
else:
lo = mid + 1
# Otherwise the right half [mid..hi] is sorted
else:
if nums[mid] < target <= nums[hi]:
lo = mid + 1 # target in right half
else:
hi = mid - 1
return -1O(log n).O(1).<= /
< at the boundary — always trace through a small
example.nums[lo] == nums[mid] (rotated but
adjacent elements equal). With distinct values it’s fine because
<= ensures “left half sorted”.nums[lo] == nums[mid] == nums[hi],
we can’t tell which half is sorted → fall back to
lo += 1, hi -= 1 (worst O(n)).Given a non-negative integer x, return the floor
of its square root — the largest integer r such
that r * r <= x.
Built-in sqrt is not
allowed.
Input: x = 4 → 2
Input: x = 8 → 2 (because 2² = 4 ≤ 8 < 9 = 3²)
Input: x = 0 → 0
Input: x = 1 → 1
0 <= x <= 2^31 - 1pow allowed? → Per the spirit of the
problem: no. The problem wants binary search or Newton’s method.Approach 1 — Binary search on the answer —
O(log x).
Find the largest integer r such that
r² <= x. Equivalent to “last True” in the monotonic
sequence [T, T, ..., T, F, F, ...] (T =
r² <= x).
Search range: [0, x] (or [0, x//2 + 1] to
save).
Approach 2 — Newton’s Method — O(log x) with a
smaller constant.
Iterate r = (r + x/r) / 2 until
r² <= x < (r+1)². Convergence is quadratic — this is
how sqrt is implemented in many standard libraries.
Illustration — Binary Search for
x = 8:
r : 0 1 2 3 4 5 6 7 8
r² : 0 1 4 9 16 25 36 49 64
↑
last r with r² <= 8
Iter 1: lo=0, hi=8 mid=4, 16 > 8 → hi = 3
Iter 2: lo=0, hi=3 mid=1, 1 <= 8 → answer=1, lo=2
Iter 3: lo=2, hi=3 mid=2, 4 <= 8 → answer=2, lo=3
Iter 4: lo=3, hi=3 mid=3, 9 > 8 → hi=2 → lo > hi, stop
Return 2 ✓
class Solution:
"""Approach 1 — binary search."""
def mySqrt(self, x: int) -> int:
if x < 2:
return x
lo, hi = 1, x // 2 + 1
answer = 0
while lo <= hi:
mid = (lo + hi) // 2
if mid * mid <= x:
answer = mid
lo = mid + 1
else:
hi = mid - 1
return answer
class SolutionNewton:
"""Approach 2 — Newton's method, smaller constant."""
def mySqrt(self, x: int) -> int:
if x < 2:
return x
r = x
while r * r > x:
r = (r + x // r) // 2
return rO(log x) time,
O(1) space.O(log x) worst case,
but typically far fewer iterations (quadratic convergence, ~5 steps for
x = 10^9).lo = 0 but forgetting x = 0 → the
loop 0 * 0 == 0 <= 0 may return 0, but
cleaner to special-case x < 2.mid * mid overflow in 32-bit languages (Python is safe)
— use (long long)mid * mid or compare via
mid <= x // mid.r and x / r. The true root r*
lies between them → the average moves closer to r*.
Convergence is quadratic (correct digits double each iteration).epsilon → still binary search on [0, x] with
floats, stop when hi - lo < eps.Closed interval [lo, hi]:
lo, hi = 0, n - 1
while lo <= hi:
mid = (lo + hi) // 2
if check(mid): return mid
elif too_small(mid): lo = mid + 1
else: hi = mid - 1
return -1
Half-open [lo, hi) — first-true
(lower_bound):
lo, hi = 0, n # hi is NOT inclusive
while lo < hi:
mid = (lo + hi) // 2
if pred(mid): hi = mid
else: lo = mid + 1
return lo # first index where pred holds
pred(mid) holds on the final [lo..hi); the
returned position is the first True, or n
if there is none.lo, hi are initialised correctly
(especially for search on answer: lo = min,
hi = max or max + 1).<= vs
<).mid+1 / mid-1 /
mid are correct — no infinite loop.-1, n, lo?(lo + hi) // 2 overflow is safe in Python; in Java/C++
use lo + (hi - lo) // 2.Python ints never overflow. Java/C++: mid * mid may
overflow int32. Use (long) mid * mid
or compare mid <= x / mid to avoid the
multiplication.
Hash Table is the “magic weapon” of coding interviews: it turns many
O(n²)problems intoO(n). Philosophy: trade memory for time — acceptO(n)extra memory in exchange forO(1)lookups. This chapter teaches you to recognise when you should and when you shouldn’t use hashing, along with 6 classic problems that frequently appear in Big Tech interviews.
After this chapter, you will be able to:
O(n) lookup into
O(1).prefix → check complement pattern (Two Sum,
Subarray Sum K).O(n) search inside a loop into an
O(1) membership test.When not to use hash: - You need sorted
order → use SortedSet / TreeMap (Python:
sortedcontainers). - You need O(1) worst-case
(not amortised) → hash is vulnerable to adversarial collisions. - Keys
are complex (list, dict) → must convert to tuple /
frozenset.
from collections import Counter, defaultdict
from typing import List
# 1) Counter: frequency table
cnt = Counter(nums) # {value: count}
top3 = cnt.most_common(3) # 3 most-common elements
# 2) defaultdict(list): group by key
groups: dict[str, list[int]] = defaultdict(list)
for i, v in enumerate(arr):
groups[v].append(i)
# 3) Prefix sum + dict: find subarrays
prefix_index = {0: -1} # prefix_sum -> earliest index
cur = 0
for i, x in enumerate(arr):
cur += x
if cur - target in prefix_index:
# found a subarray with sum = target
...
if cur not in prefix_index:
prefix_index[cur] = i0/1 counted via
prefix sum)Given an array nums, return True if any
value appears at least twice, otherwise False.
Input: nums = [1, 2, 3, 1] → True
Input: nums = [1, 2, 3, 4] → False
Input: nums = [] → False
1 <= len(nums) <= 10^5-10^9 <= nums[i] <= 10^9O(1) extra space: sort in place then check.Brute force — O(n²). Compare every
pair.
Sort — O(n log n), O(1) extra
space. Sort then check adjacent elements.
Hash set — O(n) time, O(n) space —
the most common answer.
Pythonic one-liner:
return len(set(nums)) != len(nums).
from typing import List
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
seen: set[int] = set()
for x in nums:
if x in seen:
return True
seen.add(x)
return FalseO(n) on average.O(n).len(set(nums)) != len(nums)?
Good as a one-liner, but no early exit — it still
iterates the whole array. The loop allows returning as soon as a
duplicate is found.k (sliding window + hash).int so it’s fine.Given an unsorted array nums, return the length of the
longest sequence of consecutive integers (they need not
be adjacent in the array). Must run in O(n).
Input: nums = [100, 4, 200, 1, 3, 2]
Output: 4
Explanation: the consecutive run [1, 2, 3, 4] has length 4.
Input: nums = [0, 3, 7, 2, 5, 8, 4, 6, 0, 1]
Output: 9
Explanation: the run [0, 1, 2, 3, 4, 5, 6, 7, 8].
Input: nums = []
Output: 0
0 <= len(nums) <= 10^5-10^9 <= nums[i] <= 10^9O(n); sort is
O(n log n) so no.Brute force — O(n³). For each element,
count x, x+1, x+2, ... in the array.
Sort — O(n log n). Sort, count
consecutive runs. Simple but fails the O(n) target.
Optimal — Hash Set + “only start from a chain’s beginning” —
O(n).
Key insight: a number x is a
chain starter ↔︎ x - 1 is not in the array.
Only for such x do we count the chain
x, x+1, x+2, ... via hash lookup. Each element is “walked
forward” at most once across all starters → total O(n).
Illustration for
nums = [100, 4, 200, 1, 3, 2]:
Set: {100, 4, 200, 1, 3, 2}
Iterate over each x in the set:
x=100: 99 not in set → starter
Count: 100 ✓, 101 ✗ → length 1
x=4: 3 IS in set → SKIP (will be counted starting from 1)
x=200: 199 not in set → starter
Count: 200 ✓, 201 ✗ → length 1
x=1: 0 not in set → starter
Count: 1 ✓, 2 ✓, 3 ✓, 4 ✓, 5 ✗ → length 4 ★
x=3: 2 IS in set → SKIP
x=2: 1 IS in set → SKIP
Total: max length = 4.
Key: each element of the chain 1-2-3-4 is "walked forward" exactly once
(when x=1). Total work ~ O(n).
from typing import List
class Solution:
def longestConsecutive(self, nums: List[int]) -> int:
num_set = set(nums)
best = 0
for x in num_set:
# Only start from a chain head (x-1 not in set).
if x - 1 not in num_set:
cur = x
length = 1
while cur + 1 in num_set:
cur += 1
length += 1
best = max(best, length)
return bestO(n) — each element is “walked
forward” at most once.O(n) for the set.O(n)? Look at the inner
while: it only runs for x’s such that
x - 1 is not in the set (chain starts). Each element of a
chain of length L is visited once (when the while starts
from the chain head). Total Σ L = n.x - 1 not in num_set check →
O(n²) because every element in a chain restarts the inner
loop → TLE.x
itself is one element).Given an array nums and integer k, return
the k most frequent elements (output order does not
matter).
Input: nums = [1, 1, 1, 2, 2, 3], k = 2
Output: [1, 2]
Input: nums = [1], k = 1
Output: [1]
1 <= len(nums) <= 10^5-10^4 <= nums[i] <= 10^4k is guaranteed to be within
[1, number of distinct elements].O(n log n) (LC hint).Approach 1 — Counter + sort —
O(n log n). Simple but doesn’t meet the hint.
Approach 2 — Min-heap of size k —
O(n log k). Maintain a heap of size
k; pop the least-frequent when it overflows.
Approach 3 — Bucket sort by frequency — O(n) —
the slickest.
Maximum frequency is n → create n + 1
buckets where bucket i holds elements whose frequency is
i. Sweep buckets from high to low, gather k
elements.
Illustration for
nums = [1,1,1,2,2,3], k=2:
Step 1: Counter → {1:3, 2:2, 3:1}
Step 2: Bucket sort by frequency (n=6, 7 buckets 0..6):
bucket[0] = []
bucket[1] = [3] # element 3 appears 1 time
bucket[2] = [2] # element 2 appears 2 times
bucket[3] = [1] # element 1 appears 3 times
bucket[4..6] = []
Step 3: Walk buckets from index 6 down:
i=6: empty
i=5: empty
i=4: empty
i=3: [1] → result = [1]
i=2: [2] → result = [1, 2] reached k=2, stop
Output: [1, 2]
import heapq
from collections import Counter
from typing import List
class Solution:
"""Approach 3 — bucket sort, O(n)."""
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
cnt = Counter(nums)
n = len(nums)
buckets: list[list[int]] = [[] for _ in range(n + 1)]
for x, freq in cnt.items():
buckets[freq].append(x)
result: list[int] = []
for freq in range(n, 0, -1):
for x in buckets[freq]:
result.append(x)
if len(result) == k:
return result
return result
class SolutionHeap:
"""Approach 2 — min-heap of size k, O(n log k)."""
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
cnt = Counter(nums)
# heapq.nlargest uses a heap-based partial sort
return heapq.nlargest(k, cnt.keys(), key=cnt.get)| Approach | Time | Space |
|---|---|---|
| Counter + sort | O(n log n) |
O(n) |
| Min-heap | O(n log k) |
O(n) |
| Bucket sort | O(n) |
O(n) |
k = O(n) → bucket sort wins.k tiny (~10) while n huge → min-heap saves
memory.n + 1 because freq
can equal n).max-heap instead of a size-k
min-heap → ends up O(n log n).Counter(nums).most_common(k) returns
(val, freq) pairs — definitely the production pick. In an
interview, walk through one of the three approaches first, then mention
most_common.Given an integer array nums and integer k,
return the number of contiguous subarrays whose sum
equals k.
Input: nums = [1, 1, 1], k = 2
Output: 2
Explanation: two subarrays [1,1] (positions 0..1 and 1..2).
Input: nums = [1, 2, 3], k = 3
Output: 2
Explanation: [1,2] and [3].
1 <= len(nums) <= 2·10^4-1000 <= nums[i] <= 1000-10^7 <= k <= 10^7Brute force — O(n²). For each
i, accumulate a prefix sum and check == k.
Acceptable but not optimal.
Optimal — Prefix sum + Hash map —
O(n).
Let P[i] = sum of nums[0..i-1]
(P[0] = 0). The sum of nums[j..i-1] is
P[i] - P[j]. A subarray of sum k ↔︎
P[i] - P[j] = k ↔︎ P[j] = P[i] - k.
→ Walk and count j < i with
P[j] == cur - k. Maintain a dict
{prefix_sum: count}.
Illustration for
nums = [3, 4, 7, 2, -3, 1, 4, 2], k = 7:
i : 0 1 2 3 4 5 6 7
nums : 3 4 7 2 -3 1 4 2
P[i+1]: 3 7 14 16 13 14 18 20
Hand-trace:
P[] = [0, 3, 7, 14, 16, 13, 14, 18, 20]
counts = {0:1} cur=0
i=0 cur=3 (3-7=-4 not in counts) add 3 → {0:1, 3:1}
i=1 cur=7 (7-7= 0 in counts: +1) add 7 → {0:1, 3:1, 7:1} answer=1
i=2 cur=14 (14-7=7 in counts: +1) add 14 → ... answer=2
i=3 cur=16 (16-7=9 not in counts) answer=2
i=4 cur=13 (13-7=6 not in counts) answer=2
i=5 cur=14 (14-7=7 in counts: +1) cur seen → counts[14]+=1
answer=3
i=6 cur=18 (18-7=11 not in counts) answer=3
i=7 cur=20 (20-7=13 in counts: +1) answer=4
Answer: 4 subarrays with sum = 7.
from collections import defaultdict
from typing import List
class Solution:
def subarraySum(self, nums: List[int], k: int) -> int:
counts: dict[int, int] = defaultdict(int)
counts[0] = 1 # prefix sum 0 has occurred once (empty prefix)
cur = 0
result = 0
for x in nums:
cur += x
result += counts[cur - k] # how many j satisfy P[j] = cur - k
counts[cur] += 1
return resultO(n).O(n).counts[0] = 1 initially → misses subarrays
that start at index 0.counts[cur] before checking →
counts j == i (the empty subarray). The order must be
result += counts[cur - k] first, then
counts[cur] += 1.(i, j) with f(j) = g(i)” → maintain
counts[f(j)] for j < i. Reappears in:
prefix % k).count(1) - count(0)).O(n), no hash needed. With negatives → prefix sum is
mandatory.Two strings s and t are
isomorphic if there exists a bijection
between their characters such that replacing each character in
s according to the mapping yields t.
Input: s = "egg", t = "add" → True
Explanation: e→a, g→d (bijection).
Input: s = "foo", t = "bar" → False
Explanation: o would map to both a and r (not a function).
Input: s = "paper", t = "title" → True
Input: s = "badc", t = "baba" → False
Explanation: d→a and c→a, two different chars map to one → not a bijection.
1 <= len(s) == len(t) <= 5·10^4s, t may contain any ASCII
characters.f: s → t and g: t → s must be
injective.s and t always share length? →
Per the problem: yes. Otherwise return False immediately.Approach 1 — Two dicts (mapping in both directions).
Walk the two strings together: - If s[i] is already in
s2t → check s2t[s[i]] == t[i]. - Otherwise →
confirm t[i] is not already in t2s (avoids
multiple s mapping to the same t). - Store the
pair (s[i], t[i]) in both dicts.
Approach 2 — Replace by “first-occurrence index”.
A string can be “normalised” by replacing each character with the index of its first occurrence. Two strings are isomorphic ↔︎ their normalised sequences match.
Example: "egg" → [0, 1, 1],
"add" → [0, 1, 1] → equal → True.
Approach 1 is more intuitive; Approach 2 is algorithmically slicker. Both
O(n).
class Solution:
"""Approach 1 — two dicts, verify bijection."""
def isIsomorphic(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
s2t: dict[str, str] = {}
t2s: dict[str, str] = {}
for a, b in zip(s, t):
if a in s2t:
if s2t[a] != b:
return False
else:
if b in t2s: # b already mapped from a different char
return False
s2t[a] = b
t2s[b] = a
return True
class SolutionNormalize:
"""Approach 2 — normalise by first-occurrence index."""
def isIsomorphic(self, s: str, t: str) -> bool:
return self._normalize(s) == self._normalize(t)
@staticmethod
def _normalize(s: str) -> list[int]:
idx: dict[str, int] = {}
out: list[int] = []
for ch in s:
if ch not in idx:
idx[ch] = len(idx)
out.append(idx[ch])
return outO(n).O(k) where k is
the alphabet size.s2t → misses cases where multiple
s chars map to the same t (as in
"badc" / "baba").Design a Least Recently Used (LRU) Cache with both
operations in O(1):
get(key): return value if present,
otherwise -1. Every successful access marks the key as
“just used” (most recently used).put(key, value): insert/update. On capacity overflow,
evict the least recently used key.Input (LC-style operation arrays):
ops = ["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
args = [[2], [1,1], [2,2], [1], [3,3], [2], [4,4], [1], [3], [4]]
Output: [null, null, null, 1, null, -1, null, -1, 3, 4]
Step-by-step trace (capacity = 2; right = MRU, left = LRU):
LRUCache(2) → null; cache = {} (LRU ← → MRU)
put(1, 1) → null; cache = {1=1}
put(2, 2) → null; cache = {1=1, 2=2}
get(1) → 1; cache = {2=2, 1=1} (1 just used → MRU)
put(3, 3) → null; cache = {1=1, 3=3} (evict 2: LRU)
get(2) → -1; (key 2 gone)
put(4, 4) → null; cache = {3=3, 4=4} (evict 1)
get(1) → -1
get(3) → 3; cache = {4=4, 3=3}
get(4) → 4; cache = {3=3, 4=4}
1 <= capacity <= 30000 <= key, value <= 10^42·10^5 get and put
calls.put is called on an existing
key? → Update the value and mark it MRU.Core requirement: O(1) for both
get and put ↔︎ we need both: - Hash
map: key → reference to a node (gives O(1)
lookup). - Doubly Linked List (DLL): access order (MRU
at one end, LRU at the other). Lets us delete an arbitrary node in
O(1) given its reference.
Each get(k): - If k is in the map → take
the node, move it to the front of the DLL (= MRU),
return value. - Otherwise return -1.
Each put(k, v): - If k is already present →
update value, move to front. - Otherwise → insert a new node at the
front. On capacity overflow → remove the tail node (LRU) and erase from
the map.
Pythonic shortcut — use OrderedDict
(already supports both operations):
OrderedDict is implemented underneath as a hash map
combined with a doubly linked list. It exposes two methods that are gold
for LRU: move_to_end(key) and
popitem(last=False) (pop from the front).
Illustration — DLL state through the operations:
capacity = 2
Head (MRU) Tail (LRU)
│ │
▼ ▼
put(1,1): DLL: 1 cache = {1: node1}
put(2,2): DLL: 2 ─── 1 cache = {1: ., 2: .}
get(1)=1: DLL: 1 ─── 2 (1 → MRU)
put(3,3): DLL: 3 ─── 1 (evict 2 as LRU)
get(2)=-1
put(4,4): DLL: 4 ─── 3 (evict 1)
get(1)=-1
get(3)=3: DLL: 3 ─── 4
get(4)=4: DLL: 4 ─── 3
from collections import OrderedDict
class LRUCache:
"""Pythonic — OrderedDict already provides hash + DLL."""
def __init__(self, capacity: int):
self.cap = capacity
self.cache: OrderedDict[int, int] = OrderedDict()
def get(self, key: int) -> int:
if key not in self.cache:
return -1
self.cache.move_to_end(key) # push to MRU end
return self.cache[key]
def put(self, key: int, value: int) -> None:
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = value
if len(self.cache) > self.cap:
self.cache.popitem(last=False) # pop LRU at the front
# ─────────────────────────────────────────────────────────────
# Hand-rolled version (interview-friendly): dict + doubly linked list.
# Use this when the interviewer says "Implement LRU without built-ins."
class _Node:
__slots__ = ("key", "val", "prev", "next")
def __init__(self, key: int = 0, val: int = 0):
self.key, self.val = key, val
self.prev: "_Node | None" = None
self.next: "_Node | None" = None
class LRUCacheManual:
def __init__(self, capacity: int):
self.cap = capacity
self.cache: dict[int, _Node] = {}
# Two sentinels (head/tail) keep code short — no None checks at edges.
self.head, self.tail = _Node(), _Node()
self.head.next = self.tail
self.tail.prev = self.head
def _remove(self, node: _Node) -> None:
node.prev.next = node.next
node.next.prev = node.prev
def _add_to_front(self, node: _Node) -> None:
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
def get(self, key: int) -> int:
if key not in self.cache:
return -1
node = self.cache[key]
self._remove(node)
self._add_to_front(node)
return node.val
def put(self, key: int, value: int) -> None:
if key in self.cache:
node = self.cache[key]
node.val = value
self._remove(node)
self._add_to_front(node)
return
if len(self.cache) == self.cap:
lru = self.tail.prev # LRU = just before tail sentinel
self._remove(lru)
del self.cache[lru.key]
new_node = _Node(key, value)
self.cache[key] = new_node
self._add_to_front(new_node)O(1) for both get
and put (amortised).O(capacity).O(1) lookup by key.O(1) removal/insertion at any
position given a reference. A singly linked list cannot because it
lacks prev.put(k) is called on
an existing k.None checks; in interviews
prefer this style for cleaner code and fewer boundary bugs.| Requirement | Hash enough? | Replacement |
|---|---|---|
O(1) lookup, no order needed |
✅ | — |
| Need ordered traversal | ❌ | OrderedDict / sorted list |
Range query [l, r] |
❌ | Fenwick / Segment Tree (Chapter 22) |
| Top-k frequent | Partial | Heap (Chapter 15) |
| Nearest neighbour | ❌ | Sorted set / BST |
| Subarray sum with negatives | ✅ Prefix sum + hash | — |
| Subarray sum non-negative only | Use sliding window (27) | — |
move_to_end): 5 lines,
perfect for demos in interviews.get/put. Senior
interviews often require this implementation.x - 1 ∉ set. Each chain has exactly one
starter ⇒ the total cost of “walking through each chain” sums to
O(n).O(n²).Linked List is “simple in theory, painful in code”. Each node points to the next — that’s it — but writing bug-free linked-list code requires memorising 5 small tricks: dummy head, two pointers, in-place reverse, split-by-pivot, and cross-pointer relinking. By the end of this chapter, LL will stop being intimidating.
This chapter has 12 problems — twice as many as basic chapters — because the LL pattern has many important variants, ranging from Easy (Reverse, Merge, Cycle) to Hard (Reverse k-Group, Sort, Reorder).
After this chapter, you will be able to:
next, accidental cycles,
forgetting to update tail/head.O(1) given a
reference).O(1) extra space requirement — you cannot copy to an
array and process.linked list patterns.5 tricks to memorise:
dummy.next = head, use prev = dummy. Avoids a
barrage of if head is None checks.prev / curr / nxt.a.next = b, always detach a from its old spot
first (update both incoming and outgoing pointers).Every problem in Chapter 7 (and 3.3, 15.5) uses LeetCode’s
standard ListNode:
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = nexthead parameter is always a single
ListNode (or None for an empty
list).1 → 2 → 3 → None to illustrate a
linked list initialised from
head = ListNode(1, ListNode(2, ListNode(3)))."reorder",
"remove nth", …).Node with an extra
random pointer; problem 7.11 LRU uses a
custom doubly linked list.class ListNode:
def __init__(self, val: int = 0, next: "ListNode | None" = None):
self.val = val
self.next = next
def use_dummy(head: ListNode | None) -> ListNode | None:
"""Dummy-head pattern — problems that insert/delete near the head."""
dummy = ListNode(0, head)
prev = dummy
while prev.next:
# ... operate on prev.next ...
prev = prev.next
return dummy.next # head may have changed
def find_middle(head: ListNode | None) -> ListNode | None:
"""Slow/fast pointers — find middle (LC 876)."""
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
def reverse(head: ListNode | None) -> ListNode | None:
"""Reverse iterative — 3 pointers."""
prev, curr = None, head
while curr:
curr.next, prev, curr = prev, curr, curr.next
return prev[left, right])Given head of a singly linked list, reverse it and
return the new head. (The recursive version lives in
Chapter 3.3 — this section focuses on the iterative
version with O(1) space.)
Input: head = 1 → 2 → 3 → 4 → 5 → None (singly linked list)
Output: 5 → 4 → 3 → 2 → 1 → None
Three pointers: - prev = the node right
before curr in the result. - curr = the node
we are processing. - nxt = backup of curr.next
before we overwrite.
Each iteration: flip curr.next to point at
prev, then advance both prev and
curr.
Illustration with 1 → 2 → 3 → None:
Start : prev = None
curr → 1 → 2 → 3 → None
Iter 1: nxt = 2
curr.next = prev → None ← 1 2 → 3 → None
prev = curr = 1, curr = 2
Iter 2: nxt = 3
curr.next = prev → None ← 1 ← 2 3 → None
prev = 2, curr = 3
Iter 3: nxt = None
curr.next = prev → None ← 1 ← 2 ← 3
prev = 3, curr = None → stop
Return prev = 3, list: 3 → 2 → 1 → None
class Solution:
def reverseList(self, head: ListNode | None) -> ListNode | None:
prev, curr = None, head
while curr:
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
return prevPythonic in-loop one-liner:
curr.next, prev, curr = prev, curr, curr.next. Tuple unpacking evaluates the RHS first, so no temporarynxtis needed.
O(n). Space:
O(1).O(1) space — best for every case.O(n) stack — RecursionError in
Python when n is large (5·10⁴+).curr.next = prev before advancing
prev / curr → loses the pointer.Given two heads of two ascending sorted linked lists, return the head of the merged list (also ascending).
Input: l1 = 1 → 2 → 4, l2 = 1 → 3 → 4
Output: 1 → 1 → 2 → 3 → 4 → 4
<=).Iterative — use a dummy head. Create
dummy with tail = dummy. Each step attach
tail.next to the smaller node of the two lists, advance
tail. Finally splice the remaining tail.
Recursive (very concise but O(n)
stack):
if not l1: return l2
if not l2: return l1
if l1.val <= l2.val:
l1.next = self.mergeTwoLists(l1.next, l2)
return l1
else:
l2.next = self.mergeTwoLists(l1, l2.next)
return l2class Solution:
def mergeTwoLists(self, l1: ListNode | None, l2: ListNode | None) -> ListNode | None:
dummy = ListNode()
tail = dummy
while l1 and l2:
if l1.val <= l2.val:
tail.next, l1 = l1, l1.next
else:
tail.next, l2 = l2, l2.next
tail = tail.next
tail.next = l1 if l1 else l2 # splice the remaining tail
return dummy.nextO(m + n).
Space: O(1) iterative; O(m+n)
recursion stack.if dummy is None check at every step.<= (not
<) → preserves order on ties.Given the head of a linked list, return True if there is
a cycle, otherwise False. Requirement: O(1)
extra space.
Input: head = [3, 2, 0, -4], cycle begins at index 1
3 → 2 → 0 → -4
↑________|
Output: True
Brute force — Hash set, O(n) space.
Store every seen node.
Optimal — Floyd’s Tortoise and Hare, O(1)
space.
Two pointers slow (1×) and fast (2×). If a
cycle exists, fast “catches up” to slow inside the loop (their distance
shrinks by 1 each step). If not, fast runs off the end.
Illustration — slow/fast on a cycle:
Linked list: 3 → 2 → 0 → -4 → ⟲ (back to 2)
Step 0: slow=3, fast=3
Step 1: slow=2, fast=0
Step 2: slow=0, fast=2 (fast looped back)
Step 3: slow=-4, fast=-4 ★ they meet → return True
On a cycle of length L, slow steps 1, fast steps 2 → distance shrinks
by 1 per step → meet within L steps.
class Solution:
def hasCycle(self, head: ListNode | None) -> bool:
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return FalseO(n). Space:
O(1).slow == fast (value
comparison) instead of slow is fast (reference comparison)
→ wrong when two distinct nodes share the same value.slow == fast, reset slow = head and advance
both at speed 1; they will meet at the cycle start
(provable algebraically).Given the head, return the middle node. If there are two middles (even length), return the second.
Input: head = 1 → 2 → 3 → 4 → 5 (singly linked list)
Output: node with value 3 (middle; belongs to the right half on even length)
Input: head = 1 → 2 → 3 → 4 → 5 → 6 (singly linked list, even length)
Output: node with value 4 (the second of the two middles)
Brute force — 2 passes, O(n). Count
length, then walk to the middle.
Optimal — Slow/fast one pass, O(n).
When fast hits the end, slow is at the
middle.
class Solution:
def middleNode(self, head: ListNode | None) -> ListNode | None:
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slowO(n). Space:
O(1).while fast.next and fast.next.next: (stop one step
earlier).Given the head and integer n, remove the
n-th node counting from the end
(1-indexed) and return the possibly-changed head.
Input: 1 → 2 → 3 → 4 → 5, n = 2 → 1 → 2 → 3 → 5 (remove node 4)
Input: 1, n = 1 → None
Input: 1 → 2, n = 1 → 1
n always ≤ list length? → Yes per the
problem.Brute force — 2 passes. Count length L,
then remove the (L - n)-th from the start.
Optimal — single pass, two pointers separated by
n. - Create a dummy to handle removal
of the head. - fast advances n steps first. -
Then slow and fast advance together until
fast.next is None. At that point slow.next is
exactly the node to remove.
Illustration with
1 → 2 → 3 → 4 → 5, n = 2:
Start: dummy → 1 → 2 → 3 → 4 → 5 → None
slow
fast
After fast advances n=2 steps:
dummy → 1 → 2 → 3 → 4 → 5 → None
slow fast
Advance together until fast.next == None:
dummy → 1 → 2 → 3 → 4 → 5 → None
slow fast
slow.next = 4 → the node to remove. slow.next = slow.next.next.
dummy → 1 → 2 → 3 → 5 → None ✓
class Solution:
def removeNthFromEnd(self, head: ListNode | None, n: int) -> ListNode | None:
dummy = ListNode(0, head)
slow = fast = dummy
for _ in range(n):
fast = fast.next
while fast.next:
slow = slow.next
fast = fast.next
slow.next = slow.next.next
return dummy.nextO(L). Space:
O(1).n == L, we remove the
head. The dummy keeps the code uniform — slow
lands on dummy, and slow.next = slow.next.next
produces the correct new head.Given the head of a singly linked list, return True if
the values form a palindrome. Required: O(n) time,
O(1) space.
Input: head = 1 → 2 → 2 → 1 → Output: True (palindrome)
Input: head = 1 → 2 → Output: False (1 ≠ 2)
Brute force — copy values to an array + two pointers,
O(n) space. Simple but violates the
O(1) space goal.
Optimal — Split + Reverse half + Compare, O(1)
space. 1. Find the middle (slow/fast). 2.
Reverse the second half (in place). 3. Compare
node-by-node between the first half and the reversed second half. 4.
(Optional) Restore the second half (usually unnecessary in
interviews).
Illustration with
1 → 2 → 3 → 2 → 1:
Step 1: find middle (slow lands at node 3)
1 → 2 → 3 → 2 → 1
↑ slow
Step 2: reverse the second half (starting from slow.next = 2):
1 → 2 → 3 1 → 2
(first half) (reversed second half)
Step 3: compare node-by-node:
1 vs 1 ✓
2 vs 2 ✓
→ True
class Solution:
def isPalindrome(self, head: ListNode | None) -> bool:
# 1. Find middle.
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# 2. Reverse second half (starting at slow).
prev, curr = None, slow
while curr:
curr.next, prev, curr = prev, curr, curr.next
# 3. Compare.
left, right = head, prev
while right: # reversed second half may be one node shorter
if left.val != right.val:
return False
left = left.next
right = right.next
return TrueO(n). Space:
O(1).while right: instead of
while left and right:? Because after splitting,
the reversed second half is at most as long as the first half (the
middle node belongs to the reversed second half). The reversed half
reaches its end first, terminating the loop.O(n) array-copy approach.Given two linked lists representing two non-negative integers with digits stored in reverse (ones digit at the head), return the linked list = sum of the two numbers (also reversed).
Input: l1 = 2 → 4 → 3 (represents 342)
l2 = 5 → 6 → 4 (represents 465)
Output: 7 → 0 → 8 (represents 807 = 342 + 465)
Input: l1 = 9 → 9 → 9 → 9 → 9 → 9 → 9
l2 = 9 → 9 → 9 → 9
Output: 8 → 9 → 9 → 9 → 0 → 0 → 0 → 1
0 itself.Simulate addition by hand: walk both lists together,
keep a carry. Each step: -
total = (l1.val if l1 else 0) + (l2.val if l2 else 0) + carry
- digit = total % 10, carry = total // 10. -
Push digit to the result; advance l1,
l2.
Stop only when both are exhausted and
carry == 0.
class Solution:
def addTwoNumbers(self, l1: ListNode | None, l2: ListNode | None) -> ListNode | None:
dummy = ListNode()
tail = dummy
carry = 0
while l1 or l2 or carry:
total = (l1.val if l1 else 0) + (l2.val if l2 else 0) + carry
carry, digit = divmod(total, 10)
tail.next = ListNode(digit)
tail = tail.next
if l1: l1 = l1.next
if l2: l2 = l2.next
return dummy.nextO(max(m, n)).
Space: O(max(m, n)) for output.or carry in the while
condition → misses the last digit when both lists end but
carry > 0 (e.g. 5 + 5 = 10).Given a linked list where each node has both next and
random — pointing to any node in the list (or
None) — produce a deep copy (every new
node is a distinct instance, with random pointers pointing
into the copies).
Input (LC-style):
head = [[7, null], [13, 0], [11, 4], [10, 2], [1, 0]]
(each entry [val, random_index]; random_index is the 0-based index of
the node that `random` points to, or null if `random = None`)
Corresponding linked list:
node 0 node 1 node 2 node 3 node 4
val=7 → val=13 → val=11 → val=10 → val=1 → None
random: (next pointers)
[0] → None
[1] → node 0 (val 7)
[2] → node 4 (val 1)
[3] → node 2 (val 11)
[4] → node 0 (val 7)
Output: deep copy of the above — same vals and same random structure,
but EVERY node is a NEW instance (no sharing with the input).
Output as an LC array:
[[7, null], [13, 0], [11, 4], [10, 2], [1, 0]]
Approach 1 — Hash map “old → new”, O(n) time,
O(n) space.
Pass 1: create the new nodes, store
old_to_new[old] = new. Pass 2: for each old,
set new.next = old_to_new[old.next] and
new.random = old_to_new[old.random].
Approach 2 — Interweave, O(n) time,
O(1) extra space.
A classic trick: 1. Pass 1: insert each copied node
right after its original: A → A' → B → B' → C → C'. 2.
Pass 2: for each original node A, set
A'.random = A.random.next (since
A.random.next is the copy of A.random). 3.
Pass 3: split the two lists apart.
Illustration — Approach 2 with 3 nodes A, B, C:
Pass 1 (insert copy):
A → A' → B → B' → C → C'
Pass 2 (set random):
Suppose A.random = C
→ A'.random = A.random.next = C.next = C' (copy of C) ✓
Pass 3 (split):
Original: A → B → C
Copy: A' → B' → C'
class Node:
def __init__(self, val: int = 0, next=None, random=None):
self.val = val
self.next = next
self.random = random
class Solution:
"""Approach 1 — hash map. Easiest to debug."""
def copyRandomList(self, head: "Node | None") -> "Node | None":
if not head:
return None
old_to_new: dict[Node, Node] = {}
# Pass 1: create copy nodes.
cur = head
while cur:
old_to_new[cur] = Node(cur.val)
cur = cur.next
# Pass 2: link next/random.
cur = head
while cur:
old_to_new[cur].next = old_to_new.get(cur.next)
old_to_new[cur].random = old_to_new.get(cur.random)
cur = cur.next
return old_to_new[head]
class SolutionInterleave:
"""Approach 2 — interweave, O(1) extra space."""
def copyRandomList(self, head: "Node | None") -> "Node | None":
if not head:
return None
# 1. Insert a copy right after each original node.
cur = head
while cur:
cur.next = Node(cur.val, cur.next)
cur = cur.next.next
# 2. Set random pointers for the copies.
cur = head
while cur:
if cur.random:
cur.next.random = cur.random.next
cur = cur.next.next
# 3. Split into two lists.
new_head = head.next
cur, copy = head, new_head
while cur:
cur.next = copy.next
cur = cur.next
copy.next = cur.next if cur else None
copy = copy.next
return new_headO(n) time,
O(n) space.O(n) time,
O(1) extra space (output excluded).Given the head and integer k, reverse each
consecutive group of k nodes in the list. If the
remaining nodes are fewer than k, leave them as is.
Required: O(1) extra space.
Input: 1 → 2 → 3 → 4 → 5, k = 2
Output: 2 → 1 → 4 → 3 → 5 (last group has only 1 node → leave it)
Input: 1 → 2 → 3 → 4 → 5, k = 3
Output: 3 → 2 → 1 → 4 → 5
Procedure: 1. Walk k steps to locate
the group tail. If fewer than k → break. 2.
Reverse the group in
[head_of_group, tail_of_group]. 3. Splice
the head of the reversed group onto prev_group_tail, and
the tail of the reversed group on to next_group_head. 4.
Update prev_group_tail for the next group.
Illustration with
1 → 2 → 3 → 4 → 5, k = 2:
Start: dummy → 1 → 2 → 3 → 4 → 5 → None
prev
Group 1: [1, 2]. Reverse → [2, 1].
dummy → 2 → 1 → 3 → 4 → 5 → None
↑
prev (= 1, the tail of the just-reversed group)
Group 2: [3, 4]. Reverse → [4, 3].
dummy → 2 → 1 → 4 → 3 → 5 → None
↑
prev (= 3)
Group 3: [5]. Only 1 node → fewer than k=2 → leave.
dummy → 2 → 1 → 4 → 3 → 5 → None ✓
class Solution:
def reverseKGroup(self, head: ListNode | None, k: int) -> ListNode | None:
dummy = ListNode(0, head)
prev_group_tail = dummy
while True:
# 1. Find the group tail — walk k steps.
kth = prev_group_tail
for _ in range(k):
kth = kth.next
if not kth:
return dummy.next # fewer than k left → leave as is
group_next = kth.next
# 2. Reverse from prev_group_tail.next to kth.
prev, curr = group_next, prev_group_tail.next
while curr is not group_next:
curr.next, prev, curr = prev, curr, curr.next
# 3. Splice the reversed group in.
old_head = prev_group_tail.next
prev_group_tail.next = kth
prev_group_tail = old_head # this is now the reversed group's tailO(n) — each node is reversed
exactly once.O(1).prev_group_tail, kth, group_next
clearly before writing code. The interviewer will follow the
visual.prev_group_tail = old_head for the next iteration →
re-reversing the same group.k = 2).Sort a linked list ascending in O(n log n) time and
O(1) extra space (per the follow-up —
counts call stack/aux only, not nodes).
Input: head = 4 → 2 → 1 → 3 (singly linked list)
Output: 1 → 2 → 3 → 4
O(n log n) ⇒ quicksort, mergesort, heapsort. Quicksort
is awkward on LL, heapsort needs O(n) extra. Merge
sort is the most natural:
slow.next).Top-down (recursive) is clean but consumes
O(log n) stack. Bottom-up (iterative)
achieves true O(1) space — but more complex. In an
interview, top-down is good enough.
Illustration with 4 → 2 → 1 → 3:
sort([4, 2, 1, 3])
/ \
sort([4, 2]) sort([1, 3])
/ \ / \
[4] [2] [1] [3]
\ / \ /
merge → [2, 4] merge → [1, 3]
\ /
merge → [1, 2, 3, 4] ✓
class Solution:
def sortList(self, head: ListNode | None) -> ListNode | None:
if not head or not head.next:
return head
# 1. Split: find middle, cut in half.
slow, fast = head, head.next
while fast and fast.next:
slow = slow.next
fast = fast.next.next
mid = slow.next
slow.next = None
# 2. Recurse.
left = self.sortList(head)
right = self.sortList(mid)
# 3. Merge.
return self._merge(left, right)
@staticmethod
def _merge(l1: ListNode | None, l2: ListNode | None) -> ListNode | None:
dummy = ListNode()
tail = dummy
while l1 and l2:
if l1.val <= l2.val:
tail.next, l1 = l1, l1.next
else:
tail.next, l2 = l2, l2.next
tail = tail.next
tail.next = l1 or l2
return dummy.nextO(n log n).O(log n) stack (top-down).
Bottom-up achieves O(1).fast = head.next (not
head)? For a 2-node list a → b, we want
slow to land on a (left = [a], right = [b]).
With fast = head, slow lands on b
→ right is empty → infinite recursion.slow.next = None →
halves not separated → infinite recursion.This problem is fully solved in Chapter 6 — Hash Table (section 6.6). Here we summarise the Doubly Linked List pattern, which is the LL highlight.
Design an LRU Cache with get(key) and
put(key, value) both O(1). On capacity
overflow → evict the least-recently-used key.
put hits an existing key? → Update
value + promote to MRU.LRU requires O(1) for both: - Lookup by
key → hash map. - Move an arbitrary node to the
front → doubly linked list (only DLL supports O(1)
unlink given a reference).
capacity = 2
Head sentinel — MRU end LRU end — Tail sentinel
│ │
▼ ▼
┌───┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌───┐
│ H │ ↔ │ K=4 │ ↔ │ K=3 │ ↔ │ K=1 │ ↔ │ T │
└───┘ └──────┘ └──────┘ └──────┘ └───┘
▲
on capacity overflow, evict this node
(Tail.prev = LRU)
Hash map: {1: node1, 3: node3, 4: node4}
class Node:
__slots__ = ("key", "val", "prev", "next")
def __init__(self, key=0, val=0):
self.key, self.val = key, val
self.prev = self.next = None
# Two sentinels (head, tail) save many None checks.
head, tail = Node(), Node()
head.next, tail.prev = tail, head
def _remove(node): # O(1) — only requires a reference
node.prev.next = node.next
node.next.prev = node.prev
def _add_to_front(node):
node.prev = head
node.next = head.next
head.next.prev = node
head.next = nodeO(1) amortised.O(capacity).O(1) — you must walk from the
head to find prev.node.prev is None or node.next is None.OrderedDict version (Python ships a DLL + hash
internally).Given a linked list L: L0 → L1 → ... → Ln-1, rearrange
it to:
L0 → Ln-1 → L1 → Ln-2 → L2 → Ln-3 → ...
In place; no new nodes may be created.
Input: head = 1 → 2 → 3 → 4
Output: 1 → 4 → 2 → 3 (mutate in place)
Input: head = 1 → 2 → 3 → 4 → 5
Output: 1 → 5 → 2 → 4 → 3 (mutate in place)
Three standard steps — the “split → reverse → merge” pattern:
Illustration with
1 → 2 → 3 → 4 → 5:
Step 1: find middle (slow at 3)
1 → 2 → 3 → 4 → 5
Step 2: split and reverse the second half:
1 → 2 → 3 5 → 4
(first half) (reversed second half)
Step 3: merge interleaved:
Take 1 from left → 1
Take 5 from right → 1, 5
Take 2 from left → 1, 5, 2
Take 4 from right → 1, 5, 2, 4
Take 3 from left → 1, 5, 2, 4, 3
Output: 1 → 5 → 2 → 4 → 3 ✓
class Solution:
def reorderList(self, head: ListNode | None) -> None:
if not head or not head.next:
return
# 1. Find middle.
slow = fast = head
while fast.next and fast.next.next:
slow = slow.next
fast = fast.next.next
# 2. Reverse second half (starting from slow.next).
second = slow.next
slow.next = None # cut in half
prev, curr = None, second
while curr:
curr.next, prev, curr = prev, curr, curr.next
second = prev # head of the reversed second half
# 3. Merge interleaved.
first = head
while second:
tmp1, tmp2 = first.next, second.next
first.next = second
second.next = tmp1
first, second = tmp1, tmp2O(n). Space:
O(1).slow.next = None →
merging will create a cycle.a.next = b, did you
save the old a.next?head may change.)while cur and cur.next: be careful with the compound
condition for the last two nodes..next = None? (Avoid cycles.)head = None),
single node, k > len.dummy = ListNode(0, head)
prev = dummy
# ... operate on prev.next ...
return dummy.next
Used by: Remove Nth From End, Merge Two Sorted, Partition, Odd Even, Reverse K-Group.
Stack (LIFO) and Queue (FIFO) are mirror images of each other. Stack solves every problem with a nested structure (parentheses, recursion, nested expressions); Queue handles level-order traversal (BFS, sliding window). This chapter focuses on stack — pure queue problems return in Chapter 10 (BFS). The chapter ends with a teaser on monotonic stack — a powerful pattern explored in depth in Chapter 18.
After this chapter, you will be able to:
Stack fits perfectly when: - The structure is nested / balanced (parentheses, HTML tags, nested expressions). - You need to “undo” — the previous step must finish only after the current step (iterative DFS). - Problems like “next greater element”, “largest rectangle”, “daily temperatures” → monotonic stack (Chapter 18).
Queue fits perfectly when: - You need level-order / BFS traversal (Chapter 10). - “Sliding window max” → monotonic deque (Chapter 18). - Producer-consumer, task scheduler.
from collections import deque
from typing import List
# 1) Stack with a list (built-in in Python, O(1) amortised).
stack: List[int] = []
stack.append(x) # push
top = stack[-1] # peek
val = stack.pop() # pop
# 2) Queue with collections.deque — O(1) push/pop at both ends.
queue: deque[int] = deque()
queue.append(x) # enqueue (push at the back)
val = queue.popleft() # dequeue (pop from the front)
# 3) Stack of (index, value) — monotonic pattern.
stack: list[tuple[int, int]] = [] # (index, value)
for i, v in enumerate(arr):
while stack and stack[-1][1] < v:
idx, _ = stack.pop()
# ... process idx ...
stack.append((i, v))Given a string s containing only ()[]{},
return True if it is valid: - Every
opening bracket has a matching closing one. - Closing brackets respect
LIFO order.
Input: s = "()" → Output: True
Input: s = "()[]{}" → Output: True
Input: s = "(]" → Output: False
Input: s = "([)]" → Output: False (wrong nesting)
Input: s = "{[]}" → Output: True
1 <= len(s) <= 10^4()[]{}.The classic stack pattern. Scan each character: -
Opening bracket → push. - Closing bracket → check the stack top for the
matching opener. If it does not match, or the stack is empty → return
False. Otherwise pop.
At the end the stack must be empty (all brackets matched).
Illustration for s = "{[()]}":
ch action stack after action
─────────────────────────────────────
{ push ['{']
[ push ['{', '[']
( push ['{', '[', '(']
) pop, match ( ['{', '[']
] pop, match [ ['{']
} pop, match { []
Stack empty → True ✓
class Solution:
def isValid(self, s: str) -> bool:
pairs = {')': '(', ']': '[', '}': '{'}
stack: list[str] = []
for ch in s:
if ch in pairs: # closing bracket
if not stack or stack.pop() != pairs[ch]:
return False
else: # opening bracket
stack.append(ch)
return not stackO(n). Space:
O(n).not stack before popping → IndexError on
"]"."(" would
also return True.Design a stack supporting all four operations in O(1): -
push(x) - pop() - top() — peek -
getMin() — return the current minimum in the stack
Input (LC-style operation arrays):
ops = ["MinStack","push","push","push","getMin","pop","top","getMin"]
args = [[], [-2], [0], [-3], [], [], [], []]
Output: [null, null, null, null, -3, null, 0, -2]
Step-by-step:
MinStack() → init
push(-2); push(0); push(-3)
getMin() → -3
pop() → drop -3
top() → 0
getMin() → -2
pop() on an empty stack? → Per LC: doesn’t
happen (caller maintains invariant).Problem: getMin() in O(1)
⇒ the min has to be stored somewhere. But when we pop the min, we must
know the new min — that’s the crux.
Approach 1 — Auxiliary stack holding
min_so_far.
The auxiliary stack mins mirrors the main stack; its top
is the min of all elements at and below in the main stack. On
push, push min(x, mins[-1]). On pop, pop both stacks.
Approach 2 — Single stack of
(value, current_min) tuples.
Same idea as Approach 1, just consolidated into tuples. Same overhead.
Illustration — Approach 1 for
push(-2), push(0), push(-3), pop():
push(-2): stack=[-2] mins=[-2]
push(0): stack=[-2, 0] mins=[-2, -2] (min(0, -2)=-2)
push(-3): stack=[-2,0,-3] mins=[-2,-2,-3] (min(-3, -2)=-3)
getMin() → mins[-1] = -3 ✓
pop(): stack=[-2, 0] mins=[-2, -2]
getMin() → mins[-1] = -2 ✓
class MinStack:
def __init__(self):
self.stack: list[int] = []
self.mins: list[int] = []
def push(self, val: int) -> None:
self.stack.append(val)
cur_min = val if not self.mins else min(val, self.mins[-1])
self.mins.append(cur_min)
def pop(self) -> None:
self.stack.pop()
self.mins.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.mins[-1]O(1) for every op.
Space: O(n).O(1)
extra-space approach when all values are positive and you know the
range, using “encoded difference” — complicated and rarely worth it. Two
stacks remain the practical choice.MaxStack (LC 716) — similar
but with popMax(), needs DLL + ordered map.Design a Queue (FIFO) using only two stacks. Support
push, pop, peek,
empty.
Input (LC-style operation arrays):
ops = ["MyQueue","push","push","peek","pop","empty"]
args = [[], [1], [2], [], [], []]
Output: [null, null, null, 1, 1, false]
Step-by-step:
MyQueue() → init
push(1); push(2)
peek() → 1 (FIFO: first in first out)
pop() → 1
empty() → false
Idea — two stacks: in (input) and
out (output). - push(x): push onto
in. - pop / peek: if
out is empty → “pour” the entire in into
out (reversing order due to LIFO). Then pop/peek from
out.
Amortised analysis: each element is moved between
the two stacks at most once. Worst-case single op is
O(n), but amortised is O(1).
Illustration for
push(1), push(2), push(3), pop(), push(4), pop():
push(1): in=[1] out=[]
push(2): in=[1, 2] out=[]
push(3): in=[1, 2, 3] out=[]
pop(): out empty → pour in into out
in=[] out=[3, 2, 1] (1 on top)
out.pop() → 1
in=[] out=[3, 2]
push(4): in=[4] out=[3, 2]
pop(): out non-empty → out.pop() → 2
in=[4] out=[3]
→ FIFO: 1 came out first (matching push order)
class MyQueue:
def __init__(self):
self.in_st: list[int] = []
self.out_st: list[int] = []
def push(self, x: int) -> None:
self.in_st.append(x)
def pop(self) -> int:
self._shift()
return self.out_st.pop()
def peek(self) -> int:
self._shift()
return self.out_st[-1]
def empty(self) -> bool:
return not self.in_st and not self.out_st
def _shift(self) -> None:
"""When out is empty, pour all of in into out."""
if not self.out_st:
while self.in_st:
self.out_st.append(self.in_st.pop())O(1). Pop /
Peek: O(1) amortised (worst
O(n)).in → out on every
pop, even when out is non-empty → breaks FIFO order. Pour
only when out is empty.O(1) worst-case is required → impossible with just
two plain stacks.Given an array tokens representing a Reverse
Polish Notation (postfix) expression. Each token is an integer
or one of the four operators + - * /. Return the result
(division truncated toward 0).
Input: tokens = ["2","1","+","3","*"]
Output: 9
Explanation: (2 + 1) * 3 = 9.
Input: tokens = ["4","13","5","/","+"]
Output: 6
Explanation: 4 + (13 / 5) = 4 + 2 = 6.
Input: tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]
Output: 22
1 <= len(tokens) <= 10^4tokens[i] is an integer in -200..200 or an
operator.int(a/b)).RPN ↔︎ stack — classic. Walk through: - Number →
push. - Operator → pop two values (b first, then
a), compute a op b, push the result.
At the end the stack contains one element = the answer.
Illustration for
["2","1","+","3","*"]:
token action stack
─────────────────────────────────────────────
"2" push 2 [2]
"1" push 1 [2, 1]
"+" pop b=1, a=2; push 3 [3]
"3" push 3 [3, 3]
"*" pop b=3, a=3; push 9 [9]
Result: 9
from typing import List
import operator
class Solution:
OPS = {
'+': operator.add,
'-': operator.sub,
'*': operator.mul,
'/': lambda a, b: int(a / b), # truncate toward 0
}
def evalRPN(self, tokens: List[str]) -> int:
stack: list[int] = []
for tk in tokens:
if tk in self.OPS:
b = stack.pop()
a = stack.pop()
stack.append(self.OPS[tk](a, b))
else:
stack.append(int(tk))
return stack[0]O(n). Space:
O(n).-7 // 2 == -4 (floor), but the problem wants truncate
toward zero → int(-7 / 2) == -3. Use
int(a / b) for correctness.b (right operand)
is popped first, a (left) second.
- and / are not commutative.Given an array temperatures, for each day i
find how many days you have to wait before a day with a strictly
higher temperature. If none exists, return 0.
Input: temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
Output: [1, 1, 4, 2, 1, 1, 0, 0]
Explanation:
day 0: 73 → day 1 (74) is higher → 1
day 2: 75 → wait until day 6 (76) → 6-2=4
day 6: 76 has nothing higher → 0
1 <= len <= 10^530 <= temperatures[i] <= 100<, not <=.Brute force — O(n²). For each
i scan forward for a greater value. TLEs at
n = 10^5.
Optimal — Monotonic decreasing stack —
O(n).
Idea: maintain a stack of indices
whose temperatures decrease from bottom to top. When day i
arrives with a temperature greater than the stack top → that is the
answer for the top day. Pop and write
result[top] = i - top.
Illustration for
[73, 74, 75, 71, 69, 72, 76, 73]:
i temp action stack (idx) result
─────────────────────────────────────────────────────────────────────────
0 73 push 0 [0] [_, _, _, _, _, _, _, _]
1 74 74 > 73 → pop 0, result[0]=1-0=1 [1] [1, _, _, _, _, _, _, _]
push 1
2 75 75 > 74 → pop 1, result[1]=2-1=1 [2] [1, 1, _, _, _, _, _, _]
push 2
3 71 71 < 75 → push 3 [2, 3]
4 69 69 < 71 → push 4 [2, 3, 4]
5 72 72 > 69 → pop 4, result[4]=5-4=1 [2, 3, 5]
72 > 71 → pop 3, result[3]=5-3=2
push 5
6 76 76 > 72 → pop 5, result[5]=6-5=1 [6]
76 > 75 → pop 2, result[2]=6-2=4
push 6
7 73 73 < 76 → push 7 [6, 7]
Leftover in stack: [6, 7] → result stays at 0.
Answer: [1, 1, 4, 2, 1, 1, 0, 0] ✓
Stack invariant: indices on the stack have
strictly decreasing temperatures from bottom to top.
Each index is pushed once and popped at most once → O(n)
total.
from typing import List
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
n = len(temperatures)
result = [0] * n
stack: list[int] = [] # indices with decreasing temperatures
for i, t in enumerate(temperatures):
while stack and temperatures[stack[-1]] < t:
j = stack.pop()
result[j] = i - j
stack.append(i)
return resultO(n) — each index is
pushed/popped at most once.O(n).t[i] exceeds an element on the stack, every element
below (if also smaller than t[i]) already has a
candidate answer i. The stack is decreasing, so we just pop
everything < t[i] from the top down.<= instead of
< → a later tied temperature would count as “warmer”,
which is wrong.Given a string s encoded as
k[encoded_string] — meaning encoded_string is
repeated k times — decode the string.
Input: s = "3[a]2[bc]"
Output: "aaabcbc"
Input: s = "3[a2[c]]"
Output: "accaccacc" (nested)
Input: s = "2[abc]3[cd]ef"
Output: "abcabccdcdcdef"
1 <= len(s) <= 301 <= k <= 300 (integer)A nested structure ⇒ use a stack. Whenever we
encounter [, “save” the current k and the
in-progress string onto the stack, then reset. When we hit
], pop (prev_str, k) and assemble
prev_str + k * cur_str.
Approach 2 — Recursive. Each k[...] is
a recursive call. Cleaner code but uses recursion stack (can exceed
limits with deep nesting).
Illustration — Stack approach for
"3[a2[c]]":
ch action stack cur
─────────────────────────────────────────────────────────────────
'3' k = 3 [] ""
'[' push (k, cur); reset k, cur [(3, "")] ""
'a' cur += 'a' [(3, "")] "a"
'2' k = 2 [(3, "")] "a"
'[' push (k=2, cur="a"); reset k, cur [(3, ""), (2, "a")] ""
'c' cur += 'c' [(3, ""), (2, "a")] "c"
']' (prev_k=2, prev_str="a") = pop;
cur = prev_str + prev_k * cur = "a" + "cc" [(3, "")] "acc"
']' (prev_k=3, prev_str="") = pop;
cur = "" + 3 * "acc" = "accaccacc" [] "accaccacc"
Answer: "accaccacc" ✓
class Solution:
def decodeString(self, s: str) -> str:
stack: list[tuple[str, int]] = [] # (prev_str, prev_k)
cur, k = "", 0
for ch in s:
if ch.isdigit():
k = k * 10 + int(ch)
elif ch == '[':
stack.append((cur, k))
cur, k = "", 0
elif ch == ']':
prev_str, prev_k = stack.pop()
cur = prev_str + cur * prev_k
else:
cur += ch
return cur
class SolutionRecursive:
"""Approach 2 — recursive. Clean code but uses recursion stack."""
def decodeString(self, s: str) -> str:
self.i = 0
return self._decode(s)
def _decode(self, s: str) -> str:
result = ""
k = 0
while self.i < len(s) and s[self.i] != ']':
ch = s[self.i]
if ch.isdigit():
k = k * 10 + int(ch)
self.i += 1
elif ch == '[':
self.i += 1
inner = self._decode(s)
result += k * inner
k = 0
self.i += 1 # skip ']'
else:
result += ch
self.i += 1
return resultO(N) where N is the
length of the decoded output (each output character is produced
once).O(N).k: k = k * 10 + int(ch) (since
10[ab] has k = 10).k = 0 after pushing.prev_str + cur * prev_k — the string accumulated
before [ goes on the left.| Purpose | What the stack holds | Example problems |
|---|---|---|
| Match symmetric pairs | Opening brackets / tokens awaiting close | LC 20, 1249 |
| Save previous tokens not yet finished | Numbers / strings to “expand” later | LC 394 Decode |
| Undo / context | Parent operations | LC 224 Calculator |
| Monotonic | Index/value increasing/decreasing | Chapter 18 |
| Iterative DFS | Frame call | Tree iterative inorder |
3[a2[c]]| Step | Char | num | cur | numStack | strStack |
|---|---|---|---|---|---|
| 0 | 3 |
3 | "" |
[] |
[] |
| 1 | [ |
0 | "" |
[3] |
[""] |
| 2 | a |
0 | "a" |
[3] |
[""] |
| 3 | 2 |
2 | "a" |
[3] |
[""] |
| 4 | [ |
0 | "" |
[3,2] |
["", "a"] |
| 5 | c |
0 | "c" |
[3,2] |
["", "a"] |
| 6 | ] |
0 | "acc" (a + c×2) |
[3] |
[""] |
| 7 | ] |
0 | "accaccacc" (×3) |
[] |
[] |
Pop b first, then a,
compute a op b. Reversing the order is the classic bug for
- and /.
(val, current_min): simple,
O(n) memory.Graph is the most abstract yet most ubiquitous data structure in real life: friendships, road networks, dependencies, … This chapter introduces graph representations and 6 general graph problems. The two traversal techniques BFS and DFS are explored in depth in Chapters 10 and 11; here we use both at a basic level to build familiarity.
After this chapter, you will be able to:
Three questions to answer first: 1. Directed or undirected? Directed graphs require extra care for cycles. 2. Weighted? If yes → consider Dijkstra (Chapter 30); otherwise BFS/DFS suffices. 3. Special properties? DAG → topological sort, bipartite, planar, …
from collections import defaultdict
from typing import List
# 1) Adjacency List — what most problems use
graph: dict[int, list[int]] = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u) # drop this line if the graph is directed
# 2) Edge List — the "raw" input format
edges: list[tuple[int, int]] = [(0, 1), (1, 2), ...]
# 3) Adjacency Matrix — only when V is small (≤ 1000) and density is high
adj = [[0] * n for _ in range(n)]
for u, v in edges:
adj[u][v] = 1Which one to use?
| Representation | Lookup (u, v) |
Walk neighbours of u |
Memory |
|---|---|---|---|
| Adj list | O(deg(u)) |
O(deg(u)) |
O(V + E) |
| Edge list | O(E) |
O(E) |
O(E) |
| Adj matrix | O(1) |
O(V) |
O(V²) |
→ Default to adjacency list. Switch to a matrix only
when you need O(1) edge checks and V is
small.
from collections import defaultdict, deque
# Recursive DFS
def dfs(node: int, visited: set[int], graph: dict) -> None:
if node in visited:
return
visited.add(node)
for neighbor in graph[node]:
dfs(neighbor, visited, graph)
# Iterative DFS with a stack
def dfs_iter(start: int, graph: dict) -> set[int]:
visited = set()
stack = [start]
while stack:
node = stack.pop()
if node in visited:
continue
visited.add(node)
for nb in graph[node]:
if nb not in visited:
stack.append(nb)
return visited
# BFS with a queue
def bfs(start: int, graph: dict) -> set[int]:
visited = {start}
queue = deque([start])
while queue:
node = queue.popleft()
for nb in graph[node]:
if nb not in visited:
visited.add(nb)
queue.append(nb)
return visitedGiven n nodes labelled 0..n-1 and an array
of undirected edges edges[i] = [u, v],
plus source and destination, return
True if there is a path between them.
Input: n = 3, edges = [[0,1],[1,2],[2,0]], source = 0, destination = 2
Output: True
Input: n = 6, edges = [[0,1],[0,2],[3,5],[5,4],[4,3]], source = 0, destination = 5
Output: False
1 <= n <= 2·10^50 <= len(edges) <= 2·10^5Three classic approaches, all O(V + E): 1.
BFS — level traversal, return as soon as we hit
destination. 2. DFS — recursive or
stack-based. 3. Union Find (Chapter 24) — merge
endpoints of every edge under a single root; check
find(source) == find(destination). Great when the problem
asks many “is there a path between u, v?” queries.
from collections import defaultdict, deque
from typing import List
class Solution:
"""BFS — cleanest for a single query."""
def validPath(self, n: int, edges: List[List[int]],
source: int, destination: int) -> bool:
if source == destination:
return True
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
visited = {source}
queue = deque([source])
while queue:
node = queue.popleft()
for nb in graph[node]:
if nb == destination:
return True
if nb not in visited:
visited.add(nb)
queue.append(nb)
return FalseO(V + E).
Space: O(V + E).V = 10^5+).source == destination
→ still correct because BFS visits itself, but a guard makes the code
crisp.Given a node in an undirected, connected graph. Each
Node has val: int and
neighbors: list[Node]. Return a deep copy
of the graph: every new node is a distinct instance with
neighbors pointing at the corresponding new
nodes.
Input: adjList = [[2,4], [1,3], [2,4], [1,3]]
(adjList[i-1] = neighbour list of node i; values are 1-indexed
per LC. The actual API parameter is Node = adjList[0] = node 1)
Corresponding graph:
1 ── 2
│ │
4 ── 3
Output: [[2,4], [1,3], [2,4], [1,3]]
(same adjacency structure, but EVERY Node in the output is a NEW
instance; no Node is shared with the input)
0 <= number of nodes <= 1001 <= val <= 100, values are unique.Same pattern as LC 138 (Copy List with Random
Pointer, problem 7.8): use a hash map
original_to_copy to avoid duplicates and to handle
cycles.
BFS or DFS works — the crucial part is check visited via the dict.
Illustration for graph 1—2—3—4—1:
Start: visit 1.
cloned = {1: Node(1)}
queue = [1]
Pop 1: neighbours = [2, 4]
Create Node(2), Node(4); add to cloned.
cloned[1].neighbors = [cloned[2], cloned[4]]
queue = [2, 4]
Pop 2: neighbours = [1, 3]
cloned[1] exists; create Node(3).
cloned[2].neighbors = [cloned[1], cloned[3]]
queue = [4, 3]
Pop 4: neighbours = [1, 3]
Both exist in cloned.
cloned[4].neighbors = [cloned[1], cloned[3]]
Pop 3: neighbours = [2, 4]
Both exist.
cloned[3].neighbors = [cloned[2], cloned[4]]
→ Return cloned[1] as the head of the copy.
from collections import deque
class Node:
def __init__(self, val: int = 0, neighbors: "list[Node] | None" = None):
self.val = val
self.neighbors = neighbors if neighbors is not None else []
class Solution:
"""BFS with a hash map."""
def cloneGraph(self, node: "Node | None") -> "Node | None":
if not node:
return None
cloned: dict["Node", "Node"] = {node: Node(node.val)}
queue: deque["Node"] = deque([node])
while queue:
cur = queue.popleft()
for nb in cur.neighbors:
if nb not in cloned:
cloned[nb] = Node(nb.val)
queue.append(nb)
cloned[cur].neighbors.append(cloned[nb])
return cloned[node]
class SolutionDFS:
"""Recursive DFS — shorter code."""
def cloneGraph(self, node: "Node | None") -> "Node | None":
cloned: dict["Node", "Node"] = {}
def dfs(cur: "Node") -> "Node":
if cur in cloned:
return cloned[cur]
copy = Node(cur.val)
cloned[cur] = copy # MUST set BEFORE recursing on neighbours
copy.neighbors = [dfs(nb) for nb in cur.neighbors]
return copy
return dfs(node) if node else NoneO(V + E).O(V) for
cloned.cloned[cur] = copy before recursing on
neighbours — otherwise a cycle causes infinite recursion.O(V + E). BFS sidesteps stack
overflow.Given n nodes labelled 0..n-1 and an array
of undirected edges, count the number of
connected components.
Input: n = 5, edges = [[0,1],[1,2],[3,4]]
Output: 2
Explanation: 2 components {0,1,2} and {3,4}.
Input: n = 5, edges = [[0,1],[1,2],[2,3],[3,4]]
Output: 1
1 <= n <= 20000 <= len(edges) <= n*(n-1)/2Approach 1 — DFS/BFS from each unvisited node —
O(V + E). For each unvisited node, increment a
counter and DFS/BFS-mark the whole component.
Approach 2 — Union Find —
O((V + E) · α(V)). Union endpoints of every edge
under the same root. Count distinct roots.
from collections import defaultdict
from typing import List
class Solution:
def countComponents(self, n: int, edges: List[List[int]]) -> int:
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
visited = [False] * n
count = 0
def dfs(node: int) -> None:
visited[node] = True
for nb in graph[node]:
if not visited[nb]:
dfs(nb)
for i in range(n):
if not visited[i]:
count += 1
dfs(i)
return count
class SolutionUF:
"""Union Find — nicer when many queries appear."""
def countComponents(self, n: int, edges: List[List[int]]) -> int:
parent = list(range(n))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]] # path compression
x = parent[x]
return x
def union(x: int, y: int) -> bool:
px, py = find(x), find(y)
if px == py:
return False
parent[px] = py
return True
components = n
for u, v in edges:
if union(u, v):
components -= 1
return componentsO(V + E) time,
O(V + E) space.O((V + E) · α(V)) time,
O(V) space.V → fall back to iterative DFS.There are numCourses courses labelled
0..numCourses-1. Each
prerequisites[i] = [a, b] means to take course
a you must first finish course b. Return
True if you can finish all courses, otherwise
False.
Input: numCourses = 2, prerequisites = [[1, 0]]
Output: True
Input: numCourses = 2, prerequisites = [[1, 0], [0, 1]]
Output: False (cycle: 1 requires 0, 0 requires 1)
1 <= numCourses <= 20000 <= len(prerequisites) <= 5000Reformulation: build a directed graph
b → a (b must finish before a). The question becomes:
does the graph have a cycle? No cycle → all courses can
be finished.
Approach 1 — DFS with three colours (white / gray / black).
white = unvisited.gray = currently on the recursion path.black = fully processed, no cycle from here.If DFS encounters gray → back-edge → cycle.
Approach 2 — Topological sort BFS (Kahn’s algorithm).
Count indegree of each node. Enqueue nodes with
indegree == 0. Pop and decrement the indegree of
neighbours. If at the end we processed numCourses nodes →
DAG; otherwise a cycle exists.
Topological sort is the heart of Chapter 13. We introduce it early because Course Schedule is the classic application.
Illustration — 3-colour DFS with the cycle
0 → 1 → 0:
Start: all white.
DFS(0):
mark 0 = gray.
visit 1 (white):
DFS(1):
mark 1 = gray.
visit 0 (gray!) → back-edge detected → return False (cycle)
from collections import defaultdict, deque
from typing import List
class Solution:
"""3-colour DFS."""
WHITE, GRAY, BLACK = 0, 1, 2
def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
graph = defaultdict(list)
for a, b in prerequisites:
graph[b].append(a) # b → a
color = [self.WHITE] * numCourses
def has_cycle(node: int) -> bool:
if color[node] == self.GRAY:
return True # back-edge
if color[node] == self.BLACK:
return False # already cleared
color[node] = self.GRAY
for nb in graph[node]:
if has_cycle(nb):
return True
color[node] = self.BLACK
return False
for i in range(numCourses):
if has_cycle(i):
return False
return True
class SolutionKahn:
"""BFS topological sort (Kahn's algorithm)."""
def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
graph = defaultdict(list)
indeg = [0] * numCourses
for a, b in prerequisites:
graph[b].append(a)
indeg[a] += 1
queue = deque(i for i, d in enumerate(indeg) if d == 0)
processed = 0
while queue:
node = queue.popleft()
processed += 1
for nb in graph[node]:
indeg[nb] -= 1
if indeg[nb] == 0:
queue.append(nb)
return processed == numCoursesO(V + E).O(V + E).b → a (b enables a).
Drawing the right direction is half the battle.Given an undirected graph as adjacency list
graph[i] = [neighbours of i], return True if
the graph is bipartite — the vertices can be split into
two sets such that every edge connects vertices from
different sets.
Input: graph = [[1,2,3],[0,2],[0,1,3],[0,2]]
Output: False
Explanation: 0—1—2—0 is a triangle → cannot 2-colour.
Input: graph = [[1,3],[0,2],[1,3],[0,2]]
Output: True
Explanation: set A = {0, 2}, set B = {1, 3}.
1 <= n <= 1000 <= graph[i].length < nEquivalent: bipartite ↔︎ can 2-colour such that no two adjacent vertices share a colour.
BFS/DFS with 2-colouring: start each component,
colour the first vertex 0. As BFS/DFS visits neighbours,
flip the colour. If a neighbour already has the same colour → return
False.
Illustration — triangle (not bipartite):
0
/ \
1───2
BFS from 0:
color[0] = 0.
Visit 1: color[1] = 1 (opposite of 0).
Visit 2: color[2] = 1 (opposite of 0).
From 1, visit 2: color[2] is already 1 == color[1] = 1 → CONFLICT → False
from collections import deque
from typing import List
class Solution:
def isBipartite(self, graph: List[List[int]]) -> bool:
n = len(graph)
color = [-1] * n # -1 = uncoloured
for start in range(n):
if color[start] != -1:
continue
# BFS the component containing `start`.
color[start] = 0
queue = deque([start])
while queue:
node = queue.popleft()
for nb in graph[node]:
if color[nb] == -1:
color[nb] = 1 - color[node]
queue.append(nb)
elif color[nb] == color[node]:
return False
return TrueO(V + E).O(V).for start in range(n)? The graph
may be disconnected — start BFS in every component.Given equations equations[i] = [Ai, Bi] and values
values[i], meaning Ai / Bi = values[i]. Given
queries queries[j] = [Cj, Dj], answer each with
Cj / Dj, or -1 if it cannot be determined.
Input: equations = [["a","b"], ["b","c"]]
values = [2.0, 3.0]
queries = [["a","c"], ["b","a"], ["a","e"], ["a","a"], ["x","x"]]
Output: [6.0, 0.5, -1.0, 1.0, -1.0]
Explanation:
a/c = a/b * b/c = 2 * 3 = 6
b/a = 1/(a/b) = 0.5
a/e is undefined (e is not in any equation)
a/a = 1
x/x: x not in any equation → -1
1 <= len(equations) <= 200.0 < values[i] <= 20.0Insight: each equation A / B = k ↔︎ two
edges in a directed weighted graph: - A → B with weight
k. - B → A with weight 1/k.
Then C / D = product of weights along any path from
C to D. No path → return -1.
DFS on the weighted graph suffices. A tighter solution: Weighted Union Find (see Chapter 24).
Illustration for equations
a/b=2, b/c=3:
Weighted graph:
── 2 ──> ── 3 ──>
a b c
<─ 0.5 ── <─ 1/3 ─
Query a/c: DFS from a:
a → b (× 2), then b → c (× 3) → product 2 * 3 = 6. ✓
from collections import defaultdict
from typing import List
class Solution:
def calcEquation(
self, equations: List[List[str]],
values: List[float], queries: List[List[str]]
) -> List[float]:
graph: dict[str, dict[str, float]] = defaultdict(dict)
for (a, b), v in zip(equations, values):
graph[a][b] = v
graph[b][a] = 1.0 / v
def dfs(src: str, dst: str, visited: set[str]) -> float:
if src not in graph or dst not in graph:
return -1.0
if src == dst:
return 1.0
visited.add(src)
for nb, weight in graph[src].items():
if nb in visited:
continue
sub = dfs(nb, dst, visited)
if sub != -1.0:
return weight * sub
return -1.0
return [dfs(c, d, set()) for c, d in queries]O(Q · (V + E)) where
Q is the number of queries.O(V + E).src == dst base case → returns -1 even
for a/a.src not in graph → query on an unknown
variable misbehaves.visited per query — sharing it across
queries would block valid paths.find(x) returns both root and the product of
weights on the path.O(α(V)).| Symptom in the problem | Best pattern | Chapter |
|---|---|---|
| “Path from A to B?” | BFS / DFS / Union Find | 10, 11, 24 |
| “Number of clusters/islands” | DFS / Union Find | 12, 24 |
| “Ordering with constraints” | Topological sort | 13 |
| “Shortest path positive weights” | Dijkstra (Chapter 30) | |
| “Shortest path 0/1 edges” | 0-1 BFS / regular BFS | |
| “All-pairs shortest” | Floyd-Warshall O(V³) |
|
| “Minimum spanning network” | MST (Chapter 33) | |
| “Bottleneck min/max on path” | Kruskal + DSU / BS + BFS (37) | |
| “Bipartite?” | BFS/DFS 2-colour |
old: 1 — 2
\ /
3 — 4
old_to_new = {1:1', 2:2', 3:3', 4:4'} (dict from original to copy)
clone(node):
if node in old_to_new: return old_to_new[node]
new = Node(node.val)
old_to_new[node] = new # SET BEFORE recursing → avoid cycles
for nei in node.neighbors:
new.neighbors.append(clone(nei))
return new
a / b = w as a weighted edge: walking from
a to b “multiplies by w”.x / y: find a path x → y; the answer
is the product of weights along the way.-1.0.The full Topological Sort treatment lives in Chapter 13. This chapter only introduces DFS cycle detection.
BFS traverses a graph level by level. The key property: if every edge has the same weight (= 1), BFS from
sourceyields the shortest path to every other vertex. That’s why BFS shows up repeatedly in “shortest path on an unweighted graph”, “minimum steps”, “minimum transformations”, … problems.
After this chapter, you will be able to:
(r, c, k).BFS vs DFS: - BFS: shortest path, level traversal. - DFS: deep exploration (first path to a target), connectivity, count components.
from collections import deque
# 1) Standard BFS — shortest path from start to target
def bfs_shortest(start, target, neighbors_fn) -> int:
if start == target:
return 0
visited = {start}
queue = deque([(start, 0)]) # (node, distance)
while queue:
node, dist = queue.popleft()
for nb in neighbors_fn(node):
if nb == target:
return dist + 1
if nb not in visited:
visited.add(nb)
queue.append((nb, dist + 1))
return -1
# 2) Level BFS — no need to store distance in the queue
def bfs_by_level(start, neighbors_fn):
visited = {start}
queue = deque([start])
level = 0
while queue:
size = len(queue)
for _ in range(size):
node = queue.popleft()
# ... process node at this level ...
for nb in neighbors_fn(node):
if nb not in visited:
visited.add(nb)
queue.append(nb)
level += 1
# 3) Multi-source BFS — push many sources into the queue simultaneously
def multi_source(sources: list, neighbors_fn):
queue = deque(sources)
visited = set(sources)
while queue:
...Given the root of a binary tree, return its
level-order traversal as a list of lists (each inner
list contains the nodes at one level, top to bottom, left to right).
Input: root = [3, 9, 20, null, null, 15, 7] (LC level-order serialisation)
Actual tree:
3
/ \
9 20
/ \
15 7
Output: [[3], [9, 20], [15, 7]]
0 <= number of nodes <= 2000-1000 <= node.val <= 1000Level-order BFS. Each outer loop iteration = one
level. At the start of each iteration, record
size = len(queue), then pop exactly size nodes
— those form the entire current level.
Illustration:
Init: queue = [3]
Level 0: size=1
Pop 3. result.append([3]). Push 9, 20.
queue = [9, 20]
Level 1: size=2
Pop 9 → null children.
Pop 20 → push 15, 7.
result.append([9, 20]).
queue = [15, 7]
Level 2: size=2
Pop 15, 7 → null children.
result.append([15, 7]).
queue = []
→ [[3], [9, 20], [15, 7]]
from collections import deque
from typing import List, Optional
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val, self.left, self.right = val, left, right
class Solution:
def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
if not root:
return []
result: List[List[int]] = []
queue = deque([root])
while queue:
size = len(queue)
level_vals: list[int] = []
for _ in range(size):
node = queue.popleft()
level_vals.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
result.append(level_vals)
return resultO(n). Space:
O(n) for the queue (last level may hold ~n/2 nodes).result.reverse().level_vals[-1] per
level.max(level_vals).level_vals.size = len(queue) before the inner loop → the queue grows
during iteration → levels get “mixed”.Given a grid grid with values: - 0 = empty
cell - 1 = fresh orange - 2 = rotten
orange
Every minute, each rotten orange turns its 4
axis-adjacent (up/down/left/right) fresh neighbours into rotten
ones. Return the minimum number of minutes until no fresh orange
remains, or -1 if impossible.
Input: grid = [[2,1,1],
[1,1,0],
[0,1,1]]
(0 = empty, 1 = fresh orange, 2 = rotten)
Output: 4 (minutes for every orange to rot)
Input: grid = [[2,1,1],
[0,1,1],
[1,0,1]]
Output: -1 (the orange at (2,0) is isolated and never rots)
Insight: every rotten orange is a source. All sources spread simultaneously every minute → multi-source BFS.
Procedure: 1. Enqueue all initial
rotten oranges (level 0). 2. Level-order BFS — each level increments
minutes by 1. 3. Count the initial fresh oranges. Every
newly rotted one decrements the count. 4. End: if any fresh remains →
-1, otherwise → minutes.
Illustration with a 3x3 grid:
Init: t=0: t=1: t=4:
[2, 1, 1] [2, 1, 1] [2, 2, 1] [2, 2, 2]
[1, 1, 0] [1, 1, 0] [2, 1, 0] [2, 2, 0]
[0, 1, 1] [0, 1, 1] [0, 1, 1] [0, 2, 2]
(4 fresh) (done)
BFS dynamics:
The queue holds same-level cells (i, j) → each outer iteration pops the
entire queue then enqueues neighbours. Outer iterations = minutes.
from collections import deque
from typing import List
class Solution:
def orangesRotting(self, grid: List[List[int]]) -> int:
rows, cols = len(grid), len(grid[0])
queue: deque[tuple[int, int]] = deque()
fresh = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 2:
queue.append((r, c))
elif grid[r][c] == 1:
fresh += 1
if fresh == 0:
return 0
minutes = 0
dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
while queue and fresh > 0:
minutes += 1
for _ in range(len(queue)):
r, c = queue.popleft()
for dr, dc in dirs:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
grid[nr][nc] = 2
fresh -= 1
queue.append((nr, nc))
return minutes if fresh == 0 else -1O(R · C).
Space: O(R · C).0 immediately if there
are no fresh oranges — without this guard, the outer loop won’t run and
you’d return minutes = 0 correctly by luck.Given beginWord, endWord, and a
wordList (all same length). Each transformation step
replaces exactly one character of the current word so
that the new word is in wordList. Return the
minimum number of steps to transform
beginWord → endWord (counting both ends).
Return 0 if impossible.
Input: beginWord = "hit", endWord = "cog"
wordList = ["hot","dot","dog","lot","log","cog"]
Output: 5
Explanation: hit → hot → dot → dog → cog (length 5)
Input: beginWord = "hit", endWord = "cog"
wordList = ["hot","dot","dog","lot","log"]
Output: 0 (cog not in wordList)
1 <= len(beginWord) <= 101 <= len(wordList) <= 5000Modelling: each word is a vertex; an edge exists between two words that differ by exactly one character. The problem becomes shortest path in an undirected graph → BFS.
Neighbour generation optimisation: instead of
comparing the current word with every word in wordList
(O(N·L) per node — too slow), we generate neighbours by
replacing each position with a..z (O(26·L) per
node).
Illustration for "hit" → "cog":
Level 1: hit
Level 2: hot (change i→o)
Level 3: dot, lot (change h→d/l)
Level 4: dog, log (change t→g)
Level 5: cog ★ answer (5 steps)
from collections import deque
from typing import List
class Solution:
def ladderLength(self, beginWord: str, endWord: str, wordList: List[str]) -> int:
word_set = set(wordList)
if endWord not in word_set:
return 0
queue = deque([(beginWord, 1)])
visited = {beginWord}
while queue:
word, steps = queue.popleft()
if word == endWord:
return steps
for i in range(len(word)):
for ch in 'abcdefghijklmnopqrstuvwxyz':
if ch == word[i]:
continue
next_word = word[:i] + ch + word[i + 1:]
if next_word in word_set and next_word not in visited:
visited.add(next_word)
queue.append((next_word, steps + 1))
return 0O(N · L² · 26) where
N = words, L = word length.
26·L neighbours, each costs
O(L) to build.O(N · L).beginWord and endWord, stop when the two
frontiers meet. Reduces O(b^d) to O(b^(d/2)) —
a major speed-up on long paths.{"h*t": ["hot", "hit", ...]}. Neighbours of
"hot" are unions over patterns "_ot",
"h_t", "ho_". Faster for large
L.steps from 1 (includes
beginWord).A 4-digit lock starts at "0000". Each step rotates one
digit up or down by 1 (wrap 0..9). Given a list of
deadends (forbidden states) and a target,
return the minimum number of steps to reach target, or
-1.
Input: deadends = ["0201","0101","0102","1212","2002"], target = "0202"
Output: 6
Explanation: 0000 → 1000 → 1100 → 1200 → 1201 → 1202 → 0202
(avoiding every deadend)
1 <= len(deadends) <= 500target is not in deadends."0000" itself in deadends? → Return
-1.Implicit state-space graph. Each state is a 4-digit string → 10^4 = 10000 states. From each state there are 8 transitions (4 digits × 2 directions).
BFS: start from "0000", BFS to
target. Skip states in deadends.
Illustration of part of BFS:
Level 0: 0000
Level 1: 1000, 9000, 0100, 0900, 0010, 0090, 0001, 0009 (8 neighbours)
Level 2: ... (each node 8 neighbours, except visited / deadend)
...
Level 6: 0202 ★
from collections import deque
from typing import List
class Solution:
def openLock(self, deadends: List[str], target: str) -> int:
dead = set(deadends)
if "0000" in dead:
return -1
if target == "0000":
return 0
def neighbors(state: str):
for i in range(4):
d = int(state[i])
for delta in (-1, 1):
new_d = (d + delta) % 10
yield state[:i] + str(new_d) + state[i + 1:]
visited = {"0000"}
queue = deque([("0000", 0)])
while queue:
state, steps = queue.popleft()
for nb in neighbors(state):
if nb in dead or nb in visited:
continue
if nb == target:
return steps + 1
visited.add(nb)
queue.append((nb, steps + 1))
return -1O(10^4) states × 8 neighbours =
O(80000).O(10^4)."0000" in dead first — if
the start is dead, return immediately.Given an n × n matrix of 0 (passable) and
1 (obstacle), find the shortest path from
(0,0) to (n-1,n-1) moving in 8
directions (4 axes + 4 diagonals). Path length = number of
cells passed (including start and end). Return -1 if no
path exists.
Input: grid = [[0,0,0],
[1,1,0],
[1,1,0]]
Output: 4
Explanation: (0,0) → (0,1) → (1,2) → (2,2)
1 <= n <= 100grid[i][j] ∈ {0, 1}grid[0][0] and grid[n-1][n-1] may be 1
(then result -1).BFS from (0,0) with 8 directions. Each edge has weight 1 (one step = one cell).
from collections import deque
from typing import List
class Solution:
def shortestPathBinaryMatrix(self, grid: List[List[int]]) -> int:
n = len(grid)
if grid[0][0] != 0 or grid[n - 1][n - 1] != 0:
return -1
if n == 1:
return 1
dirs = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
queue = deque([(0, 0, 1)]) # (r, c, steps)
grid[0][0] = 1 # mark visited
while queue:
r, c, steps = queue.popleft()
for dr, dc in dirs:
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < n and grid[nr][nc] == 0:
if (nr, nc) == (n - 1, n - 1):
return steps + 1
grid[nr][nc] = 1
queue.append((nr, nc, steps + 1))
return -1O(n²). Space:
O(n²).grid[r][c] = 1 to
mark visited instead of a separate set. Mutating input — ask
the interviewer if allowed.max(|nr - end_r|, |nc - end_c|) (Chebyshev distance for 8
directions), A* materially beats plain BFS (Chapter 30).n == 1 check →
return 1 directly instead of entering BFS (which would
never pop anything).Given an n × n board where cells are numbered in zigzag
(Snakes & Ladders style). board[i][j] = -1 means an
ordinary cell; if >= 1, that’s a snake/ladder that
teleports you to the indicated cell.
Each step you roll a six-sided die and move 1..6 cells; if you land
on a snake/ladder, follow it immediately. Find the minimum
number of rolls to reach cell n*n. Return
-1 if impossible.
Input: board =
[[-1,-1,-1,-1,-1,-1],
[-1,-1,-1,-1,-1,-1],
[-1,-1,-1,-1,-1,-1],
[-1,35,-1,-1,13,-1],
[-1,-1,-1,-1,-1,-1],
[-1,15,-1,-1,-1,-1]]
Output: 4
2 <= n <= 20target.State space: each cell is labelled 1..n². From cell
s we can reach s+1, s+2, ..., s+6 (then
teleport if a snake/ladder lives there). Each edge = one roll →
BFS yields the minimum number of rolls.
Zigzag → coordinate trick: - Row (counting from the
bottom): (label - 1) // n. - Column depends on row parity:
even rows from the bottom run left→right, odd rows run right→left.
from collections import deque
from typing import List
class Solution:
def snakesAndLadders(self, board: List[List[int]]) -> int:
n = len(board)
def label_to_pos(label: int) -> tuple[int, int]:
quot, rem = divmod(label - 1, n)
row = n - 1 - quot
col = rem if quot % 2 == 0 else n - 1 - rem
return row, col
target = n * n
visited = {1}
queue = deque([(1, 0)]) # (square, throws)
while queue:
square, throws = queue.popleft()
for d in range(1, 7):
nxt = square + d
if nxt > target:
break
r, c = label_to_pos(nxt)
if board[r][c] != -1:
nxt = board[r][c]
if nxt == target:
return throws + 1
if nxt not in visited:
visited.add(nxt)
queue.append((nxt, throws + 1))
return -1O(n²) states × 6
transitions.O(n²).(row, col) incorrectly from label. Draw a
small n = 4 example on paper to verify the formula.visited.| State | Representative problem |
|---|---|
node |
Shortest path on an unweighted graph |
(r, c) |
Grid (Number of Islands, 01 Matrix) |
word |
Word Ladder |
(r, c, k_remaining) |
Shortest Path with K Obstacles |
board_serialized |
Sliding Puzzle, Open Lock |
bitmask_visited |
Shortest Path Visiting All Nodes |
(node, parity) |
Bipartite, even/odd step counting |
h*t → hot, hat, hit, ...:
precompute O(N · L), lookup O(L).O(L · 26) per node, easier to write but slower for large
N.Serpentine n×n board: index i (1..n²) →
coordinates:
row_from_bottom = (i - 1) // n # 0 = bottom row
col_in_row = (i - 1) % n
r = n - 1 - row_from_bottom
c = col_in_row if row_from_bottom % 2 == 0 else n - 1 - col_in_row
Classic bug: forgetting to flip on odd rows, or 0/1 indexing.
for _ in range(len(q)): ...): dist = number
of popped levels. Use when you don’t need a per-node
dist.(node, d): more flexible
(per-node d) but more memory.DFS goes as deep as possible before backtracking. This is the natural pattern for any tree/graph problem with a recursive structure: depth, path sum, validate, LCA, … This chapter focuses on DFS on trees — easy to memorise and very common in interviews. DFS on grids is covered in Chapter 12 (Island Matrix).
After this chapter, you will be able to:
Three DFS templates on trees: 1.
Top-down: pass state down
(e.g. path_so_far, current_sum). 2.
Bottom-up: leaves return values upward, the node
aggregates from children. 3. Hybrid: pass down and
collect back up.
Every tree problem in chapters 10/11/22 uses LeetCode’s standard
TreeNode:
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = rightroot parameter is always one
TreeNode (or None for an empty
tree).Input: root = [1, 2, 3, null, 4],
that is the LC level-order serialisation — read by BFS,
with null for absent children. The actual tree:
1 is the root, 2/3 its left/right
children; 2.left = None,
2.right = TreeNode(4).root / int /
list[list[int]] depending on the problem.class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val, self.left, self.right = val, left, right
# 1) Bottom-up: return a value from children
def dfs_bottom_up(node) -> int:
if not node:
return 0
left = dfs_bottom_up(node.left)
right = dfs_bottom_up(node.right)
return combine(node.val, left, right)
# 2) Top-down: pass state down
def dfs_top_down(node, state) -> None:
if not node:
return
new_state = update(state, node.val)
if is_leaf(node):
# ... record result ...
return
dfs_top_down(node.left, new_state)
dfs_top_down(node.right, new_state)
# 3) Iterative DFS via stack
def dfs_iter(root):
stack = [root]
while stack:
node = stack.pop()
if not node:
continue
# ... visit node ...
stack.append(node.right)
stack.append(node.left) # left ends up on top firstGiven the root of a binary tree, return its
maximum depth (number of nodes on the longest
root-to-leaf path).
Input: root = [3, 9, 20, null, null, 15, 7] (LC level-order serialisation)
Actual tree:
3
/ \
9 20
/ \
15 7
Output: 3
Bottom-up one-liner:
depth(node) = 1 + max(depth(left), depth(right)), base
case empty → 0.
class Solution:
def maxDepth(self, root) -> int:
if not root:
return 0
return 1 + max(self.maxDepth(root.left), self.maxDepth(root.right))O(n). Space:
O(h) stack (h = height).+1 outside
max(...) while computing min depth (LC 111) —
careful with None children (see related practice).Given root and targetSum, return
all root-to-leaf paths whose values sum to
targetSum.
Input: root = [5, 4, 8, 11, null, 13, 4, 7, 2, null, null, 5, 1]
targetSum = 22
Actual tree:
5
/ \
4 8
/ / \
11 13 4
/ \ / \
7 2 5 1
Output: [[5,4,11,2], [5,8,4,5]]
Top-down DFS + backtracking: - Descend each node,
decrementing target and appending the node to
path. - At a leaf: if target == leaf.val →
copy path into the result. - On return (after children) →
path.pop() to restore state.
Illustration — the path 5 → 4 → 11 → 2 for
target = 22:
DFS(5, target=22, path=[]):
path=[5], remaining=17
DFS(4, 17):
path=[5,4], remaining=13
DFS(11, 13):
path=[5,4,11], remaining=2
DFS(7, 2): leaf, 7 != 2 → skip
pop → path=[5,4,11]
DFS(2, 2): leaf, 2 == 2 → ADD [5,4,11,2] to result
pop → path=[5,4,11]
pop → path=[5,4]
pop → path=[5]
...
from typing import List, Optional
class Solution:
def pathSum(self, root: Optional["TreeNode"], targetSum: int) -> List[List[int]]:
result: list[list[int]] = []
path: list[int] = []
def dfs(node, remaining: int) -> None:
if not node:
return
path.append(node.val)
remaining -= node.val
if not node.left and not node.right and remaining == 0:
result.append(path.copy())
else:
dfs(node.left, remaining)
dfs(node.right, remaining)
path.pop() # backtrack
dfs(root, targetSum)
return resultO(n²) worst — each path copy
costs O(h), up to O(n) paths.O(h) stack + path.path.copy() → every entry in result aliases
the same list.path.pop() → state leaks.Given a DAG graph (adjacency list, node i
has neighbours graph[i]), return all paths
from node 0 to node n - 1.
Input: graph = [[1,2],[3],[3],[]]
# graph: 0 → 1 → 3
# 0 → 2 → 3
Output: [[0,1,3], [0,2,3]]
2 <= n <= 15DAG ⇒ no cycle ⇒ no visited required. DFS from
0; whenever we reach n-1, record
path.
from typing import List
class Solution:
def allPathsSourceTarget(self, graph: List[List[int]]) -> List[List[int]]:
n = len(graph)
result: list[list[int]] = []
path: list[int] = [0]
def dfs(node: int) -> None:
if node == n - 1:
result.append(path.copy())
return
for nb in graph[node]:
path.append(nb)
dfs(nb)
path.pop()
dfs(0)
return resultO(2^n · n) worst (DAGs can have
exponentially many paths).O(n) stack.visited? A DAG → DFS cannot
re-visit a node within the same path. (On a general graph, we
must track visited to prevent infinite loops.)(node, path) — but
cloning the path adds overhead.Given root, determine whether the tree is a valid BST
under: - Every left descendant: value strictly less
than the current node. - Every right descendant: value
strictly greater than the current node. - Both subtrees
are also BSTs.
Input: root = [2, 1, 3]
Actual tree:
2
/ \
1 3
Output: true
Input: root = [5, 1, 4, null, null, 3, 6]
Actual tree:
5
/ \
1 4
/ \
3 6
Output: false
(node 3 is in the right subtree of 5, but 3 < 5 → violates BST)
Common wrong approach — checking only
node.left.val < node.val < node.right.val. Wrong
because BST requires the entire left subtree < node,
not just the direct child.
Counterexample:
5
/ \
1 4
/ \
3 6
At node 5: 4 < 5 (OK), 1 < 5 (OK). At node 4: 3 < 4 < 6 (OK). Local checks pass, but 3 < 5 is in the right subtree → invalid.
Correct approach — DFS with (low, high)
bounds:
dfs(node, low, high): node must satisfy
low < node.val < high. Going down: - Left: new bound
(low, node.val). - Right: new bound
(node.val, high).
Approach 2 — Inorder traversal must be strictly ascending.
BST inorder = sorted sequence. Traverse inorder and check each value exceeds the previous.
import math
from typing import Optional
class Solution:
"""Approach 1 — DFS with (low, high) bounds."""
def isValidBST(self, root: Optional["TreeNode"]) -> bool:
def dfs(node, low: float, high: float) -> bool:
if not node:
return True
if not (low < node.val < high):
return False
return dfs(node.left, low, node.val) and \
dfs(node.right, node.val, high)
return dfs(root, -math.inf, math.inf)
class SolutionInorder:
"""Approach 2 — inorder traversal."""
def isValidBST(self, root) -> bool:
self.prev = -math.inf
def inorder(node) -> bool:
if not node:
return True
if not inorder(node.left):
return False
if node.val <= self.prev:
return False
self.prev = node.val
return inorder(node.right)
return inorder(root)O(n). Space:
O(h) stack.<= vs < trap:
standard BST forbids duplicates, use strict <. Some
variants allow duplicates on one side — clarify first.Given root of a binary tree (LC level-order serialised,
e.g. [3,2,3,null,3,null,1]). Each node represents a house
with value node.val. The thief cannot rob two
adjacent houses (parent ↔︎ child). Return the maximum
money he can steal.
Input: root = [3, 2, 3, null, 3, null, 1]
Actual tree:
3
/ \
2 3
\ \
3 1
Output: 7
(rob 3 + 3 + 1 = 7)
Input: root = [3, 4, 5, 1, 3, null, 1]
Actual tree:
3
/ \
4 5
/ \ \
1 3 1
Output: 9
(rob 4 + 5 = 9)
Tree DP — each node returns two
values: - rob_this = max money if we
rob this node (children skipped). -
skip_this = max money if we don’t rob this
node (children can do whatever they want).
Recurrences: -
rob_this = node.val + left.skip + right.skip -
skip_this = max(left.rob, left.skip) + max(right.rob, right.skip)
Answer = max(root.rob, root.skip).
Illustration:
3
/ \
2 3
\ \
3 1
Post-order DFS:
node 3 (left-leaf of 2): rob=3, skip=0
node 1 (right-leaf of right 3): rob=1, skip=0
node 2: rob = 2 + 0 (no left) + 0 (skip the 3) = 2
skip = 0 + max(3, 0) = 3
node 3 (right child of root): rob = 3 + max(0, 0)(no left) + 0 = 3
skip = 0 + max(1, 0) = 1
root 3: rob = 3 + 3 (skip 2) + 1 (skip right-3) = 7
skip = max(2,3) + max(3,1) = 3 + 3 = 6
Answer: max(7, 6) = 7
from typing import Optional, Tuple
class Solution:
def rob(self, root: Optional["TreeNode"]) -> int:
def dfs(node) -> Tuple[int, int]:
"""Return (rob_this, skip_this)."""
if not node:
return 0, 0
l_rob, l_skip = dfs(node.left)
r_rob, r_skip = dfs(node.right)
rob_this = node.val + l_skip + r_skip
skip_this = max(l_rob, l_skip) + max(r_rob, r_skip)
return rob_this, skip_this
return max(dfs(root))O(n). Space:
O(h) stack.dfs(node, robbed_parent: bool)? Perfectly valid,
but requires 2n states. The “return a 2-tuple” trick is
cleaner and sidesteps @cache (which can’t hash a
TreeNode by default).Given root of a binary tree (not a BST) and two nodes
p, q, find their lowest common
ancestor (LCA) — the lowest node that has both p
and q in its subtree.
Input: root = [3, 5, 1, 6, 2, 0, 8, null, null, 7, 4]
Actual tree:
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
Input: p = 5, q = 1 → Output: 3
Input: p = 5, q = 4 → Output: 5 (a node is its own ancestor)
Subtle insight: at each node: - If
node == p or node == q → return
node straight away. - Recurse on left and
right. - If both recursions return non-None →
node is the LCA. - If only one is non-None → return that
one (both p and q lie on the same side).
from typing import Optional
class Solution:
def lowestCommonAncestor(self, root, p, q):
if not root or root is p or root is q:
return root
left = self.lowestCommonAncestor(root.left, p, q)
right = self.lowestCommonAncestor(root.right, p, q)
if left and right:
return root # p and q on different sides → root is LCA
return left if left else rightO(n). Space:
O(h) stack.root is p (reference comparison)
instead of root.val == p.val — with duplicate values, value
comparison fails.O(h) and concise.parent pointer → equivalent to Intersection of LL.| Traversal | When to use |
|---|---|
| Pre-order (root → L → R) | Serialise, clone, copy |
| In-order (L → root → R) | BST sorted output, kth smallest |
| Post-order (L → R → root) | Aggregate from children (tree DP, diameter) |
| Level-order (BFS) | By layer, by distance |
def dfs(node):
if not node: return base
L = dfs(node.left)
R = dfs(node.right)
# combine L, R with node.val → answer for this subtree
# UPDATE global answer if needed
return result_to_pass_up
left.val < root.val < right.val.(lo, hi); each node must lie inside
(lo, hi). Going left, update hi = node.val;
going right, update lo = node.val.(rob, skip) traceTree:
3
/ \
2 3
\ \
3 1
Post-order returns (rob_this, skip_this): - Leaf
3 (left of 2): (3, 0). - Leaf 1
(right of right-3): (1, 0). - Node 2:
rob = 2 + 0 = 2, skip = max(3,0) = 3 →
(2, 3). - Node 3 (right of root):
rob = 3 + 0 = 3, skip = max(1,0) = 1 →
(3, 1). - Root 3:
rob = 3 + 3 + 1 = 7,
skip = max(2,3) + max(3,1) = 3 + 3 = 6 →
max(7,6) = 7.
| Tree type | How |
|---|---|
| General binary tree | Bottom-up recursion, return node if it contains p or q (LC 236) |
| BST | Compare values with root, walk one side (LC 235) — O(log n) |
| With parent pointer | Hash ancestors of p, then walk up from q |
A 2D grid is really an implicit graph: each cell is a node, and the 4 adjacent cells (up/down/left/right) are edges. Every “island” / “region painting” / “flood fill” problem is just DFS/BFS on that graph. This chapter teaches 4 tricks specific to grids: (1) flood fill, (2) multi-source BFS from the border, (3) reverse thinking (mark what we don’t need), (4) mutating the input to mark visited.
After this chapter, you will be able to:
grid: List[List[T]] with cells in 2-3
states.from collections import deque
from typing import List
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
# 1) Flood fill DFS (recursive)
def flood_fill(grid: List[List[int]], r: int, c: int, marker: int) -> int:
rows, cols = len(grid), len(grid[0])
if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != 1:
return 0
grid[r][c] = marker # mark visited
size = 1
for dr, dc in DIRS:
size += flood_fill(grid, r + dr, c + dc, marker)
return size
# 2) Multi-source BFS from all boundary or all "special" cells
def multi_source_bfs(grid, sources):
queue = deque(sources)
visited = set(sources)
while queue:
r, c = queue.popleft()
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and (nr, nc) not in visited:
visited.add((nr, nc))
queue.append((nr, nc))
return visitedGiven a grid m × n with '1' = land and
'0' = water. An island is a maximally
4-connected region of land. Count the number of islands.
Input:
[["1","1","1","1","0"],
["1","1","0","1","0"],
["1","1","0","0","0"],
["0","0","0","0","0"]]
Output: 1 (all "1"s connect into one island)
Input:
[["1","1","0","0","0"],
["1","1","0","0","0"],
["0","0","1","0","0"],
["0","0","0","1","1"]]
Output: 3 (top-left, middle, bottom-right)
1 <= m, n <= 300Classic flood fill pattern. Walk every cell: - If
it’s '1' and not yet visited → increment counter, DFS/BFS
to mark the whole island as visited (change '1' →
'0' to avoid a separate set).
Illustration for the 4×5 grid above:
Step 1, cell (0,0) = '1' → DFS:
[1 1 0 0 0] [* * 0 0 0]
[1 1 0 0 0] → [* * 0 0 0]
[0 0 1 0 0] [0 0 1 0 0]
[0 0 0 1 1] [0 0 0 1 1]
(island 1 marked)
Step 2, hit (2,2) = '1' → DFS (marks itself only):
[* * 0 0 0]
[* * 0 0 0]
[0 0 * 0 0]
[0 0 0 1 1]
Step 3, hit (3,3) = '1' → DFS:
[* * 0 0 0]
[* * 0 0 0]
[0 0 * 0 0]
[0 0 0 * *]
Count: 3 islands
from typing import List
class Solution:
def numIslands(self, grid: List[List[str]]) -> int:
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
def dfs(r: int, c: int) -> None:
if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != '1':
return
grid[r][c] = '0' # mark visited
for dr, dc in DIRS:
dfs(r + dr, c + dc)
count = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
count += 1
dfs(r, c)
return countO(m · n).
Space: O(m · n) worst-case stack on an
all-1 grid.'1' → '0'): saves space, code is shorter.RecursionError. Fallback: BFS with a
deque.Same 0/1 grid. Return the largest area of any island
(cell count), or 0 if there are no islands.
Input:
[[0,0,1,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,1,1,1,0,0,0],
[0,1,1,0,1,0,0,0,0,0,0,0,0],
[0,1,0,0,1,1,0,0,1,0,1,0,0],
[0,1,0,0,1,1,0,0,1,1,1,0,0],
[0,0,0,0,0,0,0,0,0,0,1,0,0],
[0,0,0,0,0,0,0,1,1,1,0,0,0],
[0,0,0,0,0,0,0,1,1,0,0,0,0]]
Output: 6
A variant of 12.1, but DFS returns the size instead
of void. Take max across all DFS starts.
from typing import List
class Solution:
def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
def dfs(r: int, c: int) -> int:
if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != 1:
return 0
grid[r][c] = 0
return 1 + sum(dfs(r + dr, c + dc) for dr, dc in DIRS)
best = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
best = max(best, dfs(r, c))
return bestO(m · n).
Space: O(m · n) worst stack.1 + sum(...) trick is very
Pythonic. Each neighbour recursion returns the size of “island connected
via this neighbour”; add 1 for the current cell.0 into 1 to maximise
an island. Significantly harder: requires labelling islands first.Given a grid of 'X' and 'O'.
Flip every 'O' region that is
fully surrounded by 'X' (regions that
don’t touch the grid boundary) into 'X'. 'O'
regions touching the boundary are preserved.
Input: board = [['X','X','X','X'],
['X','O','O','X'],
['X','X','O','X'],
['X','O','X','X']]
Output: board = [['X','X','X','X'],
['X','X','X','X'],
['X','X','X','X'],
['X','O','X','X']]
(mutate in place; the 'O' at (3,1) touches the bottom border → keep;
the inner 'O' cluster is surrounded → flip to 'X')
1 <= m, n <= 200Reverse thinking — an extremely useful pattern: -
Instead of finding the surrounded 'O' regions, find the
'O' regions that touch the border (much
easier). - Mark them (e.g. temporarily change to '#'). -
Final pass: - '#' → 'O' (keep). - Remaining
'O' → 'X' (flip).
Illustration:
Initial grid: After DFS from boundary 'O's (marker '#'):
X X X X X X X X
X O O X X O O X (the O at (1,1),(1,2) does NOT touch
X X O X → X X O X the border → not marked)
X O X X X # X X (O at (3,1) touches border → marked)
Final sweep:
'#' → 'O'; 'O' → 'X':
X X X X
X X X X
X X X X
X O X X
from typing import List
class Solution:
def solve(self, board: List[List[str]]) -> None:
if not board:
return
rows, cols = len(board), len(board[0])
def dfs(r: int, c: int) -> None:
if not (0 <= r < rows and 0 <= c < cols) or board[r][c] != 'O':
return
board[r][c] = '#'
dfs(r + 1, c); dfs(r - 1, c); dfs(r, c + 1); dfs(r, c - 1)
# 1. DFS from every boundary 'O' — mark them as '#'.
for r in range(rows):
dfs(r, 0)
dfs(r, cols - 1)
for c in range(cols):
dfs(0, c)
dfs(rows - 1, c)
# 2. Flip: '#' → 'O' (keep); 'O' → 'X' (flip).
for r in range(rows):
for c in range(cols):
if board[r][c] == 'O':
board[r][c] = 'X'
elif board[r][c] == '#':
board[r][c] = 'O'O(m · n).
Space: O(m · n) worst-case stack.O is surrounded requires visiting the
whole region and verifying every boundary — complicated. Conversely,
does it touch the border becomes a single query after the
marking sweep.Given a matrix heights[i][j] representing island
heights. The left and top edges border the Pacific; the
right and bottom edges border the Atlantic. Water flows
from (r, c) to a neighbouring cell of height
≤ (r, c).
Return all (r, c) from which water can flow to
both oceans.
Input: heights = [[1,2,2,3,5],
[3,2,3,4,4],
[2,4,5,3,1],
[6,7,1,4,5],
[5,1,1,2,4]]
Output: [[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]
Forward thinking — TLE. For each cell, BFS to see
which oceans it can reach. Worst-case O((mn)²).
Reverse thinking — O(m · n).
Instead of “which cells flow to the ocean?”, ask “where can the ocean climb up to?”. The ocean climbs only to cells of height ≥ the current one.
Illustration — Pacific reach (P) and Atlantic reach (A) on a 5x5 matrix:
P P P P P/A A A A A A
P . . . A P/A . . . A
P . . . A P . . . A
P/A . . . A P . . . A
P/A A A A A P P P P P
P ∩ A = cells in both sets → answer.
from typing import List
class Solution:
def pacificAtlantic(self, heights: List[List[int]]) -> List[List[int]]:
if not heights:
return []
rows, cols = len(heights), len(heights[0])
pacific: set[tuple[int, int]] = set()
atlantic: set[tuple[int, int]] = set()
def dfs(r: int, c: int, visited: set, prev_h: int) -> None:
if (r, c) in visited:
return
if not (0 <= r < rows and 0 <= c < cols):
return
if heights[r][c] < prev_h: # ocean cannot climb to a lower cell
return
visited.add((r, c))
for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
dfs(r + dr, c + dc, visited, heights[r][c])
# Pacific: left + top edges.
for r in range(rows):
dfs(r, 0, pacific, heights[r][0])
for c in range(cols):
dfs(0, c, pacific, heights[0][c])
# Atlantic: right + bottom edges.
for r in range(rows):
dfs(r, cols - 1, atlantic, heights[r][cols - 1])
for c in range(cols):
dfs(rows - 1, c, atlantic, heights[rows - 1][c])
return [[r, c] for r, c in pacific & atlantic]O(m · n) — each cell is visited
at most twice (once per ocean).O(m · n).>=, not <=).Given rooms: an m × n integer matrix with
three meaningful values: - -1 = wall (blocks the way). -
0 = gate. - INF (= 2³¹ - 1) = empty room.
Fill each empty room with the shortest distance
(4-direction steps) to the nearest gate. If a room can’t reach any gate,
leave it as INF.
Input: rooms = [[INF, -1, 0, INF],
[INF,INF,INF, -1],
[INF, -1,INF, -1],
[ 0, -1,INF,INF]]
(-1 = wall, 0 = gate, INF = empty room)
Output: rooms = [[3, -1, 0, 1],
[2, 2, 1,-1],
[1, -1, 2,-1],
[0, -1, 3, 4]]
(mutate in place; each cell = 4-step distance to the nearest gate)
INF.Multi-source BFS — enqueue all gates simultaneously. They expand outward; each cell receives a distance equal to the steps from the nearest gate.
Why is multi-source better than BFS from each gate? Multi-source
O(mn), per-gate BFSO(gates · mn)—gatescan beO(mn).
from collections import deque
from typing import List
class Solution:
def wallsAndGates(self, rooms: List[List[int]]) -> None:
if not rooms:
return
rows, cols = len(rooms), len(rooms[0])
queue: deque[tuple[int, int]] = deque()
for r in range(rows):
for c in range(cols):
if rooms[r][c] == 0:
queue.append((r, c))
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
while queue:
r, c = queue.popleft()
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
# Only write to cells still at INF — BFS first-write = min.
if 0 <= nr < rows and 0 <= nc < cols and rooms[nr][nc] == 2147483647:
rooms[nr][nc] = rooms[r][c] + 1
queue.append((nr, nc))O(m · n).
Space: O(m · n).rooms[nr][nc] == INF check → would overwrite 0
(other gate) or -1 (wall).Given a matrix mat containing only 0 and
1. Return a matrix of the same size where each cell = the
distance (4-direction steps) to the nearest
0.
Input: mat = [[0,0,0],
[0,1,0],
[1,1,1]]
Output: [[0,0,0],
[0,1,0],
[1,2,1]]
(each cell = Manhattan distance to its nearest 0)
Same as Walls and Gates: multi-source BFS from every
0. The initial distance of each 0 is
0; each 1 is unknown.
Alternative — 2-pass DP, O(m · n): -
Pass 1 (top-left → bottom-right):
dp[r][c] = min(dp[r-1][c], dp[r][c-1]) + 1. - Pass 2
(bottom-right → top-left):
dp[r][c] = min(dp[r][c], dp[r+1][c]+1, dp[r][c+1]+1).
Both are O(m · n). BFS is more intuitive; DP is
space-efficient.
from collections import deque
from typing import List
class Solution:
def updateMatrix(self, mat: List[List[int]]) -> List[List[int]]:
rows, cols = len(mat), len(mat[0])
INF = float('inf')
dist = [[INF] * cols for _ in range(rows)]
queue: deque[tuple[int, int]] = deque()
for r in range(rows):
for c in range(cols):
if mat[r][c] == 0:
dist[r][c] = 0
queue.append((r, c))
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
while queue:
r, c = queue.popleft()
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and dist[nr][nc] > dist[r][c] + 1:
dist[nr][nc] = dist[r][c] + 1
queue.append((nr, nc))
return dist
class SolutionDP:
"""Two-pass DP — space-efficient (can be in-place where allowed)."""
def updateMatrix(self, mat: List[List[int]]) -> List[List[int]]:
rows, cols = len(mat), len(mat[0])
INF = rows + cols + 1
dist = [[0 if mat[r][c] == 0 else INF
for c in range(cols)] for r in range(rows)]
# Pass 1: top-left → bottom-right.
for r in range(rows):
for c in range(cols):
if dist[r][c] == 0:
continue
top = dist[r-1][c] if r > 0 else INF
left = dist[r][c-1] if c > 0 else INF
dist[r][c] = min(top, left) + 1
# Pass 2: bottom-right → top-left.
for r in range(rows - 1, -1, -1):
for c in range(cols - 1, -1, -1):
if dist[r][c] == 0:
continue
bot = dist[r+1][c] + 1 if r < rows - 1 else INF
right = dist[r][c+1] + 1 if c < cols - 1 else INF
dist[r][c] = min(dist[r][c], bot, right)
return distO(m · n).
Space: O(m · n).INF at the edges).dirs = [(-1,0),(1,0),(0,-1),(0,1)] (4-conn) or 8-conn.0 <= nr < R and 0 <= nc < C.'1' → '0' or #) or set?visited.For Surrounded Regions (LC 130) and Pacific Atlantic (LC 417): - “A cell that does not satisfy the property” = “a cell that is connected to the boundary”. - Seed BFS/DFS from the boundary, mark reachable cells; remaining cells are the surrounded ones.
For 01 Matrix (LC 542) and Walls and
Gates (LC 286): - Enqueue every source up
front (cell 0 for 542, gate 0 for 286). - BFS
by level → distances spread outward. Each cell is visited
once ⇒ O(R·C).
| Criterion | In-place | Set/2D bool |
|---|---|---|
| Extra memory | O(1) | O(R·C) |
| Mutates input? | Yes | No |
| Concurrency / restore | Hard | Easy |
| Pick when | Mutation allowed & O(1) extra needed | Grid is immutable or needs re-run |
Topological Sort orders the vertices of a DAG (Directed Acyclic Graph) so that for every edge
u → v,uappears beforevin the order. This is the mandatory pattern for any “complete-in-dependency-order” problem: build systems, task schedulers, course prerequisites, …
After this chapter, you will be able to:
a before b → edge
a → b (accumulates b’s indegree).len(order) != V → there is a cycle.One of the most common topo-sort bugs is drawing the edges in the wrong direction. For this reason, the entire book follows one convention:
Real description Edge in graph Indegree
─────────────────────────────────────────────────────────
"a must come before b" a → b indeg[b] += 1
"b depends on a" a → b indeg[b] += 1
"a is prerequisite of b" a → b indeg[b] += 1
─────────────────────────────────────────────────────────
LC 207/210 input: prerequisites[i] = [course, prereq]
i.e. [b, a] meaning "to do b, must do a"
→ edge a → b (prereq → course)
─────────────────────────────────────────────────────────
LC 269 Alien Dict: words[i] < words[i+1] in lex order
→ first differing character: c1 < c2
→ edge c1 → c2
Kahn’s invariant: popping a node with
indeg == 0 ↔︎ “nobody must finish before it”.
Every topo-sort problem in this book uses the convention
u → v means u finishes before v. When the
problem uses different wording, always draw 2–3 edges on paper
first to make sure your code’s edge direction matches the
problem statement.
Two classic algorithms:
indegree, enqueue nodes with indeg=0.Both are O(V + E). We default to Kahn because it’s easy
to extend to “min levels”.
from collections import defaultdict, deque
from typing import List
def topo_sort_kahn(n: int, edges: List[tuple]) -> List[int]:
graph = defaultdict(list)
indeg = [0] * n
for u, v in edges:
graph[u].append(v)
indeg[v] += 1
queue = deque(i for i in range(n) if indeg[i] == 0)
order: list[int] = []
while queue:
u = queue.popleft()
order.append(u)
for v in graph[u]:
indeg[v] -= 1
if indeg[v] == 0:
queue.append(v)
return order if len(order) == n else [] # empty = cycle existsGiven numCourses courses and
prerequisites[i] = [a, b] (taking a requires
finishing b first), return a valid order
of courses, or [] if there is a cycle.
Input: numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]
Output: [0, 1, 2, 3] (or [0, 2, 1, 3])
Explanation:
Edges: 0 → 1, 0 → 2, 1 → 3, 2 → 3.
Kahn’s algorithm — straight from the template. As we
pop, append to order. At the end: if
len(order) == numCourses → return order;
otherwise a cycle exists.
Illustration for
[[1,0],[2,0],[3,1],[3,2]]:
Graph: 0
/ \
1 2
\ /
3
Initial indeg: [0, 1, 1, 2]
queue = [0] (indeg 0)
Pop 0 → order=[0]
decrement indeg[1], indeg[2] → [_, 0, 0, 2]
queue = [1, 2]
Pop 1 → order=[0, 1]
decrement indeg[3] → [_, _, _, 1]
Pop 2 → order=[0, 1, 2]
decrement indeg[3] → [_, _, _, 0]
queue = [3]
Pop 3 → order=[0, 1, 2, 3]
len(order)=4=numCourses → return [0, 1, 2, 3] ✓
from collections import defaultdict, deque
from typing import List
class Solution:
def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:
graph = defaultdict(list)
indeg = [0] * numCourses
for a, b in prerequisites:
graph[b].append(a)
indeg[a] += 1
queue = deque(i for i, d in enumerate(indeg) if d == 0)
order: list[int] = []
while queue:
node = queue.popleft()
order.append(node)
for nb in graph[node]:
indeg[nb] -= 1
if indeg[nb] == 0:
queue.append(nb)
return order if len(order) == numCourses else []O(V + E).
Space: O(V + E).order.append(node). LC 207 only checks DAG-ness; LC 210
returns a specific order.a you
need b” → b → a. Draw it before coding.An alien language uses Latin letters in a different order. Given a
list of words sorted in that order, find a valid letter
ordering (a string of letters). Return "" if
contradictory.
Input: words = ["wrt","wrf","er","ett","rftt"]
(the list of words sorted by the alien alphabet)
Output: "wertf" (one valid alphabet order; many answers may exist)
Explanation:
wrt < wrf → t < f
wrf < er → w < e
er < ett → r < t
ett < rftt → e < r
→ topo: w < e < r < t < f
Input: words = ["z","x","z"]
Output: "" (cyclic contradiction: z<x from pair 1 but x<z from pair 2)
Two steps:
(words[i], words[i+1]):
words[i] < the char in words[i+1].words[i] is a prefix of
words[i+1] we’re fine, but if words[i+1] is a
strict prefix of words[i] (e.g. ["abc", "ab"])
→ contradiction → return "".Illustration for
["wrt","wrf","er","ett","rftt"]:
Pair 1: wrt vs wrf
first diff at index 2 → t < f
→ edge t → f
Pair 2: wrf vs er
first diff at index 0 → w < e
→ edge w → e
Pair 3: er vs ett
first diff at index 1 → r < t
→ edge r → t
Pair 4: ett vs rftt
first diff at index 0 → e < r
→ edge e → r
Graph: w → e → r → t → f
Topo: w, e, r, t, f → "wertf"
from collections import defaultdict, deque
from typing import List
class Solution:
def alienOrder(self, words: List[str]) -> str:
# Initialise indeg for every character that appears.
indeg = {ch: 0 for w in words for ch in w}
graph = defaultdict(set)
# Extract relations from adjacent pairs.
for i in range(len(words) - 1):
w1, w2 = words[i], words[i + 1]
# Invalid prefix edge case.
if len(w1) > len(w2) and w1.startswith(w2):
return ""
for c1, c2 in zip(w1, w2):
if c1 != c2:
if c2 not in graph[c1]:
graph[c1].add(c2)
indeg[c2] += 1
break
# Kahn's.
queue = deque(ch for ch, d in indeg.items() if d == 0)
order: list[str] = []
while queue:
ch = queue.popleft()
order.append(ch)
for nb in graph[ch]:
indeg[nb] -= 1
if indeg[nb] == 0:
queue.append(nb)
return ''.join(order) if len(order) == len(indeg) else ""O(C) where C =
total number of characters.O(1) (alphabet ≤ 26 —
practically constant).indeg for
every character that appears (including those with no
incoming edges) — otherwise the final
len(order) == len(indeg) check is wrong.indeg — double counting.len(w1) > len(w2) and w1.startswith(w2) case → creating
an invalid structure.Input: n (number of nodes) and
edges: List[List[int]] — a list of n-1
undirected edges [u, v] describing a tree. Nodes labelled
0..n-1.
Find every root that yields the minimum tree height. Return the list of such roots (1 or 2 possible).
Input: n=6, edges = [[0,3],[1,3],[2,3],[4,3],[5,4]]
Tree: 0 1 2
\ | /
\ | /
3
|
4
|
5
Output: [3, 4]
Insight: the centroid of a tree (1 or 2 nodes) minimises tree height. To find it: BFS from the leaves, “peeling” inwards.
Procedure: 1. Build the graph + count
degree. 2. Enqueue every leaf (degree == 1).
3. Loop: pop one layer of leaves, decrement neighbour degrees, push new
leaves (degree == 1). 4. When ≤ 2 nodes remain → those are
the centroid(s).
Illustration:
Initial leaves: [0, 1, 2, 5]
Peel → remaining: [3, 4]
3 (degree=1 after peel), 4 (degree=1 after peel)
→ ≤ 2 nodes → centroids = [3, 4]
from collections import defaultdict, deque
from typing import List
class Solution:
def findMinHeightTrees(self, n: int, edges: List[List[int]]) -> List[int]:
if n == 1:
return [0]
graph = defaultdict(set)
for u, v in edges:
graph[u].add(v)
graph[v].add(u)
leaves = deque(i for i in range(n) if len(graph[i]) == 1)
remaining = n
while remaining > 2:
size = len(leaves)
remaining -= size
for _ in range(size):
leaf = leaves.popleft()
nb = next(iter(graph[leaf])) # a leaf has exactly one neighbour
graph[nb].remove(leaf)
if len(graph[nb]) == 1:
leaves.append(nb)
return list(leaves)O(V + E) = O(n).O(n).n == 1 case →
empty graph, no leaves.Given n items, each belonging to a
group (group[i] = -1 means not yet
assigned — should get its own group). beforeItems[i] lists
items that must come before item i. Arrange items so that:
- beforeItems are respected. - Items of the same group are
contiguous.
Return [] if impossible.
Input: n=8, m=2, group=[-1,-1,1,0,0,1,0,-1], beforeItems=[[],[6],[5],[6],[3,6],[],[],[]]
Output: [6,3,4,1,5,2,0,7]
Two topological sorts: 1. Sort the
groups among themselves (an item-level edge
i → j across groups becomes a group-level edge). 2. Within
each group, sort its items. 3. Concatenate the result: use the group
order; inside each group write its items by item order.
Pre-processing: any item with
group[i] == -1 → assign its own private group so “no group”
doesn’t influence the topology.
from collections import defaultdict, deque
from typing import List
class Solution:
def sortItems(
self, n: int, m: int,
group: List[int], beforeItems: List[List[int]]
) -> List[int]:
# Assign a private group to items with -1.
for i in range(n):
if group[i] == -1:
group[i] = m
m += 1
item_graph = defaultdict(list)
item_indeg = [0] * n
group_graph = defaultdict(set)
group_indeg = defaultdict(int)
for cur, befores in enumerate(beforeItems):
for prev in befores:
item_graph[prev].append(cur)
item_indeg[cur] += 1
if group[prev] != group[cur]:
if group[cur] not in group_graph[group[prev]]:
group_graph[group[prev]].add(group[cur])
group_indeg[group[cur]] += 1
def topo(nodes, graph, indeg) -> List[int]:
queue = deque(x for x in nodes if indeg[x] == 0)
out: list = []
while queue:
x = queue.popleft()
out.append(x)
for nb in graph[x]:
indeg[nb] -= 1
if indeg[nb] == 0:
queue.append(nb)
return out if len(out) == len(nodes) else []
item_order = topo(range(n), item_graph, item_indeg)
if not item_order:
return []
group_order = topo(range(m), group_graph, group_indeg)
if not group_order:
return []
# Bucket by group respecting group_order; items keep item_order.
bucket: dict[int, list[int]] = defaultdict(list)
for item in item_order:
bucket[group[item]].append(item)
result: list[int] = []
for g in group_order:
result.extend(bucket[g])
return resultO(n + e_item + e_group).O(n + m + e).-1 items → unrelated loose items get merged into the same
group.Given nums (a permutation of 1..n) and
sequences (a list of sub-sequences), check whether
nums is the unique topological order
implied by sequences.
Input: nums = [1, 2, 3], sequences = [[1,2],[1,3]]
Output: False
Explanation: from [1,2] and [1,3] → either [1,2,3] or [1,3,2] → not unique.
Input: nums = [1, 2, 3], sequences = [[1,2],[1,3],[2,3]]
Output: True
Run Kahn’s. For uniqueness, every level must
contain exactly one node with indeg == 0 — ≥ 2 ⇒
multiple topo orders ⇒ False. Additionally the pop order must match
nums.
from collections import defaultdict, deque
from typing import List
class Solution:
def sequenceReconstruction(self, nums: List[int], sequences: List[List[int]]) -> bool:
n = len(nums)
graph = defaultdict(set)
indeg = [0] * (n + 1)
for seq in sequences:
for i in range(len(seq) - 1):
u, v = seq[i], seq[i + 1]
if v not in graph[u]:
graph[u].add(v)
indeg[v] += 1
queue = deque(i for i in range(1, n + 1) if indeg[i] == 0)
idx = 0
while queue:
if len(queue) > 1:
return False # >1 choice → not unique
x = queue.popleft()
if nums[idx] != x:
return False # diverges from nums
idx += 1
for nb in graph[x]:
indeg[nb] -= 1
if indeg[nb] == 0:
queue.append(nb)
return idx == nO(V + E).
Space: O(V + E).len(queue) > 1 is the
twist — topo-order uniqueness.set for the graph.Given n courses and relations [a, b] (take
a before b). Each semester you may take
any number of courses provided all their prerequisites
are done. Return the minimum number of semesters to
take all courses, or -1 if a cycle exists.
Input: n=3, relations=[[1,3],[2,3]]
Output: 2
Explanation: Semester 1 take [1,2], semester 2 take [3]
Kahn’s BFS but count by level (semester). Each outer iteration of BFS processes the entire current queue = the courses takeable in the same semester.
from collections import defaultdict, deque
from typing import List
class Solution:
def minimumSemesters(self, n: int, relations: List[List[int]]) -> int:
graph = defaultdict(list)
indeg = [0] * (n + 1)
for u, v in relations:
graph[u].append(v)
indeg[v] += 1
queue = deque(i for i in range(1, n + 1) if indeg[i] == 0)
taken = 0
semesters = 0
while queue:
semesters += 1
for _ in range(len(queue)):
u = queue.popleft()
taken += 1
for v in graph[u]:
indeg[v] -= 1
if indeg[v] == 0:
queue.append(v)
return semesters if taken == n else -1O(V + E).
Space: O(V + E).taken must equal n;
cycles leave some nodes with indeg > 0 forever.dp[node] based only on dp[predecessors].indegree == 0, we can pick more than one way → not
unique.If w_i is a prefix of
w_{i-1} (e.g. ["abc", "ab"]), the dictionary
is invalid → return "". Check this
BEFORE building edges (don’t forget to
break correctly).
items 5,6 ∈ groupA items 7,8 ∈ groupB items 9 ∈ -1 (private)
Item DAG: 5 → 6, 7 → 8, 6 → 7 (intra + cross-group)
Group DAG: A → B (because 6 → 7 with 6 ∈ A, 7 ∈ B)
→ Topo on group order → within each group topo on item order.
Intervals (
[start, end]) cover many important calendar/scheduling/booking problems. Chapter 4 (Sorting) already touched Merge Intervals and Meeting Rooms II; this chapter dives deep into 8 operation patterns on intervals (merge, insert, intersection, overlap, free time) — unavoidable in interviews for calendar (Google Calendar) and booking (Airbnb, Booking) companies.
After this chapter, you will be able to:
[s, e] (closed) vs
[s, e) (half-open) → affects < vs
<=.start for merge; sort by end for
greedy maximum-selection.(time, +1/-1) →
count max overlap.[start, end]
(bookings, meetings, video segments, …).Four canonical relations between intervals
A = [a₁, a₂], B = [b₁, b₂]:
1. Disjoint: A.end < B.start → A entirely before B
2. Touching: A.end == B.start → adjacent; may merge depending on problem
3. Partial overlap: A.start < B.start ≤ A.end < B.end
4. Containment: A.start ≤ B.start ≤ B.end ≤ A.end
from typing import List
# 1) Merge two overlapping intervals
def merge_two(a, b):
return [min(a[0], b[0]), max(a[1], b[1])]
# 2) Overlap check (touching counts as overlap)
def overlaps(a, b) -> bool:
return a[0] <= b[1] and b[0] <= a[1]
# 3) Sweep line: same pattern for every "max overlap" question
events: List[tuple[int, int]] = []
for s, e in intervals:
events.append((s, +1)) # open
events.append((e, -1)) # close
events.sort()
cur = peak = 0
for _, delta in events:
cur += delta
peak = max(peak, cur)Fully solved in Chapter 4.2 under the Sorting lens. Here we summarise it under the “interval” lens and extend the follow-ups.
Merge overlapping intervals. [1,3] and
[2,6] → [1,6].
Sort by start. Walk once and keep last =
the most recent interval in the output. If
cur.start <= last.end →
last.end = max(last.end, cur.end); otherwise push
cur.
class Solution:
def merge(self, intervals):
intervals.sort(key=lambda x: x[0])
result = []
for cur in intervals:
if result and cur[0] <= result[-1][1]:
result[-1][1] = max(result[-1][1], cur[1])
else:
result.append(cur[:])
return resultO(n log n) (sort
dominates).O(n) output.Given intervals sorted by
start and pairwise non-overlapping, insert
newInterval and merge as needed.
Input: intervals = [[1,3],[6,9]], newInterval = [2,5]
Output: [[1,5],[6,9]]
Input: intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]], newInterval = [4,8]
Output: [[1,2],[3,10],[12,16]]
0 <= len(intervals) <= 10^4Approach 1 — O(n) single sweep in 3
phases.
newInterval: push every
interval with end < newInterval.start.start <= newInterval.end, extend
newInterval (start = min,
end = max). At the end push newInterval.newInterval: push the remaining
intervals.Approach 2 — Concat + merge (reuse problem 14.1).
Simple but O(n log n) from the unnecessary sort.
Illustration for
intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]],
new = [4,8]:
Number line:
1 3 5 6 7 8 10 12 16
├─┤ ├───┤ ├─┤ ├──┤ ├──────┤
├──────────┤ new = [4, 8]
Phase 1 (end < 4): [1, 2]
result = [[1,2]]
Phase 2 (start <= 8):
[3, 5]: extend newInterval = [min(4,3), max(8,5)] = [3, 8]
[6, 7]: extend = [3, 8]
[8, 10]: extend = [3, 10]
Push [3, 10]
result = [[1,2], [3,10]]
Phase 3: remaining [12, 16]
result = [[1,2], [3,10], [12,16]] ✓
from typing import List
class Solution:
def insert(self, intervals: List[List[int]], newInterval: List[int]) -> List[List[int]]:
result: list[list[int]] = []
i, n = 0, len(intervals)
# 1) Before newInterval.
while i < n and intervals[i][1] < newInterval[0]:
result.append(intervals[i])
i += 1
# 2) Overlap — extend newInterval.
while i < n and intervals[i][0] <= newInterval[1]:
newInterval[0] = min(newInterval[0], intervals[i][0])
newInterval[1] = max(newInterval[1], intervals[i][1])
i += 1
result.append(newInterval)
# 3) After newInterval.
while i < n:
result.append(intervals[i])
i += 1
return resultO(n). Space:
O(n) for the output.O(n). If
input isn’t sorted, sort first then reuse 14.1 —
O(n log n).< vs <= trap: the
overlap condition is intervals[i][0] <= newInterval[1].
The = matters — LC 56/57 treat touching as overlap.Given an array of intervals, return the minimum
number of intervals to remove so the rest are pairwise
non-overlapping.
Input: intervals = [[1,2],[2,3],[3,4],[1,3]]
Output: 1
Explanation: remove [1,3] → remainder [1,2],[2,3],[3,4] is non-overlapping.
Input: intervals = [[1,2],[1,2],[1,2]]
Output: 2
Input: intervals = [[1,2],[2,3]]
Output: 0 (already non-overlapping, nothing to remove)
(each [start, end] represents the half-open interval [start, end))
end? → Sort by end; on ties order
doesn’t affect the count.Greedy — sort by end ascending. Keeping
the interval with the smallest end leaves the most “room” for subsequent
ones.
Pseudocode: - Sort by end. - Keep
last_end = -∞. For each interval [s, e]: - If
s >= last_end → keep (no overlap),
last_end = e. - Otherwise → count removal.
Why sort by end, not
start? Greedy works because: “always pick the
interval with the earliest end” maximises the remaining
“free time” — provable by induction.
Illustration for
[[1,2],[2,3],[3,4],[1,3]]:
Sort by end: [[1,2], [2,3], [1,3], [3,4]]
end=2 end=3 end=3 end=4
Walk:
[1,2]: start=1 >= -inf → keep; last_end=2 kept: 1
[2,3]: start=2 >= 2 → keep; last_end=3 kept: 2
[1,3]: start=1 < 3 → remove removed: 1
[3,4]: start=3 >= 3 → keep; last_end=4 kept: 3
Kept 3, removed 1 → answer = 1.
from typing import List
class Solution:
def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
if not intervals:
return 0
intervals.sort(key=lambda x: x[1])
kept = 1
last_end = intervals[0][1]
for s, e in intervals[1:]:
if s >= last_end:
kept += 1
last_end = e
return len(intervals) - keptO(n log n).
Space: O(1) or O(n) for the
sort.start
can work with extra care (keep the interval with the smaller
end on conflict). But sort by end is the
simplest.Fully solved in Chapter 4.4. Here we summarise and link.
Find the minimum number of rooms needed for all meetings.
Three approaches (heap, sweep-line events, chronological two-pointer)
— all O(n log n). Sweep line is the
canonical interval language.
from typing import List
class Solution:
def minMeetingRooms(self, intervals: List[List[int]]) -> int:
events = [(s, +1) for s, _ in intervals] + [(e, -1) for _, e in intervals]
events.sort(key=lambda x: (x[0], x[1]))
cur = peak = 0
for _, d in events:
cur += d
peak = max(peak, cur)
return peakO(n log n).O(n).Given an array of balloons [x_start, x_end] (each
balloon spans an interval on the x-axis). A vertical arrow shot at
x = X bursts every balloon with
x_start <= X <= x_end. Find the minimum
number of arrows needed to burst all balloons.
Input: points = [[10,16],[2,8],[1,6],[7,12]]
(each entry [xstart, xend] is one balloon spanning the closed [xstart, xend])
Output: 2 (at least 2 arrows: shoot x=6 bursts [1,6] and [2,8]; shoot x=11 bursts [7,12] and [10,16])
Explanation:
1 arrow at x = 6 bursts [1,6] and [2,8].
1 arrow at x = 11 bursts [7,12] and [10,16].
Equivalent to problem 14.3 (Non-overlapping Intervals): each arrow corresponds to a group of balloons that share a common intersection. Count groups = number of arrows.
Greedy sort by end just like 14.3: -
Sort balloons by end. - Keep last_end = -∞.
For each balloon [s, e]: - If s > last_end
→ need a new arrow; last_end = e. - Otherwise → this
balloon is burst by the current arrow.
from typing import List
class Solution:
def findMinArrowShots(self, points: List[List[int]]) -> int:
if not points:
return 0
points.sort(key=lambda x: x[1])
arrows = 1
last_end = points[0][1]
for s, e in points[1:]:
if s > last_end: # disjoint → new arrow needed
arrows += 1
last_end = e
return arrowsO(n log n).
Space: O(1).>= (touching counts as overlap), this one uses
> (touching bursts together). LC 452 states “touching is
burst”.Given schedule[i] = a list of busy intervals for
employee i. Return every interval that is free for
all employees, sorted ascending. (Skip the time before the
first busy or after the last finishes.)
Input: schedule = [[[1,2],[5,6]],[[1,3]],[[4,10]]]
Output: [[3, 4]]
Explanation:
Union of busy: [1,3] (from [1,2] + [1,3]), [4,10] (from [5,6] + [4,10]).
Free in between: [3, 4].
Step 1: combine every busy interval into one big list independent of employees. Step 2: sort by start, merge (like problem 14.1). Step 3: gaps between merged intervals = free time.
from typing import List
class Interval:
def __init__(self, start: int = 0, end: int = 0):
self.start, self.end = start, end
class Solution:
def employeeFreeTime(self, schedule: "List[List[Interval]]") -> "List[Interval]":
all_busy: list[tuple[int, int]] = []
for emp_sched in schedule:
for iv in emp_sched:
all_busy.append((iv.start, iv.end))
all_busy.sort()
merged: list[list[int]] = []
for s, e in all_busy:
if merged and s <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
free = []
for i in range(1, len(merged)):
if merged[i - 1][1] < merged[i][0]:
free.append(Interval(merged[i - 1][1], merged[i][0]))
return freeO(N log N) where N
= total intervals.O(N).schedule[i] is already sorted). Pop the earliest busy
interval, merge. Detect gap → free time. O(N log K) where K
= number of employees.[s, e] or half-open
[s, e)?
[1,3] and
[3,5] are considered touching ⇒
merge.[1,3) and [3,5) are not
overlapping.start (Merge, Insert) or
sort by end (Greedy, Min Arrows)?t:
x, but be careful about heights.e1: |==1==| |==3==|
e2: |==2==| |==4==|
sort all → merge ⇒ busy: [1∪2] [3∪4]
free = complement between busy blocks
Heap (Priority Queue) quickly answers “what is the largest/smallest element right now?”. The two core operations
pushandpopare bothO(log n). Heap concisely solves: top-k, streaming median, merge k lists, scheduling. Most common pattern: a size-k heap to maintain the k best elements.
After this chapter, you will be able to:
O(n log k).heapq is a min-heap → for max-heap
push -x.k sorted
lists).Python heapq is only a min-heap. For
max-heap → push -x.
import heapq
# 1) Basic min-heap
heap: list[int] = []
heapq.heappush(heap, x)
top = heap[0] # peek (no pop)
val = heapq.heappop(heap)
# 2) Max-heap via negation
heapq.heappush(heap, -x)
val = -heapq.heappop(heap)
# 3) K smallest / largest from a list
smallest_k = heapq.nsmallest(k, arr) # O(n log k)
largest_k = heapq.nlargest(k, arr)
# 4) Heapify O(n) — faster than n pushes
heapq.heapify(arr)
# 5) Heap with arbitrary key — push tuple (priority, payload)
heapq.heappush(heap, (priority, payload))Given an array nums and integer k, return
the k-th largest element (1-indexed, descending order).
Duplicates allowed.
Input: nums = [3, 2, 1, 5, 6, 4], k = 2 → 5
Input: nums = [3, 2, 3, 1, 2, 4, 5, 5, 6], k = 4 → 4
Approach 1 — Sort, O(n log n).
sorted(nums)[-k]. Simplest.
Approach 2 — Size-k min-heap,
O(n log k).
Walk through the array, push to the heap. When the heap exceeds k →
pop. At the end heap[0] is the Kth largest.
Approach 3 — Quickselect, O(n)
average.
A quicksort variant: partition around a pivot, recurse only into the half that contains the Kth.
import heapq
from typing import List
class Solution:
"""Approach 2 — size-k heap."""
def findKthLargest(self, nums: List[int], k: int) -> int:
heap: list[int] = []
for x in nums:
heapq.heappush(heap, x)
if len(heap) > k:
heapq.heappop(heap)
return heap[0]
class SolutionQS:
"""Approach 3 — quickselect, O(n) average."""
def findKthLargest(self, nums: List[int], k: int) -> int:
# Kth largest = (n-k)-th smallest (0-indexed).
target = len(nums) - k
def partition(lo: int, hi: int) -> int:
pivot = nums[hi]
store = lo
for i in range(lo, hi):
if nums[i] < pivot:
nums[store], nums[i] = nums[i], nums[store]
store += 1
nums[store], nums[hi] = nums[hi], nums[store]
return store
lo, hi = 0, len(nums) - 1
while True:
p = partition(lo, hi)
if p == target:
return nums[p]
elif p < target:
lo = p + 1
else:
hi = p - 1| Approach | Time | Space |
|---|---|---|
| Sort | O(n log n) |
O(1) |
| Size-k heap | O(n log k) |
O(k) |
| Quickselect | O(n) avg, O(n²) worst |
O(1) |
k is small), then mention
quickselect if asked “any O(n) approach?”.Given words and integer k, return the
k most frequent words. On tie, the
lexicographically smaller word wins.
Input: words = ["i", "love", "leetcode", "i", "love", "coding"], k = 2
Output: ["i", "love"]
Explanation: "i" and "love" tie at frequency 2. "i" < "love" lexicographically.
Input: k = 4
Output: ["the", "is", "sunny", "day"] (tie → lex order)
Crux: the comparator tie-break — higher frequency wins; on tie, lexicographically smaller wins.
Approach 1 — Sort, O(n log n).
sorted(cnt.keys(), key=lambda w: (-cnt[w], w))[:k].
Approach 2 — Size-k min-heap,
O(n log k).
The heap holds (freq, word) tuples. Because it’s a
min-heap, we want: - Lower frequency at the top (pop first) → push
freq directly. - On tie → lexicographically
larger word at the top (pop first).
A trick: per tuple use (-freq, word), then sort… no, a
custom wrapper is cleaner. Approach 1 is much simpler.
import heapq
from collections import Counter
from typing import List
class Solution:
"""Approach 1 — sort. Cleanest for this problem."""
def topKFrequent(self, words: List[str], k: int) -> List[str]:
cnt = Counter(words)
return sorted(cnt.keys(), key=lambda w: (-cnt[w], w))[:k]
class SolutionHeap:
"""Approach 2 — size-k min-heap."""
def topKFrequent(self, words: List[str], k: int) -> List[str]:
cnt = Counter(words)
# In a min-heap, we want the "loser" at the top to pop first.
# "Loser" = lower freq OR (same freq + larger word).
# Using (freq, -word_chars) is awkward because word is a string.
# Solution: use a wrapper class.
class Wrap:
__slots__ = ("freq", "word")
def __init__(self, f, w): self.freq, self.word = f, w
def __lt__(self, other):
if self.freq != other.freq:
return self.freq < other.freq # min-heap → smaller freq on top
return self.word > other.word # tie → larger word on top
def __eq__(self, other):
return (self.freq, self.word) == (other.freq, other.word)
heap: list[Wrap] = []
for w, f in cnt.items():
heapq.heappush(heap, Wrap(f, w))
if len(heap) > k:
heapq.heappop(heap)
# Pop everything → reverse for correct order.
return [heapq.heappop(heap).word for _ in range(k)][::-1]O(n log n).
Heap: O(n log k).__lt__, no key=).__lt__
is extremely handy — works for any complex comparator on a heap.Design a class: - addNum(num): add num to
the data stream. - findMedian(): return the current
median.
Median of n numbers: the middle element (sorted), or the
average of the two middle elements if n is even.
Input (LC-style operation arrays):
ops = ["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"]
args = [[], [1], [2], [], [3], []]
Output: [null, null, null, 1.5, null, 2.0]
Explanation:
MedianFinder() → init, null
addNum(1) → null; stream = [1]
addNum(2) → null; stream = [1, 2]
findMedian() → 1.5 (mean of the 2 middle)
addNum(3) → null; stream = [1, 2, 3]
findMedian() → 2.0 (middle of 3 sorted numbers)
The two-heap trick: - low =
max-heap holding the lower half (below the median). -
high = min-heap holding the upper half
(above the median).
Invariants: - Every element of low ≤
every element of high. - len(low) == len(high)
or len(low) == len(high) + 1.
addNum: 1. Push into low
(max-heap → push -num). 2. Move the top of low
into high (rebalance). 3. If
len(high) > len(low) → move high’s top back
to low.
findMedian: -
len(low) > len(high) → low[0] (negated). -
Equal sizes → average of the two tops.
A cleaner formulation:
addNum(num):
if not low or num <= -low[0]:
heappush(low, -num)
else:
heappush(high, num)
# rebalance: |len(low) - len(high)| <= 1, low >= high in size
if len(low) > len(high) + 1:
heappush(high, -heappop(low))
elif len(high) > len(low):
heappush(low, -heappop(high))
import heapq
class MedianFinder:
def __init__(self):
self.low: list[int] = [] # max-heap (negated)
self.high: list[int] = [] # min-heap
def addNum(self, num: int) -> None:
if not self.low or num <= -self.low[0]:
heapq.heappush(self.low, -num)
else:
heapq.heappush(self.high, num)
# Rebalance.
if len(self.low) > len(self.high) + 1:
heapq.heappush(self.high, -heapq.heappop(self.low))
elif len(self.high) > len(self.low):
heapq.heappush(self.low, -heapq.heappop(self.high))
def findMedian(self) -> float:
if len(self.low) > len(self.high):
return float(-self.low[0])
return (-self.low[0] + self.high[0]) / 2addNum: O(log n).
findMedian: O(1).O(n).O(1) access to the “boundary”
between halves.Given an array of points (each [x, y]), return the
k points closest to the origin (Euclidean
distance).
Input: points = [[1,3], [-2,2]], k = 1
Output: [[-2, 2]]
Explanation: dist²(1,3) = 10, dist²(-2,2) = 8.
Size-k heap — O(n log k). A max-heap
holds the k closest points; when it exceeds k, pop the farthest.
Note: use
dist²instead ofsqrt(dist)— avoids floats, preserves the ordering.
import heapq
from typing import List
class Solution:
def kClosest(self, points: List[List[int]], k: int) -> List[List[int]]:
heap: list[tuple[int, list[int]]] = [] # (-dist², point) — max-heap
for p in points:
d2 = p[0] ** 2 + p[1] ** 2
heapq.heappush(heap, (-d2, p))
if len(heap) > k:
heapq.heappop(heap)
return [p for _, p in heap]O(n log k).
Space: O(k).O(n) average
(like problem 15.1) by partitioning on dist².sqrt(d²) wastes
computation and loses precision — always compare via
d².Input: lists: List[Optional[ListNode]] — an array of
k heads of k ascending-sorted singly linked
lists. Merge them all into one sorted linked list and return the new
head.
Input: lists = [1→4→5, 1→3→4, 2→6]
Output: 1→1→2→3→4→4→5→6
Approach 1 — Min-heap k-way merge,
O(N log k).
The heap holds (val, idx, node) for the head of each
list. Pop the smallest, push the next element from the same list. Repeat
until empty.
idxis the tie-breaker — Python can’t compareListNodes when values tie.
Approach 2 — Pairwise merge (Divide & Conquer),
O(N log k).
Tournament-style merge sort: pair lists and merge, repeat. Same complexity.
import heapq
from typing import List, Optional
class ListNode:
def __init__(self, val=0, next=None):
self.val, self.next = val, next
class Solution:
"""Approach 1 — min-heap k-way merge."""
def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
heap: list[tuple[int, int, ListNode]] = []
for i, head in enumerate(lists):
if head:
heapq.heappush(heap, (head.val, i, head))
dummy = ListNode()
tail = dummy
while heap:
val, i, node = heapq.heappop(heap)
tail.next = node
tail = node
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.nextO(N log k) where N
= total nodes.O(k) for the heap.k is
very small and you want to avoid heap dependencies.idx tie-breaker →
TypeError: '<' not supported between instances of 'ListNode' and 'ListNode'
on ties.Given tasks (uppercase letters A-Z) and integer
n. Each unit of time runs one task or stays idle. Two
identical tasks must be at least n units
apart. Return the minimum number of time units required to finish
everything.
Input: tasks = ["A","A","A","B","B","B"], n = 2
Output: 8
Schedule: A → B → idle → A → B → idle → A → B
Approach 1 — Greedy + max-heap,
O(N log 26).
Each tick: - Take the highest-frequency task (heap
top). Decrement and put into a “cooldown queue”
(end_time, freq). - When
cooldown_queue.front().end_time <= now → pop and push
back into the heap.
Approach 2 — Direct formula, O(N).
Let f_max = max frequency, count_max =
number of tasks with that frequency. Answer =
max(n_total, (f_max - 1) * (n + 1) + count_max).
Intuition: the most-frequent task creates
f_max - 1 slots of length n+1, plus
count_max more at the “last row”.
from collections import Counter
from typing import List
class Solution:
"""Approach 2 — O(N) formula."""
def leastInterval(self, tasks: List[str], n: int) -> int:
cnt = Counter(tasks)
f_max = max(cnt.values())
count_max = sum(1 for v in cnt.values() if v == f_max)
slots = (f_max - 1) * (n + 1) + count_max
return max(slots, len(tasks))
import heapq
from collections import deque
class SolutionHeap:
"""Approach 1 — heap + cooldown queue."""
def leastInterval(self, tasks: List[str], n: int) -> int:
heap = [-c for c in Counter(tasks).values()] # max-heap (negated)
heapq.heapify(heap)
cooldown: deque[tuple[int, int]] = deque() # (available_time, neg_count)
time = 0
while heap or cooldown:
time += 1
if heap:
neg = heapq.heappop(heap) + 1 # decrement freq
if neg < 0:
cooldown.append((time + n, neg))
if cooldown and cooldown[0][0] == time:
_, neg = cooldown.popleft()
heapq.heappush(heap, neg)
return timeO(N). Heap:
O(N log 26).| Requirement | Heap O(n log k) |
Sort O(n log n) |
Quickselect O(n) avg |
|---|---|---|---|
Top-k with n large, k small |
✅ Best | OK | ✅ When order doesn’t matter |
| Need k sorted elements | ✅ | ✅ | Needs an extra sort |
| Streaming (data arrives in pieces) | ✅ | ❌ | ❌ |
| Worst-case guarantee | ✅ | ✅ | ❌ (worst O(n²)) |
| Interview preference | Default, easy to explain | Small n |
“Linear time” demo |
max_heap (lo) min_heap (hi)
…,3,5,7,8 ← ← 9,10,12,…
top = 8 top = 9
|len(lo) - len(hi)| ≤ 1, every element
of lo ≤ every element of hi.n+1 pull at most n+1 distinct
tasks.max((max_count - 1) * (n + 1) + ties, total_tasks). Elegant
and faster but requires a proof.Sort by (-freq, word): descending frequency, ascending
lexicographic.
Greedy = at every step pick the locally best choice in the hope that the cumulative result is globally optimal. The trick: it is very hard to prove a greedy works. In an interview, you must both guess the right “greedy rule” and concisely justify why it is optimal. This chapter teaches 6 classic problems so you internalise the reasoning patterns.
After this chapter, you will be able to:
Greedy-proof toolbox: 1. Exchange argument: assume another optimal solution differs from greedy. Swap one of its choices into the greedy choice without making it worse. Repeat → it equals greedy. 2. Matroid structure: rare in interviews but elegant in theory.
When greedy fails → DP saves you: if exchange does not preserve optimality, you must consider the global picture → DP.
def greedy_template(items):
items.sort(key=...) # 90% of greedy problems sort first
result = 0
for x in items:
if local_condition(x):
result += x
# ... update state ...
return resultGiven nums, nums[i] = max steps you can
jump from position i. Start at i = 0. Return
True if you can reach i = n - 1.
Input: nums = [2, 3, 1, 1, 4] → True
Explanation: 0 → 1 → 4 or 0 → 2 → 3 → 4
Input: nums = [3, 2, 1, 0, 4] → False
Explanation: stuck at index 3 (nums[3]=0).
Brute force — DFS from 0, O(2^n).
TLE.
Optimal — Greedy one pass, O(n).
Maintain farthest = the farthest index reachable so far.
At each i: - If i > farthest → can’t reach
i → return False. - Update
farthest = max(farthest, i + nums[i]). - If
farthest >= n - 1 → return True.
Illustration for [2, 3, 1, 1, 4]:
index : 0 1 2 3 4
nums : 2 3 1 1 4
i=0: farthest = max(0, 0+2) = 2
i=1: 1 <= 2 OK; farthest = max(2, 1+3) = 4 → >= n-1=4 → True ✓
from typing import List
class Solution:
def canJump(self, nums: List[int]) -> bool:
farthest = 0
for i, x in enumerate(nums):
if i > farthest:
return False
farthest = max(farthest, i + x)
if farthest >= len(nums) - 1:
return True
return TrueO(n). Space:
O(1).farthest >= n - 1 → there’s some chain of jumps
to the end. If i > farthest → no position ≤ i can reach
i → impossible.>= instead of
> in i > farthest — exactly
farthest is reachable (inclusive).Same setup as 16.1, but assume you always reach
n - 1. Return the minimum number of
jumps.
Input: nums = [2, 3, 1, 1, 4]
Output: 2
Explanation: 0 → 1 → 4.
Input: nums = [2, 3, 0, 1, 4]
Output: 2
Level-by-level BFS. Each “level” of BFS = positions
reachable after k jumps.
One-pass Greedy implementation: -
current_end = boundary of the current level. -
farthest = farthest reachable from anywhere in the current
level. - When i == current_end (level ends), bump
jumps += 1 and set current_end = farthest.
Illustration for [2, 3, 1, 1, 4]:
i=0: farthest = max(0, 0+2) = 2
i == current_end (=0) → jumps=1, current_end=2
i=1: farthest = max(2, 1+3) = 4
i=2: farthest = max(4, 2+1) = 4
i == current_end (=2) → jumps=2, current_end=4
i=3: ... (we can stop once current_end >= n-1)
Answer: 2 ✓
from typing import List
class Solution:
def jump(self, nums: List[int]) -> int:
jumps = 0
current_end = 0
farthest = 0
for i in range(len(nums) - 1):
farthest = max(farthest, i + nums[i])
if i == current_end:
jumps += 1
current_end = farthest
if current_end >= len(nums) - 1:
break
return jumpsO(n). Space:
O(1).n - 1 (exclusive) — no
jump needed once at n-1.current_end and farthest.A circular route has n gas stations; station
i has gas[i] gas. Going from station
i to i+1 costs cost[i]. Find the
starting station so you can complete the loop, or -1 if
impossible.
Input: gas = [1,2,3,4,5], cost = [3,4,5,1,2]
Output: 3
Explanation: start at station 3.
Input: gas = [2,3,4], cost = [3,4,3]
Output: -1
1 <= n <= 10^5Brute force — try every start,
O(n²). Can TLE.
Optimal Greedy — O(n).
Necessary & sufficient condition:
sum(gas) >= sum(cost). Else -1.
When the condition holds, exactly one valid answer
exists (with distinct sums). Find it via: - Walk through,
maintaining tank = current balance. - If
tank < 0 at station i → every
start ∈ [last_start..i] fails. Set
start = i + 1, reset tank = 0.
Illustration for
gas = [1,2,3,4,5], cost = [3,4,5,1,2]:
i : 0 1 2 3 4
gas: 1 2 3 4 5
cost: 3 4 5 1 2
diff: -2 -2 -2 +3 +3
Cumulative tank:
i=0: tank = -2 → negative, reset start=1, tank=0
i=1: tank = -2 → reset start=2, tank=0
i=2: tank = -2 → reset start=3, tank=0
i=3: tank = +3 OK
i=4: tank = +6 OK
sum(diff) = -2-2-2+3+3 = 0 >= 0 → a solution exists.
Answer: start=3 ✓
from typing import List
class Solution:
def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
if sum(gas) < sum(cost):
return -1
start = 0
tank = 0
for i in range(len(gas)):
tank += gas[i] - cost[i]
if tank < 0:
start = i + 1
tank = 0
return startO(n). Space:
O(1).s
reaches i with tank < 0. Any s' ∈ [s, i]
also fails at i (the sum from s' to
i is ≤ sum from s to i). So skip
[s, i] and try i + 1.sum(gas) < sum(cost) check — otherwise the returned
start is invalid.Given g[] (children’s greed factors) and
s[] (cookie sizes). Child i is happy if
assigned cookie j with s[j] >= g[i]. Each
child gets at most one cookie; each cookie goes to at most one child.
Maximise the number of happy children.
Input: g = [1, 2, 3], s = [1, 1] → 1
Input: g = [1, 2], s = [1, 2, 3] → 2
Classic Greedy. Sort both arrays ascending. Two
pointers i (children), j (cookies). Walk
cookies from small to large: if s[j] >= g[i] → child
i gets cookie → i++; j++
always.
Justification: give the smallest cookie that satisfies the least-greedy child — no large cookies “wasted”. Exchange argument: any other optimal solution can be re-arranged into this greedy without losing satisfied children.
from typing import List
class Solution:
def findContentChildren(self, g: List[int], s: List[int]) -> int:
g.sort()
s.sort()
i = j = 0
while i < len(g) and j < len(s):
if s[j] >= g[i]:
i += 1
j += 1
return iO(n log n + m log m).
Space: O(1).Given a string s, partition s into
as many parts as possible so each character appears in
only one part. Return the lengths of the parts.
Input: s = "ababcbacadefegdehijhklij"
Output: [9, 7, 8]
Explanation:
"ababcbaca" - contains a, b, c.
"defegde" - contains d, e, f, g.
"hijhklij" - contains h, i, j, k, l.
Greedy with “last occurrence”:
last[ch] = last index of each character.s, keep end = max(end, last[s[i]]).
i == end → finalise one part.Illustration for
"ababcbacadefegdehijhklij":
last[a]=8, last[b]=5, last[c]=7, last[d]=14, last[e]=15, ...
i=0 (a): end = max(0, 8) = 8
i=1 (b): end = max(8, 5) = 8
...
i=8 (a): end = 8, i == end → part 1: length 9 (0..8)
i=9 (d): end = 14
...
i=15 (e): end = 15, i == end → part 2: length 7 (9..15)
...
from typing import List
class Solution:
def partitionLabels(self, s: str) -> List[int]:
last = {ch: i for i, ch in enumerate(s)}
result: list[int] = []
start = end = 0
for i, ch in enumerate(s):
end = max(end, last[ch])
if i == end:
result.append(i - start + 1)
start = i + 1
return resultO(n). Space:
O(1) (26-letter alphabet).end.Given an array ratings, distribute candies to
n children so that: 1. Each child gets at least 1 candy. 2.
A child with a higher rating than a neighbour gets more
candies.
Return the minimum number of candies.
Input: ratings = [1, 0, 2] → 5 (candies: [2, 1, 2])
Input: ratings = [1, 2, 2] → 4 (candies: [1, 2, 1])
Two passes through the array: - Pass 1 (left →
right): if ratings[i] > ratings[i-1] →
candies[i] = candies[i-1] + 1. - Pass 2 (right → left):
if ratings[i] > ratings[i+1] →
candies[i] = max(candies[i], candies[i+1] + 1).
Sum of candies = answer.
Illustration for
ratings = [1, 0, 2]:
ratings: 1 0 2
Pass 1 (L→R), init candies = [1, 1, 1]:
i=1: r[1]=0 <= r[0]=1 → keep 1
i=2: r[2]=2 > r[1]=0 → candies[2] = candies[1]+1 = 2
→ candies = [1, 1, 2]
Pass 2 (R→L):
i=1: r[1]=0 <= r[2]=2 → keep
i=0: r[0]=1 > r[1]=0 → candies[0] = max(1, candies[1]+1) = max(1, 2) = 2
→ candies = [2, 1, 2]
Sum: 2 + 1 + 2 = 5 ✓
from typing import List
class Solution:
def candy(self, ratings: List[int]) -> int:
n = len(ratings)
candies = [1] * n
# Pass 1.
for i in range(1, n):
if ratings[i] > ratings[i - 1]:
candies[i] = candies[i - 1] + 1
# Pass 2.
for i in range(n - 2, -1, -1):
if ratings[i] > ratings[i + 1]:
candies[i] = max(candies[i], candies[i + 1] + 1)
return sum(candies)O(n). Space:
O(n).max in pass 2 ensures both hold simultaneously.O(1) space is possible but more
complex (count “ascending” and “descending” runs — see follow-up).k, the
greedy solution is at least as “advanced” as every other solution. By
induction → globally optimal.i: 0 1 2 3 4
nums: [2, 3, 1, 1, 4]
reach: 2 4 4 4 ≥4 ✅
reach = the farthest index reachable so far.i > reach at any step →
stuck.If starting from s the tank goes negative at
i, then every k in
[s, i] also fails when starting from k
(because we had surplus from s to k, yet still
couldn’t cross from s to i+1). ⇒ Try
i+1 next.
i, if
rating[i] > rating[i-1] ⇒
candy[i] = candy[i-1] + 1.rating[i] > rating[i+1] ⇒
candy[i] = max(candy[i], candy[i+1] + 1).max is enough.Divide and Conquer (D&C): split a problem into independent subproblems, solve recursively, then combine. Unlike DP (where subproblems can overlap), D&C subproblems do not overlap. Famous algorithms (merge sort, quicksort, fast power, closest pair) are all D&C.
After this chapter, you will be able to:
2T(n/2)+O(n) = O(n log n).O(n²) → O(n log n) by
combining two halves in O(n).T(n) = aT(n/b) + f(n).Master-theorem cheat: - a = b,
f(n) = O(n) → T(n) = O(n log n) (merge sort).
- a = 1, b = 2, f(n) = O(1) →
T(n) = O(log n) (binary search). - a = 2,
b = 2, f(n) = O(n) →
T(n) = O(n log n).
def divide_conquer(arr, lo: int, hi: int):
if lo >= hi: # base case
return base_value(arr[lo])
mid = (lo + hi) // 2
left = divide_conquer(arr, lo, mid)
right = divide_conquer(arr, mid + 1, hi)
return combine(left, right) # "conquer" step — typically O(n)Given nums, find the contiguous
subarray with maximum sum. Return the sum.
Input: nums = [-2,1,-3,4,-1,2,1,-5,4]
Output: 6 (subarray [4,-1,2,1])
Three approaches: 1. Kadane’s (1D
DP, O(n)) — standard for LC 53. 2. Prefix sum +
min, O(n). 3. Divide &
Conquer, O(n log n) — the focus of this
chapter.
D&C insight: split [lo, hi] into
[lo, mid], [mid+1, hi]. The maximum subarray
is either: - Entirely in the left half (recursion). - Entirely in the
right half (recursion). - Crossing the middle —
starting in the left, ending in the right.
Crossing sum computed in O(n): -
left_max = max of
nums[mid] + nums[mid-1] + ... extending leftward. -
right_max = same extending rightward. -
crossing = left_max + right_max.
Take the max of the three.
from typing import List
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
def dc(lo: int, hi: int) -> int:
if lo == hi:
return nums[lo]
mid = (lo + hi) // 2
left = dc(lo, mid)
right = dc(mid + 1, hi)
# Crossing — must include both nums[mid] and nums[mid+1].
left_max = -float('inf')
cur = 0
for i in range(mid, lo - 1, -1):
cur += nums[i]
left_max = max(left_max, cur)
right_max = -float('inf')
cur = 0
for i in range(mid + 1, hi + 1):
cur += nums[i]
right_max = max(right_max, cur)
crossing = left_max + right_max
return max(left, right, crossing)
return dc(0, len(nums) - 1)T(n) = 2T(n/2) + O(n) = O(n log n).O(log n) stack.O(n) is easier and faster.
D&C is worth learning because:
(left_max, right_max, total, best), enough to merge its two
children.Count the number of inversions in nums:
pairs (i, j) with i < j and
nums[i] > nums[j].
Input: nums = [2, 4, 1, 3, 5]
Output: 3
(pairs i < j with nums[i] > nums[j]; namely (2,1), (4,1), (4,3))
Brute force — O(n²). Count every
pair.
D&C via Merge Sort —
O(n log n).
While merging two sorted halves: - When taking from the right half
(nums_right[j] < nums_left[i]), every remaining element
in the left half creates an inversion with nums_right[j] →
add (len_left - i) to the counter.
Illustration for [2, 4, 1, 3, 5]:
Split: [2, 4, 1] | [3, 5]
Recurse [2, 4] [1] [3] [5]
left: [2] [4]
merge [2,4]: 0 inversion
merge [2,4] with [1]:
take 1 from right → 2 elements [2,4] are larger → +2 inversion
result: [1, 2, 4]; total = 2
right: merge [3] [5]: 0 inversion → [3, 5]
merge [1, 2, 4] with [3, 5]:
1 (left), 2 (left), 4 (left), 3 (right) — when 3 is taken, 4 still in left → +1
4, 5 → 5 from right, nothing left → 0 inv
Total: 2 + 1 = 3 ✓
from typing import List
class Solution:
def countInversions(self, nums: List[int]) -> int:
def merge_sort(arr: List[int]) -> tuple[List[int], int]:
if len(arr) <= 1:
return arr, 0
mid = len(arr) // 2
left, inv_l = merge_sort(arr[:mid])
right, inv_r = merge_sort(arr[mid:])
merged, inv_split = merge(left, right)
return merged, inv_l + inv_r + inv_split
def merge(L: List[int], R: List[int]) -> tuple[List[int], int]:
i = j = inv = 0
out: List[int] = []
while i < len(L) and j < len(R):
if L[i] <= R[j]:
out.append(L[i]); i += 1
else:
out.append(R[j]); j += 1
inv += len(L) - i # every L[i..] > R[j]
out.extend(L[i:])
out.extend(R[j:])
return out, inv
_, total = merge_sort(nums)
return totalO(n log n).
Space: O(n) for the auxiliary array.Fully solved in Chapter 15.1. Here we summarise under the D&C lens.
Given nums, find the k-th largest
element (1-indexed). The D&C pattern solves it in O(n)
average.
Input: nums = [3, 2, 1, 5, 6, 4], k = 2
Output: 5
Input: nums = [3, 2, 3, 1, 2, 4, 5, 5, 6], k = 4
Output: 4
Quickselect is a special D&C — recursing only one
half (the one containing the Kth) instead of both. Therefore: -
T(n) = T(n/2) + O(n) = O(n) average (not
O(n log n) like Quicksort). - Worst-case O(n²)
with bad pivots — avoid via random pivot.
Index direction: the partition below sorts
ascending, so p is the p-th from
smallest (0-based). The Kth largest lives at
position len(nums) - k (0-based) after sorting. So the
LC-public function takes k 1-indexed and converts it to
target = len(nums) - k before calling quickselect.
import random
from typing import List
class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
# Kth largest 1-indexed ⇔ index (n - k) 0-based after ascending sort.
target = len(nums) - k
return self._quickselect(nums, 0, len(nums) - 1, target)
def _quickselect(self, arr: List[int], lo: int, hi: int, target: int) -> int:
while lo <= hi:
p = self._partition(arr, lo, hi)
if p == target:
return arr[p]
elif p < target:
lo = p + 1
else:
hi = p - 1
return -1 # unreachable per problem
def _partition(self, arr: List[int], lo: int, hi: int) -> int:
# Random pivot to avoid worst-case O(n^2) on already-sorted input.
rand = random.randint(lo, hi)
arr[rand], arr[hi] = arr[hi], arr[rand]
pivot = arr[hi]
store = lo
for i in range(lo, hi):
if arr[i] < pivot:
arr[store], arr[i] = arr[i], arr[store]
store += 1
arr[store], arr[hi] = arr[hi], arr[store]
return storeO(n) average, O(n²)
worst-case (rare with random pivot).O(1).p == k while the partition sorts ascending actually
returns the kth smallest (0-based). The wrapper
findKthLargest above does the conversion explicitly so this
bug can’t slip in.Fully solved in Chapter 3.2. Here we summarise under the D&C lens.
Compute x^n for real x and integer
n (possibly negative). D&C gives
O(log n).
Input: x = 2.00000, n = 10
Output: 1024.00000
Input: x = 2.10000, n = 3
Output: 9.26100
Input: x = 2.00000, n = -2
Output: 0.25000 (= 1 / 2^2)
x^n = (x^(n/2))^2 (even) or
x · (x^((n-1)/2))^2 (odd). Each step halves n
→ T(n) = T(n/2) + O(1) = O(log n).
class Solution:
def myPow(self, x: float, n: int) -> float:
if n == 0: return 1.0
if n < 0: return 1.0 / self.myPow(x, -n)
half = self.myPow(x, n // 2)
return half * half if n % 2 == 0 else half * half * xO(log n).O(log n) stack or
O(1) iterative.Given an expression s with numbers and operators
+, -, *, return
all possible values that can result from parenthesising
it differently.
Input: s = "2*3-4*5"
Output: [-34, -14, -10, -10, 10]
Explanation:
(2*(3-(4*5))) = -34
((2*3)-(4*5)) = -14
((2*(3-4))*5) = -10
(2*((3-4)*5)) = -10
(((2*3)-4)*5) = 10
At each operator op, split
s into left and right. Recursively compute all
values of each side. Combine: for every (l, r)
pair, append l op r to the result.
Memoise by substring to avoid recomputation.
Illustration for "2*3-4":
"2*3-4":
At '*' (pos 1):
Left = "2" → [2]
Right = "3-4" → recurse:
At '-' (pos 1):
Left = "3" → [3]
Right = "4" → [4]
→ [3 - 4] = [-1]
→ "3-4" yields [-1]
Combine: 2 * -1 = -2
At '-' (pos 3):
Left = "2*3" → recurse → [6]
Right = "4" → [4]
→ [6 - 4] = [2]
→ "2*3-4" yields [-2, 2]
from functools import cache
from typing import List
class Solution:
def diffWaysToCompute(self, s: str) -> List[int]:
@cache
def compute(expr: str) -> tuple[int, ...]:
if expr.isdigit():
return (int(expr),)
result: list[int] = []
for i, ch in enumerate(expr):
if ch in '+-*':
for l in compute(expr[:i]):
for r in compute(expr[i + 1:]):
if ch == '+': result.append(l + r)
elif ch == '-': result.append(l - r)
else: result.append(l * r)
return tuple(result)
return list(compute(s))O(C_n · n) where
C_n is the n-th Catalan number.O(C_n) cache.n+1
operands is C_n.tuple for
caching (list is not hashable).Given n points on the plane, find the pair with the
smallest Euclidean distance. Must run in O(n log n).
Input: points = [(0,0), (1,1), (4,5), (2,2), (8,8)]
Output: ((0,0), (1,1)) dist = sqrt(2)
Brute force — O(n²). Compare every
pair.
D&C — O(n log n).
x.x_mid.d_left, d_right.d = min(d_left, d_right).|x - x_mid| < d, sort by y, each point only
compares with ≤ 7 next points in the strip (geometric
proof).This is a CS-textbook classic — mostly absent from standard LeetCode, but asked at Big Tech interviews as a follow-up to brute
O(n²).
import math
from typing import List, Tuple
Point = Tuple[float, float]
class Solution:
def closestPair(self, points: List[Point]) -> float:
# Sort by x first.
pts_x = sorted(points)
def dist(p: Point, q: Point) -> float:
return math.hypot(p[0] - q[0], p[1] - q[1])
def dc(pts: List[Point]) -> float:
n = len(pts)
if n <= 3:
return min(dist(pts[i], pts[j])
for i in range(n) for j in range(i + 1, n))
mid = n // 2
mid_x = pts[mid][0]
d_left = dc(pts[:mid])
d_right = dc(pts[mid:])
d = min(d_left, d_right)
# Strip.
strip = sorted([p for p in pts if abs(p[0] - mid_x) < d],
key=lambda p: p[1])
for i in range(len(strip)):
# Proof: only need to check ≤ 7 neighbours along y.
for j in range(i + 1, min(i + 8, len(strip))):
d = min(d, dist(strip[i], strip[j]))
return d
return dc(pts_x)O(n log n).O(n).2d, no more than 6 points can fit in a
d × d square (density limit).O(n²)?”.solve(P):
if |P| ≤ threshold: brute()
split P → P1, P2 (roughly equal)
A1 = solve(P1)
A2 = solve(P2)
return combine(A1, A2, cross_information)
T(n) = aT(n/b) + f(n) ⇒ Master
theorem.combine
(cross structure).n ━━━ split ━━━ n/2, n/2 ━━━ split ━━━ n/4, n/4, n/4, n/4 ━━━ ...
depth = log n
combine cost = O(n) per layer × log n layers ⇒ O(n log n)
| Quickselect | Heap size k | Sort | |
|---|---|---|---|
| Average | O(n) |
O(n log k) |
O(n log n) |
| Worst | O(n²) (random pivot ↘) |
O(n log k) |
O(n log n) |
| In place | ✅ | ✗ extra heap | ✅ |
| Code | medium | short | shortest |
x. Split → solve two halves yielding
d1, d2. Set d = min(d1, d2).2d around the dividing line: only
pairs inside the strip can be smaller than
d. Sort the strip by y, each point checks only
6 next points.Monotonic stack/deque = a stack/deque whose elements maintain monotonicity (ascending or descending) as we go through. This is the strongest “weapon” for “next greater/smaller”, “range max/min in sliding window”, “largest rectangle”-style problems. Problem 8.5 (Daily Temperatures) teased it — this chapter goes deep.
After this chapter, you will be able to:
O(n).min/max queries in a sliding window.When ascending vs descending? - Ascending stack (top is the max in the stack): handy for “previous smaller”. - Descending stack (top is the min): for “previous greater” / “next greater”.
The difference between a “regular stack” and a “monotonic stack” is the invariant — a rule that always holds. Understanding the invariant = understanding the entire pattern.
Invariant at all times:
bottom → top
[v0 , v1 , v2 , ... , vk]
v0 ≥ v1 ≥ v2 ≥ ... ≥ vk (decreasing)
When pushing a new value x:
- While stack[-1] < x: pop (stack[-1] no longer a candidate for "next greater")
- Push x
Interpretation:
• The stack holds candidates "waiting for their next greater".
• When we see x larger than the top → the top has found its answer (= x) → pop.
• Each index is pushed once, popped at most once → O(n) total.
Trace example for
arr = [73, 74, 75, 71, 69, 72, 76]:
Step i v Action Stack (index, value)
─────────────────────────────────────────────────────────────────────────
init []
1 0 73 push [(0, 73)]
2 1 74 74 > 73 → pop (0,73), result[0]=74 [(1, 74)]
3 2 75 75 > 74 → pop (1,74), result[1]=75 [(2, 75)]
4 3 71 push [(2, 75), (3, 71)]
5 4 69 push [(2, 75), (3, 71), (4, 69)]
6 5 72 72 > 69 → pop (4,69), result[4]=72 [(2, 75), (3, 71), (5, 72)]
72 > 71 → pop (3,71), result[3]=72
7 6 76 76 > 72 → pop (5,72), result[5]=76 [(6, 76)]
76 > 75 → pop (2,75), result[2]=76
Result: [74, 75, 76, 72, 72, 76, -1]
Each index pushed once and popped ≤ once → O(n).
Invariant at all times (deque decreasing from head → tail):
head tail
[i0 , i1 , i2 , ... , ik]
arr[i0] ≥ arr[i1] ≥ ... ≥ arr[ik]
• Head is always the MAX of the current window.
• Tail is the most recent element.
When the window slides (add index r, possibly drop index l):
1. Pop from tail while arr[tail] ≤ arr[r] (useless — r is newer and larger)
2. Push r at the tail
3. Pop head while head ≤ r - k (outside the window)
4. Max = arr[head]
Trace example for
arr = [1, 3, -1, -3, 5, 3, 6, 7], k = 3:
i v deque (index) Pop tail/head? Max when i≥k-1=2
──────────────────────────────────────────────────────────────────
0 1 [0] push 0
1 3 [1] pop 0 (arr[0]=1 ≤ 3); push 1
2 -1 [1, 2] push 2 arr[1]=3
3 -3 [1, 2, 3] push 3 (head 1 still > 3-k=0,
so within window) arr[1]=3
4 5 [4] pop 3,2,1 (all ≤ 5) arr[4]=5
5 3 [4, 5] push 5 arr[4]=5
6 6 [6] pop 5 (3 ≤ 6); pop 4 (5 ≤ 6); push 6 arr[6]=6
7 7 [7] pop 6 arr[7]=7
Output: [3, 3, 5, 5, 6, 7] ✓
This invariant makes debugging fast: if the deque is no longer monotonic, or the head is outside the window, you have a bug for sure.
# 1) Monotonic decreasing stack — Next Greater Element
def next_greater(arr: list[int]) -> list[int]:
n = len(arr)
result = [-1] * n
stack: list[int] = [] # index, values decreasing
for i, v in enumerate(arr):
while stack and arr[stack[-1]] < v:
result[stack.pop()] = v
stack.append(i)
return result
# 2) Monotonic deque — Sliding Window Max
from collections import deque
def sliding_max(arr: list[int], k: int) -> list[int]:
dq: deque[int] = deque() # index, values decreasing head→tail
out = []
for i, v in enumerate(arr):
while dq and arr[dq[-1]] <= v:
dq.pop()
dq.append(i)
if dq[0] <= i - k:
dq.popleft()
if i >= k - 1:
out.append(arr[dq[0]])
return outFully solved in Chapter 8.5. Pattern: monotonic decreasing stack, each element pushed/popped exactly once →
O(n).
class Solution:
def dailyTemperatures(self, t):
result = [0] * len(t)
stack = []
for i, v in enumerate(t):
while stack and t[stack[-1]] < v:
j = stack.pop()
result[j] = i - j
stack.append(i)
return resultO(n).O(n) stack.Given a circular array nums, for each
element find the first greater number going clockwise
(the array wraps around). If none → -1.
Input: nums = [1, 2, 1] → [2, -1, 2]
Explanation: 1 (idx 0) → 2; 2 → none; 1 (idx 2) → 2 (wraps).
Circular trick: iterate 2n times, use
i % n to access values. The monotonic stack stores real
indices (0..n-1) and only pushes in the first round (the second round
only propagates).
from typing import List
class Solution:
def nextGreaterElements(self, nums: List[int]) -> List[int]:
n = len(nums)
result = [-1] * n
stack: list[int] = []
for i in range(2 * n):
cur = nums[i % n]
while stack and nums[stack[-1]] < cur:
result[stack.pop()] = cur
if i < n:
stack.append(i)
return resultO(n).O(n).i < n); pushing in both rounds → wrong output.Given heights[] (one bar of width 1 per index), find the
largest rectangular area in the histogram.
Input: heights = [2, 1, 5, 6, 2, 3]
Output: 10
Explanation: choose bars [5, 6] → 2 * 5 = 10.
Brute force — O(n²). For each bar
i, expand sides until lower bars.
Monotonic increasing stack — O(n).
The stack holds indices, heights ascending from
bottom to top. When we see h[i] smaller than
h[stack top], the top bar ends on the
right at i. Pop the top, compute the area: -
height = h[top]. - width = i - stack[-1] - 1
(if the stack is empty after pop, width = i).
Illustration for
heights = [2, 1, 5, 6, 2, 3]:
heights: 2 1 5 6 2 3
█
█ █
█ █
█ █ █
█ █ █ █
█ █ █ █ █ █
_________________________________
0 1 2 3 4 5
Stack tracing (sentinel -1):
i=0 h=2: push 0 stack=[0]
i=1 h=1: h[0]=2 > 1 → pop 0. height=2, width=1-(-1)-1=1, area=2
push 1 stack=[1]
i=2 h=5: push 2 stack=[1, 2]
i=3 h=6: push 3 stack=[1, 2, 3]
i=4 h=2: h[3]=6 > 2 → pop 3. height=6, width=4-2-1=1, area=6
h[2]=5 > 2 → pop 2. height=5, width=4-1-1=2, area=10 ★
push 4 stack=[1, 4]
i=5 h=3: push 5 stack=[1, 4, 5]
End of array. Right sentinel (i=6, h=0):
pop 5. height=3, width=6-4-1=1, area=3
pop 4. height=2, width=6-1-1=4, area=8
pop 1. height=1, width=6-(-1)-1=6, area=6
Max = 10 ✓
from typing import List
class Solution:
def largestRectangleArea(self, heights: List[int]) -> int:
stack: list[int] = [-1] # sentinel
max_area = 0
for i, h in enumerate(heights):
while stack[-1] != -1 and heights[stack[-1]] >= h:
height = heights[stack.pop()]
width = i - stack[-1] - 1
max_area = max(max_area, height * width)
stack.append(i)
# Drain the stack with right sentinel = n.
n = len(heights)
while stack[-1] != -1:
height = heights[stack.pop()]
width = n - stack[-1] - 1
max_area = max(max_area, height * width)
return max_areaO(n). Space:
O(n).-1 at the stack bottom keeps
the formula width = i - stack[-1] - 1 uniform after all
pops.Given nums and k, a window of
k slides from left to right. Return the maximum at
every window position.
Input: nums = [1,3,-1,-3,5,3,6,7], k = 3
Output: [3, 3, 5, 5, 6, 7]
Brute force — O(n · k). TLE.
Monotonic Deque — O(n).
Deque holds indices, values
decreasing from head → tail. When new i
arrives: 1. Pop from tail if value <= nums[i] (they’re
useless — i is newer and larger). 2. Push i to
the tail. 3. Pop from the head if the index is outside the window
(dq[0] <= i - k). 4. When i >= k - 1,
nums[dq[0]] is the max.
Illustration for
nums = [1,3,-1,-3,5,3,6,7], k = 3:
i v deque (index) state max when i>=k-1=2
─────────────────────────────────────────────────────────────────
0 1 [0] v=1
1 3 [1] v=3 > 1, pop 0
2 -1 [1, 2] nums[1]=3
3 -3 [1, 2, 3] check head: 1 > 3-k=0 OK nums[1]=3
4 5 [4] 5 > -3,-1,3 → pop all nums[4]=5
5 3 [4, 5] nums[4]=5
6 6 [6] 6 > 3,5 → pop nums[6]=6
7 7 [7] 7 > 6 → pop nums[7]=7
Output: [3, 3, 5, 5, 6, 7] ✓
from collections import deque
from typing import List
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
dq: deque[int] = deque()
result: list[int] = []
for i, v in enumerate(nums):
while dq and nums[dq[-1]] <= v:
dq.pop()
dq.append(i)
if dq[0] <= i - k:
dq.popleft()
if i >= k - 1:
result.append(nums[dq[0]])
return resultO(n) — each index pushed/popped
at most once.O(k).dq[0] <= i - k (head may have aged out).Given arr, return the sum of min(subarray)
over every contiguous subarray. Modulo 10^9 + 7.
Input: arr = [3, 1, 2, 4]
Output: 17
Subarrays: [3],[1],[2],[4],[3,1],[1,2],[2,4],[3,1,2],[1,2,4],[3,1,2,4]
Mins: 3 + 1 + 2 + 4 + 1 + 1 + 2 + 1 + 1 + 1 = 17
>= vs
> choice to avoid double-counting.Reframe insight:
Instead of iterating over subarrays, ask: of how many subarrays
is arr[i] the min?
arr[i] is the min of [l, r] ↔︎
l ∈ (prev_less[i], i] and
r ∈ [i, next_less[i]).
So the number of subarrays with min = arr[i] =
(i - prev_less[i]) * (next_less[i] - i).
Answer =
Σ arr[i] * (i - prev_less[i]) * (next_less[i] - i).
Compute prev_less,
next_less via monotonic stack in
O(n).
Careful with duplicates: use strict
<on one side,<=on the other so each subarray min is counted exactly once.
from typing import List
class Solution:
MOD = 10**9 + 7
def sumSubarrayMins(self, arr: List[int]) -> int:
n = len(arr)
# prev_less[i]: nearest j < i with arr[j] < arr[i]; -1 if none.
prev_less = [-1] * n
stack: list[int] = []
for i in range(n):
while stack and arr[stack[-1]] >= arr[i]:
stack.pop()
prev_less[i] = stack[-1] if stack else -1
stack.append(i)
# next_less[i]: nearest j > i with arr[j] <= arr[i]; n if none.
next_less = [n] * n
stack.clear()
for i in range(n - 1, -1, -1):
while stack and arr[stack[-1]] > arr[i]:
stack.pop()
next_less[i] = stack[-1] if stack else n
stack.append(i)
result = 0
for i in range(n):
left = i - prev_less[i]
right = next_less[i] - i
result = (result + arr[i] * left * right) % self.MOD
return resultO(n). Space:
O(n).>= for prev
(strict less in the past) and > for next (allow equal in
the future) — ensures each subarray has exactly one “min owner”.Given a digit string num and integer k,
remove exactly k digits so that the resulting
string is the smallest possible (preserving the order of
remaining digits). Strip leading zeros; if empty return
"0".
Input: num = "1432219", k = 3 → "1219"
Input: num = "10200", k = 1 → "200"
Input: num = "10", k = 2 → "0"
Greedy + Monotonic increasing stack.
Walk num. For each digit d: - While the stack is
non-empty, its top > d, and k > 0: pop
the top (removing it makes the number smaller). - Push
d.
At the end: if k > 0 remains, pop from the back of
the stack (the last digit is the largest among those left, because the
stack is ascending).
Finally: strip leading zeros and return.
Illustration for
num = "1432219", k = 3:
i=0 '1': stack=[1]
i=1 '4': 4 > 1, push. stack=[1,4]
i=2 '3': 4 > 3, k=3 → pop. stack=[1]. now stack[-1]=1 < 3, push. stack=[1,3] k=2
i=3 '2': 3 > 2, k=2 → pop. stack=[1]. 1 < 2, push. stack=[1,2] k=1
i=4 '2': 2 not > 2, push. stack=[1,2,2]
i=5 '1': 2 > 1, k=1 → pop. stack=[1,2]. 2 > 1, k=0 → stop. push. stack=[1,2,1]
i=6 '9': k=0, no pop. push. stack=[1,2,1,9]
len=4 OK. Result "1219". ✓
class Solution:
def removeKdigits(self, num: str, k: int) -> str:
stack: list[str] = []
for d in num:
while k > 0 and stack and stack[-1] > d:
stack.pop()
k -= 1
stack.append(d)
# Still k? Pop from the back (largest digit if stack is ascending).
while k > 0:
stack.pop()
k -= 1
# Strip leading zeros.
result = ''.join(stack).lstrip('0')
return result if result else '0'O(n). Space:
O(n)."10200" k=1 → stack [1, 0, 2, 0, 0], pop 1 →
"0200", strip leading → "200".Input: heights = [2, 1, 5, 6, 2, 3], append sentinel
0 at the end.
i h[i] stack (idx) pop & compute maxA
0 2 [0] 0
1 1 pop 0: h=2, w=1-0 ⇒ 2 2
[1]
2 5 [1,2] 2
3 6 [1,2,3] 2
4 2 pop 3: h=6, w=4-2-1=1 ⇒ 6 6
pop 2: h=5, w=4-1-1=2 ⇒ 10 10
[1,4]
5 3 [1,4,5] 10
6 0 pop 5: h=3, w=6-4-1=1 ⇒ 3
pop 4: h=2, w=6-1-1=4 ⇒ 8
pop 1: h=1, w=6 ⇒ 6 10
Invariant: the stack stores indices whose heights are strictly ascending; a smaller height “closes the rectangle” of every taller bar to its left.
Each a[i] contributes a[i] × left × right
times as a minimum, with: - left[i] = number of elements to
the left where a[i] is still the min (including
i itself). - right[i] = the same to the right.
- Tie-break: use < on the left,
≤ on the right (or vice versa) so each subarray min is
counted exactly once.
s[-1] > d_in: erasing s[-1]
is always better because its position on the left has a
higher place value; removing a large digit on the left shrinks the
number more than removing one on the right.Prefix Sum =
P[i] = arr[0] + arr[1] + ... + arr[i-1]. Lets us compute the sum of[l, r]inO(1):P[r+1] - P[l]. This is the gateway to Chapter 6.4 (Subarray Sum K) and many problems involving sums / remainders / counting subarrays by condition. It is also the foundation for Fenwick Tree (Chapter 22).
After this chapter, you will be able to:
P[0] = 0 convention so the sum of
[l, r] = P[r+1] - P[l].# 1) 1D prefix sum
P = [0] * (n + 1)
for i in range(n):
P[i + 1] = P[i] + arr[i]
# Sum of arr[l..r] (inclusive):
sum_lr = P[r + 1] - P[l]
# 2) Prefix sum + hash counting subarray = K (Subarray Sum K)
from collections import defaultdict
def count_subarray_sum_k(arr, k):
counts = defaultdict(int)
counts[0] = 1
cur = result = 0
for x in arr:
cur += x
result += counts[cur - k]
counts[cur] += 1
return result
# 3) 2D prefix sum
P = [[0] * (cols + 1) for _ in range(rows + 1)]
for r in range(rows):
for c in range(cols):
P[r+1][c+1] = mat[r][c] + P[r][c+1] + P[r+1][c] - P[r][c]
# Sum of rect (r1, c1) → (r2, c2):
# P[r2+1][c2+1] - P[r1][c2+1] - P[r2+1][c1] + P[r1][c1]Design a class: - NumArray(nums): initialise with an
integer array. - sumRange(left, right): return the sum of
nums[left..right] inclusive.
The array is immutable after construction.
sumRange must be O(1).
Input (LC-style operation arrays):
ops = ["NumArray", "sumRange", "sumRange", "sumRange"]
args = [[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]
Output: [null, 1, -1, -3]
Explanation:
NumArray([-2, 0, 3, -5, 2, -1]) → null (internal prefix sum precomputed)
sumRange(0, 2) → 1 (-2 + 0 + 3)
sumRange(2, 5) → -1 (3 + (-5) + 2 + (-1))
sumRange(0, 5) → -3 (sum of the whole array)
Precompute the prefix sum in __init__.
Each query is O(1).
from typing import List
class NumArray:
def __init__(self, nums: List[int]):
n = len(nums)
self.P = [0] * (n + 1)
for i in range(n):
self.P[i + 1] = self.P[i] + nums[i]
def sumRange(self, left: int, right: int) -> int:
return self.P[right + 1] - self.P[left]O(n). Query:
O(1). Space: O(n).P[0] = 0 convention is idiomatic —
the formula P[r+1] - P[l] needs no special case for
l == 0.Fully solved in Chapter 6.4. Pattern: prefix sum + hash counting
cur - k.
from collections import defaultdict
from typing import List
class Solution:
def subarraySum(self, nums: List[int], k: int) -> int:
counts = defaultdict(int)
counts[0] = 1
cur = result = 0
for x in nums:
cur += x
result += counts[cur - k]
counts[cur] += 1
return resultcur % k.0 → -1, key = cur.O(n).O(n) for the hash map.Given nums and k, return True
if there is a subarray of length ≥ 2 with sum that is a
multiple of k (including 0×k = 0).
Input: nums = [23, 2, 4, 6, 7], k = 6 → True
Explanation: [2, 4] sums to 6 = 1*6.
Input: nums = [23, 2, 6, 4, 7], k = 6 → True
Explanation: [23, 2, 6, 4, 7] sums to 42 = 7*6.
Input: nums = [1, 2, 3], k = 5 → False
Prefix sum mod K + hash.
A subarray nums[l..r] is divisible by k ↔︎
P[r+1] % k == P[l] % k. Find two positions with the same
modulo and distance ≥ 2.
Dict {remainder: earliest_index}. For each
i, compute cur % k: - If we’ve seen this
remainder before at index j and i - j >= 2
→ True. - Otherwise → record i.
from typing import List
class Solution:
def checkSubarraySum(self, nums: List[int], k: int) -> bool:
# remainder -> earliest index at which this prefix-sum remainder appeared.
# Initialise {0: -1} so a subarray starting at index 0 counts.
first_idx: dict[int, int] = {0: -1}
cur = 0
for i, x in enumerate(nums):
cur += x
rem = cur % k
if rem in first_idx:
if i - first_idx[rem] >= 2:
return True
else:
first_idx[rem] = i
return FalseO(n). Space:
O(min(n, k)).earliest? We want
i - j to be large (≥ 2); keeping the smallest
j is safe.{0: -1} trick: if the prefix sum
itself is divisible by k and long enough (i ≥ 1) → match.Design a class NumMatrix(matrix) with a method
sumRegion(row1, col1, row2, col2) returning the sum of the
rectangle with corners (row1, col1) and
(row2, col2) (both inclusive) in O(1).
Input (LC-style operation arrays):
ops = ["NumMatrix", "sumRegion", "sumRegion", "sumRegion"]
args = [[[[3,0,1,4,2],
[5,6,3,2,1],
[1,2,0,1,5],
[4,1,0,1,7],
[1,0,3,0,5]]],
[2, 1, 4, 3],
[1, 1, 2, 2],
[1, 2, 2, 4]]
Output: [null, 8, 11, 12]
Explanation:
NumMatrix(matrix) → init, returns null
sumRegion(2, 1, 4, 3) → sum of [2..4] × [1..3] = 8
sumRegion(1, 1, 2, 2) → sum of [1..2] × [1..2] = 11
sumRegion(1, 2, 2, 4) → sum of [1..2] × [2..4] = 12
2D prefix sum: P[r+1][c+1] = the sum of
the rectangle [0,0] → [r,c].
Inclusion-exclusion for the sum of
[r1,c1] → [r2,c2]:
P[r2+1][c2+1] − P[r1][c2+1] − P[r2+1][c1] + P[r1][c1]
Illustration:
P[r2+1][c2+1] = sum of the whole rectangle (0,0)..(r2,c2)
− P[r1][c2+1] = subtract the top strip (0,0)..(r1-1, c2)
− P[r2+1][c1] = subtract the left strip (0,0)..(r2, c1-1)
+ P[r1][c1] = add back the overlap subtracted twice
from typing import List
class NumMatrix:
def __init__(self, matrix: List[List[int]]):
if not matrix or not matrix[0]:
return
rows, cols = len(matrix), len(matrix[0])
self.P = [[0] * (cols + 1) for _ in range(rows + 1)]
for r in range(rows):
for c in range(cols):
self.P[r + 1][c + 1] = (
matrix[r][c]
+ self.P[r][c + 1]
+ self.P[r + 1][c]
- self.P[r][c]
)
def sumRegion(self, row1: int, col1: int, row2: int, col2: int) -> int:
return (
self.P[row2 + 1][col2 + 1]
- self.P[row1][col2 + 1]
- self.P[row2 + 1][col1]
+ self.P[row1][col1]
)O(rows · cols).
Query: O(1). Space:
O(rows · cols).Fully solved in Chapter 1.3. Linking to prefix sum.
from typing import List
class Solution:
def productExceptSelf(self, nums: List[int]) -> List[int]:
n = len(nums)
result = [1] * n
left = 1
for i in range(n):
result[i] = left
left *= nums[i]
right = 1
for i in range(n - 1, -1, -1):
result[i] *= right
right *= nums[i]
return resultleft) and a suffix product
(right).P[0] = 1
because left = 1 plays the “neutral element” role from the
start.O(n).O(1) extra (output
excluded).Given nums, find the pivot index — the
index i such that the sum of nums[0..i-1]
equals the sum of nums[i+1..n-1]. Return the leftmost
index, or -1.
Input: nums = [1, 7, 3, 6, 5, 6]
Output: 3
Explanation: left sum 1+7+3=11; right sum 5+6=11.
Input: nums = [1, 2, 3] → -1
Input: nums = [2, 1, -1] → 0 (left = empty = 0; right = 1 + -1 = 0)
left[i] + nums[i] + right[i] = total. Pivot ↔︎
left[i] == right[i] ↔︎
left[i] = (total - nums[i]) / 2.
One pass: keep left_sum. At
i: pivot ↔︎
left_sum == total - left_sum - nums[i].
from typing import List
class Solution:
def pivotIndex(self, nums: List[int]) -> int:
total = sum(nums)
left_sum = 0
for i, x in enumerate(nums):
if left_sum == total - left_sum - x:
return i
left_sum += x
return -1O(n). Space:
O(1).i = 0 or i = n - 1:
the left/right side may be empty → sum 0. The code handles this
naturally.P[0] = 0 — whyarr: [ a0, a1, a2, a3 ]
P: [ 0, a0, a0+a1, a0+a1+a2, a0+a1+a2+a3 ]
P[0] P[1] P[2] P[3] P[4]
Sum of arr[l..r] (inclusive, 0-indexed) =
P[r+1] - P[l]. Without P[0] = 0, the formula
needs a special case for l == 0.
% k == 0.prefix % k, the
subarray length is j - i. Require
j - i ≥ 2.Python’s % always returns [0, k):
(-3) % 5 == 2. Safe for prefix modulo. Java/C++:
(-3) % 5 == -3 → need ((x % k) + k) % k.
Full code lives in Chapter 1.3. This recap
emphasises: it’s prefix product + suffix
product, not P[r+1] - P[l]. No separate
P[0] = 1 is needed — the code uses
left = 1, right = 1 initially.
The last chapter of Level 1, with a “basic number-theory” flavour. Prime numbers are not a huge interview pattern at Big Tech (Google asks occasionally), but it’s worth learning so the Sieve of Eratosthenes sits in your toolbox — a classical algorithm that remains extremely useful.
After this chapter, you will be able to:
O(n log log n).O(sqrt(n)) for factoring a single
number.MOD = 10^9 + 7
(prime).10^9 + 7 (prime for modulo),
998244353 (FFT-friendly).def is_prime(n: int) -> bool:
"""Trial division O(sqrt(n))."""
if n < 2:
return False
if n < 4:
return True
if n % 2 == 0:
return False
i = 3
while i * i <= n:
if n % i == 0:
return False
i += 2
return True
def sieve(n: int) -> list[bool]:
"""Sieve of Eratosthenes — O(n log log n)."""
is_p = [True] * (n + 1)
is_p[0] = is_p[1] = False
for i in range(2, int(n ** 0.5) + 1):
if is_p[i]:
for j in range(i * i, n + 1, i):
is_p[j] = False
return is_p
def prime_factors(n: int) -> list[int]:
"""Factor — O(sqrt(n))."""
factors: list[int] = []
d = 2
while d * d <= n:
while n % d == 0:
factors.append(d)
n //= d
d += 1
if n > 1:
factors.append(n)
return factorsGiven n, return the count of primes strictly less than
n.
Input: n = 10 → 4
Explanation: primes < 10 = {2, 3, 5, 7}.
Input: n = 0 → 0
Input: n = 1 → 0
Brute force — trial division per number,
O(n sqrt n). TLE at n = 5·10^6.
Sieve of Eratosthenes —
O(n log log n).
A boolean is_prime[] array. For each i from
2, if is_prime[i] holds → mark every multiple
(i*i, i*i+i, i*i+2i, ...) as composite.
Illustration for n = 30:
i=2: mark 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28
i=3: mark 9, 12, 15, 18, 21, 24, 27
i=4: composite → skip
i=5: mark 25
i=6..29: either composite or i² > 30 → skip
Remaining: 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 → 10 primes.
class Solution:
def countPrimes(self, n: int) -> int:
if n < 2:
return 0
is_p = [True] * n
is_p[0] = is_p[1] = False
for i in range(2, int(n ** 0.5) + 1):
if is_p[i]:
# Start at i*i — smaller multiples were already marked by smaller primes.
for j in range(i * i, n, i):
is_p[j] = False
return sum(is_p)O(n log log n).O(n).bytearray instead of list[bool] to cut memory
by 8x.sqrt(n) (every composite < n has a factor ≤
sqrt(n)).i*i? Smaller multiples
(2i, 3i, ..., (i-1)i) were already marked by primes smaller
than i.An ugly number is a positive integer of the form
2^a · 3^b · 5^c. Return the n-th ugly
number. (1 is also ugly.)
Input: n = 10
Output: 12
Explanation: the first 10 ugly numbers = 1, 2, 3, 4, 5, 6, 8, 9, 10, 12.
Brute force counting and checking is too slow for large n.
Three-pointer DP — O(n).
ugly[k] = the k-th ugly. Each new ugly =
min(ugly[i2]*2, ugly[i3]*3, ugly[i5]*5). After selecting,
advance the corresponding pointer.
Illustration:
ugly = [1] i2=i3=i5=0
n2=1*2=2, n3=1*3=3, n5=1*5=5 min=2 → ugly=[1,2], i2=1
n2=2*2=4, n3=3, n5=5 min=3 → ugly=[1,2,3], i3=1
n2=4, n3=2*3=6, n5=5 min=4 → ugly=[1,2,3,4], i2=2
n2=3*2=6, n3=6, n5=5 min=5 → i5=1
n2=6, n3=6, n5=2*5=10 min=6 → i2=3, i3=2
...
class Solution:
def nthUglyNumber(self, n: int) -> int:
ugly = [1]
i2 = i3 = i5 = 0
while len(ugly) < n:
next_ugly = min(ugly[i2] * 2, ugly[i3] * 3, ugly[i5] * 5)
ugly.append(next_ugly)
if next_ugly == ugly[i2] * 2:
i2 += 1
if next_ugly == ugly[i3] * 3:
i3 += 1
if next_ugly == ugly[i5] * 5:
i5 += 1
return ugly[-1]O(n). Space:
O(n).if elif trap: use plain
if (not elif) — an ugly may equal two products
(e.g. 6 = 2*3 = 3*2); with elif one pointer
wouldn’t advance → duplicates.O(n log n)
— slower than DP.Given n, count the permutations of 1..n
such that every prime sits at a prime
position (1-indexed). Modulo 10^9 + 7.
Input: n = 5
Output: 12
Explanation: 5 positions, 3 are prime (2, 3, 5). Primes in 1..5 are 3.
3! * 2! = 6 * 2 = 12.
Counting: let p = number of primes ≤
n. There are p! ways to place primes in the p
prime positions, and (n - p)! ways for non-primes.
Answer = p! * (n - p)! mod (10^9 + 7).
from math import factorial
class Solution:
MOD = 10**9 + 7
def numPrimeArrangements(self, n: int) -> int:
# Count primes ≤ n.
if n < 2:
return 1
is_p = [True] * (n + 1)
is_p[0] = is_p[1] = False
for i in range(2, int(n ** 0.5) + 1):
if is_p[i]:
for j in range(i * i, n + 1, i):
is_p[j] = False
p = sum(is_p)
return (factorial(p) * factorial(n - p)) % self.MODO(n log log n) for the
sieve.O(n).Given left, right, return the pair of primes
(p, q) with left <= p < q <= right
whose q - p is minimum. Ties: pick the
pair with smaller p. None → [-1, -1].
Input: left=10, right=19
Output: [11, 13]
right, collect primes within
[left, right].from typing import List
class Solution:
def closestPrimes(self, left: int, right: int) -> List[int]:
is_p = [True] * (right + 1)
is_p[0] = is_p[1] = False
for i in range(2, int(right ** 0.5) + 1):
if is_p[i]:
for j in range(i * i, right + 1, i):
is_p[j] = False
primes = [i for i in range(left, right + 1) if is_p[i]]
if len(primes) < 2:
return [-1, -1]
best_gap = float('inf')
result = [-1, -1]
for i in range(len(primes) - 1):
gap = primes[i + 1] - primes[i]
if gap < best_gap:
best_gap = gap
result = [primes[i], primes[i + 1]]
return resultO(right log log right).O(right).Given nums, connect two elements in the same component
if they share at least one prime factor > 1. Return the largest
component size.
Input: nums = [4, 6, 15, 35] → 4
Explanation: 4 and 6 share 2; 6 and 15 share 3; 15 and 35 share 5 → one component.
Insight: instead of unioning elements directly
(which would need O(n²)), we union each element
with its own prime factors. Two elements sharing any prime →
same component.
Pseudocode: - For each x in
nums, factor x. Union x with
every prime factor. - Count component sizes.
from collections import Counter
from typing import List
class DSU:
def __init__(self): self.par = {}
def find(self, x):
if x not in self.par: self.par[x] = x
while self.par[x] != x:
self.par[x] = self.par[self.par[x]]
x = self.par[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra != rb: self.par[ra] = rb
class Solution:
def largestComponentSize(self, nums: List[int]) -> int:
dsu = DSU()
for x in nums:
d = 2
v = x
while d * d <= v:
if v % d == 0:
dsu.union(x, d)
while v % d == 0:
v //= d
d += 1
if v > 1:
dsu.union(x, v)
# Count: only over elements of nums (not prime nodes).
counter = Counter(dsu.find(x) for x in nums)
return max(counter.values())O(n · sqrt(max(nums)) · α).O(n + max(nums)).O(n²) to O(n · sqrt(max)).Given nums, return the number of distinct prime
factors of the product of the elements.
Input: nums = [2, 4, 3, 7, 10, 6]
Output: 4
Explanation: product = 2·4·3·7·10·6 = 10080 = 2^5 · 3^2 · 5 · 7. 4 distinct primes.
There’s no need to compute the actual product (it may overflow). For each number, gather its prime factors into a set.
from typing import List
class Solution:
def distinctPrimeFactors(self, nums: List[int]) -> int:
primes: set[int] = set()
for x in nums:
d = 2
while d * d <= x:
while x % d == 0:
primes.add(d)
x //= d
d += 1
if x > 1:
primes.add(x)
return len(primes)O(n · sqrt(max(nums))).O(k) where k =
number of distinct primes.n. Factor per number is the safe approach.n (bound) |
Method | Time |
|---|---|---|
Test a single n ≤ 10¹² |
Trial division up to √n |
O(√n) |
Count primes ≤ n, n ≤ 10⁷ |
Sieve of Eratosthenes | O(n log log n) |
Factor many numbers ≤ n, n ≤ 10⁶ |
SPF (Smallest Prime Factor) sieve | precompute O(n log log n), each factor
O(log n) |
Factor a single n ≤ 10¹⁸ |
Pollard ρ + Miller-Rabin | sub-exponential |
a, union
a with each of its prime factors.n: p. Non-primes:
q = n - p.= p! × q! (mod 10⁹+7).fact[0..n] mod M step by step:
fact[i] = fact[i-1] * i % M.Bit manipulation unlocks
O(1)solutions for many “seems-O(n)” problems. XOR, AND, OR + bit-shift tricks are a programmer’s second language. This chapter teaches 6 classic problems plus bit tricks you should memorise. Bitmask is expanded later in Chapter 40 (Bitmask DP) and 41 (Bitmask + Trie).
After this chapter, you will be able to:
x & -x, Brian Kernighan
x & (x-1).a ^ a = 0 → find single number / pair
with differing bits.# Set bit i: x |= (1 << i)
# Clear bit i: x &= ~(1 << i)
# Toggle bit i: x ^= (1 << i)
# Test bit i: (x >> i) & 1
# Lowest set bit (rightmost 1): x & -x (e.g. x=12=0b1100 → 4=0b100)
# Pop lowest set bit: x &= x - 1
# Count set bits: bin(x).count('1') # or x.bit_count() Py3.10+
# Iterate over subsets of mask:
sub = mask
while sub:
# ... use sub ...
sub = (sub - 1) & mask
# Check power of 2: x > 0 and (x & (x - 1)) == 0
# Invert 32-bit: xor with 0xFFFFFFFFGiven nums where every element appears
twice except for one which appears once, find that
element. O(n) time, O(1) space.
Input: nums = [2, 2, 1] → Output: 1
Input: nums = [4, 1, 2, 1, 2] → Output: 4
(every element appears exactly twice except a unique element appearing once)
XOR trick: a XOR a = 0;
a XOR 0 = a. XOR is commutative and associative. XOR the
whole array → pairs cancel, leaving the lonely element.
from typing import List
from functools import reduce
from operator import xor
class Solution:
def singleNumber(self, nums: List[int]) -> int:
return reduce(xor, nums)O(n). Space:
O(1).O(n) but at
O(n) space.Given integer n, return the number of 1
bits in its binary representation (Hamming weight).
Input: n = 11 (0b1011) → 3
Input: n = 128 (0b10000000) → 1
Approach 1 — Bit loop, O(32).
Approach 2 — n &= n - 1 trick,
O(set bits). Each n & (n-1)
clears the lowest 1 bit.
Illustration — n = 12 = 0b1100:
n = 1100
n-1=1011
n & (n-1) = 1000 → count = 1
n = 1000
n-1=0111
n & (n-1) = 0000 → count = 2
n = 0, stop. Total 2 bits. ✓
class Solution:
def hammingWeight(self, n: int) -> int:
count = 0
while n:
n &= n - 1
count += 1
return countO(set bits) ≤ 32.
Space: O(1).int.bit_count() —
built-in and effectively O(1). In interviews present the
n & (n-1) trick to show you understand.Given n, return an array of length n + 1
where result[i] = the set-bit count of i.
Input: n = 5
Output: [0, 1, 1, 2, 1, 2]
Brute force — call hammingWeight(i) for
each i, O(n log n).
DP — O(n):
result[i] = result[i >> 1] + (i & 1).
Intuition: the set bits of i = the set bits of
i >> 1 (drop the last bit) + the last bit.
Alternatively:
result[i] = result[i & (i-1)] + 1.
from typing import List
class Solution:
def countBits(self, n: int) -> List[int]:
result = [0] * (n + 1)
for i in range(1, n + 1):
result[i] = result[i >> 1] + (i & 1)
return resultO(n). Space:
O(n).i’s result builds on a smaller i’s
already-computed result.Compute a + b without using + or
-.
Input: a = 1, b = 2 → 3
Input: a = 2, b = 3 → 5
Insight: - a XOR b = the sum of bits
without carry. - (a AND b) << 1 =
carries. - Loop until carry = 0.
class Solution:
MASK = 0xFFFFFFFF
def getSum(self, a: int, b: int) -> int:
while b:
carry = (a & b) << 1
a = (a ^ b) & self.MASK
b = carry & self.MASK
# Handle negative numbers in Python (unbounded int).
return a if a < 0x80000000 else ~(a ^ self.MASK)O(32). Space:
O(1).MASK = 0xFFFFFFFF to simulate 32-bit.Given left, right, return the AND of every integer in
[left, right].
Input: left = 5 (0b101), right = 7 (0b111)
Output: 4 (0b100)
Explanation: 5 & 6 & 7 = 100 & 110 & 111 = 100 = 4.
Insight: the AND over a range = common
binary prefix of left and right,
padded with zeros at the lower bits.
Because somewhere in a long range every lower bit hits 0 → ANDed away to 0.
Method: shift both right until
left == right (find the common prefix), then shift back
left.
class Solution:
def rangeBitwiseAnd(self, left: int, right: int) -> int:
shifts = 0
while left < right:
left >>= 1
right >>= 1
shifts += 1
return left << shiftsO(log right).
Space: O(1).right &= (right - 1) until
right < left. Equivalent but slicker.Given nums, return the largest XOR of two distinct
elements.
Input: nums = [3, 10, 5, 25, 2, 8]
Output: 28
Explanation: 5 XOR 25 = 28.
Brute force — O(n²). Every pair.
“Greedy bit” trick — O(n · 32).
Build the result one bit at a time from the top. At bit
b: - Suppose the result from the high bit down to
b+1 is result. - Try turning on bit
b: candidate = result | (1 << b). -
Check: does a pair (a, b) exist in nums with
(a ^ b) & mask == candidate? (mask = bits ≥ b) - How
to check: build prefixes = {x & mask for x in nums}.
For each prefix p, test if
p ^ candidate in prefixes.
If yes → keep candidate; otherwise → keep
result as is.
Trie approach (Chapter 41) also works — that’s the extension.
from typing import List
class Solution:
def findMaximumXOR(self, nums: List[int]) -> int:
result = 0
mask = 0
for b in range(31, -1, -1):
mask |= (1 << b)
prefixes = {x & mask for x in nums}
candidate = result | (1 << b)
for p in prefixes:
if p ^ candidate in prefixes:
result = candidate
break
return resultO(n · 32) = O(n).
Space: O(n).Python int is infinite-bit →
(-1) << 1 doesn’t overflow. To simulate C++
32-bit:
MASK = 0xFFFFFFFF
INT_MIN_NEG = 0x80000000
while b:
a, b = (a ^ b) & MASK, ((a & b) << 1) & MASK
return a if a < INT_MIN_NEG else ~(a ^ MASK)
dp[i] = dp[i >> 1] + (i & 1): top bit +
lsb.dp[i] = dp[i & (i - 1)] + 1:
i & (i-1) clears the lowest bit ⇒ “subproblem = i with
one bit removed”. Both O(n); pick whichever you can explain
better.[m, n] = longest binary
common prefix of m and n, lower bits
= 0.LC 421 (Max XOR of Two Numbers) is the gateway to Binary Trie in Chapter 41 (Bitmask + Trie). Each number = a 32-bit path through the trie; greedily pick the opposite bit to maximise XOR.
The advanced-tree chapter covers three key structures: BST (Binary Search Tree), Segment Tree, and Fenwick Tree (Binary Indexed Tree). All three answer “range query with update” in
O(log n)per op. In Big Tech interviews, BST is asked frequently; Segment/Fenwick show up in Hard on-site rounds.
After this chapter, you will be able to:
(low, high) bounds downward, not just check
locally.O(log n), the
shortest code.O(log n).O(log n), shorter than segment tree.Quick selection: - Need sum / prefix only → Fenwick (simpler). - Need min / max / GCD / complex merge → Segment Tree. - Range updates with lazy → Segment Tree with lazy propagation.
# 1) BST validate / search — see Chapter 11.4
# 2) Fenwick Tree (1-indexed)
class Fenwick:
def __init__(self, n: int):
self.n = n
self.tree = [0] * (n + 1)
def update(self, i: int, delta: int) -> None: # 1-indexed
while i <= self.n:
self.tree[i] += delta
i += i & -i
def query(self, i: int) -> int: # prefix sum [1..i]
s = 0
while i > 0:
s += self.tree[i]
i -= i & -i
return s
def range_query(self, l: int, r: int) -> int:
return self.query(r) - self.query(l - 1)
# 3) Segment Tree — sum, iterative version
class SegTree:
def __init__(self, n: int):
self.n = n
self.tree = [0] * (2 * n)
def update(self, i: int, val: int) -> None:
i += self.n
self.tree[i] = val
while i > 1:
i //= 2
self.tree[i] = self.tree[2*i] + self.tree[2*i+1]
def query(self, l: int, r: int) -> int: # [l, r)
res = 0
l += self.n; r += self.n
while l < r:
if l & 1: res += self.tree[l]; l += 1
if r & 1: r -= 1; res += self.tree[r]
l //= 2; r //= 2
return resFully solved in Chapter 11.4. Pattern: DFS with bounds
(low, high), or inorder check for strictly increasing.
import math
class Solution:
def isValidBST(self, root) -> bool:
def dfs(node, low: float, high: float) -> bool:
if not node:
return True
if not (low < node.val < high):
return False
return dfs(node.left, low, node.val) and \
dfs(node.right, node.val, high)
return dfs(root, -math.inf, math.inf)O(n). Space:
O(h) stack.Input: root of a BST (LC level-order serialised).
Exactly two nodes of the BST have swapped values.
Restore the BST (mutating values) without changing the tree
structure. O(1) extra space (follow-up).
Input: root = [1, 3, null, null, 2]
(LC level-order serialisation)
Initial tree (NOT a valid BST):
1
/ \
3 *
\
2
Output: root after recovery = [3, 1, null, null, 2]
Recovered tree (valid BST):
3
/ \
1 *
\
2
Explanation: the function mutates in place; the two nodes that were
swapped hold values 1 and 3 → swap them back.
Inorder traversal of a correct BST yields a strictly ascending sequence. With 2 swapped nodes, the inorder will show two violations (a > b with a before b).
a and b of that single
violation.first = a of the first violation, second = b
of the second.After finding first, second → swap their
values.
class Solution:
def recoverTree(self, root) -> None:
first = second = prev = None
def inorder(node) -> None:
nonlocal first, second, prev
if not node:
return
inorder(node.left)
if prev and prev.val > node.val:
if not first:
first = prev
second = node
prev = node
inorder(node.right)
inorder(root)
first.val, second.val = second.val, first.valO(n). Space:
O(h) stack.O(1)
extra space (no stack) — more complex, usually unnecessary in
interviews.Design two functions: serialize(root) -> str and
deserialize(str) -> root.
Input: root = [1, 2, 3, null, null, 4, 5]
(LC level-order serialisation)
Actual tree:
1
/ \
2 3
/ \
4 5
Output (serialize): "1,2,#,#,3,4,#,#,5,#,#"
(preorder DFS; `#` marks a None node)
Round-trip requirement: deserialize(serialize(root)) recreates an
equivalent tree (same structure + same values) as the original root.
Preorder DFS with # for None.
val, per node; write
#, per None.# → None.class Codec:
def serialize(self, root) -> str:
parts: list[str] = []
def go(node):
if not node:
parts.append('#')
return
parts.append(str(node.val))
go(node.left)
go(node.right)
go(root)
return ','.join(parts)
def deserialize(self, data: str):
tokens = iter(data.split(','))
def build():
tk = next(tokens)
if tk == '#':
return None
node = TreeNode(int(tk))
node.left = build()
node.right = build()
return node
return build()O(n) for both.
Space: O(n).next(tokens))
rather than manually maintaining an index — avoids state-tracking
bugs.Given root of a binary tree. A path is
any node-sequence connected by edges (not necessarily through the root).
Find the path with the maximum sum.
Input: root = [1, 2, 3]
Actual tree:
1
/ \
2 3
Output: 6
(path 2 → 1 → 3, sum = 6)
Input: root = [-10, 9, 20, null, null, 15, 7]
Actual tree:
-10
/ \
9 20
/ \
15 7
Output: 42
(path 15 → 20 → 7, sum = 42, skip root -10 because it would lower the sum)
Bottom-up DFS: each node returns its best
“downward path to a leaf” =
node.val + max(left_path, right_path, 0). Also update
best with the “path passing through this node” =
node.val + left_path + right_path.
max(..., 0) lets us drop any subtree
with a negative sum.
class Solution:
def maxPathSum(self, root) -> int:
self.best = -float('inf')
def dfs(node) -> int:
if not node:
return 0
left = max(dfs(node.left), 0)
right = max(dfs(node.right), 0)
self.best = max(self.best, node.val + left + right)
return node.val + max(left, right)
dfs(root)
return self.bestO(n). Space:
O(h) stack.Given nums, return counts[i] = the number
of elements smaller than nums[i] that appear
after it.
Input: nums = [5, 2, 6, 1]
Output: [2, 1, 1, 0]
Explanation:
5 has {2, 1} smaller after → 2.
2 has {1} → 1.
6 has {1} → 1.
1 has 0.
Brute force — O(n²). TLE.
Approach 1 — Merge sort + inversion count (Chapter 17.2).
When merging two sorted halves, every time we take from the
left L[i], everything already
taken from the right is smaller than L[i] → add to
counts[i].
Approach 2 — Fenwick Tree,
O(n log n).
Walk from right to left. For each nums[i]: -
counts[i] = fenwick.query(rank(nums[i]) - 1) — count of
smaller values seen so far. -
fenwick.update(rank(nums[i]), +1).
rank = position of nums[i] in sorted unique
values → coordinate compression.
from typing import List
from bisect import bisect_left
class Fenwick:
def __init__(self, n): self.n = n; self.tree = [0] * (n + 1)
def update(self, i, d=1):
while i <= self.n:
self.tree[i] += d
i += i & -i
def query(self, i):
s = 0
while i > 0:
s += self.tree[i]
i -= i & -i
return s
class Solution:
def countSmaller(self, nums: List[int]) -> List[int]:
sorted_vals = sorted(set(nums))
rank = {v: i + 1 for i, v in enumerate(sorted_vals)} # 1-indexed
f = Fenwick(len(sorted_vals))
counts = [0] * len(nums)
for i in range(len(nums) - 1, -1, -1):
r = rank[nums[i]]
counts[i] = f.query(r - 1)
f.update(r, 1)
return countsO(n log n).
Space: O(n).[1, k] with k ≤ n.Design a class: - NumArray(nums): initialise. -
update(index, val): set nums[index] = val. -
sumRange(left, right): sum of
nums[left..right].
Both update and sumRange must be
O(log n).
Input (LC-style operation arrays):
ops = ["NumArray", "sumRange", "update", "sumRange"]
args = [[[1, 3, 5]], [0, 2], [1, 2], [0, 2]]
Output: [null, 9, null, 8]
Explanation:
NumArray([1, 3, 5]) → init, returns null
sumRange(0, 2) → 1 + 3 + 5 = 9
update(1, 2) → set nums[1] = 2; array is now [1, 2, 5]
sumRange(0, 2) → 1 + 2 + 5 = 8
Fenwick Tree is the ideal pick — the shortest code for this problem. A Segment Tree also works but is longer.
from typing import List
class NumArray:
def __init__(self, nums: List[int]):
self.n = len(nums)
self.nums = nums[:]
self.tree = [0] * (self.n + 1)
for i, x in enumerate(nums):
self._add(i + 1, x)
def _add(self, i: int, delta: int) -> None:
while i <= self.n:
self.tree[i] += delta
i += i & -i
def _prefix(self, i: int) -> int:
s = 0
while i > 0:
s += self.tree[i]
i -= i & -i
return s
def update(self, index: int, val: int) -> None:
delta = val - self.nums[index]
self.nums[index] = val
self._add(index + 1, delta)
def sumRange(self, left: int, right: int) -> int:
return self._prefix(right + 1) - self._prefix(left)O(n log n). Update /
Query: O(log n).i stores the sum of a segment of length
lowbit(i)”. The i & -i trick extracts the
lowest bit → the next position.| Structure | Supports | When to use |
|---|---|---|
| Binary Tree (general) | Traversal, paths | LC 124, 543 |
| BST (sorted invariant) | Search, kth, range count | LC 230, 938 |
| Fenwick / BIT | Prefix sum / count, point update | LC 307, 315 |
| Segment Tree | General range query/update | LC 732, 218 |
| Trie | Prefix strings | Chapters 23, 41 |
| Fenwick | Segment Tree | |
|---|---|---|
| Basic op | Prefix sum, point update | Range sum, range min/max/gcd… |
| Code | ~10 lines | ~40-60 lines |
| Memory | n | 4n |
| Lazy propagation | Hard | Easy |
| Pick when | Only need prefix/sum | Need complex range query + update |
±10⁴ → big array? But the element
count is ≤ 5 × 10⁴.[0, m-1]. Fenwick of size m suffices."1,2,#,#,3,#,#"): natural with recursion; deserialize with
a queue.O(n) time/space. Preorder usually saves a few
bytes.Trie (prefix tree) is a tree where each node represents one character; every root-to-node path = one prefix. Tries elegantly solve “find a word with prefix X”, “search with wildcards”, “longest common prefix across many queries”. The Bitmask + Trie pattern shows up in Chapter 41.
After this chapter, you will be able to:
dict[str, Node] (Unicode) and an array
of 26 (a-z, faster).. wildcards or fuzzy search.class TrieNode:
__slots__ = ("children", "is_end")
def __init__(self):
self.children: dict[str, "TrieNode"] = {}
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
def search(self, word: str) -> bool:
node = self._find(word)
return node is not None and node.is_end
def startsWith(self, prefix: str) -> bool:
return self._find(prefix) is not None
def _find(self, s: str) -> "TrieNode | None":
node = self.root
for ch in s:
if ch not in node.children:
return None
node = node.children[ch]
return nodeImplement a Trie class with three methods:
insert, search, startsWith. Each
op must run in O(length(word)).
Input (LC-style operation arrays):
ops = ["Trie", "insert", "search", "search", "startsWith", "insert", "search"]
args = [[], ["apple"], ["apple"], ["app"], ["app"], ["app"], ["app"]]
Output: [null, null, true, false, true, null, true]
Explanation:
Trie() → init, returns null
insert("apple") → null
search("apple") → true
search("app") → false ("app" not yet inserted on its own)
startsWith("app") → true (still a prefix of "apple")
insert("app") → null
search("app") → true
Trie = a tree where each node is one character; root → node is a
prefix. Insert: walk character by character, create new nodes as needed,
mark is_end at the final node. Search/StartsWith: walk by
character, return False on a missing node.
class TrieNode:
__slots__ = ("children", "is_end")
def __init__(self):
self.children: dict[str, "TrieNode"] = {}
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
def search(self, word: str) -> bool:
node = self._find(word)
return node is not None and node.is_end
def startsWith(self, prefix: str) -> bool:
return self._find(prefix) is not None
def _find(self, s: str) -> "TrieNode | None":
node = self.root
for ch in s:
if ch not in node.children:
return None
node = node.children[ch]
return nodeO(L)
where L = word length.O(N · L) for N
total-length words.children: TrieNode[26])
is faster than dict[str, TrieNode] for small alphabets but
uses more memory.__slots__ trims overhead when there
are many nodes.Implement a class with addWord(word) and
search(word). search allows . to
match any single character.
Input (LC-style operation arrays):
ops = ["WordDictionary", "addWord", "addWord", "addWord", "search", "search", "search", "search"]
args = [[], ["bad"], ["dad"], ["mad"], ["pad"], ["bad"], [".ad"], ["b.."]]
Output: [null, null, null, null, false, true, true, true]
Explanation:
WordDictionary() → null
addWord("bad") → null
addWord("dad") → null
addWord("mad") → null
search("pad") → false ("pad" not added)
search("bad") → true
search(".ad") → true (`.` matches any single char → "bad", "dad", "mad")
search("b..") → true (matches "bad")
addWord is identical to a standard Trie.
search needs recursive DFS when it hits
.: try all children.
class WordDictionary:
def __init__(self):
self.root: dict = {}
def addWord(self, word: str) -> None:
node = self.root
for ch in word:
node = node.setdefault(ch, {})
node['$'] = True # end marker
def search(self, word: str) -> bool:
def dfs(node: dict, i: int) -> bool:
if i == len(word):
return '$' in node
ch = word[i]
if ch == '.':
return any(dfs(child, i + 1)
for k, child in node.items() if k != '$')
if ch not in node:
return False
return dfs(node[ch], i + 1)
return dfs(self.root, 0)O(L).O(L) no wildcards;
O(26^L) worst with all ..dict[str] instead of a class —
shorter code, easier to inspect on print for debugging. The marker
$ flags word end (instead of an is_end
attribute).Given a board and a list of words, return
every word that appears on the board (via 4-direction
paths, no cell visited twice).
Input: board = [["o","a","a","n"],
["e","t","a","e"],
["i","h","k","r"],
["i","f","l","v"]]
words = ["oath", "pea", "eat", "rain"]
Output: ["oath", "eat"] (output order may vary)
words? → Still count as one
appearance.Brute force: for each word, DFS from each cell →
O(W · m · n · 4^L). TLE.
Trie + DFS — O(m · n · 4^L)
(independent of word count).
Build a Trie from words. DFS from each cell while
walking the Trie in parallel. On hitting a node with is_end
→ record the word. After recording a word, delete it from the
trie (set is_end = None) to avoid duplicates.
from typing import List
class Solution:
def findWords(self, board: List[List[str]], words: List[str]) -> List[str]:
# 1. Build Trie.
root: dict = {}
for w in words:
node = root
for ch in w:
node = node.setdefault(ch, {})
node['$'] = w
rows, cols = len(board), len(board[0])
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
result: list[str] = []
def dfs(r: int, c: int, node: dict) -> None:
ch = board[r][c]
if ch not in node:
return
nxt = node[ch]
if '$' in nxt:
result.append(nxt['$'])
del nxt['$'] # avoid duplicates
board[r][c] = '#'
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] != '#':
dfs(nr, nc, nxt)
board[r][c] = ch
if not nxt:
del node[ch] # prune
for r in range(rows):
for c in range(cols):
dfs(r, c, root)
return resultO(m · n · 4^L) worst.O(N · L) for the Trie.# then
restore (visited).Given words, find the longest word such
that every prefix of it also appears in the array. Tie
→ take the lexicographically smallest.
Input: words = ["w","wo","wor","worl","world"]
Output: "world" (every nested prefix is in words; lex smallest on tie)
Input: words = ["a","banana","app","appl","ap","apply","apple"]
Output: "apple"
(both "apple" and "apply" have all nested prefixes in words;
"apple" < "apply" lexicographically → pick "apple")
Approach 1 — Sort + set,
O(N · L log N). Sort words. Maintain a
valid set. Walk each word: if every prefix (only need to
check length-1 prefix) is in valid, add it.
Approach 2 — Trie + BFS/DFS. Build a Trie. DFS
preorder, preferring is_end. Only descend if
is_end is true.
Approach 1 is shorter for this problem.
from typing import List
class Solution:
def longestWord(self, words: List[str]) -> str:
words.sort() # lex
valid: set[str] = {""}
best = ""
for w in words:
if w[:-1] in valid:
valid.add(w)
if len(w) > len(best):
best = w
return bestO(Σ L · log N).
Space: O(Σ L).Given a dictionary of “roots” and a
sentence. For each word in the sentence, replace it with
the shortest root in the dictionary that is a prefix of
it. If no root → leave it alone.
Input: dictionary = ["cat","bat","rat"]
sentence = "the cattle was rattled by the battery"
Output: "the cat was rat by the bat"
Trie. Insert every root. For each word in the
sentence, walk the Trie: on the first is_end → replace the
word with the prefix up to that point.
from typing import List
class Solution:
def replaceWords(self, dictionary: List[str], sentence: str) -> str:
root: dict = {}
for w in dictionary:
node = root
for ch in w:
node = node.setdefault(ch, {})
node['$'] = True
def replace(word: str) -> str:
node = root
prefix = []
for ch in word:
if '$' in node:
return ''.join(prefix)
if ch not in node:
return word
prefix.append(ch)
node = node[ch]
return ''.join(prefix) if '$' in node else word
return ' '.join(replace(w) for w in sentence.split())O(N_dict · L + N_sent · L).
Space: O(N_dict · L).is_end — classic.Design StreamChecker whose constructor takes a list of
words. The method query(letter) returns
True if some suffix of the current stream
matches a word in the list.
Input (LC-style operation arrays):
ops = ["StreamChecker", "query", "query", "query", "query", "query", "query"]
args = [[["cd","f","kl"]], ["a"], ["b"], ["c"], ["d"], ["e"], ["f"]]
Output: [null, false, false, false, true, false, true]
Explanation:
StreamChecker(["cd","f","kl"])
query('a') → false (stream = "a")
query('b') → false (stream = "ab")
query('c') → false (stream = "abc")
query('d') → true (suffix "cd" of stream "abcd" matches)
query('e') → false
query('f') → true (suffix "f" matches)
Insight: suffixes are hard to look up in a prefix Trie. Reverse trick — build the Trie on reversed words. Reverse the stream too (newest first): now a prefix of the reversed stream matches the Trie.
Implementation: keep a buffer of queried characters. Each query, walk the Trie reading the buffer back to front (= reversed word).
from typing import List
class StreamChecker:
def __init__(self, words: List[str]):
self.root: dict = {}
for w in words:
node = self.root
for ch in reversed(w):
node = node.setdefault(ch, {})
node['$'] = True
self.buf: list[str] = []
def query(self, letter: str) -> bool:
self.buf.append(letter)
node = self.root
for ch in reversed(self.buf):
if '$' in node:
return True
if ch not in node:
return False
node = node[ch]
return '$' in nodeO(Σ L).O(max_word_length).| Criterion | Dict children {ch: TrieNode} |
Array [26] |
|---|---|---|
| Memory | Small when sparse | Large but uniform |
| Access | O(1) hash |
O(1) index |
| Unicode support | ✅ | ❌ (26 letters only) |
| Code | Pythonic, concise | Faster in C++/Java |
words.
DFS from each cell with a trie pointer.node.word = None (delete from trie) so it doesn’t appear
twice.c_0 c_1 c_2 .... After each char, ask whether
any suffix matches a word.c_i, walk from c_i, c_{i-1}, ... backwards —
this is a prefix in the reversed trie.While descending the trie, stop at the first terminal node — that is the shortest root to replace with. Going further only finds longer roots (not what we want).
Union Find (DSU) is the data structure for two operations on disjoint sets: (i) find(x) — the root of the set containing
x; (ii) union(x, y) — merge two sets. With path compression + union by rank/size, each op is nearlyO(1)(preciselyO(α(n))with α the inverse Ackermann function).
After this chapter, you will be able to:
find (path compression) and
union (by rank/size).O(1) (precisely
O(α(n))).class DSU:
def __init__(self, n: int):
self.parent = list(range(n))
self.rank = [0] * n
self.size = [1] * n
self.components = n
def find(self, x: int) -> int:
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path compression
x = self.parent[x]
return x
def union(self, x: int, y: int) -> bool:
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False # already same set
# Union by rank — attach the shorter tree below the taller one.
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
self.size[rx] += self.size[ry]
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
self.components -= 1
return True
def connected(self, x: int, y: int) -> bool:
return self.find(x) == self.find(y)There are n cities and a matrix
isConnected[i][j] = 1 if i and j are directly connected. A
province is a maximal connected set of cities. Count
the provinces.
Input: isConnected=[[1,1,0],[1,1,0],[0,0,1]]
Output: 2
DSU: union every connected pair, count components. Or
DFS/BFS — simpler.
from typing import List
class Solution:
def findCircleNum(self, isConnected: List[List[int]]) -> int:
n = len(isConnected)
dsu = DSU(n)
for i in range(n):
for j in range(i + 1, n):
if isConnected[i][j]:
dsu.union(i, j)
return dsu.componentsO(n² · α(n)).
Space: O(n).O(α)
per query.Input: edges: List[List[int]] — edges
[u, v] of an undirected graph. The graph
was a tree (n nodes, n-1 edges) before one extra edge
was added that created a cycle. Return the last edge in the input that
we can remove so the rest forms a tree.
Input: edges=[[1,2],[1,3],[2,3]]
Output: [2,3]
DSU union edge by edge. The first edge where
find(u) == find(v) is the one forming the
cycle → return it.
from typing import List
class Solution:
def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
n = len(edges)
dsu = DSU(n + 1)
for u, v in edges:
if not dsu.union(u, v):
return [u, v]
return []O(E · α(V)).O(V).Given accounts where each entry is
[name, email1, email2, ...]. Two accounts belong to the
same person ↔︎ they share at least one email. Merge accounts of the same
person; output emails lex-sorted.
Input: accounts=[["John","a@x","b@x"],["John","b@x","c@x"],["Mary","d@x"]]
Output: [["John","a@x","b@x","c@x"],["Mary","d@x"]]
from collections import defaultdict
from typing import List
class Solution:
def accountsMerge(self, accounts: List[List[str]]) -> List[List[str]]:
dsu_parent: dict[str, str] = {}
email_to_name: dict[str, str] = {}
def find(x: str) -> str:
if dsu_parent.setdefault(x, x) != x:
dsu_parent[x] = find(dsu_parent[x])
return dsu_parent[x]
def union(x: str, y: str) -> None:
dsu_parent[find(x)] = find(y)
for acc in accounts:
name, emails = acc[0], acc[1:]
for e in emails:
email_to_name[e] = name
union(emails[0], e)
groups: dict[str, list[str]] = defaultdict(list)
for e in email_to_name:
groups[find(e)].append(e)
return [[email_to_name[g[0]]] + sorted(g) for g in groups.values()]O(N · L · α(N · L) + N · L log(L)) for sorting.O(N · L).Given an m × n grid initially all water. Given
positions[i] = [r, c] — turn each cell into land in order.
After each addition, return the current island
count.
Input: m = 3, n = 3
positions = [[0,0], [0,1], [1,2], [2,1]]
Output: [1, 1, 2, 3]
Step-by-step trace:
After [0,0]: [1, 0, 0] → 1 island
[0, 0, 0]
[0, 0, 0]
After [0,1]: [1, 1, 0] → 1 island (connects to (0,0))
[0, 0, 0]
[0, 0, 0]
After [1,2]: [1, 1, 0] → 2 islands
[0, 0, 1]
[0, 0, 0]
After [2,1]: [1, 1, 0] → 3 islands
[0, 0, 1]
[0, 1, 0]
Online DSU. For each position: - Mark as land,
components += 1. - For each of the 4 neighbours already
land, union → if union succeeds, components -= 1. - Push
components into the result.
from typing import List
class Solution:
def numIslands2(self, m: int, n: int, positions: List[List[int]]) -> List[int]:
parent = [-1] * (m * n) # -1 = water
components = 0
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
result: list[int] = []
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x: int, y: int) -> bool:
rx, ry = find(x), find(y)
if rx == ry: return False
parent[rx] = ry
return True
for r, c in positions:
idx = r * n + c
if parent[idx] != -1:
result.append(components)
continue
parent[idx] = idx
components += 1
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < m and 0 <= nc < n:
nidx = nr * n + nc
if parent[nidx] != -1 and union(idx, nidx):
components -= 1
result.append(components)
return resultO(K · α(mn)) where
K = number of positions.O(mn).parent[idx] != -1 to skip.Given equations of the form "a==b" or
"a!=b". Return True if values can be assigned
to variables to satisfy every equation.
Input: ["a==b","b!=a"]
Output: False
a == a)? →
Trivially True; the code still handles it correctly.Two passes: 1. Union all == equations.
2. Check != equations: if two variables share a root →
contradiction.
from typing import List
class Solution:
def equationsPossible(self, equations: List[str]) -> bool:
parent = list(range(26))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for eq in equations:
if eq[1] == '=':
parent[find(ord(eq[0]) - 97)] = find(ord(eq[3]) - 97)
for eq in equations:
if eq[1] == '!':
if find(ord(eq[0]) - 97) == find(ord(eq[3]) - 97):
return False
return TrueO((E + Q) · α(26)).O(26).A matrix grid[i][j] = “elevation” at cell
(i, j). At time t, every cell with elevation ≤
t is submerged enough to swim into. Starting at (0, 0),
reach (n-1, n-1). Find the smallest t.
Input: grid=[[0,1,2,3,4],[24,23,22,21,5],[12,13,14,15,16],[11,17,18,19,20],[10,9,8,7,6]]
Output: 16
Approach 1 — Offline DSU. Sort cells by elevation
ascending. “Activate” cells one at a time and union with active
4-neighbours. When (0,0) and (n-1,n-1) lie in
the same component → return the elevation of the cell just added.
Approach 2 — Binary search on the answer + DFS (Chapter 37). Approach 3 — Dijkstra (Chapter 30).
Approach 1 is the most “DSU-flavoured” pattern.
from typing import List
class Solution:
def swimInWater(self, grid: List[List[int]]) -> int:
n = len(grid)
parent = list(range(n * n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
# Enumerate cells by increasing elevation.
cells = sorted((grid[r][c], r, c) for r in range(n) for c in range(n))
active = [[False] * n for _ in range(n)]
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
for t, r, c in cells:
active[r][c] = True
idx = r * n + c
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < n and active[nr][nc]:
parent[find(idx)] = find(nr * n + nc)
if find(0) == find(n * n - 1):
return t
return -1O(n² · log(n²) + n² · α).O(n²).nodes: 0 1 2 3 4 5
parent: 0 0 0 3 3 5 (after union(1,0), union(2,1), union(4,3))
Forest:
0 3 5
/ \ |
1 2 4
find(x) walks up
parent until parent[x] == x.Before find(4): After find(4) with compression:
0 0
| / | \
1 1 2 4
| |
2 3 ← 4 points directly to 0 via compression
|
3
|
4
(r, c):
count += 1.union((r,c), neighbor). If it actually merged two different
components → count -= 1.O(k × R × C).A bridge to Chapter 37 (BS + Graph) and
Chapter 33 (MST): - Sort cells by ascending elevation.
- Union adjacent cells that have been “flooded” by the current
elevation. - When (0, 0) and (n−1, n−1) fall
into the same component, the current elevation is the answer.
Chapter 5 taught binary search on sorted arrays. This chapter teaches a stronger technique: binary search on the answer (search on answer / parametric search). When the answer space has a monotonic predicate, we binary-search on the value of the answer instead of on an index.
After this chapter, you will be able to:
check(x) monotonic over
value x.lo = min possible,
hi = max possible.check(X).Procedure: 1. Identify the answer
(capacity, speed, distance, …). 2. Bound [lo, hi] for the
answer. 3. Write check(mid) -> bool monotonic. 4.
Binary-search for “first True” or “last True”.
def search_on_answer(lo: int, hi: int, check) -> int:
"""Find the smallest value in [lo, hi] with check(x)=True."""
while lo < hi:
mid = (lo + hi) // 2
if check(mid):
hi = mid # try to shrink to the True side
else:
lo = mid + 1
return loKoko has n piles of bananas, pile i has
piles[i] bananas. Each hour Koko eats one pile at speed
k bananas/hour (if the pile is smaller, the hour still
counts). Find the smallest k such that all
bananas are eaten in h hours.
Input: piles = [3,6,7,11], h = 8 → 4
Input: piles = [30,11,23,4,20], h = 5 → 30
Input: piles = [30,11,23,4,20], h = 6 → 23
k still ≤
max(piles).The answer k ∈
[1, max(piles)]. The predicate check(k) = “eat
everything in ≤ h hours?”.
time(k) = Σ ceil(piles[i] / k). The predicate is
monotonic: bigger k → smaller time. → Find the
smallest k with
time(k) ≤ h.
from math import ceil
from typing import List
class Solution:
def minEatingSpeed(self, piles: List[int], h: int) -> int:
def can_finish(k: int) -> bool:
return sum((p + k - 1) // k for p in piles) <= h
lo, hi = 1, max(piles)
while lo < hi:
mid = (lo + hi) // 2
if can_finish(mid):
hi = mid
else:
lo = mid + 1
return loO(n log(max_pile)).
Space: O(1).(p + k - 1) // k avoids
math.ceil with floats.Given weights[] (in order) and days. Find
the smallest capacity of a ship that can transport
everything within days days (one trip per day; the ship
must carry packages contiguously in order).
Input: weights = [1,2,3,4,5,6,7,8,9,10], days = 5
Output: 15
lo = max(weights) prevents this.Answer cap ∈
[max(weights), sum(weights)]. (cap < max → can’t load
the heaviest package; cap = sum → one trip is enough.)
Predicate: “with this cap, can we ship within ≤ days days?” → greedy day counter.
from typing import List
class Solution:
def shipWithinDays(self, weights: List[int], days: int) -> int:
def can_ship(cap: int) -> bool:
d = 1
cur = 0
for w in weights:
if cur + w > cap:
d += 1
cur = 0
cur += w
return d <= days
lo, hi = max(weights), sum(weights)
while lo < hi:
mid = (lo + hi) // 2
if can_ship(mid):
hi = mid
else:
lo = mid + 1
return loO(n log(sum_weights)).
Space: O(1).lo = max(weights)
(un-skippable), hi = sum(weights).Given nums (non-negative) and k, split into
k non-empty contiguous subarrays. Find a
split that minimises the maximum subarray sum.
Input: nums = [7,2,5,10,8], k = 2
Output: 18 (split [7,2,5] and [10,8])
Identical to 25.2! Answer = max sum. check(cap) = “can
we split into ≤ k subarrays with each sum ≤ cap?”.
from typing import List
class Solution:
def splitArray(self, nums: List[int], k: int) -> int:
def can_split(cap: int) -> bool:
groups = 1
cur = 0
for x in nums:
if cur + x > cap:
groups += 1
cur = 0
cur += x
return groups <= k
lo, hi = max(nums), sum(nums)
while lo < hi:
mid = (lo + hi) // 2
if can_split(mid):
hi = mid
else:
lo = mid + 1
return loO(n · log(sum)).O(1).Given nums, compute all pairwise distances
|nums[i] - nums[j]| with i < j. Return the
k-th smallest distance.
Input: nums=[1,3,1], k=1
Output: 0
Brute force: generate all C(n, 2)
distances, sort, take k-th. O(n² log n²). TLE with
n = 10^4.
Search on answer + Sliding-window count: - Sort
nums. Answer ∈ [0, max - min]. -
check(d) = “are there ≥ k pairs with distance ≤ d?” - Count
pairs via sliding window: for each right, advance
left until nums[right] - nums[left] <= d.
The pair count contributed = right - left.
from typing import List
class Solution:
def smallestDistancePair(self, nums: List[int], k: int) -> int:
nums.sort()
def count_le(d: int) -> int:
cnt = 0
left = 0
for right in range(len(nums)):
while nums[right] - nums[left] > d:
left += 1
cnt += right - left
return cnt
lo, hi = 0, nums[-1] - nums[0]
while lo < hi:
mid = (lo + hi) // 2
if count_le(mid) >= k:
hi = mid
else:
lo = mid + 1
return loO(n log n + log(max_d) · n).O(1).Given two sorted arrays nums1, nums2, find the median of
their union. O(log(m+n)).
Input: nums1=[1,3], nums2=[2]
Output: 2.0
Explanation: median of [1,2,3] = 2
Binary search on the partition. Find i
(split nums1 at index i) and
j = (m + n + 1) // 2 - i (split nums2) such
that: - nums1[i-1] <= nums2[j] and
nums2[j-1] <= nums1[i].
When found, median = max(left) (if total length odd) or
(max(left) + min(right)) / 2 (even).
Always binary-search on the shorter array to keep it
O(log min(m, n)).
from typing import List
class Solution:
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
# Ensure nums1 is shorter.
if len(nums1) > len(nums2):
nums1, nums2 = nums2, nums1
m, n = len(nums1), len(nums2)
half = (m + n + 1) // 2
INF = float('inf')
lo, hi = 0, m
while lo <= hi:
i = (lo + hi) // 2
j = half - i
left1 = nums1[i - 1] if i > 0 else -INF
right1 = nums1[i] if i < m else INF
left2 = nums2[j - 1] if j > 0 else -INF
right2 = nums2[j] if j < n else INF
if left1 <= right2 and left2 <= right1:
if (m + n) % 2:
return max(left1, left2)
return (max(left1, left2) + min(right1, right2)) / 2
elif left1 > right2:
hi = i - 1
else:
lo = i + 1
return 0.0O(log min(m, n)).
Space: O(1).±INF sentinels to handle
i = 0 or i = m.Given n stalls at positions pos[i] (sorted)
and c cows. Place each cow in a stall so the
minimum distance between any two cows is
maximised. Return that distance.
Input: pos = [1, 2, 4, 8, 9], c = 3
Output: 3
Explanation: place at 1, 4, 8 → min distance = 3.
Answer d ∈ [0, max - min].
Predicate: “can we place c cows with all pairwise distances
≥ d?” → greedy: place the first cow at the leftmost position, then each
subsequent at the first position ≥ previous + d.
from typing import List
class Solution:
def aggressiveCows(self, pos: List[int], c: int) -> int:
pos.sort()
def can_place(d: int) -> bool:
placed = 1
last = pos[0]
for p in pos[1:]:
if p - last >= d:
placed += 1
last = p
if placed >= c:
return True
return False
lo, hi = 0, pos[-1] - pos[0]
while lo < hi:
mid = (lo + hi + 1) // 2 # "last True" search → ceil mid
if can_place(mid):
lo = mid
else:
hi = mid - 1
return loO(n log n + n log(max_d)).O(1).mid = (lo + hi + 1) // 2 (ceil), lo = mid when
feasible. Different from the usual “first True”.| Problem | Answer to find | lo, hi |
Predicate can(x) |
Goal |
|---|---|---|---|---|
| Koko (LC 875) | speed k | 1, max(piles) |
total hours ≤ H | min x with can(x) |
| Ship (LC 1011) | capacity | max(w), sum(w) |
ship in ≤ D days | min |
| Split Array (LC 410) | largest subarray sum | max(arr), sum(arr) |
split into ≤ m parts | min |
| Aggressive Cows | distance d |
1, max - min |
place ≥ C cows | max x with can(x) |
| Magnetic Force (LC 1552) | force | 1, max - min |
place ≥ m | max |
General template (first-true / last-true):
# first-true monotonic: F F F T T T → return first T
while lo < hi:
mid = (lo + hi) // 2
if can(mid): hi = mid
else: lo = mid + 1
return lo
Verify can(x) is monotonic in x: - Koko:
bigger k → eat faster → smaller total hours ⇒
can is monotonically increasing. -
Aggressive Cows: bigger d → harder to place → fewer cows
fit ⇒ can is monotonically decreasing.
This is binary partition: find i, j
such that A[..i] ∪ B[..j] forms the smaller half. A
different pattern — don’t force it into the general template.
positions sorted: [1, 2, 4, 8, 9] cows c = 3, d = ?
d = 3 ⇒ pick 1, 4, 8 ✅ (3 cows)
d = 4 ⇒ pick 1, 8 (only 2 cows) ❌
⇒ max d allowing ≥ 3 cows = 3
Two pointers is the basic weapon already encountered in Array (1.4, 1.5), String (2.2), Linked List (7.3-7.6). This chapter systematises it into 3 templates: (i) two-end (converging), (ii) slow & fast (1×/2×), (iii) same-direction (sliding window). Each template solves a family of problems.
After this chapter, you will be able to:
O(1) extra space while processing.The three most useful templates:
| Template | Example problems | Characteristic |
|---|---|---|
| Two-end | 3Sum, Container Most Water, Trapping | sort first, converge from sides |
| Slow & Fast | Cycle, Middle of LL, Move Zeroes | different speeds |
| Same direction | Sliding window, Remove Dup | window grows / shrinks |
# 1) Two-end (sorted)
l, r = 0, len(arr) - 1
while l < r:
if check(arr[l], arr[r]):
# ... use the pair ...
l += 1
r -= 1
elif arr[l] + arr[r] < target:
l += 1
else:
r -= 1
# 2) Slow & Fast (in-place)
slow = 0
for fast in range(len(arr)):
if condition(arr[fast]):
arr[slow] = arr[fast]
slow += 1Given nums, find all triplets [a, b, c]
with a + b + c == 0 and no duplicates.
Input: nums = [-1, 0, 1, 2, -1, -4]
Output: [[-1, -1, 2], [-1, 0, 1]]
Brute force O(n³). Every triplet.
Sort + two pointers O(n²).
Sort. For each i, use two pointers l, r to
find a pair summing to -nums[i] in
nums[i+1..n-1].
Handle duplicates: skip duplicates at
i, l, r.
Illustration for
nums = [-1, 0, 1, 2, -1, -4] sorted →
[-4, -1, -1, 0, 1, 2]:
i=0 (-4): target=4. l=1 (-1), r=5 (2). -1+2=1 < 4. l++. ... nothing.
i=1 (-1): target=1. l=2 (-1), r=5 (2). -1+2=1 ✓ → add [-1,-1,2].
l++, r--. l=3 (0), r=4 (1). 0+1=1 ✓ → add [-1,0,1].
i=2 (-1): skip (duplicate of nums[1]).
i=3 (0): target=0. l=4 (1), r=5 (2). 1+2=3 > 0. r--. → l=r → stop.
from typing import List
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
nums.sort()
result: list[list[int]] = []
n = len(nums)
for i in range(n - 2):
if nums[i] > 0:
break
if i > 0 and nums[i] == nums[i - 1]:
continue
l, r = i + 1, n - 1
target = -nums[i]
while l < r:
s = nums[l] + nums[r]
if s == target:
result.append([nums[i], nums[l], nums[r]])
l += 1; r -= 1
while l < r and nums[l] == nums[l - 1]: l += 1
while l < r and nums[r] == nums[r + 1]: r -= 1
elif s < target:
l += 1
else:
r -= 1
return resultO(n²). Space:
O(1) (output excluded).nums[i] > 0: since the
array is sorted, nums[i] > 0 →
nums[i+1], nums[r] > 0 → the sum is positive.Given an array height[]. Each bar has width 1. Compute
the water trapped between bars.
Input: height = [0,1,0,2,1,0,1,3,2,1,2,1]
(each element is the height of one width-1 bar)
Output: 6 (total water trapped between bars)
Approach 1 — Prefix max + Suffix max, O(n) time,
O(n) space.
Cell i holds:
min(max_left[i], max_right[i]) - height[i].
Approach 2 — Two pointers, O(n) time,
O(1) space.
l = 0, r = n - 1, keep lmax, rmax. Move the
pointer on the lower side — that side determines the
water at the cell being considered.
from typing import List
class Solution:
def trap(self, height: List[int]) -> int:
l, r = 0, len(height) - 1
lmax = rmax = 0
water = 0
while l < r:
if height[l] < height[r]:
if height[l] >= lmax:
lmax = height[l]
else:
water += lmax - height[l]
l += 1
else:
if height[r] >= rmax:
rmax = height[r]
else:
water += rmax - height[r]
r -= 1
return waterO(n). Space:
O(1).height[l] < height[r], then at
cell l the “right bound” is at least
height[r] > height[l] → enough to fix water =
lmax - height[l].Fully solved in Chapter 1.5. This is the core two-pointer pattern.
from typing import List
class Solution:
def maxArea(self, height: List[int]) -> int:
l, r = 0, len(height) - 1
best = 0
while l < r:
h = min(height[l], height[r])
best = max(best, h * (r - l))
if height[l] < height[r]:
l += 1
else:
r -= 1
return bestO(n). Space:
O(1).Fully solved in Chapter 4.1. Three pointers (Dutch flag).
from typing import List
class Solution:
def sortColors(self, nums: List[int]) -> None:
lo, mid, hi = 0, 0, len(nums) - 1
while mid <= hi:
if nums[mid] == 0:
nums[lo], nums[mid] = nums[mid], nums[lo]
lo += 1; mid += 1
elif nums[mid] == 1:
mid += 1
else:
nums[mid], nums[hi] = nums[hi], nums[mid]
hi -= 1O(n). Space:
O(1).Given a sorted array nums, remove duplicates so each
element appears at most twice; return the new length.
In place.
Input: nums = [1,1,1,2,2,3]
Output: 5, nums = [1,1,2,2,3,_]
Slow & fast two pointers. Sorted → just check
nums[fast] != nums[slow - 2].
from typing import List
class Solution:
def removeDuplicates(self, nums: List[int]) -> int:
slow = 0
for x in nums:
if slow < 2 or x != nums[slow - 2]:
nums[slow] = x
slow += 1
return slowO(n). Space:
O(1).x != nums[slow - 2] trick is
elegant — a third occurrence would “see” itself at
slow - 2.Given nums and target, find all quadruplets
[a, b, c, d] summing to target, no
duplicates.
Input: nums=[1,0,-1,0,-2,2], target=0
Output: [[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]
i, j, l, r.Generalisation of 3Sum. Sort + 2 outer loops
(i, j) + two pointers for (l, r).
O(n³).
Skip duplicates at all four indices
i, j, l, r.
from typing import List
class Solution:
def fourSum(self, nums: List[int], target: int) -> List[List[int]]:
nums.sort()
n = len(nums)
result: list[list[int]] = []
for i in range(n - 3):
if i > 0 and nums[i] == nums[i - 1]:
continue
for j in range(i + 1, n - 2):
if j > i + 1 and nums[j] == nums[j - 1]:
continue
l, r = j + 1, n - 1
tgt = target - nums[i] - nums[j]
while l < r:
s = nums[l] + nums[r]
if s == tgt:
result.append([nums[i], nums[j], nums[l], nums[r]])
l += 1; r -= 1
while l < r and nums[l] == nums[l - 1]: l += 1
while l < r and nums[r] == nums[r + 1]: r -= 1
elif s < tgt:
l += 1
else:
r -= 1
return resultO(n³). Space:
O(1).nums[i]*4 > target (if target is large).| Shape | When to use | Representative problems |
|---|---|---|
Two-end (l=0, r=n-1,
converge inward) |
Sorted, palindrome, container | LC 11, 15, 167, 125 |
Same-direction (l, r both
forward) |
Subarray with invariant (sliding window is a special case) | LC 26, 283; Chapter 27 |
Slow-fast (slow step 1,
fast step 2) |
Cycle detect, middle node | LC 141, 142, 876 |
nums.sort()
for i in range(n):
if i > 0 and nums[i] == nums[i-1]: continue # skip anchor dup
l, r = i+1, n-1
while l < r:
s = nums[i] + nums[l] + nums[r]
if s == 0:
ans.append([nums[i], nums[l], nums[r]])
l += 1; r -= 1
while l < r and nums[l] == nums[l-1]: l += 1 # skip inner left dup
while l < r and nums[r] == nums[r+1]: r -= 1 # skip inner right dup
elif s < 0: l += 1
else: r -= 1
Four skip points — forgetting any one produces duplicates.
| Prefix/suffix max array | Two pointers | |
|---|---|---|
| Time | O(n) | O(n) |
| Space | O(n) | O(1) |
| Code length | Medium | Shorter |
| Insight | Visual: water at i = min(L[i], R[i]) - h[i] |
Must argue “the lower bar decides” |
Recommendation: present prefix/suffix first (easier to explain), then optimise to two-pointer if asked about space.
Sliding Window = two pointers moving in the same direction. The window
[l, r]extendsr, shrinkslwhen the condition is violated. This pattern crisply solves many “longest / shortest / count substring/subarray with condition” problems inO(n).
After this chapter, you will be able to:
r - l + 1 == k.Two main templates:
| Template | Pseudo |
|---|---|
| Fixed-size window | maintain r - l + 1 == k always |
| Variable-size window | always extend r, shrink l until
valid() |
from collections import defaultdict, Counter
# 1) Variable-size: longest with condition
l = 0
state = ...
best = 0
for r in range(len(s)):
add(s[r], state)
while not valid(state):
remove(s[l], state)
l += 1
best = max(best, r - l + 1)
# 2) Count subarray "exactly K" = "at most K" - "at most K-1"
def at_most(k):
l, total = 0, 0
state = ...
for r in range(len(arr)):
add(arr[r], state)
while violates(state):
remove(arr[l], state)
l += 1
total += r - l + 1
return total
result = at_most(k) - at_most(k - 1)Given a string s, find the length of the longest
substring without repeating characters.
Input: s = "abcabcbb" → Output: 3 (best substring: "abc")
Input: s = "bbbbb" → Output: 1 (substring "b")
Input: s = "pwwkew" → Output: 3 (substring "wke")
Sliding window + set / dict.
Extend r. When s[r] is already in the
window, shrink l until it isn’t.
Better — dict of last-seen indices:
l = max(l, last_seen[s[r]] + 1).
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
last: dict[str, int] = {}
l = 0
best = 0
for r, ch in enumerate(s):
if ch in last and last[ch] >= l:
l = last[ch] + 1
last[ch] = r
best = max(best, r - l + 1)
return bestO(n). Space:
O(k) with k = alphabet size.last[ch] >= l: only jump
l when last[ch] is still in the current
window.Given s, t, find the smallest substring
of s containing every character of
t (including frequency).
Input: s = "ADOBECODEBANC", t = "ABC" → "BANC"
Sliding window + 2 Counters.
need = Counter(t). have counts inside the
window.have[k] >= need[k] → valid
→ shrink l.(best_len, l_best, r_best).Optimisation: track formed (number of
satisfied keys) instead of comparing whole dicts.
from collections import Counter
class Solution:
def minWindow(self, s: str, t: str) -> str:
if not t or len(t) > len(s):
return ""
need = Counter(t)
have: dict[str, int] = {}
required = len(need)
formed = 0
l = 0
best = (float('inf'), 0, 0)
for r, ch in enumerate(s):
have[ch] = have.get(ch, 0) + 1
if ch in need and have[ch] == need[ch]:
formed += 1
while formed == required:
if r - l + 1 < best[0]:
best = (r - l + 1, l, r)
have[s[l]] -= 1
if s[l] in need and have[s[l]] < need[s[l]]:
formed -= 1
l += 1
return "" if best[0] == float('inf') else s[best[1]:best[2] + 1]O(n + m).
Space: O(k).formed counts “satisfied keys” —
formed == required is the validity condition.have == need every
step → O(k) per step → O(nk) total.Given s and k, you may replace at most
k characters with any others. Find the length of the
longest substring of identical characters achievable.
Input: s = "ABAB", k = 2 → 4
Input: s = "AABABBA", k = 1 → 4 ("AABA" or "ABBA")
Insight: the window is valid ↔︎
window_len - max_freq <= k (characters to flip ≤ k).
Extend r, update max_freq. On violation →
shrink l by 1.
Trick:
max_freqdoes not need to “decrease” when shrinkingl— the answer doesn’t grow whenmax_freqstays at its previous peak (the window can’t be larger than that peak’s run).
from collections import defaultdict
class Solution:
def characterReplacement(self, s: str, k: int) -> int:
count: dict[str, int] = defaultdict(int)
l = 0
max_freq = 0
best = 0
for r, ch in enumerate(s):
count[ch] += 1
max_freq = max(max_freq, count[ch])
while (r - l + 1) - max_freq > k:
count[s[l]] -= 1
l += 1
best = max(best, r - l + 1)
return bestO(n). Space:
O(26).max_freq = 1s.Given s1, s2, check whether s2 contains any
permutation of s1 (= some substring of s2 has
Counter == Counter(s1)).
Input: s1="ab", s2="eidbaooo"
Output: True
Explanation: s2 contains "ba" — a permutation of "ab"
Fixed-size sliding window of length
len(s1). Compare two 26-element count arrays each step.
class Solution:
def checkInclusion(self, s1: str, s2: str) -> bool:
if len(s1) > len(s2):
return False
need = [0] * 26
have = [0] * 26
for ch in s1:
need[ord(ch) - 97] += 1
for i, ch in enumerate(s2):
have[ord(ch) - 97] += 1
if i >= len(s1):
have[ord(s2[i - len(s1)]) - 97] -= 1
if have == need:
return True
return FalseO(n · 26) = O(n).
Space: O(26).have == need compares two 26-element
lists in O(26) constant time.Given nums and k, count subarrays with
exactly k distinct integers.
Input: nums = [1,2,1,2,3], k = 2 → 7
Input: nums = [1,2,1,3,4], k = 3 → 3
Trick: “exactly K” = “at most K” - “at most K - 1”.
at_most(k): sliding-window count of subarrays with
≤ k distinct integers.
from collections import defaultdict
from typing import List
class Solution:
def subarraysWithKDistinct(self, nums: List[int], k: int) -> int:
def at_most(k: int) -> int:
count: dict[int, int] = defaultdict(int)
distinct = 0
l = 0
total = 0
for r in range(len(nums)):
if count[nums[r]] == 0:
distinct += 1
count[nums[r]] += 1
while distinct > k:
count[nums[l]] -= 1
if count[nums[l]] == 0:
distinct -= 1
l += 1
total += r - l + 1
return total
return at_most(k) - at_most(k - 1)O(n). Space:
O(k).Fully solved in Chapter 18.4 (Monotonic Queue).
from collections import deque
from typing import List
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
dq: deque[int] = deque() # stores **indices**, values in dq decreasing
result: List[int] = []
for i, x in enumerate(nums):
while dq and nums[dq[-1]] <= x:
dq.pop()
dq.append(i)
if dq[0] <= i - k:
dq.popleft()
if i >= k - 1:
result.append(nums[dq[0]])
return resultdq[0] <= i - k).O(n).O(k).need / have traceS = "ADOBECODEBANC", T = "ABC"
need = {A:1, B:1, C:1}; need_unique = 3
have_unique = characters fully matched in the window.
extend window until have_unique == need_unique
→ shrink from the left until losing a required char → update min.
Classic bug: confusing “fully matched” with “raw
count exceeded”. Only increment have_unique when
cnt[c] == need[c] (just matched), and decrement when
cnt[c] < need[c] (just dropped below).
atMost(K) is typically easy via sliding window.exactly(K) = atMost(K) - atMost(K-1). Applies to: LC
992, 1248,
| Type | Pattern | Problems |
|---|---|---|
Fixed k |
Window size k, slide one step |
LC 643, 567 |
| Variable shrink on violation | Extend r, shrink l until valid |
LC 3, 76, 209 |
| atMost K | Extend r, shrink l until
< K features |
LC 992, 1248 |
Backtracking = DFS combined with undoing state on the way back up. This is the pattern to enumerate every valid configuration (permutations, subsets, combinations, Sudoku, N-Queens). The secret to efficient backtracking is pruning — cut branches early once you know they can’t lead to a solution.
12 problems — full template plus 12 classics from Medium to Hard.
After this chapter, you will be able to:
choose → explore → unchoose mantra.if i > start and ...).start, check “budget” (remaining
count).Three components of backtracking: 1. Choice: what options exist at each step? 2. Constraint: which choices are valid? 3. Goal: when to stop and record the answer?
def backtrack(path, choices):
if is_goal(path):
result.append(path.copy())
return
for c in choices:
if not valid(c, path):
continue
path.append(c)
backtrack(path, next_choices(choices, c))
path.pop() # UNDO — the backtracking signatureGiven n and k, return all
combinations of k numbers chosen from
1..n.
Input: n = 4, k = 2
Output: [[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]]
(output order of combinations may vary)
Backtracking with start: at each step
pick a number ≥ start to avoid duplicates.
def backtrack(start):
if len(path) == k:
result.append(path.copy()); return
for i in range(start, n + 1):
path.append(i)
backtrack(i + 1)
path.pop()from typing import List
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result: list[list[int]] = []
path: list[int] = []
def backtrack(start: int) -> None:
if len(path) == k:
result.append(path.copy())
return
# Pruning: need (k - len(path)) more, max start = n - (k - len(path)) + 1.
for i in range(start, n - (k - len(path)) + 2):
path.append(i)
backtrack(i + 1)
path.pop()
backtrack(1)
return resultO(C(n, k) · k).O(k) for path + stack.start so we
don’t waste branches that can’t supply enough numbers.Given nums (may contain duplicates), return all
unique subsets.
Input: nums = [1, 2, 2] (has duplicates)
Output: [[], [1], [1,2], [1,2,2], [2], [2,2]]
(every unique subset; the output array order is flexible)
Sort nums. At each level, skip
duplicates with
if i > start and nums[i] == nums[i-1]: continue.
from typing import List
class Solution:
def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
nums.sort()
result: list[list[int]] = []
path: list[int] = []
def backtrack(start: int) -> None:
result.append(path.copy())
for i in range(start, len(nums)):
if i > start and nums[i] == nums[i - 1]:
continue
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return resultO(n · 2^n).O(n).i > start ensures the skip only applies within the same
recursive for loop, not between parent-child.Given nums with duplicates, return all
distinct permutations.
Input: nums = [1, 1, 2] (has duplicates)
Output: [[1,1,2], [1,2,1], [2,1,1]]
(every unique permutation)
Sort + skip duplicates. A used array. Trick: if
nums[i] == nums[i-1] and
nums[i-1] is unused → skip (forces consumption in index
order).
from typing import List
class Solution:
def permuteUnique(self, nums: List[int]) -> List[List[int]]:
nums.sort()
result: list[list[int]] = []
path: list[int] = []
used = [False] * len(nums)
def backtrack() -> None:
if len(path) == len(nums):
result.append(path.copy())
return
for i in range(len(nums)):
if used[i]:
continue
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue
used[i] = True
path.append(nums[i])
backtrack()
path.pop()
used[i] = False
backtrack()
return resultO(n · n!).O(n).not used[i-1] trick: only pick a
same-value element if its predecessor has already been used.used[i-1] also works — the
inverse logic.Given a digit string 2..9, return all letter
combinations from the classic phone keypad.
Input: digits = "23"
Output: ["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]
('2' → "abc"; '3' → "def"; order may vary)
Input: digits = ""
Output: []
Mapping {'2':'abc', ..., '9':'wxyz'}. Backtrack picks
one letter per digit.
from typing import List
class Solution:
MAP = {'2':'abc','3':'def','4':'ghi','5':'jkl','6':'mno','7':'pqrs','8':'tuv','9':'wxyz'}
def letterCombinations(self, digits: str) -> List[str]:
if not digits:
return []
result: list[str] = []
path: list[str] = []
def backtrack(i: int) -> None:
if i == len(digits):
result.append(''.join(path))
return
for ch in self.MAP[digits[i]]:
path.append(ch)
backtrack(i + 1)
path.pop()
backtrack(0)
return resultO(4^n · n) with n =
len(digits).O(n) stack.Given candidates (distinct) and target,
find every combination summing to target. Each number
can be reused.
Input: candidates=[2,3,6,7], target=7
Output: [[2,2,3],[7]]
Backtrack with start. Allow reuse →
recurse backtrack(i) instead of i+1.
from typing import List
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
candidates.sort()
result: list[list[int]] = []
path: list[int] = []
def backtrack(start: int, remain: int) -> None:
if remain == 0:
result.append(path.copy())
return
for i in range(start, len(candidates)):
if candidates[i] > remain:
break # sorted → everything after is larger
path.append(candidates[i])
backtrack(i, remain - candidates[i])
path.pop()
backtrack(0, target)
return resultO(N^(T/M + 1)) with N=candidate
count, T=target, M=min(candidates).O(T/M) stack.backtrack(i) (not
i+1) to allow reuse.Given s, return every partition of s such
that each piece is a palindrome.
Input: s = "aab"
Output: [["a","a","b"], ["aa","b"]]
(every partition where each piece is a palindrome; order may vary)
Backtrack: at each start, try every end
such that s[start..end] is a palindrome; recurse with
start = end + 1.
from typing import List
class Solution:
def partition(self, s: str) -> List[List[str]]:
result: list[list[str]] = []
path: list[str] = []
def is_pal(l: int, r: int) -> bool:
while l < r:
if s[l] != s[r]:
return False
l += 1; r -= 1
return True
def backtrack(start: int) -> None:
if start == len(s):
result.append(path.copy())
return
for end in range(start, len(s)):
if is_pal(start, end):
path.append(s[start:end + 1])
backtrack(end + 1)
path.pop()
backtrack(0)
return resultO(n · 2^n) (max partitions =
2^(n-1)).O(n).is_pal[i][j] in O(n²) to avoid redoing
palindrome checks.Place n queens on an n × n board so that no
two attack each other. Return all configurations (each as a list of
strings).
Input: n=4
Output: [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
Backtrack row by row. At row r, try
each column c. Ensure c is unused, diagonal
(r - c) unused, diagonal (r + c) unused.
from typing import List
class Solution:
def solveNQueens(self, n: int) -> List[List[str]]:
cols = set()
diag1 = set() # r - c
diag2 = set() # r + c
board = [['.'] * n for _ in range(n)]
result: List[List[str]] = []
def backtrack(r: int) -> None:
if r == n:
result.append([''.join(row) for row in board])
return
for c in range(n):
if c in cols or (r - c) in diag1 or (r + c) in diag2:
continue
cols.add(c); diag1.add(r - c); diag2.add(r + c)
board[r][c] = 'Q'
backtrack(r + 1)
board[r][c] = '.'
cols.discard(c); diag1.discard(r - c); diag2.discard(r + c)
backtrack(0)
return resultO(n!) worst.O(n²) for the board +
O(n) for the sets.Solve a 9×9 Sudoku in place. Empty cells are '.'.
Input: board = (9×9 matrix, '.' for empty, '1'..'9' for filled)
[['5','3','.','.','7','.','.','.','.'],
['6','.','.','1','9','5','.','.','.'],
['.','9','8','.','.','.','.','6','.'],
['8','.','.','.','6','.','.','.','3'],
['4','.','.','8','.','3','.','.','1'],
['7','.','.','.','2','.','.','.','6'],
['.','6','.','.','.','.','2','8','.'],
['.','.','.','4','1','9','.','.','5'],
['.','.','.','.','8','.','.','7','9']]
Output: board mutated in place to a valid solution
[['5','3','4','6','7','8','9','1','2'],
['6','7','2','1','9','5','3','4','8'],
['1','9','8','3','4','2','5','6','7'],
['8','5','9','7','6','1','4','2','3'],
['4','2','6','8','5','3','7','9','1'],
['7','1','3','9','2','4','8','5','6'],
['9','6','1','5','3','7','2','8','4'],
['2','8','7','4','1','9','6','3','5'],
['3','4','5','2','8','6','1','7','9']]
(Function returns nothing; only mutates `board`. Every row, column,
and 3×3 sub-grid contains '1'..'9' exactly once.)
Backtrack every empty cell. Track three sets: row, col, 3×3 box.
from typing import List
class Solution:
def solveSudoku(self, board: List[List[str]]) -> None:
rows = [set() for _ in range(9)]
cols = [set() for _ in range(9)]
boxes = [set() for _ in range(9)]
empty: list[tuple[int, int]] = []
for r in range(9):
for c in range(9):
if board[r][c] != '.':
d = board[r][c]
rows[r].add(d); cols[c].add(d); boxes[(r//3)*3 + c//3].add(d)
else:
empty.append((r, c))
def backtrack(i: int) -> bool:
if i == len(empty):
return True
r, c = empty[i]
b = (r // 3) * 3 + c // 3
for d in '123456789':
if d in rows[r] or d in cols[c] or d in boxes[b]:
continue
rows[r].add(d); cols[c].add(d); boxes[b].add(d)
board[r][c] = d
if backtrack(i + 1):
return True
rows[r].discard(d); cols[c].discard(d); boxes[b].discard(d)
board[r][c] = '.'
return False
backtrack(0)O(9^(empty cells)) worst.O(81).Given a board and word, check whether
word can be built from 4-directional paths without
revisiting a cell.
Input: board=[["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word="ABCCED"
Output: True
DFS from every cell with board[r][c] == word[0].
Backtrack: mark # then restore.
from typing import List
class Solution:
def exist(self, board: List[List[str]], word: str) -> bool:
rows, cols = len(board), len(board[0])
DIRS = [(-1,0),(1,0),(0,-1),(0,1)]
def dfs(r: int, c: int, i: int) -> bool:
if i == len(word):
return True
if not (0 <= r < rows and 0 <= c < cols) or board[r][c] != word[i]:
return False
board[r][c] = '#'
found = any(dfs(r+dr, c+dc, i+1) for dr, dc in DIRS)
board[r][c] = word[i]
return found
return any(dfs(r, c, 0) for r in range(rows) for c in range(cols))O(m·n · 4^L).O(L) stack.count(ch)
in board < count(ch) in word.Given s and wordDict, return every
way to split s into dictionary words (separated by
spaces).
Input: s="catsanddog", wordDict=["cat","cats","and","sand","dog"]
Output: ["cats and dog","cat sand dog"]
Backtrack with memoization (cache by start).
from functools import cache
from typing import List
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> List[str]:
words = set(wordDict)
@cache
def helper(start: int) -> List[str]:
if start == len(s):
return [""]
result = []
for end in range(start + 1, len(s) + 1):
if s[start:end] in words:
for rest in helper(end):
result.append(s[start:end] + ("" if not rest else " " + rest))
return result
return helper(0)O(n^2 · 2^n) worst.O(2^n) cache.O(n²).Given a digit string, list every valid IP (4 numbers 0..255; no leading zeros unless the number itself is “0”).
Input: s = "25525511135" (digits only)
Output: ["255.255.11.135", "255.255.111.35"]
(every valid IPv4 formed by inserting 3 dots; no leading zeros)
Backtrack splitting into 4 parts. Each part has length 1, 2, or 3 and satisfies the constraints.
from typing import List
class Solution:
def restoreIpAddresses(self, s: str) -> List[str]:
result: list[str] = []
path: list[str] = []
def is_valid(p: str) -> bool:
if len(p) > 3 or not p:
return False
if p[0] == '0' and len(p) > 1:
return False
return int(p) <= 255
def backtrack(start: int) -> None:
if len(path) == 4:
if start == len(s):
result.append('.'.join(path))
return
for length in (1, 2, 3):
if start + length > len(s):
break
p = s[start:start + length]
if is_valid(p):
path.append(p)
backtrack(start + length)
path.pop()
backtrack(0)
return resultO(3^4) = O(81) constant.O(1)."010" is invalid;
"0" is valid.Given a digit string num and target, insert
+, -, * between digits so the
expression evaluates to target. Return every such
expression.
Input: num="123", target=6
Output: ["1+2+3","1*2*3"]
Backtrack with two auxiliary states: - cur = expression
value so far. - prev = value of the last term (needed for
* precedence).
On +: cur += x, prev = x. On
-: cur -= x, prev = -x. On
*: cur = cur - prev + prev * x,
prev = prev * x.
from typing import List
class Solution:
def addOperators(self, num: str, target: int) -> List[str]:
result: list[str] = []
def backtrack(i: int, expr: str, cur: int, prev: int) -> None:
if i == len(num):
if cur == target:
result.append(expr)
return
for j in range(i + 1, len(num) + 1):
if j > i + 1 and num[i] == '0':
break # leading zero
x = int(num[i:j])
if i == 0:
backtrack(j, str(x), x, x)
else:
backtrack(j, expr + '+' + str(x), cur + x, x)
backtrack(j, expr + '-' + str(x), cur - x, -x)
backtrack(j, expr + '*' + str(x), cur - prev + prev * x, prev * x)
backtrack(0, "", 0, 0)
return resultO(4^n) worst case (3 operators +
skip).O(n) stack.cur - prev + prev * x: undo the
previous +/- (via prev), then multiply.| Component | Questions to answer before coding |
|---|---|
| Path (current state) | List / string / mask? |
| Choice list | From n elements, indices, or digits 1..9? |
| Goal test | When to record / return? |
| Pruning / constraints | Can we cut early? Sort first to skip duplicates? |
| Undo | Pop list, remove from set, restore mask? |
[1,2,3]) []
/ | \
1 2 3
/ | | \ | \
2 3 1 3 1 2
| | | | | |
3 2 3 1 2 1
Each root→leaf path = one permutation. Backtrack = DFS on this imaginary tree.
Subsets II (sort + same-level dup skip):
nums.sort()
for i in range(start, n):
if i > start and nums[i] == nums[i-1]: continue # same level, skip
path.append(nums[i])
dfs(i + 1)
path.pop()
Permutations II (used array):
nums.sort()
for i in range(n):
if used[i]: continue
if i > 0 and nums[i] == nums[i-1] and not used[i-1]: continue # preserve order
cols : set[int]./: r + c constant →
pos_diag : set[int].\: r - c constant →
neg_diag : set[int].rows[r], cols[c], boxes[(r//3)*3 + c//3].prev_operandBecause * has higher precedence than +/-,
when multiplying we must retract prev then
multiply:
cur_total - prev_operand + prev_operand * num
Dynamic Programming (DP) = recursion + memoization (or bottom-up). Requires two properties: optimal substructure (the optimum of a big problem is built from the optima of its subproblems) + overlapping subproblems (subproblems repeat). The biggest chapter in the book — 18 problems split into 3 groups:
- DP I (29.1–29.6): LCS, LIS, Edit Distance, Knapsack — classic 2D DP.
- DP II (29.7–29.12): Coin Change, Stock Trading, House Robber — DP along the time axis.
- DP III (29.13–29.18): Partition / Interval DP — DP over intervals.
After this chapter, you will be able to:
@cache) vs bottom-up
(table).The longest chapter in the book. To stay oriented, every problem fits one of four families:
| Family | Problems (LC) | Common feature |
|---|---|---|
| DP I — Sequence DP (2D) | 29.1 LCS, 29.2 LIS, 29.3 Edit Distance | State = (i, j) over two prefixes |
| DP I — Knapsack | 29.4 0/1 Knapsack, 29.5 Partition Equal Sum, 29.6 Russian Doll | State = (i, capacity) |
| DP II — Linear DP | 29.7 Coin Change, 29.8 Coin Change II, 29.9 Stock Cooldown, 29.10 Stock IV, 29.11 House Robber II, 29.12 Max Product | State = dp[i] or dp[i][k] |
| DP III — Interval / Partition DP | 29.13 Palindrome Partition II, 29.14 Burst Balloons, 29.15 MCM, 29.16 Cut Stick, 29.17 Stone Game VII, 29.18 Strange Printer | State = dp[l][r], split at k |
When you meet a DP problem, the four core questions are: (1) What is the state? (2) What is the transition? (3) Base case? (4) Where is the answer?
| Problem | State | Transition | Base | Answer |
|---|---|---|---|---|
| 29.1 LCS | dp[i][j] = LCS of s1[..i],
s2[..j] |
Match: dp[i-1][j-1]+1; Else:
max(dp[i-1][j], dp[i][j-1]) |
dp[0][j] = dp[i][0] = 0 |
dp[m][n] |
| 29.2 LIS | tails[k] = smallest tail of an LIS of length
k+1 |
bisect_left + replace/append |
tails = [] |
len(tails) |
| 29.3 Edit Distance | dp[i][j] = min edits
s1[..i] → s2[..j] |
Match: dp[i-1][j-1]; Else:
1 + min(3 ways) |
dp[i][0]=i, dp[0][j]=j |
dp[m][n] |
| 29.4 0/1 Knapsack | dp[w] = max value with capacity w |
dp[w] = max(dp[w], dp[w-wi]+vi) (iterate
descending) |
dp[0..W] = 0 |
dp[W] |
| 29.5 Partition Equal Sum | dp[w] = is there a subset summing to w? |
dp[w] = dp[w] or dp[w-x] |
dp[0] = True |
dp[sum/2] |
| 29.6 Russian Doll | Sort 2D + LIS on h |
Same as LIS (29.2) | — | len(tails) |
| 29.7 Coin Change | dp[a] = min coins summing to a |
dp[a] = min(dp[a-c]+1) |
dp[0] = 0 |
dp[amount] |
| 29.8 Coin Change II | dp[a] = number of ways |
dp[a] += dp[a-c] (outer = coin) |
dp[0] = 1 |
dp[amount] |
| 29.9 Stock Cooldown | hold[i], sold[i], rest[i] |
3 transitions between states | hold[0] = -prices[0] |
max(sold[-1], rest[-1]) |
| 29.10 Stock IV | dp[t][i] = max profit ≤ t txns by day i |
max(dp[t][i-1], price[i]+max_diff) |
dp[0][i] = 0 |
dp[k][n-1] |
| 29.11 House Robber II | Split into 2 cases (rob 0 / not) | Same as Robber I | — | max(case_1, case_2) |
| 29.12 Max Product | rolling cur_max, cur_min |
Swap on x < 0 |
Start = nums[0] |
max(best) |
| 29.13 Palindrome Partition II | dp[i] = min cuts for s[..i] |
dp[i] = min(dp[j-1]+1) if s[j..i]
palindrome |
dp[i]=0 if s[0..i] palindrome |
dp[n-1] |
| 29.14 Burst Balloons | dp[i][j] = max coins on (i, j)
exclusive |
max(arr[i]*arr[k]*arr[j] + dp[i][k] + dp[k][j]) |
dp[i][i+1] = 0 |
dp[0][n-1] |
| 29.15 MCM | dp[i][j] = min multiplications Ai..Aj |
min(dp[i][k]+dp[k+1][j]+p[i-1]*p[k]*p[j]) |
dp[i][i]=0 |
dp[1][n] |
| 29.16 Cut Stick | dp[i][j] = min cost cuts in
(cuts[i], cuts[j]) |
min(dp[i][k]+dp[k][j]) + cuts[j]-cuts[i] |
dp[i][i+1]=0 |
dp[0][m-1] |
| 29.17 Stone Game VII | dp[l][r] = max diff for the current player |
max(sum-stones[l] - dp[l+1][r], sum-stones[r] - dp[l][r-1]) |
dp[i][i]=0 |
dp[0][n-1] |
| 29.18 Strange Printer | dp[i][j] = min turns in s[i..j] |
min(dp[i][j-1]+1, dp[i][k] + dp[k+1][j-1] when s[k]==s[j]) |
dp[i][i]=1 |
dp[0][n-1] |
If you have only one day, follow this priority:
3 steps to build a DP: 1. Define
state: dp[i][j] = ? 2.
Transition:
dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...) 3. Base
case + iteration order: small → large.
Top-down (memo) vs Bottom-up (tabulation): -
Top-down: write recursion + @cache. Easy to see the
transition. - Bottom-up: iterate from small. Saves stack and is easier
to space- optimise.
from functools import cache
# 1) Top-down
@cache
def dp(*state):
if base_condition(*state):
return base_value
return combine([dp(*sub_state) for sub_state in transitions(*state)])
# 2) Bottom-up 2D
dp = [[0] * cols for _ in range(rows)]
for i in range(rows):
for j in range(cols):
if base(i, j):
dp[i][j] = base_value
else:
dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...)
return dp[-1][-1]
# 3) Space-optimised 1D (transition uses only the previous row)
prev = [0] * cols
for i in range(rows):
cur = [0] * cols
for j in range(cols):
cur[j] = f(prev[j], cur[j-1], ...)
prev = curGiven strings s1, s2, find the length of the
LCS (longest common subsequence).
Input: text1 = "abcde", text2 = "ace"
Output: 3
(longest LCS is "ace")
Input: text1 = "abc", text2 = "def"
Output: 0
(no common characters)
dp[i][j] = LCS of s1[0..i-1] and
s2[0..j-1].
s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1.dp[i][j] = max(dp[i-1][j], dp[i][j-1]).class Solution:
def longestCommonSubsequence(self, s1: str, s2: str) -> int:
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n]O(m · n).O(m · n); can be reduced to
O(min(m, n)).O(min(m,n)): keep just 2
rows.O(n log n)Find the length of the strictly-increasing LIS of
nums.
Input: nums = [10, 9, 2, 5, 3, 7, 101, 18]
Output: 4
(one longest LIS: [2, 3, 7, 18]; multiple exist)
Input: nums = [0, 1, 0, 3, 2, 3]
Output: 4
(one longest LIS: [0, 1, 2, 3])
< not
≤).O(n²) DP —
dp[i] = max(dp[j]) + 1 for
j < i, nums[j] < nums[i].
O(n log n) — Patience
Sort: - tails[i] = smallest tail value of an LIS
of length i + 1. - Walk nums, use
bisect_left to locate replace/extend index.
from bisect import bisect_left
from typing import List
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
tails: list[int] = []
for x in nums:
i = bisect_left(tails, x)
if i == len(tails):
tails.append(x)
else:
tails[i] = x
return len(tails)O(n log n).O(n).tails is not the actual LIS — only the
length is correct.Given word1, word2, return the minimum number of
operations (insert, delete, replace) to convert
word1 → word2.
Input: word1="horse", word2="ros"
Output: 3
Explanation: horse → rorse → rose → ros (3 ops)
dp[i][j] = edit distance between the
i-prefix of word1 and the
j-prefix of word2.
word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1].dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
(delete, insert, replace).class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1): dp[i][0] = i
for j in range(n + 1): dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
return dp[m][n]O(m · n).O(m · n); can be reduced to
O(min(m, n)).dp[i][0] = i, dp[0][j] = j.Given n items, each with weight[i] and
value[i]. Bag capacity W. Choose items to
maximise total value, each item used ≤ once.
Input: weights=[1,3,4,5], values=[1,4,5,7], W=7
Output: 9
dp[i][w] = max value using the first i
items with capacity w.
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i-1]] + value[i-1])
(if it fits).
from typing import List
def knapsack_01(weights: List[int], values: List[int], W: int) -> int:
n = len(weights)
dp = [0] * (W + 1)
for i in range(n):
# Iterate descending so each item is used ≤ once.
for w in range(W, weights[i] - 1, -1):
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[W]O(n · W).O(W) after the rolling
array.Given positive nums, can it be split into two subsets
with equal sums?
Input: nums = [1, 5, 11, 5]
Output: True
(splits into [1,5,5] and [11], each sum 11)
Let S = sum(nums). If S is odd → False.
Find a subset summing to S / 2 → boolean knapsack
DP.
dp[w] = True/False (is there a subset summing to
w?).
from typing import List
class Solution:
def canPartition(self, nums: List[int]) -> bool:
total = sum(nums)
if total % 2: return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for x in nums:
for w in range(target, x - 1, -1):
dp[w] = dp[w] or dp[w - x]
return dp[target]O(n · sum/2).O(sum/2).Given envelopes[i] = [w, h]. Envelope A
fits inside B if A.w < B.w and
A.h < B.h. Find the maximum chain length.
Input: envelopes = [[5,4], [6,4], [6,7], [2,3]]
(each entry [width, height])
Output: 3 (chain [2,3] ⊂ [5,4] ⊂ [6,7])
“2D sort” trick: sort by w ascending,
h descending for equal w (so
two envelopes with the same w don’t nest). Then run LIS on the
h sequence.
from bisect import bisect_left
from typing import List
class Solution:
def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
envelopes.sort(key=lambda e: (e[0], -e[1]))
tails: list[int] = []
for _, h in envelopes:
i = bisect_left(tails, h)
if i == len(tails):
tails.append(h)
else:
tails[i] = h
return len(tails)O(n log n).O(n).-e[1] trick: with equal
w, h descending → equal w doesn’t produce a fake LIS on
h.Given coin denominations and amount, find the
minimum number of coins summing to amount.
Return -1 if impossible.
Input: coins=[1,2,5], amount=11
Output: 3
Explanation: 11 = 5+5+1
dp[a] = min coins summing to a.
dp[0] = 0.
dp[a] = min(dp[a - c] + 1 for c in coins if c <= a).
from typing import List
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
INF = amount + 1
dp = [0] + [INF] * amount
for a in range(1, amount + 1):
for c in coins:
if c <= a:
dp[a] = min(dp[a], dp[a - c] + 1)
return -1 if dp[amount] > amount else dp[amount]O(amount · n_coins).O(amount).amount + 1 as sentinel.Count the number of ways to sum amount using unbounded
coins.
Input: amount=5, coins=[1,2,5]
Output: 4
dp[a] = number of ways to sum a.
Loop order matters: outer loop coins, inner loop amount → counts each combination once (no over-count).
from typing import List
class Solution:
def change(self, amount: int, coins: List[int]) -> int:
dp = [0] * (amount + 1)
dp[0] = 1
for c in coins:
for a in range(c, amount + 1):
dp[a] += dp[a - c]
return dp[amount]O(amount · n_coins).O(amount).Buy/sell multiple times; after selling you must cooldown for one day before buying again.
Input: prices = [1, 2, 3, 0, 2] (prices[i] = price on day i)
Output: 3 (transactions: buy@1 sell@2; cooldown; buy@0 sell@2)
Three states per day: hold (currently holding),
sold (just sold today), rest (idle).
hold[i] = max(hold[i-1], rest[i-1] - price[i])sold[i] = hold[i-1] + price[i]rest[i] = max(rest[i-1], sold[i-1])Answer = max(sold[-1], rest[-1]).
from typing import List
class Solution:
def maxProfit(self, prices: List[int]) -> int:
if not prices: return 0
hold = -prices[0]
sold = 0
rest = 0
for p in prices[1:]:
prev_sold = sold
sold = hold + p
hold = max(hold, rest - p)
rest = max(rest, prev_sold)
return max(sold, rest)O(n).O(1).sold must wait one day before becoming
hold again.At most k transactions. Find the max profit.
Input: k=2, prices=[3,2,6,5,0,3]
Output: 7
dp[t][i] = max profit using ≤ t
transactions through day i.
dp[t][i] = max(dp[t][i-1], max(price[i] - price[j] + dp[t-1][j-1]) for j < i).
Optimisation: track
max_diff = max(dp[t-1][j-1] - price[j]) while
iterating.
from typing import List
class Solution:
def maxProfit(self, k: int, prices: List[int]) -> int:
n = len(prices)
if n == 0 or k == 0: return 0
if k >= n // 2:
# Unlimited transactions.
return sum(max(prices[i] - prices[i - 1], 0) for i in range(1, n))
dp = [[0] * n for _ in range(k + 1)]
for t in range(1, k + 1):
max_diff = -prices[0]
for i in range(1, n):
dp[t][i] = max(dp[t][i - 1], prices[i] + max_diff)
max_diff = max(max_diff, dp[t - 1][i] - prices[i])
return dp[k][n - 1]O(n · k).O(n · k); can be reduced to
O(k).k >= n // 2 → reduce to
unlimited (LC 122).The thief cannot rob two adjacent houses; houses are arranged in a
circle (nums[0] and nums[n-1]
are neighbours).
Input: nums = [2, 3, 2] (money at each house; circular layout)
Output: 3 (can rob only one of the two end houses; here rob the 3)
Split into two cases: rob or skip the first house. -
Case 1: rob house 0 → cannot rob house n-1 → House Robber on
nums[0..n-2]. - Case 2: skip house 0 → House Robber on
nums[1..n-1].
Answer = max of the two.
from typing import List
class Solution:
def rob(self, nums: List[int]) -> int:
def rob_linear(arr: List[int]) -> int:
prev = curr = 0
for x in arr:
prev, curr = curr, max(curr, prev + x)
return curr
if len(nums) == 1: return nums[0]
return max(rob_linear(nums[:-1]), rob_linear(nums[1:]))O(n).O(1).Given nums, find the contiguous subarray with the
maximum product.
Input: nums = [2, 3, -2, 4]
Output: 6 (subarray [2, 3] has product 6, the largest contiguous-subarray product)
Track both max and min at each i (because two
negatives multiply to a positive): -
cur_max = max(x, x * prev_max, x * prev_min) -
cur_min = min(x, x * prev_max, x * prev_min)
from typing import List
class Solution:
def maxProduct(self, nums: List[int]) -> int:
cur_max = cur_min = best = nums[0]
for x in nums[1:]:
if x < 0:
cur_max, cur_min = cur_min, cur_max
cur_max = max(x, cur_max * x)
cur_min = min(x, cur_min * x)
best = max(best, cur_max)
return bestO(n).O(1).x < 0” trick: a
negative number flips max/min roles → concise.Given s, find the minimum number of cuts to split
s into all palindromic pieces.
Input: s = "aab"
Output: 1
(cut once into ["aa", "b"], two palindromic pieces)
Explanation: cut into ["aa", "b"]
dp[i] = min cuts for s[0..i]. Pre-compute
is_pal[i][j]. Transition: - If s[0..i] is
already a palindrome → dp[i] = 0. - Else:
dp[i] = min(dp[j-1] + 1 for j <= i if s[j..i] is palindrome).
class Solution:
def minCut(self, s: str) -> int:
n = len(s)
is_pal = [[False] * n for _ in range(n)]
for i in range(n):
for j in range(i + 1):
if s[j] == s[i] and (i - j < 2 or is_pal[j + 1][i - 1]):
is_pal[j][i] = True
dp = [0] * n
for i in range(n):
if is_pal[0][i]:
dp[i] = 0
else:
dp[i] = min(dp[j - 1] + 1 for j in range(1, i + 1) if is_pal[j][i])
return dp[n - 1]O(n²) (palindrome precompute) +
O(n²) DP.O(n²).is_palindrome to
avoid checking it inside the DP loop.Given nums representing balloons. Bursting balloon
i earns nums[i-1] * nums[i] * nums[i+1]
(boundaries treated as 1). Maximise the total score.
Input: nums = [3, 1, 5, 8] (balloons left to right)
Output: 167 (max coins by bursting everything; each burst i earns nums[L]*nums[i]*nums[R])
Inverted interval DP: instead of asking “which to
burst first?”, ask “which is the last balloon burst
inside (i, j)?”.
dp[i][j] = max score from bursting all balloons in (i,
j) exclusive.
dp[i][j] = max(nums[i] * nums[k] * nums[j] + dp[i][k] + dp[k][j])
for k ∈ (i, j).
Pad nums with 1 on both ends.
from typing import List
class Solution:
def maxCoins(self, nums: List[int]) -> int:
arr = [1] + nums + [1]
n = len(arr)
dp = [[0] * n for _ in range(n)]
for length in range(2, n):
for i in range(n - length):
j = i + length
for k in range(i + 1, j):
dp[i][j] = max(dp[i][j],
arr[i] * arr[k] * arr[j] + dp[i][k] + dp[k][j])
return dp[0][n - 1]O(n³).O(n²).Given n matrices A1 · A2 · ... · An with
dimensions p[i-1] × p[i]. Find the optimal parenthesisation
minimising the number of multiplications.
Input: p=[10,20,30,40,30]
Output: 30000
dp[i][j] = min cost to multiply
A_i · A_{i+1} · ... · A_j. Split at k:
dp[i][j] = min(dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j]).
from typing import List
def matrix_chain(p: List[int]) -> int:
n = len(p) - 1
dp = [[0] * (n + 1) for _ in range(n + 1)]
for length in range(2, n + 1):
for i in range(1, n - length + 2):
j = i + length - 1
dp[i][j] = float('inf')
for k in range(i, j):
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j])
return dp[1][n]O(n³).O(n²).A stick of length n. Array cuts lists the
positions to cut. Each cut costs the current stick length. Find the
minimum total cost.
Input: n=7, cuts=[1,3,4,5]
Output: 16
Sort cuts and add boundaries [0, n].
dp[i][j] = min cost to cut the stick between
cuts[i] and cuts[j]. Try every cut
k ∈ (i, j):
dp[i][j] = min(dp[i][k] + dp[k][j] + cuts[j] - cuts[i]).
from typing import List
class Solution:
def minCost(self, n: int, cuts: List[int]) -> int:
cuts = sorted([0] + cuts + [n])
m = len(cuts)
dp = [[0] * m for _ in range(m)]
for length in range(2, m):
for i in range(m - length):
j = i + length
dp[i][j] = min(dp[i][k] + dp[k][j] for k in range(i + 1, j)) + cuts[j] - cuts[i]
return dp[0][m - 1]O(m³) where m = len(cuts) +
2.O(m²).[0, n] → lose edge info.Alice and Bob alternately pick stones from either
end of the array. The player scores the sum of the
remaining stones. Both play optimally. Find
Alice - Bob.
Input: stones = [5, 3, 1, 4, 2] (per-stone values; only ends are pickable)
Output: 6 (Alice - Bob with both playing optimally)
Game theory + Interval DP. dp[i][j] = optimal difference
when considering stones [i..j]. The current player picks
gain sum[i+1..j] (drop stones[i]) or
sum[i..j-1] (drop stones[j]) minus
dp[next][next].
from functools import cache
from typing import List
class Solution:
def stoneGameVII(self, stones: List[int]) -> int:
n = len(stones)
prefix = [0] * (n + 1)
for i, s in enumerate(stones):
prefix[i + 1] = prefix[i] + s
@cache
def dp(i: int, j: int) -> int:
if i >= j: return 0
# Remove stones[i]: gain = sum of [i+1..j]
gain_l = prefix[j + 1] - prefix[i + 1]
# Remove stones[j]: gain = sum of [i..j-1]
gain_r = prefix[j] - prefix[i]
return max(gain_l - dp(i + 1, j), gain_r - dp(i, j - 1))
return dp(0, n - 1)O(n²).O(n²) memo.Given s, a printer prints in turns; each turn prints a
substring of identical characters that may overwrite a previous one.
Find the minimum number of turns to print s.
Input: s = "aaabbb"
Output: 2 (the printer prints only one run of same char per turn; min 2 turns)
Interval DP. dp[i][j] = min turns to
print s[i..j].
dp[i][i] = 1.dp[i][j] = dp[i][j-1] + 1 (print s[j] alone).k < j with s[k] == s[j],
we can “reuse” the print of s[k] to cover s[j] →
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j-1]).class Solution:
def strangePrinter(self, s: str) -> int:
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n):
dp[i][i] = 1
for length in range(2, n + 1):
for i in range(n - length + 1):
j = i + length - 1
dp[i][j] = dp[i][j - 1] + 1
for k in range(i, j):
if s[k] == s[j]:
cost = dp[i][k] + (dp[k + 1][j - 1] if k + 1 <= j - 1 else 0)
dp[i][j] = min(dp[i][j], cost)
return dp[0][n - 1]O(n³).O(n²).k transactions.Burst Balloons / Strange Printer / Matrix Chain: -
dp[i][j] = optimal answer for range [i..j]. -
Ask: which operation is performed LAST in this range?
Choice k ∈ [i..j] splits into [i..k-1] and
[k+1..j] already solved. - Differs from “pick first” — most
of the time “pick last” gives the cleanest recurrence.
"" |
a |
b |
c |
d |
e |
|
|---|---|---|---|---|---|---|
"" |
0 | 0 | 0 | 0 | 0 | 0 |
a |
0 | 1 | 1 | 1 | 1 | 1 |
c |
0 | 1 | 1 | 2 | 2 | 2 |
e |
0 | 1 | 1 | 2 | 2 | 3 |
s1[i] == s2[j]) ⇒
dp[i][j] = dp[i-1][j-1] + 1.dp[i][j] = max(dp[i-1][j], dp[i][j-1])."horse" → "ros""" |
r |
o |
s |
|
|---|---|---|---|---|
"" |
0 | 1 | 2 | 3 |
h |
1 | 1 | 2 | 3 |
o |
2 | 2 | 1 | 2 |
r |
3 | 2 | 2 | 2 |
s |
4 | 3 | 3 | 2 |
e |
5 | 4 | 4 | 3 |
Three operations min(insert, delete, replace) + 1;
matching characters inherit diagonally.
Dijkstra finds the shortest path from one source to every vertex in a graph with non-negative edge weights. With negative edges → Bellman-Ford. With uniform weights (= 1) → BFS already suffices. Heap-based Dijkstra runs in
O((V + E) log V).
After this chapter, you will be able to:
if d > dist[u]: continue) instead
of priority updates.k+1
rounds instead of Dijkstra.import heapq
from collections import defaultdict
def dijkstra(graph: dict, source: int, n: int) -> list[float]:
INF = float('inf')
dist = [INF] * n
dist[source] = 0
heap = [(0, source)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]: # outdated entry
continue
for v, w in graph[u]:
nd = d + w
if nd < dist[v]:
dist[v] = nd
heapq.heappush(heap, (nd, v))
return distGiven times[i] = [u, v, w] (directed weighted),
n nodes, source k. Find the time for
the signal to reach every node, or -1.
Input: times=[[2,1,1],[2,3,1],[3,4,1]], n=4, k=2
Output: 2
Dijkstra from k. Answer = max of
dist[].
import heapq
from collections import defaultdict
from typing import List
class Solution:
def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int:
graph = defaultdict(list)
for u, v, w in times:
graph[u].append((v, w))
INF = float('inf')
dist = {i: INF for i in range(1, n + 1)}
dist[k] = 0
heap = [(0, k)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]: continue
for v, w in graph[u]:
if d + w < dist[v]:
dist[v] = d + w
heapq.heappush(heap, (d + w, v))
ans = max(dist.values())
return ans if ans < INF else -1O((V + E) log V).O(V + E).if d > dist[u]: continue) is idiomatic — no priority
updates required.A heights grid. Walk from (0,0) to
(m-1,n-1) (4 directions). The effort of a
path = max |h(u) - h(v)| across its edges. Find the minimum
effort.
Input: heights = [[1,2,2],
[3,8,2],
[5,3,5]] (m×n grid, 4-dir walk (0,0) → (m-1,n-1))
Output: 2 (min effort = max |h[u] - h[v]| on the best path)
Dijkstra with “path weight” = max edge weight
instead of sum. nd = max(d, |h(u) - h(v)|).
import heapq
from typing import List
class Solution:
def minimumEffortPath(self, heights: List[List[int]]) -> int:
rows, cols = len(heights), len(heights[0])
INF = float('inf')
effort = [[INF] * cols for _ in range(rows)]
effort[0][0] = 0
heap = [(0, 0, 0)] # (effort, r, c)
DIRS = [(-1,0),(1,0),(0,-1),(0,1)]
while heap:
e, r, c = heapq.heappop(heap)
if (r, c) == (rows - 1, cols - 1):
return e
if e > effort[r][c]: continue
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols:
ne = max(e, abs(heights[nr][nc] - heights[r][c]))
if ne < effort[nr][nc]:
effort[nr][nc] = ne
heapq.heappush(heap, (ne, nr, nc))
return 0O(R · C · log(R · C)).O(R · C).Given flights[i] = [u, v, price], n nodes,
source src, destination dst, k
stops. Find the cheapest flight using ≤ k stops.
Input: n=3, flights=[[0,1,100],[1,2,100],[0,2,500]], src=0, dst=2, k=1
Output: 200
Bellman-Ford with k + 1 iterations is
the cleanest approach.
Dijkstra also works, but the state must expand to
(cost, node, stops) — more complex due to 2D state.
from typing import List
class Solution:
def findCheapestPrice(self, n: int, flights: List[List[int]], src: int, dst: int, k: int) -> int:
INF = float('inf')
dist = [INF] * n
dist[src] = 0
for _ in range(k + 1):
new_dist = dist.copy()
for u, v, p in flights:
if dist[u] + p < new_dist[v]:
new_dist[v] = dist[u] + p
dist = new_dist
return dist[dst] if dist[dst] < INF else -1O(k · E) (Bellman-Ford k+1
iterations).O(V).new_dist trap: must use a copy to
avoid in-round propagation.Fully solved in Chapter 24.6 (offline DSU). Also solvable with Dijkstra: path weight = max elevation encountered.
import heapq
class Solution:
def swimInWater(self, grid):
n = len(grid)
dist = [[float('inf')] * n for _ in range(n)]
dist[0][0] = grid[0][0]
heap = [(grid[0][0], 0, 0)]
DIRS = [(-1,0),(1,0),(0,-1),(0,1)]
while heap:
d, r, c = heapq.heappop(heap)
if (r, c) == (n-1, n-1): return d
for dr, dc in DIRS:
nr, nc = r+dr, c+dc
if 0 <= nr < n and 0 <= nc < n:
nd = max(d, grid[nr][nc])
if nd < dist[nr][nc]:
dist[nr][nc] = nd
heapq.heappush(heap, (nd, nr, nc))
return -1O((V + E) log V).O(V + E).Input: grid: List[List[int]] (m × n, 0 =
walkable, 1 = obstacle) and integer k. Walk 4-directionally
from (0,0) → (m-1, n-1), allowed to break
at most k obstacles. Find the shortest
path from (0,0) to (m-1,n-1).
Input: grid=[[0,0,0],[1,1,0],[0,0,0],[0,1,1],[0,0,0]], k=1
Output: 6
rows + cols - 2 shortcut.BFS with state
(r, c, eliminations_left) (because the edge weight
is 1). Dijkstra also works but is overkill.
from collections import deque
from typing import List
class Solution:
def shortestPath(self, grid: List[List[int]], k: int) -> int:
rows, cols = len(grid), len(grid[0])
if rows == 1 and cols == 1: return 0
# Optimisation: large k → plain BFS.
if k >= rows + cols - 2:
return rows + cols - 2
visited = set([(0, 0, k)])
queue = deque([(0, 0, k, 0)]) # (r, c, eliminations, steps)
DIRS = [(-1,0),(1,0),(0,-1),(0,1)]
while queue:
r, c, e, steps = queue.popleft()
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if not (0 <= nr < rows and 0 <= nc < cols): continue
ne = e - grid[nr][nc]
if ne < 0: continue
if (nr, nc) == (rows - 1, cols - 1): return steps + 1
if (nr, nc, ne) not in visited:
visited.add((nr, nc, ne))
queue.append((nr, nc, ne, steps + 1))
return -1O(R · C · k) states with k =
eliminations.O(R · C · k).eliminations_left to
avoid revisiting.A grid where each cell has an arrow (4 directions). Following an
arrow is free; flipping an arrow costs 1. Find the min cost so that a
valid path exists from (0,0) to (m-1,n-1).
Input: grid = [[1,1,1,1],
[2,2,2,2],
[1,1,1,1],
[2,2,2,2]]
(1=right, 2=left, 3=down, 4=up; following arrows is free, changing arrow costs 1)
Output: 3 (need to flip 3 cells to create a valid path)
0-1 BFS (Dijkstra with a deque): - “Follow arrow” edge = 0 → push to the front of the deque. - “Flip arrow” edge = 1 → push to the back.
O(V + E).
from collections import deque
from typing import List
class Solution:
def minCost(self, grid: List[List[int]]) -> int:
rows, cols = len(grid), len(grid[0])
INF = float('inf')
dist = [[INF] * cols for _ in range(rows)]
dist[0][0] = 0
# 1: right, 2: left, 3: down, 4: up
DIRS = {1: (0,1), 2: (0,-1), 3: (1,0), 4: (-1,0)}
dq = deque([(0, 0, 0)]) # (cost, r, c)
while dq:
cost, r, c = dq.popleft()
if cost > dist[r][c]: continue
for d, (dr, dc) in DIRS.items():
nr, nc = r + dr, c + dc
if not (0 <= nr < rows and 0 <= nc < cols): continue
nc_cost = cost + (0 if grid[r][c] == d else 1)
if nc_cost < dist[nr][nc]:
dist[nr][nc] = nc_cost
if grid[r][c] == d:
dq.appendleft((nc_cost, nr, nc))
else:
dq.append((nc_cost, nr, nc))
return dist[rows - 1][cols - 1]O(V + E) (0-1 BFS with
deque).O(V).O(V + E).| Graph property | Algorithm | Time |
|---|---|---|
| Unweighted (every edge = 1) | BFS | O(V + E) |
| Edge weight ∈ {0, 1} | 0-1 BFS (deque, push_front for 0, push_back for 1) | O(V + E) |
| Edge weight ≥ 0 | Dijkstra (heap) | O((V + E) log V) |
| Negative edges, no negative cycle | Bellman-Ford | O(V · E) |
| Negative edges, must detect negative cycle | Bellman-Ford + V-th loop check | O(V · E) |
| All-pairs, V ≤ 500 | Floyd-Warshall | O(V³) |
| DAG | Topo + relax | O(V + E) |
dist[node].K+1 iterations (each iteration
relaxes ≤ 1 more edge).O(R · C), no heap log factor.Game theory in coding interviews means two players alternating turns, each playing optimally. Common pattern: DP minimax — at each state the player to move tries to maximise their own score or minimise their opponent’s. When the state graph has cycles → use BFS multi-source from terminal states.
After this chapter, you will be able to:
diff(l, r) pattern: max score difference for
the current player.from functools import cache
@cache
def dp(state, is_alice_turn):
if terminal(state):
return score(state)
if is_alice_turn:
return max(dp(next_state(s), False) - delta for s in moves(state))
else:
return min(dp(next_state(s), True) + delta for s in moves(state))There are n stones. Each turn, take 1, 2, or 3 stones.
Whoever takes the last stone wins. Alice plays first.
Does Alice win?
Input: n=4
Output: False
Observation (induction): - n ∈ {1,2,3}:
Alice takes them all → wins. - n == 4: whatever Alice takes
(1/2/3), Bob faces {1,2,3} → Bob wins. - General: Alice
wins ↔︎ n % 4 != 0.
class Solution:
def canWinNim(self, n: int) -> bool:
return n % 4 != 0O(1).O(1).Array piles (even length, odd total). Alice and Bob
alternately take a pile from either end. Alice first.
Both play optimally. Does Alice win?
Input: piles = [5, 3, 4, 5] (even number of piles, odd total; only ends are pickable)
Output: True (Alice always wins under optimal play)
Math trick: with n even, Alice can adopt the strategy of always taking even-indexed piles or always odd-indexed, picking whichever group has the bigger total. Since the total is odd, one of the groups exceeds half.
→ Alice always wins.
from typing import List
class Solution:
def stoneGame(self, piles: List[int]) -> bool:
return True
class SolutionDP:
"""DP version — the canonical pattern for variants."""
def stoneGame(self, piles: List[int]) -> bool:
from functools import cache
n = len(piles)
@cache
def diff(l: int, r: int) -> int:
if l > r: return 0
return max(piles[l] - diff(l + 1, r), piles[r] - diff(l, r - 1))
return diff(0, n - 1) > 0O(1) math; O(n²) DP
version.O(n²) memo.diff(l, r) = max score difference the
current player can achieve on piles[l..r].Same as Stone Game but n is arbitrary (possibly odd)
and the total is arbitrary. Alice wins if
score(Alice) ≥ score(Bob).
Input: nums = [1, 5, 2] (two players alternately take from either end)
Output: False (Player 1 cannot win under optimal play)
DP diff(l, r) identical to 31.2.
from functools import cache
from typing import List
class Solution:
def predictTheWinner(self, nums: List[int]) -> bool:
@cache
def diff(l: int, r: int) -> int:
if l == r: return nums[l]
return max(nums[l] - diff(l + 1, r), nums[r] - diff(l, r - 1))
return diff(0, len(nums) - 1) >= 0O(n²).O(n²) memo.Piles. Alice first. Each turn take X piles from the
front where 1 <= X <= 2*M (M starts at
1, and after each turn M = max(M, X)). Maximise the number
of stones Alice can take.
Input: piles = [2, 7, 9, 4, 4] (M starts at 1; player takes X piles with 1 ≤ X ≤ 2M, then M = max(M, X))
Output: 10 (max stones Alice can take)
DP dfs(i, M) = stones the current
player can take from piles[i:] with current M.
Maximise over X.
from functools import cache
from typing import List
class Solution:
def stoneGameII(self, piles: List[int]) -> int:
n = len(piles)
suffix = [0] * (n + 1)
for i in range(n - 1, -1, -1):
suffix[i] = suffix[i + 1] + piles[i]
@cache
def dfs(i: int, M: int) -> int:
if i + 2 * M >= n:
return suffix[i]
return suffix[i] - min(dfs(i + x, max(M, x)) for x in range(1, 2 * M + 1))
return dfs(0, 1)O(n²).O(n²) memo.suffix[i] - min(...): remaining total
minus what the opponent optimally takes = what Alice keeps.Input: graph: List[List[int]] — adjacency list
of an undirected graph, graph[i] are the
neighbours of node i. Nodes are labelled
0..n-1.
Game rules: - Mouse starts at node 1,
Cat at node 2, hole is
node 0. - Turn 1: mouse moves; turn 2: cat moves (each must
move to a neighbour). - Cat cannot enter the hole (node
0). - Mouse wins on reaching the hole; Cat wins on landing on the Mouse;
draw if a state repeats.
Return one of: 1 = MOUSE_WIN, 2 = CAT_WIN,
0 = DRAW.
Input: graph = [[2,5], [3], [0,4,5], [1,4,5], [2,3], [0,2,3]]
(adjacency list of an undirected graph)
The graph has 6 nodes 0..5; node 0 is the hole.
Output: 0
(0 = DRAW, 1 = MOUSE_WIN, 2 = CAT_WIN)
BFS from terminal states (retrograde analysis). This differs from DP because the state graph has cycles (cat & mouse can shuttle back and forth) ⇒ top-down memo wouldn’t terminate.
State = (mouse, cat, turn) with
turn ∈ {0=mouse, 1=cat}. Total O(n²)
states.
Terminal: - mouse == 0 (mouse in hole)
⇒ MOUSE_WIN. - mouse == cat (cat catches
mouse) ⇒ CAT_WIN.
Outcome propagation (retrograde): for each state
with known outcome, walk backwards (parents that could
reach this state) and deduce: - If the winner’s turn is
in the parent (e.g. parent is mouse’s turn and current state is
MOUSE_WIN) ⇒ parent is also WIN (one
winning move is enough). - If the loser’s turn is in
the parent ⇒ only mark parent loss when every move
leads to loss (count via degree).
State/outcome diagram (simplified):
Terminal layer:
(0, *, *) ─────────────► MOUSE_WIN
(k, k, *) k>0 ─────────────► CAT_WIN
Retrograde propagation (BFS):
state S with known outcome = X
│
▼ for each parent P of S (flip turn):
│
├── turn(P) is "X's winner" ? YES → P = X (push)
│
└── turn(P) is "X's loser" ?
degree[P] -= 1
if degree[P] == 0:
P = X (push) ← every move loses
states still unset after BFS → DRAW.
This is a notoriously Hard LeetCode problem; the chapter focuses on the pattern (retrograde BFS); the full implementation is below.
from collections import deque
from typing import List
MOUSE_WIN, CAT_WIN, DRAW = 1, 2, 0
class Solution:
def catMouseGame(self, graph: List[List[int]]) -> int:
n = len(graph)
# state: (mouse, cat, turn) — turn 0=mouse, 1=cat
color = {}
degree = {}
for m in range(n):
for c in range(n):
degree[(m, c, 0)] = len(graph[m])
degree[(m, c, 1)] = len(graph[c]) - (0 in graph[c])
q = deque()
for c in range(n):
for t in range(2):
color[(0, c, t)] = MOUSE_WIN
q.append((0, c, t, MOUSE_WIN))
color[(c, c, 0)] = color[(c, c, 1)] = CAT_WIN if c != 0 else MOUSE_WIN
for t in range(2):
q.append((c, c, t, color[(c, c, t)]))
def parents(m: int, c: int, t: int):
prev_turn = 1 - t
if prev_turn == 0: # mouse just moved
for prev_m in graph[m]:
yield (prev_m, c, prev_turn)
else:
for prev_c in graph[c]:
if prev_c == 0: continue
yield (m, prev_c, prev_turn)
while q:
m, c, t, col = q.popleft()
for pm, pc, pt in parents(m, c, t):
if (pm, pc, pt) in color: continue
if (pt == 0 and col == MOUSE_WIN) or (pt == 1 and col == CAT_WIN):
color[(pm, pc, pt)] = col
q.append((pm, pc, pt, col))
else:
degree[(pm, pc, pt)] -= 1
if degree[(pm, pc, pt)] == 0:
color[(pm, pc, pt)] = col
q.append((pm, pc, pt, col))
return color.get((1, 2, 0), DRAW)O(n³) (state
(m, c, t)).O(n²).You have 1..maxChoosable. Each turn the current player
picks an unused number. The first to make the running total ≥
desiredTotal wins. Alice plays first. Does
Alice win?
Input: maxChoosable=10, desiredTotal=11
Output: False
Bitmask DP — state = set of used numbers (bitmask) +
current sum. @cache memoizes.
from functools import cache
class Solution:
def canIWin(self, maxChoosable: int, desiredTotal: int) -> bool:
if (1 + maxChoosable) * maxChoosable // 2 < desiredTotal:
return False
@cache
def dfs(mask: int, remaining: int) -> bool:
for i in range(1, maxChoosable + 1):
bit = 1 << i
if mask & bit:
continue
if i >= remaining:
return True
if not dfs(mask | bit, remaining - i):
return True # opponent loses → we win
return False
return dfs(0, desiredTotal)O(2^n · n).O(2^n) memo.| Kind | Hallmark | Problems |
|---|---|---|
| Math trick | Parity / sum / “always wins” analysis | LC 877 (Stone Game I), LC 292 Nim |
| Minimax DP | dp[state] = optimal value for the player to move |
LC 486, 1140 |
| Memoized state graph | Discrete state, complex transitions | LC 464 (Can I Win) |
| Retrograde BFS | Propagate outcomes from terminals backwards | LC 913 (Cat and Mouse) |
n even ⇒ Alice can pick all even-index
piles or all odd-index piles (colour them red/blue).
The two group sums differ (because even + odd = total is
odd) ⇒ Alice picks the larger group.bitmask of used numbers (≤ 20 → mask ≤
2²⁰).memo[mask] = True if the player to
move has a winning move.(mouse_pos, cat_pos, turn). Terminals: mouse at
hole → mouse wins; cat == mouse → cat wins.“String parser” problems ask you to implement a mini-language: calculator, atom formula, lisp, … Common patterns: (i) Stack for nested structures, (ii) State machine for token parsing, (iii) Recursive descent for grammars with nested rules. Problems 8.6 (Decode String) and 2.4 (atoi) already teased.
After this chapter, you will be able to:
3 common templates:
| Template | Example problems |
|---|---|
| Stack with operators | Basic Calculator I/II |
| FSM tokens | atoi, Valid Number |
| Recursive descent | Parse Lisp, Add Operators |
Given a string s containing numbers, +,
-, *, /, and whitespace. Evaluate
the expression. Division truncates toward 0. No
parentheses.
Input: s = " 3+5 / 2 "
Output: 5
(3 + (5/2) = 3 + 2 = 5; integer division)
Input: s = " 14-3/2 "
Output: 13
(14 - (3/2) = 14 - 1 = 13)
Stack of “terms” built so far. On encountering a new
operator: - +x → push x. - -x →
push -x. - *x → top = top * x. -
/x → top = int(top / x).
At the end: sum the stack.
class Solution:
def calculate(self, s: str) -> int:
s += '+' # sentinel
stack: list[int] = []
num = 0
op = '+'
for ch in s:
if ch.isdigit():
num = num * 10 + int(ch)
elif ch in '+-*/':
if op == '+': stack.append(num)
elif op == '-': stack.append(-num)
elif op == '*': stack[-1] *= num
else: stack[-1] = int(stack[-1] / num) # truncate to 0
op = ch
num = 0
return sum(stack)O(n).O(n) stack.+ trick: no special case for
the last token.-7 // 2 = -4,
but we want -3 (truncate to 0) → use
int(a / b).Fully solved in Chapter 8.6. Pattern: nested stack with
(prev_str, k).
This is the gateway to String Parser — nested structure → stack. The whole of Chapter 32 extends this pattern: Calculator (operator stack), Number of Atoms (dict stack), Regex (DP table), Valid Number (FSM).
class Solution:
def decodeString(self, s: str) -> str:
stack: list[tuple[str, int]] = []
cur, k = "", 0
for ch in s:
if ch.isdigit():
k = k * 10 + int(ch)
elif ch == '[':
stack.append((cur, k))
cur, k = "", 0
elif ch == ']':
prev_str, prev_k = stack.pop()
cur = prev_str + cur * prev_k
else:
cur += ch
return curO(N) (N = output length).
Space: O(N).Given a chemical formula (e.g. "K4(ON(SO3)2)2"), return
the canonical form K4N2O14S4 — alphabetised atomic names +
counts.
Input: formula = "H2O"
Output: "H2O"
Input: formula = "Mg(OH)2"
Output: "H2MgO2"
(Mg×1, O×2, H×2; sort alphabetic; count 1 omitted)
Input: formula = "K4(ON(SO3)2)2"
Output: "K4N2O14S4"
(K×4, N×2, O×14, S×4; sort alphabetic)
Stack of dicts. Each ( opens a new
dict. Each ) + multiplier → multiply the dict and merge
into the parent. Each atom name + count → add to the top dict.
from collections import Counter, defaultdict
class Solution:
def countOfAtoms(self, formula: str) -> str:
stack: list[dict] = [defaultdict(int)]
i, n = 0, len(formula)
while i < n:
ch = formula[i]
if ch == '(':
stack.append(defaultdict(int))
i += 1
elif ch == ')':
i += 1
j = i
while j < n and formula[j].isdigit():
j += 1
mult = int(formula[i:j]) if j > i else 1
i = j
top = stack.pop()
for atom, cnt in top.items():
stack[-1][atom] += cnt * mult
else:
j = i + 1
while j < n and formula[j].islower():
j += 1
atom = formula[i:j]
i = j
k = i
while k < n and formula[k].isdigit():
k += 1
count = int(formula[i:k]) if k > i else 1
i = k
stack[-1][atom] += count
result = stack[-1]
return ''.join(
atom + (str(cnt) if cnt > 1 else '')
for atom, cnt in sorted(result.items())
)O(n²) worst (dict merging).O(n).Check whether string s matches pattern p.
p may contain . (matches any single char) and
* (matches 0+ of the preceding character).
Input: s = "aa", p = "a*"
Output: True
(`a*` matches 0+ 'a' → matches "aa")
Input: s = "mississippi", p = "mis*is*p*."
Output: False
Input: s = "ab", p = ".*"
Output: True
(`.*` matches 0+ of any character → matches "ab")
a-z, .,
*? → Per the problem: yes.2D DP. dp[i][j] = does
s[:i] match p[:j]?
p[j-1] is *:
dp[i][j-2].dp[i-1][j].p[j-1] is . or equals s[i-1]:
dp[i-1][j-1].class Solution:
def isMatch(self, s: str, p: str) -> bool:
m, n = len(s), len(p)
dp = [[False] * (n + 1) for _ in range(m + 1)]
dp[0][0] = True
for j in range(2, n + 1):
if p[j - 1] == '*':
dp[0][j] = dp[0][j - 2]
for i in range(1, m + 1):
for j in range(1, n + 1):
if p[j - 1] == '*':
dp[i][j] = dp[i][j - 2]
if p[j - 2] == '.' or p[j - 2] == s[i - 1]:
dp[i][j] = dp[i][j] or dp[i - 1][j]
else:
if p[j - 1] == '.' or p[j - 1] == s[i - 1]:
dp[i][j] = dp[i - 1][j - 1]
return dp[m][n]O(m · n).
Space: O(m · n).*
includes the 0-times case — the hardest detail.Check whether a string s is a valid number. Valid forms
include integers, decimals, and scientific notation,
e.g. "2", "-2.5", "3e+10",
"-.5".
Input: s = "0" → Output: True
Input: s = "e" → Output: False (only the letter e, no digits)
Input: s = "." → Output: False (only a dot, no digits)
Input: s = "+6e-1" → Output: True (valid scientific notation)
++3)? → Invalid..? → Invalid.FSM with 9 states or use regex. The FSM is concise and easier to explain in interviews.
import re
class Solution:
def isNumber(self, s: str) -> bool:
pattern = r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$'
return bool(re.match(pattern, s.strip()))O(n) (regex match).O(1).^[+-]? — optional sign.(\d+\.?\d*|\.\d+) — 12, 12.,
12.3, .3 (integer / decimal).([eE][+-]?\d+)? — optional exponent.Convert num (≤ 2³¹-1) into English text.
Input: num = 123
Output: "One Hundred Twenty Three"
Input: num = 12345
Output: "Twelve Thousand Three Hundred Forty Five"
Input: num = 0
Output: "Zero"
Split into groups of three digits (units, thousands, millions, billions). Each group has the pattern: hundreds + tens-ones.
class Solution:
UNDER_20 = ["", "One","Two","Three","Four","Five","Six","Seven","Eight","Nine",
"Ten","Eleven","Twelve","Thirteen","Fourteen","Fifteen","Sixteen",
"Seventeen","Eighteen","Nineteen"]
TENS = ["", "", "Twenty","Thirty","Forty","Fifty","Sixty","Seventy","Eighty","Ninety"]
THOUSANDS = ["", "Thousand", "Million", "Billion"]
def numberToWords(self, num: int) -> str:
if num == 0: return "Zero"
def under_thousand(n: int) -> str:
if n == 0: return ""
if n < 20: return self.UNDER_20[n] + " "
if n < 100: return self.TENS[n // 10] + " " + under_thousand(n % 10)
return self.UNDER_20[n // 100] + " Hundred " + under_thousand(n % 100)
parts = []
i = 0
while num > 0:
if num % 1000 != 0:
parts.append((under_thousand(num % 1000) + self.THOUSANDS[i]).strip())
num //= 1000
i += 1
return ' '.join(reversed(parts)).strip()O(log n) (groups of
thousand).O(log n).num == 0 →
"Zero".| Kind | Hallmark | Problems |
|---|---|---|
| Stack-based | Nested structures (paren, brackets) | LC 224, 394, 726 |
| FSM / state machine | Linear tokens with distinct states | LC 8, 65 |
| Recursive descent | Grammar with recursive structure | LC 736, 770 |
| DP on string | Pattern matching (., *) |
LC 10, 44 |
States: 0 start, 1 sign, 2
int, 3 dot_after_int, 4 dot_before_int (needs
frac), 5 frac, 6 e/E,
7 exp_sign, 8 exp_int, 9 end
(whitespace/error). | State | digit | +/- | .
| e/E | |—|—|—|—|—| | 0 (start) | 2 | 1 | 4 | – | | 1
(sign) | 2 | – | 4 | – | | 2 (int) | 2 | – | 3 | 6 | | 3 (dot after int)
| 5 | – | – | 6 | | 4 (dot only) | 5 | – | – | – | | 5 (frac) | 5 | – |
– | 6 | | 6 (e) | 8 | 7 | – | – | | 7 (exp sign) | 8 | – | – | – | | 8
(exp int) | 8 | – | – | – |
Accept states: {2, 3, 5, 8}.
* allows 0 or more repetitions → the decision is
non-local: the * at p[j]
affects many prefixes of s.dp[i][j] = does s[..i] match
p[..j]?p[j] == '*': try “use 0 times”
dp[i][j-2] or “use one more time” dp[i-1][j]
if s[i-1] matches p[j-1]."K4(ON(SO3)2)2":
stack = [Counter()]
'K' '4' → top {K:4}
'(' → push new {}
'O' 'N' → top {O:1, N:1}
'(' → push
'S' 'O' '3' → top {S:1, O:3}
')' '2' → pop, multiply by 2: {S:2, O:6} → merge into below {O:1,N:1,S:2,O:7} ⇒ {O:7, N:1, S:2}
')' '2' → pop, multiply by 2, merge into base {K:4} → {K:4, O:14, N:2, S:4}
MST = a subset of edges connecting all vertices with minimum total weight and no cycles. Two algorithms: Kruskal (sort edges + DSU) and Prim (heap-grown from a vertex). Both are
O(E log E).
After this chapter, you will be able to:
# Kruskal: sort edges + DSU
def kruskal(n, edges):
edges.sort(key=lambda e: e[2])
dsu = DSU(n)
total = 0
for u, v, w in edges:
if dsu.union(u, v):
total += w
return total
# Prim: heap-based growth
import heapq
def prim(n, graph):
visited = [False] * n
heap = [(0, 0)] # (weight, node)
total = 0
while heap:
w, u = heapq.heappop(heap)
if visited[u]: continue
visited[u] = True
total += w
for v, weight in graph[u]:
if not visited[v]:
heapq.heappush(heap, (weight, v))
return totalGiven 2D points, the cost of connecting two points is
their Manhattan distance. Find the minimum cost to connect all
points.
Input: points = [[0,0], [2,2], [3,10], [5,2], [7,0]]
(each point [x, y]; cost between two = |x1-x2| + |y1-y2| Manhattan)
Output: 20 (MST total cost connecting every point)
Kruskal: build all O(n²) edges, sort,
DSU. Prim: better for dense graphs (every pair is an
edge) — O(n²).
import heapq
from typing import List
class Solution:
def minCostConnectPoints(self, points: List[List[int]]) -> int:
n = len(points)
visited = [False] * n
heap = [(0, 0)]
total = 0
count = 0
while count < n:
w, u = heapq.heappop(heap)
if visited[u]: continue
visited[u] = True
total += w
count += 1
for v in range(n):
if not visited[v]:
dist = abs(points[u][0]-points[v][0]) + abs(points[u][1]-points[v][1])
heapq.heappush(heap, (dist, v))
return totalO(n²) with
dense Prim (no heap).O(n²)
instead of heap for dense graphs.Given cities 1..n and
connections[i] = [a, b, cost]. Minimum cost to connect all
cities, or -1 if impossible.
Input: n=3, connections=[[1,2,5],[1,3,6],[2,3,1]]
Output: 6
Plain Kruskal. Sort edges ascending by weight; use DSU to accept only those that join different components; after the sweep, if the DSU still has one component, return the total weight.
from typing import List
class Solution:
def minimumCost(self, n: int, connections: List[List[int]]) -> int:
connections.sort(key=lambda c: c[2])
parent = list(range(n + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
total = 0
used = 0
for a, b, c in connections:
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
total += c
used += 1
if used == n - 1:
return total
return -1O(E log E) (Kruskal sort).O(V).n houses. Each house can either (a) dig its own well
costing wells[i], or (b) connect to another via
pipes[j] = [u, v, c]. Minimum total cost to supply water
everywhere.
Input: n=3, wells=[1,2,2], pipes=[[1,2,1],[2,3,1]]
Output: 3
Virtual-node 0 trick: add node 0 and edges
(0, i, wells[i]) for each house. Run MST on
n+1 nodes.
from typing import List
class Solution:
def minCostToSupplyWater(self, n: int, wells: List[int], pipes: List[List[int]]) -> int:
edges = pipes[:]
for i, w in enumerate(wells):
edges.append([0, i + 1, w])
edges.sort(key=lambda e: e[2])
parent = list(range(n + 1))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
total = 0
for u, v, c in edges:
ru, rv = find(u), find(v)
if ru != rv:
parent[ru] = rv
total += c
return totalO((V + E) log E).O(V + E).Input: n (number of vertices) and
edges: List[List[int]] — each edge [u, v, w]
is undirected with weight w. Find every
critical edge (removing it raises the MST cost) and
pseudo-critical (can appear in some MST).
Input: n=5, edges=[[0,1,1],[1,2,1],[2,3,2],[0,3,2],[0,4,3],[3,4,3],[1,4,6]]
Output: [[0,1],[2,3,4,5]]
mst_cost.e:
from typing import List
class Solution:
def findCriticalAndPseudoCriticalEdges(self, n: int, edges: List[List[int]]) -> List[List[int]]:
# Track original indices after sorting.
indexed = [e + [i] for i, e in enumerate(edges)]
indexed.sort(key=lambda e: e[2])
def kruskal(skip=-1, force=-1) -> int:
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
total = used = 0
if force != -1:
u, v, w, _ = indexed[force]
parent[find(u)] = find(v)
total = w
used = 1
for i, (u, v, w, _) in enumerate(indexed):
if i == skip or i == force: continue
ru, rv = find(u), find(v)
if ru != rv:
parent[ru] = rv
total += w
used += 1
return total if used == n - 1 else float('inf')
base = kruskal()
critical, pseudo = [], []
for i, (u, v, w, orig) in enumerate(indexed):
if kruskal(skip=i) > base:
critical.append(orig)
elif kruskal(force=i) == base:
pseudo.append(orig)
return [critical, pseudo]O(E² · α(V)) worst (test each
edge with skip + force).O(V + E).Given an undirected graph with
edgeList[i] = [u, v, dist] and queries
queries[j] = [p, q, limit]. For each query, return
True if there exists a path between p, q whose
every edge has dist < limit.
Input: n=3, edges=[[0,1,2],[1,2,4],[2,0,8],[1,0,16]]
queries=[[0,1,2],[0,2,5]]
Output: [False, True]
2 <= n <= 10^51 <= edgeList.length <= 10^5dist == limit allowed? →
No (the problem uses <).Brute force: BFS/DFS per query with
dist < limit. O(Q · E). TLE.
Optimal — Offline Kruskal-style —
O((E + Q) log).
Sort both edgeList (by dist) and
queries (by limit). Process queries in
increasing order: for each query, union every edge with
dist < limit in the DSU. Then check
find(p) == find(q).
from typing import List
class Solution:
def distanceLimitedPathsExist(self, n: int, edgeList: List[List[int]],
queries: List[List[int]]) -> List[bool]:
parent = list(range(n))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x: int, y: int) -> None:
rx, ry = find(x), find(y)
if rx != ry:
parent[rx] = ry
edgeList.sort(key=lambda e: e[2])
# Tag queries with original index to output in order.
indexed_q = sorted(enumerate(queries), key=lambda kv: kv[1][2])
result = [False] * len(queries)
i = 0
for q_idx, (p, q, limit) in indexed_q:
while i < len(edgeList) and edgeList[i][2] < limit:
u, v, _ = edgeList[i]
union(u, v)
i += 1
result[q_idx] = (find(p) == find(q))
return resultO((E + Q) log (E + Q) + (E + Q) · α(n)).O(n + Q).< per the problem;
switch to <= if the problem says so.Grid m × n. Find a path from (0,0) to
(m-1,n-1) (4-directional) maximising the minimum
value on the path. Return that value.
Input: grid = [[5, 4, 5],
[1, 2, 6],
[7, 4, 6]] (m × n grid, 4-dir walk (0,0) → (m-1, n-1))
Output: 4 (the MIN value on the best path is maximised = 4; e.g. 5→4→5→6→6 has min = 4)
1 <= m, n <= 1000 <= grid[i][j] <= 10^9“Reverse Kruskal” pattern — activate cells in
decreasing value order. When (0,0) and
(m-1,n-1) share a component → the value of the cell just
activated is the answer (it is the minimum along the discovered
path).
Same family as Swim in Rising Water (Ch 24.6) but reversed: instead of “lowest elevation first” → “highest value first”.
from typing import List
class Solution:
def maximumMinimumPath(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
parent = list(range(m * n))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x: int, y: int) -> None:
rx, ry = find(x), find(y)
if rx != ry:
parent[rx] = ry
# Sort cells in DESCENDING value.
cells = sorted(
((grid[r][c], r, c) for r in range(m) for c in range(n)),
reverse=True,
)
active = [[False] * n for _ in range(m)]
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
for val, r, c in cells:
active[r][c] = True
idx = r * n + c
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < m and 0 <= nc < n and active[nr][nc]:
union(idx, nr * n + nc)
if find(0) == find(m * n - 1):
return val
return -1O(mn · log(mn) + mn · α).O(mn).v, BFS over cells with
grid[r][c] >= v. O(log(max) · mn). See Ch
37 for this pattern.| Kruskal | Prim | |
|---|---|---|
| Data structures | DSU + sorted edges | Heap + visited |
| Time | O(E log E) |
O(E log V) |
| Sparse graph | ✅ Good | OK |
| Dense graph | OK | ✅ Better |
| Need edge list | ✅ | Needs adjacency list |
| Streaming edges | ✅ (process incrementally) | ❌ |
“Sort edges by weight → for each edge, if it links two different components, take it. DSU tracks connectivity incrementally.”
src ↔︎ dst are connected).min(d, edge)).Rolling Hash = a string hash that you can “slide a window” over with
O(1)updates. This pattern turnsO(n · m)intoO(n + m)for substring matching, duplicate detection, etc.
After this chapter, you will be able to:
O(1).(1<<61)-1); production needs double hashing.Pattern (Rabin-Karp):
hash(s[l..r]) = (s[l]·base^(r-l) + s[l+1]·base^(r-l-1) + ... + s[r]) mod M
When sliding: hash_new = (hash_old · base + s[r+1] - s[l] · base^(r-l+1)) mod M
Collision pitfall. A prime modulus
10^9 + 7 is usually safe for LC. For adversarial inputs use
two different hashes (double hashing).
def rolling_hash(s: str, length: int) -> set[int]:
BASE = 26
MOD = (1 << 61) - 1
n = len(s)
base_pow = pow(BASE, length, MOD)
h = 0
seen = set()
for i in range(n):
h = (h * BASE + ord(s[i])) % MOD
if i >= length:
h = (h - ord(s[i - length]) * base_pow) % MOD
if i >= length - 1:
seen.add(h)
return seenGiven a DNA string s (containing A, C, G, T), return
every length-10 substring that appears at least
twice.
Input: s = "AAAAACCCCCAAAAACCCCCCAAAAAGGGTTT" (string of A/C/G/T)
Output: ["AAAAACCCCC", "CCCCCAAAAA"]
(every length-10 substring appearing ≥ 2 times; order doesn't matter)
Brute force: set of length-10 substrings.
O(n · 10) time, O(n · 10) space.
Rolling hash: O(n) time,
O(n) space (smaller memory because hashes are ints).
from typing import List
class Solution:
def findRepeatedDnaSequences(self, s: str) -> List[str]:
seen: dict[str, int] = {}
result: list[str] = []
for i in range(len(s) - 9):
sub = s[i:i + 10]
seen[sub] = seen.get(sub, 0) + 1
if seen[sub] == 2:
result.append(sub)
return resultO(n).O(n).Given s, find the longest substring
appearing at least twice (overlap allowed). If multiple, return any.
Input: s = "banana"
Output: "ana" (longest repeated substring; if multiple, return any)
Binary search on length + rolling-hash check.
L such that some length-L substring
repeats.check(L): rolling hash + set; collisions → duplicate
exists.
class Solution:
def longestDupSubstring(self, s: str) -> str:
BASE = 26
MOD = (1 << 61) - 1
n = len(s)
nums = [ord(c) - ord('a') for c in s]
def search(L: int) -> int:
base_pow = pow(BASE, L, MOD)
h = 0
for i in range(L):
h = (h * BASE + nums[i]) % MOD
seen = {h: 0}
for i in range(1, n - L + 1):
h = (h * BASE - nums[i - 1] * base_pow + nums[i + L - 1]) % MOD
if h in seen:
return i
seen[h] = i
return -1
lo, hi = 1, n - 1
start = 0
best_len = 0
while lo <= hi:
mid = (lo + hi) // 2
idx = search(mid)
if idx != -1:
if mid > best_len:
best_len = mid
start = idx
lo = mid + 1
else:
hi = mid - 1
return s[start:start + best_len]O(n log n).
Space: O(n).MOD = (1<<61)-1 (Mersenne prime), safe on LC.Count the number of distinct substrings of the form
a + a (a string concatenated with itself).
Input: text = "abcabcabc"
Output: 3
(distinct echo substrings; echo = a+a;
here "abcabc", "bcabca", "cabcab")
a + a
doesn’t overlap.For each L (length of a), check every
position where s[i..i+L-1] == s[i+L..i+2L-1]. Rolling hash
makes the comparison O(1). Collect seen hashes.
class Solution:
def distinctEchoSubstrings(self, s: str) -> int:
n = len(s)
BASE = 26
MOD = (1 << 61) - 1
nums = [ord(c) - ord('a') for c in s]
# Precompute prefix hash + power.
h = [0] * (n + 1)
p = [1] * (n + 1)
for i in range(n):
h[i + 1] = (h[i] * BASE + nums[i]) % MOD
p[i + 1] = (p[i] * BASE) % MOD
def get_hash(l: int, r: int) -> int:
return (h[r + 1] - h[l] * p[r - l + 1]) % MOD
seen: set[int] = set()
for L in range(1, n // 2 + 1):
for i in range(n - 2 * L + 1):
h1 = get_hash(i, i + L - 1)
h2 = get_hash(i + L, i + 2 * L - 1)
if h1 == h2:
seen.add(h1)
return len(seen)O(n²). Space:
O(n²) worst.Given s, prepend the fewest characters
so it becomes a palindrome.
Input: s = "aacecaaa"
Output: "aaacecaaa" (prepend the FEWEST chars to make s a palindrome)
Find the longest prefix of s that is a palindrome.
Reverse the rest and prepend.
Rolling-hash approach: compute hashes of
s and reverse(s). Find the largest
L such that hash(s[:L]) == hash(rev_s[n-L:])
(= s[:L] is a palindrome).
class Solution:
def shortestPalindrome(self, s: str) -> str:
n = len(s)
if n <= 1: return s
BASE = 131
MOD = 10**9 + 7
h1 = h2 = 0
power = 1
best = 0
for i, ch in enumerate(s):
h1 = (h1 * BASE + ord(ch)) % MOD
h2 = (h2 + ord(ch) * power) % MOD
power = power * BASE % MOD
if h1 == h2:
best = i + 1
return s[best:][::-1] + sO(n).O(1).h1 = forward hash of prefix
[0..i].h2 = backward hash (still prefix
[0..i] but read in reverse order).Given a list of equal-length strings. Return True if any
two differ in exactly one position.
Input: dict = ["abcd", "acbd", "aacd"]
Output: True (two strings differ by exactly one position; e.g. "abcd" vs "aacd")
For each string and each position i, “mask” the
character at i and hash the rest. If a collision occurs →
there’s a pair differing in exactly one position.
from typing import List
class Solution:
def differByOne(self, dict: List[str]) -> bool:
n_words = len(dict)
L = len(dict[0])
seen: set[tuple[int, str, int]] = set()
for w in dict:
for i in range(L):
key = (i, w[:i] + w[i+1:])
if key in seen:
return True
seen.add(key)
return FalseO(N · L²) worst (slice
cost).O(N · L).O(L) but fine for LC.O(L · N) total and avoids adversarial collisions.Build s by prepending characters one at a time. At step
i, the “score” = the longest prefix of s_i
that is also a suffix of s_i. Return the sum of all
scores.
Input: s = "babab"
Output: 9 (score[i] = LCP(s, s[i:]); sum scores over all i)
Z function in Chapter 36 is the main pattern. Rolling hash also works: for each i, binary-search the longest matching prefix-suffix via hash equality.
class Solution:
def sumScores(self, s: str) -> int:
# Z function — details in Chapter 36.
n = len(s)
z = [0] * n
z[0] = n
l = r = 0
for i in range(1, n):
if i < r:
z[i] = min(r - i, z[i - l])
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
z[i] += 1
if i + z[i] > r:
l, r = i, i + z[i]
return sum(z)O(n).O(n).s_i = s[n-i:] (built
from the right).| Goal | Enough |
|---|---|
| LC accepted | 1 hash with a large prime modulus (10⁹+7 or
(1<<61) - 1) |
| Production / robust | Double hash (two (base, mod) pairs) or
re-verify the substring on collision |
| Absolutely safe | Store the substring instead of the hash → costs memory |
[l, r)hash[i] = (hash[i-1] * base + ord(s[i-1])) mod M
substring s[l:r] hash = (hash[r] - hash[l] * pow_base[r - l]) mod M
Note in Python: (a - b) % M is always
correct (negative-safe). Java/C++ need
((... % M) + M) % M.
| Rolling hash | KMP / Z | |
|---|---|---|
Find pattern P in text T |
O(|T|) avg, possible collision |
O(|T| + |P|) guaranteed |
| Many queries (multi-pattern search) | Index text hashes | Build per pattern |
| General substring comparison | ✅ Hash any range | KMP/Z don’t directly support |
| Collision risk | Yes | No |
KMP solves substring matching in
O(n + m), the heart of which is the LPS array (Longest Prefix Suffix) — for each indexiin the pattern,lps[i]= the length of the longest prefix ofpattern[0..i]that is also a suffix of it (not itself).
ababacaLPS is KMP’s trickiest concept. Hand-trace with
pattern = "ababaca":
index : 0 1 2 3 4 5 6
char : a b a b a c a
LPS : 0 0 1 2 3 0 1
Cell-by-cell explanation:
LPS[0]=0 (one character, no proper prefix)
LPS[1]=0 "ab" — prefix "a" ≠ suffix "b"
LPS[2]=1 "aba" — prefix "a" = suffix "a" → 1
LPS[3]=2 "abab" — prefix "ab" = suffix "ab" → 2
LPS[4]=3 "ababa" — prefix "aba" = suffix "aba" → 3
LPS[5]=0 "ababac" — no prefix matches suffix ending in "c" → 0
LPS[6]=1 "ababaca" — prefix "a" = suffix "a" → 1
Intuition: LPS[i] tells us that when a mismatch
happens at pattern[i+1], we don’t have to restart from the
beginning — we can jump to pattern[LPS[i]] because
pattern[0..LPS[i]-1] must already match (it is also a
suffix of what we’ve matched).
After this chapter, you will be able to:
lps[i] = longest proper prefix =
suffix of pattern[0..i].O(n + m).# not appearing in the input (pick
safely).len(s) - lps[-1] divides
len(s) ↔︎ s is a repeated string.def build_lps(p: str) -> list[int]:
n = len(p)
lps = [0] * n
length = 0
i = 1
while i < n:
if p[i] == p[length]:
length += 1
lps[i] = length
i += 1
elif length > 0:
length = lps[length - 1]
else:
lps[i] = 0
i += 1
return lps
def kmp_search(text: str, p: str) -> list[int]:
lps = build_lps(p)
result = []
i = j = 0
while i < len(text):
if text[i] == p[j]:
i += 1; j += 1
if j == len(p):
result.append(i - j)
j = lps[j - 1]
elif j > 0:
j = lps[j - 1]
else:
i += 1
return resultFind the first index of needle inside
haystack, or -1.
Input: haystack="sadbutsad", needle="sad"
Output: 0
KMP in O(n + m) time, O(m)
space for lps.
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
n, m = len(haystack), len(needle)
if m == 0: return 0
lps = [0] * m
length = 0
i = 1
while i < m:
if needle[i] == needle[length]:
length += 1
lps[i] = length
i += 1
elif length > 0:
length = lps[length - 1]
else:
i += 1
i = j = 0
while i < n:
if haystack[i] == needle[j]:
i += 1; j += 1
if j == m:
return i - j
elif j > 0:
j = lps[j - 1]
else:
i += 1
return -1O(n + m).O(m) for LPS.O(n·m) still passes on LC
with m, n ≤ 10⁴, but KMP is the better algorithmic answer
and demonstrates linear-time pattern matching.Same as Chapter 34.4 but with KMP.
Input: s = "aacecaaa"
Output: "aaacecaaa" (prepend the FEWEST chars so s becomes a palindrome)
Construct combined = s + '#' + reverse(s). Compute
lps of combined; lps[-1] is precisely the
length of the longest prefix of s that is
also a suffix of reverse(s) = the
longest palindromic prefix.
class Solution:
def shortestPalindrome(self, s: str) -> str:
combined = s + '#' + s[::-1]
lps = [0] * len(combined)
length = 0
for i in range(1, len(combined)):
while length > 0 and combined[i] != combined[length]:
length = lps[length - 1]
if combined[i] == combined[length]:
length += 1
lps[i] = length
return s[lps[-1]:][::-1] + sO(n).O(n).# trick prevents accidental
prefix/suffix overlap (a fake palindrome).Check whether s can be built by concatenating multiple
copies of some substring.
Input: s = "abab"
Output: True
("abab" = "ab" repeated twice ⇒ True)
KMP trick: if s = pattern * k (k ≥ 2),
then lps[-1] > 0 and n - lps[-1] divides
n.
Specifically: len(pattern) = n - lps[-1].
class Solution:
def repeatedSubstringPattern(self, s: str) -> bool:
n = len(s)
lps = [0] * n
length = 0
for i in range(1, n):
while length > 0 and s[i] != s[length]:
length = lps[length - 1]
if s[i] == s[length]:
length += 1
lps[i] = length
return lps[-1] > 0 and n % (n - lps[-1]) == 0O(n).O(n).(s + s)[1:-1].find(s) != -1. Elegant but less
efficient.Find every start index in s such that the
length-|p| substring is an anagram of p.
Input: s="cbaebabacd", p="abc"
Output: [0, 6]
Sliding window + Counter. KMP does not directly solve this — anagrams aren’t substring matches. Still listed because it belongs to the “string pattern” family.
from collections import Counter
from typing import List
class Solution:
def findAnagrams(self, s: str, p: str) -> List[int]:
if len(s) < len(p): return []
need = Counter(p)
have = Counter(s[:len(p)])
result = []
if have == need:
result.append(0)
for i in range(len(p), len(s)):
have[s[i]] += 1
have[s[i - len(p)]] -= 1
if have[s[i - len(p)]] == 0:
del have[s[i - len(p)]]
if have == need:
result.append(i - len(p) + 1)
return resultO(n + m · k) with k = alphabet;
effectively O(n).O(k).O(26), not O(1); still fine for LC.Given s, maxLetters, minSize,
maxSize. Find the maximum occurrence count of a substring
with length ∈ [minSize, maxSize] and distinct characters ≤
maxLetters.
Input: s="aababcaab", maxLetters=2, minSize=3, maxSize=4
Output: 2
Trick: we only need to consider length =
minSize (a longer substring can’t occur more often than its
prefix of length minSize).
Sliding window + Counter.
from collections import Counter, defaultdict
class Solution:
def maxFreq(self, s: str, maxLetters: int, minSize: int, maxSize: int) -> int:
freq: dict[str, int] = defaultdict(int)
for i in range(len(s) - minSize + 1):
sub = s[i:i + minSize]
if len(set(sub)) <= maxLetters:
freq[sub] += 1
return max(freq.values(), default=0)O(n · minSize).O(n).minSize-only insight: if a
length-L substring occurs k times, every length-(L-1) prefix of it also
occurs ≥ k times.A “happy prefix” is a non-trivial prefix that is also a suffix. Return the longest one.
Input: s = "level"
Output: "l" (longest prefix of s that is also a suffix of s and NOT equal to s itself;
empty string if none exists)
Directly lps[-1] of s is
the answer.
class Solution:
def longestPrefix(self, s: str) -> str:
n = len(s)
lps = [0] * n
length = 0
for i in range(1, n):
while length > 0 and s[i] != s[length]:
length = lps[length - 1]
if s[i] == s[length]:
length += 1
lps[i] = length
return s[:lps[-1]]O(n).O(n).s[:lps[-1]] — empty if no
happy prefix exists.ababacai |
char | j (prev LPS) |
lps[i] |
|---|---|---|---|
| 0 | a | – | 0 |
| 1 | b | lps[0]=0; s[0]!=s[1] |
0 |
| 2 | a | j=0; s[0]==s[2] ⇒ j=1 |
1 |
| 3 | b | j=1; s[1]==s[3] ⇒ j=2 |
2 |
| 4 | a | j=2; s[2]==s[4] ⇒ j=3 |
3 |
| 5 | c | j=3; s[3]!=s[5]; fall back
j=lps[2]=1; s[1]!=s[5]; fall back
j=lps[0]=0; s[0]!=s[5] |
0 |
| 6 | a | j=0; s[0]==s[6] ⇒ j=1 |
1 |
⇒ lps = [0, 0, 1, 2, 3, 0, 1].
When concatenating P + '#' + T (the LC reference),
# must not appear in the allowed charset.
If the input may contain any Unicode character, use a pair of two
sentinels or use the Z-function (Chapter 36) directly.
| KMP | Z | |
|---|---|---|
| Preprocess | LPS array of P | Z array of P + '#' + T |
| Mindset | “Failure → smart fall-back” | “Match prefix at every index” |
| Implementation | Failure function, 2 pointers | One [l, r) box, easy off-by-one |
| Niche uses | String periodicity, find pattern | LCP, distinct substrings, Z-array properties |
The Z function of
s:z[i]= the length of the longest segment starting ats[i]that is also a prefix ofs. By conventionz[0] = n. Computable inO(n).
The Z function uses the “Z-box” [l, r) = the most recent
matched segment of s with the prefix of s.
This is the key to seeing why it runs in O(n).
[l ........... r)
s : ┌───┐ ┌─────────────┐
│ A │ ...│ B = prefix │ ...
└───┘ └─────────────┘
prefix matched with prefix
│ │
└──────────────┴── this segment = prefix of s, length r - l
When evaluating z[i] with i ∈ [l, r):
• We already know z[i - l] (because s[l..r) = s[0..r-l) by alignment).
• Lower bound: z[i] = min(r - i, z[i - l]).
• Then extend by character comparison.
When i ≥ r:
• No info → compare from s[0].
Update Z-box when a longer match is found (i + z[i] > r).
Trace with s = "aabxaabxaab":
i l r z[i] computed from
0 0 0 11 (z[0] = n)
1 0 0 ? i ≥ r, compare from start
s[1]=a, s[0]=a → match. z[1]=1 (s[2]=b ≠ s[1]=a)
Update Z-box: l=1, r=2.
2 1 2 ? i = r, no info → compare from start.
s[2]=b ≠ s[0]=a → z[2]=0.
3 1 2 0 Similarly, z[3]=0.
4 1 2 ? i ≥ r, compare: s[4..]=aabxaab, prefix=aabxaab
Match all 7 → z[4]=7. Update Z-box: l=4, r=11.
5 4 11 ? i ∈ [4, 11). z[5] = min(11-5, z[5-4]) = min(6, z[1]) = min(6, 1) = 1.
Extend: s[6]=b ≠ s[1]=a → stop. z[5]=1.
6 4 11 ? z[6] = min(5, z[2]) = min(5, 0) = 0.
7 4 11 ? z[7] = min(4, z[3]) = min(4, 0) = 0.
8 4 11 ? z[8] = min(3, z[4]) = min(3, 7) = 3.
Extend: i+z[i]=11=r → could exceed? s[11] out of bound. z[8]=3.
9 4 11 ? z[9] = min(2, z[5]) = min(2, 1) = 1.
10 4 11 ? z[10] = min(1, z[6]) = min(1, 0) = 0.
Final z: [11, 1, 0, 0, 7, 1, 0, 0, 3, 1, 0]
Amortised O(n) proof: every “compare
extend” inside the while loop increases r. r
is monotonically non-decreasing and ≤ n → total extensions ≤ n. Adding
O(1) for each non-extending iteration → total
O(n).
After this chapter, you will be able to:
z[i] = length of the segment starting at
s[i] that matches the prefix.[l, r] invariant: most recent matched
segment, amortised O(n).def z_function(s: str) -> list[int]:
n = len(s)
z = [0] * n
z[0] = n
l = r = 0
for i in range(1, n):
if i < r:
z[i] = min(r - i, z[i - l])
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
z[i] += 1
if i + z[i] > r:
l, r = i, i + z[i]
return zCompute the z[] array for the string s.
This is the building block every other Z-function problem relies on.
Input: s = "aabxaabxaab"
Output: [11, 1, 0, 0, 7, 1, 0, 0, 3, 1, 0]
Explanation of a few representative values (see the full trace in the "Z-box invariant" section):
z[0] = 11 (convention: equals the string length)
z[4] = 7 (s[4..10] = "aabxaab" matches the prefix "aabxaab")
z[8] = 3 (s[8..10] = "aab" matches the prefix "aab")
Maintain [l, r] = the most recent matched segment with
the prefix. For each i: - If i < r: reuse
the known z[i - l] to skip work. - Extend z[i]
by comparing s[z[i]] with s[i + z[i]]. -
Update [l, r] if the new matched segment exceeds the old
one.
Amortised: each character is “extended” at most once →
O(n).
def z_function(s: str) -> list[int]:
n = len(s)
z = [0] * n
z[0] = n
l = r = 0
for i in range(1, n):
if i < r:
z[i] = min(r - i, z[i - l])
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
z[i] += 1
if i + z[i] > r:
l, r = i, i + z[i]
return zIntuition for the Z function with
s = "aabcaabxaaaz":
i : 0 1 2 3 4 5 6 7 8 9 10 11
s : a a b c a a b x a a a z
z : 12 1 0 0 3 1 0 0 2 2 1 0
z[1] = 1: s[1..1] = "a" is the prefix of
length 1.z[4] = 3: s[4..6] = "aab" is the prefix of
length 3.z[8] = 2: s[8..9] = "aa" is the prefix of
length 2.O(n).O(n).z[0] = n matters
for some applications.Find the first index of needle in haystack,
or -1. This chapter uses Z function
instead of KMP (Chapter 35.1).
Input: haystack="sadbutsad", needle="sad"
Output: 0
#? → LC says lowercase only
→ safe.combined = pattern + '#' + text. Compute z. Find the
first i with z[i] == len(pattern); return
i - len(pattern) - 1 (position in text).
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
if not needle: return 0
combined = needle + '#' + haystack
z = self.z_function(combined)
target = len(needle)
for i, v in enumerate(z):
if v == target:
return i - target - 1
return -1
@staticmethod
def z_function(s: str) -> list[int]:
n = len(s)
z = [0] * n
z[0] = n
l = r = 0
for i in range(1, n):
if i < r:
z[i] = min(r - i, z[i - l])
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
z[i] += 1
if i + z[i] > r:
l, r = i, i + z[i]
return zO(n + m).O(n + m).# is the sentinel — ensures
z[i] doesn’t cross the boundary between pattern and
text.Fully solved in Chapter 34.6 (uses the Z function).
score(s_i) = z[n - len(s_i)] (matched-prefix length as
we build). Summing gives sum(z[i] for i in range(1, n)) + n
(counting z[0] = n).
class Solution:
def sumScores(self, s: str) -> int:
n = len(s)
z = [0] * n
z[0] = n
l = r = 0
for i in range(1, n):
if i < r:
z[i] = min(r - i, z[i - l])
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
z[i] += 1
if i + z[i] > r:
l, r = i, i + z[i]
return sum(z)O(n) (Z function).
Space: O(n).Given s. Each operation chooses one of
two moves:
i with 1 ≤ i ≤ len(s) / 2
such that s[0..i-1] == s[i..2i-1] (prefix of length
i matches the following block). Delete the prefix
of length i; the remaining string is
s[i:].i exists, delete the entire
s (end).Return the maximum number of operations until
s is empty.
Input: s = "abcabcdabc"
Output: 2 (max deletions; each deletion removes a prefix equal to the next block)
DP from right to left. dp[i] = max turns from
s[i..].
Transition:
dp[i] = max(1, max(dp[i + j] + 1 for j with s[i..i+j-1] == s[i+j..i+2j-1])).
Compare substrings quickly using the Z function or a precomputed LCP table.
class Solution:
def deleteString(self, s: str) -> int:
n = len(s)
# lcp[i][j] = LCP of s[i:] and s[j:].
lcp = [[0] * (n + 1) for _ in range(n + 1)]
for i in range(n - 1, -1, -1):
for j in range(n - 1, -1, -1):
if s[i] == s[j]:
lcp[i][j] = lcp[i + 1][j + 1] + 1
dp = [1] * (n + 1)
dp[n] = 0
for i in range(n - 1, -1, -1):
for j in range(1, (n - i) // 2 + 1):
if lcp[i][i + j] >= j:
dp[i] = max(dp[i], dp[i + j] + 1)
return dp[0]O(n²). Space:
O(n²).O(L) per check; use the LCP table.Given s, sub, and
mappings[i] = [old, new] (each character old
may be changed to new at most once when matching). Check
whether sub (after applying any subset of mappings) is a
substring of s.
Input: s="fool3e7bar", sub="leet", mappings=[["e","3"],["t","7"],["t","8"]]
Output: True
Brute force O(n · m) with a smart check
(each char matches if equal or via a mapping).
The Z function or KMP can be adapted — more complex.
from collections import defaultdict
from typing import List
class Solution:
def matchReplacement(self, s: str, sub: str, mappings: List[List[str]]) -> bool:
m: dict[str, set[str]] = defaultdict(set)
for a, b in mappings:
m[a].add(b)
def matches(a: str, b: str) -> bool:
return a == b or b in m[a]
n, ns = len(s), len(sub)
for i in range(n - ns + 1):
if all(matches(sub[j], s[i + j]) for j in range(ns)):
return True
return FalseO(n · m).O(k) with k = number of
mappings.Same as problem 34.3, but uses the Z function instead of rolling hash to avoid collisions.
Count the number of distinct substrings of the form
a + a.
Input: text = "aaaa"
Output: 1 (the only distinct echo substring is "aa"; "aaaa" is a+a but counted distinctly)
For each i, compute z on
s[i:]. Find j with z[j] >= j
(= an echo of length j at position i). Track
distinct echoes using a set of
(start_i, len).
class Solution:
def distinctEchoSubstrings(self, s: str) -> int:
def z_function(t: str) -> list[int]:
n = len(t)
z = [0] * n
z[0] = n
l = r = 0
for i in range(1, n):
if i < r:
z[i] = min(r - i, z[i - l])
while i + z[i] < n and t[z[i]] == t[i + z[i]]:
z[i] += 1
if i + z[i] > r:
l, r = i, i + z[i]
return z
seen: set[tuple[int, int]] = set()
n = len(s)
for i in range(n):
z = z_function(s[i:])
for j in range(1, len(z)):
if z[j] >= j:
seen.add((i, j)) # echo "aa" with each a = s[i..i+j-1]
# Filter by content uniqueness.
return len({s[i:i + 2 * j] for i, j in seen})O(n²) worst case.
O(n²) worst.O(n²).aabcaabxaaazi: 0 1 2 3 4 5 6 7 8 9 10 11
s: a a b c a a b x a a a z
Z: – 1 0 0 3 1 0 0 2 1 1 0
The box [l, r) is the currently-matched
prefix region. For each i ∈ [l, r): - If
i + Z[i-l] < r: copy Z[i] = Z[i-l]. -
Otherwise: brute-extend from Z[i] = r - i.
Update the box when extending succeeds:
i = 4: no box, brute: s[4..]=aabxaaaz vs s[0..]=aabcaab... ⇒ matches 3 ⇒ Z[4]=3, [l,r)=[4,7)
i = 5: i-l=1, Z[1]=1, i+Z[1]=6 < 7 ⇒ Z[5] = 1 (copy)
i = 6: i-l=2, Z[2]=0, 6+0=6 < 7 ⇒ Z[6] = 0
i = 7: outside box, brute: Z[7]=0
i = 8: brute, match aaz vs aab → 2 ⇒ Z[8]=2, [l,r)=[8,10)
lcp[i][j] = LCP of the suffixes
s[i:] and s[j:]. The Z function can compute
this indirectly, but a 2D DP is more natural.KMP/Z assume equality comparison. When the matching relation is not symmetric (e.g. constrained wildcards), the failure function cannot be reused — DP is needed instead.
Full solution in Chapter 34 (rolling hash).
Recapping here to contrast: Z-function can count an echo
s[i:i+L] == s[i+L:i+2L] by checking
Z[i+L] ≥ L.
When a problem looks like “min of max” or “max of min” on a graph/grid, we can binary-search the answer + check feasibility via BFS/DFS. The pattern shines when we want a simple
O((V + E) · log range)solution instead of a fancier Dijkstra or MST.
After this chapter, you will be able to:
can_reach(x): with threshold x, is there a
path?def search_on_answer_graph(lo, hi, can_reach):
while lo < hi:
mid = (lo + hi) // 2
if can_reach(mid):
hi = mid
else:
lo = mid + 1
return loSolved two ways (offline DSU, Dijkstra) in Chapters 24.6 and 30.4. Here is a third way: binary search on
t+ BFS check.
An n × n elevation grid. At time t, every
cell with elevation ≤ t is covered with water. Find the smallest
t such that you can swim from (0,0) to
(n-1,n-1).
Binary search on t. For each
t, BFS to check whether a path exists through cells of
elevation ≤ t. The predicate is monotonic: a larger t lets us reach more
cells → it’s easier to find a path.
from collections import deque
from typing import List
class Solution:
def swimInWater(self, grid: List[List[int]]) -> int:
n = len(grid)
DIRS = [(-1,0),(1,0),(0,-1),(0,1)]
def can_reach(t: int) -> bool:
if grid[0][0] > t: return False
visited = {(0, 0)}
queue = deque([(0, 0)])
while queue:
r, c = queue.popleft()
if (r, c) == (n - 1, n - 1): return True
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < n and grid[nr][nc] <= t and (nr, nc) not in visited:
visited.add((nr, nc))
queue.append((nr, nc))
return False
lo, hi = 0, n * n - 1
while lo < hi:
mid = (lo + hi) // 2
if can_reach(mid): hi = mid
else: lo = mid + 1
return loO(n² · log(n²)).O(n²).Solved via Dijkstra in Chapter 30.2. BS version: binary search on effort, BFS check.
from collections import deque
from typing import List
class Solution:
def minimumEffortPath(self, heights: List[List[int]]) -> int:
rows, cols = len(heights), len(heights[0])
DIRS = [(-1,0),(1,0),(0,-1),(0,1)]
def can_reach(e: int) -> bool:
visited = {(0, 0)}
queue = deque([(0, 0)])
while queue:
r, c = queue.popleft()
if (r, c) == (rows - 1, cols - 1): return True
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and (nr, nc) not in visited:
if abs(heights[nr][nc] - heights[r][c]) <= e:
visited.add((nr, nc))
queue.append((nr, nc))
return False
lo, hi = 0, 10**6
while lo < hi:
mid = (lo + hi) // 2
if can_reach(mid): hi = mid
else: lo = mid + 1
return loO(R · C · log(max_height)).O(R · C).A grid row × col. Each day one water cell
cells[i] becomes wet. Find the last day on which you can
still cross from row 0 to row n-1 over land cells.
Input: row=2, col=2, cells=[[1,1],[2,1],[1,2],[2,2]]
Output: 2
Binary search on d + BFS/DFS check:
after d days, is the grid still passable?
from collections import deque
from typing import List
class Solution:
def latestDayToCross(self, row: int, col: int, cells: List[List[int]]) -> int:
def can_cross(d: int) -> bool:
grid = [[0] * col for _ in range(row)]
for i in range(d):
r, c = cells[i]
grid[r - 1][c - 1] = 1 # water
queue = deque()
visited = set()
for c in range(col):
if grid[0][c] == 0:
queue.append((0, c))
visited.add((0, c))
DIRS = [(-1,0),(1,0),(0,-1),(0,1)]
while queue:
r, c = queue.popleft()
if r == row - 1: return True
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < row and 0 <= nc < col and grid[nr][nc] == 0 and (nr, nc) not in visited:
visited.add((nr, nc))
queue.append((nr, nc))
return False
lo, hi = 0, len(cells)
while lo < hi:
mid = (lo + hi + 1) // 2
if can_cross(mid): lo = mid
else: hi = mid - 1
return loO(R · C · log K) where K =
number of days.O(R · C).2D extension of Trapping Rain Water. Compute the total water trapped on a heights grid.
Input: heightMap = [[1,4,3,1,3,2],
[3,2,1,3,2,4],
[2,3,3,2,3,1]] (m × n grid; each cell is a column height)
Output: 4 (total trapped water after rain)
Dijkstra/BFS from the border with a min-heap: instead of binary search, pop the lowest border cell. Each pop raises the water level for its neighbours.
import heapq
from typing import List
class Solution:
def trapRainWater(self, heights: List[List[int]]) -> int:
rows, cols = len(heights), len(heights[0])
visited = [[False] * cols for _ in range(rows)]
heap = []
for r in range(rows):
for c in range(cols):
if r == 0 or r == rows - 1 or c == 0 or c == cols - 1:
heapq.heappush(heap, (heights[r][c], r, c))
visited[r][c] = True
water = 0
DIRS = [(-1,0),(1,0),(0,-1),(0,1)]
while heap:
h, r, c = heapq.heappop(heap)
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and not visited[nr][nc]:
visited[nr][nc] = True
water += max(0, h - heights[nr][nc])
heapq.heappush(heap, (max(h, heights[nr][nc]), nr, nc))
return waterO(R · C · log(R · C)).O(R · C).Input: four integers divisor1, divisor2,
uniqueCnt1, uniqueCnt2.
We need to build two arrays arr1 (length
uniqueCnt1) and arr2 (length
uniqueCnt2) of positive integers such that: - Every element
of arr1 is not divisible by
divisor1. - Every element of arr2 is
not divisible by divisor2. -
arr1 ∪ arr2 consists of distinct values
(no duplicates between arrays).
Return the smallest possible maximum value in
arr1 ∪ arr2.
Input: divisor1 = 2, divisor2 = 7, uniqueCnt1 = 1, uniqueCnt2 = 3
Output: 4
Explanation:
arr1 = [1] (1 not divisible by 2)
arr2 = [2, 3, 4] (none divisible by 7)
max(arr1 ∪ arr2) = 4, minimised.
Binary search the answer X: - Numbers
in [1, X] not divisible by d1:
X - X // d1. - Similarly for d2. - Only
divisible by lcm(d1, d2): shared budget — decide who takes
what.
Inclusion-exclusion + binary search.
from math import lcm
class Solution:
def minimizeSet(self, divisor1: int, divisor2: int, uniqueCnt1: int, uniqueCnt2: int) -> int:
L = lcm(divisor1, divisor2)
def enough(x: int) -> bool:
# Numbers ≤ x divisible by d1: x // d1; by d2: x // d2; by L: x // L
available1 = x - x // divisor1
available2 = x - x // divisor2
available_both = x - x // L
# arr1 needs available1 for itself but may share with arr2.
need1 = max(0, uniqueCnt1 - (available1 - (x // divisor2 - x // L)))
# ... brain teaser; the formula version is cleaner.
shared = available_both
return available1 >= uniqueCnt1 and available2 >= uniqueCnt2 and \
shared >= max(0, uniqueCnt1 - (x - x // divisor1 - (x // divisor2 - x // L))) + \
max(0, uniqueCnt2 - (x - x // divisor2 - (x // divisor1 - x // L)))
lo, hi = 1, 10**11
while lo < hi:
mid = (lo + hi) // 2
if enough(mid):
hi = mid
else:
lo = mid + 1
return loO(log(10^11)).O(1).A matrix. A peak is a cell greater than its 4 neighbours. Return
[r, c] of any peak. O(m log n).
Input: mat = [[1, 4],
[3, 2]] (all values distinct; boundary treated as -∞)
Output: [0, 1] (one valid peak: larger than 4 neighbours; multiple may exist, return any)
Binary search on columns. Pick the middle column, find the max in it → that row “owns the peak”. Compare with the two adjacent columns: if the max < the left column → the peak lies on the left; similarly for the right.
from typing import List
class Solution:
def findPeakGrid(self, mat: List[List[int]]) -> List[int]:
rows, cols = len(mat), len(mat[0])
lo, hi = 0, cols - 1
while lo <= hi:
mid = (lo + hi) // 2
max_row = max(range(rows), key=lambda r: mat[r][mid])
left = mat[max_row][mid - 1] if mid > 0 else -1
right = mat[max_row][mid + 1] if mid < cols - 1 else -1
if mat[max_row][mid] > left and mat[max_row][mid] > right:
return [max_row, mid]
if left > mat[max_row][mid]:
hi = mid - 1
else:
lo = mid + 1
return [-1, -1]O(rows · log cols).O(1).def solve():
lo, hi = min_possible, max_possible
while lo < hi:
mid = (lo + hi) // 2
if can_reach(mid): # reachability with threshold/capacity mid
hi = mid
else:
lo = mid + 1
return lo
can_reach(x) is a BFS/DFS with a
constraint depending on x.x is larger (more lenient),
can_reach can only transition
False → True.| Problem | Answer | can(x) |
Monotonic |
|---|---|---|---|
| Path with Min Effort (LC 1631) | max edge diff | BFS through edges ≤ x | True as x ↗ |
| Swim in Rising Water (LC 778) | min threshold | BFS through cells ≤ x | True as x ↗ |
| Min Bridges (LC 1102) | max path min | DFS through cells ≥ x | True as x ↘ |
| Aggressive Cows / Magnetic Force | distance | Greedy placement | True as x ↘ |
O((V+E) log range).At its core this is Dijkstra-like with a priority queue (pick the lowest boundary cell next). Placed in Chapter 37 to contrast “BS + Graph” with “Min-heap traversal” — both belong to the “answer = threshold” family.
When a DP transition needs to “quickly find the optimal value across already-considered states”, binary searching into the prefix optimum drops
O(n²)toO(n log n). The pattern appears in LIS, Russian Doll Envelopes, Constrained Subsequence Sum.
After this chapter, you will be able to:
O(n log n) with bisect on
tails.dp[i] = max/min(...) whose argmax can be found via
binary search.from bisect import bisect_left, bisect_right
# DP with binary search lookup
tails = []
for x in arr:
idx = bisect_left(tails, x)
if idx == len(tails):
tails.append(x)
else:
tails[idx] = xFully solved in Chapter 29.6. Sort 2D + LIS with binary search.
This is the gateway to BS + DP: after sorting 2D,
the LIS transition (tails[bisect_left(tails, x)] = x) is
literally binary search over the DP structure.
from bisect import bisect_left
from typing import List
class Solution:
def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
envelopes.sort(key=lambda e: (e[0], -e[1]))
tails: list[int] = []
for _, h in envelopes:
i = bisect_left(tails, h)
if i == len(tails):
tails.append(h)
else:
tails[i] = h
return len(tails)O(n log n).
Space: O(n).LIS with the constraint nums[i+1] - nums[i] <= k.
Find the maximum length.
Input: nums=[4,2,1,4,3,4,5,8,15], k=3
Output: 5
<).Segment Tree with max query (range max over dp values).
dp[v] = max LIS ending at value v.
Transition: dp[v] = max(dp[v-k..v-1]) + 1.
A segment tree supports the range-max query in O(log V)
per step. Total O(n log V).
from typing import List
class Solution:
def lengthOfLIS(self, nums: List[int], k: int) -> int:
n = max(nums)
size = 1
while size < n + 1: size <<= 1
tree = [0] * (2 * size)
def update(i: int, val: int):
i += size
tree[i] = max(tree[i], val)
while i > 1:
i //= 2
tree[i] = max(tree[2*i], tree[2*i+1])
def query(l: int, r: int) -> int:
res = 0
l += size; r += size + 1
while l < r:
if l & 1: res = max(res, tree[l]); l += 1
if r & 1: r -= 1; res = max(res, tree[r])
l //= 2; r //= 2
return res
best = 0
for x in nums:
lo = max(1, x - k)
cur = query(lo, x - 1) + 1
update(x, cur)
best = max(best, cur)
return bestO(n log V) with V = max
value.O(V) for the segment tree.Count the number of LISes of nums.
Input: nums = [1, 3, 5, 4, 7]
Output: 2 (longest LISes: [1,3,5,7] and [1,3,4,7])
O(n²) DP: dp[i] = (length, count) — length
of the LIS ending at i and its count.
An O(n log n) version with a segment tree exists.
from typing import List
class Solution:
def findNumberOfLIS(self, nums: List[int]) -> int:
n = len(nums)
length = [1] * n
count = [1] * n
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
if length[j] + 1 > length[i]:
length[i] = length[j] + 1
count[i] = count[j]
elif length[j] + 1 == length[i]:
count[i] += count[j]
max_len = max(length)
return sum(c for l, c in zip(length, count) if l == max_len)O(n²).O(n).dp[i] = (len, cnt).A matrix. Find the submatrix with maximum sum ≤ k.
Input: matrix=[[1,0,1],[0,-2,3]], k=2
Output: 2
Fix two columns (c1, c2). Row sums between them → 1D
array. Find the subarray sum closest to k but ≤ k: prefix sum +
SortedList + binary search.
from sortedcontainers import SortedList
from typing import List
class Solution:
def maxSumSubmatrix(self, matrix: List[List[int]], k: int) -> int:
rows, cols = len(matrix), len(matrix[0])
result = -float('inf')
for c1 in range(cols):
row_sums = [0] * rows
for c2 in range(c1, cols):
for r in range(rows):
row_sums[r] += matrix[r][c2]
# Find the largest subarray sum ≤ k.
sl = SortedList([0])
prefix = 0
for v in row_sums:
prefix += v
# Find prefix' ≥ prefix - k → subarray sum = prefix - prefix' ≤ k.
idx = sl.bisect_left(prefix - k)
if idx < len(sl):
result = max(result, prefix - sl[idx])
sl.add(prefix)
return resultO(rows · cols² · log).O(cols).SortedList (requires
pip install sortedcontainers) — not in Python’s
stdlib.Given nums and k, find the max sum of a
subsequence such that consecutive chosen indices differ by ≤
k.
Input: nums=[10,2,-10,5,20], k=2
Output: 37
DP dp[i] = nums[i] + max(0, max(dp[i-k..i-1])).
The max(dp[i-k..i-1]) is computed via sliding
window max (Chapter 18.4) → O(n).
from collections import deque
from typing import List
class Solution:
def constrainedSubsetSum(self, nums: List[int], k: int) -> int:
n = len(nums)
dp = [0] * n
dq: deque[int] = deque()
best = -float('inf')
for i in range(n):
window_max = dp[dq[0]] if dq else 0
dp[i] = nums[i] + max(0, window_max)
best = max(best, dp[i])
while dq and dp[dq[-1]] <= dp[i]:
dq.pop()
dq.append(i)
if dq[0] == i - k:
dq.popleft()
return bestO(n).O(n) for deque + dp.max(0, ...) → a
negative dp may be wrongly picked.Given houses (positions). Place k mailboxes
minimising the sum of
distance(house → nearest mailbox).
Input: houses=[1,4,8,10,20], k=3
Output: 5
DP: dp[i][j] = min cost using j mailboxes
for houses[:i].
Transition:
dp[i][j] = min(dp[p][j-1] + cost(houses[p:i])) where
cost is the total distance from placing 1 mailbox on the
range — = sum |x - median|.
O(k · n²). Can be sped up with divide-conquer DP
optimisation → O(k · n log n).
from typing import List
class Solution:
def minDistance(self, houses: List[int], k: int) -> int:
houses.sort()
n = len(houses)
# cost[l][r] = cost of placing 1 mailbox for houses[l..r]
cost = [[0] * n for _ in range(n)]
for l in range(n):
for r in range(l + 1, n):
mid = houses[(l + r) // 2]
cost[l][r] = sum(abs(houses[i] - mid) for i in range(l, r + 1))
INF = float('inf')
dp = [[INF] * (k + 1) for _ in range(n + 1)]
dp[0][0] = 0
for i in range(1, n + 1):
for j in range(1, k + 1):
for p in range(j - 1, i):
dp[i][j] = min(dp[i][j], dp[p][j - 1] + cost[p][i - 1])
return dp[n][k]O(k · n² + n³) (precompute cost
+ DP).O(k · n + n²).tails array:
bisect_left to find the replacement position =
binary search.(w asc, h desc)
then LIS on h = patience.LC 2407 (Longest Increasing Subsequence II): plain patience doesn’t
work because of the ≤ k gap constraint ⇒ we need a segment
tree lookup over dp[v - k .. v - 1].
SortedList + bisect to find the
nearest prefix - target ≤ k ⇒ not pure DP,
but in the same “binary search into a sorted structure” family.[l..r], the optimal cost = total
distance to the median ⇒ precompute
cost[l][r].dp[k][i] = partition houses[..i] into
k clusters.dp[k][i] = min_{j} dp[k-1][j] + cost[j+1][i].Sort + DP is a powerful combination when item order matters and DP proceeds in that order. A common pattern: sort by one attribute, then LIS-like DP on another.
After this chapter, you will be able to:
(w asc, h desc) trick so equal w doesn’t fake
an LIS.Given nums (distinct). Find the largest subset such that
for every pair (a, b) either a % b == 0 or
b % a == 0.
Input: nums = [1, 2, 4, 8]
Output: [1, 2, 4, 8] (largest subset where every pair is divisible)
Sort ascending. dp[i] = largest subset ending at
nums[i].
dp[i] = max(dp[j] + 1 for j < i if nums[i] % nums[j] == 0).
Track parent[] to reconstruct the subset.
from typing import List
class Solution:
def largestDivisibleSubset(self, nums: List[int]) -> List[int]:
nums.sort()
n = len(nums)
dp = [1] * n
parent = [-1] * n
best_idx = 0
for i in range(n):
for j in range(i):
if nums[i] % nums[j] == 0 and dp[j] + 1 > dp[i]:
dp[i] = dp[j] + 1
parent[i] = j
if dp[i] > dp[best_idx]:
best_idx = i
result = []
while best_idx != -1:
result.append(nums[best_idx])
best_idx = parent[best_idx]
return result[::-1]O(n²) DP +
O(n log n) sort.O(n).Fully solved in Chapter 29.6. Sort 2D + LIS with binary search.
The “sort 2D + LIS on the other dimension” pattern reappears in 39.3 (Stacking Cuboids, 3D), 39.5 (Visible People), and beyond.
from bisect import bisect_left
from typing import List
class Solution:
def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
envelopes.sort(key=lambda e: (e[0], -e[1]))
tails: list[int] = []
for _, h in envelopes:
i = bisect_left(tails, h)
if i == len(tails):
tails.append(h)
else:
tails[i] = h
return len(tails)O(n log n).
Space: O(n).Given cuboids[i] = [w, l, h]. Each cuboid can be rotated
(any of the three dimensions can serve as height). Cuboid i
can be stacked on j if every dimension of i ≤
every dimension of j. Find the max total height.
Input: cuboids = [[50,45,20], [95,37,53], [45,23,12]]
(each cuboid [w, l, h]; rotations allowed; stack if w1≤w2, l1≤l2, h1≤h2)
Output: 190 (the maximum stack height)
Insight: sort each cuboid’s three dimensions → cuboid A can stack on B ↔︎ A’s sorted dims ≤ B’s sorted dims (after sorting all cuboids ascending).
Then the problem becomes a 3D LIS.
from typing import List
class Solution:
def maxHeight(self, cuboids: List[List[int]]) -> int:
for c in cuboids:
c.sort()
cuboids.sort()
n = len(cuboids)
dp = [c[2] for c in cuboids]
for i in range(n):
for j in range(i):
if all(cuboids[j][k] <= cuboids[i][k] for k in range(3)):
dp[i] = max(dp[i], dp[j] + cuboids[i][2])
return max(dp)O(n²) DP +
O(n log n) sort.O(n).Given jobs = [(start, end, profit)]. Pick
non-overlapping jobs to maximise total profit.
Input: startTime=[1,2,3,3], endTime=[3,4,5,6], profit=[50,10,40,70]
Output: 120
Sort by end. dp[i] = max profit using jobs
[0..i].
dp[i] = max(dp[i-1], dp[k] + jobs[i].profit) where
k = the last job with
end <= jobs[i].start.
Binary search to find k → O(n log n).
from bisect import bisect_right
from typing import List
class Solution:
def jobScheduling(self, startTime: List[int], endTime: List[int], profit: List[int]) -> int:
jobs = sorted(zip(endTime, startTime, profit))
ends = [j[0] for j in jobs]
dp = [0] * (len(jobs) + 1)
for i, (end, start, p) in enumerate(jobs):
k = bisect_right(ends, start)
dp[i + 1] = max(dp[i], dp[k] + p)
return dp[-1]O(n log n).O(n).Given heights. Each person sees the people behind them in the queue until they hit someone taller than themselves or taller than the one they just saw. Count visible people per person.
Input: heights = [10, 6, 8, 5, 11, 9]
(heights[i] = height of the i-th person in the queue; all distinct)
Output: [3, 1, 2, 1, 1, 0]
(answer[i] = number of people on the right that the i-th person can see)
Monotonic stack from right to left. For each person: pop everyone shorter (count them), and add 1 if the stack still has someone taller.
from typing import List
class Solution:
def canSeePersonsCount(self, heights: List[int]) -> List[int]:
n = len(heights)
result = [0] * n
stack: list[int] = []
for i in range(n - 1, -1, -1):
while stack and heights[i] > stack[-1]:
stack.pop()
result[i] += 1
if stack:
result[i] += 1
stack.append(heights[i])
return resultO(n).O(n).A garden of length n. Tap i at position
i waters [i - r, i + r]. Find the minimum
number of taps that fully cover the garden.
Input: n=5, ranges=[3,4,1,1,0,0]
Output: 1
Reduce to Jump Game II (16.2): for each position
i, farthest[i] = the farthest position
reachable starting from i. Greedy.
from typing import List
class Solution:
def minTaps(self, n: int, ranges: List[int]) -> int:
farthest = [0] * (n + 1)
for i, r in enumerate(ranges):
left = max(0, i - r)
right = min(n, i + r)
farthest[left] = max(farthest[left], right)
taps = 0
current_end = 0
next_end = 0
for i in range(n + 1):
if i > next_end:
return -1
if i > current_end:
taps += 1
current_end = next_end
next_end = max(next_end, farthest[i])
return tapsO(n + max_range).O(n + max_range).This chapter collects problems whose first step is sort, followed by: - DP (Russian Doll, Job Scheduling). - Monotonic stack (LC 1944 Visible People — standalone but order-dependent). - Greedy intervals (LC 1326 Minimum Taps).
The chapter label is broader than rigid “Sort + DP”; think of it as “Sort preprocessing patterns”.
jobs sorted by endTime: J1=[1,3,50], J2=[2,4,10], J3=[3,5,40], J4=[3,6,70]
↑ ↑ ↑
end=3 end=4 end=5,6
dp[i] = max(dp[i-1], jobs[i].profit + dp[prev(i)])
prev(i) = last job with endTime ≤ jobs[i].startTime ← binary search!
(i, range) into the interval
[i - r, i + r].When the state is a subset of a small set (≤ 20 elements), we encode the subset as a 32-bit
intbitmask and DP over the bitmask. The state space isO(2^n). The pattern powers TSP-style, set cover, and partition problems.
After this chapter, you will be able to:
n ≤ 20-22 (since
2^20 ≈ 10^6).sub = (sub - 1) & mask.n ≤ 20-22 (since 2^20 ≈ 10^6 fits).from functools import cache
@cache
def dp(mask, *extra_state):
if mask == 0: # base case (or full = (1 << n) - 1)
return base_value
return best([dp(mask ^ (1 << i), ...) for i in iterate_bits(mask)])Given nums and k. Can we partition into
k subsets with equal sums?
Input: nums=[4,3,2,3,5,2,1], k=4
Output: True
dp[mask] = boolean “subset indicated by bitmask can be
partitioned into valid groups”.
total = sum(nums). Each subset must sum to
total / k.
Walk: for every mask, if dp[mask] is True
and current_subset_sum % target == 0, try adding unused
elements.
from functools import cache
from typing import List
class Solution:
def canPartitionKSubsets(self, nums: List[int], k: int) -> bool:
total = sum(nums)
if total % k: return False
target = total // k
nums.sort(reverse=True)
if nums[0] > target: return False
n = len(nums)
@cache
def dfs(mask: int, current_sum: int) -> bool:
if mask == (1 << n) - 1:
return True
for i in range(n):
if mask & (1 << i): continue
new_sum = current_sum + nums[i]
if new_sum > target: continue
next_sum = new_sum if new_sum < target else 0
if dfs(mask | (1 << i), next_sum):
return True
return False
return dfs(0, 0)O(k · 2^n · n) worst.O(2^n) memo.next_sum = 0 when a subset is
full: “close” the current subset and open a new one.Input: graph: List[List[int]] — adjacency list of a
connected undirected graph with n nodes
(labelled 0..n-1). Find the shortest path (edge count) that
visits every node; you may start and end anywhere, and revisit
nodes.
Input: graph = [[1,2,3], [0], [0], [0]]
(undirected adjacency list; graph[i] = neighbours of node i; start/end anywhere)
Output: 4 (shortest edge count visiting every node; revisits allowed)
BFS with state (node, visited_mask).
The answer is the first step count that reaches a state
(_, full_mask).
from collections import deque
from typing import List
class Solution:
def shortestPathLength(self, graph: List[List[int]]) -> int:
n = len(graph)
full = (1 << n) - 1
if n == 1: return 0
queue = deque((i, 1 << i, 0) for i in range(n)) # (node, mask, dist)
visited = {(i, 1 << i) for i in range(n)}
while queue:
node, mask, dist = queue.popleft()
if mask == full: return dist
for nb in graph[node]:
new_mask = mask | (1 << nb)
if (nb, new_mask) not in visited:
visited.add((nb, new_mask))
queue.append((nb, new_mask, dist + 1))
return -1O(2^n · n²) states ×
transitions.O(2^n · n).Given req_skills and
people[i] = list of skill. Find the
smallest subset of people covering every required
skill.
Input: req_skills=["java","nodejs","reactjs"], people=[["java"],["nodejs"],["nodejs","reactjs"]]
Output: [0,2]
Bitmask DP over skills. dp[mask] = smallest team
covering skills in mask.
dp[mask | skills_of_p] = min(dp[mask | skills_of_p], dp[mask] + [p]).
from typing import List
class Solution:
def smallestSufficientTeam(self, req_skills: List[str], people: List[List[str]]) -> List[int]:
skill_idx = {s: i for i, s in enumerate(req_skills)}
n = len(req_skills)
full = (1 << n) - 1
people_mask = []
for p in people:
mask = 0
for s in p:
if s in skill_idx:
mask |= 1 << skill_idx[s]
people_mask.append(mask)
dp: dict[int, list[int]] = {0: []}
for i, mask in enumerate(people_mask):
for cur_mask, team in list(dp.items()):
new_mask = cur_mask | mask
if new_mask == cur_mask: continue
if new_mask not in dp or len(team) + 1 < len(dp[new_mask]):
dp[new_mask] = team + [i]
return dp[full]O(2^k · m) with k = number of
skills, m = number of people.O(2^k).Given an array of strings, find the shortest string that contains all of them as substrings.
Input: words = ["alex", "loves", "leetcode"]
Output: "alexlovesleetcode" (shortest string containing every word as substring;
multiple answers may exist)
Bitmask DP combined with TSP. State
(mask, last_string_idx) = shortest string visiting set
mask and ending at last_idx.
Precompute overlap[i][j] = max overlap when
concatenating words[i] + words[j].
from typing import List
class Solution:
def shortestSuperstring(self, words: List[str]) -> str:
n = len(words)
# overlap[i][j] = max k such that words[i] ends with the length-k prefix of words[j].
overlap = [[0] * n for _ in range(n)]
for i in range(n):
for j in range(n):
if i != j:
for k in range(min(len(words[i]), len(words[j])), 0, -1):
if words[i].endswith(words[j][:k]):
overlap[i][j] = k
break
INF = float('inf')
# dp[mask][i] = length of the shortest superstring visiting set mask, ending at i.
dp = [[INF] * n for _ in range(1 << n)]
parent = [[-1] * n for _ in range(1 << n)]
for i in range(n):
dp[1 << i][i] = len(words[i])
for mask in range(1 << n):
for i in range(n):
if not (mask & (1 << i)) or dp[mask][i] == INF:
continue
for j in range(n):
if mask & (1 << j):
continue
new_mask = mask | (1 << j)
new_len = dp[mask][i] + len(words[j]) - overlap[i][j]
if new_len < dp[new_mask][j]:
dp[new_mask][j] = new_len
parent[new_mask][j] = i
# Trace path: find end-state with minimum length.
full = (1 << n) - 1
last = min(range(n), key=lambda i: dp[full][i])
# Reconstruct order backwards.
order: list[int] = []
mask = full
cur = last
while cur != -1:
order.append(cur)
prev = parent[mask][cur]
mask ^= 1 << cur
cur = prev
order.reverse()
# Build the superstring.
result = words[order[0]]
for idx in range(1, len(order)):
prev_i, cur_i = order[idx - 1], order[idx]
result += words[cur_i][overlap[prev_i][cur_i]:]
return resultO(2^n · n²).O(2^n · n) for dp +
parent.O(2^n · n²),
Space: O(2^n · n). With
n ≤ 12, feasible.An exam room m × n. seats[i][j] =
. (OK) or # (broken). Two students cannot sit
immediately left/right of each other or diagonally adjacent (they could
copy). Maximise the number of students seated.
Input: seats = [["#",".","#","#",".","#"],
[".","#","#","#","#","."],
["#",".","#","#",".","#"]]
("." = OK seat, "#" = broken; students can see neighbours in 4 diagonals + sides)
Output: 4 (max students placed without cheating)
Row DP bitmask. dp[i][mask] = max in
row i with students seated according to mask.
Each mask must be valid (no two adjacent bits) and not overlap broken cells.
Transition:
dp[i][mask] = max(dp[i-1][prev]) + popcount(mask) for
prev not diagonally conflicting with mask.
from typing import List
class Solution:
def maxStudents(self, seats: List[List[str]]) -> int:
m, n = len(seats), len(seats[0])
# row_bad[i] = bitmask of '#' cells in row i.
row_bad = [0] * m
for i in range(m):
for j in range(n):
if seats[i][j] == '#':
row_bad[i] |= 1 << j
# Valid masks: no two adjacent bits.
valid = [m for m in range(1 << n) if (m & (m << 1)) == 0]
dp = {0: 0} # mask -> max students
for i in range(m):
new_dp = {}
for mask in valid:
if mask & row_bad[i]: continue
cnt = bin(mask).count('1')
best = 0
for prev, prev_cnt in dp.items():
if (mask & (prev << 1)) or (mask & (prev >> 1)): continue
best = max(best, prev_cnt)
new_dp[mask] = best + cnt
dp = new_dp
return max(dp.values(), default=0)O(m · 2^n · 2^n) worst.O(2^n).mask & (prev << 1) and
mask & (prev >> 1).Two arrays of length n. Permute nums2 to
minimise Σ nums1[i] ^ nums2[π(i)].
Input: nums1=[1,2], nums2=[2,3]
Output: 2
Bitmask DP in O(n · 2^n). dp[mask] = min
sum once we’ve matched the indices in mask (popcount =
number of i’s processed) with the same number of nums2 elements.
from typing import List
class Solution:
def minimumXORSum(self, nums1: List[int], nums2: List[int]) -> int:
n = len(nums1)
INF = float('inf')
dp = [INF] * (1 << n)
dp[0] = 0
for mask in range(1 << n):
i = bin(mask).count('1')
if i >= n: continue
for j in range(n):
if mask & (1 << j): continue
new_mask = mask | (1 << j)
dp[new_mask] = min(dp[new_mask], dp[mask] + (nums1[i] ^ nums2[j]))
return dp[(1 << n) - 1]O(2^n · n).O(2^n).i = popcount(mask) indicates
which element of nums1 we are currently matching.| Operation | Code |
|---|---|
Is bit i set? |
(mask >> i) & 1 |
Set bit i |
mask | (1 << i) |
Clear bit i |
mask & ~(1 << i) |
Toggle bit i |
mask ^ (1 << i) |
| Lowest set bit | mask & -mask |
| Pop count | bin(mask).count('1') or int.bit_count()
(Py 3.10+) |
| Iterate submasks | sub = mask; while sub: ...; sub = (sub - 1) & mask
(final iteration still has sub=0) |
| Iterate set bits | while mask: i = (mask & -mask).bit_length() - 1; mask &= mask - 1 |
n |
Bitmask 2^n |
Common DP shape | Time |
|---|---|---|---|
| ≤ 20 | ≤ 10⁶ | dp[mask] (TSP, Assignment) |
O(2^n · n) |
| ≤ 16 | ≤ 65k | dp[mask][i] (TSP with endpoint) |
O(2^n · n²) |
| ≤ 22-25 | ≤ 33M | Need optimisation or submask enumeration | O(3^n) for subset-sum DP |
Row mask s (bit i = 1 if a student sits in column i):
- Valid within the row: s & (s << 1) == 0 (no two students adjacent).
- Compatible with previous mask p:
(s & (p << 1)) == 0 AND (s & (p >> 1)) == 0
- Must respect broken cells: s & broken_mask[row] == 0.
When a problem needs “max XOR” with numbers from an array, we build a binary Trie of the numbers (one level per bit). The max XOR query is a greedy DFS that follows the bit opposite to the current number.
After this chapter, you will be able to:
Note: each
### Code (Python 3)below is standalone — paste the whole block into LeetCode. If you just want a reference template, use this class:
class BinaryTrie:
"""Binary Trie for 32-bit ints. Supports insert / max_xor."""
def __init__(self):
self.children = [None, None] # 0, 1
def insert(self, x: int, bits: int = 31) -> None:
node = self
for b in range(bits, -1, -1):
bit = (x >> b) & 1
if not node.children[bit]:
node.children[bit] = BinaryTrie()
node = node.children[bit]
def max_xor(self, x: int, bits: int = 31) -> int:
node = self
out = 0
for b in range(bits, -1, -1):
bit = (x >> b) & 1
opposite = 1 - bit
if node.children[opposite]:
out |= 1 << b
node = node.children[opposite]
else:
node = node.children[bit]
return outSolved with “greedy bit + set” in Chapter 21.6. The Trie way is more elegant.
Given nums, return the max XOR of two distinct
elements.
Input: nums = [3, 10, 5, 25, 2, 8]
Output: 28 (max XOR between any two elements: 5 XOR 25 = 28)
Build the binary Trie of every number. For each x, query
max_xor(x) (DFS along the opposite bit). Take the max over
all x.
from typing import List
class _Trie:
__slots__ = ("children",)
def __init__(self):
self.children = [None, None]
class Solution:
BITS = 31
def findMaximumXOR(self, nums: List[int]) -> int:
root = _Trie()
for x in nums:
node = root
for b in range(self.BITS, -1, -1):
bit = (x >> b) & 1
if not node.children[bit]:
node.children[bit] = _Trie()
node = node.children[bit]
best = 0
for x in nums:
node = root
cur = 0
for b in range(self.BITS, -1, -1):
bit = (x >> b) & 1
opp = 1 - bit
if node.children[opp]:
cur |= 1 << b
node = node.children[opp]
else:
node = node.children[bit]
best = max(best, cur)
return bestO(n · 32) = O(n).O(n · 32) for the Trie.Given nums and queries[i] = [x, m]. For
each query, return the maximum XOR between x and any
y ∈ nums with y ≤ m. No such y →
-1.
Input: nums = [0,1,2,3,4], queries = [[3,1],[1,3],[5,6]]
Output: [3, 3, 7]
Offline + sort + Trie.
m ascending.nums[i] ≤ m into
the Trie, then query max_xor(x).from typing import List
class _Trie:
__slots__ = ("children",)
def __init__(self):
self.children = [None, None]
class Solution:
BITS = 30 # nums, x ≤ 10^9 < 2^30
def maximizeXor(self, nums: List[int], queries: List[List[int]]) -> List[int]:
def insert(root, x):
node = root
for b in range(self.BITS, -1, -1):
bit = (x >> b) & 1
if not node.children[bit]:
node.children[bit] = _Trie()
node = node.children[bit]
def max_xor(root, x):
node = root
out = 0
for b in range(self.BITS, -1, -1):
bit = (x >> b) & 1
opp = 1 - bit
if node.children[opp]:
out |= 1 << b
node = node.children[opp]
else:
node = node.children[bit]
return out
nums.sort()
indexed = sorted(enumerate(queries), key=lambda kv: kv[1][1])
result = [-1] * len(queries)
root = _Trie()
i = 0
for q_idx, (x, m) in indexed:
while i < len(nums) and nums[i] <= m:
insert(root, nums[i])
i += 1
if i == 0:
result[q_idx] = -1
else:
result[q_idx] = max_xor(root, x)
return resultO((n + q) · 30).
Space: O(n · 30).Given nums and [low, high], count pairs
(i, j) with i < j and
low <= nums[i] ^ nums[j] <= high.
Input: nums = [1, 4, 2, 7], low = 2, high = 6
Output: 6
Explanation of the 6 pairs (i, j) with 2 ≤ nums[i] ^ nums[j] ≤ 6:
(0,1) 1 ^ 4 = 5 ✓
(0,2) 1 ^ 2 = 3 ✓
(0,3) 1 ^ 7 = 6 ✓
(1,2) 4 ^ 2 = 6 ✓
(1,3) 4 ^ 7 = 3 ✓
(2,3) 2 ^ 7 = 5 ✓
count_xor_less(x) = pairs (num, y) (y was
inserted earlier) with num ^ y < x. Answer =
count_xor_less(high + 1) - count_xor_less(low).
The Trie also stores a count at each node = how many
ys passed through it. For each num, walk top
bit to low; at the current bit let bit_n = bit of num,
bit_x = bit of x:
bit_x = 1: the XOR at this bit can
be 0 or 1 while the prefix stays
< x. If XOR bit = 0 (i.e. y
has the same bit as num, walking
children[bit_n]), then the lower bits are
free ⇒ add the whole subtree count:
total += node.children[bit_n].count. Then “claim” XOR bit =
1 by descending into children[1 - bit_n]
(opposite) for the next bits.bit_x = 0: the XOR at this bit
must be 0 (otherwise prefix ≥ x). Descend
into children[bit_n] (same as num), don’t add
anything.In short: “same bit ⇒ XOR = 0” and “opposite bit ⇒ XOR = 1”. The code counts the same-bit subtree (XOR = 0) when
bit_x = 1, then moves the pointer to the opposite branch to continue.
from typing import List
class _TrieCnt:
__slots__ = ("children", "count")
def __init__(self):
self.children = [None, None]
self.count = 0
class Solution:
BITS = 15 # nums[i] ≤ 2·10^4 < 2^15
def countPairs(self, nums: List[int], low: int, high: int) -> int:
def count_less(target: int) -> int:
root = _TrieCnt()
total = 0
for num in nums:
node = root
for b in range(self.BITS, -1, -1):
bit_n = (num >> b) & 1
bit_x = (target >> b) & 1
if bit_x == 1:
# XOR bit = 0 (same bit) → count the entire subtree.
if node.children[bit_n]:
total += node.children[bit_n].count
# Descend into the XOR-bit = 1 branch (= opposite).
if node.children[1 - bit_n]:
node = node.children[1 - bit_n]
else:
node = None
break
else:
# Must keep XOR bit = 0 → walk down the same branch.
if node.children[bit_n]:
node = node.children[bit_n]
else:
node = None
break
# Insert num into the Trie after counting.
node = root
for b in range(self.BITS, -1, -1):
bit_n = (num >> b) & 1
if not node.children[bit_n]:
node.children[bit_n] = _TrieCnt()
node = node.children[bit_n]
node.count += 1
return total
return count_less(high + 1) - count_less(low)O(n · 15).
Space: O(n · 15).low <= ... <= high directly is hard; split into
< (high+1) and < low then subtract.Tree given via parents[i] (parent of i, -1 for root). On
LC this problem assigns no separate value to each node
— the node’s “value” = the node index itself. Each
query (node, val): find max val ^ x where
x is the index of an ancestor (including
node itself). Return the array of maxima in query
order.
Input: parents = [-1, 0, 1, 1]
queries = [[0, 2], [3, 2], [2, 5]]
The tree (parents[0] = -1 makes 0 the root):
0
|
1
/ \
2 3
Output: [2, 3, 7]
Explanation:
query (0, 2): ancestors of 0 are {0}; max(2^0) = 2.
query (3, 2): ancestors of 3 are {3,1,0}; max(2^3, 2^1, 2^0) = max(1,3,2) = 3.
query (2, 5): ancestors of 2 are {2,1,0}; max(5^2, 5^1, 5^0) = max(7,4,5) = 7.
val ^ root_index is one
candidate.vals[]).Offline DFS on the tree. Maintain a Trie containing the indices of ancestors of the current DFS path (including the current node).
u: insert
u’s index into the Trie, answer every query attached to
u.u: remove
u’s index.The Trie must support remove — track
count per node so we know when to prune.
import sys
from collections import defaultdict
from typing import List
class _TrieX:
__slots__ = ("children", "count")
def __init__(self):
self.children = [None, None]
self.count = 0
class Solution:
BITS = 17 # node indices ≤ n - 1 ≤ 10^5 - 1 < 2^17
def maxGeneticDifference(self, parents: List[int], queries: List[List[int]]) -> List[int]:
sys.setrecursionlimit(10**6)
n = len(parents)
children = defaultdict(list)
root_node = -1
for v, p in enumerate(parents):
if p == -1:
root_node = v
else:
children[p].append(v)
# Group queries by node.
node_queries = defaultdict(list)
for q_idx, (node, val) in enumerate(queries):
node_queries[node].append((q_idx, val))
root = _TrieX()
result = [0] * len(queries)
def insert(x: int) -> None:
node = root
for b in range(self.BITS, -1, -1):
bit = (x >> b) & 1
if not node.children[bit]:
node.children[bit] = _TrieX()
node = node.children[bit]
node.count += 1
def remove(x: int) -> None:
node = root
path = []
for b in range(self.BITS, -1, -1):
bit = (x >> b) & 1
path.append((node, bit))
node = node.children[bit]
node.count -= 1
# Prune branches whose count drops to 0.
for parent, bit in reversed(path):
if parent.children[bit] and parent.children[bit].count == 0:
parent.children[bit] = None
def query_max(val: int) -> int:
node = root
out = 0
for b in range(self.BITS, -1, -1):
bit = (val >> b) & 1
opp = 1 - bit
if node.children[opp] and node.children[opp].count > 0:
out |= 1 << b
node = node.children[opp]
else:
node = node.children[bit]
return out
def dfs(u: int) -> None:
insert(u)
for q_idx, val in node_queries[u]:
result[q_idx] = query_max(val)
for v in children[u]:
dfs(v)
remove(u)
dfs(root_node)
return resultO((n + q) · 17).
Space: O(n · 17).n = 10^5),
recursive DFS can blow the stack → switch to iterative.A pair (x, y) is “strong” ↔︎
|x - y| <= min(x, y). Given nums, find the
max XOR over strong pairs.
Input: nums = [1, 2, 3, 4, 5]
Output: 7 ("strong" pairs (x, y) satisfy |x - y| ≤ min(x, y); max XOR = 3 XOR 4 = 7)
Observation: |x - y| ≤ min(x, y) ↔︎
max(x, y) ≤ 2 · min(x, y) (assuming both ≥ 0). Sort nums,
sliding window: for each r, advance l until
nums[r] > 2 · nums[l].
Within the window [l, r], max XOR = Trie query. Sliding
Trie: when l advances, remove
nums[l]. When a new r arrives,
insert nums[r].
from typing import List
class _TrieRem:
__slots__ = ("children", "count")
def __init__(self):
self.children = [None, None]
self.count = 0
class Solution:
BITS = 20 # nums ≤ 2·10^5 < 2^20
def maximumStrongPairXor(self, nums: List[int]) -> int:
nums.sort()
root = _TrieRem()
def insert(x: int) -> None:
node = root
for b in range(self.BITS, -1, -1):
bit = (x >> b) & 1
if not node.children[bit]:
node.children[bit] = _TrieRem()
node = node.children[bit]
node.count += 1
def remove(x: int) -> None:
node = root
for b in range(self.BITS, -1, -1):
bit = (x >> b) & 1
node = node.children[bit]
node.count -= 1
def query_max(x: int) -> int:
node = root
out = 0
for b in range(self.BITS, -1, -1):
bit = (x >> b) & 1
opp = 1 - bit
if node.children[opp] and node.children[opp].count > 0:
out |= 1 << b
node = node.children[opp]
else:
node = node.children[bit]
return out
l = 0
best = 0
for r in range(len(nums)):
insert(nums[r])
while nums[r] > 2 * nums[l]:
remove(nums[l])
l += 1
best = max(best, query_max(nums[r]))
return bestO(n · 20 + n log n).
Space: O(n · 20).Given nums. Operation: pick nums[i] and any
x ≥ 0, set
nums[i] = nums[i] AND (nums[i] XOR x). Find the max XOR of
all elements after any number of operations.
Input: nums = [3, 2, 4, 6]
Output: 7 (each op: pick i, x; replace nums[i] = nums[i] AND (nums[i] XOR x);
max XOR over the whole array = OR of all nums[i] = 3|2|4|6 = 7)
Insight: the operation lets us turn off any
bit of nums[i] (keep existing bits). Because each
bit can be toggled freely on at least one number, the XOR of all = OR of
all → max = OR of all.
from typing import List
class Solution:
def maximumXOR(self, nums: List[int]) -> int:
result = 0
for x in nums:
result |= x
return resultO(n). Space:
O(1).A fuller name: Binary Trie to optimise XOR/operations bit by bit. “Bitmask combined with Trie” in the folder is just shorthand.
count(L, R) = count(≤ R) - count(≤ L - 1).
Build the helper countLE(limit).countLE(limit) for each a[i]: walk top bit
to bottom.
b, if limit’s bit is
1:
a[i]
at bit b is 0 (same bit) → guaranteed ≤ limit
at this bit → add to the answer.a[i]”
branch.limit’s bit is 0: walk only the “same
bit as a[i]” branch.LC’s setup uses parents[i] to build the tree; the
value of node i is simply i.
The Trie stores i along the root → query path.
|x - y| ≤ min(x, y). Assume
x ≤ y ⇒ y - x ≤ x ⇒
y ≤ 2x.y, the candidate
xs lie in the suffix already added.
Maintain a sliding window on the trie.Tree DP = bottom-up DFS on a tree. Each node computes its value from its children. A special pattern: re-rooting — compute the answer for every node as root in
O(n)instead ofO(n²).
After this chapter, you will be able to:
(state1, state2, ...) from
each child.# 1) Bottom-up tree DP
def dfs(node, parent):
state = base
for child in graph[node]:
if child == parent: continue
sub = dfs(child, node)
state = combine(state, sub)
return state
# 2) Re-rooting (see 42.5 for the full implementation)
def re_root(graph, n):
# 1st DFS: compute "down" values (subtree rooted at i).
# 2nd DFS: propagate "up" values from parent to children.
passFully solved in Chapter 11.5. Tree DP with two states per node: rob_this, skip_this.
This is the gateway to Tree DP — the “return a 2-tuple from each child” pattern. The rest of Chapter 42 extends it: Binary Tree Cameras (3 states), Diameter (track best across subtrees), Sum of Distances (re-rooting).
from typing import Optional, Tuple
class Solution:
def rob(self, root) -> int:
def dfs(node) -> Tuple[int, int]:
"""Return (rob_this, skip_this)."""
if not node:
return 0, 0
l_rob, l_skip = dfs(node.left)
r_rob, r_skip = dfs(node.right)
rob_this = node.val + l_skip + r_skip
skip_this = max(l_rob, l_skip) + max(r_rob, r_skip)
return rob_this, skip_this
return max(dfs(root))O(n). Space:
O(h) stack.Place cameras on the tree so every node is “covered” (camera on or adjacent to it). Minimise the number of cameras.
Input: root = [0, 0, null, 0, 0]
(LC level-order serialisation; 'null' = absent node;
node values are irrelevant here — only the structure matters.)
Output: 1 (minimum cameras covering all nodes)
Three states per node: - 0: not yet covered. - 1: covered (no camera here but a child has one). - 2: has a camera here.
Greedy bottom-up: if any child is 0 → install a camera at the current node.
class Solution:
def minCameraCover(self, root) -> int:
self.cnt = 0
def dfs(node) -> int:
if not node: return 1 # null counted as "covered"
l = dfs(node.left)
r = dfs(node.right)
if l == 0 or r == 0:
self.cnt += 1
return 2
if l == 2 or r == 2:
return 1
return 0
if dfs(root) == 0:
self.cnt += 1
return self.cntO(n).O(h) stack.The longest path between any two nodes in the tree (counted in edges).
Input: root = [1, 2, 3, 4, 5] (LC level-order; left → right, top tier → bottom tier)
Output: 3 (paths 4 → 2 → 1 → 3 or 5 → 2 → 1 → 3 contain 3 edges)
Bottom-up DFS. Each node returns its depth downward. The
diameter through a node = left_depth + right_depth.
class Solution:
def diameterOfBinaryTree(self, root) -> int:
self.best = 0
def depth(node) -> int:
if not node: return 0
l = depth(node.left)
r = depth(node.right)
self.best = max(self.best, l + r)
return 1 + max(l, r)
depth(root)
return self.bestO(n).O(h) stack.top1 + top2 (two
longest depths from a node), not top1 * 2.Input: parent: List[int] (parent array,
parent[0] = -1 is root) and s: str — node
i has character s[i]. Tree with n
nodes labelled 0..n-1. Find the longest path (counted in
nodes) where adjacent nodes on the path have different
characters.
Input: parent=[-1,0,0,1,1,2], s="abacbe"
Output: 3
Tree DP. Per node u: longest downward chain =
max(1, 1 + max chain of children with different char).
Update best with the top-2 chains.
from collections import defaultdict
from typing import List
class Solution:
def longestPath(self, parent: List[int], s: str) -> int:
n = len(parent)
children = defaultdict(list)
for i in range(1, n):
children[parent[i]].append(i)
self.best = 1
def dfs(u: int) -> int:
chains = [0]
for v in children[u]:
sub = dfs(v)
if s[v] != s[u]:
chains.append(sub)
chains.sort(reverse=True)
top1 = chains[0] if chains else 0
top2 = chains[1] if len(chains) > 1 else 0
self.best = max(self.best, top1 + top2 + 1)
return top1 + 1
dfs(0)
return self.bestO(n).O(h) stack.s[v] != s[u]) before counting.Input: n (number of nodes) and
edges: List[List[int]] — n-1 undirected tree
edges [u, v]. Return result: List[int] where
result[i] = sum of distances from node i to
every other node.
Input: n=6, edges=[[0,1],[0,2],[2,3],[2,4],[2,5]]
Output: [8,12,6,10,10,10]
Classic re-rooting.
count[u] = subtree
size of u; result[0] = total distance from root 0.result[v] from
result[u] (v’s parent):
result[v] = result[u] - count[v] + (n - count[v]).from collections import defaultdict
from typing import List
class Solution:
def sumOfDistancesInTree(self, n: int, edges: List[List[int]]) -> List[int]:
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
count = [1] * n
answer = [0] * n
def dfs1(u: int, parent: int) -> None:
for v in graph[u]:
if v != parent:
dfs1(v, u)
count[u] += count[v]
answer[u] += answer[v] + count[v]
def dfs2(u: int, parent: int) -> None:
for v in graph[u]:
if v != parent:
answer[v] = answer[u] - count[v] + (n - count[v])
dfs2(v, u)
dfs1(0, -1)
dfs2(0, -1)
return answerO(n).O(n).u to v (v is u’s child), count[v]
nodes “move 1 step closer”, n - count[v] nodes “move 1 step
farther”.Input: n and edges: List[List[int]] — each
[u, v] is a directed edge u → v. Ignoring
direction the graph is a tree (n-1 edges, connected). For
each node, count the edges that need reversing for that node to reach
every other node.
Input: n=4, edges=[[2,0],[2,1],[1,3]]
Output: [1,1,0,2]
Re-rooting with “flip cost”.
result[0] = total
cost.u → v (undirected),
check the original direction: if original is u → v →
`result[v] = result[u]
; if original isv → u→result[v] =
result[u] - 1`.from collections import defaultdict
from typing import List
class Solution:
def minEdgeReversals(self, n: int, edges: List[List[int]]) -> List[int]:
# graph[u] = list of (v, cost) where cost = 0 if directed u→v, 1 if must reverse.
graph = defaultdict(list)
for u, v in edges:
graph[u].append((v, 0)) # u → v: forward
graph[v].append((u, 1)) # v → u: reverse needed to go from v to u
result = [0] * n
def dfs1(u: int, parent: int) -> int:
cost = 0
for v, c in graph[u]:
if v != parent:
cost += c + dfs1(v, u)
return cost
result[0] = dfs1(0, -1)
def dfs2(u: int, parent: int) -> None:
for v, c in graph[u]:
if v != parent:
# When the root moves u → v: the edge u-v with cost c flips.
# Original cost = c → after re-rooting at v: 1 - c.
result[v] = result[u] + (1 - 2 * c)
dfs2(v, u)
dfs2(0, -1)
return resultO(n).O(n).Pass 1 (post-order): compute down[v] =
answer for v’s subtree when rooted at v. Pass 2
(pre-order from root): move the root from parent p
to child c:
ans[c] = ans[p] - contribution_of_c_to_p_subtree + contribution_of_rest_to_c_subtree
For “sum of distances”:
ans[c] = ans[p] - size[c] + (n - size[c])
p down to c:
size[c].n − size[c].ans[c] = ans[p] − size[c] + (n − size[c]).0: node not yet covered, needs an
adjacent camera.1: node has a camera.2: node already covered (by a child
with a camera) but no camera here.0 ⇒ node = 1 (install a
camera, cover the children).1 ⇒ node = 2 (already
covered).2) ⇒ node = 0 (wait
for the parent to cover).Tree:
0
/|\
1 2 3
|
4
down[4] = 0, size[4] = 1.down[1] = down[4] + size[4] = 1, size[1] = 2.down[2] = 0, size[2] = 1.down[3] = 0, size[3] = 1.down[0] = (1+2) + (0+1) + (0+1) = 5, size[0] = 5.ans[0] = 5.ans[1] = ans[0] - size[1] + (5 - size[1]) = 5 - 2 + 3 = 6.
Similarly for 2, 3, 4.ans[v] = reversals needed when the
root is v.DP on a DAG = topological sort + linear DP in topo order. The state at a node depends on the nodes that come “before” it in topo order.
After this chapter, you will be able to:
u → v means u finishes
before v.A matrix. Find the longest strictly-increasing path (4 directions).
Input: matrix = [[9, 9, 4],
[6, 6, 8],
[2, 1, 1]] (m × n grid, 4-dir walk, values must STRICTLY increase)
Output: 4 (longest increasing path: 1 → 2 → 6 → 9)
> not
≥).Implicit DAG: each cell is a node, an edge
u → v exists if mat[v] > mat[u].
DFS with memo.
from functools import cache
from typing import List
class Solution:
def longestIncreasingPath(self, matrix: List[List[int]]) -> int:
rows, cols = len(matrix), len(matrix[0])
DIRS = [(-1,0),(1,0),(0,-1),(0,1)]
@cache
def dfs(r: int, c: int) -> int:
best = 1
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and matrix[nr][nc] > matrix[r][c]:
best = max(best, 1 + dfs(nr, nc))
return best
return max(dfs(r, c) for r in range(rows) for c in range(cols))O(R · C).O(R · C) memo.(r, c); no need
to track visited (implicit DAG).Input: n (number of courses, labelled
1..n), relations: List[List[int]] — each
[a, b] means must finish course a before b
(DAG edge a → b), and time: List[int] with
time[i] = time to learn course i+1. Courses
can be taken in parallel if their prerequisites are
done. Min total time.
Input: n=3, relations=[[1,3],[2,3]], time=[3,2,5]
Output: 8
Topo sort + DP.
dp[u] = time[u] + max(dp[v] for v a prerequisite).
from collections import defaultdict, deque
from typing import List
class Solution:
def minimumTime(self, n: int, relations: List[List[int]], time: List[int]) -> int:
graph = defaultdict(list)
indeg = [0] * (n + 1)
for u, v in relations:
graph[u].append(v)
indeg[v] += 1
dp = [0] * (n + 1)
queue = deque()
for i in range(1, n + 1):
if indeg[i] == 0:
dp[i] = time[i - 1]
queue.append(i)
while queue:
u = queue.popleft()
for v in graph[u]:
dp[v] = max(dp[v], dp[u] + time[v - 1])
indeg[v] -= 1
if indeg[v] == 0:
queue.append(v)
return max(dp)O(V + E).O(V + E).dp[v] = max(dp[v], dp[u] + time[v]) — ensures the max
across all prerequisites.Input: colors: str (colors[i] = colour of
node i, 'a'..'z') and
edges: List[List[int]] — directed edges
[u, v]. Find the path maximising the number of nodes of any
single colour on the path.
Input: colors="abaca", edges=[[0,1],[0,2],[2,3],[3,4]]
Output: 3
Topo + DP dp[u][c] = max number of colour-c nodes on a
path ending at u.
dp[v][c] = max(dp[v][c], dp[u][c] + (1 if color[v] == c else 0)).
from collections import defaultdict, deque
from typing import List
class Solution:
def largestPathValue(self, colors: str, edges: List[List[int]]) -> int:
n = len(colors)
graph = defaultdict(list)
indeg = [0] * n
for u, v in edges:
graph[u].append(v)
indeg[v] += 1
dp = [[0] * 26 for _ in range(n)]
queue = deque()
for i in range(n):
if indeg[i] == 0:
queue.append(i)
dp[i][ord(colors[i]) - 97] = 1
visited = 0
best = 0
while queue:
u = queue.popleft()
visited += 1
best = max(best, max(dp[u]))
for v in graph[u]:
for c in range(26):
add = 1 if (ord(colors[v]) - 97) == c else 0
if dp[u][c] + add > dp[v][c]:
dp[v][c] = dp[u][c] + add
indeg[v] -= 1
if indeg[v] == 0:
queue.append(v)
return best if visited == n else -1O(V · 26 + E).O(V · 26).visited != n → there
is a cycle → return -1.Given k (size) and row/col conditions (a must be
above/before b). Return a k×k matrix containing each number 1..k exactly
once that satisfies all conditions.
Input: k=3, rowConditions=[[1,2],[3,2]], colConditions=[[2,1],[3,2]]
Output: [[3,0,0],[0,0,1],[0,2,0]]
Two topo sorts: row conditions → row position; col conditions → col position. Place each number at (row_pos, col_pos).
from collections import defaultdict, deque
from typing import List
class Solution:
def buildMatrix(self, k: int, rowConditions: List[List[int]], colConditions: List[List[int]]) -> List[List[int]]:
def topo(conditions):
graph = defaultdict(list)
indeg = [0] * (k + 1)
for u, v in conditions:
graph[u].append(v)
indeg[v] += 1
queue = deque(i for i in range(1, k + 1) if indeg[i] == 0)
order = []
while queue:
u = queue.popleft()
order.append(u)
for v in graph[u]:
indeg[v] -= 1
if indeg[v] == 0:
queue.append(v)
return order if len(order) == k else []
rows = topo(rowConditions)
cols = topo(colConditions)
if not rows or not cols: return []
row_pos = {x: i for i, x in enumerate(rows)}
col_pos = {x: i for i, x in enumerate(cols)}
result = [[0] * k for _ in range(k)]
for x in range(1, k + 1):
result[row_pos[x]][col_pos[x]] = x
return resultO(V + E).O(V + E).Given numCourses and
prerequisites[i] = [a, b] meaning “must finish
course a before course b”
(i.e. a is a prerequisite of b; the DAG edge
is a → b). For each queries[i] = [u, v],
return True if u is a (direct or indirect)
prerequisite of v — that is, a path
u → ... → v exists in the DAG.
Edge-direction convention (see Chapter 13): “u before v” ⇔ edge
u → v. Don’t reverse it — this is the classic bug in the Course Schedule family.
Input: numCourses = 3, prerequisites = [[1,2],[1,0],[2,0]], queries = [[1,0],[1,2]]
Output: [True, True]
2 <= numCourses <= 1001 <= prerequisites.length <= n·(n-1)/2Brute force: per query, BFS/DFS from u
and check for v. O(Q · (V + E)).
Optimal — Topo + DP closure: reach[u] =
set of vertices v reachable from u. Walk
reverse topo order and set
reach[u] = {u} ∪ union(reach[v] for v in adj[u]).
After preprocessing each query is O(1).
from collections import defaultdict, deque
from typing import List
class Solution:
def checkIfPrerequisite(self, numCourses: int,
prerequisites: List[List[int]],
queries: List[List[int]]) -> List[bool]:
graph = defaultdict(list)
indeg = [0] * numCourses
for a, b in prerequisites:
graph[a].append(b)
indeg[b] += 1
# Topo order.
queue = deque(i for i in range(numCourses) if indeg[i] == 0)
topo = []
while queue:
u = queue.popleft()
topo.append(u)
for v in graph[u]:
indeg[v] -= 1
if indeg[v] == 0:
queue.append(v)
# reach[u] = set of vertices reachable from u.
reach = [set() for _ in range(numCourses)]
# Process in reverse topo: children already have their reach computed.
for u in reversed(topo):
for v in graph[u]:
reach[u].add(v)
reach[u] |= reach[v]
return [v in reach[u] for u, v in queries]O(V · E + Q) (worst-case set
union).O(V²) for the reach sets.prerequisites[i] = [a, b] meaning “a must finish
before b” (a is a prerequisite of b) ⇒ DAG edge
a → b. Matches Chapter 13’s “X before Y” ⇒
X → Y.graph[b].append(a) because they conflate “b needs a” with
“edge b → a”. The correct code below uses
graph[a].append(b).bitmask
instead of a set when n ≤ 64.Given recipes (each requiring ingredients), supplies (available ingredients). Return the recipes that can be made.
Input: recipes=["bread"], ingredients=[["yeast","flour"]], supplies=["yeast","flour","corn"]
Output: ["bread"]
Topo sort: edges ingredient → recipes needing it. BFS from supplies; when all ingredients of a recipe are available → the recipe is “available”.
from collections import defaultdict, deque
from typing import List
class Solution:
def findAllRecipes(self, recipes: List[str], ingredients: List[List[str]], supplies: List[str]) -> List[str]:
graph = defaultdict(list)
indeg = {}
for recipe, ings in zip(recipes, ingredients):
indeg[recipe] = len(ings)
for ing in ings:
graph[ing].append(recipe)
queue = deque(supplies)
available = set(supplies)
result = []
recipe_set = set(recipes)
while queue:
ing = queue.popleft()
for r in graph[ing]:
indeg[r] -= 1
if indeg[r] == 0:
result.append(r)
available.add(r)
queue.append(r)
return resultO(V + E).O(V + E).| Kind | Representative problem |
|---|---|
| Longest path on DAG | LC 329 Longest Increasing Path |
| Counting paths | LC 1976 Number of Ways to Arrive |
| Reachability closure | Transitive closure / “can reach from A to B” |
| Dependency scheduling | LC 1136 Parallel Courses, LC 2050 |
| Aggregation along DAG | LC 1857 Largest Color Value |
dp[node][color] = max number of
colour-color nodes on a path ending at
node.(u → v): `dp[v][c] =
max(dp[v][c], dp[u][c]
-1.a a prerequisite of b?” —
transitive closure.u → v,
closure[v] |= closure[u] (bitmask or set).(a, b) via
a in closure[b].indegree == 0 initially means
the ingredient is available).LC 1462: prerequisites[i] = [u, v]
means “must finish u before v” ⇒ edge
u → v (matches Chapter 13). Don’t
reverse!
The last chapter — counting DP. The pattern: a counting state with additive transitions.
mod 10^9 + 7appears in every problem because the answers are huge.
After this chapter, you will be able to:
% MOD after every transition to
avoid overflow.10^9 + 7.MOD = 10**9 + 7
def dp_count(states):
table = [0] * len(states)
table[0] = base
for i in range(1, len(states)):
table[i] = (sum(table[j] for j in transitions(i)) % MOD)
return table[-1]An m × n grid. A robot starts at (0,0) and
moves to (m-1,n-1), right or down only. Count paths.
Input: m=3, n=7
Output: 28
DP dp[i][j] = dp[i-1][j] + dp[i][j-1]. Or via
combinatorics: C(m+n-2, m-1).
from math import comb
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
return comb(m + n - 2, m - 1)O(m + n) via the combinatorial
formula; O(m · n) via DP.O(1) formula;
O(m · n) DP.m-1
down-steps out of m+n-2 total steps.Same as 44.1 but with obstacles. 1 = blocked.
Input: obstacleGrid = [[0, 0, 0],
[0, 1, 0],
[0, 0, 0]] (0 = empty, 1 = obstacle)
Output: 2 (number of paths from (0,0) to (m-1, n-1); right/down only)
DP. If a cell is blocked → dp[i][j] = 0.
from typing import List
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
m, n = len(obstacleGrid), len(obstacleGrid[0])
if obstacleGrid[0][0] == 1: return 0
dp = [[0] * n for _ in range(m)]
dp[0][0] = 1
for i in range(m):
for j in range(n):
if obstacleGrid[i][j] == 1:
dp[i][j] = 0
continue
if i > 0: dp[i][j] += dp[i - 1][j]
if j > 0: dp[i][j] += dp[i][j - 1]
return dp[-1][-1]O(m · n).O(m · n); can be
O(n) via rolling.On a numeric keypad (3×4), a chess knight makes n - 1
moves (each a chess knight move). Count the number of strings
producible. Mod 10^9 + 7.
Input: n=2
Output: 20
Neighbour map for the keypad. DP
dp[i][digit] = strings of length i ending at digit.
class Solution:
MOD = 10**9 + 7
NEIGHBORS = {
0: [4, 6], 1: [6, 8], 2: [7, 9], 3: [4, 8], 4: [0, 3, 9],
5: [], 6: [0, 1, 7], 7: [2, 6], 8: [1, 3], 9: [2, 4]
}
def knightDialer(self, n: int) -> int:
dp = [1] * 10
for _ in range(n - 1):
new_dp = [0] * 10
for d in range(10):
for nb in self.NEIGHBORS[d]:
new_dp[nb] = (new_dp[nb] + dp[d]) % self.MOD
dp = new_dp
return sum(dp) % self.MODO(n).O(1) (only 10 keys).O(log n)
— handy for very large n.Count vowel strings of length n obeying: - ‘a’ may only
follow ‘e’. - ‘e’ may only follow ‘a’ or ‘i’. - ‘i’ may not follow ‘a’
or ‘i’. - ‘o’ may only follow ‘i’ or ‘u’. - ‘u’ may only follow ‘i’.
Input: n=1
Output: 5
DP dp[i][v] = number of length-i strings ending at vowel
v.
class Solution:
MOD = 10**9 + 7
def countVowelPermutation(self, n: int) -> int:
# Index: 0=a, 1=e, 2=i, 3=o, 4=u
# prev_can[v] = vowels that may immediately precede v.
prev_can = {
0: [1, 2, 4], # a after e, i, u
1: [0, 2], # e after a, i
2: [1, 3], # i after e, o
3: [2], # o after i
4: [2, 3], # u after i, o
}
dp = [1] * 5
for _ in range(n - 1):
new_dp = [0] * 5
for v in range(5):
for prev in prev_can[v]:
new_dp[v] = (new_dp[v] + dp[prev]) % self.MOD
dp = new_dp
return sum(dp) % self.MODO(n).O(1) (5 vowels).40 hats, n people (n ≤ 10), each person has a list of
hats they like. Count assignments where each person gets a distinct
hat.
Input: hats = [[3, 4], [4, 5], [5]]
(hats[i] = list of hats person i likes; hat labels ∈ [1, 40])
Output: 1 (number of ways to assign each person one hat with distinct hats; mod 10^9 + 7)
Bitmask DP over people, iterating over hats (people ≤ 10, hats ≤ 40):
dp[hat][mask] = ways to use hats <= hat
covering the people in mask.
from collections import defaultdict
from typing import List
class Solution:
MOD = 10**9 + 7
def numberWays(self, hats: List[List[int]]) -> int:
n = len(hats)
hat_to_people = defaultdict(list)
for p, hs in enumerate(hats):
for h in hs:
hat_to_people[h].append(p)
full = (1 << n) - 1
dp = [0] * (1 << n)
dp[0] = 1
for h in range(1, 41):
new_dp = dp[:]
for mask in range(1 << n):
for p in hat_to_people[h]:
if mask & (1 << p): continue
new_dp[mask | (1 << p)] = (new_dp[mask | (1 << p)] + dp[mask]) % self.MOD
dp = new_dp
return dp[full]O(40 · 2^n · n) with n ≤
10.O(2^n).n songs, listen to goal of them. Each song
must be heard at least once. Two plays of the same song are separated by
≥ k other songs. Count playlists.
Input: n=3, goal=3, k=1
Output: 6
DP dp[i][j] = playlists of length i using j distinct
songs.
dp[i][j] += dp[i-1][j-1] * (n - (j-1)).dp[i][j] += dp[i-1][j] * max(0, j - k).class Solution:
MOD = 10**9 + 7
def numMusicPlaylists(self, n: int, goal: int, k: int) -> int:
dp = [[0] * (n + 1) for _ in range(goal + 1)]
dp[0][0] = 1
for i in range(1, goal + 1):
for j in range(1, n + 1):
dp[i][j] = dp[i - 1][j - 1] * (n - j + 1) % self.MOD
if j > k:
dp[i][j] = (dp[i][j] + dp[i - 1][j] * (j - k)) % self.MOD
return dp[goal][n]O(n · goal).O(n · goal); can be
O(n) via rolling.You have just finished the book 288 DSA coding-interview questions, from Easy to Hard, covering 44 patterns. Best of luck passing every Big Tech interview round!
“The best way to learn is to teach.” — Try explaining a chapter to a friend. If your explanation is clear, you really know the material.
% MOD after
every large add/multiply — prevents huge
ints (Python is fine, but the convention still
applies).| Approach | Time | Space | When |
|---|---|---|---|
Grid DP dp[i][j] = dp[i-1][j] + dp[i][j-1] |
O(mn) |
O(mn) or O(min(m,n)) |
With obstacles (LC 63) or extensions |
Formula C(m+n-2, m-1) |
O(min(m,n)) |
O(1) |
No obstacles |
dp[i][j] = playlists of length i using
exactly j distinct songs.dp[i-1][j-1] × (N - (j-1)).dp[i-1][j] × max(0, j - K).≤ s2, ≥ s1, not
containing evil → f(s2) - f(s1 - 1)
where f is “count of ≤ s not containing
evil”.(idx, kmp_state, is_tight).kmp_state = LPS pointer in evil to detect
impending matches.a → e
e → a, i
i → a, e, o, u
o → i, u
u → a
DP dp[i][v] = strings of length i ending in
vowel v.
Use this table for reverse lookup: “my problem looks like this — what pattern should I pick?”
| Problem signature | Pattern (Chapter) |
|---|---|
| “Find pair / triplet with sum” | Two Pointers (26), Hash (6) |
| “Longest / shortest substring with condition” | Sliding Window (27) |
| “Find element in sorted array” | Binary Search (5) |
| “Min/max X such that …” | Search on Answer (25) |
| “Number of subarray …” | Prefix Sum + Hash (19, 6) |
| “Connected components / cycle in undirected” | Union Find (24), DFS/BFS |
| “Shortest path unweighted” | BFS (10) |
| “Shortest path weighted ≥ 0” | Dijkstra (30) |
| “Shortest path with negative” | Bellman-Ford |
| “Topological order / cycle in directed” | Topo Sort (13) |
| “Min cost to connect all” | MST (33) |
| “Next greater / smaller element” | Monotonic Stack (18, 8.5) |
| “Sliding window max/min” | Monotonic Deque (18.4) |
| “Substring matching” | KMP (35), Rolling Hash (34), Z (36) |
| “Prefix matching, dictionary lookup” | Trie (23) |
| “Max XOR pair” | Binary Trie (41) |
| “All permutations / subsets / combinations” | Backtracking (3, 28) |
| “Min steps / number of ways” | DP (29), Combinatorics DP (44) |
| “Game with 2 players optimal” | Game Theory DP (31) |
| “Path in tree / depth/diameter/LCA” | Tree DFS (11), Tree DP (42) |
| “Range sum / update” | Prefix Sum (19), Fenwick/Segment (22) |
| “Median / kth largest stream” | Heap (15) |
| “n ≤ 20 with subset” | Bitmask DP (40) |
BFS vs DFS vs Union Find (for connectivity / components):
| Pattern | When | Time | Space |
|---|---|---|---|
| BFS | Shortest path unweighted, level order | O(V + E) |
O(V) |
| DFS | Connectivity, cycle detection, topo sort, any path | O(V + E) |
O(V) recursion stack |
| Union Find | Online add edges, “connected?” queries, offline edge sort | O((V + E) · α) |
O(V) |
Plain Binary Search vs Search on Answer:
| Pattern | Search on | Predicate |
|---|---|---|
| Plain Binary Search (Ch 5) | Index in a sorted array | nums[mid] vs target |
| Search on Answer (Ch 25) | The answer value | check(mid) monotonic T/F |
Sliding Window vs Two Pointers:
| Pattern | When | Window |
|---|---|---|
| Two-end Two Pointers (Ch 26) | Sort + find pair (sum, distance) | Converging from both sides |
| Same-direction Sliding Window (Ch 27) | Longest/shortest subarray with monotonic condition | Extend r, shrink l |
KMP vs Z vs Rolling Hash:
| Pattern | Preprocess | Good for | Drawback |
|---|---|---|---|
| KMP (Ch 35) | LPS array O(m) |
Deterministic, in place | LPS is conceptually trickier |
| Z (Ch 36) | Z array O(n) |
Easier to visualise than LPS | Same perf as KMP |
| Rolling Hash (Ch 34) | Powers + prefix hash | Multi-pattern, distinct count | Collision risk |
Dijkstra vs BFS+BS vs offline DSU (for min/max path):
| Pattern | When | Complexity |
|---|---|---|
| Dijkstra (Ch 30) | Non-negative weights, online queries | O((V+E) log V) |
| BS + BFS (Ch 37) | Monotonic predicate on threshold | O(log · (V+E)) |
| Offline DSU (Ch 24, 33) | Process queries in order + sort edges | O((V+E) · α) |
Recursion vs Backtracking vs DFS vs Top-down DP:
| Pattern | Hallmark |
|---|---|
| Recursion (Ch 3) | Pure recursion, no choice/state tracking |
| Backtracking (Ch 28) | choose → explore → unchoose, enumerate everything |
| DFS (Ch 11, 12) | Traverse graph/tree, mark visited |
| Top-down DP (Ch 29) | Recursion + @cache, overlapping subproblems |
# 1. Counter
from collections import Counter
Counter("anagram").most_common(2) # [('a',3), ('n',1)]
# 2. defaultdict
from collections import defaultdict
groups = defaultdict(list)
groups[key].append(val)
# 3. deque
from collections import deque
dq = deque([1,2,3]); dq.appendleft(0); dq.pop()
# 4. heapq (min-heap; max-heap = negate)
import heapq
h = []; heapq.heappush(h, (priority, value))
heapq.nsmallest(3, lst, key=lambda x: x.score)
# Max-heap idiom:
heapq.heappush(h, -value)
neg = heapq.heappop(h); val = -neg
# 5. bisect (binary search)
from bisect import bisect_left, insort
i = bisect_left(sorted_lst, x)
insort(sorted_lst, x) # insert while maintaining sort
# 6. functools.cache (memoization)
from functools import cache
@cache
def dp(state): ...
# Caveat: the state must be hashable. List/dict → tuple/frozenset.
# 7. itertools
from itertools import (
combinations, permutations, product, accumulate, pairwise
)
list(combinations([1,2,3], 2)) # [(1,2),(1,3),(2,3)]
list(accumulate([1,2,3,4])) # [1, 3, 6, 10]
list(pairwise([1,2,3,4])) # [(1,2),(2,3),(3,4)]
# 8. cmp_to_key for custom sort
from functools import cmp_to_key
arr.sort(key=cmp_to_key(lambda a, b: a-b))
# 9. math
from math import gcd, lcm, comb, factorial, inf, log2, ceil, floor
gcd(12, 18) # 6
lcm(4, 6) # 12 (Python 3.9+)
comb(5, 2) # 10
log2(8) # 3.0
# 10. SortedList (requires pip install sortedcontainers)
from sortedcontainers import SortedList
sl = SortedList()
sl.add(x); sl.remove(x) # O(log n)
sl.bisect_left(x)
# 11. Increase recursion limit
import sys
sys.setrecursionlimit(10**6)
# 12. Bit operations
bin(12) # '0b1100'
(12).bit_count() # 2 (Python 3.10+)
(12 & -12) # 4 (lowest set bit)
(12 & (12 - 1)) # 8 (clear lowest set bit)
# 13. String / character
chr(97); ord('a') # 97; 'a'
'abc'.zfill(5) # '00abc'
str(123).rjust(5) # ' 123'
# 14. List unpacking
a, *rest, b = [1, 2, 3, 4, 5] # a=1, rest=[2,3,4], b=5
# 15. Walrus operator (Python 3.8+)
if (n := len(lst)) > 10:
print(f"too long: {n}")(Snippets 16–35 can be found in each chapter’s code samples.)
Template code lives at the top of each chapter (the “Template code” section). Here is a quick index:
| # | Pattern | Template in chapter |
|---|---|---|
| 1 | Two Pointers | Ch. 26 |
| 2 | Sliding Window | Ch. 27 |
| 3 | Binary Search | Ch. 5 |
| 4 | Search on Answer | Ch. 25 |
| 5 | Prefix Sum | Ch. 19 |
| 6 | Monotonic Stack/Deque | Ch. 18 |
| 7 | BFS | Ch. 10 |
| 8 | DFS | Ch. 11 |
| 9 | Backtracking | Ch. 28 |
| 10 | Union Find | Ch. 24 |
| 11 | Topological Sort | Ch. 13 |
| 12 | Dijkstra | Ch. 30 |
| 13 | MST (Kruskal+Prim) | Ch. 33 |
| 14 | Trie | Ch. 23 |
| 15 | Fenwick / Segment | Ch. 22 |
| 16 | LCS / LIS | Ch. 29 |
| 17 | Knapsack | Ch. 29 |
| 18 | Tree DP | Ch. 42 |
| 19 | Bitmask DP | Ch. 40 |
| 20 | KMP / Z | Ch. 35, 36 |
Trimmed and deduplicated by chapter. Each LC problem appears only once.
Array & String (8): LC 1, 121, 238, 11, 53, 49, 76, 3.
Linked List (5): LC 206, 21, 141, 25, 146.
Tree (5): LC 104, 102, 98, 236, 124.
Graph & BFS/DFS (7): LC 200, 207, 269, 994, 127, 133, 743.
Heap & Sort (3): LC 215, 23, 56.
Hash & Sliding Window (4): LC 49 (already in Array, skip), LC 76 (already in Array, skip), LC 424, 560.
Binary Search (3): LC 33, 34, 875.
DP (8): LC 70, 198, 322, 300, 72, 152, 416, 309.
Backtracking (3): LC 46, 78, 22.
Bit / String parser (4): LC 136, 421, 224, 8.
Note: LC 121 (Stock I) is prioritised over LC 207 (Course Schedule) for the first day — Stock is a gentle warm-up before graph problems. LC 49 and LC 76 appear in both Array and Sliding Window families — counted once under Array (the earlier tier).
Some interview rounds combine design + implementation. Examples:
| Problem | Related chapters |
|---|---|
| LRU Cache (LC 146) | Ch. 6, 7 |
| LFU Cache (LC 460) | Ch. 7 |
| Snake Game (LC 353) | Ch. 7 (deque) |
| Rate Limiter | Ch. 14 (interval) |
| Twitter Feed (LC 355) | Ch. 15 (heap) |
| Search Autocomplete (LC 642) | Ch. 23 (Trie) |
| Tic-Tac-Toe (LC 348) | Ch. 6 (hash) |
| File System (LC 588) | Ch. 23 |
| Stack with Pop Middle | Ch. 22 (DLL) |
Process: clarify the scope → design the class/interfaces → implement the methods → test.
| English | Vietnamese | Short definition |
|---|---|---|
| Invariant | Bất biến | A property that always holds inside a loop/recursion |
| State | Trạng thái | A quantity sufficient to describe a subproblem (DP, game theory) |
| Transition | Bước chuyển | dp[next] = f(dp[curr]) — the relation between
states |
| Subproblem | Bài con | A smaller problem used to build the larger one (DP, D&C) |
| Optimal substructure | Cấu trúc con tối ưu | The optimum is built from optimal substructures |
| Overlapping subproblems | Bài con trùng lặp | Subproblems repeat → cacheable |
| Monotonic | Đơn điệu | Increasing / decreasing along one direction (sort, stack, BS predicate) |
| Amortized | Khấu hao | Average op is O(1) even if worst-case is
O(n) |
| Greedy | Tham lam | Pick the locally best choice each step |
| Heuristic | Heuristic | A rule that helps choose; no optimality guarantee |
| Trie | Trie (prefix tree) | Tree where each node = 1 char, root → node path = 1 prefix |
| Adjacency list | Danh sách kề | graph[u] = [v1, v2, ...] |
| In-place | In-place | Mutate the input directly, no aux data structure |
| Stable sort | Sort ổn định | Preserves the original order of equal elements (Python’s
sorted is stable) |
| Sentinel | Sentinel | A fake boundary element to avoid special cases (dummy head,
[-1, n]) |
| Pivot | Pivot | A reference element for partitioning (quicksort, quickselect) |
| Backtracking | Backtracking | Recursion + undo state on the way back |
| Memoization | Memoization | Cache subproblem results (top-down DP) |
| Tabulation | Tabulation | Build the DP table bottom-up |
| Bitmask | Bitmask | Encode a subset via an int (bit i = whether element
i is in) |
| LIS | LIS | Longest Increasing Subsequence |
| LCS | LCS | Longest Common Subsequence |
| LPS | LPS | Longest Prefix Suffix (KMP) |
| MST | MST | Minimum Spanning Tree |
| DSU | DSU | Disjoint Set Union (Union Find) |
| DAG | DAG | Directed Acyclic Graph |
| BST | BST | Binary Search Tree |
| Big-O | Big-O | Asymptotic complexity notation |
| α(n) | Alpha(n) | Inverse Ackermann function (≈ 4 for any practical n) |
Look up by LeetCode number → location in the book. Supports quick reverse lookup.
| LC # | Problem | Chapter | Section |
|---|---|---|---|
| 1 | Two Sum | 1 | 1.1 |
| 2 | Add Two Numbers | 7 | 7.7 |
| 3 | Longest Substring Without Repeating | 27 | 27.1 |
| 4 | Median of Two Sorted Arrays | 25 | 25.5 |
| 8 | String to Integer (atoi) | 2 | 2.4 |
| 10 | Regular Expression Matching | 32 | 32.4 |
| 11 | Container With Most Water | 1, 26 | 1.5, 26.3 |
| 14 | Longest Common Prefix | 2 | 2.3 |
| 15 | 3Sum | 26 | 26.1 |
| 17 | Letter Combinations Phone | 28 | 28.4 |
| 18 | 4Sum | 26 | 26.6 |
| 19 | Remove Nth From End | 7 | 7.5 |
| 20 | Valid Parentheses | 8 | 8.1 |
| 21 | Merge Two Sorted Lists | 7 | 7.2 |
| 22 | Generate Parentheses | 3 | 3.4 |
| 23 | Merge k Sorted Lists | 15 | 15.5 |
| 25 | Reverse Nodes in k-Group | 7 | 7.9 |
| 28 | strStr() | 35, 36 | 35.1, 36.2 |
| 33 | Search Rotated Sorted Array | 5 | 5.5 |
| 34 | Find First/Last Position | 5 | 5.4 |
| 35 | Search Insert Position | 5 | 5.2 |
| 37 | Sudoku Solver | 28 | 28.8 |
| 42 | Trapping Rain Water | 26 | 26.2 |
| 45 | Jump Game II | 16 | 16.2 |
| 46 | Permutations | 3 | 3.5 |
| 47 | Permutations II | 28 | 28.3 |
| 49 | Group Anagrams | 2 | 2.5 |
| 50 | Pow(x, n) | 3, 17 | 3.2, 17.4 |
| 51 | N-Queens | 28 | 28.7 |
| 53 | Maximum Subarray | 17 | 17.1 |
| 55 | Jump Game | 16 | 16.1 |
| 56 | Merge Intervals | 4, 14 | 4.2, 14.1 |
| 57 | Insert Interval | 14 | 14.2 |
| 62 | Unique Paths | 44 | 44.1 |
| 63 | Unique Paths II | 44 | 44.2 |
| 65 | Valid Number | 32 | 32.5 |
| 69 | Sqrt(x) | 5 | 5.6 |
| 72 | Edit Distance | 29 | 29.3 |
| 75 | Sort Colors | 4, 26 | 4.1, 26.4 |
| 76 | Minimum Window Substring | 27 | 27.2 |
| 77 | Combinations | 28 | 28.1 |
| 78 | Subsets | 3 | 3.6 |
| 79 | Word Search | 28 | 28.9 |
| 80 | Remove Duplicates Sorted II | 26 | 26.5 |
| 84 | Largest Rectangle Histogram | 18 | 18.3 |
| 90 | Subsets II | 28 | 28.2 |
| 93 | Restore IP Addresses | 28 | 28.11 |
| 98 | Validate BST | 11, 22 | 11.4, 22.1 |
| 99 | Recover BST | 22 | 22.2 |
| 102 | Level Order Traversal | 10 | 10.1 |
| 104 | Max Depth Binary Tree | 11 | 11.1 |
| 113 | Path Sum II | 11 | 11.2 |
| 121 | Best Time to Buy/Sell | 1 | 1.2 |
| 124 | Max Path Sum | 22 | 22.4 |
| 125 | Valid Palindrome | 2 | 2.2 |
| 127 | Word Ladder | 10 | 10.3 |
| 128 | Longest Consecutive | 6 | 6.2 |
| 131 | Palindrome Partitioning | 28 | 28.6 |
| 132 | Palindrome Partitioning II | 29 | 29.13 |
| 133 | Clone Graph | 9 | 9.2 |
| 134 | Gas Station | 16 | 16.3 |
| 135 | Candy | 16 | 16.6 |
| 136 | Single Number | 21 | 21.1 |
| 138 | Copy List Random | 7 | 7.8 |
| 140 | Word Break II | 28 | 28.10 |
| 141 | Linked List Cycle | 7 | 7.3 |
| 143 | Reorder List | 7 | 7.12 |
| 146 | LRU Cache | 6, 7 | 6.6, 7.11 |
| 148 | Sort List | 7 | 7.10 |
| 150 | Eval RPN | 8 | 8.4 |
| 151 | Reverse Words | 2 | 2.6 |
| 152 | Max Product Subarray | 29 | 29.12 |
| 155 | Min Stack | 8 | 8.2 |
| 179 | Largest Number | 4 | 4.3 |
| 187 | Repeated DNA | 34 | 34.1 |
| 188 | Stock IV | 29 | 29.10 |
| 189 | Rotate Array | 1 | 1.6 |
| 191 | Number of 1 Bits | 21 | 21.2 |
| 198 | House Robber I | — | (practice for Ch 29) |
| 200 | Number of Islands | 12 | 12.1 |
| 201 | Bitwise AND Range | 21 | 21.5 |
| 204 | Count Primes | 20 | 20.1 |
| 205 | Isomorphic Strings | 6 | 6.5 |
| 206 | Reverse Linked List | 3, 7 | 3.3, 7.1 |
| 207 | Course Schedule | 9 | 9.4 |
| 208 | Implement Trie | 23 | 23.1 |
| 210 | Course Schedule II | 13 | 13.1 |
| 211 | Add and Search Word | 23 | 23.2 |
| 212 | Word Search II | 23 | 23.3 |
| 213 | House Robber II | 29 | 29.11 |
| 215 | Kth Largest | 15, 17 | 15.1, 17.3 |
| 217 | Contains Duplicate | 6 | 6.1 |
| 224 | Basic Calculator | — | (see Ch 32) |
| 227 | Basic Calculator II | 32 | 32.1 |
| 232 | Implement Queue Stacks | 8 | 8.3 |
| 234 | Palindrome Linked List | 7 | 7.6 |
| 236 | LCA Binary Tree | 11 | 11.6 |
| 238 | Product Except Self | 1, 19 | 1.3, 19.5 |
| 239 | Sliding Window Maximum | 18, 27 | 18.4, 27.6 |
| 241 | Different Ways Parentheses | 17 | 17.5 |
| 242 | Valid Anagram | 2 | 2.1 |
| 253 | Meeting Rooms II | 4, 14 | 4.4, 14.4 |
| 264 | Ugly Number II | 20 | 20.2 |
| 269 | Alien Dictionary | 13 | 13.2 |
| 273 | Integer to English Words | 32 | 32.6 |
| 278 | First Bad Version | 5 | 5.3 |
| 280 | Wiggle Sort | 4 | 4.6 |
| 282 | Expression Add Operators | 28 | 28.12 |
| 283 | Move Zeroes | 1 | 1.4 |
| 286 | Walls and Gates | 12 | 12.5 |
| 292 | Nim Game | 31 | 31.1 |
| 295 | Find Median Stream | 15 | 15.3 |
| 297 | Serialize Tree | 22 | 22.3 |
| 300 | LIS | 29 | 29.2 |
| 303 | Range Sum Immutable | 19 | 19.1 |
| 304 | Range Sum 2D | 19 | 19.4 |
| 305 | Number Islands II | 24 | 24.4 |
| 307 | Range Sum Mutable | 22 | 22.6 |
| 309 | Stock Cooldown | 29 | 29.9 |
| 310 | Min Height Trees | 13 | 13.3 |
| 312 | Burst Balloons | 29 | 29.14 |
| 315 | Count Smaller After | 22 | 22.5 |
| 322 | Coin Change | 29 | 29.7 |
| 323 | Connected Components | 9 | 9.3 |
| 329 | Longest Increasing Path | 43 | 43.1 |
| 332 | Reconstruct Itinerary | — | (Ch 11 follow-up) |
| 337 | House Robber III | 11, 42 | 11.5, 42.1 |
| 338 | Counting Bits | 21 | 21.3 |
| 347 | Top K Frequent | 6 | 6.3 |
| 354 | Russian Doll Envelopes | 29, 38, 39 | 29.6, 38.1, 39.2 |
| 363 | Max Sum Rectangle ≤ K | 38 | 38.4 |
| 368 | Largest Divisible Subset | 39 | 39.1 |
| 371 | Sum of Two Integers | 21 | 21.4 |
| 394 | Decode String | 8, 32 | 8.6, 32.2 |
| 399 | Evaluate Division | 9 | 9.6 |
| 402 | Remove K Digits | 18 | 18.6 |
| 407 | Trapping Rain Water II | 37 | 37.4 |
| 410 | Split Array Largest Sum | 25 | 25.3 |
| 416 | Partition Equal Subset Sum | 29 | 29.5 |
| 417 | Pacific Atlantic | 12 | 12.4 |
| 421 | Max XOR Pair | 21, 41 | 21.6, 41.1 |
| 424 | Char Replacement | 27 | 27.3 |
| 435 | Non-overlapping Intervals | 14 | 14.3 |
| 438 | Find All Anagrams | 35 | 35.4 |
| 444 | Sequence Reconstruction | 13 | 13.5 |
| 452 | Min Arrows Burst Balloons | 14 | 14.5 |
| 455 | Assign Cookies | 16 | 16.4 |
| 459 | Repeated Substring | 35 | 35.3 |
| 464 | Can I Win | 31 | 31.6 |
| 486 | Predict the Winner | 31 | 31.3 |
| 503 | Next Greater II | 18 | 18.2 |
| 509 | Fibonacci | 3 | 3.1 |
| 518 | Coin Change II | 29 | 29.8 |
| 523 | Continuous Subarray Sum | 19 | 19.3 |
| 542 | 01 Matrix | 12 | 12.6 |
| 543 | Diameter Tree | 42 | 42.3 |
| 547 | Number of Provinces | 24 | 24.1 |
| 560 | Subarray Sum K | 6, 19 | 6.4, 19.2 |
| 567 | Permutation in String | 27 | 27.4 |
| 591 | Tag Validator | — | (Ch 32) |
| 621 | Task Scheduler | 15 | 15.6 |
| 648 | Replace Words | 23 | 23.5 |
| 664 | Strange Printer | 29 | 29.18 |
| 673 | Number of LIS | 38 | 38.3 |
| 684 | Redundant Connection | 24 | 24.2 |
| 692 | Top K Frequent Words | 15 | 15.2 |
| 695 | Max Area Island | 12 | 12.2 |
| 698 | Partition K Equal Subsets | 40 | 40.1 |
| 704 | Binary Search | 5 | 5.1 |
| 719 | Find K-th Smallest Pair Dist | 25 | 25.4 |
| 720 | Longest Word | 23 | 23.4 |
| 721 | Accounts Merge | 24 | 24.3 |
| 724 | Find Pivot Index | 19 | 19.6 |
| 726 | Number of Atoms | 32 | 32.3 |
| 736 | Parse Lisp | — | (Ch 32 ref) |
| 739 | Daily Temperatures | 8, 18 | 8.5, 18.1 |
| 743 | Network Delay Time | 30 | 30.1 |
| 752 | Open the Lock | 10 | 10.4 |
| 759 | Employee Free Time | 14 | 14.6 |
| 763 | Partition Labels | 16 | 16.5 |
| 778 | Swim Rising Water | 24, 30, 37 | 24.6, 30.4, 37.1 |
| 785 | Bipartite Graph | 9 | 9.5 |
| 787 | Cheapest Flights K Stops | 30 | 30.3 |
| 791 | Custom Sort String | 4 | 4.5 |
| 797 | All Paths Source Target | 11 | 11.3 |
| 834 | Sum of Distances Tree | 42 | 42.5 |
| 841 | Keys and Rooms | 9 | (practice) |
| 847 | Shortest Path Visit All | 40 | 40.2 |
| 876 | Middle Linked List | 7 | 7.4 |
| 877 | Stone Game | 31 | 31.2 |
| 907 | Sum Subarray Mins | 18 | 18.5 |
| 909 | Snakes and Ladders | 10 | 10.6 |
| 913 | Cat and Mouse | 31 | 31.5 |
| 920 | Music Playlists | 44 | 44.6 |
| 935 | Knight Dialer | 44 | 44.3 |
| 943 | Shortest Superstring | 40 | 40.4 |
| 947 | Most Stones Removed | 9 | (practice) |
| 952 | Largest Comp by Factor | 20 | 20.5 |
| 968 | Binary Tree Cameras | 42 | 42.2 |
| 973 | K Closest Points | 15 | 15.4 |
| 990 | Equation Satisfiability | 24 | 24.5 |
| 992 | Subarrays K Distinct | 27 | 27.5 |
| 994 | Rotting Oranges | 10 | 10.2 |
| 1011 | Capacity Ship Packages | 25 | 25.2 |
| 1032 | Stream of Characters | 23 | 23.6 |
| 1044 | Longest Duplicate Substring | 34 | 34.2 |
| 1091 | Shortest Path Binary Matrix | 10 | 10.5 |
| 1102 | Path Max Min Value | 33 | 33.6 |
| 1125 | Smallest Sufficient Team | 40 | 40.3 |
| 1135 | Connecting Cities | 33 | 33.2 |
| 1136 | Parallel Courses | 13 | 13.6 |
| 1140 | Stone Game II | 31 | 31.4 |
| 1143 | LCS | 29 | 29.1 |
| 1168 | Optimize Water | 33 | 33.3 |
| 1175 | Prime Arrangements | 20 | 20.3 |
| 1203 | Sort Items Groups | 13 | 13.4 |
| 1220 | Count Vowels Permutation | 44 | 44.4 |
| 1235 | Job Scheduling | 39 | 39.4 |
| 1293 | Shortest Path Obstacles | 30 | 30.5 |
| 1297 | Max Occurrences Substring | 35 | 35.5 |
| 1316 | Distinct Echo Substrings | 34, 36 | 34.3, 36.6 |
| 1326 | Min Taps Garden | 39 | 39.6 |
| 1349 | Max Students Exam | 40 | 40.5 |
| 1368 | Min Cost Valid Path | 30 | 30.6 |
| 1392 | Longest Happy Prefix | 35 | 35.6 |
| 1425 | Constrained Subseq Sum | 38 | 38.5 |
| 1434 | Hats Permutation | 44 | 44.5 |
| 1462 | Course Schedule IV | 43 | 43.5 |
| 1478 | Allocate Mailboxes | 38 | 38.6 |
| 1489 | Critical MST Edges | 33 | 33.4 |
| 1547 | Min Cost Cut Stick | 29 | 29.16 |
| 1557 | Min Vertices Reach | 9 | (practice) |
| 1584 | Min Cost Connect Points | 33 | 33.1 |
| 1631 | Path Min Effort | 30, 37 | 30.2, 37.2 |
| 1638 | Strings Differ One Char | 34 | 34.5 |
| 1690 | Stone Game VII | 29 | 29.17 |
| 1691 | Stacking Cuboids | 39 | 39.3 |
| 1697 | Edge Length Limited Paths | 33 | 33.5 |
| 1707 | Max XOR With Element | 41 | 41.2 |
| 1791 | Center Star Graph | 9 | (practice) |
| 1803 | Count Pairs XOR Range | 41 | 41.3 |
| 1857 | Largest Color Value | 43 | 43.3 |
| 1879 | Min XOR Sum | 40 | 40.6 |
| 1901 | Peak Element II | 37 | 37.6 |
| 1938 | Genetic Difference Query | 41 | 41.4 |
| 1944 | Visible People in Queue | 39 | 39.5 |
| 1970 | Last Day Cross | 37 | 37.3 |
| 1971 | Path Exists Graph | 9 | 9.1 |
| 2050 | Parallel Courses III | 43 | 43.2 |
| 2115 | Find Recipes | 43 | 43.6 |
| 2223 | Sum of Scores Built Strings | 34, 36 | 34.6, 36.3 |
| 2246 | Longest Path Diff Adj | 42 | 42.4 |
| 2301 | Match Substring Replace | 36 | 36.5 |
| 2317 | Max XOR After Ops | 41 | 41.6 |
| 2392 | Build Matrix Conditions | 43 | 43.4 |
| 2407 | LIS II | 38 | 38.2 |
| 2430 | Max Deletions String | 36 | 36.4 |
| 2513 | Min Max Two Arrays | 37 | 37.5 |
| 2521 | Distinct Prime Factors | 20 | 20.6 |
| 2523 | Closest Primes Range | 20 | 20.4 |
| 2589 | (replaced by LC 1462) | — | — |
| 2858 | Min Edge Reversals | 42 | 42.6 |
| 2935 | Max Strong Pair XOR II | 41 | 41.5 |
Several problems appear in multiple chapters from different angles. Here is a quick map to avoid confusion:
| LC | Full version | Recap in | New angle in the recap |
|---|---|---|---|
| LC 56 Merge Intervals | Ch 4.2 (Sorting) | Ch 14.1 (Interval) | Sort vs interval pattern |
| LC 75 Sort Colors | Ch 4.1 (Sorting Dutch flag) | Ch 26.4 (Two Pointers) | Sort vs 3-pointer |
| LC 11 Container Most Water | Ch 1.5 (Array) | Ch 26.3 (Two Pointers) | Array trick vs converging pointers |
| LC 98 Validate BST | Ch 11.4 (DFS) | Ch 22.1 (Advanced Tree) | DFS vs BST property |
| LC 146 LRU Cache | Ch 6.6 (Hash Table) | Ch 7.11 (Linked List) | Hash + DLL vs DLL pattern |
| LC 206 Reverse LL | Ch 7.1 (iterative) | Ch 3.3 (recursive) | Two different approaches |
| LC 50 Pow(x, n) | Ch 3.2 (Recursion) | Ch 17.4 (D&C) | Recursion vs D&C framework |
| LC 215 Kth Largest | Ch 15.1 (Heap) | Ch 17.3 (D&C) | Heap vs quickselect |
| LC 239 Sliding Window Max | Ch 18.4 (Monotonic) | Ch 27.6 (Sliding Window) | Deque vs window |
| LC 253 Meeting Rooms II | Ch 4.4 (Sorting) | Ch 14.4 (Interval) | Heap vs sweep line |
| LC 354 Russian Doll | Ch 29.6 (DP) | Ch 38.1, 39.2 | Three angles: pure DP, BS + DP, sort + DP |
| LC 421 Max XOR | Ch 21.6 (Bit) | Ch 41.1 (Trie) | Greedy bit vs binary trie |
| LC 560 Subarray Sum K | Ch 6.4 (Hash) | Ch 19.2 (Prefix Sum) | Hash vs prefix sum |
| LC 778 Swim Water | Ch 24.6 (DSU offline) | Ch 30.4, 37.1 | Three ways: DSU, Dijkstra, BS + BFS |
| LC 1316 Distinct Echo | Ch 34.3 (Rolling Hash) | Ch 36.6 (Z function) | Two different algorithms |
| LC 1631 Path Min Effort | Ch 30.2 (Dijkstra) | Ch 37.2 (BS + BFS) | Two approaches |
| LC 2223 Sum of Scores | Ch 34.6 (Rolling Hash) | Ch 36.3 (Z) | Two algorithms |
How to read recaps: focus on the “new angle” — how the current pattern looks at the old problem differently, not re-read the solution verbatim.
24 hours before: - [ ] Re-read Frontmatter 0.2 (UMPIRE) — revisit the 5 steps. - [ ] Re-read Appendix E (Behavioral) — prepare 3–4 STAR stories. - [ ] Sleep 7–8 hours. Don’t pull an all-nighter coding. - [ ] Check the equipment (mic, camera, screen share, IDE / online editor).
One week before: - [ ] Finish the 50 must-do problems (Appendix D). - [ ] 2–3 mock interviews (Pramp / interviewing.io / friends). - [ ] Study company-specific patterns (LeetCode tag by company). - [ ] Review your CV/resume — story behind every project.
One month before: - [ ] Finish Levels 1 + 2 (Chapters 1–32). - [ ] Regular mock interviews 1–2 sessions / week. - [ ] Learn the behavioural framework + prepare 10 STAR stories. - [ ] Apply broadly (5–10 companies) to get more practice rounds.
Best of luck on your interview journey! Thanks for reading this book.