Problem 18 — Maximum Path Sum I

solved Thu, 10 Oct 2019, 10:30 · level 2 · difficulty 7% projecteuler.net github

By starting at the top of the triangle below and moving to adjacent numbers on the row below, the maximum total from top to bottom is $23$.

3
7 4
2 4 6
8 5 9 3

That is, $3 + 7 + 4 + 9 = 23$.

Find the maximum total from top to bottom of the triangle below:

75
95 64
17 47 82
18 35 87 10
20 04 82 47 65
19 01 23 75 03 34
88 02 77 73 07 63 67
99 65 04 28 06 16 70 92
41 41 26 56 83 40 80 70 33
41 48 72 33 47 32 37 16 94 29
53 71 44 65 25 43 91 52 97 51 14
70 11 33 28 77 73 17 78 39 68 17 57
91 71 52 38 17 14 91 43 58 50 27 29 48
63 66 04 68 89 53 67 30 73 16 69 87 40 31
04 62 98 27 23 09 70 98 73 93 38 53 60 04 23

NOTE: As there are only $16384$ routes, it is possible to solve this problem by trying every route. However, Problem 67, is the same challenge with a triangle containing one-hundred rows; it cannot be solved by brute force, and requires a clever method! ;o)


Problem Analysis

The task is to find, among all top-to-bottom paths through a number triangle, the one whose sum of visited values is largest. At each step we descend from a cell to one of the two cells immediately below it. A triangle with N rows therefore has 2^(N-1) distinct paths - 16,384 for the 15-row triangle here, and an astronomical 2^99 for the 100-row variant in Problem 67. A brute-force enumeration of every path quickly becomes intractable, so I needed a smarter method.

The unlocking observation is that this problem has optimal substructure: the best path from any cell down to the base is that cell's value plus the better of the two best paths starting from its two children. Because the best overall path must also be the best path through every sub-triangle it crosses, I can solve the problem with dynamic programming - a technique that builds up an answer from solutions to smaller, overlapping sub-problems instead of recomputing them. By computing "the maximum sum reachable from this cell down to the base" for every cell, starting at the bottom and working upward, the apex eventually holds the global answer. Each cell is visited exactly once, so the algorithm runs in O(N^2) time (proportional to the number of cells) - a dramatic improvement over the exponential enumeration, and one that scales effortlessly to the 100-row case.

Solution Approach

Bottom-Up Reduction of the Triangle (Python and C)

Both p0018_s0.py and p0018_s0.c implement the same algorithm. I first parse the triangle from a multiline string into a two-dimensional array of integers, then collapse it row by row from the bottom up.

The collapse works as follows. I look at the last two rows. For each position i in the second-to-last row, I add the larger of its two children - the cells at indices i and i+1 in the last row - to the value already stored there. After this, every cell in the second-to-last row holds "the maximum sum reachable from here to the base". I then discard the last row and repeat. When only the single apex cell remains, it holds the global maximum. This is the direct expression of the optimal-substructure recurrence: best(cell) = value(cell) + max(best(left child), best(right child)), evaluated bottom-up so that each child's answer is already known when its parent is processed. The whole reduction touches each of the N(N+1)/2 cells once, which is why it runs in O(N^2) time.

In Python the inner step is a single list comprehension: triangle[-2] = [v + max(triangle[-1][i], triangle[-1][i + 1]) for i, v in enumerate(triangle[-2])], followed by del triangle[-1] to shrink the structure. I call deep copy (copy.deepcopy) first so that the caller's triangle is never mutated - important because the benchmark harness calls solve repeatedly with the same data. In C I perform the update in place on triangle[current_rows - 2] and simply decrement current_rows rather than freeing rows mid-loop; this is cache-friendly and avoids repeated allocation. The C parser counts rows by scanning for newlines, makes a mutable copy of the input (strtok modifies its argument), and allocates each row to its exact length via a count-then-fill pass. All allocations are freed after the result is computed.

workbench

Test cases

categoryinputanswer
dev triangle_str = TRIANGLE_A 23
main triangle_str = TRIANGLE_B 1074

Results

solutioncategoryanswerverdict avg seconds cavg seconds py
p0018_s0 dev 23 correct 0.000001 (84) 0.000007 (84)
p0018_s0 main 1074 correct 0.000006 (84) 0.000038 (84)

Files

Tags & blogs