一个 P8438 极寒之地 的脚手架

  求 $\bigoplus^{2^n-1}_{s=0} (s \bigoplus^{n-1}_{i=0} a_i[s \& 2^i=1]) \bmod 2^{64}, n \leq 30, a_i \leq 2^{64}-1$

  $\bigoplus^n_{i=1}$ 表示 $a_1 \oplus a_2 \oplus \cdots \oplus a_n$,$[p]$ 当 $p$ 为真时为 $1$,否则为 $0$。 

  在讨论算法前,看到 $a_i$ 的数据范围,用无符号长整型(unsigned long long)恰好能储存,而且它的自然溢出特性,使得我们不必特地取模,所以我们选择它。

  如果你想几分钟想不到比 $O(2^n)$ 更优秀的算法,可以想想有没有 $O(2^n)$ 的做法。这时候你应该在 OJ 上先测测能不能跑 $2^{30}$ (注意不要让你的循环被编译器优化掉),发现确实可以,-O2 才跑了 300ms。

  既然是 $O(2^n)$,我想就要让每个 $s$ 被访问到 $O(1)$ 次,计算 $s$ 对答案的贡献(即 $s \bigoplus^{n-1}_{i=0} a_i[s \& 2^i=1]$)是 $O(1)$ 的,所以我想到用 $s$ 的贡献来更新 $s \& 2^k$ 的贡献,其中 $k > s$ 二进制中 '1' 的最高位,这样子每个 $s$ 都被恰好更新一次(更新它的数就是 $s$ 最高的 '1' 变成 '0' 的那个数),而且更新也只需要 $O(1)$ 的时间。但很可惜,如果要存储每一个 $s$ 的贡献,空间不可承受,所以只能递归求解,而递归 $O(2^n)$ 次是过不了这道题的(不卡常,我最快也要耗时 1.5s)。

  于是我放弃递归做法,所以新做法要做到不用存储每一个 $s$ 的贡献才行,而又要满足每个 $s$ 被访问到 $O(1)$ 次,计算 $s$ 的贡献是 $O(1)$ 的。那么最自然的想法是用 $s$ 的贡献来 $O(1)$ 更新 $s+1$ 的贡献。怎么做呢?考虑 $s$ 和 $s+1$ 的不同,设 $(s+1)_2$ '1' 的最低位为 $k$,那么 $(s)_2$ 第 $k$ 位为 '0',第 $j(0 \leq j < k)$ 位为 '1',其余位和 $(s+1)_2$ 相同。那么我们要做的就是让 $s$ 的贡献异或 $a_k$,再“消掉” $a_j(j < k)$,即解 $x \oplus a_j = v$ 的方程($v$ 代表贡献),其实就是要求异或的逆运算是什么。讨论异或的逆运算前,首先看看异或是什么,$$0 \oplus 0 = 0, 0 \oplus 1 = 1, 1 \oplus 0 = 1, 1 \oplus 1 = 0. $$我们发现,异或可以看成二进制不进位的加法,所以异或的逆运算应当是二进制不退位的减法才对。所以有 $$0 \oplus^{-1} 0 = 0, 0 \oplus^{-1} 1 = 1, 1 \oplus^{-1} 0 = 1, 1 \oplus^{-1} 1 = 0, $$进一步得出,异或的逆运算就是异或(即 $a \oplus b \oplus b = a$)!那么,$s+1$ 的贡献就是 $s$ 的贡献异或上 $a_j(j \leq k)$。直接做这个操作,可以得到如下代码:

#include <bits/stdc++.h>

int main()
{
	int n;
	scanf("%d",&n);
	unsigned long long a[35];
	for(int i=0;i<n;i++)
		scanf("%llu",a+i);
	unsigned long long ans=0,res=0;
	for(int s=1;s<(1 << n);s++) // 注意 s 从 1 开始枚举
	{
		int bit=0;
		while((s&(1 << bit)) == 0)
			bit++; // 求 s 二进制中 '1' 的最低位
		for(int j=0;j <= bit;j++)
			res ^= a[j];
		ans ^= res*s;
	}
	printf("%llu\n",ans);	
}

  很多人认为这段代码是 $O(n2^n)$ 的,因为 $s$ 更新 $s+1$ 的时间复杂度是 $O(n)$ 的,但是更新操作的均摊复杂度就一定是 $O(n)$ 吗?不妨记录一下  bit++;  执行了多少次,发现执行次数和 $2^n$ 差不多,这预示着更新操作的均摊复杂度可能不是 $O(n)$ 的。接下来我们具体算一下更新操作的均摊复杂度。显然更新操作的均摊复杂度和 bit 的平均值是同阶的,bit 即 $s$ 后缀零的个数,所以我们求下 $s$ 后缀零的期望是多少就行了。当 $s \in [0, 2^n)$ 的时候,$s$ (不考虑 0)有 $i$ 个后缀零的概率是 $2^{-i-1}$,那么 $s$ 后缀零的期望就是 $\sum^{n-1}_{i=0} 2^{-i-1}i$,容易证明这东西是 $O(1)$ 的,你把这东西扔到 wolframalpha 会发现它等于 $1 - 2^{-n}(n+1)$。所以这份代码中更新操作的均摊复杂度是 $O(1)$ 的(所以接下来我们对更新操作的改进只能改变它的常数),总复杂度是 $O(2^n)$ 的,但是过不去这题。我们还需要优化更新操作的常数。

  常数优化其实不都是无关算法的东西。求 bit 可以不用枚举,而是使用 GCC 的内置函数 __builtin_ctz(x) ($O(1)$ 返回 $(x)_2(0 < x < 2^{32})$ 的后缀零个数,这东西现在 NOIp 也可以用了)。更新 res 发现可以用前缀异或和(即把 $\bigoplus^j_{i=0} a_i(j<n)$ 求出来)。

#include <bits/stdc++.h>

int main()
{
	int n;
	scanf("%d",&n);
	unsigned long long a[35];
	for(int i=0;i<n;i++)
	{
		scanf("%llu",a+i);
		if(i)
			a[i] ^= a[i-1]; // 这里的 a[i] 是前缀异或和
	}
	unsigned long long ans=0,res=0;
	for(int s=1;s<(1 << n);s++)
	{
		res ^= a[__builtin_ctz(s)]);
		ans ^= res*s; 
	}
	printf("%llu\n",ans);	
}
posted @ 2022-07-18 13:07  Lcyanstars  阅读(38)  评论(0编辑  收藏  举报