【Leetcode刷题】位运算

本篇文章为 LeetCode 位运算模块的刷题笔记,仅供参考。

位运算的常用性质如下:

a ^ a = 0
a ^ 0 = a
a ^ 0xFFFFFFFF = ~a

目录

  • 一. 基本位运算
    • Leetcode29.两数相除
    • Leetcode89.格雷编码
  • 二. 位运算的性质
    • Leetcode136.只出现一次的数字
    • Leetcode137.只出现一次的数字 II
    • Leetcode260.只出现一次的数字 III
    • Leetcode201.数字范围按位与
    • Leetcode389.找不同
  • 三. 位运算的应用
    • Leetcode78.子集
    • Leetcode957.N 天后的牢房

一. 基本位运算

Leetcode29.两数相除

Leetcode29.两数相除
给你两个整数,被除数 dividend 和除数 divisor。将两数相除,要求不使用乘法、除法和取余运算。
整数除法应该向零截断,也就是截去(truncate)其小数部分。例如,8.345 将被截断为 8 ,-2.7335 将被截断至 -2 。
返回被除数 dividend 除以除数 divisor 得到的 商 。
注意:假设我们的环境只能存储 32 位 有符号整数,其数值范围是 [−231, 231 − 1] 。本题中,如果商 严格大于 231 − 1 ,则返回 231 − 1 ;如果商 严格小于 -231 ,则返回 -231
示例 1:
输入: dividend = 10, divisor = 3
输出: 3
解释: 10/3 = 3.33333… ,向零截断后得到 3 。
示例 2:
输入: dividend = 7, divisor = -3
输出: -2
解释: 7/-3 = -2.33333… ,向零截断后得到 -2 。
提示:
-231 <= dividend, divisor <= 231 - 1
divisor != 0

手动模拟无符号多位数除法的过程,最后加上符号即可。由于被除数和除数范围是 -231 ~ 231 - 1,为了防止溢出,将其化为 无符号数 进行计算。为了防止 INT_MAX 和 INT_MIN 计算产生溢出,在除法开始前进行特殊情况处理。

需要注意的是,在计算每一位商的时候,一开始使用的是 i 从 9 到 0 乘 divisor,第一次小于等于 remainer 时的 i 就是当前位的商。但是这种做法不仅违背了题目要求的不使用乘法,当 divisor 较大时还会产生溢出。后来改为从 0 到 9 不断加 divisor,第一次大于 remainer 时的 i 就是当前位的商:

int i;                              // 记录当前位的商
// for(i=9;i>=0;i--){
//     if(remainer>=i*us_divisor){  // 会出现溢出
//         break;
//     }
// }
int tmpsum=0;
for(i=0;i<=9;i++){
    tmpsum+=us_divisor;
    if(tmpsum>remainer) break;
}
remainer-=i*us_divisor;

AC 代码如下:

class Solution {
public:
    int divide(int dividend, int divisor) {
        // 特殊情况处理
        if(dividend==INT_MIN){
            if(divisor==-1)         return INT_MAX;     // 大于2^32-1
            if(divisor==1)          return INT_MIN;
            if(divisor==INT_MIN)    return 1;
            if(divisor==INT_MAX)    return -1;
        }
        if(dividend==INT_MAX){
            if(divisor==-1)         return INT_MIN+1;
            if(divisor==1)          return INT_MAX;
            if(divisor==INT_MIN)    return 0;
            if(divisor==INT_MAX)    return 1;
        }
        // 化为无符号数进行计算
        int sign=(dividend>0&&divisor>0 || dividend<0&&divisor<0)?1:-1;
        unsigned int us_dividend=abs(dividend);
        unsigned int us_divisor=abs(divisor);
        string sdividend=to_string(us_dividend);// 字符串便于切片
        unsigned int remainer=0;                // 余数
        unsigned int quo=0;                     // 商
        int ptr=0;                              // 切片sdividend的指针
        while(ptr<sdividend.size()){
            remainer=10*remainer+(sdividend[ptr]-'0');
            int i;                              // 记录当前位的商
            int tmpsum=0;
            for(i=0;i<=9;i++){
                tmpsum+=us_divisor;
                if(tmpsum>remainer) break;
            }
            remainer-=i*us_divisor;
            quo=quo*10+i;
            ptr++;
        }
        return sign*quo;
    }
};
【Leetcode刷题】位运算_第1张图片

其实 ptr 索引字符串获取 dividend 每一位的过程就是 位运算,因为要取的是十进制数的每一位而不是二进制数,因此使用的是切片字符串而不是移位和位运算。

Leetcode89.格雷编码

Leetcode89.格雷编码
n 位格雷码序列 是一个由 2n 个整数组成的序列,其中:
每个整数都在范围 [0, 2n - 1] 内(含 0 和 2n - 1)
第一个整数是 0
一个整数在序列中出现 不超过一次
每对 相邻 整数的二进制表示 恰好一位不同 ,且
第一个 和 最后一个 整数的二进制表示 恰好一位不同
给你一个整数 n ,返回任一有效的 n 位格雷码序列 。
示例 1:
输入:n = 2
输出:[0,1,3,2]
解释:
[0,1,3,2] 的二进制表示是 [00,01,11,10] 。

  • 00 和 01 有一位不同
  • 01 和 11 有一位不同
  • 11 和 10 有一位不同
  • 10 和 00 有一位不同

[0,2,3,1] 也是一个有效的格雷码序列,其二进制表示是 [00,10,11,01] 。

  • 00 和 10 有一位不同
  • 10 和 11 有一位不同
  • 11 和 01 有一位不同
  • 01 和 00 有一位不同

示例 2:
输入:n = 1
输出:[0,1]
提示:
1 <= n <= 16

法一:观察 n = 1 和 n = 2 的样例,可以看到 n = 2 的格雷码的前两个元素就是 n = 1 的格雷码,继续枚举 n = 3 的格雷码进行观察:[000, 001, 011, 010, 110, 111, 101, 100]。不难发现,n = 3 对应的格雷码的前 4 个元素也是 n = 2 的格雷码,后 4 个元素是 前 4 个元素的逆序并在最高位置 1。

上面的观察可以证明:设 Gn 表示 n 位格雷码序列的集合,则 Gn+1 可以表示为 Gn ∪ Gn 的逆序再将最高位置 1:

class Solution {
public:
    vector<int> grayCode(int n) {
        vector<int> ans(2);
        ans[0]=0;ans[1]=1;
        for(int i=2;i<=n;i++){
            int tmpn=ans.size();
            for(int j=tmpn-1;j>=0;j--){
                ans.push_back(ans[j] | 1<<(i-1));	// 位运算
            }
        }
        return ans;
    }
};
【Leetcode刷题】位运算_第2张图片

法二:其实格雷码有固定的运算公式: g i = i ⊕ i 2 g_i = i \oplus \frac i 2 gi=i2i,用公式计算耗时更加稳定高效。

二. 位运算的性质

Leetcode136.只出现一次的数字

Leetcode136.只出现一次的数字
给你一个 非空 整数数组 nums ,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
你必须设计并实现线性时间复杂度的算法来解决此问题,且该算法只使用常量额外空间。
示例 1 :
输入:nums = [2,2,1]
输出:1
示例 2 :
输入:nums = [4,1,2,1,2]
输出:4
示例 3 :
输入:nums = [1]
输出:1
提示:
1 <= nums.length <= 3 * 104
-3 * 104 <= nums[i] <= 3 * 104
除了某个元素只出现一次以外,其余每个元素均出现两次。

法一:最直接的解法是排序 + 遍历查找,复杂度为 O(nlogn):

class Solution {
public:
    int singleNumber(vector<int>& nums) {
        sort(nums.begin(),nums.end());
        for(int i=1;i<nums.size();i+=2){
            if(nums[i]!=nums[i-1])    return nums[i-1];
        }
        return nums.back();
    }
};
【Leetcode刷题】位运算_第3张图片

但上述排序超出了线性时间复杂度,不符合题目要求。

法二:线性时间复杂度不需要额外空间 的方法可以往 位运算 上想。题目中说 nums 中只有一个元素出现一次,其余每个元素均出现两次,联想到 异或运算 a ⊕ a = 0 a \oplus a = 0 aa=0,所以 a ⊕ a ⊕ b ⊕ b ⊕ ⋯ ⊕ c = c a \oplus a \oplus b \oplus b \oplus \cdots \oplus c = c aabbc=c,因此对所有元素进行异或即可,得到的结果就是只出现一次的元素:

class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int ans=0;				// a^0=a,因此初值赋为0
        for(int i=0;i<nums.size();i++){
            ans=ans^nums[i];
        }
        return ans;
    }
};
【Leetcode刷题】位运算_第4张图片

Leetcode137.只出现一次的数字 II

Leetcode137.只出现一次的数字 II
给你一个整数数组 nums ,除某个元素仅出现 一次 外,其余每个元素都恰出现 三次 。请你找出并返回那个只出现了一次的元素。
你必须设计并实现线性时间复杂度的算法且不使用额外空间来解决此问题。
示例 1:
输入:nums = [2,2,3,2]
输出:3
示例 2:
输入:nums = [0,1,0,1,0,1,99]
输出:99
提示:
1 <= nums.length <= 3 * 104
-231 <= nums[i] <= 231 - 1
nums 中,除某个元素仅出现 一次 外,其余每个元素都恰出现 三次

法一: ⌈ \lceil 1 个出现 1 次的数字 + n 个出现 k 次的数字 ⌋ \rfloor 这类问题的本质就是 周期循环,Leetcode136.只出现一次的数字 的周期是 2,恰巧可以使用异或运算;Leetcode137.只出现一次的数字 II 的周期是 3,没有现成的位运算,因此需要寻找新的规律。不妨回归周期本身:对 nums 中所有数字的 每一位进行叠加后模 3,所有出现 3 次的数字的和在模 3 后都应该是 0,因此最后得到的答案就是只出现一次的元素:

class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int ans=0;
        for(int i=0;i<32;i++){      // ans的每一位
            int tmp=0;
            for(int j=0;j<nums.size();j++){
                tmp+=(nums[j]>>i) & 1;
            }
            ans=ans | ((tmp)%3)<<i;
        }
        return ans;
    }
};
【Leetcode刷题】位运算_第5张图片

法二:其实 位运算的本质就是状态机,当状态较为复杂时,可以通过枚举状态转移表获取位运算表达式。因为题目中涉及三种状态,分别对应数字出现 0、1、2 次,因此需要两比特位描述状态,记为 {AB}。状态转移表如下所示,逻辑关系为模 3 加法:

当前状态 AB 输入 X 下一个状态 A’B’
00 0 00
00 1 01
01 0 01
01 1 10
10 0 10
10 1 00

分别考虑 A 和 B 的状态变化,画出真值表:

ABX A’ B’
000 0 0
001 0 1
010 0 1
011 1 0
100 1 0
101 0 0

得到 逻辑表达式
A ′ = A ‾   B   X + A B ‾   X ‾ A' = \overline{A} \, B \, X + A \overline{B} \, \overline{X} A=ABX+ABX
B ′ = A ‾   B ‾ X + A ‾   B   X ‾ B' = \overline{A} \, \overline{B} X + \overline{A} \, B \, \overline{X} B=ABX+ABX
使用该表达式遍历 nums 中的元素即可,得到两个 32 bit 的结果 A 和 B,{A[i], B[i]} 表示第 i 位的状态。对于所有出现 3 次的元素,状态转移的次数是 3 的整数倍,因此计算后的状态应该是 {00}。只有出现 1 次的元素会作为输入 X 改变本该为 0 的结果,因此最后的 A 一定是 0,B 一定是那个出现一次的元素。

需要注意的是,表达式中的 A 和 B 的更新应该是非阻塞赋值,因此更新状态时注意保存中间值:

class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int a=0, b=0;
        for(int i=0;i<nums.size();i++){
            int tmpa=a;
            a=(~tmpa & b & nums[i]) | (tmpa & ~b & ~nums[i]);
            b=(~tmpa & ~b & nums[i]) | (~tmpa & b & ~nums[i]);
        }
        return b;
    }
};
【Leetcode刷题】位运算_第6张图片

Leetcode260.只出现一次的数字 III

Leetcode260.只出现一次的数字 III
给你一个整数数组 nums,其中恰好有两个元素只出现一次,其余所有元素均出现两次。 找出只出现一次的那两个元素。你可以按 任意顺序 返回答案。
你必须设计并实现线性时间复杂度的算法且仅使用常量额外空间来解决此问题。
示例 1:
输入:nums = [1,2,1,3,2,5]
输出:[3,5]
解释:[5, 3] 也是有效的答案。
示例 2:
输入:nums = [-1,0]
输出:[-1,0]
示例 3:
输入:nums = [0,1]
输出:[1,0]
提示:
2 <= nums.length <= 3 * 104
-231 <= nums[i] <= 231 - 1
除两个只出现一次的整数外,nums 中的其他数字都出现两次

本题的周期还是 2,按道理构造周期为 2 的运算(如异或)就能得到出现一次的数字。但棘手的是,nums 中有两个元素只出现一次,最终得到的是两个数异或的结果,没法直接得到这两个数。

法一:先遍历一遍 nums 数组,对所有元素做异或操作得到 ans,然后双层循环遍历 nums[i] 和 nums[j] 的所有组合直至与 ans 匹配,时间复杂度为 O(n2):

class Solution {
public:
    vector<int> singleNumber(vector<int>& nums) {
        int ans=nums[0];
        for(int i=1;i<nums.size();i++){
            ans=ans^nums[i];
        }
        for(int i=0;i<nums.size();i++){
            for(int j=i+1;j<nums.size();j++){
                if((nums[i]^nums[j])==ans)    return {nums[i],nums[j]};
            }
        }
        return {};
    }
};
【Leetcode刷题】位运算_第7张图片

法二:考虑异或得到的结果 ans,异或运算的性质是两个数的每一位相同为 0,不同为 1。因此 ans 的 每一个为 1 的位一定是 0 ^ 1 得到的,随便选取某一位,然后将 nums 中的元素分为两组,一组是该位为 1 的,另一组是该位为 0 的。各自组内所有元素异或运算,得到的两个数就是只出现一次的元素,因为出现两次的元素会各自抵消。时间复杂度为 O(n):

class Solution {
public:
    vector<int> singleNumber(vector<int>& nums) {
        int ans=0;
        for(int i=0;i<nums.size();i++){
            ans=ans^nums[i];
        }						// ans=a^b
        int ptr=0;
        for(;ptr<32;ptr++){
            if((1&(ans>>ptr))==1) break;
        }						// ans[ptr]=1
        int a=0,b=0;
        for(int i=0;i<nums.size();i++){
            if((1&(nums[i]>>ptr))==1)   a=a^nums[i];
            else                        b=b^nums[i];
        }
        return {a,b};
    }
};
【Leetcode刷题】位运算_第8张图片

Leetcode201.数字范围按位与

Leetcode201.数字范围按位与
给你两个整数 left 和 right ,表示区间 [left, right] ,返回此区间内所有数字 按位与 的结果(包含 left 、right 端点)。
示例 1:
输入:left = 5, right = 7
输出:4
示例 2:
输入:left = 0, right = 0
输出:0
示例 3:
输入:left = 1, right = 2147483647
输出:0
提示:
0 <= left <= right <= 231 - 1

直接遍历的时间复杂度是 O(n),会被系统卡超时,需要另辟蹊径。考虑 按位与运算的性质,有一位为 0 就全部为 0,因此只需要考虑 left 和 right 之间所有数的每一位是否有 0。设 left 和 right 的高 h 位相同,低 32-h 位不全相同,则 [left, right] 之间的所有数高 h 位都相同。既然 left 和 right 的低 32-h 位不全相同,那么第 31-h 位一定不同,因此 left 的第 31-h 位一定是 0,right 的第 31-h 位一定是 1。于是,第 30-h 位在第 31-h 位刚跳变成 1 时一定是 0 … 以此类推,[left, right] 之间的所有数中低 32-h 位一定都存在 0,因此最终的答案就是 left 或 right 的高 h 位。时间复杂度为 O(logn):

class Solution {
public:
    int rangeBitwiseAnd(int left, int right) {
        int offset=0;
        while(left!=right){
            left>>=1;
            right>>=1;
            offset++;
        }
        return left<<offset;
    }
};
【Leetcode刷题】位运算_第9张图片

Leetcode389.找不同

Leetcode389.找不同
给定两个字符串 s 和 t ,它们只包含小写字母。
字符串 t 由字符串 s 随机重排,然后在随机位置添加一个字母。
请找出在 t 中被添加的字母。
示例 1:
输入:s = “abcd”, t = “abcde”
输出:“e”
解释:‘e’ 是那个被添加的字母。
示例 2:
输入:s = “”, t = “y”
输出:“y”
提示:
0 <= s.length <= 1000
t.length == s.length + 1
s 和 t 只包含小写字母

思路同 Leetcode136.只出现一次的数字,s 和 t 只有一个元素不同,那么可以使用异或运算遍历 s 和 t 的每一位,最后剩下的就是多出来的一位:

class Solution {
public:
    char findTheDifference(string s, string t) {
        int ans=0;
        for(int i=0;i<s.length();i++)   ans=ans^(s[i]-'a');
        for(int i=0;i<t.length();i++)   ans=ans^(t[i]-'a');
        return ans+'a';
    }
};
【Leetcode刷题】位运算_第10张图片

三. 位运算的应用

Leetcode78.子集

Leetcode78.子集
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
提示:
1 <= nums.length <= 10
-10 <= nums[i] <= 10
nums 中的所有元素 互不相同

显然集合的幂集 ans 有 2n 个元素,对应 n 位的 二进制编码,因此可以穷举一个 n 位的二进制序列。在计算第 i 个元素编码对应的数组时,可以使用 位运算 i & (1< 获取其第 j 位。遍历编码的 n 位,若该位为 1 则选中 nums[j],否则不选,复杂度为 O(n*2n):

class Solution {
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        int n=nums.size();
        int len=pow(2,n);
        vector<vector<int>> ans;
        for(int i=0;i<len;i++){     // i的二进制编码用来索引nums的
            vector<int> tmp;
            for(int j=0;j<n;j++){   // i[j]=1则取nums[j]
                if(i & (1<<j)){     // 位运算
                    tmp.push_back(nums[j]);
                }
            }
            ans.push_back(tmp);
        }
        return ans;
    }
};
【Leetcode刷题】位运算_第11张图片

Leetcode957.N 天后的牢房

Leetcode957.N 天后的牢房
监狱中 8 间牢房排成一排,每间牢房可能被占用或空置。
每天,无论牢房是被占用或空置,都会根据以下规则进行变更:
如果一间牢房的两个相邻的房间都被占用或都是空的,那么该牢房就会被占用。
否则,它就会被空置。
注意:由于监狱中的牢房排成一行,所以行中的第一个和最后一个牢房不存在两个相邻的房间。
给你一个整数数组 cells ,用于表示牢房的初始状态:如果第 i 间牢房被占用,则 cell[i]=1,否则 cell[i]=0 。另给你一个整数 n 。
请你返回 n 天后监狱的状况(即,按上文描述进行 n 次变更)。
示例 1:
输入:cells = [0,1,0,1,1,0,0,1], n = 7
输出:[0,0,1,1,0,0,0,0]
解释:下表总结了监狱每天的状况:
Day 0: [0, 1, 0, 1, 1, 0, 0, 1]
Day 1: [0, 1, 1, 0, 0, 0, 0, 0]
Day 2: [0, 0, 0, 0, 1, 1, 1, 0]
Day 3: [0, 1, 1, 0, 0, 1, 0, 0]
Day 4: [0, 0, 0, 0, 0, 1, 0, 0]
Day 5: [0, 1, 1, 1, 0, 1, 0, 0]
Day 6: [0, 0, 1, 0, 1, 1, 0, 0]
Day 7: [0, 0, 1, 1, 0, 0, 0, 0]
示例 2:
输入:cells = [1,0,0,1,0,0,1,0], n = 1000000000
输出:[0,0,1,1,1,1,1,0]
提示:
cells.length = 8
cells[i] 为 0 或 1
1 <= n <= 109

考虑每一位的逻辑关系,cells[i]=~cells[i-1]^cells[i+1],且 cells[0]=cells[7]=0。为了便于位运算,将 cells 数组转为 int 型整数 cell 进行运算对 cell 进行移位得到 cells[i-1] 和 cells[i+1],则逻辑表达式为 cell=~(cell<<1)^(cell>>1)

然而,n 的取值最大可达 109,直接遍历会超时,需要寻找 周期规律。使用两个变量 ptr 和 i 分别记录第一次出现两次的元素出现的位置,则有周期 T=i-ptr,于是 n 可以表示为 ptr+k*T+x(0<=x,因此 n 天后的 cell 就等于 ptr+x 天后的 cell:

class Solution {
public:
    vector<int> prisonAfterNDays(vector<int>& cells, int n) {
        // vector -> int
        int cell=0;
        for(int i=0;i<8;i++){
            cell<<=1;
            cell^=cells[i];
        }
        // 计算N天后的牢房
        unsigned int match=0x0000007E;
        vector<int> Ncells(1,cell);
        vector<int>::iterator it;
        int i,ptr=-1;               // ptr和i分布记录第一、二次出现的位置
        for(i=1;i<=n;i++){
            cell=~(cell<<1)^(cell>>1);
            cell&=match;            // 首尾置0
            it=find(Ncells.begin(),Ncells.end(),cell);
            if(it!=Ncells.end()){   // cell出现过
                ptr=it-Ncells.begin();
                break;
            }
            Ncells.push_back(cell);
        }
        if(ptr!=-1){
            int T=i-ptr;            // 周期
            int x=(n-ptr)%T;
            cell=Ncells[x+ptr];
        }
        // int -> vector
        vector<int> ans(8);
        for(int i=0;i<8;i++){
            ans[i]=(cell>>(7-i))&1;
        }
        return ans;
    }
};
【Leetcode刷题】位运算_第12张图片

你可能感兴趣的:(LeetCode刷题,leetcode,算法)