【数据结构之堆Heap】一个神奇的数据结构

1,313 阅读9分钟

在二分搜索树那篇文章介绍了二叉树这种树形结构,实际上堆的结构也是一颗树。但我们为什么需要堆这种数据结构呢?如果你用过抖音你应该会在抖音搜索模块看到一个“抖音热榜”,点赞数最多的排第一,点赞数第二多的排第二,依次类推,如果让你来实现这个功能你会怎么做呢?

回忆一下我们前面学到的数据结构,我们可以使用链表和数组,维护一个有序的链表或者数组,点赞数多的往前排,这样从前到后遍历一遍链表或者数组然后插入到合适的位置就可以得到一个按点赞数从大到小的列表。但是,我们维护一个有序的链表的时候需要至少遍历一遍链表,每次点赞我们都需要遍历一遍排行榜,时间复杂度为O(n)。同样对于数组来说由于有我们需要在数组的中间某个位置插入元素,会触发数组的扩容和搬移,所以时间复杂度也为O(n),在热榜的数据达到几万甚至上百万级别的时候,如果每次点赞我们都要去维护链表或者数组的有序性显然是不现实的。

对于查找表相关的set、map和hash表由于其本身是无序的,所以不适用于我们这个场景。最后我们来看一下使用树这种数据结构,我们前面说二分搜索树的插入和查找时间复杂度都为O(logn),我们每次点赞都去更新我们的排行榜,更新的过程分两步,首先是查找到对应的视频将点赞数加1,然后调整位置,修改或者插入一个元素的时间复杂度是O(logn)。再来看查找,我们要查找点赞数最高的视频,需要递归找到右子树最后一个节点(如果对这个规则不清楚可以去看一下二分搜索树那篇文章),时间复杂度为O(logn),我们看到通过二分搜索树这种实现方式确实可以有效降低时间复杂度,虽然查找的时候复杂度比数组和链表的O(1)要差,但是插入时的时间复杂度O(logn)明显是要比O(n)要更好的。

下面我们就来介绍堆这种数据结构,理解了堆这种数据结构之后相信就有答案了,那么什么是堆呢?

堆其实也是一颗树,我们通过某种方式保持堆的根节点一定是整个堆里面所有元素的最小值或者最大值。如果根节点是最小值的堆我们叫小顶堆,如果根节点是最大值的堆我们叫大顶堆。

下面是两个堆的示例,左边是一个大顶堆,右边是一个小顶堆:

图片

通过上面的图我们看到,所有父节点都比子节点要大或者是要小,我们把这样一颗树叫作堆。

堆的实现

堆最为经典的实现就是使用一个数组作为底层数据结构,如下图:

图片

可以看到从根节点开始依次将元素放入到一个数组里面,我们将树的节点标上了编号,和数组下标一一对应,对于任意一个根节点通过观察我们可以得出以下规律:

  • 对于任意节点的父节点下标:parent(i) = i / 2
  • 左子树的下标为:left child(i) = 2 * i
  • 右子树的下标为:right child(i) = 2 * i + 1

注意,我们忽略了数组下标0,这是因为下标从1开始更容易计算,当然我们也可以从0开始,只是公式稍微做一下调整就可以了。对于使用0下标的方式这里我们不展开讲。

上面的规律很关键,通过这个规律我们就可以实现堆的常用操作了。那么堆都有哪些操作呢?

Insert操作

下面我们来看一下,往堆里插入一个新元素怎么做,其实也很简单,我们可以将新的元素放到数组的末尾,然后进行shiftUp操作,如下图:

图片

我们插入了一个新的元素45,先插入到数组的末尾,使用parent(i)=i/2找到父亲节点,然后我们比较当前节点和父亲节点谁更大,如果当前节点比父亲节点要大的话,我们进行一次交换swap(i/2, i),重复这个过程,直到父亲节点比当前节点要大,停止shiftUp操作,shiftUp核心代码如下:

private void shiftUp(int i) {  
    while(i > 0 && data.get(i/2).compareTo(data.get(i)) < 0) {     
        data.swap(i/2i);     
        i = i / 2;  
    }
}

解释一下上面的代码:

  • 首先,我们的方法是一个私有方法,因为shiftUp并不对外暴露。
  • 第2行的while循环的条件是i>0 并且父节点比当前节点要小。
  • 第3行交换父节点和当前节点的值。
  • 第4行继续向上shiftUp。

至此,我们就完成了节点的插入操作。

Extract操作

extract操作就是取出堆顶元素,对于大顶堆来讲就是取出堆中最大的节点,相反对于小顶堆来讲就是取出当前堆中最小的元素,从堆的性质我们可以很容易看出来,堆顶元素就是数组第一个元素。但是我们要解决的问题是取出堆顶元素之后如何保持堆的性质,如果自己去硬想可能不太容易想到这个方法,我们这里直接给出一个方案。

首先,我们将堆顶元素保存起来,然后将第一个元素和最后一个元素进行一次交换,接下来删除最后一个元素(也就是原堆顶元素)。最后,我们对堆顶元素做shiftDown操作。下面是extract操作的代码:

public E extractMAX(){  
    E ret = data.get(1);  
    data.swap(1data.size() - 1);  
    data.remove(data.size() - 1);  
    shiftDown(1);  
    return ret;
}

解释一下代码:

  • 首先,extractMAX方法是一个public的,表示这个方法用户是可以直接使用的。
  • 第2行,我们先将堆顶元素保存起来。
  • 第3行,我们交换堆顶元素和数组最后一个元素。
  • 第4行,我们删除数组最后一个元素(也就是我们取出的堆顶元素)。
  • 第5行,做shiftDown操作,下面我们会讲。

要注意,这里为了代码的连贯,我省略了边界的判断,比如当前堆的size是否已经等于0,前面我们说数组下标0我们不使用。所以,堆顶元素永远是在数组下标为1上面。

然后我们看下shiftDown操作,如下图:

图片

shiftDown比shiftUp要稍微复杂一点,因为我们要判断左右子树,如果根节点比左子树或者右子树要小,就要和较大的子节点进行交换。下面我们看一下shiftDown代码:

private void shiftDown(int i) {    
    while(i * 2 < data.size()) {    
        int j = i * 2;   
        
        // j是i的左子树,j+1是i的右子树,如果右子树比左子树要大,要交换的就是左子树    
        if (j + 1 < data.size() && data.get(j+1).compareTo(data.get(j)) > 0) {      
            j = i * 2 + 1;    
        }   
        
        // 如果当前结节比要交换的节点大或者相等就不交换        
        if (data.get(i).compareTo(data.get(j)) >= 0) {      
            break;    
        }   
        
        // 进行一次交换        
        data.swap(i, j);    
        i = j;  
    }
}

我在代码里写了注释,对照shiftDown的图相信还是很好理解的。至此,我们从堆顶取出一个元素也完成了。

索引堆

上面我们实现的堆有一个小问题,就是我们的元素比较单一,上面我们实现的堆的元素都是整型数字,但在实际工程中我们可能需要向堆中保存各种各样的数据类型。或者我们要实现一个类似于操作系统进程调度器,我们知道linux里面使用task_struct来记录一个进程的信息,比如进程的pid,栈指令信息,优先级信息等,是一个结构体。如果我们要提升一个进程的优先级,那么对于我们上面实现的堆就没法去实现了。所以,这里引入了索引堆这种数据结构。我们看下面的图:

图片

我们定义了三个数组,index表示堆的原数组,data表示真正保存数据的数组,rev也就是reverse的意思,表示有索引i在indexes(堆)中的位置,我们这里不展开代码实现,我给出如下规律:

  • indexes[i] = j
  • reverse[j] = i
  • indexes[reverse[i]] = i 
  • reverse[indexes[i]] = i 

和单数组的实现不同,索引堆维护堆的性质是通过索引数组index来实现的,至于维护的依据,shiftUp和shiftDown的操作中元素之间的比较实际比较的都是data数组里的元素。

如果你理解了上面使用单数组的实现,相信你也可以自己实现一个索引堆,这里我贴出我的实现,代码是使用c++写的,没有太多复杂的语法。

github.com/seepre/data…

当然,操作系统进程的调度远远不是使用一个堆就能实现的,里面还涉及到相当多的细节,这部分内容等后面在写操作系统系列文章的时候再展开讲。对于索引堆,在后面讲图论的时候我们还会接触到,所以其实基础的数据结构和算法是非常重要的,只有理解了基础数据结构之后再去学高阶的数据结构才会更加得心应手。

堆排序

堆的一个重要的应用就是堆排序,其实我们上面通过单数组实现的堆就已经可以实现一个简单的堆排序了,我们可以建一个size大小为待排数组长度的堆,遍历一遍要排序的数据将元素依次插入到堆,然后依次取出来就得到了一个有序的数组了。时间复杂度为O(nlogn),这是因为每个元素都要做一次Insert,堆的insert操作的时间复杂度为O(logn)。下面我们来看一下堆排序的实现:

int len = arr.size();
maxheap<int> h = maxheap<int>(len);
for (int i = 0; i < len; i ++) {  
    h.insert(arr[i]);
}

while(!h.isEmpty()) {  
    cout << h.extractMAX() << " ";
}

上面是一段c++的代码,我们可以看到实现了堆之后,再实现堆排序是非常简单的,只需要把待排序数组的元素插入到堆里,然后不断取出堆顶的元素,就得到了一个有序的数组。

其实,对于上面的堆排序还有进一步优化的空间,比如如何实现原地堆排序,在讲排序算法的时候我们再详细展开。

优先队列

同样的,对于优先队列的实现也非常简单了,这里我直接贴出核心代码:

public class PriorityQueue<E extends Compareble<E>> implements Queue<E> {  
    private MaxHeap<E> maxHeap;    
    public PriorityQueue() {    
        maxHeap = new MaxHeap<>();  
    }
    
    // some other code ...  
    @Override  
    public void enqueue(E e) {    
        maxHeap.insert(e);  
    }  
    
    @Override  
    public void dequeue(E e) {    
        return maxHeap.extractMAX();  
    }  
    // some other code ...
}

对于入队操作,我们只管插入就可以了,堆会自动帮我们维护堆的性质,对于出队,我们只管取堆顶元素就可以了。

回到我们文章标题的问题,如何实现抖音热榜呢?我们可以使用一个大顶索引堆,索引index数组用来表示视频ID,data数组保存点赞数,当有新的点赞我们就去更新data数组,然后重新维护堆的性质,这样我们就可以始终将点赞数最多的视频放在堆顶,然后我们取列表的时候就从堆顶一个一个取出来就可以得到一个点赞数从大到小的列表了。当然抖音热榜的实现肯定是没有这么简单的,比如如何实现搜索结果的过滤及排序?比如,如何解决高并发等等问题,这里我们只是提供了这类场景的一个思路。

更多堆的实现

我们这里只讲了使用单数组的实现方式,实现的堆是一个二叉堆,其实堆还有很多其它的实现和变种,这里我列举几个方向:

多叉堆

多叉堆顾名思义就是一个父亲节点有多个子节点,如下图就是一个三叉堆:

图片

斐波拉契堆

斐波拉契堆是可用于生产环境的堆实现,效率很高,具体可以参考《算法导论》第19章

好了,到此为止对于堆就写完了,堆在实际工程中有非常多的应用,是值得花大力气掌握的。