分块与根号分治

分块 \(=\) 优雅的暴力。

简单来说,将一个序列分成 \(\sqrt{n} \sim \sqrt{n} + 1\) 个区间,每个区间单独处理。

这么分主要为了使这些序列的长度平均一点,这样可以保证复杂度稳定 \(O(\sqrt{n})\)

用一个例题来讲一下:

数列分块入门 1

简化题意

对于 \(0\) 操作,位于 \([l,r]\) 之间的数都加 \(c\)

对于 \(1\) 操作,询问 \(a_r\) 的值(不看 \(l\)\(c\))。

做法

我们将这个序列分成 \(\sqrt{n} \sim \sqrt{n} + 1\) 个区间(如果直接分 \(\sqrt{n}\) 个分不够就多分一个),每个区间维护一个 \(add\) 表示这个区间加了多少。

那么对于操作 \(0\),我们发现可以将其分成三个部分(或者更少):

  1. 左边不完整块(完整不管)
  2. 中间完整块
  3. 右边不完整块(完整不管)

那么我们可以分别对这三个部分操作。

对于左边不完整块,我们直接暴力将区间多出来的数值加 \(c\) 就行了。

对于中间完整块,我们将每个区间对应的 \(add\) 加上 \(c\)。(等同于懒标记)

对于右边不完整块,同左边不完整块。

那么最后的答案就是 \(a_r + add_{pos_r}\),这里的 \(pos\) 是指的下标为 \(r\) 的数所在的区间,而我们没有给区间中的每个数值直接加的要返还回去,所以不要忘记加上区间的标记 \(add\)

点戳我看代码喵~
int n;
int a[N], pos[N], ans[N], add[N];
int sum, block;

void work(int l, int r, int x) {
	int cnt = 0;
	for (int i = l; i <= r; i++) cnt += a[i];
	ans[x] = cnt;
}

void update_add(int l, int r, int k) { //区间加
	for (int i = l; i <= min(pos[l] * block, r); i++) a[i] += k; //处理左边不完整块
	if (pos[l] != pos[r]) for (int i = (pos[r] - 1) * block + 1; i <= r; i++) a[i] += k; //处理右边不完整块
	for (int i = pos[l] + 1; i <= pos[r] - 1; i++) add[i] += k; //直接整块打lazy
}

signed main() {
	n = re;
	for (int i = 1; i <= n; i++) a[i] = re;
	block = (int)sqrt(n); //块的个数
	sum = n / block; //每个块的长度
	if (n % block) sum++;
	for (int i = 1; i <= n; i++) pos[i] = (i - 1) / block + 1;
	for (int i = 1; i <= sum; i++) work((i - 1) * block + 1, i * block, i);
	for (int i = 1; i <= n; i++) {
		int op = re, l = re, r = re, c = re;
		if (op == 1) wr(a[r] + add[pos[r]]), endl; //不要忘记加回去
		else update_add(l, r, c);
	}
}

数列分块入门 2

简化题意

\(\mathrm{opt} = 0\),表示将位于 \([l, r]\) 的之间的数字都加 \(c\)

\(\mathrm{opt} = 1\),表示询问 \([l, r]\) 中,小于 \(x=c^2\) 的数字的个数。

做法

区间加上面有,这里不讲了(后面只会讲不同之处)

对于 \(\mathrm{opt} = 1\),我们需要使用一个 \(b\) 数组来存储排序后的数组 \(a\),在 \(\mathrm{opt} = 0\) 时,操作完后都要重新更新 \(b\) 数组,这样会方便我们使用 lower_bound

依然分成三个部分操作。

对于左边不完整块,我们直接枚举区间内的数值,如果符合条件,即 \(a_i + add_{pos_i} \lt x\),那么就统计进去。

对于中间完整块,我们直接对于区间 lower_bound 出来 \(x - add_i\),也就是找到大于等于 \(x\) 的第一个数的下标,那么剩下的就是用这个 lower_bound 出来的下标减去区间的开头。

对于右边不完整块,同左边不完整块。

戳我看代码喵~
int n;
int a[N], b[N], pos[N], ans[N], add[N];
int sum, block;

void update(int x) {
	for (int i = (x - 1) * block + 1; i <= min(x * block, n); i++) b[i] = a[i];
	sort(b + (x - 1) * block + 1, b + min(x * block, n) + 1);
}

void work(int l, int r, int x) {
	int cnt = 0;
	for (int i = l; i <= r; i++) cnt += a[i];
	ans[x] = cnt;
}

void update_add(int l, int r, int k) { //区间加
	if (pos[l] == pos[r]) {
		for (int i = l; i <= r; i++) a[i] += k;
		update(pos[l]);
	}
	else {
		for (int i = l; i <= pos[l] * block; i++) a[i] += k;
		update(pos[l]);
		for (int i = pos[l] + 1; i <= pos[r] - 1; i++) add[i] += k;
		for (int i = (pos[r] - 1) * block + 1; i <= r; i++) a[i] += k;
		update(pos[r]);
	}
}

int query(int l, int r, int k) { //查询小于k的数的个数
	int cnt = 0;
	if (pos[l] == pos[r]) {
		for (int i = l; i <= r; i++) if (a[i] + add[pos[l]] < k) cnt++;
	}
	else {
		for (int i = l; i <= pos[l] * block; i++) if (a[i] + add[pos[l]] < k) cnt++;
		for (int i = pos[l] + 1; i <= pos[r] - 1; i++) cnt += lower_bound(b + (i - 1) * block + 1, b + i * block + 1, k - add[i]) - b - (i - 1) * block - 1;
		for (int i = (pos[r] - 1) * block + 1; i <= r; i++) if (a[i] + add[pos[r]] < k) cnt++;
	}
	return cnt;
}
signed main() {
	n = re;
	for (int i = 1; i <= n; i++) a[i] = b[i] = re;
	block = (int)sqrt(n); //块的个数
	sum = n / block; //每个块的长度
	if (n % block) sum++;
	for (int i = 1; i <= n; i++) pos[i] = (i - 1) / block + 1;
	for (int i = 1; i <= sum; i++) sort(b + (i - 1) * block + 1, b + min(i * block, n) + 1);
	for (int i = 1; i <= sum; i++) work((i - 1) * block + 1, min(i * block, n), i);
	for (int i = 1; i <= n; i++) {
		int op = re, l = re, r = re, c = re;
		if (op == 1) wr(query(l, r, c * c)), endl;
		else update_add(l, r, c);
	}
}

总结一下

分块是一种优雅的暴力,他通过分成 \(\sqrt{n} \sim \sqrt{n} + 1\) 个区间,保证了每个区间长度的均衡,保证了每次操作的复杂度大约为 \(O(\sqrt{n})\)

根号分治

一种思想,对于值域为 \(x \le \sqrt{n}\) 的范围使用一种算法。而对于 \(x > \sqrt{n}\) 的范围又是另一种算法。

直接用一道例题来讲解一下吧。

P3645 [APIO2015] 雅加达的摩天楼

这道题我们可以想到直接bfs,但是我们需要剪枝,对于每栋楼,我们其实只需要遍历一次,而对于一个 doge 的位置,我们也只需要跳一次,因为多跳的话没有任何的用。

这很好想,但是时间复杂度怎么证明呢?

我们很容易想到时间复杂度就是我们 bfs 时队列的大小,也就是对应的状态个数。

那么我们分两种情况。

对于 \(p \le \sqrt{n}\) 的情况:

我们发现对于位置状态最多有 \(n\) 个,也就是 \(p = 1\)

而它的可以跳的距离状态就是 \(\sqrt{n}\) 个,所以,对于 \(p \le \sqrt{n}\) 的情况,时间复杂度为 \(O(n\sqrt{n})\)

而对于 \(p > \sqrt{n}\) 的情况:

对于位置的状态为 \(\lfloor \frac{n}{p} \rfloor + 1\),其实对应的就是 \(x \pm p, x \pm 2p, x \pm 3p, \dots, x \pm kp\)

那么

\[\lfloor \frac{n}{p} \rfloor + 1 \le \frac{n}{p} + 1 \]

\(p > \sqrt{n}\),那么:

\[\frac{n}{p} < \frac{n}{\sqrt{n}} = \sqrt{n} \]

而对应的跳的距离状态最多也就 \(m\) 个,所以,对于 \(p > \sqrt{n}\) 的情况,时间复杂度为 \(O(m\sqrt{n})\)

所以最后的时间复杂度就为:\(O((n+m)\sqrt{n})\)

戳我看代码喵~
queue<tuple<int, int, int> > q;
bitset<N> vis[N], f;
vector<int> dog[N];

void update(int x, int p, int step) {
	if (!f[x]) {
		f[x] = 1;
		for (auto i : dog[x]) if (!vis[x][i]) vis[x][i] = 1, q.push({x, i, step});
	}
	if (!vis[x][p]) vis[x][p] = 1, q.push({x, p, step});
}

signed main() {
	int s = 0, t = 0, n = re, m = re;
	for (int i = 0; i < m; i++) {
		int b = re, p = re;
		if (!i) s = b;
		if (i == 1) t = b;
		dog[b].push_back(p);
	}
	if (s == t) return puts("0"), 0;
	f[s] = 1;
	for (auto i : dog[s]) if (!vis[s][i]) vis[s][i] = 1, q.push({s, i, 0});
	while (!q.empty()) {
		auto [x, p, step] = q.front(); q.pop();
		if (x - p == t || x + p == t) return wr(step + 1), endl, 0;
		if (x - p >= 0) update(x - p, p, step + 1);
		if (x + p < n) update(x + p, p, step + 1);
	}
	puts("-1");
}

总结一下:

根号分治本质思想是对于不同的范围使用不同的算法来稳定复杂度到 \(\sqrt{n}\) 的好想法!

posted @ 2026-07-20 17:05  OiLight  阅读(8)  评论(0)    收藏  举报