练完双指针和滑动窗口之后,我开始接触树相关的题目。第一反应是:既然树也是一种数据结构,能不能把 Vol.1 的那套指针思路搬过来用?
试了一下,很快撞墙了。
这篇文章是我试图搞清楚"为什么不行"的过程。结论先说:数据结构的形状决定了遍历工具的选择——线性数据用指针,分支数据用队列或递归。 这不是规定,是形状本身的约束。
一、先试试:用双指针处理树会发生什么
假设有一棵二叉树,我想用双指针找到某个节点:
1
/ \
2 3
/ \
4 5
按照 Vol.1 的思路,left 从头,right 从尾,向中间逼近。
问题来了:树的"头"在哪?"尾"在哪?
根节点 1 算头,但"尾"是 4、5、还是 3?树有多个末端,没有唯一的"另一头"。
好,那换快慢指针。reader 往前扫,writer 维护写入位。
问题还是来了:reader 从根节点 1 走到 2 之后,下一步该走 4 还是 5?
在数组里,reader++ 只有一个方向。在树里,每个节点有 0 到 N 个子节点,"下一个"是不确定的。指针假设的是线性推进,但树的结构是分叉的,指针在第一个分叉处就失去了方向。
这就是撞墙的地方。
二、线性 vs 分支:下一个节点是"确定的1个"还是"不确定的0-N个"
这是两种数据结构最本质的区别:
数组 / 链表(线性):
每个节点的下一个节点是确定的,最多1个
[1] → [2] → [3] → [4] → null
二叉树(分支):
每个节点的下一个节点是不确定的,0到2个
[1]
/ \
[2] [3]
/ \
[4] [5]
图(分支的极端情况):
每个节点可以连接任意数量的其他节点,甚至有环
指针能工作,是因为"下一步去哪"在编写代码时就已经确定了——i++、left++、right--,方向是写死的。
树和图不行,因为在到达一个节点之前,你不知道它有几个子节点,也不知道该先走哪个。需要一种能够**记录"还没走过的分支"**的机制,等当前路径走完了再回来处理。
指针没有这个能力。队列和递归有。
三、分支数据的两种处理方式
方式一:队列(BFS,按层遍历)
队列的核心思想:先进先出,按层处理。
遇到一个节点,不立刻深入它的子节点,而是把子节点放进队列排队。处理完当前层的所有节点,再处理下一层。
// 环境:浏览器 / Node.js
// 场景:BFS 层序遍历二叉树
// 输入的树节点结构:{ val, left, right }
function bfs(root) {
if (!root) return [];
const queue = [root]; // 队列,初始放入根节点
const result = [];
while (queue.length > 0) {
const node = queue.shift(); // 取出队列最前面的节点
result.push(node.val);
// 把子节点加入队列——不立刻处理,排队等候
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
return result;
}
// 构造测试用的树:
// 1
// / \
// 2 3
// / \
// 4 5
const tree = {
val: 1,
left: {
val: 2,
left: { val: 4, left: null, right: null },
right: { val: 5, left: null, right: null },
},
right: { val: 3, left: null, right: null },
};
console.log(bfs(tree)); // [1, 2, 3, 4, 5]
执行过程:
初始:queue=[1]
处理 1:result=[1],把 2、3 加入队列
queue=[2, 3]
处理 2:result=[1,2],把 4、5 加入队列
queue=[3, 4, 5]
处理 3:result=[1,2,3],没有子节点
queue=[4, 5]
处理 4:result=[1,2,3,4]
处理 5:result=[1,2,3,4,5]
queue 空,结束
队列保证了节点按层被处理——它用"排队等候"解决了"分支不知道先走哪个"的问题。
方式二:递归 / 调用栈(DFS,按深度遍历)
递归的核心思想:先走到底,再回头。
遇到一个节点,立刻深入它的子节点,一路走到叶子节点,然后原路返回,处理下一个分支。
// 环境:浏览器 / Node.js
// 场景:DFS 前序遍历二叉树(根 → 左 → 右)
function dfs(root) {
if (!root) return [];
const result = [];
function traverse(node) {
if (!node) return;
result.push(node.val); // 先处理当前节点
traverse(node.left); // 再深入左子树
traverse(node.right); // 最后深入右子树
}
traverse(root);
return result;
}
console.log(dfs(tree)); // [1, 2, 4, 5, 3]
递归之所以能处理分支,是因为它把"还没走过的分支"隐式地存在了调用栈里——每次递归调用都是一个栈帧,保存着当前节点的上下文,等深层递归返回后继续执行。
这就是为什么递归能自然处理树:调用栈就是它的"记忆",存着所有待处理的分支。
BFS vs DFS:怎么选
BFS(队列)适合:
- 找最短路径(层数最少的路径)
- 按层处理(每层做某件事)
- 判断两个节点之间的距离
DFS(递归)适合:
- 找是否存在某条路径
- 计算深度 / 高度
- 遍历所有可能的路径
- 树的序列化 / 反序列化
一个粗糙但实用的判断:关心"层"就用 BFS,关心"路径"就用 DFS。
四、前端里的真实场景
树和图不只是算法题里的概念,前端日常工作里到处都是。
DOM 树遍历
浏览器里的 DOM 就是一棵树。document.querySelectorAll 的底层是 DFS——从根节点出发,深度优先地扫描每一个节点,找到匹配选择器的元素。
如果要实现"找到某个节点的所有直接子元素",需要 BFS:
// 环境:浏览器
// 场景:BFS 遍历 DOM 树,按层收集节点
function domBFS(root) {
const queue = [root];
const result = [];
while (queue.length > 0) {
const node = queue.shift();
result.push(node);
for (const child of node.children) {
queue.push(child);
}
}
return result;
}
// 使用:domBFS(document.body) 按层返回所有节点
JSON 深拷贝
JSON.parse(JSON.stringify(obj)) 能深拷贝,但处理循环引用会报错。手写深拷贝需要递归遍历对象的每一个属性——这就是 DFS,对象就是一棵树(属性是边,值是节点):
// 环境:浏览器 / Node.js
// 场景:手写深拷贝,DFS 遍历对象树
function deepClone(obj, seen = new WeakMap()) {
// 基本类型直接返回
if (obj === null || typeof obj !== 'object') return obj;
// 处理循环引用
if (seen.has(obj)) return seen.get(obj);
const clone = Array.isArray(obj) ? [] : {};
seen.set(obj, clone);
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key], seen); // 递归处理每个属性
}
}
return clone;
}
const original = { a: 1, b: { c: 2, d: [3, 4] } };
const cloned = deepClone(original);
cloned.b.c = 99;
console.log(original.b.c); // 2(未被影响)
console.log(cloned.b.c); // 99
React Fiber 树
React 的 Fiber 架构本质上是一棵链表树——每个 Fiber 节点有 child(第一个子节点)、sibling(下一个兄弟节点)、return(父节点)三个指针。
React 的协调过程(reconciliation)是 DFS:从根节点出发,深入子树,完成工作后原路返回。Fiber 架构相比传统递归的优势是可以在任意节点暂停、恢复——因为"还没处理的分支"不是存在调用栈里,而是存在 Fiber 节点自身的 sibling 和 return 指针里,可以随时中断。
这也是为什么 React 能实现时间切片(Time Slicing):普通递归一旦开始就无法暂停,但 Fiber 的迭代式 DFS 可以。
组件树权限判断
多级菜单、权限树、组织架构——这些都是树形数据。判断某个用户是否有权限访问某个页面,本质上是在权限树里做路径查找,用 DFS 或 BFS 都可以,取决于权限树的深度和结构。
五、决策图:收到题目时怎么判断用哪个
数据是什么形状?
│
├── 线性(数组、链表、字符串)
│ │
│ ├── 有序 + O(log n) → 二分法
│ ├── 连续子区间 + 最大最小 → 滑动窗口
│ ├── 原地修改 / 过滤 → 快慢指针(reader/writer)
│ └── 有序 + 找两个数 → 对撞指针
│
└── 分支(树、图、嵌套对象)
│
├── 关心"层"(最短路径、按层处理)→ BFS(队列)
│
└── 关心"路径"(深度、是否存在、所有路径)→ DFS(递归)
还有一个辅助信号:题目里出现"最短"两个字,几乎一定是 BFS。因为 BFS 按层扩散,第一次到达目标节点时经过的层数就是最短路径。DFS 可能绕远路先到,不保证最短。
小结
这篇文章没有新的例题,更像是从 Vol.1/2 往后退一步,看清楚指针能解决什么、不能解决什么。
对我帮助最大的认知是:工具的局限性来自它的假设。 指针假设"下一步是确定的",这个假设在线性结构里成立,在分支结构里不成立。换工具不是因为指针"不好",而是因为问题的形状变了。
树和图的题目还没开始系统练,BFS 和 DFS 目前只是概念层面的理解。等真正做完 LC 102(层序遍历)和 LC 104(最大深度)之后,会回来把这篇的感受更新一遍——理论理解和动手之后的理解,通常不是同一件事。
参考资料
- MDN - TreeWalker — DOM 树遍历的原生 API
- React Fiber Architecture — Fiber 架构设计文档
- LeetCode 102 - Binary Tree Level Order Traversal
- LeetCode 104 - Maximum Depth of Binary Tree