LeetCode 190. 颠倒二进制位:两种解法详解

0 阅读8分钟

LeetCode 上一道经典的位运算题目——190. 颠倒二进制位。这道题看似简单,实则藏着位运算的核心技巧,尤其是第二种“分治颠倒”的思路,非常值得深入理解,既能巩固位运算基础,也能锻炼逻辑思维。

先明确题目要求:给定一个 32 位有符号整数,将其二进制位全部颠倒,返回颠倒后的整数。比如输入二进制 00000010100101000001111010011100,输出就是 00111001011110000010100101000000

解法一:逐位颠倒(基础易懂,适合入门)

这是最直观的思路:从原数字的最低位(最右边)开始,依次取出每一位二进制数,然后将其放到结果的对应高位(最左边),循环 32 次(因为是 32 位整数),最终得到颠倒后的结果。

先看完整代码:

function reverseBits_1(n: number): number {
    let rev = 0; // 存储颠倒后的结果,初始为0(二进制全0)
    // 循环32次(覆盖32位),若n提前变为0,可提前退出(优化效率)
    for (let i = 0; i < 32 && n !== 0; ++i) {
        // 1. 取出n的最低位:n & 1(二进制中,只有最低位是1时结果为1,否则为0)
        // 2. 将取出的最低位移到对应高位:<< (31 - i)(第i次循环,对应31-i位)
        // 3. 用或运算(|)将该位存入rev,不影响已存入的高位
        rev |= (n & 1) << (31 - i);
        // 4. n右移1位(无符号右移>>>),丢弃已处理的最低位,准备处理下一位
        n >>>= 1; 
    }
    // 无符号右移0位,确保结果是32位无符号整数(避免符号位干扰)
    return rev >>> 0; 
}

关键细节解析(必看)

  • n & 1:这是位运算中“取最低位”的经典操作。比如 n=5(二进制 101),n&1=1(取最低位1);n=4(100),n&1=0(取最低位0)。

  • << (31 - i):循环第 i 次(从0开始),我们取出的是原数字的第 i 位(从右数),需要放到结果的第 31 - i 位(从右数,即从左数的对应位置)。比如 i=0 时,取最低位,放到最高位(31位);i=31时,取最高位,放到最低位(0位)。

  • n >>>= 1:这里必须用无符号右移(>>>),而不是有符号右移(>>)。因为如果是有符号整数,右移时符号位会补1,导致处理负数时出错;无符号右移会在高位补0,符合32位无符号整数的处理逻辑。

  • return rev >>> 0:同样是为了确保结果是32位无符号整数。在TypeScript/JavaScript中,整数可能会有符号位,无符号右移0位可以将其转为无符号数,避免因符号位导致的结果错误。

解法一总结

优点:思路简单,容易理解,代码量少,适合新手入门位运算。

缺点:循环32次,时间复杂度是 O(32) = O(1)(固定循环次数,属于常数时间),效率其实不低,但还有更优的“分治”思路,可以减少位运算的次数。

解法二:分治颠倒(进阶技巧,高效简洁)

这种思路借鉴了“分而治之”的思想:将32位二进制数拆分成更小的单元(2位、4位、8位、16位),先颠倒每个小单元内部,再将颠倒后的小单元整体颠倒,最终实现整个32位的颠倒。

核心原理:利用位掩码(mask)分离出不同长度的单元,通过“右移+与掩码”“左移+与掩码”的组合,实现单元内部的颠倒,再合并单元。

完整代码:

function reverseBits_2(n: number): number {
    // 定义4个位掩码,用于分离不同长度的二进制单元
    const M1 = 0x55555555; // 01010101 01010101 01010101 01010101(每2位一组,01交替)
    const M2 = 0x33333333; // 00110011 00110011 00110011 00110011(每4位一组,0011交替)
    const M4 = 0x0f0f0f0f; // 00001111 00001111 00001111 00001111(每8位一组,00001111交替)
    const M8 = 0x00ff00ff; // 00000000 11111111 00000000 11111111(每16位一组,0000000011111111交替)
    
    let result: number = n;
    
    // 第一步:颠倒每2位(比如 01 → 10,10 → 01)
    result = ((result >>> 1) & M1) | ((result & M1) << 1);
    // 第二步:颠倒每4位(比如 0011 → 1100,0101 → 1010)
    result = ((result >>> 2) & M2) | ((result & M2) << 2);
    // 第三步:颠倒每8位
    result = ((result >>> 4) & M4) | ((result & M4) << 4);
    // 第四步:颠倒每16位
    result = ((result >>> 8) & M8) | ((result & M8) << 8);
    
    // 最后:颠倒整个32位(前16位和后16位互换),并转为无符号整数
    return ((result >>> 16) | (result << 16)) >>> 0;
}

分治步骤拆解(以8位二进制为例,便于理解)

假设我们有8位二进制数 11001010,用分治思路颠倒的过程如下:

  1. 每2位颠倒:拆分为 11、00、10、10,颠倒后为 11、00、01、01,合并为 11000101

  2. 每4位颠倒:拆分为 1100、0101,颠倒后为 0011、1010,合并为00111010

  3. 每8位颠倒:拆分为 0011、1010(此时8位拆分为两个4位),颠倒后为 10100011,即最终颠倒结果。

32位的逻辑和8位完全一致,只是拆分的单元更长,通过4个掩码逐步实现“从小单元到整体”的颠倒。

关键细节解析

  • 位掩码的作用:比如 M1(0x55555555),二进制每2位为一组,每组是01,用它和result做“与运算”,可以只保留result的奇数位(第1、3、5...31位);同理,M2保留每4位的前2位,M4保留每8位的前4位,M8保留每16位的前8位。

  • 颠倒单元的核心操作:以 ((result >>> 1) & M1) | ((result & M1) << 1) 为例:

    • (result >>> 1) & M1:将result右移1位,再和M1做与运算,得到“原奇数位右移1位”的结果(即原奇数位变成偶数位);

    • (result & M1) << 1:将result和M1做与运算,得到原奇数位,再左移1位(即原奇数位变成偶数位);

    • 两者用或运算(|)合并,就实现了“每2位颠倒”。

  • 效率优势:整个过程只需要5次位运算(4次单元颠倒+1次整体颠倒),无论输入是什么,都不需要循环,时间复杂度依然是 O(1),但实际运算次数比解法一更少,效率更高。

解法二总结

优点:高效简洁,位运算技巧性强,适合深入理解位掩码和分治思想,在面试中写出这种解法,能体现对位运算的熟练掌握。

缺点:思路相对抽象,需要理解位掩码的作用和分治的拆分逻辑,新手可能需要多琢磨几遍。

两种解法对比 & 实战建议

解法时间复杂度空间复杂度特点适用场景
逐位颠倒(解法一)O(1)O(1)思路简单,易理解,循环32次新手入门、快速解题、面试中快速写出正确代码
分治颠倒(解法二)O(1)O(1)技巧性强,运算次数少,效率高深入理解位运算、面试加分、追求代码简洁高效

常见易错点提醒

  • 忘记用无符号右移(>>>):无论是处理n还是结果,都必须用无符号右移,否则符号位会干扰,导致负数处理出错。

  • 循环次数不足32次:即使n提前变为0,也需要循环32次(或者最后用rev >>> 0补全32位),否则会导致高位补0不完整,结果错误。

  • 位掩码记错:解法二中的4个掩码是固定的,记错掩码会导致拆分单元错误,最终结果出错,建议记住这4个常用掩码(对应2、4、8、16位拆分)。

最后总结

LeetCode 190题是位运算的经典入门题,两种解法各有优势:解法一胜在易懂,解法二胜在高效。建议新手先掌握解法一,理解“逐位取数、逐位放置”的核心逻辑,再深入研究解法二的分治思想和位掩码技巧。

其实位运算的核心就是“操作二进制的每一位”,多练习这类题目,就能慢慢掌握各种位运算技巧(比如取位、移位、掩码、或/与/异或运算),后续遇到更复杂的位运算题目(如位1的个数、两数相加等)也能迎刃而解。