算法学习笔记 Vol.3:为什么处理树和图的时候不能用指针?

0 阅读7分钟

练完双指针和滑动窗口之后,我开始接触树相关的题目。第一反应是:既然树也是一种数据结构,能不能把 Vol.1 的那套指针思路搬过来用?

试了一下,很快撞墙了。

这篇文章是我试图搞清楚"为什么不行"的过程。结论先说:数据结构的形状决定了遍历工具的选择——线性数据用指针,分支数据用队列或递归。 这不是规定,是形状本身的约束。


一、先试试:用双指针处理树会发生什么

假设有一棵二叉树,我想用双指针找到某个节点:

        1
       / \
      2   3
     / \
    4   5

按照 Vol.1 的思路,left 从头,right 从尾,向中间逼近。

问题来了:树的"头"在哪?"尾"在哪?

根节点 1 算头,但"尾"是 45、还是 3?树有多个末端,没有唯一的"另一头"。

好,那换快慢指针。reader 往前扫,writer 维护写入位。

问题还是来了:reader 从根节点 1 走到 2 之后,下一步该走 4 还是 5

在数组里,reader++ 只有一个方向。在树里,每个节点有 0 到 N 个子节点,"下一个"是不确定的。指针假设的是线性推进,但树的结构是分叉的,指针在第一个分叉处就失去了方向。

这就是撞墙的地方。


二、线性 vs 分支:下一个节点是"确定的1个"还是"不确定的0-N个"

这是两种数据结构最本质的区别:

数组 / 链表(线性):
  每个节点的下一个节点是确定的,最多1[1][2][3][4] → null

二叉树(分支):
  每个节点的下一个节点是不确定的,02[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],把 23 加入队列
        queue=[2, 3]

处理 2:result=[1,2],把 45 加入队列
        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 节点自身的 siblingreturn 指针里,可以随时中断。

这也是为什么 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(最大深度)之后,会回来把这篇的感受更新一遍——理论理解和动手之后的理解,通常不是同一件事。


参考资料