1. 位运算算法专题概述
位运算作为计算机底层最基础的操作之一,在算法领域有着独特而强大的应用价值。相比传统算术运算,位运算直接操作二进制位,具有极高的执行效率,能够在O(1)时间复杂度内完成许多复杂操作。本专题精选LeetCode中五个经典位运算问题,通过实际案例展示如何利用位运算特性解决特定类型的算法问题。
对于Java开发者而言,深入理解位运算不仅能提升算法能力,还能加深对JVM底层机制的认识。位运算在Java集合框架、并发编程等领域都有广泛应用,比如HashMap的哈希计算、ThreadPoolExecutor的状态控制等。掌握这些技巧,可以帮助后端开发者编写出更高效、更优雅的代码。
2. 判断字符唯一性问题解析
2.1 位图法的核心思想
判断字符串中所有字符是否唯一(LeetCode面试题01.01)的常规解法是使用HashSet,但利用位运算可以将空间复杂度优化到O(1)。其核心在于使用一个整数变量作为位图(bitmap),每个比特位代表一个字符是否出现过。
对于仅包含小写字母的情况(a-z),只需要26个比特位即可。Java中int类型有32位,完全能满足需求。具体实现时:
- 初始化bitmap为0(所有位为0)
- 遍历字符串,计算当前字符与'a'的偏移量x
- 检查bitmap的第x位是否为1
- 若为1则返回false,否则将该位置1
注意:ASCII码中大写字母和小写字母不连续,此解法仅适用于纯小写或纯大写字母场景。若字符集包含其他字符,需要调整实现方式。
2.2 鸽巢原理优化
在实现中首先检查字符串长度是否超过26是一个重要优化。根据鸽巢原理(Pigeonhole Principle),当字符串长度超过字符集大小时,必然存在重复字符。这个小技巧可以提前终止不必要的计算:
if (astr.length() > 26) return false;2.3 位运算操作详解
关键位运算操作解析:
bitMap >> x:将bitmap右移x位,使目标位移动到最低位(bitMap >> x) & 1:通过与1进行AND运算,提取最低位的值bitMap |= 1 << x:将1左移x位后与bitmap进行OR运算,设置对应位
实测表明,相比HashSet解法,位运算版本在LeetCode上运行时间从1ms减少到0ms,内存消耗从40MB降到39.7MB,体现了位运算的高效性。
3. 丢失数字问题的异或解法
3.1 异或运算的特性
异或运算(XOR)有几个重要性质:
- 任何数与自身异或结果为0:a ^ a = 0
- 任何数与0异或结果为自身:a ^ 0 = a
- 满足交换律和结合律:a ^ b ^ c = a ^ c ^ b
这些特性使得异或成为解决"出现次数"类问题的利器。在丢失数字问题(LeetCode 268)中,我们可以利用这些性质找出缺失的数字。
3.2 算法实现步骤
具体实现分为三步:
- 初始化结果ret为0
- 遍历数组,将所有元素与ret异或
- 遍历0到n(n为数组长度),将所有数字与ret异或
最终ret的值就是缺失的数字。因为除了缺失数字外,其他数字都出现了两次,异或后相互抵消。
public int missingNumber(int[] nums) { int ret = 0; for (int x : nums) ret ^= x; for (int i = 0; i <= nums.length; i++) ret ^= i; return ret; }3.3 复杂度分析
- 时间复杂度:O(n),需要遍历数组两次
- 空间复杂度:O(1),只使用常数空间
相比排序后遍历或数学求和法,异或解法不需要处理整数溢出问题,也不需要对数组进行修改,是更优的选择。
4. 位运算实现加法
4.1 无进位加法的实现
LeetCode 371要求不使用加减运算符实现两数相加。这需要深入理解计算机加法的底层原理。关键观察点:
- 异或运算相当于无进位加法
- 按位与运算可以找出需要进位的位置
例如,计算5(101)和3(011)的和:
- 无进位加法:101 ^ 011 = 110(6)
- 计算进位:(101 & 011) << 1 = 001 << 1 = 010(2)
- 将结果相加:110 + 010 = 1000(8)
4.2 递归与迭代实现
上述过程可以通过递归或迭代实现。迭代版本更节省栈空间:
public int getSum(int a, int b) { while (b != 0) { int x = a ^ b; // 无进位和 int carry = (a & b) << 1; // 进位值 a = x; b = carry; } return a; }4.3 处理负数的情况
这个算法同样适用于负数。以-1(111...1111)和1(000...0001)为例:
- 第一次迭代:
- x = -1 ^ 1 = 111...1110
- carry = (-1 & 1) << 1 = 000...0010
- 第二次迭代:
- x = 111...1110 ^ 000...0010 = 111...1100
- carry = (111...1110 & 000...0010) << 1 = 000...0100
- 最终carry变为0,结果为0
提示:Java中使用补码表示负数,右移操作符>>是算术右移,会保留符号位。在位移操作时要特别注意这一点。
5. 只出现一次的数字II
5.1 统计比特位思路
LeetCode 137题中,除一个数字出现一次外,其他数字都出现三次。这种情况下简单的异或不再适用。我们需要更精细的位级统计:
- 遍历32位整数的每一位
- 统计所有数字在该位上1的个数
- 如果该位的1的个数不是3的倍数,则目标数字在该位为1
public int singleNumber(int[] nums) { int ret = 0; for (int i = 0; i < 32; i++) { int sum = 0; for (int x : nums) { sum += (x >> i) & 1; } if (sum % 3 == 1) { ret |= (1 << i); } } return ret; }5.2 有限状态自动机解法
更高级的解法是使用有限状态自动机,将每一位的状态用两个比特位表示(00→01→10→00),可以一次遍历完成计算:
public int singleNumber(int[] nums) { int ones = 0, twos = 0; for (int num : nums) { ones = (ones ^ num) & ~twos; twos = (twos ^ num) & ~ones; } return ones; }这种方法将时间复杂度保持在O(n),空间复杂度O(1),但理解起来较为复杂。在实际面试中,能够解释清楚位统计法通常已经足够。
6. 消失的两个数字问题
6.1 问题分解思路
LeetCode面试题17.19要求从1到N的连续数字中找出缺失的两个数字。这个问题可以分解为:
- 将所有数字(数组元素+完整范围)异或,得到两个缺失数字的异或值
- 找到这个异或值中任意一个为1的位(表示两个数字在该位不同)
- 根据该位将所有数字分为两组,分别异或得到最终结果
6.2 具体实现细节
public int[] missingTwo(int[] nums) { // 第一步:全部异或 int tmp = 0; for (int x : nums) tmp ^= x; for (int i = 1; i <= nums.length + 2; i++) tmp ^= i; // 第二步:找到不同的位 int diff = 0; while (((tmp >> diff) & 1) != 1) diff++; // 第三步:分组异或 int[] ret = new int[2]; for (int x : nums) { if (((x >> diff) & 1) == 1) ret[1] ^= x; else ret[0] ^= x; } for (int i = 1; i <= nums.length + 2; i++) { if (((i >> diff) & 1) == 1) ret[1] ^= i; else ret[0] ^= i; } return ret; }6.3 边界情况处理
需要注意几个边界情况:
- 当N很小时(如N=1),需要正确处理数组边界
- 当缺失的数字是最大值和次大值时,循环范围要包含N+2
- 找不同位时确保不会无限循环(虽然tmp不会为0)
在实际工程中,这类位运算代码建议添加详细注释,因为位运算的可读性通常较差,容易引入难以发现的bug。
7. 位运算的工程实践技巧
7.1 性能优化场景
位运算在以下场景特别有用:
- 密集布尔值存储(如权限系统)
- 哈希函数计算
- 状态压缩(如棋盘类游戏)
- 高性能数学运算(如2的幂次判断)
7.2 可读性与维护性
虽然位运算高效,但会降低代码可读性。建议:
- 为复杂的位操作添加详细注释
- 将常用操作封装成工具方法
- 编写单元测试验证边界情况
- 避免过度优化,只在性能关键路径使用
7.3 常见位运算技巧
- 判断奇偶:x & 1
- 取最低位的1:x & -x
- 清除最低位的1:x & (x - 1)
- 2的幂次判断:(x & (x - 1)) == 0
- 交换两个数:a ^= b; b ^= a; a ^= b;
位运算就像算法世界中的瑞士军刀,虽然看起来简单,但熟练掌握后能解决许多复杂问题。在实际开发中,我经常使用位运算来优化性能关键路径的代码,但也会注意保持代码的可维护性。建议读者从这些经典问题入手,逐步积累位运算的使用经验,最终达到灵活运用的水平。