位元運算 — AND、OR、XOR 六大運算子與 Bitmask 技巧 | 資料結構與演算法

2026/07/07
位元運算 — AND、OR、XOR 六大運算子與 Bitmask 技巧 | 資料結構與演算法

位元運算(Bit Manipulation) 是直接對整數的二進位(Binary)表示進行操作的技術,以硬體層級的單一 CPU 指令實現 O(1) 的集合、邏輯與數值運算。從 AND/OR/XOR 六大基礎運算子,到 lowbitpopcountBitmask 狀態壓縮,位元操作是底層系統設計、面試題破解與動態規劃優化的核心工具。

前言

想像一個電燈板:每個燈泡不是開(1)就是關(0)。你想一次控制多個燈泡的狀態——不需要一個個檢查,只需要對整組燈泡做一次「遮罩(mask)」操作就能批次控制。這正是位元運算的直覺:用一個整數的每個位元代表一個開關狀態,讓 CPU 在一條指令內同時處理 32 或 64 個開關。

位元運算之所以重要,在於它能將複數步驟的邏輯壓縮到 O(1) 的硬體指令。判斷 2 的冪不需要迴圈,一句 x & (x - 1) === 0 搞定;計算二進位中 1 的個數不需要逐位掃描,Brian Kernighan 算法以 O(popcount) 次完成;列舉所有子集只需從 0 數到 (1 << n) - 1,每個整數的位元模式直接對應一個子集。

讀完本文,你將能夠:

  • 掌握六大位元運算子(AND、OR、XOR、NOT、左移、右移)的使用時機
  • 理解 二補數(Two’s Complement) 與 JavaScript 的 32 位整數限制
  • 熟練使用 lowbitpopcount(Brian Kernighan)子集列舉 等核心技巧
  • Bitmask 應用於狀態壓縮、Feature Flag 系統與 Linux 權限管理
  • 解決 LeetCode 136、191、268、371、78 等五道經典位元操作題目

核心概念

六大位元運算子

位元運算直接操作整數的二進位表示,以下是六大運算子的符號、用途與範例:

運算子符號核心用途範例(8-bit)
AND&遮罩取位、同時為 1 才得 10110 & 1010 = 0010
OR|設位、任一為 1 即得 10110 | 1010 = 1110
XOR^切換、不同為 1 相同為 00110 ^ 1010 = 1100
NOT~所有位元取反(含符號位)~0110 = ...11111001(負數!)
左移<<所有位元向左移 n 位(乘以 2ⁿ)0011 << 2 = 1100
右移>>所有位元向右移 n 位(除以 2ⁿ)1100 >> 2 = 0011

JavaScript 特別注意:

  • >>算術右移(有號右移,保留符號位,負數高位補 1)
  • >>>邏輯右移(無號右移,永遠補 0)
  • ~ 的結果在 JS 中是 -(n + 1),例如 ~5 === -6
  • 所有位元運算會先將運算元轉為 32 位有號整數,結果超出範圍會溢位

二補數與負數表示

JavaScript 使用二補數(Two’s Complement) 表示有號整數,這是 lowbit 技巧成立的數學基礎:

正數 12  的二進位:0000 1100
-12 的二補數表示:1111 0100  (全部取反再加 1)

12 & (-12):
  0000 1100
& 1111 0100
= 0000 0100  (= 4,即最低位的 1)

為什麼 x & (-x) 能取出最低位的 1?

-x 的二補數表示,恰好在 x 最低位的 1 位置上也是 1,而該位以上的所有位元都是 x 的取反。AND 之後,只有最低位的 1 被保留下來。

位元操作視覺化(n = 106 = 0b01101010)

n  = 01101010  (106)

━━━━━━━━━━ 基本運算 ━━━━━━━━━━

AND(取低4位,mask = 0x0F):
  01101010
& 00001111
= 00001010  (10)  ← 只保留低 4 位

OR(設第4位):
  01101010
| 00010000
= 01111010  (122) ← 第 4 位被設為 1

XOR(反轉所有位):
  01101010
^ 11111111
= 10010101  (149) ← 等同 ~n + 256(8-bit 範圍內)

左移2位(× 4):
  01101010 → 10101000  (168,溢位位元被丟棄)

右移2位(÷ 4):
  01101010 → 00011010  (26)

━━━━━━━━━━ 核心技巧視覺化 ━━━━━━━━━━

取最低位的 1(Lowbit = x & (-x)):
  n        = 01101010
  -n       = 10010110  (二補數)
  n & (-n) = 00000010  (= 2,只剩最低位的 1)

清除最低位的 1(x & (x - 1)):
  n      = 01101010
  n-1    = 01101001
  n&(n-1)= 01101000  (= 104,最低位 1 被清除)

XOR 四大核心性質

XOR(互斥或) 是位元操作中最多技巧的運算子,其四大性質是許多演算法的基石:

a ^ a = 0          (自身消除:相同的數 XOR 後得 0)
a ^ 0 = a          (恆等元素:任何數 XOR 0 得自身)
a ^ b = b ^ a      (交換律:順序不影響結果)
a ^ b ^ a = b      (消除律:可用於找唯一出現的數)

XOR 消除律的直觀演示(LeetCode 136 原理):

陣列 [4, 1, 2, 1, 2] 找唯一出現一次的數:
  0 ^ 4 = 00000100
        ^ 1 = 00000101
        ^ 2 = 00000111
        ^ 1 = 00000110   ← 第二個 1 消除了第一個 1
        ^ 2 = 00000100   ← 第二個 2 消除了第一個 2
  結果 = 4 ✓ (1 和 2 各自抵消,只剩唯一出現的 4)

常用技巧速查表

技巧表達式說明
取最低位的 1(Lowbit)x & (-x)Fenwick Tree 核心
清除最低位的 1x & (x - 1)popcount、判斷 2 的冪
判斷是否為 2 的冪x > 0 && (x & (x-1)) === 02 的冪只有一個位元為 1
取第 k 位(0-indexed)(x >> k) & 1回傳 0 或 1
設第 k 位為 1x | (1 << k)置位(Set bit)
清第 k 位為 0x & ~(1 << k)清位(Clear bit)
切換第 k 位x ^ (1 << k)Toggle
判斷奇偶x & 11 為奇,0 為偶
XOR 交換兩數a^=b; b^=a; a^=b無需暫存變數(同位置有陷阱!)

TypeScript / JavaScript 實作

Lowbit 與 Popcount(Brian Kernighan 算法)

Lowbit 取出整數最低位的 1,是 Fenwick Tree(Binary Indexed Tree)的核心操作。Brian Kernighan 算法 利用 x & (x - 1) 每次清除最低位的 1,迭代次數等於 1 的個數(popcount),因此效率優於逐位掃描。

// ━━ Lowbit:取出 x 最低位的 1 ━━
// x & (-x) = x & (~x + 1)(二補數定義)
function lowbit(x: number): number {
  return x & (-x);
}
// lowbit(12) = lowbit(0b1100) = 0b0100 = 4
// lowbit(6)  = lowbit(0b0110) = 0b0010 = 2
// lowbit(8)  = lowbit(0b1000) = 0b1000 = 8

// ━━ Brian Kernighan 算法:計算二進位中 1 的個數 ━━
// 時間 O(popcount(n)),比逐位掃描的 O(32) 更快
function popcount(n: number): number {
  let count = 0;
  while (n > 0) {
    n &= n - 1; // 每次清除最低位的 1
    count++;
  }
  return count;
}
// popcount(106) = popcount(0b01101010)
// 0b01101010 → 0b01101000 → 0b01100000 → 0b01000000 → 0b00000000
// 共 4 次 → count = 4

// ━━ 查表法 popcount(O(1) — 實際競程常用) ━━
const popcount256: number[] = new Array(256).fill(0);
for (let i = 1; i < 256; i++) {
  popcount256[i] = popcount256[i >> 1] + (i & 1);
}

function popcountFast(n: number): number {
  // 32 位整數分 4 個 byte 分別查表
  return popcount256[n & 0xFF] +
         popcount256[(n >> 8) & 0xFF] +
         popcount256[(n >> 16) & 0xFF] +
         popcount256[(n >> 24) & 0xFF];
}

// ━━ LeetCode 191:Number of 1 Bits ━━
function hammingWeight(n: number): number {
  let count = 0;
  while (n !== 0) {
    n &= n - 1; // Brian Kernighan
    count++;
  }
  return count;
}

// ━━ LeetCode 338:Counting Bits — O(n) DP ━━
// dp[i] = i 的 popcount,利用 i >> 1 已知的子問題遞推
function countBits(n: number): number[] {
  const dp = new Array<number>(n + 1).fill(0);
  for (let i = 1; i <= n; i++) {
    // i >> 1 去掉最低位;i & 1 最低位的貢獻
    dp[i] = dp[i >> 1] + (i & 1);
  }
  return dp;
}
// countBits(5) → [0, 1, 1, 2, 1, 2]

// 另一種遞推(利用 x & (x-1) 清除最低位)
function countBitsV2(n: number): number[] {
  const dp = new Array<number>(n + 1).fill(0);
  for (let i = 1; i <= n; i++) {
    dp[i] = dp[i & (i - 1)] + 1; // dp[i 去掉最低位的1] + 1
  }
  return dp;
}

子集列舉(Bitmask Enumeration)

Bitmask 列舉 的核心思想:n 個元素的所有子集恰好對應 0(1 << n) - 1 的所有整數,每個整數的第 i 位代表第 i 個元素是否在子集中。

// ━━ 列舉 n 個元素的所有 2ⁿ 個子集 ━━
function enumerateSubsets(elements: number[]): void {
  const n = elements.length;
  const total = 1 << n; // 2^n 個子集

  for (let mask = 0; mask < total; mask++) {
    const subset: number[] = [];
    for (let i = 0; i < n; i++) {
      if ((mask >> i) & 1) { // 第 i 位是否為 1
        subset.push(elements[i]);
      }
    }
    console.log(`mask=${mask.toString(2).padStart(n, '0')}: [${subset}]`);
  }
}

// enumerateSubsets([1, 2, 3]):
// mask=000: []        mask=001: [1]     mask=010: [2]
// mask=011: [1,2]     mask=100: [3]     mask=101: [1,3]
// mask=110: [2,3]     mask=111: [1,2,3]

// ━━ 列舉 mask 的所有子集(子集 DP 關鍵操作) ━━
// 技巧:sub = (sub - 1) & mask,每次去掉 sub 中最低位的 1
// 時間:對所有 mask 枚舉子集,總計 O(3ⁿ)(三進位分析)
function enumerateSubsetsOfMask(mask: number): number[] {
  const subsets: number[] = [];
  for (let sub = mask; sub > 0; sub = (sub - 1) & mask) {
    subsets.push(sub);
    // 注意:sub = 0 時 (sub - 1) & mask = (-1) & mask = mask,會無限循環
    // 所以用 sub > 0 作終止條件,最後手動加入空集
  }
  subsets.push(0); // 加入空集 {}
  return subsets;
}
// mask = 0b110 → [6(110), 4(100), 2(010), 0(000)]

// ━━ LeetCode 78:Subsets(Bitmask 列舉法) ━━
function subsets(nums: number[]): number[][] {
  const n = nums.length;
  const result: number[][] = [];

  for (let mask = 0; mask < (1 << n); mask++) {
    const subset: number[] = [];
    for (let i = 0; i < n; i++) {
      if ((mask >> i) & 1) {
        subset.push(nums[i]);
      }
    }
    result.push(subset);
  }

  return result;
}
// subsets([1,2,3]) → [[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]
// 順序對應 mask 的二進位:000, 001, 010, 011, 100, 101, 110, 111

console.log(subsets([1, 2, 3]));
// 輸出:[[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]

XOR 技巧集錦

// ━━ LeetCode 136:Single Number — 找唯一不重複的數 ━━
// 思路:所有出現兩次的數 XOR 後為 0,最終結果就是唯一的數
function singleNumber(nums: number[]): number {
  return nums.reduce((xor, n) => xor ^ n, 0);
}
// [4,1,2,1,2] → 0^4^1^2^1^2 = (1^1)^(2^2)^4 = 0^0^4 = 4
console.log(singleNumber([4, 1, 2, 1, 2])); // 輸出:4
console.log(singleNumber([2, 2, 1]));        // 輸出:1

// ━━ LeetCode 268:Missing Number — 找 [0, n] 中缺失的數 ━━
// 思路:將 [0, n] 與陣列全部 XOR,重複的數消除,剩下的就是缺失的數
function missingNumber(nums: number[]): number {
  let xor = nums.length; // 從 n 開始([0, n] 包含 n)
  for (let i = 0; i < nums.length; i++) {
    xor ^= i ^ nums[i]; // i 和 nums[i] 分別 XOR
  }
  return xor;
}
// [3,0,1] → xor = 3 ^ (0^3) ^ (1^0) ^ (2^1) = 2
console.log(missingNumber([3, 0, 1])); // 輸出:2
console.log(missingNumber([9,6,4,2,3,5,7,0,1])); // 輸出:8

// ━━ LeetCode 371:Sum of Two Integers(不用 + 運算子)━━
// 思路:XOR 計算不進位的和,AND 左移計算進位,重複直到無進位
function getSum(a: number, b: number): number {
  while (b !== 0) {
    const carry = (a & b) << 1; // 進位(每個有進位的位元左移一位)
    a = a ^ b;                   // 不帶進位的和
    b = carry;
  }
  return a;
}
// getSum(3, 5):
// iter1: carry=(011 & 101)<<1 = 010, a = 011^101 = 110, b = 010
// iter2: carry=(110 & 010)<<1 = 100, a = 110^010 = 100, b = 100
// iter3: carry=(100 & 100)<<1 =1000, a = 100^100 = 000, b =1000
// iter4: carry=(000 &1000)<<1 =   0, a = 000^1000= 1000, b = 0 → return 8
console.log(getSum(3, 5));   // 輸出:8
console.log(getSum(-3, -5)); // 輸出:-8

// ━━ 判斷是否為 2 的冪(LeetCode 231)━━
// 2 的冪的二進位表示只有一個位元為 1
// 例:8 = 1000,8-1 = 0111,8 & 7 = 0000
function isPowerOfTwo(n: number): boolean {
  return n > 0 && (n & (n - 1)) === 0;
}
console.log(isPowerOfTwo(16)); // 輸出:true  (10000 & 01111 = 00000)
console.log(isPowerOfTwo(18)); // 輸出:false (10010 & 10001 ≠ 0)

// ━━ XOR Swap(不用暫存變數交換兩數)━━
function xorSwap(arr: number[], i: number, j: number): void {
  if (i === j) return; // 重要!相同位置 XOR 會清零
  arr[i] ^= arr[j];
  arr[j] ^= arr[i]; // arr[j] = arr[j] ^ (原 arr[i] ^ arr[j]) = 原 arr[i]
  arr[i] ^= arr[j]; // arr[i] = (原 arr[i] ^ arr[j]) ^ 原 arr[i] = 原 arr[j]
}

Bitmask 實際應用 — Feature Flag 系統

位元遮罩在現實系統中的最常見應用之一,是以單一整數壓縮多個開關狀態,讓比較、儲存、傳輸都極為高效:

// 使用 Bitmask 管理功能開關,支援組合查詢
const Features = {
  DARK_MODE:     1 << 0,  // 0000 0001
  NOTIFICATIONS: 1 << 1,  // 0000 0010
  ANALYTICS:     1 << 2,  // 0000 0100
  BETA_FEATURES: 1 << 3,  // 0000 1000
  PREMIUM:       1 << 4,  // 0001 0000
  AI_ASSISTANT:  1 << 5,  // 0010 0000
} as const;

type FeatureFlag = typeof Features[keyof typeof Features];

class FeatureFlagService {
  private userFlags: Map<string, number> = new Map();

  // 啟用功能:OR 置位
  enableFeature(userId: string, feature: FeatureFlag): void {
    const current = this.userFlags.get(userId) ?? 0;
    this.userFlags.set(userId, current | feature);
  }

  // 停用功能:AND NOT 清位
  disableFeature(userId: string, feature: FeatureFlag): void {
    const current = this.userFlags.get(userId) ?? 0;
    this.userFlags.set(userId, current & ~feature);
  }

  // 切換功能:XOR Toggle
  toggleFeature(userId: string, feature: FeatureFlag): void {
    const current = this.userFlags.get(userId) ?? 0;
    this.userFlags.set(userId, current ^ feature);
  }

  // 檢查是否啟用(支援組合查詢:是否同時啟用多項功能)
  hasFeature(userId: string, feature: number): boolean {
    const flags = this.userFlags.get(userId) ?? 0;
    return (flags & feature) === feature; // 必須全部啟用才回傳 true
  }

  // 取得所有已啟用的功能名稱
  getEnabledFeatures(userId: string): string[] {
    const flags = this.userFlags.get(userId) ?? 0;
    return Object.entries(Features)
      .filter(([, value]) => (flags & value) !== 0)
      .map(([key]) => key);
  }
}

// 使用範例
const service = new FeatureFlagService();
service.enableFeature("user1", Features.DARK_MODE);
service.enableFeature("user1", Features.NOTIFICATIONS);
service.enableFeature("user1", Features.PREMIUM);
// user1 的 flags = 0b010011 = 19

console.log(service.hasFeature("user1", Features.DARK_MODE));  // 輸出:true
console.log(service.hasFeature("user1", Features.ANALYTICS));  // 輸出:false

// 組合查詢:是否同時啟用 PREMIUM 和 AI_ASSISTANT
const PREMIUM_AI = Features.PREMIUM | Features.AI_ASSISTANT;
console.log(service.hasFeature("user1", PREMIUM_AI)); // 輸出:false(未啟用 AI)

console.log(service.getEnabledFeatures("user1"));
// 輸出:["DARK_MODE", "NOTIFICATIONS", "PREMIUM"]

JavaScript 32 位限制與 BigInt

// ━━ 陷阱:JavaScript 位元運算的 32-bit 限制 ━━
console.log(1 << 31);  // 輸出:-2147483648(符號位被設定!)
console.log(1 << 32);  // 輸出:1(移位量對 32 取模,等同 1 << 0)

// 正確:使用 >>> 確保邏輯右移(補 0 而非符號位)
function reverseBits(n: number): number {
  let result = 0;
  for (let i = 0; i < 32; i++) {
    result = (result << 1) | (n & 1); // 取 n 最低位加入 result
    n >>>= 1; // 邏輯右移(確保補 0)
  }
  return result >>> 0; // 確保回傳無號整數的正確表示
}

// BigInt 用於超過 32 位的場景(子集枚舉 n > 30 時)
function enumerateSubsetsSafe(n: number): void {
  if (n > 30) {
    const total = BigInt(1) << BigInt(n);
    for (let mask = BigInt(0); mask < total; mask++) {
      // 使用 BigInt 操作(注意:BigInt 不能和 number 混用)
    }
  }
}

// NOT 運算的陷阱:~ 在 JS 中是負數
const x = 5; // 0b00000101
console.log(~x); // 輸出:-6(不是 250!)
// 原因:~5 = -(5+1) = -6(32 位有號整數取補數)

// 若要取 8-bit 的 NOT 結果:
const not8bit = (~x) & 0xFF;
console.log(not8bit); // 輸出:250 = 0b11111010 ✓

C++ 對照實作

C++ 提供了多項位元操作的內建函式(GCC/Clang Builtins),在競程和底層開發中大量使用:

#include <vector>
#include <bitset>
#include <numeric>
#include <iostream>

// ━━━━━━━━━━ GCC/Clang 內建位元函式 ━━━━━━━━━━

void builtinDemo() {
    int n = 0b01101010; // 106

    // 計算 1 的個數(popcount)
    int ones = __builtin_popcount(n);       // 4(int 版本)
    int ones_ll = __builtin_popcountll(n);  // 4(long long 版本)

    // 計算末尾 0 的個數(Count Trailing Zeros)
    // lowbit(n) 的位置 = __builtin_ctz(n)
    int ctz = __builtin_ctz(n);             // 1(最低位 1 之前有 1 個 0)

    // 計算開頭 0 的個數(Count Leading Zeros),int 為 32 位
    int clz = __builtin_clz(n);             // 24(106 = 0x6A,高 24 位為 0)

    // 計算最高位 1 的位置(0-indexed from LSB)
    int highest = 31 - __builtin_clz(n);    // 6(第 6 位是最高的 1)

    std::cout << "popcount: " << ones << "\n";   // 輸出:4
    std::cout << "ctz: " << ctz << "\n";         // 輸出:1
    std::cout << "highest bit: " << highest << "\n"; // 輸出:6
}

// ━━━━━━━━━━ std::bitset(固定大小的位元陣列)━━━━━━━━━━

void bitsetDemo() {
    std::bitset<8> bs1("01101010"); // 從字串初始化
    std::bitset<8> bs2(0b10110100);

    // 基本操作
    bs1.set(0);   // 設第 0 位為 1
    bs1.reset(1); // 清第 1 位為 0
    bs1.flip(2);  // 切換第 2 位

    // 統計
    std::cout << bs1.count() << "\n"; // 輸出 1 的個數
    std::cout << bs1.size() << "\n";  // 輸出總位元數 = 8

    // 集合操作
    std::bitset<8> andResult = bs1 & bs2;
    std::bitset<8> orResult  = bs1 | bs2;
    std::bitset<8> xorResult = bs1 ^ bs2;

    // 效能優勢:bitset<1000000> 操作以 64 位 word 批次處理
    // 每次相當於處理 64 位 → 速度是逐位處理的 64 倍
    std::bitset<1000000> largeBitset;
    largeBitset.set();           // 全部設 1,O(n/64)
    std::cout << largeBitset.count() << "\n"; // 輸出:1000000
}

// ━━━━━━━━━━ 核心技巧(C++ 版)━━━━━━━━━━

class BitTricks {
public:
    // 判斷是否為 2 的冪
    bool isPowerOf2(int x) {
        return x > 0 && (x & (x - 1)) == 0;
    }

    // Brian Kernighan popcount
    int popcount(int n) {
        int count = 0;
        while (n) {
            n &= n - 1;
            count++;
        }
        return count;
    }

    // Single Number(LeetCode 136)
    int singleNumber(std::vector<int>& nums) {
        return std::reduce(nums.begin(), nums.end(), 0, std::bit_xor<int>());
    }

    // Sum of Two Integers(LeetCode 371)
    // 注意:有號整數左移可能是未定義行為(UB),轉型為 unsigned 避免
    int getSum(int a, int b) {
        while (b != 0) {
            unsigned carry = (unsigned(a) & unsigned(b)) << 1;
            a = a ^ b;
            b = static_cast<int>(carry);
        }
        return a;
    }

    // 枚舉 mask 的所有子集(子集 DP 關鍵)
    void enumerateSubsets(int mask) {
        for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {
            process(sub);
        }
        process(0); // 空集
    }

private:
    void process(int sub) {
        // 處理子集 sub 的邏輯
    }
};

// ━━━━━━━━━━ LeetCode 338 Counting Bits(C++ 版)━━━━━━━━━━

std::vector<int> countBits(int n) {
    std::vector<int> dp(n + 1, 0);
    for (int i = 1; i <= n; i++) {
        dp[i] = dp[i >> 1] + (i & 1);
    }
    return dp;
}

複雜度分析表

操作時間空間說明
基本位元運算(&, |, ^, ~, «, »)O(1)O(1)單一 CPU 指令
取最低位 1(Lowbit:x & (-x)O(1)O(1)二補數性質
清除最低位 1(x & (x-1)O(1)O(1)連帶用於判斷 2 的冪
計算 popcount(Brian Kernighan)O(popcount(n))O(1)最多 32 次,等效 O(1)
計算 popcount(查表法)O(1)O(256)預建 256 項表
__builtin_popcount(C++)O(1)O(1)單一硬體指令(POPCNT)
列舉 n 個元素的所有子集O(2ⁿ)O(1)mask 從 0 到 (1«n)-1
列舉 mask 的所有子集O(2^popcount(mask))O(1)子集 DP 核心
對所有 mask 枚舉子集(子集 DP)O(3ⁿ)三進位分析(分配每位 0/1/不選)
std::bitset 操作(n 位)O(n/64)O(n/8)SIMD 加速,以 64 位 word 批次

O(3ⁿ) 的直覺: 對 n 個元素的每個位元,在「子集枚舉子集」中有三種可能:完全不在 mask 中(0 種子集)、在 mask 中但不在 sub 中、在 mask 和 sub 中都有。因此總工作量是 3ⁿ。


變體與延伸

Bitmask DP 回顧

Bitmask 狀態壓縮 DP 將集合狀態壓縮為整數,讓 DP 能在指數級狀態空間中高效搜尋。典型問題是旅行商問題(TSP)

// dp[mask][i] = 訪問了 mask 中所有城市、目前在城市 i 的最短路徑
// mask 的每個位元代表對應城市是否已訪問
function tsp(dist: number[][]): number {
  const n = dist.length;
  const INF = Infinity;
  const FULL = (1 << n) - 1; // 所有城市都訪問的狀態

  const dp: number[][] = Array.from({ length: 1 << n }, () =>
    new Array(n).fill(INF)
  );
  dp[1][0] = 0; // 從城市 0 出發,mask = 0b0001

  for (let mask = 1; mask <= FULL; mask++) {
    for (let u = 0; u < n; u++) {
      if (dp[mask][u] === INF) continue;
      if (!((mask >> u) & 1)) continue; // 城市 u 必須在 mask 中

      // 嘗試前往未訪問的城市 v
      for (let v = 0; v < n; v++) {
        if ((mask >> v) & 1) continue; // 城市 v 已訪問,跳過
        const newMask = mask | (1 << v);
        dp[newMask][v] = Math.min(dp[newMask][v], dp[mask][u] + dist[u][v]);
      }
    }
  }

  // 找回到起點(城市 0)的最短總路徑
  let ans = INF;
  for (let u = 1; u < n; u++) {
    if (dp[FULL][u] !== INF) {
      ans = Math.min(ans, dp[FULL][u] + dist[u][0]);
    }
  }
  return ans;
}

Bitmask DP 的使用時機:

  • 問題涉及「選擇一個集合的子集」
  • 集合大小 n ≤ 20(2²⁰ = 1,048,576 個狀態,可接受)
  • 子問題之間有重疊,需要記憶化

XOR 技巧進階

XOR 在密碼學、資料結構和數學競賽中有豐富的應用:

// ━━ 找兩個不重複的數(LeetCode 260)━━
// 擴展自 Single Number,利用 lowbit 將陣列分兩組
function singleNumberIII(nums: number[]): number[] {
  const xorAll = nums.reduce((acc, n) => acc ^ n, 0); // a ^ b
  const bit = xorAll & (-xorAll); // a 和 b 在此位元上不同的 lowbit
  let a = 0;
  for (const n of nums) {
    if (n & bit) a ^= n; // 此位元為 1 的一組,XOR 得到 a
  }
  return [a, xorAll ^ a]; // 另一個數 = a ^ (a ^ b) = b
}

// ━━ 格雷碼(Gray Code)生成 ━━
// 相鄰格雷碼只差 1 個位元,應用於旋轉編碼器、錯誤更正碼
function grayCode(n: number): number[] {
  const result: number[] = [];
  for (let i = 0; i < (1 << n); i++) {
    result.push(i ^ (i >> 1)); // 二進位轉格雷碼公式
  }
  return result;
}
// grayCode(3) → [0,1,3,2,6,7,5,4]
// 二進位:000,001,010,011,100,101,110,111
// 格雷碼:000,001,011,010,110,111,101,100

// ━━ XOR Checksum(簡單資料完整性驗證)━━
function xorChecksum(data: number[]): number {
  return data.reduce((acc, x) => acc ^ x, 0);
}
// 傳輸後重新計算 checksum,若與原始相同表示資料完整

Linux 檔案權限 — 九位元 Bitmask

Linux 使用 9 個位元(3 × 3)表示 owner/group/others 的 read/write/execute 權限,是 Bitmask 最廣為人知的現實應用:

class FilePermission {
  // 權限位元常數(八進位表示)
  static readonly S_IRUSR = 0o400; // owner read
  static readonly S_IWUSR = 0o200; // owner write
  static readonly S_IXUSR = 0o100; // owner execute
  static readonly S_IRGRP = 0o040; // group read
  static readonly S_IWGRP = 0o020; // group write
  static readonly S_IXGRP = 0o010; // group execute
  static readonly S_IROTH = 0o004; // others read
  static readonly S_IWOTH = 0o002; // others write
  static readonly S_IXOTH = 0o001; // others execute

  private mode: number;
  constructor(mode: number = 0o644) { this.mode = mode; }

  hasPermission(perm: number): boolean { return (this.mode & perm) !== 0; }
  addPermission(perm: number): void    { this.mode |= perm; }
  removePermission(perm: number): void { this.mode &= ~perm; }
  togglePermission(perm: number): void { this.mode ^= perm; }
}

const perm = new FilePermission(0o755); // rwxr-xr-x
console.log(perm.hasPermission(FilePermission.S_IXUSR)); // 輸出:true
perm.removePermission(FilePermission.S_IXGRP | FilePermission.S_IXOTH);
// mode 變為 0o744(rwxr--r--)

面試考點

不用加法的加法(LeetCode 371 延伸)

二進位加法器的邏輯:XOR 計算不進位的和,AND 左移計算進位,兩步驟交替直到進位為 0。這道題的難點在於 JavaScript 中負數的處理——32 位有號整數的二補數表示讓 while (b !== 0) 最多循環 32 次,保證終止。

常見追問:

  • 「為什麼不能用 + 就能做加法?」 — 二進位加法器的硬體實現本質上就是 XOR 和 AND 組合,軟體層面模擬這個過程。
  • 「如何處理負數?」 — JavaScript 的 32 位二補數自動處理,-35 的位元運算結果和 2 一致。

缺失數字(LeetCode 268 延伸)

XOR 找缺失數字有兩種等效思路:

  1. XOR 法[0, n] 與陣列全部 XOR,相同數消除,缺失數留下。
  2. 數學法n*(n+1)/2 - sum(nums),但在大數時有溢位風險。

XOR 法的優勢:無溢位風險,且對「兩個缺失數字」(LeetCode 260 的變體)的擴展更自然。

常見陷阱整理

// 陷阱 1:XOR Swap 在相同位置使用會清零
function xorSwapBad(arr: number[], i: number, j: number): void {
  // 若 i === j:
  arr[i] ^= arr[j]; // arr[i] ^= arr[i] = 0  ← 資料遺失!
  arr[j] ^= arr[i];
  arr[i] ^= arr[j];
}
// 修正:先判斷 if (i === j) return;

// 陷阱 2:子集枚舉時空集漏掉
function enumerateSubsetsBad(mask: number): void {
  for (let sub = mask; sub > 0; sub = (sub - 1) & mask) {
    // 當 sub = 0 時迴圈終止,空集 {} 未被處理!
    process(sub);
  }
  // 修正:迴圈後加 process(0);
}

// 陷阱 3:C++ 有號整數左移溢位是未定義行為(UB)
// int a = 1;
// int bad = a << 31;  // UB!
// unsigned safe = 1u << 31;  // 安全:轉為 unsigned 再移位

function process(sub: number): void {}

LeetCode 練習

#題目難度核心位元技巧
136Single NumberEasyXOR 消除重複,a ^ a = 0
191Number of 1 BitsEasyBrian Kernighan:n &= n - 1
268Missing NumberEasyXOR 找缺失,[0, n] XOR 陣列
371Sum of Two IntegersMediumXOR 模擬加法,AND 模擬進位
78SubsetsMediumBitmask 列舉所有 2ⁿ 個子集

LeetCode 191 — Number of 1 Bits(詳解):

// 方法 1:Brian Kernighan 算法(最優雅)
function hammingWeightBK(n: number): number {
  let count = 0;
  while (n !== 0) {
    n &= n - 1; // 清除最低位的 1,每次迭代減少一個 1
    count++;
  }
  return count;
}

// 方法 2:逐位掃描(直觀但固定 32 次)
function hammingWeightBit(n: number): number {
  let count = 0;
  for (let i = 0; i < 32; i++) {
    if ((n >>> i) & 1) count++; // 使用 >>> 確保邏輯右移
  }
  return count;
}

// 方法 3:JavaScript 的奇技(parseInt + split)
// 僅供了解,競程中請用 Brian Kernighan
function hammingWeightString(n: number): number {
  return (n >>> 0).toString(2).split('').filter(c => c === '1').length;
}

console.log(hammingWeightBK(11));  // 輸出:3(11 = 0b1011,三個 1)
console.log(hammingWeightBK(128)); // 輸出:1(128 = 0b10000000,一個 1)

LeetCode 78 — Subsets(詳解):

// Bitmask 版本:最簡潔,面試首選
function subsets78(nums: number[]): number[][] {
  const n = nums.length;
  const result: number[][] = [];

  for (let mask = 0; mask < (1 << n); mask++) {
    const subset: number[] = [];
    for (let i = 0; i < n; i++) {
      if ((mask >> i) & 1) {
        subset.push(nums[i]);
      }
    }
    result.push(subset);
  }

  return result;
}

// 回溯版本(另一種常見解法,便於比較)
function subsets78Backtrack(nums: number[]): number[][] {
  const result: number[][] = [];

  function backtrack(start: number, current: number[]): void {
    result.push([...current]);
    for (let i = start; i < nums.length; i++) {
      current.push(nums[i]);
      backtrack(i + 1, current);
      current.pop();
    }
  }

  backtrack(0, []);
  return result;
}

// 兩種方法的複雜度相同:O(2ⁿ × n)(共 2ⁿ 個子集,每個最多 n 個元素)
console.log(subsets78([1, 2, 3]));
// 輸出:[[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]

總結

位元運算的本質是以硬體指令直接操作二進位資訊,讓許多看似複雜的邏輯變成 O(1) 的一行程式碼。六大運算子各有職司:AND 用於取位與遮罩,OR 用於置位與合併,XOR 用於切換與差異,NOT 用於取反,移位運算相當於乘除 2 的冪。

三個最重要的核心技巧值得反覆練習:

  • x & (x-1):清除最低位的 1,用於 popcount(Brian Kernighan)和判斷 2 的冪
  • x & (-x):Lowbit,取出最低位的 1,是 Fenwick Tree 的心臟
  • Bitmask 列舉mask 從 0 到 (1 << n) - 1,每個整數對應一個子集,是狀態壓縮 DP 的基礎

XOR 四大性質(自消除、恆等、交換律、消除律)是面試中最常考的位元技巧,Single Number 系列、Missing Number、Sum of Two Integers 都圍繞這些性質展開。

下一篇將深入探討圖的表示法(Graph Representation)——鄰接矩陣、鄰接串列、邊列表各自的時空權衡,以及在 BFS、DFS、最短路徑中如何選擇最合適的圖結構。

希望這篇文章能幫助你徹底掌握位元運算的核心思維,在面試中靈活運用這把「硬體層級的瑞士刀」。如有任何問題或疑惑,歡迎至 Contact 頁面 留言討論!

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。