DSA DSA In Depth
0%
lesson combinatoricspermutationsgray-code

Combinatorial Generation: Subsets, Lexicographic Permutations (Narayana) & Gray Codes

7 min read Module 6.1: Recursion, Backtracking, Pruning & Greedy Exchange Arguments Updated Oct 23, 2026
📄 Cheat Sheet
Generate All Permutations — Backtracking State Space Tree
Back To Back SWE Generate All Permutations — Backtracking State Space Tree Watch on YouTube ↗

Covers iterative and recursive generation of combinatorial structures, amortized transition costs, and successor algorithms.

0:00 / --:--

1. In-Place State Machines vs. Recursive Materialization

When enumerating $2^n$ subsets or $n!$ permutations, materializing all states into a list requires $\Omega(n \cdot 2^n)$ or $\Omega(n \cdot n!)$ memory. For $n = 13$, $13! = 6{,}227{,}020{,}800$, which exceeds 75 GB of RAM. Instead, production combinatorial generators maintain a single mutable state vector $A[0..n-1]$ and implement an in-place next() transition that mutates $A$ in $O(1)$ amortized time.

Core Invariant — Narayana Pandita's Lexicographic Successor (14th Century / C++ std::next_permutation)

To find the smallest permutation strictly greater than $A[0..n-1]$ in lexicographic order, we must keep the longest possible prefix unchanged. Scanning from right to left, the longest non-increasing suffix $A[i+1..n-1]$ is already maximal for its elements and cannot be incremented on its own. Thus, the pivot $A[i]$ (the rightmost index where $A[i] < A[i+1]$) is the exact rightmost position that can increase. Swapping $A[i]$ with the smallest element $A[j]$ in the suffix satisfying $A[j] > A[i]$ preserves the non-increasing order of the suffix; reversing $A[i+1..n-1]$ turns that suffix into its minimal (sorted ascending) configuration.

Why is std::next_permutation $O(1)$ amortized per permutation? In half of all $n!$ states, $A[n-2] < A[n-1]$, so the algorithm compares 1 pair and swaps 2 elements. In $1/k!$ of states, the suffix of length $k$ is reversed, doing $O(k)$ work. Summing over all $k$ gives $\sum_{k=2}^{n} \frac{c \cdot k}{k!} = O(1)$ amortized comparisons and swaps per call.

2. Reflected Binary Gray Code & Gosper's Hack

Standard binary counting from $0$ to $2^n - 1$ flips up to $n$ bits on a single increment (e.g., $0111_2 \to 1000_2$), which means maintaining a running subset property (such as a knapsack weight or XOR sum) takes $O(1)$ amortized work only if we inspect the changed bits, or $O(1)$ worst-case work if we use a Binary Reflected Gray Code (BRGC).

  • Closed-Form $i$-th Gray Code: $G(i) = i \oplus (i \gg 1)$.
  • Bit Flipped at Step $i \to i+1$: The index of the single bit that changes between $G(i)$ and $G(i+1)$ is the number of trailing zeros of $i+1$, i.e., __builtin_ctz(i + 1).
  • Inverse Gray Code ($G(i) \to i$): Repeatedly XOR with shifted versions: for (uint64_t mask = g >> 1; mask; mask >>= 1) g ^= mask; (or in $\log_2(64) = 6$ parallel prefix XOR shifts).
  • Gosper's Hack ($inom{n}{k}$ Fixed-Popcount Subsets): Given a bitmask $x > 0$ with exactly $k$ set bits, the lexicographically next integer with $k$ set bits is computed in 4 ALU ops without branching.
flowchart LR subgraph Narayana["Narayana Next Permutation on [1, 3, 5, 4, 2]"] S1["1. Suffix [5, 4, 2] is non-increasing
Pivot i=1 (A[1] = 3)"] --> S2["2. Rightmost A[j] > 3 in suffix
is j=3 (A[3] = 4)"] S2 --> S3["3. Swap A[1], A[3]
-> [1, 4, 5, 3, 2]"] S3 --> S4["4. Reverse suffix A[2..4]
-> [1, 4, 2, 3, 5]"] end

Hardware Reality — BMI1/BMI2 Bit Manipulation vs. Branch Mispredictions

On modern x86-64 (Haswell+) and ARM64 CPUs, Gosper's hack compiles to BLSI ($c = x \mathbin{\&} -x$, 1 cycle), ADD ($r = x + c$, 1 cycle), XOR, and TZCNT (trailing zero count, 3 cycles latency, 1 cycle throughput)—zero conditional branches inside the inner loop! In contrast, a recursive $\binom{n}{k}$ generator pushes stack frames, spills registers, and incurs branch predictor misses when backtracking from base cases, running $8\times$–$15\times$ slower than a flat Gosper register loop.

3. Complexity Matrix

N-Queens Bitmask Pruning & Knuth Dancing Links (DLX)
O(1) Bitmask / Exact Cover

Step through O(1) bitmask attack propagation (cols, ld, rd) for N-Queens, Knuth Algorithm X circular doubly-linked cover/uncover, and Meet-in-the-Middle subset search.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

Row 0 Queen at c=1 sets cols=0100₂, ld=1000₂, rd=0010₂. Row 1 avail = ~(cols|ld|rd) & 1111₂ = 0001₂ (only col 3 safe).
♛✕✕✕
cols Mask
0100₂
ld / rd Masks
1000₂ / 0010₂
Next Row Avail
0001₂ (c=3)
[Step 1] Placed Queen at (0, 1); bitwise OR prunes 3 of 4 columns on Row 1 in O(1).
Generation Algorithm Single Step (Worst) Single Step (Amortized) Total Time (All States) Auxiliary Space
Narayana next_permutation O(n) O(1) O(n!) O(1)
Heap's Algorithm (Single-Swap Permutations) O(1) O(1) O(n!) O(n)
Binary Reflected Gray Code ($G(i) = i \oplus (i \gg 1)$) O(1) O(1) O(2^n) O(1)
Gosper's Hack ($k$-Element Subsets of $n$) O(1) O(1) O(C(n, k)) O(1)

4. Reference C++20 Implementation

#include <algorithm>
#include <cstdint>
#include <vector>

// 1. Narayana's Lexicographic Next Permutation (handles duplicates seamlessly!)
template <typename T>
bool next_permutation_narayana(std::vector<T>& a) {
    const int n = static_cast<int>(a.size());
    int i = n - 2;
    // Strict '<' ensures duplicate elements are never redundantly swapped
    while (i >= 0 && !(a[i] < a[i + 1])) --i;
    if (i < 0) {
        std::reverse(a.begin(), a.end());
        return false; // Wrapped around to lexicographically smallest state
    }
    int j = n - 1;
    while (!(a[i] < a[j])) --j;
    std::swap(a[i], a[j]);
    std::reverse(a.begin() + i + 1, a.end());
    return true;
}

// 2. Gosper's Hack: Iterate over all n-bit masks with exactly k set bits in O(1) per mask
template <typename Callback>
void enumerate_k_subsets_gosper(int n, int k, Callback&& visit) {
    if (k == 0) { visit(0ULL); return; }
    if (k > n) return;
    std::uint64_t mask = (1ULL << k) - 1;
    const std::uint64_t limit = (n == 64) ? ~0ULL : (1ULL << n);
    while (mask < limit) {
        visit(mask);
        std::uint64_t c = mask & -mask;              // Lowest set bit (BLSI)
        std::uint64_t r = mask + c;                  // Carry propagation across lowest 1-run
        if (r == 0) break;                           // Overflow guard when n = 64
        mask = (((r ^ mask) >> 2) / c) | r;          // Or: (((r ^ mask) >> (__builtin_ctzll(c) + 2)) | r)
    }
}

// 3. Gray Code Incremental Subset Sum Evaluator in O(2^n) total additions (not O(n 2^n))
long long count_subsets_with_target_gray(const std::vector<long long>& weights, long long target) {
    const int n = static_cast<int>(weights.size());
    long long current_sum = 0;
    long long matches = (current_sum == target) ? 1 : 0;
    std::uint64_t prev_gray = 0;
    const std::uint64_t total = 1ULL << n;

    for (std::uint64_t i = 1; i < total; ++i) {
        std::uint64_t curr_gray = i ^ (i >> 1);
        int bit = __builtin_ctzll(i); // Exactly bit index that flipped between prev_gray and curr_gray
        if ((curr_gray >> bit) & 1ULL) {
            current_sum += weights[bit];
        } else {
            current_sum -= weights[bit];
        }
        if (current_sum == target) ++matches;
        prev_gray = curr_gray;
    }
    return matches;
}

Production Pitfall — Forgetting to Sort Before std::next_permutation or Using <= on Duplicates

Two classic bugs plague permutation loops: First, std::next_permutation stops as soon as the array reaches descending order; if your initial array is {3, 1, 2} instead of std::sorted {1, 2, 3}, you will silently skip $2/3$ of the permutations! Second, when implementing Narayana's algorithm manually on multisets (e.g., {1, 2, 2, 3}), you must use strict inequality a[i] < a[i+1] and a[i] < a[j]; if you use <=, equal elements will swap in an infinite loop instead of generating the exact $\frac{n!}{c_1! c_2! \cdots c_m!}$ distinct multiset permutations.

Check yourself Question 1 of 4
Concept Check Narayana's Next Permutation Amortized Complexity recall

What are the worst-case single-call time complexity and the amortized time complexity per permutation when generating all $n!$ permutations in lexicographic order using Narayana's algorithm (`std::next_permutation`)?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

In Gosper's hack for advancing a bitmask `x` to the next lexicographic integer with the same popcount (`c = x & -x; r = x + c; next_x = (((r ^ x) >> 2) / c) | r;`), what is the exact role of the subexpression `((r ^ x) >> 2) / c`?

Key Takeaways

Narayana's next-permutation algorithm finds the rightmost ascent a[i] < a[i+1], swaps a[i] with the smallest suffix element > a[i], and reverses the suffix in O(1) amortized time.
Strict inequality in Narayana's comparisons automatically deduplicates multiset permutations in O(1) amortized time per distinct permutation.
Binary Reflected Gray Code G(i) = i ^ (i >> 1) flips exactly bit __builtin_ctz(i) at step i, shaving a factor of n off incremental subset evaluations.
Gosper's hack steps from one k-bit mask to the next lexicographic k-bit mask in 4 register operations with zero recursion.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue