【问题标题】:How to properly backtrack in Depth First Search Algorithm?如何在深度优先搜索算法中正确回溯?
【发布时间】:2023-12-18 01:48:01
【问题描述】:

我正在尝试解决一个问题:在二叉树中查找特定节点的所有祖先。

Input: root, targetNode
Output: An array/list containing the ancestors

假设,我们以上面的二叉树为例。我们想找到节点 4 的祖先。输出应该是 [3, 5, 2, 4]。如果节点为8,则输出为[3, 1, 8]

为了解决这个问题,我编写了一个实现 DFS 的函数。

var ancestor = function(root, target) {
    var isFound = false;
    const dfs = (node, curr) => {
        if (node === null) {
            return curr;
        }
        
        if (node.val === target.val) {
            curr.push(node.val);
            isFound = true;
            return curr;
        }
        
        curr.push(node.val);
        const left = dfs(node.left, curr);
        if (!isFound) {
            const right = dfs(node.right, curr);
            curr.pop();
            return right;
        } else {
            curr.pop();
            return left;
        }
        
    }
    
    console.log(dfs(root, []));
};

但它没有返回正确的输出。例如,如果targetNode为7,则输出为[3],如果targetNode为8,则输出也是[3]。如果我删除curr.pop() 行,输出也无效。对于 targetNode 7,它是 [3, 5, 6, 2, 7]。我想我发现了我犯错的问题。在回溯时,我在删除curr 数组中推送的节点时做错了。如果我传递一个字符串而不是数组,它会正确打印输出。

var ancestor = function(root, target) {
    var isFound = false;
    const dfs = (node, curr) => {
        if (node === null) {
            return curr;
        }
        
        if (node.val === target.val) {
            curr += node.val;
            isFound = true;
            return curr;
        }
        
        const left = dfs(node.left, curr + node.val + '->);
        if (!isFound) {
            const right = dfs(node.right, curr + node.val + '->);
            return right;
        } else {
            return left;
        }
        
    }
    
    console.log(dfs(root, ''));

上面的代码用字符串而不是数组正确打印输出,如果我通过targetNode 7,输出是3->5->2->7 我的问题是,如何在这里正确取消选择/回溯?还是我做错了什么?提前致谢。

【问题讨论】:

    标签: recursion binary-tree depth-first-search backtracking


    【解决方案1】:

    自然环境中的递归

    递归是一种函数式遗产,因此将其与函数式风格一起使用会产生最佳效果。这意味着要避免诸如pushcur += node.val 之类的突变、isFound = true 之类的变量重新分配以及其他副作用之类的命令性事情。我们可以将ancestor 写成一个简单的基于生成器的函数,它将每个节点添加到递归子问题的输出之前 -

    const empty =
      Symbol("tree.empty")
    
    function node(val, left = empty, right = empty) {
      return { val, left, right }
    }
    
    function* ancestor(t, q) {
      if (t == empty) return
      if (t.val == q) yield [t.val]
      for (const l of ancestor(t.left, q)) yield [t.val, ...l]
      for (const r of ancestor(t.right, q)) yield [t.val, ...r]
    }
    
    const mytree =
      node(3, node(5, node(6), node(2, node(7), node(4))), node(1, node(0), node(8)))
      
    for (const path of ancestor(mytree, 7))
      console.log(path.join("->"))
    3->5->2->7
    

    使用模块

    最后,我会为这段代码推荐一种基于模块的方法 -

    // tree.js
    
    const empty =
      Symbol("tree.empty")
    
    function node(val, left = empty, right = empty) {
      return { val, left, right }
    }
    
    function* ancestor(t, q) {
      if (t == empty) return
      if (t.val == q) yield [t.val]
      for (const l of ancestor(t.left, q)) yield [t.val, ...l]
      for (const r of ancestor(t.right, q)) yield [t.val, ...r]
    }
    
    function insert(t, val) {
      // ...
    }
    
    function remove(t, val) {
      // ...
    }
    
    function fromArray(a) {
      // ...
    }
    
    // other tree functions...
    
    export { empty, node, ancestor, insert, remove, fromArray }
    
    // main.js
    
    import { node, ancestor } from "./tree.js"
    
    const mytree =
      node(3, node(5, node(6), node(2, node(7), node(4))), node(1, node(0), node(8)))
      
    for (const path of ancestor(mytree, 7))
      console.log(path.join("->"))
    
    3->5->2->7
    

    私有生成器

    在前面的实现中,我们的模块为ancestor 的公共接口公开了一个生成器。另一种选择是在找不到节点且没有祖先时返回undefined。考虑这个隐藏生成器并要求调用者对结果进行空检查的替代实现 -

    const empty =
      Symbol("tree.empty")
    
    function node(val, left = empty, right = empty) {
      return { val, left, right }
    }
    
    function ancestor(t, q) {
      function* dfs(t) {
        if (t == empty) return
        if (t.val == q) yield [t.val]
        for (const l of dfs(t.left)) yield [t.val, ...l]
        for (const r of dfs(t.right)) yield [t.val, ...r]
      }
      return Array.from(dfs(t))[0]
    }
    
    const mytree =
      node(3, node(5, node(6), node(2, node(7), node(4))), node(1, node(0), node(8)))
      
    const result =
      ancestor(mytree, 7)
    
    if (result)
      console.log(result.join("->"))
    else
      console.log("no result")
    3->5->2->7
    

    【讨论】:

    • 非常感谢您对递归副作用的回答和洞察。我不熟悉 JavaScript 中的生成器概念,我现在一定会研究一下。
    • 我会尽量避免递归中的变异,但是你能告诉我我写的代码哪里做错了吗?
    【解决方案2】:

    需要检查右孩子的DFS是否找到了节点。 修复:

            const left = dfs(node.left, curr);
            if (!isFound) {
                const right = dfs(node.right, curr);
                if(isFound) {
                    return right;
                }
                curr.pop();
                return; // return nothing, backtracking
            }
            return left;
    

    【讨论】:

    • 感谢您的回复。我已经尝试过您的解决方案,它仍然给我错误的输出。对于目标节点 7,输出为 [3,5,6,2,7]
    • @h_a 那是因为我忘记包含 pop()-s,修复它。
    • 成功了。谢谢你。 :-) 所以如果我总结一下我做错了什么,即使我找到了解决方案,我还是从curr 数组中弹出了该项目。那是我的问题,对吧?如果它不能引导我找到正确的解决方案,我应该只 pop() 项目吗?
    • @h_a 是的,您不需要在 DFS 中返回任何特定值,因此修复代码的一种方法是,就像您说的那样,仅弹出当前的如果它没有得到到节点
    【解决方案3】:

    在数组示例中,您的循环以 DFS 方式遍历节点,因此每个节点都以这种方式连接。如果我们计算 DFS 算法中的树节点,[3 , 5, 6, 2, 7] 实际上是按 1, 2, 3, 4 和 5 的顺序排列的。这样,您在数组中的整个树应该是这样的; [3、5、6、2、7、4、1、0、8]。

    所以当你找到目标值时,你会从当前节点弹出,并在 DFS 算法中将其全部追溯到头节点。

    我要么建议找到一种方法来解决这个问题,要么你可以保存每个节点的父节点。这意味着您可以使用元组而不是 int 数组(如果可以接受的话)。索引可能如下所示 = (node value, parent value)

    [(3,NULL),(5,3),(6,5),(2,5)...]

    然后相应地回溯……

    【讨论】:

    • 谢谢。我会这样想。