DYNP-4

One-dimensional DP

Dynamic programmingLesson 4 of 914 min

In many DP problems the state is one number: a position in a list or a string, or an amount. The table is then a single row. The hard part is not the code but saying exactly what each entry means. This lesson shows four common meanings.

One answer per position

For a list of n items, the two most common states are:

  • a prefix: the answer for the first i items, a[:i], filled from i = 0 upwards;
  • a suffix: the answer for the items from i to the end, a[i:], filled from the end backwards.

Both lead to correct methods. Choose the one that makes the rule shorter. A prefix rule usually looks at the last item; a suffix rule at the first.

Take it or leave it

Choose values from a list so that no two chosen values are side by side, with the largest total. Let best[i] be the largest total using positions 0 to i. The value at position i is either skipped or taken:

  • skipped: the best is best[i - 1];
  • taken: position i − 1 must be skipped, and the rest is a best choice from positions 0 to i − 2, so best[i - 2] + a[i].

Every choice falls into one of the two cases, and in each case the rest must be a best choice (lesson 1’s argument), so best[i] is the larger of the two. Step through the first version of the figure.

FIG DYNP-4.1One answer per position
Problem
best[i] = max(best[i − 1], best[i − 2] + a[i])
1/10

Choose values from the row so that no two chosen values are side by side, with the largest total. best[i] is the largest total using only positions 0 to i.

Step 1 of 10

no_neighbours.pyPython
def best_total(a):
    before, best = 0, 0        # best[i - 2], best[i - 1]
    for x in a:
        before, best = best, max(best, before + x)
    return best

Only the last two answers are needed, so two names replace the list: O(n) time, O(1) space. Starting both at 0 stands for the empty prefix, which also makes an empty list return 0.

Fixing where it ends

The largest sum of a stretch (DVCQ-1) is harder to describe with prefixes: the best stretch in a[:i] may be far from position i, so it says nothing about how to extend it. The way out is to fix where the stretch ends. Let end[i] be the largest sum of a stretch that ends exactly at position i. Such a stretch is either the value a[i] alone, or a stretch ending at i − 1 with a[i] added, and the best of those is end[i - 1] + a[i]. The second version of the figure fills it.

best_stretch.pyPython
def best_stretch(a):
    end = best = a[0]
    for x in a[1:]:
        end = max(x, end + x)      # extend, or start again
        best = max(best, end)
    return best

This is Kadane’s method, O(n). The answer is not end[n − 1] but the largest end[i], because the best stretch can end anywhere.

Lesson 6 uses the same idea: the longest rising subsequence that ends at position i.

Splitting a string

Can a string be cut into pieces that are all words from a given set? Let ok[i] be True if the first i letters can. The last word of such a split ends at i and starts at some j, so ok[i] is True if ok[j] is True and s[j:i] is a word, for some j. The empty prefix can always be split: ok[0] = True.

word_break.pyPython
def can_split(s, words):
    longest = max(map(len, words), default=0)
    ok = [True] + [False] * len(s)
    for i in range(1, len(s) + 1):
        for j in range(max(0, i - longest), i):
            if ok[j] and s[j:i] in words:
                ok[i] = True
                break
    return ok[len(s)]

Only the last longest places can start the last word, so for n letters and words up to L letters long there are O(n · L) checks, each taking O(L) to slice and hash: O(n · L²), where L² means L multiplied by L. A trie of the words (TRIE) checks all the j for one i in a single walk, which removes one factor of L. Pass words as a set, so that in is fast.

Looking further back

Sometimes the rule reads an entry far back, and finding it is part of the work. GRDY-3 ended with bookings that have values, where no greedy rule works. Sort the bookings by end time. Let best[i] be the largest total value from the first i bookings. Booking i is skipped (best[i - 1]), or taken, and then only bookings that end by its start can go with it. Those are a prefix of the sorted list, and binary search finds its length:

bookings_with_values.pyPython
from bisect import bisect_right

def most_value(bookings):          # (start, end, value)
    bookings = sorted(bookings, key=lambda b: b[1])
    ends = [b[1] for b in bookings]
    best = [0] * (len(bookings) + 1)
    for i, (s, e, v) in enumerate(bookings, 1):
        p = bisect_right(ends, s, 0, i - 1)  # end <= s
        best[i] = max(best[i - 1], best[p] + v)
    return best[-1]

That is O(n log n): one sort and one binary search per booking. Half-open times are used, as in GRDY-3, so a booking that ends at 5 can be followed by one that starts at 5.

Now try DYNP-P4, Season tickets.

DYNP-4 counts as completed once you step through the figure to the last step.