Nhập môn

Thuật toán Chia để trị (Divide and Conquer): mô hình 3 bước, phân biệt với QHĐ và code C++, Python

Thuật toán chia để trị (Divide and Conquer) giải quyết một bài toán lớn kích thước `N` bằng cách tách nó thành các bài toán con nhỏ hơn cùng dạng nhưng hoàn toàn độc lập, giải đệ quy từng bài toán con rồi gộp các kết quả lại. Trong các ví dụ chia đôi cân bằng, cây đệ quy cao `O(log N)`. Tổng thời gian còn phụ thuộc số nhánh phải giải và chi phí gộp; chia để trị không tự động bảo đảm nhanh hơn.

Ý chính
  • Mọi thuật toán Chia để trị (Divide and Conquer) đều xoay quanh 3 bước: Chia (Divide) bài toán thành các phần nhỏ độc lập → Trị (Conquer) giải đệ quy từng phần tới trường hợp cơ sở → Gộp (Combine) nghiệm các phần lại.
  • Khác biệt cốt lõi với Quy hoạch động: trong Chia để trị, các bài toán con rời nhau (không trùng lặp) nên không cần mảng nhớ; còn trong Quy hoạch động, các bài toán con gối lên nhau nên phải lưu bảng để tránh tính lại.
  • Khi chia đôi bài toán cỡ N thành 2 bài toán con cỡ N / 2 và tốn O(N) để gộp (T(N) = 2T(N/2) + O(N)), độ phức tạp tổng thể là O(N log N).
  • Nếu mỗi bước chia đôi chỉ cần giải 1 nhánh duy nhất (T(N) = T(N/2) + O(1)), độ phức tạp giảm xuống chỉ còn O(log N).

Mô hình 3 bước kinh điển của Chia để trị (Divide – Conquer – Combine)

Khi một bài toán trên mảng N = 100.000 phần tử không thể chạy kịp bằng vét cạn O(N²), một hướng tiếp cận đáng xét là Chia để trị (Divide and Conquer). Mọi hàm đệ quy chia để trị trên đoạn a[lo .. hi] đều tuân theo khung thiết kế 3 bước:

Bài toán gốc kích thước N: a[lo .. hi] 1. CHIA (Divide) Nửa trái: a[lo .. mid] Nửa phải: a[mid+1 .. hi] 2. TRỊ (Conquer) Giải đệ quy nửa trái Thu được nghiệm: tot_trai Giải đệ quy nửa phải Thu được nghiệm: tot_phai 3. GỘP (Combine) Kết hợp nghiệm hai nửa + phần vắt ngang qua mid T(N) = 2 · T(N / 2) + O(N) ⟹ Độ phức tạp: O(N log N)
Bài toán gốc cỡ N: a[lo .. hi] 1. CHIA: Nửa trái a[lo .. mid] 1. CHIA: Nửa phải a[mid+1 .. hi] 2. TRỊ: Đệ quy trái Nghiệm tot_trai 2. TRỊ: Đệ quy phải Nghiệm tot_phai 3. GỘP (Combine) Gộp tot_trai, tot_phai và phần qua mid T(N) = 2T(N/2) + O(N) = O(N log N)
Hình 1. Sơ đồ 3 bước Chia (Divide) – Trị (Conquer) – Gộp (Combine) trên đoạn a[lo .. hi] và công thức độ phức tạp O(N log N).
  1. Bước 1 — Chia (Divide): Tìm điểm chính giữa mid = lo + (hi - lo) / 2 để tách đoạn a[lo .. hi] cỡ N thành hai đoạn con độc lập là nửa trái a[lo .. mid] và nửa phải a[mid + 1 .. hi], mỗi nửa cỡ N / 2.
  2. Bước 2 — Trị (Conquer): Gọi đệ quy giải bài toán trên nửa trái và nửa phải. Quá trình chia dừng lại ở trường hợp cơ sở (base case) — thường là khi đoạn chỉ còn 1 phần tử (lo == hi).
  3. Bước 3 — Gộp (Combine): Kết hợp nghiệm của nửa trái, nghiệm của nửa phải (cộng thêm các phương án vắt ngang qua ranh giới mid nếu có) để thu được nghiệm của cả đoạn a[lo .. hi].

Khi chia đôi liên tiếp, cây đệ quy chỉ cao log₂ N tầng; nếu việc gộp ở mỗi tầng tốn tổng cộng O(N) phép tính thì thời gian thực thi toàn cục là O(N log N).

Hệ thống hóa các thuật toán Chia để trị nền tảng

Tùy vào việc thuật toán đặt phần việc chính ở bước Chia hay bước Gộp, các thuật toán Chia để trị tiêu biểu có đặc trưng như sau:

Thuật toán Bước Chia (Divide) Bước Trị (Conquer) Bước Gộp (Combine) Công thức và Độ phức tạp
Tìm kiếm nhị phân So sánh x với a[mid] trong O(1) Chỉ gọi đệ quy 1 nửa chứa x Không cần gộp (O(1)) T(N) = T(N/2) + O(1) → O(log N)
Lũy thừa nhị phân Chia đôi số mũ b thành b / 2 Tính đệ quy 1 lần half = a^(b/2) Nhân half × half trong O(1) T(b) = T(b/2) + O(1) → O(log b)
Sắp xếp trộn (Merge Sort) Cắt đôi mảng tại mid trong O(1) Gọi đệ quy sắp xếp cả 2 nửa Trộn hai nửa tăng dần trong O(N) T(N) = 2T(N/2) + O(N) → O(N log N)
Sắp xếp nhanh (Quick Sort) Phân hoạch quanh chốt pivot trong O(N) Gọi đệ quy sắp xếp 2 nửa Không cần gộp (O(1)) Phân hoạch cân bằng: 2T(N/2) + O(N) → O(N log N); xấu nhất O(N²)

Bạn có thể thấy: Merge Sort chia rất nhẹ (O(1)) nhưng dồn việc nặng sang bước Gộp (O(N)); ngược lại Quick Sort dồn việc nặng vào bước Chia (O(N)) để bước Gộp bằng 0.

Ví dụ thực chiến 1: Tìm đoạn con liên tiếp có tổng lớn nhất

Quy ước đoạn cần chọn không rỗng và mảng có ít nhất một phần tử. Do đó mảng toàn âm phải trả phần tử lớn nhất, không phải 0.

Xét bài toán Maximum Subarray Sum: cho mảng a = [-2, 1, -3, 4, -1, 2, 1, -5, 4], hãy tìm đoạn con liên tiếp a[u .. v] có tổng lớn nhất.

Khi chia đôi đoạn a[lo .. hi] tại mid = (lo + hi) / 2, mọi đoạn con liên tiếp a[u .. v] phải thuộc đúng 1 trong 3 vị trí:

  1. Nằm hoàn toàn bên nửa trái (lo ≤ u ≤ v ≤ mid): kết quả đệ quy tot_trai.
  2. Nằm hoàn toàn bên nửa phải (mid + 1 ≤ u ≤ v ≤ hi): kết quả đệ quy tot_phai.
  3. Vắt ngang qua ranh giới giữa (u ≤ mid và v ≥ mid + 1): đoạn này bắt buộc chứa cả a[mid] và a[mid + 1], nên nó là ghép nối của hậu tố có tổng lớn nhất kết thúc tại mid (max_hau_to_trai, tìm bằng cách quét ngược từ mid về lo) với tiền tố có tổng lớn nhất bắt đầu từ mid + 1 (max_tien_to_phai, quét xuôi từ mid + 1 đến hi). Tổng tốt nhất vắt ngang là tot_giua = max_hau_to_trai + max_tien_to_phai.

Ở bước Gộp, ta trả về max({tot_trai, tot_phai, tot_giua}). Trên mảng ví dụ (mid = 4), hậu tố trái tốt nhất là [4, -1] (tổng 3), tiền tố phải tốt nhất là [2, 1] (tổng 3), ghép lại được đoạn [4, -1, 2, 1] có tổng lớn nhất bằng 6:

#include <algorithm>
#include <climits>
#include <iostream>
#include <vector>
using namespace std;

// Tìm tổng đoạn con liên tiếp lớn nhất trong đoạn a[lo .. hi] bằng Chia để trị O(n log n)
long long max_subarray_dac(const vector<long long>& a, int lo, int hi) {
    // Trường hợp cơ sở: đoạn chỉ có 1 phần tử
    if (lo == hi) {
        return a[lo];
    }
    int mid = lo + (hi - lo) / 2;

    // 1 & 2. Chia (Divide) và Trị (Conquer) hai nửa độc lập
    long long tot_trai = max_subarray_dac(a, lo, mid);
    long long tot_phai = max_subarray_dac(a, mid + 1, hi);

    // 3. Gộp (Combine): Tìm đoạn con tốt nhất vắt ngang qua ranh giới mid và mid + 1
    long long max_hau_to_trai = LLONG_MIN, tong_trai = 0;
    for (int i = mid; i >= lo; --i) {
        tong_trai += a[i];
        max_hau_to_trai = max(max_hau_to_trai, tong_trai);
    }

    long long max_tien_to_phai = LLONG_MIN, tong_phai = 0;
    for (int j = mid + 1; j <= hi; ++j) {
        tong_phai += a[j];
        max_tien_to_phai = max(max_tien_to_phai, tong_phai);
    }

    long long tot_giua = max_hau_to_trai + max_tien_to_phai;
    return max({tot_trai, tot_phai, tot_giua});
}

int main() {
    // Test 1: Mảng kinh điển [-2, 1, -3, 4, -1, 2, 1, -5, 4] -> đoạn [4, -1, 2, 1] có tổng = 6
    vector<long long> a1 = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
    cout << "MaxSub1: " << max_subarray_dac(a1, 0, (int)a1.size() - 1) << "\n";

    // Test 2: Mảng [5, 4, -1, 7, 8] -> toàn bộ mảng có tổng = 23
    vector<long long> a2 = {5, 4, -1, 7, 8};
    cout << "MaxSub2: " << max_subarray_dac(a2, 0, (int)a2.size() - 1) << "\n";

    // Test 3: Mảng toàn số âm [-8, -3, -6, -2, -5] -> chọn phần tử lớn nhất = -2
    vector<long long> a3 = {-8, -3, -6, -2, -5};
    cout << "MaxSub3: " << max_subarray_dac(a3, 0, (int)a3.size() - 1) << "\n";

    return 0;
}
Học cùng giảng viên

Chủ đề này nằm trong Lecture 04: Divide and Conquer (Chia để trị) của Khóa Orange. Xem đề cương

Ví dụ thực chiến 2: Đếm số cặp nghịch thế trong O(N log N)

Một cặp chỉ số (i, j) với i đứng trước j nhưng a[i] lớn hơn a[j] là một cặp nghịch thế (xem thêm bài Sắp xếp nổi bọt). Dựa trên khung trộn của Merge Sort, ta đếm toàn bộ cặp nghịch thế trong O(N log N):

  • Chia & Trị: Đếm đệ quy số cặp nghịch thế trong nửa trái và nửa phải, đồng thời sắp xếp mỗi nửa tăng dần.
  • Gộp: Khi trộn hai nửa đã tăng dần bằng hai con trỏ i (nửa trái) và j (nửa phải), nếu gặp a[i] lớn hơn a[j] thì vì nửa trái đã tăng dần, mọi phần tử từ a[i] đến a[mid] đều lớn hơn a[j]! Ta cộng ngay dem += (mid - i + 1) trong O(1).
#include <iostream>
#include <vector>
using namespace std;

// Đếm số cặp (i < j) mà a[i] > a[j] trong đoạn a[lo .. hi] bằng Chia để trị O(n log n)
long long dem_cap_lon_hon_dac(vector<int>& a, int lo, int hi) {
    if (lo >= hi) return 0;
    int mid = lo + (hi - lo) / 2;

    // 1 & 2. Đếm đệ quy ở nửa trái và nửa phải
    long long dem = dem_cap_lon_hon_dac(a, lo, mid)
                  + dem_cap_lon_hon_dac(a, mid + 1, hi);

    // 3. Gộp hai nửa đã tăng dần và đếm các cặp vắt ngang (i ở nửa trái, j ở nửa phải)
    vector<int> tam;
    tam.reserve(hi - lo + 1);
    int i = lo, j = mid + 1;
    while (i <= mid && j <= hi) {
        if (a[i] <= a[j]) {
            tam.push_back(a[i++]);
        } else {
            // Vì nửa trái đã tăng dần, mọi phần tử từ a[i .. mid] đều lớn hơn a[j]
            dem += (mid - i + 1);
            tam.push_back(a[j++]);
        }
    }
    while (i <= mid) tam.push_back(a[i++]);
    while (j <= hi) tam.push_back(a[j++]);
    for (int k = lo; k <= hi; ++k) a[k] = tam[k - lo];
    return dem;
}

int main() {
    vector<vector<int>> cac_mang = {
        {5, 1, 4, 2, 8},  // 4 cặp: (5,1), (5,4), (5,2), (4,2)
        {8, 4, 2, 1},     // 6 cặp
        {1, 3, 5, 7}      // 0 cặp
    };

    for (auto a : cac_mang) {
        cout << "Inversions: " << dem_cap_lon_hon_dac(a, 0, (int)a.size() - 1) << "\n";
    }
    return 0;
}

Phân biệt Chia để trị và Quy hoạch động (Dynamic Programming)

Cả Chia để trị và Quy hoạch động đều tách bài toán lớn thành bài toán con, nhưng khác nhau ở tính độc lập của các bài toán con:

Tiêu chí đối chiếu Chia để trị (Divide and Conquer) Quy hoạch động (Dynamic Programming)
Quan hệ giữa các bài toán con Độc lập, rời nhau (nửa trái a[0..3] và nửa phải a[4..7] không giao nhau) Gối lên nhau, lặp lại nhiều lần (F(5) gọi F(4), F(3), rồi F(4) gọi lại F(3))
Có cần bảng nhớ (DP Table) không? Không cần, mỗi bài toán con chỉ giải đúng 1 lần Lưu kết quả trạng thái, có thể chỉ giữ vài giá trị nếu quan hệ phụ thuộc cho phép
Ví dụ tiêu biểu Merge Sort, Quick Sort, Tìm kiếm nhị phân Dãy Fibonacci, Bài toán cái túi (Knapsack), Dãy con tăng dài nhất

Lỗi hay gặp khi thiết kế hàm Chia để trị

Cẩn thận
  • Quên xét phương án vắt ngang qua mid ở bước Gộp. Nếu chỉ lấy max(tot_trai, tot_phai) mà bỏ quên tot_giua, thuật toán sẽ bỏ sót mọi nghiệm đi qua tâm mảng.
  • Chia khoảng đệ quy gây lặp vô hạn. Với mid = lo + (hi - lo) / 2 (làm tròn xuống), hai nhánh đệ quy bắt buộc là (lo, mid) và (mid + 1, hi). Nếu viết (lo, mid - 1) và (mid, hi), khi lo = 0, hi = 1 thì mid = 0 và nhánh phải gọi lại (0, 1) vô hạn.
  • Truyền mảng theo giá trị (pass-by-value) vào hàm đệ quy. Sao chép cả mảng ở mỗi lời gọi tốn O(N), làm hỏng độ phức tạp. Hãy truyền tham chiếu const vector<long long>& a kèm chỉ số lo, hi.

Luyện ngay trên Big-O Coding

Thực hành ngay tư duy chia đôi bài toán và kết hợp nghiệm trên hệ thống Big-O Coding:

Câu hỏi thường gặp

Bài toán tìm đoạn con có tổng lớn nhất có cách nào nhanh hơn Chia để trị O(N log N) không?

Có, thuật toán Kadane giải trong O(N). Tuy nhiên, tư duy Chia để trị (hợp nhất thông tin từ nửa trái và nửa phải) lại là nền tảng trực tiếp để xây dựng Cây phân đoạn (Segment Tree) khi bài toán yêu cầu vừa cập nhật phần tử vừa truy vấn đoạn con có tổng lớn nhất trong O(log N).

Vì sao Tìm kiếm nhị phân chỉ đi vào 1 nhánh mà vẫn xếp vào Chia để trị?

Tìm kiếm nhị phân và Lũy thừa nhị phân là trường hợp đặc biệt (còn gọi là Giảm để trị — Decrease and Conquer) khi ở bước Chia ta loại bỏ được hẳn một nửa và chỉ cần trị nửa còn lại với bước Gộp O(1).

Khi nào KHÔNG nên dùng Chia để trị?

Khi các bài toán con bị trùng lặp nhiều lần (cần dùng Quy hoạch động) hoặc khi bước Gộp tốn tới O(N²) không nhanh hơn cách giải trực tiếp.

Muốn học bài bản cùng giảng viên?

Khóa Orange: làm chủ thuật toán nâng cao như Graph, DP tối ưu, Segment Tree, HLD để giải bài Hard. 16 buổi online, C++/Java/Python, học thử buổi 1 miễn phí.

Xem Khóa Orange

Nguồn tham khảo

Chia sẻ: Facebook LinkedIn
Nhắn Zalo tư vấnZalo Gọi 0937 401 483