一份给算法新人们的「动态规划」讲解

简介: 一份给算法新人们的「动态规划」讲解

网络异常,图片无法展示
|


前言



公众号目前与「动态规划」相关系列包括:已经完结的「动态规划-路径问题」和正在更新「动态规划-背包问题」。


这都是默认大家有一定的「动态规划」认识的系列文章。


但事实上,可能有不少同学是刚接触算法,还处于只会使用「朴素/暴力解法」来解决算法问题的阶段,对于「动态规划」并不了解。


因此我特意翻出来大概是我六七年写的文章(当时更多作为学习笔记),来帮助大家对「动态规划」有个基本认识。


需要说明的是,本文只停留在对「动态规划」的感性认识,并没有深入到动态规划与图论的本质关系。


因此更多的面向是算法入门读者。


以下是正文内容。


演变过程



一直搞不懂「动态规划」和「记忆化搜索」之间的区别。


总觉得动态规划只是单纯的难在于对“状态”的抽象定义和“状态转移方程”的推导,并无具体的规律可循。


但从算法逐步优化角度而言,动态规划更多是从如下方式进行演化:


暴力递归 -> 记忆化搜索 -> 动态规划。


甚至可以说几乎所有的「动态规划」都可以通过「暴力递归」转换而来,前提是该问题是一个“无后效性”问题。


从对“个例”的朴素枚举做法,演变为对“集合”的枚举做法。


无后效性



所谓的“无后效性”是指:当某阶段的状态一旦确定,此后的决策过程和最终结果将不受此前的各种状态所影响。


可简单理解为当编写好一个递归函数之后,当可变参数确定之后,结果是唯一确定的。


可能你还是对什么是“无后效性”问题感到难以理解。没关系,我们再举一个更具象的例子,这是 LeetCode 62. Unique Paths :给定一个 m * nmn 的矩阵,从左上角作为起点,到达右下角共有多少条路径(机器人只能往右或者往下进行移动)。


网络异常,图片无法展示
|


这是一道经典的「动态规划」入门题目,也是一个经典的“无后效性”问题。


它的“无后效性”体现在:当给定了某个状态(一个具体的 m * nmn 的矩阵和某个起点,如 (1,2)),那么从这个点到达右下角的路径数量就是完全确定的。


而与如何到达这个“状态”无关,与机器人是经过点 (0,2) 到达的 (1,2),还是经过 (1,1) 到达的 (1,2) 无关。


这就是所谓的“无后效性”问题。


当我们尝试使用「动态规划」解决问题的时候,首先要关注该问题是否为一个“无后效性”问题。


1:暴力递归



经常我们面对一个问题,即使我们明确知道了它是一个“无后效性”问题,它可以通过「动态规划」来解决。我们还是觉得难以入手。


这时候我的建议是,先写一个「暴力递归」的版本。


还是以刚刚说到的 LeetCode 62. Unique Paths 举例:


class Solution {
    public int uniquePaths(int m, int n) {
        return recursive(m, n, 0, 0);
    }
    private int recursive(int m, int n, int i, int j) {
        if (i == m - 1 || j == n - 1) return 1;
        return recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1);
    }
}
复制代码


当我还不知道如何使用「动态规划」求解时,我会设计一个递归函数 recursiverecursive


函数传入矩阵信息和机器人当前所在的位置,返回在这个矩阵里,从机器人所在的位置出发,到达右下角有多少条路径。


有了这个递归函数之后,那问题其实就是求解 recursive(m, n, 0, 0)recursive(m,n,0,0):求解从 (0,0) 到右下角的路径数量。


接下来,实现这个函数:


  1. Base case: 由于题目明确了机器人只能往下或者往右两个方向走,所以可以定下来递归方法的 base case 是当已经处于矩阵的最后一行或者最后一列,即只一条路可以走。
  2. 其余情况:机器人既可以往右走也可以往下走,所以对于某一个位置来说,到达右下角的路径数量等于它右边位置到达右下角的路径数量 + 它下方位置到达右下角的路径数量。即 recursive(m,n,i+1,j) + recursive(m,n,i,j+1),这两个位置都可以通过递归函数进行求解。


其实到这里,我们已经求解了这个问题了。


但这种做法还有个严重的“性能”问题。


2:记忆化搜索


如果将我们上述的代码提交到 LeetCode,会得到 timeout 的结果。


可见「暴力递归」的解决方案“很慢”。


我们知道所有递归函数的本质都是“压栈”和“弹栈”。


既然这个过程很慢,我们可以通过将递归版本暴力解法的改为非递归的暴力解法,来解决 timeout 的问题吗?


答案是不行,因为导致 timeout 的原因不在于使用“递归”手段所带来的成本。


而在于在计算过程,我们进行了多次的重复计算。


我们尝试展开递归过程第几步来看看:


网络异常,图片无法展示
|


不难发现,在递归展开过程会遇到很多的重复计算。


随着我们整个递归过程的展开,重复计算的次数会呈倍数增长。


这才是「暴力递归」解决方案“慢”的原因。


既然是重复计算导致的 timeout,我们自然会想到将计算结果进行“缓存”的方案:


class Solution {
    private int[][] cache;
    public int uniquePaths(int m, int n) {
        cache = new int[m][n];
        for (int i = 0; i < m; i++) {
            int[] ints = new int[n];
            Arrays.fill(ints, -1);
            cache[i] = ints;
        }
        return recursive(m, n, 0, 0);
    }
    private int recursive(int m, int n, int i, int j) {
        if (i == m - 1 || j == n - 1) return 1;
        if (cache[i][j] == -1) {
            if (cache[i + 1][j] == -1) {
                cache[i + 1][j] = recursive(m, n, i + 1, j);
            }
            if (cache[i][j + 1] == -1) {
                cache[i][j + 1] = recursive(m, n, i, j + 1);
            }
            cache[i][j] = cache[i + 1][j] + cache[i][j + 1];
        }
        return cache[i][j];
    }
}
复制代码


对「暴力递归」过程中的中间结果进行缓存,确保相同的情况只会被计算一次的做法,称为「记忆化搜索」。


做了这样的改进之后,提交 LeetCode 已经能 AC 并得到一个不错的评级了。


我们再细想一下就会发现,其实整个求解过程,对于每个情况(每个点)的访问次数并没有发生改变。


只是从「以前的每次访问都进行求解」改进为「只有第一次访问才真正求解」。


事实上,我们通过查看 recursive() 方法就可以发现:


当我们求解某一个点 (i, j)(i,j) 的答案时,其实是依赖于 (i, j + 1)(i,j+1)(i + 1, j)(i+1,j)


也就是每求解一个点的答案,都需要访问两个点的结果。


这种情况是由于我们采用的是“自顶向下”的解决思路所导致的。


我们无法直观确定哪个点的结果会在什么时候被访问,被访问多少次。


所以我们不得不使用一个与矩阵相同大小的数组,将所有中间结果“缓存”起来。


换句话说,「记忆化搜索」解决的是重复计算的问题,并没有解决结果访问时机和访问次数的不确定问题。


2.1:次优解版本的「记忆化搜索」


关于「记忆化搜索」最后再说一下。


网上有不少博客和资料在编写「记忆化搜索」解决方案时,会编写类似如下的代码:


class Solution {
    private int[][] cache;
    public int uniquePaths(int m, int n) {
        cache = new int[m][n];
        for (int i = 0; i < m; i++) {
            int[] ints = new int[n];
            Arrays.fill(ints, -1);
            cache[i] = ints;
        }
        return recursive(m, n, 0, 0);
    }
    private int recursive(int m, int n, int i, int j) {
        if (i == m - 1 || j == n - 1) return 1;
        if (cache[i][j] == -1) {
            cache[i][j] = recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1);
        }
        return cache[i][j];
    }
}
复制代码


可以和我上面提供的解决方案作对比。主要区别在于 if (cache[i][j] == -1) 的判断里面。


在我提供解决方案中,会在计算 cache[i][j] 时,尝试从“缓存”中读取

cache[i+1][j]cache[i][j+1],确保每次调用 recursive() 都是必须的,不重复的。


网上大多数的解决方案只会在外层读取“缓存”,在真正计算 cache[i][j] 的时候并不采取先检查再调用的方式,直接调用 recursive() 计算子问题 。


虽然两者相比与直接的「暴力递归」都大大减少了计算次数(recursive() 的访问次数),但后者的计算次数显然要比前者高上不少。


你可能会觉得反正都是“自顶向下”,两者应该没有区别吧?


为此我提供了以下实验代码来比较它们对 recursive() 的调用次数:


class Solution {
    public static void main(String[] args) {
        Solution solution = new Solution();
        solution.uniquePaths(15, 15);
    }
    private int[][] cache;
    private long count; // 统计 递归函数 的调用次数
    public int uniquePaths(int m, int n) {
        cache = new int[m][n];
        for (int i = 0; i < m; i++) {
            int[] ints = new int[n];
            Arrays.fill(ints, -1);
            cache[i] = ints;
        }
        // int result = recursive(m, n, 0, 0); // count = 80233199
        // int result = cacheRecursive(m, n, 0, 0); // count = 393
        int result = fullCacheRecursive(m, n, 0, 0); // count = 224
        System.out.println(count);
        return result;
    }
    // 完全缓存 
    private int fullCacheRecursive(int m, int n, int i, int j) {
        count++;
        if (i == m - 1 || j == n - 1) return 1;
        if (cache[i][j] == -1) {
            if (cache[i + 1][j] == -1) {
                cache[i + 1][j] = fullCacheRecursive(m, n, i + 1, j);
            }
            if (cache[i][j + 1] == -1) {
                cache[i][j + 1] = fullCacheRecursive(m, n, i, j + 1);
            }
            cache[i][j] = cache[i + 1][j] + cache[i][j + 1];
        }
        return cache[i][j];
    }
    // 只有外层缓存
    private int cacheRecursive(int m, int n, int i, int j) {
        count++;
        if (i == m - 1 || j == n - 1) return 1;
        if (cache[i][j] == -1) {
            cache[i][j] = cacheRecursive(m, n, i + 1, j) + cacheRecursive(m, n, i, j + 1);
        }
        return cache[i][j];
    }
    // 不使用缓存
    private int recursive(int m, int n, int i, int j) {
        count++;
        if (i == m - 1 || j == n - 1) return 1;
        return recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1);
    }
}
复制代码


因为我们使用 cache 数组的目的是减少 recursive() 函数的调用。


只有确保在每次调用 recursive() 之前先去 cache 数组检查,我们才可以将对 recursive() 函数的调用次数减到最少。


在数据为 15 的样本下,这是 O(393n)O(393n)O(224n)O(224n) 的区别,但对于一些卡常数特别严重的 OJ,尤其重要。


所以我建议你在「记忆化搜索」的解决方案时,采取与我一样的策略:


确保在每次访问递归函数时先去“缓存”检查。尽管这有点“不美观”,但它能发挥「记忆化搜索」的最大作用。


3:从「自顶向下」到「自底向上」


你可能会想,为什么我们需要改进「记忆化搜索」,为什么需要明确中间结果的访问时机和访问次数?


因为一旦我们能明确中间结果的访问时机和访问次数,将为我们的算法带来巨大的提升空间。


前面说到,因为我们无法确定中间结果的访问时机和访问次数,所以我们不得不“缓存”全部中间结果。


但如果我们能明确中间结果的访问时机和访问次数,至少我们可以大大降低算法的空间复杂度。


这就涉及解决思路的转换:从「自顶向下」到「自底向上」 。


如何实现从「自顶向下」到「自底向上」的转变,还是通过具体的例子来理解。


这是 LeetCode 509. Fibonacci Number,著名的“斐波那契数列”问题。


如果不了解什么是“斐波那契数列”,可以查看对应的 维基百科


由于斐波那契公式为:


f(n) =\begin{cases} 1 & n = 1, 2 \\ f(n - 1) + f(n - 2) & n > 2 \end{cases}f(n)={1f(n1)+f(n2)n=1,2n>2


天然适合使用递归:


public class Solution {
    private int[] cache;
    public int fib(int n) {
        cache = new int[n + 1];
        return recursive(n);
    }
    private int recursive(int n) {
        if (n <= 1) return n;
        if (n == 2) return 1;
        if (cache[n] == 0) {
            if (cache[n - 1] == 0) {
                cache[n - 1] = recursive(n - 1);
            }
            if (cache[n - 2] == 0) {
                cache[n - 2] = recursive(n - 2);
            } 
            cache[n] = cache[n - 1] + cache[n - 2];
        }
        return cache[n];
    }
}
复制代码


但这仍然会有我们之前所说的问题,这些问题都是因为直接递归是“自顶向下”所导致的。


这样的解法的时空复杂度为 O(n)O(n) :每个值计算一次,使用了长度为 n + 1n+1 的数组。


通过观察斐波那契公式,我们可以发现要计算某个 nn ,只需要知道 n - 1n1 的解和 n - 2n2 的解。


同时 n = 1n=1n = 2n=2 的解又是已知的(base case)。


所以我们大可以从 n = 3n=3 出发,逐步往后迭代得出 nn 的解。


由于计算某个值的解,只依赖该值的前一位的解和前两位的解,所以我们只需要使用几个变量缓存最近的中间结果即可:


class Solution {
    public int fib(int n) {
        if (n <= 1) return n;
        if (n == 2) return 1;
        int prev1 = 1, prev2 = 1;
        int cur = prev1 + prev2;
        for (int i = 3; i <= n; i++) {
            cur = prev1 + prev2;
            prev2 = prev1;
            prev1 = cur;
        }
        return cur;
    }
}
复制代码


这样我们就把原本空间复杂度为 O(n)O(n) 的算法降低为 O(1)O(1) :只是用了几个有限的变量。


但不是所有的「动态规划」都像“斐波那契数列”那么简单就能实现从“自顶向下”到“自底向上”的转变。


当然也不是毫无规律可循,尤其是我们已经写出了「暴力递归」的解决方案。


让我们再次回到 LeetCode 62. Unique Paths 当中:


class Solution {
    public int uniquePaths(int m, int n) {
        // 由于我们的「暴力递归」函数,真正的可变参数就是 i 和 j( 变化范围分别是 [0,m-1] 和 [0, n-1] )
        // 所以建议一个二维的 dp 数组进行结果存储(相当于建一个表格)
        int[][] dp = new int[m][n];
        // 根据「暴力递归」函数中的 base case
        // 我们可以直接得出 dp 中最后一行和最后一列的值(将表格的最后一行和最后一列填上)
        for (int i = 0; i < n; i++) dp[m - 1][i] = 1
        for (int i = 0; i < m; i++) dp[i][n - 1] = 1;
        // 根据「暴力递归」函数中对其他情况的处理逻辑(依赖关系)编写循环
        //(根据表格的最后一行和最后一列的值,得出表格的其他格子的值)
        for (int i = m - 2; i >= 0; i--) {
            for (int j = n - 2; j >= 0; j--) {
                dp[i][j] = dp[i + 1][j] + dp[i][j + 1];
            }
        }
        // 最终我们要的是 dp[0][0](表格中左上角的位置,也就起点的值)
        return dp[0][0];
        // 原「暴力递归」调用
        // return recursive(m, n, 0, 0);
    }
    private int recursive(int m, int n, int i, int j) {
        // base case
        if (i == m - 1 || j == n - 1) return 1;
        // 其余情况
        return recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1);
    }
}
复制代码


不难发现,我们甚至可以直接根据「暴力递归」来写出「动态规划」,而不需要关心原问题是什么。


简单的「动态规划」其实就是一个“打表格”的过程:


先根据 base case 定下来表格中的一些位置的值,再根据已得出值的位置去推算其他格子的信息。


推算所用到的依赖关系,也就是我们「暴力递归」中的“其余情况”处理逻辑。


动态规划的本质



动态规划的本质其实仍然是枚举:枚举所有的方案,并从中找出最优解。


但和「暴力递归」不同的是,「动态规划」少了很多的重复计算。


因为所依赖的这些历史结果,都被存起来了,因此节省了大量重复计算。


从这一点来说,「动态规划」和「记忆化搜索」都是类似的。


要把历史结果存起来,必然要使用数据结构,在 dp 中我们通常使用一维数组或者二维数据来存储,假设是 dp[]。


那么对应解 dp 问题我们有以下过程


  1. 状态定义 : 确定 dp[] 中元素的含义,也就是说需要明确 dp[i] 是代表什么内容
  2. 状态转移 :确定 dp[] 元素之间的关系,dp[i] 这个格子是由哪些 dp 格子推算而来的。如斐波那契数列中就有 dp[i] = dp[i - 1] + dp[i - 2]
  3. 起始值 :base case,dp[] 中的哪些格子是可以直接得出结果的。如斐波那契数列中就有 dp[0] = 0 和 dp[1] = 1


消除“后效性”



我们知道使用「动态规划」的前提是问题的“无后效性” 。


但是有些时候问题的“无后效性” 并不容易体现。


需要我们多引入一维来进行“消除”。


例如 LeetCode 上经典的「股票问题」,使用动态规划求解时往往需要多引入一维表示状态,有时候甚至需要再引入一维代表购买次数。


注意这里说的消除是带引号的,其实这样的做法更多的是作为一种“技巧”,它并没有真正改变问题“后效性” ,只是让问题看上去变得简单的。


总结



到这里我们已经可以简单回答「动态规划」和「记忆化搜索」的区别是什么了。


「记忆化搜索」本质是带“缓存”功能的「暴力递归」:


它只能解决重复计算的问题,而不能确定中间结果的访问时机和访问次数,本质是一种“自顶向下”的解决方式;


「动态规划」是一种“自底向上”的解决方案 :


能明确访问时机和访问次数,这为降低算法的空间复杂度带来巨大空间,我们可以根据依赖关系来决定保留哪些中间结果,而无须将全部中间结果进行“缓存”。

相关文章
|
1月前
|
存储 算法
深入了解动态规划算法
深入了解动态规划算法
55 1
|
1月前
|
算法 测试技术 C++
【动态规划算法】蓝桥杯填充问题(C/C++)
【动态规划算法】蓝桥杯填充问题(C/C++)
|
4月前
|
算法 开发者 Python
惊呆了!Python算法设计与分析,分治法、贪心、动态规划...这些你都会了吗?不会?那还不快来学!
【7月更文挑战第10天】探索编程巅峰,算法至关重要。Python以其易读性成为学习算法的首选。分治法,如归并排序,将大问题拆解;贪心算法,如找零问题,每步求局部最优;动态规划,如斐波那契数列,利用子问题解。通过示例代码,理解并掌握这些算法,提升编程技能,面对挑战更加从容。动手实践,体验算法的神奇力量吧!
72 8
|
7天前
|
算法 Python
在Python编程中,分治法、贪心算法和动态规划是三种重要的算法。分治法通过将大问题分解为小问题,递归解决后合并结果
在Python编程中,分治法、贪心算法和动态规划是三种重要的算法。分治法通过将大问题分解为小问题,递归解决后合并结果;贪心算法在每一步选择局部最优解,追求全局最优;动态规划通过保存子问题的解,避免重复计算,确保全局最优。这三种算法各具特色,适用于不同类型的问题,合理选择能显著提升编程效率。
24 2
|
4月前
|
算法 Python
算法不再难!Python分治法、贪心、动态规划实战解析,轻松应对各种算法挑战!
【7月更文挑战第8天】掌握Python算法三剑客:分治、贪心、动态规划。分治如归并排序,将大问题拆解递归解决;贪心策略在每步选最优解,如高效找零;动态规划利用子问题解,避免重复计算,解决最长公共子序列问题。实例展示,助你轻松驾驭算法!**
68 3
|
1月前
|
算法
动态规划算法学习三:0-1背包问题
这篇文章是关于0-1背包问题的动态规划算法详解,包括问题描述、解决步骤、最优子结构性质、状态表示和递推方程、算法设计与分析、计算最优值、算法实现以及对算法缺点的思考。
60 2
动态规划算法学习三:0-1背包问题
|
1月前
|
算法
动态规划算法学习四:最大上升子序列问题(LIS:Longest Increasing Subsequence)
这篇文章介绍了动态规划算法中解决最大上升子序列问题(LIS)的方法,包括问题的描述、动态规划的步骤、状态表示、递推方程、计算最优值以及优化方法,如非动态规划的二分法。
61 0
动态规划算法学习四:最大上升子序列问题(LIS:Longest Increasing Subsequence)
|
1月前
|
算法
动态规划算法学习二:最长公共子序列
这篇文章介绍了如何使用动态规划算法解决最长公共子序列(LCS)问题,包括问题描述、最优子结构性质、状态表示、状态递归方程、计算最优值的方法,以及具体的代码实现。
116 0
动态规划算法学习二:最长公共子序列
|
1月前
|
存储 人工智能 算法
【算法——动态规划】蓝桥ALGO-1007 印章(C/C++)
【算法——动态规划】蓝桥ALGO-1007 印章(C/C++)
【算法——动态规划】蓝桥ALGO-1007 印章(C/C++)
|
1月前
|
存储 算法
动态规划算法学习一:DP的重要知识点、矩阵连乘算法
这篇文章是关于动态规划算法中矩阵连乘问题的详解,包括问题描述、最优子结构、重叠子问题、递归方法、备忘录方法和动态规划算法设计的步骤。
95 0