上回,我讲了树是啥。这篇文章我讲一下二叉树是啥,二叉树应该怎么实现。
二叉树是啥
二叉树的概念
二叉树其实用简单的话讲,就是一棵度为2的树。也就是树的每个节点最多有两个子节点。
下图可以很清晰地看到二叉树是啥样。
二叉树的三种情况
二叉树从子节点的个数来看还分为两种。前面讲到,度为2的树是指每个节点最多有两个子节点,但不代表它就一定要有子节点。所以可以将全部都有两个子节点的二叉树和不是每个节点都有两个子节点的二叉树分开。而不是每个节点都有两个子节点的二叉树又可以分为完全二叉树和普通二叉树。
满二叉树
前面讲到的每个节点都有两个子节点的二叉树就是一棵满二叉树,上图就是一棵满二叉树。再看下面这张图片,是不是也很像一棵满二叉树。
完全二叉树
完全二叉树是一种效率很高的数据结构。它是由满二叉树演变而来的。
假设完全二叉树有n个结点,那么这n个节点中间是不会出现空节点的,一旦出现空节点,那么这个空节点就是最后一个节点的兄弟节点,或者堂兄节点。满二叉树其实就是一种特殊的完全二叉树。
二叉树的一些性质
二叉树的实现
如标题所说,二叉树有两种结构去存储,一种是顺序存储,一种是链式存储。接下来我来实现一下这两种存储方式。
顺序存储
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储,二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
上面说到,在现实中只有堆会使用数组来存储。其实如果用数组来实现二叉树,而离开堆的话,其实没啥意义,就只是单独的存储。所以我们直接来实现堆,和说明一下为什么说完全二叉树是一种效率很高的数据结构。
首先我们来了解一下堆是什么。我们说到,完全二叉树顺序存储在物理上是一个数组,在逻辑上是一颗完全二叉树。我们把这个存储的数据符合完全二叉树的分布的数组就叫堆。
堆又分为大堆和小堆,小堆就是这棵完全二叉树的每一个父节点都要比子节点小,大堆就是这棵完全二叉树的每一个父节点都要比子节点大。为了统一,我接下来说的堆没有说明就默认是小堆。
好的,了解了堆的概念后,我们来实现堆。
首先我们肯定需要定义一个结构体来表示堆,我们来想想这个堆需要什么,第一个肯定是一个数组的首地址,第二个是最后一个节点在数组的下标,第三个是数组的大小(因为没人知道到底需要存多大,所以可能会扩容)。其实这真的很像顺序表对吧?不然为什么说是顺序存储呢。
堆的结构体定义
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Heap;
为了方便后续修改数据类型,就直接typedef一下。
堆的创建
Heap* HeapCreate()
{
Heap* tmp = (Heap*)malloc(sizeof(Heap));
if (tmp == NULL)
{
printf("Malloc Fail In HeapCraete!\n");
exit(-1);
}
tmp->a = NULL;
tmp->capacity = 0;
tmp->size = 0;
return tmp;
}
这个创建跟顺序表是一模一样的。我其实更喜欢这种能够返回一个指针的创建,因为你不需要干什么,就可以直接调用函数得到一个初始化好的堆指针。
堆的打印
void HeapPrint(Heap* hp)
{
if (hp == NULL)
return;
for (int i = 0; i < hp->size; i++)
printf("%d ", hp->a[i]);
printf("\n");
}
为了方便观察结果,弄一个打印函数,和顺序表是一样的。
堆的插入
堆的插入可就不像之前的顺序表了,因为堆需要符合完全二叉树的分布结构的,所有的插入删除之后都仍需要符合完全二叉树分布。小堆操作后还必须是小堆。
例如这棵树,如果我现在要插入16,插哪呢。它的实际存储结构是下面这个。
所以我们看到这个地方是有一个难点的。因为你一旦把它插到除了尾之外的任何位置,都会破坏树的结构。所以一定要插尾上,可是插了尾之后怎么办呢?是不是就需要把这个节点的位置进行调整呢?那我们就来调整试试看
这样貌似就调整完了,也就是说让这个插入的节点去和它父节点比较,如果比他小就让他们交换位置,然后继续让换了位置后的16和新父节点9比较,发现9比他小,换了就不符合小堆的定义,因为9和上面的节点我们都没有动过,他们仍是符合小堆的,所以调整结束。
好了,思路来了,也就是说第一步,先把插入的数据插到尾端,不改变原来的树,然后让这个节点去进行一个向上的调整。
为了方便先写一个交换函数
//交换
void Swap(HPDataType* x1, HPDataType* x2)
{
assert(x1 && x2);
*x1 = *x1 ^ *x2;
*x2 = *x1 ^ *x2;
*x1 = *x1 ^ *x2;
}
// 堆的插入
void HeapPush(Heap* hp, HPDataType x)
{
assert(hp);
//先检查是否需要扩容
if (hp->size == hp->capacity)
{
int newcapacity = hp->capacity == 0 ? 2 : hp->capacity * 2;
HPDataType* newa = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * newcapacity);
if (newa == NULL)
{
printf("Realloc Fail In HeapPush!\n");
exit(-1);
}
hp->a = newa;
hp->capacity = newcapacity;
}
hp->a[hp->size++] = x;
AdjustUp(hp);
}
//向上调整
void AdjustUp(Heap* hp)
{
assert(hp);
int son = hp->size - 1;//尾节点
int father = (son + 1) / 2;//尾节点的父节点
while (son > 0) // 当尾节点为根节点时就无需比较了
{
if (hp->a[son] < hp->a[father])
{
Swap(hp->a + son, hp->a + father);
son = father;
father = (son + 1) / 2;
}
else
{
return; //不满足就可以直接退出
}
}
}
写完了,我们写个测试来看看,就用上面那个树插16来看看。
void test()
{
Heap* tmp = HeapCreate();
HeapPush(tmp, 5);
HeapPush(tmp, 9);
HeapPush(tmp, 11);
HeapPush(tmp, 14);
HeapPush(tmp, 18);
HeapPush(tmp, 19);
HeapPush(tmp, 21);
HeapPush(tmp, 33);
HeapPush(tmp, 17);
HeapPush(tmp, 27);
HeapPrint(tmp);
HeapPush(tmp, 16);
HeapPrint(tmp);
}
我们可以看到就是按照我们的想法来的,完美。
小堆的插入已经解决了,我们可以来看看这个时间复杂度,假设有n个节点,最多只需要交换高度-1次就能完成,时间复杂度是logn,可以说是很快了。
好了,既然插入说完了,那接下来肯定是要讲和插入对偶的操作--删除了。
堆的删除
注意我们讲堆的删除都是删根节点,不是删其中的某个节点。
堆的删除好像不简单,因为我们之前在进行插入的时候说到,之所以要尾插,是因为这样不会破坏树的结构,结果你现在要删除,不管怎么删都是要删树的头节点。那能不能想个办法不让这种难办的事情发生呢?有的,你看我们删什么地方不会破坏呢?还得是删尾,删尾怎么办?直接size--就完了,那能不能把删头变成删尾呢?诶,不就是把头和尾交换一下嘛。然后我们再让这个新的头节点去往下调整,使其保持堆的特性。这个在上面也是有讲到的。
大致的思路就和上图一样。那代码也就是不难写了。
// 堆的删除
void HeapPop(Heap* hp)
{
if (hp == NULL)
return;
Swap(hp->a, hp->a + hp->size - 1);
hp->size--;
AdjustDown(hp);
}
//向下调整
void AdjustDown(Heap* hp)
{
if (hp == NULL)
return;
int father = 0;
int son = father * 2 + 1; //默认认为左孩子符合条件
while (son < hp->size)
{
if (son != hp->size - 1) //判断是否有右孩子 如果有则需要对两者进行比较
{
if (hp->a[son] > hp->a[son + 1])
son++;
}
if (hp->a[father] > hp->a[son])
{
Swap(hp->a + father, hp->a + son);
father = son;
son = father * 2 + 1;
}
else
{
return;
}
}
}
ok,写完,来个测试看一下,依然是上面那棵树啊。
void test()
{
Heap* tmp = HeapCreate();
HeapPush(tmp, 5);
HeapPush(tmp, 9);
HeapPush(tmp, 11);
HeapPush(tmp, 14);
HeapPush(tmp, 18);
HeapPush(tmp, 19);
HeapPush(tmp, 21);
HeapPush(tmp, 33);
HeapPush(tmp, 17);
HeapPush(tmp, 27);
HeapPrint(tmp);
HeapPop(tmp);
HeapPrint(tmp);
}
结果不能说一模一样吧,只能说是完全一样。
获取堆顶数据,堆的节点个数,堆的判空
这个就太简单了,就放一起了。
// 取堆顶的数据
HPDataType HeapTop(Heap* hp)
{
assert(hp);
return hp->a[0];
}
// 堆的节点个数
int HeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
// 堆的判空
bool HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
堆的销毁
// 堆的销毁
void HeapDestory(Heap* hp)
{
if (hp == NULL)
return;
free(hp->a);
free(hp);
hp = NULL;
}
堆排序
好了,到了重头戏。还记得之前讲过,我会说明,为什么完全二叉树,或者说堆是一个非常高效的数据结构,主要就是在排序这块。
我们来回忆一下,学过哪些排序。第一个就是冒泡排序,时间复杂度为O(N*N)。第二个是快排,但是我们本没有学到它的核心思想,我们实现的也只是一个打着快排名号的冒泡排序。
但是今天这个排序,可以说是非常快的一个排序。我们先来看一下什么是堆排序。
顾名思义,推排序,当然就是借助堆的思想来进行排序。那什么是堆的思想呢?
之前的小堆我们说有一个定义,任何一个父节点都比子节点要小。那就是说,根节点一定比所有节点都要小。那这不就是最小的一个元素嘛。假设我们把它拿出来,也就是先取到它的值,然后把他删除。那我们说过,堆在删除元素后还是一个堆,这个新堆的最小节点仍是它的根节点,那这不就是次小的数嘛。这样重复下去,不久能得到了有序的数据了嘛。之前说过,删除一次时间复杂度是logn,那总的不就是n*logn。如果n是一千亿,n*logn就是30个一千亿,但是n*n可就是一千亿个一千亿。你说快不快。
好,讲了这么多,那就来弄下思路吧。
1.建堆。给你的数据并不是一个堆,所以要建堆。建大堆还是小堆呢?如果你实现了一个完整的堆,大堆小堆都不是问题。但是如果是一个在线oj题呢,并且要求空间复杂度是O(1),这可就有讲究了。我假设有这样一个数组。{1,3,5,4,2}。显然不是有序的。既然空间复杂度是O(1),就说明要把这个数组直接变成堆。那我们就想啊,如果在空间复杂度不限制的情况下,是不是先建立一个堆,然后把数据一个个插入呀。现在不能这么干,那我直接假设最后一个元素他就是一个堆,只是这个堆的size是,我只要把size--,就等于把4给插到头上了 ,只要向下调整一下就行了(没错,插头然后向下调整也是能建堆的)。
2.pop。当把整个数组变成堆后,我再不停地pop,你想想,我们的pop不是要先把头尾交换,然后size--,再调整一下嘛。那建大堆还是小堆的关键就在这里,升序我是希望最大的再后面,所以最大的应该是堆的根,也就是应该建一个大堆。相对应的,降序就应该建小堆。
那咋实现呢。
我通过这道题目来讲解,怎么实现。
链接 : 力扣
题目的意思很简单,就是要升序一个数组。
从上面的思路我们可以看到,不管是建堆还是删除都用到了向下调整,所以我们只需要实现一个向下调整就能实现堆排序。
void Swap(int* x, int* y)
{
assert(x && y);
int tmp = *x;
*x = *y;
*y = tmp;
}
//从第i个位置开始 size结束(包含size) 全部用的下标
void AdjustDown(int* nums, int size, int i)
{
assert(nums);
int father = i;
int son = i * 2 + 1;
while (son <= size)
{
if (son != size)
{
if (nums[son] < nums[son + 1])
son++;
}
if (nums[father] < nums[son])
{
Swap(nums + father, nums + son);
father = son;
son = father * 2 + 1;
}
else
{
return;
}
}
}
int* sortArray(int* nums, int numsSize, int* returnSize)
{
assert(nums && returnSize);
*returnSize = numsSize;
int i = 0;
for (int i = (numsSize - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(nums, numsSize - 1, i);
}
for(int i = numsSize - 1;i>=0;i--)
{
Swap(nums, nums + i);
AdjustDown(nums, i - 1, 0);
}
return nums;
}
然后显然是轻松过了。
有一个细节我想特别说明一下。
在前面的Swap,我用的是^,没有创建中间变量,于是在这道题中出现了一个问题。
那就是当自己与自己交换时,会变成0。大家可以试下。
最后
本来我是打算这篇博客把二叉树的顺序存储和链式存储都讲掉,可惜有点太累了,讲不完。