|
1 | 1 | # 动态规划之子序列问题解题模板
|
2 | 2 |
|
3 | | -<p align='center'> |
4 | | -<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> |
5 | | -<a href="https://labuladong.online/algo/" target="_blank"><img class="my_header_icon" src="https://img.shields.io/static/v1?label=精品课程&message=查看&color=pink&style=flat"></a> |
6 | | -<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> |
7 | | -<a href="https://space.bilibili.com/14089380"><img src="https://img.shields.io/badge/B站-@labuladong-000000.svg?style=flat-square&logo=Bilibili"></a> |
8 | | -</p> |
| 3 | + |
9 | 4 |
|
10 | 5 | 
|
11 | 6 |
|
12 | | -**通知:[新版网站会员](https://labuladong.online/algo/intro/site-vip/) 即将涨价;已支持老用户续费~另外,建议你在我的 [网站](https://labuladong.online/algo/) 学习文章,体验更好。** |
| 7 | +**通知:为满足广大读者的需求,网站上架 [速成目录](https://labuladong.online/algo/intro/quick-learning-plan/),如有需要可以看下,谢谢大家的支持~另外,建议你在我的 [网站](https://labuladong.online/algo/) 学习文章,体验更好。** |
13 | 8 |
|
14 | 9 |
|
15 | 10 |
|
16 | 11 | 读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
|
17 | 12 |
|
18 | 13 | | LeetCode | 力扣 | 难度 |
|
19 | 14 | | :----: | :----: | :----: |
|
20 | | -| [1312. Minimum Insertion Steps to Make a String Palindrome](https://leetcode.com/problems/minimum-insertion-steps-to-make-a-string-palindrome/) | [1312. 让字符串成为回文串的最少插入次数](https://leetcode.cn/problems/minimum-insertion-steps-to-make-a-string-palindrome/) | 🔴 |
21 | | -| [516. Longest Palindromic Subsequence](https://leetcode.com/problems/longest-palindromic-subsequence/) | [516. 最长回文子序列](https://leetcode.cn/problems/longest-palindromic-subsequence/) | 🟠 |
| 15 | +| [1312. Minimum Insertion Steps to Make a String Palindrome](https://leetcode.com/problems/minimum-insertion-steps-to-make-a-string-palindrome/) | [1312. 让字符串成为回文串的最少插入次数](https://leetcode.cn/problems/minimum-insertion-steps-to-make-a-string-palindrome/) | 🔴| |
| 16 | +| [516. Longest Palindromic Subsequence](https://leetcode.com/problems/longest-palindromic-subsequence/) | [516. 最长回文子序列](https://leetcode.cn/problems/longest-palindromic-subsequence/) | 🟠| |
22 | 17 |
|
23 | 18 | **-----------**
|
24 | 19 |
|
| 20 | + |
| 21 | + |
| 22 | +> [!NOTE] |
| 23 | +> 阅读本文前,你需要先学习: |
| 24 | +> |
| 25 | +> - [动态规划核心框架](https://labuladong.online/algo/essential-technique/dynamic-programming-framework/) |
| 26 | + |
25 | 27 | 子序列问题是常见的算法问题,而且并不好解决。
|
26 | 28 |
|
27 | 29 | 首先,子序列问题本身就相对子串、子数组更困难一些,因为前者是不连续的序列,而后两者是连续的,就算穷举你都不一定会,更别说求解相关的算法问题了。
|
|
34 | 36 |
|
35 | 37 | 既然要用动态规划,那就要定义 `dp` 数组,找状态转移关系。我们说的两种思路模板,就是 `dp` 数组的定义思路。不同的问题可能需要不同的 `dp` 数组定义来解决。
|
36 | 38 |
|
37 | | -### 一、两种思路 |
| 39 | +## 一、两种思路 |
38 | 40 |
|
39 | 41 |
|
40 | 42 |
|
41 | | -<hr> |
42 | | -<details class="hint-container details"> |
43 | | -<summary><strong>引用本文的文章</strong></summary> |
44 | 43 |
|
45 | | - - [动态规划设计:最长递增子序列](https://labuladong.online/algo/dynamic-programming/longest-increasing-subsequence/) |
46 | | - - [如何判断回文链表](https://labuladong.online/algo/data-structure/palindrome-linked-list/) |
47 | | - - [对动态规划进行降维打击](https://labuladong.online/algo/dynamic-programming/space-optimization/) |
48 | | - - [最优子结构原理和 dp 数组遍历方向](https://labuladong.online/algo/dynamic-programming/faq-summary/) |
49 | | - - [经典动态规划:最长公共子序列](https://labuladong.online/algo/dynamic-programming/longest-common-subsequence/) |
50 | 44 |
|
51 | | -</details><hr> |
52 | 45 |
|
53 | 46 |
|
| 47 | +**1、第一种思路模板是一个一维的 `dp` 数组**: |
54 | 48 |
|
| 49 | +```java |
| 50 | +int n = array.length; |
| 51 | +int[] dp = new int[n]; |
55 | 52 |
|
| 53 | +for (int i = 1; i < n; i++) { |
| 54 | + for (int j = 0; j < i; j++) { |
| 55 | + dp[i] = 最值(dp[i], dp[j] + ...) |
| 56 | + } |
| 57 | +} |
| 58 | +``` |
56 | 59 |
|
57 | | -**_____________** |
| 60 | +比如我们写过的 [最长递增子序列](https://labuladong.online/algo/dynamic-programming/longest-increasing-subsequence/) 和 [最大子数组和](https://labuladong.online/algo/dynamic-programming/maximum-subarray/) 都是这个思路。 |
58 | 61 |
|
59 | | -本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/dynamic-programming/subsequence-problem/) 查看: |
| 62 | +在这个思路中 `dp` 数组的定义是: |
60 | 63 |
|
61 | | - |
| 64 | +**在子数组 `arr[0..i]` 中,以 `arr[i]` 结尾的子序列的长度是 `dp[i]`**。 |
62 | 65 |
|
63 | | -======其他语言代码====== |
| 66 | +为啥最长递增子序列需要这种思路呢?前文说得很清楚了,因为这样符合归纳法,可以找到状态转移的关系,这里就不具体展开了。 |
64 | 67 |
|
65 | | -[516.最长回文子序列](https://leetcode-cn.com/problems/longest-palindromic-subsequence) |
| 68 | +**2、第二种思路模板是一个二维的 `dp` 数组**: |
66 | 69 |
|
67 | | -### javascript |
| 70 | +```java |
| 71 | +int n = arr.length; |
| 72 | +int[][] dp = new dp[n][n]; |
68 | 73 |
|
69 | | -```js |
70 | | -/** |
71 | | - * @param {string} s |
72 | | - * @return {number} |
73 | | - */ |
74 | | -var longestPalindromeSubseq = function (s) { |
75 | | - let l = s.length; |
76 | | - if (l <= 1) { |
77 | | - return l; |
| 74 | +for (int i = 0; i < n; i++) { |
| 75 | + for (int j = 0; j < n; j++) { |
| 76 | + if (arr[i] == arr[j]) |
| 77 | + dp[i][j] = dp[i][j] + ... |
| 78 | + else |
| 79 | + dp[i][j] = 最值(...) |
78 | 80 | }
|
79 | | - |
80 | | - // 初始化一个 dp[l][l] |
81 | | - let dp = new Array(l); |
82 | | - for (let i = 0; i < l; i++) { |
83 | | - dp[i] = new Array(l); |
84 | | - dp[i].fill(0, 0, l) |
85 | | - // // base case |
86 | | - dp[i][i] = 1 |
| 81 | +} |
| 82 | +``` |
| 83 | + |
| 84 | +这种思路运用相对更多一些,尤其是涉及两个字符串/数组的子序列时,比如前文讲的 [最长公共子序列](https://labuladong.online/algo/dynamic-programming/longest-common-subsequence/) 和 [编辑距离](https://labuladong.online/algo/dynamic-programming/edit-distance/);这种思路也可以用于只涉及一个字符串/数组的情景,比如本文讲的回文子序列问题。 |
| 85 | + |
| 86 | +**2.1 涉及两个字符串/数组的场景**,`dp` 数组的定义如下: |
| 87 | + |
| 88 | +**在子数组 `arr1[0..i]` 和子数组 `arr2[0..j]` 中,我们要求的子序列长度为 `dp[i][j]`**。 |
| 89 | + |
| 90 | +**2.2 只涉及一个字符串/数组的场景**,`dp` 数组的定义如下: |
| 91 | + |
| 92 | +**在子数组 `array[i..j]` 中,我们要求的子序列的长度为 `dp[i][j]`**。 |
| 93 | + |
| 94 | +下面就看看最长回文子序列问题,详解一下第二种情况下如何使用动态规划。 |
| 95 | + |
| 96 | +## 二、最长回文子序列 |
| 97 | + |
| 98 | +之前解决了 [最长回文子串](https://labuladong.online/algo/essential-technique/array-two-pointers-summary/) 的问题,这次提升难度,看看力扣第 516 题「最长回文子序列」,求最长回文子序列的长度: |
| 99 | + |
| 100 | +输入一个字符串 `s`,请你找出 `s` 中的最长回文子序列长度,函数签名如下: |
| 101 | + |
| 102 | +```java |
| 103 | +int longestPalindromeSubseq(String s); |
| 104 | +``` |
| 105 | + |
| 106 | +比如说输入 `s = "aecda"`,算法返回 3,因为最长回文子序列是 `"aca"`,长度为 3。 |
| 107 | + |
| 108 | +我们对 `dp` 数组的定义是:**在子串 `s[i..j]` 中,最长回文子序列的长度为 `dp[i][j]`**。一定要记住这个定义才能理解算法。 |
| 109 | + |
| 110 | +为啥这个问题要这样定义二维的 `dp` 数组呢?我在 [最长递增子序列](https://labuladong.online/algo/dynamic-programming/longest-increasing-subsequence/) 提到,找状态转移需要归纳思维,说白了就是如何从已知的结果推出未知的部分。而这样定义能够进行归纳,容易发现状态转移关系。 |
| 111 | + |
| 112 | +具体来说,如果我们想求 `dp[i][j]`,假设你知道了子问题 `dp[i+1][j-1]` 的结果(`s[i+1..j-1]` 中最长回文子序列的长度),你是否能想办法算出 `dp[i][j]` 的值(`s[i..j]` 中,最长回文子序列的长度)呢? |
| 113 | + |
| 114 | + |
| 115 | + |
| 116 | +可以!这取决于 `s[i]` 和 `s[j]` 的字符: |
| 117 | + |
| 118 | +**如果它俩相等**,那么它俩加上 `s[i+1..j-1]` 中的最长回文子序列就是 `s[i..j]` 的最长回文子序列: |
| 119 | + |
| 120 | + |
| 121 | + |
| 122 | +**如果它俩不相等**,说明它俩**不可能同时**出现在 `s[i..j]` 的最长回文子序列中,那么把它俩**分别**加入 `s[i+1..j-1]` 中,看看哪个子串产生的回文子序列更长即可: |
| 123 | + |
| 124 | + |
| 125 | + |
| 126 | +以上两种情况写成代码就是这样: |
| 127 | + |
| 128 | + |
| 129 | + |
| 130 | + |
| 131 | + |
| 132 | +```java |
| 133 | +if (s[i] == s[j]) |
| 134 | + // 它俩一定在最长回文子序列中 |
| 135 | + dp[i][j] = dp[i + 1][j - 1] + 2; |
| 136 | +else |
| 137 | + // s[i+1..j] 和 s[i..j-1] 谁的回文子序列更长? |
| 138 | + dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); |
| 139 | +``` |
| 140 | + |
| 141 | + |
| 142 | + |
| 143 | +至此,状态转移方程就写出来了,根据 dp 数组的定义,我们要求的就是 `dp[0][n - 1]`,也就是整个 `s` 的最长回文子序列的长度。 |
| 144 | + |
| 145 | +## 三、代码实现 |
| 146 | + |
| 147 | +首先明确一下 base case,如果只有一个字符,显然最长回文子序列长度是 1,也就是 `dp[i][j] = 1 (i == j)`。 |
| 148 | + |
| 149 | +因为 `i` 肯定小于等于 `j`,所以对于那些 `i > j` 的位置,根本不存在什么子序列,应该初始化为 0。 |
| 150 | + |
| 151 | +另外,看看刚才写的状态转移方程,想求 `dp[i][j]` 需要知道 `dp[i+1][j-1]`,`dp[i+1][j]`,`dp[i][j-1]` 这三个位置;再看看我们确定的 base case,填入 `dp` 数组之后是这样: |
| 152 | + |
| 153 | + |
| 154 | + |
| 155 | +**为了保证每次计算 `dp[i][j]`,左下右方向的位置已经被计算出来,只能斜着遍历或者反着遍历**: |
| 156 | + |
| 157 | + |
| 158 | + |
| 159 | +> [!TIP] |
| 160 | +> 关于 `dp` 数组的遍历方向,详情见 [动态规划答疑篇](https://labuladong.online/algo/dynamic-programming/faq-summary/)。 |
| 161 | + |
| 162 | +我选择反着遍历,代码如下: |
| 163 | + |
| 164 | +```java |
| 165 | +class Solution { |
| 166 | + public int longestPalindromeSubseq(String s) { |
| 167 | + int n = s.length(); |
| 168 | + // dp 数组全部初始化为 0 |
| 169 | + int[][] dp = new int[n][n]; |
| 170 | + // base case |
| 171 | + for (int i = 0; i < n; i++) { |
| 172 | + dp[i][i] = 1; |
| 173 | + } |
| 174 | + // 反着遍历保证正确的状态转移 |
| 175 | + for (int i = n - 1; i >= 0; i--) { |
| 176 | + for (int j = i + 1; j < n; j++) { |
| 177 | + // 状态转移方程 |
| 178 | + if (s.charAt(i) == s.charAt(j)) { |
| 179 | + dp[i][j] = dp[i + 1][j - 1] + 2; |
| 180 | + } else { |
| 181 | + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]); |
| 182 | + } |
| 183 | + } |
| 184 | + } |
| 185 | + // 整个 s 的最长回文子串长度 |
| 186 | + return dp[0][n - 1]; |
87 | 187 | }
|
88 | | - |
89 | | - // 从右下角开始,逐渐往上推 |
90 | | - for (let i = l - 2; i >= 0; i--) { |
91 | | - for (let j = i + 1; j <= l - 1; j++) { |
92 | | - if (s[i] === s[j]) { |
93 | | - dp[i][j] = dp[i + 1][j - 1] + 2; |
94 | | - } else { |
95 | | - dp[i][j] = Math.max( |
96 | | - dp[i + 1][j], |
97 | | - dp[i][j - 1] |
98 | | - ) |
| 188 | +} |
| 189 | +``` |
| 190 | + |
| 191 | +<visual slug='longest-palindromic-subsequence'/> |
| 192 | + |
| 193 | + |
| 194 | + |
| 195 | +至此,最长回文子序列的问题就解决了。 |
| 196 | + |
| 197 | +## 四、拓展延伸 |
| 198 | + |
| 199 | +虽然回文相关的问题没有什么特别广泛的使用场景,但是你会算最长回文子序列之后,一些类似的题目也可以顺手做掉。 |
| 200 | + |
| 201 | +比如力扣第 1312 题「计算让字符串成为回文串的最少插入次数」: |
| 202 | + |
| 203 | +输入一个字符串 `s`,你可以在字符串的任意位置插入任意字符。如果要把 `s` 变成回文串,请你计算最少要进行多少次插入? |
| 204 | + |
| 205 | +函数签名如下: |
| 206 | + |
| 207 | +```java |
| 208 | +int minInsertions(String s); |
| 209 | +``` |
| 210 | + |
| 211 | +比如说输入 `s = "abcea"`,算法返回 2,因为可以给 `s` 插入 2 个字符变成回文串 `"abeceba"` 或者 `"aebcbea"`。如果输入 `s = "aba"`,则算法返回 0,因为 `s` 已经是回文串,不用插入任何字符。 |
| 212 | + |
| 213 | +这也是一道单字符串的子序列问题,所以我们也可以使用一个二维 `dp` 数组,其中 `dp[i][j]` 的定义如下: |
| 214 | + |
| 215 | +**对字符串 `s[i..j]`,最少需要进行 `dp[i][j]` 次插入才能变成回文串**。 |
| 216 | + |
| 217 | +根据 `dp` 数组的定义,base case 就是 `dp[i][i] = 0`,因为单个字符本身就是回文串,不需要插入。 |
| 218 | + |
| 219 | +然后使用数学归纳法,假设已经计算出了子问题 `dp[i+1][j-1]` 的值了,思考如何推出 `dp[i][j]` 的值: |
| 220 | + |
| 221 | + |
| 222 | + |
| 223 | +实际上和最长回文子序列问题的状态转移方程非常类似,这里也分两种情况: |
| 224 | + |
| 225 | + |
| 226 | + |
| 227 | + |
| 228 | + |
| 229 | +```java |
| 230 | +if (s[i] == s[j]) { |
| 231 | + // 不需要插入任何字符 |
| 232 | + dp[i][j] = dp[i + 1][j - 1]; |
| 233 | +} else { |
| 234 | + // 把 s[i+1..j] 和 s[i..j-1] 变成回文串,选插入次数较少的 |
| 235 | + // 然后还要再插入一个 s[i] 或 s[j],使 s[i..j] 配成回文串 |
| 236 | + dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1; |
| 237 | +} |
| 238 | +``` |
| 239 | + |
| 240 | + |
| 241 | + |
| 242 | +最后,我们依然采取倒着遍历 `dp` 数组的方式,写出代码: |
| 243 | + |
| 244 | +```java |
| 245 | +class Solution { |
| 246 | + public int minInsertions(String s) { |
| 247 | + int n = s.length(); |
| 248 | + // dp[i][j] 表示把字符串 s[i..j] 变成回文串的最少插入次数 |
| 249 | + // dp 数组全部初始化为 0 |
| 250 | + int[][] dp = new int[n][n]; |
| 251 | + // 反着遍历保证正确的状态转移 |
| 252 | + for (int i = n - 1; i >= 0; i--) { |
| 253 | + for (int j = i + 1; j < n; j++) { |
| 254 | + // 状态转移方程 |
| 255 | + if (s.charAt(i) == s.charAt(j)) { |
| 256 | + dp[i][j] = dp[i + 1][j - 1]; |
| 257 | + } else { |
| 258 | + dp[i][j] = Math.min(dp[i + 1][j], dp[i][j - 1]) + 1; |
| 259 | + } |
99 | 260 | }
|
100 | 261 | }
|
| 262 | + // 整个 s 的最少插入次数 |
| 263 | + return dp[0][n - 1]; |
101 | 264 | }
|
102 | | - return dp[0][l - 1] |
103 | | -}; |
| 265 | +} |
104 | 266 | ```
|
105 | 267 |
|
| 268 | +至此,这道题也使用子序列解题模板解决了,整体逻辑和最长回文子序列非常相似,那么这个问题是否可以直接复用回文子序列的解法呢? |
| 269 | + |
| 270 | +其实是可以的,我们甚至都不用写状态转移方程,你仔细想想: |
| 271 | + |
| 272 | +**我先算出字符串 `s` 中的最长回文子序列,那些不在最长回文子序列中的字符,不就是需要插入的字符吗**? |
| 273 | + |
| 274 | +所以这道题可以直接复用之前实现的 `longestPalindromeSubseq` 函数: |
| 275 | + |
| 276 | +```java |
| 277 | +class Solution { |
| 278 | + // 计算把 s 变成回文串的最少插入次数 |
| 279 | + public int minInsertions(String s) { |
| 280 | + return s.length() - longestPalindromeSubseq(s); |
| 281 | + } |
| 282 | + |
| 283 | + // 计算 s 中的最长回文子序列长度 |
| 284 | + int longestPalindromeSubseq(String s) { |
| 285 | + // 见上文 |
| 286 | + } |
| 287 | +} |
| 288 | +``` |
| 289 | + |
| 290 | +好了,子序列相关的算法就讲到这里,希望对你有启发。 |
| 291 | + |
| 292 | + |
| 293 | + |
| 294 | + |
| 295 | + |
| 296 | + |
| 297 | + |
| 298 | +<hr> |
| 299 | +<details class="hint-container details"> |
| 300 | +<summary><strong>引用本文的文章</strong></summary> |
| 301 | + |
| 302 | + - [动态规划设计:最长递增子序列](https://labuladong.online/algo/dynamic-programming/longest-increasing-subsequence/) |
| 303 | + - [对动态规划进行降维打击](https://labuladong.online/algo/dynamic-programming/space-optimization/) |
| 304 | + - [最优子结构原理和 dp 数组遍历方向](https://labuladong.online/algo/dynamic-programming/faq-summary/) |
| 305 | + - [经典动态规划:最长公共子序列](https://labuladong.online/algo/dynamic-programming/longest-common-subsequence/) |
| 306 | + |
| 307 | +</details><hr> |
| 308 | + |
| 309 | + |
| 310 | + |
| 311 | + |
| 312 | + |
| 313 | +**_____________** |
| 314 | + |
| 315 | + |
| 316 | + |
| 317 | + |
0 commit comments