14 KiB
title | description | date | slug | categories | tags | math | ||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
dfs回溯 | dfs回溯问题:组合问题、排列问题、回文子串分割的所有方案 | 2025-05-07T01:23:00+08:00 | dfs回溯 |
|
|
true |
组合问题
问题描述
给定一个无重复元素的数组num
和一整数m
,返回所有可能的m
个数的组合。
代码实现
咱们的目标是用深度优先搜索(DFS)配合回溯策略,来找出所有可能的数字组合。
combination
方法都干了啥:
- 准备一个
result
列表: 这个列表 (List<List<Integer>>
) 用来装所有找出来的组合。每个组合它自己也是一个整数列表。 - 再准备一个
path
列表: 这个列表 (List<Integer>
) 临时记录咱们在DFS过程中,当前正在凑的这个组合长啥样。 - 调用
dfs
干活:dfs
是真正干搜索和凑组合的递归函数。num
: 就是输入的那个原始数组。m
: 咱们要凑的组合里有几个数。0
: 从数组的第0个位置开始选数。path
: 当前凑到一半的组合。result
: 最终装结果的那个列表。
- 返回
result
: 等dfs
跑完了,result
里面就装满了所有大小为m
的组合,把它返回就行。
dfs
(深度优先搜索) 是回溯算法的精髓所在。
-
啥时候停呢?(递归的出口):
if(path.size()==m)
: 如果当前path
列表里的数字个数,正好等于咱们想要的组合大小m
,那说明一个组合凑齐了。result.add(new ArrayList<Integer>(path))
: 赶紧把这个凑好的path
存到result
里。特别注意:这里一定要new ArrayList<>(path)
创建一个新的列表再存进去。为啥呢?因为path
这个列表在后面的回溯步骤里还会被修改(删掉元素)。如果不创建副本,那result
里存的所有组合都会指向同一个不断变化的path
对象,最后结果就全乱套了。return;
: 找到了一个组合,这条递归的路就走到头了,返回。
-
递归探索和"反悔"(回溯)的过程:
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
。
combination([1,2,3], 2)
会调用dfs([1,2,3], 2, 0, [], result)
。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)
结束,返回。
combination
方法返回result
,里面装着[[1,2], [1,3], [2,3]]
。
// 组合
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
,表示这个元素没有被使用过。
// 排列
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
是怎么干活的:
-
dfs(String s, int start, List<String> path, List<List<String>> results)
这个函数是核心:s
:就是咱们要切的那个原字符串。start
:这是个数字,告诉我们当前要从字符串s
的第几个字符开始找下一段回文子串。path
:一个临时的List<String>
,像个小本本,记录着咱们当前这一趟切下来的一串回文子串是哪些。results
:一个List<List<String>>
,这是最终的成果篮子,所有成功的切法(每一套切法都是一个回文子串列表)都会装到这里面。
-
啥时候算切完了呢?(递归的出口):
- 当
start
这个数字等于字符串s
的总长度s.length()
的时候,就说明咱们已经从头到尾把整个字符串s
都成功切成了回文串啦! - 这时候,
path
小本本里记的就是一套完整的、成功的切割方案。赶紧把它存到results
成果篮子里。特别注意:这里一定要new ArrayList<>(path)
创建一个新的列表再存进去。为啥呢?因为path
这个小本本在后面的回溯步骤里还会被修改(删掉里面的子串)。如果不创建副本,那results
里存的所有方案都会指向同一个不断变化的path
对象,最后结果就全乱套了。
- 当
-
探索和"反悔"(回溯)的过程是咋样的:
- 函数里有个
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
之前的状态。
- 做选择 (选它!):把它加到咱们的
- 函数里有个
-
isPalindrome(String s)
:- 这个函数很简单,就是专门判断一个字符串
s
是不是回文的。 - 我们用"双指针"的方法:一个指针指着字符串的开头,另一个指着字符串的末尾。两个指针同时往中间跑,一边跑一边比较它们指着的字符是不是一样。如果跑到中间或者相遇了,所有对应位置的字符都一样,那这个字符串就是回文的。
- (题目通常会说,空字符串或单个字符的串也算回文——代码里也通常会处理这种情况)。
- 这个函数很简单,就是专门判断一个字符串
通过这种"一路切下去,不合适就退回来换条路再切"的深度优先搜索和回溯机制,这个算法就能把所有可能的切割方式都系统地试一遍,然后把那些每一段都是回文串的成功方案都收集起来。
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)$,取决于字符串的存储方式