756. Pyramid Transition Matrix
Problem Description
You need to build a pyramid of blocks where each block has a color represented by a single letter. The pyramid follows these rules:
-
Pyramid Structure: Each row has one less block than the row below it, and each row is centered on top of the previous row.
-
Allowed Patterns: You can only stack blocks according to specific triangular patterns. Each pattern consists of three blocks - two bottom blocks and one top block. These patterns are given as three-letter strings in the
allowed
list:- The first character represents the left bottom block
- The second character represents the right bottom block
- The third character represents the top block that can be placed on them
- For example,
"ABC"
means you can place block'C'
on top of blocks'A'
(left) and'B'
(right)
-
Starting Point: You begin with a bottom row given as the string
bottom
, which must be used as the base of your pyramid. -
Goal: Determine if it's possible to build a complete pyramid all the way to the top (single block) using only the allowed triangular patterns.
Example: If bottom = "AABA"
and you have certain allowed patterns, you would need to:
- Build the second row (3 blocks) by applying allowed patterns to each adjacent pair in "AABA"
- Build the third row (2 blocks) from the second row
- Build the top (1 block) from the third row
The function should return true
if you can successfully build the entire pyramid following the allowed patterns, or false
if it's impossible.
Flowchart Walkthrough
First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:
Is it a graph?
- Yes: While not explicitly a traditional graph problem, we can model this as a graph where each possible state of a pyramid row is a node, and edges represent valid transitions from one row to the next row based on allowed patterns.
Is it a tree?
- No: The problem is not a tree structure. Multiple different paths (combinations of blocks) can lead to the same result, and we may revisit states.
Is the problem related to directed acyclic graphs?
- No: This isn't a typical DAG problem involving dependencies or ordering.
Is the problem related to shortest paths?
- No: We're not looking for the shortest path. We need to determine if ANY valid path exists to build the complete pyramid.
Does the problem involve connectivity?
- No: We're not checking if components are connected or finding connected components.
Does the problem have small constraints?
- Yes: The pyramid naturally gets smaller as we go up (each row has one less block), and we need to explore different combinations of allowed patterns. The constraint space is manageable for recursive exploration.
Brute force / Backtracking?
- Yes: We need to try different combinations of allowed patterns at each level and backtrack if a path doesn't lead to a valid complete pyramid. This is essentially DFS with backtracking.
Conclusion: The flowchart suggests using DFS/backtracking approach. We recursively try to build each level of the pyramid by exploring all valid combinations of blocks that can be formed using the allowed patterns, and backtrack when we hit a dead end.
Intuition
The key insight is that building a pyramid is a recursive process where each level depends on the level below it. When we look at any row in the pyramid, we need to figure out what the next row above it could be.
Consider how a pyramid is built: for a bottom row like "AABA"
, we need to process each adjacent pair ("AA"
, "AB"
, "BA"
) and find valid blocks that can go on top of each pair according to our allowed patterns. This creates the next level up.
Here's where it gets interesting - there might be multiple valid blocks for each pair. For instance, if both "AAC"
and "AAD"
are allowed patterns, then either 'C'
or 'D'
can go on top of the "AA"
pair. This creates a branching decision tree where we need to explore all possible combinations.
The recursive nature becomes clear when we realize that:
- Once we build a new row, it becomes the "bottom" for building the next row
- We repeat this process until we reach a single block (the pyramid's peak)
- If at any point we can't find valid blocks for a pair, we know this path won't work
The challenge is that different combinations at lower levels might lead to success or failure at higher levels. For example, choosing 'C'
for the first pair might work perfectly with later choices, while choosing 'D'
might lead to a dead end three levels up. This is why we need to try all possibilities - we can't know which choice is correct until we've built the entire pyramid.
Using DFS with memoization is natural here because:
- We might encounter the same intermediate row through different paths (e.g., different choices at the bottom might converge to the same middle row)
- Caching results for each row string prevents redundant computation
- The state space is manageable since each row is smaller than the previous one
The approach essentially asks: "Given this current row, can I build a complete pyramid on top of it?" We answer this by trying all valid ways to build the next row and recursively checking if any of them leads to success.
Learn more about Depth-First Search and Breadth-First Search patterns.
Solution Approach
The solution implements a DFS with memoization approach using the following key components:
1. Pre-processing the Allowed Patterns
First, we build a dictionary d
that maps each pair of blocks to all possible blocks that can be placed on top:
d = defaultdict(list)
for a, b, c in allowed:
d[a, b].append(c)
For example, if allowed = ["ABC", "ABD"]
, then d["A", "B"]
would contain ['C', 'D']
.
2. Recursive DFS Function
The core logic is in the dfs(s)
function where s
represents the current row:
-
Base Case: If the row has only 1 block (
len(s) == 1
), we've successfully reached the pyramid's peak, so returnTrue
. -
Recursive Case: For the current row, we:
- Use
pairwise(s)
to get all adjacent pairs of blocks - For each pair
(a, b)
, look up all valid blocks that can go on top usingd[a, b]
- If any pair has no valid blocks (
not cs
), this path is impossible, returnFalse
- Collect all possible blocks for each position in the next row
- Use
3. Exploring All Combinations
The trickiest part is handling multiple valid choices for each position:
t.append(cs) # t contains lists of possible blocks for each position
return any(dfs(''.join(nxt)) for nxt in product(*t))
Here, product(*t)
generates all possible combinations. For example, if:
- Position 0 can have blocks
['C', 'D']
- Position 1 can have blocks
['E']
- Position 2 can have blocks
['F', 'G']
Then product(*t)
generates: ('C','E','F')
, ('C','E','G')
, ('D','E','F')
, ('D','E','G')
We try each combination by:
- Joining the characters to form the next row string
- Recursively calling
dfs
on that row - Using
any()
to returnTrue
if at least one path succeeds
4. Memoization with @cache
The @cache
decorator automatically memoizes the function results. If we encounter the same row string again (through different paths), we immediately return the cached result instead of recomputing. This is crucial for efficiency since different bottom-row combinations might converge to identical middle rows.
Example Walkthrough
For bottom = "ABC"
with appropriate allowed patterns:
- Call
dfs("ABC")
- Process pairs:
("A","B")
and("B","C")
- Find valid blocks for each pair from dictionary
d
- Generate all combinations for the next row (2 blocks)
- Recursively call
dfs
on each 2-block combination - Each of those calls will create 1-block rows
- When we reach 1 block, return
True
- Propagate success/failure back up the recursion chain
The algorithm effectively explores all valid pyramid configurations using DFS, pruning impossible paths early and avoiding redundant computation through memoization.
Ready to land your dream job?
Unlock your dream job with a 3-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a concrete example with bottom = "AABA"
and allowed = ["AAB", "AAC", "ABA", "ABB", "BAC"]
.
Step 1: Build the lookup dictionary
d = { ("A","A"): ["B", "C"], # from "AAB" and "AAC" ("A","B"): ["A", "B"], # from "ABA" and "ABB" ("B","A"): ["C"] # from "BAC" }
Step 2: Start DFS with bottom row "AABA"
Call dfs("AABA")
:
- Length is 4, not base case
- Process adjacent pairs: ("A","A"), ("A","B"), ("B","A")
- Look up valid blocks:
- ("A","A") โ ["B", "C"]
- ("A","B") โ ["A", "B"]
- ("B","A") โ ["C"]
Step 3: Generate all combinations for the 3-block row
Using product(["B","C"], ["A","B"], ["C"])
:
- Combination 1: "BAC"
- Combination 2: "BBC"
- Combination 3: "CAC"
- Combination 4: "CBC"
Step 4: Try each combination recursively
Let's follow "BAC":
Call dfs("BAC")
:
- Process pairs: ("B","A"), ("A","C")
- Look up valid blocks:
- ("B","A") โ ["C"]
- ("A","C") โ [] (no valid block!)
- Since ("A","C") has no valid blocks, return
False
Let's try "BBC":
Call dfs("BBC")
:
- Process pairs: ("B","B"), ("B","C")
- Assume we have no patterns for these pairs
- Return
False
Continue this process for "CAC" and "CBC". If none work, we'd return False
.
Step 5: Successful path (hypothetical)
Suppose we had additional patterns and "CAC" worked:
Call dfs("CAC")
:
- Process pairs: ("C","A"), ("A","C")
- Assume both pairs have valid blocks that lead to row "XY"
Call dfs("XY")
:
- Process pair: ("X","Y")
- Assume this gives us block "Z" for the top
Call dfs("Z")
:
- Length is 1 - we've reached the peak!
- Return
True
This True
propagates back through all the recursive calls, ultimately returning True
for the original pyramidTransition(bottom, allowed)
call.
Key Observations:
- We explored multiple branches (different 3-block combinations)
- Some branches failed early when no valid blocks existed for a pair
- Memoization would help if we reached the same intermediate row through different paths
- The algorithm exhaustively tries all possibilities until finding a valid pyramid or confirming none exists
Solution Implementation
1from functools import cache
2from collections import defaultdict
3from itertools import pairwise, product
4from typing import List
5
6class Solution:
7 def pyramidTransition(self, bottom: str, allowed: List[str]) -> bool:
8 # Build a mapping from (left_block, right_block) -> list of possible top blocks
9 transitions_map = defaultdict(list)
10 for transition in allowed:
11 left_block, right_block, top_block = transition[0], transition[1], transition[2]
12 transitions_map[left_block, right_block].append(top_block)
13
14 @cache
15 def can_build_pyramid(current_level: str) -> bool:
16 # Base case: if we reach the top (single block), pyramid is complete
17 if len(current_level) == 1:
18 return True
19
20 # Build all possible options for the next level
21 next_level_options = []
22
23 # For each adjacent pair in current level, find possible blocks above them
24 for left_block, right_block in pairwise(current_level):
25 possible_blocks = transitions_map[left_block, right_block]
26
27 # If no valid block can be placed above this pair, pyramid cannot be built
28 if not possible_blocks:
29 return False
30
31 next_level_options.append(possible_blocks)
32
33 # Try all combinations of blocks for the next level
34 # product(*next_level_options) generates all possible combinations
35 for next_level_combination in product(*next_level_options):
36 next_level_string = ''.join(next_level_combination)
37 if can_build_pyramid(next_level_string):
38 return True
39
40 return False
41
42 # Start building from the bottom
43 return can_build_pyramid(bottom)
44
1class Solution {
2 // Stores allowed transitions: transitions[a][b] is a bitmask representing possible top blocks
3 // for base blocks 'A'+a and 'A'+b
4 private int[][] transitions = new int[7][7];
5
6 // Memoization cache: key is "currentLevel.nextLevelBuiltSoFar", value is whether pyramid can be completed
7 private Map<String, Boolean> memo = new HashMap<>();
8
9 public boolean pyramidTransition(String bottom, List<String> allowed) {
10 // Build transition table from allowed triplets
11 // Each triplet "XYZ" means blocks X and Y can support block Z
12 for (String triplet : allowed) {
13 int leftBlock = triplet.charAt(0) - 'A';
14 int rightBlock = triplet.charAt(1) - 'A';
15 int topBlock = triplet.charAt(2) - 'A';
16
17 // Use bitmask to store multiple possible top blocks for each pair
18 transitions[leftBlock][rightBlock] |= (1 << topBlock);
19 }
20
21 // Start DFS from bottom level with empty next level
22 return canBuildPyramid(bottom, new StringBuilder());
23 }
24
25 private boolean canBuildPyramid(String currentLevel, StringBuilder nextLevel) {
26 // Base case: reached the top of pyramid (single block)
27 if (currentLevel.length() == 1) {
28 return true;
29 }
30
31 // Current level is complete, move to next level up
32 if (nextLevel.length() + 1 == currentLevel.length()) {
33 return canBuildPyramid(nextLevel.toString(), new StringBuilder());
34 }
35
36 // Create memoization key
37 String memoKey = currentLevel + "." + nextLevel.toString();
38 if (memo.containsKey(memoKey)) {
39 return memo.get(memoKey);
40 }
41
42 // Get current pair of blocks to process
43 int leftBlockIndex = currentLevel.charAt(nextLevel.length()) - 'A';
44 int rightBlockIndex = currentLevel.charAt(nextLevel.length() + 1) - 'A';
45
46 // Get bitmask of possible blocks that can go on top of this pair
47 int possibleBlocks = transitions[leftBlockIndex][rightBlockIndex];
48
49 // Try each possible block
50 for (int blockIndex = 0; blockIndex < 7; blockIndex++) {
51 // Check if this block is allowed on top of current pair
52 if (((possibleBlocks >> blockIndex) & 1) == 1) {
53 // Try placing this block
54 nextLevel.append((char) ('A' + blockIndex));
55
56 // Recursively check if pyramid can be completed
57 if (canBuildPyramid(currentLevel, nextLevel)) {
58 memo.put(memoKey, true);
59 return true;
60 }
61
62 // Backtrack: remove the block we just tried
63 nextLevel.deleteCharAt(nextLevel.length() - 1);
64 }
65 }
66
67 // No valid pyramid can be built from this state
68 memo.put(memoKey, false);
69 return false;
70 }
71}
72
1class Solution {
2public:
3 // Bitmask array to store possible top blocks for each pair of bottom blocks
4 // allowedTopBlocks[i][j] stores a bitmask of allowed blocks that can be placed
5 // on top of blocks 'A'+i and 'A'+j
6 int allowedTopBlocks[7][7];
7
8 // Memoization cache: maps state (currentRow + "." + nextRow) to whether it leads to success
9 unordered_map<string, bool> memo;
10
11 bool pyramidTransition(string bottom, vector<string>& allowed) {
12 // Initialize the allowed top blocks array
13 memset(allowedTopBlocks, 0, sizeof(allowedTopBlocks));
14
15 // Process each allowed triple and build the bitmask array
16 // For pattern "XYZ", block Z can be placed on top of blocks X and Y
17 for (auto& pattern : allowed) {
18 int leftBlock = pattern[0] - 'A';
19 int rightBlock = pattern[1] - 'A';
20 int topBlock = pattern[2] - 'A';
21
22 // Set the bit corresponding to topBlock in the bitmask
23 allowedTopBlocks[leftBlock][rightBlock] |= (1 << topBlock);
24 }
25
26 // Start DFS with the bottom row and empty next row
27 return buildPyramid(bottom, "");
28 }
29
30 bool buildPyramid(string& currentRow, string nextRow) {
31 // Base case: pyramid is complete when we reach a single block at the top
32 if (currentRow.size() == 1) {
33 return true;
34 }
35
36 // Current row is fully processed, move to the next level
37 if (nextRow.size() + 1 == currentRow.size()) {
38 return buildPyramid(nextRow, "");
39 }
40
41 // Create a unique key for memoization
42 string stateKey = currentRow + "." + nextRow;
43 if (memo.count(stateKey)) {
44 return memo[stateKey];
45 }
46
47 // Get the two adjacent blocks in the current row
48 int leftBlockIndex = currentRow[nextRow.size()] - 'A';
49 int rightBlockIndex = currentRow[nextRow.size() + 1] - 'A';
50
51 // Get bitmask of allowed blocks that can be placed on top
52 int possibleBlocks = allowedTopBlocks[leftBlockIndex][rightBlockIndex];
53
54 // Try each possible block that can be placed on top
55 for (int blockIndex = 0; blockIndex < 7; ++blockIndex) {
56 // Check if this block is allowed (bit is set in the bitmask)
57 if ((possibleBlocks >> blockIndex) & 1) {
58 // Recursively try building with this block added to the next row
59 if (buildPyramid(currentRow, nextRow + (char)(blockIndex + 'A'))) {
60 memo[stateKey] = true;
61 return true;
62 }
63 }
64 }
65
66 // No valid pyramid can be built from this state
67 memo[stateKey] = false;
68 return false;
69 }
70};
71
1// Bitmask array to store possible top blocks for each pair of bottom blocks
2// allowedTopBlocks[i][j] stores a bitmask of allowed blocks that can be placed
3// on top of blocks 'A'+i and 'A'+j
4const allowedTopBlocks: number[][] = Array(7).fill(0).map(() => Array(7).fill(0));
5
6// Memoization cache: maps state (currentRow + "." + nextRow) to whether it leads to success
7const memo: Map<string, boolean> = new Map();
8
9function pyramidTransition(bottom: string, allowed: string[]): boolean {
10 // Reset the allowed top blocks array for each test case
11 for (let i = 0; i < 7; i++) {
12 for (let j = 0; j < 7; j++) {
13 allowedTopBlocks[i][j] = 0;
14 }
15 }
16
17 // Clear memoization cache
18 memo.clear();
19
20 // Process each allowed triple and build the bitmask array
21 // For pattern "XYZ", block Z can be placed on top of blocks X and Y
22 for (const pattern of allowed) {
23 const leftBlock = pattern.charCodeAt(0) - 'A'.charCodeAt(0);
24 const rightBlock = pattern.charCodeAt(1) - 'A'.charCodeAt(0);
25 const topBlock = pattern.charCodeAt(2) - 'A'.charCodeAt(0);
26
27 // Set the bit corresponding to topBlock in the bitmask
28 allowedTopBlocks[leftBlock][rightBlock] |= (1 << topBlock);
29 }
30
31 // Start DFS with the bottom row and empty next row
32 return buildPyramid(bottom, "");
33}
34
35function buildPyramid(currentRow: string, nextRow: string): boolean {
36 // Base case: pyramid is complete when we reach a single block at the top
37 if (currentRow.length === 1) {
38 return true;
39 }
40
41 // Current row is fully processed, move to the next level
42 if (nextRow.length + 1 === currentRow.length) {
43 return buildPyramid(nextRow, "");
44 }
45
46 // Create a unique key for memoization
47 const stateKey = currentRow + "." + nextRow;
48 if (memo.has(stateKey)) {
49 return memo.get(stateKey)!;
50 }
51
52 // Get the two adjacent blocks in the current row
53 const leftBlockIndex = currentRow.charCodeAt(nextRow.length) - 'A'.charCodeAt(0);
54 const rightBlockIndex = currentRow.charCodeAt(nextRow.length + 1) - 'A'.charCodeAt(0);
55
56 // Get bitmask of allowed blocks that can be placed on top
57 const possibleBlocks = allowedTopBlocks[leftBlockIndex][rightBlockIndex];
58
59 // Try each possible block that can be placed on top
60 for (let blockIndex = 0; blockIndex < 7; blockIndex++) {
61 // Check if this block is allowed (bit is set in the bitmask)
62 if ((possibleBlocks >> blockIndex) & 1) {
63 // Recursively try building with this block added to the next row
64 if (buildPyramid(currentRow, nextRow + String.fromCharCode(blockIndex + 'A'.charCodeAt(0)))) {
65 memo.set(stateKey, true);
66 return true;
67 }
68 }
69 }
70
71 // No valid pyramid can be built from this state
72 memo.set(stateKey, false);
73 return false;
74}
75
Time and Space Complexity
Time Complexity: O(b^n * n)
where b
is the branching factor (maximum number of possible characters for any pair) and n
is the length of the bottom string.
- The pyramid has
n
levels (from bottom to top). - At each level
i
, we haven - i + 1
positions. - For each pair of adjacent characters, we might have up to
b
choices (whereb โค 7
since we have at most 7 different characters A-G). - In the worst case, at level
i
, we explore up tob^(n-i)
different strings. - The total number of states explored is bounded by
ฮฃ(i=1 to n) b^(n-i) = b^(n-1) + b^(n-2) + ... + b^0 = (b^n - 1)/(b - 1) = O(b^n)
. - For each state, we perform
O(n)
work to process the string and generate the next level. - Therefore, the overall time complexity is
O(b^n * n)
.
Space Complexity: O(b^n * n)
for the memoization cache plus O(n)
for the recursion stack depth.
- The
@cache
decorator memoizes all unique strings that appear during recursion. - In the worst case, we might cache
O(b^n)
different strings across all levels. - Each cached string has maximum length
n
. - The recursion depth is at most
n
(height of the pyramid). - The dictionary
d
stores the allowed transitions, which takesO(|allowed|)
space where|allowed| โค 500
. - Therefore, the total space complexity is
O(b^n * n + n + |allowed|) = O(b^n * n)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Not Handling Multiple Valid Transitions for the Same Pair
A critical pitfall is assuming each pair of blocks can only produce one block above it. In reality, the same pair might have multiple valid transitions.
Incorrect Approach:
# Wrong: Only considering one possible block per pair transitions_map = {} # Using regular dict instead of defaultdict(list) for transition in allowed: left, right, top = transition[0], transition[1], transition[2] transitions_map[left, right] = top # Overwrites previous values!
Why it fails: If allowed = ["ABC", "ABD"]
, the second pattern overwrites the first, losing valid pyramid paths.
Correct Solution:
transitions_map = defaultdict(list)
for transition in allowed:
left, right, top = transition[0], transition[1], transition[2]
transitions_map[left, right].append(top) # Keeps all possibilities
2. Forgetting to Use Memoization
Without memoization, the algorithm becomes exponentially slow due to recomputing the same intermediate levels multiple times.
Incorrect Approach:
def can_build_pyramid(current_level: str) -> bool:
# No @cache decorator - recalculates everything!
if len(current_level) == 1:
return True
# ... rest of logic
Why it fails: Different bottom configurations can converge to the same middle levels. For example, both "ABCD" and "EFGH" might produce "XY" as an intermediate level. Without caching, we'd rebuild from "XY" multiple times.
Correct Solution: Always use @cache
or implement manual memoization with a dictionary.
3. Incorrect Iteration Over Adjacent Pairs
A subtle bug occurs when manually iterating over pairs instead of using pairwise
.
Incorrect Approach:
# Wrong: Off-by-one error or incorrect pairing
for i in range(len(current_level)): # Goes too far!
left = current_level[i]
right = current_level[i + 1] # IndexError when i == len-1
Or:
# Wrong: Creates wrong number of pairs
for i in range(len(current_level) - 2): # Should be -1, not -2
left = current_level[i]
right = current_level[i + 1]
Correct Solution: Use itertools.pairwise()
or carefully implement the loop:
# Option 1: Using pairwise (Python 3.10+)
for left, right in pairwise(current_level):
# Process pair
# Option 2: Manual implementation
for i in range(len(current_level) - 1):
left = current_level[i]
right = current_level[i + 1]
4. Not Exploring All Combinations with Cartesian Product
A common mistake is trying only one possible configuration for the next level instead of exploring all combinations.
Incorrect Approach:
# Wrong: Only tries first valid option for each position next_level = [] for left, right in pairwise(current_level): possible = transitions_map[left, right] if not possible: return False next_level.append(possible[0]) # Only uses first option! return can_build_pyramid(''.join(next_level))
Why it fails: This greedy approach misses valid pyramid configurations where the first choice leads to a dead end but other choices succeed.
Correct Solution: Use itertools.product()
to generate all combinations:
for next_level_combination in product(*next_level_options): if can_build_pyramid(''.join(next_level_combination)): return True
5. Using Tuples as Dictionary Keys Without Converting
When using pairs as dictionary keys, forgetting to maintain consistent key types causes lookup failures.
Incorrect Approach:
# Building the map with string concatenation transitions_map[left + right] = top # Key is string "AB" # But looking up with tuple for left, right in pairwise(current_level): possible = transitions_map[(left, right)] # Key is tuple ('A', 'B') # KeyError!
Correct Solution: Be consistent with key types:
# Either use tuples consistently: transitions_map[(left, right)].append(top) # ... possible = transitions_map[(left, right)] # Or use strings consistently: transitions_map[left + right].append(top) # ... possible = transitions_map[left + right]
What are the most two important steps in writing a depth first search function? (Select 2)
Recommended Readings
https assets algo monster cover_photos dfs svg Depth First Search Prereqs Recursion Review problems recursion_intro Trees problems tree_intro With a solid understanding of recursion under our belts we are now ready to tackle one of the most useful techniques in coding interviews Depth First Search DFS As the name suggests
https assets algo monster cover_photos bfs svg Breadth First Search on Trees Hopefully by this time you've drunk enough DFS Kool Aid to understand its immense power and seen enough visualization to create a call stack in your mind Now let me introduce the companion spell Breadth First Search BFS
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Want a Structured Path to Master System Design Too? Donโt Miss This!