第 46 题:“全排列”题目描述
给定一个没有重复数字的序列,返回其所有可能的全排列。
示例:
输入: [1,2 , 3]
输出:
[
[1, 2, 3],
[1, 3, 2],
[2, 1, 3],
[2, 3, 1],
[3, 1, 2],
[3, 2, 1]
]
思路:
- 第 46 题是回溯搜索算法的入门问题,可以把搜索全排列的过程画成一棵递归树,请务必动手在纸上画出递归树;
- 所有符合条件的结点在这棵递归树的叶子结点;
- 使用深度优先遍历(DFS)或者广度优先遍历(BFS)遍历这棵递归树,在叶子结点处添加符合题意的一个结果,发现使用 BFS 编码较难;
- 使用 DFS 可以使用递归方法,借助方法栈完成,即传递的参数通过递归方法的方法栈进行传递,而不用手动编写栈和结点类,把结点类需要的变量通过递归方法的参数进行传递即可;
- 树的每一个结点表示解决这个问题处在了哪一个阶段,我们使用不同的变量进行区分,这些变量叫做“状态变量”;
- 而深度优先遍历有一个回退的过程,在回退的时候,所有的“状态”需要和第一次来到这个结点的时候相同,因此这里需要做“状态重置”(或者称“恢复变量”、“撤销选择”),这是深搜称之为“回溯算法”的原因;
深度优先遍历作为搜索遍历的方法,其思想也是很朴素且深刻的,深搜表现出一种“不撞南墙不回头”的特点。具体的行为是:完成一件事情有多个阶段,在每一个阶段有多种选择,先走其中一个,然后实在走不下去了,再回退到上一个结点继续下一个选择。
回到上一个结点的步骤就称之为“回溯”,在“回溯”的时候必须保证回到之前刚来到这个结点的状态,这叫做“状态重置”。
大家想一想在电影《大话西游》里月光宝盒的作用,正是因为月光宝盒有“回到过去”,将所有的一切恢复到之前的样子的功能,至尊宝才能做出最正确的选择。
在人类的世界里没有“月光宝盒”,但是在计算机的世界里,“状态重置”是很容易实现的,因此我们可以使用一份状态变量完成所有状态的搜索,只把符合特定条件的“状态”保存下来,作为结果集,这就是“回溯算法”能成为强大的搜索算法的原因。
- 需要设计的状态变量有:递归树到了第几层(已经使用了几个数),从根结点到叶子结点的路径(即一个全排列),哪些数是否使用过。
Java 代码:
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
public class Solution {
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];
// 由于只在结尾操作,因此是一个栈,Java 的 Stack 类建议使用 Deque 作为栈的实现
Deque<Integer> path = new ArrayDeque<>(len);
// 由于是深搜,深搜需要使用栈,而写递归方法就可以把状态变量设计成递归方法参数
dfs(nums, len, 0, path, used, res);
return res;
}
/**
* @param nums 候选数组
* @param len 冗余变量,作为参数传递不用每次都从 nums 中读取 length 属性值
* @param depth 冗余变量,作为参数传递不用每次都从 path 中调用 size() 方法
* @param path 从根结点到叶子结点的路径
* @param used 记录当前结点已经使用了哪些元素,这些元素都在 path 变量中
* @param res 结果集
*/
private void dfs(int[] nums, int len, int depth,
Deque<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]) {
continue;
}
used[i] = true;
path.addLast(nums[i]);
dfs(nums, len, depth + 1, path, used, res);
// 此处是回退的过程,发生状态重置(撤销选择),代码与 dfs 是对称出现的
path.removeLast();
used[i] = false;
}
}
}
Python 代码:
from typing import List
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
def dfs(nums, size, depth, path, state, res):
if depth == size:
res.append(path)
return
for i in range(size):
if ((state >> i) & 1) == 0:
dfs(nums, size, depth + 1, path + [nums[i]], state ^ (1 << i), res)
size = len(nums)
if size == 0:
return []
state = 0
res = []
dfs(nums, size, 0, [], state, res)
return res
说明:即使是这样一个简单的回溯搜索算法,这里面也有比较多的细节需要注意。请读者思考以下问题:
1、为什么需要状态重置,不重置是否可以?
2、在最后 if (depth == len)
这一步,为什么要套一层 new ArrayList<>(path)
,这与 Java 和 Python 的方法传递机制相关,请读者一定要搞清楚这里的细节;
3、广搜能不能实现,可以尝试写一下广搜(知道广搜不好写的原因即可,不一定真的写出来),对比与深搜算法的不同;
4、为什么要设计 used
数组,不使用 used
数组会带来什么变化,搜索会更快吗?
5、如果会 Python 的朋友,比较一下在给出的 Python 代码与 Java 代码的不同之处。
在这里为了节约篇幅,突出重点和思想,就不展开叙述了。更多的细节(包括复杂度分析)可以参考我在「力扣」第 46 题:“全排列”问题下写的题解:从全排列问题开始理解“回溯搜索”算法。
回溯算法本质上得通过遍历,因此复杂度一般不低,但是再一些问题我们可以通过在搜索中判断哪些结点一定不会得到符合题意的结果,而跳过某一个分支的遍历,这样的操作犹如在一棵树上剪去一个枝叶,因此称之为“剪枝”。
“剪枝”的思想其实也很朴素、常见。就像我们在人生道路上,如果能够知道当前某个选择不能达到目标,应该及时停止,不继续投入时间和精力,这就是“剪枝”操作。
第 46 题的扩展问题就是一个运用到剪枝操作的问题。
第 47 题:“全排列II”题目描述
给定一个可包含重复数字的序列,返回所有不重复的全排列。
示例:
输入: [1, 1, 2]
输出:
[
[1, 1, 2],
[1, 2, 1],
[2, 1, 1]
]
思路:
-
这一题在“全排列” 的基础上增加了元素可重复这一条件,但要求返回的结果又不能有重复元素;
-
如果依然按照上一题的方去做,就需要在结果集中去重。但是问题又来了,这些结果集的元素是一个又一个列表,对列表去重不像用哈希表对基本元素去重那样容易;
-
如果实在要比较两个列表是否一样,一个很显然的办法就是分别排序,然后逐个比对;
既然一定要排序,可以在搜索之前就对候选数组排序,一旦发现这一支搜索下去可能搜索到重复元素,就停止搜索,这个思想是解决这个问题的核心。
- 那么在候选数组有序的前提下,什么时候会发生重复呢?我们依然是看画出的树形图,模拟一下深度优先遍历的过程。
- 重复的搜索发生在这一轮考虑的选择和上一轮一样的时候,并且上一轮搜索的那个值因为在回退的过程中,刚刚被撤销,应该剪去的是这样的枝叶。
Java 代码:
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.List;
public class Solution {
public List<List<Integer>> permuteUnique(int[] nums) {
int len = nums.length;
List<List<Integer>> res = new ArrayList<>();
if (len == 0) {
return res;
}
// 排序(升序或者降序都可以),为了剪枝方便
Arrays.sort(nums);
boolean[] used = new boolean[len];
// 使用 Deque 是 Java 官方 Stack 类的建议
Deque<Integer> path = new ArrayDeque<>(len);
dfs(nums, len, 0, used, path, res);
return res;
}
private void dfs(int[] nums, int len, int depth, boolean[] used, Deque<Integer> path, List<List<Integer>> res) {
if (depth == len) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < len; ++i) {
if (used[i]) {
continue;
}
// 剪枝条件,i > 0 是为了保证 nums[i - 1] 有意义
// used[i - 1] 是因为 nums[i - 1] 在回退的过程中刚刚被撤销选择
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
continue;
}
path.addLast(nums[i]);
used[i] = true;
dfs(nums, len, depth + 1, used, path, res);
used[i] = false;
path.removeLast();
}
}
}
说明:这里重点是想清楚为什么是 used[i - 1] == false
(写作 !used[i]
更好,这里只是为了可读性不写成这样)。如果写成 used[i - 1] == true
也可以剪枝,代码依然可以通过,但是剪枝的思路很不自然,具体细节可以参考我在「力扣」第 47 题:“全排列 II” 写的题解:回溯搜索 + 剪枝。
总结
以下几点是对上面介绍的知识的概括,没有写得很具体,留下一些空间给读者思考。如果有说错,或者是造成歧义的地方,欢迎大家与我交流。
- 回溯算法是一种深度优先遍历的搜索算法,本质上是在一个树形问题上进行遍历的算法;
- 如果使用广度优先遍历,我们得手动编创建队列、把状态变量封装成结点类,更要命的是,由于广度优先遍历是层序遍历的关系,到下一层的时候,“状态变量”会发生“突变”,因此就不能使用一个状态变量完成搜索任务;
- 而深度优先遍历,由于不同的状态变量在栈中出栈和入栈的结果下,它们的状态是相邻的,因此状态变化的消耗特别少,因此全程可以使用一个状态变量完成所有状态的搜索;
- 并且借助递归方法,我们不用手动编写栈,并且把需要的状态变量设计在递归方法的参数里即可,这一点和上一点是深度优先遍历可以成为强大的搜索算法的原因;
- 回溯算法本质上是遍历算法,因此复杂度一般而言较高,但是在一些问题中,我们可以提前发现某些分支无需遍历,应该跳过,这一步操作称之为“剪枝”。
回溯算法 = 深度优先遍历() + 状态重置 + 剪枝。