树解决"层级关系怎么存",堆解决"谁最优先怎么快速拿出来",两者共用同一套下标算术。
DOM 是树、React Fiber 是树、文件目录是树;而任务调度、Top K 排行榜用的是堆。一道递归遍历就能同时考指针操作、边界和复杂度,所以树是前端面试出现频率最高的结构。
| 术语 | 含义 |
|---|---|
| 根(root) | 没有父节点的节点 |
| 叶子(leaf) | 没有子节点的节点 |
| 度(degree) | 一个节点拥有的子节点个数 |
| 深度 / 高度 | 根到节点的边数 / 节点到最远叶子的边数 |
function inorderRec(node, out = []) {
if (!node) return out;
inorderRec(node.left, out);
out.push(node.val); // 挪到最前是前序,挪到最后是后序
inorderRec(node.right, out);
return out;
}
迭代写法要用显式栈,否则深树会抛 RangeError:
function inorder(root) {
const out = [], stack = [];
let cur = root;
while (cur || stack.length) {
while (cur) {
stack.push(cur);
cur = cur.left;
}
cur = stack.pop();
out.push(cur.val);
cur = cur.right;
}
return out;
}
前序把入栈顺序改成先右后左;后序按"根 → 右 → 左"压栈再 reverse(),是前序的镜像。层序遍历用队列,见常考第二题。
BST 的性质:左子树所有值 < 根 < 右子树所有值。查找、插入、删除都沿一条路径走,代价等于树高。
| 插入顺序 | 树形 | 查找复杂度 |
|---|---|---|
[4, 5, 6, 7] 升序 | 退化成一条链 | O(n) |
[4, 2, 6, 1, 3] 随机 | 接近平衡 | O(log n) |
所以工程上要自平衡。AVL 要求左右子树高度差不超过 1,红黑树要求黑高相等、最长路径不超过最短路径的两倍,用颜色换更少的旋转,所以工程上更常用。
| 维度 | AVL | 红黑树 |
|---|---|---|
| 平衡强度 | 更严格,树更矮 | 较松,树高最多 2 倍 |
| 插入删除 | 旋转多,调整代价高 | 旋转少,最多 3 次 |
| 适用场景 | 读多写少 | 读写都频繁,TreeMap 用它 |
堆满足两条:结构上是完全二叉树,最后一层靠左填满;顺序上每个节点都不小于(大顶堆)或不大于(小顶堆)子节点。完全二叉树可以紧凑存进数组,不需要指针:
| 关系 | 下标公式 |
|---|---|
| 父节点 | (i - 1) >> 1 |
| 左子节点 | 2 * i + 1 |
| 右子节点 | 2 * i + 2 |
| 最后一个非叶节点 | (n >> 1) - 1 |
建堆从最后一个非叶节点往前做 siftDown,整体 O(n):高度 h 的节点最多 n / 2^(h+1) 个,下沉代价 O(h),求和收敛到 O(n);逐个插入才是 O(n log n)。
function siftDown(a, i, n) {
while (true) {
const l = 2 * i + 1, r = 2 * i + 2;
let max = i;
if (l < n && a[l] > a[max]) max = l;
if (r < n && a[r] > a[max]) max = r;
if (max === i) break;
[a[i], a[max]] = [a[max], a[i]];
i = max;
}
}
function heapSort(a) {
for (let i = (a.length >> 1) - 1; i >= 0; i--) siftDown(a, i, a.length);
for (let end = a.length - 1; end > 0; end--) {
[a[0], a[end]] = [a[end], a[0]]; // 堆顶换到末尾
siftDown(a, 0, end);
}
return a;
}
建堆 O(n),每次取堆顶后下沉 O(log n),共 n 次,整体 O(n log n),原地排序、空间 O(1)。不稳定,交换会打乱相等元素的相对顺序。堆的真实用途:优先队列(任务调度、延时队列)、Top K(维护大小为 k 的堆)、Dijkstra 取最近节点、合并 K 个有序链表。
很多人以为"BST 查找是 O(log n)"。 实际上只有平衡时才是;按升序插入会退化成链表,查找变 O(n),这就是需要 AVL / 红黑树的原因。
很多人以为建堆和堆排序一样是 O(n log n)。 实际上自底向上建堆是 O(n),因为绝大多数节点在底层、下沉距离很短,只有堆排序整体才是 O(n log n)。
思路:递归取左右子树较大值加一;深树改层序遍历数层数,避免栈溢出。
function maxDepth(root) {
if (!root) return 0;
return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
}
时间 O(n),空间 O(h),h 为树高。易错点:空树返回 0 而不是 1。
思路:队列里按层存节点,每轮先记下当前层长度,处理完这一层再进下一层。
function levelOrder(root) {
if (!root) return [];
const res = [], queue = [root];
let head = 0; // 用头指针代替 shift,避免 O(n) 搬移
while (head < queue.length) {
const level = [];
for (let i = queue.length - head; i > 0; i--) {
const node = queue[head++];
level.push(node.val);
if (node.left) queue.push(node.left);
if (node.right) queue.push(node.right);
}
res.push(level);
}
return res;
}
时间 O(n),空间 O(n)。易错点:循环条件写成 queue.length 会边遍历边追加,层就乱了。
思路:递归时把合法区间往下传,左子树收紧上界、右子树收紧下界。
function isValidBST(root, min = -Infinity, max = Infinity) {
if (!root) return true;
if (root.val <= min || root.val >= max) return false;
return isValidBST(root.left, min, root.val) && isValidBST(root.right, root.val, max);
}
时间 O(n),空间 O(h)。易错点:只比较父子节点会漏掉"孙子比爷爷大";BST 不允许重复值时边界要用 <= / >=。
思路:维护大小为 k 的小顶堆,堆顶是当前第 k 大,比堆顶小的元素直接丢弃。
function findKthLargest(nums, k) {
const heap = [];
for (const x of nums) {
heap.push(x);
let i = heap.length - 1;
while (i > 0) {
const p = (i - 1) >> 1;
if (heap[p] <= heap[i]) break;
[heap[p], heap[i]] = [heap[i], heap[p]];
i = p;
}
if (heap.length > k) {
heap[0] = heap.pop();
let j = 0;
while (true) {
const l = 2 * j + 1, r = l + 1;
let m = j;
if (l < heap.length && heap[l] < heap[m]) m = l;
if (r < heap.length && heap[r] < heap[m]) m = r;
if (m === j) break;
[heap[j], heap[m]] = [heap[m], heap[j]];
j = m;
}
}
}
return heap[0];
}
时间 O(n log k),空间 O(k)。易错点:求第 K 大要用小顶堆;heap[0] = heap.pop() 之后必须重新下沉。