LeetCode算法日记 - Day 88: 环绕字符串中唯一的子字符串
目录
1. 环绕字符串中唯一的子字符串
1.1 题目解析
1.2 解法
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 个不同的子串。
状态转移方程:
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 代码实现
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) 空间
