状态压缩技巧.md 13.4 KB
Newer Older
L
labuladong 已提交
1 2 3
---
title: '对动态规划发动降维打击'
---
L
labuladong 已提交
4 5 6 7 8 9 10 11

<p align='center'>
<a href="https://github.com/labuladong/fucking-algorithm" target="view_window"><img alt="GitHub" src="https://img.shields.io/github/stars/labuladong/fucking-algorithm?label=Stars&style=flat-square&logo=GitHub"></a>
<a href="https://appktavsiei5995.pc.xiaoe-tech.com/index" target="_blank"><img class="my_header_icon" src="https://img.shields.io/static/v1?label=精品课程&message=查看&color=pink&style=flat"></a>
<a href="https://www.zhihu.com/people/labuladong"><img src="https://img.shields.io/badge/%E7%9F%A5%E4%B9%8E-@labuladong-000000.svg?style=flat-square&logo=Zhihu"></a>
<a href="https://space.bilibili.com/14089380"><img src="https://img.shields.io/badge/B站-@labuladong-000000.svg?style=flat-square&logo=Bilibili"></a>
</p>

L
labuladong 已提交
12
![](https://labuladong.gitee.io/pictures/souyisou1.png)
L
labuladong 已提交
13

L
labuladong 已提交
14
**通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V2.1,[手把手刷二叉树系列课程](https://aep.xet.tech/s/3YGcq3) 上线。[第 18 期每日打卡](https://aep.xet.tech/s/2PLO1n) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
L
labuladong 已提交
15 16 17 18 19



**-----------**

L
labuladong 已提交
20 21
写在最前面:状态压缩并不难,可以理解为一种投机取巧的办法去优化某些动态规划问题的空间复杂度。我个人认为状态压缩并不是必须掌握的技巧,如果你对这个技巧感兴趣,务必阅读并理解 [动态规划系列答疑篇](https://labuladong.github.io/article/fname.html?fname=最优子结构)

L
labuladong 已提交
22 23 24 25
我们号之前写过十几篇动态规划文章,可以说动态规划技巧对于算法效率的提升非常可观,一般来说都能把指数级和阶乘级时间复杂度的算法优化成 O(N^2),堪称算法界的二向箔,把各路魑魅魍魉统统打成二次元。

但是,动态规划求解的过程中也是可以进行阶段性优化的,如果你认真观察某些动态规划问题的状态转移方程,就能够把它们解法的空间复杂度进一步降低,由 O(N^2) 降低到 O(N)。

L
labuladong 已提交
26
> note:之前我在本文中误用了「状态压缩」这个词,有读者指出「状态压缩」这个词的含义是把多个状态通过二进制运算用一个整数表示出来,从而减少 `dp` 数组的维度。而本文描述的优化方式是通过观察状态转移方程的依赖关系,从而减少 `dp` 数组的维度,确实和「状态压缩」有所区别。所以严谨起见,我把原来文章中的「状态压缩」都改为了「空间压缩」,避免名词的误用。
L
labuladong 已提交
27 28 29 30 31

能够使用空间压缩技巧的动态规划都是二维 `dp` 问题,**你看它的状态转移方程,如果计算状态 `dp[i][j]` 需要的都是 `dp[i][j]` 相邻的状态,那么就可以使用空间压缩技巧**,将二维的 `dp` 数组转化成一维,将空间复杂度从 O(N^2) 降低到 O(N)。

什么叫「和 `dp[i][j]` 相邻的状态」呢,比如前文 [最长回文子序列](https://labuladong.github.io/article/fname.html?fname=子序列问题模板) 中,最终的代码如下:

L
labuladong 已提交
32
<!-- muliti_language -->
L
labuladong 已提交
33 34 35
```cpp
int longestPalindromeSubseq(string s) {
    int n = s.size();
L
labuladong 已提交
36
    // nxn 的 dp 数组全部初始化为 0
L
labuladong 已提交
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
    vector<vector<int>> dp(n, vector<int>(n, 0));
    // base case
    for (int i = 0; i < n; i++)
        dp[i][i] = 1;
    // 反着遍历保证正确的状态转移
    for (int i = n - 2; i >= 0; i--) {
        for (int j = i + 1; j < n; j++) {
            // 状态转移方程
            if (s[i] == s[j])
                dp[i][j] = dp[i + 1][j - 1] + 2;
            else
                dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
        }
    }
    // 整个 s 的最长回文子串长度
    return dp[0][n - 1];
}
```

L
labuladong 已提交
56
> tip:我们本文不探讨如何推状态转移方程,只探讨对二维 DP 问题进行空间压缩的技巧。技巧都是通用的,所以如果你没看过前文,不明白这段代码的逻辑也无妨,完全不会阻碍你学会空间压缩。
L
labuladong 已提交
57 58 59

你看我们对 `dp[i][j]` 的更新,其实只依赖于 `dp[i+1][j-1], dp[i][j-1], dp[i+1][j]` 这三个状态:

L
labuladong 已提交
60
![](https://labuladong.gitee.io/pictures/状态压缩/1.jpeg)
L
labuladong 已提交
61 62 63

这就叫和 `dp[i][j]` 相邻,反正你计算 `dp[i][j]` 只需要这三个相邻状态,其实根本不需要那么大一个二维的 dp table 对不对?**空间压缩的核心思路就是,将二维数组「投影」到一维数组**

L
labuladong 已提交
64
![](https://labuladong.gitee.io/pictures/状态压缩/2.jpeg)
L
labuladong 已提交
65

L
labuladong 已提交
66 67 68
「投影」这个词应该比较形象吧,说白了就是希望让一维数组发挥原来二维数组的作用。

思路很直观,但是也有一个明显的问题,图中 `dp[i][j-1]``dp[i+1][j-1]` 这两个状态处在同一列,而一维数组中只能容下一个,那么他俩投影到一维必然有一个会被另一个覆盖掉,我还怎么计算 `dp[i][j]` 呢?
L
labuladong 已提交
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83

这就是空间压缩的难点,下面就来分析解决这个问题,还是拿「最长回文子序列」问题举例,它的状态转移方程主要逻辑就是如下这段代码:

```cpp
for (int i = n - 2; i >= 0; i--) {
    for (int j = i + 1; j < n; j++) {
        // 状态转移方程
        if (s[i] == s[j])
            dp[i][j] = dp[i + 1][j - 1] + 2;
        else
            dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
    }
}
```

L
labuladong 已提交
84
回想上面的图,「投影」其实就是把多行变成一行,所以想把二维 `dp` 数组压缩成一维,一般来说是把第一个维度,也就是 `i` 这个维度去掉,只剩下 `j` 这个维度。**压缩后的一维 `dp` 数组就是之前二维 `dp` 数组的 `dp[i][..]` 那一行**
L
labuladong 已提交
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128

我们先将上述代码进行改造,直接无脑去掉 `i` 这个维度,把 `dp` 数组变成一维:

```cpp
for (int i = n - 2; i >= 0; i--) {
    for (int j = i + 1; j < n; j++) {
        // 在这里,一维 dp 数组中的数是什么?
        if (s[i] == s[j])
            dp[j] = dp[j - 1] + 2;
        else
            dp[j] = max(dp[j], dp[j - 1]);
    }
}
```

上述代码的一维 `dp` 数组只能表示二维 `dp` 数组的一行 `dp[i][..]`,那我怎么才能得到 `dp[i+1][j-1], dp[i][j-1], dp[i+1][j]` 这几个必要的的值,进行状态转移呢?

在代码中注释的位置,将要进行状态转移,更新 `dp[j]`,那么我们要来思考两个问题:

1、在对 `dp[j]` 赋新值之前,`dp[j]` 对应着二维 `dp` 数组中的什么位置?

2、`dp[j-1]` 对应着二维 `dp` 数组中的什么位置?

**对于问题 1,在对 `dp[j]` 赋新值之前,`dp[j]` 的值就是外层 for 循环上一次迭代算出来的值,也就是对应二维 `dp` 数组中 `dp[i+1][j]` 的位置**

**对于问题 2,`dp[j-1]` 的值就是内层 for 循环上一次迭代算出来的值,也就是对应二维 `dp` 数组中 `dp[i][j-1]` 的位置**

那么问题已经解决了一大半了,只剩下二维 `dp` 数组中的 `dp[i+1][j-1]` 这个状态我们不能直接从一维 `dp` 数组中得到:

```cpp
for (int i = n - 2; i >= 0; i--) {
    for (int j = i + 1; j < n; j++) {
        if (s[i] == s[j])
            // dp[i][j] = dp[i+1][j-1] + 2;
            dp[j] = ?? + 2;
        else
            // dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
            dp[j] = max(dp[j], dp[j - 1]);
    }
}
```

因为 for 循环遍历 `i``j` 的顺序为从左向右,从下向上,所以可以发现,在更新一维 `dp` 数组的时候,`dp[i+1][j-1]` 会被 `dp[i][j-1]` 覆盖掉,图中标出了这四个位置被遍历到的次序:

L
labuladong 已提交
129
![](https://labuladong.gitee.io/pictures/状态压缩/3.jpeg)
L
labuladong 已提交
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154

**那么如果我们想得到 `dp[i+1][j-1]`,就必须在它被覆盖之前用一个临时变量 `temp` 把它存起来,并把这个变量的值保留到计算 `dp[i][j]` 的时候**。为了达到这个目的,结合上图,我们可以这样写代码:

```cpp
for (int i = n - 2; i >= 0; i--) {
    // 存储 dp[i+1][j-1] 的变量
    int pre = 0;
    for (int j = i + 1; j < n; j++) {
        int temp = dp[j];
        if (s[i] == s[j])
            // dp[i][j] = dp[i+1][j-1] + 2;
            dp[j] = pre + 2;
        else
            dp[j] = max(dp[j], dp[j - 1]);
        // 到下一轮循环,pre 就是 dp[i+1][j-1] 了
        pre = temp;
    }
}
```

别小看这段代码,这是一维 `dp` 最精妙的地方,会者不难,难者不会。为了清晰起见,我用具体的数值来拆解这个逻辑:

假设现在 `i = 5, j = 7``s[5] == s[7]`,那么现在会进入下面这个逻辑对吧:

```cpp
L
labuladong 已提交
155 156 157 158 159 160 161
for (int i = 5; i--) {
    for (int j = 7; j++) {
        if (s[5] == s[7])
            // dp[5][7] = dp[i+1][j-1] + 2;
            dp[7] = pre + 2;
        }
}
L
labuladong 已提交
162 163 164 165
```

我问你这个 `pre` 变量是什么?是内层 for 循环上一次迭代的 `temp` 值。

L
labuladong 已提交
166 167 168
那我再问你**内层** for 循环上一次迭代的 `temp` 值是什么?是 `dp[j-1]` 也就是 `dp[6]`,但请注意,这是**外层** for 循环**上一次迭代**对应的 `dp[6]`,不是现在的 `dp[6]`

这个要对应二维数组的索引来理解。你现在的 `dp[6]` 是二维 `dp` 数组中的 `dp[i][6] = dp[5][6]`,而人家这个 `temp` 是二维 `dp` 数组中的 `dp[i+1][6] = dp[6][6]`
L
labuladong 已提交
169 170 171 172 173

也就是说,`pre` 变量就是 `dp[i+1][j-1] = dp[6][6]`,也就是我们想要的结果。

那么现在我们成功对状态转移方程进行了降维打击,算是最硬的的骨头啃掉了,但注意到我们还有 base case 要处理呀:

L
labuladong 已提交
174
<!-- muliti_language -->
L
labuladong 已提交
175 176 177 178 179 180 181 182 183 184
```cpp
// dp 数组全部初始化为 0
vector<vector<int>> dp(n, vector<int>(n, 0));
// base case
for (int i = 0; i < n; i++)
    dp[i][i] = 1;
```

如何把 base case 也打成一维呢?很简单,记住空间压缩就是投影,我们把 base case 投影到一维看看:

L
labuladong 已提交
185
![](https://labuladong.gitee.io/pictures/状态压缩/4.jpeg)
L
labuladong 已提交
186 187 188

二维 `dp` 数组中的 base case 全都落入了一维 `dp` 数组,不存在冲突和覆盖,所以说我们直接这样写代码就行了:

L
labuladong 已提交
189
<!-- muliti_language -->
L
labuladong 已提交
190 191 192 193 194 195 196
```cpp
// 一维 dp 数组全部初始化为 1
vector<int> dp(n, 1);
```

至此,我们把 base case 和状态转移方程都进行了降维,实际上已经写出完整代码了:

L
labuladong 已提交
197
<!-- muliti_language -->
L
labuladong 已提交
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235
```cpp
int longestPalindromeSubseq(string s) {
    int n = s.size();
    // base case:一维 dp 数组全部初始化为 0
    vector<int> dp(n, 1);

    for (int i = n - 2; i >= 0; i--) {
        int pre = 0;
        for (int j = i + 1; j < n; j++) {
            int temp = dp[j];
            // 状态转移方程
            if (s[i] == s[j])
                dp[j] = pre + 2;
            else
                dp[j] = max(dp[j], dp[j - 1]);
            pre = temp;
        }
    }
    return dp[n - 1];
}
```

本文就结束了,不过空间压缩技巧再牛逼,也是基于常规动态规划思路之上的。

你也看到了,使用空间压缩技巧对二维 `dp` 数组进行降维打击之后,解法代码的可读性变得非常差了,如果直接看这种解法,任何人都是一脸懵逼的。算法的优化就是这么一个过程,先写出可读性很好的暴力递归算法,然后尝试运用动态规划技巧优化重叠子问题,最后尝试用空间压缩技巧优化空间复杂度。

也就是说,你最起码能够熟练运用我们前文 [动态规划框架套路详解](https://labuladong.github.io/article/fname.html?fname=动态规划详解进阶) 的套路找出状态转移方程,写出一个正确的动态规划解法,然后才有可能观察状态转移的情况,分析是否可能使用空间压缩技巧来优化。

希望读者能够稳扎稳打,层层递进,对于这种比较极限的优化,不做也罢。毕竟套路存于心,走遍天下都不怕!



<hr>
<details>
<summary><strong>引用本文的文章</strong></summary>

 - [一个方法团灭 LeetCode 股票买卖问题](https://labuladong.github.io/article/fname.html?fname=团灭股票问题)
 - [动态规划之最小路径和](https://labuladong.github.io/article/fname.html?fname=最小路径和)
L
labuladong 已提交
236
 - [动态规划解题套路框架](https://labuladong.github.io/article/fname.html?fname=动态规划详解进阶)
L
labuladong 已提交
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264
 - [动态规划设计:最大子数组](https://labuladong.github.io/article/fname.html?fname=最大子数组)
 - [我的刷题心得](https://labuladong.github.io/article/fname.html?fname=算法心得)
 - [经典动态规划:子集背包问题](https://labuladong.github.io/article/fname.html?fname=背包子集)
 - [经典动态规划:完全背包问题](https://labuladong.github.io/article/fname.html?fname=背包零钱)
 - [经典动态规划:最长公共子序列](https://labuladong.github.io/article/fname.html?fname=LCS)
 - [经典动态规划:高楼扔鸡蛋](https://labuladong.github.io/article/fname.html?fname=高楼扔鸡蛋问题)

</details><hr>




<hr>
<details>
<summary><strong>引用本文的题目</strong></summary>

<strong>安装 [我的 Chrome 刷题插件](https://mp.weixin.qq.com/s/X-fE9sR4BLi6T9pn7xP4pg) 点开下列题目可直接查看解题思路:</strong>

| LeetCode | 力扣 |
| :----: | :----: |
| [63. Unique Paths II](https://leetcode.com/problems/unique-paths-ii/?show=1) | [63. 不同路径 II](https://leetcode.cn/problems/unique-paths-ii/?show=1) |

</details>



**_____________**

L
labuladong 已提交
265
**《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「**进群**」可加入算法群;回复「**全家桶**」可下载配套 PDF 和刷题全家桶**
L
labuladong 已提交
266

L
labuladong 已提交
267
![](https://labuladong.gitee.io/pictures/souyisou2.png)