状压dp:带你从入门到入土(从tsp到dominoTiling问题)

应群u要求水一篇状压dp的博客

动态规划(DP)是算法竞赛和编程面试中的常客,而状态压缩动态规划(状压DP)则是其中一种高级技巧,本文将带你从零开始学习状压DP,理解其核心思想,并通过C++代码示例掌握实现方法

一、什么是状压DP?

状压DP是一种利用位运算来高效表示和转移状态的动态规划方法。它特别适用于状态可以用二进制位表示的问题,通常处理的是"选或不选"、"存在或不存在"这类的二元状态

为什么需要状态压缩?

  • 常规DP在表示某些状态时需要大量空间

  • 状态压缩可以极大减少内存使用

  • 位运算操作非常高效,能提升算法速度  (其实就是因为这个)

二、基础知识准备

考虑到有些小伙伴们可能对位运算不太熟悉,故在此写一些在状压中常用的位运算:

// 常用位运算操作
int S = 0;          // 空集
S |= (1 << i);      // 将第i位置为1(加入集合)
S & (1 << i);       // 判断第i位是否为1
S &= ~(1 << i);     // 将第i位置为0(从集合移除)
S ^= (1 << i);      // 切换第i位的状态
S & (S - 1);        // 清除最低位的1
__builtin_popcount(S); // 计算S中1的个数

其中__builtin_popcount(S)内置于gcc中  (其实这个煮啵也不常用,板子上写上来凑数的,记不住的小伙伴们可以借lowbit操作完成)

三、经典问题:旅行商问题(TSP)

让我们以著名的旅行商问题为例,展示状压DP的应用 :

问题描述给定n个城市和它们之间的距离,求从某个城市出发,经过所有城市恰好一次并返回起点的最短路径。

状态设计

定义dp[s][i]表示:

  • s:已经访问过的城市集合(用二进制位表示)

  • i:当前所在的城市

  • 值:表示达到这个状态的最小花费

c++实现

#include 
#include 
#include 
#include 

using namespace std;

const int INF = INT_MAX / 2;

int tsp(const vector>& dist) {
    int n = dist.size();
    int size = 1 << n;
    vector> dp(size, vector(n, INF));
    
    // 初始化:从0号城市出发
    dp[1][0] = 0;
    
    for (int mask = 1; mask < size; ++mask) {
        for (int last = 0; last < n; ++last) {
            if (!(mask & (1 << last))) continue;  // 必须包含last城市
            
            for (int next = 0; next < n; ++next) {
                if (mask & (1 << next)) continue;  // 不能重复访问
                
                int new_mask = mask | (1 << next);
                dp[new_mask][next] = min(dp[new_mask][next], 
                                        dp[mask][last] + dist[last][next]);
            }
        }
    }
    
    // 最后需要返回起点0
    int final_mask = (1 << n) - 1;
    int res = INF;
    for (int last = 1; last < n; ++last) {
        res = min(res, dp[final_mask][last] + dist[last][0]);
    }
    
    return res;
}

int main() {
    vector> dist = {
        {0, 10, 15, 20},
        {10, 0, 35, 25},
        {15, 35, 0, 30},
        {20, 25, 30, 0}
    };
    
    cout << tsp(dist) << endl;
    return 0;
}
1. 问题理解

旅行商问题:给定一组城市和每对城市之间的距离,找到访问每个城市恰好一次并返回起点的最短路线。

2. 代码结构

代码主要由两部分组成:

  1. tsp()函数:实现动态规划算法

  2. main()函数:提供测试用例并调用tsp()

3. 详细解释
3.1 动态规划表定义
int n = dist.size();
int size = 1 << n;
vector> dp(size, vector(n, INF));
n:城市数量

size = 1 << n:计算所有可能的状态数,每个状态用一个n位的二进制数表示

dp表:dp[mask][last]表示:

mask:已经访问过的城市集合(二进制位为1表示已访问)

last:当前所在的最后一个城市

值:从起点出发,经过mask指定的城市,最后到达last城市的最短路径长度
    3.2初始化
    dp[1][0] = 0;
    • 初始状态:从城市0出发,只访问过城市0(mask=0001),路径长度为0

    3.3动态规划填充
    for (int mask = 1; mask < size; ++mask) {
        for (int last = 0; last < n; ++last) {
            if (!(mask & (1 << last))) continue;  // 必须包含last城市
            
            for (int next = 0; next < n; ++next) {
                if (mask & (1 << next)) continue;  // 不能重复访问
                
                int new_mask = mask | (1 << next);
                dp[new_mask][next] = min(dp[new_mask][next], 
                                       dp[mask][last] + dist[last][next]);
            }
        }
    }

    这部分是算法的核心:

    1. 外层循环遍历所有可能的状态mask(从0001到1111)

    2. 对于每个状态,检查所有可能的最后城市last

      • if (!(mask & (1 << last))) continue:确保last城市确实在当前状态中

    3. 对于每个可能的下一城市next

      • if (mask & (1 << next)) continue:确保不重复访问已访问的城市

      • 计算新状态new_mask(将next城市标记为已访问)

      • 更新dp[new_mask][next]的值:比较当前值与从lastnext的转移路径

    3.3 计算最终结果
    int final_mask = (1 << n) - 1;
    int res = INF;
    for (int last = 1; last < n; ++last) {
        res = min(res, dp[final_mask][last] + dist[last][0]);
    }
    • final_mask = (1 << n) - 1:所有城市都已访问的状态(如4个城市时,1111)

    • 遍历所有可能的最后城市(除了起点0),计算从该城市返回起点的总距离

    • 取最小值作为最终结果

    3.4 测试用例
    vector> dist = {
        {0, 10, 15, 20},
        {10, 0, 35, 25},
        {15, 35, 0, 30},
        {20, 25, 30, 0}
    };

    这是一个4个城市的对称tsp实例,城市间的距离形成一个对称矩阵

    4. 算法复杂度
    • 时间复杂度:O(n²·2ⁿ) —— 这是TSP问题动态规划解法的标准复杂度

    • 空间复杂度:O(n·2ⁿ)

    对于n=4,算法会高效运行;但对于更大的n(如n>20),这种解法会因为指数级增长而变得不实用,但本篇只用以给小伙伴们入门,所以不再做后续优化

    5.样例过程

    以给定的4城市为例,算法会:

    1. 从城市0出发 (状态0001)

    2. 考虑所有可能的下一步:城市1、2、3

    3. 逐步构建所有可能的部分路径

    4. 最终考虑所有城市都访问过的情况,并计算返回起点的距离

    5. 输出最短路径长度(在这个样例中结果是80)

    四、状压DP的解题步骤

    1. 分析问题:确定问题是否适合状压DP(通常是状态可以二进制表示的选择问题)

    2. 设计状态:确定dp数组的含义,通常包含状态集合和附加信息

    3. 状态转移:找出状态之间的关系和转移方程

    4. 初始化:设置初始状态的值

    5. 计算顺序:确定状态的遍历顺序(通常从小到大枚举状态)

    6. 结果提取:从最终状态中提取答案

    五、另一个例子:覆盖棋盘问题

    问题描述:给定一个n×m的棋盘,用1×2的骨牌覆盖,问有多少种不重叠的覆盖方式。

    C++实现

    #include 
    #include 
    #include 
    #include 
    
    using namespace std;
    
    long long dominoTiling(int n, int m) {
        // 通常我们让m较小以压缩状态
        if (n < m) swap(n, m);
        
        int total_states = 1 << m;
        vector> dp(n + 1, vector(total_states, 0));
        
        dp[0][0] = 1;  // 初始状态
        
        for (int row = 0; row < n; ++row) {
            for (int mask = 0; mask < total_states; ++mask) {
                if (dp[row][mask] == 0) continue;
                
                int next_mask = (~mask) & (total_states - 1);  // 反转mask得到空缺位置
                
                // 生成所有可能的放置方式
                function dfs = [&](int col, int current) {
                    if (col == m) {
                        dp[row + 1][current] += dp[row][mask];
                        return;
                    }
                    
                    // 当前位置已被覆盖,跳过
                    if (next_mask & (1 << col)) {
                        // 尝试竖放(需要下一行同一列为空)
                        if (row + 1 < n) {
                            dfs(col + 1, current | (1 << col));
                        }
                        
                        // 尝试横放(需要右侧位置也空)
                        if (col + 1 < m && (next_mask & (1 << (col + 1)))) {
                            dfs(col + 2, current);
                        }
                    } else {
                        dfs(col + 1, current);
                    }
                };
                
                dfs(0, 0);
            }
        }
        
        return dp[n][0];
    }
    
    int main() {
        int n = 2, m = 3;
        cout << n  << " "  << m  << " "  << dominoTiling(n, m) << endl;
        return 0;
    }
    1. 问题理解

    骨牌平铺问题:给定一个n×m的棋盘,使用1×2或2×1的多米诺骨牌完全覆盖棋盘,计算有多少种不同的平铺方式。

    2. 代码结构

    这段代码主要有dominoTiling函数构成,主要目的是计算骨牌堆砌过程中不断变化而存储的过程

    3. 详细解释
    3.1 函数参数和预处理
    long long dominoTiling(int n, int m) {
        // 通常我们让m较小以压缩状态
        if (n < m) swap(n, m);
    • 为了使状态压缩更高效,通常让m是较小的维度

    • 如果n < m,交换它们以确保m是较小的值

    3.2 动态规划表初始化
    
    int total_states = 1 << m;
    vector> dp(n + 1, vector(total_states, 0));
        
    dp[0][0] = 1;  // 初始状态
    • total_states = 1 << m:计算所有可能的状态数,每个状态用一个m位的二进制数表示

    • dp表:dp[row][mask]表示:

      • row:当前处理的行号

      • mask:当前行的覆盖状态(二进制位为1表示该位置被覆盖)

      • 值:到达该状态的不同平铺方式数量

    • 初始状态:第0行完全未被覆盖(mask=0),有1种方式(空棋盘)

    3.3 动态规划填充
    for (int row = 0; row < n; ++row) {
        for (int mask = 0; mask < total_states; ++mask) {
            if (dp[row][mask] == 0) continue;
                
            int next_mask = (~mask) & (total_states - 1);  // 反转mask得到空缺位置
    • 外层循环遍历每一行

    • 内层循环遍历所有可能的状态

    • next_mask:计算下一行的空缺位置(反转当前mask并限制在有效位数内)

    3.4 深度优先搜索生成所有可能放置方式
    function dfs = [&](int col, int current) {
        if (col == m) {
            dp[row + 1][current] += dp[row][mask];
            return;
        }
                    
        // 当前位置已被覆盖,跳过
        if (next_mask & (1 << col)) {
            // 尝试竖放(需要下一行同一列为空)
            if (row + 1 < n) {
                dfs(col + 1, current | (1 << col));
            }
                        
            // 尝试横放(需要右侧位置也空)
            if (col + 1 < m && (next_mask & (1 << (col + 1)))) {
                dfs(col + 2, current);
            }
        } else {
            dfs(col + 1, current);
        }
    };
                
    dfs(0, 0);

    这个DFS函数递归地生成所有可能的骨牌放置方式:

    1. 基本情况:当处理完所有列(col == m),更新下一行的状态计数

    2. 如果当前位置有空缺(next_mask & (1 << col)):

      • 尝试竖放(2×1骨牌):标记下一行的当前位置为已覆盖

      • 尝试横放(1×2骨牌):需要右侧位置也空缺,然后跳过下一列

    3. 如果当前位置已被覆盖,直接处理下一列

    3.5 返回最终结果
    return dp[n][0];

    最终结果存储在dp[n][0],表示处理完所有n行且最后一行完全被覆盖的状态

    3.6 测试用例
    int n = 2, m = 3;
    cout << n  << " "  << m  << " "  << dominoTiling(n, m) << endl;
    • 测试一个2×3的棋盘,输出平铺方式的数量(正确结果为3)

    4. 算法复杂度
    • 时间复杂度:O(n × 2^m × f(m)),其中f(m)是DFS的复杂度

    • 空间复杂度:O(n × 2^m)

    由于m通常较小(m ≤ 12),这个算法在实际中是可行的。

    5. 样例过程

    以2×3棋盘为例:

    1. 初始状态:dp[0][000] = 1

    2. 处理第0行:

      • 生成所有可能的放置方式

      • 更新第1行的状态

    3. 处理第1行:

      • 生成所有可能的放置方式

      • 更新第2行的状态

    4. 最终结果:dp[2][000] = 3

    六、状压DP的优化技巧

    1. 滚动数组:当状态转移只依赖前一层时,可以压缩空间

    2. 预处理合法状态:提前计算并存储可能的转移状态

    3. 剪枝:跳过不可能达到的状态

    4. 双端BFS:对于某些对称问题,可以从两端同时进行状态转移

    七、常见问题类型

    1. 排列问题:如TSP、任务调度

    2. 覆盖问题:如棋盘覆盖、铺砖

    3. 子集问题:如子集和、集合划分

    4. 图论问题:如哈密尔顿路径、最小顶点覆盖

    八、练习建议

    1. 从简单问题开始,如力扣的状压DP题目

    2. 手动模拟小规模案例,理解状态转移过程

    3. 尝试将常规DP问题改写为状压DP版本

    4. 分析时间复杂度和空间复杂度

    九、练习链接

    1. LeetCode 464. 我能赢吗  

    2. LeetCode 691. 贴纸拼词  

    3. LeetCode 1349. 参加考试的最大学生数

    4. POJ 2411. Mondriaan's Dream

    (如果大家觉得这些题目难度较高可以在评论区留言,如果小伙伴较多的话煮啵会再出个题解)

    十、总结

    状压DP是一种强大的算法技巧,通过本文的学习,你应该已经掌握了(真的吗?):

    • 状压DP的基本概念和适用场景

    • 如何使用位运算表示和操作状态

    • 经典问题的状压DP解法

    • 状压DP的实现模式和优化方法

    记住,掌握状压DP需要大量练习。开始时可能会觉得难以理解状态表示,但随着练习的增加,你会逐渐培养出对状态设计的直觉。

    希望这篇教程对你有所帮助!如果有任何问题,欢迎在评论区留言讨论

    你可能感兴趣的:(动态规划,算法,状压dp,c++)