Mahesha999
Mahesha999

Reputation: 24941

Understanding subtleties of dynamic programming approaches

I understand that there are mainly two approaches to dynamic programming solutions:

  1. Fixed optimal order of evaluation (lets call it Foo approach): Foo approach usually goes from subproblems to bigger problems thus using results obtained earlier for subproblems to solve bigger problems, thus avoiding "revisiting" subproblem. CLRS also seems to call this "Bottom Up" approach.

  2. Without fixed optimal order of evaluation (lets call it Non-Foo approach): In this approach evaluation proceeds from problems to their sub-problems . It ensures that sub problems are not "re-evaluated" (thus ensuring optimality) by maintaining results of their past evaluations in some data structure and then first checking if the result of the problem at hand exists in this data structure before starting its evaluation. CLRS seem to call this as "Top Down" approach

This is what is roughly conveyed as one of the main points by this answer.

I have following doubts:

Q1. Memoization or not?


CLRS uses terms "top down with memoization" approach and "bottom up" approach. I feel both approaches require memory to cache results of sub problems. But, then, why CLRS use term "memoization" only for top down approach and not for bottom up approach? After solving some problems by DP approach, I feel that solutions by top down approach for all problems require memory to caches results of "all" subproblems. However, that is not the case with bottom up approach. Solutions by bottom up approach for some problems does not need to cache results of "all" sub problems. Q1. Am I correct with this?

For example consider this problem:

Given cost[i] being the cost of ith step on a staircase, give the minimum cost of reaching the top of the floor if:

  • you can climb either one or two steps
  • you can start from the step with index 0, or the step with index 1

The top down approach solution is as follows:

class Solution: 
    def minCostAux(self, curStep, cost):

        if self.minCosts[curStep] > -1:
            return self.minCosts[curStep]
        
        if curStep == -1:
            return 0
        elif curStep == 0:
            self.minCosts[curStep] = cost[0] 
        else: 
            self.minCosts[curStep] = min(self.minCostAux(curStep-2, cost) + cost[curStep]
                                , self.minCostAux(curStep-1, cost) + cost[curStep]) 
        
        return self.minCosts[curStep]
        
    def minCostClimbingStairs(self, cost) -> int:
        cost.append(0)
        self.minCosts = [-1] * len(cost)
        return self.minCostAux(len(cost)-1, cost)

The bottom up approach solution is as follows:

class Solution:        
    def minCostClimbingStairs(self, cost) -> int:
        cost.append(0)
        
        secondLastMinCost = cost[0]
        lastMinCost = min(cost[0]+cost[1], cost[1])
        
        minCost = lastMinCost
        
        for i in range(2,len(cost)):
            minCost = min(lastMinCost, secondLastMinCost) + cost[i]
            secondLastMinCost = lastMinCost
            lastMinCost = minCost
        
        return minCost

Note that the top down approach caches result of all steps in self.minCosts while bottom up approach caches result of only last two steps in variables lastMinCost and secondLastMinCost.

Q2. Does all problems have solutions by both approaches?


I feel no. I came to this opinion after solving this problem:

Find the probability that the knight will not go out of n x n chessboard after k moves, if the knight was initially kept in the cell at index (row, column).

I feel the only way to solve this problem is to find successive probabilities in increasing number of steps starting from cell (row, column), that is probability that the knight will not go out of chessboard after step 1, then after step 2, then after step 3 and so on. This is bottom up approach. We cannot do it top down, for example, we cannot start with kth step and go to k-1th step, then k-2th step and so on, because:

  1. We cannot know which cells will be reached in kth step to start with
  2. We cannot ensure that all paths from kth step will lead to initial knight cell position (row,column).

Even one of the top voted answer gives dp solution as follows:

class Solution {
    private int[][]dir = new int[][]{{-2,-1},{-1,-2},{1,-2},{2,-1},{2,1},{1,2},{-1,2},{-2,1}};
    private double[][][] dp;
    public double knightProbability(int N, int K, int r, int c) {
        dp = new double[N][N][K + 1];
        return find(N,K,r,c);
    }
    public double find(int N,int K,int r,int c){
        if(r < 0 || r > N - 1 || c < 0 || c > N - 1) return 0;
        if(K == 0)  return 1;
        if(dp[r][c][K] != 0) return dp[r][c][K];
        double rate = 0;
        for(int i = 0;i < dir.length;i++)   rate += 0.125 * find(N,K - 1,r + dir[i][0],c + dir[i][1]);
        dp[r][c][K] = rate;
        return rate;
    }
}

I feel this is still a bottom up approach since it starts with initial knight cell position (r,c) (and hence starts from 0th or no step to Kth step) despite the fact that it counts K downwads to 0. So, this is bottom up approach done recursively and not top down approach. To be precise, this solution does NOT first find:

and then find:

but it finds in reverse / bottom up order: first for K-1 steps and then for K steps.

Also, I did not find any solutions in of top voted discussions in leetcode doing it in truly top down manner, starting from Kth step to 0th step ending in (row,column) cell, instead of starting with (row,column) cell.

Similarly we cannot solve the following problem with the bottom up approach but only with top down approach:

Find the probability that the Knight ends up in the cell at index (row,column) after K steps, starting at any initial cell.

Q2. So am I correct with my understanding that not all problems have solutions by both top down or bottom up approaches? Or am I just overthinking unnecessarily and both above problems can indeed be solved with both top down and bottom up approaches?

PS: I indeed seem to have done overthinking here: knightProbability() function above is indeed top down, and I ill-interpreted as explained in detailed above 😑. I have kept this explanation for reference as there are already some answers below and also as a hint of how confusion / mis-interpretaions might happen, so that I will be more cautious in future. Sorry if this long explanation caused you some confusion / frustrations. Regardless, the main question still holds: does every problem have bottom up and top down solutions?

Q3. Bottom up approach recursively?


I am pondering if bottom up solutions for all problems can also be implemented recursively. After trying to do so for other problems, I came to following conclusion:

We can implement bottom up solutions for such problems recursively, only that the recursion won't be meaningful, but kind of hacky.

For example, below is recursive bottom up solution for minimum cost climbing stairs problem mentioned in Q1:

class Solution:   
    def minCostAux(self, step_i, cost):
        if self.minCosts[step_i] != -1:
            return self.minCosts[step_i]
            
        self.minCosts[step_i] = min(self.minCostAux(step_i-1, cost)
                                    , self.minCostAux(step_i-2, cost)) + cost[step_i]

        if step_i == len(cost)-1: # returning from non-base case, gives sense of 
                                  # not-so meaningful recursion.
                                  # Also, base cases usually appear at the 
                                  # beginning, before recursive call.
                                  # Or should we call it "ceil condition"?
            return self.minCosts[step_i]

        return self.minCostAux(step_i+1, cost)
    
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        cost.append(0)

        self.minCosts = [-1] * len(cost)

        self.minCosts[0] = cost[0]
        self.minCosts[1] = min(cost[0]+cost[1], cost[1])
        
        return self.minCostAux(2, cost)

Is my quoted understanding correct?

Upvotes: 3

Views: 349

Answers (3)

joaopfg
joaopfg

Reputation: 1287

I found your question ( does every dynamic programming problem have bottom up and top down solutions ? ) very interesting. That's why I'm adding another answer to continue the discussion about it.

To answer the question in its generic form, I need to formulate it more precisely with math. First, I need to formulate precisely what is a dynamic programming problem. Then, I need to define precisely what is a bottom up solution and what is a top down solution.

I will try to put some definitions but I think they are not the most generic ones. I think a really generic definition would need more heavy math.

First, define a state space S of dimension d as a subset of Z^d (Z represents the integers set).

Let f: S -> R be a function that we are interested in calculate for a given point P of the state space S (R represents the real numbers set).

Let t: S -> S^k be a transition function (it associates points in the state space to sets of points in the state space).

Consider the problem of calculating f on a point P in S.

We can consider it as a dynamic programming problem if there is a function g: R^k -> R such that f(P) = g(f(t(P)[0]), f(t(P)[1]), ..., f(t(P)[k])) (a problem can be solved only by using sub problems) and t defines a directed graph that is not a tree (sub problems have some overlap).

Consider the graph defined by t. We know it has a source (the point P) and some sinks for which we know the value of f (the base cases). We can define a top down solution for the problem as a depth first search through this graph that starts in the source and calculate f for each vertex at its return time (when the depth first search of all its sub graph is completed) using the transition function. On the other hand, a bottom up solution for the problem can be defined as a multi source breadth first search through the transposed graph that starts in the sinks and finishes in the source vertex, calculating f at each visited vertex using the previous visited layer.

The problem is: to navigate through the transposed graph, for each point you visit you need to know what points transition to this point in the original graph. In math terms, for each point Q in the transition graph, you need to know the set J of points such that for each point Pi in J, t(Pi) contains Q and there is no other point Pr in the state space outside of J such that t(Pr) contains Q. Notice that a trivial way to know this is to visit all the state space for each point Q.

The conclusion is that a bottom up solution as defined here always exists but it only compensates if you have a way to navigate through the transposed graph at least as efficiently as navigating through the original graph. This depends essentially in the properties of the transition function.

In particular, for the leetcode problem you mentioned, the transition function is the function that, for each point in the chessboard, gives all the points to which the knight can go to. A very special property about this function is that it's symmetric: if the knight can go from A to B, then it can also go from B to A. So, given a certain point P, you can know to which points the knight can go as efficiently as you can know from which points the knight can come from. This is the property that guarantees you that there exists a bottom up approach as efficient as the top down approach for this problem.

Upvotes: 2

joaopfg
joaopfg

Reputation: 1287

For the leetcode question you mentioned, the top down approach is like the following:

Let P(x, y, k) be the probability that the knight is at the square (x, y) at the k-th step. Look at all squares that the knight could have come from (you can get them in O(1), just look at the board with a pen and paper and get the formulas from the different cases, like knight in the corner, knight in the border, knight in a central region etc). Let them be (x1, y1), ... (xj, yj). For each of these squares, what is the probability that the knight jumps to (x, y) ? Considering that it can go out of the border, it's always 1/8. So:

P(x, y, k) = (P(x1, y1, k-1) + ... + P(xj, yj, k-1))/8

The base case is k = 0:

P(x, y ,0) = 1 if (x, y) = (x_start, y_start) and P(x, y, 0) = 0 otherwise.

You iterate through all n^2 squares and use the recurrence formula to calculate P(x, y, k). Many times you will need solutions you already calculated for k-1 and so you can benefit a lot from memoization.

In the end, the final solution will be the sum of P(x, y, k) over all squares of the board.

Upvotes: 0

btilly
btilly

Reputation: 46542

First, context.

Every dynamic programming problem can be solved without dynamic programming using a recursive function. Generally this will take exponential time, but you can always do it. At least in principle. If the problem can't be written that way, then it really isn't a dynamic programming problem.

The idea of dynamic programming is that if I already did a calculation and have a saved result, I can just use that saved result instead of doing the calculation again.

The whole top-down vs bottom-up distinction refers to the naive recursive solution.

In a top-down approach your call stack looks like the naive version except that you make a "memo" of what the recursive result would have given. And then the next time you short-circuit the call and return the memo. This means you can always, always, always solve dynamic programming problems top down. There is always a solution that looks like recursion+memoization. And that solution by definition is top down.

In a bottom up approach you start with what some of the bottom levels would have been and build up from there. Because you know the structure of the data very clearly, frequently you are able to know when you are done with data and can throw it away, saving memory. Occasionally you can filter data on non-obvious conditions that are hard for memoization to duplicate, making bottom up faster as well. For a concrete example of the latter, see Sorting largest amounts to fit total delay.


Start with your summary.

I strongly disagree with your thinking about the distinction in terms of the optimal order of evaluations. I've encountered many cases with top down where optimizing the order of evaluations will cause memoization to start hitting sooner, making code run faster. Conversely while bottom up certainly picks a convenient order of operations, it is not always optimal.

Now to your questions.

Q1: Correct. Bottom up often knows when it is done with data, top down does not. Therefore bottom up gives you the opportunity to delete data when you are done with it. And you gave an example where this happens.

As for why only one is called memoization, it is because memoization is a specific technique for optimizing a function, and you get top down by memoizing recursion. While the data stored in dynamic programming may match up to specific memos in memoization, you aren't using the memoization technique.

Q2: I do not know.

I've personally found cases where I was solving a problem over some complex data structure and simply couldn't find a bottom up approach. Maybe I simply wasn't clever enough, but I don't believe that a bottom up approach always exists to be found.

But top down is always possible. Here is how to do it in Python for the example that you gave.

First the naive recursive solution looks like this:

def prob_in_board(n, i, j, k):
    if i < 0 or j < 0 or n <= i or n <= j:
        return 0
    elif k <= 0:
        return 1
    else:
        moves = [
            (i+1, j+2), (i+1, j-2),
            (i-1, j+2), (i-1, j-2),
            (i+2, j+1), (i+2, j-1),
            (i-2, j+1), (i-2, j-1),
        ]
        answer = 0
        for next_i, next_j in moves:
            answer += prob_in_board(n, next_i, next_j, k-1) / len(moves)
        return answer

print(prob_in_board(8, 3, 4, 7))

And now we just memoize.

def prob_in_board_memoized(n, i, j, k, cache=None):
    if cache is None:
        cache = {}

    if i < 0 or j < 0 or n <= i or n <= j:
        return 0
    elif k <= 0:
        return 1
    elif (i, j, k) not in cache:
        moves = [
            (i+1, j+2), (i+1, j-2),
            (i-1, j+2), (i-1, j-2),
            (i+2, j+1), (i+2, j-1),
            (i-2, j+1), (i-2, j-1),
        ]
        answer = 0
        for next_i, next_j in moves:
            answer += prob_in_board_memoized(n, next_i, next_j, k-1, cache) / len(moves)
        cache[(i, j, k)] = answer
    return cache[(i, j, k)]

print(prob_in_board_memoized(8, 3, 4, 7))

This solution is top down. If it seems otherwise to you, then you do not correctly understand what is meant by top-down.

Upvotes: 3

Related Questions