【数据结构】堆 : 从二叉树到优先队列的推导和手撕实现

0 阅读17分钟

我们来看看数据结构中一个尤为重要、且绕不开的结构——堆。

堆给出的正式定义是“一棵具有以下特性的二叉树,其所有非叶节点均不大于(或不小于)其左右孩子节点,按照完全二叉树的节点编号进行排列,n个节点的关键字序列称为堆”。 进行学习时,往往对这类定义是缺乏记忆点的,我们来看两个堆,图文结合,加深印象:

image.png

image.png

依据堆的定义,通常分为两类,大顶堆和小顶堆,上面图片是大堆(父>=子),下面是小堆(父<=子),现在在照着定义去看,堆的构成已然不再神秘,揭开了面纱。

一、堆的底层存储结构

堆本质上是一棵二叉树,基于二叉树的特点,我们知道顺序存储结构十分适合作为底层,所以在后面的手撕选择使用vector来实现,为什么数组能够存树呢?核心要点在于“下标映射关系”。

  • 父节点 : i
  • 左孩子 : 2 * i
  • 右孩子 : 2 * i + 1
  • 父节点索引 : i / 2(除去堆顶任一节点)

这里的公式基于图片从1作为索引得到,数组下标常用从0开始,若树节点从0开始,有:

左 = 2 * i + 1

右 = 2 * i + 2

父索引 = (i - 1) / 2

对于数据的存储结构,分为顺序存储和链式存储,想必初学时一定会有疑问“为什么不用链式结构呢?”,对比往往能够突出问题的本质,我们来看看:

对比维度顺序存储结构(数组/vector)链式存储结构(二叉链表)
空间开销✅只存储元素本身,无额外开销❌每个节点需额外2个指针(存左、右孩子),空间多2~3倍
随机访问✅O(1)随机访问,通过下标计算父子索引方便❌O(n)查找,要遍历定位节点
缓存命中✅连续内存,CPU缓存命中率高,速度快❌节点内存分散,CPU缓存命中率低,容易产生内存碎片,发生内存泄漏
实现复杂度✅代码实现简单❌需要维护指针,在插入/删除较为繁琐
适用场景✅完全二叉树,节点紧密排列❌适合普通二叉树,需要频繁动态增删

了解完基础知识,我们想使用堆,绕不开核心操作之一——“建堆”,而建堆中又别有天地,常见的两种建堆方法,“自底向上建堆(下沉)” 和 “逐个插入(上浮)”。

二、堆的核心算法:上浮和下沉

前面提到建堆,那就不得不提及向上调整算法(上浮) 和 向下调整算法(下沉) 了,掌握了这两个算法,后面遇到需要使用堆的场景就能够在脑海中很快浮现出堆的逐步构建。

(注:下面所有代码是基于下标为0进行的实现 !!!)

1.向上调整算法

适用场景:子节点可能破坏堆性质时,如:

  • 新节点插入到堆尾
  • 建堆时的逐个插入

实现步骤(大顶堆):

1.从当前节点i(通常是末尾)开始。

2.i与其父节点(i - 1) / 2比较。

3.若i大于父节点,发生交换。

4.重复2-3步骤,直到达到根或无需再调整为止。

代码实现(C++):

//大顶堆实现
void AdjustUp(vector<int>& heap, int child) {
    while (child > 0) {
        int parent = (child - 1) / 2;
        if(heap[child] > heap[parent]) {   //子节点大于父节点,破坏堆性质,需交换(若想实现小顶堆,变号即可)
            swap(heap[child], heap[parent]);
            child = parent;                //继续向上比较
        } else {
            break;
        }
    } 
}

2.向下调整算法

适用场景:父节点可能破坏堆性质时,如:

  • 自底向上建堆
  • 删除堆顶元素

实现步骤:

  • 1.从当前父节点parent(建堆时通常是第一个非叶节点(n - 1 - 1) / 2,删除时通常从根0)开始(这里的n是数组长度,n - 1 是最后一个元素下标,即堆尾元素,再-1 和 /2才是公式)。
  • 2.找出其左孩子2 * parent + 1和右孩子2 * parent + 2中的较大者(记作child)。
  • 3.若父节点parent小于child,发生交换。
  • 4.重复2~3步骤,直到父节点达到最大或抵达叶子层为止。

代码实现:

void AdjustDown(vector<int>& heap, int n, int parent) {
    while (true) {
        int child = 2 * parent + 1;     //假设左孩子为其中最大者
        if(child >= n) break;           //无左孩子
        
        if(child + 1 < n && heap[child + 1] > heap[child]) {    //假设错误则换为右孩子
            child++;
        }
        
        if(heap[parent] >= heap[child]) break;         //父节点>=孩子,大顶堆性质已满足
        
        swap(heap[parent], heap[child]);
        parent = child;
    }
}

三、堆的构建

我们提到,有“自底向上建堆(下沉)” 和 “逐个插入(上浮) 这两种方法,被频繁使用的是“下沉建堆”,因为其时间复杂度为O(n),而插入建堆则是O(nlogn),关于复杂度的推导放到末尾来谈。

这里以建立大堆为例,来详细这两种建堆的过程:

给定一个随机数组vector<int> a = {2,7,26,25,19,17,1,90,3,36},二叉树表示如下:

1.自底向上建堆(下沉)

image.png

从第一个非叶节点开始与其孩子比较: image.png

image.png

可以看到第一个非叶节点为19,与其孩子36比较,因为目标是建立大堆,所以小的元素需要向下移动(对应“下沉”),直到与根节点完成最后一次比较加上是否交换。

这里的关键是找到最后一个非叶节点开始进行下沉操作,具体怎么找呢?就依赖于我们之前提到的下标映射关系。 选取树编号为1对应的公式(从0开始选另一份)。

流程如下:

  • 找起点:最后一个非叶节点一定是最后一个叶子节点的父亲,利用下标映射关系得到的公式可知,父节点索引为i / 2,数组长度为10,所以从下标10 / 2 = 5(节点19)开始找。
  • 下沉:目标是大顶堆,父节点要大于子节点,节点19与孩子36比较,19 < 36,发生交换。
  • 推进:依次对下标4(25)、3(26)、2(36)、1(90)进行下沉操作,比如节点7会和25、90比较后发生下沉操作。
  • 重复:重复上述步骤,直到完成最后一次交换。

image.png

(最终结构:根节点90,整体满足父>=子)

2.逐个插入建堆(上浮)

顾名思义,核心在于插入操作,因为一个节点我们可以看作是堆,要不断将新元素插到堆尾,新的节点也许会破坏堆的结构(大顶堆为例,新节点可能大于父节点),这时就利用上浮使得整个堆重新变得符合规范。

思路 :从空堆开始,不断向堆尾插入新元素,并通过上浮调整堆结构。

采用与下沉建堆一样的数组vector<int> a = {2,7,26,25,19,17,1,90,3,36},方便比较。

image.png

image.png

发生上浮:

image.png

image.png

image.png

过程:

  • 插入2:[2](单元素天然是堆)。
  • 插入7:[2,7]-> 7 > 2,上浮 ->[7,2]。
  • 插入26:[7,2,26]-> 26 > 7 -> 上浮 -> [26, 7, 2]。
  • ......以此类推,最终完成大顶堆。

3.复杂度分析

1.下沉建堆分析

一棵二叉树有n个节点,高度为h,由性质有:

  • 二叉树高度h = logn(以2为底) + 1
  • 二叉树至多有节点数n = (2^h) - 1
  • 第i层最多节点数为2^(h - 1)
                                      1               第一层:2^0个节点,需要下沉h-1次
                                    /    \
                                   2      3           第二层:2^1个节点,需要下沉h-2次
                                  / \    /   \
                                4   5    6     7      ......
                               / \ / \  / \   /  \
                             8  9 10 11 12 13 14 15   第h-1层:2^(h-2)个节点,需要下沉1次
                            ......                    第h层直接看作堆,不需要调整

复杂度分析,往往要考虑最坏情况,这里最坏情况是将节点调整到叶子的位置,调整总次数=每层节点个数 * 最坏要向下调整的次数。

T(N) = 2^0 * (h - 1) + 2^1 * (h - 2) + 2^2 * (h - 3) + 2^3 * (h - 4) + ... + 2^(h - 3) * 2 + 2^(h - 2) * 1 (a)

这种形式想必并不会感到陌生,次数就是一个等比数列求和的结果,对于这种形式,采用“错位相减法”来进行解决。

2 * T(N) = 2^1 * (h - 1) + 2^2 * (h - 2) + 2^3 * (h - 3) + ... + 2^(h - 2) * 2 + 2^(h - 1) * 1 (b)

(b) - (a)式得T(N) = 2^0 + 2^1 + 2^2 + ... + 2^(h - 2) + 2^(h - 1) - h

2^0 + 2^1 + 2^2 + ... + 2^(h - 2) + 2^(h - 1)这里就是二叉树的节点总数,所以T(N) = 2^h - 1 - h

由上面列出的性质得n = 2^h - 1 和 h = log(n+1)(均是以2为底),得到T(N) = n - log(n+1) = O(n)

2.插入建堆(上浮)分析

                                      1               第一层:不需要调整
                                    /    \
                                   2      3           第二层:2^1个节点,最坏需要上浮1次
                                  / \    /   \
                                4   5    6     7      ......
                               / \ / \  / \   /  \
                             8  9 10 11 12 13 14 15   第h-1层:2^(h-2)个节点,最坏需要上浮h-2次
                            ......                    第h层:2^(h-1)个节点,最坏需要上浮h-1次

一样的分析方法,这里的最坏情况就是要将节点调整至根节点,故可得T(N) = 2^1 * 1 + 2^2 * 2 + 2^3 * 3 + ... + 2^(h-2) * (h - 2) + 2^(h - 1) * (h - 1)

利用错位相减,最后得到T(N) = -n + (n + 1) * (log(n + 1) - 1) + 1 = O(nlogn)

通俗理解就是:树一共n个节点,可得树高约为logn,每个节点进行插入操作时,最坏情况需要从叶子层上浮到根,即上浮O(logn)次,又一共n个节点,每个节点上浮O(logn)次,所以总复杂度就是T(N) = n * O(logn) = O(nlogn).

四、堆的核心操作:插入/删除

1、堆的插入

堆的插入是在堆尾进行插入新元素,虽然vector支持在头、尾、中间插入。作为堆,试想一下,如果支持头插等操作,在堆顶插入新元素,会彻底破坏堆结构。

image.png

好比在堆顶插入节点100,依照大顶堆性质,改变堆结构,此时100是根节点,节点90和36变成100的孩子了,原来90和36是父子关系,现在却变成同辈了,很荒谬吧。

因此,堆只支持尾插,但也不是尾插后就万事大吉了,因为子节点可能大于其父亲,这时就要用到前面的AdjustUp算法,将堆结构重新变得规范。

void HeapPush(vector<int>& Heap, int val) {
    heap.push_back(val);
    AdjustUp(heap, heap.size() - 1);
}

2、堆的删除

提到堆的删除,一般操作是将堆顶元素与堆尾元素进行交换,接着尾删,再用AdjustDown算法调整结构。 这里或许大概率会问 为什么删堆顶呢,堆尾行吗? 一个堆无非就是大顶堆或小顶堆,你删除堆尾叶子节点对整个堆性质不会造成任何实质性影响,仅仅只是改变了完全二叉树的节点数,所以这里特指删除堆顶节点。

void HeapPop(vector<int>& heap) {
    if(heap.empty()) return;     //空堆无法进行删除
    
    swap(heap[0], heap[heap.size() - 1]);   //堆顶堆尾元素交换
    
    heap.pop_back();
    
    if(heap.empty()) {
        AdjustDown(heap, heap.size(), 0);   //防止删除后变为空堆,向下调整发生越界
    }
}

五、建堆代码实现(大顶堆)

1.插入建堆

void BuildHeapByInsert(vector<int>& heap) {
    for(int i = 1; i < heap.size(); ++i) {   //下标从1开始是因为根节点(0)没有父节点,无需上浮
        AdjustUp(heap, i);
    }
}

问:这里建议使用范围for进行遍历插入建堆吗?

答:不太建议用范围for,因为AdjustUp依赖下标进行计算,使用范围for则需要额外去维护一个计数器,不如直接用下标直接遍历简单。范围for更推荐用在纯遍历的场景下。

2.自底向上建堆

void BuildHeap(vector<int>& heap) {
    int n = heap.size();
    
    //(n - 1)是最后一个节点,再减1,除以2,得最后一个非叶子节点的下标
    for(int i = (n - 1 - 1) / 2; i >= 0; --i) {   
        AdjustDown(heap, n, i);
    }
}

补充:关于堆还有一个经典的排序算法——“堆排序”,放到后面排序专题进行详细说明。

六、优先队列与仿函数(扩展)

1.什么是优先队列和堆之间的关系

优先队列,正式定义为:

优先队列是一种特殊的队列,其中每个元素都被赋予一个“优先级”。出队操作(Dequeue)总是移除当前队列中优先级最高(或最低) 的元素,而非遵循先进先出(FIFO)原则。

在定义中我们可以摘取到要点“出队操作(Dequeue)总是移除当前队列中优先级最高(或最低) 的元素”。

这是什么?不就是在大顶堆(或小顶堆)中进行删除操作嘛,堆的性质决定了堆顶元素是整个堆中最大(或最小)的元素,删除后通过AdjustDown算法将第二大(或小)的元素重新成为堆顶,从而继续操作。

image.png

官方文档指出:"This context is similar to a heap,where elements can be inserted at any moment, and only the max heap element can be retrieved"

所以堆是优先队列最经典的底层实现。

(补充:这里的模板参数Compare中提到的less比较器构建的是大顶堆,但是在实际开发中,我们很少通过手动建堆来实现优先队列,而是通过仿函数或Lambda匿名函数来控制大小顶堆,后面一一道来)

2、仿函数控制大小顶堆(less/greater)辨析

在此之前,我们先提一嘴什么是仿函数。仿函数本质上是一个"行为像函数的类/结构体",它通过重载()运算符,让我们能通过函数来调用它的对象。

来看一个简单的例子:

#include<iostream>
using namespace std;

class Compare {
public:
    bool operator()(int a, int b) {   //重载()运算符,为了在后面能够发生调用行为
        return a > b;
    }
};

int main() {
    Compare cmp;
    cout << cmp(3, 5) <<endl;   //像函数一样调用
    return 0;
}

正因为仿函数可以定制比较逻辑,所以STL中的priority_queue就靠它来决定谁优先级高。

在priority_queue中:

  • 默认是less<T>,为大顶堆。
  • 传入greater<T>,为小顶堆。

image.png

image.png

3、Lambda写法

在这里,什么是Lambda呢?Lambda表达式(匿名函数)是C++11引入的特性,允许我们直接在调用处直接定义函数对象,无需单独声明一个类或函数。

它的本质就是一个匿名的仿函数————编译器会自动生成一个仿函数类。

基本语法:[captrue](paraments) -> return_type { body }

部分说明必需性
[captrue]捕获列表,指定Lambda可访问的外部变量✅必需
(paraments)参数列表,与普通函数相同✅必需
-> return_type返回类型,可以省略(编译器可自动推导)❌可选
{ body }函数体✅必需

简单示例:

#include<iostream>
using namespace std;

int main() {
    //定义一个 Lambda,接受两个 int,返回和
    auto Add = [](int a, int b) -> int {
        return a + b;
    };
    
    cout << Add(3, 5) << endl;    //输出8
    cout << Add(4, -2) << endl;   //输出2
    return 0;
}

剩余更多有关Lambda的内容,可以自己去查阅相关内容,不再此处详细展开了。

上面提到Lambda本质就是一个编译器自动生成一个匿名仿函数,自然Lambda在这里也可以胜任。

#include<iostream>
#include<vector>
#include<queue>

using namespace std;

int main() {
// 定义一个Lambda,小顶堆比较规则(a > b表示a优先级更低)
    auto cmp = [](int a, int b) {
        return a > b;
    };
    
    //使用decltype(cmp)获取Lambda的类型
    //必须将 cmp 作为构造参数传入
    priority_queue<int, vector<int>, decltype(cmp)> minHeap(cmp);
    
    minHeap.push(3);
    minHeap.push(1);
    minHeap.push(5);
    
    cout << minHeap.top() << endl;  // 输出1(最小元素)
    return 0;
}

Lambda也可以用于处理复杂结构体,无需单独定义仿函数类。

4、优先队列的应用

了解完优先队列的一系列构成,不禁要思考有什么用处呢

这里提两个经典的用途:

  • top-k问题
  • 在Dijkstra算法中的应用

top-k问题

看名字大概也能猜到这个问题想求什么(top意为顶部),所以求从top开始向下数K个,即前K极值。

这样解释有人也许会说“那我排序不就行了,再取前K个”,这个说法在特定情况下的确正确,但是如果是海量动态数据呢,不断进出数据,这时排序就显得乏力了。

优先队列的核心优势在于:动态维护一组数据的极值。

思路:维护一个大小为K的堆,满足则放入堆中,反之滤过。

#include<iostream>
#include<vector>
#include<queue>
using namespace std;

vector<int> topk(vector<int>& nums, int k) {
//求前K大(用小顶堆,堆顶是第K大)
priority_queue<int, vector<int>, greater<int>> minHeap;
for(const int& x : nums) {
    if(minHeap.size() < k) {
        minHeap.push(x);
    }  else if (x > minHeap.top()) {
        minHeap.pop();
        minHeap.push(x);
        }
    }
    
    vector<int> res;
    while (!minHeap.empty()) {
        res.push_back(minHeap.top());
        minHeap.pop();
    }
    return res;
}

Dijkstra算法

Dijkstra算法(最短路径规划),这里又为什么需要优先队列呢,Dijkstra算法的核心是“贪心” ,每次必须挑出“当前距离起点最近、还没被处理过”的节点,然后去更新它的邻居。

  • 如果使用普通数组来存,每次找最近节点都需要遍历,耗时为O(n) (n为节点数)
  • 优先队列将{当前节点,距离}放入小顶堆,堆顶永远是“离目前最近的节点”,取用耗时为O(logn),使得该算法在稠密图中效率大幅提升。
#include<iostream>
#include<vector>
#include<queue>
#include<climits>
using namespace std;

typedef pair<int, int> PII;   //{距离,节点}

vector<int> dijkstra(vector<int>& grid, int n, int start) {
    priority_queue<PII, vector<PII>, greater<PII>> pq;
    vector<int> dist(n, INT_MAX);
    
    dist[start] = 0;
    pq.push({0,start});
    
    while(!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if(d != dist[u]) continue;        //说明之前碰到过,跳过
        
        for(auto [v, w] : grid[u]) {      //v是邻居节点,w是边权
            if(dist[v] > d + w) {         //d + w是从u到v的新路径长度
                dist[v] = d + w;          //比原来短,更新长度
                pq.push({dist[v], v});    //将新距离 + 节点入队
            }
        }
    }
    return dist;
}

这里的auto []是 C++17引入结构化绑定,低于C++17无法使用,可以这样进行平替:

auto t = pq.top();pq.pop();   //用一个变量进行保存
d = t.first;   //取到距离
u = t.second;  //取到节点

七、面试常见问题

⭐基础部分

1.什么是堆?堆和二叉树的区别是什么?

2.堆的底层存储为什么用数组而不是链表?

3.大顶堆和小顶堆分别适用于什么场景?

4.堆的插入和删除操作的时间复杂度分别是多少?为什么?

5.建堆有两种方式(逐个插入 vs 自底向上),它们的复杂度分别是多少?为什么自底向上更快?

⭐⭐进阶部分

6.在 0-based 数组中,给定一个节点的下标 i,如何计算它的父节点、左孩子、右孩子的下标?

7.向上调整(AdjustUp)和向下调整(AdjustDown)分别用于哪些操作?

8.堆的删除操作为什么是“交换堆顶与堆尾,再尾删,最后下沉”?直接删除堆顶不行吗?

9.AdjustDown 的循环终止条件是什么?如果写错了会导致什么后果?

10.在建堆时,为什么自底向上是从 n/2 - 1 开始,而不是从 n-1 开始?

⭐⭐⭐应用

11.如何用堆实现一个优先队列?优先队列的 push 和 pop 分别对应堆的哪些操作?

12.Top-K 问题中,求前 K 大为什么要用小顶堆而不是大顶堆?

13.如果数据是动态流式的(源源不断到来),如何实时维护前 K 大的元素?

14.Dijkstra 算法中,优先队列的作用是什么?如果不使用优先队列,该如何实现?

⭐⭐⭐⭐工程与陷阱篇

15.C++ 的 priority_queue 默认是大顶堆还是小顶堆?如何改为小顶堆?

16.Lambda 表达式作为优先队列的比较器时,为什么需要用 decltype 获取类型?

17.仿函数(Functor)和 Lambda 在作为优先队列比较器时,各自的优缺点是什么?

18.如果优先队列中存储的是自定义结构体,如何定义比较规则?(举一个例子)

19.在使用堆实现 Top-K 时,如果 K 非常大(接近 N),堆方法的优势还明显吗?此时有没有更好的方案?