Người viết:
Reviewer:
Mục đích của bài viết này là để giới thiệu cho mọi người về một mô típ đã xuất hiện trong nhiều bài tập trung bình khó tới khó -- tối ưu tổng của một số hàm lồi cho trước. Ý tưởng giải chung của các bài toán này thường là như nhau: tham lam trên chi phí tăng của các hàm lồi này (ta sẽ định nghĩa chi phí tăng ở phần tiếp theo).
Bài viết này là phần 1 của một chuỗi 2 bài viết; phần 2 của chuỗi bài viết này mình sẽ giới thiệu thuật toán tìm tổng Minkowski của các bao lồi.
Mình sẽ sử dụng các khái niệm sau xuyên suốt bài viết.
Ta xét bài toán tổng quát sau.
Cho hàm lồi được định nghĩa trên tập số nguyên không âm và một giá trị cho trước. Tìm số nguyên không âm sao cho:
In ra giá trị nhỏ nhất của .
Giới hạn: .
Để việc trình bày được thuận tiện hơn, đầu tiên ta xét bài toán với .
Xét thao tác sau: khởi tạo và , ta lặp bước sau lần:
Ý tưởng cơ bản của cách làm trên là ở mỗi bước, ta tăng tổng lên ; ta chọn cách làm mà tăng tổng lên ít nhất.
Để chứng minh cách làm trên là đúng, giả sử đáp án của bài toán là . Ta xét ba dãy sau:
Nhận xét là nếu ta sắp xếp dãy theo thứ tự tăng dần rồi lấy tổng phần tử nhỏ nhất, ta sẽ thu được một số mà chắc chắn là .
Ngoài ra, ta cũng có thể chứng minh được là thao tác trên sẽ thu được tổng đúng bằng . Để ý rằng khi sắp xếp dãy tăng dần, ta sẽ có vì là hàm lồi. Tương tự, với dãy , sau khi sắp xếp các giá trị của tăng dần. Nói cách khác, dãy và được sắp xếp theo . Bởi vì chẳng qua là dãy ghép của và , dễ dàng nhận thấy qua thuật toán merge sort rằng khi thao tác trên dừng, ta thu được giá trị nhỏ nhất của .
Với lớn hơn, ta có thuật toán tương tự: khởi tạo mọi , lặp bước sau lần:
Ta có thể sử dụng heap để cài đặt thuật toán trên với độ phức tạp là .
int n; // Số lượng hàm f_i
int f(int i, int x){
// Trả về f_i(x)
}
long long cost_greedy(int k) {
vector<int> x(n, 0);
long long ans = 0;
priority_queue<pair<int, int>> pq;
// lưu cặp {f_i(x_i) - f_i(x_i + 1), i} vì priority_queue lấy phần tử lớn nhất
for (int i = 0; i < n; i++) {
pq.push({f(i, 0) - f(i, 1), i});
}
while (k--) {
int cost = pq.top().first, i = pq.top().second;
pq.pop();
ans -= cost; // cost = f_i(x_i) - f_i(x_i + 1)
x[i]++;
pq.push({f(i, x[i]) - f(i, x[i] + 1), i});
}
return ans;
}
Link bài: CF - 1428E.
Có củ cà rốt có độ dài . Người chủ muốn cắt các củ cà rốt này các phần có độ dài nguyên dương cho chú thỏ. Một phần cà rốt có độ dài sẽ mất giây để ăn. Người chủ này muốn cắt cà rốt thành phần sao cho tổng thời gian ăn hết phần này là nhỏ nhất có thể.
Giới hạn:
Xét hàm là tổng thời gian ăn nhỏ nhất nếu ta chỉ xét củ cà rốt , và củ cà rốt này được chia thành đúng phần.
Nhận xét rằng ta có thể tính trong độ phức tạp với mọi : để ý rằng khi chia củ cà rốt thành phần, các phần này cần có độ dài cách nhau tối đa là (ta có thể chứng minh bằng việc nhận xét rằng là tổng với , từ đó ta có thể dùng cách lập luận tham chi phí tăng để chứng minh điều này, chi tiết xin để dành cho bạn đọc).
Quan trọng hơn, ta nhận thấy là mọi là hàm lồi. Để chứng minh điều này với mọi , ta giả sử củ cà rốt có độ dài là . Nhận xét rằng với mọi , . Đây là vì nếu củ cà rốt thứ được chia thành các phần có độ dài và , thì số lượng đoạn cà rốt có độ dài là chẵn, và tương tự với ; vì thế, ta luôn có thể chia củ cà rốt thứ làm đôi, rồi cắt mỗi phần giống nhau. Ngoài ra, , bởi vì một cách cắt một củ cà rốt có độ dài thành phần là cắt đôi củ cà rốt, rồi cắt nửa đầu thành phần và nửa sau thành phần. Bởi thế, , từ đó ta có .
Bởi thế, ta có thể dùng ý tưởng tham lam được đề cập ở phần bài toán tổng quát để cài đặt bài này với độ phức tạp là .
#include <bits/stdc++.h>
using namespace std;
struct carrot {
int a, b;
carrot(int _a, int _b) : a(_a), b(_b) {}
long long value() const {
// tính f_i(b). mỗi phần có độ dài là a / b hoặc là a / b + 1, và có a % b phần có độ dài là a / b + 1
int cnt = a / b, ov = a % b;
return 1LL * cnt * cnt * (b - ov) + 1LL * (cnt + 1) * (cnt + 1) * ov;
}
long long next() const {
return carrot(a, b).value() - carrot(a, b + 1).value();
}
long long operator<(const carrot& oth) const {
return next() < oth.next();
}
};
int main() {
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
int n, k; cin >> n >> k;
long long ans = 0;
priority_queue<carrot> pq;
for (int i = 0; i < n; i++) {
int a; cin >> a;
carrot cur(a, 1);
ans += cur.value();
pq.push(cur);
}
// để ý rằng x_1 = x_2 = ... = x_n = 1; ta tiếp tục từ đây
// ta cũng không cần phải quan tâm trường hợp ta cắt một củ cà rốt thành nhiều hơn a[i] phần, vì lúc đó .next() sẽ bằng 0 thay vì là một số âm
for (int i = n; i < k; i++) {
carrot u = pq.top(); pq.pop();
ans -= u.next();
pq.push({u.a, u.b + 1});
}
cout << ans << "\n";
}
Link bài: CF - 1344D.
Cho mảng gồm số nguyên dương và một số , bạn cần tìm mảng gồm số nguyên thỏa mãn:
In ra mảng thỏa mãn 3 điều kiện này.
Giới hạn:
Nếu ta đặt , thì ta nhận thấy là là hàm lõm (hay nói cách khác, là hàm lồi). Để chứng minh điều này, với mọi ta có
và vế phải giảm dần khi tăng dần. Vì thế, ta có thể dùng ý tưởng tổng quát như trên. Tuy nhiên, với ở bài toán này, ta không thể trực tiếp sử dụng thuật toán trên. Để tối ưu, ta có thể chặt nhị phân chi phí tăng lớn thứ . Khi đang xét chặt nhị phân với giá trị , ta cần tìm với mọi giá trị lớn nhất sao cho ; thao tác này có thể được thực hiện với độ phức tạp nếu sử dụng công thức phương trình bậc hai, hoặc với độ phức tạp nếu sử dụng thêm 1 vòng chặt nhị phân. Vì thế, ta có thể giải bài trên với độ phức tạp là hoặc với .
Một số lưu ý khi cài đặt thuật toán trên: sẽ có thể tồn tại giá trị mà số lượng chi phí tăng bé hơn , nhưng số lượng chi phí tăng thì lại lớn hơn (vì có thể có nhiều chi phí tăng đúng bằng ). Ta cần phải cẩn thận khi cài đặt trường hợp này.
#include <bits/stdc++.h>
using namespace std;
const long long INF = 4E18;
// chi phí tăng từ b lên b + 1 với a
long long cost(long long a, long long b) {
return a - 3 * b * b - 3 * b - 1;
}
// chặt nhị phân tìm b lớn nhất sao cho cost(a, b) >= t
int get_last(long long a, long long t) {
int l = 0, r = a + 1;
while (l + 1 < r) {
int m = (l + r) / 2;
(cost(a, m) >= t ? l : r) = m;
}
return l;
};
int main() {
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
int n; cin >> n;
long long k; cin >> k;
vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}
// chặt nhị phân tìm giá trị ri nhỏ nhất sao cho số lượng chi phí tăng >= ri là không quá k
long long le = -INF, ri = INF;
while (le + 1 < ri) {
long long mi = (le + ri) / 2;
long long cnt = 0;
for (int i = 0; i < n; i++) {
cnt += get_last(a[i], mi);
}
(cnt <= k ? ri : le) = mi;
}
// có thể xảy ra trường hợp số lượng chi phí tăng >= ri bé hơn k, nhưng số lượng chi phí tăng >= ri - 1 thì lớn hơn k
// ở đây sau khi dựng đáp án với chi phí tăng >= ri, ta chạy thêm 1 lần để chọn thêm một số chi phí tăng
// sao cho tổng b đúng bằng k
long long tot = 0;
vector<int> b(n);
vector<pair<long long, int>> nxt;
for (int i = 0; i < n; i++) {
b[i] = get_last(a[i], ri);
tot += b[i];
if (b[i] < a[i]) {
nxt.push_back({cost(a[i], b[i]), i});
}
}
sort(nxt.begin(), nxt.end(), greater<pair<long long, int>>());
// tăng từ tot lên k
for (int i = 0; i < k - tot; i++) {
b[nxt[i].second]++;
}
for (int i = 0; i < n; i++) {
cout << b[i] << " ";
}
}
Link bài: SEERC 2022 - M.
Jerry có một cái cây vô hướng có đỉnh, trong đó mỗi đỉnh được đặt miếng phô mai.
Có một chú chuột hiện đang đứng ở đỉnh và muốn đi tới đỉnh để thoát khỏi cái cây này. Ở mỗi bước, chú chuột này sẽ dùng mũi đánh hơi số lượng phô mai ở những đỉnh kề đỉnh chú chuột đang đứng, rồi di chuyển tới một đỉnh kề ngẫu nhiên với xác suất tỉ lệ thuận với số lượng phô mai hiện có ở đỉnh này. Lưu ý rằng chú chuột này sẽ không bao giờ quay trở lại những đỉnh chú chuột đã đi trước đó. Nếu không còn đỉnh kề nào hợp lệ để di chuyển, chú chuột sẽ bị kẹt ở đỉnh này mãi mãi.
Jerry muốn giúp chú chuột này thoát khỏi mê cung (tức là đi tới đỉnh ) với xác suất lớn nhất có thể. Để làm được điều này, Jerry có thể đặt thêm một vài miếng phô mai vào các đỉnh ở trong cây. Tuy nhiên, Jerry chỉ có thể sử dụng tối đa miếng phô mai, và Jerry phải đặt một số nguyên các miếng phô mai vào mọi đỉnh.
In ra một cách đặt phô mai để Jerry tối đa hóa xác suất chú chuột kia có thể tẩu thoát.
Giới hạn:
Đầu tiên ta nhận thấy là ta chỉ nên đặt phô mai ở những đỉnh trên đường đi từ tới không bao gồm . Ngoài ra, với mỗi đỉnh trên đường đi từ tới , ta có thể tính được là tổng số lượng phô mai ở những đỉnh là con của cha của --- nói cách khác, là xác suất đi tới đỉnh từ đỉnh cha của trước khi thao tác. Sau khi thêm miếng phô mai vào đỉnh , xác suất này trở thành . Vì thế, xác suất đi tới sẽ là là . Để thuận tiện hơn, ta xét logarit của hàm này: .
Nhận xét rằng nếu ta đặt , thì là hàm lõm. Vì thế, ta có thể đưa về bài toán quen thuộc sau: chọn sao cho:
Ta có thể áp dụng kĩ thuật ở trên: chặt nhị phân chi phí tăng cuối cùng , rồi ở mỗi đỉnh ta chặt nhị phân nhỏ nhất sao cho . Tuy nhiên, độ phức tạp của cách làm này là với là độ chính xác cần đạt được; với bài toán này, cách làm này sẽ bị vượt quá thời gian cho phép ( có thể xuống tới ).
Một ý tưởng cho bài toán này là bỏ giới hạn rằng là số nguyên, rồi từ nghiệm thực nhận được, ta chuyển về bằng cách nào đó (kĩ thuật này được gọi là integer program relaxation). Nói cách khác, xét bài toán sau: cho , chọn sao cho
Ở đây, hàm trên tập số thực là hàm lõm nếu với mọi . Tương tự, hàm trên tập số thực là hàm lồi nếu với mọi . Nhận thấy là hàm được định nghĩa ở trên cũng là một hàm lõm trên tập số thực.
Để giải bài toán trên với nghiệm thực, ta xét ý tưởng tham chi phí tăng nhưng với bước tiến nhỏ. Nói cách khác, ta chọn một hằng số nhỏ nào đó, rồi thực hiện thao tác sau lần:
Ta nhận thấy là với nghiệm nguyên, ta lặp thao tác trên với . Với nghiệm thực, ta lặp thao tác trên nhiều lần với tiến tới . Nhận thấy rằng khi , . Từ nhận xét trên và bởi vì là hàm liên tục, ta có thể kết luật rằng nghiệm thỏa mãn điều kiện sau với mọi :
Vì thế, ta có thể tìm nghiệm thực của bài toán trên như sau: ta chặt nhị phân giá trị ; ở mỗi vòng chặt nhị phân và với mỗi , ta giải sao cho (hoặc gán nếu ). Độ phức tạp của phần này là vì ta có thể trực tiếp giải .
Ta biết rằng $f'_u(\hat{x}_u) = \frac{1}{c_u + \hat{x}_u} - \frac{1}{b_u + \hat{x}_u}$. Giả sử $c_u < b_u$ (nếu không thì ta không cần phải thêm phô mai vào đỉnh này), thì $f'_u(\hat{x}_u) = t$ là phương trình bậc hai, nhận nghiệm $\hat{x}_u = \frac{\sqrt{\frac{4(b_u - c_u)}{t} + (b_u - c_u)^2} - b_u - c_u}{2}$.
Ta nhận được nghiệm thực của bài toán thực trên; bây giờ ta phải chuyển đổi nghiệm thực này thành nghiệm nguyên của bài toán ban đầu. Ta có nhận xét cuối cùng: nghiệm dương phải thỏa mãn với mọi , hoặc với mọi .
Gọi $\hat{x}_u$ là nghiệm thực của bài toán và $x_u$ là nghiệm nguyên của bài toán. Ta chứng minh mệnh đề trên bằng phản chứng.
Giả sử tồn tại và sao cho và . Để ý rằng bởi vì , điều này nghĩa rằng , tức là . Tuy nhiên, ta có:
Bởi thế, theo thuật toán tham chi phí tăng, ta phải chọn tăng lên trước khi chọn tăng lên . Vì thế, không phải nghiệm nguyên của bài toán trên.
Từ hai nhận xét này, ta nhận thấy nghiệm nguyên có thể được tạo nên từ nghiệm thực bằng một trong hai cách sau:
Trên thực tế, với riêng bài toán này, ta chỉ cần sử dụng cách thứ nhất. Mình không biết cách chứng minh điều này, tuy nhiên kể cả khi ta thực hiện cả 2 cách thì độ phức tạp của phần này là .
Vì thế, bài toán có thể được giải với độ phức tạp .
Ở đây mình không sử dụng ; thay vào đó, điều kiện thoát của mình là khi tổng của cách tối đa là .
#include <bits/stdc++.h>
using namespace std;
long double log_prob(long double c, long double b) {
return log(c) - log(b);
}
long double cost(long double c, long double b, long double add) {
return log_prob(c + add + 1, b + add + 1) - log_prob(c + add, b + add);
}
// giải phương trình d/dx(log(c + x) - log(b + x)) = t
long double solve(long double c, long double b, long double t) {
if (t * b >= (b - c) / c) {
return 0.0;
} else {
return 0.5 * (sqrt(4 * (b - c) / t + (c - b) * (c - b)) - c - b);
}
}
int main() {
int n, x; cin >> n >> x;
vector<int> c(n);
vector<vector<int>> adj(n);
for (int i = 0; i < n; i++) {
cin >> c[i];
}
for (int i = 1; i < n; i++) {
int u, v; cin >> u >> v; u--; v--;
adj[u].push_back(v);
adj[v].push_back(u);
}
vector<tuple<int, int, int>> pat;
{
// khởi tạo đường đi từ 1 tới n
function<bool(int, int)> DFS;
DFS = [&](int u, int p) {
if (u == n - 1) {
return true;
} else {
int nod = -1, tot = 0;
for (int v : adj[u]) {
if (v != p) {
tot += c[v];
if (DFS(v, u)) {
nod = v;
}
}
}
// ta chỉ thêm đỉnh vào pat nếu đỉnh này thỏa mãn b_u != c_u
if (nod != -1 && c[nod] != tot) {
pat.push_back({c[nod], tot, nod});
}
return nod != -1;
}
};
DFS(0, -1);
}
// chặt nhị phân đạo hàm
long double le = 0, ri = 1;
while (true) {
long double mi = (le + ri) / 2, cur = 0;
for (auto [c, b, i] : pat) {
cur += solve(c, b, mi);
}
(cur <= x ? ri : le) = mi;
// điều kiện thoát: x - n <= tổng \hat{x}_i <= x
if (cur <= x && cur + n >= x) {
break;
}
}
vector<int> ans(n);
int tot = 0;
// khởi tạo nghiệm nguyên là phần nguyên của nghiệm thực
priority_queue<tuple<long double, int, int, int>> pq;
for (auto [c, b, i] : pat) {
int add = solve(c, b, ri);
ans[i] = add;
tot += ans[i];
pq.push({cost(c, b, ans[i]), c, b, i});
}
// chạy thêm bài bước tham chi phí tăng
while (tot < x && !pq.empty()) {
auto [ignore, c, b, i] = pq.top(); pq.pop();
ans[i]++;
tot++;
pq.push({cost(c, b, ans[i]), c, b, i});
}
for (int i = 0; i < n; i++) {
cout << ans[i] << " ";
}
}