CHUYÊN ĐỀ COMPETITIVE PROGRAMMING — TUẦN 1

Python & Phân tích Big-O

Nền tảng ngôn ngữ và tư duy phân tích độ phức tạp — hai công cụ tiên quyết cho mọi bài toán LeetCode & Codility.

14 tuần · 100–120 bài tập · Mock interview cuối khoá

Phần 1. Mở đầu khoá học

Mục tiêu, triết lý học tập, và cách thiết lập môi trường
1.1

1.1 Mục tiêu khoá học

Sau 14 tuần, sinh viên sẽ có khả năng:

Quan điểm cốt lõi:
Khoá học này không dạy thuộc lòng 200 bài. Chúng ta học cách suy nghĩ để giải bài chưa từng gặp.
1.2

1.2 Triết lý: Pattern trước Problem

Đa số người mới mắc lỗi là cày bài lẻ tẻ — giải xong bài này, gặp bài tương tự vẫn không nhận ra. Khoá học áp dụng ba nguyên tắc khoa học học tập:

  1. Pattern trước Problem. Mỗi tuần học 1–2 pattern, mỗi pattern luyện 4–6 bài đại diện. Bài thứ 7 trở đi nên là bài mới hoàn toàn để kiểm tra khả năng nhận pattern.
  2. Phân tích trước khi code. Bắt buộc viết complexity dự kiến và pseudo-code trước khi gõ dòng đầu tiên. Đây là kỹ năng phỏng vấn thực sự được chấm điểm.
  3. Spaced retrieval. Mỗi bài làm xong phải code lại từ đầu sau 1 tuần và sau 3 tuần. Đây là cách duy nhất để pattern chìm vào trí nhớ dài hạn.
1.3

1.3 Thiết lập môi trường thực hành

Tài khoản online (bắt buộc)

Môi trường code cục bộ

Yêu cầu nộp bài:
Mỗi bài có 3 file: solution.py, tests.py (3 test tự nghĩ), và analysis.md (1 đoạn ngắn về complexity và lý do chọn approach).

Phần 2. Python cho Competitive Programming

Bộ công cụ tối thiểu cần thông thạo trước khi vào pattern
2.1

2.1 Vì sao chọn Python?

Khoá học mặc định dùng Python. Bốn lý do:

  1. Ít boilerplate. Một bài có thể giải trong 15–25 dòng thay vì 40–60 dòng Java/C++. Tiết kiệm thời gian phỏng vấn.
  2. Cấu trúc dữ liệu mạnh sẵn có. dict, set, list, tuple đều hiệu năng tốt và cú pháp gọn.
  3. Hỗ trợ tốt trên LeetCode/Codility. Hầu hết bài đều chấp nhận Python với time limit hào phóng hơn (thường ×3 so với C++).
  4. Phổ biến trong ngành. ML, data, backend đều dùng Python. Kỹ năng chuyển trực tiếp sang công việc.
Khi nào không dùng Python?
Một số bài Codility chấm chặt chẽ time limit cho input lớn (n ≥ 10⁷). Trong trường hợp đó, C++ hoặc Java là lựa chọn tốt hơn. Khoá học sẽ điểm qua cú pháp Java tương đương ở Tuần 14.
2.2

2.2 Bốn cấu trúc dữ liệu nội tại

Cấu trúc Mô tả Truy cập Thay đổi được?
list Mảng động, có thứ tự O(1) theo index
tuple Mảng cố định, có thứ tự O(1) theo index Không
dict Bảng băm key → value O(1) trung bình theo key
set Tập hợp không trùng O(1) trung bình kiểm tra thuộc
Lưu ý kỹ thuật:
dictset đều cài đặt bằng hash table. Trong worst case (hash collision dày đặc), thao tác có thể là O(n) — nhưng thực tế cực hiếm xảy ra ngoài bài bịa nhằm phá hash.
2.3

2.3 List — Operations và độ phức tạp

Thao tácCú phápComplexity
Truy cập indexa[i]O(1)
Thêm vào cuốia.append(x)O(1) amortised
Thêm vào đầua.insert(0, x)O(n)
Xoá ở cuốia.pop()O(1)
Xoá ở đầua.pop(0)O(n)
Tìm phần tửx in aO(n)
Sorta.sort()O(n log n)
Slicinga[i:j]O(j − i)
Bẫy phổ biến:
Sinh viên hay dùng list.insert(0, x) hoặc list.pop(0) trong vòng lặp, biến O(n) thành O(n²). Khi cần thêm/xoá ở đầu, dùng collections.deque (O(1) cả hai đầu).
2.4

2.4 Dict & Set — Hashing trong thực tế

# Đếm tần suất ký tự — O(n)
freq = {}
for ch in s:
    freq[ch] = freq.get(ch, 0) + 1

# Cách viết Pythonic hơn
from collections import Counter
freq = Counter(s)

# Set: kiểm tra trùng — O(1)
seen = set()
for x in nums:
    if x in seen:        # O(1) trung bình
        return True
    seen.add(x)
return False
Quan sát:
Đoạn seen trên là pattern "hash để khử O(n²)". Đây là pattern xuất hiện ở khoảng 25% số bài Easy/Medium đầu tiên trên LeetCode. Nhận diện được pattern này là thắng nửa trận.
2.5

2.5 String — Bất biến (immutable) và slicing

String trong Python là immutable. Mỗi thao tác ghép tạo ra string mới:

# BẪY: O(n²) vì mỗi += copy toàn bộ string cũ
result = ""
for word in words:
    result += word

# ĐÚNG: O(n) tổng
result = "".join(words)

Slicing và đảo chuỗi

s = "leetcode"
s[0:4]      # "leet"
s[-3:]      # "ode"  (3 ký tự cuối)
s[::-1]     # "edocteel" (đảo ngược)
s[::2]      # "lecd" (mỗi 2 ký tự)
Quy tắc:
Khi cần build string từ nhiều mảnh, luôn dùng join hoặc list tạm rồi "".join(list). Đừng += trong vòng lặp.
2.6

2.6 Idioms quan trọng

List comprehension

squares = [x*x for x in range(10)]
evens   = [x for x in nums if x % 2 == 0]
matrix  = [[0]*n for _ in range(m)]   # ma trận m×n

# BẪY: KHÔNG dùng [[0]*n] * m — tất cả hàng là cùng 1 reference

Unpacking và enumerate / zip

a, b = b, a                          # swap không cần biến tạm
for i, x in enumerate(nums):         # truy cập index + giá trị
    ...
for k, v in d.items():               # duyệt dict
    ...
for x, y in zip(arr1, arr2):         # ghép cặp 2 mảng
    ...

Multi-assignment trong logic

prev, curr = curr, prev + curr       # Fibonacci 1 dòng
2.7

2.7 Built-in functions thiết yếu

HàmTác dụngVí dụ
sortedSort không inplace, trả về list mớisorted(nums, reverse=True)
min/maxMin/max của iterablemax(nums, key=abs)
sumTổng iterablesum(nums)
any/allCó ít nhất 1 / tất cả Trueany(x < 0 for x in nums)
enumerateCặp (index, value)for i,x in enumerate(a)
zipGhép cặp nhiều iterablezip(a, b)
map/filterÁp dụng / lọclist(map(int, line.split()))
reversedĐảo iterablefor x in reversed(a)
Ý nhị:
Tham số key= trong sorted/min/max cực mạnh. sorted(words, key=len) sort theo độ dài; sorted(points, key=lambda p: p[0]**2 + p[1]**2) sort theo khoảng cách từ gốc toạ độ.
2.8

2.8 Module collections — ba bảo bối

defaultdict

from collections import defaultdict
graph = defaultdict(list)            # giá trị mặc định: list rỗng
graph[u].append(v)                   # không cần check key tồn tại

Counter

from collections import Counter
c = Counter("leetcode")              # {'e':3, 'l':1, 't':1, 'c':1, 'o':1, 'd':1}
c.most_common(2)                     # [('e', 3), ('l', 1)]

deque (double-ended queue)

from collections import deque
q = deque([1, 2, 3])
q.appendleft(0)                      # O(1)
q.popleft()                          # O(1) — ngược với list.pop(0) là O(n)
Khi nào dùng deque?
Bất cứ khi nào cần queue (BFS), sliding window, hoặc thao tác hai đầu. Đây là vũ khí chuẩn cho Tuần 4 (Sliding Window) và Tuần 10 (BFS).
2.Q

Knowledge Check — Phần 2

Q1: Đoạn code nums = [1,2,3]; nums.insert(0, 0) có complexity là gì?
Insert ở đầu list buộc Python phải dịch tất cả phần tử hiện có sang phải 1 vị trí, do đó O(n). Để thêm ở đầu O(1), dùng deque.appendleft.
Q2: Cách nào ghép một mảng string words thành một string với complexity tốt nhất?
Vì string immutable, += trong vòng lặp tạo ra string mới mỗi lần, dẫn tới O(n²). join tính tổng độ dài trước rồi cấp phát một lần, đạt O(n).

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

Ngôn ngữ chung của mọi thuật toán
3.1

3.1 Vì sao quan tâm complexity?

Một bài LeetCode điển hình có time limit ~1 giây. Máy chạy được khoảng 10⁸ thao tác đơn giản/giây. Đối chiếu với input size:

n tối đaComplexity yêu cầuVí dụ thuật toán
≤ 10O(n!) hoặc O(2ⁿ)Permutation, brute backtrack
≤ 20O(2ⁿ)Subset enumeration
≤ 500O(n³)Floyd-Warshall, DP 3 chiều nhẹ
≤ 5,000O(n²)DP 2 chiều
≤ 10⁶O(n log n)Sort, segment tree
≤ 10⁸O(n) hoặc O(n log log n)Two pointers, sliding window
Quy tắc đầu tiên cần thuộc:
Đọc constraint trước khi bắt đầu nghĩ thuật toán. Constraint cho biết bậc complexity tối đa được phép.
3.2

3.2 Định nghĩa Big-O

Định nghĩa hình thức:
Hàm \(f(n)\) là \(O(g(n))\) nếu tồn tại các hằng số \(c > 0\) và \(n_0 \geq 0\) sao cho: $$ f(n) \leq c \cdot g(n) \quad \text{với mọi } n \geq n_0 $$ Tức là kể từ một ngưỡng \(n_0\) trở đi, \(g(n)\) là cận trên của \(f(n)\) (sai số hằng số bỏ qua).

Ý nghĩa thực tế

Big-O đo tốc độ tăng, không đo thời gian thực. Hai thuật toán cùng O(n) có thể khác nhau 10 lần về thời gian thực — nhưng cùng tăng tuyến tính theo n.

Quy tắc đơn giản hoá

3.3

3.3 Các bậc complexity phổ biến

n (input size) ops O(1) O(log n) O(n) O(n log n) O(n²) O(2ⁿ)
Hình 3.1: So sánh tốc độ tăng của các bậc complexity phổ biến.

Khoảng cách giữa O(n) và O(n²) khi n = 10⁵ là 100,000 lần. Đó là khác biệt giữa "chạy 1 giây" và "chạy 1 ngày".

3.4

3.4 Đọc complexity từ code

Quy trình 3 bước:

  1. Xác định vòng lặp ngoài cùng chạy bao nhiêu lần.
  2. Xác định công việc bên trong mỗi lần lặp tốn bao nhiêu.
  3. Nhân lại, đơn giản hoá theo quy tắc Big-O.
# Ví dụ
def has_pair(nums, target):
    for i in range(len(nums)):           # vòng ngoài: n lần
        for j in range(i+1, len(nums)):  # vòng trong: trung bình n/2 lần
            if nums[i] + nums[j] == target:
                return True
    return False
# Tổng: n × n/2 = n²/2  →  O(n²)
Lưu ý ẩn:
Một số thao tác trông như O(1) nhưng thực ra không. Ví dụ x in lst nếu lst là list thì O(n); nếu là set thì O(1). Luôn check kiểu dữ liệu.
3.5

3.5 Worked Example: Linear scan

def max_value(nums):
    m = float('-inf')
    for x in nums:        # n lần
        if x > m:         # O(1)
            m = x         # O(1)
    return m

Phân tích:

Space: chỉ dùng biến m — O(1).

Mẫu tổng quát:
Một vòng for chạy qua mảng + công việc O(1) mỗi bước = O(n) thời gian, O(1) bộ nhớ. Đây là cấu trúc ~30% bài Easy.
3.6

3.6 Worked Example: Vòng lặp lồng nhau

def count_inversions(nums):
    count = 0
    for i in range(len(nums)):           # n lần
        for j in range(i+1, len(nums)):  # (n-1) + (n-2) + ... + 0
            if nums[i] > nums[j]:
                count += 1
    return count

Phân tích:

Tổng số lần thao tác bên trong:

$$ \sum_{i=0}^{n-1} (n - 1 - i) = \frac{n(n-1)}{2} \approx \frac{n^2}{2} $$

Bỏ hằng số ½ → O(n²). Space: O(1).

Bài này về sau sẽ làm O(n log n):
Bằng merge sort cải tiến (Tuần 8). Đây là ví dụ điển hình của việc tối ưu thuật toán đổi lấy complexity tốt hơn — kỹ năng cốt lõi của khoá.
3.7

3.7 Worked Example: Đệ quy

def fib(n):
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

Phân tích: Mỗi lần gọi fib(n) sinh ra 2 lời gọi nhỏ hơn. Cây gọi đệ quy có chiều cao ~n và phân nhánh 2 → tổng số nút ~\(2^n\).

$$ T(n) = T(n-1) + T(n-2) + O(1) \implies T(n) = O(2^n) $$

Space: O(n) cho call stack tại bất kỳ thời điểm nào.

Bài học:
Đệ quy naive như trên là cấm kỵ. Tuần 12 (DP) sẽ học memoisation để biến O(2ⁿ) → O(n). Nhận ra "có lời gọi lặp lại" là dấu hiệu cần memoisation.
3.8

3.8 Space Complexity

Phỏng vấn ngày càng quan tâm bộ nhớ, không chỉ thời gian. Hai loại space cần đếm:

1. Auxiliary space (bộ nhớ phụ trợ)

Bộ nhớ thuật toán dùng thêm ngoài input. Đây là cái phỏng vấn thường hỏi.

2. Total space (tổng bộ nhớ)

Bao gồm cả input. Ít quan tâm hơn vì input là "miễn phí".

# Auxiliary O(n)
def double_array(nums):
    return [x*2 for x in nums]   # tạo list mới size n

# Auxiliary O(1)
def double_inplace(nums):
    for i in range(len(nums)):   # sửa trực tiếp
        nums[i] *= 2
Đừng quên call stack:
Đệ quy có space ẩn = chiều sâu stack. fib(n) đệ quy có space O(n) dù không tạo mảng nào.
3.9

3.9 Đọc constraints để đoán complexity yêu cầu

Đây là kỹ năng quan trọng nhất của tuần này. Mở một bài LeetCode bất kỳ, mục "Constraints" cho biết:

Constraints:
  1 <= nums.length <= 10^5
  -10^9 <= nums[i] <= 10^9

Khi thấy n ≤ 10⁵, lập tức biết:

Khi thấy n ≤ 10⁹, biết:

Quy trình mở bài:
(1) Đọc đề · (2) Đọc constraints · (3) Tính bậc complexity cho phép · (4) Mới nghĩ thuật toán.
3.Q

Knowledge Check — Phần 3

Q1: Thuật toán O(n²) chạy được tối đa input size khoảng bao nhiêu trong giới hạn 1 giây?
Với 10⁸ thao tác/giây, O(n²) yêu cầu n² ≤ 10⁸, suy ra n ≤ 10⁴ (an toàn). Một số bài có thể nhồi tới n = 5×10⁴ nhưng nguy cơ TLE.
Q2: Hàm fib(n) đệ quy naive (không memo) có space complexity bao nhiêu?
Dù không cấp phát mảng, đệ quy có chiều sâu stack tối đa n (đường fib(n) → fib(n-1) → ... → fib(0)). Time là O(2ⁿ) nhưng space chỉ O(n).
Q3: Bài có n ≤ 20 gợi ý thuật toán bậc nào?
n nhỏ bất thường (≤ 20) thường là dấu hiệu thuật toán bậc mũ trên tập con (2²⁰ ≈ 10⁶). Đây là pattern quen thuộc cho subset sum, TSP nhỏ, bitmask DP.

Phần 4. Bài tập thực hành

Áp dụng những gì đã học vào ba bài LeetCode Easy kinh điển
4.1

4.1 Bài 1: Two Sum (LeetCode #1)

Đề: Cho mảng nums và số target. Tìm hai chỉ số i, j sao cho nums[i] + nums[j] == target. Giả sử có đúng một đáp án.

Constraints: 2 ≤ n ≤ 10⁴.

Approach 1 — Brute Force

def twoSum(nums, target):
    for i in range(len(nums)):
        for j in range(i+1, len(nums)):
            if nums[i] + nums[j] == target:
                return [i, j]

Time O(n²), space O(1). Với n = 10⁴ thì ~5×10⁷ thao tác — chạy được nhưng rất sát giới hạn.

Câu hỏi:
Có thể giảm xuống O(n) không? Dấu hiệu nào trong đề gợi ý điều đó?
4.2

4.2 Two Sum — Tối ưu với Hash Map

Quan sát: với mỗi nums[i], ta cần tìm complement = target - nums[i] trong phần còn lại. Tìm trong list là O(n), nhưng tìm trong set/dict là O(1).

def twoSum(nums, target):
    seen = {}                       # value → index
    for i, x in enumerate(nums):
        complement = target - x
        if complement in seen:      # O(1)
            return [seen[complement], i]
        seen[x] = i
    return []

Time O(n), space O(n).

Pattern rút ra:
"Đổi space lấy time" — dùng O(n) bộ nhớ phụ để giảm time từ O(n²) xuống O(n). Đây là pattern sẽ xuất hiện ở Tuần 3 (Hashing) hàng chục lần.
4.3

4.3 Bài 2: Valid Anagram (LeetCode #242)

Đề: Cho hai string st. Trả về True nếu t là một anagram (đảo chữ) của s.

Approach 1 — Sort

def isAnagram(s, t):
    return sorted(s) == sorted(t)
# Time: O(n log n), Space: O(n)

Approach 2 — Counter (tốt hơn)

from collections import Counter
def isAnagram(s, t):
    return Counter(s) == Counter(t)
# Time: O(n), Space: O(k)  với k = số ký tự khác nhau

Approach 3 — Mảng đếm thủ công (chỉ với chữ thường a-z)

def isAnagram(s, t):
    if len(s) != len(t): return False
    cnt = [0] * 26
    for ch in s: cnt[ord(ch) - ord('a')] += 1
    for ch in t: cnt[ord(ch) - ord('a')] -= 1
    return all(c == 0 for c in cnt)
# Time: O(n), Space: O(1)  vì 26 là hằng số
4.4

4.4 Bài 3: Best Time to Buy and Sell Stock (LeetCode #121)

Đề: Mảng prices[i] = giá cổ phiếu ngày i. Mua 1 ngày, bán 1 ngày sau đó. Tính lợi nhuận tối đa (≥ 0).

Constraints: 1 ≤ n ≤ 10⁵ → cần O(n).

Brute force — TLE

# O(n²) — sẽ vượt time limit
for i in range(n):
    for j in range(i+1, n):
        profit = max(profit, prices[j] - prices[i])

Tối ưu — One pass O(n)

def maxProfit(prices):
    min_so_far = float('inf')
    best = 0
    for p in prices:
        min_so_far = min(min_so_far, p)
        best = max(best, p - min_so_far)
    return best
Quan sát:
Tại mỗi vị trí, ta chỉ cần biết min từ đầu đến trước đó. Không cần lưu cả mảng. Đây là mầm mống của Dynamic Programming (Tuần 12).
5.0

Tổng kết Tuần 1

Đã học

Bài về nhà (8 bài)

  1. LeetCode #1 — Two Sum (đã chữa, code lại không nhìn lời giải)
  2. LeetCode #242 — Valid Anagram (cả 3 approach)
  3. LeetCode #121 — Best Time to Buy and Sell Stock
  4. LeetCode #217 — Contains Duplicate
  5. LeetCode #136 — Single Number
  6. LeetCode #169 — Majority Element
  7. LeetCode #283 — Move Zeroes
  8. LeetCode #14 — Longest Common Prefix

Mỗi bài: nộp solution.py + tests.py (3 test tự nghĩ) + analysis.md (complexity và lý do chọn approach).

Tuần 2 sẽ học gì?

Array & String — Two Pointers và Prefix Sum

Hai pattern cốt lõi giải quyết ~30% bài Easy/Medium đầu tiên trên LeetCode. Sẽ áp dụng trực tiếp Big-O đã học để chứng minh tại sao two pointers giảm O(n²) xuống O(n).

Hết Tuần 1 — Cảm ơn các em.

Mục lục

Nhấn T hoặc Esc để đóng