05.1-堆(Heap)与优先队列(堆的数据结构基础篇)

120 阅读16分钟

堆(Heap)与优先队列

引申知识

何为数据结构

数据结构可以拆借为两部分:结构定义 + 结构操作

拿今天要讲的大顶堆来举个例子:

结构定义:

一种具有任意根节点的值的大小都大于他的左右子树根节点大小的完全二叉树性质的数据结构

结构操作:

插入、弹出操作

结构操作的根本目的就是为了维护结构定义的结构,也就是说,在插入和弹出一个大顶堆的节点时,我们始终都要确保这个大顶堆的性质不变,如果改变了,就要想办法调整回来。

总结:

数据结构就是定义一种性质(原则),并维护这种性质,我们的任何操作都必须始终遵循我们定义的这个性质(原则)进行操作,否则,数据结构就失去了他的意义

PS:

当我们在学习一个新的数据结构的时候,我们首先要弄明白,你要学习的这个数据结构,他有什么性质,对这个数据结构进行操作的时候,他是怎么维护这个性质的。例如,当我们在学习这样的数据结构的时候:

栈的性质: 先入后出(FILO)

维护性质: 始终从顶部入栈,也从顶部出栈,以此来确保先入后出的性质

堆的基础知识

完全二叉树知识回顾

之前分享的文章中我们有讨论过完全二叉树的一些基本的概念与特性,在这里不就详述了,如果不了解的同学,可以先去看看之前的文章你真的了解二叉树吗(树形结构基础篇)。之所以在这里要提起完全二叉树,那是因为今天的主角完全二叉树之间有一种特殊的“暧昧”关系,了解完全二叉树的一些基础知识和特性,有助于您更好的理解

我们知道,完全二叉树的子节点编号是可以根据父节点编号计算得到的,借助这个特性,我们可以使用连续的存储区数组来存储每一个节点。假如一个完全二叉树的编号是从1开始的,那么,如果目标节点的编号是i,他的左子树的根节点编号为2 * i,右子树根节点编号为2 * i +1。假如一个完全二叉树的编号从0开始的话,那么,如果目标节点的编号是i,他的左子树的根节点编号为2 * i + 1,右子树根节点编号为2 * i + 2。如下面两个分别是编号从1开始和编号从0开始的完全二叉树

# 编号从1开始
         1
       /   \
      2     3
     / \   / \
    4   5 6
    
# 存储与数组中的表示为
[,1,2,3,4,5,6]
    
# 编号从0开始
         0
       /   \
      1     2
     / \   / \
    3   4 5
    
# 存储于数组中的表示为
[0,1,2,3,4,5]

显然,为了存储于数组更方便,我们通常会使用第二种表示形式。那么,接下来出一个思考题:

下列数组是一个完全二叉树的表示形式,请根据数组还原这棵完全二叉树

[5,6,3,2,1,9,10,8,12,11]

这个问题其实很简单,按照完全二叉树的编号特性,我们很容易的就能把它还原出来:

# 数组元素:[5,6,3,2,1,9,10,8,12,11]
# 数组索引:[0,1,2,3,4,5, 6,7,8, 9]

             5
         /       \
        6         3
      /   \      / \
     2     1    9  10
    / \   /
   8  12 11

堆的性质

其实就是一个基于完全二叉树的一个数据结构

大顶堆

一棵完全二叉树中,如果任意的根节点的值大于他的左右子树的根节点的值的话,那么我们管这个数据结构叫做大顶堆。

因此,我们可以得出结论:一个大顶堆中,最大值就是我们的堆顶元素,也就是我们的根节点的值。

那么第二大的值就应该是:Max(根节点左子树根节点的值,根节点右子树根节点的值)。

那么,问题来了,第三大的值是什么呢?由于大顶堆中,只有父子之间才有明确的大小关系,父节点一定大于子节点,但是兄弟节点直接不存在大小关系。我们要找一个第三大的值,也就说明,应该有两个值是比这个值要大的。比如说第3层中的8,我们明确的知道,至少有1218肯定是大于他的,同理第三层中的4也是至少有1118是大于他的。但是,我们不要忘了11这个节点,他就得是11么,如果我们把它的数值换成7,同样也是一个合格大顶堆,但我们第三大的值就不再在第2层了,而是在第3层了。所以,我们的第三大的值,其实应该在第二层或者是第三层。

同理,第四大的值,应该在第二层或第三层或第四层中

第1层                18
第-层              /    \
第2层            12      11[7]
第-层           /  \    /  \
第3层          8    3  5    4
第-层         / \
第4层        1   2

小顶堆

一棵完全二叉树中,如果任意的根节点的值小于他的左右子树的根节点的值的话,那么我们管这个数据结构叫做小顶堆。

因此,我们可以得出结论:一个小顶堆中,最小值就是我们的堆顶元素,也就是我们的根节点的值。

那么第二小的值就应该是:Min(根节点左子树根节点的值,根节点右子树根节点的值)

其他特性其实跟大顶堆的特性一样,只是第几大变成了第几小而已

        1
     /     \
    2       3
  /   \    /  \
 4     5  6

堆的操作

堆的末尾插入调整

因为堆本质上是一个完全二叉树,因此,如果想要插入一个节点的话,必须在最后一层的最左侧插入节点,如下:

              18
          /        \
         12         11
       /    \     /    \
      8      3   5      4
     / \    /
    1   2 <10>
# 如上图中的<10>节点就是我们插入的新节点,其实本质上就是在我们的数组末尾插入一个节点
[18,12,11,8,3,5,4,1,2,<10>]

但是,我们可以发现,我们上面的堆刚开始是一个标准的大顶堆,但是,当我们把10插入到堆中后,就违反了大顶堆的性质:任意根节点的值大于他的左子树和右子树根节点的值,即3是比他的左子树根节点10要小了。因此,在插入新节点后,我们还得对我们的堆进行一定的调整,让他重新成为符合大顶堆小顶堆性质的堆。

那么,我们要怎么调整呢?其实也很简单,当我们插入一个元素之后,先拿这个元素的值跟他的根节点比较一下,看谁大,谁大谁就是爸爸,比如上图,当103大时,我们就应该让103调换位置,以前你是爸爸,现在该我当爸爸了,这也叫做向上调整上升操作。当然,这个反映在现实当中就是,当你实力不强时,你是乙方,只能被甲方爸爸肆意蹂躏,当你哪一天小宇宙爆发,比甲方爸爸还强时,角色就互换了,此时,可能你就是甲方爸爸,原来的甲方爸爸就变成了乙方。所以,奋斗吧,骚年!

# 上面的调整表现在树形结构的话
              18
          /        \
         12         11
       /    \     /    \
      8     <10>   5      4
     / \    /
    1   2  3
# 表现为数组
[18,12,11,8,<10>,5,4,1,2,3]

当然,上述的示例只是调整了一次,那么,如果我们插入的数字不是10,而是20呢,他会不断的跟他的父节点对比,直到发现它比根节点还大时,他就翻身农奴把歌唱了,他成为了整个族群的领袖了。

# 插入新节点20
              18
          /        \
         12         11
       /    \     /    \
      8      3   5      4
     / \    /
    1   2 <20>
[18,12,11,8,3,5,4,1,2,<20>]
# 第一次调整
              18
          /        \
         12         11
       /    \     /    \
      8     <20>   5      4
     / \    /
    1   2  3
[18,12,11,8,<20>,5,4,1,2,3]

# 第二次调整
              18
          /        \
        <20>         11
       /    \     /    \
      8     12   5      4
     / \    /
    1   2  3
[18,<20>,11,8,12,5,4,1,2,3]

# 第三次调整

             <20>
          /        \
         18         11
       /    \     /    \
      8     12   5      4
     / \    /
    1   2  3
[<20>,18,11,8,12,5,4,1,2,3]

# 经过上述三次调整之后,我们的堆有变成了一个合法的大顶堆了

堆顶弹出调整

堆弹出元素永远是弹出堆顶元素,也就是根节点。如果是一个大顶堆的话,弹出的元素就是整个堆的最大值,小顶堆弹出的元素则是整个堆的最小值

             <18>
          /        \
         12         11
       /    \     /    \
      8      3   5      4
     / \    /
    1   2  0
# 如上图,一个大顶堆要弹出一个元素,那么弹出的就是18,这个时候,18的位置就空了,那我们要怎么把这个位置补上呢?
[<18>,12,11,8,3,5,4,1,2,0]

元素弹出之后,我们得先把原先根节点留下的坑填上,那么,用谁来填呢?我们都清楚,其实堆就是一个特殊的完全二叉树,而完全二叉树只能在最后一层的右侧存在空节点,其他都应该是满的,因此,我们应该先把最后一层的最后一位0挪到原先根节点的位置先占个坑,这样,我们这棵树,又是一个标准的完全二叉树了。

但是,他还不是一个标准的大顶堆,因此,我们还需要进行一定的调整。我们可以每一次都让根节点和他的左右子树的根节点的值取一个最大值并与之交换位置,这种操作我们也成为向下调整下沉操作

# 原大顶堆
             <18>
          /        \
         12         11
       /    \     /    \
      8      3   5      4
     / \    /
    1   2  0

[<18>,12,11,8,3,5,4,1,2,0]

# 执行弹出操作

             
          /        \
         12         11
       /    \     /    \
      8      3   5      4
     / \    /
    1   2  0

[,12,11,8,3,5,4,1,2,0]

# 最下层最右边的元素放到根节点


             [0]
          /        \
         12         11
       /    \     /    \
      8      3   5      4
     / \    /
    1   2 

[[0],12,11,8,3,5,4,1,2]

# 进行第一轮的向下调整(下沉操作)
# 根节点与他的左右子树的最大值
Math.max(0, 12, 11) = 12
# 根节点与最大值12交换位置

              12
          /         \
         [0]         11
       /     \     /     \
      8       3   5       4
     / \     /
    1   2 

[12,[0],11,8,3,5,4,1,2]

# 进行第二轮调整
# 调整后[0]所在的节点与他的左右子树根节点的值取最大值
Math.math(0, 8, 3) = 8
# [0]与8交换位置

               12
           /         \
          8           11
       /     \     /     \
     [0]      3   5       4
     / \     /
    1   2 

[12,8,11,[0],3,5,4,1,2]

# 进行第三轮调整
# 调整后[0]所在的节点与他的左右子树根节点的值取最大值
Math.math(0, 1, 2) = 2
# [0]与2交换位置


               12
           /         \
          8           11
       /     \     /     \
      2       3   5       4
     / \     /
    1  [0]

[12,8,11,2,3,5,4,1,[0]]

# 经过三轮的调整,我们的大顶堆又回来了


堆排序

使用堆实现排序任务的一种方式

  1. 将堆顶元素和堆尾元素元素交换
  2. 将此操作看作是堆顶元素的弹出操作
  3. 按照头部弹出以后的策略调整堆
# 将以下大顶堆的每个元素从小到大排序
              18
          /        \
         12         11
       /    \     /    \
      8      3   5      4
     / \    /
    1   2  0
    
[18,12,11,8,3,5,4,1,2,0]

# 首先,将堆顶元素弹出,
已经弹出的元素:<18>
弹出后的数组:  [12,8,11,2,3,5,4,1,0,]

              12
          /        \
         8         11
       /    \    /    \
      2      3  5      4
     / \    /
    1   0

# 从上面我们可以看出,当把堆顶元素弹出,并将剩下的元素调整之后的新的大顶堆中,最后一个用来放元素的位置空着,这个位置其实算是一个游离元素,我们的大顶堆遍历的任何操作其实都不会操作到这个元素,因为这个元素应不属于堆的有效位置了,但他还是数组的有效空间,我们只会从索引为0的位置操作到索引为8的元素,所以,我们可以把弹出的元素放到这个数组的最末尾,这样,既不会影响堆的正常操作,又可以有效利用空间,无需额外开辟一个控件用来存储排序后的数据。
[12,8,11,2,3,5,4,1,0,<18>]
# 然后我们再重复上述的操作
               11
           /         \
          8           5
       /     \     /     \
      2       3  [0]       4
     / \     /
    1  

# 注意,之所以是<12>,<18>而不是<18>,<12>是因为我们弹出的最大值始终都是跟我们队尾元素交换位置的,也就是说,新弹出的值,应该放在堆的最后面,而在上述堆中,队尾元素是1,因此应该把新弹出的最大值<12>放在1的后面
[11, 8, 5, 2, 3, 0, 4, 1,<12>,<18>]
# 重复操作直至堆顶元素为空时
[<0>,<1>,<2>,<3>,<4>,<5>,<8>,<11>,<12>,<18>]

总结

无论是我们的向上调整(上升操作)还是向下调整(下沉操作),其实根本目的只有一个,就是在维护堆(大顶堆)的性质,让我们在操作堆的过程中,始终保证堆的性质不变,这又回到了本文刚开始的时候讲的引申知识数据结构就是定义一种性质(原则),并维护这种性质

优先队列

优先队列通常情况下是使用堆实现的,也就是说,堆是优先队列的一种实现方式。

普通队列与堆特点和性质的对比

普通队列(最大/最小)堆
尾部入队尾部可插入元素
头部出队头部可弹出元素
先进先出每次出队权值(最大值或最小值)
数组实现程序实现上使用数组,逻辑结构上看成一个堆

从上面的比较中我们不难看出,普通队列很像,都是从尾部添加元素,从头部弹出元素,所以我们才会拿队列 拿来作比较。当然,有自己的一个特点,这个特点就是:每次弹出的元素一定是这个的最值(最大值/最小值),如果我们把这个最大值或最小值看做是优先级的话,那么,在大顶堆结构中,每次弹出的元素一定是优先级最高的元素,所以我们才把优先队列当做是的一个别名,更准确的说:堆应该是优先队列的一种实现方式

所以,优先队列其实不是一种新的数据结构,而是我们变换了一种思维方式后对的一种叫法而已。

用Javascript实现一个大顶堆

class Heap {
    private arr: number[]=[];
    private count: number=0;
    private _shift_up(idx: number): void {
      // 然后对堆进行向上调整
      // 我们需要通过子节点坐标得到父节点的坐标,因为我们这边的下标是从0开始的,所以左子树根节点坐标为:2 * i + 1,右子树根节点坐标为:2 * i + 2。那么我们父节点的编号:parseInt((子节点编号 - 1) / 2),如知道左节点编号为:2 * i + 1,那么他的父节点坐标就是: parseInt((2 * i + 1 - 1) / 2) = parseInt(i);如果知道有节点的编号:2 * i + 2,那么他的父节点的坐标为:parseInt((2 * i + 2 - 1) / 2) = parseInt((2*i+1)/2) = i
      // 上面我们讲过,我们需要如果在一个大顶堆中,如果我们父节点的值小于子节点的值的话,就要交换这两个值
      while(idx && this.arr[parseInt(String((idx - 1) / 2))] < this.arr[idx]) {
        // 父节点编号
        const pIdx = parseInt(String((idx - 1) / 2));
        // 交换父节点和当前节点的值
        [ this.arr[pIdx], this.arr[idx] ] = [ this.arr[idx], this.arr[pIdx] ];
        // 然后再让idx变成父节点的编号,这样就能依次向上调整了
        idx = pIdx;
      }
    }
    private _shift_down(idx: number): void {
      // 交换之后,根节点再依次向下调整
      // 最大的子节点的下标
      let n = this.count - 1;
      // 当idx的子节点的下标比最大的子节点的下标小,就说明还有子节点,就要往下继续调整
      while(idx * 2 + 1 <= n) {
        // max代表在根节点、左子树、右子树中最大值的下标
        let max = idx;
        // 如果左子树的根节点的值比当前值大,就把max更新为左子树根节点的下标
        if(this.arr[max] < this.arr[2*idx+1]) max = 2*idx+1;
        // 如果右子树根节点的值比当前节点大,就把max更新为右子树根节点的下标
        // 需要注意的是,我们当前的节点,可能存在左子树,但不一定存在右子树,所以需要多加一个2*idx+2<=n的条件
        if(2*idx+2<=n && this.arr[max] < this.arr[2*idx+2]) max = 2*idx+2;
        // 如果我的最大值的下标就是当前的这个值,那就不需要向下调整了,直接结束循环
        if(max === idx) break;
        // 然后交换这个最大值的下标和根节点
        [ this.arr[idx], this.arr[max] ] = [ this.arr[max], this.arr[idx] ];
        // 然后把idx改为原最大值的下标,继续向下调整
        idx = max;
      }
    }
    push(x: number) {
      // 将x先放到堆的末尾,也就是数组的末尾
      this.arr[this.count++] = x;
      // 进行向上调整
      this._shift_up(this.count-1);
      
    }
    pop(): void {
      if(this.size()===0) return;
      // 根据上面的讲解,我们首先需要让队尾元素与根节点交换
      [ this.arr[0], this.arr[this.count-1] ] = [ this.arr[this.count-1], this.arr[0] ];
      // 交换之后记得count要减1
      this.count--;
      // 进行向下调整
      this._shift_down(0);
    }
    top(): number {
      return this.arr[0];
    }
    size(): number {
      return this.count;
    }
    output(){
        console.log(this.arr.join('\n'));
    }
  }
  
	// 以下为测试这个堆新增和弹出元素功能以及堆排序的方法
  const heap = new Heap();
  let i = 10;
  while(--i) {
      heap.push(Math.round(Math.random()*100));
  }

  heap.output()
  console.log("=========================");

	
  while(heap.size()!==0) {
      heap.pop();
  }

  console.log("============以下为堆排序结果=============");

  heap.output();

上面的堆写得太死了,只能存放数字,那么我们来使用Typescript实现一个扩展性比较好的堆

export type HeapDataStruct<T> = {
    idx?: number,
    data: T
};
export type CompareFn<T> = (x: HeapDataStruct<T>, y: HeapDataStruct<T>) => boolean;
class Heap<T> {
    private arr: HeapDataStruct<T>[] = [];
    private count: number = 0;
    constructor(private cmpFn: CompareFn<T>) {

    }

    // 比较方法,决定了我们要构建的堆是一个大顶堆还是小顶堆
    private _compare(curIdx: number, pIdx: number): boolean {
        return this.cmpFn(this.arr[curIdx], this.arr[pIdx])
    }

    private _shift_up(idx: number): void {
        // 然后对堆进行向上调整
        // 我们需要通过子节点坐标得到父节点的坐标,因为我们这边的下标是从0开始的,所以左子树根节点坐标为:2 * i + 1,右子树根节点坐标为:2 * i + 2。那么我们父节点的编号:parseInt((子节点编号 - 1) / 2),如知道左节点编号为:2 * i + 1,那么他的父节点坐标就是: parseInt((2 * i + 1 - 1) / 2) = parseInt(i);如果知道有节点的编号:2 * i + 2,那么他的父节点的坐标为:parseInt((2 * i + 2 - 1) / 2) = parseInt((2*i+1)/2) = i
        // 上面我们讲过,我们需要如果在一个大顶堆中,如果我们父节点的值小于子节点的值的话,就要交换这两个值
        // while(idx && this.arr[parseInt(String((idx - 1) / 2))].data < this.arr[idx].data) {
        while(idx && this._compare(parseInt(String((idx - 1) / 2)), idx)) {
          // 父节点编号
          const pIdx = parseInt(String((idx - 1) / 2));
          // 交换父节点和当前节点的值
          [ this.arr[pIdx], this.arr[idx] ] = [ this.arr[idx], this.arr[pIdx] ];
          // 然后再让idx变成父节点的编号,这样就能依次向上调整了
          idx = pIdx;
        }
      }

      private _shift_down(idx: number): void {
        // 交换之后,根节点再依次向下调整
        // 最大的子节点的下标
        let n = this.count - 1;
        // 当idx的子节点的下标比最大的子节点的下标小,就说明还有子节点,就要往下继续调整
        while(idx * 2 + 1 <= n) {
          // max代表在根节点、左子树、右子树中最大值的下标
          let max = idx;
          // 如果左子树的根节点的值比当前值大,就把max更新为左子树根节点的下标
        //   if(this.arr[max].data < this.arr[2*idx+1].data) max = 2*idx+1;
          if(this._compare(max, 2*idx+1)) max = 2*idx+1;
          // 如果右子树根节点的值比当前节点大,就把max更新为右子树根节点的下标
          // 需要注意的是,我们当前的节点,可能存在左子树,但不一定存在右子树,所以需要多加一个2*idx+2<=n的条件
          if(2*idx+2<=n && this._compare(max, 2*idx+2)) max = 2*idx+2;
        //   if(2*idx+2<=n && this.arr[max].data < this.arr[2*idx+2].data) max = 2*idx+2;
          // 如果我的最大值的下标就是当前的这个值,那就不需要向下调整了,直接结束循环
          if(max === idx) break;
          // 然后交换这个最大值的下标和根节点
          [ this.arr[idx], this.arr[max] ] = [ this.arr[max], this.arr[idx] ];
          // 然后把idx改为原最大值的下标,继续向下调整
    
          idx = max;
        }
      }

    push(item: HeapDataStruct<T>): void {
        this.arr[this.count++] = item;
        this._shift_up(this.count-1);
        // this.output('push: ');
    }

    pop(): HeapDataStruct<T>|null {
      if(this.size()===0) return null;
      const tmp = this.arr[0];
      // 根据上面的讲解,我们首先需要让队尾元素与根节点交换
      [ this.arr[0], this.arr[this.count-1] ] = [ this.arr[this.count-1], this.arr[0] ];
      // 交换之后记得count要减1
      this.count--;
      // 进行向下调整
      this._shift_down(0);
    //   this.output('pop: ');
      return tmp;
    }

    size(): number {
        return this.count;
    }

    top(): HeapDataStruct<T> {
        return this.arr[0];
    }

    output(pre: string = ""): void {
        console.log(pre+JSON.stringify(this.getArray()));
    }

    // 将堆中的数据里面的data单独拿出来放到数组返回
    getArray(sort: (a: HeapDataStruct<T>, b: HeapDataStruct<T>) => number = () => 0): T[] {
        return this.arr.slice(0, this.count).sort(sort).map(item=>item.data);
    }
}

堆适合处理什么问题

堆适合维护集合最值

PS:一般我们在处理查找最大/多/长...k个...时,我们会用小顶堆,处理最小/少/短...k个...时,我们会用大顶堆,因为你要找最大,,如果集合达到了最大值时,需要把集合中的最小值弹出。找最大也是相同道理。所以,记住:「大多长k个用小顶」「小少短k个用大顶」「大多长用大顶」「小少短用小顶」

结语

按照惯例,为了避免篇幅过长让一些没有相关基础的同学看起来过于痛苦,本文就到此为止,如果想要通过一些算法题提升和公共堆这个数据结构理解的小伙伴,稍后会提供一个新的文章链接,在这篇文章中将会跟大家一起来通过堆相关的算法题提升和巩固对于堆的认知。