地址:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/
首先解释“回溯”算法的应用,“回溯”算法主要用于搜索,因此有时候“回溯算法”也叫“回溯搜索”。这里“搜索”的意思即“查找我们所需要的解”。我们每天使用的“搜索引擎”就是帮助我们在庞大的互联网上搜索我们需要的信息。
而这里的“回溯”指的是“状态重置”,可以理解为“回到过去”、“恢复现场”,是在编码的过程中,为了节约空间而使用的一种技巧。
下面我们通过一个非常经典的问题,介绍“回溯”算法在查找问题的解中的应用。
这是「力扣」上第 46 号问题:“全排列”,这道题给我们一个没有重复数字的数组,要求我们返回其所有可能的全排列。
例如给出的数组是 [1, 2, 3]
,这个数组所有可能的全排列如下:
[
[1, 2, 3],
[1, 3, 2],
[2, 1, 3],
[2, 3, 1],
[3, 1, 2],
[3, 2, 1]
]
我们知道,N
个数字的全排列一共有 这么多个。
大家可以尝试一下在纸上写 3 个数字、4 个数字、5 个数字的全排列,相信不难找到这样的方法。
例如数组 [1, 2, 3]
的全排列。
- 我们先写以 1 开头的全排列,它们是:
[1, 2, 3], [1, 3, 2]
; - 再写以 2 开头的全排列,它们是:
[2, 1, 3], [2, 3, 1]
; - 最后写以 3 开头的全排列,它们是:
[3, 1, 2], [3, 2, 1]
。
我们只需要按顺序枚举每一位可能出现的情况,已经选择的数字在接下来要确定的数字中不能出现。按照这种策略选取就能够做到不重不漏,把可能的全排列都枚举出来。
- 在枚举第一位的时候,有 3 种情况。
- 在枚举第二位的时候,前面已经出现过的数字就不能再被选取了;
- 在枚举第三位,前面 2 个已经选择过的数字就不能再被选取了。
这样的思路,我们可以用一个树形结构来表示。
使用编程的方法得到全排列,就是在这样的一个树形结构中进行编程,具体来说,就是执行一次深度优先遍历,从树的根结点到叶子结点形成的路径就是一个全排列。
下面我们解释如何编码:
1、首先这棵树除了根结点和叶子结点以外,每一个结点做的事情其实是一样的,即在已经选了一些数的前提,我们需要在剩下还没有选择的数中按照顺序依次选择一个数,这显然是一个递归结构;
2、递归的终止条件是,数已经选购了,因此我们需要一个变量来表示当前递归到第几层,我们把这个变量叫做 depth
;
3、这些结点实际上表示了搜索(查找)全排列问题的不同阶段,为了区分这些不同阶段,我们就需要一些变量来记录为了得到一个全排列,我们进行到那一步了,在这里我们需要两个变量:
(1)已经选了哪些数,到叶子结点时候,这些已经选择的数就构成了一个全排列;
(2)一个布尔数组 used
,初始化的时候都为 false
表示这些数还没有被选择,当我们选定一个数的时候,就将这个数组的相应位置设置为 true
,这样在考虑下一个位置的时候,就能够以 的时间复杂度判断这个数是否被选择过,这是一种“以空间换时间”的思想。
我们把这两个变量称之为“状态变量”,它们表示了我们在求解一个问题的时候所处的阶段。
4、在非叶子结点处,产生不同的分支,这一操作的语义是:在还未选择的数中依次选择一个元素作为下一个位置的元素,这显然得通过一个循环实现。
5、另外,因为是执行深度优先遍历,从较深层的结点返回到较浅层结点的时候,需要做“状态重置”,即“回到过去”、“恢复现场”,我们举一个例子。
从 [1, 2, 3]
到 [1, 3, 2]
,深度优先遍历是这样做的,从 [1, 2, 3]
回到 [1, 2]
的时候,需要撤销刚刚已经选择的数 3
,因为在这一层只有一个数 3
我们已经尝试过了,因此程序回到上一层,需要撤销对 2
的选择,好让后面的程序知道,选择 3
了以后还能够选择 2
。
这种在遍历的过程中,从深层结点回到浅层结点的过程中所做的操作就叫“回溯”
下面我们就来看看代码应该如何编写:
Java 代码:(注意:这个代码有个坑,希望读者能自己运行一下测试用例自己发现原因,然后再阅读后面的内容)
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Solution9 {
public List<List<Integer>> permute(int[] nums) {
// 首先是特判
int len = nums.length;
// 使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
boolean[] used = new boolean[len];
List<Integer> path = new ArrayList<>();
backtracking(nums, len, 0, path, used, res);
return res;
}
private void backtracking(int[] nums, int len, int depth, List<Integer> path, boolean[] used, List<List<Integer>> res) {
if (depth == len) {
res.add(path);
return;
}
for (int i = 0; i < len; i++) {
if (!used[i]) {
path.add(nums[i]);
used[i] = true;
backtracking(nums, len, depth + 1, path, used, res);
// 注意:这里是状态重置,是从深层结点回到浅层结点的过程,代码在形式上和递归之前是对称的
used[i] = false;
path.remove(depth);
}
}
}
public static void main(String[] args) {
int[] nums = {1, 2, 3};
Solution9 solution9 = new Solution9();
List<List<Integer>> lists = solution9.permute(nums);
System.out.println(lists);
}
}
这段代码在运行的时候输出如下:
[[], [], [], [], [], []]
原因出现在递归终止条件这里:
if (depth == len) {
res.add(path);
return;
}
path
这个变量所指向的对象在递归的过程中只有一份,深度优先遍历完成以后,因为回到了根结点(因为我们之前说了,从深层结点回到浅层结点的时候,需要撤销之前的选择),因此 path
这个变量回到根结点以后都为空。
在 Java 中,因为都是值传递,对象类型变量在传参的过程中,复制的都是变量的地址。这些地址被添加到 res
变量,但实际上指向的是同一块内存地址,因此我们会看到 6 个空的列表对象。解决的方法很简单,在 res.add(path);
这里做一次拷贝即可。
正确代码如下:
Java 代码:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Solution9 {
public List<List<Integer>> permute(int[] nums) {
// 首先是特判
int len = nums.length;
// 使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
boolean[] used = new boolean[len];
List<Integer> path = new ArrayList<>();
backtracking(nums, len, 0, path, used, res);
return res;
}
private void backtracking(int[] nums, int len, int depth, List<Integer> path, boolean[] used, List<List<Integer>> res) {
if (depth == len) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < len; i++) {
if (!used[i]) {
path.add(nums[i]);
used[i] = true;
backtracking(nums, len, depth + 1, path, used, res);
// 注意:这里是状态重置,是从深层结点回到浅层结点的过程,代码在形式上和递归之前是对称的
used[i] = false;
path.remove(depth);
}
}
}
public static void main(String[] args) {
int[] nums = {1, 2, 3};
Solution9 solution9 = new Solution9();
List<List<Integer>> lists = solution9.permute(nums);
System.out.println(lists);
}
}
此时再提交到「力扣」上就能得到一个 Accept 了。
希望大家能够通过这个例子理解“回溯”这个技巧在搜索问题中起到的作用。
下面我们对这一版的代码做以下几个说明:
1、如果在每一个非叶子结点分支的尝试,我都创建新的变量表示状态,那么不需要“回溯”,在递归终止的时候,也不需要做拷贝。这样的做法虽然可以得到解,但同时会创建很多中间变量,这些中间变量很多时候是我们不需要的,会有一定空间和时间上的消耗。
为了验证上面的说明,我们写如下代码进行实验:
Java 代码:
import java.util.ArrayList;
import java.util.List;
public class Solution10 {
public List<List<Integer>> permute(int[] nums) {
// 首先是特判
int len = nums.length;
// 使用一个动态数组保存所有可能的全排列
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
boolean[] used = new boolean[len];
List<Integer> path = new ArrayList<>();
backtracking(nums, len, 0, path, used, res);
return res;
}
private void backtracking(int[] nums, int len, int depth, List<Integer> path, boolean[] used, List<List<Integer>> res) {
if (depth == len) {
// 3、不用拷贝,因为每一层传递下来的 path 变量都是新建的
res.add(path);
return;
}
for (int i = 0; i < len; i++) {
if (!used[i]) {
// 1、每一次尝试都创建新的变量表示当前的"状态"
List<Integer> newPath = new ArrayList<>(path);
newPath.add(nums[i]);
boolean[] newUsed = new boolean[len];
System.arraycopy(used, 0, newUsed, 0, len);
newUsed[i] = true;
backtracking(nums, len, depth + 1, newPath, newUsed, res);
// 2、无需回溯
}
}
}
}
这就好比我们在实验室里做对比实验,每一个步骤的尝试都要保证使用的材料是一样的。为此有两种办法:
(1)每做完一种尝试,都把实验材料恢复成做上一个实验之前的样子,只有这样做出的对比才有意义;
(2)每一次尝试都使用同样的新的材料做实验。
只不过很多时候,做实验对材料有破坏性。不过在计算机的世界里,“恢复现场”和“回到过去”是相对容易的。
在一些字符串的“回溯”问题中,有些时候不需要回溯就是这个原因,因为字符串变量在拼接的过程中会产生新的对象(针对 Java 和 Python 语言,其它语言我并不清楚)。
如果你使用 Python 语言,会知道有这样一种语法:[1, 2, 3] + [4]
也是创建了一个新的列表对象,我们会在后面的参考代码中展示这种写法。
2、也可以不使用 used
数组,在遍历的过程中,对于一个数是否使用过,就得遍历 path
里的每一个元素,这个操作的时间复杂度是 ,一般情况下,没有必要节约这个空间。
3、ArrayList
是 Java 中的动态数组,Java 建议我们如果一开始就知道这个集合里需要保存元素的大小,可以在初始化的时候直接传入。
在 res
变量初始化的时候,最好传入 len
的阶乘。
在 path
变量初始化的时候,可以传入 len
。
4、path
变量我们发现只是对它的末尾位置进行增加和删除的操作,显然它是一个栈,因此,使用栈语义会更清晰。但同时 Stack
这个类的文档我们,由于一些设计上的问题,建议我们使用:
Deque<Integer> stack = new ArrayDeque<Integer>();
这一点让我很奔溃,Deque
是双端队列,它提供了更灵活的接口,同时破坏了语义,一不小心,如果用错了接口,就会导致程序错误。我采用的做法是接收官方的建议,在程序变量命名和使用的接口时让语义尽量清晰:
这里 path
我需要表示它是从根结点到叶子结点的路径,我认为这个语义更重要,因此不改名为 stack
。而在末尾添加元素和删除元素的时候,分别使用 addLast()
和 removeLast()
方法强调只在末尾操作。
5、布尔数组在这题里的作用是判断某个位置上的元素是否已经使用过。有两种等价的替换方式:
(1)哈希表;
(2)位掩码,即使用一个整数表示布尔数组。此时可以将空间复杂度降到 (不包括 path
变量和 res
变量)。
来源:CSDN
作者:liweiwei1419
链接:https://blog.csdn.net/lw_power/article/details/103795299