全排列和组合问题是 L e e t C o d e {\rm LeetCode} LeetCode中的经典问题,常涉及到使用回溯等方法解决问题。本文将介绍各种类型的全排列和组合问题,包括全排列Ⅰ Ⅱ
、组合、组合总和Ⅰ Ⅱ Ⅲ Ⅳ
。在开始接下来的内容前,可以首先参考回溯法的基本内容,链接。
题目来源 46.全排列
题目描述 给定一个没有重复数字的序列,返回其所有可能的全排列。
如输入数组为[1, 2, 3]
,则返回结果为[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
。
先来看怎么手动计算得到上述全排列的结果,通常做法是首先固定第一位,然后第二位的选择变为两种,固定第二位后随之第三位也固定,如果数组元素大于三个类似。显然,由于给定输入数组的元素个数位三,所以当回溯路径上的元素个数为三时满足结束条件。这里的选择列表即为我们可选择的范围,首先第一次我们有三种可选的元素,第二次有两个可选的元素,以此类推。这里的做选择即为不断填充路径,使其满足结束条件,即得到一个可行的排列结果。当得到一组可行解后,我们需要寻找下一组解,这时候我们要破坏前一组的结果,即撤回先前做的选择,这在递归后执行。最后,由于排列中不包含重复元素,我们额外使用一个数组来标识当前元素是否已经使用过。程序如下:
vector<int> path;
vector<vector<int>> res;
void backtrack(vector<int>& nums, vector<int>& used) {
// 结束条件
if (path.size() == nums.szie()) {
res.push_back(path);
return;
}
// 遍历
for (int i = 0; i < nums.size(); ++i) {
// 排除重复选择
if (used[i]) continue;
// 置当前元素已经访问
used[i] = 1;
// 做选择
path.push_back(nums[i]);
// 开始尝试继续做选择
backtrack(nums, used);
// 撤销选择
path.pop_back();
// 置当前元素没有访问
used[i] = 0;
}
}
// 主体函数
vector<vector<int>> permute(vector<int>& nums) {
vector<int> u(nums.size(), 0);
backtrack(nums, u);
return res;
}
其他题解 官方题解
题目来源 47.全排列Ⅱ
题目描述 与上一题要求不同的是,本题的序列可包含重复数字,按要求返回所有不重复的全排列。
如输入数组为[1, 1, 2]
,则返回结果为[[1, 1, 2], [1, 2, 1], [2, 1, 1]]
。
如果我们按照上一题的程序来解答此题,则返回的结果为[[1, 1, 2], [1, 2, 1], [1, 1, 2], [1, 2, 1], [2, 1, 1], [2, 1, 1]]
。其中会包含很多重复的序列,造成这个的原因是输入数组中包含重复元素。为了方便对重复元素的处理,我们首先对原数组按从小到大的规则排序,这时重复元素会处于相邻位置。我们将上述输入数组改为[1, 1', 2]
,在第一次回溯时会得到结果[1, 1', 2]
;在第二次回溯时固定第二位即1'
,如果再选择第一个1
的话就会得到重复的序列[1', 1, 2]
。出现重复序列的情景是当前位置元素的值等于前一位置元素的值,并且前一位置元素没有被使用(如果前一位置元素的值被使用过,就会得到上述第一种序列,此时不会得到重复序列),我们在回溯时对这一情况进行剪枝即可。我们在上一题的程序的基础上,加上这一剪枝条件即可得到答案。程序如下:
vector<int> path;
vector<vector<int>> res;
void backtrack(vector<int>& nums, vector<int>& used) {
// 结束条件
if (path.size() == nums.size()) {
res.push_back(path);
return;
}
// 遍历
for (int i = 0; i < nums.size(); ++i) {
// 剪枝,i > 0保证nums[i - 1]有效
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;
// 排除重复选择
if (used[i]) continue;
// 置当前元素已经访问
used[i] = 1;
// 做选择
path.push_back(nums[i]);
// 开始尝试继续做选择
backtrack(nums, used);
// 撤销选择
path.pop_back();
// 置当前元素没有访问
used[i] = 0;
}
}
// 主体函数
vector<vector<int>> permute(vector<int>& nums) {
vector<int> u(nums.size(), 0);
backtrack(nums, u);
return res;
}
其他题解 官方题解
题目来源 77.组合
题目描述 给定两个整数n和k,返回1…n中所有可能的k个数的组合。
如输入为n = 4, k = 2
,则返回结果为[[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]
。
如果我们将n = 4
改写成数组形式即[1, 2, 3, 4]
,则该题的解法和第二个问题-全排列非常类似。但本题的另外一个特殊之处在于结果不能出现[1, 2]
和[2, 1]
的组合,即我们只能选择上一次选择元素的后面元素,我们通过在回溯的时候指定回溯起始点实现,同时也不会出现重复使用元素的情况。程序如下:
vector<int> path;
vector<vector<int>> result;
void backtrack(int start, int k, int n) {
// 结束条件
if (path.size() == k) {
result.push_back(path);
return ;
}
// 遍历
for (int i = start; i <= n; ++i) {
// 做选择
path.push_back(i);
// 开始尝试继续做选择
backtrack(i + 1, k, n);
// 撤销选择
path.pop_back();
}
}
// 主体函数
vector<vector<int>> combine(int n, int k) {
backtrack(1, k, n);
return result;
}
其他题解 官方题解
题目来源 39.组合总和
题目描述 给定一个无重复元素的数组candidates
和一个目标数target
,找出candidates
中所有可以数字和为target
的组合。并且,candidates
中的数字可以无限制重复选取。同时,题目中所给出的数字全部为正整数;解集中不能包含重复的集合。
如输入数组为candidates = [2, 3, 6, 7]
、目标数为target = 7
,则返回结果为[[2, 2, 3], [7]]
。
首先,数字可以无限制重复选取,那么回溯形式肯定不能是上一题中的backtrack(i + 1, ..., ...)
,因为i + 1
规定了下一个元素只能从当前位置的后一位置开始寻找。其次,由于解集中不能包含重复的集合,即不能存在[2, 2, 3]
和[2, 3, 2]
的组合,则我们最多只能在当前位置多次停留选择元素,而不能往回寻找。所以,最终的回溯结构应该是backtrack(i, ..., ...)
。同时为了避免产生重复的解集合,首先对原输入数组排序。程序如下:
vector<int> path;
vector<vector<int>> result;
void backtrack(int start, vector<int>& candidates, int target) {
// 结束条件
if (target == 0) {
result.push_back(path);
return;
}
// 遍历
for (int i = start; i < candidates.size() && target - candidates[i] >= 0; ++i) {
// 做选择
path.push_back(candidates[i]);
// 开始尝试继续做选择
backtrack(i, candidates, target - candidates[i]);
// 撤销选择
path.pop_back();
}
}
// 主体函数
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end());
backtrack(0, candidates, target);
return result;
}
其他题解 官方题解
题目来源 10.组合总和Ⅱ
题目描述 该题的要求与上一题有两点不同:candidates
数组中可能包含重复元素;candidates
中的每个数字仅能选择一次。并且不能包含重复的解集合,其他要求均相同。
如是如输入数组为candidates = [10, 1, 2, 7, 6, 1, 5]
、目标数为target = 8
,则返回结果为[[1, 1, 6], [1, 2, 5], [1, 7], [2, 6]]
。
该题的解法与第三个问题-全排列Ⅱ基本一致,不过后者要求的是直接输出所有组合,而本题做了每个组合的和必须等于目标数target
的限制。这里我们额外定义函数,求一条路径上的元素总和。由于不能包含重复的解集合且每个数字仅能选择一次,我们还要结合第四个问题-组合的思路。最后,如果路径上的元素总和大于给定的目标数,则没有必须再往下执行,这是回溯法的一种剪枝。程序如下:
vector<int> path;
vector<vector<int>> result;
int sumOfPath(vector<int>& nums) {
int s = 0;
for (int num: nums)
s += num;
return s;
}
void backtrack(int start, vector<int>& candidates, int target, vector<int>& used) {
// 结束条件
if (sumOfPath(path) >= target) {
if (sumOfPath(path) == target)
result.push_back(path);
return;
}
// 遍历,注意循环及其循环体的形式
for (int i= start; i < candidates.size(); ++i) {
// 剪枝,i > 0保证nums[i - 1]有效
if (i > start && candidats[i] == candidates[i - 1] && !used[i - 1]) continue;
// 排除重复选择
if (used[i]) continue;
// 做选择
used[i] = 1;
path.push_back(candidates[i]);
// 开始继续尝试做选择
backtrack(i + 1, candidates, target, used);
// 撤销选择
path.pop_back();
used[i] = 0;
}
}
// 主体函数
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end());
vector<int> u(candidates.size(), 0);
backtrack(0, candidates, target, u);
return result;
}
其他题解 官方题解
题目来源 216.组合总和Ⅲ
题目描述 找出所有相加之和为n的k个数的组合。组合中只允许含有1-9的正整数,并且每种组合中不存在重复的数字。
如输入为k = 3
、n = 9
,则返回结果为[[1, 2, 6], [1, 3, 5], [2, 3, 4]]
。
首先,题目给出的输入形式与第四个问题-组合相同。不同的是,前者要求返回的是具体组合,而本题要求返回的组合满足和为n
的条件,再综合上一题的解答。例外,由于题目要求组合中只允许含有1-9的正整数,则需要再对其中的某些情况剪枝。程序如下:
vector<int> path;
vector<vector<int>> result;
int sumOfPath(vector<int>& nums) {
int s = 0;
for (int num: nums)
s += num;
return s;
}
void backtrack(int start, int k, int n) {
// 结束条件
if (sumOfPath(path) >= n) {
if (sumOfPath(path) == n && path.size() == k)
result.push_back(path);
return;
}
// 遍历
for (int i = start; i <= n; ++i) {
if (i > 9) return;
// 做选择
path.push_back(i);
// 开始尝试继续做选择
backtrack(i + 1, k, n);
// 撤销选择
path.pop_back();
}
}
// 主体函数
vector<vector<int>> combine(int n, int k) {
backtrack(1, k, n);
return result;
}
题目来源 377.组合总和Ⅳ
题目描述 给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。这里将顺序不同的序列视为不同的组合。
如输入为nums = [1, 2, 3]
、target = 4
,则返回结果为7
、即[[1, 1, 1, 1], [1, 1, 2], [1, 2, 1], [1, 3], [2, 1, 1], [2, 2], [3, 1]]
。
首先,题目将顺序不同的序列视为不同的组合,则回溯时可以到当前位置的前面去寻找,这与第二个问题-全排列一致。此外,根据输入输出实例,可以在重复选取数组中的元素,则我们可以在此基础上不使用used
数组来判断是否选取重复元素。程序如下:
int cnt = 0;
vector<int> path;
int sumOfPath(vector<int>& nums) {
int s = 0;
for (int num: nums)
s += num;
return s;
}
void backtrack(vector<int>& candidates, int target) {
// 结束条件
if (sumOfPath(path) >= target) {
if (sumOfPath(path) == target)
cnt += 1;
return;
}
// 遍历
for (int i = 0; i < candidates.size(); ++i) {
// 做选择
path.push_back(candidates[i]);
// 开始尝试继续做选择
backtrack(candidates, target);
// 撤销选择
path.pop_back();
}
}
// 主体函数
int combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end());
backtrack(candidates, target);
return cnt;
}
但是直接使用上述代码,当target
较大时会出现超时。一种可以AC的方法是使用动态规划,将本题视为背包问题即可解决。本文不再讨论该方法(以后介绍背包问题的时候再仔细说),解答方法见下,链接。
在全排列和组合问题中,最基本的是第二个问题-全排列,这时的输入数组中不包含重复元素,通过额外定义一个数组used
来保证得到的集合中没有重复元素(不同排列顺序的集合视为不同集合);如果输入数组中包含重复元素,往往首先需要通过对输入数组排序以去重(第三个问题-全排列Ⅱ和第六个问题-组合总和Ⅱ);如果不同排列顺序的集合视为同一集合,则在回溯时只能通过往后寻找下一个元素(第四个问题-组合、第六个问题-组合总和Ⅱ和第六个问题-组合总和Ⅲ);而最后一个问题组合总和Ⅳ是对前面问题的综合。