面试必备:深入解析动态规划经典题目

199 阅读3分钟

注意啦!!!这期内容非常重要,如果你在准备面试,请你一定要看完!!!


动态规划是笔试和面试算法中的重要章节,在之前的文章中我们讲解了一维和二维dp,还有动态规划中非常经典的背包问题。其实每年的笔试和面试场上都会出现一些经久不衰的动态规划经典题目,难住了一届又一届的面试候选人。本期文章给大家带来几道经典题目,希望能给大家带来帮助。

leetcode72 编辑距离

题意解析

首先是非常经典的字符串编辑距离问题,给定两个字符串word1word2,可以进行插入、删除和替换字符的操作,请你计算将word1转换成word2使用的最少操作数。

题目给我们举了两个例子,比如示例1中word1 = "horse"word2 = "ros",当然我们肯定可以把word1中的字符一个个删掉,再一个个插入word2中的字符,这也是一种编辑路径。题目要求我们计算最少操作数,所以我们先将word1中的h替换成r,再依次删掉中间的r和最后的e,这样的操作数就是3。

状态转移方程

其实,类似这种针对两个字符串的动态规划问题,我们一般都会使用一个二维数组去解决,dp[i][j]代表的含义就是第一个字符串的前i个字符 和 第二个字符串的前j个字符 它们两个之间你要求解的业务答案。比如说针对这道编辑距离问题,我们就可以使用dp[i][j]去代表word1[0...i-1]和word2[0...j-1]之间的编辑距离。那这道题目要求的最终返回结果就是dp[m][n],其中mword1的长度,nword2的长度。

i=0i=0时,dp[0][j]代表的含义是一个空字符串和word2中前j个字符之间的编辑距离,不难想到一定是执行j次插入操作;当j=0j=0时,dp[i][0]代表的含义是word1中的前i个字符到一个空字符串之间的编辑距离,那就是执行ii次删除字符的操作;所以可以得到:

dp[i][j]={ji=0ij=0dp[i][j] = \begin{cases} j &&&&& i = 0 \\ \\ i &&&&& j = 0 \end{cases}

再想一下,针对word1[0...i-1]和word2[0...j-1]这两个子串,如果word1i1i-1位置的字符和word2j1j-1位置的字符相等,那么我只需要将word100位置到i2i-2位置的字符变成word200位置到j2j-2位置的字符就可以了,所以这两个子串的编辑距离等于word1[0...i-2]word2[0...j-2]的编辑距离;

例如:word1[0...i-1] = "abcd"word2[0...j-1] = "1234d",由于word1中的最后一个字符和word2中的最后一个字符相等,所以只需要将word1中的"abc"变成word2中的"1234"就可以了。也就是说dp[i][j] = dp[i-1][j-1]

如果这两个位置上的字符不相同,那么要想将word1编辑成word2,可能有如下的几种可能:

  • 先将word1[0...i-2]编辑成word2[0...j-1],然后将word1的最后一个字符删掉;
  • 先将word1[0...i-1]编辑成word2[0...j-2],然后在word1的最后插入一个字符;
  • 先将word1[0...i-2]编辑成word2[0...j-2],然后将word1的最后一个字符替换成word2的最后一个字符

例如:word1[0...i-1] = "abcd"word2[0...j-1] = "1234",那么要将"abcd"编辑成"1234"有以下几种可能

  • 先把"abcd"编辑成"1234d",然后再将最后面的"d"删掉,得到"1234"
  • 先把"abcd"编辑成"123",然后再再后面插入字符"4",得到"1234"
  • 先把"abcd"编辑成"123d",然后再把最后面的d替换成4,得到"1234"

综上所述,"word1[0...i-1]"word2[0...j-1]"的编辑距离,是以上三种可能的最小值+1+ 1,至此我们得到了下面完整的状态转移方程:

dp[i][j]={ji=0ij=0dp[i1][j1]word1[i1]=word2[j1]min{dp[i1][j],dp[i1][j1],dp[i][j1]}+1word1[i1]word2[j1] dp[i][j] = \begin{cases} j & i = 0 \\ \\ i & j = 0 \\ \\ dp[i-1][j-1] & word1[i-1] = word2[j-1] \\ \\ min\{dp[i-1][j], dp[i-1][j-1], dp[i][j-1]\} + 1 & word1[i-1] \ne word2[j-1] \end{cases}

从转移方程中可以得知,dp[i][j]可能是通过左侧、上方和左上方三个位置的值推出来的,所以可以从左到右,从上到下推出整个二维数组,最后二维数组右下角的值就是题目要的返回值。

AC代码

public int minDistance(String word1, String word2) {
    int m = word1.length();
    int n = word2.length();

    // dp[i][j]:  word1(0....i-1) -> word2(0....j-1)的编辑距离
    int[][] dp = new int[m+1][n+1];

    for (int i=0; i<=m; i++) {
        dp[i][0] = i;
    }

    for (int j=0; j<=n; j++) {
        dp[0][j] = j;
    }

    for (int i=1; i<=m; i++) {
        for (int j=1; j<=n; j++) {
            if (word1.charAt(i-1) == word2.charAt(j-1)) {
                dp[i][j] = dp[i-1][j-1];
            } else {
                dp[i][j] = Math.min(Math.min(dp[i-1][j-1], dp[i-1][j]), dp[i][j-1]) + 1;
            }
        }
    }

    return dp[m][n];
}

leetcode1143 最长公共子序列

题目描述

题目要求字符串text1text2的最长 公共子序列 长度。如果不存在则返回 0 。题目给出了公共子序列的概念,是这两个字符串所共同拥有的子序列。

需要注意的是,我们在刷题过程中经常能看到两种概念:“子串”和“子序列”,其中”子串“指的是字符串中一段连续的字符,而“子序列”不要求字符连续,只要它们的相对位置不变即可;例如在"abcde"这个字符串中,"ace"是它的子序列,而不是子串,"abc"才是子串。

状态转移方程

在前面我们提到过,针对两个字符串的动态规划问题,一般使用二维数组,dp[i][j]代表的含义就是第一个字符串的前ii个字符 和 第二个字符串的前jj个字符 它们两个之间你要求解的业务答案。按照这个思路的话,我们就可以用dp[i][j]去代表text1中前ii个字符和text2中前jj个字符的最长公共子序列。这样题目要求的返回值就应该是dp[m][n],这里m是指text1的长度,n是指text2的长度。

按照这个含义,当i=0j=0时,dp[i][j]代表的就是一个空字符串和另一个字符串的最长公共子序列,此时显然应该是0。

iijj均不为0时,如果text1[i-1]text2[j-1]相同,那么text1[0...i-1]text2[0...j-1]的最长公共子序列应该等于text1[0...i-2]text2[0...j-2]的最长公共子序列长度 +1+ 1

例如:"abcde""ace"的最长公共子序列,应该是"abcd""ac"两个字符串的最长公共子序列长度+1

如果text1[i-1]text2[j-1]不同,那么text1[0...i-1]text2[0...j-1]的最长公共子序列就应该等于text1[0...i-2]text2[0...j-1]的最长公共子序列长度 与 text1[0...i-1]text2[0...j-2]的最长公共子序列长度 中的最大值。

例如:"abcd""ac"的最长公共子序列,是"abc""ac"的最长公共子序列 与 "abcd""a"的最长公共子序列之中的最大值。

这样我们就得到了下面的状态转移方程

dp[i][j]={0i=0j=0dp[i1][j1]text1[i1]=text2[j1]max{dp[i1][j],dp[i][j1]}text1[i1]text2[j1]dp[i][j] = \begin{cases} 0 & i = 0 或 j = 0 \\ \\ dp[i-1][j-1] & text1[i-1] = text2[j-1] \\ \\ max\{dp[i-1][j], dp[i][j-1]\} & text1[i-1] \ne text2[j-1] \end{cases}

通过这个转移方程可以知道,二维数组中dp[i][j]的值是由它上方、左侧和左上方的元素推导出来的,所以可以通过从左到右、从上到下的顺序进行推导。

AC代码

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=0; i<=m; i++) {
        dp[i][0] = 0;
    }

    for (int j=0; j<=n; j++) {
        dp[0][j] = 0;
    }

    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];
}

leetcode300 最长递增子序列

状态转移方程

这个问题的题意比较好理解,让我们求一个数组中的最长递增子序列的长度。在这个数组中,如果使用dp[i]来代表以nums[i]为结尾的最长递增子序列长度,那么题目要求的返回值就应该是以nums数组中每一个元素为结尾的最长递增子序列长度的最大值,也就是dp数组中的最大值。

在推导dp[i]的过程中,如果i=0,意味着我们在求的是以nums[0]为结尾的最长递增子序列长度,以nums[0]为结尾的子序列只有一个,它符合自增子序列的定义,所以结果是1

i0i \ne 0时,我们遍历整个数组,假设我们遍历到nums[k], 0 <= k < i,如果nums[k] < nums[i],那么nums[i]就可以拼接在nums[k]的后面形成一个新的递增子序列,这个递增子序列的长度应该是nums[k]作为结尾的递增子序列长度+1+1,否则nums[i]拼在nums[k]后面就肯定无法形成递增子序列。因此,dp[i]就应该等于dp[0...i-1]中的最大值。

比如数组[0, 1, 0, 3, 2],当我们要求以3位置的3作为结尾的最长递增子序列长度时,3 > 1位置的1,那么3就可以拼接在它的后面形成新的递增子序列,这个子序列的是[0, 1, 3],长度是[0, 1]这个子序列的长度 +1+ 1

至此,我们可以得到如下的状态转移方程

dp[i]={1i=0max{dp[k]}+10k<inums[k]<num[i]dp[i] = \begin{cases} 1 & i = 0 \\ \\ max\{dp[k]\} + 1 & 0 \le k \lt i 且 nums[k] < num[i] \end{cases}

由于dp[i]是依赖dp[0...i-1]推出来的,所以dp数组需要从左向右一路推出来,最终返回dp数组中的最大值

AC代码

public int lengthOfLIS(int[] nums) {
    int n = nums.length;
    int[] dp = new int[n];
    dp[0] = 1;

    int res = dp[0];

    for (int i=1; i<n; i++) {
        int temp = 0;
        for (int j=0; j<i; j++) {
            if (nums[j] < nums[i]) {
              temp = Math.max(temp, dp[j]);
            }
        }
        dp[i] = temp + 1;
        res = Math.max(res, dp[i]);
    }

    return res;
}

总结

动态规划在笔试和面试算法中占的比重非常大,文章中介绍的三个问题是动态规划中非常经典的题目,也全都是我本人在笔试和面试场上遇到过的,属于是面试之前背也要背会的内容。同时,这几道题目中也包含了动态规划中一些典型的思考方向,例如dp数组的含义和推导方式等。

如果觉得本篇文章对你有所帮助的话,请给我点个赞哦,写对我非常重要,谢谢!