文章目录
- 组合
- (*中等)77. 组合
- (*中等)17. 电话号码的字母组合
- (中等)39. 组合总和
- (中等)40. 组合总和II
- (中等)216. 组合总和|||
- 分割
- (*中等)131. 分割回文串
- (*中等)93. 复制IP地址
- 子集
- (中等)78. 子集
- (中等)90. 子集II
- 排列
- (中等)46. 全排列
- (中等)47. 全排列II
- 棋盘问题
- HDU - 2553 N皇后问题
- (困难)51. N皇后
- (困难)37. 解数独
- 其他
- (中等)491. 递增子序列
- (*困难)332. 重新安排行程 欧拉回路/欧拉通路
组合
(*中等)77. 组合
以下思路参考链接https://leetcode.cn/problems/combinations/solution/hui-su-suan-fa-jian-zhi-python-dai-ma-java-dai-ma-/
- 如果解决一个问题有多个步骤,每一个步骤有多种方法,题目又需要我们找出所有的方法,可以使用回溯算法
- 回溯算法是在一棵树上的深度优先遍历
- 组合问题,相对于排列问题而言,不计较一个组合内元素的顺序性([1,2,3]和[1,3,2]被认为是同一个组合),因此很多时候需要按某种顺序展开搜索,这样才能做到不重不漏
说明:
- 叶子节点的信息体现在从根节点到叶子节点的路径上,因此需要一个表示路径的遍历(也就是一个列表,其中包含了目前已选择的元素)
- 每一个节点递归地在做同样的事情,区别在于搜索起点,因此需要一个变量start,表示在区间[start, n]里选出若干个数的组合
- 可能有一些分支是没有必要执行的,比如选4这条路径,就没有必要
import java.util.ArrayList;
import java.util.List;
class Solution {
List<List<Integer>> res;
public List<List<Integer>> combine(int n, int k) {
res = new ArrayList<>();
List<Integer> list = new ArrayList<>(k);
dfs(1, n, k, list);
return res;
}
public void dfs(int start, int n, int k, List<Integer> list) {
if (list.size() == k) {
res.add(new ArrayList<>(list));
return;
}
for (int i = start; i <= n; i++) {
list.add(i);
dfs(i + 1, n, k, list);
list.remove(list.size() - 1);
}
}
}
优化:分析搜索起点的上界进行剪枝
事实上,如果n=7,k=4,从5开始搜索就没有意义了,因为,即时把5选上,后面的数字只有6和7,一共3个可选的数字,凑不出4个数字的组合。因此,搜索起点有上界。
分析搜索树起点的上界,其实是在深度优先遍历的过程中剪枝,剑指可以避免不必要的遍历,剪枝剪得好,可以大幅度节约算法的执行时间。
搜索起点和当前还需要选几个数有关,而当前还需要几个数与已经选了几个数有关,也就是与path的长度有关。
例如,n=6,k=4
- path.size=0,没有选,搜索起点最大是3,最后一个被选的组合是[3,4,5,6]
- path.size=1,已经选了一个数,搜素起点最大是4,最后一个被选的组合是[4,5,6]
- path.size=2,已经选了两个数,搜素起点最大是5,最后一个被选的组合是[5,6]
- path.size=3,已经选了三个数,搜素起点最大是6,最后一个被选的组合是[6]
【解释】
path.size=0的时候,需要确定四个数中的第一个数字,需要从1到6的数字中选出四个数字,为了不重不漏,按顺序选取,如果第一个数字选4,后面只剩下5和6,无法凑成四个数字,所以,对于第一位的数字,最大选择3
path.size=1,这时需要确定四个数字中的第二个数字,按顺序选取,最大选择4,如果选择5的话,5后面只有6,无法凑成四个数字
其他的path.size以此类推
n = 6 ,k = 4,从下表可以看出规律,搜索起点最大是n-(k-path.size)+1
path.size(已经选了多少个) | k-path.size(还需要选多少个) | 搜索起点最大是 |
---|---|---|
0 | 4 | 3 |
1 | 3 | 4 |
2 | 2 | 5 |
3 | 1 | 6 |
import java.util.ArrayList;
import java.util.List;
class Solution {
List<List<Integer>> res;
public List<List<Integer>> combine(int n, int k) {
res = new ArrayList<>();
List<Integer> list = new ArrayList<>(k);
dfs(1, n, k, list);
return res;
}
public void dfs(int start, int n, int k, List<Integer> list) {
if (list.size() == k) {
res.add(new ArrayList<>(list));
return;
}
for (int i = start; i <= n - (k - list.size()) + 1; i++) {
list.add(i);
dfs(i + 1, n, k, list);
list.remove(list.size() - 1);
}
}
}
(*中等)17. 电话号码的字母组合
我的思路,使用队列,BFS思想
以"23"为例,首先把2对应的abc分别放入队列,此时队列中的元素是a,b,c。然后取出a,在a后面加上3对应的字母,生成ad,ae,af,放入队列,取出b,生成bd,be,bf,放入队列,取出c,生成cd,ce,cf,放入队列。
import java.util.*;
class Solution {
public List<String> letterCombinations(String digits) {
ArrayList<String> res = new ArrayList<>();
if (digits == null || digits.length() == 0) {
return res;
}
Map<Integer, String> map = new HashMap<>();
map.put(2, "abc");
map.put(3, "def");
map.put(4, "ghi");
map.put(5, "jkl");
map.put(6, "mno");
map.put(7, "pqrs");
map.put(8, "tuv");
map.put(9, "wxyz");
Queue<String> queue = new LinkedList<>();
queue.add("");
for (char number : digits.toCharArray()) {
String str = map.get(number - '0');
int size = queue.size();
for (int i = 0; i < size; i++) {
String pollStr = queue.poll();
for (char c : str.toCharArray()) {
queue.add(pollStr + c);
}
}
}
while (!queue.isEmpty()) {
res.add(queue.poll());
}
return res;
}
}
回溯,使用哈希表存储每个数字对那个的所有可能的字母,然后进行回溯操作。
回溯过程维护一个字符串,表示已有的字母排列(如果未遍历完电话号码的所有数字,则已有的字母排列是不完整的)。
该字符串初始为空,每次取电话号码的一位数字,从哈希表中获取该数字对应的所有可能的字母,并将其中一个字母插入到已有的字母排列后面,然后继续处理电话号码的后一位数字,直到处理完所有电话号码中的所有数字,即得到一个完整的字母排列。然后进行回退操作,遍历其余的字母排列。
回溯算法用于寻找所有的可行解,如果发现一个解不可行,则会舍弃不可行的解。在这道题中,由于每个数字对应的每个字母都可能进入字母组合,因此不存在不可行的解,直接穷举所有的解即可。
import java.util.*;
class Solution {
ArrayList<String> res = new ArrayList<>();
Map<Integer, String> map = new HashMap<>();
String digits;
public List<String> letterCombinations(String digits) {
this.digits = digits;
if (digits == null || digits.length() == 0) {
return res;
}
map.put(2, "abc");
map.put(3, "def");
map.put(4, "ghi");
map.put(5, "jkl");
map.put(6, "mno");
map.put(7, "pqrs");
map.put(8, "tuv");
map.put(9, "wxyz");
dfs(0, new StringBuilder());
return res;
}
public void dfs(int index, StringBuilder stringBuilder) {
if (index == digits.length()) {
res.add(stringBuilder.toString());
return;
}
String letters = map.get(digits.charAt(index) - '0');
for (int i = 0; i < letters.length(); i++) {
stringBuilder.append(letters.charAt(i));
dfs(index + 1, stringBuilder);
stringBuilder.deleteCharAt(index);
}
}
}
复杂度分析:
- 时间复杂度:O( 3 m × 4 n 3^m \times 4^n 3m×4n),其中m是输入中对应3个字母的数字个数(包括数字2、3、4、5、6、8),n是输入中对应4个字母的数字个数(包括7,9),m+n是输入数字的总个数。当输入包含m个字母的数字和n个对应4个字母的数字时,不同的字母组合一共有 3 m × 4 n 3^m \times 4^n 3m×4n种,需要遍历每一种字母组合
- 空间复杂度:O(m+n),其中m是输入中对应3个字母的数字个数(包括数字2、3、4、5、6、8),n是输入中对应4个字母的数字个数(包括7,9),m+n是输入数字的总个数。除了返回值以外,空间复杂度主要取决于哈希表以及回溯过程中的递归调用层数,哈希表的大小与输入无关,可以看成常数,递归调用的层数最大为m+n
也就是有多少个数字,递归调用多少层
(中等)39. 组合总和
import java.util.ArrayList;
import java.util.List;
class Solution {
private int[] candidates;
private List<List<Integer>> res;
private List<Integer> list;
private int target;
public List<List<Integer>> combinationSum(int[] candidates, int target) {
this.candidates = candidates;
res = new ArrayList<>();
list = new ArrayList<>();
this.target = target;
dfs(0, 0);
return res;
}
public void dfs(int sum, int index) {
if (sum == target) {
res.add(new ArrayList<>(list));
return;
}
if (sum > target) {
return;
}
if (index == candidates.length) {
return;
}
//跳过当前位置
dfs(sum, index + 1);
list.add(candidates[index]);
//不跳过当前位置
dfs(sum + candidates[index], index);
list.remove(list.size() - 1);
}
}
本来想写for循环,因为每个数字可以重复取,但是会出现重复的列表,例如,对于示例1,candicates=[2,3,6,7],target=[7],如果写成for循环,答案会是[[2,2,3],[2,3,2],[3,2,2],[7]]
所以,在dfs方法中加入一个形参index,表示当前遍历到的candidates数组中的下标,跳过还是不跳过,这个角度来写代码
对于这类寻找所有可行解的题,都可以尝试用【搜索回溯】的方法来解决。
回到本题,定义dfs(target,combine,idx)表示当前在candidates数组的第idx位,还剩target要组合,已经组合的列表为combine。递归终止条件为target<=0或者candidates数组被全部用完。那么在当前的函数中,每次我们可以选择跳过不用第idx个数,即执行dfs(target,combine,idx+1)。也可以选择使用第idx个数,即执行dfs(target-candidates[idx],combine,idx),注意每个数字可以被无限制重复选取,因此搜索的下标仍为idx。
import java.util.ArrayList;
import java.util.List;
class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> combine = new ArrayList<>();
dfs(candidates, target, res, combine, 0);
return res;
}
private void dfs(int[] candidates, int target, List<List<Integer>> res, List<Integer> combine, int idx) {
if (idx == candidates.length) {
return;
}
if (target == 0) {
res.add(new ArrayList<>(combine));
return;
}
//直接跳过当前位置
dfs(candidates, target, res, combine, idx + 1);
//不跳过,选择当前位置,将candidates当前位置的值加入列表combine
if (target - candidates[idx] >= 0) {
combine.add(candidates[idx]);
dfs(candidates, target - candidates[idx], res, combine, idx);
combine.remove(combine.size() - 1);
}
}
}
复杂度分析:
- 时间复杂度:O(S),其中S为所有可行解的长度之和。时间复杂度取决于搜索树所有叶子节点的深度之和,即所有可行解的长度之和。O( n × 2 n n\times2^n n×2n)是一个比较松的上界,即在这份代码中,n个位置每次考虑选或者不选,如果符合条件,就加入答案的时间代价。因为不可能所有的解都满足条件,递归地时候还会if判断target-candidates[idx]>=0进行剪枝,所以实际运行情况是远小于这个上界的。
- 空间复杂度:O(target),除答案数组外,空间复杂度取决于递归的栈深度,在最差的情况下需要递归O(target)层。
在做了其他题目以后,把for循环版本的写出来了,为了不重复,出现[2,3,2]和[3,2,2]这种情况,在写for循环的初始化条件时 ,需要初始化为index,这样,只能选取index和其后面的数字,在dfs函数中第一个参数写i,表示可以重复选取
import java.util.ArrayList;
import java.util.List;
class Solution {
int[] candidates;
int target;
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
this.candidates = candidates;
this.target = target;
dfs(0, target);
return res;
}
private void dfs(int index, int target) {
if (target == 0) {
res.add(new ArrayList<>(list));
return;
}
for (int i = index; i < candidates.length; i++) {
if (target - candidates[i] >= 0) {
list.add(candidates[i]);
dfs(i, target - candidates[i]);
list.remove(list.size() - 1);
}
}
}
}
(中等)40. 组合总和II
我的错误dfs代码
private void dfs(int idx, int target, List<List<Integer>> res, List<Integer> list, int[] candidates) {
if (target == 0) {
res.add(new ArrayList<>(list));
return;
}
for (int i = idx; i < candidates.length; i++) {
if (target - candidates[i] >= 0) {
list.add(candidates[i]);
dfs(i + 1, target - candidates[i], res, list, candidates);
list.remove(list.size() - 1);
}
}
}
按照这种dfs的方式
输入的是:
[10,1,2,7,6,1,5]
8
数组中出现了两个1,有的时候得把这两个1看成是不同的1,所以输出[1,1,6]
有时候,又得把这两个1看成是一样的1,所以只能输出一个[1,7]或者[7,1]
不知道该如何处理…
输出的是
[[1,2,5],[1,7],[1,6,1],[2,6],[2,1,5],[7,1]]
预期结果是
[[1,1,6],[1,2,5],[1,7],[2,6]]
与39题(组合总和)的差别
- 第39题,candidates中的数字可以无限制重复被选取,candidates数组中的元素是不重复的
- 第40题,candidates中的每个数字在每个组合中只能使用一次,candidates数组中的元素是可重复的
与39题的相同点
相同数字列表的不同排列视为一个结果
如何去掉重复的结合,是重点
为了使得阶级不包含重复的组合,有以下两种方案:
- 使用哈希表天然去重的功能,但编码相对复杂
- 使用和第15题(三数之和)类似的思路:不重复就需要按顺序搜索,在搜索的过程中检测分支是否会出现重复的结果。注意:这里的顺序不仅仅指数组candidates有序,还指按照一定顺序搜索结果。
题目中的关键信息
每一个数最多只能用一次,因此,深层节点可以考虑的分支越来越少
在同一层节点上,如果上一次减去的数相同,只需要保留第一个分支的结果
这也就是在代码中,for循环中的第一个if判断
if(idx>0 && candidates[i]==candidates[i-1])
一开始我写成了
if(i>0 && candidates[i]==candidates[i-1])
i > 0:得到的结果是[[1, 2, 5],[1, 7],[2, 6]]
i > idx:得到的结果是[[1, 1, 6],[1, 2, 5],[1, 7],[2, 6]]
这两个的区别就在于,写成i>0,表示排序后数组中只要有相同的数,就会跳过,但这样就不满足题目的要求了,因为[1,1,6]也是正确的组合。
而写成idx,表示,在这一层中,如果遇到相同的数,则剪枝,但是在不同层中,是允许相同的数字出现的
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class Solution {
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
Arrays.sort(candidates);
dfs(0, target, res, list, candidates);
return res;
}
private void dfs(int idx, int target, List<List<Integer>> res, List<Integer> list, int[] candidates) {
if (target == 0) {
res.add(new ArrayList<>(list));
return;
}
for (int i = idx; i < candidates.length; i++) {
if (i > idx && candidates[i] == candidates[i - 1]) {
continue;
}
if (target - candidates[i] >= 0) {
list.add(candidates[i]);
dfs(i + 1, target - candidates[i], res, list, candidates);
list.remove(list.size() - 1);
}
}
}
}
(中等)216. 组合总和|||
一开始dfs函数中的两个if的顺序不对,这样的写法,用例k=9,n=45,得到的结果是[[]],但正确的答案是[[1,2,3,4,5,6,7,8,9]],原因是,当list中添加完9以后,i+1=10,直接被第一个if return了,所以需要把两个if调换一下位置。
private void dfs(int index, int n, int k, List<List<Integer>> res, List<Integer> list) {
if (index > 9 || list.size() > k) {
return;
}
if (list.size() == k && n == 0) {
res.add(new ArrayList<>(list));
return;
}
for (int i = index; i <= 9; i++) {
if (n - i >= 0) {
list.add(i);
dfs(i + 1, n - i, k, res, list);
list.remove(list.size() - 1);
}
}
}
import java.util.ArrayList;
import java.util.List;
class Solution {
public List<List<Integer>> combinationSum3(int k, int n) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
dfs(1, n, k, res, list);
return res;
}
//和为n的k个数
private void dfs(int index, int n, int k, List<List<Integer>> res, List<Integer> list) {
if (list.size() == k && n == 0) {
res.add(new ArrayList<>(list));
return;
}
if (index > 9 || list.size() > k) {
return;
}
for (int i = index; i <= 9; i++) {
if (n - i >= 0) {
list.add(i);
dfs(i + 1, n - i, k, res, list);
list.remove(list.size() - 1);
}
}
}
}
其他思路,二进制(子集)枚举
【组合中只允许含有1-9的正整数,并且每种组合中不存在重复的数字】意味着这个组合中最多包含九个数字。
可以把原问题转化成集合S={1,2,3,4,5,6,7,8,9},找出S的当中满足如下条件的子集
- 大小为k
- 集合中元素的和为n
因此,可以用子集枚举的方法,即原序列中有9个数,每个数都有两种状态,被选择到子集中和不被选择到子集中,所以状态的总数为 2 9 2^9 29。用一个9位二进制数mask来记录当前所有位置的状态。第i位为0表示不被选择到子集中,为1表示i被选择到子集中。当按顺序枚举[0, 2 9 − 1 2^9-1 29−1]中的所有整数的时候,就可以不重不漏地把每个状态枚举到,对于一个状态mask,就可以用位运算的方法得到对应的子集序列,然后再判断是否满足上面两个条件(感觉有点复杂…列举所有情况,再判断是否满足条件…不应该是根据条件去找答案吗),如果满足,就记录答案。
如何通过位运算来得到mask各个位置的信息?对于第i个位置,可以通过(1<<i)&mask是否为0,如果不为0,说明i在子集当中。当然,这里要注意的是,一个9位二进制数i的范围是[0,8],而可选择的数字是[1,9],所以需要一个映射,最简单的办法就是当第i位为1时,将i+1加入子集
import java.util.ArrayList;
import java.util.List;
class Solution {
List<Integer> list = new ArrayList<>();
List<List<Integer>> res = new ArrayList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
for (int mask = 0; mask < (1 << 9); mask++) {
if (check(mask, k, n)) {
res.add(new ArrayList<>(list));
}
}
return res;
}
//检查当前值是否满足条件
private boolean check(int mask, int k, int n) {
list.clear();
for (int i = 0; i < 9; i++) {
if (((1 << i) & mask) != 0) {
list.add(i + 1);
}
}
if (list.size() != k) {
return false;
}
int sum = 0;
for (int num : list) {
sum += num;
}
return sum == n;
}
}
复杂度分析:
-
时间复杂度:O( M × 2 M M\times2^M M×2M),其中M为集合的大小,本题中M固定为9。一共有 2 M 2^M 2M个状态,每个状态需要O(M+k)=O(M)的判断(k<=M),故时间复杂度为O( M × 2 M M\times2^M M×2M)
-
空间复杂度:O(M),即list的空间代价。
分割
(*中等)131. 分割回文串
dfs遍历所有的子串,检查子串是否是回文串,是的话,就加入res结果列表,
import java.util.ArrayList;
import java.util.List;
class Solution {
private List<List<String>> res = new ArrayList<>();
private List<String> list = new ArrayList<>();
private String s;
public List<List<String>> partition(String s) {
this.s = s;
dfs(0);
return res;
}
//遍历所有子串
private void dfs(int index) {
if (index == s.length()) {
res.add(new ArrayList<>(list));//固定答案
return;
}
for (int i = index; i < s.length(); i++) {//枚举子串结束的位置
if (check(index, i)) {
list.add(s.substring(index, i + 1));
dfs(i + 1);
list.remove(list.size() - 1);//恢复现场
}
}
}
//用于检查从index到i的子串是否是回文串
private boolean check(int left, int right) {
while (left < right) {
if (s.charAt(left++) != s.charAt(right--)) {
return false;
}
}
return true;
}
}
for循环表示横向扩展,dfs表示纵向扩展
(*中等)93. 复制IP地址
由于需要找出所有可能的IP地址,因此可以考虑使用回溯的方法,对所有可能的字符串分隔方式进行搜索,并筛选出满足要求的作为答案。
我们用递归函数dfs(segId, segStart)表示我们正在从s[segStart]位置开始,搜索IP地址中的第segId段,其中segId的取值可以是0,1,2,3。由于IP地址的每一段必须是[0,255]的整数,因此可以从segStart开始,从小到大依次枚举当前这一段IP地址的结束位置segEnd。如果满足要求,就递归到下一段进行搜索,递归函数调用dfs(segId+1,segStart+1)。
特别地,由于IP地址的每一段不能又前导零,因此如果s[segStart]等于字符0,那么IP地址第segId段只能为0,需要作为特殊情况进行考虑。
在搜索过程中,如果我们已经得到了全部的4段IP地址(即segId=4),并且遍历完了整个字符串(即segStart=|s|,其中|s|表示字符串s的长度),那么就复原出了一种满足题目要求的IP地址,将其加入答案。在其他的时刻,如果提前遍历完了整个字符串,那就需要结束搜索,回溯到上一步。
import java.util.ArrayList;
import java.util.List;
class Solution {
String s;
static final int SEG_COUNT = 4;
List<String> res = new ArrayList<String>();
int[] segments = new int[SEG_COUNT];
public List<String> restoreIpAddresses(String s) {
this.s = s;
dfs(0, 0);
return res;
}
private void dfs(int segId, int segStart) {
//找到满足条件的,将答案添加到结果集中
if (segId == SEG_COUNT && segStart == s.length()) {
StringBuilder ipAddr = new StringBuilder();
for (int i = 0; i < SEG_COUNT; i++) {
ipAddr.append(segments[i]);
if (i != SEG_COUNT - 1) {
ipAddr.append(".");
}
}
res.add(ipAddr.toString());
return;
}
//已经找到四段IP,但是字符串还没有遍历完成
if (segId == SEG_COUNT) {
return;
}
//如果还没找到4段IP地址就已经遍历完毕了字符串,那么提起回溯
if (segStart == s.length()) {
return;
}
//由于不能有前导零,如果当前数字为0,那么这一段IP地址只能为0
if (s.charAt(segStart) == '0') {
segments[segId] = 0;
dfs(segId + 1, segStart + 1);
}
int sum = 0;
//枚举每一种可能性并递归
for (int i = segStart; i < s.length(); i++) {
sum = sum * 10 + (s.charAt(i)) - '0';
if (sum > 0 && sum <= 255) {
segments[segId] = sum;
dfs(segId + 1, i + 1);
} else {
//break的含义:从segStart开始后序没有满足条件的数字,则回退到上一层,类似于N皇后的思想
break;
}
}
}
}
如果是碰到0的情况,会先把0作为一个元素填到数组中,如果这样的方式不满足,则回溯,进入for循环,由于0只能单独出现,不能作为前导零,则继续回溯到上一层,重新划分
子集
(中等)78. 子集
得到该数组的所有子集,对于数组中的每个元素来说,就是选或者不选该元素。
import java.util.ArrayList;
import java.util.List;
class Solution {
int[] nums;
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
public List<List<Integer>> subsets(int[] nums) {
this.nums = nums;
dfs(0);
return res;
}
public void dfs(int index) {
if (index == nums.length) {
res.add(new ArrayList<>(list));
return;
}
//不选当前位置上的数字
dfs(index + 1);
//选当前位置上的数字
list.add(nums[index]);
dfs(index + 1);
list.remove(list.size() - 1);
}
}
复杂度分析:
- 时间复杂度:O( n × 2 n n\times2^{n} n×2n),一共 2 n 2^n 2n个状态,每种状态需要O(n)的时间来构造子集
- 空间复杂度:O(n),临时数组list的空间代价是O(n),递归时栈空间的代价为O(n)
其他方法,迭代实现子集的枚举
记原序列中元素的总数为n。原序列中的每个数字 a i a_i ai的状态可能有两种,即【在子集中】和【不在子集中】。用1表示【在子集中】,0表示【不在子集中】,那么每一个子集对应一个长度为n的0/1序列,第i位表示 a i a_i ai是否在子集中
可以发现0/1序列对应的二进制数正好从0到 2 n − 1 2^n-1 2n−1。可以枚举mask ∈ \in ∈[0, 2 n − 1 2^n-1 2n−1],mask的二进制表示是一个0/1序列,可以按照这个0/1序列在原集合当中取数。当枚举完所有2^n个mask,也就能构造出所有的子集。
import java.util.ArrayList;
import java.util.List;
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
public List<List<Integer>> subsets(int[] nums) {
int n = nums.length;
//2^n-1 种状态
for (int mask = 0; mask < (1 << n); mask++) {
list.clear();
//依据某种状态从集合种取出元素
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) != 0) {
list.add(nums[i]);
}
}
res.add(new ArrayList<>(list));
}
return res;
}
}
复杂度分析:
- 时间复杂度:O( n × 2 n n\times2^{n} n×2n),一共 2 n 2^n 2n个状态,每种状态需要O(n)的时间来构造子集
- 空间复杂度:O(n),临时数组list的空间代价是O(n),递归时栈空间的代价为O(n)
(中等)90. 子集II
private void dfs(int index) {
if (index == nums.length) {
res.add(new ArrayList<>(list));
return;
}
for (int i = index; i < nums.length; i++) {
if (i > index && nums[i] == nums[i - 1]) {
continue;
}
dfs(i + 1);
list.add(nums[i]);
dfs(i + 1);
list.remove(list.size() - 1);
}
}
之前有过类似的题目,就是在不同情况下数字表示的情况不一样,第40题组合总和,但是又不太一样
输入
[1,2,2]
输出
[[],[2],[2],[2,2],[1],[1,2],[1,2],[1,2,2],[],[2],[2],[2,2]]
预期结果
[[],[1],[1,2],[1,2,2],[2],[2,2]]
看到代码随想录中的树形图,改变一下向res结果集中添加元素的位置,就可以得到正确的答案
从图中可以看出,同一树层上重复取2就要过滤掉,同一树枝上就可以重复取2,因为同一树枝上的元素的集合才是唯一的子集。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
int[] nums;
public List<List<Integer>> subsetsWithDup(int[] nums) {
this.nums = nums;
Arrays.sort(nums);
res.add(new ArrayList<>());
dfs(0);
return res;
}
private void dfs(int index) {
for (int i = index; i < nums.length; i++) {
if (i > index && nums[i] == nums[i - 1]) {
continue;
}
list.add(nums[i]);
res.add(new ArrayList<>(list));
dfs(i + 1);
list.remove(list.size() - 1);
}
}
}
官方思路,递归实现子集枚举
考虑数组[1,2,2],选择前两个数,或者第一、三个数,都会得到相同的子集。
也就是说,对于当前选择的数x,若当前有与其相同的数y,且没有选择y,此时包含x的子集,必然会出现在包含y的所有子集中。
可以通过判断这种情况,来避免重复的子集。代码实现时,可以先将数组排序;递归时,若发现没有选择上一个数,且当前数字与上一个数相同,就可以跳过当前生成的子集。
在递归时,若发现没有选择上一个数,且当前数字与上一个数相同,则可以跳过当前生成的子集。
这个地方没看懂,不理解,没有选择上一个数,且当前数字与上一个数字相同,就可以跳过当前生成的子集???
为什么没有选择上一个,还要跳过当前?因为要选的话,也是选重复数字的第一个???
代码随想录中,使用的是used数组(其实和官方代码中的一个boolean类型的变量是一样的)
i=2,nums[i]=nums[i-1],used[i-1]=true,说明同一树枝上有两个重复的元素nums[2]和nums[1]可以重复选取
i=2,nums[i]=nums[i-1],used[i-1]=false,表示同一树层candidates[i-1]使用过,而我们要对同一树层使用过的元素进行跳过,也就是对同一树层进行去重。
如果if判断条件是i>0 而不是i>index,那么需要一个boolean变量标记在这个数和前面那个数相同的时候,是否选择了前面那个数
排列
(中等)46. 全排列
回溯法采用试错的思想,它尝试分步地去解决一个问题。在分布解决问题的过程中,当它通过尝试发现现有的分布答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其他的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:
- 找到了一个可能存在的正确答案
- 在尝试了所有可能的分布方法后宣告该问题没有答案
深度优先搜索算法(DFS)是一种用于遍历或搜索树或图的算法。这个算法会尽可能深的搜索树的分支。当节点v的所在边都已被探寻过,搜索将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。如果还存在未被发现的节点,则选择其中一个作为源节点并重复以上过程,整个进程反复进行直到所有节点都被访问为止。
回溯算法与深度优先遍历都有【不撞南墙不回头】不回头的意思。【回溯算法】强调了【深度优先搜索】思想的用途,用一个不断变化的变量,在尝试各种可能的过程中,搜索需要的结果。强调了回退操作对于搜索的合理性。而【深度优先遍历】强调一种遍历的思想,与之对应的遍历思想是【广度优先遍历】。
说明:
- 每一个节点表示了求解全排列问题的不同阶段,这个阶段通过变量的【不同的值】体现,这些变量的不同的值,称之为【状态】
- 使用深度优先遍历有【回头】的过程,在【回头】以后,状态变量需要设置成为和先前一样,因此在回到上一层节点的过程中,需要撤销上一次的选择,这个操作称为【状态重置】
- 深度优先遍历,借助系统栈空间,保存所需的状态变量,在编码中只需要注意遍历到相应的节点的时候,状态变量的值是正确的,具体做法是:往下走一层的时候,path变量在尾部追加,而往回走的时候,需要撤销上一次的选择,也是在尾部操作,因此,path变量是一个栈
- 深度优先遍历通过【回溯】操作,实现了全局使用一份状态变量的效果
设计状态变量
- 首先,这棵树除了根节点和叶子节点以外,每一个节点做的事情是一样的,即在已选择了一些数的前提下,在剩下的还没有选择的数种,依次选择一个数,这显然是一个递归结构
- 递归的终止条件是:一个排列中的数字已经选够了,因此需要一个变量来表示当前程序递归到第几层,命名为index,表示当前要确定的是某个全排列中下标为index的那个数是多少
- 布尔数组used,初始化的时候都为false,表示这些数还没有被选择,当选定一个数的时候,就将这个数组的相应位置设置为true,这样在考虑下一个位置的时候,就能够以O(1)的时间复杂度判断这个数是否被选择过,这是一种【以空间换时间】的思想。
import java.util.ArrayList;
import java.util.List;
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
boolean[] isUsed;
int[] nums;
public List<List<Integer>> permute(int[] nums) {
this.nums = nums;
this.isUsed = new boolean[nums.length];
dfs(0);
return res;
}
private void dfs(int index) {
if (index == nums.length) {
res.add(new ArrayList<>(list));
return;
}
for (int i = 0; i < nums.length; i++) {
if (!isUsed[i]) {
list.add(nums[i]);
isUsed[i] = true;
dfs(index + 1);
list.remove(list.size() - 1);
isUsed[i] = false;
}
}
}
}
(中等)47. 全排列II
把这个问题看作n个排列成一行的空格,而需要做的是,从左往右依次填入题目给定的n个数,每个数只能使用一次。
使用穷举的算法,即从左往右每个位置都尝试往里填数,当填完的时候,将这一种填数方式添加到结果集中。
在填数的过程中,对于已经使用过的数字使用vis数组标记。
这样也还没有解决重复数字的问题,要解决重复问题,只要设定一个规则,保证在填第idx个数的时候重复数字只会被填入一次即可。对原数组进行排序,保证相同的数字都相邻,然后每次填入的数一定是这个数所在重复数组中【从左往右第一个未被填过的数字】。
在i>0和nums[i]==nums[i-1]的情况下:
vis[i]=true,是从纵向的角度去考虑,表示前一个相同的数字被选择,所以即时当前数字和前一个数字是一样的,也是可以选的
vis[i]=false,是从横向的角度去考虑,表示前一个相同的数字已经把所有的情况都已经添加过了,这个相同的数字需要跳过
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class Solution {
int[] nums;
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
boolean pre;
boolean[] vis;
public List<List<Integer>> permuteUnique(int[] nums) {
this.nums = nums;
this.vis = new boolean[nums.length];
Arrays.sort(this.nums);
dfs(0);
return res;
}
private void dfs(int index) {
if (index == nums.length) {
res.add(new ArrayList<>(list));
return;
}
for (int i = 0; i < nums.length; i++) {
if (i > 0 && nums[i - 1] == nums[i] && !vis[i - 1]) {
continue;
}
if (!vis[i]) {
list.add(nums[i]);
vis[i] = true;
dfs(index + 1);
list.remove(list.size() - 1);
vis[i] = false;
}
}
}
}
棋盘问题
HDU - 2553 N皇后问题
dfs+打表,不然Time Limit Exceeded
import java.util.Arrays;
import java.util.Scanner;
public class Main {
int n;
int cnt;
int[] pos;
int[] ans;
public static void main(String[] args) {
new Main().solve();
}
public void solve() {
Scanner in = new Scanner(System.in);
ans = new int[10];
for (int i = 0; i < 10; i++) {
n = i + 1;
cnt = 0;
pos = new int[n];
dfs(0);
ans[i] = cnt;
}
while (in.hasNextInt()) {
n = in.nextInt();
if (n == 0) {
break;
}
System.out.println(ans[n - 1]);
}
}
private void dfs(int index) {
if (index == n) {
cnt++;
return;
}
for (int i = 0; i < n; i++) {
if (check(index, i)) {
pos[index] = i;
dfs(index + 1);
}
}
}
//检查第index行的皇后放在col的位置是否合适
private boolean check(int index, int col) {
for (int i = index - 1; i >= 0; i--) {
if (pos[i] == col) {
return false;
}
if (index - i == Math.abs(col - pos[i])) {
return false;
}
}
return true;
}
}
(困难)51. N皇后
在 HDU 2553 题目的基础上稍加修改即可。
import java.util.ArrayList;
import java.util.List;
class Solution {
int[] pos;
int n;
List<List<String>> res = new ArrayList<>();
public List<List<String>> solveNQueens(int n) {
this.n = n;
pos = new int[n];
dfs(0);
return res;
}
//index表示当前要放置第index层的皇后位置
private void dfs(int index) {
//得到了某一种排列方式
if (index == n) {
List<String> list = new ArrayList<>();
//遍历每一层
for (int i = 0; i < n; i++) {
StringBuilder sb = new StringBuilder();
//对于某一层,初始化字符串
for (int j = 0; j < n; j++) {
sb.append(".");
}
sb.setCharAt(pos[i], 'Q');
list.add(sb.toString());
}
res.add(list);
return;
}
for (int i = 0; i < n; i++) {
if (check(index, i)) {
pos[index] = i;
dfs(index + 1);
}
}
}
//检查第index行的皇后放在col的位置是否合适
private boolean check(int index, int col) {
for (int i = index - 1; i >= 0; i--) {
if (pos[i] == col) {
return false;
}
if (index - i == Math.abs(col - pos[i])) {
return false;
}
}
return true;
}
}
还有一种向结果列表中添加元素的方式
private void dfs(int index) {
if (index == n) {
List<String> list = new ArrayList<>();
//遍历每一层
for (int i = 0; i < n; i++) {
char[] chars = new char[n];
Arrays.fill(chars,'.');
chars[pos[i]] = 'Q';
list.add(new String(chars));
//StringBuilder sb = new StringBuilder();
对于某一层,初始化字符串
//for (int j = 0; j < n; j++) {
// sb.append(".");
//}
//sb.setCharAt(pos[i], 'Q');
//list.add(sb.toString());
}
res.add(list);
return;
}
for (int i = 0; i < n; i++) {
if (check(index, i)) {
pos[index] = i;
dfs(index + 1);
}
}
}
为了降低总时间复杂度,每次放置皇后时需要快速判断,都可以在O(1)的时间内判断一个位置是否可以放置皇后,算法总的时间复杂度是O(N!)
方法一:基于集合的回溯
为了判断一个位置所在的列和两条斜线上是否已经有皇后,使用三个集合,columns、diagonals1和diagonals2分别记录每一列以及两个方向的每条斜线上是否有皇后。
列的表示法很直观,一共有N列,每一列的下标范围从0到N-1,使用列的下标即可明确表示每一列。
对于两个方向的斜线,需要找到斜线上的每个位置的行下标与列下标之间的关系。
同一斜线上的每个位置满足行下标与列下标之差相等。
同一斜线上的每个位置满足行下标与列下标之和相等。
每次放置皇后时,对于每个位置判断其是否在三个集合中,如果三个集合都并不包含当前位置,则当前位置是可以放置皇后的位置。
import java.util.*;
class Solution {
//用来记录每一层皇后的放置位置
int[] pos;
List<List<String>> res = new ArrayList<>();
Set<Integer> columns = new HashSet<>();
Set<Integer> diagonals1 = new HashSet<>();
Set<Integer> diagonals2 = new HashSet<>();
int n;
public List<List<String>> solveNQueens(int n) {
this.n = n;
pos = new int[n];
dfs(0);
return res;
}
private void dfs(int row) {
if (row == n) {
res.add(generateBoard());
return;
}
for (int i = 0; i < n; i++) {
if (columns.contains(i)) {
continue;
}
int diagonal1 = row - i;
if (diagonals1.contains(diagonal1)) {
continue;
}
int diagonal2 = row + i;
if (diagonals2.contains(diagonal2)) {
continue;
}
pos[row] = i;
columns.add(i);
diagonals1.add(diagonal1);
diagonals2.add(diagonal2);
dfs(row + 1);
columns.remove(i);
diagonals1.remove(diagonal1);
diagonals2.remove(diagonal2);
}
}
private List<String> generateBoard() {
List<String> list = new ArrayList<>();
for (int i = 0; i < n; i++) {
char[] chars = new char[n];
Arrays.fill(chars, '.');
chars[pos[i]] = 'Q';
list.add(new String(chars));
}
return list;
}
}
复杂度分析:
- 时间复杂度:O(N!),其中N是皇后的数量
- 空间复杂度:O(N),其中N是皇后的数量。空间复杂度主要取决于递归调用的层数、记录每行放置的皇后的列下标的数组以及三个集合,递归调用的层数不会超过N,数组的长度为N,每个集合的元素个数不会超过N。
(困难)37. 解数独
class Solution {
public void solveSudoku(char[][] board) {
solveSudokuHelper(board);
}
public boolean solveSudokuHelper(char[][] board) {
//一个for循环遍历棋盘的行,一个for循环遍历棋盘的列
//一行一行确定下来之后,递归遍历这个位置放9个数字的可能性
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (board[i][j] != '.') {
continue;
}
for (char k = '1'; k <= '9'; k++) {//(i,j)这个位置放k是否合适
if (isValidSoduku(i, j, k, board)) {
board[i][j] = k;
if (solveSudokuHelper(board)) {
return true;
}
board[i][j] = '.';
}
}
return false;
}
}
return true;
}
private boolean isValidSoduku(int row, int col, char val, char[][] board) {
//同一行是否有重复
for (int i = 0; i < 9; i++) {
if (board[row][i] == val) {
return false;
}
}
//同一列是否有重复
for (int i = 0; i < 9; i++) {
if (board[i][col] == val) {
return false;
}
}
//同一块是否有重复
int startRow = (row / 3) * 3;
int startCol = (col / 3) * 3;
for (int i = startRow; i < startRow + 3; i++) {
for (int j = startCol; j < startCol + 3; j++) {
if (board[i][j] == val) {
return false;
}
}
}
return true;
}
}
其他
(中等)491. 递增子序列
代码随想录 回宿舍三部曲
- 递归函数参数
本题求子序列,很明显一个元素不能重复使用,所以需要startIndex,调整下一层递归的起始位置
- 终止条件
本题起始类似求子集问题,也是要遍历树形结构找每一个节点,所以,可以不加终止条件,startIndex每次都会加1,并不会无限递归
注意:本题收集结果有所不同,题目要求递增子序列大小至少为2,所以,只有当list的size大于1时,才向结果集中添加
- 单层搜索逻辑
在图中可以看出,同一父节点下的同层上使用过的元素就不能再使用了
优化:
可以使用HashSet来保存同一层中已经访问过的数字,其实用数组来做哈希,效率就高很多。题目中已经明确说明了数值范围[-100,100],所以完全可以用数组来做哈希。
数组、set、map都可以做哈希表,而且数组干的活,map和set都能干,但如果数值范围小的话,能用数组尽量用数组
import java.util.ArrayList;
import java.util.List;
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
int[] nums;
public List<List<Integer>> findSubsequences(int[] nums) {
this.nums = nums;
dfs(0);
return res;
}
private void dfs(int index) {
if (list.size() > 1) {
res.add(new ArrayList<>(list));
}
boolean[] used = new boolean[201];
for (int i = index; i < nums.length; i++) {
if (!list.isEmpty() && nums[i] < list.get(list.size() - 1) || used[nums[i] + 100]) {
continue;
}
used[nums[i] + 100] = true;
list.add(nums[i]);
dfs(i + 1);
//每一层都有一个数组来标记,这一层有哪些数字是使用过的
//对每一层来说,使用过的数字不能再重复使用,所以,不需要设置used[nums[i]+100]=false
list.remove(list.size() - 1);
}
}
}
(*困难)332. 重新安排行程 欧拉回路/欧拉通路
**简化题意:**给定一个n个点m条边的图,要求从指定的顶点出发,经过所有的边恰好一次(可以理解为给定起点的【一笔画】问题),使得路径的字典序最小。
这种【一笔画】问题与欧拉图或者半欧拉图有着紧密的联系,下面给出定义:
- 通过图中所有边恰好一次且行遍所有顶点的通路称为欧拉通路
- 通过图中所有边恰好一次且行遍所有顶点的回路称为欧拉回路
- 具有欧拉回路的无向图称为欧拉图
- 具有欧拉通路但不具有欧拉回路的无向图称为半欧拉图
因为本题保证至少存在一种合理的路径,也就告诉我们,这张图是一个欧拉图或者半欧拉图。只需要输出这条欧拉通路的路径即可。
如果只是贪心地选择字典序最小地节点前进时,可能会先走入死胡同,从而导致无法遍历到其他还未访问的边。于是,希望能够遍历完当前节点所连接的其他节点后再进入【死胡同】。
注意:对于每一个节点,他只有最多一个【死胡同】分支,只有那个入度与出度差为1的节点会导致死胡同。
方法一:Hierholzer算法
Hierholzer算法用于在连通图中寻找欧拉路径,其流程如下:
- 从起点出发,进行深度优先搜索
- 每次沿着某条边从某个顶点移动到另外一个顶点的时候,都需要删除这条边
- 如果没有可移动的路径,则将所在节点加入到栈中,并返回
不妨倒过来思考,注意到只有那个入度和出度差为1的节点会导致死胡同。该节点必然是最后一个遍历到的节点。可以改变一下入栈规则,当遍历完一个节点所连的所有节点后,才将该节点入栈(即逆序入栈)
对于当前节点而言,从它的每一个非【死胡同】分支出发进行深度优先搜索,都将会搜回当前节点。而从它的【死胡同】分支出发进行深度优先搜索将不会搜回到当前节点。也就是说,当前节点的死胡同分支将会优先于其他非【死胡同】分支入栈。
这样就能保证可以【一笔画】地走完所有边,最终的栈中逆序保存了【一笔画】地结果。只要将栈中的内容反转,即可得到答案。