数据结构学习01:堆与优先队列从入门到实战

堆与优先队列从入门到实战

前言

在算法题里,只要遇到“反复获取当前最大值或最小值”的场景,堆就很容易进入候选方案。

比如:

  • 从一组数据里找前 K 大元素。
  • 数据流不断进入时,维护第 K 大元素。
  • 每次从多个有序序列中取当前最小值。
  • Dijkstra 算法中反复取当前距离最短的节点。

这些问题的共同点是:数据会变化,但我们又需要高频访问某个“最值”。如果每次都重新排序,成本通常偏高;如果用堆维护,插入和删除堆顶都可以控制在 O(log n)。

本文按“概念 -> 操作 -> JavaScript 实现 -> 典型应用”的顺序,把堆和优先队列串起来。

一、堆是什么

堆是一种特殊的完全二叉树,常用于快速获取一组数据中的最大值或最小值。

它有两个关键点:

  1. 堆是一棵完全二叉树。
  2. 堆只保证父子节点之间满足大小关系,不保证整棵树整体有序。

常见堆类型有两种。

大根堆:每个节点的值都大于等于它的子节点,堆顶是最大值。

小根堆:每个节点的值都小于等于它的子节点,堆顶是最小值。

大根堆示例                    小根堆示例
      10                          1
     /  \                        / \
    8    9                       3   2
   / \  / \                     / \  / \
  7  6 5  4                    8  7 6  9

数组表示: [10, 8, 9, 7, 6, 5, 4]
数组表示: [1, 3, 2, 8, 7, 6, 9]

这里容易误解的一点是:堆不是有序数组,也不是二叉搜索树。

对于大根堆,只能确定堆顶是最大值,以及每个父节点不小于自己的子节点。至于同一层节点之间、不同子树之间的完整大小关系,堆并不保证。

二、堆为什么适合用数组表示

堆是一棵完全二叉树。

完全二叉树的特点是:

  • 除最后一层外,其余层都是满的。
  • 最后一层节点从左到右连续排列。

这个结构非常适合用数组存储,不需要额外维护左右指针。

如果当前节点下标是 i,则:

const parent = Math.floor((i - 1) / 2)
const left = 2 * i + 1
const right = 2 * i + 2

数组下标和树结构的对应关系如下:

      0
     / \
    1   2
   / \ / \
  3  4 5  6

这个下标关系是堆实现的基础。后面的上浮、下沉、建堆,本质上都是基于这三个公式在数组里交换元素。

三、堆的核心操作

堆的常用操作主要有四类:查看堆顶、插入元素、删除堆顶、建堆。

1. 查看堆顶

堆顶就是数组第一个元素。

heap[0]

时间复杂度是 O(1)。

大根堆的堆顶是当前最大值,小根堆的堆顶是当前最小值。

2. 插入元素:上浮

插入元素时,一般先把新元素放到数组末尾,然后向上调整。

过程是:

  1. 新元素放到堆的末尾。
  2. 和父节点比较。
  3. 如果不满足堆性质,就和父节点交换。
  4. 重复向上调整,直到满足堆性质。

这个过程通常叫“上浮”,也叫 sift up。

以大根堆为例:

初始: [10, 8, 5, 7, 6] 插入 9

Step 1: 添加到末尾
      10
     /  \
    8    5
   / \  /
  7  6 9

Step 2: 9 > 5,交换
      10
     /  \
    8    9
   / \  /
  7  6 5

结果: [10, 8, 9, 7, 6, 5]

插入元素的时间复杂度是 O(log n),因为最多从叶子节点一路上浮到根节点。

3. 删除堆顶:下沉

删除堆顶时,不能直接删除数组第一个元素,否则会破坏完全二叉树的结构。

常见做法是:

  1. 记录堆顶元素。
  2. 用最后一个元素替换堆顶。
  3. 删除数组最后一个元素。
  4. 从堆顶开始向下调整。
  5. 和更合适的子节点交换,直到满足堆性质。

这个过程通常叫“下沉”,也叫 sift down。

以大根堆为例:

初始: [10, 8, 9, 7, 6, 5]

Step 1: 取出堆顶 10,末尾元素 5 移到堆顶
      5
     / \
    8   9
   / \
  7   6

Step 2: 5 和较大的子节点 9 交换
      9
     / \
    8   5
   / \
  7   6

结果: [9, 8, 5, 7, 6]

删除堆顶的时间复杂度也是 O(log n)。

4. 建堆

建堆是把一个普通数组调整成堆。

常见方式有两种:

方式 时间复杂度 说明
逐个插入建堆 O(n log n) 每个元素都执行一次上浮
从最后一个非叶子节点向下调整 O(n) 更常用,适合原地建堆

实际算法题里,更推荐掌握 O(n) 建堆方式。

最后一个非叶子节点的下标是:

Math.floor(n / 2) - 1

从这个位置开始,倒序对每个节点执行下沉,就可以完成建堆。

四、复杂度总结

操作 时间复杂度
查看堆顶 O(1)
插入元素 O(log n)
删除堆顶 O(log n)
建堆 O(n)
堆排序 O(n log n)

这也是堆适合处理动态最值问题的原因:它不用维护完整有序,只维护足够取出堆顶的局部顺序。

五、JavaScript 实现优先队列

JavaScript 没有内置堆结构。做算法题时,如果要使用优先队列,通常需要自己封装。

下面这个 PriorityQueue 通过传入比较函数,同时支持大根堆和小根堆。

class PriorityQueue {
  constructor(comparator = (a, b) => a > b) {
    this.heap = []
    this.compare = comparator
  }

  insert(element) {
    this.heap.push(element)
    this.bubbleUp()
  }

  bubbleUp() {
    let index = this.heap.length - 1

    while (index > 0) {
      const parentIndex = Math.floor((index - 1) / 2)

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

  remove() {
    if (this.heap.length === 0) return null
    if (this.heap.length === 1) return this.heap.pop()

    const removedElement = this.heap[0]
    this.heap[0] = this.heap.pop()
    this.bubbleDown()
    return removedElement
  }

  bubbleDown() {
    let index = 0

    while (index < this.heap.length) {
      const leftChildIndex = 2 * index + 1
      const rightChildIndex = 2 * index + 2
      let extremChildIndex = index

      if (
        leftChildIndex < this.heap.length &&
        this.compare(this.heap[leftChildIndex], this.heap[extremChildIndex])
      ) {
        extremChildIndex = leftChildIndex
      }

      if (
        rightChildIndex < this.heap.length &&
        this.compare(this.heap[rightChildIndex], this.heap[extremChildIndex])
      ) {
        extremChildIndex = rightChildIndex
      }

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

  peek() {
    return this.heap[0]
  }

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

默认比较函数是:

(a, b) => a > b

也就是大根堆。数值越大,优先级越高。

使用示例:

const maxHeap = new PriorityQueue()

maxHeap.insert(5)
maxHeap.insert(10)
maxHeap.insert(3)

console.log(maxHeap.peek()) // 10

如果要实现小根堆,只需要换比较函数:

const minHeap = new PriorityQueue((a, b) => a < b)

minHeap.insert(5)
minHeap.insert(10)
minHeap.insert(3)

console.log(minHeap.peek()) // 3

这个封装后面可以直接用于 TopK、合并有序链表、数据流维护等场景。

六、典型应用一:TopK 问题

TopK 问题的目标是:从 N 个元素中找出最大或最小的 K 个元素。

最直接的做法是排序,然后取前 K 个:

排序: O(N log N)

但如果 N 很大、K 相对较小,用堆会更合适。

找最大的 K 个元素

找最大的 K 个元素时,可以维护一个大小为 K 的小根堆。

核心思路是:

  1. 遍历数组。
  2. 当前元素入堆。
  3. 如果堆大小超过 K,就弹出堆顶。
  4. 遍历结束后,堆里剩下的就是最大的 K 个元素。

为什么是小根堆?

因为堆顶是当前 K 个元素里最小的那个。当新元素进来时,只要堆大小超过 K,就把最小的淘汰掉。这样堆里始终保留更大的那一批元素。

复杂度对比:

方法 时间 空间
排序 O(N log N) O(1) 或取决于排序实现
小根堆 O(N log K) O(K)

当 N 远大于 K 时,堆方法的优势会比较明显。

实现如下:

function findTopK(arr, k) {
  const minHeap = new PriorityQueue((a, b) => a < b)

  for (const num of arr) {
    minHeap.insert(num)

    if (minHeap.size() > k) {
      minHeap.remove()
    }
  }

  const result = []

  while (minHeap.size() > 0) {
    result.push(minHeap.remove())
  }

  return result
}

const arr = [3, 2, 1, 5, 6, 4, 7, 8, 9, 10]
const k = 3

console.log(findTopK(arr, k)) // [8, 9, 10]

注意:上面返回结果是从小到大弹出的,因为小根堆每次弹出堆顶最小值。如果业务要求从大到小展示,可以再反转一次:

return result.reverse()

相关题目:

七、典型应用二:堆排序

堆排序是基于堆结构实现的原地排序算法。

它的基本特征是:

特征 说明
时间复杂度 O(n log n)
空间复杂度 O(1)
稳定性 不稳定

堆排序通常使用大根堆完成升序排序。

核心步骤是:

  1. 将数组构建成大根堆。
  2. 把堆顶最大值和当前堆的末尾元素交换。
  3. 缩小堆的范围。
  4. 对新的堆顶执行下沉调整。
  5. 重复直到排序完成。

排序过程示例

以数组 [12, 11, 13, 5, 6, 7] 为例。

初始树结构:

        12
       /  \
     11    13
    /  \   /
   5   6  7

从最后一个非叶子节点开始建堆:

i = 2,节点 13,子节点 7,无需交换
i = 1,节点 11,子节点 5 和 6,无需交换
i = 0,节点 12,较大子节点是 13,交换

建堆完成: [13, 11, 12, 5, 6, 7]

然后开始排序:

第 1 轮:
交换 13 和 7,堆化剩余元素
结果: [12, 11, 7, 5, 6 | 13]

第 2 轮:
交换 12 和 6,堆化剩余元素
结果: [11, 6, 7, 5 | 12, 13]

第 3 轮:
交换 11 和 5,堆化剩余元素
结果: [7, 6, 5 | 11, 12, 13]

第 4 轮:
交换 7 和 5,堆化剩余元素
结果: [6, 5 | 7, 11, 12, 13]

第 5 轮:
交换 6 和 5
结果: [5 | 6, 7, 11, 12, 13]

最终结果: [5, 6, 7, 11, 12, 13]

完整实现:

class MaxHeap {
  constructor(arr = []) {
    this.heap = [...arr]
    this.buildHeap()
  }

  buildHeap() {
    for (let i = Math.floor(this.heap.length / 2) - 1; i >= 0; i--) {
      this.heapify(i, this.heap.length)
    }
  }

  heapify(i, n) {
    let largest = i
    const left = 2 * i + 1
    const right = 2 * i + 2

    if (left < n && this.heap[left] > this.heap[largest]) {
      largest = left
    }

    if (right < n && this.heap[right] > this.heap[largest]) {
      largest = right
    }

    if (largest !== i) {
      ;[this.heap[i], this.heap[largest]] = [this.heap[largest], this.heap[i]]
      this.heapify(largest, n)
    }
  }

  sort() {
    const result = [...this.heap]

    for (let i = result.length - 1; i > 0; i--) {
      ;[result[0], result[i]] = [result[i], result[0]]
      this.heapifyForSort(result, 0, i)
    }

    return result
  }

  heapifyForSort(arr, i, n) {
    let largest = i
    const left = 2 * i + 1
    const right = 2 * i + 2

    if (left < n && arr[left] > arr[largest]) {
      largest = left
    }

    if (right < n && arr[right] > arr[largest]) {
      largest = right
    }

    if (largest !== i) {
      ;[arr[i], arr[largest]] = [arr[largest], arr[i]]
      this.heapifyForSort(arr, largest, n)
    }
  }
}

const arr = [12, 11, 13, 5, 6, 7]
const maxHeap = new MaxHeap(arr)

console.log('原数组:', arr)
console.log('排序后:', maxHeap.sort())

这段代码为了避免修改原数组,在 sort() 中复制了一份 result。如果题目要求原地排序,可以直接在原数组上操作。

八、其他典型应用

除了 TopK 和堆排序,堆还有几个常见使用场景。

1. 合并 K 个有序链表

可以使用小根堆保存每个链表的当前最小节点。

每次取出堆顶节点,把它接到结果链表后面,再把该节点的下一个节点放入堆中。

这样每次都能从 K 个候选节点里快速取出当前最小值。

2. 数据流中位数

可以用两个堆维护数据流:

  • 大根堆保存较小的一半数据。
  • 小根堆保存较大的一半数据。

通过控制两个堆的大小差,就可以快速获取中位数。

3. 最短路径算法

在 Dijkstra 算法中,可以用小根堆优化“每次取当前距离最小节点”的过程。

没有堆时,查找当前最短节点可能需要线性扫描;使用堆后,可以把这一步优化为优先队列弹出。

九、常见误区

1. 把堆当成二叉搜索树

堆只保证父子节点关系,不保证左子树小于根、右子树大于根。

二叉搜索树关注全局查找路径,堆关注堆顶最值。

2. 以为堆是完全有序结构

堆不是完全排序。

对于小根堆,只能确定堆顶是最小值,不能确定其他节点之间的完整大小关系。

3. TopK 选错堆类型

找最大的 K 个元素时,通常维护大小为 K 的小根堆。

找最小的 K 个元素时,通常维护大小为 K 的大根堆。

这里的直觉容易反:不是“找最大就用大根堆”,而是要看你想淘汰谁。找最大的 K 个,需要不断淘汰当前候选集合里最小的那个,所以用小根堆更方便。

4. 忽略 JavaScript 没有内置堆

JavaScript 标准库没有直接可用的 heap 或 PriorityQueue。

在算法题中,如果题目需要堆,通常要自己实现;在工程项目中,则可以根据项目规范选择成熟工具库。

十、总结

堆适合解决“动态数据中反复获取最大值或最小值”的问题。

理解堆时,抓住几件事就够了:

  • 堆是完全二叉树,适合用数组表示。
  • 大根堆堆顶是最大值,小根堆堆顶是最小值。
  • 插入元素靠上浮,删除堆顶靠下沉。
  • 建堆可以从最后一个非叶子节点开始向下调整,做到 O(n)。
  • 优先队列通常可以用堆实现。
  • TopK、堆排序、合并 K 个有序链表、数据流中位数、Dijkstra 都是堆的典型应用。

一句话概括:堆不追求整体有序,只用较低成本维护“当前最值得先处理的元素”。

posted @ 2026-06-18 21:28  bleemyoung  阅读(13)  评论(0)    收藏  举报