☰
判断2的幂:从暴力循环到位运算,一次搞懂二进制优化
2026/10/19 16:32:28 网站建设 项目流程

开篇:为什么一道“简单题”这么值得写

判断一个数字是不是 2 的幂,看起来是个初级到不能再初级的问题。但恰恰是这种基础题,最能拉开代码水平的差距。有人几行循环取模搞定,有人用一行位运算秒杀,还有人能写出漏洞百出的“正确代码”——负数、零、边界溢出,处处是坑。

我在刷算法题的时候,Day45 遇到了这个题目。标题很朴素:“实现一个算法确定一个数字 N 是否为 2 的幂。若该数字是 2 的幂则输出 YES,否则输出 NO。”我原以为 10 分钟能写完,结果折腾了一晚上——光是想清楚“为什么位运算写法是对的”就花了不少时间。这道题适合所有刚开始刷算法的同学,也适合工作几年后想夯实基础的开发者。它能串起来的知识点比表面上多得多:位运算、负数补码、整数溢出、边界条件设计,还有工程里缓存对齐、哈希表容量规划这些实际场景。

这篇文章就把这道题彻底拆透,从朴素解法到最优解,从代码实现到实战踩坑,一次讲明白。

1. 先拆解题目:到底在问什么

1.1 输入输出的边界分析

题目表面上是“判断 N 是否为 2 的幂”,但我第一次提交就挂了——因为我没仔细想清楚输入范围。

题目没有明确说明 N 是正整数还是任意整数。这就带来了一个关键问题:负数算不算 2 的幂?零算不算?

从数学定义上,2 的幂指的是形如 2^k 的数,其中 k 是非负整数(也有人允许负指数,但在算法题里几乎不会出现,因为负指数会产生小数 0.5、0.25 等)。所以在编程语境里,输入通常限制为整数,而合法答案只有 1、2、4、8、16、32……这样的数。

换句话说:

  • N = 1,是 2^0,应该输出 YES。
  • N = 2,是 2^1,应该输出 YES。
  • N = 3,不是 2 的幂,输出 NO。
  • N = 0,不是 2 的幂,输出 NO。
  • N = -2,不是 2 的幂,输出 NO(某些不严谨的实现会在这里翻车)。

1.2 为什么这个题目适合作为基础训练

我在带新人做 Code Review 的时候就发现,很多初学者能写出正确的解法,但说不清楚解法背后的原理。能写出(N > 0) && ((N & (N - 1)) == 0)的人不少,能解释清楚“为什么 N-1 会让二进制中的低位置反”的人不多。

这道题的价值就在于:它是学习位运算的最佳切入点之一。通过它,你能把二进制的本质、补码的表示、位运算的物理含义一次弄懂。后面再遇到判断 3 的幂、4 的幂、找只出现一次的数字这些题,思路会顺畅很多。

2. 解法演进:从暴力到一行代码

2.1 最朴素的循环取余法

如果你第一次接触这个问题,最直观的思路就是:不停地除以 2,直到除不动为止,看最后的结果是不是 1。

def is_power_of_two(n): if n <= 0: return "NO" while n % 2 == 0: n //= 2 return "YES" if n == 1 else "NO"

这个思路是对的:只要一个数能被 2 整除,就继续除以 2,直到变成奇数为止。如果最后剩下 1,说明它从头到尾一直在被 2 整除,那它就是 2 的幂。

比如 N = 8,流程是 8 → 4 → 2 → 1,最后是 1,YES。 比如 N = 12,流程是 12 → 6 → 3,最后是 3,不是 1,NO。

这个解法的时间复杂度是 O(log N),因为每除一次 2,数字就缩小一半。对于 32 位整数,最多循环 31 次,成本很低。但它有个小问题:如果 N 很大(比如 2^1000),这个解法在大整数语言里依然有效,但在 C++/Java 这种固定精度的语言里,超出表示范围的数字在输入时就溢出了,根本轮不到算法来处理。

这个解法最大的贡献是帮助我们理解 2 的幂的数学定义。但在算法竞赛和面试场景里,它是“能跑但不够优雅”的典型。

2.2 二分法思路:如果你有和场景

如果你拿到的是一个排好序的 2 的幂数组(比如 [1, 2, 4, 8, 16...]),可以用二分查找来判断 N 是否在里面。但问题在于,如果 N 很大,你得先构建这个数组。空间浪费不说,构建过程本身就是 O(log N) 的循环,所以这个思路在本题里意义不大。

不过,有一种相关的二分思路值得提——在某些变体题里,题目会要求“求大于等于 N 的最小 2 的幂”。这时用二分法在预先生成的幂数组中查找,比逐次乘 2 要快。这里不展开,只提醒一句:算法题最忌讳的是套模板,要先判断数据形态再选方法。

2.3 对数公式法:数学解法

如果你学过对数,会想到这样的思路:

一个整数 N 是 2 的幂,当且仅当 log₂N 是整数。

用代码表达就是:

import math def is_power_of_two(n): if n <= 0: return "NO" result = math.log2(n) return "YES" if result.is_integer() else "NO"

这个解法写起来很短,但隐患非常大。我用一个实际测试来说明:

print(math.log2(2**29) == 29) # True print(math.log2(2**30) == 30) # True print(math.log2(2**31) == 31) # True,但已经接近边界

如果输入是 2^53 呢?Python 的 float 是双精度浮点数,当数值超过 2^53 时,精度就不足以精确表示整数值了。这时候math.log2(2**53 + 1)很可能返回 53.0,导致误判。

所以在算法竞赛里,对数法并不可靠。浮点数的舍入误差会在边界处坑你一次。倒是在工程脚本里,如果明确知道输入范围很小(比如 N < 10^6),用对数法图省事是可以的。但我个人不建议用,因为“不严谨”这个标签一旦贴上,后面出问题排查成本很高。

2.4 位运算终解:一行代码杀穿

这才是本题的真正核心。2 的幂在二进制表示里有一个显著特征:只有一位是 1。

  • 2^0 = 1 → 二进制 0001
  • 2^1 = 2 → 二进制 0010
  • 2^2 = 4 → 二进制 0100
  • 2^3 = 8 → 二进制 1000
  • 2^4 = 16 → 二进制 0001 0000
  • 2^5 = 32 → 二进制 0010 0000

看出来了吗?二进制中只有一个 1,其余全是 0。

那么判断一个正整数是不是 2 的幂,就等价于判断它的二进制表示中是否只有一位是 1。

经典的位运算技巧是:N & (N - 1)。如果一个数 N 是 2 的幂,那么 N 和 N-1 在二进制上有一个非常特殊的关系:

  • N = 8 → 1000
  • N-1 = 7 → 0111
  • N & (N-1) = 0 → 0000

为什么?因为 8 只有最高位是 1,减去 1 之后,所有低位都变成 1,最高位变成 0,两者按位与,结果必然是 0。

推广到所有 2 的幂,这个性质都成立。

反过来,如果 N 不是 2 的幂,比如 N = 10 → 1010,N-1 = 9 → 1001,两者相与:1010 & 1001 = 1000,不是 0。

所以判断条件就是:N > 0 且 (N & (N-1)) == 0。

写成代码:

def is_power_of_two(n): return "YES" if (n > 0) and (n & (n - 1)) == 0 else "NO"

Java 版本:

public String isPowerOfTwo(int n) { return (n > 0) && ((n & (n - 1)) == 0) ? "YES" : "NO"; }

C 版本:

const char* isPowerOfTwo(int n) { return (n > 0) && ((n & (n - 1)) == 0) ? "YES" : "NO"; }

这个解法的核心优势只有一个——常数时间复杂度。不管 N 是 2^3 还是 2^31,运算量都是一样的:一次位与、一次减法、一次比较。

2.5 另一个位运算变种:取余技巧

除了 N & (N-1),还有一个相关的位运算技巧:N & (-N)。

负整数在计算机中使用补码表示。N 的补码是 N 取反加 1。对任意正整数 N,N & (-N)得到的结果是二进制中最低位的 1 所代表的值(专业说法是 lowbit 运算)。

  • N = 12 → 1100,-N = 0100(补码),12 & (-12) = 0100 = 4
  • N = 16 → 10000,-N = 10000(补码),16 & (-16) = 10000 = 16

如果 N 是 2 的幂,N & (-N)的结果就等于 N 本身。因为它的二进制只有一位 1,而这一位恰恰是“最低位的 1”,取出来就是 N 自己。

所以判断条件还可以写成:

def is_power_of_two(n): return "YES" if (n > 0) and (n & -n) == n else "NO"

这个写法和N & (N-1) == 0本质上是同一个原理的两面,但N & (N-1)更通用一些,因为它能直接判断“二进制中是否只有一个 1”,而 lowbit 技巧更适合“提取最低位的 1”这种场景。

3. 代码实现与边界条件逐个击破

3.1 负数与零的陷阱

如果你不处理 n <= 0 的情况,直接写(n & (n-1)) == 0,那 n = 0 时会发生什么?

n = 0,n - 1 = -1。0 的补码是全 0,-1 的补码是全 1,0 & (-1) = 0。结果确实是 0,但它不应该输出 YES。所以必须加 n > 0 的约束。

负数呢?比如 n = -8。-8 的补码表示是 1111...1000(假设 32 位)。n & (n-1) = -8 & -9 = 1111...1000 & 1111...0111 = 1111...0000,不是 0。看起来能排除负数,但如果你用 lowbit 写法,n = -8 时 -8 & 8 = 8,n & -n == n不成立,也能排除。可是每个负数都要想一遍补码特别累。

最优实践是:第一行先判断 n <= 0,直接输出 NO,后续逻辑只管正整数。这样既安全又容易理解。

3.2 整数溢出的边界值处理

如果用 C/Java 的 32 位 int,最大的 2 的幂是 2^30 = 1073741824。如果你输入的 N 是 2147483647(INT_MAX 附近的值),它显然不是 2 的幂,但计算N-1没问题。真正要小心的是N+1或者N*2这类运算,如果在算法里出现,可能溢出成负数,把结果搞乱。

这也提醒一个写作习惯:优先使用减法N-1而不是加法N+1来做位运算判断,因为减法的溢出风险远低于加法。

3.3 多语言实现的细节差异

同样是位运算,不同语言有个小坑:无符号数与有符号数的位运算语义不同。

在 Python 中,整数是任意精度的,不存在溢出问题。但 Python 的&运算是按补码进行的,负数的位运算结果依然是负数,这在底层和 C 一致。

在 Java 中,int是 32 位有符号整数。如果你执行Integer.MIN_VALUE & (Integer.MIN_VALUE - 1),结果是 0,但这不代表 MIN_VALUE 是 2 的幂。所以 Java 代码里n > 0的判断绝对不可或缺。

在 C++ 中,如果你用了unsigned int,那么负数压根不会进入输入,因为无符号数里不存在负数。但 C++ 面试里通常用int,所以逻辑和 Java 一致。

在 JavaScript 里,位运算有个反直觉的特性:所有按位运算都会先把操作数转成 32 位有符号整数。这意味着如果你传入一个超过 2^31-1 的数,它会被截断后才能进行位运算。下面这段代码就很有迷惑性:

function isPowerOfTwo(n) { return n > 0 && (n & (n - 1)) === 0; } console.log(isPowerOfTwo(2147483648)); // 2^31,超过了 int 上限

在 JavaScript 里,2147483648 的 32 位表示实际上是 0(截断后),所以函数错误地返回了 false。想正确判断,得改用其他方式,比如Number.isInteger(Math.log2(n))或者自己写循环判断。这是一个非常容易踩的坑,如果你用 JavaScript 刷题,务必小心位运算的 32 位截断特性。

4. 核心原理深度追问:为什么位运算这么快

4.1 二进制的本质直觉

很多人一开始不理解位运算“快”在哪里。其实快的原因很简单:计算机的底层电路就是按位运算的。加法需要进位链,乘法需要移位和相加,周期更长;而位与、位或、移位这些操作,往往是单周期完成的。

你可以把二进制想象成一排开关,1 代表开,0 代表关。判断一个数是不是 2 的幂,就是在看这一排开关里“有没有恰好一个开着”。N & (N-1)这个操作的效果是:把最低位的那个 1 抹掉。如果抹掉之后整排开关全灭了,说明原本只有一个 1;如果还有 1 亮着,说明原本就不止一个 1。

这个“抹掉最低位 1”的技巧,在算法竞赛里叫 lowbit 思想。它延伸出了树状数组这个数据结构的核心操作。树状数组的更新和查询靠的就是反复执行x & (-x)来跳到下一个管辖区间。如果你彻底弄懂了今天这道题,后面学树状数组会轻松很多。

4.2 数学定义到计算机表示的映射

从数学定义出发,2 的幂是 2^k。我们可以选择循环计算乘法来验证(从 1 开始不断乘 2,直到超过 N),也可以选择用二进制特征来验证。前者复杂度 O(log N),后者 O(1)。选择位运算,本质上是因为我们找到了问题的结构特征——二进制表示中 1 的个数。

同样的问题模式,在判断“一个数是不是 4 的幂”时也有体现:4 的幂在二进制中也是只有一个 1,但那个 1 必须出现在偶数位上(0、2、4……位)。所以判断 4 的幂需要在N & (N-1) == 0的基础上再附加一个条件:N % 3 == 1或者N & 0x55555555 != 0。

4.3 整数溢出的边界值处理

说回精度问题。在 Java 和 C++ 里,如果把int换成long,能表示的 2 的幂更多了,但判断逻辑不变。64 位整数里最大的 2 的幂是 2^62 = 4611686018427387904。如果你输入 2^63,在 long 范围内已经溢出了,输入时就会变成负数(Long.MIN_VALUE),这时候 n <= 0 的判断会直接输出 NO。看似正确,但其实它根本没有检查“2^63 是不是 2 的幂”这个问题,因为输入本身就错了。

面试时如果追问边界,能答清楚“输入类型能表示的最大 2 的幂是多少”,会是不错的加分项。

5. 变体题与工程场景:这道题不只是做题

5.1 连续判断多次的效率优化

有时候你需要在一个较大的数据范围内,反复判断某个数是不是 2 的幂。这时可以用一个“预处理 + 查表”的思路:因为 32 位整数范围内,2 的幂只有 32 个,把它们全部存到一个数组或哈希集合里,然后直接成员判断。

POWERS_OF_TWO = {1 << i for i in range(32)} # 1~2^31 def is_power_of_two(n): return "YES" if n in POWERS_OF_TWO else "NO"

这个写法在多次查询时速度极快,哈希查找 O(1),前提是你乐意用空间换时间。在工程里,如果某个配置项只能填 2 的幂(比如内存池大小、线程池的容量),用查表法做合法性校验非常方便。

5.2 向上取整到 2 的幂(实际迭代)

比“判断是否是 2 的幂”更常用的是“把任意正整数对齐到 2 的幂”。比如很多内存池分配器、HashMap 的扩容,都要求容量是 2 的幂。HashMap 在初始化时,会把用户指定的初始容量通过tableSizeFor方法,向上取整到最近的 2 的幂。

这个方法有个经典的位运算优化:

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 + 1; }

看不懂没关系,你可以理解为:这一连串的右移和或运算,把二进制中最高位 1 以下的所有位全部填成 1,得到形如 1111... 的数,再加 1 就变成了 1000...,也就是 2 的幂。

这个技巧和“判断是否为 2 的幂”是同一块知识。如果你能独立推导出tableSizeFor的实现,那么对位运算的理解就到了一个新的水平。

5.3 判断 2 的幂在工程中的真实应用

聊到工程,有几个实际场景经常用到这个判断:

  1. 缓存与队列容量校验:很多高性能组件设计要求容量必须是 2 的幂,以便用(index & (length - 1))代替index % length来求余数。求余运算慢,位与运算快。比如 JDK 的 ConcurrentHashMap 中,段数组的长度就是 2 的幂。

  2. 内存池额度检查:分配器按固定块大小分配内存,块大小经常是 2 的幂。参数校验时,就需要判断传入的期望大小是否是合法块大小。

  3. 图像处理的尺寸检查:某些图像缩放算法要求输入输出尺寸是 2 的幂(比如纹理映射的 mipmap),这时也需要做合法性判断。

我在实际工作中就遇到过一个线上问题:某个服务的内存缓存 key 数量布隆过滤器在设计时,把位数组长度设置成了非 2 的幂,结果每次取模都慢几个微秒。虽然单次看起来不多,但 QPS 上来之后,这几个微秒直接变成了 CPU 瓶颈。后来用前面说的tableSizeFor思路强制对齐到 2 的幂,取模全部换成位运算,性能立刻好了。

5.4 从 2 的幂到 n 的幂推广

搞懂了 2 的幂判断之后,你自然想知道 3 的幂、4 的幂怎么判断。4 的幂刚才说过了,是“2 的幂 + 额外位条件”。3 的幂有个众所周知的数学技巧:

因为 3^19 = 1162261467 是 32 位 int 范围内最大的 3 的幂,所以只要 N 是这个数的约数,N 就是 3 的幂:

def is_power_of_three(n): return n > 0 and 1162261467 % n == 0

这个思路只适用于质数底数,因为质数的幂不会互相干扰。用在 3 上很漂亮,但用在 4 上就不行(4 是 2 的平方,约数关系会误判)。

这种从一道题延伸出去的“考点树”,才是刷题真正的价值。我见过很多同学题量刷了不少,但每道题都是孤立的,完全没有建立联系。今天这个判断 2 的幂就是很好的“知识枢纽”,建议你顺手把 4 的幂、3 的幂、判断二进制中 1 的个数(BitCount)、判断二进制奇偶位是否交替这四道变体题一起练了。

6. 典型错误清单与快速自查表

我把实际写这道题最容易犯的错误整理成了一张表,方便你自查。

错误类型错误案例后果正确做法
忘记排除零n & (n-1) == 00 被误判为 YES先判断 n > 0
忘记排除负数直接位运算不判符号-2 等负数可能误判先判断 n <= 0 输出 NO
使用浮点对数log2(n) % 1 == 0大数精度丢失误判改用位运算或整数循环
用 JS 位运算处理 2^31 以上n & (n-1)截断超出 32 位范围误判改用 Math.log2 或 BigInt
循环中乘法溢出从 1 开始乘 2,超过目标C/Java 中溢出成负数改用除法或位运算
忽略多位 1 的场景把 12(1100)误判为 YES只有单个 1 才是牢记二进制特征
试图用循环计数 1 的个数遍历 32 次正确但效率低直接用 N & (N-1)

另外说一个面试高频追问:如果输入不是整数而是浮点数呢?比如 4.0 算不算 2 的幂?严格来说算,因为 4.0 = 2^2。但浮点数的幂在计算机里没有简洁的位运算判定方式,而且 0.5 也是 2 的 -1 次幂。这种问题在工程里往往没有意义,所以算法题基本不会考浮点版本。

7. 实测过程:一个晚上踩坑的记录

最后分享一次我的实际调试过程。那天我写这道题,最初版本是这样的:

public String isPowerOfTwo(int n) { return (n & (n - 1)) == 0 ? "YES" : "NO"; }

测试用例过了几个:4、8、16、32 都返回 YES,6、10、20 返回 NO。我以为完了,顺手提交,结果第一个隐藏测试就挂了——输入是 0,输出期望 NO,我的代码返回 YES。

我这才意识到n > 0的必要性,赶紧加上,又提交了一次。结果又挂了——输入是 Integer.MIN_VALUE。我的代码流判断n > 0已经挡住它了,为什么还挂?仔细看才发现,自己改的时候没保存,或者放在本地跑的时候用的还是旧版本。这种“本地对但线上错”的低级失误,相信每个程序员都经历过。

改完之后我心想,用位运算一行就完成了,还有什么好优化的?这时候我试着用 Python 写了个sys.set_int_max_str_digits相关的超大数测试。Python 的整数不限长度,所以即使 N 是 2^1000,位运算也能正确处理。不过,如果 N 是字符串形式输入(比如判断一个 100 位的十进制数是不是 2 的幂),位运算就不好使了,得用字符串转 BigInteger 或者大数库,手动处理除法。这种场景一般在竞赛里不常见,但如果你用 Python 刷题时遇到超大数输入,心里要有数。

最终我的提交版本是这样的:

public String isPowerOfTwo(int n) { if (n <= 0) return "NO"; return (n & (n - 1)) == 0 ? "YES" : "NO"; }

接下来我做了个简单测试:输入 1、2、4、8、16、32、64、128、256、512、1024,全部返回 YES;输入 0、-1、3、5、6、7、9、10、12、18、100,全部返回 NO。然后又随机生成了一万个 32 位整数,用循环除法和位运算两种方法对比验证,结果完全一致。这种“双实现互验”的做法推荐给大家,尤其是写和数学、位运算相关的逻辑时,非常能帮助发现隐藏问题。

为什么要做随机验证?因为循环除法是直观版的“真值来源”,位运算版是优化版。两者输出一致,就说明优化没有改变语义。这比单纯看几个用例有说服力得多。

个人经验:关于边界条件的一些体会

这道题让我想起一个朴素的道理:算法题的坑往往不在算法本身,而在输入的边界。不少时候,你写出了正确的核心逻辑,却被 0、负数、溢出这些“边缘情况”打败。实战里很多 bug 也是这样产生的——不是主流程出了问题,而是边界条件没想清楚。

如果你把这道题当作「温故知新」来练习,我建议你在草稿纸上先把 2 的幂的二进制表列出来,从 2^0 写到 2^8,强迫自己观察 1 出现的位置规律。这个过程看似多余,实际上能帮你建立深刻的二进制直觉。有了这个直觉,后面遇到任何位运算题目,你都会比别人多一分享受和从容。

最后补充一个小技巧——以后凡是你写出N & (N - 1)这种位运算表达式,都建议加注释说明原理。不是每个人看到位运算都能秒懂,我自己的项目里就吃过“别人看不懂、代码变成了坑”的亏。对于这类可读性较低的表达,一行注释能让后续维护的人少走很多弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询