Chương 1·Bài 1.2

Divide and Conquer

Hai nửa của một dãy đã được sắp tăng riêng. Muốn cả dãy tăng, chỉ cần đi một lượt, mỗi bước lấy số nhỏ hơn trong hai số đứng đầu hai nửa. Còn sắp mỗi nửa thế nào? Dùng lại đúng cách ấy trên nửa nhỏ hơn, cho tới khi đoạn chỉ còn một số.

Đó là chia để trị (divide and conquer): đưa bài toán về các bài con cùng dạng nhưng nhỏ hơn, giải từng bài con bằng đệ quy, rồi gộp lời giải lại. Cùng khuôn này cho ra lũy thừa nhanh, sắp xếp trộn, đoạn con lớn nhất, cặp điểm gần nhất, và sẽ gặp lại ở cây phân đoạn (bài 1.8) cùng CDQ (bài 6.1).

Chia, trị, gộp

Một thuật toán chia để trị gồm ba việc.

  • Chia (divide): đưa bài toán cỡ nn về vài bài con cùng dạng, cỡ nhỏ hơn.
  • Trị (conquer): giải từng bài con bằng chính thuật toán đang viết, tức đệ quy. Bài đủ nhỏ thì giải thẳng; đó là điều kiện dừng.
  • Gộp (combine): ghép lời giải các bài con thành lời giải của bài ban đầu.

Ví dụ ngắn nhất là lũy thừa nhanh (fast exponentiation, bài 0.9 viết bằng vòng lặp): tính xn mod mx^n \bmod m với nn tới 101810^{18}. Nhân từng thừa số cần n−1n - 1 phép nhân, không thể kịp. Nhưng xn=(xn/2)2x^n = (x^{n/2})^2 khi nn chẵn và xn=x⋅(x(n−1)/2)2x^n = x \cdot (x^{(n-1)/2})^2 khi nn lẻ. Chia là đổi nn thành ⌊n/2⌋\lfloor n/2 \rfloor; trị là tính x⌊n/2⌋x^{\lfloor n/2 \rfloor} bằng đệ quy; gộp là bình phương kết quả và nhân thêm xx nếu nn lẻ; điều kiện dừng là n=0n = 0, khi đó x0=1x^0 = 1.

Chạy tay với x=3x = 3, n=13n = 13. Các lời gọi đi xuống theo 13→6→3→1→013 \to 6 \to 3 \to 1 \to 0, rồi kết quả đi ngược lên: 30=13^0 = 1, 31=33^1 = 3, 33=3⋅32=273^3 = 3 \cdot 3^2 = 27, 36=272=7293^6 = 27^2 = 729, 313=3⋅7292=1 594 3233^{13} = 3 \cdot 729^2 = 1\,594\,323. Chỉ 4 phép bình phương và 3 phép nhân thêm, thay cho 12 phép nhân; tổng quát là khoảng 2log⁡2n2\log_2 n thay vì n−1n - 1. Gọi T(n)T(n) là thời gian chạy với số mũ nn. Mỗi lời gọi làm O(1)O(1) việc rồi gọi một lần với số mũ nửa, nên T(n)=T(n/2)+O(1)T(n) = T(n/2) + O(1): có khoảng log⁡2n\log_2 n lời gọi, tức O(log⁡n)O(\log n).

Điểm mấu chốt là kết quả x⌊n/2⌋x^{\lfloor n/2 \rfloor} được tính một lần rồi lưu vào biến: chia để trị chỉ có lợi khi các bài con độc lập và mỗi bài con được giải một lần. Nếu các bài con chồng lên nhau thì đệ quy trần giải đi giải lại cùng một bài; viết F(n)=F(n−1)+F(n−2)F(n) = F(n-1) + F(n-2) thành hai lời gọi cho thời gian tăng theo cấp số nhân, và chỗ của kiểu bài đó là quy hoạch động (bài 1.3).

Hỏi nhanh

Một bạn viết lũy thừa nhanh như sau: khi nn chẵn thì return power(x, n / 2) * power(x, n / 2) % m, gọi hai lần cho cùng một giá trị. Kết quả vẫn đúng. Thời gian chạy theo nn là bao nhiêu?

Cây đệ quy

Giả sử mỗi lời gọi cỡ nn sinh aa bài con cỡ n/bn/b và phần việc riêng của nó (chia cộng gộp) tốn f(n)f(n). Khi đó thời gian chạy T(n)T(n) thỏa hệ thức truy hồi

T(n)=a T(n/b)+f(n).T(n) = a\,T(n/b) + f(n).

Cách giải thực dụng nhất là vẽ cây đệ quy và cộng chi phí theo từng mức. Mức ii có aia^i nút, mỗi nút cỡ n/bin/b^i nên cả mức tốn aif(n/bi)a^i f(n/b^i); cây dừng ở mức log⁡bn\log_b n, số lần chia nn cho bb tới khi còn 1 (giống log⁡2n\log_2 n ở bài 0.4, chỉ thay 2 bằng bb). Với sắp xếp trộn, a=b=2a = b = 2 và f(n)=cnf(n) = cn vì gộp hai nửa tốn O(n)O(n): mức ii có 2i2^i nút cỡ n/2in/2^i nên tốn cncn, mọi mức bằng nhau. Có log⁡2n+1\log_2 n + 1 mức, vậy T(n)=O(nlog⁡n)T(n) = O(n \log n).

Đổi a, b, k: thanh ngang là chi phí của từng mức trong cây đệ quy.

Khi f(n)=c nkf(n) = c\,n^k, chi phí mức ii là c nkqic\,n^k q^i với q=a/bkq = a / b^k, nên chi phí mỗi mức gấp qq lần mức ngay trên nó, và T(n)T(n) là tổng của log⁡bn+1\log_b n + 1 mức như vậy. Có ba khả năng.

  • q<1q < 1: các mức giảm dần, tổng cỡ số hạng đầu, T(n)=O(nk)T(n) = O(n^k). Việc ở gốc quyết định.
  • q=1q = 1: mọi mức bằng nhau, T(n)=O(nklog⁡n)T(n) = O(n^k \log n).
  • q>1q > 1: các mức tăng dần, tổng cỡ mức cuối, tức số lá của cây vì mỗi lá tốn O(1)O(1). Chẳng hạn a=4a = 4, b=2b = 2: mỗi mức số nút gấp 4, có log⁡2n\log_2 n mức nên có 4log⁡2n=n24^{\log_2 n} = n^2 lá và T(n)=O(n2)T(n) = O(n^2). Tổng quát số lá là nlog⁡ban^{\log_b a}. Việc ở các lá quyết định.

Đó là định lý thợ (master theorem) cho dạng hay gặp; bản đầy đủ cho ff tùy ý xem ở phần Đọc thêm. Các hệ thức sẽ gặp trong bài:

Hệ thức qq Thời gian Gặp ở đâu
T(n)=T(n/2)+O(1)T(n) = T(n/2) + O(1) 11 O(log⁡n)O(\log n) lũy thừa nhanh, chặt nhị phân
T(n)=2T(n/2)+O(1)T(n) = 2T(n/2) + O(1) 22 O(n)O(n) gọi đôi lũy thừa nhanh; đoạn con lớn nhất với bốn số
T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n) 11 O(nlog⁡n)O(n \log n) sắp xếp trộn, nghịch thế, cặp điểm gần nhất
T(n)=4T(n/2)+O(1)T(n) = 4T(n/2) + O(1) 44 O(n2)O(n^2) chia bảng thành bốn phần tư, nn là cạnh
T(n)=T(n/2)+O(n)T(n) = T(n/2) + O(n) 12\tfrac12 O(n)O(n) quickselect khi pivot luôn chia đôi
T(n)=3T(n/2)+O(n)T(n) = 3T(n/2) + O(n) 32\tfrac32 O(nlog⁡23)≈O(n1,58)O(n^{\log_2 3}) \approx O(n^{1{,}58}) nhân hai số lớn kiểu Karatsuba

Cây chỉ đẹp như vậy khi các bài con co lại theo tỉ lệ. Nếu mỗi lần chỉ bớt một phần tử thì T(n)=T(n−1)+O(n)=O(n2)T(n) = T(n-1) + O(n) = O(n^2) và cây sâu nn mức, có thể tràn ngăn xếp; đó là trường hợp xấu nhất của quicksort. Chia đôi theo chỉ số luôn cân bằng (chỉ sâu log⁡2n≈18\log_2 n \approx 18 mức với n=2⋅105n = 2 \cdot 10^5); chia theo giá trị quanh một pivot thì cân bằng tùy dữ liệu, vì thế mới cần pivot ngẫu nhiên.

Hỏi nhanh

Một chương trình kiểu quicksort luôn lấy phần tử đầu của đoạn làm pivot, chia các phần tử còn lại thành nhóm nhỏ hơn pivot và nhóm không nhỏ hơn pivot, rồi đệ quy vào cả hai nhóm. Dãy vào đã sắp tăng, n=2⋅105n = 2 \cdot 10^5. Số phép so sánh cỡ bao nhiêu?

Ví dụĐộ Sâu Theo Cực Đại

Đề. Cho một hoán vị a1,…,ana_1, \dots, a_n (n≤100n \le 100, tối đa 100 test). Gắn nó với cây nhị phân: phần tử lớn nhất là gốc, phần bên trái nó tạo cây con trái, phần bên phải tạo cây con phải, theo cùng quy tắc. In độ sâu của từng phần tử theo thứ tự trong hoán vị, gốc có độ sâu 0.

Lời giải. Định nghĩa đã là một lời gọi đệ quy: trên đoạn [l,r)[l, r), tìm vị trí pp của số lớn nhất, gán độ sâu hiện tại cho pp, rồi giải [l,p)[l, p) và [p+1,r)[p + 1, r) với độ sâu tăng 1; đoạn rỗng thì dừng. Điểm chia là vị trí số lớn nhất chứ không phải điểm giữa, nên cây không cân bằng: với hoán vị tăng dần, mỗi lần chỉ tách được một phần tử và T(n)=T(n−1)+O(n)=O(n2)T(n) = T(n - 1) + O(n) = O(n^2), đúng trường hợp xấu đã nói. Với n≤100n \le 100 chuyện đó chẳng sao, nhưng với n=105n = 10^5 thì cần cách khác (bài 1.8).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll t, n, a[105], d[105];

void go(ll l, ll r, ll dep) {
    if (l >= r) return;
    ll p = l;
    for (ll i = l + 1; i < r; i++)
        if (a[i] > a[p]) p = i;
    d[p] = dep;
    go(l, p, dep + 1);
    go(p + 1, r, dep + 1);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> t;
    while (t--) {
        cin >> n;
        for (ll i = 0; i < n; i++) cin >> a[i];
        go(0, n, 0);
        for (ll i = 0; i < n; i++) cout << d[i] << ' ';
        cout << '\n';
    }
}
1
5
2 5 1 4 3

Sắp xếp trộn

Bài toán: sắp nn số tăng dần bằng sắp xếp trộn (merge sort). Gộp hai dãy đã sắp thành một dãy sắp chỉ cần một lượt duyệt, nên ta giao việc sắp mỗi nửa cho đệ quy.

Quy ước chỉ số của cả bài. Mọi đoạn trong đệ quy là đoạn nửa mở [l,r)[l, r), đánh số từ 0: gồm các vị trí l,l+1,…,r−1l, l+1, \dots, r-1, độ dài r−lr - l. Đặt mid=(l+r)/2\mathrm{mid} = (l + r) / 2 thì hai nửa [l,mid)[l, \mathrm{mid}) và [mid,r)[\mathrm{mid}, r) không giao nhau, hợp lại đúng [l,r)[l, r), không phải cộng trừ 1 ở đâu cả. Đoạn dài 0 hoặc 1 đã sắp sẵn: điều kiện dừng là r - l <= 1.

Giả sử hai nửa đã tăng. Đặt hai con trỏ ii, jj vào phần tử đầu chưa lấy của mỗi nửa. Phần tử nhỏ nhất trong mọi phần tử chưa lấy chắc chắn là một trong hai phần tử đó, nên mỗi bước ta so hai phần tử ấy, ghi cái nhỏ hơn vào mảng phụ rồi dịch con trỏ. Khi một nửa hết, chép nguyên phần còn lại của nửa kia. Ví dụ gộp (2,5)(2, 5) với (1,7)(1, 7): lấy 11, rồi 22, rồi 55, nửa trái hết nên chép 77, ra (1,2,5,7)(1, 2, 5, 7).

Chia đôi xuống tới đoạn dài 1, rồi gộp ngược lên; mỗi mức gộp là một lượt qua n phần tử.

Tính đúng chứng minh bằng quy nạp theo độ dài đoạn. Đoạn dài không quá 1 đúng hiển nhiên. Đoạn dài hơn thì hai lời gọi con sắp đúng hai nửa, và bước gộp giữ bất biến “sau kk bước, kk phần tử đã ghi là kk phần tử nhỏ nhất của cả đoạn, theo thứ tự tăng”, vì phần tử nhỏ nhất chưa lấy luôn là đầu của một trong hai nửa.

Gộp tốn O(r−l)O(r - l) nên T(n)=2T(n/2)+O(n)=O(nlog⁡n)T(n) = 2T(n/2) + O(n) = O(n \log n), cỡ 3,5⋅1063{,}5 \cdot 10^6 phép toán với n=2⋅105n = 2 \cdot 10^5. Lấy bên trái khi hai phần tử bằng nhau giữ nguyên thứ tự của các phần tử bằng nhau (sắp ổn định).

Mảng phụ khai báo một lần, toàn cục, dùng chung cho mọi lời gọi. Tạo một vector mới trong mỗi lời gọi thì cùng O(nlog⁡n)O(n \log n) nhưng tốn cả triệu lần xin và trả bộ nhớ:

Sắp một triệu số.trộn, vector mới mỗi lời gọi376 ms · gấp 6,3 lầntrộn, mảng phụ toàn cục98 ms · gấp 1,6 lầnstd::sort60 ms
Sắp một triệu số bằng sắp xếp trộn với vector mới mỗi lời gọi, với mảng phụ toàn cục, và bằng std::sort.

Mảng phụ toàn cục nhanh gấp hơn 3 lần, gần bằng sort. Khi chỉ cần sắp xếp thì cứ gọi sort; ta học sắp xếp trộn vì bước gộp là nơi tính thêm những thứ khác ngay lúc ghép hai nửa.

Hỏi nhanh

Một bạn viết điều kiện dừng if (r <= l) return; thay cho if (r - l <= 1) return; trong hàm sắp đoạn nửa mở [l,r)[l, r) với mid=(l+r)/2\mathrm{mid} = (l + r)/2. Chuyện gì xảy ra khi đoạn có đúng một phần tử?

Bài 0.4 đã dùng chính bước gộp này để đếm nghịch thế: mỗi nghịch thế có hai chỉ số cùng ở nửa trái, cùng ở nửa phải, hoặc mỗi chỉ số một nửa; hai loại đầu giao cho đệ quy, loại thứ ba đếm ngay lúc gộp, mỗi khi phần tử nửa phải được lấy ra trước. Cách này còn là điểm khởi đầu của CDQ (bài 6.1).

Cặp cắt ngang

Khuôn “trái, phải, chéo” của đếm nghịch thế dùng được cho nhiều bài về đoạn hay cặp. Bài thứ nhất: cho dãy số nguyên, có thể âm, tìm tổng lớn nhất của một đoạn con liên tiếp không rỗng. Bài này còn có lời giải tuyến tính khác; ta làm bằng chia để trị vì nó dạy đúng khuôn.

Chia dãy tại mid\mathrm{mid}. Mỗi đoạn [i,j][i, j] nằm hẳn trong nửa trái, nằm hẳn trong nửa phải, hoặc cắt ngang đường chia (i<mid≤ji < \mathrm{mid} \le j). Hai loại đầu giao cho đệ quy; bước gộp chỉ lo loại thứ ba. Đoạn cắt ngang là một hậu tố của nửa trái nối với một tiền tố của nửa phải, cả hai không rỗng. Hai phần chọn độc lập, nên ta chọn riêng hậu tố tốt nhất và tiền tố tốt nhất, mỗi thứ bằng một lượt quét từ mid\mathrm{mid} ra một phía.

Có thể bỏ cả lượt quét bằng cách để mỗi lời gọi trả về bốn số mô tả đoạn của nó: tổng cả đoạn, tiền tố tốt nhất, hậu tố tốt nhất và đoạn con tốt nhất. Tiền tố tốt nhất của đoạn gộp nằm trong nửa trái, hoặc là cả nửa trái nối tiền tố tốt nhất của nửa phải; hậu tố đối xứng; đoạn tốt nhất là của nửa trái, của nửa phải, hoặc hậu tố trái nối tiền tố phải:

C++
struct Info {
    ll sum, pre, suf, best;
};

Info combine(Info a, Info b) {
    return {a.sum + b.sum, max(a.pre, a.sum + b.pre), max(b.suf, b.sum + a.suf),
            max({a.best, b.best, a.suf + b.pre})};
}

Gộp tốn O(1)O(1) nên T(n)=2T(n/2)+O(1)=O(n)T(n) = 2T(n/2) + O(1) = O(n). Ý “tóm tắt mỗi đoạn bằng vài con số mà hai bản tóm tắt gộp được trong O(1)O(1)” chính là thứ mỗi nút của cây phân đoạn (bài 1.8) lưu.

Ví dụĐoạn Liên Tiếp Tốt Nhất

Đề. Cho dãy N≤2⋅105N \le 2 \cdot 10^5 số nguyên, ∣ai∣≤109|a_i| \le 10^9. In tổng lớn nhất của một đoạn con liên tiếp không rỗng.

Lời giải. Cài bản quét từ giữa ra. Với a=(−4,6,−2,5,−7,4,3)a = (-4, 6, -2, 5, -7, 4, 3) và mid=3\mathrm{mid} = 3: nửa trái có đáp án riêng 66, nửa phải có đáp án riêng 77 (đoạn 4,34, 3). Hậu tố của nửa trái có tổng −2,4,0-2, 4, 0, tốt nhất 44; tiền tố của nửa phải có tổng 5,−2,2,55, -2, 2, 5, tốt nhất 55. Đoạn cắt ngang tốt nhất 4+5=94 + 5 = 9, đáp án max⁡(6,7,9)=9\max(6, 7, 9) = 9. Hai biến hậu tố, tiền tố tốt nhất khởi tạo bằng −1018-10^{18} chứ không phải 0: khởi tạo 0 là cho phép phần rỗng, và với dãy toàn số âm chương trình sẽ in 0, tổng của một đoạn không tồn tại. Tổng tới 2⋅10142 \cdot 10^{14} nên dùng ll; thời gian O(nlog⁡n)O(n \log n).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 2e5 + 5;
ll n, a[N];

ll solve(ll l, ll r) {
    if (r - l == 1) return a[l];
    ll mid = (l + r) >> 1;
    ll res = max(solve(l, mid), solve(mid, r));
    ll s = 0, bl = -1e18, br = -1e18;
    for (ll i = mid - 1; i >= l; i--) {
        s += a[i];
        bl = max(bl, s);
    }
    s = 0;
    for (ll j = mid; j < r; j++) {
        s += a[j];
        br = max(br, s);
    }
    return max(res, bl + br);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 0; i < n; i++) cin >> a[i];
    cout << solve(0, n) << '\n';
}
7
-4 6 -2 5 -7 4 3

Ví dụBình Phương Khoảng Cách Nhỏ Nhất

Đề. Cho N≤105N \le 10^5 điểm nguyên, ∣xi∣,∣yi∣≤104|x_i|, |y_i| \le 10^4, hai điểm khác chỉ số có thể trùng tọa độ. In giá trị nhỏ nhất của (xi−xj)2+(yi−yj)2(x_i - x_j)^2 + (y_i - y_j)^2 trên mọi cặp i<ji < j: bài cặp điểm gần nhất (closest pair of points).

Lời giải. Sắp các điểm theo xx một lần, rồi chia theo chỉ số tại mid\mathrm{mid}; đường chia là x=xmidx = x_{\mathrm{mid}}. Giải hai nửa bằng đệ quy, gọi DD là bình phương khoảng cách nhỏ hơn trong hai kết quả và d=Dd = \sqrt D. Còn lại các cặp chéo.

Cặp chéo muốn tốt hơn DD thì hai điểm cách nhau theo hoành độ chưa tới dd, mà đường chia nằm giữa chúng, nên cả hai thuộc dải ∣x−xmid∣<d|x - x_{\mathrm{mid}}| < d. Sắp các điểm trong dải theo yy; với điểm pp, chỉ so với các điểm qq đứng trước có py−qy<dp_y - q_y < d. Những điểm đó cùng pp nằm trong hình chữ nhật 2d×d2d \times d. Nửa trái của hình chữ nhật là hình vuông d×dd \times d mà các điểm bên trong đôi một cách nhau ít nhất dd; chia nó thành bốn ô d2×d2\tfrac d2 \times \tfrac d2, đường chéo mỗi ô là d/2<dd/\sqrt2 < d nên mỗi ô chứa không quá một điểm. Vậy hình chữ nhật chứa không quá 8 điểm kể cả pp, và vòng so của mỗi điểm chạy tối đa 7 bước.

Dải xanh rộng 2d quanh đường chia; khung cam là hình chữ nhật 2d × d của điểm đang xét.

Mọi so sánh dùng bình phương bằng số nguyên, không có căn: điều kiện dải là (x−xmid)2<D(x - x_{\mathrm{mid}})^2 < D. Code gồm ba bước trong hàm solve(l, r), trả về đáp án của đoạn [l,r)[l, r) và để lại đoạn đó đã sắp theo yy.

Bước 1: chia và giải hai nửa. Đoạn dưới hai điểm thì chưa có cặp nào, trả về vô cùng. Hoành độ đường chia mx phải lưu trước khi đệ quy, vì sau đó đoạn đã bị sắp lại theo yy.

C++
if (r - l <= 1) return 1e18;
ll mid = (l + r) >> 1, mx = p[mid].first;
ll d = min(solve(l, mid), solve(mid, r));

Bước 2: trộn hai nửa theo yy. Hai nửa đã sắp theo yy, trộn như sắp xếp trộn trong O(r−l)O(r - l). Nhờ vậy không phải gọi sort theo yy ở mỗi lời gọi, việc sẽ nhân độ phức tạp thêm một thừa số log⁡N\log N.

C++
ll i = l, j = mid, k = l;
while (i < mid || j < r) {
    if (j == r || (i < mid && p[i].second <= p[j].second)) t[k++] = p[i++];
    else t[k++] = p[j++];
}
for (ll q = l; q < r; q++) p[q] = t[q];

Bước 3: quét dải. Đi qua đoạn theo thứ tự yy, giữ các điểm của dải trong t[l..l + m). Điểm nằm ngoài dải thì bỏ. Điểm trong dải so với các điểm dải đứng trước, lùi dần khi hiệu tung độ còn nhỏ hơn dd, rồi tự nhập vào dải. Theo lập luận ở trên, vòng lùi chạy tối đa 7 bước.

C++
ll m = 0;
for (ll q = l; q < r; q++) {
    if (sq(p[q].first - mx) >= d) continue;
    for (ll s = m - 1; s >= 0 && sq(p[q].second - t[l + s].second) < d; s--)
        d = min(d, sq(p[q].first - t[l + s].first) + sq(p[q].second - t[l + s].second));
    t[l + m++] = p[q];
}

Điểm trùng nhau cho D=0D = 0, khi đó dải rỗng và chương trình vẫn đúng. Với test ví dụ, nửa trái {(0,0),(1,1)}\{(0,0), (1,1)\} cho D=2D = 2 và không cặp chéo nào tốt hơn.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5;
ll n;
pair<ll, ll> p[N], t[N];

ll sq(ll v) {
    return v * v;
}

ll solve(ll l, ll r) {
    if (r - l <= 1) return 1e18;
    ll mid = (l + r) >> 1, mx = p[mid].first;
    ll d = min(solve(l, mid), solve(mid, r));
    ll i = l, j = mid, k = l;
    while (i < mid || j < r) {
        if (j == r || (i < mid && p[i].second <= p[j].second)) t[k++] = p[i++];
        else t[k++] = p[j++];
    }
    for (ll q = l; q < r; q++) p[q] = t[q];
    ll m = 0;
    for (ll q = l; q < r; q++) {
        if (sq(p[q].first - mx) >= d) continue;
        for (ll s = m - 1; s >= 0 && sq(p[q].second - t[l + s].second) < d; s--)
            d = min(d, sq(p[q].first - t[l + s].first) + sq(p[q].second - t[l + s].second));
        t[l + m++] = p[q];
    }
    return d;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 0; i < n; i++) cin >> p[i].first >> p[i].second;
    sort(p, p + n);
    cout << solve(0, n) << '\n';
}
5
0 0
3 4
1 1
9 9
1 4

Độ phức tạp: T(N)=2T(N/2)+O(N)T(N) = 2T(N/2) + O(N), tức O(Nlog⁡N)O(N \log N) cộng một lần sắp ban đầu.

Chỉ đi một nhánh

Có khi bước gộp chẳng còn việc gì vì lời giải nằm trọn trong một bài con; ta chỉ cần biết nên đi vào bài con nào. Cây đệ quy khi đó rút thành một đường đi. Có hai dạng hay gặp.

Phần tử thứ kk (quickselect). Tìm số đứng thứ kk (đánh số từ 0) khi sắp tăng, không sắp cả dãy. Chọn một pivot xx ngẫu nhiên, chia các số thành ba nhóm: nhỏ hơn xx (pp số), bằng xx (qq số), lớn hơn xx. Số cần tìm nằm trong nhóm nhỏ nếu k<pk < p; bằng xx nếu p≤k<p+qp \le k < p + q; ngược lại nằm ở vị trí k−p−qk - p - q của nhóm lớn. Chỉ một nhóm cần đi tiếp.

C++
mt19937 rng(712367);

ll kth(vector<ll> a, ll k) {
    while (true) {
        ll x = a[rng() % a.size()];
        vector<ll> lo, eq, hi;
        for (ll v : a) {
            if (v < x) lo.push_back(v);
            else if (v == x) eq.push_back(v);
            else hi.push_back(v);
        }
        if (k < (ll)lo.size()) a = lo;
        else if (k < (ll)(lo.size() + eq.size())) return x;
        else {
            k -= lo.size() + eq.size();
            a = hi;
        }
    }
}

Xấu nhất là O(n2)O(n^2) nếu pivot luôn rơi vào đầu mút, nhưng với pivot ngẫu nhiên trung bình chỉ O(n)O(n). Gọi pivot là tốt nếu nó nằm trong nửa giữa của thứ tự sắp, xác suất ít nhất 1/21/2; khi đó mỗi nhóm có không quá 3n/43n/4 phần tử. Trung bình chờ 2 vòng là gặp pivot tốt, mỗi vòng tốn tuyến tính theo cỡ hiện tại, nên trung bình tổng công việc không quá 2n(1+34+(34)2+… )2n\left(1 + \tfrac34 + \left(\tfrac34\right)^2 + \dots\right); tổng trong ngoặc bằng 4, nên cả bài trung bình không quá 8n8n. Thư viện có nth_element làm đúng việc này tại chỗ.

Hỏi nhanh

Một bạn cài quickselect với hai nhóm: “nhỏ hơn pivot” và “lớn hơn hoặc bằng pivot”, bỏ riêng pivot ra ngoài. Cho nn số giống hệt nhau và k=n/2k = n/2. Số phép so sánh cỡ bao nhiêu?

Vị trí trong dãy sinh theo quy tắc. Nhiều đề cho một dãy sinh bằng quy tắc đệ quy, dài tới 101810^{18}, rồi hỏi phần tử thứ pp. Không dựng được cả dãy, nhưng cấu trúc của nó đã là một lời gọi đệ quy sẵn. Ví dụ S0=(0)S_0 = (0) và Sk=Sk−1,k,Sk−1S_k = S_{k-1}, k, S_{k-1}: hai bản sao của Sk−1S_{k-1} kẹp số kk ở giữa. Vậy S2=(0,1,0,2,0,1,0)S_2 = (0, 1, 0, 2, 0, 1, 0) và ∣Sk∣=2k+1−1|S_k| = 2^{k+1} - 1. Phần tử thứ pp (từ 1) của SkS_k chỉ ở ba chỗ: bản sao trái nếu p≤∣Sk−1∣p \le |S_{k-1}|; phần tử giữa, bằng kk, nếu p=∣Sk−1∣+1p = |S_{k-1}| + 1; còn lại là bản sao phải, nơi nó là phần tử thứ p−∣Sk−1∣−1p - |S_{k-1}| - 1 của Sk−1S_{k-1}. Ta chỉ lưu độ dài từng cấp rồi đi xuống một nhánh.

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll q, len[61];

ll at(ll k, ll p) {
    if (k == 0) return 0;
    if (p <= len[k - 1]) return at(k - 1, p);
    if (p == len[k - 1] + 1) return k;
    return at(k - 1, p - len[k - 1] - 1);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    len[0] = 1;
    for (ll k = 1; k <= 60; k++) len[k] = 2 * len[k - 1] + 1;
    cin >> q;
    while (q--) {
        ll k, p;
        cin >> k >> p;
        cout << at(k, p) << '\n';
    }
}
3
3 6
3 8
60 1000000000000000000

Mỗi truy vấn qua tối đa k+1≤61k + 1 \le 61 lời gọi. Nhìn kết quả thấy phần tử thứ pp của SkS_k chính là số mũ của 2 trong pp (vì 1018=218⋅51810^{18} = 2^{18} \cdot 5^{18} nên ra 18), một công thức đóng; viết vài phần tử đầu ra giấy luôn đáng làm. Cùng khuôn này giải các bài về dãy bit tự đảo, xâu mở rộng hay thứ tự loại trong vòng tròn.

Ví dụĐếm Một Trong Dãy Gấp Đôi

Đề. Với n≥0n \ge 0, dãy F(n)F(n) là (n)(n) nếu n≤1n \le 1, và là F(⌊n/2⌋)F(\lfloor n/2 \rfloor), rồi n mod 2n \bmod 2, rồi lại F(⌊n/2⌋)F(\lfloor n/2 \rfloor) nếu n>1n > 1. Cho n≤1018n \le 10^{18} và l≤rl \le r, đếm số phần tử bằng 1 ở các vị trí từ ll tới rr (đánh số từ 1).

Lời giải. Đếm trên đoạn bằng hiệu hai tiền tố: gọi pre(n,k)\mathrm{pre}(n, k) là số số 1 trong kk phần tử đầu của F(n)F(n), đáp án là pre(n,r)−pre(n,l−1)\mathrm{pre}(n, r) - \mathrm{pre}(n, l - 1). Đặt h=∣F(⌊n/2⌋)∣h = |F(\lfloor n/2 \rfloor)|. Nếu k≤hk \le h, cả kk phần tử nằm trong bản sao trái: đi vào nó. Nếu k>hk > h thì kk phần tử đầu gồm trọn bản sao trái, phần tử giữa n mod 2n \bmod 2, và k−h−1k - h - 1 phần tử đầu của bản sao phải.

Đi vào bản sao phải là một nhánh, nhưng “trọn bản sao trái” lại là thêm một nhánh nữa, và hai nhánh mỗi mức là O(n)O(n) lời gọi. Mẹo là F(m)F(m) có đúng mm số 1: số số 1 thỏa c(m)=2c(⌊m/2⌋)+m mod 2c(m) = 2c(\lfloor m/2 \rfloor) + m \bmod 2, đúng hệ thức của chính m=2⌊m/2⌋+m mod 2m = 2\lfloor m/2 \rfloor + m \bmod 2, và hai bên bằng nhau ở m≤1m \le 1. Vậy trọn bản sao trái đóng góp ⌊n/2⌋\lfloor n/2 \rfloor không cần gọi gì, và mỗi mức chỉ còn một lời gọi. Độ sâu là log⁡2n≈60\log_2 n \approx 60 mức, mỗi mức tính hh thêm 60 bước.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll n, l, r;

ll len(ll m) {
    return m <= 1 ? 1 : 2 * len(m / 2) + 1;
}

ll pre(ll m, ll k) {
    if (k <= 0) return 0;
    if (m <= 1) return m;
    ll h = len(m / 2);
    if (k <= h) return pre(m / 2, k);
    return m / 2 + m % 2 + pre(m / 2, k - h - 1);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> l >> r;
    cout << pre(n, r) - pre(n, l - 1) << '\n';
}
5 2 6

Chia bốn

Trên bảng vuông ta chia thành bốn phần tư. Bài quen thuộc nhất: bảng 2k×2k2^k \times 2^k khuyết đúng một ô; lát kín 4k−14^k - 1 ô còn lại bằng các mảnh chữ L, mỗi mảnh phủ 3 ô (chia hết vì 4k4^k chia 3 luôn dư 1). Chia thành bốn phần tư thì chỉ phần tư chứa ô khuyết là bài con cùng dạng, ba phần tư kia thì không. Mẹo là tạo ra ô khuyết: đặt một mảnh chữ L vào giữa bảng, phủ ba ô sát tâm thuộc ba phần tư không chứa ô khuyết. Giờ mỗi phần tư thiếu đúng một ô và là bài con cùng dạng; bảng 1×11 \times 1 là điều kiện dừng. Tính đúng suy ra từ quy nạp theo kk.

Ô đen là ô khuyết, bấm ô khác để đổi; khung cam là hình vuông đang chia.

Chương trình dưới đọc cạnh nn (lũy thừa của 2) và ô khuyết (hr,hc)(h_r, h_c), tính từ 0, rồi in số hiệu mảnh phủ từng ô; ô khuyết giữ số 0. Hàm tile(r0, c0, s, hr, hc) lát hình vuông cạnh ss, góc trên trái (r0,c0)(r_0, c_0), thiếu ô (hr,hc)(h_r, h_c). Hai mảng mr, mc là hai hàng và hai cột sát tâm.

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll n, hr, hc, id, b[130][130];

void tile(ll r0, ll c0, ll s, ll hr, ll hc) {
    if (s == 1) return;
    ll h = s / 2, cur = ++id;
    ll mr[2] = {r0 + h - 1, r0 + h}, mc[2] = {c0 + h - 1, c0 + h};
    for (ll i = 0; i < 2; i++)
        for (ll j = 0; j < 2; j++) {
            ll x = r0 + i * h, y = c0 + j * h;
            if (x <= hr && hr < x + h && y <= hc && hc < y + h) tile(x, y, h, hr, hc);
            else {
                b[mr[i]][mc[j]] = cur;
                tile(x, y, h, mr[i], mc[j]);
            }
        }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> hr >> hc;
    tile(0, 0, n, hr, hc);
    for (ll i = 0; i < n; i++) {
        for (ll j = 0; j < n; j++) cout << b[i][j] << ' ';
        cout << '\n';
    }
}
4 1 2

Mảnh số 1 là mảnh giữa bảng, phủ các ô (1,1)(1, 1), (2,1)(2, 1), (2,2)(2, 2) của ba phần tư không chứa ô khuyết (1,2)(1, 2). Mỗi lời gọi tốn O(1)O(1) ngoài đệ quy và sinh 4 bài con cạnh một nửa: T(n)=4T(n/2)+O(1)T(n) = 4T(n/2) + O(1) với nn là cạnh bảng. Ở đây q=4>1q = 4 > 1 nên các lá quyết định, O(nlog⁡24)=O(n2)O(n^{\log_2 4}) = O(n^2), đúng bằng số ô phải ghi.

Ví dụNhững Mảnh Giấy Hai Màu

Đề. Tờ giấy N×NN \times N (N≤128N \le 128, lũy thừa của 2), mỗi ô trắng (0) hoặc xanh (1). Một vùng vuông cùng màu thì giữ nguyên thành một mảnh; có cả hai màu thì chia thành bốn hình vuông bằng nhau và làm lại cho từng phần. In số mảnh trắng, rồi số mảnh xanh.

Lời giải. Đề đã là lời gọi chia bốn. Việc còn lại là hỏi nhanh “vùng này có cùng màu không”: đếm số ô xanh trong vùng bằng tổng tiền tố hai chiều (bài 0.3) trong O(1)O(1); bằng 0 là mảnh trắng, bằng k2k^2 là mảnh xanh, còn lại thì chia bốn.

Bước 1: tổng tiền tố khi đọc. s[i][j] là số ô xanh trong hình chữ nhật từ (1,1)(1, 1) tới (i,j)(i, j), tính ngay khi đọc từng ô bằng công thức bao hàm loại trừ.

C++
for (ll i = 1; i <= n; i++)
    for (ll j = 1; j <= n; j++) {
        ll v;
        cin >> v;
        s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + v;
    }

Bước 2: đếm ô xanh của một vùng. Vùng vuông cạnh kk có góc trên trái là ô (x+1,y+1)(x + 1, y + 1) (vì s đánh số từ 1) chứa get(x, y, k) ô xanh.

C++
ll get(ll x, ll y, ll k) {
    return s[x + k][y + k] - s[x][y + k] - s[x + k][y] + s[x][y];
}

Bước 3: chia bốn. Vùng toàn trắng hay toàn xanh thì đếm một mảnh và dừng; ngược lại gọi đệ quy cho bốn hình vuông cạnh k/2k / 2.

C++
void go(ll x, ll y, ll k) {
    ll c = get(x, y, k);
    if (c == 0) {
        w++;
        return;
    }
    if (c == k * k) {
        g++;
        return;
    }
    ll h = k / 2;
    go(x, y, h);
    go(x, y + h, h);
    go(x + h, y, h);
    go(x + h, y + h, h);
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

ll n, w, g, s[130][130];

ll get(ll x, ll y, ll k) {
    return s[x + k][y + k] - s[x][y + k] - s[x + k][y] + s[x][y];
}

void go(ll x, ll y, ll k) {
    ll c = get(x, y, k);
    if (c == 0) {
        w++;
        return;
    }
    if (c == k * k) {
        g++;
        return;
    }
    ll h = k / 2;
    go(x, y, h);
    go(x, y + h, h);
    go(x + h, y, h);
    go(x + h, y + h, h);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i <= n; i++)
        for (ll j = 1; j <= n; j++) {
            ll v;
            cin >> v;
            s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + v;
        }
    go(0, 0, n);
    cout << w << '\n' << g << '\n';
}
4
0 0 1 1
0 0 1 1
1 1 0 0
1 1 0 1

Số vùng được xét không quá 1+4+16+⋯<43N21 + 4 + 16 + \dots < \tfrac43 N^2, mỗi vùng O(1)O(1), nên cả bài O(N2)O(N^2). Thiếu tổng tiền tố thì mỗi vùng tốn k2k^2 để kiểm, thành O(N2log⁡N)O(N^2 \log N), vẫn qua với N=128N = 128 nhưng không còn đẹp.

Khi đi thi

  • Bài đếm hoặc tối ưu trên các cặp (i,j)(i, j) cần quan hệ giữa aia_i và aja_j mà O(n2)O(n^2) chậm: nghĩ tới chia đôi chỉ số, giao hai nửa cho đệ quy và xử lý cặp chéo lúc gộp hai nửa đã sắp; nếu còn có cập nhật xen giữa thì chuyển sang cây Fenwick (bài 1.7) hoặc CDQ (bài 6.1).
  • Trước khi cài, viết hệ thức T(n)T(n) và đọc độ phức tạp từ cây đệ quy: bài con phải co lại theo tỉ lệ và mỗi bài chỉ giải một lần. Cài với đoạn nửa mở [l,r)[l, r), điều kiện dừng r - l <= 1 và một mảng phụ toàn cục dùng chung.
  • Dãy hoặc xâu sinh theo quy tắc đệ quy dài tới 101810^{18} thì đừng dựng nó: lưu độ dài từng cấp rồi đi một nhánh theo vị trí, và so với bản vét cạn trên cỡ nhỏ trước khi nộp.

Tóm tắt

  • Chia để trị gồm chia, trị (đệ quy, có điều kiện dừng) và gộp; các bài con phải cùng dạng, độc lập và co lại theo tỉ lệ. Dùng đoạn nửa mở [l,r)[l, r) với mid=(l+r)/2\mathrm{mid} = (l + r)/2.
  • Hệ thức T(n)=a T(n/b)+f(n)T(n) = a\,T(n/b) + f(n) đọc bằng cây đệ quy: cộng chi phí theo mức rồi xem mức đầu, mọi mức hay mức cuối trội hơn. Nhớ 2T(n/2)+O(n)=O(nlog⁡n)2T(n/2) + O(n) = O(n \log n), T(n/2)+O(1)=O(log⁡n)T(n/2) + O(1) = O(\log n), 4T(n/2)+O(1)=O(n2)4T(n/2) + O(1) = O(n^2).
  • Sắp xếp trộn gộp hai nửa đã sắp bằng hai con trỏ trong O(n)O(n); lấy bên trái khi bằng nhau để giữ ổn định.
  • Phần khó là cặp chéo, việc của bước gộp: cộng mid−i\mathrm{mid} - i khi gộp (nghịch thế), quét từ giữa ra (đoạn con lớn nhất), lọc dải rồi so với tối đa 7 điểm kế tiếp (cặp điểm gần nhất).
  • Khi lời giải nằm trọn trong một bài con thì chỉ đi một nhánh: quickselect với phân hoạch ba phía trung bình O(n)O(n), vị trí trong dãy sinh theo quy tắc cần O(log⁡)O(\log) lời gọi.

Bài tập

Đọc thêm