目录
堆(Heap)
堆是一种特殊的树,需要满足两点:
- 是一个完全二叉树(除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列);
- 每一个节点的值都大于等于(或小于等于)其子树中每个节点的值,叫大顶堆(小顶堆)
堆的实现
堆的存储
堆适合用数组来存储。因为堆是完全二叉树,用数组来存储完全二叉树是非常节省内存的。
存堆(或其他完全二叉树)的数组中:
- 下标为0的节点不存储信息
- 下标为i的节点的左子节点就是下标为2i的节点,右子节点就是下标为2i+1的节点
- 下标为i的节点的父节点就是下标为i/2的节点
堆的操作
1. 插入一个元素:自下而上的堆化
- 先把元素放到堆的最后一个元素,
- 然后从下往上堆化(heapify):也即向上(与父节点)对比、交换,直到与父节点满足大小关系(对于大顶堆来说父节点大于它,或对于小顶堆来说父节点小于它)、或无父节点。
// 向大顶堆中插入一个元素:自
void insertHeap(vector<int> & heap, int val, int count)
{
// heap是一个大顶堆,用数组存储,目前已经存储了count个元素
// val是要插入的元素
if(count >= heap.size()) return; //堆已经满了
++count; //要存进一个数,计数+1
vector[count] = val; // 把要插入的数字放在最后
int i = count; // i标记的是val的下标
while(i / 2 >0 && vector[i] > vector[i/2]) // 自下而上堆化:如果val比父节点大,就要跟父节点交换,直到小于等于父节点或者没有父节点了
{
swap(vector[i], vector[i/2]);
i = i / 2; // i标记的是val的下标,记得更新
}
}
2. 删除堆顶元素:自上而下的堆化
- 先把最后一个元素放到堆顶
- 然后利用父子节点对比的方法,自上而下堆化:与子节点对比、交换,直到与子节点满足大小关系,或无子节点
// 删除堆顶元素:自上而下的堆化
void deleteHeapRoot(vector<int> & heap, int count)
{
// heap是一个大顶堆,存了count个元素
if(count == 0) return ;
int n = heap.size() - 1; // heap一共可以存n个元素
heap[1] = heap[count]; //把最后一个元素放到堆顶
--count; //删掉了一个元素,计数-1
int i = 1; // i标记栈顶元素值(其实是原来最后一个元素)所在的下标
while(true)
{
int j = i; // j标记要交换的值,是左子节点和右子节点中的最大值
if(i*2 <= n && heap[i] < heap[i*2]) j = i*2;
if(i*2+1 <= n && heap[j] < heap[i*2+1]) j = i*2+1;
// 看是否要交换
if(i == j) break;
swap(heap[i], heap[j]);
i = j;
}
}
时间复杂度
节点数为n,树的高度不会超过 l o g 2 n log_2n log2n,堆化是顺着节点所在的路径走的,所以往堆中插入一个元素、删除堆顶元素的时间复杂度不会超过 O ( l o g n ) O(logn) O(logn)。
堆排序
步骤一:建堆
原地建堆:不借助另一个数组,就在原数组上操作。
- 思路一:从下往上堆化。从前往后处理数组(将下标2向后到n的数据依次插入到堆中,因为下标为1是根节点,不需要向上堆化),插入时都是从下往上堆化。
- 思路二(复杂度更低):从上往下堆化。从后往前处理数组(从下标n/2向前到1的数据依次插入堆中,因为下标叶子节点不需要向下堆化),插入时都是从上往下堆化。
// 建大顶堆的C++实现(思路二)
void buildHead(vector<int> heap)
{
if(heap.size() <= 1) return;
int n = heap.size() - 1;
for(int i = n / 2; i >= 1; --i)
{
heapify(heap, n, i);
}
}
void heapify(vector<int> & heap, int n, int i)
{
// 自上而下堆化: i为最上,下指的不只是左右子节点,而是这棵左子树和右子树,所以要while
while(true)
{
int j = i; // j标记最大值所在的下标
if(i*2 <=n && heap[i] < heap[i*2]) j = i*2;
if(i*2+1 <=n && heap[j] < heap[i*2+1]) j = i*2+1;
if(i == j) break;
swap(heap[i], heap[j]);
i = j;
}
}
建堆的精确的时间复杂度是
O
(
n
)
O(n)
O(n),而不是$
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn),推导如下:
步骤二:排序
堆排序的步骤:
- 第1步:先建堆
- 第2步:然后把数组中下标为1的元素(也即堆顶元素,最大的元素)与下标为n的元素交换,就把最大元素排好了(有点像删除堆顶元素的方法),将剩下的n-1个元素重新堆化
- 重复第2步,直到最后堆中只剩下下标为1的元素。
堆排序的代码:
void sortHeap(vector<int> heap)
{
int n = heap.size() - 1;
buildHeap(heap, n);
int k = n;
while(k > 1)
{
swap(heap[1], heap[k]);
--k; //
heapify(heap, k, i);
}
}
堆排序的分析:
- 空间复杂度: O ( 1 ) O(1) O(1),原地排序
- 时间复杂度:建堆$O(n) $ + n次节点堆化 O ( n l o g n ) O(nlogn) O(nlogn) = O ( n l o g n ) O(nlogn) O(nlogn)
- 稳定性:不稳定排序,因为存在堆顶和最后一个节点的交换,就可以能改变相同数据的原始相对顺序。
在实际开发中,快排比堆排序性能好:
- 数据访问方式:快排是顺序访问的,堆排序是跳着访问的,所以堆CPU缓存不友好
- 交换次数:快排的交换次数不会比逆序多,堆排序里的第一步建堆回打乱数据原有的相对先后顺序,导致数据有序度降低,所以堆排序比快排的交换次数多
堆的三种应用
1. 优先级队列
优先级队列:出队顺序是优先级最高的最先出队。一个堆就可以看作一个优先级队列,往优先级队列中插入一个元素就相当于往堆中插入一个元素,从优先级队列中出队优先级最高的元素就相当于取出堆顶元素。
应用一:合并多个有序小文件
假设有100个小文件,每个文件的大小是100MB,每个文件中存储的都是有序字符串,失望将这些小文件合并成一个有序的大文件。
类似归并排序中的合并函数,只不过这里不是两个数字之间比较大小,而是100个数字(字符串)之间比较大小挑最小的,如果用数组来存和找就是O(100)的复杂度,如果用小顶堆来存和找就是O(log100)的复杂度,会比数组更高效。
应用二:高性能定时器
假设有一个定时器中维护了很多个定时任务,每个任务设定了一个要触发的时间点及动作。一般做法是定时轮巡这些任务,看是否有任务达到触发时间点,如果到达,就拿出来执行。
但这样每过一定的时间(如1s)就扫描一遍任务列表的做法比较低效:(1)离执行时间可能还比较久,很多次扫描是徒劳的;(2)每次都要扫描整个任务列表,如果任务列表很大,会有很多耗时。
用优先级队列来解决:按照触发时间点存储到优先级队列中,队首就是最先执行的任务,可以得到队首任务与当前时间点的差值T。等到T时就取队首任务执行,再计算新的队首任务与当前时间点的差值。这样就解决了上面的(1)(2)低效的问题。
2. Top K
类型一:静态数据(数据集合不会变)
如何在一个包含n个数据的数组中,查找**前K大(前K小)**数据呢?
维护一个大小为K的小顶堆(大顶堆)(结果数组),顺序遍历数组(输入数组),从数组中取出数据与堆顶元素进行比较。如果比堆顶元素大,就删除堆顶元素,将这个元素代替堆顶元素,自上而下堆化;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数据都遍历完之后,堆中的数据就是前K大数据了。
// 自己写的,不保证正确
// 找前K大的数据,用小顶堆(把比堆顶元素更大的加进去);
// 找前K小的数据,用大顶堆(把比堆顶元素更小的加进去)
vector<int> TopkLarge(vector<int> nums, int k)
{
// num是输入数组
if(nums.size()== 0) return;
// res是结果数组,是num里前k大的数据
// 1. 先建一个大小为k的小顶堆
vector<int> res;
res.push_back(0); // 空一个值
for(int i = 0; i < k; ++i) res.push_back(nums[i]);
buildHead(res);
// 2. 将num里剩下的元素与堆顶元素比较
for(int i = k; i < nums.size(); ++i)
{
if(nums[i] > res[1])
{
res[1] = nums[i];
// 堆化
heapify(heap, k, 1);
}
}
}
最坏复杂度:遍历数组 O ( n ) O(n) O(n),一次堆化 O ( l o g K ) O(logK) O(logK),所以是 O ( n l o g K ) O(nlogK) O(nlogK)
类型二:动态数据(数据集合会变)
动态数据求TopK举例:一个数据集合中有2种操作,添加数据、查询当前TopK大的数据。
对于查询TopK大的数据,对于动态数据如果每次当前查询都要重新计算的话,复杂度就是 O ( n l o g K ) O(nlogK) O(nlogK)。实际上可以在添加数据时,就将它去跟堆顶元素对比,如果比栈顶元素大,就把栈顶元素删掉,将这个元素代替栈顶元素,进行从上到下的堆化;如果比栈顶元素小,则不做处理,这样查询TopK大的数据时,可以立即返回。
3. 中位数、分位数
求中位数或分位数,用的是1个大顶堆+1个小顶堆,小顶堆里的数据都大于大顶堆中的数据。从数组的角度看,大顶堆的顶和小顶堆的顶就是一个数组里面较小的数和较大的数的分隔数。
静态数组求中位数
如果有n个数据,
- 如果n为偶数,前n/2个数据存储在大顶堆,后n/2个数据存储在小顶堆,这样大顶堆中的堆顶元素就是中位数;
- 如果n为奇数,前n/2+1个数据存储在大顶堆,后n/2个数据存储在小顶堆,同样的大顶堆的堆顶就是中位数。
具体实现方法是:遍历n个数字的数组,如果大顶堆和小顶堆都是空,将当前元素作为大顶堆堆顶;如果大顶堆非空,判断当前元素与大顶堆堆顶元素的关系,如果比大顶堆堆顶元素小,加入大顶堆,判断是否满足0<=大顶堆元素个数-小顶堆元素个数<=1,如果不满足把大顶堆堆顶元素删除,插入小顶堆;如果当前元素比大顶堆堆顶元素大,插入小顶堆,判断是否满足0<=大顶堆元素个数-小顶堆元素个数<=1,如果不满足把小顶堆堆顶元素删除,插入大顶堆。
动态数据求中位数
当新添加一个数据的时候,需要调整两个堆,让大顶堆中的堆顶元素继续是中位数,
- 如果新加入的数据<=大顶堆的堆顶元素,就将这个新数据插入到大顶堆;否则,将这个新数据插入到小顶堆;
- 这个时候可能出现两个堆中的数据个数不符合前面约定的情况(如果n是偶数,两个堆中的数据个数都是n/2;如果n是奇数,大顶堆中有n/2+1个数据,小顶堆中有n/2个数据),这个时候我们可以从一个堆中不停地将堆顶元素插入到另一个堆,来让两个堆中的数据满足约定。
每次插入会涉及几个数据的堆化,所以时间复杂度是 O ( l o g n ) O(logn) O(logn)。查询时只需要返回堆顶数据,所以时间复杂度是 O ( 1 ) O(1) O(1)。
例如:有一个包含10亿个搜索关键词的日志文件,如何快速获取Top 10最热门的搜索关键词?当限制为单机内存1GB时:
- 用散列表来记录关键词及其出现的次数,为满足内存限制,可以将关键词哈希分片到10个文件中;
- 用堆求TopK的方法,建立一个大小为10的小顶堆,遍历散列表,依次去除每个搜索词及其对应搜索次数,与堆顶的搜索关键词的次数对比,如果比堆顶的多,就删除堆顶关键词,将这个更多的词加入到堆中;
- 以此类推,当遍历完这个那个散列表中的关键词后,堆中的搜索关键词就是出现次数最多的Top10关键词了。
求分位数
问题:求分位数,例如99%响应时间,即如果有100个接口访问请求,每个接口请求的响应时间不同,把这些响应时间按照从小到大排序,排在第99的数据就是99%响应时间。
方法:维护两个堆,一个大顶堆,一个小顶堆,
- 对于静态数据:假设当前总数据的个数是n,大顶堆中保存n99%个数据,小顶堆中保存n1%个数据,大顶堆堆顶的数据就是99%响应时间。
- 对于动态数据:每次插入一个数据的时候,判断这个数据跟大顶堆和小顶堆堆顶数据的大小关系,如果比大顶堆堆顶数据小,就插入大顶堆;如果比小顶堆的堆顶数据大,就插入小顶堆。插入之后要重新移动两个堆的数据,直到满足99:1的个数比例。