深度优先搜索算法(Depth First Search,DFS ),简称 深搜 ,源自于图论,用于遍历或搜索树或图。 该算法在每达到一个节点后,会选择一条边并尽可能深地搜索树,当一个节点所有边己被探寻过,搜索将回溯到上一个节点选择另外边继续搜索。
下图展示了从节点 A 出发搜索 所有路径 过程。
先看下,力扣上面一道经典题目 ------ 两数之和,
给定一个整数数组
nums
和一个整数目标值target
,请你在该数组中找出 和为目标值target
的那 两个 整数,并返回它们的数组下标。
对于这道题目,可以很快想到用两重循环分别枚举这两个整数下标,如果循环体中,这两个数和为 target
,则其即为答案。
java
public int[] twoSum(int[] nums, int target) {
for (int i = 0; i < nums.length; i++) { // 枚举第一个数
for (int j = i+1; j < nums.length; j++) { // 枚举第二个数,为了避免重复第二个数下标大于第一个数下标
if (nums[i] + nums[j] == target) {
return new int[]{i, j};
}
}
}
return new int[]{};
}
当然,三数之和、四数之和都可以通过类似方法求解,多组合一个数就多一重循环嵌套。
如果此题再给定一个正整数 n
,并求在 nums
数组中找出 和为目标值 target
的 n
个整数呢?
难道写 n
重循环? 肯定是不现实的。那怎么办呢?
回顾下上面代码,我们用一重循环枚举一个数,如果多个数组合的话,就需要用多重循环嵌套,这样做比较直观易懂,但是也有一个明显缺点 ------ 循环嵌套层数在代码编写时就已经确定,程序运行时无法修改。
为了解决这个问题,我们考虑把枚举一个数的 单重循环定义到函数中,并在该函数循环中递归调用去枚举下一个数。这样,在函数循环体中发生的递归调用相当于循环嵌套,并且,我们可以用递归终止条件来动态控制函数递归深度,从而实现动态控制组合数量。
java
// 在 nums 数组中找出和为目标值 target 的 n 个整数
public int[] nSum(int[] nums, int target, int n) {
int[] ans = new int[n]; // 答案
rec(-1, 0, nums, target, n, ans);
return ans;
}
// 该函数通过循环遍历 nums 数组枚举第 k 个数值
// 参数 pre 表示, 第 k-1 个数选择的是 nums 数组中下标为 pre 的数
// 该函数返回布尔值, 表示对于前面已经选择的 k 个数, 后面能否能找到剩余数与其和为 target
public boolean rec(int pre, int k, int[] nums, int target, int n, int[] ans) { // ①
// 如果枚举到第 n 个数, 说面前面已经枚举了 [0, n-1] 共 n 个数, 这 n 个数下标保存在 [ans[0], ans[n-1]]中
// 此时, 需要递归终止,并计算已经枚举的前 n 个数和是否为 target , 以确定该方案是否可行
if (k == n) { // ②
return Arrays.stream(ans).map(i -> nums[i]).sum() == target;
}
// 为了避免重复, 第 k 个数从上一个选择的数下标之后开始选
for (int i = pre + 1; i < nums.length; i++) { // ③
ans[k] = i; // 记录
// 第 k 个 数选择 nums 数组中下表为 i 的数后, 递归函数去选择第 k+1 个数
if (rec(i, k + 1, nums, target, n, ans)) {
// 如果方案可行, 不需要再枚举后面的数了
return true;
}
}
return false;
}
可以看出,这种方式,代码复杂了不少,而且不易理解,代码重点部分已圈出,并说明如下
① 我们发现 rec
函数携带参数比较多,这是由于一次 rec
函数调用枚举一个数,枚举多个数组合则需要函数递归调用,那上一个数枚举情况与枚举下一个数所需材料只能通过 参数 或者 全局变量 告知到下一个递归调用的函数。例如,在 rec
函数中通过 pre
参数告知上一个数选择的是 nums
数组中下标为 pre
的数,本次函数则从 pre
之后开始枚举。 通常,为了避免参数过多,我们把 不变的已知条件或者结果设置到全局变量 ,这样就可以在后续函数直接使用,而不需要通过参数传递,简化代码。例如 rec
函数中,我们就可以在 nSum
函数中把 nums
/ target
/ n
/ ans
设置到全局变量中,这样 rec
函数就只需要携带两个参数。
参数设计往往是这类递归问题中比较重要部分,好的参数设计可能达到事半功倍效果。例如,两数之和 直接使提交如下代码,会有两个测试点超时
java
public int[] twoSum(int[] nums, int target) {
return nSum(nums, target, 2);
}
因为每次搜有数枚举完后再用 Arrays.stream(ans).map(i -> nums[i]).sum()
计算所选数之和,相当于多了一次循环嵌套,这无疑增加了时间开销,整体时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 3 ) O(n^3) </math>O(n3) 。我们可以在 rec
函数中多增加一个参数 sum
,每当选择一个数后就把该数累加到 sum
,最后只需要判断 sum
是否和 target
相等即可。最后优化代码如下
java
int nums[], target, n, ans[];
public int[] nSum(int[] nums, int target, int n) {
this.ans = new int[n];
this.nums = nums;
this.target = target;
this.n = n;
rec(-1, 0, 0);
return ans;
}
public boolean rec(int pre, int k, int sum) {
if (k == n) {
return sum == target;
}
for (int i = pre + 1; i < nums.length; i++) {
ans[k] = i;
if (rec(i, k + 1, sum + nums[i])) {
return true;
}
}
return false;
}
② 在循环嵌套代码中,我们在最内层循环中处理每层循环所枚举元素之和是否为 target
,如果肯定,则直接 return
枚举结果。但在函数递归调用中,我们通常把递归终止条件放函数开始。所以在最后一次函数调用枚举完最后一个数后,不会立即在该函数中判断方案可行性,而是 继续进行一次额外的函数递归调用,在该次函数调用中处理结果 ,并返回告知上层主调函数,终止递归。上层主调函数拿到结果后再决定是否还需要继续枚举。
即,如果枚举 n
个数组合, 循环通常会有 n+1
层函数调用,前 n
层函数调用用来枚举这 n
个数,最后一侧函数用来判断前面枚举的 n
个数是否满足条件。
③ 如果在函数中,每次都从数组 nums
头开始枚举,那么可能会造成同一个 nums
数组中同一个数被选择多次和方案重复问题。比如,枚举三个数时,如下六种情况应该属于同一种方案
nums[0], nums[1], nums[2]
nums[0], nums[2], nums[1]
nums[1], nums[0], nums[2]
nums[1], nums[2], nums[0]
nums[2], nums[0], nums[1]
nums[2], nums[1], nums[0]
即此题应属多个数之间 组合 问题,而非排列问题。所以,为了避免重复,对于这几个数在数组中的位置,我们规定一种顺序 ------ 枚举下一个数时,需要在 nums
数组中应位于上一个数之后。这样,上面六种情况只有第一种情况符合条件。
在代码中,我们通过参数 pre
告诉下一次函数调用本次函数选择了 nums
数组那哪个数,下一次枚举时,从该位置之后开始。
像这种规定顺序去重方式应用还有很多,比如《全排列 II》。
下图展示了在 nums
数组 {2, 7, 11, 15}
中选择两个数,且这两个数和为 target=18
时,该方法运行情况。
那这和深搜有什么关系呢?
仔细回顾下,以上方法其实这就是深搜思想的运用。上例中对于每个数可以理解为图论中一个节点,函数中循环枚举 nums 数组相当于枚举图论中节点所有出边,这样就构建出一张 隐式图。当我们选定一个数并递归选择下一个数时,相当于图论中从一个节点的一条边出发达到下一个节点,当循环执行完饭回当上次函数调用是不是就相当于 "一个节点所有边己被探寻过,搜索将回溯到上一个节点选择另外边继续搜索"?
利用该算法,我们可以解决力扣如下题目
给定两个整数
n
和k
,返回范围[1, n]
中所有可能的k
个数的组合。 你可以按 任何顺序 返回答案
java
List<List<Integer>> ans = new ArrayList<>();
int n, k, path[]; // 记录单个答案
public List<List<Integer>> combine(int n, int k) {
this.n = n;
this.k = k;
this.path = new int[k];
dfs(0, 0);
return ans;
}
public void dfs(int pre, int c){
if(c == k){
// 将本次搜索到的答案添加到答案集中
ans.add(Arrays.stream(path).boxed().collect(Collectors.toList()));
return;
}
for(int i = pre + 1; i <= n; i++){
path[c] = i;
dfs(i, c+1);
}
}
给定一个仅包含数字
2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
java
Map<Character, String> dict = new HashMap<>(); // 数字到字母的映射
{
dict.put('2', "abc");
dict.put('3', "def");
dict.put('4', "ghi");
dict.put('5', "jkl");
dict.put('6', "mno");
dict.put('7', "pqrs");
dict.put('8', "tuv");
dict.put('9', "wxyz");
}
String digits;
List<String> ans = new ArrayList<>();
Character[] path;
// 枚举 digits 第 k 个数字映射的字母
void dfs(int k) {
if (k == digits.length()) { // 都已经枚举完成, 将结果添加到结果集并返回
ans.add(Arrays.stream(path).map(c -> c.toString()).collect(Collectors.joining()));
return;
}
String str = dict.get(digits.charAt(k)); // 第 k 个数字映射的字母构成的字符串
for (int i = 0; i < str.length(); i++) {
path[k] = str.charAt(i);
dfs(k + 1);
}
}
public List<String> letterCombinations(String digits) {
this.digits = digits;
this.path = new Character[digits.length()];
if (!digits.isEmpty()) {
dfs(0);
}
return ans;
}
给你一个整数
n
,返回任一有效的 n 位格雷码序列 。
java
int[] ans;
boolean visited[]; //每个数字只能出现一次, visited[i] 表示数字 i 是否已经出现在格雷码序列中
// 求第 k 个格雷码
public boolean dfs(int k, String pre) {
if (k == 1 << pre.length()) { // 都已经枚举完成, 直接返回
return true;
}
// 第 k 个格雷码在上一个格雷码基础上变换一个二进制位, 这里枚举上一个格雷码所有二进制位
for (int i = 0; i < pre.length(); i++) {
StringBuilder sb = new StringBuilder(pre);
// 变换后的二进制位
char c = pre.charAt(i) == '0' ? '1' : '0';
sb.setCharAt(i, c); // 变换上一个格雷码第 i 位
String s = sb.toString();
int intValue = Integer.parseInt(s, 2); // 新生成的格雷码
if (visited[intValue] == false) { // ① 新生成的格雷码需要未出现过, 才能加入格雷码序列
visited[intValue] = true; // 表示该格雷码已经生产
ans[k] = intValue; // 加入格雷码序列
boolean found = dfs(k + 1, s); // 生成下一个格雷码
if (found) {
return true;
}
visited[intValue] = false; // ② 回溯
}
}
return false;
}
public List<Integer> grayCode(int n) {
String st = "0".repeat(n);
this.ans = new int[1 << n];
ans[0] = 0;
this.visited = new boolean[1 << n];
visited[0] = true;
dfs(1, st);
return Arrays.stream(ans).boxed().collect(Collectors.toList());
}
给定一个字符串
s
,通过将字符串s
中的每个字母转变大小写,我们可以获得一个新的字符串。 返回 所有可能得到的字符串集合 。以 任意顺序 返回输出。
java
List<String> ans = new ArrayList<>();
// 枚举第下标为 k 字符变化情况
public void dfs(int k, String pre) {
if (k == pre.length()) { // [0, k-1] 字符大小写都已经变换完成, 将结果存入到结果集, 并返回
ans.add(pre);
return;
}
dfs(k + 1, pre); // 第 ① 种情况, 下标为 k 字符大小写不变
// 下标为 k 字符要发生大小写变化前提是 { 该字符是字母 }
if (Character.isUpperCase(pre.charAt(k)) || Character.isLowerCase(pre.charAt(k))) {
// 下标为 k 字符大小写变化后的字符
char ch = Character.isUpperCase(pre.charAt(k)) ? Character.toLowerCase(pre.charAt(k))
: Character.toUpperCase(pre.charAt(k));
StringBuilder sb = new StringBuilder(pre);
sb.setCharAt(k, ch);
dfs(k + 1, sb.toString()); // 第 ② 种情况, 下标为 k 字符大小写变化
}
}
public List<String> letterCasePermutation(String s) {
dfs(0, s);
return ans;
}