7.4状压DP

在C++中,状态压缩动态规划(State Compression DP,简称状压DP) 是一种通过 二进制位运算 高效表示离散状态集合的动态规划方法,特别适用于解决 组合优化排列选择 类问题。其核心思想是将多维状态压缩为整数,利用位操作快速进行状态转移。以下是状压DP的详细解析与实战指南:

一、状压DP的核心思想

  1. 状态表示
    用二进制数的每一位(bit)表示某个元素的 存在性状态。例如:

    • 00101 表示第0位和第2位被选中(从右向左数)
    • mask = 1 << k 表示第k位被激活
  2. 适用场景

    • 元素数量较少(通常 n ≤ 20,因为 2^20 ≈ 1e6 在内存可接受范围内)
    • 需要快速判断元素之间的组合关系(如覆盖、冲突、依赖)
  3. 优势

    • 将多维状态压缩为一维整数,简化状态管理
    • 利用位运算高效处理状态转移(与、或、异或、移位)

二、状压DP的解题步骤

  1. 定义状态
    确定状态变量 dp[mask],其中 mask 是一个二进制整数,表示当前已选/已访问的元素组合。

  2. 初始化状态
    通常 dp[0] = 0 或根据问题需求设置初始值。

  3. 状态转移方程
    遍历所有可能的状态转移路径,更新 dp[mask]。例如:

    for (int mask = 0; mask < (1 << n); ++mask) {
        for (int i = 0; i < n; ++i) {
            if (!(mask & (1 << i))) {  // 第i位未被选中
                int new_mask = mask | (1 << i);
                dp[new_mask] = min(dp[new_mask], dp[mask] + cost[i]);
            }
        }
    }
    
  4. 提取结果
    最终答案通常位于 dp[(1 << n) - 1](全选状态)或特定状态。


三、位运算技巧

1. 基本操作
操作 代码 说明
检查第k位是否为1 mask & (1 << k) 结果为非0表示存在
设置第k位为1 `mask (1 << k)`
设置第k位为0 mask & ~(1 << k) 取消选中第k个元素
切换第k位状态 mask ^ (1 << k) 取反第k
统计1的个数 __builtin_popcount(mask) GCC内置函数
2. 高级操作
  • 枚举子集:遍历所有子集(用于集合覆盖类问题)

    for (int subset = mask; subset; subset = (subset - 1) & mask) {
        // 处理子集subset
    }
    
  • 快速判断状态转移可行性:检查是否满足约束条件(如无冲突)


四、经典问题与代码实现

1. 旅行商问题(TSP)

问题:访问所有城市恰好一次,求最短路径。
状态定义dp[mask][u] 表示已访问城市集合为mask,当前位于城市u的最短路径。
状态转移dp[mask | (1 << v)][v] = min(dp[mask][u] + dist[u][v])

int tsp(vector<vector<int>>& dist) {
    int n = dist.size();
    vector<vector<int>> dp(1 << n, vector<int>(n, INT_MAX));
    // 初始化:从起点0出发
    dp[1][0] = 0;  // mask=000...001,表示只访问了城市0

    for (int mask = 1; mask < (1 << n); ++mask) {
        for (int u = 0; u < n; ++u) {
            if (!(mask & (1 << u))) continue;  // u不在当前路径中
            for (int v = 0; v < n; ++v) {
                if (mask & (1 << v)) continue; // v已被访问过
                int new_mask = mask | (1 << v);
                dp[new_mask][v] = min(dp[new_mask][v], dp[mask][u] + dist[u][v]);
            }
        }
    }

    // 返回从终点回到起点的最短路径
    int final_mask = (1 << n) - 1;
    int res = INT_MAX;
    for (int u = 0; u < n; ++u) {
        if (dp[final_mask][u] != INT_MAX && dist[u][0] != INT_MAX) {
            res = min(res, dp[final_mask][u] + dist[u][0]);
        }
    }
    return res;
}

2. 棋盘覆盖问题(LeetCode 52. N皇后 II)

问题:在n x n棋盘放置皇后,使得彼此不能互相攻击,求方案数。
状态定义:用位掩码表示列、主对角线、副对角线的占用情况。

int totalNQueens(int n) {
    int count = 0;
    function<void(int, int, int, int)> dfs = [&](int row, int cols, int diag1, int diag2) {
        if (row == n) {
            count++;
            return;
        }
        // 计算当前行可放置的位置
        int available = ((1 << n) - 1) & ~(cols | diag1 | diag2);
        while (available) {
            int pos = available & -available; // 取最低位的1
            available ^= pos; // 移除该位置
            dfs(row + 1, cols | pos, (diag1 | pos) << 1, (diag2 | pos) >> 1);
        }
    };
    dfs(0, 0, 0, 0);
    return count;
}

3. 最小顶点覆盖(LeetCode 943. 最短超级串)

问题:合并多个字符串为一个最短超级串,要求所有字符串都是其子串。
状态定义dp[mask][i] 表示已选字符串集合为mask,最后一个字符串为i时的最小长度。

string shortestSuperstring(vector<string>& words) {
    int n = words.size();
    vector<vector<int>> overlap(n, vector<int>(n, 0));
    
    // 预处理计算两两字符串的重叠长度
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < n; ++j) {
            if (i == j) continue;
            int len = min(words[i].size(), words[j].size());
            for (int k = len; k >= 0; --k) {
                if (words[i].substr(words[i].size() - k) == words[j].substr(0, k)) {
                    overlap[i][j] = k;
                    break;
                }
            }
        }
    }
    
    // 状压DP
    vector<vector<int>> dp(1 << n, vector<int>(n, INT_MAX));
    vector<vector<int>> parent(1 << n, vector<int>(n, -1));
    
    // 初始化:只选一个字符串
    for (int i = 0; i < n; ++i) {
        dp[1 << i][i] = words[i].size();
    }
    
    // 状态转移
    for (int mask = 1; mask < (1 << n); ++mask) {
        for (int last = 0; last < n; ++last) {
            if (!(mask & (1 << last))) continue;
            for (int next = 0; next < n; ++next) {
                if (mask & (1 << next)) continue;
                int new_mask = mask | (1 << next);
                int new_len = dp[mask][last] + words[next].size() - overlap[last][next];
                if (new_len < dp[new_mask][next]) {
                    dp[new_mask][next] = new_len;
                    parent[new_mask][next] = last;
                }
            }
        }
    }
    
    // 回溯构造结果
    int min_len = INT_MAX, last = -1;
    int full_mask = (1 << n) - 1;
    for (int i = 0; i < n; ++i) {
        if (dp[full_mask][i] < min_len) {
            min_len = dp[full_mask][i];
            last = i;
        }
    }
    
    // 根据parent数组重建路径
    vector<int> path;
    int mask = full_mask;
    while (last != -1) {
        path.push_back(last);
        int prev = parent[mask][last];
        mask ^= (1 << last);
        last = prev;
    }
    reverse(path.begin(), path.end());
    
    // 拼接字符串
    string res = words[path[0]];
    for (int i = 1; i < path.size(); ++i) {
        int overlap_len = overlap[path[i-1]][path[i]];
        res += words[path[i]].substr(overlap_len);
    }
    return res;
}

五、状压DP的优化技巧

  1. 预处理减少计算
    提前计算必要信息(如字符串重叠长度、冲突关系),避免在DP循环中重复计算。

  2. 滚动数组优化空间
    若状态转移仅依赖前一层的状态,可用两个数组交替使用减少内存占用:

    vector<int> prev(1 << n), curr(1 << n);
    
  3. 剪枝策略
    跳过不可能达到更优解的状态(如当前路径已超过已知最短长度)。

  4. 对称性优化
    利用问题对称性减少状态数量(如旋转、镜像对称的棋盘状态)。


六、常见错误与调试技巧

  1. 位运算优先级错误
    使用括号明确运算顺序,例如 mask & (1 << k) == 0 应写为 (mask & (1 << k)) == 0

  2. 状态空间过大
    n > 20 时,考虑其他算法(如启发式搜索)或问题特定优化。

  3. 初始化遗漏
    确保所有可能的初始状态正确设置(如 dp[1<)。

  4. 调试方法

    • 打印关键状态的值和转移路径
    • 对小规模输入(如 n=3)手动验证
    • 使用断言检查中间结果合法性

七、典型应用场景

问题类型 特点 示例问题
排列组合优化 选择元素的最优排列方式 TSP、任务调度
棋盘覆盖 处理行列、对角线的冲突 N皇后、数独
集合覆盖 选择子集满足覆盖条件 最小顶点覆盖、集合覆盖
位操作依赖 状态转移依赖位运算结果 子集生成、位掩码权限控制

八、实战训练建议

  1. 从简单问题入手
    先掌握基础状态表示(如子集选择),再挑战复杂问题(如TSP)。

  2. 模板化代码结构
    将状态循环、位操作封装为可复用的代码块。

  3. 理解问题本质
    分析状态转移的物理意义,避免盲目套用模板。

  4. 练习经典题目

    • LeetCode 464. 我能赢吗
    • LeetCode 691. 贴纸拼词
    • LeetCode 1434. 每个人戴不同帽子的方案数

状压DP的难点在于 将问题抽象为位掩码状态设计高效的状态转移方程。通过大量练习和总结,能够逐步掌握这一高效算法的精髓。

你可能感兴趣的:(c++数据结构与算法,开发语言,DP)