Go源码分析:搞懂 Go 是如何实现堆的

如果你写过 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)算法维护堆结构。

相关推荐
调试人生的显微镜4 小时前
iOS开发入门:Interface Builder、基础控件及UITextField详解
后端·ios
泡海椒4 小时前
巡检照片与工单附件:jquick-pdf 图片嵌入的业务实战
后端
蜗牛互联网4 小时前
Python消费Responses SSE事件:增量文本、超时与取消
java·开发语言·人工智能·后端·python
AI持续学习4 小时前
文档权限变更如何回归测试?账号、空间、文件和分享链接用例清单
后端
谢亮_vipxieliang5 小时前
Go Worker Pool 设计——从原理到生产级实现
开发语言·后端·golang
IT_陈寒5 小时前
为什么你应该学习JavaScript?
前端·人工智能·后端
lightning_bug5 小时前
Windows系统Docker+SpringBoot+H5+Mysql+Redis打包部署流程
后端·架构
SingleShadow5 小时前
一文搞懂 CAN 总线:从入门到 STM32 应用
后端
码事漫谈6 小时前
国庆七天,AI圈没一天消停
后端