题目链接:https://codeforces.com/contest/1322/problem/B
题意
给一个 (n(1leq n leq 400000)) 个数的数组 (a) ,其中的元素都是正整数,且数据范围是 (a_1,a_2,...,a_n(1leq a_i leq 10^7)) 。求下式的值:
其中, (oplus) 表示异或运算。换言之,就是对于每个无序对 ((i,j)) ,求出 ((a_i+a_j)) 的值 (v_{i,j}) ,然后再求所有得到的值 (v_{i,j}) 的异或和。
时间限制:3000ms
空间限制:512MB
题解
下面展示一下整个算法设计并优化的过程。
一、暴力算法
时间复杂度:(O(n^2))
很多高效的算法,都是先设计一个暴力算法,然后通过观察题目和数据之中的特殊条件作出针对性优化。这题的暴力算法非常简洁,观察上面的式子,可以通过枚举无序对 ((i,j)) 来直接计算出 (v_{ij}) ,然后再把全部结果 (v_{ij}) 异或起来。
这个算法的时间复杂度是 (O(n^2)) 。
这个算法的时间复杂度过高,下面进行逐步优化。
二、按二进制位分解,对每个二进制位,按照低位排序,再使用二分法统计
求这个公式的值,复杂的地方在于异或运算与加法运算不满足分配律,也就是说是因为加法运算产生的进位导致不能够直接把每一个二进制位单独抽出来考虑。那么一个优化的方法就是统计加法运算产生的进位对异或运算的影响。
虽然不能很容易求出整个答案,但是很容易求出答案的最低位是0还是1。分解每个元素的二进制位,记最低位为0的有 (cnt_0) 个,记最低位为1的有 (cnt_1) 个。要使得答案的最低位是0,就要求最低位为1的 (v_{ij}) 有偶数个。也就是当 (cnt_0*cnt1) 为偶数时,最低位是0,否则最低位就是1。然后可以同时观察出,产生的进位的个数为 (cnt_1*(cnt1-1)) 。
再观察次低位,容易知道也可以记次低位为0的有 (cnt_0x) 个,记次低位为1的有 (cnt_1x) 个,不过此时要使得答案的最低位是0,就要求最低位为1的 (v_{ij}) 加上从最低位进位上来的1,一共有偶数个。这里要统计最低位产生的进位的个数,可以直接用上一步的结果,不过问题在于这个算法不容易推广到第3低位,因为无序对 ((i,j)) 在第3低位产生进位的条件是“ (a_i,a_j) 的最低2位的和 (geq 100) (这里的100是指二进制的4)”,会导致需要分成 (cnt_{00},cnt_{01},cnt_{10},cnt_{11}) 四种不同的最低2位的组合来统计。以此类推到更高位时,这个算法需要统计的量是指数上升的。
这里就要引入这道题解法的核心:排序。
以十进制为例,假如有一个排序后的数组 ({0,1,2,3,5,5,7,8,9}) 。那么:
和 (0) 组合产生进位的数不存在;
和 (1) 组合产生进位的数为 ({9}) ;
和 (2) 组合产生进位的数为 ({8,9}) ;
和 (3) 组合产生进位的数为 ({7,8,9}) ;
和第一个 (5) 组合产生进位的数为 ({5,7,8,9}) ;
和第二个 (5) 组合产生进位的数为 ({7,8,9}) ;
和 (7) 组合产生进位的数为 ({8,9}) ;
和 (8) 组合产生进位的数为 ({9}) ;
和 (9) 组合产生进位的数不存在。
(上面统计的是无序对的情况,所以有些组合不能重复计算)
可以观察到,从排序后的数组中从左到右枚举每个元素,产生进位的组合的区间都在其右侧,且是包含右端点的连续的一段,这里可以使用二分来确定产生组合的区间的左端点的位置。那么每一位的复杂度就是一次 (O(nlogn)) 的排序,然后对于每个元素进行一次 (O(logn)) 的二分。对于这道题来说,整体的时间复杂度 (O(nlognlog(max(a_i))) 足够通过,但是还有可以优化的地方。
三、改用基数排序替代快速排序,改用双指针法替代二分法
上面的算法比较简单且容易理解,下面我想介绍的是我说的高性能程序设计的“高性能”在于哪里。
记当前位为 (b+1) 位,可以发现对于低 (b) 位二进制处理完成之后,这个数组是“对于低 (b) 位二进制位而言有序的”。所以可以利用这个结果,使用一步基数排序,或者说一步归并排序,来将“对于低 (b) 位二进制位而言有序的”数组变成“对于低 (b+1) 位二进制位而言有序的”,这里可以把每个元素按照当前位是0还是1分类,假如当前位后分别放入两个数组中,然后使用STL的merge函数进行合并。那么在当前位排序产生的时间复杂度就是 (O(n)) 。那么算法的瓶颈就变成了对于每个元素,需要通过二分来确定其产生组合的区间的位置。
还是以上面的十进制为例,假如有一个排序后的数组 ({0,1,2,3,5,5,7,8,9}) 。那么:
和 (0) 组合产生进位的数不存在;
和 (1) 组合产生进位的数为 ({9}) ;
和 (2) 组合产生进位的数为 ({8,9}) ;
和 (3) 组合产生进位的数为 ({7,8,9}) ;
和第一个 (5) 组合产生进位的数为 ({5,7,8,9}) ;
和第二个 (5) 组合产生进位的数为 ({7,8,9}) ;
和 (7) 组合产生进位的数为 ({8,9}) ;
和 (8) 组合产生进位的数为 ({9}) ;
和 (9) 组合产生进位的数不存在。
(上面统计的是无序对的情况,所以有些组合不能重复计算)
从排序后的数组中从左到右枚举每个元素,产生进位的组合的区间都在其右侧,且是包含右端点的连续的一段,且区间的长度先增加后减少,增加的原因是因为枚举的元素变大了,可以和更小的元素组合产生进位,减少的原因是因为统计的是无序对,所以规定每个元素只和其右侧的元素组合。
那么满足这种性质的统计问题,就是经典的“双指针法”:
枚举的元素是左指针 (L) ,需要被统计的产生进位的区间的左端点是右指针 (R) ,显然左指针至多移动 (n) 次,右指针首先向左移动至多 (n) 次,然后遇到左指针之后,会被左指针向右“驱赶”至 (R=L+1) 。整个统计的过程是 (O(n)) 的。由于使用了 (O(n)) 的排序,统计该位的复杂度就就是 (O(n)) ,需要统计的二进制位有 (O(log(max(a_i))) 位,使用基数排序以及双指针的解法,把整个问题的复杂度降低到 (O(nlog(max(a_i))) 。