HARRY-28 — GĐ00 — Data Structures, Algorithms & Complexity cho Software Engineer
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.
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.
- Thay một đoạn tìm kiếm lặp lại trên array bằng
Mapvà đo trước/sau. - Giải thích vì sao tối ưu
O(n²)đáng ưu tiên hơn micro-optimize một vòngO(n).
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.
function countByStatus(items: { status: string }[]) {
const counts = new Map<string, number>()
for (const item of items) {
counts.set(item.status, (counts.get(item.status) ?? 0) + 1)
}
return counts
}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.
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).
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.
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.
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ử.
twoSum([2, 7, 11, 15], 9) // [0, 1]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.
function twoSum(values: number[], target: number): [number, number] | null {
const indexByValue = new Map<number, number>()
for (let index = 0; index < values.length; index++) {
const value = values[index]!
const complement = target - value
const complementIndex = indexByValue.get(complement)
if (complementIndex !== undefined) {
return [complementIndex, index]
}
indexByValue.set(value, index)
}
return null
}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.
type DomainEvent = { id: string; occurredAt: number; payload: string }Đáp án.
function deduplicateLatest(events: DomainEvent[]): DomainEvent[] {
const latestById = new Map<string, DomainEvent>()
for (const event of events) {
const current = latestById.get(event.id)
if (!current || event.occurredAt > current.occurredAt) {
latestById.set(event.id, event)
}
}
return [...latestById.values()].sort(
(left, right) => left.occurredAt - right.occurredAt,
)
}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.
maxRequestsInWindow([100, 200, 900, 1_099, 1_200], 1_000) // 4Đáp án.
function maxRequestsInWindow(timestamps: number[], windowMs: number): number {
let left = 0
let maximum = 0
for (let right = 0; right < timestamps.length; right++) {
while (timestamps[right]! - timestamps[left]! >= windowMs) {
left++
}
maximum = Math.max(maximum, right - left + 1)
}
return maximum
}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.
class RangeSum {
private readonly prefix: number[]
constructor(values: number[]) {
this.prefix = new Array(values.length + 1).fill(0)
for (let index = 0; index < values.length; index++) {
this.prefix[index + 1] = this.prefix[index]! + values[index]!
}
}
query(start: number, end: number): number {
if (start < 0 || end < start || end >= this.prefix.length - 1) {
throw new RangeError('Invalid range')
}
return this.prefix[end + 1]! - this.prefix[start]!
}
}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.
function hasValidBrackets(input: string): boolean {
const expectedOpening: Record<string, string> = {
')': '(',
']': '[',
'}': '{',
}
const stack: string[] = []
for (const character of input) {
const opening = expectedOpening[character]
if (opening) {
if (stack.pop() !== opening) return false
} else {
stack.push(character)
}
}
return stack.length === 0
}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.
class Queue<T> {
private readonly items: T[] = []
private head = 0
get size(): number {
return this.items.length - this.head
}
enqueue(value: T): void {
this.items.push(value)
}
dequeue(): T | undefined {
const value = this.items[this.head]
if (value === undefined) return undefined
this.head++
if (this.head > 1_000 && this.head * 2 > this.items.length) {
this.items.splice(0, this.head)
this.head = 0
}
return value
}
peek(): T | undefined {
return this.items[this.head]
}
}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.
lowerBound([1, 2, 2, 4], 2) // 1
lowerBound([1, 2, 2, 4], 3) // 3Đáp án.
function lowerBound(values: number[], target: number): number {
let left = 0
let right = values.length
while (left < right) {
const middle = left + Math.floor((right - left) / 2)
if (values[middle]! < target) {
left = middle + 1
} else {
right = middle
}
}
return left
}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.
type TreeNode<T> = {
value: T
left?: TreeNode<T>
right?: TreeNode<T>
}Đáp án.
function levelOrder<T>(root?: TreeNode<T>): T[][] {
if (!root) return []
const queue: TreeNode<T>[] = [root]
const levels: T[][] = []
let head = 0
while (head < queue.length) {
const levelSize = queue.length - head
const level: T[] = []
for (let count = 0; count < levelSize; count++) {
const node = queue[head++]!
level.push(node.value)
if (node.left) queue.push(node.left)
if (node.right) queue.push(node.right)
}
levels.push(level)
}
return levels
}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.
function hasDependencyCycle(graph: Map<string, string[]>): boolean {
const visiting = new Set<string>()
const visited = new Set<string>()
const visit = (node: string): boolean => {
if (visiting.has(node)) return true
if (visited.has(node)) return false
visiting.add(node)
for (const dependency of graph.get(node) ?? []) {
if (visit(dependency)) return true
}
visiting.delete(node)
visited.add(node)
return false
}
for (const node of graph.keys()) {
if (visit(node)) return true
}
return false
}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.
function deploymentOrder(graph: Map<string, string[]>): string[] {
const dependents = new Map<string, string[]>()
const remainingDependencies = new Map<string, number>()
for (const [service, dependencies] of graph) {
remainingDependencies.set(service, dependencies.length)
for (const dependency of dependencies) {
if (!remainingDependencies.has(dependency)) {
remainingDependencies.set(dependency, 0)
}
const list = dependents.get(dependency) ?? []
list.push(service)
dependents.set(dependency, list)
}
}
const queue = [...remainingDependencies]
.filter(([, count]) => count === 0)
.map(([service]) => service)
const order: string[] = []
let head = 0
while (head < queue.length) {
const deployed = queue[head++]!
order.push(deployed)
for (const dependent of dependents.get(deployed) ?? []) {
const nextCount = remainingDependencies.get(dependent)! - 1
remainingDependencies.set(dependent, nextCount)
if (nextCount === 0) queue.push(dependent)
}
}
if (order.length !== remainingDependencies.size) {
throw new Error('Dependency cycle detected')
}
return order
}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.
class MinHeap {
private readonly values: number[] = []
get size(): number {
return this.values.length
}
peek(): number | undefined {
return this.values[0]
}
push(value: number): void {
this.values.push(value)
let index = this.values.length - 1
while (index > 0) {
const parent = Math.floor((index - 1) / 2)
if (this.values[parent]! <= this.values[index]!) break
;[this.values[parent], this.values[index]] = [
this.values[index]!,
this.values[parent]!,
]
index = parent
}
}
pop(): number | undefined {
if (this.values.length === 0) return undefined
const minimum = this.values[0]!
const last = this.values.pop()!
if (this.values.length === 0) return minimum
this.values[0] = last
let index = 0
while (true) {
const left = index * 2 + 1
const right = left + 1
let smallest = index
if (left < this.values.length && this.values[left]! < this.values[smallest]!) smallest = left
if (right < this.values.length && this.values[right]! < this.values[smallest]!) smallest = right
if (smallest === index) break
;[this.values[index], this.values[smallest]] = [
this.values[smallest]!,
this.values[index]!,
]
index = smallest
}
return minimum
}
}
function topKLatencies(latencies: number[], k: number): number[] {
if (k <= 0) return []
const heap = new MinHeap()
for (const latency of latencies) {
heap.push(latency)
if (heap.size > k) heap.pop()
}
const result: number[] = []
while (heap.size > 0) result.push(heap.pop()!)
return result.reverse()
}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.
minCost([10, 15, 20]) // 15Đáp án tối ưu bộ nhớ.
function minCost(costs: number[]): number {
let costBeforeTwo = 0
let costBeforeOne = 0
for (const currentCost of costs) {
const next = currentCost + Math.min(costBeforeOne, costBeforeTwo)
costBeforeTwo = costBeforeOne
costBeforeOne = next
}
return Math.min(costBeforeOne, costBeforeTwo)
}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
import assert from 'node:assert/strict'
assert.deepEqual(twoSum([2, 7, 11, 15], 9), [0, 1])
assert.equal(maxRequestsInWindow([100, 200, 900, 1_099, 1_200], 1_000), 4)
assert.equal(hasValidBrackets('([]{})'), true)
assert.equal(hasValidBrackets('([)]'), false)
assert.equal(lowerBound([1, 2, 2, 4], 2), 1)
assert.equal(lowerBound([1, 2, 2, 4], 3), 3)
assert.deepEqual(topKLatencies([120, 10, 900, 340, 50], 3), [900, 340, 120])
assert.equal(minCost([10, 15, 20]), 15)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.
Thực hành — mini backend utilities
Viết và test:
- LRU cache giới hạn số phần tử.
- Rate limiter sliding window chạy trong memory.
- Job scheduler nhỏ dùng priority queue.
- Dependency resolver có cycle detection và topological sort.
- Hàm group/deduplicate một batch event bằng
Map/Set.
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.
- [ ] Chọn được Array, Map, Set, Stack, Queue, Heap, Tree hoặc Graph và giải thích vì sao.
- [ ] Viết được BFS và DFS mà không nhìn tài liệu.
- [ ] Dùng binary search đúng điều kiện.
- [ ] Giải được khoảng 40–60 bài theo pattern, không học thuộc lời giải.
- [ ] Hoàn thành năm mini utilities và có test.
- [ ] Liên hệ được ít nhất năm cấu trúc dữ liệu với backend production.
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?
- Công ty mục tiêu yêu cầu DSA ở mức thực dụng hay competitive programming?
