题目描述
思路解析
一句话答案: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²)。
▶ 动画逐步走查(文字版)——想跟着上方动画一帧帧对照就展开
记住这套「是后继就 cur+1、否则归一,dp[当前字符]取较大值」,下面每一帧都在套它。最后把 dp 全部加起来就是答案。
先把 s 平铺出来。我们从左往右扫,维护两样东西:绿色高亮的「当前连续环绕段」,以及右侧 dp 面板「以每个字母结尾的最长段」。开扫。
开局看第 0 个字符 z。它前面没有字符,自己就是一段长度 1 的环绕段,cur = 1。
把它登记进 dp 面板:以字符 z 结尾的最长段目前是 1。dp[z] = 1。
扫到第 1 个字符 a,看它能不能接在前一个 z 后面。绿色那段是到上一个字符为止还在生长的环绕段,长度 1。
这里最妙:z 明明是字母表最后一个,但在环上它的下一个绕回 a。算式 (97 − 122 + 26) % 26 = 1,说明 a 正好是 z 的环绕后继,段能接着长。
a 接得上,绿色段延伸到长度 2。再看 dp[a]:之前是 0,现在 cur = 2,取较大值,dp[a] = 2。刷新了。
扫到第 2 个字符 b,看它能不能接在前一个 a 后面。绿色那段是到上一个字符为止还在生长的环绕段,长度 2。
b 接得上,绿色段延伸到长度 3。再看 dp[b]:之前是 0,现在 cur = 3,取较大值,dp[b] = 3。刷新了。
扫到第 3 个字符 a,看它能不能接在前一个 b 后面。绿色那段是到上一个字符为止还在生长的环绕段,长度 3。
注意 a 不是 b 的下一个(b 的下一个是 c)。算式结果不是 1,环绕段在这里断了。绿色段不能再往左连,cur 要归一,从第 3 个字符重新开始数。
a 接不上,绿色段缩回从第 3 个重新开始,cur = 1。dp[a] 之前是 2,和现在的 1 取较大值,结果 dp[a] = 2。原来的 2 更大,保留不动,这一步没白扫。
扫到第 4 个字符 b,看它能不能接在前一个 a 后面。绿色那段是到上一个字符为止还在生长的环绕段,长度 1。
b 接得上,绿色段延伸到长度 2。再看 dp[b]:之前是 3,现在 cur = 2,取较大值,dp[b] = 3。原值更大,保留不动。
扫到第 5 个字符 c,看它能不能接在前一个 b 后面。绿色那段是到上一个字符为止还在生长的环绕段,长度 2。
c 接得上,绿色段延伸到长度 3。再看 dp[c]:之前是 0,现在 cur = 3,取较大值,dp[c] = 3。刷新了。
整条字符串扫完了。dp 面板里每个字母对应「以它结尾的最长环绕段」,这些段互不相同(结尾字母不同就一定不同子串),所以直接把这些长度加起来,就是不同合法子串的总数。
加上 dp[z] = 1(以 z 结尾最长 1 个段,对应 1 个不同子串),当前累计 1。
加上 dp[a] = 2(以 a 结尾最长 2 个段,对应 2 个不同子串),当前累计 3。
加上 dp[b] = 3(以 b 结尾最长 3 个段,对应 3 个不同子串),当前累计 6。
加上 dp[c] = 3(以 c 结尾最长 3 个段,对应 3 个不同子串),当前累计 9。
把 dp[z]=1、dp[a]=2、dp[b]=3、dp[c]=3 全部相加,得到 9。这就是 s = "zababc" 中在环绕串里连续出现的不同子串个数,答案 9。
边界先想清:单字符为 1、重复同字符仍为 1、整段连续时 dp 是 1+2+3。
面试重点:dp 表是为了去重,以及认出「一遍扫描维护状态」母题。
参考代码
from __future__ import annotationsfrom 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())复杂度
- 时间:O(n),从头到尾扫一遍字符串
- 空间:O(1),只用 cur 和固定 26 格的 dp 表
易错点
面试追问把动画讲成自己的话
追问为什么用 dp[26] 而不是一个总计数变量?
追问这题和最长连续递增序列那类扫描题像在哪?
这道题到这就讲完了。动画和文字是同一套思路——别光看,关掉页面自己默写一遍。然后顺着主线继续:
火柴拼正方形
LeetCode 473 · 中等 · 沿着 动态规划套路 继续往下推进
把这道题真正学会,再走
图解算法年卡 ¥99 /年
- ✓本题每步动画的吴师兄语音讲解(全站陆续覆盖)
- ✓小欧带 8 步通关训练——追问到不看答案也能写对、能 30 秒讲给面试官听
- ✓学习报告,记录每道题的掌握程度
76k+ GitHub Star · 吴师兄开源图解算法,几十万开发者在看的算法讲解
想成体系刷透这类套路?去图解算法专题