面试刷题:用10道题聊一聊动态规划算法 | 第99期

Posted 青衣极客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试刷题:用10道题聊一聊动态规划算法 | 第99期相关的知识,希望对你有一定的参考价值。

遗漏消息回复:

这一段时间比较忙,没看微信消息。这两天才发现有个网友加我微信交流“加密解密”相关的技术,但是消息已经过期,无法通过验证。如果该网友看到这则回复,可以再申请一次“添加好友”。

        动态规划是经典的算法思想之一,说的是问题的解总是可以由若干子问题的解计算出来,或者说是当前状态总是可以从若干的历史状态中推导出来。 因此,动态规划常常适用于有明确递推公式的问题。在递推公式中,我们要计算最终的结果,并不是从零开始枚举所有可能的情况,而是先求解子问题的状态,然后通过子问题状态的组合来获得当前状态。我们知道,计算机是有限状态机,其中所有问题的求解理论上都可以通过枚举来进行。但是在一些情况下,如果能够记录曾经枚举过的情况,下次遇到就可以直接使用,从而避免重复枚举,这正是动态规划的意义。。 本文接下来就用10道LeetCode题目从易到难来聊一聊动态规划算法。

1 斐波那契数 Q0509


       相信很多朋友在中学时就遇到过斐波那契数列,还觉得这个数列特别简单,事实上也确实不难,因此常常作为计算机中讲述基础程序的例子。不过,在这里我们要用它来演示一下动态规划算法的大概情况,因为它有一个明确的递推公式

LeetcCode中的题目翻译如下:

斐波那契数通常表示为F(n)形成一个序列,称为斐波那契数列,这样每个数都是从0和1开始的两个前面的数之和。F(0)=0,F(1)=1 对于 N>1,F(N) = F(N-1) + F(N-2)。给定N,计算F(N)。

斐波那契数列

       在给定最开始两项的情况下,我们就可以使用循环来将第N个数计算出来,代码如下:

面试刷题:用10道题聊一聊动态规划算法 | 第99期

       这段代码中,我们使用两个临时变量来存储 (n-1) 和 (n-2) 这两个子问题或者历史时刻的状态,然后通过这两个状态计算出当前 (n) 时刻的状态。 用循环来实现是一个很直观的思路,因为这就是模拟了我们人类大脑解决这个问题的方式。此外,还可以使用递归的方式实现,虽然效率比不上循环结构,但是代码更加简洁,逻辑更加清晰,可以自行动手一试。

2 爬楼梯的方式 Q0070


       在实际的问题中,几乎不会遇到斐波那契数列这种直接给出递推公式,而是需要自己从问题的描述中分析出递推公式,比如 LeetCode中的Q0070问题描述如下:

假设你在爬一段楼梯,需要N个台阶才能到达顶部。每一次,你可以爬1或2个台阶。请问你可以用多少种不同的方式爬到顶上?注:鉴于n将是一个正整数。

面试刷题:用10道题聊一聊动态规划算法 | 第99期

爬楼梯问题

       这个问题虽然没有直接给出递推公式,但是已经描述得非常直白了,即爬到当前台阶的方式数量等于爬到前两个台阶的方式数量之和。 因为可以从 (N-1) 号台阶跳跃1个台阶到达 (N) 号台阶,也可以从 (N-2) 号台阶跳跃2个台阶到达第 (N) 号台阶,除此之外,没有其他可能性。

      再看看斐波那契数列的问题,是不是发现与这个爬台阶的问题非常相似,但又有所不同。比如当 N=1时,只有一种方式,但是当 N=2时却有两种方案。我们可以认为,当 N=0时也只有一种方式,这样整个逻辑就完全通顺了,递推公式如下:

      同样地,当前状态只与前两个时刻的状态有关,因此使用两个临时变量存储对应状态即可,具体代码实现如下:

面试刷题:用10道题聊一聊动态规划算法 | 第99期

       以上逻辑也可以使用递归实现,欢迎将你实现的新方案提交到我们的代码库。

3 最长回文子串 Q0005


       以上两个问题使用动态规划的线索太明显,所以在真正的面试中几乎不可能遇到,除非你遇到一个特意放水的面试官。接下来我们来看一个稍微有点难度的,也是在真实面试中可能遇到的问题, Leetcode Q0005

给定一个字符串s,在s中找到最长的回文子串,可以假设s的最大长度为1000。

面试刷题:用10道题聊一聊动态规划算法 | 第99期

最长回文子串

        所谓回文就是从左到右的序列元素与从右到左的序列元素完全相同。对于这个问题,我们当然可以采用枚举的办法,逐个检查每一个子串是否为回文,然后记录下最长的子串即可。这样做的时间复杂度是 O(n^3),在LeetCode在线评测很大可能会超时。

        我们可以换一个思路来考虑这个问题,要判断从 i 到 j 的这一段子串是否为回文有两种情况:(1) i 与 j 这两个位置的字符不同,则不可能是回文;(2) i 与 j 这两个位置的字符相同,则这段子串是不是回文取决于 (i+1) 到 (j-1) 这一段子串是不是回文。 到这里大家应该就发现了,可以使用动态规划来减少重复的枚举,递推公式如下:

        如果字符串的长度为 n, 我们就需要一个 n行n列 的表格来记录子串的状态,以方便查询。具体解决方案的代码如下:

面试刷题:用10道题聊一聊动态规划算法 | 第99期

        在这段代码中,我们优先处理长度为1和长度为2的子串,因为更长的子串是否为回文的判断都是以这两个长度的子串状态为起点的。虽然这里使用动态规划的解法不至于超时,但是也有一个很严重的问题,那就是非常耗费内存,需要的辅助存储复杂度为 O(n^2)。

4 最短路径和 Q0064


        将最终状态分解为若干个子状态,这是使用动态规划问题的关键,比如 LeetCode Q0064

给定一个由非负数组成的 m x n 网格,从左上角到右下角,找出一条从左上角到右下角的路径,使所有数字之和最小化。注意:在任何时间点上,你只能向下或向右移动。

面试刷题:用10道题聊一聊动态规划算法 | 第99期

最短路径和问题

        在这个问题中,我们可以首先直接考虑一下最后的结果,要想到达右下角的累计和最小,可以分解成到达右下角方块的左侧和上侧方块的累计和分别最小,然后从这两个子状态中选择最小的构成最终状态。 下面直接给出输入为 grid[m][n] 递推公式

         根据递推公式,不难编写出解决方案的代码

面试刷题:用10道题聊一聊动态规划算法 | 第99期

         以上代码直接复用输入的存储空间,因此辅助的存储复杂度为 O(1),相比于使用回溯法的递归形式来实现,更加高效一点。

5 通配符字符串匹配 Q0044


        字符串匹配问题在面试中很容易出现,比如LeetCode Q0044 带有通配符的字符串匹配

给定一个输入字符串(s)和模式(p),实现通配符模式匹配,支持'?'和'*'。(1) '?' 匹配任何单个字符。(2) '*' 匹配任何字符序列(包括空序列)。匹配应该覆盖整个输入字符串(而不是局部)。

注意:(1) s可以是空的,只包含小写字母a-z。(2) p可以是空的,只包含小写字母a-z,以及像 ? 或 * 这样的字符。

面试刷题:用10道题聊一聊动态规划算法 | 第99期

通配符匹配

       字符串的匹配问题很容易联想到动态规划的解决思路,因为最终是否匹配与同样起点但长度稍短的子串息息相关。比如,在这个问题中,如果模式串的 j 位置为 '?' 那么 0 到 j的子串与 0 到 j的模式是否匹配就依赖于 0 到 j-1 的模式和子串是否匹配。虽然很容易想到使用动态规划,但是要把这其中的递推逻辑理清楚也是需要费一番周折,其中最复杂的就是模式串中 '*'。

        考虑两个特殊情况:(1) 空串与空的模式串应该是匹配的,(2) 空串与全为 ' * ' 的模式串应该匹配的。这样空串与任意模式串的匹配情况就确定了。 也就是以下解决方案中首先处理的情况

面试刷题:用10道题聊一聊动态规划算法 | 第99期

        而对于非空字符串与非空模式串之间的匹配就稍微复杂一点,分成两种情况来看:(1) 当前位置是否匹配上,可以是字符串严格相等,也可以是 '?' 通配符匹配;(2) 模式串在当前位置为 ' * ',则当前位置的匹配情况取决于前一个位置的状态,也就是解决方案中第二块代码所表达的逻辑。

6 正则表达式匹配 Q0010


       上一题的逻辑还算是比较简单,因为关于“ * ”的作用是匹配任意字符任意次,那如果对“ * ”的作用稍加修改呢?比如LeetCode Q0010问题:

给定一个输入字符串(s)和一个模式(p),实现正则表达式匹配,支持'.'和' * '。(1) ' . ' 匹配任何单个字符。(2) ' * ' 匹配前面的零个或多个元素。匹配应该覆盖整个输入字符串(而不是局部)。注意: s可以是空的,并且只包含小写字母a-z; p可以是空的,只包含小写字母a-z,以及像.或*这样的字符。

面试刷题:用10道题聊一聊动态规划算法 | 第99期

正则表达式匹配

        在这个问题中,“ * ”的作用与上一个位置的模式字符有关,这意味着,当前状态并不仅仅取决于与它相邻的状态,而是相隔一个元素的状态也会影响当前状态。明白这一点之后,最重要的也就是枚举出 “ * ” 模式字符状态的各种可能性。感兴趣的朋友可以自己先行思考并枚举一下,然后在看看参考代码

面试刷题:用10道题聊一聊动态规划算法 | 第99期

       在这段代码中,同样是先对空串的匹配进行处理,然后再处理非空字符串。看到代码中对 “ * ” 的状态枚举,可以发现使用了 j-2 位置的状态,不知道你是否会担心出现不合法的负数,从而导致程序崩溃。 这一担心是合理的,只是在这里,题意规则和测试用例会保证不出现这种情况,当然在代码的最开始的检查中也保证了这一点。

7 解码方式 Q0091


          在算法问题中,常常出现多种方式完成一种任务,然后要求给出方式的数量。一般而言,如果没有更好的思路,采用回溯法枚举所有情况是可选方案。不过,这这里我们显然并不打算使用回溯法,而是使用动态规划。比如LeetCode Q0091问题

使用以下映射将包含A-Z字母的消息编码为数字:(1) 'A'-> 1; (2) 'B'-> 2; ... (3) 'Z'-> 26。给定一个仅包含数字的非空字符串,请确定对其进行解码的总数。

面试刷题:用10道题聊一聊动态规划算法 | 第99期

解码方式数量

         比如,输入字符串为“12”时,就可以将其解码为 "AB"(1 2) 或者 "L"(12),也就是两种解码方式;当输入字符串为 "226" 时,可以将其解码为 "BZ"(2 26)、 "VF"(22 6) 或者 "BBF"(2 2 6),也就是三种解码方式。

        一个26个字母,也就是说,组成的合法数字最多有两位,而且这两位字符构成的数字必须小于等于26。从动态规划的角度看,我们会发现,位置 i 处的解码方式数量取决于 (i-1) 和 (i-2) 这两个位置的解码方式数量,即当前状态依赖于前两个位置的状态,当然,这是有条件的依赖,条件是组成的数字合法。

        分析到这一步,使用动态规划的思路也就明显了,这里直接给出代码实现:

面试刷题:用10道题聊一聊动态规划算法 | 第99期

        在以上解决方案中,我们假设空字符串情况下的状态为有1种解码。对于位置i处单独构成数字的情况,其解码数量在 (i-1) 位置的状态上累加;对于位置i处联合上一位置构成合法数字的情况,其解码数量在 (i-2) 位置的状态上累加。

8 编辑距离 Q0072


       在编辑文本的时候,常常使用插入、删除和替换这三个操作。怎样使用最小的修改次数让当前字符串变成想要的形式,这是LeetCode Q0072中需要解决的问题。

给出两个单词word1和word2,求出将word1转换为word2所需的最小操作数。你可以对一个单词进行以下3个操作: (1) 插入一个字符, (2) 删除一个字符, (3) 替换一个字符。

面试刷题:用10道题聊一聊动态规划算法 | 第99期

最短编辑距离

        如果你能想到使用动态规划的思想来解决这个问题,那么剩下的就不太困难。为什么能够想到动态规划呢?因为当我们考虑 word1的长度为i的子串修改成word2中长度为j的子串时,只依赖于word1中的长度为 (i-1) 的子串和word 2 中长度为 (j-1) 的子串,然后选择三个操作中中的一种即可。以下就是解决方案的代码:

面试刷题:用10道题聊一聊动态规划算法 | 第99期

        在以上解决方案中,首先是对空串的处理,分别是word1为空和word2为空。只要其中一个为空,那么需要将一个转换为另一个所需的步骤数就等于非空字符串的长度。如果我们已经将word1的 (i-1) 子串都调整为 word2的 (j-1)子串,那么word1的长度为i的子串就可以通过插入、替换或者删除这其中的一个操作修改成word2的长度为j的子串。

9 最大矩形 Q0085


        除了以上关于字符串的匹配和修改问题可以使用动态规划,还有一种问题也可以,那就是LeetCode Q0085:

给定一个填充有0和1的2D二进制矩阵,找到仅包含1的最大矩形,并返回其面积。

面试刷题:用10道题聊一聊动态规划算法 | 第99期

最大矩形

       对于这个问题,我们可以使用枚举的办法,检查所有可能的矩形框,不过时间复杂度是 O((mn)^2),这当然是不可接受的。在使用动态规划的情况下,时间复杂度可以降低为 O(mn)。

        在这个问题中,我们需要使用三个状态记录表,分别记录“行的连续1的个数”、“列的连续1的个数”以及“连续矩形块的宽和高”。然后在每次遇到“1”的时候,都需要记录最大的矩形面积。说起来可能有点抽象,但是代码实现却很简单:

面试刷题:用10道题聊一聊动态规划算法 | 第99期

          以上解决方案中,状态记录表的初始值是0,并且内部的核心逻辑只对“1”元素进行处理,也就是说每次遇到0都会导致重新累计,这是符合题意规则的。矩阵中(i, j) 位置的状态记录在状态表中的 (i+1, j+1) 处。(i, j) 位置由“1”构成的矩形可以通过连续“1”的行数和列数决定。

10 交错字符串 Q0097


         以上的问题中,动态规划的状态表都是在循环中维护的,实际使用中其实并不拘泥于此,接下来就展示一个在递归中维护状态表的问题。LeetCode Q0097 如下:

给定s1,s2,s3,找出s3是否由s1和s2的交错形成。

         这个题目的描述实在太过简单,以至于很多人看过之后都没明白到底在表达着什么,这里接着例子来描述一下。比如输入 s1="aabcc", s2="dbbca"是否可以交错构成s3="aadbbcbcac" ?答案为“是”,因为交错的方式为:aa(s1)->db(s2)->bc(s1)->bc(s2)->a(s2)->c(s1)。如果输入s1和s2不变,那么对于s3="aadbbbaccc" 是否是由s1和s2构成的交错呢?答案为“否”,前面的交错 aa(s1)->db(s2)->b(s1)这是没有问题的,但是 "ba" 这种模式是不存在的。也就是说,可能对相邻的模式字符插入另一个字符而变成不相邻的,而不能将原本的不相邻的模式字符变成相邻的,除非另一个字符串中有相同的字符。

       在这个问题中,动态规划主要负责什么逻辑呢?我们考虑枚举s1和s2构成s3的所有可能情况,这其中必定存在重复的枚举,动态规划中的状态表就是负责记录第一次枚举时的结果,之后遇到相同情况直接查询即可,从而避免重复枚举。

        枚举的过程使用递归实现比较直观,而动态规划的思路还是使用状态表来实现,这里提供的解决方案如下:

        在枚举所有可能构成s3的可能性时,使用的是深度优先搜索,也可以视为回溯法。动态规划的状态表初始化为-1,用于表示是否存储着有效状态,当值不为-1时,说明该情况已经枚举过,可以直接查询。

         以上这10道题目中,既有简单的也有复杂的,很适合对动态规划的算法思想不太熟悉的朋友练习使用。除此之外,还有几个典型问题没有列出,包括换零钱问题、丑数问题等等,但在我们的leetcode代码库中有收录,欢迎感兴趣的朋友前往代码库中查看更多问题的解法。这里需要说明的是,在面对具体问题的时候,动态规划很可能不是最好的解法,因为一般都需要一个比较大的辅助空间。在时间复杂度上也很可能不是最优。但对于很多问题而言,在你没有思路的时候,动态规划很可能是你唯一能想到的方法。

以上是关于面试刷题:用10道题聊一聊动态规划算法 | 第99期的主要内容,如果未能解决你的问题,请参考以下文章

经典面试题:聊一聊垃圾回收算法

那些经典算法:动态规划

动态规划太难?刷题无数,不如掌握这些套路

面试干货10——聊一聊Redis的应用吧!(实现分布式锁缓存抽奖热搜点赞商品筛选..)

面试干货10——聊一聊Redis的应用吧!(实现分布式锁缓存抽奖热搜点赞商品筛选..)

《算法零基础100例》(第99例) 动态规划 - 路径DP