提交 68d6053b 编写于 作者: L labuladong

同步网站新增功能及文章

上级 c8fb3709
此差异已折叠。
# 详解最长公共子序列问题,秒杀三道动态规划题目
<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>
![](https://labuladong.github.io/algo/images/souyisou1.png)
**通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
| LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [1143. Longest Common Subsequence](https://leetcode.com/problems/longest-common-subsequence/) | [1143. 最长公共子序列](https://leetcode.cn/problems/longest-common-subsequence/) | 🟠
| [583. Delete Operation for Two Strings](https://leetcode.com/problems/delete-operation-for-two-strings/) | [583. 两个字符串的删除操作](https://leetcode.cn/problems/delete-operation-for-two-strings/) | 🟠
| [712. Minimum ASCII Delete Sum for Two Strings](https://leetcode.com/problems/minimum-ascii-delete-sum-for-two-strings/) | [712. 两个字符串的最小ASCII删除和](https://leetcode.cn/problems/minimum-ascii-delete-sum-for-two-strings/) | 🟠
| - | [剑指 Offer II 095. 最长公共子序列](https://leetcode.cn/problems/qJnOS7/) | 🟠
**-----------**
不知道大家做算法题有什么感觉,**我总结出来做算法题的技巧就是,把大的问题细化到一个点,先研究在这个小的点上如何解决问题,然后再通过递归/迭代的方式扩展到整个问题**
比如说我们前文 [手把手带你刷二叉树第三期](https://labuladong.github.io/article/fname.html?fname=二叉树系列3),解决二叉树的题目,我们就会把整个问题细化到某一个节点上,想象自己站在某个节点上,需要做什么,然后套二叉树递归框架就行了。
动态规划系列问题也是一样,尤其是子序列相关的问题。**本文从「最长公共子序列问题」展开,总结三道子序列问题**,解这道题仔细讲讲这种子序列问题的套路,你就能感受到这种思维方式了。
### 最长公共子序列
计算最长公共子序列(Longest Common Subsequence,简称 LCS)是一道经典的动态规划题目,力扣第 1143 题「最长公共子序列」就是这个问题:
给你输入两个字符串 `s1``s2`,请你找出他们俩的最长公共子序列,返回这个子序列的长度。函数签名如下:
```java
int longestCommonSubsequence(String s1, String s2);
```
比如说输入 `s1 = "zabcde", s2 = "acez"`,它俩的最长公共子序列是 `lcs = "ace"`,长度为 3,所以算法返回 3。
如果没有做过这道题,一个最简单的暴力算法就是,把 `s1``s2` 的所有子序列都穷举出来,然后看看有没有公共的,然后在所有公共子序列里面再寻找一个长度最大的。
显然,这种思路的复杂度非常高,你要穷举出所有子序列,这个复杂度就是指数级的,肯定不实际。
正确的思路是不要考虑整个字符串,而是细化到 `s1``s2` 的每个字符。前文 [子序列解题模板](https://labuladong.github.io/article/fname.html?fname=子序列问题模板) 中总结的一个规律:
<hr>
<details>
<summary><strong>引用本文的文章</strong></summary>
- [动态规划之子序列问题解题模板](https://labuladong.github.io/article/fname.html?fname=子序列问题模板)
- [经典动态规划:编辑距离](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 | 力扣 |
| :----: | :----: |
| [97. Interleaving String](https://leetcode.com/problems/interleaving-string/?show=1) | [97. 交错字符串](https://leetcode.cn/problems/interleaving-string/?show=1) |
| - | [剑指 Offer II 095. 最长公共子序列](https://leetcode.cn/problems/qJnOS7/?show=1) |
</details>
**_____________**
应合作方要求,本文不便在此发布,请扫码关注回复关键词「LCS」或 [点这里](https://appktavsiei5995.pc.xiaoe-tech.com/detail/i_6298793ae4b09dda12708be8/1) 查看:
![](https://labuladong.github.io/algo/images/qrcode.jpg)
\ No newline at end of file
# 动态规划之KMP字符匹配算法 # 动态规划之KMP字符匹配算法
<p align='center'> <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://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://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://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></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> <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> </p>
![](../pictures/souyisou.png) ![](https://labuladong.github.io/algo/images/souyisou1.png)
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~ **通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
[28.实现 strStr()](https://leetcode-cn.com/problems/implement-strstr)
读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
| LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [28. Implement strStr()](https://leetcode.com/problems/implement-strstr/) | [28. 实现 strStr()](https://leetcode.cn/problems/implement-strstr/) | 🟢
**-----------** **-----------**
> 阅读本文之前,建议你先学习一下另一种字符串匹配算法:[Rabin Karp 字符匹配算法](https://labuladong.github.io/article/fname.html?fname=rabinkarp)。
KMP 算法(Knuth-Morris-Pratt 算法)是一个著名的字符串匹配算法,效率很高,但是确实有点复杂。 KMP 算法(Knuth-Morris-Pratt 算法)是一个著名的字符串匹配算法,效率很高,但是确实有点复杂。
很多读者抱怨 KMP 算法无法理解,这很正常,想到大学教材上关于 KMP 算法的讲解,也不知道有多少未来的 Knuth、Morris、Pratt 被提前劝退了。有一些优秀的同学通过手推 KMP 算法的过程来辅助理解该算法,这是一种办法,不过本文要从逻辑层面帮助读者理解算法的原理。十行代码之间,KMP 灰飞烟灭。 很多读者抱怨 KMP 算法无法理解,这很正常,想到大学教材上关于 KMP 算法的讲解,也不知道有多少未来的 Knuth、Morris、Pratt 被提前劝退了。有一些优秀的同学通过手推 KMP 算法的过程来辅助理解该算法,这是一种办法,不过本文要从逻辑层面帮助读者理解算法的原理。十行代码之间,KMP 灰飞烟灭。
...@@ -28,13 +33,13 @@ KMP 算法(Knuth-Morris-Pratt 算法)是一个著名的字符串匹配算法 ...@@ -28,13 +33,13 @@ KMP 算法(Knuth-Morris-Pratt 算法)是一个著名的字符串匹配算法
读者见过的 KMP 算法应该是,一波诡异的操作处理 `pat` 后形成一个一维的数组 `next`,然后根据这个数组经过又一波复杂操作去匹配 `txt`。时间复杂度 O(N),空间复杂度 O(M)。其实它这个 `next` 数组就相当于 `dp` 数组,其中元素的含义跟 `pat` 的前缀和后缀有关,判定规则比较复杂,不好理解。**本文则用一个二维的 `dp` 数组(但空间复杂度还是 O(M)),重新定义其中元素的含义,使得代码长度大大减少,可解释性大大提高** 读者见过的 KMP 算法应该是,一波诡异的操作处理 `pat` 后形成一个一维的数组 `next`,然后根据这个数组经过又一波复杂操作去匹配 `txt`。时间复杂度 O(N),空间复杂度 O(M)。其实它这个 `next` 数组就相当于 `dp` 数组,其中元素的含义跟 `pat` 的前缀和后缀有关,判定规则比较复杂,不好理解。**本文则用一个二维的 `dp` 数组(但空间复杂度还是 O(M)),重新定义其中元素的含义,使得代码长度大大减少,可解释性大大提高**
PS:本文的代码参考《算法4》,原代码使用的数组名称是 `dfa`(确定有限状态机),因为我们的公众号之前有一系列动态规划的文章,就不说这么高大上的名词了,我对书中代码进行了一点修改,并沿用 `dp` 数组的名称。 > PS:本文的代码参考《算法4》,原代码使用的数组名称是 `dfa`(确定有限状态机),因为我们的公众号之前有一系列动态规划的文章,就不说这么高大上的名词了,我对书中代码进行了一点修改,并沿用 `dp` 数组的名称。
### 一、KMP 算法概述 ### 一、KMP 算法概述
首先还是简单介绍一下 KMP 算法和暴力匹配算法的不同在哪里,难点在哪里,和动态规划有啥关系。 首先还是简单介绍一下 KMP 算法和暴力匹配算法的不同在哪里,难点在哪里,和动态规划有啥关系。
暴力的字符串匹配算法很容易写,看一下它的运行逻辑: 力扣第 28 题「实现 strStr」就是字符串匹配问题,暴力的字符串匹配算法很容易写,看一下它的运行逻辑:
```java ```java
// 暴力匹配(伪码) // 暴力匹配(伪码)
...@@ -57,19 +62,19 @@ int search(String pat, String txt) { ...@@ -57,19 +62,19 @@ int search(String pat, String txt) {
对于暴力算法,如果出现不匹配字符,同时回退 `txt``pat` 的指针,嵌套 for 循环,时间复杂度 `O(MN)`,空间复杂度`O(1)`。最主要的问题是,如果字符串中重复的字符比较多,该算法就显得很蠢。 对于暴力算法,如果出现不匹配字符,同时回退 `txt``pat` 的指针,嵌套 for 循环,时间复杂度 `O(MN)`,空间复杂度`O(1)`。最主要的问题是,如果字符串中重复的字符比较多,该算法就显得很蠢。
比如 txt = "aaacaaab" pat = "aaab" 比如 `txt = "aaacaaab", pat = "aaab"`
![brutal](../pictures/kmp/1.gif) ![](https://labuladong.github.io/algo/images/kmp/1.gif)
很明显,`pat` 中根本没有字符 c,根本没必要回退指针 `i`,暴力解法明显多做了很多不必要的操作。 很明显,`pat` 中根本没有字符 c,根本没必要回退指针 `i`,暴力解法明显多做了很多不必要的操作。
KMP 算法的不同之处在于,它会花费空间来记录一些信息,在上述情况中就会显得很聪明: KMP 算法的不同之处在于,它会花费空间来记录一些信息,在上述情况中就会显得很聪明:
![kmp1](../pictures/kmp/2.gif) ![](https://labuladong.github.io/algo/images/kmp/2.gif)
再比如类似的 txt = "aaaaaaab" pat = "aaab",暴力解法还会和上面那个例子一样蠢蠢地回退指针 `i`,而 KMP 算法又会耍聪明: 再比如类似的 `txt = "aaaaaaab", pat = "aaab"`,暴力解法还会和上面那个例子一样蠢蠢地回退指针 `i`,而 KMP 算法又会耍聪明:
![kmp2](../pictures/kmp/3.gif) ![](https://labuladong.github.io/algo/images/kmp/3.gif)
因为 KMP 算法知道字符 b 之前的字符 a 都是匹配的,所以每次只需要比较字符 b 是否被匹配就行了。 因为 KMP 算法知道字符 b 之前的字符 a 都是匹配的,所以每次只需要比较字符 b 是否被匹配就行了。
...@@ -92,21 +97,21 @@ pat = "aaab" ...@@ -92,21 +97,21 @@ pat = "aaab"
只不过对于 `txt1` 的下面这个即将出现的未匹配情况: 只不过对于 `txt1` 的下面这个即将出现的未匹配情况:
![](../pictures/kmp/txt1.jpg) ![](https://labuladong.github.io/algo/images/kmp/txt1.jpg)
`dp` 数组指示 `pat` 这样移动: `dp` 数组指示 `pat` 这样移动:
![](../pictures/kmp/txt2.jpg) ![](https://labuladong.github.io/algo/images/kmp/txt2.jpg)
PS:这个`j` 不要理解为索引,它的含义更准确地说应该是**状态**(state),所以它会出现这个奇怪的位置,后文会详述。 > PS:这个`j` 不要理解为索引,它的含义更准确地说应该是**状态**(state),所以它会出现这个奇怪的位置,后文会详述。
而对于 `txt2` 的下面这个即将出现的未匹配情况: 而对于 `txt2` 的下面这个即将出现的未匹配情况:
![](../pictures/kmp/txt3.jpg) ![](https://labuladong.github.io/algo/images/kmp/txt3.jpg)
`dp` 数组指示 `pat` 这样移动: `dp` 数组指示 `pat` 这样移动:
![](../pictures/kmp/txt4.jpg) ![](https://labuladong.github.io/algo/images/kmp/txt4.jpg)
明白了 `dp` 数组只和 `pat` 有关,那么我们这样设计 KMP 算法就会比较漂亮: 明白了 `dp` 数组只和 `pat` 有关,那么我们这样设计 KMP 算法就会比较漂亮:
...@@ -140,46 +145,45 @@ int pos2 = kmp.search("aaaaaaab"); //4 ...@@ -140,46 +145,45 @@ int pos2 = kmp.search("aaaaaaab"); //4
为什么说 KMP 算法和状态机有关呢?是这样的,我们可以认为 `pat` 的匹配就是状态的转移。比如当 pat = "ABABC": 为什么说 KMP 算法和状态机有关呢?是这样的,我们可以认为 `pat` 的匹配就是状态的转移。比如当 pat = "ABABC":
![](../pictures/kmp/state.jpg) ![](https://labuladong.github.io/algo/images/kmp/state.jpg)
如上图,圆圈内的数字就是状态,状态 0 是起始状态,状态 5(`pat.length`)是终止状态。开始匹配时 `pat` 处于起始状态,一旦转移到终止状态,就说明在 `txt` 中找到了 `pat`。比如说当前处于状态 2,就说明字符 "AB" 被匹配: 如上图,圆圈内的数字就是状态,状态 0 是起始状态,状态 5(`pat.length`)是终止状态。开始匹配时 `pat` 处于起始状态,一旦转移到终止状态,就说明在 `txt` 中找到了 `pat`。比如说当前处于状态 2,就说明字符 "AB" 被匹配:
![](../pictures/kmp/state2.jpg) ![](https://labuladong.github.io/algo/images/kmp/state2.jpg)
另外,处于不同状态时,`pat` 状态转移的行为也不同。比如说假设现在匹配到了状态 4,如果遇到字符 A 就应该转移到状态 3,遇到字符 C 就应该转移到状态 5,如果遇到字符 B 就应该转移到状态 0: 另外,处于不同状态时,`pat` 状态转移的行为也不同。比如说假设现在匹配到了状态 4,如果遇到字符 A 就应该转移到状态 3,遇到字符 C 就应该转移到状态 5,如果遇到字符 B 就应该转移到状态 0:
![](../pictures/kmp/state4.jpg) ![](https://labuladong.github.io/algo/images/kmp/state4.jpg)
具体什么意思呢,我们来一个个举例看看。用变量 `j` 表示指向当前状态的指针,当前 `pat` 匹配到了状态 4: 具体什么意思呢,我们来一个个举例看看。用变量 `j` 表示指向当前状态的指针,当前 `pat` 匹配到了状态 4:
![](../pictures/kmp/exp1.jpg) ![](https://labuladong.github.io/algo/images/kmp/exp1.jpg)
如果遇到了字符 "A",根据箭头指示,转移到状态 3 是最聪明的: 如果遇到了字符 "A",根据箭头指示,转移到状态 3 是最聪明的:
![](../pictures/kmp/exp3.jpg) ![](https://labuladong.github.io/algo/images/kmp/exp3.jpg)
如果遇到了字符 "B",根据箭头指示,只能转移到状态 0(一夜回到解放前): 如果遇到了字符 "B",根据箭头指示,只能转移到状态 0(一夜回到解放前):
![](../pictures/kmp/exp5.jpg) ![](https://labuladong.github.io/algo/images/kmp/exp5.jpg)
如果遇到了字符 "C",根据箭头指示,应该转移到终止状态 5,这也就意味着匹配完成: 如果遇到了字符 "C",根据箭头指示,应该转移到终止状态 5,这也就意味着匹配完成:
![](../pictures/kmp/exp7.jpg) ![](https://labuladong.github.io/algo/images/kmp/exp7.jpg)
当然了,还可能遇到其他字符,比如 Z,但是显然应该转移到起始状态 0,因为 `pat` 中根本都没有字符 Z: 当然了,还可能遇到其他字符,比如 Z,但是显然应该转移到起始状态 0,因为 `pat` 中根本都没有字符 Z:
![](../pictures/kmp/z.jpg) ![](https://labuladong.github.io/algo/images/kmp/z.jpg)
这里为了清晰起见,我们画状态图时就把其他字符转移到状态 0 的箭头省略,只画 `pat` 中出现的字符的状态转移: 这里为了清晰起见,我们画状态图时就把其他字符转移到状态 0 的箭头省略,只画 `pat` 中出现的字符的状态转移:
![](../pictures/kmp/allstate.jpg) ![](https://labuladong.github.io/algo/images/kmp/allstate.jpg)
KMP 算法最关键的步骤就是构造这个状态转移图。**要确定状态转移的行为,得明确两个变量,一个是当前的匹配状态,另一个是遇到的字符**;确定了这两个变量后,就可以知道这个情况下应该转移到哪个状态。 KMP 算法最关键的步骤就是构造这个状态转移图。**要确定状态转移的行为,得明确两个变量,一个是当前的匹配状态,另一个是遇到的字符**;确定了这两个变量后,就可以知道这个情况下应该转移到哪个状态。
下面看一下 KMP 算法根据这幅状态转移图匹配字符串 `txt` 的过程: 下面看一下 KMP 算法根据这幅状态转移图匹配字符串 `txt` 的过程:
![](../pictures/kmp/kmp.gif) ![](https://labuladong.github.io/algo/images/kmp/kmp.gif)
**请记住这个 GIF 的匹配过程,这就是 KMP 算法的核心逻辑** **请记住这个 GIF 的匹配过程,这就是 KMP 算法的核心逻辑**
...@@ -234,29 +238,29 @@ for 0 <= j < M: # 状态 ...@@ -234,29 +238,29 @@ for 0 <= j < M: # 状态
这个 next 状态应该怎么求呢?显然,**如果遇到的字符 `c` 和 `pat[j]` 匹配的话**,状态就应该向前推进一个,也就是说 `next = j + 1`,我们不妨称这种情况为**状态推进** 这个 next 状态应该怎么求呢?显然,**如果遇到的字符 `c` 和 `pat[j]` 匹配的话**,状态就应该向前推进一个,也就是说 `next = j + 1`,我们不妨称这种情况为**状态推进**
![](../pictures/kmp/forward.jpg) ![](https://labuladong.github.io/algo/images/kmp/forward.jpg)
**如果字符 `c` 和 `pat[j]` 不匹配的话**,状态就要回退(或者原地不动),我们不妨称这种情况为**状态重启** **如果字符 `c` 和 `pat[j]` 不匹配的话**,状态就要回退(或者原地不动),我们不妨称这种情况为**状态重启**
![](../pictures/kmp/back.jpg) ![](https://labuladong.github.io/algo/images/kmp/back.jpg)
那么,如何得知在哪个状态重启呢?解答这个问题之前,我们再定义一个名字:**影子状态**(我编的名字),用变量 `X` 表示。**所谓影子状态,就是和当前状态具有相同的前缀**。比如下面这种情况: 那么,如何得知在哪个状态重启呢?解答这个问题之前,我们再定义一个名字:**影子状态**(我编的名字),用变量 `X` 表示。**所谓影子状态,就是和当前状态具有相同的前缀**。比如下面这种情况:
![](../pictures/kmp/shadow.jpg) ![](https://labuladong.github.io/algo/images/kmp/shadow.jpg)
当前状态 `j = 4`,其影子状态为 `X = 2`,它们都有相同的前缀 "AB"。因为状态 `X` 和状态 `j` 存在相同的前缀,所以当状态 `j` 准备进行状态重启的时候(遇到的字符 `c``pat[j]` 不匹配),可以通过 `X` 的状态转移图来获得**最近的重启位置** 当前状态 `j = 4`,其影子状态为 `X = 2`,它们都有相同的前缀 "AB"。因为状态 `X` 和状态 `j` 存在相同的前缀,所以当状态 `j` 准备进行状态重启的时候(遇到的字符 `c``pat[j]` 不匹配),可以通过 `X` 的状态转移图来获得**最近的重启位置**
比如说刚才的情况,如果状态 `j` 遇到一个字符 "A",应该转移到哪里呢?首先只有遇到 "C" 才能推进状态,遇到 "A" 显然只能进行状态重启。**状态 `j` 会把这个字符委托给状态 `X` 处理,也就是 `dp[j]['A'] = dp[X]['A']`** 比如说刚才的情况,如果状态 `j` 遇到一个字符 "A",应该转移到哪里呢?首先只有遇到 "C" 才能推进状态,遇到 "A" 显然只能进行状态重启。**状态 `j` 会把这个字符委托给状态 `X` 处理,也就是 `dp[j]['A'] = dp[X]['A']`**
![](../pictures/kmp/shadow1.jpg) ![](https://labuladong.github.io/algo/images/kmp/shadow1.jpg)
为什么这样可以呢?因为:既然 `j` 这边已经确定字符 "A" 无法推进状态,**只能回退**,而且 KMP 就是要**尽可能少的回退**,以免多余的计算。那么 `j` 就可以去问问和自己具有相同前缀的 `X`,如果 `X` 遇见 "A" 可以进行「状态推进」,那就转移过去,因为这样回退最少。 为什么这样可以呢?因为:既然 `j` 这边已经确定字符 "A" 无法推进状态,**只能回退**,而且 KMP 就是要**尽可能少的回退**,以免多余的计算。那么 `j` 就可以去问问和自己具有相同前缀的 `X`,如果 `X` 遇见 "A" 可以进行「状态推进」,那就转移过去,因为这样回退最少。
![](../pictures/kmp/A.gif) ![](https://labuladong.github.io/algo/images/kmp/A.gif)
当然,如果遇到的字符是 "B",状态 `X` 也不能进行「状态推进」,只能回退,`j` 只要跟着 `X` 指引的方向回退就行了: 当然,如果遇到的字符是 "B",状态 `X` 也不能进行「状态推进」,只能回退,`j` 只要跟着 `X` 指引的方向回退就行了:
![](../pictures/kmp/shadow2.jpg) ![](https://labuladong.github.io/algo/images/kmp/shadow2.jpg)
你也许会问,这个 `X` 怎么知道遇到字符 "B" 要回退到状态 0 呢?因为 `X` 永远跟在 `j` 的身后,状态 `X` 如何转移,在之前就已经算出来了。动态规划算法不就是利用过去的结果解决现在的问题吗? 你也许会问,这个 `X` 怎么知道遇到字符 "B" 要回退到状态 0 呢?因为 `X` 永远跟在 `j` 的身后,状态 `X` 如何转移,在之前就已经算出来了。动态规划算法不就是利用过去的结果解决现在的问题吗?
...@@ -350,7 +354,7 @@ for (int i = 0; i < N; i++) { ...@@ -350,7 +354,7 @@ for (int i = 0; i < N; i++) {
下面来看一下状态转移图的完整构造过程,你就能理解状态 `X` 作用之精妙了: 下面来看一下状态转移图的完整构造过程,你就能理解状态 `X` 作用之精妙了:
![](../pictures/kmp/dfa.gif) ![](https://labuladong.github.io/algo/images/kmp/dfa.gif)
至此,KMP 算法的核心终于写完啦啦啦啦!看下 KMP 算法的完整代码吧: 至此,KMP 算法的核心终于写完啦啦啦啦!看下 KMP 算法的完整代码吧:
...@@ -419,15 +423,26 @@ KMP 算法也就是动态规划那点事,我们的公众号文章目录有一 ...@@ -419,15 +423,26 @@ KMP 算法也就是动态规划那点事,我们的公众号文章目录有一
<hr>
<details>
<summary><strong>引用本文的文章</strong></summary>
- [我的刷题心得](https://labuladong.github.io/article/fname.html?fname=算法心得)
- [滑动窗口算法延伸:Rabin Karp 字符匹配算法](https://labuladong.github.io/article/fname.html?fname=rabinkarp)
</details><hr>
**_____________** **_____________**
**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章** **《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「进群」可加入算法群;回复「PDF」可获取精华文章 PDF**
![](https://labuladong.github.io/algo/images/souyisou2.png)
**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode**
<p align='center'>
<img src="../pictures/qrcode.jpg" width=200 >
</p>
======其他语言代码====== ======其他语言代码======
[28.实现 strStr()](https://leetcode-cn.com/problems/implement-strstr) [28.实现 strStr()](https://leetcode-cn.com/problems/implement-strstr)
......
# 动态规划之博弈问题 # 动态规划之博弈问题
<p align='center'> <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://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://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://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></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> <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> </p>
![](../pictures/souyisou.png) ![](https://labuladong.github.io/algo/images/souyisou1.png)
**通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目: 读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
[877.石子游戏](https://leetcode-cn.com/problems/stone-game) | LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [486. Predict the Winner](https://leetcode.com/problems/predict-the-winner/) | [486. 预测赢家](https://leetcode.cn/problems/predict-the-winner/) | 🟠
| [877. Stone Game](https://leetcode.com/problems/stone-game/) | [877. 石子游戏](https://leetcode.cn/problems/stone-game/) | 🟠
**-----------** **-----------**
上一篇文章 [几道智力题](https://labuladong.gitee.io/algo/) 中讨论到一个有趣的「石头游戏」,通过题目的限制条件,这个游戏是先手必胜的。但是智力题终究是智力题,真正的算法问题肯定不会是投机取巧能搞定的。所以,本文就借石头游戏来讲讲「假设两个人都足够聪明,最后谁会获胜」这一类问题该如何用动态规划算法解决。 上一篇文章 [几道智力题](https://labuladong.github.io/article/fname.html?fname=一行代码解决的智力题) 中讨论到一个有趣的「石头游戏」,通过题目的限制条件,这个游戏是先手必胜的。但是智力题终究是智力题,真正的算法问题肯定不会是投机取巧能搞定的。所以,本文就借石头游戏来讲讲「假设两个人都足够聪明,最后谁会获胜」这一类问题该如何用动态规划算法解决。
博弈类问题的套路都差不多,下文参考 [这个 YouTube 视频](https://www.youtube.com/watch?v=WxpIHvsu1RI) 的思路讲解,其核心思路是在二维 dp 的基础上使用元组分别存储两个人的博弈结果。掌握了这个技巧以后,别人再问你什么俩海盗分宝石,俩人拿硬币的问题,你就告诉别人:我懒得想,直接给你写个算法算一下得了。 博弈类问题的套路都差不多,下文参考 [这个 YouTube 视频](https://www.youtube.com/watch?v=WxpIHvsu1RI) 的思路讲解,其核心思路是在二维 dp 的基础上使用元组分别存储两个人的博弈结果。掌握了这个技巧以后,别人再问你什么俩海盗分宝石,俩人拿硬币的问题,你就告诉别人:我懒得想,直接给你写个算法算一下得了。
我们「石头游戏」改的更具有一般性: 我们把力扣第 877 题「石头游戏」改的更具有一般性:
你和你的朋友面前有一排石头堆,用一个数组 piles 表示,piles[i] 表示第 i 堆石子有多少个。你们轮流拿石头,一次拿一堆,但是只能拿走最左边或者最右边的石头堆。所有石头被拿完后,谁拥有的石头多,谁获胜。 你和你的朋友面前有一排石头堆,用一个数组 `piles` 表示,`piles[i]` 表示第 `i` 堆石子有多少个。你们轮流拿石头,一次拿一堆,但是只能拿走最左边或者最右边的石头堆。所有石头被拿完后,谁拥有的石头多,谁获胜。
石头的堆数可以是任意正整数,石头的总数也可以是任意正整数,这样就能打破先手必胜的局面了。比如有三堆石头 `piles = [1, 100, 3]`,先手不管拿 1 还是 3,能够决定胜负的 100 都会被后手拿走,后手会获胜。 石头的堆数可以是任意正整数,石头的总数也可以是任意正整数,这样就能打破先手必胜的局面了。比如有三堆石头 `piles = [1, 100, 3]`,先手不管拿 1 还是 3,能够决定胜负的 100 都会被后手拿走,后手会获胜。
**假设两人都很聪明**,请你设计一个算法,返回先手和后手的最后得分(石头总数)之差。比如上面那个例子,先手能获得 4 分,后手会获得 100 分,你的算法应该返回 -96。 **假设两人都很聪明**,请你写一个 `stoneGame` 函数,返回先手和后手的最后得分(石头总数)之差。比如上面那个例子,先手能获得 4 分,后手会获得 100 分,你的算法应该返回 -96。
这样推广之后,这个问题算是一道 Hard 的动态规划问题了。**博弈问题的难点在于,两个人要轮流进行选择,而且都贼精明,应该如何编程表示这个过程呢?** 这样推广之后就变成了一道难度比较高的动态规划问题了,力扣第 486 题「预测赢家」就是一道类似的问题:
还是强调多次的套路,首先明确 dp 数组的含义,然后和股票买卖系列问题类似,只要找到「状态」和「选择」,一切就水到渠成了。 ![](https://labuladong.github.io/algo/images/博弈问题/title.jpg)
### 一、定义 dp 数组的含义 函数签名如下:
定义 dp 数组的含义是很有技术含量的,同一问题可能有多种定义方法,不同的定义会引出不同的状态转移方程,不过只要逻辑没有问题,最终都能得到相同的答案。 ```java
boolean PredictTheWinner(int[] nums);
```
那么如果有了一个计算先手和后手分差的 `stoneGame` 函数,这道题的解法就直接出来了:
```java
public boolean PredictTheWinner(int[] nums) {
// 先手的分数大于等于后手,则能赢
return stoneGame(nums) >= 0;
}
```
这个 `stoneGame` 函数怎么写呢?博弈问题的难点在于,两个人要轮流进行选择,而且都贼精明,应该如何编程表示这个过程呢?其实不难,还是按照 [动态规划核心框架](https://labuladong.github.io/article/fname.html?fname=动态规划详解进阶) 中强调多次的套路,首先明确 `dp` 数组的含义,然后只要找到「状态」和「选择」,一切就水到渠成了。
### 一、定义 `dp` 数组的含义
定义 `dp` 数组的含义是很有技术含量的,同一问题可能有多种定义方法,不同的定义会引出不同的状态转移方程,不过只要逻辑没有问题,最终都能得到相同的答案。
我建议不要迷恋那些看起来很牛逼,代码很短小的奇技淫巧,最好是稳一点,采取可解释性最好,最容易推广的设计思路。本文就给出一种博弈问题的通用设计框架。 我建议不要迷恋那些看起来很牛逼,代码很短小的奇技淫巧,最好是稳一点,采取可解释性最好,最容易推广的设计思路。本文就给出一种博弈问题的通用设计框架。
介绍 dp 数组的含义之前,我们先看一下 dp 数组最终的样子: 介绍 `dp` 数组的含义之前,我们先看一下 `dp` 数组最终的样子:
![1](../pictures/博弈问题/1.png) ![](https://labuladong.github.io/algo/images/博弈问题/1.png)
下文讲解时,认为元组是包含 first 和 second 属性的一个类,而且为了节省篇幅,将这两个属性简写为 fir 和 sec。比如按上图的数据,我们说 `dp[1][3].fir = 10``dp[0][1].sec = 3` 下文讲解时,认为元组是包含 `first``second` 属性的一个类,而且为了节省篇幅,将这两个属性简写为 `fir``sec`。比如按上图的数据,我们说 `dp[1][3].fir = 11``dp[0][1].sec = 2`
先回答几个读者可能提出的问题: 先回答几个读者可能提出的问题:
...@@ -52,22 +73,21 @@ ...@@ -52,22 +73,21 @@
**以下是对 dp 数组含义的解释:** **以下是对 dp 数组含义的解释:**
```python `dp[i][j].fir = x` 表示,对于 `piles[i...j]` 这部分石头堆,先手能获得的最高分数为 `x`
dp[i][j].fir 表示对于 piles[i...j] 这部分石头堆先手能获得的最高分数
dp[i][j].sec 表示对于 piles[i...j] 这部分石头堆后手能获得的最高分数
举例理解一下假设 piles = [3, 9, 1, 2]索引从 0 开始 `dp[i][j].sec = y` 表示,对于 `piles[i...j]` 这部分石头堆,后手能获得的最高分数为 `y`
dp[0][1].fir = 9 意味着面对石头堆 [3, 9]先手最终能够获得 9
dp[1][3].sec = 2 意味着面对石头堆 [9, 1, 2]后手最终能够获得 2 举例理解一下,假设 `piles = [2, 8, 3, 5]`,索引从 0 开始,那么:
```
`dp[0][1].fir = 8` 意味着:面对石头堆 `[2, 8]`,先手最多能够获得 8 分;`dp[1][3].sec = 5` 意味着:面对石头堆 `[8, 3, 5]`,后手最多能够获得 5 分。
我们想求的答案是先手和后手最终分数之差,按照这个定义也就是 `dp[0][n-1].fir - dp[0][n-1].sec`,即面对整个 piles,先手的最优得分和后手的最优得分之差。 我们想求的答案是先手和后手最终分数之差,按照这个定义也就是 `dp[0][n-1].fir - dp[0][n-1].sec`,即面对整个 `piles`,先手的最优得分和后手的最优得分之差。
### 二、状态转移方程 ### 二、状态转移方程
写状态转移方程很简单,首先要找到所有「状态」和每个状态可以做的「选择」,然后择优。 写状态转移方程很简单,首先要找到所有「状态」和每个状态可以做的「选择」,然后择优。
根据前面对 dp 数组的定义,**状态显然有三个:开始的索引 i,结束的索引 j,当前轮到的人。** 根据前面对 `dp` 数组的定义,**状态显然有三个:开始的索引 `i`,结束的索引 `j`,当前轮到的人。**
```python ```python
dp[i][j][fir or sec] dp[i][j][fir or sec]
...@@ -84,16 +104,15 @@ for 0 <= i < n: ...@@ -84,16 +104,15 @@ for 0 <= i < n:
for j <= i < n: for j <= i < n:
for who in {fir, sec}: for who in {fir, sec}:
dp[i][j][who] = max(left, right) dp[i][j][who] = max(left, right)
``` ```
上面的伪码是动态规划的一个大致的框架,股票系列问题中也有类似的伪码。这道题的难点在于,两人是交替进行选择的,也就是说先手的选择会对后手有影响,这怎么表达出来呢? 上面的伪码是动态规划的一个大致的框架,这道题的难点在于,两人足够聪明,而且是交替进行选择的,也就是说先手的选择会对后手有影响,这怎么表达出来呢?
根据我们对 dp 数组的定义,很容易解决这个难点,**写出状态转移方程:** 根据我们对 `dp` 数组的定义,很容易解决这个难点,**写出状态转移方程:**
```python ```python
dp[i][j].fir = max(piles[i] + dp[i+1][j].sec, piles[j] + dp[i][j-1].sec) dp[i][j].fir = max(piles[i] + dp[i+1][j].sec, piles[j] + dp[i][j-1].sec)
dp[i][j].fir = max( 选择最左边的石头堆 , 选择最右边的石头堆 ) dp[i][j].fir = max( 选择最左边的石头堆 , 选择最右边的石头堆 )
# 解释:我作为先手,面对 piles[i...j] 时,有两种选择: # 解释:我作为先手,面对 piles[i...j] 时,有两种选择:
# 要么我选择最左边的那一堆石头,然后面对 piles[i+1...j] # 要么我选择最左边的那一堆石头,然后面对 piles[i+1...j]
# 但是此时轮到对方,相当于我变成了后手; # 但是此时轮到对方,相当于我变成了后手;
...@@ -122,18 +141,23 @@ dp[i][j].sec = 0 ...@@ -122,18 +141,23 @@ dp[i][j].sec = 0
# 后手没有石头拿了,得分为 0 # 后手没有石头拿了,得分为 0
``` ```
![2](../pictures/博弈问题/2.png) ![](https://labuladong.github.io/algo/images/博弈问题/2.png)
这里需要注意一点,我们发现 base case 是斜着的,而且我们推算 dp[i][j] 时需要用到 dp[i+1][j] 和 dp[i][j-1]
![3](../pictures/博弈问题/3.png) 这里需要注意一点,我们发现 base case 是斜着的,而且我们推算 `dp[i][j]` 时需要用到 `dp[i+1][j]``dp[i][j-1]`
所以说算法不能简单的一行一行遍历 dp 数组,**而要斜着遍历数组:** ![](https://labuladong.github.io/algo/images/博弈问题/3.png)
![4](../pictures/博弈问题/4.png) 根据前文 [动态规划答疑篇](https://labuladong.github.io/article/fname.html?fname=最优子结构) 判断 `dp` 数组遍历方向的原则,算法应该倒着遍历 `dp` 数组:
说实话,斜着遍历二维数组说起来容易,你还真不一定能想出来怎么实现,不信你思考一下?这么巧妙的状态转移方程都列出来了,要是不会写代码实现,那真的很尴尬了。 ```java
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
dp[i][j] = ...
}
}
```
![](https://labuladong.github.io/algo/images/博弈问题/4.png)
### 三、代码实现 ### 三、代码实现
...@@ -149,7 +173,7 @@ class Pair { ...@@ -149,7 +173,7 @@ class Pair {
} }
``` ```
然后直接把我们的状态转移方程翻译成代码即可,可以注意一下斜着遍历数组的技巧 然后直接把我们的状态转移方程翻译成代码即可,注意我们要倒着遍历数组
```java ```java
/* 返回游戏最后先手和后手的得分之差 */ /* 返回游戏最后先手和后手的得分之差 */
...@@ -165,14 +189,15 @@ int stoneGame(int[] piles) { ...@@ -165,14 +189,15 @@ int stoneGame(int[] piles) {
dp[i][i].fir = piles[i]; dp[i][i].fir = piles[i];
dp[i][i].sec = 0; dp[i][i].sec = 0;
} }
// 斜着遍历数组
for (int l = 2; l <= n; l++) { // 倒着遍历数组
for (int i = 0; i <= n - l; i++) { for (int i = n - 2; i >= 0; i--) {
int j = l + i - 1; for (int j = i + 1; j < n; j++) {
// 先手选择最左边或最右边的分数 // 先手选择最左边或最右边的分数
int left = piles[i] + dp[i+1][j].sec; int left = piles[i] + dp[i+1][j].sec;
int right = piles[j] + dp[i][j-1].sec; int right = piles[j] + dp[i][j-1].sec;
// 套用状态转移方程 // 套用状态转移方程
// 先手肯定会选择更大的结果,后手的选择随之改变
if (left > right) { if (left > right) {
dp[i][j].fir = left; dp[i][j].fir = left;
dp[i][j].sec = dp[i+1][j].fir; dp[i][j].sec = dp[i+1][j].fir;
...@@ -189,29 +214,37 @@ int stoneGame(int[] piles) { ...@@ -189,29 +214,37 @@ int stoneGame(int[] piles) {
动态规划解法,如果没有状态转移方程指导,绝对是一头雾水,但是根据前面的详细解释,读者应该可以清晰理解这一大段代码的含义。 动态规划解法,如果没有状态转移方程指导,绝对是一头雾水,但是根据前面的详细解释,读者应该可以清晰理解这一大段代码的含义。
而且,注意到计算 `dp[i][j]` 只依赖其左边和下边的元素,所以说肯定有优化空间,转换成一维 dp,想象一下把二维平面压扁,也就是投影到一维。但是,一维 dp 比较复杂,可解释性很差,大家就不必浪费这个时间去理解了。 而且,注意到计算 `dp[i][j]` 只依赖其左边和下边的元素,所以说肯定有优化空间,转换成一维 dp,想象一下把二维平面压扁,也就是投影到一维。但是,一维 `dp` 比较复杂,可解释性比较差,大家就不必浪费这个时间去理解了。
### 四、最后总结 ### 四、最后总结
本文给出了解决博弈问题的动态规划解法。博弈问题的前提一般都是在两个聪明人之间进行,编程描述这种游戏的一般方法是二维 dp 数组,数组中通过元组分别表示两人的最优决策。 本文给出了解决博弈问题的动态规划解法。博弈问题的前提一般都是在两个聪明人之间进行,编程描述这种游戏的一般方法是二维 dp 数组,数组中通过元组分别表示两人的最优决策。
之所以这样设计,是因为先手在做出选择之后,就成了后手,后手在对方做完选择后,就变成了先手。这种角色转换使得我们可以重用之前的结果,典型的动态规划标志。 之所以这样设计,是因为先手在做出选择之后,就成了后手,后手在对方做完选择后,就变成了先手。**这种角色转换使得我们可以重用之前的结果,典型的动态规划标志**
读到这里的朋友应该能理解算法解决博弈问题的套路了。学习算法,一定要注重算法的模板框架,而不是一些看起来牛逼的思路,也不要奢求上来就写一个最优的解法。不要舍不得多用空间,不要过早尝试优化,不要惧怕多维数组。`dp` 数组就是存储信息避免重复计算的,随便用,直到咱满意为止。
<hr>
<details>
<summary><strong>引用本文的文章</strong></summary>
- [贪心算法之区间调度问题](https://labuladong.github.io/article/fname.html?fname=贪心算法之区间调度问题)
</details><hr>
读到这里的朋友应该能理解算法解决博弈问题的套路了。学习算法,一定要注重算法的模板框架,而不是一些看起来牛逼的思路,也不要奢求上来就写一个最优的解法。不要舍不得多用空间,不要过早尝试优化,不要惧怕多维数组。dp 数组就是存储信息避免重复计算的,随便用,直到咱满意为止。
希望本文对你有帮助。
**_____________** **_____________**
**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章** **《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「进群」可加入算法群;回复「PDF」可获取精华文章 PDF**
![](https://labuladong.github.io/algo/images/souyisou2.png)
**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode**
<p align='center'>
<img src="../pictures/qrcode.jpg" width=200 >
</p>
======其他语言代码====== ======其他语言代码======
### python ### python
......
# 动态规划之四键键盘 # 动态规划之四键键盘
<p align='center'> <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://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://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://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></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> <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> </p>
![](../pictures/souyisou.png) ![](https://labuladong.github.io/algo/images/souyisou1.png)
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~ **通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
[651.四键键盘](https://leetcode-cn.com/problems/4-keys-keyboard)
**-----------** 读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
| LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [651. 4 Keys Keyboard](https://leetcode.com/problems/4-keys-keyboard/)🔒 | [651. 4键键盘](https://leetcode.cn/problems/4-keys-keyboard/)🔒 | 🟠
PS:现在这到题好想变成会员题目了?我当时做的时候还是免费的。 **-----------**
四键键盘问题很有意思,而且可以明显感受到:对 dp 数组的不同定义需要完全不同的逻辑,从而产生完全不同的解法。 力扣第 651 题「四键键盘」很有意思,而且可以明显感受到:对 `dp` 数组的不同定义需要完全不同的逻辑,从而产生完全不同的解法。
首先看一下题目: 首先看一下题目:
![](../pictures/4keyboard/title.png) 假设你有一个特殊的键盘,上面只有四个键,它们分别是:
1、`A` 键:在屏幕上打印一个 `A`
2、`Ctrl-A` 键:选中整个屏幕。
3、`Ctrl-C` 键:复制选中的区域到缓冲区。
4、`Ctrl-V` 键:将缓冲区的内容输入到光标所在的屏幕上。
这就和我们平时使用的全选复制粘贴功能完全相同嘛,只不过题目把 `Ctrl` 的组合键视为了一个键。现在要求你只能进行 `N` 次操作,请你计算屏幕上最多能显示多少个 `A`
函数签名如下:
```java
int maxA(int N);
```
比如说输入 `N = 3`,算法返回 3,因为连按 3 次 `A` 键是最优的方案。
如何在 N 次敲击按钮后得到最多的 A?我们穷举呗,每次有对于每次按键,我们可以穷举四种可能,很明显就是一个动态规划问题。 如果输入是 `N = 7`,则算法返回 9,最优的操作序列如下:
`A`, `A`, `A`, `Ctrl-A`, `Ctrl-C`, `Ctrl-V`, `Ctrl-V`
可以得到 9 个 `A`
如何在 `N` 次敲击按钮后得到最多的 `A`?我们穷举呗,每次有对于每次按键,我们可以穷举四种可能,很明显就是一个动态规划问题。
### 第一种思路 ### 第一种思路
...@@ -107,7 +132,7 @@ dp[n][a_num][copy] ...@@ -107,7 +132,7 @@ dp[n][a_num][copy]
# 状态的总数(时空复杂度)就是这个三维数组的体积 # 状态的总数(时空复杂度)就是这个三维数组的体积
``` ```
我们知道变量 `n` 最多为 `N`,但是 `a_num``copy` 最多为多少我们很难计算,复杂度起码也有 O(N^3) 。所以这个算法并不好,复杂度太高,且已经无法优化了。 我们知道变量 `n` 最多为 `N`,但是 `a_num``copy` 最多为多少我们很难计算,复杂度起码也有 O(N^3) 。所以这个算法并不好,复杂度太高,且已经无法优化了。
这也就说明,我们这样定义「状态」是不太优秀的,下面我们换一种定义 dp 的思路。 这也就说明,我们这样定义「状态」是不太优秀的,下面我们换一种定义 dp 的思路。
...@@ -165,7 +190,7 @@ public int maxA(int N) { ...@@ -165,7 +190,7 @@ public int maxA(int N) {
其中 `j` 变量减 2 是给 `C-A C-C` 留下操作数,看个图就明白了: 其中 `j` 变量减 2 是给 `C-A C-C` 留下操作数,看个图就明白了:
![](../pictures/4keyboard/1.jpg) ![](https://labuladong.github.io/algo/images/4keyboard/1.jpg)
这样,此算法就完成了,时间复杂度 O(N^2),空间复杂度 O(N),这种解法应该是比较高效的了。 这样,此算法就完成了,时间复杂度 O(N^2),空间复杂度 O(N),这种解法应该是比较高效的了。
...@@ -188,15 +213,27 @@ def dp(n, a_num, copy): ...@@ -188,15 +213,27 @@ def dp(n, a_num, copy):
根据这个事实,我们重新定义了状态,重新寻找了状态转移,从逻辑上减少了无效的子问题个数,从而提高了算法的效率。 根据这个事实,我们重新定义了状态,重新寻找了状态转移,从逻辑上减少了无效的子问题个数,从而提高了算法的效率。
<hr>
<details>
<summary><strong>引用本文的文章</strong></summary>
- [一个方法团灭 LeetCode 打家劫舍问题](https://labuladong.github.io/article/fname.html?fname=抢房子)
- [最优子结构原理和 dp 数组遍历方向](https://labuladong.github.io/article/fname.html?fname=最优子结构)
</details><hr>
**_____________** **_____________**
**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章** **《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「进群」可加入算法群;回复「PDF」可获取精华文章 PDF**
**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode** ![](https://labuladong.github.io/algo/images/souyisou2.png)
<p align='center'>
<img src="../pictures/qrcode.jpg" width=200 >
</p>
======其他语言代码====== ======其他语言代码======
......
...@@ -2,18 +2,23 @@ ...@@ -2,18 +2,23 @@
<p align='center'> <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://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://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://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></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> <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> </p>
![](../pictures/souyisou.png) ![](https://labuladong.github.io/algo/images/souyisou1.png)
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~ **通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
[10.正则表达式匹配](https://leetcode-cn.com/problems/regular-expression-matching/)
读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
| LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [10. Regular Expression Matching](https://leetcode.com/problems/regular-expression-matching/) | [10. 正则表达式匹配](https://leetcode.cn/problems/regular-expression-matching/) | 🔴
| - | [剑指 Offer 19. 正则表达式匹配](https://leetcode.cn/problems/zheng-ze-biao-da-shi-pi-pei-lcof/) | 🔴
**-----------** **-----------**
...@@ -108,190 +113,40 @@ if (s[i] == p[j] || p[j] == '.') { ...@@ -108,190 +113,40 @@ if (s[i] == p[j] || p[j] == '.') {
bool dp(string& s, int i, string& p, int j); bool dp(string& s, int i, string& p, int j);
``` ```
`dp` 函数的定义如下:
**若 `dp(s, i, p, j) = true`,则表示 `s[i..]` 可以匹配 `p[j..]`;若 `dp(s, i, p, j) = false`,则表示 `s[i..]` 无法匹配 `p[j..]`**
根据这个定义,我们想要的答案就是 `i = 0, j = 0``dp` 函数的结果,所以可以这样使用这个 `dp` 函数:
```cpp
bool isMatch(string s, string p) {
// 指针 i,j 从索引 0 开始移动
return dp(s, 0, p, 0);
```
可以根据之前的代码写出 `dp` 函数的主要逻辑:
```cpp
bool dp(string& s, int i, string& p, int j) {
if (s[i] == p[j] || p[j] == '.') {
// 匹配
if (j < p.size() - 1 && p[j + 1] == '*') {
// 1.1 通配符匹配 0 次或多次
return dp(s, i, p, j + 2)
|| dp(s, i + 1, p, j);
} else {
// 1.2 常规匹配 1 次
return dp(s, i + 1, p, j + 1);
}
} else {
// 不匹配
if (j < p.size() - 1 && p[j + 1] == '*') {
// 2.1 通配符匹配 0 次
return dp(s, i, p, j + 2);
} else {
// 2.2 无法继续匹配
return false;
}
}
}
```
**根据 `dp` 函数的定义**,这几种情况都很好解释:
1.1 通配符匹配 0 次或多次
`j` 加 2,`i` 不变,含义就是直接跳过 `p[j]` 和之后的通配符,即通配符匹配 0 次:
![](../pictures/正则/1.jpeg)
`i` 加 1,`j` 不变,含义就是 `p[j]` 匹配了 `s[i]`,但 `p[j]` 还可以继续匹配,即通配符匹配多次的情况:
![](../pictures/正则/2.jpeg)
两种情况只要有一种可以完成匹配即可,所以对上面两种情况求或运算。
1.2 常规匹配 1 次
由于这个条件分支是无 `*` 的常规匹配,那么如果 `s[i] == p[j]`,就是 `i``j` 分别加一:
![](../pictures/正则/3.jpeg)
2.1 通配符匹配 0 次
类似情况 1.1,将 `j` 加 2,`i` 不变:
![](../pictures/正则/1.jpeg)
2.2 如果没有 `*` 通配符,也无法匹配,那只能说明匹配失败了:
![](../pictures/正则/4.jpeg)
看图理解应该很容易了,现在可以思考一下 `dp` 函数的 base case:
**一个 base case 是 `j == p.size()` 时**,按照 `dp` 函数的定义,这意味着模式串 `p` 已经被匹配完了,那么应该看看文本串 `s` 匹配到哪里了,如果 `s` 也恰好被匹配完,则说明匹配成功:
```cpp
if (j == p.size()) {
return i == s.size();
}
```
**另一个 base case 是 `i == s.size()` 时**,按照 `dp` 函数的定义,这种情况意味着文本串 `s` 已经全部被匹配了,那么是不是只要简单地检查一下 `p` 是否也匹配完就行了呢?
```cpp
if (i == s.size()) {
// 这样行吗?
return j == p.size();
}
```
**这是不正确的,此时并不能根据 `j` 是否等于 `p.size()` 来判断是否完成匹配,只要 `p[j..]` 能够匹配空串,就可以算完成匹配**。比如说 `s = "a", p = "ab*c*"`,当 `i` 走到 `s` 末尾的时候,`j` 并没有走到 `p` 的末尾,但是 `p` 依然可以匹配 `s` <hr>
<details>
<summary><strong>引用本文的文章</strong></summary>
所以我们可以写出如下代码: - [最优子结构原理和 dp 数组遍历方向](https://labuladong.github.io/article/fname.html?fname=最优子结构)
- [经典动态规划:编辑距离](https://labuladong.github.io/article/fname.html?fname=编辑距离)
```cpp </details><hr>
int m = s.size(), n = p.size();
if (i == s.size()) {
// 如果能匹配空串,一定是字符和 * 成对儿出现
if ((n - j) % 2 == 1) {
return false;
}
// 检查是否为 x*y*z* 这种形式
for (; j + 1 < p.size(); j += 2) {
if (p[j + 1] != '*') {
return false;
}
}
return true;
}
```
根据以上思路,就可以写出完整的代码:
```cpp
/* 计算 p[j..] 是否匹配 s[i..] */
bool dp(string& s, int i, string& p, int j) {
int m = s.size(), n = p.size();
// base case
if (j == n) {
return i == m;
}
if (i == m) {
if ((n - j) % 2 == 1) {
return false;
}
for (; j + 1 < n; j += 2) {
if (p[j + 1] != '*') {
return false;
}
}
return true;
}
// 记录状态 (i, j),消除重叠子问题 <hr>
string key = to_string(i) + "," + to_string(j); <details>
if (memo.count(key)) return memo[key]; <summary><strong>引用本文的题目</strong></summary>
bool res = false;
if (s[i] == p[j] || p[j] == '.') {
if (j < n - 1 && p[j + 1] == '*') {
res = dp(s, i, p, j + 2)
|| dp(s, i + 1, p, j);
} else {
res = dp(s, i + 1, p, j + 1);
}
} else {
if (j < n - 1 && p[j + 1] == '*') {
res = dp(s, i, p, j + 2);
} else {
res = false;
}
}
// 将当前结果记入备忘录
memo[key] = res;
return res;
}
```
代码中用了一个哈希表 `memo` 消除重叠子问题,因为正则表达算法的递归框架如下: <strong>安装 [我的 Chrome 刷题插件](https://mp.weixin.qq.com/s/X-fE9sR4BLi6T9pn7xP4pg) 点开下列题目可直接查看解题思路:</strong>
```cpp | LeetCode | 力扣 |
bool dp(string& s, int i, string& p, int j) { | :----: | :----: |
dp(s, i, p, j + 2); // 1 | - | [剑指 Offer 19. 正则表达式匹配](https://leetcode.cn/problems/zheng-ze-biao-da-shi-pi-pei-lcof/?show=1) |
dp(s, i + 1, p, j); // 2
dp(s, i + 1, p, j + 1); // 3
}
```
那么,如果让你从 `dp(s, i, p, j)` 得到 `dp(s, i+2, p, j+2)`,至少有两条路径:`1 -> 2 -> 2``3 -> 3`,那么就说明 `(i+2, j+2)` 这个状态存在重复,这就说明存在重叠子问题。 </details>
动态规划的时间复杂度为「状态的总数」*「每次递归花费的时间」,本题中状态的总数当然就是 `i` 和 `j` 的组合,也就是 `M * N`(`M` 为 `s` 的长度,`N` 为 `p` 的长度);递归函数 `dp` 中没有循环(base case 中的不考虑,因为 base case 的触发次数有限),所以一次递归花费的时间为常数。二者相乘,总的时间复杂度为 `O(MN)`。
空间复杂度很简单,就是备忘录 `memo` 的大小,即 `O(MN)`。
**_____________** **_____________**
**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章**。 应合作方要求,本文不便在此发布,请扫码关注回复关键词「正则」或 [点这里](https://appktavsiei5995.pc.xiaoe-tech.com/detail/i_6298796ae4b01a4852072fb9/1) 查看:
**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode**。 ![](https://labuladong.github.io/algo/images/qrcode.jpg)
<p align='center'>
<img src="../pictures/qrcode.jpg" width=200 >
</p>
======其他语言代码====== ======其他语言代码======
### javascript ### javascript
......
此差异已折叠。
# 动态规划之子序列问题解题模板 # 动态规划之子序列问题解题模板
<p align='center'> <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://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://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://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></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> <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> </p>
![](../pictures/souyisou.png) ![](https://labuladong.github.io/algo/images/souyisou1.png)
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~ **通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
[516.最长回文子序列](https://leetcode-cn.com/problems/longest-palindromic-subsequence)
读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
| LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [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/) | 🔴
| [516. Longest Palindromic Subsequence](https://leetcode.com/problems/longest-palindromic-subsequence/) | [516. 最长回文子序列](https://leetcode.cn/problems/longest-palindromic-subsequence/) | 🟠
**-----------** **-----------**
...@@ -23,154 +26,39 @@ ...@@ -23,154 +26,39 @@
首先,子序列问题本身就相对子串、子数组更困难一些,因为前者是不连续的序列,而后两者是连续的,就算穷举你都不一定会,更别说求解相关的算法问题了。 首先,子序列问题本身就相对子串、子数组更困难一些,因为前者是不连续的序列,而后两者是连续的,就算穷举你都不一定会,更别说求解相关的算法问题了。
而且,子序列问题很可能涉及到两个字符串,比如前文「最长公共子序列」,如果没有一定的处理经验,真的不容易想出来。所以本文就来扒一扒子序列问题的套路,其实就有两种模板,相关问题只要往这两种思路上想,十拿九稳。 而且,子序列问题很可能涉及到两个字符串,比如前文 [最长公共子序列](https://labuladong.github.io/article/fname.html?fname=LCS),如果没有一定的处理经验,真的不容易想出来。所以本文就来扒一扒子序列问题的套路,其实就有两种模板,相关问题只要往这两种思路上想,十拿九稳。
一般来说,这类问题都是让你求一个**最长子序列**,因为最短子序列就是一个字符嘛,没啥可问的。一旦涉及到子序列和最值,那几乎可以肯定,**考察的是动态规划技巧,时间复杂度一般都是 O(n^2)** 一般来说,这类问题都是让你求一个**最长子序列**,因为最短子序列就是一个字符嘛,没啥可问的。一旦涉及到子序列和最值,那几乎可以肯定,**考察的是动态规划技巧,时间复杂度一般都是 O(n^2)**
原因很简单,你想想一个字符串,它的子序列有多少种可能?起码是指数级的吧,这种情况下,不用动态规划技巧,还想怎么着? 原因很简单,你想想一个字符串,它的子序列有多少种可能?起码是指数级的吧,这种情况下,不用动态规划技巧,还想怎么着?
既然要用动态规划,那就要定义 dp 数组,找状态转移关系。我们说的两种思路模板,就是 dp 数组的定义思路。不同的问题可能需要不同的 dp 数组定义来解决。 既然要用动态规划,那就要定义 `dp` 数组,找状态转移关系。我们说的两种思路模板,就是 `dp` 数组的定义思路。不同的问题可能需要不同的 `dp` 数组定义来解决。
### 一、两种思路 ### 一、两种思路
**1、第一种思路模板是一个一维的 dp 数组**
```java
int n = array.length;
int[] dp = new int[n];
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
dp[i] = 最值(dp[i], dp[j] + ...)
}
}
```
举个我们写过的例子「最长递增子序列」,在这个思路中 dp 数组的定义是:
**在子数组 `array[0..i]` 中,我们要求的子序列(最长递增子序列)的长度是 `dp[i]`**
为啥最长递增子序列需要这种思路呢?前文说得很清楚了,因为这样符合归纳法,可以找到状态转移的关系,这里就不具体展开了。
**2、第二种思路模板是一个二维的 dp 数组**
```java
int n = arr.length;
int[][] dp = new dp[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (arr[i] == arr[j])
dp[i][j] = dp[i][j] + ...
else
dp[i][j] = 最值(...)
}
}
```
这种思路运用相对更多一些,尤其是涉及两个字符串/数组的子序列,比如前文讲的「最长公共子序列」。本思路中 dp 数组含义又分为「只涉及一个字符串」和「涉及两个字符串」两种情况。
**2.1 涉及两个字符串/数组时**(比如最长公共子序列),dp 数组的含义如下:
**在子数组 `arr1[0..i]` 和子数组 `arr2[0..j]` 中,我们要求的子序列(最长公共子序列)长度为 `dp[i][j]`**
**2.2 只涉及一个字符串/数组时**(比如本文要讲的最长回文子序列),dp 数组的含义如下:
**在子数组 `array[i..j]` 中,我们要求的子序列(最长回文子序列)的长度为 `dp[i][j]`**
第一种情况可以参考这两篇旧文:「编辑距离」「公共子序列」
下面就借最长回文子序列这个问题,详解一下第二种情况下如何使用动态规划。
### 二、最长回文子序列
之前解决了「最长回文子串」的问题,这次提升难度,求最长回文子序列的长度:
![](../pictures/最长回文子序列/title.jpg)
我们说这个问题对 dp 数组的定义是:**在子串 `s[i..j]` 中,最长回文子序列的长度为 `dp[i][j]`**。一定要记住这个定义才能理解算法。 <hr>
<details>
<summary><strong>引用本文的文章</strong></summary>
为啥这个问题要这样定义二维的 dp 数组呢?我们前文多次提到,**找状态转移需要归纳思维,说白了就是如何从已知的结果推出未知的部分**,这样定义容易归纳,容易发现状态转移关系。 - [动态规划设计:最长递增子序列](https://labuladong.github.io/article/fname.html?fname=动态规划设计:最长递增子序列)
- [如何判断回文链表](https://labuladong.github.io/article/fname.html?fname=判断回文链表)
- [对动态规划进行降维打击](https://labuladong.github.io/article/fname.html?fname=状态压缩技巧)
- [最优子结构原理和 dp 数组遍历方向](https://labuladong.github.io/article/fname.html?fname=最优子结构)
- [经典动态规划:最长公共子序列](https://labuladong.github.io/article/fname.html?fname=LCS)
具体来说,如果我们想求 `dp[i][j]`,假设你知道了子问题 `dp[i+1][j-1]` 的结果(`s[i+1..j-1]` 中最长回文子序列的长度),你是否能想办法算出 `dp[i][j]` 的值(`s[i..j]` 中,最长回文子序列的长度)呢? </details><hr>
![](../pictures/最长回文子序列/1.jpg)
可以!这取决于 `s[i]``s[j]` 的字符:
**如果它俩相等**,那么它俩加上 `s[i+1..j-1]` 中的最长回文子序列就是 `s[i..j]` 的最长回文子序列:
![](../pictures/最长回文子序列/2.jpg)
**如果它俩不相等**,说明它俩**不可能同时**出现在 `s[i..j]` 的最长回文子序列中,那么把它俩**分别**加入 `s[i+1..j-1]` 中,看看哪个子串产生的回文子序列更长即可:
![](../pictures/最长回文子序列/3.jpg)
以上两种情况写成代码就是这样:
```java
if (s[i] == s[j])
// 它俩一定在最长回文子序列中
dp[i][j] = dp[i + 1][j - 1] + 2;
else
// s[i+1..j] 和 s[i..j-1] 谁的回文子序列更长?
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
```
至此,状态转移方程就写出来了,根据 dp 数组的定义,我们要求的就是 `dp[0][n - 1]`,也就是整个 `s` 的最长回文子序列的长度。
### 三、代码实现
首先明确一下 base case,如果只有一个字符,显然最长回文子序列长度是 1,也就是 `dp[i][j] = 1 (i == j)`
因为 `i` 肯定小于等于 `j`,所以对于那些 `i > j` 的位置,根本不存在什么子序列,应该初始化为 0。
另外,看看刚才写的状态转移方程,想求 `dp[i][j]` 需要知道 `dp[i+1][j-1]``dp[i+1][j]``dp[i][j-1]` 这三个位置;再看看我们确定的 base case,填入 dp 数组之后是这样:
![](../pictures/最长回文子序列/4.jpg)
**为了保证每次计算 `dp[i][j]`,左下右方向的位置已经被计算出来,只能斜着遍历或者反着遍历**
![](../pictures/最长回文子序列/5.jpg)
我选择反着遍历,代码如下:
```cpp
int longestPalindromeSubseq(string s) {
int n = s.size();
// dp 数组全部初始化为 0
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 - 1; 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];
}
```
至此,最长回文子序列的问题就解决了。
**_____________** **_____________**
**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章** 应合作方要求,本文不便在此发布,请扫码关注回复关键词「子序列」或 [点这里](https://appktavsiei5995.pc.xiaoe-tech.com/detail/i_62987943e4b01c509ab8b6aa/1) 查看:
**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,labuladong 带你搞定 LeetCode** ![](https://labuladong.github.io/algo/images/qrcode.jpg)
<p align='center'>
<img src="../pictures/qrcode.jpg" width=200 >
</p>
======其他语言代码====== ======其他语言代码======
......
...@@ -2,22 +2,26 @@ ...@@ -2,22 +2,26 @@
<p align='center'> <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://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://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://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></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> <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> </p>
![](../pictures/souyisou.png) ![](https://labuladong.github.io/algo/images/souyisou1.png)
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~ **通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
[198.打家劫舍](https://leetcode-cn.com/problems/house-robber)
[213.打家劫舍II](https://leetcode-cn.com/problems/house-robber-ii) 读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
[337.打家劫舍III](https://leetcode-cn.com/problems/house-robber-iii) | LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [198. House Robber](https://leetcode.com/problems/house-robber/) | [198. 打家劫舍](https://leetcode.cn/problems/house-robber/) | 🟠
| [213. House Robber II](https://leetcode.com/problems/house-robber-ii/) | [213. 打家劫舍 II](https://leetcode.cn/problems/house-robber-ii/) | 🟠
| [337. House Robber III](https://leetcode.com/problems/house-robber-iii/) | [337. 打家劫舍 III](https://leetcode.cn/problems/house-robber-iii/) | 🟠
| - | [剑指 Offer II 089. 房屋偷盗](https://leetcode.cn/problems/Gu0c2T/) | 🟠
| - | [剑指 Offer II 090. 环形房屋偷盗](https://leetcode.cn/problems/PzWKhm/) | 🟠
**-----------** **-----------**
...@@ -27,234 +31,46 @@ ...@@ -27,234 +31,46 @@
下面,我们从第一道开始分析。 下面,我们从第一道开始分析。
### House Robber I ### 打家劫舍 I
![title](../pictures/robber/title.png)
```java
public int rob(int[] nums);
```
题目很容易理解,而且动态规划的特征很明显。我们前文「动态规划详解」做过总结,**解决动态规划问题就是找「状态」和「选择」,仅此而已**
假想你就是这个专业强盗,从左到右走过这一排房子,在每间房子前都有两种**选择**:抢或者不抢。
如果你抢了这间房子,那么你**肯定**不能抢相邻的下一间房子了,只能从下下间房子开始做选择。
如果你不抢这件房子,那么你可以走到下一间房子前,继续做选择。
当你走过了最后一间房子后,你就没得抢了,能抢到的钱显然是 0(**base case**)。
以上的逻辑很简单吧,其实已经明确了「状态」和「选择」:**你面前房子的索引就是状态,抢和不抢就是选择**
![1](../pictures/robber/1.jpg)
在两个选择中,每次都选更大的结果,最后得到的就是最多能抢到的 money:
```java
// 主函数
public int rob(int[] nums) {
return dp(nums, 0);
}
// 返回 nums[start..] 能抢到的最大值
private int dp(int[] nums, int start) {
if (start >= nums.length) {
return 0;
}
int res = Math.max(
// 不抢,去下家
dp(nums, start + 1),
// 抢,去下下家
nums[start] + dp(nums, start + 2)
);
return res;
}
```
明确了状态转移,就可以发现对于同一 `start` 位置,是存在重叠子问题的,比如下图:
![2](../pictures/robber/2.jpg)
盗贼有多种选择可以走到这个位置,如果每次到这都进入递归,岂不是浪费时间?所以说存在重叠子问题,可以用备忘录进行优化:
```java
private int[] memo;
// 主函数
public int rob(int[] nums) {
// 初始化备忘录
memo = new int[nums.length];
Arrays.fill(memo, -1);
// 强盗从第 0 间房子开始抢劫
return dp(nums, 0);
}
// 返回 dp[start..] 能抢到的最大值
private int dp(int[] nums, int start) {
if (start >= nums.length) {
return 0;
}
// 避免重复计算
if (memo[start] != -1) return memo[start];
int res = Math.max(dp(nums, start + 1),
nums[start] + dp(nums, start + 2));
// 记入备忘录
memo[start] = res;
return res;
}
```
这就是自顶向下的动态规划解法,我们也可以略作修改,写出**自底向上**的解法:
```java
int rob(int[] nums) {
int n = nums.length;
// dp[i] = x 表示:
// 从第 i 间房子开始抢劫,最多能抢到的钱为 x
// base case: dp[n] = 0
int[] dp = new int[n + 2];
for (int i = n - 1; i >= 0; i--) {
dp[i] = Math.max(dp[i + 1], nums[i] + dp[i + 2]);
}
return dp[0];
}
```
我们又发现状态转移只和 `dp[i]` 最近的两个状态有关,所以可以进一步优化,将空间复杂度降低到 O(1)。
```java 力扣第 198 题「打家劫舍」的题目如下:
int rob(int[] nums) {
int n = nums.length;
// 记录 dp[i+1] 和 dp[i+2]
int dp_i_1 = 0, dp_i_2 = 0;
// 记录 dp[i]
int dp_i = 0;
for (int i = n - 1; i >= 0; i--) {
dp_i = Math.max(dp_i_1, nums[i] + dp_i_2);
dp_i_2 = dp_i_1;
dp_i_1 = dp_i;
}
return dp_i;
}
```
以上的流程,在我们「动态规划详解」中详细解释过,相信大家都能手到擒来了。我认为很有意思的是这个问题的 follow up,需要基于我们现在的思路做一些巧妙的应变 街上有一排房屋,用一个包含非负整数的数组 `nums` 表示,每个元素 `nums[i]` 代表第 `i` 间房子中的现金数额。现在你是一名专业小偷,你希望**尽可能多**的盗窃这些房子中的现金,但是,**相邻的房子不能被同时盗窃**,否则会触发报警器,你就凉凉了
### House Robber II 请你写一个算法,计算在不触动报警器的前提下,最多能够盗窃多少现金呢?函数签名如下:
这道题目和第一道描述基本一样,强盗依然不能抢劫相邻的房子,输入依然是一个数组,但是告诉你**这些房子不是一排,而是围成了一个圈**
也就是说,现在第一间房子和最后一间房子也相当于是相邻的,不能同时抢。比如说输入数组 `nums=[2,3,2]`,算法返回的结果应该是 3 而不是 4,因为开头和结尾不能同时被抢。
这个约束条件看起来应该不难解决,我们前文「单调栈解决 Next Greater Number」说过一种解决环形数组的方案,那么在这个问题上怎么处理呢?
首先,首尾房间不能同时被抢,那么只可能有三种不同情况:要么都不被抢;要么第一间房子被抢最后一间不抢;要么最后一间房子被抢第一间不抢。
![3](../pictures/robber/3.jpg)
那就简单了啊,这三种情况,那种的结果最大,就是最终答案呗!不过,其实我们不需要比较三种情况,只要比较情况二和情况三就行了,**因为这两种情况对于房子的选择余地比情况一大呀,房子里的钱数都是非负数,所以选择余地大,最优决策结果肯定不会小**
所以只需对之前的解法稍作修改即可:
```java ```java
public int rob(int[] nums) { int rob(int[] nums);
int n = nums.length;
if (n == 1) return nums[0];
return Math.max(robRange(nums, 0, n - 2),
robRange(nums, 1, n - 1));
}
// 仅计算闭区间 [start,end] 的最优结果
int robRange(int[] nums, int start, int end) {
int n = nums.length;
int dp_i_1 = 0, dp_i_2 = 0;
int dp_i = 0;
for (int i = end; i >= start; i--) {
dp_i = Math.max(dp_i_1, nums[i] + dp_i_2);
dp_i_2 = dp_i_1;
dp_i_1 = dp_i;
}
return dp_i;
}
``` ```
至此,第二问也解决了 比如说输入 `nums=[2,1,7,9,3,1]`,算法返回 12,小偷可以盗窃 `nums[0], nums[3], nums[5]` 三个房屋,得到的现金之和为 2 + 9 + 1 = 12,是最优的选择
### House Robber III 题目很容易理解,而且动态规划的特征很明显。我们前文 [动态规划详解](https://labuladong.github.io/article/fname.html?fname=动态规划详解进阶) 做过总结,**解决动态规划问题就是找「状态」和「选择」,仅此而已**
第三题又想法设法地变花样了,此强盗发现现在面对的房子不是一排,不是一圈,而是一棵二叉树!房子在二叉树的节点上,相连的两个房子不能同时被抢劫,果然是传说中的高智商犯罪:
![title](../pictures/robber/title1.png)
整体的思路完全没变,还是做抢或者不抢的选择,去收益较大的选择。甚至我们可以直接按这个套路写出代码:
```java
Map<TreeNode, Integer> memo = new HashMap<>();
public int rob(TreeNode root) {
if (root == null) return 0;
// 利用备忘录消除重叠子问题
if (memo.containsKey(root))
return memo.get(root);
// 抢,然后去下下家
int do_it = root.val
+ (root.left == null ?
0 : rob(root.left.left) + rob(root.left.right))
+ (root.right == null ?
0 : rob(root.right.left) + rob(root.right.right));
// 不抢,然后去下家
int not_do = rob(root.left) + rob(root.right);
int res = Math.max(do_it, not_do);
memo.put(root, res);
return res;
}
```
这道题就解决了,时间复杂度 O(N),`N` 为数的节点数。
但是这道题让我觉得巧妙的点在于,还有更漂亮的解法。比如下面是我在评论区看到的一个解法:
```java <hr>
int rob(TreeNode root) { <details>
int[] res = dp(root); <summary><strong>引用本文的题目</strong></summary>
return Math.max(res[0], res[1]);
}
/* 返回一个大小为 2 的数组 arr <strong>安装 [我的 Chrome 刷题插件](https://mp.weixin.qq.com/s/X-fE9sR4BLi6T9pn7xP4pg) 点开下列题目可直接查看解题思路:</strong>
arr[0] 表示不抢 root 的话,得到的最大钱数
arr[1] 表示抢 root 的话,得到的最大钱数 */
int[] dp(TreeNode root) {
if (root == null)
return new int[]{0, 0};
int[] left = dp(root.left);
int[] right = dp(root.right);
// 抢,下家就不能抢了
int rob = root.val + left[0] + right[0];
// 不抢,下家可抢可不抢,取决于收益大小
int not_rob = Math.max(left[0], left[1])
+ Math.max(right[0], right[1]);
return new int[]{not_rob, rob};
}
```
时间复杂度 O(N),空间复杂度只有递归函数堆栈所需的空间,不需要备忘录的额外空间。 | LeetCode | 力扣 |
| :----: | :----: |
| - | [剑指 Offer II 089. 房屋偷盗](https://leetcode.cn/problems/Gu0c2T/?show=1) |
| - | [剑指 Offer II 090. 环形房屋偷盗](https://leetcode.cn/problems/PzWKhm/?show=1) |
你看他和我们的思路不一样,修改了递归函数的定义,略微修改了思路,使得逻辑自洽,依然得到了正确的答案,而且代码更漂亮。这就是我们前文「不同定义产生不同解法」所说过的动态规划问题的一个特性。 </details>
实际上,这个解法比我们的解法运行时间要快得多,虽然算法分析层面时间复杂度是相同的。原因在于此解法没有使用额外的备忘录,减少了数据操作的复杂性,所以实际运行效率会快。
**_____________** **_____________**
**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章** 应合作方要求,本文不便在此发布,请扫码关注回复关键词「抢房子」或 [点这里](https://appktavsiei5995.pc.xiaoe-tech.com/detail/i_62987952e4b09dda12708bf8/1) 查看:
**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode** ![](https://labuladong.github.io/algo/images/qrcode.jpg)
<p align='center'>
<img src="../pictures/qrcode.jpg" width=200 >
</p>
======其他语言代码====== ======其他语言代码======
[198.打家劫舍](https://leetcode-cn.com/problems/house-robber) [198.打家劫舍](https://leetcode-cn.com/problems/house-robber)
......
# 最长公共子序列
<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://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://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></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>
![](../pictures/souyisou.png)
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
[1143.最长公共子序列](https://leetcode-cn.com/problems/longest-common-subsequence)
**-----------**
最长公共子序列(Longest Common Subsequence,简称 LCS)是一道非常经典的面试题目,因为它的解法是典型的二维动态规划,大部分比较困难的字符串问题都和这个问题一个套路,比如说编辑距离。而且,这个算法稍加改造就可以用于解决其他问题,所以说 LCS 算法是值得掌握的。
题目就是让我们求两个字符串的 LCS 长度:
```
输入: str1 = "abcde", str2 = "ace"
输出: 3
解释: 最长公共子序列是 "ace",它的长度是 3
```
肯定有读者会问,为啥这个问题就是动态规划来解决呢?因为子序列类型的问题,穷举出所有可能的结果都不容易,而动态规划算法做的就是穷举 + 剪枝,它俩天生一对儿。所以可以说只要涉及子序列问题,十有八九都需要动态规划来解决,往这方面考虑就对了。
下面就来手把手分析一下,这道题目如何用动态规划技巧解决。
### 一、动态规划思路
**第一步,一定要明确 `dp` 数组的含义**。对于两个字符串的动态规划问题,套路是通用的。
比如说对于字符串 `s1``s2`,一般来说都要构造一个这样的 DP table:
![](../pictures/LCS/dp.png)
为了方便理解此表,我们暂时认为索引是从 1 开始的,待会的代码中只要稍作调整即可。其中,`dp[i][j]` 的含义是:对于 `s1[1..i]``s2[1..j]`,它们的 LCS 长度是 `dp[i][j]`
比如上图的例子,d[2][4] 的含义就是:对于 `"ac"``"babc"`,它们的 LCS 长度是 2。我们最终想得到的答案应该是 `dp[3][6]`
**第二步,定义 base case。**
我们专门让索引为 0 的行和列表示空串,`dp[0][..]``dp[..][0]` 都应该初始化为 0,这就是 base case。
比如说,按照刚才 dp 数组的定义,`dp[0][3]=0` 的含义是:对于字符串 `""``"bab"`,其 LCS 的长度为 0。因为有一个字符串是空串,它们的最长公共子序列的长度显然应该是 0。
**第三步,找状态转移方程。**
这是动态规划最难的一步,不过好在这种字符串问题的套路都差不多,权且借这道题来聊聊处理这类问题的思路。
状态转移说简单些就是做选择,比如说这个问题,是求 `s1``s2` 的最长公共子序列,不妨称这个子序列为 `lcs`。那么对于 `s1``s2` 中的每个字符,有什么选择?很简单,两种选择,要么在 `lcs` 中,要么不在。
![](../pictures/LCS/lcs.png)
这个「在」和「不在」就是选择,关键是,应该如何选择呢?这个需要动点脑筋:如果某个字符应该在 `lcs` 中,那么这个字符肯定同时存在于 `s1``s2` 中,因为 `lcs` 是最长**公共**子序列嘛。所以本题的思路是这样:
用两个指针 `i``j` 从后往前遍历 `s1``s2`,如果 `s1[i]==s2[j]`,那么这个字符**一定在 `lcs` 中**;否则的话,`s1[i]``s2[j]` 这两个字符**至少有一个不在 `lcs` 中**,需要丢弃一个。先看一下递归解法,比较容易理解:
```python
def longestCommonSubsequence(str1, str2) -> int:
def dp(i, j):
# 空串的 base case
if i == -1 or j == -1:
return 0
if str1[i] == str2[j]:
# 这边找到一个 lcs 的元素,继续往前找
return dp(i - 1, j - 1) + 1
else:
# 谁能让 lcs 最长,就听谁的
return max(dp(i-1, j), dp(i, j-1))
# i 和 j 初始化为最后一个索引
return dp(len(str1)-1, len(str2)-1)
```
对于第一种情况,找到一个 `lcs` 中的字符,同时将 `i` `j` 向前移动一位,并给 `lcs` 的长度加一;对于后者,则尝试两种情况,取更大的结果。
其实这段代码就是暴力解法,我们可以通过备忘录或者 DP table 来优化时间复杂度,比如通过前文描述的 DP table 来解决:
```python
def longestCommonSubsequence(str1, str2) -> int:
m, n = len(str1), len(str2)
# 构建 DP table 和 base case
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 进行状态转移
for i in range(1, m + 1):
for j in range(1, n + 1):
if str1[i - 1] == str2[j - 1]:
# 找到一个 lcs 中的字符
dp[i][j] = 1 + dp[i-1][j-1]
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[-1][-1]
```
### 二、疑难解答
对于 `s1[i]``s2[j]` 不相等的情况,**至少有一个**字符不在 `lcs` 中,会不会两个字符都不在呢?比如下面这种情况:
![](../pictures/LCS/1.png)
所以代码是不是应该考虑这种情况,改成这样:
```python
if str1[i - 1] == str2[j - 1]:
# ...
else:
dp[i][j] = max(dp[i-1][j],
dp[i][j-1],
dp[i-1][j-1])
```
我一开始也有这种怀疑,其实可以这样改,也能得到正确答案,但是多此一举,因为 `dp[i-1][j-1]` 永远是三者中最小的,max 根本不可能取到它。
原因在于我们对 dp 数组的定义:对于 `s1[1..i]``s2[1..j]`,它们的 LCS 长度是 `dp[i][j]`
![](../pictures/LCS/2.png)
这样一看,显然 `dp[i-1][j-1]` 对应的 `lcs` 长度不可能比前两种情况大,所以没有必要参与比较。
### 三、总结
对于两个字符串的动态规划问题,一般来说都是像本文一样定义 DP table,因为这样定义有一个好处,就是容易写出状态转移方程,`dp[i][j]` 的状态可以通过之前的状态推导出来:
![](../pictures/LCS/3.png)
找状态转移方程的方法是,思考每个状态有哪些「选择」,只要我们能用正确的逻辑做出正确的选择,算法就能够正确运行。
**_____________**
**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章**
**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode**
<p align='center'>
<img src="../pictures/qrcode.jpg" width=200 >
</p>
======其他语言代码======
[1143.最长公共子序列](https://leetcode-cn.com/problems/longest-common-subsequence)
### c++
[Edwenc](https://github.com/Edwenc) 提供 C++ 代码:
```C++
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
// 先计算两条字符串的长度
int m = text1.size();
int n = text2.size();
// 构建dp矩阵 默认初始值0
// 这里会多扩建一边和一列
// 因为dp[i][j]的含义是:对于 s1[1..i] 和 s2[1..j],它们的LCS长度是 dp[i][j]。
// 所以当i或者j为零时 LCS的长度默认为0
vector< vector<int> > dp ( m+1 , vector<int> ( n+1 , 0 ) );
// 状态转移
// i、j都从1开始遍历 因为下面的操作中都会-1 相当于从0开始
for ( int i=1 ; i<m+1 ; i++ ){
for ( int j=1 ; j<n+1 ; j++ ){
// 如果text1和text2相同
// 就在它们的前一位基础上加一
// 如果不同 只能在之前的两者中去最大
dp[i][j] = (text1[i-1] == text2[j-1]) ? dp[i-1][j-1] + 1 : max( dp[i-1][j] , dp[i][j-1] );
}
}
// 返回最终右下角的值
return dp[m][n];
}
};
```
### java
[Shawn](https://github.com/Shawn-Hx) 提供 Java 代码:
```java
public int longestCommonSubsequence(String text1, String text2) {
// 字符串转为char数组以加快访问速度
char[] str1 = text1.toCharArray();
char[] str2 = text2.toCharArray();
int m = str1.length, n = str2.length;
// 构建dp table,初始值默认为0
int[][] dp = new int[m + 1][n + 1];
// 状态转移
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++)
if (str1[i - 1] == str2[j - 1])
// 找到LCS中的字符
dp[i][j] = dp[i-1][j-1] + 1;
else
dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
return dp[m][n];
}
```
### python
[lo-tp](http://blog.lotp.xyz/) 提供 Python 代码:
```python
class Solution(object):
def longestCommonSubsequence(self, text1, text2):
# calculate the size of the first and second string
sz1, sz2 = len(text1), len(text2)
# since to calculate dp(i,j) we only need dp(i-1,j-1), dp(i-1,j), dp(i,j-1)
# we don't have to save data before i-1
# we use dp to save dp(i-1, 0), dp(i-1, 1)....dp(i-1, sz2)
# we use tmp to save dp(i, 0), dp(i,1)....(dpi-1, sz2)
tmp, dp = [0]*(sz2+1), [0]*(sz2+1)
for i in range(0, sz1):
for j in range(0, sz2):
tmp[j+1] = dp[j] + \
1 if text1[i] == text2[j] else max(tmp[j], dp[j+1])
# In the next iteration, we will calculate dp(i+1,0),dp(i+1, 1)....dp(i+1,sz2)
# So we exchange dp and tmp
tmp, dp = dp, tmp
return dp[-1]
```
### javascript
**暴力解法**
```js
var longestCommonSubsequence = function (text1, text2) {
let s1 = text1.length;
let s2 = text2.length;
let dp = function (i, j) {
// 空串的base case
if (i === -1 || j === -1) {
return 0;
}
if (text1[i] === text2[j]) {
// 这边找到一个 lcs 的元素,继续往前找
return dp(i - 1, j - 1) + 1
} else {
// 谁能让lcs最长,就听谁的
return Math.max(dp(i - 1, j), dp(i, j - 1))
}
}
// i 和 j 初始化为最后一个索引
return dp(s1 - 1, s2 - 1)
};
```
**暴力解法+备忘录优化**
```js
var longestCommonSubsequence = function (text1, text2) {
let s1 = text1.length;
let s2 = text2.length;
let memo = new Map();
let dp = function (i, j) {
// 空串的base case
if (i === -1 || j === -1) {
return 0;
}
// 查询一下备忘录,防止重复计算
let key = i + "," + j
if (memo.has(key)) {
return memo.get(key)
}
let res;
if (text1[i] === text2[j]) {
// 这边找到一个 lcs 的元素,继续往前找
// 记入备忘录
res = dp(i - 1, j - 1) + 1
memo.set(key, res)
} else {
// 谁能让lcs最长,就听谁的
res = Math.max(dp(i - 1, j), dp(i, j - 1))
memo.set(key, res)
}
return res;
}
// i 和 j 初始化为最后一个索引
return dp(s1 - 1, s2 - 1)
};
```
**DPtable优化**
```js
var longestCommonSubsequence = function (text1, text2) {
let s1 = text1.length;
let s2 = text2.length;
// 构建 DP table 和 base case
// 初始化一个 (s1+1)*(s2+1)的dp表
let dp = new Array(s1 + 1);
for (let i = 0; i < s1 + 1; i++) {
dp[i] = new Array(s2 + 1);
dp[i].fill(0, 0, s2 + 1)
}
// 进行状态转移
for (let i = 1; i < s1 + 1; i++) {
for (let j = 1; j < s2 + 1; j++) {
if (text1[i - 1] === text2[j - 1]) {
// 找到一个lcs中的字符
dp[i][j] = 1 + dp[i - 1][j - 1]
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
// i 和 j 初始化为最后一个索引
return dp[s1][s2]
};
```
# 对动态规划发动降维打击
<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>
![](https://labuladong.github.io/algo/images/souyisou1.png)
**通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
**-----------**
我们号之前写过十几篇动态规划文章,可以说动态规划技巧对于算法效率的提升非常可观,一般来说都能把指数级和阶乘级时间复杂度的算法优化成 O(N^2),堪称算法界的二向箔,把各路魑魅魍魉统统打成二次元。
但是,动态规划求解的过程中也是可以进行阶段性优化的,如果你认真观察某些动态规划问题的状态转移方程,就能够把它们解法的空间复杂度进一步降低,由 O(N^2) 降低到 O(N)。
> PS:之前我在本文中误用了「状态压缩」这个词,有读者指出「状态压缩」这个词的含义是把多个状态通过二进制运算用一个整数表示出来,从而减少 `dp` 数组的维度。而本文描述的优化方式是通过观察状态转移方程的依赖关系,从而减少 `dp` 数组的维度,确实和「状态压缩」有所区别。所以严谨起见,我把原来文章中的「状态压缩」都改为了「空间压缩」,避免名词的误用。
能够使用空间压缩技巧的动态规划都是二维 `dp` 问题,**你看它的状态转移方程,如果计算状态 `dp[i][j]` 需要的都是 `dp[i][j]` 相邻的状态,那么就可以使用空间压缩技巧**,将二维的 `dp` 数组转化成一维,将空间复杂度从 O(N^2) 降低到 O(N)。
什么叫「和 `dp[i][j]` 相邻的状态」呢,比如前文 [最长回文子序列](https://labuladong.github.io/article/fname.html?fname=子序列问题模板) 中,最终的代码如下:
```cpp
int longestPalindromeSubseq(string s) {
int n = s.size();
// dp 数组全部初始化为 0
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];
}
```
> PS:我们本文不探讨如何推状态转移方程,只探讨对二维 DP 问题进行空间压缩的技巧。技巧都是通用的,所以如果你没看过前文,不明白这段代码的逻辑也无妨,完全不会阻碍你学会空间压缩。
你看我们对 `dp[i][j]` 的更新,其实只依赖于 `dp[i+1][j-1], dp[i][j-1], dp[i+1][j]` 这三个状态:
![](https://labuladong.github.io/algo/images/状态压缩/1.jpeg)
这就叫和 `dp[i][j]` 相邻,反正你计算 `dp[i][j]` 只需要这三个相邻状态,其实根本不需要那么大一个二维的 dp table 对不对?**空间压缩的核心思路就是,将二维数组「投影」到一维数组**
![](https://labuladong.github.io/algo/images/状态压缩/2.jpeg)
思路很直观,但是也有一个明显的问题,图中 `dp[i][j-1]``dp[i+1][j-1]` 这两个状态处在同一列,而一维数组中只能容下一个,那么当我计算 `dp[i][j]` 时,他俩必然有一个会被另一个覆盖掉,怎么办?
这就是空间压缩的难点,下面就来分析解决这个问题,还是拿「最长回文子序列」问题举例,它的状态转移方程主要逻辑就是如下这段代码:
```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]);
}
}
```
想把二维 `dp` 数组压缩成一维,一般来说是把第一个维度,也就是 `i` 这个维度去掉,只剩下 `j` 这个维度。**压缩后的一维 `dp` 数组就是之前二维 `dp` 数组的 `dp[i][..]` 那一行**
我们先将上述代码进行改造,直接无脑去掉 `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]` 覆盖掉,图中标出了这四个位置被遍历到的次序:
![](https://labuladong.github.io/algo/images/状态压缩/3.jpeg)
**那么如果我们想得到 `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
if (s[5] == s[7])
// dp[5][7] = dp[i+1][j-1] + 2;
dp[7] = pre + 2;
```
我问你这个 `pre` 变量是什么?是内层 for 循环上一次迭代的 `temp` 值。
那我再问你内层 for 循环上一次迭代的 `temp` 值是什么?是 `dp[j-1]` 也就是 `dp[6]`,但这是外层 for 循环上一次迭代对应的 `dp[6]`,也就是二维 `dp` 数组中的 `dp[i+1][6] = dp[6][6]`
也就是说,`pre` 变量就是 `dp[i+1][j-1] = dp[6][6]`,也就是我们想要的结果。
那么现在我们成功对状态转移方程进行了降维打击,算是最硬的的骨头啃掉了,但注意到我们还有 base case 要处理呀:
```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 投影到一维看看:
![](https://labuladong.github.io/algo/images/状态压缩/4.jpeg)
二维 `dp` 数组中的 base case 全都落入了一维 `dp` 数组,不存在冲突和覆盖,所以说我们直接这样写代码就行了:
```cpp
// 一维 dp 数组全部初始化为 1
vector<int> dp(n, 1);
```
至此,我们把 base case 和状态转移方程都进行了降维,实际上已经写出完整代码了:
```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=最小路径和)
- [动态规划设计:最大子数组](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>
**_____________**
**《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「进群」可加入算法群;回复「PDF」可获取精华文章 PDF**
![](https://labuladong.github.io/algo/images/souyisou2.png)
# 编辑距离 # 编辑距离
<p align='center'> <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://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://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://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></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> <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> </p>
![](../pictures/souyisou.png) ![](https://labuladong.github.io/algo/images/souyisou1.png)
**通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~
读完本文,你不仅学会了算法套路,还可以顺便去 LeetCode 上拿下如下题目:
[72.编辑距离](https://leetcode-cn.com/problems/edit-distance) 读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
| LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [72. Edit Distance](https://leetcode.com/problems/edit-distance/) | [72. 编辑距离](https://leetcode.cn/problems/edit-distance/) | 🔴
**-----------** **-----------**
前几天看了一份鹅场的面试题,算法部分大半是动态规划,最后一题就是写一个计算编辑距离的函数,今天就专门写一篇文章来探讨一下这个问题。 > 本文有视频版:[编辑距离详解动态规划](https://www.bilibili.com/video/BV1uv411W73P/)
前几天看了一份鹅厂的面试题,算法部分大半是动态规划,最后一题就是写一个计算编辑距离的函数,今天就专门写一篇文章来探讨一下这个问题。
我个人很喜欢编辑距离这个问题,因为它看起来十分困难,解法却出奇得简单漂亮,而且它是少有的比较实用的算法(是的,我承认很多算法问题都不太实用)。下面先来看下题目: 我个人很喜欢编辑距离这个问题,因为它看起来十分困难,解法却出奇得简单漂亮,而且它是少有的比较实用的算法(我承认很多算法问题都不太实用)。
![](../pictures/editDistance/title.png) 力扣第 72 题「编辑距离」就是这个问题,先看下题目:
![](https://labuladong.github.io/algo/images/editDistance/title.png)
函数签名如下:
```java
int minDistance(String s1, String s2)
```
为什么说这个问题难呢,因为显而易见,它就是难,让人手足无措,望而生畏。 为什么说这个问题难呢,因为显而易见,它就是难,让人手足无措,望而生畏。
...@@ -36,25 +49,27 @@ ...@@ -36,25 +49,27 @@
编辑距离问题就是给我们两个字符串 `s1``s2`,只能用三种操作,让我们把 `s1` 变成 `s2`,求最少的操作数。需要明确的是,不管是把 `s1` 变成 `s2` 还是反过来,结果都是一样的,所以后文就以 `s1` 变成 `s2` 举例。 编辑距离问题就是给我们两个字符串 `s1``s2`,只能用三种操作,让我们把 `s1` 变成 `s2`,求最少的操作数。需要明确的是,不管是把 `s1` 变成 `s2` 还是反过来,结果都是一样的,所以后文就以 `s1` 变成 `s2` 举例。
前文「最长公共子序列」说过,**解决两个字符串的动态规划问题,一般都是用两个指针 `i,j` 分别指向两个字符串的最后,然后一步步往前走,缩小问题的规模** 前文 [最长公共子序列](https://labuladong.github.io/article/fname.html?fname=LCS) 说过,**解决两个字符串的动态规划问题,一般都是用两个指针 `i, j` 分别指向两个字符串的最后,然后一步步往前移动,缩小问题的规模**
> PS:其实让 `i, j` 从前往后移动也可以,改一下 `dp` 函数/数组的定义即可,思路是完全一样的。
设两个字符串分别为 "rad" 和 "apple",为了把 `s1` 变成 `s2`,算法会这样进行: 设两个字符串分别为 `"rad"``"apple"`,为了把 `s1` 变成 `s2`,算法会这样进行:
![](../pictures/editDistance/edit.gif) ![](https://labuladong.github.io/algo/images/editDistance/edit.gif)
![](../pictures/editDistance/1.jpg) ![](https://labuladong.github.io/algo/images/editDistance/1.jpg)
请记住这个 GIF 过程,这样就能算出编辑距离。关键在于如何做出正确的操作,稍后会讲。 请记住这个 GIF 过程,这样就能算出编辑距离。关键在于如何做出正确的操作,稍后会讲。
根据上面的 GIF,可以发现操作不只有三个,其实还有第四个操作,就是什么都不要做(skip)。比如这个情况: 根据上面的 GIF,可以发现操作不只有三个,其实还有第四个操作,就是什么都不要做(skip)。比如这个情况:
![](../pictures/editDistance/2.jpg) ![](https://labuladong.github.io/algo/images/editDistance/2.jpg)
因为这两个字符本来就相同,为了使编辑距离最小,显然不应该对它们有任何操作,直接往前移动 `i,j` 即可。 因为这两个字符本来就相同,为了使编辑距离最小,显然不应该对它们有任何操作,直接往前移动 `i, j` 即可。
还有一个很容易处理的情况,就是 `j` 走完 `s2` 时,如果 `i` 还没走完 `s1`,那么只能用删除操作把 `s1` 缩短为 `s2`。比如这个情况: 还有一个很容易处理的情况,就是 `j` 走完 `s2` 时,如果 `i` 还没走完 `s1`,那么只能用删除操作把 `s1` 缩短为 `s2`。比如这个情况:
![](../pictures/editDistance/3.jpg) ![](https://labuladong.github.io/algo/images/editDistance/3.jpg)
类似的,如果 `i` 走完 `s1``j` 还没走完了 `s2`,那就只能用插入操作把 `s2` 剩下的字符全部插入 `s1`。等会会看到,这两种情况就是算法的 **base case** 类似的,如果 `i` 走完 `s1``j` 还没走完了 `s2`,那就只能用插入操作把 `s2` 剩下的字符全部插入 `s1`。等会会看到,这两种情况就是算法的 **base case**
...@@ -79,43 +94,50 @@ else: ...@@ -79,43 +94,50 @@ else:
替换replace 替换replace
``` ```
有这个框架,问题就已经解决了。读者也许会问,这个「三选一」到底该怎么选择呢?很简单,全试一遍,哪个操作最后得到的编辑距离最小,就选谁。这里需要递归技巧,理解需要点技巧,先看下代码: 有这个框架,问题就已经解决了。读者也许会问,这个「三选一」到底该怎么选择呢?很简单,全试一遍,哪个操作最后得到的编辑距离最小,就选谁。这里需要递归技巧,先看下暴力解法代码:
```python ```java
def minDistance(s1, s2) -> int: int minDistance(String s1, String s2) {
int m = s1.length(), n = s2.length();
def dp(i, j): // i,j 初始化指向最后一个索引
# base case return dp(s1, m - 1, s2, n - 1);
if i == -1: return j + 1 }
if j == -1: return i + 1
// 定义:返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
if s1[i] == s2[j]: int dp(String s1, int i, String s2, int j) {
return dp(i - 1, j - 1) # 啥都不做 // base case
else: if (i == -1) return j + 1;
return min( if (j == -1) return i + 1;
dp(i, j - 1) + 1, # 插入
dp(i - 1, j) + 1, # 删除 if (s1.charAt(i) == s2.charAt(j)) {
dp(i - 1, j - 1) + 1 # 替换 return dp(s1, i - 1, s2, j - 1); // 啥都不做
) }
return min(
# i,j 初始化指向最后一个索引 dp(s1, i, s2, j - 1) + 1, // 插入
return dp(len(s1) - 1, len(s2) - 1) dp(s1, i - 1, s2, j) + 1, // 删除
dp(s1, i - 1, s2, j - 1) + 1 // 替换
);
}
int min(int a, int b, int c) {
return Math.min(a, Math.min(b, c));
}
``` ```
下面来详细解释一下这段递归代码,base case 应该不用解释了,主要解释一下递归部分。 下面来详细解释一下这段递归代码,base case 应该不用解释了,主要解释一下递归部分。
都说递归代码的可解释性很好,这是有道理的,只要理解函数的定义,就能很清楚地理解算法的逻辑。我们这里 dp(i, j) 函数的定义是这样的: 都说递归代码的可解释性很好,这是有道理的,只要理解函数的定义,就能很清楚地理解算法的逻辑。我们这里 `dp` 函数的定义是这样的:
```python ```java
def dp(i, j) -> int // 定义:返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离 int dp(String s1, int i, String s2, int j) {
``` ```
**记住这个定义**之后,先来看这段代码: **记住这个定义**之后,先来看这段代码:
```python ```python
if s1[i] == s2[j]: if s1[i] == s2[j]:
return dp(i - 1, j - 1) # 啥都不做 return dp(s1, i - 1, s2, j - 1); # 啥都不做
# 解释: # 解释:
# 本来就相等,不需要任何操作 # 本来就相等,不需要任何操作
# s1[0..i] 和 s2[0..j] 的最小编辑距离等于 # s1[0..i] 和 s2[0..j] 的最小编辑距离等于
...@@ -123,98 +145,121 @@ if s1[i] == s2[j]: ...@@ -123,98 +145,121 @@ if s1[i] == s2[j]:
# 也就是说 dp(i, j) 等于 dp(i-1, j-1) # 也就是说 dp(i, j) 等于 dp(i-1, j-1)
``` ```
如果 `s1[i]!=s2[j]`,就要对三个操作递归了,稍微需要点思考: 如果 `s1[i] != s2[j]`,就要对三个操作递归了,稍微需要点思考:
```python ```python
dp(i, j - 1) + 1, # 插入 dp(s1, i, s2, j - 1) + 1, # 插入
# 解释: # 解释:
# 我直接在 s1[i] 插入一个和 s2[j] 一样的字符 # 我直接在 s1[i] 插入一个和 s2[j] 一样的字符
# 那么 s2[j] 就被匹配了,前移 j,继续跟 i 对比 # 那么 s2[j] 就被匹配了,前移 j,继续跟 i 对比
# 别忘了操作数加一 # 别忘了操作数加一
``` ```
![](../pictures/editDistance/insert.gif) ![](https://labuladong.github.io/algo/images/editDistance/insert.gif)
```python ```python
dp(i - 1, j) + 1, # 删除 dp(s1, i - 1, s2, j) + 1, # 删除
# 解释: # 解释:
# 我直接把 s[i] 这个字符删掉 # 我直接把 s[i] 这个字符删掉
# 前移 i,继续跟 j 对比 # 前移 i,继续跟 j 对比
# 操作数加一 # 操作数加一
``` ```
![](../pictures/editDistance/delete.gif) ![](https://labuladong.github.io/algo/images/editDistance/delete.gif)
```python ```python
dp(i - 1, j - 1) + 1 # 替换 dp(s1, i - 1, s2, j - 1) + 1 # 替换
# 解释: # 解释:
# 我直接把 s1[i] 替换成 s2[j],这样它俩就匹配了 # 我直接把 s1[i] 替换成 s2[j],这样它俩就匹配了
# 同时前移 i,j 继续对比 # 同时前移 i,j 继续对比
# 操作数加一 # 操作数加一
``` ```
![](../pictures/editDistance/replace.gif) ![](https://labuladong.github.io/algo/images/editDistance/replace.gif)
现在,你应该完全理解这段短小精悍的代码了。还有点小问题就是,这个解法是暴力解法,存在重叠子问题,需要用动态规划技巧来优化。 现在,你应该完全理解这段短小精悍的代码了。还有点小问题就是,这个解法是暴力解法,存在重叠子问题,需要用动态规划技巧来优化。
**怎么能一眼看出存在重叠子问题呢**?前文「动态规划之正则表达式」有提过,这里再简单提一下,需要抽象出本文算法的递归框架: **怎么能一眼看出存在重叠子问题呢**?前文 [动态规划之正则表达式](https://labuladong.github.io/article/fname.html?fname=动态规划之正则表达) 有提过,这里再简单提一下,需要抽象出本文算法的递归框架:
```python ```java
def dp(i, j): int dp(i, j) {
dp(i - 1, j - 1) #1 dp(i - 1, j - 1); // #1
dp(i, j - 1) #2 dp(i, j - 1); // #2
dp(i - 1, j) #3 dp(i - 1, j); // #3
}
``` ```
对于子问题 `dp(i-1, j-1)`,如何通过原问题 `dp(i, j)` 得到呢?有不止一条路径,比如 `dp(i, j) -> #1``dp(i, j) -> #2 -> #3`。一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。 对于子问题 `dp(i-1, j-1)`,如何通过原问题 `dp(i, j)` 得到呢?有不止一条路径,比如 `dp(i, j) -> #1``dp(i, j) -> #2 -> #3`。一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。
### 三、动态规划优化 ### 三、动态规划优化
对于重叠子问题呢,前文「动态规划详解」详细介绍过,优化方法无非是备忘录或者 DP table。 对于重叠子问题呢,前文 [动态规划详解](https://labuladong.github.io/article/fname.html?fname=动态规划详解进阶) 详细介绍过,优化方法无非是备忘录或者 DP table。
备忘录很好加,原来的代码稍加修改即可: 备忘录很好加,原来的代码稍加修改即可:
```python ```java
def minDistance(s1, s2) -> int: // 备忘录
int[][] memo;
memo = dict() # 备忘录
def dp(i, j):
if (i, j) in memo:
return memo[(i, j)]
...
if s1[i] == s2[j]:
memo[(i, j)] = ...
else:
memo[(i, j)] = ...
return memo[(i, j)]
return dp(len(s1) - 1, len(s2) - 1) public int minDistance(String s1, String s2) {
int m = s1.length(), n = s2.length();
// 备忘录初始化为特殊值,代表还未计算
memo = new int[m][n];
for (int[] row : memo) {
Arrays.fill(row, -1);
}
return dp(s1, m - 1, s2, n - 1);
}
int dp(String s1, int i, String s2, int j) {
if (i == -1) return j + 1;
if (j == -1) return i + 1;
// 查备忘录,避免重叠子问题
if (memo[i][j] != -1) {
return memo[i][j];
}
// 状态转移,结果存入备忘录
if (s1.charAt(i) == s2.charAt(j)) {
memo[i][j] = dp(s1, i - 1, s2, j - 1);
} else {
memo[i][j] = min(
dp(s1, i, s2, j - 1) + 1,
dp(s1, i - 1, s2, j) + 1,
dp(s1, i - 1, s2, j - 1) + 1
);
}
return memo[i][j];
}
int min(int a, int b, int c) {
return Math.min(a, Math.min(b, c));
}
``` ```
**主要说下 DP table 的解法** **主要说下 DP table 的解法**
首先明确 dp 数组的含义,dp 数组是一个二维数组,长这样: 首先明确 `dp` 数组的含义,`dp` 数组是一个二维数组,长这样:
![](../pictures/editDistance/dp.jpg) ![](https://labuladong.github.io/algo/images/editDistance/dp.jpg)
有了之前递归解法的铺垫,应该很容易理解。`dp[..][0]``dp[0][..]` 对应 base case,`dp[i][j]` 的含义和之前的 dp 函数类似: 有了之前递归解法的铺垫,应该很容易理解。`dp[..][0]``dp[0][..]` 对应 base case,`dp[i][j]` 的含义和之前的 `dp` 函数类似:
```python ```java
def dp(i, j) -> int int dp(String s1, int i, String s2, int j)
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离 // 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
dp[i-1][j-1] dp[i-1][j-1]
# 存储 s1[0..i] 和 s2[0..j] 的最小编辑距离 // 存储 s1[0..i] 和 s2[0..j] 的最小编辑距离
``` ```
dp 函数的 base case 是 `i,j` 等于 -1,而数组索引至少是 0,所以 dp 数组会偏移一位。 `dp` 函数的 base case 是 `i, j` 等于 -1,而数组索引至少是 0,所以 `dp` 数组会偏移一位。
既然 dp 数组和递归 dp 函数含义一样,也就可以直接套用之前的思路写代码,**唯一不同的是,DP table 是自底向上求解,递归解法是自顶向下求解** 既然 `dp` 数组和递归 `dp` 函数含义一样,也就可以直接套用之前的思路写代码,**唯一不同的是,DP table 是自底向上求解,递归解法是自顶向下求解**
```java ```java
int minDistance(String s1, String s2) { int minDistance(String s1, String s2) {
int m = s1.length(), n = s2.length(); int m = s1.length(), n = s2.length();
// 定义:s1[0..i] 和 s2[0..j] 的最小编辑距离是 dp[i+1][j+1]
int[][] dp = new int[m + 1][n + 1]; int[][] dp = new int[m + 1][n + 1];
// base case // base case
for (int i = 1; i <= m; i++) for (int i = 1; i <= m; i++)
...@@ -222,16 +267,19 @@ int minDistance(String s1, String s2) { ...@@ -222,16 +267,19 @@ int minDistance(String s1, String s2) {
for (int j = 1; j <= n; j++) for (int j = 1; j <= n; j++)
dp[0][j] = j; dp[0][j] = j;
// 自底向上求解 // 自底向上求解
for (int i = 1; i <= m; i++) for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) for (int j = 1; j <= n; j++) {
if (s1.charAt(i-1) == s2.charAt(j-1)) if (s1.charAt(i-1) == s2.charAt(j-1)) {
dp[i][j] = dp[i - 1][j - 1]; dp[i][j] = dp[i - 1][j - 1];
else } else {
dp[i][j] = min( dp[i][j] = min(
dp[i - 1][j] + 1, dp[i - 1][j] + 1,
dp[i][j - 1] + 1, dp[i][j - 1] + 1,
dp[i-1][j-1] + 1 dp[i - 1][j - 1] + 1
); );
}
}
}
// 储存着整个 s1 和 s2 的最小编辑距离 // 储存着整个 s1 和 s2 的最小编辑距离
return dp[m][n]; return dp[m][n];
} }
...@@ -245,7 +293,7 @@ int min(int a, int b, int c) { ...@@ -245,7 +293,7 @@ int min(int a, int b, int c) {
一般来说,处理两个字符串的动态规划问题,都是按本文的思路处理,建立 DP table。为什么呢,因为易于找出状态转移的关系,比如编辑距离的 DP table: 一般来说,处理两个字符串的动态规划问题,都是按本文的思路处理,建立 DP table。为什么呢,因为易于找出状态转移的关系,比如编辑距离的 DP table:
![](../pictures/editDistance/4.jpg) ![](https://labuladong.github.io/algo/images/editDistance/4.jpg)
还有一个细节,既然每个 `dp[i][j]` 只和它附近的三个状态有关,空间复杂度是可以压缩成 `O(min(M, N))` 的(M,N 是两个字符串的长度)。不难,但是可解释性大大降低,读者可以自己尝试优化一下。 还有一个细节,既然每个 `dp[i][j]` 只和它附近的三个状态有关,空间复杂度是可以压缩成 `O(min(M, N))` 的(M,N 是两个字符串的长度)。不难,但是可解释性大大降低,读者可以自己尝试优化一下。
...@@ -271,23 +319,48 @@ class Node { ...@@ -271,23 +319,48 @@ class Node {
我们的最终结果不是 `dp[m][n]` 吗,这里的 `val` 存着最小编辑距离,`choice` 存着最后一个操作,比如说是插入操作,那么就可以左移一格: 我们的最终结果不是 `dp[m][n]` 吗,这里的 `val` 存着最小编辑距离,`choice` 存着最后一个操作,比如说是插入操作,那么就可以左移一格:
![](../pictures/editDistance/5.jpg) ![](https://labuladong.github.io/algo/images/editDistance/5.jpg)
重复此过程,可以一步步回到起点 `dp[0][0]`,形成一条路径,按这条路径上的操作进行编辑,就是最佳方案。 重复此过程,可以一步步回到起点 `dp[0][0]`,形成一条路径,按这条路径上的操作进行编辑,就是最佳方案。
![](../pictures/editDistance/6.jpg) ![](https://labuladong.github.io/algo/images/editDistance/6.jpg)
<hr>
<details>
<summary><strong>引用本文的文章</strong></summary>
- [动态规划之子序列问题解题模板](https://labuladong.github.io/article/fname.html?fname=子序列问题模板)
- [动态规划和回溯算法到底谁是谁爹?](https://labuladong.github.io/article/fname.html?fname=targetSum)
- [最优子结构原理和 dp 数组遍历方向](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 | 力扣 |
| :----: | :----: |
| [97. Interleaving String](https://leetcode.com/problems/interleaving-string/?show=1) | [97. 交错字符串](https://leetcode.cn/problems/interleaving-string/?show=1) |
</details>
**_____________** **_____________**
**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章** **《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「进群」可加入算法群;回复「PDF」可获取精华文章 PDF**
![](https://labuladong.github.io/algo/images/souyisou2.png)
**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode**
<p align='center'>
<img src="../pictures/qrcode.jpg" width=200 >
</p>
======其他语言代码====== ======其他语言代码======
### python ### python
......
# 动态规划之背包问题 # 动态规划之背包问题
<p align='center'> <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://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://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://i.loli.net/2020/10/10/MhRTyUKfXZOlQYN.jpg"><img src="https://img.shields.io/badge/公众号-@labuladong-000000.svg?style=flat-square&logo=WeChat"></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> <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> </p>
![](../pictures/souyisou.png) ![](https://labuladong.github.io/algo/images/souyisou1.png)
**通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
**《labuladong 的算法秘籍》、《labuladong 的刷题笔记》两本 PDF 和刷题插件 2.0 免费开放下载,详情见 [labuladong 的刷题三件套正式发布](https://mp.weixin.qq.com/s/yN4cHQRsFa5SWlacopHXYQ)**~
**-----------** **-----------**
本文有视频版:[0-1背包问题详解](https://www.bilibili.com/video/BV15B4y1P7X7/) > 本文有视频版:[0-1背包问题详解](https://www.bilibili.com/video/BV15B4y1P7X7/)
后台天天有人问背包问题,这个问题其实不难啊,如果我们号动态规划系列的十几篇文章你都看过,借助框架,遇到背包问题可以说是手到擒来好吧。无非就是状态 + 选择,也没啥特别之处嘛。 后台天天有人问背包问题,这个问题其实不难啊,如果我们号动态规划系列的十几篇文章你都看过,借助框架,遇到背包问题可以说是手到擒来好吧。无非就是状态 + 选择,也没啥特别之处嘛。
...@@ -22,6 +23,8 @@ ...@@ -22,6 +23,8 @@
给你一个可装载重量为 `W` 的背包和 `N` 个物品,每个物品有重量和价值两个属性。其中第 `i` 个物品的重量为 `wt[i]`,价值为 `val[i]`,现在让你用这个背包装物品,最多能装的价值是多少? 给你一个可装载重量为 `W` 的背包和 `N` 个物品,每个物品有重量和价值两个属性。其中第 `i` 个物品的重量为 `wt[i]`,价值为 `val[i]`,现在让你用这个背包装物品,最多能装的价值是多少?
![](https://labuladong.github.io/algo/images/knapsack/1.png)
举个简单的例子,输入如下: 举个简单的例子,输入如下:
``` ```
...@@ -34,11 +37,11 @@ val = [4, 2, 3] ...@@ -34,11 +37,11 @@ val = [4, 2, 3]
题目就是这么简单,一个典型的动态规划问题。这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。这就是 0-1 背包这个名词的来历。 题目就是这么简单,一个典型的动态规划问题。这个题目中的物品不可以分割,要么装进包里,要么不装,不能说切成两块装一半。这就是 0-1 背包这个名词的来历。
解决这个问题没有什么排序之类巧妙的方法,只能穷举所有可能,根据我们 [动态规划详解](https://labuladong.gitee.io/algo/) 中的套路,直接走流程就行了。 解决这个问题没有什么排序之类巧妙的方法,只能穷举所有可能,根据我们 [动态规划详解](https://labuladong.github.io/article/fname.html?fname=动态规划详解进阶) 中的套路,直接走流程就行了。
### 动规标准套路 ### 动规标准套路
看来我得每篇动态规划文章都得重复一遍套路,历史文章中的动态规划问题都是按照下面的套路来的。 看来每篇动态规划文章都得重复一遍套路,历史文章中的动态规划问题都是按照下面的套路来的。
**第一步要明确两点,「状态」和「选择」** **第一步要明确两点,「状态」和「选择」**
...@@ -55,7 +58,7 @@ for 状态1 in 状态1的所有取值: ...@@ -55,7 +58,7 @@ for 状态1 in 状态1的所有取值:
dp[状态1][状态2][...] = 择优(选择1选择2...) dp[状态1][状态2][...] = 择优(选择1选择2...)
``` ```
PS:此框架出自历史文章 [团灭 LeetCode 股票问题](https://labuladong.gitee.io/algo/) > PS:此框架出自历史文章 [团灭 LeetCode 股票问题](https://labuladong.github.io/article/fname.html?fname=团灭股票问题)。
**第二步要明确 `dp` 数组的定义** **第二步要明确 `dp` 数组的定义**
...@@ -65,7 +68,7 @@ PS:此框架出自历史文章 [团灭 LeetCode 股票问题](https://labulado ...@@ -65,7 +68,7 @@ PS:此框架出自历史文章 [团灭 LeetCode 股票问题](https://labulado
比如说,如果 `dp[3][5] = 6`,其含义为:对于给定的一系列物品中,若只对前 3 个物品进行选择,当背包容量为 5 时,最多可以装下的价值为 6。 比如说,如果 `dp[3][5] = 6`,其含义为:对于给定的一系列物品中,若只对前 3 个物品进行选择,当背包容量为 5 时,最多可以装下的价值为 6。
PS:为什么要这么定义?便于状态转移,或者说这就是套路,记下来就行了。建议看一下我们的动态规划系列文章,几种套路都被扒得清清楚楚了。 > PS:为什么要这么定义?便于状态转移,或者说这就是套路,记下来就行了。建议看一下我们的动态规划系列文章,几种套路都被扒得清清楚楚了。
根据这个定义,我们想求的最终答案就是 `dp[N][W]`。base case 就是 `dp[0][..] = dp[..][0] = 0`,因为没有物品或者背包没有空间的时候,能装的最大价值就是 0。 根据这个定义,我们想求的最终答案就是 `dp[N][W]`。base case 就是 `dp[0][..] = dp[..][0] = 0`,因为没有物品或者背包没有空间的时候,能装的最大价值就是 0。
...@@ -93,15 +96,15 @@ return dp[N][W] ...@@ -93,15 +96,15 @@ return dp[N][W]
先重申一下刚才我们的 `dp` 数组的定义: 先重申一下刚才我们的 `dp` 数组的定义:
`dp[i][w]` 表示:对于前 `i` 个物品,当前背包的容量为 `w` 时,这种情况下可以装下的最大价值是 `dp[i][w]` `dp[i][w]` 表示:对于前 `i` 个物品(从 1 开始计数),当前背包的容量为 `w` 时,这种情况下可以装下的最大价值是 `dp[i][w]`
**如果你没有把这第 `i` 个物品装入背包**,那么很显然,最大价值 `dp[i][w]` 应该等于 `dp[i-1][w]`,继承之前的结果。 **如果你没有把这第 `i` 个物品装入背包**,那么很显然,最大价值 `dp[i][w]` 应该等于 `dp[i-1][w]`,继承之前的结果。
**如果你把这第 `i` 个物品装入了背包**,那么 `dp[i][w]` 应该等于 `dp[i-1][w - wt[i-1]] + val[i-1]` **如果你把这第 `i` 个物品装入了背包**,那么 `dp[i][w]` 应该等于 `val[i-1] + dp[i-1][w - wt[i-1]]`
首先,由于 `i` 是从 1 开始的,所以 `val``wt` 的索引是 `i-1`表示第 `i` 个物品的价值和重量。 首先,由于数组索引从 0 开始,而我们定义中的 `i` 是从 1 开始计数的,所以 `val[i-1]``wt[i-1]` 表示第 `i` 个物品的价值和重量。
`dp[i-1][w - wt[i-1]]` 也很好理解:你如果装了第 `i` 个物品,就要寻求剩余重量 `w - wt[i-1]` 限制下的最大价值,加上第 `i` 个物品的价值 `val[i-1]` 你如果选择将第 `i` 个物品装进背包,那么第 `i` 个物品的价值 `val[i-1]` 肯定就到手了,接下来你就要在剩余容量 `w - wt[i-1]` 的限制下,在前 `i - 1` 个物品中挑选,求最大价值,即 `dp[i-1][w - wt[i-1]]`
综上就是两种选择,我们都已经分析完毕,也就是写出来了状态转移方程,可以进一步细化代码: 综上就是两种选择,我们都已经分析完毕,也就是写出来了状态转移方程,可以进一步细化代码:
...@@ -117,21 +120,23 @@ return dp[N][W] ...@@ -117,21 +120,23 @@ return dp[N][W]
**最后一步,把伪码翻译成代码,处理一些边界情况** **最后一步,把伪码翻译成代码,处理一些边界情况**
我用 C++ 写的代码,把上面的思路完全翻译了一遍,并且处理了 `w - wt[i-1]` 可能小于 0 导致数组索引越界的问题: 我用 Java 写的代码,把上面的思路完全翻译了一遍,并且处理了 `w - wt[i-1]` 可能小于 0 导致数组索引越界的问题:
```cpp ```java
int knapsack(int W, int N, vector<int>& wt, vector<int>& val) { int knapsack(int W, int N, int[] wt, int[] val) {
// base case 已初始化 // base case 已初始化
vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0)); int[][] dp = new int[N + 1][W + 1];
for (int i = 1; i <= N; i++) { for (int i = 1; i <= N; i++) {
for (int w = 1; w <= W; w++) { for (int w = 1; w <= W; w++) {
if (w - wt[i-1] < 0) { if (w - wt[i - 1] < 0) {
// 这种情况下只能选择不装入背包 // 这种情况下只能选择不装入背包
dp[i][w] = dp[i - 1][w]; dp[i][w] = dp[i - 1][w];
} else { } else {
// 装入或者不装入背包,择优 // 装入或者不装入背包,择优
dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1], dp[i][w] = Math.max(
dp[i - 1][w]); dp[i - 1][w - wt[i-1]] + val[i-1],
dp[i - 1][w]
);
} }
} }
} }
...@@ -142,17 +147,30 @@ int knapsack(int W, int N, vector<int>& wt, vector<int>& val) { ...@@ -142,17 +147,30 @@ int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
至此,背包问题就解决了,相比而言,我觉得这是比较简单的动态规划问题,因为状态转移的推导比较自然,基本上你明确了 `dp` 数组的定义,就可以理所当然地确定状态转移了。 至此,背包问题就解决了,相比而言,我觉得这是比较简单的动态规划问题,因为状态转移的推导比较自然,基本上你明确了 `dp` 数组的定义,就可以理所当然地确定状态转移了。
接下来请阅读: 接下来可阅读:
* [背包问题变体之子集分割](https://labuladong.gitee.io/algo/) * [完全背包问题之零钱兑换](https://labuladong.github.io/article/fname.html?fname=背包零钱)
* [完全背包问题之零钱兑换](https://labuladong.gitee.io/algo/) * [背包问题变体之子集分割](https://labuladong.github.io/article/fname.html?fname=背包子集)
**_____________**
**刷算法,学套路,认准 labuladong,公众号和 [在线电子书](https://labuladong.gitee.io/algo/) 持续更新最新文章**
**本小抄即将出版,微信扫码关注公众号,后台回复「小抄」限时免费获取,回复「进群」可进刷题群一起刷题,带你搞定 LeetCode** <hr>
<details>
<summary><strong>引用本文的文章</strong></summary>
<p align='center'> - [扫描线技巧:安排会议室](https://labuladong.github.io/article/fname.html?fname=安排会议室)
<img src="../pictures/qrcode.jpg" width=200 > - [经典动态规划:子集背包问题](https://labuladong.github.io/article/fname.html?fname=背包子集)
</p> - [经典动态规划:完全背包问题](https://labuladong.github.io/article/fname.html?fname=背包零钱)
\ No newline at end of file - [经典回溯算法:集合划分问题](https://labuladong.github.io/article/fname.html?fname=集合划分)
</details><hr>
**_____________**
**《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「进群」可加入算法群;回复「PDF」可获取精华文章 PDF**
![](https://labuladong.github.io/algo/images/souyisou2.png)
此差异已折叠。
此差异已折叠。
此差异已折叠。
# 关于 Linux shell 你必须知道的技巧 # 关于 Linux shell 你必须知道的技巧
<p align='center'> <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://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://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>
...@@ -13,7 +9,7 @@ ...@@ -13,7 +9,7 @@
![](https://labuladong.github.io/algo/images/souyisou1.png) ![](https://labuladong.github.io/algo/images/souyisou1.png)
**通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。** **通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V1.9,[第 11 期刷题打卡挑战(9/19 开始)](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 开始报名。另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
...@@ -150,6 +146,20 @@ $ where connect.sh ...@@ -150,6 +146,20 @@ $ where connect.sh
$ sudo /home/fdl/bin/connect.sh $ sudo /home/fdl/bin/connect.sh
``` ```
<hr>
<details>
<summary><strong>引用本文的文章</strong></summary>
- [Linux 管道符原理大揭秘](https://labuladong.github.io/article/fname.html?fname=linux技巧3)
</details><hr>
**_____________** **_____________**
**《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「进群」可加入算法群;回复「PDF」可获取精华文章 PDF** **《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「进群」可加入算法群;回复「PDF」可获取精华文章 PDF**
......
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册