如果只看语法ArrayList和String大概是Java里最“友好”的两个类了一个用起来像动态数组一个就是字面量的字符串。可一旦把它们放在同一道题里情况就完全不一样了。我陪不少新手做过练习几乎每轮都能看到同一种迷茫明明“把字符串存进集合”这句话每个字都认识真写代码时却不知道那个变量到底该用String还是ArrayList也不知道存进去之后该怎么改、怎么删、怎么比。这篇文章就是把这些年最常见的“ArrayList类的字符串例题”集中拆一遍从原理讲到实操再讲几个你十有八九会踩的坑。1. 为什么ArrayList和String组合起来总会出问题1.1 一切坑的源头String的不可变性先别急着写代码先把String这个“老好人”的真面目看清楚。String是不可变的意思是你一旦创建了abc这个对象它就永远是abc任何看似“修改”的操作本质都是创建了一个新对象然后把引用重新赋值给你手里的变量。String s hello; s s world; // 这里不是把hello改成hello world而是新建了对象这句话放到ArrayList的场景里就特别容易想错。很多人以为把字符串丢进集合之后“改一改”集合里的元素是在原地修改那个字符串——不是你是把集合里那个位置的引用换成了新的引用。举个实际现象你从list里取出一个字符串想替换某个字符写s.replace(a, b)结果打印发现根本没变就是因为replace返回的是新字符串你没有接住返回值。这个问题的根源就是String不可变。1.2 ArrayList的“引用存储”到底意味着什么再来看ArrayList。声明ArrayListString很多人脑子里想的是“一个装了很多String的数组”。这话没错但不够准确。准确的说法是ArrayList底层维护了一个Object[]数组数组里每个位置保存的是String对象的引用不是把字符串内容拷贝进去。画个内存图你就能理解list里有10个字符串实际内存里可能有10个String对象或者指向常量池里的10个对象list的数组里存的是这10个对象的内存地址。所以你对list做set(index, newValue)本质是把数组第index个位置上的引用换成了新对象的引用而一旦两个变量指向了同一个String对象某个变量重新赋值并不会影响另一个变量——因为“赋值”改的是变量自己的引用槽对象本身没变。理解这一点对后面做所有例题都很关键。比如你写String temp list.get(0);然后temp temp.toUpperCase();list里的第一个字符串并不会变成大写。不是API不对是temp已经指向了一个新对象list里的引用还指着老对象。1.3 基础热身先跑通第一个增删改查既然聊到组合第一件事就是把ArrayList 的基本操作跑一遍这是后面所有例题的地基。这里我直接给出一个最简示例你自己在本地跑一遍跑完再看后面的题目会顺很多。import java.util.ArrayList; import java.util.List; public class BasicDemo { public static void main(String[] args) { ListString list new ArrayList(); list.add(apple); list.add(banana); list.add(cherry); System.out.println(size list.size()); // 3 list.set(1, blueberry); // 用set替换 list.remove(0); // 用索引删除 // 三种遍历方式普通for、增强for、forEach for (int i 0; i list.size(); i) { System.out.println(i - list.get(i)); } for (String s : list) { System.out.println(s); } list.forEach(System.out::println); } }有两个习惯建议从一开始就养成第一声明变量时用ListString不要写ArrayListString这样以后换成LinkedList或者其他实现都不用改调用方第二能用list.size()的地方就别自己记一个“当前数量”变量集合自己最清楚自己有多少元素手动维护的计数器十有八九会在增删之后错乱。热身做完下面进入正文五道我自己在带教和刷题中最常见的ArrayList字符串例题每一道我都会把解题思路和代码一并给出。2. 五道高频例题拆解从筛选到字符统计2.1 例题一键盘录入一串字符串把长度大于3的存进ArrayList题目本身很朴素但它是“输入 筛选 集合存储”的经典组合几乎所有教材都会拿它当入门题。import java.util.ArrayList; import java.util.List; import java.util.Scanner; public class FilterDemo { public static void main(String[] args) { ListString result new ArrayList(); Scanner scanner new Scanner(System.in); System.out.println(请输入字符串输入 quit 结束); while (true) { String input scanner.nextLine(); if (quit.equalsIgnoreCase(input)) { break; } if (input.length() 3) { result.add(input); } } System.out.println(长度大于3的字符串); for (String s : result) { System.out.println(s); } } }这里有两个细节要重点说明。一个是quit.equalsIgnoreCase(input)这种写法把常量放前面、变量放后面。写成input.equals(quit)虽然也能跑但万一input是null就会抛空指针异常把常量放前面即使input为null也只是返回false不会崩。这种顺序在解析用户输入时尤其重要因为用户永远不会按你预期的格式输入。另一个是nextLine()和next()的区别。这个题我用的是nextLine()它会读取直到换行符为止的整行内容像hello world这样的带空格字符串也能完整读进去。如果用next()遇到空格就停了题目要求“键盘录入字符串”时带空格的句子就会被误拆成两段。反过来如果你先用了next()读一个单词再想用nextLine()读整行会发现读到的是空字符串——因为next()不会把末尾的换行符吃掉nextLine()一上来就撞见了这个换行符。这时候需要在中间补一个scanner.nextLine()把换行符消费掉。这是非常典型的控制台输入坑面试和考试里都爱考。如果题目把“长度大于3”换成“只包含字母和数字”这类条件思路完全一样把判断条件换成遍历字符、用Character.isLetterOrDigit逐个检查即可。这类变体题在练习平台非常常见核心还是“遍历集合 条件判断 收集结果”这个骨架。2.2 例题二去掉集合里重复的字符串这题一看简单做起来版本很多。我见过三种主流解法各有各的适用场景别只背一种。第一种是“新集合 contains”法最直白也最能体现对ArrayList API的熟悉程度public static ListString distinctByLoop(ListString list) { ListString result new ArrayList(); for (String s : list) { if (!result.contains(s)) { result.add(s); } } return result; }contains底层是用equals逐个比较的所以这里去重用的是“内容相等”不是“引用相等”这正好符合大多数去重场景的要求。时间复杂度是O(n^2)数据量小无所谓如果给你几万个字符串这个方法就会明显变慢。第二种是LinkedHashSet法。如果题目对“是否保持原顺序”有要求可以用LinkedHashSetListString result new ArrayList(new LinkedHashSet(list));一句话搞定顺序也保持但它返回的本质上不再是ArrayList的操作语义而且当你需要对去重结果继续做索引访问时需要再转回List。所以这个方法适合“只要结果不要过程”的场合。第三种是Stream写法JDK8之后我很推荐在项目里用ListString result list.stream() .distinct() .collect(Collectors.toList());它够短、够清晰还有个额外好处distinct后面可以继续链式调用filter、sorted非常适合在复杂筛选场景里串流程。不过面试手写代码时你需要先能写出来第一种再提Stream方案这能证明你不只是背过API而是真的理解两种写法的差异。2.3 例题三先按长度排再按字典序排字符串排序是ArrayList例题里的常客难倒新手的点往往不是“怎么排序”而是“排序规则怎么写”。比如这个经典要求长度短的在前如果长度一样就按字母顺序。最原始的写法是实现Comparator接口手写比较逻辑list.sort(new ComparatorString() { Override public int compare(String s1, String s2) { if (s1.length() ! s2.length()) { return s1.length() - s2.length(); } return s1.compareTo(s2); } });这个写法逻辑最清楚适合用来理解Comparator的本质——compare方法返回负数、零、正数分别表示第一个参数排在前面、两者相等、第二个参数排在前面。很多新手以为返回1就是“大”其实核心是“谁排在谁前面”。当你能把这个匿名内部类写顺了再进阶到lambda和链式写法list.sort(Comparator .comparing(String::length) .thenComparing(Comparator.naturalOrder()));comparing指定第一排序键thenComparing继续指定第二排序键读代码的时候像在读英文句子可维护性比手写compare高不少。不过要留意一个点Comparator.naturalOrder()对String来说是按字典序这个排序依赖String内部的compareTo实现中文环境下它按Unicode码点排序不是你脑子里那个“拼音字母表”。如果题目要求中文按拼音排得引入java.text.Collator这就超出ArrayList本身的范围了但你在真正的项目里一定会遇到。2.4 例题四统计并找出所有回文字符串回文串正读反读都一样比如level、上海自来水来自海上是字符串例题的经典题型。放到ArrayList里通常是这么考的给一个字符串列表把其中的回文串找出来或者统计个数、找出最长的一个。判断回文有两种主流实现。第一种是双指针法不生成任何新对象public static boolean isPalindrome(String s) { int left 0; int right s.length() - 1; while (left right) { if (s.charAt(left) ! s.charAt(right)) { return false; } left; right--; } return true; }第二种是反转比较法代码短但依赖StringBuilderpublic static boolean isPalindrome(String s) { return s.equals(new StringBuilder(s).reverse().toString()); }两种我都建议写一遍。双指针法告诉你“判断是不是回文”这个动作本身可以做到O(1)额外空间反转法虽然创建了新对象代码可读性却更好。在面试手写时能写出第一种再补充说明第二种会显得你对性能敏感。在主方法里遍历列表、统计并找出最长回文串的骨架大致这样int count 0; String longest ; for (String s : list) { if (isPalindrome(s)) { count; if (s.length() longest.length()) { longest s; } } }这里有个细节容易被忽略如果列表里出现空字符串空串算不算回文绝大多数题目默认空串长度0可以参与统计但不参与“最长”比较。我在代码里会给“最长”加一个长度大于0的判断否则结果容易出歧义。2.5 例题五统计每个字符串出现次数进阶最后一个例题从“集合操作”上升到“集合与映射的配合”也是很多公司机试的隐藏高频题给你一个ArrayList 统计每个字符串出现了多少次按次数从高到低输出。import java.util.*; public class CountDemo { public static void main(String[] args) { ListString list Arrays.asList(apple, banana, apple, cherry, banana, apple); MapString, Integer counter new HashMap(); for (String s : list) { counter.put(s, counter.getOrDefault(s, 0) 1); } ListMap.EntryString, Integer entries new ArrayList(counter.entrySet()); entries.sort((e1, e2) - e2.getValue() - e1.getValue()); for (Map.EntryString, Integer entry : entries) { System.out.println(entry.getKey() : entry.getValue()); } } }这里有两个关键动作。一是getOrDefault(s, 0) 1它替代了“先判断containsKey再决定是1还是1”的啰嗦写法是统计频次时最高效的套路如果你用的还是JDK8之前的版本才需要老老实实写containsKey分支。二是把Map的entrySet转成ArrayList再对这个“装着键值对的新列表”排序。这一步很妙你完全可以不引入新集合但把entrySet塞进ArrayList之后再排序代码逻辑一目了然也顺便复习了“集合里能装任意对象”这个思想——ArrayList 是集合ArrayListMap.EntryString, Integer同样是集合类型参数不就是个泛型嘛。如果想统计“每个字符”而不是“每个字符串”只要把外层循环改成对s.toCharArray()再套一层内层循环即可。把题目从“字符串级”延伸到“字符级”是理解这套API组合的很好的自我测验。3. ArrayList 三个最容易翻车的操作陷阱例题做完了下面这些坑是我看新手写代码看到次数最多的也几乎是我自己当年一个个踩过的。我不按难度排按“出现频率”排。3.1 用索引删除元素时越删越乱删除是ArrayList日常操作但一牵扯到循环就出事。看这段代码ListString list new ArrayList(Arrays.asList(a, b, b, c)); for (int i 0; i list.size(); i) { if (list.get(i).equals(b)) { list.remove(i); } } System.out.println(list); // 期望[a, c]实际会输出[a, b, c]为什么删不干净因为remove(1)把第一个b删掉后后面的元素整体前移原来第二个b补到了索引1的位置而for循环的i已经递增到2了这个“漏网之鱼”就被跳过了。解决思路有三个按推荐程度排序。第一种倒序遍历从后往前删前移的元素不会影响还没检查到的索引for (int i list.size() - 1; i 0; i--) { if (list.get(i).equals(b)) { list.remove(i); } }第二种用Iterator的removeIteratorString it list.iterator(); while (it.hasNext()) { if (it.next().equals(b)) { it.remove(); } }第三种最省事JDK8之后的removeIf一行搞定list.removeIf(s - s.equals(b));我的建议刷题练习时务必把第一种和第二种都写一遍理解“为什么倒序就行”项目里直接用removeIf代码简洁且不易错。3.2 equals和混用字符串比较翻车的高发区“判断两个字符串是否相等”到底用还是equals是个老生常谈的问题但放到ArrayList里问法会变为什么contains返回false为什么remove删不掉先说结论比较String内容必须用equals比较的是引用地址。下面这段代码最能说明问题String a abc; String b new String(abc); System.out.println(a b); // false因为b是一个新对象 System.out.println(a.equals(b)); // true因为内容相同 ListString list new ArrayList(); list.add(a); System.out.println(list.contains(abc)); // truecontains底层用的equalscontains和remove之所以能“按内容找到元素”正是因为它们内部走的都是equals。如果你自己写了一个自定义对象往ArrayList里放却不重写equals那contains和remove就会变成“按引用找”几乎必然找不到——这是自定义对象存储时最隐蔽的坑。3.3 增强for里做删除ConcurrentModificationException很多新手学会增强for之后就习惯用它遍历然后顺手在循环里删元素for (String s : list) { if (s.equals(b)) { list.remove(s); // 运行到这里抛异常 } }这个报错是ConcurrentModificationException原因一句话能说清增强for底层用的是Iterator它在遍历时会检查“集合有没有被外部修改过”一旦检测到modCount发生变化就立刻抛异常防止你边遍历边改导致无法预料的后果。它并不是为了“禁止修改”而是防止“非预期的修改”。所以要删元素就用上一小节说的Iterator.remove()或removeIf如果确实要在遍历过程中做“比较复杂的逻辑”那就先收集再处理比如用一个新的ArrayList装下要删除的元素遍历结束统一remove。这三个坑在ArrayList和String的例题里出现频率极高我建议你做例题练习时每写一个循环就停下来问自己三个问题这个循环里有没有修改集合如果用索引删了会不会跳项比较字符串用的equals还是问完这几句能帮你避开80%的运行时报错。4. 从例题走向工程实践几个值得养成的习惯五道题做完、三个坑排完你把ArrayList和String的组合已经用得很顺了。最后分享几个我在真实项目里回头才想明白的习惯对初学者尤其值钱。4.1 构造测试集合时先用List.of()和Arrays.asList()再转ArrayList很多新手一上来就new ArrayList()然后add一个个往里塞代码又长又容易分心。练习和写单元测试时可以先这样ListString list new ArrayList(Arrays.asList(apple, banana, cherry)); // 或者JDK9之后更推荐 ListString list new ArrayList(List.of(apple, banana, cherry));要特别注意List.of()返回的是不可变列表Arrays.asList()返回的是定长列表不能add和remove只能set所以直接拿它们去调“需要增删”的方法会报UnsupportedOperationException。最稳妥的做法是外面包一层new ArrayList(...)既保留了快速初始化的便利又获得了完全可变的ArrayList语义。这个细节我在不少工作两三年的同事身上都见到过翻车值得当成固定套路记下来。另外补充一个实战中很常见的场景当你手里拿到的是一段逗号分隔的字符串时配合String.split()可以一行生成集合ListString list new ArrayList(Arrays.asList(apple,banana,cherry.split(,)));这个组合本质上就是“字符串例题”在真实业务里的常见形态比如解析CSV的一行、处理前端传过来的标签列表。4.2 多条件排序用Comparator链式写法而不是手写大段compare刚才例题三已经提到了链式写法这里再强调一次它的工程价值。假设条件变成“先按长度升序长度相同按字典序字典序还相同再按字符串长度倒序”手写compare会变得非常臃肿而链式版本只要追加一行list.sort(Comparator .comparing(String::length) .thenComparing(Comparator.naturalOrder()) .thenComparing(Comparator.comparing(String::length).reversed()) );每增加一个排序条件手写版本就要多几行if判断而链式版本几乎零成本追加。真实项目的排序规则常年变用Comparator链式写法改起来非常舒服。这也是我为什么总说“刷题时把原理写一遍项目里用高效写法”。4.3 字符串拼接大量数据前先想想StringBuilder最后一个习惯和性能有关。ArrayList里经常装着几十上百条字符串如果要把它们拼成一个大的字符串比如拼成一条日志、一条SQL的IN子句用直接拼会有性能问题。看这段代码String result ; for (String s : list) { result result s ,; // 每次循环都产生新的字符串对象 }换成StringBuilder之后StringBuilder sb new StringBuilder(); for (String s : list) { sb.append(s).append(,); } String result sb.toString();原理还是回到第一节说的String不可变性拼接的每一次循环即使编译器在背后做了StringBuilder优化也会在每轮循环里新建一个StringBuilder对象临时对象频繁创建既慢又占内存。我之前在本地用10万条短字符串做了个简单对比普通拼接跑完接近两秒StringBuilder基本在10毫秒以内差了上百倍。日常写业务代码可能感觉不到但放在批处理、生成报表这些场景里差别非常明显。最后再分享一个我常用的学习习惯拿到一道“ArrayList类的字符串例题”时别急着写代码先把它翻译成方法签名。比如“筛选长度大于3的字符串”就写成public ListString filterLongStrings(ListString input, int minLength)比如“统计出现次数”就写成public MapString, Integer countWords(ListString words)。这个翻译动作能强迫你把题目需求拆成输入、输出和规则代码写着写着就会清晰很多。等你练多了会发现所有看似天花乱坠的字符串集合题最后都在考三件事懂不懂String的特性熟不熟悉ArrayList的API会不会在合适的时候引入Map和Comparator。把这三件事练扎实这个类型的题基本就通关了。