【问题标题】:Shouldn't this be using a backtracking algorithm?这不应该使用回溯算法吗?
【发布时间】:2017-08-13 21:08:38
【问题描述】:

我正在解决关于 LeetCode 的一些问题。其中一个问题是:

给定一个由非负数填充的 m x n 网格,找到一条从左上角到右下角的路径,该路径使沿其路径的所有数字的总和最小化。您只能在任何时间点向下或向右移动。

社论和发布的解决方案都使用动态编程。最受欢迎的解决方案之一如下:

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size(); 
        vector<vector<int> > sum(m, vector<int>(n, grid[0][0]));
        for (int i = 1; i < m; i++)
            sum[i][0] = sum[i - 1][0] + grid[i][0];
        for (int j = 1; j < n; j++)
            sum[0][j] = sum[0][j - 1] + grid[0][j];
        for (int i = 1; i < m; i++)
            for (int j = 1; j < n; j++)
                sum[i][j]  = min(sum[i - 1][j], sum[i][j - 1]) + grid[i][j];
        return sum[m - 1][n - 1];
    }
};

我的问题很简单:不应该使用回溯来解决这个问题吗?假设输入矩阵是这样的:

[
[1,2,500]
[100,500,500]
[1,3,4]
]

我的怀疑是因为在 DP 中,子问题的解决方案是全局解决方案(最优子结构)的一部分。但是,如上所示,当我们从(2,100) 中选择2 时,我们可能是错误的,因为未来的路径可能过于昂贵(2 周围的所有数字都是500s)。那么,在这种情况下如何使用动态规划呢?

总结一下:

  1. 我们是否应该使用回溯,因为如果我们之前做出了错误的选择(查看局部最大值),我们可能不得不撤回我们的路径?
  2. 这是一个动态规划问题吗?

P.S.:上面的解决方案肯定可以运行。

【问题讨论】:

  • 你什么时候回溯?您评估了多少条路径/单元格?
  • @Jarod42,我不知道。但我想我应该。这正是我的问题!

标签: c++ algorithm dynamic-programming


【解决方案1】:

您上面说明的示例表明,问题的贪婪解决方案不一定会产生最佳解决方案,您对此绝对正确。

但是,这个问题的 DP 解决方案并没有完全使用这种策略。 DP 解决方案背后的想法是为每个位置计算在该位置结束的最短路径的成本。在解决整体问题的过程中,DP 算法最终会计算通过网格中 2 的一些最短路径的长度,但它不一定使用那些中间最短路径确定返回的整体最短路径。尝试在您的示例中跟踪上述代码 - 您是否看到它是如何计算的,然后最终没有使用其他路径选项?

【讨论】:

  • 所以,如果我理解你,代码会计算 所有路径 的总和(它没有做出任何 选择,所以不需要回溯)。它使用 DP 来创建路径;不将其用作查找全局解决方案的本地解决方案。我说的对吗?
  • 它正在计算到每个网格位置的最短路径的成本,并且由于问题的结构,无需进行任何回溯。每个方格的成本是通过查看从上方或左侧进入的最佳路径并取两个选项中的哪一个来找到的。
【解决方案2】:

我们是否应该使用回溯,因为如果我们之前做出了错误的选择(查看局部最大值),我们可能不得不撤回我们的路径?

在现实世界的场景中,有很多因素会决定哪种算法更适合解决这个问题。

这个 DP 解决方案很好,因为它可以在处理最坏情况时为您提供最佳性能/内存使用率。

任何回溯/dijkstra/A* 算法都需要维护一个完整的矩阵以及一个开放节点列表。这个 DP 解决方案只是假设每个节点最终都会被访问,因此它可以放弃开放节点列表并只维护成本缓冲区。

通过假设每个节点都会被访问,它也摆脱了算法的“我接下来要打开哪个节点”部分。

因此,如果我们正在寻找最佳的最坏情况下的性能,那么该算法实际上将很难被击败。但我们是否想要那是另一回事。

这是一个动态规划问题吗?

这只是一个动态规划问题,因为它存在动态规划解决方案。但绝不是 DP 是解决它的唯一方法。

编辑:在我开始之前,是的,有更多内存效率更高的解决方案,但在最坏的情况下 CPU 成本非常高。

【讨论】:

    【解决方案3】:

    供您参考

    [
    [  1,   2, 500]
    [100, 500, 500]
    [  1,   3,   4]
    ]
    

    sum数组结果到

    [
    [  1,   3,  503]
    [101, 503, 1003]
    [102, 105,  109]
    ]
    

    我们甚至可以回溯最短路径:

    109, 105, 102, 101, 1
    

    算法不会检查每条路径,而是使用它可以采取先前最优路径的属性来计算当前成本:

    sum[i][j] = min(sum[i - 1][j], // take better path between previous horizontal
                    sum[i][j - 1]) // or previous vertical
                + grid[i][j]; // current cost
    

    【讨论】:

    • 那么,前两个 for 循环是否可以精确处理我的有点输入?我的意思是,要消除我在示例中提到的边缘情况?
    • 前两个 for 循环用于处理只有一个邻居的单元格。
    【解决方案4】:

    回溯本身并不能很好地解决这个问题。

    回溯对于像八皇后这样的问题非常有效,其中提出的解决方案要么有效,要么无效。我们尝试找到解决方案的可能路线,如果失败,我们会回溯并尝试另一条可能的路线,直到找到可行的路线。

    然而,在这种情况下,每条可能的路线都会让我们从头到尾。在找到可行的方法之前,我们不能只是尝试不同的可能性。相反,我们基本上必须从头到尾尝试每条 路线,直到找到最有效的路线(在这种情况下,权重最低)。

    现在,通过回溯 剪枝,我们可以(或许)至少在一定程度上改进我们的解决方案。特别是,让我们假设您进行了一次搜索,首先向下(如果可能)然后向侧面看。在这种情况下,您第一次尝试的输入最终会成为最佳路线。

    问题是它是否可以识别这一点,并在不完全遍历它们的情况下修剪树的一些分支。答案是可以的。为此,它会跟踪到目前为止找到的最佳路线,并据此拒绝整个子树。在这种情况下,它的第一条路由的总权重为 109。然后它尝试到第一个节点的右侧,即 2,到目前为止总权重为 3。这比 109 小,所以它继续。从那里,它向下看并到达 500。这给出了 503 的权重,所以没有进一步看,它知道从那里没有合适的路线,所以它停止并修剪从 500 开始的所有分支. 然后它从 2 向右尝试并找到另一个 500。这使它也可以修剪整个分支。因此,在这些情况下,它从不查看第三个 500,或者根本不查看 3 和 4 — 只需查看 500 节点,我们就可以确定它们不可能产生最佳解决方案。

    这是否真的是对 DP 策略的改进,很大程度上归结为一个问题,即哪些操作需要花费多少。对于手头的任务,这两种方式可能都没有太大区别。但是,如果您的输入矩阵要大得多,它可能会。例如,我们可能有一个大的输入存储在瓦片中。使用 DP 解决方案,我们评估所有可能性,因此我们总是加载所有图块。使用修剪树的方法,我们也许可以完全避免加载一些瓦片,因为包括这些瓦片的路线已经被消除了。

    【讨论】:

    • 谢谢。我真的很喜欢你关于何时使用回溯的观点。这可能是题外话,但您能否让我知道一些资源,我可以在其中找到这些有见地的概念
    • @UmedhSinghBundela:不幸的是,我所知道的唯一一本书(某种程度上)试图涵盖这一点(Niklaus Wirth 的 算法 + 数据结构 = 程序)已经绝版一段时间以来,较新的版本(算法和数据结构)虽然可以在线免费获得,但省略了这一特定部分。
    • 哦,真不幸。令人惊讶的是,这样的部分被省略了。你还记得那个部分的名字吗?
    • @UmedhSinghBundela:多看一点,我很高兴地说我实际上错了:is 部分实际上仍然存在。 Algorithms and Data Structures,第 3.2 节,“何时不使用递归”。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2021-04-28
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-29
    • 2019-10-25
    相关资源
    最近更新 更多