Go语言实现区间乘法查询后的异或:暴力解法与实战解析
2026/9/18 3:55:36 网站建设 项目流程

这题我拿到标题的时候,第一反应是“区间更新 + 区间异或查询”,下意识就想上线段树。但仔细看了标题里的“Ⅰ”字,又想了想异或和乘法搅在一起的那个别扭劲,我觉得这个版本的题目应该没那么复杂。标题里信息很明确:用 Go 语言,给定数组nums,每次查询给四个整数[li, ri, ki, vi],从位置liri做区间乘法,然后要输出和异或有关的结果。下面把我个人理解后的完整题面、暴力实现思路、完整 Go 代码以及踩坑过程都写出来,给同样在刷题的朋友一个参考。

1. 题目理解:区间乘法查询后的异或到底要算什么

1.1 从标题拆出三个关键词

标题里有三个核心词:区间乘法查询后的异或nums

“区间乘法”指的不是把整个数组所有数都乘一遍,而是每次只针对一个连续区间,比如从第li个位置到第ri个位置,把这一小段里的每一个数都乘以同一个ki。这种操作在算法题里很常见,类似线段树里的“区间乘”懒惰标记,但这里的关键是它和“异或”拼在了一起。

“查询后的异或”说明题目不是单纯让你做区间修改,而是每次修改完要去查询某个异或值。这个异或值和哪个区间有关,就是这次查询的[li, ri]区间。所以整个过程就是:先改一段,再算这一段更新后的异或和,最后把结果输出。

nums就是初始数组,也是所有操作的作用对象。题目强调用 Go 语言,说明我们得写出能直接跑通的 Go 代码,而不是只给个伪代码。

1.2 我补全的题面定义

由于原始描述只给到“对每一条查询,从位置 li”,后半部分缺失,这里我按最常见的“区间乘法查询后的异或”题型补全成如下版本:

有一个长度为n的整数数组nums,下标从 1 开始。一共有q条查询,每条查询给出四个整数li, ri, ki, vi。对于每条查询,先执行一步更新:把nums[li]nums[ri]之间的所有元素都乘以ki,然后计算区间[li, ri]内所有元素更新后的异或和xorSum,最后输出xorSum ^ vi的结果。需要注意的是,每条查询的更新会永久影响数组,后面的查询看到的是更新后的数组。

这个定义可能和原题有一些出入,但整体逻辑非常贴合“区间乘法查询后的异或”这串字眼。如果原题里vi的含义不是这样,你可以把vi那一步替换成题目要求的最终输出方式,核心难点——区间乘法和异或的组合处理——是一模一样的。

2. 为什么这题不能直接套线段树

2.1 乘法对异或没有分配律

很多人的第一反应是:区间乘法、区间查询,这不是标准线段树模板吗?确实,如果是维护区间和,线段树加乘法懒惰标记分分钟搞定。但这里维护的是“区间异或和”,问题就来了。

异或和本身是每一位独立运算的结果。设一个区间里有两个数ab,它们的异或和是a ^ b。现在区间每个数都乘k,得到kakb,新的异或和是ka ^ kb

问题在于:ka ^ kb不等于k * (a ^ b)。随便举个例子:a=2, b=3, k=2,原异或和是2 ^ 3 = 1,乘 2 后变成4 ^ 6 = 2,而k * 1 = 2,这俩碰巧相等。换个例子:a=1, b=2, k=3,原异或和是1 ^ 2 = 3,乘 3 后变成3 ^ 6 = 5,而k * 3 = 9,完全不同。

也就是说,乘法对异或不满足分配律,不能用“先维护区间异或和,区间乘的时候给和也乘个 k”这种偷懒办法。如果要用线段树,需要额外记录每个二进制位上 1 的个数,而且乘法会改变每个数的二进制位分布,更新起来非常复杂。

2.2 暴力方法反而是最稳的解法

题目标题带着“Ⅰ”,通常这种入门版本的数据范围不会太大,比如nq可能都在几千到一万以内。这种情况下,直接暴力模拟反而是最稳妥、最不容易写错的方案。

暴力思路很简单:对每条查询,从li遍历到ri,每到一个位置就把当前元素乘上ki,同时用异或操作累加结果。因为乘法和异或都要遍历区间,所以一次查询的时间复杂度是O(ri - li + 1),也就是区间长度。最坏情况下,如果每个查询的区间都是整个数组,那么总复杂度是O(nq)。只要n * q的数量级在几百万到一千万级别,Go 语言跑起来完全没有压力。

相比去实现复杂的数据结构,暴力代码直观、容易调试,而且不容易在“更新顺序”“懒惰标记下传”这类地方翻车。对初学者来说,先把暴力写对,再考虑优化,是刷题的正确节奏。

3. Go 语言实现细节与完整代码

3.1 输入处理与下标转换

Go 语言处理算法题的输入一般有两种方式:直接用fmt.Scan,或者用bufio.Reader配合fmt.Fscan。当数据量不大时,fmt.Scan足够;如果q到了十万级别,建议用bufio.Reader减少系统调用。

需要注意,题目里数组下标从 1 开始,而 Go 的切片下标从 0 开始。两种处理方式:

  • 申请长度n+1的切片,下标1n存数,这样代码跟题面完全对齐。
  • 申请长度n的切片,读入时下标减 1,操作时l--r--

我更推荐第一种,因为读到liri之后不用做减法,写起来更不容易晕。下面代码采用这种方式。

3.2 核心循环:一次遍历同时完成乘法和异或

很多新手会写两个循环:第一个循环做区间乘法,第二个循环做区间异或。这当然没错,但其实可以合并成一个循环。因为每一步我们只关心当前元素乘完之后的值,把它异或进结果即可。具体如下:

for i := l; i <= r; i++ { nums[i] *= k xorSum ^= nums[i] }

这个循环有两个作用:

  1. nums[i]更新为乘k后的值,保证后续查询能看到修改。
  2. 把乘完后的nums[i]累加到xorSum里,保证输出的是“更新后的异或和”。

这里有个容易忽略的细节:xorSum的初始值应该是 0,因为任何数异或 0 都等于它本身。如果初始化成别的值,结果就全错了。

3.3 完整代码示例

下面是我写好的完整 Go 程序,直接可以运行:

package main import ( "bufio" "fmt" "os" ) func main() { // 使用 bufio.Reader 提高输入效率 in := bufio.NewReader(os.Stdin) out := bufio.NewWriter(os.Stdout) defer out.Flush() var n, q int fmt.Fscan(in, &n, &q) // 下标从 1 开始,所以长度 n+1 nums := make([]int64, n+1) for i := 1; i <= n; i++ { fmt.Fscan(in, &nums[i]) } for ; q > 0; q-- { var l, r int var k, v int64 fmt.Fscan(in, &l, &r, &k, &v) // 区间乘法 + 计算更新后的区间异或和 var xorSum int64 for i := l; i <= r; i++ { nums[i] *= k xorSum ^= nums[i] } // 输出异或结果,再异或 vi fmt.Fprintln(out, xorSum^v) } }

这段代码里用int64存所有数值,是因为乘法很容易让int溢出。在很多在线评测环境里,32 位int的最大值是 2147483647,而nums[i]ki如果都在万级,乘积就能到亿级,乘几次之后很容易爆。用int64可以安全很多,但也不能完全无视溢出风险,见后面的踩坑部分。

4. 样例推演与复杂度分析

4.1 手动模拟一个例子

我们用一个简单例子来验证逻辑。

假设初始数组:

nums = [1, 2, 3, 4]

第一次查询:

li=1, ri=3, ki=3, vi=0

执行过程:

  • i=1nums[1] = 1 * 3 = 3xorSum = 3
  • i=2nums[2] = 2 * 3 = 6xorSum = 3 ^ 6 = 5
  • i=3nums[3] = 3 * 3 = 9xorSum = 5 ^ 9 = 12

输出:

12 ^ 0 = 12

此时数组变成:

nums = [3, 6, 9, 4]

第二次查询:

li=2, ri=4, ki=2, vi=1

执行过程:

  • i=2nums[2] = 6 * 2 = 12xorSum = 12
  • i=3nums[3] = 9 * 2 = 18xorSum = 12 ^ 18 = 30
  • i=4nums[4] = 4 * 2 = 8xorSum = 30 ^ 8 = 22

输出:

22 ^ 1 = 23

这个例子说明,每次查询都会在之前的数组基础上继续操作,所以必须保证数组在循环中被真实更新,不能只在临时副本上操作。

4.2 时间空间复杂度

时间复杂度方面,每条查询都要遍历liri,长度为len = ri - li + 1,所以单次查询是O(len),所有查询累加为O(sum(len))。最坏情况下,如果每条查询的区间都是[1, n],则总复杂度为O(nq)

空间复杂度为O(n),因为只需要一个长度为n+1的数组来存数据,没有额外的大数组。

如果题目把nq都限制在 2000 以内,这种暴力做法实测时间可以忽略不计;如果n=1e5, q=1e5,那肯定超时,需要另想方案。

5. 我在实战中踩过的坑和排查技巧

5.1 整数溢出是最常见的坑

前面提到用int64,这还不够,因为如果ki本身很大,比如ki=1e9,连续乘几次,int64也会爆。很多题目为了保证可解,会说明结果在 64 位有符号整数范围内,或者要求对某个数取模。做题前一定要看数据范围。

如果题目没有给保证,我的习惯是:先把所有数值都设为int64,如果样例过了但大数据 WA,就要怀疑溢出。判断溢出的一个技巧是:乘之前判断nums[i] > math.MaxInt64 / k,如果成立,说明乘完会溢出。但在算法竞赛里,更常见的是题目设计时已经规避了溢出,你只需要用int64就好。

5.2 异或运算的优先级比想象中低

在 Go 语言里,^按位异或的优先级和+-是同一个层级,低于乘除。所以如果你写出类似:

ans := xorSum ^ v

没问题,因为只有一个异或。但如果混进加减乘除,比如:

ans := xorSum ^ v * 2

那就等于xorSum ^ (v * 2),而不是(xorSum ^ v) * 2。一旦表达式复杂,我强烈建议加括号,别跟编译器玩优先级游戏。

5.3 操作是持久更新,不是只读查询

这是最容易被忽略的。有人把“查询后的异或”理解成:每次单独拿初始数组乘一乘、再算异或,不改变原数组。这是错的。题目说的是“每条查询”执行乘法操作,后面的查询必须看到前面的修改。我一开始写代码时,不小心在循环里使用了原始数组的副本,导致第二条查询算出来的结果完全不对。

排查方法很简单:在每条查询之后把整个数组打印出来,跟手推结果对比。如果发现数组没变,或者变错了,就说明更新逻辑有问题。

5.4 输入输出别拖后腿

q到几万的时候,fmt.Scanfmt.Println会带来不小的性能开销。我更喜欢用bufio.NewReaderbufio.NewWriter,配合fmt.Fscanfmt.Fprintln来读写。上面的代码已经做了这个优化。如果数据量特别大,还可以考虑自己写快读,但一般用不上。

6. 如果数据范围变大,可以怎么优化

6.1 分块维护是性价比最高的方案

假设nq都到 1e5,暴力会超时,但线段树又因为异或和乘法的冲突很难维护。这种情况下,可以考虑分块。把数组分成若干个块,每块维护两个信息:

  • 块内元素的真实值(经过所有乘法更新后的值);
  • 块内所有元素的异或和。

更新区间[li, ri]时,对于完全覆盖的块,不能只把块内异或和乘ki,因为乘法对异或不满足分配律。所以只能把块内的每个元素都更新一遍,然后重新计算该块异或和。对于部分覆盖的块,同样需要逐元素更新。

这样做的好处是:中间整块的更新从“逐元素”变成了“整块重算异或和”,但本质上还是要遍历块内元素,所以复杂度并不比暴力低多少。除非我们能找到某种数学性质,将乘法和异或统一起来。

6.2 从异或的按位性质入手

异或运算可以按二进制位拆分看待:一个数字的二进制第b位是 0 还是 1,决定了它是否参与异或和的该位贡献。我们如果能维护区间内每个二进制位上 1 的个数,当区间乘一个奇数时,每个数的二进制位可能会发生变化,这个变化和数值本身有关,很难用简单的计数更新。

但如果题目有限制,比如ki是 2 的幂,那么乘以ki就等同于所有数左移若干位,这时区间内所有二进制位会整体平移,异或和也可以直接左移相应位数,问题就变得非常简单。如果ki没有特殊限制,那么这种区间乘法加区间异或和的问题,本质上需要更高级的数据结构,比如线性基或者块状链表。

6.3 我的优化建议

如果只是应付“Ⅰ”这个版本,暴力已经足够。我可以给一个进阶的思考方向:假如题目变成“Ⅱ”,很可能会加入取模、或者限定ki只有 2 的幂次、或者查询改为单点异或。到时候再根据具体限制选择分块、线段树还是更特殊的位运算维护方式。现在硬造一个复杂解法反而容易出错。

7. 结尾的一点个人体会

这段代码虽然短,但我在实际实现时花了不少时间在“理解题意”上。因为原始描述被截断了,vi到底怎么用,不同人的理解可能不一样。刷题最怕的不是代码写不出来,而是题面没看明白就开始动手。我的习惯是:先写一个最简单的暴力版本,拿样例验证,如果通过了再根据数据范围去考虑优化。这样做可以最大程度避免“思路偏了还埋头写”的尴尬。

最后再分享一个小技巧:如果你想测试自己的实现是否和题目预期一致,可以构造一个n=3, q=2的小数据,手算一遍结果,然后跟程序输出对比。一旦这种小数据通过,基本逻辑就没有问题。之后再用大数据压测性能,这样你的解法就能又快又稳。希望这篇博文对你有帮助,也欢迎评论区一起讨论更多关于异或和区间操作的细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询