环绕字符串中唯一的子字符串 图解题解
这道题到底在问什么
- 输入
- s = "zab"
- 输出
- 6 (z, a, b, za, ab, zab 都在环上连续)
- 输入
- s = "cac"
- 输出
- 2 (只有 a 和 c 两个不同子串合法)
最优解:为什么这么做
一句话答案:LeetCode 467 环绕字符串中唯一的子字符串:dp[c] 记以字符 c 结尾的最长环绕连续段,答案是 26 个 dp[c] 之和。按结尾聚合恰好数出每个结尾的全部不同子串、天然去重,一遍扫描 O(n)、空间 O(1)。
环绕串上的合法子串,重复的怎么只算一次
base 是 26 个小写字母首尾相接的无限环绕串,abc 排下去、z 后面绕回 a。给字符串 s,问它有多少个互不相同的非空子串,正好是环上连续的一段。s = "zab" 时 z、a、b、za、ab、zab 都在环上连着,答案 6;s = "cac" 只有 a、c 合法,答案 2。数的是不同子串个数,重复只算一次。
为什么枚举所有子串再判重会爆
最直接是把 s 的所有子串列出来,逐个判断在不在环上连续,去重后留下不同的。可长度 n 的串有大约 n²/2 个子串,n 一大就爆。而且同一个合法子串会从不同起点被重复数到——s = "abab" 里两段 ab 就是同一个串,会被数两遍。改成按结尾字符给子串归堆,每个不同子串只在它的结尾那格被数一次,判重就免了。
dp[c] 为什么记「以 c 结尾的最长环绕段」
每个合法子串都落在一个结尾字符上。定义 dp[c] 为「s 中以字符 c 结尾、最长的环绕连续段有多长」,26 个字母各占一格。为什么盯最长?环绕串固定,给定结尾 c 和长度 L,往前倒推的字符全被定死,以 c 结尾、长度 L 的合法子串只有一种。于是以 c 结尾的全部不同子串,恰好是长度 1 到 dp[c] 这 dp[c] 个。
一遍扫描更新 dp,加起来为什么不重不漏
从左往右扫 s,维护 cur 表示「到当前字符为止还在生长的环绕段」有多长。当前字符 c 是不是前一个的环绕后继(在字母环上紧跟前一个的那个字母):是就 cur 加一,不是就 cur 归一从头起段。判后继用 (c − 前一个 + 26) % 26 == 1,加 26 是为了让 z 到 a 也算数(z 是 122、a 是 97,直接相减为负判不出)。每扫到 c 就用 dp[c] 和 cur 取较大值,同一结尾只留最长那次。
扫完把 dp[c] 全加起来就是答案。为什么不会重:以 c 结尾的不同子串恰好是长度 1 到 dp[c] 这 dp[c] 个,一格全数完;结尾不同的两个子串又必然不是同一个。每个不同子串都被它的结尾那格数到、且只数一次,相加不重不漏。
拿示例 s = "zab" 逐格算一遍
z 打头,前面没字符,cur = 1,dp[z] = 1。a 接 z:(97 − 122 + 26) % 26 = 1 是环绕后继,cur = 2,dp[a] = 2。b 接 a:(98 − 97) % 26 = 1 也是后继,cur = 3,dp[b] = 3。扫完 dp[z]=1、dp[a]=2、dp[b]=3,相加 1+2+3 = 6,正是这个例子的答案。
为什么一遍扫描就够,环绕取模最容易漏
一趟扫描每字符做常数次判断,时间 O(n);dp 表固定 26 格加 cur 一个变量,空间 O(1)。
三处最容易写错。一是环绕判定漏了 z 接 a:不加 26 直接取模,z 到 a 算成负数,本该连上的段被判成断开。二是 dp[c] 直接赋值而非取较大值:同一结尾出现多次,较短的一次会覆盖更长的。三是回头枚举子串判重,退回 O(n²)。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记住这套「是后继就 cur+1、否则归一,dp[当前字符]取较大值」,下面每一帧都在套它。最后把 dp 全部加起来就是答案。
- 4先把 s 平铺出来。我们从左往右扫,维护两样东西:绿色高亮的「当前连续环绕段」,以及右侧 dp 面板「以每个字母结尾的最长段」。开扫。
- 5开局看第 0 个字符 z。它前面没有字符,自己就是一段长度 1 的环绕段,cur = 1。
- 6把它登记进 dp 面板:以字符 z 结尾的最长段目前是 1。dp[z] = 1。
- 7扫到第 1 个字符 a,看它能不能接在前一个 z 后面。绿色那段是到上一个字符为止还在生长的环绕段,长度 1。
- 8这里最妙:z 明明是字母表最后一个,但在环上它的下一个绕回 a。算式 (97 − 122 + 26) % 26 = 1,说明 a 正好是 z 的环绕后继,段能接着长。
- 9a 接得上,绿色段延伸到长度 2。再看 dp[a]:之前是 0,现在 cur = 2,取较大值,dp[a] = 2。刷新了。
- 10扫到第 2 个字符 b,看它能不能接在前一个 a 后面。绿色那段是到上一个字符为止还在生长的环绕段,长度 2。
- 11b 接得上,绿色段延伸到长度 3。再看 dp[b]:之前是 0,现在 cur = 3,取较大值,dp[b] = 3。刷新了。
- 12扫到第 3 个字符 a,看它能不能接在前一个 b 后面。绿色那段是到上一个字符为止还在生长的环绕段,长度 3。
- 13注意 a 不是 b 的下一个(b 的下一个是 c)。算式结果不是 1,环绕段在这里断了。绿色段不能再往左连,cur 要归一,从第 3 个字符重新开始数。
- 14a 接不上,绿色段缩回从第 3 个重新开始,cur = 1。dp[a] 之前是 2,和现在的 1 取较大值,结果 dp[a] = 2。原来的 2 更大,保留不动,这一步没白扫。
- 15扫到第 4 个字符 b,看它能不能接在前一个 a 后面。绿色那段是到上一个字符为止还在生长的环绕段,长度 1。
- 16b 接得上,绿色段延伸到长度 2。再看 dp[b]:之前是 3,现在 cur = 2,取较大值,dp[b] = 3。原值更大,保留不动。
- 17扫到第 5 个字符 c,看它能不能接在前一个 b 后面。绿色那段是到上一个字符为止还在生长的环绕段,长度 2。
- 18c 接得上,绿色段延伸到长度 3。再看 dp[c]:之前是 0,现在 cur = 3,取较大值,dp[c] = 3。刷新了。
- 19整条字符串扫完了。dp 面板里每个字母对应「以它结尾的最长环绕段」,这些段互不相同(结尾字母不同就一定不同子串),所以直接把这些长度加起来,就是不同合法子串的总数。
- 20加上 dp[z] = 1(以 z 结尾最长 1 个段,对应 1 个不同子串),当前累计 1。
- 21加上 dp[a] = 2(以 a 结尾最长 2 个段,对应 2 个不同子串),当前累计 3。
- 22加上 dp[b] = 3(以 b 结尾最长 3 个段,对应 3 个不同子串),当前累计 6。
- 23加上 dp[c] = 3(以 c 结尾最长 3 个段,对应 3 个不同子串),当前累计 9。
- 24把 dp[z]=1、dp[a]=2、dp[b]=3、dp[c]=3 全部相加,得到 9。这就是 s = "zababc" 中在环绕串里连续出现的不同子串个数,答案 9。
⚠️ 容易写错的地方
✗ 错:直接枚举所有子串再判重
✓ 对:按「结尾字符」聚合,dp[c] 取最长
子串数量是 O(n²),且要去重;按结尾聚合天然不重复
✗ 错:环绕判定漏了 z 接 a
✓ 对:用 (c2 − c1 + 26) % 26 == 1
不加 26 取模,z(122) 到 a(97) 会算成负数判不出环绕
✗ 错:dp[c] 直接赋值而非取较大值
✓ 对:dp[c] = max(dp[c], cur)
同一个结尾字符可能出现多次,要保留最长的那次
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def findSubstringInWraproundString(self, s: str) -> int:
f = defaultdict(int)
k = 0
for i, c in enumerate(s):
if i and (ord(c) - ord(s[i - 1])) % 26 == 1:
k += 1
else:
k = 1
f[c] = max(f[c], k)
return sum(f.values())C++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int findSubstringInWraproundString(string s) {
int f[26]{};
int n = s.length();
for (int i = 0, k = 0; i < n; ++i) {
if (i && (s[i] - s[i - 1] + 26) % 26 == 1) {
++k;
} else {
k = 1;
}
f[s[i] - 'a'] = max(f[s[i] - 'a'], k);
}
return accumulate(begin(f), end(f), 0);
}
};Java
import java.util.*;
class Solution {
public int findSubstringInWraproundString(String s) {
int[] f = new int[26];
int n = s.length();
for (int i = 0, k = 0; i < n; ++i) {
if (i > 0 && (s.charAt(i) - s.charAt(i - 1) + 26) % 26 == 1) {
++k;
} else {
k = 1;
}
f[s.charAt(i) - 'a'] = Math.max(f[s.charAt(i) - 'a'], k);
}
return Arrays.stream(f).sum();
}
}复杂度
时间
O(n)
从头到尾扫一遍字符串
空间
O(1)
只用 cur 和固定 26 格的 dp 表
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 环绕字符串中唯一的子字符串 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么用 26 格 dp,而不是一个总计数变量一路加 cur?+
直接把每步的 cur 累加会重复计数。不同的环绕段可能共享后缀,比如 s = "abab",第二段 ab 产生的 a、ab 和第一段的 a、ab 是同一批子串,逐步加 cur 会把它们数两遍。按结尾字符把最长段存进 dp,每个结尾只贡献一次它名下的全部不同子串,重复自动消掉,这正是本题去重的做法。
(c − 前一个 + 26) % 26 == 1 里的 +26 到底防什么?+
防负数。字符按 ASCII 值算,普通后继比如 a 到 b 是 98 − 97 = 1,直接取模就对。但环绕处 z 到 a 是 97 − 122 = −25,负数取模的结果因语言而异、判不出后继。先加 26 把差值抬成正数(−25 + 26 = 1),再对 26 取模,z 接 a 这种绕回开头的情况才能正确算成 1。
这题和最长连续递增那类一遍扫描的题像在哪?+
骨架一样:一趟从左扫到右,维护一个 cur,满足条件就 cur 加一、否则归一。区别是本题的「条件」是环绕后继而不是数值递增,而且要多挂一层——把每个结尾字符的最长 cur 存进 dp 去重再求和。你在最长连续递增里练的那套扫描,把判断从数值递增换成环绕后继,就能直接用在这题。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 环绕字符串中唯一的子字符串 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。