算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)

1. 写在前面

今天开始复习字符串专题, 字符串的相关题目动态规划那里也整理了一些,涉及到子序列的一些问题, 那些在这里可能就不整理了, 这里主要是查缺补漏的一些解题方法和有关字符串的其他题型,主要又梳理了三大题型,字符串的旋转与替换, 字符串的匹配,字符串的覆盖, 涉及到的解法双指针反转,KMP匹配,滑动窗口找覆盖等。在python中,旋转和替换的题目把字符串转成列表进行操作往往会使得字符串的操作变简单,因为在python里面字符串是不可变对象。下面看具体题目了。

2. 题目思路和代码梳理

2.1 字符串的反转与替换

  • Leetcode344: 反转字符串: 这个题目相对来说就比较简单了, 之前的双指针方法就是比较好的反转方式, 所以一前一后的两个指针遍历,同时交换指向的字符即可。现在养成的一个习惯就是定义变量尽量的见名知意,而首尾指针这种,如果不是写在for循环里面的,也很少用i, j这种了,而是喜欢用start和end或者left和right这种有位置感觉的指针。
    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第1张图片

  • LeetCode541: 反转字符串II:这个题python解还是比较简单的,巧用了range的控制步长,2*k跳跃式遍历,然后每一次把前k个进行反转即可。这里还要注意的就是列表与字符串之间的转换。
    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第2张图片

  • 剑指offer05: 替换空格: 这个题目,也是先把字符串转成数组,然后遍历替换空格即可,依然是数组与字符串的转换,并且发现这种题目字符串转成数组操作会简单

    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第3张图片
    这个题目如果是C++写的话,有一种空间优化的方式就是双指针,这个也是剑指offer上提倡的一种解法,就是先预先给数组扩容成填充后的大小,然后利用双指针(一个指向新长度末尾,一个指向旧长度末尾)在从后向前进行操作, 遇到空格,就加“%20”, 这种思想要会,就是从后往前遍历插入,比从前往后插入要高效,毕竟这个不用每一次移动其他元素。 但是python里面字符串是不可变的,没法这么操作,只能是转成数组遍历替换,这个的空间复杂度和上面是一样的O(n), 在python中,如果不用这种方式,而是用字符串的加法操作(res="", 遇到空格就res+="20%", 否则res += 当前字符这种), 空间复杂度是会到 O ( n 2 ) O(n^2) O(n2)级别的,原因就是python字符串不可变,每次都得重新申请空间,把原来的先复制过去。所以这也是为啥提倡python里面先把字符串转成列表操作的原因。

  • LeetCode151: 翻转字符串里面的单词: 首先,先去除首尾的多余空格,然后把字符串转成列表的形式,最后遍历列表的每个单词,进行翻转。
    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第4张图片
    这里记录一个python的知识点, s.split()操作,如果不传任何参数,默认是所有空字符作为分隔符,所以用这一个函数就能去掉中间多余的空格,如果s.split(' ')操作的话,无法解决单词间多空格问题。

    这个题用了库函数,面试的时候应该不能这么玩,那么就需要手动遍历字符串,然后跳过空格,只把单词提取出来放到list中,然后双指针交换,然后空格拼接起来即可。这里也试了一下。 倒序遍历字符串s,记录单词左右索引边界 i , j i,j i,j, 每确定一个单词的边界,则将其添加到单词列表res中,最终拼接返回。

    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第5张图片

    面试的时候,库函数的使用原则是如果本题考查的不是库函数实现的这个功能,我觉得就可以用, 比如让写个排序,那我觉得就可以用反转的库函数这种,感觉只要能让这个题目考察出相应的能力即可。 人生苦短,没有必要那么折磨自己,哈哈。

  • 剑指offer58-II: 左旋转字符串: 这个题目考察的依然是反转,旋转操作这里要记住一个技巧:局部反转+整体反转达到左旋转,整体旋转+局部反转达到右旋转,所以这里三次反转即可搞定。
    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第6张图片

2.2 字符串的匹配

  • 剑指offer19: 正则表达式匹配: 这个题首先是考虑的是否全面, 匹配的时候, 根据模式当前字符后面是不是'*'得分情况:

    1. 如果当前字符后面是’*’, 那么又分为三种情况
      • 如果当前的字符是匹配的, 那么我可以考虑用'*'前面的这个字符, 也可以不用, 如果不用的话, 那么相当于模式后移两位(跳过'*'及其前面字符), 用剩下的字符去匹配。 如果用的时候, 又有两种选择, 我可以用'*'前面的字符1次匹配,也可以多次匹配,这时候字符串往后移动一位,而模式位置往后走两位或者不变。
      • 如果当前字符不匹配, 那么就让'*'前面字符出现0次,此时字符串位置不变,模式往后移动两次。
    2. 如果不是'*', 并且当前字符能匹配,这里的匹配是相等或者模式的符号为'.'。这时候同时往后移动即可。

    开始是跟着剑指offer的思路写了个递归代码,但是有样例超时了。

    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第7张图片
    所以后面写成了动规的版本, 递归的思路类似于自顶向下(我当前没法得到当前具体结果,只能先去后面,缩小规模,再一层层的返回来)。 而动规的思路是自底向上, 是从最底层开始推导,慢慢的往高层逼近。而动规这个题,其实也不是很好想。五步神曲:

    1. 确定dp数组
      这里的dp数组是二维的,len(s)+1行,len(p)+1列,dp[i][j]表示的是s的前 i i i个字符s[:i]与p的前 j j j个字符p[:j]的匹配情况,这里为了好算,dp开始都是空字符。
    2. 动态转移方程
      这里依然是需要考虑'*'情况, 根据上面dp[i][j]的含义,如果是考虑s的前 i i i个字符是否和p的前 j j j个字符匹配, 那么需要先考虑p的第 j j j个字符p[j-1]是不是'*'
      1. 如果是'*', 那么又会分为下面三种情况:
        1. 如果我p的前 j − 2 j-2 j2个字符就能匹配s的前 i i i个字符,即dp[i][j-2]=True的时候, 这时候,我就不用p的第 j − 1 j-1 j1个字符,直接把p的第 j − 1 j-1 j1个字符以及p的第 j j j个字符('*')跳过。dp[i][j]=dp[i][j-2]
        2. 如果我p的前 j − 2 j-2 j2个字符不能匹配s的前 i − 1 i-1 i1个字符,这时候需要用上'*'前面的字符进行匹配了,如果p的第 j − 1 j-1 j1个字符能够匹配s的第 i i i个字符, 那么这时候就看是否p的前j个字符是否匹配了s的前 i − 1 i-1 i1个字符了。即dp[i][j]=dp[i-1][j]. 因为这个地方,第 j − 1 j-1 j1个字符可以使用多次。如果p的前j个字符能够匹配s的前 i − 1 i-1 i1个字符,又p的第 j − 1 j-1 j1个字符又能匹配s的第 i i i个字符,才能说当前匹配。
      2. 如果不是'*', 这时候就好办了,如果当前的字符能够匹配上,那么就看是否p前面的 j − 1 j-1 j1个字符匹配上了s的前 i − 1 i-1 i1个字符了。即dp[i][j] = dp[i-1][j-1]
    3. 确定初始化方式
      这里初始化全局的话是False, 而第0列除了dp[0][0]都是False, dp[0][0]是True,这是两者都是空的情况。而第一行注意下, 表示的是字符串是空,模式不同取值下的匹配情况,这里如果p的第j个字符为'*', 那么直接看p的前 j − 2 j-2 j2个字符与空字符串的匹配情况。因为'*'和前面的一个字符可以不用.
    4. 遍历顺序
      两层for循环正向从1开始遍历,这里一定要注意索引的对应情况,我们说的p的第 j j j个字符,其实在下标里面指的p[j-1]

    代码如下:

    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第8张图片

  • 剑指offer20: 表示数值的字符串: 这个题又是考察是否是考虑的全面,这个看的后面的第5个题解,用了一种比较好像的方式, 这里面有这么几条规则:

    1. 小数点前面不能出现小数点或者e
    2. e前面不能出现e,且必须前面跟着数,后面跟着数
    3. +号或者-号只能出现在0位置,或者e的后面
    4. 字符串里面除了整数,小数点,e,正负号,不能有其他字符

    这种方式是比较巧妙的,根据上面的这些情况进行排除不合法的,剩下的情况就是合法的。

    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第9张图片
    这种思维方式是比较重要的,当正着想问题发现可能性非常多的时候,看看倒着想会不会好一点。

2.3 又见滑动窗口

这里整理滑动窗口框架,前面一篇文章在长度最小子数组里面见识过了,那里只是简单的分析了一下,而做字符串这里的题目,已经确认是一种框架了,于是乎再统一总结,然后跟几道题目练习,滑动窗口问题非常适合解决一些子串或者子数组的问题,有时候,甚至比动态规划要简单, 动态规划我发现一般找最小长度,个数这样的还行,如果是要输出答案的那种,就不大行了,所以滑动窗口此时的威力就显示出来了,后面看题目就知道,这里先整理知识。

对于滑动窗口,还是那四个问题:①当移动right扩大窗口时, 应该怎么更新? ② 什么条件下,窗口应该暂停扩大,开始移动left缩小窗口? ③当移动left缩小窗口时, 应该怎么更新? ④我们要的结果应该在扩大窗口时更新还是在缩小窗口时更新?

后面具体分析问题的时候,就直接分析四大问,然后上模板了,那么框架长啥样子呢? 这个是我结合题解探索出来的适合自己的框架,如果用的不习惯,可以看题解里面的:

def SlidingWindow(self, s: str, t: str) -> str:  
    # 异常判断逻辑.......

    # 预处理逻辑,需要提前一些结果的时候......

    # 定义存储结果的变量.....

    # 定义滑动窗口变量 这里的格式固定:窗口形成条件标志, 窗口起始位置,窗口长度
    valid, win_start, win_len = 0, 0, 0

    # 开始在s上滑动 滑动窗口闭区间[win_start, win_end], 外层我习惯for循环,右边界在这里定义
    for win_end in range(len(s)):
    
        # 右移动窗口,带来的窗口内的一系列更新逻辑.....
        
        # debug 调试位置
        # print(win_start, win_end)
        
        # 满足窗口形成条件,接下来需要动态调整窗口了
        while window needs shrink:
            # 更新当前窗口大小,并更新最终结果逻辑.....
            
            # 尝试左移动窗口
            win_start += 1

            # 左移动窗口,带来的窗口内的一系列更新......
    	
    	# 更新当前窗口大小,并更新最终结果逻辑.....
    # 最终返回结果逻辑.......      
    return None

上面…的地方是需要我们根据具体题目加的处理代码。并且更新最后结果的逻辑我写了两个,这个是根据不同的题目选择其一,也就是如果我们想在扩大窗口的时候更新最终结果,那么就选择最下面这个地方更新,一般适用于找最大的窗口(最长序列), 而如果是想在缩小窗口的时候更新最终结果, 就选上面那个地方更新,一般适用于找最小的窗口(最小长度)

如果看起来还比较抽象,那就看题目了,体会一下威力。前面那一篇里面的长度最小子数组也拿过来了,重新规整下代码。

  • LeetCode76: 最小覆盖子串: 直接拿上面的四步分析了:

    1. 当移动right扩大窗口时, 应该怎么更新?: 这个题就是需要统计刚进入窗口内字符的个数,如果发现满足了目标字符的字符所需要个数, 计数器加1, 说明覆盖了1个字符。
    2. 什么条件下,窗口应该暂停扩大,开始移动left缩小窗口?: 这个题当窗口内的字符能够覆盖掉目标子串了,那么就暂停扩大,开始缩小。
    3. 当移动left缩小窗口时, 应该怎么更新?: 把移出去的字符的个数相应的减掉,如果不满足覆盖目标串字符的时候,计数器减1
    4. 我们要的结果应该在扩大窗口时更新还是在缩小窗口时更新?: 由于我们这里要的是能覆盖目标子串的最小长度,所以应该在缩小时更新,也就是在left左移的上面更新结果。

    这个题里面比较难判断的就是如何算是覆盖,这里学到的一个技巧字典先统计t里面各个字符的个数,然后在更新右指针移动窗口内结果变化的情况时,也拿一个字典来统计当前窗口内各个字符的个数, 当有字符个数满足t里面的字符个数时,就拿一个计数器累加,当发现这个计数器累加到了满足t里面所有字符的个数时,窗口形成。代码如下:valid表示的是当前已经覆盖了t里面的字符个数

    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第10张图片

  • LeetCode567: 字符串的排列: 这个题和上面那个基本上差不多, 上面那个是考虑覆盖问题,而这里又是从覆盖的基础上变成了连续,也就是要求不仅覆盖,还得连续。 拿上面四步分析下:

    1. 当移动right扩大窗口时, 应该怎么更新?: 这个题依然是统计新加入的字符个数,如果发现能够覆盖掉目标串的字符了,计数器加1
    2. 什么条件下,窗口应该暂停扩大,开始移动left缩小窗口?:这个题目由于要保证连续,所以当窗口内的字符个数等于了目标串的长度,就应该停止扩大了。
    3. 当移动left缩小窗口时, 应该怎么更新?: 这个和上面一样,减去相应的字符个数,同时如果发现不满足覆盖目标串的字符了,valid要加1
    4. 我们要的结果应该在扩大窗口时更新还是在缩小窗口时更新?: 由于我们这里也要找到字符串的排列结果,所以这个依然是缩小窗口的过程中进行更新。

    代码如下:
    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第11张图片

  • LeetCode438: 找到字符串中的所有字母异位词: 所以的字母异位词,其实还是找连续且覆盖的子串, 所以和上面的这个题目基本上一模一样,无非就是记录结果的时候会修改下,因为这个要计算的是起始位置了。 四步和上面其实是一模一样的,这里就不描述了,直接上代码:

    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第12张图片

  • LeetCode3: 无重复字符的最长子串:这个题目和上面的不太一样的是,找最长了,还是分析下四步:

    1. 当移动right扩大窗口时, 应该怎么更新?:这个题由于是找无重复字符的最长子串,那么当有新字符加入的时候,肯定是要统计个数判断当前窗口是不是有重复。
    2. 什么条件下,窗口应该暂停扩大,开始移动left缩小窗口?:这个就是在发现窗口中有重复元素的时候,就要停止扩大,开始缩小窗口
    3. 当移动left缩小窗口时, 应该怎么更新?:移除去的字符相应个数要减掉
    4. 我们要的结果应该在扩大窗口时更新还是在缩小窗口时更新?: 这里就和上面的都不一样了,更新最后结果的时候不能缩小窗口的时候了,因为越缩越短啊, 必须放到扩大窗口的时候,这里要长最长的无重复。扩大的时候更新结果才有可能得到最长。

    代码如下:
    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第13张图片
    字节一面的时候就考到这个题目了, 当时是要找具体的最长无重复子串,而不是只返回最大长度, 所以这里我简单的修改了代码,这个可以得到最长无重复子串。 唉,只可惜,当时没写出来。 窗口更新那个地方忘了实在是。

  • LeetCode209: 长度最小子数组: 关于这个题的分析,这里就不分析了,在前面分析过了, 只是把代码按照上面的模板规范了一下,形成统一的写法了。

    算法刷题重温(十二): 回归基础数据结构之字符串(看字符串还能玩出啥花样)_第14张图片

这几道题目下来,就见识到了滑动窗口的威力了,同样的框架,能打掉四五道中等偏上的题目。但是也有一些注意的地方,下面是刷完了这几道题目之后我对滑动窗口的感觉。

首先,滑动窗口的题目框架固定,但是细节偏多,做之前一定要想好那四大要素,比较难想的就是滑动窗口扩大的改变和收缩的条件,也就是扩大的时候要变啥数据, 扩大到啥程度不变了

冥冥之中会有这么个规律, 像子串覆盖,排列或者字母异位词,甚至加上长度最小数组的题目,扩大的时候要死盯住目标,比如扩大的时候统计字符个数看是否达到了覆盖, 扩大的时候统计窗口内的元素和看是否满足了target等, 当发现窗口内的元素有目标了,开始暂停扩大,然后开始收。收的时候就简单了,和扩大变得是对称的。 而这几个题目都是在找最短, 扩大的时候盯住目标,但目标窗口内有目标的时候暂停扩大

再看最长无重复数组这个,会发现扩大的时候统计窗口内的个数,但此时不是看是否满足了无重复这个条件,而是让它继续扩大,当发现不满足target条件了,停, 虽然也是在盯目标,但是是扩展到不能满足条件的时候,开始收,这个要体会和上面的区别哈, 上面是盯目标, 但目标刚出现就收, 并且收一步更新下最终结果。而这里盯目标,是当目标马上逃脱了的时候收左边,当目标又出现了的时候,更新下最终结果。而这两个正好对应着找最小和最大。 所以冥冥之中的规律如下:

  1. 找最短长度,最小覆盖,字符串排列等的题目,用滑动窗口的时候,是目标出现的时候,就暂停扩大, 更新最终结果,然后开始收左边,等目标没了的时候,再扩右边,反复往右走。
  2. 找最长无重复,用滑动窗口,先右边扩大,当扩大到目标没了的时候,停,收左边,等目标出现,更新最终结果。 然后再往右边扩大,反复走。

当然,这是目前的感觉,不一定普适性。

3. 小总

字符串的相关题目整理如下:

字符串的选择和替换:

  • Leetcode344: 反转字符串
  • LeetCode541: 反转字符串II
  • 剑指offer05: 替换空格
  • LeetCode151: 翻转字符串里面的单词
  • 剑指offer58: 左旋转字符串

字符串的匹配:

  • 剑指offer19: 正则表达式匹配
  • 剑指offer20: 表示数值的字符串

滑动窗口:

  • LeetCode76: 最小覆盖子串
  • LeetCode567: 字符串的排列
  • LeetCode438: 找到字符串中的所有字母异位词
  • LeetCode3: 无重复字符的最长子串  ⭐️⭐️⭐️⭐️
  • LeetCode209: 长度最小子数组

你可能感兴趣的:(算法刷题笔记,leetcode,算法刷题,字符串)