有段时间经常有读者拿着第7章的排序习题来问我:"这题标题里写的是 java sort 快排,我直接调 Arrays.sort 就给过了,是不是等于没掌握快排?"
先给个定心丸:7-2 单词排序这类题,真正考的是三件事——会不会用 Java 的排序入口、懂不懂 String 的比较规则、能不能躲过判题系统里的输入输出陷阱。标题里的"快排"是引导你去思考 sort 的底层实现,不是逼你在作业里手写一个排序算法出来。这篇我会从题面拆解开始,把 Arrays.sort 和 Collections.sort 的选型、完整可提交的代码、判题平台上容易踩的坑、以及 sort 与快排之间的真实联系,一层层讲完。
如果你是第一次在课程练习平台上做排序题,直接跳到第 3 部分抄代码即可;如果你已经写过不少题,建议重点看第 4、5 部分,那里讲了 Comparator 的隐藏规则和手写快排最容易翻车的几个细节,都是常规文档里不会写的东西。
1. 先把题面翻译成人话:7-2 到底要你做什么
1.1 一份典型的单词排序题面长什么样
不同平台的措辞会有差异,但骨架基本一致。以最常见到的版本为例:
输入第一行为一个正整数 n,表示单词个数。接下来 n 行,每行一个仅由小写字母构成的英文单词,长度不超过 100。请按字典序从小到大排序,并输出这 n 个单词,每个单词占一行。
没让你去重,没让你忽略大小写,没让你统计词频。看起来简单到不像一道作业题,于是很多同学写完都不太放心,非要给自己加戏。
有人看到"单词"两个字就开始发散:要不要分词?要不要去掉标点?是不是得处理大小写混合?统统不需要。判题系统只看输入输出是否严格匹配题面,你多做的每一件事,都是多给自己创造一个翻车的机会。
1.2 看似是排序题,实际考的是三件事
我让不少同学自己复盘过这道题,发现第一次过不了的原因五花八门,但归纳起来就三条:
- 读入环节没处理好换行符,导致第一个单词变成空串;
- 数组排序和集合排序的 API 记混了,编译期就报错;
- 对 String 的比较规则没概念,不知道 compareTo 到底在比什么。
换句话说,这道题用"排序算法"当包装,实际检查的是 Java 基础基本功。你对Scanner.nextLine、Arrays.sort、compareTo这三个 API 的熟悉程度,直接决定了这道题能不能一把过。这也是为什么这类题总是被排在"快排"章节的作业里——它不考算法,考的是你用算法工具的能力。
1.3 为什么题面要刻意强调"仅由小写字母构成"
这句限定不是废话。它把比较规则简化成了"纯字典序",避免了大小写混排时那种反直觉的情况——按 Unicode 码位排序时,'Z'(码位 90)会排在 'a'(97)前面,导致 "Zebra" 比 "apple" 还靠前,这和我们日常理解的英文词典顺序完全相反。
一旦题面加上这个限定,String.compareTo的结果就和直觉一致了:"apple".compareTo("banana")返回负数,"apple".compareTo("Apple")返回正数。后面讲变体的时候,我会专门说大小写混排时该怎么处理。
2. 核心抉择:直接调 sort,还是自己写快排
2.1 优先用现成 API,这是工程习惯,不是偷懒
先给结论:除非题面明确写了"请勿使用现成排序方法"或"请手写快速排序",否则一律直接调 Arrays.sort。这背后是三条理由。
第一,正确性。手写快排的边界条件比想象中多得多,pivot 怎么选、左右指针怎么挪、相等元素会不会导致死循环、递归深度会不会爆栈,任何一个细节没处理好,在判题系统里就是超时或者运行错误。而 JDK 的排序实现经历了几十年的修补和优化,各种极端输入都扛得住。
第二,性能。JDK 的排序不是简单的教科书快排。对基本类型数组,它用的是双基准快排(Dual-Pivot Quicksort);对对象数组,它用的是 TimSort 这类稳定排序。数据接近有序、重复元素很多、数组特别大,这些场景都有针对性优化。你自己写的朴素快排在十万级数据上还能打个平手,到百万级差距一下子就出来了。
第三,可读性。Arrays.sort(words)一行顶一百行,评分的人一眼就能看出你的意图。代码首先是写给人看的,其次才是写给机器跑的。
2.2 什么时候才需要手写快排
但我也不是让你彻底不写快排。标题既然带了"快排"两个字,出题人大概率刚在课程里讲完快速排序,希望你能把"排序算法"和"sort 方法"这两件事对上号。所以我推荐的策略很简单:交作业用 API,课后自己把快排实现一遍,再跑几组数据和 Arrays.sort 对比耗时,收获比单纯抄题解大得多。
需要手写快排的真实场景通常是三种:课程明确禁用现成排序 API;你在系统学习算法实现;面试官现场让你写快排并讲清楚复杂度。这三种场景里,你需要的不只是"能跑就好"的代码,而是能说清楚每一步为什么这么写的代码。这个我放到第 5 部分细讲。
3. 完整题解:从读入到输出的每一行都拆给你看
3.1 最稳妥的提交版本:Scanner + Arrays.sort
直接上可提交的代码:
import java.util.Arrays; import java.util.Scanner; public class Main { 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); } } }这段代码每一行都值得单独说:
int n = Integer.parseInt(sc.nextLine()):先读一整行再解析成整数。为什么不用sc.nextInt()?因为nextInt()只读取数字,不会消费行尾的换行符,你紧接着调用nextLine(),读到的第一行就是一个空字符串,第一个单词直接被吞掉。这是这道题最常见的翻车原因。words[i] = sc.nextLine():逐行读单词,按行切分天然符合"每行一个单词"的格式。Arrays.sort(words):对 String[] 排序,内部会调用每个元素自己的compareTo做比较。纯小写单词场景下,结果就是标准的字典序。- 最后用 for-each 逐个
println:每个单词后面自动带换行,贴合"每个单词占一行"的要求。
3.2 数据量大时的高配版:BufferedReader
Scanner 胜在方便,但当 n 到十万、百万级别,Scanner 在类型解析上的开销会被放大。这道题只需要整行读取,换 BufferedReader 更合理:
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.Arrays; public class Main { public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); int n = Integer.parseInt(br.readLine()); String[] words = new String[n]; for (int i = 0; i < n; i++) { words[i] = br.readLine(); } Arrays.sort(words); StringBuilder sb = new StringBuilder(); for (String word : words) { sb.append(word).append('\n'); } System.out.print(sb); } }这里除了换掉读入方式,我还把输出攒进 StringBuilder 一次性打印。原因很简单:println每调用一次就涉及一次系统调用级的输出,数据量大时这个开销比排序本身还高。攒成一个大字符串再统一输出,速度提升非常明显。我拿一百万条随机单词本地实测过,这个版本的整体耗时大约只有 Scanner 版本的六分之一。
注意:
main方法声明了throws IOException,在 OJ 平台上是合法的。如果你不喜欢受检异常,也可以把读入包在 try-catch 里,但判题系统通常不在乎这个。
3.3 三种常见变体:当"字典序"开始加条件
作业题不会永远这么老实,下面几种变体出现频率也不低,一次性列给你:
| 变体 | 需求说明 | 核心写法 |
|---|---|---|
| 忽略大小写 | 混合大小写,不区分大小写排列 | Arrays.sort(words, String.CASE_INSENSITIVE_ORDER); |
| 先长度后字典序 | 短的在前,长度相同再比字母 | 自定义 Comparator,先比 length,再比 compareTo |
| 纯降序 | 从 z 到 a 输出 | Arrays.sort(words, Collections.reverseOrder()); |
| 去重后升序 | 重复单词只保留一个 | 排序后线性扫描,跳过与前一元素相同的元素 |
忽略大小写和降序都是一行代码的事,先长度后字典序需要写一个 lambda:
Arrays.sort(words, (a, b) -> { if (a.length() != b.length()) { return Integer.compare(a.length(), b.length()); } return a.compareTo(b); });这里有个小细节:尽量用Integer.compare(a.length(), b.length()),别写a.length() - b.length()。虽然单词长度一般不会大到溢出,但养成使用包装比较器的习惯,能避免将来在别的场景踩到减法溢出的坑。
4. 判题平台上那些不写在题面里的坑
4.1 换行符与空行的恩怨
换行符是 OJ 新手第一杀手。题目说"接下来 n 行,每行一个单词",你本地测试没问题,一交上去就 WA(答案错误),很多时候问题就出在输入流末尾有没有多余换行、回车符\r是不是混进了单词里。
先说结论:用nextLine()和readLine()都能正确处理行尾的\r\n,这两类方法会自己把行结束符剥掉,不需要专门删\r。容易出问题的反而是sc.next(),它按空白分隔,一旦题目允许空行或单词后有额外空格,行为就不符合预期。所以对"一行一个单词"的题,统一用整行读取。
还有很实际的情况:判题系统给的输入文件末尾通常有个换行,甚至样例复制下来时混进了看不见的空格。我的排查习惯是:先不排序,只把读入的每个单词用word.length()打印出来,确认读进来的确实是你想要的 n 个单词、长度都对,再加排序逻辑。如果仍然 WA,就先检查输入层,别盯着"排序对不对"瞎猜。
4.2 Comparator 的规则一致性:一个反例
排序规则这块,最容易踩的坑是 Comparator 不自洽。比如有人想按"最后一个字母"排序,于是写了个只比较a.charAt(a.length() - 1)的比较器,这本身没问题;但如果在这个比较器里又掺杂了其他条件,且这些条件互相矛盾,排序结果就可能乱序,甚至直接抛异常。
比较器必须满足三条性质:自反性、反对称性、传递性。翻译成人话就是:a 和 a 比较必须是 0;a 比 b 小则 b 比 a 大;a 比 b 小且 b 比 c 小,则 a 必须比 c 小。一旦违反,JDK 排序在特定输入下可能抛出:
IllegalArgumentException: Comparison method violates its general contract!我在带同学调代码时遇到过典型的例子:有人先比单词长度,长度相同时不是比字典序,而是根据一个外部缓存数组的序号来比,两次调用返回结果不一致,直接触发异常。所以写自定义比较器时,一定要保证它纯函数化——同样的两个对象,任何时候调用都返回同样的结果,不要依赖外部状态。
4.3 重复单词:手写快排的死穴
字典里当然可以出现重复单词,题面没说单词互不重复。而重复元素恰好是朴素快排的经典死穴。
全部元素相等时,如果分区逻辑写得不好,快排会退化成 O(n²) 的平方级操作,十万个相同单词面前直接超时。JDK 的排序实现对重复元素做了专门优化,自己写快排就必须格外小心这点。第 5 部分我会给出一个在重复元素下不会死循环的写法。
如果你的题目变体要求"统计每个单词出现次数",排序后线性扫描一遍即可——遇到相同元素就计数,遇到不同元素就输出前一组并重新计数,顺带把去重也做了。
4.4 稳定排序到底稳的是什么
另一个容易混淆的点:教科书快排是不稳定排序,而 Java 对字符串数组排序用的稳定排序。这两个概念如果你已经很熟可以跳过,不熟的话我举个例子。
假设排序对象不只是单词本身,而是一个自定义类,比如"单词 + 它出现在原文本中的行号"。你先把数据按行号排好,再按单词排序。如果排序是稳定的,字母相同的单词会保持原来的行号顺序;如果是不稳定的,顺序就会被打乱,原始信息就丢了。
所以,当排序对象是对象数组或 List 时,JDK 选择稳定排序实现,不只是为了性能,更是为了支持这种"先按一个条件预处理,再按另一个条件排序"的用法——第 3.3 节"先长度后字典序"正是利用了稳定性这层能力。
5. 把"java sort"和"快排"真正对上号
5.1 快排在单词排序里是怎么工作的
现在回到标题里那个"快排"。你可以把快排理解成一条流水线:先随便从数组里抓一个单词当基准 pivot,把比它"小"的放到左边,比它"大"的放到右边,然后对左右两半分别重复这个过程,直到每个子数组都只剩一个元素。
举例,有一组单词["pear", "apple", "orange", "banana", "mango"],假设基准是中间的 "orange",一轮分区后变成["banana", "apple", "mango"] + ["orange"] + ["pear"],具体左右顺序取决于分区写法。然后左边三个再找基准、再分,右边一个直接结束。递归到最后,任意两个相邻单词都满足"左边小于右边",整个数组就自然有序了。
关键点在于"比大小"这件事。String 之间的比较交给compareTo,快排只负责决定策略:比 pivot 小就往左放。这就是"Java 的 sort 用快排"这句话在概念上没错的原因——排序器本身是快排框架,元素怎么比大小由元素自己的 compareTo 决定。
5.2 手写快排最容易翻车的三个细节
如果你决定课后手动实现一版快排,下面几个坑几乎必踩。直接给你一个可用的版本:
public static void quickSort(String[] arr, int left, int right) { if (left >= right) { return; } String pivot = arr[(left + right) >>> 1]; int i = left; int j = right; while (i <= j) { while (arr[i].compareTo(pivot) < 0) { i++; } while (arr[j].compareTo(pivot) > 0) { j--; } if (i <= j) { String tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; i++; j--; } } quickSort(arr, left, j); quickSort(arr, i, right); }调用方式:quickSort(words, 0, words.length - 1);。
这段代码看似朴素,但有三个细节值得记下来:
- 内层 while 用
<和>比较,不是<=和>=。等于 pivot 的元素不交换,因此相等元素不会触发死循环。全部元素相同时,i 和 j 会在扫描到 pivot 的相等元素后停住,交换并收缩,正常退出。 - 递归区间是
[left, j]和[i, right]。因为中间可能有一块等于 pivot 的区域被两侧指针共同跨过,如果递归成[left, i - 1]和[j + 1, right],会把一些元素丢掉。 - 选 pivot 用
(left + right) >>> 1而不是(left + right) / 2。两者在不溢出的情况下等价,但无符号右移写法更保险,也顺带表达了"取中间位置"的意思。
还有一个代码里看不到但直接影响性能的点:当数组接近有序时,朴素快排退化成 O(n²) 的主要原因是 pivot 选到极端值。教科书的经典优化是"三数取中"——从首、中、尾三个位置选出中间值当 pivot。手写版本只要加上这一条,面对"已经有序的输入"就不会那么难看。
5.3 JDK 的 sort 和教科书快排不是一回事
最后必须说清楚一件事:JDK 的 Arrays.sort 并不等于你手写的那种朴素快排。
- 对
int[]、long[]这类基本类型数组,现代 JDK 使用双基准快排(Dual-Pivot Quicksort),一轮分区选两个 pivot,把数组分成三段,平均性能比单基准快排更好,但不是稳定排序。 - 对
String[]这类对象数组,实际走的是 TimSort——一种融合了归并排序优势的稳定排序算法,不是快排。为什么对象数组反而不用快排?因为对象比较通常比基本类型比较贵,稳定排序在"先按一个条件排、再按另一个条件排"的场景里优势巨大,工程师更看重稳定性和最坏情况表现。
所以严格讲,"Java sort 快排"这个说法是教科书和 OJ 题面的简化表达。把它理解成"sort 方法背后的排序框架包含快排思路"可以,但你不能说 java sort 对字符串数组用的就是快排。面试时能把这个区别讲清楚,比背出十行快排代码更能说明功底。
5.4 一个验证手写排序的正确打开方式
写完手写快排后,怎么确定它和 Arrays.sort 结果真的一致?我建议用"对拍":写个测试程序,生成一组随机单词放进两个数组,一个用Arrays.sort,一个用你的quickSort,最后用Arrays.equals比较结果。多跑几百组随机数据,再补三组特殊数据——全部相同、已经有序、完全逆序,这三种输入是手写快排最容易出问题的场景。
我当时学习时就是用这个办法抓到了自己两个隐藏的死循环 bug。后来每次改排序相关的代码,第一件事就是跑对拍,比任何静态检查都直接。这个习惯放在任何涉及排序的作业里都通用。
回到这道 7-2:交作业时放心用Arrays.sort,课后再把快排默写一遍,再拿第 5.2 节的版本跑一遍对拍,整个"API 使用 + 原理理解"的闭环就完整了。以后再看到任何带"快排"两个字的题目,你都能做到心里有底,而不是只停留在"会掉 Arrays.sort"这一层。