← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202634 phút đọc28 min read

Brute Force, Greedy & Thuật toán ngẫu nhiênBrute Force, Greedy & Randomised Algorithms

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Danh mục các kỹ thuật giải quyết vấn đề
  4. Brute force
  5. Khái niệm chính
  6. Thuật toán greedy
  7. Thuật toán ngẫu nhiên
  8. Best Practices
  9. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. The catalogue of problem-solving techniques
  4. Brute force
  5. Key Concepts
  6. Greedy algorithms
  7. Randomised algorithms
  8. Best Practices
  9. References

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 đắnchi 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ưởngChi phíKhi nào áp dụngBài nào bao quát
Brute forceLiệt kê mọi ứng viênThường là hàm mũ hoặc giai thừaLuô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épThường Θ(n log n)Subproblem độc lập nhau./19-recursion-and-divide-and-conquer.md
GreedyChọn tốt nhất cục bộ, không xét lạiThường Θ(n log n) (một lần sort)Greedy choice property + optimal substructureBài này
Quy hoạch độngGiả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
BacktrackingDFS trên cây quyết định, cắt nhánh chếtHàm mũ ở worst case, thực tế tốt hơn nhiềuBài toán ràng buộc (constraint satisfaction)./21-backtracking.md
Branch and boundBacktracking cộng một cận để cắt theo giá trị mục tiêuHàm mũ ở worst caseBài toán tối ưu có cận tính đượcBài này (sơ lược)
Ngẫu nhiên hóaDùng lựa chọn ngẫu nhiên để né trường hợp xấuĐảm bảo theo kỳ vọngInput đối kháng, không gian tìm kiếm quá lớn, lấy mẫuBài này

Quy trình quyết định thực sự dùng được:

  1. 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.
  2. 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.
  3. 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ị.
  4. 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.
  5. 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ênn = 20n = 30n = 40n = 50
2ⁿ (subset)1,0 M — tức thì1,1 G — ~1 s1,1 T — ~18 phút1,1 P — ~13 ngày
n! (hoán vị)2,4×10¹⁸ — ~77 năm2,7×10³² — vũ trụ tắt
(mọi cặp)400 — tức thì9001 6002 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:

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:

  1. 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.
  2. 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:

  1. Gọi G là lời giải greedy và O là một lời giải tối ưu bất kỳ.
  2. Tìm vị trí đầu tiên chúng khác nhau.
  3. Chứng minh bạn có thể đổi lựa chọn của O tại điểm đó lấy lựa chọn của G mà không làm O tệ đi (và không phá vỡ tính khả thi).
  4. Lặp lại phép đổi sẽ biến O thành G mà không bao giờ mất tính tối ưu, nên G cũ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.

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./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ánTiêu chí greedyĐộ phức tạpCó đúng không?
Activity selectionKết thúc sớm nhấtΘ(n log n)Có (exchange argument)
Fractional knapsackValue/weight cao nhấtΘ(n log n)Có (vật phẩm chia nhỏ được)
Huffman codingTrộn hai tần suất thấp nhấtΘ(n log n)Có (exchange argument)
Kruskal MSTCạnh an toàn rẻ nhấtΘ(E log E)Có (cut property)
Prim MSTCạ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 knapsackValue/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 salespersonThà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:

Las Vegas và Monte Carlo

Las VegasMonte Carlo
Tính đúng đắnLuôn đúngĐúng với xác suất 1 − δ
Thời gian chạyBiến ngẫu nhiênCố định / bị chặn
Kiểu thất bạiChạy lâu hơn dự kiếnTrả về đáp án sai, âm thầm
Lặp lại để cải thiệnGiảm phương sai thời gianGiảm xác suất lỗi (δ^k sau k lần)
Ví dụRandomised quicksort, quickselect, treap, skip listMiller–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:

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ênLoạiĐộ phức tạpNgẫu nhiên mua được gì
Randomised quicksortLas VegasKỳ vọng Θ(n log n)Không có input đối kháng Θ(n²)
QuickselectLas VegasKỳ vọng Θ(n)Nhanh hơn sort một hệ số log n
Skip listLas VegasKỳ vọng Θ(log n)Cân bằng mà không cần rotation
TreapLas VegasKỳ vọng Θ(log n)Cân bằng mà không cần logic rebalance
Reservoir samplingLas VegasΘ(n) time, Θ(k) spaceMột lượt duyệt, độ dài stream chưa biết
Miller–Rabin primalityMonte CarloΘ(k log³ n)Kiểm tra số nguyên tố thực dụng
Karger min cutMonte CarloΘ(n²) mỗi lần thửĐơn giản hơn max-flow
Bloom filterMonte CarloΘ(k) mỗi thao tácKiểm tra thành viên trong không gian rất nhỏ
Tích phân Monte CarloMonte CarloΘ(N) cho sai số O(1/√N)Hội tụ độc lập số chiều

Best Practices

Tài liệu tham khảo

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

TechniqueIdeaCostWhen it appliesWhere covered
Brute forceEnumerate all candidatesUsually exponential or factorialAlways applies; small n onlyThis note
Divide and conquerSplit into independent subproblems, combineOften Θ(n log n)Subproblems are independent./19-recursion-and-divide-and-conquer.md
GreedyTake the best local choice, never reviseUsually Θ(n log n) (a sort)Greedy choice property + optimal substructureThis note
Dynamic programmingSolve overlapping subproblems once, reusePolynomial, often Θ(n·W)Overlapping subproblems + optimal substructure./22-dynamic-programming.md
BacktrackingDFS over decisions, prune dead branchesExponential worst case, much better in practiceConstraint satisfaction./21-backtracking.md
Branch and boundBacktracking plus a bound that prunes by objective valueExponential worst caseOptimization with a computable boundThis note (briefly)
RandomisationUse random choices to avoid bad casesExpected-case guaranteesAdversarial input, huge search spaces, samplingThis note

The decision procedure that actually works in practice:

  1. Write the brute force first. It is correct by construction, it clarifies what the problem actually asks, and it becomes your test oracle.
  2. Ask whether greedy works. If you can state a greedy choice and prove it by exchange, you are done and the solution is fast.
  3. If greedy fails, ask whether subproblems overlap. If yes, dynamic programming. If no, divide and conquer.
  4. If the problem is a constraint satisfaction with a huge space, backtrack with pruning.
  5. 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 spacen = 20n = 30n = 40n = 50
2ⁿ (subsets)1.0 M — instant1.1 G — ~1 s1.1 T — ~18 min1.1 P — ~13 days
n! (permutations)2.4×10¹⁸ — ~77 years2.7×10³² — heat death
(all pairs)400 — instant9001 6002 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:

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:

  1. 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.
  2. 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:

  1. Let G be the greedy solution and O be any optimal solution.
  2. Find the first place they differ.
  3. Show you can exchange O’s choice at that point for G’s choice without making O worse (and without breaking feasibility).
  4. Repeating the exchange transforms O into G without ever losing optimality, so G is 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.

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.

ProblemGreedy criterionComplexityCorrect?
Activity selectionEarliest finish timeΘ(n log n)Yes (exchange argument)
Fractional knapsackHighest value/weightΘ(n log n)Yes (items divide)
Huffman codingMerge two lowest frequenciesΘ(n log n)Yes (exchange argument)
Kruskal MSTCheapest safe edgeΘ(E log E)Yes (cut property)
Prim MSTCheapest edge leaving the treeΘ(E log V)Yes (cut property)
DijkstraClosest 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 knapsackHighest value/weightΘ(n log n)No
Coin change (arbitrary coins)Largest coin ≤ remainderΘ(n)No
Travelling salespersonNearest 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:

Las Vegas vs Monte Carlo

Las VegasMonte Carlo
CorrectnessAlways correctCorrect with probability 1 − δ
Running timeRandom variableFixed / bounded
Failure modeRuns longer than expectedReturns a wrong answer, silently
Repeat to improveReduces variance in timeReduces error probability (δ^k after k runs)
ExamplesRandomised quicksort, quickselect, treaps, skip listsMiller–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:

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 algorithmTypeComplexityWhat randomness buys
Randomised quicksortLas VegasExpected Θ(n log n)No adversarial Θ(n²) input
QuickselectLas VegasExpected Θ(n)Beats sorting by log n
Skip listLas VegasExpected Θ(log n)Balance without rotations
TreapLas VegasExpected Θ(log n)Balance without rebalancing logic
Reservoir samplingLas VegasΘ(n) time, Θ(k) spaceOne pass, unknown stream length
Miller–Rabin primalityMonte CarloΘ(k log³ n)Practical primality testing
Karger’s min cutMonte CarloΘ(n²) per trialSimplicity over max-flow
Bloom filterMonte CarloΘ(k) per opSet membership in tiny space
Monte Carlo integrationMonte CarloΘ(N) for O(1/√N) errorDimension-independent convergence

Best Practices

References