🎇个人主页:Ice_Sugar_7
🎇所属专栏:初阶数据结构
🎇欢迎点赞收藏加关注哦!
文章目录
🍉树
●树是一种非线性
的数据结构,它是由n(n>=0)个结点组成,具有层次关系
●有一个特殊的结点,称为根结点
,根节点没有前驱结点
●除根节点外,其余结点被分成M(M>0)个互不相交
的集合,每个集合是一棵子树
🍉二叉树
二叉树一个非空结点的子树为空或者至多两个子树(左子树和右子树)
从这个图可以看出:
二叉树不存在度大于2的结点
二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
🍌特殊二叉树
满二叉树:每一层结点数都达到最大值的二叉树。如果一棵满二叉树有k层
,那它结点总数就是2^k-1
完全二叉树:最后一层抠掉几个结点的满二叉树,就是一般的完全二叉树(满二叉树是特殊的完全二叉树)
🍌二叉树的性质
二叉树的性质都在下图了:
🍌存储结构
二叉树一般使用两种结构存储:顺序结构
和链式结构
顺序结构存储就是使用数组
来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实使用中只有堆才会使用数组来存储
二叉树顺序存储在物理结构上是一个数组,在逻辑结构上是一颗二叉树
我们的代码是按照其物理结构写的,而具体想实现的函数接口则是根据逻辑结构展开的(往下看入堆、出堆、调整等函数之后,你就能理解这句话了)
本文讲二叉树的顺序存储结构——堆(正文开始)
🍉堆
堆分为两种:大堆和小堆。
大堆:除了叶子结点外,所有结点的孩子都比自己小
小堆:除了叶子结点外,所有结点的孩子都比自己大
根据堆的逻辑结构可知,大堆是上面的结点(位于较低层次的结点)大,小堆是上面的结点小
🍌堆的结构
堆的物理结构就是顺序表,所以代码基本和顺序表一模一样
typedef int HPDataType;
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
🍌插入
插入这一步很简单,就直接往数组插入元素(注意检查容量是否足够,并且插入后记得让size加1)
插入后,要对这个元素进行调整,采用向上调整
🥝向上调整算法
假设要建一个小堆
,那就要拿它和它的双亲进行比较,如果它比双亲小,就和双亲交换位置。假设数组名为_a
,大小为size
,那插入的结点下标为size-1
,它的双亲在数组中的下标就是(size-1-1)/2
这里要用循环,将插入的结点记为child,当child为0时(即它到了堆顶),循环终止。如果中途经比较后发现不用换位置的话,说明调整好了,直接break跳出循环
代码如下:
typedef int HPDataType;
void Swap(HPDataType* hp1, HPDataType* hp2) {
HPDataType tmp = *hp1;
*hp1 = *hp2;
*hp2 = tmp;
}
//小堆向上调整
void AdjustUp(Heap* hp,int child) {
assert(hp);
int parent = (child - 1) / 2;
while (child > 0) {
if (hp->_a[child] >= hp->_a[parent]) //孩子比双亲大,退出循环
break;
else {
Swap(&(hp->_a[child]),&(hp->_a[parent])); //两结点交换
child = parent;
parent = (parent - 1) / 2;
}
}
}
🫐时间复杂度分析
因为堆是完全二叉树,而满二叉树也是完全二叉树,为了简化问题,就用满二叉树来证明了(时间复杂度本来看的就是近似值,多几个节点不影响最终结果),下面向下调整算法的时间复杂度也这样处理
假设有n个结点,那就有log(n+1)层,那每次向下调整最多遍历log(n+1)次
,总共有n个结点,那么就遍历n*log(n+1)次,时间复杂度就是O(N*logN)
🍌删除
不能直接将数组往前挪一位,因为这样虽然在物理结构(数组)上没什么问题,但是在逻辑结构(完全二叉树)上就有问题了,会打乱结点间的关系(比如原先的兄弟现在变为父子,父子变兄弟)
有一个比较巧妙的解决办法,就是将根结点和尾结点(数组最后一个元素)交换位置,然后将新的尾结点删掉,这样就不会影响到结点间的关系了
删掉后要进行向下调整,这就涉及到向下调整算法了
🥝向下调整算法
现在有一个数组,它有n个元素,从逻辑结构上看成是完全二叉树,我们从根结点开始,通过向下调整算法可以把它调整为一个小堆
这种算法的前提是左右子树必须是一个堆,才能调整
int array[] = {27,15,19,18,28,34,65,49,25,37};
调整过程如图:
每次调整,先比较该结点两个孩子的大小,现在要调整为小堆,就先找出较小的孩子,然后这个孩子和双亲进行比较,若孩子<双亲,就把它和双亲交换位置;反之则说明调整完毕
调整的过程显然也要用循环。我们将双亲结点记为parent,左孩子结点记为child(因为左右孩子下标相差1,没必要用leftchild和rightchild进行区分)。那右孩子就是child + 1,不过由于右孩子可能不存在(当child为叶子结点时可能会有这种情况),所以我们得在循环里面判断一下
这里采用假设法,就是我们先假设左孩子是较小的结点(因为右孩子可能不存在,不方便假设),如果右孩子存在的话,就拿左右孩子进行比较,最后将child赋给较小者
(假设法相比于if语句,可以有效简化代码,具体可以看我之前那篇判断相交链表的题解,or看下面的代码也ok)文章链接:判断相交链表
那循环的终止条件呢?显然当child>=n的时候就要跳出循环了
代码如下:
void AdjustDown(HPDataType* a,int n,int parent) { //n为数组大小
assert(a);
int child = 2 * parent + 1; //左孩子下标
while (child < n) {
if (child + 1 < n && a[child+1] <= a[child]) { //右结点存在并且右孩子比左孩子小
child++; //将child设为右孩子结点,等会儿拿它和双亲进行比较,决定是否交换
}
if (a[child] < a[parent]) {
Swap(&a[child], &a[parent]);
parent = child; //更新双亲结点
child = parent * 2 + 1; //更新孩子结点
}
else
break; //不用换位置说明调整完毕
}
}
🫐时间复杂度分析
“向下移动”的层数指的是最多要调整几次,即从这个结点开始,一直调整到叶子结点为止(最坏的情况)
从结果可以看出:向下调整的时间复杂度(O(N))比向上调整的小,所以建议使用向下调整
🥝两种调整算法的区别
向上调整和向下调整并不是我们口头理解的“上”和“下”,它们区别在于调整的场景、进行比较的对象
向上:在堆的最后插入元素后进行的调整,是为了把插入的新元素调整到堆中合适的位置,仅涉及该结点与其双亲结点的比较
向下:涉及到结点与其兄弟结点的比较,得到二者的较大值/较小值再与双亲结点比较
🍌堆的创建(堆的初始化)
建堆既可以建一个空堆,也可以根据一个现成的数组建堆
建空堆就是将数组赋为空指针,然后size和capacity都赋为0。和顺序表初始化一样,不多赘述
这里主要来讲数组建堆
思路是:给堆开辟空间+拷贝+调整
●开空间:数组多大就开多大
●拷贝:使用memcpy将数组的元素拷贝给堆
●调整:向上调整or向下调整
先展示向上调整
void HeapCreate(Heap* hp, HPDataType* a, int n) {
assert(hp);
assert(a);
HPDataType* tmp = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (tmp == NULL) {
perror("malloc fail");
exit(-1);
}
hp->_a = tmp;
hp->_capacity = hp->_size = n;
memcpy(hp->_a, a, n * sizeof(HPDataType)); //把数组数据拷贝到堆的数组中
int parent = (hp->_size - 1) / 2;
for (int i = 1; i < n; i++) { // 调整建堆
AdjustUp(hp, i);
}
}
也可以复用刚才写的入堆函数,因为它自带向上调整函数。而且push函数是将数组的元素一个一个放进堆的,这样就不需要memcpy了,代码如下(为方便观察,我把向上调整函数和入堆函数也放在下面):
//小堆向上调整
void AdjustUp(Heap* hp, int child) {
assert(hp);
int parent = (child - 1) / 2;
while (child > 0) {
if (hp->_a[child] >= hp->_a[parent]) //孩子比双亲大,退出循环
break;
else {
Swap(hp->_a[child], hp->_a[parent]); //两结点交换
child = parent;
parent = (child - 1) / 2;
}
}
}
void HeapPush(Heap* hp, HPDataType x) {
assert(hp);
//如果满了 那就要扩容
if (hp->_capacity == hp->_size) {
int newcapacity = hp->_capacity == 0 ? 4 : 2 * hp->_capacity;
HPDataType* tmp = (HPDataType*)realloc(hp->_a, newcapacity * sizeof(HPDataType));
if (tmp == NULL) {
perror("realloc fail");
exit(-1);
}
hp->_a = tmp;
hp->_capacity = newcapacity;
}
hp->_a[hp->_size] = x;
hp->_size++;
AdjustUp(hp, hp->_size - 1);
}
void HeapCreate(Heap* hp, HPDataType* a, int n) {
assert(hp);
HeapInit(hp); //初始化为空堆
for (int i = 0; i < n; ++i) {
HeapPush(hp, a[i]);
}
}
由于向上调整的效率不及向下调整,所以建议采用向下调整建堆
向下调整要求左子树和右子树也都是堆,又因为单个叶子结点既可以看作是大堆,也可以看成小堆,所以我们从叶子结点的双亲开始向下调整
比如下面这个数组要建一个大堆
int a[] = {4,3,5,7,2,6,8,65,100,70,32,50,60};
调整后,红色方框内就是一个大堆了,对于3,5这两个结点而言,左右子树都是大堆,那它们也可以向下调整了
代码如下:
void HeapCreate(Heap* hp, HPDataType* a, int n) {
assert(hp);
HPDataType* tmp = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (tmp == NULL) {
perror("malloc fail");
exit(-1);
}
hp->_a = tmp;
hp->_capacity = hp->_size = n;
memcpy(hp->_a, a, n * sizeof(HPDataType)); //把数组数据拷贝到堆的数组中
int parent = (hp->_size - 1 - 1) / 2; //最后一个结点的双亲结点
for (int i = parent; i >= 0;i--) { //从该结点开始进行向下调整
AdjustDown(hp->_a, n, i);
}
}
🍌堆排序
现在有一个数组,要把它排成升序
如果建小堆,那么很容易就可以找到最小的元素。但是要找次小元素的时候,把数组剩下的元素看作完全二叉树的话,它们之间的关系会乱掉
●所以要建大堆
,建好后最大的元素就在根结点,将它和最后一个结点交换,就把最大的元素排好了
●然后size-1
剔除最大的元素,对于剩下的元素,因为根结点的左右子树也都是大堆,可以采用向下调整,调整后可以把第二大的元素移动到堆顶(根结点),再和最后一个结点交换,第二大元素就排好了
●剩下的元素也如法炮制
void HeapSort(int* a, int k) { //a为给定数组
for (int i = (k - 1 - 1) / 2; i >= 0; i--) { //调整为一个堆
AdjustDown(a, k, i);
}
for (int i = k - 1; i >= 0; i--) { //采用删除结点的思想,先交换,再调整
Swap(a[0], a[i]);
AdjustDown(a, i, 0);
}
}
排序后得到:
🍌top k 问题
这个问题就是要找出数组中从大到小(或从小到大)的前k个数,下面以从大到小为例
如果要找从大到小的前k个数,我们可以先从数组中选k个数,建一个大小为k的小堆,然后将数组中剩下的数和堆顶的数进行比较,如果比它大,就替代它,然后向下调整。
这个方法的原理是:放一个比较小的数“卡”在堆顶,类似守门员,比它大的数就能进堆,不断把堆中较小的数踢出去,到最后就留下最大的前k个数
//取最大的前k个
void TopK(HPDataType* pa, int n, int k) {
Heap ph;
HeapInit(&ph);
HeapCreate1(&ph, pa, k); //建小堆
for (int i = k; i < n; i++) //遍历剩下的元素
{
if (pa[i] > ph._a[0]) {
ph._a[0] = pa[i];
AdjustDown(ph._a, k, 0); //小堆向下调整
}
}
HeapSort(ph._a, k); //将得到前k数进行排序
HeapPrint(&ph);
}
🍉写在最后
以上就是本篇文章的全部内容,如果你觉得本文对你有所帮助的话,那不妨点个小小的赞哦!(比心)