数据结构

离散化

说明

离散化:
离散化就是把无限空间中有限的个体映射到有限的空间中去,以此提高算法的时间和空间效率。
通俗来说,离散化就是在不改变数据相对大小的条件下,对数据进行相应的缩小。
例如:
原数据:1,999,100000,15
处理后:1,3,4,2
作用:
1、离散化是程序设计中一个常用的技巧,它可以有效的降低时间和空间的复杂度
2、其基本思想就是在众多可能的情况中,只考虑需要用的值。
3、离散化可以改进一个低效的算法,甚至实现根本不可能实现的算法。
4、要掌握这个思想,必须从大量的题目中理解此方法的特点。

实现

结构体实现

以前讲过,不再赘述

#include <bits/stdc++.h>
using namespace std;

const int maxn = 100; 

//input:
//5
//9 1 0 5 4
int n = 0;
struct node
{
	int x, id;
}a[maxn];
int b[maxn] = {};

bool cmp(node &nd1, node &nd2)
{
	return nd1.x < nd2.x;
}

int main()
{
	scanf("%d", &n);
	for(int i=1; i<=n; i++)
	{
		scanf("%d", &a[i].x);
		a[i].id = i;
	}
	sort(a+1, a+1+n, cmp);
	for(int i=1; i<=n; i++) b[a[i].id] = i;
	for(int i=1; i<=n; i++) printf("%d ", b[i]);
}

使用STL实现-方法一

c++标准库中的三个函数
lower_bound: 查找首个大于等于给定值的元素,返回其指针(内存地址)
upper_bound: 查找首个大于给定值的元素,返回其指针(内存地址)
unique:对某个区间的数据进行去重,返回去重的结果序列的最后一个元素的下一个指针(内存地址)
步骤:
1、把数据排好序(sort)
2、把数据去重(unique)
3、索引元素离散化对应的值(lower_bound)

#include <bits/stdc++.h>
using namespace std;

const int maxn = 100; 

//input:
//6
//9 1 1 0 5 4
int n = 0;
int a[maxn] = {}, b[maxn] = {}; 

int main()
{
	scanf("%d", &n);
	for(int i=1; i<=n; i++) 
	{
		scanf("%d", &a[i]);  	
		b[i] = a[i];
	}
	
	sort(a+1, a+1+n);
	int cnt = unique(a+1, a+1+n) - (a+1);
	for(int i=1; i<=n; i++)
	{
		b[i] = lower_bound(a+1, a+1+cnt, b[i]) - a;
	}
	for(int i=1; i<=n; i++) printf("%d ", b[i]);
} 

使用STL实现-方法二

map简介:
map是有序键值对容器,它的元素的键是唯一的。搜索、移除和插入操作拥有对数复杂度。map通常实现为红黑树
设想如下场景:现在需要存储一些键值对,例如存储学生姓名对应的分数:Tom 0,Bob 100,Alan 100.
但是由于数组下标只能为非负整数,所以无法用姓名作为下标来存储,这个时候最简单的办法就是使用STL中的map
ps:可以想象成数组,只不过数组的下标只能为非负整数,但mp的下标可以是任意类型
map的访问:
map重载了operator[],可以用任意定义了operator <的类型作为下标(在map中叫做key,也就是索引):

map<Key, T> yourMap;

其中,k是键的类型,T是值的类型,下面是使用map的实例:

map<string, int> mp;

map中不会存在键相同的元素,multimap中允许多个元素拥有同一键。multimap的使用方法与map的使用方法基本相同。
注意:正式因为multimap允许多个元素拥有同一键的特点,multimap并没有提供给出键访问其对应值的方法。
插入与删除操作:
1、可以直接通过下标访问来进行查询或插入操作,例如mp["Alan"]=100;
2、通过向map中插入一个类型为pair<Key,T>的值可以达到插入元素的目的,例如mp.insert(pair<string, int>("Alan", 100));
3、erase(Key)函数会删除键为key的所有元素,返回值为删除元素的数量
4、erase(first, lase)删除迭代器在[first, last) 范围内的所有元素
5、clear()函数会清空整个容器。
注意:
在利用下标访问map中的某个元素时,如果map中不存在相应键的元素,会自动在map中插入一个新元素,并将其值设置为默认值(对于整数,值为零;对于有默认构造函数的类型,会调用默认构造函数进行初始化)
当下标访问操作过于频繁时,容器中会出现大量无意义元素,影响map的效率,因此,一般情况下推荐使用find()函数来寻找特定键的元素。
查询操作:
1、count(x):返回容器内键为x的元素数量。复杂度为O(log(size)+ans)(关于容器大小对数复杂度,加上匹配个数)
2、find(x):若容器内存在键为x的元素,会返回该元素的迭代器,否则返回end().
3、lower_bound(x):返回指向首个不小于给定键的元素的迭代器
4、upper_bound(x):返回指向首个大于给定键的元素的迭代器。若容器内所有元素均小于或等于给定键,返回end()
5、empty():返回容器是否为空
6、size():返回容器内元素个数
使用样例:
在搜素中,我们有时需要存储一些较为复杂的状态(如坐标、无法离散化的数值,字符串等)以及与之有关的答案(如到达此状态的最小步数)。
map可以用来实现此功能。其中的键是状态,而值是与之相关的答案。
下面的示例展示了如何使用map存储以string表示的状态。

// 存储状态与对应的答案
map<string, int> record;

// 新搜索到的状态与对应答案
string status;
int ans;
// 查找对应的状态是否出现过
map<string, int>::iterator it = record.find(status);
if (it == record.end()) 
{
  // 尚未搜索过该状态,将其加入状态记录中
  record[status] = ans;
  // 进行相应操作……
} 
else 
{
  // 已经搜索过该状态,进行相应操作……
}

遍历容器:
在c++11中,可以使用for循环遍历

map<int, int> mp;
for(auto x : mp)
{
	printf("%d %d\n", x.first, x.second);
}

使用map进行离散化:

#include <bits/stdc++.h>
using namespace std;

const int maxn = 100; 

//input:
//5
//9 1 0 5 4
int n = 0;
map<int, int> mp;
int cnt = 0;
int a[maxn] = {}, b[maxn] = {};

int main()
{ 
	scanf("%d", &n);
	for(int i=1; i<=n; i++) 
	{
		scanf("%d", &a[i]);  
		b[i] = a[i];  
	}
	
	sort(a+1, a+1+n);
	for(int i=1; i<=n; i++) mp[a[i]] = ++cnt;
	
	for(int i=1; i<=n; i++) printf("%d ", mp[b[i]]);
} 

并查集

并查集是一种树型的数据结构,用于处理一些点所在集合的合并及查询问题。
常常在使用中以森林来表示。

朴素并查集

算法步骤

1、初始化:把每个点所在集合初始化为其自身
2、查找:查找元素所在的集合,即根节点
3、合并:将两个元素所在集合合并为一个集合。合并之前,应先判断两个元素是否属于同一集合,这可用上面的“查找”操作实现
4、路径压缩

算法图解

1、初始化:并查集通常用数组来实现,比如有0,1,2,3,4,五个元素,先令father[i] = i,即所有点的父亲节点为其自身
image
2、合并:将0/1合并为一类,2/3/4合并为一类,那么可以这样:
father[1] = 0
father[3] = 2,father[4] = 2
image
3、如果要把这两类合并为一类呢,可以直接设置father[2] = 0,即将一类的根结点指向另一类的根结点
image
4、查找:要想查找某两个点是否处于同一类,只需要递归找到这两个点的根结点
根结点相同:同一类
根结点不同:不是同一类
5、路径压缩
在并查集中,合并元素常常会使树的深度越来越大,而我们的查询操作是采用递归,层层向上查。
如果元素的深度太大,那么查询效率会很低,因此我们需要优化元素的排布。
路径压缩实际上就是在找完根节点之后,在递归回来的时候顺便把路径上元素的父亲指针都指向根节点
image
上图在递归找20的根节点时,顺便把在递归路径上的父亲节点均直接连到根节点上。
image
上图为查找节点4的根结点时进行的路径压缩

例题:亲戚

#include <bits/stdc++.h>
using namespace std;

const int inf = 0x3f3f3f3f;
const int maxn = 20010;

int n = 0, m = 0, q = 0;
int fa[maxn] = {};

//查找祖先节点
int getfa(int x)
{
	if(x == fa[x]) return fa[x];
	return fa[x] = getfa(fa[x]);
}

//将y合并到x
void merge(int x, int y)
{
	int fx = getfa(x);
	int fy = getfa(y);
	if(fx != fy) fa[fy] = fx;
}

int main()
{ 
	int x = 0, y = 0;
	scanf("%d%d", &n, &m);
	//初始化
	for(int i=1; i<=n; i++) fa[i] = i;
	for(int i=1; i<=m; i++)
	{
		scanf("%d%d", &x, &y);
		merge(x, y);
	}
	scanf("%d", &q);
	for(int i=1; i<=q; i++)
	{
		scanf("%d%d", &x, &y);
		int fx = getfa(x);
		int fy = getfa(y);
		if(fx == fy) printf("Yes\n");
		else printf("No\n");
	}

	return 0;
}

额外维护项目的并查集

维护元素数量

维护各连通集合的元素数量,元素数量另开辟一个数组存储,并存储到根结点上。
代码示例:

const int maxn = 1010;
int n = 0;
//fa[]存储每个点的父亲节点, size[]只有祖宗节点的有意义,表示祖宗节点所在集合中的点的数量
int fa[maxn] = {}, size[maxn] = {};

//返回x的祖宗节点
int findset(int x)
{
	if(fa[x] != x) fa[x] = findset(fa[x]);
	return fa[x];
}

//合并a和b所在的两个集合:
void merge(int x, int y)
{
	int fx = findset(x), fy = findset(y);
	if(fx != fy)
	{
		fa[fy] = fx;
		size[fx] += size[fy];	
	}
}

//初始化,假定节点编号是1~n
for(int i=1; i<=n; i++) 
{
	fa[i] = i;
	size[i] = 1;	
}

维护到祖宗节点距离

维护各节点到根节点的距离,距离另开辟一个数组存储,且存储到对应节点上
代码示例:

const int maxn = 200010;
int n = 0;
//fa[]存储每个点的父亲节点, d[x]存储x到祖宗节点的距离
int fa[maxn], d[maxn];

//返回x的祖宗节点
int findset(int x)
{
	if(fa[x] != x) 
	{
		//这里需要先查询,因为在查询的过程中,会更新d[fa[x]]
		int y = findset(fa[x]);
		//这里要累加,针对的是后合并进来的,非根节点
		d[x] += d[fa[x]];
		fa[x] = y;
	}
	return fa[x];
}

//将y所在集合合并到x集合
void merge(int x, int y)
{
	int fx = findset(x), fy = findset(y);
	if(fx != fy)
	{
		fa[fy] = fx;
		//计算到根结点距离时,相当于从x点接上的,所以是d[x]+1
		d[fy] = d[x] + 1;
	}
}

// 初始化,假定节点编号是1~n
for(int i=1; i<=n; i++) fa[i] = i;

启发式合并(选修)

理想状况下,经过路径压缩操作,整个并查集应该为一个深度为2的树。
但是路径压缩操作只在查询操作时进行,并且也只会压缩单条路线,不会优化整个并查集。
image
如上图:
如果我们要合并这两个树,如果将6合并到1,会得到下图左边的结果,深度为3
如果将1合并到6,会得到下图右边的结果,深度为4
由此可见,如何合并两个树,对结果的深度有影响。
image
为了查询效率,我们当然要让树的深度越小越好,即矮胖型。

按秩(约等于深度)

合并两个集合时,让“矮树”挂到“高树”的根上(按秩合并)
秩: 表示树的高度上限(初始时每个节点的秩为1)

按节点数

合并两个集合时,让“小树”挂到“大树”上(按大小合并)
大小: 表示集合的元素个数(初始时每个集合的大小为1)

带权并查集

1、带权并查集即节点存有权值信息的并查集
2、当两个元素之间的关系可以量化,并且关系可以合并时,可以使用带权并查集来维护元素之间的关系
3、带权并查集每个元素的权通常描述其与并查集中祖先的关系,这种关系如何合并,路径压缩时就如何压缩
4、带权并查集可以推算集合内点的关系,而一般并查集只能判断属于某个集合

例题 食物链

1、用动物之间的“相对”关系来确定一个并查集

  • 0:这个结点与它的父节点是同类
  • 1:这个结点被它的父节点吃
  • 2:这个结点吃它的父节点
    注意,这个0,1,2所代表的意义不是随便制定的,题目中要求,说话的时候第一个数字(下文中,设为d)
    指定了后面两种动物的关系:
  • 1:x与y同类
  • 2:x吃y
    当d=1时,(d-1)=0,代表y和x是同类,也就是我们制定的意义
    当d=2时,(d-1)=1,代表y被x吃,也是我们制定的意义
    2、路径压缩,以及节点间关系确定
    确定了权值之后,我们要确定有关的操作。我们把所有的动物全初始化
    relation[i]=0,f[i]=i;
    路径压缩时的节点算法,路径压缩的关键是把路径上的节点都指向根节点后,那相对关系也发生了变化,那如何修改节点和根节点的关系呢
    通过穷举:
    image
    通过穷举我们可以发现,当前节点与祖父节点的关系可以得出如下公式
    \(relation[now]=(relation[now]+relation[f[now]]) % 3\)
    这样可以看到,( 儿子relation + 父亲relation ) % 3 = 儿子对爷爷的relation,所以有
    \(relation[now]=(relation[now]+relation[f[now]])\% 3\),这就是路径压缩的节点算法
    3、集合间关系的确定
    当x,y不在一个集合时就需要合并操作,合并时除了直接把x,y的根root(x)和root(y)并起来外,还需要重新计算root(x)和root(y)之间的关系,如果是把root(y)挂在root(x)上,那么可以推出公式
    \(relation[root(y)]=(3-relation[y]+(d-1)+relation[x]) \% 3;\)
    这个公式,是分三部分,这么推出来的:
    (1) (d-1) :这是X和Y之间的relation(前面倒过),X是Y的父节点时,Y的relation就是这个
    (2) 3 - relation[y], 根据Y与根节点的关系,逆推根节点与Y的关系(最终想确定r(y)与r(x)的关系
    穷举
  • 0(与根节点同类)( 3 - 0 ) % 3 = 0
  • 1(被根节点吃) ( 3 - 1 ) % 3 = 2 //吃根节点
  • 2(吃根节点) ( 3 - 2 ) % 3 = 1 //被根节点吃
    image
    4、判断
    先处理特殊情况:
  1. 当x>n或y>n时,为假话
  2. 当d=2而x=y时,为假话
  • 首先,如何判断1 X Y是不是假话。//此时 d = 1
if ( X 和 Y 不在同一集合) Union(x,y,xroot,yroot,d)   
else if x.relation != y.relation ->假话
  • 其次,如何判断2 X Y是不是假话 //此时d = 2
if ( X 和 Y 不在同一集合)Union(x,y,xroot,yroot,d)
else (relation [y]+ 3 - relation[x] ) % 3 != 1 ->假话

这个公式是这么来的:
3-relation[x]得到了根节点关于x的relation(前面公式(儿+父)%3为儿和爷爷被吃关系
3-relation[x]为根到x的关系,因为路径压缩,已经把relation记为跟根的关系,relation[y]+3-relation[x]相当于y看做儿,根看做父,x看做祖父,找y和x的被吃关系。看看跟输入的x吃y矛盾吗)
relation [y]+ 3 - relation[x]得到了y关于x的relation,所以,只要y关于x的relation不是1,就是y不被x吃的话,这句话肯定是假话!
综合(1) 和(2),无论d=1或2,只要满足 ((relation[y]-relation[x]+3) % 3)<>(d-1) 即为假话,3要加上,不然可能出现负数。

种类并查集(扩展域)

种类并查集是并查集的一个变种,它通过扩大原有的并查集空间来维护多种不同的关系。

例题 团伙

1、并查集,1 ~ n表示朋友关系,n+1 ~ 2n表示敌人关系
2、(x, y)如果是朋友,则将y合并到x的集合
(x, y)如果是敌人,合并反集到朋友集合

#include <bits/stdc++.h>
using namespace std;

/*
并查集+补集
1~n表示朋友关系,n+1~2n表示敌人关系
*/

const int maxn = 1010;

int n = 0, m = 0;
int fa[maxn*2] = {};

int findset(int x)
{
	if(fa[x] != x) fa[x] = findset(fa[x]);
	return fa[x];
}

void merge(int x, int y)
{
	int fx = findset(x);
	int fy = findset(y);
	if(fx != fy) fa[fy] = fx;
}

int main()
{
	int p = 0, x = 0, y = 0;
	
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n*2; i++) fa[i] = i;
	for(int i=1; i<=m; i++)
	{
		scanf("%d%d%d", &p, &x, &y);
		if(p == 0) merge(x, y);	//朋友,合并到x的集合
		else 	//敌人,合并反集到朋友集合
		{
			merge(x, y + n);
			merge(y, x + n);
		}
	}
	
	int ans = 0;
	for(int i=1; i<=n; i++) 
	{
		if(fa[i] == i) ans++;
	}
	printf("%d", ans);

	return 0;
}  

树状数组

单点修改,区间查询

例题:提高题库 232.数列操作

#include <bits/stdc++.h>
using namespace std;

const int maxn = 100010;
int n = 0, m = 0;
int c[maxn] = {};

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

void add(int x, int val)
{
	while(x <= n)
	{
		c[x] += val;
		x += lowbit(x);
	}
}

int getsum(int x)
{
	int res = 0;
	while(x)
	{
		res += c[x];
		x -= lowbit(x);
	}
	return res;
}

int main()
{ 
	char op[5];
	int a = 0, b = 0;
	scanf("%d", &n);
	for(int i=1; i<=n; i++)
	{
		scanf("%d", &a);
		add(i, a);
	}
	scanf("%d", &m);
	for(int i=1; i<=m; i++)
	{
		scanf("%s%d%d", op, &a, &b);
		if(op[0] == 'S')	//区间查询
		{
			printf("%d\n", getsum(b) - getsum(a-1));
		}
		else	//单点修改
		{
			add(a, b);
		}
	}

    return 0;
}

区间修改,单点查询

例题:提高题库 210 数列操作b

#include <bits/stdc++.h>
using namespace std;

const int maxn = 100010;
int n = 0, m = 0;
int a[maxn] = {}, c[maxn] = {};

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

void add(int x, int val)
{
	while(x <= n)
	{
		c[x] += val;
		x += lowbit(x);
	}
}

int getsum(int x)
{
	int res = 0;
	while(x)
	{
		res += c[x];
		x -= lowbit(x);
	}
	return res;
}

int main()
{ 
	char op[5];
	int x = 0, y = 0, z = 0;
	scanf("%d", &n);
	for(int i=1; i<=n; i++)
	{
		scanf("%d", &a[i]); 
	}
	scanf("%d", &m);
	for(int i=1; i<=m; i++)
	{
		scanf("%s", op);
		if(op[0] == 'Q')	//单点查询
		{
			scanf("%d", &x);
			printf("%d\n", getsum(x) + a[x]);
		}
		else	//区间修改
		{
			scanf("%d%d%d", &x, &y, &z);
			add(x, z);
			add(y+1, -z);
		}
	}

    return 0;
}

区间修改,区间查询

例题:提高题库 235 数列操作c

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int maxn = 100010;
int n = 0, m = 0;
int a[maxn] = {};
ll c1[maxn] = {}, c2[maxn] = {};

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

void add(int x, int val)
{
	ll val2 = 1ll * val * x;
	while(x <= n)
	{
		c1[x] += val;
		c2[x] += val2;
		x += lowbit(x);
	}
}

ll getsum1(int x)
{
	ll res = 0;
	while(x)
	{
		res += c1[x];
		x -= lowbit(x);
	}
	return res;
}

ll getsum2(int x)
{
	ll res = 0;
	while(x)
	{
		res += c2[x];
		x -= lowbit(x);
	}
	return res;
}

ll getsum(int l, int r)
{
	ll res1 = getsum1(r) * (r + 1) - getsum1(l-1) * l;
	ll res2 = getsum2(r) - getsum2(l-1);
	return res1 - res2;
}

int main()
{ 
	char op[5];
	int x = 0, y = 0, z = 0;
	scanf("%d", &n);
	for(int i=1; i<=n; i++) scanf("%d", &a[i]);  
	for(int i=n; i>=2; i--) a[i] = a[i] - a[i-1];
	for(int i=1; i<=n; i++) add(i, a[i]);
	
	scanf("%d", &m);
	for(int i=1; i<=m; i++)
	{
		scanf("%s", op);
		if(op[0] == 'A')	//区间修改
		{
			scanf("%d%d%d", &x, &y, &z);
			add(x, z);
			add(y+1, -z);
			
		}
		else	//区间查询
		{
			scanf("%d%d", &x, &y);
			printf("%lld\n", getsum(x, y));
		}
	}

    return 0;
}

求逆序对

树状数组开到值域的大小,将原数组按数值倒序存到树状数组中,
每存一个数值,给对应位置+1,这样当存的值为\(x\)时,
\(sum(x-1)\)\(x-1\)的前缀和即为\(x\)对应的逆序对的个数
注意:如果值域较大,需要离散化
例题:求逆序对个数
https://h.hszxoj.com/d/gyktg/p/HZTG109?tid=66b3378e4a2d69bf4ecdd081

#include <bits/stdc++.h> 
using namespace std;

/*
树状数组求逆序对
树状数组开到值域的大小,将原数组按数值倒序存到树状数组中,
每存一个数值,给对应位置+1,这样当存的值为x时,
sum(x-1)即x-1的前缀和即为x对应的逆序对的个数
注意:如果值域较大,需要离散化
*/

typedef long long ll;
const int maxn = 3000010;

int n = 0;
//a表示原序列,b表示离散化后的序列,c为树状数组
int a[maxn] = {}, b[maxn] = {}, c[maxn] = {};

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

void add(int x, int val)
{
	while(x <= n)
	{
		c[x] += val;
		x += lowbit(x);
	}
}

ll getsum(int x)
{
	ll res = 0;
	while(x)
	{
		res += c[x];
		x -= lowbit(x);
	}
	return res;
}

int main()
{
	scanf("%d", &n);
	for(int i=1; i<=n; i++) 
	{
		scanf("%d", &a[i]);
		b[i] = a[i];
	}
	
	sort(a+1, a+1+n);
	int cnt = unique(a+1, a+1+n) - (a+1);
	for(int i=1; i<=n; i++)
	{
		b[i] = lower_bound(a+1, a+1+cnt, b[i]) - a;
	}
	
	ll ans = 0;
	for(int i=n; i>=1; i--)
	{
		ans += getsum(b[i] - 1);
		add(b[i], 1);
	}
	
	printf("%lld", ans);
} 

二维树状数组

单点修改,区间查询

例题:提高题库 201.移动电话

#include <bits/stdc++.h>
using namespace std;
  
//二维树状数组板子题
const int maxn = 1050;
  
int n = 0;
int a[maxn][maxn] = {}, c[maxn][maxn] = {};

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

//单点加
//修改(x, y)这个点的值
void add(int x, int y, int val)
{
	for(int i=x; i<=n; i+=lowbit(i))
	{
		for(int j=y; j<=n; j+=lowbit(j))
		{
			c[i][j] += val;	
		}	
	}	
} 

//求(0, 0)到(x, y)之间矩阵的和
int getsum(int x, int y)
{
	int res = 0;
	for(int i=x; i>0; i-=lowbit(i))
	{
		for(int j=y; j>0; j-=lowbit(j))
		{
			res += c[i][j];
		}
	}
	return res;
}

//求子矩阵和,子矩阵x满足l<=x<=r,子矩阵y满足b<=y<=t
int sum(int l, int b, int r, int t)
{
	int res = 0;
	res = getsum(r, t) - getsum(l-1, t) - getsum(r, b-1) + getsum(l-1, b-1);
	return res;
}
  
int main()
{
	int op = 0;
	int x = 0, y = 0, val = 0;
	int l = 0, b = 0, r = 0, t = 0;
	scanf("%d%d", &op, &n);
	while(1)
	{
		scanf("%d", &op);
		if(op == 3) break;
		else if(op == 1)
		{
			scanf("%d%d%d", &x, &y, &val);
			add(x+1, y+1, val);
		}
		else if(op == 2)
		{
			scanf("%d%d%d%d", &l, &b, &r, &t);
			int res = sum(l+1, b+1, r+1, t+1);
			printf("%d\n", res);
		}
	}
	
	return 0;
}

线段树

线段树基础

基本原理

1、线段树是竞赛中常用来维护区间信息的数据结构
2、主要操作:
单点修改,区间查询
区间修改,单点查询
区间修改,区间查询
3、原理:
①线段树是一颗完全二叉树,用来维护一段区间的信息,每个节点管某一段的信息
②线段树的每一个节点要记录该节点的左右端点值,区间和、区间最大值等信息,所以要用结构体数组。

struct node
{
	int l, r, sum;	//l和r分别表示该节点维护区间的左、右边界,sum表示这一段区间的和
}tree[maxn << 2];

③由于使用完全二叉树的结构,因此对于节点\(k\),表示的区间为\([l, r]\),那么:
左儿子编号:\(k << 1\),表示的区间为 \([l, (l+r)>>1]\)
右儿子编号:\(k << 1 | 1\),表示的区间为 \([(l+r>>1)+1, r]\)
因为要频繁用到找左右儿子节点,我们常常把求左右儿子编号写成预编译命令:

#define lson (rt<<1)
#define rson (rt<<1|1)

4、线段树的节点个数一般开4倍的区间长度,oi-wiki上有证明
5、线段树常见操作
建线段树(build)
更新操作(update)
查询操作(query)
向上回溯(pushup)
向下延迟更新(pushdown)
6、解决问题场景
①单点更新
更新数组元素的值时,我们首先递归到对应的叶子节点,修改其数据域的信息,再在回溯时逐个更新父节点的信息
图片1
②查询区间和-区间查询
数组[1,5,4,1,6],查询sum(3,5),箭头标示递归查询的方向,在回溯的时候返回查询结果,并汇总
图片2
③向上回溯
该函数就是由子结点递归回来,修改父结点中的信息。
由于一般建树和更新都有这个相同的操作,因此我们可以写成一个函数,简化代码量

void pushup(int rt)
{
	tree[rt].sum = tree[lson].sum + tree[rson].sum;
}

④更新操作-区间修改
当需要更新某一区间内的元素值的时候,我们可以拆分成多个单点更新的形式,但是肯定会导致算法时间复杂度的增加。
如果只更新到区间完全覆盖的节点表示的区间时,对后面的更新和查询会造成计算的遗留,如图所示
图片3
⑤延迟标记

  • 做区间更新时,如果要更新的区间能够完全覆盖当前节点表示的区间,则在此节点上做个标记(表示此节点曾被修改,但子节点尚未被更新),不再继续向下更新,同时在回溯时更新父节点的信息。
  • 如果在之后的维护或查询过程中需要对这个节点的某个儿子递归地进行处理,则将这个标记分解,传递给它的两个儿子节点。
  • 这种在需要的时候才进行分解的做法,使我们整体的时间复杂度仍在\(O(\log_2 N)\)的水平上。
    结构体的定义:
struct node
{
	int l, r, sum;
	int lazy;	//延迟标记
}tree[maxn << 2];

演示示例:
图片4
图片5
图片6
图片7
图片8

代码示例

单点修改,区间查询

例题:数列操作a

#include <bits/stdc++.h>
using namespace std;

//预编译命令,做符号代换
#define lson (rt << 1)
#define rson (rt << 1 | 1)
const int maxn = 1e5 + 10;

int n = 0, m = 0;
struct node
{
	int l, r, sum;
}tree[maxn << 2];
int a[maxn] = {};

void pushup(int rt)
{
	tree[rt].sum = tree[lson].sum + tree[rson].sum;
}

//建树
void Build(int rt, int l, int r)
{
	tree[rt].l = l;
	tree[rt].r = r;	//节点信息初始化
	if(l == r)	//到叶节点
	{
		tree[rt].sum = a[l];
		return;
	}
	
	int mid = (l + r) >> 1;
	Build(lson, l, mid);
	Build(rson, mid+1, r);
	
	//子树建好后,回溯时更新父节点信息
	pushup(rt);
}

void Update(int rt, int pos, int val)
{
	if(tree[rt].l == tree[rt].r)	//找到对应的叶子节点
	{
		tree[rt].sum += val;
		return;
	}
	
	int mid = (tree[rt].l + tree[rt].r) >> 1;
	if(pos <= mid) Update(lson, pos, val);
	else Update(rson, pos, val);
	
	pushup(rt);
}

//当前节点为rt,要查询的区间是[l, r]
int Query(int rt, int l, int r)
{
	//如果节点表示的区间是查询区间的真子集
	if(l<=tree[rt].l && tree[rt].r<=r)
	{
		return tree[rt].sum;
	}
	
	int mid = (tree[rt].l + tree[rt].r) >> 1;
	if(r <= mid) return Query(lson, l, r);
	else if(l > mid) return Query(rson, l, r);
	else return Query(lson, l, r) + Query(rson, l, r);
}

int main()
{ 
	char op[5];
	int x = 0, y = 0;
	scanf("%d", &n);
	if(n == 0) return 0;
	for(int i=1; i<=n; i++) scanf("%d", &a[i]);
	
	Build(1, 1, n);
	
	scanf("%d", &m);
	for(int i=1; i<=m; i++)
	{
		scanf("%s%d%d", op, &x, &y);
		if(op[0] == 'S')
		{
			printf("%d\n", Query(1, x, y));
		}	
		else
		{
			Update(1, x, y);
		}	
	}
 
	return 0;
} 

区间修改,单点查询

例题:数列操作b

#include <bits/stdc++.h>
using namespace std;

//预编译命令,做符号代换
#define lson (rt << 1)
#define rson (rt << 1 | 1)
const int maxn = 1e5 + 10;

int n = 0, m = 0;
struct node
{
	int l, r, sum;
	int lazy;	//延迟标记
}tree[maxn << 2];
int a[maxn] = {};

void pushup(int rt)
{
	tree[rt].sum = tree[lson].sum + tree[rson].sum;
}

//把当前节点rt的延迟标记下放到左右儿子
void pushdown(int rt)
{
	if(tree[rt].lazy)	//此节点有延迟标记
	{
		int lz = tree[rt].lazy;
		tree[rt].lazy = 0;	//记住要清零
		tree[lson].lazy += lz;
		tree[rson].lazy += lz;
		tree[lson].sum += lz * (tree[lson].r - tree[lson].l + 1);
		tree[rson].sum += lz * (tree[rson].r - tree[rson].l + 1);
	}
}

//建树
void Build(int rt, int l, int r)
{
	tree[rt].l = l;
	tree[rt].r = r;	//节点信息初始化
	if(l == r)	//到叶节点
	{
		tree[rt].sum = a[l];
		return;
	}
	
	int mid = (l + r) >> 1;
	Build(lson, l, mid);
	Build(rson, mid+1, r);
	
	//子树建好后,回溯时更新父节点信息
	pushup(rt);
}

void Update(int rt, int l, int r, int val)
{
	//更新区间完全覆盖节点表示的区间
	if(l<=tree[rt].l && tree[rt].r<=r)	
	{
		tree[rt].lazy += val;
		tree[rt].sum += val * (tree[rt].r - tree[rt].l + 1);
		return;
	}
	
	//如果不能完全覆盖,此时需要向下递归,要下放标记
	pushdown(rt);
	int mid = (tree[rt].l + tree[rt].r) >> 1;
	if(l <= mid) Update(lson, l, r, val);
	if(r > mid) Update(rson, l, r, val);
	
	pushup(rt);
}

//当前节点为rt,要查询的点是pos
int Query(int rt, int pos)
{
	//左右边界相等,说明找到了该pos点
	if(tree[rt].l == tree[rt].r)
	{
		return tree[rt].sum;
	}
	
	//如果不能完全覆盖,此时需要向下递归,要下放标记
	pushdown(rt);
	int mid = (tree[rt].l + tree[rt].r) >> 1;
	if(pos <= mid) return Query(lson, pos);
	if(pos > mid) return Query(rson, pos);
	return 0;
}

int main()
{ 
	char op[10];
	int x = 0, y = 0, z = 0;
	scanf("%d", &n);
	if(n == 0) return 0;
	for(int i=1; i<=n; i++) scanf("%d", &a[i]);
	
	Build(1, 1, n);
	
	scanf("%d", &m);
	for(int i=1; i<=m; i++)
	{
		scanf("%s", op);
		if(op[0] == 'Q')
		{
			scanf("%d", &x);
			printf("%d\n", Query(1, x));
		}	
		else
		{
			scanf("%d%d%d", &x, &y, &z);
			Update(1, x, y, z);
		}	
	}
 
	return 0;
} 

区间修改,区间查询

例题:数列操作c

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
//预编译命令,做符号代换
#define lson (rt << 1)
#define rson (rt << 1 | 1)
const int maxn = 1e5 + 10;

int n = 0, m = 0;
struct node
{
	int l, r;
	ll sum;
	int lazy;	//延迟标记
}tree[maxn << 2];
int a[maxn] = {};

void pushup(int rt)
{
	tree[rt].sum = tree[lson].sum + tree[rson].sum;
}

//把当前节点rt的延迟标记下放到左右儿子
void pushdown(int rt)
{
	if(tree[rt].lazy)	//此节点有延迟标记
	{
		int lz = tree[rt].lazy;
		tree[rt].lazy = 0;	//记住要清零
		tree[lson].lazy += lz;
		tree[rson].lazy += lz;
		tree[lson].sum += lz * (tree[lson].r - tree[lson].l + 1);
		tree[rson].sum += lz * (tree[rson].r - tree[rson].l + 1);
	}
}

//建树
void Build(int rt, int l, int r)
{
	tree[rt].l = l;
	tree[rt].r = r;	//节点信息初始化
	if(l == r)	//到叶节点
	{
		tree[rt].sum = a[l];
		return;
	}
	
	int mid = (l + r) >> 1;
	Build(lson, l, mid);
	Build(rson, mid+1, r);
	
	//子树建好后,回溯时更新父节点信息
	pushup(rt);
}

void Update(int rt, int l, int r, int val)
{
	//更新区间完全覆盖节点表示的区间
	if(l<=tree[rt].l && tree[rt].r<=r)	
	{
		tree[rt].lazy += val;
		tree[rt].sum += val * (tree[rt].r - tree[rt].l + 1);
		return;
	}
	
	//如果不能完全覆盖,此时需要向下递归,要下放标记
	pushdown(rt);
	int mid = (tree[rt].l + tree[rt].r) >> 1;
	if(l <= mid) Update(lson, l, r, val);
	if(r > mid) Update(rson, l, r, val);
	
	pushup(rt);
}

//当前节点为rt,要查询的区间是[l, r]
long long Query(int rt, int l, int r)
{
	//如果节点表示的区间是查询区间的真子集
	if(l<=tree[rt].l && tree[rt].r<=r)
	{
		return tree[rt].sum;
	}
	
	//如果不能完全覆盖,此时需要向下递归,要下放标记
	pushdown(rt);
	int mid = (tree[rt].l + tree[rt].r) >> 1;
	if(r <= mid) return Query(lson, l, r);
	else if(l > mid) return Query(rson, l, r);
	else return Query(lson, l, r) + Query(rson, l, r);
}

int main()
{ 
	char op[10];
	int x = 0, y = 0, z = 0;
	scanf("%d", &n);
	if(n == 0) return 0;
	for(int i=1; i<=n; i++) scanf("%d", &a[i]);
	
	Build(1, 1, n);
	
	scanf("%d", &m);
	for(int i=1; i<=m; i++)
	{
		scanf("%s", op);
		if(op[0] == 'S')
		{
			scanf("%d%d", &x, &y);
			printf("%lld\n", Query(1, x, y));
		}	
		else
		{
			scanf("%d%d%d", &x, &y, &z);
			Update(1, x, y, z);
		}	
	}
 
	return 0;
} 

线段树动态开点

理论

1、动态开点核心思想: 结点只有在有需要的时候才被创建
2、动态开点原理:

  • 上面讲到,普通线段树需要开\(4n\)大小的数组,为节省空间,我们可以不一次性建好树,而是在更新操作(update)时,再建立结点。
  • 这时不再用\(2p\)\(2p+1\)代表\(p\)的左右儿子,而是用\(ls\)\(rs\)记录儿子的编号
  • 比起普通线段树,动态开点线段树有一个优势:他能够处理零或负数的位置。此时,求\(mid\)时不能用\((l+r)/2\),而要用\((l+r-1)/2\)\((l+r)>>1\)(例如处理区间\([-1, 0]\)时,自己可以试一下),\(pushdown\)因此也要相应改一下。

3、动态开点空间计算:
单次操作的时间复杂度是不变的,为\(O(logn)\)。由于每次操作都有可能创建并访问全新的一系列节点,因此m次单点操作后节点的数量规模是\(O(mlogn)\),节点的数组开到\(mlogn\)即可

权值线段树

1、权值线段树的理解:
权值线段树就是用线段树维护一个桶,他可以以\(O(logv)\)\(v\)为值域)查询某个范围内数出现的总次数。
还可以\(O(logv)\)地求的第\(k\)大的数。
它常常可以代替平衡树使用
2、权值线段树需要按值域开空间,当值域过大时需要离散化或动态开点
3、权值线段树举例:
假如值域为[1, 8],我们现有一个长度为10的数组{1,5,2,3,4,1,3,4,4,4}
1 出现了 2 次,2 出现了 1 次,3 出现了 2 次,4 出现了 4 次,5 出现了 1 次。
则这个线段树长这样:
不动态开点:
image

每个叶子节点的值:代表这个值出现的次数
非叶子节点的值:代表了某一个值域内,所有值出现次数的和
上图中并非所有的点都需要,可以动态开点来实现

动态开点:
image

4、权值线段树的用途:
求某个数\(x\)的排名
求排名为\(x\)的数值
求某个数的前趋
求某个数的后继
求数列中低\(k大/小\)的数

例题:普通平衡树

1、插入/删除\(x\)
动态开点、给\(pos\)这个值的数量增加\(val\)个,若\(val\)为负数,则为减少\(val\)

void update(int &rt, int l, int r, int pos, int val)

2、查询\(x\)数的排名
先找到所有\([-1e7,x-1]\)之间的数的数量,那么\(x\)的排名就是该数量+1
3、查询排名为\(x\)的数
这里是指从小到大第\(k\)个数,重复数字的排名不一样,会按顺序往下排
因为是从小到大排,所以比较左子树,小于左子树的数量,就在左子树中找,否则,往右子树中找

int queryk(int rt, int l, int r, int k)

4、求\(x\)的前趋
\(x\)的前趋指小于\(x\)的数中最大的那个,先求出\([-1e7,x-1]\)之间的数的数量\(y\),那么排名为\(y\)的数就是\(x\)的前趋
5、求\(x\)的后继
\(x\)的后继指大于\(x\)的数中最小的那个,先求出\([-1e7,x]\)之间的数的数量\(y\),那么排名为\(y+1\)的数就是\(x\)的后继

#include <bits/stdc++.h>
using namespace std;
 
/*
动态开点权值线段树
*/ 

#define lson tree[rt].ls
#define rson tree[rt].rs
 
const int maxn = 1e5 + 10;  

int n = 0;
//建立一棵权值线段树
//底层叶子节点的数量即为数字的值域[-1e7,1e7]
//树的高度为log(2e7)≈25
//所以线段树点数理论为maxn*25,保险起见开30*maxn
struct node
{
	int ls, rs, cnt, num;
}tree[maxn*30];
int root = 0, segtot = 0;
    
//动态开点
//给pos这个值的数量增加val个,若val为负数,则为减少val个
void update(int &rt, int l, int r, int pos, int val)
{
	if(!rt) rt = ++segtot;
	if(l == r)
	{
		tree[rt].num = pos;
		tree[rt].cnt += val; 
		return;
	}
	
	int mid = (l + r) >> 1;
	if(pos <= mid) update(lson, l, mid, pos, val);
	else update(rson, mid+1, r, pos, val);
	tree[rt].cnt = tree[lson].cnt + tree[rson].cnt;
}
    
//查询区间[L,R]数字的个数
//这里的区间[L,R]指的是值域,所以该函数会把值域[L,R]之间所有数返回,包括重复的数字
int query(int rt, int l, int r, int L, int R)
{
	if(!rt) return 0;
	if(L <= l && r <= R) return tree[rt].cnt;
	
	int ans = 0;
	int mid = (l + r) >> 1;
	if(L <= mid) ans += query(lson, l, mid, L, R);
	if(mid < R) ans += query(rson, mid + 1, r, L, R);
	return ans;
}
    
//查询第k小的元素(排名为k的数)
//这里是指从小到大第k个数,重复数字的排名不一样,会按顺序往下排
int queryk(int rt, int l, int r, int k)
{
	if(!rt) return -0x3f3f3f3f;
	if(l == r) return tree[rt].num;
	int mid = (l + r) >> 1;
	//因为是从小到大排,所以比较左子树,小于左子树的数量,就在左子树中找
	if(k <= tree[lson].cnt)	
	{
		return queryk(lson, l, mid, k);
	}
	else 
	{
		return queryk(rson, mid+1, r, k - tree[lson].cnt);
	}
}
    
int main() 
{    
	int opt = 0, x = 0, y = 0;
	scanf("%d", &n);
	
	while(n--)
	{
		scanf("%d%d", &opt, &x);
		
		if(opt == 1)	//插入一个x数
		{ 
			update(root, -1e7, 1e7, x, 1);
		}
		else if(opt == 2)	//删除一个x数
		{ 
			update(root, -1e7, 1e7, x, -1);
		}
		else if(opt == 3)	//查询x数的排名
		{ 
			//先找到所有[-1e7,x-1]之间的数的数量
			//那么x的排名就是该数量+1
			y = query(root, -1e7, 1e7, -1e7, x - 1);
			printf("%d\n", y + 1); 
		}
		else if(opt == 4)	//查询排名为x的数
		{
			y = queryk(root, -1e7, 1e7, x);
			printf("%d\n", y); 
		}
		else if(opt == 5)	//求x的前趋
		{ 
			//x的前趋指小于x的数中最大的那个
			//先求出[-1e7,x-1]之间的数的数量y
			//那么排名为y的数就是x的前趋
			y = query(root, -1e7, 1e7, -1e7, x - 1);
			y = queryk(root, -1e7, 1e7, y); 
			printf("%d\n", y); 
		}
		else if(opt == 6) 	//求x的后继
		{
			//x的后继指大于x的数中最小的那个
			//先求出[-1e7,x]之间的数的数量y
			//那么排名为y+1的数就是x的后继
			int t = 0; 
			y = query(root, -1e7, 1e7, -1e7, x);
			t = queryk(root, -1e7, 1e7, y + 1);
			printf("%d\n", t); 
		}
	}
 
    return 0;
}  

线段树合并

理论

1、概念: 线段树合并是指建立一棵新的线段树,这棵线段树的每个节点都是两颗原线段树对应节点合并后的结果。
2、线段树合并常被用于维护树上或是图上的信息
3、我们不可能真的每次建满一棵新的线段树,因此需要使用动态开点线段树
ps:线段树合并不会产生新的节点,做题时计算节点数量时要注意
4、合并过程:
线段树合并的过程本质上相当暴力
假设两颗线段树为A和B,我们从1号节点开始递归合并
递归到某个节点时,如果A树或B树上的对应节点为空,直接返回另一个树上对应节点,这里运用了动态开点线段树的特性
如果递归到叶子节点,我们合并两棵树上的对应节点
最后,根据子节点更新当前节点并返回。
5、时间复杂度:
对于两颗满的线段树,合并操作的复杂度是\(O(nlogn)\)的。
实际情况下,使用的常常是权值线段树,总点数和\(n\)的规模相差并不大。
并且合并时一般不会重复地合并某个线段树,所以最终增加的点数大致是\(O(nlogn)\)级别的。
这样,总的复杂度就是\(O(nlogn)\)级别的。

例题:Promotion Counting

#include <bits/stdc++.h>
using namespace std;
 
#define lson tree[rt].ls
#define rson tree[rt].rs

const int maxn = 1e5 + 10; 

int n = 0, len = 0;	//n为原始长度,len为离散化后的长度
int a[maxn] = {}, b[maxn] = {};
//邻接链表存图
int h[maxn] = {}, to[maxn] = {}, nxt[maxn] = {}, tot = 0;
//线段树变量
int root[maxn] = {}, segtot = 0;
//为树的每个节点建立一棵权值线段树,每个权值线段树只标记一个值
//离散化后,值的范围为maxn,所以每插入一个值,需要开log(maxn)≈17个点
//线段树合并不会产生新节点,所以tree开20*maxn个节点就够了
struct node
{
	int ls, rs, cnt;
}tree[20*maxn];
//存储答案
int ans[maxn] = {};

void addedge(int x, int y)
{
	to[++tot] = y;
	nxt[tot] = h[x];
	h[x] = tot;
}

//权值线段树加点
void update(int &rt, int l, int r, int pos, int val)
{
	if(!rt) rt = ++segtot;
	if(l == r)
	{
		tree[rt].cnt += val;
		return;
	}
	int mid = (l + r) >> 1;
	if(pos <= mid) update(lson, l, mid, pos, val);
	else update(rson, mid+1, r, pos, val);
	tree[rt].cnt = tree[lson].cnt + tree[rson].cnt;
}

//线段树合并
int segmerge(int ra, int rb)
{
	if(!ra) return rb;
	if(!rb) return ra;
	
	tree[ra].cnt += tree[rb].cnt;
	tree[ra].ls = segmerge(tree[ra].ls, tree[rb].ls);
	tree[ra].rs = segmerge(tree[ra].rs, tree[rb].rs);
	return ra;
}

//查询以rt为根节点的权值线段树中值>=val的个数
int query(int rt, int l, int r, int val)
{
	if(!rt) return 0;
	if(l == r) return tree[rt].cnt;
	
	int mid = (l + r) >> 1;
	if(val <= mid) return query(lson, l, mid, val) + tree[rson].cnt;
	return query(rson, mid+1, r, val);
}

//遍历整棵树,并不断将子树合并到父节点
void dfs(int x)
{
	for(int i=h[x]; i; i=nxt[i])
	{
		int y = to[i];
		dfs(y);
		root[x] = segmerge(root[x], root[y]);
	}
	ans[x] = query(root[x], 1, len, a[x]) - 1;
}

int main()
{   
	int x = 0;
	scanf("%d", &n);
	for(int i=1; i<=n; i++)
	{
		scanf("%d", &a[i]);
		b[i] = a[i];
	}
	
	//离散化
	sort(b+1, b+1+n);
	len = unique(b+1, b+1+n) - (b+1);
	for(int i=1; i<=n; i++)
	{
		a[i] = lower_bound(b+1, b+1+n, a[i]) - b;	
		update(root[i], 1, len, a[i], 1); 
	}	
	 
	//读边
	for(int i=2; i<=n; i++)
	{
		scanf("%d", &x);
		addedge(x, i);
	}

	dfs(1);
	for(int i=1; i<=n; i++) printf("%d\n", ans[i]);
	 
	return 0;
} 

单调队列

例题

例题:249.窗口

#include <bits/stdc++.h>
using namespace std;

const int maxn = 1e6+10;

int n = 0, k = 0;
int a[maxn] = {};
deque<int> q;	//双端队列

int main()
{ 
	scanf("%d%d", &n, &k);
	for(int i=1; i<=n; i++) scanf("%d", &a[i]);
	
	//求最小值,单调递减队列
	for(int i=1; i<=n; i++)
	{
		if(i>k && q.front()==i-k) q.pop_front();	//越界元素出队
		while(q.size() && a[q.back()]>a[i]) q.pop_back();	//将队尾不合法元素出队
		q.push_back(i);
		if(i >= k) printf("%d ", a[q.front()]);
	}
	printf("\n");
	
	while(q.size()) q.pop_front();	//清空队列
	//求最大值,单调递增队列
	for(int i=1; i<=n; i++)
	{
		if(i>k && q.front()==i-k) q.pop_front();	//越界元素出队
		while(q.size() && a[q.back()]<a[i]) q.pop_back();	//将队尾不合法元素出队
		q.push_back(i);
		if(i >= k) printf("%d ", a[q.front()]);
	}
 
	return 0;
}

ST算法

例题

例题:346.均衡队形

#include <bits/stdc++.h>
using namespace std;
 
const int maxn = 50010;
 
int n = 0, q = 0; 
int a[maxn] = {}, f[maxn][30] = {}, g[maxn][30] = {}; 

//ST算法求最大值
int getmax(int l, int r)
{
	int k = log2(r - l + 1);
	return max(f[l][k], f[r-(1<<k)+1][k]);
}
 
//ST算法求最小值
int getmin(int l, int r)
{
	int k = log2(r - l + 1);
	return min(g[l][k], g[r-(1<<k)+1][k]);
}
 
int main()
{ 
	scanf("%d%d", &n, &q);
	for(int i=1; i<=n; i++)
	{
		scanf("%d", &a[i]);
		f[i][0] = g[i][0] = a[i];
	}

	//ST算法初始化
	int t = log2(n) + 1;
	for(int j=1; j<t; j++)
	{
		for(int i=1; i+(1<<j)-1<=n; i++)
		{
			f[i][j] = max(f[i][j-1], f[i+(1<<(j-1))][j-1]);
			g[i][j] = min(g[i][j-1], g[i+(1<<(j-1))][j-1]);
		}
	}
	
	int x = 0, y = 0;
	for(int i=1; i<=q; i++)
	{
		scanf("%d%d", &x, &y); 
		printf("%d\n", getmax(x, y) - getmin(x, y));
	}

	return 0;
}

分块/莫队

分块

简介

分块是一种思想,而不是一种数据结构
基本思想: 通过对原数据的适当划分,并在划分后的每一块上预处理部分信息,从而较一般的暴力算法取得更优的时间复杂度,因此分块也被称为优雅地暴力
时间复杂度:

  • 分块的时间复杂度主要取决于分块的块长,一般可以通过均值不等式求出某个问题下的最优块长,以及相应的时间复杂度
  • 块的大小为b,当\(b = \sqrt{n}\)时,单次操作的时间复杂度最优,为\(O(\sqrt{n})\),总时间复杂度为\(O(m*\sqrt{n})\)

优点: 分块是一种很灵活的思想,实现起来也比较简单,相较于树状数组和线段树,分块的优点是通用性更好,可以维护很多树状数组和线段树无法维护的信息。
缺点: 一般情况下时间复杂度比线段树和树状数组要高

例题

例题: 5939.一个简单的整数问题
https://h.hszxoj.com/d/hztg/p/HZTG5939

分析: 将序列按每\(s\)个元素一块进行分块,并记录每块的区间和\(b_i\)
image

最后一个块可能是不完整的(因为\(n\)很可能不是\(s\)的倍数),不影响分析
查询:

  • \(l\)\(r\)在同一个块内,直接暴力求和即可,因为块长为\(s\),因此最坏的复杂度为\(O(s)\)
  • \(l\)\(r\)不在同一个块内,则答案由三部分组成:以\(l\)开头的不完整块,中间几个完整块,以\(r\)结尾的不完整块。对于不完整块,直接暴力计算,对于完整块,直接利用已经求出的\(b_i\)求和即可。最坏时间复杂度为\(O(n/s+s)\)

修改:

  • \(l\)\(r\)在同一个块内,直接暴力修改即可,因为块长为\(s\),因此最坏的复杂度为\(O(s)\)
  • \(l\)\(r\)不在同一个块内,则需要修改三部分:以\(l\)开头的不完整块,中间几个完整块,以\(r\)结尾的不完整块。对于不完整块,直接暴力修改,对于完整块,直接修改\(b_i\)即可。最坏时间复杂度为\(O(n/s+s)\)

利用均值不等式可知,当\(n/s == s\)时,即 \(s = \sqrt{n}\)时,单次操作的时间复杂度最优,为\(O(\sqrt{n})\)

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int maxn = 1e5 + 10;

int n = 0, m = 0;
int a[maxn] = {};
//L[i]和R[i]表示第i段的左右端点,pos[i]表示a序列中的第i个位置的数位于第几段
//cnt表示分成的段数
int L[400] = {}, R[400] = {}, pos[maxn] = {}, cnt;
ll sum[400] = {}, add[400] = {};

//分块
void init()
{
	cnt = sqrt(n);
	for(int i=1; i<=cnt; i++)
	{
		L[i] = (i-1) * cnt + 1;
		R[i] = i * cnt;
	}
	
	//分出sqrt(n)块后,还有剩余的部分,单独再分一块
	if(R[cnt] < n)
	{
		cnt++;
		L[cnt] = R[cnt-1] + 1;
		R[cnt] = n;
	}
	
	//预处理pos和sum
	for(int i=1; i<=cnt; i++)
	{
		for(int j=L[i]; j<=R[i]; j++)
		{
			pos[j] = i;
			sum[i] += a[j];
		}
	}
}

ll Query(int l, int r)
{
	ll ans = 0;
	int p = pos[l], q = pos[r];
	if(p == q)//l~r处于同一分块中
	{
		for(int i=l; i<=r; i++) ans += a[i];
		ans += (r - l + 1) * add[p];
	}
	else
	{
		//先处理整块数据
		for(int i=p+1; i<q; i++) 
		{
			ans += sum[i] + add[i] * (R[i] - L[i] + 1);
		}
		//处理最左边块
		for(int i=l; i<=R[p]; i++) ans += a[i];
		ans += (R[p] - l + 1) * add[p];
		//处理最右边块
		for(int i=L[q]; i<=r; i++) ans += a[i];
		ans += (r - L[q] + 1) * add[q];
	}
	
	return ans;
}

void Update(int l, int r, int d)
{
	int p = pos[l], q = pos[r];
	if(p == q)//l~r处于同一分块中
	{
		for(int i=l; i<=r; i++) a[i] += d;
		sum[p] += d * (r - l + 1);
	}
	else
	{
		//先处理整块数据
		for(int i=p+1; i<q; i++) add[i] += d;
		//处理最左边块
		for(int i=l; i<=R[p]; i++) a[i] += d;
		sum[p] += d * (R[p] - l + 1);
		//处理最右边块
		for(int i=L[q]; i<=r; i++) a[i] += d;
		sum[q] += d * (r - L[q] + 1);
	}
}

int main()
{  
	char op[3] = {};
	int l = 0, r = 0, d = 0;
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++) scanf("%d", &a[i]);
	
	init();
	
	//指令
	while(m--)
	{
		scanf("%s", op);
		if(op[0] == 'Q')
		{
			scanf("%d%d", &l, &r);
			ll ans = Query(l, r);
			printf("%lld\n", ans);
		}
		else
		{
			scanf("%d%d%d", &l, &r, &d);
			Update(l, r, d);
		}		
	}
 
	return 0;
} 

莫队

莫队:莫涛归纳总结的一种解决区间查询等问题的离线算法,基于分块思想。

朴素版

对于序列上的区间询问问题(序列长度为\(n\),询问为\(m\)),如果可以在\(O(1)\)内从\([l, r]\)的答案扩展到\([l-1, r],[l+1, r],[l, r-1],[l, r+1]\)(即与\([l, r]\)相邻的区间)的答案,可以考虑使用莫队算法来解决问题。

例题: 1648.HH的项链
https://h.hszxoj.com/d/hztg/p/HZTG1648?tid=67877d8357cd8663ca471038

\([l, r]\)移动到\([l-1, r]\)相当于增加\(a[l]\)的值,时间复杂度\(O(1)\)
\([l, r]\)移动到\([l+1, r]\)相当于减去\(a[l]\)的值,时间复杂度\(O(1)\)
所以该题可以用莫队算法,每次询问的移动的时间复杂度为\(O(n)\),总时间复杂度为\(O(mn)\)

#include <bits/stdc++.h>
using namespace std;
 
const int maxn = 50010;
const int maxx = 1e6+10;

int n = 0, m = 0; 
int a[maxn] = {};
int l = 1, r = 0, cnt[maxx] = {}, ans = 0;

void add(int x)
{
	if(cnt[x] == 0) ans++;
	cnt[x]++;
}

void del(int x)
{
	cnt[x]--;
	if(cnt[x] == 0) ans--;
}

int main()
{  
	int x = 0, y = 0;
	scanf("%d", &n);
	for(int i=1; i<=n; i++) scanf("%d", &a[i]);
	scanf("%d", &m);
	while(m--)
	{
		scanf("%d%d", &x, &y);
		
		//莫队算法
		while(x<l) { l--; add(a[l]); }
		while(x>l) { del(a[l]); l++; }
		while(y<r) { del(a[r]); r--; }
		while(y>r) { r++; add(a[r]); }
		printf("%d\n", ans);
	}
 
	return 0;
} 

分块排序版

  • 对原序列进行分块,先按左端点所在块由小到大排序,再按右端点由小到大排序

时间复杂度:
假设m为操作数,B为块大小。考虑对于一个块内的l,其r是单增的,于是考虑如下:

  • 左端点,在块内可能会一前一后这样设置,这样复杂度被卡到最满,为O(m*B)
  • 右端点,考虑各块内的询问,显然都可以做到单块O(n),所以不需要考虑m的贡献,即右端点移动的复杂度就是\(\frac{n}{B} \cdot n = \frac{n^2}{B}\)
  • 总的时间复杂度为\(O(m*b + \frac{n}{B} \cdot n = \frac{n^2}{B})\)

均值一下发现\(m \cdot B + \frac{n^2}{B} \geq 2\sqrt{n^2 m} = O(n\sqrt{m})\),此时有 \(m \cdot B = \frac{n^2}{B}\),解得 \(B = \frac{n}{\sqrt{m}}\)
所以取块的大小为\(\frac{n}{\sqrt{m}}\)时达到理论最优,复杂度为\(O(n\sqrt{m})\)

例题: 1648.HH的项链
https://h.hszxoj.com/d/hztg/p/HZTG1648?tid=67877d8357cd8663ca471038

#include <bits/stdc++.h>
using namespace std;
 
const int maxn = 50010;
const int maxx = 1e6+10;
const int maxm = 2e5 + 10;

int n = 0, m = 0; 
int a[maxn] = {};
int l = 1, r = 0, col[maxx] = {}, num = 0, ans[maxm] = {};
int L[maxn] = {}, R[maxn] = {}, pos[maxn] = {}, cnt = 0;
struct node
{
	int x, y, id;
}q[maxm];

bool cmp(node &nd1, node &nd2)
{
	int p = pos[nd1.x], q = pos[nd2.x];
	if(p == q) return nd1.y < nd2.y;
	return p < q;
}

void init()
{
	int B = n / sqrt(m);
	cnt = n / B;
	for(int i=1; i<=cnt; i++)
	{
		L[i] = n / cnt * (i - 1) + 1;
		R[i] = n / cnt * i;
	}
	if(R[cnt] < n) 
	{
		cnt++;	
		L[cnt] = R[cnt-1] + 1;
		R[cnt] = n;
	}
	for(int i=1; i<=cnt; i++)
	{
		for(int j=L[i]; j<=R[i]; j++)
		{
			pos[j] = i;
		}
	}
}

void add(int x)
{
	if(col[x] == 0) num++;
	col[x]++;
}

void del(int x)
{
	col[x]--;
	if(col[x] == 0) num--;
}

int main()
{  
	int x = 0, y = 0;
	scanf("%d", &n);
	for(int i=1; i<=n; i++) scanf("%d", &a[i]);
	
	//分块
	init();
	
	scanf("%d", &m);
	for(int i=1; i<=m; i++)
	{
		scanf("%d%d", &x, &y);
		q[i] = { x, y, i }; 
	}
	
	sort(q+1, q+1+m, cmp);
	
	for(int i=1; i<=m; i++)
	{
		x = q[i].x;
		y = q[i].y;
		//莫队算法
		while(x<l) { l--; add(a[l]); }
		while(x>l) { del(a[l]); l++; }
		while(y<r) { del(a[r]); r--; }
		while(y>r) { r++; add(a[r]); }
		ans[q[i].id] = num;		
	}
	
	for(int i=1; i<=m; i++) printf("%d\n", ans[i]);
 
	return 0;
} 

奇偶排序版

奇偶排序是对上述分块排序算法的优化
看下面这组数据:

//设块的大小为2(假设)
1 1
2 100
3 1
4 100

手动模拟一下就可以发现,\(r\)指针移动次数大概为\(300\)次,先从\(1\)移动到\(100\),再移动到\(1\),再移动到\(100\)
优化:
对于奇数块的询问,\(r\)按从小到大排序,偶数块的询问,\(r\)按从大到小排序,这样我们的\(r\)指针在处理完奇数块的问题后,将在返回的途中处理偶数块的问题,再向\(n\)移动处理下一个奇数块的问题,优化了\(r\)指针的移动次数。
一般情况下,这种优化能让程序快\(30\%\)左右

例题: 1648.HH的项链
https://h.hszxoj.com/d/hztg/p/HZTG1648?tid=67877d8357cd8663ca471038

#include <bits/stdc++.h>
using namespace std;
 
const int maxn = 50010;
const int maxx = 1e6+10;
const int maxm = 2e5 + 10;

int n = 0, m = 0; 
int a[maxn] = {};
//莫队信息
int l = 1, r = 0, col[maxx] = {}, num = 0, ans[maxm] = {};
//分块信息
int L[maxn] = {}, R[maxn] = {}, pos[maxn] = {}, cnt = 0;
struct node
{
	int x, y, id;
}q[maxm];

//奇偶排序
//左端点按所在块,由小到大排序
//奇数块时右端点由小到大,偶数块右端点由大到小
//奇偶排序可以做到上一块r由1~n,该块r由n~1
bool cmp(node &nd1, node &nd2)
{
	int p = pos[nd1.x], q = pos[nd2.x];
	if(p != q) return p < q;
	if(p & 1 == 1) return nd1.y < nd2.y;
	else return nd1.y > nd2.y; 
}

void init()
{
	//当块的大小取n/sqrt(m)时,时间复杂度可以达到最优
	int B = n / sqrt(m);
	cnt = n / B;
	for(int i=1; i<=cnt; i++)
	{
		L[i] = B * (i - 1) + 1;
		R[i] = B * i;
	}
	if(R[cnt] < n) 
	{
		cnt++;	
		L[cnt] = R[cnt-1] + 1;
		R[cnt] = n;
	}
	for(int i=1; i<=cnt; i++)
	{
		for(int j=L[i]; j<=R[i]; j++)
		{
			pos[j] = i;
		}
	}
}

void add(int x)
{
	if(col[x] == 0) num++;
	col[x]++;
}

void del(int x)
{
	col[x]--;
	if(col[x] == 0) num--;
}

int main()
{  
	int x = 0, y = 0;
	scanf("%d", &n);
	for(int i=1; i<=n; i++) scanf("%d", &a[i]);
	
	//分块
	scanf("%d", &m);
	init();
	 
	for(int i=1; i<=m; i++)
	{
		scanf("%d%d", &x, &y);
		q[i] = { x, y, i }; 
	}
	
	sort(q+1, q+1+m, cmp);
	
	for(int i=1; i<=m; i++)
	{
		x = q[i].x;
		y = q[i].y;
		//莫队算法
		while(x<l) { l--; add(a[l]); }
		while(x>l) { del(a[l]); l++; }
		while(y<r) { del(a[r]); r--; }
		while(y>r) { r++; add(a[r]); }
		ans[q[i].id] = num;		
	}
	
	for(int i=1; i<=m; i++) printf("%d\n", ans[i]);
 
	return 0;
} 

带修莫队

带修莫队是一种支持单点修改的莫队算法。
普通莫队是不能带修改的,我们可以强行让他可以修改,就像\(DP\)一样,可以强行加上一维时间维,表示这次操作的时间
即把询问\([l, r]\)变成\([l, r, time]\)
那么我们的坐标也可以在时间维上移动,即\([l, r, time]\)多了一维可以移动的方向,变成:

  • \([l-1, r, time]\)
  • \([l+1, r, time]\)
  • \([l, r-1, time]\)
  • \([l, r+1, time]\)
  • \([l, r, time-1]\)
  • \([l, r, time+1]\)

这样的转移也是\(O(1)\)的,但是排序多了一个关键字

算法简介:
还是对询问进行排序,每个询问除了左端点和右端点,还要记录这次询问是在第几次修改之后(时间),以左端点所在块为第一关键字,右端点所在块为第二关键字,时间为第三关键字进行排序
暴力查询时,如果当前修改数比询问的修改数少,就把没修改的进行修改,反之回退
需要注意的是,修改分为两部分:

  • 若修改的位置在当前区间内,需要更新答案(del原颜色,add修改后的颜色)
  • 无论修改的位置是否在当前区间内,都要进行修改(以供add和del函数在以后更新答案)

分块大小为\(n^{2/3}\),分成\(n^{1/3}\)块,此时时间复杂度最低,证明见\(oi-wiki\)

例题: 351.维护队列
https://h.hszxoj.com/d/hztg/p/HZTG351?tid=67877d8357cd8663ca471038

#include <bits/stdc++.h>
using namespace std;
 
const int maxn = 150010;
const int maxx = 1e6+10;
const int maxm = 150010;

//带修莫队,相当于在朴素莫队的基础上,增加了一维,表示时间
//在完成朴素莫队更新后,还要判断修改时间,将修改时间更新到正确位置
int n = 0, m = 0; 
int a[maxn] = {};
//莫队数据
int l = 1, r = 0, t = 0, col[maxx] = {}, num = 0, ans[maxm] = {};
//分块数据
int L[500] = {}, R[500] = {}, pos[maxn] = {}, cnt = 0;
struct node1
{
	//t表示该询问位于第几次修改
	int id, l, r, t;
}q[maxm] = {};
//存储修改信息
struct node2
{
	int x, c;
}rp[maxm] = {};

bool cmp(const node1 &nd1, const node1 &nd2)
{
	//先按左端点所在块升序
	//左端点块相同按右端点所在块升序
	//左右端点块相同,按t升序
	int p = pos[nd1.l], q = pos[nd2.l];
	if(p != q) return p < q;
	p = pos[nd1.r], q = pos[nd2.r];
	if(p != q) return p < q;
	return nd1.t < nd2.t;
}

//分块
void init()
{  
	int B = pow(n, 0.666);
	cnt = n / B;
	for(int i=1; i<=cnt; i++)
	{
		L[i] = (i-1) * B + 1;
		R[i] = i * B;
	}
	if(R[cnt] < n)
	{
		cnt++;
		L[cnt] = R[cnt-1] + 1;
		R[cnt] = n;
	} 
	for(int i=1; i<=cnt; i++)
	{
		for(int j=L[i]; j<=R[i]; j++)
		{
			pos[j] = i;
		}
	}
}

//朴素莫队加
void add(int x)
{
	if(col[x] == 0) num++;
	col[x]++;
}

//朴素莫队减
void del(int x)
{
	col[x]--;
	if(col[x] == 0) num--;
}

int main()
{  
	char op[3] = {};
	int x = 0, y = 0;
	int mq = 0, mr = 0;
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++) scanf("%d", &a[i]); 
	for(int i=1; i<=m; i++)
	{
		scanf("%s%d%d", op, &x, &y);
		if(op[0] == 'Q')	//单独记录查询的信息
		{
			mq++;
			q[mq] = { mq, x, y, mr };	
		}
		else 	//单独记录修改的信息
		{
			mr++;
			rp[mr] = { x, y };
		} 
	}
	
	init();
	sort(q+1, q+1+mq, cmp);
	
	for(int i=1; i<=mq; i++)
	{
		//朴素莫队操作
		while(q[i].l<l) { l--; add(a[l]); }
		while(q[i].l>l) { del(a[l]); l++; }
		while(q[i].r<r) { del(a[r]); r--; }
		while(q[i].r>r) { r++; add(a[r]); }
		//更新时间t这一维
		while(q[i].t<t)
		{
			//修改的数据位于[l,r]之间,则需要更新,否则不需要更新
			if(rp[t].x>=l && rp[t].x<=r)
			{
				del(a[rp[t].x]);
				add(rp[t].c);
			}
			//注意这里是交换值,不是直接更改a数组,因为后面还要回滚
			swap(a[rp[t].x], rp[t].c);
			t--;
		}
		while(q[i].t>t)
		{
			t++;
			if(rp[t].x>=l && rp[t].x<=r)
			{
				del(a[rp[t].x]);
				add(rp[t].c);
			}
			swap(a[rp[t].x], rp[t].c);
		}
		ans[q[i].id] = num;
	}
	for(int i=1; i<=mq; i++) printf("%d\n", ans[i]);
 
	return 0;
} 

回滚莫队

引入:

  • 有些题目在区间转移时,可能会出现增加或者删除无法实现的问题。在只有增加不可实现或者只有删除不可实现的时候,就可以使用回滚莫队在\(O(n\sqrt{m})\)的时间内解决问题。
  • 回滚莫队分为只使用增加操作的回滚莫队和只使用删除操作的回滚莫队。

核心思想:
既然只能实现一个操作,那么就只使用一个操作,剩下的交给回滚莫队
复杂度证明:
假设回滚莫队的分块大小是\(b\):

  • 对于左、右端点在同一个块内的询问,可以在\(O(b)\)时间内计算
  • 对于其他询问,考虑左端点在相同块内的询问,他们的右端点单调递增,移动右端点的时间复杂度是\(O(n)\),而左端点单次询问的移动不超过\(b\),因为有\(\frac{n}{b}\)个块,所以总复杂度是\(O\left(mb + \frac{n^2}{b}\right)\),取\(b = \frac{n}{\sqrt{m}}\)最优,时间复杂度为\(O(n\sqrt{m})\)

块大小\(b\),当\(b = \frac{n}{\sqrt{m}}\)最优,时间复杂度为\(O(n\sqrt{m})\)

例题: 605 permu
https://h.hszxoj.com/d/hztg/p/HZTG605?tid=67877d8357cd8663ca471038

#include <bits/stdc++.h>
using namespace std;
 
const int maxn = 50010;
const int maxm = 50010;

int n = 0, m = 0; 
int a[maxn] = {};
//分块信息
int L[300] = {}, R[300] = {}, pos[maxn] = {}, cnt = 0;
struct node
{
	int id, l, r;
}q[maxm];
//ls[x]和rs[x]分别表示x点向左向右延伸距离
//backls, backrs用于后面的回滚
int ls[maxn] = {}, rs[maxn] = {}, backls[maxn] = {}, backrs[maxn] = {}, num = 0, ans[maxm] = {};

bool cmp(const node &nd1, const node &nd2)
{
	int p = pos[nd1.l], q = pos[nd2.l];
	if(p != q) return p < q;
	return nd1.r < nd2.r;
}

void init()
{
	int B = n / sqrt(m);
	cnt = n / B;
	for(int i=1; i<=cnt; i++)
	{
		L[i] = (i-1) * B + 1;
		R[i] = i * B;
	}
	
	if(R[cnt] < n)
	{
		cnt++;
		L[cnt] = R[cnt-1] + 1;
		R[cnt] = n;
	}
	
	for(int i=1; i<=cnt; i++)
	{ 
		for(int j=L[i]; j<=R[i]; j++)
		{
			pos[j] = i; 
		} 
	} 
}

void add(int x)
{
	//更新x这个点的左右延伸距离
	//如果x在某个连续区间的中间位置,可能会出现左延伸或右延伸不能更新,这种情况不影响答案
	//因为我们求的是最大连续区间,所以只要保证当x为连续区间边界的点时,能够更新就可以
	//因此,后面我们要更新rs[x-ls[x]+1]和ls[x+rs[x]-1]的值
	ls[x] = ls[x-1] + 1;	//左边延伸+1
	rs[x] = rs[x+1] + 1;	//右边延伸+1; 
	int t = ls[x] + rs[x] - 1;
	//更新该连续区间左右端点的延伸距离
	rs[x-ls[x]+1] = t;
	ls[x+rs[x]-1] = t;
	num = max(num, t);
}

int main()
{   
	int x = 0, y = 0;
	scanf("%d%d", &n, &m);
	for(int i=1; i<=n; i++) 
	{
		scanf("%d", &a[i]); 
	}	
	for(int i=1; i<=m; i++)
	{
		scanf("%d%d", &x, &y);
		q[i] = { i, x, y };
	}
	
	init();
	sort(q+1, q+1+m, cmp);
 
	for(int i=1; i<=m; )
	{ 
		int j = i;
		while(j<=m && pos[q[i].l] == pos[q[j].l]) j++;
		int right = R[pos[q[i].l]];
		
		//块内移动,直接暴力
		while(i<j && q[i].r<=right)
		{
			num = 0;
			memset(ls, 0, sizeof(ls));
			memset(rs, 0, sizeof(rs));
			int id = q[i].id, l = q[i].l, r = q[i].r;
			for(int k=l; k<=r; k++) add(a[k]);
			ans[q[i].id] = num;
			i++;
		}
		
		//块外移动
		//r设置为q[i].l所在块终点位置,然后向右移动
		//l设置为r+1,即q[i].l所在块的下一个块的起始位置,以保证初始空间为0,然后向左移动
		//r的轨迹会一直往后移(前面对q的排序)
		//l会在q[i].l中不断来回移动
		//每次求出一个询问后,回滚到l的起始位置
		num = 0;
		memset(ls, 0, sizeof(ls));
		memset(rs, 0, sizeof(rs));
		int r = right, l = right + 1;
		while(i < j)
		{
			//先向右扩展
			while(r < q[i].r) { r++; add(a[r]); }
			//先备份下来,以便后面回滚,然后再向左扩展
			int backup = num;
			memcpy(backls, ls, sizeof(ls));
			memcpy(backrs, rs, sizeof(rs));
			while(l > q[i].l) { l--; add(a[l]); }
			ans[q[i].id] = num;
			//回滚
			l = right + 1;
			num = backup;
			memcpy(ls, backls, sizeof(ls));
			memcpy(rs, backrs, sizeof(rs));
			i++;
		}
		num = 0;
		memset(ls, 0, sizeof(ls));
		memset(rs, 0, sizeof(rs));
	}
	for(int i=1; i<=m; i++) printf("%d\n", ans[i]); 
 
	return 0;
} 

平衡树

平衡树简介

前置知识:二叉搜索树(也叫二叉排序树、二叉查找树(\(BST\)))
使用二叉搜索树的目的之一是缩短插入、删除、修改和查找(插入、删除、修改都包括查找操作)节点的时间
关于查找效率,如果一棵树的高度为\(h\),在最坏的情况,查找一个关键字需要对比\(h\)次,查找时间复杂度(也为平均查找长度\(ASL\)\(Average Search Length\))不超过\(O(h)\),一棵理想的二叉搜索树所有操作的时间可以缩短到\(O(logn)\)(\(n\)是节点总数)。
然而\(O(logn)\)的时间复杂度仅为理想情况。在最坏的情况下,搜索树有可能退化为链表。想象一棵每个节点只有右孩子的二叉搜索树,那么它的性质就和链表一样,所有操作(增删改查)的时间是\(O(n)\)
可以发现操作的复杂度与树的高度h有关。由此引出了平衡树,通过一定操作维持树的高度(平衡性)来降低操作的复杂度。

有旋Treap

概念

\(Treap\)(树堆)是一种弱平衡的二叉搜索树。
\(Treap\)的节点除了被维护的权值外,还附加了一个随机的优先级。其中,权值满足二叉搜索树性质,优先级满足堆性质(小根堆或大根堆)
其中,二叉搜索树的性质是指:

  • 左子树所有节点的权值比父节点小
  • 右子树所有节点的权值比父节点大

堆的性质是:

  • 子结点优先级比父节点大或小(取决于是小根堆还是大根堆)

不难看出,如果用的是同一个值,那么这两种数据结构在组合后会变成一条链,

旋转

旋转原理

满足\(BST\)性质且中序遍历为相同序列的二叉查找树是不唯一的。这些二叉查找树是等价的,他们维护的是相同的一组数值。在这些二叉查找树上执行同样的操作,将得到相同的结果。
因此,我们可以在维持\(BST\)性质的基础上,通过改变二叉查找树的形态,使得树上每个节点的左右子树大小达到平衡,从而使整棵树的深度维持在\(O(logn)\)级别。
改变形态并保持\(BST\)性质的方法就是旋转。最基本的旋转操作称为“单旋转”,它又分为“左旋”和“右旋”。如下图所示:
image
注意:某些书籍中把左、右旋操作定义为一个节点绕其父亲节点向左或右旋转。本文后面即将讲解的\(Treap\)代码仅记录左右子结点,没有记录父节点,为了方便起见,统一以“旋转前处于父节点位置”(旋转后处于子结点位置)的节点作为左、右旋的作用对象(函数参数)。
右旋为例。在初始情况下,\(x\)\(y\)的左子结点,\(A\)\(B\)分别是\(x\)的左右子树,\(C\)\(y\)的右子树。
右旋操作在保持\(BST\)性质的基础上,把\(x\)变为\(y\)的父节点。因为\(x\)的关键码小于\(y\)的关键码,所以\(y\)应该作为\(x\)的右子节点。
\(x\)变为\(y\)的父节点后,\(y\)的左子树就空了出来,于是\(x\)原来的右子树\(B\)就恰好作为\(y\)的左子树。
右旋操作的代码如下,\(zig(p)\)可以理解成把\(p\)的左子结点绕着\(p\)向右旋转:

// 右旋 zig
void zig(int &p)
{
    int q = tr[p].l;
    tr[p].l = tr[q].r;
    tr[q].r = p;
    p = q;
}

左旋操作的代码如下,\(zag(p)\)可以理解成把\(p\)的右子节点绕着\(p\)向左旋转:

// 左旋 zag
void zag(int &p)
{
    int q = tr[p].r;
    tr[p].r = tr[q].l;
    tr[q].l = p;
    p = q;
}

合理的旋转操作可使\(BST\)变得更“平衡”。如下图所示,对形态为一条链的\(BST\)进行一系列单旋转操作后,这棵\(BST\)变得比较平衡了。
image

旋转操作

现在,我们的问题是,怎样才算“合理”的旋转操作呢?
我们发现,在随机数据下,普通的\(BST\)就是趋近平衡的。\(Treap\)的思想就是利用“随机”来创造平衡条件。因为在旋转过程中必须维持\(BST\)性质,所以\(Treap\)就把“随机”作用在堆性质上。
\(Treap\)是英文\(Tree\)\(Heap\)的合成词。\(Treap\)在插入每个新节点时,给该节点随机生成一个额外的权值。然后像二叉堆的插入过程一样,自底向上依次检查,当某个节点不满足大根堆性质时,就执行单旋转,使该点与其父节点的关系发生对换。
特别地,对于删除操作,因为\(Treap\)支持旋转,我们可以直接找到需要删除的节点,并把它向下旋转成叶节点,最后直接删除。这样就避免了采取类似普通\(BST\)的删除方法可能导致的节点信息更新、堆性质维护等复杂问题。
总而言之,\(Treap\)通过适当的单旋转,在维持节点关键码满足\(BST\)性质的同时,还使每个节点上随机生成的额外权值满足大根堆性质。
时间复杂度:\(O(logn)\).\(Treap\)是一种平衡二叉查找树,检索、插入、求前趋后继以及删除节点的时间复杂度都是\(O(logn)\).证明可参考oi-wiki

例题:普通平衡树

#include <bits/stdc++.h>
using namespace std;

//平衡树板子,BST+大根堆

const int maxn = 1e5 + 10;
const int inf = 1e8;

int n = 0;
int root = 0, tot = 0;

struct node
{
	int l, r;		//左右子结点在数组中的下标
	int val, key;	//val为关键码,key为随机权值
	//cnt为该关键码的数量
	//size为该子树所有cnt的和(若权值不重复,则为该子树的大小)
	int cnt, size;	
}tr[maxn];
 
//更新当前节点size
void pushup(int p)
{
	tr[p].size = tr[tr[p].l].size + tr[tr[p].r].size + tr[p].cnt;
}
 
//右旋zig
void zig(int &p)
{
	int q = tr[p].l;
	tr[p].l = tr[q].r;
	tr[q].r = p;
	p = q;
	pushup(tr[p].r);
	pushup(p);
}
 
//左旋zag
void zag(int &p)
{
	int q = tr[p].r;
	tr[p].r = tr[q].l;
	tr[q].l = p;
	p = q;
	pushup(tr[p].l);
	pushup(p);
}

//新建节点
int get_node(int val)
{
	tr[++tot].val = val;
	tr[tot].key = rand();
	tr[tot].cnt = tr[tot].size = 1;
	tr[tot].l = tr[tot].r = 0;
	return tot;
}

//插入节点
void insert(int &p, int val)
{
	if(!p) p = get_node(val);
	else if(tr[p].val == val) tr[p].cnt++;
	else if(tr[p].val > val)
	{
		insert(tr[p].l, val);
		if(tr[tr[p].l].key > tr[p].key) zig(p);
	}
	else
	{
		insert(tr[p].r, val);
		if(tr[tr[p].r].key > tr[p].key) zag(p);
	}
	pushup(p);
}

//删除节点
void remove(int &p, int val)
{
	if(!p) return;
	
	if(tr[p].val == val)	//找到了该节点
	{
		if(tr[p].cnt > 1) tr[p].cnt--;	//数量不止一个,直接删
		else if(tr[p].l || tr[p].r)	//有左或右子树,先将该节点旋转到叶子节点再删
		{
			//没有右子树,或者左子树的随机数>右子树
			//这种情况进行右旋,以保证整棵树的大根堆
			if(!tr[p].r || tr[tr[p].l].key > tr[tr[p].r].key)
			{
				zig(p);	//右旋
				remove(tr[p].r, val);
			}
			else
			{
				zag(p);
				remove(tr[p].l, val);
			}
		}
		else p = 0;	//没有子结点,直接删
	}
	else if(tr[p].val > val) remove(tr[p].l, val);	//到左子树中删
	else remove(tr[p].r, val);	//到右子树中删
	pushup(p);
}
 
//根据数值查排名
int get_rank_by_key(int p, int val)
{
	if(!p) return 0;
	if(tr[p].val == val) return tr[tr[p].l].size + 1;
	if(tr[p].val > val) return get_rank_by_key(tr[p].l, val);
	return tr[tr[p].l].size + tr[p].cnt + get_rank_by_key(tr[p].r, val);
}

//根据排名查数值
int get_key_by_rank(int p, int rank)
{
	if(!p) return inf;
	if(tr[tr[p].l].size >= rank) return get_key_by_rank(tr[p].l, rank);
	if(tr[tr[p].l].size + tr[p].cnt >= rank) return tr[p].val;
	return get_key_by_rank(tr[p].r, rank - tr[tr[p].l].size - tr[p].cnt);
}
 
//找前趋:严格小于val的最大值
int get_prev(int p, int val)
{
	if(!p) return -inf;
	if(tr[p].val >= val) return get_prev(tr[p].l, val);
	//如果右子树中找到了比val小的值,则该值一定大于tr[p].val
	//若没找到,则是负无穷,则一定是tr[p].val大
	return max(tr[p].val, get_prev(tr[p].r, val));
}

//找后继:严格大于val的最小值
int get_next(int p, int val)
{
	if(!p) return inf;
	if(tr[p].val <= val) return get_next(tr[p].r, val);
	return min(tr[p].val, get_next(tr[p].l, val));
}

//初始化树,加入哨兵-inf/inf
void build()
{
	srand((unsigned)time(NULL));	//设置随机数种子
	get_node(-inf);
	get_node(inf);
	root = 1;
	tr[root].r = 2;
	pushup(root);
}
 
int main()
{   
	build();
	
	scanf("%d", &n);
	while(n--)
	{
		int opt = 0, x = 0;
		scanf("%d%d", &opt, &x);
		if(opt == 1) insert(root, x);
		else if(opt == 2) remove(root, x);
		else if(opt == 3) printf("%d\n", get_rank_by_key(root, x) - 1);	//前面有个最小哨兵
		else if(opt == 4) printf("%d\n", get_key_by_rank(root, x + 1));	//前面有个最小哨兵
		else if(opt == 5) printf("%d\n", get_prev(root, x));
		else printf("%d\n", get_next(root, x));
	}
	 
	return 0;
}  

splay

定义

splay树,或伸展树,是一种平衡二叉查找树,它通过伸展(splay)操作不断将某个节点旋转到根节点,使得整棵树仍然满足二叉查找树的性质,能够在均摊(O(logn))时间内完成插入、查找和删除操作,并且保持平衡而不至于退化为链。

旋转操作

为了使splay保持平衡,需要进行旋转操作,旋转的作用是将某个节点上移一个位置。(与treap的旋转操作一样)
旋转需要保证:

  • 整颗splay的中序遍历不变(不能破坏二叉查找树的性质)
  • 受影响的节点维护的信息依然正确有效
  • rt必须指向旋转后的根结点
    在splay中旋转分为两种:左旋和右旋
    image
    观察图示可知,如果要通过旋转将节点x(左旋时的1和右旋时的2)上移,则旋转的方向由该节点是其父节点的左节点还是右节点唯一确定。因此,实现旋转操作时,只需要将要上移的节点x传入即可。
    具体分析旋转步骤:(假设需要上移的节点为x,以右旋为例)
  • 首先,记录节点x的父节点y,以及y的父节点z(可能为空),并记录x是y的左子结点还是右子节点
  • 按照旋转后的树中自下向上的顺序,依次更新y的左子结点为x的右子节点,x的右子节点为y,以及若z非空,z的子节点为x。
  • 按照同样的顺序,依次更新当前y的左子结点(若存在)的父节点为y,y的父节点为x,以及x的父节点为z。
  • 自下而上维护节点信息
    旋转示例代码:
// 旋转操作:把x向上旋转一层,是Splay核心基础操作
void rotate(int x)
{
    int y = tr[x].p;   // x的父节点
    int z = tr[y].p;   // x的祖父节点
    // k=0: x是y左儿子; k=1: x是y右儿子
    int k = tr[y].s[1] == x;

    // 步骤1:祖父z重新认x做儿子,替代原来的y
    tr[z].s[tr[z].s[1] == y] = x;
    tr[x].p = z;

    // 步骤2:x反向的儿子过继给y,成为y对应方向的儿子
    tr[y].s[k] = tr[x].s[k ^ 1];
    tr[tr[x].s[k ^ 1]].p = y;

    // 步骤3:y变成x反向的儿子
    tr[x].s[k ^ 1] = y;
    tr[y].p = x;

    // 旋转后y深度更深,先更新y的size,再更新x的size
    pushup(y);
    pushup(x);
} 

伸展操作

将节点x旋转到目标节点k的儿子位置,x比k大就到右儿子,x比k小就到左儿子
k=0代表把x旋转到根结点
旋转有两种情形,一字型和之字形,对应不同的旋转方式,固定旋转要记住,只有这样旋转才能保证O(logn)的时间复杂度
一字型:
一字型有两种结构,该结构先转父节点y,再转x
image
之字形:
之字形也是两种结构,该额机构连续转两次x
image
伸展操作示例代码:

// Splay伸展:将节点x旋转到目标节点k的儿子位置
// k=0 代表把x旋转到根节点
void splay(int x, int k)
{
    // 只要x的父节点不是目标k,就持续向上旋转
    while (tr[x].p != k)
    {
        int y = tr[x].p;
        int z = tr[y].p;
        // 祖父不是目标节点,需要判断是一字型/之字形
        if (z != k)
        {
            // 之字形:父子旋转方向不同,先转x
            if ((tr[y].s[1] == x) ^ (tr[z].s[1] == y))
                rotate(x);
            // 一字型:父子旋转方向相同,先转父节点y
            else
                rotate(y);
        }
        // 最后把x再旋转一层
        rotate(x);
    }
    // 如果目标k是0,说明x变成整棵树的根
    if (!k)
        root = x;
}

时间复杂度: 对大小为n的splay树做m次伸展操作的复杂度是(O((n+m)logn))的,单次均摊复杂度是(O(logn))的。证明见oi-wiki

平衡树操作

1、查找x的排名
2、查找排名为x的数
3、插入一个数x
4、查询前趋\后继
以上四种操作同“普通平衡树”,不在赘述
5、合并/分裂操作
有些时候需要合并splay数,设两棵树的根节点分别为x和y,那么为了保证结果仍是二叉查找树,需要求x树中的最大值小于y树中的最小值。这种条件通常都可以满足,因为两棵树往往是从更大的子树中分裂出的。
合并操作如下:

  • 如果x和y其中之一或两者都为空树,直接返回不为空的那一棵树的根结点或空树
  • 否则,将y树中的最小值上移至根y处,再将它的左节点(此时必然为空)设置为x,并更新节点信息,返回节点y

分裂操作类似。因而,splay树可以模拟无旋treap的思路做各种操作,包括区间操作。
6、删除操作
删除操作也是一个比较复杂的操作,具体步骤如下:(假设删除的值为v)

  • 首先按照值v查找存储它的节点,并上移至根部
  • 如果不存在存储它的节点,直接返回;(上一步已经做了伸展操作)
  • 否则,更新节点信息
  • 如果得到的根结点为空节点,就合并左右子树作为新的根结点,注意合并前需要更新两个子树的根的父节点为空

序列操作

splay树也可以运用到序列上,用于维护区间信息,与线段树对比,splay树常数较大,但是支持更复杂的序列操作,如区间翻转等,上文提到的splay树同样支持分裂和合并操作,因而可以模拟无旋treap进行区间操作,在此不在过多讨论。本节主要讨论基于伸展操作的区间操作实现方法。
将序列建成的splay树有如下性质:

  • splay树的中序遍历相当于原序列从左到右的遍历
  • splay树上的一个节点代表原序列的一个元素
  • splay树上的一棵子树,代表原序列的一段区间

因为有伸展操作,可以快速提取出代表某个区间的splay子树。
作为例子,本节将讨论模板题目文艺平衡树的实现
1、根据序列建树
插入哨兵 0 ~ n+1
作用:处理区间边界时不用特判l=1、r=n的情况
实际有效序列:1,2,3,...,n

// 插入数值v,按二叉搜索树规则插入,最后splay到根平衡树
void insert(int v)
{
    int u = root;
    int p = 0;
    // 从根往下遍历,找到v该插入的叶子位置
    while (u)
    {
        p = u;
        // v更大去右子树s[1],更小去左子树s[0]
        u = tr[u].s[v > tr[u].v];
    }
    // 分配新节点编号
    u = ++idx;
    // 父节点p挂载上新节点u
    if (p)
        tr[p].s[v > tr[p].v] = u;
    // 初始化新节点
    tr[u].init(v, p);
    // 把新节点伸展到根,维持树平衡
    splay(u, 0);
}

2、区间翻转
结合懒标记下传进行区间翻转,区间翻转对应到树上的操作,其实就是交换当前节点x的左右儿子,然后将懒标记下传到其儿子,然后其儿子不断进行翻转

// 向下下传懒标记:处理区间翻转标记
void pushdown(int x)
{
    // 如果当前节点有翻转标记,需要下传给两个儿子
    if (tr[x].flag)
    {
        // 交换当前节点左右子树,完成当前层翻转
        swap(tr[x].s[0], tr[x].s[1]);
        // 给左右儿子打上翻转懒标记
        tr[tr[x].s[0]].flag ^= 1;
        tr[tr[x].s[1]].flag ^= 1;
        // 清除当前节点翻转标记
        tr[x].flag = 0;
    }
}

例题:文艺平衡树

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

// 最大节点数量,包含哨兵节点
const int maxn = 100010;

int n, m;

/*
Splay树节点结构体
s[0]:左儿子  s[1]:右儿子
p:父节点编号
v:节点存储的值
size:以当前节点为根的子树总节点数量
flag:区间翻转懒标记,1表示需要翻转当前子树,0无需翻转
*/
struct Node
{
    int s[2], p, v;
    int size, flag;

    // 节点初始化:赋值数值、父节点,子树大小初始化为1
    void init(int _v, int _p)
    {
        v = _v;
        p = _p;
        size = 1;
    }
} tr[maxn];

int root;  // Splay树根节点编号
int idx;   // 动态分配节点计数器,分配新节点时自增

// 向上更新:更新当前节点的size(左右子树节点数 + 自身1个)
void pushup(int x)
{
    tr[x].size = tr[tr[x].s[0]].size + tr[tr[x].s[1]].size + 1;
}

// 向下下传懒标记:处理区间翻转标记
void pushdown(int x)
{
    // 如果当前节点有翻转标记,需要下传给两个儿子
    if (tr[x].flag)
    {
        // 交换当前节点左右子树,完成当前层翻转
        swap(tr[x].s[0], tr[x].s[1]);
        // 给左右儿子打上翻转懒标记
        tr[tr[x].s[0]].flag ^= 1;
        tr[tr[x].s[1]].flag ^= 1;
        // 清除当前节点翻转标记
        tr[x].flag = 0;
    }
}

// 旋转操作:把x向上旋转一层,是Splay核心基础操作
void rotate(int x)
{
    int y = tr[x].p;   // x的父节点
    int z = tr[y].p;   // x的祖父节点
    // k=0: x是y左儿子; k=1: x是y右儿子
    int k = tr[y].s[1] == x;

    // 步骤1:祖父z重新认x做儿子,替代原来的y
    tr[z].s[tr[z].s[1] == y] = x;
    tr[x].p = z;

    // 步骤2:x反向的儿子过继给y,成为y对应方向的儿子
    tr[y].s[k] = tr[x].s[k ^ 1];
    tr[tr[x].s[k ^ 1]].p = y;

    // 步骤3:y变成x反向的儿子
    tr[x].s[k ^ 1] = y;
    tr[y].p = x;

    // 旋转后y深度更深,先更新y的size,再更新x的size
    pushup(y);
    pushup(x);
}

// Splay伸展:将节点x旋转到目标节点k的儿子位置
// k=0 代表把x旋转到根节点
void splay(int x, int k)
{
    // 只要x的父节点不是目标k,就持续向上旋转
    while (tr[x].p != k)
    {
        int y = tr[x].p;
        int z = tr[y].p;
        // 祖父不是目标节点,需要判断是一字型/之字形
        if (z != k)
        {
            // 之字形:父子旋转方向不同,先转x
            if ((tr[y].s[1] == x) ^ (tr[z].s[1] == y))
                rotate(x);
            // 一字型:父子旋转方向相同,先转父节点y
            else
                rotate(y);
        }
        // 最后把x再旋转一层
        rotate(x);
    }
    // 如果目标k是0,说明x变成整棵树的根
    if (!k)
        root = x;
}

// 插入数值v,按二叉搜索树规则插入,最后splay到根平衡树
void insert(int v)
{
    int u = root;
    int p = 0;
    // 从根往下遍历,找到v该插入的叶子位置
    while (u)
    {
        p = u;
        // v更大去右子树s[1],更小去左子树s[0]
        u = tr[u].s[v > tr[u].v];
    }
    // 分配新节点编号
    u = ++idx;
    // 父节点p挂载上新节点u
    if (p)
        tr[p].s[v > tr[p].v] = u;
    // 初始化新节点
    tr[u].init(v, p);
    // 把新节点伸展到根,维持树平衡
    splay(u, 0);
}

// 查找序列中第k个位置对应的节点(中序第k位)
int get_k(int k)
{
    int u = root;
    while (true)
    {
        // 往下查找前必须下传懒标记,保证子树信息正确
        pushdown(u);
        int left_sz = tr[tr[u].s[0]].size;
        // 左子树节点总数≥k,目标在左子树
        if (left_sz >= k)
            u = tr[u].s[0];
        // 左子树+自己刚好是第k位,找到答案
        else if (left_sz + 1 == k)
            return u;
        // 目标在右子树,减掉左子树+自身数量,继续往右找
        else
        {
            k -= left_sz + 1;
            u = tr[u].s[1];
        }
    }
    return -1;
}

// 中序遍历整棵Splay树,输出原始序列(跳过前后哨兵0和n+1)
void output(int u)
{
    // 递归访问子树前必须下传翻转标记
    pushdown(u);
    // 递归遍历左子树
    if (tr[u].s[0])
        output(tr[u].s[0]);
    // 只输出1~n有效值,屏蔽哨兵节点0、n+1
    if (tr[u].v >= 1 && tr[u].v <= n)
        printf("%d ", tr[u].v);
    // 递归遍历右子树
    if (tr[u].s[1])
        output(tr[u].s[1]);
}

int main()
{
    // n:序列长度,m:翻转操作次数
    scanf("%d%d", &n, &m);

    /*
    插入哨兵 0 ~ n+1
    作用:处理区间边界时不用特判l=1、r=n的情况
    实际有效序列:1,2,3,...,n
    */
    for (int i = 0; i <= n + 1; i++)
        insert(i);

    // 处理m次区间翻转
    while (m--)
    {
        int l, r;
        scanf("%d%d", &l, &r);

        /*
        要截取区间 [l, r]
        第l个节点splay到根,第r+2个节点splay到根的右儿子
        则根右儿子的左子树恰好就是区间[l, r]
        */
        l = get_k(l);
        r = get_k(r + 2);

        splay(l, 0);    // l伸展为根
        splay(r, l);    // r伸展为l的右儿子

        // 给目标区间根节点打上翻转懒标记,区间整体反转
        tr[tr[r].s[0]].flag ^= 1;
    }

    // 中序遍历输出最终序列
    output(root);
    return 0;
}
posted @ 2024-01-18 09:53  毛竹259  阅读(757)  评论(0)    收藏  举报