目录
[1. 环绕字符串中唯一的子字符串](#1. 环绕字符串中唯一的子字符串)
[1.1 题目解析](#1.1 题目解析)
[1.2 解法](#1.2 解法)
[1.3 代码实现](#1.3 代码实现)
1. 环绕字符串中唯一的子字符串
https://leetcode.cn/problems/unique-substrings-in-wraparound-string/description/
定义字符串 base 为一个 "abcdefghijklmnopqrstuvwxyz" 无限环绕的字符串,所以 base 看起来是这样的:
- "...zabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd....".
给你一个字符串 s ,请你统计并返回 s 中有多少 不同 非空子串 也在 base 中出现。
示例 1:
输入:s = "a"
输出:1
解释:字符串 s 的子字符串 "a" 在 base 中出现。
示例 2:
输入:s = "cac"
输出:2
解释:字符串 s 有两个子字符串 ("a", "c") 在 base 中出现。
示例 3:
输入:s = "zab"
输出:6
解释:字符串 s 有六个子字符串 ("z", "a", "b", "za", "ab", and "zab") 在 base 中出现。
提示:
- 1 <= s.length <= 105
- s 由小写英文字母组成
1.1 题目解析
题目本质
统计字符串中有多少不同的连续子串符合字母表循环顺序。本质是"连续性判断 + 去重统计"问题,核心在于识别符合 base 规则的子串(按字母顺序连续,z 可接 a),并统计不重复的个数。
常规解法
最直观的想法是枚举所有子串,逐个检查是否符合连续规则。例如 s = "zab",枚举 "z"、"za"、"zab"、"a"、"ab"、"b",每个子串都检查相邻字符是否连续。使用 HashSet 去重,最后返回集合大小。
问题分析
枚举所有子串需要 O(n²) 时间,每个子串检查连续性需要 O(n),总复杂度 O(n³)。对于长度 10⁵ 的字符串会超时。更关键的是,大量子串会重复存储,例如 "abc" 包含的 "a"、"ab" 会在其他位置重复计数,HashSet 虽然去重但效率低。
思路转折
要想高效 → 必须找到去重的规律 → 观察子串特征。
关键发现:如果存在长度为 k 的连续子串以字符 'c' 结尾,那么以 'c' 结尾的所有更短的连续子串(长度 1 到 k-1)都已经被包含了。例如 "abc" 以 'c' 结尾且长度 3,则包含了 "c"(长度1)和 "bc"(长度2)。因此,只需记录每个字符结尾的最长连续长度,就能自动去重。用动态规划计算每个位置的连续长度,再用哈希表记录 26 个字母各自的最大长度,求和即得答案。
1.2 解法
算法思想: 动态规划 + 按字符去重。定义 dp[i] 表示以位置 i 结尾的最长连续子串长度,如果 s[i-1] 和 s[i] 连续(满足字母顺序或 z→a),则 dp[i] = dp[i-1] + 1,否则 dp[i] = 1。用 hash[c] 记录以字符 c 结尾的最长连续长度。最终答案是所有 hash[c] 的和,因为长度为 k 的连续串包含了 k 个不同的子串。
状态转移方程:
java
dp[i] = dp[i-1] + 1 如果 s[i-1]+1 == s[i] 或 (s[i-1]=='z' && s[i]=='a')
dp[i] = 1 否则
hash[c] = max(hash[c], dp[i]) 其中 c = s[i]
i)初始化: 创建 dp[n] 数组全部填充为 1(每个字符本身是长度为 1 的连续串),创建 hash[26] 数组记录 26 个字母的最大连续长度
**ii)计算连续长度:**从 i=1 开始遍历字符串,判断 s[i] 和 s[i-1] 是否连续:
- 若 ch[i-1] + 1 == ch[i](正常字母顺序,如 a→b)
- 或 ch[i-1] == 'z' && ch[i] == 'a'(循环边界)
- 则 dp[i] = dp[i-1] + 1,表示连续长度增加
**iii)记录最大值:**遍历所有位置 i,用 hash[ch[i] - 'a'] 保存以字符 ch[i] 结尾的最长连续长度,取 Math.max(hash[ch[i]-'a'], dp[i]) 确保记录最大值
**iv)统计答案:**遍历 hash 数组(26 个字母),累加所有最大连续长度,得到不重复子串总数
易错点
- 去重逻辑理解: 为什么 hash 存最大值就能去重?因为长度为 k 的以 'c' 结尾的连续串,自动包含了长度 1 到 k 的所有以 'c' 结尾的子串(如 "abc" 包含 "c"、"bc"、"abc")。不同位置的同一字符,保留最长的即可覆盖所有情况
- **循环边界:**最后求和时应该循环 26 次(i < 26),而不是 n 次,因为 hash 数组长度固定为 26,循环 n 次会在 n > 26 时数组越界
- z→a 的判断: 别忘了处理循环边界 ch[i-1] == 'z' && ch[i] == 'a',这也算连续。注意需要用括号括起来:(ch[i-1] == 'z' && ch[i] == 'a')
- **dp 初始化:**所有 dp[i] 初始化为 1,因为单个字符本身就是长度为 1 的有效子串
- 为什么累加 hash: hash[c] = k 表示以字符 c 结尾有 k 个不同的连续子串(长度从 1 到 k),所以直接累加 hash 值就是总数
1.3 代码实现
java
class Solution {
public int findSubstringInWraproundString(String s) {
int n = s.length();
char[] ch = s.toCharArray();
// dp[i]: 以位置 i 结尾的最长连续长度
int[] dp = new int[n];
Arrays.fill(dp, 1);
// hash[c]: 以字符 c 结尾的最长连续长度
int[] hash = new int[26];
// 计算每个位置的连续长度
for (int i = 1; i < n; i++) {
if (ch[i-1] + 1 == ch[i] || (ch[i-1] == 'z' && ch[i] == 'a')) {
dp[i] = dp[i-1] + 1;
}
}
// 记录每个字符的最大连续长度
for (int i = 0; i < n; i++) {
hash[ch[i] - 'a'] = Math.max(hash[ch[i] - 'a'], dp[i]);
}
// 累加所有字符的贡献
int ret = 0;
for (int i = 0; i < 26; i++) {
ret += hash[i];
}
return ret;
}
}
复杂度分析
- **时间复杂度: O(n),**三次线性遍历:计算 dp 数组 O(n),更新 hash 数组 O(n),累加结果 O(26) = O(1)
- **空间复杂度: O(n),**dp 数组占用 O(n) 空间,hash 数组占用 O(26) = O(1) 空间