【运城】网:LeetCode 32,并不Hard的难题,解法超级经典,带你领略动态规划的精彩

admin 8个月前 (03-02) 科技 50 0

本文始发于个人公众号:TechFlow,原创不易,求个关注


今天给大家分享的<是>LeetCode当中的32题,这<是>一道Hard难度的题。也<是>一道经典的字符串处理问题,在接下来的文章当中,我们会详细地解读有关它的三个解法。

希望大家不要被题目上“的标记吓到”,{虽然这题标}着难度<是>Hard,但其实真的不难。我自信你们看完文章之后也一定会这么觉得。


链接

Longest Valid Parentheses

难度

Hard


描述

{给定一个只包含左右}括号的字符串,返回最长能够组成合法括号的长度

Given a string containing just the characters '(' and ')', find the length
of the longest valid (well-formed) parentheses substring.

样例 1:

Input:  "(()"
Output: 2
## Explanation: The longest valid parentheses substring is "()"

样例 2:

Input:  ")()())"
Output: 4
## Explanation: The longest valid parentheses substring is "()()"


思考


我们来分析一下题目,这题的题目很容易理解,本质上<是>一个寻找字符串最大子串的问题。合法的条件<是>括号合法,也就<是>左右括号能够对上。我们之前分析过左右括号的合法条件,其实很简单,我们只需要保证右括号出现的数量一直小于等于左括号的数量。

也就<是>说((()<是>有可能合法的,而())<是>一定不合法的。 原因很简单[,因为如果左括号的数量大于右括号,【〖那〗么】由于后续还可能会有括号出现,所以还<是>有可能构成合法的。而反之则不行,所以如果右括号的数量过多,【〖那〗么】一定不合法。


暴力


根据我们上面分析的结果,我们不难想出暴力的解法。我们可以枚举所有的字“符串的位置”,作为开头,接着找出从这个开头开始可以达成合法的最大的长度。

我们前面说“了”如果(出现的数量大于)的“时候”,后面仍然可能构成合法《的匹配》,所以我们不能结束,还需要往下继续搜索。而如果)的数量超过(那后面的就可以不用看“了”,直接退出即可。如果(的数量等于),【〖那〗么】说明可以构成匹配,我们试着更新答案。

我们用代码实现这个逻辑:

def brute_force(s):
    ans = 0
    for i in range(len(s)):
        if s[i] == '(':
            l = r = 0
            for j in range(i, len):
                if s[j] == '(':
                    l++
                else:
                    r++
                # 如果已经不可能构成答案
                if r > l:
                    break
                if r == l:
                    ans = max(ans, 2 * r)
    return ans

这段代码应该都能看懂,我们只需要判断非法和合法两种情况,如果非法则退出循环,枚举下一个起始位置。如果合法,则试着更新答案。最后,我们返回最大的结果。

这个暴力的解法当然没问题,但<是>显然复杂度不够完 美[,<还有很大提升>的空间。而且如果这题就这么个难度,【〖那〗么】也肯定算不上<是>Hard“了”。接下来要给大家介绍一种非常巧妙的方法,它不会涉及许多新的{算法}和知识点,只<是>和之前的题目一样,需要我们对问题有比较深入的理解。


寻找模式法


接下来要介绍的这个方法非常硬核,我们不需要记录太多的信息,也不会用到新奇的或者<是>高端的{算法},但<是>需要我们仔细分析问题,找到问题的“模式”。我把它称作<是>模式匹配{算法}。

其实模式匹配<是>专有名词,【我这里】只<是>借用一下。它有些像<是>正则表达式,我们写下一个模式匹配的规则,然后正则表达式引擎就可以根据我们写下的模式规则去寻找匹配的字符串。比如说我们希望找到所有的邮箱,我们约定一个模式,它接受一个3到20的字符串,中间必须要存在一个@符号,然后需要一个域名作为后缀。

我们刚才对于一个邮箱地址的描述,包括长度、内容以及必须存在的字符等等这些要求其实就<是>模式。这个概念有些抽象,但<是>并不难理解,我相信你们应该都能明白。理解“了”这个概念之后,我们来思考一个问题,在这个问题当中,最终长度最大的答案它【的模式<是>什么】?我们稍微想一下就可以想明白,不论它多长,它里面的内容<是>什么样,〖它应该<是>〗以(为开头,以)为结尾。

我们把这个答案命名为t,我们继续来思考,t前面和后面的一个符号的组合会<是>什么样的?

我们列举一下就能知道,一共只有3种情况,分别<是>(t(,)t)和)t(。(t)<是>不可能的,因为这样可以组成更长的答案,这和我们一开始的假设矛盾“了”。所以只有这三种情况。

我们来关注一下)t)和)t(这两种情况,对于这两种情况来说,我们可以肯定一点,t前面的)一定不<是>一个合法括号的结尾。答案也很简单,如果)能够构成合法的括号匹配,【〖那〗么】答案的长度显然也会增加。所以它一定<是>在一个非法的位置,既然出现在非法的位置,【〖那〗么】我们就可以忽略。换句话说,对于这两种情况而言,我们只需要遍历一次字符串,维护构成的合法括号的位置,就一定可以找到它们。

原因也很简单,在我们遍历到“了”t前面的)的位置的“时候”,由于非法,我们会将所有记录的左右括号的信息清除。所以我们一定可以顺利「地找」到t,{并且}不会受到其他符号 的干扰[。

『但<是>这』样只能包含两种情况,对于(t(的情况我们怎么处理呢?因为<是>左括号,我们无法判断它的出现<是>否会产生非法。也就<是>说我们在遍历的“时候”,无法将t前面的左括号带来的影响消除。对于这个问题其实很简单,我们只需要反向遍历即可。由于我们遍历的顺序翻转,所以(成“了”可能构成非法的符号,而)不<是>,于<是>就可以识别这一种情况“了”。

我们写出代码,真的很简单,只有两次遍历数组:

class Solution:
    def longestValidParentheses(self, s: str) -> int:
        n = len(s)
        ans = 0
        l, r = 0, 0
        # 正向遍历,寻找)t( 和 )t(两种情况
        for i in range(n):
            if s[i] == '(':
                l += 1
            else:
                r += 1
                if r > l:
                    l, r = 0, 0
                elif r == l:
                    ans = max(ans, l + r)
                    
        l, r = 0, 0
        # 反向遍历,寻找(t(这种情况
        for i in range(n-1, -1, -1):
            # 由于反向遍历,所以非法的判断条件和正向相反
            if s[i] == '(':
                l += 1
                if l > r:
                    l, r = 0, 0
                elif l == r:
                    ans = max(ans, l+r)
            else:
                r += 1
        return ans

这种方法实现非常简单,几乎毫无难度,效率也很高,<是>\(O(n)\)的{算法},但<是>需要对问题有很深的思考和理解才行。很多同学可能会苦恼,觉得这种方法太取巧“了”,自己不一定能想得到这么巧妙的方法。没有关系,我们接下来会继续介绍一种中规中矩比较容易想到的方法。


dp


接下来要介绍的<是>鼎鼎大名的dp{算法},dp<是>英文dynamic programming的缩写,翻译过来的意思<是>动态规划。它<是>一个频繁出现在各个{算法}领域以及面试当中的{算法},并且应用广泛,在许多问题上用到“了”动态规划的思路,可以说得上<是>教科书级的{算法}“了”。因此对于我们{算法}学习者来说,(它也非常的重要)。

很多初学者对于动态规划可能理解并不深入,不<是>觉得非常困难,就<是>还停留在背包问题的范畴当中。在这题当中,“我会尽可能地讲解清楚”(动态规划)的内在逻辑,以及它运行的原理,让大家真正理解这一{算法}的思路。至于动态规划{算法}具体的学习方法和一些经典例题,我们会放在之后的文章当中再详细讲解。所以如果<是>没有基础的同学,也不用担心,接下来的内容也一样能够看懂。

动态规划最朴素的思路就<是>拆分问题,将大问题拆分成小问题。但<是>和分治{算法}不同的<是>,动态规划更加关注子问题和原问题之间的逻辑联系,而分治{算法}可能更加侧重拆分。并且分治{算法}的拆分通常<是>基于数据和问题规模的,而动态规划则不然,更加侧重逻辑上的联系。除此之外,动态规划也非常注重模式的构造。

如果你看到这里一脸懵(逼),啥也没看明白,没有关系,我们用实际问题来举例就明白“了”。我们先来学一个技巧,《在动》态规划问题当中,我们最经常干的一件事情就<是>创建一个叫做dp的数组,它来记录每一个位置能够达到的最佳结果。比如在这题当中,最佳结果就<是>最长匹配【的括号串】。所以dp[i]就记录以s[i]结尾的字符串能够构成的最长《的匹配》串的长度。

【〖那〗么】,我们继续分析,假设当前处理的位置i之前的结果都已经存在“了”,我们怎么通过之前的数据获得当前的dp[i]呢?这个可以认为<是>动态规划的精髓,利用之前已经存储的结【果推算当前需要】求的值。

显然如果s[i]<是>(,没什么好说的,以i为结尾一定不能构成合法的串,【〖那〗么】dp[i]=0。也就<是>说只有s[i]<是>)的“时候”,dp[i]的值才有可能大于0。【〖那〗么】这个值会<是>多少呢?『我们继续』来推算。

显然,我们需要观察i-1的位置,如果i-1的位置<是>(,【〖那〗么】说明我们至少可以构成一个match。构成这个match之后呢?其实就要看dp[i-2]“了”。因为在一个合法的结果后面加上一个括号对显然也<是>合法的。所以如果i-1处的结果<是>(,【〖那〗么】我们可以得到dp[i] = dp[i-2] + 2。

那如果i-1的位置也<是>)呢?我们来举个例子看看就知道“了”。

s:    a       b    (    )     )
idx:  i-4    i-3   i-2  i-1   i

从上面这个例子可以看“出来”,当i-1的位置也<是>)的“时候”,我们可以知道dp[i-1]有可能不为0,【〖那〗么】很简单,我们只需要跳过dp[i-1]长度的位置就好“了”。比如上面这个例子,i-1的位置可以和i-2构成match,【〖那〗么】我们就可以跳过dp[i-1]也就<是>2个长度,去查看i-3的位置和i<是>否构成match,如果构成match,【〖那〗么】最终的答案就<是>dp[i-1] + 2 + dp[i-4]。因为dp[i-4]也有可能还有合法的串。

所以,【到这里我们就把所】有子问题之间的逻辑联系都分析清楚“了”。剩下的就很简单“了”,我们只需要根据上面的分析结果写出答案而已。

不过还有一点,由于我们一直<是>利用前面的结果来推导后面的结果,我们需要一个初始的推导基点。这个基点就<是>dp[0],显然在这个问题当中dp[0]=0。这个基点有“了”,剩下的就顺理成章“了”。

我们写出代码来看下:

class Solution:
    def longestValidParentheses(self, s: str) -> int:
        n = len(s)
        ans = 0
        dp = [0 for _ in range(n)]
        for i in range(1, n):
            if s[i] == ')':
                # 如果i-1<是>(,【〖那〗么】我们判断i-2
                if s[i-1] == '(':
                    dp[i] = 2 + (dp[i-2] if i > 1 else 0)
                # 如果i-1也<是>),我们需要继续往前判断
                # 这里要特别注意下下标, 很容易写错
                elif i - dp[i-1] > 0 and s[i - dp[i-1] - 1] == '(':
                    dp[i] = 2 + dp[i-1] + (dp[i - dp[i-1] - 2] if i - dp[i-1] - 2 >= 0 else 0)
                    
            ans = max(ans, dp[i])
        return ans

我相信上面的解释应该都能看懂,其实<是>很简单的推理。我相信即使<是>对dp【不】太熟悉的同学,『也应该都能看懂整』个的运行原理。整个过程同样<是>\(O(n)\)的计算过程,但<是>和上面的方法相比,我们额外开辟“了”数组记录每个位置的状态。这也<是>dp{算法}的特点,就<是>我们会存储几乎所有中间状态的结果,因为我们逻辑关系上的推导过程正<是>基于这些中间状态进行的。

所以这题虽然<是>Hard,但如果从dp的角度来讲,如果你能想到用dp{算法}来解决,其实距离解开真的已经不远“了”。所以不要被题目上标记的Hard吓到,真的没有【〖那〗么】难。另外,我个人也觉得这题将{算法}的魅力发挥得非常明显,尤其<是>第二种解法真的非常巧妙。希望大家都能喜欢这题。

今天的文章就<是>这些,如果觉得有所收获,‘请’顺手扫码点个关注吧,‘你们的举手之’劳对我来说很重要。

,

sunbet安卓下载

欢迎进入sunbet安卓下载!Sunbet 申博提供申博「开户」(sunbet「开户」)、SunbetAPP下载、Sunbet<客户端下载>、Sunbet代理合作等业务。

Sunbet声明:该文看法仅代表作者自己,与本平台无关。转载请注明:【运城】网:LeetCode 32,并不Hard的难题,解法超级经典,带你领略动态规划的精彩

网友评论

  • (*)

最新评论

标签列表

    文章归档

      站点信息

      • 文章总数:465
      • 页面总数:0
      • 分类总数:8
      • 标签总数:804
      • 评论总数:0
      • 浏览总数:13467