数据结构[常规/进阶]

目录

链表/数组

题目来源:队列安排

栈/队列

题目来源:后缀表达式

题目来源:机器翻译

并查集

题目来源:奶酪

题目来源:星球大战

题目来源:食物链

题目来源:银河英雄传说

堆/优先队列

题目来源:【模板】堆

题目来源:合并果子

题目来源:中位数

题目来源:Cow Coupons G

ST表

题目来源:忠诚

题目来源:最大数

树状数组

题目来源:【模板】树状数组1

题目来源:【模板】树状数组2

题目来源:计数问题

题目来源:Promotion Counting P


链表/数组

最基础的线性数据结构

题目来源:队列安排

背景:有n个同学,序号为1~n,先让一号同学进队列,然后2~n号同学每次被安排在第1~i-1号同学的左边或右边,最后删除m名同学,求剩下的同学的序号组成的序列

思路:每次要将第k个同学放在第i个同学的左边或者右边,可以看出会有三个同学的左右会被改变,即k、i、i-1/i+1,如何改变则要看放在左边还是右边,很快我们就能联想到线性数据的插入操作,不过数组的插入每次需要向后移动元素,时间复杂度会达到O(n^2),肯定是不行的,而链表的查找过程又是线性的,时间复杂度也会达到O(n^2),所有我们考虑用数组来模拟链表,结合两者的优点,让查找效率和插入效率都变为O(1),总体时间复杂度达到O(n)

用一个结构体数组来模拟链表,结构体存储第序号为i的学生的左边和右边的学生的序号,每次插入就像链表的插入一样把对应的三个元素的l,r改变,这里不多解释

总体代码:

#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;

struct node{
	int l,r;
}arr[100010];

int main()
{
	int n;
	scanf("%d",&n);
	arr[1].l = 0;//初始第一个学生入队,此时队中只有一个元素,所以左右编号都为0
	arr[1].r = 0;
	for(int i = 2;i <= n;i++)
	{
		int k,p;
		scanf("%d %d",&k,&p);
		if(p == 0)
		{
            //标准的插入操作(i插入到k左边的位置)
			arr[arr[k].l].r = i;
			arr[i].l = arr[k].l;
			arr[i].r = k;
			arr[k].l = i;
		}
		else
		{
            //标准的插入操作(i插入到k右边的位置)
			arr[arr[k].r].l = i;
			arr[i].r = arr[k].r;
			arr[k].r = i;
			arr[i].l = k;
		}
	}
	int m;
	scanf("%d",&m);
	for(int i = 1;i <= m;i++)
	{
		int x;
		scanf("%d",&x);
		if(arr[x].l == -1)//用-1标记来判断是否是被删除过的,减少重复操作
		{
			continue;
		}
		arr[arr[x].l].r = arr[x].r;
		arr[arr[x].r].l = arr[x].l;
		arr[x].l = -1;
	}
	for(int i = 1;i <= n;i++)
	{
		if(arr[i].l == 0)//这里找到左边为0的学生的序号,即该学生就是当前队列中的第一位
		{
			int j = i;
			while(arr[j].r != 0)//arr[j].r == 0则代表j是最后一位同学,最后单独输出即可
			{
				cout << j << " ";
				j = arr[j].r;//j每次改变为j右边的学生的序号
			}
			cout << j;
			break;
		}
	}
	return 0;
}

用数组模拟链表是一种基础的操作,结合了两者的优点,往往能更快的解决线性问题


栈/队列

  • 栈:后进先出原则
  • 队列:先进先出原则

题目来源:后缀表达式

背景:所谓后缀表达式是指这样的一个表达式:式中不再引用括号,运算符号放在两个运算对象之后,所有计算按运算符号出现的顺序,严格地由左而右新进行(不用考虑运算符的优先级)。

思路:运算符只会出现在两个数字/运算表达式之后,也就是说每次遇到输入符号就要把前面两次的数字或者算式的值拿出来进行运算,然后形成新的值,很经典的考察对栈的运用,这里要注意每次输入的都是一个字符,如果遇到了一个大于10的数就要每次都把前面的数字拿出来乘十再加上当前的数字

这里说一下c++标准库中的stack的几个常用的函数:

  • xx.top(),返回栈顶元素的值
  • xx.push(e),将元素e入栈
  • xx.pop(),栈顶元素出栈
  • xx.size(),返回栈中元素个数
  • xx.empty(),栈为空返回true,否则返回false

总体代码:

#include<iostream>
#include<algorithm>
#include<stack>
using namespace std;

stack<int> st;
int main()
{
	int num = 0,x,y;//num记录输入的当前数字的值,x,y用作每次取出的进行运算的两个数字
	char ch;
	while(ch != '@')
    {
    	ch = getchar();
        switch(ch)//判断输入的是符号还是数字,遇到‘.’就将数字归零
        {
            case '+':
				x = st.top();
				st.pop();
				y = st.top();
				st.pop();
				st.push(x+y);
				break;
            case '-':
				x = st.top();
				st.pop();
				y = st.top();
				st.pop();
				st.push(y-x);
				break;
            case '*':
				x = st.top();
				st.pop();
				y = st.top();
				st.pop();
				st.push(x*y);
				break;
            case '/':
				x = st.top();
				st.pop();
				y = st.top();
				st.pop();
				st.push(y/x);
				break;
            case '.':
				st.push(num);
				num = 0;
				break;
            default :
				num = num*10 + ch-'0';
				break;
        }
    }
	printf("%d",st.top());//最后栈顶就是答案
	return 0;
}

题目来源:机器翻译

背景:某软件翻译原理如下:假设内存中有M个单元,每个单元可存放一个数据,每次遇到文字先在查询内存中是否出现过,有则直接使用,否则要从外部字典中查询该文字并将其放入内存单元中,当内存已满时要将最先存储的数据删除掉,然后才能放下新的数据,现有一段序列有n个文字,求改软件需要查询多少次外部字典

思路:每次读入文字的时候只需要判断是否记忆过该文字即可,这一步操作可以用一个bool数组来模拟,主要的是如何判断当前文字是否在当前的M个内存中。

每次内存超出的时候需要将最先进入的数据清除,这一步我们可以用一个先进先出的数据结构:队列来实现,同时要求限制队列的长度不超过M即可

这里说一下c++标准库中的queue的几个常用的函数:

  • xx.front(),返回队头元素的值
  • xx.push(e),将元素e入队
  • xx.pop(),队头元素出队
  • xx.size(),返回队列中元素个数
  • xx.empty(),队列为空返回true,否则返回false

总体代码:

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

int arr[1010];
bool vis[1010];//模拟是否含有某个数据
queue<int> q;
int main()
{
	int m,n;
	scanf("%d %d",&m,&n);
	for(int i = 1;i <= n;i++)
	{
		scanf("%d",&arr[i]);
	}
	int cnt = 1;
	int ans = 1;
	vis[arr[1]] = 1;//第一个元素先入队,这两步操作可有可无,下面从1开始循环即可
	q.push(arr[1]);
	for(int i = 2;i <= n;i++)
	{
		if(vis[arr[i]])//如果队列中已有则跳过
		{
			continue;
		}
		else//没有的话查询次数和队列长度都要更新
		{
			ans++;
			cnt++;
			if(cnt > m)//长度超出M,队头元素出队,同时该元素设置为false
			{
				vis[q.front()] = false;
				q.pop();
			}
			q.push(arr[i]);
			vis[arr[i]] = true;
		}
	}
	cout << ans;
}

并查集

用于合并某些不相交的集合和查询两个集合之间是否相交,通常用森林实现,其中每棵树用根代表一个集合

  • 初始化:每个集合的根节点默认为自身
  • 合并操作:两个集合合并到一起,即将两棵树连起来成为一棵树即可,通常是将两个集合的根节点连接起来
  • 查询操作:由于在一个集合中可能有多个元素,所以可以找一个元素作为这个集合的代表(根节点),该集合中的所有元素都是该节点的子节点,设置一个数组fa,fa[i]代表元素i的父节点——即该元素所属的集合,当元素较多时每次查询某个元素所属集合的根节点的时候需从当前节点开始寻找fa[i]、寻找fa[i]的fa[fa[i]]......直到fa[i] = i,这样的查询效率在某些情况下会退化成线性的,效率很低,所有我们在查询的时候进行路径压缩:让当前元素之间连接集合的根节点——即将当前元素的父节点设置为父节点的父节点直到fa[i] = i,这样每次查询效率都是O(1)的

合并和查询操作的代码如下:

int getp(int x)//查询
{
	if(fa[x] == x)
	{
		return x;
	}
	else
	{
		return fa[x] = getp(fa[x]);
	}
}
void mer(int a,int b)//合并
{
	int x = getp(a);
	int y = getp(b);
	fa[x] = y;
}

题目来源:奶酪

背景:在一个高为H,长宽无线的正方体中有n个球心为xi,yi、半径位ri的空心球,当两个球相交或者相切时可以从一个球的任意地方移动到另一个球的任意地方,现给出n个数据,初始位置在高为0的任意地方,要求只能在球内移动,求是否能到达>=H的高度

思路:考察最基本的并查集的运用,题目要求只能在相切或相交的球中移动,可以把符合条件的球看作一个集合,该集合可以到达集合中所有球能到达的任意地方,当到达的高度>=H时即满足答案,所以我们可以先将所以的球进行预处理

  • 将可连通的球放到一个集合,然后依次遍历每个球低高度<=0的球,再依次遍历每个球顶高度>=H的球,当这两个球属于同一集合时输出YES即可

要注意的是如果在计算距离的时候如果用到开方要注意精度,这里推荐直接将距离平方处理不开根号

总体代码:

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

struct node{
	long long x,y,z;
}arr[1010];
int fa[1010];
int getp(int x)
{
    if(fa[x] == x)
    {
        return x;
    }
	return fa[x] = getp(fa[x]);
}
void mer(int a,int b)
{
	fa[getp(a)] = getp(b);
}

long long dis(int a,int b)//距离计算公式
{
	return (arr[a].x-arr[b].x)*(arr[a].x-arr[b].x)+(arr[a].y-arr[b].y)*(arr[a].y-arr[b].y)+(arr[a].z-arr[b].z)*(arr[a].z-arr[b].z);
}

int main()
{
	int t;
	scanf("%d",&t);
	while(t--)
	{
		long long n,h,r;
		bool flag = false;
		scanf("%lld %lld %lld",&n,&h,&r);
		for(int i = 1;i <= n;i++)
		{
			fa[i] = i;
		}
		for(int i = 1;i <= n;i++)
		{
			scanf("%lld %lld %lld",&arr[i].x,&arr[i].y,&arr[i].z);
		}
		long long d = r*2*r*2;//d表示直径的平方
		for(int i = 1;i <= n;i++)
		{
			for(int j = i+1;j <= n;j++)
			{
				if(dis(i,j) <= d)//两圆心距离不超过直径即可互通
				{
					mer(getp(i),getp(j));
				}
			}
		}
		for(int i = 1;i <= n;i++)
		{
			if(arr[i].z-r > 0)//找出0高度可达的球
			{
				continue;
			}
			for(int j = 1;j <= n;j++)
			{
				if(arr[j].z+r < h)//找出能到达H高度的球
				{
					continue;
				}
				if(getp(fa[i]) == getp(fa[j]))//判断是否所属同一集合
				{
					flag = true;
					break;
				}
			}
			if(flag)
			{
				break;
			}
		}
		if(flag)
		{
			puts("Yes");
		}
		else
		{
			puts("No");
		}
	}
	return 0;
}

题目来源:星球大战

背景:现有n个星球,其中有m条通道,被通道直接或间接连接的星球之间可以任意通行,现给出一个序列,代表依次摧毁的星球,求一开始和每次摧毁一个星球之后的剩余星球的连通块个数

思路:第一次答案很简单,只需呀将可连通的星球看作一个集合,最后判断有几个连通块即可,可以用dfs或者bfs来进行遍历判断连通块的个数,重点在于后面的问题,删去一个点之后可能有很多种情况:

  • 删掉的点为一个独立的集合,那么连通块个数就会减少
  • 删掉的点为集合树中的叶子节点,那么连通块个数不会改变
  • 删掉的点为两个或多个集合交界处,那么连通块个数会增加x个

并查集路径压缩之后我们就只能知道某个星球属于哪个集合,并不知道该星球实际直接连接的星球是哪一个,这样只能放弃路径压缩,每次向上寻找父节点的同时判断父节点是否被删除,通过这种方式来判断连通块的个数,这样就相当于每次都跑一遍dfs/bfs来判断,时间复杂度达到O((n+m)*n),效率非常低

我们可以换位思考,每次摧毁一个星球的反向操作就是每次添加一个星球,每次新增一个星球后就可以将当前存在的星球中与当前星球有通道的星球全部连通,也就是将与该星球互通的且当前存在的星球放入同一个集合

  • 如果当前有且仅有一个集合可以容纳该星球,则连通块数量不变
  • 如果当前有多个集合可以容纳该星球,即通过该星球能将多个集合连接起来,则连通块数量增加值为可容纳该星球的集合数-1(至少有两个集合才能合并)
  • 如果当前没有集合可以容纳该星球,则该星期独自成为一个集合,连通块个数+1

我们可以先按照星球的连通来建图,注意是互通要建无向图,然后先记录下星球依次被摧毁的顺序,将他们设置为已被摧毁的状态(false),按照逆序依次还原(true),每次还原按照上面的推论对连通块进行判断,得出的答案逆序存在一个数组中,因为是逆向的,所以这里要特判一次没有任何还原的连通块的情况作为初始值,同时也是顺序中最后一次的答案

总体代码:

#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;

int cnt;
struct graph{
	int next,to;
}edge[400010];
int head[400010];//以上为建图所需
int fa[400010];//并查集
int qe[400010];//记录问题:顺序摧毁的星球
int ans[400010];//记录答案
bool nvis[400010];//novisit,表示是否未被还原

void ADD(int u,int v)//建图
{
	cnt++;
	edge[cnt] = (graph){head[u],v};
	head[u] = cnt;
}
int getp(int x)
{
	if(fa[x] == x)
	{
		return x;
	}
	else
	{
		return fa[x] = getp(fa[x]);
	}
}
void mer(int a,int b)
{
	fa[getp(a)] = getp(b);
}
void dfs(int now)//dfs只需开始跑记录初始连通块个数
{
	for(int i = head[now];i;i = edge[i].next)
	{
		int pos = edge[i].to;
		if(nvis[pos])
		{
			continue;
		}
		if(getp(now) != getp(pos))
		{
			mer(now,pos);
		}
		nvis[pos] = true;
		dfs(pos);
	}
}
int main()
{
	int n,m;
	scanf("%d %d",&n,&m);
	for(int i = 0;i < n;i++)
	{
		fa[i] = i;
	}
	for(int i = 1;i <= m;i++)
	{
		int x,y;
		scanf("%d %d",&x,&y);
		ADD(x,y);
		ADD(y,x);
	}
	int k;
	scanf("%d",&k);
	for(int i = 1;i <= k;i++)
	{
		scanf("%d",&qe[i]);
		nvis[qe[i]] = true;//代表已被摧毁
	}
	cnt = 0;
	for(int i = 0;i < n;i++)
	{
		if(!nvis[i])
		{
			nvis[i] = true;
			cnt++;//dfs运行次数即连通块个数
			dfs(i);
		}
	}
	ans[k+1] = cnt;//最后一次的答案
	memset(nvis,false,sizeof(nvis));//重新初始化nvis数组,因为这里和dfs共用了
	for(int i = 1;i <= k;i++)
	{
		nvis[qe[i]] = true;
	}
	for(int i = k;i >= 1;i--)//逆序重建星球
	{
		int now = qe[i];
		nvis[now] = false;
		for(int j = head[now];j;j = edge[j].next)//判断是否存在当前已存在的可与该星球连通的集合
		{
			int pos = edge[j].to;
			if(nvis[pos])
			{
				continue;
			}
			if(getp(now) != getp(pos))//若存在且当前未在同一集合则合并并且连通块个数-1
			{
				mer(now,pos);
				cnt--;
			}
		}
		cnt++;//无论如何连通块个数+1(可能是自身成为独立集合,也可能是通过自身合并多个集合)
		ans[i] = cnt;//记录答案
	}
	for(int i = 1;i <= k+1;i++)
	{
		cout << ans[i] << "\n";
	}
	return 0;
}

题目来源:食物链

背景:有三种类型的动物A吃B,B吃C,C吃A,现给出n个动物,他们都属于这三种类型,同时给出K句话,表示x吃y或者x,y属于同种类型,要求判断其中的假话个数

  • 当前的话表示X吃X则为假话
  • 当前的话中X或者Y大于n则为假话
  • 当前的话中与前面的真话冲突则为假话

思路:有n个动物,分成三种类型,可以把一个类型看作一个集合,当表示x,y属于同类的时候将x和y放入相同的集合即可,问题在于如何处理x吃y

这里介绍一种新的并查集:扩展域并查集

将原有的并查集扩大x倍,当前1~n中集合表示”朋友“,n+1~2*n中表示”敌人“......以此扩展,在不知道自身的确定身份的情况下要考虑合并/查询每种可能

例如:假设1~n为种类X,n+1~2*n为种类Y,那么X中的xi和Y中的yi属于同一集合就代表xi和yi是敌人,X中的xi和X中的xj属于同一集合则代表xi和xj是朋友

回到本题,本题有三个关系:A-B,B-C,C-A,所以这里开三倍的并查集来模拟这个情况:

A中a和B中b合并代表a吃b,B中b和C中c合并代表b吃c,C中c和A中a合并代表c吃a等等;这样一来我们可以通过判断x和y分别属于哪个类型来判断当前说的话是真的还是假的,但三种类型不同于两种类型的非黑即白,要考虑x是A、B、C的三种情况

  • 当前话表示x吃y时则表示x+n和y不属于同一集合(y吃x),x和y不属于同一集合(x,y同类型)
  • 当前话表示x,y同类型则表示x+n和y不属于同一集合,x和y+n不属于同一集合(x吃y和y吃x)

当发现这是真话的时候就将所有可能的关系合并:

  • 当前话表示x吃y则合并x和y+n、x+n和y+2n、x+2n和y
  • 当前话表示x,y同类型则合并x和y、x+n和y+n、x+2n和y+2n

最后输出假话的个数即可

总体代码:

#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;

int fa[50010*3];//开三倍

int getp(int x)
{
	if(fa[x] == x)
	{
		return x;
	}
	else
	{
		return fa[x] = getp(fa[x]);
	}
}
void mer(int a,int b)
{
	fa[getp(a)] = getp(b);
}

int main()
{
	int n,k;
	scanf("%d %d",&n,&k);
	int ans = 0;
	for(int i = 1;i <= n*3;i++)
	{
		fa[i] = i;
	}
	for(int i = 1;i <= k;i++)
	{
		int op,x,y;
		scanf("%d %d %d",&op,&x,&y);
		if(x > n || y > n)//大于n则为假话
		{
			ans++;
			continue;
		}
		if(op == 1)
		{
			if(getp(x) == getp(y+n) || getp(x+n) == getp(y))//若x吃y或者y吃x则为假话
			{
				ans++;
				continue;
			}
			else//合并x,y同类型所有可能
			{
				mer(x,y);
				mer(x+n,y+n);
				mer(x+n*2,y+n*2);
			}
		}
		else
		{
			if(getp(x) == getp(y) || getp(x+n) == getp(y))//若x,y同类或y吃x则为假话
			{
				ans++;
				continue;
			}
			else//合并x吃y所有可能
			{
				mer(x,y+n);
				mer(x+n,y+n*2);
				mer(x+n*2,y);
			}
		}
	}
	cout << ans;
	return 0;
}

题目来源:银河英雄传说

背景:有30000支队伍,命令M:含有a的队伍排到含有b的队伍后面,命令C:输入a和b之间有多少支队伍

思路:

这里介绍另一种新的并查集:带权并查集

在使用并查集进行合并的时候,每个元素可能会对该集合带来不同的影响,我们将这个影响叫做权值,一般是两个节点之间的相对关系,并查集的各条边上额外记录一些信息的并查集叫做带权并查集

带权并查集的权值更新操作发生在查询和合并上,一般合并会更新某个集合的权值,查询通过路径压缩的过程来更新所需使用的点的权值

以本题为例,队列a排到b后面则以b为头的队伍的总长度由1变成了2,而以a为头的前面的队伍长度由0变为1,这步更新是可以通过合并来完成的

这里用up数组存储以a为头的队伍总长度,down数组存储a前方队伍的数量

void mer(int a,int b)//将a放在b后面
{
	int x = getp(a);
	int y = getp(b);
	down[x] += up[y];//a集合根节点的前方队伍数量增加值为b集合根节点的队伍总数
	up[y] += up[x];//b集合根节点存储总数增加值为a集合根节点存储队伍总数
	up[x] = 0;//此时没有a集合的旧根节点为头的队伍了
	fa[x] = y;//a集合的根节点设置为b集合的根节点
}

假设现在有两个队伍123、456,现在所记录的2前面的队伍数量为1,以4为头的队伍长度为2,现在将这两支队伍合并

合并时会更新3的前面的队伍的数量和以6开头的队伍的长度,但查询2到4之间有几个队伍时却犯难了,因为这两个队伍的权值没有在合并时得到更新,如果想要更新只能不使用路径压缩,每次通过寻找父节点依次传递,但这样每次更新的效率又退化为O(n),肯定会超时,所以这里在查询时进行一步带权继承操作:

当两个集合合并时,有一边集合的元素的根是不需要改变的,而另一边集合的所有的元素的根都发生了改变,当再一次查询另一边集合中的某个元素时,由于只有该集合的根进行了合并,该元素找到的根还是原来所在集合中的根,所以需要更新一次,同时由于合并操作已经更新了两集合的根的权值,此次查询我们可以直接利用更新之后的根来对该元素进行更新:

  • 该元素的前面的队伍数量 = 原数量 + 未更新的根的前面的队伍的数量
int getp(int x)
{
	if(fa[x] == x)
	{
		return x;
	}
	else
	{
		int nx = getp(fa[x]);
		down[x] += down[fa[x]];//该元素的前方队伍加上原先集合中根节点当前前方队伍的数量
		return fa[x] = nx;
	}
}

进行带权继承之后该点的权值也更新完毕

最后注意初始化的时候把记录以某个节点作为队头的队伍总长度的数组初始化为1

总体代码:

#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;

int fa[300010];
int up[300010];
int down[300010]; 
const int n = 300000;
int getp(int x)
{
	if(fa[x] == x)
	{
		return x;
	}
	else
	{
		int nx = getp(fa[x]);
		down[x] += down[fa[x]];
		return fa[x] = nx;
	}
}
void mer(int a,int b)
{
	int x = getp(a);
	int y = getp(b);
	down[x] += up[y];
	up[y] += up[x];
	up[x] = 0;
	fa[x] = y;
}

int main()
{
	int t;
	for(int i = 1;i <= n;i++)
	{
		fa[i] = i;
		up[i] = 1;//初始化为1
	}
	scanf("%d",&t);
	getchar();
	while(t--)
	{
		char op;
		int x,y;
		scanf("%c %d %d",&op,&x,&y);
		getchar();
		if(op == 'M')
		{
			mer(x,y);
		}
		else
		{
			if(getp(x) != getp(y))//不再同一集合就输出-1
			{
				cout << "-1\n";
			}
			else
			{
                //答案就是x和y两点前面的队伍数量只差的绝对值-1,很好理解
				cout << abs(down[x]-down[y])-1 << "\n";
			}
		}
	}
	return 0;
}

堆/优先队列

堆是用数组来模拟实现的一颗完全二叉树,堆的顶端一般用来存储优先级最高(不一定就是数最大的,根据题目要求)的元素,同样的,对于堆的每颗子树也是一个堆,符合堆的性质

堆的主要操作由两个:插入元素和弹出元素;堆也主要分为两种:大根堆和小根堆

  • 插入元素:将要插入的元素放在数组末尾,不断的根据父节点的数据和当前堆的性质来进行交换调整,最终使得整个堆符合堆的性质,由于堆是一颗完全二叉树,不难得出i位置的父节点为i/2,i位置的孩子节点为2*i和2*i+1;这里以大根堆为例:
int heap[N];//堆
int len;//堆的大小
void push(int x)
{
	heap[++len] = x;//将元素x插入堆末尾
	int now = len;//now为当前元素位置
	while(now > 1)//当前元素不为堆顶,还有调整的可能
	{
		int fa = now>>1;//父节点为当前节点除以2的位置
		if(heap[fa] < heap[now])//如果父节点比当前节点大就交换
		{
			swap(heap[fa],heap[now]);
		}
		else//否则直接退出即可,不需要再进行其他操作
		{
			break;
		}
		now = fa;//交换后当前节点的位置应该更新为父节点的位置
	}
	return;
}
  • 弹出元素:即得到优先级最高的堆顶的元素,该元素就是第一个元素,直接返回第一个位置的元素即可,同时要将该元素从堆顶移出,也就是堆顶的下放操作
int top()//取堆顶元素
{
	return heap[1];
}
void pop()//弹出堆顶元素
{
	swap(heap[len],heap[1]);//先将堆顶元素和最后一个元素互换
	len--;//长度-1,即删除最初的堆顶元素
	int now = 1;//将更新后的堆顶再次进行调整直到符合堆的性质
	while(now<<1 <= len)
	{
		int l = now<<1;//左孩子
		int r = now<<1|1;//右孩子
		if(r <= len && heap[r] < heap[l])//判断要交换的是左孩子还是右孩子
		{
			swap(heap[now],heap[r]);
			now = r;//交换之后位置也将改变
		}
		else if(heap[l] < heap[now])
		{
			swap(heap[now],heap[l]);
			now = l;
		}
		else
		{
			break;
		}
	}
	return;
}

这里再介绍一下stl中的优先队列:priority_queue

  • 优先队列也是用堆来实现的,同样支持插入和删除等操作,不过手写的堆支持删除中间元素,优先队列只允许删除堆顶元素,优先队列默认是大根堆,这里给出几种创建方法和常用操作:
#include<queue>
typedef struct node{
    int x,y;
    bool operator < (const node& n) const
    {
        return ......;//比较方式可自定义
    }
}node;
priority_queue<int> q1;//默认大根堆
priority_queue<int,vector<int>,greater<int>> q2;//小根堆
priority_queue<node> q3;//node类型,排序方式可自定义
void test()
{
	priority_queue<int> q;
	q.push(x);//插入元素 
	int num = q.top();//取堆顶元素 
	q.pop();//弹出堆顶元素 
	int len = q.size();//求堆长度 
	bool a = q.empty();//判断是否为空 
}

题目来源:【模板】堆

背景:给出一个数列要求支持插入元素、删除最小元素、查询最小元素三种操作

思路:模板题,根据上面给出的代码创建一个小根堆然后进行判断插入删除还是输出即可


题目来源:合并果子

背景:有n堆果子,每堆数量可能相同或不同,每次合并两堆果子成为一堆时消耗的体力为两堆果子数量之和,求花费最小的体力将n堆果子合并为一堆

思路:要使花费体力最少,那么我们就尽量让数量较多的几堆果子放在后面来合并,优先合并数量较少的堆,因为每堆果子合并后如果这不是最后一堆后面仍需要继续合并,花费两堆之和的代价;如果让数量多的堆放在前面合并,那么后面每次合并都会带上这堆果子的数量,花费体力一定会比放在后面合并要多

直到本题的思路是贪心之后就很容易了,创建一个小根堆,每次都取出最小的两对果子合并,然后将结果再放回堆中,重复操作直到只剩一堆即可

总体代码:

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

int main()
{
	int n;
	int result = 0;
	priority_queue<int, vector<int>, greater<int>> q;//小根堆
	scanf("%d",&n);
	for(int i = 1;i <= n;i++)
	{
		int a;
		scanf("%d",&a);
		q.push(a);
	}
	for(int i = 1;i <= n;i++)
	{
		int a = q.top();
		q.pop();
		if(q.empty())//如果只剩下一个元素那么就代表合并完毕
        //当然这里很明显知道n堆果子需要n-1次合并,控制n-1次循环也可以
		{
			break;
		}
		int b = q.top();
		q.pop();
		int c = a+b;//合并需要的花费
		result += c;//答案更新
		q.push(c);//将新的一堆果子放入堆中
	}
	printf("%d",result);
	return 0;
}

题目来源:中位数

背景:给出一个序列,对于所有的前奇数项求出中位数

思路:中位数,即序列中升序排名正好为中间的数,对于中位数我们可以知道中位数的左边都是小于中位数的元素,中位数的右边都是大于中位数的元素,且左右两边元素数量相等;

根据这个特性,我们可以将其分成两个序列:

  • 分别为包含中位数的左序列和不包含中位数的右序列;
  • 同时保证左序列长度为右序列长度+1,还要保证右序列的最小值要大于等于左序列的最大值

根据两边的性质,我们发现左边需要知道最大值,右边需要知道最小值,正好可以用一个大根堆和小根堆来维护:

  • 每次左边有比右边大的元素时就将元素放入右边,右边有比左边小的元素就放入左边,此时满足了两个序列的单调性
  • 最后如果左边长度不为右边长度+1就将左边或右边的堆顶元素放到对面即可,左边的堆顶即为所求的中位数

看起来要操作很多次,实际因为每次插入的元素只有两个,所以调整次数<=2,时间复杂度仍为nlogn

总体代码:

#include<iostream>
#include<queue>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;

priority_queue<int,vector<int>,greater<int>> q1;//小根堆
priority_queue<int> q2;//大根堆
int arr[100010];
int main()
{
	int n;
	scanf("%d",&n);
	for(int i = 1;i <= n;i++)
	{
		scanf("%d",&arr[i]);
	}
	cout << arr[1] << "\n";
	if(n <= 2)
	{
		return 0;
	}
	q2.push(arr[1]);//先把第一个元素放入左边
	int i = 2;
	while(i < n)//要判断拿出一个i位置的元素后还可以继续拿一个元素
	{
		q2.push(arr[i++]);
		q2.push(arr[i++]);//取出两个元素放入左边
		while(q2.size() > i/2)//判断左边序列长度是否超出i/2,第一步调整
		{
			q1.push(q2.top());
			q2.pop();
		}
		while(q2.top() > q1.top())//左边元素大于右边元素也要放到对面,第二步调整
		{
			q1.push(q2.top());
			q2.pop();
		}
		while(q2.size() < i/2)//若调整的元素太多导致当前长度小于i/2,继续调整
		{
			q2.push(q1.top());
			q1.pop();
		}
		cout << q2.top() << "\n";//大根堆的堆顶即为中位数
	}
	return 0;
}

题目来源:Cow Coupons G

背景:现有M资金,有N头牛,每头牛的原价为Pi,优惠价为Ci(Ci <= Pi),现拥有K张优惠券,求当前资金最多可以买多少头牛

思路:第一遍思路很容易想到贪心:因为优惠价一定比原价便宜,所以按照优惠价排序将可以购买的全部购买,剩下的钱再将没买过的按原价排序,每次买最小的;然后就很自然的WA了

首先贪心的思路优惠价一定比原价便宜没有错,但是按照优惠价排序先购买优惠价便宜的这一条是错的

假设现在有10元钱一张优惠券,奶牛A原价为4,优惠价为3,奶牛B原价为100,优惠价为6;如果按照贪心的思想用优惠券先买了奶牛A,那么只能购买一头牛,如果用优惠券买B,原价购买A那么恰好可以买两头牛

那是不是优惠券优先给Pi-Ci最大也就是优惠力度最大的呢

这样的话只考虑了优惠后的价格但没有考虑我们的财力问题,1000的原价100的优惠价优惠力度为900和10原价9优惠价优惠力度为1的肯定是要买后面的

  • 我们发现无论我们如何贪心总会有一个问题:本次到底该不该使用优惠券

如果用动态规划考虑每头牛是否购买->购买是否以及能否使用优惠券来枚举状态的话时间复杂度可能要达到O(n*k)肯定是过不了的

所以这里我们仍然考虑贪心,不过这里的贪心我们要带上反悔策略

  • 首先,在能够使用优惠券的情况下肯定是先使用优惠券来购买最便宜的牛,此方法最多可以购买k只牛
  • 在没有优惠券的时候我们只能从原价最便宜的牛开始购买,但在购买当前牛的时候我们可以尝试在前面使用的优惠券中取消一张给当前使用
  • 考虑退掉前面一张造成的金额损失:由于已经花了Ci用优惠价购买了牛,现在不使用优惠券,需要按原价购买,也就是还需要补上原价和优惠价的差价Pi-Ci

当什么时候我们才可以选择退掉一张优惠券来给当前使用呢?

很明显,就是当补上的差价Pi-Ci小于当前牛的原价和优惠价的差价的时候

所以我们这里有了最终的贪心策略:

  • 在k之前全部使用优惠券,k之后按原价升序的顺序开始选择,花费当前的优惠价并补上之前的一个最小的差价之和如果小于当前的原价那我们就可以采用该策略,否则我们只能按原价购买

这里我们要维护三个优先队列:

  • 一个用来维护优惠价的小根堆
  • 一个用来维护原价的小根堆
  • 一个用来维护已使用优惠券购买的赚取的差价的小根堆

同时还要使用一个bool数组来表示某头牛是否被买过,前两个堆中出现已经购买过的直接弹出即可

总体代码:(可以多模拟几次来理解)

#include<iostream>
#include<queue>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;

typedef struct fav{
	int c,id;
	bool operator < (const fav& a) const
	{
		return c > a.c;
	}
}fav;
typedef struct val{
	int p,id;
	bool operator < (const val& a) const
	{
		return p > a.p;
	}
}val;
struct node{
	int p,c,id;
}arr[50010];
bool vis[50010];
priority_queue<fav> q1;//存储优惠价的小根堆
priority_queue<val> q2;//存储原价的小根堆
priority_queue<int,vector<int>,greater<int>> q3;//存储已有差价的小根堆
//记得每个结构体中要记录牛的id,否则无法找到该牛
int main()
{
	long long n,k,m;
	scanf("%lld %lld %lld",&n,&k,&m);
	for(int i = 1;i <= n;i++)
	{
		scanf("%d %d",&arr[i].p,&arr[i].c);
		arr[i].id = i;
		q1.push((fav){arr[i].c,i});
		q2.push((val){arr[i].p,i});
	}
	long long sum = 0,ans = 0;
	for(int i = 1;i <= k;i++)//前k个无脑选优惠价即可,注意如果超出m直接结束
	{
		sum += q1.top().c;
		vis[q1.top().id] = true;
		q3.push(arr[q1.top().id].p - arr[q1.top().id].c);//每使用一次优惠价就记录一次差价
		q1.pop();
		if(sum > m)
		{
			cout << ans;
			return 0;
		}
		ans++;
	}
	while(!q1.empty())
	{
		while(!q1.empty() && vis[q1.top().id])//该牛已被买,弹出,下同
		{
			q1.pop();
		}
		if(q1.empty())
		{
			break;
		}
		while(!q2.empty() && vis[q2.top().id])
		{
			q2.pop();
		}
		fav a = q1.top();
		val b = q2.top();//这里不需要考虑q2为空是因为q1、q2其实所存的牛的id都是相同的
		if(b.p < a.c + q3.top())//如果原价小于优惠价+补差价,直接按原价来
		{
			sum += b.p;
			if(sum > m)
			{
				cout << ans;
				return 0;
			}
			ans++;
			q2.pop();
			vis[b.id] = true;
		}
		else//否则就补上差价,取消一张优惠券给当前使用
		{
			sum += a.c+q3.top();
			if(sum > m)
			{
				cout << ans;
				return 0;
			}
			ans++;
			q1.pop();
			q3.pop();//将旧的差价弹出
			vis[a.id] = true;
			q3.push(arr[a.id].p-arr[a.id].c);将新的差价放入
		}
	}
	cout << ans;
	return 0;
}

ST表

  • 经典的用于解决RMQ(区间最值)问题的数据结构
  • 预处理:将给定区间分成2的幂次个区间,先求出这些区间的最值,随后运用倍增+动态规划的思想从小区间到大区间依次求解区间最值,因为每次都是倍增处理,所以st表预处理的效率为O(nlogn)
int lg[100010];
int st[100010][20];
void fun()//预处理,这里以最大值为例
{
	int n,m;
	scanf("%d %d",&n,&m);
	for(int i = 1;i <= n;i++)
	{
		scanf("%d",&st[i][0]);
	}
	lg[1] = 0;
	for(int i = 2;i <= n;i++)//预处理lg(i)的对数,向下取整
	{
		lg[i] = lg[i>>1] + 1;
	}
	for(int j = 1;j <= lg[n];j++)
	{
        //因为是倍增处理,所以长度为2^j的区间有n-(2^j)+1个
        //st[i][j]表示i位置后面2^j长度的区间中的最值,类似于动态规划
		for(int i = 1;i <= n-(1<<j)+1;i++)
		{
            //预处理和查询的思想相同,都是分解成两个相交的区间求最值
            //每次都是2的整数幂次,所以i到2^j可以看作是[i,2^(j-1)]和[i+2^(j-1),2^j]两个区间
            //也就是从i开始移动2^(j-1)长度和从[i+2^(j-1)]移动2^j-1长度
			st[i][j] = max(st[i][j-1],st[i+(1<<(j-1))][j-1]);
		}
	}
}
  • 查询:进行查询时将所查询的区间分为两部分,由于区间长度(l-r+1)不一定为2的整数幂,所以将区间分为两部分:令k为log(区间长度)向下取整,那么区间可以分为[l,l+2^k-1][r-2^k+1,r],即l向后延伸2^k、r向前延伸2^k个长度的两个区间,由于区间长度一定是大于2^k且小于2^(k+1)的,所以这两个区间一定是有交集的,但交集对所求的区间最值不会产生影响,因此只需找出这两个区间的最值即可
void fun()//查询
{
    int x,y;
	scanf("%d %d",&x,&y);
	int k = lg[y-x+1];
	cout << max(st[x][k],st[y-(1<<k)+1][k]) << "\n";
}

题目来源:忠诚

背景:给出一个长度为n的序列,k次询问每次输出询问区间的最小值

思路:裸的st表题,只需要维护区间最小值即可,明白了st表就会这题

直接上代码:

#include<iostream>
#include<queue>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;

int lg[100010];
int st[100010][20];

int main()
{
	int n,m;
	scanf("%d %d",&n,&m);
	for(int i = 1;i <= n;i++)
	{
		scanf("%d",&st[i][0]);
	}
	lg[1] = 0;
	for(int i = 2;i <= n;i++)
	{
		lg[i] = lg[i>>1] + 1;
	}
	for(int j = 1;j <= lg[n];j++)
	{
		for(int i = 1;i <= n-(1<<j)+1;i++)
		{
			st[i][j] = min(st[i][j-1],st[i+(1<<(j-1))][j-1]);
		}
	}
	for(int i = 1;i <= m;i++)
	{
		int x,y;
		scanf("%d %d",&x,&y);
		int k = lg[y-x+1];
		cout << min(st[x][k],st[y-(1<<k)+1][k]) << " ";
	}
	return 0;
}

题目来源:最大数

背景:在一个起始位空的数列上有n次操作,每次操作为以下两种之一:查询末尾L个数的最大值;将最后一次查询的答案(初始为0)加上K再对一个数D取模,插入到数列最后

思路:每次查询操作只需要求最后L个数的最大值,可以直接用st表求出来,但后面还有插入操作,也就是说每次插入都需要重新更新st表中的值

如果每次更新都重建表的话肯定会超时,我们考虑再末尾插入一个元素有什么影响

  • 我们知道st[i][j]的值是区间[i,i+2^j-1]的最值,插入一个元素在末尾的m位置之后只会影响到区间右端点为m的区间,也就是i*2^j-1 == m
  • 我们可以枚举所有的j来使所有右端点为m的区间都得到更新,也就是所有的st[m-2^j+1][j]
void insert(long long x)
{
    cnt++;
    st[cnt][0] = x;
    for (int j = 1; (1 << j) <= cnt; j++)
    {
        int i = cnt - (1<<j) + 1;//对于i和j不懂的再看一遍st表预处理操作
        st[i][j] = max(st[i][j-1],st[i+ (1<<(j-1))][j-1]);
    }
}

这样每次只需log(m)次更新即可

查询操作还是普通的查询即可,每次只需要查询后L个元素中的最大值即可

long long check(int x)
{
	int k = lg[x];
	return max(st[cnt-x+1][k],st[cnt-(1<<k)+1][k]);//cnt为当前长度
}

最后就是输入输出操作了,放上总体代码:

#include<iostream>
#include<queue>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;

int lg[200010];
int st[200010][20];
int cnt = 0;
void insert(long long x)
{
    cnt++;
    st[cnt][0] = x;
    for (int j = 1; (1 << j) <= cnt; j++)
    {
        int i = cnt - (1<<j) + 1;
        st[i][j] = max(st[i][j-1],st[i+ (1<<(j-1))][j-1]);
    }
}
long long check(int x)
{
	int k = lg[x];
	return max(st[cnt-x+1][k],st[cnt-(1<<k)+1][k]);
}
int main()
{
	int m,d;
	scanf("%d %d",&m,&d);
	long long t = 0;
	lg[1] = 0;
	for(int i = 2;i <= m;i++)
	{
		lg[i] = lg[i>>1] + 1;
	}
	for(int i = 1; i <= m; i++)
	{
		char op;
		int x;
        cin >> op >> x;
        if (op == 'Q')
		{
			t = check(x);
            cout << t  << "\n;
        }
        else
		{
            insert((x + t)%d);
        }
    }
    return 0;
}

树状数组

  • 支持单点/区间修改,区间查询的数据结构

这里直接用题来代入树状数组的思想

题目来源:【模板】树状数组1

如题,已知一个数列,你需要进行下面两种操作:

  • 将某一个数加上x

  • 求出某区间每一个数的和

先说一下树状数组维护区间和的原理:

将整个区间[1,n]拆分成不超过log(n)个区间,使得这些区间的结果是已知的

以区间[1,2,3,4,5,6,7,8]为例,将区间拆分成[1,2,3,4]和[5,6,7,8],再将区间拆分成[1,2],[3,4],[5,6],[7,8],最后将他们拆分成1,2,3,4,5,6,7,8八个区间,这八个区间长度都为1,区间和是已知的,然后返回合并求解,1,2已知,则[1,2]可求,其他长度为2的区间也可求,同理[1,2],[3,4]已知,则[1,2,3,4]可求,最终可以求出整个数组的区间和,求出的区间是这样的一个情况:

[1, 2, 3, 4 ,  5, 6, 7, 8]

[1, 2, 3, 4]  [5, 6, 7, 8]

[1, 2][3, 4] [5, 6][7, 8]

[1][2][3][4][5][6][7][8]

可以看出这类似于一颗二叉树

  • 在这些区间的基础上我们可以挑出一些肯定不会用到的区间:

        比如知道[1,2]和[1]可以求出[2],那么[2]就不需要了        

        知道[1,2,3,4]和[1,2]可以求出[3,4],同理[3,4]也是不需要的

最后删除不需要的区间后剩余区间如下:

[1, 2, 3, 4, 5, 6, 7, 8]

[1, 2, 3, 4]

[1, 2]        [5, 6]

[1]    [3]    [5]    [7]    

可以利用剩下的这些区间来求1~8中任意区间的和,此时剩下的区间个数正好为原数据个数

  • 此时我们将这八个区间的值按照中序的顺序——也就是拆分到最后一层后依次返回得出结果的顺序放入数组中
  • 最后整个数组就是{ [1],[1, 2],[3],[1, 2, 3, 4],[5],[5, 6],[7],[1, 2, 3, 4, 5, 6, 7, 8] }

这里再来介绍一下树状数组的一个很重要的函数:lowbit()

  • lowbit函数目的是要求出一个数的二进制最低位1代表哪个十进制数字

根据二进制存储原理,假设一个正数x的二进制为xxxx1000,那么-x的二进制则为(xxxx1000)全部按位取反并且+1,也就是-x的二进制表示为yyyy0111+1 = yyyy1000;要得出x最后一个1所在位置代表的十进制数只需要 x & -x == xxxx1000 & yyyy1000;因为xxxx和yyyy每一位都是相反的,所以最后的结果就是1000 == 8,也就是我们所求的lowbit的值

int lowbit(int x)
{
	return x & -x;
}

例如:3 :11最后一个1代表的是1,lowbit(3)就是1;6:110最后一个1代表的是2,则lowbit(6)就是2

这个函数有什么用呢?回到上面的存放区间的数组,我们发现长度为1的区间所在的数组下标的lowbit值都为1,长度为2的区间所在的数组下标lowbit值都为2......依次类推

  • 也就是说下标为i的数据所代表的是一个区间长度为lowbit(i)的和

接下来介绍lowbit函数的运用:区间查询与区间更新

区间查询:我们知道一个区间一定是连续的,查询一个区间可以由几个小区间拼接而来,以查询区间[1,7]为例,按照上面已经求出的区间值,我们可以使用[7]+[5,6]+[1,2,3,4]来完成,那么如何快速找到这些区间呢?

这时候就用上了我们的lowbit函数

  • 求x之前所有值的和只需要每次将x向低位进位,即x之前的区间作为下标查询即可,即x = x-lowbit(x)将查询的路径相加即为答案
long long query(int x)//查询前x个数之和
{
	long long ans = 0;
	for(;x;x -= lowbit(x))
	{
		ans += tree[x];
	}
	return ans;
}

该操作是求出区间[1,x]的和,要求出区间[x,y]的和只需这样:

long long check(int x,int y)
{
	return query(y) - query(x-1);
}

所以查询的复杂度为log(n)

接下来是单点更新操作:

  • 由于更新一个点会改变后面所有区间的总值,所以每次只需从当前位置向高位进位,即x = x+lowbit(x),每次都将该区间值更新
void updata(int x,int k)//给第x个数加上k
{
	for(;x <= n;x += lowbit(x))
	{
		tree[x] += k;
	}
}

这里再说一下树状数组的构造:

  • 初始数组为空的情况下每次加入一个数据就相当于进行了一次单点更新,n个数据更新n次即可,所以构造的时间为nlogn
void build()
{
	int n;
	scnaf("%d",&n);
	for(int i = 1;i <= n;i++)
	{
		long long num;
		scanf("%lld",&num);
		updata(i,num);
	}
}

至此,树状数组的所有操作已经全部完成,本题代码:

#include<iostream>
#include<queue>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;

int n,m;
long long tree[500010];
int lowbit(int x)
{
	return x & -x;
}
long long query(int x)
{
	long long ans = 0;
	for(;x;x -= lowbit(x))
	{
		ans += tree[x];
	}
	return ans;
}
long long check(int x,int y)
{
	return query(y) - query(x-1);
}
void updata(int x,int k)
{
	for(;x <= n;x += lowbit(x))
	{
		tree[x] += k;
	}
}
int main()
{
	scanf("%d %d",&n,&m);
	for(int i = 1;i <= n;i++)
	{
		long long a;
		scanf("%lld",&a);
		updata(i,a);
	}
	for(int i = 1;i <= m;i++)
	{
		int p,x,y;
 		scanf("%d %d %d",&p,&x,&y);
 		if(p == 1)
  		{
  			updata(x,y);
  		}
  		if(p == 2)
  		{
  			cout << check(x,y) << "\n";
  		}
	}
	return 0;
}

题目来源:【模板】树状数组2

上面提到了树状数组的单点修改,这里来介绍一下区间修改操作

在介绍区间操作前要先介绍一个和前缀和可以互逆的思想:差分

差分数组的表示为:[A1,A2-A1,A3-A2......]

  • 如果对差分数组求前缀和,那么该前缀和数组的每个值都为原数组值

前缀和数组的表示为:[A1,A1+A2,A1+A2+A3......]

  • 如果对前缀和数据求差分,那么该差分数组的每个值也都为原数组值

如果考虑将一个区间[x,y]整体加上k,实际上就是将差分数组的x位置加上k,y的位置减去k

如数组[1,1,1,1,1,1,1]将区间[2,5]全部加1得到新的数组[1,2,2,2,2,1,1]

那么原数组的差分数组[1,0,0,0,0,0,0]就变为了[1,1,0,0,0,-1,0]

根据前缀和和差分的可逆运算可知:

  • 单点查询第三个元素的值只需要求差分数组前三个数的前缀和
  • 要使差分数组支持查询前缀和、区间修改操作,那么就可以将差分数组构造成树状数组

总体代码:

#include<iostream>
#include<queue>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;

int n,m;
long long arr[500010];
long long tree[500010];
int lowbit(int x)
{
	return x & -x;
}
long long query(int x)
{
	long long ans = 0;
	for(;x;x -= lowbit(x))
	{
		ans += tree[x];
	}
	return ans;
}
void updata(int x,int k)
{
	for(;x <= n;x += lowbit(x))
	{
		tree[x] += k;
	}
}
int main()
{
	scanf("%d %d",&n,&m);
	for(int i = 1;i <= n;i++)
	{
		//这里可以省去原数组,改为两个变量a和la
		//scanf("%lld",&a);
		//updata(i,a-la);
		//la = a;
		scanf("%lld",&arr[i]);
		updata(i,arr[i]-arr[i-1]);
	}
	for(int i = 1;i <= m;i++)
	{
		int op,x,y;
 		scanf("%d",&op);
 		if(op == 1)
  		{
  			int x,y,k;
  			scanf("%d %d %d",&x,&y,&k);
  			updata(x,k);
  			updata(y+1,-k);
  		}
  		if(op == 2)
  		{
  			int x;
  			scanf("%d",&x);
  			cout << query(x) << "\n";
  		}
	}
	return 0;
}

题目来源:计数问题

背景:有一个n*m个格子的矩阵,初始每个格子都有一个特定的权值,现有k次操作,每次会选择以下两种操作之一:改变一个格子的权值;求一个子矩阵中特定权值的格子个数

思路:二维前缀和知识大家应该都了解了(不了解的去了解一下,很简单),简单的树状数组可以维持一维前缀和进行区间查询,那么二维树状数组也是可以维护二维前缀和区间查询的,也就是所谓的树状数组套树状数组

对于一维来讲,每次进行单点更新后我们要考虑这个点会影响哪些区间(对于前缀和来讲肯定是后面的区间),也就是updata函数中x+lowbit(x)向上进位的过程,而对于二维也一样:

  • 把一维的区间改变成了二维的矩阵,在x轴上有着x+lowbit(x)的过程,同样的在y轴上也有着y+lowbit(y)的过程,每次更新当前x长度下高为所有可能的y的区间,所以二维树状数组更新函数如下:
void updata(int x,int y,int k)//在x,y位置上加上k
{
	for(int i = x;i <= n;i += lowbit(i))//枚举所有含x的区间
	{
		for(int j = y;j <= m;j += lowbit(j))//枚举所有含y的区间
		{
			tree[i][j] += k;
		}
	}
}

不难看出更新的效率为log(n)*log(m);

  • 区间查询也同样,[1,1]到[x,y]同样是根据一维的查询转换到二维,不过要注意二维的前缀和要减去左下和右上两个二维前缀和的值,同时要加上减去的两个矩阵中重叠的部分(算了两次)的值:
long long query(int x,int y,int color)
{
	long long res = 0;
	for(int i = x;i > 0;i -= lowbit(i))
	{
		for(int j = y;j > 0;j -= lowbit(j))
		{
			res += tree[i][j][color];
		}
	}
	return res;
}
long long check(int x1,int x2,int y1,int y2,int color)
{
	return query(x1-1,y1-1,color) + query(x2,y2,color) - query(x1-1,y2,color) - query(x2,y1-1,color);
}

注意本题中还有一个权值因素,所以数组要多开一维来记录权值,每次单点更新之后涉及的区间中原权值数量减一,更新后的权值数量加一

总体代码:

#include<iostream>
#include<queue>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;

int n,m;
int arr[310][310];
int tree[310][310][110];

int lowbit(int x)
{
	return x & -x;
}
void updata(int x,int y,int color,int k)
{
	for(int i = x;i <= n;i += lowbit(i))
	{
		for(int j = y;j <= m;j += lowbit(j))
		{
			tree[i][j][color] += k;
		}
	}
}
long long query(int x,int y,int color)
{
	long long res = 0;
	for(int i = x;i > 0;i -= lowbit(i))
	{
		for(int j = y;j > 0;j -= lowbit(j))
		{
			res += tree[i][j][color];
		}
	}
	return res;
}
long long check(int x1,int x2,int y1,int y2,int color)//注意二维的前缀和查询
{
	return query(x1-1,y1-1,color) + query(x2,y2,color) - query(x1-1,y2,color) - query(x2,y1-1,color);
}
int main()
{
	scanf("%d %d",&n,&m);
	for(int i = 1;i <= n;i++)
	{
		for(int j = 1;j <= m;j++)
		{
			int c;
			scanf("%d",&c);
			arr[i][j] = c;
			updata(i,j,arr[i][j],1);//每次更新都将涉及到的区间中该权值数量加一
		}
	}
	int q;
	scanf("%d",&q);
	for(int i = 1;i <= q;i++)
	{
		int op;
		scanf("%d",&op);
		if(op == 1)
		{
			int x,y,c;
			scanf("%d %d %d",&x,&y,&c);
			updata(x,y,arr[x][y],-1);//原权值数量减一
			arr[x][y] = c;
			updata(x,y,arr[x][y],1);//现权值数量加一
		}
		else
		{
			int x1,x2,y1,y2,c;
			scanf("%d %d %d %d %d",&x1,&x2,&y1,&y2,&c);
			cout << check(x1,x2,y1,y2,c) << "\n";
		}
	}
}

题目来源:Promotion Counting P

背景:简化题目:求树上每个结点与其所有孩子结点的逆序对数量

思路:之前讲过用归并排序求线性序列的逆序对,这里要涉及到树上的逆序对,用归并排序不好控制树状的数据结构(每次单独求解时间太长),所有这里用树状数组来解决

首先树状数组是可以求逆序对的,主要思想在于离散化+单点更新+区间查询

  • 原数组离散化后的数据依次放入数组中,数组数组初始化为0,每次插入一个数据就更新所有包含该数据的区间+1
  • 然后查找大于该元素的个数(即整个区间的值减去[1,a[i]]区间的值),所有个数之和即为逆序对数

这里先放一下求逆序对的代码:

#include<iostream>
#include<queue>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;

int n;
int a[500010];
int rk[500010];
int tree[500010];
int lowbit(int x)
{
	return x&-x;
}
void updata(int x,int y)
{
	for(int i = x;i <= n;i += lowbit(i))
	{
		tree[i] += y;
	}
}
long long check(int x)
{
	long long res = 0;
	for(int i = x;i > 0;i -= lowbit(i))
	{
		res += tree[i];
	}
	return res;
}
int main()
{
	scanf("%d",&n);
	long long ans = 0;
	for(int i = 1;i <= n;i++)
	{
		scanf("%d",&rk[i]);
		a[i] = rk[i];
	}
	sort(rk+1,rk+1+n);
	for(int i = 1;i <= n;i++)
	{
		a[i] = lower_bound(rk+1,rk+1+n,a[i])-rk;//离散化 
	}
	for(int i = 1;i <= n;i++)
	{
		updata(a[i],1);
		ans += check(n)-check(a[i]);//答案累加
	}
	cout << ans;
	return 0;
}

线性的求逆序对的方法只有在树完全退化成一条链之和才合适,当树不是链的时候就需要考虑树的左孩子和右孩子这些

我们考虑一下如何得到一个结点的左孩子和右孩子的全部路径,很明显可以使用dfs来走,当从当前结点进行dfs,再次返回该结点的时候就代表已经走过了他的左孩子/右孩子只有的全部路径,我们可以在这里记录答案

  • 求出整棵树的dfs序,开始进入某个结点的时候记录下当前所遍历过的所有结点中比当前结点大的数量,然后从该节点继续遍历
  • 当最后一次返回当前结点的时候(即当前结点不能向任何方向延伸)再一次记录所有遍历过的比当前结点大的数量
  • 两次记录结果的差即为当前结点与其孩子产生的逆序对数量

dfs函数如下:

void dfs(int now)
{
	int before = check(n) - check(a[now]);//记录之前比该结点大的数
	for(int i = head[now];i;i = edge[i].next)
	{
		dfs(edge[i].to);
	}
    //运行到此即代表该节点完成了所有的使命,不能再进行任何遍历
	ans[now] += check(n) - check(a[now]) - before;//当前比该结点大的数减去之前的记录
	updata(a[now],1);//将自身放入区间中
}

最后只需要注意输入的数据代表谁是谁的父节点连边建图就好了

总体代码:

#include<iostream>
#include<queue>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;

int n,cnt = 0;
int a[100010];//离散化后的数组 
int arr[100010];//树状数组 
int ans[100010];//记录答案 
int head[100010];
int rk[100010];//暂时接收输入数据进行离散化 
struct node{
	int next,to;
}edge[100010];
void ADD(int u,int v)
{
	cnt++;
	edge[cnt] = (node){head[u],v};
	head[u] = cnt;
}
int lowbit(int x)
{
	return x&-x;
}
void updata(int x,int y)
{
	for(int i = x;i <= n;i += lowbit(i))
	{
		arr[i] += y;
	}
}
int check(int x)
{
	int res = 0;
	for(int i = x;i > 0;i -= lowbit(i))
	{
		res += arr[i];
	}
	return res;
}
void dfs(int now)
{
	int before = check(n) - check(a[now]);
	for(int i = head[now];i;i = edge[i].next)
	{
		dfs(edge[i].to);
	}
	ans[now] += check(n) - check(a[now]) - before;
	updata(a[now],1);
}
int main()
{
	scanf("%d",&n);
	for(int i = 1;i <= n;i++)
	{
		scanf("%d",&rk[i]);
		a[i] = rk[i];
	}
	sort(rk+1,rk+1+n);
	for(int i = 1;i <= n;i++)
	{
		a[i] = lower_bound(rk+1,rk+1+n,a[i])-rk;//代表第一个不小于a[i]的元素的位置 
	}
	for(int i = 2;i <= n;i++)
	{
		int x;
		scanf("%d",&x);
		ADD(x,i);//从父节点向孩子节点建边
	}
	dfs(1);
	for(int i = 1;i <= n;i++)
	{
		cout << ans[i] << "\n";
	}
	return 0;
}

  • 21
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
以下是图的邻接矩阵常规操作的介绍: 1. 创建邻接矩阵 创建邻接矩阵需要两个数组,一个一维数组存储图中顶点信息,一个二维数组存储图中的边或弧的信息。其中,一维数组的每个元素表示一个顶点,二维数组的每个元素表示一条边或弧,如果该元素的值为1,则表示该边或弧存在,否则不存在。 2. 添加顶点 添加顶点时,需要在一维数组中添加一个元素,并在二维数组中添加一行和一列,分别表示该顶点与其他顶点之间的边或弧的关系。 3. 添加边或弧 添加边或弧时,只需要在二维数组中相应的位置上将元素的值改为1即可。 4. 删除顶点 删除顶点时,需要将一维数组中对应的元素删除,并将二维数组中对应的行和列删除。 5. 删除边或弧 删除边或弧时,只需要在二维数组中相应的位置上将元素的值改为0即可。 6. 遍历图 图的遍历有两种方式:深度优先遍历和广度优先遍历。其中,深度优先遍历需要借助栈结构,广度优先遍历需要借助队列结构。 深度优先遍历的代码示例: ```c++ void MGraph::DFTraverse(int v) { cout<<vertex[v]<<" "; visited[v]=1; for(int j=0;j<vertexNum;j++) { if(edge[v][j]==1 && visited[j]==0) { DFTraverse(j); } } } ``` 广度优先遍历的代码示例: ```c++ void MGraph::BFTraverse(int v) { queue<int> q; cout<<vertex[v]<<" "; visited[v]=1; q.push(v); while(!q.empty()) { int u=q.front(); q.pop(); for(int j=0;j<vertexNum;j++) { if(edge[u][j]==1 && visited[j]==0) { cout<<vertex[j]<<" "; visited[j]=1; q.push(j); } } } } ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值