一、回溯介紹
1.定義
-
搜索與回溯是計算機解題中常用的算法,很多問題無法根據某種確定的計算法則來求解,可以利用搜索與回溯的技術求解。回溯是搜索算法中的一種控制策略。
-
它的基本思想是:爲了求得問題的解,先選擇某一種可能情況向前探索,在探索過程中,一旦發現原來的選擇是錯誤的,就退回一步重新選擇,繼續向前探索,如此反覆進行,直至得到解或證明無解。
-
如迷宮問題:進入迷宮後,先隨意選擇一個前進方向,一步步向前試探前進,如果碰到死衚衕,說明前進方向已無路可走,這時,首先看其它方向是否還有路可走,如果有路可走,則沿該方向再向前試探;如果已無路可走,則返回一步,再看其它方向是否還有路可走;如果有路可走,則沿該方向再向前試探。按此原則不斷搜索回溯再搜索,直到找到新的出路或從原路返回入口處無解爲止。
從上面的定義可以看出,回溯是跟DFS很相近的。
2.公式僞代碼
void search(int k){
if (到目的地) 輸出結果
for(int i=1;i<=算符種類;i++){
if(符合條件){
保存條件
search(k + 1);
回溯,把保存的結果回退
}
}
}
PS:公式也不是萬能的,不同的題目需要對公式進行不同的改進
二、LeetCode例題
力扣上面有很多回溯的題目,這裏就簡單舉幾個例子。
(一)一維數組的全排列:LeetCode46. 全排列
【題目】
給定一個 沒有重複 數字的序列,返回其所有可能的全排列。
【示例】
輸入: [1,2,3]
輸出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
【代碼】
List<Integer> path;
List<List<Integer>> paths;
public List<List<Integer>> permute(int[] nums) {
path = new ArrayList<>();
paths = new ArrayList<>();
boolean[] flag = new boolean[nums.length];
search(nums,flag);
return paths;
}
public void search(int[] nums, boolean[] flag) {
if (path.size()==nums.length) {
paths.add(new ArrayList<>(path));
return;
}
for(int i=0; i<nums.length; i++) {
if (!flag[i]) {
flag[i] = true;
path.add(nums[i]);
search(nums, flag);
path.remove(path.size()-1);
flag[i] = false;
}
}
}
【思考】
這個題目是非常典型的回溯,直接套公式就可以。唯一需要注意的是在符合條件的時候,把path加入paths中的時候,需要創建一個副本對象加入。
paths.add(new ArrayList<>(path));
如果不重新創建新的path,你們加入的結果在後面的回溯中還會被remove等操作,因爲path的引用是同一個,執行的操作對象一直未變,所以需要創建新的對象。
(二)一維數組全排列+剪枝:LeetCode47. 全排列 II
【題目】
給定一個可包含重複數字的序列,返回所有不重複的全排列。
【示例】
輸入: [1,1,2]
輸出:
[
[1,1,2],
[1,2,1],
[2,1,1]
]
【代碼】
List<Integer> path;
List<List<Integer>> paths;
public List<List<Integer>> permuteUnique(int[] nums) {
path = new ArrayList<>();
paths = new ArrayList<>();
Arrays.sort(nums);
boolean[] flag = new boolean[nums.length];
search(nums,flag);
return paths;
}
public void search(int[] nums, boolean[] flag) {
if (path.size()==nums.length) {
paths.add(new ArrayList<>(path));
return;
}
for(int i=0; i<nums.length; i++) {
if(i>0 && nums[i] == nums[i-1] && !flag[i-1]) continue;
if (!flag[i]) {
flag[i] = true;
path.add(nums[i]);
search(nums, flag);
path.remove(path.size()-1);
flag[i] = false;
}
}
}
【思考】
基本的思路沒有變,還是套回溯的公式,但是得到的結果會包含重複的排列,所以需要進行處理,要麼對結果進行去重,要麼在回溯的過程進行剪枝;對結果進行去重的操作大部分時候是超時的,因爲本身回溯就是一種比較費時的操作,在回溯結束之後在去重操作,會額外增加時間複雜度。而剪枝正好相反,不僅不會增加時間複雜度還會減少時間複雜度。
剪枝:在回溯過程把某些不合適的情況直接排除掉,不進入回溯的遞歸中。
在這道題目中:先需要對數組進行排序,因爲這樣可以把相同的元素放在一起,然後在算子的循環中加上相同的判斷,相同的時候,就不進入回溯遞歸中。
Arrays.sort(nums);// 排序
// 其他操作
if(i>0 && nums[i] == nums[i-1] && !flag[i-1]) continue; // 剪枝操作
// 剪枝條件:i > 0 是爲了保證 nums[i - 1] 有意義
// 寫 !flag[i - 1] 是因爲 nums[i - 1] 在深度優先遍歷的過程中剛剛被撤銷選擇
(三)二維數組的路徑選擇:LeetCode980. 不同路徑 III
【題目】
在二維網格 grid 上,有 4 種類型的方格:
1 表示起始方格。且只有一個起始方格。
2 表示結束方格,且只有一個結束方格。
0 表示我們可以走過的空方格。
-1 表示我們無法跨越的障礙。
返回在四個方向(上、下、左、右)上行走時,從起始方格到結束方格的不同路徑的數目,每一個無障礙方格都要通過一次。
PS:題目要求可能有很多種,比如:最長路徑,最短時間,路徑個數;萬變不離其宗!
【示例】
輸入:[[1,0,0,0],[0,0,0,0],[0,0,2,-1]]
輸出:2
解釋:我們有以下兩條路徑:
1. (0,0),(0,1),(0,2),(0,3),(1,3),(1,2),(1,1),(1,0),(2,0),(2,1),(2,2)
2. (0,0),(1,0),(2,0),(2,1),(1,1),(0,1),(0,2),(0,3),(1,3),(1,2),(2,2)
PS:注意看清題目,這是要求把所有的無障礙都走一遍的路徑,而不是求任意路徑
【代碼】
int count;
int[][] pos = {{1,0},{-1,0},{0,1},{0,-1}};
public int uniquePathsIII(int[][] grid) {
if (grid==null || grid.length==0 || grid[0].length==0)
return 0;
int i,j,m,n,ans,s1,s2,e1,e2;
m = grid.length;
n = grid[0].length;
ans = 0;
s1 = s2 = e1 = e2 = 0;
// 找到起點和終點的座標以及無障礙通道的個數
for (i=0; i<m; i++) {
for (j=0; j<n; j++) {
if (grid[i][j]==1) {
s1 = i;
s2 = j;
}
if (grid[i][j]==0)
ans++;
}
}
boolean[][] flag = new boolean[m][n];
flag[s1][s2] = true;
count = 0;
// 以起點爲中心,上下左右遞歸回溯
dfs(grid,flag,s1+1,s2,0,ans);
dfs(grid,flag,s1-1,s2,0,ans);
dfs(grid,flag,s1,s2+1,0,ans);
dfs(grid,flag,s1,s2-1,0,ans);
return count;
}
public void dfs(int[][] grid, boolean[][] flag,
int cur1, int cur2,
int curK, int ans) {
if (cur1<grid.length && cur2<grid[0].length && cur1>=0 && cur2>=0) {
// 達到終點並且走的無障礙個數爲全部個數
if (grid[cur1][cur2]==2 && curK==ans) {
count++;
return;
}
// 算子就是上下左右四個方向
for (int i=0; i<4; i++) {
if (!flag[cur1][cur2] && grid[cur1][cur2]==0) {
flag[cur1][cur2] = true;
dfs(grid,flag,
cur1+pos[i][0],cur2+pos[i][1],
curK+1,ans);
flag[cur1][cur2] = false;
}
}
}
}
【思考】
整體思路還是很明瞭的,基本上都是套公式,剪枝都在判斷條件中體現出來,二維數組還需要注意邊界越界的判斷。
【Java 面試那點事】
這裏致力於分享 Java 面試路上的各種知識,無論是技術還是經驗,你需要的這裏都有!
這裏可以讓你【快速瞭解 Java 相關知識】,並且【短時間在面試方面有跨越式提升】
面試路上,你不孤單!