GĐ00 — Data Structures, Algorithms & Complexity cho Software Engineer
Mục tiêu của giai đoạn này không phải biến bạn thành competitive programmer. Bạn cần đủ nền để chọn cấu trúc dữ liệu đúng, đọc được độ phức tạp của code, giải thích quyết định trong phỏng vấn, và nhận ra khi một đoạn code sẽ gãy khi dữ liệu tăng.
Học song song được với GĐ02–05. Không cần hoàn thành mọi bài thuật toán rồi mới viết backend.
Kiểm chứng ngày 2026-10-05: các đoạn mã ngắn ở mục 2, 5, 6, 7, 8, 9, 10, mục 10a, benchmark ở bài tập mục 1 và bản LRU danh sách liên kết đôi đều đã chạy trên Node 24.21 (macOS, type stripping) và qua
tsc --noEmitTypeScript 6.0.3 vớistrict,noUncheckedIndexedAccess,erasableSyntaxOnly. Số ms của benchmark tuỳ máy; điều cần so là tỉ lệ. Chưa kiểm: hành vi trên các engine ngoài V8.
1. Complexity — đo trước khi tối ưu#
Big-O mô tả tốc độ tăng của thời gian hoặc bộ nhớ khi kích thước input n tăng.
| Độ phức tạp | Trực giác | Ví dụ |
|---|---|---|
O(1) | Không đổi theo n | Đọc một key trong hash map |
O(log n) | Mỗi bước loại một phần lớn dữ liệu | Binary search, B-tree lookup |
O(n) | Đi qua dữ liệu một lần | Tìm phần tử trong array chưa sắp xếp |
O(n log n) | Mức phổ biến của sort tốt | Merge sort, built-in sort điển hình |
O(n²) | Mỗi phần tử so với gần như mọi phần tử | Hai vòng lặp lồng nhau |
O(2ⁿ) | Tăng theo mọi tổ hợp | Brute-force nhiều bài chọn/bỏ |
Luôn xét cả:
- Time complexity: CPU tăng thế nào.
- Space complexity: bộ nhớ bổ sung tăng thế nào.
- Average vs worst case: hash map thường
O(1), nhưng không phải lời hứa tuyệt đối. - Input thực tế: thuật toán tốt hơn về Big-O chưa chắc nhanh hơn với dữ liệu rất nhỏ.
Bài tập#
-
Phân tích time/space complexity của năm hàm bạn từng viết.
Lời giải và cách kiểm tra
Tự làm trước, rồi mới mở. Code tham chiếu, chưa chạy riêng (các hàm ở mục 11 thì đã chạy, xem cuối mục đó).
Cách làm: đếm vòng lặp lồng nhau, xem mỗi bước bên trong có tự lặp qua
nkhông (includes,indexOf,slice, spread đều làO(n)ẩn), rồi đếm bộ nhớ tạo thêm.typescriptReadySai thường gặp: quên chi phí ẩn của
indexOf/includes/slicenên tưởng B và E làO(n). -
Thay một đoạn tìm kiếm lặp lại trên array bằng
Mapvà đo trước/sau.Lời giải và cách kiểm tra
Tự làm trước, rồi mới mở. Benchmark dưới đây đã chạy trên Node 24.21 (máy 12 core, ba lần chạy cho kết quả cùng cỡ).
Cách làm: dựng
nbản ghi, tranlần; bản chậm dùngfind, bản nhanh dựngMapmột lần (chi phí dựngMaptính vào bản nhanh). Phải có warm-up: vài lượt đầu V8 chưa tối ưu code nóng nên chậm và dao động; bỏ chúng ra, rồi lấy trung vị nhiều lượt thay vì một lầnconsole.time.typescriptReadySố đo (median,
node bench.ts N):nfindMapfind/Map2 000 1,04 ms 0,14 ms cỡ 7 lần 20 000 113 đến 120 ms 1,3 đến 1,5 ms cỡ 80 lần Cách đọc:
ntăng 10 lần thìfindchậm khoảng 110 lần (O(n²)),Mapchậm khoảng 10 lần (O(n)); vớin = 2 000chênh lệch còn nhỏ nên tối ưu sớm ở đó chưa đáng. Sai thường gặp: đo vớin = 10, thấyfindkhông chậm hơn, rồi kết luận Big-O vô nghĩa (vớinnhỏ, hằng số che mất tốc độ tăng); đo một lần duy nhất bằngconsole.timerồi tin con số đó. -
Giải thích vì sao tối ưu
O(n²)đáng ưu tiên hơn micro-optimize một vòngO(n).Lời giải và cách kiểm tra
Tự làm trước, rồi mới mở. Code tham chiếu, chưa chạy riêng (các hàm ở mục 11 thì đã chạy, xem cuối mục đó).
Micro-optimize chỉ đổi hằng số (thường 1,5–3 lần); đổi lớp độ phức tạp đổi cả hệ số theo
n. Ví dụ:n = 10⁵thìn²= 10¹⁰ phép tính (hàng chục giây) cònn= 10⁵ (dưới 1 ms): bỏ vòngO(n²)được lợi cỡ 10⁵ lần, tối ưu hằng số một vòngO(n)được lợi vài lần trên một phần nhỏ thời gian. Điều kiện: đo trước (profile) để chắc đoạnO(n²)nằm trên đường nóng vớinthật.
2. Array và String#
Array lưu một dãy phần tử theo thứ tự. Đây là cấu trúc bạn dùng nhiều nhất trong application code.
Cần nắm:
- Truy cập theo index thường là
O(1). - Tìm theo giá trị là
O(n)nếu chưa có index phụ. - Thêm/xoá ở cuối thường rẻ; thêm/xoá ở đầu hoặc giữa phải dịch chuyển phần tử.
slice, spread và nhiều hàm immutable tạo array mới, vì vậy có chi phí bộ nhớ.- String thường immutable; nối chuỗi lớn trong loop có thể tạo nhiều allocation.
Pattern cần luyện:
- Two pointers.
- Sliding window.
- Prefix sum.
- Sort rồi scan.
Liên hệ backend: pagination trong bộ nhớ, xử lý batch, rolling metrics, rate limiting theo cửa sổ thời gian.
3. Hash Map và Set#
Trong TypeScript, hai công cụ chính là Map và Set.
Dùng khi cần:
- Lookup theo key.
- Đếm tần suất.
- Deduplicate.
- Group dữ liệu.
- Join hai tập dữ liệu nhỏ trong application memory.
Pitfall: biến toàn bộ bảng database thành Map trong RAM không thay thế cho index hoặc query đúng.
4. Stack và Queue#
Stack — LIFO#
Phần tử vào sau ra trước.
Ứng dụng:
- Call stack.
- Undo.
- Parse expression.
- DFS không dùng recursion.
- Kiểm tra ngoặc hợp lệ.
Queue — FIFO#
Phần tử vào trước ra trước.
Ứng dụng:
- Background jobs.
- Buffer.
- Request scheduling.
- BFS.
Trong JavaScript, gọi shift() liên tục trên array lớn có thể tốn O(n). Khi cần queue hiệu quả, dùng con trỏ head hoặc cấu trúc queue chuyên dụng.
5. Linked List#
Linked list cho phép thêm/xoá tại vị trí đã biết mà không dịch chuyển toàn bộ phần tử, đổi lại:
- Không truy cập index
O(1)như array. - Tốn thêm bộ nhớ cho pointer.
- Cache locality kém hơn array.
Bạn cần hiểu để phỏng vấn và đọc implementation của LRU cache; trong product code TypeScript, ít khi cần tự viết linked list.
Bản LRU dùng danh sách liên kết đôi kèm Map nằm ở mini utility số 1 (phần Thực hành bên dưới).
6. Tree, Binary Search Tree và B-tree#
Tree mô hình hoá dữ liệu phân cấp: DOM, filesystem, category tree, AST.
Cần nắm:
- Root, parent, child, leaf, depth, height.
- DFS: preorder, inorder, postorder.
- BFS theo từng level.
- Binary Search Tree và điều kiện trái < node < phải.
- Tree cân bằng giữ lookup gần
O(log n).
Chèn khoá đã sắp xếp sẵn (1, 2, 3, ...) vào BST không cân bằng cho ra cây lệch thành một đường thẳng, lookup lùi về O(n); đó là lý do cần tree tự cân bằng và B-tree.
Liên hệ backend: PostgreSQL thường dùng B-tree index, được thiết kế cho block storage và cho phép mỗi node chứa nhiều key. Không đồng nhất B-tree với binary tree.
7. Heap và Priority Queue#
Heap trả phần tử nhỏ nhất hoặc lớn nhất hiệu quả mà không cần sort lại toàn bộ collection.
Ứng dụng:
- Job priority.
- Top-K.
- Scheduler.
- Merge nhiều stream đã sắp xếp.
Cần biết:
- Peek:
O(1). - Push/pop:
O(log n). - Build heap có thể đạt
O(n).
Không cần tự thuộc implementation; cần giải thích được vì sao priority queue phù hợp hơn việc sort array sau mỗi lần thêm.
8. Graph#
Graph gồm vertex và edge, có thể có hướng hoặc vô hướng, có trọng số hoặc không.
Ứng dụng:
- Dependency graph.
- Social connection.
- Workflow/state transition.
- Service dependency.
- Route planning.
Hai cách biểu diễn chính:
- Adjacency list: phù hợp graph thưa, thường dùng nhất.
- Adjacency matrix: lookup edge nhanh nhưng tốn
O(V²)bộ nhớ.
Cần luyện:
- BFS cho đường đi ngắn nhất trên graph không trọng số.
- DFS cho traversal và cycle detection.
- Topological sort cho dependency có hướng không chu trình.
Ngoài phạm vi của stage này: đường đi ngắn nhất có trọng số (Dijkstra), Trie, Union-Find. Gặp trong thực tế thì dùng thư viện hoặc tìm bài giải khi cần; BFS, DFS và topological sort ở trên là đủ cho nền backend.
9. Search và Sort#
Search#
- Linear search:
O(n). - Binary search:
O(log n), chỉ đúng khi dữ liệu có thứ tự và invariant được giữ.
Binary search không chỉ dùng để tìm một giá trị. Nó còn dùng để tìm biên đầu/cuối hoặc “giá trị nhỏ nhất thoả điều kiện”.
Sort#
Cần hiểu trade-off, không cần tự viết mọi thuật toán:
- Stable vs unstable.
- In-place vs cần bộ nhớ phụ.
- Average vs worst case.
- Comparator phải nhất quán.
Trong application code, ưu tiên built-in sort. Tự triển khai chỉ để học cơ chế.
10. Recursion và Dynamic Programming cơ bản#
Recursion phù hợp cấu trúc tự lặp như tree, nhưng recursion quá sâu có thể làm tràn call stack.
Dynamic Programming dùng khi bài toán có:
- Subproblem lặp lại.
- Optimal substructure.
Hai cách:
- Memoization: top-down.
- Tabulation: bottom-up.
Cho mục tiêu full-stack, chỉ cần nhận diện và giải các bài DP một chiều/two-dimensional cơ bản. Không để DP chiếm thời gian đáng ra dùng để build sản phẩm.
10a. Bẫy JavaScript có chứng minh bằng lệnh chạy được#
Sáu lệnh trong bảng dưới đây đều chạy xong mà không ném exception (lỗi nằm ở kết quả sai, không phải ở lỗi chạy; phần ghi chú của bẫy 6 nhắc thêm rằng JSON.stringify với BigInt thì có ném TypeError, nhưng lệnh trong bảng không chứa BigInt). Mỗi bẫy có một lệnh để tự chạy, kết quả thật trên Node 24.21 và cách tránh.
| # | Lệnh (node -p '...') | Kết quả thật | Cách tránh |
|---|---|---|---|
| 1 | 0.1 + 0.2 | 0.30000000000000004 | Tiền tệ lưu số nguyên đơn vị nhỏ nhất (cent) hoặc dùng kiểu decimal của DB; so sánh số thực bằng ngưỡng sai số |
| 2 | [1, 10, 2].sort() | [ 1, 10, 2 ] | sort mặc định so sánh như chuỗi và đổi mảng gốc; luôn truyền comparator (a, b) => a - b, dùng toSorted khi cần giữ bản gốc |
| 3 | 9007199254740993 | in ra 9007199254740992 | Số nguyên an toàn tối đa là Number.MAX_SAFE_INTEGER (9007199254740991); ID lớn (snowflake, bigint của DB) giữ dạng string hoặc BigInt |
| 4 | 'constructor' in {} | true | Object thường có prototype: khoá constructor, toString, __proto__ từ input người dùng gây nhầm; dùng Map cho từ điển động |
| 5 | const g = Array(3).fill([]); g[0].push(1); JSON.stringify(g) | [[1],[1],[1]] | fill gán cùng một tham chiếu; dùng Array.from({ length: 3 }, () => []) (cho [[],[],[]]) |
| 6 | JSON.stringify({ a: undefined, m: new Map([[1, 2]]), n: NaN }) | {"m":{},"n":null} | undefined bị bỏ, Map thành {}, NaN thành null, BigInt ném TypeError; chuyển Map thành mảng/object và BigInt thành string trước khi serialize |
Bẫy 4 còn có mặt nguy hiểm hơn: Object.assign({}, JSON.parse('{"__proto__":{"admin":true}}')).admin trả true (đã chạy), vì Object.assign gán qua setter __proto__ nên prototype của object đích bị thay bằng dữ liệu của người dùng; spread { ...input } không dính. Đừng trộn input thô vào object cấu hình bằng Object.assign, hãy validate bằng schema (Zod) rồi mới dùng.
11. Bài tập có đáp án TypeScript#
Cách học phần này#
Với mỗi bài:
- Tự làm tối thiểu 20 phút.
- Viết brute-force trước nếu chưa thấy lời giải tối ưu.
- Chạy test bằng tay.
- Ghi time/space complexity.
- Sau đó mới mở đáp án và tự viết lại không nhìn.
Các snippet giả định TypeScript strict: true. Có thể đặt vào một project Vitest hoặc chạy bằng tsx.
Bài 1 — Two Sum bằng Hash Map#
Đề. Cho array số nguyên và target, trả về index của hai phần tử có tổng bằng target. Mỗi input có tối đa một đáp án; không dùng lại cùng một phần tử.
Hướng nghĩ. Brute-force thử mọi cặp mất O(n²). Khi đang đứng ở value, ta cần biết target - value đã xuất hiện chưa. Map trả lời lookup đó trung bình O(1).
Đáp án.
Vì sao đúng. Trước khi lưu phần tử hiện tại, map chỉ chứa các phần tử đứng trước nó. Nếu complement tồn tại, hai index chắc chắn khác nhau. Lưu sau khi kiểm tra cũng xử lý đúng [3, 3], target 6.
- Time:
O(n)trung bình. - Space:
O(n). - Pitfall: dùng
if (complementIndex)sẽ bỏ sót index0; phải so vớiundefined.
Bài 2 — Deduplicate event, giữ bản mới nhất#
Đề. Một batch có thể chứa nhiều event cùng id. Giữ event có occurredAt mới nhất và trả theo thứ tự thời gian tăng dần.
Đáp án.
Giải thích. Map loại vòng lặp tìm event cũ cho từng event mới. Sau deduplicate còn k event duy nhất, nên bước sort là O(k log k).
- Time:
O(n + k log k). - Space:
O(k). - Production note: deduplicate trong RAM chỉ có hiệu lực trong một process/batch; idempotency phân tán cần unique constraint hoặc shared store.
Bài 3 — Sliding window rate limit#
Đề. Cho danh sách timestamp đã tăng dần, tìm số request lớn nhất xuất hiện trong bất kỳ cửa sổ windowMs nào.
Đáp án.
Vì sao là O(n). right đi từ trái sang phải một lần. left cũng chỉ tăng, tổng cộng tối đa n lần; while lồng bên trong không biến toàn bộ thuật toán thành O(n²).
- Time:
O(n). - Space:
O(1)ngoài input. - Boundary: code dùng cửa sổ nửa mở
[now - windowMs, now); timestamp cách nhau đúngwindowMskhông cùng cửa sổ.
Bài 4 — Prefix Sum cho số liệu theo khoảng#
Đề. Cho số request mỗi phút. Trả tổng request từ phút start đến end, bao gồm cả hai đầu. Hàm sẽ được gọi nhiều lần trên cùng dữ liệu.
Đáp án.
Giải thích. prefix[i] lưu tổng của các phần tử trước index i. Tổng [start, end] bằng tổng trước end + 1 trừ tổng trước start.
- Build: time
O(n), spaceO(n). - Mỗi query:
O(1)thay vìO(n). - Trade-off: phù hợp dữ liệu ít thay đổi, nhiều query; update liên tục cần cấu trúc khác như Fenwick tree hoặc xử lý ở database.
Bài 5 — Stack kiểm tra dấu ngoặc#
Đề. Chuỗi chỉ chứa ()[]{}. Trả true nếu mọi dấu ngoặc đóng đúng loại và đúng thứ tự.
Đáp án.
Giải thích. Dấu đóng phải khớp dấu mở gần nhất chưa được đóng — đúng định nghĩa LIFO của stack.
- Time:
O(n). - Space:
O(n)worst case khi toàn dấu mở. - Pitfall: chỉ đếm số lượng từng loại không phát hiện sai thứ tự như
([)].
Bài 6 — Queue không dùng shift()#
Đề. Cài queue generic với enqueue, dequeue, peek và size, tránh dịch chuyển array sau mỗi lần lấy phần tử.
Đáp án.
Giải thích. head tăng mà không dịch chuyển phần còn lại. Thỉnh thoảng compact array để phần tử đã dequeue không bị giữ mãi; chi phí được phân bổ qua nhiều operation.
enqueue: amortizedO(1).dequeue: amortizedO(1).- Space:
O(n)với số phần tử đang chờ cộng phần chưa compact.
Bài 7 — Binary Search tìm lower bound#
Đề. Array đã sắp xếp tăng dần. Tìm index đầu tiên có giá trị >= target; nếu mọi giá trị nhỏ hơn target, trả values.length.
Đáp án.
Invariant. Đáp án luôn nằm trong đoạn nửa mở [left, right]. Khi values[middle] < target, middle chắc chắn không phải đáp án. Ngược lại, middle vẫn có thể là phần tử đầu tiên nên giữ lại bằng right = middle.
- Time:
O(log n). - Space:
O(1). - Pitfall: dùng
right = middle - 1trong template nửa mở này làm mất candidate.
Bài 8 — BFS theo level trên cây#
Đề. Trả danh sách value theo từng depth của binary tree.
Đáp án.
Giải thích. Tại đầu mỗi vòng while, queue chứa đúng các node chưa xử lý; levelSize chụp số node của level hiện tại trước khi child được thêm vào.
- Time:
O(n). - Space:
O(w), vớiwlà chiều rộng lớn nhất của cây; array backing có thể giữ tớiO(n)nếu không compact.
Bài 9 — DFS phát hiện cycle trong dependency graph#
Đề. Graph có hướng biểu diễn service -> dependencies. Trả true nếu có cycle.
Đáp án.
Giải thích. visiting là các node trên recursion path hiện tại. Gặp lại node trong tập này tạo back-edge, tức cycle. visited chứa node đã kiểm tra xong và giúp mỗi node chỉ xử lý một lần.
- Time:
O(V + E). - Space:
O(V). - Pitfall: chỉ dùng một
visitedset không phân biệt “đang thăm” và “đã xong”, nên không phát hiện đúng cycle có hướng.
Bài 10 — Topological Sort cho thứ tự deploy#
Đề. Trả thứ tự sao cho dependency được deploy trước service phụ thuộc nó. Nếu có cycle, ném lỗi.
Đáp án — Kahn's algorithm.
Giải thích. Bắt đầu từ service không còn dependency. Mỗi khi deploy một service, giảm dependency count của các service phụ thuộc nó. Nếu không thể xử lý hết node, phần còn lại nằm trong cycle.
- Time:
O(V + E). - Space:
O(V + E).
Bài 11 — Min Heap và Top-K latency#
Đề. Trả k latency lớn nhất nhưng không sort toàn bộ input. Dùng min heap kích thước tối đa k.
Đáp án.
Giải thích. Heap chỉ giữ k phần tử lớn nhất đã thấy. Phần tử nhỏ nhất trong nhóm nằm ở root và bị loại khi heap vượt kích thước.
- Time:
O(n log k)thay vì sort toàn bộO(n log n). - Space:
O(k). - Production note: percentile như p95 không nên tính bằng top-K trên toàn bộ traffic; dùng histogram hoặc streaming quantile phù hợp.
Bài 12 — Dynamic Programming: minimum cost#
Đề. Có chi phí tại mỗi bước. Từ vị trí 0 hoặc 1, mỗi lần đi 1 hoặc 2 bước. Tính chi phí nhỏ nhất để đi qua cuối array.
Đáp án tối ưu bộ nhớ.
Giải thích. Chi phí tối ưu để đến bước hiện tại chỉ phụ thuộc hai bước trước. Không cần giữ cả bảng DP; hai biến đủ duy trì state transition.
- Time:
O(n). - Space:
O(1). - Cách nhận diện DP: cùng subproblem “chi phí tốt nhất tới index
i” được dùng lại, và đáp án hiện tại được xây từ đáp án tối ưu nhỏ hơn.
Test nhanh cho các đáp án#
Các test trên là smoke test, chưa đủ edge case. Tự bổ sung input rỗng, một phần tử, duplicate, số âm, boundary và input lớn.
Kết quả mong đợi và sơ đồ: chạy 12 bài
Đã chạy: Node 24.21 (type stripping, không cờ) và tsc --noEmit TypeScript 6.0.3 với strict, noUncheckedIndexedAccess, erasableSyntaxOnly: toàn bộ code mục 11 gộp một file, các assert ở trên cộng edge case (twoSum([3, 3], 6), input rỗng, lowerBound ngoài biên, Queue qua 5.000 phần tử để chạy nhánh compact, timestamp cách đúng windowMs) đều qua, tsc sạch. Output quan sát được:
Hai sơ đồ để tự dò lại bằng tay.
Bài 7, lowerBound([1, 2, 2, 4], 2). Đáp án nằm trong [left, right], mỗi bước thu hẹp:
Bài 10, Kahn trên api -> [db, cache], cache -> [db], db -> [].
Lỗi hay gặp: sửa lowerBound thành right = values.length - 1 rồi vẫn dùng while (left < right) thì trả sai khi target lớn hơn mọi phần tử (phải trả values.length).
Thực hành — mini backend utilities#
Viết và test:
-
LRU cache giới hạn số phần tử.
Lời giải và cách kiểm tra
Tự làm trước. Cả hai bản dưới đây đã chạy trên Node 24.21 với test, và qua
tsc --noEmit(TypeScript 6.0.3,strict,erasableSyntaxOnly).Hướng làm:
Mapgiữ thứ tự chèn, nên key đầu tiên là key lâu chưa dùng nhất; mỗi lầngetxoá rồi chèn lại để đẩy xuống cuối.getvàsetđềuO(1).typescriptReadyKết quả mong đợi: với
capacity = 2, chuỗiset a, set b, get a, set cloạib(không phảia):get btrảundefined,get avàget ctrả giá trị. Test cần có: ghi đè key cũ không làm tăng size,getkey không tồn tại không đổi thứ tự. Lỗi hay gặp: dùngif (value)để kiểm tồn tại (bỏ sót giá trị0,''); không refresh thứ tự khiget, thành FIFO chứ không phải LRU.Bản danh sách liên kết đôi +
Map(đúng cơ chế mà bản trên dựa vào ngầm; phỏng vấn thường yêu cầu bản này).Mapcho lookupO(1)tới node; danh sách giữ thứ tự dùng, node cóprevvànextnên gỡ ra khỏi giữa danh sách cũngO(1):typescriptReadyTest đã chạy:
capacity = 2vớiset a, set b, get a, set cloạib; ghi đè key cũ không tăng size và refresh thứ tự; giá trị0vẫn là hit;capacity = 0némRangeError; cuối cùng 20 000 thao tác ngẫu nhiên (seed cố định, 12 key,capacity = 5) cho kết quảgetgiống hệt bảnMap. Output:lru ok. Lỗi hay gặp: quên cập nhậthead/tailkhi gỡ node ở hai đầu; quênindex.deletekhi loại node cuối; đểprev/nextcũ còn trỏ sau khi gỡ (rò node). -
Rate limiter sliding window chạy trong memory.
Lời giải và cách kiểm tra
Tự làm trước. Code tham chiếu, chưa chạy: chỉ kiểm tĩnh bằng
tsc --noEmit(TypeScript 6.0.3,strict,erasableSyntaxOnly); kết quả mong đợi suy ra từ code.Hướng làm: mỗi key giữ timestamp của các request được chấp nhận trong cửa sổ; bỏ timestamp đã ra khỏi cửa sổ rồi so với giới hạn. Đây là bài 3 áp vào từng key.
typescriptReadyKết quả mong đợi:
limit = 3,windowMs = 1000: gọi tại0, 100, 200đềutrue, tại300làfalse, tại1000làtrue(request ở0đã ra khỏi cửa sổ nửa mở, khớp quy ước bài 3). Truyềnnowvào để test không cầnsleep. Giới hạn: nằm trong RAM một process,Mapkhông tự dọn key không còn truy cập (cần job dọn hoặc TTL), nhiều instance mỗi bên đếm riêng nên cần Redis (GĐ09 nói về rate limit thật). -
Job scheduler nhỏ dùng priority queue.
Lời giải và cách kiểm tra
Tự làm trước. Code tham chiếu, chưa chạy: chỉ kiểm tĩnh bằng
tsc --noEmit(TypeScript 6.0.3,strict,erasableSyntaxOnly); kết quả mong đợi suy ra từ code.Hướng làm: tổng quát hoá
MinHeapở bài 11 bằng comparator; khoá so sánh là(runAt, seq)để job cùng giờ chạy theo thứ tự thêm (tính ổn định).typescriptReadyKết quả mong đợi: thêm job ở
t=30, 10, 20, 10;tick(15)chạy 2 jobt=10(theo thứ tự thêm),tick(100)chạy hai job còn lại theo thứ tự 20, 30. Lấy job tiếp theo làO(log n)thay vì sort lại cả danh sách mỗi lần thêm. Với process thật,tickđược gọi từ mộtsetTimeoutđặt theopeek().runAt - Date.now(). Giới hạn: mất hết khi process chết, nên production dùng queue có lưu trữ (GĐ10). -
Dependency resolver có cycle detection và topological sort.
Lời giải và cách kiểm tra
Tự làm trước. Code tham chiếu, chưa chạy: chỉ kiểm tĩnh bằng
tsc --noEmit(TypeScript 6.0.3,strict,erasableSyntaxOnly); kết quả mong đợi suy ra từ code.Hướng làm: gộp bài 9 (phát hiện cycle) và bài 10 (thứ tự), nhưng báo cả đường đi của cycle để lỗi đọc được.
typescriptReadyKết quả mong đợi:
a -> b -> c -> anémDependency cycle: a -> b -> c -> a; đồ thịapi -> [db, cache],cache -> [db]chodb, cache, api(đã chạydeploymentOrdervới đồ thị này). Test cần có: node chỉ xuất hiện như dependency (không có key), self-loopa -> a, hai thành phần rời nhau. -
Hàm group/deduplicate một batch event bằng
Map/Set.Lời giải và cách kiểm tra
Tự làm trước. Code tham chiếu, chưa chạy: chỉ kiểm tĩnh bằng
tsc --noEmit(TypeScript 6.0.3,strict,erasableSyntaxOnly); kết quả mong đợi suy ra từ code.Hướng làm:
Map<key, mảng>để group,Setđể loại trùng; dedupe có chọn bản mới nhất đã có ở bài 2.typescriptReadyKết quả mong đợi:
groupBy(events, e => e.type)choMapcó số phần tử bằng số type, tổng độ dài các nhóm bằngevents.length;uniqueBygiữ bản đầu tiên của mỗi key và giữ thứ tự gốc. TimeO(n), spaceO(n). Lỗi hay gặp: dùng object{}làm map với key do người dùng gửi (__proto__), nên dùngMap.
Không dùng các utility này thay Redis hoặc queue thật trong production. Mục tiêu là hiểu cơ chế mà công cụ production đang cung cấp.
Done khi#
-
Tự phân tích được time và space complexity của code có một hoặc hai vòng lặp.
Đáp án
Quy trình: đếm vòng lặp lồng nhau, tìm chi phí ẩn (
includes,indexOf,slice, spread,sort), rồi đếm bộ nhớ thêm. Ví dụ: hai vòng song song làO(n), hai vòng lồng nhau làO(n²), vòng lặp cóSet.hasbên trong vẫnO(n). Cách tự kiểm: làm lại bài tập mục 1, đối chiếu với bài 1, 3, 4 ở mục 11. Sai thường gặp: bỏ qua space của[...xs]/slice. Xem GĐ00 mục 1. -
Chọn được Array, Map, Set, Stack, Queue, Heap, Tree hoặc Graph và giải thích vì sao.
Đáp án
Cần lookup/đếm/dedupe:
Map/Set. Thứ tự vào/ra:Queue(FIFO),Stack(LIFO). Lấy nhỏ/lớn nhất liên tục: heap. Phân cấp: tree. Quan hệ n-n: graph. Mẫu trả lời: "cần X thao tác, cấu trúc này làm X trongO(...), đánh đổi là ...". Ví dụ: job ưu tiên dùng heap vìpoplàO(log n)còn sort lại mỗi lần thêm làO(n log n). Xem GĐ00 mục 3 đến 8. -
Viết được BFS và DFS mà không nhìn tài liệu.
Đáp án
BFS: queue +
head+ chụplevelSize(bài 8). DFS: đệ quy hoặc stack, với đồ thị có hướng cần tập "đang thăm" và "đã xong" (bài 9). Cách tự kiểm: viết lại cả hai trên cùng đồ thị, kết quả BFS phải theo level, DFS phát hiện đượca -> b -> a. Sai thường gặp: quên đánh dấu visited khi push vào queue nên node bị thêm nhiều lần. -
Dùng binary search đúng điều kiện.
Đáp án
Điều kiện: dữ liệu có thứ tự theo đúng vị từ đang tìm, và giữ nguyên một invariant (đáp án luôn nằm trong
[left, right]nửa mở). MẫulowerBoundở bài 7 giải được cả "tìm biên đầu", "chèn vào đâu", "giá trị nhỏ nhất thoả điều kiện". Cách tự kiểm: chạy trên mảng rỗng, một phần tử, target nhỏ hơn hết và lớn hơn hết. Sai thường gặp:right = middle - 1trong template nửa mở. -
Giải được khoảng 40–60 bài theo pattern, không học thuộc lời giải.
Đáp án
Đây là việc luyện tập, tự kiểm bằng sổ ghi: mỗi bài ghi pattern (two pointers, sliding window, prefix sum, hash map, heap, BFS/DFS, binary search, DP), độ phức tạp, và một lỗi đã mắc. Đạt khi gặp bài mới trong 5 phút gọi được tên pattern. Sai thường gặp: học thuộc lời giải; kiểm bằng cách giải lại sau một tuần không nhìn.
-
Hoàn thành năm mini utilities và có test.
Đáp án
Năm cái là LRU, rate limiter, scheduler, dependency resolver, group/dedupe; mỗi cái có test (ít nhất: bình thường, rỗng, biên, lỗi). Xem khối lời giải dưới từng mục 1–5. Cách tự kiểm: các test chạy không cần
sleep(truyền thời gian vào). -
Nêu được sáu bẫy JavaScript ở mục 10a và cách tránh từng bẫy.
Đáp án
Số thực (
0.1 + 0.2): tiền lưu số nguyên cent.sort()mặc định so như chuỗi và đổi mảng gốc: truyền comparator, dùngtoSorted. Số nguyên quáMAX_SAFE_INTEGERmất độ chính xác: ID lớn dạngstring/BigInt. Object thường có prototype: dùngMapcho từ điển.Array(n).fill([])dùng chung một tham chiếu:Array.from.JSON.stringifybỏundefined, làm phẳngMap, ném vớiBigInt. Cách tự kiểm: chạy lại sáu lệnh ở bảng và đoán kết quả trước khi chạy. Xem GĐ00 mục 10a. -
Liên hệ được ít nhất năm cấu trúc dữ liệu với backend production.
Đáp án
Ví dụ: B-tree (index PostgreSQL), hash map (cache, đếm rate limit), heap/sorted set (hàng đợi job có độ ưu tiên, delayed job của BullMQ dùng sorted set của Redis), queue (background job, buffer), graph (dependency giữa service, thứ tự migration), linked list + hash map (LRU), tree (filesystem, category). Cách tự kiểm: với mỗi cái chỉ ra một nơi trong dự án của bạn đang dùng nó. Xem GĐ00 mục 6 về B-tree.
Câu hỏi mở / chưa giải quyết#
-
Ngôn ngữ phỏng vấn chính sẽ là TypeScript hay ngôn ngữ khác?
Hướng trả lời hiện tại
Chưa chốt, suy ra từ mục tiêu của stage: người học chuyển Fullstack/AI SaaS đã dùng TypeScript hằng ngày, nên luyện bằng TypeScript là hợp lý; nếu công ty yêu cầu ngôn ngữ khác, các pattern vẫn giữ nguyên và chỉ đổi cú pháp.
-
Công ty mục tiêu yêu cầu DSA ở mức thực dụng hay competitive programming?
Hướng trả lời hiện tại
Chưa chốt: với mục tiêu "thực dụng" ở đầu file, ưu tiên các pattern ở mục 11 trước competitive programming; chỉ mở rộng (đồ thị có trọng số, DP khó) khi biết cụ thể vòng phỏng vấn của công ty mục tiêu.