给定一个字符串 s,求其中不含重复字符的最长子串的长度。标准做法是滑动窗口:右端逐个字符向前,窗口内始终没有重复字符;遇到重复时,只把左端移到"上一次出现的位置之后",而不是一格一格地缩。本文实测暴力解在不同长度下的耗时,演示一个很容易写错的细节(左端不能倒退)会得到什么结果,给出五种语言的实现,并把窗口移动的过程做成分步演示。
完整题目见官方页面(链接在文末)。前几组基准用例和它们的结果由暴力解算出(枚举每个子串,检查是否有重复字符)。一共 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
$ 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; } }
$ 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 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; }
$ 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; } };
$ 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。