動態規劃進階 — Bitmask DP、Digit DP 與空間優化技巧 | 資料結構與演算法

2026/07/04
動態規劃進階 — Bitmask DP、Digit DP 與空間優化技巧 | 資料結構與演算法

在掌握了動態規劃(Dynamic Programming)的基礎框架之後,進階技巧才是真正拉開競賽與面試實力差距的地方。Bitmask DP 用二進位整數壓縮集合狀態,解決排列與子集問題;Digit DP 逐位決策,統計數字範圍內的特定計數;區間 DPdp[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 中 tightstarted 旗標的作用
  • 將 O(n²) 空間的 DP 壓縮成 O(n) 的滾動陣列

核心概念

區間 DP(Interval DP)

狀態定義dp[l][r] 表示處理區間 [l, r] 的最優解。透過枚舉分割點 kl ≤ 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²)區間合併、括號、戳氣球
樹形 DPO(n)O(n)樹上子問題,後序合併
Bitmask DP(TSP)O(2ⁿ × n²)O(2ⁿ × n)n ≤ 20 的集合/排列問題
Bitmask DP(分配)O(2ⁿ × n)O(2ⁿ)n ≤ 20 的分組問題
Digit DPO(digits × states)O(digits × states)數字範圍內的計數
單調佇列優化 DPO(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 DPTSP、任務分配、美麗排列
計算 [0, N] 內滿足條件的整數個數Digit DP不含 4 的數、各位不重複的數
問題在樹上,子節點先算後合併樹形 DP打家劫舍 III、樹的直徑
轉移依賴滑動視窗內的最優前驅單調佇列優化最大跳躍代價

快速識別三步驟

  1. 看約束:n ≤ 20 → 考慮 Bitmask DP;問題涉及「計算 1 到 N 中有多少」→ 考慮 Digit DP
  2. 看狀態形狀:子問題是連續區間 [i, j] → 區間 DP;子問題在樹上節點 → 樹形 DP
  3. 看轉移結構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 技術關鍵思路
312Burst BalloonsHard區間 DP逆向思考:枚舉「最後一個被戳的氣球」
526Beautiful ArrangementMediumBitmask DPdp[mask] = 已安排 mask 中數字的方案數
877Stone GameMedium博弈 DP / 數學先手必勝;DP 解法 dp[i][j] 為先手優勢
1000Minimum Cost to Merge StonesHard區間 DP合法條件 (n-1) % (k-1) == 0
1012Numbers With Repeated DigitsHardDigit DP補集思想:總數減去各位不重複的數

LeetCode 1012 補充:「各位有重複數字的整數」= N - 各位不重複的整數數量,可直接複用 LC 2376 的 Digit DP 解法取補集。

刷題建議順序:526(Bitmask 入門)→ 877(博弈 DP 直覺)→ 312(區間 DP 核心)→ 1012(Digit DP)→ 1000(區間 DP 進階條件)


總結

進階動態規劃的學習核心在於「狀態建模」:選擇什麼作為狀態,直接決定問題是否可解以及解的效率。

本篇涵蓋的四大進階模式,每一種都有其獨特的建模方式:

  • 區間 DPdp[l][r] 表示區間最優解,從短到長填表,枚舉分割點 k
  • Bitmask DPdp[mask]dp[mask][i] 用整數位元壓縮集合狀態,n ≤ 20 的利器
  • Digit DPdfs(pos, state, tight, started) 逐位決策,tight 旗標是關鍵
  • 空間優化:滾動陣列、單調佇列,在不影響正確性的前提下壓縮記憶體

建議從 LeetCode 526(Bitmask)和 312(區間 DP)入手,親手追蹤 mask 的每一個位元與 dp[l][r] 的每一次轉移,直到能夠不看模板推導出轉移方程為止。Digit DP 則建議先默記模板(pos, mask, tight, started 四個參數的含義),再套用到具體題目上。

希望這篇文章能幫助你掌握進階 DP 的思路框架。如有任何問題或討論,歡迎透過 聯絡頁面 與我交流!下一篇將進入貪心演算法(Greedy Algorithm)——從局部最優推導全域最優,解鎖另一類高頻面試題型,敬請期待。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。