💎 状态压缩DP — 旅行商与棋盘覆盖

用二进制压缩状态空间——指数级问题的多项式解法

📖 状态压缩DP的核心

当DP状态包含"哪些元素已被选/访问"时,用二进制位表示选择状态,将2^n个状态压缩为整数,这就是状态压缩DP(bitmask DP)。它让指数级的状态空间变得可处理。

状态压缩DP原理: 问题: n个元素的选/不选状态 普通表示: 用数组[True, False, True, ...] --> 空间大 位运算表示: 用整数 0b101... --> 空间小,操作快 例子: n=4, 4个元素的选择状态 0b0000 = 0 (都不选) 0b0001 = 1 (只选第0个) 0b1010 = 10 (选第1和第3个) 0b1111 = 15 (全选) 核心位运算: +-- 检查第i位是否选中: mask & (1 << i) +-- 选中第i位: mask | (1 << i) +-- 取消第i位: mask & ~(1 << i) +-- 枚举所有子集: sub = mask; sub = (sub-1) & mask +-- 统计1的个数: bin(mask).count('1')

1. 状态压缩DP的通用框架

def bitmask_dp(n):
    full_mask = (1 << n) - 1  # 全部选中的状态
    dp = [初始值] * (1 << n)  # dp[mask] = 状态mask下的最优解
    for mask in range(1 << n):
        for i in range(n):
            if mask & (1 << i):  # 第i位已选
                prev = mask ^ (1 << i)  # 去掉第i位的前驱
                dp[mask] = optimize(dp[mask], dp[prev] + cost[i])
    return dp[full_mask]

2. 适用场景判断

特征是否适合状态压缩
n <= 202^20 = 100万,可接受
n <= 252^25 = 3200万,需要优化
n > 25状态空间太大,不适合
状态是选/不选的集合天然适合bitmask
需要枚举子集bitmask子集枚举很高效

🔬 题目一:访问所有节点的最短路径(LC 847)

给定无向图,返回能访问所有节点的最短路径长度。

思路分析

BFS + 状态压缩: 图: [[1,2,3],[0],[0],[0]] 4个节点, 状态数 = 2^4 = 16 BFS状态: (当前节点, 访问掩码) 初始: (0,0001=1), (1,0010=2), (2,0100=4), (3,1000=8) 目标: mask == 1111 = 15 从(0,0001)出发: -> 到1: (1, 0011=3) dist=1 -> 到2: (2, 0101=5) dist=1 -> 到3: (3, 1001=9) dist=1 继续BFS... 最终(?, 1111=15)时返回dist

代码实现

class Solution:
    def shortestPathLength(self, graph: List[List[int]]) -> int:
        n = len(graph)
        if n == 1:
            return 0
        target = (1 << n) - 1
        visited = set()
        queue = deque()
        for i in range(n):
            queue.append((i, 1 << i, 0))
            visited.add((i, 1 << i))
        while queue:
            node, mask, dist = queue.popleft()
            for neighbor in graph[node]:
                new_mask = mask | (1 << neighbor)
                if new_mask == target:
                    return dist + 1
                if (neighbor, new_mask) not in visited:
                    visited.add((neighbor, new_mask))
                    queue.append((neighbor, new_mask, dist + 1))
        return 0

复杂度分析

时间: O(n * 2^n) 空间: O(n * 2^n)

这是旅行商问题(TSP)的"可重复访问"变体。经典TSP(不重复)用DP解:dp[mask][i] = 访问mask中的城市、最后在i的最短路径。面试中一般考BFS+bitmask版本,因为更简单。

🔬 题目二:考试的最大出勤人数(LC 1349)

给定座位矩阵,学生不能看到左边、右边、左前、右前方的答案,求最多能坐多少学生。

思路分析

棋盘覆盖状态压缩: 座位: # . # # . # . # # # # . # . # # . # 约束: 不能看到 左 右 左前 右前 四个方向 即: 当前行mask & (mask >> 1) == 0 (左右) 当前行mask & (prev_mask >> 1) == 0 (左前) 当前行mask & (prev_mask << 1) == 0 (右前) dp[i][mask] = 第i行选择方案为mask时的最大人数

代码实现

class Solution:
    def maxStudents(self, seats: List[List[str]]) -> int:
        m, n = len(seats), len(seats[0])
        valid_rows = []
        for row in seats:
            mask = 0
            for j in range(n):
                if row[j] == '.':
                    mask |= (1 << j)
            valid_rows.append(mask)

        dp = [{} for _ in range(m)]
        for mask in range(1 << n):
            if (mask & valid_rows[0]) != mask: continue
            if mask & (mask >> 1): continue
            dp[0][mask] = bin(mask).count('1')

        for i in range(1, m):
            for mask in range(1 << n):
                if (mask & valid_rows[i]) != mask: continue
                if mask & (mask >> 1): continue
                cnt = bin(mask).count('1')
                for prev_mask in dp[i-1]:
                    if (mask & (prev_mask >> 1)) or (mask & (prev_mask << 1)):
                        continue
                    dp[i][mask] = max(dp[i].get(mask, 0), dp[i-1][prev_mask] + cnt)
        return max(dp[m-1].values()) if dp[m-1] else 0

复杂度分析

时间: O(m * 2^n * 2^n) 空间: O(m * 2^n)

棋盘覆盖类问题的通用模式:逐行处理,每行用bitmask表示选择方案,行间通过位运算检查约束。n<=8时2^n=256非常高效。

🔬 题目三:我能赢吗(LC 464)

两个玩家轮流从1到maxChoosableInteger中选一个数(不可重复选),先使累计和>=desiredTotal的玩家获胜。

思路分析

状态压缩 + 记忆化搜索:
1. 状态:已选数字的集合(bitmask)+ 剩余目标值
2. 枚举每个未选数字i:
- 若 i >= remaining,当前玩家直接赢
- 否则递归检查对手是否能赢,若对手不能赢则当前能赢
3. 剪枝:总和 < desiredTotal时两人都赢不了
4. memo缓存:避免重复计算

代码实现

class Solution:
    def canIWin(self, maxChoosableInteger: int, desiredTotal: int) -> bool:
        if maxChoosableInteger * (maxChoosableInteger + 1) // 2 < desiredTotal:
            return False
        if desiredTotal <= 0:
            return True
        memo = {}
        def dfs(remaining, used):
            if remaining <= 0:
                return False
            if used in memo:
                return memo[used]
            for i in range(1, maxChoosableInteger + 1):
                bit = 1 << (i - 1)
                if used & bit: continue
                if not dfs(remaining - i, used | bit):
                    memo[used] = True
                    return True
            memo[used] = False
            return False
        return dfs(desiredTotal, 0)

复杂度分析

时间: O(n * 2^n) 空间: O(2^n)

博弈论DP的核心:当前玩家能赢 iff 存在一个选择使得对手不能赢。这是极小化极大(Minimax)的思想。记忆化搜索比递推更好写,因为博弈问题的状态转移不总是按mask递增的顺序。

面试实战技巧

在实际面试中,算法题不仅仅是写代码,更考察沟通、分析和解决问题的能力。以下是一些实战技巧:

1. 解题步骤模板

5分钟分析:理解题意 - 确认边界 - 想暴力解 - 优化 - 写代码

1. 先复述题目,确认理解无误
2. 问清边界条件:空输入?负数?溢出?
3. 先说暴力解法(证明你理解问题)
4. 分析瓶颈,提出优化思路
5. 边写代码边解释思路

2. 常见陷阱

通用陷阱:
1. 索引越界:特别注意循环范围和数组边界
2. 初始化遗漏:DP的初始条件是否正确?
3. 遍历方向:依赖关系决定遍历顺序
4. 整数溢出:Python不溢出但其他语言注意
5. 边界条件:空数组、单元素、全相同

3. 代码模板

# 通用解题模板
class Solution:
    def solve(self, input_data):
        # 1. 边界检查
        if not input_data:
            return default_value
        # 2. 初始化
        result = initial_value
        state = initial_state
        # 3. 主循环
        for item in input_data:
            state = transition(state, item)
            result = max/min/update(result, state)
        # 4. 返回结果
        return result
面试中的代码不是越短越好,而是越清晰越好。适当的注释、合理的变量名、分步骤的实现,比一行式解法更能展示工程素养。
成就解锁:状态压缩大师 — 掌握bitmask DP的核心技巧:状态编码与转移
LeetCode AC验证:LC 847 Shortest Path Visiting All Nodes ✅ | LC 1349 Maximum Students Taking Exam ✅ | LC 464 Can I Win ✅

📝 课后练习

  1. LC 980 不同路径III
  2. LC 1595 连接两组点的最小成本
  3. LC 1655 分配重复整数
  4. LC 1986 任务调度器II

🔑 本课要点回顾

📚 扩展阅读

思考题:如果n=30,2^30约10亿状态无法直接存入数组,有什么优化策略?

⚡ 进阶专题:常见变体与扩展

每道经典题目都有丰富的变体。掌握核心模板后,灵活应对变体才是面试的真正挑战。以下列举本课相关的高频变体和扩展思路。

1. 常见题目变体

条件变化型:在原题基础上增加约束条件,如:数组中有负数、元素有上限、需要考虑溢出等。核心思路:不改变DP框架,只在转移时增加条件判断。

维度扩展型:从一维DP扩展到二维或三维。如:从线性到环形(LC 213)、从单串到双串(LCS)、从无约束到有约束。核心思路:增加状态维度来编码额外信息。

目标变化型:从求最值变为求方案数、求具体方案、判定可行性。核心思路:dp存的值从max/min变为count/boolean,转移从取最值变为累加。

2. 面试中的沟通技巧

# 面试沟通模板
# 1. 先说思路(1-2分钟)
"这道题我想到两种解法,先说更直观的一种..."

# 2. 分析复杂度(30秒)
"时间复杂度O(n^2),空间可以优化到O(n)"

# 3. 边写边说
"这里我定义dp[i]表示...,转移方程是..."

# 4. 主动验证
"我用示例走一遍:dp[0]=..., dp[1]=..."

# 5. 讨论优化
"空间还可以进一步优化,因为dp[i]只依赖..."

3. 代码调试检查清单

检查项常见错误
初始条件dp[0]是否正确?空输入是否处理?
遍历范围循环从0还是1开始?是否越界?
状态转移依赖的状态是否已计算?
返回值是dp[n]还是max(dp)?
边界情况单元素、全相同、空数组
面试中主动验证是最被低估的技巧。用示例手动走一遍DP表,既能发现bug,又能展示你的严谨性。很多面试官就是看你能否主动发现和修正错误。