通俗易懂的理解递归 & 回溯 & DFS | 图解 & 伪代码

递归

概念

“方法自己调用自己,每一次调用都会更加接近递归的终止条件,用于解决可以分解为相似子问题的问题。”

看不懂没关系,下面通过两个例子来证明这句话。

递归例子1:递归打印链表

举个例子🌰:递归打印链表,执行下面这段代码

public static void printListReverse(ListNode head) {
    if (head == null) {
        return;
    }
    System.out.print(head.val);  // 正序打印
    printListReverse(head.next);
    System.out.print(head.val);	 // 逆序打印
}

public static void main(String[] args) {
    ListNode listNode1 = new ListNode(1);
    ListNode listNode2 = new ListNode(2);
    ListNode listNode3 = new ListNode(3);
    listNode1.next = listNode2;
    listNode2.next = listNode3;
    ListNode.printListReverse(listNode1); // 输出 123321
}

递的过程:就是每次方法都调用 printListReverse,那么它会一直输出 1 2 3 直接走到 return[终止条件] ,就开始归的过程了,会从后向前走。

如下图的伪代码,调用方法传入1【打印1】,1去递归调用2【打印2】,2去递归调用3【打印3】,3则是递归的出口,接下来开始归的过程,此时执行3的最后一行的print【打印3】,再执行2的【打印2】,最后执行1的【打印1】,就完成了正序输出和逆序输出。

在这里插入图片描述

也就是说,println("after:" + node.value) 这行代码,就是触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。

现在再来回到一开始对递归的解释:方法自己调用自己,每一次调用都会更加接近递归的出口,用于解决可以分解为相似子问题的方法。

printListReverse 每一次递归调用,都会更加靠近 return,每个节点都需要打印则为相似的问题(这个例子中没有拆分子问题)

递归例子2:求n数之和

🌰再举个有拆分子问题的例子:求 1+2+3+…n 的和:

/* 递归 */
int recur(int n) {
    // 终止条件
    if (n == 1)
        return 1;
    // 递:递归调用
    int res = recur(n - 1);
    // 归:返回结果
    return n + res;
}

recur(3); // 输出 6  	-> 3+2+1
recur(4); // 输出 10  -> 4+3+2+1

这个例子中,就将大问题分解了为相似子问题,比如说我要求 recur(4) 是不是先得求 4+(3+2+1)、3+(2+1)、2+(1),递归流程如下图所示。

在这里插入图片描述

所以就将每一次子问题归的结果进行相加,得到了大问题的结果。

回溯

概念

它是一种通过尝试所有可能的选项,并在发现某个选项不可行时撤销上一步重新选择的方法。

那么哪里可以产生回溯呢?就是归的过程,递归函数之后。

与递归不同的是,回退并不仅仅包括函数返回,还包括了其它的的操作,还有点类似于穷举,但是和穷举不同的是回溯会“剪枝”。

回溯都有一个通用的操作,就是尝试,撤销,剪枝,下面是解决回溯问题的框架:

/* 回溯算法框架 */
void backtrack(State state, List<Choice> choices, List<State> res) { // 确定参数,这个步骤在最后
    // 判断是否为解
    if (isSolution(state)) {
        // 记录解
        recordSolution(state, res);
        // 不再继续搜索
        return;
    }
    // 遍历所有选择
    for (Choice choice : choices) {
        // 剪枝:判断选择是否合法
        if (isValid(state, choice)) {
            // 尝试:做出选择,更新状态
            makeChoice(state, choice);
            backtrack(state, choices, res);
            // 回退:撤销选择,恢复到之前的状态
            undoChoice(state, choice);
        }
    }
}

回溯例子1:组合问题

77. 组合 - 力扣(LeetCode)

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。可以按 任何顺序 返回答案。

示例:

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

思路

从 1 开始逐个数字尝试,看是否能作为组合的第一个数。然后,在剩余的数字中递归地寻找剩余的 k-1 个数的组合。重复这个过程,直到找到所有组合。

参考上面的回溯框架,代码如下:

class Solution {

    // 用于收集路径
    List<Integer> path = new ArrayList<>();
    // 用于收集结果集
    List<List<Integer>> result = new ArrayList<>();

    public List<List<Integer>> combine(int n, int k) {
        backtrack(n, k, 1);
        return result;
    }

    public void backtrack(int n, int k, int startIndex){
        if(k == path.size()) {
            // 收集结果
            result.add(new ArrayList(path));
            return;
        }
        // 让 startIndex 成为起始节点,进行去重
        for(int i = startIndex; i <= n; i++) {
            path.add(i);				  // 处理节点
            backtrack(n, k, i + 1);
            path.remove(path.size() - 1); // 撤销本次处理的结果。
        }
    }

}

解释

回溯法解决的问题都可以抽象为 N 叉树,可以使用树形结构来理解回溯就容易多了。

如下图

  • 每一个方框都是一次 for 循环,红色箭头代表 startIndex,代表下一次 for 循环的起始位置
  • 一开始集合是 [1,2,3,4] 到 [2,3,4] 到 [3, 4] 到 [4] 最后到 [],从左向右取数,取过的数,不再重复取,每一层都是同样的道理。
  • 图中每次搜索到了叶子节点,就找到了一个结果。

在这里插入图片描述

从某个分支理解回溯,如图中的path.remove和 path 的变化过程,将之前添加过的元素移除,让下一个元素加入进来,符合条件则添加到结果集中。

在这里插入图片描述

从伪代码理解回溯

void backtrack(n = 4, k = 2, startIndex = 1) {
    // 第一次循环
    for (int i = 1; i <= n; i++) {
        path.add(1);	// 添加元素
        backtracking(n = 4, k = 2, startIndex = i+1 = 2) {
            for (int i = 2; i <= n; i++) {
                path.add(2);
				// 【收集元素(1,2)】
                path.remove(2);
            }
            for (int i = 3; i <= n; i++) {
                path.add(3);
				// 【收集元素(1,3)】
                path.remove(3);
            }
            for (int i = 4; i <= n; i++) {
                path.add(4);
				// 【收集元素(1,4)】
                path.remove(4);
            }
        }
        path.remove(1); // 回溯
    }
    // 第二次循环
    for (int i = 2; i <= n; i++) {
        path.add(2);
        backtracking(n = 4, k = 2, startIndex = i+1 = 3) {
            for (int i = 3; i <= n; i++) {
                path.add(3);
				// 【收集元素(2,3)】
                path.remove(3);
            }
            for (int i = 4; i <= n; i++) {
                path.add(4);
				// 【收集元素(2,4)】
                path.remove(4);
            }
        }
        path.remove(2);
    }
    // 第三次循环
    for (int i = 3; i <= n; i++) {
        path.add(3);
        backtracking(n = 4, k = 2, startIndex = i+1 = 4) {
            for (int i = 4; i <= n; i++) {
                path.add(4);
				// 【收集元素(3,4)】
                path.remove(4);
            }
        }
        path.remove(3);
    }
}

DFS

概念

DFS(深度优先搜索)是一种用于遍历或搜索树或图的算法,它沿着每个分支尽可能深地搜索,直到达到目标或叶子节点,然后回溯并继续搜索其他未访问的分支。

DFS例子1:不同路径

62. 不同路径 - 力扣(LeetCode)

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。

问总共有多少条不同的路径?

示例 1:
在这里插入图片描述

输入:m = 3, n = 2
输出:3

解释:

从左上角开始,总共有 3 条路径可以到达右下角。

在这里插入图片描述

  1. 向右 -> 向下 -> 向下
  2. 向下 -> 向下 -> 向右
  3. 向下 -> 向右 -> 向下

思路:

因为只有两种走法,向右走和向下走,所以可以使用 DFS 让它一直向下走,走不通之后向右走,如果当前位置是右下角,那么路径数为 1。

  1. 定义状态:使用DFS时,我们需要记录当前的位置(行和列)。
  2. 递归条件:
    • 如果当前位置是右下角(即目标位置),那么路径数为 1。
    • 否则,路径数为从当前位置向右走一步的路径数和向下走一步的路径数之和。
  3. 边界条件:
    • 如果当前位置超出了网格范围,那么路径数为 0。

代码:

public int dfs(int i, int j, int m, int n) {
    if (i > m || j > n) return 0;
    if (i == m && j == n) return 1;
    int down = dfs(i + 1, j, m, n);
    int right = dfs(i, j + 1, m, n);
    return down + right;
}

怎么代码很简短吧,一开始看确实有难度,将上面的代码转换成伪代码结合着图看,就简单多了。

注意点:

  • return 0 代表向右走或向左走越界此路不通所以结果为 0
  • return 1 代表走到了右下角。
  • 递归计算向右走一步和向下走一步的路径数,并将它们相加。

在这里插入图片描述

dfs(int i = 1, int j = 1) { // 向下走
    int down = dfs(int i = 2, int j = 1) { // 向下走
        int down = dfs(int i = 3, int j = 1) { // 向下走
            int down = dfs(int i = 4, int j = 1) { // 向下走
                return 0; // 走不同了返回零
            };
            int right = dfs(int i = 3, int j = 2) { // 向右走
                return 1; // 走到了右下角返回1 -> 也就是情况1【向下 -> 向下 -> 向右】
            };
            return 0+1; // 将这次的结果收集
        };
        int right = dfs(int i = 2, int j = 2) { // 向右走
            int down = dfs(int i = 3, int j = 2) { // 向下走
                return 1; // 走到了右下角返回1 -> 也就是情况3【向下 -> 向右 -> 向下】
            };
            int right = dfs(int i = 3, int j = 3) {
                return 0;
            };
            return 1+0;
        };
        return 1+1;
    };
    int right = dfs(int i = 1, int j = 2) { // 向右走
        int down = dfs(int i = 2, int j = 2) { // 向下走
            int down = dfs(int i = 3, int j = 2) { // 向下走
                return 1; // 走到了右下角返回1 -> 也就是情况2【向右 -> 向下 -> 向下】
            };
            int right = dfs(int i = 2, int j = 3) {
                return 0;
            };
            return 1+0;
        };
        int right = dfs(int i = 2, int j = 3) {
            return 0;
        };
        return 1+0;
    };
    return 2+1;
}

DFS例子2:岛屿数量

200. 岛屿数量 - 力扣(LeetCode)

给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。

此外,你可以假设该网格的四条边均被水包围。

示例 1:

在这里插入图片描述

输入:grid = [
  ["1","1","0","0","0"],
  ["1","1","0","0","0"],
  ["0","0","1","0","0"],
  ["0","0","0","1","1"]
]
输出:3

思路:

遍历二维数组,如果有一个位置为“1”,就使用 DFS 上下左右找,把它相邻的所有一个标识为“0”,相当于把整个岛屿给“沉”了,当 DFS 结束时,岛屿数量++;

把岛屿沉了,是因为避免了之后重复搜索相同岛屿。

在 DFS 过程中,最重要的是不能重复访问之前访问过的格子

在这里插入图片描述

知道把所有元素遍历完,此过程如下:

在这里插入图片描述

代码

class Solution {
    public int numIslands(char[][] grid) {
        int count = 0;
        for(int i = 0; i < grid.length; i++) {
            for(int j = 0; j < grid[0].length; j++) {
                if(grid[i][j] == '1'){	// 找到岛或者岛屿中的某一部分
                    dfs(grid, i, j); 	// 将这个岛整个给“沉”了
                    count++;			// 统计结果
                }
            }
        }
        return count;
    }
    private void dfs(char[][] grid, int i, int j){
        // 判断是否越界 && 位置元素“1”
        if(i < 0 || j < 0 || i >= grid.length || j >= grid[0].length || grid[i][j] == '0') return;
        // 找到了就将这位置给“沉”了
        grid[i][j] = '0';
        dfs(grid, i + 1, j); // 向上找
        dfs(grid, i, j + 1); // 向右找
        dfs(grid, i - 1, j); // 向下找
        dfs(grid, i, j - 1); // 向左找
    }
}

总结

  • 递归:方法自己调用自己,每一次调用都会更加接近递归的终止条件,用于解决可以分解为相似子问题的问题。
  • 回溯:通过尝试所有可能的选项,并在发现某个选项不可行时撤销上一步重新选择的方法。
  • DFS:用于遍历或搜索树或图的算法,它沿着每个分支尽可能深地搜索,直到达到目标或叶子节点,然后回溯并继续搜索其他未访问的分支。

算法书籍推荐:https://www.hello-algo.com/
刷题路线推荐:https://www.programmercarl.com/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

tiantian17)

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值