LeetCode 1632 - Rank Transform of a Matrix
The problem asks us to assign a rank to every element in a matrix while preserving ordering relationships inside rows and columns. For any two elements that share the same row or the same column: - If one value is smaller, its rank must also be smaller.
Difficulty: 🔴 Hard
Topics: Array, Union-Find, Graph Theory, Topological Sort, Sorting, Matrix
Solution
Problem Understanding
The problem asks us to assign a rank to every element in a matrix while preserving ordering relationships inside rows and columns.
For any two elements that share the same row or the same column:
- If one value is smaller, its rank must also be smaller.
- If two values are equal, their ranks must also be equal.
Additionally, the assigned ranks must be as small as possible while satisfying all constraints.
The input is an m x n matrix where:
mandncan each be as large as500- Values may be negative
- Values may repeat many times
The output is another matrix of the same dimensions where each position contains the computed rank.
The important challenge is that equal values connected through rows and columns must receive the same rank. This creates groups of cells that must be processed together. A naive local comparison approach fails because rank dependencies can propagate across the matrix.
For example:
1 2
3 4
The value 4 must have rank 3, not rank 2, because it is greater than both 2 and 3, and those already require rank 2.
The constraints are large enough that expensive repeated propagation or graph relaxation approaches will time out. A solution must efficiently process up to 250,000 cells.
Several edge cases are especially important:
- Multiple equal values in the same row or column
- Equal values connected indirectly through chains
- Negative numbers
- Single row or single column matrices
- Large groups of repeated values
- Cases where equal values must share a rank even though they appear far apart
The problem guarantees that a unique minimum valid ranking exists.
Approaches
Brute Force Approach
A brute force solution would repeatedly try to update ranks until all row and column constraints are satisfied.
One possible implementation would:
- Initialize all ranks to
1 - Compare every pair of cells sharing a row or column
- If ordering constraints are violated, increase ranks
- Repeat until no changes occur
This eventually converges because ranks only increase and the constraints are finite.
However, this approach is extremely inefficient. Each iteration may scan many row and column relationships, and many iterations may be required before ranks stabilize.
With up to 250,000 cells, repeated propagation becomes far too expensive.
Key Insight
The critical observation is that matrix values should be processed in sorted order.
Suppose we already know the final ranks for all smaller values. Then for a current value:
- Its rank must be one greater than the maximum rank already present in its row or column.
- Equal values connected through rows or columns must be merged together and assigned the same rank.
This naturally suggests:
- Sort cells by value
- Process equal values together
- Use Union-Find to group connected equal-value cells
- Track the maximum rank already assigned to each row and column
The Union-Find structure is essential because equal values may form connected components through rows and columns.
For example:
7 7
7 7
All four cells must share the same rank.
Even more subtle cases exist where equality connections are indirect.
By processing values in ascending order, we ensure all smaller dependencies are already resolved before assigning the next rank.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O((mn)^2) or worse | O(mn) | Repeatedly propagates rank constraints until stable |
| Optimal | O(mn log(mn)) | O(mn) | Sorts values and uses Union-Find for equal groups |
Algorithm Walkthrough
- Create a mapping from value to all positions containing that value.
We process values in ascending order because smaller values determine the minimum valid ranks for larger values.
2. Maintain an array called rank.
The first m positions represent row ranks.
The next n positions represent column ranks.
rank[i] stores the maximum rank already assigned in that row or column.
3. Process matrix values in sorted order.
For every distinct value:
- Gather all cells containing that value.
- Build connected components among equal-value cells using Union-Find.
- Build Union-Find groups.
For each cell (r, c):
- Treat row
rand columncas nodes. - Union row node
rwith column nodec + m.
This connects all equal-value cells sharing rows or columns. 5. Group cells by their Union-Find root.
Cells in the same component must receive the same rank because they are equal and connected through row/column relationships. 6. Compute the rank for each component.
For every cell (r, c) in a component:
-
The new rank must exceed both:
-
the current rank of row
r -
the current rank of column
c
Therefore:
new_rank = max(rank[r], rank[c + m]) + 1
The component rank is the maximum such value across all cells in the component. 7. Assign the computed rank to every cell in the component. 8. Update row and column rank trackers.
After assigning ranks:
rank[r] = component_rankrank[c + m] = component_rank
This ensures larger future values build on correct dependencies.
Why it works
Processing values in ascending order guarantees that all smaller values already have finalized ranks. Union-Find ensures that all connected equal-value cells are treated as one unit. The algorithm always assigns the smallest possible rank consistent with existing row and column constraints. Because every assignment is minimal and dependencies are processed in sorted order, the final ranking is both valid and minimal.
Python Solution
from typing import List
from collections import defaultdict
class UnionFind:
def __init__(self):
self.parent = {}
def find(self, x):
if x != self.parent.setdefault(x, x):
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
self.parent[self.find(x)] = self.find(y)
class Solution:
def matrixRankTransform(self, matrix: List[List[int]]) -> List[List[int]]:
rows = len(matrix)
cols = len(matrix[0])
value_positions = defaultdict(list)
for r in range(rows):
for c in range(cols):
value_positions[matrix[r][c]].append((r, c))
answer = [[0] * cols for _ in range(rows)]
# rank[i] for rows, rank[rows + j] for columns
rank = [0] * (rows + cols)
for value in sorted(value_positions):
uf = UnionFind()
positions = value_positions[value]
# Build connected components
for r, c in positions:
uf.union(r, c + rows)
groups = defaultdict(list)
# Group cells by connected component
for r, c in positions:
root = uf.find(r)
groups[root].append((r, c))
updates = []
# Compute rank for each component
for group in groups.values():
max_rank = 0
for r, c in group:
max_rank = max(
max_rank,
rank[r],
rank[c + rows]
)
new_rank = max_rank + 1
for r, c in group:
answer[r][c] = new_rank
updates.append((r, c, new_rank))
# Apply updates after processing entire value
for r, c, new_rank in updates:
rank[r] = new_rank
rank[c + rows] = new_rank
return answer
The implementation begins by grouping matrix positions according to their values. This allows all equal values to be processed together.
The rank array tracks the highest assigned rank for every row and column. Rows occupy indices [0, rows-1], while columns occupy [rows, rows+cols-1].
For each distinct value, a fresh Union-Find structure is created. Rows and columns connected by equal-value cells are unioned together. This creates components representing cells that must share the same rank.
After building components, the algorithm computes the smallest valid rank for each group by examining the maximum existing row and column ranks.
The updates are stored temporarily because all equal-value cells must be processed simultaneously. Updating ranks immediately could incorrectly affect other cells with the same value.
Finally, the computed ranks are written into the answer matrix and row/column trackers are updated.
Go Solution
package main
import (
"sort"
)
type UnionFind struct {
parent map[int]int
}
func NewUnionFind() *UnionFind {
return &UnionFind{
parent: make(map[int]int),
}
}
func (uf *UnionFind) Find(x int) int {
if _, exists := uf.parent[x]; !exists {
uf.parent[x] = x
}
if uf.parent[x] != x {
uf.parent[x] = uf.Find(uf.parent[x])
}
return uf.parent[x]
}
func (uf *UnionFind) Union(x, y int) {
rootX := uf.Find(x)
rootY := uf.Find(y)
if rootX != rootY {
uf.parent[rootX] = rootY
}
}
func matrixRankTransform(matrix [][]int) [][]int {
rows := len(matrix)
cols := len(matrix[0])
valuePositions := make(map[int][][]int)
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
val := matrix[r][c]
valuePositions[val] = append(valuePositions[val], []int{r, c})
}
}
values := make([]int, 0, len(valuePositions))
for val := range valuePositions {
values = append(values, val)
}
sort.Ints(values)
answer := make([][]int, rows)
for i := range answer {
answer[i] = make([]int, cols)
}
rank := make([]int, rows+cols)
for _, val := range values {
uf := NewUnionFind()
positions := valuePositions[val]
for _, pos := range positions {
r := pos[0]
c := pos[1]
uf.Union(r, c+rows)
}
groups := make(map[int][][]int)
for _, pos := range positions {
r := pos[0]
c := pos[1]
root := uf.Find(r)
groups[root] = append(groups[root], []int{r, c})
}
type Update struct {
rank int
row int
col int
}
updates := []Update{}
for _, group := range groups {
maxRank := 0
for _, pos := range group {
r := pos[0]
c := pos[1]
if rank[r] > maxRank {
maxRank = rank[r]
}
if rank[c+rows] > maxRank {
maxRank = rank[c+rows]
}
}
newRank := maxRank + 1
for _, pos := range group {
r := pos[0]
c := pos[1]
answer[r][c] = newRank
updates = append(updates, Update{
rank: newRank,
row: r,
col: c,
})
}
}
for _, upd := range updates {
rank[upd.row] = upd.rank
rank[upd.col+rows] = upd.rank
}
}
return answer
}
The Go implementation follows the same logic as the Python solution. Because Go does not provide built-in dictionaries with default initialization, the Union-Find Find method explicitly initializes missing parents.
Slices are used instead of tuples for coordinate storage. Structs are used for update tracking because Go does not support lightweight tuple syntax.
No integer overflow concerns exist because ranks never exceed m + n.
Worked Examples
Example 1
Input:
[[1,2],
[3,4]]
Step 1: Group by Value
| Value | Positions |
|---|---|
| 1 | (0,0) |
| 2 | (0,1) |
| 3 | (1,0) |
| 4 | (1,1) |
Initial rank array:
[0,0,0,0]
Rows use indices 0,1
Columns use indices 2,3
Process Value 1
Cell (0,0) connects row 0 and column 2.
Current max:
max(rank[0], rank[2]) = max(0,0) = 0
Assign rank:
1
Updated:
answer =
[[1,0],
[0,0]]
rank =
[1,0,1,0]
Process Value 2
Cell (0,1) connects row 0 and column 3.
Current max:
max(1,0) = 1
Assign:
2
Updated:
answer =
[[1,2],
[0,0]]
rank =
[2,0,1,2]
Process Value 3
Cell (1,0) connects row 1 and column 2.
Current max:
max(0,1) = 1
Assign:
2
Updated:
answer =
[[1,2],
[2,0]]
rank =
[2,2,2,2]
Process Value 4
Cell (1,1) connects row 1 and column 3.
Current max:
max(2,2) = 2
Assign:
3
Final answer:
[[1,2],
[2,3]]
Example 2
Input:
[[7,7],
[7,7]]
All cells belong to one connected component.
Every row and column initially has rank 0.
Therefore all cells receive:
1
Final answer:
[[1,1],
[1,1]]
Example 3
Input:
[[20,-21,14],
[-19,4,19],
[22,-47,24],
[-19,4,19]]
Sorted values:
-47, -21, -19, 4, 14, 19, 20, 22, 24
Processing proceeds in ascending order.
Important connected components:
| Value | Connected Cells |
|---|---|
| -19 | (1,0), (3,0) |
| 4 | (1,1), (3,1) |
| 19 | (1,2), (3,2) |
These equal values share rows and columns, so Union-Find merges them and assigns equal ranks.
Final result:
[[4,2,3],
[1,3,4],
[5,1,6],
[1,3,4]]
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(mn log(mn)) | Sorting distinct values dominates |
| Space | O(mn) | Hash maps, Union-Find, and answer matrix |
The matrix contains mn cells. Grouping positions takes linear time. Sorting values costs O(mn log(mn)) in the worst case when all values are distinct.
Union-Find operations are nearly constant time because of path compression. Every cell participates in only a small number of operations.
The space usage is linear because we store position groups, Union-Find structures, rank trackers, and the output matrix.
Test Cases
from typing import List
class Solution:
def matrixRankTransform(self, matrix: List[List[int]]) -> List[List[int]]:
from collections import defaultdict
class UnionFind:
def __init__(self):
self.parent = {}
def find(self, x):
if x != self.parent.setdefault(x, x):
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
self.parent[self.find(x)] = self.find(y)
rows = len(matrix)
cols = len(matrix[0])
value_positions = defaultdict(list)
for r in range(rows):
for c in range(cols):
value_positions[matrix[r][c]].append((r, c))
answer = [[0] * cols for _ in range(rows)]
rank = [0] * (rows + cols)
for value in sorted(value_positions):
uf = UnionFind()
positions = value_positions[value]
for r, c in positions:
uf.union(r, c + rows)
groups = defaultdict(list)
for r, c in positions:
groups[uf.find(r)].append((r, c))
updates = []
for group in groups.values():
max_rank = 0
for r, c in group:
max_rank = max(
max_rank,
rank[r],
rank[c + rows]
)
new_rank = max_rank + 1
for r, c in group:
answer[r][c] = new_rank
updates.append((r, c, new_rank))
for r, c, new_rank in updates:
rank[r] = new_rank
rank[c + rows] = new_rank
return answer
sol = Solution()
assert sol.matrixRankTransform([[1,2],[3,4]]) == [[1,2],[2,3]] # basic increasing matrix
assert sol.matrixRankTransform([[7,7],[7,7]]) == [[1,1],[1,1]] # all equal values
assert sol.matrixRankTransform(
[[20,-21,14],[-19,4,19],[22,-47,24],[-19,4,19]]
) == [[4,2,3],[1,3,4],[5,1,6],[1,3,4]] # complex dependency example
assert sol.matrixRankTransform([[1]]) == [[1]] # single cell matrix
assert sol.matrixRankTransform([[1,2,3]]) == [[1,2,3]] # single row
assert sol.matrixRankTransform([[1],[2],[3]]) == [[1],[2],[3]] # single column
assert sol.matrixRankTransform([[5,5,1]]) == [[2,2,1]] # repeated values in same row
assert sol.matrixRankTransform([[1,2],[1,3]]) == [[1,2],[1,3]] # repeated column values
assert sol.matrixRankTransform([[10,-10],[0,5]]) == [[3,1],[2,3]] # negative values
assert sol.matrixRankTransform([[1,1],[1,2]]) == [[1,1],[1,2]] # connected equal component
Test Summary
| Test | Why |
|---|---|
[[1,2],[3,4]] |
Basic increasing relationships |
[[7,7],[7,7]] |
All values equal |
| Complex example from prompt | Tests multiple connected equal groups |
[[1]] |
Smallest possible matrix |
| Single row | Verifies row-only ordering |
| Single column | Verifies column-only ordering |
| Repeated values in row | Ensures equal ranks |
| Repeated values in column | Ensures column consistency |
| Negative values | Confirms sorting handles negatives |
| Connected equal component | Tests Union-Find grouping |
Edge Cases
All Values Equal
When every cell contains the same value, every cell must receive the same rank. A buggy implementation might incorrectly assign increasing ranks while scanning rows or columns sequentially. The Union-Find grouping prevents this by merging all connected equal-value cells into one component and assigning them a single shared rank.
Equal Values Connected Indirectly
Consider:
1 2 2
2 2 3
Some equal values are not directly adjacent but become connected through shared rows and columns. A naive implementation that only checks immediate neighbors could assign inconsistent ranks. The Union-Find structure correctly merges all connected equal-value cells into the same component.
Updating Ranks Too Early
Suppose several equal-value cells are being processed together. If row and column ranks are updated immediately after each cell, later cells of the same value may incorrectly see inflated dependencies and receive ranks that are too large. The implementation avoids this by storing updates temporarily and applying them only after all groups for the current value have been processed.