【问题标题】:Intuitive explanation of binary tree traversals without recursion无递归二叉树遍历的直观解释
【发布时间】:2016-08-02 15:20:54
【问题描述】:

我看过许多文章和书籍(以及 Stack Overflow 的答案),它们展示了如何使用显式堆栈而不是递归迭代地执行前序、中序和后序深度优先树遍历。 例如:https://en.wikipedia.org/wiki/Tree_traversal#Depth-first_search_2

前序遍历很简单,但我认为其他的很复杂,而且远非显而易见。

是否有任何资源(最好是文章或书籍)可以直观地解释这些算法,以便您可以看到有人最初是如何想出这些算法的?

【问题讨论】:

  • 从技术上讲,使用堆栈是递归的,只是没有明确地使用。它是隐式递归,因为类似于一遍又一遍地调用方法,使用堆栈。只是一种方式使用调用堆栈,另一种使用节点堆栈。如果没有某种递归,你就无法真正遍历一棵树。
  • @DaneBrick,技术上,这是不正确的。当一个函数根据自身定义时,它就是递归。
  • @MattTimmermans 我理解,但是如果 OP 无法理解堆栈的树遍历但可以理解递归的遍历,那么我想指出这两种遍历方法实际上非常相似。因为栈是递归使用的。
  • 我想指出一件重要的事情。通过递归,堆栈中的每个元素都将具有函数的所有数据,例如参数、局部变量等,但是使用显式堆栈,您可能可以控制要放入堆栈的内容。使用显式堆栈并最小化堆栈上的对象大小会更有效。

标签: algorithm recursion tree language-agnostic


【解决方案1】:

如何提出没有堆栈的迭代解决方案

实现迭代树遍历不需要堆栈!您可以通过在树节点数据结构中保留父指针来摆脱任何堆栈。这就是你想出的方法:

什么是迭代解决方案?迭代解决方案是在循环中重复执行代码的固定部分的解决方案(几乎是迭代的确定性)。循环的输入是系统的状态 s1,输出是状态 s2,循环将系统从状态 s1 带到状态 s2。您从初始状态 s 开始,并在达到最终所需状态 s 时结束。

所以我们的问题归结为寻找:

  • 有助于我们实现这一目标的系统状态特征。初始状态将与我们的初始条件一致,最终状态将与我们想要的结果一致
  • 查找作为循环的一部分重复执行的指令

(这实际上将树变成了状态机。)

在树遍历中,每一步都会访问一个节点。树的每个节点最多被访问三次——一次来自父节点,一次来自左子节点,一次来自右子节点。我们在特定步骤对节点做什么取决于它是三种情况中的哪一种。

因此,如果我们捕获所有这些信息:我们正在访问的是哪个节点,以及它是哪种情况,我们就有了系统的特征。

捕获此信息的一种方法是存储对先前节点/状态的引用:

Node current;
Node previous;

如果previous = current.parent,那么我们是从父访问。如果previous = current.leftChild,我们从左边访问,如果previous = current.rightChiild,我们从右边访问。

我们可以获取此信息的另一种方式:

Node current; 
boolean visitedLeft;
boolean visitedRight;

如果visitedLeft和visitedRight都为false,那么我们是从父节点访问,如果visitedLeft是true但是visitedRight是false,我们是从左边访问,如果visitedLeft和visitedRight都为true,我们是从右边访问(第四种状态:visitedLeft false 但visitedRight false,preOrder 中永远不会到达)。

最初,我们从 viisitedLeft = false、visitedRight = false 和 current = root 开始。当遍历完成时,我们期望visitedLeft = true,visitedRight = true,并且current = null。

在作为循环的一部分重复运行的指令中,系统必须从一种状态转移到另一种状态。所以在指令中,我们只是告诉系统当它遇到任何状态时该做什么,以及何时结束执行。

您可以将所有三个遍历组合到一个函数中:

void traversal(String typeOfTraversal){

    boolean visitedLeft = false;
    boolean visitedRight = false;
    TreeNode currentNode = this.root;

    while(true){

        if (visitedLeft == false && currentNode.leftChild != null){
            if(typeOfTraversal == "preOrder"){
                System.out.println(currentNode.key);
            }
            currentNode = currentNode.leftChild;
            continue;
        }

        if (visitedLeft == false && currentNode.leftChild == null){
            if(typeOfTraversal == "preOrder"){
                System.out.println(currentNode.key);
            }
            visitedLeft = true;
            continue;
        }

        if (visitedLeft == true && visitedRight == false && currentNode.rightChild != null){
            if(typeOfTraversal == "inOrder"){
                System.out.println(currentNode.key);
            }
            currentNode = currentNode.rightChild;
            visitedLeft = false;
            continue;
        }

        if (visitedLeft == true && visitedRight == false && currentNode.rightChild == null){
            if(typeOfTraversal == "inOrder"){
                System.out.println(currentNode.key);
            }
            visitedRight = true;
            continue;
        }

        if (visitedLeft == true && visitedRight == true && currentNode.parent != null){
            if(typeOfTraversal == "postOrder"){
                System.out.println(currentNode.key);
            }

            if (currentNode == currentNode.parent.leftChild){
                visitedRight = false;
            }
            currentNode = currentNode.parent;
        }

        if (visitedLeft == true && visitedRight == true && currentNode.parent == null){       
            if(typeOfTraversal == "postOrder"){
                System.out.println(currentNode.key);
            }
            break; //Traversal is complete.
        }

如果给你节点级锁,这个算法允许并发遍历和更新树。除了分离非叶节点之外的任何原子操作都是安全的。


如何提出基于堆栈的解决方案

在考虑将递归解决方案转换为迭代解决方案或为递归定义的问题提出迭代解决方案时,堆栈是有用的数据结构。调用堆栈是一种堆栈数据结构,用于存储有关计算机程序的活动子例程的信息,是大多数高级编程语言实现底层递归的方式。因此,在迭代解决方案中显式使用堆栈,我们只是在模仿处理器在编写递归代码时所做的事情。 Matt Timmermans 的回答很好地说明了为什么要使用堆栈以及如何提出基于堆栈的显式解决方案。

我已经在此处写过如何提出带有两个堆栈的 postOrder 解决方案:Understanding the logic in iterative Postorder traversal implementation on a Binary tree


基于父指针的方法比基于堆栈的方法消耗更多的内存。在堆栈上,指向仍要处理的节点的指针是瞬态的,并且只需要 O(log n) 堆栈空间的顺序,因为您只需要为沿树的单个路径保留足够多的指针(实际上是,这可能会更少)。相比之下,将父指针与节点一起存储需要固定的 O(n) 空间。

【讨论】:

    【解决方案2】:
    • Preorder:通过访问节点来处理节点,然后处理每个子节点。

    • 顺序:一个节点的处理是先处理左孩子,再访问该节点,再处理右孩子。

    • PostOrder (DFS):通过处理每个子节点,然后访问该节点来处理一个节点。

    在所有情况下,堆栈都用于存储您无法立即完成的工作。预购情况是最简单的,因为只有一种工作需要推迟——处理子节点。

    预排序:堆栈保存要处理的节点。要处理一个节点,请访问它,将右孩子压入堆栈,然后处理左孩子。如果没有左孩子,则从堆栈中抓取一个。

    顺序也很简单。堆栈必须存储要访问的节点要处理的节点,但是要处理的节点始终是刚刚访问的节点的右子节点,所以:

    中序:堆栈保存要访问的节点。当我们从堆栈中取出一个节点时,我们访问它,然后处理它的右孩子。当我们处理一个节点时,我们将它放入堆栈,然后处理它的左孩子。

    Postorder 比较棘手,因为堆栈必须存储节点以访问 节点以进行处理,并且它们并不总是像在 Inorder 情况下那样简单相关。堆栈必须以某种方式指示哪个是哪个。

    你可以这样做:

    后序:堆栈包含要访问或处理的节点,以及已处理的子节点数。要处理堆栈中的条目(n,x),请访问节点n(如果它有(n,x+1) 放入堆栈并处理该节点的第一个未处理的子节点。

    【讨论】:

      【解决方案3】:

      这个视频帮助我理解了迭代,希望对你有帮助

      https://www.youtube.com/watch?v=lxTGsVXjwvM&t=541s

      我在视频的帮助下编写的代码:

      公共 ArrayList dfsInOrderIterative(){

          ArrayList<Integer> list = new ArrayList<Integer>();
          Stack<Node> stack = new Stack<Node>();
          Node temp = root;
          
          while(true) {   
              
              if(temp != null) {
                  
                  stack.add(temp);
                  temp = temp.getLeftChild();
              }
              
              else {
                  
                  if(stack.isEmpty()) {
                      break;
                  }
                  
                  Node n = stack.pop();
                  list.add(n.getData());
                  temp = n.getRightChild();
              }
          }
          
          return list;
      }
      

      【讨论】:

      • 您的答案可以通过额外的支持信息得到改进。请edit 添加更多详细信息,例如引用或文档,以便其他人可以确认您的答案是正确的。你可以找到更多关于如何写好答案的信息in the help center
      猜你喜欢
      • 2021-05-04
      • 1970-01-01
      • 2019-08-23
      • 1970-01-01
      • 2010-11-20
      • 1970-01-01
      • 1970-01-01
      • 2023-03-14
      相关资源
      最近更新 更多