位运算及其应用

内容概要:

  1. 位运算基本操作
  2. 基于位运算的状态压缩
  3. 位运算经典应用
  4. 位运算解N皇后问题

位运算

符号 描述 规则
& 1&1=1,1&0=0,0&1=0,0&0=0
11=1,10=1,01=1,00=0
~ 取反 0变1,1变0
^ 异或 1^1=0,1&0=1,0&1=1,0&0=0
<< 左移 二进制位左移若干位
>> 右移 二进制位右移若干位

关于移位操作的算术移位和逻辑移位(计算机中用补码表示所以只讨论补码)
算术左移和逻辑左移没有区别都是低位补0高位丢弃。比如 00101011 算术左移一位:01010110;逻辑左移一位:01010110。对二进制的数来说左移n位等于原来的数值乘以2的n次方。
但是算术移位时最高位是符号位,算术左移可能会产生溢出,如10110101算术左移后01101010产生了溢出。
逻辑右移很简单,低位丢弃高位补0,如10101101逻辑右移一位为01010110;算术右移时低位丢弃高位补符号位。如:11101110算术右移一位为11110111。对于二进制数的右移n位结果等于原来的数值除以2的n次方。

各运算的基本性质与应用

  • 与运算&:指定位清零(x&0=0)、取指定位(x&1=x);
  • 或运算|:指定位置1(x | 1 =1);
  • 异或运算^:翻转指定位(x1异或后变~x),保持运算(x0异或后还是x);
  • 左移运算:乘2;
  • 右移运算:除以2。

基本操作的延伸

  • 使a的最低位为0,可以表示为:a & ~1,1取反的值为 1111 1110,再进行"与"运算,最低位为0。

  • 判定整数a的奇偶可以用:a & 1 == 0 ? even : odd

  • -n = ~ n + 1(补码表示下)。

  • x & (x - 1)x的最后一位 1 变成 001001100 -> 01001000

  • 配偶:(0,1),(2,3),(4,5),...,配偶中的元素与1异或得配偶中的另一个元素。(如网络流中正向边反向边:edge[index],edge[index^1])

  • lowbit运算:只保留n的二进制表示中,最低的一位1。如lowbit(11011000)=00001000,得到n的lowbit可以用-n & n

  • 不借用任何额外空间交换a和b的值:

void swap(int &a, int &b){
    if (a != b){
        a ^= b;
        b ^= a;
        a ^= b;
    }
}
  • 求绝对值
int abs(int a) {
  int i = a >> 31;
  return i == 0 ? a : (~a + 1);
}

基于位运算的状态压缩

在一些问题中,我们需要对求解过的情况或组合进行标记,比如图论中的很多问题,顶点是否访问过会有一个visited数组来标记,有时我们希望把访问标记数组当做一个状态来使用,即visited数组的一组取值对应某个子问题的一个解。为此引入状态压缩。由于visited数组元素的取值只有true和false,对应二进制的1和0,可以用二进制数来表示,而二进制数又与十进制数一一对应,所以最终用整数就能表示顶点的访问情况,也就是一个整数就表示了一个集合。

状态压缩

但是整型数据位数是有限的,int型32位,去掉符号位只有31位,这样只能表示31个顶点的访问状态。不过对于一些本身就是指数级别的算法,问题规模不会太大,所以31位一般足够了,实在不够还可以用long long有64位。
由十进制整型查看顶点的访问状态和修改顶点的访问状态也非常简单:

与运算取位

即如果要通过十进制数的查看第位是否为0,只需要数和(1左移位后的数)做相应的与运算:

进而如果要把某一位设置为0或1,只需要做加法(减法)即可:

修改操作

即如果要通过十进制数的修改第位,只需要数和(1左移位后的数)加减运算:

位运算应用实例

整数的二进制表示中1的个数
例如74=0b01001010,里面有3个1。一个基本的处理思路是,让输入的数与1相与结果得1则计数加一,然后右移重复这个过程:

int countOnes(int n) {
    int count = 0;
    while (n) {
        if ((n & 1) == 1)count++;
        n >>= 1;
    }
    return count;
}

当然还有基于分治的算法。设n=0b11010010:先得到分组的每2位中1的个数,接着得到每4位中1的个数,最后8位中1的个数。32位整型数据以此类推。

int countOnes2(int a) {
    int m_1 = 0x55555555;//0101 0101 0101 0101 0101 0101 0101 0101
    int m_2 = 0x33333333;//0011 0011 0011 0011 0011 0011 0011 0011
    int m_4 = 0x0f0f0f0f;
    int m_8 = 0x00ff00ff;
    int m_16 = 0x0000ffff;   // 过滤器
    int b = (a & m_1) + ((a >> 1)& m_1);
    int c = (b & m_2) + ((b >> 2)& m_2);
    int d = (c & m_4) + ((c >> 4)& m_4);
    int e = (d & m_8) + ((d >> 8)& m_8);
    int f = (e & m_16) + ((e >> 16)& m_16);
    return f;
}

上述代码可以改为循环的形式,这里略去。
整数二进制翻转
求将整数n的二进制翻转后得到的数m,如8位二进制表示下,46=0b00101110,其二进制翻转后为0b01110100=116。基于分治,在位运算下,可以先将前4位与后4位翻转,再将4位中的前2位与后2位翻转,最后将2位翻转即得到结果。32位的数也用同样的思想处理。

int reverseInt(int n) { // 32位的整数翻转
    int m[] = { 0x55555555,0x33333333,0x0f0f0f0f,0x00ff00ff,0x0000ffff };// 过滤器
    int i = 4;
    while (i >= 0) {
        n = ((n & m[i]) << (1 << i)) + ((n >> (1 << i)) & m[i]);
        i--;
    }
    return n;
}

从位运算看快速幂
在平常计算我们是如何计算的呢?
方法1:最简单的想法是:,,...,这样做需要乘法9次。方案1显然太慢了;
方案2:可以先计算用4次乘法,再计算,这样一共进行乘法5次;
方案3:计算,然后计算,接着计算,这样一共进行乘法4次。

显然方案3是一个很优秀的方案,它的思想是分治,这就是快速幂:
计算,如果n是偶数,则先计算,然后平方;如果n是奇数,则计算,再乘上a;特殊地。这样的时间复杂度只有。
这样很容易给出递归的快速幂代码:

int quickPow(int a, int n){
    if(n == 0) return 1;
    else if(n % 2 == 1){
        return quickPow(a, n-1) * a;
    }
    else{
        int temp = quickPow(a, n/2);
        return temp * temp;
    }
}

相应的有非递归代码:

int quickPow(int a, int n, int mod){
    int ans = 1;
    while(n > 0){
        if(n % 2 == 1)
            ans = ans * a;
        a = a * a;
        n = n / 2;
    }
    return ans;
}

如果将指数看做二进制数,指数的变化看做二进制数的移位,就可以从位运算的角度来看快速幂,如计算,我们可以先预处理出来,1000000的二进制表示为,在求时我们只需要把1000000的二进制表示中那些非0的位置提取出来,把对应的预处理结果乘起来即可。此时非递归代码如下:

int quickPow(int a, int n){
    int res = 1;
    while(n){
        if(n&1) // n的二进制表示个位为1
            res = res * a;
        a = a * a; // 十位提取预处理好
        n >>= 1;// 去掉个位,开始考虑下一位
    }
    return res;
}

快速幂例子

AcWing89

有了上面的快速幂,给出解答:

#include
using namespace std;
int main(){
    int a, b, p;
    cin >> a >> b >> p;
    int res = 1 % p;
    while(b){
        if(b & 1) //个位是1
            res = res * 1ll * a % p;//变成ll类型
        a = a * 1ll *a % p;
        b >>= 1;//个位去掉
        
    }
    cout << res << endl;
    return 0;
}

注意,为了避免溢出,乘1ll相当于做了到long long的强制类型转换;另外,解释一下为什么res初值为1 % p而不直接是1,假设输入为9,0,1,那么由于b=0,while循环不会进入,如果没有res=1%p直接就输出res了,但是对1取模结果一定都是0。
乘法取模

90

这个问题描述很简单,但是如果直接计算,当数比较大时,两个18位数的乘法一定会超出128位(计算机里基本类型的最大位数),所以不可以直接计算。考虑到两个18位数的加法是不会溢出的,所以我们可以把乘法变成加法,a×b=a+a+...+a,共加b次,这样预处理出来:

#include
using namespace std;
typedef unsigned long long ull;
int main(){
    ull a, b, p;
    cin >> a >> b >> p;
    ull res=0;
    while(b){
        if(b & 1) res = (res + a) % p;
        a = a * 2 % p;
        b >>= 1;
    }
    cout << res << endl;
    return 0;
}

时间复杂度为log(b)。
最短Hamilton路径

最短哈密顿

如果枚举搜索所有路径数量级是20!,这是很恐怖的,现在来做一些优化。假如现在在20个顶点中搜索了4个顶点,0-1-2-3距离18,0-2-1-3距离20,这样实际从3开始搜索就可以抛弃0-2-1-3的搜索序列了,它的距离一定不是最短的。所以我们真正关心的其实是当前已经访问过哪些顶点,以及当前停在哪个顶点,这两个信息就唯一确定了一个搜索状态。顶点访问状态共有次幂个,停在的点的可能状态是20,故搜索状态共有个,这比20!要好很多,使用状态压缩,顶点访问状态用一个整数来表示。
动态规划解,设状态:

有状态转移方程(枚举求解):而就是visited_cur把k去掉的顶点访问状态。

# include 
# include 
# include 
using namespace std;
const int M = 1 << 20, N = 20;
int f[M][N], w[M][N]; // w 为邻接矩阵, f 存放求解结果

// 动态规划
int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++)
            cin >> w[i][j];

    memset(f, 0x3f, sizeof(f));
    f[1][0] = 0; // 初始访问状态为0号顶点被访问,停在0号顶点,由于没有走任何路径,长度为0
    for (int cur = 0; cur < 1 << n; cur++)
        for (int j = 0; j < n; j++)
            if ((cur >> j) & 1)//判断整数cur的第j位是不是1(合法状态停在顶点j,cur的第j位会是1)
                for (int k = 0; k < n; k++)// 枚举求解
                    if (((cur - (1 << j)) >> k) & 1)// 去掉顶点j的访问状态,如果没有求解过k的话,转移到顶点k的求解,
                        f[cur][j] = min(f[cur][j], f[cur - (1 << j)][k] + w[k][j]);
    cout << f[(1 << n) - 1][n - 1] << endl;
    return 0;
}

一些编程小技巧:

  • 大数组最好定义在全局。如果数组定义在函数内,占用的内存来自栈空间,栈空间是在进程创制建时初始化的,一般比较小,所以太大的数组会耗光栈空间。而全局变量占用的静态区,32位系统理论上有4GB。
  • memset对数组初始化正无穷用0x3f,0x3f按字节分配后,每个int数为0x3f3f3f3f,十进制是1061109567,是级别的,而一般场合下的数据规模都不会超过这个量级,所以它可以作为无穷大使用。 另一方面,把这个无穷大加上一个数据时,它并不会产生整型溢出(这就满足了“无穷大加一个有穷的数依然是无穷大”),事实上0x3f3f3f3f+0x3f3f3f3f=2122219134,这非常大但却没有超过32位int的表示范围,所以0x3f3f3f3f还满足了“无穷大加无穷大还是无穷大”的需求。

位运算解N皇后问题
LeetCode51:N皇后问题
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。

N皇后

纯暴力求解时间复杂度在量级,考虑约束,如果在棋盘的某个点上放置了皇后,那么它的同行同列以及两个斜对角线位置就都不能放置皇后了。
这样可以用回溯法解决,每一行尝试摆放一个,回溯求得所有解,最后生成指定的输出即可,回溯法:

class Solution {
public:
    vector col, dia1, dia2;
    vector> res;
    vector> solveNQueens(int n) {
        col = vector(n, false);
        dia1 = vector(2 * n - 1, false);
        dia2 = vector(2 * n - 1, false);
        vectorsite; // site[row] = i;记录第row行的皇后放到了第i列
        putQueen(n, 0, site);
        return res;
    }
    void putQueen(int n, int row, vector& site) {
        if (row == n) {
            res.push_back(generateSolve(site, n));
            return;
        }
        for (int i = 0; i < n; i++) {
            // 尝试将第row行的皇后放到第i列
            if (!col[i] && !dia1[row + i] && !dia2[row - i + n - 1]) {
                site.push_back(i);
                col[i] = true; dia1[row + i] = true; dia2[row - i + n - 1] = true;
                putQueen(n, row + 1, site);
                col[i] = false; dia1[row + i] = false; dia2[row - i + n - 1] = false;// 回溯
                site.pop_back();
            }
        }
        return;
    }
    vectorgenerateSolve(vector site, int n) {
        // 由site记录生成N皇后问题的一个解
        vectorsolve(n, string(n, '.'));
        for (int r = 0; r < n; r++)
            solve[r][site[r]] = 'Q';
        return solve;
    }
};

可以看到上述代码为了描述约束状态,为数组col,dia1,dia2都分配了的空间,下面用状态压缩对该算法进行优化,使用二进制数来存储当前格子的可用信息。以八皇后为题为例,设标识row,ld,rd,其含义分别为:
row:当前行中各列是否可用 \\ ld:左斜(右上到左下方向) \\ rd:右斜(左上到右下方向) \\ 为1表示被占用

例子:

8-Queens

row,ld,rd进行“或”运算,可求得当前行所有可以放置皇后的列,然后再取反后“与”上全1的数,此时当前所有可以放置皇后的位置对应位为1 。pos = ~(row | ld | rd) & ((1 << n) - 1)=01000011,也就是当前行只有1,6,7位置能放皇后。接下来继续回溯搜索,首先选择最右边可以被选择的位置,考虑到位运算x & -x会得到x最后面的1:pick[r]=pos & -pos,在上图中r=3pick[r]=00000001
回溯的时候,考虑位运算x & (x - 1)会把末尾的1变0,所以pos = pos & (pos - 1)就可以将最后一位1置0表示考虑过该位,效果01000011 -> 01000010,这样就开始考虑下一个可放置皇后的位置。
row,ld,rd状态的更新:
由于可被选择的位置用pos中的1表示,所以row的更新只需要与pick[r]进行或运算就可以了:row=row | pick[r]
上图例子中row更新为:10101000 -> 10101001
同理下一行ldrd就是当前值与pick[r]或运算后向左 / 右移动一位:
ld=(ld | pick[r])<<1 \\ rd=(rd | pick[r])>>1 这里决定左移还是右移和定义的ldrd方式有关,如这里定义 ld 是右上到左下方向,所以对角线到下一行的作用向左移动了一位。

pick[r]一定只有一位为1,且pick[r]为1的位置rd,ld相应位置一定为0,所以直接将二者相加再左右移位也可以。)
最后,如果求得一个解,把 pick 中保存的状态转换出来存成字符串:

class Solution {
public:
    vector> res;
    vector> solveNQueens(int n) {
        vectorpick(n, 0);
        // pick[r] = 00010000;记录第r行的皇后放到了第i列
        putQueen(n, 0, 0, 0, 0, pick);
        return res;
    }
    void putQueen(int n, int r, int row, int ld, int rd,  vector& pick) {
        if (r == n) {
            res.push_back(generateSolve(pick, n));
            return;
        }
        int pos = ~(row | ld | rd) & ((1 << n) - 1);
        while (pos) {// 如果pos=0,表示没有位置可放了
            pick[r] = (pos & -pos);
            putQueen(n, r + 1, (row | pick[r]), (ld | pick[r]) << 1, (rd | pick[r]) >> 1, pick);
            pos &= (pos - 1);
        }
        return;
    }
    vectorgenerateSolve(vector pick, int n) {
        vectorsolve(n, string(n, '.'));
        for (int i = 0; i < n; i++)
            for (int j = 0; j < n; j++)
                if (pick[i] & (1 << j)) {
                    solve[i][j] = 'Q';
                    break;
                }
        return solve;
    }
};

如果只是求N皇后问题解的个数(LeetCode52):

class Solution {
public:
    int count = 0;
    int totalNQueens(int n) {
        calNQueens(n, 0, 0, 0, 0);
        return count;
    }
    void calNQueens(int n, int r, int row, int ld, int rd) {
        if (r == n) {
            count++;
            return;
        }
        int pos = ~(row | ld | rd) & ((1 << n) - 1);
        while (pos) {// 如果pos=0,表示没有位置可放了
            int pick = (pos & -pos);
            calNQueens(n, r + 1, (row | pick), (ld | pick) << 1, (rd | pick) >> 1);
            pos &= (pos - 1);
        }
        return;
    }
};

你可能感兴趣的:(位运算及其应用)