算法竞赛中的Lowbit

“Lowbit”在程序设计里是一个很常用的位运算概念,尤其在二叉索引树(Fenwick Tree)等数据结构中非常重要。下面我会从基础原理、计算方法、常见应用三个方面,用通俗易懂的方式给你详细讲解。


一、Lowbit 的含义与原理

  1. 概念定义
    对任意一个正整数 x,我们称它的 lowbit(也叫“最低位的 1”)为它二进制表示中最低位的那个 1 对应的值。
    换句话说,如果把 x 写成二进制,最低位的 1 出现在第 k 个位置(从右往左数,第 1 位是最低位),那么 lowbit(x) 就是 \(2^{k-1}\) (即该位置对应的权值)。

  2. 位运算公式
    最常见的计算方法是:

    lowbit(x) = x & (-x)
    

    这里的 -x 指的是对 x 取二进制补码(即先按位取反再加 1)。

    • 在二进制补码表示中,−x 就相当于把 x 的所有位取反再加 1。
    • 当你把 x-x 做按位与 (&) 时,结果正好是“x 最低位的那个 1” 保留,其它所有位都变为 0。

    举例说明:

    • x = 12,二进制是 1100

      • -x = -12 的补码表示是先将 12 的二进制 0000 1100 取反变成 1111 0011,再加 1 得到 1111 0100(即补码形式的 -12)。

      • 再做 1100 & 1111 0100,相当于:

        0000 1100   (12)
        &1111 0100   (-12 补码)
        ----------
        0000 0100   (结果 4)
        
      • 结果是 0100,也就是十进制的 4。

      • 这正是 12(二进制 1100)最低位的那个 1 所代表的值:2²=4。

    • 再看 x = 20(二进制 10100)。

      • -20 的补码过程:20 取反 01011,再加1 得到 01100(补码形式)。
      • 20 & (-20)10100 & 01100 = 00100,也就是 4。
      • 对应原数 20(10100)中从右往左第 3 位的那个 1(权值 2²=4)。
  3. 为什么能“挑出”最低位的 1?

    • 二进制补码 -x 会把 x 的最低位 1 及其右边所有位“保留”最后一个 1,在它右边位置全部变为 0。左边高位则因取反产生不相同。

    • 具体推演一下:

      • 假设 x 从右往左遇到第一个 1 的位置为第 k 位,那么它右边(第 1~k-1 位)全是 0。
      • -x 在加 1 时,会造成第 1~k-1 位由 0 变为 0(因为低位全为 0,加 1 会进到第 k 位),第 k 位本来是 1,取反后是 0,加 1 又变为 1。此时第 k 位在 -x 中是 1,而在 x 中是 1。
      • x & (-x) 对于第 k 位,两个都是 1,就保留了 1;对于比 k 位更低的位置,x 为 0,与任何位做 & 都是 0;对于第 k 位以上的位置,由于取反后两者必然至少有一个 0,与运算都得 0。
    • 所以结果就是一个仅在第 k 位上为 1,而其它位全 0 的数,也即 lowbit。


二、Lowbit 的具体计算与常见代码示例

1. 单纯计算 lowbit

最经典的 C/C++ 写法:

int lowbit(int x) {
    return x & -x;
}
  • 输入: 任意正整数 x
  • 输出: x 在二进制表示中最低位那个 1 所对应的值。

你可以在调试时多做几个测试,印证一下结果:

#include <iostream>
using namespace std;

int lowbit(int x) {
    return x & -x;
}

int main() {
    int a[] = {1, 2, 3, 4, 5, 6, 12, 20, 37};
    for (int v : a) {
        printf("lowbit(%d) = %d\n", v, lowbit(v));
    }
    return 0;
}

期望输出:

lowbit(1) = 1    // 1 的二进制 0001,最低位是第 1 位
lowbit(2) = 2    // 2 的二进制 0010,最低位是第 2 位
lowbit(3) = 1    // 3 的二进制 0011,最低位是第 1 位
lowbit(4) = 4    // 4 的二进制 0100,最低位是第 3 位
lowbit(5) = 1    // 5 的二进制 0101,最低位是第 1 位
lowbit(6) = 2    // 6 的二进制 0110,最低位是第 2 位
lowbit(12) = 4   // 12(1100) 最低位 1 在第 3 位 => 2^2 = 4
lowbit(20) = 4   // 20(10100) 最低位 1 在第 3 位 => 4
lowbit(37) = 1   // 37(100101) 最高到第 6 位,最低 1 在第 1 位 => 1

2. 在 Python 里模拟

如果你在 Python 中想要写,等价地可以用:

def lowbit(x):
    return x & -x

# 测试
for v in [1,2,3,4,5,6,12,20,37]:
    print(f"lowbit({v}) = {lowbit(v)}")

原理都是一样的,Python 里也是用补码来表示负数。


三、Lowbit 的典型应用场景

1. Fenwick Tree(二叉索引树)

Fenwick Tree(也叫 BIT,Binary Indexed Tree),是一种支持“前缀和查询”和“单点更新”都能在 \(O(\log n)\) 时间内完成的数据结构。而 lowbit 在 Fenwick Tree 的实现里至关重要,几乎贯穿于所有操作。

(1)Fenwick Tree 原理简述

  • 给定一个长度为 n 的数组 A[1..n],我们想要频繁地:

    1. 查询前缀和 sum(1..i) = A[1] + A[2] + ... + A[i]
    2. 更新某个位置 A[i] += Δ
  • 直接用线段树也能做到 \(O(\log n)\),但 Fenwick Tree 实现更简洁,常见于竞赛/培训场景。

Fenwick Tree 内部维护一个辅助数组 C[1..n],其中:

  • C[i] 存储了一段对应区间的部分和,具体来说:

    \[ C[i] = A[i - lowbit(i) + 1] + A[i - lowbit(i) + 2] + \dots + A[i] \]

    换句话说,C[i] 记录从位置 i - lowbit(i) + 1i 这一段区间的和。

(2)更新操作:add(i, Δ)

如果想把原数组中下标 i 处加上 Δ,那么我们需要:

  1. 先让 C[i] 加上 Δ;
  2. 然后让 i 往“下一个”要负责包含 i 位置的节点位置移动。
    那么“下一个”节点位置用到的就是 i += lowbit(i)
    这是因为在 Fenwick Tree 的设计里,所有「包含 i 的 C[]」的下标,都满足“下标 j 的那段区间能覆盖 i”,而 j 的遍历就是从 i 开始,不断 + lowbit(j),直到 j 超出 n 为止。

更新伪码:

void add(int i, int Δ) {
    while (i <= n) {
        C[i] += Δ;
        i += lowbit(i);
    }
}
  • 说明:一开始先给 C[i] 加上 Δ;然后把 i 移动到 “下一个”要负责的节点,直到越界。

(3)查询前缀和:prefix_sum(i)

想要查询 A[1] + A[2] + ... + A[i],可以把答案拆成若干段,就是从 i 开始,取 C[i],然后往 i 减去 lowbit(i) 的方向移动,一直减到 0 即可。
伪码如下:

int prefix_sum(int i) {
    int s = 0;
    while (i > 0) {
        s += C[i];
        i -= lowbit(i);
    }
    return s;
}
  • 这里的思路类似贪心:C[i] 存的正好是从 i - lowbit(i) + 1i 的那段和,减去这段后把 i 跳到 i - lowbit(i),再继续拆更低位,直到 i 变到 0。

(4)Fenwick Tree 整体代码

以下给出一个完整版(C++)示例,帮助你理清思路:

#include <bits/stdc++.h>
using namespace std;

struct FenwickTree {
    int n;
    vector<long long> C;  // 用 long long 以防累加时溢出

    FenwickTree(int _n) : n(_n), C(_n+1, 0) {}

    // lowbit 函数
    int lowbit(int x) {
        return x & -x;
    }

    // 单点更新:把 A[i] += delta
    void add(int i, long long delta) {
        // i 从 1 开始到 n
        while (i <= n) {
            C[i] += delta;
            i += lowbit(i);
        }
    }

    // 查询前缀和:sum A[1..i]
    long long prefix_sum(int i) {
        long long s = 0;
        while (i > 0) {
            s += C[i];
            i -= lowbit(i);
        }
        return s;
    }

    // 查询区间和:sum A[l..r]
    long long range_sum(int l, int r) {
        return prefix_sum(r) - prefix_sum(l-1);
    }
};

int main() {
    int n = 10;  // 假设长度为 10
    FenwickTree tree(n);

    // 初始化数组 A[1..n],先全部为 0
    // 如果要一开始就批量赋值,可以用 add 操作逐个加

    // 举例:给 A[3] 加上 5
    tree.add(3, 5);

    // 给 A[7] 加上 2
    tree.add(7, 2);

    // 查询 A[1..8] 的前缀和
    cout << "sum(1..8) = " << tree.prefix_sum(8) << endl;

    // 查询 A[3..7] 的区间和
    cout << "sum(3..7) = " << tree.range_sum(3, 7) << endl;

    return 0;
}
  • 运行思路:

    1. tree.add(3,5):会依次修改 C[3], C[4], C[8] 等位置;
    2. tree.add(7,2):修改 C[7], C[8] 等位置;
    3. prefix_sum(8):相当于取 C[8] + C[0],其中 C[8] 已经包涵了之前对 3、7 的更新,正好符合前缀和逻辑。

2. 其他位运算场景

除了 Fenwick Tree 之外,lowbit(即 x & -x)在竞赛/位运算中也常用于多种场景,下面罗列几个典型用途:

  1. 快速判断一个数是否为 2 的幂

    • 如果 x 是 2 的幂,那么它的二进制表示只有一位是 1,其余全是 0。那么 lowbit(x) 就等于 x 本身。
    • 因此,可以用 lowbit(x) == x 来判断 “x 是否为 2 的幂”。
    bool isPowerOfTwo(int x) {
        if (x <= 0) return false;
        return (x & -x) == x;
    }
    
  2. 把二进制表示中最低位的 1 “去掉”

    • 如果你想把一个数 x 的二进制表示里的最低位那个 1 变成 0,常见写法是:

      x = x - lowbit(x);
      

      或更“位运算”风格地:

      x = x & (x - 1);
      
    • 这样做在统计二进制中 1 的个数时非常高效(每次把最右侧的 1 “消去”),时间复杂度是 \(O(\text{popcount}(x))\),一般比逐位左移判断快。

    • 例如,要统计 x 中一共有多少个 1,可以写:

      int countOnes(int x) {
          int cnt = 0;
          while (x) {
              x &= (x - 1);  // 消去最低位的 1
              cnt++;
          }
          return cnt;
      }
      
  3. 枚举子集 / 组合数学

    • 在需要对二进制掩码、子集中 “从小到大枚举” 的场合,常常会用到 lowbit。例如,给定一个掩码 mask,想要找到它所有非空子集,可以用如下模板:

      for (int sub = mask; sub; sub = (sub - 1) & mask) {
          // 这里 sub 会遍历 mask 的所有子集(从 mask 本身到 0 依次减)
      }
      
    • 这里用到的是 (sub - 1) & mask,而 sub - 1 的操作本身也会自动消去最低位的 1,配合 &mask,就能保证子集不越界。

    • 如果只是想快速取出子集中 “当前子集中末尾最低位那一位”,也可写 low = sub & -sub 来快速定位。

  4. 某些 DP 状态压缩技巧

    • 在状态压缩 DP 中,如果状态用“位掩码”来表示,比如有 n 个开关,每个状态用一个 mask0~(1<<n)-1)表示开关的开/关情况。我们想要快速从某个状态 mask 中取出最低位开的那个开关位置 pos,可以做:

      int lb = mask & -mask;           // 得到最低位的值,例如 001000 表示第 4 位
      int pos = __builtin_ctz(lb);    // GCC/Clang 提供的内建函数,返回 lb 最低位 1 的索引(从 0 开始)
      
    • 这样就不用写循环去从最低位往上找了,效率更高。


四、一步步图示理解(以 12 为例)

假设我们要理解 lowbit(12) 的整个计算过程,按二进制画出来会更清晰:

x =  12 (十进制)
   = 1100 (二进制)
  1. 先写出 x 的二进制:0000 1100(我们假设用 8 位表示,左边补 0)。

  2. 计算 -x(补码表示):

    • x 的原码(8 位) = 0000 1100
    • 取反 (~x) = 1111 0011
    • 加 1 = 1111 0100 (这就是 -12 的补码)
  3. x & (-x) 即:

    0000 1100  (12)
AND 1111 0100  (-12)
  = 0000 0100  (= 4)
  • 结果只有第 3 位是 1,其它全为 0。也就是你在原数 12(1100) 中最低那位 1 对应的值。

五、为什么要学习 Lowbit?

  1. 理解底层位运算的奥妙

    • x & -x 之所以能“挑出最低那位 1”,是因为二进制补码里负数的表示原理。掌握它能够让你对位运算的本质更明晰,也能在需要高性能、常见竞赛里迅速写出常见技巧。
  2. Fenwick Tree 及其相关数据结构

    • 在竞赛或需要高效处理区间前缀和/区间更新时,Fenwick Tree 是一个非常简洁且高效的方案。lowbit 是 Fenwick Tree 的灵魂,一旦理解了 lowbit,就能轻松驾驭这类题目。
  3. 位运算技巧在算法竞赛中非常常见

    • 比如要高效枚举二进制子集、统计二进制中 1 的个数、判断 2 的幂、甚至在数论/组合数学题中,lowbit 能派上大用场。
    • 有时候面试题会考察你对“低位掩码”技巧的熟悉度,比如“快速删除二进制最低位的 1”,“计算某数最低位 1 的位置”等等。

六、举例题目:Fenwick Tree 基础操作

题目示例:
你有一个长度为 10 的数组 A[1..10],初始都为 0。

  1. 对第 3 个元素加 5;
  2. 对第 7 个元素加 2;
  3. 求前缀和 sum(1..8)
  4. 求区间和 sum(3..7)

请写出 Fenwick Tree 的实现,并回答上述查询结果是多少。

解:

  • 初始 A[i] = 0,辅助数组 C[i] 也全 0。

  • 操作 1:add(3,5)

    • i=3 开始:

      • 更新 C[3] += 5
      • 然后 3 + lowbit(3) = 3 + 1 = 4,更新 C[4] += 5
      • 4 + lowbit(4) = 4 + 4 = 8,更新 C[8] += 5
      • 8 + lowbit(8) = 8 + 8 = 16,超过 10,停止。
    • 此时 C 数组变化(只列第 1~10 位):

      C = [0, 0, 0, 5, 5, 0, 0, 5, 0, 0]
           i:1 2 3 4 5 6 7 8 9 10
      
  • 操作 2:add(7,2)

    • i=7 开始:

      • C[7] += 2
      • 7 + lowbit(7) = 7 + 1 = 8C[8] += 2
      • 8 + lowbit(8) = 8 + 8 = 16,越界结束。
    • 更新完毕后 C 变成:

      C = [0, 0, 0, 5, 5, 0, 0, 2, 7, 0, 0]
           i:1 2 3 4 5 6 7 8 9 10
      

      其中 C[8] 原先是 5,现在加 2 变成 7。

  • 操作 3:查询 prefix_sum(8)

    • i = 8 开始:

      • res += C[8] = 7
      • 然后 i = 8 - lowbit(8) = 8 - 8 = 0,终止。
    • 所以前缀和 sum(1..8) = 7

  • 操作 4:查询 range_sum(3,7) = prefix_sum(7) - prefix_sum(2)

    • 先算 prefix_sum(7)

      • i=7res = C[7] = 2
      • i = 7 - lowbit(7) = 7 - 1 = 6
      • i=6res += C[6] = 0(因为 C[6] 一直没变)
      • i = 6 - lowbit(6) = 6 - 2 = 4
      • i=4res += C[4] = 5,此时 res = 2 + 5 = 7
      • i = 4 - lowbit(4) = 4 - 4 = 0,终止。
      • 所以 prefix_sum(7) = 7
    • 再算 prefix_sum(2)

      • i=2res = C[2] = 0
      • i = 2 - lowbit(2) = 2 - 2 = 0,终止。
      • 所以 prefix_sum(2) = 0
    • 因此 range_sum(3,7) = 7 - 0 = 7

总结:

  • sum(1..8) = 7
  • sum(3..7) = 7

—— 与我们直觉一致,只有对索引 3 和 7 做了更新,分别加了 5 和 2,因此 A = [0,0,5,0,0,0,2,0,0,0],前 8 项和正好是 7;第 3~7 项也只包含了 A[3]=5 和 A[7]=2,共 7。


七、小结

  1. lowbit(x) = x & (-x),它可以“挑出” x 二进制表示中最右边的那个 1 对应的值。

  2. lowbit 的原理源自“二进制补码”对负数的表示方式。

  3. 在 Fenwick Tree(Binary Indexed Tree)中,用 lowbit 轻松实现「单点更新」和「前缀和查询」,使得两种操作都可以 \(O(\log n)\) 完成。

  4. 其他场景中,lowbit 常用于:

    • 判断是否为 2 的幂;
    • 从一个数中“去掉”最低位的 1;
    • 快速枚举二进制掩码的子集;
    • 状态压缩 DP 中快速定位最低位 1 的位置;
    • 以及各种需要对二进制尾部 1 进行操作的算法技巧。

掌握 lowbit,不仅能让你写出简洁高效的 Fenwick Tree,还能在竞赛中处理各类位运算题目时更加得心应手。希望这番讲解能帮你彻底搞懂 “lowbit 能干什么”,从原理到代码到应用都有所了解。如果你还有什么进一步的疑问,或者想看更多例题演示,随时欢迎继续交流!

posted @ 2025-06-11 16:31  Thin_time  阅读(170)  评论(0)    收藏  举报