526. Beautiful Arrangement
Problem Description
The problem presents the concept of a beautiful arrangement, which is a permutation of n
integers, where each integer is labeled from 1
through n
. To be considered beautiful, the arrangement must satisfy a condition for each element perm[i]
: either perm[i]
is divisible by its position i
or the position i
must be divisible by the value of perm[i]
. The objective is to find out how many such beautiful arrangements can be made for a given integer n
.
In simpler terms, if we arrange numbers 1
to n
in a certain order, for each spot i
in the order, the number that goes in spot i
must be such that i
is divisible by that number or vice versa. The task is to count all the possible ways (permutations) we can arrange the numbers satisfying the above condition for each number and its position.
Flowchart Walkthrough
Let's analyze LeetCode problem 526, Beautiful Arrangement, using the Flowchart. Here's the step-by-step process to determine the best algorithm:
-
Is it a graph?
- No: The problem deals with arranging numbers, not graph structures.
-
Need to solve for kth smallest/largest?
- No: The task is not about finding a kth element but arranging numbers to meet certain conditions.
-
Involves Linked Lists?
- No: The problem is related to numbers and their arrangement, not data structures like linked lists.
-
Does the problem have small constraints?
- Yes: The number n is at most 15, which is a relatively small number allowing brute force or backtracking solutions.
-
Brute force / Backtracking?
- Yes: Given the constraints and the need to explore different arrangements to meet specific conditions, brute force or backtracking is suitable.
Conclusion: Following the analysis with the flowchart, it's appropriate to use a backtracking approach for problem 526, Beautiful Arrangement, considering the permutation of numbers and meeting specific divisibility conditions aligns well with the typical use of backtracking to explore all possible configurations.
Intuition
To address this problem, we can use a Depth-First Search (DFS) approach to generate all possible permutations and check which of them qualifies as a beautiful arrangement. However, instead of generating all permutations and then checking the condition for each (which would be time-consuming and inefficient), we can incorporate the given condition into the process of generating permutations. This optimizes the search, as we only recurse further into permutations that are potentially beautiful.
The solution involves the following steps:
- Start by creating a list of possible candidates (
match
) for each positioni
in the arrangement that fulfill the given divisibility condition. - Use recursive DFS to attempt to construct a beautiful arrangement beginning with position
1
and moving forward. - Maintain an array (
vis
) that keeps track of which values have been used in the current partial arrangement to avoid using any number more than once. - At each level of recursion, iterate over all viable candidates for the current position
i
, marking them as used (invis
), and calling DFS for the next positioni + 1
. - If we reach a position beyond
n
(i == n + 1
), it means we've found a full arrangement that satisfies the conditions, so we increment the result counter (ans
). - After each DFS call, we backtrack by unmarking the currently tested value as unused (to consider it for other positions in further iterations).
This approach efficiently explores only potential solutions and counts the number of valid beautiful arrangements, rather than generating all permutations and filtering them afterward.
Learn more about Dynamic Programming, Backtracking and Bitmask patterns.
Solution Approach
The solution uses a recursive function dfs
to explore all possible arrangements while adhering to the constraints of the problem. Here is a breakdown of the implementation, including algorithms, data structures, and design patterns used:
-
Depth-First Search (DFS): DFS is a standard algorithm used to traverse all possible paths in a tree or graph-like structure. This is ideal for our case because we want to explore all permutations that satisfy the conditions for a beautiful arrangement. The
dfs
function represents a node in the DFS tree, where each level of recursion corresponds to making a choice for a specific position in the permutation. -
Backtracking: This pattern allows us to undo choices that don’t lead to a solution and explore other options. The backtracking occurs when we mark a number as visited (
vis[j] = True
), recurse, and then unmark it after the recursive call (vis[j] = False
). This ensures numbers are freed up for subsequent recursive calls at other tree nodes (positions in the permutation). -
Boolean Visited Array (
vis
): An array of boolean values to keep track of which numbers from1
ton
have been used in the current partial arrangement. This is crucial because we cannot repeat a number in the permutation. -
Pre-computation of Viable Candidates (
match
): A list of lists (or a dictionary of lists, if we use Python’sdefaultdict
) is prepared before the DFS begins. Each indexi
contains a list of numbers thati
can be divisible by or that can dividei
. This pre-computation optimizes our search because we do not have to calculate the divisibility each time we want to put a number in positioni
. -
Result Counter (
ans
): A counter to keep track of the total number of beautiful arrangements found so far. We use a nonlocal variable to allow the nesteddfs
function to modify the outer scope'sans
variable.
The dfs
function starts by checking if we have completed an arrangement (i.e., i == n + 1
), in which case we increment ans
. Otherwise, we iterate over all numbers that could fit in position i
(array match[i]
). If a number hasn't been used yet (not vis[j]
), we mark it as used and call dfs(i + 1)
to attempt to place a number at position i + 1
.
Finally, dfs(1)
initiates our recursive search starting at the first position in the arrangement, and return ans
passes back the total count of beautiful arrangements after the search is complete.
By combining a clever DFS that only explores valid paths, with backtracking and a pre-computation of viable candidates, we can solve the problem efficiently.
Ready to land your dream job?
Unlock your dream job with a 2-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's consider a small example where n = 3
to illustrate the solution approach. Our task is to count the number of beautiful arrangements possible for the numbers 1, 2, and 3.
First, we precompute the list match
containing all the candidates for each position that can divide the position or be divided by it:
- For position 1: 1 (since every number is divisible by 1)
- For position 2: 1, 2 (since 2 is divisible by 1 and 2, and 1 is divisible by 1)
- For position 3: 1, 3 (since 3 is divisible by 1 and 3, and 1 is divisible by 1)
This gives us match[1] = [1]
, match[2] = [1, 2]
, and match[3] = [1, 3]
.
Now, we start our DFS with the first position (i=1). Since the only candidate for the first position is 1 (based on match[1]
), we place 1 in the first position and mark it as visited.
Next, we move on to the second position (i=2). The candidates for the second position are 1 and 2, but since 1 is already used, we can only place 2 in the second position. We mark 2 as visited and proceed.
Now, we are at the third position (i=3). The candidates are 1 and 3; 1 is used, so we place 3 in the third position, marking it as visited.
We've now reached i = n + 1
(i = 4 in this case), which means we've found a complete arrangement that satisfies the beautiful arrangement condition. The permutation [1, 2, 3]
is beautiful as:
- 1 is divisible by its position 1
- 2 is divisible by its position 2
- 3 is divisible by its position 3
We increment the result counter ans
. We then backtrack to explore other possibilities, but here, given n = 3
, there's no need to backtrack since all the numbers are used.
During the actual process, we would continue this backtracking process, checking all permutations that satisfy the conditions, and each time we complete an arrangement, we would increment ans
.
Finally, starting dfs(1)
would explore these permutations, and at the end, return ans
would hold the total count of beautiful arrangements. In this case, there is only one beautiful arrangement for n = 3
: [1, 2, 3]
, so ans = 1
.
Solution Implementation
1from collections import defaultdict
2
3class Solution:
4 def countArrangement(self, n: int) -> int:
5 # Helper function for the depth-first search algorithm.
6 def dfs(position):
7 nonlocal count, n
8 # If the position is out of range, a valid arrangement is found.
9 if position == n + 1:
10 count += 1
11 return
12 # Try possible numbers for the current position.
13 for number in matches[position]:
14 # Check if the number is not visited.
15 if not visited[number]:
16 # Mark the number as visited.
17 visited[number] = True
18 # Recurse for the next position.
19 dfs(position + 1)
20 # Backtrack, unmark the number as visited for future arrangements.
21 visited[number] = False
22
23 # Initial count of beautiful arrangements.
24 count = 0
25 # List to check if a number is already used in the arrangement.
26 visited = [False] * (n + 1)
27 # Dictionary to store all matching numbers for each position.
28 matches = defaultdict(list)
29 # Populate the matches dictionary with all possible numbers for each position.
30 for i in range(1, n + 1):
31 for j in range(1, n + 1):
32 # Numbers are a match if they are divisible by each other.
33 if j % i == 0 or i % j == 0:
34 matches[i].append(j)
35
36 # Start the depth-first search from position 1.
37 dfs(1)
38 # Return the total count of beautiful arrangements.
39 return count
40
41# The Solution can be instantiated and the method can be called as follows:
42# solution = Solution()
43# beautiful_arrangements_count = solution.countArrangement(n)
44
1class Solution {
2 public int countArrangement(int n) {
3 // Calculate the number of possible states (2^n)
4 int maxState = 1 << n;
5 // Initialize the array to store the count of valid permutations for each state
6 int[] dp = new int[maxState];
7 // Base case: there's one way to arrange an empty set
8 dp[0] = 1;
9
10 // Iterate through all states from the empty set to the full set
11 for (int i = 0; i < maxState; ++i) {
12 // Count the number of elements in the current set (state)
13 int count = 1;
14 for (int j = 0; j < n; ++j) {
15 count += (i >> j) & 1;
16 }
17
18 // Try to add each element from 1 to N into the current set (state)
19 for (int j = 1; j <= n; ++j) {
20 // We can only add element 'j' if it's not already present in the set (state)
21 // and the position 'count' is divisible by 'j' or vice versa
22 if (((i >> (j - 1) & 1) == 0) && (count % j == 0 || j % count == 0)) {
23 // Update the dp value for the state that results from adding 'j' to the current set (state)
24 dp[i | (1 << (j - 1))] += dp[i];
25 }
26 }
27 }
28
29 // The answer is the number of valid permutations for the full set (state)
30 return dp[maxState - 1];
31 }
32}
33
1class Solution {
2public:
3 int N; // Number of positions (and also numbers to arrange)
4 int answer; // The count of valid arrangements
5 vector<bool> visited; // Used to check if a number has been used in the arrangement
6 unordered_map<int, vector<int>> validMatches; // Map containing numbers which can be placed at index i + 1
7
8 // Method to start counting the number of valid beautiful arrangements
9 int countArrangement(int N) {
10 this->N = N;
11 answer = 0;
12 visited.resize(N + 1, false);
13
14 // Precompute the valid matches for all positions 1 to N.
15 for (int i = 1; i <= N; ++i) {
16 for (int j = 1; j <= N; ++j) {
17 if (i % j == 0 || j % i == 0) {
18 validMatches[i].push_back(j);
19 }
20 }
21 }
22
23 // Begin the depth-first search from the first position.
24 depthFirstSearch(1);
25 return answer;
26 }
27
28 // Recursive method used to perform depth-first search for beautiful arrangements
29 void depthFirstSearch(int index) {
30 // If the index is out of bounds, we've found a valid arrangement.
31 if (index == N + 1) {
32 ++answer;
33 return;
34 }
35
36 // Loop through all numbers which are valid to place at the current position
37 for (int num : validMatches[index]) {
38 // Make sure the number hasn't been used yet.
39 if (!visited[num]) {
40 visited[num] = true; // Mark the number as used
41 depthFirstSearch(index + 1); // Continue search for the next position.
42 visited[num] = false; // Backtrack: unmark the number to make it available again
43 }
44 }
45 }
46};
47
1// Function to count the number of beautiful arrangements that can be formed using the numbers 1 to n
2function countArrangement(n: number): number {
3 // 'visited' array to keep track of which numbers have been used in the arrangement
4 const visited = new Array(n + 1).fill(false);
5
6 // 'matches' array where each index i contains an array of numbers that are compatible with i
7 // in terms of the beautiful arrangement rules (either is a multiple of i or i is a multiple of it)
8 const matches = Array.from({ length: n + 1 }, () => new Array<number>());
9
10 // Fill the 'matches' array with appropriate values by checking the compatibility condition
11 for (let i = 1; i <= n; i++) {
12 for (let j = 1; j <= n; j++) {
13 if (i % j === 0 || j % i === 0) {
14 matches[i].push(j);
15 }
16 }
17 }
18
19 // Variable to store the total number of beautiful arrangements found
20 let count = 0;
21
22 // Depth-first search function to explore possible arrangements
23 const dfs = (position: number) => {
24 // If position is beyond the last index, a valid arrangement has been found
25 if (position === n + 1) {
26 count++;
27 return;
28 }
29 // Iterate through each number that matches the current position in the arrangement
30 for (const number of matches[position]) {
31 // If the number has not yet been used in this arrangement
32 if (!visited[number]) {
33 // Mark the number as used
34 visited[number] = true;
35 // Continue building the arrangement from the next position
36 dfs(position + 1);
37 // Backtrack: unmark the number as used to explore other arrangements
38 visited[number] = false;
39 }
40 }
41 };
42
43 // Start the DFS process from position 1
44 dfs(1);
45
46 // Return the total count of beautiful arrangements found
47 return count;
48}
49
Time and Space Complexity
Time Complexity
The time complexity of the given code can be analyzed as follows:
- The code uses a depth-first search (DFS) strategy to generate all possible permutations of numbers from
1 to n
. - In the worst case, each call to
dfs
function generatesn-i
subsequent calls (wherei
is the current depth of the search), because it iterates through thematch[i]
list which can have up ton-i
valid candidates for thei-th
position. - However, for each
i
, the size ofmatch[i]
is not the same, and each match requires bothi
andj
to be divisible by one another, so it’s not alwaysn-i
. - Since the permutations are unique, every subsequent call to the
dfs
function will have one less option to choose from. - Therefore, the time complexity has an upper bound of
O(n!)
, as thedfs
will have to explore all possible permutations in the worst case, but due to the divisibility condition, it is likely to be significantly less in practice. Hence, the strict mathematical representation is elusive but significantly better thanO(n!)
in many cases.
Space Complexity
Regarding the space complexity:
vis
array is of sizen + 1
, which occupiesO(n)
space.ans
is a single integer, so it occupiesO(1)
space.match
is a dictionary where each key has a list, and in the worst case, every list could have up ton
items, making space taken bymatch
up toO(n^2)
.- The depth of the recursive
dfs
call could go up ton
in the worst case, which means a recursion stack space ofO(n)
. - Combining all the space complexities together, we get
O(n^2)
for thematch
dictionary being the dominant term.
Therefore, the overall space complexity of the code is O(n^2)
.
Learn more about how to find time and space complexity quickly using problem constraints.
What's the output of running the following function using input [30, 20, 10, 100, 33, 12]
?
1def fun(arr: List[int]) -> List[int]:
2 import heapq
3 heapq.heapify(arr)
4 res = []
5 for i in range(3):
6 res.append(heapq.heappop(arr))
7 return res
8
1public static int[] fun(int[] arr) {
2 int[] res = new int[3];
3 PriorityQueue<Integer> heap = new PriorityQueue<>();
4 for (int i = 0; i < arr.length; i++) {
5 heap.add(arr[i]);
6 }
7 for (int i = 0; i < 3; i++) {
8 res[i] = heap.poll();
9 }
10 return res;
11}
12
1class HeapItem {
2 constructor(item, priority = item) {
3 this.item = item;
4 this.priority = priority;
5 }
6}
7
8class MinHeap {
9 constructor() {
10 this.heap = [];
11 }
12
13 push(node) {
14 // insert the new node at the end of the heap array
15 this.heap.push(node);
16 // find the correct position for the new node
17 this.bubble_up();
18 }
19
20 bubble_up() {
21 let index = this.heap.length - 1;
22
23 while (index > 0) {
24 const element = this.heap[index];
25 const parentIndex = Math.floor((index - 1) / 2);
26 const parent = this.heap[parentIndex];
27
28 if (parent.priority <= element.priority) break;
29 // if the parent is bigger than the child then swap the parent and child
30 this.heap[index] = parent;
31 this.heap[parentIndex] = element;
32 index = parentIndex;
33 }
34 }
35
36 pop() {
37 const min = this.heap[0];
38 this.heap[0] = this.heap[this.size() - 1];
39 this.heap.pop();
40 this.bubble_down();
41 return min;
42 }
43
44 bubble_down() {
45 let index = 0;
46 let min = index;
47 const n = this.heap.length;
48
49 while (index < n) {
50 const left = 2 * index + 1;
51 const right = left + 1;
52
53 if (left < n && this.heap[left].priority < this.heap[min].priority) {
54 min = left;
55 }
56 if (right < n && this.heap[right].priority < this.heap[min].priority) {
57 min = right;
58 }
59 if (min === index) break;
60 [this.heap[min], this.heap[index]] = [this.heap[index], this.heap[min]];
61 index = min;
62 }
63 }
64
65 peek() {
66 return this.heap[0];
67 }
68
69 size() {
70 return this.heap.length;
71 }
72}
73
74function fun(arr) {
75 const heap = new MinHeap();
76 for (const x of arr) {
77 heap.push(new HeapItem(x));
78 }
79 const res = [];
80 for (let i = 0; i < 3; i++) {
81 res.push(heap.pop().item);
82 }
83 return res;
84}
85
Recommended Readings
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
Bitmask and Dynamic Programming Bit manipulation is a crucial aspect of computer programming and one of the most powerful tools for bit manipulation is bitmasks Let's first understand what a bit is A bit is a binary digit It's the smallest piece of data in a computer and can be
Want a Structured Path to Master System Design Too? Don’t Miss This!