Tối ưu phân thức (Dinkelbach)
Tối ưu phân thức (fractional programming) là lớp bài toán cực trị một tỉ số dạng trên một tập ràng buộc. Ví dụ điển hình: chọn tập con để trung bình cộng lớn nhất, tìm chu trình có tỉ lệ chi phí/thời gian nhỏ nhất, hay đồ thị con có mật độ cao nhất. Kỹ thuật Dinkelbach biến bài toán tỉ số (khó tối ưu trực tiếp) thành một chuỗi bài toán tuyến tính hoá dễ hơn nhiều, và hội tụ chỉ sau vài vòng lặp thay vì phải chặt nhị phân hàng chục lần.
Bài toán và vì sao tỉ số khó
Ta muốn cực đại (hoặc cực tiểu) tỉ số
trên tập các lời giải hợp lệ . Ở đây tử và mẫu đều phụ thuộc lời giải, nên không thể tách rời để tối ưu từng phần: thêm một phần tử độ khó cao làm tử tăng nhưng mẫu cũng tăng, chưa chắc tỉ số tăng. Đó là lý do một DP/greedy "cộng dồn" thông thường không áp dụng trực tiếp được.
Bài minh hoạ xuyên suốt trang này là Đề thi (exampick): chọn tập con khác rỗng các bài, sao cho mọi vị trí liên tiếp có ít nhất một bài được chọn (ràng buộc phủ), và trung bình độ khó lớn nhất.
Ý tưởng Dinkelbach
Mấu chốt: thay vì hỏi "trung bình lớn nhất là bao nhiêu", ta hỏi câu dễ kiểm tra hơn:
Với ngưỡng cho trước, có tồn tại lời giải hợp lệ với trung bình không?
Trung bình của đạt khi và chỉ khi
Phép trừ đã gỡ mẫu số ra khỏi tỉ số: giờ mỗi phần tử được chọn đóng góp một trọng số độc lập , và ta chỉ cần tối đa hoá tổng trọng số, một bài toán tuyến tính giải được bằng DP/greedy. Đặt
Hàm có tính chất đẹp:
- Nghịch biến theo (tăng thì mọi trọng số giảm).
- tồn tại tập có trung bình , nên đáp án tối ưu .
- mọi tập đều có trung bình , nên .
- tại đúng đáp án.
Vậy ta chỉ cần tìm nghiệm của phương trình .
Dinkelbach chính là Newton trên
Có thể chặt nhị phân , nhưng Dinkelbach nhanh hơn nhiều nhờ tận dụng luôn tập tối ưu tìm được:
- Bắt đầu từ một lời giải hợp lệ bất kỳ, đặt (trung bình của nó).
- Giải bài con: tìm tập tối đa hoá , được giá trị .
- Nếu : tập có trung bình cao hơn (vì ). Cập nhật rồi lặp lại.
- Nếu : không cải thiện được nữa, hiện tại chính là . Dừng.
Mỗi vòng tăng ngặt và tiến về với tốc độ siêu tuyến tính (giống Newton), nên thực nghiệm chỉ cần khoảng vòng ngay cả với dữ liệu lớn.
Ví dụ chạy tay: vòng lặp Dinkelbach
Xét ví dụ 1 của exampick: , , (đánh số từ 1).
Khởi tạo bằng một cách phủ hợp lệ: các vị trí tức , trung bình .
| Vòng | trước | Tập tìm được | Trung bình mới | |
|---|---|---|---|---|
| 1 | ||||
| 2 | ||||
| 3 | dừng |
Đáp án: tập , trung bình . Để ý cách leo dốc và chốt chỉ sau 3 vòng.
Bài con: DP + hàng đợi đơn điệu
Còn lại là giải bài con "tối đa dưới ràng buộc phủ". Ràng buộc phủ (không có vị trí liên tiếp cùng bị bỏ) tương đương ba điều kiện về các vị trí được chọn:
- Bài chọn đầu tiên có chỉ số (không thì đầu dãy có ô trống).
- Bài chọn cuối cùng có chỉ số .
- Hai bài chọn liên tiếp thoả , tức .
Đặt = tổng trọng số lớn nhất của một cách phủ hợp lệ đoạn với bài được chọn:
Số hạng ứng với trường hợp là bài đầu tiên (chỉ hợp lệ khi ). Đáp án bài con là . Truy vết cha để dựng lại tập.
Phần là cực đại trên cửa sổ trượt độ rộng , xử lý bằng hàng đợi đơn điệu (monotonic deque): duy trì các chỉ số có giảm dần; đầu deque luôn là vị trí cho lớn nhất trong cửa sổ. Nhờ đó mỗi lần chạy DP là thay vì .
Chạy tay bài con (vòng 2, )
Dùng mẹo số nguyên (giải thích ở dưới): nhân trọng số với , thành . Với :
i 1 2 3 4 5 6 7 8
w_i -24 -19 -14 -9 -4 1 6 11 (= 5*a_i - 29)
Điền với (cửa sổ 3 phần tử trước đó; được phép làm bài đầu, base ):
| base | base | cha | |
|---|---|---|---|
| 1 | đầu | ||
| 2 | đầu | ||
| 3 | đầu | ||
| 4 | 3 | ||
| 5 | 3 | ||
| 6 | 3 | ||
| 7 | 6 | ||
| 8 | 7 |
Vị trí cuối hợp lệ là : giá trị lớn nhất là . Truy vết cha đầu, cho tập . Vì nên cập nhật trung bình lên và sang vòng sau.
Cài đặt
#include <bits/stdc++.h>
using namespace std;
int n, P;
vector<long long> a;
// Maximize sum of w_i = cnt * a_i - sumA over all valid covers (chosen
// positions with consecutive gaps <= P, first <= P, last >= n - P + 1).
// This is the Dinkelbach subproblem: with lambda = sumA / cnt the weight is
// cnt * (a_i - lambda). Returns the best value and fills bestSet.
static long long dp_solve(long long cnt, long long sumA, vector<int>& bestSet) {
const long long NEG = LLONG_MIN / 4; // "unreachable" sentinel
vector<long long> dp(n + 1, NEG);
vector<int> par(n + 1, -2);
deque<int> dq; // indices j with dp[j] decreasing
long long best = NEG;
int bestEnd = -1;
for (int i = 1; i <= n; i++) {
while (!dq.empty() && dq.front() < i - P) // drop out-of-window
dq.pop_front();
long long startCand = (i <= P) ? 0 : NEG; // i can be the first chosen
long long base;
int pj;
if (!dq.empty() && dp[dq.front()] >= startCand) {
base = dp[dq.front()];
pj = dq.front();
} else {
base = startCand;
pj = -1; // -1 marks "i is the first chosen"
}
if (base <= NEG / 2) { // no valid way to reach i
dp[i] = NEG;
par[i] = -2;
} else {
dp[i] = base + cnt * a[i] - sumA;
par[i] = pj;
while (!dq.empty() && dp[dq.back()] <= dp[i]) // keep deque decreasing
dq.pop_back();
dq.push_back(i);
}
if (i >= n - P + 1 && dp[i] > best) { // valid last position
best = dp[i];
bestEnd = i;
}
}
bestSet.clear();
for (int cur = bestEnd; cur != -1; cur = par[cur])
bestSet.push_back(cur);
reverse(bestSet.begin(), bestSet.end());
return best;
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> P;
a.assign(n + 1, 0);
for (int i = 1; i <= n; i++)
cin >> a[i];
// Start from any valid cover: positions P, 2P, 3P, ...
vector<int> S;
for (int i = P; i <= n; i += P)
S.push_back(i);
long long sumA = 0;
for (int i : S) sumA += a[i];
long long cnt = (long long)S.size();
// Dinkelbach: keep raising the average until no cover beats it.
while (true) {
vector<int> B;
long long V = dp_solve(cnt, sumA, B);
if (V > 0) { // found a strictly better average
sumA = 0;
for (int i : B) sumA += a[i];
cnt = (long long)B.size();
S = B;
} else {
break; // g(lambda) == 0: optimal reached
}
}
cout << S.size() << "\n";
for (size_t i = 0; i < S.size(); i++)
cout << S[i] << " \n"[i + 1 == S.size()];
return 0;
}
Độ phức tạp
- Thời gian: mỗi vòng Dinkelbach chạy DP + deque hết (mỗi chỉ số vào/ra deque đúng một lần). Số vòng lặp nhỏ (siêu tuyến tính, thực nghiệm ), nên tổng thể gần . So với chặt nhị phân mất , Dinkelbach vừa nhanh vừa không cần chọn epsilon.
- Bộ nhớ: cho mảng , mảng cha và deque.
Mẹo số nguyên (tránh sai số thực)
Vì là phân số, nếu tính bằng double sẽ có sai số và điều kiện có thể sai ở ngưỡng. Cách chuẩn: nhân toàn bộ trọng số với cnt:
Nhân với số dương cnt không đổi dấu tổng, nên tương đương trên số nguyên, hoàn toàn chính xác. Kết quả trả về là trung bình lớn nhất đúng tuyệt đối.
⚠️ Lỗi thường gặp
- Bỏ qua ràng buộc phủ, chỉ lấy phần tử lớn nhất. Tối ưu tỉ số "không ràng buộc" của bài này là chọn đúng một bài khó nhất, nhưng nó gần như luôn vi phạm điều kiện phủ WA. Bài con phải giữ nguyên ràng buộc; Dinkelbach chỉ đổi hàm mục tiêu, không nới miền hợp lệ.
- Nhầm "phủ ít nhất" với "trung bình lớn nhất". In ra một cách phủ cỡ nhỏ nhất (các vị trí ) không hề tối đa trung bình; nó chỉ trùng đáp án khi mọi cách phủ có cùng trung bình. Cách phủ khởi tạo chỉ là điểm xuất phát của Dinkelbach, không phải kết quả.
- DP quét cả cửa sổ TLE. Nếu tại mỗi duyệt hết thì mỗi vòng là ; với thành , quá chậm. Bắt buộc dùng deque để lấy cực đại cửa sổ trong khấu hao.
- Tính trọng số bằng
double. Sai số nổi khiến so sánh chập chờn ở biên, cho đáp án lệch 1 phần tử. Dùng mẹo số nguyên . - Tràn số. và nên và tổng có thể tới : phải dùng
long longcho ,sumA, và giá trịNEG. - Quên điều kiện biên đầu/cuối. Bài đầu phải , bài cuối phải . Bỏ điều kiện cuối (chỉ lấy trên toàn bộ ) sẽ cho tập kết ở giữa dãy, để lộ ô trống ở đuôi.
- Điều kiện dừng sai. Phải dừng khi (không cải thiện), không phải khi . Nếu lỡ dùng
>= 0để tiếp tục thì lặp vô hạn tại nghiệm.
Biến thể và mở rộng
Dinkelbach là khuôn mẫu chung cho mọi bài "tối ưu tỉ số" khi bài con tuyến tính hoá giải được nhanh:
- Chặt nhị phân trên đáp số là lựa chọn thay thế: nhị phân , mỗi bước kiểm tra . Dễ cài hơn nhưng chậm hơn và phải xử lý epsilon; xem Tìm kiếm nhị phân trên đáp số.
- Chu trình tỉ lệ nhỏ nhất (minimum mean cycle): tìm chu trình cực tiểu ; bài con là dò chu trình âm với trọng số (Bellman-Ford), hoặc thuật toán Karp.
- Đồ thị con mật độ cao nhất (maximum density subgraph): cực đại ; bài con quy về luồng cực đại / lát cắt nhỏ nhất với tham số .
- Bài toán vận tải / phân số tuyến tính tổng quát: cùng công thức miễn là và bài con giải được.
Điểm chung: nhận diện tỉ số hai đại lượng cùng phụ thuộc lời giải, rồi kiểm tra bài con "trừ " có giải nhanh không.
Bài tập luyện
- Cửa Sổ Phép Thuật (slidewin) — (Trung cấp) làm chủ cực đại cửa sổ trượt bằng deque, chính là chương trình con lõi của DP ở đây.
- Tỷ lệ thành công (succrate) — (Nâng cao) tư duy tỉ số và chặt nhị phân trên đáp số, làm quen với việc "trừ ngưỡng" để kiểm tra một tỉ lệ.
- Bức ảnh Gold (photogold) — (Kỳ cựu) đúng khuôn DP + monotonic deque dạng cửa sổ, luyện phần bài con của Dinkelbach.
- Vũ Điệu Cầu Thang (gridwaltz) — (Kỳ cựu) DP tối ưu bằng deque ở mức khó hơn, siết kỹ năng cài đặt cửa sổ trượt cho quy hoạch động.
- Đề thi (exampick) — (Rất khó) bài tổng hợp: ghép Dinkelbach + DP phủ + deque + mẹo số nguyên thành một lời giải hoàn chỉnh.