数据结构-堆

学习顺序

  1. 堆的概念,优先级队列
  2. C语言/Java实现简单的堆
  3. 堆排序
  4. 堆应用-TopK问题

堆和优先队列.

C语言内存中提及到过"堆"的概念,在这里说明,C中的堆指操作系统中的虚拟内存.
Java中堆被引申"垃圾存储机制".
堆一词,是基于一种名为堆排序的排序算法—(这也是本篇内容之一).
此处堆是一种dataStructure:一种数据结构.
另外这里是介绍的堆是二叉堆
关于堆的分类:
二叉堆,斐波那契堆,二项堆等.
堆的应用:

  1. 堆排序.
  2. 实现优先级队列
  3. TopK问题
  4. 图算法找最短路径和最小生成树.—高阶数据结构再谈.

堆介绍
(二叉)堆是一种完全二叉树,从逻辑结构上可以看出.
二叉堆底层实际是一个数组.
比如下面一个数组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这种数据结构没有意义.

上面的完全二叉树不是堆!
因为二叉堆还要满足两个性质,第二个性质也是二叉堆的分类.

  1. 二叉堆必须是完全二叉树.
  2. 二叉堆必须是大根堆和小根堆.
    大根堆: 父节点的值必须大于等于孩子节点.注意:子树也必须满足.
    小根堆:父节点的值必须小于等于孩子节点.注意:子树也必须满足.
    用公式描述:大根堆:A[Parent]>=(A[leftChild] and A[rightChild])
    小根堆:A[Parent]<=(A[leftChild] and A[rightChild])
    总之根节点要么一直大于等于左右孩子要么一直小于等于.
    那么问题又有了,我怎么知道parentchild的关系呢?
    对于任意一个节点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)=2i1]
    其左孩子的下标为: [ 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=log2n,其中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:维护堆的性质 MAXHEAPIFY:维护堆的性质
B U I L D − M A X − H E A P : 无序数组建立大根堆 BUILD-MAX-HEAP:无序数组建立大根堆 BUILDMAXHEAP:无序数组建立大根堆
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:利用堆实现优先队列 MAXHELPINSERT,HELPEXTRACTMAX,HEAPINCRESEKEY,HELPMAXINUM:利用堆实现优先队列
下面开始代码实现环节:
先C语言后Java,若不懂C,只看思路然后看Java部分代码即可.
在C中,有三个文件

  1. heap.h:结构体,接口.
  2. heap.c:函数实现,内联函数,静态函数,宏等.
  3. Main.c:测试.
    还记得我们之前说过ParentChildren的关系吗?
    在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)

堆排序要干什么呢?
前面提过了建堆操作,还有堆的性质.
论最大堆的性质,从征途来看,数的最大值在堆顶,即根节点.
所以,我们只需要将堆顶元素与最后一个元素交换,然后调整堆,再将堆顶元素与倒数第二个元素交换,再调整堆…直到堆中只剩下一个元素.
思路:

  1. 将一个无序数组建成最大堆.
  2. 交换堆顶元素与最后一个元素.
  3. 调整堆,使其满足最大堆的性质.
  4. 重复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 n1次.
尽管需要进行建堆的操作,但用大 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个最大的(或最小的)元素。

  1. 对于这个问题,你可能首先想到的是排序。调用以下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).

思路有三种:

  1. 基于快速排序思路的快速选择法
  2. 无脑排序
  3. 堆排序思想.—只说明这一种思路.

问题:
给定n个随机数,随机取出起前K个最大的数.
解决方案:

  1. 建立一个大小为K的最小堆,将前K个元素放入堆中.
  2. 随后遍历剩下的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];
}

雄关漫道真如铁,而今迈步从头越.
险就一身乾坤精,我心依旧望苍天.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值