大家好,今天跟大家聊个很实在的Python算法题——无重复字符的最长子串。
最近有朋友问我这道题,说看代码能看懂大概,但逐行扣细节就懵了:字典里的数值怎么来的?左边界为什么要用max?变量名为什么这么起?
其实我刚开始学这道题的时候,也卡了好久,总觉得滑动窗口和哈希表的组合很绕,尤其是那几行核心代码,反复看了好几遍才吃透。今天就以聊天的方式,把这道题从头到尾拆解开,每行代码、每个逻辑、每个坑点,都讲得明明白白,新手也能轻松跟上。
先说说题目:无重复字符的最长子串
题目很简单:给定一个字符串s,找出其中无重复字符的最长子串的长度。
举个例子,比如s="abcabcbb",最长无重复子串就是"abc",长度是3;如果s="bbbbb",最长的就是单个"b",长度1;s="pwwkew",最长的是"wke"或者"kew",长度3。
这里有个小误区,大家要注意:子串必须是连续的,比如"abcabcbb"里,"abc"是子串,但"acb"就不是,因为它不连续。
先讲个小故事,理解核心思路
其实这道题的核心思路,就像我们去超市排队结账。
假设排队的人都是字符串里的字符,每个人胸前都挂着自己的“名字”(比如a、b、c)。我们要找的“无重复子串”,就相当于一队里没有重名的人,而且这队人必须是连续排队的。
一开始,队伍里没人(对应空字典),我们从第一个人开始排队(j从0开始),每来一个人,就记一下他的位置(用字典存字符和对应的索引)。如果来了一个重名的人,说明队伍里已经有他了,这时候就要把队伍的最左边,移到上一个重名人的后面(更新左边界i),保证队伍里没有重名。
每来一个人,我们都看看当前队伍的长度(j-i),记一下最长的队伍长度(res),到最后,最长的队伍长度就是我们要的答案。
这个“排队”的思路,就是滑动窗口+哈希表,既能保证效率,又能轻松找到最长无重复子串。
完整代码先奉上,一目了然
classSolution:deflengthOfLongestSubstring(self, s: str) -> int: dic, res, i = {}, 0, -1for j in range(len(s)):if s[j] in dic: i = max(dic[s[j]], i) dic[s[j]] = j res = max(res, j - i)return res
逐行拆解代码,每一步都不跳步
接下来,我们就一行一行看,每一行代码是什么意思,为什么要这么写,有没有其他写法,还有哪些坑要避。
第1-2行:类和方法定义
classSolution:deflengthOfLongestSubstring(self, s: str) -> int:
这两行是LeetCode的固定模板,就像我们写作文要先写标题一样,平台要求算法题都放在Solution类里,不能改。
拆解一下:
class Solution: 定义一个叫Solution的类,没什么复杂的,就是平台规定。
def lengthOfLongestSubstring(self, s: str) -> int: 定义一个实例方法,这是题目要求的固定方法名,不能改。
再细说几个关键点:
def:Python里定义函数/方法的关键字,只要写函数,就必须先写def。
self:实例方法的第一个参数,代表这个类的实例本身,必须写(这是Python的约定俗成),调用的时候不用传这个参数。
s: str:参数s的类型注解,意思是s是字符串类型,Python不强制要求写,但写了之后,别人看代码就知道s应该传什么类型,更清晰。
-> int:返回值类型注解,意思是这个方法会返回一个整数,也就是我们要找的“最长无重复子串的长度”。
如果是自己本地调试,不用这么麻烦,直接写函数就行,不用套类:
deflengthOfLongestSubstring(s: str) -> int:# 核心逻辑放在这里
第3行:变量初始化
dic, res, i = {}, 0, -1
这行是多变量赋值,Python里很常用,等价于分开写三行:
dic = {}res = 0i = -1
读作:把空字典赋值给dic,把0赋值给res,把-1赋值给i。
这三个变量的作用,以及为什么初始值是这样,我们一个个说:
dic:是dictionary(字典)的缩写,也就是我们说的哈希表,作用是记录每个字符“最后一次出现的索引”。初始值是空字典{},因为一开始还没遍历任何字符,没有任何记录。
res:是result(结果)的缩写,用来记录我们找到的“最长无重复子串长度”。初始值是0,因为如果字符串是空的(s=""),直接返回0,这样更安全,不会出错。
i:是滑动窗口的左边界,窗口的范围是(i, j](不包含i,包含j)。初始值是-1,这里很关键,举个例子:当j=0(第一个字符)时,j - i = 0 - (-1) = 1,刚好是这个字符的长度,要是初始值设为0,j-i就成了0,少算了1,结果就错了。
坑点提醒:i的初始值一定不能设为0,否则会少算长度;dic必须是空字典,不能提前放任何值,不然会干扰判断。
第4行:循环遍历字符串
for j in range(len(s)):
这行是for循环,作用是遍历整个字符串,j就是滑动窗口的右边界,每次循环,j就往右移动一位。
拆解一下:
len(s):Python的内置函数,返回字符串s的长度,比如s="abc",len(s)=3。
range(len(s)):生成一个从0到len(s)-1的整数序列,比如len(s)=3,就生成0、1、2,这样j就会依次取0、1、2,对应字符串的每个字符的索引。
读作:j从0到len(s)-1依次遍历,每次循环执行下面缩进的代码块。
为什么不用直接遍历字符(for char in s)?因为我们需要字符的索引来更新窗口和字典,只遍历字符的话,拿不到索引,所以用range(len(s))配合s[j]来取字符。
还有一种更简洁的写法,用enumerate,能同时拿到索引j和字符char:
for j, char in enumerate(s):# 这里char就等于s[j],不用再写s[j],更简洁
比如把核心代码改成这样,逻辑完全一样,只是更Pythonic:
for j, char in enumerate(s):if char in dic: i = max(dic[char], i) dic[char] = j res = max(res, j - i)
第5-6行:处理重复字符,更新左边界
if s[j] in dic: i = max(dic[s[j]], i)
这两行是核心,也是最容易懵的地方,我们分开说,结合之前的“排队”故事来理解。
先看第5行:if s[j] in dic:
语法:in运算符,判断s[j](当前字符)是不是字典dic的键,也就是判断这个字符之前有没有出现过(有没有在“队伍”里)。
逻辑:如果当前字符已经在字典里了,说明队伍里有重名的人,需要移动左边界i,把重名的人“请出”队伍,保证队伍里没有重复。
再看第6行:i = max(dic[s[j]], i)
读作:把dic[s[j]]和i中较大的那个值,赋值给i。
这里重点说:为什么要用max?不能直接写i = dic[s[j]]吗?
dic[s[j]]是当前字符上一次出现的索引(上一次这个“人”排队的位置)。如果直接写i = dic[s[j]],会出现一个问题:左边界i可能会往左退,导致队伍里又出现重复字符。
第7行:更新字典中当前字符的索引
dic[s[j]] = j
读作:把字典dic中,键s[j]对应的值,赋值为j。
逻辑:不管当前字符之前有没有出现过,都要把它的“最新位置”(当前j)记录下来,这样下次再遇到这个字符时,就能拿到它最后一次出现的位置,避免出错。
比如j=3(字符a)时,之前dic[a]=0,执行这行后,dic[a]就变成了3,下次再遇到a,就会用3这个最新索引来更新i,而不是之前的0。
这里要注意:这行代码是“更新”,不是“新增”——如果字符第一次出现,就是新增键值对;如果已经出现过,就是覆盖原来的值。
比如j=0(a),dic是空的,执行后dic变成{'a':0}(新增);j=3(a),执行后dic变成{'a':3}(覆盖)。第8行:更新最长无重复子串长度
res = max(res, j - i)
读作:把res和j-i中较大的那个值,赋值给res。
逻辑:j-i是当前窗口(i,j]的长度(因为i不包含,j包含,长度就是j-i)。每次循环,我们都对比当前窗口的长度和之前记录的最长长度(res),把更长的那个存到res里,这样到最后,res就是最长无重复子串的长度。
比如j=2(字符c)时,i=-1,j-i=3,res从0更新为3;j=3(字符a)时,i=0,j-i=3,res还是3,因为没有更长的窗口。
第9行:返回结果
return res
读作:函数返回res的值,也就是我们要找的最长无重复子串的长度。
语法:return是Python里返回函数结果的关键字,后面跟要返回的变量或值,执行到return,函数就结束了。
完整流程演示,再巩固一遍
我们用s="abcabcbb",跟着循环走一遍,看看每个变量的变化,彻底吃透逻辑:最后res=3,和题目答案一致,整个流程就走通了。
其他写法对比,按需选择
除了上面的核心写法,还有两种常用写法,逻辑差不多,只是实现方式不同,大家可以根据自己的习惯选择。
写法1:用enumerate简化循环(最推荐)
classSolution:deflengthOfLongestSubstring(self, s: str) -> int: dic, res, i = {}, 0, -1for j, char in enumerate(s):if char in dic: i = max(dic[char], i) dic[char] = j res = max(res, j - i)return res
优点:不用反复写s[j],代码更简洁,更符合Python的写法习惯。
写法2:用集合实现滑动窗口
classSolution:deflengthOfLongestSubstring(self, s: str) -> int: char_set = set() left = 0 max_len = 0for right in range(len(s)):while s[right] in char_set: char_set.remove(s[left]) left += 1 char_set.add(s[right]) max_len = max(max_len, right - left + 1)return max_len
逻辑:用集合存窗口内的字符,遇到重复字符时,左指针一直右移,直到集合里没有重复字符,再把当前字符加入集合。
对比:集合写法最坏情况(比如全重复字符串)效率略低,左指针可能要移动多次;哈希表写法是一次遍历,效率更高。
常见坑点提醒,避坑不踩雷
这道题不难,但很容易踩坑,我整理了5个最常见的坑,大家一定要注意:
i的初始值必须是-1,不能是0,否则第一次计算长度会少1;
更新i时,必须用max(dic[s[j]], i),否则左边界会往左退,出现重复字符;
每次循环都要更新dic[s[j]] = j,否则下次遇到重复字符,拿到的是旧索引;
空字符串处理:s=""时,循环不执行,直接返回res=0,正确;
单字符字符串:s="a"时,循环执行一次,j=0,i=-1,j-i=1,返回1,正确。
最后说几句
其实这道题的核心,就是理解“滑动窗口”和“哈希表”的配合——滑动窗口用来维护无重复的子串,哈希表用来快速记录字符的位置,两者结合,就能高效找到最长无重复子串。
刚开始学的时候,卡壳很正常,我也是反复看了好几遍,手动走了好几遍流程,才彻底吃透。大家可以多手动走一遍s="abba"或s="abcabcbb"的流程,看看每个变量的变化,慢慢就懂了。如果觉得这篇讲解对你有帮助,麻烦点个赞、在看,也可以转发给身边正在学Python的朋友,一起进步~另外,要是还有哪行代码、哪个逻辑没懂,评论区留言,我再给你拆解开讲~