在这篇文章中,我们将深入探讨一道在计算机科学面试和底层开发中经常遇到的基础但至关重要的题目:寻找整数二进制表示中“最右侧设置位”的位置。这听起来像是一个简单的位操作问题,但实际上它是理解计算机如何存储和处理数据的基础,也是解决更复杂算法问题的基石。
什么是“最右侧设置位”?
首先,让我们明确一下问题的定义。在计算机中,所有数据最终都以二进制形式存储,即由一串 0 和 1 组成。其中,“1”被称为“设置位”或“置位”。“最右侧设置位”指的是从右边数(即最低位方向起)第一个出现的“1”。
注意: 位置编号通常从 1 开始,而不是 0。也就是说,最右边的那一位(Least Significant Bit, LSB)是第 1 位。
让我们看一个具体的例子:
假设我们有一个整数 18。
它的二进制表示是 10010。
如果我们从右向左数:
- 第 0 位(通常指索引)是 0
- 第 1 位是 1
- 第 2 位是 0
- …
所以,第一个出现“1”的位置是 2。
#### 问题输入/输出示例
- 输入:
n = 18 - 输出:
2 - 解释: INLINECODEa306286b 的二进制是 INLINECODEc5660720,最右侧的
1位于第 2 位。
- 输入:
n = 19 - 输出:
1 - 解释: INLINECODE0fabb11a 的二进制是 INLINECODE22e3991a,最右侧的
1位于第 1 位。
- 输入:
n = 0 - 输出:
0 - 解释: INLINECODE5aa8804a 的二进制全是 INLINECODEc7702ec8,没有设置位。
接下来,我们将探索四种不同的方法来解决这个问题,从巧妙的数学技巧到实用的位操作技巧。
—
方法一:利用 2 的补码与对数运算
这是一个非常优雅且“极客”的解法。它的核心思想利用了负数在计算机中存储的方式(2 的补码)的性质。
#### 算法思路
你可能知道,计算机会使用 INLINECODE95920ac6 来计算 INLINECODE90d48e0e。在这个过程中,有一个非常有趣的位运算性质:
当我们执行 INLINECODE6517bdfb(或者等价的 INLINECODEc457ffb9)时,结果是一个只有原来 n 的最右侧那个 1 是 1,其他位全部变为 0 的数字。
为什么?
~n将 n 的所有位取反(0 变 1,1 变 0)。- INLINECODE8041f391 是一个加法操作。在二进制加法中,进位会一直向左传递,直到遇到第一个 0(而在 INLINECODEb28cdfd4 中,这个 0 对应的是
n中的 1)。 - 因此,INLINECODE6bffda18 和 INLINECODEbaa5b42b 只有最右侧的那个 1 是相同的,其他位都是相反的。按位与(AND)之后,就只剩下了这一个 1。
一旦我们分离出了这个唯一的“1”,我们就可以计算以 2 为底的对数(INLINECODE649b8d40)。因为 INLINECODE7e8e8bf7,这就能直接告诉我们这个 1 在第几位。
#### 复杂度分析
- 时间复杂度: O(1)。虽然
log函数在底层实现上有一定开销,但这被视为常数时间操作。 - 空间复杂度: O(1)。只使用了几个变量。
#### 代码实现
让我们看看如何在不同语言中实现这个逻辑。
C++ 实现
#include
#include
using namespace std;
// 获取最右侧设置位位置的函数
int getFirstSetBit(int n) {
// 边界情况处理:如果 n 是 0,则没有设置位
if (n == 0) return 0;
// 核心技巧:n & -n 可以分离出最右侧的 1
// 例如 n = 18 (10010), -n (以补码存储) 对应 ...11010
// 运算结果为 00010 (即 2)
int res = n & (-n);
// 使用 log2 计算 2 的幂次,加 1 是因为位置从 1 开始
// log2(2) = 1, 位置 = 1 + 1 = 2
return log2(res) + 1;
}
int main() {
int n = 18;
cout << "第一个设置位的位置是: " << getFirstSetBit(n) << endl;
return 0;
}
Python 实现
import math
def get_first_set_bit(n):
# 处理 n 为 0 的特殊情况
if n == 0:
return 0
# Python 的整数精度无限,位运算行为一致
# 计算最右侧 1 对应的数值
res = n & (-n)
# 返回其对数加 1
return int(math.log2(res)) + 1
if __name__ == "__main__":
n = 18
print(f"第一个设置位的位置是: {get_first_set_bit(n)}")
JavaScript 实现
function getFirstSetBit(n) {
// 检查是否为 0
if (n === 0) return 0;
// 在 JavaScript 中位运算操作数会被转换为 32 位整数
// 同样利用 n & -n 的性质
let res = n & (-n);
// Math.log2 返回以 2 为底的对数
return Math.log2(res) + 1;
}
// 测试代码
let n = 18;
console.log("第一个设置位的位置是: " + getFirstSetBit(n));
—
方法二:利用与运算和右移
这是一种更直观、更符合逻辑的暴力解法。它的思路非常简单:我们要找最右边的 1,那就一个一个地看过去。
#### 算法思路
我们可以定义一个位置计数器 pos,初始为 1。然后我们检查数字的第 1 位是不是 1。如果不是,我们将数字向右移动一位(相当于除以 2),然后把计数器加 1。重复这个过程,直到我们遇到了一个 1,或者数字变成了 0。
或者更高效一点的做法是:不移动数字 INLINECODE774c6a82,而是移动一个掩码。我们准备一个 INLINECODE39533e45。每次循环检查 INLINECODEcb7249ad 是否为 0。如果是,说明这一位不是 1,我们就把 INLINECODE2e782b52 左移一位(mask <<= 1),并将位置计数器加 1。
#### 复杂度分析
- 时间复杂度: O(log n)。因为整数 INLINECODE15a738d1 的二进制位数是 INLINECODE83d666d2。最坏情况下(比如 n 是 2 的幂,只有最高位是 1),我们需要遍历所有位。注意:在算法分析中,如果 n 是输入数值,这通常被视为 O(log n);但如果视 n 为固定的 32 位或 64 位整数,有时也被视为 O(1)。这里我们视作位数级操作。
- 空间复杂度: O(1)。
#### 代码实现
这里我们展示如何利用“移动掩码”的方式来查找。
Java 实现
public class Main {
static int getFirstSetBit(int n) {
if (n == 0) return 0;
int pos = 1;
// 使用掩码检查每一位
// 为了避免死循环,可以限制循环次数为 32 或 64(整数位数)
// 这里假设 int 是 32 位
for (int i = 0; i < 32; i++) {
// 检查第 i 位是否为 1
if ((n & (1 << i)) != 0) {
return i + 1; // 返回位置(从1开始)
}
}
return 0; // 实际上如果前面没返回,这里不应该到达,除非 n==0
}
public static void main(String[] args) {
int n = 18; // 10010
System.out.println("第一个设置位的位置是: " + getFirstSetBit(n));
}
}
C# 实现
using System;
class GFG {
static int getFirstSetBit(int n) {
// 特殊情况:如果 n 是 0,直接返回 0
if (n == 0) return 0;
int pos = 1;
// 创建一个掩码,初始值为 1 (二进制 ...0001)
int mask = 1;
// 循环检查直到找到设置位
while ((n & mask) == 0) {
// 如果当前位不是 1,将掩码左移
mask <<= 1;
pos++; // 位置计数器加 1
}
return pos;
}
static void Main() {
int n = 18;
Console.WriteLine("第一个设置位的位置是: " + getFirstSetBit(n));
}
}
—
方法三:利用左移运算符
这个方法和方法二非常相似,但是它是从一个不同的角度来实现循环的。我们不使用显式的掩码变量,而是直接利用位运算符和位置索引来进行检查。
#### 算法思路
我们初始化位置 pos = 1。
然后,在一个循环中,我们检查 INLINECODEae6b2b78 的 INLINECODE1c27dacf 位是否为 0。如果是 0,意味着还没找到第一个 1,于是我们将 pos 加 1,继续检查下一位。
具体来说,我们利用表达式 n & (1 << (pos - 1))。
- 当 INLINECODE42570a26 时,我们检查 INLINECODE00132631(第0位)。
- 当 INLINECODE4182b33f 时,我们检查 INLINECODE81a53908(第1位)。
- 以此类推。
只要 INLINECODE7c05077f 的结果是 0,就说明当前位不是 1,继续循环。一旦结果不为 0,说明找到了,循环结束,返回当前的 INLINECODE55df56d5。
#### 代码实现
Python 实现(逻辑清晰版)
def get_first_set_bit_left_shift(n):
# 边界检查
if n == 0:
return 0
pos = 1
# 当 (n 按位与 上只有第 pos 位为1的数) 等于 0 时,继续循环
# 这意味着当前的第 pos 位是 0
while (n & (1 << (pos - 1))) == 0:
pos += 1
return pos
# 测试
n = 19 # 二进制 10011
print(f"输入: {n}")
print(f"第一个设置位的位置: {get_first_set_bit_left_shift(n)}")
C++ 实现
#include
using namespace std;
int getFirstSetBit(int n) {
// 边界情况:0没有设置位
if (n == 0) return 0;
int pos = 1;
// 移动检查位直到找到 1
// (1 << (pos - 1)) 生成一个只有第 (pos-1) 位为 1 的掩码
while ((n & (1 << (pos - 1))) == 0) {
pos++;
}
return pos;
}
int main() {
int n = 12; // 二进制 1100
cout << "输入: " << n << endl;
cout << "第一个设置位的位置: " << getFirstSetBit(n) << endl; // 应该输出 3
return 0;
}
—
方法四:利用内置库函数(偷懒且高效)
在实际的软件开发中,如果你是在做业务逻辑而不是做算法题,千万不要重复造轮子。现代编程语言和标准库通常提供了高度优化的底层函数来处理位运算。
#### 算法思路
GCC 和 Clang 编译器提供了一个非常强大的内置函数 __builtin_ffs (Find First Set)。这个函数可以直接返回最右侧 1 的位置。
类似的,Java 的 Integer 类也提供了相关的位计数方法。
这种方法通常是最快的,因为它被编译成了单条 CPU 指令(如 x86 的 BSF 指令)。
#### 代码实现
C++ (使用 GCC 内置函数)
#include
// 无需引入额外头文件,GCC内置
using namespace std;
int getFirstSetBit(int n) {
if (n == 0) return 0;
// __builtin_ffs(n) 返回最右侧 1 的位置(从1开始)
// 如果 n 是 0,返回 0。这是最完美的解决方案。
return __builtin_ffs(n);
}
int main() {
int n = 18;
cout << "使用内置函数结果: " << getFirstSetBit(n) << endl;
return 0;
}
Java (利用 Integer 类)
Java 没有直接的 INLINECODEd50a61db,但我们可以利用 INLINECODE022fe692 和位计数来实现。INLINECODE7268dabd 返回 INLINECODEe88751c5 中最低位的 1 构成的整数(类似于方法一中的 INLINECODE28c21232)。然后我们需要算出它是第几位。我们可以用 INLINECODE24c6a8c5 来获取它后面有多少个 0。
class Main {
static int getFirstSetBit(int n) {
if (n == 0) return 0;
// numberOfTrailingZeros 返回二进制补码中最右边连续 0 的个数
// 如果 n = 18 (10010),连续0有1个,所以返回1。
// 位置 = 连续0个数 + 1
return Integer.numberOfTrailingZeros(n) + 1;
}
public static void main(String[] args) {
int n = 18;
System.out.println("Java 内置方法结果: " + getFirstSetBit(n));
}
}
—
总结与最佳实践
在这篇文章中,我们探讨了四种不同的方法来寻找二进制表示中最右侧设置位的位置。
- 数学技巧法 (INLINECODE245de0f5 + INLINECODEdff0449c): 代码非常简洁,适合面试中展示对补码和数学的理解。缺点是依赖浮点运算
log,在极度性能敏感的场景下可能不如纯位运算快,且对极大整数可能有精度问题。
- 右移/左移循环法: 最直观,最容易理解和手动实现。性能上是 O(k)(k 是位数),对于普通 32 位整数来说非常快,完全可接受。
- 内置库函数: 这是我们在生产环境中推荐的做法。它利用了 CPU 的特殊指令,速度最快,代码最短,可读性最好。
#### 实用见解
- 什么时候会用到这个?
* 霍纳法则与多项式计算: 在某些高效的多项式计算中,需要根据最低位决定分支。
* 二进制索引树: 在数据结构 Fenwick Tree 中,核心操作 INLINECODEe95069ec 正是利用了 INLINECODE9acf211d 来寻找父节点。
* 哈希表优化: 一些高性能哈希表算法利用低位来决定桶的位置。
- 常见错误:
* 混淆索引: 很多编程语言的位运算索引从 0 开始,但题目要求的“位置”通常从 1 开始。记得 +1!
* 忽略 0: 如果忘记处理 INLINECODE78a523db 的情况,INLINECODE2298d7aa 函数可能会报错,或者 while 循环可能会变成死循环(取决于数据类型限制)。
希望这篇文章帮助你彻底理解了位操作中的这个经典问题。下次当你遇到需要处理二进制位的场景时,你就知道该如何高效地动手了!