
这是前几天(2023/01/05)的每日一题,这道题对现在的我,实在是太太太难了。这也是有生以来,从学前班到大学,从大学到工作,第一次难到让我怀疑人生、怀疑自己智商的一道题。有扣友评价它:
今日大敌当头,百思不得其解。——扣友@joey91
然后默默啃了官解和多位大佬的题解,想总结一下,结果因为太难,连总结都写了好几天。
这篇帖子耗费了我大量的心血,如果对你有用,可以点个赞再走🤣
@liberg 大佬的题解:《Java暴力侥幸通过(1650 ms)》。
注意,这位大佬用的语言是Java,用其他小伙伴刷题的小伙伴,其思路仅做参考。
public int countPairs(int[] nums, int low, int high) {
int ans = 0;
int max = 0;
int min = Integer.MAX_VALUE;
int[] freq = new int[20001];
for (int num : nums) {
freq[num]++;
max = Math.max(max, num);
min = Math.min(min, num);
}
for (int j = min; j <= max; j++) {
if(freq[j] == 0)continue;
for (int i = low; i <= high; i++) {
int xor = i ^ j;
if(xor >=1 && xor <=20000) {
ans += freq[j]*freq[xor];
}
}
}
return ans>>1;
}我永远想不到这个思路,虽然并不难理解🤣:
ans>>1)。可以看到,大佬的优化思路,核心要义是剪枝:
最坏情况下,剪枝可能不起作用,但是考虑到测试用例有一定的随机性,这个策略是有效的。除非,出题人比较“变态”,精心设计过用例🤣。
事实上,即便不知道前面提到的异或性质,仅仅利用计数进行剪枝,本题也能 AC 的,而且执行时间甚至还更好:
/**
执行用时:1482 ms, 在所有 Java 提交中击败了18.28%的用户
内存消耗:43.3 M, 在所有 Java 提交中击败了98.07%的用户
通过测试用例:63 / 63
*/
class Solution {
public int countPairs(int[] nums, int low, int high) {
int ans = 0;
int max = 0;
int min = Integer.MAX_VALUE;
int[] freq = new int[20001];
for (int num : nums) {
freq[num]++;
max = Math.max(max, num);
min = Math.min(min, num);
}
for (int j = min; j <= max; j++) {
if(freq[j] == 0)continue;
for (int i = j + 1; i <= max; i++) { // 相等的数字异或,必然等于 0,小于 low,所以 i 初始化为 j+1
int xor = i ^ j;
if(xor >= low && xor <= high) {
ans += freq[j] * freq[i];
}
}
}
return ans; // 结果不用除 2 了。
}
}通常情况下,在使用哈希表的环境中,如果键的取值范围是确定的,那么用数组代替哈希表,可以取得更优的效果。尽管数组的下标是连续的,而哈希表的键不需要连续,所以数组中可能存在很多不必要的“窟窿”,浪费空间和遍历时间;但是,数组更加底层,而哈希表经过了更多的封装,根据我的经验,数组效果确实更好。
「@liberg」的解法中,也采取了这一策略。
但我依然好奇心大发,想着大佬在计数时,用的是一个长度为 20001 的数组,假如我换成哈希表,可以真正地减少循环的次数,省去大佬代码中对频率为 0 的数进行的 continue,还可以降低空间复杂度:
class Solution {
public int countPairs(int[] nums, int low, int high) {
int n = nums.length, ret = 0;
Map<Integer, Integer> frq = new HashMap<>();
for(int num: nums)frq.put(num, frq.getOrDefault(num, 0) + 1);
for(int x: frq.keySet()){
for(int t = low; t <= high; t++){
int y = t ^ x;
ret += frq.get(x) * frq.getOrDefault(y, 0);
}
}
return ret / 2;
}
}然后,没有然后,喜提超时了。所以,键范围确定的情况下,尽量用数组替代哈希表吧。
这道题的官解,对我来说看的很痛苦。首先需要我去学习之间没接触过过的字典树(也叫前缀树) 。
但是,当我补充了前缀树的知识,我依然……呃……那个……,读不懂官解🙃,相信我,我真的读了一遍又一遍,最后觉得我是个智障。所以@灵佬的解法让我抓住了救命稻草:
这标题,我打 100 分🤣。这帖子简直就是为我这种智商量身打造的嘛。
但我读了两遍,很快就哭着出来了,灵佬他固然没有骗我,但胜似骗我😭。对照着代码和文字反复看到三更半夜,才明白是怎么回事。
/**
作者:endlesscheng
链接:https://leetcode.cn/problems/count-pairs-with-xor-in-a-range/solution/bu-hui-zi-dian-shu-zhi-yong-ha-xi-biao-y-p2pu/
执行用时:70 ms, 在所有 Java 提交中击败了91.03%的用户
内存消耗:45 MB, 在所有 Java 提交中击败了76.07%的用户
通过测试用例:63 / 63
*/
class Solution {
public int countPairs(int[] nums, int low, int high) {
int ans = 0;
var cnt = new HashMap<Integer, Integer>();
for (int x : nums) cnt.put(x, cnt.getOrDefault(x, 0) + 1);
for (++high; high > 0; high >>= 1, low >>= 1) {
var nxt = new HashMap<Integer, Integer>();
for (var e : cnt.entrySet()) {
int x = e.getKey(), c = e.getValue();
if ((high & 1) == 1) ans += c * cnt.getOrDefault(x ^ (high - 1), 0);
if ((low & 1) == 1) ans -= c * cnt.getOrDefault(x ^ (low - 1), 0);
nxt.put(x >> 1, nxt.getOrDefault(x >> 1, 0) + c);
}
cnt = nxt;
}
return ans / 2;
}
}

以前每次看到官解用位运算,总觉得像是读鬼故事。我现在不害怕位运算了,但是位运算依旧每次都会给我新的惊喜(or 惊吓)。
问题一:考虑把一个非负整数 转换为二进制形式,我起初唯一能想到的方法是:对一个数除以二,余数是最低位的二进制值,然后拿商继续除以二,余数是次低位二进制值,如此往复,直到商变成 0。这个法子的时间复杂度是 。
问题二:如果我想获取某个数的倒数第 n 位二进制值,我唯一能想到的法子是:先尝试把这个数转换成二进制,直到求出倒数第 i 位。如此,时间复杂度是是 O(n)。
但是用右移运算、按位与运算,可以更好地解决上面两个问题。
对于问题一,可以不断把 右移一位,然后和 1 进行按位与运算,直到 。这样就能得到从低位到高位二进制序列了。这样做,比反复除以 2 效率高。
对于问题二,更是可以通过左移和按位与直接在 内解决:x & (1 << n),如果等于 ,那么 的倒数第 位就是 ;否则为 1。
灵佬上面的代码里,就用了这种技巧。
感兴趣的小伙伴可以尝试一下这道题剑指 Offer II 003. 前 n 个数字二进制中 1 的个数,感受一下不同写法的差异,还挺有趣的:
/**
方法 1:反复除以 2。效率较差。
执行用时:7 ms, 在所有 Java 提交中击败了13.23%的用户
内存消耗:45.7 MB, 在所有 Java 提交中击败了30.92%的用户
通过测试用例:15 / 15
**/
class Solution {
public int[] countBits(int n) {
int[] ret = new int[n + 1];
for(int i = 0; i <= n; i++){
int v = i;
while(v > 0){
ret[i] += v % 2;
v /= 2;
}
}
return ret;
}
}/**
方法二:反复右移,效率较高
执行用时:2 ms, 在所有 Java 提交中击败了40.61%的用户
内存消耗:45.4 MB, 在所有 Java 提交中击败63.19%的用户
通过测试用例 15 / 15
*/
class Solution {
public int[] countBits(int n) {
int[] ret = new int[n + 1];
for(int i = 0; i <= n; i++){
int v = i;
while(v > 0){
ret[i] += v & 1;
v >>= 1;
}
}
return ret;
}
}此外,还可以尝试一下剑指 Offer 15. 二进制中1的个数,也十分有趣,可以用多种不同写法。
我在灵佬的 java 版代码里看到了关键字 var,恍惚了一秒钟,还以为是 javascript 和 java 写串了(我是前端出身)。然后很快反应过来,这应该是 java8 以上的某个版本中新语法,我平常用的都是 java8 🤣。
还有就是,灵佬代码里那个 for 循环,看起来很奇特,我是第一次遇到:
// 第二个分号后的表达式子里,出现了逗号,而且居然不会报错,神奇
for (++high; high > 0; high >>= 1, low >>= 1){}但java 里如果写成:
int i = 0, j = 0;
i = 10, j = 20;那么第二行的逗号那里是会报错了。所以我对这个 for 循环里的逗号语法,感觉颇为奇特,学到了。
灵佬的这个题解,技能点密度对我来说太高,所以前面铺垫的有点多了。现在正式开始给大佬做注解。这里再贴一遍灵佬的帖子链接,方便读者可以右键在浏览器新标签中打开,和本文对照着看:
下面基截自灵佬帖子的图中,我用红框圈出来的部分,其实就是我前面讲到的:[「@liberg」的代码魔改 2](#「「@liberg」的代码魔改 2),所以这一块我们可以愉快地跳过,忘了的小伙伴点击蓝字跳到前面再看看:
接下来,把求解闭区间 上的数量问题,转换成分别求解求解左闭右开区间 和 上的数量问题,二者相减就是答案。这一步转换很要,这里利用了类似前缀和的思路:
对一个数组 arr,求出每一个下标对应的数组前缀中所有元素的和,就可以轻松求出任何一个下标区间内所有元素的和。
这也是我和大佬的差距,我虽然知道前缀和,也做过相关的题,但是这道题这么明显,也是要求解某个区间上的特定数量问题,我居然没有第一时间想起前缀和来。以后一定把这个观念焊死在脑子里。
原贴中下面这张图里被我描红的部分,真的是折磨了我好久好久,我连躺在床上睡觉了也依然在琢磨:
原贴中,即便灵佬还解释了一下:
问:「数对异或值的后两个比特是什么都可以」是什么意思?
答:区间 的后两个比特为 ,无论是哪两个数异或,后面两个比特必然是这四种之一,所以只需要考虑 的前三个比特就行了,后面两个比特是多少无所谓。
但这个解释对我依然是天书。真的需要感叹一句,造化钟神秀,但是把神秀都放在别人大脑里了,一点也没匀出来给我,绝望😭😭。这种绝望感有人能理解吗?
好在我锲而不舍,反复对比文字、代码、评论区的留言,总算看懂了。我现在尝试更形式化地为灵佬注解一下:
设数组 nums 中元素的二进制形式最长为 位,不足 位的数,前面补 。遍历数组,分别统计长度为 的所有可能的二进制前缀的数目。
对于一个非负数 ,我们的目标是求左闭右开区间 上,所有 nums 中异或值位于该区间内的数对的数量。
假设 二进制形式中有 位 是 ,设其中倒数第 个 1 位于倒数第 位 ;不失一般性,假设 的倒数第 bit 为 1,否则更新之。于是,可以按照这些等于 的位,将左闭右开区间 ,拆分成 个,其中倒数第 个为:
注意这些区间也是左闭右开。上面这个区间是使用数学语言描述的,看起来比较可怕,但是换成程序语言,就很好理解:区间右边界是把 的后 个二进制位替换成 0,左边界类似,可以使用右移和左移快速求出区间。
这个区间有这样的性质:区间内的每个数,都有公共二进制前缀:
且该前缀的长度为:
举个例子,,其中共有三个 ,总共可以被拆分为三个区间:
我们前面已经统计出了任意长度的某个前缀的数目,所以对于倒数第 个区间,很容易求出所有异或值等于 ,且长度等于 的所有前缀对的数目 。显然, 恰好等于异或和位于该区间内的所有原始数组中的元素对的数目。
至此,前面那张灵佬的图中描红的部分,就得到了解释。
最后,累加每个区间上的,就能得到最终答案:
注意:求和后要除以二,因为异或运算满足交换律,且相同的两个数异或结果为 0,而题目要求的 对需要满足 。
这个算法,会不会重复计数或者有所遗漏呢?不会。因为拆分出来的每个子区间彼此是连续的,既没有空隙,也没有重叠。
思路理顺以后,就是是编码工作了。灵佬的原版代码十分高效紧凑,一开始理解起来有点难度。所以我先写了一个等价的、更啰嗦、更低效、但是更好理解的魔改版本:
这个版本核心,在于 helper 函数,我加了详细的注释。读者可以先看这个版本,然后再把代码精简成灵佬的版本:
/**
灵佬原版代码魔改 1:等价的、更啰嗦、更低效、但是更好理解的版本
执行用时:321 ms, 在所有 Java 提交中击败了20.31%的用户
内存消耗:48.8 MB, 在所有 Java 提交中击败了44.68%的用户
通过测试用例:63 / 63
*/
class Solution {
private final int MAX_BITS = 15; // nums中的数字,最多有 MAX_BITS 个二进制位
public int countPairs(int[] nums, int low, int high) {
return helper(nums, high + 1) - helper(nums, low);
}
/**
* 对于区间[0, upper), 求出所有异或运算结果等于区间内任意元素的所有数对的数量,数对中的数字,都位于数组 nums 内。
* 注意区间是左闭右开的
*/
private int helper(int[] nums, int upper){
int ret = 0;
// 1. 统计不同长度的所有可能的前缀数目
// cnt[i]是一个哈希表,存储 nums 中的数字被去掉结尾的 i 个 二进制位后形成的所有可能的【二进制前缀】的数量,key 表示前缀,value 表示数量
Map<Integer, Integer>[] cnt = new HashMap[MAX_BITS + 1];
for(int i = 0; i <= MAX_BITS; i++){
Map<Integer, Integer> cntMap = new HashMap<>();
for(int x: nums){
// 将 nums 中的数字 x,右移 i 位,相当于去掉了最末尾的 i 个 bit,得到 x 的长度为 MAX_BITS - i 的前缀
int prefix = x >> i;
cntMap.put(prefix, cntMap.getOrDefault(prefix, 0) + 1);
cnt[i] = cntMap;
}
}
// 02. 切分区间,并且遍历每个子区间。设任意子区间有相同的长度 l 的公共前缀 prefix,只需要求出所有异或结果等于 prefix、且长度等于 l 的前缀对的数量,就能确定异或值位于该子区间内所有原始数字对的数量。
// 为了演示思路,下面的代码略啰嗦低效
// 这个 for循环遍历 upper 的所有二进制位中,等于 1 的位,并以此计算区间分割点,切出一个区间
for(int i = 0; i < MAX_BITS; i++){
if((upper & (1 << i)) == 0){ // upper的倒数第 i + 1 位是 0,跳过,直到遇到 1
continue;
}
Map<Integer, Integer> cntMap = cnt[i];
// upper 右移 i 位,也就是去掉结尾的 i 比特,得到前缀 upper >> i,此时前缀的倒数第 1 bit,肯定是 1
// 由于 helper 求的是左闭右开区间[0, upper)上的情况,所以将前缀减去 1,用 t 表示之
// t 就是当前切出的区间内所有数字的公共前缀,前缀的长度为 MAX_BITS - i。
int t = (upper >> i) - 1;
// 求出所有「长度为 MAX_BITS - i 的前缀」两两异或后,结果等于公共前缀 t 的【前缀对儿】的数目。 用 count 表示
int count = 0;
for(int x: cntMap.keySet()){
// 更新异或结果等于 t 的数对的数量
// 注意 if 条件不要写成 if(upper & 1 != 0)
count += cntMap.get(x) * cntMap.getOrDefault(x ^ t, 0);
}
ret += count;
}
return ret >> 1; // 因为异或运算满足交换律,结果要除以 2。刚体会到右移运算的妙处,忍不住用一把哈哈。
}
}这个版本执行时间大大减少,和版本 1 相比:
nums,循环次数更小。这个版本再优化一下,就可以得到灵佬的原版了:只需把 helper(cnt, high + 1) 和 helper(cnt, low),合并到一个 for 循环中,避免额外多做的那一次计数。
/**
灵佬原版魔改版本 2:优化了空间复杂度,减少了循环的迭代次数。
和魔改版本 1 相比,主要差别在:
1. 不是一次性求出所有长度的前缀的数量,而是用一个滚动的哈希表,只储存当前需要的长度。并且,不需要对每个前缀长度都遍历一遍 `nums`,循环次数更小。
2. 反复右移 upper,而不是完整遍历[0, max_bits], 减少了迭代次数。
这个版本再优化一下,就可以得到灵佬的原版了:把 helper(cnt, high + 1)和 helper(cnt, low),合并到一个 for 循环中,避免额外多做的那一次计数。
执行用时:114 ms, 在所有 Java 提交中击败了25.38%的用户
内存消耗:48.7 MB, 在所有 Java 提交中击败了49.49%的用户
通过测试用例:63 / 63
*/
class Solution {
public int countPairs(int[] nums, int low, int high) {
int n = nums.length, ret = 0;
Map<Integer, Integer> cnt = new HashMap<>();
for(int num: nums)cnt.put(num, cnt.getOrDefault(num, 0) + 1);
return helper(cnt, high + 1) - helper(cnt, low);
}
/**
* 对于区间[0, upper), 求出所有异或运算结果等于区间内任意元素的所有数对的数量,数对可能的取值位于 cnt 的 keySet 中。
* 注意区间是左闭右开的
*/
private int helper(Map<Integer, Integer> cnt, int upper){
int ret = 0;
for(; upper > 0; upper >>= 1){ // 每次循环,都把 upper 的最末二进制位去掉
Map<Integer, Integer> next = new HashMap<>();
int t = upper - 1;
for(int x: cnt.keySet()){
int c = cnt.get(x), prefix = x >> 1;
// 更新异或结果等于 t 的数对的数量
// 注意 if 条件不要写成 if(upper & 1 != 0)
if((upper & 1) == 1)ret += c * cnt.getOrDefault(x ^ t, 0);
// 计算将 x 去掉最末二进制位后的前缀 prefix的数量
next.put(prefix, next.getOrDefault(prefix, 0) + c);
}
cnt = next;
}
return ret >> 1; // 因为异或运算满足交换律,结果要除以 2。刚体会到右移运算的妙处,忍不住用一把哈哈。
}
}前文提到过,在键值范围确定的情况下,用数组替代哈希表,通常可以取得更好的效果。
本版本就是按照这个思路做的优化,和原版相比,执行时间确实大幅提升。
/**
灵佬原版魔改 3:用数组替代哈希表
执行用时:21 ms, 在所有 Java 提交中击败了99.53%的用户
内存消耗:42.8 MB, 在所有 Java 提交中击败了99.21%的用户
通过测试用例:63 / 63
*/
class Solution {
private final int N = 20001; // nums中数字的最大二进制位数
public int countPairs(int[] nums, int low, int high) {
int n = nums.length, ret = 0;
int[] cnt = new int[N];
for(int num: nums)cnt[num]++;
for(++high; high > 0; high >>= 1, low >>= 1){
int[] next = new int[N];
for(int x = 0; x < cnt.length; x++){
int c = cnt[x];
if(c == 0)continue;
if((high & 1) == 1){
int y = x ^ (high - 1);
if(y < N)ret += c * cnt[y];
}
if((low & 1) == 1){
int y = x ^ (low - 1);
if(y <= N)ret -= c * cnt[y];
}
next[x >> 1] += c;
}
cnt = next;
}
return ret >> 1;
}
}对复杂度计算不感兴趣的读者可以先跳过。灵佬原贴写到:
首先, 指的的是 数组 nums 中的的最大值。
cnt 长度最坏为 。而哈希表需要被遍历一次,所以是 。读这道题的官解,是我第一次接触到前缀树这种数据结构。感觉,amzaing……到底还有多少种树是我不知道的?
官解给出的前缀树参考题目「208. 实现 Trie (前缀树)」,也十分有趣,并且不难理解。顾名思义,字典树中保存着一本字典,其每个节点对应着一个字符串前缀,从根节点到这个节点的路径,就是这个前缀;如果节点的 isEnd 属性为 true,则相应的前缀同时还是一个单词。
根据我的理解,前缀树和普通树在存储结构上最大的区别,是前缀树的子节点被存储在一个哈希表中,查找某个节点的值已知的子节点,可以在 内完成,而不用遍历所有子节点。所以,如果要查找前缀树中是否存在某个长度为 的前缀或字符串,需要 的时间。
我对 208 这道题写了一个题解,比较了一下字符串两种不同的存储方式,再次印证了前文提到的:
在键值范围确定的情况下,用数组替代哈希表,通常可以取得更好的效果。
假如为字典树的节点维护一个 sum 属性,用来表示有多少个单词共用节点对应的前缀,并且在插入节点时维护这个属性,那么,用这种形式解这道地狱题。本题中,单词的字符集为 ,单词指的就是 nums 中的数字的二进制序列,这样的字典树叫做 01-trie。
先上官解链接:
官解再次诠释了什么叫:我认识每一个字,但是我真的不知道是什么意思。这真的是我刷题以来最绝望的一次。
我再次反复比对官解的文字和代码,当我看懂以后,我尝试自己复述一遍这个思路,写个题解;然后我绝望地发现,自然语言真的太苍白了,似乎不管我怎么表达,都说不清楚。当我过了两天重新准备完成这个题解时,我甚至连千辛万苦看懂的思路,居然也被忘了😭。
以前总是忍不住吐槽官解,以后再也不好意思吐槽了。如果你懂这种凌乱的感觉,快来和我握个抓~

官解也是先利用前缀和,对问题进行了转换。这一点和灵神的思路是一样的,只不过灵神的区间是左闭右开,官解是左右都闭,这关系不大。让我们再次把前缀和焊死在大脑里:
题目想要求解有多少对数字的异或运算结果处于 之间,为了方便求解,我们用 来表示有多少对数字的异或运算结果小于等于 ,这时问题变为求解 。
随后,官解的文字描述部分,以光速迈进了迷魂阵模式,不管怎么读,都读不懂、读不通,我前后反复读了不下 20 遍。特别是下图中红框里圈出来的部分,极其费解,如果孤立地看某些句子,我甚至会认为这些句子可能是错误的,比如字面箭头处的句子①:
读不懂文字,只能硬着头皮读代码,相互参照着看。可以看到,代码中先是从左到右遍历 nums,将元素逐个加入到一颗 01 字典树(01-trie);每次加入一个元素,都要调用一次 get 方法,这个 get 方法正是官解的核心,也是上图中的大红框所描述的内容。
我详细地给 get 方法加了注释,看完注释,大体上就能明白上图红框里的意思了(如果不抠红框里字眼的话):
/**
官解添加详细注释版。官解的核心,是下面详细被注释了的 get 方法。
执行用时:85 ms, 在所有 Java 提交中击败了74.44%的用户
内存消耗:48.8 MB, 在所有 Java 提交中击败了41.93%的用户
通过测试用例:63 / 63
*/
class Solution {
// 字典树的根节点
private Trie root = null;
// 最高位的二进制位编号为 14
private static final int HIGH_BIT = 14;
public int countPairs(int[] nums, int low, int high) {
return f(nums, high) - f(nums, low - 1);
}
public int f(int[] nums, int x) {
root = new Trie();
int res = 0;
for (int i = 1; i < nums.length; i++) {
add(nums[i - 1]);
res += get(nums[i], x);
}
return res;
}
/*将数字 num 加入 01-tree*/
public void add(int num) {
Trie cur = root;
for (int k = HIGH_BIT; k >= 0; k--) {
int bit = (num >> k) & 1;
if (cur.son[bit] == null) {
cur.son[bit] = new Trie();
}
cur = cur.son[bit];
cur.sum++;
}
}
/**
* 官解的核心方法。传入数组 nums 中的一个数 num。本方法被调用时,数组中位于 num 之前的所有数字,都已经被添加到了 01-trie 中了。
* 还要传入一个上界 x,最终返回 nums 中所有异或结果位于区间 [0, x] 的数对(y, num)的数量,其中 y 是数组 nums 中位于 num 前面的数。
* 注意:代码注释中说的【位】,都是指二进制位。
* @param num
* @param x
* @return
*/
public int get(int num, int x) {
// 注意,本注解中,凡是说到树的【倒数第 几 层】、【倒数第 几 位】,都是从 1 算起,而不是从 0 算起。官解在逻辑上所有的数字补齐了前导 0,但是位数的编码是从 0 开始的,最高位为 HIGH_BIT = 14。
// 初始化 cur 为 01-trie 的根节点。此时,cur 位于 01-trie 的倒数第 HIGH_BIT + 2 层。注意,按照官解的写法,树总共有 HIGH_BIT + 2 层。
// 这个算法的主体是一个循环,每循环一次,cur 在树中的层级就会下降 1 层。记某次迭代中 cur 所在的层级为倒数第(k + 1)层,则
// cur 总是沿着这样的轨迹运动:若 cur 存在某个子节点(必然位于倒数第 k 层),子节点对应的位和 num 的倒数第 k 位异或后,等于 x 的倒数第 k 位,那么将 cur 更新为它的这个子节点。如果找不到这样的子节点,则循环结束。
Trie cur = root;
int sum = 0; // 答案
for (int k = HIGH_BIT; k >= 0; k--) { // 从最高层开始,按层序访问 01-trie
// 新的一轮迭代开始,此时 cur 位于倒数第 k + 2 层,其子节点 cur.son[0]和 cur.son[1]位于第 k + 1 层
// 此时,记 cur 对应的前缀为 prefix,如果 num 的倒数第 k + 2, k + 3, ……, HIGH_BIT,HIGH_BIT + 1 位和 prefix 相异或,算法会保证这几位必然都会变成 0;
// 获取数字 num 倒数第 k + 1 位的值 r。心里需要有个基本认知:cur.son[r]的值,此时必然等于 r,二者异或一下,结果为 0;cur.son[r ^ 1] 的值 和 r 异或一下,结果为 1。
int r = (num >> k) & 1;
// 判断 x 的 倒数第 k + 1 位是 1 还是 0。
if (((x >> k) & 1) != 0) {
if (cur.son[r] != null) {
// x 的 倒数第 k + 1 位是 1,而在倒数 k + 2(含)位到倒数第 HIGH_BITS + 1(含)的位上,cur 对应的前缀 和 num 异或的结果等于 x 的相应位;
// 另一方面,前面已经提到,位于第 k + 1位(层)的cur.son[r]的值,此时必然等于 r,二者异或一下,结果为 0,
// 所以,nums 中任何以cur.son[r]为前缀的数字,和 num 异或后,必然小于 x;
// 所以,将cur.son[r] 的 sum值,累加到答案中
sum += cur.son[r].sum; // 把以这个子节点对应的前缀为前缀的所有数字的数目,累加到答案中
// 那么,nums 中,还有没有其他前缀不是 cur.son[r] 的数字,也满足条件呢?
// 必然是有可能的,但是现在还没法确定这些数字,只能按照 cur 运动规则,让它继续沿着树往下走。怎么证明这个运动规则一定能帮助得到正确答案,既不会遗漏任何数字对,也不会重复计数呢?这一点需要证明,先按下不表。
}
// 此时,cur.son[r]无法满足 cur 的运动方向,而它的兄弟节点 cur.son[r ^ 1] 和 r 异或后等于 1,和 x 的第 k 位 1 必然相等,按照前面提到的 cur 的运动规则,将 cur 更新为 cur.son[r ^ 1]。如果cur.son[r ^ 1]不存在,循环结束。
if (cur.son[r ^ 1] == null) {
return sum;
}
cur = cur.son[r ^ 1];
} else {
// x 的 倒数第 k + 1 位是 0。
// 此时cur.son[r] 和 r 异或后,必然等于 0,而此时 x 的倒数第 k 位恰好也是 0, 按照前面提到的 cur 的运动规则,将 cur 更新为 cur.son[r]。如果cur.son[r] 不存在,循环终止。
if (cur.son[r] == null) {
return sum;
}
cur = cur.son[r];
}
}
// 能走到这一步,说明 cur 的运动轨迹恰好等于 x 。cur 此刻对应 x 的最后一位, cur 的 sum 值代表等于 x 的数字,cur 和 num 异或后,恰好等于 x,满足条件,将其 sum 累加到结果中。
sum += cur.sum;
return sum;
}
}
class Trie {
// son[0] 表示左子树,son[1] 表示右子树
Trie[] son = new Trie[2];
int sum;
public Trie() {
sum = 0;
}
}
以上,是我写过的最狂乱的代码注释。我再也不想遇到第二次了。官解中的位的编号是倒着来的,而且从 开始,真的让好难受。
看懂算法执行的流程后,新问题来了:正如注释中提到的,怎么证明这个算法既不会重复计数,也不会有遗漏呢?
现在我们已经搞清楚了官解的步骤,但是官解并没有给出正确性证明,或者官解以为它给出了,只是我没有看出来🦉。(现在说话好难,必须严谨一点,不然又被能被喷,说我态度有问题,是在怼官解)
容易注意到,官解中对结果有贡献的时刻,是 get 方法中发现上界 x 的某一位为 的时候。此时,不妨设 这个位是第 位(从 开始编号),则此时的 cur.son[r],和 num 的前 位异或后,得到的前缀为 x 的前 位减去 。
是不是似曾相识?
巧得很,灵佬的方案中,也是以为 的位为关键点,来切分数组的。
这种相似性,暗示着两种解法具有某种底层的联系。
举个例子,令 ,其中共有 4 个位是 1,对应的前缀可参照线图红色部分:

可以看到,这些分界点,与前面介绍灵佬的解法时,是很像的。
可以很直观地看到,图中的 4 个红色的前缀,可以涵盖所有小于上界 的数。由于官解中 左右都是闭区间,所以循环结束后,还要额外加上异或值等于 的数对的数量。如此,既不会重复计数,也不会遗漏。
有这个直观说明就够了,没必要再写一大堆公式形式化地证明了。我突然感觉打字好累啊🤣。
可以看到,等于 1 的位,在两种解法中都处于关键地位。
两种解法都利用了前缀,大大减少了暴力遍历的次数。
灵佬在他的题解里还提到了二者的不同:
其实这个方法就是字典树自底向上的过程,由于没有像字典树那样逐位统计 的每个比特位,所以时间复杂度和空间复杂度都比字典树的写法要优(见复杂度分析)。
可以好好品味一下这段话。确实,官解是借助字典树,从高位到低位逐步计算的;灵佬的解法没有显示地声明字典树,是从低位到高位逐步计算的。
/**
官解优化:
1. 原先需要维护两颗字典树,改成 1 颗
2. 将区间右侧改成开区间,规避每次 get 方法中循环接结束后,需要额外在增加异或值等于 x 的数对数量
3. 简化 get 方法中漫山遍野的 if 条件
执行用时64 ms, 在所有 Java 提交中击败了95.49%的用户
内存消耗:49.1 MB, 在所有 Java 提交中击败了19.00%的用户
通过测试用例:63 / 63
*/
class Solution {
class Trie {
// son[0] 表示左子树,son[1] 表示右子树
Trie[] son = new Trie[2];
int sum;
public Trie() {
sum = 0;
}
}
// 字典树的根节点
private Trie root = null;
// 最高位的二进制位编号为 14
private static final int HIGH_BIT = 14;
public int countPairs(int[] nums, int low, int high) {
root = new Trie();
int res = 0;
high++;
for (int i = 1; i < nums.length; i++) {
add(nums[i - 1]);
res += get(nums[i], high);
res -= get(nums[i], low);
}
return res;
}
public void add(int num) {
Trie cur = root;
for (int k = HIGH_BIT; k >= 0; k--) {
int bit = (num >> k) & 1;
if (cur.son[bit] == null) {
cur.son[bit] = new Trie();
}
cur = cur.son[bit];
cur.sum++;
}
}
public int get(int num, int x) {
Trie cur = root;
int sum = 0;
for (int k = HIGH_BIT; k >= 0; k--) {
int r = (num >> k) & 1;
int rx = (x >> k) & 1;
if(rx == 1 && cur.son[r] != null) sum += cur.son[r].sum;
Trie next = cur.son[r ^ rx];
if(next == null) return sum;
cur = next;
}
return sum;
}
}终于写完了,可以睡个好觉了。
\oplus 输出之,很形象也很好记, 外面套了个 。这道题消耗了我大量的时间,而且并不仅仅是因为缺乏字典树这个前置技能。我在学习了字典树后,依然在很长时间内,怎么都看不懂这道题的题解,无论是灵佬的,还是官解。
解决的办法,似乎只能是来回看文字和代码,一边靠文字猜测代码的逻辑,一边靠代码猜测文字的含义,相互印证🤣。但是这样效率奇低,这个过程中需要不断做出各种假设,并仔细求证,期间思维很容易发散,最后耗尽精力一无所获。
以后再遇到这种文字比较难懂的情况,我会选择把主要精力放在啃代码上。因为文字这种载体比较特殊,表达者传递的,和阅读者理解到的,可能相差会很远,特别是用自然语言表达的时候。而代码不同于文字,需要遵循硬一点的逻辑,写错了无法通过。
通过硬啃代码,至少可以先搞清楚算法的流程。光搞清楚流程,可能依然搞不清背后的原理,但是针对流程提出的疑问,可以为进一步思考提供目标和着力点。
此外,这两篇题解都侧重于描述算法本身,没有详细提供对正确性的证明,可能是大佬觉得比较简单,但是对我来说是巨大的门槛。
我有时候,自己也会写点题解,虽然主要是为了让自己看,但是毕竟是公开的,还是希望能逐渐提高写作水平,收到读者的认可。本题的两篇题解,让我对技术协作本身思考了很多。
不过这部分就略过了,不放在本文了🤣。
最后,创作不易,请留下你的赞再走^_^,这样小海豹会很开心的~~。
