文章目录
491.递增子序列
给你一个整数数组 nums
,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。
数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。
示例 1:
输入:nums = [4,6,7,7]
输出:[[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]
示例 2:
输入:nums = [4,4,3,2,1]
输出:[[4,4]]
提示:
1 <= nums.length <= 15
-100 <= nums[i] <= 100
Related Topics
-
位运算
-
数组
-
哈希表
-
回溯
思路
这个递增子序列比较像是取有序的子集。而且本题也要求不能有相同的递增子序列。
这又是子集,又是去重,是不是不由自主的想起了刚刚讲过的90.子集II (opens new window)。
就是因为太像了,更要注意差别所在,要不就掉坑里了!
在90.子集II (opens new window)中我们是通过排序,再加一个标记数组来达到去重的目的。
而本题求自增子序列,是不能对原数组进行排序的,排完序的数组都是自增子序列了。
所以不能使用之前的去重逻辑!
本题给出的示例,还是一个有序数组 [4, 6, 7, 7],这更容易误导大家按照排序的思路去做了。
为了有鲜明的对比,我用[4, 7, 6, 7]这个数组来举例,抽象为树形结构如图:
回溯三部曲
- 递归函数参数
本题求子序列,很明显一个元素不能重复使用,所以需要startIndex,调整下一层递归的起始位置。
代码如下:
private List<List<Integer>> res = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
private void travel(int[] nums, int startIndex){
}
- 终止条件
本题其实类似求子集问题,也是要遍历树形结构找每一个节点,所以和回溯算法:求子集问题! (opens new window)一样,可以不加终止条件,startIndex每次都会加1,并不会无限递归。
但本题收集结果有所不同,题目要求递增子序列大小至少为2,所以代码如下:
//将当前路径加入
if (path.size() > 1) {
res.add(new ArrayList<>(path));
}
- 单层搜索逻辑
在图中可以看出,同一父节点下的同层上使用过的元素就不能再使用了
那么单层搜索代码如下:
//创建一个set数组,用来判断当前数字是否已经遍历过
Set<Integer> set = new HashSet<>();
for (int i = startIndex; i < nums.length; i++) {
//如果set数组里有当前数字,则说明当前数字已经遍历过,跳过当前数字,进入下一次循环
if (set.contains(nums[i])) {
continue;
}
//如果当前数字小于数组的最大值,则不需要加入数组
if (!path.isEmpty() && nums[i] < path.get(path.size() - 1)) {
continue;
}
set.add(nums[i]);
path.add(nums[i]);
travel(nums, i + 1);
path.remove(path.size() - 1);
}
整体代码
public List<List<Integer>> findSubsequences(int[] nums) {
travel(nums, 0);
return res;
}
private List<List<Integer>> res = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
private void travel(int[] nums, int startIndex) {
//将当前路径加入
if (path.size() > 1) {
res.add(new ArrayList<>(path));
}
//创建一个set数组,用来判断当前数字是否已经遍历过
Set<Integer> set = new HashSet<>();
for (int i = startIndex; i < nums.length; i++) {
//如果set数组里有当前数字,则说明当前数字已经遍历过,跳过当前数字,进入下一次循环
if (set.contains(nums[i])) {
continue;
}
//如果当前数字小于数组的最大值,则不需要加入数组
if (!path.isEmpty() && nums[i] < path.get(path.size() - 1)) {
continue;
}
set.add(nums[i]);
path.add(nums[i]);
travel(nums, i + 1);
path.remove(path.size() - 1);
}
}
- 时间复杂度: O(n * 2^n)
- 空间复杂度: O(n)
优化
以上代码用我用了unordered_set<int>
来记录本层元素是否重复使用。
其实用数组来做哈希,效率就高了很多。
注意题目中说了,数值范围[-100,100],所以完全可以用数组来做哈希。
程序运行的时候对unordered_set 频繁的insert,unordered_set需要做哈希映射(也就是把key通过hash function映射为唯一的哈希值)相对费时间,而且每次重新定义set,insert的时候其底层的符号表也要做相应的扩充,也是费事的。
那么优化后的代码如下:
public List<List<Integer>> findSubsequences(int[] nums) {
travel(nums, 0);
return res;
}
private List<List<Integer>> res = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
private void travel(int[] nums, int startIndex) {
//将当前路径加入
if (path.size() > 1) {
res.add(new ArrayList<>(path));
}
//创建一个set数组,用来判断当前数字是否已经遍历过
//Set<Integer> set = new HashSet<>();
int[] set = new int[201];
for (int i = startIndex; i < nums.length; i++) {
//如果set数组里有当前数字,则说明当前数字已经遍历过,跳过当前数字,进入下一次循环
if (set[nums[i]+100]==1) {
continue;
}
//如果当前数字小于数组的最大值,则不需要加入数组
if (!path.isEmpty() && nums[i] < path.get(path.size() - 1)) {
continue;
}
//set.add(nums[i]);
set[nums[i]+100]=1;
path.add(nums[i]);
travel(nums, i + 1);
path.remove(path.size() - 1);
}
}
46.全排列
给定一个不含重复数字的数组 nums
,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
示例 3:
输入:nums = [1]
输出:[[1]]
提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums
中的所有整数 互不相同
Related Topics
-
数组
-
回溯
思路
此时我们已经学习了77.组合问题 (opens new window)、 131.分割回文串 (opens new window)和78.子集问题 (opens new window),接下来看一看排列问题。
相信这个排列问题就算是让你用for循环暴力把结果搜索出来,这个暴力也不是很好写。
所以正如我们在关于回溯算法,你该了解这些! (opens new window)所讲的为什么回溯法是暴力搜索,效率这么低,还要用它?
因为一些问题能暴力搜出来就已经很不错了!
我以[1,2,3]为例,抽象成树形结构如下:
回溯三部曲
- 递归函数参数
首先排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。
但排列问题需要一个used数组,标记已经选择的元素,如图橘黄色部分所示:
代码如下:
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int[] used;
private void travel(int[] nums)
- 递归终止条件
可以看出叶子节点,就是收割结果的地方。
那么什么时候,算是到达叶子节点呢?
当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
代码如下:
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
- 单层搜索的逻辑
这里和77.组合问题 (opens new window)、131.切割问题 (opens new window)和78.子集问题 (opens new window)最大的不同就是for循环里不用startIndex了。
因为排列问题,每次都要从头开始搜索,例如元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1。
而used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。
代码如下:
for (int i = 0; i < nums.length; i++) {
if (used[i] == 1) {
continue;
}
path.add(nums[i]);
used[i] = 1;
travel(nums);
//回溯
path.remove(path.size()-1);
used[i] = 0;
}
整体代码
public List<List<Integer>> permute(int[] nums) {
used = new int[nums.length];
travel(nums);
return res;
}
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int[] used;
private void travel(int[] nums) {
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i] == 1) {
continue;
}
path.add(nums[i]);
used[i] = 1;
travel(nums);
//回溯
path.remove(path.size()-1);
used[i] = 0;
}
}
时间复杂度: O(n!)
空间复杂度: O(n)
47.全排列 II
给定一个可包含重复数字的序列 nums
,按任意顺序 返回所有不重复的全排列。
示例 1:
输入:nums = [1,1,2]
输出:
[[1,1,2],
[1,2,1],
[2,1,1]]
示例 2:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
提示:
1 <= nums.length <= 8
-10 <= nums[i] <= 10
Related Topics
-
数组
-
回溯
思路
这道题目和46.全排列 (opens new window)的区别在与给定一个可包含重复数字的序列,要返回所有不重复的全排列。
这里又涉及到去重了。
在40.组合总和II (opens new window)、90.子集II (opens new window)我们分别详细讲解了组合问题和子集问题如何去重。
那么排列问题其实也是一样的套路。
还要强调的是去重一定要对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用了。
我以示例中的 [1,1,2]为例 (为了方便举例,已经排序)抽象为一棵树,去重过程如图:
图中我们对同一树层,前一位(也就是nums[i-1])如果使用过,那么就进行去重。
一般来说:组合问题和排列问题是在树形结构的叶子节点上收集结果,而子集问题就是取树上所有节点的结果。
在46.全排列 (opens new window)中已经详细讲解了排列问题的写法,在40.组合总和II (opens new window)、90.子集II (opens new window)中详细讲解了去重的写法,所以这次我就不用回溯三部曲分析了,直接给出代码,如下:
public List<List<Integer>> permuteUnique(int[] nums) {
used = new boolean[nums.length];
Arrays.fill(used, false);
Arrays.sort(nums);
travel(nums);
return res;
}
private List<List<Integer>> res = new ArrayList<>();
private List<Integer> path = new ArrayList<>();
private boolean[] used;
private void travel(int[] nums) {
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
//表示在当前树层已经使用过这个元素
说明同一树枝使用过
used[i - 1] == true,说明同一树枝nums[i - 1]使用过
used[i - 1] == false,说明同一树层nums[i - 1]使用过
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
continue;
}
//当同一树枝遇到自己时跳过
if (used[i] == true) {
continue;
}
path.add(nums[i]);
used[i] = true;
travel(nums);
path.remove(path.size() - 1);
used[i] = false;
}
}
- 时间复杂度: O(n! * n)
- 空间复杂度: O(n)
332.重新安排行程
给你一份航线列表 tickets
,其中 tickets[i] = [fromi, toi]
表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。
所有这些机票都属于一个从 JFK
(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK
开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。
- 例如,行程
["JFK", "LGA"]
与["JFK", "LGB"]
相比就更小,排序更靠前。
假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。
示例 1:
输入:tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
输出:["JFK","MUC","LHR","SFO","SJC"]
示例 2:
输入:tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]
输出:["JFK","ATL","JFK","SFO","ATL","SFO"]
解释:另一种有效的行程是 ["JFK","SFO","ATL","JFK","ATL","SFO"] ,但是它字典排序更大更靠后。
提示:
1 <= tickets.length <= 300
tickets[i].length == 2
fromi.length == 3
toi.length == 3
fromi
和toi
由大写英文字母组成fromi != toi
Related Topics
-
深度优先搜索
-
图
-
欧拉回路
思路
这道题目还是很难的,之前我们用回溯法解决了如下问题:组合问题 (opens new window),分割问题 (opens new window),子集问题 (opens new window),排列问题 (opens new window)。
直觉上来看 这道题和回溯法没有什么关系,更像是图论中的深度优先搜索。
实际上确实是深搜,但这是深搜中使用了回溯的例子,在查找路径的时候,如果不回溯,怎么能查到目标路径呢。
所以我倾向于说本题应该使用回溯法,那么我也用回溯法的思路来讲解本题,其实深搜一般都使用了回溯法的思路,在图论系列中我会再详细讲解深搜。
这里就是先给大家拓展一下,原来回溯法还可以这么玩!
回溯法
开始回溯三部曲讲解:
- 递归函数参数
代码如下:
private List<String> res;
private List<String> path = new LinkedList<>();
private boolean[] used;
private boolean travel(List<List<String>> tickets)
注意函数返回值我用的是bool!
我们之前讲解回溯算法的时候,一般函数返回值都是void,这次为什么是bool呢?
因为我们只需要找到一个行程,就是在树形结构中唯一的一条通向叶子节点的路线,如图:
所以找到了这个叶子节点了直接返回,这个递归函数的返回值问题我们在讲解二叉树的系列的时候,在这篇二叉树:递归函数究竟什么时候需要返回值,什么时候不要返回值? (opens new window)详细介绍过。
- 递归终止条件
拿题目中的示例为例,输入: [[“MUC”, “LHR”], [“JFK”, “MUC”], [“SFO”, “SJC”], [“LHR”, “SFO”]] ,这是有4个航班,那么只要找出一种行程,行程里的机场个数是5就可以了。
所以终止条件是:我们回溯遍历的过程中,遇到的机场个数,如果达到了(航班数量+1),那么我们就找到了一个行程,把所有航班串在一起了。
代码如下:
//根据path的长度以及车票的数量,判断是否已经找到结果
if (path.size() == tickets.size() + 1) {
res = new ArrayList<>(path);
return true;
}
- 单层搜索的逻辑
回溯的过程中,如何遍历一个机场所对应的所有机场呢?
遍历过程如下:
for (int i = 0; i < tickets.size(); i++) {
//r如果当前车票已经被使用则跳过当前循环
if (!used[i] && tickets.get(i).get(0).equals(path.get(path.size() - 1))) {
// continue;
//}
如果当前path的尾部,等于某一张车票的起点,才能进入下次递归
//if(!path.getLast().equals(tickets.get(i).get(0)))
//{
// continue;
//}
path.add(tickets.get(i).get(1));
used[i] = true;
//递归到下一层,如果返回值为true,则直接返回,不需要继续循环遍历
if (travel(tickets)) {
return true;
}
//如果没找到,则进行回溯接着进行循环遍历
//回溯
path.remove(path.size() - 1);
used[i] = false;
}
//如果没找到,则返回false
}
return false;
整体代码
public List<String> findItinerary(List<List<String>> tickets) {
//先进行数组排序,使用每一张车票的终点的字典排序
Collections.sort(tickets, (a, b) -> a.get(1).compareTo(b.get(1)));
//创建一个车票长度的使用数组
used = new boolean[tickets.size()];
//先将首站添加进去
path.add("JFK");
travel(tickets);
return res;
}
private List<String> res;
private List<String> path = new LinkedList<>();
private boolean[] used;
private boolean travel(List<List<String>> tickets) {
//根据path的长度以及车票的数量,判断是否已经找到结果
if (path.size() == tickets.size() + 1) {
res = new ArrayList<>(path);
return true;
}
for (int i = 0; i < tickets.size(); i++) {
//r如果当前车票已经被使用则跳过当前循环
if (!used[i] && tickets.get(i).get(0).equals(path.get(path.size() - 1))) {
// continue;
//}
如果当前path的尾部,等于某一张车票的起点,才能进入下次递归
//if(!path.getLast().equals(tickets.get(i).get(0)))
//{
// continue;
//}
path.add(tickets.get(i).get(1));
used[i] = true;
//递归到下一层,如果返回值为true,则直接返回,不需要继续循环遍历
if (travel(tickets)) {
return true;
}
//如果没找到,则进行回溯接着进行循环遍历
//回溯
path.remove(path.size() - 1);
used[i] = false;
}
//如果没找到,则返回false
}
return false;
}
这种方法只能通过80/81个用例,最后一个会显示超时,但是
换一种判断是否使用的方式
在遍历 Map<出发机场, map<到达机场, 航班次数>> targets
的过程中,可以使用"航班次数"这个字段的数字做相应的增减,来标记到达机场是否使用过了。
如果“航班次数”大于零,说明目的地还可以飞,如果“航班次数”等于零说明目的地不能飞了,而不用对集合做删除元素或者增加元素的操作。
相当于说我不删,我就做一个标记!
public List<String> findItinerary(List<List<String>> tickets) {
map = new HashMap<>();
res = new LinkedList<>();
length = tickets.size();
//遍历车票
for (List<String> ticket : tickets) {
Map<String, Integer> temp;
if (map.containsKey(ticket.get(0))) {
//如果已经存在了,则先得到当前机票信息,然后修改机票的航班次数
temp = map.get(ticket.get(0));
//将map<到达机场,航班次数>,根据到达机场,修改航班次数
temp.put(ticket.get(1), temp.getOrDefault(ticket.get(1), 0) + 1);
} else {
//如果不存在,创建一个升序map
temp = new TreeMap<>();
//将当前航班次数,修改为1.
temp.put(ticket.get(1), 1);
}
//将当前航班信息更新到map中.
map.put(ticket.get(0), temp);
}
res.add("JFK");
travel();
return res;
}
//路线总长度,也就是票数
private int length;
private List<String> res;
//Map<出发机场, map<到达机场, 航班次数>> targets
private Map<String, Map<String, Integer>> map;
private boolean[] used;
/**
* @Description
* @Param ticketNum传入当前是第几个航班
* @Return {@link boolean}
* @Author 君君
* @Date 2024/7/16 22:31
*/
private boolean travel() {
if (length + 1 == res.size()) {
return true;
}
//获得路径中最后一个地点,也就是下一个航班的起始地点
String last = res.get(res.size() - 1);
if (!map.containsKey(last)) {
return false;
}
//找到起点是last的map,然后进行遍历
for (Map.Entry<String, Integer> target : map.get(last).entrySet()) {
//找到当前航班的数量
int count = target.getValue();
//如果还有航班,则进行递归回溯操作,没有航班则进入下一层循环
if (count > 0) {
res.add(target.getKey());
target.setValue(count - 1);
//如果在当前树枝找到了目标值,则返回true
if (travel()) {
return true;
}
res.remove(res.size() - 1);
target.setValue(count);
}
}
//在当前层没找到,则返回false
return false;
}
51.N皇后
按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n
个皇后放置在 n×n
的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n
,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q'
和 '.'
分别代表了皇后和空位。
示例 1:
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
示例 2:
输入:n = 1
输出:[["Q"]]
提示:
1 <= n <= 9
Related Topics
-
数组
-
回溯
思路
都知道n皇后问题是回溯算法解决的经典问题,但是用回溯解决多了组合、切割、子集、排列问题之后,遇到这种二维矩阵还会有点不知所措。
首先来看一下皇后们的约束条件:
- 不能同行
- 不能同列
- 不能同斜线
确定完约束条件,来看看究竟要怎么去搜索皇后们的位置,其实搜索皇后的位置,可以抽象为一棵树。
下面我用一个 3 * 3 的棋盘,将搜索过程抽象为一棵树,如图:
从图中,可以看出,二维矩阵中矩阵的高就是这棵树的高度,矩阵的宽就是树形结构中每一个节点的宽度。
那么我们用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了。
回溯三部曲
- 递归函数参数
我依然是定义全局变量二维数组res来记录最终结果。
参数n是棋盘的大小,然后用row来记录当前遍历到棋盘的第几层了。
代码如下:
List<List<String>> res = new ArrayList<>();
//List<String> path = new ArrayList<>();
//使用一个二维数组用来遍历整个期盼
private char[][] used;
/**
* @Description
* @Param n 棋盘的大小
* @Param row 当前的层数
* @Return
* @Author 君君
* @Date 2024/7/16 23:25
*/
private void travel(int n, int row){
}
- 递归终止条件
在如下树形结构中:
可以看出,当递归到棋盘最底层(也就是叶子节点)的时候,就可以收集结果并返回了。
代码如下:
//已经到了最底层,将当前的路径添加到结果中
if (row == n) {
res.add(Array2List(used));
return;
}
- 单层搜索的逻辑
递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置。
每次都是要从新的一行的起始位置开始搜,所以都是从0开始。
代码如下:
//没找到的话,则在本层遍历寻找符合的位置
for (int i = 0; i < n; i++) {
//如果合法的话,就放入皇后
if (isOk(n, used, row, i)) {
used[row][i] = 'Q';//放置皇后
travel(n, row + 1);
//回溯
used[row][i] = '.';
}
}
- 验证棋盘是否合法
按照如下标准去重:
- 不能同行
- 不能同列
- 不能同斜线 (45度和135度角)
代码如下:
/**
* @Description
* @Param n
* @Param used
* @Param row 当前填充到第几行
* @Param col 当前填充的最后一行的皇后所在位置,就是列
* @Return {@link boolean}
* @Author 君君
* @Date 2024/7/16 23:48
*/
private boolean isOk(int n, char[][] used, int row, int col) {
//检查列
//一行一行去检查(这里有一点点剪枝操作)
for (int i = 0; i < row; i++) {
if (used[i][col] == 'Q') {
return false;
}
}
//检查45度角
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (used[i][j] == 'Q') {
return false;
}
}
//检查135度角
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
if (used[i][j] == 'Q') {
return false;
}
}
return true;
}
在这份代码中,细心的同学可以发现为什么没有在同行进行检查呢?
因为在单层搜索的过程中,每一层递归,只会选for循环(也就是同一行)里的一个元素,所以不用去重了。
整体代码
public List<List<String>> solveNQueens(int n) {
used = new char[n][n];
for (char[] chars : used) {
Arrays.fill(chars, '.');
}
travel(n, 0);
return res;
}
List<List<String>> res = new ArrayList<>();
//List<String> path = new ArrayList<>();
//使用一个二维数组用来遍历整个期盼
private char[][] used;
/**
* @Description
* @Param n 棋盘的大小
* @Param row 当前的层数
* @Return
* @Author 君君
* @Date 2024/7/16 23:25
*/
private void travel(int n, int row) {
//已经到了最底层,将当前的路径添加到结果中
if (row == n) {
res.add(Array2List(used));
return;
}
//没找到的话,则在本层遍历寻找符合的位置
for (int i = 0; i < n; i++) {
//如果合法的话,就放入皇后
if (isOk(n, used, row, i)) {
used[row][i] = 'Q';//放置皇后
travel(n, row + 1);
//回溯
used[row][i] = '.';
}
}
}
/**
* @Description
* @Param n
* @Param used
* @Param row 当前填充到第几行
* @Param col 当前填充的最后一行的皇后所在位置,就是列
* @Return {@link boolean}
* @Author 君君
* @Date 2024/7/16 23:48
*/
private boolean isOk(int n, char[][] used, int row, int col) {
//检查列
//一行一行去检查(这里有一点点剪枝操作)
for (int i = 0; i < row; i++) {
if (used[i][col] == 'Q') {
return false;
}
}
//检查45度角
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (used[i][j] == 'Q') {
return false;
}
}
//检查135度角
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
if (used[i][j] == 'Q') {
return false;
}
}
return true;
}
/**
* @Description
* @Param used 将二维数组转化为列表
* @Return {@link java.util.List<java.lang.String>}
* @Author 君君
* @Date 2024/7/16 23:28
*/
private List<String> Array2List(char[][] used) {
List<String> list = new ArrayList<>();
for (char[] c : used) {
list.add(String.copyValueOf(c));
}
return list;
}
- 时间复杂度: O(N!)
- 空间复杂度: O(N^2)
37.解数独
编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
- 数字
1-9
在每一行只能出现一次。 - 数字
1-9
在每一列只能出现一次。 - 数字
1-9
在每一个以粗实线分隔的3x3
宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 '.'
表示。
示例 1:
输入:board = [["5","3",".",".","7",".",".",".","."],["6",".",".","1","9","5",".",".","."],[".","9","8",".",".",".",".","6","."],["8",".",".",".","6",".",".",".","3"],["4",".",".","8",".","3",".",".","1"],["7",".",".",".","2",".",".",".","6"],[".","6",".",".",".",".","2","8","."],[".",".",".","4","1","9",".",".","5"],[".",".",".",".","8",".",".","7","9"]]
输出:[["5","3","4","6","7","8","9","1","2"],["6","7","2","1","9","5","3","4","8"],["1","9","8","3","4","2","5","6","7"],["8","5","9","7","6","1","4","2","3"],["4","2","6","8","5","3","7","9","1"],["7","1","3","9","2","4","8","5","6"],["9","6","1","5","3","7","2","8","4"],["2","8","7","4","1","9","6","3","5"],["3","4","5","2","8","6","1","7","9"]]
解释:输入的数独如上图所示,唯一有效的解决方案如下所示:
提示:
board.length == 9
board[i].length == 9
board[i][j]
是一位数字或者'.'
- 题目数据 保证 输入数独仅有一个解
Related Topics
-
数组
-
哈希表
-
回溯
-
矩阵
思路
棋盘搜索问题可以使用回溯法暴力搜索,只不过这次我们要做的是二维递归。
怎么做二维递归呢?
大家已经跟着「代码随想录」刷过了如下回溯法题目,例如:77.组合(组合问题) (opens new window),131.分割回文串(分割问题) (opens new window),78.子集(子集问题) (opens new window),46.全排列(排列问题) (opens new window),以及51.N皇后(N皇后问题) (opens new window),其实这些题目都是一维递归。
如果以上这几道题目没有做过的话,不建议上来就做这道题哈!
N皇后问题 (opens new window)是因为每一行每一列只放一个皇后,只需要一层for循环遍历一行,递归来遍历列,然后一行一列确定皇后的唯一位置。
本题就不一样了,本题中棋盘的每一个位置都要放一个数字(而N皇后是一行只放一个皇后),并检查数字是否合法,解数独的树形结构要比N皇后更宽更深。
因为这个树形结构太大了,我抽取一部分,如图所示:
回溯三部曲
- 递归函数以及参数
递归函数的返回值需要是bool类型,为什么呢?
因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用bool返回值。
代码如下:
private boolean travel(char[][] board){
}
- 递归终止条件
本题递归不用终止条件,解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。
不用终止条件会不会死循环?
递归的下一层的棋盘一定比上一层的棋盘多一个数,等数填满了棋盘自然就终止(填满当然好了,说明找到结果了),所以不需要终止条件!
那么有没有永远填不满的情况呢?
- 递归单层搜索逻辑
在树形图中可以看出我们需要的是一个二维的递归 (一行一列)
一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性!
//遍历行
for (int i = 0; i < 9; i++) {
//遍历列
for (int j = 0; j < 9; j++) {
//如果当前位置有数字了,则跳过本次循环
if (board[i][j] != '.') {
continue;
}
// (i, j) 这个位置放k是否合适
for (int k = '1'; k <= '9'; k++) {
//如果合适的话,就插入
if (isOk(i, j, k, board)) {
board[i][j] = (char) k;
if (travel(board)) {
return true;
}
board[i][j] = '.';
}
}
//9个数都试过了,但是没有符合要求的则返回false;
return false;
}
}
//遍历完成之后没有返回false,则说明找到了合适的棋盘位置
return true;
注意这里return false的地方,这里放return false 是有讲究的。
因为如果一行一列确定下来了,这里尝试了9个数都不行,说明这个棋盘找不到解决数独问题的解!
那么会直接返回, 这也就是为什么没有终止条件也不会永远填不满棋盘而无限递归下去!
- 判断棋盘是否合法
判断棋盘是否合法有如下三个维度:
- 同行是否重复
- 同列是否重复
- 9宫格里是否重复
代码如下:
/**
* @Description 判断当前数独棋盘在[i][j]位置放入k是否合适
* @Param i
* @Param j
* @Param k
* @Param board
* @Return {@link boolean}
* @Author 君君
* @Date 2024/7/17 0:39
*/
private boolean isOk(int i, int j, int k, char[][] board) {
//判断同行是否重读
for (int l = 0; l < 9; l++) {
if (board[i][l] == k) {
return false;
}
}
//判断同列是否重读
for (int l = 0; l < 9; l++) {
if (board[l][j] == k) {
return false;
}
}
//判断9宫格是否重复
//定义9宫格起始位置
int startRow = (i / 3) * 3;
int startCol = (j / 3) * 3;
for (int l = startRow; l < startRow + 3; l++) {
for (int m = startCol; m < startCol + 3; m++) {
if (board[l][m] == k) {
return false;
}
}
}
return true;
}
整体代码
public void solveSudoku(char[][] board) {
travel(board);
}
private boolean travel(char[][] board) {
//遍历行
for (int i = 0; i < 9; i++) {
//遍历列
for (int j = 0; j < 9; j++) {
//如果当前位置有数字了,则跳过本次循环
if (board[i][j] != '.') {
continue;
}
// (i, j) 这个位置放k是否合适
for (int k = '1'; k <= '9'; k++) {
//如果合适的话,就插入
if (isOk(i, j, k, board)) {
board[i][j] = (char) k;
if (travel(board)) {
return true;
}
board[i][j] = '.';
}
}
//9个数都试过了,但是没有符合要求的则返回false;
return false;
}
}
//遍历完成之后没有返回false,则说明找到了合适的棋盘位置
return true;
}
/**
* @Description 判断当前数独棋盘在[i][j]位置放入k是否合适
* @Param i
* @Param j
* @Param k
* @Param board
* @Return {@link boolean}
* @Author 君君
* @Date 2024/7/17 0:39
*/
private boolean isOk(int i, int j, int k, char[][] board) {
//判断同行是否重读
for (int l = 0; l < 9; l++) {
if (board[i][l] == k) {
return false;
}
}
//判断同列是否重读
for (int l = 0; l < 9; l++) {
if (board[l][j] == k) {
return false;
}
}
//判断9宫格是否重复
//定义9宫格起始位置
int startRow = (i / 3) * 3;
int startCol = (j / 3) * 3;
for (int l = startRow; l < startRow + 3; l++) {
for (int m = startCol; m < startCol + 3; m++) {
if (board[l][m] == k) {
return false;
}
}
}
return true;
}
时间复杂度O(9^N)
空间复杂度O(1)
回溯总结
回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。
回溯算法能解决如下问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题:N皇后,解数独等等