浅谈线段树、树状数组和分块

都要用到分配律,不满足的不能使用这种算法。

线段树

其实树状数组是比线段树简单的。但是因为树状数组能做的,线段树也能做,但是线段树能做树状数组不能做的。

建树

用一张图表示线段树:

呃呃呃可能有点抽象,把它改成线段就好理解了(但是上面这份图对于熟悉的人更清晰):
诶太丑了不放了,大家理解就好了(
其实和普通二叉树一样,只是线段树的每个节点存储的是一个区间,也就是这个区间的信息(和、积等)能通过子节点推出来。
给出建树代码:

点击查看代码
void build(int i , int l , int r){
	tree[i] = {l,r,0,0};
	if(l==r){
		tree[i].sum = input[l];
		return;
	}
	int mid = (l + r) / 2;
	build(2*i,l,mid);
	build(2*i+1,mid+1,r);
	tree[i].sum = tree[2*i].sum + tree[2*i+1].sum;
}

这里我用的是结构体,方便一点,还有一种写法是在每次区间操作时重新计算两个端点,更节省空间。

区间修改

用区间加举例。
显然我们知道 \(a\times k=a\times (k/2)+a\times (k/2)\)。那么就可以对于要操作的区间,如果不能全部覆盖,那么传给它的子节点操作,然后重新计算该节点的值。
也很好理解,对吧。不过有一个问题,如果一个区间被完全覆盖,那么它的子节点的区间也要改变,但是实际上并不需要一直修改到最低端,这时候就要用到懒标记(\(lazytage\),简称 \(lz\))了。

懒标记

这就是线段树的精髓。懒标记思想就是要用的时候再下传,即不用的时候就放在那里,节省时间。注意懒标记要可以叠加,如果有多种区间操作记得分清先后顺序。
给处代码实现(包含区间加):

点击查看代码
void push_down(int i){
	if(!tree[i].lz)return;
	int k = tree[i].lz;
	tree[2*i].sum += (tree[2*i].r - tree[2*i].l + 1) * k , tree[2*i+1].sum += (tree[2*i+1].r - tree[2*i+1].l + 1) * k;
	tree[2*i].lz += k , tree[2*i+1].lz += k;
	tree[i].lz = 0;
	return;
}
void add(int i , int l , int r , int k){
	if(tree[i].l>=l&&tree[i].r<=r){
		tree[i].sum += (tree[i].r - tree[i].l + 1) * k , tree[i].lz += k;
		return;
	}<details>
	push_down(i);
	if(tree[2*i].r>=l)add(2*i,l,r,k);
	if(tree[2*i+1].l<=r)add(2*i+1,l,r,k);
	tree[i].sum = tree[2*i].sum + tree[2*i+1].sum;
}

区间查询

思想和区间加差不多,也是没有完全被覆盖就传给子节点操作,最后把区间和全部加起来。

点击查看代码
int search(int i , int l , int r){
	if(tree[i].l>=l&&tree[i].r<=r)return tree[i].sum;
	push_down(i);
	int sum = 0;
	if(tree[2*i].r>=l)sum += search(2*i,l,r);
	if(tree[2*i+1].l<=r)sum += search(2*i+1,l,r);
	return sum;
}

动态开点线段树

不是很常用但是有用。思想就是尽量少开节点,需要用在开新的节点。
注意这里需要记录左子结点的右子节点的编号。
需要用到标记永久化的思想。

树状数组

树状数组的码量比线段树少,也简单一些。

其中 \(c\) 就是树状数组。我们先不关心左边界,感受一下树状数组的工作原理:
假设要求 \(3\sim 5\) 的元素和,也就是求 \(1\sim 5\) 的元素和 \(-1\sim 2\) 的元素和。
首先找到 \(c_5\) 管辖 \(5\sim 5\),加入计数器,往前跳到 \(4\),发现 \(c_4\) 管辖 \(1\sim 4\),加入计数器,跳到到 \(0\),超出边界,结束。最后 \(1\sim 5\) 的和 \(=c_4+c_5\)
同理 \(1\sim 5\) 的和 \(=c_2\),相减即可。

管辖区间

树状数组规定 \(c_x\) 的管辖区间长度为 \(2^k\),表示 \(x\) 的二进制中最后一个 \(1\) 和后面所有 \(0\) 组成的二进制数。

lowbit

上面被称为“\(x\)\(lowbit\)”,具体求法:
我们知道 \(-x\) 的二进制编码为 \(\sim x+1\),即对 \(x\) 的二进制编码按位取反,然后 \(+1\)
例如 \(6\)110,则 \(-6\)010按位与一下 \(=\) 010,就求出来了 \(6\)\(lowbit\)
好的 \(x\)\(lowbit=x\& (-x)\)

区间查询

一直往前跳 \(lowbit\) 就好。

点击查看代码
int search(int x){
	int ans = 0;
	for(;x>0;x-=lowbit(x))ans += tree[x];
	return ans;
}

单点修改

只需要修改管辖 \(i\) 能对区间查询产生贡献的所有位置就行了,一直往后跳 \(lowbit\)

点击查看代码
void add(int i , int x){
	for(;i<=n;i+=lowbit(i))tree[i] += x;
}

区间修改

一般用到差分的思想,即只修改 \(l\)\(r+1\),注意这里区间查询最后的输出也要改变一下。

建树

n次单点修改。

二维树状数组

再多加一个维度就好,修改或查询也是跳 \(lowbit\)

权值线段树

一个序列 \(a\) 的权值数组 \(b\),满足 \(b[x]\) 的值为 \(x\)\(a\) 中的出现次数。例如:\(a = (1, 3, 4, 3, 4)\) 的权值数组为 \(b = (1, 0, 2, 2)\)。很明显,\(b\) 的大小和 \(a\) 的值域有关。
若原数列值域过大,且重要的不是具体值而是值与值之间的相对大小关系,常离散化原数组后再建立权值数组。
另外,权值数组是原数组无序性的一种表示:它重点描述数组的元素内容,忽略了数组的顺序,若两数组只是顺序不同,所含内容一致,则它们的权值数组相同。

单点修改,查询全局第 \(k\)

在此处只讨论第 \(k\) 小,第 \(k\) 大问题可以通过简单计算转化为第 \(k\) 小问题。
该问题可离散化,如果原序列 \(a\) 值域过大,离散化后再建立权值数组 \(b\)。注意,还要把单点修改中的涉及到的值也一起离散化,不能只离散化原数组 \(a\) 中的元素。
对于单点修改,只需将对原数列的单点修改转化为对权值数组的单点修改即可。具体来说,原数组 \(a[x]\)\(y\) 修改为 \(z\),转化为对权值数组 \(tree\) 的单点修改就是 \(tree[y]\) 单点减 \(1\)\(b[z]\) 单点加 \(1\)
对于查询第 \(k\) 小,考虑二分 \(x\),查询权值数组中 \([1, x]\) 的前缀和,找到 \(x_0\) 使得 \([1, x_0]\) 的前缀和 \(< k\)\([1, x_0 + 1]\) 的前缀和 \(\ge k\),则第 \(k\) 大的数是 \(x_0 + 1\)(注:这里认为 \([1, 0]\) 的前缀和是 \(0\))。
这样做时间复杂度是 \(O(log^2n)\) 的。
考虑用倍增替代二分。
\(x = 0\)\(sum = 0\),枚举 \(i\)\(log_2n\) 降为 \(0\)
查询权值数组中 \([x + 1 \ldots x + 2^i]\) 的区间和 \(t\)。如果 \(sum + t < k\),扩展成功,\(x \gets x + 2^i\)\(sum \gets sum + t\) 否则扩展失败,不操作。
这样得到的 \(x\) 是满足 \([1 \ldots x]\) 前缀和 \(< k\) 的最大值,所以最终 \(x + 1\) 就是答案。
看起来这种方法时间效率没有任何改善,但事实上,查询 \([x + 1 \ldots x + 2^i]\) 的区间和只需访问 \(tree[x + 2^i]\) 的值即可。
如此一来,时间复杂度降低为 \(O(log n)\)

全局逆序对

首先需要离散化。
接下来这样做:(倒序枚举,设 \(a_i=x\)):

  • 查询 \([1,\ldots,x-1]\) 的和,答案加上这个数。
  • \(tree[x]\) 加一。

这种很容易证明,在这里我就不证了。不严格逆序(\(i<j,a[i]\ge a[j]\),注意在这里 \(i \le j\)\(i<j\) 不等价)对稍改一下就可以。

分块

分块的思想就是把序列分成很多快操作。
接下来统一块长为 \(S\)

修改

如果区间在同一块中则暴力修改,最坏 \(O(S)\)
否则分成两段未被完全覆盖的段(暴力修改)和其他被完全覆盖的段(整体修改),最坏 \(O(max(n/s,s))\)

查询

和修改思想一样,未被完全覆盖就暴力查询,否则直接求整体。
不难发现块长最优为 \(\sqrt n\)

点击查看代码
int op , l , r , k;cin >> op >> l >> r >> k;
if(op==0){
	if(id[l]==id[r]){
		for(int i=l;i<=r;i++)a[i] += k , s[id[l]] += k;
	}else{
		int i;
		for(i=l;id[i]==id[l];i++)a[i] += k , s[id[l]] += k;
		for(;i+b<r;i+=b)sum[id[i]] += k , s[id[i]] += b * k;
		for(;i<=r;i++)a[i] += k , s[id[i]] += k;
	}
}else{
	int ans = 0;
	if(id[l]==id[r]){
		for(int i=l;i<=r;i++)ans += a[i] + sum[id[i]];
	}else{
		int i;
		for(i=l;id[i]==id[l];i++)ans += a[i] + sum[id[i]];
		for(;i+b<r;i+=b)ans += s[id[i]];
		for(;i<=r;i++)ans += a[i] + sum[id[i]];
	}
	cout << ((ans % (k + 1) + (k + 1)) % (k + 1)) << "\n";
}
posted @ 2025-09-18 21:28  虚空远行者  阅读(26)  评论(1)    收藏  举报