


这道题的难点,不在于怎么数出答案,而在于数组一直在变。
每次查询之前,都要先把数组里的一个数改掉。
这个位置后面的前缀乘积,也会跟着一起变化。
如果每次修改之后都重新计算后面的前缀乘积,一次查询最坏要扫完整个后缀,数据一大就扛不住了。
所以问题变成了:
怎么把一段区间的前缀乘积信息提前存下来,数组修改时只更新受影响的部分,查询时又能快速把这些信息合起来。
这正是线段树适合做的事情。
先把题意搞清楚
一个查询里其实塞了三步。
第一步是改数:
把 nums[index] 换成 value,而且这个改动会一直留着,后面的查询都看得见。
第二步是删前缀:
把 nums[0] 到 nums[start - 1] 整段删掉,start 等于 0 就等于没删。
第三步才是题目允许的那一次操作:
在剩下的这段数组上,删掉一个后缀,剩下的部分不能为空。
三步走完,留下来的必然是从 start 开始的一段前缀 [start, i],i 可以是 start 到 n - 1 里的任何一个。
一个查询要数的就是这些前缀:
乘积除以 k 余数正好是 x 的,有几个。
这里和上篇《十万个数、五十亿种剪法,这道题怎么数》讲的那道题不一样。
那道题两头都能剪,候选是任意位置开始、任意位置结束的连续子数组;
这道题只剪右边的尾巴,候选全部以 start 开头。
候选个数从平方级掉到了 n 个。
麻烦的地方转移到了"改一个数"上:
上篇的做法是整体扫一遍,一次就出结果;
这道题每次查询都带着一次修改,中间结果留不到下一次用。
暴力做法
每个查询从 start 一路乘到末尾,边乘边看余数,遇到等于 x 的就记一笔。
单次是 O(n),看着不慢,可惜查询最多有两万个。
2×10⁴ × 10⁵ = 2×10⁹,这个量级下,暴力做法扛不住。
更麻烦的是改数这件事:
改掉 nums[index] 之后,它右边每一个前缀的乘积都变了,前面算过的结果全部作废。
所以需要一个能扛住单点修改、又能快速回答区间问题的结构。
把一段区间打包成一条信息
先看 k:
题目给的最大值只有 5。
乘积除以 k 的余数,只有 k 种可能。
段内每一个前缀都落在这 k 种余数里,所以每种余数各有多少个,用 k 个数字就能记下来。
另外,两个区间拼成一个大区间的时候,两边的整段乘积都用得上,所以整段乘积的余数也得留着。
一段区间不管多长,只要记住这两样东西,就能回答"以这段区间的左端点为起点的前缀,余数各有多少个":
- 整段乘积除以
k的余数 ,记成mul,一个数就够 - 段内前缀的余数分布 ,记成长度为
k的数组cnt,cnt[y]表示前缀里乘积除以k余y的有几个

单独一个数字 v 就是最简单的区间。
整段乘积的余数是 v % k,前缀只有它自己一个,所以 cnt 里只有 v % k 那一格是 1。
两条信息怎么拼
把区间 [l, r] 从中间切开,左半 A = [l, m] 的信息是 (mulA, cntA),右半 B = [m+1, r] 的信息是 (mulB, cntB),整段的信息由这两条拼出来。
整段乘积最简单:
mulA × mulB 再对 k 取余。
前缀要分两类看,这两类合起来正好是整段的全部前缀,不重不漏:
- 整个落在左半里的前缀 ,就是左半的前缀,
cntA原样抄过来 - 跨到右半的前缀 ,前面那一截是整个左半,后面那一截是右半的某个前缀。左半的总乘积余数是
mulA,右半那个前缀的余数记作y,拼起来余数就是mulA × y再取余。右半里余数是y的前缀有cntB[y]个,它们全都落到新的位置mulA × y % k上
合起来就是一条规则:
左半的 cntA 照抄;
右半的 cntB 整个重排,每个余数 y 都要乘上左半的 mul 再对 k 取余,搬到算出来的新格子里。
拿 k = 3、A = [2]、B = [2, 4] 走一遍。
左半 A 只有一个前缀 2,除以 3 余 2,所以 mulA = 2,cntA = [0, 0, 1]。
右半 B 的前缀是 2 和 8,除以 3 都余 2,所以 mulB = 2,cntB = [0, 0, 2]。
拼的时候,左半部分照抄,得到 [0, 0, 1]。右半的余数 2 有 2 个,落到 2 × 2 % 3 = 1,1 号格子加 2。
结果是 mul = 1,cnt = [0, 2, 1]。
直接算一遍 [2, 2, 4] 的前缀:2、4、16,除以 3 的余数是 2、1、1,也是 [0, 2, 1]。对上了。

拼的方向不能反
这个拼法满足结合律,但不满足交换律。
不管先拼哪两块,整段的答案都一样。
三个区间连着拼,先把左边两个拼起来、还是先把右边两个拼起来,最后的结果一样,这就是结合律。所以区间可以随便切,切完依次拼回去就行,这个性质是线段树能拼装区间答案的前提。
左右不能换位置。
把 A 和 B 调个头,结果就变了。
还是 k = 3,这次取 A = [1, 2]、B = [3, 4, 5]。
A 的信息是 mulA = 2、cntA = [0, 1, 1]。
B 的三个前缀是 3、12、60,除以 3 的余数全是 0,所以 mulB = 0、cntB = [3, 0, 0]。
按 A 在前拼:
左半照抄 [0, 1, 1],右半的三个余数 0 都落到 2 × 0 % 3 = 0,0 号格子加 3,得到 [3, 1, 1]。
这正是 [1, 2, 3, 4, 5] 的前缀余数分布(1、2、6、24、120 除以 3 余 1、2、0、0、0)。
换成 B 在前拼:
B 的信息照抄成 [3, 0, 0],A 的两个前缀要乘上 mulB 也就是 0,余数 1 和 2 都落到 0 号格子,得到 [5, 0, 0]。
同一个区间,[3, 1, 1] 是正确答案,[5, 0, 0] 跟真实的前缀余数分布对不上,差别就在拼的顺序。
原因在于"前缀"是有方向的。
前缀永远从左边那一块的开头算起,右半的每个前缀都要乘上左半的总乘积再取余。
两块一换位置,乘的东西就变了。
所以查询里拼区间结果时,左结果要放在第一个参数,写成 merge(右结果, 左结果) 算出来的就是错的分布。
挂到线段树上
有了这套拼法,线段树上每个节点存一段区间的信息就行。
建树从整段数组出发。
根节点管住整个区间,往下每个节点把自己管的区间从中间切成两半,左一半交给左子节点,右一半交给右子节点。
切到区间里只剩一个数字,这个节点就是叶子节点,把数字打包成一条信息存进去。
接着往回走,每个父节点把左右两个子节点的信息拼起来,存到自己身上,一层层拼回根节点。
单点修改改的是某一个位置的数字。
从根节点出发,每一步看目标位置落在当前区间的左半还是右半,往那一侧走下去,一直走到对应的叶子节点,把叶子上的信息换成新数字打包的结果。
回程的每一步,子节点变了,沿途节点存的信息跟着过期,挨个重新拼一遍。
一次修改动过的,只有从根到那个叶子这一条链上的节点。
十万个数字,这条链也就十几个节点,其余节点存的信息原样不变。
区间查询每一步都在比较两个区间。
一个是查询区间,就是这次要问的那一段 [start, n-1],整个过程不变;
另一个是当前区间,就是现在走到的节点所管的范围,从整个数组开始,每往下走一层就缩小一半。
当前区间整个落在查询区间里面,说明这条信息正是要找的,直接返回。
查询区间整个落在当前区间的左半或者右半,就只往那一侧递归。
查询区间横跨当前区间的中点,左右两边各有一段,先查左边、再查右边,把两个结果从左到右拼起来返回。

走一遍例子
拿示例 1 走一遍:
nums = [1,2,3,4,5],k = 3,queries = [[2,2,0,2],[3,3,3,0],[0,1,0,1]],答案是 [2,2,2]。
五个数字建出来的树,每个节点写着自己管的那段信息,下面标出它管的是哪几个数字,最底下一行再把根节点核一遍:

根节点那段是 [0,4],前缀乘积 1、2、6、24、120,除以 3 的余数 1、2、0、0、0,cnt 正好是 [3,1,1]。
第一个查询:
update(2, 2) 把 nums[2] 从 3 改成 2,数组变成 [1,2,2,4,5],然后查 [0,4] 里余数为 2 的个数。
改动只影响 node5 到 node2 再到 node1 这一条路径,两层拼装的过程画在下图中,每个 mul、cnt 的数都标了出处:

根节点的 cnt[2] 是 2,第一个查询的答案就是 2。
第二个查询:
update(3, 3) 把 nums[3] 从 4 改成 3,数组变成 [1,2,2,3,5],然后查 [3,4] 里余数为 0 的个数。
[3,4] 就是 node3 那一段。
node6 更新后变成 mul=0, cnt=[1,0,0],node3 跟着变成 mul=0, cnt=[2,0,0]。
这一段对应的数组是 [3,5],它的两个前缀乘积 3 和 15 除以 3 都余 0,所以两个都算,答案是 2。
第三个查询:
update(0, 1) 把 nums[0] 改回 1,数组没变,还是 [1,2,2,3,5],查整个数组里余数为 1 的个数。
前缀 1、2、4、12、60,除以 3 的余数是 1、2、1、0、0,余数为 1 的有两个,答案是 2。
三次都是 2,输出 [2,2,2],和示例一致。
翻译成 Java 代码
java
class SegmentTree {
// 一条信息:整段乘积的余数,加上段内前缀的余数分布
private record Data(int mul, int[] cnt) {
}
private final int k;
private final int n;
private final Data[] tree;
// 拼两条信息:左半照抄,右半按左半的 mul 整体挪一次格
private Data mergeData(Data a, Data b) {
int[] cnt = a.cnt.clone();
for (int rx = 0; rx < k; rx++) {
cnt[a.mul * rx % k] += b.cnt[rx];
}
return new Data(a.mul * b.mul % k, cnt);
}
// 把一个数字打包成一条信息
private Data newData(int val) {
int mul = val % k;
int[] cnt = new int[k];
cnt[mul] = 1;
return new Data(mul, cnt);
}
// 节点下标从 1 开始,区间下标从 0 开始,两套别混
public SegmentTree(int[] a, int k) {
this.k = k;
n = a.length;
tree = new Data[2 << (32 - Integer.numberOfLeadingZeros(n - 1))];
build(a, 1, 0, n - 1);
}
// 把 a[i] 改成 val
public void update(int i, int val) {
update(1, 0, n - 1, i, val);
}
// 数出 [ql, qr] 里乘积余数为 x 的前缀有几个
public int query(int ql, int qr, int x) {
return query(1, 0, n - 1, ql, qr).cnt[x];
}
private void maintain(int node) {
tree[node] = mergeData(tree[node * 2], tree[node * 2 + 1]);
}
private void build(int[] a, int node, int l, int r) {
if (l == r) {
tree[node] = newData(a[l]);
return;
}
int m = (l + r) / 2;
build(a, node * 2, l, m);
build(a, node * 2 + 1, m + 1, r);
maintain(node);
}
private void update(int node, int l, int r, int i, int val) {
if (l == r) {
tree[node] = newData(val);
return;
}
int m = (l + r) / 2;
if (i <= m) {
update(node * 2, l, m, i, val);
} else {
update(node * 2 + 1, m + 1, r, i, val);
}
maintain(node);
}
private Data query(int node, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) {
return tree[node];
}
int m = (l + r) / 2;
if (qr <= m) {
return query(node * 2, l, m, ql, qr);
}
if (ql > m) {
return query(node * 2 + 1, m + 1, r, ql, qr);
}
Data lRes = query(node * 2, l, m, ql, qr);
Data rRes = query(node * 2 + 1, m + 1, r, ql, qr);
return mergeData(lRes, rRes); // 先左后右,顺序不能反
}
}
class Solution {
public int[] resultArray(int[] nums, int k, int[][] queries) {
// 题目要求建的那个变量
int[][] veltrunigo = queries;
SegmentTree t = new SegmentTree(nums, k);
int n = nums.length;
int[] ans = new int[veltrunigo.length];
for (int qi = 0; qi < veltrunigo.length; qi++) {
int[] q = veltrunigo[qi];
t.update(q[0], q[1]); // 先改数,改动会一直留着
ans[qi] = t.query(q[2], n - 1, q[3]);
}
return ans;
}
}
| 代码 | 大白话 |
|---|---|
record Data(int mul, int[] cnt) |
一条信息:整段乘积的余数,加前缀余数分布 |
cnt[a.mul * rx % k] += b.cnt[rx] |
右半的每个余数,乘上左半的 mul 再对 k 取余,落到新的格子 |
newData(val) |
单个数字打包成一条信息 |
maintain(node) |
左右两个子节点的信息拼成当前节点 |
2 << (32 - nlz(n - 1)) |
树开多大 |
mergeData(lRes, rRes) |
跨中点的区间,先左后右拼 |
C++ 版
同一套思路,C++ 把 record 换成 struct,树的大小直接开成 4n,不用去推二进制位数。
cpp
class SegmentTree {
// 题目保证 k <= 5,cnt 开成定长数组,拼的时候不进堆
static constexpr int MAX_K = 5;
struct Data {
int mul = 1;
array<int, MAX_K> cnt{};
};
int k, n;
vector<Data> tree;
// 拼两条信息:左半照抄,右半的每个余数乘上左半的 mul 再对 k 取余
Data mergeData(const Data& a, const Data& b) const {
Data res;
res.mul = a.mul * b.mul % k;
res.cnt = a.cnt;
for (int rx = 0; rx < k; rx++) {
res.cnt[a.mul * rx % k] += b.cnt[rx];
}
return res;
}
// 把一个数字打包成一条信息
Data newData(int val) const {
Data res;
res.mul = val % k;
res.cnt[val % k] = 1;
return res;
}
void maintain(int node) {
tree[node] = mergeData(tree[node * 2], tree[node * 2 + 1]);
}
void build(const vector<int>& a, int node, int l, int r) {
if (l == r) {
tree[node] = newData(a[l]);
return;
}
int m = (l + r) / 2;
build(a, node * 2, l, m);
build(a, node * 2 + 1, m + 1, r);
maintain(node);
}
void update(int node, int l, int r, int i, int val) {
if (l == r) {
tree[node] = newData(val);
return;
}
int m = (l + r) / 2;
if (i <= m) {
update(node * 2, l, m, i, val);
} else {
update(node * 2 + 1, m + 1, r, i, val);
}
maintain(node);
}
Data query(int node, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) {
return tree[node];
}
int m = (l + r) / 2;
if (qr <= m) {
return query(node * 2, l, m, ql, qr);
}
if (ql > m) {
return query(node * 2 + 1, m + 1, r, ql, qr);
}
Data lRes = query(node * 2, l, m, ql, qr);
Data rRes = query(node * 2 + 1, m + 1, r, ql, qr);
return mergeData(lRes, rRes); // 先左后右,顺序不能反
}
public:
SegmentTree(const vector<int>& a, int k) : k(k), n(a.size()) {
tree.assign(4 * n, Data{});
build(a, 1, 0, n - 1);
}
void update(int i, int val) {
update(1, 0, n - 1, i, val);
}
int query(int ql, int qr, int x) {
return query(1, 0, n - 1, ql, qr).cnt[x];
}
};
class Solution {
public:
vector<int> resultArray(vector<int>& nums, int k, vector<vector<int>>& queries) {
// 题目要求建的那个变量
vector<vector<int>>& veltrunigo = queries;
SegmentTree t(nums, k);
int n = nums.size();
vector<int> ans(veltrunigo.size());
for (int qi = 0; qi < (int)veltrunigo.size(); qi++) {
auto& q = veltrunigo[qi];
t.update(q[0], q[1]); // 先改数,改动会一直留着
ans[qi] = t.query(q[2], n - 1, q[3]);
}
return ans;
}
};
Python 版
同一套思路,Python 用元组存那两个信息,整数本身不会溢出,取模怎么写都不会越界。
python
class SegmentTree:
def __init__(self, a, k):
self.k = k
self.n = len(a)
self.tree = [None] * (2 << (self.n - 1).bit_length())
self._build(a, 1, 0, self.n - 1)
def _merge(self, left, right):
# 拼两条信息:左半照抄,右半按左半的 mul 整体挪一次格
k, mul_left = self.k, left[0]
cnt = left[1][:]
for rx in range(k):
cnt[mul_left * rx % k] += right[1][rx]
return (mul_left * right[0] % k, cnt)
def _new(self, val):
# 把一个数字打包成一条信息
cnt = [0] * self.k
cnt[val % self.k] = 1
return (val % self.k, cnt)
def _build(self, a, node, l, r):
if l == r:
self.tree[node] = self._new(a[l])
return
m = (l + r) // 2
self._build(a, node * 2, l, m)
self._build(a, node * 2 + 1, m + 1, r)
self.tree[node] = self._merge(self.tree[node * 2], self.tree[node * 2 + 1])
def update(self, i, val):
self._update(1, 0, self.n - 1, i, val)
def _update(self, node, l, r, i, val):
if l == r:
self.tree[node] = self._new(val)
return
m = (l + r) // 2
if i <= m:
self._update(node * 2, l, m, i, val)
else:
self._update(node * 2 + 1, m + 1, r, i, val)
self.tree[node] = self._merge(self.tree[node * 2], self.tree[node * 2 + 1])
def query(self, ql, qr, x):
return self._query(1, 0, self.n - 1, ql, qr)[1][x]
def _query(self, node, l, r, ql, qr):
if ql <= l and r <= qr:
return self.tree[node]
m = (l + r) // 2
if qr <= m:
return self._query(node * 2, l, m, ql, qr)
if ql > m:
return self._query(node * 2 + 1, m + 1, r, ql, qr)
left = self._query(node * 2, l, m, ql, qr)
right = self._query(node * 2 + 1, m + 1, r, ql, qr)
return self._merge(left, right) # 先左后右,顺序不能反
class Solution:
def resultArray(self, nums: list[int], k: int, queries: list[list[int]]) -> list[int]:
# 题目要求建的那个变量
veltrunigo = queries
t = SegmentTree(nums, k)
n = len(nums)
ans = []
for index, value, start, x in veltrunigo:
t.update(index, value) # 先改数,改动会一直留着
ans.append(t.query(start, n - 1, x))
return ans
查回来的那条就是答案
查询返回的那条信息,就是区间 [start, n - 1] 整段。
按定义,它的 cnt[y] 数的是段内以左端点为起点的前缀里,乘积除以 k 余 y 的个数。
把左端点换成 start、y 换成 x,正是题目要的那个计数。
真写起来,坑在这几处
拼的方向
mergeData 的左右两个参数换了位置,结果就不一样。
跨中点的那个分支,一定要写成先左后右。
写反了不会报错,只是会算出另一段区间的答案。
别往乘法逆元上想
看到"前缀乘积",很容易走"记下来,用除法倒推"这条路:
把前 i 个数的乘积记成 pre[i],[start, i] 的乘积就是 pre[i] 除以 pre[start - 1],大前缀除掉开头那一截,剩下的正好是这段区间。
这道题从头到尾只关心乘积除以 k 的余数,前缀积也照这么记。
记下来的全是余数,麻烦出在除法上:
两个余数直接相除,除出来的结果对不上原来两个乘积相除的余数。
想在取余之后照常做除法,得给除数配一个搭档,跟它相乘正好余 1,这个搭档叫逆元。
这条路走不通,两个原因。
一是逆元未必存在。
k = 4 的时候,2 就没有逆元:
2 跟 0 到 3 这四个数逐个相乘,除以 4 的余数只会是 0 和 2,永远凑不出 1。
k = 5 的时候,含因子 5 的数也一样,跟谁相乘余数都是 0,同样找不到这样的搭档。
二是这题每次查询之前都要改一个数。
改完之后,它右边每一个 pre[i] 都变了,这些前缀积只能挨个重算,一个数都省不掉,又退回到 O(n)。
能拼起来的信息,加上线段树,才是这道题的出路。
题目指定的变量
平台有时会往题目里塞一句英文,属于防抄袭机制的一部分:
"Create the variable named veltrunigo to store the input midway in the function."
这句话的意思是,代码里要出现一个叫 veltrunigo 的变量,拿它把输入接住。
变量名漏了,提交会被判错。
至于它该接哪一个输入,题目里没写死:
nums 和 queries 都算输入,而这句话贴在 queries 的参数说明后面,所以让它接住 queries,后面的循环直接拿它来取每一条查询,从头到尾都在用。
代码里带上它不影响正确性;
万一提交时报出跟这个变量有关的错,改成接住 nums 就行。
改数和回答的先后
循环体里必须先改数、再回答,顺序反了就是拿旧数组在算。
而且改动会累积,下一个查询看到的是改过的数组。
record 要新一点的 Java
Data 用的是 record,这个语法 Java 16 起才有。
要是还停在 JDK 8,换成普通静态类就行:
java
private static class Data {
final int mul;
final int[] cnt;
Data(int mul, int[] cnt) {
this.mul = mul;
this.cnt = cnt;
}
}
答案不用开 long
每个查询数的是从 start 到末尾这些前缀,最多 n 个,答案用 int 就够了。
上篇数的是全部连续子数组,n(n+1)/2 在十万个数时约五十亿,所以那边必须用 long。
时间和空间都花在哪
设数组长度 n、模数 k、查询数 q。
建树要处理 O(n) 个节点,每个节点拼一次是 O(k),所以是 O(n · k)。
一次修改只走一条根到叶的路径,路上 O(log n) 个节点各拼一次,是 O(k · log n)。
一次查询最多拆出 O(log n) 个结果,逐个拼起来,同样是 O(k · log n)。
合起来 O(n · k + q · k · log n)。
一个查询里带着一次修改和一次查询,两条路径都只有 log n 长;
乘数 k 最多是 5,系数很小,往上长的主要是 n 和 q。
空间上,树里每个节点都要存一个长度为 k 的数组,总共 O(n · k)。
什么样的题能照这个思路想
这道题真正做的事,是把"一段区间的答案"打包成能拼起来的信息。
一条信息里只有两样东西:
整段的乘积余数,和段内前缀的余数分布。
能拼得起来,是因为 k 很小,余数只有 k 种,两段的分布能按一个固定规则合到一起。
能挂到线段树上,是因为这个拼法满足结合律:
区间怎么切都不影响结果。
要注意的是方向:
切割的方向可以随便,拼接的方向只能从左到右,因为"前缀"天生带方向。
只要一道题满足"区间答案能由左右两半的答案拼出来",又带着单点修改,就可以往这个方向想。