Lab 12 — SOS DP (Sum over Subsets)
Goal
Compute F[mask] = Σ A[sub] over all sub ⊆ mask, for all masks simultaneously, in O(2ⁿ · n) — replacing the O(4ⁿ) all-pairs-of-masks or O(3ⁿ) subset-enumeration approaches. Apply it to LeetCode 982 (Triples with Bitwise AND Equal To Zero). After this lab you should write the forward transform, its Möbius inverse, and the 3ⁿ subset loop from memory, and recognize when each wins.
Background Concepts
The naive “for each mask, sum over its subsets” costs O(3ⁿ) (each of 2ⁿ masks enumerates its subsets; Σ over masks of 2^popcount = 3ⁿ). SOS DP does it in O(2ⁿ · n) by a dimension-by-dimension relaxation — the subset-sum (zeta) transform:
def sos(A, n):
F = A[:]
for i in range(n): # one bit-dimension at a time
bit = 1 << i
for mask in range(1 << n):
if mask & bit:
F[mask] += F[mask ^ bit]
return F
Interpretation as n-dimensional prefix sum. Think of a mask as a point in the hypercube {0,1}ⁿ. Dimension i sweeps the “prefix sum along axis i”: after processing bit i, F[mask] holds the sum over all sub-masks that agree with mask on bits > i (already processed) and are ≤ mask on bits ≤ i. After all n sweeps, F[mask] = sum over every sub ⊆ mask. It is exactly the standard multi-dimensional prefix-sum, one axis per bit, each axis of length 2.
The 3ⁿ subset-enumeration trick is the intermediate power tool:
sub = mask
while True:
# visit sub ⊆ mask
if sub == 0: break
sub = (sub - 1) & mask # next lower submask
Use 3ⁿ when you need only one mask’s subset sum, or when the per-subset work is non-additive. Use SOS’s 2ⁿ·n when you need the sums for all masks. Crossover: for n ≈ 20, 3ⁿ ≈ 3.5×10⁹ vs 2ⁿ·n ≈ 2×10⁷ — SOS wins by two orders of magnitude when all masks are needed.
Inverse (Möbius) transform. Undo the zeta by subtracting in the same sweep structure: F[mask] -= F[mask ^ bit]. Zeta ∘ Möbius = identity. This is the inclusion-exclusion inverse.
Superset sums are the mirror image: process the unset bit direction (F[mask] += F[mask | bit]), or equivalently run subset SOS on complemented indices.
Interview Context
SOS DP is niche but a hard differentiator: it appears in bitmask-heavy counting problems where a candidate’s first instinct is O(4ⁿ) pair enumeration. Recognizing “I need, for every mask, the aggregate over all its submasks” and reaching for the 2ⁿ·n transform is a senior/competitive signal. LeetCode has few pure hits — the honest framing is that SOS is a technique you fold into subset-DP problems (Maximum Students Taking Exam, Count Subtrees) and AND/OR pair-counting. LC 982 is the cleanest canonical showcase.
Problem Statement (LC 982)
Given an integer array nums, return the number of triples of indices (i, j, k) (each independently ranging over all indices, repetition allowed, order counted) such that nums[i] & nums[j] & nums[k] == 0, where & is bitwise AND.
Constraints
- 1 ≤ nums.length ≤ 1000.
- 0 ≤ nums[i] < 2¹⁶ (so masks live in a 16-bit universe,
2¹⁶ = 65536).
Clarifying Questions
- Are
i, j, kordered and independently chosen? (Yes —(0,1,2)and(2,1,0)are distinct;i = jis allowed.) - What’s the bit width? (Values < 2¹⁶, so a 16-bit mask universe —
U = 2¹⁶.) - Do we count value-triples or index-triples? (Index-triples — duplicates in
numseach count.) - Is the answer possibly large? (Up to
1000³ = 10⁹— fits in 64-bit, but note it in languages with 32-bit ints.) - Is
AND == 0a subset or a disjointness condition? (x & y & z == 0means the ANDed bits are a subset of the complement of… — see the reduction below.)
Examples
nums = [2, 1, 3] → 12
nums = [0, 0, 0] → 27 (every triple ANDs to 0)
Initial Brute Force
Triple nested loop over all (i, j, k), test nums[i] & nums[j] & nums[k] == 0, count. O(m³) with m = len(nums) — the oracle.
Brute Force Complexity
O(m³). At m = 1000 that is 10⁹ AND-and-compare operations — borderline-to-TLE in Python, and the pattern generalizes badly. A better structure: precompute a histogram of pairwise ANDs (pairs[v] = number of (i, j) with nums[i] & nums[j] == v) in O(m²), then for each k count pairs whose AND is disjoint from nums[k].
Optimization Path
The reduction: x & z == 0 ⟺ x ⊆ ~z (every set bit of x is unset in z). So nums[i] & nums[j] & nums[k] == 0 ⟺ (nums[i] & nums[j]) ⊆ ~nums[k]. Therefore:
- Build
pairs[v]= #{(i, j) : nums[i] & nums[j] == v} in O(m²). - SOS:
F[mask] = Σ_{v ⊆ mask} pairs[v]= number of pairs whose AND is a submask ofmask, for all masks in O(U · log U). - Answer =
Σ_k F[~nums[k] & (U−1)]— for eachk, count pairs disjoint fromnums[k].
Total O(m² + U log U) instead of O(m³). The SOS step is what turns “for each k, re-scan all pair-values” (another O(U) or O(m²)) into an O(1) table lookup per k.
Final Expected Approach
def sos(A, n):
"""F[mask] = Σ A[sub] over all sub ⊆ mask, all masks, in O(2ⁿ · n)."""
F = A[:]
for i in range(n):
bit = 1 << i
for mask in range(1 << n):
if mask & bit: # relax along bit-dimension i
F[mask] += F[mask ^ bit]
return F
def inverse_sos(F, n):
"""Möbius inverse: recover A from F = sos(A). Subtract in the same order."""
A = F[:]
for i in range(n):
bit = 1 << i
for mask in range(1 << n):
if mask & bit:
A[mask] -= A[mask ^ bit]
return A
def count_and_triples(nums):
"""LC 982: # ordered triples (i,j,k) with nums[i] & nums[j] & nums[k] == 0."""
B = 16
FULL = (1 << B) - 1
pairs = [0] * (1 << B)
for x in nums: # O(m²) pairwise-AND histogram
for y in nums:
pairs[x & y] += 1
F = sos(pairs, B) # F[m] = #pairs with AND ⊆ m
return sum(F[FULL ^ z] for z in nums) # AND & z == 0 ⟺ AND ⊆ ~z
# 3ⁿ single-mask subset sum (the intermediate power tool)
def subset_sum_of(A, mask):
total, sub = 0, mask
while True:
total += A[sub]
if sub == 0:
break
sub = (sub - 1) & mask # next lower submask of mask
return total
Data Structures Used
- A flat array
Fof length2ⁿindexed by bitmask — the transform is entirely in-place-ish over it. - The
pairs[]histogram of size2¹⁶for LC 982. - Bit operations:
1 << i,mask ^ bit,(sub − 1) & mask,~z & FULLfor the complement.
Correctness Argument
Loop invariant of the zeta transform. After processing dimensions 0..i−1, F[mask] equals Σ A[sub] over all sub that equal mask on bits ≥ i and are arbitrary (≤ mask) on bits < i. Base: before any dimension, F[mask] = A[mask] (only sub = mask). Step: when we process bit i for a mask with bit i set, F[mask] += F[mask ^ bit] fuses the “bit-i-set” family with the “bit-i-clear” family, both already summed over bits < i — extending the invariant to include bit i. After all n dimensions, the free set is all bits, i.e. every sub ⊆ mask. Each sub is added exactly once because each dimension merges disjoint families.
Möbius inverts zeta. Subtracting A[mask ^ bit] in the same sweep order peels off precisely the contribution the forward pass added, so inverse_sos(sos(A)) == A — the standard subset-lattice inclusion-exclusion pair.
LC 982 reduction. a & z == 0 ⟺ a ⊆ ~z. F[~z] sums pairs[a] over all a ⊆ ~z, i.e. counts pairs (i, j) whose AND is disjoint from z = nums[k]. Summing over k counts all valid ordered triples.
Complexity
| Step | Time | Space |
|---|---|---|
| Pair histogram (LC 982) | O(m²) | O(2ⁿ) |
| SOS forward / inverse | O(2ⁿ · n) | O(2ⁿ) |
| Answer aggregation | O(m) | O(1) |
| 3ⁿ single-mask subset sum | O(3ⁿ) total over all masks | O(1) |
Implementation Requirements
- Copy the input (
F = A[:]) — the transform mutates in place; don’t clobber the caller’s array unless intended. - Iterate dimensions outer, masks inner; swapping the loop order breaks the prefix-sum semantics.
- Only update
F[mask]whenmask & bit— skipping this doubles-adds. - For LC 982, use the full 16-bit universe even if
max(nums)is small; sizing tomax+1bits is fine but must cover every complement~z.
Tests
sos(A, n)vs the 3ⁿ subset-enumeration sum for every mask, randomA,n ∈ {3,4,5}.inverse_sos(sos(A, n), n) == Afor randomA.- LC 982:
[2,1,3] → 12,[0,0,0] → 27. - LC 982 vs O(m³) brute force on random arrays (values up to 2¹⁶ and small values), dozens of trials.
- Superset-sum variant: verify
F[mask] += F[mask | bit]gives Σ over supersets.
Follow-up Questions
- What are the zeta and Möbius transforms, and how do they connect to inclusion-exclusion? The subset-sum transform is the zeta transform on the subset lattice; its inverse is the Möbius transform, whose kernel is
(−1)^popcount(mask \ sub). Alternating-sign subset sums are inclusion-exclusion — the same machinery as Phase 12’s inclusion-exclusion lab, specialized to the boolean lattice where Möbius is just “subtract each dropped bit.” - How do you convolve over subsets —
C[mask] = Σ_{s∪t=mask, s∩t=∅} A[s]·B[t]? Subset-sum convolution: rank the arrays by popcount inton+1layers, zeta-transform each layer, multiply layer-wise with a popcount-carrying convolution, then Möbius back — O(2ⁿ · n²). It fixes the double-counting that a plain OR-convolution would introduce. - What is OR-convolution vs AND-convolution vs XOR-convolution? OR-convolution = zeta (subset SOS) → pointwise multiply → Möbius. AND-convolution is the same with the superset transform. XOR-convolution needs the Walsh–Hadamard transform instead of zeta (cross-reference Phase 12 lab 07’s follow-up) — a different butterfly, but the same “transform, pointwise-multiply, inverse-transform” pipeline.
- When does 3ⁿ beat 2ⁿ·n? When you need submask sums for only a few masks, or when the per-subset operation isn’t additive (so the incremental zeta relaxation doesn’t apply). For all-masks additive aggregation, 2ⁿ·n dominates.
- How does SOS help other bitmask DPs? Precompute a table of “best value achievable by any submask of
mask” via a max-flavored SOS (replace+=with amax), then answer meet-in-the-middle or set-cover pruning queries in O(1). The transform generalizes to any commutative-monoid aggregate (sum, max, min, count). - How do you get superset sums instead of subset sums? Flip the merge direction:
if not (mask & bit): F[mask] += F[mask | bit], or run subset SOS on the bit-complemented index space. Useful for AND-based counting where you want “supersets of a given mask.”
Product Extension
Feature-flag and permission systems: given a value assigned to each exact capability set, SOS answers “total value across all subsets of these permissions” in one pass — e.g. cohort counts over every attribute combination for analytics roll-ups. Ad/recommendation systems that bucket users by a bitmask of interests use subset/superset sums to answer “how many users have at least these interests” (superset) or “value summed over all interest subsets” (subset) without re-scanning. Any OLAP-style cube aggregation over a small set of boolean dimensions is an n-dimensional prefix sum — literally SOS.
Language/Runtime Follow-ups
- Python: the tight
2ⁿ·nloop is slow in pure Python; for n = 20 prefernumpyvectorization per dimension (F[mask_has_bit] += F[mask_has_bit ^ bit]via boolean indexing) or accept the constant factor. Copy withA[:]. - Java/C++: trivial
for (i) for (mask)withint[]/vector<long long>; watch overflow — LC 982 counts reach 10⁹, uselong/long long. - C++: this is the canonical competitive form;
for(int i=0;i<n;i++) for(int m=0;m<(1<<n);m++) if(m>>i&1) F[m]+=F[m^1<<i];. - Go:
int64slice, same double loop; no generics needed. - JS/TS:
Int32Array/Float64Arrayof length2ⁿ; useNumberup to 2⁵³,BigIntonly if counts overflow.
Common Bugs
- Loop order swapped: iterating masks outer and dimensions inner computes garbage — the zeta relaxation requires each bit-dimension to fully sweep before the next.
- Missing the
mask & bitguard: updating every mask (not just those with bitiset) double-adds and inflates sums. - In-place clobber: transforming the caller’s array without copying corrupts data the caller still needs (e.g. reusing
pairsaftersos). - Wrong universe size: sizing
Ftomax(nums)+1bits but then indexing~z(which can set high bits) reads out of range or misses pairs. Mask complements with& FULLand size for the full bit width. - Integer overflow: LC 982’s answer approaches
m³ = 10⁹; a 32-bit accumulator overflows in Java/C/Go. - Confusing subset and superset direction: using
F[mask ^ bit](subset) when the problem wants supersets (F[mask | bit]) inverts the meaning of the aggregate.
Debugging Strategy
Validate sos against the 3ⁿ subset loop on tiny n first — if they disagree, the transform itself is wrong, independent of the problem. Then check inverse_sos(sos(A)) == A to confirm the Möbius direction. For LC 982, run the O(m³) oracle on random small arrays: a mismatch localizes to either the pairs histogram (O(m²) step) or the complement lookup F[FULL ^ z]. Print F for a 2-bit universe by hand — F[0b11] should equal A[0]+A[1]+A[2]+A[3] — to catch loop-order and guard bugs instantly.
Mastery Criteria
- Wrote the forward SOS transform from memory in <5 minutes.
- Wrote the Möbius inverse and the 3ⁿ subset loop cold.
- Explained SOS as an n-dimensional prefix sum with the loop invariant.
- Solved LC 982 via the AND-to-submask reduction and SOS in <20 minutes.
- Stated the crossover between 3ⁿ and 2ⁿ·n and picked correctly.
- Connected zeta/Möbius to inclusion-exclusion and named the OR/AND/XOR-convolution pipeline (WHT for XOR).