|
1 | 1 | # [Problem 2392: Build a Matrix With Conditions](https://leetcode.com/problems/build-a-matrix-with-conditions/description/?envType=daily-question)
|
2 | 2 |
|
3 | 3 | ## Initial thoughts (stream-of-consciousness)
|
4 |
| - |
| 4 | +- My first thought is that we can figure out the "above/below" and "left/right" conditions separately. So we "just" need a function that takes in a list of conditions and returns an ordering of $1...k$ (or None if there isn't any) |
| 5 | +- The brute force solution would be to list all permutations of $1...k$ and then eliminate any that don't satisfy each condition in turn. However, there's no way this approach would scale: there are $k!$ permutations of the numbers $1...k$, and so this approach would be $O(nk!)$ where $n$ is the number of conditions. |
| 6 | +- Another approach would be to start with the first condition, and create all possible placements of those first two numbers that satisfy the first condition: |
| 7 | + - For a $k \times k$ matrix, there are $k - 1$ possibly placements of the "above" or "left' number and for each of those there are up to $k - 1$ placements of the "below" or "right" number. So if we enumerate all possible placments of those first two numbers, we'd have $O(k^2)$ options. |
| 8 | + - We could do this for each option, and then merge the compatable options somehow. This would be incredibly inefficient, though-- it'd be $O(nk^2)$ just to enumerate those possibilities, plus who knows how many steps to merge everything. There must be a better way... |
| 9 | +- Another approach would be to somehow involve a stack. We could make a "guess" about the positions of the numbers based on one condition. Then when we encounter the next condition, if there's no possible way to satisfy it, we'd need to backtrack (pop the stack?) and try placing the previous condition's numbers somewhere else. I'm not totally sure yet how this would work... |
| 10 | +- Something else I'm thinking of is that not all of the conditions necessarily interact. E.g., suppose we have `[[1, 2], [2, 3], [4, 5], [5, 6]]`. The placements of 1, 2, and 3 don't depend on the placements of 4, 5, or 6-- so the two sequences can be optimized independently. One potential way to track this would be using a list of sets. At first we start a single set of numbers containing just the first condition's numbers. Then, for each new condition: |
| 11 | + - If either of the numbers are in an existing set, add both numbers to that set |
| 12 | + - If the numbers appear in two different sets (e.g., one appears in one set and the other appears in a different set), we need to merge both sets |
| 13 | + - If neither number appears in any set, we need to start a new set with just those numbers. |
| 14 | + - After looping through all conditions, now we have distinct sets of numbers that can be optimized independently. |
| 15 | + - Note: I'm not totally sure what this would actually buy us...maybe more efficient? But it might also take any time we would save to do this partitioning. |
| 16 | +- Another idea: what if we maintain a hash table where the keys are the numbers $1...k$ and the values are lists of numbers that need to appear below/right of the given key. (Note: or maybe we want to do this in the opposite way by tracking numbers that need to be above/left of the given key? Come back to this...) |
| 17 | + - Creating this would be straightforward: |
| 18 | + - Start with `requirements = {i: [] for i in range(1, k + 1)}` |
| 19 | + - For each condition `c`, update the hash table using: `requirements[c[0]].append(c[1]]` (Note: to do the "reverse" tracking we'd just use `requirements[c[1]].append(c[0]]`) |
| 20 | + - Now maybe we start with...what? Just go in order from $1...k$? Let's say we start a list as `order = [1]`. |
| 21 | + - Next, maybe we go through each key, `i`, but going through the `order` list and placing it at the earliest position where the given constraints are satisfied? |
| 22 | + - Checking those constraints might be expensive...e.g., if `a` is in `requirements[x]`, then we know we want to place `a` after `x`. But how *much* after `x`? Can we mark in some way that `x` is the "early bound" of where `a` can be shifted? Or...maybe it's fine if we only allow prepending, inserting, or appending operations, since the relative positions of anything already placed in the list will remain unchanged. |
| 23 | + - Maybe we want to do some sort of updating operation...like if `a` needs to be before `b` and `b` needs to be before `c`, then even though it might not appear in the conditions, it might be useful to have `c` in the list of numbers that `a` needs to be in front of. This also makes me realize that we should use sets, not lists-- because each number only needs to appear in the values for some key at most once. |
| 24 | + - Then again, this could be expensive...every time we add a new instruction, we need to go through the full hash table, which takes $O(k)$ steps. But...maybe it's necessary? Or maybe it'll save time in some other part of the algorithm? |
| 25 | +- Ok, so what about something like this: |
| 26 | +``` |
| 27 | +<Create the requirements hash table as described above> |
| 28 | +order = [1] |
| 29 | +for k in range(2, k + 1): |
| 30 | + i = 0 # everything to the left of this has k in its requirements |
| 31 | + while i < len(orders) - 1: |
| 32 | + if k in requirements[orders[i]]: |
| 33 | + i += 1 |
| 34 | + <insert k into orders at position i> |
| 35 | +<now we need to do some sort of check to verify that no conditions are violated...this could potentially involve going through another k^2 steps to check whether anything prior to the position of k appears in requirements[k]-- if so, return None> |
| 36 | +return order |
| 37 | +``` |
| 38 | +- Another thought: suppose that a given number, `n` *never* appears in an instruction. Then it doesn't matter where it goes. It'd have `len(requirements[n])` equal to 0. |
| 39 | +- Once we've placed a number, .... ah. Actually: I'm pretty sure this is a [topological sort problem](https://en.wikipedia.org/wiki/Topological_sorting). I'm going to cheat a bit and look up the algorithm on wikipedia... |
| 40 | + |
| 41 | + |
| 42 | +- Ok, so this is actually sort of similar to what I came up with. The key thing I was stuck on is what happens once we place a number-- do we then have to keep track of it? Or can we ignore it when placing the other numbers? Topological sort says we can just ignore it. |
| 43 | +- So I think (for each set of instructions) we just need to apply topological sort. If that fails, there is no solution so we return an empty matrix. |
| 44 | + |
5 | 45 | ## Refining the problem, round 2 thoughts
|
| 46 | +- Once we have the sort order for the rows and columns, we just initialize a matrix of zeros. |
| 47 | +- Then, for each row (`i`) and column (`j`) we fill in the entries of the matrix with the corresponding values. |
6 | 48 |
|
7 | 49 | ## Attempted solution(s)
|
8 | 50 | ```python
|
9 |
| -class Solution: # paste your code here! |
10 |
| - ... |
| 51 | +class Solution: |
| 52 | + def buildMatrix(self, k: int, rowConditions: List[List[int]], colConditions: List[List[int]]) -> List[List[int]]: |
| 53 | + def topologicalSort(conds, k): |
| 54 | + # after some experimenting, i'm realizing we need to track both a before *and* an after requirements |
| 55 | + before = {i: set() for i in range(1, k + 1)} |
| 56 | + after = {i: set() for i in range(1, k + 1)} |
| 57 | + for key, val in conds: |
| 58 | + before[key].add(val) |
| 59 | + after[val].add(key) |
| 60 | + |
| 61 | + order = [] |
| 62 | + queue = [key for key, val in after.items() if len(val) == 0] |
| 63 | + |
| 64 | + while len(queue) > 0: |
| 65 | + x = queue.pop(0) |
| 66 | + order.append(x) |
| 67 | + # remove all references from x to its neighbors |
| 68 | + for y in before[x]: |
| 69 | + after[y].remove(x) |
| 70 | + if len(after[y]) == 0: |
| 71 | + queue.append(y) |
| 72 | + |
| 73 | + return None if len(order) < k else order |
| 74 | + |
| 75 | + row_order = topologicalSort(rowConditions, k) |
| 76 | + if row_order is None: |
| 77 | + return [] |
| 78 | + |
| 79 | + col_order = topologicalSort(colConditions, k) |
| 80 | + if col_order is None: |
| 81 | + return [] |
| 82 | + |
| 83 | + matrix = [[0 for _ in range(k)] for _ in range(k)] |
| 84 | + |
| 85 | + row_pos = {num: i for i, num in enumerate(row_order)} |
| 86 | + col_pos = {num: i for i, num in enumerate(col_order)} |
| 87 | + |
| 88 | + for num in range(1, k + 1): |
| 89 | + matrix[row_pos[num]][col_pos[num]] = num |
| 90 | + |
| 91 | + return matrix |
11 | 92 | ```
|
| 93 | +- ok...not shown is a bunch of trying an failing. In the topological sort implementation, I thought I just needed to track a single dictionary with lists (sets) of numbers that had to come *after* each given item (building this up from the list of conditions). But actually, once we place an item, we also have to know if that item is referenced in *other* number's sets as needed to come *before* the other number. So to solve this, I needed to use two hash tables (one listing requirements of $a$ coming *before* $b$ and the other listing requirements of $b$ coming *after* $a$). I could have instead solved this by looping through every key's list repeatedly, but that would have been very inefficient. |
| 94 | +- after that confusion, the test cases now pass. and...I'm out of time to work on this, so i'm just going to cross my fingers and submit 🤞... 😬 |
| 95 | + |
| 96 | + |
| 97 | +- Meh...slow again 🙃 |
| 98 | +- Some things one "could" optimize (if one were so inclined): |
| 99 | + - Using the `deque` object instead of a list as the queue. Actually...that's not so bad to implement. Out of curiousity, let's see if that "just works"... |
| 100 | + |
| 101 | +### Revised solution using `dequeue` |
| 102 | +```python |
| 103 | +from collections import deque |
| 104 | + |
| 105 | +class Solution: |
| 106 | + def buildMatrix(self, k: int, rowConditions: List[List[int]], colConditions: List[List[int]]) -> List[List[int]]: |
| 107 | + def topologicalSort(conds, k): |
| 108 | + # after some experimenting, i'm realizing we need to track both a before *and* an after requirements |
| 109 | + before = {i: set() for i in range(1, k + 1)} |
| 110 | + after = {i: set() for i in range(1, k + 1)} |
| 111 | + for key, val in conds: |
| 112 | + before[key].add(val) |
| 113 | + after[val].add(key) |
| 114 | + |
| 115 | + order = [] |
| 116 | + queue = deque([key for key, val in after.items() if len(val) == 0]) |
| 117 | + |
| 118 | + while len(queue) > 0: |
| 119 | + x = queue.popleft() |
| 120 | + order.append(x) |
| 121 | + # remove all references from x to its neighbors |
| 122 | + for y in before[x]: |
| 123 | + after[y].remove(x) |
| 124 | + if len(after[y]) == 0: |
| 125 | + queue.append(y) |
| 126 | + |
| 127 | + return None if len(order) < k else order |
| 128 | + |
| 129 | + row_order = topologicalSort(rowConditions, k) |
| 130 | + if row_order is None: |
| 131 | + return [] |
| 132 | + |
| 133 | + col_order = topologicalSort(colConditions, k) |
| 134 | + if col_order is None: |
| 135 | + return [] |
| 136 | + |
| 137 | + matrix = [[0 for _ in range(k)] for _ in range(k)] |
| 138 | + |
| 139 | + row_pos = {num: i for i, num in enumerate(row_order)} |
| 140 | + col_pos = {num: i for i, num in enumerate(col_order)} |
| 141 | + |
| 142 | + for num in range(1, k + 1): |
| 143 | + matrix[row_pos[num]][col_pos[num]] = num |
| 144 | + |
| 145 | + return matrix |
| 146 | +``` |
| 147 | + |
| 148 | + |
| 149 | + |
| 150 | +Huh, ok, so it does seem to make a difference. Good to know! Other things to optimize: |
| 151 | + - There's definitely a way to improve memory use (by not "double storing" both the `before` and `after` hash tables). That would roughly halve memory use. |
| 152 | + - I can't think of a non-topological sort solution, but maybe there's something? |
| 153 | + |
| 154 | + |
| 155 | + |
0 commit comments