树
树是一种逻辑概念,我们通常会把某种尊出结构认为是树的结构
树的简单概念
- 结点的度:一个结点含有的子树的个数
- 叶结点(终端节点)度为0的结点
- 双亲结点:
- 兄弟结点:
- 树的度:该树的结点的为最大的结点的度
- 树的高度(深度):可以认为根节点的深度为1或者为0
树的判定
树的子树是不会相交的,也就是说树种是没有回路的(没有环的)
树的表示
- 链式表示法
(左孩子右兄弟表示法)
// 二叉链
struct TreeNode
{
int val;
TreeNode* leftChild;
TreeNode* rightBrother;
TreeNode() :val(0), leftChild(NULL), rightBrother(NULL) {}
};
// 三叉链
struct TreeNode
{
int val;
TreeNode* leftChild;
TreeNode* rightBrother;
TreeNode* prevFather;
TreeNode() :val(0), leftChild(NULL), rightBrother(NULL), prevFather(NULL) {}
};
二叉树
二叉树就是结点最多有两颗子树的树,树的度为2
特殊的二叉树:
1.满二叉树
每一层的结点都是最大值,如果数的深度为h,则结点的个数为2h - 1
2.完全二叉树
前h - 1层都是满的,最后一层从左到右是连续的
二叉树的性质:
- 一颗非空二叉树的第i层最多有2i-1个节点
- 深度为h的二叉树最大的节点数为2h - 1
- 对于任何一个二叉树,如果叶子结点为n0,度为2的结点为n2,则n0=n2+1
- n个结点的满二叉树,深度为log(n + 1)
(以上是对任何的二叉树,不一定是对完全二叉树)
孩子和父亲的表示
1.已知父亲的下标为parent
,左孩子的下标为leftChild = parent * 2 + 1
,右孩子的下标为rightChild = parent * 2 + 2
2.已知孩子的下标为child
,则父亲的下标为parent = (child - 1) / 2
,(为啥用孩子的下标找父亲不用分情况呢),因为C++中➗自动向下去整,所有就算是有孩子不用-2,右孩子-1之后/2也会取整的
堆
堆的概念
1.堆的物理结构是一个数组
2.逻辑结构是一个完全二叉树
3.堆分为大堆和小堆
- 大堆中树中所有父亲结点的值都>=孩子
- 小堆中树中所有父亲结点的值都<=孩子
堆的搭建
这部分的代码可以跳过,等我后面的讲解
#include <stdlib.h>
#include <assert.h>
#include <cstring>
#include <algorithm>
using namespace std;
typedef int HPDataType;
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
// 向下调整法
void AdjustDown(HPDataType* a, int n, int parent);
// 向上调整法
void AdjustUp(HPDataType* a, int child);
// 堆的构建
void HeapCreate(Heap* hp, HPDataType* a, int n);
// 堆的销毁
void HeapDestory(Heap* hp);
// 堆的插入
void HeapPush(Heap* hp, HPDataType x);
// 堆的删除
void HeapPop(Heap* hp);
// 取堆顶的数据
HPDataType HeapTop(Heap* hp);
// 堆的数据个数
int HeapSize(Heap* hp);
// 堆的判空
bool HeapEmpty(Heap* hp);
// 打印堆
void HeapPrint(Heap hp);
void AdjustDown(HPDataType* a, int n, int parent) // 向下调整法
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] > a[child])
{
child++;
}
if (a[parent] < a[child])
{
swap(a[child], a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void AdjustUp(HPDataType* a, int child) // 向上调整法
{
int parent = (child - 1) / 2;
// 注意这里不能用parnet >= 0来结束条件判断,因为/是向下取整的所以-1/2一直会得到0
while (child > 0)
{
if (a[parent] < a[child])
{
swap(a[parent], a[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
// 堆的构建
void HeapCreate(Heap* hp, HPDataType* a, int n)
{
assert(hp);
// 先将a数组中的数据移到hp中
hp->_a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (!hp->_a)
{
cout << "malloc failed" << endl;
exit(-1);
}
for (int i = 0; i < n; i++)
{
hp->_a[i] = a[i];
}
hp->_capacity = n;
hp->_size = n;
// 将hp数组建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(hp->_a, hp->_size, i); // 建一个大堆
}
}
// 堆的销毁
void HeapDestory(Heap* hp)
{
assert(hp);
free(hp->_a);
hp->_a = NULL;
hp->_capacity = hp->_size = 0;
}
// 堆的插入
void HeapPush(Heap* hp, HPDataType x)
{
if (hp->_capacity == hp->_size) // 检查堆的容量
{
if (hp->_capacity == 0)
hp->_capacity = 1;
HPDataType* tmp = (HPDataType*)realloc(hp->_a, sizeof(HPDataType) * hp->_capacity * 2);
if (!tmp)
{
cout << "realloc failed" << endl;
exit(-1);
}
hp->_a = tmp;
hp->_capacity *= 2;
}
hp->_a[hp->_size++] = x; // 在堆的底部添加一个元素
// 让堆的结构重新变为大堆
// 向上调整法
AdjustUp(hp->_a, hp->_size - 1);
}
// 堆的删除
void HeapPop(Heap* hp)
{
// 因为直接删除堆顶的数据会破坏原来的堆的结构
// 所以就曲线救国,将堆底和堆顶的数据交换,然后删除堆底的数据
swap(hp->_a[0], hp->_a[hp->_size - 1]);
hp->_size--;
AdjustDown(hp->_a, hp->_size, 0); // 进行一次向下调整
}
// 取堆顶的数据
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 HeapPrint(Heap hp)
{
for (int i = 0; i < hp._size; i++)
{
cout << hp._a[i] << ' ';
}
cout << endl;
}
建堆(向下调整法)
堆的绝大部分的操作都基于堆的搭建,在建堆之前我们先来了解一下建堆其中的一个核心操作向下调整法,
啥叫向下调整法呢?这又和建堆有什么关系呢??我这就来讲解一下
(当然建堆有两种方式,建小堆和建大堆,我这里就拿建大堆举例说明了哈,你可以根据我的方法,见一个小堆检验一下自己是否会建小堆)
// 假设有一个数组a
int a[] = {6, 18, 25, 8, 10, 9, 20};
这个堆是不是除了第一个数6,其他的左右的两个子树都是满足大堆的性质,这时我们就可以用向下调整法了。向下向下调整法的原理就是每次将不满足大堆性质的父亲节点(我们这就是6)和他的两个子节点中最大的那个交换,然后依次向下重复操作,知道6到了叶子结点或者6已经比他的两个孩子结点都大,这是也已经满足大堆的性质了
经过这样的操作,就可以让大的元素一直向上,而向下调整的元素就一直向下找到自己的位置
那我就有一个问题了,为啥一定要和最大的那个子节点交换???
因为如果要满足大堆的性质就一定要比自己所有的孩子的值都要大,所以要找最大的那和孩子
代码如下
void AdjustDown(HPDataType* a, int n, int parent) // 向下调整法
{
int child = parent * 2 + 1;
while (child < n) // 当child == n的时候,就说明已经比较完了
{
if (child + 1 < n && a[child + 1] > a[child]) // 要注意要下判断child+1是否<n
// 因为可能最后一个树只有一个结点(左孩子)没有右孩子,这时在while中也判断不出来,所以为了防止越界访问所以要加一个特判
{
child++;
}
if (a[parent] < a[child])
{
swap(a[child], a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
好,到这如果已经看懂了向下调整法,那我们就来康康真正的建堆吧。
刚刚的向下调整法其实是有一个前提的那就除了堆顶元素不满足性质之外,其余的两个子树都满足大堆的性质,那如果是一个无序的数组怎么办呢?其实道理也不能,但是要拐一个小小的弯,一定要仔细看好了哦。
我们可以倒着看,从叶子结点开始,每个叶子结点也都是一颗树,只不过有点小,哈哈,他自己也没啥好比的,所以就把他和他的父亲结点相比,他和他的父亲构成了一个树,这是已经满足了向下调整法的条件了,因为只有两个结点呀,这时利用向下调整法将这个树调整成为大堆,然后依次向上
先举个栗子:
可以看出先是3和7换
然后是6和7比较,但是6和7这个树已经满足条件了,所以不用换了
然后是2和5换
然后是4和5比较,发现也不用换
好,重点来啦这时发现除了根节点5和7左右的两个子树有满足大堆的性质了,是不是很神奇,只要从下到上我将每个树都调整一遍就可以了,因为调整上面的树的时候,下面的树就已经被调整过了。
代码如下:
for (int i = (n - 1 - 1) / 2; i >= 0; i--)// n-1是最后一个数, 再-1然后/2得到对应结点的父亲结点,i就是在枚举所有的父亲结点
{
AdjustDown(hp->_a, hp->_size, i); // 建一个大堆
}
代码其实很简单,就是从后往前调整就完事了,重要的是理解原理
建堆的时间复杂度O(N)
堆排序
讲完了如何建堆,只是第一步也是最核心的一步,接下来就要用堆的性质来解决事迹的问题,第一个要解决的就是利用堆来解决排序问题。但是这就有一个问题了,排序有排升序和排降序,而堆有两种,那比如说排一个升序使用大堆还是小堆呢?
这就是堆排序的除了建堆之后的一个核心思想了,我们可以一个一个来试一试么。因为是排升序,所以我们自然而然的想先取出最小的元素,这时就要用小堆来实现,
举个例子:
这是一个小堆,我们先把堆顶取出,1取出来,这是数组中是{3, 2, 6, 7, 4, 5},但是这是堆的结构也发生了变化,因为我们是把数组中的元素一次放成堆的结构的,如果数组中的首元素发生了改变随之整个堆结构也发生了改变
因为结构的改变,所以可能会导致某些子树已经不满足小堆的性质了,这时就要再将这个堆重建一次,时间复杂度为O(N),如果要重新建堆,我就没有必要大费周章的将数组变成堆了,我干脆像选择排序那样直接扫描一遍也是O(N)的复杂度,那样还不用建堆那么麻烦,所以不是说用小堆排升序不可以,只是这就没有用到堆的性质了,不是一个好的方法,所以排升序用小堆可以pass了。
那就只能用大堆了,那大堆怎么用呢?大堆每次只能给我最大的元素呀,但是我要升序呀,大元素给我也没用呀。谁说没用你可以反过来想排升序那最大的元素肯定是最后一个元素,那每次将最大的元素固定在最后一个位置,这样不也是排序吗!
先让堆顶的最大元素和末尾的元素交换
最大的元素最一定排在最后面了,这时就可忽略掉最后一个元素了
因为只用一个元素的相对位置变了,就是刚刚和第一个元素交换的那个最后一个元素,大堆(注意哦我们已经在用大堆了)的大结构并没用变,所以只要进行一次向下调整法,只需要O(logN)的复杂度,这是不是就用用到了堆的性质了,然后这个堆又变成了大堆了,在将堆顶的元素放在这个树的最后一个位置,相对于上一个树就是倒数第二个位置,一次循环下去就完成了从后往前一次是从大到小,如果从前往后不就是从小到大了吗?就完成了排升序。
void HeapSort(int* a, int n)
{
int end = n - 1;
for (int i = (n - 2) / 2; i >= 0; i--) // 先要建堆
AdjustDown(a, n, i);
while (end > 0)
{
swap(a[0], a[end]); // 交换堆顶元素和最后一个元素
AdjustDown(a, end, 0); // 这里的end既是最后一个数的坐标,也是剩下的数组中的个数
end--;
}
}
注意: 其中end既是最后一个元素的下标,也是剩下堆中的元素个数,所以在swap(a[0], a[end])
之后,先要AdjustDown(a, end, 0)
因为这时堆中的元素个数已经变成了end(也就是n - 1),最后end --,让堆中的元素-1
在看完这个逻辑之后,你也可以用这个逻辑,试一试用小堆排一个降序
总之重点就是:排降序用小堆,排升序用大堆(记忆方法:可以倒着记忆也就是根据原理记忆,从后往前记忆,升序是后大前小,所以要先找出大的元素,所以用大堆,降序是后小前大,所以要先找出小的元素,所以用小堆)
看完了堆排序,你就可以回头看一看堆的搭建了,其实你已经把堆结构的核心都掌握了,但是又向下调整法,就还有向上调整整法,那向上调整是干嘛的呢?其实是在堆的插入中因为不能在堆顶插入元素,所以只能在堆的末尾插入一个元素,但是堆底又不能向下调整,所以就有了向上调整,其实和向下调整的原理类似,代码如下:
void AdjustUp(HPDataType* a, int child) // 向上调整法(建小堆)
{
int parent = (child - 1) / 2;
// 注意这里不能用parnet >= 0来结束条件判断,因为/是向下取整的所以-1/2一直会得到0
while (child > 0)
{
if (a[parent] < a[child])
{
swap(a[parent], a[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
TopK问题
堆在解决实际问题当中经常会应用与TopK问题,也就是在N个数中找出前K个最大或者最小的元素 这时就要用到TopK问题的解法。
这里我就用求前K个最大的数为例。
维护大堆法 O(KNlogN)
1.当然这题最容易想到的方法就是用一个大堆来维护数据也就是将数据都放到一个大堆当中,每次将堆顶的元素取出,然后再将堆重建一遍,变成大堆,像这样重复K此即可,但是为题又出来了,每次建堆的时间复杂度很高,这样的成本太大,而且第二个问题在于如果有很多数据比如说有10亿个数据,堆中根本不能放得下,这样的空间复杂度也是很高的。所以这不是一种很好的方法,所以就要用到,TopK问题的解法
维护小堆法 O(NlogK)
TopK问题解法最好使用一个小堆来维护一个大小为K的堆,将数组中的前K个元素放入小堆,将数组中的元素从第K + 1个元素到第N个元素依次和大堆的堆顶比较,如果数组中的元素比堆顶的元素大,那就将数组中的元素替换堆顶的元素,再将堆向下调整一下,经过这样的遍历一遍就可以将最大的前K个元素放入堆中
void PrintTopK(int* a, int n, int k)
{
// a数组和堆无关,就是要在a数组中找到前k个小的数,所以要给tmpHeap建一个大堆
Heap tmpHeap;
HeapCreate(&tmpHeap, a, k);
for (int i = k; i < n; i++)
{
if (a[i] < tmpHeap._a[0])
{
tmpHeap._a[0] = a[i]; // 将大堆中的第一个数替换掉
AdjustDown(tmpHeap._a, k, 0); // 然后通过向下调整法,恢复堆的结构
}
}
for (int i = 0; i < k; i++)
cout << tmpHeap._a[i] << ' ';
}
那为啥用小堆来维护呢? 因为小堆堆顶一定是堆中最小的元素,这样这样遍历一遍是不是遇到一个比堆中最小的元素小的元素就将元素放到堆中并且可以删除掉原来那个最小的元素了,看到这是不是很懵,我不是要最大的前K个元素么,和最小的元素比较干啥呀?其实你忽略了一个细节,那就是每次插入一个元素还会向下调整一次堆的结构,这样如果是大的元素就会放大最下面了,这样下去,有一个大的元素就会放到下面,有一个更大的元素就会放到更后面,如果元素小于最小的元素就不会放到堆中,这样一来是不是遍历一遍最大的元素就会放到堆中了。