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

Binary Search

Pattern cổ điển nhất, mạnh nhất. Biến O(n) thành O(log n) — và áp dụng được không chỉ trên array sort mà còn trên answer space, một kỹ thuật then chốt cho phỏng vấn senior.

10 worked examples · 3 knowledge checks · 12 bài về nhà

Phần 1. Nền tảng Binary Search

Template chuẩn để tránh off-by-one mãi mãi
1.1

1.1 Binary Search là gì?

Định nghĩa:
Tìm kiếm trên một không gian có thứ tự bằng cách chia đôi không gian sau mỗi bước. Sau k bước, vùng còn lại là n / 2^k. Khi vùng còn 1 phần tử, thuật toán dừng → tổng O(log n).

Hai điều kiện phải có để áp dụng

  1. Không gian có thứ tự — array đã sort, hoặc một thuộc tính monoton trên answer space.
  2. Hàm kiểm tra (predicate) trả True/False, và là monoton: F → F → ... → T → T → ... Tìm boundary giữa F và T là mục tiêu.

Vì sao mạnh?

Với n = 10⁹, O(log n) = 30 bước. Đây là khác biệt giữa "1 giây" và "10 năm". Nhiều bài có n ≤ 10⁹ bắt buộc phải dùng Binary Search hoặc công thức toán.

1.2

1.2 Template chuẩn — Half-open [left, right)

Có rất nhiều phiên bản Binary Search. Khoá học này dùng 1 template duy nhất để tránh nhầm:

def binary_search(arr, target):
    left, right = 0, len(arr)         # half-open: right không truy cập
    while left < right:
        mid = (left + right) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid
    return -1

Vì sao chọn half-open?

Template này dùng được cho cả "tìm chính xác" và "tìm boundary" — chỉ thay đổi điều kiện so sánh.

1.3

1.3 Lower bound vs Upper bound

Hai biến thể quan trọng của BS — phải phân biệt rõ:

Lower bound — vị trí đầu tiên mà arr[i] ≥ target

def lower_bound(arr, target):
    left, right = 0, len(arr)
    while left < right:
        mid = (left + right) // 2
        if arr[mid] < target:
            left = mid + 1
        else:
            right = mid
    return left

Upper bound — vị trí đầu tiên mà arr[i] > target

def upper_bound(arr, target):
    left, right = 0, len(arr)
    while left < right:
        mid = (left + right) // 2
        if arr[mid] <= target:
            left = mid + 1
        else:
            right = mid
    return left

Khác biệt: so sánh với hay <.

Quan sát:
Số lần xuất hiện của target trong mảng sort = upper_bound − lower_bound. Đây là cách giải First and Last Position (#34) gọn nhất.
1.4

1.4 Bốn bẫy kinh điển

  1. Vòng lặp vô tận.
    # SAI — kẹt khi left = mid:
    while left < right:
        mid = (left + right) // 2
        if condition: left = mid          # mid không đổi!
    # ĐÚNG: left = mid + 1
  2. Tràn số khi tính mid. Trong C++/Java, (left + right) / 2 tràn nếu cả hai gần INT_MAX. Dùng left + (right − left) // 2. Python không có vấn đề này nhưng nên viết theo thói quen.
  3. Nhầm left ≤ right với left < right. Hai loại template tồn tại; trộn chúng dẫn đến bug. Khuyên dùng nhất quán half-open.
  4. Nghĩ rằng BS chỉ chạy trên array. BS chạy trên mọi không gian có thứ tự với predicate monoton. Đây là ý tưởng then chốt cho Phần 4.

Phần 2. Binary Search trên Array

Bài tập kinh điển — luyện template cho nhuyễn
2.1

2.1 Worked Example 1 — Classic Binary Search

Đề (LeetCode #704): Mảng nums sort tăng. Tìm index của target, -1 nếu không có.

def search(nums, target):
    left, right = 0, len(nums)
    while left < right:
        mid = (left + right) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid + 1
        else:
            right = mid
    return -1

Trace với nums = [-1, 0, 3, 5, 9, 12], target = 9

Bướcleftrightmidnums[mid]Hành động
106355 < 9 → left=4
24651212 > 9 → right=5
34549match → return 4

Time O(log n), Space O(1).

2.2

2.2 Worked Example 2 — Find First and Last Position

Đề (LeetCode #34): Mảng sort. Tìm index đầu và cuối của target. Trả [-1, -1] nếu không có. Yêu cầu O(log n).

Dùng lower_bound + upper_bound

def searchRange(nums, target):
    def lower(t):
        l, r = 0, len(nums)
        while l < r:
            m = (l + r) // 2
            if nums[m] < t: l = m + 1
            else: r = m
        return l

    first = lower(target)
    if first == len(nums) or nums[first] != target:
        return [-1, -1]
    last = lower(target + 1) - 1     # mẹo: upper bound = lower(target+1)
    return [first, last]

Time O(log n), Space O(1).

2.3

2.3 First and Last Position — Trace

nums = [5,7,7,8,8,10], target = 8. Kỳ vọng: [3, 4].

lower(8)

Bướclrmnums[m]Hành động
10638không < 8 → r=3
20317< 8 → l=2
32327< 8 → l=3
33thoát, trả 3

lower(9) − 1 = upper bound của 8 trừ 1

Tương tự, lower(9) = 5 → last = 4.

Kết quả: [3, 4]. Time O(log n).

Mẹo "lower(target + 1) − 1":
Trong array số nguyên, vị trí cuối của target = vị trí đầu của (target + 1) trừ 1. Cho phép viết một template lower_bound dùng cho mọi bài.
2.4

2.4 Worked Example 3 — Search Insert Position

Đề (LeetCode #35): Mảng sort, target. Trả về index nơi target nên được chèn vào.

Đây chính là lower_bound — vị trí đầu tiên ≥ target.

def searchInsert(nums, target):
    left, right = 0, len(nums)
    while left < right:
        mid = (left + right) // 2
        if nums[mid] < target:
            left = mid + 1
        else:
            right = mid
    return left

Time O(log n), Space O(1).

Vì sao trả left, không phải right?
Khi thoát, left == right — trả cái nào cũng được. Quy ước trả left để code thống nhất. Khi target lớn hơn mọi phần tử, left = len(nums) — đây là vị trí chèn cuối, đúng với yêu cầu.
2.5

2.5 Worked Example 4 — Sqrt(x) bằng Binary Search

Đề (LeetCode #69): Cho số nguyên không âm x. Trả floor(sqrt(x)). Không dùng built-in sqrt.

BS trên answer space [0, x]

Đây là bài đầu tiên minh hoạ "BS không nhất thiết trên array". Tìm số r lớn nhất sao cho r² ≤ x.

def mySqrt(x):
    if x < 2: return x
    left, right = 1, x
    while left < right:
        mid = (left + right + 1) // 2     # ceil — tìm cận trên
        if mid * mid <= x:
            left = mid
        else:
            right = mid - 1
    return left

Time O(log x), Space O(1).

Mẹo "ceil mid":
Khi left = mid (giữ mid là ứng viên), phải dùng (left + right + 1) // 2 (ceil) để tránh vòng lặp vô tận khi left = right − 1. Đây là một trong các template "BS tìm cận trên".
2.Q

Knowledge Check — BS trên Array

Q1: Trong template half-open, vì sao khi điều kiện đúng thì right = mid chứ không phải right = mid − 1?
Half-open có nghĩa right là "vùng cấm" — vị trí right không được xem xét nữa. Khi mid đã không phải đáp án (hoặc cần loại), gán right = mid là loại đúng mid mà không loại nhầm các vị trí trước.
Q2: Số lần xuất hiện của target trong mảng sort được tính bằng?
Lower bound trả vị trí đầu tiên ≥ target; upper bound trả vị trí đầu tiên > target. Khoảng giữa chính là tất cả vị trí của target. Mẹo này tổng quát cho bài "đếm" trên mảng sort.
Q3: Khi left = mid, vì sao phải dùng mid = (l + r + 1) // 2?
Khi l = r − 1, mid floor = l, nếu gán l = mid thì l không đổi → vô tận. Mid ceil = r → l = r → thoát đúng. Quy tắc: cặp (l = mid, mid ceil) hoặc (l = mid + 1, mid floor).

Phần 3. BS trên Rotated/Non-trivial Arrays

Khi mảng không sort thuần — tìm "phần sort" để áp dụng
3.1

3.1 Rotated Sorted Array — Trực giác

Rotated = mảng sort nhưng bị "cắt và ghép" tại điểm pivot. Ví dụ: [4,5,6,7,0,1,2][0,1,2,4,5,6,7] rotated tại pivot 4.

Quan sát then chốt

Với mid bất kỳ, một trong hai nửa luôn còn sort: nửa trái [l..mid] hoặc nửa phải [mid..r].

4 5 6 7 0 1 2 Phần sort 1 (cao) Phần sort 2 (thấp)

Quy trình: kiểm tra nửa nào sort → nếu target nằm trong khoảng sort đó, BS bên đó; ngược lại bên kia.

3.2

3.2 Worked Example 5 — Search in Rotated Sorted Array

Đề (LeetCode #33): Mảng đã rotate. Tìm target. O(log n).

def search(nums, target):
    left, right = 0, len(nums) - 1
    while left <= right:
        mid = (left + right) // 2
        if nums[mid] == target:
            return mid
        # Xác định nửa nào sort
        if nums[left] <= nums[mid]:                   # nửa trái sort
            if nums[left] <= target < nums[mid]:
                right = mid - 1
            else:
                left = mid + 1
        else:                                         # nửa phải sort
            if nums[mid] < target <= nums[right]:
                left = mid + 1
            else:
                right = mid - 1
    return -1

Time O(log n), Space O(1).

Quan trọng — chú ý dấu :
nums[left] ≤ nums[mid] với "≤" để xử lý case mid = left (mảng size 2). Quên dấu = sẽ sai trên test nhỏ.
3.3

3.3 Search Rotated — Trace

nums = [4,5,6,7,0,1,2], target = 0. Kỳ vọng: 4.

Bướclrmidnums[mid]Phân tíchHành động
10637nums[0]=4 ≤ 7 → trái sort. 0 không trong [4,7) → bên phảil=4
24651nums[4]=0 ≤ 1 → trái sort. 0 trong [0,1) → bên tráir=4
34440matchreturn 4

3 bước log₂(7) ≈ 3 — đúng O(log n).

3.4

3.4 Worked Example 6 — Find Minimum in Rotated Sorted Array

Đề (LeetCode #153): Mảng đã rotate, không trùng. Tìm phần tử nhỏ nhất.

Quan sát

Min nằm ở "vết cắt" — vị trí mà nums[i] < nums[i-1]. So sánh nums[mid] với nums[right]:

def findMin(nums):
    left, right = 0, len(nums) - 1
    while left < right:
        mid = (left + right) // 2
        if nums[mid] > nums[right]:
            left = mid + 1
        else:
            right = mid
    return nums[left]

Time O(log n).

Vì sao so sánh với right, không phải left?
So sánh với left không đủ: case không rotate (sorted), nums[mid] luôn ≥ nums[left] → không phân biệt được. So sánh với right tận dụng được tính chất: bên có giá trị nhỏ hơn cuối là bên chứa min.
3.5

3.5 Worked Example 7 — Find Peak Element

Đề (LeetCode #162): Mảng nums. Một "peak" là nums[i] > nums[i-1]nums[i] > nums[i+1]. Trả về index của một peak bất kỳ. Giả sử nums[-1] = nums[n] = -∞. Yêu cầu O(log n).

Trực giác

Mảng không cần sort. Nhưng vẫn áp dụng BS được vì có "monotonicity" cục bộ: nếu nums[mid] < nums[mid+1], bên phải chắc chắn có peak (vì hàm đang đi lên và phải xuống lại sau hữu hạn bước).

def findPeakElement(nums):
    left, right = 0, len(nums) - 1
    while left < right:
        mid = (left + right) // 2
        if nums[mid] < nums[mid + 1]:
            left = mid + 1                # peak bên phải
        else:
            right = mid                   # peak bên trái hoặc tại mid
    return left

Time O(log n).

Bài học mở rộng:
BS không cần "sort toàn cục". Đủ là monotonicity cục bộ hoặc một predicate monoton đảm bảo có thể loại bỏ một nửa không gian.
3.Q

Knowledge Check — BS trên Rotated

Q1: Trong Search in Rotated, vì sao luôn có "ít nhất một nửa sort"?
Mảng rotate có chính xác 1 vết cắt (giữa max và min). Khi chia tại mid, vết cắt chỉ thuộc 1 trong 2 nửa → nửa kia liên tục sort. Đây là tính chất mấu chốt cho phép BS hoạt động.
Q2: Trong Find Peak Element, tại sao không cần mảng sort?
BS làm việc trên không gian có "decision predicate monoton". Ở đây predicate là "đi lên hay đi xuống". Nếu đi lên ở mid, bên phải sẽ có peak vì nums[n] = -∞ buộc hàm phải xuống lại.

Phần 4. Binary Search on Answer Space

Pattern nâng cao nhất — biến bài "tối ưu" thành "kiểm tra"
4.1

4.1 Pattern: Parametric Binary Search

Ý tưởng cốt lõi:
Khi bài hỏi "tìm giá trị nhỏ nhất/lớn nhất X sao cho có thể làm Y", thay vì đi tìm X trực tiếp, ta đoán X rồi kiểm tra "với X này có làm được Y không?". Predicate canDo(X) phải monoton — nếu canDo(X) = True thì canDo(X+1) cũng True (hoặc ngược lại).

Tín hiệu nhận biết

Khi cả ba có → BS trên answer space.

4.2

4.2 Template Parametric BS

def parametric_bs(low, high, can_do):
    """Tìm X nhỏ nhất sao cho can_do(X) == True."""
    while low < high:
        mid = (low + high) // 2
        if can_do(mid):
            high = mid                # mid khả thi → cố nhỏ hơn
        else:
            low = mid + 1             # mid không khả thi → phải lớn hơn
    return low

Quy trình thiết kế

  1. Xác định biên answer space: low = giá trị nhỏ nhất khả dĩ; high = giá trị lớn nhất.
  2. Viết hàm can_do(X): cho X này, có làm được không? Kiểm tra monoton (vẽ trên giấy: với X tăng thì can_do từ False/True đến True/True).
  3. Áp dụng template.
Phần khó nhất:
Không phải BS — mà là thiết kế can_do và xác định biên. BS chỉ là "vỏ ngoài" gọi can_do nhiều lần. Trong phỏng vấn, dành phần lớn thời gian giải thích can_do, không phải template BS.
4.3

4.3 Worked Example 8 — Koko Eating Bananas

Đề (LeetCode #875): Koko có piles[i] chuối ở đống thứ i. Mỗi giờ ăn k quả từ một đống (nếu đống < k, chỉ ăn hết đống). Bảo vệ về sau h giờ. Tìm k nhỏ nhất để ăn hết trước khi bảo vệ về.

Phân tích

import math
def minEatingSpeed(piles, h):
    def can_do(k):
        return sum(math.ceil(p / k) for p in piles) <= h

    low, high = 1, max(piles)
    while low < high:
        mid = (low + high) // 2
        if can_do(mid):
            high = mid
        else:
            low = mid + 1
    return low

Time O(n log(max(piles))).

4.4

4.4 Koko — Trace

piles = [3, 6, 7, 11], h = 8. Kỳ vọng: 4.

Answer space: [1, 11].

BướclowhighmidTổng giờ với k=midcan_do?Hành động
111161+1+2+2 = 66 ≤ 8 ✓high=6
21631+2+3+4 = 1010 ≤ 8 ✗low=4
34651+2+2+3 = 88 ≤ 8 ✓high=5
44541+2+2+3 = 88 ≤ 8 ✓high=4
44thoát, trả 4

Kết quả: k = 4. Chỉ tốn ~log₂(11) ≈ 4 lần can_do call.

4.5

4.5 Worked Example 9 — Capacity to Ship Packages Within D Days

Đề (LeetCode #1011): Mảng weights, số ngày days. Tàu phải chở hàng theo thứ tự, mỗi ngày chở liên tiếp. Tìm capacity nhỏ nhất.

Phân tích

def shipWithinDays(weights, days):
    def can_do(cap):
        d = 1
        cur = 0
        for w in weights:
            if cur + w > cap:
                d += 1
                cur = 0
            cur += w
        return d <= days

    low, high = max(weights), sum(weights)
    while low < high:
        mid = (low + high) // 2
        if can_do(mid):
            high = mid
        else:
            low = mid + 1
    return low

Time O(n log(sum)).

4.6

4.6 Worked Example 10 — Median of Two Sorted Arrays (Hard)

Đề (LeetCode #4, Hard): Hai mảng sort nums1, nums2 tổng size n+m. Tìm median. Yêu cầu O(log(n+m)).

Ý tưởng — BS trên "đường chia"

Đặt i là số phần tử lấy từ nums1 cho nửa trái. Khi đó j = (n+m+1)/2 − i phần tử từ nums2. Cần tìm i sao cho nums1[i-1] ≤ nums2[j]nums2[j-1] ≤ nums1[i] — đảm bảo nửa trái ≤ nửa phải.

def findMedianSortedArrays(A, B):
    if len(A) > len(B): A, B = B, A         # đảm bảo BS trên array nhỏ
    n, m = len(A), len(B)
    half = (n + m + 1) // 2
    low, high = 0, n
    while low <= high:
        i = (low + high) // 2
        j = half - i
        Aleft  = A[i-1] if i > 0 else float('-inf')
        Aright = A[i]   if i < n else float('inf')
        Bleft  = B[j-1] if j > 0 else float('-inf')
        Bright = B[j]   if j < m else float('inf')
        if Aleft <= Bright and Bleft <= Aright:
            if (n + m) % 2:
                return max(Aleft, Bleft)
            return (max(Aleft, Bleft) + min(Aright, Bright)) / 2
        elif Aleft > Bright:
            high = i - 1
        else:
            low = i + 1
4.7

4.7 Median of Two Sorted Arrays — Phân tích

Vì sao BS trên array nhỏ?

Để đảm bảo j = half − i luôn hợp lệ (≥ 0). Nếu n < m, biên của i ∈ [0, n] hẹp hơn ⟹ j ≥ 0 luôn được đảm bảo.

Trực giác đường chia

Hình dung "đường thẳng" chia mảng tổng thành 2 nửa bằng nhau. Đường này đi qua nums1 ở vị trí i và nums2 ở vị trí j. Median sẽ ở chính giữa khi:

$$ \max(\text{Aleft}, \text{Bleft}) \leq \min(\text{Aright}, \text{Bright}) $$

Vì sao "+1" trong half?

(n + m + 1) // 2 đảm bảo nửa trái có ≥ nửa phải 0 hoặc 1 phần tử. Khi tổng lẻ, median = max nửa trái. Khi tổng chẵn, median = average của max nửa trái và min nửa phải.

Time O(log min(n, m)). Đây là một trong những bài Hard kinh điển nhất — sinh viên hiểu được = đã làm chủ BS.

Lời khuyên:
Đừng tự code bài này từ đầu trong phỏng vấn. Hãy giải thích ý tưởng đường chia, viết code khung, và trao đổi với phỏng vấn viên về các edge case. Bài này test "thinking process" hơn là "memorisation".
4.Q

Knowledge Check — BS on Answer Space

Q1: Trong Parametric BS, điều kiện gì PHẢI có để pattern hoạt động?
Đây là điều kiện then chốt. Không có monoton thì không thể "loại bỏ một nửa" — pattern không áp dụng. Verify monoton là bước quan trọng nhất khi thiết kế parametric BS.
Q2: Trong Capacity to Ship Packages, vì sao low = max(weights) chứ không phải 1?
Biên answer space phải hợp lý. Cap nhỏ hơn max(weights) là vô lý → loại bỏ trước. Đây là kỹ năng "tighten the search space" — quan trọng để BS chạy nhanh và đúng.
Q3: Trong Median of Two Sorted Arrays, vì sao luôn BS trên mảng NHỎ hơn?
Khi n ≤ m và i ∈ [0, n], j = half − i luôn hợp lệ. Nếu BS trên mảng lớn hơn, j có thể âm hoặc vượt biên → bug. Đây là edge case dễ quên nhất của bài.

Phần 5. Tổng hợp

Decision tree và bài về nhà
5.1

5.1 Decision Tree — Khi nào dùng Binary Search?

Tín hiệu trong đềBiến thể BS
"Mảng sort, tìm phần tử"Classic BS
"Vị trí đầu/cuối", "đếm số lần"Lower/Upper bound
"Vị trí chèn"Lower bound
"Mảng rotated", "tìm peak"BS trên monotonicity cục bộ
"Sqrt(x)", "căn bậc k"BS trên answer space [0, x]
"Min X sao cho có thể Y"Parametric BS
"Constraint n ≤ 10⁹"BS trên answer space (gần như chắc chắn)
"Median of two sorted arrays"BS trên đường chia

Ba câu hỏi check trước khi code

  1. Không gian tìm kiếm là gì? (mảng / answer space / 2D matrix)
  2. Predicate monoton như thế nào? (F→T hay T→F?)
  3. Biên low/high tighten đến mức nào hợp lý?
5.2

Tổng kết Tuần 7

Đã học

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

  1. LeetCode #704 — Binary Search
  2. LeetCode #34 — Find First and Last Position
  3. LeetCode #35 — Search Insert Position
  4. LeetCode #69 — Sqrt(x)
  5. LeetCode #33 — Search in Rotated Sorted Array
  6. LeetCode #153 — Find Minimum in Rotated Sorted Array
  7. LeetCode #162 — Find Peak Element
  8. LeetCode #875 — Koko Eating Bananas
  9. LeetCode #1011 — Capacity to Ship Packages Within D Days
  10. LeetCode #410 — Split Array Largest Sum (parametric BS)
  11. LeetCode #4 — Median of Two Sorted Arrays (Hard)
  12. LeetCode #74 — Search a 2D Matrix

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

Recursion & Tree — DFS, Tree Traversal, Divide & Conquer

Cấu trúc cây và đệ quy đi đôi với nhau. Học ba phép duyệt (preorder, inorder, postorder), pattern "đệ quy trả về thông tin tổng hợp", áp dụng vào bài như Maximum Depth, Diameter, Lowest Common Ancestor, Path Sum. Kỹ thuật Divide & Conquer (Merge Sort, Quick Sort) cũng được giới thiệu để chuẩn bị cho Tuần 9 (Heap) và Tuần 12 (DP).

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

Mục lục

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