从libuv源码中学习最小二叉堆

1,632 阅读6分钟

阅读本文你需具备知识点:

  • 二叉查找树(js版本的简单实现可以参考二叉查找树的简单学习)
  • 准备纸和笔(自己动手画一画,这样方能真的理解)

1、libuv如何使用最小二叉堆?

libuv将最小二叉堆的算法应用到了timer上,我们先回顾一下timer的使用:

uv_timer_t timer_handle;
r = uv_timer_init(loop, &timer_handle);
// 每10秒钟调用定时器回调一次
r = uv_timer_start(&timer_handle, timer_cb, 10 * 1000, 10 * 1000);

当我们每调用一次uv_timer_start的时候,libuv都会往最小二叉堆中插入一条定时器信息,如下:

int uv_timer_start(uv_timer_t* handle,
                   uv_timer_cb cb,
                   uint64_t timeout,
                   uint64_t repeat) {
  ... ...
  heap_insert(timer_heap(handle->loop),
              (struct heap_node*) &handle->heap_node,
              timer_less_than);
  ... ...
}

当调用uv_timer_stop的时候,libuv都会删除一条定时器信息:

int uv_timer_stop(uv_timer_t* handle) {
  if (!uv__is_active(handle))
    return 0;

  heap_remove(timer_heap(handle->loop),
              (struct heap_node*) &handle->heap_node,
              timer_less_than);
  uv__handle_stop(handle);

  return 0;
}

为什么用最小二叉堆呢?因为它永远把最小值放在了根节点,而这里的最小值就是定时器最先到时间点的那一组,所以为了查询效率,采用了这么一种算法:

void uv__run_timers(uv_loop_t* loop) {
  ... ...

  for (;;) {
    heap_node = heap_min(timer_heap(loop));
    if (heap_node == NULL)
      break;

    handle = container_of(heap_node, uv_timer_t, heap_node);
    if (handle->timeout > loop->time)
      break;

    ... ...
  }
}

libuv的最小二叉堆的实现源码在这里:heap-inl.h

接下去,我们开始从libuv的源码中学习最小二叉堆的知识,为了让大家不至于那么陌生,将C语言实现版本转换为Js版本,我们会一遍讲解理论,一边代码实现。

2、二叉堆的基本概念

首先我们得知道二叉堆的定义:二叉堆是一棵完全二叉树,且任意一个结点的键值总是小于或等于其子结点的键值。

那么什么是完全二叉树(complete binary tree)呢?我们先来看一下关于的数据结构都有哪些?

2.1、完全二叉树

定义是:

对于一个树高为h的二叉树,如果其第0层至第h-1层的节点都满。如果最下面一层节点不满,则所有的节点在左边的连续排列,空位都在右边。这样的二叉树就是一棵完全二叉树。

如下图所示:

正因为完全二叉树的独特性质,因此其数据可以使用数组来存储,而不需要使用特有的对象去链接左节点和右节点。因为其左右节点的位置和其父节点位置有这样的一个计算关系:

k表示父节点的索引位置
left = 2 * k + 1
right = 2 * k + 2

2.2、最小(大)二叉堆

知道了完全二叉树,那么二叉堆的这种神奇的数据结构就是多了一个硬性条件:任意一个结点的键值总是小于(大于)或等于其子结点的键值。因为其存储结构不是使用左右节点互相链接的形式,而是使用简单的数组,所以称之为”堆“,但是基于完全二叉树,因此又带上了”二叉“两字。

那么有了上面的特征,当我们插入或者删除某个值的时候,为了保持二叉堆的特性,于是又出现了一些二叉堆稳定的调整算法,具体在下面讲解。

3、二叉堆的基本操作

搞懂二叉堆的插入和删除操作,我们先得掌握两个基本操作:一个是从顶向下调整堆(bubble down),一个自底向上调整堆(bubble up),二者的调整分别用于二叉堆的删除和插入。

3.1、自顶向下调整

这个操作其实就是根据父节点的位置,往下寻找符合条件的子节点,不断地交换直到找到节点大于父节点,示意图如下:

实现代码如下:

bubbleDown(array, parentIndex, length) {
  // 可以看出这种向下调整的操作是以父节点找子节点的行为
  let childIndex = parentIndex * 2 + 1
  // parentIndex的值会和子节点、孙子节点等节点进行比较,直到找到属于自己的位置
  let temp = array[parentIndex]
  while (childIndex < length) {

    if (childIndex + 1 < length && array[childIndex + 1] < array[childIndex]) {
      childIndex += 1
    }
    // 子节点都比父节点大,此时的位置便是父节点的最终位置
    if (temp <= array[childIndex]) {
      break
    }

    array[parentIndex] = array[childIndex]
    // 将替换的节点作为父节点,继续遍历下面的子节点
    parentIndex = childIndex
    childIndex = childIndex * 2 + 1
  }
  array[parentIndex] = temp
}

3.2、自底向上调整

这种调整是当插入一个新值的时候,为了保证二叉堆的特性,需要从该新插入的子节点中一步步与父节点判断,不断交换位置,直到整个二叉堆满足特性。示意图如下:

代码实现如下:

bubbleUp(array) {
  // 可以看出这种向上调整的操作是以子节点找父节点的行为
  let childIndex = array.length - 1

  let parentIndex = Math.floor((childIndex - 1) / 2)

  let temp = array[childIndex]
  // 查找父节点大于子节点的话,继续往上
  while(childIndex > 0 && array[parentIndex] > temp) {
    array[childIndex] = array[parentIndex]
    childIndex = parentIndex
    parentIndex = Math.floor((parentIndex - 1) / 2)
  }

  array[childIndex] = temp
}

4、插入和删除

有了上面的两种操作,插入的删除的实现就顺理成章了。只需要这么调用上面的两个操作:

插入操作

insert(newNode) {
  // 插入节点操作,放在最后面,然后进行自下而上的操作
  this.heap.push(newNode)
  this.length++
  this.bubbleUp(this.heap)
}

删除操作

这里的删除都是删除根节点,然后再把最后一个节点的数拿到根节点,之后再自上而下调整整个二叉堆。

remove() {
  // 二叉堆的删除操作指的是删除根节点,之后自稳定
  if (this.length === 1) {
    return this.heap[0]
  }
  const lastEle = this.heap.pop()
  const top = this.heap[0]
  this.heap[0] = lastEle
  this.length--
  this.bubbleDown(this.heap, 0, this.length)
  return top
}

完整代码展示如下:

const array = [8, 10, 4342, 2, 34, 18, 11, 77, 16, 66]

class MinBinaryHeap {
  constructor(array) {
    this.heap = [...array]
    this.length = this.heap.length

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

  bubbleDown(array, parentIndex, length) {
  	// 可以看出这种向下调整的操作是以父节点找子节点的行为
    let childIndex = parentIndex * 2 + 1
    // parentIndex的值会和子节点、孙子节点等节点进行比较,直到找到属于自己的位置
    let temp = array[parentIndex]
    while (childIndex < length) {

      if (childIndex + 1 < length && array[childIndex + 1] < array[childIndex]) {
        childIndex += 1
      }
      // 找到了有一个节点比父节点大,此时的位置便是父节点的最终位置
      if (temp <= array[childIndex]) {
        break
      }

      array[parentIndex] = array[childIndex]
      // 将替换的节点作为父节点,继续遍历下面的子节点
      parentIndex = childIndex
      childIndex = childIndex * 2 + 1
    }
    array[parentIndex] = temp
  }
  bubbleUp(array) {
  	// 可以看出这种向上调整的操作是以子节点找父节点的行为
  	let childIndex = array.length - 1

    let parentIndex = Math.floor((childIndex - 1) / 2)

    let temp = array[childIndex]
    while(childIndex > 0 && array[parentIndex] > temp) {
    	array[childIndex] = array[parentIndex]
      childIndex = parentIndex
      parentIndex = Math.floor((parentIndex - 1) / 2)
    }

    array[childIndex] = temp
  }
  insert(newNode) {
  	// 插入节点操作,放在最后面,然后进行自下而上的操作
    this.heap.push(newNode)
    this.length++
    this.bubbleUp(this.heap)
  }
  print() {
  	return this.heap
  }
  remove() {

  	// 二叉堆的删除操作指的是删除根节点,之后自稳定
    if (this.length === 1) {
    	return this.heap[0]
    }
    const lastEle = this.heap.pop()
    const top = this.heap[0]
    this.heap[0] = lastEle
    this.length--
    this.bubbleDown(this.heap, 0, this.length)
    return top
  }
  sort() {
  	const result = []
    let i = this.heap.length
    while (i > 0) {
    	result.push(this.remove())
      i -= 1
    }
    return result
  }
}

const heap = new MinBinaryHeap(array)
console.log('最小二叉堆是:', heap.print())

heap.insert(5)

console.log('插入5之后的最小二叉堆是:', heap.print())

heap.remove()

console.log('删除根节点之后的最小二叉堆是:', heap.print())

console.log('二叉堆进行堆排序结果:', heap.sort())

console.log(heap.print())

参考

  1. js数据结构-二叉树(二叉堆)
  2. 最大堆的虚拟化
  3. 最小堆的虚拟化