位运算判断2的幂:从二进制特征到哈希表与内存池优化
发布时间:2026/9/26 6:07:49 作者:尧图编辑部 阅读量:1,286

前两天改一个内存池模块遇到了一个再常见不过的需求判断某个 block size 是不是正好是 2 的幂。我下意识写了个 while 循环刚写完就停住了——LeetCode 第 231 题“2 的幂”躺在我的刷题列表里不知道多久了这一道在大厂面试里被问烂了的简单题真实落到工程代码里居然还有人用循环除 2 的写法。这道题表面上是“判断一个整数是否为 2 的幂”但它的价值远不止一个 AC。位运算n (n - 1)的语义、负数与零的边界处理、哈希表扩容时为什么执着于 2 的幂、“2 的幂数组”为什么能撑起一堆经典数据结构——这些才是藏在题目背后的真正干货。这篇文章想跟你聊的不只是怎么过这道题而是借这道题把“2 的幂”这一件事彻底聊透。适合准备算法面试的人、写底层中间件或者网络库的工程师也适合所有想把位运算真正用起来的同学。1. 这道“简单题”到底在考什么1.1 从二进制视角重新认识 2 的幂LeetCode 231 的题干极短给定一个整数n判断它是否是 2 的幂次方。常规思路是循环除以 2能除到 1 就是。但如果你只停留在这个层面题目就白做了。计算机里的一切底层偏爱 2 的幂本质原因是计算机本身就是二进制的。二进制数从右往左的每一位对应的权值依次是 1、2、4、8、16、32……这恰好就是 2 的幂序列。所以“一个数是 2 的幂”这件事翻译成二进制就变得异常清晰这个数的二进制表示里有且仅有一个位是 1其余全是 0。举几个例子10001200104010081000160001 0000你看每个 2 的幂的二进制里都只有一个孤零零的 1。反过来也能说得通只要一个正整数的二进制里恰好只有一个 bit 是 1那它就是 2 的某个幂次。这个特征就是所有高效解法尤其是位运算解法的根基。你后面看到的n (n - 1) 0、n (-n) n本质上都是在检查这个特征。1.2 为什么“只有一个 1”这个特征很重要一旦你抓住“二进制只有一个 1”这个精髓很多看似高深的位运算技巧就有了自然的推导路径。举个例子n (n - 1)这个运算作用是把n二进制里最低位的那个 1 清零。这个结论记住不难但理解它怎么来的才值钱n - 1会把n最右侧的 1 变成 0同时把它右边所有的 0 都变成 1左边的高位不动。比如n 12二进制1100n - 1 1011两者按位与1100 1011 ------- 1000最低位的 1 没了结果从1100变成了1000。如果n原本就只有一个 1比如n 8二进制1000n - 1 0111相与之后1000 0111 ------- 0000结果直接归零。这就是n (n - 1) 0判断 2 的幂的原理。整个推导过程没有任何魔法就是从二进制借位规则出发的自然结果。注意n (n - 1) 0只对正整数成立。n 0时0 (-1)等于 0但 0 不是 2 的幂所以必须加上n 0的前置条件。这道简单题考的就是这种“从二进制特征出发反推位运算表达式”的思维能力而不是让你死记硬背一个公式。2. 判断方法大对比从循环除法到一行位运算2.1 最直觉的解法循环除以 2先看新手最容易写出来的版本public boolean isPowerOfTwo(int n) { if (n 0) return false; while (n % 2 0) { n / 2; } return n 1; }思路很直白2 的幂不断除以 2最终会变成 1不是 2 的幂的数除到最后会剩一个大于 1 的奇数。正确性没问题每次循环把数字缩小一半时间复杂度是O(log n)空间复杂度O(1)。但说实话这版代码在面试里只能拿一个“基础分”。不是因为它错而是因为它完全没有体现“2 的幂”这个问题的特殊性。你拿它去判断“3 的幂”“5 的幂”改个除数照样能跑。一个解法如果对题目没有针对性那它大概率不是最优解。2.2 数学流解法对数判断的精度陷阱还有不少人会想到用对数。2 的幂n 2^k那么log2(n)一定是整数。于是有人写出这样的代码public boolean isPowerOfTwo(int n) { if (n 0) return false; return Math.log(n) / Math.log(2) % 1 0; }这个写法在概念上是对的但工程上非常不稳。浮点数的精度问题会在这里冷不丁咬你一口。Math.log返回的是double对数运算本身就有舍入误差。比如某些大整数理论上log2(n)是整数实际计算出来却是29.000000000000004取模 1 之后不等于 0判断直接失败。我在实际测试里就踩过这个坑传入536870912即2^29某些 Java 版本下Math.log(n) / Math.log(2)得到的结果并不是干净的 29.0。这类 bug 极其隐蔽因为它只在特定的大整数上触发单元测试覆盖不到线上偶发一次就够你查半天。所以我的建议是工程代码里不要用对数判断 2 的幂除非你只是写个一次性脚本而且数据范围极小。2.3 位运算解法n (n - 1)和n (-n)真正的主角登场。判断一个正整数是不是 2 的幂最经典的写法有两行public boolean isPowerOfTwo(int n) { return n 0 (n (n - 1)) 0; }以及它的变体public boolean isPowerOfTwo(int n) { return n 0 (n -n) n; }第一种的原理前面已经推导过2 的幂只有一个 bit 是 1n (n - 1)会把最低位 1 清零所以结果必然为 0。不是 2 的幂的数至少有两个 1清零掉最低位之后剩下的不为 0。第二种的原理同样在建基于“只有一个 1”的特征。n (-n)在位运算里的语义是“提取最低位的 1”。对于 2 的幂来说这个“最低位的 1”就是它仅有的那一个 1提取出来自然等于它自己。比如n 8二进制1000-8的补码表示是1000在这个例子中恰好相同8 (-8) 8。换成n 121100-12补码是010012 (-12) 4不等于 12判断失败。两种写法在一个脑筋急转弯问题里Java 的Integer.numberOfLeadingZeros(31 - n)关系靠谱吗老实说这种写法纯属炫技读完代码的人要多花三秒钟才反应得过来维护成本不划算。日常开发选n (n - 1)那版就够了。2.4 四种解法横向对比解法核心思路时间复杂度空间复杂度适用场景循环除 2不断除以 2 看余数O(log n)O(1)教学演示逻辑直观对数判断log2(n) 是否为整数O(1) 但有浮点误差O(1)不推荐精度隐患n (n - 1)清零最低位 1 后看是否为 0O(1)O(1)面试与工程首选n (-n)提取最低位 1 后看是否等于自己O(1)O(1)与上一种等价从时间复杂度的数学意义上讲位运算和取对数都是 O(1)但位运算没有浮点舍入问题没有Math.log的函数调用开销在现代 CPU 上就是一条指令的事。在热路径代码里这种差别会被放大得非常明显。3. 热搜里的“2 的幂数组”到底指什么3.1 哈希表容量为什么死磕 2 的幂网上关于“2 的幂”的热搜词里经常跟着一个“2 的幂数组”。这个词不算严谨的学术名词但它精准概括了一类工程现象很多核心数据结构的内部数组长度会被刻意设计成 2 的幂。最典型的例子就是 Java 的HashMap。你去看源码table.length永远是 2 的幂扩容时也是直接翻倍。为什么这么设计核心原因在索引计算上。常规的哈希表在计算桶下标时用hash % capacity也就是取模。模运算在 CPU 指令层面是个比较贵的操作。但如果capacity是 2 的幂取模运算可以等价替换成位运算hash % capacity hash (capacity - 1)这个等式成立的前提正是capacity 2^k。因为capacity - 1的二进制低位恰好全是 1高位的 0 会把hash的高位屏蔽掉留下的正好是hash对capacity取模的余数。哈希分布是否均匀取决于低位但是低位容易碰撞所以HashMap还会把hash的高位也参与进来这就是 JDK 7 和 JDK 8 里那一串扰动函数的由来。但不管怎么扰动最后落到hash (len - 1)这一步都必须依赖数组长度是 2 的幂。扩容时 2 的幂还有额外好处。容量从16变成32一个元素的索引要么不变要么变成“旧索引 旧容量”。判断新旧索引只需要看新参与计算的那一位是 0 还是 1。JDK 8 里的resize()方法正是用(e.hash oldCap) 0区分元素应该留在原位还是移动到新位置这就是“2 的幂数组”在散列表设计中的巨大便利。3.2 二叉堆和树状数组里的隐藏幂次除了哈希表还有一类数据结构天然依赖 2 的幂数组完全二叉树。以二叉堆为例如果数组下标从 1 开始那么下标i的节点左孩子下标是2*i右孩子是2*i1父节点是i/2。这套下标公式之所以能成立本质上是因为二叉树的每一层节点数都是 2 的幂第 0 层 1 个第 1 层 2 个第 2 层 4 个……于是我们才能用紧凑的数组连续存放整棵树而不需要存储左右孩子指针。再看数据结构课程里的“树状数组”Fenwick Tree它的步子更是踩在 2 的幂上。核心操作里有个lowbit(i)定义是i (-i)作用就是取一个整数二进制里最低位的 1 所代表的权值。查询前缀和时我们用i - lowbit(i)往前跳单点更新时我们用i lowbit(i)往后跳。这个跳转的步长就是 2 的某个幂次。如果不懂 2 的幂与位运算树状数组的代码在观感上就是天书理解了之后才明白它的设计多么精致。所以“2 的幂数组”在工程语境里更多指的是一种容量或者长度约定数组长度是 2 的幂时很多索引计算、取模、哈希分桶都能退化为位运算代码既快又简洁。3.3 内存池与缓冲区对齐底层代码里的幂次信仰如果你写过网络库或者内存池对“对齐到 2 的幂”一定不陌生。Netty 的池化内存分配器里PoolSubpage的大小是 2 的幂分配内存时不同规格的块也是按 2 的幂分层管理。为什么执着于 2 的幂因为内存对齐、偏移计算、页表映射这些底层操作都极度偏好 2 的幂。对齐的本质是“把一个数向上取整到某个 2 的幂的倍数”。如果对齐单位是alignment且它是 2 的幂那么“向上对齐”可以用一条位运算完成long aligned (size alignment - 1) ~(alignment - 1);~(alignment - 1)相当于把低位清零前面加上alignment - 1是为了进位。这个式子没有任何分支没有取模几个指令就把问题解决了。如果对齐单位不是 2 的幂比如非要按 10 字节对齐对不起你就只能老老实实做除法了。从 HashMap 的索引计算到二叉堆的下标映射再到内存分配器的字节对齐“2 的幂”处处在给计算机科学的底层打工。理解了这一层你再回头看 LeetCode 231就会觉得它不仅仅是道面试题更是整个数据结构体系中一个高频出现的基础元素。4. 进阶玩法把一个数向上对齐到 2 的幂4.1 从判断到生成HashMap 的 tableSizeFor 算法会判断“是不是 2 的幂”只是第一步工程上更常见的需求是“把一个数变成 2 的幂”。比如用户调HashMap构造方法时传了initialCapacity 13内部不可能真的开一个长度为 13 的数组它会悄悄算出一个不小于 13 的 2 的幂也就是 16。JDK 里这个方法叫tableSizeFor代码很经典static final int tableSizeFor(int cap) { int n cap - 1; n | n 1; n | n 2; n | n 4; n | n 8; n | n 16; return (n 0) ? 1 : (n MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n 1; }这段代码初看像天书其实思路极其清晰目标是把cap - 1的最高位以下的低位全部变成 1最后再加 1。拆开看。第一步n cap - 1是为了处理cap本身就是 2 的幂的情况。如果cap 16不减 1最终结果会变成 32多扩了一倍。减 1 之后16 变成 15位或填充后最高位 1 以下全是 1加 1 刚好回到 16。接下来那五步右移和位或n | n 1能保证最高位往右 1 位也是 1n | n 2能保证最高位往右 3 位内全是 1以此类推。124816恰好覆盖了 Javaint的 32 位范围。经过这一串操作原来只有最高位一个 1 的数字变成了“从最高位到最低位全是 1”的形态。最后n 1进位得到一个干净的 2 的幂。4.2 动手验证几个边界值我手动推了一遍cap 13cap - 1 12二进制1100n | n 11100 | 0110 1110n | n 21110 | 0011 1111后续的右移 4、8、16 不再改变结果因为低位已经全是 1最后n 1 16再试cap 16cap - 1 15二进制1111右移位或一圈还是111115 1 16再试cap 1cap - 1 0所有位或都是 0最后0 1 1三个案例跑下来这个方法在边界上的行为是自洽的。它只用纯位运算完成了一个看起来需要循环或者Integer.highestOneBit辅助的逻辑效率极高。这个算法的思想也是 LeetCode 231 的镜像判断 2 的幂看的是二进制里 1 的个数生成 2 的幂则要把散落的 1 全部“铺满”。4.3 手写版本与标准库实现对比tableSizeFor是 JDK 内部方法不允许外部直接调用。如果你在项目里需要同样功能可以自己写一个更易读的版本public static int ceilToPowerOfTwo(int n) { if (n 0) return 1; if (n (1 30)) return 1 30; int highest Integer.highestOneBit(n); return highest n ? n : highest 1; }思路完全不同先用Integer.highestOneBit(n)取出最高位对应的 2 的幂然后判断原数是否正好等于这个幂。如果等于直接返回如果不等于说明原数比这个幂大那就把最高位再左移一位。两个版本结果一致但风格迥异。JDK 版本全是位运算极端追求性能手写版本可读性好有if有return容易看懂。工程实践中我会优先写后者等真的被性能测试锤了再换位运算版本。不过说实话在大部分业务代码里这个函数调用的频次没那么高可读性的收益远大于那一点微小的性能差异。5. 从 231 延伸出去类型题与被追问的连环炮5.1 一脉相承的姊妹题LeetCode 里围绕 2 的幂有一串亲戚。342 题“4 的幂”最直接4 的幂当然是 2 的幂但 2 的幂未必是 4 的幂。判断逻辑就是先过了“2 的幂”这一关n (n - 1) 0再检查幂次是不是偶数。位运算的技巧是检查二进制中那个唯一的 1 是否落在奇数位上常见写法是n 0x55555555 ! 0。338 题“比特位计数”看着和“2 的幂”无关其实也用到了lowbit思想。经典的动态规划递推式dp[i] dp[i 1] (i 1)或者借用dp[i] dp[i (i - 1)] 1都是在反复操作“最低位的 1”。如果你刷过 231再看 338 的官方题解会觉得亲切很多。还有 326 题“3 的幂”虽然不是 2 的幂但解法思路形成鲜明对比。3 的幂没法用位运算因为 3 不是 2 的底数常规做法是用对数或者把它世界里的“最大 3 的幂”拿出来取模public boolean isPowerOfThree(int n) { return n 0 1162261467 % n 0; }1162261467是 int 范围内最大的 3 的幂。如果n是 3 的幂它必然是最大 3 的幂的因子——这个思路和 2 的幂的位运算解法一对比你就能感受到“底数是 2”这个前提有多么大的特权。5.2 面试官会怎么层层追问“判断一个数是不是 2 的幂”问完之后面试官通常会在一分钟内抛出连环追问。我把自己经历过的和听说过的版本都整理一下。第一问往往是边界条件。“如果 n 是负数呢是 0 呢”目的是看你在不在 5 秒内反应过来低于 0 的整数不存在 2 的幂。第二问是“不用位运算可以吗”考察你对对数、循环等常规方法的理解。第三问开始上强度“如果输入是 long 呢”答案依然可以用n (n - 1)只要把整个式子平移但要注意Long的MIN_VALUE和溢出边界。第四问更有区分度“给你一个很大的数组里面几十亿个 int找出所有 2 的幂怎么优化”如果还一个个n (n - 1)判断不能说错但面试官可能会期待你从数据流、并行、批处理的角度回答。我给一个参考答案把整数画到二进制视角可以先按最高位分桶每个桶里再筛选低位特征也可以用查表法预处理一个 16 位的表格判断每个 2 字节块是否是 2 的幂然后跳过大量不符合规则的数字。最后一问最贴近工程“你会在什么真实场景里用到‘一个数是 2 的幂’这个判断”到这一步前面讲的 HashMap 容量计算、内存池对齐、二叉堆下标就都有用武之地了。能回答出“哈希表容量向上取整到 2 的幂、内存块对齐、分治递归时把规模对半切”面试官就知道你不只是在背题。5.3 遇到“2 的幂”相关的生产 bug讲个真实案例。前两年我接手过一个缓存服务里面有一张自定义哈希表扩容逻辑是自己写的。某个版本里扩容后数组长度是原长度 * 1.5而不是 2 倍结果索引计算还是用位运算hash (len - 1)。由于len不是 2 的幂这个式子算出来的根本不是hash % len导致大量 key 映射到错误桶位缓存命中率骤降线上告警炸了一晚上。排查的时候我第一反应就是去数数组长度是不是 2 的幂一眼就看出问题。这个 bug 的教训很直接如果代码里用了hash (len - 1)这种位运算取模那len的每一种可能取值都必须对 2 的幂做校验要么在构造时强制向上取整要么在运行时断言。事后我在这个服务里加了一个启动自检遍历所有哈希表实例逐个校验len (len - 1) 0不等就直接报错。这个判断正是 LeetCode 231 的工程化应用一行代码避免了一次潜在的生产事故。6. 写在最后把一道简单题拆到这么细值吗经常有人问我LeetCode 231 的代码只要一行写篇长文讲它是不是小题大做。我的观点恰恰相反简单题的“简单”体现在答案短但它背后牵扯的思维方式一点都不简单。n (n - 1) 0这行代码是“从问题特征到最优解法”的一次完整思维训练从二进制特征出发到反推位运算表达式再到扩展到 HashMap、二叉堆、内存对齐整个过程把计算机系统里的很多核心设计串在了一起。我个人在实际项目里的体会是凡是出现“2 的幂”的地方往往都是性能敏感的核心路径。哈希桶定位、内存池分配、缓冲区自动扩容这些代码对速度的要求极其苛刻位运算在这种位置上才真正发挥价值。建议你把今天讨论的tableSizeFor和lowbit在本地跑一遍打印出每步的二进制结果亲自观察那些 1 是怎么铺开、又是怎么归位的。看到 15 加 1 变 16 的那一瞬间你会觉得这道简单题确实不简单。