rubymax-path

Ruby - Project Euler # 18 - Maximum Path Sum


I've been working on some Project Euler problems, and for the most part, I've been doing pretty well. Problem 18, though has really stumped me.

Starting at the top of a tree, I'm supposed to find the path that leads to the maximum sum

      3
    7   4
  2   4   6
8   5   9   3

In this case, there are 24 possible paths, or 4! The best possible path is 3 -> 7 -> 4 -> 9 which sums up to 23.

I tried to solve the problem by replicating the example.

array = [[3],[7,4],[2,4,6],[8,5,9,3]]
array.each_slice(1){|s|p s}                          => This prints the tree

I got an answer which will on rare cases be correct, but it's not really legitimate.

sum = []
array.each{|a|sum.push(a.sample)}
return sum

This method is basically just selecting a random path and even with this easy example, there is still only a 1 in 24 chance of getting it right.

I've tried things like

level_two = []
level_three = []
for i in 0..array.length-1
  for j in 0..array[1].length-1
    level_two.push([array[0], array[1][i])        => Now level 2 has 2 paths - [3,7] & [3,4]
    for k in 0..array[2].length-1
      level_three.push([level_two[0],array[2][k], [level_two[1], array[2][k])
    end
  end
end

But at this point I can't even track which variables I'm using.

I've tried each_cons, combination and zip, none of which have worked.

Does anyone know of a strategy to solve this problem?

Edit: This won't give the answer to the problem, but simply the answer to the example. I will then apply that design pattern on the main problem.


Solution

  • This is how I would do it.

    Code

    def longest_path(arr)
      return nil if arr.empty?
    
      h = { len: arr.first.first, path: [] }
      return h if arr.size == 1
    
      arr[1..-1].reduce([h]) do |l,row|
        h = l.first
        left = { len: h[:len]+row.first, path: h[:path]+[:l] }
        mid = l.each_cons(2).to_a.zip(row[1..-2]).map do |(h1,h2),v|
                if h1[:len] >= h2[:len]
                  { len: h1[:len]+v, path: h1[:path]+[:r] }
                else
                  { len: h2[:len]+v, path: h2[:path]+[:l] }
                end
              end
        h = l.last
        right = { len: h[:len]+row.last, path: h[:path]+[:r] }
        [left, *mid, right]
      end.max_by { |h| h[:len] }
    end
    

    Example

    a = [   [3],
           [7,4],
          [2,4,6],
         [8,5,9,3]]
    
    longest_path a
      #=> {:len=>23, :path=>[:l, :r, :r]}
    

    Thus, the longest path has length 23. From 3 in the first row, down and left (:l) to 7 in the second row, down and right (:r) to 4 in the third row and down and right to 9 in the last row: 3+7+4+9 => 23.

    Explanation

    This question is as much about the implementation of an algorithm as the choice of algorithm. It seems to me that the latter is fairly obvious: solve for one row, use that to solve for two rows, and so on.

    Consider the example array a above.

    arr = a
    arr.empty? #=> false, so continue
    
    h = { len: arr.first.first, path: [] }
      #=> {:len=>3, :path=>[]}
    return h if arr.size == 1 # arr.size => 4, so continue
    

    As

    arr[1..-1] => [[7, 4], [2, 4, 6], [8, 5, 9, 3]]
    

    reduce passes [h] and [7, 4] into the block and assigns the block variables:

    l   = [{ len: arr.first.first, path: [] }]
    row = [7, 4]
    

    It then computes:

    h     = l.first
      #=> {:len=>3, :path=>[]}
    left  = { len: h[:len]+row.first, path: h[:path]+[:l] }
      #=> {:len=>10, :path=>[:l]}
    mid   = []
    h     = l.last
      #=> {:len=>3, :path=>[]}
    right = { len: h[:len]+row.last, path: h[:path]+[:r] }
      #=> {:len=>7, :path=>[:r]}
    [left, *mid, right]
      #=> [{:len=>10, :path=>[:l]}, {:len=>7, :path=>[:r]}]
    

    mid => [] because each_cons(2) is executed on an array of size 1.

    This last line above provides information for the longest paths to each of the two elements in the second row. For the first element (7), the path is of length 10 and goes from the only element in the first row (3) and then down and "left" (:l) to the given element.

    As [left, *mid, right] is computed in the last row of the reduce block, the block variable l is given that value for the processing of the next row of arr:

    l   = [{:len=>10, :path=>[:l]}, {:len=>7, :path=>[:r]}]
    row = [2, 4, 6]
    

    Next we compute the following:

    left = { len: h[:len]+row.first, path: h[:path]+[:l] }
      #=> {:len=>5, :path=>[:l]}
    l.each_cons(2).to_a.zip(row[1..-2]).map do |(h1,h2),v|
       if h1[:len] >= h2[:len]
         { len: h1[:len]+v, path: h1[:path]+[:r] }
       else
         { len: h2[:len]+v, path: h2[:path]+[:l] }
       end
    end
      #=> [{:len=>14, :path=>[:l, :r]}]
    h = l.last
      #=> {:len=>7, :path=>[:r]}
    right = { len: h[:len]+row.last, path: h[:path]+[:r] }
      #=> {:len=>13, :path=>[:r, :r]}
    [left, *mid, right]
      #=> [{:len=>5, :path=>[:l]}, {:len=>14, :path=>[:l, :r]},
      #    {:len=>13, :path=>[:r, :r]}]
    

    The calculations of left and right are similar to those done for the previous element of arr. Let's look at the calculation of mid:

    pairs = l.each_cons(2).to_a
      #=>   [[{:len=>10, :path=>[:l]}, {:len=>7, :path=>[:r]}]]
    vals  = pairs.zip(row[1..-2])
      #=>   pairs.zip([4])
      #=>   [[[{:len=>10, :path=>[:l]}, {:len=>7, :path=>[:r]}], 4]]
    

    vals is an array containing one element. That element is passed into the map, decomposed and assigned to the block variables:

    h1       = {:len=>10, :path=>[:l]}
    h2       = {:len=> 7, :path=>[:r]}
    v        = 4
    h1[:len] #=> 10
    h2[:len] #=> 7
    

    As 10 > 7, we execute:

    { len: h1[:len]+v, path: h1[:path]+[:r] }
    

    which is the value of mid. The block value l for reduce is now assigned the result of [left, *mid, right]:

    l = [{:len=> 5, :path=>[:l]}, {:len=>14, :path=>[:l, :r]},
         {:len=>13, :path=>[:r, :r]}]
    

    and processing of the third row commences. reduce returns:

    d = [{:len=>20, :path=>[:l, :l, :l]}, {:len=>19, :path=>[:l, :r, :l]},
         {:len=>23, :path=>[:l, :r, :r]}, {:len=>16, :path=>[:r, :r, :r]}]
    

    which provides information describing the longest path to each element of the last row. The last step is:

    d.max_by { |h| h[:len] }
      #=> {:len=>23, :path=>[:l, :r, :r]}