迭代法(迭代+栈)模拟递归实现深度优先搜索(DFS):以力扣原题[112. 路径总和]及[113. 路径总和 II]为例

我们以两道力扣原题作为例子来讲解如何用迭代法来实现深度优先搜索:

112. 路径总和
113. 路径总和 II

对于[112. 路径总和],我们可以很容易写出以下递归版的深度优先搜索代码:

class Solution {
    public boolean hasPathSum(TreeNode root, int targetSum) {
        if (root == null) { // 1.边界条件
            return false;
        }
        if (targetSum - root.val == 0 && root.left == null && root.right == null) { // 3.边界条件及条件判断,符合条件的叶节点
            return true;
        }

		// 2.状态转移
		// 只要子递归有一个返回 true,由于 || 的运算逻辑,将直接返回 true (短路运算特性,如果是前面的返回 true,后面的递归将不会调用)
        return hasPathSum(root.left, targetSum - root.val) || hasPathSum(root.right, targetSum - root.val); // targetSum - root.val 隐藏着回溯信息:因为是传值而非传引用,子递归返回时 targetSum 在该层的值不变
    }
}

深度优先搜索有三大特点:

  1. 边界条件:到达搜索边界时就该停止继续搜索了
  2. 状态转移:深度优先搜索的任何一层递归(树的节点、图的节点)都可以视为一种状态,从一层递归到另一层递归可以认为是状态转移,求解过程其实就是在不断地状态转移中寻找符合要求的状态
  3. 条件判断:每转移到一个新的状态就要判断这是否是想要的答案。如果只求找到一个答案,找到一个答案后就可以通过返回值将这个答案不断 return 到调用者那儿去了;如果要求找所有符合要求的答案,找到一个答案就要记录一个答案,然后继续搜索直到穷尽所有状态为止。

在状态转移中有一个深度优先搜索中重之又重的概念:回溯。回溯指的是在从一个前状态转移到一个后状态的情景中,在从后状态返回前状态后,前状态应该和转移前一样,这里就涉及到从后状态返回前状态的状态恢复,我们称这个状态恢复为回溯。

对二叉树进行递归遍历其实就可以看做是一种特殊的深度优先搜索。对二叉树进行递归遍历:有边界条件:空节点;有状态转移:递归遍历左子树和右子树;唯一没有的就是条件判断,因为任何一个非空节点都是所求的答案。
注:对树进行深度优先搜索,条件判断一般位于对树进行前序遍历访问节点的位置(根左右的根处)

所以对二叉树的迭代法(迭代+栈)模拟递归实现深度优先搜索(DFS)实际是在迭代法模拟递归实现对二叉树的遍历的基础上实现的。需要重点改造的其实就是实现状态转移中非树节点元素的回溯(树节点的回溯在对二叉树的迭代遍历中已经实现了)。

在[112. 路径总和]中,非树节点元素需要回溯的只有 targetSum。
迭代法实现深度优先搜索求解[112. 路径总和]:

// 用栈模拟深度优先搜索 回溯思想 对应的递归版的回溯逻辑使用参数而非全局变量
class Solution {
    public boolean hasPathSum(TreeNode root, int targetSum) {
        Deque<Object> stack = new LinkedList<>();
        if (root != null) {
            stack.push(root);
            stack.push(root.val);
        }

        while (!stack.isEmpty()) {
            Integer sum = (Integer) stack.pop();
            TreeNode node = (TreeNode) stack.pop();

            if (sum == targetSum && node.left == null && node.right == null) { // 3.边界条件及条件判断,符合条件的叶节点
                return true;
            }


            // 2.状态转移
            // 想象 node 是有左右子孩子,且左右子孩子都是叶节点的节点
            // 右孩子先入栈,同时配对入栈从根节点到右孩子这条路径上所有节点值之和;左孩子后入栈,同时配对入栈从根节点到左孩子这条路径上所有节点值之和
            // 左孩子先出栈,处理的是从根节点到左孩子这条路径上所有节点值之和,因为是叶节点,没有新入栈的节点;
            // 然后紧接着右孩子出栈,处理的是从根节点到右孩子这条路径上所有节点值之和
            // 左孩子出栈到右孩子出栈就有一个回溯的过程,而这个回溯是在处理它们的父节点,将父节点的左右孩子入栈时就设定好的
            if (node.right != null) { // 1.边界条件,空节点不入栈
                stack.push(node.right); // 先入栈右节点,才会先出栈左节点
                stack.push(sum + node.right.val); // sum + root.right.val 隐藏着回溯逻辑
            }
            if (node.left != null) { // 1.边界条件,空节点不入栈
                stack.push(node.left);
                stack.push(sum + node.left.val); // sum + root.left.val 隐藏着回溯逻辑
            } 
        }

        return false;
    }
}

[112. 路径总和]属于找到一个答案后就通过返回值将这个答案不断 return 到调用者那儿的类型,而[113. 路径总和 II]属于要求找所有符合要求的答案,找到一个答案就要记录一个答案,然后继续搜索直到穷尽所有状态为止的类型。一般来说,前者更适合直接用函数返回值返回答案,后者更适合用成员变量(全局变量)记录所有答案,返回类型设为 void,因为后者需要搜索整颗树寻找所有答案。

递归法实现深度优先搜索求解[113. 路径总和 II]:

class Solution {
    private List<List<Integer>> roads = new LinkedList<>();
    private LinkedList<Integer> road = new LinkedList<>();

    // 原则上应该新写一个 DFS 函数,设定返回值为 void,在 pathSum 中调用 DFS,然后返回记录了答案集的 roads
    // 但这种类型其实也可以写成用返回值返回答案
    public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
        if (root == null) { // 1.边界条件
            return roads;
        }

        // 计算从根节点遍历到该节点的整数目标和(用减表示)和路径,属于前序遍历处理逻辑,对应深度优先搜索条件判断的位置
        targetSum -= root.val; // 2.状态转移
        road.add(root.val); // 2.状态转移

        if (targetSum == 0 && root.left == null && root.right == null) { // 3.边界条件及条件判断,符合条件的叶节点
            roads.add(new LinkedList(road));
        }

        // 2.状态转移
        pathSum(root.left, targetSum); // targetSum 隐藏着回溯逻辑:因为是传值而非传引用,子递归中对 targetSum 的改变不影响该层递归 targetSum 的值
        pathSum(root.right, targetSum); // targetSum 隐藏着回溯逻辑:因为是传值而非传引用,子递归中对 targetSum 的改变不影响该层递归 targetSum 的值
        road.removeLast(); // 对指向对象不变的引用类型(一般在 DFS 中不作为参数)做回溯操作

        return roads;
    }
}

在[113. 路径总和 II]中,需要回溯的非树节点元素 road 有一个特点,它是引用类型的。如果直接将 road 作为参数,因为是传引用,子递归对 road 的改变将直接影响上层递归中 road 的值(毕竟自始至终都是在对同一个对象做操作),如果要以用 road 做参数的方式实现回溯,需要在传递 road 时 new 一个新的对象,新对象即是子递归需要的新 road,将新对象作为参数传递给子递归函数,这样,每次递归调用时传入的 road 都不同,子递归对 road 的改变也就不会影响上层递归中 road 的值了。pathSum(root.left, targetSum, new LinkedList(road){{ add(root.val); }}); // 匿名内部类 + 初始化块:初始化集合

另外一种方式就是将 road 设为成员变量(全局变量),在递归后对 road 做回溯操作,即我在代码中使用的方式。

迭代法实现深度优先搜索求解[113. 路径总和 II]:

// 迭代实现深度优先搜索,对应的递归版的回溯逻辑使用全局变量而非参数
class Solution {
    public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
        List<List<Integer>> roads = new LinkedList<>();
        LinkedList<Integer> road = new LinkedList<>();

        Deque<Object> stack = new LinkedList<>();
        if (root != null) {
            stack.push(root);
        }

        while (!stack.isEmpty()) {
            Object object = stack.pop();
            if (object instanceof TreeNode) {  // 如果 object 是 TreeNode 类对象,接下来该遍历
                TreeNode node = (TreeNode) object;

                // 计算从根节点遍历到该节点的整数目标和(用减表示)和路径,属于前序遍历处理逻辑,对应深度优先搜索条件判断的位置
                targetSum -= node.val; // 2.状态转移
                road.add(node.val); // 2.状态转移

                if (targetSum == 0 && node.left == null && node.right == null) { // 从根节点到该节点的整数目标和是否符合要求 // 3.条件判断
                    roads.add(new LinkedList(road));
                }


                // 在后序遍历的根处标记回溯节点:在后序遍历中,后序遍历访问节点值后,接下来就是返回父节点了,不会再用到该节点信息了,所以该回溯了
                // 也可以使用 Deque 类型的 stack,stack.push(node) 后紧接着 stack.push(null) 来标记回溯节点
                stack.push(node.val); // 标记回溯节点并存储该节点值,用于回溯时减去该节点信息  根

                if (node.right != null) { // 空节点不加入栈  右 // 1.边界条件
                    stack.push(node.right); // 先入栈右节点,才会先出栈左节点 // 2.状态转移
                }

                if (node.left != null) { // 空节点不加入栈  左 // 1.边界条件
                    stack.push(node.left); // 2.状态转移
                }
            } else { // 如果 object 是 Integer 类对象,说明即将从某节点回退父节点,需要回溯,减去该节点信息
                int val = (Integer) object;
                targetSum += val;
                road.removeLast();
            }
        }

        return roads;
    }
}

递归版的回溯逻辑使用全局变量而非参数,转换为迭代版时,重点关注是在代码中的什么位置 stack.push(node.val) 来标记回溯节点的。出栈时遇到回溯节点即做回溯操作。

你可能感兴趣的:(Java,算法,leetcode,算法,深度优先搜索,java,路径总和)