Go 堆实战:从完全二叉树到 Top-K 与任务调度

认识堆

堆是一个完全二叉树,所谓完全二叉树,除最后一层,每一层都是满的,并且最后一层的节点必须全部靠左连续排列。

如下面的就算一个完全二叉树:

text 复制代码
        1
       / \
      3   2
     /
    7

当然,下面这种也算完全二叉树:

text 复制代码
        1
       / \
      3   2
     / \
    7   8

但是,下面这种不算,因为最后一层的节点 7 没有从最左边开始:

text 复制代码
        1
       / \
      3   2
         /
        7

记忆口诀:完全二叉树 = "上面全满,下面靠左"。

如果任一节点的值大于等于其左右孩子,则叫大顶堆(大根堆),堆顶为最大值。如下面这棵完全二叉树就是大顶堆:

text 复制代码
        10
       /  \
      8    5
     / \
    3   1

如果任一节点的值小于等于其左右孩子,则叫小顶堆(小根堆),堆顶为最小值。如下面这棵完全二叉树就是小顶堆:

text 复制代码
        1
       / \
      3   2
     / \
    7   6

堆是实现优先队列的常用方法。

因为堆是完全二叉树,所以特别适合使用数组存储。将这个完全二叉树从上到下,从左到右按照顺序将节点一个一个地存储在数组里。数组在内存上是连续的。

例如这颗小顶堆:

text 复制代码
        1
       / \
      3   2
     / \
    7   6

对应的数组就是:

text 复制代码
[1, 3, 2, 7, 6]
 0  1  2  3  4

数组下标和树节点之间存在固定关系。

假设下标从 0 开始:

text 复制代码
父节点:      (i - 1) / 2

左孩子:      2*i + 1

右孩子:      2*i + 2

例如节点 3:

text 复制代码
数组:

[1, 3, 2, 7, 6]
    ↑
    i = 1

它的孩子:

text 复制代码
左孩子 = 2*1 + 1 = 3 → 7
右孩子 = 2*1 + 2 = 4 → 6

因此,堆节点的存储不需要额外存储左右孩子节点的指针。堆中找父节点、左右孩子只需要计算下标:

go 复制代码
parent := (i - 1) / 2
left   := 2*i + 1
right  := 2*i + 2

在 Go 语言中使用堆

在 go 语言中,提供了 container/heap 的堆实现。

要使用 container/heap 中的堆相关的方法,需要实现 go 语言中 heap.Interface 接口。

heap.Interface 接口的定义如下:

go 复制代码
type Interface interface {
  sort.Interface
  Push(x any) // add x as element Len()
  Pop() any   // remove and return element Len() - 1.
}

其中 sort.Interface 的定义为:

go 复制代码
type Interface interface {
  Len() int
  Less(i, j int) bool
  Swap(i, j int)
}

heap.Interface 接口的定义中

  • Push 函数用于将元素 x 添加末尾;

  • Pop 函数用于删除并返回末尾元素。

sort.Interface 接口的定义中:

  • Len() 函数返回堆中元素个数,相当于 JS 中的 arr.length ;

  • Less(i, j) 函数判断索引 i 处的元素是否应该排在索引 j 处的元素之前。它不一定表示数值更小,也可以用于自定义排序规则。

  • Swap(i, j) 函数交换索引 i 和索引 j 处的元素。

在上面认识堆中说过,可以使用数组存储堆,因此,在 go 语言中,要使用堆,还需要定义切片,用于存储堆:

go 复制代码
// 定义 int 类型的切片,类型名字为 IntHeap
type IntHeap []int

因此,在 Go 语言中,要使用堆,需要定义一个切片数据结构和实现 5 个方法:

go 复制代码
// 切片数据结构
type IntHeap []int

// 要实现的 5 个方法
func (h IntHeap) Len() int           { /* 有几个元素 */ }
func (h IntHeap) Less(i, j int) bool { /* i 该排在 j 前面吗 */ }
func (h IntHeap) Swap(i, j int)      { /* 交换第 i、j 个 */ }
func (h *IntHeap) Push(x any)        { /* 放到末尾 */ }
func (h *IntHeap) Pop() any          { /* 删掉末尾元素并返回该元素 */ }

注意 Push / Pop 要用「指针接收者」,因为 Push / Pop 要修改 len ,如果是值接收者,你改的是副本的 len,函数返回后原切片毫无变化。

这里有实现小顶堆案例的完整代码:

go 复制代码
// 小顶堆(min-heap)的最小可运行示例。
package main

import (
  "container/heap"
  "fmt"
)

// IntHeap 是小顶堆:堆顶((*h)[0])永远是当前最小值。
//
// 注意:heap.Interface 需要 5 个方法 ------
// Len/Less/Swap 来自 sort.Interface(值接收者),Push/Pop 是指针接收者
type IntHeap []int

func (h IntHeap) Len() int           { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] } // ← 最小堆
func (h IntHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *IntHeap) Push(x any) {
  *h = append(*h, x.(int)) // 只负责「放到末尾」,上浮由 heap.Push 完成
}

func (h *IntHeap) Pop() any {
  old := *h
  n := len(old)
  x := old[n-1] // 必须返回「最后一个」,不是 h[0]
  old[n-1] = 0  // 清空引用,帮助 GC
  *h = old[:n-1]
  return x
}

// 编译期断言:类型写错时立刻报错,而不是等到运行时 panic。
var _ heap.Interface = (*IntHeap)(nil)

func section(n int, title string) {
  fmt.Printf("\n=== %d. %s ===\n", n, title)
}

func main() {
  section(1, "基础用法:Init / Push / Pop(最小堆)")

  h := &IntHeap{5, 3, 8, 1} // 乱序数据
  fmt.Println("  原始切片     :", *h)
  fmt.Printf("  Init 之前堆顶 : %d(还不是最小值,此时不算堆)\n", (*h)[0])

  heap.Init(h) // O(n) 建堆
  fmt.Printf("  Init 之后 heap=%v  堆顶=%d\n", *h, (*h)[0])

  heap.Push(h, 2) // O(log n):末尾追加 + 上浮
  heap.Push(h, 0)
  fmt.Printf("  Push 2、0 之后 heap=%v  堆顶=%d\n", *h, (*h)[0])

  fmt.Print("  依次 Pop:")
  for h.Len() > 0 {
    fmt.Print(heap.Pop(h), " ")
  }
  fmt.Println("← 弹出顺序一定是升序")
}

上述代码的运行结果:

其中:

  • heap.Init

  • heap.Push

  • heap.Pop

都是标准库 container/heap 提供的方法。

heap.Init 方法的作用是建堆,把一段乱序数据原地整理成合法的堆。

heap.Push 方法的作用是把元素加入堆中。

heap.Pop 方法的作用是弹出堆顶元素。

案例 1:Top-K 问题

比如从 10 亿个数里找出最大的 3 个,内存装不下全部数据(只能流式读取)。

核心思路:维护 K 个元素的最小堆,堆顶就是这 K 个里最小的那个。新元素只要能比堆顶大,就说明它够格进入 Top-K,于是踢掉堆顶、换上新元素。

Top-K 案例的完整代码如下:

go 复制代码
// Top-K(从一堆数里找出最大的 K 个)的最小可运行示例。
// 核心技巧:用「最小堆」维护当前最大的 K 个元素 ------ 堆顶就是这 K 个里最小的那个,
// 一旦遇到更大的数,就替换堆顶并 heap.Fix。复杂度 O(n log k),不需要全排序。
package main

import (
  "container/heap"
  "fmt"
)

// IntHeap 是最小堆:堆顶((*h)[0])永远是当前最小值。
type IntHeap []int

func (h IntHeap) Len() int           { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] } // ← 最小堆
func (h IntHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *IntHeap) Push(x any) {
  *h = append(*h, x.(int)) // 只负责「放到末尾」,上浮由 heap.Push 完成
}

func (h *IntHeap) Pop() any {
  old := *h
  n := len(old)
  x := old[n-1] // 必须返回「最后一个」,不是 h[0]
  old[n-1] = 0  // 清空引用,帮助 GC
  *h = old[:n-1]
  return x
}

// 编译期断言:类型写错时立刻报错,而不是等到运行时 panic。
var _ heap.Interface = (*IntHeap)(nil)

func section(n int, title string) {
  fmt.Printf("\n=== %d. %s ===\n", n, title)
}

func main() {
  section(2, "只读堆顶 & Top-K:用最小堆维护「最大的 K 个」")

  h := &IntHeap{}
  heap.Init(h)
  for _, v := range []int{42, 7, 19, 88, 3, 56} {
    heap.Push(h, v)
  }
  fmt.Printf("  堆顶(只看不弹,O(1) 且不改变堆)= %d,堆大小仍为 %d\n", (*h)[0], h.Len())

  nums := []int{42, 7, 19, 88, 3, 56, 91, 23}
  fmt.Printf("  nums=%v\n  最大的 3 个 = %v\n", nums, topK(nums, 3))
}

// topK 返回 nums 中最大的 k 个(降序)。
//
// 技巧:用「最小堆」维护当前最大的 k 个元素 ------ 堆顶就是这 k 个里最小的那个。
// 一旦遇到更大的数,就替换堆顶并 Fix。复杂度 O(n log k),不需要全排序。
func topK(nums []int, k int) []int {
  if k <= 0 || len(nums) == 0 {
    return nil
  }

  h := &IntHeap{}
  heap.Init(h)

  for _, n := range nums {
    if h.Len() < k {
      heap.Push(h, n)
      continue
    }
    if n > (*h)[0] { // 比「K 个里最小的」还大 → 踢掉它
      (*h)[0] = n // 直接改堆顶,再修复堆序
      heap.Fix(h, 0)
    }
  }

  out := make([]int, 0, h.Len())
  for h.Len() > 0 {
    out = append(out, heap.Pop(h).(int)) // 弹出是升序
  }
  for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
    out[i], out[j] = out[j], out[i] // 反转成降序
  }
  return out
}

运行结果:

heap.Fix() 方法是 Go 语言标准库提供的方法,作用是当堆中某个元素的值发生变化后,重新调整堆结构,让它继续满足堆的性质。

案例 2:任务调度器

堆(Heap)是实现优先队列的常用方式。

实际开发中有多个不同优先级的任务时,可以将这些任务构造成优先级队列,然后依据不同的优先级出队执行任务。

任务调度器案例的完整代码如下:

go 复制代码
// 优先级队列(Priority Queue):container/heap 最经典的用途。
//
//(Enqueue / Dequeue / Peek / Update / Cancel),使用方不必直接接触 container/heap。

package main

import (
  "container/heap"
  "fmt"
  "strings"
)

// Item 是队列中的一个元素。
//
// Value 是业务载荷(任务名、工单号、订单 ID......);Priority 是优先级,
// **数字越大越先被处理**。
type Item struct {
  Value    string
  Priority int
  index    int // 在堆中的下标,由 heap.Interface 的实现维护;-1 表示不在堆中
}

// PriorityQueue 是 Item 的优先队列,堆顶((*pq)[0])永远是优先级最高的元素。
type PriorityQueue []*Item

func (pq PriorityQueue) Len() int { return len(pq) }

// Less 决定「谁排前面」,也就是「谁先出队」。
//
// ⚠️注意这里用的是 `>` 而不是 `<`:
// container/heap 弹出的是 Less 意义上的「最小元素」,我们让「优先级大」等价于「更小」,
// 于是堆顶就成了优先级最高的元素 ------ 这就把小顶堆变成大顶堆了。
func (pq PriorityQueue) Less(i, j int) bool {
  return pq[i].Priority > pq[j].Priority
}

// Swap 是堆算法唯一会移动元素的地方(上浮/下沉都靠它),
// 所以反向索引只需要在这一个地方维护。
func (pq PriorityQueue) Swap(i, j int) {
  pq[i], pq[j] = pq[j], pq[i]
  pq[i].index = i
  pq[j].index = j
}

// Push 只负责「放到末尾」,上浮由 heap.Push 完成。
func (pq *PriorityQueue) Push(x any) {
  n := len(*pq)
  item := x.(*Item)
  item.index = n
  *pq = append(*pq, item)
}

// Pop 只负责「删掉末尾并返回」,下沉由 heap.Pop 完成。
func (pq *PriorityQueue) Pop() any {
  old := *pq
  n := len(old)
  item := old[n-1]
  old[n-1] = nil  // 元素是指针,置 nil 才能让被删元素被 GC 回收
  item.index = -1 // 标记「已不在堆中」,Update/Cancel 据此判断
  *pq = old[:n-1]
  return item
}

// 编译期断言:签名写错时立刻报错,而不是等到运行时 panic。
var _ heap.Interface = (*PriorityQueue)(nil)

// ---------------------------------------------------------------- 业务 API

// Enqueue 入队。
func (pq *PriorityQueue) Enqueue(item *Item) {
  heap.Push(pq, item)
}

// Dequeue 出队(优先级最高者)。队列为空时返回 nil。
func (pq *PriorityQueue) Dequeue() *Item {
  if pq.Len() == 0 {
    return nil
  }
  return heap.Pop(pq).(*Item)
}

// Peek 只看队首(优先级最高者),不改变队列。空队列返回 nil。
func (pq *PriorityQueue) Peek() *Item {
  if pq.Len() == 0 {
    return nil
  }
  return (*pq)[0]
}

// Update 修改优先级并修复堆序。
//
// ⚠️ 直接写 item.Priority = x 而不调用本方法,堆序会被破坏(静默出错):
// 元素在数组里的位置没变,但它和父/子的大小关系可能已经不成立了。
func (pq *PriorityQueue) Update(item *Item, priority int) {
  item.Priority = priority
  heap.Fix(pq, item.index) // 比 Remove + Push 便宜
}

// Cancel 把某个元素从队列中移除。O(log n)
//
// 返回 false 表示它已经不在队列中(已出队或已被取消)。
func (pq *PriorityQueue) Cancel(item *Item) bool {
  // 用「下标有效 + 指针身份一致」双重校验,避免误删别人
  if item.index < 0 || item.index >= pq.Len() || (*pq)[item.index] != item {
    return false
  }
  heap.Remove(pq, item.index)
  return true
}

// dump 打印堆的内部数组(注意:这是堆序,不是优先级顺序)。
func dump(label string, pq *PriorityQueue) {
  parts := make([]string, 0, pq.Len())
  for _, it := range *pq {
    parts = append(parts, fmt.Sprintf("%s(p=%d,idx=%d)", it.Value, it.Priority, it.index))
  }
  fmt.Printf("  %s: [%s]\n", label, strings.Join(parts, ", "))
}

func section(n int, title string) {
  fmt.Printf("\n=== %d. %s ===\n", n, title)
}

func main() {
  section(3, "优先级队列:数字越大越先处理")

  taskA := &Item{Value: "任务A", Priority: 3}
  taskB := &Item{Value: "任务B", Priority: 1}
  taskC := &Item{Value: "任务C", Priority: 5}
  taskD := &Item{Value: "任务D", Priority: 2}
  taskE := &Item{Value: "任务E", Priority: 4}

  // ① 批量建堆:模拟「从数据库恢复一批待办任务」
  pq := make(PriorityQueue, 0, 5)
  for i, it := range []*Item{taskA, taskB, taskC} {
    it.index = i // ⚠️ 直接批量建堆时必须自己把下标设对,否则 Update/Cancel 会定位错元素
    pq = append(pq, it)
  }
  heap.Init(&pq) // O(n) 原地建堆,比逐个 Push 的 O(n log n) 更快
  dump("批量建堆 A=3 B=1 C=5", &pq)

  // ② 逐个入队
  pq.Enqueue(taskD)
  pq.Enqueue(taskE)
  dump("入队 D=2 E=4 后    ", &pq)

  // ③ Peek:只看不弹,O(1)
  if top := pq.Peek(); top != nil {
    fmt.Printf("队首(只看不弹)= %s(优先级 %d)\n", top.Value, top.Priority)
  }

  // ④ 动态提升优先级:A 从 3 提到 6,一次 Fix 归位
  pq.Update(taskA, 6)
  top := pq.Peek()
  fmt.Printf("A 的优先级 3 → 6,队首变为 %s(优先级 %d)\n", top.Value, top.Priority)

  // ⑤ 取消任务
  fmt.Printf("取消 D(优先级 %d)→ %v\n", taskD.Priority, pq.Cancel(taskD))
  fmt.Printf("   再取消一次 D → %v(已不在队列中,幂等返回 false)\n", pq.Cancel(taskD))

  // ⑥ 依次出队:优先级高的先出
  fmt.Print("依次出队:")
  for it := pq.Dequeue(); it != nil; it = pq.Dequeue() {
    fmt.Printf("%s(p=%d) ", it.Value, it.Priority)
  }
  fmt.Println()

  // ⑦ 空队列是安全的
  fmt.Printf("队列已空:Peek()=%v  Dequeue()=%v  Len()=%d\n", pq.Peek(), pq.Dequeue(), pq.Len())
}

运行结果:

heap.Remove() 方法是 Go 语言标准库提供的方法,作用是删除堆中指定索引位置的元素,同时维护堆的结构和堆序性,并返回被删除的元素。

总结

堆本质上是用数组存储的完全二叉树 :节点之间不需要指针,靠 parent = (i-1)/2、left = 2*i+1、right = 2*i+2 三个公式就能定位。

Go 采用接口 + 组合 的设计:heap.Interface 组合了 sort.Interface,所以我们只要为自己的切片类型实现 Len、Less、Swap、Push、Pop 这 5 个方法,就能复用标准库的建堆与堆化算法(Init、 上浮/下沉)。

其中 Less 是唯一决定"谁先出队"的方法------写 < 是小顶堆,写 > 就是大顶堆,这也是堆能灵活支撑不同业务优先级的关键。

只要掌握了这套接口,就能轻松实现优先队列 、Top-K 、任务调度等场景。

相关推荐
刚子编程11 小时前
我用 ASP.NET Core 做了个水稻病虫害检查系统
后端·asp.net
谢亮_vipxieliang11 小时前
用 PHP 构建轻量级 REST API:从路由到鉴权的完整实践
开发语言·后端·php
码事漫谈14 小时前
三步改掉 AI 味,附可直接复制的去 AI 味提示词
后端
IT_陈寒14 小时前
Redis卡顿的锅,这次真不是大key的错
前端·人工智能·后端
可乐鸡翅yeah_14 小时前
video.js 集成 hls.js 开发 M3U8 播放器,新手高频踩坑
开发语言·前端·javascript·后端·ecmascript·m3u8·音视频在线播放
程序猿_极客14 小时前
【免费】分享一套优质的基于SpringBoot的服装商城管理系统的设计与实现(带可视化图表、协同过滤功能),源码+文档+视频详解(讲解)
java·spring boot·后端·服装商城管理系统
明月_清风15 小时前
面对陌生的 GitHub 项目无从下手?这 4 个网站帮你快速读懂源码
前端·后端·github
bug菌15 小时前
🤔同事突然问我:Spring的注解 @Component 和 @Service 有何不同?
java·spring boot·后端