“7-2 单词排序”这道题,我猜很多人在Java课上或者在线判题系统里见过。题目本身描述很简单:输入若干个单词,按字典序排列输出。但恰恰是这种看起来毫无难度的题目,背后串联了Scanner读入、数组排序、字符串比较规则、以及Arrays.sort底层排序算法切换等一系列知识点。不少同学明明能写出来,却在平台上反复提交出错,问题往往不是排序逻辑本身,而是输入处理或者对字符串字典序的理解出了偏差。这篇文章就把这道题彻底讲透,从需求拆解到代码实现,从内置排序到手写快排思路,再到常见的坑和排查方法,一次性说清楚,读完后无论是完成作业还是应付笔试,心里都会踏实很多。
1. 题目到底在考什么:单词排序的需求拆解
1.1 从输入到输出:先理清格式再动手
“单词排序”这类题目的输入格式通常是这样的:
- 第一行给一个整数
n,表示接下来有n个单词; - 后面跟着
n行,每行一个单词; - 输出要求是把这
n个单词按照字典序从小到大排列,每个单词单独占一行。
有些变体是第一行不给n,直接输入若干单词,直到文件结束(EOF),这种处理方式在在线判题里也很常见。我建议拿到题目先做一件事:确认输入究竟有几行、单词之间是用换行分隔还是空格分隔。因为很多同学在第一步就踩了Scanner的坑。
如果按行读取,比较稳妥的写法是这样的:
Scanner sc = new Scanner(System.in); int n = Integer.parseInt(sc.nextLine()); String[] words = new String[n]; for (int i = 0; i < n; i++) { words[i] = sc.nextLine(); }这里直接用nextLine()读完整的一行,避免nextInt()读完数字后只消费了数字本身,却把换行符留在缓冲区的尴尬。这个问题后面具体章节我会详细说,现在只需要记得:如果题目说“每行一个单词”,优先用nextLine()而不是next()。因为next()虽然也能读到单词,但它是按空白字符分割的,一旦输入是多行文本,理论上也能读出来,只是容易让你在潜意识里忽略“换行”这个结构,后续如果题目改成“第一行是整数”就很容易出错。
从输入解析层面看,最规范的顺序是:
- 读取整数
n; - 用循环读取接下来
n行; - 调用
Arrays.sort(words); - 按行打印结果。
就这么简单。很多人卡住不是因为不会sort,而是因为在第 1 步到第 2 步之间,被残留的回车符坑了一次。
1.2 “字典序”到底是什么:别说你懂字符串比较
既然是单词排序,核心就是“按字典序比较”。很多人想当然地认为字符串排序就是“A 在 B 前面”,其实这里有很多细节:
- 在 Java 中,
String类实现了Comparable<String>接口,其compareTo()方法基于字符的 Unicode 码值进行逐字符比较。 - 比较规则是从第一个字符开始,逐个比较对应字符的码值,一旦出现不一样的字符,码值小的字符串就排在前面。
- 如果前面所有字符都相同,那么长度较短的字符串排在前面。举个例子:
"abc"和"abcd"比较,前三个字符都一样,此时"abc"更短,因此"abc" < "abcd"。 - 字母
'A'的码值是 65,'a'的码值是 97,所以在大写字母和小写字母同时出现时,所有大写字母都会排在所有小写字母的前面。"Apple"会排在"apple"前面,这点经常让人意外。
如果你看到平台上标题或题目描述里写着“快排”,很可能这个题目原本的需求是让你实现一个快速排序来对字符串数组排序,以此考察你对分治思想的理解。但在 Java 里,直接调Arrays.sort不仅代码量更少,还更容易保证正确性,因为库里的排序算法经过了大量优化。
1.3 这题看似是排序,其实是四层知识点的叠加
我在帮一些学习 Java 的同学看代码时发现,大家往往只把这道题当成一个“排序题”,所以在sort用完之后就觉得万事大吉。但从面试和考试的角度看,这个题至少包含四个隐藏考点:
- 标准输入流的读取方式,尤其是有无
n、每行一个数据等不同输入格式; - 数组或集合的排序 API 使用,包括默认排序和自定义
Comparator; - 字符串字典序的比较规则,以及大小写对结果的影响;
- 排序算法的基本复杂度分析,能不能说清楚内部排序为什么高效。
所以这篇文章不是只贴一行Arrays.sort(words)就结束,而是希望你把每一个环节都理解透。这样以后遇到类似的题目,比如按整数排序、按对象某个属性排序、按字符串长度排序,都可以直接迁移思路。
2. 选型:为什么优先用 Arrays.sort,而不是自己写快排
2.1 题目里写着快排,但工程上更推荐内置排序
很多教材在讲到排序算法时,都会拿快速排序举例,这也解释了为什么这个题目标签里会出现“快排”字样。但到了实际编码阶段,Java 内置的Arrays.sort并不只是一个简单的快速排序实现。它根据被排序的数据类型采用了不同的策略:
| 排序对象 | 底层算法 | 特点 |
|---|---|---|
| 基本类型数组(如 int[]) | Dual-Pivot Quicksort(双轴快排) | 速度快,但排序不稳定 |
| 对象类型数组(如 String[]、Integer[]) | TimSort(一种结合归并和插入排序的稳定排序) | 保证稳定性,利用输入数据的部分有序性 |
看到这里可能有人会问:既然是对象数组,为什么题目还强调快排?其实这种标签通常是题目描述沿用传统说法,而 JDK 的实现早就不是单纯快排了。刚接触 Java 的同学,直接使用Arrays.sort完全正确,也完全符合“使用 Java 标准库解决排序问题”的思路。
不过,在面试场景中你如果能说一句“我了解Arrays.sort在对象排序时采用 TimSort,在基本类型排序时采用 DualPivotQuicksort,我能手写一个基础的快速排序作为算法理解”,那会让对方觉得你既会调用工具,也懂底层逻辑。后面第 4 节我会专门写出手写快排的完整思路。
2.2 默认排序怎么来的:String 已经实现了 Comparable
用Arrays.sort(words)时,你并没有传任何额外的比较规则,之所以能排成功,是因为String类实现了Comparable<String>接口,并且提供了天然的字典序比较逻辑。这个设计的好处是:只要一个类实现了Comparable,它就能直接被放进Arrays.sort或Collections.sort里排序。
如果哪天你遇到的是自定义类,比如一个Word类,包含content和length两个字段。你希望单词按字典序排,可以通过实现Comparable<Word>来决定“哪个对象更大”;如果不希望修改类本身,也可以用后文提到的Comparator。
class Word implements Comparable<Word> { String content; Word(String content) { this.content = content; } @Override public int compareTo(Word other) { return this.content.compareTo(other.content); } }对于这道题目,因为数组元素本身就是String,所以默认排序已经满足需求,不需要额外写Comparator。
2.3 什么时候需要自定义 Comparator:长度优先、忽略大小写
“单词排序”这个题最常见的变体有两个:按单词长度排序,以及忽略大小写排序。这两种情况用默认的compareTo都解决不了,必须借助Comparator。
- 如果要求按长度从小到大排序,长度相同再按字典序排,可以这么写:
Arrays.sort(words, (a, b) -> { if (a.length() != b.length()) { return a.length() - b.length(); } return a.compareTo(b); });- 如果要求忽略大小写排序,可以直接用 JDK 提供的常量:
Arrays.sort(words, String.CASE_INSENSITIVE_ORDER);甚至可以利用Comparator.comparing链式写法:
Arrays.sort(words, Comparator.comparing(String::length).thenComparing(Comparator.naturalOrder()));所以说,这个题虽然简单,但从“默认排序”到“自定义排序”的过渡非常自然,完全可以直接拿来做Comparator的入门练习。搞清楚它们之间的区别,后面学习Stream的sorted、或者List.sort时都会很轻松。
3. 完整可运行的实现:从 Scanner 到输出结果
3.1 最稳妥的基础版本:按行读取,直接排序
我先给你一个可以直接在本地运行,也可以直接提交到大多数判题平台的版本。
import java.util.Arrays; import java.util.Scanner; public class WordSort { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = Integer.parseInt(sc.nextLine()); String[] words = new String[n]; for (int i = 0; i < n; i++) { words[i] = sc.nextLine(); } Arrays.sort(words); for (String word : words) { System.out.println(word); } sc.close(); } }这段代码有几个细节值得说明:
Integer.parseInt(sc.nextLine())用一个完整的nextLine先把整行读进来,再用parseInt转成整数。这种方法可以绕开nextInt之后换行符残留的问题。- 数组长度直接由
n决定,不需要动态扩容。 - 输出用普通
for循环逐行打印,完全满足“每行一个单词”的要求。
如果你更习惯用nextInt,那在读完n之后,最好额外调用一次sc.nextLine()来吃掉换行符,否则第一行单词可能会被空字符串覆盖。
3.2 运行效果验证
假设输入:
5 banana apple cherry date elderberry输出结果就是按字典序排好的:
apple banana cherry date elderberry如果是包含大小写混合的输入:
3 Apple apple Banana结果会是这样:
Apple Banana apple因为大写字母的 ASCII 码小于小写字母,所以Apple和Banana会先排出来。很多第一次接触这个知识点的人会觉得“怎么不是 a 在 A 前面”,其实这正是字典序基于字符码值的体现。
3.3 用 List 的写法:更贴近真实开发习惯
如果你已经用习惯了集合框架,那么把数组换成ArrayList<String>也是一个常见选择。尤其是有些题目的输入中n不是固定的,或者你事先不知道有多少单词,用List会更灵活。
import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Scanner; public class WordSortList { public static void main(String[] args) { Scanner sc = new Scanner(System.in); List<String> words = new ArrayList<>(); while (sc.hasNextLine()) { String line = sc.nextLine().trim(); if (line.isEmpty()) { break; } words.add(line); } Collections.sort(words); words.forEach(System.out::println); sc.close(); } }这种写法甚至不需要提前知道有多少个单词,直接一直读到空行为止。如果你使用的是List<String>,也可以调用words.sort(null),或者words.sort(Comparator.naturalOrder()),效果是一样的。
3.4 输出格式的细节:要不要用 String.join
有些同学想把结果拼接成一个长字符串一次性输出,这样可以减少println的调用次数。在数据量不大的时候,两种方式性能上没有区别,但用String.join确实更简洁:
System.out.println(String.join("\n", words));这里需要特别注意的是,String.join的用法要求传入一个CharSequence的可迭代对象,数组可以直接传,但你要是用List就得确保里面不是null。实际开发中,我更推荐逐行输出,因为一旦题目要求“数据之间用空格分隔”,String.join("\n")就得改成String.join(" "),不如保留循环直观。
3.5 关于 Scanner.close() 的一个小建议
很多教材会写sc.close(),这本身没错,可以防止资源泄漏。但如果在某些判题环境中,你在读取结束后立刻关闭System.in关联的Scanner,理论上不会有什么影响。我个人的习惯是:在本地练习中加close(),在提交判题时有时候不加,因为判题系统的输入流由平台统一管理,不过这两种方式都不会导致错误。真正需要关注的是顺序,不要在循环读取的过程中关闭,否则会直接抛异常。
4. 如果让你手写快排:字符串数组也能分治
4.1 第一版快排:把整数版本的逻辑改到字符串上
如果你在考试或面试里被要求“不要调用库函数来实现快排”,那你需要自己能写出一版针对String[]排序的代码。思路和整数排序完全一致,只是把比较大小的操作从a < b改成了a.compareTo(b) < 0。
下面是一个最基础的递归版本:
public class QuickSortStrings { public static void quickSort(String[] arr, int left, int right) { if (left >= right) { return; } int i = left; int j = right; String pivot = arr[left + (right - left) / 2]; while (i <= j) { while (arr[i].compareTo(pivot) < 0) { i++; } while (arr[j].compareTo(pivot) > 0) { j--; } if (i <= j) { String temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; i++; j--; } } quickSort(arr, left, j); quickSort(arr, i, right); } public static void main(String[] args) { String[] words = {"banana", "apple", "cherry", "date", "elderberry"}; quickSort(words, 0, words.length - 1); for (String word : words) { System.out.println(word); } } }这种写法采用中间位置元素作为基准值,然后用两个指针从两端向中间扫描。当左指针发现一个不小于基准的元素,右指针发现一个不大于基准的元素时,就交换它们。这样一趟下来,基准左侧的元素都不会大于基准,右侧的元素都不会小于基准。
4.2 基准选择为什么很重要
快速排序的平均时间复杂度是 O(n log n),但最坏情况下会退化到 O(n²)。最坏情况发生在每次选到的基准都是当前区间的最小值或最大值,比如序列本身已经有序,而你每次正好选到第一个元素作为基准,那每趟只能排好一个元素,递归深度会变成 n。为了避免这种情况,常见做法有:
- 选中间位置:简单,而且对大部分输入友好;
- 选随机位置:用随机数生成下标的基准值,几乎不可能被特殊输入反制;
- 三数取中:取区间首、中、尾三个位置的值,选中间大小的那个作为基准。
对一道简单的单词排序题来说,选中间位置已经足够。但如果你想展示自己对算法的理解,可以额外提一句“JDK 的底层实现还引入了阈值,当区间长度小于某个值时改用插入排序,因为小规模数据插入排序的常数更小”。这种话说出来,说明你不仅会写快排,还研究过真实工程实现。
4.3 手写快排对比 Arrays.sort:为什么工程实现更稳
手写快排本质上是原地排序,不需要额外数组空间。但Arrays.sort在对对象数组排序时采用 TimSort,其特点是稳定,并且能利用输入数据中已经存在的有序片段,所以在很多真实场景下表现优于通用快速排序。尤其对于字符串数组,元素比较本身可能涉及较长的公共前缀,顺序被轻微打乱时,TimSort 能更好地减少比较次数。
刚才代码里的手写快排,如果在数据量很大并且输入已经基本有序的情况下,性能可能反而不如内置排序。这就是为什么我对初学者的建议永远是:考试能调用库就调用库,手写快排是为了理解算法思想。你不需要在手写版本里纠结过多优化,能写出整洁的分治结构就已经足够了。
4.4 快排中的细节:重复元素导致的死循环
假如数组里有大量相同的字符串,比如 100 个"hello",基准也是"hello"。如果循环条件写得不严谨,i和j可能不会正常交错,甚至在交换完后出现i > j的情况,紧接着递归区间出错,导致栈溢出。解决方式就是代码里常见的if (i <= j) { ... i++; j--; }这一个判断。这个判断保证指针不会停留在原地,所以即使所有元素都和基准相等,一趟之后i > j,递归也能正确地把区间切分成两段空或很小的子区间。每当你手写快排遇到“死循环”或“数组没变化”的问题,先检查这个位置。
5. 常见错误与排查:这些坑我基本都踩过
5.1 回车在缓冲区里“阴魂不散”
这是初学者在 Java 控制台输入里遇到最多的老大难问题。先看错误示范:
int n = sc.nextInt(); String[] words = new String[n]; for (int i = 0; i < n; i++) { words[i] = sc.nextLine(); // 第一行读到空字符串 }原因很简单:nextInt()只读取了整数,并不会把用户按下回车时产生的换行符消耗掉。紧接着第一次nextLine()读到的就是那个残留的换行符前面的空字符串。结果数组的第一个元素变成"",排序后空字符串会被排在最前面,输出格式完全错误。
诊断方法也简单,打印一下数组内容就能看出来。
两种解决办法:
- 在
nextInt()之后紧接着调用一次sc.nextLine()把残留的换行符“吃掉”。 - 直接用
int n = Integer.parseInt(sc.nextLine()),一步到位。
我个人偏爱第二种,因为更贴近“按行读取”的思维模式。
5.2 字符串比较用了 ==,结果永远排不出正确顺序
Java 中==比较两个字符串引用是否指向同一个对象,而compareTo或equals才是比较字符串内容。如果你在手写快排时写成了arr[i] == pivot,很可能在交换时漏掉本应处理的相等元素。如果你在排序后想检查是否排对了,也别用words[i].equals(words[i + 1])来判断相邻是否相等,因为排序结果有序不意味着没有重复。
5.3 空指针和空字符串
如果输入中包含空行,sc.nextLine()读到的就是空字符串""。排序时它当然也会参与排序,而且会排在所有正常单词之前。如果你的题目要求忽略空白行,最好在读入时做一次isEmpty()判断。还有一种情况是数组某些位置没有赋值,也就是null,此时直接调用Arrays.sort会在比较时抛NullPointerException。遇到这种报错,第一反应应该是检查输入行数是否和数据行数对不上。
5.4 把平台的最小/最大数据规模想得太简单
有些题目会给出n = 10^5,很多同学会担心自己写的排序会不会超时。实际上,对于十万个字符串,使用Arrays.sort几乎没有压力,瓶颈反而是输入输出。如果你在循环里用System.out.println一次打印一个结果,十万行也是完全没问题的。但如果你在极端情况下遇到很大的输出,可以考虑用StringBuilder拼接后一次性输出。
5.5 常见错误速查表
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 排序后第一个元素是空字符串 | nextInt()后的换行符被nextLine()读到 | 使用Integer.parseInt(sc.nextLine()) |
| 输出大小写顺序和预期不一致 | 没有考虑 ASCII 码值大小 | 需要时使用String.CASE_INSENSITIVE_ORDER |
| 手写快排出现栈溢出 | 基准选择不当或重复元素未正确划分 | 调整递归边界条件,增加i++、j-- |
抛NullPointerException | 数组中有未赋值的元素 | 检查读取循环是否覆盖所有下标 |
| 输出为空白 | 单词可能包含空格或换行符残留 | 打印前输出长度或使用Arrays.toString |
5.6 为什么明明答案对,平台却报错
如果本地运行完全正常,提交到在线判题平台却报错,常见原因有三个:
- 类名不是题目要求的
Main,导致编译不通过; - 输出格式多了或少了空格、换行;
- 没有在必要时退出输入读取循环,导致程序一直等待额外输入而超时。
从这道题的角度看,最需要注意的是类名。很多平台要求主类名必须是Main,你把类名写成WordSort就会编译错误。建议提交前看一眼题目说明。
6. 从单词排序出发,还能学会什么
6.1 中文单词排序比你想的更复杂
英文字母排序有固定的 ASCII 码顺序,但中文排序没有统一的 Unicode 音序规则。如果单词里有中文,比如“苹果”“香蕉”“橘子”,直接用compareTo是按 Unicode 码值排序,得到的结果可能是“橘、苹、香”这种并非汉语拼音顺序的结果。如果确实要按拼音排序,需要借助java.text.Collator:
import java.text.Collator; import java.util.Arrays; import java.util.Locale; Collator collator = Collator.getInstance(Locale.CHINA); String[] words = {"苹果", "香蕉", "橘子"}; Arrays.sort(words, collator);这个问题可以作为扩展思考,但“7-2 单词排序”这类基础题一般不会涉及。
6.2 并行排序在大数据量下的表现
Arrays.parallelSort是 Java 8 起提供的方法,它利用多线程并行处理,在数据量非常大时能获得比普通sort更好的性能。但需要注意它的开销:如果数据量只有几百或几千,并行排序的线程调度成本反而会让性能变差。所以在单词排序这种典型的小规模场景,不建议使用parallelSort。
6.3 稳定性到底重不重要
排序算法的稳定性是指:如果两个元素相等,它们在排序前后的相对次序是否保持不变。对于本题来说,所有单词如果字典序相同,内容也完全相同,所以稳定性没有意义。但如果你扩展成“按单词长度排序,长度相同保留原始顺序”,那稳定排序就很重要。这正好能解释为什么Arrays.sort对对象类型采用 TimSort 而不是双轴快排,因为很多实际场景要求稳定。
6.4 把这一题当成一切排序题的母题
只要你把这道题真正吃透了,很多题目都可以基于它改出来:
- 输入从单词变成整数:
Arrays.sort对int[]直接用; - 输入从单词变成包含多个字段的对象:定义
Comparator即可; - 要求按出现频率排序:先用
HashMap统计,再对Map.Entry排序; - 要求从文件读取:只需要把
System.in换成FileInputStream。
很多看起来复杂的算法题,都是这种“小题目”加上一层包装。把基础打牢,后面学高级内容就会轻松得多。
写在最后:我的一点实际操作体会
这道题我已经帮很多同学看过,也自己手敲过好几版代码。最想提醒的是:不要因为题目简单就只记结论,比如“反正用Arrays.sort就对了”。倒不如静下心把Scanner输入、字符串compareTo、快速排序的指针移动逻辑都过一遍。这几样东西在后面的编程学习里会用得非常频繁。
如果让我给一条最实际的经验,那就是:写代码之前,先在纸上把输入数据写出来,再把期望输出写出来,然后才动手写程序。这个习惯能帮你避开八成以上的低级错误。等你把这种思路内化了,再遇到任何“排序题”,都不会慌。