blog/content/post/算法题/dfs回溯.md
2025-05-08 01:00:35 +08:00

261 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: dfs回溯
description: dfs回溯问题组合问题、排列问题、回文子串分割的所有方案
date: 2025-05-07T01:23:00+08:00
slug: dfs回溯
# image: helena-hertz-wWZzXlDpMog-unsplash.jpg
categories:
- 算法题
tags: [
"dfs",
"回溯",
"递归",
"算法",
"回文串"
]
math: true
---
# 组合问题
## 问题描述
给定一个无重复元素的数组`num`和一整数`m`,返回所有可能的`m`个数的组合。
## 代码实现
咱们的目标是用深度优先搜索DFS配合回溯策略来找出所有可能的数字组合。
**`combination` 方法都干了啥:**
1. **准备一个`result`列表**: 这个列表 (`List<List<Integer>>`) 用来装所有找出来的组合。每个组合它自己也是一个整数列表。
2. **再准备一个`path`列表**: 这个列表 (`List<Integer>`) 临时记录咱们在DFS过程中当前正在凑的这个组合长啥样。
3. **调用`dfs`干活**: `dfs`是真正干搜索和凑组合的递归函数。
* `num`: 就是输入的那个原始数组。
* `m`: 咱们要凑的组合里有几个数。
* `0`: 从数组的第0个位置开始选数。
* `path`: 当前凑到一半的组合。
* `result`: 最终装结果的那个列表。
4. **返回`result`**: 等`dfs`跑完了,`result`里面就装满了所有大小为`m`的组合,把它返回就行。
**`dfs` (深度优先搜索) 是回溯算法的精髓所在。**
1. **啥时候停呢?(递归的出口)**:
* `if(path.size()==m)`: 如果当前`path`列表里的数字个数,正好等于咱们想要的组合大小`m`,那说明一个组合凑齐了。
* `result.add(new ArrayList<Integer>(path))`: 赶紧把这个凑好的`path`存到`result`里。**特别注意**:这里一定要 `new ArrayList<>(path)` 创建一个新的列表再存进去。为啥呢?因为`path`这个列表在后面的回溯步骤里还会被修改(删掉元素)。如果不创建副本,那`result`里存的所有组合都会指向同一个不断变化的`path`对象,最后结果就全乱套了。
* `return;`: 找到了一个组合,这条递归的路就走到头了,返回。
2. **递归探索和"反悔"(回溯)的过程**:
* `for(int i=start; i<num.length; i++)`: 这个循环呢,就是从数组`num``start`位置开始,一个一个往后看。`start`参数有啥用?
* 它保证在凑一个组合的时候,同一个数字不会被重复用。
* 它还能避免生成重复的组合。打个比方,如果我们已经考虑过以`num[0]`开头的组合了,那后面选数的时候,就没必要再回头去选`num[0]`之前的数来凑新组合了。因为那样凑出来的组合,只是顺序不一样(比如`[1,2]``[2,1]`,假设原数组是`[1,2,3,...]`),但组合本身是不讲究元素顺序的。
* `path.add(num[i])`: **做选择**:先把当前看到的`num[i]`这个数加到`path`里,表示"我选它了!"。
* `dfs(num, m, i+1, path, result)`: **继续往下走**:递归调用`dfs`,让它接着凑。
* 这里的`i+1`是关键!它告诉下一层`dfs`"你从我选的这个`num[i]`的**下一个**位置开始选数吧。" 这样做能确保组合里的数是按原数组里的顺序(或者说,索引是递增的)选出来的,自然也就避免了像`[1,2]``[2,1]`这样的重复组合。
* `path.remove(path.size()-1)`: **反悔操作(回溯)**:当上面那行`dfs`调用结束返回了,说明从`num[i]`开始、包含`m`个数的所有组合都已经找遍了。这时候,就得把刚才加入`path``num[i]`给删掉。这个操作就像是"撤销"了刚才的选择,程序退回到选`num[i]`之前的状态。这样,`for`循环的下一次迭代(`i++`)才能去尝试选`num[i]`后面的其他数字,从而探索其他的组合可能性。
**举个例子看看咋工作的:**
假设 `num = [1, 2, 3]`, `m = 2`
1. `combination([1,2,3], 2)` 会调用 `dfs([1,2,3], 2, 0, [], result)`
2. `dfs(start=0)`:
- `i=0` (选元素 `1`):
- `path.add(1)`,现在 `path = [1]`
- 调用 `dfs([1,2,3], 2, 1, [1], result)`
- `dfs(start=1)`:
- `i=1` (选元素 `2`):
- `path.add(2)`,现在 `path = [1, 2]`
- `path.size() == m` (2 == 2) 满足,于是 `result.add([1,2])`。这条路结束,返回。
- `path.removeLast()``path` 变回 `[1]` (回溯)
- `i=2` (选元素 `3`):
- `path.add(3)`,现在 `path = [1, 3]`
- `path.size() == m` (2 == 2) 满足,于是 `result.add([1,3])`。这条路结束,返回。
- `path.removeLast()``path` 变回 `[1]` (回溯)
- `i` 到头了 (`i=2` 是最后一个可选的)。`dfs(start=1)`结束,返回。
- `path.removeLast()``path` 变回 `[]` (回溯)
- `i=1` (选元素 `2`):
- `path.add(2)`,现在 `path = [2]`
- 调用 `dfs([1,2,3], 2, 2, [2], result)`
- `dfs(start=2)`:
- `i=2` (选元素 `3`):
- `path.add(3)`,现在 `path = [2, 3]`
- `path.size() == m` (2 == 2) 满足,于是 `result.add([2,3])`。这条路结束,返回。
- `path.removeLast()``path` 变回 `[2]` (回溯)
- `i` 到头了。`dfs(start=2)`结束,返回。
- `path.removeLast()``path` 变回 `[]` (回溯)
- `i=2` (选元素 `3`):
- `path.add(3)`,现在 `path = [3]`
- 调用 `dfs([1,2,3], 2, 3, [3], result)`
- `dfs(start=3)`:
- `path.size()` (1) 不等于 `m` (2)。
- `for` 循环条件 `i < num.length` (3 < 3) 不满足循环不执行`dfs(start=3)`结束返回
- `path.removeLast()``path` 变回 `[]` (回溯)
- `i` 到头了`dfs(start=0)`结束返回
3. `combination` 方法返回 `result`里面装着 `[[1,2], [1,3], [2,3]]`
```java
// 组合
public static List<List<Integer>> combination(int[] num, int m){
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new ArrayList<>();
dfs(num,m,0,path,result);
return result;
}
// 深度优先搜索
public static void dfs(int[] num, int m, int start, List<Integer> path, List<List<Integer>> result) {
if(path.size()==m) {
result.add(new ArrayList<Integer>(path));
return;
}
for(int i=start;i<num.length;i++) {
path.add(num[i]);
dfs(num, m, i+1, path, result);
path.remove(path.size()-1);
}
}
```
## 复杂度分析
- 时间复杂度$O(C(n,m)\cdot m)$
- 空间复杂度$O(m)$
# 排列问题
## 问题描述
给定一个无重复元素的数组`num`和一整数`m`返回所有可能的`m`个数的排列
## 代码实现
这里与组合数不同我们不需要start参数因为排列问题中每个位置的元素都可以是任意一个未被选过的元素作为替代我们使用一个`visited`数组来跟踪每个元素是否已经被使用过
主要的步骤和组合数一样都是先在`path`中添加元素然后递归调用`backtrack`最后回溯`path`中删除元素不过注意在添加元素前先把`visited`数组中对应的元素设置为`true`表示这个元素已经被使用过然后回溯时再把它设置为`false`表示这个元素没有被使用过
```java
// 排列
public static List<List<Integer>> permutation(int[] num, int m){
boolean[] visited = new boolean[num.length];
List<Integer> path = new ArrayList<>();
List<List<Integer>> result = new ArrayList<>();
backtrack(num,m,visited,path,result);
return result;
}
// 回溯
public static void backtrack(int[] num, int m, boolean[] visited, List<Integer> path, List<List<Integer>> result) {
if(m==path.size()) {
result.add(new ArrayList<>(path));
return;
}
for(int i=0;i<num.length;i++) {
if(visited[i]) continue;
visited[i] = true;
path.add(num[i]);
backtrack(num, m, visited, path, result);
path.remove(path.size()-1);
visited[i] = false;
}
}
```
## 复杂度分析
- 时间复杂度$O(P(n,m)\cdot m)$
- 空间复杂度$O(m)$
# 经典例题:分割回文串
## 问题描述
给定一个字符串`s`返回所有可能的分割方式使得每个子串都是回文串这里认为单个字符也是回文串
## 代码实现
这个问题咱们同样用回溯法或者叫深度优先搜索DFS来解决目标就是把一个字符串 `s` 切割成好几段而且每一段都必须是个回文串回溯算法呢就是通过在字符串的不同位置尝试"剪一刀"来找出所有可能的切割方案
**主要的思路是这样的:**
* 想象咱们现在站在字符串的某个位置 `start`
* 从这个 `start` 位置开始往后瞅试试切下来一段子串
* 看看切下来的这段子串是不是回文的
* 要是回文的太好了把它记到咱们当前的路径 `path` 然后呢对剩下还没切的那部分字符串从刚才切完的地方往后重复一样的操作
* 等从更深一层的探索回来之后就得"反悔"一下把刚才记到 `path` 里的那段子串删掉这样才能试试别的切法比如从同一个 `start` 位置开始切一个更长点的回文串出来
**下面来看看 `dfs` 函数和它的小助手 `isPalindrome` 是怎么干活的:**
1. **`dfs(String s, int start, List<String> path, List<List<String>> results)` 这个函数是核心**
* `s`就是咱们要切的那个原字符串
* `start`这是个数字告诉我们当前要从字符串 `s` 的第几个字符开始找下一段回文子串
* `path`一个临时的 `List<String>`像个小本本记录着咱们当前这一趟切下来的一串回文子串是哪些
* `results`一个 `List<List<String>>`这是最终的成果篮子所有成功的切法每一套切法都是一个回文子串列表都会装到这里面
2. **啥时候算切完了呢?(递归的出口)**
* `start` 这个数字等于字符串 `s` 的总长度 `s.length()` 的时候就说明咱们已经从头到尾把整个字符串 `s` 都成功切成了回文串啦
* 这时候`path` 小本本里记的就是一套完整的成功的切割方案赶紧把它存到 `results` 成果篮子里。**特别注意**这里一定要 `new ArrayList<>(path)` 创建一个新的列表再存进去为啥呢因为`path`这个小本本在后面的回溯步骤里还会被修改删掉里面的子串)。如果不创建副本`results`里存的所有方案都会指向同一个不断变化的`path`对象最后结果就全乱套了
3. **探索和"反悔"(回溯)的过程是咋样的**
* 函数里有个 `for` 循环它会从当前的 `start` 位置开始一直到字符串末尾尝试所有可能的切割点 `i`
* 对于每一个可能的切割点 `i`咱们就先切出一段子串 `substr = s.substring(start, i + 1)`这段 `substr` 就是从当前 `start` 位置开始 `i` 位置包括 `i` 本身结束的这么一段
* **第一步,判断一下是不是回文**喊小助手 `isPalindrome(substr)` 来帮忙看看这段 `substr` 是不是个回文串
* **如果 `substr` 确实是回文串,那接下来**
* **做选择 (选它!)**把它加到咱们的 `path` 小本本里`path.add(substr)`
* **继续往下切 (深入探索)**递归调用 `dfs(s, i + 1, path, results)`注意新的起始点变成了 `i + 1`因为 `substr` 已经把从 `start` `i` 这些字符给占了下一次就从 `i` 的下一个位置开始切
* **反悔操作 (撤销选择/回溯)**当上面那行 `dfs` 调用结束返回了说明基于当前这个 `substr` 开头的所有后续切法都已经试完了为了能试试其他的可能性比如从同一个 `start` 位置开始尝试切一个更长的回文子串或者在更早的切割步骤里选择一个不一样的初始子串咱们就得把刚才加入 `path` 的那个 `substr` 给删掉`path.remove(path.size() - 1)`这个操作就像是"撤销"了刚才的选择程序退回到选 `substr` 之前的状态
4. **`isPalindrome(String s)`**
* 这个函数很简单就是专门判断一个字符串 `s` 是不是回文的
* 我们用"双指针"的方法一个指针指着字符串的开头另一个指着字符串的末尾两个指针同时往中间跑一边跑一边比较它们指着的字符是不是一样如果跑到中间或者相遇了所有对应位置的字符都一样那这个字符串就是回文的
* 题目通常会说空字符串或单个字符的串也算回文——代码里也通常会处理这种情况)。
通过这种"一路切下去不合适就退回来换条路再切"的深度优先搜索和回溯机制这个算法就能把所有可能的切割方式都系统地试一遍然后把那些每一段都是回文串的成功方案都收集起来
```java
public static List<List<String>> divide(String s){
List<String> path = new ArrayList<>();
List<List<String>> results = new ArrayList<>();
dfs(s, 0, path, results);
return results;
}
public static void dfs(String s, int start, List<String> path, List<List<String>> results) {
if(start == s.length()) { // 此时start应该已经移到字符串末字符的下一个位置了所以不是length-1
results.add(new ArrayList<>(path)); // 必须添加一个副本而不是引用本身
}
for(int i=start;i<s.length();i++) {
String substr = s.substring(start, i+1);
if(isPalindrome(substr)) {
path.add(substr);
dfs(s, i+1, path, results);
path.remove(path.size()-1);
}
}
}
public static boolean isPalindrome(String s) {
// 空字符串
if (s == null || s.length() == 0) {
return true;
}
int start = 0, end = s.length() - 1;
while (start < end) {
if (s.charAt(start) != s.charAt(end)) {
return false;
}
start++;
end--;
}
return true;
}
```
## 复杂度分析
- 时间复杂度通常$O(2^n)$
- 空间复杂度$O(n)$、$O(n^2)$取决于字符串的存储方式