递归不是深度优先遍历(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; // 栈空了,走完了
}
整个循环就是三件套,别的没有了:
- 一个
stack,先把起点塞进去; -
pop()取出栈顶,访问它; - 把它的孩子压回栈里。
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 对比,答案自己会跳出来。