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 ifgcd(1,1)==1
,gcd(2,2)==1
,gcd(3,3)==1
[1, 3, 2]
: Check ifgcd(1,1)==1
,gcd(3,2)==1
,gcd(2,3)==1
[2, 1, 3]
: Check ifgcd(2,1)==1
,gcd(1,2)==1
,gcd(3,3)==1
[2, 3, 1]
: Check ifgcd(2,1)==1
,gcd(3,2)==1
,gcd(1,3)==1
[3, 1, 2]
: Check ifgcd(3,1)==1
,gcd(1,2)==1
,gcd(2,3)==1
[3, 2, 1]
: Check ifgcd(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:
- Try placing each unused number at the current position
- Check if the placement satisfies the GCD constraint (
gcd(number, position) == 1
) - If valid, recursively continue building the permutation
- Backtrack when we can't proceed or have completed a valid permutation
- 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.
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:
- Determine the current position
i
we're filling - Try each unused number
j
wheregcd(i, j) == 1
- Recursively count valid completions after placing
j
at positioni
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 thej
-th bit being1
means numberj
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:
- Calculate the current position
i
based on how many numbers are already placed - If
i > n
, we've successfully placed all numbers - return 1 - 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)
- Check if
Bit Operations Explained:
mask >> j & 1
: Checks if bitj
is set (numberj
is used)mask | 1 << j
: Sets bitj
to mark numberj
as usedmask.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 withmask = 0b10
- If placing 2: Check
gcd(1, 2) == 1
✓, recurse withmask = 0b100
- If placing 3: Check
gcd(1, 3) == 1
✓, recurse withmask = 0b1000
- If placing 1: Check
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 EvaluatorExample 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
✓ → Calldfs(0b010)
- Try number 2:
gcd(1,2) = 1
✓ → Calldfs(0b100)
- Try number 3:
gcd(1,3) = 1
✓ → Calldfs(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
✓ → Calldfs(0b1010)
Step 3a: Continuing (mask = 0b1010
)
- Position 3 (
i = 3
), available numbers: {2} - Try number 2:
gcd(3,2) = 1
✓ → Calldfs(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
✓ → Calldfs(0b110)
- Try number 3:
gcd(2,3) = 1
✓ → Calldfs(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:
- The memoization cache stores results for each unique mask state, which can have at most
2^n
different values - The recursion stack depth is at most
n
(when building a complete permutation), contributingO(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.
Which of the following is a min heap?
Recommended Readings
Math for Technical Interviews How much math do I need to know for technical interviews The short answer is about high school level math Computer science is often associated with math and some universities even place their computer science department under the math faculty However the reality is that you
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Backtracking Template Prereq DFS with States problems dfs_with_states Combinatorial search problems Combinatorial search problems involve finding groupings and assignments of objects that satisfy certain conditions Finding all permutations combinations subsets and solving Sudoku are classic combinatorial problems The time complexity of combinatorial problems often grows rapidly with the size of
Want a Structured Path to Master System Design Too? Don’t Miss This!