動態規劃進階 — Bitmask DP、Digit DP 與空間優化技巧 | 資料結構與演算法
在掌握了動態規劃(Dynamic Programming)的基礎框架之後,進階技巧才是真正拉開競賽與面試實力差距的地方。Bitmask DP 用二進位整數壓縮集合狀態,解決排列與子集問題;Digit DP 逐位決策,統計數字範圍內的特定計數;區間 DP 以
dp[l][r]枚舉分割點,處理合併類問題;搭配滾動陣列等空間優化,讓你在時間與空間之間取得最佳平衡。
前言
上一篇文章介紹了動態規劃的五步驟解題法,帶你用線性 DP、背包 DP 與二維 DP 打好基礎。這些技巧能解決大多數入門到中級的 DP 問題,但面對競賽中高難度題目或大廠面試的 Hard 題,還需要掌握更專精的進階模式。
進階 DP 的核心問題往往不是「能不能用 DP」,而是「怎麼定義狀態才能讓問題可解」。本篇將介紹四種進階 DP 模式,並搭配完整的 TypeScript 與 C++ 實作:
- 區間 DP(Interval DP):狀態定義在連續區間
[l, r]上,枚舉分割點k轉移 - Bitmask DP(狀態壓縮 DP):用整數的二進位位元表示集合,n ≤ 20 時的利器
- Digit DP(數位 DP):逐位建構數字,統計
[0, N]內滿足特定條件的數的個數 - 空間優化技巧:滾動陣列、狀態壓縮讓記憶體使用量從 O(2ⁿ) 降至 O(n)
讀完這篇文章,你將能夠:
- 識別問題屬於哪種進階 DP 模式
- 掌握區間 DP「從短到長」的正確填表順序
- 用位元運算建構 Bitmask DP 的狀態轉移
- 理解 Digit DP 中
tight與started旗標的作用 - 將 O(n²) 空間的 DP 壓縮成 O(n) 的滾動陣列
核心概念
區間 DP(Interval DP)
狀態定義:dp[l][r] 表示處理區間 [l, r] 的最優解。透過枚舉分割點 k(l ≤ k < r)將區間分成左右兩段,從兩段的答案推導出整段的答案。
填表順序至關重要:計算 dp[l][r] 時,依賴 dp[l][k] 與 dp[k+1][r],這兩段都比 [l, r] 短。因此必須從短區間往長區間填,也就是外層枚舉區間長度 len,內層枚舉起點 l。
區間填表順序(n = 5,長度由小到大):
長度 1(基底,對角線):
[0,0] [1,1] [2,2] [3,3] [4,4]
長度 2:
[0,1] [1,2] [2,3] [3,4]
長度 3:
[0,2] [1,3] [2,4]
長度 4:
[0,3] [1,4]
長度 5(最終答案):
[0,4]
j=0 j=1 j=2 j=3 j=4
i=0 [■] [■] [■] [■] [★]
i=1 [■] [■] [■] [■]
i=2 [■] [■] [■]
i=3 [■] [■]
i=4 [■]
■ = 已填,★ = 目標,計算 dp[0][4] 時 dp[0][k] 和 dp[k+1][4] 均已就緒
時間複雜度:O(n³),空間複雜度:O(n²)。
Bitmask DP(狀態壓縮 DP)
核心思想:當問題涉及 n 個元素的子集選取(通常 n ≤ 20),用一個整數的二進位位元表示集合狀態。1 << i 表示第 i 個元素被選取,整個 mask 就是目前已選集合的快照。
以 n = 4 個城市為例,mask 的含義:
mask = 0b1101 = 13
bit 0 = 1 → 城市 0 已訪問
bit 1 = 0 → 城市 1 未訪問
bit 2 = 1 → 城市 2 已訪問
bit 3 = 1 → 城市 3 已訪問
常用位元操作:
(mask >> i) & 1 → 檢查第 i 位是否為 1
mask | (1 << i) → 將第 i 位設為 1(加入城市 i)
mask ^ (1 << i) → 將第 i 位翻轉(移除城市 i)
mask == (1<<n)-1 → 是否所有城市都已訪問(full mask)
旅行商問題(TSP)狀態:
dp[mask][i] = 從起點出發,訪問過 mask 中所有城市,當前在城市 i 的最短距離
共 2^n × n 個狀態,時間複雜度 O(2^n × n²)
適用場景:n ≤ 20 的集合問題,如旅行商問題(TSP)、任務分配、集合覆蓋。
Digit DP(數位 DP)
核心思想:統計 [0, N] 範圍內滿足某性質的整數個數,逐位決策。關鍵在於追蹤兩個旗標:
tight(受限旗標):當前填寫的數字是否仍受上界 N 的約束。若tight = true,當前位最多只能填到 N 的對應位;若tight = false,可以填 0–9 任意數字。started(前導零旗標):是否已開始填有效數字,避免把007當成已用了 0 兩次。
數字 N = 325 的逐位決策樹(統計各位數字不重複的數):
pos=0 [tight=true]
選 0 (0<3) → [tight=false] → 後面隨意填(不受限)
選 1 (1<3) → [tight=false] → 後面隨意填
選 2 (2<3) → [tight=false] → 後面隨意填
選 3 (3=3) → [tight=true]
選 0 (0<2) → [tight=false] → 後面隨意填
選 1 (1<2) → [tight=false] → 後面隨意填
選 2 (2=2) → [tight=true]
選 0-5 (≤5) → tight 繼續傳遞
不能選 3-9(超出上界且 tight=true)
tight=false 的子問題可以記憶化(相同的 pos + mask 狀態結果一樣)
tight=true 的路徑每個 N 都不同,但總共只有 O(digits) 條
時間複雜度:O(digits × states),具體取決於記憶化的狀態維度。
TypeScript 實作
區間 DP — 戳氣球(LeetCode 312)
戳氣球是區間 DP 的經典難題。關鍵洞察:逆向思考,考慮「最後一個被戳破的氣球 k」而非第一個。這樣區間 (l, r) 中的 k 兩側在 k 被戳破時已全部清空,鄰居正好是邊界 arr[l] 與 arr[r],避免了狀態之間相互依賴的問題。
function maxCoins(nums: number[]): number {
// 在兩端加入虛擬氣球 1,避免邊界特殊處理
const arr = [1, ...nums, 1];
const n = arr.length;
// dp[l][r] = 戳破開區間 (l, r) 內所有氣球的最大硬幣數
// 注意是開區間:l 和 r 本身不被戳,作為邊界存在
const dp: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
// 外層枚舉區間長度(最小為 2,因為是開區間,至少包含一個元素)
for (let len = 2; len < n; len++) {
for (let l = 0; l + len < n; l++) {
const r = l + len;
// 枚舉最後一個被戳破的氣球 k(在開區間 (l, r) 內)
for (let k = l + 1; k < r; k++) {
// k 被戳破時,左側 (l, k) 和右側 (k, r) 已清空,鄰居是 arr[l] 和 arr[r]
const coins = arr[l] * arr[k] * arr[r];
dp[l][r] = Math.max(dp[l][r], dp[l][k] + coins + dp[k][r]);
}
}
}
return dp[0][n - 1];
// 時間:O(n³),空間:O(n²)
}
console.log(maxCoins([3, 1, 5, 8])); // 輸出:167
console.log(maxCoins([1, 5])); // 輸出:10
區間 DP — 通用模板
所有區間 DP 問題都共用這個框架,只需根據題目替換 cost 函數:
function intervalDP(arr: number[]): number {
const n = arr.length;
// 初始化 dp 表(長度為 1 的區間通常為 0 或特定基底值)
const dp: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
// 外層枚舉區間長度,從 2 開始(長度 1 為基底)
for (let len = 2; len <= n; len++) {
for (let l = 0; l + len - 1 < n; l++) {
const r = l + len - 1;
dp[l][r] = Infinity;
// 枚舉分割點 k:將 [l, r] 分成 [l, k] 和 [k+1, r]
for (let k = l; k < r; k++) {
const cost = arr[l] + arr[k] + arr[r]; // 根據題目替換
dp[l][r] = Math.min(dp[l][r], dp[l][k] + dp[k + 1][r] + cost);
}
}
}
return dp[0][n - 1];
}
Bitmask DP — TSP 旅行商問題
旅行商問題(Travelling Salesman Problem)是 Bitmask DP 的標誌性應用。從城市 0 出發,恰好訪問每個城市一次後回到起點,求最短總距離。
function tsp(dist: number[][]): number {
const n = dist.length;
const full = (1 << n) - 1; // 所有城市都訪問過的 mask
// dp[mask][i] = 已訪問 mask 中的城市,當前在城市 i 的最短距離
// 初始化為 Infinity
const INF = 1e9;
const dp: number[][] = Array.from(
{ length: 1 << n },
() => new Array(n).fill(INF)
);
// 起點:從城市 0 出發,mask 為 0b...0001
dp[1][0] = 0;
for (let mask = 1; mask <= full; mask++) {
for (let u = 0; u < n; u++) {
// 城市 u 必須在 mask 中
if (!(mask & (1 << u))) continue;
if (dp[mask][u] === INF) continue;
// 嘗試前往未訪問的城市 v
for (let v = 0; v < n; v++) {
if (mask & (1 << v)) continue; // v 已訪問,跳過
const nextMask = mask | (1 << v);
dp[nextMask][v] = Math.min(dp[nextMask][v], dp[mask][u] + dist[u][v]);
}
}
}
// 計算回到起點 0 的最短路徑
let ans = INF;
for (let u = 1; u < n; u++) {
if (dist[u][0] < INF) {
ans = Math.min(ans, dp[full][u] + dist[u][0]);
}
}
return ans === INF ? -1 : ans;
// 時間:O(2^n × n²),空間:O(2^n × n)
}
// 測試:4 個城市,距離矩陣
const dist = [
[0, 10, 15, 20],
[10, 0, 35, 25],
[15, 35, 0, 30],
[20, 25, 30, 0],
];
console.log(tsp(dist)); // 輸出:80(路徑:0→1→3→2→0)
Bitmask DP — 分配問題(LeetCode 526 美麗排列 / LC 698 分割等和子集)
以 LC 698 為例,展示如何用 Bitmask DP 記錄「哪些元素已被分配」的狀態:
function canPartitionKSubsets(nums: number[], k: number): boolean {
const total = nums.reduce((a, b) => a + b, 0);
if (total % k !== 0) return false;
const target = total / k;
if (nums.some(n => n > target)) return false;
// 大的先放,剪枝更有效
nums.sort((a, b) => b - a);
const n = nums.length;
const full = (1 << n) - 1;
// dp[mask] = 已選 mask 中的數字後,當前桶的累積餘數(對 target 取模)
// -1 表示此狀態不可達
const dp = new Int32Array(1 << n).fill(-1);
dp[0] = 0; // 初始狀態:無元素選取
for (let mask = 0; mask <= full; mask++) {
if (dp[mask] === -1) continue;
for (let i = 0; i < n; i++) {
if (mask & (1 << i)) continue; // 已選,跳過
const next = mask | (1 << i);
const cur = dp[mask] + nums[i];
if (cur <= target) {
// cur === target 表示一個桶滿了,下一個桶從 0 開始
dp[next] = cur % target;
}
}
}
// 所有元素選完,最後一個桶恰好裝滿
return dp[full] === 0;
// 時間:O(2^n × n),空間:O(2^n)
}
console.log(canPartitionKSubsets([4, 3, 2, 3, 5, 2, 1], 4)); // 輸出:true
console.log(canPartitionKSubsets([1, 2, 3, 4], 3)); // 輸出:false
Digit DP — 統計各位不重複的整數(LeetCode 2376)
這道題要計算 [1, n] 中各位數字互不相同的整數個數,是 Digit DP 的標準模板題:
function countSpecialNumbers(n: number): number {
const digits = String(n).split('').map(Number);
const len = digits.length;
// 記憶化:key 為 (pos, usedMask, tight, started) 的組合
const memo = new Map<string, number>();
function dfs(pos: number, mask: number, tight: boolean, started: boolean): number {
// 所有位都填完,若已開始填數字則計數 1,否則 0(防止純前導零)
if (pos === len) return started ? 1 : 0;
const key = `${pos},${mask},${tight},${started}`;
if (memo.has(key)) return memo.get(key)!;
// tight 決定當前位最多能填幾
const limit = tight ? digits[pos] : 9;
let result = 0;
for (let d = 0; d <= limit; d++) {
// 已開始填數字且數字 d 已用過,跳過
if (started && (mask >> d) & 1) continue;
const newTight = tight && d === limit; // 是否繼續受上界約束
const newStarted = started || d !== 0; // 填入非 0 數字即視為開始
// 前導零不佔用數字名額(007 中前兩個 0 不算「用過 0」)
const newMask = newStarted ? mask | (1 << d) : mask;
result += dfs(pos + 1, newMask, newTight, newStarted);
}
memo.set(key, result);
return result;
}
return dfs(0, 0, true, false);
// 時間:O(10 × 2^10 × len),空間:O(2^10 × len)
}
console.log(countSpecialNumbers(20)); // 輸出:19
console.log(countSpecialNumbers(135)); // 輸出:110
Digit DP — 1 到 N 中不含數字 4 的計數
另一個 Digit DP 經典:統計 [1, N] 中不包含數字 4 的整數個數。
function countWithoutFour(n: number): number {
const digits = String(n).split('').map(Number);
const len = digits.length;
// memo[pos][tight] 即可記憶化(此題狀態較簡單)
const memo: Map<string, number> = new Map();
function dfs(pos: number, tight: boolean, started: boolean): number {
if (pos === len) return started ? 1 : 0;
const key = `${pos},${tight},${started}`;
if (memo.has(key)) return memo.get(key)!;
const limit = tight ? digits[pos] : 9;
let result = 0;
for (let d = 0; d <= limit; d++) {
if (d === 4) continue; // 跳過含有數字 4 的選擇
if (!started && d === 0) {
// 前導零:繼續但不視為開始
result += dfs(pos + 1, false, false);
} else {
result += dfs(pos + 1, tight && d === limit, true);
}
}
memo.set(key, result);
return result;
}
return dfs(0, true, false);
}
console.log(countWithoutFour(100)); // 輸出:72(1-100 中不含 4 的數)
C++ 對照
戳氣球(Burst Balloons)
#include <vector>
#include <algorithm>
using namespace std;
class Solution {
public:
int maxCoins(vector<int>& nums) {
int n = nums.size();
// 在兩端插入虛擬氣球 1
vector<int> arr;
arr.push_back(1);
for (int x : nums) arr.push_back(x);
arr.push_back(1);
int m = arr.size();
// dp[l][r] = 戳破開區間 (l, r) 內所有氣球的最大硬幣
vector<vector<int>> dp(m, vector<int>(m, 0));
for (int len = 2; len < m; len++) {
for (int l = 0; l + len < m; l++) {
int r = l + len;
for (int k = l + 1; k < r; k++) {
int coins = arr[l] * arr[k] * arr[r];
dp[l][r] = max(dp[l][r], dp[l][k] + coins + dp[k][r]);
}
}
}
return dp[0][m - 1];
}
};
TSP — 旅行商問題(C++)
#include <vector>
#include <climits>
#include <algorithm>
using namespace std;
int tsp(vector<vector<int>>& dist) {
int n = dist.size();
int full = (1 << n) - 1;
const int INF = INT_MAX / 2;
// dp[mask][i] = 已訪問 mask 中城市,當前在城市 i 的最短距離
vector<vector<int>> dp(1 << n, vector<int>(n, INF));
dp[1][0] = 0; // 從城市 0 出發
for (int mask = 1; mask <= full; mask++) {
for (int u = 0; u < n; u++) {
if (!(mask & (1 << u))) continue;
if (dp[mask][u] == INF) continue;
for (int v = 0; v < n; v++) {
if (mask & (1 << v)) continue;
int nextMask = mask | (1 << v);
dp[nextMask][v] = min(dp[nextMask][v], dp[mask][u] + dist[u][v]);
}
}
}
int ans = INF;
for (int u = 1; u < n; u++) {
if (dist[u][0] != INF)
ans = min(ans, dp[full][u] + dist[u][0]);
}
return ans == INF ? -1 : ans;
}
// C++ 位元操作小技巧(GCC/Clang 內建):
// __builtin_popcount(mask) → mask 中 1 的個數(已選元素數)
// __builtin_ctz(mask) → 末尾 0 的個數(最低位 1 的位置)
// 例:__builtin_popcount(0b1101) = 3
// __builtin_ctz(0b1100) = 2
Digit DP(C++ 版)
#include <string>
#include <vector>
#include <functional>
using namespace std;
int countSpecialNumbers(int n) {
string s = to_string(n);
int len = s.size();
// memo[pos][mask][tight][started]
vector<vector<vector<vector<int>>>> memo(
len,
vector<vector<vector<int>>>(
1 << 10,
vector<vector<int>>(2, vector<int>(2, -1))
)
);
function<int(int, int, bool, bool)> dfs =
[&](int pos, int mask, bool tight, bool started) -> int {
if (pos == len) return started ? 1 : 0;
auto& ref = memo[pos][mask][tight][started];
if (ref != -1) return ref;
int limit = tight ? (s[pos] - '0') : 9;
int result = 0;
for (int d = 0; d <= limit; d++) {
if (started && (mask >> d & 1)) continue;
bool newTight = tight && (d == limit);
bool newStarted = started || (d != 0);
int newMask = newStarted ? (mask | (1 << d)) : mask;
result += dfs(pos + 1, newMask, newTight, newStarted);
}
return ref = result;
};
return dfs(0, 0, true, false);
}
複雜度分析
| 技術 | 時間複雜度 | 空間複雜度 | 適用條件 |
|---|---|---|---|
| 區間 DP(基本) | O(n³) | O(n²) | 區間合併、括號、戳氣球 |
| 樹形 DP | O(n) | O(n) | 樹上子問題,後序合併 |
| Bitmask DP(TSP) | O(2ⁿ × n²) | O(2ⁿ × n) | n ≤ 20 的集合/排列問題 |
| Bitmask DP(分配) | O(2ⁿ × n) | O(2ⁿ) | n ≤ 20 的分組問題 |
| Digit DP | O(digits × states) | O(digits × states) | 數字範圍內的計數 |
| 單調佇列優化 DP | O(n) | O(n) | 滑動視窗最大/最小值轉移 |
空間優化技巧
滾動陣列(Rolling Array)
當 dp[i] 只依賴 dp[i-1] 時,可以用兩個一維陣列交替使用,將 O(n²) 的空間壓縮為 O(n)。
/**
* LCS 空間優化版:O(m × n) → O(min(m, n))
* 每次只保留「前一行」的值
*/
function lcSpaceOptimized(text1: string, text2: string): number {
// 確保 text2 是較短的字串(最小化空間)
if (text1.length < text2.length) [text1, text2] = [text2, text1];
const m = text1.length, n = text2.length;
let prev = new Array<number>(n + 1).fill(0);
let curr = new Array<number>(n + 1).fill(0);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (text1[i - 1] === text2[j - 1]) {
curr[j] = prev[j - 1] + 1;
} else {
curr[j] = Math.max(prev[j], curr[j - 1]);
}
}
// 滾動交換:讓 curr 成為下一輪的 prev
[prev, curr] = [curr, prev];
curr.fill(0);
}
return prev[n];
}
console.log(lcSpaceOptimized("abcde", "ace")); // 輸出:3
單調佇列優化 DP
當轉移形如 dp[i] = min(dp[j]) + cost(i),且 j 的範圍是一個滑動視窗 [i-k, i-1] 時,用雙端佇列(Deque)維護單調性,可將 O(n²) 的遍歷降至 O(n)。
/**
* 滑動視窗最小值 DP(單調佇列優化)
* dp[i] = min(dp[j]) + nums[i],j 在 [i-k, i-1] 範圍內
* Time: O(n),Space: O(n)
*/
function slidingWindowMinDP(nums: number[], k: number): number {
const n = nums.length;
const dp = new Array(n).fill(Infinity);
dp[0] = 0;
// 單調遞增佇列,存放索引,佇列頭部是視窗內 dp 值最小的索引
const deque: number[] = [0];
for (let i = 1; i < n; i++) {
// 移除過期元素(超出視窗 [i-k, i-1])
while (deque.length > 0 && deque[0] < i - k) deque.shift();
// 用佇列頭部(最小值)更新 dp[i]
if (deque.length > 0) {
dp[i] = dp[deque[0]] + nums[i];
}
// 維護單調遞增性:隊尾 dp 值 >= dp[i] 時移除
// 因為 i 比隊尾更新且值更小,隊尾永遠不會被選到
while (deque.length > 0 && dp[deque[deque.length - 1]] >= dp[i]) {
deque.pop();
}
deque.push(i);
}
return dp[n - 1];
}
0/1 背包的空間壓縮
從二維 dp[i][j] 到一維滾動陣列 dp[j],關鍵在於從右往左遍歷容量,確保每個物品只被選一次:
/**
* 0/1 背包空間優化演示
* 二維版:dp[i][j] = max(dp[i-1][j], dp[i-1][j-w]+v)
* 壓縮後:dp[j] = max(dp[j], dp[j-w]+v)
* 從右往左遍歷:保證 dp[j-w] 是上一輪(未選當前物品)的值
*/
function knapsack01(weights: number[], values: number[], capacity: number): number {
const dp = new Array<number>(capacity + 1).fill(0);
for (let i = 0; i < weights.length; i++) {
// 從右往左:確保 dp[j-weights[i]] 尚未被當輪更新
for (let j = capacity; j >= weights[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[capacity];
}
面試考點:進階 DP 識別與分類
進階 DP 問題的識別技巧:
| 問題特徵 | 對應 DP 模式 | 典型題目 |
|---|---|---|
子問題定義在連續區間 [i, j],枚舉分割點 | 區間 DP | 戳氣球、矩陣鏈乘法、合併石頭 |
| n ≤ 20 的集合/排列選取 | Bitmask DP | TSP、任務分配、美麗排列 |
計算 [0, N] 內滿足條件的整數個數 | Digit DP | 不含 4 的數、各位不重複的數 |
| 問題在樹上,子節點先算後合併 | 樹形 DP | 打家劫舍 III、樹的直徑 |
| 轉移依賴滑動視窗內的最優前驅 | 單調佇列優化 | 最大跳躍代價 |
快速識別三步驟:
- 看約束:n ≤ 20 → 考慮 Bitmask DP;問題涉及「計算 1 到 N 中有多少」→ 考慮 Digit DP
- 看狀態形狀:子問題是連續區間
[i, j]→ 區間 DP;子問題在樹上節點 → 樹形 DP - 看轉移結構:
dp[i] = min over k in window(dp[k]) + cost→ 考慮單調佇列優化
常見陷阱複習:
- 區間 DP 枚舉順序錯誤:外層必須枚舉區間長度
len,而非起點l,確保短區間先於長區間計算完畢。 - Bitmask DP 初始狀態設錯:TSP 起點是
dp[1 << 0][0] = 0(城市 0 已訪問),而非dp[0][0] = 0,後者代表從未訪問任何城市出發。 - Digit DP 前導零處理:
started = false時不可把0記入已用數字 mask,否則007會被誤判為已使用兩次0。 - 滾動陣列方向混淆:0/1 背包從右往左(每個物品選一次),完全背包從左往右(物品可重複選)。兩者方向相反,面試時務必口頭說明原因。
LeetCode 練習題
| 題號 | 題目 | 難度 | DP 技術 | 關鍵思路 |
|---|---|---|---|---|
| 312 | Burst Balloons | Hard | 區間 DP | 逆向思考:枚舉「最後一個被戳的氣球」 |
| 526 | Beautiful Arrangement | Medium | Bitmask DP | dp[mask] = 已安排 mask 中數字的方案數 |
| 877 | Stone Game | Medium | 博弈 DP / 數學 | 先手必勝;DP 解法 dp[i][j] 為先手優勢 |
| 1000 | Minimum Cost to Merge Stones | Hard | 區間 DP | 合法條件 (n-1) % (k-1) == 0 |
| 1012 | Numbers With Repeated Digits | Hard | Digit DP | 補集思想:總數減去各位不重複的數 |
LeetCode 1012 補充:「各位有重複數字的整數」= N - 各位不重複的整數數量,可直接複用 LC 2376 的 Digit DP 解法取補集。
刷題建議順序:526(Bitmask 入門)→ 877(博弈 DP 直覺)→ 312(區間 DP 核心)→ 1012(Digit DP)→ 1000(區間 DP 進階條件)
總結
進階動態規劃的學習核心在於「狀態建模」:選擇什麼作為狀態,直接決定問題是否可解以及解的效率。
本篇涵蓋的四大進階模式,每一種都有其獨特的建模方式:
- 區間 DP:
dp[l][r]表示區間最優解,從短到長填表,枚舉分割點k - Bitmask DP:
dp[mask]或dp[mask][i]用整數位元壓縮集合狀態,n ≤ 20 的利器 - Digit DP:
dfs(pos, state, tight, started)逐位決策,tight旗標是關鍵 - 空間優化:滾動陣列、單調佇列,在不影響正確性的前提下壓縮記憶體
建議從 LeetCode 526(Bitmask)和 312(區間 DP)入手,親手追蹤 mask 的每一個位元與 dp[l][r] 的每一次轉移,直到能夠不看模板推導出轉移方程為止。Digit DP 則建議先默記模板(pos, mask, tight, started 四個參數的含義),再套用到具體題目上。
希望這篇文章能幫助你掌握進階 DP 的思路框架。如有任何問題或討論,歡迎透過 聯絡頁面 與我交流!下一篇將進入貪心演算法(Greedy Algorithm)——從局部最優推導全域最優,解鎖另一類高頻面試題型,敬請期待。