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. Bản web miễn phí: 14 chương đầu; bản đầy đủ mua trên Gumroad.
📚 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:
🎯 Cần mentor 1:1 đồng hành đến khi có offer?
MentorPro — chương trình mentor 1-1 cá nhân hoá từ engineer đang/từng làm tại các Big Tech (NVIDIA, TikTok, Google, Meta, AWS). Là đối tác chiến lược của EngineerPro, đồng hành cùng bạn từ screening CV → luyện thuật toán → mock interview → cho đến khi nhận được job offer.
✅ 30+ học viên đã nhận offer tại Grab, NAB, IBM, ANT Group, VinBigData, Cognizant, SAP, Deputy… kể từ 7/2025. Học viên MentorPro còn được tham gia miễn phí 100% các khoá EngineerPro do mentor chỉ định.
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.
📖 Bản web miễn phí: chỉ công khai 14 chương đầu (Array → Interval). Chương 15–44 và phụ lục nằm trong sách đầy đủ trên Gumroad. Các roadmap bên dưới vẫn mô tả toàn bộ sách để bạn lập kế hoạch học.
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).
🎥 Xem thêm bài giảng video về Big-O — trình bày bởi anh Lê Chương, Senior Software Engineer @ Google, giảng viên từ team EngineerPro.
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
Bạn vừa đọc xong 14 chương đầu công khai trên web. Bản đầy đủ gồm 44 chương + phụ lục (288 bài kèm lời giải Python 3) có tại Gumroad: