Hi 大家好,我是张小猪。欢迎来到『宝宝也能看懂』系列之 leetcode 周赛题解。
这里是第 170 期的第 2 题,也是题目列表中的第 1310 题 -- 『子数组异或查询』
有一个正整数数组 arr
,现给你一个对应的查询数组 queries
,其中 queries[i] = [Li, Ri]
。
对于每个查询 i
,请你计算从 Li
到 Ri
的 XOR 值(即 arr[Li] xor arr[Li+1] xor ... xor arr[Ri]
)作为本次查询的结果。
并返回一个包含给定查询 queries
所有结果的数组。
示例 1:
输入:arr = [1,3,4,8], queries = [[0,1],[1,2],[0,3],[3,3]]
输出:[2,7,14,8]
解释:
数组中元素的二进制表示形式是:
1 = 0001
3 = 0011
4 = 0100
8 = 1000
查询的 XOR 值为:
[0,1] = 1 xor 3 = 2
[1,2] = 3 xor 4 = 7
[0,3] = 1 xor 3 xor 4 xor 8 = 14
[3,3] = 8
示例 2:
输入:arr = [4,8,2,10], queries = [[2,3],[1,3],[0,0],[0,3]]
输出:[8,0,4,4]
提示:
1 <= arr.length <= 3 * 10^4
1 <= arr[i] <= 10^9
1 <= queries.length <= 3 * 10^4
queries[i].length == 2
0 <= queries[i][0] <= queries[i][1] < arr.length
MEDIUM
这又是一道非常直白的题目。数据提供了一个 queries
数组,其中每一个 query 其实就是在给定的 arr
数组中划定一个范围,然后我们需要做的计算就是把这个范围内的所有数字进行异或(xor)运算,最终得到这个 query 的结果。
简单粗暴,没什么奇怪的装饰和描述。那么就先上直接方案,brute force,奥利给,淦了!
其实这里没有什么需要额外分析的,就是根据题目描述淦就完事了。具体流程如下:
- 遍历所有 queries。
- 针对每个 query 的范围,循环执行异或计算。
- 得到结果。
const xorQueries = (arr, queries) => {
const ret = new Uint16Array(queries.length);
for (let i = 0; i < queries.length; ++i) {
let val = 0;
for (let j = queries[i][0]; j <= queries[i][1]; ++j) {
val ^= arr[j];
}
ret[i] = val;
}
return ret;
};
由于是 brute force,时间自然不会理想,跑到了 800ms+。 本来想借着和小伙伴出去玩开溜,不过良心是在有点看不下去。摸摸猪鼻子,我们换个思路再来一次。
看着 queries
里的一大堆范围,小猪不由的想到了小时候学校门口的小卖部里那些好吃的小浣熊干脆面,以及小卖部的那个小窗户。等等,小窗户...窗口...滑动窗口...妙啊,我们可以用滑动窗口的思路来解决这个问题。小猪真是个想象力丰富的宝宝,嘤嘤嘤 >.<
先解释一下这里滑动窗口的思路吧。假设当前已经基于范围 [x1, y1]
计算出了我们的目标值 v1
,接下来我们想计算范围 [x2, y2]
的目标值,那么其实完全可以不用重新计算所有内容,只需要把当前窗口的左边界从 x1
移动到 x2
,把右边界从 y1
移动到 y2
即可。具体到针对 v1
值的变化即是配合边界的移动进行值的运算,而恰好我们需要做的异或操作是一个执行两次就相当于撤销的操作。于是可以非常方便的进行 v1
到 v2
的计算。
为了让我们的滑动行为相比于直接计算更加有优势,这时候需要各个目标窗口最好是有一定的顺序,这样就不会出现一下子很大幅度的滑动,以及非常浪费的来回滑动。所以我们会先对 queries
进行一个排序。但是最后的返回结果需要是符合题目给定数据的顺序,所以我们不能直接修改 queries
原地排序,只能新开一个空间进行排序。
那么具体流程如下:
- 复制原始
queries
数组,并按照范围的开始点和结束点来进行排序。 - 初始化当前窗口位置和运算值。
- 遍历已排序过的数组,进行窗口的滑动,并记录每一个窗口的计算值:
- 左边界移动到新的左边界
- 右边界移动到新的右边界
- 移动过程中维护运算值
- 重新根据原始
queries
数组的顺序赋值计算值。
基于以上流程,我们可以实现类似下面的代码:
const xorQueries = (arr, queries) => {
const ret = new Uint32Array(queries.length);
const map = new Map();
// 复制原始数组,并按照左边界从小到大排序,如果左边界相同,再按照右边界从小到大排序
const sorted = [...queries].sort((a, b) => a[0] === b[0] ? a[1] - b[1] : a[0] - b[0]);
let val = left = right = 0;
for (let i = 0; i < sorted.length; ++i) {
const [start, end] = sorted[i];
// 移动左边界
while (left < start) val ^= arr[left++];
// 移动右边界,需要区分两种情况,因为是基于左边界排序的,所以新的右边界可能比之前的右边界小
while (right <= end) val ^= arr[right++];
while (right > end + 1) val ^= arr[--right];
map.set(left + '-' + (right - 1), val);
}
for (let i = 0; i < queries.length; ++i) {
ret[i] = map.get(queries[i][0] + '-' + queries[i][1]);
}
return ret;
};
这个代码的时间大约能跑到 400ms+,说明我们的优化思路确实起到了作用,不过还不够。We need more!
上面的思路已经提到了一点,即我们需求的异或操作,针对这个操作我们可以看看它的一些特性:
(4) === (3 ^ 4 ^ 3)
(4 ^ 5) === (3 ^ 4 ^ 5 ^ 3)
(4 ^ 5) === (2 ^ 3 ^ 4 ^ 5 ^ 2 ^ 3)
不知道这样写完小伙伴们有没有发现一件事情,也就是我们的目标范围 [x, y]
的运算值其实可以转化为 [start, x) ^ [start, y]
。
然后我们再看,如果从 0 开始遍历 arr
,我们可以很容易的得到从 0 开始的不断累积各个数组值的异或运算值。换句话说就是我们可以很容易的计算出 [0, n]
这个范围的值。那么结合上面的那个转化,对于 [x, y]
这个范围其实可以通过 [0, x) ^ [0, y]
来计算得到。
到此,我们可以整理出这个思路的具体流程:
- 遍历
arr
得到各个从 0 开始的范围的目标运算值。 - 遍历
queries
,针对每个具体的 query 范围,根据上面的转化方式求得运算值。
是不是一下子简单了好多。并且这里还有个小优化,我们可以直接在 arr
数组中记录从 0 开始的累积运算值,从而不需要额外的储存空间。
基于以上流程,我们可以实现类似下面的代码:
const xorQueries = (arr, queries) => {
const ret = new Uint32Array(queries.length);
for (let i = 1; i < arr.length; ++i) {
arr[i] ^= arr[i - 1];
}
for (let i = 0; i < queries.length; ++i) {
ret[i] = arr[queries[i][1]];
queries[i][0] !== 0 && (ret[i] = arr[queries[i][0] - 1] ^ ret[i]);
}
return ret;
};
到这里,我们的时间复杂度降低到了 O(n),额外的空间使用降低到了 O(1)。应该已经到比较极限啦。
这也是一道内容简单粗暴的题,两次的思路转换都是基于一些题目数据的特性进行的。在实际的生产环境中,其实类似的情况还有很多,即根据具体需求的一些特性,我们往往能找到更优秀的处理方法。