先说一下面试场景。面试官递给你一道题:“用 Java 写一个方法,输入字符串 aabbbccdee,输出每一段连续相同字符的字符和个数,格式是 a2b3c2d1e2。” 我当年第一次碰到这种题时,第一反应是拿 HashMap 统计次数,后来被面试官一句话点醒:“如果只是算总数,那和连续有什么关系?” 确实,这类题考的不是你能不能数数,而是你能不能把“连续”这两个字翻译成代码里的索引控制。今天就把这个看似基础、实则暗藏多个考点的遍历连续字符(Java)问题,从审题、选型到边界条件、面试追问,完整拆给你看。
这题在 Java 面试里出镜率不低,尤其喜欢在一面基础轮里出现。它本身不难,但能一次性写对的人并不多。如果你正准备 Java 面试,或者刚开始刷题想巩固字符串基本功,这篇文章值得认真读完,建议顺手跑一遍代码。
1. 审题与考点:拿到“遍历连续字符”先想清楚这几件事
1.1 题干其实有歧义:连续相等还是连续递增
刷题第一件事,是先确认“连续字符”的准确定义。大多数情况下,面试官说的是“连续相同字符”,也就是相同字符挨在一起形成一个段,比如aabbbcc可以分成aa、bbb、cc三段。但也有人会问“查找字符串里连续递增的字符段”,比如abcxyz里的abc,这种题目本质上是另一种算法,考察的是 ASCII 码值连续性判断。
如果你不确认就直接写代码,大概率会跑偏。我习惯的做法是,先口头和面试官对齐需求:输入是单个字符串吗?输出格式是拼接字符串、数组,还是把每段存进 List?空串、单个字符怎么处理?这些问题不是废话,而是让面试官看到你具备需求澄清意识,这比代码本身加分。
这篇文章接下来讨论的是最常见的版本:按连续相同的字符切割字符串,统计每个连续段的长度并输出。
1.2 为什么这道基础题在面试里反复出现
表面上看,遍历连续字符只是“循环+比较”的入门操作,但它背后至少压了四个考点:
- 遍历索引的可控性:很多人写循环时只习惯 i++,遇到需要跳跃索引的场景就懵。
- 边界条件判断:字符串最后一个连续段怎么处理,是新手最容易漏的问题。
- 复杂度分析能力:嵌套循环不一定就是 O(n²),能不能说清楚,直接反映算法功底。
- Java 基础细节:String、StringBuilder、charAt、toCharArray 相关的性能差异和使用场景。
这就是为什么面试官愿意出这题。它看起来没有难度,但能在五分钟内考察出一个人的基本功扎不扎实。很多候选人 HashMap 背得顺溜,反而在这道题上栽跟头,因为压根没看懂“连续”才是题眼。
1.3 前置知识点:String、charAt、toCharArray 的关系
先花一分钟过基础,方便零基础读者跟上。Java 里的 String 底层是一个 char 数组,但是 String 是不可变对象,你没法直接修改它内部的字符数组。正因为不可变,所以每次用+拼接字符串,底层都会 new 出新的 String 对象,这在循环里是性能杀手。
读取字符串中的某个字符,常用两个 API:
s.charAt(index):返回指定位置的字符,内部会做一次范围检查。s.toCharArray():一次性把整个字符串转成 char[],之后直接用数组下标访问。
两者在遍历场景下都可行。toCharArray 多了一次数组拷贝,但后续访问更直接;charAt 没有拷贝,但每次调用都有方法调用开销。对于这道题这种必须逐字符访问的算法,两种方案实测差异其实很小,但在面试场景里,转成 char[] 的写法更干净,也方便你解释索引逻辑,所以我下面的主推代码会采用 toCharArray。
此外还要知道 StringBuilder。它内部是一个可变的字符数组,append 方法不会频繁创建新对象,循环拼接时应该优先用它。
2. 方案选型:为什么我更推荐双指针而不是“遍历+Map”
2.1 最直觉的写法:单指针加计数器
很多人的第一反应是:从头扫到尾,维护一个“当前字符”的变量和一个“出现次数”的计数器,遇到不同的字符就输出上一段,然后重置。这个思路叫单指针遍历,代码大概长这样:
public static String groupByChar(String s) { if (s == null || s.isEmpty()) { return ""; } StringBuilder sb = new StringBuilder(); char cur = s.charAt(0); int count = 1; for (int i = 1; i < s.length(); i++) { if (s.charAt(i) == cur) { count++; } else { sb.append(cur).append(count); cur = s.charAt(i); count = 1; } } sb.append(cur).append(count); return sb.toString(); }这段逻辑是对的,而且很容易理解。但它有个特点:需要把“输出上一段”的动作放在 for 循环内部,同时还要在循环结束后再补一次输出,用来处理最后一个连续段。这种“循环外单独补一笔”的写法,在实际生产代码里很容易漏,尤其当循环内部逻辑变复杂之后,忘记补最后一段是常见 bug。
单指针解法适合用来跟面试官讲思路,但如果你想要一段“闭环更好”的代码,我更推荐双指针。
2.2 双指针:让连续段的边界直接在循环里闭合
双指针的思路是,用两个下标 i 和 j 把字符串“一段一段切出来”。i 指向当前段的开头,j 从 i 开始向右探索,直到遇到第一个和 s[i] 不同的字符。此时 j - i 就是当前连续段的长度。处理完这一段后,直接把 i 移动到 j 的位置,继续找下一段。
public static String compressByGroup(String s) { if (s == null || s.isEmpty()) { return ""; } char[] cs = s.toCharArray(); StringBuilder sb = new StringBuilder(); int i = 0; int n = cs.length; while (i < n) { int j = i; while (j < n && cs[j] == cs[i]) { j++; } sb.append(cs[i]).append(j - i); i = j; } return sb.toString(); }为什么推荐这个写法?因为它把“整段连续字符”抽象成了一个区间 [i, j),每次内层循环结束后,区间边界非常清晰,不需要维护额外状态,也不用在循环外补最后一段。内层循环因为遇到不同字符或到达数组末尾而停住时,外层直接更新 i = j,就算 j 已经到了 n,外层 while 的条件也会自然终止,不会产生死循环或漏段。
我在实际讲这道题时,习惯把双指针解法比作“切香肠”:i 是刀落下的位置,j 是刀往前推的探针,每切完一段,刀直接抬到上一刀的终点,不留重复处理的区域。
2.3 为什么不用正则或 Map“偷懒”
有读者可能会问:Java 正则不是能直接按连续字符分组吗?比如用Pattern.compile("(.)\\1*")配合 Matcher,确实可以匹配出每一段连续字符。Map 也能统计每个字符的出现次数,虽然丢失了“分段”语义,但也能算出每种字符的总量。那为什么面试不建议这么做?
原因有三点。第一,正则匹配底层会构建状态机,性能开销比普通遍历大不少,尤其字符串很长时,匹配耗时和内存占用都没优势。第二,正则写出来可读性并不好,反向引用\\1对很多人来说是个知识盲区,面试官无法从中看出你掌握了索引控制。第三,Map 统计的是“全局次数”,不是“连续段长度”,一旦题目要求输出顺序为从左到右的连续段,Map 还要额外维护顺序,纯属绕远路。
所以在面试中,老老实实用双指针手写遍历,反而是最聪明的选择。算法题的核心从来不是“有没有更短的 API”,而是“你能不能控制住代码的每个细节”。
3. 核心代码逐行拆解:把双指针写到条件反射
3.1 三个关键变量的运行过程
仔细看上面的压缩代码,核心变量只有三个:
char[] cs:把字符串转成的字符数组,加快访问速度。int i:外层循环游标,也是“当前连续段”的开头索引。int j:内层探测游标,从 i 开始向右找段的终点。
用aabbbcc手动模拟一下执行过程,你会更清楚索引是怎么移动的。初始时 i = 0,j = 0,此时 cs[0] 是 'a'。内层循环判断 cs[1] 也是 'a',继续;cs[2] 是 'b',和 cs[0] 不同,停止。于是当前段是 a,长度 j - i = 2 - 0 = 2,输出 a2,再让 i = j = 2。
第二轮 i = 2,当前字符是 cs[2] = 'b'。j 从 2 开始探索:cs[3] = 'b'、cs[4] = 'b' 都相等,cs[5] = 'c' 不同,停止。段长 5 - 2 = 3,输出 b3,i = 5。
第三轮 i = 5,当前字符 c。j 从 5 开始,cs[6] = 'c' 相等继续,然后 j++ 到 7,此时 j == n,内层循环因越界条件停止。段长 7 - 5 = 2,输出 c2,i = 7。外层 while 条件 i < n 不再成立,整个循环结束,结果是 a2b3c2。
从这个过程能看到一个很容易被忽略的点:内层循环其实起到了“预判终点”的作用。它不是因为发现不同字符才停,也可能是因为已经走到了字符串末尾。这两种停止原因都需要靠 j < n 这个条件来兜底,否则会产生数组越界异常。写代码时,内层 while 的先后顺序一定不能写反,必须先判断 j < n,再取 cs[j],否则短路的优先级会让你在 j 越界时先访问数组,直接抛异常。
3.2 StringBuilder 拼接时的一个隐蔽细节
代码里写的是:
sb.append(cs[i]).append(j - i);第一眼看上去,append(cs[i])追加了一个字符,append(j - i)追加了一个 int,Java 的 StringBuilder 会自动把 int 转成字符串拼在后面。这一步没问题。但如果你写成:
sb.append(cs[i] + (j - i));结果就完全变了,因为 Java 里字符和整数相加时,char 会被提升为 int 做算术运算。比如 cs[i] 是 'a',也就是 97,j - i 是 2,相加结果是 99,对应字符是 'c'。你本来想输出 a2,结果变成 c。这个坑我在给同事 review 代码时见过不止一次,看起来是微不足道的语法细节,但真正跑起来就是诡异 bug。记住一个原则:StringBuilder 里想连续拼接不同内容,就分开多次 append,不要图省事用+先做运算。
3.3 进阶微优化:提前转换 char[] 的意义
上面的代码一开始就执行了s.toCharArray(),把字符串复制成字符数组。有面试官可能会追问:charAt 也一样能访问,为什么要多一次拷贝?
这个问题可以从两个角度回答。宏观性能上,toCharArray 是一次性 O(n) 拷贝,后续访问是数组下标操作;charAt 每次都会走 String 内部的方法调用和范围检查。虽然 JIT 可能会做内联优化,但在高频循环里,直接操作局部数组往往更稳定。代码可读性上,当你写cs[j]时,读者一眼能看出这是数组元素访问,索引逻辑很直观;而s.charAt(j)在长表达式里会让代码显得啰嗦。
但这个优化不是必须的。如果你面试时紧张,写 charAt 也完全没问题,因为算法本身的时间复杂度没有变化。优化的前提是基础写法已经稳定,我建议在家练习时两个版本都写一遍,面试时顺手用哪个都不会卡壳。
4. 完整可运行的 Demo 与结果验证
4.1 一个可直接运行的代码示例
把上面的方法包装成完整可运行的 Java 类,加入几个测试用例,方便你直接复制到本地跑一遍。
public class ContinuousCharDemo { public static String groupCount(String s) { if (s == null || s.isEmpty()) { return ""; } char[] cs = s.toCharArray(); StringBuilder sb = new StringBuilder(); int i = 0; int n = cs.length; while (i < n) { int j = i; while (j < n && cs[j] == cs[i]) { j++; } sb.append(cs[i]).append(j - i); i = j; } return sb.toString(); } public static void main(String[] args) { String[] inputs = { "aabbbcc", "a", "ab", "aaaaaa", "aabbbccdee", "" }; for (String input : inputs) { System.out.printf("输入: %-12s 输出: %s%n", input.isEmpty() ? "(空串)" : input, groupCount(input)); } } }运行后会得到这样的结果:
输入: aabbbcc 输出: a2b3c2 输入: a 输出: a1 输入: ab 输出: a1b1 输入: aaaaaa 输出: a6 输入: aabbbccdee 输出: a2b3c2d1e2 输入: (空串) 输出:输出符合预期。很多人会忽略“空串”用例,但空串恰恰能暴露空指针问题。上面的代码对 null 和空串分别做了处理:null 直接返回字符串"null"?并不是,我写的是返回空串,但如果你传入 null,s.isEmpty()那一步就会抛空指针,因为 null 上没有方法可以调用。所以判断顺序很关键,必须先把s == null放在前面,利用短路或的特性避免后续调用。
4.2 测试用例的设计思路
我自己在调试这类方法时,会固定跑四类边界测试:
| 测试类型 | 示例输入 | 预期输出 | 设计意图 |
|---|---|---|---|
| 常规混合 | aabbbcc | a2b3c2 | 验证基本切段逻辑 |
| 单字符 | a | a1 | 验证循环体至少执行一次 |
| 无连续段 | ab | a1b1 | 验证每个字符独立成段 |
| 全相同 | aaaaaa | a6 | 验证内层循环能走到末尾 |
| 空串/空指针 | "" / null | 空串/空串 | 验证边界兜底 |
第 4 类“全相同”特别容易出问题。如果代码里内层循环没有把 j < n 考虑全,当 j 一路走到 n 之后,外层虽然能退出,但结果会漏掉最后一段。双指针写法里,内层循环因为 j == n 停止时,恰好说明当前段从 i 一直延伸到字符串末尾,此时段长 n - i,正是我们需要输出的。这和“遇到不同字符而停止”的处理方式是一致的,所以代码有天然的统一性。
4.3 用一张表看清遍历过程
调试时如果心里还悬着,可以把过程打印出来。我在学习阶段经常写这样一段临时调试代码:
while (i < n) { int j = i; while (j < n && cs[j] == cs[i]) { j++; } System.out.printf("i=%d, j=%d, segment=%s, length=%d%n", i, j, cs[i], j - i); i = j; }输出表格化如下:
| 轮次 | i | j | 当前字符 | 段长度 | 输出 |
|---|---|---|---|---|---|
| 1 | 0 | 2 | a | 2 | a2 |
| 2 | 2 | 5 | b | 3 | b3 |
| 3 | 5 | 7 | c | 2 | c2 |
注意第三轮 j 是从 5 直接移动到 7 的,7 已经等于数组长度 n,说明 c 段一直延续到末尾。这就是为什么不需要在 while 循环外额外补一段,双指针写法的闭环优势在这里体现得淋漓尽致。
5. 避坑清单:边界、死循环与字符编码的隐性细节
5.1 最经典的死循环写法:忘记更新 i
我见过很多人在写双指针时,内层逻辑想明白了,但外层循环体最后忘记写i = j,或者顺手写了i++。
如果忘记更新 i,外层 while 的 i 永远是 0,内层循环重复扫描 a 段,代码进入死循环,程序会一直卡住不结束。如果写了i++而不是i = j,后果更隐蔽:你以为 i 在前进,实际上每次只走一步,会把连续段的内部元素逐个当成新的段起点。比如aabbbcc会输出a2 a1 b3 b2 b1 c2 c1这种重复结果,逻辑错误但程序不报错,很难排查。
这其实反映出一个习惯问题:你要把 i 理解成“当前处理到的位置”,而不是一个单纯递增的循环变量。双指针中外层游标的移动方式取决于内层探索结果,这是和普通 for 循环最大的不同。
5.2 null 与空串不能混为一谈
空串""是一个长度为 0 的字符串对象,可以安全调用 isEmpty();而 null 表示引用不存在,任何方法调用都会抛 NullPointerException。很多新手把两者混在一起,写if (s == null || s.isEmpty())才是正确的判断顺序。
这里有个业务语义问题值得思考:如果输入是空串,方法的返回应该是什么?我上面的实现是返回空串,因为“没有字符,自然没有分组信息”。如果你面试的是业务开发岗,可能还需要额外讨论 null 时是抛出异常、返回 null,还是返回空串。这些看似微不足道的设计,往往是区分普通码农和工程意识好坏的地方。
5.3 char 不等于 Unicode 字符:小心 emoji 和生僻字
Java 的 char 是 16 位,采用 UTF-16 编码。大部分常用字符,比如中文汉字、英文字母、数字,都能用单个 char 表示,所以遍历字符串几乎没有感知。但 emoji 比如 😄,在 UTF-16 里需要两个 char 组成一个“代理对”。如果你用 charAt 或 toCharArray 遍历包含 emoji 的字符串,按 char 切段会出问题。
举个具体的例子,字符串"aa😄😄bb",底层 char 序列是'a' 'a' 高代理 低代理 高代理 低代理 'b' 'b'。按 char 相等比较,两个 emoji 各自的两个代理值是相同的,所以它们会分成一段,但输出结果如果用 char 计数会出现每个 emoji 段长 4 这个奇怪结论,因为实际是两个 emoji 字符,却被 char 数组算成了 4 个 code unit。
如果题目明确说“按字符输出”,你就要考虑用String.codePointAt()配合Character.charCount()来按 Unicode 码点遍历。但大多数 Java 面试题默认输入是 ASCII 或常用中文,不会深究这块。把它作为展开知识去了解即可,能主动提出来反而是加分项。
5.4 输出形态不唯一:返回压缩串还是连续段集合
有的变体不要求输出a2b3这种压缩串,而是要求返回List<String>,把每一段连续字符串都放到列表里。比如输入aabbbcc,希望得到["aa", "bbb", "cc"]。这种情况下,代码只需要把原来的sb.append(cs[i]).append(j - i)换成list.add(new String(cs, i, j - i))。
public static List<String> splitToContinuousSegments(String s) { List<String> list = new ArrayList<>(); if (s == null || s.isEmpty()) { return list; } char[] cs = s.toCharArray(); int i = 0; int n = cs.length; while (i < n) { int j = i; while (j < n && cs[j] == cs[i]) { j++; } list.add(new String(cs, i, j - i)); i = j; } return list; }这个变体在业务里更常见,比如日志脱敏时会先把连续字符段切出来再处理。做题时养成“输出结果是可变需求”的意识,会让你在面试里更灵活。
6. 高频追问与常见问题排查实录
6.1 面试官问:“复杂度是多少?你为什么说它不是 O(n²)?”
这是很多面试官针对嵌套循环最常问的问题。表面上看,外层 while 和内层 while 都是循环,直觉像 O(n²),但实际上每个字符最多被访问两次:一次被 j 当作当前段成员扫描判断,一次在下一轮作为其他段的成员被处理。
计算方式不是看循环嵌套层数,而是看内部操作的总次数。内层 j 从 0 扫到末尾,中间没有回溯,整体执行的判断次数和字符串长度 n 保持线性关系,所以时间复杂度是 O(n)。空间复杂度方面,如果只输出到 StringBuilder,结果字符串本身的长度和字符种数相关,最坏情况下每个字符都不同,输出长度约为 n 的常数倍,空间复杂度 O(n)。如果改成统计最长连续段并只返回长度,不保存完整结果,空间复杂度可以降到 O(1)。这组问答能体现你对算法复杂度的真正理解,建议背熟。
6.2 字符串特别长时,能不能不一次性加载到内存
真实业务里,字符串可能来自日志文件、网络流,几 GB 也不稀奇。这时候用 toCharArray 把所有数据读入内存不太现实。更合理的做法是逐字符读入,维护一个“上一个字符”和计数器,边读边输出分段。核心逻辑如下:
public static void processStream(Reader reader, Writer writer) throws IOException { int prev = -1; int count = 0; int cur; while ((cur = reader.read()) != -1) { if (prev != -1 && cur != prev) { writer.write(prev); writer.write(String.valueOf(count)); count = 0; } prev = cur; count++; } if (prev != -1) { writer.write(prev); writer.write(String.valueOf(count)); } }这个版本就是单指针加计数器的思想,它的优点是内存消耗恒定,不会随字符串长度增长。但流式处理在面试里通常作为扩展讨论,面试官想看的是你有没有大数据量下的敏感度,而不是要求你真把文件流写完。
6.3 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 程序卡死,不输出 | 外层 i 没有更新,或者更新成了 i++ 而非 i = j | 检查外层循环末尾是否将 i 指向 j |
| 数组越界异常 | 内层 while 把 j < n 条件放到了后面 | 确保范围判断写在取字符之前 |
| 输出结果漏了最后一段 | 采用了“遇到不同字符才输出”的写法且没有循环外补段 | 改用双指针写法,或确认循环外补了一次 append |
| 输出 a2 却得到 c2 | 用了sb.append(cs[i] + (j - i)),char 被提升为 int 再拼接 | 把几次 append 分开写,不要用加号先运算 |
| 输入 null 抛空指针 | 空串判断写在 null 判断前面 | 先判断 null,再 isEmpty |
| 表情符号被切坏 | 按 char 遍历,无法识别代理对 | 数据包含 emoji 时改用 codePoint 方式遍历 |
6.4 排查这类算法 bug 的通用技巧
如果你写完代码但结果不对,不要盯着代码瞎猜,建议先手写一组小输入,比如aabbbcc,然后在关键位置打印 i、j 和当前字符,逐个对比自己的预期。绝大多数问题会集中在两处:一是索引更新的位置不对,二是边界条件写错。索引排查可以重点看每次 i 的变化是否符合“跳到上一段终点”;边界排查则可以用空串、单字符、全相同字符这种极端输入验证。
另外我有个个人习惯:写完算法,会立刻用“单字符”和“全相同字符”这两组输入跑一遍。单字符能验证最小输入路径,全相同字符能验证内层循环能否正确处理到数组末尾的情况。这两组如果能过,这道题大概率不会错。
7. 举一反三:这类字符串题的变体套路
7.1 变体一:求最长连续相同字符段
如果题目改成“找出字符串里最长的一段连续相同字符”,代码的骨架几乎不变,只是把外层循环里的输出动作改成比较并记录最大长度。
public static int longestContinuousCount(String s) { if (s == null || s.isEmpty()) { return 0; } char[] cs = s.toCharArray(); int maxLen = 1; int i = 0; int n = cs.length; while (i < n) { int j = i; while (j < n && cs[j] == cs[i]) { j++; } maxLen = Math.max(maxLen, j - i); i = j; } return maxLen; }这类题在实际项目中也有典型场景,比如判断用户密码是否包含超过 4 个相同字符的弱口令,又比如解析连续重复符号标记。双指针几乎是通解。
7.2 变体二:游程编码压缩
aabbbcc压缩成a2b3c2,这就是传统意义上的游程编码。真实编码器里还会遇到一个问题:当某段字符只有 1 个时,a1比原来的a多占一个字符,起不到压缩效果。因此有些实现会约定,连续长度为 1 时不追加数字,只有长度大于 1 才追加。代码可以改成:
if (j - i > 1) { sb.append(cs[i]).append(j - i); } else { sb.append(cs[i]); }这个细节很能体现你是否真理解“压缩”两个字的含义。做题时如果输出格式允许自定义,主动设计这类规则会显得很有业务意识。
7.3 变体三:允许替换 k 个字符后的最长连续段
如果你想加大难度,可以把题目扩展成 LeetCode 424 的简化版:给定一个字符串,最多替换 k 个字符,使得某一字符连续出现的长度最大。这类题用的还是双指针,只不过内层探针变成了滑动窗口的右边界,外层左指针不一定要跳到右指针位置,而是按窗口内“非主流字符数量是否超过 k”来决定是否前进。理解了基本双指针,再理解滑动窗口只是换了一层比较逻辑。
这里想特别强调一点:很多刷题者容易陷入“背题”的怪圈,一看到类似结构就直接套模板。但真正有价值的是把双指针的索引移动模型吃透,理解“探测”和“跳跃”之间的配合,这样不管题目包装成什么,你都能快速识别出核心结构。
我在实际工作中,不止一次在日志解析、数据清洗、字符串压缩场景里用到这套遍历连续字符的逻辑。面试之外,它真的能派上用场。建议你把这段代码亲手敲上几遍,跑通所有边界样例,再把面试官可能追问的复杂度和内存问题梳理一遍。熟练之后,这道题就不只是会写,而是真的理解了。