回溯算法团灭子集、排列、组合问题
回溯算法團滅子集、排列、組合問題
一、子集
給定一組不含重復元素的整數數組 nums,返回該數組所有可能的子集(冪集)。
說明:解集不能包含重復的子集。
示例:
輸入: nums = [1,2,3] 輸出: [[3],[1],[2],[1,2,3],[1,3],[2,3],[1,2],[] ]第一個解法是利用數學歸納的思想:假設我現(xiàn)在知道了規(guī)模更小的子問題的結果,如何推導出當前問題的結果呢?
具體來說就是,現(xiàn)在讓你求 [1,2,3] 的子集,如果你知道了 [1,2] 的子集,是否可以推導出 [1,2,3] 的子集呢?先把 [1,2] 的子集寫出來瞅瞅:
[ [],[1],[2],[1,2] ]你會發(fā)現(xiàn)這樣一個規(guī)律:
subset([1,2,3]) - subset([1,2]) = [3],[1,3],[2,3],[1,2,3]而這個結果,就是把 sebset([1,2]) 的結果中每個集合再添加上 3。換句話說,如果 A = subset([1,2]) ,那么:
subset([1,2,3]) = A + [A[i].add(3) for i = 1..len(A)]這就是一個典型的遞歸結構嘛,[1,2,3] 的子集可以由 [1,2] 追加得出,[1,2] 的子集可以由 [1] 追加得出,base case 顯然就是當輸入集合為空集時,輸出子集也就是一個空集。
翻譯成代碼就很容易理解了:
vector<vector<int>> subsets(vector<int>& nums) {// base case,返回一個空集if (nums.empty()) return {{}};// 把最后一個元素拿出來int n = nums.back();nums.pop_back();// 先遞歸算出前面元素的所有子集vector<vector<int>> res = subsets(nums);int size = res.size();for (int i = 0; i < size; i++) {// 然后在之前的結果之上追加res.push_back(res[i]);//在每個結果后面push一個前面保存的元素nres.back().push_back(n);//eg:{1,2} : {},{1},{2},{1,2} //n = 2 res = subsets()// n = 1 res = sunsets()// res = { {} }// i = 0; i < 1;i ++// res = { {}, {} }// res = { {},{1} }//i = 0;i < 2;i ++//res = { {},{1},{} }//res = { {},{1},{2} }//res = { {},{1},{2},{1} }//res = { {},{1},{2},{1,2} }}return res; }這個問題的時間復雜度計算比較容易坑人。我們之前說的計算遞歸算法時間復雜度的方法,是找到遞歸深度,然后乘以每次遞歸中迭代的次數。對于這個問題,遞歸深度顯然是 NNN,但我們發(fā)現(xiàn)每次遞歸 for 循環(huán)的迭代次數取決于 res 的長度,并不是固定的
根據剛才的思路,res 的長度應該是每次遞歸都翻倍,所以說總的迭代次數應該是 2N2^N2N。或者不用這么麻煩,你想想一個大小為 N 的集合的子集總共有幾個?2N2^N2N 個對吧,所以說至少要對 res 添加 2N2^N2N 次元素.
那么算法的時間復雜度就是 O(2N)O(2^N)O(2N) 嗎?還是不對,2N2^N2N 個子集是 push_back 添加進 res 的,所以要考慮 push_back 這個操作的效率:
for (int i = 0; i < size; i++) {res.push_back(res[i]); // O(N)res.back().push_back(n); // O(1) }因為 res[i] 也是一個數組呀,push_back 是把 res[i] copy 一份然后添加到數組的最后,所以一次操作的時間是 O(N)O(N)O(N)。
綜上,總的時間復雜度就是 O(N?2N)O(N*2^N)O(N?2N),還是比較耗時的。
空間復雜度的話,如果不計算儲存返回結果所用的空間的,只需要 O(N)O(N)O(N) 的遞歸堆棧空間。如果計算 res 所需的空間,應該是 O(N?2N)O(N*2^N)O(N?2N)。
第二種通用方法就是回溯算法。而回溯算法的模板:
result = [] def backtrack(路徑, 選擇列表):if 滿足結束條件:result.add(路徑)returnfor 選擇 in 選擇列表:做選擇backtrack(路徑, 選擇列表)撤銷選擇只要改造回溯算法的模板就行了
vector<vector<int>> res;vector<vector<int>> subsets(vector<int>& nums) {// 記錄走過的路徑vector<int> track;backtrack(nums, 0, track);return res; }void backtrack(vector<int>& nums, int start, vector<int>& track) {res.push_back(track);for (int i = start; i < nums.size(); i++) {// 做選擇track.push_back(nums[i]);// 回溯backtrack(nums, i + 1, track);// 撤銷選擇track.pop_back();} }//eg : {1,2} //backtrack(nums,0,track); //res : { {} } i = 0;i < 2;i++ //track : {1}; // backtrack(i + 1) // res = { {},{1}},i = 1;i < 2;i++ // track : { {1,2} }; // backtrack(nums,i + 1,track) // res : {{},{1},{1,2}};i = 2;i < 2;i++ return; // track : {1} return; //track : { {} } //track : { {2} }; // backtrack(i + 1); // res : {{},{1},{1,2},{2}};i = 2;i < 2;i++;return //track : { {} }; //return不要看他比較復雜,但是其本身就是比較復雜,但是千萬不要用腦袋去’遞歸‘,要記住框架邏輯再舉個簡單的例子即可
可以看見,對 res 更新的位置處在前序遍歷,也就是說,res 就是樹上的所有節(jié)點(所以代碼中沒有對結果進行if判斷):
二、組合
給定兩個整數 n 和 k,返回 1 … n 中所有可能的 k 個數的組合(不能重復)。
示例:
輸入: n = 4, k = 2 輸出: [[2,4],[3,4],[2,3],[1,2],[1,3],[1,4], ]這也是典型的回溯算法,k 限制了樹的高度,n 限制了樹的寬度,繼續(xù)套我們以前講過的回溯算法模板框架就行了:
vector<vector<int>>res;vector<vector<int>> combine(int n, int k) {if (k <= 0 || n <= 0) return res;vector<int> track;backtrack(n, k, 1, track);return res; }void backtrack(int n, int k, int start, vector<int>& track) {// 到達樹的底部if (k == track.size()) {res.push_back(track);return;}// 注意 i 從 start 開始遞增for (int i = start; i <= n; i++) {// 做選擇track.push_back(i);backtrack(n, k, i + 1, track);// 撤銷選擇track.pop_back();} }backtrack 函數和計算子集的差不多,區(qū)別在于,更新 res 的時機是樹到達底端時。
三、排列
給定一個沒有重復數字的序列,返回其所有可能的全排列。
示例:
輸入: [1,2,3] 輸出: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1] ]首先畫出回溯樹來看一看:
java代碼:
List<List<Integer>> res = new LinkedList<>();/* 主函數,輸入一組不重復的數字,返回它們的全排列 */ List<List<Integer>> permute(int[] nums) {// 記錄「路徑」LinkedList<Integer> track = new LinkedList<>();backtrack(nums, track);return res; }void backtrack(int[] nums, LinkedList<Integer> track) {// 觸發(fā)結束條件if (track.size() == nums.length) {res.add(new LinkedList(track));return;}for (int i = 0; i < nums.length; i++) {// 排除不合法的選擇if (track.contains(nums[i]))continue;// 做選擇track.add(nums[i]);// 進入下一層決策樹backtrack(nums, track);// 取消選擇track.removeLast();} }C++代碼:
class Solution { public:vector<vector<int>> ret;//保存最后的結果vector<vector<int>> permute(vector<int>& nums) {if(nums.empty()){return ret;}vector<int> track;//保存單次結果backtrack(nums,track);//遞歸return ret;}void backtrack(vector<int>& nums,vector<int>& track){if(track.size() == nums.size())//當track中的元素和nums中的元素個數相等//時,代表是一種全排列,可以放到ret的結果中{ret.push_back(track);return;}//循環(huán)回朔for(int i = 0;i < nums.size();i++){//如果nums[i]這個元素已經存在track中,代表上次的選擇錯誤,//continue回去,判斷下一個元素//反之,就把nums這個元素直接放到track中,繼續(xù)回朔判斷if(IsExist(track,nums[i])){track.push_back(nums[i]);}else{continue;}backtrack(nums,track);//撤銷選擇track.pop_back();}}//在track中判斷一個元素是否已經存在bool IsExist(vector<int>& track,int val){for(auto e : track){if(e == val){return false;}}return true;} };回溯模板依然沒有變,但是根據排列問題和組合問題畫出的樹來看,排列問題的樹比較對稱,而組合問題的樹越靠右節(jié)點越少。
在代碼中的體現(xiàn)就是,排列問題每次通過 contains 方法來排除在 track 中已經選擇過的數字;而組合問題通過傳入一個 start 參數,來排除 start 索引之前的數字。
注意對于出現(xiàn)重復的數字的情況,如:
題目:
輸入一個字符串,按字典序打印出該字符串中字符的所有排列。例如輸入字符串abc,則打印出由字符a,b,c所能排列出來的所有字符串abc,acb,bac,bca,cab和cba。
輸入:
輸入一個字符串,長度不超過9(可能有字符重復),字符只包括大小寫字母。
注意此處和之前的是不一樣的,但是模版還是一樣的,只不過換一種思維:
C++代碼:
class Solution { public:vector<string>ret;//判斷結果是否已經在ret當中bool IsEist(vector<string>& a,string& val){for(auto e : a){if(e == val){return false;}}return true;}void backtrack(string& str,int start){//如果當start走到最后一個字符的位置if(start == str.size() - 1){//如果這次結果已經在ret當中就不添加進去if(IsEist(ret,str))ret.push_back(str);}else{for(int i = start;i < str.size();i++){//做選擇swap(str[i],str[start]);backtrack(str,start + 1);//撤回swap(str[i],str[start]);}}}vector<string> Permutation(string str) {if(str.size()< 1){return ret;}//str是字符串,0代表從哪里開始backtrack(str,0);//按字典排序sort(ret.begin(),ret.end());return ret;} };四、總結一下
子集問題可以利用數學歸納思想,假設已知一個規(guī)模較小的問題的結果,思考如何推導出原問題的結果。也可以用回溯算法,要用 start 參數排除已選擇的數字。
組合問題利用的是回溯思想,結果可以表示成樹結構,我們只要套用回溯算法模板即可,關鍵點在于要用一個 start 排除已經選擇過的數字。
排列問題是回溯思想,也可以表示成樹結構套用算法模板,關鍵點在于使用 contains 方法排除已經選擇的數字。
記住這幾種樹的形狀,就足以應對大部分回溯算法問題了,無非就是 start 或者 contains 剪枝,也沒啥別的技巧了。
模版模版模版!!!
超強干貨來襲 云風專訪:近40年碼齡,通宵達旦的技術人生總結
以上是生活随笔為你收集整理的回溯算法团灭子集、排列、组合问题的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 回溯算法详解之全排列、N皇后问题
- 下一篇: 滑动窗口技巧