动态规划方法的细微差别理解

3
我了解到动态规划有两种主要的解决方法:
  1. 固定最优求解顺序 (我们称之为Foo方法):Foo方法通常从子问题开始,逐步求解更大的问题,因此使用先前获得的子问题的结果来解决更大的问题,从而避免“重复计算”子问题。 CLRS 似乎也将其称为“自底向上”方法。

  2. 没有固定的最优求解顺序(我们称之为非Foo方法):在这种方法中,求解从问题到它们的子问题进行,确保子问题没有被“重新计算”(从而确保最优性),通过在某些数据结构中维护以前评估结果的结果,然后首先检查手头问题的结果是否存在于该数据结构中,然后再开始评估。 CLRS似乎将其称为“自顶向下”方法

这是此答案中粗略传达的主要观点之一。
我有以下疑问:

Q1. 有无记忆化?


CLRS使用术语“自顶向下带记忆化”方法和“自底向上”方法。我认为这两种方法都需要内存来缓存子问题的结果。但是,然后,为什么CLRS仅针对自顶向下方法而不是针对自底向上方法使用术语“记忆化”?通过使用DP方法解决一些问题,我发现对于所有问题,自顶向下方法的解决方案都需要内存来缓存所有子问题的结果。然而,自底向上方法并非如此。自底向上方法的某些问题的解决方案不需要缓存所有子问题的结果。 Q1. 我的理解是否正确?

例如,考虑此问题

给定一个楼梯上每个阶梯的成本cost[i],如果:

  • 您可以爬上一步或两步
  • 您可以从索引0或索引1的步骤开始

请给出到达地板顶部的最小成本。

自顶向下的解决方案如下:
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)

从下往上的方法解决方案如下:
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

请注意,自顶向下的方法将所有步骤的结果缓存到self.minCosts中,而自底向上的方法仅将最后两个步骤的结果缓存在变量lastMinCostsecondLastMinCost中。
Q2. 是否所有问题都可以通过这两种方法得出解决方案?
我认为不是。我得出这个结论是在解决这个问题之后:
找出马在 n x n 的棋盘上经过 k 步后不会跑出边界的概率,假设马最初在位置 (row, column)
我认为解决此问题的唯一办法是从初始位置(row,column)开始逐步增加步数,逐步计算马在走了1步、2步、3步...k步后未跑出棋盘的概率,这就是自底向上的方法。我们不能使用自顶向下的方法,例如无法从第k步开始倒推至k-1步,再倒推至k-2步等,因为:
1. 我们不知道第k步时哪些位置可以到达 2. 我们不能确保从第k步出发的所有路径都可以回到起始位置(row,column) 甚至在最赞的答案中,也有一个给出dp解决方案的例子:
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;
    }
}

我认为这仍然是一种自底向上的方法,因为它从初始骑士单元格位置 (r,c) 开始(因此从第 0 步或无步开始,到第 K 步),尽管它将 K 从上往下数到 0。因此,这是递归地完成的自底向上的方法,而不是自顶向下的方法。确切地说,该解决方案没有首先找到

  • 在单元格 (r,c) 起始,在经过 K 步后骑士未离开棋盘的概率

然后再找:

  • 在单元格 (r,c) 起始,在经过 K-1 步后骑士未离开棋盘的概率

但是,它按相反/自底向上的顺序查找:首先查找 K-1 步,然后查找 K 步。

此外,在 LeetCode 的热门讨论中未发现任何真正的自顶向下方法的解决方案,都是从单元格 (row,column) 开始,而不是从第 K 步开始,直到第 0 步结束在单元格 (row,column)

同样,我们不能使用自底向上的方法解决以下问题,只能使用自顶向下的方法:

找到骑士在任何初始单元格出发后,经过 K 步后最终在索引为 (row,column) 的单元格中的概率。

Q2. 那么我是否正确地理解了并非所有问题都可以通过自顶向下或自底向上的方法进行解决?还是说我过度思考了,两个以上的问题确实可以通过自顶向下和自底向上的方法解决?

PS: 我确实觉得我过度思考了:上面的 knightProbability() 函数确实是自顶向下的,正如上文所解释的那样。我将此解释保留供参考,因为下面已经有一些答案,同时也作为混淆/误解可能发生的提示,以便我将来会更加谨慎。如果这个长说明导致了您的一些混淆/挫败,我很抱歉。无论如何,主要问题仍然存在:每个问题都有自底向上和自顶向下的解决方案吗?

Q3. 自底向上的方法递归实现?


我在考虑是否对所有问题都可以进行自底向上的递归实现。在尝试其他问题时,我得出以下结论:

我们可以递归地实现这些问题的自底向上解决方案,但递归不会有意义,而是有点巧妙。

例如,下面是递归自底向上的解决方案,用于在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)

我的理解是正确的吗?


对于问题1,我认为情况恰好相反。对于自底向上的方法,基本上是一种表格法,你必须在得出解决方案之前填满整个表格(不确定你提供的示例是否符合这种基本方法),而对于自顶向下的方法,你要为刚刚解决的问题填写备忘录。这里有一个很好的讨论(链接:https://dev59.com/LG025IYBdhLWcg3wLizo)。 - SomeDude
1
使用自底向上的方法,你不一定需要填满整个表格;通常你可以选择一个聪明的顺序来解决问题,并且舍弃那些你知道不再需要的子问题的解决方案。 - Stef
3个回答

3

首先,给出上下文。

每个动态规划问题都可以使用递归函数解决而不使用动态规划。一般情况下,这将需要指数时间,但你总是可以这样做。至少在原理上是如此。如果问题无法用这种方式编写,那么它确实不是一个动态规划问题。

动态规划的想法是,如果我已经进行了计算并保存了结果,那么我可以直接使用该保存的结果,而不必再次执行计算。

整个自顶向下与自底向上的区别都是指朴素递归解法。

在自顶向下方法中,调用堆栈类似于朴素版本,只是你会记录递归结果的“备忘录”。然后下一次你就可以捷径调用并返回备忘录。这意味着你始终可以、总是总是像递归+记忆化的形式来解决动态规划问题。而且这种解法本质上是自顶向下的。

在自底向上方法中,你从某些底层元素开始构建,并从底层向上构建。因为你非常清楚数据的结构,所以通常你能够知道何时已经完成了数据,并且可以丢弃它,从而节省内存。偶尔,你可以根据难以复制的非明显条件过滤数据,使得自底向上更快。对于后者的具体示例,请参见将最大金额排序以适应总延迟


从摘要开始。

我强烈反对你的思考方式,即以评估的最优顺序为区分标准。我遇到过许多情况,在自顶向下中,优化评估顺序会导致记忆化更早地开始命中,使代码运行更快。相反,虽然自底向上确实选择了一种方便的操作顺序,但这不总是最优的。

现在来回答你的问题。

Q1:正确。自底向上通常知道何时完成数据,而自顶向下则不知道。因此自底向上给你删除数据的机会。而你给出了一个发生这种情况的示例。

至于为什么只有一个叫做记忆化,那是因为记忆化是一种特定的优化函数的技术,你通过记忆化递归来实现自顶向下。虽然动态规划中存储的数据可能与记忆中的特定内容相匹配,但你没有使用记忆化技术。

Q2:我不知道。

我个人发现,在解决某些复杂数据结构上的问题时,我无法找到自底向上的方法。也许我只是不够聪明,但我不认为总是存在可以找到的自底向上方法。

但是,自顶向下始终可行。以下是如何在Python中对你提供的示例进行自顶向下处理。

首先,朴素递归解法如下:

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))

现在我们只需要进行记忆化处理。

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))

这个解决方案是自上而下的。如果你认为不是这样,那么你就没有正确理解自上而下的含义。


非常抱歉在原问题的末尾又添加了一个问题。我没有编辑原来的两个问题,第三个问题也不会干扰它们。如果可能的话,您能否请看一下这个新的第三个问题? - Mahesha999
感谢分享经验:「简单地找不到自下而上的方法...我不相信总有一种自下而上的方法可以被找到。」 - Mahesha999
1
@Mahesha999 我不知道你为什么认为这是自下而上的。话虽如此,在任何具有尾递归的语言中,迭代都可以被写成递归形式,因此你显然可以用递归来编写几乎任何东西。在没有尾递归的语言中,你也可以做同样的事情,只是效率不高。 - btilly
是的,我现在可以看出这确实是自顶向下的解决方案!可能有点过度思考了。但仍然很想知道是否所有问题都存在这两种解决方案,如果不是,那么它们何时存在和不存在。是的,我们可以使用递归实现自底向上的方法,就像我举的例子一样。但在自顶向下的方法中,基本情况对应于最小的子问题,而在自底向上的方法中,它们对应于给定/最大的问题。但我想知道为什么人们通常喜欢用递归来处理自顶向下的方法,而用迭代来处理自底向上的方法。这个主题中的所有片段,甚至CLRS也是如此! - Mahesha999

2
我发现你的问题(每个动态规划问题是否都有自底向上和自顶向下的解决方案?)非常有趣。这就是为什么我要添加另一个答案来继续讨论它的原因。
为了回答这个问题,我需要用数学更精确地阐述它的形式。首先,我需要确切地阐明什么是动态规划问题。然后,我需要准确定义什么是自底向上的解决方案和什么是自顶向下的解决方案。
我将尝试提出一些定义,但我认为它们不是最通用的定义。我认为一个真正通用的定义需要更多的重量级数学。
首先,将维度为d的状态空间S定义为Z^d的子集(Z代表整数集)。
让f:S->R是我们感兴趣的函数,我们想要计算状态空间S中给定点P的值(R代表实数集)。
让t:S->S^k是一个转移函数(它将状态空间中的点与状态空间中的点集相关联)。
考虑在状态空间S中计算点P上的f的问题。
如果存在一个函数g:R^k->R,使得f(P)= g(f(t(P)[0]),f(t(P)[1]),...,f(t(P)[k]))(一个问题只能通过使用子问题来解决),并且t定义了一个不是树的有向图(子问题有一些重叠),则可以将其视为动态规划问题。
考虑由t定义的图。我们知道它有一个源(点P)和一些我们知道f值的汇点(基本情况)。我们可以定义一个自顶向下的解决方案,它通过这个图进行深度优先搜索,从源开始,并在返回时间计算每个顶点的f(当所有子图的深度优先搜索完成时),使用转移函数。另一方面,可以定义一个自底向上的问题解决方案,它通过从汇点开始到源顶点结束的多源广度优先搜索来定义,使用前面访问的层计算每个访问的顶点的f。
问题是:要浏览反转的图形,对于您访问的每个点,您需要知道哪些点在原始图形中转换到该点。在数学术语中,对于转换图中的每个点Q,您需要知道集合J的点,使得对于J中的每个点Pi,t(Pi)包含Q,并且没有其他状态空间外的点Pr,使得t(Pr)包含Q。请注意,了解这一点的一个平凡的方法是为每个点Q访问整个状态空间。
结论是,这里定义的自底向上的解决方案总是存在的,但它只能在您有一种以至少与浏览原始图形一样高效的方式浏览反转的图形时进行补偿。这主要取决于转移函数的属性。
特别是,在你提到的Leetcode问题中,转换函数是一个函数,对于棋盘上的每个点,它会给出马可以到达的所有点。这个函数有一个非常特殊的属性,就是它是对称的:如果马可以从A走到B,那么它也可以从B走到A。因此,给定一个特定的点P,你可以知道马可以去哪些点,就像你知道马可以从哪些点来一样高效。这就是保证为这个问题存在一种与自上而下方法一样高效的自下而上方法的属性。

0
对于您提到的leetcode问题,自顶向下的方法如下:
设P(x, y, k)为马在第k步时位于(x, y)的概率。考虑所有可能到达(x, y)的方格(可以在O(1)时间内得到它们,只需用笔和纸查看棋盘并从不同情况下得出公式,例如马在角落、边界或中央区域等)。将它们表示为(x1, y1),...(xj, yj)。对于这些方格中的每一个,马跳到(x, y)的概率是多少?考虑到它可能越过边界,因此始终为1/8。因此:
P(x, y, k) = (P(x1, y1, k-1) + ... + P(xj, yj, k-1))/8
基本情况是k = 0:
如果(x, y) = (x_start, y_start),则P(x, y, 0) = 1,否则P(x, y, 0) = 0。
你需要遍历所有n^2个方格,并使用递归公式计算P(x, y, k)。很多时候,你会需要已经计算过的k-1的解决方案,因此你可以从记忆化中获益良多。
最终的解决方案将是棋盘上所有方格的P(x, y, k)之和。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接