5. Dynamic Programming

Memoisation

Fibonacci can be defined as a recurrence relation: f(n)=f(n1)+f(n2)f(n) = f(n - 1) + f(n - 2). However, this approach is inefficient due to lots of duplicate calls: when expanded, it becomes f(n)=f(1)+f(1)+f(1)+f(n) = f(1)+ f(1) + f(1) + \dots.

One solution to this is memoisation: the caching of results.

General Purpose Memoiser

def memoise(f):
  known = {}
  def new_func(*params):
    if param not in known:
      known[param] = f(*params)
    return known[params]
  return new_func

@memoise
def function_definition():

# Or use the built in tools:
from func_tools import lru_cache
# LRU: least recently used; gets rid of oldest result once number of results stored reaches `maxsize`

@lru_cache(maxsize = None)
def function_definition():
  // ...

Notes:

Fibonacci, Iteratively

Bottom-up iteration:

def fibonacci_i(n):
  a, b = 0, 1
  for i in range(n):
    a, b = b, a + b
  return a

This approach is O(n)O(n) in time and Θ(1)\Theta(1) in space.

Top-down Dynamic Programming

Recursion and memoisation.

Bottom-up Dynamic Programming

Example: Minimum Cost Path

Rectangular grid of cells, each with a weight. You can move to any adjacent cell (including diagonals) from each cell. Find the minimum cost needed to get from top to bottom.

Recurrence formula

Ci,jC_{i, j} is optimal cost of getting to the cell (i,j)(i,j) where i[0,I)i\in [0, I), j[0,J)j\in[0, J):

Ci,j={cell outside of the gridWi,ji=0 (cell is at the the top of the grid)Wi,j+mink(j1,j,j+1)(Ci1,k)otherwise C_{i,j} = \begin{cases} \infty &\text{cell outside of the grid}\\ W_{i,j} &i = 0 \text{ (cell is at the the top of the grid)} \\ W_{i,j} + \min_{k\in(j-1, j, j+1)}(C_{i-1, k}) &\text{otherwise} \end{cases}

Hence in the normal case, the cost of the cell is the cell weight plus minimum cost to get to cell above it (left, directly above, or right).

Using a top-down approach:

Using a bottom-up approach::

If the grid is square, it is O(n2)O(n^2) in time and space.

This problem can also be found using Dijkstra’s shortest path by converting the problem to a multi-stage graph (with weights moved to edges). However, this is harder to implement and less efficient.

Suitably of DP

DP approaches are likely to work when the problem:

Then build the solution from solutions to sub-problems:

Optimal Substructure

When solutions to subproblems can be reused (multiple times!) to find the optimal solution to the problem.

For example, in the shortest path problem, the shortest path for a sub-problem will be inside the shortest path for the larger problem.

The longest path problem does not have an optimal substructure.

Properties of Recursive Solutions

Coin Changing Problem

A greedy approach does not produce optimal results. Hence, DP must be used.

Top-Down

Given coinage = [1, 10, 25] and amount = 80, the solution will always be minimum of:

Either amount or coinage decreases to decrease the size of the problem.

An alternative approach would be finding the minimum of:

That is 1 + min(coinage_calculate(amount - coin, coinage) for coin in coinage)

Without memoisation, there are a maximum of len(coinage) sub-calls per call. With memoisation, the graph collapses to amount + 1 calls:

0/1 Knapsack

No fractional amounts. For each item you have two options:

Repeat for each item, picking the option that gives the greater worth.

Edge cases:

Cache using index of item (ii) and weight of knapsack (CC): $$ V(i, c) = \begin{cases} 0 &i < 0 \ V(i-1, C) &i\ge 0, w_i > C \ \max(V(i-1, c), v_i + V(i-1, C-w_i)) &i\ge0, w_i \le c \end{cases} $$ This is easier when using 1 indexing: length and index are the same.

Top-Down

The number of possible calls is nCnC, where nn is the number of items, and each function call will call two other calls until it reaches the base case.

If using LRU cache, run the computation in in a nested function using LRU cache, and store the array of items outside the scope of the function.

Bottom-Up

Generate table of knapsack weight (column) and index (row), filling table row-wise. Go backwards to reconstruct solution.

1 indexing is also useful in this case, in which an extra row and column (0) will be needed.

For each cell, you have two options:

Take the maximum of these two options.

This has a time and space complexity of O(nW)O(nW). If only the maximum value and not the path is required, only two rows are needed. Hence, the space complexity gets reduced to O(W)O(W).

NB: this is pseudo-polynomial time - it is polynomial with the size of the knapsack, not the number of items.

Assuming the path is required, use backtracking to reconstruct the solution: start at the end, and If the value of the item directly above is the same, it means the optimal solution came from not picking up the item. Else, go up one and left by the weight of the item. This has a complexity of O(n)O(n).

Subset Sum Problem

Given a set of integers, find a subset that sums to zero.

Generalize this to finding a subset that sums to some value SS.

def subset_sum_exists(numbers, n, required_sum)
# returns true iff subset within first n elements that sum to required_sum

Longest Common Subsequence

Given two strings AA of length nn and BB of length mm, find the length of longest subsequence common to both strings. Characters do not need to be contiguous.

Brute forcing this is not possible - there are 2n12^n-1 subsequences of AA, and determining if it is a subsequence of BB would take O(mO(m) time. Hence, the time complexity of brute forcing a solution is O(m2n)O(m\cdot 2^n).

Create a function, Li,jL_{i,j}, which returns the longest common subsequence given substrings of AA and BB: A=a1a2aiA=a_1a_2\dots a_i and B=b1b2bjB=b_1b_2\dots b_j. $$ L_{i,j} = \begin{cases} 0 & i = 0 \text{ or } j = 0 \ L_{i-1, j-1} + 1 & a_i = b_j \ \max(L_{i, j-1}, L_{i-1, j}) & a_i \neq b_j \end{cases} $$

Bottom-Up

Create a table of 1-origin subscripts for all possible values of Li,jL_{i, j}, initializing L0,j=0L_{0, j} = 0. Then, built up the table row-by-row:

Edit Distance

The diff algorithm. Transform source string A=a1anA = a_1\dots a_n into B=b1bmB = b_1\dots b_m using the minimum number of insertions, deletions, and substitutions. This number is called the edit distance.

When a transformation is not needed, it is called the alignment operation, and has zero cost.

Work from end of string to start.

Longest Increasing Subsequence

One solution is to sort, remove duplicates, and then run LCS on the two sequences. However, a more efficient solution is possible.

Let LjL_j be the length of the longest increasing substring up to and including position sjs_j in the string ssns\dots s_n.

Recurrence relation: find the maximum of LIS for all solutions before the current index, provided the last (and so biggest) element is smaller than the current element.

Lj={ii=1,sjsi  0<j<i1+max0<j<i,  sj<siLjotherwise L_j = \begin{cases} i & i=1, s_j \ge s_i \; \forall 0 < j < i \\ 1 + \max_{0<j<i, \; s_j < s_i}{L_j} & \text{otherwise} \end{cases}

This can be further optimized into an O(nlogn)O(n\log{n}) solution: optional sequences are in increasing order, so a binary search can be done instead of searching all elements before it.