位运算技巧

数据结构与算法专题 · 二进制层面的高效操作

专题:数据结构与算法系统学习

关键词:数据结构, 算法, 位运算, 异或, Bitmask, lowbit, 位操作, 状态压缩, 布隆过滤器, BitSet

一、位运算基础

位运算(Bitwise Operation)是直接对整数的二进制位进行操作的一类运算。在计算机底层,所有数据都以二进制形式存储,因此位运算的执行效率极高,通常在一个CPU时钟周期内即可完成。掌握位运算不仅是写出高效代码的关键,更是理解计算机工作原理的重要途径。

1.1 六种基本位运算符

运算符名称规则示例
&按位与两位同时为1时结果为15 & 3 = 1 (0101 & 0011 = 0001)
|按位或两位至少有一个为1时结果为15 | 3 = 7 (0101 | 0011 = 0111)
^按位异或两位不同时结果为15 ^ 3 = 6 (0101 ^ 0011 = 0110)
~按位取反0变1, 1变0~5 = -6 (补码表示)
<<左移各二进位全部左移n位,高位丢弃,低位补05 << 1 = 10 (0101 → 1010)
>>右移各二进位全部右移n位,正数高位补0,负数高位补1(算术右移)5 >> 1 = 2 (0101 → 0010)

1.2 位运算的性质

异或运算具有几条非常重要的数学性质,是许多算法技巧的基础:

交换律: a ^ b = b ^ a

结合律: (a ^ b) ^ c = a ^ (b ^ c)

自反性: a ^ a = 0 (任何数和自己异或得0)

零元: a ^ 0 = a (任何数和0异或得自身)

幂等性(与运算): a & a = a, a | a = a

分配律(与对或): a & (b | c) = (a & b) | (a & c)

理解补码表示对位运算至关重要。在Python中,整数是无限精度的,负数的取反操作需要特别注意:~x = -(x+1)。在C++等定长整数中,负数采用二补数(Two's Complement)表示,即 -x = ~x + 1。

1.3 位运算的底层优势

位运算比算术运算快得多。一个乘法指令可能需要3-5个时钟周期,而一次左移只需要1个周期。编译器在优化层面会自动将"乘以2的幂次"替换为左移操作,但在逻辑判断、掩码操作等场景中,手写位运算可以更精确地表达算法意图。

二、常用位操作技巧

以下列举了算法面试和日常编码中最高频的位操作技巧,掌握这些技巧可以显著提升代码效率。

2.1 判断奇偶性

利用二进制最低位判断:奇数的最低位为1,偶数的最低位为0。

// 判断奇偶 —— 比取模更快 bool isOdd(int x) { return x & 1; } bool isEven(int x) { return (x & 1) == 0; } // Python实现 def is_odd(x): return x & 1 == 1 def is_even(x): return x & 1 == 0

2.2 取第 k 位

将数字右移k位然后与1相与,即可得到第k位的值(从0开始编号)。

// 获取整数x的第k位(0-indexed) int getBit(int x, int k) { return (x >> k) & 1; } // 示例:获取5(0101)的第2位 // 5 >> 2 = 1 (0001), 1 & 1 = 1

2.3 设置第 k 位

将第k位置为1使用或运算,置为0使用与运算+取反掩码。

// 将x的第k位设置为1 int setBitToOne(int x, int k) { return x | (1 << k); } // 将x的第k位设置为0 int setBitToZero(int x, int k) { return x & ~(1 << k); } // 翻转第k位 int flipBit(int x, int k) { return x ^ (1 << k); }

2.4 Lowbit 操作

lowbit(x) 用于提取整数x二进制表示中最低位的1及其后面的所有0。这是树状数组(Fenwick Tree)和某些位运算DP的核心操作。

// lowbit 实现 —— 利用补码特性 int lowbit(int x) { return x & (-x); } // 示例:lowbit(12) = 4 // 12 = 1100, -12 = 0100 (补码) // 1100 & 0100 = 0100 = 4 // 应用:枚举x中的所有1位 void enumerateOnes(int x) { while (x) { int lb = x & (-x); printf("lowbit: %d\n", lb); x -= lb; // 或 x ^= lb } }

为什么 lowbit 是正确的? 在补码表示中,-x = ~x + 1。当x的二进制末尾有连续的0时,~x把这些0变成1,+1使得这些1进位回0,只有最低位的1保持不变。因此 x & (-x) 恰好提取了最低位的1。

2.5 统计二进制中1的个数(Popcount)

统计整数二进制表示中1的个数有经典的低位消除法(Brian Kernighan算法)。

// Brian Kernighan算法 —— 循环次数等于1的个数 int popcount(int x) { int cnt = 0; while (x) { x &= (x - 1); // 消除最低位的1 cnt++; } return cnt; } // 示例:x = 13 (1101) // 1101 & 1100 = 1100, cnt=1 // 1100 & 1011 = 1000, cnt=2 // 1000 & 0111 = 0000, cnt=3

Python 3.8+ 内置了 int.bit_count() 方法,C++20 提供了 std::popcount。但理解底层实现仍然很重要。

2.6 判断 2 的幂次

2的幂次在二进制表示中只有一个1,利用这个性质可以快速判断。

// 判断x是否是2的幂次 bool isPowerOfTwo(int x) { return x > 0 && (x & (x - 1)) == 0; } // 原理:如果x是2的幂次,则二进制形如100...0 // x-1形如011...1,两者相与必为0 // 例如:8=1000, 7=0111, 1000&0111=0000 // 扩展到判断x是否是4的幂次 bool isPowerOfFour(int x) { return x > 0 && (x & (x - 1)) == 0 && (x & 0x55555555) != 0; }

2.7 交换两数(不使用临时变量)

// 使用异或交换两个整数 void swap(int &a, int &b) { a ^= b; b ^= a; a ^= b; } // 推导过程: // a' = a ^ b // b' = b ^ a' = b ^ (a ^ b) = a ^ (b ^ b) = a ^ 0 = a // a'' = a' ^ b' = (a ^ b) ^ a = b

注意:当 a 和 b 指向同一内存地址时,此方法会将值置为0。实际开发中推荐使用 std::swap 或临时变量,因为现代编译器对临时变量的优化已经非常出色。

2.8 绝对值与符号判断

// 不使用分支语句的绝对值 int abs(int x) { int mask = x >> (sizeof(int) * 8 - 1); return (x + mask) ^ mask; } // 原理:当x为正数时,mask=0,返回x // 当x为负数时,mask=-1(全1),x+(-1)=x-1 // (x-1)^(-1) = ~(x-1) = -x = |x| // 更简洁的写法(需避免溢出) int abs2(int x) { int mask = x >> 31; return (x ^ mask) - mask; } // 取符号位 int sign(int x) { return x >> 31; } // 正数为0,负数为-1

三、异或的妙用

异或运算是位运算中"最聪明"的运算,利用其自反性零元性质,可以解决许多看似复杂的算法问题。

3.1 找出数组中唯一出现一次的数

经典面试题:给定一个非空整数数组,除了某个元素只出现一次外,其余每个元素均出现两次。找出那个只出现一次的元素。

// 全部异或一遍,成对的数全部消去 int singleNumber(vector<int>& nums) { int ans = 0; for (int x : nums) ans ^= x; return ans; } // 扩展问题:如果有两个数只出现一次,其余都出现两次? // 思路:先全员异或得到 a^b,然后找任意一个为1的位 // 按该位将数组分成两组,各自异或消除 vector<int> singleNumber2(vector<int>& nums) { int xorAll = 0; for (int x : nums) xorAll ^= x; int lb = xorAll & (-xorAll); // 最低不同位 int a = 0, b = 0; for (int x : nums) { if (x & lb) a ^= x; else b ^= x; } return {a, b}; }

3.2 找出缺失的数

给定一个包含 [0, n] 中 n 个数的数组,找出缺失的那个数。

// 利用异或:将下标和数值全部异或 int missingNumber(vector<int>& nums) { int n = nums.size(); int ans = n; for (int i = 0; i < n; i++) { ans ^= i ^ nums[i]; } return ans; } // 原理:假设数组不缺数,则每个i应该等于nums[i] // 缺失的那个数在异或过程中只出现一次(来自下标), // 对应的数值没有出现,因此留下的就是缺失的数

3.3 找出两个不同的元素

有两个字符串 s 和 t,t 由 s 随机重排后在随机位置添加一个字符构成。找出 t 中添加的字符。

// 将两个字符串的所有字符异或,成对的消去 char findTheDifference(string s, string t) { char ans = 0; for (char c : s) ans ^= c; for (char c : t) ans ^= c; return ans; } // 也可以利用ASCII码的性质,一行搞定 char findDiff(string s, string t) { return accumulate(t.begin(), t.end(), 0) - accumulate(s.begin(), s.end(), 0); }

3.4 数组中两数异或最大值

给定一个整数数组,找出两个数异或结果的最大值。这是Trie树与位运算结合的经典题目。

// 使用前缀树(Trie)优化,时间复杂度O(N*32) struct TrieNode { TrieNode* next[2] = {nullptr, nullptr}; }; class Solution { public: int findMaximumXOR(vector<int>& nums) { TrieNode* root = new TrieNode(); // 建树:将每个数的二进制位插入Trie for (int x : nums) { TrieNode* p = root; for (int i = 31; i >= 0; i--) { int b = (x >> i) & 1; if (!p->next[b]) p->next[b] = new TrieNode(); p = p->next[b]; } } // 查询:对每个数贪心地走相反位 int ans = 0; for (int x : nums) { TrieNode* p = root; int cur = 0; for (int i = 31; i >= 0; i--) { int b = (x >> i) & 1; if (p->next[b ^ 1]) { cur |= (1 << i); p = p->next[b ^ 1]; } else { p = p->next[b]; } } ans = max(ans, cur); } return ans; } };

异或解题模式总结:凡是出现"成对消除""找出唯一""找出不同"的问题,首先尝试异或思路。异或的"自反性"(a^a=0)使得成对元素可以轻松消去,而"零元"(a^0=a)保证了只有一次的元素会保留下来。

四、位掩码与状态压缩

位掩码(Bitmask)是利用整数的二进制位来表示集合或状态的技术,在状态压缩DP、搜索剪枝和权限系统中有着广泛应用。

4.1 枚举子集

用一个n位二进制数表示集合,第i位为1表示元素i在集合中。枚举所有子集即枚举0到(1<<n)-1。

// 枚举集合{0,1,...,n-1}的所有子集 void enumerateSubsets(int n) { for (int mask = 0; mask < (1 << n); mask++) { printf("子集 { "); for (int i = 0; i < n; i++) { if (mask & (1 << i)) printf("%d ", i); } printf("}\n"); } } // 枚举子集的子集(常见于状压DP) // 复杂度 O(3^n) for (int mask = 0; mask < (1 << n); mask++) { for (int sub = mask; sub; sub = (sub - 1) & mask) { // sub 是 mask 的非空子集 } // 空集单独处理 }

4.2 状态压缩动态规划

状压DP是位运算最核心的应用之一,适合处理小规模的状态集合(通常n ≤ 20)。典型问题包括旅行商问题(TSP)、图着色、任务分配等。

// 旅行商问题(TSP)的状压实现 // dp[mask][v]: 从起点出发经过mask集合中的城市,最后到达v的最短路径 // 城市数量 n ≤ 20,dist[][] 为距离矩阵 int tsp(vector<vector<int>>& dist) { int n = dist.size(); vector<vector<int>> dp(1 << n, vector<int>(n, INT_MAX / 2)); dp[1][0] = 0; // 从城市0出发,只访问了城市0 for (int mask = 1; mask < (1 << n); mask++) { for (int u = 0; u < n; u++) { if (!(mask & (1 << u))) continue; if (dp[mask][u] == INT_MAX / 2) continue; for (int v = 0; v < n; v++) { if (mask & (1 << v)) continue; // 已经访问过v int nMask = mask | (1 << v); dp[nMask][v] = min(dp[nMask][v], dp[mask][u] + dist[u][v]); } } } // 回到起点 int fullMask = (1 << n) - 1; int ans = INT_MAX; for (int v = 1; v < n; v++) { ans = min(ans, dp[fullMask][v] + dist[v][0]); } return ans; }

4.3 布隆过滤器(Bloom Filter)与位图

布隆过滤器利用多个哈希函数和一个位数组实现高效的存在性判断,虽然存在误判率(False Positive)但不会漏判(False Negative)。

// Python实现一个简单的布隆过滤器 import mmh3 # 使用murmurhash import math class BloomFilter: def __init__(self, n, fp_rate): # n: 预计插入的元素数量 # fp_rate: 可接受的误判率 self.bit_size = int(-n * math.log(fp_rate) / (math.log(2) ** 2)) self.hash_count = int(self.bit_size / n * math.log(2)) self.bit_array = [0] * (self.bit_size // 8 + 1) def _set_bit(self, pos): idx = pos // 8 offset = pos % 8 self.bit_array[idx] |= (1 << offset) def _get_bit(self, pos): idx = pos // 8 offset = pos % 8 return (self.bit_array[idx] >> offset) & 1 def add(self, item): for seed in range(self.hash_count): h = mmh3.hash(item, seed) self._set_bit(h % self.bit_size) def contains(self, item): for seed in range(self.hash_count): h = mmh3.hash(item, seed) if not self._get_bit(h % self.bit_size): return False return True

4.4 BitSet 的实现

BitSet(位集合)是用位数组实现的紧凑集合,在空间效率和交集/并集运算上远优于传统集合。

// C++ 手动实现简易BitSet #include <cstdint> #include <vector> class BitSet { private: std::vector<uint64_t> bits; size_t size; public: BitSet(size_t n) : size(n) { bits.resize((n + 63) / 64, 0); } void set(size_t pos, bool val = true) { if (val) bits[pos / 64] |= (1ULL << (pos % 64)); else bits[pos / 64] &= ~(1ULL << (pos % 64)); } bool get(size_t pos) const { return (bits[pos / 64] >> (pos % 64)) & 1; } // 交集 BitSet operator&(const BitSet& other) const { BitSet res(size); for (size_t i = 0; i < bits.size(); i++) res.bits[i] = bits[i] & other.bits[i]; return res; } // 并集 BitSet operator|(const BitSet& other) const { BitSet res(size); for (size_t i = 0; i < bits.size(); i++) res.bits[i] = bits[i] | other.bits[i]; return res; } // Popcount size_t count() const { size_t cnt = 0; for (uint64_t x : bits) cnt += __builtin_popcountll(x); return cnt; } };

4.5 权限系统设计

位掩码在权限系统中天然适用:每个权限占用一个独立的二进制位。

// 基于位掩码的权限系统 enum Permission { NONE = 0, // 0000 READ = 1 << 0, // 0001 WRITE = 1 << 1, // 0010 EXECUTE = 1 << 2, // 0100 DELETE = 1 << 3 // 1000 }; // 添加权限 int grant(int perm, int target) { return perm | target; } // 移除权限 int revoke(int perm, int target) { return perm & ~target; } // 检查权限 bool hasPermission(int perm, int target) { return (perm & target) == target; } // 应用: int userPerm = READ | WRITE; // 0011 — 可读可写 userPerm = grant(userPerm, EXECUTE); // 0111 userPerm = revoke(userPerm, WRITE); // 0101 bool canRead = hasPermission(userPerm, READ); // true bool canWrite = hasPermission(userPerm, WRITE); // false

五、位运算在算法竞赛中的应用

算法竞赛(如ACM/ICPC、LeetCode、Codeforces)中位运算题目通常以"隐藏技巧"的形式出现,掌握这些套路能显著提高解题速度。

5.1 格雷码(Gray Code)

格雷码的特点是相邻两个编码之间只有一位不同。构造格雷码的经典公式为:g(i) = i ^ (i >> 1)。

// 生成n位格雷码序列 vector<int> grayCode(int n) { vector<int> res(1 << n); for (int i = 0; i < (1 << n); i++) { res[i] = i ^ (i >> 1); } return res; } // 示例:n=3 // i: 0(000) → 0^0 = 0 → 000 // i: 1(001) → 1^0 = 1 → 001 // i: 2(010) → 2^1 = 3 → 011 // i: 3(011) → 3^1 = 2 → 010 // i: 4(100) → 4^2 = 6 → 110 // i: 5(101) → 5^2 = 7 → 111 // i: 6(110) → 6^3 = 5 → 101 // i: 7(111) → 7^3 = 4 → 100 // 相邻编码确实只有一位不同!

5.2 数位DP中的位运算

在数位DP中,常用位掩码记录已使用的数字集合,例如解决"数位中每位数字各不相同"的问题。

// 统计 [0, n] 中每位数字各不相同的数的个数 // 状态:pos(当前位置), mask(已用数字集合), lead(前导零), tight(上限约束) int countSpecialNumbers(int n) { string s = to_string(n); int len = s.length(); // memo[pos][mask][lead][tight] // 这里仅展示核心逻辑 function<int(int,int,bool,bool)> dfs = [&](int pos, int mask, bool lead, bool tight) -> int { if (pos == len) return lead ? 0 : 1; int limit = tight ? s[pos] - '0' : 9; int ans = 0; for (int d = 0; d <= limit; d++) { if (!lead && (mask & (1 << d))) continue; // 已使用 bool nLead = lead && (d == 0); int nMask = nLead ? mask : (mask | (1 << d)); ans += dfs(pos + 1, nMask, nLead, tight && (d == limit)); } return ans; }; return dfs(0, 0, true, true); }

5.3 N皇后问题的位运算优化

N皇后问题使用位运算可以将回溯的判断从O(n)降低到O(1)。这是位运算在搜索剪枝中的经典应用。

// N皇后的位运算极速实现 // 每行用一个位表示列的攻击位置 int totalNQueens(int n) { int ans = 0; int fullMask = (1 << n) - 1; function<void(int,int,int)> dfs = [&](int col, int diag1, int diag2) { if (col == fullMask) { ans++; return; } // 当前行可放置皇后的位置 int avail = fullMask & ~(col | diag1 | diag2); while (avail) { int pos = avail & (-avail); // 取最低位可用位置 avail -= pos; dfs(col | pos, (diag1 | pos) << 1, // 下一行的主对角线攻击 (diag2 | pos) >> 1); // 下一行的副对角线攻击 } }; dfs(0, 0, 0); return ans; } // 四皇后示例(n=4) // fullMask = 1111 (二进制) // 第一行:col=0000, diag1=0000, diag2=0000 // avail = 1111, 取pos=0001 // 第二行:col=0001, diag1=0010, diag2=0000 // avail = 1111 & ~(0001|0010|0000) = 1100 ...

复杂度对比:传统N皇后回溯每次需要遍历整行检查冲突,时间复杂度约为O(N!),而位运算版本利用整型并行处理列和对角线冲突,常数因子大幅降低。实际测试中,N=15时位运算版本比传统版本快10倍以上。

5.4 容斥原理与位运算

在计算多个集合的并集大小时,容斥原理需要枚举所有集合的组合。位运算是枚举组合最自然的方式。

// 容斥原理求区间[1, n]中被集合S中任意数整除的数的个数 // S = {a[0], a[1], ..., a[m-1]} int inclusionExclusion(int n, vector<int>& a) { int m = a.size(); int ans = 0; for (int mask = 1; mask < (1 << m); mask++) { int lcm = 1, bits = 0; for (int i = 0; i < m; i++) { if (mask & (1 << i)) { lcm = lcm / gcd(lcm, a[i]) * a[i]; // 防溢出 if (lcm > n) break; bits++; } } if (lcm > n) continue; // 奇数个集合加,偶数个集合减 if (bits % 2 == 1) ans += n / lcm; else ans -= n / lcm; } return ans; }

六、进阶技巧与拓展

6.1 位运算实现加法

使用异或模拟无进位加法,与运算模拟进位,循环直到进位为0。

// 位运算实现整数加法 int add(int a, int b) { while (b) { int carry = (a & b) << 1; // 进位 a ^= b; // 无进位和 b = carry; // 循环处理进位 } return a; } // 可以扩展实现减法:a - b = add(a, add(~b, 1)) // 乘法:a * b 可以通过循环加实现 int multiply(int a, int b) { int res = 0; while (b) { if (b & 1) res = add(res, a); a <<= 1; b >>= 1; } return res; }

6.2 计算整数对数

通过位运算快速计算以2为底的对数的整数部分(即最高位的位置)。

// 求floor(log2(x)),即x的最高位的位置 int floorLog2(int x) { int res = 0; while (x >>= 1) res++; return res; } // 更快的二分查找法(32位整数) int floorLog2Fast(uint32_t x) { int res = 0; if (x >= 1 << 16) { res += 16; x >>= 16; } if (x >= 1 << 8) { res += 8; x >>= 8; } if (x >= 1 << 4) { res += 4; x >>= 4; } if (x >= 1 << 2) { res += 2; x >>= 2; } if (x >= 1 << 1) { res += 1; } return res; } // 向上取整到2的幂次 uint32_t roundUpToPowerOf2(uint32_t x) { x--; x |= x >> 1; x |= x >> 2; x |= x >> 4; x |= x >> 8; x |= x >> 16; return x + 1; }

6.3 编译期内置位运算函数

函数功能平台
__builtin_popcount(x)统计1的个数GCC/Clang
__builtin_ctz(x)末尾0的个数(Count Trailing Zeros)GCC/Clang
__builtin_clz(x)前导0的个数(Count Leading Zeros)GCC/Clang
__builtin_ffs(x)最低位1的位置(1-indexed, Find First Set)GCC/Clang
std::popcount(x)C++20标准popcountC++20
int.bit_count()Python 3.8+ popcountPython
Integer.bitCount(x)Java popcountJava

实用建议:

在刷题和竞赛中优先使用语言内置的位运算函数,它们经过高度优化(通常使用CPU指令POPCNT直接计算)。理解手动实现是为了应对"不允许使用内置函数"的面试场景以及深入理解计算机原理。

七、核心要点总结

位运算是算法面试和竞赛中的"核武器",平时练习时应有意识地使用位运算优化代码,将位运算思维内化为直觉。

7.1 必背位运算口诀

清零取反用与(&), 特定位置一用或(|) 要交换不用临时, 异或(^)三遍就搞定 判断奇偶看低位, 除以二就用右移(>>) 判断幂次有技巧, x & (x-1) 等于0 取最低位1有妙招, x & (-x) 是lowbit 子集枚举用掩码, 状态压缩省时空

7.2 常用技巧速查表

操作表达式
判断奇偶x & 1
取第k位(x >> k) & 1
设置第k位为1x | (1 << k)
设置第k位为0x & ~(1 << k)
翻转第k位x ^ (1 << k)
取最低位1x & (-x)
消去最低位1x & (x - 1)
判断2的幂次x > 0 && (x & (x-1)) == 0
保留低位n位x & ((1 << n) - 1)
截取连续区间位(x >> i) & ((1 << k) - 1)
模2的幂次x & (mod - 1) (相当于 x % mod)
向上取整到2的幂次1 << (32 - __builtin_clz(x - 1))
异或交换a ^= b; b ^= a; a ^= b;
绝对值(x ^ (x >> 31)) - (x >> 31)
两数平均(防溢出)(x & y) + ((x ^ y) >> 1)

7.3 适用场景总结

学习路径建议:先从常用的奇偶判断、lowbit、popcount入手,理解基础操作后在LeetCode上刷"位运算"标签下的前10道高频题,然后再挑战状压DP和Bloom Filter等进阶内容。将位运算作为"工具箱"而非主修方向,遇到问题时自然想到可用位运算优化即可。

八、参考题目与练习

题号题目核心技巧
LeetCode 136只出现一次的数字异或消去
LeetCode 137只出现一次的数字 II逐位统计
LeetCode 260只出现一次的数字 III异或+分组
LeetCode 191位1的个数Popcount / lowbit
LeetCode 2312的幂x & (x-1) 判断
LeetCode 268缺失的数字异或
LeetCode 78子集位掩码枚举
LeetCode 421数组中两个数的最大异或值Trie + 贪心
LeetCode 89格雷编码i ^ (i >> 1)
LeetCode 52N皇后 II位运算剪枝
LeetCode 338比特位计数DP + lowbit
LeetCode 187重复的DNA序列位编码+滑动窗口