【LeetCode】全排列、子集、組合問題
文章目錄
一、全排列
1.1 無重複元素全排列
給定一個沒有重複數字的序列,返回其所有可能的全排列
//示例 輸入[1, 2, 3]
/*輸出
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
*/
1.1.1 從左向右交換
觀察元組 [1, 2, 3],以元素1為第一位的排列有 [1, 2, 3] 和 [1, 3, 2],此排列可以由初始排列和交換2和3後得到。以元素2為第一位的元素排列有 [2, 1, 3] 和 [2, 3, 1] ,同樣以元素3為第一位的排列有 [3, 2, 1] (此處是321而不是312的原因是按下面程式碼執行順序來的,因為先交換了3和1,不同的交換順序或者操作不同生成的全排列順序不同,但其組成的集合是相同的,其他的順序下面會講到
在函式backtrack()中,迴圈中i初始值為begin是因為要將自己與自己交換來儲存當前這個序列,之後深度搜索遞迴處理交換位置從begin+1開始,遞迴結束後恢復現場,將交換的數交換回來,繼續迴圈交換處理第i+1個數據。
class Solution {
List<List<Integer>> res;
public List<List<Integer>> permute(int[] nums) {
res = new ArrayList();
List<Integer> temp = new ArrayList<>();
for(int i : nums){
temp.add(i);
}
backtrack(temp, 0, nums.length);
return res;
}
public void backtrack(List<Integer> temp, int begin, int end){
if(begin == end){
res.add(new ArrayList<>(temp));
return;
}
for(int i = begin; i < end; i++){
Collections.swap(temp, begin, i);
backtrack(temp, begin + 1, end);
Collections.swap(temp, begin, i);
}
}
}
在主函式中呼叫如下
Solution so = new Solution();
List list = so.permute(new int[] {1, 2, 3});
for(Object temp : list) {
System.out.println(temp);
}
輸出結果如下圖所示,可看出第一個元素依次是1, 2, 3
1.1.2 從右往左交換
若將for迴圈修改為從end至begin交換
//backtrack(temp, 0, nums.length - 1);
public void backtrack(List<Integer> temp, int begin, int end){
if(begin == end){
res.add(new ArrayList<>(temp));
return;
}
for(int i = end; i >= begin; i--){
Collections.swap(temp, i, end);
backtrack(temp, begin, end - 1);
Collections.swap(temp, i, end);
}
}
輸入結果如下圖所示,可看出最後一個元素依次是3,2,1
1.1.3 移動元素至左(升序排列)
若將第i個元素移動至begin前面,返回的序列正好是按其升序排列的序列,可對比從左向右交換的方法
for(int i = begin; i < end; i++){
int t = temp.remove(i);
temp.add(begin, t);
backtrack(temp, begin + 1, end);
temp.remove(begin);
temp.add(i, t);
}
輸入結果如下圖所示,可看出其結果按升序排列
1.1.4 移動元素至右(逆序看
降序排列)
若將第i個元素移動至end後面,返回的正好是按其序列逆序降序排列的序列,可以對比從右往左交換的方法
for(int i = end; i >= begin; i--){
int t = temp.remove(i);
temp.add(end, t);
backtrack(temp, begin, end - 1);
temp.remove(end);
temp.add(i, t);
}
輸入結果如下圖所示
【拓展】康託展開
輸入n和k,返回1-n按其升序排列的第k個排列序列
按大小順序列出所有排列情況,並一一標記,當 n = 3
時, 所有排列如下:
"123"
"132"
"213"
"231"
"312"
"321"
當k = 4時應輸出“231”
解題方法:此問題可以用上面講到的第三種方法找到第k個後終止搜尋並返回結果就可以了,但比較耗時。可以用下面的數學方法康託展開來解這道題。
康託展開
是一個全排列到一個自然數的雙射,常用於構建雜湊表時的時間壓縮。康拓展開的實質是計算當前排列在所有由小到大全排列中的順序,因此是可逆的
X
=
a
n
(
n
−
1
)
!
+
a
(
n
−
1
)
(
n
−
2
)
!
+
⋯
+
a
1
0
!
X= a_n (n-1)!+a_(n-1) (n-2)!+⋯+a_1 0!
X=an(n−1)!+a(n−1)(n−2)!+⋯+a10!
其中,ai為整數,並且0<=ai<i, 1< =i <= n,ai表示原數的第i位在當前未出現的元素中是排在第幾個,詳細見連結
class Solution {
public String getPermutation(int n, int k) {
int[] factor = new int[n];
factor[0] = 1;
for(int i = 1; i < n; i++) {
factor[i] = factor[i - 1] * i;
}
ArrayList<Integer> list = new ArrayList<>();
for(int i = 1; i <= n; i++) {
list.add(i);
}
k--;
StringBuffer sb = new StringBuffer();
for(int i = n - 1; i >= 0; i--) {
int t = k / factor[i];
sb.append(list.remove(t));
k %= factor[i];
}
return sb.toString();
}
}
1.2 有重複元素全排列
給定一個可包含重複數字的序列 nums
,按任意順序 返回所有不重複的全排列
/*輸入:nums = [1,1,2]
輸出:
[[1,1,2],
[1,2,1],
[2,1,1]]
*/
將此陣列元素加入ArrayList中,在每次遍歷元素前判斷此元素之前是否已經被交換過,若之前有相同的元素已經交換了,則跳過此元素。此過程可以用HashSet來實現。就是在上述1.1從左向右交換中使用HashSet,具體程式碼如下
public void backtrack(List<Integer> list, int begin, int end){
if(begin == end){
res.add(new ArrayList(list));
return;
}
Set<Integer> set = new HashSet<>();
for(int i = begin; i < end; i++){
if(set.contains(list.get(i))){
continue;
}
set.add(list.get(i));
Collections.swap(list, begin, i);
backtrack(list, begin + 1, end);
Collections.swap(list, begin, i);
}
}
對於陣列[1, 1, 2, 2],程式執行結果如下圖所示
二、子集
2.1 無重複元素子集
給定一組不含重複元素的整數陣列 nums,返回該陣列所有可能的子集(冪集)
【注意】 解集不能包含重複的子集
//輸入 nums = [1, 2, 3]
/*輸出
[3],
[1],
[2],
[1,2,3],
[1,3],
[2,3],
[1,2],
[]
*/
2.1.1 回溯法
空集必定包含在子集中,可以初始化list為空,所以backtrack()函式開始時應在結果集中加入list,然後從begin開始每次遍歷一個元素將其加入到list中,深度遍歷的下一步是遍歷第i+1個元素(因為已經加入了第i個元素,此時遞迴加入第i+1個元素),最後一步狀態重置移除剛剛加入的第i個元素。然後繼續迴圈處理第i+1個元素。此處使用LinkedList方便新增和刪除
class Solution {
List<List<Integer>> res = new LinkedList<>();
public List<List<Integer>> subsets(int[] nums) {
backtrack(nums, new LinkedList<Integer>(), 0);
return res;
}
public void backtrack(int[] nums, LinkedList<Integer> list, int begin){
res.add(new LinkedList<Integer>(list));
for(int i = begin; i < nums.length; i++) {
list.add(nums[i]);
backtrack(nums, list, i + 1);
list.removeLast();
}
}
}
呼叫程式nums = [1, 2, 3]的輸出結果如下圖所示
2.1.2 二進位制計數法
對於 nums = [1, 2, 3]來說,有三個元素,則包含的子集個數為2^3 = 8個,此八個子集分別為0 - 7對應的二進位制中相應位置為1的元素組合,如下示例
/*
321
0 000 []
1 001 [1]
2 010 [2]
3 011 [2, 1]
4 100 [3]
5 101 [3, 1]
6 110 [3, 2]
7 111 [3, 2, 1]
*/
所以可以遍歷0-7的數字,根據對應二進位制位的數字為1(或0)來決定加入(不加入)對應元素,具體程式碼如下
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
int n = (1 << nums.length);
for(int i = 0; i < n; i++){
List<Integer> list = new ArrayList<>();
int x = i;
for(int j = 0; j < nums.length; j++){
if((x & 1) == 1){
list.add(nums[j]);
}
x >>= 1;
}
res.add(list);
}
return res;
}
}
輸出結果res如下圖所示
2.1.3 迭代法
可以先將空集加入結果集,然後每遍歷一個元素,將此元素加入結果集的每個子集中形成新的集合,將此集合加入到結果集中,直至遍歷完最後一個元素,詳細過程如下
/*
[]
[1]
[2]
[1, 2]
[3]
[1, 3]
[2, 3]
[1, 2, 3]
*/
具體程式碼如下
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
res.add(new ArrayList<Integer>());
for(int x : nums){
int len = res.size();
for(int i = 0; i < len; i++){
List<Integer> list = new ArrayList(res.get(i));
list.add(x);
res.add(list);
}
}
return res;
}
}
程式輸出結果如下
2.2 有重複元素子集
給定一個可能包含重複元素的整數陣列 nums,返回該陣列所有可能的子集(冪集)
【注意】解集不能包含重複的子集
/*
輸入: [1,2,2]
輸出:
[
[2],
[1],
[1,2,2],
[2,2],
[1,2],
[]
]
*/
2.2.1 回溯法
首先要進行排序,之後再用HashSet去重。若不進行排序,對於像[4, 4, 4, 1, 4]這樣的序列,因為每次回溯過程中會移除當前元素,假如在移除了第三個4後遍歷到最後一個4,會重複記錄[4, 4, 1, 4]。
對於有重複元素的全排列來說,不需要排序,因為它的集合是所有元素的組合
class Solution {
List<List<Integer>> res;
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums);
res = new ArrayList<>();
backtrack(nums, new ArrayList<Integer>(), 0);
return res;
}
private void backtrack(int[] nums, List<Integer> list, int begin) {
res.add(new ArrayList<Integer>(list));
Set<Integer> set = new HashSet<>();
for(int i = begin; i < nums.length; i++) {
if(set.contains(nums[i])) {
continue;
}
set.add(nums[i]);
list.add(nums[i]);
backtrack(nums, list, i + 1);
list.remove(list.size() - 1);
}
}
}
對於陣列nums = [4, 4, 4, 1, 4]執行結果如下圖所示
2.2.2 迭代法
首先要進行排序
和無重複元素中的迭代法一樣,假如nums = [1, 2, 2, 3],根據前面的迭代若此時結果集中元組為[[], [1], [2], [1, 2]
],且[2]和[1, 2]是遍歷第一個2時生成的,此時在遍歷第二個2時,對[]和[1]不需重新加入了,只需在遍歷前面2的生成的子集基礎上加入2,即得到 [2, 2] 和 [1, 2, 2] 。因此需要記錄每次新生成的子集數,若當前元素與前一個元素不相等,則結果集從0開始處理;若相同,則從(list.size() - count)處開始處理。具體見程式碼和執行結果分析。
class Solution {
public List<List<Integer>> subsetsWithDup(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
res.add(new ArrayList<Integer>());
Arrays.sort(nums);
int count = 0;
for(int i = 0; i < nums.length; i++){
int begin = 0;
if(i != 0 && nums[i] == nums[i - 1]){
begin = res.size() - count;
}
int len = res.size();
count = 0;
for(int j = begin; j < len; j++){
List<Integer> list = new ArrayList<>(res.get(j));
list.add(nums[i]);
res.add(list);
count++;
}
}
return res;
}
}
執行結果和分析如下
三、組合
3.1 組合
給定兩個整數 n 和 k,返回 1 … n 中所有可能的 k 個數的組合
/*
輸入: n = 4, k = 2
輸出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
*/
組合其實就是在子集問題的基礎上增加了約束條件,使得子集中元素的長度為k。所以可以在選擇將集合加入結果集時判斷是否等於要求個數k,可以通過剪枝提高效率,若當前集合元素個數加上未遍歷元素個數小於k,剩餘元素沒必要判斷了。
class Solution {
List<List<Integer>> res = new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
backtrack(new LinkedList<Integer>(), 1, n, k);
return res;
}
public void backtrack(LinkedList<Integer> list, int begin, int n, int k){
if(n - begin + 1 < k){
return;
}
if(k == 0){
res.add(new LinkedList<>(list));
return;
}
for(int i = begin; i <= n; i++){
list.add(i);
backtrack(list, i + 1, n, k - 1);
list.removeLast();
}
}
}
對於示例 n = 4, k = 2 時執行程式輸出結果如下圖所示
3.2 組合總和
給定一個無重複元素的陣列 candidates 和一個目標數 target ,找出 candidates 中所有可以使數字和為 target 的組合。
candidates 中的數字可以無限制重複被選取。
說明:
所有數字(包括 target)都是正整數
解集不能包含重複的組合
/*輸入:candidates = [2,3,6,7], target = 7,
所求解集為:
[
[7],
[2,2,3]
]
*/
因為陣列中的數字可以重複選取,所以在遞迴呼叫backtrack函式時begin是從i開始,表示還是從當前元素判斷,直到target < 0(即目標和大於給定初始值target)為止。具體程式碼如下所示(此處只給出backtrack( )函式)
public void backtrack(int[] candidates, LinkedList<Integer> list ,int begin, int end, int target){
if(target < 0){
return;
}
if(target == 0){
res.add(new LinkedList(list));
return;
}
for(int i = begin; i < end; i++){
list.add(candidates[i]);
backtrack(candidates, list, i, end, target - candidates[i]);
list.removeLast();
}
}
對於 candidates = [1, 2, 3], target = 6應用程式輸出結果如下圖
3.3 組合總和IV
給定一個由正整陣列成且不存在重複數字的陣列,找出和為給定目標正整數的組合的個數
/*
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)
請注意,順序不同的序列被視作不同的組合
*/
因為不同的順序被視為不同的組合,此問題是組合和全排列的結合,使用回溯法可以先求出和為目標數的子集(雜湊表去重排序後的子集),然後求子集的全排列個數,之後相加便是。
但這道題使用動態規劃可以很簡單地解決,相關程式碼如下
class Solution {
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target + 1];
dp[0] = 1;
for(int i = 1; i <= target; i++){
for(int j : nums){
if(i >= j){
dp[i] += dp[i - j];
}
}
}
return dp[target];
}
}
四、總結
類似的題目有字母的全排列、字母的子集,組合總和II,組合總和III······
相信你只要認真讀過上述所有題解,下次遇到類似的題目一定會清晰快速地完成!