跳转至

位运算

算数位运算

二进制意义:

将二进制数的每一位看作旗帜

与:将二进制数的某一位设为0,可用于位的提取

或:将二进制数的某一位设为1,可用于大量布尔变量的保存

异或:1. \(x \oplus x=0\) 2.对二进制数的某一位切换,可用于操作二进制数

补码

定义:

\[ \overline S=\left\{\begin{array}{ll}S_{(2)}, & S\geqslant0,\\\sim S_{(2)}+1, &S<0\end{array}\right.\]

设计原理(a,b为n位二进制数):

1.加减法统一:\(a-b=a+(-b)\),用加法表示减法

2.自然溢出:若\(a+b\geqslant2^n\),则\(a+b=a+b-2^n\)

(溢出且至多溢出一次(\(a+b-2^n<2(2^n-1)-2^n<2^n\)))

推导:\(-a=0-(a)\overset{①}{=}2^n+0-(a)\overset{②}{=}(2^n-1+1)-(a)=(2^n-1-a)+1\overset{③}{=}\sim a+1(其中a>0)\)

①由加法的自然溢出,减法可向第\(n+1\)位(溢出位)借位至多1次

②n位二进制数能表示的最大的数为\(\overline {11…1}_{(2)}=2^n-1\),故将\(2^n\)拆为\(2^n-1+1\),进而调整运算次序

\(\sim a\)\(a\)每一位各为0和1,故\(a+\sim a=\overline {11…1}_{(2)}=2^n-1\),即\(2^n-1-a=\sim a\)

注:推导过程中未需考虑符号位,因为符号位为负数的原码表示下赋予的意义,在利用正数推导补码时无需考虑

推广:负数补码表示转换为原码表示:利用\(0-(-a)=a(a>0)\),将负号提出

应用:int数组无穷大初始化

int变量的最大值:\(\overline{01111111\ 11111111\ 11111111\ 11111111}_{(2)}=0x7F\ FF\ FF\ FF\)

\((\overline{0111\ 1111}_{(2)}=0x7F)\)

\(memset(a, val, sizeof(a))\)语句:将\(val(0x00 \sim 0xFF)\)(8 bit)填充到数组a的每一个字节(byte)上

因此,\(0x7F7F7F7F\)\(memset\)语句能初始化出的最大数值

技巧:选择\(0x3F3F3F3F\)做无穷大,使得\(2INF<INT\_MAX\)

memset(a,0x3f,sizeof(a));

移位运算

算数意义:

左移\(1\ll n=2^n,n\ll 1=2n\)(高位越界舍弃)

略证:

对于正数,\(S\ll1=\sum\limits_{n}2^{n+1}a_{n}=2\sum\limits_{n}2^{n}a_{n}=2S(S>0)\)

对于负数,易证\((a\pm b)\ll1=(a\ll1)\pm (b\ll1)\)(先进退位=先左移后进退位,第0位不影响高位进退位)

设该n位负数为\(-S(S>0)\),则\((-S)\ll1=(2^n-S)\ll1=2^{n+1}-2S(*)\)

\(2^{n+1}-2S\geqslant2^{n+1}-2(2^{n-1}-1)>2^n\)(正数符号位为0)

故发生且仅发生一次溢出,\((*)=2^{n+1}-2S-2^n=2^n-2S=-2S\)

进一步地,若\(-2S\)能够被表达(即本身未溢出),则\(-2S<0\),即符号位为1,那么\(2^n-2S\geqslant2^{n-1}\),即\(S\leqslant2^{n-2}\)

那么\(-S=2^{n}-S\geqslant2^{n-1}+2^{n-2}\),即该负数应满足第n-2位为1,才能保证左移后不发生溢出

反过来,若第n-2位为0,左移后符号位变为0,溢出为正数

算数右移\(n\gg 1=\lfloor \dfrac {n}{2.0}\rfloor\)(高位填符号位)

c++整数除法向0取整

略证:

\(S=2n\),则\(S\gg1=(n\ll1)\gg1=n=\dfrac{S}{2}\)

\(S=2n+1\),则\(S\gg1=(n\ll1+1)\gg1=n=\dfrac{S-1}{2}=\lfloor \dfrac {S}{2.0}\rfloor\)

逻辑右移:高位填0


例1 a^b

思路:二进制拆分

对指数b进行二进制拆分,即\(a^b=a^{\sum\limits_{n}2^{n}b_{n}}=\prod\limits_{n}a^{2^nb_n}=\prod\limits_{b_n=1}a^{2^n}\)

(拆分:化整为零,化幂为积,实现降次)

利用循环生成\(a^{2i}\),即\(a^{2i}=(a^{i})^2\)

判断\(b\)的二进制表示每一位是否为1:将\(b\)不断右移,利用\(b\)&1取出第0位

时间复杂度估计:

\(b\)的位数不超过\(\lceil log_2(b+1)\rceil\)(n位二进制数最大为\(\overline{11…1}_{(2)}=2^n-1\)),故复杂度为\(O(log_2b)\)

实现:

int a, b, p;
cin >> a >> b >> p;
int ans = 1 % p; // 不写ans=1:防止b=0,p=1的情况,此时无法进入循环,而ans=0
for (; b; b >>= 1) { // 遍历b二进制表示的每一位
  if (b & 1) ans = (long long)ans * a % p; // 不写*=:方便取模
  a = (long long)a * a % p; // 计算时变量类型与保存结果的变量类型无关,需强制类型转换
}
cout << ans << endl;

例264位整数乘法

思路1:二进制拆分

对乘数b进行二进制拆分,即\(a*b=a\sum \limits_{n}2^nb_n=\sum \limits _{n}2^nab_n=\sum \limits_{b_n=1}2^na\)化积为和

其中\(2^na=2^{n-1}a*2\)

时间复杂度:\(O(log_2 b)\)

实现:

long long a, b, p;
cin >> a >> b >> p;
long long ans = 0;
for (; b; b >>= 1) {
  if (b & 1) ans = (ans + a) % p;
  a = a * 2 % p;
}
cout << ans << endl;

思路2:数论

易知:\(n\%p=n-\lfloor\dfrac{n}{p}\rfloor*p\)(余数定义),\((a*b)\%p=(a\%p)*(b\%p)\)(对a,b做带余除法,代入)

\((a*b)\%p=a*b-\lfloor\dfrac{a*b}{p}\rfloor*p\ (*)\)

\((*)<p\),可使两项自然溢出而得到正确结果

实现:

long long a, b, p;
cin>>a>>b>>p;
a %= p, b %= p; // 2.
long long c = (long double)a * b / p;  // 1.
long long ans = a * b - c * p;
if (ans < 0) ans += p;
cout << ans << endl;

1.此处有溢出的可能性。该过程可以表示为:\(a*b\xrightarrow{}溢出\xrightarrow{}除以p\xrightarrow{}类型转换\),下面在\(a*b\)溢出为负数的前提下考察这几个步骤:

溢出过程:将\(a*b\)作整体考虑,\(a*b\)在补码意义下将表示负数。以3位补码为例,按二进制由小到大表示的数依次为:\(0,1,2,3,-4,-3,-2,-1\),即构成\(-4,-3,-2,-1,0,1,2,3\)的循环,故自然溢出并未改变数字的相对大小。

除法过程:尽管我们不清楚除法的底层实现,但从其算数效果来看,符号位将影响计算方式(类比算数右移),具体表现为负数除以正数,结果仍为负数。

类型转换:将实数转换为整数的底层实现是去掉小数位,而去掉小数位对正数的影响是缩小,但对负数而言是放大(向0取整),这是导致相对大小发生变化的根源。

由此可见,由于类型转换向0取整的特性,导致实际计算结果与我们期望的向下取整有了出入。有两条解决思路:一是将a,b的变量类型指定为unsigned;二是对计算结果进行讨论,若结果为负,则补一个p:

\(n-\lceil\dfrac{n}{p}\rceil*p=n-(\lfloor\dfrac{n}{p}\rfloor+1)*p=n-\lfloor\dfrac{n}{p}\rfloor*p-p\)

2.由\((a*b)\%p=(a\%p)*(b\%p)\)知,通过分别取模,在缩小数据范围的同时保证结果的正确性:取模后\(a,b<p\),\(a*b<p^2\);若此处不取模,则无法保证\(a*b<p^2\)

3.处利用long double科学计数法的特性,将我们需要的精度保存下来;而若不对a,b进行范围限制,则\(a*b\)位数过多,会丢失必要精度,导致自然溢出了不该溢出的位数从而得到错误结果。

二进制状态压缩

定义:用m位二进制数存储长度为m的\(bool\)数组

基本元素:

\(\ll\):用于\(\overline{10…0}_{(2)}\)的生成 \(\gg\):用于数位的搬动

&:1.将数位置为0 2.将数位取出 |:1.将数位置为1 2.将数位放入 \(\oplus\):将数位切换

\(\sim\):将数位取反

1:用于操作的数位 -1:用于\(\overline{11…1}_{(2)}\)的生成

操作:(n的处理) 操作符 蒙版

取第k位:\((n\gg k)\)&\(1\)

取第0-k-1位:\(n\)&\((-1+(1\ll k))\)

利用-1生成\(\overline{11…1}_{(2)}\),再利用第k位的1使第\(k \sim n\)位进位,第n位自然溢出,构成局部蒙版

取反第k位:\(n\oplus(1\ll k)\)

第k位 置1:\(n|(1\ll k)\)

第k位 置0:\(n\)&\((\sim (1\ll k))\)

利用&进行置0,需要\(\overline{11…0…1}_{(2)}\)的模板,可将\(\overline{11…1}_{(2)}\)位于第k位的1减去,即

\(-1-(1\ll k)=\sim (1\ll k)\)


例3最短Hamilton路径

思路1:暴搜

我们可以枚举每一种合法路径,计算出路径长度的最小值。

时间复杂度估计:

对于n个点,从0号点出发,第一次有n-1种选择,第二次n-2种,由此类推,共\((n-1)!\)种路径;对于每条路径上的每一个点,需要花费\(O(1)\)的时间叠加边权,而每条路径都有\((n-1)\)条边,故时间复杂度为\(O(n*n!)\)

思路2:状压DP

思路1复杂度过大的根本原因是我们记录了冗余数据:点的访问顺序。由于我们只需求解最短路径,所以无需关心具体走法,可以设置简化的状态,降低时间复杂度。

首先,由于我们不需要考虑点的访问顺序,我们可以仅记录所有点的访问状态,又由于每个点仅有访问和不访问两种状态,故可以使用二进制进行状态压缩。由于答案对访问的末端点进行了要求,故我们需要将末端点编号加入状态中,以区分访问状态相同但末端点不同的两个状态。

由此,我们设计出如下状态:

\(f[S][k]\):访问状态为S且末端点位于k的最短路径长度,边界为\(f[1][0]=0\)

接着考虑状态转移。从状态本身出发,我们忽略访问顺序,对于状态\(f[S][k]\)的转移,可以理解为:

●●●●●●|● \(\xrightarrow{}\) ●●●●●o \(\xrightarrow{}\) min{●'●●●●o|●' + w(●',o)}

写作数学表达式即

\(f[S][k]=\min\limits_{((S\oplus(1\ll k))\gg i)\&1}\{f[S\oplus(1\ll k)][i]+w[i,k]\}\)

由于确保k点一定被经过,故使用\(\oplus\)将1置为0

时间复杂度估计:

状态空间\(2^n*n\),转移花费\(O(n)\),故时间复杂度为\(O(2^n*n^2)\)

实现:

memset(f, 0x3f, sizeof(f)); // 1.
f[1][0] = 0;
for (int S = 1; S < (1 << n); S++) // 2.
  for (int k = 0; k < n; k++)
    if ((S >> k) & 1)
      for (int i = 0; i < n; i++)
        if (((S ^ (1 << k)) >> i) & 1)
          f[S][k] = min(f[S ^ (1 << k)][i] + w[i][k], f[S][k]);

细节:

1.由于状态转移方程的策略为取min,为防止初值对结果的干扰,将f初始化为无穷大

2.状态转移方程等式左边的值从等式右边获得,故要求等式右边先于等式左边计算;观察转移方程,不难发现等式右边\(S \oplus (1\ll k)\)始终小于等式左边的S,故要求S始终按照递增的顺序进行枚举,所以将S放在外层循环保证S始终递增

例4起床困难综合症

思路:模拟

不难发现三种按位操作的独立性:对每一位进行&、|、\(\oplus\)操作后,均不对高位产生影响。

在这基础上,我们只需要对初始攻击力二进制表示的每一位进行讨论,便能得到最大的伤害。一般地,若能证明某数的各个数位互相独立,就不用考虑各个数位各种情况的组合,时间复杂度将由\(O(n)\)优化至\(O(logn)\).

故时间复杂度为\(O(nlog_2m)\)

实现:

bool calc(int pos, bool bit) {
  for (int i = 0; i < n; i++) {
    bool x = ((t[i] >> pos) & 1);
    if (ops[i] == 0)
      bit &= x;
    else if (ops[i] == 1)
      bit |= x;
    else
      bit ^= x;
  }
  return bit;
}
int main() {
  string op;
  cin >> n >> m;
  for (int i = 0; i < n; i++) {
    cin >> op >> t[i];
    if (op == "AND")
      ops[i] = 0;
    else if (op == "OR")
      ops[i] = 1;
    else
      ops[i] = 2;
  }
  for (int pos = 29; pos >= 0; pos--) { // 1.
    bool res0 = calc(pos, 0), res1 = calc(pos, 1);
    if (m >= (1 << pos) && res1 > res0) // 2.
      m -= (1 << pos), ans += (res1 << pos);
    else
      ans += (res0 << pos);
  }
  cout << ans << endl;
  return 0;
}

细节:

1.此处采用贪心策略,先填高位,后填低位:由于高位带来的贡献大于低位,尽可能的在高位处多填1,使结果最大化;pos从29开始递减,是因为n最大为\(10^9\),二进制表示最多30位,最高位最大为第29位。

2.此处采用贪心策略,若不是一定填1,则选择填0:若高位填0或1带来的结果一致,则优先选择填0,为低位选择留出更多空间。

注:此时初始攻击力的选择可能有多解(例如最后一层AND 0),而最大伤害值的解唯一:若能填1,则一定填1;若不能填1,则填0。

成对变换

利用\(\oplus\)位切换的性质,对n的第0位进行切换,形成偶数加一,奇数减一的效果。

lowbit运算

定义:非负整数n二进制表示下最低位1及后边所有0构成的数值。

推导:差异化

利用定义给出的\(\overline{…10…0}_{(2)}\)中的1,使1前和1后发生不同的变化。

差异化根源:进位

思路:

将n取反,加一则产生进位,由于原位置的1取反后化为0,将进位截断,故高于1的位不改变取反状态。此时该数与原数低于1的位完全相同,高于1的位完全相反。再与原数做&,即可取出lowbit.

写作数学表达式:

\(lowbit(n)=n\&(\sim n+1)=n\&(-1-n+1)=n\&(-n)\)

应用:取出二进制中所有的1

将n不断减去lowbit(n),不断移除此时最低为的1

1的位数为\(log_2lowbit(n)\),利用哈希将对数表准备好

GCC内置相关函数:

int __builtin_ctz(unsigned int x) 返回x二进制表示下最低位1后有多少0

int __builtin_popcount(unsigned int x) 返回x二进制表示下有多少位为1