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 的方式可以降低在一个 容器中寻找元素是否存在的时间复杂度。我觉得这个方案也可以用这个办法。”
“赞啊!的确可以这么做” 老马说:“其实这种方法叫做记忆优化,就是我们缓存部分结果来减小时间复杂度。”
“小二,要不你把我们今天的谈话总结一下吧?”老马提了一个小要求。
“好的”,王小二愉快的答应了,毕竟他觉得今天的收获还是很大的:“我明天早上给你。”
“对了这个题目还可以用 二分法和分治算法,有空你可以练练代码。” 老马给王小二布置了一个家庭作业。
晚上睡前前,王小二开始做总结,他想了想跟老马的讨论,写下了如下几个重点:
-
灵活运用数据结构,比如用 Hash/Set 等容器能将搜索元素的复杂度降低到 O(1)
-
双指针 和 滑动窗口 在解决连续串的问题非常有效(连续的字符串或者链表),双指针更灵活
-
对比思考:从右往左 比 从左往右 往往可以得到一些新的思路;将 i 作为条件终点,比将 i 作为条件起点能让问题更简单
-
扫描的过程中,做记忆优化可以显著的降低时间复杂度
@一元硬币 的C++ 版本
附录Java 版本
二分法
Ruby 版本
Ruby二分法
C++版本
Python版本
C版本