動的計画法と問題解決のアイデアを理解する方法

1.動的計画法

動的計画法(DP)は、数学、経営科学、コンピューターサイエンス、経済学、およびバイオインフォマティクスで使用される方法であり、元の問題を比較的単純なサブ問題に分解することによって複雑な問題を解決します。

動的計画問題の一般的な形式は、最大値を見つけることです動的計画法は、実際にはオペレーションズリサーチの最適化手法ですが、最長増加部分列、最小編集距離などを見つけることができるなど、コンピューターの問題で広く使用されています。

動的計画法は、多くの場合、重複する部分問題最適な部分構造特性の問題に適しており、すべての部分問題の結果が記録されるため、動的計画法の時間消費は、多くの場合、単純なソリューションよりもはるかに少なくなります。

動的計画法には、ボトムアップトップダウンの2つの問題解決方法があります。トップダウンはメモ化の再帰であり、ボトムアップは再帰です。

動的計画法によって解決される問題には明らかな特徴があります。サブ問題が解決されると、後続の計算プロセスはそれを変更しません。この特徴は後遺症なしと呼ばれます。問題を解決するプロセスは有向グラフを形成します。非巡回グラフ。動的計画法は、各サブ問題を1回だけ解決し、自然な剪定の機能を備えているため、計算量が削減されます。

これはleetcodeの上の概念であり、構造化して特定の概念を理解することができます。

画像-20210913104321193

1.最適な下部構造は何ですか?

問題の最適解にその部分問題の最適解が含まれている場合、その問題は最適な部分構造特性を持っていると言われます。

逆に理解することができます。つまり、子供が父親を見つけるというサブ問題の最適解を通して、問題の最適解を推測することができます。先に定義した動的計画法に最適な部分構造を対応させると、元の問題は、thenyesの最適な部分構造などの部分問題から導き出せることも理解できます。F(10) = F(9)+F(8)F(9)F(8)F(10)

2.重複するサブ問題とは何ですか?

再帰的アルゴリズムが問題を解決する場合、毎回生成されるサブ問題は必ずしも新しい問題ではなく、一部のサブ問題は何度も繰り返し計算されます。このプロパティは、サブ問題のオーバーラッププロパティと呼ばれます。

3.後遺症とは何ですか?

特定の段階の状態が決定されると、その後のプロセスの進化は、以前の状態や決定の影響を受けなくなります。

2.フィボナッチ数列

フィボナッチ数列は、そのような数列を指します。

画像-20210913112547375

この数列は第3項から始まり、各項は前の2つの項の合計に等しくなります。その再帰式は次のとおりです。

F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
复制代码

2.1ブルートフォース再帰

フィボナッチ数列を再帰的に実装します

public int fib(int n) {
    if (n == 0) {
        return 0;
    }
    if (n == 1) {
        return 1;
    }
    return fib(n - 1) + fib(n - 2);
}
复制代码

リートコードの動作があまり良くないことがわかります。

image-20210913113734097

その実行プロセスを分析してみましょう。n=6値が必要な場合、再帰ツリーは次のようになります。

image-20210913144609538

上記の各ノードは1回実行され、繰り返し実行されるノードがあります。これは、 5回繰り返し実行されるなど、重複するサブ問題の性質のパフォーマンスです。fib(2)各関数を呼び出すときにコンテキストが保持されるため、スペースのオーバーヘッドは小さくありません。非常に多くの子ノードが繰り返し実行されます。実行中に実行された子ノードを保存すると、テーブルを検索して後で必要になったときに呼び出すことで、時間を大幅に節約できます。

動的計画法のボトムアップおよびトップダウンアプローチを使用して、フィボナッチ数列の問題を解決しようとします。

2.2トップダウンメモ

在递归方法中如果要计算原问题 f(20)的值,就得先计算出子问题 f(19)f(18),然后要计算 f(19),我就要先算出子问题 f(18)f(17),以此类推。最后遇到 f(1) 或者 f(2) 的时候,结果已知,就能直接返回结果,递归树不再向下生长了。

image-20210913145627020

因此,我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。

一般使用一个数组充当这个「备忘录」,当然也可以使用哈希表。

private Map<Integer, Integer> memo = new HashMap<>();
//备忘录法
public int fib(int n) {
    if (n == 0) return 0;
    if (n == 1 || n == 2) return 1;
    if (memo.containsKey(n)) {
        return memo.get(n);
    } else {
        int value = fib(n - 1) + fib(n - 2);
        memo.put(n, value);
        return value;
    }
}
复制代码

再次查看时间消耗,很明显耗时减少。

image-20210913143212839

「备忘录」到底做了什么?

当计算 f(20)的值,先计算出 f(19)f(18),而在计算f(19)的值时,已经把f(18)的值计算出来了。

image-20210913150539383

实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数,即:

image-20210913151214786

2.3 自底向上动态规划

可以看到,备忘录法还是利用了递归,计算fib(20)的时候还是要计算出fib(19)fib(18)fib(17)…,如果我们先计算出fib(1)fib(2)fib(3)…呢?这也就是动态规划的核心,先计算子问题,再由子问题计算父问题。

因为我们已经知道fib(0)fib(1)的值,实际上,fib(2)的值也是知道的,即:

fib(2) = fib(0) + fib(1) = 1
fib(3) = fib(1) + fib(2) = 2
fib(4) = fib(2) + fib(3) = 3
fib(5) = fib(3) + fib(4) = 5
fib(6) = fib(4) + fib(5) = 8
......
复制代码

我们根据备忘录的思想,用一张表来记录,即:

image-20210913161559976

public int fib(int n) {
    if (n == 0) return 0;
    //dp表,n+1是因为0~n,有n+1个数
    int[] dp = new int[n + 1];
    dp[0] = 0;
    dp[1] = 1;
    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
}
复制代码

看看实际效果:

image-20210913161644191

另外,我们可以看到尽管用数组自底向上保存了先计算的值,但实际上参与循环计算的始终只有i,i-1,i-2这3项,即

image-20210913165629994

因此,可以把上面的代码优化为:

public int fib(int n) {
    if (n == 0) return 0;
    if (n == 1 || n == 2) return 1;
    int a = 1;
    int b = 1;
    int temp = 0;//temp即为下一次循环的b的值
    for (int i = 3; i <= n; i++) {
        temp = a + b;
        a = b;
        b = temp;
    }
    return temp;
}
复制代码

3. 动态规划基本设计步骤

当然,斐波拉契数列只是一个超级简单的动态规划的引子,也就是说刚入门,但是我们确可以从中得到一些启发,对于动态规划,无非就是利用历史记录,来避免我们的重复计算。而这些历史记录,我们得需要一些变量来保存,一般是用一维数组或者二维数组来保存。因此,我们可以总结出动态规划基本设计步骤

  1. 数组元素的含义,即dp[],如自底向上的解法,dp[n]就代表第n项的值。
  2. 数组元素之间的关系式(或者叫状态转移方程) ,当我们要计算 dp[n] 时,是可以利用 dp[n-1],dp[n-2]… dp[1],来推出 dp[n] ,也就是可以利用历史数据来推出新的元素值,所以我们要找出数组元素之间的关系式,如 dp[n] = dp[n-1] + dp[n-2],当然这个不同题意,关系式也不同,同时也是难点。
  3. 初始值,也就是 dp 中的边界情况,我们通过公式dp[n] = dp[n-1] + dp[n-2]去递推,当递推到 dp[2] = dp[1] + dp[0],此时dp[1]dp[0] 不能再分解,因此我们必须要知道dp[1]dp[0] 的值。

下面通过几个例子来实战一下。

3.1 爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。有多少种不同的方法可以爬到楼顶?(n 为正整数)

我们按照上面的步骤来分析。

1、定义数组元素的含义

假设跳上一个 n 阶的台阶总共有dp(n)种跳法。

2、定义数组元素之间的关系式

把原问题拆分为子问题,因为每次可以爬 1 或 2 个台阶,因此:

第一层:1种,记为dp(1)=1,n=1
​
第二层:2种(走2步或走两个1步),记为dp(2)=2,n=2
​
第三层:3种(走3个1步或在第一层走2步或在第二层走1步),记为dp(3)=dp(1)+dp(2),n=3
​
......
复制代码

因此第 n 层与第 n-1 和第 n-2 层有关。即到达第 n 级的台阶有两种方式:

  • 一种是从第 n-1 级跳上来
  • 一种是从第 n-2 级跳上来

即:dp[n] = dp[n-1] + dp[n-2]

很明显可以看出,其实就是一个斐波拉契数列,唯一的区别就是临界值不同,这里的 n 不能为 0。

3、初始值

在第二步就已经得到其初始值为dp(1)=1,dp(2)=2,需要注意的就是临界值

代码实现

public int climbStairs(int n) {
    if (n <= 1) return 1;
    if (n == 2) return 2;
    int[] dp = new int[n + 1];
    dp[1] = 1;
    dp[2] = 2;
    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
}
复制代码

3.2 不同路径

一个机器人位于一个 m x n 网格的左上角(起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。问总共有多少条不同的路径?

image-20210913193037546

还是按照上面的步骤来分析:

1、定义数组元素的含义

题目是求从左上角到右下角有多少种路径,如果用函数来表示则是:dp(i,j),假设i,j的位置就是目标点,则其取值范围分别为[0,i)[0,j)。也就是说当机器人从左上角走到(i,j) 这个位置时,一共有 dp[i][j] 种走法。(很自然的一个二维数组,假设i表示向右走,j表示向下走)

2、定义数组元素之间的关系式

因为机器人每次只能向下或者向右移动一步,假设目标点是(i,j),则分两种情况:

  • 向下走一步到达,那么上一步的位置则是(i-1,j)
  • 向右走一步到达,那么上一步的位置则是(i,j-1)

因此,总共的走法则有:dp[i][j] = dp[i-1][j] + dp[i][j-1]

3、初始值

对于初始值,其实我们很好判定,如 i = 0或者j = 0(注:这里的0表示的数组下标),那么此时对于上面的表达式是不存在的,即为负数了,所以我们可以设置边界条件,如下:

  • i = 0,表示横向只有一个格子,则只能一直往下走,很显然只有一条路径,dp[0][j] = 1
  • j = 0,表示纵向只有一个格子,则只能一直往右走,很显然也只有一条路径,dp[i][0] = 1

代码实现

public int uniquePaths(int m, int n) {
   
    int[][] dp = new int[m][n];
    // 赋初始值
    for (int i = 0; i < m; i++) {
        dp[i][0] = 1;
    }
    for (int j = 0; j < n; j++) {
        dp[0][j] = 1;
    }
    // 从 1 开始
    for (int i = 1; i < m; i++) {
        for (int j = 1; j < n; j++) {
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }
    // 注意数组从 0 开始,所以要 -1
    return dp[m-1][n-1];
}
复制代码
  • 时间复杂度:O(m*n)
  • 空间复杂度:O(m*n)

3.3 最长公共子序列

给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。

例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。

两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。

示例 1:

输入:text1 = "abcde", text2 = "ace" 输出:3 解释:最长公共子序列是 "ace" ,它的长度为 3 。

注:子序列可以是不连续的,子字符串需要是连续的。

先分析一下,假如现在有字符串如下:

text1 = abfeghac

text2 = cabfeghc
复制代码

1、定义数组元素的含义

还是老样子,2 个字符串,对应 2 个二维数组,思路如下:

  1. 把两个字符串分别以行和列组成一个二维的矩阵;
  2. 比较二维矩阵中每个点对应行列字符中否相等,相等的话值设置为 1,否则设置为 0;
  3. 通过查找出值为1的最长对角线就能找到最长公共子串。

image-20220128192227767

从上图可以看出,有 4 个公共子串,且最长公共子串为 7,abfeghc(为什么是 7 ,注意上面对子序列的描述)。

但这存在一个问题,我们还得再去计算这个公共子串的长度,因此我们在计算这个二维矩阵的时候顺带着计算出这个子串的长度,用ij表示两个子串的长度, dp[i][j]表示其公共长度,如下:

image-20220128194540399

dp[i][j] ,其含义是在 text1[0,i-1]text2[0,j-1] 之间匹配得到的想要的结果。

注:ij表示两个数组的长度,所以定义的数组范围是0 ~ i-1,因此i-1表示最后一位字符,j同理。

2、定义数组元素之间的关系式

从上图中我们可以看出,存在两种情况,即 2 个字符串存在相等的字符:

  • text1[i-1] == text2[j-1]时,说明两个字符串最后一位有相等的字符,所以公共子序列+1,即dp[i][j] = dp[i-1][j-1]+1

  • text1[i-1] != text2[j-1]时,说明两个字符串最后一位没有相等的字符,最后一个元素不相等,那说明最后一个元素不可能是最长公共子序列中的元素,此时存在 2 种情况,dp[i-1][j]dp[i][j-1],其中:

    • dp[i-1][j]:表示最长公共序列可以在1,2,3,...,i-11,2,3,...,j中找。
    • dp[i][j-1]:表示最长公共序列可以在1,2,3,...,i1,2,3,...,j-1中找。

    上記の2つのサブ問題を解決すると、最長共通部分列を取得する人が最長部分列になりdp[i][j] = max(dp[i−1][j],dp[i][j−1])ます。

要約すると、状態遷移方程式は次のとおりです。

image.png

3.初期値

空の文字列と文字列を比較する場合、i=0またはそのj=0場合、結果は0、つまり、である必要がありdp[0][j]=0ますdp[i][0]=0

dp[i][j]依存関係とのためにとの走査順序は小さいものから大きいものへでなければなりません。dp[i-1][j-1] , dp[i-1][j], dp[i][j-1]ij

さらに、ijが0dp[i][j] = 0の場合、、、およびdp配列自体が0に初期化されるためij1から直接トラバースします。トラバーサルの終わりは、ストリングの長さである必要があります。

コード

public int longestCommonSubsequence(String text1, String text2) {
    int m = text1.length();
    int n = text2.length();
    int[][] dp = new int[m + 1][n + 1];
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[m][n];
}
复制代码

4.まとめ

実際、動的計画法の主な問題は、その特性、最適な部分構造、重複する部分問題を把握すること、つまり、大きな問題を小さな問題に分割し、上記の手順に従って問題を解決することです。

もちろん、練習は不可欠であり、練習は完璧になります。

5.リファレンス

ナゲッツテクノロジーコミュニティのクリエイター署名プログラムの募集に参加しています。リンクをクリックして登録し、送信してください。

おすすめ

転載: juejin.im/post/7119777964820004895