0、
堆(Heap)
堆排序是一种原地的、时间复杂度为O(nlogn)的排序算法。
引入:快速排序平均情况下,时间复杂度为O(nlogn),甚至堆排序比快速排序的时间复杂度还要稳定,但在实际中,快排性能要比堆排序好,为什么???
一、堆的概述
堆——是一种特殊的树
- 堆是一个完全二叉树(除了最后一层,其他层的节点个数都是满的,最后一层的节点都是靠左排列)
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值(也就是说,堆中每个节点的值都大于等于(或小于等于)其左右子节点的值)
大顶堆:对于每个节点的值都大于等于子树中每个节点的堆。
小顶堆:对于每个节点的值都小于等于子树中每个节点的堆。
注意:对于同一组数据,可以构建多种不同形态的堆(大顶堆可有不同形态,小顶堆同理)
二、堆的操作
ps:以大顶堆为例
1、插入操作
- 把新插入的元素放在堆的最后;
- 堆化(heapify):对堆进行调整,使其重新满足堆的特性
- 两种堆化方法:
- 思路:顺着节点所在的路径,向上或向下进行对比,然后交换
- (1)从下往上(如下图所示)
- (2)从上往下
- 两种堆化方法:
==》code
public class Heap {
private int[] a; // 数组,从下标1开始存储堆中的数据
private int n; // 堆可以存储的最大数据个数
private int count; // 堆中已经存储的数据个数
// 初始化
public Heap(int capacity) {
a = new int[capacity + 1];
n = capacity;
count = 0;
}
public void insert(int data) {
if(count >= n)
return; // 堆满了
count++;
a[count] = data;
int i = count;
while(i/2 > 0 && a[i] > a[i/2]) {
// 自下往上堆化
// swap()函数用于交换下标为 i 和 i/2 的两个元素
swap(a, i, i/2);
i = i/2;
}
}
}
2、删除堆顶元素
栈顶元素存储的就是堆中数据的最大值或最小值。
以大顶堆为例,堆顶元素就是最大元素。当删除堆顶元素之后,则需要把第二大的元素放到堆顶,第二大元素必为于左右子节点中。然后迭代地删除第二大节点,以此类推,直到叶子节点被删除。
==》会出现数组空洞,也就是堆化出来的堆并不满足完全二叉树的特性。
解决方法:
- 将最后一个元素放在堆顶;
- 然后利用同样的父子节点对比方法:若不满足父子节点大小关系,则交换两个节点并重复该过程,直到父子节点之间满足大小关系为止。==》从上往下的堆化方法
实现代码:
public void removeMax() {
// 堆中没有数据
if (count == 0)
return -1;
a[1] = a[count];
--count;
heapify(a, count, 1);
}
private void heapify(int[] a, int n, int i) {
// 自上往下堆化
while(true) {
int maxPos = i;
// 寻找最大值的位置
if(i*2 <= n && a[i] < a[i/2])
maxPos = i * 2;
if(i*2+1 <= n && a[maxPos] < a[i*2+1])
maxPos = i * 2 + 1;
if(maxPos == i)
break;
swap(a, i, maxPos);
i = maxPos;
}
}
3、时间复杂度分析
① 一个包含 n 个节点的完全二叉树,树的高度不会超过 log2n;
② 堆化过程是顺着节点所在路径比较交换的
==》堆化时间复杂度与树高成正比,也就是O(logn)
==》插入数据和删除堆顶元素主要逻辑是堆化
==》时间复杂度O(logn)
三、堆的存储
完全二叉树比较适合用数组来存储,非常节省存储空间。
- 原因:不需要存储左右节点的指针,可单纯地通过数组的下标,来找到节点的左右子节点和父节点。(根节点存储在数组下标为1的位置,数组下标为
i
的节点的左子结点的下标为2*i
,右子节点的下标为2*i+1
)
四、堆排序的实现
- 时间复杂度为O(n2):冒泡排序、插入排序、选择排序
- 时间复杂度为O(nlogn):归并排序、快速排序、线性排序
1、堆排序
堆排序:基于堆这种数据结构实现的排序算法。
堆排序时间复杂度非常稳定的原地排序算法——O(nlogn)
堆排序的过程大致分解为:建堆和排序
(1)建堆
目标:将数组原地建成一个堆。 原地就是不借助另一个数组,就在原数组上进行操作。
思路一:将元素依次插入堆中,数组下标从1开始。该思路从前往后处理数组数据,并且每个数据插入堆中时,都是从下往上堆化。
思路二
-
与思路一相反,从后往前处理数组,并且每个数据都是从上往下堆化。
-
思路二代码实现:
private static void buildHeap(int[] a, int n) {
for(int i = n/2; i >= 1; --i){
heapify(a, n, i); // 自上往下堆化
}
}
private void heapify(int[] a, int n, int i){
// 自上往下堆化
while(true){
int maxPos = i;
if(i*2 <= n && a[i] < a[i*2])
maxPos = i*2;
if(i*2+1 <= n && a[maxPos] < a[i*2+1])
maxPos = i*2 + 1;
if(maxPos == i)
break;
swap(a, i, maxPos);
i = maxPos;
}
}
-
分析:代码仅对下标从
n/2
开始到 1 的数据进行堆化,下标从n/2+1
到 n 的节点是叶子节点,不需要堆化 -
时间复杂度:
- 节点堆化的时间复杂度:O(logn)
- n/2 个节点堆化的总时间复杂度:O(nlogn) ==》不够精确
- 堆排序的建堆过程的时间复杂度:O(n)
-
推导过程:
① 由于叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始;
② 每个节点堆化的过程中,需比较和交换的节点个数,与该节点的高度 k (从节点到叶节点的路径长度)成正比
③ 将每个非叶子节点的高度求和,公式如下:
④ 由于 h = log2n,代入公式S,可得 S = O(n)
⑤ 时间复杂度——O(n)
(2)排序——大顶堆《==》小顶堆
- 建堆后,数组中的数据已经是按照大顶堆的特性组织的;
- 将数组中的第一个元素(堆顶)与最后一个元素交换,则最大元素就放在下标为 n 的位置;
- 类似“删除堆顶元素”的操作,然后通过堆化方法将剩下的 n-1 个元素重新构建为堆;
- 重复上述过程,直到最后堆只剩一个下标为1的一个元素
// n 表示数据的个数,数组 a 中的数据从下标 1 到 n 的位置。
public static void sort(int[] a, int n) {
buildHeap(a, n);
int k = n;
while(k > 1){
swap(a, 1, k);
--k;
heapify(a, k, 1);
}
}
-
分析
- 原地排序算法:整个堆排序过程,都只需要极个别临时存储空间。
- 时间复杂度:建堆O(n)+排序O(nlogn)
- ==》整体时间复杂度: O(nlogn)
- 不稳定排序算法:在排序过程中,存在将堆的最后一个节点跟堆顶点互换的操作,可以改变值相同数据的原始相当顺序。
注意:若堆中数据从数组下标0开始存储,则节点下标为 i 时,其左子节点下标为 2i+1,右子节点的下标为2i+2,其父节点的下标为 (i-1)/2
五、在实际开发中,为什么快排要比堆排序性能好?
- 堆排序数据访问的方式没有快速排序友好。
- 快排数据——顺序访问
- 堆排序数据——跳着访问==》对CPU缓存不友好
- 对于同样的数据,在排序过程中,,堆排序算法的数据交换次数要多于快速排序。
- 快排数据交换的次数不会比逆序度多;
- 堆排序的建堆过程会打乱数据原有的相对前后顺序,导致原数据的有序度降低;
六、堆应用
场景:假设现有一个包含10亿个搜索关键词的日志文件,如何快速获取热门榜 Top 10 的搜索关键词?
思路:
- 通过哈希算法求取对应的哈希值,然后对哈希值同 10 取模,得到的结果就是这个搜索关键词应被分到的文件编码。
- 然后利用散列表和堆,分别求取 Top 10,将10个Top 10放在一起,取Top 10。
1、优先级队列
- 优先级队列:数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。
- 堆可以看作为一个优先级队列——往优先级队列插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出最高的元素,就相当于取出堆顶元素。
- 应用:赫夫曼编码、图的最短路径、最小生成树算法等等。
- 实现:JAVA 中 PriorityQueue,C++的priority_queue等。
(1)合并有序小文件
目标:假设存在100个小文件,每个文件大小为100M,每个文件中存储都是有序的字符串。==》合并成一个有序的大文件。
思路1:类似归并排序中的合并函数。分别从100个文件中,各取第一个字符串,放入数组,然后比较,将最小的字符串放入合并后的大文件中,并从数组中删除;然后从最小字符串文件中取下一个字符串,并放入数组,重复上述过程,直到所有的文件中的数据都放入到大文件为止。
思路2:利用优先级队列,也就是利用堆。从小文件中取出字符串放入小顶堆中,那堆顶的元素就是优先级队列队首的元素,即最小的字符串;将该字符串放入大文件中,并将其从堆中删除;然后再从小文件中取出下一个字符串,放入到堆中,重复上述过程,直到可以将100个小文件中的数据依次放入大文件中。
==》删除堆顶数据和往堆中插入数据的时间复杂度:O(logn),n表示堆中的数据个数,这里是100
(2)高性能定时器
目标:有个定时器,定时器中维护很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间就要扫描一次任务,看是否有任务达到设定的执行时间,若达到,则执行。
思路:利用优先级队列按照设定的执行时间,将这些任务存储到优先级队列中,队列首部(堆顶)存储的就是最先执行的任务。
==》只需取队首任务的执行时间点,与当前时间相减,就得到一个时间间隔 T。
==》该时间间隔 T 就是从当前时间开始到第一个任务需要被执行的所需时间;从当前时间点到(T-1)这段时间内,定时器不需要做任何事情,当T时间过后,定时器取优先级队列中队首的任务执行。然后再计算新的队首任务的执行时间点与当前时间点的差值。
2、求 Top K
场景:将求 Top K 的问题抽象成两类。
- 针对静态数据集合——数据集合事先确定,不再改变;
- 针对动态数据集合——数据集合事先不确定,有数据动态地插入到集合中。
(1)静态数据
在包含n个数据的数组中,查找前 K 大数据
==》维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。若比堆顶元素大,则删除堆顶元素,将这个元素插入到堆中;若比堆顶元素小,则继续遍历数组。
==》 遍历数组的时间复杂度:O(n)
==》一次堆化操作的时间复杂度:O(logK)
==》最坏情况下,n个元素都入堆一次,时间复杂度:O(nlogK)
(2)动态数据
针对动态数据求得 Top K 就是实时 Top K。
一个数据集合中有两个操作:
- 添加数据:
- 询问当前的前 K 大数据:
- 维护一个大小为 K 的小顶堆,当有数据被添加到集合中时,将其与堆顶元素比较。若比堆顶元素大,则删除堆顶元素,将这个元素插入到堆中;若比堆顶元素小,则不做处理。
3、利用堆求中位数
目标:求动态数据集合中的中位数。
中位数:
- 若数据的个数是奇数,把数据从小往大排,第 n/2+1 个数据就是中位数;
- 若数据的个数是偶数,把数据从小往大排,第 n/2 个数据和第 n/2+1 个数据的单独一个或均值或…就是中位数(此处为第 n/2 个数据);
(1)利用堆高效地实现求中位数
通过维护两个堆:一个大顶堆,一个小顶堆。 大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中数据。
静态数据
如果有 n 个数据,n 是偶数,我们从小到大排序,那前 n/2 个数据存储在大顶堆中,后 n/2 个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果 n 是奇数,情况是类似的,大顶堆就存储 n/2+1 个数据,小顶堆就存储 n/2 个数据。
动态数据
若新加入的数据小于等于大顶堆的堆顶元素,则将这个新数据插入到大顶堆;否则若新加入的数据大于等于小顶堆的堆顶元素 ,则将这个新数据插入到小顶堆。
若两个堆中的数据个数不符合前面约定的情况:
- 若 n 是偶数,则两个堆中数据个数都是 n/2;
- 若 n 为奇数,则大顶堆有 n/2+1 个数据,小顶堆有 n/2 个数据;
- 若两个堆数据个数不满足约定,则从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,让两个堆的数据满足上面约定。
==》插入数据需要堆化操作,所以时间复杂度:O(logn)
==》中位数只需返回大顶堆的堆顶元素,所以时间复杂度:O(1)
(2)利用堆求百分位的数据
目标:快速求接口的 99% 响应时间?
注:99 百分位数的概念可以类比中位数,如果将一组数据从
小到大排列,这个 99 百分位数就是大于前面 99% 数据。
如果有 n 个数据,将数据从小到大排列之后,99 百分位数大
约就是第 n*99%
个数据,同类,80 百分位数大约就是第 n*80%
个数据。
为了保持大顶堆中的数据占 99%,小顶堆中的数据占 1%,
在每次新插入数据之后,我们都要重新计算,这个时候大顶堆和小顶堆中的数据个数,是否还符合 99:1 这个比例。如果不符合,我们就将一个堆中的数据移动到另一个堆,直到满足这个比例。