2026
Mục tiêu project: tổng hợp các câu hỏi DSA coding interview tiêu biểu nhất ở các công ty Big Tech, kèm lời giải Python 3, phân tích độ phức tạp, bẫy phỏng vấn thường gặp, và bài tự luyện liên quan để bạn đọc luyện tập có hệ thống theo pattern, không phải học vẹt từng bài.
Quy mô: 288 bài tập (267 bài giải đầy đủ + 21 bài recap cross-reference qua các chương) · 44 pattern · Lời giải Python 3 chuẩn LC.
📚 Cuốn sách dựa trên nội dung khoá học — các topic được biên soạn theo lộ trình của khoá DSA Coding Interview đang được giảng dạy tại EngineerPro.
Tuy nhiên, cuốn sách không thể thay thế việc rèn luyện tư duy thuật toán cũng như cách thức học và hiểu sâu về thuật toán mà bạn có được khi tham gia khoá học trực tiếp tại EngineerPro.
Các bạn quan tâm về khoá học vui lòng nhắn tin qua fanpage để được tư vấn:
Trước khi đi vào nội dung chính, mời bạn xem một buổi phỏng vấn coding mẫu do EngineerPro thực hiện. Video giúp bạn hình dung luồng diễn ra thực tế của một vòng phỏng vấn: cách interviewer đặt câu hỏi, cách ứng viên clarify, phân tích, code và trao đổi follow-up.
▶ Xem cả playlist các buổi phỏng vấn mẫu khác trên kênh YouTube EngineerPro.
Mẹo xem: lần đầu xem hãy quan sát cách ứng viên trình bày, không cần hiểu hết thuật toán. Lần thứ 2 quay lại sau khi đã đọc xong Chương 1–5, bạn sẽ nhận ra rất nhiều pattern quen thuộc.
Chào mừng bạn đến với Coding DSA Interview At Big Tech kèm lời giải.
Cuốn sách này được viết với một niềm tin đơn giản: phỏng vấn coding ở Big Tech không phải là cuộc thi đố vui — đó là một bộ kỹ năng có thể học và rèn luyện một cách có hệ thống.
Hầu hết các tài liệu phỏng vấn hiện có thường rơi vào 1 trong 2 thái cực: - Quá học thuật (textbook DSA), thiếu thực tế phỏng vấn. - Quá “mẹo vặt” (300 LeetCode), thiếu khung tư duy hệ thống.
Cuốn sách này cố gắng đứng ở giữa: học PATTERN, không học bài. Mỗi chương tập trung vào 1 pattern, 5-18 bài tiêu biểu, kèm cách trình bày trên whiteboard và bẫy thường gặp khi phỏng vấn thật. Khi bạn nắm được pattern, bài mới chỉ là biến thể.
Đối tượng độc giả: - Sinh viên CNTT chuẩn bị thực tập / new-grad ở Big Tech. - Lập trình viên đã đi làm muốn chuyển việc lên FAANG-tier. - Người tự học LeetCode đang bí cách tiếp cận có hệ thống.
Chúc bạn pass mọi vòng phỏng vấn!
Có 3 cách tiếp cận tuỳ thời gian + kinh nghiệm:
Đọc tuần tự (khuyến nghị cho người mới): Chương 1 → 44 theo thứ tự. Mỗi chương bạn phải tự code trước khi xem lời giải. Hết Level 1 (Chương 1–20) bạn đã có nền tảng tốt cho phỏng vấn entry-level Big Tech.
Đọc theo pattern (nếu đã có nền): lướt mục lục, đọc chương nào còn yếu. Mỗi chương đứng độc lập, có tham chiếu chéo (cross-reference) sang chương khác rõ ràng.
Ôn gấp trước onsite: xem Roadmap học dưới đây.
Roadmap 1 tuần (chuẩn bị onsite gấp): - Ngày 1-2: Frontmatter (0.1-0.5) + Phụ lục D (50 bài must-do). - Ngày 3: Ôn Chương 1, 2, 5, 6 (Array, String, Binary Search, Hash) — toàn bộ Easy/Medium tier. - Ngày 4: Chương 7, 9, 10, 11 (Linked List, Graph, BFS, DFS). - Ngày 5: Chương 17, 18, 27 (D&C, Monotonic, Sliding Window). - Ngày 6: Chương 28, 29 (Backtracking, DP) — 2 chương dài và quan trọng nhất. - Ngày 7: Mock interview + đọc Phụ lục E (Behavioral).
Roadmap 2 tuần (đã biết DSA, refresh): - Tuần 1: Level 1 (Chương 1-20) — 3 chương/ngày. - Tuần 2: Level 2 (Chương 21-32) — 2 chương/ngày + mock interview cuối tuần.
Roadmap 6 tuần (người mới): - Tuần 1-2: Frontmatter + Level 1 (Ch 1-10) — 1 chương/ngày, tự code mỗi bài. - Tuần 3-4: Level 1 (Ch 11-20) — 1 chương/ngày. - Tuần 5: Level 2 (Ch 21-32) — 2 chương/ngày, đọc kỹ pattern. - Tuần 6: Level 3 (Ch 33-44) — 2 chương/ngày, không cần thuộc hết — biết tồn tại.
Skip nếu thiếu thời gian (priority chương quan trọng): bỏ qua Ch 20 (Prime), Ch 31 (Game Theory), Ch 33-36 (MST/Hash/KMP/Z) — niche pattern, hiếm trong phỏng vấn entry/mid.
Tự đánh giá level: - Easy < 30 phút mỗi bài → Level 1 đủ. - Medium 30-60 phút → đọc tới Level 2. - Hard > 60 phút hoặc bí thường xuyên → đọc cả Level 3.
⚠️ Đừng đọc code trước khi tự lên kế hoạch. Sách trình bày lời giải sau mục “Hướng tiếp cận” có lý do — bạn phải vật lộn với bài trước khi xem đáp án; đó là cách não bộ hấp thu pattern hiệu quả nhất.
UMPIRE = framework 6 bước giúp bạn không “đóng băng” khi nhận đề:
O(?).left, right thay vì
i, j).Mẹo tâm lý: Interviewer muốn thấy quá trình tư duy, không phải lời giải hoàn hảo ngay từ đầu. Hãy nói thành lời (think out loud).
f(n) = O(g(n)) ↔︎ tồn tại c > 0, n₀ sao
cho f(n) <= c·g(n) với mọi n >= n₀.
Trong phỏng vấn: bỏ qua hằng số, bỏ qua thành phần thấp
hơn. 3n² + 100n + 5 = O(n²).
| Notation | Tên | Ví dụ |
|---|---|---|
O(1) |
Constant | Hash lookup, push/pop stack |
O(log n) |
Logarithm | Binary search |
O(n) |
Linear | Duyệt mảng |
O(n log n) |
Linearithmic | Sort, segment tree build |
O(n²) |
Quadratic | Brute force 2 vòng for |
O(2^n) |
Exponential | Subset bruteforce |
O(n!) |
Factorial | Permutation bruteforce |
a();b();):
O(a + b), lấy max.O(n × n) = O(n²).O(log n).O(n log n) nếu work
O(n) mỗi level (merge sort).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).Một số op đôi khi chậm, trung bình nhanh: -
list.append() Python: O(1) amortized (mặc dù
dynamic resize). - Hash table với open addressing: O(1)
amortized.
O(n) với constant 1000
có thể chậm hơn O(n²) với n nhỏ.n |
Cho phép |
|---|---|
n ≤ 10 |
O(n!) brute force |
n ≤ 20 |
O(2^n) bitmask |
n ≤ 5000 |
O(n²) OK |
n ≤ 10^5 |
O(n log n) hoặc O(n) |
n ≤ 10^7 |
O(n) strict |
n ≤ 10^9 |
O(log n) (search on answer) |
# List
lst = [1, 2, 3]
lst.append(x); lst.pop() # O(1) cả 2
lst.insert(0, x); lst.pop(0) # O(n) - tránh!
sorted_lst = sorted(lst) # O(n log n), trả về list mới
lst.sort() # in-place
lst[::-1] # reverse, O(n)
lst[a:b] # slicing, O(b-a) copy
# Dict
d = {}; d[k] = v # O(1) amortized
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 # giao/hợp/hiệu
# Deque (double-ended queue) — dùng cho BFS, sliding window
from collections import deque
dq = deque()
dq.append(x); dq.appendleft(x)
dq.pop(); dq.popleft() # tất cả 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)]Số học & division: - dict[k] raise
KeyError nếu k không có → dùng
dict.get(k, default) hoặc defaultdict. -
int / int = float; phải dùng // cho integer
division. - -7 // 2 = -4 (floor), không phải
-3 (truncate toward 0). Truncate: int(-7/2). -
-7 % 2 = 1 trong Python (luôn ≥ 0), khác với C/C++/Java.
Cẩn thận khi modulo số âm trong prefix sum.
Heap & comparison: - heapq Python
chỉ có min-heap. Muốn max-heap → push -x.
- Heap so sánh tuple element-by-element:
(priority, idx, payload) — idx làm tiebreaker
khi priority bằng nhau (vì payload có thể
không hashable / comparable, ví dụ ListNode). -
heappush heap với tuple chứa object không comparable (như
ListNode) sẽ raise TypeError khi priority bằng
nhau.
Recursion & cache: - Python recursion limit mặc
định ~1000. Set sys.setrecursionlimit(10**6) cho graph/tree
lớn. - Python không có tail call optimization → đệ quy
đệ quy đệ quy ⇒ stack overflow. Chuyển sang iterative khi
n > 10^5. - @functools.cache chỉ work với
arguments hashable. list /
dict / set → không cache được. Dùng
tuple(...) / frozenset(...) để wrap. -
@cache trên self.method lưu cache
global (qua mọi instance). Dùng @cached_property
nếu cache theo instance.
Mutable defaults & references: - Default mutable
argument: def f(x=[]) — bug nghiêm trọng (mọi call share
cùng list). Dùng def f(x=None): x = x or []. -
result.append(path) — append reference, không
copy. Cần result.append(path.copy()) hoặc
result.append(path[:]).
Sort & stability: - sorted() Python
là Timsort, stable, O(n log n). Tận dụng
stability để sort multi-key bằng nhiều lần sort. - Sort custom: Python 3
không có cmp= arg. Dùng key=... hoặc
functools.cmp_to_key(...).
Integer & overflow: - Python int vô
hạn → không cần lo overflow như Java/C++. Nhưng cố ý truncate (32-bit)
khi đề yêu cầu (LC 7, LC 8, LC 50).
def solve(...) trước, gọi tới các hàm phụ (helper), rồi mới
cài đặt hàm phụ sau.left, right, slow,
fast, prev, curr.a, b, c, x,
tmp.O(n²) chạy được nhưng sẽ TLE; em đang tìm hướng tối ưu
hơn…”n = 3 thay
vì n = 100.O(n log n) vì bước sort chiếm chi phí lớn nhất”.O(n) rồi mỗi truy vấn trả lời
trong O(1)…”| Giai đoạn | Câu mẫu |
|---|---|
| Làm rõ đề | “Cho em xác nhận lại đề: input là …, output là …, có ràng buộc gì thêm không?” |
| Brute force | “Trước hết em mô tả cách trực tiếp: duyệt mọi cặp, độ phức tạp
O(n²)…” |
| Tối ưu | “Em thấy có thể dùng hash map để giảm thao tác tra cứu xuống
O(1)…” |
| Bí | “Em đang lưỡng lự giữa hash map và sorted array. Anh/chị có gợi ý nào không?” |
| Xong | “Lời giải chạy O(n) thời gian và O(n)
bộ nhớ. Để em chạy thử vài edge case…” |
Array (mảng) là cấu trúc dữ liệu cơ bản nhất nhưng cũng là pattern xuất hiện nhiều nhất trong phỏng vấn coding ở Big Tech. Phần lớn các kỹ thuật ở chương sau (two pointers, sliding window, prefix sum, monotonic stack, …) đều bắt nguồn từ việc thao tác trên array. Mục tiêu của chương: thành thạo các thao tác in-place, two-pass, và bắt đầu hình thành thói quen “nghĩ về index thay vì nghĩ về phần tử”.
Sau chương này, bạn sẽ:
O(1) extra space.from typing import List
def two_pass_pattern(nums: List[int]) -> List[int]:
"""Mẫu 2-lượt: pass 1 gom thông tin, pass 2 dùng thông tin đó."""
n = len(nums)
aux = [0] * n
# pass 1: tính prefix / suffix / count
for i in range(n):
aux[i] = ... # tuỳ bài
# pass 2: dùng aux để ra kết quả
out = [0] * n
for i in range(n):
out[i] = ... # tuỳ bài
return out
def two_pointers_in_place(nums: List[int]) -> int:
"""Mẫu two pointers in-place: slow = vị trí ghi, fast = vị trí đọc."""
slow = 0
for fast in range(len(nums)):
if condition(nums[fast]):
nums[slow] = nums[fast]
slow += 1
return slow # độ dài phần "hợp lệ" sau khi nénCho một mảng số nguyên nums và một số nguyên
target. Hãy trả về chỉ số của hai phần tử
trong nums sao cho tổng của chúng bằng
target.
Bạn có thể giả định mỗi input có đúng một đáp án, và không được dùng cùng một phần tử hai lần.
Input: nums = [2, 7, 11, 15], target = 9
Output: [0, 1]
Giải thích: nums[0] + nums[1] == 9.
2 <= len(nums) <= 10^4-10^9 <= nums[i] <= 10^9-10^9 <= target <= 10^9Brute force — O(n²). Duyệt mọi cặp
(i, j) với i < j và kiểm tra
nums[i] + nums[j] == target. Dễ code nhưng sẽ TLE khi
n lớn.
Tối ưu — Hash map một lượt — O(n). Khi
đứng tại chỉ số i, ta cần tìm xem có j < i
nào để nums[j] == target - nums[i] hay không. Dùng
dict để lưu {giá_trị: chỉ_số} của các phần tử
đã thấy.
Mẹo trình bày: Luôn bắt đầu bằng brute force, nói rõ độ phức tạp của nó, sau đó nói: “Em nghĩ có thể thay phép tìm tuyến tính
O(n)bằng hash map tra cứuO(1), nhờ đó tổng độ phức tạp giảm từO(n²)xuốngO(n)…” — interviewer rất thích luồng tư duy này.
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 [] # theo đề bài, dòng này không bao giờ chạyO(n) — duyệt 1 lượt, mỗi
look-up trong dict là O(1) trung bình.O(n) — dict lưu tối đa
n cặp (giá_trị, chỉ_số).seen[x] = i
trước khi check complement — sẽ sai khi
nums = [3, 3] và target = 6 (lúc đó
complement == x và ta sẽ dùng cùng một phần tử hai
lần).O(n) time,
O(1) extra space (LC 167).Cho mảng prices với prices[i] là giá cổ
phiếu vào ngày thứ i. Bạn được mua một lần
rồi bán một lần sau đó (không được bán trước khi mua).
Hãy trả về lợi nhuận lớn nhất có thể, hoặc
0 nếu không có giao dịch nào có lãi.
Input: prices = [7, 1, 5, 3, 6, 4]
Output: 5
Giải thích: mua ngày 2 (giá 1), bán ngày 5 (giá 6), lợi nhuận = 6 - 1 = 5.
Input: prices = [7, 6, 4, 3, 1]
Output: 0
Giải thích: giá giảm liên tục, không có giao dịch nào có lãi.
1 <= len(prices) <= 10^50 <= prices[i] <= 10^40.Brute force — O(n²). Duyệt mọi cặp
(i, j) với i < j và lấy
max(prices[j] - prices[i]). TLE với
n = 10^5.
Tối ưu — một lượt, O(n). Khi đứng tại
ngày i và quyết định “sẽ bán hôm nay”, lợi nhuận
tối ưu là prices[i] - min(prices[0..i-1]). Vậy ta chỉ cần
duy trì min_so_far khi duyệt và cập nhật best
mỗi bước.
Hình minh hoạ với
prices = [7, 1, 5, 3, 6, 4]:
day : 0 1 2 3 4 5
prices : 7 1 5 3 6 4
│ │
│ mua ở đây │ bán ở đây
▼ ▼
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 ← đáp án = 5
▲
giữ nguyên vì 2 < 4
Mindset: Đây là DP một biến. State
min_so_farchính là cách rút gọn mảngdp[i] = min(prices[0..i])vềO(1)space — kỹ thuật sẽ gặp đi gặp lại ở các chương DP sau.
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) — duyệt 1 lượt.O(1) — chỉ 2 biến.best = -inf
rồi cuối cùng quên xử lý trường hợp tất cả giá giảm — sẽ trả về số âm.
Khởi tạo best = 0 cho an toàn.Cho một mảng nums có n số nguyên, trả về
mảng answer cùng độ dài, sao cho answer[i] là
tích của tất cả phần tử của nums
trừ nums[i].
Ràng buộc đặc biệt: - Không được dùng phép
chia. - Phải chạy trong O(n) time.
Input: nums = [1, 2, 3, 4]
Output: [24, 12, 8, 6]
Giải thích:
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] <= 30int 32-bit.O(1) extra space.nums? → Mảng mới.Brute force — O(n²). Với mỗi
i, duyệt lại toàn mảng để tính tích — đề bài đã cấm
sẵn.
Phép chia — O(n) nhưng bị cấm. Tính
total = product(nums) rồi
answer[i] = total / nums[i]. Cấm vì khi
nums[i] == 0 ta chia cho 0; hơn nữa, nhiều ngôn ngữ không
có integer-exact division.
Tối ưu — Prefix product + Suffix product — O(n)
time, O(1) extra space (không tính output).
Quan sát:
answer[i] = (∏ nums[0..i-1]) * (∏ nums[i+1..n-1]). Gọi 2
lượng: - left[i] = tích nums[0..i-1] (left
product, left[0] = 1). - right[i] = tích
nums[i+1..n-1] (right product,
right[n-1] = 1). Khi đó
answer[i] = left[i] * right[i].
Để đạt O(1) extra space, ta dùng chính mảng
answer: - Lượt 1 (trái → phải): điền
answer[i] = left[i]. - Lượt 2 (phải →
trái): nhân answer[i] *= right, vừa duyệt vừa cập nhật biến
right.
Hình minh hoạ với
nums = [1, 2, 3, 4]:
i=0 i=1 i=2 i=3
nums : [ 1 , 2 , 3 , 4 ]
┌─────────────┐
│ prefix → │ (tích các phần tử BÊN TRÁI i)
▼ ▼
left[i] : [ 1 , 1 , 2 , 6 ]
(rỗng) (1) (1·2) (1·2·3)
┌─────────────┐
│ ← suffix │ (tích các phần tử BÊN PHẢI i)
▼ ▼
right[i] : [ 24 , 12 , 4 , 1 ]
(2·3·4)(3·4) (4) (rỗng)
↓ nhân từng vị trí ↓
answer[i] : [ 24 , 12 , 8 , 6 ]
1·24 1·12 2·4 6·1
Trong code thực, ta không lưu cả 2 mảng
left và right — chỉ dùng answer
cho lượt prefix, rồi dùng 1 biến right rolling từ phải sang
trái để nhân vào answer ngay tại chỗ.
from typing import List
class Solution:
def productExceptSelf(self, nums: List[int]) -> List[int]:
n = len(nums)
answer = [1] * n
# Lượt 1: answer[i] = tích các phần tử ở bên trái i.
left = 1
for i in range(n):
answer[i] = left
left *= nums[i]
# Lượt 2: nhân thêm tích bên phải, dùng biến right rolling.
right = 1
for i in range(n - 1, -1, -1):
answer[i] *= right
right *= nums[i]
return answerO(n) — đúng 2 lượt qua
mảng.O(1) extra (không tính
output).zero_count).
Nếu zero_count >= 2 → all-zero. Nếu == 1 →
chỉ vị trí đó nhận product_non_zero, các vị trí khác là 0.
Nếu == 0 → chia bình thường.Cho mảng nums, hãy di chuyển tất cả số 0 về cuối
mảng, giữ nguyên thứ tự tương đối của các phần tử khác 0. Phải
làm in-place, không được tạo mảng phụ.
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).0 mới bị “đẩy” về cuối.Brute force — O(n) time, O(n) extra
space. Tạo mảng phụ chứa các số khác 0, sau đó pad số 0 cho đủ
n. Đề bài cấm dùng mảng phụ — bị loại.
Tối ưu — Two pointers — O(n) time,
O(1) extra space. Dùng 2 con trỏ: -
slow = vị trí tiếp theo để ghi một số khác
0. - fast = vị trí đang đọc trong
mảng.
Pass 1: với mỗi fast, nếu nums[fast] != 0 →
nums[slow] = nums[fast], tăng slow. Pass 2: từ
slow đến hết mảng, gán 0.
Hình minh hoạ với
nums = [0, 1, 0, 3, 12]:
Lượt 1: dồn các số khác 0 về đầu
──────────────────────────────────
Khởi tạo : [ 0 , 1 , 0 , 3 , 12] slow=0 fast=0
S
F
fast=0: nums[0]=0, bỏ qua
[ 0 , 1 , 0 , 3 , 12] slow=0 fast=1
S
F
fast=1: nums[1]=1≠0 → ghi nums[0]=1, slow++
[ 1 , 1 , 0 , 3 , 12] slow=1 fast=2
S
F
fast=2: nums[2]=0, bỏ qua slow=1 fast=3
fast=3: nums[3]=3≠0 → ghi nums[1]=3, slow++
[ 1 , 3 , 0 , 3 , 12] slow=2 fast=4
S
F
fast=4: nums[4]=12≠0 → ghi nums[2]=12, slow++
[ 1 , 3 ,12 , 3 , 12] slow=3 fast=hết
Lượt 2: từ slow đến hết, gán 0
──────────────────────────────────
[ 1 , 3 ,12 , 0 , 0 ] ← đáp án
▲ ▲
gán 0 gán 0
Cách này tối thiểu hoá số phép ghi về đúng
n(vì mỗi ô được ghi đúng 1 lần). Có một biến thể dùngswapngay khi đi qua, code ngắn hơn nhưng số ghi gấp đôi.
from typing import List
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
slow = 0
# Lượt 1: dồn các số khác 0 về đầu.
for fast in range(len(nums)):
if nums[fast] != 0:
nums[slow] = nums[fast]
slow += 1
# Lượt 2: phần còn lại gán 0.
for i in range(slow, len(nums)):
nums[i] = 0O(n) — 2 lượt liên tiếp,
tổng cộng vẫn 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 += 1slow đến
hết mảng → vẫn còn duplicate của các số khác 0.Cho mảng height với height[i] là chiều cao
của cột thứ i. Hãy chọn ra 2 cột i < j sao
cho lượng nước chứa được giữa chúng là lớn nhất.
Lượng nước = min(height[i], height[j]) * (j - i).
Input: height = [1, 8, 6, 2, 5, 4, 8, 3, 7]
Output: 49
Giải thích: chọn cột 1 (cao 8) và cột 8 (cao 7) → 7 * (8-1) = 49.
Input: height = [1, 1]
Output: 1
2 <= len(height) <= 10^50 <= height[i] <= 10^4j - i).Brute force — O(n²). Duyệt mọi cặp
(i, j) và lấy max. TLE với n = 10^5.
Tối ưu — Two pointers, O(n). Đặt
l = 0, r = n - 1. Tại mỗi bước, diện tích hiện
tại = min(height[l], height[r]) * (r - l).
Câu hỏi cốt lõi: dịch con trỏ nào? — Dịch con trỏ ở bên thấp hơn.
Tại sao? Diện tích bị giới hạn bởi cột thấp hơn. Nếu dịch con trỏ ở cột cao hơn vào trong, khoảng cách giảm và cột thấp vẫn là bottleneck → diện tích chỉ có thể giảm hoặc bằng. Còn nếu dịch con trỏ ở cột thấp hơn, ta có cơ hội (không bảo đảm) gặp một cột cao hơn để diện tích tăng.
Proof bằng “loại trừ”: Khi cố định cột thấp (giả sử bên trái) và dịch cột bên phải vào, mọi cặp (
l,r' < r) đều có diện tích ≤height[l] * (r - l). Do đó các cặp này không thể là đáp án nếu chưa tốt hơn cái hiện tại — ta “loại trừ” chúng cùng lúc và chỉ cần dịchl.
Hình minh hoạ với
height = [1, 8, 6, 2, 5, 4, 8, 3, 7]:
▓ ▓
8 ▓ ▓ | ← chiều cao 8
7 ▓ ▓ ▓
6 ▓ ▓ ▓ ▓ ▓
5 ▓ ▓ ▓ ▓ ▓ ▓
4 ▓ ▓ ▓ ▓ ▓ ▓ ▓
3 ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓
2 ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓
1 ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓
└─┴───┴───┴───┴───┴───┴───┴────┴───┴─
index: 0 1 2 3 4 5 6 7 8
L R
Bảng trace (★ = đáp án tốt nhất tại thời điểm đó):
bước │ 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 │ │ │
Đáp án: 49 (cặp cột index 1 và 8, cao 8 và 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))
# Luôn dịch con trỏ ở phía thấp hơn.
if height[l] < height[r]:
l += 1
else:
r -= 1
return bestO(n) — mỗi vòng lặp dịch 1
con trỏ, tổng tối đa n - 1 bước.O(1).height[l] == height[r]: dịch con
trỏ nào cũng được, vì cặp (l, r) với cả 2 cột bằng nhau đã
được đo, các cặp tiếp theo phải có một bên ≤ h hiện tại →
không tốt hơn nữa.Cho mảng nums và một số nguyên không âm k.
Hãy xoay mảng sang phải k bước.
Input: nums = [1, 2, 3, 4, 5, 6, 7], k = 3
Output: [5, 6, 7, 1, 2, 3, 4]
Giải thích: xoay phải 1 bước → [7,1,2,3,4,5,6]; 2 bước → [6,7,1,2,3,4,5]; 3 bước → [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 có thể lớn hơn n không? → Có —
phải k %= n trước.k = 0 được phép không? → Có, output giống
input.Brute force — Xoay 1 bước, lặp k lần —
O(n·k). TLE.
Mảng phụ — O(n) time, O(n)
space. new[(i + k) % n] = nums[i], rồi copy
new về nums. Đơn giản nhưng vi phạm follow-up
O(1) space.
Tối ưu — Three Reverses, O(n) time,
O(1) extra space. Quan sát ví dụ với
n = 7, k = 3: - Reverse toàn bộ:
[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]. ✓
Trực giác: Khi reverse toàn bộ, phần “đáng lẽ ra cuối” giờ đứng đầu nhưng bị đảo ngược. Hai lần reverse con tiếp theo “sửa” lại thứ tự bên trong mỗi khối.
Hình minh hoạ với n = 7, k = 3:
Input : [ 1 2 3 4 │ 5 6 7 ]
▲
k=3 phần tử cuối cần "nhảy" lên đầu
──────────────────────────────────────────────────
Bước 1: reverse toàn bộ [0..6]
◀═══════════════════════════▶
[ 7 6 5 4 3 2 1 ]
↑ ↑
(5,6,7 đảo) (1,2,3,4 đảo)
Bước 2: reverse [0..k-1] = [0..2] (sửa lại 3 phần tử đầu)
◀═══════▶
[ 5 6 7 │ 4 3 2 1 ]
↑
3 phần tử đầu 4 phần tử cuối vẫn đảo
đã đúng cần sửa tiếp
Bước 3: reverse [k..n-1] = [3..6] (sửa lại 4 phần tử cuối)
◀═══════════════▶
[ 5 6 7 │ 1 2 3 4 ] ← đáp án ✓
from typing import List
class Solution:
def rotate(self, nums: List[int], k: int) -> None:
n = len(nums)
k %= n # luôn normalize trước
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) # đảo toàn bộ
reverse(0, k - 1) # đảo phần đầu (k phần tử)
reverse(k, n - 1) # đảo phần đuôiO(n) — mỗi phần tử bị swap
đúng 2 lần.O(1).k %= n → khi k > n, các vòng
reverse có index âm hoặc sai phạm.k bước: làm tương tự nhưng reverse
[0..k-1] trước, sau đó [k..n-1], cuối cùng
reverse cả mảng (hoặc tương đương: rotate phải n - k).gcd(n, k) chu trình độc lập, mỗi chu trình “đẩy”
phần tử theo nhịp k. Code phức tạp hơn một chút nhưng cũng
O(n) / O(1). Nên biết cho follow-up.| Câu hỏi | Nếu YES | Nếu NO |
|---|---|---|
| Cho phép mutate input không? | In-place (Move Zeroes, Rotate) | Tạo array kết quả |
| Cần giữ thứ tự gốc? | Two-pointer cùng chiều | Có thể swap tự do |
| Trả về index hay value? | Cẩn thận khi sort: lưu (value, index) |
— |
| Có số 0 / số âm? | Product Except Self không dùng được division | Có thể prefix×suffix bình thường |
| Cần O(1) bộ nhớ? | 3-reverse trick, in-place marker | Có thể dùng hash/extra array |
| Cách | Time | Space | Khi nào chọn |
|---|---|---|---|
| Extra array | O(n) | O(n) | Dễ viết, ít bug nhất; khi RAM dư |
| 3-reverse | O(n) | O(1) | Mặc định trong phỏng vấn — đẹp & ngắn |
| Cyclic replacement (GCD) | O(n) | O(1) | Khi interviewer hỏi follow-up “không reverse” |
Chuỗi thực ra là mảng các ký tự — tất cả kỹ thuật ở Chương 1 (two pointers, in-place, prefix) đều áp dụng được. Tuy nhiên, string có 2 đặc thù riêng: (i) phải xử lý bảng mã (chỉ ASCII hay full Unicode?), và (ii) trong Python, chuỗi là immutable — không sửa được tại chỗ, mọi thao tác “đổi ký tự” thực ra phải chuyển sang
listrồi''.join.
Sau chương này, bạn sẽ:
list.from collections import Counter
from typing import List
def two_pointers_in_string(s: str) -> bool:
"""Mẫu two pointers: kiểm tra điều kiện đối xứng / cặp."""
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]:
"""Bảng đếm ký tự — gần như mọi bài string đều dùng."""
return Counter(s)Cho hai chuỗi s và t. Trả về
True nếu t là anagram của
s (cùng các ký tự, cùng số lần xuất hiện, chỉ khác thứ tự),
ngược lại False.
Input: s = "anagram", t = "nagaram"
Output: True
Input: s = "rat", t = "car"
Output: False
1 <= len(s), len(t) <= 5·10^4s, t chỉ chứa chữ thường tiếng Anh.Counter.Brute force — sắp xếp, O(n log n).
sorted(s) == sorted(t). Code 1 dòng, nhưng
O(n log n) về thời gian và O(n) về bộ nhớ (vì
sorted trả về list).
Tối ưu — Counter một lượt, O(n). Đếm ký
tự trong s, sau đó duyệt t và trừ. Nếu có ký
tự nào âm hoặc kết thúc với mọi count = 0 → đúng anagram.
Tối ưu hơn nữa — bảng cố định 26 phần tử, O(1)
extra space (theo bảng mã). Vì chỉ có 26 chữ cái, ta dùng
int[26] (hoặc list 26 phần tử) thay cho dict. Bộ nhớ thực
tế là O(1) (không phụ thuộc 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:
"""Bảng cố định 26 chữ cái — O(1) bộ nhớ thực tế."""
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) cho cả 2 cách Counter
/ bảng 26.O(1) (chính xác là
O(k) với k = kích thước bảng chữ).O(n log n) time, O(n)
space.True sai khi
len(s) != len(t).set(s) == set(t) — sai! Set bỏ đi
count, “aab” và “ab” sẽ ra True.Counter, không thể dùng bảng 26.Counter trước (gọn gàng, chỉ 1 dòng), sau đó mới nhắc đến
giải pháp dùng mảng đếm 26 phần tử khi interviewer hỏi về tối ưu bộ
nhớ.Cho chuỗi s. Coi là palindrome nếu sau
khi chuyển toàn bộ chữ in hoa → thường và bỏ tất cả ký tự không
phải chữ-và-số thì chuỗi đọc xuôi và ngược giống nhau. Trả về
True / False.
Input: s = "A man, a plan, a canal: Panama"
Output: True
Giải thích: sau lọc → "amanaplanacanalpanama" — đọc xuôi và ngược giống nhau.
Input: s = "race a car"
Output: False
Giải thích: sau lọc → "raceacar" — không palindrome.
Input: s = " "
Output: True
Giải thích: chuỗi rỗng coi là palindrome.
1 <= len(s) <= 2·10^5s chứa chữ in hoa, in thường, số, và các ký tự
khác.Brute force — lọc rồi so sánh đảo ngược — O(n)
time, O(n) space.
filtered = ''.join(ch.lower() for ch in s if ch.isalnum()),
rồi filtered == filtered[::-1]. Đơn giản, nhưng tốn
O(n) bộ nhớ phụ.
Tối ưu — Two pointers in-place — O(n) time,
O(1) space. Hai con trỏ l (đầu) và
r (cuối), bỏ qua ký tự không phải alphanumeric ở mỗi bên,
rồi so sánh s[l].lower() == s[r].lower(). Nếu khác →
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) — mỗi ký tự duyệt tối
đa 1 lần.O(1).l < r trong vòng while inner
→ out of range..lower() khi so sánh → “Aa” sẽ bị
False.isalpha() thay vì isalnum() → bỏ sót
chữ số.s[l] hoặc s[r], kiểm tra phần còn lại.l < r cẩn thận
khi có vòng while bên trong.Cho mảng các chuỗi strs. Hãy trả về tiền tố
chung dài nhất. Nếu không có tiền tố chung, trả về
"".
Input: strs = ["flower", "flow", "flight"]
Output: "fl"
Input: strs = ["dog", "racecar", "car"]
Output: ""
Giải thích: không có ký tự nào chung ngay từ vị trí đầu.
1 <= len(strs) <= 2000 <= len(strs[i]) <= 200strs[i] chỉ chứa chữ thường."".Cách 1 — Vertical scan, O(S) với S
là tổng độ dài. Duyệt từng vị trí cột
i = 0, 1, 2, .... Tại mỗi i, kiểm tra ký tự
strs[0][i] có khớp với strs[j][i] cho mọi
j không. Nếu có chuỗi nào hết hoặc khác → trả về
strs[0][:i].
Cách 2 — Horizontal scan. Lấy
prefix = strs[0], sau đó với mỗi chuỗi tiếp theo, rút ngắn
prefix cho đến khi nó là tiền tố của chuỗi đó.
Cách 3 — Sort + so sánh 2 đầu,
O(n log n · L). Sort mảng theo thứ tự
lexicographic. Tiền tố chung dài nhất chính là tiền tố chung của
strs[0] và strs[-1]. Hay nhưng không tối ưu
time.
Mình giới thiệu vertical scan vì nó là cách dễ nhất để diễn đạt trên whiteboard và có thể early-exit ngay khi gặp mismatch đầu tiên.
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] # toàn bộ strs[0] là tiền tố chungO(S) với
S = Σ len(strs[i]) (trong worst case).O(1).i >= len(s) → IndexError khi có chuỗi
ngắn hơn strs[0].strs[0] khi đáng lẽ phải trả về tiền tố ngắn
hơn.strs = [""] → kết quả "".strs = ["a"] → kết quả "a".strs = ["abc", "abc"] → kết quả
"abc".Cài đặt hàm atoi (ASCII to Integer) chuyển một chuỗi
thành số nguyên 32-bit có dấu. Quy tắc:
+ hoặc - (tuỳ chọn).int 32-bit:
[-2^31, 2^31 - 1].0 nếu không đọc được số nào (ví dụ chuỗi toàn
chữ).Input: s = "42"
Output: 42
Input: s = " -42"
Output: -42 (bỏ space đầu, đọc dấu '-', rồi đọc "42")
Input: s = "4193 with words"
Output: 4193 (dừng tại khoảng trắng sau "4193"; phần "with words" bị bỏ)
Input: s = "words and 987"
Output: 0 (gặp 'w' đầu tiên ngay sau khi bỏ space → không đọc được số nào)
Input: s = "-91283472332"
Output: -2147483648 (= INT_MIN, clamp vì -91283472332 < -2^31)
Input: s = "+-12"
Output: 0 (đã đọc dấu '+', sau đó gặp '-' không phải digit → fail ngay)
0 <= len(s) <= 200s chứa chữ in hoa, in thường, số, ’ ‘,’+‘,’-‘,’.’.INT_MIN /
INT_MAX. Không raise exception.Cách 1 — Quy trình tuần tự, dùng index chạy qua
chuỗi. 4 bước rõ ràng: skip space → đọc dấu → đọc số → clamp.
Mỗi bước có biến trạng thái riêng.
Cách 2 — Finite State Machine (FSM). Mô hình trạng thái giúp code gọn hơn và dễ mở rộng khi đề bài thêm yêu cầu (số thực, scientific notation, …). Rất đáng học vì đây là pattern chung cho mọi bài parser (Chương 32).
Hình minh hoạ FSM:
blank sign digit khác
┌───────────────────────────────────────────────┐
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 └───────────────────────────────────────────────┘
Trạng thái:
start : đang skip space đầu
signed : đã đọc 1 dấu, chờ digit
in_number : đang đọc các chữ số
end : đã kết thúc, mọi ký tự sau bị ignore
INT_MAX = 2**31 - 1 # 2147483647
INT_MIN = -2**31 # -2147483648
class Solution:
"""Cách 1 — quy trình tuần tự."""
def myAtoi(self, s: str) -> int:
i, n = 0, len(s)
# 1. Bỏ space đầu.
while i < n and s[i] == ' ':
i += 1
# 2. Đọc dấu (tuỳ chọn).
sign = 1
if i < n and s[i] in '+-':
sign = -1 if s[i] == '-' else 1
i += 1
# 3. Đọc các chữ số.
result = 0
while i < n and s[i].isdigit():
result = result * 10 + (ord(s[i]) - ord('0'))
# Tối ưu: có thể early-clamp ngay đây để khỏi overflow.
if result > 2**31: # vượt rất nhiều
break
i += 1
# 4. Áp dấu và clamp.
result *= sign
return max(INT_MIN, min(INT_MAX, result))
class SolutionFSM:
"""Cách 2 — Finite State Machine. Dễ extend khi đề thêm yêu cầu."""
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) # chặn overflow sớm
elif state == 'signed':
sign = -1 if ch == '-' else 1
elif state == 'end':
break
return max(INT_MIN, min(INT_MAX, sign * result))O(n) — duyệt chuỗi đúng 1
lần.O(1).+-12 hay
++12 → phải end ngay khi gặp dấu thứ 2." 1 2 3" → kết quả là 1.s.strip() là
sai — nó loại bỏ cả space cuối, không phải vấn đề;
nhưng cẩn thận s.lstrip() thay vì
strip().., e, dấu, … → bắt buộc dùng FSM (Chương
32).0b,
0x).Cho mảng chuỗi strs. Hãy gom các chuỗi là
anagram của nhau vào cùng một nhóm. Trả về danh sách
các nhóm (thứ tự nhóm và thứ tự trong nhóm không quan trọng).
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] chỉ chứa chữ thường.Ý tưởng cốt lõi: Hai chuỗi là anagram ↔︎ có cùng
“chữ ký”. Ta dùng dict
{chữ_ký: list các chuỗi} để gom.
Cách 1 — Chữ ký = sorted(s),
O(n · k log k).
key = ''.join(sorted(s)). Hai anagram sẽ có cùng sorted
form.
Cách 2 — Chữ ký = tuple count 26 chữ,
O(n · k).
key = tuple(Counter(s)[ch] for ch in 'abcdefghijklmnopqrstuvwxyz').
Tránh được phép sort O(k log k) nhưng tuple 26 phần tử có
overhead.
Hình minh hoạ với
["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:
"""Chữ ký = tuple count 26 chữ — không cần sort."""
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())| Cách | Time | Space |
|---|---|---|
| sorted-key | O(n · k log k) |
O(n·k) |
| count-key | O(n · k) |
O(n·k) |
Với n = số chuỗi, k = độ dài chuỗi.
k rất nhỏ (≤ 100, như LC) → cả 2 đều ổn, sorted-key gọn
hơn.k lớn (≥ 10^4) → count-key thắng vì O(k)
< O(k log k).Counter,
tránh tuple 1000+ phần tử.''.join sau
sorted() — sẽ ra list không hash được.Cho chuỗi s chứa nhiều từ cách nhau bởi
ít nhất 1 dấu cách. Hãy đảo thứ tự các từ và trả về chuỗi kết quả, sao
cho:
Input: s = "the sky is blue"
Output: "blue is sky the"
Input: s = " hello world "
Output: "hello world" (gọn space đầu/cuối + giữa)
Input: s = "a good example"
Output: "example good a" (gộp nhiều space giữa thành 1)
1 <= len(s) <= 10^4s chứa chữ in hoa, in thường, số, và dấu cách
' '.s chứa ít nhất một từ.O(1) extra space (chỉ áp
dụng nếu input là mảng ký tự có thể sửa được — như C/C++).s.split() không? → Có, đây là cách
Pythonic. Nhưng follow-up trên mảng ký tự sẽ yêu cầu 3-reverse
trick.Cách 1 — Pythonic split-reverse-join,
O(n).
return ' '.join(reversed(s.split())). split()
không tham số sẽ tự gộp nhiều space và bỏ space đầu/cuối — chính xác cái
ta cần.
Cách 2 — Three Reverses (in-place trên mảng ký tự). Áp dụng đúng ý tưởng của bài Rotate Array (1.6): 1. Reverse toàn bộ chuỗi. 2. Reverse từng “từ” trong chuỗi đã reverse. 3. Dọn space (chỉ giữ 1 space giữa từ, bỏ space đầu/cuối).
Hình minh hoạ với
s = "the sky is blue":
Input : "the sky is blue"
Bước 1: reverse toàn bộ
"eulb si yks eht"
Bước 2: reverse từng từ trong chuỗi đã đảo
"blue is sky the" ← đáp án ✓
So sánh với Rotate Array: Rotate Array reverse trên đơn vị phần tử, Reverse Words reverse trên đơn vị “từ” (substring giữa các space). Same idea, khác mức trừu tượng.
class Solution:
"""Cách 1 — Pythonic, ngắn gọn nhất."""
def reverseWords(self, s: str) -> str:
return ' '.join(reversed(s.split()))
class SolutionInPlace:
"""Cách 2 — Three reverses, in-place trên list ký tự."""
def reverseWords(self, s: str) -> str:
chars = list(s.strip()) # Python string immutable → phải chuyển list
# 1. Reverse toàn bộ.
self._reverse(chars, 0, len(chars) - 1)
# 2. Reverse từng từ.
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. Gộp nhiều space giữa các từ thành 1.
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 tạo chuỗi mới).O(n) time, O(n)
space cho chars (Python immutable string). Nếu input là
list[str] (như C/C++ char array), thì là O(1)
extra space..strip() → space đầu/cuối còn nguyên.start = i thay vì
i + 1 → ký tự bị tính 2 lần.char[], làm in-place O(1) space —
đúng bài cách 2 áp dụng.a–z (26)?
ASCII 128? Unicode? — Mảng đếm [26] chỉ dùng được khi đúng
26 chữ."Aa" có là
palindrome không? LC 125 lowercase trước; LC 5 không.isalnum(), hay đề bài đã đảm bảo sạch?strip() trước khi parse số.| Pattern | Khi gặp | Chương |
|---|---|---|
Counting (Counter, [26]) |
Anagram, frequency | 02, 06 |
| Two pointers (in/out) | Palindrome, reverse | 02, 26 |
| Sliding window | Substring với ràng buộc động | 27 |
| Parsing với stack/FSM | atoi, calculator, Decode | 08, 32 |
| Pattern matching | strStr, anagrams trong text | 35, 36, 34 |
| Hashing string | Rabin-Karp, Distinct substrings | 34 |
"eat" → "aet": code
2 dòng, O(n·k log k).(0,0,1,...,1,...):
O(n·k), nhanh hơn khi k lớn và bảng chữ cái
nhỏ.LC 8 (atoi) là FSM nhỏ (4 trạng thái: start, sign, digits, overflow). Khi đề bài phức tạp hơn (Valid Number, Calculator) → đọc Chương 32.
Recursion (đệ quy) là ngôn ngữ tự nhiên để mô tả bài toán có cấu trúc tự tương tự — giải bài lớn bằng cách kết hợp lời giải của vài bài con nhỏ hơn. Chương này dạy bạn cảm nhận 3 thành phần của một lời giải đệ quy: (i) base case (điều kiện dừng), (ii) recursive case (gọi vào bài con nhỏ hơn), (iii) kết hợp kết quả từ các bài con. Khi 3 cái này “click”, bạn sẽ thấy DP, Backtracking, Tree, Graph DFS đều là cùng một ngôn ngữ.
Sau chương này, bạn sẽ:
choose → explore → unchoose cho
backtracking.@cache.RecursionError.f(n) = ... f(n-1) ...
hoặc f(L,R) = ... f(L,M) + f(M+1,R) ....3 câu hỏi cần trả lời trước khi code đệ quy: 1. Trạng thái của hàm gồm những biến gì? (Cần đủ để định nghĩa “bài con”.) 2. Base case là gì? (Khi nào trả về luôn?) 3. Bước đệ quy chia bài lớn thành bài con thế nào, kết hợp kết quả ra sao?
from functools import cache
# 1) Đệ quy "thuần" (có thể chậm vì lặp lại bài con).
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: thêm cache để O(số trạng thái).
@cache
def f(*state):
if base_condition(*state):
return base_value
return combine(f(*sub1(*state)), f(*sub2(*state)))
# 3) Backtracking: liệt kê + 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 — đặc trưng của backtrackingTính số Fibonacci thứ n theo định nghĩa:
F(0) = 0, F(1) = 1, và
F(n) = F(n-1) + F(n-2) với n >= 2.
Input: n = 2 → 1
Input: n = 3 → 2
Input: n = 10 → 55
0 <= n <= 30 (LC); follow-up thường mở lên
n <= 10^6 hoặc n <= 10^18.n có thể lớn cỡ nào? → Quyết định cách chọn:
đệ quy thuần / DP / matrix expo.n lớn (10^18),
thường modulo 10^9+7.Cách 1 — Đệ quy thuần, O(2^n). Mỗi
F(n) gọi 2 lần đệ quy. Cây gọi có ~2^n node →
rất chậm.
Cách 2 — Memoization (top-down DP), O(n) time,
O(n) space. Cache lại kết quả → mỗi
F(k) tính đúng 1 lần.
Cách 3 — Iterative (bottom-up), O(n) time,
O(1) space. Chỉ cần 2 biến prev,
curr cuộn từ dưới lên.
Cách 4 — Matrix exponentiation,
O(log n). Khi n lên đến
10^18.
Hình minh hoạ — cây gọi đệ quy cho
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) bị tính LẠI nhiều lần
→ Nếu memo cache mỗi F(k), số subproblem chỉ là n+1 (n=5 → 6 calls).
→ Nếu không memo, số calls ~ Fibonacci(n+1) ~ φ^n (exponential).
from functools import cache
class Solution:
"""Cách 3 — iterative O(1) space, đáp án production."""
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:
"""Cách 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:
"""Cách 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 cho mọi case thường.O(n) time, O(n) space
(stack + cache).O(log n) time — khi n cực
lớn.n >= 40.n < 2 → vô hạn đệ quy.F(0) = F(1) = 1.Cài đặt hàm tính x^n với x là số thực và
n là số nguyên (có thể âm).
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 âm, kết quả là 1 / x^|n|? →
Đúng.x = 0 và n = 0? → Quy ước
0^0 = 1 (theo LC).Cách 1 — Nhân tay, O(n). Loop nhân
n lần. TLE khi n = 2^31.
Cách 2 — Fast Power (đệ quy / iterative),
O(log n).
Quan sát đệ quy: - Nếu n == 0: trả 1. - Nếu
n chẵn: x^n = (x^(n/2))^2. - Nếu
n lẻ: x^n = x · x^(n-1).
Cho phép n âm: gọi đệ quy với n = -n rồi
nghịch đảo.
Hình minh hoạ — cây gọi cho x^10:
x^10
│ chẵn → (x^5)^2
▼
x^5
│ lẻ → x · x^4
▼
x^4
│ chẵn → (x^2)^2
▼
x^2
│ chẵn → (x^1)^2
▼
x^1
│ lẻ → x · x^0
▼
x^0 = 1
Tổng số phép nhân: ~ 2 log₂(10) ≈ 8 (vs. 10 phép của brute force)
class Solution:
"""Đệ quy 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 — tránh chiều sâu đệ quy. Đọc bit từ thấp lên cao."""
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) — mỗi bước chia
đôi.O(log n) (call stack);
iterative O(1).n = -2^31 khi đảo dấu thành
2^31 sẽ overflow. Python int vô hạn nên an toàn, nhưng vẫn
nên ý thức.n // 2 (integer division) → tính sai khi
n lẻ.O(log n).x^n mod m — đổi
*= thành * % m.n cực
lớn, biểu diễn dạng mảng chữ số.Cho head của một danh sách liên kết đơn. Hãy đảo ngược
danh sách và trả về node đầu mới. (Bài này có 2 cách: iterative và đệ
quy. Ở chương này tập trung bản đệ quy; bản iterative
gặp lại ở Chương 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 <= số node <= 5000-5000 <= node.val <= 5000.next) không? → Có,
đó là yêu cầu chính.Ý tưởng đệ quy: - Base case: nếu
head là None hoặc head.next là
None → trả head. - Đệ quy:
gọi reverseList(head.next) để đảo phần đuôi, được node cuối
mới (chính là node cuối cũ → đầu mới sau khi đảo). - Kết
hợp: lúc này head.next vẫn trỏ tới node cũ ngay
sau head (chưa bị thay đổi vì đệ quy chỉ làm với phần
đuôi). Ta gán head.next.next = head và
head.next = None để khâu head vào
cuối danh sách đã đảo.
Hình minh hoạ với 1 → 2 → 3 → None:
Gọi reverseList(1):
reverseList(2):
reverseList(3):
base case → return 3 # 3 → None
# tại đây head=2, head.next=3
# phần đuôi đã đảo: 3 → None
# ta cần khâu 2 vào sau 3:
head.next.next = head # 3 → 2
head.next = None # 2 → None
# giờ chuỗi: 3 → 2 → None, đầu mới = 3
return 3
# tại đây head=1, head.next=2
# phần đuôi đã đảo: 3 → 2 → None
# khâu 1 vào sau 2:
head.next.next = head # 2 → 1
head.next = None # 1 → None
# giờ chuỗi: 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) — mỗi node được duyệt
đúng 1 lần.O(n) cho call stack (vì đệ quy
không tail-call optimized trong Python).head.next = None → vòng lặp
vô hạn (1 → 2 → 1 → 2 …).head thay vì
new_head → mất phần đuôi.RecursionError. Khi đó chuyển sang
iterative (xem code dưới):prev = None
while head:
nxt = head.next
head.next = prev
prev = head
head = nxt
return prev[left, right].Cho số nguyên n, sinh tất cả các chuỗi
dấu ngoặc đúng (well-formed) độ dài 2n.
Input: n = 3
Output: ["((()))", "(()())", "(())()", "()(())", "()()()"]
Input: n = 1
Output: ["()"]
1 <= n <= 8C(n) = (2n)! / (n!(n+1)!). Nhưng bài này yêu cầu
liệt kê.Ý tưởng: Sinh ký tự ( hoặc
) từng bước. Mỗi bước có 2 lựa chọn, nhưng phải đảm bảo
tính hợp lệ: - Số ( đã đặt không
vượt quá n. - Số ) đã đặt
không vượt quá số ( đã đặt (vì sẽ tạo
ngoặc không match).
Đệ quy với 2 counter: open_count,
close_count. Khi len(path) == 2n → đẩy vào kết
quả.
Hình minh hoạ — cây quyết định cho
n = 2:
""
/ \
( / \ ) ✗ (close > open)
"("
/ \
( / \ )
"((" "()"
│ │
) ▼ ( / \ ) ✗
"(()" "()("
│ │
) ▼ ) ▼
"(())" ★ "()()" ★
Đáp án: ["(())", "()()"]
(✗ = nhánh bị cắt vì không hợp lệ)
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) với
C(n) = số Catalan thứ n =
(2n)! / (n!(n+1)!). Mỗi chuỗi tốn O(n) để
build.O(n) cho call stack +
O(C(n) · n) cho output.) khi
close_cnt >= open_cnt → sinh ra chuỗi sai như
())).path.pop() sau đệ quy → state dirty cho nhánh
kế.Cho mảng nums các số phân biệt, trả về
tất cả hoán vị có thể của chúng.
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 phân biệt.Ý tưởng: Tại mỗi bước, chọn 1 số chưa
dùng đẩy vào path. Khi path đủ
n phần tử → 1 hoán vị hoàn chỉnh.
2 cách quản lý “đã dùng”: - Mảng
used: bool[n]. - Hoặc dùng set các index đã
dùng.
Hình minh hoạ — cây quyết định cho
[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]
Tổng: 3 · 2 · 1 = 6 hoán vị.
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:
"""Cách 2 — swap in-place, không cần mảng used."""
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!) — có
n! hoán vị, mỗi cái build trong O(n).O(n) cho call stack +
path (không tính output).path.copy() → mọi entry trong result
trỏ tới cùng list (bị thay đổi sau).used[i] = False khi undo → bỏ sót hoán vị.if i > 0 and nums[i] == nums[i-1] and not used[i-1]: continue.k mà không liệt kê tất cả.Cho mảng nums các số phân biệt. Trả về
tất cả tập con có thể của nums (bao gồm
tập rỗng và tập đầy đủ). Tổng cộng 2^n tập con.
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] <= 10Có 3 cách kinh điển, đều nên biết.
Cách 1 — Backtracking “chọn / không chọn”. Tại mỗi
index i, có 2 nhánh: thêm nums[i] vào
path hoặc bỏ qua.
Cách 2 — Backtracking “bắt đầu từ start”. Mỗi node
trong cây gọi result.append(path.copy()) (mọi prefix đều là
tập con hợp lệ), rồi loop for i in range(start, n).
Cách 3 — Bit Mask iteration. Mỗi số từ
0 đến 2^n - 1 đại diện cho 1 tập con: bit
i bật ↔︎ nums[i] trong tập. (Xem thêm Chương
21.)
Hình minh hoạ — cây “chọn / không chọn” cho
[1, 2, 3]:
[ ]
bỏ 1 / \ chọn 1
[ ] [1]
bỏ 2 / \ chọn 2 bỏ 2 / \ chọn 2
[ ] [2] [1] [1,2]
bỏ3/\ ... ... ... ...
[ ] [3]
Mỗi LÁ của cây = 1 tập con. Cây có 2^3 = 8 lá.
from typing import List
class Solution:
"""Cách 2 — gom mọi prefix là 1 tập con."""
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()) # mọi state đều là một subset
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return result
class SolutionBitmask:
"""Cách 3 — iterate qua 2^n bitmask."""
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:
"""Cách 1 — backtracking chọn / không chọn."""
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
# nhánh không chọn nums[i]
backtrack(i + 1)
# nhánh chọn nums[i]
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return resultO(n · 2^n) — có
2^n tập con, mỗi cái copy trong O(n).O(n) cho call stack (không
tính output).path.copy() trong result.append(path)
— tất cả entry sẽ là tham chiếu tới cùng list.result.append(path.copy())
trước khi loop (mọi prefix là tập con); nếu để sau loop
sẽ thiếu các tập con “lá”.if i > start and nums[i] == nums[i-1]: continue.len(path) == k).| Thuộc tính | Recursion | DFS | Backtracking | Top-down DP |
|---|---|---|---|---|
| Mục tiêu | Giải bài con tự gọi lại | Duyệt graph/tree | Liệt kê tất cả lời giải | Giải tối ưu / đếm |
| Có undo state? | Không bắt buộc | Hiếm | Bắt buộc (choose/unchoose) | Không |
| Có memo? | Có thể có | Hiếm | Hiếm (state phụ thuộc đường đi) | Bắt buộc |
| Ví dụ | 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; cây/list dài >1000 →
sys.setrecursionlimit(10**6) và tăng stack
(threading.stack_size).def f(n): return f(n-1) vẫn stack overflow.Sort tự nó là bài toán đã giải. Chương này không dạy bạn cài đặt quicksort — Python đã có
sorted()rất tốt (Timsort,O(n log n)worst, stable). Cái cần học là: khi nào sort là tiền đề giải bài, và bí mật nằm ở hàm so sánh (comparator) tuỳ biến và việc duyệt mảng đã sort bằng pattern two pointers / sweep line.
Sau chương này, bạn sẽ:
cmp_to_key).O(n log n) chỉ là chi
phí mở màn).3 câu hỏi vàng: 1. Sort theo khoá
nào? start, end, length, freq, ratio? 2. Sort xong
duyệt thế nào? one-pass / two pointers / sweep line / heap? 3.
Cần stable không? Python sorted mặc định
là stable — đây là tài sản quý.
from functools import cmp_to_key
from typing import List
# 1) Sort theo khoá đơn giản
nums.sort(key=lambda x: x[0])
# 2) Sort theo nhiều khoá (tie-breaker)
nums.sort(key=lambda x: (x[0], -x[1])) # x[0] tăng, x[1] giảm
# 3) Sort theo comparator tuỳ biến
def cmp(a, b) -> int:
if a + b > b + a: return -1 # a đứng trước
if a + b < b + a: return 1 # b đứng trước
return 0
arr.sort(key=cmp_to_key(cmp))
# 4) Sweep line trên mảng các sự kiện
events = [(start, +1), (end, -1)]
events.sort()Cho mảng nums chỉ chứa các giá trị 0,
1, 2 (đại diện cho 3 màu). Hãy sắp xếp
nums sao cho cùng màu đứng cạnh nhau theo thứ tự
0 → 1 → 2. Phải làm in-place, không được dùng hàm
sort của ngôn ngữ.
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} không? → Theo
đề: không.Cách 1 — Counting sort 2 lượt, O(n).
Đếm số lượng 0, 1, 2 rồi ghi đè lại. Đơn giản nhưng đi 2 lượt.
Cách 2 — Dutch National Flag (Edsger Dijkstra), 1 lượt,
O(n).
Giữ 3 con trỏ: - lo = ranh giới phải của vùng
0s (mọi phần tử ở [0..lo-1] là
0). - hi = ranh giới trái của vùng
2s (mọi phần tử ở [hi+1..n-1] là
2). - mid = con trỏ duyệt giữa hai vùng.
Bất biến: [0..lo-1] = 0, [lo..mid-1] = 1,
[mid..hi] chưa xử lý, [hi+1..n-1] = 2.
Tại mỗi bước: - nums[mid] == 0 → swap
với nums[lo], lo++, mid++. -
nums[mid] == 1 → đã đúng vùng, mid++. -
nums[mid] == 2 → swap với nums[hi],
hi-- (mid không tăng vì giá trị mới từ
hi xuống chưa được xử lý).
Hình minh hoạ với
nums = [2, 0, 2, 1, 1, 0]:
lo mid hi
Khởi tạo : [ 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 → dừng
Kết quả : [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
# KHÔNG tăng mid — giá trị từ hi chưa biết là gìO(n) — mỗi vòng tăng
mid hoặc giảm hi ít nhất 1 lần.O(1).mid khi vừa swap với hi — sẽ bỏ qua
phần tử vừa swap về.mid < hi thay vì
mid <= hi — bỏ sót xử lý ô cuối.O(n log n) về trung bình, nhưng nếu có nhiều phần tử
trùng, 3-way partition tránh được trường hợp O(n²)
thoái hoá.k màu (k > 3) thì sao?” → Counting
sort, O(n + k).Cho mảng các khoảng intervals[i] = [start_i, end_i]. Hãy
gộp tất cả các khoảng giao nhau lại thành các khoảng
không giao nhau, và trả về kết quả.
Input: intervals = [[1,3], [2,6], [8,10], [15,18]]
Output: [[1,6], [8,10], [15,18]]
Giải thích: [1,3] và [2,6] giao nhau → gộp thành [1,6].
Input: intervals = [[1,4], [4,5]]
Output: [[1,5]]
Giải thích: [1,4] và [4,5] coi là giao (chia sẻ điểm 4).
1 <= len(intervals) <= 10^4intervals[i].length == 20 <= start_i <= end_i <= 10^4[1,4] và [4,5]).Brute force. Lặp đi lặp lại tìm cặp giao nhau và
gộp. O(n²) hoặc tệ hơn.
Tối ưu — Sort + 1 lượt —
O(n log n).
Sort theo start tăng dần. Sau đó duyệt, giữ
last = khoảng cuối cùng đã thêm vào kết quả. Với khoảng
cur tiếp theo: - Nếu cur.start <= last.end
→ giao nhau, mở rộng last.end = max(last.end, cur.end). -
Nếu không → push cur thành khoảng mới.
Hình minh hoạ với
[[1,3], [2,6], [8,10], [15,18]]:
Trục số:
1 3 5 7 9 11 13 15 17 19
| | | | | | | | | |
├───┤ [1,3]
├──────────┤ [2,6]
├───┤ [8,10]
├───┤ [15,18]
Sau khi sort theo start: [[1,3], [2,6], [8,10], [15,18]]
Duyệt:
Push [1,3] result = [[1,3]]
cur=[2,6], 2 <= 3 → mở rộng [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]]
Kết quả: [[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 để khỏi share reference
return resultO(n log n) — chi phí chính
là sort.O(n) cho output (hoặc
O(log n) cho stack sort).< thay vì <= khi check overlap
→ bỏ sót case “chạm tại điểm”.cur mà
result.append(cur) trực tiếp → khi sửa
result[-1][1] ở vòng sau, có thể vô tình sửa luôn phần tử
trong input.Cho mảng số nguyên không âm nums. Hãy ghép chúng (theo
thứ tự nào đó) lại thành một chuỗi sao cho số tạo ra là lớn
nhất. Trả về kết quả dưới dạng chuỗi (vì số có thể rất
lớn).
Input: nums = [10, 2]
Output: "210"
Input: nums = [3, 30, 34, 5, 9]
Output: "9534330"
Input: nums = [0, 0]
Output: "0" (không phải "00")
1 <= len(nums) <= 1000 <= nums[i] <= 10^90, trả "0" hay
"000...0"? → "0".0 ở đầu hợp lệ không? → Không cho phép (trừ
kết quả “0”).Brute force — thử mọi hoán vị
O(n! · n). TLE khi n đủ lớn.
Tối ưu — Sort với comparator tuỳ biến —
O(n log n · L) với L = độ dài tối
đa.
Insight: Để quyết định a đứng trước
b hay sau, chỉ cần so sánh 2 cách ghép:
str(a) + str(b) vs str(b) + str(a) — chuỗi nào
lớn hơn thì cách đó “tốt hơn”.
Tại sao đúng? Quan hệ “ghép nào lớn hơn” có tính bắc cầu — chứng minh chặt qua trường hợp Lexicographic của các chuỗi ghép, đảm bảo tồn tại thứ tự sort hợp lệ.
Ví dụ: a = 3, b = 30 → "330"
> "303" → 3 đứng trước 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 đứng trước
if a + b < b + a: return 1 # b đứng trước
return 0
strs.sort(key=cmp_to_key(cmp))
result = ''.join(strs)
# edge case: [0, 0, 0] → tránh ra "000"
return '0' if result[0] == '0' else resultO(n log n · L) — mỗi phép
so sánh O(L), có O(n log n) lần so sánh.O(n · L) cho list chuỗi."000" thay vì "0".-1 / 1) — luôn
test với example nhỏ.cmp_to_key?
Python 3 bỏ tham số cmp= trong sort() — chỉ
còn key=. Khi cần comparator tuỳ biến, phải đưa qua
functools.cmp_to_key() để chuyển thành “key function”.strs.sort(key=lambda s: s * 10, reverse=True) (vì max độ
dài ~ 10).Cho mảng các khoảng intervals[i] = [start_i, end_i] đại
diện cho các cuộc họp. Tìm số phòng tối thiểu cần thiết
để chứa tất cả.
(Tức là, tại mọi thời điểm, có tối đa mấy cuộc họp đang diễn ra đồng thời?)
Input: intervals = [[0,30], [5,10], [15,20]]
Output: 2
Giải thích: tại t=5, [0,30] và [5,10] cùng diễn ra → cần 2 phòng.
Input: intervals = [[7,10], [2,4]]
Output: 1
Giải thích: 2 cuộc họp không overlap, 1 phòng dùng được cả 2.
1 <= len(intervals) <= 10^40 <= start_i < end_i <= 10^6[1,4] và
[4,5]) có overlap không? → Theo quy ước LC:
không overlap (vì end là exclusive, hoặc end == start
được hiểu là “ngay sau khi xong là họp mới”).Có 3 cách hay, đều xứng đáng biết:
Cách 1 — Heap (priority queue) —
O(n log n).
Sort theo start. Duyệt từng cuộc họp, dùng min-heap chứa
end_time của các cuộc đang diễn ra. Khi cuộc mới đến
(start): - Nếu heap top có end <= start →
cuộc cũ đã xong → pop ra (tái dùng phòng). - Push end của
cuộc mới.
Kích thước heap tại mỗi thời điểm = số phòng đang dùng → max của size là đáp án.
Cách 2 — Sweep line / Chronological —
O(n log n).
Tạo 2 mảng: starts (đã sort) và ends (đã
sort). Duyệt 2 con trỏ: nếu starts[i] < ends[j] → cuộc
mới bắt đầu trước cuộc cũ kết thúc → cần thêm phòng
(rooms++, i++); ngược lại → giải phóng
(i++ thì sao đây — sai). Đúng ra: i++ khi
starts[i] < ends[j] và j++ khi
starts[i] >= ends[j].
Cách 3 — Event-driven, O(n log n).
Mỗi cuộc tạo 2 sự kiện: (start, +1) và
(end, -1). Sort tất cả sự kiện (ưu tiên -1
trước +1 nếu cùng time → ưu tiên đóng phòng). Duyệt và giữ
cur/peak.
Hình minh hoạ với
[[0,30], [5,10], [15,20]] — heap-based:
Sort by start: [[0,30], [5,10], [15,20]]
Bước 1: cuộc [0,30] heap = [30] → rooms = 1
Bước 2: cuộc [5,10] top=30 > 5 → giữ; push 10 heap = [10, 30] → rooms = 2 ★
Bước 3: cuộc [15,20] top=10 <= 15 → pop 10; push 20 heap = [20, 30] → rooms = 2
Trục số:
0 5 10 15 20 25 30
| | | | | | |
├───────────────────────┤ [0, 30] dùng phòng A
├───┤ [5, 10] dùng phòng B
├───┤ [15, 20] tái dùng phòng 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:
"""Cách event-driven — clean cho follow-up."""
def minMeetingRooms(self, intervals: List[List[int]]) -> int:
events = []
for s, e in intervals:
events.append((s, +1))
events.append((e, -1))
# Khi cùng time: ưu tiên -1 trước +1 (cuộc kết thúc thì phòng giải phóng trước cuộc mới)
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) xử lý sai thứ tự.heap[0] <= start (dấu <=)
— nếu dùng < thì 2 cuộc kề nhau bị tính overlap.Cho hai chuỗi order (toàn các ký tự phân
biệt) và s. Hãy sắp xếp lại s sao cho
thứ tự các ký tự xuất hiện trong order được tôn trọng. Các
ký tự không nằm trong order có thể đặt ở bất cứ đâu trong
kết quả.
Input: order = "cba", s = "abcd"
Output: "cbad"
Giải thích: trong order, c < b < a. Các ký tự d không xuất hiện trong order
nên đặt đâu cũng được.
Input: order = "bcafg", s = "abcd"
Output: "bcad"
1 <= len(order) <= 26, các ký tự trong
order phân biệt.1 <= len(s) <= 200order đặt ở đâu? → Đâu cũng
được. Mình quy ước đẩy về cuối cho gọn.Cách 1 — Sort với comparator theo bảng index,
O(|s| log |s|).
Tạo dict
priority = {ch: i for i, ch in enumerate(order)}. Mỗi ký tự
không có trong order cho priority lớn vô cùng (ví dụ
26). Rồi sort s theo priority này.
Cách 2 — Counter + emit theo order,
O(|s|).
Đếm Counter(s), sau đó duyệt qua từng ký tự trong
order và “in ra” đúng số lần. Cuối cùng nối thêm các ký tự
còn lại (không trong order).
Cách 2 không cần sort, nhanh hơn, và rất tự nhiên — interviewer thường mong đợi cách này.
from collections import Counter
class Solution:
"""Cách 2 — Counter + emit theo order."""
def customSortString(self, order: str, s: str) -> str:
cnt = Counter(s)
parts: list[str] = []
# 1. Phần các ký tự thuộc order, theo đúng thứ tự order.
for ch in order:
if ch in cnt:
parts.append(ch * cnt.pop(ch))
# 2. Các ký tự còn lại (không trong order) — thứ tự không quan trọng.
for ch, c in cnt.items():
parts.append(ch * c)
return ''.join(parts)
class SolutionSort:
"""Cách 1 — comparator theo bảng index."""
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)))| Cách | Time | Space |
|---|---|---|
| Counter | O(|s| + |order|) |
O(1) |
| Sort key | O(|s| log |s|) |
O(|s|) |
priority[ch] thay vì
priority.get(ch, 26) → KeyError với ký tự không trong
order.cnt[ch] không pop → khi duyệt phần
còn lại sẽ in trùng. Nhớ dùng cnt.pop(ch).order có thể chứa duplicate?” → Đề loại trừ,
nhưng nếu có thì lấy vị trí đầu tiên.s rất dài (10^9 ký tự)?” → Counter vẫn
O(|s|), nhưng phải streaming.Cho mảng nums. Sắp xếp lại để thoả mãn:
nums[0] <= nums[1] >= nums[2] <= nums[3] >= nums[4] <= ...
Tức là vị trí lẻ luôn >= vị trí kề bên trái và
phải.
Input: nums = [3, 5, 2, 1, 6, 4]
Output: [3, 5, 1, 6, 2, 4] (một trong nhiều đáp án hợp lệ)
Input: nums = [6, 6, 5, 6, 3, 8]
Output: [6, 6, 5, 6, 3, 8] (đã thoả mãn)
1 <= len(nums) <= 5·10^40 <= nums[i] <= 10^4<=
và >= (không strict) giúp xử lý duplicate tự nhiên.Cách 1 — Sort rồi swap cặp, O(n log n).
Sort tăng dần, sau đó với mỗi cặp (i, i+1) với
i lẻ → swap. Bài giải đúng nhưng không tối ưu.
Cách 2 — Greedy 1 lượt, O(n).
Quan sát điều kiện: tại mỗi vị trí i, - Nếu
i lẻ (1, 3, 5, …): nums[i] >= nums[i-1]. -
Nếu i chẵn (2, 4, 6, …):
nums[i] <= nums[i-1].
Duyệt từ i = 1, nếu vi phạm thì swap
nums[i] với nums[i-1]. Tại sao swap đảm bảo
không phá quan hệ trước đó? Vì ta chỉ sửa phần tử ở vị trí
i-1 (làm nó nhỏ hơn hoặc lớn hơn), và quan hệ giữa
nums[i-2] và nums[i-1] ở bước trước đã đảm bảo
“biên” phù hợp.
Hình minh hoạ với
nums = [3, 5, 2, 1, 6, 4]:
Index : 0 1 2 3 4 5
Input : [3, 5, 2, 1, 6, 4]
lẻ chẵn lẻ chẵn lẻ
≥ ≤ ≥ ≤ ≥
i=1 (lẻ): cần nums[1] >= nums[0] 5 >= 3 ✓
i=2 (chẵn):cần nums[2] <= nums[1] 2 <= 5 ✓
i=3 (lẻ): cần nums[3] >= nums[2] 1 >= 2 ✗ → swap
[3, 5, 1, 2, 6, 4]
i=4 (chẵn):cần nums[4] <= nums[3] 6 <= 2 ✗ → swap
[3, 5, 1, 6, 2, 4]
i=5 (lẻ): cần nums[5] >= nums[4] 4 >= 2 ✓
Kết quả : [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) — đúng 1 lượt.O(1).nums[i] (swap nếu cần), thuộc tính của các vị trí từ
0 đến i được bảo toàn. Trong lúc swap, chỉ
nums[i-1] bị thay đổi — nhưng nó chỉ ảnh hưởng đến cặp
(i-2, i-1), mà cặp đó được thiết kế để vẫn còn
đúng sau swap (vì nếu nums[i] < nums[i-1] mà
cần nums[i] >= nums[i-1], thì hoán đổi cho
nums[i-1] nhỏ hơn → vẫn thoả
nums[i-1] <= nums[i-2] ở bước trước).< / > strict thay vì
<= / >= — sẽ sai khi có duplicate.< và >) → khó hơn nhiều, phải sort rồi
đan xen 2 nửa.Mua: - Đưa thứ tự về monotonic → cho phép two-pointer, binary search, sweep. - Hợp nhóm các phần tử “giống nhau” cạnh nhau (anagram, intervals).
Mất: - Mất index gốc → nếu output
yêu cầu index, lưu (value, idx) trước. - Mutate input — làm
rõ với interviewer trước khi sort. - O(n log n), không miễn phí.
(a+b) vs (b+a) là bắc cầu
(transitive) — chứng minh được nên an toàn dùng
cmp_to_key.cmp mặc định nữa. Dùng
from functools import cmp_to_key."00...0" → strip leading zeros sau khi nối.| Heap | Sweep line | |
|---|---|---|
| Tư duy | Phòng nào trống sớm nhất → tái dùng | Đếm overlap tại mỗi mốc thời gian |
| Code | heapq + sort theo start |
Sort events (time, ±1) |
| Output yêu cầu | Số phòng max | Số phòng max |
| Khi mở rộng | Dễ trả về schedule (phòng nào lúc nào) | Khó trả về schedule |
nums[0] ≤ nums[1] ≥ nums[2] ≤ ... — chỉ cần swap láng giềng
sai → O(n).nums[0] < nums[1] > nums[2] < ... —
chặt, cần sort + interleave → O(n log n) hoặc median
trick.Binary Search (tìm kiếm nhị phân) tưởng dễ — “chia đôi mảng đã sort” — nhưng thực tế là bug magnet số một trong phỏng vấn. Knuth từng viết: “trong khi ý tưởng đơn giản, viết đúng nó là chuyện khó hơn ta tưởng”. Chương này dạy bạn một template duy nhất áp dụng cho mọi biến thể: tìm equal, tìm boundary, tìm trên rotated, search on answer, … — sẽ gặp lại ở Chương 25 (Advanced Binary Search).
Sau chương này, bạn sẽ:
[lo, hi)).O(log n) hoặc gợi ý dùng search.[lo, hi] và bài chia được thành 2 nửa “có đáp án” / “không
có đáp án”.Mẫu suy nghĩ chuẩn: 1. Không gian tìm kiếm
là gì? (index, giá trị, đáp án). 2. Hàm
check(mid) trả True/False thế nào?
Liệu nó đơn điệu không? 3. Đáp án là ranh giới
nào? First True hay last False?
def lower_bound(nums: list[int], target: int) -> int:
"""Trả về vị trí đầu tiên có nums[i] >= target. Nếu không có, trả về len(nums)."""
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:
"""Trả về vị trí đầu tiên có 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:
"""Tìm giá trị nhỏ nhất trong [lo, hi] thoả check(x)=True (check đơn điệu F→T)."""
while lo < hi:
mid = (lo + hi) // 2
if check(mid):
hi = mid
else:
lo = mid + 1
return loMẹo cuối cùng: mình luôn dùng nửa khoảng đóng-mở
[lo, hi)và điều kiện vòng lặplo < hi. Cách này đồng bộ vớibisectcủa Python và ít bug off-by-one hơn cáchlo <= hi.
Cho mảng nums đã sort tăng dần và số
target. Trả về chỉ số của
target trong nums, hoặc -1 nếu
không có. Phải chạy 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] phân biệt và đã sort tăng dần.Brute force — O(n). Duyệt tuần tự —
không thoả O(log n).
Tối ưu — Binary Search half-open,
O(log n).
Duy trì [lo, hi). Tại mỗi vòng: -
mid = (lo + hi) // 2. - Nếu
nums[mid] == target → trả mid. - Nếu
nums[mid] < target → đáp án (nếu có) ở
[mid+1, hi) → lo = mid + 1. - Nếu
nums[mid] > target → đáp án ở [lo, mid) →
hi = mid.
Hình minh hoạ với
nums = [-1, 0, 3, 5, 9, 12], target = 9:
index : 0 1 2 3 4 5
nums : [ -1, 0, 3, 5, 9, 12 ]
Vòng 1: lo=0, hi=6 → mid=3, nums[3]=5 < 9 → lo = mid+1 = 4
Vòng 2: lo=4, hi=6 → mid=5, nums[5]=12 > 9 → hi = mid = 5
Vòng 3: lo=4, hi=5 → mid=4, nums[4]=9 == 9 → trả 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 int
không bị, nhưng C/C++/Java dùng lo + (hi - lo) // 2 để an
toàn.[lo, hi] (đóng-đóng)
và [lo, hi) (đóng-mở) — chọn 1 cách, dùng kiên định cho mọi
bài.hi luôn
không bao gồm — cùng convention với range() và
bisect của Python.Cho mảng nums đã sort, không có duplicate, và
target. Trả về: - Chỉ số của target nếu có
trong mảng. - Vị trí mà target sẽ được chèn vào nếu không
có (giữ mảng vẫn sort).
Phải 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 (chèn cuối)
Input: nums = [1, 3, 5, 6], target = 0 → 0 (chèn đầu)
1 <= len(nums) <= 10^4-10^4 <= nums[i], target <= 10^4nums sort tăng dần, không duplicate.lower_bound.Đây chính là lower_bound! Vị trí đầu
tiên có nums[i] >= target — ý nghĩa: “đây là vị trí
target sẽ đứng nếu được chèn”.
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. Bài 5.1 trả -1 nếu không thấy,
bài này luôn trả về vị trí — đó là khác biệt duy nhất.return bisect.bisect_left(nums, target). Khi phỏng vấn, hãy
code “tay” template trước, rồi mới đề cập bisect.target nhỏ hơn tất cả → trả 0.target lớn hơn tất cả → trả
len(nums).Cho n phiên bản, đánh số từ 1 đến
n. Có một phiên bản “bad” và mọi phiên bản sau nó
cũng bad. Bạn được cấp hàm API isBadVersion(int)
trả True/False. Hãy tìm phiên bản bad đầu
tiên với số lần gọi API tối thiểu.
n = 5, bad version = 4
gọi isBadVersion(3) → False
gọi isBadVersion(5) → True
gọi isBadVersion(4) → True
→ trả 4
1 <= bad <= n <= 2^31 - 1O(log n) lần.[False, ..., False, True, ..., True] là
đơn điệu → áp được binary search.Đây là search on monotonic predicate. Pattern hoàn
hảo cho template binary_search_answer:
check(v) = isBadVersion(v) — đơn điệu
False → True.check thành
True.Hình minh hoạ với n = 7, bad = 4:
version : 1 2 3 4 5 6 7
check : F F F T T T T
↑
cần tìm vị trí này
Vòng 1: lo=1, hi=7 → mid=4, check(4)=T → hi=4
Vòng 2: lo=1, hi=4 → mid=2, check(2)=F → lo=3
Vòng 3: lo=3, hi=4 → mid=3, check(3)=F → lo=4
lo == hi → trả lo = 4 ✓
def isBadVersion(v: int) -> bool: ... # API có sẵn
class Solution:
def firstBadVersion(self, n: int) -> int:
lo, hi = 1, n # closed interval [lo, hi]
while lo < hi:
mid = lo + (hi - lo) // 2 # tránh overflow ở các ngôn ngữ khác
if isBadVersion(mid):
hi = mid
else:
lo = mid + 1
return loO(log n) lần gọi API.O(1).lo + (hi - lo) // 2?
Trong Java/C++, lo + hi có thể vượt INT_MAX.
Python int vô hạn nên không vấn đề, nhưng đây là good habit vì
bạn có thể phỏng vấn ngôn ngữ khác. Cách viết này dễ trở thành thói
quen.hi = n + 1 (như half-open) — sẽ gọi
isBadVersion(n + 1) → out of range. Phải dùng closed
[1, n].True (bad từ version
1) hoặc toàn bộ False (đề bài đảm bảo không xảy
ra, nhưng vẫn nên check).check đơn điệu — kỹ thuật cốt lõi
của Chương 25.Cho mảng nums đã sort tăng dần (có thể có duplicate) và
target. Trả về [first, last] — chỉ số đầu và
cuối của các vị trí target trong mảng. Nếu không có, trả
[-1, -1]. Phải 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].Dùng 2 lần binary search: - first =
lower_bound(target) — vị trí đầu tiên
>= target. - last =
upper_bound(target) - 1 — vị trí cuối cùng
<= target.
Sau đó kiểm tra xem first có hợp lệ và
nums[first] == target không.
Hình minh hoạ với
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 (vị trí đầu tiên >= 8)
upper_bound(8) = 5 (vị trí đầu tiên > 8)
last = 4 (= upper - 1)
Trả [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) — 2 lần binary
search độc lập.O(1).upper_bound(t) == lower_bound(t + 1) với mảng số nguyên.
Mình tận dụng để chỉ phải viết 1 hàm lower_bound.first == len(nums) → IndexError khi target
lớn hơn mọi phần tử.nums[first] == target → trả về
[first, first - 1] sai khi target không tồn tại.last - first + 1 (nếu first hợp lệ).Cho mảng nums đã sort tăng dần và các giá trị
phân biệt, sau đó bị xoay tại một pivot bí ẩn
(k lần xoay phải, k chưa biết). Cho
target, trả về index hoặc -1. Phải
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 đã được xoay tại một pivot không biết
trước.Quan sát then chốt: Khi chia mảng bị rotated tại
mid, luôn có ít nhất một nửa
([lo, mid] hoặc [mid, hi]) là sorted
đúng (không bị rotate).
Quy trình mỗi vòng: 1. Tính mid. 2. Nếu
nums[mid] == target → trả mid. 3. Xác định nửa
nào đang sorted: - Nếu nums[lo] <= nums[mid] → nửa trái
sorted. - Ngược lại → nửa phải sorted. 4. Kiểm tra target
có nằm trong nửa sorted không (so sánh bằng <,
>): - Có → search nửa sorted. - Không → search nửa còn
lại.
Hình minh hoạ với
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
Vòng 1: lo=0, hi=6, mid=3, nums[mid]=7
nums[lo]=4 <= nums[mid]=7 → nửa trái [0..3] = [4,5,6,7] đã sort.
target=0 nằm trong [4..7]? Không (0 < 4) → đi nửa phải.
→ lo = mid + 1 = 4
Vòng 2: lo=4, hi=6, mid=5, nums[mid]=1
nums[lo]=0 <= nums[mid]=1 → nửa trái [4..5] = [0,1] đã sort.
target=0 nằm trong [0..1]? Có → đi nửa trái.
→ hi = mid - 1 = 4
Vòng 3: lo=4, hi=4, mid=4, nums[mid]=0 == target → trả 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
# Nửa trái [lo..mid] đã sort?
if nums[lo] <= nums[mid]:
if nums[lo] <= target < nums[mid]:
hi = mid - 1 # target ở nửa trái
else:
lo = mid + 1
# Ngược lại: nửa phải [mid..hi] đã sort
else:
if nums[mid] < target <= nums[hi]:
lo = mid + 1 # target ở nửa phải
else:
hi = mid - 1
return -1O(log n).O(1).<= /
< ở các điều kiện biên — luôn chạy thử bằng ví dụ nhỏ để
kiểm chứng.nums[lo] == nums[mid] (rotated
nhưng 2 phần tử kề bằng). Với mảng phân biệt thì OK vì điều kiện
<= đảm bảo “nửa trái sort”.nums[lo] == nums[mid] == nums[hi] ta
không phân biệt được nửa nào sort → fallback
lo += 1, hi -= 1 (worst O(n)).Cho số nguyên không âm x. Trả về căn bậc hai
làm tròn xuống của x, tức là số nguyên
r lớn nhất sao cho r * r <= x.
Không được dùng hàm built-in sqrt.
Input: x = 4 → 2
Input: x = 8 → 2 (vì 2² = 4 ≤ 8 < 9 = 3²)
Input: x = 0 → 0
Input: x = 1 → 1
0 <= x <= 2^31 - 1pow? → Theo tinh thần đề:
không. Bài muốn test binary search hoặc Newton’s method.Cách 1 — Binary Search trên đáp án —
O(log x).
Tìm số nguyên lớn nhất r thoả r² <= x.
Tương đương với “last True” trong dãy đơn điệu
[T, T, ..., T, F, F, ...] (T =
r² <= x).
Khoảng tìm kiếm: [0, x] (hoặc [0, x//2 + 1]
để tiết kiệm).
Cách 2 — Newton’s Method — O(log x) nhưng hằng
số nhỏ hơn.
Lặp r = (r + x/r) / 2 cho đến khi
r² <= x < (r+1)². Hội tụ rất nhanh (quadratic
convergence) — đây là cách sqrt được cài trong nhiều thư
viện chuẩn.
Hình minh hoạ — Binary Search cho
x = 8:
r : 0 1 2 3 4 5 6 7 8
r² : 0 1 4 9 16 25 36 49 64
↑
last r với r² <= 8
Vòng 1: lo=0, hi=8 mid=4, 16 > 8 → hi = 3
Vòng 2: lo=0, hi=3 mid=1, 1 <= 8 → answer=1, lo=2
Vòng 3: lo=2, hi=3 mid=2, 4 <= 8 → answer=2, lo=3
Vòng 4: lo=3, hi=3 mid=3, 9 > 8 → hi=2 → lo > hi, dừng
Trả 2 ✓
class Solution:
"""Cách 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:
"""Cách 2 — Newton's Method, hằng số nhỏ hơn."""
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,
nhưng số bước thực tế rất ít (quadratic convergence, ~ 5 bước cho
x = 10^9).lo = 0 ban đầu nhưng quên xử lý x = 0 →
vòng lặp 0 * 0 == 0 <= 0 có thể trả 0,
nhưng tốt nhất check riêng x < 2.mid * mid overflow ở các ngôn ngữ 32-bit (Python an
toàn) — cần (long long)mid * mid hoặc so sánh
mid <= x // mid.r và x / r. Đáp số thật r* ở giữa
hai số này → trung bình tiến gần hơn r*. Sự hội tụ là bậc 2
(số đúng nhân đôi mỗi vòng).epsilon →
vẫn dùng binary search trên [0, x] với float, dừng khi
hi - lo < eps.k — LC 50 (Pow) ngược lại bằng
Newton tổng quát.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 không inclusive
while lo < hi:
mid = (lo + hi) // 2
if pred(mid): hi = mid
else: lo = mid + 1
return lo # vị trí đầu tiên thỏa
pred(mid) là
True ở [lo..hi) cuối cùng; vị trí trả về là
vị trí True đầu tiên hoặc n nếu không
có.lo, hi khởi tạo đúng (đặc biệt khi search
on answer: lo = min, hi = max hoặc
max+1).<= hay
<).mid+1 / mid-1 / mid
đúng — tránh infinite loop.-1, n, lo?(lo + hi) // 2 an toàn ở Python; ở Java/C++
dùng lo + (hi - lo) // 2.Python int không tràn. Java/C++: mid * mid có thể tràn
int32. Dùng (long) mid * mid
hoặc so sánh mid <= x / mid để tránh
nhân.
Hash Table (bảng băm) là “vũ khí thần kỳ” của phỏng vấn coding: nó biến nhiều bài
O(n²)thànhO(n). Triết lý: đổi bộ nhớ lấy thời gian — chấp nhận thêmO(n)bộ nhớ phụ để có look-upO(1). Chương này dạy bạn nhận diện khi nào nên và khi nào không nên dùng hash, cùng 6 bài kinh điển rất hay gặp ở Big Tech.
Sau chương này, bạn sẽ:
O(n) lookup thành
O(1).prefix → check complement (Two Sum, Subarray
Sum K).O(n) search bên trong loop thành
O(1) membership test.Khi không nên dùng hash: - Cần thứ tự sort
→ dùng SortedSet/TreeMap (Python:
sortedcontainers). - Cần O(1) worst-case
(không phải amortized) → hash bị adversary tấn công collision. - Khoá là
loại phức tạp (list, dict) → phải convert sang
tuple/frozenset.
from collections import Counter, defaultdict
from typing import List
# 1) Counter: đếm tần suất
cnt = Counter(nums) # {value: count}
top3 = cnt.most_common(3) # 3 phần tử thường gặp nhất
# 2) defaultdict(list): nhóm theo khoá
groups: dict[str, list[int]] = defaultdict(list)
for i, v in enumerate(arr):
groups[v].append(i)
# 3) Prefix sum + dict: tìm subarray
prefix_index = {0: -1} # prefix_sum -> index sớm nhất
cur = 0
for i, x in enumerate(arr):
cur += x
if cur - target in prefix_index:
# tìm thấy subarray có sum = target
...
if cur not in prefix_index:
prefix_index[cur] = i0/1 đếm prefix
sum)Cho mảng nums. Trả về True nếu có
ít nhất 1 phần tử xuất hiện ≥ 2 lần, ngược lại
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 tại chỗ rồi check.Brute force — O(n²). So sánh mọi
cặp.
Sort — O(n log n), O(1) extra
space. Sort rồi check 2 phần tử kề.
Hash set — O(n) time, O(n) space —
đáp án phổ biến nhất.
One-liner Pythonic:
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) trung bình.O(n).len(set(nums)) != len(nums)? Tốt cho 1 dòng, nhưng
không early-exit — vẫn duyệt hết mảng. Cách loop hỗ trợ
break sớm.k (sliding window + hash).int nên OK.Cho mảng nums không sort. Trả về độ dài của dãy số
liên tiếp (consecutive integers, có thể không đứng cạnh
trong mảng) dài nhất. Phải chạy O(n).
Input: nums = [100, 4, 200, 1, 3, 2]
Output: 4
Giải thích: dãy [1, 2, 3, 4] dài 4.
Input: nums = [0, 3, 7, 2, 5, 8, 4, 6, 0, 1]
Output: 9
Giải thích: dãy [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 O(n log n) không thoả.Brute force — O(n³). Với mỗi phần tử,
đếm số x, x+1, x+2, ... có trong mảng.
Sort — O(n log n). Sort, đếm dãy liên
tiếp. Đơn giản nhưng không thoả O(n).
Tối ưu — Hash Set + “chỉ bắt đầu từ điểm đầu dãy” —
O(n).
Insight then chốt: Một số x là
điểm bắt đầu dãy ↔︎ x - 1 không có trong
mảng. Vậy chỉ với những x thoả điều kiện này, ta mới đếm
dãy x, x+1, x+2, ... bằng cách lookup hash. Mỗi phần tử bị
“đếm tới” tối đa 1 lần trên toàn quá trình → tổng O(n).
Hình minh hoạ với
nums = [100, 4, 200, 1, 3, 2]:
Set: {100, 4, 200, 1, 3, 2}
Duyệt từng x trong set:
x=100: 99 không có trong set → là điểm bắt đầu
Đếm: 100 ✓, 101 ✗ → length 1
x=4: 3 CÓ trong set → SKIP (sẽ được đếm khi bắt đầu từ 1)
x=200: 199 không có → là điểm bắt đầu
Đếm: 200 ✓, 201 ✗ → length 1
x=1: 0 không có → là điểm bắt đầu
Đếm: 1 ✓, 2 ✓, 3 ✓, 4 ✓, 5 ✗ → length 4 ★
x=3: 2 CÓ → SKIP
x=2: 1 CÓ → SKIP
Tổng: max length = 4.
Chìa khoá: mỗi phần tử của dãy 1-2-3-4 chỉ được "duyệt forward" đúng 1 lần
(khi x=1). Tổng công ~ 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:
# Chỉ bắt đầu khi x là điểm đầu dãy (x-1 không có).
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) — mỗi phần tử bị “đếm
forward” tối đa 1 lần.O(n) cho set.O(n)? Nhìn vào vòng
while: nó chỉ chạy với những x mà
x - 1 không trong set (điểm đầu dãy). Mỗi phần tử của 1 dãy
độ dài L chỉ được duyệt 1 lần (khi vòng while chạy từ điểm
đầu). Tổng Σ L = n.x - 1 not in num_set →
O(n²) vì mỗi điểm trong dãy đều khởi đầu vòng while →
TLE.x đã là
1 phần tử).Cho mảng nums và số nguyên k. Trả về
k phần tử thường gặp nhất (output order tuỳ ý).
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 được đảm bảo nằm trong
[1, số phần tử phân biệt].O(n log n) (gợi ý của LC).Cách 1 — Counter + sort — O(n log n).
Đơn giản nhưng không thoả gợi ý LC.
Cách 2 — Min-heap kích thước k —
O(n log k). Duy trì heap kích thước
k; khi vượt thì pop phần tử ít tần suất nhất.
Cách 3 — Bucket sort theo frequency — O(n) —
xịn nhất.
Frequency tối đa là n → tạo n + 1 bucket,
bucket i chứa các phần tử có frequency i.
Duyệt bucket từ cao xuống thấp, gom k phần tử.
Hình minh hoạ với
nums = [1,1,1,2,2,3], k=2:
Bước 1: Counter → {1:3, 2:2, 3:1}
Bước 2: Bucket sort theo frequency (n=6, có 7 bucket 0..6):
bucket[0] = []
bucket[1] = [3] # số 3 xuất hiện 1 lần
bucket[2] = [2] # số 2 xuất hiện 2 lần
bucket[3] = [1] # số 1 xuất hiện 3 lần
bucket[4..6] = []
Bước 3: Duyệt bucket từ index 6 xuống:
i=6: rỗng
i=5: rỗng
i=4: rỗng
i=3: [1] → result = [1]
i=2: [2] → result = [1, 2] đủ k=2, dừng
Output: [1, 2]
import heapq
from collections import Counter
from typing import List
class Solution:
"""Cách 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:
"""Cách 2 — Min-heap kích thước k, O(n log k)."""
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
cnt = Counter(nums)
# heapq.nlargest dùng heap-based partial sort
return heapq.nlargest(k, cnt.keys(), key=cnt.get)| Cách | 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 thắng.k rất nhỏ (~10) trong khi n cực lớn →
min-heap đỡ tốn space.n + 1 vì freq có thể bằng
n).max-heap thay vì min-heap size
k → cần O(n log n) đầy đủ.Counter(nums).most_common(k) trả về list cặp
(val, freq) — chắc chắn sẽ dùng trong production. Trong
phỏng vấn, nên trình bày một trong 3 cách trên trước, rồi nhắc đến
most_common.Cho mảng số nguyên nums và số k. Trả về
số lượng subarray (liên tục) có tổng đúng bằng
k.
Input: nums = [1, 1, 1], k = 2
Output: 2
Giải thích: 2 subarray [1,1] (vị trí 0..1 và 1..2).
Input: nums = [1, 2, 3], k = 3
Output: 2
Giải thích: [1,2] và [3].
1 <= len(nums) <= 2·10^4-1000 <= nums[i] <= 1000-10^7 <= k <= 10^7Brute force — O(n²). Với mỗi
i, tính prefix sum dần và check == k. Chấp
nhận được nhưng không tối ưu.
Tối ưu — Prefix sum + Hash map —
O(n).
Đặt P[i] = tổng nums[0..i-1] (P[0] = 0).
Khi đó tổng subarray nums[j..i-1] =
P[i] - P[j]. Subarray có tổng k ↔︎
P[i] - P[j] = k ↔︎ P[j] = P[i] - k.
→ Duyệt và đếm số j < i thoả
P[j] == cur - k. Dùng dict đếm
{prefix_sum: số lần xuất hiện}.
Hình minh hoạ với
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
↑ ↑
P[3]=14 P[7]=18 ← P[7]-P[3]=4? KHÔNG, =4
Tay xét:
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 cũ đã có → 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
Đáp án: 4 subarray có tổng = 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 đã xuất hiện 1 lần (rỗng)
cur = 0
result = 0
for x in nums:
cur += x
result += counts[cur - k] # bao nhiêu j thoả P[j] = cur - k
counts[cur] += 1
return resultO(n).O(n).counts[0] = 1 ban đầu → bỏ sót các subarray bắt
đầu từ index 0.counts[cur] trước check → đếm cả
j == i (subarray rỗng). Phải
result += counts[cur - k] trước, sau đó mới
counts[cur] += 1.(i, j) thoả f(j) = g(i)” → luôn duy trì
counts[f(j)] cho j < i. Xuất hiện nhiều
trong:
prefix % k).count(1) - count(0)).O(n) không cần hash. Có số âm → bắt buộc prefix sum.Hai chuỗi s và t gọi là đồng cấu
(isomorphic) nếu tồn tại một song ánh giữa các
ký tự của s và t sao cho thay thế từng ký tự
trong s theo ánh xạ đó cho ra t.
Input: s = "egg", t = "add" → True
Giải thích: e→a, g→d (bijection).
Input: s = "foo", t = "bar" → False
Giải thích: o phải map cả vào a và r (không là hàm).
Input: s = "paper", t = "title" → True
Input: s = "badc", t = "baba" → False
Giải thích: d→a và c→a, hai ký tự khác map vào cùng 1 → vi phạm song ánh.
1 <= len(s) == len(t) <= 5·10^4s, t chứa ký tự ASCII bất kỳ.f: s → t và g: t → s phải
injective.s và t có cùng độ dài không? →
Theo đề: có. Nếu không, return False ngay.Cách 1 — Dùng 2 dict (map cả 2 chiều).
Duyệt cùng lúc 2 chuỗi: - Nếu s[i] đã trong
s2t → check s2t[s[i]] == t[i]. - Nếu chưa →
kiểm tra t[i] chưa nằm trong t2s (tránh nhiều
s map vào cùng 1 t). - Lưu cặp
(s[i], t[i]) vào cả 2 dict.
Cách 2 — Thay bằng “first occurrence index”.
Một chuỗi có thể được “chuẩn hoá” bằng cách thay mỗi ký tự bằng vị trí xuất hiện đầu tiên của nó. Hai chuỗi isomorphic ↔︎ chuẩn hoá xong giống nhau.
Ví dụ "egg" → [0, 1, 1], "add"
→ [0, 1, 1] → bằng nhau → True.
Cách 1 trực quan hơn, cách 2 đẹp về mặt thuật toán. Cả 2 đều
O(n).
class Solution:
"""Cách 1 — 2 dict, kiểm tra song ánh."""
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 đã được map từ ký tự khác
return False
s2t[a] = b
t2s[b] = a
return True
class SolutionNormalize:
"""Cách 2 — chuẩn hoá theo 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) với k = số
ký tự phân biệt.s2t → bỏ sót case nhiều s
map cùng 1 t (như
"badc"/"baba").Thiết kế Least Recently Used (LRU) Cache với 2
operations đều O(1):
get(key): trả value của key
nếu có, ngược lại trả -1. Mỗi lần truy cập thành công làm
key đó “vừa dùng” (most recently used).put(key, value): thêm/cập nhật. Nếu vượt capacity,
xoá key ít dùng gần nhất (least recently
used).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]
Trace từng bước (capacity = 2; cuối phải = MRU, trái = 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 vừa dùng → MRU)
put(3, 3) → null; cache = {1=1, 3=3} (evict 2: LRU)
get(2) → -1; (key 2 không còn)
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 lệnh get và
put.put 1 key đã tồn tại? → Cập nhật value và
đẩy key thành MRU.Yêu cầu cốt lõi: O(1) cho cả
get và put ↔︎ cần đồng thời: - Hash
map: key → reference đến node (cho O(1) lookup). -
Doubly Linked List (DLL): thứ tự truy cập (MRU ở 1 đầu,
LRU ở đầu kia). Cho phép xoá node bất kỳ trong O(1) nếu có
reference.
Mỗi get(k): - Nếu k trong map → lấy node,
di chuyển node lên đầu DLL (= MRU), trả value. - Ngược
lại trả -1.
Mỗi put(k, v): - Nếu k đã có → update
value, di chuyển lên đầu. - Nếu không → thêm node mới ở đầu. Nếu vượt
capacity → xoá node cuối (LRU) và xoá khỏi map.
Cách Pythonic — dùng OrderedDict (đã hỗ
trợ sẵn 2 yêu cầu trên):
OrderedDict Python được triển khai bên dưới như hash map
kết hợp doubly linked list. Lớp này có 2 method đắt giá cho LRU:
move_to_end(key) và popitem(last=False) (pop
đầu).
Hình minh hoạ — DLL state qua các operation:
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 (xoá 2 vì LRU)
get(2)=-1
put(4,4): DLL: 4 ─── 3 (xoá 1)
get(1)=-1
get(3)=3: DLL: 3 ─── 4
get(4)=4: DLL: 4 ─── 3
from collections import OrderedDict
class LRUCache:
"""Cách Pythonic — OrderedDict đã có sẵn DLL + hash."""
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) # đẩy thành MRU (cuối)
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 ở đầu
# ─────────────────────────────────────────────────────────────
# Phiên bản tự tay (interview-friendly): dict + doubly linked list.
# Trình bày khi interviewer hỏi: "Implement LRU không dùng built-in."
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] = {}
# Dùng 2 sentinel head/tail để code rút gọn (không phải check None).
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 = sát tail
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) cho cả
get và put (amortized).O(capacity).O(1) lookup theo key.O(1) ở bất kỳ vị trí nào
khi đã có reference. Singly LL không làm được vì cần
prev.put(k) với k đã
tồn tại.None; trong phỏng vấn nên ưu tiên cách này để code rõ ràng
và ít bug biên.concurrent.OrderedDict.| Yêu cầu | Hash đủ? | Thay thế |
|---|---|---|
Lookup O(1), không cần thứ tự |
✅ | — |
| Cần thứ tự duyệt | ❌ | OrderedDict / sorted list |
Range query [l, r] |
❌ | Fenwick / Segment Tree (Chương 22) |
| Top-k frequent | Partial | Heap (Chương 15) |
| Nearest neighbor | ❌ | Sorted set / BST |
| Subarray sum có số âm | ✅ Prefix sum + hash | — |
| Subarray sum không âm | Thường dùng sliding window (27) | — |
move_to_end): 5 dòng,
demo tốt cho phỏng vấn.get/put. Phỏng vấn senior thường yêu
cầu cài.x - 1 ∉ set.
Mỗi chain chỉ có một starter ⇒ tổng chi phí “đi tới hết
chain” cộng dồn = O(n).O(n²)
worst-case.Linked List (danh sách liên kết) là cấu trúc “đơn giản về lý thuyết, phức tạp về code”. Mỗi node trỏ tới node kế tiếp, vậy thôi — nhưng để code không bug, bạn cần thuộc lòng 5 trick nhỏ: dummy head, two pointers, đảo in-place, split-by-pivot, và phép gắn pointer chéo. Hết chương này, bạn sẽ thấy LL không còn đáng sợ.
Chương này có 12 bài — gấp đôi các chương cơ bản — vì pattern LL có nhiều biến thể quan trọng, từ Easy (Reverse, Merge, Cycle) đến Hard (Reverse k-Group, Sort, Reorder).
Sau chương này, bạn sẽ:
next, vòng lặp vô tình, quên cập
nhật tail/head.O(1) nếu có reference).O(1) extra space — không được copy ra mảng rồi
xử lý.linked list patterns.5 trick phải thuộc lòng:
dummy.next = head, dùng prev = dummy. Tránh
hàng tá if head is None.prev / curr / nxt.a.next = b,
luôn nhớ rời a khỏi vị trí cũ trước (cập nhật cả pointer
“đi vào” và “đi ra” của a).Mọi bài trong chương 7 (và 3.3, 15.5) đều dùng
ListNode chuẩn của LeetCode:
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = nexthead luôn là một node
ListNode (hoặc None nếu list
rỗng).1 → 2 → 3 → None để minh hoạ
một linked list khởi tạo từ
head = ListNode(1, ListNode(2, ListNode(3))).Node mở rộng có thêm
random pointer; bài 7.11 LRU dùng
doubly linked list tự định nghĩa.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:
"""Mẫu dummy head — bài hay có chèn/xoá node ở đầu."""
dummy = ListNode(0, head)
prev = dummy
while prev.next:
# ... thao tác trên prev.next ...
prev = prev.next
return dummy.next # head có thể đã đổi
def find_middle(head: ListNode | None) -> ListNode | None:
"""Slow/fast pointers — tìm 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 con trỏ."""
prev, curr = None, head
while curr:
curr.next, prev, curr = prev, curr, curr.next
return prev[left, right])Cho head của linked list đơn. Đảo ngược danh sách và trả
về head mới. (Bài này đã có bản đệ quy ở Chương 3.3 —
phần này tập trung vào bản iterative với
O(1) space.)
Input: head = 1 → 2 → 3 → 4 → 5 → None (singly linked list)
Output: 5 → 4 → 3 → 2 → 1 → None
3 con trỏ: - prev = node ngay trước
curr trong danh sách kết quả. - curr = node
đang xử lý. - nxt = sao lưu curr.next trước
khi sửa.
Mỗi vòng: lật curr.next về prev, rồi dịch
prev, curr về phía trước.
Hình minh hoạ với 1 → 2 → 3 → None:
Bắt đầu : prev = None
curr → 1 → 2 → 3 → None
Vòng 1: nxt = 2
curr.next = prev → None ← 1 2 → 3 → None
prev = curr = 1, curr = 2
Vòng 2: nxt = 3
curr.next = prev → None ← 1 ← 2 3 → None
prev = 2, curr = 3
Vòng 3: nxt = None
curr.next = prev → None ← 1 ← 2 ← 3
prev = 3, curr = None → dừng
Trả prev = 3, danh sách: 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 1 dòng inside loop:
curr.next, prev, curr = prev, curr, curr.next. Tuple unpacking đánh giá RHS trước, không cầnnxttạm.
O(n). Bộ
nhớ: O(1).O(1) space — best cho mọi case.O(n) stack — khi n lớn (5·10⁴+) sẽ
RecursionError trong Python.curr.next = prev
trước khi dịch prev / curr → mất
pointer.Cho 2 head của 2 linked list đã sort tăng dần. Trả về head của list gộp lại (cũng sort tăng).
Input: l1 = 1 → 2 → 4, l2 = 1 → 3 → 4
Output: 1 → 1 → 2 → 3 → 4 → 4
<=).Iterative — dùng dummy head. Tạo dummy
và tail = dummy. Mỗi vòng, gắn tail.next vào
node nhỏ hơn của 2 list, dịch tail. Cuối cùng gắn phần đuôi
còn dư.
Đệ quy (rất gọn nhưng 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 # gắn phần đuôi còn dư
return dummy.nextO(m + n). Bộ
nhớ: O(1) iterative; O(m+n) stack đệ
quy.if dummy is None mỗi vòng.<= (không phải
<) → giữ thứ tự khi value bằng nhau.Cho head của linked list. Trả về True nếu có chu trình,
ngược lại False. Yêu cầu: O(1) extra
space.
Input: head = [3, 2, 0, -4], cycle bắt đầu ở index 1
3 → 2 → 0 → -4
↑________|
Output: True
Brute force — Hash set, O(n) space. Lưu
các node đã thấy.
Tối ưu — Floyd’s Tortoise and Hare, O(1)
space.
Hai con trỏ slow (1×) và fast (2×). Nếu có
chu trình, fast sẽ “đuổi kịp” slow trong vòng tròn (mỗi bước khoảng cách
giữa 2 giảm 1). Nếu không, fast hết đường.
Hình minh hoạ — slow/fast trên chu trình:
Linked list: 3 → 2 → 0 → -4 → ⟲ (về 2)
Bước 0: slow=3, fast=3
Bước 1: slow=2, fast=0
Bước 2: slow=0, fast=2 (fast đã quay vòng)
Bước 3: slow=-4, fast=-4 ★ gặp nhau → return True
Trên chu trình dài L, slow đi 1 bước, fast đi 2 bước → khoảng cách
giảm 1 mỗi bước → tối đa L bước thì gặp.
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). Bộ
nhớ: O(1).slow == fast (so sánh value)
thay vì slow is fast (so sánh reference) → có thể sai khi 2
node khác nhưng val giống.slow == fast, reset slow = head và đi cùng tốc
độ 1 với fast, chúng sẽ gặp nhau tại node bắt đầu
chu trình (chứng minh bằng đại số).Cho head. Trả về node giữa của linked list. Nếu có 2 node giữa (list chẵn), trả về cái thứ 2.
Input: head = 1 → 2 → 3 → 4 → 5 (singly linked list)
Output: node có value 3 (giữa, thuộc về nửa sau khi length chẵn)
Input: head = 1 → 2 → 3 → 4 → 5 → 6 (singly linked list, length chẵn)
Output: node có value 4 (giữa thứ 2)
Brute force — 2 lượt, O(n). Đếm độ dài,
rồi đi đến giữa.
Tối ưu — Slow/fast 1 lượt, O(n). Khi
fast chạm cuối, slow ở giữa.
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). Bộ
nhớ: O(1).while fast.next and fast.next.next: (dừng 1 bước sớm
hơn).Cho head và số n. Xoá node thứ n tính từ
cuối (1-indexed) và trả về head có thể đã đổi.
Input: 1 → 2 → 3 → 4 → 5, n = 2 → 1 → 2 → 3 → 5 (xoá node 4)
Input: 1, n = 1 → None
Input: 1 → 2, n = 1 → 1
n luôn ≤ độ dài list? → Có (theo đề).Brute force — 2 lượt. Đếm độ dài L, sau
đó xoá node thứ L - n từ đầu.
Tối ưu — 1 lượt, two pointers cách nhau
n. - Tạo dummy để xử lý case xoá
head. - fast đi n bước trước. - Sau đó
slow và fast cùng đi đến khi
fast.next is None. Lúc đó slow.next chính là
node cần xoá.
Hình minh hoạ với
1 → 2 → 3 → 4 → 5, n = 2:
Bắt đầu: dummy → 1 → 2 → 3 → 4 → 5 → None
slow
fast
Sau khi fast đi n=2 bước:
dummy → 1 → 2 → 3 → 4 → 5 → None
slow fast
Cùng đi đến khi fast.next == None:
dummy → 1 → 2 → 3 → 4 → 5 → None
slow fast
slow.next = 4 → cần xoá. 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). Bộ
nhớ: O(1).n == L, ta xoá
head. Dummy giúp code thống nhất — slow sẽ là
dummy, slow.next = slow.next.next đúng cho
head mới.Cho head của linked list đơn. Trả về True nếu giá trị
các node tạo thành chuỗi palindrome. Yêu cầu: 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 ra mảng + two pointers, O(n)
space. Đơn giản, nhưng vi phạm O(1) space.
Tối ưu — Split + Reverse half + Compare, O(1)
space. 1. Tìm middle (slow/fast). 2.
Đảo nửa sau (in-place). 3. So sánh từng node giữa nửa
đầu và nửa sau đã đảo. 4. (Optional) Khôi phục nửa sau (trong
phỏng vấn thường khỏi cần).
Hình minh hoạ với
1 → 2 → 3 → 2 → 1:
Bước 1: tìm middle (slow ở node 3)
1 → 2 → 3 → 2 → 1
↑ slow
Bước 2: đảo nửa sau (từ slow.next = 2):
1 → 2 → 3 1 → 2
(nửa đầu) (nửa sau đã đảo)
Bước 3: so sánh từng node:
1 vs 1 ✓
2 vs 2 ✓
→ True
class Solution:
def isPalindrome(self, head: ListNode | None) -> bool:
# 1. Tìm middle.
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
# 2. Đảo nửa sau (bắt đầu từ slow).
prev, curr = None, slow
while curr:
curr.next, prev, curr = prev, curr, curr.next
# 3. So sánh.
left, right = head, prev
while right: # nửa sau đã đảo có thể ngắn hơn 1 node
if left.val != right.val:
return False
left = left.next
right = right.next
return TrueO(n). Bộ
nhớ: O(1).while right: thay vì
while left and right:? Vì sau khi split, nửa sau
đã đảo luôn ngắn hơn hoặc bằng nửa đầu (do middle thuộc về nửa sau khi
đảo). Nửa sau “chạm cuối trước” sẽ kết thúc loop.O(n)
space.Cho 2 linked list đại diện 2 số nguyên không âm, digits lưu ngược (digit hàng đơn vị ở head). Trả về linked list = tổng 2 số (cũng theo dạng ngược).
Input: l1 = 2 → 4 → 3 (đại diện 342)
l2 = 5 → 6 → 4 (đại diện 465)
Output: 7 → 0 → 8 (đại diện 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.Mô phỏng cộng “tay”: đi đồng thời 2 list, giữ biến
carry. Mỗi vòng: -
total = (l1.val if l1 else 0) + (l2.val if l2 else 0) + carry
- digit = total % 10, carry = total // 10. -
Push digit vào kết quả; dịch l1,
l2.
Vòng lặp dừng khi cả 2 cạn và 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)). Bộ
nhớ: O(max(m, n)) cho output.or carry ở vòng while → bỏ
sót digit cuối khi 2 list cạn nhưng carry > 0 (ví dụ
5 + 5 = 10).Cho linked list mà mỗi node ngoài next còn có
random — trỏ tới node bất kỳ trong list (hoặc
None). Hãy deep copy danh sách (mỗi node
mới là 1 instance riêng, các random trỏ đúng vào node
mới tương ứng).
Input (LC-style):
head = [[7, null], [13, 0], [11, 4], [10, 2], [1, 0]]
(mỗi phần tử [val, random_index]; random_index là chỉ số 0-based của node
mà `random` trỏ tới, hoặc null nếu `random = None`)
Tương ứng 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 của list trên — cùng val và cùng cấu trúc random,
nhưng MỌI node là instance MỚI (không chia sẻ với input).
Output ở dạng LC array:
[[7, null], [13, 0], [11, 4], [10, 2], [1, 0]]
Cách 1 — Hash map “old → new”, O(n) time,
O(n) space.
Lượt 1: tạo các node mới, lưu old_to_new[old] = new.
Lượt 2: với mỗi old, gán
new.next = old_to_new[old.next] và
new.random = old_to_new[old.random].
Cách 2 — Interweave, O(n) time,
O(1) extra space.
Trick rất nổi tiếng: 1. Lượt 1: chèn mỗi node copy
ngay sau node gốc: A → A' → B → B' → C → C'. 2.
Lượt 2: với mỗi node gốc A: gán
A'.random = A.random.next (vì A.random.next
chính là copy của A.random). 3. Lượt 3:
tách 2 list ra.
Hình minh hoạ — Cách 2 với 3 node A, B, C:
Lượt 1 (chèn copy):
A → A' → B → B' → C → C'
Lượt 2 (gán random):
Giả sử A.random = C
→ A'.random = A.random.next = C.next = C' (copy của C) ✓
Lượt 3 (tách):
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:
"""Cách 1 — Hash map. Code dễ debug nhất."""
def copyRandomList(self, head: "Node | None") -> "Node | None":
if not head:
return None
old_to_new: dict[Node, Node] = {}
# Lượt 1: tạo node copy.
cur = head
while cur:
old_to_new[cur] = Node(cur.val)
cur = cur.next
# Lượt 2: gán 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:
"""Cách 2 — Interweave, O(1) extra space."""
def copyRandomList(self, head: "Node | None") -> "Node | None":
if not head:
return None
# 1. Chèn copy ngay sau mỗi node gốc.
cur = head
while cur:
cur.next = Node(cur.val, cur.next)
cur = cur.next.next
# 2. Gán random cho copy.
cur = head
while cur:
if cur.random:
cur.next.random = cur.random.next
cur = cur.next.next
# 3. Tách 2 list.
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 (không tính output).Cho head và số k. Đảo ngược từng nhóm
k node liên tiếp trong list. Nếu số node còn lại
không đủ k thì giữ nguyên. Yêu cầu: O(1) extra
space.
Input: 1 → 2 → 3 → 4 → 5, k = 2
Output: 2 → 1 → 4 → 3 → 5 (nhóm cuối chỉ có 1 node → giữ)
Input: 1 → 2 → 3 → 4 → 5, k = 3
Output: 3 → 2 → 1 → 4 → 5
Quy trình: 1. Đi k bước để xác định
đuôi nhóm. Nếu không đủ k → break. 2. Đảo
nhóm trong khoảng [head_nhóm, đuôi_nhóm]. 3.
Khâu đầu nhóm đã đảo vào prev_group_tail,
đuôi nhóm đã đảo trỏ tới next_group_head. 4. Cập nhật
prev_group_tail để tiếp tục nhóm sau.
Hình minh hoạ với
1 → 2 → 3 → 4 → 5, k = 2:
Bắt đầu: dummy → 1 → 2 → 3 → 4 → 5 → None
prev
Nhóm 1: [1, 2]. Đảo → [2, 1].
dummy → 2 → 1 → 3 → 4 → 5 → None
↑
prev (= 1, tail của nhóm vừa đảo)
Nhóm 2: [3, 4]. Đảo → [4, 3].
dummy → 2 → 1 → 4 → 3 → 5 → None
↑
prev (= 3)
Nhóm 3: [5]. Chỉ 1 node → không đủ k=2 → giữ.
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. Tìm đuôi nhóm — đi k bước.
kth = prev_group_tail
for _ in range(k):
kth = kth.next
if not kth:
return dummy.next # không đủ k → giữ nguyên
group_next = kth.next
# 2. Đảo từ prev_group_tail.next đến kth.
prev, curr = group_next, prev_group_tail.next
while curr is not group_next:
curr.next, prev, curr = prev, curr, curr.next
# 3. Khâu nhóm đã đảo vào.
old_head = prev_group_tail.next
prev_group_tail.next = kth
prev_group_tail = old_head # bây giờ là đuôi của nhóm đã đảoO(n) — mỗi node được đảo
đúng 1 lần.O(1).prev_group_tail, kth, group_next
rõ ràng trước khi viết code. Interviewer sẽ follow theo dễ.prev_group_tail = old_head cho vòng sau → đảo lại nhóm
cũ.k = 2).Sort linked list tăng dần với O(n log n) time và
O(1) extra space (theo follow-up — chỉ
tính call stack/aux, không tính node).
Input: head = 4 → 2 → 1 → 3 (singly linked list)
Output: 1 → 2 → 3 → 4
O(n log n) ⇒ quicksort, mergesort, heapsort. Quicksort
khó áp cho LL, heapsort cần O(n) extra.
Mergesort là tự nhiên nhất:
slow.next).Top-down (đệ quy) đẹp về code nhưng tốn
O(log n) stack. Bottom-up (iterative) đạt
thật sự O(1) space — nhưng code phức tạp. Trong phỏng vấn,
top-down là đủ tốt.
Hình minh hoạ với 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: tìm middle, ngắt đôi.
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) cho stack (top-down).
Bottom-up đạt O(1).fast = head.next (không phải
head)? Vì với list 2 node a → b, ta muốn
slow dừng ở a (nửa trái = [a], nửa phải =
[b]). Nếu fast = head, slow dừng ở
b → nửa phải rỗng → vòng lặp infinite.slow.next = None → 2 nửa
không tách ra → infinite recursion.Bài này đã được giải đầy đủ ở Chương 6 — Hash Table (mục 6.6). Ở đây chúng ta chỉ tóm tắt nhanh pattern Doubly Linked List vốn là điểm nhấn của LL.
Thiết kế LRU Cache với get(key) và
put(key, value) đều O(1). Khi vượt capacity →
xoá key ít dùng gần nhất.
put key đã tồn tại? → Update value + đẩy
thành MRU.LRU yêu cầu O(1) cho cả: - Lookup theo
key → hash map. - Di chuyển 1 phần tử bất kỳ về
đầu → doubly linked list (chỉ DLL mới cho phép unlink
O(1) khi có reference).
capacity = 2
Head sentinel — MRU end LRU end — Tail sentinel
│ │
▼ ▼
┌───┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌───┐
│ H │ ↔ │ K=4 │ ↔ │ K=3 │ ↔ │ K=1 │ ↔ │ T │
└───┘ └──────┘ └──────┘ └──────┘ └───┘
▲
khi vượt capacity, xoá node này
(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
# Hai sentinel head/tail giúp tránh hàng tá check None.
head, tail = Node(), Node()
head.next, tail.prev = tail, head
def _remove(node): # O(1) — chỉ cần 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)
amortized.O(capacity).O(1) xoá node bất kỳ — phải duyệt từ đầu để tìm
prev.node.prev is None hay node.next is None.OrderedDict (Python đã có sẵn DLL + hash trong nội
bộ).Cho linked list L: L0 → L1 → ... → Ln-1. Hãy sắp xếp lại
thành:
L0 → Ln-1 → L1 → Ln-2 → L2 → Ln-3 → ...
In-place, không được tạo node mới.
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)
3 bước chuẩn — vẫn là pattern “split → reverse → merge”:
Hình minh hoạ với
1 → 2 → 3 → 4 → 5:
Bước 1: tìm middle (slow ở 3)
1 → 2 → 3 → 4 → 5
Bước 2: cắt đôi và đảo nửa sau:
1 → 2 → 3 5 → 4
(nửa đầu) (nửa sau đã đảo)
Bước 3: merge xen kẽ:
Lấy 1 từ trái → 1
Lấy 5 từ phải → 1, 5
Lấy 2 từ trái → 1, 5, 2
Lấy 4 từ phải → 1, 5, 2, 4
Lấy 3 từ trái → 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. Tìm middle.
slow = fast = head
while fast.next and fast.next.next:
slow = slow.next
fast = fast.next.next
# 2. Đảo nửa sau (từ slow.next).
second = slow.next
slow.next = None # cắt đôi
prev, curr = None, second
while curr:
curr.next, prev, curr = prev, curr, curr.next
second = prev # head của nửa sau đã đảo
# 3. Merge xen kẽ.
first = head
while second:
tmp1, tmp2 = first.next, second.next
first.next = second
second.next = tmp1
first, second = tmp1, tmp2O(n). Bộ
nhớ: O(1).slow.next = None → khi merge
sẽ tạo cycle.a.next = b, đã lưu
a.next cũ chưa?head có thể đổi.)while cur and cur.next: chú ý điều kiện kép
cho 2 nút cuối..next = None chưa? (Tránh cycle.)head = None), 1
phần tử, k > len.dummy = ListNode(0, head)
prev = dummy
# ... thao tác trên prev.next ...
return dummy.next
Dùng cho: Remove Nth From End, Merge Two Sorted, Partition, Odd Even, Reverse K-Group.
Stack (LIFO) và Queue (FIFO) là cặp đôi “ngược nhau”. Stack giải được mọi bài có cấu trúc lồng (parentheses, recursion, nested structure), Queue phục vụ duyệt theo lớp (BFS, sliding window). Chương này tập trung stack — bài queue thuần sẽ gặp lại ở Chương 10 (BFS). Cuối chương có 1 bài teaser về monotonic stack — pattern mạnh sẽ được khai thác sâu ở Chương 18.
Sau chương này, bạn sẽ:
Stack rất hợp khi: - Có cấu trúc lồng / balanced (ngoặc, tag HTML, nested expression). - Cần “undo” — bước trước phải xử lý xong sau bước hiện tại (DFS iterative). - Bài “next greater element”, “largest rectangle”, “daily temperatures” → monotonic stack (Chương 18).
Queue rất hợp khi: - Cần duyệt theo lớp / BFS (Chương 10). - Bài “sliding window max” → monotonic deque (Chương 18). - Producer-consumer, task scheduler.
from collections import deque
from typing import List
# 1) Stack với list (built-in trong Python, O(1) amortized).
stack: List[int] = []
stack.append(x) # push
top = stack[-1] # peek
val = stack.pop() # pop
# 2) Queue với collections.deque — O(1) push/pop ở cả 2 đầu.
queue: deque[int] = deque()
queue.append(x) # enqueue (đẩy vào cuối)
val = queue.popleft() # dequeue (lấy đầu)
# 3) Stack lưu (index, value) — pattern monotonic.
stack: list[tuple[int, int]] = [] # (index, value)
for i, v in enumerate(arr):
while stack and stack[-1][1] < v:
idx, _ = stack.pop()
# ... xử lý idx ...
stack.append((i, v))Cho chuỗi s chỉ chứa ()[]{}. Trả về
True nếu chuỗi hợp lệ: - Mỗi dấu ngoặc mở
có dấu đóng tương ứng. - Các dấu ngoặc đóng đúng thứ tự (LIFO).
Input: s = "()" → Output: True
Input: s = "()[]{}" → Output: True
Input: s = "(]" → Output: False
Input: s = "([)]" → Output: False (lồng sai)
Input: s = "{[]}" → Output: True
1 <= len(s) <= 10^4()[]{}.Stack pattern kinh điển. Duyệt từng ký tự: - Nếu là
ngoặc mở → push. - Nếu là ngoặc đóng →
check stack top có phải cặp tương ứng không. Nếu không, hoặc stack rỗng
→ return False. Nếu có → pop.
Cuối cùng stack phải rỗng (mọi ngoặc đã match).
Hình minh hoạ với s = "{[()]}":
ch action stack sau action
─────────────────────────────────────
{ push ['{']
[ push ['{', '[']
( push ['{', '[', '(']
) pop, match ( ['{', '[']
] pop, match [ ['{']
} pop, match { []
Stack rỗng → True ✓
class Solution:
def isValid(self, s: str) -> bool:
pairs = {')': '(', ']': '[', '}': '{'}
stack: list[str] = []
for ch in s:
if ch in pairs: # ngoặc đóng
if not stack or stack.pop() != pairs[ch]:
return False
else: # ngoặc mở
stack.append(ch)
return not stackO(n). Bộ
nhớ: O(n).not stack trước khi pop → IndexError với
"]"."(" cũng trả True.Thiết kế stack hỗ trợ 4 thao tác đều O(1): -
push(x) - pop() - top() — peek -
getMin() — trả về min hiện có trong 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]
Giải thích từng bước:
MinStack() → khởi tạo
push(-2); push(0); push(-3)
getMin() → -3
pop() → bỏ -3
top() → 0
getMin() → -2
pop() khi stack rỗng? → Theo LC: không xảy ra
(caller giữ invariant).Vấn đề: getMin() O(1) ⇒
phải lưu min đâu đó. Nhưng khi pop min ra, phải biết min mới — đây là
cốt lõi.
Cách 1 — Aux stack song song giữ
min_so_far.
Stack phụ mins mỗi ô = min của tất cả các phần
tử dưới (kể cả nó) trong main stack. Khi push, push
min(x, mins[-1]). Khi pop, pop cả 2 stack.
Cách 2 — 1 stack lưu (value, current_min).
Tương đương cách 1 nhưng gộp thành tuple. Cùng overhead.
Hình minh hoạ — Cách 1 với
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) cho mọi op. Bộ
nhớ: O(n).O(1) extra space
khi mọi value đều dương và biết trước range, dùng “encoded difference” —
phức tạp, không thực tế. Tốt nhất cứ dùng 2 stack.MaxStack (LC 716) — tương
tự nhưng có thêm popMax(), cần dùng DLL + ordered map.Thiết kế Queue (FIFO) chỉ dùng 2 stack. Hỗ trợ 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]
Giải thích từng bước:
MyQueue() → khởi tạo
push(1); push(2)
peek() → 1 (FIFO: phần tử đẩy vào trước ra trước)
pop() → 1
empty() → false
Ý tưởng — 2 stack: in (input) và
out (output). - push(x): push vào
in. - pop / peek: nếu
out rỗng → “đổ” toàn bộ in sang
out (đảo thứ tự vì stack). Sau đó pop/peek từ
out.
Phân tích amortized: Mỗi phần tử bị move giữa 2
stack tối đa 1 lần. Worst-case 1 op =
O(n), nhưng amortized = O(1).
Hình minh hoạ với
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 rỗng → đổ in sang out
in=[] out=[3, 2, 1] (1 ở top)
out.pop() → 1
in=[] out=[3, 2]
push(4): in=[4] out=[3, 2]
pop(): out không rỗng → out.pop() → 2
in=[4] out=[3]
→ FIFO: 1 ra trước (đúng thứ tự push)
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:
"""Khi out rỗng, đổ toàn bộ in sang out."""
if not self.out_st:
while self.in_st:
self.out_st.append(self.in_st.pop())O(1). Pop /
Peek: O(1) amortized (worst
O(n)).in → out mỗi lần
pop, kể cả khi out còn → sai thứ tự FIFO. Chỉ đổ
khi out rỗng.O(1) worst-case → không thể với 2 stack
thuần.Cho mảng tokens biểu diễn biểu thức Reverse
Polish Notation (postfix). Mỗi token là số nguyên hoặc 1 trong
4 phép + - * /. Trả về kết quả (chia lấy phần nguyên hướng
về 0).
Input: tokens = ["2","1","+","3","*"]
Output: 9
Giải thích: (2 + 1) * 3 = 9.
Input: tokens = ["4","13","5","/","+"]
Output: 6
Giải thích: 4 + (13 / 5) = 4 + 2 = 6.
Input: tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]
Output: 22
1 <= len(tokens) <= 10^4tokens[i] là số -200..200 hoặc một phép
tính.int(a/b)).RPN ↔︎ stack — kinh điển. Duyệt: - Số → push. - Phép
→ pop 2 phần tử (b lấy trước, a lấy sau), tính a op b, push
kết quả.
Cuối cùng stack còn 1 phần tử = kết quả.
Hình minh hoạ với
["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]
Kết quả: 9
from typing import List
import operator
class Solution:
OPS = {
'+': operator.add,
'-': operator.sub,
'*': operator.mul,
'/': lambda a, b: int(a / b), # chia hướng về 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). Bộ
nhớ: O(n).-7 // 2 == -4
(floor), nhưng đề yêu cầu truncate towards zero →
int(-7 / 2) == -3. Dùng int(a / b) để
đúng.b (right
operand) pop trước, a (left) pop sau. Phép
- và / không giao hoán.Cho mảng temperatures các nhiệt độ. Với mỗi ngày
i, tìm xem bao nhiêu ngày sau đó mới có nhiệt độ
cao hơn ngày i. Nếu không có, trả
0.
Input: temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
Output: [1, 1, 4, 2, 1, 1, 0, 0]
Giải thích:
day 0: 73 → ngày 1 (74) cao hơn → 1
day 2: 75 → phải đợi tới ngày 6 (76) → 6-2=4
day 6: 76 không có gì cao hơn → 0
1 <= len <= 10^530 <= temperatures[i] <= 100< không phải <=.Brute force — O(n²). Với mỗi
i, quét forward tìm cái lớn hơn. TLE khi
n = 10^5.
Tối ưu — Monotonic decreasing stack —
O(n).
Ý tưởng: Duy trì stack các index mà
temperature giảm dần (từ đáy lên đỉnh). Khi gặp ngày i có
nhiệt cao hơn top của stack → đó chính là “answer” cho ngày ở top. Pop
và ghi result[top] = i - top.
Hình minh hoạ với
[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]
Còn lại trong stack: [6, 7] → result giữ 0.
Kết quả: [1, 1, 4, 2, 1, 1, 0, 0] ✓
Stack invariant: index trong stack có temperature
giảm dần từ đáy lên đỉnh. Mỗi index được push 1 lần,
pop tối đa 1 lần → tổng O(n).
from typing import List
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
n = len(temperatures)
result = [0] * n
stack: list[int] = [] # các index có temperature giảm dần
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) — mỗi index được
push/pop tối đa 1 lần.O(n).t[i]
lớn hơn 1 phần tử ở stack, mọi phần tử dưới nữa (nếu
cũng nhỏ hơn t[i]) đều đã có sẵn 1 ứng viên là
i. Stack giảm dần nên ta chỉ cần pop các ô <
t[i] từ trên xuống.<= thay vì
< → ngày sau bằng nhiệt sẽ tính là “ấm hơn”, sai
đề.Cho chuỗi s mã hoá theo dạng
k[encoded_string] — nghĩa là encoded_string sẽ
được lặp k lần. Decode chuỗi.
Input: s = "3[a]2[bc]"
Output: "aaabcbc"
Input: s = "3[a2[c]]"
Output: "accaccacc" (lồng)
Input: s = "2[abc]3[cd]ef"
Output: "abcabccdcdcdef"
1 <= len(s) <= 301 <= k <= 300 (số nguyên)Cấu trúc lồng ⇒ dùng stack. Mỗi khi gặp
[, ta “lưu” số k và chuỗi đang build vào
stack, rồi reset chuỗi build. Khi gặp ], pop ra
(prev_str, k) và nối
prev_str + k * cur_str.
Cách 2 — Đệ quy. Mỗi k[...] thành 1 lần
gọi đệ quy. Code gọn hơn nhưng tốn stack (đệ quy có thể vượt giới hạn
với chuỗi lồng sâu).
Hình minh hoạ — Cách stack với
"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"
Kết quả: "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:
"""Cách 2 — đệ quy. Code clean nhưng tốn 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) với N là
độ dài chuỗi output (mỗi ký tự được tạo 1 lần).O(N).k = k * 10 + int(ch) (vì
10[ab] có k = 10).k = 0 sau khi push.prev_str + cur * prev_k — chuỗi đã build
trước [ đứng bên trái.| Mục đích | Stack chứa gì | Bài ví dụ |
|---|---|---|
| Match cặp đối xứng | Mở ngoặc / token chờ đóng | LC 20, 1249 |
| Lưu token trước đó chưa hoàn tất | Số / chuỗi cần “expand” sau | LC 394 Decode |
| Undo / context | Phép tính cha | LC 224 Calculator |
| Monotonic | Index/value tăng/giảm | Chương 18 |
| Iterative DFS | Frame call | Tree iterative inorder |
3[a2[c]]| Bước | 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 trước, a sau, tính
a op b. Nhầm thứ tự là bug điển hình với - và
/.
(val, current_min): code
đơn giản, O(n) bộ nhớ.Graph là cấu trúc dữ liệu trừu tượng nhất nhưng cũng phổ biến nhất trong đời thực: bạn bè, đường đi, dependency, … Chương này giới thiệu biểu diễn graph và 6 bài phổ thông về graph. Hai kỹ thuật duyệt BFS, DFS sẽ được đào sâu ở Chương 10 và 11; chương này dùng cả 2 ở mức cơ bản để bạn làm quen.
Sau chương này, bạn sẽ:
3 câu hỏi trước khi code: 1. Hướng / vô hướng? Directed cần cân nhắc thêm chu trình. 2. Có trọng số? Nếu có → cân nhắc Dijkstra (Chương 30), không thì BFS/DFS đủ. 3. Đặc tính đặc biệt? DAG → topo sort, bipartite, planar, …
from collections import defaultdict
from typing import List
# 1) Adjacency List — hầu hết bài dùng cái này
graph: dict[int, list[int]] = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u) # bỏ dòng này nếu directed
# 2) Edge List — input "raw"
edges: list[tuple[int, int]] = [(0, 1), (1, 2), ...]
# 3) Adjacency Matrix — chỉ khi V nhỏ (≤ 1000) và mật độ cao
adj = [[0] * n for _ in range(n)]
for u, v in edges:
adj[u][v] = 1Khi nào dùng cái nào?
| Biểu diễn | Lookup (u, v) |
Duyệt láng giềng u |
Bộ nhớ |
|---|---|---|---|
| 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²) |
→ Mặc định dùng adjacency list. Chỉ chuyển sang
matrix khi cần kiểm tra edge O(1) và V
nhỏ.
from collections import defaultdict, deque
# DFS đệ quy
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)
# DFS iterative bằng 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 bằng 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 visitedCho n đỉnh đánh số 0..n-1 và mảng cạnh
vô hướng edges[i] = [u, v]. Cho
source và destination. Trả về
True nếu có đường đi giữa chúng.
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^53 cách kinh điển, đều O(V + E): 1. BFS
— duyệt theo lớp, return ngay khi gặp destination. 2.
DFS — đệ quy hoặc stack. 3. Union Find
(Chương 24) — gộp 2 đỉnh thành 1 root nếu có cạnh; check
find(source) == find(destination). Đẹp khi đề bài có nhiều
query “có đường đi giữa u, v?”.
from collections import defaultdict, deque
from typing import List
class Solution:
"""BFS — sạch nhất cho 1 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). Bộ
nhớ: O(V + E).V = 10^5+).source == destination → vẫn
đúng vì BFS sẽ duyệt tới chính nó, nhưng check riêng cho gọn.Cho node của một undirected connected graph. Mỗi
Node có val: int và
neighbors: list[Node]. Hãy deep copy
graph: tạo bản sao mà mỗi node mới là instance riêng, các
neighbors trỏ vào node mới tương ứng.
Input: adjList = [[2,4], [1,3], [2,4], [1,3]]
(adjList[i-1] = danh sách neighbor của node i, node value 1-indexed
giống LC; tham số API thực chất là Node = adjList[0] = node 1)
Tương ứng graph:
1 ── 2
│ │
4 ── 3
Output: [[2,4], [1,3], [2,4], [1,3]]
(cùng cấu trúc adjacency, nhưng MỌI Node trong output là instance MỚI;
không có Node nào dùng chung với input)
0 <= số node <= 1001 <= val <= 100, val phân biệt.Pattern y hệt LC 138 (Copy List with Random Pointer,
bài 7.8): dùng hash map original_to_copy
để tránh tạo trùng và xử lý chu trình.
BFS / DFS đều OK — quan trọng là check visited qua dict.
Hình minh hoạ với graph 1—2—3—4—1:
Bắt đầu: visit 1.
cloned = {1: Node(1)}
queue = [1]
Pop 1: hàng xóm = [2, 4]
Tạo Node(2), Node(4); thêm vào cloned.
cloned[1].neighbors = [cloned[2], cloned[4]]
queue = [2, 4]
Pop 2: hàng xóm = [1, 3]
cloned[1] đã có; tạo Node(3).
cloned[2].neighbors = [cloned[1], cloned[3]]
queue = [4, 3]
Pop 4: hàng xóm = [1, 3]
cả 2 đã có trong cloned.
cloned[4].neighbors = [cloned[1], cloned[3]]
Pop 3: hàng xóm = [2, 4]
cả 2 đã có.
cloned[3].neighbors = [cloned[2], cloned[4]]
→ Trả cloned[1] làm head của bản 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 với 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:
"""DFS đệ quy — code ngắn hơn."""
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 # phải set TRƯỚC khi đệ quy neighbors
copy.neighbors = [dfs(nb) for nb in cur.neighbors]
return copy
return dfs(node) if node else NoneO(V + E).O(V) cho
cloned.cloned[cur] = copy trước khi đệ quy hàng
xóm — nếu không, chu trình sẽ gây infinite recursion.O(V + E). BFS dễ tránh stack
overflow.Cho n đỉnh đánh số 0..n-1 và mảng cạnh
vô hướng. Đếm số thành phần liên
thông.
Input: n = 5, edges = [[0,1],[1,2],[3,4]]
Output: 2
Giải thích: 2 component {0,1,2} và {3,4}.
Input: n = 5, edges = [[0,1],[1,2],[2,3],[3,4]]
Output: 1
1 <= n <= 20000 <= len(edges) <= n*(n-1)/2Cách 1 — Duyệt DFS/BFS từng đỉnh chưa thăm —
O(V + E). Cho mỗi đỉnh chưa thăm, tăng counter và
DFS/BFS đánh dấu cả component.
Cách 2 — Union Find —
O((V + E) · α(V)). Gom các đỉnh có cạnh thành cùng
root. Đếm số root khác nhau.
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 — đẹp khi đề bài có nhiều query."""
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 rất
lớn → fallback iterative.Có numCourses khoá học đánh số
0..numCourses-1. prerequisites[i] = [a, b]
nghĩa là muốn học khoá a thì phải hoàn thành khoá
b trước. Trả về True nếu có thể học hết
tất cả khoá, ngược lại False.
Input: numCourses = 2, prerequisites = [[1, 0]]
Output: True
Input: numCourses = 2, prerequisites = [[1, 0], [0, 1]]
Output: False (vòng tròn: 1 cần 0, 0 cần 1)
1 <= numCourses <= 20000 <= len(prerequisites) <= 5000Phát biểu lại: Tạo directed graph b → a
(b phải xong trước a). Câu hỏi: graph có chu trình
không? Nếu không có chu trình → có thể học hết.
Cách 1 — DFS với 3 trạng thái (white / gray / black).
white = chưa thăm.gray = đang trong recursion path hiện tại.black = đã thăm xong, không có chu trình từ đây.Nếu DFS gặp gray → tìm thấy back-edge → có chu
trình.
Cách 2 — Topological Sort BFS (Kahn’s algorithm).
Đếm indegree mỗi node. Đẩy các node
indegree == 0 vào queue. Pop và giảm indegree của các
neighbors. Nếu cuối cùng đếm được numCourses node → DAG;
ngược lại có chu trình.
Topological sort là cốt lõi của Chương 13. Ở đây giới thiệu sớm vì Course Schedule là kinh điển ứng dụng nó.
Hình minh hoạ — DFS 3 màu với cycle
0 → 1 → 0:
Bắt đầu: tất cả white.
DFS(0):
mark 0 = gray.
visit 1 (white):
DFS(1):
mark 1 = gray.
visit 0 (gray!) → back-edge phát hiện → return False (có chu trình)
from collections import defaultdict, deque
from typing import List
class Solution:
"""DFS 3-color."""
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 # đã xong, không có cycle từ đây
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 topo 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 enable a). Vẽ đúng chiều là 50% thành
công.Cho graph vô hướng dạng adjacency list
graph[i] = [hàng xóm của i]. Trả về True nếu
graph là bipartite — có thể chia các đỉnh thành 2 tập
sao cho mọi cạnh nối 2 đỉnh ở khác tập.
Input: graph = [[1,2,3],[0,2],[0,1,3],[0,2]]
Output: False
Giải thích: 0—1—2—0 là tam giác → không thể 2-color.
Input: graph = [[1,3],[0,2],[1,3],[0,2]]
Output: True
Giải thích: tập A = {0, 2}, tập B = {1, 3}.
1 <= n <= 1000 <= graph[i].length < nEquivalent: Graph bipartite ↔︎ tô được 2 màu sao cho không có 2 đỉnh kề nhau cùng màu.
BFS/DFS với 2-color: Bắt đầu mỗi component, tô đỉnh
đầu màu 0. Khi BFS/DFS sang hàng xóm, tô màu ngược. Nếu gặp hàng xóm đã
có màu giống → return False.
Hình minh hoạ — graph tam giác (không bipartite):
0
/ \
1───2
BFS từ 0:
color[0] = 0.
Sang 1: color[1] = 1 (ngược màu 0).
Sang 2: color[2] = 1 (ngược màu 0).
Từ 1, sang 2: color[2] đã là 1 == color[1] = 1 → MÂU THUẪN → 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 = chưa tô
for start in range(n):
if color[start] != -1:
continue
# BFS component chứa 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)?
Graph có thể không liên thông — phải khởi động BFS ở mọi component.Cho mảng các đẳng thức equations[i] = [Ai, Bi] và mảng
values[i] nghĩa là Ai / Bi = values[i]. Cho
mảng query queries[j] = [Cj, Dj]. Hãy trả lời mỗi query với
giá trị Cj / Dj, hoặc -1 nếu không xác định
được.
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]
Giải thích:
a/c = a/b * b/c = 2 * 3 = 6
b/a = 1/(a/b) = 0.5
a/e không xác định (e không có trong equations)
a/a = 1
x/x: x không có trong equations → -1
1 <= len(equations) <= 200.0 < values[i] <= 20.0Insight: mỗi đẳng thức A / B = k ↔︎
trong directed graph có 2 cạnh: - A → B trọng số
k. - B → A trọng số 1/k.
Khi đó C / D = tích trọng số dọc theo bất kỳ đường đi
nào từ C đến D. Nếu không có đường đi → trả
-1.
DFS trên graph trọng số là đủ. Tối ưu hơn: Union Find với trọng số (xem Chương 24).
Hình minh hoạ với equations
a/b=2, b/c=3:
Graph trọng số:
── 2 ──> ── 3 ──>
a b c
<─ 0.5 ── <─ 1/3 ─
Query a/c: DFS từ a:
a → b (× 2), tiếp b → c (× 3) → tổng 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)) với
Q = số queries.O(V + E).src == dst base case → return -1 cả với
a/a.src not in graph → query với variable không
tồn tại.visited mới cho mỗi query — chia sẻ visited giữa
query sẽ ban đường đi.find(x) không chỉ trả root mà còn tích trọng
số trên đường lên root.O(α(V)).| Triệu chứng đề bài | Pattern phù hợp | Chương |
|---|---|---|
| “Có đường từ A đến B?” | BFS / DFS / Union Find | 10, 11, 24 |
| “Số cụm/đảo” | DFS / Union Find | 12, 24 |
| “Thứ tự thực hiện với ràng buộc” | Topological sort | 13 |
| “Shortest path trọng số dương” | Dijkstra (Chương 30) | |
| “Shortest path 0/1 edges” | 0-1 BFS / BFS thường | |
| “All-pairs shortest” | Floyd-Warshall O(V³) |
|
| “Nhỏ nhất kết nối tất cả” | MST (Chương 33) | |
| “Bottleneck min/max trên path” | Kruskal + DSU / BS + BFS (37) | |
| “Bipartite?” | BFS/DFS 2-color |
old: 1 — 2
\ /
3 — 4
old_to_new = {1:1', 2:2', 3:3', 4:4'} (dict cũ → bản sao)
clone(node):
if node in old_to_new: return old_to_new[node]
new = Node(node.val)
old_to_new[node] = new # ĐẶT TRƯỚC khi đệ quy → tránh vòng
for nei in node.neighbors:
new.neighbors.append(clone(nei))
return new
a / b = w là cạnh có trọng số: từ a đi
sang b “nhân với w”.x / y: tìm đường đi x → y, kết quả là
tích các trọng số dọc đường.-1.0.Bài đầy đủ Topological sort xem Chương 13. Chương này chỉ trình bày DFS detect cycle.
BFS duyệt graph theo lớp (level by level). Đặc tính then chốt: nếu mọi cạnh có trọng số bằng nhau (= 1), BFS từ
sourcecho ra đường đi ngắn nhất đến mọi đỉnh khác. Đây là lý do BFS xuất hiện rất nhiều ở các bài “shortest path in unweighted graph”, “minimum steps”, “minimum transformations”, …
Sau chương này, bạn sẽ:
(r, c, k).Phân biệt với DFS: - BFS: tìm path ngắn nhất, duyệt theo lớp. - DFS: thám hiểm sâu (path đầu tiên đến đích), check connectivity, đếm components.
from collections import deque
# 1) BFS chuẩn — đường đi ngắn nhất từ start tới 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) BFS theo lớp — không cần lưu distance trong 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()
# ... xử lý node ở level này ...
for nb in neighbors_fn(node):
if nb not in visited:
visited.add(nb)
queue.append(nb)
level += 1
# 3) Multi-source BFS — đẩy nhiều nguồn vào queue cùng lúc
def multi_source(sources: list, neighbors_fn):
queue = deque(sources)
visited = set(sources)
while queue:
...Cho root của một binary tree. Trả về level order
traversal dưới dạng danh sách các list (mỗi list chứa các node
ở 1 level từ trên xuống, trái sang phải).
Input: root = [3, 9, 20, null, null, 15, 7] (LC level-order serialize)
Cây thực tế:
3
/ \
9 20
/ \
15 7
Output: [[3], [9, 20], [15, 7]]
0 <= số node <= 2000-1000 <= node.val <= 1000BFS theo lớp. Mỗi vòng outer = 1 level. Đầu mỗi
vòng, ghi size = len(queue), sau đó pop đúng
size node — đó là toàn bộ level hiện tại.
Hình minh hoạ:
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). Bộ
nhớ: O(n) cho queue (worst-case level cuối có ~n/2
node).result.reverse().level_vals[-1] mỗi
level.max(level_vals).level_vals
reverse.size = len(queue) trước
vòng inner → queue bị mở rộng trong khi for loop → level bị “trộn”.Cho lưới grid với các giá trị: - 0 = ô
trống - 1 = quả tươi - 2 = quả thối
Mỗi phút, mỗi quả thối làm 4 ô kề bên
(lên/xuống/trái/phải) có quả tươi trở thành thối. Trả về số phút tối
thiểu để không còn quả tươi, hoặc -1 nếu
không khả thi.
Input: grid = [[2,1,1],
[1,1,0],
[0,1,1]]
(0 = empty, 1 = fresh orange, 2 = rotten)
Output: 4 (số phút để mọi orange thối)
Input: grid = [[2,1,1],
[0,1,1],
[1,0,1]]
Output: -1 (quả tươi ở (2,0) bị cô lập, không bao giờ thối)
Insight: Mỗi quả thối là một nguồn lan toả. Tất cả nguồn lan đồng thời mỗi phút → multi-source BFS.
Quy trình: 1. Đẩy tất cả quả thối
ban đầu vào queue cùng lúc (level 0). 2. BFS theo lớp — mỗi level tăng
minutes thêm 1. 3. Đếm số quả tươi ban đầu. Mỗi lần thối
thêm một quả → giảm count. 4. Cuối: nếu còn quả tươi → -1,
ngược lại → minutes.
Hình minh hoạ với grid 3x3:
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 quả tươi) (xong)
Hàng động BFS:
Queue chứa các ô (i, j) cùng level → mỗi vòng outer pop hết queue rồi push
hàng xóm. Số vòng outer = số phút.
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). Bộ
nhớ: O(R · C).0 ngay nếu không có quả tươi
ban đầu — nếu không, vòng outer không chạy và return
minutes = 0 đúng nhưng vô tình “may rủi”.Cho beginWord, endWord, và
wordList (các từ cùng độ dài). Mỗi bước biến đổi: thay đúng
1 ký tự trong word hiện tại sao cho từ mới vẫn nằm
trong wordList. Trả về số bước tối thiểu
để biến beginWord → endWord (bao gồm cả 2
đầu). Trả 0 nếu không khả thi.
Input: beginWord = "hit", endWord = "cog"
wordList = ["hot","dot","dog","lot","log","cog"]
Output: 5
Giải thích: hit → hot → dot → dog → cog (độ dài 5)
Input: beginWord = "hit", endWord = "cog"
wordList = ["hot","dot","dog","lot","log"]
Output: 0 (cog không trong wordList)
1 <= len(beginWord) <= 101 <= len(wordList) <= 5000Mô hình hoá: Mỗi từ là 1 đỉnh; có cạnh giữa 2 từ nếu chúng khác nhau đúng 1 ký tự. Bài thành shortest path trong undirected graph → BFS.
Tối ưu sinh hàng xóm: Thay vì so sánh từ cur với mọi
từ trong wordList (O(N·L) mỗi node) — quá chậm — ta sinh
hàng xóm bằng cách thay từng vị trí ký tự bằng a..z
(O(26·L) mỗi node).
Hình minh hoạ với "hit" → "cog":
Level 1: hit
Level 2: hot (đổi i→o)
Level 3: dot, lot (đổi h→d/l)
Level 4: dog, log (đổi t→g)
Level 5: cog ★ đáp án (5 bước)
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) với
N = số từ, L = độ dài từ.
26·L neighbors, mỗi cái xây string
O(L).O(N · L).beginWord và endWord, dừng khi 2 BFS gặp nhau.
Giảm O(b^d) xuống O(b^(d/2)) — cải thiện đáng
kể khi đường đi dài.{"h*t": ["hot", "hit", ...]}. Sau đó hàng xóm của
"hot" là union các pattern "_ot",
"h_t", "ho_". Cách này nhanh hơn cho
L lớn.steps bắt đầu từ 1 (bao gồm
beginWord).Cho khoá 4 ô số 0000. Mỗi bước: xoay 1 ô số lên hoặc
xuống 1 đơn vị (vòng 0..9). Cho deadends (các
tổ hợp không được đến) và target. Trả về số bước tối thiểu
để đến target, hoặc -1 nếu không khả thi.
Input: deadends = ["0201","0101","0102","1212","2002"], target = "0202"
Output: 6
Giải thích: 0000 → 1000 → 1100 → 1200 → 1201 → 1202 → 0202
(không trùng deadend nào)
1 <= len(deadends) <= 500target không trong deadends.0000 có trong deadends không? → Trả
-1.State space implicit graph. Mỗi state là chuỗi 4 chữ số → 10^4 = 10000 state. Từ mỗi state có 8 transition (4 ô × 2 chiều).
BFS: bắt đầu từ "0000", BFS đến
target. Skip các state trong deadends.
Hình minh hoạ một phần BFS:
Level 0: 0000
Level 1: 1000, 9000, 0100, 0900, 0010, 0090, 0001, 0009 (8 hàng xóm)
Level 2: ... (mỗi node 8 hàng xóm, trừ những cái đã 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) state × 8 neighbors
= O(80000).O(10^4)."0000" in dead trước —
nếu start đã chết thì return ngay.Cho ma trận vuông n × n chứa 0 (đi được) và
1 (vật cản). Tìm đường đi ngắn nhất từ (0,0)
đến (n-1,n-1), đi được 8 hướng (4 trục + 4
chéo). Độ dài đường đi = số ô đi qua (kể cả start và end). Trả
-1 nếu không đi được.
Input: grid = [[0,0,0],
[1,1,0],
[1,1,0]]
Output: 4
Giải thích: (0,0) → (0,1) → (1,2) → (2,2)
1 <= n <= 100grid[i][j] ∈ {0, 1}grid[0][0] và grid[n-1][n-1] có thể là 1
(kết quả -1).BFS từ (0,0) với 8 hướng. Mỗi cạnh trọng số 1 (mỗi bước = 1 ô).
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²). Bộ
nhớ: O(n²).grid[r][c] = 1 để
mark visited thay cho set riêng. Mutating input — nên hỏi
interviewer trước có cho phép.max(|nr - end_r|, |nc - end_c|) (Chebyshev distance vì 8
hướng), A* nhanh hơn BFS thuần đáng kể (Chương 30).n == 1 → trả về
1 thay vì đi vào BFS (sẽ không pop được).Cho bàn cờ n × n đánh số ô theo zigzag (như cờ rắn
rồng). board[i][j] = -1 nghĩa là ô bình thường; nếu
>= 1, đó là rắn/thang đưa bạn đến ô số đó.
Mỗi bước, từ ô hiện tại bạn được tung xúc xắc (6 mặt) đi 1..6 bước;
nếu ô đến có rắn/thang, tự động đi tiếp đến ô đích. Tìm số lần
tung tối thiểu để đến ô n*n cuối cùng. Trả
-1 nếu không thể.
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: mỗi ô đánh số 1..n². Từ ô
s, có thể đi đến ô s+1, s+2, ..., s+6 (rồi
nhảy nếu có rắn/thang). Mỗi cạnh = 1 lần tung → BFS ra
số tung tối thiểu.
Trick zigzag → tọa độ: - Hàng (từ dưới):
(label - 1) // n. - Cột tuỳ hướng hàng: chẵn từ dưới thì
trái → phải, lẻ thì phải → trái.
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²) state × 6
transitions.O(n²).(row, col) từ
label. Vẽ ví dụ nhỏ n = 4 trên giấy để kiểm
chứng công thức.visited.| State | Bài tiêu biểu |
|---|---|
node |
Shortest path 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, chẵn/lẻ bước |
h*t → hot, hat, hit, ...:
precompute O(N · L), lookup O(L).O(L · 26)
mỗi node, đơn giản hơn nhưng chậm khi N lớn.Board n×n serpentine: index i (1..n²) → tọa
độ:
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
Bug điển hình: quên đảo chiều hàng lẻ, hoặc index 0/1.
for _ in range(len(q)): ...): dist = số lần
đã pop level. Dùng khi không cần trả dist từng
node.(node, d): linh hoạt hơn khi mỗi
node có d riêng, nhưng tốn bộ nhớ.DFS đi sâu nhất có thể trước khi quay lui. Đây là pattern tự nhiên cho mọi bài cây / đồ thị có cấu trúc đệ quy: depth, path sum, validate, LCA, … Chương này tập trung DFS trên cây — pattern dễ thuộc lòng và rất hay trong phỏng vấn. DFS trên grid sẽ ở Chương 12 (Island Matrix).
Sau chương này, bạn sẽ:
3 mẫu DFS trên cây: 1. Top-down:
truyền state xuống (vd. path_so_far,
current_sum). 2. Bottom-up: leaf trả giá
trị lên, node tổng hợp từ con. 3. Hỗn hợp: vừa truyền
xuống vừa nhận lên.
Mọi bài trong chương 10/11/22 (tree) dùng
TreeNode chuẩn của LeetCode:
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = rightroot luôn là một node
TreeNode (hoặc None nếu cây
rỗng).Input: root = [1, 2, 3, null, 4], đây là
LC level-order serialize — đọc theo BFS,
null là vị trí thiếu con. Cây thực tế: 1 là
root, 2/3 là con trái/phải;
2.left = None, 2.right = TreeNode(4).root/int/list[list[int]] tùy
bài.class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val, self.left, self.right = val, left, right
# 1) Bottom-up: trả về giá trị từ con
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: truyền state xuống
def dfs_top_down(node, state) -> None:
if not node:
return
new_state = update(state, node.val)
if is_leaf(node):
# ... ghi kết quả ...
return
dfs_top_down(node.left, new_state)
dfs_top_down(node.right, new_state)
# 3) Iterative DFS bằng 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 lên top trướcCho root của binary tree. Trả về độ sâu lớn
nhất (số node trên path dài nhất từ root đến lá).
Input: root = [3, 9, 20, null, null, 15, 7] (LC level-order serialize)
Cây thực tế:
3
/ \
9 20
/ \
15 7
Output: 3
Bottom-up một dòng:
depth(node) = 1 + max(depth(left), depth(right)), base
case rỗng → 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). Bộ
nhớ: O(h) stack (h = chiều cao).+1 ngoài
max(...) thay vì trong min/max — nếu phải tính
min depth (LC 111), cần check None children cẩn thận (xem tự
luyện).Cho root và số targetSum. Trả về
tất cả đường đi root-to-leaf có tổng giá trị bằng
targetSum.
Input: root = [5, 4, 8, 11, null, 13, 4, 7, 2, null, null, 5, 1]
targetSum = 22
Cây thực tế:
5
/ \
4 8
/ / \
11 13 4
/ \ / \
7 2 5 1
Output: [[5,4,11,2], [5,8,4,5]]
DFS top-down + backtracking: - Đi xuống mỗi node,
giảm target còn lại và append node vào path. -
Tại leaf: nếu target == leaf.val → copy path
vào kết quả. - Khi quay lên (sau khi duyệt xong các con) →
path.pop() để khôi phục trạng thái.
Hình minh hoạ — đường đi 5 → 4 → 11 → 2 cho
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 → bỏ
pop → path=[5,4,11]
DFS(2, 2): leaf, 2 == 2 → ADD [5,4,11,2] vào 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 — copy mỗi path
tốn O(h), có thể có O(n) paths.O(h) stack + path.path.copy() → mọi entry trong result trỏ vào cùng
list.path.pop() cuối cùng → state dirty.Cho DAG graph (mảng adjacency list, node i
có hàng xóm graph[i]). Trả về tất cả đường
đi từ node 0 đến node n - 1.
Input: graph = [[1,2],[3],[3],[]]
# đồ thị: 0 → 1 → 3
# 0 → 2 → 3
Output: [[0,1,3], [0,2,3]]
2 <= n <= 15DAG ⇒ không có chu trình ⇒ không cần visited. DFS từ
0, mỗi đến n-1 ghi 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 (DAG có
thể có exponential paths).O(n) stack.(node, path) —
nhưng phải clone path nên overhead.Cho root. Kiểm tra cây có phải BST hợp lệ không, theo
định nghĩa: - Mọi node con trái: value strictly less
than node hiện tại. - Mọi node con phải: value strictly
greater than node hiện tại. - Cả 2 subtree đều là BST.
Input: root = [2, 1, 3]
Cây thực tế:
2
/ \
1 3
Output: true
Input: root = [5, 1, 4, null, null, 3, 6]
Cây thực tế:
5
/ \
1 4
/ \
3 6
Output: false
(node 3 ở subtree phải của 5, nhưng 3 < 5 → vi phạm BST)
Cách sai phổ biến — chỉ check
node.left.val < node.val < node.right.val. Sai vì BST
yêu cầu TOÀN BỘ subtree trái < node, không chỉ child
trực tiếp.
Ví dụ ngược:
5
/ \
1 4
/ \
3 6
Tại node 5: 4 < 5 (OK), 1 < 5 (OK). Tại node 4: 3 < 4 < 6 (OK). Local check pass, nhưng 3 < 5 trong subtree phải → sai.
Cách đúng — DFS truyền (low, high) bound:
dfs(node, low, high): node phải thoả
low < node.val < high. Khi đi xuống: - Trái: bound
mới (low, node.val). - Phải: bound mới
(node.val, high).
Cách 2 — Inorder traversal phải sắp xếp tăng strict.
BST inorder = sequence sorted. Duyệt inorder, kiểm tra mỗi value > value trước.
import math
from typing import Optional
class Solution:
"""Cách 1 — DFS với bound (low, high)."""
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:
"""Cách 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). Bộ
nhớ: O(h) stack.<= vs <: BST
chuẩn cấm duplicate, dùng strict <. Một số biến thể cho
phép duplicate ở một bên — phải hỏi rõ.Cho root của binary tree (LC level-order serialize, vd
[3,2,3,null,3,null,1]). Nhà ở mỗi node có giá trị
node.val. Tên trộm không thể trộm 2 nhà kề
nhau (parent ↔︎ child). Trả về số tiền tối đa trộm được.
Input: root = [3, 2, 3, null, 3, null, 1]
Cây thực tế:
3
/ \
2 3
\ \
3 1
Output: 7
(trộm 3 + 3 + 1 = 7)
Input: root = [3, 4, 5, 1, 3, null, 1]
Cây thực tế:
3
/ \
4 5
/ \ \
1 3 1
Output: 9
(trộm 4 + 5 = 9)
DP trên cây — mỗi node trả về 2 giá
trị: - rob_this = max tiền nếu
trộm node này (con không trộm). -
skip_this = max tiền nếu không trộm node
này (con tuỳ ý).
Quan hệ: - rob_this = node.val + left.skip + right.skip
-
skip_this = max(left.rob, left.skip) + max(right.rob, right.skip)
Đáp án = max(root.rob, root.skip).
Hình minh hoạ:
3
/ \
2 3
\ \
3 1
DFS bottom-up:
node 3 (lá phải-phải): rob=3, skip=0
node 3 (lá phải-trái): rob=3, skip=0
node 1 (lá phải-phải-right): rob=1, skip=0
node 2: rob = 2 + 0 (no left) + 0 (3 skip) = 2
skip = 0 + max(3, 0) = 3
node 3 (right): rob = 3 + max(0, 0)(no left) + 0 = 3
skip = 0 + max(1, 0) = 1
node 3 (root): rob = 3 + 3 (no rob của 2) + 1 (no rob của 3-right) = 7
skip = max(2,3) + max(3,1) = 3 + 3 = 6
Đáp án: max(7, 6) = 7
from typing import Optional, Tuple
class Solution:
def rob(self, root: Optional["TreeNode"]) -> int:
def dfs(node) -> Tuple[int, int]:
"""Trả về (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). Bộ
nhớ: O(h) stack.dfs(node, robbed_parent: bool)? Hoàn toàn được,
nhưng cần 2n state. Pattern “trả tuple 2 giá trị” gọn hơn
và tránh dùng @cache (vốn không hash được
TreeNode mặc định).Cho root của binary tree (không phải BST) và 2 node
p, q. Tìm lowest common ancestor
(LCA) — node thấp nhất có cả p và q
trong subtree của nó.
Input: root = [3, 5, 1, 6, 2, 0, 8, null, null, 7, 4]
Cây thực tế:
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
Input: p = 5, q = 1 → Output: 3
Input: p = 5, q = 4 → Output: 5 (5 là tổ tiên của chính nó)
Insight tinh tế: Tại mỗi node: - Nếu
node == p hoặc node == q → trả về
node luôn. - Đệ quy trên left và
right. - Nếu cả 2 đệ quy đều trả về non-None →
node là LCA. - Nếu chỉ 1 cái trả non-None → trả cái đó
(nghĩa là cả p và q đều ở 1 bên).
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 và q ở 2 nhánh → root là LCA
return left if left else rightO(n). Bộ
nhớ: O(h) stack.root is p (so sánh reference)
thay vì root.val == p.val — nếu cây có duplicate values,
val-comparison sẽ sai.O(h) ngắn gọn.parent → giống Intersection of LL.| Traversal | Khi nào dùng |
|---|---|
| Pre-order (root → L → R) | Serialize, clone, copy |
| In-order (L → root → R) | BST sorted output, kth smallest |
| Post-order (L → R → root) | Tổng hợp từ con (tree DP, diameter) |
| Level-order (BFS) | Theo tầng, distance |
def dfs(node):
if not node: return base
L = dfs(node.left)
R = dfs(node.right)
# combine L, R với node.val → ans cho subtree này
# CẬP NHẬT đáp số toàn cục nếu cần
return result_to_pass_up
left.val < root.val < right.val.(lo, hi); mỗi node phải nằm trong
(lo, hi). Sang trái cập nhật hi = node.val,
sang phải cập nhật lo = node.val.(rob, skip) traceCây:
3
/ \
2 3
\ \
3 1
Post-order trả về (rob_this, skip_this): - Lá
3 (left của 2): (3, 0). - Lá 1
(right của 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.
| Loại cây | Cách |
|---|---|
| Binary tree thường | Đệ quy bottom-up, trả node nếu chứa p hoặc q (LC 236) |
| BST | So sánh value với root, đi 1 nhánh (LC 235) — O(log n) |
| Có parent pointer | Hash các tổ tiên của p, đi từ q lên |
Grid 2D thực ra là graph ngầm: mỗi ô là 1 node, 4 ô kề (lên/xuống/trái/ phải) là cạnh. Mọi bài “đảo” / “tô màu vùng” / “flood fill” đều là DFS/BFS trên graph này. Chương này dạy bạn 4 trick đặc trưng cho grid: (1) flood fill, (2) multi-source BFS từ biên, (3) reverse thinking (đánh dấu cái không cần), (4) mutate input để mark visited.
Sau chương này, bạn sẽ:
grid: List[List[T]] với cell có 2-3 trạng
thái.from collections import deque
from typing import List
DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
# 1) Flood fill DFS (đệ quy)
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 từ tất cả biên hoặc tất cả ô đặc biệt
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 visitedCho grid m × n với '1' = đất,
'0' = nước. Đảo là vùng đất liên thông (4
hướng). Đếm số đảo.
Input:
[["1","1","1","1","0"],
["1","1","0","1","0"],
["1","1","0","0","0"],
["0","0","0","0","0"]]
Output: 1 (cả vùng "1" liên thông)
Input:
[["1","1","0","0","0"],
["1","1","0","0","0"],
["0","0","1","0","0"],
["0","0","0","1","1"]]
Output: 3 (góc trái-trên, giữa, góc phải-dưới)
1 <= m, n <= 300Pattern flood fill kinh điển. Duyệt mỗi ô: - Nếu là
'1' và chưa visited → tăng counter, gọi DFS/BFS đánh dấu
toàn bộ đảo này thành visited (đổi '1' → '0'
để khỏi dùng set riêng).
Hình minh hoạ với grid 4×5 thứ 2:
Bước 1, ô (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]
(đảo 1 đánh dấu)
Bước 2, gặp ô (2,2) = '1' → DFS (chỉ đánh dấu chính nó):
[* * 0 0 0]
[* * 0 0 0]
[0 0 * 0 0]
[0 0 0 1 1]
Bước 3, gặp ô (3,3) = '1' → DFS:
[* * 0 0 0]
[* * 0 0 0]
[0 0 * 0 0]
[0 0 0 * *]
Đếm: 3 đảo
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). Bộ
nhớ: O(m · n) worst-case stack với grid toàn
‘1’.RecursionError. Fallback: BFS bằng
deque.Cùng grid với 0/1. Trả về diện tích lớn nhất của một
đảo (số ô ‘1’ của nó), hoặc 0 nếu không có đảo nào.
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
Variant của bài 12.1, nhưng DFS trả về size thay vì
void. Lấy max qua các lần khởi động DFS.
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). Bộ
nhớ: O(m · n) worst stack.1 + sum(...) rất Pythonic. Mỗi
hàng xóm đệ quy trả về số ô trong phần “đảo nối từ nó”, cộng
1 cho chính ô đang DFS.0 → 1, tìm đảo
lớn nhất sau khi đổi. Tăng độ phức tạp đáng kể: phải label từng đảo
trước.Cho grid chứa 'X' và 'O'.
Lật mọi vùng 'O' được bao quanh
hoàn toàn bởi 'X' (vùng không chạm biên grid)
thành 'X'. Vùng 'O' chạm biên được giữ
nguyên.
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; 'O' ở (3,1) chạm biên dưới → giữ;
cụm 'O' bên trong bị bao quanh → lật thành 'X')
1 <= m, n <= 200O nhỏ nhất là 1 ô? → Có.Reverse thinking — đây là pattern cực hay: - Thay vì
tìm vùng 'O' bị bao, ta tìm vùng 'O'
chạm biên (rất dễ). - Đánh dấu chúng (vd. đổi tạm thành
'#'). - Cuối cùng: - '#' → 'O'
(giữ). - 'O' còn lại → 'X' (lật).
Hình minh hoạ:
Grid ban đầu: Sau DFS từ các 'O' biên (đánh dấu '#'):
X X X X X X X X
X O O X X O O X (O ở (1,1),(1,2) KHÔNG chạm biên,
X X O X → X X O X không bị mark)
X O X X X # X X (O ở (3,1) chạm biên → mark)
Quét cuối:
'#' → '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 từ mọi 'O' ở biên — mark thành '#'.
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. Lật: '#' → 'O' (giữ); 'O' → 'X' (lật).
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). Bộ
nhớ: O(m · n) stack worst-case.O có bị bao không đòi hỏi duyệt cả vùng và kiểm
tra mọi biên đảo — phức tạp. Ngược lại, có chạm biên không là 1
query khi đã đánh dấu xong.Cho ma trận chiều cao heights[i][j] đại diện chiều cao
đảo. Mép trái và mép trên giáp Thái Bình Dương; mép
phải và mép dưới giáp Đại Tây Dương. Nước chảy từ ô
(r, c) sang ô kề có chiều cao ≤
(r, c).
Trả về tất cả (r, c) mà nước từ đó có thể chảy ra
cả 2 đại dương.
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. Với mỗi ô, BFS xem chảy được
ra biên nào. Worst-case O((mn)²).
Reverse thinking — O(m · n).
Thay vì hỏi “ô nào chảy được ra biển?”, ta hỏi “biển có thể vươn lên tới ô nào?”. Biển vươn lên cao theo quy tắc: chỉ vào ô có chiều cao ≥ ô hiện tại.
Hình minh hoạ — Pacific reach (P) và Atlantic reach (A) trên ma trận 5x5:
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
Giao P ∩ A = các ô có cả 2 → đáp án.
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: # biển không vươn được vào ô thấp hơn
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: biên trái + biên trên.
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: biên phải + biên dưới.
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) — mỗi ô được thăm
tối đa 2 lần (1 cho mỗi đại dương).O(m · n).>=, không phải <=).Cho rooms: ma trận m × n các số nguyên với
3 giá trị có ý nghĩa: - -1 = tường (cản đường). -
0 = cổng (gate). - INF (= 2³¹ - 1) = phòng
trống.
Điền vào mỗi phòng trống khoảng cách ngắn nhất (số
bước 4 hướng) đến cổng gần nhất. Nếu phòng không đến được cổng nào, giữ
INF.
Input: rooms = [[INF, -1, 0, INF],
[INF,INF,INF, -1],
[INF, -1,INF, -1],
[ 0, -1,INF,INF]]
(-1 = tường, 0 = cổng, INF = phòng trống)
Output: rooms = [[3, -1, 0, 1],
[2, 2, 1,-1],
[1, -1, 2,-1],
[0, -1, 3, 4]]
(mutate in-place; mỗi ô = khoảng cách 4 hướng tới cổng gần nhất)
INF.Multi-source BFS — đẩy tất cả các cổng vào queue cùng lúc. Lan toả ra ngoài, mỗi ô đặt distance = số bước từ cổng gần nhất.
Vì sao multi-source > BFS từng cổng? Multi-source
O(mn), BFS từng cổngO(gates · mn)—gatescó thểO(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
# Chỉ "ghi" vào ô INF — vì BFS, lần đầu ghi đã là 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). Bộ
nhớ: O(m · n).rooms[nr][nc] == INF
→ ghi đè vào 0 (cổng khác) hoặc -1
(tường).Cho ma trận mat chỉ chứa 0 và
1. Trả về ma trận cùng kích thước, mỗi ô = khoảng
cách (số bước 4 hướng) đến số 0 gần nhất.
Input: mat = [[0,0,0],
[0,1,0],
[1,1,1]]
Output: [[0,0,0],
[0,1,0],
[1,2,1]]
(mỗi ô = khoảng cách Manhattan tới ô '0' gần nhất)
Y hệt Walls and Gates: multi-source BFS từ tất cả ô
0. Distance ban đầu của ô 0 = 0,
các ô 1 chưa biết.
Cách khác — DP 2 lượt, O(m · n): - Lượt
1 (trên-trái → dưới-phải):
dp[r][c] = min(dp[r-1][c], dp[r][c-1]) + 1. - Lượt 2
(dưới-phải → trên-trái):
dp[r][c] = min(dp[r][c], dp[r+1][c]+1, dp[r][c+1]+1).
Cả 2 đều O(m · n). BFS trực quan hơn; DP gọn space.
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:
"""Cách DP 2 lượt — gọn space (in-place khi cho phép)."""
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)]
# Lượt 1: trên-trái → dưới-phải.
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
# Lượt 2: dưới-phải → trên-trái.
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). Bộ
nhớ: O(m · n).INF ở các ô ngoài).dirs = [(-1,0),(1,0),(0,-1),(0,1)] (4-conn) hoặc
8-conn.0 <= nr < R and 0 <= nc < C.'1' → '0'
hoặc #) hay set?visited 2D.Cho bài Surrounded Regions (LC 130) và Pacific Atlantic (LC 417): - “Cell không thoả điều kiện” = cell kết nối với biên. - Seed BFS/DFS từ biên, đánh dấu cell reach được; cell còn lại là cell bị bao quanh.
Cho 01 Matrix (LC 542) và Walls and
Gates (LC 286): - Bỏ tất cả nguồn vào queue
ban đầu (cell 0 cho 542, cổng 0 cho 286). -
BFS level → distance lan ra. Mỗi cell được visit một
lần ⇒ O(R·C).
| Tiêu chí | In-place | Set/2D bool |
|---|---|---|
| Bộ nhớ phụ | O(1) | O(R·C) |
| Mutate input? | Có | Không |
| Concurrency/restore | Khó | Dễ |
| Ưu tiên | Khi cho phép & cần O(1) extra | Khi grid immutable hoặc cần re-run |
Topological Sort sắp xếp đỉnh của DAG (Directed Acyclic Graph) sao cho mọi cạnh
u → vthìuđứng trướcvtrong thứ tự. Đây là pattern bắt buộc cho mọi bài “hoàn thành theo thứ tự phụ thuộc”: build system, task scheduler, course prerequisites, …
Sau chương này, bạn sẽ:
a before b → cạnh
a → b (tích luỹ indegree của b).len(order) != V → có chu
trình.Một trong những lỗi phổ biến nhất khi giải topo sort là vẽ cạnh sai chiều. Vì lý do này, toàn bộ sách dùng một quy ước duy nhất:
Mô tả thực tế Cạnh trong 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]
tức là [b, a] dạng "to do b, must do a"
→ cạnh a → b (prereq → course)
─────────────────────────────────────────────────────────────
LC 269 Alien Dict: words[i] < words[i+1] theo lex
→ ký tự khác nhau đầu tiên: c1 < c2
→ cạnh c1 → c2
Kahn’s invariant: Pop node có
indeg == 0 ↔︎ “không còn ai phải xong trước nó”.
Mọi bài Topo trong cuốn sách dùng convention
u → v nghĩa là u xong trước v. Khi gặp đề
có wording khác, bước đầu tiên nên là vẽ 2–3 cạnh ra
giấy để kiểm tra rằng hướng cạnh trong code khớp đúng với mô tả
của đề bài.
2 thuật toán kinh điển:
indegree,
đẩy node có indeg=0 vào queue.Cả 2 đều O(V + E). Mặc định mình dùng Kahn vì nó dễ
extend cho “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 [] # rỗng = có chu trìnhCho numCourses khoá và
prerequisites[i] = [a, b] (học a cần
b xong trước). Trả về thứ tự học hợp lệ,
hoặc [] nếu có chu trình.
Input: numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]
Output: [0, 1, 2, 3] (hoặc [0, 2, 1, 3])
Giải thích:
Cạnh: 0 → 1, 0 → 2, 1 → 3, 2 → 3.
Kahn’s algorithm — y hệt template. Khi pop node,
push vào order. Cuối: nếu
len(order) == numCourses → trả order; ngược
lại có chu trình.
Hình minh hoạ với
[[1,0],[2,0],[3,1],[3,2]]:
Graph: 0
/ \
1 2
\ /
3
indeg ban đầu: [0, 1, 1, 2]
queue = [0] (indeg 0)
Pop 0 → order=[0]
giảm indeg[1], indeg[2] → [_, 0, 0, 2]
queue = [1, 2]
Pop 1 → order=[0, 1]
giảm indeg[3] → [_, _, _, 1]
Pop 2 → order=[0, 1, 2]
giảm indeg[3] → [_, _, _, 0]
queue = [3]
Pop 3 → order=[0, 1, 2, 3]
len(order)=4=numCourses → trả [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). Bộ
nhớ: O(V + E).order.append(node). LC 207 chỉ check có DAG; LC 210 trả thứ
tự cụ thể.a cần
b” → b → a. Vẽ rõ trước khi code.Một ngôn ngữ ngoài hành tinh dùng chữ cái Latin nhưng thứ tự khác.
Cho danh sách words đã sắp xếp theo thứ tự đó. Hãy tìm 1
thứ tự chữ cái hợp lệ (string các chữ cái). Trả "" nếu mâu
thuẫn.
Input: words = ["wrt","wrf","er","ett","rftt"]
(mảng từ đã sort theo thứ tự alphabet của ngôn ngữ ngoài hành tinh)
Output: "wertf" (1 thứ tự chữ cái hợp lệ; có thể có nhiều đáp án)
Giải thích:
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: "" (mâu thuẫn cyclic: z<x từ cặp 1 nhưng x<z từ cặp 2)
2 bước:
(words[i], words[i+1]) liền kề:
words[i] < ký tự ở words[i+1].words[i] là tiền tố của
words[i+1] thì OK, nhưng nếu words[i+1] là
tiền tố thực sự của words[i] (vd
["abc", "ab"]) → mâu thuẫn → trả về "".Hình minh hoạ với
["wrt","wrf","er","ett","rftt"]:
Cặp 1: wrt vs wrf
vị trí khác đầu: index 2 → t < f
→ cạnh t → f
Cặp 2: wrf vs er
vị trí khác đầu: index 0 → w < e
→ cạnh w → e
Cặp 3: er vs ett
vị trí khác đầu: index 1 → r < t
→ cạnh r → t
Cặp 4: ett vs rftt
vị trí khác đầu: index 0 → e < r
→ cạnh 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:
# Khởi tạo indeg cho tất cả ký tự xuất hiện.
indeg = {ch: 0 for w in words for ch in w}
graph = defaultdict(set)
# Trích quan hệ từ các cặp kề nhau.
for i in range(len(words) - 1):
w1, w2 = words[i], words[i + 1]
# Edge case mâu thuẫn tiền tố.
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) với C =
tổng số ký tự.O(1) (bảng chữ ≤ 26 — gần như
constant).indeg cho tất cả ký tự
xuất hiện (kể cả không có edge in) — nếu không, vòng final check
len(order) == len(indeg) sai.indeg — tăng
đúp.len(w1) > len(w2) and w1.startswith(w2) → tạo cấu trúc
invalid.Input: n (số đỉnh) và
edges: List[List[int]] — danh sách n-1 cạnh
[u, v] mô tả tree vô hướng. Đỉnh đánh số
0..n-1.
Tìm tất cả root có thể chọn để chiều cao tree là min. Trả về danh sách root đó (có thể có 1 hoặc 2).
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: Centroid của tree (gồm 1 hoặc 2 node) tối thiểu hoá chiều cao. Cách tìm: BFS từ các lá, “gọt vỏ” dần.
Quy trình: 1. Xây graph + tính degree.
2. Đẩy mọi lá (degree == 1) vào queue. 3. Lặp: pop một lớp
lá, giảm degree hàng xóm, lá mới (degree == 1) → queue. 4.
Khi còn ≤ 2 node → đó là centroid(s).
Hình minh hoạ:
Lớp đầu — lá: [0, 1, 2, 5]
Gọt → còn lại: [3, 4]
3 (degree=1 sau gọt), 4 (degree=1 sau gọt)
→ ≤ 2 node → 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])) # lá có đúng 1 hàng xóm
graph[nb].remove(leaf)
if len(graph[nb]) == 1:
leaves.append(nb)
return list(leaves)O(V + E) = O(n).O(n).n == 1 → graph rỗng,
không có lá.Cho n items, mỗi item thuộc 1 group
(group[i] = -1 nếu chưa thuộc nhóm nào, sẽ phân nhóm
riêng). Cho beforeItems[i] = các item phải làm trước item
i. Hãy sắp xếp items sao cho: - Tôn trọng
beforeItems. - Các item cùng group đứng liền
nhau.
Trả [] nếu không khả thi.
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]
Hai lần topo sort: 1. Sort các
group với nhau (item-level edge i → j mà
khác group → group-edge). 2. Trong mỗi group, sort các item của nó. 3.
Concat kết quả: dùng order group, mỗi group ghi tất cả item của nó theo
order item.
Pre-processing: mọi item có
group[i] == -1 → gán group mới riêng để tránh “không nhóm”
ảnh hưởng.
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]:
# Gán group riêng cho item -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 []
# Gom theo group theo thứ tự group, các item trong group giữ thứ tự 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 →
nhiều item rời rạc nhập làm 1 nhóm sai.Cho nums (1 hoán vị của 1..n) và
sequences (list các sub-sequence). Hãy kiểm tra
nums có phải là topological order duy nhất
suy ra từ sequences hay không.
Input: nums = [1, 2, 3], sequences = [[1,2],[1,3]]
Output: False
Giải thích: từ [1,2] và [1,3] → có thể là [1,2,3] hoặc [1,3,2] → không unique.
Input: nums = [1, 2, 3], sequences = [[1,2],[1,3],[2,3]]
Output: True
Chạy Kahn’s. Để unique, mỗi level chỉ có
đúng 1 node indeg == 0 — nếu có ≥ 2 ⇒ có nhiều
topo order ⇒ False. Đồng thời thứ tự pop phải khớp
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 lựa chọn → không unique
x = queue.popleft()
if nums[idx] != x:
return False # khác thứ tự nums
idx += 1
for nb in graph[x]:
indeg[nb] -= 1
if indeg[nb] == 0:
queue.append(nb)
return idx == nO(V + E). Bộ
nhớ: O(V + E).len(queue) > 1 là cái twist
của bài — uniqueness của topo order.set cho graph.Cho n khoá học và relations [a, b] (học
a xong rồi học b). Mỗi semester bạn có thể học
bất kỳ số khoá miễn đã hoàn thành prerequisite. Trả về
số semester tối thiểu để học hết, hoặc -1
nếu có chu trình.
Input: n=3, relations=[[1,3],[2,3]]
Output: 2
Giải thích: Semester 1 học [1,2], semester 2 học [3]
Kahn’s BFS, nhưng đếm theo level (semester). Mỗi vòng outer of BFS xử lý toàn bộ queue hiện tại = các khoá có thể học cùng 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). Bộ
nhớ: O(V + E).n; nếu chu trình →
một số node không bao giờ rơi về indeg == 0.dp[node] chỉ dựa trên dp[predecessors].indegree == 0, ta có thể chọn nhiều cách → không
duy nhất.Nếu w_i là prefix của
w_{i-1} (vd ["abc", "ab"]), từ điển
không hợp lệ → return "". Kiểm tra
TRƯỚC khi build edges (đừng quên break đúng chỗ).
items 5,6 ∈ groupA items 7,8 ∈ groupB items 9 ∈ -1 (riêng)
Item DAG: 5 → 6, 7 → 8, 6 → 7 (intra + cross-group)
Group DAG: A → B (vì 6 → 7 mà 6 ∈ A, 7 ∈ B)
→ Topo group order → trong mỗi group topo item order.
Interval (khoảng
[start, end]) là pattern bao trùm nhiều bài calendar/scheduling/booking quan trọng. Hai chương 4 (Sorting) đã chạm qua Merge Intervals và Meeting Rooms II; chương này xoáy sâu vào 8 mẫu thao tác trên interval (merge, insert, intersection, overlap, free time) — đây là pattern không thể tránh khi phỏng vấn các công ty lịch (Google Calendar) và đặt phòng (Airbnb, Booking).
Sau chương này, bạn sẽ:
[s, e] (closed) vs
[s, e) (half-open) ảnh hưởng < vs
<=.start cho merge; sort theo end
cho greedy chọn nhiều nhất.(time, +1/-1) → đếm overlap
tối đa.[start, end] (booking, meeting,
video segment, …).4 thao tác chuẩn trên 2 interval A = [a₁, a₂],
B = [b₁, b₂]:
1. Tách rời (disjoint): A.end < B.start → A trước B, không giao
2. Chạm điểm (touch): A.end == B.start → liền kề, có thể merge tuỳ đề
3. Giao một phần: A.start < B.start ≤ A.end < B.end
4. Chứa nhau (contain): A.start ≤ B.start ≤ B.end ≤ A.end
from typing import List
# 1) Merge 2 interval có giao nhau
def merge_two(a, b):
return [min(a[0], b[0]), max(a[1], b[1])]
# 2) Check overlap (kể cả chỉ chạm điểm)
def overlaps(a, b) -> bool:
return a[0] <= b[1] and b[0] <= a[1]
# 3) Sweep line: cùng pattern cho mọi bài "đếm overlap tối đa"
events: List[tuple[int, int]] = []
for s, e in intervals:
events.append((s, +1)) # mở
events.append((e, -1)) # đóng
events.sort()
cur = peak = 0
for _, delta in events:
cur += delta
peak = max(peak, cur)Đã giải đầy đủ ở Chương 4.2 dưới góc Sorting. Ở đây mình tóm tắt nhanh dưới lens “interval” và mở rộng follow-up.
Gộp các khoảng giao nhau. [1,3] và [2,6] →
[1,6].
Sort theo start. Duyệt 1 lượt, giữ last =
interval cuối đã thêm vào kết quả. Nếu
cur.start <= last.end →
last.end = max(last.end, cur.end); ngược lại push
cur mới.
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
dominate).O(n) output.Cho mảng intervals đã sort theo
start và không giao nhau. Chèn newInterval vào
và merge nếu cần.
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^4Cách 1 — O(n) duyệt 1 lượt, 3 giai
đoạn.
newInterval: đẩy hết các
interval có end < newInterval.start.start <= newInterval.end, mở rộng
newInterval (start = min,
end = max). Cuối giai đoạn, push
newInterval.newInterval: đẩy phần còn
lại.Cách 2 — Concat + merge (gọi lại bài 14.1). Đơn giản
nhưng O(n log n) cho sort thừa.
Hình minh hoạ với
intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]],
new = [4,8]:
Trục số:
1 3 5 6 7 8 10 12 16
├─┤ ├───┤ ├─┤ ├──┤ ├──────┤
├──────────┤ new = [4, 8]
Giai đoạn 1 (end < 4): [1, 2]
result = [[1,2]]
Giai đoạn 2 (start <= 8):
[3, 5]: mở rộng newInterval = [min(4,3), max(8,5)] = [3, 8]
[6, 7]: mở rộng = [3, 8]
[8, 10]: mở rộng = [3, 10]
Push [3, 10]
result = [[1,2], [3,10]]
Giai đoạn 3: còn [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) Trước newInterval.
while i < n and intervals[i][1] < newInterval[0]:
result.append(intervals[i])
i += 1
# 2) Giao nhau — mở rộng 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) Sau newInterval.
while i < n:
result.append(intervals[i])
i += 1
return resultO(n). Bộ
nhớ: O(n) cho output.O(n). Nếu
input không sort, sort trước rồi gọi bài 14.1 —
O(n log n).< vs <=: điều
kiện giao là intervals[i][0] <= newInterval[1]. Dấu
= ở đây quan trọng — đề LC 56/57 coi 2 interval chạm điểm
là giao.Cho mảng intervals. Trả về số interval tối
thiểu cần xoá để các interval còn lại không giao nhau.
Input: intervals = [[1,2],[2,3],[3,4],[1,3]]
Output: 1
Giải thích: xoá [1,3] → còn [1,2],[2,3],[3,4] không giao nhau.
Input: intervals = [[1,2],[1,2],[1,2]]
Output: 2
Input: intervals = [[1,2],[2,3]]
Output: 0 (đã không giao, không cần xoá)
(mỗi phần tử [start, end] biểu diễn khoảng nửa-mở [start, end))
Greedy — sort theo end tăng dần. Giữ
interval có end nhỏ nhất → giữ được càng nhiều “không gian” về sau cho
các interval tiếp theo.
Pseudocode: - Sort theo end. - Giữ
last_end = -∞. Với mỗi interval [s, e]: - Nếu
s >= last_end → giữ (không overlap),
last_end = e. - Ngược lại → đếm xoá.
Tại sao sort theo end, không phải
start? Greedy hoạt động vì: “luôn chọn interval có
end sớm nhất” cho phép phần còn lại có nhiều “free time”
hơn — chứng minh quy nạp.
Hình minh hoạ với
[[1,2],[2,3],[3,4],[1,3]]:
Sort theo end: [[1,2], [2,3], [1,3], [3,4]]
end=2 end=3 end=3 end=4
Duyệt:
[1,2]: start=1 >= -inf → giữ; last_end=2 giữ: 1
[2,3]: start=2 >= 2 → giữ; last_end=3 giữ: 2
[1,3]: start=1 < 3 → xoá xoá: 1
[3,4]: start=3 >= 3 → giữ; last_end=4 giữ: 3
Giữ 3, xoá 1 → đáp án = 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). Bộ
nhớ: O(1) hoặc O(n) cho sort.start có
thể work nếu xử lý cẩn thận (giữ interval có end nhỏ hơn khi có
conflict). Nhưng sort theo end là cách đơn giản
nhất.Đã giải đầy đủ ở Chương 4.4. Ở đây chỉ tóm tắt và liên hệ.
Tìm số phòng tối thiểu để chứa tất cả meeting.
3 cách (heap, sweep line events, chronological 2-pointer) — tất cả
đều O(n log n). Sweep line là pattern
interval ngôn ngữ chuẩn.
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).Cho mảng các balloon [x_start, x_end] (mỗi balloon là 1
interval trên trục x). Một mũi tên bắn thẳng đứng tại x = X
sẽ làm nổ tất cả balloon có
x_start <= X <= x_end. Tìm số mũi tên tối
thiểu để nổ hết.
Input: points = [[10,16],[2,8],[1,6],[7,12]]
(mỗi phần tử [xstart, xend] biểu diễn 1 quả bóng nằm trong khoảng đóng [xstart, xend])
Output: 2 (cần ít nhất 2 mũi tên: bắn x=6 nổ [1,6] và [2,8]; bắn x=11 nổ [7,12] và [10,16])
Giải thích:
1 mũi tại x = 6 nổ [1,6] và [2,8].
1 mũi tại x = 11 nổ [7,12] và [10,16].
Tương đương bài 14.3 (Non-overlapping Intervals): mỗi mũi tên ứng với 1 nhóm balloon có giao chung. Đếm nhóm = số mũi tên.
Greedy sort theo end y hệt 14.3: - Sort
balloons theo end. - Giữ last_end = -∞. Với
mỗi balloon [s, e]: - Nếu s > last_end →
cần mũi mới; last_end = e. - Ngược lại → balloon này được
nổ chung với mũi hiện tại.
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: # không giao → cần mũi mới
arrows += 1
last_end = e
return arrowsO(n log n). Bộ
nhớ: O(1).>= (chạm điểm vẫn tính overlap), bài này dùng
> (chạm điểm bị nổ chung). Đề LC 452 nói rõ “chạm là
nổ”.Cho schedule[i] = danh sách interval đại diện thời gian
bận của employee i. Trả về tất cả interval
free chung cho tất cả employee, sắp xếp tăng dần.
(Không tính khoảng trước người đầu tiên bận và sau người cuối kết
thúc.)
Input: schedule = [[[1,2],[5,6]],[[1,3]],[[4,10]]]
Output: [[3, 4]]
Giải thích:
Hợp các bận: [1,3] (gồm [1,2] + [1,3]), [4,10] (gồm [5,6] + [4,10]).
Free chung giữa các khoảng bận: [3, 4].
Bước 1: Gộp tất cả interval bận thành 1 list không phụ thuộc employee. Bước 2: Sort theo start, merge (như bài 14.1). Bước 3: Kẽ hở giữa các 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) với
N = tổng số interval.O(N).schedule[i] mỗi cái đã sort). Lấy interval bận sớm nhất,
merge. Khi gặp gap → free time. O(N log K) với K = số
employee.[s, e] hay nửa mở
[s, e)?
[1,3] và
[3,5] được coi là chạm nhau ⇒ merge.[1,3) và [3,5) không đè.start (Merge, Insert) hay
sort theo end (Greedy, Min Arrows)?t:
x, nhưng cẩn thận height.e1: |==1==| |==3==|
e2: |==2==| |==4==|
sort all → merge ⇒ busy: [1∪2] [3∪4]
free = complement giữa các busy block
Heap (Priority Queue) trả lời nhanh câu hỏi “phần tử lớn/nhỏ nhất bây giờ là gì?”. Hai thao tác cốt lõi
pushvàpopđềuO(log n). Heap giải gọn các bài: top-k, median trên data stream, merge k lists, scheduling. Pattern phổ biến nhất: heap kích thước k để duy trì k phần tử tốt nhất.
Sau chương này, bạn sẽ:
O(n log k).-x (vì
heapq là min-heap).k list đã sort).Python heapq chỉ có min-heap. Muốn
max-heap → push -x.
import heapq
# 1) Min-heap cơ bản
heap: list[int] = []
heapq.heappush(heap, x)
top = heap[0] # peek (không pop)
val = heapq.heappop(heap)
# 2) Max-heap bằng cách negate
heapq.heappush(heap, -x)
val = -heapq.heappop(heap)
# 3) K phần tử nhỏ nhất / lớn nhất từ list
smallest_k = heapq.nsmallest(k, arr) # O(n log k)
largest_k = heapq.nlargest(k, arr)
# 4) Heapify O(n) — nhanh hơn n lần push
heapq.heapify(arr)
# 5) Heap với key tuỳ ý — push tuple (priority, payload)
heapq.heappush(heap, (priority, payload))Cho mảng nums và số k. Trả về phần tử
lớn thứ k (1-indexed, theo thứ tự giảm
dần). Có duplicate.
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
Cách 1 — Sort, O(n log n).
sorted(nums)[-k]. Đơn giản nhất.
Cách 2 — Min-heap kích thước k,
O(n log k).
Duyệt mảng, đẩy vào heap. Khi heap > k → pop. Cuối cùng
heap[0] là Kth largest.
Cách 3 — Quickselect, O(n) trung
bình.
Variant của quicksort: partition quanh pivot, recurse chỉ vào nửa chứa Kth.
import heapq
from typing import List
class Solution:
"""Cách 2 — heap kích thước k."""
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:
"""Cách 3 — quickselect, O(n) trung bình."""
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| Cách | Time | Space |
|---|---|---|
| Sort | O(n log n) |
O(1) |
| Heap size k | O(n log k) |
O(k) |
| Quickselect | O(n) avg, O(n²) worst |
O(1) |
k
trước (gọn và hiệu quả khi k nhỏ), sau đó nhắc đến
quickselect nếu interviewer hỏi “có cách O(n) không?”.Cho mảng words và số k. Trả về
k từ thường gặp nhất. Nếu hoà tần suất, từ
nào lexicographically smaller ưu tiên.
Input: words = ["i", "love", "leetcode", "i", "love", "coding"], k = 2
Output: ["i", "love"]
Giải thích: "i" và "love" cùng tần suất 2. "i" < "love" trong từ điển.
Input: k = 4
Output: ["the", "is", "sunny", "day"] (tie → sort lex)
Mấu chốt: comparator tie-break — tần suất cao hơn ưu tiên, nếu hoà thì từ lex nhỏ hơn ưu tiên.
Cách 1 — Sort, O(n log n).
sorted(cnt.keys(), key=lambda w: (-cnt[w], w))[:k].
Cách 2 — Min-heap kích thước k,
O(n log k).
Heap chứa tuple (freq, word). Vì heap min, ta muốn: -
Tần suất thấp ở top (pop trước) → push thẳng freq. - Hoà →
từ lex lớn hơn ở top (pop trước) → push
word.
Trick: với từng tuple, ta dùng (-freq, word) rồi sort…
không, dùng custom wrapper cũng được. Cách 1 đơn giản hơn nhiều.
import heapq
from collections import Counter
from typing import List
class Solution:
"""Cách 1 — sort. Sạch nhất cho bài này."""
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:
"""Cách 2 — min-heap kích thước k."""
def topKFrequent(self, words: List[str], k: int) -> List[str]:
cnt = Counter(words)
# Trong min-heap, muốn pop ra cái "thua kém" trước.
# "Thua kém" = freq thấp hơn HOẶC (freq bằng + word lex lớn hơn).
# Dùng tuple (freq, -word_chars) thì không tiện vì word là string.
# Cách: dùng 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 → freq nhỏ ở top
return self.word > other.word # tie → word lex LỚN ở 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 ra → ngược lại để có thứ tự đúng.
return [heapq.heappop(heap).word for _ in range(k)][::-1]O(n log n).
Heap: O(n log k).__lt__ mặc định, không cho key=).__lt__ tuỳ biến
rất hữu ích — dùng được cho mọi trường hợp comparator phức tạp trên
heap.Thiết kế class: - addNum(num): thêm num vào
data stream. - findMedian(): trả về median hiện tại.
Median của n số: phần tử giữa (sorted), hoặc trung bình
2 phần tử giữa nếu n chẵn.
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]
Giải thích:
MedianFinder() → khởi tạo, null
addNum(1) → null; stream = [1]
addNum(2) → null; stream = [1, 2]
findMedian() → 1.5 (mean của 2 phần tử giữa)
addNum(3) → null; stream = [1, 2, 3]
findMedian() → 2.0 (phần tử giữa của 3 số sorted)
Trick 2 heap: - low =
max-heap chứa nửa nhỏ (dưới median). -
high = min-heap chứa nửa lớn (trên
median).
Invariant: - Mọi phần tử trong low ≤
mọi phần tử trong high. -
len(low) == len(high) hoặc
len(low) == len(high) + 1.
addNum: 1. Push vào low
(max-heap → push -num). 2. Đẩy top của low
sang high (rebalance). 3. Nếu
len(high) > len(low) → đẩy top của high về
lại low.
findMedian: -
len(low) > len(high) → low[0] (negated). -
Bằng nhau → trung bình của 2 top.
Hình minh hoạ với stream 1, 2, 3:
addNum(1):
push low=-1. low=[-1]
move -low[0]=1 to high. low=[], high=[1]
len(high) > len(low) → move 1 back to low. low=[-1], high=[]
median: low[0]=-(-1)=1.0
addNum(2):
push -2 to low. low=[-1, -2] → heap-balanced [-2, -1] (max-heap → 2 on top)
Hmm cần check: max-heap đơn giản, top = max = 2.
move 2 to high. low=[-1], high=[2]
len(high) <= len(low) OK.
median: (low[0] + high[0]) / 2 = (1 + 2) / 2 = 1.5 ✓
addNum(3):
push -3 to low. After push: low contains -1 and -3.
Top of max-heap = 1 (smallest negated → 1).
move 1 to high. low=[-3] (i.e., values {3}), high=[1, 2]
Hmm, now high has 2 items, low has 1.
Wait that's wrong invariant. Let me redo.
Thực ra trick đúng:
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 vào “ranh giới” giữa 2 nửa.Cho mảng points, mỗi điểm [x, y]. Trả về
k điểm gần origin nhất (Euclidean
distance).
Input: points = [[1,3], [-2,2]], k = 1
Output: [[-2, 2]]
Giải thích: dist²(1,3) = 10, dist²(-2,2) = 8.
Cách heap size k — O(n log k). Max-heap
chứa k điểm gần nhất; khi vượt k, pop điểm xa nhất.
Lưu ý: dùng
dist²thay vìsqrt(dist)— tránh float, giữ thứ tự không đổi.
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). Bộ
nhớ: O(k).O(n) trung
bình (như bài 15.1), bằng cách partition theo dist².sqrt(d²) thừa tính
toán và mất chính xác — luôn so bằng d².Input: lists: List[Optional[ListNode]] — mảng
k head của k singly linked list đã
sort tăng dần. Gộp tất cả thành 1 linked list sort và trả về
head mới.
Input: lists = [1→4→5, 1→3→4, 2→6]
Output: 1→1→2→3→4→4→5→6
Cách 1 — Min-heap k-way merge,
O(N log k).
Heap chứa (val, idx, node) của head mỗi list. Pop cái
nhỏ nhất, push hàng tiếp theo từ cùng list. Lặp đến hết.
idxlà tiebreaker — Python không so sánhListNodeđược khi val bằng.
Cách 2 — Merge từng cặp (Divide & Conquer),
O(N log k).
Mergesort kiểu tournament: ghép cặp lists rồi merge, lặp lại. Cùng độ phức tạp.
import heapq
from typing import List, Optional
class ListNode:
def __init__(self, val=0, next=None):
self.val, self.next = val, next
class Solution:
"""Cách 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) với
N = tổng số node.O(k) cho heap.k rất nhỏ và muốn tránh dependency trên heap.idx tiebreaker →
TypeError: '<' not supported between instances of 'ListNode' and 'ListNode'
khi val trùng.Cho mảng tasks (chữ cái A-Z) và số n. Mỗi
đơn vị thời gian thực hiện 1 task hoặc idle. 2 task giống
nhau phải cách nhau ít nhất n đơn
vị. Trả về số đơn vị thời gian tối thiểu để hoàn thành
tất cả.
Input: tasks = ["A","A","A","B","B","B"], n = 2
Output: 8
Lịch: A → B → idle → A → B → idle → A → B
Cách 1 — Greedy + max-heap,
O(N log 26).
Mỗi tick: - Lấy task có freq cao nhất (heap top).
Decrement freq, đẩy vào “cooldown queue” (end_time, freq).
- Khi cooldown_queue.front().end_time <= now → pop và
đẩy lại vào heap.
Cách 2 — Công thức trực tiếp, O(N).
Cho f_max = freq cao nhất, count_max = số
task có freq này. Đáp án =
max(n_total, (f_max - 1) * (n + 1) + count_max).
Trực giác: Task có freq cao nhất tạo ra
f_max - 1 khoảng có độ dài n+1, cộng
count_max cuối ở “row cuối”.
from collections import Counter
from typing import List
class Solution:
"""Cách 2 — công thức O(N)."""
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:
"""Cách 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 # giảm freq 1 đơn vị
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).| Yêu cầu | Heap O(n log k) |
Sort O(n log n) |
Quickselect O(n) avg |
|---|---|---|---|
Top-k khi n lớn, k nhỏ |
✅ Tốt nhất | OK | ✅ Khi không cần thứ tự |
| Cần k phần tử đã sắp | ✅ | ✅ | Cần sort thêm sau |
| Streaming (data đến từng phần) | ✅ | ❌ | ❌ |
| Đảm bảo worst-case | ✅ | ✅ | ❌ (worst O(n²)) |
| Interview preference | Mặc định, dễ giải thích | Khi n nhỏ |
Khi push “linear time” |
max_heap (lo) min_heap (hi)
…,3,5,7,8 ← ← 9,10,12,…
top = 8 top = 9
|len(lo) - len(hi)| ≤ 1, mọi phần tử
lo ≤ mọi phần tử hi.n+1 lấy ra nhiều nhất n+1 task khác nhau.max((max_count - 1) * (n + 1) + ties, total_tasks). Đẹp
& nhanh hơn nhưng cần chứng minh.Sort theo (-freq, word): tần số giảm dần, từ điển tăng
dần.
Greedy = tham lam = mỗi bước chọn cái tốt nhất tại chỗ, hy vọng cộng dồn lại được kết quả tối ưu toàn cục. Pattern lừa dối ở chỗ: Greedy hợp lệ rất khó chứng minh. Trong phỏng vấn, bạn vừa phải đoán đúng “luật tham” vừa phải justify ngắn gọn vì sao nó tối ưu. Chương này dạy 6 bài kinh điển — học để có mẫu reasoning.
Sau chương này, bạn sẽ:
Trick chứng minh Greedy: 1. Exchange argument: giả sử có nghiệm tối ưu khác greedy, “hoán đổi” để biến nó thành greedy mà không xấu đi. 2. Cấu trúc matroid: ít gặp trong phỏng vấn nhưng đẹp về lý thuyết.
Khi Greedy sai → DP cứu: nếu hoán đổi không bảo toàn tối ưu, phải xét toàn cục → DP.
def greedy_template(items):
items.sort(key=...) # 90% bài greedy phải sort trước
result = 0
for x in items:
if local_condition(x):
result += x
# ... cập nhật state ...
return resultCho mảng nums, nums[i] = số bước tối đa bạn
có thể nhảy từ vị trí i. Bắt đầu ở i = 0. Trả
về True nếu có thể tới i = n - 1.
Input: nums = [2, 3, 1, 1, 4] → True
Giải thích: 0 → 1 → 4 hoặc 0 → 2 → 3 → 4
Input: nums = [3, 2, 1, 0, 4] → False
Giải thích: tới index 3 thì kẹt (nums[3]=0).
Brute force — DFS từ 0, O(2^n).
TLE.
Tối ưu — Greedy 1 lượt, O(n).
Duy trì farthest = vị trí xa nhất có thể tới đến bây
giờ. Tại mỗi i: - Nếu i > farthest → không
tới được i → return False. - Cập nhật
farthest = max(farthest, i + nums[i]). - Nếu
farthest >= n - 1 → return True.
Hình minh hoạ với [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). Bộ
nhớ: O(1).farthest >= n - 1 → có một chuỗi nhảy tới đích.
Nếu i > farthest → mọi vị trí ≤ i đều không tới
i → impossible.>= thay vì
> ở check i > farthest — chính xác
farthest là tới được (bao gồm).Cùng thiết lập như 16.1, nhưng giả sử luôn tới được
n - 1. Trả về số bước nhảy tối thiểu.
Input: nums = [2, 3, 1, 1, 4]
Output: 2
Giải thích: 0 → 1 → 4.
Input: nums = [2, 3, 0, 1, 4]
Output: 2
BFS theo lớp. Mỗi “level” của BFS = các vị trí có
thể tới sau k bước.
Implementation Greedy 1 lượt: -
current_end = ranh giới của level hiện tại. -
farthest = xa nhất tới được trong level hiện tại. - Khi
i == current_end (kết thúc level), bump
jumps += 1 và set current_end = farthest.
Hình minh hoạ với [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: ... (đã quá đủ, ta dừng khi current_end >= n-1)
Đáp án: 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). Bộ
nhớ: O(1).n - 1 (không inclusive) —
vì khi ở n-1 không cần nhảy nữa.current_end và farthest.Có n trạm xăng vòng tròn, trạm i có
gas[i] xăng. Đi từ trạm i tới i+1
tốn cost[i]. Tìm chỉ số trạm bắt đầu sao cho đi hết vòng
được, hoặc -1 nếu không thể.
Input: gas = [1,2,3,4,5], cost = [3,4,5,1,2]
Output: 3
Giải thích: bắt đầu từ trạm 3.
Input: gas = [2,3,4], cost = [3,4,3]
Output: -1
1 <= n <= 10^5Brute force — thử mỗi start,
O(n²). Có thể TLE.
Tối ưu Greedy — O(n).
Điều kiện cần & đủ:
sum(gas) >= sum(cost). Nếu vi phạm →
-1.
Khi điều kiện thoả, chỉ tồn tại đúng 1 nghiệm (nếu
mảng phân biệt). Tìm bằng: - Duyệt, giữ tank = balance hiện
tại. - Nếu tank < 0 tại trạm i → mọi
start ∈ [last_start..i] đều fail. Set
start = i + 1, reset tank = 0.
Hình minh hoạ với
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
tank tích luỹ:
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 → có nghiệm.
Đáp án: 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). Bộ
nhớ: O(1).s và đi đến i thì tank âm. Mọi
s' ∈ [s, i] cũng sẽ âm tại i (vì sum từ
s' đến i ≤ sum từ s đến
i). Vậy ta bỏ hết [s, i] và thử
i + 1.sum(gas) < sum(cost) — nếu không, code trả về
start vẫn không hợp lệ.Cho mảng g[] (greed factor của trẻ) và s[]
(size của cookie). Trẻ i hài lòng nếu nhận được cookie
j với s[j] >= g[i]. Mỗi trẻ tối đa 1
cookie, mỗi cookie tối đa 1 trẻ. Tìm max số trẻ hài
lòng.
Input: g = [1, 2, 3], s = [1, 1] → 1
Input: g = [1, 2], s = [1, 2, 3] → 2
Greedy kinh điển. Sort cả 2 mảng tăng dần. Two
pointers i (trẻ), j (cookie). Đi qua cookie từ
nhỏ đến lớn: nếu s[j] >= g[i] → trẻ i được
cookie → i++; j++ luôn.
Justification: Đưa cookie nhỏ nhất đủ thoả cho trẻ greed nhỏ nhất — không “phí” cookie lớn. Exchange argument: nếu nghiệm tối ưu khác greedy, có thể đổi cookie giữa 2 trẻ mà không xấu đi.
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).
Bộ nhớ: O(1).Cho chuỗi s. Chia s thành nhiều
phần tối đa sao cho mỗi ký tự chỉ xuất hiện trong đúng
1 phần. Trả về độ dài của các phần.
Input: s = "ababcbacadefegdehijhklij"
Output: [9, 7, 8]
Giải thích:
"ababcbaca" - chứa a, b, c.
"defegde" - chứa d, e, f, g.
"hijhklij" - chứa h, i, j, k, l.
Greedy với “last occurrence”:
last[ch] = chỉ số cuối của mỗi ký tự.s, giữ end = max(end, last[s[i]]).
i == end → kết thúc 1 phần.Hình minh hoạ với
"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 → phần 1: length 9 (0..8)
i=9 (d): end = 14
...
i=15 (e): end = 15, i == end → phần 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). Bộ
nhớ: O(1) (bảng chữ 26).end chính là last occurrence này.Cho mảng ratings. Phát kẹo cho n trẻ sao
cho: 1. Mỗi trẻ ít nhất 1 kẹo. 2. Trẻ có rating cao hơn người
hàng xóm (kề bên) thì được nhiều kẹo hơn.
Trả về số kẹo tối thiểu.
Input: ratings = [1, 0, 2] → 5 (kẹo: [2, 1, 2])
Input: ratings = [1, 2, 2] → 4 (kẹo: [1, 2, 1])
2 lượt qua mảng: - Lượt 1 (trái → phải): nếu
ratings[i] > ratings[i-1] →
candies[i] = candies[i-1] + 1. - Lượt 2 (phải → trái): nếu
ratings[i] > ratings[i+1] →
candies[i] = max(candies[i], candies[i+1] + 1).
Tổng candies = đáp án.
Hình minh hoạ với
ratings = [1, 0, 2]:
ratings: 1 0 2
Lượt 1 (L→R), khởi tạo candies = [1, 1, 1]:
i=1: r[1]=0 <= r[0]=1 → giữ 1
i=2: r[2]=2 > r[1]=0 → candies[2] = candies[1]+1 = 2
→ candies = [1, 1, 2]
Lượt 2 (R→L):
i=1: r[1]=0 <= r[2]=2 → giữ
i=0: r[0]=1 > r[1]=0 → candies[0] = max(1, candies[1]+1) = max(1, 2) = 2
→ candies = [2, 1, 2]
Tổng: 2 + 1 + 2 = 5 ✓
from typing import List
class Solution:
def candy(self, ratings: List[int]) -> int:
n = len(ratings)
candies = [1] * n
# Lượt 1.
for i in range(1, n):
if ratings[i] > ratings[i - 1]:
candies[i] = candies[i - 1] + 1
# Lượt 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). Bộ
nhớ: O(n).max ở lượt 2 đảm
bảo cả 2 ràng buộc cùng thoả.k, lời giải
greedy “tiến” ít nhất bằng mọi lời giải khác. Quy nạp → toàn cục tối
ưu.i: 0 1 2 3 4
nums: [2, 3, 1, 1, 4]
reach: 2 4 4 4 ≥4 ✅
reach = chỉ số xa nhất đến được tới thời điểm
i.i > reach tại bất kỳ bước nào →
kẹt.Nếu khởi đầu từ s mà fail tại i (tank âm),
thì mọi điểm k trong [s, i]
cũng fail khi xuất phát từ k (vì từ s đến
k mình đã có dư xăng, vẫn không kéo được tới
i+1). ⇒ Tiếp tục thử i+1.
i, nếu
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 đủ.Divide and Conquer (D&C): chia bài thành các bài con độc lập, giải đệ quy, rồi kết hợp. Khác với DP (bài con có thể overlap), D&C bài con không trùng nhau. Các thuật toán nổi tiếng (merge sort, quicksort, fast power, closest pair) đều là D&C.
Sau chương này, bạn sẽ:
2T(n/2)+O(n) = O(n log n).O(n²) → O(n log n) thông qua kết
hợp 2 nửa 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) # phần "conquer" — thường O(n)Cho nums, tìm dãy con liên tục có
tổng max. Trả về tổng.
Input: nums = [-2,1,-3,4,-1,2,1,-5,4]
Output: 6 (dãy [4,-1,2,1])
3 cách: 1. Kadane’s (DP 1D,
O(n)) — chuẩn cho LC 53. 2. Prefix sum +
min, O(n). 3. Divide &
Conquer, O(n log n) — focus của chương
này.
D&C insight: chia mảng [lo, hi] làm
2 nửa [lo, mid], [mid+1, hi]. Max subarray
hoặc: - Hoàn toàn trong nửa trái (đệ quy). - Hoàn toàn trong nửa phải
(đệ quy). - Qua giữa — bắt đầu trong trái, kết thúc
trong phải.
Crossing sum tính O(n): -
left_max = max của
nums[mid] + nums[mid-1] + ... cộng dồn về trái. -
right_max = tương tự về phải. -
crossing = left_max + right_max.
Max của 3 lựa chọn = đáp án bài con.
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 — bắt buộc bao gồm cả nums[mid] và 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) dễ hơn và nhanh hơn.
D&C đáng học vì:
(left_max, right_max, total, best) đủ để merge 2 con.Tính số inversion trong mảng nums: cặp
(i, j) với i < j và
nums[i] > nums[j].
Input: nums = [2, 4, 1, 3, 5]
Output: 3
(số cặp i < j với nums[i] > nums[j]; cụ thể: (2,1), (4,1), (4,3))
Brute force — O(n²). Đếm mọi cặp.
D&C bằng Merge Sort —
O(n log n).
Trong khi merge 2 nửa đã sort: - Khi lấy từ nửa phải (vì
nums_right[j] < nums_left[i]), mọi phần tử còn lại trong
nửa trái đều tạo inversion với nums_right[j]. → cộng
(len_left - i) vào counter.
Hình minh hoạ với [2, 4, 1, 3, 5]:
Chia: [2, 4, 1] | [3, 5]
Đệ quy [2, 4] [1] [3] [5]
trái: [2] [4]
merge [2,4]: 0 inversion
merge [2,4] với [1]:
lấy 1 từ phải → còn 2 phần tử [2,4] trong trái lớn hơn → +2 inversion
kết quả: [1, 2, 4]; total = 2
phải: merge [3] [5]: 0 inversion → [3, 5]
merge [1, 2, 4] với [3, 5]:
1 (trái), 2 (trái), 4 (trái), 3 (phải) — khi lấy 3, 4 vẫn ở trái còn lớn → +1
4, 5 → 5 từ phải, không còn trái → 0 inv
Tổng: 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 # các L[i..] đều > R[j]
out.extend(L[i:])
out.extend(R[j:])
return out, inv
_, total = merge_sort(nums)
return totalO(n log n). Bộ
nhớ: O(n) cho auxiliary array.Đã giải đầy đủ ở Chương 15.1. Ở đây tóm tắt lens D&C.
Cho nums, tìm phần tử lớn thứ
k (1-indexed). Pattern D&C giải
O(n) trung bình.
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 là D&C đặc biệt — đệ quy chỉ 1 nửa
(nửa chứa Kth) thay vì cả 2. Vì vậy: -
T(n) = T(n/2) + O(n) = O(n) trung bình (không phải
O(n log n) như Quicksort). - Worst-case O(n²)
nếu pivot xấu — tránh bằng random pivot.
Lưu ý chiều index: partition bên dưới sắp theo thứ
tự tăng dần, nên p là vị trí thứ
p từ nhỏ nhất (0-based). Kth
largest đứng ở vị trí len(nums) - k
(0-based) sau khi sort. Vì vậy hàm public LC nhận k
1-indexed và chuyển thành target = len(nums) - k trước khi
gọi quickselect.
import random
from typing import List
class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
# Kth largest 1-indexed ⇔ vị trí (n - k) 0-based sau khi sort tăng dần.
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 theo đề bài
def _partition(self, arr: List[int], lo: int, hi: int) -> int:
# Random pivot để tránh worst-case O(n^2) trên input đã sort.
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) trung bình,
O(n²) worst-case (rất hiếm với random pivot).O(1).p == k mà partition sắp tăng dần thì kết quả
thực ra là kth smallest (0-based). Wrapper
findKthLargest ở trên chuyển đổi rõ ràng giúp tránh bug
này.Đã giải đầy đủ ở Chương 3.2. Ở đây tóm tắt lens D&C.
Tính x^n với x thực, n nguyên
(có thể âm). Pattern D&C cho 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 (chẵn) hoặc
x · (x^((n-1)/2))^2 (lẻ). Mỗi bước chia nửa 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 hoặc
O(1) iterative.Cho biểu thức s chứa số và toán tử +,
-, *. Trả về tất cả giá trị
có thể có sau khi thêm dấu ngoặc theo các cách khác nhau.
Input: s = "2*3-4*5"
Output: [-34, -14, -10, -10, 10]
Giải thích:
(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
Tại mỗi toán tử op, chia s
thành 2 phần (trái, phải). Đệ quy tính all values của
mỗi phần. Kết hợp: với mỗi (l, r) cặp giá trị, thêm
l op r vào kết quả.
Memoize theo substring để tránh tính lại.
Hình minh hoạ với "2*3-4":
"2*3-4":
Tại '*' (pos 1):
Trái = "2" → [2]
Phải = "3-4" → đệ quy:
Tại '-' (pos 1):
Trái = "3" → [3]
Phải = "4" → [4]
→ [3 - 4] = [-1]
→ "3-4" có [-1]
Kết hợp: 2 * -1 = -2
Tại '-' (pos 3):
Trái = "2*3" → đệ quy → [6]
Phải = "4" → [4]
→ [6 - 4] = [2]
→ "2*3-4" có [-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)
với C_n = số Catalan thứ n.O(C_n) cho cache.n+1 số =
C_n.tuple cho cache
(list không hashable).Cho n điểm trên mặt phẳng, tìm cặp 2 điểm gần nhất
(Euclidean distance). Phải 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²). So mọi cặp.
D&C — O(n log n).
x.x_mid.d_left, d_right.d = min(d_left, d_right).|x - x_mid| < d, sort theo y, mỗi điểm chỉ
cần so với ≤ 7 điểm kế tiếp trong strip (hình học chứng
minh).Đây là bài kinh điển trong giáo trình CS — phần lớn không xuất hiện trên LeetCode chuẩn, nhưng được hỏi ở phỏng vấn Big Tech như một bài tối ưu hoá brute force
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 trước.
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)):
# Chứng minh: chỉ cần kiểm tra ≤ 7 láng giềng theo 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, không quá 6 điểm có thể nằm trong hình vuông
d × d (mật độ giới hạn).solve(P):
if |P| ≤ threshold: brute()
chia P → P1, P2 (gần đều)
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) mỗi tầng × log n tầng ⇒ O(n log n)
| Quickselect | Heap size k | Sort | |
|---|---|---|---|
| Avg | O(n) |
O(n log k) |
O(n log n) |
| Worst | O(n²) (random pivot ↘) |
O(n log k) |
O(n log n) |
| Tại chỗ | ✅ | ✗ extra heap | ✅ |
| Code | trung bình | ngắn | ngắn nhất |
x. Chia làm đôi → solve 2 nửa được
d1, d2. Đặt d = min(d1, d2).2d quanh đường chia: chỉ các cặp
trong strip mới có thể nhỏ hơn d. Sort
strip theo y, mỗi điểm chỉ check 6 điểm
kế.Monotonic stack/deque = stack/deque mà các phần tử giữ đơn điệu (tăng hoặc giảm) khi đi qua. Đây là “vũ khí” mạnh nhất để giải các bài “next greater/smaller”, “range max/min in sliding window”, “largest rectangle”. Bài 8.5 (Daily Temperatures) đã teaser một chút — chương này đi sâu.
Sau chương này, bạn sẽ:
O(n)
amortized.min/max trong sliding window.Khi nào tăng, khi nào giảm? - Stack tăng dần (top là max của stack): hữu ích cho “previous smaller”. - Stack giảm dần (top là min): cho “previous greater” / “next greater”.
Sự khác biệt giữa “stack thường” và “monotonic stack” là invariant — quy luật luôn đúng tại mọi thời điểm. Hiểu invariant = hiểu cả pattern.
Invariant tại mọi thời điểm:
bottom → top
[v0 , v1 , v2 , ... , vk]
v0 ≥ v1 ≥ v2 ≥ ... ≥ vk (giảm dần)
Khi thêm value mới x:
- Trong khi stack[-1] < x: pop (vì stack[-1] không còn cơ hội là "next greater")
- Push x
Diễn giải:
• Stack giữ các "ứng viên đang chờ next greater".
• Khi gặp x lớn hơn top → top tìm được answer (= x) → pop.
• Mỗi index push 1 lần, pop tối đa 1 lần → O(n) total.
Ví dụ trace với
arr = [73, 74, 75, 71, 69, 72, 76]:
Bước 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]
Mỗi index push 1× và pop ≤ 1× → O(n).
Invariant tại mọi thời điểm (deque giảm dần từ head → tail):
head tail
[i0 , i1 , i2 , ... , ik]
arr[i0] ≥ arr[i1] ≥ ... ≥ arr[ik]
• Head luôn là MAX của window hiện tại.
• Tail là phần tử mới nhất.
Khi window slide (thêm index r, có thể bỏ index l):
1. Pop từ tail nếu arr[tail] ≤ arr[r] (vô dụng — r mới hơn + lớn hơn)
2. Push r vào tail
3. Pop head nếu head ≤ r - k (ngoài window)
4. Max = arr[head]
Ví dụ trace với
arr = [1, 3, -1, -3, 5, 3, 6, 7], k = 3:
i v deque (index) Pop tail/head? Max khi 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 vẫn ≤ 3-k=0?
Không, 1 > 0, ok) arr[1]=3
4 5 [4] pop 3,2,1 (đều ≤ 5) arr[4]=5
5 3 [4, 5] push 5 arr[4]=5
6 6 [6] pop 5 (3 ≤ 6); pop 4 (5 ≤ 6)
Wait — index 4 với arr[4]=5
5 ≤ 6 → pop. push 6. arr[6]=6
7 7 [7] pop 6 arr[7]=7
Output: [3, 3, 5, 5, 6, 7] ✓
Invariant này giúp gỡ lỗi rất nhanh: nếu deque không còn monotonic, hoặc phần tử ở đầu deque nằm ngoài window, thì chắc chắn code có bug.
# 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, value giảm dần
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, value giảm dần ở 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 outĐã giải đầy đủ ở Chương 8.5. Pattern: monotonic decreasing stack, mỗi phần tử push/pop đúng 1 lần →
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.Cho mảng vòng tròn nums. Với mỗi phần
tử, tìm số đầu tiên lớn hơn nó tính theo chiều kim đồng
hồ (có thể vòng lại). Không có → -1.
Input: nums = [1, 2, 1] → [2, -1, 2]
Giải thích: 1 (idx 0) → 2; 2 → không có; 1 (idx 2) → 2 (vòng lại).
Trick mảng vòng: duyệt 2n bước, dùng
i % n để truy cập value. Stack monotonic giữ index thật
(0..n-1) và chỉ pop ở vòng đầu (vì vòng 2 lan toả).
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),
nếu push cả 2 vòng → output sai.Cho mảng heights[] (chiều cao mỗi cột width 1). Tìm
diện tích hình chữ nhật lớn nhất trong histogram.
Input: heights = [2, 1, 5, 6, 2, 3]
Output: 10
Giải thích: chọn cột [5, 6] → 2 * 5 = 10.
Brute force — O(n²). Với mỗi cột
i, expand sang 2 bên đến khi gặp cột thấp hơn.
Monotonic increasing stack — O(n).
Stack chứa index, các height tăng dần từ đáy lên
đỉnh. Khi gặp h[i] nhỏ hơn h[stack top], đó là
dấu hiệu cột top kết thúc bên phải ở i.
Pop top, tính diện tích: - height = h[top]. - width =
i - stack[-1] - 1 (nếu stack rỗng sau pop, width =
i).
Hình minh hoạ với
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]
Hết mảng. Sentinel right (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 stack với sentinel right = 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). Bộ
nhớ: O(n).-1 ở đáy stack giúp công thức
width = i - stack[-1] - 1 thống nhất khi pop ra hết.Cho mảng nums và số k. Cửa sổ
k phần tử trượt từ trái sang phải. Trả về max trong
mỗi vị trí cửa sổ.
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 chứa index, các value giảm
dần từ head → tail. Khi mới đến i: 1. Pop từ tail
nếu value <= nums[i] (chúng vô dụng — i mới
hơn và lớn hơn). 2. Push i vào tail. 3. Pop từ head nếu
index ngoài cửa sổ (dq[0] <= i - k). 4. Khi
i >= k - 1, nums[dq[0]] là max.
Hình minh hoạ với
nums = [1,3,-1,-3,5,3,6,7], k = 3:
i v deque (index) state max khi 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 tất 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) — mỗi index push/pop
tối đa 1 lần.O(k).dq[0] <= i - k (do head có thể đã quá hạn).Cho mảng arr. Trả về tổng min(subarray) qua
tất cả subarray liên tục. 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
> để tránh đếm trùng.Insight chuyển bài:
Thay vì duyệt subarray, ta hỏi: mỗi phần tử arr[i]
là min của bao nhiêu subarray?
arr[i] là min của subarray [l, r] ↔︎
l ∈ (prev_less[i], i] và
r ∈ [i, next_less[i]).
Vậy, số subarray có min = arr[i] =
(i - prev_less[i]) * (next_less[i] - i).
Đáp án =
Σ arr[i] * (i - prev_less[i]) * (next_less[i] - i).
Tính prev_less, next_less
dùng monotonic stack O(n).
Cẩn thận với duplicate: dùng strict
<ở 1 phía,<=ở phía kia để tránh đếm trùng.
from typing import List
class Solution:
MOD = 10**9 + 7
def sumSubarrayMins(self, arr: List[int]) -> int:
n = len(arr)
# prev_less[i]: nearest index j < i with arr[j] < arr[i]; -1 nếu không có.
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 index j > i with arr[j] <= arr[i]; n nếu không có.
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). Bộ
nhớ: O(n).>= cho
prev (strict less ở quá khứ), > cho next (allow equal ở
tương lai) — đảm bảo mỗi subarray có đúng 1 “min owner”.Cho chuỗi số num và số k. Xoá đúng
k chữ số sao cho chuỗi kết quả là số nhỏ
nhất có thể (giữ thứ tự các chữ số còn lại). Bỏ leading zero,
chuỗi rỗng trả "0".
Input: num = "1432219", k = 3 → "1219"
Input: num = "10200", k = 1 → "200"
Input: num = "10", k = 2 → "0"
Greedy + Monotonic increasing stack.
Duyệt num. Với mỗi digit d: - Trong khi
stack không rỗng, top > d, và
k > 0: pop top (xoá nó tốt hơn vì làm số nhỏ đi). - Push
d.
Cuối: nếu còn k > 0 (chưa đủ xoá) → pop từ cuối stack
(digit ở cuối luôn lớn nhất trong số còn lại, vì stack đang
increasing).
Cuối cùng: bỏ leading zero, return.
Hình minh hoạ với
num = "1432219", k = 3:
digit stack k action
─────────────────────────────────────────────────────
'1' [1] 3 push
'4' [1, 4] 3 push (4 > top)
'3' [1, 3] 2 pop 4 (4 > 3, k>0). push 3
'2' [1, 2] 1 pop 3 (3 > 2). push 2
'2' [1, 2, 2] 1 push
'1' [1, 1] 0 pop 2 (2 > 1). push 1
'9' [1, 1, 9] 0 push (k=0, không pop được)
Còn 7-0=7 digit chứ không, nguyên thuỷ 7 digit, k=3, kết quả phải 4 digit.
stack chứa [1, 1, 9]. Cộng với cái cuối '1' đã pop... wait, recount.
Actually 1432219 length=7, k=3, output length=4.
Let me redo more carefully:
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)
# Còn k? Pop từ cuối (digit lớn nhất nếu stack tăng).
while k > 0:
stack.pop()
k -= 1
# Bỏ leading zero.
result = ''.join(stack).lstrip('0')
return result if result else '0'O(n). Bộ
nhớ: 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], thêm sentinel
0 cuối.
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: stack lưu các index có h
tăng nghiêm ngặt; khi gặp h thấp hơn, các
cột bên trái cao hơn “đóng cửa” rectangle của chúng.
Mỗi a[i] đóng góp a[i] × left × right lần
làm minimum, với: - left[i] = số phần tử bên trái mà
a[i] vẫn là min (kể cả chính i). -
right[i] = tương tự bên phải. - Tie-break:
dùng < bên trái, ≤ bên phải (hoặc ngược) để
mỗi subarray min đếm đúng 1 lần.
s[-1] > d_in (digit mới): xóa
s[-1] luôn tốt hơn vì vị trí nó bên trái
có trọng số hàng cao hơn, “xóa số lớn ở vị trí trái” giảm giá trị nhiều
hơn xóa ở vị trí phải.Prefix Sum =
P[i] = arr[0] + arr[1] + ... + arr[i-1]. Cho phép tính tổng subarray[l, r]trongO(1):P[r+1] - P[l]. Đây là gateway cho Chương 6.4 (Subarray Sum K) và rất nhiều bài liên quan đến tổng / số dư / đếm subarray theo điều kiện. Cũng là nền cho Fenwick Tree (Chương 22).
Sau chương này, bạn sẽ:
P[0] = 0 để tổng [l, r] =
P[r+1] - P[l].# 1) Prefix sum 1D
P = [0] * (n + 1)
for i in range(n):
P[i + 1] = P[i] + arr[i]
# Tổng arr[l..r] (inclusive):
sum_lr = P[r + 1] - P[l]
# 2) Prefix sum + hash đếm 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) Prefix sum 2D
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]
# Tổng rect (r1, c1) → (r2, c2):
# P[r2+1][c2+1] - P[r1][c2+1] - P[r2+1][c1] + P[r1][c1]Thiết kế class: - NumArray(nums): khởi tạo với mảng số.
- sumRange(left, right): trả về tổng
nums[left..right] (inclusive).
Mảng không thay đổi sau khởi tạo. Yêu cầu
sumRange chạy 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]
Giải thích:
NumArray([-2, 0, 3, -5, 2, -1]) → null (khởi tạo prefix sum nội bộ)
sumRange(0, 2) → 1 (-2 + 0 + 3)
sumRange(2, 5) → -1 (3 + (-5) + 2 + (-1))
sumRange(0, 5) → -3 (tổng toàn mảng)
Precompute prefix sum trong __init__.
Mỗi query 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). Bộ nhớ: O(n).P[0] = 0 là idiom — giúp
công thức P[r+1] - P[l] không phải special-case
l == 0.Đã giải đầy đủ ở Chương 6.4. Pattern: prefix sum + hash đếm
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) cho hash map.Cho mảng nums và số k. Trả về
True nếu tồn tại subarray độ dài ≥ 2 có
tổng là bội số của k (kể cả 0×k = 0).
Input: nums = [23, 2, 4, 6, 7], k = 6 → True
Giải thích: [2, 4] có tổng 6 = 1*6.
Input: nums = [23, 2, 6, 4, 7], k = 6 → True
Giải thích: [23, 2, 6, 4, 7] = 42 = 7*6.
Input: nums = [1, 2, 3], k = 5 → False
Prefix sum mod K + hash.
Subarray nums[l..r] có tổng chia hết k ↔︎
P[r+1] % k == P[l] % k. Tìm 2 vị trí cùng modulo, khoảng
cách ≥ 2.
Dict {remainder: earliest_index}. Với mỗi
i, tính cur % k: - Nếu đã thấy remainder này
trước đó tại j và i - j >= 2 → True. - Nếu
chưa thấy → ghi i.
from typing import List
class Solution:
def checkSubarraySum(self, nums: List[int], k: int) -> bool:
# remainder -> earliest index where this prefix-sum remainder appeared.
# Khởi tạo {0: -1} để cover trường hợp subarray bắt đầu từ index 0.
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). Bộ
nhớ: O(min(n, k)).earliest? Vì ta muốn
i - j lớn nhất (≥ 2) → giữ j nhỏ nhất là an
toàn.{0: -1}: nếu prefix sum chính nó
chia hết k và đủ dài (i ≥ 1) → match.Thiết kế class NumMatrix(matrix) với phương thức
sumRegion(row1, col1, row2, col2) trả tổng vùng chữ nhật
bao quanh bởi 4 góc (row1, col1) và
(row2, col2) (inclusive cả hai đầu) trong
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]
Giải thích:
NumMatrix(matrix) → khởi tạo, return null
sumRegion(2, 1, 4, 3) → tổng vùng [2..4] × [1..3] = 8
sumRegion(1, 1, 2, 2) → tổng vùng [1..2] × [1..2] = 11
sumRegion(1, 2, 2, 4) → tổng vùng [1..2] × [2..4] = 12
Prefix sum 2D: P[r+1][c+1] = tổng vùng
[0,0] → [r,c].
Inclusion-exclusion để lấy tổng vùng
[r1,c1] → [r2,c2]:
P[r2+1][c2+1] − P[r1][c2+1] − P[r2+1][c1] + P[r1][c1]
Hình minh hoạ:
P[r2+1][c2+1] = tổng cả vùng (0,0) đến (r2,c2)
− P[r1][c2+1] = trừ phần trên (0,0) đến (r1-1, c2)
− P[r2+1][c1] = trừ phần trái (0,0) đến (r2, c1-1)
+ P[r1][c1] = cộng lại phần overlap đã trừ 2 lần
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). Bộ nhớ:
O(rows · cols).Đã giải đầy đủ ở Chương 1.3. Liên hệ với 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) và suffix product
(right).P[0] = 1 riêng vì
left = 1 ban đầu đảm nhận vai trò “phần tử trung lập”.O(n).O(1) extra (không tính
output).Cho nums. Tìm pivot index — chỉ số
i mà tổng nums[0..i-1] ==
nums[i+1..n-1]. Trả index trái nhất, hoặc
-1.
Input: nums = [1, 7, 3, 6, 5, 6]
Output: 3
Giải thích: tổng trái 1+7+3=11; tổng phải 5+6=11.
Input: nums = [1, 2, 3] → -1
Input: nums = [2, 1, -1] → 0 (trái = rỗng = 0; phải = 1 + -1 = 0)
left[i] + nums[i] + right[i] = total. Pivot ↔︎
left[i] == right[i] ↔︎
left[i] = (total - nums[i]) / 2.
1 lượt: giữ left_sum. Tại
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). Bộ
nhớ: O(1).i = 0 hoặc
i = n - 1: trái/phải có thể rỗng → tổng 0. Code
trên xử lý tự nhiên.P[0] = 0 — vì saoarr: [ 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]
Tổng arr[l..r] (đóng, 0-index) =
P[r+1] - P[l]. Nếu thiếu P[0] = 0, công thức
cần case riêng cho l == 0.
% k == 0.prefix % k, độ dài subarray giữa
chúng = j - i. Yêu cầu j - i ≥ 2.Python % luôn trả [0, k):
(-3) % 5 == 2. An toàn cho prefix mod. Java/C++:
(-3) % 5 == -3 → cần ((x % k) + k) % k.
Đã có code đầy đủ ở Chương 1.3. Recap ở đây nhấn
mạnh: prefix product và suffix
product, không dùng P[r+1] - P[l] mà là
nhân, nên không cần P[0] = 1 riêng — code
dùng left = 1, right = 1 ban đầu.
Chương cuối của Level 1, mang tinh thần “number theory cơ bản”. Prime number không phải pattern lớn trong phỏng vấn Big Tech (Google đôi khi hỏi), nhưng học để có Sàng Eratosthenes trong tủ vũ khí — thuật toán cổ điển vẫn rất hữu dụng.
Sau chương này, bạn sẽ:
O(n log log n).O(sqrt(n)) cho factorization 1 số.MOD = 10^9 + 7 (prime).10^9 + 7 (prime để mod),
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]:
"""Sàng 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]:
"""Phân tích thừa số — 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 factorsCho n. Trả về số nguyên tố nhỏ hơn n.
Input: n = 10 → 4
Giải thích: primes < 10 = {2, 3, 5, 7}.
Input: n = 0 → 0
Input: n = 1 → 0
Brute force — trial division mỗi số,
O(n sqrt n). TLE với n = 5·10^6.
Sàng Eratosthenes — O(n log log n).
is_prime[] mảng boolean. Với mỗi i từ 2,
nếu is_prime[i] thật → mark tất cả bội số
(i*i, i*i+i, i*i+2i, ...) là composite.
Hình minh hoạ với 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: hoặc composite hoặc i² > 30 → skip
Còn lại: 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]:
# Bắt đầu từ i*i — các bội nhỏ hơn đã bị mark bởi prime nhỏ hơn.
for j in range(i * i, n, i):
is_p[j] = False
return sum(is_p)O(n log log n).O(n).bytearray
thay list[bool] để giảm 8x bộ nhớ.sqrt(n)
(vì mọi composite < n đều có thừa số ≤
sqrt(n)).i*i? Các bội nhỏ
hơn (2i, 3i, ..., (i-1)i) đã bị mark bởi các prime nhỏ hơn
i.Ugly Number = số nguyên dương có dạng
2^a · 3^b · 5^c. Trả về ugly number thứ
n. (1 cũng là ugly.)
Input: n = 10
Output: 12
Giải thích: 10 ugly đầu = 1, 2, 3, 4, 5, 6, 8, 9, 10, 12.
Brute force đếm và check từng số — quá chậm khi n lớn.
3 con trỏ DP — O(n).
ugly[k] = ugly thứ k. Mỗi ugly mới =
min(ugly[i2]*2, ugly[i3]*3, ugly[i5]*5). Sau khi chọn,
advance pointer tương ứng.
Hình minh hoạ:
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). Bộ
nhớ: O(n).if elif: dùng if
(không elif) — vì ugly có thể bằng cả 2 cách (vd
6 = 2*3 = 3*2), nếu dùng elif thì 1 pointer
không advance → duplicate.O(n log n) — chậm hơn DP.Cho n. Đếm số hoán vị của 1..n sao cho
mọi số nguyên tố đứng tại vị trí prime
(index 1-based). Modulo 10^9 + 7.
Input: n = 5
Output: 12
Giải thích: 5 vị trí, có 3 prime (2, 3, 5). Số prime trong 1..5 = 3.
3! * 2! = 6 * 2 = 12.
Counting: Gọi p = số prime ≤ n. Có
p! cách xếp prime vào p vị trí prime, và
(n - p)! cách xếp non-prime vào n - p vị trí
non-prime.
Đáp án = p! * (n - p)! mod (10^9 + 7).
from math import factorial
class Solution:
MOD = 10**9 + 7
def numPrimeArrangements(self, n: int) -> int:
# Đếm prime ≤ 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) cho
sàng.O(n).Cho left, right. Trả về cặp prime (p, q)
với left <= p < q <= right mà q - p
min. Nếu hoà, lấy cặp có p nhỏ hơn. Không
có → [-1, -1].
Input: left=10, right=19
Output: [11, 13]
right, lấy danh sách prime trong
[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).Cho nums. Nối 2 phần tử cùng component nếu chúng chia sẻ
ít nhất 1 thừa số nguyên tố > 1. Trả về kích thước component lớn
nhất.
Input: nums = [4, 6, 15, 35] → 4
Giải thích: 4,6 chia sẻ 2; 6,15 chia sẻ 3; 15,35 chia sẻ 5 → tất cả 1 component.
Insight: thay vì union các phần tử trực tiếp (cần
O(n²)), ta union phần tử với mỗi thừa số prime của
nó. Hai phần tử cùng prime → cùng component.
Pseudocode: - Với mỗi x trong
nums, phân tích thừa số x. Union
x với mỗi thừa số. - Đếm size component.
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)
# Đếm: với mỗi phần tử nums (chỉ phần tử nums, không phải prime).
counter = Counter(dsu.find(x) for x in nums)
return max(counter.values())O(n · sqrt(max(nums)) · α).O(n + max(nums)).O(n²)
xuống O(n · sqrt(max)).Cho nums. Trả về số prime factor phân
biệt của tích các phần tử.
Input: nums = [2, 4, 3, 7, 10, 6]
Output: 4
Giải thích: tích = 2·4·3·7·10·6 = 10080 = 2^5 · 3^2 · 5 · 7. 4 prime phân biệt.
Không cần tính tích thực (có thể overflow). Chỉ cần với mỗi số, lấy các thừa số prime, gom hết vào 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) với k = số
prime phân biệt.n lớn. Phân tích từng số là cách an toàn.n (giới hạn) |
Phương pháp | Time |
|---|---|---|
Kiểm tra 1 số n ≤ 10¹² |
Trial division đến √n |
O(√n) |
Đếm primes ≤ n, n ≤ 10⁷ |
Sieve of Eratosthenes | O(n log log n) |
Factor nhiều số ≤ n, n ≤ 10⁶ |
SPF (Smallest Prime Factor) sieve | precompute O(n log log n), mỗi factor
O(log n) |
Factor 1 số n ≤ 10¹⁸ |
Pollard ρ + Miller-Rabin | sub-exponential |
a, union
a với mỗi prime factor của nó.n: p. Số non-prime:
q = n - p.= p! × q! (mod 10⁹+7).fact[0..n] mod M, mỗi bước
fact[i] = fact[i-1] * i % M.Bit manipulation mở ra giải pháp
O(1)cho nhiều bài tưởngO(n). Tricks XOR, AND, OR + bit-shift là ngôn ngữ thứ 2 của lập trình viên. Chương này dạy 6 bài kinh điển + bit tricks bạn cần thuộc. Bitmask sẽ được mở rộng ở Chương 40 (Bitmask DP) và 41 (Bitmask + Trie).
Sau chương này, bạn sẽ:
x & -x, Brian Kernighan
x & (x-1).a ^ a = 0 → tìm single number / cặp khác
bit.int 32-bit chứa 32 boolean.# 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 (ví dụ x=12=0b1100 → 4=0b100)
# Pop lowest set bit: x &= x - 1
# Count set bits: bin(x).count('1') # hoặc x.bit_count() Py3.10+
# Iterate qua subsets của mask:
sub = mask
while sub:
# ... use sub ...
sub = (sub - 1) & mask
# Kiểm tra power of 2: x > 0 and (x & (x - 1)) == 0
# Đảo bit 32-bit: xor với 0xFFFFFFFFCho mảng nums, mỗi phần tử xuất hiện 2
lần trừ đúng 1 phần tử xuất hiện 1 lần. Tìm phần tử đó.
O(n) time, O(1) space.
Input: nums = [2, 2, 1] → Output: 1
Input: nums = [4, 1, 2, 1, 2] → Output: 4
(mọi phần tử xuất hiện đúng 2 lần trừ một phần tử duy nhất xuất hiện 1 lần)
Trick XOR: a XOR a = 0;
a XOR 0 = a. XOR có tính giao hoán & kết hợp. XOR cả
mảng → các cặp huỷ nhau, còn lại = phần tử cô độc.
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). Bộ
nhớ: O(1).O(n) nhưng tốn
O(n) space.Cho số nguyên n. Trả về số bit 1 trong biểu
diễn nhị phân (Hamming weight).
Input: n = 11 (0b1011) → 3
Input: n = 128 (0b10000000) → 1
Cách 1 — Loop bit, O(32). Cách
2 — Trick n &= n - 1,
O(số bit 1). Mỗi lần n & (n-1)
xoá bit 1 thấp nhất.
Hình minh hoạ — 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, dừng. Tổng 2 bit 1. ✓
class Solution:
def hammingWeight(self, n: int) -> int:
count = 0
while n:
n &= n - 1
count += 1
return countO(số bit 1) ≤ 32.
Bộ nhớ: O(1).int.bit_count() —
built-in O(1) thực tế. Khi phỏng vấn nên trình bày trick
n & (n-1) để chứng tỏ hiểu.Cho n. Trả về mảng result dài
n + 1, result[i] = số bit 1 của
i.
Input: n = 5
Output: [0, 1, 1, 2, 1, 2]
Brute force — gọi hammingWeight(i) cho
mỗi i, O(n log n).
DP — O(n):
result[i] = result[i >> 1] + (i & 1).
Trực giác: bit 1 của i = bit 1 của
i >> 1 (đã trừ bit cuối) + bit cuối.
Hoặc: 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). Bộ
nhớ: O(n).i dựa trên kết quả i đã tính nhỏ hơn.Tính a + b không dùng + hoặc
-.
Input: a = 1, b = 2 → 3
Input: a = 2, b = 3 → 5
Insight: - a XOR b = tổng các bit
không carry. - (a AND b) << 1 =
carry. - Lặp đến khi 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
# Xử lý số âm trong Python (int vô hạn).
return a if a < 0x80000000 else ~(a ^ self.MASK)O(32). Bộ
nhớ: O(1).MASK = 0xFFFFFFFF để mô phỏng 32-bit.Cho left, right. Trả về AND của tất cả số trong
[left, right].
Input: left = 5 (0b101), right = 7 (0b111)
Output: 4 (0b100)
Giải thích: 5 & 6 & 7 = 100 & 110 & 111 = 100 = 4.
Insight: AND của range = prefix
chung của left và right ở dạng nhị
phân, padded zero ở các bit thấp hơn.
Vì khi range qua nhiều giá trị, các bit thấp đều có lúc bằng 0 → AND = 0.
Cách: shift cả 2 sang phải đến khi
left == right (tìm prefix chung), rồi shift trái lại.
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). Bộ
nhớ: O(1).right &= (right - 1) cho đến khi
right < left. Tương đương nhưng đẹp hơn.Cho nums. Trả về XOR lớn nhất của 2 phần tử khác
nhau.
Input: nums = [3, 10, 5, 25, 2, 8]
Output: 28
Giải thích: 5 XOR 25 = 28.
Brute force — O(n²). Mọi cặp.
Trick “Greedy bit” — O(n · 32).
Xây kết quả từng bit từ cao xuống thấp. Tại bit b: - Giả
sử kết quả từ bit cao đến b+1 đã là result. -
Thử mở bit b:
candidate = result | (1 << b). - Kiểm tra: tồn tại
cặp (a, b) trong nums với
(a ^ b) & mask == candidate không? (mask = các bit ≥ b)
- Cách check: tạo prefixes = {x & mask for x in nums}.
Với mỗi prefix p, check
p ^ candidate in prefixes.
Nếu có → giữ candidate; không có → giữ
result cũ.
Trie approach (Chương 41) cũng làm được — đó là 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).
Bộ nhớ: O(n).Python int vô hạn bit →
(-1) << 1 không tràn. Để mô phỏng 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): dùng bit cao
nhất + lsb.dp[i] = dp[i & (i - 1)] + 1: trick
i & (i-1) xoá bit thấp nhất ⇒ “subproblem = i bỏ 1
bit”. Cả hai O(n); chọn cái nào dễ giải thích hơn cho
interviewer.[m, n] AND của tất cả số = prefix nhị phân
chung dài nhất của m và n, các bit
thấp còn lại = 0.Bài LC 421 (Max XOR of Two Numbers) là gateway cho Binary Trie trong Chương 41 (Bitmask + Trie). Mỗi số = đường đi 32 bit trong trie; greedy chọn bit ngược lại để maximize XOR.
Chương cây nâng cao gồm 3 cấu trúc chính: BST (Binary Search Tree), Segment Tree, và Fenwick Tree (Binary Indexed Tree). Cả 3 đều giải bài “range query với update” trong
O(log n)per operation. Trong phỏng vấn Big Tech, BST hỏi rất nhiều; Segment/Fenwick xuất hiện ở vòng on-site Hard.
Sau chương này, bạn sẽ:
(low, high) truyền xuống, không chỉ
check local.O(log n), code
ngắn nhất.O(log n).O(log n), code ngắn hơn segment tree.Lựa chọn nhanh: - Cần sum / prefix only → Fenwick (đơn giản hơn). - Cần min / max / GCD / complex merge → Segment Tree. - Range update lazy → Segment Tree with lazy propagation.
# 1) BST validate / search — xem Chương 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 resĐã giải đầy đủ ở Chương 11.4. Pattern: DFS với bound
(low, high)hoặc inorder check 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). Bộ
nhớ: O(h) stack.Input: root của 1 BST (LC level-order serialize). Đúng 2
node của BST bị swap giá trị nhầm. Khôi phục BST (sửa
giá trị) mà không thay đổi cấu trúc cây. Yêu cầu
O(1) extra space (follow-up).
Input: root = [1, 3, null, null, 2]
(LC level-order serialize)
Cây ban đầu (KHÔNG phải BST hợp lệ):
1
/ \
3 *
\
2
Output: root sau khi recover = [3, 1, null, null, 2]
Cây sau khi sửa (BST hợp lệ):
3
/ \
1 *
\
2
Giải thích: hàm mutate cây in-place; 2 node bị swap value là 1 và 3 →
chỉ cần swap value của 2 node đó.
Inorder traversal của BST đúng = sequence tăng strictly. Khi có 2 node swap, inorder sẽ có 2 vị trí vi phạm (a > b với a đứng trước b).
a và b trong vi phạm này.first = a
của vi phạm 1, second = b của vi phạm 2.Sau khi tìm first, second → swap giá
trị chúng.
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). Bộ
nhớ: O(h) stack.O(1) extra space
thực sự (không stack) — phức tạp hơn, thường không cần trong phỏng
vấn.Thiết kế 2 hàm: serialize(root) -> str và
deserialize(str) -> root.
Input: root = [1, 2, 3, null, null, 4, 5]
(LC level-order serialize)
Cây thực tế:
1
/ \
2 3
/ \
4 5
Output (serialize): "1,2,#,#,3,4,#,#,5,#,#"
(preorder DFS; `#` đại diện cho node None)
Yêu cầu round-trip: deserialize(serialize(root)) tạo lại cây tương đương
(cùng cấu trúc + cùng giá trị) với root ban đầu.
Preorder DFS với # cho None.
val,; mỗi None ghi
#,.# → 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) cho cả 2. Bộ
nhớ: O(n).next(tokens)) thay
vì index — tránh bug khi duy trì trạng thái index thủ
công.Cho root của binary tree. Path là dãy
node nối nhau qua edge (không nhất thiết qua root). Tìm path có
sum max.
Input: root = [1, 2, 3]
Cây thực tế:
1
/ \
2 3
Output: 6
(path 2 → 1 → 3, tổng = 6)
Input: root = [-10, 9, 20, null, null, 15, 7]
Cây thực tế:
-10
/ \
9 20
/ \
15 7
Output: 42
(path 15 → 20 → 7, tổng = 42, bỏ qua root -10 vì không có lợi)
DFS bottom-up: mỗi node trả về “đường đi từ
node xuống một lá” tối đa (= node.val + max(left_path,
right_path, 0)). Đồng thời cập nhật best với “đường đi qua
chính node” = node.val + left_path + right_path.
max(..., 0) để có thể bỏ subtree có sum
âm.
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). Bộ
nhớ: O(h) stack.Cho mảng nums. Trả về counts[i] = số phần
tử nhỏ hơn nums[i] ở phía sau trong
mảng.
Input: nums = [5, 2, 6, 1]
Output: [2, 1, 1, 0]
Giải thích:
5 có {2, 1} nhỏ hơn sau → 2.
2 có {1} → 1.
6 có {1} → 1.
1 có 0.
Brute force — O(n²). TLE.
Cách 1 — Merge sort + đếm inversion (Chương 17.2).
Khi merge 2 nửa đã sort, khi lấy phần tử từ nửa trái
L[i], mọi phần tử đã được lấy ra trước từ
nửa phải đều nhỏ hơn L[i] → đếm vào
counts[i].
Cách 2 — Fenwick Tree, O(n log n).
Duyệt từ phải sang trái. Với mỗi nums[i]: -
counts[i] = fenwick.query(rank(nums[i]) - 1) — số phần tử
nhỏ hơn đã thấy. - fenwick.update(rank(nums[i]), +1).
rank = vị trí của nums[i] trong sorted
unique → 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). Bộ
nhớ: O(n).[1, k] với k ≤ n.Thiết kế class: - NumArray(nums): khởi tạo. -
update(index, val): set nums[index] = val. -
sumRange(left, right): tổng
nums[left..right].
Cả update và sumRange phải
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]
Giải thích:
NumArray([1, 3, 5]) → khởi tạo, return null
sumRange(0, 2) → 1 + 3 + 5 = 9
update(1, 2) → đặt nums[1] = 2; mảng giờ là [1, 2, 5]
sumRange(0, 2) → 1 + 2 + 5 = 8
Fenwick Tree là lựa chọn lý tưởng — code ngắn nhất cho bài này. Segment Tree cũng làm được nhưng dài hơn.
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
lưu sum của 1 đoạn dài lowbit(i)”. Trick
i & -i lấy bit thấp nhất → vị trí kế tiếp.| Cấu trúc | Hỗ trợ | Khi nào dùng |
|---|---|---|
| Binary Tree (cây thường) | Duyệt, đường đi | 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 | Range query/update tổng quát | LC 732, 218 |
| Trie | Prefix strings | Chương 23, 41 |
| Fenwick | Segment Tree | |
|---|---|---|
| Op cơ bản | Prefix sum, point update | Range sum, range min/max/gcd… |
| Code | ~10 dòng | ~40-60 dòng |
| Bộ nhớ | n | 4n |
| Lazy propagation | Khó | Dễ |
| Khi đủ dùng | Khi chỉ cần prefix/sum | Khi cần range query + update phức tạp |
±10⁴ → array size lớn? Nhưng số phần
tử ≤ 5 × 10⁴.[0, m-1]. Fenwick size m đủ."1,2,#,#,3,#,#"): tự nhiên với đệ quy, deserialize bằng
queue.O(n) time/space. Preorder thường ngắn hơn
vài byte.Trie (prefix tree) là cây mà mỗi node đại diện cho một ký tự, mỗi đường đi từ root đến node = 1 prefix. Trie giải gọn các bài “tìm word có prefix X”, “search với wildcard”, “longest common prefix nhiều query”. Pattern Bitmask + Trie sẽ ở Chương 41.
Sau chương này, bạn sẽ:
dict[str, Node] (Unicode) vs array 26 (a-z,
nhanh hơn).. hoặc 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 nodeCài đặt class Trie với 3 method: insert,
search, startsWith. Yêu cầu mỗi op
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]
Giải thích:
Trie() → khởi tạo, return null
insert("apple") → null
search("apple") → true
search("app") → false (chưa insert "app" riêng)
startsWith("app") → true (vẫn là prefix của "apple")
insert("app") → null
search("app") → true
Trie = cây mà mỗi node là 1 ký tự, đường đi root → node là 1 prefix.
Insert: walk theo ký tự, tạo node mới khi cần, mark is_end
ở node cuối. Search/StartsWith: walk theo ký tự, return False nếu thiếu
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) với
L = độ dài từ.O(N · L) cho N từ
tổng dài.children: TrieNode[26])
nhanh hơn dict[str, TrieNode] với bảng chữ nhỏ, nhưng tốn
space hơn.__slots__ giảm overhead khi có nhiều
node.Cài đặt class với addWord(word) và
search(word). search cho phép .
thay cho bất kỳ ký tự nào.
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]
Giải thích:
WordDictionary() → null
addWord("bad") → null
addWord("dad") → null
addWord("mad") → null
search("pad") → false ("pad" chưa từng add)
search("bad") → true
search(".ad") → true (`.` match 1 char bất kỳ → khớp "bad", "dad", "mad")
search("b..") → true (khớp "bad")
addWord y hệt Trie thường. search cần
DFS đệ quy khi gặp .: thử tất cả
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 # marker end
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) không wildcard;
O(26^L) worst với toàn ..dict[str] thay vì class — code
ngắn hơn, dễ quan sát khi in ra để gỡ lỗi. Dùng marker $ để
đánh dấu kết thúc word (thay cho thuộc tính is_end).Cho ma trận board và mảng words. Trả về tất
cả word xuất hiện trên board (qua đường đi 4 hướng,
không thăm 1 ô 2 lần).
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"] (thứ tự trong output có thể tuỳ ý)
Brute force: với mỗi word, DFS từ mỗi ô →
O(W · m · n · 4^L). TLE.
Trie + DFS — O(m · n · 4^L) (không phụ
thuộc số word).
Build Trie từ words. DFS từ mỗi ô, đồng hành với một con
trỏ trên Trie. Khi gặp node có is_end → thêm word vào kết
quả. Sau khi đã ghi nhận 1 word, nên xoá word đó khỏi
Trie (đặt is_end = None) để tránh tìm trùng.
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['$'] # tránh add trùng
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) cho Trie.# rồi restore
(visited).Cho mảng words. Tìm word dài nhất mà
mọi prefix của nó cũng có trong mảng. Nếu hoà, lấy word
lex smallest.
Input: words = ["w","wo","wor","worl","world"]
Output: "world" (mọi prefix lồng nhau đều có trong words; có nhiều thì lấy lex nhỏ nhất)
Input: words = ["a","banana","app","appl","ap","apply","apple"]
Output: "apple"
("apple" và "apply" đều có toàn bộ prefix lồng nhau trong words;
"apple" < "apply" theo thứ tự lex → chọn "apple")
Cách 1 — Sort + set, O(N · L log N).
Sort words. Duy trì valid set. Đi qua từng word: nếu mọi
prefix của nó đã trong valid (chỉ cần check prefix
length-1), thêm vào valid.
Cách 2 — Trie + BFS/DFS. Build Trie. DFS preorder,
ưu tiên is_end. Chỉ đi sâu nếu is_end
true.
Cách 1 ngắn gọn hơn cho bài này.
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). Bộ
nhớ: O(Σ L).Cho dictionary (các “root”) và câu
sentence. Với mỗi từ trong sentence, thay nó
bằng root ngắn nhất trong dictionary là prefix của nó.
Nếu không có root nào → giữ từ.
Input: dictionary = ["cat","bat","rat"]
sentence = "the cattle was rattled by the battery"
Output: "the cat was rat by the bat"
Trie. Insert tất cả root. Với mỗi word trong
sentence, duyệt qua Trie: khi gặp is_end đầu tiên → thay
bằng prefix tới đó.
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).
Bộ nhớ: O(N_dict · L).is_end — kinh điển.Thiết kế class StreamChecker với constructor lấy danh
sách words. Method query(letter) trả về
True nếu suffix của stream hiện tại match
một word trong 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]
Giải thích:
StreamChecker(["cd","f","kl"])
query('a') → false (stream = "a")
query('b') → false (stream = "ab")
query('c') → false (stream = "abc")
query('d') → true (suffix "cd" của stream "abcd" match)
query('e') → false
query('f') → true (suffix "f" match)
Insight: suffix khó tìm theo prefix Trie. Reverse trick — build Trie trên các word đảo ngược. Stream cũng đảo (mới nhất đầu): kiểm tra prefix của reversed-stream match Trie.
Implementation: giữ buffer các ký tự đã query. Mỗi query, walk Trie với buffer từ cuối lên đầu (= word đảo).
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).| Tiêu chí | Dict children {ch: TrieNode} |
Array [26] |
|---|---|---|
| Bộ nhớ | Nhỏ khi sparse | Lớn nhưng đều |
| Truy cập | O(1) hash |
O(1) index |
| Hỗ trợ Unicode | ✅ | ❌ (chỉ 26 chữ) |
| Code | Pythonic, ngắn | Nhanh hơn ở C++/Java |
words. DFS
từ mỗi cell với pointer trie.node.word = None (xóa khỏi trie) để không add 2 lần.c_0 c_1 c_2 .... Hỏi sau mỗi char, có
hậu tố nào trùng word không.c_i, đi từ c_i, c_{i-1}, ... ngược lên — đây
là prefix trong trie đảo.Khi đi xuống trie, dừng ngay tại node terminal đầu tiên — đó là root ngắn nhất thay được. Đi tiếp chỉ ra root dài hơn (vô ích).
Union Find (DSU) là cấu trúc dữ liệu cho 2 thao tác trên các tập rời: (i) find(x) — root của tập chứa
x, (ii) union(x, y) — gộp 2 tập. Với path compression + union by rank/size, mỗi op gần nhưO(1)(chính xácO(α(n))với α là hàm Ackermann ngược).
Sau chương này, bạn sẽ:
find (path compression) và
union (by rank/size).O(1) (chính xác 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 # đã cùng tập
# Union by rank — đính cây thấp dưới cây cao.
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)Có n thành phố và ma trận
isConnected[i][j] = 1 nếu i, j nối trực tiếp.
Province là tập thành phố liên thông. Đếm số
province.
Input: isConnected=[[1,1,0],[1,1,0],[0,0,1]]
Output: 2
DSU: union các cặp có cạnh, đếm components. Hoặc DFS/BFS
— đơn giản hơn.
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)). Bộ
nhớ: O(n).O(α) per query.Input: edges: List[List[int]] — mảng cạnh
[u, v] của graph vô hướng. Graph này được
tạo từ 1 tree (n đỉnh, n-1 cạnh) sau đó thêm 1 cạnh
thừa làm xuất hiện chu trình. Tìm cạnh thừa cuối cùng trong
input có thể xoá để còn lại là tree.
Input: edges=[[1,2],[1,3],[2,3]]
Output: [2,3]
DSU union từng cạnh. Cạnh đầu tiên mà find(u) == find(v)
đã → đó là cạnh tạo chu trình → return.
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).Cho mảng accounts, mỗi cái =
[name, email1, email2, ...]. 2 account thuộc cùng người ↔︎
chia sẻ ít nhất 1 email. Gộp các account cùng người thành 1, output
email sorted lex.
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)) cho sort.O(N · L).Cho grid m × n ban đầu toàn nước. Cho mảng
positions[i] = [r, c] — biến mỗi ô thành đất theo thứ tự.
Sau mỗi thêm, trả về số đảo hiện tại.
Input: m = 3, n = 3
positions = [[0,0], [0,1], [1,2], [2,1]]
Output: [1, 1, 2, 3]
Trace từng bước:
Sau [0,0]: [1, 0, 0] → 1 đảo
[0, 0, 0]
[0, 0, 0]
Sau [0,1]: [1, 1, 0] → 1 đảo (nối với (0,0))
[0, 0, 0]
[0, 0, 0]
Sau [1,2]: [1, 1, 0] → 2 đảo
[0, 0, 1]
[0, 0, 0]
Sau [2,1]: [1, 1, 0] → 3 đảo
[0, 0, 1]
[0, 1, 0]
DSU online. Mỗi position: - Mark ô là đất,
components += 1. - Với mỗi hàng xóm 4 hướng đã là đất,
union → nếu union thành công, components -= 1. - Push
components vào kết quả.
from typing import List
class Solution:
def numIslands2(self, m: int, n: int, positions: List[List[int]]) -> List[int]:
parent = [-1] * (m * n) # -1 = nước
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)) với
K = số position.O(mn).parent[idx] != -1 để skip.Cho mảng equations dạng "a==b" hoặc "a!=b".
Trả về True nếu có thể gán giá trị cho biến thoả tất cả
equations.
Input: ["a==b","b!=a"]
Output: False
a == a)? → Hiển nhiên
True; code vẫn xử lý đúng.2 lượt: 1. Union các == equation. 2.
Kiểm tra != equation: nếu 2 biến cùng root → mâu thuẫn.
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).Ma trận grid[i][j] = “elevation” tại ô
(i, j). Tại thời điểm t, ô có elevation ≤ t có
nước phủ đủ để bơi vào. Bắt đầu (0, 0), mục tiêu
(n-1, n-1). Tìm t nhỏ nhấ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
Cách 1 — DSU offline. Sort các ô theo elevation
tăng. Lần lượt “kích hoạt” ô và union với 4 hàng xóm đã kích hoạt. Khi
(0,0) và (n-1,n-1) cùng component → trả
elevation của ô vừa thêm.
Cách 2 — Binary search trên answer + DFS (Chương 37). Cách 3 — Dijkstra (Chương 30).
Cách 1 là pattern DSU đặc trưng nhất.
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
# Liệt kê các ô theo elevation tăng.
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 (sau union(1,0), union(2,1), union(4,3))
forest:
0 3 5
/ \ |
1 2 4
find(x) đi lên cha đến
khi parent[x] == x.Before find(4): After find(4) with compression:
0 0
| / | \
1 1 2 4
| |
2 3 ← 4 trỏ thẳng lên 0 (qua compression)
|
3
|
4
(r, c):
count += 1.union((r,c), neighbor).
Nếu thực sự gộp 2 component khác nhau → count -= 1.O(k × R × C).Cầu nối sang Chương 37 (BS + Graph) và
Chương 33 (MST): - Sort các ô theo độ cao tăng dần. -
Union các ô liền kề đã “ngập” (cùng độ cao đến thời điểm hiện tại). -
Khi (0, 0) và (n−1, n−1) rơi vào cùng một
component, độ cao hiện tại chính là đáp án.
Chương 5 dạy binary search trên mảng đã sort. Chương này dạy kỹ thuật mạnh hơn: binary search trên đáp án (search on answer / parametric search). Khi không gian đáp án có predicate đơn điệu, ta binary search trên giá trị đáp án thay vì index.
Sau chương này, bạn sẽ:
check(x) đơn điệu trên giá
trị x.lo = min possible,
hi = max possible.check(X) đơn điệu.Quy trình: 1. Xác định đáp án là
biến gì (capacity, speed, distance, …). 2. Bound [lo, hi]
của đáp án. 3. Viết check(mid) -> bool đơn điệu. 4.
Binary search “first True” hoặc “last True”.
def search_on_answer(lo: int, hi: int, check) -> int:
"""Tìm giá trị nhỏ nhất trong [lo, hi] thoả check(x)=True."""
while lo < hi:
mid = (lo + hi) // 2
if check(mid):
hi = mid # cố thu nhỏ về True
else:
lo = mid + 1
return loKoko có n đống chuối, đống i có
piles[i] quả. Mỗi giờ Koko ăn 1 đống với tốc độ
k quả/giờ (nếu đống < k, vẫn coi như 1 giờ). Tìm
k nhỏ nhất để ăn hết trong h
giờ.
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
Đáp án k ∈
[1, max(piles)]. Predicate check(k) = “ăn hết
trong ≤ h giờ?”.
time(k) = Σ ceil(piles[i] / k). Predicate đơn điệu:
k lớn hơn → time nhỏ hơn. → Tìm k nhỏ nhất
thoả 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)).
Bộ nhớ: O(1).(p + k - 1) // k tránh
dùng math.ceil với float.Cho weights[] (theo thứ tự), số ngày days.
Tìm capacity nhỏ nhất của ship để chuyển hết hàng trong
days ngày (mỗi ngày 1 chuyến, ship phải chở package liên
tục theo thứ tự).
Input: weights = [1,2,3,4,5,6,7,8,9,10], days = 5
Output: 15
lo = max(weights) đảm bảo không xảy ra.Đáp án cap ∈
[max(weights), sum(weights)]. (cap < max → không chở
được package lớn nhất; cap = sum → 1 ngày xong.)
Predicate: “với cap này, chuyển hết trong ≤ days ngày?” → greedy đếm ngày.
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)).
Bộ nhớ: O(1).lo = max(weights) (không
bỏ qua được), hi = sum(weights).Cho nums (non-negative), số k. Chia mảng
thành k subarray liên tục không rỗng. Tìm
cách chia sao cho max sum subarray là nhỏ
nhất.
Input: nums = [7,2,5,10,8], k = 2
Output: 18 (chia [7,2,5] và [10,8])
Y hệt 25.2! Đáp án = max sum. check(cap) = “chia được
thành ≤ k subarray với mỗi subarray 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).Cho nums. Tính tất cả khoảng cách
|nums[i] - nums[j]| với i < j. Trả về
khoảng cách nhỏ thứ k.
Input: nums=[1,3,1], k=1
Output: 0
Brute force: tạo tất cả C(n, 2) khoảng
cách, sort, lấy thứ k. O(n² log n²). TLE với
n = 10^4.
Search on answer + Sliding window đếm: - Sort
nums. Đáp án ∈ [0, max - min]. -
check(d) = “có ≥ k cặp có khoảng cách ≤ d?” - Đếm cặp bằng
sliding window: với mỗi right, mở rộng left
đến khi nums[right] - nums[left] <= d. Số cặp tạo thành
= 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).Cho 2 mảng sort nums1, nums2. Tìm median của 2 mảng gộp
lại. O(log(m+n)).
Input: nums1=[1,3], nums2=[2]
Output: 2.0
Giải thích: Median của [1,2,3] = 2
Binary search trên partition. Tìm i
(chia nums1 ở vị trí i) và
j = (m + n + 1) // 2 - i (chia nums2) sao cho:
- nums1[i-1] <= nums2[j] và
nums2[j-1] <= nums1[i].
Khi tìm được, median = max(left) (nếu tổng lẻ) hoặc
(max(left) + min(right)) / 2 (chẵn).
Đảm bảo binary search trên mảng ngắn hơn để O(log min(m, n)).
from typing import List
class Solution:
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
# Đảm bảo nums1 ngắn hơn.
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)).
Bộ nhớ: O(1).±INF để xử lý boundary
i = 0 hoặc i = m.Cho n chuồng tại vị trí pos[i] (đã sort) và
c con bò. Đặt mỗi con vào 1 chuồng sao cho khoảng
cách nhỏ nhất giữa 2 con là lớn nhất. Trả về
khoảng cách đó.
Input: pos = [1, 2, 4, 8, 9], c = 3
Output: 3
Giải thích: đặt 1, 4, 8 → min distance = 3.
Đáp án d ∈ [0, max - min].
Predicate: “đặt được c con với khoảng cách ≥ d?” → greedy:
đặt con đầu ở pos đầu tiên, kế tiếp ở pos ≥ pos_trước + 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 # search "last True" → 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 khi
thoả. Khác với “first True” thông thường.| Bài | Đáp số tìm | lo, hi |
Predicate can(x) |
Mục tiêu |
|---|---|---|---|---|
| Koko (LC 875) | tốc độ k | 1, max(piles) |
tổng giờ ≤ H | min x sao cho can(x) |
| Ship (LC 1011) | capacity | max(w), sum(w) |
giao trong ≤ D ngày | min |
| Split Array (LC 410) | largest subarray sum | max(arr), sum(arr) |
chia được ≤ m phần | min |
| Aggressive Cows | khoảng cách d |
1, max - min |
đặt được ≥ C bò | max x sao cho can(x) |
| Magnetic Force (LC 1552) | force | 1, max - min |
đặt được ≥ m | max |
Khung chung (first-true / last-true):
# first-true monotonic: F F F T T T → trả T đầu tiên
while lo < hi:
mid = (lo + hi) // 2
if can(mid): hi = mid
else: lo = mid + 1
return lo
Phải kiểm chứng can(x) đơn điệu theo x: -
Koko: k lớn → ăn nhanh hơn → tổng giờ giảm ⇒
can đơn điệu tăng. - Aggressive Cows:
d lớn → khó đặt hơn → số bò đặt được giảm ⇒
can đơn điệu giảm.
Đây là binary partition: tìm i, j sao
cho A[..i] ∪ B[..j] là nửa nhỏ. Pattern khác hẳn, đừng ép
vào template chung.
positions sorted: [1, 2, 4, 8, 9] cows c = 3, d = ?
d = 3 ⇒ pick 1, 4, 8 ✅ (3 con)
d = 4 ⇒ pick 1, 8 (chỉ 2 con) ❌
⇒ max d sao cho ≥ 3 con = 3
Two pointers là vũ khí cơ bản đã xuất hiện ở Array (1.4, 1.5), String (2.2), Linked List (7.3-7.6). Chương này hệ thống hoá thành 3 mẫu: (i) 2 đầu (đối nghịch), (ii) slow & fast (1×/2×), (iii) same direction (sliding window). Mỗi mẫu giải được một họ bài.
Sau chương này, bạn sẽ:
O(1) extra space khi xử lý.3 mẫu hay nhất:
| Mẫu | Ví dụ bài | Đặc điểm |
|---|---|---|
| 2 đầu | 3Sum, Container Most Water, Trapping | sort trước, hội tụ từ 2 phía |
| Slow & Fast | Cycle, Middle of LL, Move Zeroes | tốc độ khác nhau |
| Same direction | Sliding window, Remove Dup | window mở rộng / thu hẹp |
# 1) 2 đầu (sorted)
l, r = 0, len(arr) - 1
while l < r:
if check(arr[l], arr[r]):
# ... dùng cặp ...
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 += 1Cho nums. Tìm tất cả triplet [a, b, c] với
a + b + c == 0, không trùng lặp.
Input: nums = [-1, 0, 1, 2, -1, -4]
Output: [[-1, -1, 2], [-1, 0, 1]]
Brute force O(n³). Mọi triplet.
Sort + two pointers O(n²).
Sort. Với mỗi i, dùng two pointers l, r tìm
cặp tổng -nums[i] trong nums[i+1..n-1].
Xử lý duplicate: skip ký tự trùng tại cả
i, l, r.
Hình minh hoạ với
nums = [-1, 0, 1, 2, -1, -4] sort →
[-4, -1, -1, 0, 1, 2]:
i=0 (-4): target=4. l=1 (-1), r=5 (2). -1+2=1 < 4. l++. ... không tìm được.
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 (trùng nums[1]).
i=3 (0): target=0. l=4 (1), r=5 (2). 1+2=3 > 0. r--. → l=r → dừng.
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²). Bộ
nhớ: O(1) (không tính output).nums[i] > 0: vì sort
tăng, nums[i] > 0 →
nums[i+1], nums[r] > 0 → tổng > 0.Cho mảng height[]. Mỗi cột rộng 1. Tính lượng nước đọng
giữa các cột.
Input: height = [0,1,0,2,1,0,1,3,2,1,2,1]
(mỗi phần tử là chiều cao 1 cột rộng 1 đơn vị)
Output: 6 (tổng lượng nước đọng giữa các cột)
Cách 1 — Prefix max + Suffix max, O(n) time,
O(n) space.
Mỗi ô i chứa được:
min(max_left[i], max_right[i]) - height[i].
Cách 2 — Two pointers, O(n) time,
O(1) space.
l = 0, r = n - 1, giữ lmax, rmax. Dịch con
trỏ ở phía cột thấp hơn — chính bên đó quyết định nước
đọng tại ô đang xét.
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). Bộ
nhớ: O(1).height[l] < height[r], thì tại ô l,
“bao quanh phải” có ít nhất là height[r] > height[l] →
đủ để xác định nước = lmax - height[l].Đã giải đầy đủ ở Chương 1.5. Đây là two pointers pattern cốt lõi.
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). Bộ
nhớ: O(1).Đã giải đầy đủ ở Chương 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). Bộ
nhớ: O(1).Cho mảng nums đã sort. Xoá duplicate sao cho mỗi phần tử
xuất hiện tối đa 2 lần, trả về độ dài mới.
In-place.
Input: nums = [1,1,1,2,2,3]
Output: 5, nums = [1,1,2,2,3,_]
Two pointers slow & fast. Vì sort, chỉ cần 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). Bộ
nhớ: O(1).x != nums[slow - 2] đẹp — nếu
phần tử thứ 3 trùng, nó sẽ “thấy” bản thân ở slow - 2.Cho nums và target. Tìm tất cả quadruplet
[a, b, c, d] với tổng target, không trùng.
Input: nums=[1,0,-1,0,-2,2], target=0
Output: [[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]
Generalization của 3Sum. Sort + 2 vòng lặp ngoài
(i, j) + two pointers cho (l, r).
O(n³).
Skip duplicate ở cả 4 vị trí
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³). Bộ
nhớ: O(1).nums[i]*4 > target (nếu target lớn).| Shape | Khi dùng | Bài tiêu biểu |
|---|---|---|
Two-end (l=0, r=n-1, đi
vào giữa) |
Sorted, palindrome, container | LC 11, 15, 167, 125 |
Same-direction (l, r đều
đi tiến) |
Subarray với invariant (sliding window là dạng đặc biệt) | LC 26, 283; Chương 27 |
Slow-fast (slow 1 bước,
fast 2 bước) |
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
4 chỗ skip — quên chỗ nào cũng sinh duplicate.
| Prefix/suffix max array | Two pointers | |
|---|---|---|
| Time | O(n) | O(n) |
| Space | O(n) | O(1) |
| Code dài | Trung bình | Ngắn hơn |
| Hiểu | Trực quan: nước tại i = min(L[i], R[i]) - h[i] |
Cần argue tại sao “thanh thấp hơn quyết định” |
Recommend: nói trước prefix/suffix (dễ giải thích), rồi tối ưu two-pointer nếu interviewer hỏi space.
Sliding Window = two pointers cùng chiều. Window
[l, r]mở rộngr, colkhi vi phạm điều kiện. Pattern này giải gọn rất nhiều bài “longest / shortest / count substring/subarray thoả điều kiện” trongO(n).
Sau chương này, bạn sẽ:
r - l + 1 == k.2 mẫu chính:
| Mẫu | Pseudo |
|---|---|
| Fixed-size window | duy trì r - l + 1 == k luôn |
| Variable-size window | mở rộng r luôn, co l đến khi
valid() thoả |
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)Cho chuỗi s. Tìm độ dài substring dài nhất không
có ký tự lặp.
Input: s = "abcabcbb" → Output: 3 (substring đẹp nhất: "abc")
Input: s = "bbbbb" → Output: 1 (substring "b")
Input: s = "pwwkew" → Output: 3 (substring "wke")
Sliding window + set / dict.
Mở rộng r. Khi s[r] đã trong window, co
l cho đến khi không còn.
Tối ưu hơn — dict last-seen index:
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). Bộ
nhớ: O(k) với k = bảng chữ.last[ch] >= l: chỉ jump
l khi last[ch] còn trong window.Cho s, t. Tìm substring nhỏ
nhất của s chứa mọi ký tự của
t (kể cả frequency).
Input: s = "ADOBECODEBANC", t = "ABC" → "BANC"
Sliding window + 2 Counter.
need = Counter(t). have đếm trong
window.have thoả mọi key >= need[key] →
window valid → cố co l.(best_len, l_best, r_best).Optimization: track formed (số key
thoả) thay vì so sánh dict.
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). Bộ
nhớ: O(k).formed là counter “đếm số key đã thoả”
— formed == required là điều kiện valid.have == need mỗi vòng →
O(k) mỗi bước → O(nk) tổng.Cho s và k. Bạn có thể đổi tối đa
k ký tự thành ký tự bất kỳ. Tìm độ dài substring dài nhất
có tất cả ký tự giống nhau sau khi đổi.
Input: s = "ABAB", k = 2 → 4
Input: s = "AABABBA", k = 1 → 4 ("AABA" hoặc "ABBA")
Insight: window valid ↔︎
window_len - max_freq <= k (số ký tự cần đổi ≤ k).
Mở rộng r, cập nhật max_freq. Nếu vi phạm →
co l 1 bước.
Trick:
max_freqkhông cần “decrease” khi col— vì kết quả không giảm khimax_freqchỉ giữ giá trị cũ (window vẫn không lớn hơn).
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). Bộ
nhớ: O(26).max_freq = 1s.Cho s1, s2. Kiểm tra s2 có chứa permutation
của s1 không (= có substring nào trong s2 mà
Counter == Counter(s1)).
Input: s1="ab", s2="eidbaooo"
Output: True
Giải thích: s2 chứa "ba" — permutation của "ab"
Fixed-size sliding window với độ dài
len(s1). So sánh 2 array đếm 26 phần tử mỗi bước.
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).
Bộ nhớ: O(26).have == need so 2 list 26 phần tử =
O(26) constant.Cho nums và k. Đếm số subarray có
đúng k ký tự phân biệt.
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 đếm số subarray với
≤ k distinct integer.
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). Bộ
nhớ: O(k).Đã giải đầy đủ ở Chương 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() # lưu **index**, value tại dq giảm dần
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 = số ký tự đã match đủ.
window mở rộng tới khi have_unique == need_unique
→ co trái cho đến khi mất 1 ký tự cần thiết → cập nhật min.
Bug điển hình: nhầm “match đủ” với “tổng count đủ”.
Chỉ tăng have_unique khi cnt[c] == need[c]
(vừa khớp), giảm khi cnt[c] < need[c] (vừa thiếu).
atMost(K) thường dễ với sliding window.exactly(K) = atMost(K) - atMost(K-1). Áp dụng: LC
992, 1248, 930.| Loại | Pattern | Bài |
|---|---|---|
Fixed k |
Window size k, slide 1 bước |
LC 643, 567 |
| Variable shrink khi vi phạm | Mở r, co l đến khi hợp lệ |
LC 3, 76, 209 |
| atMost K | Mở r, co l đến khi < K đặc
tính |
LC 992, 1248 |
Backtracking = DFS đi kèm với undo state khi quay lui. Đây là pattern để liệt kê mọi cấu hình hợp lệ (permutation, subset, combination, Sudoku, N-Queens). Bí mật của backtracking hiệu quả là pruning — cắt nhánh sớm khi biết chắc không dẫn đến nghiệm.
Chương 12 bài — đầy đủ template + 12 bài kinh điển từ Medium đến Hard.
Sau chương này, bạn sẽ:
choose → explore → unchoose.if i > start and ...).start, check ngân sách (remaining
count).3 thành phần của backtracking: 1. Choice: tại mỗi bước có lựa chọn nào? 2. Constraint: lựa chọn nào hợp lệ? 3. Goal: khi nào dừng và ghi kết quả?
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 — đặc trưng backtrackingCho n và k. Trả về tất cả
combination chọn k số từ
1..n.
Input: n = 4, k = 2
Output: [[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]]
(thứ tự các combination trong output có thể tuỳ ý)
Backtracking với start: tại mỗi bước
chọn 1 số ≥ start để tránh trùng.
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: cần thêm (k - len(path)) số, 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) cho path + stack.start để
không phí nhánh không đủ số.Cho nums (có thể duplicate). Trả về tất cả tập con
không trùng.
Input: nums = [1, 2, 2] (có duplicate)
Output: [[], [1], [1,2], [1,2,2], [2], [2,2]]
(mọi subset duy nhất, mảng kết quả có thể theo thứ tự bất kỳ)
Sort nums. Tại mỗi level, skip
duplicate với
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 đảm bảo skip chỉ ở cùng level (cùng vòng
for đệ quy), không skip giữa parent-child.Cho nums có duplicate. Trả về tất cả hoán vị
phân biệt.
Input: nums = [1, 1, 2] (có duplicate)
Output: [[1,1,2], [1,2,1], [2,1,1]]
(mọi permutation duy nhất)
Sort + skip duplicate. Mảng used. Trick: nếu
nums[i] == nums[i-1] và
nums[i-1] chưa dùng → skip (đảm bảo dùng theo thứ tự index
xuất hiện).
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]: chỉ chọn cái cùng
giá trị nếu cái trước đã chọn rồi.used[i-1] cũng OK — đảo
logic.Cho chuỗi số 2..9. Trả về tất cả tổ hợp ký tự từ bàn
phím điện thoại.
Input: digits = "23"
Output: ["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]
('2' → "abc"; '3' → "def"; thứ tự có thể tuỳ ý)
Input: digits = ""
Output: []
Mapping {'2':'abc', ..., '9':'wxyz'}. Backtrack chọn 1
chữ cho mỗi 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) với n =
len(digits).O(n) stack.Cho candidates (phân biệt) và target. Tìm
tất cả combination tổng target. Mỗi số dùng được
nhiều lần.
Input: candidates=[2,3,6,7], target=7
Output: [[2,2,3],[7]]
Backtrack với start. Cho phép dùng lại
→ đệ quy backtrack(i) thay vì 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 → mọi sau đều lớn
path.append(candidates[i])
backtrack(i, remain - candidates[i])
path.pop()
backtrack(0, target)
return resultO(N^(T/M + 1)) với N=số
candidate, T=target, M=min(candidates).O(T/M) stack.backtrack(i) (không
i+1) để cho phép tái sử dụng.Cho s. Trả về tất cả cách phân chia s sao
cho mỗi phần là palindrome.
Input: s = "aab"
Output: [["a","a","b"], ["aa","b"]]
(mọi cách phân chia mà mỗi phần là palindrome; thứ tự có thể tuỳ ý)
Backtrack: tại mỗi start, thử mọi end sao
cho s[start..end] palindrome, đệ quy
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) (số partition
tối đa = 2^(n-1)).O(n).is_pal[i][j]
O(n²), giúp tránh check lại.Đặt n hậu trên bàn n × n sao cho không có 2
hậu nào tấn công nhau. Trả về tất cả cấu hình (mỗi cấu hình là list các
string).
Input: n=4
Output: [[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
Backtrack theo hàng. Tại hàng r, thử
mỗi cột c. Đảm bảo c chưa dùng, đường chéo
(r - c) chưa dùng, đường chéo (r + c) chưa
dùng.
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²) cho board +
O(n) cho sets.Giải Sudoku 9×9 in-place. Ô chưa điền là '.'.
Input: board = (ma trận 9×9, ký tự '.' cho ô trống, '1'..'9' cho số đã có)
[['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 được mutate in-place thành lời giải hợp lệ
[['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']]
(Hàm không return giá trị; chỉ mutate `board`. Mỗi hàng, mỗi cột,
và mỗi sub-grid 3×3 đều chứa '1'..'9' đúng 1 lần.)
Backtrack mỗi ô trống. Track 3 set: 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^(số ô trống))
worst.O(81).Cho board và word. Kiểm tra
word có thể được build bằng đường đi 4 hướng không qua 1 ô
2 lần.
Input: board=[["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word="ABCCED"
Output: True
DFS từ mỗi ô có board[r][c] == word[0]. Backtrack: mark
# rồi 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.Cho s và wordDict. Trả về tất cả
cách chia s thành các từ trong dict (cách nhau dấu
cách).
Input: s="catsanddog", wordDict=["cat","cats","and","sand","dog"]
Output: ["cats and dog","cat sand dog"]
Backtrack với memoization (cache theo 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²).Cho chuỗi số. Liệt kê tất cả IP hợp lệ có thể tạo (4 số 0..255, mỗi số không leading zero trừ khi là “0”).
Input: s = "25525511135" (chuỗi chỉ chứa chữ số)
Output: ["255.255.11.135", "255.255.111.35"]
(mảng mọi IPv4 hợp lệ tạo từ s bằng cách chèn 3 dấu chấm; không leading zero)
Backtrack chia thành 4 phần. Mỗi phần độ dài 1, 2, hoặc 3 và thoả ràng buộc.
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" không hợp lệ;
"0" hợp lệ.Cho chuỗi số num và target. Thêm
+, -, * giữa các digit sao cho
biểu thức = target. Trả về tất cả biểu thức.
Input: num="123", target=6
Output: ["1+2+3","1*2*3"]
Backtrack với 2 state phụ trợ: - cur = giá trị biểu thức
cho đến giờ. - prev = giá trị của term cuối (cần để xử lý
* đúng precedence).
Khi gặp +: cur += x, prev = x.
Khi gặp -: cur -= x, prev = -x.
Khi gặp *: 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 toán
tử + bỏ qua).O(n) stack.cur - prev + prev * x: undo lần
+/- trước (qua prev) rồi nhân.| Thành phần | Câu hỏi cần trả lời trước khi code |
|---|---|
| Path (trạng thái hiện tại) | List / string / mask? |
| Choice list | Từ n phần tử, hay vị trí, hay chữ số 1..9? |
| Goal test | Khi nào ghi nhận / return? |
| Pruning / constraints | Có thể loại sớm không? Sort trước để skip dup? |
| Undo | Pop list, xoá set, đổi lại mask? |
[1,2,3]) []
/ | \
1 2 3
/ | | \ | \
2 3 1 3 1 2
| | | | | |
3 2 3 1 2 1
Mỗi đường root→leaf = 1 permutation. Backtrack = DFS trên cây ảo này.
Subsets II (sort + skip dup tại same level):
nums.sort()
for i in range(start, n):
if i > start and nums[i] == nums[i-1]: continue # cùng 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 # giữ thứ tự
cols : set[int]./: r + c cùng →
pos_diag : set[int].\: r - c cùng →
neg_diag : set[int].rows[r], cols[c], boxes[(r//3)*3 + c//3].prev_operandVì * ưu tiên hơn +/-, khi nhân, mình phải
rút lại prev rồi nhân:
cur_total - prev_operand + prev_operand * num
Dynamic Programming (DP) = đệ quy + memoization (hoặc bottom-up). Cần 2 tính chất: optimal substructure (lời giải tối ưu của bài lớn xây từ lời giải tối ưu của bài con) + overlapping subproblems (bài con bị lặp). Chương lớn nhất sách — 18 bài chia 3 nhóm:
- DP I (29.1–29.6): LCS, LIS, Edit Distance, Knapsack — DP 2D kinh điển.
- DP II (29.7–29.12): Coin Change, Stock Trading, House Robber — DP trên trục thời gian.
- DP III (29.13–29.18): Partition / Interval DP — DP trên đoạn.
Sau chương này, bạn sẽ:
@cache) vs bottom-up (table).Chương dài nhất sách. Để không lạc, mỗi bài rơi vào 1 trong 4 nhóm:
| Nhóm | Bài (LC) | Đặc điểm chung |
|---|---|---|
| DP I — Sequence DP (2D) | 29.1 LCS, 29.2 LIS, 29.3 Edit Distance | State = (i, j) đại diện 2 prefix |
| 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] hoặc 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 tại k |
Khi gặp 1 bài DP, 4 câu hỏi cốt lõi: (1) State là gì? (2) Transition thế nào? (3) Base case? (4) Đáp án ở đâu?
| Bài | State | Transition | Base | Answer |
|---|---|---|---|---|
| 29.1 LCS | dp[i][j] = LCS của 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] = giá trị nhỏ nhất kết LIS dài
k+1 |
bisect_left + replace/append |
tails = [] |
len(tails) |
| 29.3 Edit Distance | dp[i][j] = min edit s1[..i] → s2[..j] |
Match: dp[i-1][j-1]; Else:
1 + min(3 chiều) |
dp[i][0]=i, dp[0][j]=j |
dp[m][n] |
| 29.4 0/1 Knapsack | dp[w] = max value với capacity w |
dp[w] = max(dp[w], dp[w-wi]+vi) (duyệt ngược) |
dp[0..W] = 0 |
dp[W] |
| 29.5 Partition Equal Sum | dp[w] = có subset sum = w? |
dp[w] = dp[w] or dp[w-x] |
dp[0] = True |
dp[sum/2] |
| 29.6 Russian Doll | Sort 2D + LIS trên h |
Như LIS (29.2) | — | len(tails) |
| 29.7 Coin Change | dp[a] = min coin tổng a |
dp[a] = min(dp[a-c]+1) |
dp[0] = 0 |
dp[amount] |
| 29.8 Coin Change II | dp[a] = số cách |
dp[a] += dp[a-c] (outer coin) |
dp[0] = 1 |
dp[amount] |
| 29.9 Stock Cooldown | hold[i], sold[i], rest[i] |
3 transitions giữa state | hold[0] = -prices[0] |
max(sold[-1], rest[-1]) |
| 29.10 Stock IV | dp[t][i] = max profit ≤ t txn đến ngày i |
max(dp[t][i-1], price[i]+max_diff) |
dp[0][i] = 0 |
dp[k][n-1] |
| 29.11 House Robber II | Tách 2 case (rob 0 / không) | Như Robber I | — | max(case_1, case_2) |
| 29.12 Max Product | cur_max, cur_min rolling |
Swap khi x < 0 |
Start = nums[0] |
max(best) |
| 29.13 Palindrome Partition II | dp[i] = min cut cho s[..i] |
dp[i] = min(dp[j-1]+1) nếu s[j..i]
palindrome |
dp[i]=0 nếu s[0..i] palindrome |
dp[n-1] |
| 29.14 Burst Balloons | dp[i][j] = max điểm nổ (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 phép nhân 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
(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 người hiện tại |
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 turn in s[i..j] |
min(dp[i][j-1]+1, dp[i][k] + dp[k+1][j-1] khi s[k]==s[j]) |
dp[i][i]=1 |
dp[0][n-1] |
Nếu chỉ có 1 ngày, đọc theo thứ tự ưu tiên:
3 bước xây DP: 1. Định nghĩa state:
dp[i][j] = ? 2. Transition:
dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...) 3. Base
case + thứ tự duyệt: từ nhỏ đến lớn.
Top-down (memo) vs Bottom-up (tabulation): -
Top-down: viết đệ quy + @cache. Dễ thấy “transition”. -
Bottom-up: vòng for tăng dần. Tiết kiệm stack, dễ tối ưu space.
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-optimized 1D (khi transition chỉ dùng dòng trước)
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 = curCho 2 chuỗi s1, s2. Tìm độ dài LCS
(longest common subsequence).
Input: text1 = "abcde", text2 = "ace"
Output: 3
(LCS dài nhất là "ace")
Input: text1 = "abc", text2 = "def"
Output: 0
(không có ký tự chung)
dp[i][j] = LCS của s1[0..i-1] và
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); có thể giảm về
O(min(m, n)).O(min(m,n)): chỉ giữ 2 row.O(n log n)Tìm độ dài LIS (strictly increasing) của nums.
Input: nums = [10, 9, 2, 5, 3, 7, 101, 18]
Output: 4
(1 LIS dài nhất: [2, 3, 7, 18]; có thể có nhiều LIS khác)
Input: nums = [0, 1, 0, 3, 2, 3]
Output: 4
(1 LIS dài nhất: [0, 1, 2, 3])
< không
≤).DP O(n²) —
dp[i] = max(dp[j]) + 1 với
j < i, nums[j] < nums[i].
O(n log n) — Patience
Sort: - tails[i] = giá trị nhỏ nhất kết thúc LIS
độ dài i + 1. - Duyệt nums, dùng binary search
(bisect_left) để tìm vị trí thay/extend.
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 không phải LIS thật — chỉ length
đúng.Cho word1, word2. Số phép biến đổi tối thiểu (insert,
delete, replace) để word1 → word2.
Input: word1="horse", word2="ros"
Output: 3
Giải thích: horse → rorse → rose → ros (3 thao tác)
dp[i][j] = edit distance giữa prefix i của
word1 và prefix j của 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); có thể giảm về
O(min(m, n)).dp[i][0] = i, dp[0][j] = j.Cho n đồ vật, mỗi cái có weight[i] và
value[i]. Túi có sức chứa W. Chọn các đồ vật
để max tổng value, mỗi cái dùng ≤ 1 lần.
Input: weights=[1,3,4,5], values=[1,4,5,7], W=7
Output: 9
dp[i][w] = max value khi xét i đồ đầu tiên
với capacity w.
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i-1]] + value[i-1])
(nếu fit).
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):
# Duyệt ngược để mỗi item dùng ≤ 1 lần.
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) sau khi rolling
array.Cho nums (positive). Kiểm tra có thể chia làm 2 tập có
tổng bằng nhau không.
Input: nums = [1, 5, 11, 5]
Output: True
(chia được thành [1,5,5] và [11], 2 nhóm tổng = 11)
Đặt S = sum(nums). Nếu S lẻ → False. Tìm
subset có tổng S / 2 → knapsack boolean
DP.
dp[w] = True/False (có subset tổng = w không).
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).Cho envelopes[i] = [w, h]. Envelope A fit
trong B nếu A.w < B.w và
A.h < B.h. Tìm độ dài chuỗi fit tối đa.
Input: envelopes = [[5,4], [6,4], [6,7], [2,3]]
(mỗi phần tử [width, height])
Output: 3 (chuỗi [2,3] ⊂ [5,4] ⊂ [6,7])
Trick “sort 2D”: sort theo w tăng,
h giảm với cùng w (để tránh 2
envelope cùng w fit lẫn nhau). Sau đó tìm LIS trên dãy
h.
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]: với cùng w,
h giảm → cùng w không tạo “LIS” trên h.Cho coin denominations và amount. Số coin tối
thiểu để gộp thành amount. -1 nếu không thể.
Input: coins=[1,2,5], amount=11
Output: 3
Giải thích: 11 = 5+5+1
dp[a] = min coin để gộp thành 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 làm sentinel.Đếm số cách gộp amount từ coins (unbounded).
Input: amount=5, coins=[1,2,5]
Output: 4
dp[a] = số cách gộp thành a.
Thứ tự duyệt quan trọng: outer loop coin, inner loop amount → tránh đếm trùng (mỗi combination đếm 1 lần).
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).Mua/bán nhiều lần, sau khi bán phải cooldown 1 ngày mới mua lại được.
Input: prices = [1, 2, 3, 0, 2] (prices[i] = giá ngày i)
Output: 3 (giao dịch: buy@1 sell@2; cooldown; buy@0 sell@2)
3 state mỗi ngày: hold (đang giữ), sold
(vừa bán hôm nay), rest (nghỉ).
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])Đáp án = 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 phải
wait 1 day trước khi hold lại.Tối đa k giao dịch. Max profit.
Input: k=2, prices=[3,2,6,5,0,3]
Output: 7
dp[t][i] = max profit dùng ≤ t giao dịch
tới ngày i.
dp[t][i] = max(dp[t][i-1], max(price[i] - price[j] + dp[t-1][j-1]) for j < i).
Tối ưu: theo dõi 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); có thể giảm về
O(k).k >= n // 2 → reduce thành
unlimited (LC 122).Tên trộm không thể trộm 2 nhà kề nhau. Nhà xếp vòng
tròn (nums[0] và nums[n-1] kề
nhau).
Input: nums = [2, 3, 2] (nums[i] = tiền nhà thứ i, các nhà xếp vòng tròn)
Output: 3 (chỉ trộm được 1 trong 2 nhà đầu/cuối, ở đây trộm 1 nhà giá 3)
Tách 2 case: trộm hoặc không trộm nhà đầu. - Case 1:
trộm nhà 0 → không trộm nhà n-1 → House Robber trên
nums[0..n-2]. - Case 2: không trộm nhà 0 → House Robber
trên nums[1..n-1].
Đáp án = max của 2.
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).Cho nums. Tìm subarray liên tục có tích
max.
Input: nums = [2, 3, -2, 4]
Output: 6 (subarray [2, 3] có tích 6 là tích lớn nhất của 1 subarray liên tiếp)
Track cả max và min tại mỗi i (vì 2 số âm × nhau ra
dương): - 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: số âm đảo vai
trò max/min → ngắn gọn.Cho s. Min cuts để chia s thành toàn
palindrome.
Input: s = "aab"
Output: 1
(cắt 1 lần thành ["aa", "b"], 2 mảnh đều là palindrome)
Giải thích: Cut thành ["aa", "b"]
dp[i] = min cuts cho s[0..i]. Pre-compute
is_pal[i][j]. Transition: - Nếu s[0..i] đã
palindrome → dp[i] = 0. - Ngược lại:
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²) (precompute
palindrome) + O(n²) DP.O(n²).is_palindrome để tránh
check lại trong vòng DP chính.Cho nums đại diện balloon. Nổ mỗi balloon i
được điểm nums[i-1] * nums[i] * nums[i+1] (boundary là
1). Max điểm tổng.
Input: nums = [3, 1, 5, 8] (balloons từ trái sang phải)
Output: 167 (max coin khi nổ tất cả; mỗi lần nổ i được nums[L]*nums[i]*nums[R])
Interval DP đảo ngược: thay vì nghĩ “nổ trước cái
nào”, nghĩ “nổ cuối cùng cái nào trong interval
[i, j]”.
dp[i][j] = max điểm khi nổ tất cả balloon trong (i, j)
(exclusive).
dp[i][j] = max(nums[i] * nums[k] * nums[j] + dp[i][k] + dp[k][j])
với k ∈ (i, j).
Pad nums với 1 ở 2 đầu.
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²).Cho n ma trận A1 · A2 · ... · An với chiều
p[i-1] × p[i]. Tìm cách đặt ngoặc để min số phép nhân.
Input: p=[10,20,30,40,30]
Output: 30000
dp[i][j] = min cost nhân
A_i · A_{i+1} · ... · A_j. Split tại 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²).Stick độ dài n. Mảng cuts là vị trí cần
cắt. Cost mỗi lần cắt = chiều dài stick hiện tại. Tìm cost min.
Input: n=7, cuts=[1,3,4,5]
Output: 16
Sort cuts cộng thêm boundary [0, n].
dp[i][j] = min cost cắt stick từ cuts[i] đến
cuts[j]. Try mỗi 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³) với m = len(cuts) +
2.O(m²).[0, n] vào
cuts → mất thông tin biên.Alice và Bob lần lượt lấy hòn đá ở 2 đầu array.
Người lấy được điểm = sum các đá còn lại. Cả 2 chơi
optimal. Tìm chênh lệch Alice - Bob.
Input: stones = [5, 3, 1, 4, 2] (giá trị từng stone, 2 đầu mới được lấy)
Output: 6 (Alice - Bob với cả hai đều chơi tối ưu)
Game theory + Interval DP. dp[i][j] = chênh lệch tối ưu
khi xét stones [i..j]. Người hiện tại pick lấy điểm
sum[i+1..j] (bỏ stones[i]) hoặc sum[i..j-1]
(bỏ stones[j]) trừ đi 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 của [i+1..j]
gain_l = prefix[j + 1] - prefix[i + 1]
# Remove stones[j]: gain = sum của [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²) cho memo.Cho s. Máy in mỗi turn in 1 chuỗi gồm cùng 1 ký tự, có
thể đè lên cái trước. Tìm số turn tối thiểu để in được
s.
Input: s = "aaabbb"
Output: 2 (máy in chỉ in được 1 ký tự liên tiếp 1 lần; tối thiểu 2 lượt)
Interval DP. dp[i][j] = min turn in
s[i..j].
dp[i][i] = 1.dp[i][j] = dp[i][j-1] + 1 (in s[j] riêng).k < j với s[k] == s[j],
có thể “tận dụng” lượt in s[k] để 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
giao dịch.Burst Balloons / Strange Printer / Matrix Chain: -
dp[i][j] = đáp số tối ưu cho range [i..j]. -
Hỏi: operation nào thực hiện CUỐI CÙNG trong range này?
Lựa chọn k ∈ [i..j] chia thành [i..k-1] và
[k+1..j] đã giải xong. - Khác với “chọn đầu tiên” — đa số
trường hợp “chọn cuối” cho recurrence sạch hơn.
"" |
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 |
3 phép min(insert, delete, replace) + 1; khớp ký tự → kế
thừa chéo.
Dijkstra tìm đường đi ngắn nhất từ 1 source đến mọi đỉnh trong graph trọng số không âm. Khi cạnh có thể âm → Bellman-Ford. Khi cạnh đồng nhất (= 1) → BFS đủ rồi. Dijkstra với heap chạy
O((V + E) log V).
Sau chương này, bạn sẽ:
if d > dist[u]: continue thay
priority update.k+1 lần thay vì
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 distCho times[i] = [u, v, w] (directed weighted),
n đỉnh, source k. Tìm thời gian để tín
hiệu đến tất cả đỉnh, hoặc -1.
Input: times=[[2,1,1],[2,3,1],[3,4,1]], n=4, k=2
Output: 2
Dijkstra từ k. Đáp án = max của 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) là idiomatic — không cần
priority update.Grid heights. Đi từ (0,0) đến (m-1,n-1) (4
hướng). Effort = max |h(u) - h(v)| qua các
cạnh trên path. Tìm min effort.
Input: heights = [[1,2,2],
[3,8,2],
[5,3,5]] (grid m×n, đi 4 hướng từ (0,0) → (m-1,n-1))
Output: 2 (effort min = max |h[u] - h[v]| trên path tốt nhất)
Dijkstra với “trọng số path” = max edge weight thay
vì 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).Cho flights[i] = [u, v, price], n đỉnh,
source src, dest dst, k stops.
Tìm cheapest flight ≤ 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 với k + 1 lần lặp là cách
gọn nhất.
Dijkstra cũng giải được, nhưng state phải mở rộng thành
(cost, node, stops) — phức tạp hơn vì state có 2 chiều.
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: phải dùng copy để không
“lan toả” trong cùng 1 lượt.Đã giải đầy đủ ở Chương 24.6 (DSU offline). Cũng giải được bằng Dijkstra: trọng số path = max elevation gặp phải.
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 = ô
trống đi được, 1 = vật cản) và số nguyên k. Đi 4 hướng từ
(0,0) → (m-1, n-1), được phá tối đa
k vật cản. Tìm shortest path từ (0,0)
đến (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
BFS với state (r, c, eliminations_left)
(vì cạnh trọng số 1). Dijkstra cũng được nhưng 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
# Optimization: k đủ lớn → BFS thuần.
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) state với k =
eliminations.O(R · C · k).eliminations_left để
tránh thăm trùng.Grid với mũi tên ở mỗi ô (4 hướng). Đi theo mũi tên free; đổi mũi tên
cost 1. Tìm cost min để có path từ (0,0) đến
(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; theo mũi tên free, đổi cost 1)
Output: 3 (cần đổi 3 ô để có path hợp lệ từ (0,0) tới (m-1,n-1))
0-1 BFS (Dijkstra với deque): - Cạnh “đi theo mũi tên” = 0 → đẩy vào đầu deque. - Cạnh “đổi mũi tên” = 1 → đẩy vào cuối.
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 với
deque).O(V).O(V + E).| Tính chất đồ thị | Thuật toán | Time |
|---|---|---|
| Unweighted (mọi cạnh = 1) | BFS | O(V + E) |
| Edge weight ∈ {0, 1} | 0-1 BFS (deque, push_front cho 0, push_back cho 1) | O(V + E) |
| Edge weight ≥ 0 | Dijkstra (heap) | O((V + E) log V) |
| Có cạnh âm, không vòng âm | Bellman-Ford | O(V · E) |
| Có cạnh âm, cần detect vòng âm | Bellman-Ford + check vòng V | O(V · E) |
| All-pairs, V ≤ 500 | Floyd-Warshall | O(V³) |
| DAG | Topo + relax | O(V + E) |
dist[node].K+1 vòng (mỗi vòng relax ≤ 1 cạnh
thêm).O(R · C), không cần log của heap.Game theory trong phỏng vấn coding là bài 2 người chơi luân phiên, mỗi người chơi optimally. Pattern chung: DP minimax — tại mỗi state, người đi tìm cách max điểm mình hoặc min điểm đối thủ. Bài có chu trình → dùng BFS multi-source từ “terminal states”.
Sau chương này, bạn sẽ:
diff(l, r): chênh lệch tối đa người hiện tại
đạt được.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))Có n đá. Mỗi turn lấy 1, 2, hoặc 3 đá. Người lấy đá cuối
thắng. Alice đi trước. Hỏi Alice có thắng không.
Input: n=4
Output: False
Quan sát (induction): - n ∈ {1,2,3}:
Alice lấy hết → thắng. - n == 4: bất kể Alice lấy 1/2/3,
Bob luôn ở trạng thái {1,2,3} → Bob thắng. - Mở rộng: Alice
thắng ↔︎ n % 4 != 0.
class Solution:
def canWinNim(self, n: int) -> bool:
return n % 4 != 0O(1).O(1).Mảng piles (chẵn cái, sum lẻ). Alice và Bob lần lượt lấy
đống ở 2 đầu. Alice đi trước. Cả 2 chơi optimal. Alice
thắng?
Input: piles = [5, 3, 4, 5] (số pile chẵn, tổng stones lẻ; 2 đầu mới lấy được)
Output: True (Alice luôn thắng khi cả 2 chơi tối ưu)
Trick math: Vì n chẵn, Alice có thể chiến lược luôn lấy chỉ số chẵn hoặc luôn lẻ — chọn tổng lớn hơn. Vì sum lẻ, 1 trong 2 phải > sum/2.
→ Alice luôn thắng.
from typing import List
class Solution:
def stoneGame(self, piles: List[int]) -> bool:
return True
class SolutionDP:
"""DP version — pattern chuẩn cho biến thể."""
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²) cho memo.diff(l, r) = chênh lệch tối đa người
hiện tại có thể đạt được trên piles[l..r].Như Stone Game, nhưng n bất kỳ (có thể lẻ), sum bất kỳ. Alice thắng nếu score(Alice) ≥ score(Bob).
Input: nums = [1, 5, 2] (2 player luân phiên lấy 1 đầu mảng)
Output: False (Player 1 không thể thắng khi cả 2 chơi tối ưu)
DP diff(l, r) y hệt 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²) cho memo.Piles. Alice trước. Mỗi turn lấy X pile đầu tiên với
1 <= X <= 2*M (M ban đầu 1, sau mỗi turn
M = max(M, X)). Tối đa số đá Alice lấy được.
Input: piles = [2, 7, 9, 4, 4] (M ban đầu = 1; người chơi lấy X piles với 1 ≤ X ≤ 2M, sau đó M = max(M, X))
Output: 10 (số stones tối đa Alice lấy được)
DP dfs(i, M) = số đá người hiện tại có
thể lấy được từ piles[i:] với M hiện tại. Maximize của các
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²) cho memo.suffix[i] - min(...): tổng còn lại trừ
đi cái đối thủ tối ưu hoá lấy = phần Alice giữ được.Input: graph: List[List[int]] — adjacency list
của một graph vô hướng, graph[i] là danh sách các
node kề node i. Đỉnh đánh số 0..n-1.
Luật chơi: - Mouse bắt đầu ở node 1,
Cat bắt đầu ở node 2,
hole là node 0. - Lượt 1: chuột đi, lượt
2: mèo đi (mỗi lượt mỗi con buộc phải đi sang 1 neighbor). - Mèo
không được vào hole (node 0). - Mouse thắng khi đến
hole; Cat thắng khi cùng ô với Mouse; hoà nếu lặp
state.
Trả về 1 trong 3 giá trị: 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 của graph vô hướng; graph[i] = các node kề node i)
Graph trên có 6 node 0..5; node 0 là hole.
Output: 0
(0 = DRAW, 1 = MOUSE_WIN, 2 = CAT_WIN)
BFS từ terminal states (retrograde analysis). Pattern này khác DP vì state graph có chu trình (mèo & chuột có thể đi tới đi lui) ⇒ memoization top-down không terminate.
State = (mouse, cat, turn) với
turn ∈ {0=mouse, 1=cat}. Tổng O(n²)
states.
Terminal: - mouse == 0 (chuột vào hole)
⇒ MOUSE_WIN. - mouse == cat (cùng ô, mèo bắt)
⇒ CAT_WIN.
Outcome propagation (retrograde): với mỗi state đã
biết outcome, đi ngược (xét các parent state có thể dẫn
đến state này) và suy: - Nếu đến lượt người chiến thắng
ở parent (vd parent là lượt chuột và state hiện tại là
MOUSE_WIN) ⇒ parent cũng WIN (chỉ cần 1
nước thắng). - Nếu đến lượt người thua ở parent ⇒ chỉ
ghi parent thua khi tất cả nước đi của họ đều dẫn về
thua (đếm bằng degree).
State/outcome diagram (rút gọn):
Terminal layer:
(0, *, *) ─────────────► MOUSE_WIN
(k, k, *) k>0 ─────────────► CAT_WIN
Retrograde propagation (BFS):
state S đã biết outcome = X
│
▼ for each parent P of S (đảo lượt):
│
├── lượt(P) là "người thắng X" ? YES → P = X (push)
│
└── lượt(P) là "người thua X" ?
degree[P] -= 1
if degree[P] == 0:
P = X (push) ← mọi nước đều thua
state còn lại sau khi BFS kết thúc → DRAW.
Đây là bài Hard có độ khó cao trên LeetCode; chương này tập trung giới thiệu pattern (retrograde BFS), phần cài đặt đầy đủ ngay bên dưới.
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²).Có 1..maxChoosable. Mỗi turn người chơi chọn 1 số chưa
chọn. Người đầu tiên làm tổng các số đã chọn ≥ desiredTotal
thắng. Alice trước. Alice thắng?
Input: maxChoosable=10, desiredTotal=11
Output: False
Bitmask DP — state = set các số đã chọn (bitmask) +
current sum. @cache memoize.
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 # đối thủ thua → ta thắng
return False
return dfs(0, desiredTotal)O(2^n · n).O(2^n) cho memo.| Loại | Đặc trưng | Bài |
|---|---|---|
| Math trick | Phân tích parity / sum / “always wins” | LC 877 (Stone Game I), LC 292 Nim |
| Minimax DP | dp[state] = giá trị tối ưu cho người đến lượt |
LC 486, 1140 |
| Memoized state graph | State rời rạc, transitions phức tạp | LC 464 (Can I Win) |
| Retrograde BFS | Truyền outcome từ trạng thái terminal lùi | LC 913 (Cat and Mouse) |
n chẵn ⇒ Alice có thể chọn toàn bộ pile
chẵn-index hoặc toàn bộ pile lẻ-index (chia
màu xanh/đỏ). Tổng even ≠ odd (vì
even + odd = total lẻ tổng cộng các pile) ⇒ Alice chọn nhóm
tổng lớn hơn.bitmask các số đã dùng (≤ 20 → mask ≤
2²⁰).memo[mask] = True nếu người đến lượt
có nước thắng.(mouse_pos, cat_pos, turn). Terminal: mouse ở
hole → mouse win; cat == mouse → cat win.Bài “string parser” hỏi bạn implement 1 mini-language: calculator, atom formula, lisp, … Pattern chung: (i) Stack cho cấu trúc lồng, (ii) State machine cho parsing token, (iii) Recursive descent cho grammar có nested rule. Bài 8.6 (Decode String) và 2.4 (atoi) đã teaser.
Sau chương này, bạn sẽ:
3 mẫu thường dùng:
| Mẫu | Bài ví dụ |
|---|---|
| Stack với operator | Basic Calculator I/II |
| FSM tokens | atoi, Valid Number |
| Recursive descent | Parse Lisp, Add Operators |
Cho chuỗi s chứa số, +, -,
*, /, dấu cách. Tính giá trị biểu thức. Chia
lấy phần nguyên hướng về 0. Không có ngoặc.
Input: s = " 3+5 / 2 "
Output: 5
(3 + (5/2) = 3 + 2 = 5; chia lấy phần nguyên)
Input: s = " 14-3/2 "
Output: 13
(14 - (3/2) = 14 - 1 = 13)
Stack giữ “term” đang được build. Khi gặp operator
mới: - +x → push x. - -x → push
-x. - *x → top = top * x. - /x →
top = int(top / x).
Cuối: sum 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) cho stack.+: không phải
special-case lần cuối.-7 // 2 = -4, nhưng
cần -3 (truncate to 0) → dùng int(a / b).Đã giải đầy đủ ở Chương 8.6. Pattern stack lồng với
(prev_str, k).
Đây là gateway cho String Parser — nested structure → stack. Toàn chương 32 mở rộng pattern này: 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 = độ dài output).
Bộ nhớ: O(N).Cho công thức hoá học (vd "K4(ON(SO3)2)2"). Trả về dạng
chuẩn K4N2O14S4 — alphabetic atomic names + counts.
Input: formula = "H2O"
Output: "H2O"
Input: formula = "Mg(OH)2"
Output: "H2MgO2"
(Mg×1, O×2, H×2; sort alphabetic; số 1 lược bỏ)
Input: formula = "K4(ON(SO3)2)2"
Output: "K4N2O14S4"
(K×4, N×2, O×14, S×4; sort alphabetic)
Stack of dicts. Mỗi ( mở 1 dict mới.
Mỗi ) + multiplier → multiply dict và merge vào parent. Mỗi
atom name + count → cộng vào dict top.
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 (merge
dicts).O(n).Kiểm tra string s có match pattern p không.
p chứa . (match 1 char) và *
(match 0+ ký tự trước đó).
Input: s = "aa", p = "a*"
Output: True
(`a*` match 0+ ký tự 'a' → match "aa")
Input: s = "mississippi", p = "mis*is*p*."
Output: False
Input: s = "ab", p = ".*"
Output: True
(`.*` match 0+ ký tự bất kỳ → match "ab")
a-z, .,
*? → Theo đề: có.DP 2D. dp[i][j] = s[:i]
match p[:j] không.
p[j-1] là *:
dp[i][j-2].dp[i-1][j].p[j-1] là . hoặc ==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). Bộ
nhớ: O(m · n).* bao
gồm cả 0 lần — đây là điểm khó.Kiểm tra một chuỗi s có phải là số hợp lệ hay không.
Loại số hợp lệ bao gồm: số nguyên, số thập phân, và ký pháp khoa học
(scientific notation), ví dụ "2", "-2.5",
"3e+10", "-.5".
Input: s = "0" → Output: True
Input: s = "e" → Output: False (chỉ chữ e, không có digit)
Input: s = "." → Output: False (chỉ dấu chấm, không có digit)
Input: s = "+6e-1" → Output: True (số khoa học hợp lệ)
++3)? → Không hợp lệ.. riêng lẻ? → Không hợp lệ.FSM 9 trạng thái hoặc dùng regex. FSM gọn và dễ giải thích hơn cho phỏng vấn.
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).^[+-]? — sign tuỳ chọn.(\d+\.?\d*|\.\d+) — 12, 12.,
12.3, .3 (integer / decimal).([eE][+-]?\d+)? — exponent tuỳ chọn.Convert số num (≤ 2³¹-1) sang text tiếng Anh.
Input: num = 123
Output: "One Hundred Twenty Three"
Input: num = 12345
Output: "Twelve Thousand Three Hundred Forty Five"
Input: num = 0
Output: "Zero"
Chia thành nhóm 3 chữ số (units, thousands, millions, billions). Mỗi nhóm 3 có 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) (chia nhóm
nghìn).O(log n).num == 0 →
"Zero".| Loại | Đặc trưng | Bài |
|---|---|---|
| Stack-based | Nested structures (paren, brackets) | LC 224, 394, 726 |
| FSM / state machine | Token tuyến tính, các state rõ ràng | LC 8, 65 |
| Recursive descent | Grammar có cấu trúc đệ quy | LC 736, 770 |
| DP on string | Match với pattern (., *) |
LC 10, 44 |
States: 0 start, 1 sign, 2
int, 3 dot_after_int, 4 dot_before_int
(require frac), 5 frac, 6 e/E,
7 exp_sign, 8 exp_int, 9 end
(whitespace/error). | Trạng thái | 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}.
* cho phép 0 hoặc nhiều lần → quyết định không
local: chữ * ở p[j] ảnh hưởng nhiều
prefix của s.dp[i][j] = s[..i] match
p[..j]?p[j] == '*': thử “dùng 0 lần”
dp[i][j-2] hoặc “dùng thêm 1 lần” dp[i-1][j]
nếu s[i-1] khớp 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, mul 2, merge into base {K:4} → {K:4, O:14, N:2, S:4}
MST = subset cạnh kết nối tất cả đỉnh với tổng trọng số min, không có chu trình. 2 thuật toán: Kruskal (sort edge + DSU) và Prim (heap grow từ 1 đỉnh). Cả 2 đều
O(E log E).
Sau chương này, bạn sẽ:
# 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 grow
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 totalCho points 2D. Cost nối 2 điểm = Manhattan distance. Tìm
cost min để nối tất cả.
Input: points = [[0,0], [2,2], [3,10], [5,2], [7,0]]
(mỗi điểm [x, y]; cost giữa 2 điểm = |x1-x2| + |y1-y2| Manhattan)
Output: 20 (tổng cost MST nối tất cả điểm)
Kruskal: tạo tất cả O(n²) cạnh, sort,
DSU. Prim: hợp lý hơn với dense graph (mỗi cặp đều có
cạnh) — 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²) với
dense Prim (no heap).O(n²) thay vì
heap khi graph dense.Cho cities 1..n và list
connections[i] = [a, b, cost]. Min cost kết nối tất cả,
hoặc -1 nếu không thể.
Input: n=3, connections=[[1,2,5],[1,3,6],[2,3,1]]
Output: 6
Kruskal cơ bản. Sort tất cả cạnh theo trọng số tăng dần, dùng DSU để chỉ nhận cạnh nối 2 component khác nhau; sau khi duyệt hết, nếu DSU còn đúng 1 component thì trả về tổng trọng số.
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 nhà. Mỗi nhà có thể: (a) đào giếng riêng cost
wells[i], hoặc (b) nối ống với nhà khác
pipes[j] = [u, v, c]. Min total cost cấp nước cho tất
cả.
Input: n=3, wells=[1,2,2], pipes=[[1,2,1],[2,3,1]]
Output: 3
Trick virtual node 0: thêm node 0 và cạnh
(0, i, wells[i]) cho mỗi nhà. Sau đó MST trên
n+1 đỉnh.
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 (số đỉnh) và
edges: List[List[int]] — mỗi cạnh [u, v, w] là
cạnh vô hướng có trọng số w. Tìm tất cả
critical edges (xoá → MST cost tăng) và
pseudo-critical (có thể xuất hiện trong 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 ban đầu.e:
from typing import List
class Solution:
def findCriticalAndPseudoCriticalEdges(self, n: int, edges: List[List[int]]) -> List[List[int]]:
# Đánh nhãn index để track sau khi sort.
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 (mỗi
edge thử skip + force).O(V + E).Cho graph vô hướng với edgeList[i] = [u, v, dist] và
mảng queries[j] = [p, q, limit]. Với mỗi query, trả về
True nếu tồn tại path giữa p, q mà mọi cạnh
trên path có 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 có hợp lệ không? →
Không (đề dùng <).Brute force: BFS/DFS mỗi query với điều kiện
dist < limit. O(Q · E). TLE.
Tối ưu — Offline Kruskal-style —
O((E + Q) log).
Sort cả edgeList (theo dist) và
queries (theo limit). Duyệt queries tăng: với
mỗi query, union tất cả edge có
dist < limit vào DSU. Sau đó 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])
# Thêm index gốc của query để output đúng thứ tự.
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).< (strict) theo đề; nếu
đề bài cho <= thì đổi điều kiện.Grid m × n. Tìm path từ (0,0) đến
(m-1,n-1) (4 hướng) sao cho giá trị min trên
path là max có thể. Trả về giá trị đó.
Input: grid = [[5, 4, 5],
[1, 2, 6],
[7, 4, 6]] (grid m × n, đi 4 hướng từ (0,0) → (m-1, n-1))
Output: 4 (giá trị MIN gặp phải trên path tốt nhất đạt cực đại = 4) (path 5→4→5→6→6 có min = 4)
1 <= m, n <= 1000 <= grid[i][j] <= 10^9Pattern “Kruskal ngược” — kích hoạt ô theo giá trị
giảm dần. Khi (0,0) và
(m-1,n-1) cùng component → giá trị ô vừa kích hoạt là đáp
án (vì nó là min trên path tìm được).
Bài này cùng pattern với Swim in Rising Water (Ch 24.6) nhưng đảo chiều: thay vì “elevation thấp nhất activate trước” → “value cao nhất activate trước”.
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 theo value GIẢM dần.
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 qua các ô có
grid[r][c] >= v. O(log(max) · mn). Xem Ch
37 cho pattern này.| Kruskal | Prim | |
|---|---|---|
| Cấu trúc dữ liệu | DSU + sort edges | Heap + visited |
| Time | O(E log E) |
O(E log V) |
| Đồ thị thưa | ✅ Tốt | OK |
| Đồ thị dày | OK | ✅ Tốt hơn |
| Cần edge list | ✅ | Cần adjacency list |
| Streaming edges | ✅ (xử lý tăng dần) | ❌ |
“Sort cạnh theo trọng số → mỗi cạnh, nếu nối 2 component khác nhau, lấy. DSU theo dõi connectivity dần dần.”
src ↔︎ dst cùng comp).min(d, edge)).Rolling Hash = hash chuỗi mà ta có thể “trượt window” với
O(1)cập nhật. Pattern này biếnO(n · m)thànhO(n + m)cho bài substring matching, duplicate detection, …
Sau chương này, bạn sẽ:
O(1).(1<<61)-1); production cần double hash.Pattern (Rabin-Karp):
hash(s[l..r]) = (s[l]·base^(r-l) + s[l+1]·base^(r-l-1) + ... + s[r]) mod M
Khi trượt: hash_new = (hash_old · base + s[r+1] - s[l] · base^(r-l+1)) mod M
Bẫy va chạm (collision). Modulo nguyên tố
10^9 + 7 thường an toàn cho bài LC; nếu có adversary cố
tình tấn công, dùng 2 hash khác nhau (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 seenCho chuỗi DNA s (chứa A, C, G, T). Trả về
substring độ dài 10 xuất hiện ≥ 2
lần.
Input: s = "AAAAACCCCCAAAAACCCCCCAAAAAGGGTTT" (chuỗi chỉ chứa A/C/G/T)
Output: ["AAAAACCCCC", "CCCCCAAAAA"]
(mọi substring độ dài 10 xuất hiện ≥ 2 lần; thứ tự không quan trọng)
Brute force: set substring 10-char.
O(n · 10) time, O(n · 10) space.
Rolling hash: O(n) time,
O(n) space (memory smaller because hash là int).
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).Cho s. Tìm substring dài nhất xuất hiện
ít nhất 2 lần (có thể overlap). Nếu nhiều, trả bất kỳ.
Input: s = "banana"
Output: "ana" (substring lặp lại dài nhất; nếu nhiều, trả về bất kỳ)
Binary search trên length + rolling hash check.
L mà có substring length-L xuất
hiện ≥ 2 lần.check(L): rolling hash + set; nếu collision → có
duplicate.
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). Bộ
nhớ: O(n).MOD = (1<<61)-1 (Mersenne prime), an toàn cho
LC.Đếm số substring phân biệt có dạng
a + a (concat của chuỗi với chính nó).
Input: text = "abcabcabc"
Output: 3
(số echo substring khác nhau; echo = substring dạng a+a;
ở đây có "abcabc", "bcabca", "cabcab")
a + a
không overlap.Với mỗi L (độ dài a), check mọi vị trí có
s[i..i+L-1] == s[i+L..i+2L-1]. Rolling hash giúp so sánh
O(1). Collect các hash đã thấy.
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²). Bộ
nhớ: O(n²) worst.Cho s. Thêm ký tự vào đầu sao cho
s thành palindrome, đầu càng ít càng
tốt.
Input: s = "aacecaaa"
Output: "aaacecaaa" (thêm ký tự ÍT NHẤT vào ĐẦU s để thành palindrome)
Tìm prefix dài nhất của s là palindrome. Phần còn lại
reverse và thêm vào đầu.
Rolling hash approach: compute hash của
s và reverse(s). Tìm L lớn nhất
mà hash(s[:L]) == hash(rev_s[n-L:]) (= s[:L]
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 = hash đọc xuôi prefix
[0..i].h2 = hash đọc ngược (vẫn prefix
[0..i] nhưng order ngược).Cho dict các chuỗi cùng độ dài. Trả về True
nếu tồn tại 2 chuỗi chỉ khác đúng 1 ký tự.
Input: dict = ["abcd", "acbd", "aacd"]
Output: True (tồn tại 2 chuỗi cùng độ dài khác đúng 1 vị trí; ví dụ "abcd" vs "aacd")
Cho mỗi chuỗi và mỗi vị trí i, “mask” ký tự thứ
i → hash phần còn lại. Nếu trùng → có cặp khác 1 vị
trí.
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) nhưng OK cho LC.O(L · N)
total tránh collision adversarial.Build s bằng cách prepend từng ký tự. Tại bước
i, “score” = độ dài prefix dài nhất của s_i
đồng thời là suffix của s_i. Tổng tất cả scores.
Input: s = "babab"
Output: 9 (score[i] = LCP(s, s[i:]); tổng score qua mọi i)
Z function ở Chương 36 là pattern chính. Rolling hash cũng giải được: với mỗi i tìm prefix-suffix length bằng binary search trên hash.
class Solution:
def sumScores(self, s: str) -> int:
# Z function — chi tiết ở Chương 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:]
(build từ phải).| Mục tiêu | Đủ |
|---|---|
| LC accepted | 1 hash với prime modulus lớn (10⁹+7 hoặc
(1<<61) - 1) |
| Production / robust | Double hash (2 cặp (base, mod)) hoặc
kiểm chứng lại substring khi va chạm |
| Tuyệt đối không sai | Lưu substring thay hash → tốn bộ nhớ |
[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
Lưu ý Python: (a - b) % M luôn đúng
(negative-safe). Java/C++ cần ((... % M) + M) % M.
| Rolling hash | KMP / Z | |
|---|---|---|
Tìm pattern P trong text T |
O(|T|) avg, có thể va chạm |
O(|T| + |P|) đảm bảo |
| Đa truy vấn (search nhiều pattern) | Cần index hash của T | Cần build per-pattern |
| Substring so sánh tổng quát | ✅ Hash any range | KMP/Z không hỗ trợ trực tiếp |
| Risk va chạm | Có | Không |
KMP giải bài substring matching trong
O(n + m), cốt lõi là mảng LPS (Longest Prefix Suffix) — với mỗi vị tríitrong pattern,lps[i]= độ dài prefix dài nhất củapattern[0..i]đồng thời là suffix của nó (không phải chính nó).
ababacaLPS là khái niệm khó nhất KMP. Trace tay với
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
Giải thích từng ô:
LPS[0]=0 (1 ký tự, không có proper prefix nào)
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" — không có prefix "_" = suffix "_c" → 0
LPS[6]=1 "ababaca" — prefix "a" = suffix "a" → 1
Trực giác: LPS[i] cho biết khi mismatch tại
pattern[i+1], ta không cần restart từ đầu — có thể nhảy về
pattern[LPS[i]] vì các ký tự
pattern[0..LPS[i]-1] chắc chắn đã match (do nó cũng là
suffix của những gì đã match).
Sau chương này, bạn sẽ:
lps[i] = longest proper prefix = suffix của
pattern[0..i].O(n + m).# cần KHÔNG xuất hiện trong input (chọn ký tự
an toàn).len(s) - lps[-1] chia hết len(s) ↔︎
s là string lặp.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 resultTìm index đầu tiên của needle trong
haystack, hoặc -1.
Input: haystack="sadbutsad", needle="sad"
Output: 0
KMP O(n + m) time, O(m)
space cho 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) cho LPS.O(n·m) vẫn pass với
m, n ≤ 10⁴ trên LC, nhưng KMP là lời giải tối ưu hơn về mặt
thuật toán và thể hiện được khả năng nắm pattern matching tuyến
tính.Như Chương 34.4 nhưng dùng KMP.
Input: s = "aacecaaa"
Output: "aaacecaaa" (thêm ký tự ÍT NHẤT vào ĐẦU s để thành palindrome)
Tạo combined = s + '#' + reverse(s). Tính
lps của combined; giá trị lps[-1] chính là
độ dài prefix dài nhất của s đồng thời là
suffix của reverse(s) = prefix
palindrome dài nhất.
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).# tránh prefix overlap suffix vô
tình (palindrome ảo).Kiểm tra s có thể tạo bằng concat nhiều lần 1 substring
không.
Input: s = "abab"
Output: True
("abab" = "ab" lặp 2 lần ⇒ True)
Trick KMP: nếu s = pattern * k (k ≥ 2),
thì lps[-1] > 0 và n - lps[-1] chia hết
n.
Cụ thể: 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. Tinh tế nhưng less
efficient.Tìm tất cả start index trong s mà substring
length-|p| là anagram của p.
Input: s="cbaebabacd", p="abc"
Output: [0, 6]
Sliding window + Counter. KMP không trực tiếp giải bài này — Anagram không phải substring match. Nhưng vẫn được liệt kê vì nằm trong family bài “string pattern”.
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) với k = bảng
chữ; thường O(n).O(k).O(26)
không phải O(1); vẫn ổn cho LC.Cho s, maxLetters, minSize,
maxSize. Tìm substring có maxOccurrence mà
length ∈ [minSize, maxSize] và số ký tự phân biệt ≤ maxLetters.
Input: s="aababcaab", maxLetters=2, minSize=3, maxSize=4
Output: 2
Trick: chỉ cần xét length = minSize
(substring lớn hơn không thể xuất hiện nhiều hơn).
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: nếu substring
length-L xuất hiện k lần thì substring length-(L-1) cũng xuất hiện ≥ k
lần (mỗi cái → prefix length-(L-1)).“Happy prefix” = prefix non-trivial đồng thời là suffix. Trả về cái dài nhất.
Input: s = "level"
Output: "l" (prefix DÀI NHẤT của s cũng là suffix của s, KHÔNG bằng chính s;
empty string nếu không tồn tại)
Trực tiếp lps[-1] của s
chính là đáp án.
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]] — vẫn còn case
rỗng nếu không có happy prefix.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]; fallback
j=lps[2]=1; s[1]!=s[5]; fallback
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].
Khi concat P + '#' + T (LC tham khảo), #
phải không nằm trong charset cho phép. Nếu input có thể
chứa mọi ký tự Unicode, dùng cặp 2 sentinel hoặc Z-function (Chương 36)
trực tiếp.
| KMP | Z | |
|---|---|---|
| Preprocess | LPS array của P | Z array của P + '#' + T |
| Tư duy | “Failure → quay lui khôn ngoan” | “Khớp tiền tố tại mọi vị trí” |
| Cài đặt | Failure function, 2 con trỏ | 1 box [l, r), dễ off-by-one |
| Ứng dụng đặc thù | Period of string, find pattern | LCP, distinct substring, Z-array properties |
Z function của chuỗi
s:z[i]= độ dài đoạn dài nhất bắt đầu tạis[i]đồng thời là prefix củas.z[0]được set bằngntheo quy ước. Tính được trongO(n).
Z function dùng “Z-box” [l, r) = đoạn matched gần nhất
với prefix của s. Đây là chìa khoá để hiểu vì sao tính được
trong O(n).
[l ........... r)
s : ┌───┐ ┌─────────────┐
│ A │ ...│ B = prefix │ ...
└───┘ └─────────────┘
prefix matched với prefix
│ │
└──────────────┴── đoạn này = prefix của s, độ dài r - l
Khi xét z[i] với i ∈ [l, r):
• Đã biết z[i - l] (vì s[l..r) = s[0..r-l) tương ứng).
• Lấy lower bound: z[i] = min(r - i, z[i - l]).
• Sau đó mở rộng bằng so sánh ký tự.
Khi i ≥ r:
• Không có thông tin → compare từ s[0].
Update Z-box khi tìm được đoạn mới dài hơn (i + z[i] > r).
Trace với s = "aabxaabxaab":
i l r z[i] tính từ
0 0 0 11 (z[0] = n)
1 0 0 ? i ≥ r, compare từ đầu
s[1]=a, s[0]=a → match. z[1]=1 (vì s[2]=b ≠ s[1]=a)
Update Z-box: l=1, r=2.
2 1 2 ? i = r, không có info → compare từ đầu.
s[2]=b ≠ s[0]=a → z[2]=0.
3 1 2 0 Tương tự, z[3]=0.
4 1 2 ? i ≥ r, compare: s[4..]=aabxaab, prefix=aabxaab
Match cả 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.
Compare thêm: s[6]=b ≠ s[1]=a → dừng. 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.
Compare thêm: i+z[i]=11=r → có thể vượt? 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]
Amortize chứng minh O(n): mỗi lần
“compare extend” trong while loop làm r tăng.
r đơn điệu không giảm và ≤ n → tổng số extend ≤ n. Cộng
O(1) cho mỗi i không extend → tổng O(n).
Sau chương này, bạn sẽ:
z[i] = độ dài đoạn từ s[i] khớp với
prefix.[l, r] invariant: đoạn matched gần nhất,
amortize 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 zTính mảng z[] cho chuỗi s. Đây là cài đặt
cốt lõi để mọi bài Z function khác dùng tới.
Input: s = "aabxaabxaab"
Output: [11, 1, 0, 0, 7, 1, 0, 0, 3, 1, 0]
Giải thích vài giá trị tiêu biểu (xem trace đầy đủ ở mục "Z-box invariant"):
z[0] = 11 (quy ước: bằng độ dài chuỗi)
z[4] = 7 (s[4..10] = "aabxaab" trùng prefix "aabxaab")
z[8] = 3 (s[8..10] = "aab" trùng prefix "aab")
Maintain [l, r] = đoạn matched gần nhất với prefix. Tại
mỗi i: - Nếu i < r: dùng giá trị
z[i - l] đã biết để skip. - Mở rộng z[i] bằng
cách so sánh s[z[i]] với s[i + z[i]]. - Update
[l, r] nếu vùng matched mới lớn hơn.
Amortize: mỗi ký tự được “extend” tối đa 1 lần →
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 zTrực giác Z function với
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" là prefix length
1.z[4] = 3: s[4..6] = "aab" là prefix length
3.z[8] = 2: s[8..9] = "aa" là prefix length
2.O(n).O(n).z[0] = n quan trọng
cho 1 số áp dụng.Tìm index đầu tiên của needle trong
haystack, hoặc -1. Bài này dùng Z
function thay vì KMP (Chương 35.1).
Input: haystack="sadbutsad", needle="sad"
Output: 0
#? → Đề LC chỉ chữ thường → an
toàn.combined = pattern + '#' + text. Tính z. Tìm
i đầu tiên có z[i] == len(pattern), trả
i - len(pattern) - 1 (vị trí trong 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).# là sentinel — đảm bảo
z[i] không “vượt qua” boundary giữa pattern và text.Đã giải đầy đủ ở Chương 34.6 (dùng Z function).
score(s_i) = z[n - len(s_i)] (số ký tự khớp prefix khi
build dần). Cộng dồn chính là
sum(z[i] for i in range(1, n)) + n (cho cả
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).
Bộ nhớ: O(n).Cho chuỗi s. Mỗi lượt thao tác, bạn được chọn
một trong hai cách:
i với 1 ≤ i ≤ len(s) / 2 sao
cho s[0..i-1] == s[i..2i-1] (tiền tố độ dài i
trùng với đoạn ngay sau nó). Xoá tiền tố độ dài
i; phần còn lại của s là
s[i:].i thoả mãn, xoá toàn bộ
s (kết thúc).Trả về số lượt thao tác lớn nhất có thể thực hiện
cho đến khi s rỗng.
Input: s = "abcabcdabc"
Output: 2 (số phép xoá tối đa; mỗi phép xoá tiền tố P bằng tiền tố tiếp theo)
DP từ phải sang trái. dp[i] = max turn từ
s[i..].
Transition:
dp[i] = max(1, max(dp[i + j] + 1 for j với s[i..i+j-1] == s[i+j..i+2j-1])).
So sánh substring nhanh bằng Z function hoặc precomputed LCP.
class Solution:
def deleteString(self, s: str) -> int:
n = len(s)
# lcp[i][j] = LCP của s[i:] và 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²). Bộ
nhớ: O(n²).Cho s, sub, và
mappings[i] = [old, new] (mỗi ký tự old có thể
đổi thành new ≤ 1 lần khi match). Kiểm tra sub
(sau khi áp dụng tuỳ ý mappings) có là substring của s
không.
Input: s="fool3e7bar", sub="leet", mappings=[["e","3"],["t","7"],["t","8"]]
Output: True
Brute force O(n · m) với check thông
minh (mỗi ký tự match nếu == hoặc ∈ mapping).
Z function hoặc KMP có thể adapt — phức tạp hơn.
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) với k = số mappings.Cùng bài 34.3, nhưng dùng Z function thay rolling hash để tránh collision.
Đếm số substring phân biệt có dạng
a + a (chuỗi nối với chính nó).
Input: text = "aaaa"
Output: 1 (echo substring duy nhất là "aa"; "aaaa" có dạng a+a nhưng đếm theo distinct)
Với mỗi i, tính z trên s[i:].
Tìm các j với z[j] >= j (= echo length
j tại vị trí i). Gom các echo substring
distinct dùng set lưu (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" với mỗi a = s[i..i+j-1]
# Lọc distinct theo nội dung substring.
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
Box [l, r): vùng đang khớp tiền tố hiện
tại. Tại mỗi i ∈ [l, r): - Nếu
i + Z[i-l] < r: copy Z[i] = Z[i-l]. - Ngược
lại: brute extend từ Z[i] = r - i.
Cập nhật box khi mở rộng:
i = 4: chưa có box, brute: s[4..]=aabxaaaz vs s[0..]=aabcaab... ⇒ khớp 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: ngoài box, brute: Z[7]=0
i = 8: brute, khớp aaz vs aab → 2 ⇒ Z[8]=2, [l,r)=[8,10)
lcp[i][j] = LCP của suffix s[i:] và
s[j:]. Có thể compute bằng Z theo cách indirect, nhưng
DP 2D là cách tự nhiên hơn.KMP/Z giả định so sánh bằng. Nếu match relation không đối xứng (vd wildcards có ràng buộc), failure function không xài được — phải DP.
Bài đầy đủ ở Chương 34 (rolling hash). Recap ở đây
để so sánh: Z-function có thể đếm echo
s[i:i+L] == s[i+L:i+2L] bằng cách kiểm tra
Z[i+L] ≥ L.
Khi bài có dạng “min của max” hoặc “max của min” trên graph/grid, ta có thể binary search trên đáp án + kiểm tra tính khả thi bằng BFS/DFS. Pattern này xuất hiện khi ta muốn lời giải
O((V + E) · log range)đơn giản, thay cho Dijkstra hoặc MST tinh vi hơn.
Sau chương này, bạn sẽ:
can_reach(x): với threshold x, có path không?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 loĐã giải 2 cách (DSU offline, Dijkstra) ở Chương 24.6 và 30.4. Đây là cách thứ 3: binary search trên
t+ BFS check.
Grid n × n elevation. Tại thời điểm t, ô có
elevation ≤ t có nước phủ. Tìm t nhỏ nhất bơi được từ
(0,0) đến (n-1,n-1).
Binary search trên t. Với mỗi
t, BFS check có path qua các ô elevation ≤ t không.
Predicate đơn điệu: t lớn hơn → tới được nhiều ô hơn → dễ path hơn.
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²).Đã giải bằng Dijkstra ở Chương 30.2. BS version: binary search trên 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).Lưới row × col. Mỗi ngày 1 ô nước (cells[i]). Tìm ngày
cuối cùng còn đường từ row 0 đến row n-1 (qua các ô đất).
Input: row=2, col=2, cells=[[1,1],[2,1],[1,2],[2,2]]
Output: 2
Binary search trên day d + BFS/DFS
check: với d ngày, lưới có đường đi không?
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 # nước
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) với K = số
ngày.O(R · C).Trapping Rain Water mở rộng lên 2D. Tính tổng nước đọng trên grid heights.
Input: heightMap = [[1,4,3,1,3,2],
[3,2,1,3,2,4],
[2,3,3,2,3,1]] (grid m × n, mỗi ô là chiều cao cột)
Output: 4 (tổng lượng nước đọng sau mưa)
Dijkstra/BFS từ biên với min-heap: thay vì binary search, dùng heap pop ô thấp nhất từ biên. Mỗi lần pop, “nâng” mực nước cho hàng xóm.
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: 4 số nguyên divisor1, divisor2,
uniqueCnt1, uniqueCnt2.
Cần tạo 2 mảng arr1 (độ dài uniqueCnt1) và
arr2 (độ dài uniqueCnt2) chứa các số nguyên
dương sao cho: - Mọi phần tử trong arr1 không chia
hết cho divisor1. - Mọi phần tử trong
arr2 không chia hết cho
divisor2. - arr1 ∪ arr2 toàn bộ là các
giá trị phân biệt (không trùng nhau giữa hai mảng).
Trả về giá trị lớn nhất trong
arr1 ∪ arr2 mà nhỏ nhất có thể.
Input: divisor1 = 2, divisor2 = 7, uniqueCnt1 = 1, uniqueCnt2 = 3
Output: 4
Giải thích:
arr1 = [1] (1 không chia hết cho 2)
arr2 = [2, 3, 4] (đều không chia hết cho 7)
max(arr1 ∪ arr2) = 4, đã tối thiểu.
Binary search trên đáp án X: - Số trong
[1, X] không chia hết d1:
X - X // d1. - Tương tự với d2. - Chỉ chia hết
lcm(d1, d2): là phần share — quyết định ai nhận.
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:
# Số ≤ x chia cho d1: x // d1; chia cho d2: x // d2; chia cho L: x // L
available1 = x - x // divisor1
available2 = x - x // divisor2
available_both = x - x // L
# arr1 cần available1 cho mình, có thể share với arr2.
need1 = max(0, uniqueCnt1 - (available1 - (x // divisor2 - x // L)))
# ... brain teaser, easier with formula.
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).Ma trận. Peak = ô lớn hơn 4 hàng xóm. Trả về [r, c] của
1 peak. O(m log n).
Input: mat = [[1, 4],
[3, 2]] (mọi ô khác nhau; biên grid xem như -∞)
Output: [0, 1] (1 vị trí peak: lớn hơn 4 hàng xóm; có nhiều, trả bất kỳ)
Binary search trên cột. Chọn cột giữa, tìm max trong cột → coi như có “đỉnh trong row đó”. Compare với 2 cột kề: nếu max < cột bên trái → đỉnh ở nửa trái; tương tự phải.
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 dưới ngưỡng / capacity mid
hi = mid
else:
lo = mid + 1
return lo
can_reach(x) là BFS/DFS với constraint
phụ thuộc x.x lớn (rộng rãi hơn) thì
can_reach chỉ có thể chuyển False → True.| Bài | Answer | can(x) |
Monotonic |
|---|---|---|---|
| Path with Min Effort (LC 1631) | max edge diff | BFS chỉ qua cạnh ≤ x | True khi x ↗ |
| Swim in Rising Water (LC 778) | min ngưỡng | BFS qua cell height ≤ x | True khi x ↗ |
| Min Bridges (LC 1102) | max path min | DFS qua cell ≥ x | True khi x ↘ |
| Aggressive Cows / Magnetic Force | distance | Greedy đặt | True khi x ↘ |
O((V+E) log range).Bản chất bài này là Dijkstra-like với priority queue (chọn cell biên thấp nhất tiếp theo). Đặt trong chương 37 để so sánh “BS + Graph” với “Min-heap traversal” — cả hai đều thuộc gia đình “answer = ngưỡng”.
Khi DP transition cần “tìm nhanh giá trị tối ưu trong subset đã xét”, binary search vào prefix optimal có thể giảm
O(n²)xuốngO(n log n). Pattern này gặp ở LIS, Russian Doll Envelopes, Constrained Subsequence Sum.
Sau chương này, bạn sẽ:
O(n log n) với bisect trên
tails.dp[i] = max/min(...) mà argmax có thể tìm bằng binary
search.from bisect import bisect_left, bisect_right
# DP với binary search lookup
tails = []
for x in arr:
idx = bisect_left(tails, x)
if idx == len(tails):
tails.append(x)
else:
tails[idx] = xĐã giải đầy đủ ở Chương 29.6. Sort 2D + LIS với binary search.
Đây là gateway cho pattern BS + DP: sau sort 2D,
transition của LIS (tails[bisect_left(tails, x)] = x) chính
là binary search trên cấu trúc DP.
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). Bộ
nhớ: O(n).LIS với constraint: nums[i+1] - nums[i] <= k. Tìm độ
dài max.
Input: nums=[4,2,1,4,3,4,5,8,15], k=3
Output: 5
<).Segment Tree với max query (range max trên dp values).
dp[v] = max LIS kết thúc với value v.
Transition: dp[v] = max(dp[v-k..v-1]) + 1.
Segment tree cho phép range max query 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) với V = max
value.O(V) cho segment tree.Đếm số LIS có độ dài max của nums.
Input: nums = [1, 3, 5, 4, 7]
Output: 2 (số LIS độ dài tối đa: [1,3,5,7] và [1,3,4,7])
DP O(n²): dp[i] = (length, count) — length
của LIS kết thúc tại i, count của số LIS đó.
Có version O(n log n) với segment tree.
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).Ma trận. Tìm submatrix có tổng max ≤ k.
Input: matrix=[[1,0,1],[0,-2,3]], k=2
Output: 2
Fix 2 cột (c1, c2). Tổng mỗi row trong khoảng → 1D
array. Tìm subarray tổng gần k nhất ≤ 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]
# Tìm subarray sum lớn nhất ≤ k.
sl = SortedList([0])
prefix = 0
for v in row_sums:
prefix += v
# Tìm 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).pip install sortedcontainers) — không có sẵn trong Python
stdlib.Cho nums và k. Tìm max sum của subsequence
sao cho mọi cặp index liên tiếp trong subsequence cách nhau ≤
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])).
max(dp[i-k..i-1]) qua sliding window
max (Chương 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) cho deque + dp.max(0, ...) → dp âm có thể
bị chọn nhầm.Cho houses (vị trí). Đặt k mailbox sao cho
tổng distance(house → nearest mailbox) min.
Input: houses=[1,4,8,10,20], k=3
Output: 5
DP: dp[i][j] = min cost dùng j mailbox cho
houses[:i].
Transition:
dp[i][j] = min(dp[p][j-1] + cost(houses[p:i])) với
cost là tổng distance khi đặt 1 mailbox cho range — = sum
|x - median|.
O(k · n²). Có thể tăng tốc bằng divide-conquer DP
optimization → 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] = chi phí đặt 1 mailbox cho 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: dùng
bisect_left để tìm vị trí thay = binary
search.(w asc, h desc)
rồi LIS trên h = patience.LC 2407 (Longest Increasing Subsequence II): không thể dùng patience
đơn giản vì có ràng buộc gap ≤ k ⇒ cần segment tree lookup
dp[v - k .. v - 1].
SortedList + bisect để tìm
prefix - target gần nhất ≤ k ⇒ không phải DP
thuần, nhưng dùng cùng family “binary search vào structure đã
sắp”.[l..r], cost tối ưu = tổng khoảng cách đến
median ⇒ precompute cost[l][r].dp[k][i] = chia houses[..i] thành
k cluster.dp[k][i] = min_{j} dp[k-1][j] + cost[j+1][i].Sort + DP là combo mạnh khi thứ tự items quan trọng và DP trên thứ tự đó. Pattern thường gặp: sort theo một thuộc tính, rồi LIS-like DP trên thuộc tính khác.
Sau chương này, bạn sẽ:
(w asc, h desc) để cùng w không tạo “LIS”
giả.Cho nums (phân biệt). Tìm subset lớn nhất sao cho mọi
cặp (a, b) trong đó a % b == 0 hoặc
b % a == 0.
Input: nums = [1, 2, 4, 8]
Output: [1, 2, 4, 8] (subset lớn nhất mà mọi cặp (a, b) đều có a % b == 0 hoặc b % a == 0)
Sort tăng dần. dp[i] = subset lớn nhất kết thúc tại
nums[i].
dp[i] = max(dp[j] + 1 for j < i if nums[i] % nums[j] == 0).
Track parent[] để reconstruct 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).Đã giải đầy đủ ở Chương 29.6. Sort 2D + LIS với binary search.
Pattern “sort 2D + LIS theo chiều còn lại” tái xuất ở bài 39.3 (Stacking Cuboids, 3D), 39.5 (Visible People), 45.x (Sorting + DP nâng cao).
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). Bộ
nhớ: O(n).Cho cuboids[i] = [w, l, h]. Có thể rotate mỗi cuboid
(chọn 1 trong 3 chiều làm height). Stack i lên
j nếu mọi chiều i ≤ chiều j. Tìm
height max.
Input: cuboids = [[50,45,20], [95,37,53], [45,23,12]]
(mỗi cuboid [w, l, h]; có thể xoay; stack được nếu w1≤w2, l1≤l2, h1≤h2)
Output: 190 (chiều cao stack lớn nhất)
Insight: sort 3 chiều mỗi cuboid → cuboid khác stack được lên nhau ↔︎ 3 chiều sorted của cái dưới ≥ 3 chiều sorted của cái trên (sau khi sort all cuboids tăng).
Sau khi sort, bài thành LIS 3D.
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).Cho jobs = [(start, end, profit)]. Chọn các job không
overlap, max tổng profit.
Input: startTime=[1,2,3,3], endTime=[3,4,5,6], profit=[50,10,40,70]
Output: 120
Sort theo end. dp[i] = max profit dùng các
job [0..i].
dp[i] = max(dp[i-1], dp[k] + jobs[i].profit) với
k = job cuối có end <= jobs[i].start.
Binary search tìm 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).Cho heights. Mỗi người thấy được người sau họ trong queue đến khi gặp người cao hơn họ hoặc cao hơn cả người vừa thấy. Đếm số người mỗi người thấy.
Input: heights = [10, 6, 8, 5, 11, 9]
(heights[i] = chiều cao người thứ i trong queue, mọi heights[i] khác nhau)
Output: [3, 1, 2, 1, 1, 0]
(answer[i] = số người ở bên phải mà người thứ i nhìn thấy)
Monotonic stack từ phải sang trái. Mỗi người: pop hết người thấp hơn từ stack (đếm vào kết quả), cộng 1 nếu stack còn (người cao hơn).
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).Garden độ dài n. Tap i ở vị trí
i tưới [i - r, i + r]. Min tap để tưới
hết.
Input: n=5, ranges=[3,4,1,1,0,0]
Output: 1
Chuyển sang Jump Game II (16.2): cho mỗi vị trí
i, farthest[i] = vị trí xa nhất tới được nếu
bắt đầu ở 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).Chương này tập hợp các bài mà bước đầu tiên là sort, sau đó áp dụng: - DP (Russian Doll, Job Scheduling). - Monotonic stack (LC 1944 Visible People — đứng riêng nhưng phụ thuộc thứ tự). - Greedy interval (LC 1326 Minimum Taps).
Nhãn chương rộng hơn “Sort + DP” cứng nhắc; coi như “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) = job cuối cùng có endTime ≤ jobs[i].startTime ← binary search!
(i, range) thành interval
[i - r, i + r].Khi state là subset của tập nhỏ (≤ 20 phần tử), ta encode subset bằng bitmask
int32-bit và DP trên bitmask. State spaceO(2^n). Pattern cho các bài TSP-style, set cover, partition.
Sau chương này, bạn sẽ:
n ≤ 20-22 (vì
2^20 ≈ 10^6).sub = (sub - 1) & mask.n ≤ 20-22 (vì 2^20 ≈ 10^6 còn fit).from functools import cache
@cache
def dp(mask, *extra_state):
if mask == 0: # base case (hoặc full = (1 << n) - 1)
return base_value
return best([dp(mask ^ (1 << i), ...) for i in iterate_bits(mask)])Cho nums và k. Chia thành k
subset có tổng bằng nhau?
Input: nums=[4,3,2,3,5,2,1], k=4
Output: True
dp[mask] = boolean “subset bitmask có thể partition
thoả”.
Total = sum(nums). Mỗi subset tổng = total / k.
Duyệt: cho mỗi mask, nếu dp[mask] true và
current_subset_sum % target == 0, thử thêm các element chưa
dùng.
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) cho memo.next_sum = 0 khi full: “đóng”
subset hiện tại, mở subset mới.Input: graph: List[List[int]] — adjacency list của 1
graph vô hướng liên thông n đỉnh (đỉnh
đánh số 0..n-1). Tìm path ngắn nhất (đếm cạnh) visit tất cả
đỉnh; được xuất phát/kết thúc tại đỉnh bất kỳ, được lặp lại đỉnh.
Input: graph = [[1,2,3], [0], [0], [0]]
(adjacency list undirected; graph[i] = các node kề node i; được xuất phát/kết thúc tại node bất kỳ)
Output: 4 (số cạnh ngắn nhất để visit tất cả node; được lặp lại node)
BFS với state (node, visited_mask). Đáp
án = số bước đầu tiên đạt được 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²) state ×
transition.O(2^n · n).Cho req_skills và mảng
people[i] = list of skill. Tìm subset người nhỏ
nhất cover tất cả skill.
Input: req_skills=["java","nodejs","reactjs"], people=[["java"],["nodejs"],["nodejs","reactjs"]]
Output: [0,2]
DP bitmask trên skills. dp[mask] = smallest team cover
skills trong 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) với k = số
skill, m = số người.O(2^k).Cho mảng strings. Tìm string ngắn nhất chứa tất cả các string trong mảng làm substring.
Input: words = ["alex", "loves", "leetcode"]
Output: "alexlovesleetcode" (chuỗi ngắn nhất chứa mọi word làm substring;
có thể có nhiều đáp án, trả về 1 trong số đó)
Bitmask DP kết hợp TSP. State
(mask, last_string_idx) = string ngắn nhất visit set
mask và kết thúc tại string last_idx.
Pre-compute overlap[i][j] = max overlap khi nối string i
+ j.
from typing import List
class Solution:
def shortestSuperstring(self, words: List[str]) -> str:
n = len(words)
# overlap[i][j] = max k sao cho words[i] kết thúc bằng prefix length-k của 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 của superstring ngắn nhất visit set mask, kết thúc tại 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: tìm end-state có length min.
full = (1 << n) - 1
last = min(range(n), key=lambda i: dp[full][i])
# Reconstruct order ngược.
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 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) cho dp +
parent.O(2^n · n²),
Space: O(2^n · n). Với
n ≤ 12, fit.Phòng thi m × n. seats[i][j] =
. (ok) hoặc # (hỏng). 2 học sinh không được kề
bên trái/phải hoặc 2 góc chéo (vì copy được). Max số học sinh ngồi.
Input: seats = [["#",".","#","#",".","#"],
[".","#","#","#","#","."],
["#",".","#","#",".","#"]]
("." = ghế OK, "#" = ghế hỏng; HS xem được bài 4 hướng chéo & cạnh)
Output: 4 (số HS tối đa xếp được mà không ai gian lận)
Row DP bitmask. dp[i][mask] = max ở
hàng i với học sinh ngồi theo mask.
Mỗi mask phải valid (không 2 bit kề) và không đè ô hỏng.
Transition:
dp[i][mask] = max(dp[i-1][prev]) + popcount(mask) với
prev không xung đột chéo với 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 các ô '#' trong hàng 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: không 2 bit kề nhau.
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) và
mask & (prev >> 1).2 mảng độ dài n. Hoán vị nums2 để min
Σ nums1[i] ^ nums2[π(i)].
Input: nums1=[1,2], nums2=[2,3]
Output: 2
Bitmask DP O(n · 2^n). dp[mask] = min sum
khi đã ghép cặp các index trong mask (cùng số bit = số i đã
xét) với cùng số element của nums2.
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) cho thấy
element nums1 thứ i đang được match.| Operation | Code |
|---|---|
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') hoặc int.bit_count()
(Py 3.10+) |
| Iterate submasks | sub = mask; while sub: ...; sub = (sub - 1) & mask
(cuối cùng còn 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 với endpoint) |
O(2^n · n²) |
| ≤ 22-25 | ≤ 33M | Cần tối ưu hoặc submask enum | O(3^n) cho subset-sum DP |
Row mask s (bit i = 1 nếu có HS ở cột i):
- Valid trong row: s & (s << 1) == 0 (không 2 HS kế).
- Compatible với prev mask p:
(s & (p << 1)) == 0 AND (s & (p >> 1)) == 0
- Phải nằm trong cell allowed: s & broken_mask[row] == 0.
Khi bài cần “max XOR” với số trong array, ta build Trie binary của các số (mỗi bit là 1 cấp). Query max XOR là DFS greedy đi theo bit “khác” với số hiện tại.
Sau chương này, bạn sẽ:
Lưu ý: mỗi
### Code Python 3bên dưới đều standalone — copy nguyên block vào LeetCode chạy được. Nếu chỉ cần template tham khảo, dùng class này:
class BinaryTrie:
"""Binary Trie cho int 32-bit. Hỗ trợ 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 outĐã giải bằng “greedy bit + set” ở Chương 21.6. Trie cách thanh lịch hơn.
Cho nums. Trả về max XOR của 2 phần tử khác nhau.
Input: nums = [3, 10, 5, 25, 2, 8]
Output: 28 (max XOR giữa 2 phần tử bất kỳ: 5 XOR 25 = 28)
Build Trie binary của các số. Với mỗi x, query
max_xor(x) (DFS theo bit ngược lại). Lấy max qua tất cả
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) cho Trie.Cho nums và queries[i] = [x, m]. Với mỗi
query, trả về max XOR giữa x và một số
y ∈ nums với y ≤ m. Không có y →
-1.
Input: nums = [0,1,2,3,4], queries = [[3,1],[1,3],[5,6]]
Output: [3, 3, 7]
Offline + sort + Trie.
m tăng.nums[i] ≤ m vào Trie rồi
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). Bộ
nhớ: O(n · 30).Cho nums và [low, high]. Đếm số cặp
(i, j) với i < j và
low <= nums[i] ^ nums[j] <= high.
Input: nums = [1, 4, 2, 7], low = 2, high = 6
Output: 6
Giải thích 6 cặp (i, j) thoả 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) = số cặp (num, y) (y đã
insert trước) có num ^ y < x. Đáp án =
count_xor_less(high + 1) - count_xor_less(low).
Trie có thêm count ở mỗi node = số
y đã đi qua node đó. Với mỗi num, đi từ bit
cao xuống thấp; tại bit hiện hành gọi bit_n = bit của num,
bit_x = bit của x:
bit_x = 1: XOR ở bit này có thể là
0 hoặc 1 mà vẫn < x ở prefix
này. Nếu XOR bit = 0 (tức y có cùng
bit với num, đi qua children[bit_n])
thì các bit dưới tự do ⇒ cộng nguyên cả subtree:
total += node.children[bit_n].count. Sau đó “deserve” XOR
bit = 1 bằng cách đi xuống nhánh
children[1 - bit_n] (opposite) để xét tiếp các bit
thấp.bit_x = 0: XOR bit này bắt
buộc = 0 (nếu = 1 sẽ ≥ x ở prefix). Đi xuống nhánh
children[bit_n] (cùng bit num), không cộng
gì.Nói gọn: “same bit ⇒ XOR = 0” và “opposite bit ⇒ XOR = 1”. Code dưới đếm subtree same (XOR = 0) khi
bit_x = 1, rồi chuyển con trỏ sang opposite để tiếp tục.
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 (cùng bit) → subtree cùng nhánh đếm hết.
if node.children[bit_n]:
total += node.children[bit_n].count
# Đi xuống nhánh XOR bit = 1 (= opposite).
if node.children[1 - bit_n]:
node = node.children[1 - bit_n]
else:
node = None
break
else:
# Bắt buộc XOR bit = 0 → đi xuống cùng nhánh.
if node.children[bit_n]:
node = node.children[bit_n]
else:
node = None
break
# Insert num vào Trie sau khi đếm.
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). Bộ
nhớ: O(n · 15).low <= ... <= high
trực tiếp khó; tách thành < (high+1) và
< low rồi trừ.Tree với parents[i] (cha của i, -1 cho root). Mỗi node
trong LC này không có giá trị riêng — “giá trị” của
node = chính chỉ số node. Mỗi query
(node, val): tìm max val ^ x với
x là chỉ số của một ancestor (kể cả
node chính nó). Trả mảng max theo thứ tự query.
Input: parents = [-1, 0, 1, 1]
queries = [[0, 2], [3, 2], [2, 5]]
Cây thực tế (parents[0] = -1 nên 0 là root):
0
|
1
/ \
2 3
Output: [2, 3, 7]
Giải thích:
query (0, 2): ancestor của 0 là {0}; max(2^0) = 2.
query (3, 2): ancestor của 3 là {3,1,0}; max(2^3, 2^1, 2^0) = max(1,3,2) = 3.
query (2, 5): ancestor của 2 là {2,1,0}; max(5^2, 5^1, 5^0) = max(7,4,5) = 7.
val ^ root_index (chỉ
số root) là một ứng viên.vals[] đi kèm).Offline DFS trên tree. Maintain Trie chứa chỉ số các ancestors của node hiện tại trên DFS path (bao gồm node chính nó).
u: insert chỉ số
u vào Trie, xử lý tất cả query gắn với u.u: xoá chỉ số
u khỏi Trie.Trie cần hỗ trợ remove — track count
mỗi node để biết khi nào prune nhánh khi count về 0.
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 with count = 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). Bộ
nhớ: O(n · 17).n = 10^5), DFS đệ
quy có thể stack overflow → chuyển sang iterative.Cặp (x, y) “strong” ↔︎
|x - y| <= min(x, y). Cho nums. Tìm max XOR
trong các cặp strong.
Input: nums = [1, 2, 3, 4, 5]
Output: 7 (cặp "strong" là (x, y) với |x - y| ≤ min(x, y); max XOR = 3 XOR 4 = 7)
Quan sát: |x - y| ≤ min(x, y) ↔︎
max(x, y) ≤ 2 · min(x, y) (giả sử both ≥ 0). Sort nums,
sliding window: với mỗi r, mở rộng l đến khi
nums[r] > 2 · nums[l].
Trong window [l, r], max XOR = query Trie. Sliding Trie:
khi l tiến, remove nums[l].
Khi r mới, 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).
Bộ nhớ: O(n · 20).Cho nums. Op: chọn nums[i] và bất kỳ
x ≥ 0, đặt
nums[i] = nums[i] AND (nums[i] XOR x). Tìm max XOR all
elements sau ops bất kỳ.
Input: nums = [3, 2, 4, 6]
Output: 7 (mỗi phép: chọn i, x; thay nums[i] = nums[i] AND (nums[i] XOR x);
max XOR toàn mảng đạt được = OR mọi nums[i] = 3|2|4|6 = 7)
Insight: Op cho phép tắt bit bất kỳ
của nums[i] (giữ bit có sẵn). Vì XOR all = OR all khi mỗi
bit có thể “toggle tự do” từ ít nhất 1 số → max = OR 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). Bộ
nhớ: O(1).Tên đầy đủ hơn: Binary Trie để tối ưu XOR/operation theo từng bit. “Bitmask kết hợp Trie” trong index folder chỉ là viết tắt.
count(L, R) = count(≤ R) - count(≤ L - 1).
Build hàm countLE(limit).countLE(limit) với mỗi a[i]: đi từ bit cao
xuống thấp.
b, nếu bit của limit là
1:
b với a[i]
= 0 (tức cùng bit với a[i]) → chắc
chắn ≤ limit ở bit này, cộng số đó vào
answer.a[i]”.0: chỉ đi nhánh “cùng bit
a[i]”.LC này dùng parents[i] để build cây;
value của node i đơn giản là
i. Trie chứa giá trị i dọc đường từ root →
query.
|x - y| ≤ min(x, y). Giả sử
x ≤ y ⇒ y - x ≤ x ⇒
y ≤ 2x.y, các candidate x
thoả x ≥ y/2 nằm trong suffix đã thêm.
Maintain sliding window trên trie.Tree DP = DFS bottom-up trên cây. Mỗi node tính giá trị từ con. Pattern đặc biệt: re-rooting — tính kết quả cho từng node làm root (giảm
O(n²)xuốngO(n)).
Sau chương này, bạn sẽ:
(state1, state2, ...) từ con
lên.# 1) Tree DP bottom-up
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 (xem 42.5 cho implementation đầy đủ)
def re_root(graph, n):
# 1st DFS: compute "down" values (subtree rooted at i).
# 2nd DFS: propagate "up" values from parent to children.
passĐã giải đầy đủ ở Chương 11.5. Tree DP với 2 state mỗi node: rob_this, skip_this.
Đây là gateway cho Tree DP — pattern “trả tuple 2 giá trị từ con”. Toàn chương 42 mở rộng pattern này: Binary Tree Cameras (3 state), 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]:
"""Trả (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). Bộ
nhớ: O(h) stack.Đặt camera trên cây sao cho mọi node “phủ” (camera tại node hoặc kề camera). Min số camera.
Input: root = [0, 0, null, 0, 0]
(LC level-order serialize; 'null' = node không tồn tại;
giá trị node ở đây không liên quan, chỉ cấu trúc cây.)
Output: 1 (số camera tối thiểu phủ tất cả node)
3 state mỗi node: - 0: chưa được phủ. - 1: được phủ (không có camera tại đây nhưng con có camera). - 2: có camera tại đây.
Greedy bottom-up: nếu child là 0 → đặt camera ở node hiện tại.
class Solution:
def minCameraCover(self, root) -> int:
self.cnt = 0
def dfs(node) -> int:
if not node: return 1 # null coi như "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.Đường đi dài nhất giữa 2 node bất kỳ trong cây (cạnh đếm).
Input: root = [1, 2, 3, 4, 5] (LC level-order, đọc trái → phải, tầng trên → tầng dưới)
Output: 3 (đường đi 4 → 2 → 1 → 3 hoặc 5 → 2 → 1 → 3 có 3 cạnh)
DFS bottom-up. Mỗi node trả về depth từ nó xuống.
diameter qua 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.Input: parent: List[int] (parent array,
parent[0] = -1 là root) và s: str — node
i có ký tự s[i]. Tree gồm n node
đánh số 0..n-1. Tìm longest path (đếm node) mà các node kề
trên path có char khác nhau.
Input: parent=[-1,0,0,1,1,2], s="abacbe"
Output: 3
Tree DP. Mỗi node u: longest chain xuống =
max(1, 1 + max chain of children with different char).
Update best với 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]) trước khi tính.Input: n (số node) và
edges: List[List[int]] — danh sách n-1 cạnh
[u, v] của tree vô hướng. Trả về
result: List[int] trong đó result[i] = tổng
distance từ node i đến mọi node khác.
Input: n=6, edges=[[0,1],[0,2],[2,3],[2,4],[2,5]]
Output: [8,12,6,10,10,10]
Re-rooting kinh điển.
count[u] = số node
trong subtree u, result[0] = tổng distance từ root 0.result[v] từ
result[u] (parent của v):
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).count[v] nodes “lùi gần” 1 bước,
n - count[v] nodes “lùi xa” 1 bước.Input: n và edges: List[List[int]] — mỗi
cạnh [u, v] là có hướng u → v. Graph khi
bỏ hướng là 1 tree (n-1 cạnh, vô hướng liên thông). Với mỗi
node, đếm số cạnh phải đảo chiều để node đó reach mọi node khác.
Input: n=4, edges=[[2,0],[2,1],[1,3]]
Output: [1,1,0,2]
Re-rooting với “cost flip”.
result[0] = tổng cost.u → v trong tree (undirected),
kiểm tra direction gốc: nếu original là u → v →
result[v] = result[u] + 1; nếu v → 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 nếu phải đảo.
graph = defaultdict(list)
for u, v in edges:
graph[u].append((v, 0)) # u → v: đi xuôi
graph[v].append((u, 1)) # v → u: nếu đi từ v ra u thì phải đảo
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:
# Khi root từ u → v: cạnh u-v có cost c bị flip.
# Cost ban đầu là c → sau khi root 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): tính down[v] = đáp
số subtree của v khi root tại v. Pass
2 (pre-order từ root): chuyển root từ parent p
sang child c:
ans[c] = ans[p] - contribution_of_c_to_p_subtree + contribution_of_rest_to_c_subtree
Cụ thể với “sum of distances”:
ans[c] = ans[p] - size[c] + (n - size[c])
p xuống c:
c lại gần root
hơn 1 đơn vị ⇒ tổng distance giảm size[c].n − size[c].ans[c] = ans[p] − size[c] + (n − size[c]).0: node chưa được cover, cần camera
lân cận.1: node có camera.2: node đã được cover (bởi child có
camera) nhưng không có camera.0 ⇒ node = 1 (đặt camera,
cover children).1 ⇒ node = 2 (đã được
cover).2) ⇒ node = 0 (chờ
parent 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.
Tương tự cho 2, 3, 4.ans[v] = số reversal cần khi gốc cây tại
v.DP trên DAG = topo sort + DP linear theo thứ tự topo. State của node phụ thuộc các node “trước” nó trong topo order.
Sau chương này, bạn sẽ:
u → v nghĩa là u xong
trước v.Ma trận. Tìm longest path tăng dần (4 hướng).
Input: matrix = [[9, 9, 4],
[6, 6, 8],
[2, 1, 1]] (grid m × n, đi 4 hướng, value phải TĂNG nghiêm ngặt)
Output: 4 (path tăng dài nhất: 1 → 2 → 6 → 9)
> không
≥).DAG ngầm: mỗi ô là 1 node, cạnh u → v
nếu mat[v] > mat[u]. DFS với 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) cho memo.(r, c); không cần
tracking visited (DAG implicit).Input: n (số khoá, đánh số 1..n),
relations: List[List[int]] — mỗi [a, b] nghĩa
phải xong khoá a trước b (cạnh DAG a → b),
và time: List[int] với time[i] = thời gian học
khoá i+1. Có thể học đồng thời nếu
prerequisite đã xong. Min thời gian tổng.
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 là 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]) — đảm bảo max của tất
cả prerequisite.Input: colors: str (colors[i] = ký tự màu
của node i, 'a'..'z') và
edges: List[List[int]] — cạnh có hướng
[u, v]. Tìm path mà max số node cùng 1 color trên path là
lớn nhất.
Input: colors="abaca", edges=[[0,1],[0,2],[2,3],[3,4]]
Output: 3
Topo + DP dp[u][c] = số node màu c lớn nhất đi qua u
(kết thúc tại 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 → có
cycle → trả -1.Cho k (size) và row/col conditions (a phải đứng
trên/trước b). Trả về ma trận k×k với mỗi số 1..k xuất hiện đúng 1 lần
thoả conditions.
Input: k=3, rowConditions=[[1,2],[3,2]], colConditions=[[2,1],[3,2]]
Output: [[3,0,0],[0,0,1],[0,2,0]]
Topo sort 2 lần: row conditions → row position, col conditions → col position. Đặt mỗi số vào ô (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).Cho numCourses và prerequisites[i] = [a, b]
với ý nghĩa “phải hoàn thành môn a trước môn
b” (tức a là prerequisite của
b, cạnh DAG là a → b). Cho mảng
queries[i] = [u, v]; với mỗi query, trả về
True nếu u là prerequisite (trực tiếp hoặc
gián tiếp) của v, tức tồn tại đường đi
u → ... → v trong DAG.
Quy ước hướng cạnh (xem Chương 13): “u trước v” ⇔ cạnh
u → v. Đừng đảo dấu mũi tên — đây là bug điển hình ở 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: với mỗi query, BFS/DFS từ
u → check v. O(Q · (V + E)).
Tối ưu — Topo + DP closure: reach[u] =
set các đỉnh v mà u → ... → v. Duyệt theo
topo order ngược, gán
reach[u] = {u} ∪ union(reach[v] for v in adj[u]).
Sau preprocessing, mỗi query 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 các đỉnh u có thể đến.
reach = [set() for _ in range(numCourses)]
# Process theo topo order ngược: con đã có reach trước cha.
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²) cho reach sets.prerequisites[i] = [a, b] nghĩa “a phải xong trước
b” (a là prerequisite của b) ⇒ cạnh a → b trong
DAG. Trùng quy ước Chương 13: “X trước Y” ⇒ X → Y.graph[b].append(a) vì nhầm “b cần a” với “edge b → a”. Code
đúng dưới đây là graph[a].append(b).bitmask thay set
khi n ≤ 64.Cho recipes (mỗi cái cần ingredients), supplies (ingredients sẵn có). Trả về recipes có thể làm.
Input: recipes=["bread"], ingredients=[["yeast","flour"]], supplies=["yeast","flour","corn"]
Output: ["bread"]
Topo sort: cạnh ingredient → recipe needs ingredient. BFS từ supplies, mỗi khi tất cả ingredients của 1 recipe đã có → recipe đó “có sẵn”.
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).| Loại | Bài tiêu biểu |
|---|---|
| Longest path on DAG | LC 329 Longest Increasing Path |
| Counting paths | LC 1976 Number of Ways to Arrive |
| Reachability closure | Transitive closure / “có thể đạt từ A đến B” |
| Dependency scheduling | LC 1136 Parallel Courses, LC 2050 |
| Aggregation along DAG | LC 1857 Largest Color Value |
dp[node][color] = số node màu color lớn
nhất trên đường đi kết thúc tại node.(u → v):
dp[v][c] = max(dp[v][c], dp[u][c] + (1 nếu color[v] == c else 0))
— chú ý update sau khi xét tất cả parents.-1.a là prerequisite của b?” —
closure transitive.u → v, set
closure[v] |= closure[u] (bitmask hoặc set).(a, b) bằng
a in closure[b].indegree == 0 ban đầu là các ingredient
có sẵn).LC 1462: prerequisites[i] = [u, v]
nghĩa “phải học u trước v” ⇒ cạnh u → v
(giống Chương 13). Đừng đảo!
Chương cuối — DP đếm tổ hợp. Pattern: state là cấu trúc đếm + transition qua phép cộng. Mod
10^9 + 7xuất hiện ở mọi bài (vì kết quả lớn).
Sau chương này, bạn sẽ:
% MOD ở mọi transition để tránh
overflow.10^9 + 7 thường xuyên.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]Lưới m × n. Robot ở (0,0) đi tới
(m-1,n-1), chỉ đi phải hoặc xuống. Đếm số path.
Input: m=3, n=7
Output: 28
DP dp[i][j] = dp[i-1][j] + dp[i][j-1]. Hoặc
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) với công thức tổ
hợp; O(m · n) với DP.O(1) formula;
O(m · n) DP.m-1 bước
xuống trong m+n-2 bước.Như 44.1, có vật cản. 1 = block.
Input: obstacleGrid = [[0, 0, 0],
[0, 1, 0],
[0, 0, 0]] (0 = ô trống, 1 = vật cản)
Output: 2 (số đường đi từ (0,0) tới (m-1, n-1), chỉ đi xuống/sang phải)
DP. Nếu ô block → 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); có thể
O(n) rolling.Trên bàn phím số (3×4), knight chess đi n - 1 bước, mỗi
bước đi 1 nước knight. Đếm số chuỗi tạo được. Mod
10^9 + 7.
Input: n=2
Output: 20
Neighbor map cho bàn phím. DP
dp[i][digit] = số chuỗi length i kết thúc tại 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) (chỉ 10 phím).O(log n) —
cho n cực lớn.Đếm số chuỗi nguyên âm độ dài n với rules: - ‘a’ chỉ sau
‘e’. - ‘e’ chỉ sau ‘a’ hoặc ‘i’. - ‘i’ không sau ‘a’ hoặc ‘i’. - ‘o’ chỉ
sau ‘i’ hoặc ‘u’. - ‘u’ chỉ sau ‘i’.
Input: n=1
Output: 5
DP dp[i][v] = số chuỗi length i kết thúc tại nguyên âm
v.
class Solution:
MOD = 10**9 + 7
def countVowelPermutation(self, n: int) -> int:
# Index: 0=a, 1=e, 2=i, 3=o, 4=u
# Transitions backwards: ai có thể đứng trước cái này.
prev_can = {
0: [1, 2, 4], # a sau e, i, u
1: [0, 2], # e sau a, i
2: [1, 3], # i sau e, o
3: [2], # o sau i
4: [2, 3], # u sau 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 vowel).40 nón, n người (n ≤ 10), mỗi người có list nón mình
thích. Đếm số cách phân nón sao cho mỗi người 1 nón khác nhau.
Input: hats = [[3, 4], [4, 5], [5]]
(hats[i] = danh sách mũ mà người i thích; nhãn mũ ∈ [1, 40])
Output: 1 (số cách gán cho mỗi người đúng 1 mũ, các mũ phải khác nhau; mod 10^9 + 7)
Bitmask DP trên người, iterate qua nón (vì người ≤ 10, nón ≤ 40):
dp[hat][mask] = số cách dùng nón <= hat
để cover các người trong 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) với n ≤
10.O(2^n).Có n bài hát, listen goal bài. Mỗi bài nghe
ít nhất 1 lần. Khoảng cách giữa 2 lần nghe cùng bài ≥ k.
Đếm số playlist.
Input: n=3, goal=3, k=1
Output: 6
DP dp[i][j] = số playlist length i dùng j bài phân
biệt.
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); có thể
O(n) rolling.Bạn vừa hoàn thành cuốn sách 288 câu hỏi coding DSA interview từ Easy đến Hard, cover 44 pattern. Chúc bạn pass mọi vòng phỏng vấn Big Tech!
“The best way to learn is to teach.” — Hãy thử giảng lại 1 chương cho bạn bè. Nếu bạn explain được dễ hiểu, bạn đã thuộc bài.
% MOD sau
mỗi phép cộng/nhân lớn — tránh int quá to
(Python ok, nhưng vẫn quy ước).| Cách | Time | Space | Khi nào |
|---|---|---|---|
DP grid dp[i][j] = dp[i-1][j] + dp[i][j-1] |
O(mn) |
O(mn) hoặc O(min(m,n)) |
Khi có obstacle (LC 63) hoặc đề mở rộng |
Công thức C(m+n-2, m-1) |
O(min(m,n)) |
O(1) |
Khi không obstacle |
dp[i][j] = playlist độ dài i dùng đúng
j bài khác nhau.dp[i-1][j-1] × (N - (j-1)).dp[i-1][j] × max(0, j - K).≤ s2, ≥ s1, không chứa
evil → f(s2) - f(s1 - 1) với
f là “đếm ≤ s không chứa evil”.(idx, kmp_state, is_tight).kmp_state = LPS pointer trong evil để
detect khi nào sắp khớp.a → e
e → a, i
i → a, e, o, u
o → i, u
u → a
DP dp[i][v] = số chuỗi độ dài i kết thúc
bằng nguyên âm v.
Sử dụng bảng này để tra ngược: “đề bài giống thế này thì chọn pattern gì?”
| Dấu hiệu đề bài | Pattern (Chương) |
|---|---|
| “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” | Trie binary (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 (cho connectivity / components):
| Pattern | Khi dùng | Time | Space |
|---|---|---|---|
| BFS | Shortest path unweighted, level-order | O(V + E) |
O(V) |
| DFS | Connectivity, cycle detection, topo sort, đường đi bất kỳ | O(V + E) |
O(V) recursion stack |
| Union Find | Online add edge, query “connected?”, offline edge sorting | O((V + E) · α) |
O(V) |
Binary Search thường vs Search on Answer:
| Pattern | Search trên gì | Predicate |
|---|---|---|
| Binary Search thường (Ch 5) | Index trong mảng đã sort | nums[mid] vs target |
| Search on Answer (Ch 25) | Giá trị đáp án | check(mid) đơn điệu T/F |
Sliding Window vs Two Pointers:
| Pattern | Khi dùng | Window |
|---|---|---|
| Two Pointers 2-đầu (Ch 26) | Sort + tìm cặp (sum, distance) | Hội tụ từ 2 phía |
| Sliding Window same-direction (Ch 27) | Longest/shortest subarray với điều kiện monotonic | Mở rộng r, co l |
KMP vs Z vs Rolling Hash:
| Pattern | Preprocess | Khi tốt | Khi xấu |
|---|---|---|---|
| KMP (Ch 35) | LPS array O(m) |
Deterministic, in-place | LPS construction khó hiểu |
| Z (Ch 36) | Z array O(n) |
Dễ hình dung hơn LPS | Tương đương KMP perf |
| Rolling Hash (Ch 34) | Powers + prefix hash | Multiple pattern, distinct count | Collision risk |
Dijkstra vs BFS+BS vs DSU offline (cho min/max path):
| Pattern | Khi dùng | Complexity |
|---|---|---|
| Dijkstra (Ch 30) | Weighted ≥ 0, online query | O((V+E) log V) |
| BS + BFS (Ch 37) | Predicate monotonic trên threshold | O(log · (V+E)) |
| DSU offline (Ch 24, 33) | Process query theo thứ tự + sort cạnh | O((V+E) · α) |
Recursion vs Backtracking vs DFS vs Top-down DP:
| Pattern | Đặc trưng |
|---|---|
| Recursion (Ch 3) | Đệ quy thuần, không track choice/state |
| Backtracking (Ch 28) | choose → explore → unchoose, liệt kê tất cả |
| DFS (Ch 11, 12) | Duyệt 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 maintain sorted
# 6. functools.cache (memoization)
from functools import cache
@cache
def dp(state): ...
# Cẩn thận: state phải 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 cho sort custom
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 (cần 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}")(Snippet 16-35 có thể tham khảo trong code mỗi chương.)
Các template code đã có sẵn ở đầu mỗi chương (mục “Template code”). Đây là index nhanh:
| # | Pattern | Template ở chương |
|---|---|---|
| 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 |
Tinh giản, dedupe theo chương. Mỗi bài LC chỉ xuất hiện 1 lần.
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 (đã ở Array, skip), LC 76 (đã ở 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) ưu tiên hơn LC 207 (Course Schedule) cho ngày đầu — Stock dễ làm tâm lý nhẹ trước khi vào graph. LC 49, 76 xuất hiện trong cả Array và Sliding Window family — chỉ tính 1 lần ở Array (tier xuất hiện sớm hơn).
Một số vòng phỏng vấn yêu cầu cả design + implement. Ví dụ:
| Bài | Chương liên quan |
|---|---|
| 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) |
Quy trình: làm rõ scope → thiết kế class/interface → cài đặt các method → test.
| Tiếng Anh | Tiếng Việt | Định nghĩa ngắn |
|---|---|---|
| Invariant | Bất biến | Thuộc tính luôn đúng tại mọi điểm trong vòng lặp / đệ quy |
| State | Trạng thái | Đại lượng đủ để mô tả bài con (DP, game theory) |
| Transition | Bước chuyển | dp[next] = f(dp[curr]) — quan hệ giữa state |
| Subproblem | Bài con | Bài nhỏ hơn dùng để xây bài lớn (DP, D&C) |
| Optimal substructure | Cấu trúc con tối ưu | Lời giải tối ưu được build từ lời giải con tối ưu |
| Overlapping subproblems | Bài con trùng lặp | Subproblem xuất hiện nhiều lần → cache được |
| Monotonic | Đơn điệu | Tăng / giảm theo 1 chiều (sort, stack, predicate BS) |
| Amortized | Khấu hao | Trung bình mỗi op O(1) dù worst-case có thể
O(n) |
| Greedy | Tham lam | Mỗi bước chọn cái tốt nhất tại chỗ |
| Heuristic | Heuristic | Quy tắc giúp lựa chọn không bảo đảm tối ưu |
| Trie | Trie (prefix tree) | Cây mà mỗi node = 1 ký tự, đường đi = 1 prefix |
| Adjacency list | Danh sách kề | graph[u] = [v1, v2, ...] |
| In-place | In-place | Sửa trực tiếp input, không tạo cấu trúc phụ |
| Stable sort | Sort ổn định | Giữ thứ tự gốc của các phần tử bằng nhau (Python’s
sorted là stable) |
| Sentinel | Sentinel | Phần tử biên ảo để tránh special-case (dummy head,
[-1, n]) |
| Pivot | Pivot | Phần tử chuẩn để partition (quicksort, quickselect) |
| Backtracking | Backtracking | Đệ quy + undo state khi quay lui |
| Memoization | Memoization | Cache kết quả subproblem (top-down DP) |
| Tabulation | Tabulation | Build DP table bottom-up |
| Bitmask | Bitmask | Encode subset bằng int (bit i = phần tử i
có/không) |
| 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 (đồ thị có hướng không chu trình) |
| BST | BST | Binary Search Tree |
| Big-O | Big-O | Notation độ phức tạp tiệm cận |
| α(n) | Alpha(n) | Hàm Ackermann ngược (≈ 4 với mọi n thực tế) |
Tra theo số LeetCode → bài trong sách. Hỗ trợ tra ngược nhanh.
| LC # | Bài | Chương | Mục |
|---|---|---|---|
| 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 |
| 38-… | (xem chương tương ứng) | ||
| 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 | — | (tự luyện 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 | — | (xem 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) |
| 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 | (tự luyện) |
| 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 | (tự luyện) |
| 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 | (tự luyện) |
| 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 | (tự luyện) |
| 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 | (đã thay bằng LC 1462) | — | — |
| 2858 | Min Edge Reversals | 42 | 42.6 |
| 2935 | Max Strong Pair XOR II | 41 | 41.5 |
Một số bài xuất hiện ở nhiều chương dưới những góc nhìn khác nhau. Đây là map để tránh nhầm lẫn:
| LC | Bản đầy đủ | Recap ở | Góc nhìn mới ở phần 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) | 2 cách giải khác nhau |
| 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 | 3 góc nhìn: 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 | 3 cách: DSU, Dijkstra, BS + BFS |
| LC 1316 Distinct Echo | Ch 34.3 (Rolling Hash) | Ch 36.6 (Z function) | 2 thuật toán khác |
| LC 1631 Path Min Effort | Ch 30.2 (Dijkstra) | Ch 37.2 (BS + BFS) | 2 cách |
| LC 2223 Sum of Scores | Ch 34.6 (Rolling Hash) | Ch 36.3 (Z) | 2 thuật toán |
Cách đọc recap: tập trung vào “góc nhìn mới” — pattern hiện tại đang nhìn bài cũ theo cách gì, chứ không phải đọc lại lời giải lần thứ hai.
24 giờ trước: - [ ] Ôn lại Frontmatter 0.2 (UMPIRE) — đọc lại 5 bước. - [ ] Đọc lại Phụ lục E (Behavioral) — chuẩn bị 3-4 câu chuyện STAR. - [ ] Ngủ đủ 7-8 tiếng. Đừng thức khuya code. - [ ] Kiểm tra thiết bị (micro, camera, chia sẻ màn hình, IDE / online editor).
1 tuần trước: - [ ] Hoàn thành 50 bài must-do (Phụ lục D). - [ ] 2-3 mock interview (Pramp / interviewing.io / bạn bè). - [ ] Đọc kỹ company-specific patterns (LeetCode tag theo company). - [ ] Review CV/resume — story behind mỗi project.
1 tháng trước: - [ ] Hoàn thành Level 1 + Level 2 (Chương 1-32). - [ ] Mock interview định kỳ 1-2 buổi / tuần. - [ ] Học behavioral framework + chuẩn bị 10 stories STAR. - [ ] Apply rộng (5-10 công ty) để có nhiều buổi practice.
Chúc bạn thành công trên hành trình phỏng vấn! Cảm ơn đã đọc cuốn sách này.