Matrix Traversal on 8K Images: Column-Major 10x Slower
Column-major traversal on 8K images drops L1 cache from 95% to 12%, causing 10x slower throughput.
20+ years shipping performance-critical code where algorithms decide the bill. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Row-major: left-to-right, top-to-bottom. Cache-friendly. Default for most operations.
- Column-major: top-to-bottom, left-to-right. 5-10x slower on large matrices due to cache misses.
- Spiral: boundary shrinking (top, bottom, left, right). Used for layer-by-layer processing.
- Diagonal: group by row+col invariant. Used for JPEG zigzag, DP table fill.
- Image processing pipelines use row-major for filters, column-major for rotation.
- Spiral traversal is used in matrix rotation and layer-based compression.
- Missing guard checks (top <= bottom, left <= right) in spiral traversal. Causes duplicate visits on non-square matrices.
Imagine a chessboard. A matrix is just that — a grid of rows and columns. Traversal simply means visiting every square on that board in a specific order. Sometimes you scan left-to-right like reading a book. Sometimes you spiral inward like unwinding a clock. The pattern you choose depends entirely on what you're looking for and where the data lives in that grid.
Matrices are the fundamental data structure for image processing, game boards, adjacency tables, pathfinding, and spreadsheet operations. Every image filter, matrix rotation, and grid-based search relies on a specific traversal pattern. Choosing the wrong pattern does not just slow your code — it produces wrong results.
The core problem is direction and order. A flat array has one traversal: front to back. A matrix has dozens: row-major, column-major, diagonal, anti-diagonal, spiral inward, spiral outward, and BFS/DFS flood fill. Row-major traversal is 5-10x faster than column-major on large matrices due to CPU cache locality. Spiral traversal requires careful boundary management to avoid duplicate visits on non-square grids.
The common misconception is that all traversal patterns have the same performance. Row-major reads contiguous memory — cache-friendly. Column-major jumps across rows — cache-hostile. On a 4096x4096 image, the difference is 200ms vs 2 seconds. Understanding memory layout is what separates a correct solution from a production-grade one.
Why Row-Major vs Column-Major Traversal Is a 10x Performance Trap
Matrix traversal patterns define the order in which you access elements of a 2D array. The core mechanic is simple: you iterate over rows and columns, but the order — row-major (outer loop over rows, inner over columns) versus column-major (outer loop over columns, inner over rows) — determines whether your CPU cache works for you or against you. On an 8K image (7680×4320 pixels), that choice can make traversal 10x slower.
In practice, Java stores 2D arrays as arrays of arrays, laid out row by row in contiguous memory. Row-major access reads memory sequentially, hitting L1 cache lines (typically 64 bytes) with every stride. Column-major access jumps across rows, skipping cache lines and causing cache misses on nearly every access. The penalty scales with matrix size: for a 33-megapixel image, the difference between 5 ms and 50 ms per traversal is the difference between 200 fps and 20 fps.
Use row-major traversal whenever you read or write every element — image processing, matrix multiplication, convolution kernels. Column-major is only justified when your algorithm inherently needs column-wise data (e.g., column-wise statistics, certain linear algebra operations). In real systems, the wrong traversal pattern is the silent killer of throughput, especially in latency-sensitive pipelines like real-time video processing or scientific simulations.
Worked Example — Spiral Order Traversal of a 3x3 Matrix
Matrix M = [[1,2,3],[4,5,6],[7,8,9]]. Spiral order traversal:
- Initialize: top=0, bottom=2, left=0, right=2, result=[].
- Traverse top row left-to-right: [1,2,3]. top becomes 1.
- Traverse right column top-to-bottom: [6,9]. right becomes 1.
- Traverse bottom row right-to-left: [8,7]. bottom becomes 1.
- Traverse left column bottom-to-top: [4]. left becomes 1.
- top>bottom (1>1 is false) but top==bottom: traverse remaining top row [5]. top becomes 2.
- top>bottom → stop. Result: [1,2,3,6,9,8,7,4,5].
DFS on grid for number of islands (1=land, 0=water) in [[1,1,0],[0,1,0],[0,0,1]]: 1. (0,0)=1: DFS marks (0,0),(0,1),(1,1) as visited. Count=1. 2. (0,2)=0: skip. 3. Continue scanning... (2,2)=1: DFS marks (2,2). Count=2. 4. Total islands = 2.
- Sequential scan: row-major or column-major. Default for most operations.
- Boundary shrinking: spiral. Four boundaries converge inward.
- Invariant grouping: diagonal. Cells grouped by row+col = constant.
- Connected component: DFS/BFS. Flood fill, number of islands.
- Most interview problems combine two building blocks.
Matrix Traversal Patterns — Plain English
Matrix traversal problems require visiting cells in a specific order. Three main patterns:
Pattern 1 — Spiral traversal: 1. Maintain boundaries: top, bottom, left, right. 2. Traverse right along top row; shrink top. 3. Traverse down right column; shrink right. 4. Traverse left along bottom row; shrink bottom. 5. Traverse up left column; shrink left. 6. Repeat while top<=bottom and left<=right.
Worked example — spiral of [[1,2,3],[4,5,6],[7,8,9]]: Right: 1,2,3. top=1. Down: 6,9. right=1. Left: 8,7. bottom=1. Up: 4. left=1. Right: 5. top(2)>bottom(1). Stop. Result: [1,2,3,6,9,8,7,4,5].
Pattern 2 — DFS/BFS flood fill (number of islands): For each cell matching condition: run DFS marking all connected cells visited. Count DFS starts = number of islands.
Pattern 3 — Diagonal traversal: Cells on the same anti-diagonal share i+j. Group by i+j to traverse diagonals.
- Specific order required → spiral (boundary shrinking) or diagonal (invariant grouping).
- Connectivity required → DFS/BFS flood fill.
- Process every cell → row-major for cache performance.
- Transpose/rotate → column-major logic within row-major tiles.
- Most interview problems combine two patterns.
Row-by-Row and Column-by-Column — The Foundation Every Pattern Builds On
Row-major traversal visits every element left-to-right, top-to-bottom — exactly how English speakers read a page. In Java, a 2D array is technically an 'array of arrays.' These sub-arrays (rows) are generally stored in contiguous blocks of memory.
Accessing elements row-by-row means sequential memory reads, which keeps the CPU cache happy (spatial locality) and makes your loop significantly faster. Column-major traversal flips this—the outer loop iterates over columns, the inner loop over rows. This is essential for operations like transposing an image or rotating a matrix 90 degrees, but it comes with a performance penalty on massive datasets because it forces the CPU to jump across memory addresses.
Diagonal Traversal — Using Mathematical Invariants
Diagonal traversal is used heavily in JPEG compression (zigzag scan) and Dynamic Programming. The key to mastering this without confusing your indices is the anti-diagonal invariant: every element on the same anti-diagonal shares a constant value for row + col.
By iterating through the sum of indices (from 0 to rows + cols - 2), you can systematically visit every diagonal. For non-square matrices, we use Math.max and Math.min to 'clamp' our indices so we don't wander outside the grid boundaries.
- Anti-diagonal: row + col = d (constant). Group cells by their diagonal sum.
- Main diagonal: row - col = d (constant). Used in matrix operations.
- Number of anti-diagonals: rows + cols - 1.
- startRow = max(0, d - cols + 1), endRow = min(d, rows - 1).
- Zigzag: alternate direction on each diagonal. d % 2 == 0 → reverse order.
Spiral Traversal — The Boundary Shrinking Technique
Spiral traversal is the ultimate test of boundary management. Instead of complex state machines, maintain four boundaries: top, bottom, left, and right. As you complete a pass (e.g., left to right), increment the top boundary. The loop terminates naturally when boundaries cross.
if (top <= bottom) and if (left <= right) checks are mandatory for non-square matrices to prevent re-processing the middle row/column twice. Without these checks, a 1x3 matrix [[1,2,3]] produces [1,2,3,2,1] instead of [1,2,3].DFS and BFS on Grids — Connected Component Traversal
DFS and BFS on grids solve connectivity problems: number of islands, flood fill, shortest path in unweighted grids, and surrounded regions. The key insight is that each cell has at most 4 neighbors (up, down, left, right), and the traversal visits each cell at most once.
DFS is simpler to implement recursively but risks StackOverflowError on large grids (rec an explicit queue and is safer for production systems. Both are O(m*n) time.
The boundary check before each recursive call is critical: 0 <= r < rows AND 0 <= c < cols AND cell is unvisited AND cell matches condition. Missing any of these checks causes ArrayIndexOutOfBoundsException or infinite recursion.
- DFS: connectivity problems. Recursive. O(mn) time, O(mn) space for visited array.
- BFS: shortest path in unweighted grids. Explicit queue. O(mn) time, O(mn) space.
- DFS recursion depth = grid size. StackOverflowError on grids > 10,000 cells.
- BFS is always production-safe. Use it unless the problem specifically requires DFS.
- Boundary check: 0 <= r < rows AND 0 <= c < cols AND unvisited AND condition.
Why Your BFS Grid Traversal Crashed in Production — The Queue Explosion Bug
You wrote a BFS to flood-fill connected components. It worked on your 50x50 test grid. Then production hit you with a 10,000x10,000 sparse matrix. Your queue held 80 million entries. The OOM killer took down the pod.
The problem isn't BFS. It's that you didn't check if a cell was already visited before enqueuing its neighbors. Every neighbor of a neighbor got enqueued twice before the first one was processed. On a dense grid, that's exponential queue growth.
Fix it: push a cell's coordinates onto the queue, then immediately mark it visited. Never let a cell enter the queue twice. This caps your queue size at the perimeter of the current frontier — worst case O(min(rows, cols)).
For truly massive grids, replace BFS with iterative DFS using an explicit stack. Same visit-before-push rule. Your memory stays flat, your latency drops, and you don't wake up to a 2 AM pager alert about pod restarts.
The Cache Miss Nightmare — Striding Backwards Through Memory Pages
You've got a 4K matrix. You need to sum all elements. Your loop goes column-by-column, inner loop over rows. Each access jumps 4K bytes to the next row. For a 1000x1000 grid, that's 1 million L1 cache misses. You just turned a 2ms operation into 200ms.
CPU caches load contiguous chunks of memory — cache lines (64 bytes typically). When you traverse a row, you nail 8 consecutive ints in one cache line. Column traversal? Every access lands in a different cache line. You're evicting lines you just loaded.
The grid is stored in row-major order (Java, C, Python lists of lists). Your traversal pattern must match memory layout. Period. The exception: column traversal only wins when your dataset fits in L1 cache entirely — think 16x16 convolution kernels.
Fix: always row-major in outer loop, column-major in inner loop. Unless you're doing something esoteric. And if you are, you should be using a column-major storage format like Eigen or column-oriented databases.
perf stat -e cache-misses on both loops. The column-major version will show 10-100x more misses.Parallel Traversal — Splitting a Grid Across Threads Without Losing Your Mind
You've got an 8-core machine and a 10000x10000 matrix to process. Splitting rows across threads feels natural — thread 1 gets rows 0-1249, etc. But the last thread finishes 3x faster than the first because your matrix has hot rows near the top. Load imbalance kills your speedup.
Better: use a work-stealing thread pool and divide the grid into small tiles (64x64 or 128x128). Each thread picks a tile from a concurrent queue. This naturally balances work regardless of sparsity patterns. It also preserves cache locality within tiles — your L1 cache gets reused across 64 rows instead of evicting on every row boundary.
Tile size matters. Too small (< 32) and overhead from queue operations dominates. Too large (> 1024) and you're back to load imbalance. 64-128 is the sweet spot for most grids.
One gotcha: if your operation mutates adjacent cells, you need tile boundaries that overlap by 1 cell, then merge results. Or redesign the algorithm to be embarrassingly parallel (sum, histogram, convolution with non-overlapping output).
🌀 Sliding Window — Subarray Problems in O(n)
Instead of repeatedly summing every subarray from scratch, a sliding window maintains a running aggregate as you adjust the window's boundaries. This transforms O(n²) brute-force into a single O(n) pass. The core insight: once you know the sum of elements from index i to j, you can compute the sum for i+1 to j+1 by subtracting the outgoing element and adding the incoming one. Use a fixed-size window when the problem specifies a length (e.g., maximum sum of k consecutive elements). Use a variable-size window when the condition depends on a property like sum or unique characters (e.g., smallest subarray with sum ≥ target). Always identify the invariant you are maintaining — that's the window's constraint. Expand the right pointer, shrink from the left when the invariant breaks, and update the answer at valid states. This pattern works on any linear structure, including matrices when flattened row-by-row, but be cautious: when applying to grids, the window moves across rows, not columns, which may cause cache misses if you stride vertically.
2️⃣ Two Pointers — Parallel Traversal for Sorted Data
Two pointers solve problems where you need to compare or combine elements from one or two sequences, often in sorted order. The classic use is finding a pair of numbers in a sorted array that sum to a target: start one pointer at the beginning, another at the end, and move them toward each other based on the comparison of the current sum to the target. The trick is that because the array is sorted, moving the left pointer increases the sum, and moving the right pointer decreases it. This avoids the O(n²) brute force. Extend this to three-sum by fixing one element and running two pointers on the remainder. For matrix traversal, two pointers are useful when scanning diagonals (i+j = constant) or when partitioning a grid into two regions. Another variant: slow and fast pointers for cycle detection in linked lists or infinite loops in grid BFS. Always ensure your data is sorted or that your invariant (e.g., monotonicity) holds, or else two pointers lose their correctness guarantee and degrade to brute force.
🧮 Prefix Sum — From 1D Ranges to 2D Submatrix Queries
Prefix sum precomputes cumulative totals so you can answer any range sum query in O(1). In 1D, prefix[i] is the sum from index 0 to i-1; then sum(l, r) = prefix[r+1] - prefix[l]. For 2D matrices, the prefix sum is a grid where pref[i][j] = sum of all cells from (0,0) to (i-1,j-1). The magic formula for submatrix sum (r1,c1) to (r2,c2) is: pref[r2+1][c2+1] - pref[r1][c2+1] - pref[r2+1][c1] + pref[r1][c1]. This works because of inclusion-exclusion. Build the 2D prefix sum in O(m*n) by accumulating row sums (pref[i][j] = pref[i-1][j] + pref[i][j-1] - pref[i-1][j-1] + matrix[i-1][j-1]). Use this pattern when the problem involves many range queries, or when you need to check submatrix conditions (e.g., largest submatrix with sum zero). The cost: extra memory. But for repeated queries, it's a 10x speedup over iterating. A common gotcha: off-by-one errors in indices — always double-check your formula with a small test case like a 2x2 grid.
Image Rotation Pipeline 10x Slower: Column-Major Traversal on 8K Images Caused L3 Cache Thrashing
- Row-major traversal is 5-10x faster than column-major on large matrices due to CPU cache locality. This is not a micro-optimization — it is a 10x throughput difference.
- Java 2D arrays are stored row-major. Column-major traversal causes cache thrashing on large matrices.
- For matrix transpose, use tiled traversal (process small blocks in row-major order) to keep data in L1 cache.
- Profile cache hit rates, not just CPU utilization. 100% CPU utilization with 12% cache hit rate means the CPU is waiting for memory.
- Always benchmark matrix operations on production-sized data. A 100x100 test matrix hides cache effects that appear at 8K resolution.
Test with 1x3 matrix [[1,2,3]] — expected [1,2,3], if you get [1,2,3,2,1] the guards are missingTest with 3x1 matrix [[1],[2],[3]] — expected [1,2,3]| File | Command / Code | Purpose |
|---|---|---|
| io | public class WorkedExamples { | Worked Example |
| io | public class TraversalPatterns { | Matrix Traversal Patterns |
| io | public class RowAndColumnTraversal { | Row-by-Row and Column-by-Column |
| io | public class DiagonalTraversal { | Diagonal Traversal |
| io | public class SpiralTraversal { | Spiral Traversal |
| io | public class GridBFS { | DFS and BFS on Grids |
| GridBfsFix.java | public class GridBfsFix { | Why Your BFS Grid Traversal Crashed in Production |
| CacheFriendlySum.java | public class CacheFriendlySum { | The Cache Miss Nightmare |
| ParallelGridSum.java | public class ParallelGridSum { | Parallel Traversal |
| SlidingWindow.java | int maxSumK(int[] arr, int k) { | 🌀 Sliding Window |
| TwoPointers.java | int[] twoSumSorted(int[] arr, int target) { | 2️⃣ Two Pointers |
| PrefixSum2D.java | int[][] buildPrefix(int[][] mat) { | 🧮 Prefix Sum |
Key takeaways
Practice These on LeetCode
Interview Questions on This Topic
Frequently Asked Questions
20+ years shipping performance-critical code where algorithms decide the bill. Everything here is grounded in real deployments.
That's Arrays & Strings. Mark it forged?
7 min read · try the examples if you haven't