Brute Force, Greedy & Thuật toán ngẫu nhiênBrute Force, Greedy & Randomised Algorithms
Mục lục
Table of contents
Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.
Tổng quan
Mọi thứ từ đầu roadmap tới đây đều xoay quanh cấu trúc — cách bố trí dữ liệu sao cho một thao tác cụ thể chạy nhanh. Chủ đề này là chủ đề đầu tiên về kỹ thuật: các chiến lược tổng quát để tấn công một bài toán bạn chưa từng gặp. Roadmap gom chúng dưới nhánh “Problem Solving Techniques”, và điều hữu ích của cách gom này là nó nhỏ gọn. Có khoảng sáu chiến lược, chúng kết hợp được với nhau, và gần như mọi thuật toán bạn sẽ gặp đều là một trong số đó hoặc một biến thể lai.
Ba kỹ thuật trong bài này nằm ở hai đầu của một sự đánh đổi giữa đảm bảo tính đúng đắn và chi phí.
Brute force liệt kê mọi ứng viên lời giải rồi kiểm tra từng cái. Nó luôn đúng, không đòi hỏi sự thông minh nào, và rất thường là quá chậm — nhưng “rất thường” không phải “luôn luôn”, và biết khi nào brute force thực sự là đáp án đúng là một kỹ năng thật. Nó cũng là bản tham chiếu để bạn kiểm thử thuật toán thông minh của mình.
Greedy (tham lam) chọn phương án tối ưu cục bộ ở mỗi bước và không bao giờ xem xét lại. Nó nhanh — thường chỉ là một lần sort cộng một lần quét tuyến tính — nhưng chỉ đúng khi bài toán có một cấu trúc đặc thù. Phần thú vị của thuật toán greedy không phải là viết ra nó (chỉ ba dòng) mà là chứng minh nó đúng, và công cụ chuẩn cho việc đó là exchange argument. Một thuật toán greedy áp dụng vào bài toán không có cấu trúc cần thiết sẽ cho ra đáp án nghe rất hợp lý mà lại sai — điều này còn tệ hơn là chậm một cách hiển nhiên.
Thuật toán ngẫu nhiên (randomised) tung đồng xu. Chúng từ bỏ tính tất định để đổi lấy sự đơn giản, tốc độ, hoặc khả năng miễn nhiễm với input đối kháng. Phân biệt then chốt là giữa thuật toán Las Vegas (luôn đúng, thời gian chạy ngẫu nhiên) và Monte Carlo (thời gian chạy cố định, có xác suất nhỏ trả sai) — vì hai kiểu lỗi này đòi hỏi đánh giá rủi ro hoàn toàn khác nhau trên production.
Kiến thức nền tảng
Danh mục các kỹ thuật giải quyết vấn đề
| Kỹ thuật | Ý tưởng | Chi phí | Khi nào áp dụng | Bài nào bao quát |
|---|---|---|---|---|
| Brute force | Liệt kê mọi ứng viên | Thường là hàm mũ hoặc giai thừa | Luôn áp dụng được; chỉ dùng khi n nhỏ | Bài này |
| Chia để trị | Tách thành subproblem độc lập rồi ghép | Thường Θ(n log n) | Subproblem độc lập nhau | ./19-recursion-and-divide-and-conquer.md |
| Greedy | Chọn tốt nhất cục bộ, không xét lại | Thường Θ(n log n) (một lần sort) | Greedy choice property + optimal substructure | Bài này |
| Quy hoạch động | Giải subproblem chồng lấn một lần rồi tái dùng | Đa thức, thường Θ(n·W) | Overlapping subproblems + optimal substructure | ./22-dynamic-programming.md |
| Backtracking | DFS trên cây quyết định, cắt nhánh chết | Hàm mũ ở worst case, thực tế tốt hơn nhiều | Bài toán ràng buộc (constraint satisfaction) | ./21-backtracking.md |
| Branch and bound | Backtracking cộng một cận để cắt theo giá trị mục tiêu | Hàm mũ ở worst case | Bài toán tối ưu có cận tính được | Bài này (sơ lược) |
| Ngẫu nhiên hóa | Dùng lựa chọn ngẫu nhiên để né trường hợp xấu | Đảm bảo theo kỳ vọng | Input đối kháng, không gian tìm kiếm quá lớn, lấy mẫu | Bài này |
Quy trình quyết định thực sự dùng được:
- Viết brute force trước. Nó đúng theo cấu tạo, làm rõ bài toán thực sự hỏi gì, và trở thành test oracle của bạn.
- Hỏi xem greedy có đúng không. Nếu bạn phát biểu được một lựa chọn greedy và chứng minh bằng exchange argument, bạn xong việc và lời giải rất nhanh.
- Nếu greedy sai, hỏi xem các subproblem có chồng lấn không. Nếu có, dùng quy hoạch động. Nếu không, dùng chia để trị.
- Nếu bài toán là ràng buộc với không gian khổng lồ, dùng backtracking với pruning.
- Nếu worst case là đối kháng hoặc không gian quá lớn để liệt kê, dùng ngẫu nhiên hóa.
Brute force
Thuật toán brute force (exhaustive search — tìm kiếm vét cạn) sinh ra mọi phần tử của không gian ứng viên rồi kiểm tra từng cái với yêu cầu của bài toán. Không có insight nào ở đây, và đó chính là điểm mạnh: nó hoạt động với bất kỳ bài toán nào mà bạn liệt kê được không gian ứng viên.
from itertools import combinations
def subset_sum_brute(nums, target):
"""Tìm subset có tổng bằng target bằng cách thử cả 2^n subset. Theta(2^n · n)."""
for size in range(len(nums) + 1):
for combo in combinations(nums, size):
if sum(combo) == target:
return list(combo)
return None
print(subset_sum_brute([3, 34, 4, 12, 5, 2], 9)) # [4, 5]
itertools.combinations giấu đi phần liệt kê, nên đây là cùng phép tìm kiếm viết bằng bitmask, phơi bày cơ chế — mỗi số nguyên từ 0 tới 2ⁿ − 1 chính là một subset, với bit i nghĩa là “lấy nums[i]”:
def subset_sum_bitmask(nums, target):
"""Cùng phép tìm kiếm, liệt kê tường minh. n phải <= ~25 mới thực tế."""
n = len(nums)
for mask in range(1 << n): # 2^n subset ứng viên
total = 0
for i in range(n):
if mask & (1 << i): # bit i bật -> nums[i] thuộc subset này
total += nums[i]
if total == target:
return [nums[i] for i in range(n) if mask & (1 << i)]
return None
Bùng nổ tổ hợp (combinatorial explosion) là lý do brute force thường thua. Với tốc độ hào phóng 10⁹ phép tính mỗi giây:
| Không gian ứng viên | n = 20 | n = 30 | n = 40 | n = 50 |
|---|---|---|---|---|
2ⁿ (subset) | 1,0 M — tức thì | 1,1 G — ~1 s | 1,1 T — ~18 phút | 1,1 P — ~13 ngày |
n! (hoán vị) | 2,4×10¹⁸ — ~77 năm | 2,7×10³² — vũ trụ tắt | — | — |
n² (mọi cặp) | 400 — tức thì | 900 | 1 600 | 2 500 |
Bước nhảy từ n = 40 sang n = 50 với 2ⁿ — từ 18 phút lên 13 ngày — nói lên toàn bộ câu chuyện. Tăng trưởng hàm mũ nghĩa là mua một máy nhanh gấp 1000 lần chỉ mua thêm cho bạn khoảng 10 phần tử.
Khi brute force thực sự là đáp án đúng:
nnhỏ và bị chặn bởi chính bài toán. Tám quân hậu, một lưới Sudoku, một puzzle 3×3, “tối đa 12 kho hàng.” Nếu input không bao giờ lớn lên được thì tiệm cận không còn ý nghĩa.- Nó là test oracle. Thuật toán sweep-line
O(n log n)của bạn bị lệch một đơn vị; bản brute forceO(n³)thì không. Đối chiếu chúng trên 10 000 input nhỏ ngẫu nhiên — đây là công dụng giá trị nhất của brute force trong kỹ thuật thực tế. - Thuật toán thông minh không tồn tại hoặc không đáng viết. Với một công cụ nội bộ chạy mỗi tuần một lần trên 500 dòng, một vòng lặp đôi
O(n²)mất 0,2 s là kỹ thuật đúng đắn. Tối ưu nó thì không. - Nó là nền để bạn cắt tỉa. Backtracking chính là brute force có pruning; branch and bound chính là brute force có cận. Cả hai đều xuất phát từ phép liệt kê vét cạn rồi cắt nhánh — bạn không thể viết chúng nếu chưa biết không gian tìm kiếm đầy đủ là gì.
Khi đó là ý tưởng tồi: bất cứ khi nào n do người dùng điều khiển và không bị chặn. Một nhánh O(2ⁿ) mà người dùng kích hoạt được bằng input 60 phần tử là một lỗ hổng denial-of-service, không phải vấn đề hiệu năng. Ví dụ kinh điển là catastrophic backtracking trong regular expression — (a+)+b trên chuỗi 30 ký tự a sẽ khám phá số lượng cách chia input theo hàm mũ.
Branch and bound là cải tiến có hệ thống của brute force cho bài toán tối ưu: giữ lời giải tốt nhất tìm được đến hiện tại, tính một cận lạc quan cho những gì bất kỳ cách hoàn thiện nào của lời giải dở dang hiện tại có thể đạt được, rồi bỏ nhánh nếu cận đó không thể vượt lời giải đương nhiệm. Với bài toán người bán hàng (TSP), “chi phí đường đi hiện tại + cận dưới cho các cạnh còn lại ≥ tour tốt nhất đã tìm” cắt bỏ những phần khổng lồ của cây mà vẫn đảm bảo tìm ra tối ưu. Worst case vẫn là giai thừa; trường hợp điển hình thì tốt hơn rất nhiều.
Khái niệm chính
Thuật toán greedy
Thuật toán greedy dựng lời giải theo từng bước, mỗi bước lấy lựa chọn trông tốt nhất ngay lúc đó theo một tiêu chí cục bộ nào đó, và không bao giờ xét lại. Không có backtracking, không có bảng subproblem, đó là lý do greedy vừa là kỹ thuật nhanh nhất vừa là kỹ thuật dễ sai một cách âm thầm nhất.
Hai tính chất phải thỏa để một thuật toán greedy đúng:
- Greedy choice property — có thể đạt tới lời giải tối ưu toàn cục bằng cách chọn tối ưu cục bộ. Nói chính xác: tồn tại một lời giải tối ưu chứa lựa chọn greedy. Đây mới là phần khó, và là thứ mà exchange argument chứng minh.
- Optimal substructure — sau khi cam kết với lựa chọn greedy, phần bài toán còn lại là một thể hiện nhỏ hơn của cùng bài toán, và lời giải tối ưu của toàn bộ chứa lời giải tối ưu của phần còn lại đó. (Dùng chung với quy hoạch động; DP cũng cần tính chất này.)
Điểm khác biệt so với DP đáng phát biểu chính xác: DP xét mọi lựa chọn ở mỗi bước rồi chọn cái tốt nhất sau khi đánh giá hệ quả; greedy cam kết với một lựa chọn mà không đánh giá các phương án khác. Greedy là DP trong trường hợp bạn đã chứng minh được rằng chỉ cần nhìn một nhánh duy nhất.
Exchange argument — cách chứng minh một greedy là đúng
Exchange argument là kỹ thuật chứng minh chuẩn, và nó luôn có cùng một hình dạng:
- Gọi
Glà lời giải greedy vàOlà một lời giải tối ưu bất kỳ. - Tìm vị trí đầu tiên chúng khác nhau.
- Chứng minh bạn có thể đổi lựa chọn của
Otại điểm đó lấy lựa chọn củaGmà không làmOtệ đi (và không phá vỡ tính khả thi). - Lặp lại phép đổi sẽ biến
OthànhGmà không bao giờ mất tính tối ưu, nênGcũng tối ưu.
Áp dụng vào activity selection (chọn số lượng lớn nhất các khoảng thời gian không chồng lấn): luật greedy là luôn lấy hoạt động kết thúc sớm nhất trong số những hoạt động còn tương thích.
- Gọi
Olà một tập tối ưu, vàalà hoạt động trongOcó thời điểm kết thúc sớm nhất. Gọiglà hoạt động kết thúc sớm nhất toàn cục. - Theo định nghĩa
finish(g) ≤ finish(a). - Thay
abằnggtrongO. Mọi hoạt động khác trongOđều bắt đầu tại hoặc saufinish(a) ≥ finish(g), nên không cái nào xung đột vớig. Tập vẫn khả thi và vẫn cùng kích thước. - Vậy tồn tại một lời giải tối ưu chứa lựa chọn greedy. Đệ quy trên các hoạt động còn lại bắt đầu sau
finish(g)— đó là optimal substructure — và quy nạp kết thúc chứng minh.
def activity_selection(intervals):
"""Số lượng interval không chồng lấn lớn nhất. Theta(n log n), chủ yếu do sort."""
# Tiêu chí greedy: thời điểm kết thúc sớm nhất. Sort theo START hay theo DURATION đều sai.
ordered = sorted(intervals, key=lambda iv: iv[1])
chosen, last_end = [], float("-inf")
for start, end in ordered:
if start >= last_end: # tương thích với mọi thứ đã chọn
chosen.append((start, end))
last_end = end
return chosen
meetings = [(1, 4), (3, 5), (0, 6), (5, 7), (3, 9), (5, 9), (6, 10), (8, 11), (12, 16)]
print(activity_selection(meetings))
# [(1, 4), (5, 7), (8, 11), (12, 16)] -> 4 hoạt động
Hãy để ý sai tiêu chí dễ đến mức nào. Sort theo thời điểm bắt đầu sẽ chọn (0, 6) trước và cho ra 3 hoạt động. Sort theo thời lượng ngắn nhất thất bại với [(0, 5), (4, 6), (5, 10)] — nó chọn cái ở giữa và chặn cả hai cái kia. Luật greedy mới là thứ bạn phải chứng minh; code chỉ là chuyện vặt một khi bạn đã có luật.
Những chiến thắng kinh điển của greedy
Fractional knapsack — lấy vật phẩm theo tỉ lệ giá trị/khối lượng, cắt nhỏ cái cuối:
def fractional_knapsack(items, capacity):
"""items: list các (value, weight). Trả về giá trị lớn nhất. Theta(n log n)."""
# Tiêu chí greedy: mật độ giá trị cao nhất trước. Tối ưu ĐƯỢC CHỨNG MINH VÌ vật phẩm chia nhỏ được.
ordered = sorted(items, key=lambda it: it[0] / it[1], reverse=True)
total, remaining = 0.0, capacity
for value, weight in ordered:
if remaining == 0:
break
take = min(weight, remaining) # lấy toàn bộ, hoặc phần vừa với sức chứa còn lại
total += value * (take / weight)
remaining -= take
return total
print(fractional_knapsack([(60, 10), (100, 20), (120, 30)], 50)) # 240.0
Exchange argument: nếu một lời giải tối ưu lấy ít vật phẩm mật độ cao nhất hơn so với greedy, hãy đổi một đơn vị của vật phẩm mật độ thấp hơn lấy một đơn vị của vật phẩm mật độ cao hơn. Cùng khối lượng, giá trị không giảm. Tính chia nhỏ được là thứ khiến phép đổi luôn thực hiện được — và đó cũng chính là thứ mà phiên bản 0/1 loại bỏ.
Huffman coding — dựng mã prefix-free tối ưu bằng cách liên tục trộn hai ký hiệu ít gặp nhất:
import heapq
from collections import Counter
def huffman_codes(text):
"""Mã prefix-free tối ưu. Theta(n log n) với n ký hiệu khác nhau."""
freq = Counter(text)
if len(freq) == 1: # trường hợp biên: một ký hiệu vẫn cần một bit
return {next(iter(freq)): "0"}
# Phần tử heap: (tần suất, khóa phá hòa, {ký hiệu: hậu tố mã hiện tại})
tiebreak = 0
heap = []
for symbol, f in freq.items():
heapq.heappush(heap, (f, tiebreak, {symbol: ""}))
tiebreak += 1
while len(heap) > 1:
# Lựa chọn greedy: hai ký hiệu hiếm nhất nhận mã dài nhất, nên trộn chúng trước
f1, _, left = heapq.heappop(heap)
f2, _, right = heapq.heappop(heap)
merged = {s: "0" + c for s, c in left.items()}
merged.update({s: "1" + c for s, c in right.items()})
heapq.heappush(heap, (f1 + f2, tiebreak, merged))
tiebreak += 1
return heap[0][2]
codes = huffman_codes("abracadabra")
print(sorted(codes.items(), key=lambda kv: (len(kv[1]), kv[0])))
# [('a', '0'), ('b', '110'), ('c', '100'), ('d', '101'), ('r', '111')]
# 'a' (xuất hiện 5 lần) nhận mã 1 bit; 'c' và 'd' (mỗi cái 1 lần) nhận mã 3 bit
Exchange argument ở đây: trong bất kỳ mã prefix tối ưu nào, ta có thể giả định hai ký hiệu ít gặp nhất là anh em ở mức sâu nhất — nếu không phải vậy, hoán đổi chúng với bất cứ thứ gì đang nằm dưới đó cũng không làm tăng độ dài mã có trọng số. Do đó trộn chúng là an toàn. heapq là công cụ đúng đắn trong production; nó tốn O(log n) mỗi lần push/pop và O(n) để heapify — xem ./12-heaps-and-priority-queues.md.
Kruskal MST — sort cạnh theo trọng số, thêm mỗi cạnh không tạo cycle. Đúng nhờ cut property: với mọi cách phân hoạch tập đỉnh, cạnh nhỏ nhất bắc qua nhát cắt đều thuộc một MST nào đó. Xem ./15-minimum-spanning-trees.md và ./16-disjoint-set-union-find.md.
Dijkstra shortest path — liên tục chốt đỉnh chưa thăm có khoảng cách tạm nhỏ nhất. Lựa chọn greedy chỉ an toàn bởi vì trọng số cạnh không âm: một khi một đỉnh là đỉnh gần nhất chưa chốt, không đường đi nào sau này qua một đỉnh xa hơn có thể cải thiện được nó. Thêm một cạnh âm và chứng minh sụp đổ — đó chính xác là lý do Bellman–Ford tồn tại. Xem ./14-shortest-path-algorithms.md.
| Bài toán | Tiêu chí greedy | Độ phức tạp | Có đúng không? |
|---|---|---|---|
| Activity selection | Kết thúc sớm nhất | Θ(n log n) | Có (exchange argument) |
| Fractional knapsack | Value/weight cao nhất | Θ(n log n) | Có (vật phẩm chia nhỏ được) |
| Huffman coding | Trộn hai tần suất thấp nhất | Θ(n log n) | Có (exchange argument) |
| Kruskal MST | Cạnh an toàn rẻ nhất | Θ(E log E) | Có (cut property) |
| Prim MST | Cạnh rẻ nhất rời khỏi tree | Θ(E log V) | Có (cut property) |
| Dijkstra | Đỉnh chưa chốt gần nhất | Θ((V+E) log V) | Có, chỉ khi trọng số ≥ 0 |
| Coin change (hệ tiền canonical) | Đồng lớn nhất ≤ phần dư | Θ(n) | Chỉ đúng với mệnh giá kiểu USD/EUR |
| 0/1 knapsack | Value/weight cao nhất | Θ(n log n) | Không |
| Coin change (mệnh giá tùy ý) | Đồng lớn nhất ≤ phần dư | Θ(n) | Không |
| Travelling salesperson | Thành phố chưa thăm gần nhất | Θ(n²) | Không (có thể tệ tùy ý) |
Khi greedy thất bại — hai phản ví dụ cụ thể
0/1 knapsack. Vẫn những vật phẩm như bản fractional, nhưng giờ mỗi vật phẩm là lấy hết hoặc không lấy:
def knapsack_greedy_WRONG(items, capacity):
"""Greedy theo mật độ trên bài 0/1 knapsack. Nhanh, hợp lý, và sai."""
ordered = sorted(items, key=lambda it: it[0] / it[1], reverse=True)
total, remaining = 0, capacity
for value, weight in ordered:
if weight <= remaining: # không còn lấy được một phần nữa
total += value
remaining -= weight
return total
def knapsack_dp(items, capacity):
"""0/1 knapsack đúng, bằng DP. Theta(n · capacity) time, Theta(capacity) space."""
best = [0] * (capacity + 1)
for value, weight in items:
for c in range(capacity, weight - 1, -1): # duyệt giảm dần: mỗi vật phẩm dùng một lần
best[c] = max(best[c], best[c - weight] + value)
return best[capacity]
items = [(60, 10), (100, 20), (120, 30)] # (value, weight)
print(knapsack_greedy_WRONG(items, 50)) # 160 <- sai
print(knapsack_dp(items, 50)) # 220 <- đúng
print(fractional_knapsack(items, 50)) # 240.0 (fractional là bài toán khác)
Greedy lấy vật phẩm mật độ 6 (10 kg, giá trị 60), rồi vật phẩm mật độ 5 (20 kg, giá trị 100), đạt 160 với 20 kg sức chứa còn lại — và vật phẩm 30 kg không còn vừa nữa. Tối ưu thì bỏ hẳn vật phẩm đầu tiên và lấy hai vật phẩm 20 kg với 30 kg, được 220. Greedy choice property thất bại: không lời giải tối ưu nào chứa lựa chọn greedy đầu tiên. Tính không chia nhỏ được là toàn bộ khác biệt, và cách sửa là quy hoạch động.
Coin change với mệnh giá khó chịu. Greedy “lấy đồng lớn nhất còn vừa” là tối ưu với hệ tiền Mỹ và Euro, đó là lý do nó cho cảm giác luôn đúng. Nó không phải vậy:
def coin_change_greedy(coins, amount):
"""Lấy đồng lớn nhất còn vừa. Chỉ tối ưu với hệ tiền 'canonical'."""
count, remaining = 0, amount
for coin in sorted(coins, reverse=True):
take = remaining // coin
count += take
remaining -= take * coin
return count if remaining == 0 else None
def coin_change_dp(coins, amount):
"""Số đồng tối thiểu đúng với MỌI mệnh giá. Theta(len(coins) · amount)."""
INF = float("inf")
best = [0] + [INF] * amount
for value in range(1, amount + 1):
for coin in coins:
if coin <= value and best[value - coin] + 1 < best[value]:
best[value] = best[value - coin] + 1
return None if best[amount] == INF else best[amount]
print(coin_change_greedy([1, 3, 4], 6)) # 3 -> 4 + 1 + 1
print(coin_change_dp([1, 3, 4], 6)) # 2 -> 3 + 3
print(coin_change_greedy([1, 7, 10], 15)) # 6 -> 10 + 1*5
print(coin_change_dp([1, 7, 10], 15)) # 3 -> 7 + 7 + 1
Với bộ tiền {1, 3, 4} và số tiền 6, greedy lấy 4 rồi hai đồng 1 — ba đồng — trong khi 3 + 3 chỉ là hai. Greedy không chỉ dưới tối ưu, nó dưới tối ưu một cách đầy tự tin, và nó sẽ vượt qua mọi test viết bằng mệnh giá đời thực. Đây là nguy hiểm thực tế của greedy: kiểu lỗi là một đáp án sai trông có vẻ đúng, chứ không phải crash hay timeout. Xác định xem một hệ tiền có “canonical” (greedy tối ưu) hay không tự nó đã là bài toán không tầm thường, và câu trả lời kỹ thuật an toàn là dùng DP trừ khi mệnh giá cố định và bạn đã kiểm chứng greedy với DP trên toàn dải giá trị.
Nearest-neighbour TSP hoàn thiện bức tranh: luôn đi tới thành phố chưa thăm gần nhất sinh ra những tour có thể tệ hơn tối ưu một hệ số Θ(log n), và với input đối kháng thì tệ tùy ý. Nó vẫn hữu ích — làm tour khởi đầu nhanh cho một thuật toán local search — và đó là vai trò trung thực của một greedy thất bại: một heuristic, được dán nhãn rõ ràng là heuristic.
Thuật toán ngẫu nhiên
Thuật toán ngẫu nhiên dùng số ngẫu nhiên như một phần logic của nó, nên cùng một input có thể cho ra các đường thực thi khác nhau ở các lần chạy khác nhau. Lý do chấp nhận tính bất định đó thường là một trong ba:
- Để đánh bại input đối kháng. Thuật toán tất định có một worst case cố định, và kẻ tấn công biết code sẽ dựng được nó. Thuật toán ngẫu nhiên không có input nào xấu một cách đáng tin cậy.
- Để đơn giản hóa. Thuật toán ngẫu nhiên cho các bài như min-cut hay kiểm tra số nguyên tố đơn giản hơn hẳn so với bản tất định tương ứng.
- Để lấy mẫu. Khi không gian quá lớn để liệt kê, lấy mẫu là lựa chọn duy nhất.
Las Vegas và Monte Carlo
| Las Vegas | Monte Carlo | |
|---|---|---|
| Tính đúng đắn | Luôn đúng | Đúng với xác suất 1 − δ |
| Thời gian chạy | Biến ngẫu nhiên | Cố định / bị chặn |
| Kiểu thất bại | Chạy lâu hơn dự kiến | Trả về đáp án sai, âm thầm |
| Lặp lại để cải thiện | Giảm phương sai thời gian | Giảm xác suất lỗi (δ^k sau k lần) |
| Ví dụ | Randomised quicksort, quickselect, treap, skip list | Miller–Rabin, Karger min cut, Bloom filter, count-min sketch |
Phân biệt này quan trọng về mặt vận hành. Một thuật toán Las Vegas thỉnh thoảng chạy lâu gấp 3 lần là vấn đề latency mà bạn đo được và chặn được bằng timeout. Một thuật toán Monte Carlo sai 1 trên 10⁶ lần là vấn đề đúng đắn, sẽ không lộ ra khi test và sẽ lộ ra trên production. Trước khi ship một thuật toán Monte Carlo, hãy tính ra xác suất lỗi thực tế, quyết định xem hậu quả của một đáp án sai có chấp nhận được không, và lặp lại thuật toán đủ nhiều lần để đẩy δ xuống dưới ngưỡng đó. Miller–Rabin với k cơ số độc lập có lỗi ≤ 4^(−k); ở k = 40 con số đó thấp hơn xác suất một bit bị lật do tia vũ trụ, đó là lý do nó được dùng để sinh khóa mật mã thật.
Randomised quicksort — thuật toán Las Vegas mẫu mực
Quicksort tất định với luật pivot cố định (luôn lấy phần tử đầu) là Θ(n²) trên input đã sắp xếp sẵn — dạng dữ liệu thực tế phổ biến nhất. Chọn pivot ngẫu nhiên làm thời gian kỳ vọng trở thành Θ(n log n) với mọi input, vì tính ngẫu nhiên nằm trong thuật toán chứ không nằm trong dữ liệu.
import random
def quicksort_random(arr):
"""Kỳ vọng Theta(n log n) trên MỌI input. Worst case Theta(n^2) nhưng xác suất ~0."""
if len(arr) <= 1:
return arr[:]
pivot = random.choice(arr) # quyết định ngẫu nhiên duy nhất
less = [x for x in arr if x < pivot]
equal = [x for x in arr if x == pivot]
greater = [x for x in arr if x > pivot]
return quicksort_random(less) + equal + quicksort_random(greater)
Vì sao kỳ vọng ra được như vậy: một pivot ngẫu nhiên chia mảng ít nhất theo tỉ lệ 25/75 với xác suất 1/2, và một chuỗi các phép chia 25/75 vẫn cho độ sâu O(log n) (log_{4/3} n). Cộng số phép so sánh kỳ vọng lại cho ra 2n ln n ≈ 1,39 n log₂ n. Worst case vẫn là Θ(n²), nhưng để chạm tới nó thì bộ sinh số ngẫu nhiên phải “âm mưu” — xác suất cỡ 1/n! — chứ không phải do input xui xẻo. Sự dịch chuyển đó là toàn bộ vấn đề: ngẫu nhiên hóa biến worst case trên tập input thành worst case trên tập đồng xu, và bạn kiểm soát đồng xu.
Quickselect — chọn phần tử thứ k trong thời gian tuyến tính kỳ vọng
def quickselect(arr, k):
"""Phần tử nhỏ thứ k, đánh số từ 0. Kỳ vọng Theta(n), worst case Theta(n^2)."""
if len(arr) == 1:
return arr[0]
pivot = random.choice(arr)
less = [x for x in arr if x < pivot]
equal = [x for x in arr if x == pivot]
greater = [x for x in arr if x > pivot]
if k < len(less):
return quickselect(less, k) # chỉ đệ quy vào MỘT bên
if k < len(less) + len(equal):
return pivot
return quickselect(greater, k - len(less) - len(equal))
data = [7, 10, 4, 3, 20, 15]
print(quickselect(data, 2)) # 7 -> phần tử nhỏ thứ 3
Lập luận về độ phức tạp là chuỗi cấp số nhân: công việc kỳ vọng là n + n/2 + n/4 + … = 2n = Θ(n), vì khác với quicksort, nó chỉ đệ quy vào một partition. Nhanh hơn cách hiển nhiên sort rồi lấy index (Θ(n log n)) một hệ số log n. Phương án tất định — median of medians — là Θ(n) ở worst case, nhưng hằng số của nó đủ lớn để randomised quickselect thắng trong thực tế. Xem ./12-heaps-and-priority-queues.md cho cách tiếp cận bằng heap khi bạn cần top k thay vì phần tử thứ k.
Reservoir sampling — lấy mẫu đều từ stream không biết trước độ dài
Bạn đang đọc một stream log và muốn k dòng ngẫu nhiên đều, nhưng không biết có bao nhiêu dòng và không thể giữ hết trong memory. Reservoir sampling giải quyết việc này trong một lượt duyệt với O(k) bộ nhớ:
def reservoir_sample(stream, k):
"""k mẫu đều từ stream độ dài n chưa biết. O(n) time, O(k) space."""
reservoir = []
for i, item in enumerate(stream):
if i < k:
reservoir.append(item) # lấp đầy reservoir trước
else:
j = random.randrange(i + 1) # đều trên [0, i]
if j < k: # giữ lại với xác suất k/(i+1)
reservoir[j] = item # loại một phần tử đương nhiệm chọn đều
return reservoir
print(reservoir_sample(range(1_000_000), 5)) # 5 mẫu đều, một lượt duyệt, O(1) memory
Invariant, chứng minh bằng quy nạp: sau khi xử lý i phần tử, mỗi phần tử nằm trong reservoir với xác suất đúng bằng k/i. Phần tử i+1 vào với xác suất k/(i+1); một phần tử đương nhiệm sống sót với xác suất 1 − k/(i+1) · 1/k = 1 − 1/(i+1) = i/(i+1), và k/i · i/(i+1) = k/(i+1). Invariant được bảo toàn. Đây là thuật toán đứng sau sampling của distributed tracing, lệnh shuf -n trên file khổng lồ, và chia nhóm A/B test trên các stream sự kiện không giới hạn.
Ước lượng Monte Carlo
Nghĩa còn lại của “Monte Carlo” là ước lượng số học bằng lấy mẫu ngẫu nhiên — xấp xỉ một đại lượng bạn không tính chính xác được bằng cách lấy trung bình trên các mẫu ngẫu nhiên.
def estimate_pi(samples):
"""Tỉ lệ điểm ngẫu nhiên trong hình vuông đơn vị rơi vào một phần tư hình tròn
chính là pi/4. Sai số giảm theo O(1/sqrt(samples)) -- gấp 100 lần mẫu, chính xác gấp 10."""
inside = 0
for _ in range(samples):
x, y = random.random(), random.random()
if x * x + y * y <= 1.0:
inside += 1
return 4.0 * inside / samples
print(estimate_pi(1_000_000)) # ~3,1416, khác nhau giữa các lần chạy
Tốc độ hội tụ O(1/√N) là đặc trưng định danh và là lý do tích phân Monte Carlo được dùng cho các bài toán nhiều chiều: tốc độ đó độc lập với số chiều, trong khi tích phân số dựa trên lưới tốn O(N^d). Đó là lý do Monte Carlo thống trị trong tài chính (định giá option), vật lý (vận chuyển hạt), và rendering (path tracing), và cũng là lý do nó là lựa chọn tồi cho một tích phân 1 chiều mà công thức Simpson hội tụ nhanh hơn nhiều.
Ngẫu nhiên hóa như một lá chắn trước worst case đối kháng
Đây là lý do thực tiễn sâu sắc nhất để ngẫu nhiên hóa, và nó xuất hiện liên tục trong hệ thống production:
- Hash flooding. Một hash table với hash function cố định, công khai sẽ suy biến từ
O(1)xuốngO(n)mỗi lần lookup nếu kẻ tấn công gửi các key đều đụng độ nhau — vài nghìn tham số POST được chế tác có thể ghim CPU của web server. Cách sửa là hash được seed ngẫu nhiên (Python dùng SipHash với seed ngẫu nhiên theo tiến trình từ bản 3.3; xemPYTHONHASHSEED), để kẻ tấn công không thể tính trước các key đụng độ. Đây là ngẫu nhiên hóa dùng thuần túy cho bảo mật, không phải cho tốc độ. - Quicksort killer. Introsort trong C++ và heuristic “median of three” tồn tại vì đã có người công bố các input ép
Θ(n²)trong những implementation thư viện cụ thể. Pivot ngẫu nhiên xóa sạch bề mặt tấn công đó. - Load balancing — “sức mạnh của hai lựa chọn.” Gán mỗi request cho một server chọn ngẫu nhiên cho tải lớn nhất là
Θ(log n / log log n). Chọn hai server ngẫu nhiên rồi gửi request tới cái ít tải hơn kéo con số đó xuốngΘ(log log n)— một cải thiện theo hàm mũ chỉ từ một mẫu ngẫu nhiên thêm vào. Kết quả này là nền tảng của các load balancer và distributed cache thật; xem ../../backend/vi/11-caching.md và ../../data-engineer/vi/10-big-data-and-distributed-computing.md. - Skip list. Một skip list đạt cùng cận kỳ vọng
O(log n)như balanced tree bằng cách tung đồng xu cho level của mỗi node, với code đơn giản hơn nhiều và không cần rotation. Redis dùng skip list cho sorted set.
Một lưu ý quan trọng: random không phải secrets. Module random của Python là Mersenne Twister — tính chất thống kê rất tốt, nhưng hoàn toàn dự đoán được từ khoảng 624 giá trị đầu ra. Dùng nó cho thuật toán; dùng secrets (hoặc os.urandom) cho bất cứ thứ gì kẻ tấn công không được phép đoán trước, bao gồm hash seed, token, và salt.
| Thuật toán ngẫu nhiên | Loại | Độ phức tạp | Ngẫu nhiên mua được gì |
|---|---|---|---|
| Randomised quicksort | Las Vegas | Kỳ vọng Θ(n log n) | Không có input đối kháng Θ(n²) |
| Quickselect | Las Vegas | Kỳ vọng Θ(n) | Nhanh hơn sort một hệ số log n |
| Skip list | Las Vegas | Kỳ vọng Θ(log n) | Cân bằng mà không cần rotation |
| Treap | Las Vegas | Kỳ vọng Θ(log n) | Cân bằng mà không cần logic rebalance |
| Reservoir sampling | Las Vegas | Θ(n) time, Θ(k) space | Một lượt duyệt, độ dài stream chưa biết |
| Miller–Rabin primality | Monte Carlo | Θ(k log³ n) | Kiểm tra số nguyên tố thực dụng |
| Karger min cut | Monte Carlo | Θ(n²) mỗi lần thử | Đơn giản hơn max-flow |
| Bloom filter | Monte Carlo | Θ(k) mỗi thao tác | Kiểm tra thành viên trong không gian rất nhỏ |
| Tích phân Monte Carlo | Monte Carlo | Θ(N) cho sai số O(1/√N) | Hội tụ độc lập số chiều |
Best Practices
- Luôn viết brute force trước, và giữ nó lại. Nó định nghĩa “đúng” nghĩa là gì và trở thành oracle để bạn differential-test bản tối ưu trên hàng nghìn input nhỏ ngẫu nhiên. Cách này bắt được nhiều bug hơn bất kỳ lượng thời gian nào ngồi nhìn chằm chằm vào code thông minh.
- Ước lượng không gian ứng viên trước khi viết brute force cho production.
2ⁿổn ởn = 25, vô vọng ởn = 50.n!vô vọng khi vượtn = 12. Hãy làm phép tính trước, không phải sau. - Đừng bao giờ để một đường thực thi hàm mũ có thể chạm tới được từ input không giới hạn của người dùng. Chặn kích thước input tường minh và trả về lỗi. Một endpoint
O(2ⁿ)là một lỗ hổng DoS. - Đừng bao giờ ship một thuật toán greedy mà bạn chưa chứng minh hoặc chưa test vét cạn. “Nó qua hết ví dụ” không phải bằng chứng — greedy coin change qua mọi test viết bằng tiền đời thực. Hoặc dựng exchange argument, hoặc brute force toàn bộ không gian input với
nnhỏ rồi diff. - Phát biểu tiêu chí greedy tường minh, trong comment. “Sort theo thời điểm kết thúc” so với “sort theo thời điểm bắt đầu” so với “sort theo thời lượng” là ba thuật toán khác nhau cho ba đáp án khác nhau, và chỉ một cái đúng. Tiêu chí chính là thuật toán; vòng lặp chỉ là boilerplate.
- Khi greedy thất bại, hãy kiểm tra subproblem chồng lấn trước khi bỏ cuộc. 0/1 knapsack, coin change, và longest common subsequence đều là các greedy thất bại mà DP giải được trong thời gian đa thức.
- Một greedy thất bại vẫn có giá trị như heuristic — hãy dán nhãn nó như vậy. Nearest-neighbour TSP là tour khởi đầu tốt cho local search 2-opt. Đặt tên hàm là
knapsack_heuristicthay vìknapsack_solvengăn người đọc sau này giả định rằng nó tối ưu. - Phân biệt Las Vegas với Monte Carlo trước khi deploy. Las Vegas cần một ngân sách latency và một timeout. Monte Carlo cần một ngân sách sai số và đủ số lần lặp để đạt được nó. Nhầm lẫn hai thứ dẫn tới hoặc là tail latency khó giải thích, hoặc là đáp án sai khó giải thích.
- Định lượng xác suất lỗi của mọi thuật toán Monte Carlo bạn ship, và viết nó vào docstring. “Sai với xác suất ≤ 4⁻⁴⁰” là kỹ thuật; “thường thì đúng” thì không.
- Seed ngẫu nhiên tường minh trong test, không bao giờ trong production. Một test hỏng một lần trên năm mươi lần chạy còn tệ hơn vô dụng. Dùng instance
random.Random(seed)trong test để lỗi tái hiện được; để production không seed để cơ chế chống đối kháng thực sự phát huy tác dụng. - Dùng
secrets/os.urandom, không dùngrandom, khi kẻ tấn công không được phép đoán giá trị. Mersenne Twister tái dựng được từ đầu ra của nó; điều đó ổn khi chọn pivot và chí mạng khi sinh session token. - Ưu tiên pivot ngẫu nhiên hơn “median of three” cho quicksort trong mọi code xử lý input không tin cậy. Median of three có các chuỗi killer đã được công bố; pivot ngẫu nhiên thì không.
- Nhớ rằng ngẫu nhiên hóa chỉ giúp được khi tính ngẫu nhiên là không dự đoán được với kẻ tấn công. Một pivot “ngẫu nhiên” từ seed cố định là tất định và bị tấn công được. Đây chính xác là lý do Python ngẫu nhiên hóa hash seed theo từng tiến trình.
Tài liệu tham khảo
- roadmap.sh — Data Structures & Algorithms
- Brute-force search — Wikipedia
- Combinatorial explosion — Wikipedia
- Greedy algorithm — Wikipedia
- Matroid — Wikipedia (cấu trúc đại số đặc trưng cho việc khi nào greedy là tối ưu)
- Huffman coding — Wikipedia
- Knapsack problem — Wikipedia
- Change-making problem — Wikipedia
- Randomized algorithm — Wikipedia
- Las Vegas algorithm — Wikipedia
- Monte Carlo algorithm — Wikipedia
- Reservoir sampling — Wikipedia
- Quickselect — Wikipedia
- Branch and bound — Wikipedia
- CLRS — Introduction to Algorithms, Chapter 16: Greedy Algorithms
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- Python Documentation —
random - Python Documentation —
secrets - Python Documentation —
PYTHONHASHSEED - cp-algorithms — Algorithms index
Part of the Data Structures & Algorithms Roadmap knowledge base.
Overview
Everything up to this point in the roadmap has been about structures — how to lay data out so that a particular operation is fast. This topic is the first of the techniques: general strategies for attacking a problem you have never seen before. The roadmap groups them under “Problem Solving Techniques,” and the useful thing about that grouping is that it is small. There are roughly six strategies, they compose, and almost every algorithm you will meet is one of them or a hybrid.
The three covered here sit at the extremes of a trade-off between guaranteed correctness and cost.
Brute force enumerates every candidate solution and checks each one. It is always correct, requires no cleverness, and is very often too slow — but “very often” is not “always,” and knowing when brute force is genuinely the right answer is a real skill. It is also the reference implementation you test your clever algorithm against.
Greedy makes the locally optimal choice at each step and never reconsiders. It is fast — usually a sort plus a linear pass — but it is only correct when the problem has a specific structure. The interesting part of greedy algorithms is not writing them (they are three lines) but proving they work, and the standard tool for that is the exchange argument. A greedy algorithm applied to a problem that does not have the required structure produces answers that look plausible and are wrong, which is worse than being obviously slow.
Randomised algorithms flip coins. They give up determinism in exchange for either simplicity, speed, or immunity to adversarial input. The critical distinction is between Las Vegas algorithms (always correct, random running time) and Monte Carlo algorithms (fixed running time, small probability of a wrong answer) — because those two failure modes need completely different risk assessments in production.
Fundamentals
The catalogue of problem-solving techniques
| Technique | Idea | Cost | When it applies | Where covered |
|---|---|---|---|---|
| Brute force | Enumerate all candidates | Usually exponential or factorial | Always applies; small n only | This note |
| Divide and conquer | Split into independent subproblems, combine | Often Θ(n log n) | Subproblems are independent | ./19-recursion-and-divide-and-conquer.md |
| Greedy | Take the best local choice, never revise | Usually Θ(n log n) (a sort) | Greedy choice property + optimal substructure | This note |
| Dynamic programming | Solve overlapping subproblems once, reuse | Polynomial, often Θ(n·W) | Overlapping subproblems + optimal substructure | ./22-dynamic-programming.md |
| Backtracking | DFS over decisions, prune dead branches | Exponential worst case, much better in practice | Constraint satisfaction | ./21-backtracking.md |
| Branch and bound | Backtracking plus a bound that prunes by objective value | Exponential worst case | Optimization with a computable bound | This note (briefly) |
| Randomisation | Use random choices to avoid bad cases | Expected-case guarantees | Adversarial input, huge search spaces, sampling | This note |
The decision procedure that actually works in practice:
- Write the brute force first. It is correct by construction, it clarifies what the problem actually asks, and it becomes your test oracle.
- Ask whether greedy works. If you can state a greedy choice and prove it by exchange, you are done and the solution is fast.
- If greedy fails, ask whether subproblems overlap. If yes, dynamic programming. If no, divide and conquer.
- If the problem is a constraint satisfaction with a huge space, backtrack with pruning.
- If the worst case is adversarial or the space is too large to enumerate, randomise.
Brute force
A brute-force (exhaustive search) algorithm generates every element of the candidate space and tests each against the problem’s requirements. There is no insight involved, which is exactly the point: it works on any problem whose candidate space you can enumerate.
from itertools import combinations
def subset_sum_brute(nums, target):
"""Find a subset summing to target by trying all 2^n subsets. Theta(2^n · n)."""
for size in range(len(nums) + 1):
for combo in combinations(nums, size):
if sum(combo) == target:
return list(combo)
return None
print(subset_sum_brute([3, 34, 4, 12, 5, 2], 9)) # [4, 5]
itertools.combinations hides the enumeration, so here is the same search written with a bitmask, which shows the mechanics — each integer from 0 to 2ⁿ − 1 is a subset, with bit i meaning “include nums[i]”:
def subset_sum_bitmask(nums, target):
"""Same search, enumeration made explicit. n must be <= ~25 to be practical."""
n = len(nums)
for mask in range(1 << n): # 2^n candidate subsets
total = 0
for i in range(n):
if mask & (1 << i): # bit i set -> nums[i] is in this subset
total += nums[i]
if total == target:
return [nums[i] for i in range(n) if mask & (1 << i)]
return None
Combinatorial explosion is the reason brute force usually loses. At a generous 10⁹ operations per second:
| Candidate space | n = 20 | n = 30 | n = 40 | n = 50 |
|---|---|---|---|---|
2ⁿ (subsets) | 1.0 M — instant | 1.1 G — ~1 s | 1.1 T — ~18 min | 1.1 P — ~13 days |
n! (permutations) | 2.4×10¹⁸ — ~77 years | 2.7×10³² — heat death | — | — |
n² (all pairs) | 400 — instant | 900 | 1 600 | 2 500 |
The jump from n = 40 to n = 50 for 2ⁿ — 18 minutes to 13 days — is the whole story. Exponential growth means buying a machine 1000× faster buys you about 10 more elements.
When brute force is actually the right answer:
nis small and bounded by the problem. Eight queens, a Sudoku grid, a 3×3 puzzle, “at most 12 warehouses.” If the input can never grow, asymptotics are irrelevant.- It is a test oracle. Your
O(n log n)sweep-line has an off-by-one; theO(n³)brute force does not. Cross-check them on 10 000 random small inputs — this is the single highest-value use of brute force in real engineering. - The clever algorithm does not exist or is not worth writing. For an internal tool run once a week on 500 rows, an
O(n²)double loop that takes 0.2 s is correct engineering. Optimizing it is not. - It is the base you then prune. Backtracking is brute force with pruning; branch and bound is brute force with a bound. Both start from the exhaustive enumeration and cut branches — you cannot write them without first knowing what the full search space is.
When it is a bad idea: any time n is user-controlled and unbounded. An O(2ⁿ) path that a user can trigger with a 60-element input is a denial-of-service vulnerability, not a performance problem. The classic instance is catastrophic backtracking in regular expressions — (a+)+b against a string of 30 as explores exponentially many ways to split the input.
Branch and bound is the systematic improvement on brute force for optimization problems: keep the best solution found so far, compute an optimistic bound on what any completion of the current partial solution could achieve, and abandon the branch if that bound cannot beat the incumbent. For the travelling salesperson problem, “current path cost + a lower bound on the remaining edges ≥ best tour found” prunes enormous parts of the tree while still guaranteeing the optimum. Worst case is still factorial; typical case is dramatically better.
Key Concepts
Greedy algorithms
A greedy algorithm builds a solution incrementally, at each step taking the choice that looks best right now, according to some local criterion, and never revisiting it. There is no backtracking and no table of subproblems, which is why greedy algorithms are both the fastest technique and the one most likely to be silently wrong.
Two properties must hold for a greedy algorithm to be correct:
- Greedy choice property — a globally optimal solution can be reached by making the locally optimal choice. Formally: there exists an optimal solution that contains the greedy choice. This is the hard one, and it is what the exchange argument proves.
- Optimal substructure — after committing to the greedy choice, the remaining problem is a smaller instance of the same problem, and an optimal solution to the whole contains an optimal solution to that remainder. (Shared with dynamic programming; DP needs it too.)
The difference from DP is worth stating precisely: DP considers all choices at each step and picks the best after evaluating the consequences; greedy commits to one choice without evaluating the alternatives. Greedy is DP where you have proved you only ever need to look at one branch.
The exchange argument — how to prove a greedy correct
The exchange argument is the standard proof technique, and it always has the same shape:
- Let
Gbe the greedy solution andObe any optimal solution. - Find the first place they differ.
- Show you can exchange
O’s choice at that point forG’s choice without makingOworse (and without breaking feasibility). - Repeating the exchange transforms
OintoGwithout ever losing optimality, soGis optimal too.
Applied to activity selection (choose the maximum number of mutually non-overlapping intervals): the greedy rule is always take the activity that finishes earliest among those still compatible.
- Let
Obe an optimal set, and letabe the activity inOwith the earliest finish time. Letgbe the globally earliest-finishing activity. - By definition
finish(g) ≤ finish(a). - Replace
awithginO. Every other activity inOstarted at or afterfinish(a) ≥ finish(g), so none of them conflicts withg. The set is still feasible and still has the same size. - So there is an optimal solution containing the greedy choice. Recurse on the remaining activities that start after
finish(g)— that is the optimal substructure — and induction finishes the proof.
def activity_selection(intervals):
"""Maximum number of non-overlapping intervals. Theta(n log n), dominated by the sort."""
# Greedy criterion: earliest finish time. Sorting by START or by DURATION both fail.
ordered = sorted(intervals, key=lambda iv: iv[1])
chosen, last_end = [], float("-inf")
for start, end in ordered:
if start >= last_end: # compatible with everything picked so far
chosen.append((start, end))
last_end = end
return chosen
meetings = [(1, 4), (3, 5), (0, 6), (5, 7), (3, 9), (5, 9), (6, 10), (8, 11), (12, 16)]
print(activity_selection(meetings))
# [(1, 4), (5, 7), (8, 11), (12, 16)] -> 4 activities
Note how easy it is to get the criterion wrong. Sorting by start time picks (0, 6) first and yields 3 activities. Sorting by shortest duration fails on [(0, 5), (4, 6), (5, 10)] — it picks the middle one and blocks both others. The greedy rule is the thing you have to prove; the code is trivial once you have it.
Canonical greedy wins
Fractional knapsack — take items by value-to-weight ratio, splitting the last one:
def fractional_knapsack(items, capacity):
"""items: list of (value, weight). Returns max value. Theta(n log n)."""
# Greedy criterion: highest value density first. Provably optimal BECAUSE items divide.
ordered = sorted(items, key=lambda it: it[0] / it[1], reverse=True)
total, remaining = 0.0, capacity
for value, weight in ordered:
if remaining == 0:
break
take = min(weight, remaining) # take all of it, or the fraction that fits
total += value * (take / weight)
remaining -= take
return total
print(fractional_knapsack([(60, 10), (100, 20), (120, 30)], 50)) # 240.0
The exchange argument: if an optimal solution takes less of the highest-density item than the greedy does, swap a unit of some lower-density item for a unit of the higher-density one. Same weight, no less value. Divisibility is what makes the swap always possible — and it is exactly what the 0/1 version removes.
Huffman coding — build an optimal prefix-free code by repeatedly merging the two least frequent symbols:
import heapq
from collections import Counter
def huffman_codes(text):
"""Optimal prefix-free code. Theta(n log n) for n distinct symbols."""
freq = Counter(text)
if len(freq) == 1: # edge case: one symbol needs one bit anyway
return {next(iter(freq)): "0"}
# Heap entries: (frequency, tiebreak, {symbol: code_suffix_so_far})
tiebreak = 0
heap = []
for symbol, f in freq.items():
heapq.heappush(heap, (f, tiebreak, {symbol: ""}))
tiebreak += 1
while len(heap) > 1:
# Greedy choice: the two rarest symbols get the longest codes, so merge them first
f1, _, left = heapq.heappop(heap)
f2, _, right = heapq.heappop(heap)
merged = {s: "0" + c for s, c in left.items()}
merged.update({s: "1" + c for s, c in right.items()})
heapq.heappush(heap, (f1 + f2, tiebreak, merged))
tiebreak += 1
return heap[0][2]
codes = huffman_codes("abracadabra")
print(sorted(codes.items(), key=lambda kv: (len(kv[1]), kv[0])))
# [('a', '0'), ('b', '110'), ('c', '100'), ('d', '101'), ('r', '111')]
# 'a' (5 occurrences) gets a 1-bit code; 'c' and 'd' (1 each) get 3-bit codes
The exchange argument here: in any optimal prefix code, the two least frequent symbols can be assumed to be siblings at the deepest level — if they were not, swapping them with whatever is down there does not increase the weighted code length. Merging them is therefore safe. heapq is the right tool in production; it costs O(log n) per push/pop and O(n) to heapify — see ./12-heaps-and-priority-queues.md.
Kruskal’s MST — sort edges by weight, add each edge that does not create a cycle. Correct by the cut property: for any partition of the vertices, the minimum-weight edge crossing the cut is in some MST. See ./15-minimum-spanning-trees.md and ./16-disjoint-set-union-find.md.
Dijkstra’s shortest path — repeatedly finalise the unvisited vertex with the smallest tentative distance. The greedy choice is provably safe only because edge weights are non-negative: once a vertex is the closest unfinalised one, no later path through a farther vertex can improve it. Introduce one negative edge and the proof collapses — which is exactly why Bellman–Ford exists. See ./14-shortest-path-algorithms.md.
| Problem | Greedy criterion | Complexity | Correct? |
|---|---|---|---|
| Activity selection | Earliest finish time | Θ(n log n) | Yes (exchange argument) |
| Fractional knapsack | Highest value/weight | Θ(n log n) | Yes (items divide) |
| Huffman coding | Merge two lowest frequencies | Θ(n log n) | Yes (exchange argument) |
| Kruskal MST | Cheapest safe edge | Θ(E log E) | Yes (cut property) |
| Prim MST | Cheapest edge leaving the tree | Θ(E log V) | Yes (cut property) |
| Dijkstra | Closest unfinalised vertex | Θ((V+E) log V) | Yes, iff weights ≥ 0 |
| Coin change (canonical systems) | Largest coin ≤ remainder | Θ(n) | Yes for USD/EUR denominations only |
| 0/1 knapsack | Highest value/weight | Θ(n log n) | No |
| Coin change (arbitrary coins) | Largest coin ≤ remainder | Θ(n) | No |
| Travelling salesperson | Nearest unvisited city | Θ(n²) | No (can be arbitrarily bad) |
When greedy fails — two concrete counterexamples
0/1 knapsack. Same items as the fractional version, but now each item is all-or-nothing:
def knapsack_greedy_WRONG(items, capacity):
"""Greedy by density on the 0/1 knapsack. Fast, plausible, and incorrect."""
ordered = sorted(items, key=lambda it: it[0] / it[1], reverse=True)
total, remaining = 0, capacity
for value, weight in ordered:
if weight <= remaining: # cannot take a fraction any more
total += value
remaining -= weight
return total
def knapsack_dp(items, capacity):
"""Correct 0/1 knapsack by DP. Theta(n · capacity) time, Theta(capacity) space."""
best = [0] * (capacity + 1)
for value, weight in items:
for c in range(capacity, weight - 1, -1): # descending: each item used once
best[c] = max(best[c], best[c - weight] + value)
return best[capacity]
items = [(60, 10), (100, 20), (120, 30)] # (value, weight)
print(knapsack_greedy_WRONG(items, 50)) # 160 <- wrong
print(knapsack_dp(items, 50)) # 220 <- correct
print(fractional_knapsack(items, 50)) # 240.0 (fractional is a different problem)
Greedy takes the density-6 item (10 kg, value 60), then the density-5 item (20 kg, value 100), reaching 160 with 20 kg of capacity left — and the 30 kg item no longer fits. The optimum skips the first item entirely and takes the 20 kg and 30 kg items for 220. The greedy choice property fails: no optimal solution contains the greedy first choice. Indivisibility is the whole difference, and the fix is dynamic programming.
Coin change with awkward denominations. Greedy “take the largest coin that fits” is optimal for US and Euro coin systems, which is why it feels universally right. It is not:
def coin_change_greedy(coins, amount):
"""Take the largest coin that fits. Optimal only for 'canonical' coin systems."""
count, remaining = 0, amount
for coin in sorted(coins, reverse=True):
take = remaining // coin
count += take
remaining -= take * coin
return count if remaining == 0 else None
def coin_change_dp(coins, amount):
"""Correct minimum-coin count for ANY denominations. Theta(len(coins) · amount)."""
INF = float("inf")
best = [0] + [INF] * amount
for value in range(1, amount + 1):
for coin in coins:
if coin <= value and best[value - coin] + 1 < best[value]:
best[value] = best[value - coin] + 1
return None if best[amount] == INF else best[amount]
print(coin_change_greedy([1, 3, 4], 6)) # 3 -> 4 + 1 + 1
print(coin_change_dp([1, 3, 4], 6)) # 2 -> 3 + 3
print(coin_change_greedy([1, 7, 10], 15)) # 6 -> 10 + 1*5
print(coin_change_dp([1, 7, 10], 15)) # 3 -> 7 + 7 + 1
With coins {1, 3, 4} and amount 6, greedy takes 4 then two 1s — three coins — while 3 + 3 is two. The greedy is not just suboptimal, it is confidently suboptimal, and it will pass every test written with real-world denominations. This is the practical danger of greedy: the failure mode is a wrong answer that looks right, not a crash or a timeout. Determining whether a coin system is “canonical” (greedy-optimal) is itself a non-trivial problem, and the safe engineering answer is to use DP unless the denominations are fixed and you have verified greedy against DP on the whole range.
Nearest-neighbour TSP rounds out the picture: always visiting the nearest unvisited city produces tours that can be a Θ(log n) factor worse than optimal, and on adversarial inputs arbitrarily worse. It is still useful — as a fast starting tour for a local-search algorithm — which is the honest role of a failed greedy: a heuristic, clearly labelled as such.
Randomised algorithms
A randomised algorithm uses random numbers as part of its logic, so the same input can produce different execution paths on different runs. The reason to accept that non-determinism is usually one of three:
- To defeat adversarial input. A deterministic algorithm has a fixed worst case, and an attacker who knows the code can construct it. A randomised one has no input that is reliably bad.
- To simplify. Randomised algorithms for problems like min-cut or primality are dramatically simpler than their deterministic counterparts.
- To sample. When the space is too large to enumerate, sampling is the only option.
Las Vegas vs Monte Carlo
| Las Vegas | Monte Carlo | |
|---|---|---|
| Correctness | Always correct | Correct with probability 1 − δ |
| Running time | Random variable | Fixed / bounded |
| Failure mode | Runs longer than expected | Returns a wrong answer, silently |
| Repeat to improve | Reduces variance in time | Reduces error probability (δ^k after k runs) |
| Examples | Randomised quicksort, quickselect, treaps, skip lists | Miller–Rabin primality, Karger’s min cut, Bloom filters, count-min sketch |
The distinction matters operationally. A Las Vegas algorithm that occasionally takes 3× longer is a latency problem you can measure and cap with a timeout. A Monte Carlo algorithm that is wrong 1 in 10⁶ times is a correctness problem that will not show up in testing and will show up in production. Before shipping a Monte Carlo algorithm, work out the actual error probability, decide whether the consequence of a wrong answer is acceptable, and repeat the algorithm enough times to push δ below that threshold. Miller–Rabin with k independent bases has error ≤ 4^(−k); at k = 40 that is below the probability of a cosmic-ray bit flip, which is why it is used for real cryptographic key generation.
Randomised quicksort — the archetypal Las Vegas algorithm
Deterministic quicksort with a fixed pivot rule (always take the first element) is Θ(n²) on already-sorted input — the single most common shape of real data. Choosing the pivot at random makes the expected time Θ(n log n) for every input, because the randomness lives in the algorithm rather than the data.
import random
def quicksort_random(arr):
"""Expected Theta(n log n) on ANY input. Worst case Theta(n^2) but probability ~0."""
if len(arr) <= 1:
return arr[:]
pivot = random.choice(arr) # the only random decision
less = [x for x in arr if x < pivot]
equal = [x for x in arr if x == pivot]
greater = [x for x in arr if x > pivot]
return quicksort_random(less) + equal + quicksort_random(greater)
Why the expectation works out: a random pivot splits the array at least 25/75 with probability 1/2, and a run of 25/75 splits still gives O(log n) depth (log_{4/3} n). Summing the expected comparisons gives 2n ln n ≈ 1.39 n log₂ n. The worst case is still Θ(n²), but reaching it requires the random number generator to conspire — probability roughly 1/n! — rather than requiring the input to be unlucky. That shift is the entire point: randomisation converts a worst case over inputs into a worst case over coin flips, and you control the coins.
Quickselect — expected linear-time selection
def quickselect(arr, k):
"""k-th smallest element, 0-indexed. Expected Theta(n), worst case Theta(n^2)."""
if len(arr) == 1:
return arr[0]
pivot = random.choice(arr)
less = [x for x in arr if x < pivot]
equal = [x for x in arr if x == pivot]
greater = [x for x in arr if x > pivot]
if k < len(less):
return quickselect(less, k) # recurse into ONE side only
if k < len(less) + len(equal):
return pivot
return quickselect(greater, k - len(less) - len(equal))
data = [7, 10, 4, 3, 20, 15]
print(quickselect(data, 2)) # 7 -> the 3rd smallest
The complexity argument is the geometric series: expected work is n + n/2 + n/4 + … = 2n = Θ(n), because unlike quicksort it recurses into only one partition. Beating the obvious sort-then-index Θ(n log n) by a log n factor. The deterministic alternative — median of medians — is Θ(n) worst case, but its constant factor is large enough that randomised quickselect wins in practice. See ./12-heaps-and-priority-queues.md for the heap-based approach when you want the top k rather than the k-th.
Reservoir sampling — uniform sampling from a stream of unknown length
You are reading a log stream and want k uniformly random lines, but you do not know how many lines there are and cannot hold them all in memory. Reservoir sampling solves this in one pass with O(k) memory:
def reservoir_sample(stream, k):
"""k uniform samples from a stream of unknown length n. O(n) time, O(k) space."""
reservoir = []
for i, item in enumerate(stream):
if i < k:
reservoir.append(item) # fill the reservoir first
else:
j = random.randrange(i + 1) # uniform in [0, i]
if j < k: # keep with probability k/(i+1)
reservoir[j] = item # evict a uniformly chosen incumbent
return reservoir
print(reservoir_sample(range(1_000_000), 5)) # 5 uniform samples, one pass, O(1) memory
The invariant, proved by induction: after processing i items, every one of them is in the reservoir with probability exactly k/i. Item i+1 enters with probability k/(i+1); an incumbent survives with probability 1 − k/(i+1) · 1/k = 1 − 1/(i+1) = i/(i+1), and k/i · i/(i+1) = k/(i+1). The invariant is preserved. This is the algorithm behind distributed-trace sampling, shuf -n on huge files, and A/B test bucketing over unbounded event streams.
Monte Carlo estimation
The other meaning of “Monte Carlo” is numerical estimation by random sampling — approximating a quantity you cannot compute exactly by averaging over random draws.
def estimate_pi(samples):
"""Fraction of random points in the unit square that land inside the quarter circle
is pi/4. Error shrinks as O(1/sqrt(samples)) -- 100x more samples, 10x accuracy."""
inside = 0
for _ in range(samples):
x, y = random.random(), random.random()
if x * x + y * y <= 1.0:
inside += 1
return 4.0 * inside / samples
print(estimate_pi(1_000_000)) # ~3.1416, differing run to run
The O(1/√N) convergence is the defining characteristic and the reason Monte Carlo integration is used for high-dimensional problems: that rate is independent of dimension, whereas grid-based numerical integration costs O(N^d). It is why Monte Carlo dominates in finance (option pricing), physics (particle transport), and rendering (path tracing), and why it is a poor choice for a 1-D integral where Simpson’s rule converges far faster.
Randomisation as a defence against adversarial worst cases
This is the deepest practical reason to randomise, and it shows up in production systems constantly:
- Hash flooding. A hash table with a fixed, public hash function degrades from
O(1)toO(n)per lookup if an attacker submits keys that all collide — a few thousand crafted POST parameters could pin a web server’s CPU. The fix is a randomly seeded hash (Python uses SipHash with a per-process random seed since 3.3; seePYTHONHASHSEED), so the attacker cannot precompute colliding keys. This is randomisation used purely for security, not speed. - Quicksort killers. Introsort in C++ and the “median of three” heuristic exist because attackers published inputs that force
Θ(n²)in specific library implementations. Randomised pivots remove the attack surface entirely. - Load balancing — “the power of two choices.” Assigning each request to a randomly chosen server gives a maximum load of
Θ(log n / log log n). Picking two servers at random and sending the request to the less loaded one drops it toΘ(log log n)— an exponential improvement from one extra random sample. This result underpins real load balancers and distributed caches; see ../../backend/en/11-caching.md and ../../data-engineer/en/10-big-data-and-distributed-computing.md. - Skip lists. A skip list achieves the same expected
O(log n)bounds as a balanced tree using coin flips for the level of each node, with far simpler code and no rotations. Redis uses one for sorted sets.
One caveat that matters: random is not secrets. Python’s random module is a Mersenne Twister — excellent statistical properties, completely predictable from about 624 outputs. Use it for algorithms; use secrets (or os.urandom) for anything an adversary must not predict, including hash seeds, tokens, and salts.
| Randomised algorithm | Type | Complexity | What randomness buys |
|---|---|---|---|
| Randomised quicksort | Las Vegas | Expected Θ(n log n) | No adversarial Θ(n²) input |
| Quickselect | Las Vegas | Expected Θ(n) | Beats sorting by log n |
| Skip list | Las Vegas | Expected Θ(log n) | Balance without rotations |
| Treap | Las Vegas | Expected Θ(log n) | Balance without rebalancing logic |
| Reservoir sampling | Las Vegas | Θ(n) time, Θ(k) space | One pass, unknown stream length |
| Miller–Rabin primality | Monte Carlo | Θ(k log³ n) | Practical primality testing |
| Karger’s min cut | Monte Carlo | Θ(n²) per trial | Simplicity over max-flow |
| Bloom filter | Monte Carlo | Θ(k) per op | Set membership in tiny space |
| Monte Carlo integration | Monte Carlo | Θ(N) for O(1/√N) error | Dimension-independent convergence |
Best Practices
- Always write the brute force first, and keep it. It defines what “correct” means and becomes the oracle you differential-test the optimized version against on thousands of random small inputs. This catches more bugs than any amount of staring at the clever code.
- Estimate the candidate space before writing brute force in production.
2ⁿis fine atn = 25, hopeless atn = 50.n!is hopeless pastn = 12. Do the arithmetic before, not after. - Never let an exponential-time path be reachable from unbounded user input. Cap the input size explicitly and return an error. An
O(2ⁿ)endpoint is a DoS vulnerability. - Never ship a greedy algorithm you have not proved or exhaustively tested. “It passed the examples” is not evidence — the coin-change greedy passes every test written with real-world coins. Either construct the exchange argument, or brute-force the entire input space for small
nand diff. - State the greedy criterion explicitly, in a comment. “Sort by finish time” versus “sort by start time” versus “sort by duration” are three different algorithms with three different answers, and only one is correct. The criterion is the algorithm; the loop is boilerplate.
- When greedy fails, check for overlapping subproblems before giving up. 0/1 knapsack, coin change, and longest common subsequence are all greedy-failures that DP solves in polynomial time.
- A failed greedy is still valuable as a heuristic — label it as one. Nearest-neighbour TSP makes a fine starting tour for 2-opt local search. Naming a function
knapsack_heuristicrather thanknapsack_solveprevents a future reader from assuming optimality. - Distinguish Las Vegas from Monte Carlo before you deploy. Las Vegas needs a latency budget and a timeout. Monte Carlo needs an error budget and enough repetitions to meet it. Confusing the two means either an unexplained tail latency or an unexplained wrong answer.
- Quantify the error probability of any Monte Carlo algorithm you ship, and write it in the docstring. “Fails with probability ≤ 4⁻⁴⁰” is engineering; “usually right” is not.
- Seed randomness explicitly in tests, never in production. A test that fails once in fifty runs is worse than useless. Use
random.Random(seed)instances in tests so failures reproduce; leave production unseeded so the adversarial protection actually works. - Use
secrets/os.urandom, notrandom, when an adversary must not predict the values. Mersenne Twister is reconstructible from its output; that is fine for pivot selection and fatal for a session token. - Prefer randomised pivots to “median of three” for quicksort in any code that processes untrusted input. Median of three has published killer sequences; a random pivot does not.
- Remember that randomisation only helps when the randomness is unpredictable to the adversary. A “random” pivot from a fixed seed is deterministic and attackable. This is exactly why Python randomises its hash seed per process.
References
- roadmap.sh — Data Structures & Algorithms
- Brute-force search — Wikipedia
- Combinatorial explosion — Wikipedia
- Greedy algorithm — Wikipedia
- Matroid — Wikipedia (the algebraic structure that characterises when greedy is optimal)
- Huffman coding — Wikipedia
- Knapsack problem — Wikipedia
- Change-making problem — Wikipedia
- Randomized algorithm — Wikipedia
- Las Vegas algorithm — Wikipedia
- Monte Carlo algorithm — Wikipedia
- Reservoir sampling — Wikipedia
- Quickselect — Wikipedia
- Branch and bound — Wikipedia
- CLRS — Introduction to Algorithms, Chapter 16: Greedy Algorithms
- MIT 6.006 — Introduction to Algorithms (OpenCourseWare)
- Python Documentation —
random - Python Documentation —
secrets - Python Documentation —
PYTHONHASHSEED - cp-algorithms — Algorithms index