回溯(代码实现)

151 阅读13分钟

例题 1:岛屿的最大面积

LeetCode 695. Max Area of Island (Medium)

给定一个包含了一些 0 和 1 的非空二维数组 grid,一个岛屿是一组相邻的 1(代表陆地),这里的「相邻」要求两个 1 必须在水平或者竖直方向上相邻。你可以假设 grid 的四个边缘都被 0(代表海洋)包围着。

找到给定的二维数组中最大的岛屿面积。如果没有岛屿,则返回面积为 0 。

这道题目只需要对每个岛屿做 DFS 遍历,求出每个岛屿的面积就可以了。求岛屿面积的方法也很简单,代码如下,每遍历到一个格子,就把面积加一。

int area(int[][] grid, int r, int c) {  
    return 1 
        + area(grid, r - 1, c)
        + area(grid, r + 1, c)
        + area(grid, r, c - 1)
        + area(grid, r, c + 1);
}

最终我们得到的完整题解代码如下:

public int maxAreaOfIsland(int[][] grid) {
    int res = 0;
    for (int r = 0; r < grid.length; r++) {
        for (int c = 0; c < grid[0].length; c++) {
            if (grid[r][c] == 1) {
                int a = area(grid, r, c);
                res = Math.max(res, a);
            }
        }
    }
    return res;
}
​
int area(int[][] grid, int r, int c) {
    if (!inArea(grid, r, c)) {
        return 0;
    }
    if (grid[r][c] != 1) {
        return 0;
    }
    grid[r][c] = 2;
    
    return 1 
        + area(grid, r - 1, c)
        + area(grid, r + 1, c)
        + area(grid, r, c - 1)
        + area(grid, r, c + 1);
}
​
boolean inArea(int[][] grid, int r, int c) {
    return 0 <= r && r < grid.length 
         && 0 <= c && c < grid[0].length;
}

例题 2:岛屿的周长

LeetCode 463. Island Perimeter (Easy)

给定一个包含 0 和 1 的二维网格地图,其中 1 表示陆地,0 表示海洋。网格中的格子水平和垂直方向相连(对角线方向不相连)。整个网格被水完全包围,但其中恰好有一个岛屿(一个或多个表示陆地的格子相连组成岛屿)。

岛屿中没有“湖”(“湖” 指水域在岛屿内部且不和岛屿周围的水相连)。格子是边长为 1 的正方形。计算这个岛屿的周长。

图片题目示例

实话说,这道题用 DFS 来解并不是最优的方法。对于岛屿,直接用数学的方法求周长会更容易。不过这道题是一个很好的理解 DFS 遍历过程的例题,不信你跟着我往下看。

我们再回顾一下 网格 DFS 遍历的基本框架:

void dfs(int[][] grid, int r, int c) {
    // 判断 base case
    if (!inArea(grid, r, c)) {
        return;
    }
    // 如果这个格子不是岛屿,直接返回
    if (grid[r][c] != 1) {
        return;
    }
    grid[r][c] = 2; // 将格子标记为「已遍历过」
    
    // 访问上、下、左、右四个相邻结点
    dfs(grid, r - 1, c);
    dfs(grid, r + 1, c);
    dfs(grid, r, c - 1);
    dfs(grid, r, c + 1);
}
​
// 判断坐标 (r, c) 是否在网格中
boolean inArea(int[][] grid, int r, int c) {
    return 0 <= r && r < grid.length 
         && 0 <= c && c < grid[0].length;
}

可以看到,dfs 函数直接返回有这几种情况:

  • !inArea(grid, r, c),即坐标 (r, c) 超出了网格的范围,也就是我所说的「先污染后治理」的情况
  • grid[r][c] != 1,即当前格子不是岛屿格子,这又分为两种情况:
    • grid[r][c] == 0,当前格子是海洋格子
    • grid[r][c] == 2,当前格子是已遍历的陆地格子

那么这些和我们岛屿的周长有什么关系呢?实际上,岛屿的周长是计算岛屿全部的「边缘」,而这些边缘就是我们在 DFS 遍历中,dfs 函数返回的位置。观察题目示例,我们可以将岛屿的周长中的边分为两类,如下图所示。黄色的边是与网格边界相邻的周长,而蓝色的边是与海洋格子相邻的周长。

图片将岛屿周长中的边分为两类

当我们的 dfs 函数因为「坐标 (r, c) 超出网格范围」返回的时候,实际上就经过了一条黄色的边;而当函数因为「当前格子是海洋格子」返回的时候,实际上就经过了一条蓝色的边。这样,我们就把岛屿的周长跟 DFS 遍历联系起来了,我们的题解代码也呼之欲出:

public int islandPerimeter(int[][] grid) {
    for (int r = 0; r < grid.length; r++) {
        for (int c = 0; c < grid[0].length; c++) {
            if (grid[r][c] == 1) {
                // 题目限制只有一个岛屿,计算一个即可
                return dfs(grid, r, c);
            }
        }
    }
    return 0;
}
​
int dfs(int[][] grid, int r, int c) {
    // 函数因为「坐标 (r, c) 超出网格范围」返回,对应一条黄色的边
    if (!inArea(grid, r, c)) {
        return 1;
    }
    // 函数因为「当前格子是海洋格子」返回,对应一条蓝色的边
    if (grid[r][c] == 0) {
        return 1;
    }
    // 函数因为「当前格子是已遍历的陆地格子」返回,和周长没关系
    if (grid[r][c] != 1) {
        return 0;
    }
    grid[r][c] = 2;
    return dfs(grid, r - 1, c)
        + dfs(grid, r + 1, c)
        + dfs(grid, r, c - 1)
        + dfs(grid, r, c + 1);
}
​
// 判断坐标 (r, c) 是否在网格中
boolean inArea(int[][] grid, int r, int c) {
    return 0 <= r && r < grid.length 
         && 0 <= c && c < grid[0].length;
}

39. 组合总和

知识点:递归;回溯;组合;剪枝

题目描述

给定一个无重复元素的正整数数组 candidates 和一个正整数 target ,找出 candidates 中所有可以使数字和为目标数 target 的唯一组合。

candidates 中的数字可以无限制重复被选取。如果至少一个所选数字数量不同,则两种组合是唯一的。

对于给定的输入,保证和为 target 的唯一组合数少于 150 个。

示例
输入: candidates = [2,3,6,7], target = 7
输出: [[7],[2,2,3]]

输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]   

输入: candidates = [2], target = 1
输出: []  

输入: candidates = [1], target = 1
输出: [[1]]  

输入: candidates = [1], target = 2
输出: [[1,1]]

解法一:回溯

回溯算法的模板:

result = []   //结果集
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)  //把已经做出的选择添加到结果集;
        return  //一般的回溯函数返回值都是空;

    for 选择 in 选择列表: //其实每个题的不同很大程度上体现在选择列表上,要注意这个列表的更新,
    //比如可能是搜索起点和重点,比如可能是已经达到某个条件,比如可能已经选过了不能再选;
        做选择  //把新的选择添加到路径里;路径.add(选择)
        backtrack(路径, 选择列表) //递归;
        撤销选择  //回溯的过程;路径.remove(选择)

核心就是for循环里的递归,在递归之前做选择,在递归之后撤销选择;


对于本题,有两点和77题组合不一样:

  • 此题可以重复选取选过的元素,所以选择列表的搜索起点不用i+1,仍然是i。
  • 此题没有像之前的题明确给出递归的层数,但是给了target,所以如果相加>target,那就证明到头了;

我们换个角度重新画这个图,和77题有点差距,理解的更全面一点。 其实这就是一个横向循环和纵向的递归,横向循环做出不同的选择,纵向在不同的选择基础上做下一步选择。

clipboard.png

class Solution {
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<List<Integer>> res = new ArrayList<>();
        Stack<Integer> path = new Stack<>();
        backtrack(candidates, target, 0, 0, res, path);
        return res;
    }
    private void backtrack(int[] candidates, int target, int sum, int begin, List<List<Integer>> res, Stack<Integer> path){
        if(sum > target){
            return; 
        }
        if(sum == target){
            res.add(new ArrayList<>(path));
            return;
        }
        for(int i = begin; i < candidates.length; i++){
            //做选择;
            sum += candidates[i];
            path.push(candidates[i]);
            //递归:开始下一轮选择;
            backtrack(candidates, target, sum, i, res, path);  //不用+1,可以重复选;
            //撤销选择:回溯
            sum -= candidates[i];
            path.pop();
        }
    }
}

解法二:剪枝优化

上述程序有优化的空间,我们可以对数组先进行排序,然后如果找到了当前的sum已经等于target或大于target了,那后面的就可以直接跳过了,因为后面的元素更大,肯定更大于target。

clipboard (1).png

class Solution {
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        List<List<Integer>> res = new ArrayList<>();
        Stack<Integer> path = new Stack<>();
        Arrays.sort(candidates);  //排序
        backtrack(candidates, target, 0, 0, res, path);
        return res;
    }
    private void backtrack(int[] candidates, int target, int sum, int begin, List<List<Integer>> res, Stack<Integer> path){
        if(sum == target){
            res.add(new ArrayList<>(path));
            return;
        }
        for(int i = begin; i < candidates.length && sum + candidates[i] <= target; i++){ 
            //剪枝:如果sum+candidates[i] > target就结束;
            //做选择;
            sum += candidates[i];
            path.push(candidates[i]);
            //递归:开始下一轮选择;
            backtrack(candidates, target, sum, i, res, path);  //不用+1,可以重复选;
            //撤销选择:回溯
            sum -= candidates[i];
            path.pop();
        }
    }
}

46. 全排列

知识点:递归;回溯;排列

题目描述

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

示例
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

输入:nums = [0,1]
输出:[[0,1],[1,0]]

输入:nums = [1]
输出:[[1]]

解法一:回溯

回溯算法的模板:

result = []   //结果集
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)  //把已经做出的选择添加到结果集;
        return  //一般的回溯函数返回值都是空;

    for 选择 in 选择列表: //其实每个题的不同很大程度上体现在选择列表上,要注意这个列表的更新,
    //比如可能是搜索起点和重点,比如可能是已经达到某个条件,比如可能已经选过了不能再选;
        做选择  //把新的选择添加到路径里;路径.add(选择)
        backtrack(路径, 选择列表) //递归;
        撤销选择  //回溯的过程;路径.remove(选择)

核心就是for循环里的递归,在递归之前做选择,在递归之后撤销选择;


和组合的区别就是数组可以重复使用,比如选到2的时候,可以重新选1,所以每次for的起点必须都从0开始。
但是比如在一次树枝上,选过2,就不能再选2了,所以需要看一下路径path里是否含有,有的话就不能再选了,从栈里搜索复杂度较高,所以可以使用一个数组used来记录。

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        Stack<Integer> path = new Stack<>();
        boolean[] used = new boolean[nums.length];  //记录谁被选过;
        backtrack(nums, res, path, used);
        return res;
    }
    private void backtrack(int[] nums, List<List<Integer>> res, Stack<Integer> path, boolean[] used){
        if(path.size() == nums.length){
            res.add(new ArrayList<>(path));
            return;
        }
        for(int i = 0; i < nums.length; i++){
            if(used[i]) continue; //选过了就不用了
            //做选择;
            path.push(nums[i]);
            used[i] = true;
            //递归:开始下一轮选择;
            backtrack(nums, res, path, used);
            //撤销选择:回溯;
            path.pop();
            used[i] = false;
        }
    }
}

51. N 皇后

知识点:递归;回溯;N皇后;剪枝

题目描述

给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。

示例

clipboard (2).png

输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。

输入:n = 1
输出:[["Q"]]

解法一:回溯

回溯算法的模板:

result = []   //结果集
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)  //把已经做出的选择添加到结果集;
        return  //一般的回溯函数返回值都是空;

    for 选择 in 选择列表: //其实每个题的不同很大程度上体现在选择列表上,要注意这个列表的更新,
    //比如可能是搜索起点和重点,比如可能是已经达到某个条件,比如可能已经选过了不能再选;
        做选择  //把新的选择添加到路径里;路径.add(选择)
        backtrack(路径, 选择列表) //递归;
        撤销选择  //回溯的过程;路径.remove(选择)

核心就是for循环里的递归,在递归之前做选择,在递归之后撤销选择;


这是棋盘类问题,本质上还是在做选择,在构建一个决策树,只要是决策树的,那就必然是回溯算法。

直接套模板就可以了,那不同的是什么呢,其实大部分回溯类的题不同的就是选择列表的更新,在我们这道题目里选择列表的更新受3个条件影响。不同行不同列不能对角线。

  • 终止条件:当到达最后一行,也就是row=n的时候,就可以收割结果了;

  • 做选择:我们遍历到一个列的时候,也就是for,如果path在这个点所在的行、列、对角线都没有元素那就可以放了。

clipboard (3).png

class Solution {
    public List<List<String>> solveNQueens(int n) {
        List<List<String>> res = new ArrayList<>();
        char[][] path = new char[n][n];  //路径是二维数组;
        for(char[] c : path){
            Arrays.fill(c, '.');  //全部初始化为.  
        }
        backtrack(n, 0, res, path);
        return res;
    }
    private void backtrack(int n, int row, List<List<String>> res, char[][] path){
        if(row == n){
            res.add(toList(n, path));  
            return;
        }
        for(int col = 0; col < n; col++){
            if(isValid(path, row, col, n)){ //决策树的深度就是row,宽度for就是列;row和col确定唯一的点;
                path[row][col] = 'Q';  //做选择;
                backtrack(n, row+1, res, path);  //递归:下一轮选择;
                path[row][col] = '.';
            }
        }
    }
    //检测(row, col)能否放皇后;
    private boolean isValid(char[][] path, int row, int col, int n){
        //检查列;
        for(int i = 0; i < row; i++){
            if(path[i][col] == 'Q'){
                return false;
            }
        }
        //检查45度;
        for(int i = row-1, j = col-1; i >= 0 && j >= 0; i--, j--){
            if(path[i][j] == 'Q'){
                return false;
            }
        }
        //检查135度;
        for(int i = row-1, j = col+1; i >=0 && j < n; i--,j++){
            if(path[i][j] == 'Q'){
                return false;
            }
        }
        return true;
    }
    //二维数组转为list;
    private List<String> toList(int n, char[][] path){
        List<String> list = new ArrayList<>();
        for(int i = 0; i < n; i++){
            list.add(String.valueOf(path[i]));  //字符数组转为字符串;
        }
        return list;
    }
}

【回溯】77. 组合

知识点:递归;回溯;组合;剪枝

题目描述

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例
输入:n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

输入:n = 1, k = 1
输出:[[1]]

解法一:回溯

这是回溯的第一道题,回溯是很经典的一个算法,什么是回溯,回溯其实是一种暴力枚举的方式,为啥都暴力了还是很经典的一种方法呢,其实是因为有些问题我们能暴力出来就不错了,就别要其他自行车了。常见的回溯类问题:组合;排列;切割;子集;棋牌;

比如最经典的排列。从1,2,3,4,5中取3个数组成排列有多少种,我们肯定会解决这种问题,但是程序怎么写呢。想一下我们解决这个问题的过程,我们先选1,然后第二个数可以选2,第三个数可以选3,这是一种答案了,然后呢,换第三个数,第三个数选4,又一种答案,再换,第三个数选5,没得选了,所以以12打头的数都选完了,得到三种答案.然后再换第二个数,第二个数选3,然后第三个数选4,注意是组合问题所以我们不能退往回选2了,不然就重复了。就是这样一种选择方案,一直到第3个数选成了3,得到答案345,就不用往后进行了。

这个过程中有递归吗?当然有啊,我们把问题缩小一点,比如123三个数字的全排列,首先1打头,得到123,132,这其实就是1+[2,3]的全排列;递归体现在这里!

你看,这其实就是一个多叉树啊!每走一步我们都要做出自己的选择,然后在该选择的基础上做下一步选择,直到这个选择达到了题目要求,然后我们放弃我们上一步做的选择,去换另外一种选择试一试。这个换掉我们上一步做的选择就是回溯的过程,也就是“撤销选择” 。因为只有把上一步的选择撤销了我们才能够得到新的选择,比如123,只有把3撤销了我们才能去选择4.

回溯和递归相辅相成,前面也说过了这就是一颗树,而树就一定会用到递归。这棵树我们起了一个名字叫做决策树,每走一步都是在做一次选择一次决策,就和我们的人生一样。想象一下回溯、深度优先搜索(DFS),递归,都有一种“不撞南墙不死心" 的意思,而这个南墙就是我们的结束条件。

  • 路径:记录我们做出了的选择(走过的决策树上的路径,我们一般都是在最后的叶子节点上去收集结果);【比如我们选的123,124】;
  • 选择列表:当前情况下我们可以做出的选择;【比如在第三步我们可以选3.4.5】
  • 结束条件:也就是到达了决策树的底层叶子节点,选择列表为空了,无法再做出别的选择了。【比如我们的树选完了123达到题目中的要求3个元素了,就不能够再做选择了】

回溯算法的模板:

result = []   //结果集
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)  //把已经做出的选择添加到结果集;
        return  //一般的回溯函数返回值都是空;

    for 选择 in 选择列表: //其实每个题的不同很大程度上体现在选择列表上,要注意这个列表的更新,
    //比如可能是搜索起点和终点,比如可能是已经达到某个条件,比如可能已经选过了不能再选;
        做选择  //把新的选择添加到路径里;路径.add(选择)
        backtrack(路径, 选择列表) //递归;
        撤销选择  //回溯的过程;路径.remove(选择)

核心就是for循环里的递归,在递归之前做选择,在递归之后撤销选择;

在这个过程中还有一点很重要,就是我们其实是在做两种遍历;

  • 横向遍历(for):其实就是我们在不停的做着的选择;
  • 纵向遍历(递归):其实就是在做完选择后面临的下一轮选择;

其实不同的情境下最大的不同就在于决策列表的更新,比如说搜索起点和终点,比如说是否已经被选过,比如说是否达到某个条件(只要求k个数或者和为目标值);


回到本题上,本题如果画图的话就是下面的图,也就是本题对应的决策树。

clipboard (4).png

  • 需要有一个路径变量记录做出的选择,而且要能够撤销选择,所以可以选择栈结构;
  • 每一个节点都是在做同样的事情,只不过选择列表不一样了而已,而选择列表不一样是因为开始区间不一样了(尾区间都是最后一个元素),所以需要一个start变量来限制选择列表;

另一种角度:从遍历的角度来看决策树;

  • 横向遍历(for):从左到右做决策;

  • 纵向遍历(递归):在做完决策后开始下一轮决策;

clipboard (5).png

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> res = new ArrayList<>();
        if(k <= 0 || k > n){
            return res;
        }
        Stack<Integer> path = new Stack<>();
        backtrack(n, k, 1, path, res);  //题目中说的从1开始;
        return res;
    }
    private void backtrack(int n, int k, int begin, Stack<Integer> path, List<List<Integer>> res){
        //结束条件:收割结果;
        if(path.size() == k){
            res.add(new ArrayList<>(path));  //注意这里一定要新建一个;自始至终维持的是一个path;
            return;
        }
        //遍历选择列表;
        for(int i = begin; i <= n; i++){
            //做选择;
            path.push(i);
            //递归:下一轮选择(注意选择列表的更新:选择列表变小,开始+1);
            backtrack(n, k, i+1, path, res);
            //撤销选择:回溯;
            path.pop();
        }
    }
}

解法二:剪枝优化

上述程序有优化的空间,还拿熟悉的12345举例子,我们选3个,其实选完3就不用再往后继续了,因为即使选了4,也凑不够3个数了,所以我们的选择列表是还可以优化的,也就是我们的选择起点是有上界的!

选择起点与当前还需要再选几个数有关,而当前还需要再选几个数与已经选了几个数有关,也就时path的长度有关;
比如n=6,k=4;

  • path.size()=1, 那接下来还需要再选3个数,搜索起点最大是4,最后一个被选的是456;
  • path.size()=3, 那接下来还需要再选1个数,搜索起点最大是6,最后一个选的是6;

所以,搜索起点的上界+接下来要选择的元素个数-1 = n
接下来要选择的元素个数 = k-path.size();
所以搜索起点的上界 = n-(k-path.size())+1;
所以我们的i <= n 要改为 i <= n-(k-path.size())+1

下面就是我们得到的剪枝树:

clipboard (6).png

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> res = new ArrayList<>();
        if(k <= 0 || k > n){
            return res;
        }
        Stack<Integer> path = new Stack<>();
        backtrack(n, k, 1, path, res);  //题目中说的从1开始;
        return res;
    }
    private void backtrack(int n, int k, int begin, Stack<Integer> path, List<List<Integer>> res){
        //结束条件:收割结果;
        if(path.size() == k){
            res.add(new ArrayList<>(path));  //注意这里一定要新建一个;自始至终维持的是一个path;
            return;
        }
        //遍历选择列表;
        for(int i = begin; i <= n-(k-path.size())+1; i++){
            //做选择;
            path.push(i);
            //递归:下一轮选择(注意选择列表的更新:选择列表变小,开始+1);
            backtrack(n, k, i+1, path, res);
            //撤销选择:回溯;
            path.pop();
        }
    }
}

时间复杂度:

体会

  • 1.只要是涉及到做选择的,尤其是提到的五个类型:组合、排序、分割、子集、棋盘。这种都可以构建一颗决策树,那就都可以用回溯算法去解。解之前先自己把决策树画出来。
  • 2.整体上套用模板,最大的不同就在于选择列表的更新,要能够根据题目中的要求来更新选择列表,比如到达某个深度了,比如和为某个值了等等;
  • 3.在求和问题中,排序之后加上剪枝是很常见的操作,能够舍弃无关的操作(和已经到达某一值了,因为排过序,其后的值就更大了);

作者:Curryxin

链接:(回溯 - 标签 - Curryxin - 博客园 (cnblogs.com))

链接2:(LeetCode 例题精讲 | 12 岛屿问题:网格结构中的 DFS (qq.com))