如何用 while 循环实现深度优先遍历?

递归不是深度优先遍历(DFS)唯一的写法。把「函数调用栈」换成「一个数组」,就能用 while 循环走完同一棵树。

一、先搞清楚:DFS 到底在做什么

「深度优先」这四个字,说的是同一件事:一路往深处走,走不动了才回头

打个比方。你进一栋楼找房间,每到一个岔路口,先挑一条走廊走到底;走不通了,再回到最近的那个岔路口,换另一条。所谓「深度优先」,就是先把眼前这条走廊走穿,而不是一层层横着扫。

这个「回头」的动作,需要一个东西帮你记住:哪些岔路我还没走过。 递归和 while 循环,区别只在于谁来记。

二、递归的本质:调用栈在自动记账

先看最熟悉的递归写法:

function dfs(node) {
  if (!node) return;
  console.log(node.value);   // 访问自己
  dfs(node.left);            // 走左
  dfs(node.right);           // 走右
}

这段代码里,其实藏着一个你没写出来的东西——函数调用栈

当程序执行 dfs(node.left) 时,node.right 这半边的活儿还没干,但程序必须暂时放下它、钻进左边。谁记着「右边还没走」?是调用栈。每深入一层,栈里就多压一帧;每返回一层,就弹出一帧。递归之所以简洁,是因为记账这活儿被语言替你干了

但它有个代价:调用栈占内存是有限的。树特别深的时候(比如一条上万层的链),递归会「栈溢出」。

三、while 循环版:自己拿数组当栈

既然递归的本质是「调用栈在记账」,那把它换成你自己用数组维护的一个栈,不就绕开调用栈的限制了吗?

代码这样写(树的节点是 { value, left, right }):

function dfsPreorder(root) {
  if (!root) return [];              // 空树直接返回
  const stack = [root];              // 栈,先把根节点塞进去
  const result = [];

  while (stack.length > 0) {         // 只要栈里还有待办,就一直干
    const node = stack.pop();        // ① 从栈顶取出一件(后进先出)
    result.push(node.value);         // ② 访问它
    if (node.right) stack.push(node.right);  // ③ 先压右孩子
    if (node.left)  stack.push(node.left);   // ④ 再压左孩子
  }
  return result;                     // 栈空了,走完了
}

整个循环就是三件套,别的没有了:

  1. 一个 stack,先把起点塞进去;
  2. pop() 取出栈顶,访问它;
  3. 把它的孩子压回栈里。

while (stack.length > 0) 这句话,翻译成人话就是:只要待办清单上还有事,就一直干。 栈空了,遍历结束。

四、关键一步:先压右、再压左

上面最反直觉的一行,是「先 push(right)、再 push(left)」。

为什么要反过来?因为栈是后进先出——后压进去的,会先被 pop 出来。先压右孩子、再压左孩子,左孩子就躺在栈顶,下一轮先被取出,恰好对上「先左后右」的顺序。

你要是把这两行顺序写反了,遍历顺序也会左右对调。这就是 while 循环版比递归「麻烦」的地方:递归的先后是写在代码阅读顺序里的,而循环版的先后是写在对栈的推演里的。

五、看栈怎么一步步变

固定一棵树,按前序(先自己、再左、再右)走一遍。红字标的是访问顺序:

顺序是 A → B → D → E → C → F → G。下面这张图,把每一轮循环里栈的变化和访问序列都列了出来:

对照代码看这八步,最值得盯的是第 1 步和第 5 步:每弹出一个节点,就把它的右、左两个孩子按顺序压进去,栈顶于是正好是下一个该访问的节点。

六、树换成图:补一个 visited

树的边都朝下,天然不会绕回已经走过的节点。图不一样——一个节点可能被好几条路同时指向。如果不做标记,同一个节点会被反复塞进栈,循环停不下来。

所以图只要多补一件东西:一个 visited 集合,记录已经走过的节点。

function dfsGraph(graph, start) {
  const stack = [start];           // 栈
  const visited = new Set();       // 关键:记录已经走过的节点

  while (stack.length > 0) {
    const node = stack.pop();
    if (visited.has(node)) continue;  // 早就走过就跳过,防止重复/死循环
    visited.add(node);
    console.log(node);                // 访问

    for (const neighbor of graph[node]) {
      if (!visited.has(neighbor)) {   // 可选优化:已访问的就不压了
        stack.push(neighbor);
      }
    }
  }
  return visited;
}

七、小结与动手验证

一句话收拢:while 循环版的 DFS,就是把递归自动记账的「函数调用栈」,换成你手动记账的「数组栈」。

真想把这事变成自己的,光看不顶用,得自己重写一遍。下面两个问题,写代码之前先想一想:

  • 把树的代码里「先压右、再压左」改成「先压左、再压右」,遍历顺序会变成什么?
  • stack 换成「先进先出」的队列,只改哪几个方法,就变成了广度优先(BFS)?

想清楚之后,把这两段代码跑一遍、打印 result 对比,答案自己会跳出来。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容