用於回顧數據結構與算法時刷題的一些經驗記錄
-
回溯與遞歸都要注意剪枝,避免重複計算
-
回溯的問題,一定要把整個樹圖畫出來,然後再去考慮如何縮小問題,還要注意恢復狀態
-
回溯算法的大致模板:
- 根據這個模板,然後根據自己畫出的樹圖,基本所有回溯問題都可以搞定。
void backtrack(已經做的選擇, 選擇列表): if 當前達到了結束位置: result.push(最終的選擇序列) for 每個選擇 in 選擇列表: 做選擇 //可能這裏需要判斷選擇是否可行 backtrack(新的已選擇, 新的選擇列表) //這裏的已選擇和選擇列表都要更新 撤銷選擇 //在此基礎上,如果部分節點已經無解,則將它能夠推理出的無解的樹枝均剪掉
文章目錄
- [46. 全排列](https://leetcode-cn.com/problems/permutations/)
- [78. 子集](https://leetcode-cn.com/problems/subsets/)
- [90. 子集 II](https://leetcode-cn.com/problems/subsets-ii/)
- [39. 組合總和](https://leetcode-cn.com/problems/combination-sum/)
- [40. 組合總和 II](https://leetcode-cn.com/problems/combination-sum-ii/)
- [22. 括號生成](https://leetcode-cn.com/problems/generate-parentheses/)
- [77. 組合](https://leetcode-cn.com/problems/combinations/)
- [51. N皇后](https://leetcode-cn.com/problems/n-queens/)
46. 全排列
給定一個 沒有重複 數字的序列,返回其所有可能的全排列。
示例:
輸入: [1,2,3]
輸出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
分析: 全排列的發現過程:
因此,每一步,我們需要確定一下當前深度的位置的元素,然後就可以繼續向下展開。例如:如果在第一個位置選擇了1,則在第二個位置可以選擇2或者3,選擇後再次選擇第三個位置的元素。
如果在第i個位置選過後,則之後等價於前i個位置的元素排列已經確定,而後面實際上就是還沒有排列的元素的全排列。這裏就可以發現遞歸的思想。
因此回溯的大致思路爲:
- 函數需要保存的內容有:當前已經確定的序列,當前的位置,序列的總長度
- 每次判斷,是否已經遍歷完,即當前位置是否等於序列的總長度,如果等於說明當前序列已經是一個結果,存儲
- 如果沒有遍歷完,則確定一下該位置的元素,然後繼續遞歸,遞歸時需要將當前位置右移
- 在這裏確定元素時,可以用循環的方式直接確定,但是每次遞歸結束後,需要恢復現場,將狀態均恢復,否則將導致回溯的難以結束或者結果不足
class Solution {
public:
vector<vector<int> > result;
vector<vector<int> > permute(vector<int>& nums)
{
backtrack(nums,0,nums.size());
return result;
}
void backtrack(vector<int>& output,int first,int len)
//output爲當前已有的排列,first表示現在排列到的位置,len爲長度
{
if(first==len)
{
result.emplace_back(output);
return ;
}
for(int i=first;i<len;i++) //進行回溯,每次確定好當前位置的元素後繼續遞歸即可
{
swap(output[i],output[first]); //將目前的位置依次換成output[i]
backtrack(output,first+1,len);
swap(output[first],output[i]); //回溯的關鍵,在於遞歸後要恢復現場
}
}
};
78. 子集
給定一組不含重複元素的整數數組 ,返回該數組所有可能的子集(冪集)。
**說明:**解集不能包含重複的子集。
示例:
輸入: nums = [1,2,3]
輸出:
[[3],[1],[2],[1,2,3],[1,3],[2,3],[1,2],[]]
分析:
方法一:枚舉或遞歸的思想
對於 中每個元素,都有兩種考慮,即選擇或者不選擇,因此最終結果數目是 ,時間複雜度也是,因爲每個結果都需要加到列表中,這是一個 的複雜度
因此,整個問題將化爲類似於決策樹的思路,模擬的方法很多,但思想一致,複雜度爲
方法二:回溯
回溯法的思路更多地向該圖所示,只要定好順序,每次選擇其中一個作爲頭,即可。這個過程也避免了繁重的剪枝
每次選擇該節點或者不選擇該節點,然後去回溯。
子集問題時,我們可以發現,該樹中每一個節點都是結果,因此遍歷到每一個節點都需要將其加入。
class Solution {
public:
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) //start記錄位置
{
res.push_back(track); //每次把當前位置代表的子集加入result中即可
for (int i = start; i < nums.size(); i++) //在這裏將選擇不選擇的思想化爲以哪個位置開頭
{
track.push_back(nums[i]); //將該位置的值放入到track
backtrack(nums, i + 1, track); //向下遍歷,向自己的子節點進行遍歷
track.pop_back(); //回溯回來後,將之前操作恢復,之後就走向自己的兄弟節點去遍歷
}
}
};
90. 子集 II
給定一個可能包含重複元素的整數數組 nums,返回該數組所有可能的子集(冪集)。
**說明:**解集不能包含重複的子集。
分析:該問題,和上個問題幾乎一樣,只是可能包含重複元素,因此,如果對重複元素進行剪枝是該題的關鍵。
思路:我們將 進行排序,按照上題思路,採用回溯法進行判斷,但是,當做選擇時,需要判斷當前選擇是否與之前做的一致,如果上一輪遍歷時候做過該選擇(也就是在樹中表示的意思是該節點的左兄弟節點與子集一致),則不進行選擇。
回溯的思想和順序仍爲下圖,只是如果左兄弟節點和自身是一致時,則將該枝減去。
class Solution {
public:
vector<vector<int> > res;
vector<vector<int>> subsetsWithDup(vector<int>& nums)
{
vector<int> track; //記錄走過的路徑
sort(nums.begin(),nums.end());
backtrack(nums, 0, track);
return res;
}
void backtrack(vector<int>& nums, int start, vector<int>& track) //start記錄位置
{
res.push_back(track); //每次把當前位置代表的子集加入result中即可
for (int i = start; i < nums.size(); i++)//在這裏將選擇不選擇的思想化爲以哪個位置開頭
{
if(i==start||nums[i]!=nums[i-1])
//如果該節點是最左側子節點或者與左側節點不同的話,則正常向下遍歷,否則取消這一輪的遍歷
{
track.push_back(nums[i]); //將該位置的值放入到track
backtrack(nums, i + 1, track); //向下遍歷,向自己的子節點進行遍歷
track.pop_back(); //回溯回來後,將之前操作恢復,之後就走向自己的兄弟節點去遍歷
}
}
}
};
39. 組合總和
給定一個無重複元素的數組 candidates 和一個目標數 target ,找出 candidates 中所有可以使數字和爲 target 的組合。 candidates 中的數字可以無限制重複被選取。
- 所有數字(包括
target
)都是正整數。 - 解集不能包含重複的組合。
示例:
輸入: candidates = [2,3,6,7], target = 7,
所求解集爲:
[
[7],
[2,2,3]
]
分析:
畫出樹形結構,確定出結束方式,進行剪枝優化:
首先對節點進行了排序,然後如果一個節點當前的和已經大於target,則說明其右方兄弟節點肯定不成立,故將右方全部剪枝。爲了避免重複,每個節點向下延伸的值,只能是小於等於其的。
類似於之前的代碼,補充模板即可。
class Solution {
public:
vector<vector<int> > res;
vector<vector<int>> combinationSum(vector<int>& candidates, int target)
{
sort(candidates.begin(),candidates.end());
vector<int> track;
backtrack(candidates,track,0,target);
return res;
}
bool backtrack(vector<int>& candidates,vector<int> track,int start,int target)
{
if(target<0)
return false;
if(target==0)
{
res.push_back(track);return false;
}
bool conti=true;
for(int i=start;i<candidates.size();i++)
{
if(conti==false) break; //conti爲false說明子節點和右兄弟節點一定都不成立
track.push_back(candidates[i]);
conti=backtrack(candidates,track,i,target-candidates[i]);
track.pop_back();
}
return true;
}
};
40. 組合總和 II
給定一個數組 candidates 和一個目標數 target ,找出 candidates 中所有可以使數字和爲 target 的組合。
candidates 中的每個數字在每個組合中只能使用一次。
輸入: candidates = [10,1,2,7,6,1,5], target = 8,
所求解集爲:
[ [1, 7], [1, 2, 5], [2, 6],[1, 1, 6] ]
分析:和上個題比較,candidates 中每個數字只能出現一次,實際上只需要在上題基礎上,在選擇的時候,我們減去了部分選擇即可。當然這裏還需要注意的是candidates 中數字可以重複,因此如果一開始對數組進行了排序,則將重複的部分也直接剪去即可。
class Solution {
public:
vector<vector<int> > res;
vector<vector<int>> combinationSum2(vector<int>& candidates, int target)
{
sort(candidates.begin(),candidates.end());
vector<int> track;
backtrack(candidates,track,0,target);
return res;
}
bool backtrack(vector<int>& candidates,vector<int> track,int start,int target)
{
if(target<0)
return false;
if(target==0)
{
res.push_back(track);return false; //右兄弟節點和子節點均不會成立
}
bool conti=true;
for(int i=start;i<candidates.size();i++)
{
if(i==start||candidates[i]!=candidates[i-1])//剪去重複的節點
{
if(conti==false) break; //conti爲false說明子節點和右兄弟節點一定都不成立
track.push_back(candidates[i]);
conti=backtrack(candidates,track,i+1,target-candidates[i]);
track.pop_back();
}
}
return true;
}
};
22. 括號生成
數字 n 代表生成括號的對數,請你設計一個函數,用於能夠生成所有可能的並且 有效的 括號組合。
輸入:n = 3
輸出:[ "((()))", "(()())", "(())()", "()(())","()()()" ]
分析:(有效?因爲只有一種括號且數量相同,因此只要在任意位置右括號前左括號大於右括號數量即合法)
最暴力的方法,生成所有可能的字符串,然後保留有效的。
顯然很多結果在過程中就已經是無效的,因此採用回溯法,儘可能剪枝。
因此,形成決策樹:(大致的樹形結構爲)
也就是在個節點,都可以選擇左括號或者右括號,然後繼續生成,直至用完。
限制條件(剪枝條件):
- 如果在某處右括號數量大於左括號數量,則說明已經非法,剪枝。
- 左括號和右括號數量至多n個
- 因爲在字符串生成過程中已經判斷是否非法,因此最終生成的字符串都是合法的
class Solution {
public:
vector<string> result;
vector<string> generateParenthesis(int n)
{
string a="";
backtrack(a,n,n);
return result;
}
void backtrack(string a,int left,int right)
{
if(left==0&&right==0) //字符串已經形成,則push
{
result.push_back(a);
return ;
}
if(left>0) //還可以選左括號
{
backtrack(a+'(',left-1,right);
}
if(right>0) //還可以選右括號
{
if(left>=right) return; //說明當前放置的右括號已經大於等於左括號,再放置則非法
else
backtrack(a+')',left,right-1);
}
}
};
77. 組合
給定兩個整數 n 和 k,返回 1 … n 中所有可能的 k 個數的組合。
示例:
輸入: n = 4, k = 2
輸出:
[ [2,4],[3,4],[2,3],[1,2],[1,3],[1,4],]
分析:該問題和組合總和問題基本一致,只是在這裏判斷條件是是否已經有k個數,如果數達到,則結束。否則就繼續遞歸。
以 爲例:
因爲不能重複且是有序的,所以就構成了如圖所示的決策樹
如果深度到達k,則該結果應存儲。
- 如果一個節點可選擇的數目小於還需要添加的數字的數目,則剪枝
- 每次選擇的數字,需要比當前數字都要大
class Solution {
public:
vector<vector<int> > result;
vector<vector<int> > combine(int n, int k)
{
vector<int> track;
if(n<k) return result;
backtrack(track,0,k,n);
return result;
}
void backtrack(vector<int> track,int length,int k,int n)
{
if(length==k) //當前序列長度到達k,則加入到結果中,返回
{
result.push_back(track);
return;
}
if(length>0&&n-track[length-1]<k-length) //後面的節點數一定小於k-length,故該情況不可能有解,直接剪枝
return;
for(int i=(length==0?length:track[length-1]);i<n;i++)
{
track.push_back(i+1);
backtrack(track,length+1,k,n);
track.pop_back();
}
return ;
}
};
51. N皇后
n 皇后問題研究的是如何將 n 個皇后放置在 n×n 的棋盤上,並且使皇后彼此之間不能相互攻擊。
上圖爲 8 皇后問題的一種解法。
給定一個整數 n,返回所有不同的 n 皇后問題的解決方案。
每一種解法包含一個明確的 n 皇后問題的棋子放置方案,該方案中 ‘Q’ 和 ‘.’ 分別代表了皇后和空位。
示例:
輸入: 4
輸出: [
[".Q..", // 解法 1
"...Q",
"Q...",
"..Q."],
["..Q.", // 解法 2
"Q...",
"...Q",
".Q.."]
]
解釋: 4 皇后問題存在兩個不同的解法。
分析:N皇后問題是非常經典的問題,也就是如何放置N個皇后在N*N的棋盤中,使其不攻擊,皇后可以攻擊同一行、同一列、左上左下右上右下四個方向的任意單位。
因此這也是一個排列問題,在這裏可以認爲決策樹的每一層就是棋盤的一行,在每個節點所做的選擇就是,在該行的某一列放置一個皇后。
因此基本思路爲
- 首先對棋盤進行初始化
- 進行回溯,每一行做出一個選擇,即將該位置的’.‘換位’Q’,做出選擇後需要判斷該選擇是否非法,如果非法則直接返回即剪枝
- 如果可以到達第n行且仍合法,則說明該結果合理,存儲
- 判斷非法時,只需要判斷列以及右上方和左上方就可以了,首先判斷是否越界,沒有越界的話判斷是否已經存在皇后。可以用一個方向數組表示,這裏由於只判斷上方,故方向數組只處理列的變化即可
class Solution {
public:
vector<vector<string> > result; //存儲結果
vector<vector<string> > solveNQueens(int n)
{
vector<string> map(n, string(n, '.')); //直接初始化爲n行n列的點
backtrack(map,0,n);
return result;
}
bool isVaild(vector<string>& map, int row, int col) {
int n = map.size();
static const int dx[]={-1,0,1}; //標記col的三種變化
for(int i=1;i<=row;i++) //檢測上方,左上方,右上方是否存在Q
{
int new_row=row-i;
for(int j=0;j<3;j++)
{
int new_col=col+i*dx[j];
if(new_col>=0&&new_col<n&&map[new_row][new_col]=='Q')
return false;
}
}
return true;
}
void backtrack(vector<string>&map,int pos,int n) //pos表示當前高度
{
if(pos==n) //已經到達決策樹的最底端
{
result.push_back(map);
return;
}
for(int i=0;i<n;i++)
{
if(!isVaild(map,pos,i)) //如果在(pos,i)的位置放置Q不合理的話,剪枝
continue; //否則在該處放置Q進行回溯
map[pos][i]='Q';
backtrack(map,pos+1,n);
map[pos][i]='.'; //回溯回去時候需要恢復狀態
}
}
};
52. N皇后 II
對於N皇后Ⅱ,只需要將結果進行修改即可,每次成立時候計數+1,不需要存儲具體的值。
class Solution {
public:
int result; //存儲結果
int totalNQueens(int n)
{
vector<string> map(n, string(n, '.')); //直接初始化爲n行n列的點
backtrack(map,0,n);
return result;
}
bool isVaild(vector<string>& map, int row, int col) {
int n = map.size();
static const int dx[]={-1,0,1}; //標記col的三種變化
for(int i=1;i<=row;i++) //檢測上方,左上方,右上方是否存在Q
{
int new_row=row-i;
for(int j=0;j<3;j++)
{
int new_col=col+i*dx[j];
if(new_col>=0&&new_col<n&&map[new_row][new_col]=='Q')
return false;
}
}
return true;
}
void backtrack(vector<string>&map,int pos,int n) //pos表示當前高度
{
if(pos==n) //已經到達決策樹的最底端
{
result++;
return;
}
for(int i=0;i<n;i++)
{
if(!isVaild(map,pos,i)) //如果在(pos,i)的位置放置Q不合理的話,剪枝
continue; //否則在該處放置Q進行回溯
map[pos][i]='Q';
backtrack(map,pos+1,n);
map[pos][i]='.'; //回溯回去時候需要恢復狀態
}
}
};