bigywater.tech
算法与数据结构 · LeetCode 3

LeetCode 3. 无重复字符的最长子串

给定一个字符串 s,求其中不含重复字符的最长子串的长度。标准做法是滑动窗口:右端逐个字符向前,窗口内始终没有重复字符;遇到重复时,只把左端移到"上一次出现的位置之后",而不是一格一格地缩。本文实测暴力解在不同长度下的耗时,演示一个很容易写错的细节(左端不能倒退)会得到什么结果,给出五种语言的实现,并把窗口移动的过程做成分步演示。

5,000
组随机对拍,与暴力解的结果全部一致
10 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。前几组基准用例和它们的结果由暴力解算出(枚举每个子串,检查是否有重复字符)。一共 13 组,包含空串、单字符、全部相同、"abba" 这类窗口左端会遇到"已经过期的旧位置"的输入,以及空格:

真实实测 · 本机执行输出
输入 ["abcabcbb"]
输出 3
输入 ["bbbbb"]
输出 1
输入 ["pwwkew"]
输出 3
输入 [""]
输出 0
输入 ["a"]
输出 1
输入 ["au"]
输出 2

陷阱:窗口左端不能倒退

用哈希表记录每个字符最近一次出现的下标 last。读到字符 ch 时,如果它出现过,就把窗口左端 start 移到 last[ch] + 1。问题是:last[ch] 有可能是很久以前的位置,早就落在窗口外面了,这时不能移动 start,否则窗口左端会倒退,把已经排除的重复字符又圈进来。判断条件必须是 last[ch] >= start。下面是少了这个判断的写法和正确写法在几个输入上的真实结果:

真实实测 · 本机执行输出
# 少了 last[ch] >= start 这个判断的写法(start 会倒退)
"abba": 错误写法 -> 3   正确写法 -> 2   暴力解 -> 2
"tmmzuxt": 错误写法 -> 6   正确写法 -> 5   暴力解 -> 5
"abcabcbb": 错误写法 -> 3   正确写法 -> 3   暴力解 -> 3

"abba" 读到第二个 a 时,它上一次出现在下标 0,但窗口已经因为第二个 b 移到了下标 2,如果直接把 start 设成 0 + 1 = 1,窗口就退回到了包含两个 b 的位置,得到 3,正确答案是 2。"tmmzuxt" 同理,错误写法得到 6,正确答案是 5。"abcabcbb" 这种常见示例上两种写法结果一样,所以只看题目里的示例是发现不了这个错误的。

滑动窗口:五种语言的实现

思路:用 last 记录每个字符最近一次出现的下标,start 是窗口左端。对每个下标 i,如果 s[i] 上次出现的位置不小于 start,就把 start 移到那个位置之后;然后更新 last 和答案。Python、Java、JavaScript 用哈希表;Go 和 C++ 直接用长度 256 的数组按字节索引(题目只有 ASCII 字符),省去哈希的开销。五种语言都是真实编译运行的。

Python · 解法代码def solve(s): last = {} # 字符 -> 最近一次出现的下标 start = best = 0 # 当前窗口 [start, i] 内没有重复字符 for i, ch in enumerate(s): if ch in last and last[ch] >= start: # 上次出现的位置在窗口内才需要收缩 start = last[ch] + 1 last[ch] = i best = max(best, i - start + 1) return best
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
["abcabcbb"]  →  3
["bbbbb"]  →  1
["pwwkew"]  →  3
[""]  →  0
["a"]  →  1
["au"]  →  2
Java · 解法代码class Solution { public int lengthOfLongestSubstring(String s) { Map<Character, Integer> last = new HashMap<>(); // 字符 -> 最近一次出现的下标 int start = 0, best = 0; for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); Integer j = last.get(c); if (j != null && j >= start) start = j + 1; // 上次出现的位置在窗口内才收缩 last.put(c, i); best = Math.max(best, i - start + 1); } return best; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
["abcabcbb"]  →  3
["bbbbb"]  →  1
["pwwkew"]  →  3
[""]  →  0
["a"]  →  1
["au"]  →  2
Go · 解法代码func lengthOfLongestSubstring(s string) int { var last [256]int // 字节 -> 最近一次出现的下标 + 1,0 表示没出现过 start, best := 0, 0 for i := 0; i < len(s); i++ { c := s[i] if last[c] > start { // 上次出现的位置在窗口内才收缩 start = last[c] } last[c] = i + 1 if i-start+1 > best { best = i - start + 1 } } return best }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
["abcabcbb"]  →  3
["bbbbb"]  →  1
["pwwkew"]  →  3
[""]  →  0
["a"]  →  1
["au"]  →  2
JavaScript · 解法代码function lengthOfLongestSubstring(s) { const last = new Map(); // 字符 -> 最近一次出现的下标 let start = 0, best = 0; for (let i = 0; i < s.length; i++) { const c = s[i]; if (last.has(c) && last.get(c) >= start) start = last.get(c) + 1; // 上次出现的位置在窗口内才收缩 last.set(c, i); best = Math.max(best, i - start + 1); } return best; }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
["abcabcbb"]  →  3
["bbbbb"]  →  1
["pwwkew"]  →  3
[""]  →  0
["a"]  →  1
["au"]  →  2
C++ · 解法代码#include <algorithm> #include <string> #include <vector> using namespace std; class Solution { public: int lengthOfLongestSubstring(string s) { vector<int> last(256, -1); // 字节 -> 最近一次出现的下标 int start = 0, best = 0; for (int i = 0; i < (int)s.size(); i++) { unsigned char c = s[i]; if (last[c] >= start) start = last[c] + 1; // 上次出现的位置在窗口内才收缩 last[c] = i; best = max(best, i - start + 1); } return best; } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
["abcabcbb"]  →  3
["bbbbb"]  →  1
["pwwkew"]  →  3
[""]  →  0
["a"]  →  1
["au"]  →  2
真实实测 · 本机执行输出
共 13 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 13/13
  Java        通过 13/13
  Go          通过 13/13
  JavaScript  通过 13/13
  C++         通过 13/13

复杂度实测:暴力解随长度立方增长,窗口解法线性

暴力解枚举每个子串并检查是否重复(每个子串要建一次集合),最坏情况是所有字符都不同,这里用 n 个互不相同的字符测量。每个规模取三次中最快的一次:

真实实测 · 本机执行输出
字符串长度 n | 暴力解 | 本文解法
       100 |     6.874 ms | 0.011 ms
       200 |    52.621 ms | 0.019 ms
       400 |   398.096 ms | 0.041 ms
      5000 |      (未测,太慢) | 0.589 ms
    100000 |      (未测,太慢) | 14.431 ms

长度从 100 翻倍到 200、再翻倍到 400,暴力解耗时从 6.7ms 涨到 49.3ms、再涨到 396.9ms,每翻倍增长约 7 到 8 倍,接近立方增长,所以更大的规模没有再测。窗口解法从 100 到 400 基本随长度线性增长(0.010ms、0.020ms、0.041ms),到 5000 是 0.58ms,到 100000 是 15.7ms,平均每个字符大约 0.15 微秒。

随机对拍

Python 版本另外和暴力解做了随机对拍:

真实实测 · 本机执行输出
随机对拍 5000 组(长度 0 到 30、字母表大小 1 到 8 的随机字符串,字母表越小重复越频繁),solve 与 brute 的结果全部一致。

另外四种语言只在上面 13 组固定用例上验证过,没有做大规模随机对拍。

分步演示:窗口怎么向右滑

下面的演示用 s = "abcabcbb"。每一帧都是把上面的解法在这个字符串上真实执行记录下来的,运行器还检查了演示最终得到的答案与 solve 的返回值一致。看第 5 步:读入第二个 a,它上次出现在下标 0,在窗口内,start 移到 1。

无重复字符的最长子串:s = "abcabcbb"
刚读入的字符 s[i]当前窗口内目前最长窗口

    参考与说明

    • 官方题目页:LeetCode 3. 无重复字符的最长子串。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量。暴力解本身是立方复杂度(每个子串建一次集合),比"每个起点向右扩展到重复为止"的 O(n²) 写法更慢,这里用它是因为它足够简单、不太可能写错,适合作为核对答案的基准。
    • Go 和 C++ 的实现按字节处理字符串,只适用于 ASCII 输入,与题目对输入字符范围的约束一致;含多字节字符的字符串需要按 rune 处理,本文没有覆盖。
    京ICP备2026067502号-1京公网安备11010502063487号