C++ In Practice - Bit Manipulations

Bit Manipulation (C++11 Deep Dive)

Bit manipulation allows direct control over hardware registers and efficient data compression. In C++11, understanding specific integer types (<cstdint>) and Undefined Behavior (UB) regarding signed integers is critical for safety-critical systems.

C++11 Architecture & Types

The Header: <cstdint> (or <cinttypes>) Standard C++ int size is implementation-defined (usually 32-bit, but not guaranteed). For bitwise operations, always use fixed-width types to guarantee behavior across platforms.

  • int32_t, int64_t: Signed.
  • uint32_t, uint64_t: Unsigned.

Crucial Safety Rule: Prefer unsigned types for bitwise operations. In C++, shifting a negative signed integer right (>>) is implementation-defined (arithmetic vs. logical shift). Shifting a signed integer left (<<) into the sign bit is Undefined Behavior. Unsigned integers have well-defined wrapping behavior (modulo arithmetic).


1. Single Number (XOR Pattern)

Problem: All numbers appear twice, one appears once. Complexity: Time O(N) | Space O(1).

Under the Hood: The XOR operation (^) is a digital logic gate.

  1. Commutative/Associative: Order doesn't matter. A ^ B ^ A is same as A ^ A ^ B.
  2. Self-Inverse: A ^ A = 0.
  3. Identity: 0 ^ B = B. Therefore, all pairs cancel out to 0, leaving the unique number.
#include <vector>
#include <numeric> // for std::accumulate (optional)

int singleNumber(const std::vector<int>& nums) {
    int result = 0;
    // Range-based for loop (C++11)
    for (int num : nums) {
        result ^= num;
    }
    return result;
}

// STL Alternative (One-liner)
// Uses std::accumulate with a custom binary operation (std::bit_xor)
#include <functional>
int singleNumberSTL(const std::vector<int>& nums) {
    return std::accumulate(nums.begin(), nums.end(), 0, std::bit_xor<int>());
}

2. Hamming Weight (Number of 1 Bits)

Problem: Count set bits. Complexity: Time O(k) where k is the number of set bits (Brian Kernighan’s Algorithm).

Under the Hood:

  • Software Approach: n & (n - 1) removes the least significant bit (LSB).
  • Hardware Approach (Optimization): Modern CPUs (x86 SSE4.2+, ARM NEON) have a specific instruction POPCNT (Population Count). Compilers (GCC/Clang) expose this via __builtin_popcount(n). This reduces the operation to O(1) CPU cycles.

ASCII Visualization: n & (n-1)

State 1: n       = 101100  (Value: 44)
         n-1     = 101011  (Flips trailing 0s and lowest 1)
         n&(n-1) = 101000  (Lowest 1 removed)

State 2: n       = 101000
         n-1     = 100111
         n&(n-1) = 100000  (Next lowest 1 removed)

Implementation:

#include <cstdint>

int hammingWeight(uint32_t n) {
    int count = 0;
    while (n != 0) {
        n = n & (n - 1); // Clear least significant bit
        count++;
    }
    return count;
}

// Compiler Intrinsic version (GCC/Clang) - O(1) instruction
int hammingWeightHardware(uint32_t n) {
    return __builtin_popcount(n); 
}

3. Counting Bits (Dynamic Programming)

Problem: Return array of bit counts for 0 to n. Complexity: Time O(N) | Space O(N).

Logic: A number x has the same bit pattern as x / 2 (x >> 1), plus the last bit (x & 1). dp[x] = dp[x >> 1] + (x & 1)

#include <vector>

std::vector<int> countBits(int n) {
    std::vector<int> dp(n + 1);
    dp[0] = 0;
    
    for (int i = 1; i <= n; ++i) {
        // i >> 1 refers to a value already computed (smaller index)
        dp[i] = dp[i >> 1] + (i & 1);
    }
    return dp;
}

4. Reverse Bits

Problem: Reverse bits of a 32-bit unsigned integer. Complexity: Time O(1) (Fixed 32 iterations).

Under the Hood: We maintain a result variable. We treat n as a queue of bits and result as a stack. We pop from n (read LSB, shift right) and push to result (shift left, write LSB).

#include <cstdint>

uint32_t reverseBits(uint32_t n) {
    uint32_t result = 0;
    // Iterate exactly 32 times for a 32-bit integer
    for (int i = 0; i < 32; ++i) {
        // 1. Shift result to make room
        result = result << 1;
        
        // 2. Add the LSB of n to result
        if (n & 1) {
            result = result | 1; // Set LSB of result to 1
        }
        
        // 3. Shift n to process next bit
        n = n >> 1;
    }
    return result;
}

5. Missing Number

Problem: Array [0...n] missing one number. Complexity: Time O(N) | Space O(1).

Logic: Index: 0 1 2 3 Value: 0 1 3 4 (Missing 2) XOR all indices: 0^1^2^3^4 (Includes n) XOR all values: 0^1^3^4 Result: 2 (Everything else cancels).

#include <vector>

int missingNumber(const std::vector<int>& nums) {
    int n = nums.size();
    int xorSum = n; // Initialize with n because loop only goes to n-1
    
    for (int i = 0; i < n; ++i) {
        // XOR the index and the value at that index
        xorSum ^= i ^ nums[i];
    }
    return xorSum;
}

6. Subsets (Power Set via Bitmask)

Problem: Generate all subsets. Complexity: Time O(N * 2^N) | Space O(N * 2^N).

System Limits: This approach works strictly for small N. Since a 32-bit integer can only enumerate 2^32 states, and memory is finite, this is practical only for N <= 20 roughly.

Implementation:

#include <vector>
#include <cmath>

std::vector<std::vector<int>> subsets(const std::vector<int>& nums) {
    int n = nums.size();
    // Safety check: 1 << 31 is undefined for signed int. 
    // Use 1ULL (unsigned long long) for safety if n is large, 
    // though n > 30 is unrealistic for this algorithm.
    int subsetCount = 1 << n; 
    
    std::vector<std::vector<int>> result;
    result.reserve(subsetCount); // Optimization: Pre-allocate outer vector

    for (int mask = 0; mask < subsetCount; ++mask) {
        std::vector<int> currentSubset;
        
        for (int i = 0; i < n; ++i) {
            // Check if the i-th bit is set in mask
            if ((mask >> i) & 1) {
                currentSubset.push_back(nums[i]);
            }
        }
        result.push_back(std::move(currentSubset));
    }
    
    return result;
}

7. C++11 Special: std::bitset

Sometimes manual bit shifting is error-prone or hard to read. C++ provides std::bitset, a fixed-size sequence of N bits that behaves like a string of '0's and '1's but stores data efficiently.

Usage Scenario: Flags, Bloom Filters, or when you need to print binary representation.

#include <bitset>
#include <iostream>
#include <string>

void bitsetExample() {
    // Compile-time size constant required
    std::bitset<8> bits(5); // Store 5 (00000101)
    
    // Operations
    bits.set(1);   // Set bit at index 1: 00000101 -> 00000111 (7)
    bits.reset(2); // Clear bit at index 2
    bits.flip();   // Toggle all bits (~ operation)
    
    // Inspection
    bool isSet = bits.test(0);
    int count = bits.count(); // Number of set bits (Population Count)
    
    // Conversion
    unsigned long val = bits.to_ulong();
    std::string str = bits.to_string(); // "11111000" (after flip)
}

Summary of Bitwise Ops & Tricks

Operation C++ Code Description
Check Bit (n >> i) & 1 Returns 1 if ith bit is set, 0 otherwise.
Set Bit n | (1 << i) Forces ith bit to 1.
Clear Bit n & ~(1 << i) Forces ith bit to 0.
Toggle Bit n ^ (1 << i) Flips ith bit.
Lowest Set Bit n & -n Isolates the rightmost 1-bit.
Clear Lowest Bit n & (n - 1) Sets the rightmost 1-bit to 0.
Power of 2 n > 0 && !(n & (n - 1)) True if n has exactly one bit set.