前两天改一个内存池模块,遇到了一个再常见不过的需求:判断某个 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。
举几个例子:
1:00012:00104:01008:100016:0001 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,二进制1100,n - 1 = 1011,两者按位与:
1100 & 1011 ------- 1000最低位的 1 没了,结果从1100变成了1000。如果n原本就只有一个 1,比如n = 8,二进制1000,n - 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 是 1,n & (n - 1)会把最低位 1 清零,所以结果必然为 0。不是 2 的幂的数,至少有两个 1,清零掉最低位之后剩下的不为 0。
第二种的原理同样在建基于“只有一个 1”的特征。n & (-n)在位运算里的语义是“提取最低位的 1”。对于 2 的幂来说,这个“最低位的 1”就是它仅有的那一个 1,提取出来自然等于它自己。比如n = 8,二进制1000,-8的补码表示是1000(在这个例子中恰好相同),8 & (-8) = 8。换成n = 12,1100,-12补码是0100,12 & (-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 后看是否为 0 | O(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*i+1,父节点是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 位也是 1;n |= n >>> 2能保证最高位往右 3 位内全是 1;以此类推。1+2+4+8+16,恰好覆盖了 Javaint的 32 位范围。经过这一串操作,原来只有最高位一个 1 的数字,变成了“从最高位到最低位全是 1”的形态。最后n + 1,进位,得到一个干净的 2 的幂。
4.2 动手验证几个边界值
我手动推了一遍cap = 13:
cap - 1 = 12,二进制1100n |= n >>> 1:1100 | 0110 = 1110n |= n >>> 2:1110 | 0011 = 1111- 后续的右移 4、8、16 不再改变结果,因为低位已经全是 1
- 最后
n + 1 = 16
再试cap = 16:
cap - 1 = 15,二进制1111- 右移位或一圈还是
1111 15 + 1 = 16
再试cap = 1:
cap - 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 的那一瞬间,你会觉得,这道简单题确实不简单。