Loading content...
In the world of algorithms, few techniques offer such an elegant marriage of mathematical abstraction and computational efficiency as bitmask subset representation. This technique transforms the familiar concept of sets—fundamental to mathematics since Cantor's pioneering work in the 1870s—into compact integers that computers can manipulate with extraordinary speed.
Consider this profound insight: every subset of a set with n elements can be uniquely represented by an n-bit integer. This isn't merely a convenient encoding—it's a fundamental correspondence that unlocks O(1) set operations, makes subset iteration trivial, and enables entirely new classes of dynamic programming solutions.
By the end of this page, you will understand: (1) the mathematical bijection between subsets and integers, (2) how individual set elements map to specific bit positions, (3) why this representation enables O(1) fundamental set operations, and (4) how bitmasks illuminate the connection between combinatorics and binary arithmetic.
Let's begin with the foundational insight that makes bitmask representation possible. Consider a universal set U containing exactly n elements, which we'll index from 0 to n-1:
U = {element₀, element₁, element₂, ..., elementₙ₋₁}
Now, for any subset S ⊆ U, we can define a characteristic function that maps each element to either 0 or 1:
χ_S(elementᵢ) = 1 if elementᵢ ∈ S
χ_S(elementᵢ) = 0 if elementᵢ ∉ S
This characteristic function produces a sequence of n binary digits (bits). Reading these bits as a binary number gives us a unique integer for each subset.
The profound implication: Since each bit independently chooses 'in' or 'out' for one element, there are exactly 2ⁿ possible combinations—matching the 2ⁿ possible subsets of an n-element set. This isn't coincidence; it's a mathematical bijection (one-to-one correspondence) between subsets and integers in the range [0, 2ⁿ - 1].
A bijection means every subset maps to exactly one integer, and every integer (in range) maps to exactly one subset. This isn't just encoding—it's a perfect mathematical correspondence. The empty set ∅ maps to 0, the full set U maps to 2ⁿ - 1, and every integer between represents a unique subset.
| Integer | Binary | Subset | Set Notation |
|---|---|---|---|
| 0 | 000 | {} | ∅ (empty set) |
| 1 | 001 | {a} | Element 0 only |
| 2 | 010 | {b} | Element 1 only |
| 3 | 011 | {a, b} | Elements 0 and 1 |
| 4 | 100 | {c} | Element 2 only |
| 5 | 101 | {a, c} | Elements 0 and 2 |
| 6 | 110 | {b, c} | Elements 1 and 2 |
| 7 | 111 | {a, b, c} | Full set U |
Observation from the table:
This mapping is consistent and computable in O(1): checking if element i is in subset S encoded as integer mask is simply (mask >> i) & 1, which tests whether bit i is set.
The power of bitmask representation stems from a crucial design decision: each bit position corresponds to exactly one element in our universal set. This isn't arbitrary—it enables constant-time operations.
Standard convention:
This means the integer 1 << i (which equals 2ⁱ) represents the singleton set containing only element i. More generally, if element i should be included in the set, we 'set' bit i to 1.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Universal set with n elements indexed 0 to n-1// Each element i is represented by bit position i // The bitmask for element i alone (singleton set)function singletonMask(i: number): number { // 1 << i creates a number with only bit i set // Examples: // i=0: 1 << 0 = 0001₂ = 1 // i=1: 1 << 1 = 0010₂ = 2 // i=2: 1 << 2 = 0100₂ = 4 // i=3: 1 << 3 = 1000₂ = 8 return 1 << i;} // Check if element i is in the subset represented by maskfunction containsElement(mask: number, i: number): boolean { // (mask >> i) shifts bit i to position 0 // & 1 isolates that single bit // Result is 1 (true) if element i is present, 0 (false) otherwise return ((mask >> i) & 1) === 1;} // Alternative: use AND with the singleton maskfunction containsElementAlt(mask: number, i: number): boolean { // (1 << i) creates mask with only bit i set // AND with mask is non-zero iff mask also has bit i set return (mask & (1 << i)) !== 0;} // Example usageconst n = 4; // Elements: {0, 1, 2, 3}const mask = 0b1011; // Binary: 1011 = Subset {0, 1, 3} console.log(`Subset represented by ${mask} (binary: ${mask.toString(2)}):`);for (let i = 0; i < n; i++) { if (containsElement(mask, i)) { console.log(` Element ${i} is IN the subset`); } else { console.log(` Element ${i} is NOT in the subset`); }}// Output:// Element 0 is IN the subset (bit 0 = 1)// Element 1 is IN the subset (bit 1 = 1)// Element 2 is NOT in the subset (bit 2 = 0)// Element 3 is IN the subset (bit 3 = 1)Using the least significant bit for element 0 is conventional but not mandatory. The critical requirement is consistency: whichever mapping you choose, all operations must follow the same convention. LSB-first aligns naturally with how we read binary numbers and with the behavior of the shift operators.
Now that we understand the mapping, let's build complete encoding and decoding functions. Encoding converts a set (represented as an array of element indices) into an integer. Decoding recovers the set from the integer.
These operations are fundamental—they form the bridge between human-readable sets and machine-efficient integers.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
/** * Encode a subset (array of element indices) as an integer bitmask. * * Time Complexity: O(|elements|) - linear in subset size * Space Complexity: O(1) */function encodeSubset(elements: number[]): number { let mask = 0; for (const elementIndex of elements) { // Set the bit at position elementIndex // Using |= ensures we accumulate all elements mask |= (1 << elementIndex); } return mask;} /** * Decode an integer bitmask into a subset (array of element indices). * * Time Complexity: O(n) where n is the number of bits to check * Space Complexity: O(|subset|) for the output array */function decodeSubset(mask: number, n: number): number[] { const elements: number[] = []; for (let i = 0; i < n; i++) { // Check if element i is in the subset if ((mask >> i) & 1) { elements.push(i); } } return elements;} /** * Optimized decoding that only iterates while there are set bits. * Faster when the subset is small relative to n. * * Time Complexity: O(|subset|) when subset is small */function decodeSubsetOptimized(mask: number): number[] { const elements: number[] = []; let bitPosition = 0; while (mask > 0) { // Check if current LSB is set if (mask & 1) { elements.push(bitPosition); } // Shift right to examine next bit mask >>= 1; bitPosition++; } return elements;} // Demonstrationconsole.log("=== Encoding Examples ===");console.log(`{0, 2, 3} encoded: ${encodeSubset([0, 2, 3])}`); // 13 (1101₂)console.log(`{1, 4} encoded: ${encodeSubset([1, 4])}`); // 18 (10010₂)console.log(`{} (empty) encoded: ${encodeSubset([])}`); // 0console.log(`{0} encoded: ${encodeSubset([0])}`); // 1 console.log("\n=== Decoding Examples ===");console.log(`13 decoded (n=4): [${decodeSubset(13, 4)}]`); // [0, 2, 3]console.log(`18 decoded (n=5): [${decodeSubset(18, 5)}]`); // [1, 4]console.log(`0 decoded (n=3): [${decodeSubset(0, 3)}]`); // []console.log(`7 decoded (n=3): [${decodeSubset(7, 3)}]`); // [0, 1, 2] console.log("\n=== Optimized Decoding ===");console.log(`13 decoded (optimized): [${decodeSubsetOptimized(13)}]`); // [0, 2, 3]Key insights from the implementation:
Encoding is additive: Each element contributes its bit independently. The order of processing doesn't matter because bitwise OR is commutative.
Decoding can be optimized: The standard approach checks all n bit positions. The optimized version stops when no more set bits remain, which is faster when subsets are sparse.
Round-trip consistency: For any valid subset, decodeSubset(encodeSubset(elements)) returns a sorted version of the original elements.
Here's where bitmask representation reveals its true power. Operations that would require O(n) time with traditional set representations (arrays, linked lists, or even hash sets for some operations) become O(1) with bitmasks.
The magic lies in how CPUs handle bitwise operations. Modern processors include specialized circuitry that can perform operations like AND, OR, and XOR on 32 or 64 bits in a single clock cycle. When we represent sets as integers, set operations map directly to these hardware primitives.
| Operation | Bitmask Impl | Array Impl | Hash Set Impl |
|---|---|---|---|
| Union (A ∪ B) | A | B → O(1) | O(m + n) merge | O(m) iterate & insert |
| Intersection (A ∩ B) | A & B → O(1) | O(m + n) or O(m × n) | O(m) lookup each |
| Difference (A - B) | (A) & (~B) → O(1) | O(m + n) | O(m) check & remove |
| Symmetric Diff | A ^ B → O(1) | O(m + n) | O(m + n) |
| Subset test (A ⊆ B) | (A & B) == A → O(1) | O(m) check each | O(m) check each |
| Cardinality |A| | popcount(A) → O(1)* | O(1) with length | O(1) with size |
| Element test (x ∈ A) | (A >> x) & 1 → O(1) | O(n) search | O(1) hash lookup |
Modern CPUs provide the POPCNT instruction that counts set bits in O(1). In JavaScript/TypeScript, this is available through library functions. The key insight is that bitmask operations leverage transistor-level parallelism—all 32 or 64 bit operations happen simultaneously, not sequentially.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// Prerequisite: Sets A and B are encoded as bitmasks // UNION: Elements in A OR Bfunction union(a: number, b: number): number { return a | b; // Each bit is 1 if it's 1 in A or B (or both) // Example: A={0,1}, B={1,2} → 0011 | 0110 = 0111 = {0,1,2}} // INTERSECTION: Elements in BOTH A AND Bfunction intersection(a: number, b: number): number { return a & b; // Each bit is 1 only if it's 1 in both A and B // Example: A={0,1}, B={1,2} → 0011 & 0110 = 0010 = {1}} // DIFFERENCE: Elements in A but NOT in Bfunction difference(a: number, b: number): number { return a & ~b; // ~b flips all bits of B (complement) // AND with A keeps only bits in A that aren't in B // Example: A={0,1,2}, B={1} → 0111 & 1101 = 0101 = {0,2}} // SYMMETRIC DIFFERENCE: Elements in A or B but NOT bothfunction symmetricDifference(a: number, b: number): number { return a ^ b; // XOR produces 1 where exactly one input is 1 // Example: A={0,1}, B={1,2} → 0011 ^ 0110 = 0101 = {0,2}} // SUBSET TEST: Is A a subset of B?function isSubset(a: number, b: number): boolean { return (a & b) === a; // If every element in A is also in B, then A & B = A // Alternatively: (a & ~b) === 0 (nothing in A outside B)} // PROPER SUBSET TEST: Is A a proper subset of B?function isProperSubset(a: number, b: number): boolean { return (a & b) === a && a !== b; // Subset, but not equal} // EQUALITY TEST: Are A and B the same set?function setsEqual(a: number, b: number): boolean { return a === b; // Direct integer comparison!} // CARDINALITY: Count elements in setfunction cardinality(mask: number): number { // Brian Kernighan's algorithm - O(number of set bits) let count = 0; while (mask) { mask &= (mask - 1); // Clear rightmost set bit count++; } return count;} // For truly O(1), use built-in popcount when available// In JavaScript: no native popcount, but this works:function cardinalityFast(mask: number): number { // Divide and conquer bit counting mask = mask - ((mask >>> 1) & 0x55555555); mask = (mask & 0x33333333) + ((mask >>> 2) & 0x33333333); mask = (mask + (mask >>> 4)) & 0x0f0f0f0f; mask = mask + (mask >>> 8); mask = mask + (mask >>> 16); return mask & 0x3f;} // Demonstrationconst setA = 0b1011; // {0, 1, 3}const setB = 0b1110; // {1, 2, 3} console.log("A = {0, 1, 3}, B = {1, 2, 3}");console.log(`Union A ∪ B: ${union(setA, setB).toString(2)} = {0,1,2,3}`);console.log(`Intersection A ∩ B: ${intersection(setA, setB).toString(2)} = {1,3}`);console.log(`Difference A - B: ${difference(setA, setB).toString(2)} = {0}`);console.log(`Symmetric diff A △ B: ${symmetricDifference(setA, setB).toString(2)} = {0,2}`);console.log(`Is A ⊆ B? ${isSubset(setA, setB)}`); // falseconsole.log(`|A| = ${cardinality(setA)}`); // 3The relationship between subsets and powers of two reveals deep mathematical structure. This isn't just convenient—it's a window into combinatorics itself.
Why exactly 2ⁿ subsets?
For each element in an n-element set, we have exactly two choices: include it or exclude it. These choices are independent. By the multiplication principle:
2 × 2 × 2 × ... × 2 (n times) = 2ⁿ possible subsets
This is precisely the range of n-bit integers [0, 2ⁿ - 1].
123456789101112131415161718192021222324252627282930313233
const n = 4; // 4-element universal set {0, 1, 2, 3} // The full set: all bits setconst fullSet = (1 << n) - 1; // 2⁴ - 1 = 15 = 1111₂console.log(`Full set (all ${n} elements): ${fullSet} = ${fullSet.toString(2)}`); // The empty set: no bits setconst emptySet = 0;console.log(`Empty set: ${emptySet}`); // All singleton setsconsole.log("\nSingleton sets (powers of 2):");for (let i = 0; i < n; i++) { const singleton = 1 << i; console.log(` {${i}} = ${singleton} = 2^${i}`);} // Complement of a setfunction complement(mask: number, n: number): number { const fullSet = (1 << n) - 1; return fullSet ^ mask; // XOR with all 1s flips all bits} const setA = 0b1010; // {1, 3}const complementA = complement(setA, n);console.log(`\nSet A: ${setA.toString(2).padStart(4, '0')} = {1, 3}`);console.log(`Complement of A: ${complementA.toString(2).padStart(4, '0')} = {0, 2}`); // Verify: A ∪ complement(A) = full setconsole.log(`A ∪ A' = ${(setA | complementA).toString(2)} = full set? ${(setA | complementA) === fullSet}`); // Verify: A ∩ complement(A) = empty setconsole.log(`A ∩ A' = ${setA & complementA} = empty set? ${(setA & complementA) === emptySet}`);Standard 32-bit integers limit us to sets of at most 32 elements. With 64-bit integers (BigInt in JavaScript), we can handle 64 elements. For larger sets, we'd need arrays of integers (bit vectors), but most algorithmic applications involve small sets where bitmasks excel.
Let's solidify understanding with practical examples that demonstrate why engineers reach for bitmasks in real systems.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// Define features as bit positionsconst FEATURE_DARK_MODE = 0; // bit 0 = 1const FEATURE_BETA_UI = 1; // bit 1 = 2const FEATURE_ANALYTICS = 2; // bit 2 = 4const FEATURE_AI_ASSIST = 3; // bit 3 = 8const FEATURE_EXPORT_PDF = 4; // bit 4 = 16 // Create feature masksconst DARK_MODE = 1 << FEATURE_DARK_MODE; // 0b00001 = 1const BETA_UI = 1 << FEATURE_BETA_UI; // 0b00010 = 2const ANALYTICS = 1 << FEATURE_ANALYTICS; // 0b00100 = 4const AI_ASSIST = 1 << FEATURE_AI_ASSIST; // 0b01000 = 8const EXPORT_PDF = 1 << FEATURE_EXPORT_PDF; // 0b10000 = 16 // USER FEATURE FLAGS STORED AS SINGLE INTEGER// Instead of: { darkMode: true, betaUI: false, analytics: true, ... }// We use:let userFeatures = 0; // Enable features for a useruserFeatures |= DARK_MODE; // Turn on dark modeuserFeatures |= ANALYTICS; // Turn on analytics// Now userFeatures = 0b00101 = 5 // Check if user has a feature (O(1) operation)function hasFeature(flags: number, feature: number): boolean { return (flags & feature) !== 0;} console.log(`Has dark mode? ${hasFeature(userFeatures, DARK_MODE)}`); // trueconsole.log(`Has beta UI? ${hasFeature(userFeatures, BETA_UI)}`); // falseconsole.log(`Has analytics? ${hasFeature(userFeatures, ANALYTICS)}`); // true // Toggle a featureuserFeatures ^= BETA_UI; // Now beta UI is onconsole.log(`After toggle, has beta UI? ${hasFeature(userFeatures, BETA_UI)}`); // true // Define feature tiers as combinationsconst FREE_TIER = DARK_MODE | ANALYTICS; // 0b00101 = 5const PRO_TIER = FREE_TIER | AI_ASSIST | BETA_UI; // 0b01111 = 15const ENTERPRISE = PRO_TIER | EXPORT_PDF; // 0b11111 = 31 // Check if user has all features of a tierfunction hasTier(userFlags: number, tierFlags: number): boolean { return (userFlags & tierFlags) === tierFlags;} const proUser = PRO_TIER;console.log(`Pro user has free tier features? ${hasTier(proUser, FREE_TIER)}`); // true123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// Unix-style file permissions as bitmasksconst READ = 4; // 0b100const WRITE = 2; // 0b010const EXECUTE = 1; // 0b001 // Common permission combinationsconst READ_WRITE = READ | WRITE; // 6 = 0b110const READ_EXECUTE = READ | EXECUTE; // 5 = 0b101const ALL_PERMISSIONS = READ | WRITE | EXECUTE; // 7 = 0b111 // Represent user/group/other as three 3-bit fields// Format: [user:3][group:3][other:3]function makePermissions(user: number, group: number, other: number): number { return (user << 6) | (group << 3) | other;} // Common file permission patternsconst FILE_755 = makePermissions(ALL_PERMISSIONS, READ_EXECUTE, READ_EXECUTE);const FILE_644 = makePermissions(READ_WRITE, READ, READ);const FILE_600 = makePermissions(READ_WRITE, 0, 0); function permissionString(perm: number): string { const chars = [ (perm & 4) ? 'r' : '-', (perm & 2) ? 'w' : '-', (perm & 1) ? 'x' : '-', ]; return chars.join('');} function fullPermissionString(perms: number): string { const user = (perms >> 6) & 0b111; const group = (perms >> 3) & 0b111; const other = perms & 0b111; return permissionString(user) + permissionString(group) + permissionString(other);} console.log(`chmod 755: ${fullPermissionString(FILE_755)}`); // rwxr-xr-xconsole.log(`chmod 644: ${fullPermissionString(FILE_644)}`); // rw-r--r--console.log(`chmod 600: ${fullPermissionString(FILE_600)}`); // rw------- // Check access in O(1)function canUserWrite(perms: number): boolean { const userPerms = (perms >> 6) & 0b111; return (userPerms & WRITE) !== 0;} function canOthersRead(perms: number): boolean { const otherPerms = perms & 0b111; return (otherPerms & READ) !== 0;}Storing feature flags as a single integer column instead of multiple boolean columns saves space and enables efficient querying. A query like 'SELECT * FROM users WHERE (features & required_features) = required_features' checks multiple flags in one operation.
The subsets of a set form a beautiful mathematical structure called a lattice under the subset relation. This lattice has a direct correspondence to integer ordering and bit patterns.
Consider the power set of {a, b, c}. If we draw edges between sets where one is a subset of another (differing by exactly one element), we get a structure that reveals deep patterns:
12345678910111213141516171819202122232425
{a,b,c} = 7 = 111₂ / | \ {a,b} {a,c} {b,c} =6 =5 =3 110₂ 101₂ 011₂ / \ / \ / \ {a} {b} {c} =1 =2 =4 001₂ 010₂ 100₂ \ | / {} = 0 000₂ Key observations:- The empty set (0) is at the bottom - subset of everything- The full set (7) is at the top - superset of everything- Moving UP adds one element (sets one bit)- Moving DOWN removes one element (clears one bit)- Each level contains C(3,k) subsets of size k - Level 0: C(3,0) = 1 subset (the empty set) - Level 1: C(3,1) = 3 subsets (singletons) - Level 2: C(3,2) = 3 subsets (pairs) - Level 3: C(3,3) = 1 subset (the full set)- Total: 1 + 3 + 3 + 1 = 8 = 2³ subsetsThe lattice and bitmask operations:
This structure is called a Boolean lattice or power set lattice. It appears throughout computer science—in logic, database query optimization, and formal verification. Understanding it deepens your grasp of why bitmask operations work as they do.
We've established the foundational understanding of bitmask subset representation. Let's consolidate what we've learned:
What's next:
Now that we can represent subsets as integers, the next natural question is: how do we iterate through all subsets? Whether we need to enumerate all 2ⁿ subsets, iterate through subsets of a given set, or find subsets of a particular size, bitmasks provide elegant solutions. The next page explores these iteration patterns—essential techniques for brute-force algorithms and the foundation of bitmask dynamic programming.
You now understand how bitmasks elegantly represent subsets as integers, enabling O(1) set operations and compact storage. This mathematical correspondence between sets and binary numbers is the foundation for all bitmask techniques. Next, we'll explore how to systematically iterate through these subset representations.