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

8 阅读7分钟

如果你写过 JavaScript,可能见过类似这样的代码:

const queue = [];
queue.push(task);
queue.sort((a, b) => a.time - b.time);
const next = queue.shift();

比如实现一个定时任务队列、消息队列、优先级队列。

刚开始这样写没问题,但是任务越来越多时:

  • 每次插入都需要排序
  • 每次取任务都需要移动数组元素
  • 时间复杂度越来越高

有没有一种数据结构,可以:

  • 快速插入任务
  • 快速找到优先级最高的任务
  • 不需要每次重新排序?

答案就是:堆(Heap)

Go 标准库已经帮我们实现了一套堆:

container/heap

今天我们就通过源码,看看 Go 是如何实现堆的。

什么是堆?

堆是一种特殊的完全二叉树。

它满足一个规则:

对于小顶堆,父节点永远小于子节点:

        1
       / \
      3   5
     / \
    8   10

根节点永远是最小值。

对于大顶堆,父节点永远大于子节点:

        10
       /  \
      8    5
     / \
    3   1

根节点永远是最大值。

很多场景都会使用堆:

  • 定时任务调度
  • Top K 问题
  • 优先队列

堆为什么适合用数组存储?

很多初学者会想:

二叉树不是应该用 Node 节点 + left/right 指针吗?

例如:

class Node {
  value;
  left;
  right;
}

但是堆一般不用这种方式。

Go 的 heap 使用数组:

例如:

[]int{1,3,5,8,10}

对应:

          1
        /   \
       3     5
      / \
     8  10

数组:

index:

0   1   2   3   4

1   3   5   8   10

为什么可以?

因为完全二叉树非常规律。

假设节点下标:

父节点:
i

左孩子:
2*i + 1

右孩子:
2*i + 2

例如:

节点:

index=1
value=3

左孩子索引为:

2*1+1=3

对应:

8

右孩子索引为:

2*1+2=4

对应:

10

所以:

堆本质就是一个按照规则排列的数组。

Go heap 的核心设计

打开源码:

src/container/heap/heap.go

可以看到:

type Interface interface {
	sort.Interface
	Push(x any)
	Pop() any
}

这里非常 Go。

Go 没有:

Heap<T>

这种泛型容器设计。

它使用接口 + 组合的方式设计堆。

接口(interface)负责定义“能力”,组合(composition)负责复用已有能力。Go 不依赖传统面向对象的继承,而是通过这两种方式实现代码复用和扩展。

通过 Interface 接口定义堆需要实现的方法,组合 sort.Interface 接口,复用排序的能力。

heap.Interface 继承了什么?

看:

type Interface interface {
	sort.Interface
	Push(x any)
	Pop() any
}

因为 sort.Interface 接口定义:

type Interface interface {
	Len() int
	Less(i,j int) bool
	Swap(i,j int)
}

所以完整接口:

实际上要求实现:

Len()
Less()
Swap()
Push()
Pop()

也就是:

方法作用
Len数组长度
Less比较大小
Swap交换元素
Push添加元素
Pop删除元素

为什么 Go 不直接实现 int 堆?

比如:

Heap([]int)

为什么不这样?

因为不同场景比较规则不同。

例如:

任务队列:

type Task struct {
	Name string
	Time int
}

可能按照:

Time

排序。

排行榜:

Score

排序。

文件:

Size

排序。

所以 Go 只实现堆算法而把元素比较规则交给开发者。

以上就是 Go 语言中堆的接口设计。

实现一个小顶堆

例如:

任务:

type Task struct {
	Name string
	Priority int
}

优先级越小越先执行。

实现:

type TaskHeap []*Task

需要实现 heap.Interface 接口中定义的 5 个方法。

Len

func (h TaskHeap) Len() int {
	return len(h)
}

告诉 heap:

有多少元素。

Less

重点:

func (h TaskHeap) Less(i,j int) bool {
	return h[i].Priority < h[j].Priority
}

Less 方法决定谁在前面。

这里:

true

表示:

i 应该排在 j 前面。

Swap

func (h TaskHeap) Swap(i,j int){
	h[i],h[j]=h[j],h[i]
}

交换元素。

Push

func (h *TaskHeap) Push(x any){
	*h=append(*h,x.(*Task))
}

注意:

这里为什么是指针?

因为:

append 可能改变 slice (切片)地址。

Pop

func (h *TaskHeap) Pop() any{
	old:=*h
	n:=len(old)
	item:=old[n-1]
	*h=old[:n-1]
	return item
}

注意这里删除的是最后一个元素。

为什么?

因为 heap 内部删除时不会直接删除根节点。

具体后面会讲。

heap.Push 源码分析

使用:

heap.Push(h,item)

heap.Push 在 Go 标准库中的源码如下:

func Push(h Interface, x any) {
	h.Push(x)
	up(h, h.Len()-1)
}

两个动作:

第一步

添加元素到数组末尾。

例如:

原来:

      2
     / \
    5   8

数组:

[2,5,8]

加入:

1

先放最后:

[2,5,8,1]

现在:

       2
      / \
     5   8
    /
   1

违反小顶堆规则。

第二步:上浮

调用 up() 函数。

在 Go 语言标准库中,up() 函数源码:

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())分析

假设:

       2
      / \
     5   8
    /
   1

index:

[2,5,8,1]

 0 1 2 3

新节点:

index=3

计算父节点:

公式:

(parent)

(i-1)/2

所以:

(3-1)/2

=1

父节点:

5

比较:

1 < 5

交换:

       2
      / \
     1   8
    /
   5

继续:

index:

1

父节点:

(1-1)/2

=0

比较:

1 < 2

交换:

       1
      / \
     2   8
    /
   5

完成。

上浮不断地将比父节点小的子节点交换到父节点位置完成堆化。

heap.Pop 源码分析

heap.Pop 方法在 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() 方法:

const arr = [1, 3, 5, 8, 10];

const result = arr.shift();

shift() 会删除第一个元素,并将后面的元素向前移动。

但是在 Go 中,通过交换收尾元素,然后删除尾部元素,实现删除堆顶元素,避免了将所有元素整体向前搬移。

这也是堆能够高效删除堆顶的原因。

下沉算法(down())分析

在 Go 语言标准库中,down() 函数源码:

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 标准库中的源码为:

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. 数组模拟完全二叉树

parent=(i-1)/2

left=2*i+1

right=2*i+2

2. 两个核心算法

上浮算法 up() 和 下沉算法 down() ,维护堆的性质。

3. 接口实现算法复用

Go 不关心:

你存什么。

可以是:

int

也可以:

Task

只需要告诉它:

Less()

如何比较。

总结

用一句话理解 Go 的堆:

Go 的 container/heap 本质是:利用数组模拟完全二叉树,通过 Less 定义优先级,再使用上浮(up)和下沉(down)算法维护堆结构。