这道题我在本地跑了三遍才真正看懂。标题里那个uniqueLetterString看起来只是返回一个整数,但第一次动手时我用的是 O(n^3) 暴力枚举,结果连样例都跑得吃力。后来我尝试把问题丢给 DeepSeek,让它和我一起拆LeetCode 828,才算把“统计子串中唯一的字符”这个题目彻底吃透。今天这篇就围绕这道题,讲讲题意背后的坑、贡献法为什么是正解、代码怎么写,以及我拿 AI 辅助刷题时总结出的一套提问方法。
如果你也卡在“为什么直接枚举子串会超时”“别人代码里那两个数组到底在干嘛”,那这篇就是为你准备的。新人能看懂每一步推导,刷题老手也能从我踩过的边界问题里捞到点东西。
1. 题意拆解:先别急着写代码,把“唯一字符”四个字读透
1.1 从 countUniqueChars 的定义说起
LeetCode 828的核心是给定一个字符串s,要求所有非空子串的countUniqueChars之和。函数名虽然叫uniqueLetterString,但真正的难点在countUniqueChars(t)的定义上:它统计的是子串t中出现次数恰好为 1 的字符个数,不是“有多少种不同的字符”。
很多人的第一反应是算“不同字符数”,这俩概念在绝大多数例子里结果一样,但一旦出现重复字符就立刻分叉。我举个例子:子串"ABA",按“不同字符数”算,结果是 2,因为 A 和 B 两种字符都出现过;按“出现恰好一次”算,结果只有 1,因为 A 出现了两次,不贡献,B 出现了一次,贡献 1。
别小看这个区别,官方样例里s = "ABA"的答案是 8。如果你按“不同字符数”去写,算出来会是 10 或者别的数,直接错。所以做题第一步不是开编辑器,而是把countUniqueChars的定义写在草稿纸最上面,提醒自己:只有孤零零出现一次的那个字符,才值得被统计。
1.2 手推一个例子,把暴力法的成本看清楚
拿题目里经典的s = "ABA"来手动枚举所有子串,感受一下过程:
| 子串 | countUniqueChars(恰好出现一次) | 结果 |
|---|---|---|
| "A" | A 出现一次 | 1 |
| "B" | B 出现一次 | 1 |
| "A" | A 出现一次 | 1 |
| "AB" | A 一次,B 一次 | 2 |
| "BA" | B 一次,A 一次 | 2 |
| "ABA" | A 两次不贡献,B 一次 | 1 |
合计是 1+1+1+2+2+1 = 8。这个过程太直观了,所以很多人第一版代码就是三层循环:枚举左边界、枚举右边界、统计当前子串里每个字符的频率。我第一版也是这么写的,顺手把s换成"LEETCODE",本地跑完样例输出 92,感觉稳了。
但问题来了:如果n是 10^5,三层循环就是 10^15 次操作,神仙也救不回来。更麻烦的是,每次统计频率还要额外遍历一遍子串,实际成本比理论还高。所以必须在“枚举子串”这个思路上彻底转弯,不然永远是 O(n^3) 的命。
2. 核心思路:贡献法,把“枚举子串”变成“枚举字符”
2.1 为什么每个出现位置可以独立算贡献
暴力法之所以慢,是因为它在枚举“子串”这个整体,而countUniqueChars本质上是把每个字符的贡献加在一起。那么换个角度:直接问每个字符的每一次出现,它能在多少个合法的子串里当上“唯一一次出现”的那个字符?
一个字符在某个子串里算作“唯一字符”,需要同时满足两个条件:
- 这个子串必须包含当前这个出现位置;
- 这个子串内不能再出现和当前字符相同的其他字符。
也就是说,如果我在s里固定某个位置i上的字符c,那么真正能决定它贡献多少的,是c在i左边最近一次出现的位置prev[i],以及右边最近一次出现的位置next[i]。
再直白一点:如果某次出现位置左右都被相同字符夹住了,那它无论如何都没法成为“唯一”。只有当子串的左边界落在prev[i] + 1到i之间,右边界落在i到next[i] - 1之间,这个出现位置才会在子串里只出现一次。这样的左边界有i - prev[i]种选法,右边界有next[i] - i种选法,因为左右边界选择互相独立,所以贡献就是两者的乘积。
2.2 左右边界区间就是子串选择范围
用"ABA"来验证一下这个公式。三个位置分别是 0(A)、1(B)、2(A)。
- 位置 0 的 A:左边没有 A,记
prev = -1;右边最近的 A 在位置 2,记next = 2。贡献是(0 - (-1)) * (2 - 0) = 1 * 2 = 2。解释:左边界只能选 0,右边界可以选 0 或 1,对应子串"A"和"AB",这两种情况下位置 0 的 A 都是唯一的。 - 位置 1 的 B:左边没有 B,
prev = -1;右边没有 B,next = 3。贡献是(1 - (-1)) * (3 - 1) = 2 * 2 = 4。对应左边界可选 0、1,右边界可选 1、2,组合出四个子串:"AB"、"B"、"ABA"、"BA",B 在里面全是唯一的。 - 位置 2 的 A:左边最近的 A 在 0,
prev = 0;右边没有 A,next = 3。贡献是(2 - 0) * (3 - 2) = 2 * 1 = 2。对应子串"A"和"BA"。
三个位置贡献加起来:2+4+2 = 8,和手动枚举完全一致。这就是贡献法的漂亮之处:我们不再关心子串长什么样,只关心每个字符出现位置在左右同字符之间的“自由活动范围”。
这个思路在字符串题里太常见了,凡是“统计子串中某种性质之和”的题,优先考虑能不能拆成每个位置/每个元素的独立贡献。一旦能拆,O(n^2) 变 O(n) 就是顺手的事。
3. 代码实现:两种我实测好用的写法
3.1 用 pre 和 next 数组一次遍历预处理
既然要快速拿到每个位置左右最近的同字符位置,最朴素的方法就是预处理两个数组prev和next。我先把代码贴出来,再逐行解释。
class Solution { public int uniqueLetterString(String s) { int n = s.length(); int[] prev = new int[n]; int[] next = new int[n]; int[] last = new int[26]; // 预处理 prev Arrays.fill(last, -1); for (int i = 0; i < n; i++) { int c = s.charAt(i) - 'A'; prev[i] = last[c]; last[c] = i; } // 预处理 next Arrays.fill(last, n); for (int i = n - 1; i >= 0; i--) { int c = s.charAt(i) - 'A'; next[i] = last[c]; last[c] = i; } // 累加每个位置的贡献 int ans = 0; for (int i = 0; i < n; i++) { ans += (i - prev[i]) * (next[i] - i); } return ans; } }先说prev。我用last数组记录每个字符最近一次出现的位置,初始都设为 -1,表示“还没出现过”。从左往右扫,每到一个位置i,先把last[当前字符]存进prev[i],再把last更新成i。这样prev[i]就是左边最近同样字符的下标,不存在就是 -1。
next就是反过来。从右往左扫,last初始化为 n,表示“右边不存在”。每到一个位置i,把last[当前字符]存进next[i],再更新last。这里 n 可以理解为“虚拟的右边越界位置”,因为这个位置不属于字符串,但右边界可以选到 n-1,所以用 n 参与计算时,next[i] - i依然能正确表示可选的右边界数量。
最后就是累加(i - prev[i]) * (next[i] - i)。这个表达式不需要判断字符是否重复,因为即使当前位置只有一个同字符,公式也会把它自动处理正确。
注意:原题字符串限定只有大写英文字母,所以
last数组大小开 26。如果面试官把范围扩大到 ASCII 可打印字符,直接把 26 改成 128 或者用 HashMap 就完事,别在这上面栽跟头。
3.2 用出现位置列表的写法,代码更短
prev/next数组的思路非常直白,但还有另一种我更喜欢的方式:把每个字符的所有出现位置存成列表,然后对每个字符的相邻出现位置做区间乘法。
class Solution { public int uniqueLetterString(String s) { int n = s.length(); List<Integer>[] pos = new List[26]; for (int i = 0; i < 26; i++) { pos[i] = new ArrayList<>(); } for (int i = 0; i < n; i++) { pos[s.charAt(i) - 'A'].add(i); } int ans = 0; for (List<Integer> list : pos) { for (int j = 0; j < list.size(); j++) { int p = list.get(j); int left = (j == 0) ? -1 : list.get(j - 1); int right = (j == list.size() - 1) ? n : list.get(j + 1); ans += (p - left) * (right - p); } } return ans; } }这个写法的核心是位置列表。比如s = "ABCA",字符 A 的位置列表是[0, 3]。列表里第一个元素 0 的左边用 -1,右边用下一个元素 3;第二个元素 3 的左边用前一个元素 0,右边用 n。这样每个元素的贡献都能单独算,逻辑比prev/next数组更集中。
两种写法复杂度一模一样,都是 O(n) 时间和 O(n) 空间。区别在于prev/next适合现场手撕、容易解释;位置列表适合看完思路后快速写对,少维护两个数组。我个人更喜欢第二种,因为一旦遇到变体题,位置列表往往更容易改。
3.3 复杂度与返回值细节
时间复杂度上,无论是预处理还是最终累加,都只用了一层循环,所以是 O(n)。空间复杂度主要看数组长度,prev、next各 O(n),last是常数,整体 O(n)。
到这一步你已经能 AC 了。但有两点需要额外留心:
- 答案要不要取模?我搜过讨论区,原题的测试数据保证答案在 32 位整型范围内,因此示例代码直接返回 int 没问题。但如果你在变体题里看到
n很大,就要立刻考虑用long做中间结果,最后再按题要求取模。别等溢出才发现。 s为空怎么办?如果输入是空字符串,循环不会执行,答案就是 0。这不是边界陷阱,但很多人在实现位置列表时,如果pos里某些列表为空,要确保第二层循环不会误操作空列表。上面代码里for (List<Integer> list : pos)空列表自然跳过,没问题。
4. 用 DeepSeek 辅助解题:提问方式决定效率
4.1 我第一次提问就碰壁
标题里带着 DeepSeek,我也说点实际使用感受。第一次我直接把代码丢给它,问“哪里错了”,它回复得像模像样,从“你可能没定义 countUniqueChars”一直分析到“建议用滑动窗口”。问题是它给的滑动窗口解法在这里并不适用,因为countUniqueChars不是一个区间可加的简单属性,滑动窗口统计不了所有子串。
后来我才意识到,AI 不是不行,是提问太笼统。它面对“帮我看看哪里错了”这种问题时,只能猜你的上下文,于是给出通用但不够精准的建议。刷题时想要让 AI 真正帮上忙,必须把问题拆到具体细节上。
4.2 高效的追问模板与验证方法
我后来总结了一套提问模板,实测下来效率高很多:
- 先让它解释定义:“
countUniqueChars(t)在 LeetCode 828 里到底是什么意思?请用ABA举例。”这一步能快速确认 AI 理解是否和你一致。如果它连“恰好出现一次”都没说,基本可以断定后面回答不靠谱。 - 再问思路方向:“不要给代码,先告诉我暴力枚举的复杂度瓶颈,然后给一个能优化到 O(n) 的切入点。”这样能把话题强制拉回贡献法,而不是让 AI 自由发挥。
- 最后验证边界:“我的实现里
prev和next初始值分别用了 -1 和 n,为什么这样设?如果字符串为空会怎样?”让它带着你过一遍边界,比自己瞎猜快得多。
我把这些提问整理成一段话丢给 DeepSeek,它给出的解释就立刻清晰了。所以工具没问题,问题在于提问人有没有把“我想解决什么”和“我现在卡在哪一步”讲清楚。AI 更像一个知识面很广的陪练,你得先自己握住方向盘。
4.3 把 AI 当成橡皮鸭,让它挑刺
还有一个我常用的习惯:写完代码后,不直接问“对不对”,而是把核心公式描述给它听,让它帮我反推。
比如我会说:“我假设每个位置的贡献是(i - prev[i]) * (next[i] - i),其中prev[i]是左边最近相同字符,next[i]是右边最近相同字符,请用s = "LEETCODE"手算一遍看结果是不是 92。”这一步能很快暴露我对公式边界的理解是否有漏洞。
有一回我故意把公式写成(i - prev[i]) * (next[i] - i + 1),DeepSeek 立刻指出右边多加了 1,并且用"ABA"的第二个 A 验证。虽然这是我自己有意写错的,但至少说明它具备基础的“验算”能力。把 AI 当橡皮鸭,最大的好处不是让它直接给答案,而是它能逼着我把思路完整地说出来,说着说着自己就发现错了。
5. 这类题的变体与刷题延伸
5.1 从“出现一次”改成“出现恰好 k 次”
贡献法的威力不止于“恰好一次”。如果题目改成“统计所有子串中,出现次数恰好为 k 的字符个数之和”,思路依然成立,只是状态变复杂了。
对某个字符的某个出现位置,它要想在子串里出现恰好 k 次,那么子串左边界必须跨过这个字符的前 k-1 次出现位置,但不能碰到前 k 次;右边界也必须被后 k-1 次出现位置限制住。说白了,原来我们只关心最近一个相同字符,现在要关心“往前数第 k-1 个”和“往后数第 k-1 个”。用位置列表实现时,只需要在遍历列表时用索引j取j-k+1和j+k左右边界,就能计算出当前第j个位置在“恰好出现 k 次”的场景下能贡献多少个子串。
我试着写过 k=2 的版本,发现只需要维护一个长度至少为 k+1 的位置列表,然后循环里多几次数组下标判断就行。很多人刷题只追求 AC 当前题,其实把一道题改一改条件,比新做十道题都更能锻炼思路。
5.2 从字符贡献到价值为权重的推广
贡献法的计算对象本质上是一个“位置元素”,并不一定非得是字符。如果题目给每个字符一个权重w(c),要求所有子串里出现一次字符的权重和,公式只需变成:
贡献 = w(c) * (i - prev[i]) * (next[i] - i)这个推广特别实用,因为它揭示了贡献法的本质:一个局部性质如果能只依赖左右边界,它就能被拆成每个点独立计算。字符频率、权重、甚至字符之间的距离,都可能套进这个框架里。
这类变体题在面试中也经常出现,比如有一些题要求统计所有子数组中等于某个值的最小/最大元素贡献,用的也是同样的“枚举每个元素作为最值”的思路。底层逻辑熟悉了,代码无非是换一层皮。
6. 实操中的常见问题与避坑清单
6.1 字符串类型不一定是大写字母
LeetCode 828 原题明确说明字符串只含大写英文字母,所以26这个数组大小是安全的。但我在某个模拟环境里遇到过字符串包含小写字母的版本,直接用char - 'A'就会越界。如果不想纠结,最稳的写法是:
int c = s.charAt(i) - 'A';只在原题条件下安全。如果条件写的是“包含任意 ASCII 字符”,就直接把数组长度改成 128,或者用HashMap<Character, Integer>替代数组。不要一上来就写26,先读清楚题目。
6.2 int 溢出和取模问题
这道题的答案虽然保证 int 能装下,但中间乘积很可能在极端数据下先溢出。比如n = 10^5,某个位置贡献是(i - prev[i]) * (next[i] - i),两个乘数都可能接近 50000,乘积会到 2.5e9,已经超过 int 上限。别以为“最终答案不超 int”就不需要处理中间结果,Java 的int乘法会在完成乘法后立即溢出。
稳妥做法是把累加变量ans声明成long,最后返回时再强转 int。这也是我实际提交时踩过的坑:
long ans = 0; for (int i = 0; i < n; i++) { ans += (long)(i - prev[i]) * (next[i] - i); } return (int) ans;(long)放在乘号前,能保证整个乘法按 64 位计算。别小看这一行,很多时间复杂度和空间复杂度都对的人,就挂在溢出上。
6.3 测试边界写少了会挂在哪
给你一张我当时自查用的表格,照着测基本能覆盖所有坑:
| 测试用例 | 期望结果 | 检查点 |
|---|---|---|
"" | 0 | 空串循环是否正确跳过 |
"A" | 1 | 单个字符,唯一字符贡献 1 |
"AA" | 2 | 两个相同字符,只有长度 1 的子串贡献 |
"ABA" | 8 | 经典重复字符用例 |
"LEETCODE" | 92 | 官方示例,验证整体逻辑 |
| 长度 10^5 的全相同字符 | 每个字符贡献都为 1,总数 n | 检查是否会溢出、是否超时 |
全相同字符的情况特别有意思:比如"AAAA",每个位置的 A 只能在长度为 1 的子串里成为唯一,所以答案就是 4。如果你把prev/next算错,很容易在这个简单用例上暴露。
我个人在实际操作中的体会是:算法题里最容易错的从来不是高深思维,而是边界条件和对定义的理解。贡献法本身不难,难的是你愿不愿意先花五分钟把countUniqueChars的含义和左右边界区间画清楚。最后再分享一个小技巧:做这道题时,把AAA和ABA两个例子用穷举法手算一遍,再对照贡献法的公式,一旦能对应上,整个解法就在你脑子里扎下根了。以后遇到类似“统计子串某种性质”的题,不妨先试试能不能从每个字符、每个位置的角度拆开看。