CHƯƠNG 01 · FOUNDATION · ~90 phút

Big-O & Phân tích
độ phức tạp

Nền tảng của mọi thứ trong DSA. Không hiểu Big-O thì không thể đánh giá code mình viết là nhanh hay chậm, không thể trả lời "thuật toán này có scale được khi n lớn không?" — câu hỏi xuất hiện trong gần như mọi vòng phỏng vấn coding.

1.1 Tại sao cần Big-O?

Hãy tưởng tượng bạn viết hai hàm findUser đều "đúng" — đều trả về user theo email. Nhưng cách thứ nhất chạy 0.001 giây, cách thứ hai chạy 4 giây khi database có 1 triệu user. Cả hai đều đúng logic. Khác biệt nằm ở độ phức tạp thuật toán.

Big-O là cách diễn đạt tốc độ tăng của thuật toán theo kích thước input — không quan tâm máy tính nhanh hay chậm, không quan tâm ngôn ngữ là JS hay C, chỉ quan tâm: khi n tăng gấp đôi, thời gian chạy tăng bao nhiêu lần?

💡 Trực quan đơn giản
  • O(1): bao nhiêu input cũng vẫn 1 bước. Vd: lấy phần tử thứ i của array.
  • O(n): input gấp đôi → thời gian gấp đôi. Vd: tìm tuyến tính.
  • O(n²): input gấp đôi → thời gian gấp 4. Vd: hai vòng for lồng nhau.

Câu chuyện n = 1 triệu

Giả sử máy bạn chạy 10⁸ phép tính/giây. Đây là thời gian thực tế để xử lý n = 1,000,000:

Big-O Số phép tính Thời gian chạy Chấp nhận được?
O(1)1tức thì✓ Tuyệt vời
O(log n)~20tức thì✓ Tuyệt vời
O(n)10⁶0.01s✓ Tốt
O(n log n)~2×10⁷0.2s✓ Tốt
O(n²)10¹²~3 giờ✗ Quá chậm
O(n³)10¹⁸~300 năm✗ Vô dụng
O(2ⁿ)vũ trụkhông bao giờ xong✗ Thảm hoạ

Nhìn bảng này, bạn sẽ hiểu vì sao phỏng vấn luôn hỏi "Big-O của giải pháp này là gì?". Câu trả lời O(n²) với input lớn = bài làm không pass.

1.2 Định nghĩa Big-O

Định nghĩa toán học (đơn giản hoá cho lập trình viên):

📐 Định nghĩa Big-O
f(n) = O(g(n)) nghĩa là: tồn tại hai hằng số c > 0n₀ > 0 sao cho với mọi n ≥ n₀, ta có f(n) ≤ c · g(n).

Nói nôm na:

  • Big-O là cận trên của tốc độ tăng (worst case).
  • Khi n đủ lớn, f(n) không tăng nhanh hơn g(n) theo bậc.
  • Hằng số c để "bỏ qua" phần multiply nhỏ — vì lúc n lớn, hằng số không quan trọng.

Ví dụ trực quan

function sumArray(arr) {
  let total = 0;        // 1 bước
  for (let i = 0; i < arr.length; i++) {
    total += arr[i];    // n bước
  }
  return total;         // 1 bước
}

Tổng số phép tính: 1 + n + 1 = n + 2. Nhưng Big-O bỏ qua hằng số và chỉ giữ bậc cao nhấtO(n).

💡 Tại sao bỏ hằng số?
Vì khi n = 1,000,000, sự khác biệt giữa nn + 2 là không đáng kể. Big-O quan tâm tốc độ tăng dài hạn, không quan tâm chính xác từng phép tính.

1.3 Quy tắc đếm Big-O

Quy tắc 1 — Bỏ hằng số

// Big-O = O(n), không phải O(2n)
function double(arr) {
  for (let i = 0; i < arr.length; i++) console.log(arr[i]);  // n
  for (let i = 0; i < arr.length; i++) console.log(arr[i]);  // n
  // 2n → O(n)
}

Quy tắc 2 — Giữ bậc cao nhất

// Big-O = O(n²), không phải O(n² + n)
function example(arr) {
  for (let i = 0; i < arr.length; i++) {       // n
    for (let j = 0; j < arr.length; j++) {     // n
      console.log(arr[i], arr[j]);              // 1 → tổng n²
    }
  }
  for (let i = 0; i < arr.length; i++) {       // n
    console.log(arr[i]);                         // 1 → tổng n
  }
  // n² + n → O(n²)
}

Quy tắc 3 — Vòng lặp lồng nhau thì NHÂN

// Big-O = O(n²)
for (let i = 0; i < n; i++) {
  for (let j = 0; j < n; j++) {
    // ...
  }
}

// Big-O = O(n × m) — KHÁC NHAU khi n và m khác nhau!
for (let i = 0; i < n; i++) {
  for (let j = 0; j < m; j++) {
    // ...
  }
}
⚠️ Lỗi phỏng vấn rất hay mắc
Hai vòng lồng nhau chạy trên hai mảng khác nhauO(n × m), KHÔNG phải O(n²). Nếu bạn nói "n²" mà n và m không liên quan → trừ điểm.

Quy tắc 4 — Vòng lặp tuần tự thì CỘNG

// Big-O = O(n + m), gộp lại = O(max(n, m))
for (let i = 0; i < n; i++) { /* ... */ }
for (let j = 0; j < m; j++) { /* ... */ }

Quy tắc 5 — Chia đôi thì có log

// Big-O = O(log n) — mỗi vòng n giảm một nửa
let i = n;
while (i > 1) {
  i = Math.floor(i / 2);
  // ...
}

// Binary search là ví dụ kinh điển O(log n)
function binarySearch(arr, target) {
  let lo = 0, hi = arr.length - 1;
  while (lo <= hi) {
    const mid = Math.floor((lo + hi) / 2);
    if (arr[mid] === target) return mid;
    if (arr[mid] < target) lo = mid + 1;
    else hi = mid - 1;
  }
  return -1;
}
📐 Vì sao chia đôi → log₂ n?
Hỏi: cần chia n cho 2 bao nhiêu lần để còn 1? → đáp án là log₂(n). Trong Big-O, log₂ hay log₁₀ hay ln đều viết chung là log n (chúng chỉ khác nhau bởi hằng số nhân — và Big-O bỏ hằng số).

1.4 Các "họ" Big-O — từ tốt nhất đến tệ nhất

Đây là 9 họ Big-O bạn gặp 99% trong phỏng vấn. Học thuộc thứ tự này.

O(1)
constant
O(log n)
logarithmic
O(√n)
root
O(n)
linear
O(n log n)
log-linear
O(n²)
quadratic
O(n³)
cubic
O(2ⁿ)
exponential
O(n!)
factorial

O(1) — Constant

Số phép tính không phụ thuộc kích thước input.

function getFirst(arr) { return arr[0]; }   // O(1)
function isEven(n)     { return n % 2 === 0; } // O(1)

O(log n) — Logarithmic

Mỗi bước, không gian tìm kiếm giảm một nửa. Cực nhanh ngay cả với n khổng lồ.

// Binary search — đã ví dụ ở trên: O(log n)
// Tree traversal trên balanced BST tìm kiếm: O(log n)

O(n) — Linear

Đi qua input đúng 1 lần. Đây là baseline mà mọi giải pháp tốt nên đạt được.

function findMax(arr) {
  let max = arr[0];
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] > max) max = arr[i];
  }
  return max;
}  // O(n)

O(n log n) — Log-linear

Hầu hết thuật toán sort hiệu quả (Merge Sort, Heap Sort, TimSort) đạt mức này. Đây là giới hạn dưới của comparison-based sort.

O(n²) — Quadratic

Hai vòng for lồng nhau. Phải tránh nếu input có thể lớn.

// Bubble sort, selection sort, brute-force two sum
function hasPairSum(arr, target) {
  for (let i = 0; i < arr.length; i++) {
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[i] + arr[j] === target) return true;
    }
  }
  return false;
}  // O(n²)

// Cải tiến với Hash Set → O(n)
function hasPairSumFast(arr, target) {
  const seen = new Set();
  for (const x of arr) {
    if (seen.has(target - x)) return true;
    seen.add(x);
  }
  return false;
}  // O(n)

O(2ⁿ) — Exponential

Mỗi input mới nhân đôi số trường hợp. Thường xuất hiện trong đệ quy không có memoization.

// Fibonacci đệ quy "ngây thơ" — O(2ⁿ), kinh điển slow
function fib(n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}
// fib(40) đã chậm thấy rõ. fib(60) chạy hàng giờ.

// Cải tiến với memoization → O(n)
function fibMemo(n, memo = {}) {
  if (n <= 1) return n;
  if (memo[n]) return memo[n];
  return memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
}

O(n!) — Factorial

Liệt kê mọi hoán vị. Chỉ chạy được khi n ≤ ~10. Bài kinh điển: Travelling Salesman brute force.

1.5 Big-Θ (Theta) và Big-Ω (Omega) — khi nào cần phân biệt?

Trong giao tiếp hàng ngày, lập trình viên dùng "Big-O" cho mọi thứ. Nhưng học thuật phân biệt 3 ký hiệu:

Big-Ω (Omega)

Cận dưới — best case.

"Thuật toán này ít nhất nhanh bằng g(n)."

Big-Θ (Theta)

Cận chặt — vừa trên vừa dưới.

"Thuật toán này chính xác bậc g(n)."

Big-O

Cận trên — worst case.

"Thuật toán này không chậm hơn g(n)."

Ví dụ: thuật toán Linear Search:

  • Ω(1) — best case: phần tử ở vị trí đầu tiên.
  • O(n) — worst case: phần tử ở cuối hoặc không có.
  • Không có Θ chặt vì best/worst khác nhau.

Ví dụ: thuật toán tính tổng mảng:

  • Ω(n), O(n), Θ(n) — luôn duyệt hết mảng dù sao đi nữa.
💡 Trong phỏng vấn
Khi người ta hỏi "Big-O là gì?" → trả lời worst case trừ khi họ hỏi rõ best/average. Đa số trường hợp dùng "Big-O" là đủ, đừng bị cuốn vào tranh luận Θ vs Ω trừ khi nhà tuyển dụng muốn.

1.6 Space Complexity — không chỉ thời gian, còn bộ nhớ

Big-O không chỉ đo thời gian, còn đo bộ nhớ mà thuật toán chiếm. Trong phỏng vấn, hỏi "Time complexity?" thường đi kèm "Space complexity?".

Cách tính space

Space = bộ nhớ thêm mà thuật toán cần (không tính input).

// Time: O(n), Space: O(1)
// Chỉ dùng vài biến cố định, không phụ thuộc n
function sum(arr) {
  let total = 0;
  for (let i = 0; i < arr.length; i++) total += arr[i];
  return total;
}

// Time: O(n), Space: O(n)
// Tạo mảng mới có kích thước n
function double(arr) {
  const result = [];
  for (let i = 0; i < arr.length; i++) result.push(arr[i] * 2);
  return result;
}

Stack space từ đệ quy

Mỗi lời gọi đệ quy chiếm 1 frame trên call stack. Đệ quy sâu n lần → space O(n) ngay cả khi không tạo array nào.

// Time: O(n), Space: O(n) — vì call stack sâu n
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

// Time: O(n), Space: O(1) — phiên bản iterative không tốn stack
function factorialIter(n) {
  let result = 1;
  for (let i = 2; i <= n; i++) result *= i;
  return result;
}
⚠️ Đệ quy sâu = stack overflow
JS giới hạn ~10,000-20,000 lần đệ quy (tuỳ engine). Đệ quy không có tail-call optimization sẽ RangeError: Maximum call stack size exceeded. Khi gặp input lớn, chuyển sang iterative.

1.7 Amortized Analysis — "trung bình" thông minh

Đôi khi 1 thao tác đôi lúc chậm, đôi lúc cực nhanh, nhưng trung bình thì nhanh. Đây là amortized analysis (phân tích biên hoá).

Ví dụ kinh điển: Dynamic Array (JS Array)

Khi bạn arr.push(x), hầu hết thời gian là O(1). Nhưng khi mảng đầy, JS engine phải: (1) cấp phát mảng mới gấp đôi kích thước, (2) copy hết phần tử cũ sang. Lúc đó push tốn O(n).

Vậy push() có phải O(n) không?

📐 Phân tích amortized

Giả sử mảng có capacity 8, bạn push 8 lần đầu — mỗi lần O(1). Lần thứ 9 → resize lên 16, copy 8 phần tử cũ → O(8). Lần 10-16: O(1) mỗi lần. Lần 17 → resize lên 32, copy 16 → O(16)...

Tổng cost của n lần push: n + (1 + 2 + 4 + 8 + ... + n/2) ≈ 3n

Trung bình mỗi push: 3n / n = O(1) amortized.

Vì thế, trong tài liệu của V8 và mọi sách DSA, push được ghi là O(1) amortized.

Ví dụ khác: Hash Table rehashing

Khi load factor vượt ngưỡng (vd 0.75), hash table phải resize và rehash mọi key — O(n). Nhưng amortized cost của insert vẫn là O(1).

1.8 Big-O của các thao tác JavaScript phổ biến

Bảng này bạn nên thuộc lòng. Phỏng vấn rất hay hỏi "Time complexity của arr.includes() là gì?".

Array

Thao tácBig-OGhi chú
arr[i] (access)O(1)Index trực tiếp
arr.lengthO(1)Cached property
arr.push(x)O(1) amortizedResize đôi khi xảy ra
arr.pop()O(1)Bỏ phần tử cuối
arr.shift()O(n)Phải dịch hết phần tử về trước!
arr.unshift(x)O(n)Phải dịch hết phần tử ra sau
arr.splice(i, k)O(n)Cắt + dịch
arr.slice(i, j)O(j-i)Tạo mảng mới
arr.includes(x)O(n)Tuyến tính
arr.indexOf(x)O(n)Tuyến tính
arr.find(fn)O(n)Tuyến tính
arr.sort()O(n log n)TimSort trong V8
arr.reverse()O(n)In-place swap
arr.concat(arr2)O(n+m)Tạo mảng mới
arr.map / filter / forEachO(n)Duyệt 1 lần

Map / Set

Thao tácBig-O
map.get(k) / map.set(k, v) / map.has(k) / map.delete(k)O(1) trung bình
set.add(x) / set.has(x) / set.delete(x)O(1) trung bình
map.size / set.sizeO(1)
Duyệt for...of Map/SetO(n)

String

Thao tácBig-O
str[i]O(1)
str.lengthO(1)
str.includes(s)O(n·m)
str.indexOf(s)O(n·m)
str + str2 (concat)O(n+m)
str.split / sliceO(n)
🚨 Bẫy phỏng vấn JS

arr.shift()arr.unshift() là O(n) — đừng dùng để cài queue! Nếu cần queue, dùng linked list hoặc dùng index thủ công, hoặc thư viện denque.

Code while (queue.length) { queue.shift(); ... } trông sạch nhưng chạy O(n²) cho n phần tử!

Demo trực quan — Linear Search vs Binary Search

Tìm số 17 trong mảng đã sort:

Linear search — duyệt từng ô (worst case 8 bước)

3
7
9
11
14
15
16
17

Binary search — chia đôi (chỉ 3 bước!)

3
7
9
11
14
15
16
17

Bước 1: kiểm tra giữa = 14 → nhỏ hơn 17 → loại nửa trái.

3
7
9
11
14
15
16
17

Bước 2: kiểm tra giữa nửa phải = 16 → nhỏ hơn 17 → loại tiếp.

3
7
9
11
14
15
16
17

Bước 3: kiểm tra phần tử cuối = 17 → tìm thấy! Tổng 3 bước (log₂ 8 = 3).

💡 Thấy sức mạnh O(log n)
Với n = 1 tỷ, linear search worst case 1 tỷ bước. Binary search chỉ ~30 bước. Đó là 33 triệu lần nhanh hơn!

Bài tập

Hãy tự tính Big-O trước khi xem đáp án (đáp án ở phần Quiz bên dưới có lời giải).

Bài 1 — Hai vòng độc lập
function f(n) {
  for (let i = 0; i < n; i++) console.log(i);
  for (let j = 0; j < n * n; j++) console.log(j);
}

Big-O time? Space?

Bài 2 — Vòng lặp tăng theo cấp số nhân
function f(n) {
  let i = 1;
  while (i < n) {
    console.log(i);
    i *= 2;
  }
}

Big-O time?

Bài 3 — Hai mảng khác nhau
function f(arr1, arr2) {
  for (const a of arr1) {
    for (const b of arr2) {
      if (a === b) return true;
    }
  }
  return false;
}

Big-O time? Có cải tiến được không?

Bài 4 — Recursion
function f(n) {
  if (n <= 0) return;
  console.log(n);
  f(n - 1);
  f(n - 1);
}

Big-O time? (gợi ý: vẽ recursion tree)

Bài 5 — Lùa qua array với bước nhân
function f(arr) {
  for (let i = 0; i < arr.length; i++) {
    for (let j = i; j < arr.length; j *= 2) {
      console.log(arr[i], arr[j]);
    }
  }
}

Big-O time? (khó! gợi ý: vòng trong là O(log n))

🧪 Quiz cuối chương — kiểm tra hiểu biết

Click vào đáp án bạn chọn. Click để xem giải thích.

Câu 1. Big-O của arr.unshift(x) trong JS là?

  • O(1)
  • O(log n)
  • O(n)
  • O(n²)

Đáp án: O(n). unshift thêm phần tử vào đầu mảng, phải dịch hết phần tử cũ sang phải 1 vị trí → tốn n thao tác. Đây là cái bẫy phỏng vấn nổi tiếng.

Câu 2. Đoạn code này có Big-O time là?

for (let i = 0; i < n; i++) {
  for (let j = 0; j < m; j++) {
    console.log(i, j);
  }
}
  • O(n²)
  • O(n × m)
  • O(n + m)
  • O(max(n, m))

Đáp án: O(n × m). Hai vòng lồng nhau chạy trên hai biến độc lập n và m. Đáp án "n²" sai vì n và m không nhất thiết bằng nhau.

Câu 3. Big-O của hàm này?

function f(n) {
  let i = 1;
  while (i < n) {
    console.log(i);
    i *= 3;
  }
}
  • O(1)
  • O(n)
  • O(log n)
  • O(n log n)

Đáp án: O(log n). Mỗi vòng lặp nhân i với 3 — tức là cần log₃(n) lần để i vượt n. Trong Big-O, log₃ và log₂ đều là log n vì khác nhau hằng số.

Câu 4. Hàm này có Time và Space complexity là?

function fact(n) {
  if (n <= 1) return 1;
  return n * fact(n - 1);
}
  • Time O(n), Space O(n)
  • Time O(n), Space O(1)
  • Time O(log n), Space O(log n)
  • Time O(n²), Space O(n)

Đáp án: Time O(n), Space O(n). Đệ quy n lần → time O(n). Mỗi lời gọi tốn 1 frame trên call stack → space O(n). Nếu viết iterative thì space mới là O(1).

Câu 5. Tại sao arr.push(x) được coi là O(1) dù đôi khi phải resize?

  • Vì JS engine ưu tiên push hơn các thao tác khác
  • Vì thực ra push là O(log n), không phải O(1)
  • Vì amortized analysis: trung bình mỗi push vẫn O(1) khi nhìn dài hạn
  • Vì máy tính hiện đại resize rất nhanh

Đáp án: Amortized analysis. Tuy có những lần push tốn O(n) khi resize, nhưng cost được "biên hoá" đều ra n lần push → trung bình vẫn O(1) per operation.

Câu 6. Big-O time của hàm này (Bài 4 ở trên)?

function f(n) {
  if (n <= 0) return;
  console.log(n);
  f(n - 1);
  f(n - 1);
}
  • O(n)
  • O(2ⁿ)
  • O(n²)
  • O(n log n)

Đáp án: O(2ⁿ). Mỗi lần gọi sinh ra 2 lời gọi con (với n-1). Recursion tree có 2 + 4 + 8 + ... + 2ⁿ ≈ 2ⁿ⁺¹ node → O(2ⁿ).

Câu 7. Sau bao nhiêu bước thì binary search tìm được phần tử trong mảng 1 triệu phần tử (worst case)?

  • ~1,000,000 bước
  • ~1,000 bước
  • ~20 bước
  • 1 bước

Đáp án: ~20 bước. log₂(1,000,000) ≈ 19.93 → 20 bước. Đó chính là sức mạnh của O(log n).

Câu 8. Đoạn nào có Big-O chậm hơn?

  • O(n + 1000) với n = 1,000,000
  • O(0.001 · n²) với n = 1,000,000
  • Cả hai bằng nhau
  • Không xác định được

Đáp án: O(0.001 · n²). Big-O bỏ hằng số nên O(n + 1000) = O(n) và O(0.001·n²) = O(n²). Khi n đủ lớn, n² >> n bất kể hằng số nhỏ thế nào. Với n = 10⁶: thứ nhất ~10⁶ phép, thứ hai ~10⁹ phép.

Tổng kết chương 1

  • ✅ Big-O đo tốc độ tăng, không phải thời gian tuyệt đối
  • ✅ Quy tắc cơ bản: bỏ hằng số, giữ bậc cao nhất
  • ✅ Loop lồng nhau → nhân; loop tuần tự → cộng; chia đôi → log
  • ✅ Phân biệt time complexity và space complexity (call stack tốn space!)
  • ✅ Amortized analysis giải thích vì sao push = O(1) dù đôi khi resize
  • ✅ Thuộc lòng Big-O của JS Array/Map/Set built-in — phỏng vấn rất hay hỏi
📚 Đề xuất luyện thêm
Trước khi qua chương 2, bạn nên:
  1. Tự code lại các đoạn ở mục 1.4 — không nhìn bài, gõ tay.
  2. Mở 5 đoạn code random từ project cũ của bạn → tự tính Big-O của nó.
  3. Quay lại làm 8 câu quiz đến khi đúng 100%.
← Quay lại Trang chủ DSA Chương kế tiếp Chương 02: Array & String →