Wiki Thuật toán Tối ưu phân thức (Dinkelbach)

Tối ưu phân thức (Dinkelbach)

huunguyen huunguyen Updated Tháng bảy 11, 2026

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 pixiqixi 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ố

R(S)=f(S)g(S)=iSai|S|

trên tập các lời giải hợp lệ S. Ở đâ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 P 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 S đạt λ khi và chỉ khi

iSai|S|λiS(aiλ)0.

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 wi=aiλ, 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

g(λ)=maxS hợp lệiS(aiλ).

Hàm g(λ) có tính chất đẹp:

  • Nghịch biến theo λ (tăng λ thì mọi trọng số giảm).
  • g(λ)>0 tồn tại tập có trung bình >λ, nên đáp án tối ưu λ\*>λ.
  • g(λ)<0 mọi tập đều có trung bình <λ, nên λ\*<λ.
  • g(λ\*)=0 tại đúng đáp án.

Vậy ta chỉ cần tìm nghiệm λ\* của phương trình g(λ)=0.

Dinkelbach chính là Newton trên g

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:

  1. Bắt đầu từ một lời giải hợp lệ bất kỳ, đặt λ=sumAcnt (trung bình của nó).
  2. Giải bài con: tìm tập B tối đa hoá iB(aiλ), được giá trị g(λ).
  3. Nếu g(λ)>0: tập B có trung bình cao hơn λ (vì (aiλ)>0). Cập nhật λiBai|B| rồi lặp lại.
  4. Nếu g(λ)=0: 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 20 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: n=8, P=3, a=[1,2,3,4,5,6,7,8] (đánh số từ 1).

Khởi tạo bằng một cách phủ hợp lệ: các vị trí P,2P, tức {3,6}, trung bình λ=4.5.

Vòng λ trước Tập B tìm được Trung bình mới g(λ)
1 4.5 {3,5,6,7,8} 29/5=5.8 >0
2 5.8 {3,6,7,8} 24/4=6.0 >0
3 6.0 {3,6,7,8} 6.0 =0 dừng

Đáp án: tập {3,6,7,8}, trung bình 6. Để ý cách λ leo dốc 4.55.86.0 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 (aiλ) dưới ràng buộc phủ". Ràng buộc phủ (không có P 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ố P (không thì đầu dãy có P ô trống).
  • Bài chọn cuối cùng có chỉ số nP+1.
  • Hai bài chọn liên tiếp j<i thoả ijP, tức jiP.

Đặt dp[i] = tổng trọng số lớn nhất của một cách phủ hợp lệ đoạn [1..i] với bài i được chọn:

dp[i]=(aiλ)+max(0iP, maxiPji1dp[j]).

Số hạng 0 ứng với trường hợp i là bài đầu tiên (chỉ hợp lệ khi iP). Đáp án bài con là maxnP+1indp[i]. Truy vết cha để dựng lại tập.

Phần maxiPji1dp[j]cực đại trên cửa sổ trượt độ rộng P, xử lý bằng hàng đợi đơn điệu (monotonic deque): duy trì các chỉ số jdp[j] giảm dần; đầu deque luôn là vị trí cho dp lớn nhất trong cửa sổ. Nhờ đó mỗi lần chạy DP là O(n) thay vì O(nP).

Chạy tay bài con (vòng 2, λ=5.8)

Dùng mẹo số nguyên (giải thích ở dưới): nhân trọng số với cnt=5, thành wi=5ai29. Với a=[1..8]:

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 dp với P=3 (cửa sổ 3 phần tử trước đó; i3 được phép làm bài đầu, base =0):

i base =max(0i3, maxdp[i3..i1]) dp[i]= base+wi cha
1 0 24 đầu
2 0 19 đầu
3 0 14 đầu
4 max(dp1,dp2,dp3)=14 23 3
5 max(dp2,dp3,dp4)=14 18 3
6 max(dp3,dp4,dp5)=14 13 3
7 max(dp4,dp5,dp6)=13 7 6
8 max(dp5,dp6,dp7)=7 4 7

Vị trí cuối hợp lệ là inP+1=6: giá trị lớn nhất là dp8=4>0. Truy vết cha 8763 đầu, cho tập {3,6,7,8}. Vì g>0 nên cập nhật trung bình lên 6 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 O(n) (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 ~20), nên tổng thể gần O(n). So với chặt nhị phân λ mất O(nlogVε), Dinkelbach vừa nhanh vừa không cần chọn epsilon.
  • Bộ nhớ: O(n) cho mảng dp, mảng cha và deque.

Mẹo số nguyên (tránh sai số thực)

λ=sumA/cnt là phân số, nếu tính aiλ bằng double sẽ có sai số và điều kiện g(λ)>0 có thể sai ở ngưỡng. Cách chuẩn: nhân toàn bộ trọng số với cnt:

cnt·(aiλ)=cnt·aisumA=wi.

Nhân với số dương cnt không đổi dấu tổng, nên g(λ)>0 tương đương wi>0 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í P,2P,) 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 i duyệt hết j[iP,i1] thì mỗi vòng là O(nP); với Pn/2 thành ~n2/2, quá chậm. Bắt buộc dùng deque để lấy cực đại cửa sổ trong O(1) khấu hao.
  • Tính trọng số bằng double. Sai số nổi khiến so sánh g(λ)>0 chập chờn ở biên, cho đáp án lệch 1 phần tử. Dùng mẹo số nguyên wi=cnt·aisumA.
  • Tràn số. cntn105ai108 nên wi và tổng dp có thể tới ~1013: phải dùng long long cho dp, sumA, và giá trị NEG.
  • Quên điều kiện biên đầu/cuối. Bài đầu phải P, bài cuối phải nP+1. Bỏ điều kiện cuối (chỉ lấy max trên toàn bộ dp) sẽ cho tập kết ở giữa dãy, để lộ P ô trống ở đuôi.
  • Điều kiện dừng sai. Phải dừng khi g(λ)=0 (không cải thiện), không phải khi g(λ)<0. 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 g(λ)0. 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 wesố cạnh; bài con là dò chu trình âm với trọng số weλ (Bellman-Ford), hoặc thuật toán Karp.
  • Đồ thị con mật độ cao nhất (maximum density subgraph): cực đại |E||V|; 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 g(λ)=max(f(S)λh(S)) miễn là h(S)>0 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 dp[i]=wi+max 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.
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