12

I'm currently working on a project Euler problem (www.projecteuler.net) for fun but have hit a stumbling block. One of the problem provides a 20x20 grid of numbers and asks for the greatest product of 4 numbers on a straight line. This line can be either horizontal, vertical, or diagonal.

Using a procedural language I'd have no problem solving this, but part of my motivation for doing these problems in the first place is to gain more experience and learn more Haskell.
As of right now I'm reading in the grid and converting it to a list of list of ints, eg -- [[Int]]. This makes the horizontal multiplication trivial, and by transposing this grid the vertical also becomes trivial.

The diagonal is what is giving me trouble. I've thought of a few ways where I could use explicit array slicing or indexing, to get a solution, but it seems overly complicated and hackey. I believe there is probably an elegant, functional solution here, and I'd love to hear what others can come up with.

Zero Piraeus
  • 56,143
  • 27
  • 150
  • 160
untwisted
  • 271
  • 1
  • 8
  • please specify the Euler problem number too. some people have already solved it and might desire to look at their own solution, and perhaps give you a useful answer based on it – yairchu May 08 '10 at 13:38

5 Answers5

11

I disagree with the estimable Don Stewart. Given the combinatorial nature of the problem and the fact that the problem size is only 20x20, lists of lists are going to be plenty fast enough. And the last thing you want is to futz around with array indexing. Instead I suggest that you extend the techniques developed by Richard Bird in his justly famous sudoku solver. To be more specific, I'd suggest the following:

  • Write a function that given a sequence, returns all contiguous subsequences of length 4.

  • Write a function that given a grid, returns all rows.

  • Write a function that given a grid, returns all columns.

  • Write a function that given a grid, returns all diagonals.

With these functions in hand, your solution will be easy. But as you mention the diagonal is not so obvious. What is a diagonal anyway? Let's look at an example:

X . . . . .
. X . . . .
. . X . . . 
. . . X . .
. . . . X .
. . . . . X

Suppose for a moment that you use the drop function and you drop 0 elements from row 0, 1 element from row 1, and so on. Here's what you wind up with:

X . . . . .
X . . . .
X . . . 
X . .
X .
X

The elements of the diagonal now form the first column of the triangular thing you have left. Even better, every column of the thing you have left is a diagonal of the original matrix. Throw in a few symmetry transformations and you'll easily be able to enumerate all the diagonals of a square matrix of any size. Whack each one with your "contiguous subsequences of length 4" function, and Bob's your uncle!


A little more detail for those who may be stuck:

The key to this problem is composition. Diagonals come in four groups. My example gives one group. To get the other three, apply the same function to the mirror image, the transpose, and the mirror image of the transpose.

  • Transpose is a one-line function, and you need it anyway to recover columns cleanly.

  • Mirror image is even simpler than transpose—think about what functions you can use from the Prelude.

The symmetry method will give each major diagonal twice; luckily for the problem stated it's OK to repeat a diagonal.

Norman Ramsey
  • 198,648
  • 61
  • 360
  • 533
  • I was thinking of solving this over the weekend and your example shows (validates?) the approach I was planning to take :) – Tim Perry May 08 '10 at 01:45
  • " the last thing you want is to futz around with array indexing" -- the array libraries like vector are based on combinators, so its no worse than the list API in ease of use. But I take your point on 20x20. – Don Stewart May 08 '10 at 05:37
  • @Don: Oh, nice. I followed your vector link and was overwhelmed by the number of yummy vector functions available. – Norman Ramsey May 08 '10 at 14:42
  • I like the basic idea here, but when I try to develop it to get all diagonals (both types), I get a rather hairy code ball, whose efficiency is awful. The big problem is that the request length is less than the grid size. Hence, you need a separate "triangularization" for each row. – MtnViewMark May 08 '10 at 18:37
2

Lists are the wrong data structure for this problem, as they don't provide random indexing in constant time -- they bias towards linear traversals. So your diagonals will always be more annoying/slower with lists.

How about using arrays? E.g. parallel vectors or regular vectors.

Don Stewart
  • 137,316
  • 36
  • 365
  • 468
  • I haven't really looked much into alternative data structures yet, though would these provide anything for me in terms of ease of implementation or just efficiency and speed? By the way, thanks for the great book, it is a large part of the reason I'm as far along in my Haskell learning as I am :) – untwisted May 08 '10 at 00:22
  • 1
    @untwisted: it should be easier to implement your solution using an array type, because you can index your array as a tuple (x,y) coordinate, so if you are using haskell's vanilla `Data.Array` library, you would have a type of `Array (Int,Int) Int`. This will also be much faster unless you have a really clever algorithm using [[Int]]. – jberryman May 08 '10 at 00:54
  • Thanks for the clarification, that would make it much easier than the [[Int]] to work with. – untwisted May 08 '10 at 04:14
2

Well, for this particular problem, a single linear list or array is actually the easiest structure! The key is to think about these runs as skipping through the list with a given stride. If the grid is w × h in size, then

  • a horizontal run has a stride of 1
  • a vertical run has a stride of w
  • one diagonal run has a stride of w-1
  • one diagonal run has a stride of w+1

Now, for each of the four kinds of runs, you just need to compute the possible starting points. Something like this:

allRuns :: Int -> Int -> Int -> [a] -> [[a]]
allRuns n w h es = horiz ++ vert ++ acute ++ grave
    where horiz = runs [0..w-n]   [0..h-1] 1
          vert  = runs [0..w-1]   [0..h-n] w
          acute = runs [n-1..w-1] [0..h-n] (w-1)
          grave = runs [0..w-n]   [0..h-n] (w+1)

          runs xs ys s = [run (x+y*w) s | x <- xs, y <- ys]
          run i s = map (es!!) [i,i+s..i+(n-1)*s]

Of course, in an efficient implementation, you'd replace the [a] with something like Data.Array Int a and es!! with es!

MtnViewMark
  • 5,120
  • 2
  • 20
  • 29
  • Index arithmetic is FORTRAN, not Haskell. – Norman Ramsey May 08 '10 at 14:43
  • 1
    Indeed it is, but in this case, the problem *is* essentially one of indexing. I've yet to see a solution that generates all runs that is this short and clear. I think, in the end, this expresses what the problem is after rather directly. And I think *that* is the essence of Haskell! – MtnViewMark May 08 '10 at 16:21
1

So you have a NxN grid and you want to extract all horizontal, vertical and diagonal lines of length M, then to find maximum product. Let's illustrate some Haskell techniques on example 4x4 grid, with line length being 2:

[[ 1, 2, 3, 4],
 [ 5, 6, 7, 8],
 [ 9,10,11,12],
 [13,14,15,16]]

Horizontal and vertical is easy, all you need is a function that extract chunks of length M from a list:

chunks 2 [1,2,3,4] == [[1,2],[2,3],[3,4]]

The type of such function is [a] -> [[a]]. This is a list-related function, so before reinventing the wheel, let's see if there's something similar in Data.List. Aha, tails is similar, it returns lists with more and more elements from the beginning of the list removed:

tails [1,2,3,4] == [[1,2,3,4],[2,3,4],[3,4],[4],[]]

If only we could shorten the sublists to make them of length 2. But we can, by using map function, which applies a function to every element of the list and returns a new list:

map (take n) (tails xs) -- [[1,2],[2,3],[3,4],[4],[]]

I wouldn't worry about smaller lines, as the original task is to find the biggest product, and product of [15, N] ≥ product of [15], N ≥ 1. But if you want to get rid of them, it seems that a list of length N contains N-M+1 chunks of length M, so you could apply take (4-2+1) to the resulting list. Alternatively you could simply filter the list:

chunks n xs = filter ((==n) . length) $ map (take n) (tails xs)
-- [[1,2],[2,3],[3,4]]

Ok, we can extract a list of chunks from a list, but we have a 2D-grid, not a flat list! map again rescues us:

map (chunks 2) grid -- [[[1,2],[2,3],[3,4]],[[5,6],[6,7],[7,8]],...]

But here's the thing, the resulting code puts chunks in separate lists, and it complicates things, as we don't actually care, from which line does the chunk originate. So we would want to flatten one level the resulting list by concat . map or equivalent concatMap:

concatMap (chunks 2) grid -- [[1,2],[2,3],[3,4],[5,6],[6,7],[7,8],...]

Now, how do I get vertical chunks from a grid? Sounds scary at first, until you realize that you can transpose the whole grid, i.e. turn rows into columns and columns into rows, and then apply the same code:

concatMap (chunks 2) (transpose grid) -- [[1,5],[5,9],[9,13],[2,6],[6,10],...]

Now the hard part: the diagonal lines. Norman Ramsey gives an idea: what if you could drop 0 elements from line 0, 1 elements from line 1, etc? The diagonal line would become a vertical line, which is easy to extract. You remember that to apply a function to every element of a list you use map, but here you need to apply different functions to a each element, namely drop 0, drop 1, drop 2, etc. map wouldn't suit. But look, the first argument to drop forms a pattern of successive numbers, which may be represented as an infinite list [0..]. Now what if we could take one element from [0..] What we need is a function that takes a number from an infinite list [0..] and a row from the grid, and applies drop with this number to the row. zipWith is what you need:

zipWith drop [0..] grid -- [[1,2,3,4],[6,7,8],[11,12],[16]]
map head $ zipWith drop [0..] grid -- [1,6,11,16]

But I want all diagonals of length 2, not just the biggest diagonal. So look at the grid and think, what diagonal lines you see with elements on row 0? [1,6],[2,7],[3,8]. So it's clear that you need to take only first 2 rows and transpose elements:

transpose $ zipWith drop [0,1] grid -- [[1,6],[2,7],[3,8],[4]]

Now how do I get diagonals starting from other rows as well? Remember our tails trick? We can get all diagonals by providing our new function to a concatMap and applying it to tails grid:

concatMap (transpose . zipWith drop [0,1]) (tails g)
-- [[1,6],[2,7],[3,8],[5,10],[6,11],...]

But these are only diagonals that go from top-left to bottom-right. What about those that go from top-right to bottom-left? It's easiest just to reverse the rows of the grid:

concatMap (transpose . zipWith drop [0,1]) (tails $ reverse g)
-- [[13,10],[14,11],[15,12],[9,6],[10,7],...]

Finally, you need to find products of all lines and choose the biggest. The final code looks like:

grid = [[1..4],[5..8],[9..12],[13..16]]
chunks n xs = map (take n) (tails xs)
horizontal = concatMap (chunks 2) grid
vertical = concatMap (chunks 2) (transpose grid)
grave = concatMap (transpose . zipWith drop [0,1]) (tails grid)
acute = concatMap (transpose . zipWith drop [0,1]) (tails $ reverse grid)
maxProduct = maximum $ map product $ horizontal ++ vertical ++ grave ++ acute
-- answer: 240

Is this code maximally elegant and efficient? Hell no, but it works and illustrates certain patterns of thinking of functional programming. At first you need to write code that just works, then iteratively refactor it, until you arrive at a solution that is both easy to read and general.

Community
  • 1
  • 1
Mirzhan Irkegulov
  • 17,660
  • 12
  • 105
  • 166
1

You can use the !! function to retrieve elements in a list by index. That with a fixed step either incrementing or decrementing the index gets you a diagonal.

MSN
  • 53,214
  • 7
  • 75
  • 105
  • Random access on lists is O(n) though. Of course that's probably not a problem for a mere 20x20 grid. – sepp2k May 08 '10 at 00:00
  • This was what I was referring to when I mentioned being able to solve it using indexing and slicing. Maybe this is the best solution, I just had an intuition that there was something more elegant I could try. – untwisted May 08 '10 at 00:25