mirror of
https://github.com/labuladong/fucking-algorithm.git
synced 2025-07-04 11:22:59 +08:00
update content
This commit is contained in:
@ -81,6 +81,6 @@ int longestCommonSubsequence(String s1, String s2);
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=LCS) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/dynamic-programming/longest-common-subsequence/) 查看:
|
||||
|
||||

|
@ -146,7 +146,7 @@ bool dp(string& s, int i, string& p, int j);
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=动态规划之正则表达) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/dynamic-programming/regular-expression-matching/) 查看:
|
||||
|
||||

|
||||
|
||||
|
435
动态规划系列/单词拼接.md
435
动态规划系列/单词拼接.md
@ -34,437 +34,6 @@
|
||||
|
||||
本文讲解的两道题目也不是求最值的,但依然会把他们的解法称为动态规划解法,这里提前跟大家说下这里面的细节,免得细心的读者疑惑。其他不多说了,直接看题目吧。
|
||||
|
||||
### 单词拆分 I
|
||||
|
||||
首先看下力扣第 139 题「单词拆分」:
|
||||
|
||||
<Problem slug="word-break" />
|
||||
|
||||
函数签名如下:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
boolean wordBreak(String s, List<String> wordDict);
|
||||
```
|
||||
|
||||
这是一道非常高频的面试题,我们来思考下如何通过「遍历」和「分解问题」的思路来解决它。
|
||||
|
||||
**先说说「遍历」的思路,也就是用回溯算法解决本题**。回溯算法最经典的应用就是排列组合相关的问题了,不难发现这道题换个说法也可以变成一个排列问题:
|
||||
|
||||
现在给你一个不包含重复单词的单词列表 `wordDict` 和一个字符串 `s`,请你判断是否可以从 `wordDict` 中选出若干单词的排列(可以重复挑选)构成字符串 `s`。
|
||||
|
||||
这就是前文 [回溯算法秒杀排列组合问题的九种变体](https://labuladong.online/algo/essential-technique/permutation-combination-subset-all-in-one/) 中讲到的最后一种变体:元素无重可复选的排列问题,前文我写了一个 `permuteRepeat` 函数,代码如下:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
List<List<Integer>> res = new LinkedList<>();
|
||||
LinkedList<Integer> track = new LinkedList<>();
|
||||
|
||||
// 元素无重可复选的全排列
|
||||
public List<List<Integer>> permuteRepeat(int[] nums) {
|
||||
backtrack(nums);
|
||||
return res;
|
||||
}
|
||||
|
||||
// 回溯算法核心函数
|
||||
void backtrack(int[] nums) {
|
||||
// base case,到达叶子节点
|
||||
if (track.size() == nums.length) {
|
||||
// 收集根到叶子节点路径上的值
|
||||
res.add(new LinkedList(track));
|
||||
return;
|
||||
}
|
||||
|
||||
// 回溯算法标准框架
|
||||
for (int i = 0; i < nums.length; i++) {
|
||||
// 做选择
|
||||
track.add(nums[i]);
|
||||
// 进入下一层回溯树
|
||||
backtrack(nums);
|
||||
// 取消选择
|
||||
track.removeLast();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
给这个函数输入 `nums = [1,2,3]`,输出是 3^3 = 27 种可能的组合:
|
||||
|
||||
```java
|
||||
[
|
||||
[1,1,1],[1,1,2],[1,1,3],[1,2,1],[1,2,2],[1,2,3],[1,3,1],[1,3,2],[1,3,3],
|
||||
[2,1,1],[2,1,2],[2,1,3],[2,2,1],[2,2,2],[2,2,3],[2,3,1],[2,3,2],[2,3,3],
|
||||
[3,1,1],[3,1,2],[3,1,3],[3,2,1],[3,2,2],[3,2,3],[3,3,1],[3,3,2],[3,3,3]
|
||||
]
|
||||
```
|
||||
|
||||
这段代码实际上就是遍历一棵高度为 `N + 1` 的满 `N` 叉树(`N` 为 `nums` 的长度),其中根到叶子的每条路径上的元素就是一个排列结果:
|
||||
|
||||

|
||||
|
||||
类比一下,本文讲的这道题也有异曲同工之妙,假设 `wordDict = ["a", "aa", "ab"], s = "aaab"`,想用 `wordDict` 中的单词拼出 `s`,其实也面对着类似的一棵 `M` 叉树,`M` 为 `wordDict` 中单词的个数,**你需要做的就是站在回溯树的每个节点上,看看哪个单词能够匹配 `s[i..]` 的前缀,从而判断应该往哪条树枝上走**:
|
||||
|
||||

|
||||
|
||||
然后,按照前文 [回溯算法框架详解](https://labuladong.online/algo/essential-technique/backtrack-framework/) 所说,你把 `backtrack` 函数理解成在回溯树上游走的一个指针,维护每个节点上的变量 `i`,即可遍历整棵回溯树,寻找出匹配 `s` 的组合。
|
||||
|
||||
回溯算法解法代码如下:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
List<String> wordDict;
|
||||
// 记录是否找到一个合法的答案
|
||||
boolean found = false;
|
||||
// 记录回溯算法的路径
|
||||
LinkedList<String> track = new LinkedList<>();
|
||||
|
||||
// 主函数
|
||||
public boolean wordBreak(String s, List<String> wordDict) {
|
||||
this.wordDict = wordDict;
|
||||
// 执行回溯算法穷举所有可能的组合
|
||||
backtrack(s, 0);
|
||||
return found;
|
||||
}
|
||||
|
||||
// 回溯算法框架
|
||||
void backtrack(String s, int i) {
|
||||
// base case
|
||||
if (found) {
|
||||
// 如果已经找到答案,就不要再递归搜索了
|
||||
return;
|
||||
}
|
||||
if (i == s.length()) {
|
||||
// 整个 s 都被匹配完成,找到一个合法答案
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 回溯算法框架
|
||||
for (String word : wordDict) {
|
||||
// 看看哪个单词能够匹配 s[i..] 的前缀
|
||||
int len = word.length();
|
||||
if (i + len <= s.length()
|
||||
&& s.substring(i, i + len).equals(word)) {
|
||||
// 找到一个单词匹配 s[i..i+len)
|
||||
// 做选择
|
||||
track.addLast(word);
|
||||
// 进入回溯树的下一层,继续匹配 s[i+len..]
|
||||
backtrack(s, i + len);
|
||||
// 撤销选择
|
||||
track.removeLast();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段代码就是严格按照回溯算法框架写出来的,应该不难理解,但这段代码无法通过所有测试用例,我们按照之前 [算法时空复杂度使用指南](https://labuladong.online/algo/essential-technique/complexity-analysis/) 中讲到的方法来分析一下它的时间复杂度。
|
||||
|
||||
递归函数的时间复杂度的粗略估算方法就是用递归函数调用次数(递归树的节点数) x 递归函数本身的复杂度。对于这道题来说,递归树的每个节点其实就是对 `s` 进行的一次切割,那么最坏情况下 `s` 能有多少种切割呢?长度为 `N` 的字符串 `s` 中共有 `N - 1` 个「缝隙」可供切割,每个缝隙可以选择「切」或者「不切」,所以 `s` 最多有 `O(2^N)` 种切割方式,即递归树上最多有 `O(2^N)` 个节点。
|
||||
|
||||
当然,实际情况可定会好一些,毕竟存在剪枝逻辑,但从最坏复杂度的角度来看,递归树的节点个数确实是指数级别的。
|
||||
|
||||
那么 `backtrack` 函数本身的时间复杂度是多少呢?主要的时间消耗是遍历 `wordDict` 寻找匹配 `s[i..]` 的前缀的单词:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
// 遍历 wordDict 的所有单词
|
||||
for (String word : wordDict) {
|
||||
// 看看哪个单词能够匹配 s[i..] 的前缀
|
||||
int len = word.length();
|
||||
if (i + len <= s.length()
|
||||
&& s.substring(i, i + len).equals(word)) {
|
||||
// 找到一个单词匹配 s[i..i+len)
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
设 `wordDict` 的长度为 `M`,字符串 `s` 的长度为 `N`,那么这段代码的最坏时间复杂度是 `O(MN)`(for 循环 `O(M)`,Java 的 `substring` 方法 `O(N)`),所以总的时间复杂度是 `O(2^N * MN)`。
|
||||
|
||||
这里顺便说一个细节优化,其实你也可以反过来,通过穷举 `s[i..]` 的前缀去判断 `wordDict` 中是否有对应的单词:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
// 注意,要转化成哈希集合,提高 contains 方法的效率
|
||||
HashSet<String> wordDict = new HashSet<>(wordDict);
|
||||
|
||||
// 遍历 s[i..] 的所有前缀
|
||||
for (int len = 1; i + len <= s.length(); len++) {
|
||||
// 看看 wordDict 中是否有单词能匹配 s[i..] 的前缀
|
||||
String prefix = s.substring(i, i + len);
|
||||
if (wordDict.contains(prefix)) {
|
||||
// 找到一个单词匹配 s[i..i+len)
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段代码和刚才那段代码的结果是一样的,但这段代码的时间复杂度变成了 `O(N^2)`,和刚才的代码不同。
|
||||
|
||||
到底哪样子好呢?这要看题目给的数据范围。本题说了 `1 <= s.length <= 300, 1 <= wordDict.length <= 1000`,所以 `O(N^2)` 的结果较小,这段代码的实际运行效率应该稍微高一些,这个是一个细节的优化,你可以自己做一下,我就不写了。
|
||||
|
||||
不过即便你优化这段代码,总的时间复杂度依然是指数级的 `O(2^N * N^2)`,是无法通过所有测试用例的,那么问题出在哪里呢?
|
||||
|
||||
比如输入 `wordDict = ["a", "aa"], s = "aaab"`,算法无法找到一个可行的组合,所以一定会遍历整棵回溯树,但你注意这里面会存在重复的情况:
|
||||
|
||||

|
||||
|
||||
图中标红的这两部分,虽然经历了不同的切分,但是切分得出的结果是相同的,所以这两个节点下面的子树也是重复的,即存在冗余计算,极端情况下会消耗大量时间。
|
||||
|
||||
**如何消除冗余计算呢?这就要稍微转变一下思维模式,用「分解问题」的思维模式来考虑这道题**。
|
||||
|
||||
我们刚才以排列组合的视角思考这个问题,现在我们换一种视角,思考一下是否能够把原问题分解成规模更小,结构相同的子问题,然后通过子问题的结果计算原问题的结果。
|
||||
|
||||
对于输入的字符串 `s`,如果我能够从单词列表 `wordDict` 中找到一个单词匹配 `s` 的前缀 `s[0..k]`,那么只要我能拼出 `s[k+1..]`,就一定能拼出整个 `s`。换句话说,我把规模较大的原问题 `wordBreak(s[0..])` 分解成了规模较小的子问题 `wordBreak(s[k+1..])`,然后通过子问题的解反推出原问题的解。
|
||||
|
||||
有了这个思路就可以定义一个 `dp` 函数,并给出该函数的定义:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
// 定义:返回 s[i..] 是否能够被拼出
|
||||
int dp(String s, int i);
|
||||
|
||||
// 计算整个 s 是否能被拼出,调用 dp(s, 0)
|
||||
```
|
||||
|
||||
有了这个函数定义,就可以把刚才的逻辑大致翻译成伪码:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
List<String> wordDict;
|
||||
|
||||
// 定义:返回 s[i..] 是否能够被拼出
|
||||
int dp(String s, int i) {
|
||||
// base case,s[i..] 是空串
|
||||
if (i == s.length()) {
|
||||
return true;
|
||||
}
|
||||
// 遍历 wordDict,看看哪些单词是 s[i..] 的前缀
|
||||
for (Strnig word : wordDict) {
|
||||
if word 是 s[i..] 的前缀 {
|
||||
int len = word.length();
|
||||
// 只要 s[i+len..] 可以被拼出,s[i..] 就能被拼出
|
||||
if (dp(s, i + len) == true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 所有单词都尝试过,无法拼出整个 s
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
类似之前讲的回溯算法,`dp` 函数中的 for 循环也可以优化一下:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
// 注意,用哈希集合快速判断元素是否存在
|
||||
HashSet<String> wordDict;
|
||||
|
||||
// 定义:返回 s[i..] 是否能够被拼出
|
||||
int dp(String s, int i) {
|
||||
// base case,s[i..] 是空串
|
||||
if (i == s.length()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 遍历 s[i..] 的所有前缀,看看哪些前缀存在 wordDict 中
|
||||
for (int len = 1; i + len <= s.length(); len++) {
|
||||
if wordDict 中存在 s[i..len) {
|
||||
// 只要 s[i+len..] 可以被拼出,s[i..] 就能被拼出
|
||||
if (dp(s, i + len) == true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 所有单词都尝试过,无法拼出整个 s
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
对于这个 `dp` 函数,指针 `i` 的位置就是「状态」,所以我们可以通过添加备忘录的方式优化效率,避免对相同的子问题进行冗余计算。最终的解法代码如下:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
// 用哈希集合方便快速判断是否存在
|
||||
HashSet<String> wordDict;
|
||||
// 备忘录,-1 代表未计算,0 代表无法凑出,1 代表可以凑出
|
||||
int[] memo;
|
||||
|
||||
// 主函数
|
||||
public boolean wordBreak(String s, List<String> wordDict) {
|
||||
// 转化为哈希集合,快速判断元素是否存在
|
||||
this.wordDict = new HashSet<>(wordDict);
|
||||
// 备忘录初始化为 -1
|
||||
this.memo = new int[s.length()];
|
||||
Arrays.fill(memo, -1);
|
||||
return dp(s, 0);
|
||||
}
|
||||
|
||||
// 定义:s[i..] 是否能够被拼出
|
||||
boolean dp(String s, int i) {
|
||||
// base case
|
||||
if (i == s.length()) {
|
||||
return true;
|
||||
}
|
||||
// 防止冗余计算
|
||||
if (memo[i] != -1) {
|
||||
return memo[i] == 0 ? false : true;
|
||||
}
|
||||
|
||||
// 遍历 s[i..] 的所有前缀
|
||||
for (int len = 1; i + len <= s.length(); len++) {
|
||||
// 看看哪些前缀存在 wordDict 中
|
||||
String prefix = s.substring(i, i + len);
|
||||
if (wordDict.contains(prefix)) {
|
||||
// 找到一个单词匹配 s[i..i+len)
|
||||
// 只要 s[i+len..] 可以被拼出,s[i..] 就能被拼出
|
||||
boolean subProblem = dp(s, i + len);
|
||||
if (subProblem == true) {
|
||||
memo[i] = 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// s[i..] 无法被拼出
|
||||
memo[i] = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<visual slug='word-break'/>
|
||||
|
||||
这个解法能够通过所有测试用例,我们根据 [算法时空复杂度使用指南](https://labuladong.online/algo/essential-technique/complexity-analysis/) 来算一下它的时间复杂度:
|
||||
|
||||
因为有备忘录的辅助,消除了递归树上的重复节点,使得递归函数的调用次数从指数级别降低为状态的个数 `O(N)`,函数本身的复杂度还是 `O(N^2)`,所以总的时间复杂度是 `O(N^3)`,相较回溯算法的效率有大幅提升。
|
||||
|
||||
### 单词拆分 II
|
||||
|
||||
有了上一道题的铺垫,力扣第 140 题「单词拆分 II」就容易多了,先看下题目:
|
||||
|
||||
<Problem slug="word-break-ii" />
|
||||
|
||||
相较上一题,这道题不是单单问你 `s` 是否能被拼出,还要问你是怎么拼的,其实只要把之前的解法稍微改一改就可以解决这道题。
|
||||
|
||||
上一道题的回溯算法维护一个 `found` 变量,只要找到一种拼接方案就提前结束遍历回溯树,那么在这道题中我们不要提前结束遍历,并把所有可行的拼接方案收集起来就能得到答案:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
// 记录结果
|
||||
List<String> res = new LinkedList<>();
|
||||
// 记录回溯算法的路径
|
||||
LinkedList<String> track = new LinkedList<>();
|
||||
List<String> wordDict;
|
||||
|
||||
// 主函数
|
||||
public List<String> wordBreak(String s, List<String> wordDict) {
|
||||
this.wordDict = wordDict;
|
||||
// 执行回溯算法穷举所有可能的组合
|
||||
backtrack(s, 0);
|
||||
return res;
|
||||
}
|
||||
|
||||
// 回溯算法框架
|
||||
void backtrack(String s, int i) {
|
||||
// base case
|
||||
if (i == s.length()) {
|
||||
// 找到一个合法组合拼出整个 s,转化成字符串
|
||||
res.add(String.join(" ", track));
|
||||
return;
|
||||
}
|
||||
|
||||
// 回溯算法框架
|
||||
for (String word : wordDict) {
|
||||
// 看看哪个单词能够匹配 s[i..] 的前缀
|
||||
int len = word.length();
|
||||
if (i + len <= s.length()
|
||||
&& s.substring(i, i + len).equals(word)) {
|
||||
// 找到一个单词匹配 s[i..i+len)
|
||||
// 做选择
|
||||
track.addLast(word);
|
||||
// 进入回溯树的下一层,继续匹配 s[i+len..]
|
||||
backtrack(s, i + len);
|
||||
// 撤销选择
|
||||
track.removeLast();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个解法的时间复杂度和前一道题类似,依然是 `O(2^N * MN)`,但由于这道题给的数据规模较小,所以可以通过所有测试用例。
|
||||
|
||||
类似的,这个问题也可以用分解问题的思维解决,把上一道题的 `dp` 函数稍作修改即可:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
HashSet<String> wordDict;
|
||||
// 备忘录
|
||||
List<String>[] memo;
|
||||
|
||||
public List<String> wordBreak(String s, List<String> wordDict) {
|
||||
this.wordDict = new HashSet<>(wordDict);
|
||||
memo = new List[s.length()];
|
||||
return dp(s, 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 定义:返回用 wordDict 构成 s[i..] 的所有可能
|
||||
List<String> dp(String s, int i) {
|
||||
List<String> res = new LinkedList<>();
|
||||
if (i == s.length()) {
|
||||
res.add("");
|
||||
return res;
|
||||
}
|
||||
// 防止冗余计算
|
||||
if (memo[i] != null) {
|
||||
return memo[i];
|
||||
}
|
||||
|
||||
// 遍历 s[i..] 的所有前缀
|
||||
for (int len = 1; i + len <= s.length(); len++) {
|
||||
// 看看哪些前缀存在 wordDict 中
|
||||
String prefix = s.substring(i, i + len);
|
||||
if (wordDict.contains(prefix)) {
|
||||
// 找到一个单词匹配 s[i..i+len)
|
||||
List<String> subProblem = dp(s, i + len);
|
||||
// 构成 s[i+len..] 的所有组合加上 prefix
|
||||
// 就是构成构成 s[i] 的所有组合
|
||||
for (String sub : subProblem) {
|
||||
if (sub.isEmpty()) {
|
||||
// 防止多余的空格
|
||||
res.add(prefix);
|
||||
} else {
|
||||
res.add(prefix + " " + sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 存入备忘录
|
||||
memo[i] = res;
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<visual slug='word-break-ii'/>
|
||||
|
||||
这个解法依然用备忘录消除了重叠子问题,所以 `dp` 函数递归调用的次数减少为 `O(N)`,但 `dp` 函数本身的时间复杂度上升了,因为 `subProblem` 是一个子集列表,它的长度是指数级的。再加上 Java 中用 `+` 拼接字符串的效率并不高,且还要消耗备忘录去存储所有子问题的结果,所以这个算法的时间复杂度并不比回溯算法低,依然是指数级别。
|
||||
|
||||
综上,我们处理排列组合问题时一般使用回溯算法去「遍历」回溯树,而不用「分解问题」的思路去处理,因为存储子问题的结果就需要大量的时间和空间,除非重叠子问题的数量较多的极端情况,否则得不偿失。
|
||||
|
||||
以上就是本文的全部内容,希望你能对回溯思路和分解问题的思路有更深刻的理解。
|
||||
|
||||
|
||||
|
||||
@ -472,6 +41,6 @@ class Solution {
|
||||
|
||||
**_____________**
|
||||
|
||||
**《labuladong 的算法笔记》已经出版,关注公众号查看详情;后台回复「**全家桶**」可下载配套 PDF 和刷题全家桶**:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/dynamic-programming/word-break/) 查看:
|
||||
|
||||

|
||||

|
@ -56,7 +56,7 @@
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=子序列问题模板) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/dynamic-programming/subsequence-problem/) 查看:
|
||||
|
||||

|
||||
|
||||
|
@ -69,7 +69,7 @@ int rob(int[] nums);
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=抢房子) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/dynamic-programming/house-robber/) 查看:
|
||||
|
||||

|
||||
|
||||
|
225
动态规划系列/状态压缩技巧.md
225
动态规划系列/状态压缩技巧.md
@ -15,227 +15,16 @@
|
||||
|
||||
**-----------**
|
||||
|
||||
写在最前面:状态压缩并不难,可以理解为一种投机取巧的办法去优化某些动态规划问题的空间复杂度。我个人认为状态压缩并不是必须掌握的技巧,如果你对这个技巧感兴趣,务必阅读并理解 [动态规划系列答疑篇](https://labuladong.online/algo/dynamic-programming/faq-summary/)。
|
||||
::: info 写在最前面
|
||||
|
||||
空间压缩并不难,可以理解为一种投机取巧的办法去优化某些动态规划问题的空间复杂度。我个人认为状态压缩并不是必须掌握的技巧,如果你对这个技巧感兴趣,需要先阅读并理解 [动态规划系列答疑篇](https://labuladong.online/algo/dynamic-programming/faq-summary/)。
|
||||
|
||||
:::
|
||||
|
||||
我们号之前写过十几篇动态规划文章,可以说动态规划技巧对于算法效率的提升非常可观,一般来说都能把指数级和阶乘级时间复杂度的算法优化成 O(N^2),堪称算法界的二向箔,把各路魑魅魍魉统统打成二次元。
|
||||
|
||||
但是,动态规划求解的过程中也是可以进行阶段性优化的,如果你认真观察某些动态规划问题的状态转移方程,就能够把它们解法的空间复杂度进一步降低,由 O(N^2) 降低到 O(N)。
|
||||
|
||||
::: note
|
||||
|
||||
之前我在本文中误用了「状态压缩」这个词,有读者指出「状态压缩」这个词的含义是把多个状态通过二进制运算用一个整数表示出来,从而减少 `dp` 数组的维度。而本文描述的优化方式是通过观察状态转移方程的依赖关系,从而减少 `dp` 数组的维度,确实和「状态压缩」有所区别。所以严谨起见,我把原来文章中的「状态压缩」都改为了「空间压缩」,避免名词的误用。
|
||||
|
||||
:::
|
||||
|
||||
能够使用空间压缩技巧的动态规划都是二维 `dp` 问题,**你看它的状态转移方程,如果计算状态 `dp[i][j]` 需要的都是 `dp[i][j]` 相邻的状态,那么就可以使用空间压缩技巧**,将二维的 `dp` 数组转化成一维,将空间复杂度从 O(N^2) 降低到 O(N)。
|
||||
|
||||
什么叫「和 `dp[i][j]` 相邻的状态」呢,比如前文 [最长回文子序列](https://labuladong.online/algo/dynamic-programming/subsequence-problem/) 中,最终的代码如下:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
int longestPalindromeSubseq(String s) {
|
||||
int n = s.length();
|
||||
// dp 数组全部初始化为 0
|
||||
int[][] dp = new int[n][n];
|
||||
// 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.charAt(i) == s.charAt(j)) {
|
||||
dp[i][j] = dp[i + 1][j - 1] + 2;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 整个 s 的最长回文子串长度
|
||||
return dp[0][n - 1];
|
||||
}
|
||||
```
|
||||
|
||||
::: tip
|
||||
|
||||
我们本文不探讨如何推状态转移方程,只探讨对二维 DP 问题进行空间压缩的技巧。技巧都是通用的,所以如果你没看过前文,不明白这段代码的逻辑也无妨,完全不会阻碍你学会空间压缩。
|
||||
|
||||
:::
|
||||
|
||||
你看我们对 `dp[i][j]` 的更新,其实只依赖于 `dp[i+1][j-1], dp[i][j-1], dp[i+1][j]` 这三个状态:
|
||||
|
||||

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

|
||||
|
||||
「投影」这个词应该比较形象吧,说白了就是希望让一维数组发挥原来二维数组的作用。
|
||||
|
||||
思路很直观,但是也有一个明显的问题,图中 `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] = Math.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` 数组中得到:
|
||||
|
||||
```java
|
||||
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] = Math.max(dp[j], dp[j - 1]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
因为 for 循环遍历 `i` 和 `j` 的顺序为从左向右,从下向上,所以可以发现,在更新一维 `dp` 数组的时候,`dp[i+1][j-1]` 会被 `dp[i][j-1]` 覆盖掉,图中标出了这四个位置被遍历到的次序:
|
||||
|
||||

|
||||
|
||||
**那么如果我们想得到 `dp[i+1][j-1]`,就必须在它被覆盖之前用一个临时变量 `temp` 把它存起来,并把这个变量的值保留到计算 `dp[i][j]` 的时候**。为了达到这个目的,结合上图,我们可以这样写代码:
|
||||
|
||||
```java
|
||||
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] = Math.max(dp[j], dp[j - 1]);
|
||||
// 到下一轮循环,pre 就是 dp[i+1][j-1] 了
|
||||
pre = temp;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
别小看这段代码,这是一维 `dp` 最精妙的地方,会者不难,难者不会。为了清晰起见,我用具体的数值来拆解这个逻辑:
|
||||
|
||||
假设现在 `i = 5, j = 7` 且 `s[5] == s[7]`,那么现在会进入下面这个逻辑对吧:
|
||||
|
||||
```cpp
|
||||
for (int i = 5; i--) {
|
||||
for (int j = 7; j++) {
|
||||
if (s[5] == s[7])
|
||||
// dp[5][7] = dp[i+1][j-1] + 2;
|
||||
dp[7] = pre + 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
我问你这个 `pre` 变量是什么?是内层 for 循环上一次迭代的 `temp` 值。
|
||||
|
||||
那我再问你**内层** for 循环上一次迭代的 `temp` 值是什么?是 `dp[j-1]` 也就是 `dp[6]`,但请注意,这是**外层** for 循环**上一次迭代**对应的 `dp[6]`,不是现在的 `dp[6]`。
|
||||
|
||||
这个要对应二维数组的索引来理解。你现在的 `dp[6]` 是二维 `dp` 数组中的 `dp[i][6] = dp[5][6]`,而人家这个 `temp` 是二维 `dp` 数组中的 `dp[i+1][6] = dp[6][6]`。
|
||||
|
||||
也就是说,`pre` 变量就是 `dp[i+1][j-1] = dp[6][6]`,也就是我们想要的结果。
|
||||
|
||||
那么现在我们成功对状态转移方程进行了降维打击,算是最硬的的骨头啃掉了,但注意到我们还有 base case 要处理呀:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
// dp 数组全部初始化为 0
|
||||
int[][] dp = new int[n][n];
|
||||
// base case
|
||||
for (int i = 0; i < n; i++) {
|
||||
dp[i][i] = 1;
|
||||
}
|
||||
```
|
||||
|
||||
如何把 base case 也打成一维呢?很简单,记住空间压缩就是投影,我们把 base case 投影到一维看看:
|
||||
|
||||

|
||||
|
||||
二维 `dp` 数组中的 base case 全都落入了一维 `dp` 数组,不存在冲突和覆盖,所以说我们直接这样写代码就行了:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
// base case:一维 dp 数组全部初始化为 1
|
||||
int[] dp = new int[n];
|
||||
Arrays.fill(dp, 1);
|
||||
```
|
||||
|
||||
至此,我们把 base case 和状态转移方程都进行了降维,实际上已经写出完整代码了:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
int longestPalindromeSubseq(String s) {
|
||||
int n = s.length();
|
||||
// base case:一维 dp 数组全部初始化为 1
|
||||
int[] dp = new int[n];
|
||||
Arrays.fill(dp, 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.charAt(i) == s.charAt(j))
|
||||
dp[j] = pre + 2;
|
||||
else
|
||||
dp[j] = Math.max(dp[j], dp[j - 1]);
|
||||
pre = temp;
|
||||
}
|
||||
}
|
||||
return dp[n - 1];
|
||||
}
|
||||
```
|
||||
|
||||
本文就结束了,不过空间压缩技巧再牛逼,也是基于常规动态规划思路之上的。
|
||||
|
||||
你也看到了,使用空间压缩技巧对二维 `dp` 数组进行降维打击之后,解法代码的可读性变得非常差了,如果直接看这种解法,任何人都是一脸懵逼的。算法的优化就是这么一个过程,先写出可读性很好的暴力递归算法,然后尝试运用动态规划技巧优化重叠子问题,最后尝试用空间压缩技巧优化空间复杂度。
|
||||
|
||||
也就是说,你最起码能够熟练运用我们前文 [动态规划框架套路详解](https://labuladong.online/algo/essential-technique/dynamic-programming-framework/) 的套路找出状态转移方程,写出一个正确的动态规划解法,然后才有可能观察状态转移的情况,分析是否可能使用空间压缩技巧来优化。
|
||||
|
||||
希望读者能够稳扎稳打,层层递进,对于这种比较极限的优化,不做也罢。毕竟套路存于心,走遍天下都不怕!
|
||||
|
||||
|
||||
|
||||
<hr>
|
||||
@ -274,6 +63,6 @@ int longestPalindromeSubseq(String s) {
|
||||
|
||||
**_____________**
|
||||
|
||||
**《labuladong 的算法笔记》已经出版,关注公众号查看详情;后台回复「**全家桶**」可下载配套 PDF 和刷题全家桶**:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/dynamic-programming/space-optimization/) 查看:
|
||||
|
||||

|
||||

|
@ -67,7 +67,7 @@ int intervalSchedule(int[][] intvs);
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=贪心算法之区间调度问题) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/frequency-interview/interval-scheduling/) 查看:
|
||||
|
||||

|
||||
|
||||
|
@ -86,7 +86,7 @@
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=高楼扔鸡蛋问题) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/dynamic-programming/egg-drop/) 查看:
|
||||
|
||||

|
||||
|
||||
|
@ -59,6 +59,6 @@
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=刷题技巧) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/other-skills/tips-in-exam/) 查看:
|
||||
|
||||

|
@ -334,6 +334,6 @@ void levelTraverse(TreeNode root) {
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=dijkstra算法) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/data-structure/dijkstra/) 查看:
|
||||
|
||||

|
@ -124,7 +124,7 @@ class UF {
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=UnionFind算法详解) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/data-structure/union-find/) 查看:
|
||||
|
||||

|
||||
|
||||
|
@ -313,6 +313,7 @@ int missingNumber(int[] nums) {
|
||||
<summary><strong>引用本文的文章</strong></summary>
|
||||
|
||||
- [【强化练习】哈希表更多习题](https://labuladong.online/algo/problem-set/hash-table/)
|
||||
- [【强化练习】用「遍历」思维解题 I](https://labuladong.online/algo/problem-set/binary-tree-traverse-1/)
|
||||
- [一文秒杀所有丑数系列问题](https://labuladong.online/algo/frequency-interview/ugly-number-summary/)
|
||||
- [如何同时寻找缺失和重复的元素](https://labuladong.online/algo/frequency-interview/mismatch-set/)
|
||||
|
||||
@ -329,6 +330,7 @@ int missingNumber(int[] nums) {
|
||||
|
||||
| LeetCode | 力扣 |
|
||||
| :----: | :----: |
|
||||
| [1457. Pseudo-Palindromic Paths in a Binary Tree](https://leetcode.com/problems/pseudo-palindromic-paths-in-a-binary-tree/?show=1) | [1457. 二叉树中的伪回文路径](https://leetcode.cn/problems/pseudo-palindromic-paths-in-a-binary-tree/?show=1) |
|
||||
| [389. Find the Difference](https://leetcode.com/problems/find-the-difference/?show=1) | [389. 找不同](https://leetcode.cn/problems/find-the-difference/?show=1) |
|
||||
| - | [剑指 Offer 15. 二进制中1的个数](https://leetcode.cn/problems/er-jin-zhi-zhong-1de-ge-shu-lcof/?show=1) |
|
||||
|
||||
|
@ -146,6 +146,6 @@ int right_bound(int[] nums, int target) {
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/ds-class/shu-zu-lia-39fd9/er-fen-cha-b34e4) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/frequency-interview/binary-search-in-action/) 查看:
|
||||
|
||||

|
@ -111,6 +111,6 @@ int minMeetingRooms(int[][] meetings);
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=安排会议室) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/frequency-interview/scan-line-technique/) 查看:
|
||||
|
||||

|
463
高频面试系列/岛屿题目.md
463
高频面试系列/岛屿题目.md
@ -97,464 +97,7 @@ void dfs(int[][] grid, int i, int j, boolean[][] visited) {
|
||||
}
|
||||
```
|
||||
|
||||
这种写法无非就是用 for 循环处理上下左右的遍历罢了,你可以按照个人喜好选择写法。
|
||||
|
||||
### 岛屿数量
|
||||
|
||||
这是力扣第 200 题「岛屿数量」,最简单也是最经典的一道问题,题目会输入一个二维数组 `grid`,其中只包含 `0` 或者 `1`,`0` 代表海水,`1` 代表陆地,且假设该矩阵四周都是被海水包围着的。
|
||||
|
||||
我们说连成片的陆地形成岛屿,那么请你写一个算法,计算这个矩阵 `grid` 中岛屿的个数,函数签名如下:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
int numIslands(char[][] grid);
|
||||
```
|
||||
|
||||
比如说题目给你输入下面这个 `grid` 有四片岛屿,算法应该返回 4:
|
||||
|
||||

|
||||
|
||||
思路很简单,关键在于如何寻找并标记「岛屿」,这就要 DFS 算法发挥作用了,我们直接看解法代码:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
// 主函数,计算岛屿数量
|
||||
int numIslands(char[][] grid) {
|
||||
int res = 0;
|
||||
int m = grid.length, n = grid[0].length;
|
||||
// 遍历 grid
|
||||
for (int i = 0; i < m; i++) {
|
||||
for (int j = 0; j < n; j++) {
|
||||
if (grid[i][j] == '1') {
|
||||
// 每发现一个岛屿,岛屿数量加一
|
||||
res++;
|
||||
// 然后使用 DFS 将岛屿淹了
|
||||
dfs(grid, i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// 从 (i, j) 开始,将与之相邻的陆地都变成海水
|
||||
void dfs(char[][] grid, int i, int j) {
|
||||
int m = grid.length, n = grid[0].length;
|
||||
if (i < 0 || j < 0 || i >= m || j >= n) {
|
||||
// 超出索引边界
|
||||
return;
|
||||
}
|
||||
if (grid[i][j] == '0') {
|
||||
// 已经是海水了
|
||||
return;
|
||||
}
|
||||
// 将 (i, j) 变成海水
|
||||
grid[i][j] = '0';
|
||||
// 淹没上下左右的陆地
|
||||
dfs(grid, i + 1, j);
|
||||
dfs(grid, i, j + 1);
|
||||
dfs(grid, i - 1, j);
|
||||
dfs(grid, i, j - 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<visual slug='number-of-islands'/>
|
||||
|
||||
**为什么每次遇到岛屿,都要用 DFS 算法把岛屿「淹了」呢?主要是为了省事,避免维护 `visited` 数组**。
|
||||
|
||||
因为 `dfs` 函数遍历到值为 `0` 的位置会直接返回,所以只要把经过的位置都设置为 `0`,就可以起到不走回头路的作用。
|
||||
|
||||
::: tip
|
||||
|
||||
这类 DFS 算法还有个别名叫做 [FloodFill 算法](https://labuladong.online/algo/fname.html?fname=FloodFill算法详解及应用),现在有没有觉得 FloodFill 这个名字还挺贴切的~
|
||||
|
||||
:::
|
||||
|
||||
这个最最基本的算法问题就说到这,我们来看看后面的题目有什么花样。
|
||||
|
||||
### 封闭岛屿的数量
|
||||
|
||||
上一题说二维矩阵四周可以认为也是被海水包围的,所以靠边的陆地也算作岛屿。
|
||||
|
||||
力扣第 1254 题「统计封闭岛屿的数目」和上一题有两点不同:
|
||||
|
||||
1、用 `0` 表示陆地,用 `1` 表示海水。
|
||||
|
||||
2、让你计算「封闭岛屿」的数目。所谓「封闭岛屿」就是上下左右全部被 `1` 包围的 `0`,也就是说**靠边的陆地不算作「封闭岛屿」**。
|
||||
|
||||
函数签名如下:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
int closedIsland(int[][] grid)
|
||||
```
|
||||
|
||||
比如题目给你输入如下这个二维矩阵:
|
||||
|
||||

|
||||
|
||||
算法返回 2,只有图中灰色部分的 `0` 是四周全都被海水包围着的「封闭岛屿」。
|
||||
|
||||
**那么如何判断「封闭岛屿」呢?其实很简单,把上一题中那些靠边的岛屿排除掉,剩下的不就是「封闭岛屿」了吗**?
|
||||
|
||||
有了这个思路,就可以直接看代码了,注意这题规定 `0` 表示陆地,用 `1` 表示海水:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
// 主函数:计算封闭岛屿的数量
|
||||
int closedIsland(int[][] grid) {
|
||||
int m = grid.length, n = grid[0].length;
|
||||
for (int j = 0; j < n; j++) {
|
||||
// 把靠上边的岛屿淹掉
|
||||
dfs(grid, 0, j);
|
||||
// 把靠下边的岛屿淹掉
|
||||
dfs(grid, m - 1, j);
|
||||
}
|
||||
for (int i = 0; i < m; i++) {
|
||||
// 把靠左边的岛屿淹掉
|
||||
dfs(grid, i, 0);
|
||||
// 把靠右边的岛屿淹掉
|
||||
dfs(grid, i, n - 1);
|
||||
}
|
||||
// 遍历 grid,剩下的岛屿都是封闭岛屿
|
||||
int res = 0;
|
||||
for (int i = 0; i < m; i++) {
|
||||
for (int j = 0; j < n; j++) {
|
||||
if (grid[i][j] == 0) {
|
||||
res++;
|
||||
dfs(grid, i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// 从 (i, j) 开始,将与之相邻的陆地都变成海水
|
||||
void dfs(int[][] grid, int i, int j) {
|
||||
int m = grid.length, n = grid[0].length;
|
||||
if (i < 0 || j < 0 || i >= m || j >= n) {
|
||||
return;
|
||||
}
|
||||
if (grid[i][j] == 1) {
|
||||
// 已经是海水了
|
||||
return;
|
||||
}
|
||||
// 将 (i, j) 变成海水
|
||||
grid[i][j] = 1;
|
||||
// 淹没上下左右的陆地
|
||||
dfs(grid, i + 1, j);
|
||||
dfs(grid, i, j + 1);
|
||||
dfs(grid, i - 1, j);
|
||||
dfs(grid, i, j - 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<visual slug='number-of-closed-islands'/>
|
||||
|
||||
只要提前把靠边的陆地都淹掉,然后算出来的就是封闭岛屿了。
|
||||
|
||||
::: tip
|
||||
|
||||
处理这类岛屿题目除了 DFS/BFS 算法之外,Union Find 并查集算法也是一种可选的方法,前文 [Union Find 算法运用](https://labuladong.online/algo/data-structure/union-find/) 就用 Union Find 算法解决了一道类似的问题。
|
||||
|
||||
:::
|
||||
|
||||
这道岛屿题目的解法稍微改改就可以解决力扣第 1020 题「飞地的数量」,这题不让你求封闭岛屿的数量,而是求封闭岛屿的面积总和。
|
||||
|
||||
其实思路都是一样的,先把靠边的陆地淹掉,然后去数剩下的陆地数量就行了,注意第 1020 题中 `1` 代表陆地,`0` 代表海水:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
int numEnclaves(int[][] grid) {
|
||||
int m = grid.length, n = grid[0].length;
|
||||
// 淹掉靠边的陆地
|
||||
for (int i = 0; i < m; i++) {
|
||||
dfs(grid, i, 0);
|
||||
dfs(grid, i, n - 1);
|
||||
}
|
||||
for (int j = 0; j < n; j++) {
|
||||
dfs(grid, 0, j);
|
||||
dfs(grid, m - 1, j);
|
||||
}
|
||||
|
||||
// 数一数剩下的陆地
|
||||
int res = 0;
|
||||
for (int i = 0; i < m; i++) {
|
||||
for (int j = 0; j < n; j++) {
|
||||
if (grid[i][j] == 1) {
|
||||
res += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// 和之前的实现类似
|
||||
void dfs(int[][] grid, int i, int j) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<visual slug='number-of-enclaves'/>
|
||||
|
||||
篇幅所限,具体代码我就不写了,我们继续看其他的岛屿题目。
|
||||
|
||||
### 岛屿的最大面积
|
||||
|
||||
这是力扣第 695 题「岛屿的最大面积」,`0` 表示海水,`1` 表示陆地,现在不让你计算岛屿的个数了,而是让你计算最大的那个岛屿的面积,函数签名如下:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
int maxAreaOfIsland(int[][] grid)
|
||||
```
|
||||
|
||||
比如题目给你输入如下一个二维矩阵:
|
||||
|
||||

|
||||
|
||||
其中面积最大的是橘红色的岛屿,算法返回它的面积 6。
|
||||
|
||||
**这题的大体思路和之前完全一样,只不过 `dfs` 函数淹没岛屿的同时,还应该想办法记录这个岛屿的面积**。
|
||||
|
||||
我们可以给 `dfs` 函数设置返回值,记录每次淹没的陆地的个数,直接看解法吧:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
int maxAreaOfIsland(int[][] grid) {
|
||||
// 记录岛屿的最大面积
|
||||
int res = 0;
|
||||
int m = grid.length, n = grid[0].length;
|
||||
for (int i = 0; i < m; i++) {
|
||||
for (int j = 0; j < n; j++) {
|
||||
if (grid[i][j] == 1) {
|
||||
// 淹没岛屿,并更新最大岛屿面积
|
||||
res = Math.max(res, dfs(grid, i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// 淹没与 (i, j) 相邻的陆地,并返回淹没的陆地面积
|
||||
int dfs(int[][] grid, int i, int j) {
|
||||
int m = grid.length, n = grid[0].length;
|
||||
if (i < 0 || j < 0 || i >= m || j >= n) {
|
||||
// 超出索引边界
|
||||
return 0;
|
||||
}
|
||||
if (grid[i][j] == 0) {
|
||||
// 已经是海水了
|
||||
return 0;
|
||||
}
|
||||
// 将 (i, j) 变成海水
|
||||
grid[i][j] = 0;
|
||||
|
||||
return dfs(grid, i + 1, j)
|
||||
+ dfs(grid, i, j + 1)
|
||||
+ dfs(grid, i - 1, j)
|
||||
+ dfs(grid, i, j - 1) + 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<visual slug='max-area-of-island'/>
|
||||
|
||||
解法和之前相比差不多,我也不多说了,接下来的两道岛屿题目是比较有技巧性的,我们重点来看一下。
|
||||
|
||||
### 子岛屿数量
|
||||
|
||||
如果说前面的题目都是模板题,那么力扣第 1905 题「统计子岛屿」可能得动动脑子了:
|
||||
|
||||
<Problem slug="count-sub-islands" />
|
||||
|
||||
**这道题的关键在于,如何快速判断子岛屿**?肯定可以借助 [Union Find 并查集算法](https://labuladong.online/algo/data-structure/union-find/) 来判断,不过本文重点在 DFS 算法,就不展开并查集算法了。
|
||||
|
||||
什么情况下 `grid2` 中的一个岛屿 `B` 是 `grid1` 中的一个岛屿 `A` 的子岛?
|
||||
|
||||
当岛屿 `B` 中所有陆地在岛屿 `A` 中也是陆地的时候,岛屿 `B` 是岛屿 `A` 的子岛。
|
||||
|
||||
**反过来说,如果岛屿 `B` 中存在一片陆地,在岛屿 `A` 的对应位置是海水,那么岛屿 `B` 就不是岛屿 `A` 的子岛**。
|
||||
|
||||
那么,我们只要遍历 `grid2` 中的所有岛屿,把那些不可能是子岛的岛屿排除掉,剩下的就是子岛。
|
||||
|
||||
依据这个思路,可以直接写出下面的代码:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
int countSubIslands(int[][] grid1, int[][] grid2) {
|
||||
int m = grid1.length, n = grid1[0].length;
|
||||
for (int i = 0; i < m; i++) {
|
||||
for (int j = 0; j < n; j++) {
|
||||
if (grid1[i][j] == 0 && grid2[i][j] == 1) {
|
||||
// 这个岛屿肯定不是子岛,淹掉
|
||||
dfs(grid2, i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 现在 grid2 中剩下的岛屿都是子岛,计算岛屿数量
|
||||
int res = 0;
|
||||
for (int i = 0; i < m; i++) {
|
||||
for (int j = 0; j < n; j++) {
|
||||
if (grid2[i][j] == 1) {
|
||||
res++;
|
||||
dfs(grid2, i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// 从 (i, j) 开始,将与之相邻的陆地都变成海水
|
||||
void dfs(int[][] grid, int i, int j) {
|
||||
int m = grid.length, n = grid[0].length;
|
||||
if (i < 0 || j < 0 || i >= m || j >= n) {
|
||||
return;
|
||||
}
|
||||
if (grid[i][j] == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
grid[i][j] = 0;
|
||||
dfs(grid, i + 1, j);
|
||||
dfs(grid, i, j + 1);
|
||||
dfs(grid, i - 1, j);
|
||||
dfs(grid, i, j - 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<visual slug='count-sub-islands'/>
|
||||
|
||||
这道题的思路和计算「封闭岛屿」数量的思路有些类似,只不过后者排除那些靠边的岛屿,前者排除那些不可能是子岛的岛屿。
|
||||
|
||||
### 不同的岛屿数量
|
||||
|
||||
这是本文的最后一道岛屿题目,作为压轴题,当然是最有意思的。
|
||||
|
||||
力扣第 694 题「不同的岛屿数量」,题目还是输入一个二维矩阵,`0` 表示海水,`1` 表示陆地,这次让你计算 **不同的 (distinct)** 岛屿数量,函数签名如下:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
int numDistinctIslands(int[][] grid)
|
||||
```
|
||||
|
||||
比如题目输入下面这个二维矩阵:
|
||||
|
||||

|
||||
|
||||
其中有四个岛屿,但是左下角和右上角的岛屿形状相同,所以不同的岛屿共有三个,算法返回 3。
|
||||
|
||||
很显然我们得想办法把二维矩阵中的「岛屿」进行转化,变成比如字符串这样的类型,然后利用 HashSet 这样的数据结构去重,最终得到不同的岛屿的个数。
|
||||
|
||||
如果想把岛屿转化成字符串,说白了就是序列化,序列化说白了就是遍历嘛,前文 [二叉树的序列化和反序列化](https://labuladong.online/algo/data-structure/serialize-and-deserialize-binary-tree/) 讲了二叉树和字符串互转,这里也是类似的。
|
||||
|
||||
**首先,对于形状相同的岛屿,如果从同一起点出发,`dfs` 函数遍历的顺序肯定是一样的**。
|
||||
|
||||
因为遍历顺序是写死在你的递归函数里面的,不会动态改变:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
void dfs(int[][] grid, int i, int j) {
|
||||
// 递归顺序:
|
||||
dfs(grid, i - 1, j); // 上
|
||||
dfs(grid, i + 1, j); // 下
|
||||
dfs(grid, i, j - 1); // 左
|
||||
dfs(grid, i, j + 1); // 右
|
||||
}
|
||||
```
|
||||
|
||||
所以,遍历顺序从某种意义上说就可以用来描述岛屿的形状,比如下图这两个岛屿:
|
||||
|
||||

|
||||
|
||||
假设它们的遍历顺序是:
|
||||
|
||||
> 下,右,上,撤销上,撤销右,撤销下
|
||||
|
||||
如果我用分别用 `1, 2, 3, 4` 代表上下左右,用 `-1, -2, -3, -4` 代表上下左右的撤销,那么可以这样表示它们的遍历顺序:
|
||||
|
||||
> 2, 4, 1, -1, -4, -2
|
||||
|
||||
**你看,这就相当于是岛屿序列化的结果,只要每次使用 `dfs` 遍历岛屿的时候生成这串数字进行比较,就可以计算到底有多少个不同的岛屿了**。
|
||||
|
||||
::: info
|
||||
|
||||
细心的读者问到,为什么记录「撤销」操作才能唯一表示遍历顺序呢?不记录撤销操作好像也可以?实际上不是的。
|
||||
>
|
||||
> 比方说「下,右,撤销右,撤销下」和「下,撤销下,右,撤销右」显然是两个不同的遍历顺序,但如果不记录撤销操作,那么它俩都是「下,右」,成了相同的遍历顺序,显然是不对的。
|
||||
|
||||
:::
|
||||
|
||||
所以我们需要稍微改造 `dfs` 函数,添加一些函数参数以便记录遍历顺序:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
void dfs(int[][] grid, int i, int j, StringBuilder sb, int dir) {
|
||||
int m = grid.length, n = grid[0].length;
|
||||
if (i < 0 || j < 0 || i >= m || j >= n
|
||||
|| grid[i][j] == 0) {
|
||||
return;
|
||||
}
|
||||
// 前序遍历位置:进入 (i, j)
|
||||
grid[i][j] = 0;
|
||||
sb.append(dir).append(',');
|
||||
|
||||
dfs(grid, i - 1, j, sb, 1); // 上
|
||||
dfs(grid, i + 1, j, sb, 2); // 下
|
||||
dfs(grid, i, j - 1, sb, 3); // 左
|
||||
dfs(grid, i, j + 1, sb, 4); // 右
|
||||
|
||||
// 后序遍历位置:离开 (i, j)
|
||||
sb.append(-dir).append(',');
|
||||
}
|
||||
```
|
||||
|
||||
::: note
|
||||
|
||||
仔细看这个代码,在递归前做选择,在递归后撤销选择,它像不像 [回溯算法框架](https://labuladong.online/algo/essential-technique/backtrack-framework/)?实际上它就是回溯算法,因为它关注的是「树枝」(岛屿的遍历顺序),而不是「节点」(岛屿的每个格子)。
|
||||
|
||||
:::
|
||||
|
||||
`dir` 记录方向,`dfs` 函数递归结束后,`sb` 记录着整个遍历顺序。有了这个 `dfs` 函数就好办了,我们可以直接写出最后的解法代码:
|
||||
|
||||
<!-- muliti_language -->
|
||||
```java
|
||||
class Solution {
|
||||
public int numDistinctIslands(int[][] grid) {
|
||||
int m = grid.length, n = grid[0].length;
|
||||
// 记录所有岛屿的序列化结果
|
||||
HashSet<String> islands = new HashSet<>();
|
||||
for (int i = 0; i < m; i++) {
|
||||
for (int j = 0; j < n; j++) {
|
||||
if (grid[i][j] == 1) {
|
||||
// 淹掉这个岛屿,同时存储岛屿的序列化结果
|
||||
StringBuilder sb = new StringBuilder();
|
||||
// 初始的方向可以随便写,不影响正确性
|
||||
dfs(grid, i, j, sb, 666);
|
||||
islands.add(sb.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 不相同的岛屿数量
|
||||
return islands.size();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<visual slug='number-of-distinct-islands'/>
|
||||
|
||||
这样,这道题就解决了,至于为什么初始调用 `dfs` 函数时的 `dir` 参数可以随意写,因为这个 `dfs` 函数实际上是回溯算法,它关注的是「树枝」而不是「节点」,前文 [图算法基础](https://labuladong.online/algo/data-structure/graph-traverse/) 有写具体的区别,这里就不赘述了。
|
||||
|
||||
以上就是全部岛屿系列题目的解题思路,也许前面的题目大部分人会做,但是最后两题还是比较巧妙的,希望本文对你有帮助。
|
||||
这种写法无非就是用 for 循环处理上下左右的遍历罢了,你可以按照个人喜好选择写法。下面就按照上述框架结合可视化面板来解题。
|
||||
|
||||
|
||||
|
||||
@ -591,6 +134,6 @@ class Solution {
|
||||
|
||||
**_____________**
|
||||
|
||||
**《labuladong 的算法笔记》已经出版,关注公众号查看详情;后台回复「**全家桶**」可下载配套 PDF 和刷题全家桶**:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/frequency-interview/island-dfs-summary/) 查看:
|
||||
|
||||

|
||||

|
@ -72,7 +72,7 @@ O(N) 的时间复杂度遍历数组是无法避免的,所以我们可以想想
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=缺失和重复的元素) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/frequency-interview/mismatch-set/) 查看:
|
||||
|
||||

|
||||
|
||||
|
@ -68,6 +68,6 @@
|
||||
|
||||
**_____________**
|
||||
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/fname.html?fname=随机权重) 查看:
|
||||
本文为会员内容,请扫码关注公众号或 [点这里](https://labuladong.online/algo/frequency-interview/random-pick-with-weight/) 查看:
|
||||
|
||||

|
Reference in New Issue
Block a user