DSA · Topic 21 of 21

Bit Manipulation

100 XP

Binary representation

Every integer in a computer is stored as a fixed sequence of bits (0s and 1s).

Unsigned integers: value = sum of 2^i for each bit i that is set.

13 = 8 + 4 + 1
   = 2³ + 2² + 2⁰
   = 1101 in binary

Two’s complement (signed integers): The most significant bit (MSB) is the sign bit — 0 means non-negative, 1 means negative. To negate any number: flip all bits, then add 1.

 5 in 8-bit:  00000101
-5 in 8-bit:  11111011   (flip: 11111010, add 1: 11111011)

Key consequences you must know cold:

  • -1 is all 1-bits: 11111111 11111111 11111111 11111111 (32-bit)
  • ~n = -(n + 1) — flipping all bits of n gives -n - 1 (two’s complement identity)
  • For 32-bit signed integers: range is -2,147,483,648 to 2,147,483,647
  • JavaScript bitwise operations convert all operands to 32-bit signed integers before operating

Bitwise operators

AND (&)

Both bits must be 1 for the result bit to be 1. Used to mask (extract) specific bits.

// Check if bit i is set
function isBitSet(n: number, i: number): boolean {
  return (n & (1 << i)) !== 0
}

// Isolate the lower k bits
function lowerKBits(n: number, k: number): number {
  return n & ((1 << k) - 1)
}

console.log(6 & 3)    // 110 & 011 = 010 = 2
console.log(12 & 10)  // 1100 & 1010 = 1000 = 8

OR (|)

At least one bit must be 1. Used to set specific bits.

function setBit(n: number, i: number): number {
  return n | (1 << i)
}

console.log(6 | 3)    // 110 | 011 = 111 = 7
console.log(12 | 10)  // 1100 | 1010 = 1110 = 14

XOR (^)

Bits differ → 1. Bits same → 0. Key algebraic properties:

  • a ^ a = 0 (self-inverse: XOR with itself cancels out)
  • a ^ 0 = a (identity: XOR with 0 is a no-op)
  • Commutative and associative
function toggleBit(n: number, i: number): number {
  return n ^ (1 << i)
}

console.log(6 ^ 3)  // 110 ^ 011 = 101 = 5
console.log(5 ^ 5)  // 0   (self-cancelling)
console.log(5 ^ 0)  // 5   (identity)
console.log(7 ^ 3 ^ 3)  // 7  (3 cancels itself)

XOR is the foundation of three important tricks: finding the unique element among duplicates, swapping without a temp, and checking bit differences.

NOT (~)

Flips all bits. In two’s complement: ~n === -(n + 1).

console.log(~5)    // -6
console.log(~0)    // -1
console.log(~(-1)) // 0

Left shift (<<)

Shifts bits left by the given amount, filling with 0s on the right. Each position left multiplies by 2.

console.log(1 << 3)  // 8   (1 × 2³)
console.log(3 << 2)  // 12  (3 × 4)
console.log(1 << 31) // -2147483648  ← sets the sign bit in 32-bit signed!

Arithmetic right shift (>>)

Shifts bits right, preserving the sign bit (fills left with the sign bit). Each position right divides by 2 (truncated).

console.log(8 >> 1)    //  4  (positive: fills with 0s)
console.log(-8 >> 1)   // -4  (negative: fills with 1s, sign preserved)
console.log(7 >> 1)    //  3  (truncates toward negative infinity)

Unsigned right shift (>>>)

JavaScript-specific: shifts right and fills with 0s regardless of sign. Treats the value as unsigned.

console.log(-1 >>> 0)   // 4294967295  (all 32 bits set, unsigned interpretation)
console.log(-8 >>> 1)   // 2147483644  (not -4!)
console.log(8 >>> 1)    // 4           (same as >> for positive numbers)

Use >>> 0 to convert a potentially negative 32-bit value to its unsigned equivalent.


Essential tricks

These patterns appear constantly — know them by heart.

// 1. Check if n is a power of 2
//    Powers of 2 have exactly one set bit: 1000...0
//    n-1 flips all lower bits:             0111...1
//    AND of the two is always 0
function isPowerOfTwo(n: number): boolean {
  return n > 0 && (n & (n - 1)) === 0
}

// 2. Isolate the lowest set bit
//    -n in two's complement flips all bits then adds 1,
//    which preserves only the lowest set bit
function lowestSetBit(n: number): number {
  return n & (-n)
}

// 3. Clear the lowest set bit — the heart of Brian Kernighan's algorithm
//    n-1 turns the lowest set bit to 0 and flips all lower bits to 1
//    AND with n clears exactly that one bit
function clearLowestSetBit(n: number): number {
  return n & (n - 1)
}

// 4. Count set bits — Brian Kernighan's algorithm
//    Each iteration clears one set bit. Runs in O(k) where k = set bit count.
//    Faster than looping over all 32 positions when bits are sparse.
function countSetBits(n: number): number {
  let count = 0
  while (n !== 0) {
    n = n & (n - 1)
    count++
  }
  return count
}

// 5. Swap two numbers without a temporary variable
function swapBits(a: number, b: number): [number, number] {
  a ^= b        // a = a XOR b
  b ^= a        // b = b XOR (a XOR b) = original a
  a ^= b        // a = (a XOR b) XOR original a = original b
  return [a, b]
}

// 6. Sign of a number (without branching)
function sign(n: number): number {
  return 1 | (n >> 31)  // 1 for positive/zero, -1 for negative
}

Single Number — LC 136

Every element in the array appears exactly twice except for one. Find the single element in O(n) time and O(1) space.

Trick: XOR all elements. Duplicate pairs cancel each other out (a ^ a = 0), leaving only the unique element.

function singleNumber(nums: number[]): number {
  return nums.reduce((xor, n) => xor ^ n, 0)
}

// [2, 2, 1] → 0 ^ 2 ^ 2 ^ 1 = 0 ^ 0 ^ 1 = 1
// [4, 1, 2, 1, 2] → 0 ^ 4 ^ 1 ^ 2 ^ 1 ^ 2 = 4

Time: O(n). Space: O(1). No hash map needed.


Single Number II — LC 137

Every element appears exactly three times except for one. Find the single element.

Trick: For each bit position, count how many numbers have that bit set. If the count mod 3 is 1, the unique number has that bit set. If 0, it does not.

function singleNumberII(nums: number[]): number {
  let result = 0
  for (let i = 0; i < 32; i++) {
    let bitSum = 0
    for (const num of nums) {
      bitSum += (num >> i) & 1
    }
    if (bitSum % 3 === 1) {
      result |= (1 << i)
    }
  }
  return result | 0  // force 32-bit signed interpretation
}

Optimized O(1) space — bit automaton approach:

Track which bits have been seen once (ones) and twice (twos). When a bit is seen a third time, it drops out of both.

function singleNumberIIOptimal(nums: number[]): number {
  let ones = 0, twos = 0
  for (const num of nums) {
    ones = (ones ^ num) & ~twos  // add to ones only if not already in twos
    twos = (twos ^ num) & ~ones  // add to twos only if not already in ones
  }
  return ones  // ones holds bits seen exactly once
}

Time: O(n). Space: O(1).


Number of 1 Bits — LC 191

Count the number of set bits (Hamming weight) in a 32-bit unsigned integer.

function hammingWeight(n: number): number {
  n = n >>> 0  // treat as unsigned 32-bit to handle negative input safely
  let count = 0
  while (n !== 0) {
    n = n & (n - 1)  // clear the lowest set bit
    count++
  }
  return count
}

// Trace for n = 11 (binary 1011):
// 1011 → 1010 → 1000 → 0000  (3 iterations = 3 set bits)

Time: O(k) where k = number of set bits. Space: O(1).

Note: The naive approach loops over all 32 bit positions — O(32). Brian Kernighan’s algorithm only iterates over set bits, making it faster for sparse integers.


Counting Bits — LC 338

Return an array ans where ans[i] = number of set bits in i, for all 0 <= i <= n.

Trick: i >> 1 is i with its last bit removed (already computed). Adding i & 1 accounts for the last bit.

function countBits(n: number): number[] {
  const dp = new Array(n + 1).fill(0)
  for (let i = 1; i <= n; i++) {
    dp[i] = dp[i >> 1] + (i & 1)
    //       ^^^^^^^^^^^  ^^^^^^^^
    //       bits in i/2  last bit of i (0 if even, 1 if odd)
  }
  return dp
}

// n = 5: [0, 1, 1, 2, 1, 2]
// i=4 (100): dp[2] + 0 = 1 + 0 = 1  ✓
// i=5 (101): dp[2] + 1 = 1 + 1 = 2  ✓

Time: O(n). Space: O(n). This is DP with a bit trick. The naive approach calling hammingWeight(i) for each i would be O(n × 32) = O(n log n).


Reverse Bits — LC 190

Reverse the bits of a 32-bit unsigned integer.

function reverseBits(n: number): number {
  let result = 0
  for (let i = 0; i < 32; i++) {
    result = (result << 1) | (n & 1)  // shift result left, append LSB of n
    n >>>= 1                           // unsigned right shift n (zero-fills MSB)
  }
  return result >>> 0  // ensure unsigned 32-bit result
}

// For n = 43261596 (00000010100101000001111010011100):
// Result  = 964176192 (00111001011110000010100101000000)

Time: O(32) = O(1). Space: O(1).

result >>> 0 is critical: without it, if bit 31 of result is set, JavaScript’s signed integer representation returns a negative number, which is wrong for an unsigned reverse.


Missing Number — LC 268

Given an array containing n distinct numbers in the range [0, n], find the one that’s missing.

function missingNumber(nums: number[]): number {
  const n = nums.length
  // XOR all indices 0..n with all values in nums
  // Each present number cancels with its index; only the missing number survives
  let xor = n
  for (let i = 0; i < n; i++) {
    xor ^= i ^ nums[i]
  }
  return xor
}

// nums = [3, 0, 1], n = 3
// xor = 3 ^ (0^3) ^ (1^0) ^ (2^1) = 3^3^0^1^2^1 = 0^0^2 = 2  ✓

Alternative: math formula n*(n+1)/2 - sum(nums). XOR avoids potential overflow for very large n.


Sum of Two Integers — LC 371

Add two integers without using + or -.

Trick: XOR gives the sum without carries. AND shifted left gives the carry bits. Repeat until there are no more carries.

function getSum(a: number, b: number): number {
  while (b !== 0) {
    const carry = (a & b) << 1  // positions where both bits are 1 produce a carry
    a = a ^ b                    // sum without carry
    b = carry                    // carry propagates to next position
  }
  return a
}

// 3 + 5:
// Round 1: carry = (011 & 101) << 1 = 001 << 1 = 010
//          a     = 011 ^ 101 = 110
//          b     = 010
// Round 2: carry = (110 & 010) << 1 = 010 << 1 = 100
//          a     = 110 ^ 010 = 100
//          b     = 100
// Round 3: carry = (100 & 100) << 1 = 1000
//          a     = 100 ^ 100 = 000
//          b     = 1000
// Round 4: carry = (000 & 1000) << 1 = 0
//          a     = 000 ^ 1000 = 1000 = 8  ✓

Time: O(1) — at most 32 iterations. Space: O(1).

Handles negative numbers correctly: two’s complement arithmetic works the same way for XOR/AND operations, so the algorithm is correct for all 32-bit integers.


Maximum XOR of Two Numbers in an Array — LC 421

Find the maximum XOR of any two numbers in nums.

Trie approach: Insert all numbers into a binary trie (MSB first). For each number, greedily walk the trie choosing the opposite bit at each level — opposite bits XOR to 1, maximizing the result.

class TrieNode {
  children: (TrieNode | null)[] = [null, null]
}

function findMaximumXOR(nums: number[]): number {
  const root = new TrieNode()

  function insert(num: number): void {
    let node = root
    for (let i = 31; i >= 0; i--) {
      const bit = (num >> i) & 1
      if (!node.children[bit]) node.children[bit] = new TrieNode()
      node = node.children[bit]!
    }
  }

  // For each number, find the max XOR achievable against the trie
  function query(num: number): number {
    let node = root, xor = 0
    for (let i = 31; i >= 0; i--) {
      const bit = (num >> i) & 1
      const want = 1 - bit  // prefer opposite bit to get a 1 in XOR result
      if (node.children[want]) {
        xor |= (1 << i)
        node = node.children[want]!
      } else {
        node = node.children[bit]!
      }
    }
    return xor
  }

  for (const num of nums) insert(num)
  return nums.reduce((max, num) => Math.max(max, query(num)), 0)
}

Time: O(n × 32) = O(n). Space: O(n × 32) for the trie.


Bitmask DP

Use an integer as a compact representation of a set: bit i is 1 if element i is in the set, 0 otherwise. State space becomes 2^n subsets.

When to use: n is small (typically n ≤ 20 or n ≤ 25). Problems over all subsets: assignment problems, TSP variants, minimum cost covering all states.

Essential bitmask operations:

const n = 4

// All elements included: (1 << n) - 1
const fullMask = (1 << n) - 1  // 1111 = 15

// Check if element i is in mask
const has = (mask: number, i: number) => (mask & (1 << i)) !== 0

// Add element i to mask
const add = (mask: number, i: number) => mask | (1 << i)

// Remove element i from mask
const remove = (mask: number, i: number) => mask & ~(1 << i)

// Iterate over all subsets of mask (excluding empty set)
function iterateSubsets(mask: number): void {
  for (let sub = mask; sub > 0; sub = (sub - 1) & mask) {
    // process subset `sub`
  }
}

// Popcount (number of set bits)
function popcount(mask: number): number {
  let count = 0
  let m = mask
  while (m !== 0) { m &= m - 1; count++ }
  return count
}

TSP-style example — Traveling Salesman Problem:

// dp[mask][i] = min cost to visit exactly the cities in mask, ending at city i
function tsp(cost: number[][]): number {
  const n = cost.length
  const full = (1 << n) - 1
  const INF = Infinity
  const dp: number[][] = Array.from({ length: 1 << n }, () =>
    new Array(n).fill(INF)
  )
  dp[1][0] = 0  // start at city 0, mask = 0001

  for (let mask = 1; mask <= full; mask++) {
    for (let u = 0; u < n; u++) {
      if (!(mask & (1 << u))) continue   // u not in current mask
      if (dp[mask][u] === INF) continue
      for (let v = 0; v < n; v++) {
        if (mask & (1 << v)) continue    // v already visited
        const nextMask = mask | (1 << v)
        dp[nextMask][v] = Math.min(dp[nextMask][v], dp[mask][u] + cost[u][v])
      }
    }
  }

  // Return to starting city 0
  let ans = INF
  for (let u = 1; u < n; u++) {
    if (dp[full][u] !== INF) ans = Math.min(ans, dp[full][u] + cost[u][0])
  }
  return ans
}

Time: O(2^n × n²). Space: O(2^n × n). Feasible for n ≤ 20.


JavaScript gotcha: 32-bit signed integers

JavaScript’s bitwise operators always convert operands to 32-bit signed integers before operating and return a 32-bit signed integer. This causes surprises.

// Shift amount is taken mod 32 — shifting by 32 is a no-op!
console.log(1 << 32)    // 1  (not 0)
console.log(1 << 31)    // -2147483648  (sets sign bit → negative)

// >>> 0 converts to unsigned 32-bit
console.log(-1 >>> 0)           // 4294967295
console.log(0x80000000 | 0)     // -2147483648  (MSB set = negative in signed)
console.log(0x80000000 >>> 0)   // 2147483648   (correct unsigned value)

// XOR with all 1s flips every bit (equivalent to bitwise NOT for unsigned)
const unsignedFlip = (n: number) => (n ^ 0xFFFFFFFF) >>> 0

// Safe popcount for potentially negative inputs
function hammingWeightSafe(n: number): number {
  n = n >>> 0   // reinterpret as unsigned 32-bit before counting
  let count = 0
  while (n !== 0) { n = n & (n - 1); count++ }
  return count
}

Rules of thumb:

  • Use >>> 0 any time you need unsigned 32-bit semantics
  • Avoid 1 << 31 in arithmetic — it produces a negative 32-bit signed integer
  • Bit shifts with amounts ≥ 32 silently wrap (shift amount % 32)
  • Use BigInt when you genuinely need more than 31 usable bits

Complexity and use-case reference

ProblemTimeSpaceKey Trick
Single Number (LC 136)O(n)O(1)XOR cancels pairs
Single Number II (LC 137)O(n)O(1)Bit count mod 3
Number of 1 Bits (LC 191)O(k)O(1)Brian Kernighan
Counting Bits (LC 338)O(n)O(n)DP: dp[i >> 1] + (i & 1)
Reverse Bits (LC 190)O(1)O(1)Shift and OR, 32 iterations
Missing Number (LC 268)O(n)O(1)XOR index with value
Sum of Two Integers (LC 371)O(1)O(1)XOR sum + AND carry
Maximum XOR (LC 421)O(n)O(n)Greedy trie traversal
Bitmask DP (TSP etc.)O(2^n × n²)O(2^n × n)Integer as subset
Power of 2 checkO(1)O(1)n & (n - 1) === 0
Isolate lowest set bitO(1)O(1)n & (-n)
Clear lowest set bitO(1)O(1)n & (n - 1)