【23秋】提高实战营 之 课程笔记篇
01 复杂度分析
时间复杂度:程序的运行步数和输入数据的关系。
空间复杂度:程序运行所需要的内存与输入数据的关系。
时间复杂度的计算
直接算
对于比较简单的程序,我们可以直接计算时间复杂度。
例如下列矩阵乘法的代码:
//O(nmr) ≈ O(n^3)
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
for(int k=1;k<=r;k++)
c[i][j]+=a[i][k]*b[k][j];
上述代码的时间复杂度为 \(O(nmr)\),如果 \(m,r\) 和 \(n\) 同阶,那么该代码的时间复杂度也可以记为 \(O(n^3)\)。
均摊
假设一张 \(n\) 个点 \(m\) 条边的无向图,遍历一遍所有的边:
//O(n+m)
for(int i=1;i<=n;i++)
for(auto v:g[x])
......
看似上述代码的时间复杂度为 \(O(nm)\),但是每条边的 \(u,v\) 最多会被枚举两次,所以时间复杂度为 \(O(n+m)\)。
势能
听了半天,听不懂,不过感觉没啥用,这里放三个博客吧。
主定理
主要用来计算一些递归算法的时间复杂度。
$a \ge 1,b > 1 $ 为常数,\(f(n)\) 为函数,\(T(n)\) 为非负整数,则有以下结果(分类讨论):
-
若 \(f(n) = O(n^{\log_{b} {a-\epsilon}}),\epsilon > 0\),那么 \(T(n) = O(n^{\log_{b} {a}})\);
-
若 \(f(n) = O(n^{\log_{b} {a}})\),那么 \(T(n) = O(n^{\log_{b} {a}} \log n)\);
-
若 \(f(n) = \Omega(n^{\log_{b} {a+\epsilon}}),\epsilon > 0\),且对于某个常数 \(c < 1\) 和所有充分大的 \(n\) 有 \(af(\frac{n}{b}) \le cf(n)\),那么 \(T(n) = O(f(n))\)。
博客推荐:
复杂度的量级
事实上,复杂度只能大概描述一个算法的运行效率,在实际情况下还需要考虑算法的常数带来的时间效率的影响。
通常,我们认为计算机一秒可以运行 \(10^8\) 量级的运算。
一些常见的复杂度对应的量级:
\(n\) | \(T(n)\) |
---|---|
\(10\) | \(n!\) |
\(20\) | \(2^n\) |
\(500\) | \(n^3\) |
\(2000\) | \(n^2 \log n\) |
\(5000\) | \(n^2\) |
\(10^5\) | \(n \sqrt{n}\) |
\(3 \times 10^5\) | \(n \log^2 n\) |
\(10^6\) | \(n \log n\) |
\(10^7\) | \(n\) |
02 排序算法
排序的稳定性
两个相等的元素在排序后的相对顺序是否发生改变,如果不发生改变,那么我们称这个排序算法是稳定的,否则是不稳定的。
排序方法
选择排序
for(int i=1;i<n;i++){
int k=i;
for(int j=i+1;j<=n;j++)
if(a[j]<a[k])k=j;
std::swap(a[i],a[k]);
}
选择排序是一种不稳定的排序算法。例如 \([3_1,3_2,2]\),排序后为 \([2,3_2,3_1]\)。
时间复杂度为 \(O(n^2)\)。
冒泡排序
bool f=0;
while(!f){
f=1;
for(int i=1;i<n;i++){
if(a[i]>a[i+1]){
f=0;
std::swap(a[i],a[i-1]);
}
}
}
冒泡排序是一种稳定的排序算法。
最好时间复杂度为 \(O(n)\),最坏时间复杂度 \(O(n^2)\)。
证明:冒泡排序每次操作都会让此序列逆序对的数量减少一个,而一个序列中最多有 \(\frac{n(n-1)}{2}\) 个逆序对,所以时间复杂度为 \(O(n^2)\)。
插入排序
for(int i=2;i<=n;i++){
int j=i-1;
int w=a[i];
while(j>0&&a[j]>w){
a[j+1]=[j];
j--;
}
a[j]=w;
}
插入排序是一种稳定的排序算法。
最好时间复杂度为 \(O(n)\),最坏时间复杂度为 \(O(n^2)\)。
证明:当整个序列都为倒序时(即 \(n,n-1,n-2,\ldots,2,1\)),每次插入都要将当前元素放到整个序列的最开头,即需要调整整个前缀,因此时间复杂度也是 \(O(n^2)\)。
计数排序
for(int i=1;i<=m;i++)cnt[i]=0;
for(int i=1;i<=n;i++)cnt[a[i]]++;
for(int i=1;i<=m;i++)cnt[i]+=cnt[i-1];
for(int i=n;i>=1;i--){
b[cnt[a[i]]]=a[i];
cnt[a[i]]--;
}
计数排序是一种稳定的排序算法,因为是倒着进行 for
循环的。
时间复杂度为 \(O(n+m)\),空间复杂度也为 \(O(n+m)\),此处的 \(m\) 为原数组内的值域。
基数排序
假设有若干个有 \(k\) 个关键字(数位)的元素,对于 \(a,b\) 这两个元素而言,我们规定比较他们的大小为:
-
如果 \(a_1 \neq b_1\),那么根据 \(a_1,b_1\) 的大小决定 \(a,b\) 的大小,否则接着比较;
-
如果 \(a_2 \neq b_2\),那么根据 \(a_2,b_2\) 的大小决定 \(a,b\) 的大小,否则接着比较;
-
\(\ldots\)
-
如果 \(a_k \neq b_k\),那么根据 \(a_k,b_k\) 的大小决定 \(a,b\) 的大小,否则接着比较;
-
否则我们认为两个元素相等。
那么具体如何对这些元素进行排序呢?
我们先按照第 \(1\) 关键字对所有元素进行分组,将第 \(1\) 关键字从小到大排序,那么此时,对于第 \(1\) 关键字不同的两个元素,它们之间的顺序一定已经排好了,只有相等的元素之间顺序没有排好。
所以我们可以对第 \(1\) 关键字内部相等的递归下去接着排序。
当对正整数进行排序时,我们可以将所有的数变为二进制数,将其补全到相同的位数并划分为若干段,从高位到低位分别为第 \(1,2,3,\ldots\) 关键字。
这样的方法被称为 MSD 排序,在进行关键字之间的排序时使用计数排序。
但我们会发现,当很多数的第一关键字不同时,我们需要递归很多个分支,此时计数排序的消耗将会大大增加。
为了既保证基数排序的正确性,又不递归那么多次,我们可以设计一种从低位到高位的排序方法LSD 排序来解决这个问题。
具体而言,我们采用一种稳定的排序来进行内部排序,即计数排序。
当我们从低位到高位做的话,我们可以证明,在进行下一轮排序时,低位的顺序已经是按照排好序的顺序了。并且在之后我们认为它们是相等的,由于计数排序是稳定的,所以它们之间的相对顺序不会被改变,这样就保证了该种排序算法的正确性。
代码如下:
int w=(1<<8)-1;
for(int i=0;i<32;i+=8){
for(int j=0;j<(1<<8);j++)cnt[j]=0;
for(int j=1;j<=n;j++)cnt[a[j]>>i&w]++;
for(int j=1;j<(1<<8);j++)cnt[j]+=cnt[j-1];
for(int j=n;j>=1;j--){
b[cnt[a[j]>>i&w]]=a[j];
cnt[a[j]>>i&w]--;
}
for(int j=1;j<=n;j++)a[j]=b[j];
}
假设我们要对一个二进制下为 \(d\) 位的整数进行排序,那么合理的选择位数 \(r\),可以使得时间复杂度为 \(O(\frac{d}{r} (n + 2^r))\),空间复杂度为 \(n + 2^r\),选择空间能接受的最大的 \(r\) 往往可以使得时间复杂度达到近似线性。
快速排序
快速排序是一种能在 \(O(n \log n)\) 的时间内对数组排序的基于比较的排序方法。
快速排序算法的主要思想是分治。
算法流程大致是:
-
选择一个数 \(x\);
-
将 \(< x\) 的数放到它的左边,\(> x\) 的数放到它的右边;
-
递归排序。
代码如下:
void quick_sort(int *a,int l,int r){
if(l>=r)return;
int x=a[l];
int i=l,j=r;
while(i<j){//双指针进行扫描
//注意顺序先调整 j 再调整 i
while(a[j]>x&&j>i)j--;//寻找第一个需要放到左边的数
while(a[i]<x&&i<j)i++;//寻找第一个需要放到右边的数
if(i<j)std::swap(a[i],a[j]);
}
std::swap(a[l],a[i]);//把选定的 x 放到中间
quick_sort(a,l,i-1);
quick_sort(a,i,r);//往下分治
}
我们考虑,如果每次都能选到中位数作为 \(x\),那么每次数组都会被划分为均匀的两半,时间复杂度为 \(T(n) = 2T(\frac{n}{2}) + n = O(n \log n)\),但是对于数组完全有序的情况,会退化到最劣情况,时间复杂度就会变为 \(O(n^2)\)。
所以怎么解决复杂度退化的问题呢?
一个有效的方法是,我们随机选择一个 \(x\),可以证明其期望复杂度为 \(O(n \log n)\)。
另一个比较常用的方法是,选择 \(a_l,a_{\frac{l+r}{2}},a_r\) 中的中位数量作为 \(x\),大多数情况下可以保证复杂度不退化。
但是现在的快速排序,当数组中的数有大量的重复时,时间复杂度依然会退化。因此,我们可以使用三路快排,将数组划分为 \(<,=,>\) 三个部分。
代码如下:
void quick_sort(int *a,int l,int r){
if(l>=r)return;
int x=a[rand()%(r-l+1)+l];
int i=l;//i 是用来依次扫描每个数的指针
int j=l;//j 的左边都是 <x 的数
int k=r+1;//k 的右边都是 >x 的数
while(i<k){//从左到右扫描一遍
if(a[i]<x)std::swap(a[i++],a[j++]);//放到左边
else if(a[i]>x)std::swap(a[i],a[--k]);//放到右边
else i++;//向后遍历
}
quick_sort(a,l,j-1);
quick_sort(a,k,r);
}
线性第 k 小
基于快速排序的思想,我们可以线性找到一个数组中第 \(k\) 小的数。
我们进行完一轮快速排序后,此时数组被划分为了三个部分(小于、等于和大于),那么第 \(k\) 小的数一定属于这三个部分中的其中一个,因此只需要递归其中一边寻找即可。
期望时间复杂度为 \(O(n)\)。
代码如下:
int kth(int *a,int l,int r,int K){
if(l>=r)return a[l];
int x=a[rand()%(r-l+1)+l];
int i=l,j=l,k=r+1;
while(i<K){
if(a[i]<x)std::swap(a[i++],a[j++]);
else if(a[i]>x)std::swap(a[i],a[--k]);
else i++;
}
if(K<=j-1)return kth(a,l,j-1,K);
else if(K>=k)kth(a,k,r,K);
else return x;
}
STL
我们可以利用 STL 中已经编写好的函数进行排序:
std::sort(a+1,a+n+1);
std::nth_element(a+1,a+k,a+n+1);//求第 k 小的数
注意传入的是一个指针,传入的指针的区间是左闭右开的。
如何自己定义比较方法呢?
我们可以手写一个比较器 cmp
,其需要有两个传参,当满足自己想要的比较形式时返回 1
,否则返回 0
。
例如将数组从大到小排序的代码如下:
bool cmp(int x,int y){return x>y;}
std::sort(a+1,a+n+1,cmp);
std::nth_element(a+1,a+k,a+n+1,cmp);//求第 k 小的数
那么有没有不需要手写的稳定排序呢?
std::stable_sort(a+1,a+n+1);//稳定的排序方法且速度比 sort 较快
归并排序
上面所说的快速排序不具有稳定性,那么就需要一个具有稳定性且基于比较的排序方法————归并排序。
先来考虑这样一个问题,给你两个长度分别为 \(n,m\) 的有序数组 \(a,b\),你需要将他们合并为一个有序数组。
我们利用双指针的思想,从前到后依次比较,每次将较小的元素放入结果数组中,如果两个元素相等就让前面数组中的元素加入数组中,这样就保证了其稳定性。这样就可以在 \(O(n+m)\) 的时间内得到排序的结果。
现在利用这个思想进行排序,对原数组进行分治,递归将其左右半边各自排好序后,利用归并的方法使得整个数组有序。
代码如下:
void merge_sort(int *a,int l,int r){
if(l==r)return;//一个数时自然有序
int mid=(l+r)>>1;
merge_sort(a,l,mid);
merge_sort(a,mid+1,r);//分
int i=l;//i 指针遍历前半个部分的数组
int j=mid+1;//j 指针遍历后半个部分的数组
int k=l;//k 指针用来遍历答案数组
while(i<=mid&&j<=r){//治
if(a[i]<=a[j])ans[k++]=a[i++];//相等时前半部分数组中的元素先放入,保证稳定性
else ans[k++]=a[j++];//只有前面数组中的元素 > 后面数组中的元素才能调整逆序对数量
}
while(i<=mid)ans[k++]=a[i++];
while(j<=r)ans[k++]=a[j++];//剩余位置依次填充
for(int i=l;i<=r;i++)a[i]=ans[i];//将答案数组合并后的结果转移到 a 数组中
return;
}
归并排序具有稳定性,并且时间复杂度为稳定的 \(O(n \log n)\)。
例题 1:Luogu P1908 逆序对
-
题目链接
-
简要题意
给定一个数组 \(a\),求有多少个二元组 \((i,j)\) 满足 \(a_i > a_j , i < j\)。
-
简要思路
如果左半部分的数组中的一个 \(a_i \ge\) 右半部分数组中的一个 \(a_j\),那么由于左右两半个数组都是有序的,所以 \(a_i\) 后面的数(即 \([a_{i+1},a_{mid}]\))也一定 \(\ge a_i\),就一定 \(> a_j\),因此逆序对的数量就要加上 \(mid-i+1\)(只需在合并中加上这一句话即可)。
-
完整代码
#include<bits/stdc++.h> #define int long long #define endl '\n' const int MAXN=5e5+5; int n,a[MAXN],ans[MAXN],sum; void merge_sort(int *a,int l,int r){ if(l==r)return;//一个数时自然有序 int mid=(l+r)>>1; merge_sort(a,l,mid); merge_sort(a,mid+1,r);//分 int i=l;//i 指针遍历前半个部分的数组 int j=mid+1;//j 指针遍历后半个部分的数组 int k=l;//k 指针用来遍历答案数组 while(i<=mid&&j<=r){//治 if(a[i]<=a[j])ans[k++]=a[i++];//相等时前半部分数组中的元素先放入,保证稳定性 else{ ans[k++]=a[j++]; sum+=(mid-i+1);//只有前面数组中的元素 > 后面数组中的元素才能调整逆序对数量 } } while(i<=mid)ans[k++]=a[i++]; while(j<=r)ans[k++]=a[j++];//剩余位置依次填充 for(int i=l;i<=r;i++)a[i]=ans[i];//将答案数组合并后的结果转移到 a 数组中 return; } signed main(){ std::cin>>n; for(int i=1;i<=n;i++)std::cin>>a[i]; merge_sort(a,1,n); std::cout<<sum<<endl; return 0; }
桶排序
对于元素分布比较均匀的数组,我们可以使用桶排序,即基数排序的退化形式。
我们只进行一层基数排序,第二层直接进行冒泡排序等基础排序算法。
假设有 \(k\) 个桶,那么时间复杂度为 \(O(n + k + \frac{n^2}{k})\)。
当元素分布均匀时,我们可以近似地认为时间复杂度为 \(O(n)\)。
排序方法的选择
排序方法 | 优点 | 劣势 |
---|---|---|
计数排序 | 值域较小时速度最快 | 要求数字的值域要较小 |
基数排序 | 值域较大时速度最快 | 只能对数字进行排序 |
快速排序 | 对非数字(例如字符串)的排序时速度最快;基于比较的排序 | - |
归并排序 | 时间复杂度稳定且排序稳定;基于比较的排序 | 常数比快速排序大 |
离散化
对于 \(n\) 个元素,我们如何求出每个元素的排名?
我们可以先将这些元素排序,然后通过二分求出排名。
for(int i=1;i<=n;i++)b[i]=a[i];
std::sort(b+1,b+n+1);
for(int i=1;i<=n;i++)
a[i]=std::lower_bound(b+1,b+n+1,a[i])-b;
如果我们要将相同的元素视作一个,呢么可以使用 unique
函数去重。注意 unique
函数返回的是去重后区间的最后一个元素的下一个元素的指针,并且传入的必须是有序数组。
for(int i=1;i<=n;i++)b[i]=a[i];
std::sort(b+1,b+n+1);
int m=unique(b+1,b+n+1)-b-1;
for(int i=1;i<=n;i++)
a[i]=std::lower_bound(b+1,b+m+1,a[i])-b;
总结:排序的用处
-
排序后能具有新的性质,例如很多贪心题目需要先排序。
-
排序之后数组会变得有序,可以进行二分。
-
\(\ldots\)
例题 2:CF632C The Smallest String Concatenation
-
题目链接
-
简要题意
给定 \(n\) 个字符串,输出拼接后的字符串是的其字典序最小。
-
简要思路
直接按照字典序从小到大排序拼接起来?可是是错误的。
反例:\(b,ba\),按照字典序从小到大的排序会得到 \(bba\),但是最小的应该是 \(bab\)。
因此,我们比较两个字符串 \(s,t\) 谁拼在前面时整个字符串字典序较小,应该比较 \(s + t\) 和 \(t + s\) 哪个小。
证明:将一个字符串看成一个 \(26\) 进制的数,那么我们就要使这个数最小。不难发现 \(26\) 进制数 \(s\) 拼接在 \(26\) 进制数 \(t\) 的前面,当且仅当 \(s · 26^{|t|} + t < t · 26^{|s|} + s\) 成立,整理可得需满足 \(\frac{s}{26^{|s|}-1} < \frac{t}{26^{|t|}-1}\),因此该种比较方法是正确的。
-
完整代码
#include<bits/stdc++.h> #define int long long #define endl '\n' const int MAXN=5e4+5; int n; std::string a[MAXN]; bool cmp(std::string s,std::string t){ return s+t<t+s; } signed main(){ std::cin>>n; for(int i=1;i<=n;i++)std::cin>>a[i]; std::stable_sort(a+1,a+n+1,cmp); for(int i=1;i<=n;i++)std::cout<<a[i]; return 0; }
例题 3:打怪兽
-
简要题意
有 \(n\) 个怪兽,可以按照任意的顺序进行攻打,攻打第 \(i\) 个怪兽会先损失 \(a_i\) 的血量,然后回复 \(b_i\) 的血量,问初始血量最少为多少才能通关(血量一直 \(> 0\))。
-
简要思路
我们假设从 \(0\) 生命值开始,那么就是要确定一个顺序使得打怪兽中途血量最低的时候血量尽量高。
我们可以将怪兽分为两类,一类为 \(a_i \le b_i\),一类为 \(a_i > b_i\),那么我们优选打 \(a_i \le b_i\) 一定先打。
对于 \(a_i \le b_i\) 的部分一定先打 \(a_i\) 小的。
证明:对于 \(a_i \le b_i\) 的怪兽,打完一只后血量一定 \(\ge\) 原来的血量,所以我们要保证途中(即掉血量的那一刻)的血量尽可能高,所以我们先打 \(a_i\) 小的,这样能使得血量掉的较小。
对于 \(a_i > b_i\) 的部分一定先打 \(b_i\) 大的。
证明:对于两个怪兽 \(i,j\),假设当前血量为 \(s\),先攻打怪兽 \(i\) 再攻打怪兽 \(j\) 当且仅当 \(s - a_i + b_i - a_j > s - a_j + b_j - a_i\) 成立,因为打完一只 \(a_i > b_i\) 的怪兽后血量一定 \(<\) 原来的血量,所以我们只需要看 \(b_i > b_j\) 是否成立即可,因此先打 \(b_i\) 大的.
-
完整代码
#include<bits/stdc++.h> #define int long long #define endl '\n' const int MAXN=5e5+5; int n,a[MAXN],b[MAXN]; int sor[MAXN]; int now,minn=1e18; bool cmp(int x,int y){ bool fx,fy; if(a[x]<=b[x])fx=1; else fx=0; if(a[y]<=b[y])fy=1; else fy=0;//分别记录 x,y 分别属于哪种类型的怪兽 if(fx!=fy)return fx>fy;//两种怪兽类型不一样返回打完后能恢复血量的怪兽 else{//否则说明类型一样 if(!fx)return b[x]>b[y]; else return a[x]<a[y]; } } signed main(){ std::cin>>n; for(int i=1;i<=n;i++){ std::cin>>a[i]>>b[i]; sor[i]=i; } std::sort(sor+1,sor+n+1,cmp); for(int i=1;i<=n;i++){ now-=a[sor[i]]; minn=std::min(minn,now); now+=b[sor[i]]; } std::cout<<-minn<<endl; return 0; }
03 数据结构
栈
栈是一种线性数据结构,栈的修改满足后进先出(FILO)的性质。
栈支持以下四种操作:
-
在栈顶加入一个元素;
-
在栈顶弹出一个元素;
-
访问栈顶元素;
-
返回元素数量。
我们可以运用数组解决以上操作:
int st[MAXN],cnt;
void push(int x){st[++cnt]=x;}
void pop(){cnt--;}
int top(){return st[cnt];}
int size(){return cnt;}
当然 C++ 也有封装好的栈,即 std::stack
:
std::stack<int> s;
s.push(x);
s.pop();
std::cout<<s.top()<<endl;
std::cout<<s.size()<<endl;
例题 4:括号匹配
-
简要题意
给定一个字符串,包含了
(
,)
,[
,]
。你需要输出其是否合法。 -
简要思路
利用栈来实现,每个括号将其加入到栈中。
一共有两种非法的情况:
-
右括号加入时没有与之匹配的左括号;
-
最后栈不为空。
-
-
完整代码
#include<bits/stdc++.h> #define int long long #define endl '\n' int n; std::stack<int> s; char c; signed main(){ std::cin>>n; for(int i=1;i<=n;i++){ std::cin>>c; if(c==')'||c==']'){ char t=s.top(); if(c==')'&&t=='(')s.pop(); else if(c==']'&&t=='[')s.pop(); else{//不满足第一种情况 std::cout<<"No\n"; return 0; } }else s.push(c); } if(!s.empty())std::cout<<"No\n";//不满足第二种情况 else std::cout<<"Yes\n"; return 0; }
队列
队列也是一种线性数据结构,队列的修改满足先进先出(FIFO)的性质。
队列支持以下四种操作:
-
在队尾加入一个元素;
-
在队首弹出一个元素;
-
访问队首元素;
-
返回元素数量。
同样也可以用数组解决:
int q[MAXN];
int head,tail;//头尾的指针
void push(int x){q[++tail]=x;}
void pop(){head++;}
int front(){return q[head];}
int size(){return tail-head+1;}
C++ 中也有已经封装好的队列,即 std::queue
:
std::queue<int> q;
q.push(x);
q.push(y);
std::cout<<q.size()<<endl;
q.pop();
std::cout<<q.front()<<endl;
搜索
深度优先搜索本质上使用栈实现;
广度优先搜索使用队列实现。
双端队列
双端队列是一种线性数据结构,类似与栈和队列的结合。
双端队列需要支持两种操作:
-
在队首/队尾进行插入/删除;
-
访问队列中的元素。
依然可以使用数组来模拟双端队列:
需要注意 head
和 tail
的初始化问题,开双倍空间,并将 head
和 tail
放到中点。
我们还可以用循环队列来实现:
int n;//代表最多有 n 个元素
int head=0,tail=0;
void push_back(int x){
tail=(tail+1)%n;
q[tail]=x;
}
void push_front(int x){
head=(head-1+n)%n;
q[head]=x;
}
void pop_back(){
tail=(tail-1+n)%n;
}
void pop_front(){
head=(head+1)%n;
}
C++ 中也有已经封装好的队列,即 std::deque
,但是它的空间常数比较大,使用时要注意:
堆
堆(Heap)是一种树形数据结构,树上的每个节点储存的键值代表堆中的信息。
堆中第 \(i\) 个节点的键值为 \(a_i\),父亲节点为 \(p_i\),那么如果满足 \(a_{p_i} \le a_i\),那么这种堆称为小根堆。同理,如果满足 \(a_{p_i} \le a_i\),那么这种堆称为大根堆。
堆的作用是维护一个可重集合的最值,大根堆用来维护最大值,小根堆用来维护最小值。
二叉堆
二叉堆支持以下操作:
-
插入;
-
查询最大值;
-
删除最大值;
-
直接构建一个有若干元素的堆。
-
插入
新增一个点,然后向上调整
-
删除最大值
将 \(1\) 号节点和 \(n\) 号节点对调,然后向下调整(注意每次只需考虑两个儿子中较大的一个,再考虑是否需要与之交换)。
优先队列
例题 5:Luogu P1090
SP16254
对顶堆