一、堆是什么?先甩掉那些吓人的定义

想象你是一家奶茶店的老板,店里有个排队系统。顾客休息区有一排座位,但你有一个硬规矩:坐第一个位置的人永远是“优先级最高”的那位——比如老人、孕妇、还有外卖小哥。每次有人离开,后面的人就自动补上。新来的客人进门后,不会随便找个位子坐,而是从队伍尾巴开始,跟前面的人比“优先级”,如果自己更急,就往前挪。这个“自动维持秩序”的机制,就是堆的核心思想。

堆在计算机里是一棵完全二叉树。什么叫完全二叉树?简单说,除了最后一层,每一层都必须塞满,最后一层的节点都靠左排列,不能有“豁牙”。为什么非要这造型?因为完全二叉树可以用数组一个不浪费地存下来,而且父子关系通过下标就能算出来,连指针都不用存。

具体来说,如果你把树的根节点放在数组的第0位(有的语言喜欢放第1位,这个后面会提醒),那么对于下标为 i 的节点,有这么几个神一样的公式:

  • 左孩子下标 = 2 * i + 1
  • 右孩子下标 = 2 * i + 2
  • 父节点下标 = (i - 1) / 2 然后向下取整

只要记住这三条,你手里就等于拿了一张整棵树的“寻宝图”。比如数组 [3, 8, 6, 12, 10],下标0是根,下标1和2是两个孩子,下标3和4是下标1的孩子。你完全可以不用画图,直接在脑子里把这棵树立起来。

1.1 大顶堆和小顶堆

堆还有个重要约定:大顶堆要求每个节点都比它的孩子大(或相等),所以根节点是最大值;小顶堆相反,每个节点都比它的孩子小,所以根节点是最小值。这一条约定,让“堆顶永远是最值”成了铁打的定律。也正是这个特性,让堆成了实现优先队列的天然选择。

二、数组存储机制:为什么能用数组装下一棵树

我们之间的对话,配合代码才够味儿。下面统一使用 JavaScript(ES6+)来演示。先写一个最小堆的基础骨架,把刚才那几个下标公式变成代码。

// 技术栈:JavaScript(ES6+)
class MinHeap {
  constructor() {
    this.heap = []; // 装树的数组
  }

  // 父节点下标
  getParentIndex(i) {
    return Math.floor((i - 1) / 2);
  }

  // 左孩子下标
  getLeftIndex(i) {
    return 2 * i + 1;
  }

  // 右孩子下标
  getRightIndex(i) {
    return 2 * i + 2;
  }

  // 看一眼堆顶,不删除
  peek() {
    return this.heap.length > 0 ? this.heap[0] : null;
  }

  // 交换数组里两个位置的值
  swap(i, j) {
    [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]];
  }
}

你看,代码里没有保存任何“左孩子指针”或者“右孩子指针”,全靠下标现算。这就是堆省内存的秘密:几乎没有额外开销。

三、上浮操作:新元素怎么“走”到正确的位置

往堆里加一个元素,最省事的思路是:先硬塞到数组尾部,然后让它像气泡一样“往上冒”,直到重新满足堆性质。这个过程叫上浮,英语叫 bubble upsift up

上浮的规则很简单:小顶堆里,如果新节点比父节点小,就跟父节点交换,然后继续跟更上层的父节点比,直到比对方大或者到了根。

3.1 插入元素完整示例

// 技术栈:JavaScript(ES6+)
class MinHeap {
  constructor() {
    this.heap = [];
  }

  // 父节点下标
  getParentIndex(i) {
    return Math.floor((i - 1) / 2);
  }

  // 左孩子下标
  getLeftIndex(i) {
    return 2 * i + 1;
  }

  // 右孩子下标
  getRightIndex(i) {
    return 2 * i + 2;
  }

  swap(i, j) {
    [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]];
  }

  // 插入一个新值
  insert(value) {
    this.heap.push(value);              // 先放到数组末尾
    this.bubbleUp(this.heap.length - 1); // 从最后一个位置开始上浮
  }

  // 上浮过程
  bubbleUp(index) {
    while (index > 0) {
      const parent = this.getParentIndex(index);
      // 如果当前节点比父节点小,就交换
      if (this.heap[index] < this.heap[parent]) {
        this.swap(index, parent);
        index = parent;                  // 往上继续检查
      } else {
        break;                           // 位置对了,直接收工
      }
    }
  }
}

// 使用示例
const heap = new MinHeap();
heap.insert(10);
heap.insert(5);
heap.insert(3);
heap.insert(12);
heap.insert(1);
console.log(heap.heap); // 输出 [1, 5, 3, 12, 10],满足最小堆

插入的时间复杂度是 O(log n)。因为最坏情况也就是从叶子一路爬到根,爬过的层数就是树的高度。

四、下沉操作:删除堆顶后如何“补位”

优先队列里最常用的操作是“取出最值并删除”。如果直接把根节点拿走,整棵树就碎了一地。标准做法是:把数组最后一个元素搬到根节点,然后让这个“替补队员”往下沉,跟两个孩子里的较小者比,如果它比孩子大,就换下去,直到合适的位置。这个过程叫下沉,也叫 sift down

4.1 删除堆顶完整示例

// 技术栈:JavaScript(ES6+)
class MinHeap {
  constructor() {
    this.heap = [];
  }

  // 省略 getParentIndex、getLeftIndex、getRightIndex、swap 等基础方法,实现时可参考上文

  // 删除并返回堆顶元素
  extractMin() {
    if (this.heap.length === 0) return null;
    if (this.heap.length === 1) return this.heap.pop();

    const min = this.heap[0];       // 先存下最小值
    this.heap[0] = this.heap.pop(); // 把最后一个元素挪到根
    this.sinkDown(0);               // 让它下沉到该去的位置
    return min;
  }

  // 下沉过程
  sinkDown(index) {
    const size = this.heap.length;
    while (true) {
      const left = this.getLeftIndex(index);
      const right = this.getRightIndex(index);
      let smallest = index;

      // 找父、左、右里最小的那个
      if (left < size && this.heap[left] < this.heap[smallest]) {
        smallest = left;
      }
      if (right < size && this.heap[right] < this.heap[smallest]) {
        smallest = right;
      }

      // 如果需要交换,就换,然后继续往下
      if (smallest !== index) {
        this.swap(index, smallest);
        index = smallest;
      } else {
        break; // 到位了
      }
    }
  }
}

// 使用示例
const heap = new MinHeap();
[10, 5, 3, 12, 1].forEach(v => heap.insert(v));
console.log(heap.extractMin()); // 输出 1
console.log(heap.heap);         // 输出 [3, 5, 10, 12],依然是最小堆

下沉操作的时间复杂度也是 O(log n)。注意我们是用“最后一个元素”来补位,没有把数组中间的元素往前挪,所以弹性能量很高。

五、线性时间建堆:把数组变成堆的高效方式

如果你想从一个现有数组直接建堆,最朴素的方式是逐个调用 insert,时间复杂度是 O(n log n)。但更聪明的做法是“自底向上下沉”,时间复杂度能做到 O(n)。这个结论是不是很反直觉?别急,我解释一下。

5.1 为什么能到 O(n)

假设数组长度为 n。建堆时,我们从最后一个非叶子节点开始,往前逐个下沉。叶子节点数量多,但不需要处理;越靠近根部的节点数量越少,而且每个节点的下沉代价跟它的“高度”有关。把所有这些代价加起来,是一个常数乘以 n,而不是 n 个 log n。数学上可以严格证明,这里不展开公式轰炸,你记住结论:用对方法,建堆是线性时间。

5.2 真实代码:从数组构建最小堆

// 技术栈:JavaScript(ES6+)
function buildMinHeap(arr) {
  const size = arr.length;

  // 从最后一个非叶子节点开始,往前挨个下沉
  for (let i = Math.floor(size / 2) - 1; i >= 0; i--) {
    heapify(arr, i, size);
  }
  return arr;
}

// 对下标 i 执行下沉
function heapify(arr, i, size) {
  let smallest = i;
  const left = 2 * i + 1;
  const right = 2 * i + 2;

  if (left < size && arr[left] < arr[smallest]) smallest = left;
  if (right < size && arr[right] < arr[smallest]) smallest = right;

  if (smallest !== i) {
    [arr[i], arr[smallest]] = [arr[smallest], arr[i]]; // 交换
    heapify(arr, smallest, size);                      // 继续下沉
  }
}

const result = buildMinHeap([12, 5, 8, 3, 10, 1]);
console.log(result); // 输出合法的最小堆数组,例如 [1, 3, 8, 12, 10, 5]

上面用到了递归。设计生产代码时,大数组可能造成调用栈过深,我建议你改成循环版本。原理完全一样,代码稍长一点,但更稳妥。

六、实际工程应用场景

堆在工程里遍地都是,讲几个你可能已经踩过的场景。

6.1 优先队列

浏览器的事件循环、操作系统的进程调度,背后都有优先队列的身影。比如 Node.js 里的定时器,底层就用了一个最小堆来管理。定时时间最小的任务排在堆顶,时间一到,立刻从堆里拿出并执行。如果每个定时器都开一个数组去遍历,早就卡成幻灯片了。

6.2 Top K 问题

比如从一亿条访问日志里找出访问量最大的前 100 个 IP。如果你把一亿条全塞进内存排序,机器大概率当场冒烟。换个姿势:维护一个大小为 100 的小顶堆,每来一个新数据,就跟堆顶比较。如果新数据比堆顶大,就替换掉堆顶,然后下沉。这样一来,堆里永远是当前遇到的最大 100 个,最后堆顶就是第 100 大的那个。时间复杂度 O(n log 100),差不多等于线性。

// 技术栈:JavaScript(ES6+)
// 用最小堆找 Top K 大元素,这里直接用一个简化版
function topKLargest(nums, k) {
  const minHeap = [];

  function push(val) {
    if (minHeap.length < k) {
      // 堆没满,直接加,然后上浮
      minHeap.push(val);
      let i = minHeap.length - 1;
      while (i > 0) {
        const parent = Math.floor((i - 1) / 2);
        if (minHeap[i] < minHeap[parent]) {
          [minHeap[i], minHeap[parent]] = [minHeap[parent], minHeap[i]];
          i = parent;
        } else break;
      }
    } else if (val > minHeap[0]) {
      // 比堆顶大,替换堆顶,然后下沉
      minHeap[0] = val;
      let i = 0;
      while (true) {
        let smallest = i;
        const l = 2 * i + 1;
        const r = 2 * i + 2;
        if (l < k && minHeap[l] < minHeap[smallest]) smallest = l;
        if (r < k && minHeap[r] < minHeap[smallest]) smallest = r;
        if (smallest !== i) {
          [minHeap[i], minHeap[smallest]] = [minHeap[smallest], minHeap[i]];
          i = smallest;
        } else break;
      }
    }
  }

  nums.forEach(n => push(n));
  return minHeap;
}

console.log(topKLargest([4, 1, 7, 9, 3, 8, 2], 3)); // 输出可能是 [7, 9, 8]

6.3 堆排序

堆排序就是“建堆 + 反复取堆顶”的合体。先建一个最大堆,然后把根节点和末尾元素交换,再把堆的规模缩小一,最后对新根做下沉。反复做,数组就从后往前排好了。这个算法不稳定,但是空间复杂度 O(1),在嵌入式等内存受限的环境里非常香。

七、技术优缺点和常见陷阱

7.1 优点

  • 插入和删除最值稳定在 O(log n),最坏情况也不慌。
  • 数组存储,缓存友好,内存占用低。
  • 能在线性时间内完成建堆。
  • 适合处理动态数据流,比如实时排行榜、任务调度。

7.2 缺点

  • 只能快速拿到最值,想找任意元素?得遍历。
  • 删除任意值需要额外维护“索引映射”,不然你根本不知道它藏在数组哪个角落。
  • 堆排序的常数比快排大,实际速度不一定赢过内置排序。
  • 完全二叉树的高度决定了操作次数,数据量上亿时递归写法容易爆栈。

7.3 常见陷阱(重点看)

陷阱一:下标从 0 还是从 1 开始。 很多经典教材用从 1 开始的下标,左孩子是 2*i,右孩子是 2*i+1。你从网上抄代码时,先看清楚这段代码的下标约定。如果混着用,数组越界是家常便饭。我推荐统一使用从 0 开始,代码里注释写清楚。

陷阱二:下沉时比较哪个孩子? 小顶堆下沉,必须和孩子中“最小”的那个比,不能只盯着左孩子。如果漏了右孩子,堆的性质就守不住。我见过太多新手在这里翻车,明明写的像模像样,取最值却不对。

陷阱三:建堆的起始下标。 最后一个非叶子节点的下标是 Math.floor(n / 2) - 1,不是 Math.floor(n / 2)。差一个位置,可能导致部分节点根本没参与下沉,堆是假的。

陷阱四:对象属性比较。 如果堆里存的是对象,比如任务对象 { name: '写代码', priority: 1 },你让 JavaScript 直接比较两个对象?它会把对象转成字符串 "[object Object]",那结果简直没法看。你必须显式指定比较哪个字段。

// 技术栈:JavaScript(ES6+)
// 比较两个任务对象时,应该使用 priority 字段
function compareTask(a, b) {
  return a.priority - b.priority; // 小于 0 说明 a 优先级更高
}

陷阱五:堆输出不是有序数组。 堆顶是最值,但你不能随便遍历就拿到有序序列,因为堆不是二叉搜索树。想要有序结果,得反复 extractMin(),那其实就是堆排序了。

7.4 应用注意事项

动手用堆之前,先问自己三个问题:我需要频繁取最值吗?我需要随机查找吗?数据量大到不能直接排序吗?如果前两个回答是“是”,第三个也是“是”,堆基本就是你的菜。如果只是偶尔取一次最值,直接 Math.max(...arr) 或者排序,反而更省事。

另外,如果你需要频繁合并两个堆,普通二叉堆就有点吃力了。可以考虑二项堆或斐波那契堆,但工程中很少真要你手写,了解有这回事就好。

八、手把手写一个完整的最小堆(收尾示例)

为了让你有一个能直接复制粘贴运行的完整参考,我把前面那些零散的代码凑成一份,加上注释,你可以在浏览器控制台或 Node.js 里跑一跑。

// 技术栈:JavaScript(ES6+)
class MinHeap {
  constructor(arr = []) {
    this.heap = [];
    // 通过现有数组建堆
    for (const item of arr) {
      this.insert(item);
    }
  }

  size() {
    return this.heap.length;
  }

  isEmpty() {
    return this.heap.length === 0;
  }

  peek() {
    return this.heap.length > 0 ? this.heap[0] : undefined;
  }

  insert(value) {
    this.heap.push(value);
    this._bubbleUp(this.heap.length - 1);
  }

  extractMin() {
    if (this.isEmpty()) return undefined;
    if (this.heap.length === 1) return this.heap.pop();

    const min = this.heap[0];
    this.heap[0] = this.heap.pop();
    this._sinkDown(0);
    return min;
  }

  // 上浮
  _bubbleUp(index) {
    while (index > 0) {
      const parent = Math.floor((index - 1) / 2);
      if (this.heap[index] < this.heap[parent]) {
        [this.heap[index], this.heap[parent]] = [this.heap[parent], this.heap[index]];
        index = parent;
      } else {
        break;
      }
    }
  }

  // 下沉
  _sinkDown(index) {
    while (true) {
      const left = 2 * index + 1;
      const right = 2 * index + 2;
      let smallest = index;

      if (left < this.heap.length && this.heap[left] < this.heap[smallest]) {
        smallest = left;
      }
      if (right < this.heap.length && this.heap[right] < this.heap[smallest]) {
        smallest = right;
      }

      if (smallest !== index) {
        [this.heap[index], this.heap[smallest]] = [this.heap[smallest], this.heap[index]];
        index = smallest;
      } else {
        break;
      }
    }
  }
}

// 演示全部操作
const heap = new MinHeap([8, 3, 9, 1, 7]);
heap.insert(0);
console.log(heap.peek());       // 0
console.log(heap.extractMin()); // 0
console.log(heap.extractMin()); // 1
console.log(heap.heap);         // 剩余元素依然满足最小堆性质

九、总结

堆是一棵用数组装的完全二叉树,通过上浮和下沉两个动作维护秩序。它牺牲了随机查找的能力,换来了极致的取最值效率。工程上,优先队列、任务调度、TopK、堆排序都离不开它。你只需要抓住四个关键词:完全二叉树、数组存储、上浮下沉、线性建堆。把今天这份代码吃透,以后不管是用 JavaScript 还是换到别的语言,核心心智模型都是一样的。

最后再多说一句:如果你在业务代码里需要优先队列,请优先使用语言自带的高质量实现,比如 Python 的 heapq、Java 的 PriorityQueue、JavaScript 里成熟的库,别重复造轮子。但面试或者底层优化的时候,能把这套逻辑手写出来,才是真正的本事。希望这篇“人话版”解读,能让你对堆产生“原来如此”的清爽感,而不是被一堆术语劝退。