C. Subset Sums

根号分治的一个经典模型就是,多个集合,集合大小和是 O ( n ) O(n) O(n)的。那么大小大于 n \sqrt n n 的集合个数不会超过 B \sqrt B B ,个数多于 n \sqrt n n 的集合,大小不会超过 n \sqrt n n 。
根号分治的关键就是,划分问题,对于每一类问题,都要找到一个维度是不超过 n \sqrt n n 的,这里对这两类问题,显然第一类的不超过 n \sqrt n n 的维度是集合个数,第二类的是集合大小,分别对这两个维度进行暴力处理即可。
具体来说,我们把问题划分成大集合和小集合两类,这两类都分别有查询和更新,那么我们需要解决的是,一共四个问题,小更新对小查询,小更新对大查询,大更新对小查询,小更新对大查询。
- 比较显然的是,对于小集合,更新直接暴力更新 a i a_i ai,查询直接对 a i a_i ai求和。这就解决了小更新对小查询。
- 大集合更新,不能暴力,比较常见的处理是,用一个懒标记记录增加,查询时,由于大集合只有 n \sqrt n n 个,所以可以枚举所有大集合,累加每个大集合,对当前查询集合的影响。这个影响应该是,大集合和当前集合的交集元素个数,乘上大集合的懒标记。这需要我们预处理每个大集合和所有集合的交集大小。注意这只是集合交集,不是LCS,所以我们用一个临时数组,记录每个大集合包含的元素,然后再去枚举所有集合的元素,检查他们有多少在大集合中,这样对于每个大集合,都需要 ∑ s z i = O ( n ) \sum sz_i=O(n) ∑szi=O(n)的时间,整体预处理需要 O ( n n ) O(n\sqrt n) O(nn )。至此我们解决了大集合,对大小集合的贡献
- 还剩小集合更新对大集合查询的影响,这个可以枚举大集合,直接把当前小集合加,对每个大集合的和的贡献加上,这不是懒标记,需要再开一个数组,新开的这个数组的初始值可以是每个集合的初始 ∑ a i \sum a_i ∑ai
最后是可以调节块长,虽然两部分的理论复杂度都是 O ( n ) O(n) O(n),但是常数不一样,根据均值不等式,这相当于 C 1 N B + C 2 N / B C_1NB+C_2N/B C1NB+C2N/B,那取到最小值的块长 B B B,也就会根据 C 1 , C 2 C_1,C_2 C1,C2这两个常数调整,而不是单纯的 B = n B=\sqrt n B=n
比如这题,注意到我们需要对大集合预处理他们和所有集合的交集大小,也就是大集合的工作量更大,那么为了平衡两类的速度,我们应该把 B B B调大,这样大集合会变少。
c
const int B = 500 + 10;
ll a[N], add[N], sum[N];
int id[N], f[B][N], tmp[N];
void solve() {
int n, m, q;
cin >> n >> m >> q;
int tot = 0;
rep(i, 1, n) {
cin >> a[i];
}
vvi s(m + 1);
rep(i, 1, m) {
int k;
cin >> k;
tot += k;
rep(j, 1, k) {
int x;
cin >> x;
s[i].push_back(x);
sum[i] += a[x];
}
}
int cnt = 0;
rep(i, 1, m) {
if (s[i].size() > B) {
id[++cnt] = i;
for (int pos : s[i]) {
tmp[pos] = 1;
}
rep(j, 1, m) {
int len = 0;
for (int pos : s[j]) {
len += tmp[pos];
}
f[cnt][j] = len;
}
for (int pos : s[i]) {
tmp[pos] = 0;
}
}
}
rep(i, 1, q) {
char op;
cin >> op;
ll k, x;
if (op == '+') {
cin >> k >> x;
if (s[k].size() <= B) {
for (int pos : s[k]) {
a[pos] += x;
}
rep(j, 1, cnt) {
sum[id[j]] += f[j][k] * x;
}
} else {
add[k] += x;
}
} else {
cin >> k;
ll ans = 0;
if (s[k].size() <= B) {
for (int pos : s[k]) {
ans += a[pos];
}
rep(j, 1, cnt) {
ans += f[j][k] * add[id[j]];
}
} else {
ans += sum[k];
rep(j, 1, cnt) {
ans += f[j][k] * add[id[j]];
}
}
cout << ans << '\n';
}
}
}
F. Sum of Progression
调和级数 根号分治

另一类根号分治,就是枚举倍数, n n n以内 x x x的倍数有 n / x n/x n/x个,所以对于需要枚举倍数计算的问题,如果 x x x较大,可以枚举倍数,如果 x x x较小,我们可以把这个较小的阈值设为小于 n \sqrt n n ,那么这样的 x x x个数,在 [ 1 , n ] [1,n] [1,n]梨又是很少的,个数很少,就可以有别的暴力方式。
对于这题,带权的枚举倍数求和。对于较大的 d d d显然可以直接枚举倍数,复杂度 O ( q n / B ) O(qn/B) O(qn/B),对于较小的 d d d,由于没有修改,考虑预处理然后直接查。
预处理什么呢?注意到每次查询的,实际上就是模 d d d等于 l m o d d l\mod d lmodd的所有位置,如果我们把这些位置单拿出来作为一个序列,每次查询就是这个序列上的一个区间,可以维护前缀和。
那么我们需要对于所有模数,所有余数的组合,分别维护前缀和。比较朴素的想法是三维数组 f ( i , j , k ) f(i,j,k) f(i,j,k)表示模数 i i i余数 j j j的前 k k k个位置的元素和,但这样前两个维度的空间就 O ( n ) O(n) O(n)了,第三个维度不能全都开 O ( n ) O(n) O(n),而是需要开 O ( n / i ) O(n/i) O(n/i)。有点麻烦
更简单的方式是, f ( i , j ) f(i,j) f(i,j),模数为 i i i, j j j维度是 O ( n ) O(n) O(n)的我们让不同模数的前缀和都存在这一个 O ( n ) O(n) O(n)的数组上,上面所有形如 l , l + d , l + 2 d l,l+d,l+2d l,l+d,l+2d这些位置,就是一组前缀和,前缀和转移时, i i i从 i − d i-d i−d转移过来。
这里实现上,有一个优化,就是传统前缀和 l − 1 l-1 l−1,最坏也是 0 0 0,不会到负下标,但是这里查询时 l − d l-d l−d可能是负数,为了避免特判,我们可以把这个数组往右平移一点, a i a_i ai,会贡献在 i + d i+d i+d的位置而不是 i i i,那么查询 l , l + k d l,l+kd l,l+kd的查询就是 s [ l + k d ] − s [ l ] s[l+kd]-s[l] s[l+kd]−s[l],不会出现负数下标。
F. Remainder Problem
枚举倍数 根号分治

x x x较大,依然可以直接枚举所有倍数, x x x较小,注意到查询,一次就查所有模数 x x x余数 y y y的位置和,所以我们可以直接用一个数组 f ( i , j ) f(i,j) f(i,j)记录所有模 i i i余数 j j j的位置的元素和,查询时 O ( 1 ) O(1) O(1)查表。更新时,由于是单点更新,可以枚举所有不超过阈值 B B B的模数 m o d mod mod,把当前位置的增加值加上
C. Coloring Game
枚举 双指针 贪心

首先不考虑计数,先分析这个游戏的策略,如果先手确定了三个红色位置,后手应该怎么做,如果蓝色不覆盖一个红色,那么蓝色的应该越大越好,显然应该是 a n a_n an,那么假设的红色是 a i , a j , a k a_i,a_j,a_k ai,aj,ak,先手赢的条件是 a i + a j + a k > a n a_i+a_j+a_k>a_n ai+aj+ak>an,如果覆盖一个红色,比如 a i a_i ai,先手获胜条件是 a i + a j > a k a_i+a_j>a_k ai+aj>ak。
都移项使得左侧只有 i , j i,j i,j,这两个策略分别是 a n − a k , a k a_n-a_k,a_k an−ak,ak,这两个那个大是不一定的,所以后手应该选较大的那个,也就是 max ( a n − a k , a k ) \max(a_n-a_k,a_k) max(an−ak,ak)
回到计数,总的方案数是 O ( n 3 ) O(n^3) O(n3)级别的,肯定不是全枚举,而是枚举一些维度后,剩下的可以快速计算。注意到数据量允许 O ( n 2 ) O(n^2) O(n2)的枚举,那么我们可以枚举先手选的 k k k, k k k值域 [ 1 , n ] [1,n] [1,n],再枚举 j j j,值域 [ 1 , k − 1 ] [1,k-1] [1,k−1]。此时只要确定合法的 i i i个数即可。
注意到这个式子 a i + a j > max ( a k , a n − a k ) a_i+a_j>\max(a_k,a_n-a_k) ai+aj>max(ak,an−ak),我们枚举 k k k了,先把右侧当成定值,接下来从大到小枚举 j j j的话, a j a_j aj单增,那么对于一个 j j j满足条件的 i i i在一个区间内,并且 a j a_j aj越大,这个区间的下界越小,也就是有单调性,这显然可以双指针,只不过是相向双指针。
接下来就是经典的相向双指针求 a l + a r > C a_l+a_r>C al+ar>C的 ( l , r ) (l,r) (l,r)个数
两种实现,比较经典的实现是,用一个 w h i l e while while,里面用分支决定应该移动哪个指针。对于一个 r r r,如果 a l + a r > C a_l+a_r>C al+ar>C,那么 [ l , r − 1 ] [l,r-1] [l,r−1]内的所有下标都是合法的。这相当于每次移动一次 r r r,然后调整 l l l直到合法
c
void solve() {
int n;
cin >> n;
vi a(n + 1);
rep(i, 1, n) {
cin >> a[i];
}
int ans = 0;
rep(i, 3, n) {
int l = 1, r = i - 1;
int t = max(a[i], a[n] - a[i]);
while (l < r) {
if (a[l] + a[r] > t) {
ans += r - l;
r--;
} else {
l++;
}
}
}
cout << ans << '\n';
}
另一种做法是枚举 l l l,调整 r r r直到合法。对于一个 l l l,如果 a l + a r > C a_l+a_r>C al+ar>C,那么 [ r , k − 1 ] [r,k-1] [r,k−1]内的下标都合法。但这样的话, l > r l>r l>r了,仍然有方案是合法的,只不过这些合法下标还需要满足大于 l l l,最后写起来边界有点麻烦。不如前一种写法
c
void solve() {
int n;
cin >> n;
vi a(n + 1);
rep(i, 1, n) {
cin >> a[i];
}
int ans = 0;
rep(i, 3, n) {
for (int j = 1, k = i - 1; j < i - 1; j++) {
while (j < k && a[j] + a[k] > max(a[i], a[n] - a[i])) {
k--;
}
ans += min(i - k - 1, i - j - 1);
}
}
cout << ans << '\n';
}