
先说一下面试场景。面试官递给你一道题“用 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 对象这在循环里是性能杀手。读取字符串中的某个字符常用两个 APIs.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 0j 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输出 b3i 5。第三轮 i 5当前字符 c。j 从 5 开始cs[6] c 相等继续然后 j 到 7此时 j n内层循环因越界条件停止。段长 7 - 5 2输出 c2i 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)追加了一个 intJava 的 StringBuilder 会自动把 int 转成字符串拼在后面。这一步没问题。但如果你写成sb.append(cs[i] (j - i));结果就完全变了因为 Java 里字符和整数相加时char 会被提升为 int 做算术运算。比如 cs[i] 是 a也就是 97j - 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并不是我写的是返回空串但如果你传入 nulls.isEmpty()那一步就会抛空指针因为 null 上没有方法可以调用。所以判断顺序很关键必须先把s null放在前面利用短路或的特性避免后续调用。4.2 测试用例的设计思路我自己在调试这类方法时会固定跑四类边界测试测试类型示例输入预期输出设计意图常规混合aabbbcca2b3c2验证基本切段逻辑单字符aa1验证循环体至少执行一次无连续段aba1b1验证每个字符独立成段全相同aaaaaaa6验证内层循环能走到末尾空串/空指针 / 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; }输出表格化如下轮次ij当前字符段长度输出102a2a2225b3b3357c2c2注意第三轮 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 切段会出问题。举个具体的例子字符串aabb底层 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这种压缩串而是要求返回ListString把每一段连续字符串都放到列表里。比如输入aabbbcc希望得到[aa, bbb, cc]。这种情况下代码只需要把原来的sb.append(cs[i]).append(j - i)换成list.add(new String(cs, i, j - i))。public static ListString splitToContinuousSegments(String s) { ListString 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”来决定是否前进。理解了基本双指针再理解滑动窗口只是换了一层比较逻辑。这里想特别强调一点很多刷题者容易陷入“背题”的怪圈一看到类似结构就直接套模板。但真正有价值的是把双指针的索引移动模型吃透理解“探测”和“跳跃”之间的配合这样不管题目包装成什么你都能快速识别出核心结构。我在实际工作中不止一次在日志解析、数据清洗、字符串压缩场景里用到这套遍历连续字符的逻辑。面试之外它真的能派上用场。建议你把这段代码亲手敲上几遍跑通所有边界样例再把面试官可能追问的复杂度和内存问题梳理一遍。熟练之后这道题就不只是会写而是真的理解了。