学习顺序
- 堆的概念,优先级队列
- C语言/Java实现简单的堆
- 堆排序
- 堆应用-
TopK
问题
堆和优先队列.
堆
C语言内存中提及到过"堆"的概念,在这里说明,C中的堆指操作系统中的虚拟内存.
Java中堆被引申"垃圾存储机制".
堆一词,是基于一种名为堆排序
的排序算法—(这也是本篇内容之一).
此处堆是一种dataStructure
:一种数据结构.
另外这里是介绍的堆是二叉堆
关于堆的分类:
二叉堆
,斐波那契堆
,二项堆
等.
堆的应用:
- 堆排序.
- 实现优先级队列
TopK
问题- 图算法找最短路径和最小生成树.—高阶数据结构再谈.
堆介绍
(二叉)堆是一种完全二叉树,从逻辑结构上可以看出.
二叉堆底层实际是一个数组.
比如下面一个数组A[6] = {10,5,3,2,1,4}
10
/ \
5 3
/ \ / \
2 1 4
看到这,你似乎明白了.
对于一个数组,按照自上而下,从左往右填入二叉堆里.
接下来,反过来推:将这个非完全二叉树填入数组.
10
/ \
5 3
/ \ / \
4
B[5]={10,5,3,null,4]---这里null只是表达没有数据的意思.
为什么要保留这个null?因为我们从数组到二叉堆,需要按照自上而下,从左往右.毫无疑问,数组浪费了一个空间.对于非完全二叉树用数组不可避免地会在中间某些位置出现漏洞.
这也是为什么规定堆必须是完全二叉树了–它能有效避免空间浪费又能用简单的数组表示,否则数据规模一大,中间的’漏洞’越多实在令人心烦.
完全二叉树就一定是堆吗?别急还有限制
1
/ \
3 2
/ \ / \
5 4 6 7
堆是数组,那么堆必须有数组没有的优势,否则弄出Heap
这种数据结构没有意义.
上面的完全二叉树不是堆!
因为二叉堆还要满足两个性质,第二个性质也是二叉堆的分类.
- 二叉堆必须是完全二叉树.
- 二叉堆必须是大根堆和小根堆.
大根堆: 父节点的值必须大于等于孩子节点.注意:子树也必须满足.
小根堆:父节点的值必须小于等于孩子节点.注意:子树也必须满足.
用公式描述:大根堆:A[Parent]>=(A[leftChild] and A[rightChild])
小根堆:A[Parent]<=(A[leftChild] and A[rightChild])
总之根节点要么一直大于等于左右孩子要么一直小于等于.
那么问题又有了,我怎么知道parent
与child
的关系呢?
对于任意一个节点i.
对于任意节点 ( i ) (i) (i)下标, i > 0 i>0 i>0:
其父节点的下标为: [ parent ( i ) = ⌊ i − 1 2 ⌋ ] [ \text{parent}(i) = \left\lfloor \frac{i - 1}{2} \right\rfloor ] [parent(i)=⌊2i−1⌋]
其左孩子的下标为: [ left ( i ) = 2 i + 1 ] [ \text{left}(i) = 2i + 1 ] [left(i)=2i+1]
其右孩子的下标为: [ right ( i ) = 2 i + 2 ] [ \text{right}(i) = 2i + 2 ] [right(i)=2i+2]
证明方法:数学归纳法:详情问ChatGpt
.
这也是数组给我们带来的便利.
从树的角度(逻辑结构),堆有一般二叉树都有的属性.
比如有叶子节点个数,总节点个数,树的高度.
定义堆的高度:
堆的高度是从根节点到最深叶子节点的最长路径上的节点数(从0开始计算)
套用
t
r
e
e
h
e
i
g
h
t
=
l
o
g
2
n
treeheight=log_2n
treeheight=log2n
t
r
e
e
h
e
i
g
h
t
=
log
2
⌊
n
⌋
,
其中
n
为堆节点个数
.
treeheight= \log_2\left \lfloor n \right \rfloor, 其中n为堆节点个数.
treeheight=log2⌊n⌋,其中n为堆节点个数.
定义堆某节点NODE
的高度:
节点到其所有子树中最深叶子节点的最长路径上的边的数目.
A
/ \
B C
/ \ /
D E F
以上只是略微提及,后面会在代码部分实现并且严加证明.
实现大根堆
大根堆概念提过了.
小根堆也要实现,我们会实现一个小根堆并让它实现一个优先队列.
下面我们先用C实现,C部分重点说明思路,若为了解C语言可以看完思路后看Java部分
这里我以Windows环境,IDE:VS2022,VSCode,devC++
均可.
这里用VS2022
演示.
M
A
X
−
H
E
A
P
I
F
Y
:
维护堆的性质
MAX-HEAPIFY:维护堆的性质
MAX−HEAPIFY:维护堆的性质
B
U
I
L
D
−
M
A
X
−
H
E
A
P
:
无序数组建立大根堆
BUILD-MAX-HEAP:无序数组建立大根堆
BUILD−MAX−HEAP:无序数组建立大根堆
H
E
A
P
S
O
R
T
:
堆排序
,
对数组原址排序
HEAPSORT:堆排序,对数组原址排序
HEAPSORT:堆排序,对数组原址排序
M
A
X
−
H
E
L
P
−
I
N
S
E
R
T
,
H
E
L
P
−
E
X
T
R
A
C
T
−
M
A
X
,
H
E
A
P
−
I
N
C
R
E
S
E
−
K
E
Y
,
H
E
L
P
−
M
A
X
I
N
U
M
:
利用堆实现优先队列
MAX-HELP-INSERT,HELP-EXTRACT-MAX,HEAP-INCRESE-KEY,HELP-MAXINUM:利用堆实现优先队列
MAX−HELP−INSERT,HELP−EXTRACT−MAX,HEAP−INCRESE−KEY,HELP−MAXINUM:利用堆实现优先队列
下面开始代码实现环节:
先C语言后Java
,若不懂C,只看思路然后看Java部分代码即可.
在C中,有三个文件
heap.h
:结构体,接口.heap.c
:函数实现,内联函数,静态函数,宏等.Main.c
:测试.
还记得我们之前说过Parent
和Children
的关系吗?
在C中,我们用函数宏或者内联函数实现.
Java中用private static
修饰.
//heap.c
#include"heap.h"
//给定数组下标,返回父亲,左右孩子的下标.
//根节点下标为0.
//数组[0,1,2...A[].length-1]
static inline int Parent(i)
{
return (i-1) >> 1;
}
static inline int Left(i)
{
return i << 1 + 1;
}
static inline int Right(i)
{
return i << 1 + 2;
}
//heap.c
static inline void swap(int *x, int *y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
//heap.h
#ifndef HEAP_H
#define HEAP_H
#define DEFAULT_CAPACITY 10
#include<stddef.h>
typedef struct {
int* a;
int size;
int capacity;
}Heap;
//初始化
Heap* heapInit();
//HeapSort----堆排序
void buildHeap(Heap* hp,int arr[], int length);
void heapSort(int arr[],int length);
//delete-free
void heapDelete(Heap* hp);
//insert
void heapInsert(Heap* hp, int val);
//remove
void heapRemove(Heap* hp);
//优先级队列接口
//后续提供.
#endif
先实现一些初始化接口
//初始化
//heap.c头文件包含<stdlib.h>
Heap* heapInit()
{
Heap* hp = NULL;
if ((hp = (Heap*)malloc(sizeof(Heap))) == NULL)
{
perror("hp:Memory allcation failed!\n");
return NULL;
}
if ((hp->a = (int*)malloc(sizeof(int) * 4)) == NULL)
{
perror("hp->a:Memory allcation failed!\n");
free(hp);
return NULL;
}
hp->size= 0;
hp->capacity = DEFAULT_CAPACITY;
return hp;
}
接下来是重点:
如何将一个数组建成堆和维护堆性质的算法.
void buildHeap(Heap *hp,int arr[], int length)
{
//先拷贝数据
for (int i = 0; i < length; i++)
{
checkCapacity(hp);
hp->a[i] = arr[i];
hp->size++;
}
//从最后一个非叶子节点开始调整
for (int i = Parent(length-1); i >= 0; i--)
{
heapIfy(hp, i);
}
}
下面我们讨论heapIfy
函数.
static void heapIfy(Heap* hp, int i)
{
//确定父亲,左子树,右子树的下标
int l = Left(i);
int r = Right(i);
int larget = i;
// 筛最大值
if (l < hp->size && hp->a[l] > hp->a[larget])
larget = l;
if (r < hp->size && hp->a[r] > hp->a[larget])
larget = r;
if (larget != i) // 将最大值与原先子树根节点值交换
{
swap(&hp->a[i], &hp->a[larget]);
heapIfy(hp, larget); // 递归调用传入 larget 而不是 i
}
//若i就是最大的结束递归.
}
基于假设前提:对于节点$ i$,它的左子树(根节点为
L
e
f
t
(
i
)
Left(i)
Left(i))和右子树(根节点为
R
i
g
h
t
(
i
)
Right(i)
Right(i))已经是最大堆。
上面的调整算法是堆中著名的下沉算法或者向下调整法.
算法原理
:
基于左右子树均为最大堆的情况,让根节点和左右子树中较大的值交换,然后递归调用左右子树.—这样添加了一个新节点也满足最大堆
空间复杂度说明:
由于这里采用了递归的实现方法,最坏是
O
(
n
l
g
n
)
O(nlgn)
O(nlgn),即根节点一直调整到最下层的节点.
后续提供非递归实现.
static void heapIfy2(Heap* hp, int i)
{
//获取左孩子节点
int child = Left(i);
//循环终止条件:向下调整到尽头,从数组角度就是越界.
while(child < hp->size)
{
//若右孩子存在,且大于左孩子则修改.
if (child + 1 < hp->size && hp->a[child+1] > hp->a[child])
{
child++;//变为右孩子了
}
if (hp->a[child] > hp->a[i])
{
swap(&hp->a[child], &hp->a[i]);
i = child;
child = Left(i);
}
else //满足最大堆了,不必往下调了
{
break;
}
}
}
堆的层序遍历
检验上面是否是堆,方便我们画图.
堆的层序打印,只需遍历数组即可.
void print_heap(Heap* hp)
{
for (int i = 0; i < hp->size; i++)
{
printf("%d->", hp->a[i]);
}
return;
}
建堆操作
:
现在细说一下建堆操作.
插入法建堆:一种通过逐个插入元素到堆中,并在插入每个新元素后调整堆.
此法遍历整个数组(除首元素外),它保证了前面的元素都是最大堆,并对后续的节点执行插入操作+并将其调整为最大堆维护堆的性质.
理解:插入和调整(上浮操作).
时间复杂度:
O
(
n
l
g
n
)
O(nlgn)
O(nlgn)
空间复杂度:
O
(
1
)
O(1)
O(1)
//自上而下建堆
void Build_Heap1(int arr[], int length)
{
for (int i = 1; i < length; i++)
{
//sift-Up操作
//自行封装一个函数
int parent = Parent(i);
while (parent >= 0)
{
if (arr[parent] < arr[i])
{
swap(arr+parent,arr+i);
i = parent;
parent = Parent(i);
}
else
{
break;
}
}
}
}
自下而上建堆:
从最后一个非叶子节点开始,逐个向上调整每个节点,使得整个数组符合堆的性质
推导时间复杂度是一个加权几何级数,推导过程自行搜索.
时间复杂度:
O
(
n
)
O(n)
O(n)
空间复杂度:
O
(
1
)
O(1)
O(1)
void Build_Heap2(int arr[], int length)
{
for (int i = Parent(length - 1); i >= 0; i--)
{
int parent = i;
int child = Parent(parent);
while (child < length)
{
if (child + 1 < length && arr[child+1] > arr[child])
{
child++;
}
if (arr[child] > arr[parent])
{
swap(arr + child, arr + parent);
parent = child;
child = Left(parent);
}
else {
break;
}
}
}
}
结论:向下建堆法可以在线性时间将一个无序数组建成堆,速度更快!
堆排序(HeapSort)
堆排序要干什么呢?
前面提过了建堆操作,还有堆的性质.
论最大堆的性质,从征途来看,数的最大值在堆顶,即根节点.
所以,我们只需要将堆顶元素与最后一个元素交换,然后调整堆,再将堆顶元素与倒数第二个元素交换,再调整堆…直到堆中只剩下一个元素.
思路
:
- 将一个无序数组建成最大堆.
- 交换堆顶元素与最后一个元素.
- 调整堆,使其满足最大堆的性质.
- 重复2,3,直到数组中只剩下一个元素.(单个数区间不用再排序了)
//HeapSort----堆排序
static inline void siftDown(int arr[], int n, int parent) {
int child = Left(parent);
while (child < n) {
// 如果右子节点存在且大于左子节点,则选择右子节点
if (child + 1 < n && arr[child + 1] > arr[child]) {
child++;
}
// 如果子节点的值大于当前节点的值,则交换
if (arr[child] > arr[parent]) {
swap(arr + child, arr + parent);
parent = child;
child = Left(parent); // 更新 child 为新的左子节点
}
else {
break; // 子节点值不大于当前节点,堆性质已满足
}
}
}
void heapSort(int arr[],int length)
{
for (int i = Parent(length - 1); i >= 0; i--)
{
siftDown(arr, length, i);
}
for (int i = length - 1; i > 0; i--)
{
swap(arr,arr+i);
siftDown(arr, i, 0);
}
}
//验证以下序列的正确性
int main()
{
int arr[] = {5,13,2,25,7,17,20,8,4};
heapSort(arr,sizeof(arr)/sizeof(int));
for (int i = 0; i < sizeof(arr) / sizeof(int); i++)
{
printf("%d->", arr[i]);
}
return 0;
}
讨论堆排序的时间复杂度
:
对于任何一个数组,上面写的堆排序干了两件事:建堆和排序.
建堆的时间复杂度为
O
(
n
)
O(n)
O(n),前面提过不在赘述.
排序部分的最好、最坏、平均时间复杂度都是
O
(
n
l
g
n
)
O(nlgn)
O(nlgn).
就是说对于有序序列无论升序或是逆序,时间复杂度都是
O
(
n
l
g
n
)
O(nlgn)
O(nlgn).
因为自己观察siftDown
函数,发现每次调整堆的时间复杂度为
O
(
l
g
n
)
O(lgn)
O(lgn).`—因为起点都是从0开始走完每一趟循环,一共走
n
−
1
n-1
n−1次.
尽管需要进行建堆的操作,但用大
O
O
O渐进法,
O
(
n
+
n
l
g
n
)
=
O
(
n
l
g
N
)
O(n+nlgn)=O(nlgN)
O(n+nlgn)=O(nlgN).
结论:堆排序的时间复杂度:
O
(
n
l
g
n
)
O(nlgn)
O(nlgn)
堆剩余部分补充
insert接口
static void siftUp(Heap* hp,int child)
{
int parent = Parent(child);//根据孩子计算父亲所在的下标,无论是左孩子还是右孩子,由于整数除法结果一样。
while (child > 0)
{
if (a[child] < a[parent])//此处方向决定建大根堆还是小根堆
{
swap(&a[child], &a[parent]);//父子身份互换
//处理下标
child = parent;
parent = Parent(child);//请记住,向上调整永远是父亲定孩子的位置
}
else
{
break;
}
}
}
//insert
void heapInsert(Heap* hp, int val)
{
checkCapacity(hp);
hp->a[hp->size++] = val;
siftUp(hp,hp->size-1);
}
remove接口
:移除堆顶元素.
//remove
//先和数组最后一个元素交换,在缩小数组大小,然后调整堆
void heapRemove(Heap* hp)
{
if (hp->size > 0)
{
swap(&hp->a[0], &hp->a[hp->size-1]);
hp->size--;
siftDown(hp->a, hp->size, 0);
}
else
{
perror("Heap Empty:error!\n");
return;
}
}
delete接口
:释放堆
//delete
void heapDelete(Heap* hp)
{
free(hp->a);
free(hp);
}
Java实现最小堆
用Java实现优先级队列,所以先用Java实现一个最小堆.
语言本身不重要,核心思想有了即可.
/**
* @author 秋落风声
*/
public class Heap {
private int[] elem;
private int usedSize;
private static final int DEFAULT_CAPACITY = 10;
public Heap(int[] element) {
elem = new int[DEFAULT_CAPACITY];
this.usedSize = element.length;
for (int i = 0; i < element.length; i++) {
elem[i] = element[i];
}
for(int i=(element.length-1)/2;i>=0;i--)
{
siftDown(i,usedSize);
}
}
private void siftUp(int child)
{
int parent = (child-1)/2;
while(parent>=0&&elem[parent]>elem[child])
{
int tmp = elem[parent];
elem[parent] = elem[child];
elem[child] = tmp;
child = parent;
parent = (child-1)/2;
}
}
private void siftDown(int parent,int usedSize)
{
int child = parent*2+1;
while(child<usedSize)
{
if(child+1<usedSize&&elem[child+1]<elem[child])
{
child++;
}
if(elem[child]<elem[parent])
{
int tmp = elem[child];
elem[child] = elem[parent];
elem[parent] = tmp;
parent = child;
child = parent*2+1;
}
else
{
break;
}
}
}
public boolean isFull()
{
return elem.length == usedSize;
}
public void push(int val)
{
if(isFull()){
elem = Arrays.copyOf(elem,2*elem.length);
}
elem[this.usedSize++] = val;
siftUp(this.usedSize-1);
}
public boolean isEmpty(){
return this.usedSize == 0;
}
public int peek()
{
if(isEmpty()){
return Integer.MAX_VALUE;
}
else
{
return this.elem[0];
}
}
public int poll()
{
if(isEmpty())
{
return Integer.MAX_VALUE;
}
else
{
int tmp = elem[0];
elem[0] = elem[usedSize-1];
usedSize--;
siftDown(0,usedSize);
return tmp;
}
}
}
TopK问题
TopK问题指的是在一组数据中找到前K个最大的(或最小的)元素。
- 对于这个问题,你可能首先想到的是排序。调用以下
qsort
函数,将数组排序,或者Java中的Arrays.sort()
函数,C++中的sort()
函数。
再不济,可以用先前了解的堆排序解决吧.
不,你错了.固然排序确实是一种思路,但我们问题在于只想取前k个最大的数.
而采用堆或者后面的优先级队列,就能以较低的时间复杂度解决.
提前剧透一下:时间复杂度 O ( n l o g 2 k ) O(nlog_2k) O(nlog2k).k越小就接近线性 O ( n ) O(n) O(n),否则越接近堆排序的时间复杂度 O ( n l g n ) O(nlgn) O(nlgn).
思路有三种:
- 基于快速排序思路的快速选择法
- 无脑排序
- 堆排序思想.—只说明这一种思路.
问题:
给定n个随机数,随机取出起前K个最大的数.
解决方案:
- 建立一个大小为K的最小堆,将前K个元素放入堆中.
- 随后遍历剩下的n-k个元素,如果比堆顶大,则替换堆顶,并进行调整.最终最小堆剩下的就是前K个较小数.
数组中的第K个最大元素:手写一个最小堆,用上面的思路试试.
//向下调整法
void siftDown(int *nums,int n,int parent)
{
int child = parent*2+1;
while(child<n)
{
if(child+1<n&&nums[child+1]<nums[child])
{
child++;
}
if(nums[child]<nums[parent])
{
int tmp = nums[child];
nums[child] = nums[parent];
nums[parent] = tmp;
parent = child;
child = parent*2+1;
}
else
{
break;
}
}
}
int findKthLargest(int* nums, int numsSize, int k) {
int *minHeap = (int*)malloc(sizeof(int)*k);
for(int i=0;i<k;i++)
{
minHeap[i] = nums[i];
}
for(int i = (k-1)/2;i>=0;i--)
{
siftDown(minHeap,k,i);
}
for(int i = k;i<numsSize;i++)
{
if(nums[i]>minHeap[0])
{
minHeap[0]=nums[i];
siftDown(minHeap,k,0);
}
}
return minHeap[0];
}
雄关漫道真如铁,而今迈步从头越.
险就一身乾坤精,我心依旧望苍天.