线段树的时间和空间优化

线段树优化技巧详解

前言

这篇文章拖得稍微有点久了,今天写线段树的时候突然想起这件事,现在来填坑来了。

承接上文

正文

众所周知,线段树不管是时间还是空间都有四倍常数。这个空间的四倍常数我没法给你砍掉,但时间上的还是可以的(虽然说没法给你砍完吧,至少能砍一点嘛),我们一次来讲一下有那些好用的优化。

I/O 优化

首先最容易想到的就是输入输出优化。输入输出优化有很多种,我们挨个来看看。

格式输入输出

scanfprintf 是 C++ 中的另外一种 I/O(Input/Output)方式,在 cincout 面前他们就要快很多。原因很简单:因为格式输入输出本来就是 C 语言的输入输出方式。在这之后 C++ 为了方便就发明了 cincout,但是 cincout 之前要先流同步,这样你才能在一份代码里使用格式输入输出和流输入输出两种方式,不然 C 和 C++ 的输入输出方式没同步,在一份代码里用两种不同的方式是会 WA 的。

当然,你也可以用一种很阴的方式……

关闭流同步

前面我们说了:cincout 在执行之前会先流同步,不然可能会出错。当然,这里你可以通过代码手动关闭流同步,这样 cincout 就是独立于 scanfprintf 的输入输出方式,这时因为没有了同步过程速度就会快很多。

当然,因为你关闭了流同步,所以这份代码里你不能再用 scanfprintf 这种输入输出方式。

要实现关闭流同步,只需要在你的代码里写下这样一句话:

ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);

快读快输

有的时候关闭流同步也卡不过,这时就要采用快读快输了。

快读快输的本质其实就是把数字拆成很多位读入进来(输出出去),然后把这些数又拼在一起变成原来的数。

这听上去感觉没怎么优化,实际上它的优化功能很强大。因为在 C++ 中有一个很神秘的规定:字符的读入速度一定比数字快。也就是说 getchar(读入单个字符的工具)一定会比 cin 快。但是问题就是它只能读入单个字符。这时就回到我们上面说的快读的本质了。

所以快读的读入方式不是读入数字,而是读入字符。这也就是为什么一位一位读入然后拼在一起会这么快了。

快输也是一样的:putchar(输出单个字符)的速度比 cout 的速度要快,因此我们就拆成一个字符一个字符的输出出去,从而实现快速输出。

代码:

int read()//快速读入
{
	int z=0,f=1;//z 是数字,f 是符号
	char c=getchar();//读入单个字符
	while(c<'0'||c>'9')//c 不是一个数字字符
	{
		if(c=='-')//如果是负号,说明这是一个负数
		{
			f=-1;//改符号
		}
		c=getchar();//继续读入
	}
	while(c>='0'&&c<='9')//是数字字符
	{
		z=z*10+c-'0';//算这个数是多少
		c=getchar();//继续读入
	}
	return z*f;//数字乘符号
}
void write(int x)
{
	if(x<0)//判断正负,不然负数在拆位的时候会出一点问题
	{
		putchar('-');//扔个负号
		x=-x;//变相反数,也就是变成正数
	}
	static int top=0,stk[106];//使用栈,因为最终输出是从高到低输出,但拆位是从低到高拆
	while(x)//拆位
	{
		stk[++top]=x%10;
		x/=10;
	}
	if(!top)//如果 x=0
	{
		stk[++top]=0;
	}
	while(top)//输出出去
	{
		putchar(char(stk[top--]+'0'));
	}
}

fread,fwrite

freadfwrite 是一种更高级的优化方法,当然效果也十分显著。他们主要的优化方向是 getcharputchar

简单说一下:就是利用 fread 函数把要输入进来的东西扔到一个内存条里,然后每次 getchar 的时候就从内存条中取出来(这里要重写 getchar,建议直接设一个函数)。输出也是一样的:把要输出出去的东西扔到一个内存条中,最后统一输出出去。由于实现过分复杂,而且一般情况下不会用到它,这里就只放代码,感兴趣的同学可以去 OI-wiki 上学习。

代码:

struct IO{
	char buf[1<<20],*p1,*p2;
	char pbuf[1<<20],*p3;
	IO():p1(buf),p2(buf),p3(pbuf){
	}
	~IO()
	{
		fwrite(pbuf,1,p3-pbuf,stdout);
	}
	char getc()
	{
		if(p1==p2)
		{
			p2=(p1=buf)+fread(buf,1,1<<20,stdin);
		}
		return p1==p2?' ':*p1++;
	}
	void putc(const char &c)
	{
		if(p3-pbuf==(1<<20))
		{
			fwrite(pbuf,1,1<<20,stdout);
			p3=pbuf;
		}
		*p3++=c;
	}
	int read()
	{
		int z=0,f=1;
		char c=getc();
		while(c<'0'||c>'9')
		{
			if(c=='-')
			{
				f=-1;
			}
			c=getc();
		}
		while(c>='0'&&c<='9')
		{
			z=z*10+c-'0';
			c=getc();
		}
		return z*f;
	}
	void write(int x)
	{
		if(x<0)
		{
			putc('-');
			x=-x;
		}
		static int top=0,stk[106];
		while(x)
		{
			stk[++top]=x%10;
			x/=10;
		}
		if(!top)
		{
			stk[++top]=0;
		}
		while(top)
		{
			putc(char(stk[top--]+'0'));
		}
		putc('\n');
	}
}io;

函数优化

众所周知:递归函数的常数非常大。而在线段树中几乎全都是递归函数。因此有一种非常神秘的方法就是:递归改递推。

当然,这种方法在对区间进行操作的时候基本没法用(写是可以写出来的,就是很复杂)。就只有单点修改和单点查询里可以这样干。但即便如此在值域很大的时候能优化的常数也不小了。

代码这里不做展示。

离散化

离散化是一种非常高效有用的优化方式,写起来也不复杂。

比如说你现在要以 a i a_i ai 为编号建一棵线段树(这种线段树的名字其实叫权值线段树,不过和线段树本质一样),但是有个问题: a i a_i ai 最大 1 0 9 10^9 109。用 a i a_i ai 建线段树根本不可能。这时你看了眼 n ≤ 1 0 5 n\le10^5 n105。瞬间想到了可以用离散化来解决这个问题。

离散化其实就是通过排序加去重两个操作让每个数都能有唯一的一个编号,然后通过这个编号来建树。比如说我们上面的这个例子:如果你直接建树空间复杂度就会是 4 ⋅ 1 0 9 4\cdot10^9 4109(不 MLE 我吃),非常大。但如果用了离散化,那么每个数有一个对应的编号,而这个编号最大一定不超过 n n n。因此这时的空间复杂度就是 4 n 4n 4n,瞬间缩小了一万倍!然后我们就可能用这个编号来建树了。

我相信有的读者会有一个问题:我这个编号为什么能替代原来的值?因为线段树的本质就是二分,所以线段树建完后编号一定是有序的,也就是我上面的那棵权值线段树的 a i a_i ai 最后一定是从小到大的(权值线段树的特性:自带排序功能),那如果这个编号不是从小到大的,那不就炸了吗?这个问题其实非常的简单:你离散化的时候就已经从小到大排序了,所以最后的编号的单调性一定与原本的 a i a_i ai 的单调性一致,那不就完了。

总之,离散化是一种非常好的优化方式。尤其是在权值线段树的领域里,它可以把 O ( 4 max ⁡ { a i } ) O(4\max\{a_i\}) O(4max{ai}) 的空间复杂度变成 O ( 4 n ) O(4n) O(4n),把 O ( 4 log ⁡ max ⁡ { a i } ) O(4\log\max\{a_i\}) O(4logmax{ai}) 的时间复杂度改成 O ( 4 log ⁡ n ) O(4\log n) O(4logn)。总之它很牛逼就对了。

当然使用前提必须是离散化之后不该原本的特性。

代码自己写。

结构体

我相信有些 OIer 写线段树是这样写的:

struct seg_tree{
	int l[4*N],r[4*N],...;
	...
};

这其实并不是一个很好的习惯。

看看我之前的线段树是怎么写的:

struct seg_tree{
	struct Node{
		int l,r,...;
	}node[4*N];
	...
};

这么写的原因要从 CPU 讲起。

众所周知, 电脑底层实际上有一块 CPU 和一块内存。内存的容量很大,用来存储大量的数据。CPU 要算的时候就从内存中找数据。

但是正是因为内存容量很大的原因,导致内存的寻址特别慢,但 CPU 的运算又特别快,因此 CPU 和内存的速度就没法同步,容易导致数据丢失。因此在 CPU 里面就多加了一个小东西:Cache。

Cache(高速缓存)的作用就是用来同步 CPU 和内存的速度的。它的容量并不大,可能只有几十 MB。在 CPU 要进行计算的时候,内存就会先把数据扔给 Cache,然后 Cache 就会把数据给到 CPU 里面的运算器,运算器再返回给 Cache,这时 Cache 会先短暂的保存这份数据,防止下次再用。直到运算结束之后再把数据扔给内存。

因为 Cache 可快可慢,所以它就能很好的同步运算器和内存之间的速度。

回到这里:为什么使用结构体比数组要好呢?

原因很简单:因为结构体把这些东西绑在一起。

如果说你是一个一个的数组,那么在查找的时候就会在内存里找多次,而且因为数组本身占用内存比较大,所以不是所有的数组都能被保存在 Cache 里面。而结构体就不一样了:它把这些东西绑在一起,那么它们就可以成组保存在 Cache 里面,这样 CPU 速度就会更快。

因此写结构体比写数组的速度会快其实是真的。这是一个习惯上的问题,建议大家还是写结构体。

C++ 函数

最后就是利用 C++ 里的函数来帮忙卡常。

这里列举几种:

  • inline:内联,写在函数前面,即类似于 inline void query(int pos,int x)。主要是尝试把这个函数嵌入到调用这个函数的地方从而减少调用函数的开销。注意:只是尝试,编译器有权利忽略掉它。
  • __attribute__((always_inline)):强制内联,写在函数前面。主要是强行将函数嵌入到调用函数的地方。当然也不是任何时候都可以用。
  • const:常量关键字。在 CPU 中对 const 关键字有专门的优化,可以提升效率。
  • constexpr:编译期计算,写在函数前面。可以让该函数内的运算在编译期间就计算完,减少运行时的时间,一般用在逻辑简单的函数前面。
  • noexcept:减少异常处理的开销(异常处理后面再讲),写在函数前面,声明这个函数不会使用异常处理,从而减少异常处理的开销。
  • template<typename T>:模版,一般会自动开启内联优化。
  • register: 写在变量前面,即类似于 register int a,主要作用是把这个变量放在 Cache 中方便下一次使用,但是优化效果不明显。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值