Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Bài 45: Tối ưu quy hoạch động trong C++ và Python: min tiền tố, hàng đợi đơn điệu, Convex Hull Trick, Li Chao tree, chia để trị tối ưu

Bài 45: Tối ưu quy hoạch động trong C++ và Python: min tiền tố, hàng đợi đơn điệu, Convex Hull Trick, Li Chao tree, chia để trị tối ưu
Series: Thuật toán chuyên sâu Độ khó: Khá Bài 45 / Thuật toán chuyên sâu Tối ưu QHĐ Convex Hull Trick Chia để trị
// Thuật toán chuyên sâu · Bài 45
Tối ưu quy hoạch động: min tiền tố, hàng đợi đơn điệu, Convex Hull Trick, Li Chao tree, chia để trị tối ưu
Công thức QHĐ đã đúng mà vẫn quá thời gian vì mỗi trạng thái phải duyệt $O(n)$ trạng thái trước? Bài này dạy em nhìn ra cấu trúc của phép lấy min để hạ $O(n^2)$ xuống $O(n)$ hoặc $O(n \log n)$.

Lời mở đầu

Ở bài 19 và bài 27 em đã quen việc đặt trạng thái và viết công thức truy hồi. Nhưng ở đề HSG quốc gia, viết được công thức mới là một nửa bài: rất nhiều công thức có dạng $dp_i = \min_{j < i} \big(dp_j + \text{cost}(j, i)\big)$, có $n$ trạng thái, mỗi trạng thái duyệt $n$ lựa chọn, tổng $O(n^2)$, với $n = 2 \cdot 10^5$ là quá chậm. Nửa còn lại của bài là tối ưu phép lấy min ấy.

Em đã gặp một lần rồi: bài toán nhảy ô ở bài 25 dùng hàng đợi đơn điệu để lấy max trên cửa sổ trượt. Hôm nay ta hệ thống lại thành một bộ công cụ: min tiền tố, deque đơn điệu, Convex Hull Trick (biến mỗi $j$ thành một đường thẳng), Li Chao tree (cây phân đoạn của bài 31 chứa đường thẳng), và chia để trị tối ưu (dùng tính đơn điệu của điểm tối ưu, nối tiếp tư duy chia để trị ở bài 24).

Trong bài này bạn sẽ học: nhận dạng công thức QHĐ $O(n^2)$ cần tối ưu và tách biến để dùng min tiền tố; QHĐ kết hợp hàng đợi đơn điệu khi $j$ chạy trong cửa sổ trượt (kể cả cửa sổ lệch); Convex Hull Trick: viết $dp_j + \text{cost}(j, i)$ thành đường thẳng, giữ bao dưới bằng deque khi hệ số góc và truy vấn đơn điệu; Li Chao tree khi không có tính đơn điệu; chia để trị tối ưu khi điểm tối ưu tăng dần theo $i$, và giới thiệu tối ưu Knuth cho QHĐ trên đoạn. Có khung chạy tay Convex Hull Trick và chia để trị tối ưu, ba mô phỏng từng bước (deque trên dp, thêm/loại đường thẳng trên đồ thị, khoảng opt co lại) và 8 bài tập kèm gợi ý.

Lộ trình bài viết, bấm vào từng phần để nhảy tới:

Phần 1Nhận dạng QHĐ cần tối ưu, tối ưu bằng min tiền tố

Hầu hết các bài trong bài học này có chung một dáng:

$dp_i = \min_{j \in J(i)} \big( dp_j + \text{cost}(j, i) \big)$

trong đó $J(i)$ là tập các trạng thái trước mà $i$ được phép đi từ đó. Tính thẳng tốn $\sum |J(i)|$, thường là $O(n^2)$. Trước khi nghĩ tới kỹ thuật cao siêu, em hãy tự hỏi hai câu:

  • $J(i)$ trông như thế nào? Mọi $j < i$ (một tiền tố), hay $k$ ô ngay trước (cửa sổ trượt), hay một cửa sổ lệch $[i - R, i - L]$?
  • $\text{cost}(j, i)$ có tách được không? Nếu $\text{cost}(j, i) = f(i) + g(j)$ thì $f(i)$ ra ngoài dấu min, bên trong chỉ còn $dp_j + g(j)$, không phụ thuộc $i$ nữa.
min tiền tốij ∈ [0, i − 1]cửa sổ trượtij ∈ [i − k, i − 1]cửa sổ lệchij ∈ [i − R, i − L]

Hình 1. Ba dạng tập $J(i)$ hay gặp (ô xanh dương là $i$, ô vàng là các $j$ được dùng). Tập là tiền tố thì giữ min tiền tố; tập là cửa sổ có hai đầu cùng tiến thì dùng deque đơn điệu.

Tách biến + min tiền tố. Ví dụ $dp_i = \min_{j < i} \big(dp_j + a_i - b_j\big)$. Viết lại $dp_i = a_i + \min_{j < i}(dp_j - b_j)$. Đại lượng trong ngoặc không phụ thuộc $i$, nên chỉ cần một biến best lưu min của $dp_j - b_j$ đã gặp: tính xong $dp_i$ thì cập nhật best. Từ $O(n^2)$ còn $O(n)$. Đây là kỹ thuật đơn giản nhất nhưng gặp cực nhiều, nhất là trong QHĐ hai chiều, nơi chiều thứ hai đóng vai “tiền tố”.

Bài toán. Cho dãy $a_1, \ldots, a_n$ ($n \le 5000$, $1 \le a_i \le 100$). Hãy chọn dãy không giảm $b_1 \le b_2 \le \ldots \le b_n$ với $1 \le b_i \le 100$ sao cho $\sum |a_i - b_i|$ nhỏ nhất.

Gọi $dp[i][v]$ là chi phí nhỏ nhất cho $i$ phần tử đầu khi $b_i = v$. Phần tử trước phải có $b_{i-1} = u \le v$, nên

$dp[i][v] = |a_i - v| + \min_{u \le v} dp[i-1][u].$

Tính thẳng: $n \cdot 100 \cdot 100 = 5 \cdot 10^7$, vẫn chạy nhưng nếu giá trị lên $10^3$ thì hỏng. Nhận ra $\min_{u \le v}$ là min tiền tố của hàng $i - 1$: duyệt $v$ tăng dần và giữ biến best. Mỗi hàng còn $O(V)$, tổng $O(nV)$.

v = 1v = 2v = 3v = 4v = 5
$a_1 = 3$21012
min tiền tố21000
$a_2 = 1$22234
min tiền tố22222
$a_3 = 2$32345

Bảng 1. Ba hàng đầu với $a = [3, 1, 2, \ldots]$ (chỉ vẽ $v \le 5$). Mỗi ô của hàng mới = $|a_i - v|$ + ô cùng cột ở dòng “min tiền tố” ngay trên. Ví dụ $dp[3][2] = |2 - 2| + 2 = 2$.

Xem code C++ / Python: dãy không giảm chi phí nhỏ nhất với min tiền tố (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    const int V = 100;
    int n;
    cin >> n;
    vector<long long> dp(V + 1, 0), nd(V + 1);     // dp[v]: hàng i - 1, b[i-1] = v
    for(int i = 0; i < n; i++){
        int a;
        cin >> a;
        long long best = LLONG_MAX;                // min tiền tố dp[1..v]
        for(int v = 1; v <= V; v++){
            best = min(best, dp[v]);
            nd[v] = best + abs(a - v);
        }
        dp = nd;
    }
    cout << *min_element(dp.begin() + 1, dp.end());
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    V = 100
    dp = [0] * (V + 1)                    # dp[v]: hàng i - 1, b[i-1] = v
    for i in range(n):
        a = int(data[1 + i])
        nd = [0] * (V + 1)
        best = float('inf')               # min tiền tố dp[1..v]
        for v in range(1, V + 1):
            if dp[v] < best:
                best = dp[v]
            nd[v] = best + abs(a - v)
        dp = nd
    print(min(dp[1:]))

main()
Khi nào nghĩ tới tối ưu QHĐ?
Khi em viết xong công thức và ước lượng số phép tính thấy quá giới hạn (thường là $n^2$ với $n \ge 10^5$, hoặc $n^3$ với $n \ge 2000$). Đừng vội đổi trạng thái: hãy viết công thức ra giấy dưới dạng $\min_j (\ldots)$, khai triển $\text{cost}(j, i)$, rồi đối chiếu bảng tổng kết ở cuối phần 5. Và luôn giữ bản $O(n^2)$ làm chương trình trâu để đối chiếu, vì code tối ưu QHĐ rất dễ sai ở dấu bằng và ở biên.

Phần 2QHĐ + hàng đợi đơn điệu: khi j chạy trong cửa sổ trượt

Bài toán đặt camera. Hàng rào có $n$ vị trí, đặt camera ở vị trí $i$ tốn $c_i$ ($n \le 10^6$). Yêu cầu: mọi đoạn $k$ vị trí liên tiếp có ít nhất một camera. Tổng chi phí nhỏ nhất là bao nhiêu?

Gọi $dp_i$ là chi phí nhỏ nhất khi đặt camera tại $i$ và mọi yêu cầu bên trái $i$ đã được thoả. Camera ngay trước đó ở $j$ thì giữa $j$ và $i$ có $i - j - 1$ ô trống, phải $\le k - 1$, tức $j \ge i - k$. Thêm vị trí gốc $0$ ($dp_0 = 0$) và vị trí đích $n + 1$ (chi phí $0$) để khỏi xét riêng hai đầu:

$dp_i = c_i + \min_{i - k \le j \le i - 1} dp_j, \qquad \text{đáp án} = dp_{n+1}.$

Tập $j$ là cửa sổ độ dài $k$ trượt sang phải mỗi khi $i$ tăng: đúng tình huống của hàng đợi đơn điệu ở bài 25. Deque lưu các chỉ số $j$ có $dp_j$ tăng dần từ đầu tới cuối. Với mỗi $i$: (1) bỏ ở đầu các chỉ số $< i - k$; (2) đầu deque là min, tính $dp_i$; (3) bỏ ở cuối các chỉ số có $dp \ge dp_i$ rồi đẩy $i$ vào cuối. Một chỉ số bị bỏ ở cuối vì nó cũ hơn $i$ (rời cửa sổ sớm hơn) mà lại không tốt hơn: nó vĩnh viễn hết cơ hội.

Bảng thì khó thấy deque “sống” thế nào. Em bấm Tiếp để xem từng bước: cửa sổ (ô vàng) trượt, đầu deque (xanh lá) luôn là min, và những ô đỏ bị loại vĩnh viễn. Thử đổi $k$ hoặc nhập dãy giảm dần để thấy deque dài ra:

MÔ PHỎNGQHĐ + deque đơn điệu: đặt camera với cửa sổ trượtNhập chi phí từng vị trí (tối đa 12 số) và k. Bấm Tiếp để xem deque luôn đưa ra min của cửa sổ ngay ở đầu.
ô đang tínhcửa sổ [i − k, i − 1]min của cửa sổ (đầu deque)vừa bị loại khỏi dequechưa tính
Xem code C++ / Python: qhd đặt camera với deque đơn điệu (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, k;
    cin >> n >> k;
    vector<long long> c(n + 2, 0), dp(n + 2, 0);
    for(int i = 1; i <= n; i++) cin >> c[i];
    deque<int> dq;
    dq.push_back(0);                              // gốc 0: dp[0] = 0
    for(int i = 1; i <= n + 1; i++){              // ô n + 1 là đích, c = 0
        while(dq.front() < i - k) dq.pop_front(); // ra khỏi cửa sổ [i-k, i-1]
        dp[i] = c[i] + dp[dq.front()];            // đầu deque là min
        while(!dq.empty() && dp[dq.back()] >= dp[i]) dq.pop_back();
        dq.push_back(i);
    }
    cout << dp[n + 1];
    return 0;
}
import sys
from collections import deque

def main():
    data = sys.stdin.buffer.read().split()
    n, k = int(data[0]), int(data[1])
    c = [0] + [int(x) for x in data[2:2 + n]] + [0]   # ô n + 1 là đích
    dp = [0] * (n + 2)
    dq = deque([0])                                    # gốc 0: dp[0] = 0
    for i in range(1, n + 2):
        while dq[0] < i - k:                           # ra khỏi cửa sổ
            dq.popleft()
        dp[i] = c[i] + dp[dq[0]]                       # đầu deque là min
        while dq and dp[dq[-1]] >= dp[i]:
            dq.pop()
        dq.append(i)
    print(dp[n + 1])

main()

Cửa sổ lệch $[i - R, i - L]$. Nhiều bài có cả khoảng cách tối thiểu: ví dụ ếch nhảy mỗi bước từ $L$ tới $R$ ô. Khi đó $j = i - 1, \ldots, i - L + 1$ chưa được phép dùng. Cách làm: chỉ đẩy $j = i - L$ vào deque ngay trước khi tính $dp_i$ (đẩy “trễ” $L$ bước), còn việc bỏ đầu thì so với $i - R$. Thứ tự mỗi bước: đẩy $i - L$ → bỏ đầu $< i - R$ → đọc đầu deque. Bài tập 3 luyện đúng dạng này, kèm cả trạng thái không tới được.

Ba lỗi hay gặp với QHĐ + deque
1. Đẩy $dp_i$ vào deque trước khi tính xong $dp_i$ (hoặc đẩy $i$ vào rồi mới đọc min cho chính $i$).
2. Quên trạng thái gốc: deque rỗng ở bước đầu → đọc front() của deque rỗng, chương trình chết.
3. Đề bắt max nhưng vẫn giữ deque tăng: khi tìm max, deque phải giảm dần và điều kiện loại là dp[back] <= dp[i].

Phần 3Convex Hull Trick: mỗi j là một đường thẳng

Bài toán in bài báo. Có $n$ từ, từ thứ $i$ dài $a_i$ ($1 \le a_i \le 100$, $n \le 2 \cdot 10^5$). Ta xếp các từ theo đúng thứ tự thành các dòng, mỗi dòng là một đoạn liên tiếp. Một dòng có tổng độ dài $s$ tốn $s^2 + C$ ($0 \le C \le 10^9$). Tìm tổng chi phí nhỏ nhất.

Với $S$ là tổng tiền tố, dòng cuối gồm các từ $j + 1 \ldots i$:

$dp_i = \min_{j < i} \big( dp_j + (S_i - S_j)^2 \big) + C.$

Không tách biến được, vì có tích $S_i S_j$. Nhưng khai triển ra:

$dp_j + S_i^2 - 2 S_i S_j + S_j^2 = \underbrace{(-2 S_j)}_{m_j} \cdot S_i + \underbrace{(dp_j + S_j^2)}_{b_j} + S_i^2.$

Phần $S_i^2$ không phụ thuộc $j$. Phần còn lại là $m_j \cdot x + b_j$ với $x = S_i$: một đường thẳng theo biến $x$! Bài toán biến thành:

  • Thêm đường: tính xong $dp_j$ thì thêm đường $y = m_j x + b_j$ vào tập.
  • Truy vấn: cho $x$, tìm $\min_j (m_j x + b_j)$, tức là điểm thấp nhất của các đường tại hoành độ $x$.

Kỹ thuật trả lời hai thao tác này nhanh gọi là Convex Hull Trick (CHT, “mẹo bao lồi”).

Hình học của bao dưới. Vẽ mọi đường lên mặt phẳng, hàm $f(x) = \min_j (m_j x + b_j)$ là một đường gấp khúc lõm gọi là bao dưới. Đi từ trái sang phải, các đoạn của bao dưới có hệ số góc giảm dần: bên trái đường dốc lên nhiều nhất thắng, bên phải đường dốc xuống nhiều nhất thắng. Có những đường không bao giờ chạm bao dưới, gọi là đường thừa: bỏ đi không ảnh hưởng gì.

-4-3-2-1012d1d3 (thừa)d2d4d5

Hình 2. Năm đường thẳng (tím) và bao dưới (dải xanh nhạt). Đường $d_3$: $y = 1{,}5$ (nét đứt) luôn nằm trên bao dưới nên là đường thừa. Các điểm cam là chỗ bao dưới đổi đường; thứ tự các đường trên bao dưới là $d_1, d_2, d_4, d_5$, đúng thứ tự hệ số góc giảm dần.

Khi nào đường giữa bị thừa? Giả sử đã có ba đường $a, b, c$ với $m_a > m_b > m_c$ ($c$ là đường vừa thêm). Gọi $x_{ab}$ là hoành độ giao điểm của $a$ và $b$: bên phải $x_{ab}$ thì $b$ thấp hơn $a$. Gọi $x_{ac}$ là giao điểm của $a$ và $c$: bên phải $x_{ac}$ thì $c$ thấp hơn $a$. Nếu $x_{ac} \le x_{ab}$ thì ở mọi $x$, hoặc $a$ thấp hơn $b$ (khi $x < x_{ab}$), hoặc $c$ thấp hơn $b$: $b$ thừa. Viết thành phép nhân để tránh chia số thực:

$x_{ac} \le x_{ab} \iff \dfrac{b_c - b_a}{m_a - m_c} \le \dfrac{b_b - b_a}{m_a - m_b} \iff (b_c - b_a)(m_a - m_b) \le (b_b - b_a)(m_a - m_c).$

(Hai mẫu đều dương vì $m_a > m_b > m_c$ nên nhân chéo không đổi chiều.)

CHT với deque khi mọi thứ đơn điệu. Trong bài in bài báo, $S_j$ tăng nên hệ số góc $m_j = -2S_j$ giảm dần theo thứ tự thêm, và truy vấn $x = S_i$ tăng dần. Khi đó:

  • Đường mới có hệ số góc nhỏ nhất nên luôn nằm ở cuối bao dưới. Trước khi đẩy vào cuối deque, bỏ đường cuối chừng nào nó thừa (điều kiện trên).
  • Truy vấn $x$ tăng nên điểm tối ưu trên bao dưới chỉ đi sang phải. Bỏ đường ở đầu deque chừng nào đường thứ hai cho giá trị $\le$ đường đầu, rồi đọc đường đầu.

Mỗi đường vào và ra deque nhiều nhất một lần, cả bài $O(n)$. Em xem mô phỏng trên đồ thị (có thể tự nhập các đường và các truy vấn):

MÔ PHỎNGConvex Hull Trick: thêm đường thẳng, loại đường thừa, truy vấn x tăngNhập hệ số góc m (giảm dần), hệ số tự do b và các x truy vấn (tăng dần). Theo dõi deque các đường tạo nên bao dưới.
đường vừa thêmđường trong dequeđường đang bị xét loạiđường cho min tại xđã bị loại

Giờ đọc code thật của bài in bài báo trong khung chạy tay. Mảng dq chứa chỉ số $j$ của các đường còn trên bao dưới, $h$ là đầu, $t$ là cuối (dùng mảng thay deque cho nhanh):

C++▶ Chạy tay từng dòng codeCHT với deque: bỏ đầu khi truy vấn x tăng, bỏ cuối khi đường mới làm đường cuối thừa (ô xanh lá: đường đang dùng, ô đỏ: đường bị bỏ).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4long long S[100005], dp[100005], M[100005], B[100005];
5int dq[100005];
6
7long long f(int j, long long x){ return M[j] * x + B[j]; }
8bool bad(int a, int b, int c){
9 return (__int128)(B[c] - B[a]) * (M[a] - M[b]) <= (__int128)(B[b] - B[a]) * (M[a] - M[c]);
10}
11
12int main(){
13 int n; long long C;
14 cin >> n >> C;
15 for(int i = 1; i <= n; i++){ cin >> S[i]; S[i] += S[i - 1]; }
16 int h = 0, t = 0;
17 dq[t++] = 0;
18 for(int i = 1; i <= n; i++){
19 while(t - h >= 2 && f(dq[h + 1], S[i]) <= f(dq[h], S[i])) h++;
20 dp[i] = f(dq[h], S[i]) + S[i] * S[i] + C;
21 M[i] = -2 * S[i]; B[i] = dp[i] + S[i] * S[i];
22 while(t - h >= 2 && bad(dq[t - 2], dq[t - 1], i)) t--;
23 dq[t++] = i;
24 }
25 cout << dp[n];
26 return 0;
27}
Xem code C++ / Python: convex hull trick cho bài in bài báo (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

long long S[200005], dp[200005], M[200005], B[200005];
int dq[200005];

long long f(int j, long long x){ return M[j] * x + B[j]; }

bool bad(int a, int b, int c){   // hệ số góc M[a] > M[b] > M[c]: đường b có thừa không?
    return (__int128)(B[c] - B[a]) * (M[a] - M[b]) <= (__int128)(B[b] - B[a]) * (M[a] - M[c]);
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n; long long C;
    cin >> n >> C;
    for(int i = 1; i <= n; i++){ cin >> S[i]; S[i] += S[i - 1]; }
    int h = 0, t = 0;
    dq[t++] = 0;                                    // đường của j = 0: M = 0, B = 0
    for(int i = 1; i <= n; i++){
        long long x = S[i];                         // truy vấn x tăng dần
        while(t - h >= 2 && f(dq[h + 1], x) <= f(dq[h], x)) h++;
        dp[i] = f(dq[h], x) + x * x + C;
        M[i] = -2 * x; B[i] = dp[i] + x * x;        // đường mới, hệ số góc giảm dần
        while(t - h >= 2 && bad(dq[t - 2], dq[t - 1], i)) t--;
        dq[t++] = i;
    }
    cout << dp[n];
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, C = int(data[0]), int(data[1])
    S = [0] * (n + 1)
    for i in range(n):
        S[i + 1] = S[i] + int(data[2 + i])
    M = [0] * (n + 1); B = [0] * (n + 1)      # đường của j = 0: M = 0, B = 0
    dq = [0] * (n + 1); h, t = 0, 1
    dp = 0
    for i in range(1, n + 1):
        x = S[i]                              # truy vấn x tăng dần
        while t - h >= 2 and M[dq[h + 1]] * x + B[dq[h + 1]] <= M[dq[h]] * x + B[dq[h]]:
            h += 1
        j = dq[h]
        dp = M[j] * x + B[j] + x * x + C
        M[i] = -2 * x; B[i] = dp + x * x      # đường mới, hệ số góc giảm dần
        while t - h >= 2:
            a, b = dq[t - 2], dq[t - 1]
            if (B[i] - B[a]) * (M[a] - M[b]) <= (B[b] - B[a]) * (M[a] - M[i]):
                t -= 1                        # đường b thừa
            else:
                break
        dq[t] = i; t += 1
    print(dp)

main()
Tràn số và dấu bằng
Tích $(b_c - b_a)(m_a - m_b)$ có thể vượt $10^{18}$ ngay cả khi $dp$ vừa long long: $b$ cỡ $10^{16}$, hiệu hệ số góc cỡ $10^{7}$. Trong C++ ép sang __int128 khi so sánh (hoặc so sánh bằng long double nếu chấp nhận sai số). Python không lo chuyện này. Dùng <= ở cả hai chỗ để bỏ luôn những đường trùng giao điểm, tránh deque phình ra vô ích. Nếu có hai đường cùng hệ số góc (đề cho $a_i \ge 0$ chẳng hạn), chỉ giữ đường có $b$ nhỏ hơn, nếu không phép nhân chéo sẽ sai.
Bài toán max, hệ số góc tăng, truy vấn giảm…
Mọi biến thể đều đưa về cùng một khuôn bằng cách đổi dấu: tìm $\max(m x + b)$ chính là $-\min(-m x - b)$; truy vấn $x$ giảm thì đổi $x \to -x$, $m \to -m$. Nếu hệ số góc đơn điệu nhưng truy vấn không đơn điệu, vẫn giữ bao dưới bằng deque nhưng trả lời truy vấn bằng tìm kiếm nhị phân trên các giao điểm, $O(\log n)$ mỗi truy vấn.

Phần 4Li Chao tree: khi không có gì đơn điệu

Bài toán con ếch trên đồi. Có $n$ cột đá ($n \le 5 \cdot 10^4$), cột $i$ cao $h_i$ ($0 \le h_i \le 10^6$, không theo thứ tự nào). Ếch ở cột 1, muốn tới cột $n$, mỗi bước nhảy từ $j$ tới một cột $i > j$ bất kỳ, tốn $(h_i - h_j)^2 + C$ ($0 \le C \le 10^9$). Chi phí nhỏ nhất?

Khai triển giống hệt phần 3: $dp_i = h_i^2 + C + \min_{j < i} \big( -2h_j \cdot h_i + dp_j + h_j^2 \big)$. Đường thẳng của $j$ có $m_j = -2h_j$, $b_j = dp_j + h_j^2$. Nhưng lần này $h_j$ lộn xộn: đường mới có thể chen vào giữa bao dưới, và truy vấn có thể nhảy lung tung. Deque bó tay.

Li Chao tree là một cây phân đoạn trên trục hoành. Mỗi nút quản lý một đoạn hoành độ $[l, r]$ và giữ đúng một đường: đường thấp nhất tại điểm giữa $mid$ trong số các đường đã đi qua nút đó.

  • Thêm đường mới vào nút: so đường mới với đường đang giữ tại $mid$, đường nào thấp hơn ở $mid$ thì ở lại nút. Đường thua chỉ có thể còn thắng ở một phía của $mid$ (hai đường cắt nhau nhiều nhất một lần): nếu nó thắng ở đầu trái $l$ thì giao điểm ở nửa trái, đi xuống con trái; ngược lại đi xuống con phải. Mỗi lần thêm đi đúng một đường từ gốc xuống lá: $O(\log)$.
  • Truy vấn tại $x$: đi từ gốc xuống lá chứa $x$, lấy min các đường gặp trên đường đi. Đúng vì đường tốt nhất tại $x$ chắc chắn được giữ ở một nút nào đó trên đường đi này. Cũng $O(\log)$.

Trục hoành có thể tới $10^6$ hoặc lớn hơn. Ở bài này mọi truy vấn đều là các $h_i$ biết trước, nên ta nén toạ độ: chỉ dựng cây trên mảng xs các giá trị $h$ đã sắp xếp, khác nhau. Cây chỉ còn $4n$ nút.

BướcThao tácNút gốc giữ (đoạn x ∈ [1, 9], mid = 5)Đường bị đẩy xuống
1thêm $y = x$$y = x$ (cây rỗng)–
2thêm $y = -x + 8$tại 5: $5$ so với $3$ → $y = -x + 8$ ở lại$y = x$ thắng ở $l = 1$ → xuống con trái [1, 5]
3thêm $y = 2$tại 5: $2 < 3$ → $y = 2$ ở lại$y = -x + 8$ thua ở $l = 1$ ($7 > 2$) → xuống con phải [6, 9]
4hỏi $x = 1$gốc: $2$; con trái giữ $y = x$: $1$min = 1

Bảng 2. Li Chao tree trên các hoành độ 1 … 9. Mỗi nút giữ đường tốt nhất tại điểm giữa của nó; đường thua đi xuống đúng một nhánh, nơi nó còn có thể thắng.

Xem code C++ / Python: li chao tree cho bài con ếch trên đồi (bấm để mở)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

struct Line {
    ll m, b;
    ll get(ll x) const { return m * x + b; }
};
vector<ll> xs;          // các hoành độ sẽ được hỏi, đã sắp xếp, khác nhau
vector<Line> tr;        // tr[node]: đường "thắng" tại điểm giữa đoạn của node

void addLine(Line nw){
    int node = 1, l = 0, r = xs.size() - 1;
    while(true){
        int mid = (l + r) / 2;
        bool lef = nw.get(xs[l]) < tr[node].get(xs[l]);
        bool md = nw.get(xs[mid]) < tr[node].get(xs[mid]);
        if(md) swap(tr[node], nw);                 // node giữ đường tốt hơn tại mid
        if(l == r) return;
        if(lef != md){ node = 2 * node; r = mid; } // giao điểm ở nửa trái
        else { node = 2 * node + 1; l = mid + 1; } // giao điểm ở nửa phải
    }
}

ll query(int p){        // p: chỉ số của x trong xs
    int node = 1, l = 0, r = xs.size() - 1;
    ll res = LLONG_MAX;
    while(true){
        res = min(res, tr[node].get(xs[p]));
        if(l == r) return res;
        int mid = (l + r) / 2;
        if(p <= mid){ node = 2 * node; r = mid; }
        else { node = 2 * node + 1; l = mid + 1; }
    }
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n; ll C;
    cin >> n >> C;
    vector<ll> h(n);
    for(auto &v : h) cin >> v;
    xs = h;
    sort(xs.begin(), xs.end());
    xs.erase(unique(xs.begin(), xs.end()), xs.end());
    tr.assign(4 * xs.size(), Line{0, (ll)4e18});
    ll dp = 0;
    addLine({-2 * h[0], h[0] * h[0]});            // dp[0] = 0
    for(int i = 1; i < n; i++){
        int p = lower_bound(xs.begin(), xs.end(), h[i]) - xs.begin();
        dp = query(p) + h[i] * h[i] + C;
        addLine({-2 * h[i], dp + h[i] * h[i]});
    }
    cout << dp;
    return 0;
}
import sys
from bisect import bisect_left

def main():
    data = sys.stdin.buffer.read().split()
    n, C = int(data[0]), int(data[1])
    h = [int(v) for v in data[2:2 + n]]
    xs = sorted(set(h))
    size = len(xs)
    TM = [0] * (4 * size)                  # hệ số góc của đường ở mỗi node
    TB = [4 * 10**18] * (4 * size)         # hệ số tự do (đường "vô cực" ban đầu)

    def add(m, b):
        node, l, r = 1, 0, size - 1
        while True:
            mid = (l + r) // 2
            xl, xm = xs[l], xs[mid]
            lef = m * xl + b < TM[node] * xl + TB[node]
            md = m * xm + b < TM[node] * xm + TB[node]
            if md:                         # node giữ đường tốt hơn tại mid
                TM[node], m = m, TM[node]
                TB[node], b = b, TB[node]
            if l == r:
                return
            if lef != md:
                node, r = 2 * node, mid
            else:
                node, l = 2 * node + 1, mid + 1

    def query(p):
        x = xs[p]
        node, l, r = 1, 0, size - 1
        res = TM[node] * x + TB[node]
        while l < r:
            mid = (l + r) // 2
            if p <= mid:
                node, r = 2 * node, mid
            else:
                node, l = 2 * node + 1, mid + 1
            v = TM[node] * x + TB[node]
            if v < res:
                res = v
        return res

    dp = 0
    add(-2 * h[0], h[0] * h[0])            # dp[0] = 0
    for i in range(1, n):
        dp = query(bisect_left(xs, h[i])) + h[i] * h[i] + C
        add(-2 * h[i], dp + h[i] * h[i])
    print(dp)

main()
CHT deque hay Li Chao?
Hệ số góc đơn điệu + truy vấn đơn điệu: deque, $O(n)$, code ngắn. Hệ số góc đơn điệu, truy vấn lộn xộn: deque + tìm nhị phân. Không có gì đơn điệu: Li Chao tree $O(n \log n)$, không cần nhân chéo nên khó sai hơn, chỉ cần so sánh giá trị hai đường tại một điểm. Nhiều bạn dùng luôn Li Chao cho mọi bài để khỏi nhớ điều kiện loại đường. Li Chao còn thêm được đoạn thẳng (đường chỉ có nghĩa trên $[x_1, x_2]$) với $O(\log^2)$, điều deque không làm được.

Phần 5Chia để trị tối ưu và nhắc qua tối ưu Knuth

Bài toán. Chia dãy $n$ số nguyên dương ($n \le 10^4$, $a_i \le 1000$) thành đúng $K$ đoạn liên tiếp ($K \le 20$), chi phí mỗi đoạn là (tổng đoạn)$^2$. Tìm tổng chi phí nhỏ nhất.

Thêm một chiều cho số đoạn: $dp[g][i] = \min_{j < i} \big( dp[g-1][j] + \text{cost}(j, i) \big)$. Tính thẳng là $O(K n^2)$. Bài này dùng CHT được, nhưng nếu $\text{cost}$ phức tạp hơn (không khai triển thành đường thẳng) thì sao? Ta dùng một nhận xét khác.

Gọi $opt(i)$ là $j$ nhỏ nhất đạt min khi tính $dp[g][i]$. Với rất nhiều hàm chi phí, $opt$ không giảm: $opt(1) \le opt(2) \le \ldots \le opt(n)$. Trực giác: $i$ lùi sang phải thì điểm cắt cuối cùng cũng không có lý do lùi sang trái. Điều kiện đủ thường dùng là bất đẳng thức tứ giác: với $a \le b \le c \le d$,

$\text{cost}(a, c) + \text{cost}(b, d) \le \text{cost}(a, d) + \text{cost}(b, c).$

Hàm (tổng đoạn)$^2$ với số dương thoả điều kiện này. Khi đi thi, nếu không chắc, em viết bản $O(n^2)$ in ra mảng $opt$ trên vài test ngẫu nhiên để kiểm tra nó có tăng dần không.

Chia để trị. Hàm compute(l, r, optl, optr) tính $dp[g][i]$ cho mọi $i \in [l, r]$, biết rằng opt của chúng nằm trong $[optl, optr]$. Lấy $mid = (l + r)/2$, thử mọi $j \in [optl, \min(mid - 1, optr)]$ để có $dp[g][mid]$ và $opt(mid)$. Vì $opt$ không giảm: các $i < mid$ có opt $\le opt(mid)$, các $i > mid$ có opt $\ge opt(mid)$. Gọi đệ quy hai nửa với khoảng opt đã thu hẹp. Mỗi tầng đệ quy tổng số $j$ được thử là $O(n)$, có $\log n$ tầng: mỗi lớp $O(n \log n)$, cả bài $O(K n \log n)$.

i=1i=2i=3i=4i=5i=6i=7i=801234567← jhàng giữa i = 4: thử hết, opt = 2hàng trên: chỉ cần j ≤ 2hàng dưới: chỉ cần j ≥ 2

Hình 3. Mỗi hàng là một $i$, mỗi cột là một $j$, ô xanh lá là $opt(i)$, tạo thành một bậc thang đi xuống sang phải. Giải hàng giữa ($i = 4$, opt $= 2$) xong thì hàng trên chỉ cần các cột $\le 2$, hàng dưới chỉ cần các cột $\ge 2$ (ô xanh dương); ô xám không bao giờ phải tính.

Em xem khoảng opt co lại thế nào trên một dãy cụ thể. Mỗi ô của bảng là giá trị $dp[g-1][j] + \text{cost}(j, i)$; để ý số ô phải tính so với cả tam giác:

MÔ PHỎNGChia để trị tối ưu: khoảng opt thu hẹp dầnNhập dãy (3 đến 9 số từ 1 đến 9) và số đoạn K (2 hoặc 3). Mỗi ô là giá trị dp[lớp trước][j] + chi phí đoạn (j, i].
đang thử jopt (j tốt nhất)khoảng j còn phải xétđã thửbỏ qua, không cần tính
C++▶ Chạy tay từng dòng codecompute(l, r, optl, optr): tính hàng giữa bằng cách thử j trong khoảng cho phép, rồi chia đôi với khoảng opt thu hẹp (ô vàng: j đang thử, ô xanh lá: best).
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4long long S[5005], prv[5005], cur[5005];
5
6long long cost(int j, int i){ return (S[i] - S[j]) * (S[i] - S[j]); }
7
8void compute(int l, int r, int optl, int optr){
9 if(l > r) return;
10 int mid = (l + r) / 2, best = optl;
11 cur[mid] = LLONG_MAX;
12 for(int j = optl; j <= min(mid - 1, optr); j++){
13 long long v = prv[j] + cost(j, mid);
14 if(v < cur[mid]){ cur[mid] = v; best = j; }
15 }
16 compute(l, mid - 1, optl, best);
17 compute(mid + 1, r, best, optr);
18}
19
20int main(){
21 int n, K;
22 cin >> n >> K;
23 for(int i = 1; i <= n; i++){ cin >> S[i]; S[i] += S[i - 1]; }
24 for(int i = 1; i <= n; i++) prv[i] = cost(0, i);
25 for(int g = 2; g <= K; g++){
26 compute(g, n, g - 1, n - 1);
27 for(int i = g; i <= n; i++) prv[i] = cur[i];
28 }
29 cout << prv[n];
30 return 0;
31}
Xem code C++ / Python: chia để trị tối ưu: chia dãy thành K đoạn (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

long long S[100005], prv[100005], cur[100005];

long long cost(int j, int i){ return (S[i] - S[j]) * (S[i] - S[j]); }   // đoạn j+1..i

// tính cur[l..r], biết opt của chúng nằm trong [optl, optr]
void compute(int l, int r, int optl, int optr){
    if(l > r) return;
    int mid = (l + r) / 2, best = optl;
    cur[mid] = LLONG_MAX;
    for(int j = optl; j <= min(mid - 1, optr); j++){
        long long v = prv[j] + cost(j, mid);
        if(v < cur[mid]){ cur[mid] = v; best = j; }
    }
    compute(l, mid - 1, optl, best);     // hàng trên: opt <= best
    compute(mid + 1, r, best, optr);     // hàng dưới: opt >= best
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, K;
    cin >> n >> K;
    for(int i = 1; i <= n; i++){ cin >> S[i]; S[i] += S[i - 1]; }
    for(int i = 1; i <= n; i++) prv[i] = cost(0, i);        // 1 đoạn
    for(int g = 2; g <= K; g++){
        compute(g, n, g - 1, n - 1);                         // g đoạn: i >= g, j >= g - 1
        for(int i = g; i <= n; i++) prv[i] = cur[i];
    }
    cout << prv[n];
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, K = int(data[0]), int(data[1])
    S = [0] * (n + 1)
    for i in range(n):
        S[i + 1] = S[i] + int(data[2 + i])
    prv = [(S[i]) ** 2 for i in range(n + 1)]          # 1 đoạn
    for g in range(2, K + 1):
        cur = [0] * (n + 1)
        stack = [(g, n, g - 1, n - 1)]                 # (l, r, optl, optr), khử đệ quy
        while stack:
            l, r, optl, optr = stack.pop()
            if l > r:
                continue
            mid = (l + r) // 2
            best, bv, Sm = optl, None, S[mid]
            for j in range(optl, min(mid - 1, optr) + 1):
                d = Sm - S[j]
                v = prv[j] + d * d
                if bv is None or v < bv:
                    bv, best = v, j
            cur[mid] = bv
            stack.append((l, mid - 1, optl, best))     # hàng trên: opt <= best
            stack.append((mid + 1, r, best, optr))     # hàng dưới: opt >= best
        prv = cur
    print(prv[n])

main()

Tối ưu Knuth cho QHĐ trên đoạn. Bài ghép đá ở bài 27 có công thức $dp[l][r] = \min_{l \le k < r} \big(dp[l][k] + dp[k+1][r]\big) + \text{sum}(l, r)$, tốn $O(n^3)$. Gọi $opt[l][r]$ là $k$ tốt nhất. Knuth chứng minh rằng với những hàm chi phí như tổng đoạn (thoả bất đẳng thức tứ giác và đơn điệu theo bao hàm):

$opt[l][r-1] \le opt[l][r] \le opt[l+1][r].$

Vậy khi tính $dp[l][r]$ chỉ cần thử $k$ trong khoảng đó. Cộng theo từng độ dài đoạn, các khoảng này “nối đuôi” nhau nên tổng chỉ còn $O(n^2)$. Bài tập 8 cài đặt đúng ý này.

Dạng công thức / dấu hiệuKỹ thuậtĐộ phức tạpVí dụ
$\text{cost}(j, i) = f(i) + g(j)$, $j$ chạy trên tiền tốmin tiền tố (tách biến)$O(n)$phần 1, bài tập 1
$j$ trong cửa sổ $[i - k, i - 1]$ hoặc $[i - R, i - L]$deque đơn điệu$O(n)$phần 2, bài tập 2, 3
$\text{cost}$ có tích $x_i \cdot y_j$, hệ số góc và truy vấn đơn điệuCHT bằng deque$O(n)$phần 3, bài tập 4, 6
có tích $x_i \cdot y_j$, không đơn điệuLi Chao tree$O(n \log n)$phần 4, bài tập 5
chia thành $K$ đoạn, $opt$ không giảmchia để trị tối ưu$O(K n \log n)$phần 5, bài tập 7
QHĐ trên đoạn, $opt[l][r-1] \le opt[l][r] \le opt[l+1][r]$tối ưu Knuth$O(n^2)$bài tập 8

Bảng 3. Bảng tra nhanh các kỹ thuật tối ưu QHĐ trong bài.

Thứ tự thử khi đi thi
Viết bản $O(n^2)$ đúng trước (lấy điểm subtask và làm chương trình trâu). Rồi khai triển $\text{cost}(j, i)$: tách được thì min tiền tố; có tích $x_i y_j$ thì CHT/Li Chao; chỉ có tính đơn điệu của opt thì chia để trị. Mỗi lần tối ưu xong, chạy đối chiếu với bản trâu trên vài trăm test nhỏ như bài 22 đã dạy.
Luyện tập trên web
Các bài dưới đây trên blog đều có lời giải chi tiết bằng C++ và Python, xếp từ dễ tới khó. Em hãy tự làm trước rồi mới xem lời giải nhé.Xem toàn bộ bài cùng chủ đề: nhãn Quy hoạch động.

Bài tậpLuyện tối ưu quy hoạch động

Bài 1Làm phẳng con đườngDễ

Con đường gồm $n$ đoạn, đoạn $i$ cao $h_i$ ($n \le 2000$, $0 \le h_i \le 100$). Cần đắp, xẻ để độ cao mới $b_i$ thoả $b_1 \ge b_2 \ge \ldots \ge b_n$ (đường dốc xuống, không có chỗ đi lên) và $0 \le b_i \le 100$. Chi phí là $\sum (h_i - b_i)^2$. Tìm chi phí nhỏ nhất.

Input
5
3 5 2 4 1
Output
4

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Giống bài toán phần 1 nhưng dãy không tăng: $dp[i][v] = (h_i - v)^2 + \min_{u \ge v} dp[i-1][u]$. Phần min là min hậu tố, nên duyệt $v$ từ 100 giảm về 0 và giữ biến best. Tổng $O(101 \cdot n)$. Với mẫu, một cách tối ưu là $b = [4, 4, 3, 3, 1]$, chi phí $1 + 1 + 1 + 1 + 0 = 4$.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    const int V = 100;
    int n;
    cin >> n;
    vector<long long> dp(V + 1, 0), nd(V + 1);
    for(int i = 0; i < n; i++){
        long long h;
        cin >> h;
        long long best = LLONG_MAX;                 // min hậu tố dp[v..V]
        for(int v = V; v >= 0; v--){
            best = min(best, dp[v]);
            nd[v] = best + (h - v) * (h - v);
        }
        dp = nd;
    }
    cout << *min_element(dp.begin(), dp.end());
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    V = 100
    dp = [0] * (V + 1)
    for i in range(n):
        h = int(data[1 + i])
        nd = [0] * (V + 1)
        best = float('inf')                 # min hậu tố dp[v..V]
        for v in range(V, -1, -1):
            if dp[v] < best:
                best = dp[v]
            nd[v] = best + (h - v) * (h - v)
        dp = nd
    print(min(dp))

main()
Bài 2Cắt cỏ không quá k con liên tiếpTrung bình

Có $n$ chú bò xếp hàng, chú thứ $i$ có năng suất $e_i$ ($n \le 10^5$, $0 \le e_i \le 10^9$). Chọn một số chú đi cắt cỏ sao cho không có quá $k$ chú liên tiếp trong hàng cùng được chọn ($1 \le k \le n$). Tổng năng suất lớn nhất là bao nhiêu?

Input
5 2
1 2 3 4 5
Output
12

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Nghĩ ngược: thay vì chọn, ta chọn những chú nghỉ. Điều kiện tương đương: mọi đoạn $k + 1$ chú liên tiếp có ít nhất một chú nghỉ. Đó chính là bài đặt camera ở phần 2 với cửa sổ $k + 1$ và chi phí $e_i$. Đáp án = tổng $e$ − chi phí nghỉ nhỏ nhất. Mẫu: cho chú năng suất 3 nghỉ, được $1 + 2 + 4 + 5 = 12$. Nhớ dùng long long.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, k;
    cin >> n >> k;
    vector<long long> e(n + 2, 0), dp(n + 2, 0);
    long long total = 0;
    for(int i = 1; i <= n; i++){ cin >> e[i]; total += e[i]; }
    int w = k + 1;                                   // mọi w chú liên tiếp có 1 chú nghỉ
    deque<int> dq;
    dq.push_back(0);
    for(int i = 1; i <= n + 1; i++){
        while(dq.front() < i - w) dq.pop_front();
        dp[i] = e[i] + dp[dq.front()];               // chú i nghỉ
        while(!dq.empty() && dp[dq.back()] >= dp[i]) dq.pop_back();
        dq.push_back(i);
    }
    cout << total - dp[n + 1];
    return 0;
}
import sys
from collections import deque

def main():
    data = sys.stdin.buffer.read().split()
    n, k = int(data[0]), int(data[1])
    e = [0] + [int(x) for x in data[2:2 + n]] + [0]
    w = k + 1                                  # mọi w chú liên tiếp có 1 chú nghỉ
    dp = [0] * (n + 2)
    dq = deque([0])
    for i in range(1, n + 2):
        while dq[0] < i - w:
            dq.popleft()
        dp[i] = e[i] + dp[dq[0]]               # chú i nghỉ
        while dq and dp[dq[-1]] >= dp[i]:
            dq.pop()
        dq.append(i)
    print(sum(e) - dp[n + 1])

main()
Bài 3Nhảy qua suốiTrung bình

Có $n$ hòn đá xếp thẳng hàng giữa hai bờ suối, bờ trái là vị trí $0$, các hòn đá là $1 \ldots n$, bờ phải là $n + 1$. Đặt chân lên hòn đá $i$ được $a_i$ điểm ($|a_i| \le 10^9$, có thể âm). Mỗi bước nhảy dài từ $L$ tới $R$ vị trí ($1 \le L \le R \le n + 1$, $n \le 2 \cdot 10^5$). Đi từ bờ trái sang bờ phải, tổng điểm lớn nhất là bao nhiêu? Nếu không thể sang bờ, in NO.

Input
5 2 3
2 -5 4 -1 3
Output
4

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Cửa sổ lệch của phần 2: $dp_i = a_i + \max_{i - R \le j \le i - L} dp_j$, chỉ lấy các $j$ tới được. Deque giữ $dp$ giảm dần (vì lấy max). Mỗi bước: đẩy $j = i - L$ vào cuối (nếu $j \ge 0$ và tới được), bỏ đầu nếu $< i - R$, rồi deque rỗng thì $i$ không tới được. Mẫu: $0 \to 3 \to 6$ được $4$ điểm; đi $0 \to 3 \to 5$ thì từ 5 không nhảy được 1 bước sang bờ.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, L, R;
    cin >> n >> L >> R;
    vector<long long> a(n + 2, 0), dp(n + 2, 0);
    vector<char> ok(n + 2, 0);
    for(int i = 1; i <= n; i++) cin >> a[i];
    ok[0] = 1;
    deque<int> dq;                                     // dp giảm dần
    for(int i = 1; i <= n + 1; i++){
        int j = i - L;                                 // j vừa đủ xa để nhảy tới i
        if(j >= 0 && ok[j]){
            while(!dq.empty() && dp[dq.back()] <= dp[j]) dq.pop_back();
            dq.push_back(j);
        }
        while(!dq.empty() && dq.front() < i - R) dq.pop_front();
        if(!dq.empty()){ ok[i] = 1; dp[i] = a[i] + dp[dq.front()]; }
    }
    if(ok[n + 1]) cout << dp[n + 1];
    else cout << "NO";
    return 0;
}
import sys
from collections import deque

def main():
    data = sys.stdin.buffer.read().split()
    n, L, R = int(data[0]), int(data[1]), int(data[2])
    a = [0] + [int(x) for x in data[3:3 + n]] + [0]
    dp = [None] * (n + 2)                      # None: không tới được
    dp[0] = 0
    dq = deque()                               # dp giảm dần
    for i in range(1, n + 2):
        j = i - L                              # j vừa đủ xa để nhảy tới i
        if j >= 0 and dp[j] is not None:
            while dq and dp[dq[-1]] <= dp[j]:
                dq.pop()
            dq.append(j)
        while dq and dq[0] < i - R:
            dq.popleft()
        if dq:
            dp[i] = a[i] + dp[dq[0]]
    print('NO' if dp[n + 1] is None else dp[n + 1])

main()
Bài 4Chở hàngTrung bình

Có $n$ kiện hàng xếp theo thứ tự, kiện $i$ nặng $w_i$ ($n \le 2 \cdot 10^5$, $1 \le w_i \le 1000$). Xe chở nhiều chuyến, mỗi chuyến chở một đoạn liên tiếp các kiện. Xe chạy tốt nhất khi chở đúng $T$ ($1 \le T \le 10^6$): một chuyến chở tổng $s$ tốn $(s - T)^2 + P$ ($0 \le P \le 10^9$). Tìm tổng chi phí nhỏ nhất để chở hết.

Input
6 6 4
2 3 4 1 5 2
Output
15

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Đặt $X_i = S_i - T$. Chuyến cuối chở $j + 1 \ldots i$: $(S_i - S_j - T)^2 = (X_i - S_j)^2 = X_i^2 - 2S_j X_i + S_j^2$. Vậy $dp_i = X_i^2 + P + \min_j(m_j X_i + b_j)$ với $m_j = -2S_j$, $b_j = dp_j + S_j^2$: đúng các đường thẳng của phần 3, chỉ có điểm truy vấn đổi thành $X_i$ (vẫn tăng dần). Mẫu: ba chuyến $2 + 3$, $4 + 1$, $5 + 2$ tốn $5 + 5 + 5 = 15$. Nhớ __int128 khi nhân chéo.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

long long S[200005], M[200005], B[200005];
int dq[200005];

long long f(int j, long long x){ return M[j] * x + B[j]; }
bool bad(int a, int b, int c){
    return (__int128)(B[c] - B[a]) * (M[a] - M[b]) <= (__int128)(B[b] - B[a]) * (M[a] - M[c]);
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n; long long T, P;
    cin >> n >> T >> P;
    for(int i = 1; i <= n; i++){ cin >> S[i]; S[i] += S[i - 1]; }
    int h = 0, t = 0;
    dq[t++] = 0;                                   // M[0] = B[0] = 0
    long long dp = 0;
    for(int i = 1; i <= n; i++){
        long long x = S[i] - T;                    // điểm truy vấn, tăng dần
        while(t - h >= 2 && f(dq[h + 1], x) <= f(dq[h], x)) h++;
        dp = f(dq[h], x) + x * x + P;
        M[i] = -2 * S[i]; B[i] = dp + S[i] * S[i];
        while(t - h >= 2 && bad(dq[t - 2], dq[t - 1], i)) t--;
        dq[t++] = i;
    }
    cout << dp;
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, T, P = int(data[0]), int(data[1]), int(data[2])
    S = [0] * (n + 1)
    for i in range(n):
        S[i + 1] = S[i] + int(data[3 + i])
    M = [0] * (n + 1); B = [0] * (n + 1)
    dq = [0] * (n + 1); h, t = 0, 1
    dp = 0
    for i in range(1, n + 1):
        x = S[i] - T                          # điểm truy vấn, tăng dần
        while t - h >= 2 and M[dq[h + 1]] * x + B[dq[h + 1]] <= M[dq[h]] * x + B[dq[h]]:
            h += 1
        j = dq[h]
        dp = M[j] * x + B[j] + x * x + P
        M[i] = -2 * S[i]; B[i] = dp + S[i] * S[i]
        while t - h >= 2:
            a, b = dq[t - 2], dq[t - 1]
            if (B[i] - B[a]) * (M[a] - M[b]) <= (B[b] - B[a]) * (M[a] - M[i]):
                t -= 1
            else:
                break
        dq[t] = i; t += 1
    print(dp)

main()
Bài 5Vé xe buýtKhá

Có $n$ trạm xe buýt đánh số $1 \ldots n$ ($n \le 5 \cdot 10^4$). Từ trạm $j$ có thể đi thẳng tới bất kỳ trạm $i > j$ với giá vé $b_j \cdot a_i$, trong đó $b_j$ là hệ số giá của trạm đi và $a_i$ là hệ số của trạm đến ($1 \le a_i, b_i \le 10^6$, không theo thứ tự nào). Tìm chi phí nhỏ nhất để đi từ trạm 1 tới trạm $n$ (có thể đổi xe nhiều lần).

Input
5
3 8 2 6 5
5 9 1 4 2
Output
15

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
$dp_i = \min_{j < i} (b_j \cdot a_i + dp_j)$: trạm $j$ là đường thẳng $y = b_j x + dp_j$, truy vấn tại $x = a_i$. Cả $b_j$ lẫn $a_i$ đều lộn xộn nên dùng Li Chao tree trên các giá trị $a$ đã nén. Mẫu: $1 \to 3$ tốn $5 \cdot 2 = 10$, $3 \to 5$ tốn $1 \cdot 5 = 5$, tổng $15$, rẻ hơn đi thẳng $5 \cdot 5 = 25$.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

struct Line { ll m, b; ll get(ll x) const { return m * x + b; } };
vector<ll> xs;
vector<Line> tr;

void addLine(Line nw){
    int node = 1, l = 0, r = xs.size() - 1;
    while(true){
        int mid = (l + r) / 2;
        bool lef = nw.get(xs[l]) < tr[node].get(xs[l]);
        bool md = nw.get(xs[mid]) < tr[node].get(xs[mid]);
        if(md) swap(tr[node], nw);
        if(l == r) return;
        if(lef != md){ node = 2 * node; r = mid; }
        else { node = 2 * node + 1; l = mid + 1; }
    }
}
ll query(int p){
    int node = 1, l = 0, r = xs.size() - 1;
    ll res = LLONG_MAX;
    while(true){
        res = min(res, tr[node].get(xs[p]));
        if(l == r) return res;
        int mid = (l + r) / 2;
        if(p <= mid){ node = 2 * node; r = mid; }
        else { node = 2 * node + 1; l = mid + 1; }
    }
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n;
    cin >> n;
    vector<ll> a(n), b(n);
    for(auto &v : a) cin >> v;
    for(auto &v : b) cin >> v;
    xs = a;
    sort(xs.begin(), xs.end());
    xs.erase(unique(xs.begin(), xs.end()), xs.end());
    tr.assign(4 * xs.size(), Line{0, (ll)4e18});
    ll dp = 0;
    addLine({b[0], 0});                             // trạm 1: dp = 0
    for(int i = 1; i < n; i++){
        int p = lower_bound(xs.begin(), xs.end(), a[i]) - xs.begin();
        dp = query(p);
        addLine({b[i], dp});
    }
    cout << dp;
    return 0;
}
import sys
from bisect import bisect_left

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = [int(v) for v in data[1:1 + n]]
    b = [int(v) for v in data[1 + n:1 + 2 * n]]
    xs = sorted(set(a))
    size = len(xs)
    TM = [0] * (4 * size)
    TB = [4 * 10**18] * (4 * size)

    def add(m, c):
        node, l, r = 1, 0, size - 1
        while True:
            mid = (l + r) // 2
            xl, xm = xs[l], xs[mid]
            lef = m * xl + c < TM[node] * xl + TB[node]
            md = m * xm + c < TM[node] * xm + TB[node]
            if md:
                TM[node], m = m, TM[node]
                TB[node], c = c, TB[node]
            if l == r:
                return
            if lef != md:
                node, r = 2 * node, mid
            else:
                node, l = 2 * node + 1, mid + 1

    def query(p):
        x = xs[p]
        node, l, r = 1, 0, size - 1
        res = TM[node] * x + TB[node]
        while l < r:
            mid = (l + r) // 2
            if p <= mid:
                node, r = 2 * node, mid
            else:
                node, l = 2 * node + 1, mid + 1
            v = TM[node] * x + TB[node]
            if v < res:
                res = v
        return res

    dp = 0
    add(b[0], 0)                             # trạm 1: dp = 0
    for i in range(1, n):
        dp = query(bisect_left(xs, a[i]))
        add(b[i], dp)
    print(dp)

main()
Bài 6Mua đấtKhá

Có $n$ mảnh đất hình chữ nhật, mảnh $i$ có kích thước $w_i \times h_i$ ($n \le 5 \cdot 10^4$, $1 \le w_i, h_i \le 10^6$). Người bán cho mua theo nhóm: một nhóm mảnh đất có giá bằng (chiều rộng lớn nhất trong nhóm) × (chiều dài lớn nhất trong nhóm). Mỗi mảnh thuộc đúng một nhóm. Tìm tổng chi phí nhỏ nhất để mua hết.

Input
4
100 1
15 15
20 5
1 100
Output
500

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Bước 1: mảnh có $w \le w'$ và $h \le h'$ của một mảnh khác thì bỏ đi (mua kèm miễn phí). Sắp xếp theo $w$, duyệt từ $w$ lớn về nhỏ, chỉ giữ mảnh có $h$ lớn hơn mọi mảnh đã giữ. Còn lại một dãy $w$ tăng, $h$ giảm. Bước 2: với dãy này, mỗi nhóm tối ưu là một đoạn liên tiếp $j + 1 \ldots i$ có giá $w_i \cdot h_{j+1}$, nên $dp_i = \min_j (h_{j+1} \cdot w_i + dp_j)$: hệ số góc $h_{j+1}$ giảm, truy vấn $w_i$ tăng → CHT deque. Mẫu: nhóm {15×15, 20×5} giá 300, cộng hai mảnh lẻ 100 + 100 = 500.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

ll W[50005], H[50005], dp[50005], M[50005], B[50005];
int dq[50005];

ll f(int j, ll x){ return M[j] * x + B[j]; }
bool bad(int a, int b, int c){
    return (__int128)(B[c] - B[a]) * (M[a] - M[b]) <= (__int128)(B[b] - B[a]) * (M[a] - M[c]);
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n;
    cin >> n;
    vector<pair<ll, ll>> p(n);
    for(auto &q : p) cin >> q.first >> q.second;
    sort(p.begin(), p.end());
    vector<pair<ll, ll>> keep;
    ll mh = 0;
    for(int i = n - 1; i >= 0; i--)                 // bỏ mảnh bị "che"
        if(p[i].second > mh){ keep.push_back(p[i]); mh = p[i].second; }
    reverse(keep.begin(), keep.end());              // w tăng, h giảm
    int m = keep.size();
    for(int i = 1; i <= m; i++){ W[i] = keep[i - 1].first; H[i] = keep[i - 1].second; }
    int h = 0, t = 0;
    M[0] = H[1]; B[0] = 0; dq[t++] = 0;             // đường của j: M = H[j+1], B = dp[j]
    for(int i = 1; i <= m; i++){
        while(t - h >= 2 && f(dq[h + 1], W[i]) <= f(dq[h], W[i])) h++;
        dp[i] = f(dq[h], W[i]);
        if(i == m) break;
        M[i] = H[i + 1]; B[i] = dp[i];
        while(t - h >= 2 && bad(dq[t - 2], dq[t - 1], i)) t--;
        dq[t++] = i;
    }
    cout << dp[m];
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    p = sorted((int(data[1 + 2 * i]), int(data[2 + 2 * i])) for i in range(n))
    keep, mh = [], 0
    for w, h in reversed(p):                 # bỏ mảnh bị "che"
        if h > mh:
            keep.append((w, h)); mh = h
    keep.reverse()                           # w tăng, h giảm
    m = len(keep)
    W = [0] + [w for w, _ in keep]
    H = [0] + [h for _, h in keep]
    M = [0] * (m + 1); B = [0] * (m + 1)
    M[0] = H[1]                              # đường của j: M = H[j+1], B = dp[j]
    dq = [0] * (m + 1); h, t = 0, 1
    dp = 0
    for i in range(1, m + 1):
        x = W[i]
        while t - h >= 2 and M[dq[h + 1]] * x + B[dq[h + 1]] <= M[dq[h]] * x + B[dq[h]]:
            h += 1
        j = dq[h]
        dp = M[j] * x + B[j]
        if i == m:
            break
        M[i] = H[i + 1]; B[i] = dp
        while t - h >= 2:
            a, b = dq[t - 2], dq[t - 1]
            if (B[i] - B[a]) * (M[a] - M[b]) <= (B[b] - B[a]) * (M[a] - M[i]):
                t -= 1
            else:
                break
        dq[t] = i; t += 1
    print(dp)

main()
Bài 7Phân công giám thịKhá

Phòng thi có $n$ thí sinh ngồi một hàng, thí sinh $i$ có “độ nghịch” $a_i$ ($n \le 3000$, $0 \le a_i \le 10^9$). Cần chia hàng thành đúng $K$ đoạn liên tiếp khác rỗng ($1 \le K \le \min(n, 40)$), mỗi đoạn giao cho một giám thị. Độ vất vả của giám thị coi đoạn $[l, r]$ là $(r - l + 1) \cdot (a_l + \ldots + a_r)$. Tìm tổng độ vất vả nhỏ nhất.

Input
6 3
5 2 8 1 7 3
Output
52

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
$dp[g][i] = \min_j \big(dp[g-1][j] + (i - j)(S_i - S_j)\big)$. Chi phí có tích $(i - j)(S_i - S_j)$ của hai đại lượng theo $j$ nên không thành một đường thẳng gọn, nhưng thoả bất đẳng thức tứ giác, $opt$ không giảm → chia để trị tối ưu, đúng code phần 5 chỉ đổi hàm cost. Tổng $O(K n \log n)$. Mẫu: $[5, 2]$, $[8, 1]$, $[7, 3]$ cho $14 + 18 + 20 = 52$.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

long long S[3005], prv[3005], cur[3005];

long long cost(int j, int i){ return (long long)(i - j) * (S[i] - S[j]); }

void compute(int l, int r, int optl, int optr){
    if(l > r) return;
    int mid = (l + r) / 2, best = optl;
    cur[mid] = LLONG_MAX;
    for(int j = optl; j <= min(mid - 1, optr); j++){
        long long v = prv[j] + cost(j, mid);
        if(v < cur[mid]){ cur[mid] = v; best = j; }
    }
    compute(l, mid - 1, optl, best);
    compute(mid + 1, r, best, optr);
}

int main(){
    int n, K;
    cin >> n >> K;
    for(int i = 1; i <= n; i++){ cin >> S[i]; S[i] += S[i - 1]; }
    for(int i = 1; i <= n; i++) prv[i] = cost(0, i);
    for(int g = 2; g <= K; g++){
        compute(g, n, g - 1, n - 1);
        for(int i = g; i <= n; i++) prv[i] = cur[i];
    }
    cout << prv[n];
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, K = int(data[0]), int(data[1])
    S = [0] * (n + 1)
    for i in range(n):
        S[i + 1] = S[i] + int(data[2 + i])
    prv = [i * S[i] for i in range(n + 1)]          # 1 đoạn
    for g in range(2, K + 1):
        cur = [0] * (n + 1)
        stack = [(g, n, g - 1, n - 1)]
        while stack:
            l, r, optl, optr = stack.pop()
            if l > r:
                continue
            mid = (l + r) // 2
            best, bv, Sm = optl, None, S[mid]
            for j in range(optl, min(mid - 1, optr) + 1):
                v = prv[j] + (mid - j) * (Sm - S[j])
                if bv is None or v < bv:
                    bv, best = v, j
            cur[mid] = bv
            stack.append((l, mid - 1, optl, best))
            stack.append((mid + 1, r, best, optr))
        prv = cur
    print(prv[n])

main()
Bài 8Ghép đá cỡ lớn (tối ưu Knuth)Khó

Có $n$ đống đá xếp thành hàng, đống $i$ có $a_i$ viên ($n \le 1500$, $1 \le a_i \le 10^9$). Mỗi lần được ghép hai đống kề nhau thành một, tốn chi phí bằng tổng số viên của đống mới. Ghép tới khi còn một đống. Tìm tổng chi phí nhỏ nhất. (Bản $n \le 300$ đã gặp ở bài 27; lần này $O(n^3)$ là quá chậm.)

Input
5
4 1 3 2 5
Output
34

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Công thức cũ: $dp[l][r] = \min_{l \le k < r} (dp[l][k] + dp[k+1][r]) + \text{sum}(l, r)$. Lưu thêm $opt[l][r]$ là $k$ tốt nhất; theo Knuth chỉ cần thử $k$ từ $opt[l][r-1]$ tới $opt[l+1][r]$. Tính theo độ dài tăng dần thì hai giá trị này đã có. Với mỗi độ dài cố định, tổng độ dài các khoảng $k$ “nối đuôi” nhau nên chỉ $O(n)$, cả bài $O(n^2)$. Mẫu: ghép (1, 3) tốn 4, ghép (4, 4) tốn 8, ghép (2, 5) tốn 7, cuối cùng ghép (8, 7) tốn 15, tổng 34.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

long long dp[1505][1505];
int opt[1505][1505];

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n;
    cin >> n;
    vector<long long> S(n + 1, 0);
    for(int i = 0; i < n; i++){ long long x; cin >> x; S[i + 1] = S[i] + x; }
    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;
            long long best = LLONG_MAX; int bk = l;
            for(int k = opt[l][r - 1]; k <= min(opt[l + 1][r], r - 1); k++){
                long long v = dp[l][k] + dp[k + 1][r];
                if(v < best){ best = v; bk = k; }
            }
            dp[l][r] = best + S[r + 1] - S[l];
            opt[l][r] = bk;
        }
    cout << dp[0][n - 1];
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    S = [0] * (n + 1)
    for i in range(n):
        S[i + 1] = S[i] + int(data[1 + i])
    dp = [[0] * n for _ in range(n)]
    opt = [[i] * n for i in range(n)]          # opt[i][i] = i
    for ln in range(2, n + 1):
        for l in range(n - ln + 1):
            r = l + ln - 1
            dl, best, bk = dp[l], None, l
            hi = min(opt[l + 1][r], r - 1)
            for k in range(opt[l][r - 1], hi + 1):
                v = dl[k] + dp[k + 1][r]
                if best is None or v < best:
                    best, bk = v, k
            dl[r] = best + S[r + 1] - S[l]
            opt[l][r] = bk
    print(dp[0][n - 1])

main()
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇
Bản quyền thuộc về Thầy Nguyễn Việt Nam Sơn. Bài viết, lời giải, hình minh hoạ và mã nguồn trên trang là công sức biên soạn của thầy qua nhiều năm giảng dạy. Vui lòng không sao chép, đăng lại hay sử dụng dưới mọi hình thức khi chưa được sự đồng ý của tác giả. Nếu thấy hữu ích, hãy chia sẻ bằng đường link bài gốc nhé. Cảm ơn bạn!
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
  • Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
  • Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
  • Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
  • Dễ nản giữa chừng khi không có ai theo sát.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
  • Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
  • Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
  • Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.

Nhận xét