Wiki Thuật toán Ternary Search

Ternary Search

huunguyen huunguyen Updated Tháng sáu 2, 2026

Tìm kiếm tam phân (ternary search) tìm điểm cực trị (cực tiểu hoặc cực đại) của một hàm đơn cực (unimodal) trên đoạn [lo,hi] trong O(logN) truy vấn giá trị hàm — thay vì duyệt toàn bộ O(N) điểm. Nó là người anh em của tìm kiếm nhị phân: binary search tìm ngưỡng trên hàm đơn điệu, còn ternary search tìm đỉnh/đáy trên hàm "tăng rồi giảm" (hoặc "giảm rồi tăng").

Điều kiện áp dụng (BẮT BUỘC): hàm f phải đơn cực trên [lo,hi]:

  • Để tìm cực tiểu: f phải giảm nghiêm ngặt rồi tăng nghiêm ngặt (hình chữ V / hàm lồi). Có đúng một đáy.
  • Để tìm cực đại: f phải tăng nghiêm ngặt rồi giảm nghiêm ngặt (hình chữ Λ / hàm lõm). Có đúng một đỉnh.

Nếu hàm có đoạn phẳng (hai điểm liền kề bằng nhau) hoặc nhiều cực trị, ternary search có thể trả về sai — đây là cái bẫy lớn nhất, sẽ nói kỹ ở mục Lỗi thường gặp.

Ý tưởng / Trực giác

Lấy ví dụ tìm cực tiểu của hàm lồi. Mỗi bước ta chia đoạn [lo,hi] thành ba phần bằng hai điểm:

m1=lo+hilo3,m2=hihilo3

với m1<m2. So sánh f(m1)f(m2) cho phép loại bỏ một phần ba đoạn:

  • Nếu f(m1)<f(m2): đáy không thể nằm bên phải m2. Vì sao? Hàm lồi giảm rồi tăng. Nếu đáy nằm trong (m2,hi] thì cả m1,m2 đều ở nhánh giảm, suy ra f(m1)>f(m2) — mâu thuẫn. Vậy đáy nằm trong [lo,m2], ta gán hi=m2.
  • Nếu f(m1)>f(m2): đối xứng, đáy nằm trong [m1,hi], ta gán lo=m1.
  • Nếu f(m1)=f(m2): do tính đơn cực nghiêm ngặt, đáy nằm strictly giữa m1m2, nên thu hẹp về [m1,m2] đều an toàn.

Điểm cốt lõi: mỗi lần so sánh cắt bỏ 1/3 độ dài đoạn một cách an toàn, nên đoạn co lại theo cấp số nhân với hệ số 2/3. Sau k bước, độ dài còn (2/3)k lần ban đầu — đó chính là nguồn gốc của O(logN). Khác với binary search dùng dấu của một điểm, ternary search cần hai điểm vì chỉ một giá trị f(m) không cho biết ta đang ở nhánh trái hay nhánh phải của đáy; phải so sánh hai điểm mới biết hướng dốc.

Ví dụ chạy tay

Tìm cực tiểu nguyên của f(x)=(x7)2+3 trên [0,12] (đáy thật ở x=7, f(7)=3). Bảng giá trị:

x   :  0   1   2   3   4   5   6   7   8   9  10  11  12
f(x): 52  39  28  19  12   7   4   3   4   7  12  19  28
                                ^đáy thật

Quy ước: L = lo, R = hi, a = m1, b = m2, vùng bị loại bôi x.

Bước 1: lo=0,hi=12m1=0+12/3=4, m2=1212/3=8. f(4)=12<f(8)=4? Không, 12>4 nên đáy ở phải: lo=m1=4.

x   :  0   1   2   3   4   5   6   7   8   9  10  11  12
       x   x   x   x   L       a?      R
f(4)=12  >  f(8)=4  -> bo trai, lo=4

Bước 2: lo=4,hi=12 → khoảng dài 8, m1=4+8/3=6, m2=128/3=9 (chia nguyên). f(6)=4<f(9)=7 → đáy ở trái: hi=m2=9.

x   :              4   5   6   7   8   9  10  11  12
                   L       a       b   R   x   x   x
f(6)=4  <  f(9)=7  -> bo phai, hi=9

Bước 3: lo=4,hi=9, độ dài 5>2m1=4+5/3=5, m2=95/3=8. f(5)=7<f(8)=4? Không → lo=5.

x   :              4   5   6   7   8   9
                   x   L   a?      b   R
f(5)=7  >  f(8)=4  -> lo=5

Bước 4: lo=5,hi=9, độ dài 4>2m1=5+4/3=6, m2=94/3=7. f(6)=4>f(7)=3lo=6. Giờ hilo=96=3>2, tiếp. Bước 5: lo=6,hi=9m1=6+1=7, m2=91=8. f(7)=3<f(8)=4hi=8. Còn [6,8], độ dài 2, dừng vòng lặp.

Quét nốt x{6,7,8}: f(6)=4,f(7)=3,f(8)=4 → nhỏ nhất tại x=7, f=3. Đúng.

Cài đặt

Trên số thực (lặp cố định số vòng)
#include <bits/stdc++.h>
using namespace std;

// Tìm x trong [lo, hi] TỐI THIỂU HÓA f (hàm lồi, giảm rồi tăng)
double ternary_min_real(double lo, double hi, function<double(double)> f) {
    // Mỗi vòng đoạn co lại theo hệ số 2/3; ~200 vòng -> sai số ~ (2/3)^200
    for (int iter = 0; iter < 200; iter++) {
        double m1 = lo + (hi - lo) / 3;
        double m2 = hi - (hi - lo) / 3;
        if (f(m1) < f(m2)) hi = m2;   // đáy không ở bên phải m2
        else               lo = m1;   // đáy không ở bên trái m1
    }
    return (lo + hi) / 2;             // trả về điểm giữa đoạn cuối
}

// Tìm CỰC ĐẠI (hàm lõm): chỉ đổi chiều so sánh
double ternary_max_real(double lo, double hi, function<double(double)> f) {
    for (int iter = 0; iter < 200; iter++) {
        double m1 = lo + (hi - lo) / 3;
        double m2 = hi - (hi - lo) / 3;
        if (f(m1) > f(m2)) hi = m2;
        else               lo = m1;
    }
    return (lo + hi) / 2;
}

Dùng số vòng cố định thay vì điều kiện while (hi - lo > eps) để tránh vòng lặp vô tận do sai số dấu phẩy động. Với 200 vòng, sai số (2/3)200·(hilo)1035·(hilo) — thừa cho mọi yêu cầu 109 (chỉ cần ~100 vòng).

Trên số nguyên (thu hẹp tới khi còn ≤ 3 điểm rồi quét tay)
// Tìm x NGUYÊN trong [lo, hi] tối thiểu hóa f (đơn cực nghiêm ngặt)
long long ternary_min_int(long long lo, long long hi, function<long long(long long)> f) {
    while (hi - lo > 2) {                 // còn > 3 điểm thì tiếp tục
        long long m1 = lo + (hi - lo) / 3;
        long long m2 = hi - (hi - lo) / 3;
        if (f(m1) < f(m2)) hi = m2;
        else               lo = m1;
    }
    long long best = lo;                  // quét nốt các điểm còn lại [lo..hi]
    for (long long x = lo; x <= hi; x++)
        if (f(x) < f(best)) best = x;
    return best;                          // trả về vị trí cực tiểu
}

Với số nguyên, không lặp tới hi == lo vì khi hilo nhỏ, công thức chia nguyên có thể khiến m1 hoặc m2 trùng biên và vòng lặp không tiến (kẹt vô hạn). An toàn nhất là dừng khi còn 3 điểm rồi so sánh trực tiếp.

Độ phức tạp

  • Thời gian: O(log3/2L·C), trong đó L là độ dài miền tìm kiếm (số nguyên) hoặc tỉ lệ hiloε (số thực), còn C là chi phí một lần tính f. Mỗi vòng đoạn co theo hệ số 2/3 nên số vòng để đạt độ rộng 1 (hoặc ε) là log3/2L=O(logL). Lưu ý: mỗi vòng tính f hai lần — hằng số gấp đôi binary search, nhưng vẫn cùng bậc logarit. Nếu f tốn O(N) (vd cộng khoảng cách tới N điểm), tổng là O(NlogL).
  • Bộ nhớ: O(1) ngoài dữ liệu đầu vào — chỉ giữ vài biến lo, hi, m1, m2. Bản đệ quy sẽ tốn O(logL) stack, nên ưu tiên vòng lặp.

⚠️ Lỗi thường gặp

  • Hàm không thực sự đơn cực: đây là lỗi nghiêm trọng nhất. Nếu f có nhiều cực trị, hoặc tăng/giảm không nghiêm ngặt, kết quả sai mà không báo lỗi. Hãy chứng minh tính đơn cực trước khi dùng (vd tổng các hàm lồi vẫn lồi; khoảng cách Euclid từ điểm cố định tới điểm chạy trên đoạn thẳng là hàm lồi). Khi nghi ngờ, kiểm thử bằng brute force trên dữ liệu nhỏ.
  • Đoạn phẳng làm so sánh < quyết định sai hướng: nếu tồn tại m1m2f(m1)=f(m2) do hàm có "mặt bàn" phẳng, nhánh else (gán lo = m1) có thể vứt bỏ luôn đáy thật. Với số nguyên có đoạn bằng nhau, hãy thu hẹp tới khoảng nhỏ rồi quét tuyến tính thay vì tin tưởng tuyệt đối vào so sánh.
  • Tràn số khi tính m1,m2 hoặc giá trị f: với miền số nguyên lớn (vd ±109), lo + (hi - lo)/3 an toàn hơn (lo + 2*hi)/3 (vế sau dễ tràn int). Giá trị hàm như (xc)2 hay tổng khoảng cách dễ vượt int — khai báo f trả long long.
  • Vòng lặp vô hạn trên số nguyên: điều kiện while (lo < hi) với chia nguyên có thể khiến m1=lo, m2=hi và đoạn không co. Luôn dừng khi hi - lo <= 2 rồi xử lý nốt bằng tay.
  • Dùng while (hi - lo > eps) cho số thực: với hàm gần phẳng quanh đáy, sai số dấu phẩy động khiến điều kiện không bao giờ thoả → treo. Dùng số vòng cố định (100–200) thay cho ngưỡng eps.
  • Nhầm chiều min/max: tìm cực đại nhưng để nguyên dấu so sánh của bản tìm cực tiểu (hoặc ngược lại) sẽ cho kết quả ở đầu mút sai. Quy tắc: tìm min dùng if (f(m1) < f(m2)) hi = m2;, tìm max chỉ việc đổi < thành >.

Biến thể / Mở rộng

  • Bậc hai (golden-section search): thay vì chia ba đều, dùng tỉ lệ vàng φ để tái sử dụng một điểm đánh giá ở vòng sau, giảm số lần gọi f từ 2 xuống 1 mỗi vòng — hữu ích khi f rất đắt.
  • Tối ưu trên dãy đơn cực rời rạc: mảng "tăng rồi giảm" (mountain array), tìm đỉnh; hoặc khoảng cách từ điểm P tới các đỉnh của bao lồi (convex hull) là đơn cực theo chỉ số đỉnh, cho phép tìm đỉnh xa/gần nhất trong O(logN).
  • Quy về binary search trên đạo hàm: nếu f khả vi và f đơn điệu, tìm nghiệm f(x)=0 bằng binary search cũng cho cực trị, đôi khi ổn định hơn về số học.
  • Ternary search lồng nhau (2D): tối ưu hàm lồi hai biến f(x,y) bằng cách ternary search theo x, mỗi lần lại ternary search theo y để tính minyf(x,y) — tổng O(log2) truy vấn, áp dụng khi hàm lồi theo từng biến.

So sánh nhanh với tìm kiếm nhị phân:

Binary Search Ternary Search
Điều kiện hàm Đơn điệu (monotonic) Đơn cực (unimodal)
Mục tiêu Tìm ngưỡng / giá trị Tìm điểm cực trị
Số lần gọi f mỗi vòng 1 2
Hệ số co đoạn 1/2 2/3
Độ phức tạp O(logN) O(logN)

Bài tập luyện

Hiện kho bài (pool) của CTOJ chưa có bài nào gắn đúng kỹ thuật tìm kiếm tam phân (không có tag ternary_search; tìm theo từ khoá "tam phân", "unimodal", "cực trị" và rà các bài tag optimization/math/geometry đều không thấy bài thực sự dùng kỹ thuật này). Để tránh gắn link sai chủ đề / link chết, mục này tạm để trống và sẽ được bổ sung khi có bài đúng kỹ thuật được thêm vào pool.

Trong lúc chờ, bạn có thể tự luyện bằng các dạng kinh điển ngoài CTOJ: cực tiểu hàm lồi một biến (vd tổng khoảng cách tới tập điểm), tìm đỉnh "mountain array", và tìm điểm xa nhất tới một đa giác lồi — tất cả đều là ứng dụng trực tiếp của ternary search.

gnatmake 12.2.0 a68g 3.1.2 nasm 2.16.1 as_x64 2.46 awk 1.3.4 gcc 16.1.0 csc 6.12.0.200 g++ 16.1.0 g++-themis 16.1.0 g++17 16.1.0 g++20 16.1.0 g++23 16.1.0 clang++ 22.1.6 dmd 2.112.0 dart 3.12.1 gforth 0.7.3 gfortran 12.2.0 go 1.26.3 groovyc 5.0.6 javac 25.0.3 node 26.2.0 kotlinc 2.3.21 sbcl 2.2.9 lua 5.4.8 nim 2.2.10 fpc 3.2.2 fpc-themis 3.2.2 perl 5.36.0 php 8.5.6 pike 8.0 pypy3 7.3.23 python3 3.14.5 racket 8.7 ruby 4.0.5 rustc 1.96.0 csc 5.3.0 ctoj-scratch 0.0.1 sed 4.9 tclsh 8.6 bun 1.3.14 deno 2.8.1 v 0.5.1 zig 0.16.0