排列组合问题最通用的解法就是回溯法,其实也就是深度优先搜索。
这里将最基础的排列组合题目做一个整理。
1. 排列问题:
排列问题是对n个位置来进行填充,每个元素都会被用到。
因此每次递归时,都要从头考虑所有的元素。
(1) 无重复数字的排列
给定一个不含重复数字的数组 nums,返回其所有可能的全排列 。
你可以 按任意顺序 返回答案。
leetcode-cn.com/problems/pe…
public class Solution {
List<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> permute(int[] nums) {
dfs(nums);
return ans;
}
private void dfs(int[] nums) {
// 如果已经排列完了所有的数字,则记录结果
if (path.size() == nums.length) {
ans.add(new LinkedList<>(path));
return;
}
// 排列问题需要考虑所有的数
for (int num : nums) {
// 如果重复了,则跳过
if (path.contains(num)) {
continue;
}
// 加入当前结果
path.offer(num);
// 进行回溯
dfs(nums);
// 从当前结果撤销
path.removeLast();
}
}
}
这里巧妙地使用了path.contains(num)来判断当前数num是否已经被使用。
(2) 有重复数字的排列
给定一个可包含重复数字的序列 nums,按任意顺序 返回所有不重复的全排列。
leetcode-cn.com/problems/pe…
public class Solution {
List<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
// 是否访问过该索引
boolean[] visit;
public List<List<Integer>> permuteUnique(int[] nums) {
// 把所有的数字进行排列,使得可以跳过相同的数字
Arrays.sort(nums);
// 初始化visit数组
visit = new boolean[nums.length];
dfs(nums);
return ans;
}
private void dfs(int[] nums) {
// 如果已经排列完所有的数字,则记录结果
if (path.size() == nums.length) {
ans.add(new ArrayList<>(path));
return;
}
// 排列问题需要考虑所有的数
for (int i = 0; i < nums.length; i++) {
// 如果当前数被访问过,或者与他相同的前一个数被访问过,则跳过
if (visit[i] || i > 0 && nums[i] == nums[i - 1] && visit[i - 1]) {
continue;
}
// 加入当前结果
path.add(nums[i]);
// 设置为已访问
visit[i] = true;
// 进行回溯
dfs(nums);
// 设置为未访问
visit[i] = false;
// 从当前结果撤销
path.removeLast();
}
}
}
这个题和上个题的不同之处是,nums数组中会有重复的数字,但是要求全排列的结果没有重复。
而代码中最明显的差异是:
// 如果当前数被访问过,或者与他相同的前一个数被访问过,则跳过
if (visit[i] || i > 0 && nums[i] == nums[i - 1] && visit[i - 1]) {
continue;
}
使用visit数组来保证每个数只会被访问一次,后边的判断条件是保证全排列的结果没有重复。
为什么nums[i]只用和nums[i - 1]做判断呢? 因为在前边我们对数组进行了排序,相同的数被排列在了一起。
// 把所有的数字进行排列,使得可以跳过相同的数字
Arrays.sort(nums);
2. 组合问题:
组合问题,有两种思考方式:
- 需要依次考虑每个位置,看使用哪个元素来填充它。
- 需要依次考虑每个元素,看是否选择它;
第一种方式类似于排列问题的解法,都需要关注于哪些元素能试着放在当前位置;
只不过排列问题需要填充多少个位置是确定的,所以位置被填满则结束;
而组合问题不知道有多少位置需要被填充,所以要根据其他条件来结束。
第二种方式类似于01背包问题的思想,对于每一个元素判断选或者不选,然后考虑下一个元素。
选过的元素是否需要再考虑,这需要根据题目要求,看是否能再次选择同样的元素来决定。
但是,肯定不能再去考虑之前的元素,因为已经考虑过了,否则会有重复。
(1) 无重复数字的组合,不能重复选
给你一个整数数组 nums ,数组中的元素 互不相同 ,返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集,你可以按 任意顺序 返回解集。
leetcode-cn.com/problems/su…
先用第一种方式,考虑每个位置:
public class Solution {
LinkedList<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
// 第二个参数表示从哪个元素开始是可以考虑的,即可选范围
dfs(nums, 0);
return ans;
}
private void dfs(int[] nums, int start) {
// 所有情况都满足,无需判断,直接记录结果
ans.add(new LinkedList<>(path));
// 整个循环代表依次把可选范围内的元素放在当前位置上考虑
for (int i = start; i < nums.length; i++) {
// 放上去试试,加入当前结果
path.offer(nums[i]);
// 进行回溯,因为不能重复选,把可选范围缩小
dfs(nums, i + 1);
// 从当前结果撤销
path.removeLast();
}
}
}
这个题有个隐含的条件,就是不能重复选元素,所以每次递归时i都需要加1。
可能有同学就要问了,假如我位置0放入的是值nums[1],那下层递归从i + 1开始的话,不是把nums[0]漏掉了么?
别忘了,假如 num[1] 和 num[0] 是合适的组合,那在考虑位置0时,已经把该答案加入ans中了,因为组合是无序的。
再来使用第二种方式,考虑每个元素:
由于每个数字只有 选 或者 不选 两种情况,所以也可以这么做:
public class Solution {
LinkedList<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
// 第二个参数表示考虑到哪个元素了
dfs(nums, 0);
return ans;
}
private void dfs(int[] nums, int i) {
// 结束条件:如果已经考虑到了最后一个元素,则记录结果
if (nums.length == i) {
ans.add(new LinkedList<>(path));
return;
}
// 选当前元素
// 加入当前结果
path.offer(nums[i]);
// 进行回溯,从下一个元素开始考虑
dfs(nums, i + 1);
// 从当前结果撤销
path.removeLast();
// 不选当前元素,从下一个元素开始考虑
dfs(nums, i + 1);
}
}
(2) 无重复数字的组合,可以重复选
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
leetcode-cn.com/problems/co…
先用第一种方式,考虑每个位置:
public class Solution {
List<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
// 把所有的数字进行排序,使得可以进行剪枝判断
Arrays.sort(candidates);
// 第三个参数表示当前的和,第四个参数表示从哪个元素开始是可以考虑的,即可选范围
dfs(candidates, target, 0, 0);
return ans;
}
private void dfs(int[] candidates, int target, int sum, int start) {
// 结束条件:如果满足了条件,则记录结果
if (sum == target) {
ans.add(new LinkedList<>(path));
return;
}
// 整个循环代表依次把可选范围内的元素放在当前位置上考虑
for (int i = start; i < candidates.length; i++) {
// 剪枝:因为之前已经排序过了
if (sum + candidates[i] > target) {
return;
}
sum += candidates[i];
path.add(candidates[i]);
// 进行回溯,可以重复选,所以可选范围不变
dfs(candidates, target, sum, i);
path.removeLast();
sum -= candidates[i];
}
}
}
再来使用第二种方式,考虑每个元素:
public class Solution {
List<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
// 把所有的数字进行排序,使得可以进行剪枝判断
Arrays.sort(candidates);
// 第三个参数表示 当前和,第四个参数表示当前考虑到哪个元素了
dfs(candidates, target, 0, 0);
return ans;
}
private void dfs(int[] candidates, int target, int sum, int i) {
// 结束条件:如果满足了条件,则记录结果
if (sum == target) {
ans.add(new LinkedList<>(path));
return;
}
// 结束条件:如果考虑到了最后一个元素,或者sum已经比target大了,则返回
if (i == candidates.length || sum > target) {
return;
}
// 选当前元素
// 加入当前结果
path.offer(candidates[i]);
// 计算sum
sum += candidates[i];
// 进行回溯,因为可以重复选,所以还是考虑当前元素
dfs(candidates, target, sum, i);
// 回退sum
sum -= candidates[i];
// 从当前结果撤销
path.removeLast();
// 不选当前索引,开始考虑下一个元素
dfs(candidates, target, sum, i + 1);
}
}
这里要注意这个结束条件,避免无限递归:
// 如果考虑到了最后一个元素,或者sum已经比target大了,则返回
if (i == candidates.length || sum > target) {
return;
}
那对于这两种方式应该怎么选择呢?
回溯算法比较难以判断它的时间复杂度,但是理论上考虑每个位置的写法应当会有更少的递归次数。
重要的是,需要清楚它们的 结束条件 ,对于存在重复数字的题目,还需要考虑 去重条件。
(3) 有重复数字的组合,不能重复选
给你一个整数数组 nums ,其中可能包含 重复元素,请你返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。
leetcode-cn.com/problems/su…
用第一种方式,考虑每个位置:
public class Solution {
LinkedList<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
// 把所有的数字进行排序,使得可以进行跳过重复元素
Arrays.sort(nums);
// 第二个参数表示从哪个元素开始是可以考虑的,即可选范围
dfs(nums, 0);
return ans;
}
private void dfs(int[] nums, int start) {
// 所有情况都满足,无需判断,直接记录结果
ans.add(new LinkedList<>(path));
// 整个循环代表依次把可选范围内的元素放在当前位置上考虑
for (int i = start; i < nums.length; i++) {
// 如果当前数与他前一个数相同,则跳过,不用再考虑了
if (i > start && nums[i] == nums[i - 1]) {
continue;
}
// 放上去试试,加入当前结果
path.offer(nums[i]);
// 进行回溯,因为不能重复选,把可选范围缩小
dfs(nums, i + 1);
// 从当前结果撤销
path.removeLast();
}
}
}
用第二种方式,考虑每个元素:
public class Solution {
LinkedList<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> list = new LinkedList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
// 把所有的数字进行排序,使得可以进行跳过重复元素
Arrays.sort(nums);
// 第二个参数表示考虑到哪个元素了
dfs(nums, 0);
return ans;
}
private void dfs(int[] nums, int i) {
// 考虑到了最后的元素,记录结果
if (i == nums.length) {
ans.add(new LinkedList<>(list));
return;
}
// 选当前元素
list.offer(nums[i]);
// 考虑下一个元素
dfs(nums, i + 1);
// 撤销当前元素
list.removeLast();
// 跳过所有相同的元素
while (i < nums.length - 1 && nums[i] == nums[i + 1]) {
i++;
}
// 不选当前的元素,直接考虑下一个元素
dfs(nums, i + 1);
}
}
再来一个例子:
给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用 一次 。
leetcode-cn.com/problems/co…
用第一种方式,考虑每个位置:
class Solution {
List<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
// 把所有的数字进行排序,可以跳过重复的元素
Arrays.sort(candidates);
// 第三个参数表示 当前和,第四个参数表示从第几个元素开始考虑,即可选范围
dfs(candidates, target, 0, 0);
return ans;
}
private void dfs(int[] candidates, int target, int sum, int start) {
if (sum == target) {
ans.add(new LinkedList<>(path));
return;
}
// 整个循环代表依次把可选范围内的元素放在当前位置上考虑
for (int i = start; i < candidates.length; i++) {
// 如果当前元素和前一个元素相同,则属于重复,跳过
if (i > start && candidates[i] == candidates[i - 1]) {
continue;
}
if (sum + candidates[i] > target) {
return;
}
sum += candidates[i];
path.offer(candidates[i]);
// 因为不能重复选,所以可选范围缩小
dfs(candidates, target, sum, i + 1);
path.removeLast();
sum -= candidates[i];
}
}
}
用第二种方式,考虑每个元素:
public class Solution {
List<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
// 把所有的数字进行排序,使得可以进行剪枝判断
Arrays.sort(candidates);
// 第三个参数表示 当前和,第四个参数表示当前考虑到哪个元素了
dfs(candidates, target, 0, 0);
return ans;
}
private void dfs(int[] candidates, int target, int sum, int i) {
// 结束条件:如果满足了条件,则记录结果
if (sum == target) {
ans.add(new LinkedList<>(path));
return;
}
// 结束条件:如果考虑到了最后一个元素,或者sum已经比target大了,则返回
if (i == candidates.length || sum > target) {
return;
}
// 选当前元素
// 加入当前结果
path.offer(candidates[i]);
// 计算sum
sum += candidates[i];
// 进行回溯,因为不能重复选,所以考虑下一个元素
dfs(candidates, target, sum, i + 1);
// 回退sum
sum -= candidates[i];
// 从当前结果撤销
path.removeLast();
// 跳过所有相同的元素
while (i < candidates.length - 1 && candidates[i] == candidates[i + 1]) {
i++;
}
// 不选当前索引,开始考虑下一个元素
dfs(candidates, target, sum, i + 1);
}
}
(4) 有重复数字的组合,可以重复选
给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
用第一种方式,考虑每个位置:
public class Solution {
List<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum3(int[] candidates, int target) {
// 把所有的数字进行排序,可以跳过重复的元素
Arrays.sort(candidates);
// 第三个参数表示 当前和,第四个参数表示从第几个元素开始考虑,即可选范围
dfs(candidates, target, 0, 0);
return ans;
}
private void dfs(int[] candidates, int target, int sum, int start) {
if (sum == target) {
ans.add(new LinkedList<>(path));
return;
}
// 整个循环代表依次把可选范围内的元素放在当前位置上考虑
for (int i = start; i < candidates.length; i++) {
// 如果当前元素和前一个元素相同,则属于重复,跳过
if (i > start && candidates[i] == candidates[i - 1]) {
continue;
}
if (sum + candidates[i] > target) {
return;
}
sum += candidates[i];
path.offer(candidates[i]);
// 因为可以重复选,所以可选范围不变
dfs(candidates, target, sum, i);
path.removeLast();
sum -= candidates[i];
}
}
}
用第二种方式,考虑每个元素:
public class CombinationSum3 {
List<List<Integer>> ans = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum3(int[] candidates, int target) {
// 把所有的数字进行排序,使得可以进行剪枝判断
Arrays.sort(candidates);
// 第三个参数表示 当前和,第四个参数表示当前考虑到哪个元素了
dfs(candidates, target, 0, 0);
return ans;
}
private void dfs(int[] candidates, int target, int sum, int i) {
// 结束条件:如果满足了条件,则记录结果
if (sum == target) {
ans.add(new LinkedList<>(path));
return;
}
// 结束条件:如果考虑到了最后一个元素,或者sum已经比target大了,则返回
if (i == candidates.length || sum > target) {
return;
}
// 选当前元素
// 加入当前结果
path.offer(candidates[i]);
// 计算sum
sum += candidates[i];
// 进行回溯,因为可以重复选,所以继续考虑当前元素
dfs(candidates, target, sum, i);
// 回退sum
sum -= candidates[i];
// 从当前结果撤销
path.removeLast();
// 跳过所有相同的元素
while (i < candidates.length - 1 && candidates[i] == candidates[i + 1]) {
i++;
}
// 不选当前索引,开始考虑下一个元素
dfs(candidates, target, sum, i + 1);
}
}