笔试算法题总结 | 动态规划解题思路

108 阅读4分钟

前言

本人是一名前端程序员,平时开发中能使用算法的地方其实不多,学习算法主要就通过每天的力扣打卡

本文主要介绍我在近期的笔试中的解题思路,并对动态规划这一算法做重点讲解

解题思路

近期也是参加了约10场的笔试,除了两道卡在80%没找到边界条件,其余的算法题都AC了

先概括一下拿到题目后的基本思路:数据量小,动态规划;数据量大,绝逼贪心;涉及树图表,广深遍历

详细说下:

  • 动态规划能解决面试中的大部分问题,但数据量的极限大约在 10**5 左右,在这个范围内的问题都可以尝试动态规划
  • 一般遇到很离谱的数据范围(10**9),不用想一定是贪心来解决的。贪心与动态规划的区别在于前者求的是局部最优,后者求的是全局最优,使用贪心解决问题的前置条件是局部最优能推导出全局最优
  • 还有就是树、图、表这类特殊的数据结构,解题方案基本都是深度递归广度遍历

接下来详细讲解一下动态规划;贪心其实是一种直觉,主要在于对题目的理解;而广深遍历去专门练一些基本都能掌握。唯有动态规划的落差感最强烈,看题解是真简单,但自己做又想不出来

动态规划

动态规划和记忆化搜索一样,都是将过程中的状态保存下来,避免暴力递归中的重复计算,通过空间换取时间

先说一个误区,大部分人在学动态规划时可能只看重递推公式。

但其实动态规划主要分为以下四步:

  1. 明确 dp 数组下标的含义,根据含义确定结果
  2. dp 数组的初始化
  3. 确定递推公式
  4. 确定遍历顺序

好多人面对动态规划题,直接就去思考递推公式,在dp数组还没想清楚时,想要同时完成前三步怎会不难呢?

例题

接下来用道较难的例题来实操一下,例题选自力扣664. 奇怪的打印机

image.png

观察题目数据范围,只有100,符合动态规划的要求,甚至支持n**3的时间复杂度

  • 第一步,思考如何建立dp数组。题目中并没有明显的迭代环节,我们可以尝试每次迭代一个字母,先用dp数组存储从开头到当前字符,所需的最少打印数,而所求结果就是 dp[n-1](n为字符串长度,下标从0开始)

  • 第二步,初始化dp数组。没啥需要初始化的,打印第一个字符时需要1次 dp[0]=1

  • 第三步,确定递推公式。这里出现分支:

    • 当前字符与前一字符相等,很简单,打印前一字符时可以同时打印当前字符(dp[i] = dp[i-1])
    • 当前字符与前一字符不相等,一种措施是单独打印当前字符(dp[i] = dp[i-1] + 1);
      当然我们想要最少的打印数,所以得思考该字符能否在之前一起打印,比如 aba 也只需要打印两次。
      然而在是否能够一起打印时发现了问题。因为我们不能粗暴的认为前面有相同字符,就能一起打印,比如 abab 必须打印3次,其中 a 或 b 只能够单独打印;也不能简单的与第一个字符作比较,cbab 也只需要3次

到这里,我们发现dp数组保存的内容无法支持我们的递推了,说明dp数组的含义存在问题,这时要回过头去修改dp数组的定义

  • 回到第一步,将 dp 数组升维,dp[i][j] 表示打印完成区间 [i, j] 的最少打印数

  • 然后初始化,长度为 1 的区间只需要打印一次(dp[i][i] = 1)

  • 递推公式,与前一字符相等时还是直接 dp[i] = dp[i-1];而与前一字符不同时,为了省略这次打印,可尝试与之前相同字符同时打印,也就是从那一位置做分割:dp[i][j] = Min(dp[i][k-1] + dp[k][j-1]) (k 需满足s[k] = s[j])

  • 确定遍历顺序,因为我们计算 dp[i][j] 时,要求 dp[i][k-1]dp[k][j-1] 已计算(i<k<j)。所以遍历顺序是从下至上,从前往后

上代码:

var strangePrinter = function(s) {
    const n = s.length
    // 定义dp数组 dp[i][j]表示打印完成区间 [i, j] 的最少打印数
    const dp = new Array(n).fill(0).map(() => new Array(n))
    // 初始化
    for (let i = n-1; i >= 0; i--) {
        dp[i][i] = 1
    }
    // 从下往上,从前往后遍历
    for (let i = n-2; i >= 0; i--) {
        for (let j = i + 1; j < n; j++) {
            if (s[j] == s[j - 1]) {
                // 与前一字符相同,直接一起打印
                dp[i][j] = dp[i][j - 1]
            } else {
                let min = dp[i][j - 1] + 1 // 单独打印
                for (let k = i; k < j; k++) {
                    // 遍历之前的所有相同字符,尝试省略这次打印
                    if (s[k] == s[j]) {
                        // 注意处理 k==i 时的情况
                        min = Math.min(min, dp[k][j-1] + (k==i ? 0 : dp[i][k-1]))
                    }
                }
                dp[i][j] = min
            }
        }
    }
    // 输出结果
    return dp[0][n - 1]
};

针对这道题,如果直接思考递推公式,往往会默认成一维的dp数组,进而由于信息不足无法确定递推逻辑

我们提前明确了dp数组的定义,在递推逻辑出现问题时就能够及时去修改,一般都是升维dp数组,让其存储更多的信息

其实吧,这道例题也出自我昨天的笔试,当时也意识到是力扣的原题,但具体解法忘了,通过以上几步,重新写出了动态规划的代码

结语

前端考的算法题相对来说应该较为简单,本文的讲解也并不深入,欢迎掘友评论指正

如果觉得喜欢或有所帮助,欢迎点赞关注,鼓励一下作者