DP xác suất & kỳ vọng
DP xác suất & kỳ vọng (Probability / Expected-value DP) dùng quy hoạch động để tính xác suất một sự kiện xảy ra hoặc kỳ vọng (giá trị trung bình) của một đại lượng ngẫu nhiên. Khác với DP tối ưu (lưu min/max), ở đây trạng thái DP lưu một số thực trong (xác suất) hoặc kỳ vọng. Nhờ chia bài toán thành các trạng thái con và tận dụng tính chất tuyến tính của kỳ vọng, ta tránh được việc liệt kê toàn bộ không gian mẫu có kích thước hàm mũ — ví dụ tính phân bố tổng của xúc xắc trong thay vì .
Ý tưởng / Trực giác
Mọi bài DP xác suất/kỳ vọng đều dựa trên hai công cụ nền tảng của lý thuyết xác suất, và chính chúng giải thích vì sao việc cộng dồn theo trạng thái lại đúng.
1. Luật xác suất toàn phần. Nếu từ trạng thái hiện tại ta đi tới các trạng thái rời nhau với xác suất (tổng bằng 1), thì xác suất đạt một sự kiện là
Đây chính là lý do công thức DP có dạng "cộng các nhánh con nhân với xác suất nhánh". Vì các nhánh rời nhau và phủ kín, không có sự kiện nào bị đếm trùng hay bỏ sót — đó là điều kiện để DP cộng dồn cho kết quả đúng.
2. Tính tuyến tính của kỳ vọng. luôn đúng, kể cả khi phụ thuộc nhau. Suy rộng cho kỳ vọng có điều kiện:
Với bài "kỳ vọng số bước đến đích", ta đặt = kỳ vọng số bước từ tới đích. Mỗi bước tốn 1 và đi sang với xác suất , nên
Chiều tính toán quyết định kỹ thuật:
- Trạng thái chỉ phụ thuộc trạng thái "trước" (DAG, thứ tự thời gian) → DP một chiều thẳng, gọi là forward (lan truyền xác suất từ đầu) hoặc backward (tính ngược từ đích).
- Trạng thái phụ thuộc lẫn nhau thành chu trình (ví dụ "thử lại" làm xuất hiện ở cả hai vế) → phải giải hệ phương trình tuyến tính (Gauss) hoặc biến đổi đại số để cô lập biến.
Nắm được "bài này có chu trình hay không" là chìa khoá chọn đúng cách cài.
Ví dụ chạy tay
Xét bài quen thuộc: xác suất tổng của xúc xắc 6 mặt rơi vào từng giá trị. Đặt dp[i][s] = xác suất sau khi tung con, tổng bằng . Công thức (luật toàn phần, mỗi mặt có xác suất ):
Chạy tay với . Khởi tạo dp[0][0] = 1 (chưa tung, tổng 0 chắc chắn). Sau 1 con, mỗi tổng có xác suất :
s: 0 1 2 3 4 5 6
dp[0]: 1.00 . . . . . .
dp[1]: . .167 .167 .167 .167 .167 .167
Sau 2 con, ta điền dp[2][s]. Lấy ô đang xét (đánh dấu [*]): các cách ra tổng 4 là — tức cộng dp[1][3] + dp[1][2] + dp[1][1], mỗi cái nhân :
s: 2 3 4 5 6 7 8 ...
dp[2]: .028 .056 [.083] .111 .139 .167 .139 ...
^^^
tổng 4 = (1,3)+(2,2)+(3,1) = 3/36
Đỉnh phân bố tại với , đối xứng hai bên — đúng trực giác "tổng 2 xúc xắc hay ra 7 nhất". Cộng dp[2][9] + dp[2][10] = 4/36 + 3/36 = 7/36 ≈ 0.194, khớp ví dụ của bài diceprob.
Cài đặt
Mẫu 1 — DP xác suất tiến (phân bố tổng xúc xắc, dùng cho diceprob):
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, a, b;
cin >> n >> a >> b; // n xúc xắc, hỏi P(tổng thuộc [a,b])
// dp[s] = xác suất tổng hiện tại bằng s (cuộn 1 chiều theo số con đã tung)
vector<double> dp(6 * n + 1, 0.0);
dp[0] = 1.0; // chưa tung: tổng 0 với xác suất 1
for (int i = 1; i <= n; i++) {
vector<double> nxt(6 * n + 1, 0.0);
for (int s = i - 1; s <= 6 * (i - 1); s++) {
if (dp[s] == 0) continue;
for (int f = 1; f <= 6; f++) // gieo con thứ i ra mặt f
nxt[s + f] += dp[s] / 6.0; // luật toàn phần: chia đều 1/6
}
dp = move(nxt);
}
double ans = 0;
for (int s = a; s <= b; s++) ans += dp[s];
cout << fixed << setprecision(6) << ans << "\n";
}
Mẫu 2 — DP kỳ vọng ngược (kỳ vọng số bước đến đích trên DAG):
// E[u] = kỳ vọng số bước từ u tới đích t. E[t] = 0.
// adj[u] = các cặp (v, p) với p là xác suất đi u -> v.
vector<double> E(n, 0.0);
for (int u : reverse_topo) { // duyệt theo topo NGƯỢC
if (u == t) { E[u] = 0; continue; }
E[u] = 1.0; // mỗi bước tốn 1
for (auto [v, p] : adj[u])
E[u] += p * E[v]; // cộng kỳ vọng các nhánh con
}
cout << E[s] << "\n";
Mẫu 3 — kết quả dưới dạng phân số modulo (dùng cho dicecombo, invprob):
Khi đề yêu cầu in modulo, không được dùng double. Một phân số được biểu diễn bằng , với là nghịch đảo modular tính bằng định lý Fermat nhỏ ().
const long long MOD = 1e9 + 7;
long long power(long long b, long long e) { // lũy thừa nhanh
long long r = 1; b %= MOD;
while (e > 0) {
if (e & 1) r = r * b % MOD;
b = b * b % MOD;
e >>= 1;
}
return r;
}
long long inv(long long a) { return power(a, MOD - 2); } // nghịch đảo modular
// Ví dụ dicecombo: dp[s] = số cách (có thứ tự) tung được tổng s
// dp[s] = dp[s-1] + dp[s-2] + ... + dp[s-6] (mod p), dp[0] = 1
int main() {
int n; cin >> n;
vector<long long> dp(n + 1, 0);
dp[0] = 1;
for (int s = 1; s <= n; s++)
for (int f = 1; f <= 6 && f <= s; f++)
dp[s] = (dp[s] + dp[s - f]) % MOD;
cout << dp[n] << "\n";
// Khi xác suất p/q xuất hiện, nhân thêm inv(q) thay vì chia.
}
Độ phức tạp
- Phân bố tổng xúc xắc (Mẫu 1): trạng thái là với và ; mỗi trạng thái xét 6 mặt. Thời gian , bộ nhớ nhờ cuộn mảng 1 chiều (chỉ giữ hàng hiện tại). So với liệt kê dãy kết quả, đây là cải thiện hàm mũ → đa thức.
- DP kỳ vọng trên DAG (Mẫu 2): mỗi đỉnh xử lý một lần, mỗi cạnh duyệt một lần → thời gian, bộ nhớ cho mảng
E. Lý do : topo sort đảm bảo khi tínhE[u]thì mọiE[v]kề đã sẵn sàng, không tính lại. - Giải hệ bằng Gauss (khi có chu trình): trạng thái tạo hệ phương trình thời gian, bộ nhớ cho ma trận. Đây thường là nút thắt, nên chỉ dùng khi không cô lập được biến bằng đại số.
- Nghịch đảo modular (Mẫu 3): mỗi lần do lũy thừa nhanh; nếu cần nhiều nghịch đảo, tiền xử lý bảng giai thừa nghịch đảo trong .
⚠️ Lỗi thường gặp
- Dùng
doublecho bài yêu cầu modulo. Triệu chứng: WA dù logic đúng, vì không phải phép chia số thực. Cách tránh: biểu diễn mọi phân số bằng nghịch đảo modular (), không bao giờ dùng toán tử/trên số nguyên modulo. - Modulo âm. Khi công thức có phép trừ (ví dụ dạng modulo), kết quả
(a - b) % MODcó thể âm. Cách tránh: viết((a - b) % MOD + MOD) % MOD. - Sai chiều DP với chu trình. Nếu trạng thái phụ thuộc lẫn nhau (như "thử lại": ) mà bạn cứ tính tiến, sẽ đọc một biến chưa xác định và lặp vô hạn hoặc ra số rác. Cách tránh: cô lập biến — chuyển về một vế: ; nếu không cô lập được thì dùng Gauss.
- Off-by-one ở biên trạng thái. Tổng của xúc xắc chỉ nằm trong ; duyệt ngoài khoảng này gây cộng nhầm
dprỗng hoặc truy cập âmdp[s-f]khi . Cách tránh: chặnf <= svà giới hạn vòngsđúng biên. - Quên khởi tạo trạng thái đáy. Thiếu
dp[0] = 1(xác suất "chưa làm gì" = 1) hoặcE[đích] = 0khiến toàn bộ bảng bằng 0. Cách tránh: luôn xác định rõ trạng thái cơ sở trước khi lặp. - Tràn số khi đếm cách không modulo kịp. Với bài đếm (dicecombo), số cách lớn rất nhanh; quên
% MODtrong vòng lặp gây trànlong long. Cách tránh: lấy modulo ngay sau mỗi phép cộng/nhân.
Biến thể / Mở rộng
- rất lớn (đến ): quan hệ truy hồi tuyến tính như đếm cách tung tổng (bài throwdice) không thể lặp bước. Dùng nhân ma trận + lũy thừa nhanh đưa về với là bậc truy hồi (ở đây ).
- Kỳ vọng + tối ưu hoá: một số bài trộn kỳ vọng với lựa chọn tối ưu (chọn hành động để cực tiểu kỳ vọng), khi đó trạng thái thêm chiều "đã dùng bao nhiêu lượt", như bài classrm.
- Xem thêm các trang liên quan: DP trên đồ thị, DP bitmask.
Bài tập luyện
- Tổ Hợp Xúc Xắc (dicecombo) — (Amateur) Đếm số cách (có thứ tự) tung xúc xắc để được tổng theo modulo — bài nhập môn DP tiến trên tổng kèm nghịch đảo/modulo.
- Tung xúc xắc (throwdice) — (Intermediate) Cùng truy hồi đếm tổng nhưng tới , buộc dùng nhân ma trận để tăng tốc — luyện mở rộng truy hồi tuyến tính.
- Xác suất xúc xắc (diceprob) — (Advanced) Tính xác suất tổng của xúc xắc rơi vào đoạn — đúng mẫu DP xác suất tiến lan truyền phân bố tổng.
- Xác suất nghịch thế (invprob) — (Advanced) Kỳ vọng/xác suất kết hợp tổ hợp, kết quả dạng phân số modulo — luyện biểu diễn xác suất bằng nghịch đảo modular.
- Phòng Học Hogwarts (classrm) — (Advanced) Cực tiểu hoá tổng khoảng cách kỳ vọng với giới hạn đơn xin đổi phòng — kết hợp kỳ vọng, đường đi ngắn nhất trên đồ thị và DP chọn lựa.