搞懂回溯算法思想(LeetCode46、47、980)

一、回溯介紹

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 相關知識】,並且【短時間在面試方面有跨越式提升】

面試路上,你不孤單!
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章