Cần một tập đường thẳng hỗ trợ thêm đường và hỏi giá trị nhỏ nhất tại , với hệ số góc và điểm hỏi theo thứ tự bất kỳ. Bao lồi (bài kỹ thuật bao lồi) đòi hệ số góc đơn điệu; cây Li Chao thì không: mỗi nút của cây phân đoạn trên miền giữ một đường, thêm và hỏi đều . Cùng khung còn chèn được đoạn thẳng (đường chỉ có hiệu lực trên một khoảng) và gộp hai cây, hợp cho DP trên cây.
Chèn và hỏi
Mỗi nút phụ trách đoạn của miền và giữ một đường. Chèn đường vào nút đang giữ :
- So và tại điểm giữa ; đường nhỏ hơn ở ở lại nút, đường kia đi tiếp.
- Hai đường cắt nhau tối đa một lần, nên đường thua ở chỉ có thể thắng ở một nửa: nửa trái nếu nó thắng ở , ngược lại nửa phải. Đệ quy vào đúng nửa đó.
Nút rỗng thì đặt đường vào và dừng, nên mỗi lần chèn tạo tối đa một nút mới: cây động trên miền chỉ cần số nút bằng số đường. Hỏi tại : đi từ gốc xuống lá chứa , lấy nhỏ nhất của các đường gặp trên đường đi. Đúng vì đường tốt nhất tại luôn nằm trên đường từ gốc tới lá đó.
Điểm giữa của đoạn có số âm lấy bằng (l + r) >> 1 để làm tròn xuống. Giá trị với , vừa long long.
Hỏi nhanh
Nút phụ trách đang giữ . Chèn . Đường nào ở lại nút, đường nào đi tiếp và đi về nửa nào?
Tại : nên ở lại. Tại : , thua cả ở đầu trái, nên chỉ có thể thắng ở nửa phải; quả thật khi .
Ví dụGiá Trị Thấp Nhất
Đề. Có sẵn đường, rồi thao tác (tổng tới ): thêm đường , hoặc hỏi giá trị nhỏ nhất tại , với , .
Lời giải. Cây Li Chao trên miền , nút chỉ được tạo khi cần, nên số nút không vượt số đường. Đường có sẵn và đường thêm sau đều chèn cùng một cách; hỏi là đi một đường từ gốc xuống.
Bước 1: chèn một đường. Mỗi nút giữ một đường; lc, rc bằng 0 là chưa có con. insert nhận x theo tham chiếu nên tạo nút mới là gán thẳng vào ô con của cha. left là đường mới tốt hơn ở đầu trái , mid là tốt hơn ở trung điểm . Nếu mid thì đổi chỗ: nút luôn giữ đường tốt hơn tại , đường thua đi tiếp.
void insert(int &x, ll l, ll r, ll a, ll b) {
if (!x) {
x = ++tot, la[x] = a, lb[x] = b;
return;
}
ll m = (l + r) >> 1;
bool left = a * l + b < f(x, l), mid = a * m + b < f(x, m);
if (mid) swap(la[x], a), swap(lb[x], b);
if (l == r) return;
if (left != mid) insert(lc[x], l, m, a, b);
else insert(rc[x], m + 1, r, a, b);
}
Đi nửa nào: hai đường cắt nhau tối đa một lần. left != mid thì hai đường đổi thứ tự giữa và , nên đường thua chỉ có thể thắng ở nửa trái. left == mid thì trên cả nửa trái đường giữ ở nút vẫn tốt hơn, đường thua chỉ còn cơ hội ở nửa phải.
Bước 2: hỏi. Đi từ gốc xuống theo , lấy nhỏ nhất của mọi đường gặp. Đường tốt nhất tại hoặc nằm ở một nút trên đường đi, hoặc đã bị đẩy xuống đúng nửa chứa .
while (x) {
res = min(res, f(x, p));
ll m = (l + r) >> 1;
if (p <= m) x = lc[x], r = m;
else x = rc[x], l = m + 1;
}
Tràn số: cộng vẫn dưới . (l + r) >> 1 làm tròn xuống cả khi âm, khác với / 2.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int M = 4e5 + 5;
const ll L = -1e9, R = 1e9, INF = LLONG_MAX;
int tot, root, lc[M], rc[M];
ll la[M], lb[M];
ll f(int x, ll p) {
return la[x] * p + lb[x];
}
void insert(int &x, ll l, ll r, ll a, ll b) {
if (!x) {
x = ++tot, la[x] = a, lb[x] = b;
return;
}
ll m = (l + r) >> 1;
bool left = a * l + b < f(x, l), mid = a * m + b < f(x, m);
if (mid) swap(la[x], a), swap(lb[x], b);
if (l == r) return;
if (left != mid) insert(lc[x], l, m, a, b);
else insert(rc[x], m + 1, r, a, b);
}
ll query(ll p) {
ll res = INF, l = L, r = R;
int x = root;
while (x) {
res = min(res, f(x, p));
ll m = (l + r) >> 1;
if (p <= m) x = lc[x], r = m;
else x = rc[x], l = m + 1;
}
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, q;
cin >> n >> q;
for (int i = 0; i < n; i++) {
ll a, b;
cin >> a >> b;
insert(root, L, R, a, b);
}
while (q--) {
int t;
cin >> t;
if (t == 0) {
ll a, b;
cin >> a >> b;
insert(root, L, R, a, b);
} else {
ll x;
cin >> x;
cout << query(x) << '\n';
}
}
}
2 5
2 5
-1 8
1 0
0 1 -3
1 4
1 -2
1 3
5
1
-5
0
Mỗi thao tác bước.
Đoạn thẳng
Đường chỉ có hiệu lực trên : đi xuống cây phân đoạn như cập nhật đoạn, tại mỗi nút nằm trọn trong thì chèn kiểu Li Chao bình thường trong cây con của nút đó. Có nút phủ, mỗi nút chèn tốn , tổng mỗi đoạn; hỏi vẫn .
Làm ngoại tuyến thì nén các điểm hỏi thành mảng xs đã sắp, dựng cây Li Chao tĩnh trên chỉ số , so sánh đường tại xs[m]. Đoạn đổi thành khoảng chỉ số các điểm hỏi nằm trong nó. Không có điểm hỏi nào trong đoạn thì bỏ qua đường đó.
Ví dụHàm Trên Nửa Khoảng
Đề. thao tác: thêm hàm hiệu lực trên , hoặc hỏi giá trị nhỏ nhất tại (in INFINITY nếu không có hàm nào).
Lời giải. Làm ngoại tuyến. Đọc hết thao tác, gom mọi được hỏi, sắp và bỏ trùng thành xs. Cây Li Chao dựng trên chỉ số , so sánh các đường tại xs[m] thay vì tại . Hàm hiệu lực trên đổi thành khoảng chỉ số các điểm hỏi nằm trong đó; khoảng này phủ bởi nút của cây phân đoạn, và mỗi nút phủ nhận một lần chèn Li Chao trong cây con của nó.
Bước 1: chèn Li Chao trên chỉ số. Giống insert của ví dụ trước, viết bằng vòng lặp, thay , bằng xs[l], xs[m]. has[x] = 0 là nút chưa có đường.
bool left = a * xs[l] + b < la[x] * xs[l] + lb[x];
bool mid = a * xs[m] + b < la[x] * xs[m] + lb[x];
if (mid) swap(la[x], a), swap(lb[x], b);
if (l == r) return;
if (left != mid) x = 2 * x, r = m;
else x = 2 * x + 1, l = m + 1;
Bước 2: chèn theo đoạn. Đi như cập nhật đoạn của cây phân đoạn; ở mỗi nút nằm trọn trong khoảng thì gọi push cho cây con của nút đó. Đường đặt ở nút này chỉ được hỏi bởi các điểm nằm trong đoạn của nút, nên nó không lọt ra ngoài .
void addSeg(int x, int l, int r, int ql, int qr, ll a, ll b) {
if (qr < l || r < ql) return;
if (ql <= l && r <= qr) {
push(x, l, r, a, b);
return;
}
int m = (l + r) / 2;
addSeg(2 * x, l, m, ql, qr, a, b);
addSeg(2 * x + 1, m + 1, r, ql, qr, a, b);
}
Bước 3: đổi thành chỉ số. ql là điểm hỏi đầu tiên , qr là điểm hỏi cuối cùng . ql > qr nghĩa là không ai hỏi trong khoảng đó, bỏ qua đường. Thao tác làm lại đúng thứ tự đọc, nên câu hỏi chỉ thấy các hàm thêm trước nó.
int ql = lower_bound(xs.begin(), xs.end(), o[1]) - xs.begin();
int qr = lower_bound(xs.begin(), xs.end(), o[2]) - xs.begin() - 1;
if (ql <= qr) addSeg(1, 0, k - 1, ql, qr, o[3], o[4]);
query đi từ gốc tới lá p; mỗi nút trên đường đi chứa p trong đoạn của mình, nên mọi đường ở đó đều có hiệu lực tại xs[p].
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 2e5 + 5;
const ll INF = LLONG_MAX;
int k, has[4 * N];
ll la[4 * N], lb[4 * N];
vector<ll> xs;
void push(int x, int l, int r, ll a, ll b) {
while (true) {
if (!has[x]) {
has[x] = 1, la[x] = a, lb[x] = b;
return;
}
int m = (l + r) / 2;
bool left = a * xs[l] + b < la[x] * xs[l] + lb[x];
bool mid = a * xs[m] + b < la[x] * xs[m] + lb[x];
if (mid) swap(la[x], a), swap(lb[x], b);
if (l == r) return;
if (left != mid) x = 2 * x, r = m;
else x = 2 * x + 1, l = m + 1;
}
}
void addSeg(int x, int l, int r, int ql, int qr, ll a, ll b) {
if (qr < l || r < ql) return;
if (ql <= l && r <= qr) {
push(x, l, r, a, b);
return;
}
int m = (l + r) / 2;
addSeg(2 * x, l, m, ql, qr, a, b);
addSeg(2 * x + 1, m + 1, r, ql, qr, a, b);
}
ll query(int p) {
ll res = INF;
int x = 1, l = 0, r = k - 1;
while (true) {
if (has[x]) res = min(res, la[x] * xs[p] + lb[x]);
if (l == r) return res;
int m = (l + r) / 2;
if (p <= m) x = 2 * x, r = m;
else x = 2 * x + 1, l = m + 1;
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n, q;
cin >> n >> q;
vector<array<ll, 5>> ops;
for (int i = 0; i < n; i++) {
ll l, r, a, b;
cin >> l >> r >> a >> b;
ops.push_back({0, l, r, a, b});
}
for (int i = 0; i < q; i++) {
ll t;
cin >> t;
if (t == 0) {
ll l, r, a, b;
cin >> l >> r >> a >> b;
ops.push_back({0, l, r, a, b});
} else {
ll x;
cin >> x;
ops.push_back({1, x, 0, 0, 0});
xs.push_back(x);
}
}
sort(xs.begin(), xs.end());
xs.erase(unique(xs.begin(), xs.end()), xs.end());
k = xs.size();
for (array<ll, 5> o : ops) {
if (o[0] == 0) {
int ql = lower_bound(xs.begin(), xs.end(), o[1]) - xs.begin();
int qr = lower_bound(xs.begin(), xs.end(), o[2]) - xs.begin() - 1;
if (ql <= qr) addSeg(1, 0, k - 1, ql, qr, o[3], o[4]);
} else {
ll v = query(lower_bound(xs.begin(), xs.end(), o[1]) - xs.begin());
if (v == INF) cout << "INFINITY\n";
else cout << v << '\n';
}
}
}
1 5
0 5 2 3
1 0
1 5
0 4 8 -1 10
1 4
1 6
3
INFINITY
6
4
Mỗi hàm , mỗi câu hỏi .
DP với hàm bậc nhất
DP dạng : với mỗi đã tính xong, thêm đường hệ số góc , tung độ gốc ; tính là hỏi tại . Bình phương hiệu là dạng hay gặp: , phần chỉ phụ thuộc đưa ra ngoài, phần chỉ phụ thuộc vào tung độ gốc, tích chéo là hệ số góc nhân điểm hỏi.
Hỏi nhanh
. Đường thẳng thêm vào sau khi tính là gì?
. Phần cộng sau khi hỏi; phần còn lại là đường tại .
Khi điểm hỏi nguyên trong miền nhỏ (như ), dùng cây tĩnh trên mảng phần tử, không cần nút động.
Ví dụBệ Cao Dần
Đề. bệ, độ cao tăng . Nhảy từ tới tốn , . Tìm chi phí nhỏ nhất từ bệ 1 tới bệ .
Lời giải. là chi phí nhỏ nhất để tới bệ . Khai triển được
Phần trong ngoặc là giá trị tại của đường hệ số góc , tung độ . Tính xong thì thêm đường của ; tính là hỏi nhỏ nhất tại . Độ cao nguyên tới nên dùng cây Li Chao tĩnh trên mảng ô, nút có con , ; insert, query viết bằng vòng lặp như các ví dụ trước.
Bệ đầu có , thêm đường của nó trước vòng lặp. Mỗi bệ sau: hỏi tại , cộng , rồi thêm đường của chính nó.
insert(-2 * h[0], h[0] * h[0]);
for (int i = 1; i < n; i++) {
dp[i] = query(h[i]) + h[i] * h[i] + c;
insert(-2 * h[i], dp[i] + h[i] * h[i]);
}
Độ lớn: hệ số góc tới nhân tới là ; không quá , khoảng , vẫn vừa long long.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int X = 1e6;
const ll INF = LLONG_MAX;
ll la[4 * X + 5], lb[4 * X + 5];
bool has[4 * X + 5];
void insert(ll a, ll b) {
int x = 1, l = 1, r = X;
while (true) {
if (!has[x]) {
has[x] = 1, la[x] = a, lb[x] = b;
return;
}
int m = (l + r) / 2;
bool left = a * l + b < la[x] * l + lb[x], mid = a * m + b < la[x] * m + lb[x];
if (mid) swap(la[x], a), swap(lb[x], b);
if (l == r) return;
if (left != mid) x = 2 * x, r = m;
else x = 2 * x + 1, l = m + 1;
}
}
ll query(ll p) {
ll res = INF;
int x = 1, l = 1, r = X;
while (true) {
if (has[x]) res = min(res, la[x] * p + lb[x]);
if (l == r) return res;
int m = (l + r) / 2;
if (p <= m) x = 2 * x, r = m;
else x = 2 * x + 1, l = m + 1;
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n;
ll c;
cin >> n >> c;
vector<ll> h(n), dp(n);
for (int i = 0; i < n; i++) cin >> h[i];
dp[0] = 0;
insert(-2 * h[0], h[0] * h[0]);
for (int i = 1; i < n; i++) {
dp[i] = query(h[i]) + h[i] * h[i] + c;
insert(-2 * h[i], dp[i] + h[i] * h[i]);
}
cout << dp[n - 1] << '\n';
}
6 7
2 5 6 11 12 20
134
.
Gộp cây Li Chao trên cây
DP trên cây : mỗi đỉnh đóng góp đường , đỉnh hỏi tại trên tập đường của cây con. Mỗi đỉnh giữ một cây Li Chao động; cây của là gộp cây của các con: merge(x, y) chèn đường ở gốc vào rồi gộp đệ quy các cặp con trái, con phải. Mỗi đường bị đẩy xuống sâu hơn sau mỗi lần chèn, nên tổng công là lần đẩy cộng lần gộp.
Xử lý đỉnh theo thứ tự BFS ngược để con xong trước cha, khỏi đệ quy theo cây.
Ví dụNhảy Xuống Lá
Đề. Cây đỉnh, gốc 1, . Từ không phải lá nhảy tới hậu duệ nghiêm ngặt tốn , nhảy tiếp tới khi tới lá. In chi phí nhỏ nhất từ mỗi đỉnh (lá là 0).
Lời giải. trên mọi hậu duệ nghiêm ngặt , lá có . Với cố định, là giá trị tại của đường . Vậy mỗi đỉnh giữ một cây Li Chao chứa đường của mọi đỉnh trong cây con của nó. Cây của là gộp cây của các con; hỏi tại cho ; rồi mới thêm đường của chính , để không tự nhảy tới chính nó. insert, query là Li Chao động trên như ví dụ đầu bài.
Bước 1: gộp hai cây Li Chao. Chèn đường ở gốc y vào cây x, rồi gộp đệ quy hai cặp con. Nút y bị bỏ sau khi đường của nó được chèn vào x; các nút con của y được dùng lại. Mỗi đường chỉ đi xuống sâu hơn sau mỗi lần bị chèn, độ sâu tối đa , nên tổng công của mọi lần gộp là .
int merge(int x, int y, ll l, ll r) {
if (!x || !y) return x | y;
insert(x, l, r, la[y], lb[y]);
ll m = (l + r) >> 1;
lc[x] = merge(lc[x], lc[y], l, m);
rc[x] = merge(rc[x], rc[y], m + 1, r);
return x;
}
Bước 2: duyệt từ lá lên. BFS từ gốc lấy par và order; duyệt order từ cuối thì con xong trước cha. Đỉnh không có con là lá, .
for (int i = n - 1; i >= 0; i--) {
int u = order[i];
bool leaf = 1;
for (int v : adj[u])
if (v != par[u]) leaf = 0, root[u] = merge(root[u], root[v], L, R);
dp[u] = leaf ? 0 : query(root[u], a[u]);
insert(root[u], L, R, b[u], dp[u]);
}
Độ lớn: , đường dài tới bước nên , vừa long long. Mỗi lần insert tạo tối đa một nút, nên mảng M = 4e6 nút là dư.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 1e5 + 5, M = 4e6 + 5;
const ll L = -1e5, R = 1e5, INF = LLONG_MAX;
int n, tot, root[N], par[N], lc[M], rc[M];
ll a[N], b[N], dp[N], la[M], lb[M];
vector<int> adj[N];
void insert(int &x, ll l, ll r, ll p, ll q) {
if (!x) {
x = ++tot, la[x] = p, lb[x] = q;
return;
}
ll m = (l + r) >> 1;
bool left = p * l + q < la[x] * l + lb[x], mid = p * m + q < la[x] * m + lb[x];
if (mid) swap(la[x], p), swap(lb[x], q);
if (l == r) return;
if (left != mid) insert(lc[x], l, m, p, q);
else insert(rc[x], m + 1, r, p, q);
}
int merge(int x, int y, ll l, ll r) {
if (!x || !y) return x | y;
insert(x, l, r, la[y], lb[y]);
ll m = (l + r) >> 1;
lc[x] = merge(lc[x], lc[y], l, m);
rc[x] = merge(rc[x], rc[y], m + 1, r);
return x;
}
ll query(int x, ll p) {
ll res = INF, l = L, r = R;
while (x) {
res = min(res, la[x] * p + lb[x]);
ll m = (l + r) >> 1;
if (p <= m) x = lc[x], r = m;
else x = rc[x], l = m + 1;
}
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= n; i++) cin >> b[i];
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
vector<int> order = {1};
for (int i = 0; i < (int)order.size(); i++) {
int u = order[i];
for (int v : adj[u])
if (v != par[u]) par[v] = u, order.push_back(v);
}
for (int i = n - 1; i >= 0; i--) {
int u = order[i];
bool leaf = 1;
for (int v : adj[u])
if (v != par[u]) leaf = 0, root[u] = merge(root[u], root[v], L, R);
dp[u] = leaf ? 0 : query(root[u], a[u]);
insert(root[u], L, R, b[u], dp[u]);
}
for (int i = 1; i <= n; i++) cout << dp[i] << ' ';
cout << '\n';
}
6
4 -2 3 5 1 7
2 6 -1 4 3 -5
1 2
1 3
2 4
2 5
3 6
-20 -8 -15 0 0 0
với .
Khi đi thi
- DP mà mỗi trạng thái cũ cho một hàm bậc nhất của điểm hỏi, hệ số góc hay điểm hỏi không đơn điệu: cây Li Chao, mỗi thao tác. Đơn điệu cả hai thì bao lồi với con trỏ gọn hơn.
- Miền lớn: cây động, mỗi lần chèn tạo một nút; miền nhỏ hay biết trước điểm hỏi: cây tĩnh trên mảng, nén điểm hỏi khi cần.
- Đường có miền hiệu lực: chèn vào các nút phủ, . DP trên cây: gộp cây Li Chao của các con; DFS với hoàn tác thì ghi lại nút bị đổi.
Tóm tắt
- Mỗi nút giữ đường tốt nhất tại điểm giữa; đường thua đi về nửa duy nhất nó có thể thắng.
- Hỏi lấy nhỏ nhất trên đường từ gốc tới lá chứa điểm hỏi.
- Đoạn thẳng: chèn Li Chao trong các nút phủ khoảng hiệu lực.
- Gộp: chèn đường gốc của cây này vào cây kia, gộp đệ quy hai con.
Bài tập
Đọc thêm
- Li Chao TreeVNOI Wiki
- Convex hull trick and Li Chao treecp-algorithms
- 李超线段树OI Wiki