techflow

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


今天是LeetCode专题的第45篇文章,我们一起来看看LeetCode的76题,最小窗口子串Minimum Window Substring。

这题的官方难度是Hard,通过了也是34.2%,4202人点赞,299人反对。从通过率以及点赞比来看,这题的质量很高,稍稍有些偏难,所以小伙伴们请做好准备,这是一道有点挑战的问题。

题意和样例

我们一起来看下题意,这题的题意很短,给定两个字符串S和T。要求设计一个复杂度为的算法,在S串当中找到一个子串,能够包含T串当中的所有字符。要求返回合法且长度最小的窗口的内容。

注意:

  • 如果不存在这样的窗口,返回“”。
  • 如果窗口存在,题目保证有且只有一个。

样例:

Input: S = "ADOBECODEBANC", T = "ABC"
Output: "BANC"

分析

我们来分析一下这个问题,从题意当中大家应该都能感受到它的难度。因为上来题目当中就限定了我们使用的算法的复杂度必须是,然而我们遍历字符串的复杂度就已经是了,也就是说我们不能引入额外的计算开销,否则一定不满足题目的要求。

可能有些同学会想到传说中在时间内判断字符串匹配的KMP算法,如果你不知道这个算法也没有关系,因为这个算法并不适用。因为我们要找的不是完全相等的子串的位置,而是找的是字符构成一样的子串,所以并不能通过引入字符串匹配算法来解决。没有学过KMP算法的同学可以松一口气了,这题当中并不会引入新的算法。

解题的套路

一般来说当我们面临一个算法问题的时候,我们常常的思考过程主要有两种。一种是适配,说白了就是把可能可以用上的算法往问题上套。根据题意先感觉一下,大概会用到什么样的算法,然后详细地推导适配的过程,看看是不是真的适用或者是有什么坑,或者是会出现什么新的问题。如果一切OK,能够推理得通,那么这个算法就是解。第二种方法是建模,也就是说从题意入手,对题意进行深入的分析,对问题进行建模和抽象,找到问题的核心,从而推导出用什么样的算法可以解决。

举个很简单的例子,一般来说我们的动态规划算法都是适配。都是我们先感觉或者是猜测出可以使用动态规划,然后再去找状态和转移,最后建立状态转移方程。而一些搜索问题一般是建模,我们先对问题进行分析,然后找出需要搜索的解的存在空间,然后设计算法去搜索和剪枝,最后找到答案。

据说一些*高手这两种方法是一起使用的,所以才可以那么快速地找到解。当然我不是*高手,所以这个也只是我的猜测。这个思考过程非常有用,特别是当我们面试的时候,遇到一个从未见过的问题,如果你什么套路也没有,头脑一片空白或者是苦思冥想不得要领是很常见的事情。当你有了套路之后,你就可以试着慢慢找到答案了。

回到这道题本身,我们刚才已经试过了,拿字符串匹配的算法网上套是不行的。在视野里似乎也没有其他的算法可以套用,所以我们换一种思路,试试看建模。

首先我们可以肯定一点,我们需要在遍历的时候找到答案,这样才可以保证算法的复杂度是。我们的目标是寻找子串,也就是说我们遍历的过程应该对应一个子串,并且我们有方法可以快速判断这个子串是否合法。这样我们才可以做到遍历的同时判断答案的可行性。进而可以想到这是一个区间维护的问题,区间维护我们经常使用的方法就是two pointers。所以我们可以试试two pointers能否适用。

实际上这道题的正解就是two pointers。

题解

我们维护了一个区间,我们需要判断区间里的字符构成,这个很容易想到可以使用dict,维护每一个字符出现的次数。在这个题目当中,我们只需要考虑覆盖的情况,也就是说字符多了并不会构成非法。所以我们可以维护一个dict,每次读入一个字符更新它,当dict当中的字符满足要求的时候,为了使得区间长度尽量短,我们可以试着移动区间的左侧,尽量缩短区间的长度。

从区间维护的角度来说,我们每次移动区间右侧一个单位,只有当区间内已经满足题意的时候才会移动左侧。通过移动左侧弹出元素来获取能够满足题意的最佳区间。

我们来看下主要的流程代码:

# 存储区间内的字符
segement = {}
for i in range(n):
    segement[s[i]] += 1
    # 当满足条件的时候移动区间左侧
    while l <= i and satisified(segment):
        # 更新最佳答案
        if i - l + 1 < ans_len:
            ans_len = i - l + 1
            beg, end = l, i + 1
        # 弹出元素
  segement[s[l]] -= 1
        l += 1

到这里还有一个小问题,就是怎么样判断这个segment是否合法呢?我们可以用一个数字matched来记录目前已经匹配上的字符的数量。当某个字符在segment当中出现的次数和T中的次数相等的时候,matched加一。当matched的数量和T中字符种类的数量相等的时候,就可以认为已经合法了。

我们把所有的逻辑串起来,就可以通过这题了。

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        from collections import Counter, defaultdict
        # 通过Counter直接获取T当中的字符构成
        counter = Counter(t)
        n, m = len(s), len(counter)
        l, beg, end = 0, 0, 0
        cur = defaultdict(int)
        matched = 0
        flag = False
        # 记录合法的字符串的长度
        ans_len = 0x3f3f3f3f
        
        for i in range(n):
            if s[i] not in counter:
                continue
                
            cur[s[i]] += 1
            # 当数量匹配上的时候,matched+1
            if cur[s[i]] == counter[s[i]]:
                matched += 1
                
            # 如果已经找到了合法的区间,尝试缩短区间的长度
            while l <= i and matched == m:
                if i - l + 1 < ans_len:
                    flag = True
                    beg, end = l, i+1
                    ans_len = i - l + 1
                    
                # 弹出左侧元素
                c = s[l]
                if c in counter:
                    cur[c] -= 1
                    if cur[c] < counter[c]:
                        matched -= 1
                        
                l += 1

        
        return "" if not flag else s[beg: end]

总结

到这里,这道题就算是解决了。很多同学可能会觉得疑惑,为什么我们用到了两重循环,但是它依然还是的算法呢?

这个是two pointers算法的常见问题,也是老生常谈的话题了。我们在分析复杂度的时候,不能只简单地看用到了几层循环,而是要抓住计算的核心。比如在这个问题当中,我们内部的while循环针对的变量是l,l这个变量对于i整体是递增的。也就是说无论外面这个循环执行多少次,里面的这个while循环一共最多累加只能执行n次。那么,当然这是一个的算法。

这题总体来说有些难度,特别是一开始的时候可能会觉得没有头绪无从下手。这个时候有一个清晰的头脑以及靠谱的思考链非常重要,希望大家都能学到这个其中思维的过程,这样以后才可以应付更多的算法问题。

如果喜欢本文,可以的话,请点个关注,给我一点鼓励,也方便获取更多文章。

本文使用 mdnice 排版

相关文章: