老马教你如何使用双指针和滑动窗口

2,680 阅读7分钟
原文链接: mp.weixin.qq.com
题目描述

Given a string, find the length of the longest substring without repeating characters.

Examples:

Given "abcabcbb", the answer is "abc" , which the length is 3.

Given "bbbbb", the answer is "b" , with the length of 1.

Given "pwwkew", the answer is "wke" , with the length of 3. Note that the answer must be a substring, "pwke"  is a subsequence and not a substring.

背景


王小二一大早拿了一个题目去找老马帮忙。因为他的大学同学在同学群里发了一道 Google 面试题,他想找老马聊聊思路。


正文


老马看了看王小二手中的题目,大概的意思是输入一个纯小写英文字母组成的字符串,输出该字符串的最长的没有重复字母的子串的长度。老马一直很满意王小二的勤奋好学,所以还是非常乐意跟王小二分享算法方面的思路。


老马先问王小二自己的想法:“小二, 这道题你自己有什么思路吗?”


王小二回答道:“思路倒是有,我想到的方案是暴力法。”


“怎么一个暴力法?具体说说吧!”


“就是用暴力方法,遍历字符串所有的位置,以这个位置往前走,直到出现重复字母就停下来。然后返回所有结果中符合要求的字符串的长度的最大值。” 王小二担心老王没有挺清楚,接着解释道:“具体地,可以用两个指针维护子串的区间,每往前走一格,需要判断当前位置的字符在区间是否重复。”


老马点点头:“你的这个暴力方法的时间复杂度是多少?”


王小二不好意思的说:“O(N^3)”


“嗯,复杂度有点高啊” 老马笑着说:“你有想到优化的办法吗?”


王小二用手托着下巴想了一会后,不好意思的说:“没有啥好办法。”


“其实我们可以用 Hash 或者 Set 来降低查询的时间复杂度” 老马说:“这是一种非常常见的空间换时间的手法。你应该知道,在数组中搜索一个元素的复杂度是 O(N), 但是在Hash或者Set 中搜索一个元素的复杂是 O(1)。”


老马指了指王小二带来的题目接着道:“结合你刚才的解法,其实我们可以把每次搜索过的字符加入 Hash/Set , 这样判断元素是否重复部分的时间复杂度可以降到 O(1), 这样整体的时间复杂度可以降低到 O(N^2)。”


王小二真是佩服老马,三言两语就能把时间复杂度降低一个数量级。


老马接着说 :“但是时间复杂度还是太高了。”


王小二点头说:“是的。如果能将复杂度降低到O(N) 就好了。”


老马说:“将这种类型的题目的复杂度降低到 O(N) 的方法还是很多的。我今天跟你讲讲两个思路吧。”


“第一个思路叫做 two pointers, 中文名叫双指针。双指针在链表问题中应用特别广泛,特别是在链表环路判断和排序等方面。”


“本题的双指针的用法是这样的:分别用i, j 表示符合条件的字符串的左边和右边。如果 s[j+1] 不在当前的子串中, 那么将s[j+1] 加入子串中,移动 j ; 如果 s[j+1] 存在于当前的子串中,那么移动 i 到 当前子串 s[j+1] 字符出现的位置。 ”


“懂了” 王小二赞叹到  “这种解法真是精妙啊,只需要扫描一次字符串,时间复杂度降低到了 O(N) 。” 


“嗯,还有一个方法,叫做滑动窗口” 老马见王小二理解了双指针的思路,就接着讲起了滑动窗口。


“滑动窗口的思路在本题上跟双指针法方法有点像,就是维护一个 [i, j ] 的区间,其他的操作跟 双指针一样。”


“当然”老马顿了顿说 :“其实也可以不一样。我们可以用一个 Queue 来表示滑动窗口,当 s[j+1] 在 Queue 中的时候,我们就弹出这些元素:队列的头部到队列中的s[i]的位置的元素。”


“这样做有哪些不好的地方?”老马突然抛出一个问题。


“我觉得用队列来表示滑动窗口有两个缺点:1,空间浪费,需要维护一个最大 N 大小的空间;2,时间复杂度也比双指针要差,因为队列每次只能移动一步,但是双指针可以跳跃移动,更灵活。”


“对”,老马欣慰的点点头,王小二学习能力还是很强的。


“还有其他解法吗?” 老马接着问道。


“已经有三种解法啦,还有其他的解法?” 他表示很吃惊。


“一定有”, 老马肯定的答道:“这道题还有一种非常精妙的解法,应该跟你分享一下。”


“啥方法” 王小二有点迫不及待了。


老马没有直接回答,而是说:“我们不是刚刚说了暴力嘛,我们的方案是不是从左往右扫描的?”


“是的”王小二肯定的答道。


“那你觉得从右往左扫描怎么样?”老马笑着说。


王小二想了想,拍了拍自己的脑袋“对啊,如果从右往左扫,会降低复杂度。”


“这样不会降低复杂度,是一种逆向思维”老马示意他继续讲下去。


王小二接着说“假设当前的位置是 i , 那么以 s[i] 结尾的符合要求的子串的最大长度有两种可能。”


“如果s[i] 与前面的子串中的字符没有重复,那么最大长度就是 i + 1”


“如果s[i] 与前面的子串中的字符有重复, 那么最大长度就是 i - 离i最近的出现字符s[i]的位置 + 1"


“对” 老马高兴的说 :“你分析的很对。你的这个暴力方案还可以在优化吗?”


“可以”, 王小二说:“你刚才分享了用Hash 的方式可以降低在一个 容器中寻找元素是否存在的时间复杂度。我觉得这个方案也可以用这个办法。”


“赞啊!的确可以这么做” 老马说:“其实这种方法叫做记忆优化,就是我们缓存部分结果来减小时间复杂度。”


“小二,要不你把我们今天的谈话总结一下吧?”老马提了一个小要求。


“好的”,王小二愉快的答应了,毕竟他觉得今天的收获还是很大的:“我明天早上给你。”


“对了这个题目还可以用 二分法和分治算法,有空你可以练练代码。” 老马给王小二布置了一个家庭作业。


总结


晚上睡前前,王小二开始做总结,他想了想跟老马的讨论,写下了如下几个重点:

  1. 灵活运用数据结构,比如用 Hash/Set 等容器能将搜索元素的复杂度降低到 O(1)

  2. 双指针 和 滑动窗口 在解决连续串的问题非常有效(连续的字符串或者链表),双指针更灵活

  3. 对比思考:从右往左 比 从左往右 往往可以得到一些新的思路;将 i 作为条件终点,比将 i 作为条件起点能让问题更简单

  4. 扫描的过程中,做记忆优化可以显著的降低时间复杂度


最佳提交

@一元硬币 的C++ 版本

附录

Java 版本



二分法




Ruby 版本


Ruby二分法


C++版本


Python版本










C版本