Facebook Pixel

2992. Number of Self-Divisible Permutations 🔒

Problem Description

You are given an integer n. You need to find how many permutations of the array [1, 2, ..., n] are self-divisible.

A permutation is self-divisible when it satisfies a special property: for every position i (where positions are numbered from 1 to n), the greatest common divisor (GCD) of the element at position i and the position number i itself must equal 1. In mathematical terms, gcd(a[i], i) == 1 must hold for all positions.

For example, if we have n = 3, we need to check all 6 permutations of [1, 2, 3]:

  • [1, 2, 3]: Check if gcd(1,1)==1, gcd(2,2)==1, gcd(3,3)==1
  • [1, 3, 2]: Check if gcd(1,1)==1, gcd(3,2)==1, gcd(2,3)==1
  • [2, 1, 3]: Check if gcd(2,1)==1, gcd(1,2)==1, gcd(3,3)==1
  • [2, 3, 1]: Check if gcd(2,1)==1, gcd(3,2)==1, gcd(1,3)==1
  • [3, 1, 2]: Check if gcd(3,1)==1, gcd(1,2)==1, gcd(2,3)==1
  • [3, 2, 1]: Check if gcd(3,1)==1, gcd(2,2)==1, gcd(1,3)==1

The task is to count how many of these permutations satisfy the self-divisible property and return that count.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • No: The problem involves permutations of an array, not nodes and edges in a graph structure.

Need to solve for kth smallest/largest?

  • No: We're counting valid permutations, not finding a specific kth element.

Involves Linked Lists?

  • No: The problem deals with array permutations, not linked list operations.

Does the problem have small constraints?

  • Yes: Since we're dealing with permutations, the value of n must be reasonably small. Generating all permutations has factorial time complexity, which is only feasible for small values (typically n ≤ 20).

Brute force / Backtracking?

  • Yes: We need to explore different permutations by placing numbers at different positions, checking the GCD constraint at each step, and backtracking when a placement doesn't work.

Conclusion: The flowchart correctly leads us to use a Backtracking approach. We need to:

  1. Try placing each unused number at the current position
  2. Check if the placement satisfies the GCD constraint (gcd(number, position) == 1)
  3. If valid, recursively continue building the permutation
  4. Backtrack when we can't proceed or have completed a valid permutation
  5. Count all valid complete permutations

The solution uses state compression with bitmask to track which numbers have been used, combined with memoization to avoid recalculating the same states, making it an optimized backtracking approach.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is that we need to build permutations position by position, and at each position, we must ensure the GCD constraint is satisfied. This naturally suggests a backtracking approach where we try placing different numbers and undo choices that don't work.

Think of it like filling slots one by one. For position 1, we try placing any unused number that satisfies gcd(number, 1) == 1. For position 2, we try placing any remaining unused number that satisfies gcd(number, 2) == 1, and so on.

However, naive backtracking would repeatedly solve the same subproblems. For instance, if we've placed numbers {1, 3, 5} at positions {2, 4, 6}, the count of valid ways to fill the remaining positions is the same regardless of how we arrived at this state. This observation leads us to use memoization.

To implement memoization efficiently, we need a way to represent which numbers have been used. A bitmask is perfect for this - if bit j is set in our mask, it means number j has been used. The state mask uniquely identifies which numbers are available, and the current position we're filling is simply mask.bit_count() + 1 (the number of bits set plus one).

The recursive function dfs(mask) answers: "Given that we've already placed some numbers (represented by mask), how many valid ways are there to complete the permutation?" We start with dfs(0) (no numbers placed) and at each step:

  1. Determine the current position i we're filling
  2. Try each unused number j where gcd(i, j) == 1
  3. Recursively count valid completions after placing j at position i

This approach elegantly combines backtracking with dynamic programming through memoization, reducing the time complexity from factorial to something more manageable by avoiding redundant calculations.

Learn more about Math, Dynamic Programming, Backtracking and Bitmask patterns.

Solution Approach

The solution uses state compression with bitmask and memoization to efficiently count valid permutations.

State Representation:

  • We use a binary number mask where the j-th bit being 1 means number j has been used
  • For example, mask = 0b1010 means numbers 2 and 4 have been used (reading from right, 0-indexed)
  • The current position to fill is i = mask.bit_count() + 1 (number of used numbers plus 1)

Recursive Function Design: The function dfs(mask) returns the count of valid permutations that can be formed starting from the current state:

@cache
def dfs(mask: int) -> int:
    i = mask.bit_count() + 1  # Current position to fill
    if i > n:
        return 1  # Complete valid permutation found

Core Algorithm:

  1. Calculate the current position i based on how many numbers are already placed
  2. If i > n, we've successfully placed all numbers - return 1
  3. Otherwise, try placing each unused number j (from 1 to n):
    • Check if j is unused: (mask >> j & 1) == 0
    • Check if placement is valid: gcd(i, j) == 1
    • If both conditions met, recursively count: dfs(mask | 1 << j)

Bit Operations Explained:

  • mask >> j & 1: Checks if bit j is set (number j is used)
  • mask | 1 << j: Sets bit j to mark number j as used
  • mask.bit_count(): Counts how many bits are set (numbers used)

Example Walkthrough: For n = 3, starting with dfs(0):

  • Position 1: Try numbers 1, 2, 3
    • If placing 1: Check gcd(1, 1) == 1 ✓, recurse with mask = 0b10
    • If placing 2: Check gcd(1, 2) == 1 ✓, recurse with mask = 0b100
    • If placing 3: Check gcd(1, 3) == 1 ✓, recurse with mask = 0b1000

The @cache decorator ensures we never recalculate dfs(mask) for the same mask value, converting what would be factorial time complexity into approximately O(2^n × n) time with O(2^n) space for memoization.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's trace through n = 3 to see how the algorithm works.

Initial call: dfs(0) with mask = 0b000 (no numbers used)

Step 1: Position 1 (i = 1, mask = 0b000)

  • Try number 1: gcd(1,1) = 1 ✓ → Call dfs(0b010)
  • Try number 2: gcd(1,2) = 1 ✓ → Call dfs(0b100)
  • Try number 3: gcd(1,3) = 1 ✓ → Call dfs(0b1000)

Step 2a: Following path with 1 at position 1 (mask = 0b010)

  • Position 2 (i = 2), available numbers: {2, 3}
  • Try number 2: gcd(2,2) = 2 ✗ (skip)
  • Try number 3: gcd(2,3) = 1 ✓ → Call dfs(0b1010)

Step 3a: Continuing (mask = 0b1010)

  • Position 3 (i = 3), available numbers: {2}
  • Try number 2: gcd(3,2) = 1 ✓ → Call dfs(0b1110)
  • Returns 1 (all numbers placed: [1,3,2] is valid)

Step 2b: Following path with 2 at position 1 (mask = 0b100)

  • Position 2 (i = 2), available numbers: {1, 3}
  • Try number 1: gcd(2,1) = 1 ✓ → Call dfs(0b110)
  • Try number 3: gcd(2,3) = 1 ✓ → Call dfs(0b1100)

Step 3b: From mask = 0b110

  • Position 3 (i = 3), available numbers: {3}
  • Try number 3: gcd(3,3) = 3 ✗ (skip)
  • Returns 0 (no valid completion)

Step 3c: From mask = 0b1100

  • Position 3 (i = 3), available numbers: {1}
  • Try number 1: gcd(3,1) = 1 ✓ → Returns 1
  • Valid permutation found: [2,3,1]

The process continues for all branches. Key observation: when we encounter dfs(0b110) again from a different path, memoization returns the cached result (0) without recomputation.

Final valid permutations found: [1,3,2] and [2,3,1], so the answer is 2.

Solution Implementation

1from functools import cache
2from math import gcd
3
4class Solution:
5    def selfDivisiblePermutationCount(self, n: int) -> int:
6        @cache
7        def dfs(used_mask: int) -> int:
8            """
9            Recursively count valid permutations using dynamic programming.
10          
11            Args:
12                used_mask: Bitmask representing which numbers (1 to n) have been used
13          
14            Returns:
15                Number of valid permutations from current state
16            """
17            # Calculate current position (1-indexed) based on how many numbers are used
18            current_position = used_mask.bit_count() + 1
19          
20            # Base case: all positions filled successfully
21            if current_position > n:
22                return 1
23          
24            # Try placing each unused number at current position
25            valid_permutations = 0
26            for number in range(1, n + 1):
27                # Check if number is unused and coprime with current position
28                is_unused = (used_mask >> number & 1) == 0
29                is_coprime = gcd(current_position, number) == 1
30              
31                if is_unused and is_coprime:
32                    # Mark number as used and continue building permutation
33                    new_mask = used_mask | (1 << number)
34                    valid_permutations += dfs(new_mask)
35          
36            return valid_permutations
37      
38        # Start with empty permutation (no numbers used)
39        return dfs(0)
40
1class Solution {
2    private int n;
3    private Integer[] memo;  // Memoization array for dynamic programming
4
5    /**
6     * Counts the number of valid permutations of numbers 1 to n
7     * where for each position i, either i divides the number at position i
8     * or the number at position i divides i.
9     * 
10     * @param n The size of the permutation
11     * @return The count of valid self-divisible permutations
12     */
13    public int selfDivisiblePermutationCount(int n) {
14        this.n = n;
15        // Initialize memoization array with size 2^(n+1) to handle all possible bitmasks
16        // Each bit position represents whether a number has been used
17        memo = new Integer[1 << (n + 1)];
18      
19        // Start DFS with empty mask (no numbers used yet)
20        return dfs(0);
21    }
22
23    /**
24     * Depth-first search with memoization to count valid permutations
25     * 
26     * @param mask Bitmask representing which numbers have been used
27     * @return Number of valid permutations starting from this state
28     */
29    private int dfs(int mask) {
30        // Check if this state has already been computed
31        if (memo[mask] != null) {
32            return memo[mask];
33        }
34      
35        // Calculate current position (1-indexed) based on how many numbers are used
36        int currentPosition = Integer.bitCount(mask) + 1;
37      
38        // Base case: if all positions are filled, we found a valid permutation
39        if (currentPosition > n) {
40            return 1;
41        }
42      
43        // Initialize count for this state
44        memo[mask] = 0;
45      
46        // Try placing each unused number at the current position
47        for (int number = 1; number <= n; number++) {
48            // Check if the number hasn't been used yet
49            boolean numberNotUsed = (mask >> number & 1) == 0;
50          
51            // Check if the divisibility condition is satisfied
52            boolean isDivisible = (currentPosition % number == 0) || (number % currentPosition == 0);
53          
54            if (numberNotUsed && isDivisible) {
55                // Recursively count permutations with this number placed at current position
56                memo[mask] += dfs(mask | (1 << number));
57            }
58        }
59      
60        return memo[mask];
61    }
62}
63
1class Solution {
2public:
3    int selfDivisiblePermutationCount(int n) {
4        // dp[mask] stores the count of valid permutations for the given mask
5        // mask represents which numbers have been used (bit i = 1 means number i is used)
6        int dp[1 << (n + 1)];
7        memset(dp, -1, sizeof(dp));
8      
9        // DFS with memoization to count valid permutations
10        function<int(int)> dfs = [&](int mask) {
11            // Return cached result if already computed
12            if (dp[mask] != -1) {
13                return dp[mask];
14            }
15          
16            // Calculate the current position in the permutation (1-indexed)
17            int currentPosition = __builtin_popcount(mask) + 1;
18          
19            // Base case: all numbers have been placed
20            if (currentPosition > n) {
21                return 1;
22            }
23          
24            // Initialize count for current state
25            dp[mask] = 0;
26          
27            // Try placing each unused number at the current position
28            for (int number = 1; number <= n; ++number) {
29                // Check if the number is not used and satisfies the divisibility condition
30                // Condition: either position divides number OR number divides position
31                if ((mask >> number & 1) == 0 && 
32                    (currentPosition % number == 0 || number % currentPosition == 0)) {
33                    // Add the count of valid permutations after placing this number
34                    dp[mask] += dfs(mask | (1 << number));
35                }
36            }
37          
38            return dp[mask];
39        };
40      
41        // Start DFS with empty mask (no numbers used)
42        return dfs(0);
43    }
44};
45
1/**
2 * Counts the number of valid permutations of numbers 1 to n where each number
3 * at position i satisfies: either i divides the number or the number divides i
4 * @param n - The upper bound of the permutation range (1 to n)
5 * @returns The count of valid self-divisible permutations
6 */
7function selfDivisiblePermutationCount(n: number): number {
8    // Memoization array for dynamic programming
9    // Size is 2^(n+1) to accommodate bitmask states for numbers 1 to n
10    const memo: number[] = Array(1 << (n + 1)).fill(-1);
11  
12    /**
13     * Depth-first search with memoization to count valid permutations
14     * @param mask - Bitmask representing which numbers have been used
15     * @returns Number of valid permutations from current state
16     */
17    const dfs = (mask: number): number => {
18        // Return memoized result if already computed
19        if (memo[mask] !== -1) {
20            return memo[mask];
21        }
22      
23        // Current position in the permutation (1-indexed)
24        const currentPosition = bitCount(mask) + 1;
25      
26        // Base case: all positions filled, found valid permutation
27        if (currentPosition > n) {
28            return 1;
29        }
30      
31        // Initialize count for current state
32        memo[mask] = 0;
33      
34        // Try placing each unused number at current position
35        for (let number = 1; number <= n; ++number) {
36            // Check if number is unused and satisfies divisibility condition
37            const isNumberUnused = ((mask >> number) & 1) === 0;
38            const satisfiesDivisibility = (currentPosition % number === 0) || (number % currentPosition === 0);
39          
40            if (isNumberUnused && satisfiesDivisibility) {
41                // Recursively count permutations with this number placed
42                memo[mask] += dfs(mask | (1 << number));
43            }
44        }
45      
46        return memo[mask];
47    };
48  
49    // Start DFS with empty mask (no numbers used)
50    return dfs(0);
51}
52
53/**
54 * Counts the number of set bits (1s) in the binary representation of a number
55 * Uses bit manipulation tricks for efficient counting
56 * @param i - The input number
57 * @returns The count of set bits
58 */
59function bitCount(i: number): number {
60    // Count bits in groups of 2
61    i = i - ((i >>> 1) & 0x55555555);
62    // Count bits in groups of 4
63    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
64    // Count bits in groups of 8
65    i = (i + (i >>> 4)) & 0x0f0f0f0f;
66    // Count bits in groups of 16
67    i = i + (i >>> 8);
68    // Count bits in groups of 32
69    i = i + (i >>> 16);
70    // Return final count (maximum 32 bits, so mask with 0x3f)
71    return i & 0x3f;
72}
73

Time and Space Complexity

Time Complexity: O(n × 2^n)

The algorithm uses dynamic programming with memoization through a bitmask approach. The mask parameter represents which numbers have been used in the permutation so far, with at most 2^n possible states (each of the n numbers can be either used or not used). For each state represented by the mask, we iterate through all n numbers to find valid candidates for the next position. Therefore, the total time complexity is O(n × 2^n).

Space Complexity: O(2^n)

The space complexity comes from two sources:

  1. The memoization cache stores results for each unique mask state, which can have at most 2^n different values
  2. The recursion stack depth is at most n (when building a complete permutation), contributing O(n) to space

Since 2^n dominates n for large values, the overall space complexity is O(2^n).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Incorrect Bit Indexing for Number Representation

The Pitfall: The most common mistake is confusion about how numbers are represented in the bitmask. The code uses bit position j to represent number j, which means:

  • Bit 0 represents nothing (unused)
  • Bit 1 represents number 1
  • Bit 2 represents number 2
  • And so on...

Many developers incorrectly use 0-indexed bit positions (bit 0 for number 1, bit 1 for number 2), leading to wrong results.

Wrong Implementation:

# INCORRECT: Using 0-indexed bits
for number in range(1, n + 1):
    bit_position = number - 1  # Wrong indexing!
    is_unused = (used_mask >> bit_position & 1) == 0
    if is_unused and gcd(current_position, number) == 1:
        new_mask = used_mask | (1 << bit_position)
        valid_permutations += dfs(new_mask)

Correct Solution: Keep the bit position the same as the number value. This makes the logic clearer and avoids off-by-one errors:

# CORRECT: Bit j represents number j
for number in range(1, n + 1):
    is_unused = (used_mask >> number & 1) == 0  # Bit position = number
    if is_unused and gcd(current_position, number) == 1:
        new_mask = used_mask | (1 << number)
        valid_permutations += dfs(new_mask)

2. GCD Argument Order Confusion

The Pitfall: While gcd(a, b) == gcd(b, a) mathematically, developers might confuse which argument represents the position and which represents the value, leading to logic errors when debugging or modifying the code.

Potential Confusion:

# Both work but can cause confusion about what's being checked
gcd(number, current_position) == 1  # Value first, position second
gcd(current_position, number) == 1  # Position first, value second

Best Practice: Be consistent and document your choice. The solution uses gcd(current_position, number) to match the problem statement's gcd(a[i], i) format where position comes second conceptually but we're checking if position and value are coprime.

3. Memory Optimization Oversight

The Pitfall: For larger values of n, the cache can grow to 2^n states. Without considering memory limits, this approach fails for n > 20 due to memory constraints.

Solution for Larger n: If you need to handle larger values, consider:

from functools import lru_cache

class Solution:
    def selfDivisiblePermutationCount(self, n: int) -> int:
        @lru_cache(maxsize=100000)  # Limit cache size
        def dfs(used_mask: int) -> int:
            # ... rest of the code

Or use iterative DP with careful state management to control memory usage explicitly.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of the following is a min heap?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More