数据结构——被优先队列玩起来的“树“

小伙伴看完中篇的BST树和AVL树可能会觉得,树也太折腾人了,放过孩子吧。最终篇是不是要甩大招了喃,不😭,😭😭

最终章了,主要的设计是为了对付那人人得而诛之的期末考试,我下面所总结的内容,除了堆会在算法竞赛中用到,其他两个就让它们在期末考试的舞台上蹦跶吧,可恶的考试 💔

考试

⭐⭐⭐

什么是堆🌊🌊

✅什么是优先队列

优先队列(Priority Queue):一种特殊的队列,取出元素的顺序是依照元素的优先权(关键字)大小,而不是元素进入队列的先后顺序

经验出发,我们可以用数组实现、也可以用链表实现,下面我们通过一张图看看它们的实现效果
效果对比

通过对比可以看出来,要么时间复杂度过高,要么就只是在某一种操作上很突出,其他操作又相对难受了,整体都是不太尽人意。那么是否可以采用二叉树存储结构?假如二叉树的结点数是n,那么一棵完全二叉树的树高就是logN。利用这一性质,确实可以实现高速的管理数据

✅优先队列的重要性

优先队列

✅优先队列和堆的暧昧关系

优先队列

能够完成下列操作的数据结构叫做优先队列

  1. 插入一个数组
  2. 取出最值(能获得数值,能实现删除)

示意图

能够使用二叉树高效解决上述问题的数据结构被称为堆

✅回忆完全二叉树

没有满二叉树那么完美,最后可能会缺一些子树,但是一定一定要保证子树是按照层序编号排列的二叉树
完全二叉树

✅二叉堆

如果一棵完全二叉树各结点的键值与一个数组的各元素具备如图所示的对应关系,那么这个完全二叉树就是二叉堆。
二叉堆

二叉堆的逻辑结构是完全二叉树,但是实际却是一个用1作为起点的一维数组来表示的

若将表示二叉堆的数组命名为A,二叉堆的元素大小(元素个数)为H,那么二叉堆的元素就存储在A[1,…,H]中,其中根的下标为1.当给定一个结点的下标i时,就可以通过i/2、2 * i 、 2 * i +1轻松的算出来其父结点parent(i)、左子结点left(i)、右子结点right(i)。

✅举点栗子

二叉堆的一些栗子

✅二叉堆的特性

  1. 结构性:用数组表示的完全二叉树;
  2. 有序性:任一结点的关键字是其子树所有结点的最大值(或最小值)
    1. “最大堆(MaxHeap)”,也称“大顶堆”:最大值
    2. “最小堆(MinHeap)”,也称“小顶堆” :最小值

最大堆的根中存储着最大元素,回看二叉堆的图,可以看出来它就是一个最大堆

堆的抽象数据类型描述(最大堆演示带入)🌊🌊

✅堆的主要操作

最大堆 MaxHeap H,元素ElementType item

  1. MaxHeap Create( int MaxSize ):创建一个空的最大堆。
  2. Boolean IsFull( MaxHeap H ):判断最大堆H是否已满。
  3. Insert( MaxHeap H, ElementType item ):将元素item插入最大堆H。
  4. Boolean IsEmpty( MaxHeap H ):判断最大堆H是否为空。
  5. ElementType DeleteMax( MaxHeap H ):返回H中最大元素(高优先级)。

✅最大堆的创建

struct HeapStruct{
	int Capacity;//堆的最大容量 
	int Size;//堆的当前元素个数 
	ElementType *Elements;//存储堆元素的数组 
};

MaxHeap Create( int MaxSize )
{
	//创建一个容量为MaxSize的空的最大堆
	MaxHeap H = malloc(sizeof(struct HeapStruct));
	H->Elements = malloc((MaxSize + 1) * sizeof (ElementType));
	H->Size = 0;
	H->Capacity = MaxSize;
	H->Elements[0] = MaxData;//在数组没有用到0号索引处放置一个哨兵。待会再理解为什么要放置它 
	
	return H;
} 

✅最大堆的插入

操作流程:

  1. 将这个新的元素插入到堆的最后一个元素,因为是用数组实现的堆,在数组的末尾直接修改数组的长度就可以实现插入或者删除操作。

  2. 将其和它的父结点作比较,倘若它的数值比根节点的大,它就往上走,再比较,直到调整为符合二叉堆的性质为止。

void Insert(MaxHeap H, ElementType item) //将元素item插入最大堆H,其中H->Elements[0]已经定义为哨兵
{
	int i;
	if(IsFull(H))  
	{
		printf("堆已满");
		return ;
	}
	i = ++H->Size;//i指向插入后堆中的最后一个元素的位置
	for(; H->Elements[i/2] < item;i /= 2) H->Elements[i] = H->Elements[i/2];//向上调整。将这个插入的数据调整到合适它的位置
	
	H->Elements[i]  = item;//将item插入 
} 

H->Element[ 0 ] 是哨兵元素,它不小于堆中的最大元素,控制顺环结束。在加了哨兵来控制循环的结束以后,我们要考虑的内容就减少到怎么实现逻辑,不用多花时间去想循环要执行多少次
哨兵

✅最大堆的删除

删除思路和插入是类似的,因为二叉堆的本质是用数组模拟的,删除数组的最后一位是很容易的,直接数组长度长度就可以了,所以这里的思路是用数组的最后一个元素替代要删除的元素,再调整

ElementType DeleteMax(MaxHeap H) 
{
	int Parent ,Child;//代表两个指针,分别指向父结点和子结点 
	ElementType MaxItem,temp;
	if(IsEmpty(H)) 
	{
		printf("最大堆为空");
		return ;
	}
	
	MaxItem = H->Elements[1];//取出根节点的最大值
	
	temp = H->Elements[H->Size--] ;//获取最大堆的最后一个元素从根节点Parent开始过滤 
	for(Parent = 1;Parent * 2 <= H->Size;Parent = Child)
	{
		Child = Parent * 2;
		if((Child != H->Size) && (H->Elements[Child] < H->Elements[Child+1])) Child ++;//Child指向左右子结点的较大者
		if(temp >= H->Elements[Child]) break;//满足最大堆的要求了,调整结束 
		else H->Elements[Parent] = H->Elements[Child];
		
	}
	H->Elements[Parent] = temp;
	return MaxItem;
}

手动模拟堆⭐⭐⭐

C++选手确实可以直接调用STL容器中的priority_queue实现优先队列。和queue一样,push()用于向队列中插入一个元素,top()用于访问(读取)开头元素,pop()用于删除开头元素。在priority_queue中,开头元素永远是拥有最高优先级的元素。

对于没有这个库函数的选手,可以练习如何手写堆
模拟堆

参考代码(C++版本)

#include <iostream>
#include <algorithm>
#include <string.h>

using namespace std;

const int N = 100010;

int h[N], ph[N], hp[N], cnt;

void heap_swap(int a, int b)
{
    swap(ph[hp[a]],ph[hp[b]]);
    swap(hp[a], hp[b]);
    swap(h[a], h[b]);
}

void down(int u)
{
    int t = u;
    if (u * 2 <= cnt && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= cnt && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
    if (u != t)
    {
        heap_swap(u, t);
        down(t);
    }
}

void up(int u)
{
    while (u / 2 && h[u] < h[u / 2])
    {
        heap_swap(u, u / 2);
        u >>= 1;
    }
}

int main()
{
    int n, m = 0;
    scanf("%d", &n);
    while (n -- )
    {
        char op[5];
        int k, x;
        scanf("%s", op);
        if (!strcmp(op, "I"))
        {
            scanf("%d", &x);
            cnt ++ ;
            m ++ ;
            ph[m] = cnt, hp[cnt] = m;
            h[cnt] = x;
            up(cnt);
        }
        else if (!strcmp(op, "PM")) printf("%d\n", h[1]);
        else if (!strcmp(op, "DM"))
        {
            heap_swap(1, cnt);
            cnt -- ;
            down(1);
        }
        else if (!strcmp(op, "D"))
        {
            scanf("%d", &k);
            k = ph[k];
            heap_swap(k, cnt);
            cnt -- ;
            up(k);
            down(k);
        }
        else
        {
            scanf("%d%d", &k, &x);
            k = ph[k];
            h[k] = x;
            up(k);
            down(k);
        }
    }

    return 0;
}

✅模块化剖析

heap_swap ✨ ✨ ✨

swap

int ph[N];//ph是从指针数组指向堆heap,存的是第k个插入的数在堆里面下标是多少 
int hp[N];//hp就是从堆heap映射到指针,堆里面的某一个点是第几个插入的点

手动实现堆最主要的就是实现它的交换。为此,我们额外开了两个数组。代表从指针数组pointer指向堆heap的数组ph 。 以及代表从堆heap指向指针数组pointer的hp数组。

因为是用数组模拟的堆,在使用指针的思想进行定位的时候,我们利用ph数组中存的索引下标定位到它在堆中的位置。也可以通过堆中的点,找回来它在数组中的索引
交换

down(int u)

对这个传入的位置进行向下调整。假如它的左右孩子存在,并且比它这个父结点小,就把父结点交换下去。

    int t = u;
    if (u * 2 <= cnt && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= cnt && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
up(int u)

当传入的位置的父结点存在,而且这个位置中的数据比父结点中的数据大,就将这个位置和父结点交换,实现向上调整

while (u / 2 && h[u] < h[u / 2])
    {
        heap_swap(u, u / 2);
        u >>= 1;
    }

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

下面的内容有个印象就好了。它们只会在考场上折磨咱们
退学

树和森林与二叉树的转换🌊🌊

✅树转换为二叉树

步骤:

  1. 加线:把除根节点之外的所有兄弟结点连起来
    树转二叉树加线

  2. 去线:把除长子(最左边的结点)以外的其他孩子结点之间的线去掉
    树转二叉树去线

  3. 层次调整:以根结点为圆心,每个结点顺时针旋转到看起来舒服的位置就好
    层次调整

✅森林转换为二叉树

森林是由若干树组成的,所以完全可以理解为,森林中的每一棵树都是兄弟,我们可以按照处理兄弟的处理方式来操作。

  1. 把森林中每一棵树转换二叉树
  2. 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的右孩子
    森林转二叉树

✅二叉树转换为树

  1. 加线:如果某个结点的左孩子有右子树,让该结点和其左孩子的右子树的右分支上各结点连起来
  2. 去线:去掉原二叉树中所有结点与其右子树的连线
  3. 调整
    二叉树转树

✅二叉树转换为森林

  1. 特判。只看这棵二叉树的根结点有没有右孩子,有就是森林,没有就是一棵树
  2. 从根结点连线,若右孩子存在,就把它与右孩子的线去掉
  3. 看看分出的二叉树,若右孩子存在,就继续删除连线,直到没有👀
    二叉树转森林

哈夫曼树和哈夫曼编码🌊🌊

✅日常瞎叭叭叭点闲话

哈夫曼编码了,笔者目前也没有在竞赛题或者面试题中看到,但是期末考试好像要考吧~,所以这里梳理了一下,考试的时候解决它就行。前辈们发明的创世之作,感觉现在还拿捏不住
拿捏不住

✅哈夫曼树的定义

  1. 路径:从树中一个结点到另一个结点之间的分支构成两个结点之间的路径
  2. 路径长度:路径上分支数目
  3. 树的路径长度:树根到每一个结点的路径长度之和
    路径的长度

如上图所示,第一个图中,根结点到D结点的路径长度是4。第二个图中,根结点到D结点的路径长度是2

如果考虑带权的结点,结点的带权路径长度为从该结点到树根之间的路径长度与权值的乘积。

哈夫曼树:设二叉树有n个叶子结点,每个叶子结点带有权值 wk,从根结点到每个叶子结点的长度为 lk,则其中带权路径长度WPL最小的二叉树。

哈夫曼树的构造

每次把权值最小的两棵二叉树合并。

  1. 将给定的n个权值{w1,w2,…,wn}作为n个根结点的权值构造一个具有n棵二叉树的森林{T1,T2,…,Tn},其中每棵树只有一个根节点。也就是把每个权值单独放在一个结点中,直接作为一棵树
  2. 森林中选取两棵树中根结点权值最小的的二叉树作为左右子树构造一棵新的二叉树,新的二叉树的根结点权值为原来两棵树根结点的权值和
  3. 在森林中将被选取的两个根节点从森林中删除,将新构建的二叉树加入到森林中
  4. 重复2 和 3操作
    构造流程

参考实现代码

//注:这只是核心流程的代码,部分函数这里没有附上的~
typedef struct TreeNode *HuffmanTree;
struct TreeNode{
	int Weight;
	HuffmanTree Left , Right;
};

HuffmanTree Huffman(MinHeap H)
{
	//假设H->Size个权值已经存在H->Elements[]->Weight里
	int i;
	HuffmanTree T;
	BuildMinHeap(H);//将H->Elements[]按权值调整为最小堆
	for(i = 1; i < H->Size;i++)  //做 H->Size -1 次合并 
	{
		T = malloc(sizeof (struct TreeNode)) ;
		T->Left = DeleteMin(H);//从最小堆中删除一个结点,作为新T的左子结点 
		T->Right = DeleteMin(H) ;//最小堆中删除一个结点,作为新T的右子结点 
		T->Weight = T->Left->Weight + T->Right->Weight;//计算新的权值
		Insert(H,T) ;//将新的T插入到最小堆 
		 
	}
	T = DeleteMin(H) ;
	return T;
}

✅总结哈夫曼树的特点

  1. 没有度为1的结点;
  2. n个叶子结点的哈夫曼树共有2n-1个结点
  3. 哈夫曼树的任意非叶节点的左右子树交换后仍是哈夫曼树
  4. 对同一组权值{w1 ,w2 , …… , wn},存在不同构的两棵哈夫曼树
    不同构

哈夫曼编码

哈夫曼编码是一种用于使编码总长最短的编码方案。目的也很直接,压缩数据存储和传输的成本,就可以省钱啦~
没钱了

✅哈夫曼编码的生成方式

  1. 构造哈夫曼树,设需要编码的字符集合为{d1,d2,…,dn},它们在电文中的权重集合为{w1,w2,…,wn},以d1,d2,…,dn作为叶子结点,w1,w2,…,wn作为它们的权值,构造一棵哈夫曼树
  2. 在哈弗曼树上求叶子结点的编码。规定哈夫曼树中的左分支代表0,右分支代表1,则从根结点到每个叶子结点所经过的路径分支组成的0和1的序列便是该结点的对应字符的不等长编码。

写在最后🎈🎈🎈

若有偏僻,欢迎各位小伙伴及时雅正( ^ - ^ )

持续更新对数据结构的总结和理解喔💖~

小伙伴们都应该也在备战期末了,笔者也在慢慢备考,更文就会慢一点点哈,原谅我。期末加油~ 🌹🌹🌹

高分

  • 28
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 37
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 37
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

杨枝

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值