组合问题
一、问题特点
组合问题是回溯算法的典型应用,这类问题往往会给出一个元素的集合,要求找出所有满足指定条件的子集。其核心思想就是遍历整个树,找出所有满足情况的子集,在适当条件下也可能需要进行一些剪枝操作。
其中子集的选取通常有以下几个要点:
集合中是否有重复元素
子集中是否有重复元素
集合中任意一个元素在一个子集中是否只能出现一次(是否可以被无限次重复选取)
子集需要满足哪些特殊条件(元素和为target?/元素数量为k?)
二、相关例题
(一)组合
77.组合(LeetCode)
题目要求:
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按任何顺序返回答案。
示例:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
解题思路:
本题从集合[1, n]中选取元素组成符合条件的子集,
原集合满足条件:
没有重复元素
按照从小到大的顺序排布
子集需要满足条件:
子集元素个数为k
子集中元素各不相同
为了保证不会重复取同一值,可以在递归函数中添加一个参数startIndex,及时修改取值左端,每遍历一层就将startIndex加一用于标识该索引以及该索引以前的元素已被使用,从而将剩余搜索范围缩减到[startIndex, n]
此外,通过观察发现本题搜索树可以进行适当的剪枝操作。因为需要选取k个元素组成子集,那么如果当前子集中的元素数目path.size()与剩余可以选取的元素数目(n - i + 1)的和小于k,则说明无论如何,剩余的分支不可能产生大小为k的子集,可以直接被剪除。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class Solution {public : void backtracking (int n, int k, int startIndex) { if (path.size () == k) { ans.push_back (path); return ; } for (int i = startIndex; i <= n - k + path.size () - 1 ; ++i) { path.push_back (i); backtracking (n, k, startIndex + 1 ); path.pop (); } } vector<vector<int >> combine (int n, int k) { backtracking (n, k, 1 ); return ans; } private : vector<int > path; vector<vector<int >> ans; }
(二)组合总和
39.组合总和(LeetCode)
题目要求:
给你一个无重复元素的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的所有不同组合 ,并以列表形式返回。你可以按任意顺序返回这些组合。
candidates 中的同一个数字可以无限制重复被选取。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个。
提示:
1 <= candidates.length <= 30
2 <= candidates[i] <= 40
candidates 的所有元素互不相同
1 <= target <= 40
示例:
输入:candidates = [2,3,6,7], target = 7
输出:
[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。
解题思路:
本题从集合candidates中选取若干元素组成符合条件的子集,
原集合满足条件:
没有重复元素
所有元素均为正数(提示中有说明)
子集需要满足条件:
子集元素和为target
子集中元素可以相同
仅存在排序差异的子集将被视为同一子集,因此仍需要去重
由于仅存在排序差异的子集将被视为同一子集,所以需要排除类似 [7, 1, 1] 和 [1, 1, 7]这样的解,为了保证不会重复取同一值,仍可以在递归函数中添加一个参数startIndex,及时修改取值左端,每遍历一层就将startIndex加一用于标识该索引以及该索引以前的元素已被使用,从而将剩余搜索范围缩减到[startIndex, n]
此外,通过观察发现本题搜索树可以进行适当的剪枝操作。因为需要选取若干个和为k的数组成子集,当当前和超过k时说明该分支的所有路径一定不能产生元素和等于k的数组,所以可以直接剪除。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class Solution {public : void backtracking (vector<int > nums, int sum, int startIndex, int target) { if (sum == target) { ans.push_back (elm); return ; }else if (sum > target) { return ; } for (int i = startIndex; i < nums.size (); ++i) { elm.push_back (nums[i]); backtracking (nums, sum + nums[i], i, target); elm.pop_back (); } return ; } vector<vector<int >> combinationSum (vector<int >& candidates, int target) { backtracking (candidates, 0 , 0 , target); return ans; } private : vector<int > elm; vector<vector<int >> ans; };
40.组合总和II(LeetCode)
题目要求:
给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用 一次 。
注意:解集不能包含重复的组合。
提示:
1 <= candidates.length <= 100
1 <= candidates[i] <= 50
1 <= target <= 30
示例:
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]
解题思路:
本题从集合candidates中选取若干元素组成符合条件的子集,
原集合满足条件:
可能有重复元素
所有元素均为正数(提示中有说明)
每一个元素仅能使用一次
子集需要满足条件:
子集元素和为target
子集中元素可以相同
仅存在排序差异的子集将被视为同一子集,因此仍需要去重
首先注意到原集合中每一个元素仅能使用一次,第一想法是使用类似于backtracking(n, k, startIndex + 1);的语句完成去重。这种方法的确可以保证原集合中每一个元素仅被取一次,但注意原集合中可能存在相同的数,此时仍有可能产生具有完全相同元素的子集(例如:candidates = [1, 2, 3, 6, 1] target = 7 那么6既可以和前面的那个1组成[1, 6],也可以和后面的1组成[6, 1],每个元素虽然只能取一次,但依然可以产生重复的子集)
为了解决这个问题,需要对具有相同值的元素进行树层去重(确保同一层中不使用具有相同值的元素),但不能进行树枝去重(保证值相同的元素可以同时出现在子集中)。可以设置一个元素类型为bool类型的数组visited[]存储节点是否被遍历过的信息,约定 visited[i] = true 表示该元素的值 被本树枝的元素遍历过,下一次仍可使用相同值;用 visited[i] = false 表示该元素的值 未被遍历或已被同层元素取过,需要剪枝。为了方便调整visited[]数组的内容,需要对candidates数组进行排序,让具有相同值的元素排在一起,确保可以在同一层去除所有重复。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class Solution {public : void backtracking (vector<int > nums, int target, vector<bool >& visited, int sum, int startIndex) { if (sum == target) { ans.push_back (path); return ; }else if (sum > target) { return ; } for (int i = startIndex; i < nums.size (); ++i) { if (i > 0 && nums[i] == nums[i-1 ] && visited[i-1 ] == false ) { continue ; } visited[i] = true ; path.push_back (nums[i]); backtracking (nums, target, visited, sum + nums[i], i + 1 ); path.pop_back (); visited[i] = false ; } } vector<vector<int >> combinationSum2 (vector<int >& candidates, int target) { sort (candidates.begin (), candidates.end ()); vector<bool > visited (candidates.size(), false ) ; backtracking (candidates, target, visited, 0 , 0 ); return ans; } private : vector<int > path; vector<vector<int >> ans; };
216.组合总和III(LeetCode)
题目要求:
找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:
只使用数字1到9
每个数字最多使用一次
返回所有可能的有效组合的列表。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
提示:
2 <= k <= 9
1 <= n <= 60
示例:
输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。
解题思路:
本题从集合[1, 9]中选取若干元素组成符合条件的子集,
原集合满足条件:
所有元素均为正数(提示中有说明)
每一个元素仅能使用一次
子集需要满足条件:
子集元素和为n
子集中元素不相同
子集大小为k
仅存在排序差异的子集将被视为同一子集,因此仍需要去重
本题思路较组合总和II来说比较简单,因为原集合中没有重复元素,子集中也不能有重复元素,仍使用startIndex标记去重即可。但要注意完成对当前和值sum以及startIndex的回溯操作。又由于原集合中所有元素均为正数,所以当sum大于n时即可进行剪枝,因为该分支的结果一定无法满足条件sum = n。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class Solution {public : void backtracking (int k, int n, int startindex, int tempsum) { if (tempsum > n || elm.size () > k) return ; else if (tempsum == n && elm.size () < k) return ; else if (tempsum == n && elm.size () == k) { res.push_back (elm); return ; } for (int i = startindex; i < 10 ; ++i) { elm.push_back (i); backtracking (k, n, i + 1 , tempsum + i); elm.pop_back (); } return ; } vector<vector<int >> combinationSum3 (int k, int n) { backtracking (k, n, 1 , 0 ); return res; } private : vector<int > elm; vector<vector<int >> res; };
(三)记忆化搜索优化的组合问题
组合问题也是一种典型的dfs算法,因此可以使用记忆化搜索优化时间复杂度。
474. 一和零(LeetCode)
题目要求:
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。
请你找出并返回 strs 的最大子集的长度,该子集中 最多 有 m 个 0 和 n 个 1 。
如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。
提示:
1 <= strs.length <= 600
1 <= strs[i].length <= 100
strs[i] 仅由 ‘0’ 和 ‘1’ 组成
1 <= m, n <= 100
解题思路:
本题是一个典型的组合问题,需要从strs中选取若干元素组成符合条件的子集,
原集合满足条件:
所有元素均为二进制字符串(仅包含0和1)
每一个元素仅能使用一次
子集需要满足条件:
子集元素中的0和1的数量分别是m和n
仅存在排序差异的子集将被视为同一子集,因此仍需要去重
子集要尽可能大
题目数据量较大,因此可以使用记忆化搜索 优化时间复杂度,这里使用一种新的模板,该模板更适合实现记忆化搜索。
代码示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class Solution {public : int dfs (vector<string>& strs, int m, int n, int startIndex) { if (startIndex >= strs.size ()) return 0 ; int ones = 0 , zeros = 0 ; for (const char & c : strs[startIndex]) { if (c == '0' ) zeros++; else ones++; } int ans = dfs (strs, m, n, startIndex+1 ); if (m-zeros>=0 && n-ones>=0 ) { ans = max (ans, 1 +dfs (strs, m-zeros, n-ones, startIndex+1 )); } return ans; } int findMaxForm (vector<string>& strs, int m, int n) { return dfs (strs, m, n, 0 ); } };
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 class Solution {public : int dfs (vector<string>& strs, vector<vector<vector<int >>>& dp, int m, int n, int startIndex) { if (startIndex >= strs.size ()) return 0 ; if (dp[startIndex][m][n] != -1 ) return dp[startIndex][m][n]; int ones = 0 , zeros = 0 ; for (const char & c : strs[startIndex]) { if (c == '0' ) zeros++; else ones++; } int ans = dfs (strs, dp, m, n, startIndex+1 ); if (m-zeros>=0 && n-ones>=0 ) { ans = max (ans, 1 +dfs (strs, dp, m-zeros, n-ones, startIndex+1 )); } dp[startIndex][m][n] = ans; return ans; } int findMaxForm (vector<string>& strs, int m, int n) { vector<vector<vector<int >>> dp (strs.size (), vector<vector<int >>(m+1 , vector <int >(n+1 , -1 ))); return dfs (strs, dp, m, n, 0 ); } };
事实上,记忆化搜索的模板可以用于任何回溯算法,只要能找到合适的dp数组,就能实现记忆化搜索。同时,本题是一个典型的01背包问题,因此可以直接使用动态规划优化。
动态规划优化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution {public : int findMaxForm (vector<string>& strs, int m, int n) { vector<vector<int >> dp (m+1 , vector <int >(n+1 , 0 )); for (const string& str : strs) { int zeros = count (str.begin (), str.end (), '0' ); int ones = str.size () - zeros; for (int i = m; i >= zeros; --i) { for (int j = n; j >= ones; --j) { dp[i][j] = max (dp[i][j], dp[i-zeros][j-ones]+1 ); } } } return dp[m][n]; } };