C++状压 DP解析

状压DP:从原理到实战的深度解析(对话版)

引言

状压DP(状态压缩动态规划)是动态规划中针对"状态数量有限且可编码为整数"场景的核心分支,其核心思想是用整数的二进制位表示离散的状态集合,通过位运算快速完成状态的转移与合并。它本质是线性DP/区间DP的扩展,专门解决"状态无法用少量维度表示,但总状态数可控(通常≤2²⁰)"的问题,比如旅行商问题(TSP)、集合覆盖、棋盘放置等。本文以"新手提问+导师解答"的形式,从核心概念到经典例题,帮你彻底吃透状压DP的逻辑与实现。

一、初识状压DP:它解决什么问题?

新手 :导师您好!我学完线性DP后,看到状压DP的"状态压缩"就懵了------到底什么是状态压缩?状压DP和普通DP的核心区别是什么?
导师:别急,咱们先从核心特征和适用场景入手:

1. 状压DP的核心定义

"状压"即状态压缩:将一个"离散的状态集合"(比如"选了哪些城市""棋盘上哪些格子放了棋子")编码为一个整数,用整数的二进制位表示每个元素的状态(0/1表示"未选/已选""未放置/已放置")。

举个直观例子:

  • 若有4个城市,"选了第0、2号城市"这个状态,可编码为二进制1010(从右到左对应0~3号城市),即十进制的10
  • 若有5个格子,"第1、3号格子放了棋子"编码为二进制1010(十进制10),第k位为1表示第k个元素处于"选中/激活"状态。

2. 状压DP的典型特征

  • 状态是离散集合:问题的核心状态是"哪些元素被选中"(如城市、格子、物品),而非"第i个元素的状态";
  • 总状态数可控:若有n个元素,总状态数为2ⁿ,通常要求n≤20(2²⁰≈1e6,可遍历;n=25时2²⁵≈3e7,需优化);
  • 位运算为核心:通过与(&)、或(|)、异或(^)、移位(<<、>>)等操作快速修改/查询状态;
  • 无后效性:压缩后的状态包含所有必要信息,当前状态的转移仅依赖前序状态。

3. 状压DP vs 普通DP

维度 普通线性DP 状压DP
状态表示 dp[i](第i个元素的状态) dp[mask](mask编码状态集合)
状态维度 1~2维 1维(mask)或2维(mask+i)
核心操作 数组遍历 位运算
适用场景 线性/二维结构 集合选择、排列组合类问题
时间复杂度 O(n)~O(n²) O(n×2ⁿ)(n为元素数)

状压DP的前置知识

学习前必须掌握:

  1. 二进制与十进制转换:理解二进制位的含义(如第k位对应2ᵏ);
  2. 位运算基础
    • mask & (1 << k):判断第k位是否为1(查询状态);
    • mask | (1 << k):将第k位设为1(添加元素到状态);
    • mask & ~(1 << k):将第k位设为0(移除元素);
    • __builtin_popcount(mask):GCC内置函数,统计mask中1的个数(需包含头文件);
  3. 动态规划核心思想:最优子结构、无后效性。

二、状压DP核心框架:四步走

新手 :状压DP有没有通用的实现框架?看起来全是位运算,感觉无从下手。
导师:状压DP的框架非常固定,核心是"状态编码→状态定义→初始化→状态转移",具体分四步:

步骤1:状态编码(核心前提)

将问题中的"离散状态集合"编码为整数mask:

  1. 为每个元素分配一个唯一的索引(0~n-1);
  2. 用mask的第k位表示"第k个元素是否处于目标状态"(0=否,1=是);
  3. 确定mask的取值范围(0 ~ 2ⁿ - 1)。

示例:n=3个元素(A、B、C,索引0、1、2):

  • 空集:mask=0(二进制000);
  • 仅选A:mask=1(001);
  • 选A和C:mask=5(101);
  • 选所有元素:mask=7(111)。

步骤2:状态定义

根据问题要求,定义dp[mask]的含义:

  • 计数类问题:dp[mask]表示"达到mask状态的方案数";
  • 最优解类问题:dp[mask]表示"达到mask状态的最小/最大代价";
  • 进阶场景:dp[mask][k]表示"达到mask状态且最后一步在k位置的代价"(如TSP问题)。

关键原则:mask必须包含"推导下一步状态所需的所有信息",且无后效性。

步骤3:初始化

为初始状态赋合理值:

  • 计数类:初始状态(如空集)dp[0] = 1(表示"空状态有1种方案");
  • 最优解类:初始状态dp[0] = 0,其余状态初始化为无穷大/负无穷(如dp[mask] = INF);
  • 特殊场景:若初始状态是"选第k个元素",则dp[1<<k] = 初始代价

步骤4:状态转移

遍历所有可能的mask,对每个mask,通过位运算推导其可达的下一个状态new_mask,并更新dp[new_mask]

  1. 遍历mask(0 ~ 2ⁿ - 1);
  2. 对每个mask,遍历所有元素k,判断k是否在mask中((mask & (1<<k)) != 0);
  3. 若k不在mask中,构造new_mask = mask | (1<<k)
  4. 根据问题逻辑,更新dp[new_mask](如dp[new_mask] += dp[mask]dp[new_mask] = min(dp[new_mask], dp[mask] + cost))。

状压DP通用模板(伪代码)

cpp 复制代码
// n:元素总数,max_mask = 1<<n(总状态数)
int n;
const int MAX_MASK = 1 << 20; // 最多支持20个元素
long long dp[MAX_MASK]; // 状态数组
int cost[20][20]; // 可选:元素间的转移代价

int main() {
    // 步骤1:输入初始化(如cost数组)
    cin >> n;
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < n; ++j) {
            cin >> cost[i][j];
        }
    }
    
    // 步骤2:状态初始化
    memset(dp, INF, sizeof(dp)); // 最优解类初始化为无穷大
    dp[0] = 0; // 空状态代价为0
    
    // 步骤3:状态转移
    for (int mask = 0; mask < (1 << n); ++mask) { // 遍历所有状态
        if (dp[mask] == INF) continue; // 跳过无效状态
        
        // 遍历所有元素,寻找可添加的元素k
        for (int k = 0; k < n; ++k) {
            if (mask & (1 << k)) continue; // k已在mask中,跳过
            
            // 构造新状态
            int new_mask = mask | (1 << k);
            // 状态转移:根据问题更新dp[new_mask]
            dp[new_mask] = min(dp[new_mask], dp[mask] + cost[__builtin_popcount(mask)][k]);
        }
    }
    
    // 步骤4:提取答案(通常是dp[(1<<n)-1],即所有元素都选中的状态)
    cout << dp[(1 << n) - 1] << endl;
    return 0;
}

二、入门例题1:二进制枚举型状压DP------子集和问题

新手 :先从最简单的状压DP入手吧!子集和问题是入门级的,具体该怎么实现?
导师:子集和问题是状压DP的"Hello World",核心是用mask表示"选了哪些元素",遍历所有子集计算和,判断是否存在目标和。

问题定义

给定一个长度为n的整数数组nums和目标和target,判断是否存在一个子集,其元素和等于target(n≤20)。

解题思路

  1. 状态编码:mask的第k位表示"是否选第k个元素";
  2. 状态定义dp[mask]表示"mask对应的子集的元素和";
  3. 初始化dp[0] = 0(空子集和为0);
  4. 状态转移
    对每个mask,遍历元素k:若k未被选中,则dp[mask | (1<<k)] = dp[mask] + nums[k]
  5. 答案提取 :遍历所有mask,若存在dp[mask] == target,返回true。

完整代码实现(C++)

cpp 复制代码
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;

bool subsetSum(vector<int>& nums, int target) {
    int n = nums.size();
    const int MAX_MASK = 1 << n;
    vector<int> dp(MAX_MASK, -1); // -1表示未计算
    
    // 初始化
    dp[0] = 0;
    
    // 状态转移
    for (int mask = 0; mask < MAX_MASK; ++mask) {
        if (dp[mask] == -1) continue; // 跳过无效状态
        
        for (int k = 0; k < n; ++k) {
            if (mask & (1 << k)) continue; // k已选,跳过
            
            int new_mask = mask | (1 << k);
            if (dp[new_mask] == -1) { // 未计算过才更新
                dp[new_mask] = dp[mask] + nums[k];
            }
            
            // 提前判断:找到目标和直接返回
            if (dp[new_mask] == target) {
                return true;
            }
        }
    }
    
    // 遍历所有状态,确认是否存在目标和
    for (int mask = 0; mask < MAX_MASK; ++mask) {
        if (dp[mask] == target) {
            return true;
        }
    }
    return false;
}

int main() {
    vector<int> nums = {3, 34, 4, 12, 5, 2};
    int target = 9;
    
    if (subsetSum(nums, target)) {
        cout << "存在和为" << target << "的子集" << endl; // 输出:存在(4+5或3+2+4)
    } else {
        cout << "不存在" << endl;
    }
    return 0;
}

代码核心解析

  1. 状态编码 :mask的每一位对应数组元素的"选/不选",比如mask=5(101)表示选第0、2号元素;
  2. 提前剪枝 :计算过程中若发现dp[new_mask] == target,直接返回true,无需遍历所有状态;
  3. 空间优化 :用vector<int>代替固定数组,适配不同n的情况;
  4. 位运算关键mask & (1<<k)快速判断k是否被选中,mask | (1<<k)快速构造新状态。

新手 :为什么n≤20?如果n=21,2²¹=2097152,会不会超时?
导师 :n=20时总状态数是1,048,576,遍历所有状态+每个状态遍历20个元素,总运算量约2e7,在C++中可轻松处理;n=21时运算量翻倍到4e7,可能超时(需优化);n=30时2³⁰≈1e9,完全无法遍历------这是状压DP的核心限制:仅适用于n≤20的场景

三、入门例题2:路径型状压DP------旅行商问题(TSP)

新手 :TSP问题是状压DP的经典应用,看起来比子集和复杂,该怎么理解?
导师:TSP(旅行商问题)是状压DP的核心例题,问题描述是"给定n个城市和两两之间的距离,求从起点出发遍历所有城市并返回起点的最短路径",n≤15时用状压DP可解。

问题定义

给定n个城市(编号0~n-1),cost[i][j]表示从城市i到j的距离,求从城市0出发,遍历所有城市并返回0的最短路径长度。

解题思路

  1. 状态编码 :mask表示"已访问的城市集合",比如mask=5(101)表示已访问0、2号城市;
  2. 状态定义dp[mask][u]表示"已访问mask中的城市,且当前在城市u的最短路径长度";
  3. 初始化
    • dp[1<<0][0] = 0(仅访问城市0,当前在0,路径长度0);
    • 其余状态初始化为无穷大(INF);
  4. 状态转移
    对每个mask,遍历当前城市u(u在mask中),再遍历未访问的城市v:
    • 新状态new_mask = mask | (1<<v)
    • 转移方程:dp[new_mask][v] = min(dp[new_mask][v], dp[mask][u] + cost[u][v])
  5. 答案提取 :遍历所有u,dp[(1<<n)-1][u] + cost[u][0]的最小值(遍历所有城市后返回起点0)。

完整代码实现(C++)

cpp 复制代码
#include <iostream>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;

const int INF = 0x3f3f3f3f;

int tsp(vector<vector<int>>& cost) {
    int n = cost.size();
    const int MAX_MASK = 1 << n;
    // dp[mask][u]:已访问mask,当前在u的最短路径
    vector<vector<int>> dp(MAX_MASK, vector<int>(n, INF));
    
    // 初始化:仅访问城市0,当前在0
    dp[1 << 0][0] = 0;
    
    // 状态转移:遍历所有mask
    for (int mask = 0; mask < MAX_MASK; ++mask) {
        // 遍历当前城市u(u必须在mask中)
        for (int u = 0; u < n; ++u) {
            if (!(mask & (1 << u)) || dp[mask][u] == INF) {
                continue; // u不在mask中,或状态无效
            }
            
            // 遍历未访问的城市v
            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] + cost[u][v]);
            }
        }
    }
    
    // 提取答案:遍历所有城市后返回起点0
    int full_mask = (1 << n) - 1; // 所有城市都访问的状态
    int ans = INF;
    for (int u = 0; u < n; ++u) {
        ans = min(ans, dp[full_mask][u] + cost[u][0]);
    }
    return ans;
}

int main() {
    // 测试案例:4个城市的距离矩阵
    vector<vector<int>> cost = {
        {0, 2, 6, 5},
        {2, 0, 4, 4},
        {6, 4, 0, 2},
        {5, 4, 2, 0}
    };
    
    int min_path = tsp(cost);
    cout << "TSP最短路径长度:" << min_path << endl; // 输出:13(0→1→2→3→0:2+4+2+5=13)
    return 0;
}

核心难点解析

  1. 二维状态定义dp[mask][u]同时记录"已访问的城市"和"当前位置",这是TSP的核心------仅用mask无法推导下一步的转移代价;
  2. 状态转移逻辑:必须从"当前城市u"转移到"未访问城市v",保证路径的连续性;
  3. 答案提取:需加上"从最后一个城市返回起点0"的代价,才是完整的闭环路径;
  4. 复杂度分析:时间复杂度O(n²×2ⁿ),n=15时运算量=15²×2¹⁵=15²×32768=7,372,800,完全可解。

四、进阶例题:棋盘型状压DP------国王放置问题

新手 :棋盘类问题也是状压DP的常见场景,比如"国王放置",该怎么用状压DP解决?
导师:棋盘型状压DP的核心是"用mask表示一行的放置状态,通过位运算判断行内/行间的合法性",以"n×n棋盘放置国王,国王不能相邻(包括斜相邻),求最多放置数量"为例:

问题定义

在n×n的棋盘上放置国王,要求任意两个国王不能相邻(上下、左右、斜相邻),求最多能放置多少个国王(n≤10)。

解题思路

  1. 状态编码:mask的第k位表示"第k列是否放置国王";
  2. 预处理
    • 行内合法性:mask不能有相邻的1((mask & (mask << 1)) == 0),避免左右相邻;
  3. 状态定义dp[i][mask]表示"第i行用mask状态放置国王,前i行的最大放置数";
  4. 初始化 :第一行的所有合法mask,dp[1][mask] = __builtin_popcount(mask)
  5. 状态转移
    对第i行的mask_i,遍历第i-1行的mask_j:
    • 行间合法性:(mask_i & mask_j) == 0(无上下相邻)、(mask_i & (mask_j << 1)) == 0(无右下相邻)、(mask_i & (mask_j >> 1)) == 0(无左下相邻);
    • 转移方程:dp[i][mask_i] = max(dp[i][mask_i], dp[i-1][mask_j] + cnt(mask_i))(cnt是mask_i中1的个数);
  6. 答案提取 :第n行所有合法mask的dp[n][mask]的最大值。

完整代码实现(C++)

cpp 复制代码
#include <iostream>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;

const int MAXN = 10;
const int MAX_MASK = 1 << MAXN;
int dp[MAXN + 1][MAX_MASK]; // dp[i][mask]:第i行状态mask的最大放置数
vector<int> valid_masks; // 存储所有行内合法的mask

// 预处理行内合法的mask(无相邻1)
void preprocess(int n) {
    valid_masks.clear();
    for (int mask = 0; mask < (1 << n); ++mask) {
        if ((mask & (mask << 1)) == 0) { // 无左右相邻
            valid_masks.push_back(mask);
        }
    }
}

int kingPlacement(int n) {
    preprocess(n);
    memset(dp, 0, sizeof(dp));
    
    // 初始化第一行
    for (int mask : valid_masks) {
        dp[1][mask] = __builtin_popcount(mask);
    }
    
    // 状态转移:遍历第2~n行
    for (int i = 2; i <= n; ++i) {
        // 遍历第i行的合法mask
        for (int mask_i : valid_masks) {
            int cnt_i = __builtin_popcount(mask_i);
            // 遍历第i-1行的合法mask
            for (int mask_j : valid_masks) {
                // 判断行间合法性:无上下、斜相邻
                if ((mask_i & mask_j) == 0 && // 无上下相邻
                    (mask_i & (mask_j << 1)) == 0 && // 无右下相邻
                    (mask_i & (mask_j >> 1)) == 0) { // 无左下相邻
                    dp[i][mask_i] = max(dp[i][mask_i], dp[i-1][mask_j] + cnt_i);
                }
            }
        }
    }
    
    // 提取答案:第n行所有合法mask的最大值
    int ans = 0;
    for (int mask : valid_masks) {
        ans = max(ans, dp[n][mask]);
    }
    return ans;
}

int main() {
    int n = 4;
    int max_king = kingPlacement(n);
    cout << n << "×" << n << "棋盘最多放置" << max_king << "个国王" << endl; // 输出:4(每行放1个,错开位置)
    return 0;
}

核心解析

  1. 预处理优化 :提前筛选出所有行内合法的mask,避免遍历无效状态(比如mask=3(11)有相邻1,直接跳过);
  2. 行间合法性判断:通过三次位运算,确保国王无上下、斜相邻;
  3. __builtin_popcount:GCC内置函数,快速统计mask中1的个数(即当前行放置的国王数);
  4. 复杂度优化:n=10时,行内合法mask约200个,总运算量=10×200×200=4e4,效率极高。

五、状压DP的常见坑点与优化

新手 :学习状压DP容易踩哪些坑?有没有通用的优化技巧?
导师:状压DP的核心坑点集中在"位运算"和"状态有效性",常见问题和优化技巧如下:

常见坑点

  1. 位运算移位错误 :比如1 << k写成1 << (k+1),导致状态编码错误;
  2. 无效状态未跳过 :遍历mask时未跳过dp[mask] = INF的状态,增加无意义计算;
  3. 状态定义不全 :比如TSP中仅用dp[mask]而不记录当前城市,导致无法转移;
  4. 行间合法性遗漏:棋盘问题中忘记判断斜相邻,导致答案错误;
  5. 数据溢出 :未用long long存储路径长度/方案数,导致int溢出。

通用优化技巧

  1. 预处理合法状态
    • 提前筛选出所有行内/集合内合法的mask(如棋盘问题的无相邻1、子集和的有效子集),减少遍历次数;
  2. 状态压缩(二维→一维)
    • 若状态转移仅依赖前一行/前一状态,可用滚动数组(如dp[2][MAX_MASK])代替二维数组,降低空间复杂度;
  3. 位运算加速
    • __builtin_ctz(mask)(统计末尾0的个数)、__builtin_clz(mask)(统计开头0的个数)快速定位mask中的1;
    • mask ^ (1 << k)快速翻转第k位状态;
  4. 剪枝优化
    • 最优解类问题中,若dp[new_mask]已小于当前计算值,直接跳过;
    • 计数类问题中,提前判断mask的和/数量是否超过目标,终止无效转移;
  5. 哈希表替代数组
    • 若mask稀疏(大部分状态无效),用unordered_map<int, int>存储dp,仅保存有效状态(如n=20时,有效状态可能仅1e5,远小于1e6)。

六、状压DP的经典应用场景

新手 :除了上述例题,状压DP还能解决哪些问题?
导师:状压DP的应用场景集中在"集合选择/排列"类问题,常见场景:

  1. 路径类
    • 旅行商问题(TSP)、最短哈密顿路径(遍历所有节点的最短路径);
    • 多源点路径规划(选哪些点作为中转站);
  2. 子集类
    • 子集和、子集乘积、划分等和子集;
    • 集合覆盖(选最少子集覆盖所有元素);
  3. 棋盘类
    • 国王/皇后/车的放置问题(无冲突);
    • 多米诺骨牌覆盖(进阶:轮廓线DP+状压);
  4. 组合类
    • 组队问题(选哪些队员,满足属性约束);
    • 任务分配(n个任务分配给n个人,最小代价)。

七、总结

核心要点回顾

  1. 状压DP核心:用整数的二进制位编码状态集合,通过位运算快速完成状态转移,解决"状态是离散集合且总数量可控"的问题;
  2. 通用框架
    • 编码:将状态集合转为整数mask(二进制位表示元素状态);
    • 定义:dp[mask]dp[mask][u]表示状态对应的最优解/计数;
    • 初始化:为初始状态(如空集、仅选第一个元素)赋合理值;
    • 转移:遍历mask,通过位运算构造新状态,更新dp;
    • 提取:取最终状态(如全选mask)的dp值;
  3. 关键技巧
    • 预处理合法状态,减少无效遍历;
    • 位运算快速判断/修改状态(&/|/<</>>);
    • 二维状态转一维(滚动数组),优化空间;
  4. 避坑指南
    • 确保位运算移位位数正确(第k位对应1<<k);
    • 跳过无效状态(如dp[mask] = INF);
    • 状态定义需包含"转移所需的所有信息"(如TSP的当前城市)。

学习建议

状压DP的核心是"二进制思维+位运算",建议你:

  1. 先吃透子集和、TSP这两个入门例题,手动推导小案例的mask转移(比如n=3的TSP),理解二进制位的含义;
  2. 练习棋盘放置问题,掌握"行内/行间合法性判断"的位运算技巧;
  3. 尝试优化解法(如滚动数组、哈希表存储dp),理解优化的原理;
  4. 结合真题(如LeetCode的"最短路径访问所有节点""划分等和子集")巩固,重点关注"状态编码"------只要编码正确,转移逻辑往往水到渠成。

记住:状压DP的核心不是"背模板",而是"理解如何用二进制表示状态"。从n=5的小案例入手,手动写出mask对应的状态,你会发现所有状压DP问题都能套入"编码→定义→初始化→转移→提取"的框架中。

相关推荐
Roc.Chang1 小时前
Rust 入门 - RustRover 新建项目时四种项目模板对比
开发语言·后端·rust
故事和你911 小时前
sdut-程序设计基础Ⅰ-实验三while循环(1-10)
开发语言·数据结构·c++·算法·类和对象
Yupureki1 小时前
《算法竞赛从入门到国奖》算法基础:数据结构-并查集
c语言·数据结构·c++·算法
Andy1 小时前
Cpp语法1
c++·c
前端小D1 小时前
面向对象编程
开发语言·javascript
艾莉丝努力练剑2 小时前
静态地址重定位与动态地址重定位:Linux操作系统的视角
java·linux·运维·服务器·c语言·开发语言·c++
跟着珅聪学java2 小时前
Electron + Vue 现代化“新品展示“和“快捷下单“菜单
开发语言·前端·javascript
泡沫_cqy2 小时前
Java初学者文档
java·开发语言
前进的李工2 小时前
数据库视图:数据安全与权限管理利器
开发语言·数据库·mysql·navicat