​LeetCode刷题实战30:串联所有单词的子串

算法的重要性,我就不多说了吧,想去大厂,就必须要经过基础知识和业务逻辑面试+算法面试。所以,为了提高大家的算法能力,这个公众号后续每天带大家做一道算法题,题目就从LeetCode上面选 !

今天和大家聊的问题叫做 串联所有单词的子串,我们先来看题面:

https://leetcode.com/problems/substring-with-concatenation-of-all-words/

You are given a string s and an array of strings words of the same length. Return all starting indices of substring(s) in s that is a concatenation of each word in words exactly once, in any order, and without any intervening characters.

You can return the answer in any order.

题意

给定一个字符串 s 和一些长度相同的单词 words。找出 s 中恰好可以由 words 中所有单词串联形成的子串的起始位置。

注意子串要与 words 中的单词完全匹配,中间不能有其他字符,但不需要考虑 words 中单词串联的顺序。

样例

示例 1:

输入:
  s = "barfoothefoobarman",
  words = ["foo","bar"]

输出:[0,9]

解释:
从索引 0 和 9 开始的子串分别是 "barfoo" 和 "foobar" 。
输出的顺序不重要, [9,0] 也是有效答案。

示例 2:

输入:
  s = "wordgoodgoodgoodbestword",
  words = ["word","good","best","word"]

输出:[]

题解

这道题的难度是Hard,老实讲的确不简单,尤其是如果在面试当中被问到,恐怕很难一下想出最佳答案。

暴力

还是老规矩,我们退而求其次,忘了最佳答案这茬,先想出简单的方法再来思考怎么优化。最简单的方法当然是暴力,我们首先遍历所有的起始位置,然后后面一个单词一个单词的匹配。如果成功匹配就记录答案,失败的话则继续搜索下一个位置。

这么做看起来没有问题,但是一些细节需要注意。比如题目当中只说单词的长度一样,并没有说单词会不会重复。显然我们应该考虑单词出现重复的情况,既然要考虑单词出现重复,那么就不能用一个set来记录单词是否出现过,而是需要统计每个单词出现的个数。其次,我们在遍历的时候,也一样,也需要统计当前匹配到的单词的数量。

这道题暴力的思路还是比较清晰的,代码也不难写:

class Solution:
    def findSubstring(self, s: str, words: List[str]) -> List[int]:
        n = len(s)
        # 单词不存在直接返回
        if len(words) == 0:
            return []
        
        ret = []
        word_cnt = len(words)
        m = len(words[0])
        words_dict = {}
        # 初始化,记录词表
        for word in words:
            words_dict[word] = words_dict.get(word, 0) + 1
        
        # 枚举开始的位置
        for i in range(n):
            cur_dict = {}
            matched = 0
            # 每次遍历一个单词
            for start in range(i, n, m):
                w = s[start: start+m]
                # 如果单词存在,并且当前匹配的数量小于目标,则进行记录
                if w in words_dict and cur_dict.get(w, 0) < words_dict[w]:
                    cur_dict[w] = cur_dict.get(w, 0) + 1
                    matched += 1
                else:
                    break
                # 所有单词已经匹配
                if matched == word_cnt:
                    ret.append(i)
                    break
        return ret

我们来分析一下这个算法的复杂度,我们在搜索的时候用到了两层循环。外层的循环遍历了所有的长度,内层的循环则是一个单词一个单词地枚举,在极端情况下依旧可以遍历完整个字符串,复杂度是nmnm。但是由于m是常数,并且极端情况下等于1,所以整个算法的最坏的时间复杂度依然是O(n²) .

这题官方卡的不严,即使是暴力的方法也可以通过。如果是在正规的算法竞赛当中,一定会卡时间,暴力的方法肯定是无法通过的。所以我们必须要进行优化。

在阐述优化方案之前,我们先来做一个仔细的分析。在这题当中,由于我们需要找到所有满足条件的答案,那么显然我们需要把所有可能的情况都遍历完。也就是说遍历是免不了的,在这题当中我们肯定不可能自己生成出答案,一定需要遍历。说白了,遍历所有情况的思路是对的,我们要做的并不是寻找新的方法,而是对它进行优化。

明白了前进的方向,就可以继续往下思考第二个问题了。究竟在暴力方法当中是哪里有问题,导致了大量消耗时间,哪里可以进行优化呢?

理一下思路不难想明白,会出现重复的情况只有两种。下面我们来列举一下,为了方便观看和理解, 我用[]表示一个单词,通过[]内的不同数字,表示不同的单词。

  1. ...[1][2][3]....[1][2][3]....,这种情况最容易想到。在一个正确答案后面一段距离之后还有另一个正确答案,由于我们每次找到正确答案就退出了,所以又需要遍历很多次才可以找到下一个答案。

  2. ....[1][2][1][2][3]....,这种情况当中,我们在找到了前面第一个错误的[1][2]之后,由于发现不对,所以退出了循环。接下来我们要遍历2m次(单词长度为m),才可以找到[1][2][3]这个答案。要是当时我们可以将错就错继续往下搜索,就可以直接找到答案了。

把上面两点综合一下,优化的方案其实已经很清楚了。就是不管是我们找到了答案还是没找到答案,遇到了问题,我们都不应该退出,我们应该继续搜索其他潜在的答案。

优化1

所以我们就得到了第一个优化,既然我们每次不论成功与否都会遍历结束,而且我们每一次遍历的时候,都会获取m长度的字符串和词库进行比较。那么我们在遍历起始位置的时候,就不用遍历n的长度了,而只需要遍历m个长度。

举个例子,比如说s='abcgoodgoodgirl',词库是['good', 'girl']。

我们第一次遍历a,可以获得这些单词:abcg, oodg, oodg, irl

第二次遍历遍历b,得到的单词是:a, bcgo, odgo, odgi, rl

第三次遍历c,单词是:ab, cgoo, dgoo, dgir, l

最后是遍历g,单词是:abc, good, good, girl

这样我们只需要遍历4次,就可以获取所有的单词组合。也就是说我们先获取所有的单词组合之后,再从这些组合当中寻找答案。所以我们将最外层的循环次数从n降到了m。

优化2

依然参考上面的例子,我们可以发现在上面4次遍历当中,只有最后一次能找到答案。我们单独来看这次的遍历内容:abc, good, good, girl。由于词库是['good', 'girl'],我们在遍历这个单词组合的时候,会遇到两个good,这和我们的逾期不符。按照正常的思路来看,我们应该跳过,然后将记录的答案清空,从下一个单词处开始遍历。

这当然是可以的,但是实际上,这个问题有更好的解法。如果对two pointers算法熟悉的同学,会发现这是一个经典的two pointers算法的应用场景。我们要找的是一个若干个连续的单词组成的区间,那么我们可以用两个指针维护这个区间。当我们右侧读入一个额外的单词导致数量超界的时候,应该怎么办?很简单,我们可以移动左侧边界,弹出掉一些单词,直到数量满足要求。

我们把上面的思路整理一下,就可以写出代码了:

class Solution:
    def findSubstring(self, s: str, words: List[str]) -> List[int]:
        n = len(s)
        if len(words) == 0:
            return []
        
        # 初始化的部分和之前一样
        ret = []
        word_cnt = len(words)
        m = len(words[0])
        words_dict = {}
        for word in words:
            words_dict[word] = words_dict.get(word, 0) + 1
        
        # 只遍历[0, m)
        for i in range(m):
            cur_dict = {}
            # l和r表示当前的区间两侧端点
            l = i
            matched = 0
            for r in range(i, n, m):
                # 获取当前的单词
                word = s[r: r+m]
                # 如果单词不在词库当中,清空之前的数据
                if word not in words_dict:
                    # l赋值成下一个开始的r
                    l = r + m
                    # 所有匹配记录清空
                    matched = 0
                    cur_dict = {}
                    continue
                # 记录单词
                cur_dict[word] = cur_dict.get(word, 0) + 1
                matched += 1
                # 如果数量超界的话,就弹出左侧
                while cur_dict[word] > words_dict[word]:
                    w = s[l: l+m]
                    cur_dict[w] -= 1
                    matched -= 1
                    l += m
                # 如果匹配数量一致,则记录答案,也就是l的位置
                if matched == word_cnt:
                    ret.append(l)
        return ret

代码不长,但是里面的细节还是不少的,关于边界的处理以及一些运算的逻辑,真正想要一口气写正确还是很有挑战的。感兴趣的同学可以试试看,在不参考我代码的情况下,能不能一次写通过。

这道题给我最大的感受是从表面上看,它似乎是一道字符串匹配的问题。会引导我们往各种字符串匹配的算法上去思考,但其实它是一个遍历优化的问题。这道题在LeetCode当中评分不高,很多人给了差评,也许就是因为许多人被出题人骗了吧。但是我觉得它很有意思,也很锻炼人,不是那种无脑折磨人的题。毕竟在算法竞赛当中出题人”欺骗“选手是常有的事,这也是算法的魅力之一。

觉得有所收获,请顺手点个在看或者转发吧,你们的举手之劳对我来说很重要。

上期推文:

LeetCode1-20题汇总,速度收藏!

LeetCode刷题实战21:合并两个有序链表

LeetCode刷题实战23:合并K个升序链表

LeetCode刷题实战24:两两交换链表中的节点

LeetCode刷题实战25:K 个一组翻转链表

LeetCode刷题实战26:删除排序数组中的重复项

LeetCode刷题实战27:移除元素

LeetCode刷题实战28:实现 strStr()

LeetCode刷题实战29:两数相除

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值