2184. Number of Ways to Build Sturdy Brick Wall


Problem Description

The problem entails building a brick wall of specific dimensions, height and width, using bricks that come in different fixed widths (each brick is 1 unit high). In the array bricks, each brick's width is given, and we have an unlimited number of bricks of each size. The objective is to calculate the total number of ways to build the wall without aligning the joints of bricks from adjacent rows—except at the ends of the wall—to ensure the wall's sturdiness.

Each row must be exactly width units wide, and bricks may not be stacked atop each other; each brick spans the entire height of its row (which is 1 unit tall). The answer could be quite large, so we require the total count modulo 10^9 + 7.

Intuition

To solve this problem, we can think of each row in the wall as a sequence of bricks that fit perfectly within the given width. We start by finding all the valid sequences (arrangements) of bricks that will make a single row of the specified width. We represent these sequences as a list summed up to the width.

Once we have all the valid sequences for a single row, we approach the problem by assessing which sequences can sit above each other without their joints aligning. This links to the concept of graph theory, where we can think of each sequence as a node and edges that connect sequences that can be placed on top of each other (i.e., no aligned joints, except at the ends). With this graph, we can use dynamic programming to find all possible combinations of sequences for the total height of the wall.

We initialize a matrix of dynamic programming states where dp[i][j] represents the number of ways the wall can be built up to height i with the last row's configuration being the jth sequence. For each level of height, we compute the number of ways to continue building on top of each sequence, adding those from previous layers that are compatible (no aligning joints).

In the provided code, the recursive function dfs is used to generate all possible sequences that make up a row of width width. The check function verifies if two sequences can be placed one on top of the other without joints aligning. The nested for-loops iterate over the combinations of sequences to populate our graph g with valid sequences that can follow each other. In the final for-loops, we use dynamic programming to compute the number of possible configurations for each level of the wall until we reach the desired height, and return this count modulo 10^9 + 7 as the final answer.

Learn more about Dynamic Programming and Bitmask patterns.

Solution Approach

To implement the solution, we use several programming concepts such as recursion, depth-first search (DFS), dynamic programming, and graph theory. Here's how each part of the solution works:

  • Depth-First Search (DFS): The dfs function is where we generate all the sequences of bricks that exactly fit the given width. We start with an empty sequence (t list) and extend it by adding bricks (x from bricks array) one by one. If adding a brick exceeds the width, we backtrack; this is the essence of DFS. When a sequence hits the exact width, we record it in the s list.

  • Sequences Validation: To validate whether two sequences (rows of bricks) can be placed on top of each other, we create a check function. This function compares the cumulative width of bricks in two sequences to ensure that no joints (except at the ends) line up. As it iterates over the bricks, it maintains a running sum of widths (s1 and s2) for each sequence. If at any point the sums are equal (and not at the end), the sequences are incompatible.

  • Graph Building: We construct a graph represented as a dictionary g, where each key is a sequence index, and its value is a list of indices of sequences that can be placed on top of the key sequence without joints aligning. This involves checking each sequence against every other valid sequence, facilitating the dynamic programming step.

  • Dynamic Programming (DP): With the graph g in place, our next step is to count all viable ways to build the wall up to the specified height. We initialize a DP table dp, where dp[i][j] represents the number of ways to build the wall to height where the ith layer corresponds to the jth sequence from s. We start by setting dp[0][j] to 1 for all j, as there's exactly one way to start the wall with any sequence. Then for each subsequent layer (i), we add up the ways we can stack each sequence (j) on top of all the sequences (k) from the previous layer that are connected in graph g. We apply the modulo operation (mod) to keep the numbers within the required range.

  • Summing Up Combinations: At the end, to get the total number of combinations, we iterate over the last layer of the dp table and sum up all the values, assessing the complete wall's height. This sum, modulo 10^9 + 7, gives us the final answer detailing the number of ways to construct the sturdy wall.

Throughout this process, we make use of:

  • Recursion in the dfs function to explore all brick sequences.
  • Graph Theory by constructing a graph where each node represents a possible sequence configuration and edges represent the ability to place one sequence over another.
  • Dynamic Programming to efficiently calculate the number of combinations by avoiding recalculating the same subproblems.

The entire approach revolves around breaking down the problem into manageable subproblems (valid sequences of a single row) and then using the properties of these subproblems (whether they can stack on top of each other) to solve the larger problem (building the entire wall) while avoiding repetitive work through memoization in the DP table.

Ready to land your dream job?

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

Start Evaluator

Example Walkthrough

Let's illustrate the solution approach with a small example where the goal is to build a wall of height 2 and width 4 using bricks of widths 1 and 2. The available brick sizes are represented in the bricks array: [1, 2].

Step 1: Depth-First Search (DFS) for all valid sequences:

We start by generating all sequences of bricks that fit exactly into the width of 4:

  • One option is [1, 1, 1, 1] - using four bricks of width 1.
  • Another option is [2, 2] - using two bricks of width 2.
  • Two more options include [1, 1, 2] and [1, 2, 1], [2, 1, 1] considering different arrangements of width 1 and 2 bricks.

We now have all valid sequences [ [1, 1, 1, 1], [2, 2], [1, 1, 2], [1, 2, 1], [2, 1, 1] ].

Step 2: Sequences Validation:

Using the check function, we will determine which sequences can be placed on top of each other without the joints aligning:

  • The sequence [1, 1, 1, 1] can be placed on top of any other sequence since it doesn't create any aligned joints.
  • The sequence [2, 2] cannot be placed on top of itself as that would align joints, but it can be placed on top of [1, 1, 2], [1, 2, 1], or [2, 1, 1].
  • Similarly, [1, 1, 2] cannot be on top of [2, 2], but it can go on [1, 2, 1] or [2, 1, 1].
  • The same logic applies to [1, 2, 1] and [2, 1, 1].

Step 3: Graph Building:

We construct a graph showing which sequences can sit on top of each other:

g = {
  0: [1, 2, 3, 4],  // [1, 1, 1, 1] can be placed over any sequence.
  1: [2, 3, 4],     // [2, 2] can be placed over [1, 1, 2], [1, 2, 1], and [2, 1, 1].
  2: [0, 3, 4],     // [1, 1, 2] can be placed over [1, 1, 1, 1], [1, 2, 1], and [2, 1, 1].
  3: [0, 1, 4],     // ...
  4: [0, 1, 2],
}

Step 4: Dynamic Programming (DP):

Initialize the DP table. Let dp[i][j] be the number of ways to build up to the i-th height, ending with the j-th sequence:

    dp = [
      [1, 1, 1, 1, 1],  // Base case: 1 way to lay each sequence as the first row.
      [0, 0, 0, 0, 0],  // We will fill this part with the number of ways to make the second row.
    ]

We iterate over each sequence for the second layer, and add up compatible sequences from the previous layer according to the graph g:

    dp[1][0] = dp[0][1] + dp[0][2] + dp[0][3] + dp[0][4]  // 4 ways to place the second sequence over the first
    dp[1][1] = dp[0][0] + dp[0][2] + dp[0][3] + dp[0][4]  // 4 ways
    dp[1][2] = dp[0][0] + dp[0][3] + dp[0][4]             // 3 ways
    ...

Now, our DP table looks like this:

    dp = [
      [1, 1, 1, 1, 1],
      [4, 4, 3, 3, 3],
    ]

Step 5: Summing Up Combinations:

Lastly, we sum the last row in the dp table to find the total number of ways to build the wall to the height of 2:

Total combinations = dp[1][0] + dp[1][1] + dp[1][2] + dp[1][3] + dp[1][4] = 4 + 4 + 3 + 3 + 3 = 17.

Since the answer could be very large, we would normally return this number modulo 10^9 + 7. In our example, the answer is 17.

So, for our small example with a wall of height 2 and width 4 using bricks of width 1 and 2, there are 17 different ways to build a sturdy wall where no two adjacent row joints align (except at the ends).

Solution Implementation

1from typing import List
2from collections import defaultdict
3
4class Solution:
5    def buildWall(self, height: int, width: int, bricks: List[int]) -> int:
6        # Helper function to find all possible ways to build a single layer
7        def build_layer(current_width):
8            if current_width > width:
9                return
10            if current_width == width:
11                all_layers.append(layer[:])
12                return
13            for brick in bricks:
14                layer.append(brick)
15                build_layer(current_width + brick)
16                layer.pop()
17
18        # Function to check if two configurations can be placed one over the other
19        def is_compatible(layer1, layer2):
20            sum1, sum2 = layer1[0], layer2[0]
21            index1, index2 = 1, 1
22            while index1 < len(layer1) and index2 < len(layer2):
23                if sum1 == sum2:
24                    return False
25                if sum1 < sum2:
26                    sum1 += layer1[index1]
27                    index1 += 1
28                else:
29                    sum2 += layer2[index2]
30                    index2 += 1
31            return True
32
33        mod = 10**9 + 7
34        all_layers = []  # To store all possible configurations for one layer
35        layer = []  # To store the current layer configuration
36        build_layer(0)  # Build layers starting with 0 width
37
38        adjacency_list = defaultdict(list)  # To store the graph of compatible layers
39        num_layers = len(all_layers)
40
41        # Build the graph
42        for i in range(num_layers):
43            if is_compatible(all_layers[i], all_layers[i]):
44                adjacency_list[i].append(i)
45            for j in range(i + 1, num_layers):
46                if is_compatible(all_layers[i], all_layers[j]):
47                    adjacency_list[i].append(j)
48                    adjacency_list[j].append(i)
49
50        # Dynamic programming array initialization
51        dp = [[0] * num_layers for _ in range(height)]
52        for j in range(num_layers):
53            dp[0][j] = 1  # Each configuration is one way to build the first layer
54
55        # Compute number of ways to build the wall using DP
56        for i in range(1, height):
57            for j in range(num_layers):
58                for k in adjacency_list[j]:
59                    dp[i][j] += dp[i - 1][k]
60                    dp[i][j] %= mod
61
62        return sum(dp[-1]) % mod  # Return the number of ways modulo 10^9 + 7
63
1class Solution {
2    // Store all the unique row configurations
3    private List<List<Integer>> uniqueRows = new ArrayList<>();
4    // Temporary list to store current row configuration
5    private List<Integer> currentRow = new ArrayList<>();
6    // Modulus value for the result
7    private static final int MOD = (int) 1e9 + 7;
8    // The wall's width and the set of brick lengths
9    private int wallWidth;
10    private int[] brickSizes;
11
12    public int buildWall(int height, int width, int[] bricks) {
13        this.wallWidth = width;
14        this.brickSizes = bricks;
15        // Generate all possible row configurations
16        generateConfigurations(0);
17        int numConfigs = uniqueRows.size();
18        List<Integer>[] graph = new List[numConfigs];
19        Arrays.setAll(graph, i -> new ArrayList<>());
20      
21        // Build the adjacency list for the configurations graph
22        for (int i = 0; i < numConfigs; ++i) {
23            for (int j = 0; j < numConfigs; ++j) {
24                if (i != j && canPlaceRows(uniqueRows.get(i), uniqueRows.get(j))) {
25                    graph[i].add(j);
26                }
27            }
28        }
29      
30        // Dynamic programming array to store counts of ways to build the wall
31        int[][] dp = new int[height][numConfigs];
32        // Initialize base case for the first row
33        for (int j = 0; j < numConfigs; ++j) {
34            dp[0][j] = 1;
35        }
36      
37        // Fill the DP array with number of ways to build wall
38        for (int i = 1; i < height; ++i) {
39            for (int j = 0; j < numConfigs; ++j) {
40                for (int k : graph[j]) {
41                    dp[i][j] = (dp[i][j] + dp[i - 1][k]) % MOD;
42                }
43            }
44        }
45      
46        // Sum up all the ways to build the wall of the specified height
47        int answer = 0;
48        for (int j = 0; j < numConfigs; ++j) {
49            answer = (answer + dp[height - 1][j]) % MOD;
50        }
51        return answer;
52    }
53
54    // Checks if two rows can be placed on top of each other
55    private boolean canPlaceRows(List<Integer> topRow, List<Integer> bottomRow) {
56        int sumTop = topRow.get(0);
57        int sumBottom = bottomRow.get(0);
58        int i = 1, j = 1;
59        while (i < topRow.size() && j < bottomRow.size()) {
60            if (sumTop == sumBottom) {
61                return false; // Cracks align
62            }
63            if (sumTop < sumBottom) {
64                sumTop += topRow.get(i++);
65            } else {
66                sumBottom += bottomRow.get(j++);
67            }
68        }
69        return true; // No alignment of cracks
70    }
71
72    // Depth First Search to generate all possible configurations of one row
73    private void generateConfigurations(int progress) {
74        if (progress > wallWidth) {
75            return; // Exceeds width of wall, invalid configuration
76        }
77        if (progress == wallWidth) {
78            // Found a valid row configuration
79            uniqueRows.add(new ArrayList<>(currentRow));
80            return;
81        }
82        for (int size : brickSizes) {
83            currentRow.add(size);
84            generateConfigurations(progress + size); // Continue to add bricks
85            currentRow.remove(currentRow.size() - 1); // Backtrack to try other bricks
86        }
87    }
88}
89
1#include <vector>
2
3class Solution {
4public:
5    std::vector<int> bricks;
6    int wallWidth;
7    int mod = 1e9 + 7;
8    std::vector<std::vector<int>> layerPatterns;
9    std::vector<int> currentPattern;
10
11    // Main function to compute the number of ways to build the wall
12    int buildWall(int height, int width, std::vector<int>& bricks) {
13        wallWidth = width;
14        this->bricks = bricks;
15      
16        // Generate all possible patterns for a single layer of the wall
17        generatePatterns(0);
18        currentPattern.resize(0);
19      
20        // Count patterns excluding same crack patterns
21        int patternCount = layerPatterns.size();
22        std::vector<std::vector<int>> graph(patternCount);
23      
24        // Build a graph representing compatible patterns
25        for (int i = 0; i < patternCount; ++i) {
26            if (canPlaceAdjacent(layerPatterns[i], layerPatterns[i])) {
27                graph[i].push_back(i);
28            }
29            for (int j = i + 1; j < patternCount; ++j) {
30                if (canPlaceAdjacent(layerPatterns[i], layerPatterns[j])) {
31                    graph[i].push_back(j);
32                    graph[j].push_back(i);
33                }
34            }
35        }
36      
37        // Prepare for dynamic programming
38        std::vector<std::vector<int>> dp(height, std::vector<int>(patternCount));
39      
40        // Initialize the base cases
41        for (int j = 0; j < patternCount; ++j) dp[0][j] = 1;
42      
43        // Build up solutions from the base cases
44        for (int i = 1; i < height; ++i) {
45            for (int j = 0; j < patternCount; ++j) {
46                for (int k : graph[j]) {
47                    dp[i][j] += dp[i - 1][k];
48                    dp[i][j] %= mod;
49                }
50            }
51        }
52      
53        // Sum all possible configurations for the top layer
54        int result = 0;
55        for (int j = 0; j < patternCount; ++j) {
56            result += dp[height - 1][j];
57            result %= mod;
58        }
59        return result;
60    }
61
62    // Helper function to check if two layer patterns can be placed one over the other
63    bool canPlaceAdjacent(std::vector<int>& a, std::vector<int>& b) {
64        int sum1 = a[0], sum2 = b[0];
65        int i = 1, j = 1;
66        while (i < a.size() && j < b.size()) {
67            if (sum1 == sum2) return false;
68            if (sum1 < sum2) {
69                sum1 += a[i++];
70            } else {
71                sum2 += b[j++];
72            }
73        }
74        return true;
75    }
76
77    // Depth-first search algorithm to generate all possible layer patterns
78    void generatePatterns(int sum) {
79        // If the current sum exceeds the width of the wall, stop the search
80        if (sum > wallWidth) return;
81      
82        // If the current sum equals the width of the wall, add the pattern to the results
83        if (sum == wallWidth) {
84            layerPatterns.push_back(currentPattern);
85            return;
86        }
87      
88        // Try each type of brick as a possible continuation of the pattern
89        for (int brick : bricks) {
90            currentPattern.push_back(brick);
91            generatePatterns(sum + brick);
92            // Backtrack
93            currentPattern.pop_back();
94        }
95    }
96};
97
1let bricks: number[];
2let wallWidth: number;
3const MOD = 1e9 + 7;
4let layerPatterns: number[][];
5let currentPattern: number[];
6
7/**
8 * Generate all possible patterns for a single layer of the wall
9 */
10function generatePatterns(sum: number) {
11    // Stop search if the current sum exceeds the wall's width
12    if (sum > wallWidth) return;
13
14    // If the current sum equals the wall's width, add the pattern to results
15    if (sum === wallWidth) {
16        layerPatterns.push([...currentPattern]); // Use spread to copy pattern
17        return;
18    }
19
20    // Try each type of brick as a continuation of the current pattern
21    for (let brick of bricks) {
22        currentPattern.push(brick); // Add brick to the current pattern
23        generatePatterns(sum + brick); // Recursively search for patterns
24        currentPattern.pop(); // Remove the last brick to backtrack
25    }
26}
27
28/**
29 * Check if two layer patterns can be placed over each other without aligning cracks
30 */
31function canPlaceAdjacent(a: number[], b: number[]): boolean {
32    let sum1 = a[0], sum2 = b[0];
33    let i = 1, j = 1;
34
35    // Compare the pattern of bricks, ensuring there are no aligning cracks
36    while (i < a.length && j < b.length) {
37        if (sum1 === sum2) return false; // Patterns are not compatible
38        if (sum1 < sum2) sum1 += a[i++];
39        else sum2 += b[j++];
40    }
41  
42    return true;
43}
44
45/**
46 * Main function to compute the number of ways to build the wall
47 */
48function buildWall(height: number, width: number, inputBricks: number[]): number {
49    wallWidth = width;
50    bricks = inputBricks;
51    layerPatterns = [];
52    currentPattern = [];
53
54    generatePatterns(0);
55
56    let patternCount = layerPatterns.length;
57    let graph: number[][] = Array.from({length: patternCount}, () => []);
58
59    for (let i = 0; i < patternCount; ++i) {
60        // Build a graph representing compatible patterns
61        for (let j = i; j < patternCount; ++j) {
62            if (canPlaceAdjacent(layerPatterns[i], layerPatterns[j])) {
63                graph[i].push(j);
64                if (j !== i) graph[j].push(i); // Avoid adding the same index when i == j
65            }
66        }
67    }
68
69    // Prepare for dynamic programming with a 2D array
70    let dp: number[][] = Array.from({length: height}, () => Array(patternCount).fill(0));
71
72    // Initialize the base cases for the bottom layer
73    for (let j = 0; j < patternCount; ++j) dp[0][j] = 1;
74
75    // Build up solutions layer by layer
76    for (let i = 1; i < height; ++i) {
77        for (let j = 0; j < patternCount; ++j) {
78            for (let k of graph[j]) {
79                dp[i][j] = (dp[i][j] + dp[i - 1][k]) % MOD;
80            }
81        }
82    }
83
84    // Sum all configurations for the top layer to find the result
85    let result = 0;
86    for (let j = 0; j < patternCount; ++j) {
87        result = (result + dp[height - 1][j]) % MOD;
88    }
89
90    return result;
91}
92

Time and Space Complexity

Time Complexity

The time complexity can be divided into a few parts:

  1. DFS (dfs function) for generating all possible row combinations: The DFS function is used to generate all possible combinations of bricks that can form a row of the desired width. In the worst case, it generates all permutational combinations. If we denote k as the maximum number of bricks that a row can take, then the time complexity for this step can be approximated as O(b^k), where b is the number of brick sizes in the bricks list.

  2. Checking compatibility between two rows (check function): The check function is used to determine if two rows are compatible, which means no vertical cracks align. Every row combination is checked against every other combination. This gives us time complexity of O(n^2 * w) for this part, where n is the total number of unique rows and w is the width of the wall since in the worst scenario each element of a row is checked against each element of another row.

  3. Populating the graph with compatible layers: Based on the outputs of the check function, a graph is populated. This is also O(n^2) as each row is checked against every other row for compatibility.

  4. Dynamic Programming to calculate the number of ways to build the wall (dp loop): The DP array is filled in height iterations, and each sub-problem depends on the previous layer's sub-problems. We have height * n sub-problems, and each could potentially depend on all n other sub-problems from the previous row. Thus the complexity for this part is O(height * n^2).

Combining all steps, the total time complexity of the code is:

T(height, width, b) = O(b^k) + O(n^2 * w) + O(n^2) + O(height * n^2)

Since the number of unique rows n can be pretty large (up to b^k), the time complexity is dominated by the last term which involves height, leading to a worst-case scenario of:

T(height, width, b) = O(height * b^(2k))

Space Complexity

The space complexity is determined by the storage required for:

  1. The s list that stores all possible row combinations: In the worst case, this could store up to b^k combinations.

  2. The g defaultdict that stores the adjacency list of the graph: Since we're dealing with n unique rows and each row can be compatible with up to n-1 other rows, in the worst case, this takes O(n^2) space.

  3. The dp array: The dp array is a 2D array with height rows and n columns, so it takes O(height * n) space.

  4. Additional space for the t list used during DFS and function call stack: The t list and the recursion call stack could in the worst case require O(width) space each due to the recursive calls depending on the width of the wall.

The space complexity of the code is thus the maximum of the space used by these data structures:

S(height, width, b) = max(O(b^k), O(n^2), O(height * n), O(width))

Since n can be as large as b^k, the dominant term is:

S(height, width, b) = O(height * b^(2k)) + O(width)

Considering O(height * b^(2k)) would generally be larger than O(width), we can approximate the space complexity as:

S(height, width, b) = O(height * b^(2k))

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


Discover Your Strengths and Weaknesses: Take Our 2-Minute Quiz to Tailor Your Study Plan:
Question 1 out of 10

What data structure does Breadth-first search typically uses to store intermediate states?


Recommended Readings

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


Lakshya

in example walkthrough, in step 2 i.e. sequence validation, it says 1,1,1,1 with any other sequence, but it has aligned joints at joint 2 with sequence 2,2. can you clarify this?

Tue Oct 15 2024