Chương 5·Bài 57

Ternary Search

Nhiều người đứng rải trên một con đường, cần chọn một điểm hẹn sao cho người đến muộn nhất đến càng sớm càng tốt. Hẹn quá về bên trái thì người ở xa bên phải đến muộn; quá về bên phải thì ngược lại. Thời gian chờ giảm dần rồi tăng dần, và đáy nằm ở giữa.

Hàm kiểu đó gọi là đơn đỉnh. Tìm kiếm tam phân so giá trị tại hai điểm chia ba, rồi bỏ đi một phần ba chắc chắn không chứa đáy. Trên số thực, chạy một số vòng cố định; trên số nguyên, gọn và an toàn hơn là chặt nhị phân trên hiệu f(k+1)−f(k)f(k + 1) - f(k). Phần khó thật sự là chứng minh hàm đơn đỉnh, thường bằng cách chỉ ra nó lồi.

Hàm lồi và tam phân

Hàm ff trên đoạn [l,r][l, r] là đơn đỉnh (theo cực tiểu) nếu có pp để ff giảm ngặt trên [l,p][l, p] và tăng ngặt trên [p,r][p, r]. Lấy m1<m2m_1 < m_2 trong đoạn: nếu f(m1)<f(m2)f(m_1) < f(m_2) thì đáy không thể nằm bên phải m2m_2, bỏ (m2,r](m_2, r]; ngược lại bỏ [l,m1)[l, m_1). Mỗi vòng đoạn còn 2/32/3.

Hàm lồi là đơn đỉnh. Nếu có đoạn nằm ngang ở đáy thì cũng không sao, vì mọi điểm ở đó đều là cực tiểu. Các phép giữ tính lồi hay gặp:

  • ∣x−a∣|x - a|, (x−a)2(x - a)^2, c⋅xc \cdot x là lồi; tổng các hàm lồi là lồi.
  • max⁡\max của các hàm lồi là lồi: thời gian chờ người chậm nhất, khoảng cách tới điểm xa nhất.
  • Hàm lồi theo mỗi biến khi cố định biến kia chưa đủ cho tam phân lồng; cần lồi theo cả hai biến cùng lúc, như max⁡\max khoảng cách Euclid.

Hàm có đoạn ngang không ở đáy (ví dụ hàm bậc thang) làm tam phân hỏng: f(m1)=f(m2)f(m_1) = f(m_2) không cho biết đáy ở phía nào.

Trong hình, dải xanh là đoạn còn lại, chấm đỏ là điểm thấp hơn trong hai điểm chia ba. Kéo vị trí đáy để thử.

Mỗi vòng bỏ một phần ba đoạn.

Hỏi nhanh

Hàm nào không chắc đơn đỉnh trên đoạn?

Trên số thực, đừng dừng theo sai số r−l<εr - l < \varepsilon: với tọa độ lớn, ε\varepsilon nhỏ có thể không bao giờ đạt được do làm tròn, vòng lặp chạy mãi. Hãy chạy số vòng cố định: sau kk vòng đoạn dài (r−l)(2/3)k(r - l)(2/3)^k, 100 vòng đã nhỏ hơn 10−1710^{-17} lần độ dài ban đầu.

Ví dụĐiểm Hẹn Nhanh Nhất

Đề. n≤6⋅104n \le 6 \cdot 10^4 người trên trục số, người ii ở xix_i đi với vận tốc tối đa viv_i (tới 10910^9). Chọn điểm hẹn thực pp để thời gian người tới cuối cùng, max⁡i∣xi−p∣/vi\max_i |x_i - p| / v_i, nhỏ nhất.

Lời giải. Mỗi ∣xi−p∣/vi|x_i - p|/v_i lồi theo pp, nên cực đại của chúng cũng lồi: tam phân trên [1,109][1, 10^9].

Bước 1: hàm cần cực tiểu. Thời gian của người tới cuối cùng khi hẹn ở pp, O(n)O(n).

C++
ld f(ld p) {
    ld t = 0;
    for (ll i = 1; i <= n; i++) t = max(t, fabsl(x[i] - p) / v[i]);
    return t;
}

Bước 2: tam phân. Chia [l,r][l, r] làm ba. Nếu f(m1) < f(m2) thì cực tiểu không thể nằm bên phải m2 (hàm lồi đã bắt đầu tăng), bỏ (m2,r](m2, r]; ngược lại bỏ [l,m1)[l, m1). Mỗi vòng giữ lại 2/32/3 khoảng; 200 vòng thu từ 10910^9 về cỡ 10−2610^{-26}, dư cho độ chính xác của long double.

C++
for (ll it = 0; it < 200; it++) {
    ld m1 = l + (r - l) / 3, m2 = r - (r - l) / 3;
    if (f(m1) < f(m2)) r = m2;
    else l = m1;
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
#define ld long double

const ll N = 6e4 + 5;
ll n, x[N], v[N];

ld f(ld p) {
    ld t = 0;
    for (ll i = 1; i <= n; i++) t = max(t, fabsl(x[i] - p) / v[i]);
    return t;
}

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

    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> x[i];
    for (ll i = 1; i <= n; i++) cin >> v[i];
    ld l = 1, r = 1e9;
    for (ll it = 0; it < 200; it++) {
        ld m1 = l + (r - l) / 3, m2 = r - (r - l) / 3;
        if (f(m1) < f(m2)) r = m2;
        else l = m1;
    }
    cout << fixed << setprecision(15) << f((l + r) / 2) << '\n';
}
3
1 11 21
1 2 1

Ước lượng phép tính: 200⋅2⋅n=2,4⋅107200 \cdot 2 \cdot n = 2{,}4 \cdot 10^7 phép. Hai chỗ có thể bớt: kiểu số và số vòng.

Tam phân điểm hẹn với sáu mươi nghìn người, trung vị 5 lần chạy, i7-10850H, g++ -O2.long double, 200 vòng66 ms · gấp 5,6 lầndouble, 200 vòng24 ms · gấp 2,1 lầndouble, 100 vòng12 ms
Tam phân điểm hẹn với sáu mươi nghìn người, ba cấu hình, đo trên i7-10850H, g++ -O2.

long double chậm hơn double khoảng 3 lần, và 100 vòng cho cùng đáp án như 200 vòng nhưng nhanh gấp đôi. Quy tắc: double với 100 vòng là đủ cho đa số bài; long double chỉ khi đề đòi nhiều chữ số hay tọa độ rất lớn.

Hàm tính bằng thuật toán

Nhiều khi f(x)f(x) không có công thức mà phải tính bằng cả một thuật toán: tham lam, Kadane, DP, đường đi ngắn nhất với tham số xx. Tam phân vẫn dùng được miễn chứng minh được ff lồi, thường bằng cách viết ff là cực đại (hoặc tổng) của rất nhiều hàm lồi đơn giản, mỗi hàm ứng với một lựa chọn rời rạc.

Độ phức tạp là số vòng nhân chi phí một lần tính ff, nên ff phải chạy nhanh.

Ví dụĐộ Lệch Đoạn Liên Tiếp

Đề. Dãy n≤2⋅105n \le 2 \cdot 10^5 số trong [−104,104][-10^4, 10^4]. Chọn số thực xx, trừ xx khỏi mọi phần tử; độ lệch là trị tuyệt đối lớn nhất của tổng một đoạn con. Tìm độ lệch nhỏ nhất.

Lời giải. Với mỗi đoạn cố định, ∣tổng−x⋅len∣|\text{tổng} - x \cdot len| lồi theo xx; cực đại trên mọi đoạn vẫn lồi. Tam phân trên [−104,104][-10^4, 10^4] như ví dụ trước, chỉ khác cách tính ff.

Bước 1: f(x)f(x) bằng Kadane hai chiều. Trên dãy ai−xa_i - x: hi là tổng lớn nhất của đoạn kết thúc tại ii, lo là nhỏ nhất. max(hi, 0) + b bỏ phần trước nếu nó âm, như Kadane thường; lo đối xứng. best là trị tuyệt đối lớn nhất của tổng một đoạn con.

C++
ld f(ld x) {
    ld hi = 0, lo = 0, best = 0;
    for (ll i = 1; i <= n; i++) {
        ld b = a[i] - x;
        hi = max(hi, (ld)0) + b;
        lo = min(lo, (ld)0) + b;
        best = max(best, max(hi, -lo));
    }
    return best;
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
#define ld long double

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

ld f(ld x) {
    ld hi = 0, lo = 0, best = 0;
    for (ll i = 1; i <= n; i++) {
        ld b = a[i] - x;
        hi = max(hi, (ld)0) + b;
        lo = min(lo, (ld)0) + b;
        best = max(best, max(hi, -lo));
    }
    return best;
}

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

    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    ld l = -1e4, r = 1e4;
    for (ll it = 0; it < 150; it++) {
        ld m1 = l + (r - l) / 3, m2 = r - (r - l) / 3;
        if (f(m1) < f(m2)) r = m2;
        else l = m1;
    }
    cout << fixed << setprecision(15) << f((l + r) / 2) << '\n';
}
2
0 2

Độ phức tạp: 150 vòng, mỗi vòng hai lần O(n)O(n).

Miền nguyên

Trên số nguyên, ff lồi khi và chỉ khi hiệu d(k)=f(k+1)−f(k)d(k) = f(k + 1) - f(k) không giảm. Cực tiểu là kk nhỏ nhất có d(k)≥0d(k) \ge 0, tìm bằng chặt nhị phân l, r, res quen thuộc, mỗi bước tính ff hai lần. Cách này khỏi phải xử lý “còn ba phần tử rồi vét” của tam phân nguyên.

Với hàm có giá trị thực (như A/k+1A/\sqrt{k+1}), hiệu kề có thể sai dấu ở gần đáy, nơi hai giá trị gần như bằng nhau. Khi đó ff tại điểm tìm được vẫn đủ gần cực tiểu theo sai số đề cho.

Hỏi nhanh

f(k)=k⋅1+20/k+1f(k) = k \cdot 1 + 20/\sqrt{k + 1} trên số nguyên k≥0k \ge 0. Đáp án là kk nào?

Ví dụNgưỡng Điều Chỉnh Chung

Đề. Hai dãy aa (dài nn) và bb (dài mm), n,m≤105n, m \le 10^5, phần tử tới 10910^9. Mỗi thao tác tăng hay giảm một phần tử 1 đơn vị. Cần đạt min⁡(a)≥max⁡(b)\min(a) \ge \max(b) với ít thao tác nhất.

Lời giải. Chọn ngưỡng tt: kéo mọi ai<ta_i < t lên tt, đẩy mọi bj>tb_j > t xuống tt. Chi phí f(t)=∑max⁡(0,t−ai)+∑max⁡(0,bj−t)f(t) = \sum \max(0, t - a_i) + \sum \max(0, b_j - t) là tổng các hàm lồi. Với hàm lồi trên số nguyên, hiệu kề f(t+1)−f(t)f(t + 1) - f(t) không giảm, nên chặt nhị phân trên hiệu kề thay cho tam phân.

Bước 1: chi phí một ngưỡng. O(n+m)O(n + m); chi phí tới 2⋅105⋅1092 \cdot 10^5 \cdot 10^9, dùng long long.

C++
ll f(ll t) {
    ll s = 0;
    for (ll i = 1; i <= n; i++)
        if (a[i] < t) s += t - a[i];
    for (ll j = 1; j <= m; j++)
        if (b[j] > t) s += b[j] - t;
    return s;
}

Bước 2: chặt hiệu kề. tt nhỏ nhất có f(t + 1) - f(t) >= 0: từ đó hàm không còn giảm, nên tt là cực tiểu.

C++
while (l <= r) {
    ll mid = (l + r) / 2;
    if (f(mid + 1) - f(mid) >= 0) res = mid, r = mid - 1;
    else l = mid + 1;
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5;
ll n, m, a[N], b[N];

ll f(ll t) {
    ll s = 0;
    for (ll i = 1; i <= n; i++)
        if (a[i] < t) s += t - a[i];
    for (ll j = 1; j <= m; j++)
        if (b[j] > t) s += b[j] - t;
    return s;
}

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

    cin >> n >> m;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    for (ll j = 1; j <= m; j++) cin >> b[j];
    ll l = 1, r = 1e9, res = 1e9;
    while (l <= r) {
        ll mid = (l + r) / 2;
        if (f(mid + 1) - f(mid) >= 0) res = mid, r = mid - 1;
        else l = mid + 1;
    }
    cout << f(res) << '\n';
}
3 2
1 4 9
5 8

Độ phức tạp: 3030 bước chặt, mỗi bước O(n+m)O(n + m).

Ví dụMột Lần Điều Chỉnh

Đề. Chọn số nguyên k≥0k \ge 0 để kB+A/k+1kB + A/\sqrt{k + 1} nhỏ nhất, với A,B≤1018A, B \le 10^{18}. In giá trị nhỏ nhất, sai số 10−610^{-6}.

Lời giải. BkBk tuyến tính, A/k+1A/\sqrt{k+1} lồi, tổng lồi theo kk. Đáy gần (A/2B)2/3(A/2B)^{2/3}, không vượt 101210^{12}, nên chặt hiệu kề như ví dụ trước trên [0,2⋅1012][0, 2 \cdot 10^{12}], khoảng 41 bước.

Bước 1: tính bằng số thực. kBkB tới 2⋅10302 \cdot 10^{30} không vừa long long, và A/k+1A/\sqrt{k + 1} là số thực, nên dùng long double.

C++
ld f(ll k) {
    return (ld)k * B + A / sqrtl(k + 1);
}

Gần đáy, hai giá trị kề nhau gần như bằng nhau và hiệu có thể sai dấu do làm tròn; điểm tìm được vẫn nằm sát cực tiểu, sai số giá trị nhỏ hơn nhiều so với 10−610^{-6}.

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

ll A, B;

ld f(ll k) {
    return (ld)k * B + A / sqrtl(k + 1);
}

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

    cin >> A >> B;
    ll l = 0, r = 2e12, res = 2e12;
    while (l <= r) {
        ll mid = (l + r) / 2;
        if (f(mid + 1) - f(mid) >= 0) res = mid, r = mid - 1;
        else l = mid + 1;
    }
    cout << fixed << setprecision(10) << f(res) << '\n';
}
20 1

Độ phức tạp: O(log⁡1012)O(\log 10^{12}).

Tam phân lồng

Hàm hai biến f(x,y)f(x, y) lồi theo cả hai biến cùng lúc thì g(x)=min⁡yf(x,y)g(x) = \min_y f(x, y) cũng lồi. Tam phân ngoài trên xx, mỗi lần tính g(x)g(x) bằng một tam phân trong trên yy. Chi phí là tích số vòng hai tầng nhân chi phí một lần tính ff, nên giữ số vòng vừa đủ.

Ví dụĐường Tròn Bao Nhỏ Nhất

Đề. n≤50n \le 50 điểm nguyên trong [0,1000]2[0, 1000]^2. Tìm bán kính nhỏ nhất của đường tròn chứa mọi điểm.

Lời giải. Với tâm (x,y)(x, y), bán kính cần là khoảng cách tới điểm xa nhất, một hàm lồi hai biến. Tâm tối ưu nằm trong hình vuông chứa các điểm. Tam phân lồng: vòng ngoài theo xx, vòng trong theo yy.

Bước 1: hàm hai biến. Bình phương khoảng cách tới điểm xa nhất. Bình phương đạt cực tiểu cùng chỗ với khoảng cách và vẫn lồi theo từng biến, nên khỏi gọi sqrt trong vòng lặp.

C++
ld f(ld x, ld y) {
    ld d = 0;
    for (ll i = 1; i <= n; i++) d = max(d, (x - px[i]) * (x - px[i]) + (y - py[i]) * (y - py[i]));
    return d;
}

Bước 2: tam phân trong. Với xx cố định, g(x)=min⁡yf(x,y)g(x) = \min_y f(x, y).

C++
ld g(ld x) {
    ld l = 0, r = 1000;
    for (ll it = 0; it < 120; it++) {
        ld m1 = l + (r - l) / 3, m2 = r - (r - l) / 3;
        if (f(x, m1) < f(x, m2)) r = m2;
        else l = m1;
    }
    return f(x, (l + r) / 2);
}

Bước 3: tam phân ngoài trên xx dùng g. gg lồi vì ff lồi theo cả hai biến cùng lúc. Cuối cùng lấy căn để ra bán kính.

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

ll n, px[55], py[55];

ld f(ld x, ld y) {
    ld d = 0;
    for (ll i = 1; i <= n; i++) d = max(d, (x - px[i]) * (x - px[i]) + (y - py[i]) * (y - py[i]));
    return d;
}

ld g(ld x) {
    ld l = 0, r = 1000;
    for (ll it = 0; it < 120; it++) {
        ld m1 = l + (r - l) / 3, m2 = r - (r - l) / 3;
        if (f(x, m1) < f(x, m2)) r = m2;
        else l = m1;
    }
    return f(x, (l + r) / 2);
}

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

    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> px[i] >> py[i];
    ld l = 0, r = 1000;
    for (ll it = 0; it < 120; it++) {
        ld m1 = l + (r - l) / 3, m2 = r - (r - l) / 3;
        if (g(m1) < g(m2)) r = m2;
        else l = m1;
    }
    cout << fixed << setprecision(15) << sqrtl(g((l + r) / 2)) << '\n';
}
4
0 0
0 2
2 0
2 2

Ước lượng phép tính: 120⋅2⋅120⋅2⋅n≈2,9⋅106120 \cdot 2 \cdot 120 \cdot 2 \cdot n \approx 2{,}9 \cdot 10^6 phép.

Khi đi thi

  • Chứng minh hàm lồi trước khi tam phân: tổng, cực đại, tuyến tính cộng lồi. Hàm bậc thang hay min⁡\min của nhiều hàm thì không dùng được.
  • Số thực: 100 đến 200 vòng cố định, không dừng theo ε\varepsilon; double thường đủ.
  • Số nguyên: chặt nhị phân kk nhỏ nhất có f(k+1)−f(k)≥0f(k + 1) - f(k) \ge 0; đáy có công thức gần đúng thì thử vài giá trị quanh đó.

Tóm tắt

  • Tam phân giữ đoạn chứa đáy bằng cách so f(m1)f(m_1), f(m2)f(m_2); mỗi vòng còn 2/32/3.
  • Hàm lồi là đơn đỉnh; tổng và cực đại của hàm lồi vẫn lồi.
  • Trên số nguyên, cực tiểu của hàm lồi là điểm đầu tiên hiệu kề không âm.
  • Hàm lồi hai biến: tam phân lồng, tầng trong tính min⁡yf(x,y)\min_y f(x, y).

Bài tập

Đọc thêm