# RGB 三元组求和(线段树 + 分治合并)
RGB 三元组求和(线段树 + 分治合并)
一、题目描述
给定一个长度为 N 的非负整数序列 A,以及一个由 R, G, B 组成的字符串 S。
需要处理 Q 次操作:
1 i x:将A[i]修改为x。2 l r:查询在区间[l, r]中,所有满足l <= i < j < k <= r且S[i]='R', S[j]='G', S[k]='B'的A[i] * A[j] * A[k]之和,结果对998244353取模。
对应题目:AtCoder 线段树分治应用题E
二、核心思路:线段树的分治与可合并性
这道题如果暴力做,每次查询都要枚举所有三元组,显然会超时。使用线段树维护区间信息是标准做法。
线段树本质上是一种分治结构,它的精髓在于:如果我们能高效地将左右两个子区间的答案合并(Merge)得到父区间的答案,那么就能在 O(log n) 的时间内完成更新和查询。
因此,我们的核心任务是:设计线段树节点需要维护哪些信息,才能支持这种合并?
2.1 节点信息设计
对于一个区间,我们需要维护以下 6 种信息(对应代码中的结构体):
R:区间内所有S[i]='R'的A[i]之和。G:区间内所有S[i]='G'的A[i]之和。B:区间内所有S[i]='B'的A[i]之和。RG:区间内所有满足i < j、S[i]='R'、S[j]='G'的A[i] * A[j]之和。GB:区间内所有满足i < j、S[i]='G'、S[j]='B'的A[i] * A[j]之和。RGB:区间内所有满足i < j < k、S[i]='R'、S[j]='G'、S[k]='B'的A[i] * A[j] * A[k]之和。
三、Merge 函数的推导(分治合并)
假设我们已经有了左半区间 L 和右半区间 R 的信息,如何推导出合并后的大区间信息?
对于单元素信息,直接相加即可:
res.R = L.R + R.Rres.G = L.G + R.Gres.B = L.B + R.B
关键在于跨区间的贡献:
1. 合并 RG
一个 RG 对要么都在左边,要么都在右边,要么R在左边、G在右边。
- 跨区间的贡献为:左区间的
R之和乘上右区间的G之和,即L.R * R.G。 res.RG = L.RG + R.RG + L.R * R.G
2. 合并 GB
同理,跨区间的贡献为:左区间的 G 之和乘上右区间的 B 之和。
res.GB = L.GB + R.GB + L.G * R.B
3. 合并 RGB
现在把左儿子区间 [l, mid] 记作 L,右儿子区间 [mid+1, r] 记作 R。合并后的区间是 [l, r]。
合并后的 RG、GB、RGB 里的每一项,按下标分布只有两种可能:
全部落在左区间 → 左儿子已经统计过
全部落在右区间 → 右儿子已经统计过
跨区间 → 左边取一部分,右边取一部分,左区间的下标一定小于右区间的下标
跨区间:分为两种切法:
R在左,G和B在右:贡献为L.R * R.GBR和G在左,B在右:贡献为L.RG * R.B
所以合并公式一定是:
合并后的量 = 左儿子的量 + 右儿子的量 + 跨区间的新贡献
`res.RGB = L.RGB + R.RGB + L.R * R.GB + L.RG * R.B`
四、AC 代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 2e5 + 5;
const ll MOD = 998244353;
ll a[N];
struct node
{
int l = 0, r = 0;
ll R = 0, G = 0, B = 0;
ll RG = 0, GB = 0;
ll RGB = 0;
} seg[N << 2];
string s;
// 核心分治合并函数
node merge(node L, node R)
{
node res;
res.l = L.l, res.r = R.r;
res.R = (L.R + R.R) % MOD;
res.G = (L.G + R.G) % MOD;
res.B = (L.B + R.B) % MOD;
res.RG = (L.RG + R.RG + L.R * R.G) % MOD;
res.GB = (L.GB + R.GB + L.G * R.B) % MOD;
// RGB 合并:左RGB + 右RGB + (左R * 右GB) + (左RG * 右B)
res.RGB = (L.RGB + R.RGB + L.R * R.GB + L.RG * R.B) % MOD;
return res;
}
void push_up(int p)
{
seg[p] = merge(seg[p << 1], seg[p << 1 | 1]);
}
void build(int p, int L, int R)
{
seg[p].l = L, seg[p].r = R;
if (L == R)
{
if (s[L] == 'R') seg[p].R = a[L] % MOD;
else if (s[L] == 'G') seg[p].G = a[L] % MOD;
else seg[p].B = a[L] % MOD;
return;
}
int mid = (L + R) >> 1;
build(p << 1, L, mid);
build(p << 1 | 1, mid + 1, R);
push_up(p);
}
node query(int p, int L, int R)
{
int l = seg[p].l, r = seg[p].r;
if (L <= l && r <= R) return seg[p];
int mid = (l + r) >> 1;
node resl, resr;
// 注意:如果查询区间只包含一侧,直接返回那一侧的结果,不需要 merge 空节点
if (L <= mid && R <= mid) return query(p << 1, L, R);
if (L > mid && R > mid) return query(p << 1 | 1, L, R);
resl = query(p << 1, L, R);
resr = query(p << 1 | 1, L, R);
return merge(resl, resr);
}
void update(int p, int pos, ll k)
{
int l = seg[p].l, r = seg[p].r;
if (l == r)
{
// 清空原来的值,防止多次修改叠加
seg[p].R = seg[p].G = seg[p].B = 0;
if (s[l] == 'R') seg[p].R = k;
else if (s[l] == 'G') seg[p].G = k;
else seg[p].B = k;
return;
}
int mid = (l + r) >> 1;
if (pos <= mid) update(p << 1, pos, k);
else update(p << 1 | 1, pos, k);
push_up(p);
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
int n, q;
cin >> n >> q;
for (int i = 1; i <= n; i ++) cin >> a[i];
cin >> s;
s = " " + s; // 1-indexed
build(1, 1, n);
int op;
while (q --)
{
cin >> op;
if (op == 1)
{
int i;
ll x;
cin >> i >> x;
update(1, i, x % MOD);
a[i] = x % MOD;
}
else
{
int l, r;
cin >> l >> r;
node res = query(1, l, r);
cout << res.RGB << '\n';
}
}
return 0;
}
五、复杂度
- 时间复杂度:
O((N + Q) log N)。建树O(N),每次修改和查询都是O(log N),每次合并操作是O(1)常数时间。 - 空间复杂度:
O(N),线段树数组开销。
六、注意点点
- 查询函数query不能直接void查询:在查询的时候可能目标区间由多个子区间组成,这些区间在计算横跨左右的时候,不能直接通过加减等运算在次计算跨越左右,得用node类型存储出来,最后最后再merge合并
- int溢出:
L.R * R.G这种操作,即使L.R和R.G都模过MOD,两个10^9级别的数相乘会爆int,要用long long。
七、总结
这道题是“线段树分治思想”的更加明显的体现。线段树维护的不仅是数据,更是状态之间的转移关系。通过定义 R, G, B, RG, GB, RGB 六种递进状态,我们将原本需要 O(N^3) 暴力枚举的复杂组合问题,转化为在合并区间时的常数级状态转移,极大地降低了复杂度。

浙公网安备 33010602011771号