Li Chao Tree
Li Chao Tree là cấu trúc dữ liệu cho phép thêm đường thẳng vào một tập, rồi truy vấn giá trị nhỏ nhất (hoặc lớn nhất) của tập các đường thẳng đó tại một điểm bất kỳ. Mỗi thao tác chạy trong , với là số điểm phân biệt có thể hỏi.
Bài toán điển hình: ta có DP dạng
trong đó mỗi trạng thái sinh ra một đường thẳng có hệ số góc và tung độ gốc , còn truy vấn là tính giá trị nhỏ nhất của các đường thẳng tại . Nếu tính trực tiếp thì mất ; Li Chao Tree hạ xuống .
Đây là một lựa chọn thay cho Convex Hull Trick (CHT). Khác biệt then chốt: CHT dạng stack chỉ chạy được khi hệ số góc thêm vào đơn điệu và điểm truy vấn đơn điệu. Li Chao Tree không cần điều kiện đơn điệu nào — thêm đường với hệ số góc tùy ý, hỏi tại điểm tùy ý, xen kẽ thoải mái (online). Đổi lại mỗi thao tác là thay vì khấu hao.
Ý tưởng / Trực giác
Ta dựng một segment tree trên trục giá trị (không phải trên mảng dữ liệu). Mỗi node phụ trách một đoạn của trục và chỉ lưu đúng một đường thẳng.
Câu hỏi mấu chốt: với một node , ta nên giữ đường nào? Câu trả lời: giữ đường tốt nhất tại điểm giữa trong số mọi đường từng "đi qua" node này.
Khi thêm một đường mới line vào node đang giữ đường cur, hãy nhìn vào hai điểm: điểm giữa và một đầu mút (chẳng hạn ). Hai đường thẳng cắt nhau tại tối đa một điểm, nên trên đoạn thứ tự "đường nào thấp hơn" chỉ đổi nhiều nhất một lần. Từ đó:
- So tại : nếu
linethấp hơncurtại , đổi chỗ hai đường — bây giờ node giữline, còncurtrở thành đường "thua" cần đẩy xuống. - So tại đầu mút : nếu đường thua tốt hơn ở phía (tức quan hệ tại và tại khác nhau), thì giao điểm nằm ở nửa trái → đẩy đường thua xuống con trái. Ngược lại đẩy xuống con phải.
Vì đường thua chỉ có thể "ngoi lên" trên đúng một nửa, ta luôn đẩy nó về đúng nửa chứa giao điểm, và đệ quy. Mỗi lần thêm đi xuống tối đa tầng.
Bất biến (invariant) sau khi thêm xong: tại mỗi node , đường được lưu là đường tốt nhất tại trong số tất cả đường đã thêm mà đường đi của chúng có ghé qua node này. Nhờ vậy, khi truy vấn tại , đáp án tối ưu chắc chắn nằm trên đường được lưu tại một node nào đó trên đường đi từ gốc xuống lá : ta chỉ cần lấy giá trị của các đường lưu dọc theo node đó.
Vì sao chỉ cần xét dọc đường đi? Đường tối ưu tại , giả sử là , khi được thêm vào đã được "đẩy" theo đúng quy tắc trên. Tại mỗi node nó đi qua, hoặc nó được giữ lại (nếu thắng tại ), hoặc nó bị đẩy xuống nửa chứa . Cứ thế nó hoặc dừng tại một node tổ tiên của , hoặc tiến mãi tới lá . Trong cả hai trường hợp, có mặt trên đường gốc→lá của .
Ví dụ chạy tay
Xét trục và lần lượt thêm 3 đường (tìm min):
Giá trị tại các điểm :
| min đúng | ||||
|---|---|---|---|---|
| 0 | 0 | 3 | 1 | 0 (A) |
| 1 | 2 | 2 | 1 | 1 (C) |
| 2 | 4 | 1 | 1 | 1 (B/C) |
| 3 | 6 | 0 | 1 | 0 (B) |
Cây dựng trên , với của gốc là :
node1 [0,3] mid=1
/ \
node2 [0,1] mid=0 node3 [2,3] mid=2
/ \ / \
[0,0] [1,1] [2,2] [3,3]
Thêm A (): cây rỗng, gốc nhận luôn A. Gốc giữ A.
Thêm B () tại gốc [0,3], mid=1:
- Tại : , — không nhỏ hơn hẳn, không đổi (gốc vẫn giữ A).
- Tại đầu mút : → B không tốt hơn ở trái; quan hệ tại giống tại → đẩy B xuống con phải node3 [2,3].
- Tại node3 [2,3], mid=2: node3 rỗng → nhận B. node3 giữ
B.
Thêm C () tại gốc [0,3], mid=1:
- Tại : → đổi chỗ. Gốc giờ giữ
C, đường thua làA(). - Tại : đường thua so với đường đang giữ : A tốt hơn ở trái, mà tại thì A thua → giao điểm ở nửa trái → đẩy A xuống con trái node2 [0,1].
- Tại node2 [0,1], mid=0: node2 rỗng → nhận A. node2 giữ
A.
Trạng thái cuối:
node1 [0,3] giữ C: y=1
/ \
node2 [0,1] giữ A:2x node3 [2,3] giữ B:-x+3
Truy vấn : đi gốc→trái→lá. Dọc đường: gốc giữ , node2 giữ . . Đúng (A thắng tại 0). ✔
Truy vấn : đi gốc→phải→lá. Dọc đường: gốc , node3 . . Đúng (B thắng tại 3). ✔
Truy vấn : dừng ở node2 (lá phía ). Dọc đường: gốc , node2 . . Đúng (C thắng tại 1). ✔
Cài đặt
Phiên bản đoạn cố định (đệ quy trên mảng tree), tìm min. Đường mặc định là đường "vô cực" () để node chưa có đường không ảnh hưởng kết quả.
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = 4e18; // đủ lớn, vẫn an toàn long long
struct Line {
ll m, b; // y = m*x + b
ll eval(ll x) const { return m * x + b; }
};
struct LiChaoTree {
ll lo, hi; // miền giá trị x cho phép: [lo, hi]
vector<Line> tree; // mỗi node giữ 1 đường
LiChaoTree(ll lo, ll hi) : lo(lo), hi(hi) {
// 4*(số điểm) node là đủ cho cây phân đoạn trên [lo, hi]
tree.assign(4 * (hi - lo + 1), {0, INF});
}
// thêm 'nw' vào cây con gốc 'node' phụ trách [l, r]
void add(Line nw, int node, ll l, ll r) {
ll mid = l + (r - l) / 2; // tránh tràn khi l, r lớn
// so sánh tại mid và tại biên trái l
bool atMid = nw.eval(mid) < tree[node].eval(mid);
bool atLeft = nw.eval(l) < tree[node].eval(l);
// nếu thắng tại mid: giữ nw, đẩy đường cũ (đã swap vào nw) xuống
if (atMid) swap(nw, tree[node]);
if (l == r) return; // tới lá, dừng
// giao điểm nằm ở nửa nào? => đẩy "đường thua" (nw) về đúng nửa đó
if (atLeft != atMid) add(nw, 2*node, l, mid);
else add(nw, 2*node+1, mid+1, r);
}
void add(ll m, ll b) { add({m, b}, 1, lo, hi); }
// min trên đường đi gốc -> lá x
ll query(ll x, int node, ll l, ll r) {
ll res = tree[node].eval(x);
if (l == r) return res;
ll mid = l + (r - l) / 2;
if (x <= mid) return min(res, query(x, 2*node, l, mid));
else return min(res, query(x, 2*node+1, mid+1, r));
}
ll query(ll x) { return query(x, 1, lo, hi); }
};
Cách dùng cho DP (mỗi là đường hệ số góc , tung độ gốc ; query tại ):
LiChaoTree lct(MIN_A, MAX_A);
dp[0] = 0;
lct.add(b[0], dp[0]); // thêm đường của trạng thái 0
for (int i = 1; i <= n; i++) {
dp[i] = lct.query(a[i]); // min các đường tại x = a[i]
lct.add(b[i], dp[i]); // sinh đường mới từ dp[i]
}
Tìm max thay vì min? Cách gọn nhất: thêm đường và lấy . Hoặc đảo mọi dấu < thành > và đổi INF thành -INF.
Độ phức tạp
- Thời gian: mỗi lần
addđi từ gốc xuống tối đa một lá, mỗi tầng làm việc → . Mỗiquerycũng chỉ duyệt node trên đường gốc→lá. Với thao tác: , trong đó . - Bộ nhớ: phiên bản đoạn cố định cấp phát phần tử (mỗi phần tử là một
Line) → . Nếu tới thì là quá lớn — khi đó phải nén tọa độ (rời rạc hóa về giá trị) hoặc dùng Li Chao động (cấp phát node theo nhu cầu, bộ nhớ). Xem phần Mở rộng.
⚠️ Lỗi thường gặp
- Tràn số khi tính
m*x. Với cỡ , tích tới — vượtintvà sát trầnlong long. Luôn dùnglong long(hoặc__int128nếu còn cộng dồn). Triệu chứng: đáp án âm/loạn ngẫu nhiên. Đặc biệt cẩn thận vớiINF: nếu đểINF = 1e18rồi đường thật cộng vào,evalcủa đường ảo có thể tràn — dùngINFđủ lớn nhưng để dành biên (vd4e18) và đảm bảo đường ảo không bao giờ tham gia phép cộng làm tràn; an toàn nhất là khởi tạo đường ảo bằng cờ "rỗng". - Tính
midbị tràn khi miền âm–dương lớn.(l + r) / 2với thì ổn, nhưng với miền lớn hơn dễ tràn. Dùngmid = l + (r - l) / 2. Ngoài ramidphải tính theo đúng kiểu của (long long), không phảiint. - Quên rằng cây dựng trên trục , không phải trên mảng. Số lá bằng số giá trị phân biệt, không bằng . Nếu nằm trong mà bạn cấp phát node thì sai hoàn toàn. Phải hoặc nén tọa độ, hoặc dùng bản động với cận thật.
- Truy vấn ngoài miền . Cây chỉ phủ ; hỏi ngoài miền cho kết quả rác. Đặt bao trùm mọi điểm sẽ hỏi (kể cả điểm tương lai nếu online).
- Đẩy nhầm nửa. Việc chọn con trái/phải dựa trên so sánh tại và (
atLeft != atMid). Nếu bạn so tại thay vì thì logic phải đổi tương ứng; trộn lẫn hai quy ước sẽ làm mất đường và ra kết quả lớn hơn thực tế (với min). Hãy nhất quán một quy ước. - Dùng Li Chao cho min nhưng quên đảo dấu khi cần max (hoặc ngược lại). Triệu chứng: đáp án ra giá trị "tốt ngược". Kiểm bằng một test nhỏ tự tính tay như ví dụ ở trên.
Biến thể / Mở rộng
- Li Chao Tree động (dynamic). Khi miền rất rộng () và không tiện nén tọa độ, dùng cây cấp phát node theo nhu cầu (con trỏ trái/phải tạo khi cần). Bộ nhớ . Phù hợp cho query online với tọa độ khổng lồ.
struct Node { Line line{0, INF}; Node *l = nullptr, *r = nullptr; };
void add(Node*& t, ll lo, ll hi, Line nw) {
if (!t) { t = new Node{nw}; return; }
ll mid = lo + (hi - lo) / 2;
bool atMid = nw.eval(mid) < t->line.eval(mid);
bool atLeft = nw.eval(lo) < t->line.eval(lo);
if (atMid) swap(nw, t->line);
if (lo == hi) return;
if (atLeft != atMid) add(t->l, lo, mid, nw);
else add(t->r, mid + 1, hi, nw);
}
ll query(Node* t, ll lo, ll hi, ll x) {
if (!t) return INF;
ll res = t->line.eval(x);
if (lo == hi) return res;
ll mid = lo + (hi - lo) / 2;
if (x <= mid) return min(res, query(t->l, lo, mid, x));
else return min(res, query(t->r, mid + 1, hi, x));
}
- Li Chao Tree merge / persistent. Có thể hợp nhất hai cây hoặc tạo bản bền vững để dùng trong DP trên cây (small-to-large, hoặc rút gọn theo cấu trúc cây con).
- Kết hợp với Divide & Conquer Optimization / phân đoạn. Nhiều bài "chia đoạn, chi phí mỗi đoạn tuyến tính theo chỉ số" giải được bằng Li Chao theo từng lớp (mỗi lớp xây lại cây từ lớp trước), thay cho CHT khi điểm hỏi không đơn điệu.
- So sánh nhanh với CHT:
| CHT (stack) | Li Chao Tree | |
|---|---|---|
| Điều kiện hệ số góc | đơn điệu | bất kỳ |
| Điều kiện điểm hỏi | đơn điệu | bất kỳ |
| Thêm đường | khấu hao | |
| Truy vấn | / | |
| Xóa đường | khó | (có bản nâng cao) |
| Cài đặt | đơn giản | trung bình |
Bài tập luyện
- Nhà kính của Sprout (greenhouse) — (Expert) Chia cây thành cụm phục vụ bởi nhà kính, tổng chi phí khoảng cách nhỏ nhất: DP với công thức chuyển tuyến tính theo vị trí — đúng mẫu áp dụng CHT/Li Chao theo từng lớp .
- Chuồng bò tròn mới (cbarnrev) — (Expert) Chia vòng tròn thành đoạn, chi phí mỗi đoạn là tổng "số bước × số bò" — hàm chuyển tuyến tính theo chỉ số đầu đoạn, tối ưu DP bằng kỹ thuật họ Li Chao/CHT.
- Cải tạo vườn hoa (landscape) — (Expert) DP chuyển đất với chi phí phụ thuộc khoảng cách tuyến tính theo chỉ số; thuộc họ tối ưu hàm bậc nhất, luyện tư duy biến công thức chuyển thành "đường thẳng + truy vấn min".
(Ghi chú: pool CTOJ hiện rất ít bài gắn nhãn Li Chao/CHT; ba bài trên là các bài có công thức chuyển DP tuyến tính theo chỉ số — đúng họ kỹ thuật mà Li Chao Tree nhắm tới — sắp theo độ khó. Khi pool bổ sung thêm bài đúng tag, danh sách sẽ được cập nhật.)