Wiki Thuật toán Quy hoạch động (DP) DP tối ưu hóa (Optimization DP)

DP tối ưu hóa (Optimization DP)

huunguyen huunguyen Updated Tháng sáu 1, 2026

Nhiều bài quy hoạch động (QHĐ) có công thức chuyển dạng

dp[i]=minj<i(dp[j]+cost(j,i))

hoặc QHĐ khoảng dạng dp[l][r]=mink(dp[l][k]+dp[k+1][r])+w[l][r]. Cài đặt ngây thơ duyệt mọi j (hoặc mọi điểm chia k) cho mỗi trạng thái, dẫn tới O(N2) hoặc O(N3). Các kỹ thuật trong trang này khai thác cấu trúc đặc biệt của hàm cost để cắt bớt phần lớn các lựa chọn vô ích, đưa độ phức tạp xuống O(NlogN), O(N) hoặc O(N2).

Ba kỹ thuật chính:

Kỹ thuật Áp dụng khi Giảm từ Xuống
Convex Hull Trick (CHT) cost=b[j]·a[i] (chuyển tuyến tính) O(N2) O(N) / O(NlogN)
Divide & Conquer DP điểm tối ưu opt[j] đơn điệu O(KN2) O(KNlogN)
Knuth's Optimization QHĐ khoảng, w thỏa bất đẳng thức tứ giác O(N3) O(N2)

Ý tưởng / Trực giác

Vì sao việc duyệt trâu lại lãng phí? Trong cả ba trường hợp, khi i (hoặc r) tăng, vị trí tối ưu j (hoặc điểm chia k) không nhảy lung tung mà thay đổi một cách có quy luật. Nếu ta chứng minh được quy luật đó, ta không cần xét lại toàn bộ ứng viên ở mỗi bước.

1. Convex Hull Trick — mỗi trạng thái là một đường thẳng. Viết lại dp[j]+b[j]·a[i] thành fj(x)=b[j]·x+dp[j] với x=a[i]. Đây là một đường thẳng với hệ số góc b[j] và tung độ gốc dp[j]. Với mỗi truy vấn x, ta cần đường thẳng cho giá trị nhỏ nhất tại x. Khi vẽ tất cả đường thẳng lên mặt phẳng, đường bao dưới (lower envelope) của chúng là một đường gấp khúc lồi: tại mỗi x chỉ có đúng một đoạn (một đường thẳng) là tối ưu. Một đường thẳng bị "che" hoàn toàn bởi hai đường khác thì không bao giờ tối ưu — ta loại nó vĩnh viễn. Nhờ đó tập đường thẳng cần giữ luôn tạo thành bao lồi, thêm/truy vấn rất nhanh.

2. D&C DP — tính đơn điệu của điểm tối ưu. Gọi opt[j] là vị trí k làm dp[layer][j] nhỏ nhất. Nếu hàm cost thỏa bất đẳng thức tứ giác (quadrangle inequality), người ta chứng minh được opt[j]opt[j+1]: điểm tối ưu không bao giờ lùi khi j tăng. Trực giác: nếu một điểm chia tốt cho vị trí j thì với vị trí j lớn hơn, điểm chia tốt cũng phải dịch sang phải (hoặc đứng yên), không thể tụt về trái. Khi đã biết tính đơn điệu này, ta tính dp theo kiểu chia để trị: tính dp[mid] trước bằng cách quét toàn dải [optLo,optHi], rồi opt[mid] thu được sẽ chặn dải tìm kiếm cho hai nửa trái/phải.

3. Knuth — đơn điệu hai chiều cho QHĐ khoảng. Cũng từ bất đẳng thức tứ giác (và tính đơn điệu của w), điểm chia tối ưu opt[l][r] bị kẹp: opt[l][r1]opt[l][r]opt[l+1][r]. Thay vì thử cả rl điểm chia cho mỗi khoảng [l,r], ta chỉ thử trong đoạn nhỏ giữa hai mốc trên. Tổng số phép thử trên mọi khoảng cùng độ dài co lại thành tổng nuốt chuỗi (telescoping), cho O(N2) thay vì O(N3).

Ví dụ chạy tay (Convex Hull Trick)

Bài toán: chia n giá trị đã sắp xếp thành các nhóm liên tiếp, chi phí một nhóm [l,r](aral)2+C, cực tiểu tổng. Lấy a=[1,3,4,8] (1-indexed), C=5.

Công thức: dp[i]=minj<i(dp[j]+(aiaj+1)2+C). Khai triển: dp[i]=ai2+C+minj<i(2aj+1slope m·ai+dp[j]+aj+12intercept b). Mỗi j sinh đường thẳng fj(x)=mjx+bj, truy vấn tại x=ai.

Khởi tạo dp[0]=0. Thêm đường từ j=0: m0=2a1=2, b0=0+a12=1.

Bao dưới (lower envelope) các đường thẳng — chỉ giữ phần thấp nhất:

   giá trị f(x)
     |   \         . đường j=0: f0(x) = -2x + 1
     |    \      .
     |     \   .
     |      \.        <- giao điểm: đổi đường tối ưu
     |      .\
     |    .   \
     +----------------> x = a[i]
          ^truy vấn x=a[i]

Tính từng bước (ô [DP] là giá trị đang điền):

i x=ai đường tối ưu tại x dp[i]=ai2+C+(min)
1 3 f0(3)=6+1=5 9+55= [9]
2 4 xét f0,f1 16+5+min= [?]
3 8 xét cả ba đường

Sau khi tính dp[1]=9, thêm đường j=1: m1=2a2=6, b1=dp[1]+a22=9+9=18.

Tại i=2, x=4: f0(4)=8+1=7; f1(4)=24+18=6. Nhỏ hơn là f0(4)=7 (đường j=0 vẫn tối ưu ở x=4). dp[2]=16+5+(7)=14.

Thêm đường j=2: m2=2a3=8, b2=dp[2]+a32=14+16=30. Lúc thêm, kiểm tra đường giữa có bị che không bằng hàm bad(...); nếu có thì pop.

Tại i=3, x=8: f0(8)=15, f1(8)=30, f2(8)=34. Nhỏ nhất f2(8)=34. dp[3]=64+5+(34)=35.

Đáp số dp[4] tính tương tự. Điểm mấu chốt: ở mỗi truy vấn ta không quét lại mọi j — con trỏ trên bao lồi chỉ tiến, nên tổng chi phí truy vấn là O(N).

Cài đặt

1. Convex Hull Trick — stack đơn điệu (slope và query đều đơn điệu)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

struct Line {
    ll m, b;                       // f(x) = m*x + b
    ll eval(ll x) const { return m * x + b; }
};

// True nếu l2 bị l1 & l3 che hoàn toàn => l2 vô dụng, loại bỏ.
// Dùng __int128 để tránh tràn khi nhân hai hiệu lớn.
bool bad(const Line& l1, const Line& l2, const Line& l3) {
    return (__int128)(l3.b - l1.b) * (l1.m - l2.m)
        <= (__int128)(l2.b - l1.b) * (l1.m - l3.m);
}

struct CHT {                       // dùng cho bài tối THIỂU, slope giảm dần
    vector<Line> hull;
    int ptr = 0;                   // con trỏ truy vấn, chỉ tiến

    void addLine(ll m, ll b) {
        Line l = {m, b};
        // Bỏ các đường ở đỉnh stack đã bị che bởi đường mới
        while (hull.size() >= 2 &&
               bad(hull[hull.size()-2], hull.back(), l))
            hull.pop_back();
        hull.push_back(l);
    }

    // Yêu cầu: x tăng dần qua các lần gọi -> con trỏ chỉ tiến tới
    ll query(ll x) {
        if (ptr >= (int)hull.size()) ptr = (int)hull.size() - 1;
        while (ptr + 1 < (int)hull.size() &&
               hull[ptr+1].eval(x) <= hull[ptr].eval(x))
            ptr++;
        return hull[ptr].eval(x);
    }
};

Khi truy vấn không đơn điệu, thay query tuyến tính bằng tìm kiếm nhị phân trên bao lồi, hoặc dùng Li Chao Tree (truy vấn x bất kỳ, O(logV) mỗi thao tác):

struct LiChao {
    struct Line { ll m, b; ll eval(ll x){ return m*x + b; } };
    int n; vector<Line> tr; vector<char> has;
    LiChao(int sz): n(sz), tr(4*sz), has(4*sz, 0) {}

    void add(Line nw, int node, int lo, int hi) {
        int mid = (lo + hi) >> 1;
        bool lBet = !has[node] || nw.eval(lo)  < tr[node].eval(lo);
        bool mBet = !has[node] || nw.eval(mid) < tr[node].eval(mid);
        if (mBet) { swap(tr[node], nw); has[node] = 1; }   // đường tốt hơn ở giữa thì giữ lại
        if (lo == hi) return;
        // Đường thua ở giữa chỉ có thể tốt hơn ở MỘT nửa -> đệ quy 1 nhánh
        if (lBet != mBet) add(nw, 2*node,   lo,   mid);
        else              add(nw, 2*node+1, mid+1, hi);
    }
    ll query(int x, int node, int lo, int hi) {
        ll res = has[node] ? tr[node].eval(x) : LLONG_MAX;
        if (lo == hi) return res;
        int mid = (lo + hi) >> 1;
        if (x <= mid) return min(res, query(x, 2*node,   lo,   mid));
        else          return min(res, query(x, 2*node+1, mid+1, hi));
    }
};
2. Divide & Conquer DP
const int MAXN = 1e5 + 5;
ll dp_prev[MAXN], dp_cur[MAXN];   // hai layer luân phiên để tiết kiệm bộ nhớ
ll cost(int l, int r);            // chi phí đoạn [l..r], cần tính O(1) (vd prefix sum)

// Tính dp_cur[lo..hi], biết opt nằm trong [optLo..optHi]
void solve(int lo, int hi, int optLo, int optHi) {
    if (lo > hi) return;
    int mid = (lo + hi) >> 1;
    ll best = LLONG_MAX; int opt = optLo;
    // Quét tìm điểm chia tối ưu cho mid (đây là chỗ tốn công duy nhất)
    for (int k = optLo; k <= min(mid, optHi); k++) {
        ll val = dp_prev[k] + cost(k + 1, mid);
        if (val < best) { best = val; opt = k; }
    }
    dp_cur[mid] = best;
    // Tính đơn điệu: opt của nửa trái <= opt[mid] <= opt của nửa phải
    solve(lo, mid - 1, optLo, opt);
    solve(mid + 1, hi, opt, optHi);
}
// Mỗi layer: swap(dp_prev, dp_cur); solve(1, n, 0, n);
3. Knuth's Optimization
int opt[MAXN][MAXN];
ll dp[MAXN][MAXN], w[MAXN][MAXN];   // w[l][r]: trọng số khoảng [l..r]

for (int i = 0; i < n; i++) { dp[i][i] = 0; opt[i][i] = i; }

for (int len = 2; len <= n; len++)
    for (int l = 0; l + len - 1 < n; l++) {
        int r = l + len - 1;
        dp[l][r] = LLONG_MAX;
        // CHỈ duyệt k trong [opt[l][r-1], opt[l+1][r]] -> tổng O(N^2)
        for (int k = opt[l][r-1]; k <= opt[l+1][r]; k++) {
            ll val = dp[l][k] + dp[k+1][r] + w[l][r];
            if (val < dp[l][r]) { dp[l][r] = val; opt[l][r] = k; }
        }
    }
// Đáp số: dp[0][n-1]

Độ phức tạp

Kỹ thuật Thời gian Bộ nhớ Vì sao
CHT (stack đơn điệu) O(N) O(N) Mỗi đường thêm/loại đúng 1 lần; con trỏ truy vấn chỉ tiến, tổng N bước.
CHT (Li Chao Tree) O(NlogV) O(N+V) Mỗi add/query đi từ gốc xuống lá của cây phủ miền giá trị V, sâu logV.
D&C DP O(KNlogN) O(N) Mỗi mức đệ quy có tổng dải tìm kiếm O(N); sâu O(logN) mức; nhân K layer. Chỉ lưu 2 layer nên bộ nhớ O(N).
Knuth O(N2) O(N2) Telescoping: tổng (opt[l+1][r]opt[l][r1]) trên mọi khoảng cùng độ dài là O(N), có N độ dài. Hai bảng dp, opt kích thước N2.

Lưu ý bộ nhớ: D&C DP dùng kỹ thuật 2 layer luân phiên nên dù có K tầng vẫn chỉ tốn O(N). Knuth bắt buộc bảng N2, nên thường chỉ chạy được với N5000.

⚠️ Lỗi thường gặp

  • Tràn số khi so sánh độ dốc (CHT). Hàm bad nhân hai hiệu lớn; với giá trị tới 109 tích vượt long long. Phải dùng __int128 (hoặc so sánh bằng phép chia số thực có sai số). Triệu chứng: AC test nhỏ, WA test lớn không rõ lý do.
  • Áp dụng CHT stack khi điều kiện đơn điệu bị vi phạm. Phiên bản stack + con trỏ chỉ đúng khi slope đơn điệu lúc thêmx đơn điệu lúc truy vấn. Nếu một trong hai không đơn điệu mà vẫn dùng ptr++, con trỏ "trượt qua" đường tối ưu và không quay lại được. Khi đó phải tìm nhị phân hoặc dùng Li Chao Tree.
  • Nhầm min/max và chiều sắp xếp đường thẳng. CHT cho cực tiểu giữ bao dưới (slope giảm), cho cực đại giữ bao trên (slope tăng). Đảo nhầm chiều <=/>= trong bad/query cho kết quả sai âm thầm. Mẹo: với bài max, đổi dấu toàn bộ rồi giải bài min.
  • Off-by-one trong chỉ số đoạn (CHT/D&C). Công thức (aiaj+1)2 dùng aj+1 chứ không phải aj — đường thẳng phải thêm bằng j+1. Sai chỉ số làm lệch toàn bộ nghiệm. Kiểm tra bằng cách chạy tay N=2,3.
  • D&C DP áp dụng khi opt không đơn điệu. Nếu hàm cost không thỏa bất đẳng thức tứ giác, tính đơn điệu của opt không còn đúng, và việc chặn dải [optLo,opt], [opt,optHi] sẽ bỏ sót điểm tối ưu. Phải chứng minh (hoặc kiểm thử brute-force) tính đơn điệu trước khi tin tưởng.
  • Knuth: quên cập nhật opt[i][i] hoặc duyệt sai biên. Khởi tạo thiếu opt[i][i]=i khiến opt[l][r-1] đọc rác. Ngoài ra phải duyệt theo độ dài tăng dần (len từ 2 đến n) để opt[l][r-1]opt[l+1][r] đã được tính trước.
  • Sai lầm về độ phức tạp Knuth. Nhìn vào hai vòng for lồng cộng vòng k dễ tưởng O(N3). Chỉ khi k bị chặn bởi opt thì mới là O(N2) — nếu vô tình duyệt k từ l đến r-1 thì mất hết lợi ích.

Biến thể / Mở rộng

  • CHT động (LineContainer): dùng std::multiset để chèn/xóa đường khi cả slope lẫn truy vấn đều không đơn điệu, O(logN) mỗi thao tác.
  • Lagrangian / Alien Trick: khi ràng buộc "chia đúng K nhóm", phạt mỗi lần chia một hằng λ rồi tìm nhị phân λ — biến bài K-layer thành 1-layer kết hợp được với CHT/D&C.
  • SMAWK: thuật toán nâng cao tính minimum hàng của ma trận Monge trong O(N), thay cho D&C O(NlogN).
  • Liên quan: xem thêm Quy hoạch động khoảng (nền tảng cho Knuth) và trang cha Quy hoạch động.

Bài tập luyện

  • Đội Hợp Xướng Hogwarts (choirform)(Veteran) QHĐ trên lưới có tối ưu hóa chuyển trạng thái, làm quen ý tưởng cắt bớt ứng viên trước khi vào CHT thuần.
  • Nhà kính của Sprout (greenhouse)(Expert) kết hợp Convex Hull Trick và chia để trị; bài "tất cả trong một" để luyện nhận dạng dạng chuyển tuyến tính.
  • Người Ăn Bánh (pieaters)(Expert) Divide & Conquer DP điển hình: tận dụng tính đơn điệu của điểm tối ưu để giảm một bậc.
  • Chuồng Bò Tròn Mới (cbarnrev)(Expert) D&C DP nhiều layer trên cấu trúc vòng, luyện kỹ thuật 2 layer luân phiên và chặn dải opt.
  • Chia Mảng Knuth (knuthdiv)(Expert) áp dụng trực tiếp Knuth's Optimization cho bài chia mảng, kiểm chứng bất đẳng thức tứ giác.
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