Frontend Dad Blog.

Thinking of Multidimensional Arrays as Rows and Columns

Cover Image for Thinking of Multidimensional Arrays as Rows and Columns

In doing some leetcode/algorithm practice, I've come across a series of interesting challenges that involve multidimensional or nested array structures. These problems involve modeling the array a structure that contains columns and rows. Solutions for these challenges are interesting enough that I thought they merited a blog post. They've changed the way I think about these types of problems and made them significantly less scary.

I want to credit Stephen Grider and his excellent "The Coding Interview Bootcamp: Algorithms and Data Structures course for helping me to think about these problems in a much saner way. The solutions presented here are very similar to his, and I think exactly what he posts in the Matrix Spiral example.

The "Step" Problem

Challenge:

// --- Directions
// Write a function that accepts a positive number N.
// The function should console log a step shape
// with N levels using the # character.  Make sure the
// step has spaces on the right hand side!
// --- Examples
//   steps(2)
//       '# '
//       '##'
//   steps(3)
//       '#  '
//       '## '
//       '###'
//   steps(4)
//       '#   '
//       '##  '
//       '### '
//       '####'

This is a nice introduction to the concept of moving through nested arrays with rows and columns. A couple of things to point out off the bat:

  • There is no need to return a full structure here. We can just log each row (yay!)
  • The amount of rows and columns will always be the same (also yay!)

So how can we think about the structure we are moving through here? In the below drawing, I've highlighted the rows and columns in the structure:

Row Diagram We can keep that drawing in mind while we start coding a solution.

function steps(n) {
  // Loop the "rows"
  for (let row = 0; row<n; row++) {
  	// do something...?
  }
}

Initially, we are setting up a for loop. Because of the nature of nested arrays, this initial loop will simply move through each inner array. We can think of these inner arrays as columns. If we pass 3 to our function, we want to loop over 3 columns.

But how can we access the rows?

This is where a nested loop comes into play. Our nested loop will move through each column in the current row.

function steps(n) {
  // Loop the "rows"
  for (let row = 0; row < n; row++) {
  	// access the individual columns with another loop
    for (let column = 0; column < n; column++) {
      // Now we are inside each indivdual column!
      console.log(`hello from row ${row}, column ${column}`)
    }
  }
}

If you run the above code, you'll see a log for each "square" of our overall structure from the problem statement.

If this isn't making sense, I would take a piece of paper and write this out and marinate on it. The above concept is crucial for any of the forthcoming logic or problems.

Assuming this makes sense, let's move on to the work we need to do here. We want to log a string for each row, so let's get a string ready and log it at the appropriate time:

function steps(n) {
  // Loop the "rows"
  for (let row = 0; row < n; row++) {
  	// access the individual columns with another loop

    // create a string to manipulate
    let str = "";
    for (let column = 0; column < n; column++) {
      // Now we are inside each indivdual column!

      // Ok what work do we need to do?
    }

    // log it every time we finish building a row


    console.log(str)
  }
}

The last step here is to determine which columns in a given row are given a #. We can think of that as a relationship between the current column and the current row. We know that for the first row, only the first column gets a #. And for each successive row, we add one more. We can express that like:

if (column > row) {
  string+= " ";
} else {
  string+= "#";
}

Using the previous drawing, we can visualize how this should be built out: Row Diagram

Add that condition in, and the final code looks like this, and should work:

function steps(n) {
  for (let row = 0; row < n; row++) {
    let str = ""
    for (let column = 0; column < n; column++) {
      if (column > row) {
        str += " "
      } else {
        str += "#"
      }
    }
    console.log(str)
  }
}

The "Pyramid" Problem

Challenge:

// --- Directions
// Write a function that accepts a positive number N.
// The function should console log a pyramid shape
// with N levels using the # character.  Make sure the
// pyramid has spaces on both the left *and* right hand sides
// --- Examples
//   pyramid(1)
//       '#'
//   pyramid(2)
//       ' # '
//       '###'
//   pyramid(3)
//       '  #  '
//       ' ### '
//       '#####'

This problem is a good next step if the concepts from the first challenge are starting to feel more solid. The problem statement is similar, but now we need to account for steps from "both sides" resulting in a pyramid structure.

Let's set up our basic implementation. We know that we will once again be dealing with columns and rows.

function pyramid(n) {
  //loop each row
  for (let row = 0; row < n; row++) {
    let str = "";
    // loop each column
    for (let column = 0; column < ???)
  }
}

Pretty quickly, we realize that the number of columns involved in the problem isn't as easily derived as it was in the first example. However, there is a constant formula we can use to figure this out: 2 * n - 1. Remember your order of operations here as you think through this! A pyramid of 2 rows will have 3 columns. 3 rows will have 5 columns.

Let's add that to our code:

function pyramid(n) {
  const numColumns = 2 * n - 1;
  //loop each row
  for (let row = 0; row < n; row++) {
    let str = "";
    // loop each column
    for (let column = 0; column < numColumns; column ++) {
      // Figure out when we need a `#`
    }
  }
}

With the information we have thus far, let's draw a pyramid with 3 rows: Row Diagram

This fits our criteria as defined in the function. 3 rows and 5 columns.

Now we know that we've got some work to do - we need to determine where to add our # symbols. In looking at the drawing above, there is a clear midpoint in the pyramid. We first need a way to mathematically determine which column should be considered the midpoint. We can do that with the below formula: Math.floor(numColumns /2)

This value is also a constant throughout the function, so let's add it:

function pyramid(n) {
  const numColumns = 2 * n - 1;
  const midPointIndex = Math.floor(numColumns / 2);
  //loop each row
  for (let row = 0; row < n; row++) {
    let str = "";
    // loop each column
    for (let column = 0; column < numColumns; column ++) {
      // Figure out when we need a `#`
    }
  }
}

With the midpoint value established, let's take another look at our drawing and try to understand the relationship between rows, columns and the midpoint:

Row Diagram

There's a relationship here. As we move down the rows, we can see that the blocks are inserted into the columns within a given range. It's the midpoint plus the row index on one side, and the midpoint minus the row index on the other.

Now it's 2024, and for some unknown reason, Javascript still does not have an official method to generate a simple range of numbers. Insane. Anyway, here is one way to do it from MDN.

const range = (start, stop, step) =>
  Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step);

With that helper available to us, we can establish a range from the relationship between midpoint, row and column, and then check if a given column falls within the range. With that included, the final implementation looks like this:

function pyramid(n) {
  const getRange = (start, stop, step) =>
    Array.from({
      length: (stop - start) / step + 1
    }, (_, i) => start + i * step);
  const numColumns = 2 * n - 1;
  const midPointIndex = Math.floor(numColumns / 2);
  // loop over total number of rows
  for (let row = 0; row < n; row++) {
    let level = ""
    // inside each row, loop the columns;
    for (let column = 0; column < numColumns; column++) {
      // generate a range from our bounds
      const range = getRange(midPointIndex - row, midPointIndex + row, 1)
      // check if a given column falls in that range
      const canAddBlock = range.includes(column)
      if (canAddBlock) {
        level += "#";
      } else {
        level += " ";
      }
    }
    console.log(level)

  }
}

The Spiral Problem

Challenge:

// --- Directions
// Write a function that accepts an integer N
// and returns a NxN spiral matrix.
// --- Examples
//   matrix(2)
//     [[1, 2],
//     [4, 3]]
//   matrix(3)
//     [[1, 2, 3],
//     [8, 9, 4],
//     [7, 6, 5]]
//  matrix(4)
//     [[1,   2,  3, 4],
//     [12, 13, 14, 5],
//     [11, 16, 15, 6],
//     [10,  9,  8, 7]]

This one is a doozy. The "spiral" nature of this problem can be seen upon closer inspection of the structure. The program should increment in a spiral fashion as traced in the picture below:

Row Diagram

Our task will be to programatically guide this spiral.

As before, we will be using rows and columns here. But we will be expanding on the pattern a bit to add the concept of constraints - the beginning and the end rows and columns for a given operation. This will allow us to use the constraints to narrow down the path or bounds of the loops we can expect to write.

We can think of a strategy in which we use these constraints to guide the program through the spiral.

I will start coding a solution and updating the drawing to make more sense of this.

function matrix(n) {
  // create our final structure... an array containing n subarrays
  const final = []

  for (let i = 0; i <= n; i++) {
    final.push([])
  }
  // the challenge has us counting from 1
  let counter = 1;

  // create our bounds or constraints
  let startRow = 0;
  let endRow = n - 1;
  let startColumn = 0;
  let endColumn = n - 1;

  // do something
}

Above is the basic set up of the values we will need to solve this problem. We have a final data structure to assign to, and we have established the start and end rows and columns to let us establish the bounds to guide the loop along.

Now we can think of the conditions we need to establish to keep a loop running. Essentially, we want to keep looping as long as the bounds we've established don't overrun each other. We can express that like this:

while (startColumn <= endColumn && startRow <= endRow) {
  // do something
}

And thus begins our logic. If we refer back to the drawing, we can focus in on the beginning of the spiral. It iterates across the first row: Row Diagram

We need to write a loop that says "Loop from the first to the last column in the first row and incrementally add values". That would look like this:

for (let i = startColumn; i <= endColumn; i++) {
  final[startRow][i] = counter;
  counter++
}

And looking back at the above drawing, we can see that the first row has been taken care of, we don't need to revisit it. Hence, we can increment the startRow value in code.

  for (let i = startColumn; i<= endColumn; i++) {
    final[startRow][i] = counter;
    counter++
  }
  startRow++

For now, our program looks like this:

function matrix(n) {
  // create our final structure... an array containing n subarrays
  const final = []

  for (let i = 0; i <= n; i++) {
    final.push([])
  }
  // the challenge has us counting from 1
  let counter = 1;

  // create our bounds or constraints
  let startRow = 0;
  let endRow = n - 1;
  let startColumn = 0;
  let endColumn = n - 1;

  while (startColumn <= endColumn && startRow <= endRow) {
    // do something
    // loop the top row from start to end column
    for (let i = startColumn; i<= endColumn; i++) {
      final[startRow][i] = counter;
      counter++
    }
    startRow++
  }
}

Let's look at another drawing that lays out what we need to do next: Row Diagram

We need to move from the start row to the end row, incrementing in the end column. Because we incremented the start row already, this means the value of startRow is now 1.

  for (let i = startRow; i <= endRow; i++) {
    final[i][endColumn] = counter
    counter++
  }
  endColumn--

Looking at the drawing again, we can see that the endColumn has been taken care of. We can decrement that value and work on our next piece of the spiral: Row Diagram

We need to move backwards from the endColumn to the startColumn in the endRow

  for (let i = endColumn; i >= startColumn; i--) {
    final[endRow][i] = counter;
    counter++
  }
  endRow--

And because the last row is now taken care of, we can decrement the value of endRow as well.

Finally, we need to take care of the value between the bounds of the end and start rows at the current startColumn: Row Diagram

  for (let i = endRow; i >= startRow; i--) {
    final[i][startColumn] = counter;
    counter++
  }

  startColumn++

This will start the entire while loop again, but the constraints have changed. The final number will get picked up. The complete final code looks like:

function matrix(n) {
  // create our final structure... an array containing n subarrays
  const final = []

  for (let i = 0; i < n; i++) {
    final.push([])
  }
  // the challenge has us counting from 1
  let counter = 1;

  // create our bounds or constraints
  let startRow = 0;
  let endRow = n - 1;
  let startColumn = 0;
  let endColumn = n - 1;

  while (startColumn <= endColumn && startRow <= endRow) {
    // do something
    // loop the top row from start to end column
    for (let i = startColumn; i <= endColumn; i++) {
      final[startRow][i] = counter;
      counter++
    }
    startRow++

    for (let i = startRow; i <= endRow; i++) {
      final[i][endColumn] = counter
      counter++
    }
    endColumn--

    for (let i = endColumn; i >= startColumn; i--) {
      final[endRow][i] = counter;
      counter++
    }
    endRow--

    for (let i = endRow; i >= startRow; i--) {
      final[i][startColumn] = counter;
      counter++
    }

    startColumn++
  }

  console.log(final)
}

The above problem can take a while to wrap your head around. However, the concept of using bounds to guide loops has wide applications. Think of a Sudoku game, for example.

Parting Thoughts

I don't think any of the above three examples are particularly simple problems. They require you to model data in a way that might not be completely natural or instinctive. Once you've become comfortable with these patterns, however, the basic concepts of rows and columns can be applied to all kinds of challenges.

Sources

Udemy