问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

动态规划详解:从编辑距离到钱币组合问题

创作时间:
作者:
@小白创作中心

动态规划详解:从编辑距离到钱币组合问题

引用
CSDN
1.
https://blog.csdn.net/qq_37756660/article/details/137587886

动态规划是一种通过将问题分解为更小的子问题,并利用子问题的解来构建原问题解的算法设计技术。本文将通过编辑距离计算和钱币组合问题两个案例,深入讲解动态规划的核心思想和实际应用。

状态转移方程和编程实现

在上一节中,我们从查询推荐的业务需求出发,介绍了编辑距离的概念。今天,我们将基于此,获得状态转移方程,并进行实际的编码实现。

上图展示了两个字符串之间的编辑距离状态转移表。其中,求最小值的 min 函数有三个参数,分别对应三种编辑操作:A 串插入、B 串插入(A 串删除)和替换字符。表格右下角标出了两个字符串的编辑距离为 1。

作为程序员,理解概念后,最终还是要落脚在编码上。我们假设字符数组 A[] 和 B[] 分别表示字符串 A 和 B,A[i] 表示字符串 A 中第 i 个位置的字符,B[i] 表示字符串 B 中第 i 个位置的字符。二维数组 d[,] 表示用于推导的二维表格,而 d[i,j] 表示这张表格中第 i 行、第 j 列求得的最终编辑距离。函数 r(i, j) 表示替换时产生的编辑距离。如果 A[i] 和 B[j] 相同,函数的返回值为 0,否则返回值为 1。

有了这些定义,下面我们用迭代来表达上述的推导过程:

  • 如果 i 为 0,且 j 也为 0,那么 d[i, j] 为 0。
  • 如果 i 为 0,且 j 大于 0,那么 d[i, j] 为 j。
  • 如果 i 大于 0,且 j 为 0,那么 d[i, j] 为 i。
  • 如果 i 大于 0,且 j 大于 0,那么 d[i, j] = min(d[i-1, j] + 1, d[i, j-1] + 1, d[i-1, j-1] + r(i, j))。

其中最关键的一步是 d[i, j] = min(d[i-1, j] + 1, d[i, j-1] + 1, d[i-1, j-1] + r(i, j))。这个表达式表示的是动态规划中从上一个状态到下一个状态之间可能存在的一些变化,以及基于这些变化的最终决策结果。我们把这样的表达式称为状态转移方程。在所有动态规划的解法中,状态转移方程是关键,所以你一定要掌握它。

有了状态转移方程,我们就可以很清晰地用数学的方式,来描述状态转移及其对应的决策过程,而且,有了状态转移方程,具体的编码其实就很容易了。基于编辑距离的状态转移方程,在这里列出了一种编码的实现,你可以看看。

我们首先要定义函数的参数和返回值,你需要注意判断一下 a 和 b 为 null 的情况。

public class Lesson10_1 {

  /**
   * @Description:  使用状态转移方程,计算两个字符串之间的编辑距离
   * @param a-第一个字符串,b-第二个字符串
   * @return int-两者之间的编辑距离
   */

  public static int getStrDistance(String a, String b) {

    if (a == null || b == null) return -1;

    // 初始用于记录化状态转移的二维表
    int[][] d = new int[a.length() + 1][b.length() + 1];

    // 如果i为0,且j大于等于0,那么d[i, j]为j
    for (int j = 0; j <= b.length(); j++) {
      d[0][j] = j;
    }

    // 如果i大于等于0,且j为0,那么d[i, j]为i
    for (int i = 0; i <= a.length(); i++) {
      d[i][0] = i;
    }

    // 实现状态转移方程
    // 请注意由于Java语言实现的关系,代码里的状态转移是从d[i, j]到d[i+1, j+1],而不是从d[i-1, j-1]到d[i, j]。本质上是一样的。
    for (int i = 0; i < a.length(); i++) {
      for (int j = 0; j < b.length(); j++) {

        int r = 0;
        if (a.charAt(i) != b.charAt(j)) {
          r = 1;
        }

        int first_append = d[i][j + 1] + 1;
        int second_append = d[i + 1][j] + 1;
        int replace = d[i][j] + r;

        int min = Math.min(first_append, second_append);
        min = Math.min(min, replace);
        d[i + 1][j + 1] = min;

      }
    }

    return d[a.length()][b.length()];

  }
}

最后,我们用测试代码测试不同字符串之间的编辑距离。

public static void main(String[] args) {
  // TODO Auto-generated method stub
  System.out.println(Lesson10_1.getStrDistance("mouse", "mouuse"));
}

从推导的表格和最终的代码可以看出,我们相互比较长度为 m 和 n 的两个字符串,一共需要求 mxn 个子问题,因此计算量是 mxn 这个数量级。和排列法的 m^n 相比,这已经降低太多太多了。

我们现在可以快速计算出编辑距离,所以就能使用这个距离作为衡量字符串之间相似度的一个标准,然后就可以进行查询推荐了。

到这里,使用动态规划来实现的编辑距离其实就讲完了。我把两个字符串比较的问题,分解成很多子串进行比较的子问题,然后使用状态转移方程来描述状态(也就是子问题)之间的关系,并根据问题的定义,保留最小的值作为当前的编辑距离,直到过程结束。

如果我们使用动态规划法来实现编辑距离的测算,那就能确保查询推荐的效率和效果。不过,基于编辑距离的算法也有局限性,它只适用于拉丁语系的相似度衡量,所以通常只用于英文或者拼音相关的查询。如果是在中文这种亚洲语系中,差一个汉字(或字符)语义就会差很远,所以并不适合使用基于编辑距离的算法。

实战演练:钱币组合的新问题

和排列组合等穷举的方法相比,动态规划法关注发现某种最优解。如果一个问题无需求出所有可能的解,而是要找到满足一定条件的最优解,那么你就可以思考一下,是否能使用动态规划来降低求解的工作量。

还记得之前我们提到的新版舍罕王奖赏的故事吗?国王需要支付一定数量的赏金,而宰相要列出所有可能的钱币组合,这使用了排列组合的思想。如果这个问题再变化为“给定总金额和可能的钱币面额,能否找出钱币数量最少的奖赏方式?”,那么我们是否就可以使用动态规划呢?

思路和之前是类似的。我们先把这个问题分解成很多更小金额的子问题,然后试图找出状态转移方程。如果增加一枚钱币 c,那么当前钱币的总数量就是增加 c 之前的钱币总数再加上当前这枚。举个例子,假设这里我们有三种面额的钱币,2 元、3 元和 7 元。为了凑满 100 元的总金额,我们有三种选择。

第一种,总和 98 元的钱币,加上 1 枚 2 元的钱币。如果凑到 98 元的最少币数是 x1 ,那么增加一枚 2 元后就是 (x1 + 1) 枚。

第二种,总和 97 元的钱币,加上 1 枚 3 元的钱币。如果凑到 97 元的最少币数是 x2 ,那么增加一枚 3 元后就是 (x2 + 1) 枚。

第三种,总和 93 元的钱币,加上 1 枚 7 元的钱币。如果凑到 93 元的最少币数是 x3 ,那么增加一枚 7 元后就是 (x3 + 1) 枚。

比较一下以上三种情况的钱币总数,取最小的那个就是总额为 100 元时,最小的钱币数。换句话说,由于奖赏的总金额是固定的,所以最后选择的那枚钱币的面额,将决定到上一步为止的金额,同时也决定了上一步为止钱币的最少数量。根据这个,我们可以得出如下状态转移方程:

其中,c[i]表示总额为 i 的时候,所需要的最少钱币数,其中 j=1,2,3,…,n,表示 n 种面额的钱币,value[j]表示第 j 种钱币的面额。c[i - values(j)]表示选择第 j 种钱币的时候,上一步为止最少的钱币数。需要注意的是,i - value(j) 需要大于等于 0,而且 c[0] = 0。

我这里使用这个状态转移方程,做些推导,具体的数据你可以看下面这个表格。表格每一行表示奖赏的总额,前 3 列表示 3 种钱币的面额,最后一列记录最少的钱币数量。表中的“/”表示不可能,或者说无解。

这张状态转移表同样可以帮助你来理解状态转移方程的正确性。一旦状态转移方程确定了,要编写代码来实现就不难了。

小结

通过这两节的内容,讲述了动态规划主要的思想和应用。如果仅仅看这两个案例,也许你觉得动态规划不难理解。不过,在实际应用中,你可能会产生这些疑问:什么时候该用动态规划?这个问题可以用动态规划解决啊,为什么我没想到?这里就讲一些个人的经验。

首先,如果一个问题有很多种可能,看上去需要使用排列或组合的思想,但是最终求的只是某种最优解(例如最小值、最大值、最短子串、最长子串等等),那么你不妨试试是否可以使用动态规划。其次,状态转移方程是个关键。你可以用状态转移表来帮助自己理解整个过程。如果能找到准确的转移方程,那么离最终的代码实现就不远了。当然,最好的方式,还是结合工作中的项目,不断地实践,尝试,然后总结。

思考题

对于总金额固定、找出最少钱币数的题目,用循环或者递归的方式该如何进行编码呢?

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号