堆、堆组合(对顶堆)以及堆的灵活应用

堆的简介

转载链接(堆的基本概念)

对顶堆

简介

对顶堆是由一个大顶堆和一个小顶堆组合而成的数据结构,与传统堆维护最大数不同,对顶堆用于动态维护第 k k 大的数。在对顶堆中,小根堆位于大根堆的上方,即保证了小根堆的所有数始终比大根堆大。比如维护这样一个数组 [ 1 , 4 , 6 , 8 , 10 , 14 , 16 , 18 , 23 , 55 ] [1,4,6,8,10,14,16,18,23,55] ,此时需要维护第7大的数(也就是8),那么此时对顶堆的结构如下图所示。其中,红色的节点为小根堆,绿色的节点为大根堆。对顶堆示意图,作者,OrzzrOOrzzrO
其中,数字8为小根堆对顶,数字6为大根堆堆顶。可以看到,由于小根堆堆底元素小于堆顶,而大根堆堆底元素小于堆顶,这样的组合保证了整个数据结构中上方的元素永远大于下方的。即小根堆的堆顶永远大于大根堆的堆顶。而此时,小根堆的元素有7个,而大根堆的元素有3个,由于小根堆大于大根堆,并且小根堆堆顶元素为小根堆最小的元素,因此所求的第7大的元素即为小根堆的堆顶元8。

k k 值修改

为了保证 k k 值在修改后仍能保持这样特殊的数据结构,我们需要在k值发生变化时交换两个堆中的元素,不过每次交换只对相应的堆顶进行操作。比如原先的k值是7,现在将 k k 修改为6,那相应的就需要小根堆中的元素数量为6以使得小根堆堆顶元素即为第6大的数。那么需要的操作就是将小根堆的堆顶元素8取出,并将其放入大根堆,此时小根堆中的元素数量为6,而大根堆的元素数量为4,这样此时小根堆堆顶元素即为第k大的数(第6大)。此时的对顶堆结构如下图所示。
对顶堆示意图,作者,OrzzrOOrzzrO
同理,当 k k 值递增时,只需要将小根堆堆顶元素取出,并将其放入大根堆中即可。对于k值的每次修改,假设当前堆中有共有 N N 个元素,即小根堆中有 k k 个元素,大根堆中有 N k N-k 个元素,即 k k 的每次递增或递减的时间复杂度为 O ( l o g k + l o g ( N k ) ) = O ( l o g ( N k k 2 ) ) O(logk+log(N-k))=O(log(Nk-k^2)) 。而普通的二叉平衡树在查询第 k k 大的数(对二叉平衡树进行中序遍历)的时间复杂度为 O ( k ) O(k)

插入和删除

对顶堆的元素插入只要将元素插入大根堆即可,时间复杂度 O ( l o g ( N k ) ) O(log(N-k))
对顶堆的元素删除,首先将待删除元素和两个堆的堆顶比较以确定元素在哪个堆中,如果在大根堆中,则直接删除,时间复杂度为 O ( l o g N k ) O(logN-k) ,如果在小根堆中,则将元素删除后为了维护小根堆大小,即 k k 值,需要将大根堆的堆顶元素移到小根堆中,时间复杂度为 O ( l o g ( N k ) + l o g ( k ) ) O(log(N-k)+log(k)) 。而有序数组虽然能做到 O ( 1 ) O(1) 查询,修改的时间复杂度却达到了 O ( N 2 ) O(N^2)

总结

即对于在线查询第k大的数以及需要支持修改时,若k时呈递增递减、单次变化很小或不变时,对顶堆是更好的选择。当然,如果k的值波动较大,就不适合使用对顶堆了。将问题简化一下,设每次 k k 的变化值为 x x ,即当 x l o g N N xlogN \leq N 时,对顶堆的表现会更好,否则应采用普通的平衡树或堆。

例题

洛谷P1801

题目描述
Black Box是一种原始的数据库。它可以储存一个整数数组,还有一个特别的变量i。最开始的时候Black Box是空的.而i等于0。这个Black Box要处理一串命令。
命令只有两种:
ADD(x):把x元素放进BlackBox;
GET:i加1,然后输出Blackhox中第i小的数。
记住:第i小的数,就是Black Box里的数的按从小到大的顺序排序后的第i个元素。例如:
我们来演示一下一个有11个命令的命令串。(如下图所示)
现在要求找出对于给定的命令串的最好的处理方法。ADD和GET命令分别最多200000个。现在用两个整数数组来表示命令串:
1.A(1),A(2),…A(M):一串将要被放进Black Box的元素。每个数都是绝对值不超过2000000000的整数,M$200000。例如上面的例子就是A=(3,1,一4,2,8,-1000,2)。
2.u(1),u(2),…u(N):表示第u(j)个元素被放进了Black Box里后就出现一个GET命令。例如上面的例子中u=(l,2,6,6)。输入数据不用判错。
输入格式
第一行,两个整数,M,N。
第二行,M个整数,表示A(l)……A(M)。
第三行,N个整数,表示u(l)…u(N)。
输出格式
输出Black Box根据命令串所得出的输出串,一个数字一行。
输入输出样例
输入
7 4
3 1 -4 2 8 -1000 2
1 2 6 6
输出
3
3
1
2
说明/提示 对于30%的数据,M≤10000;
对于50%的数据,M≤100000:
对于100%的数据,M≤200000。

这里采用了std::set来模拟堆操作(实际上手写堆或std::priority_queue会更快)。

#include<iostream>
#include<algorithm>
#include<set>
using namespace std;
int a[200001],
	b[200001],
	m,
	n;
multiset<int>minHeap;
multiset<int, greater<int>>maxHeap;
inline void input() {
	scanf("%d%d", &m, &n);
	for (int i = 1; i <= m; ++i) {
		scanf("%d", &a[i]);
	}
	for (int i = 1; i <= n; ++i) {
		scanf("%d", &b[i]);
	}
}
inline void work() {
	int cur = 1;
	for (int i = 1; i <= n; ++i) {
		for (; cur <= b[i]; ++cur) {
			maxHeap.insert(a[cur]);
			if (maxHeap.size() == i) {
				minHeap.insert(*maxHeap.begin());
				maxHeap.erase(maxHeap.begin());
			}
		}
		printf("%d\n", *minHeap.begin());
		maxHeap.insert(*minHeap.begin());
		minHeap.erase(minHeap.begin());
	}
}

int main() {
	input();
	work();
}

例题二 操作系统

洛谷P2278

扫描二维码关注公众号,回复: 8657541 查看本文章

题目描述
写一个程序来模拟操作系统的进程调度。假设该系统只有一个CPU,每一个进程的到达时间,执行时间和运行优先级都是已知的。其中运行优先级用自然数表示,数字越大,则优先级越高。
如果一个进程到达的时候CPU是空闲的,则它会一直占用CPU直到该进程结束。除非在这个过程中,有一个比它优先级高的进程要运行。在这种情况下,这个新的(优先级更高的)进程会占用CPU,而老的只有等待。
如果一个进程到达时,CPU正在处理一个比它优先级高或优先级相同的进程,则这个(新到达的)进程必须等待。
一旦CPU空闲,如果此时有进程在等待,则选择优先级最高的先运行。如果有多个优先级最高的进程,则选择到达时间最早的。
输入格式
输入包含若干行,每一行有四个自然数(均不超过 1 0 8 10^8 ),分别是进程号,到达时间,执行时间和优先级。不同进程有不同的编号,不会有两个相同优先级的进程同时到达。输入数据已经按到达时间从小到大排序。输入数据保证在任何时候,等待队列中的进程不超过15000个。
输出格式
按照进程结束的时间输出每个进程的进程号和结束时间。
输入输出样例
输入
1 1 5 3
2 10 5 1
3 12 7 2
4 20 2 3
5 21 9 4
6 22 2 4
7 23 5 2
8 24 2 4
输出
1 6
3 19
5 30
6 32
8 34
4 35
7 40
2 42

一道模拟加堆优化的题目。每当读入一个进程时,如果该进程的到来时间晚于已存在队列中进程执行完的时间,就先将队列头的进程执行完在将读入的进程加入队列,否则就直接将读入进程加入队列。可以考虑使用优先队列来维护进程顺序,降低时间复杂度,比较函数定义为进程优先级的大小比较,在优先级相等时考虑时间。

#include<iostream>
#include<algorithm>
#include<set>
#include<queue>
using namespace std;

struct Process {
	int node,//编号
		start,//开始时间
		length,//持续时间
		priority;//优先级
	Process(int&n,int&s,int&l,int&p):node(n),start(s),length(l),priority(p) {
	}
	Process() = default;
	bool operator<(const Process&Right)const {//定义比较函数
		if (this->priority == Right.priority) {
			return this->start > Right.start;
		}
		else {
			return this->priority < Right.priority;
		}
	}
};

priority_queue<Process> Q;

int main() {
	Process CurrentProcess;
	int time;//当前时间
	int n, s, l, p;
	scanf("%d%d%d%d", &n, &s, &l, &p);
	Q.push(Process(n, s, l, p));
	time = Q.top().start;//为第一个到达进程的到达时间
	while (scanf("%d%d%d%d", &n, &s, &l, &p) != EOF) {
		Process next(n, s, l, p);
		time = max(time, Q.top().start);//当前时间更新
		//如果读入进程晚于当前进程执行完时间就先执行,否则加入队列。
		while (!Q.empty()&&Q.top().length + time <= next.start) {
			CurrentProcess = Q.top();
			Q.pop();
			time += CurrentProcess.length;
			printf("%d %d\n", CurrentProcess.node, time);
			if (!Q.empty()) {
				time = max(time, Q.top().start);
			}
		}
		//如果新加入进程优先级大于队列顶进程
		if (!Q.empty()&&Q.top() < next) {
			CurrentProcess = Q.top();
			Q.pop();
			//队列顶进程可以先执行(当前时间到下一进程的空白时间)
			CurrentProcess.length -= next.start - time;
			Q.push(CurrentProcess);
		}
		Q.push(next);
	}
	//清空队列
	while (!Q.empty()) {
		CurrentProcess = Q.top();
		Q.pop();
		time = CurrentProcess.length + (time > CurrentProcess.start ? time : CurrentProcess.start);
		printf("%d %d\n", CurrentProcess.node, time);
	}
}

例题三 序列合并

洛谷P1631

题目描述
有两个长度都是N的序列A和B,在A和B中各取一个数相加可以得到 N 2 N^2 个和中最小的 N N 个。
输入格式
第一行一个正整数 N N
第二行 N N 个整数 A i A_i , 满足 A i 1 0 9 A_i​≤10^9 ;
第三行 N N 个整数 B i B_i ,满足 B i B_i​ 1 0 9 10^9 .
【数据规模】
对于50%的数据中,满足 1 N 1000 1\leq N\leq 1000
对于100%的数据中,满足 1 100000 1\leq 100000
输出格式
输出仅一行,包含N个整数,从小到大输出这N个最小的和,相邻数字之间用空格隔开。
输入输出样例
输入
3
2 6 6
1 4 8
输出
3 6 7

首先暴力的方法就是将这 N 2 N^2 个和都求出来,然后进行排序,但是显然这样做的时间复杂度会高达 O ( N 2 l o g N ) O(N^{2}logN) ,对于100000显然不得行。但是发现给出数据是排好序的,显然我们可以先把 a a 中最小的 a [ 0 ] a[0] b b 先加起来得到 N N 个和,接着在 a [ 1 ] a[1] b b 相加时,因为 a [ 1 ] a [ 0 ] a[1]\geq a[0] ,所以当 i i 达到一定值后,必然会出现 a [ 1 ] + b [ i ] a [ 0 ] + b [ n 1 ] a[1]+b[i]\geq a[0]+b[n-1] (最大的b值),那这时,就没必要往下找了,就接着找 a [ 2 ] a[2] 了。

#include<iostream>
#include<algorithm>
#include<set>
#include<queue>
using namespace std;
int a[100001], b[100001], N;
//采用优先队列维护n个最小和
priority_queue<int>Q;
inline void input() {
	scanf("%d", &N);
	for (int i = 1; i <= N; ++i) {
		scanf("%d", &a[i]);
	}
	for (int i = 1; i <= N; ++i) {
		scanf("%d", &b[i]);
	}
}
int main() {
	input();
	//先将a[1]和b加起来得到n个和
	for (int i = 1; i <= N; ++i) {
		Q.push(a[1] + b[i]);
	}
	//全排列枚举
	for (int i = 2; i <= N; ++i) {
		for (int j = 1; j <= N; ++j) {
			int&&sum = a[i] + b[j];
			//当当前和比队列中最大值还大时就没必要往下找了
			if (Q.top() <= sum) {
				break;
			}
			Q.pop();
			Q.push(sum);
		}
	}
	//从小到达输出
	for (int i = 1; i <= N; ++i) {
		a[i] = Q.top();
		Q.pop();
	}
	for (int i = N; i >= 1; --i) {
		printf("%d ", a[i]);
	}
}

关于时间复杂度,a[0]插入了 N N 个元素,a[1]最多扫描前 2 N \frac{2}{N} ,一直到a[n-1]最多扫描前1个元素,而优先队列的插入删除时间复杂度为 O ( l o g N ) O(logN) ,所以总体时间复杂度为 O ( N l o g N ( 1 + 1 2 + 1 3 + + 1 N ) = O ( N l o g N l n N ) = O ( N l o g 2 N ) O(NlogN(1+\frac{1}{2} + \frac{1}{3}+\cdots + \frac{1}{N}) = O(NlogNlnN)=O(Nlog^{2}N)

例题四 最小函数值

洛谷P2085

题目描述 有n个函数,分别为 F 1 , F 2 , , F n F_1,F_2,\cdots,F_n 。定义 F i ( x ) = A i x 2 + B i x + C i ( x N + ) F_i(x)=A_i*x^2+B_i*x+C_i (x∈N^+) 。给定这些 A i B i A_i、B_i C i C_i ,请求出所有函数的所有函数值中最小的m个(如有重复的要输出多个)。
输入格式 输入数据:第一行输入两个正整数n和m。以下n行每行三个正整数,其中第i行的三个数分别为 A i B i A_i、B_i C i C_i A i 10 B i 100 C i 10000 A_i\leq10,B_i\leq100,C_i\leq10000
输出格式
输出数据:输出将这n个函数所有可以生成的函数值排序后的前m个元素。这m个数应该输出到一行,用空格隔开。
输入输出样例
输入
3 10
4 5 3
3 4 5
1 7 1
输出
9 12 12 19 25 29 31 44 45 54
说明/提示 数据规模:n,m<=10000

和例题三的思路是完全一样的

#include<iostream>
#include<algorithm>
#include<set>
#include<queue>
#include<vector>
using namespace std;
struct Fun{
	int A, B, C;
	Fun(int&a, int&b, int&c) :A(a), B(b), C(c) {  }
	int get(int&val) {
		return A * val*val + B * val + C;
	}
};
vector<Fun> funs;
priority_queue<int>Q;
vector<int> ans;
int m, n;
inline void input() {
	scanf("%d%d", &n, &m);
	for (int i = 1; i <= n; ++i) {
		int a, b, c;
		scanf("%d%d%d", &a, &b, &c);
		funs.push_back(Fun(a, b, c));
	}
}
inline void init() {
	for (int i = 1; i <= m; ++i) {
		Q.push(funs[0].get(i));
	}
}
inline void compute() {
	for (int i = 1; i < funs.size(); ++i) {
		int cnt = 0;
		while (true) {
			int &&val = funs[i].get(++cnt);
			if (Q.top() <= val) {
				break;
			}
			else {
				Q.pop();
				Q.push(val);
			}
		}
	}
}
inline void reserve() {
	while (!Q.empty()) {
		ans.push_back(Q.top());
		Q.pop();
	}
}
inline void output() {
	for (auto it = ans.crbegin(); it != ans.crend(); ++it) {
		printf("%d ", *it);
	}
}
int main() {
	input();
	init();
	compute();
	reserve();
	output();
}

例题五 种树

洛谷P1484

题目描述
cyrcyr今天在种树,他在一条直线上挖了n个坑。这n个坑都可以种树,但为了保证每一棵树都有充足的养料,cyrcyr不会在相邻的两个坑中种树。而且由于cyrcyr的树种不够,他至多会种k棵树。假设cyrcyr有某种神能力,能预知自己在某个坑种树的获利会是多少(可能为负),请你帮助他计算出他的最大获利。
输入格式
第一行,两个正整数n,k。
第二行,n个正整数,第i个数表示在直线上从左往右数第i个坑种树的获利。
输出格式
输出1个数,表示cyrcyr种树的最大获利。
输入输出样例
输入
6 3
100 1 -1 100 1 -1
输出
200
说明/提示 对于20%的数据,n<=20。
对于50%的数据,n<=6000。
对于100%的数据,n<=500000,k<=n/2,在一个地方种树获利的绝对值在1000000以内。

O ( n 2 ) O(n^2) 做法:

动态规划,定义 d p [ i ] [ j ] dp[i][j] 为种到第i棵树时种第j棵树的最大收益,那么状态转移方程为 d p [ i ] [ j ] = m a x ( d p [ i 1 ] [ j ] , d p [ i 2 ] [ j 1 ] + a [ j ] ) dp[i][j]=max(dp[i-1][j],dp[i-2][j-1]+a[j]) a [ j ] a[j] 为第j棵树的收益。

#include<iostream>
#include<algorithm>
#include<set>
#include<queue>
#include<cmath>
#include<vector>
using namespace std;
int n, k;
int a[500001];
int dp[4][500001];
constexpr int mod = 3;
inline void input() {
	scanf("%d%d", &n, &k);
	for (int i = 1; i <= n; ++i) {
		scanf("%d", &a[i]);
	}
}
inline void DP() {
	dp[1][1] = max(0, a[1]);
	dp[2][1] = max(a[1], max(0, a[2]));
	for (int i = 3; i <= n; ++i) {
		//最多种 n/2棵树
		for (int j = 1; j <= ceil(double(i)/2.); ++j) {
			dp[i%mod][j] = max(dp[(i - 1)%mod][j], dp[(i - 2)%mod][j - 1] + a[i]);
		}
	}
}
inline void output() {
	int ans = dp[n%mod][1];
	for (int i = 2; i <= k; ++i) {
		ans = max(dp[n%mod][i], ans);
	}
	printf("%d\n", ans);
}
int main() {
	input();
	DP();
	output();
}
O ( k l o g n ) O(klogn) 做法:

我们发现,当第 i i 棵树被种下时,第 i + 1 i+1 i + 1 i+1 棵树就不能被种了。所以对于第 i + 1 i+1 i 1 i-1 棵树,有三种情况:只有一棵树被种,没有树被种以及两棵树都被种。可以发现,对于只有一颗树被种时( i + 1 i+1 i 1 i-1 ),第i棵树肯定不能种,那就等价于第 i + 1 i+1 i 1 i-1 棵树单独被种的情况,也就是第i以及第i+2棵树不被中或第i-2棵树以及第i棵树不被中,那么此时只有一棵树被种的情况相当于考虑其相邻树时的情况。
因此,需要考虑的情况为第i棵树同时被种或同时不被种的情况。
首先,使用双向链表来标记所有树洞的位置,一开始左右相邻,当选择第 i 1 i-1 i + 1 i+1 个树洞种树时,第 i i 个树不种,这是可以考虑将 i 1 i i + 1 i-1,i,i+1 这三个树洞合并为一个树洞,并且这个新的树洞增加的收益为 a [ i + 1 ] + a [ i 1 ] a [ i ] a[i+1]+a[i-1]-a[i] ,如此相较于种第i棵树多种了一棵树,如此循环直到种满 n n 棵树即可。

#include<iostream>
#include<algorithm>
#include<set>
#include<queue>
#include<vector>
using namespace std;
long long n, k;
long long a[500001];//树洞收益
long long Left[500001],Right[500001];//双向链表
bool isDelete[500001]{ false };//标记已被合并的树洞,避免重复选择。
struct Node {
	int node;
	long long val;
	Node(int&node, long long&val):node(node), val(val) {  }
	bool operator<(const Node&Right)const {
		return this->val < Right.val;
	}
};
priority_queue<Node> Q;//优先队列
inlicne void input() {
	scanf("%lld%lld", &n, &k);
	for (int i = 1; i <= n; ++i) {
		scanf("%lld", &a[i]);
		Q.emplace(i, a[i]);
	}
}
//初始化双向链表
inline void init() {
	for (int i = 1; i <= n; ++i) {
		Left[i] = i - 1;
		Right[i] = i + 1;
	}
}
long long compute() {
	long long ans = 0;//总收益
	while (k--) {//循环执行k次,表示最多种k棵树
		//当队头已经被合并了就不要
		while (isDelete[Q.top().node]) {
			Q.pop();
		}
		auto N = Q.top();
		Q.pop();
		//当前待处理树洞中的最大收益小于0时就没必要再种了
		if (N.val < 0) {
			break;
		}
		//种下这棵树
		ans += N.val;
		int&node = N.node;
		//将node+1,node,node-1合并为node
		a[node] = a[Left[node]] + a[Right[node]] - a[node];
		isDelete[Left[node]]=isDelete[Right[node]]=true;
		//链表合并
		Left[node] = Left[Left[node]];
		Right[Left[node]] = node;
		Right[node] = Right[Right[node]];
		Left[Right[node]] = node;
		//入队
		Q.emplace(node, a[node]);
	}
	return ans;
}
int main() {
	input();
	init();
	printf("%lld\n", compute());
}
发布了20 篇原创文章 · 获赞 22 · 访问量 2492

猜你喜欢

转载自blog.csdn.net/qq_42971794/article/details/103388920