如果你写过 JavaScript,可能见过类似这样的代码:
js
const queue = [];
queue.push(task);
queue.sort((a, b) => a.time - b.time);
const next = queue.shift();
比如实现一个定时任务队列、消息队列、优先级队列。
刚开始这样写没问题,但是任务越来越多时:
- 每次插入都需要排序
- 每次取任务都需要移动数组元素
- 时间复杂度越来越高
有没有一种数据结构,可以:
- 快速插入任务
- 快速找到优先级最高的任务
- 不需要每次重新排序?
答案就是:堆(Heap)
Go 标准库已经帮我们实现了一套堆:
go
container/heap
今天我们就通过源码,看看 Go 是如何实现堆的。
什么是堆?
堆是一种特殊的完全二叉树。
它满足一个规则:
对于小顶堆,父节点永远小于子节点:
text
1
/ \
3 5
/ \
8 10
根节点永远是最小值。
对于大顶堆,父节点永远大于子节点:
text
10
/ \
8 5
/ \
3 1
根节点永远是最大值。
很多场景都会使用堆:
- 定时任务调度
- Top K 问题
- 优先队列
堆为什么适合用数组存储?
很多初学者会想:
二叉树不是应该用 Node 节点 + left/right 指针吗?
例如:
js
class Node {
value;
left;
right;
}
但是堆一般不用这种方式。
Go 的 heap 使用数组:
例如:
go
[]int{1,3,5,8,10}
对应:
text
1
/ \
3 5
/ \
8 10
数组:
text
index:
0 1 2 3 4
1 3 5 8 10
为什么可以?
因为完全二叉树非常规律。
假设节点下标:
text
父节点:
i
左孩子:
2*i + 1
右孩子:
2*i + 2
例如:
节点:
text
index=1
value=3
左孩子索引为:
text
2*1+1=3
对应:
text
8
右孩子索引为:
text
2*1+2=4
对应:
text
10
所以:
堆本质就是一个按照规则排列的数组。
Go heap 的核心设计
打开源码:
text
src/container/heap/heap.go
可以看到:
go
type Interface interface {
sort.Interface
Push(x any)
Pop() any
}
这里非常 Go。
Go 没有:
java
Heap<T>
这种泛型容器设计。
它使用接口 + 组合的方式设计堆。
接口(interface)负责定义"能力",组合(composition)负责复用已有能力。Go 不依赖传统面向对象的继承,而是通过这两种方式实现代码复用和扩展。
通过 Interface 接口定义堆需要实现的方法,组合 sort.Interface 接口,复用排序的能力。
heap.Interface 继承了什么?
看:
go
type Interface interface {
sort.Interface
Push(x any)
Pop() any
}
因为 sort.Interface 接口定义:
go
type Interface interface {
Len() int
Less(i,j int) bool
Swap(i,j int)
}
所以完整接口:
实际上要求实现:
go
Len()
Less()
Swap()
Push()
Pop()
也就是:
| 方法 | 作用 |
|---|---|
| Len | 数组长度 |
| Less | 比较大小 |
| Swap | 交换元素 |
| Push | 添加元素 |
| Pop | 删除元素 |
为什么 Go 不直接实现 int 堆?
比如:
go
Heap([]int)
为什么不这样?
因为不同场景比较规则不同。
例如:
任务队列:
go
type Task struct {
Name string
Time int
}
可能按照:
go
Time
排序。
排行榜:
go
Score
排序。
文件:
go
Size
排序。
所以 Go 只实现堆算法 而把元素比较规则交给开发者。
以上就是 Go 语言中堆的接口设计。
实现一个小顶堆
例如:
任务:
go
type Task struct {
Name string
Priority int
}
优先级越小越先执行。
实现:
go
type TaskHeap []*Task
需要实现 heap.Interface 接口中定义的 5 个方法。
Len
go
func (h TaskHeap) Len() int {
return len(h)
}
告诉 heap:
有多少元素。
Less
重点:
go
func (h TaskHeap) Less(i,j int) bool {
return h[i].Priority < h[j].Priority
}
Less 方法决定谁在前面。
这里:
go
true
表示:
i 应该排在 j 前面。
Swap
go
func (h TaskHeap) Swap(i,j int){
h[i],h[j]=h[j],h[i]
}
交换元素。
Push
go
func (h *TaskHeap) Push(x any){
*h=append(*h,x.(*Task))
}
注意:
这里为什么是指针?
因为:
append 可能改变 slice (切片)地址。
Pop
go
func (h *TaskHeap) Pop() any{
old:=*h
n:=len(old)
item:=old[n-1]
*h=old[:n-1]
return item
}
注意这里删除的是最后一个元素。
为什么?
因为 heap 内部删除时不会直接删除根节点。
具体后面会讲。
heap.Push 源码分析
使用:
go
heap.Push(h,item)
heap.Push 在 Go 标准库中的源码如下:
go
func Push(h Interface, x any) {
h.Push(x)
up(h, h.Len()-1)
}
两个动作:
第一步
添加元素到数组末尾。
例如:
原来:
text
2
/ \
5 8
数组:
text
[2,5,8]
加入:
text
1
先放最后:
text
[2,5,8,1]
现在:
text
2
/ \
5 8
/
1
违反小顶堆规则。
第二步:上浮
调用 up() 函数。
在 Go 语言标准库中,up() 函数源码:
go
func up(h Interface, j int) {
for {
i := (j - 1) / 2 // parent
if i == j || !h.Less(j, i) {
break
}
h.Swap(i, j)
j = i
}
}
重点来了。
上浮算法(up())分析
假设:
text
2
/ \
5 8
/
1
index:
text
[2,5,8,1]
0 1 2 3
新节点:
text
index=3
计算父节点:
公式:
text
(parent)
(i-1)/2
所以:
text
(3-1)/2
=1
父节点:
text
5
比较:
text
1 < 5
交换:
text
2
/ \
1 8
/
5
继续:
index:
text
1
父节点:
text
(1-1)/2
=0
比较:
text
1 < 2
交换:
text
1
/ \
2 8
/
5
完成。
上浮不断地将比父节点小的子节点交换到父节点位置完成堆化。
heap.Pop 源码分析
heap.Pop 方法在 Go 标准库中的源码实现为:
go
func Pop(h Interface) any {
n := h.Len() - 1
h.Swap(0, n)
down(h, 0, n)
return h.Pop()
}
heap.Pop 方法的设计很巧妙。他不是直接删除 index 为 0 的元素,而是将 index 为 0 的元素与最后一个元素交换位置。
然后调用 down() (下沉算法)恢复堆的性质。
最后调用用户实现的 h.Pop() 方法,移除并返回末尾元素。
在 javascript 中,删除堆顶可以使用 shift() 方法:
javascript
const arr = [1, 3, 5, 8, 10];
const result = arr.shift();
shift() 会删除第一个元素,并将后面的元素向前移动。
但是在 Go 中,通过交换收尾元素,然后删除尾部元素,实现删除堆顶元素,避免了将所有元素整体向前搬移。
这也是堆能够高效删除堆顶的原因。
下沉算法(down())分析
在 Go 语言标准库中,down() 函数源码:
go
func down(h Interface, i0, n int) bool {
i := i0
for {
j1 := 2*i + 1
if j1 >= n || j1 < 0 { // j1 < 0 after int overflow
break
}
j := j1 // left child
if j2 := j1 + 1; j2 < n && h.Less(j2, j1) {
j = j2 // = 2*i + 2 // right child
}
if !h.Less(j, i) {
break
}
h.Swap(i, j)
i = j
}
return i > i0
}
核心是不断让父节点和更小的孩子交换。
heap.Init 源码分析
heap.Init 方法的作用是建堆,把一段乱序数据原地整理成合法的堆。
heap.Init 方法在 Go 标准库中的源码为:
go
func Init(h Interface) {
// heapify
n := h.Len()
for i := n/2 - 1; i >= 0; i-- {
down(h, i, n)
}
}
从源码中可以看到,heap.Init 方法是从 i = n/2 - 1 开始,从大到小,倒着执行下层算法建堆。
堆是用数组存的完全二叉树,节点 i 的孩子在下标 2i+1 和 2i+2。所以:
节点
i有孩子 ⟺2i+1 <= n-1⟺i <= (n-2)/2
而整数运算下 (n-2)/2 == n/2 - 1,所以 n/2 - 1 就是最后一个有孩子的节点。
下标 n/2 ~ n-1 的那些节点全是叶子(一个孩子都没有)。
heap.Init 方法是从 i = n/2 - 1 开始,从大到小,倒着执行下层算法建堆,跳过了叶子节点。
为什么跳过叶子? 因为叶子天然就是合法的堆(单节点堆没有父子关系要检查),对它们调 down 纯属浪费。
container/heap 源码设计思想总结
读完源码,可以发现 Go 的堆实现非常简洁。
核心只有三个东西:
1. 数组模拟完全二叉树
text
parent=(i-1)/2
left=2*i+1
right=2*i+2
2. 两个核心算法
上浮算法 up() 和 下沉算法 down() ,维护堆的性质。
3. 接口实现算法复用
Go 不关心:
你存什么。
可以是:
go
int
也可以:
go
Task
只需要告诉它:
go
Less()
如何比较。
总结
用一句话理解 Go 的堆:
Go 的 container/heap 本质是:利用数组模拟完全二叉树,通过 Less 定义优先级,再使用上浮(up)和下沉(down)算法维护堆结构。