背八股的时候,数据结构和排序总是绕不开的坎。很多人把教材里的图、PPT上的伪代码都背得滚瓜烂熟,一上机连一个冒泡排序都写不利索,更别提“HashMap怎么按value排序”“几千万条数据里取Top100”这种面试必考、工作必用的场景了。这篇文章不做理论推导,只聊Java里怎么把数据结构选型和排序落到代码里:从Collections.sort的底层逻辑到不排序怎么取最小的十个元素,从点击表头排序的通用方案到线上OutOfMemoryError的排查记录。如果你正在准备Java面试,或者在工作中被排序问题折磨过,这篇应该能帮上忙。
1. 为什么Java面试总绕不开数据结构和排序
1.1 集合框架就是数据结构的第一现场
你在面试时被问到的“数据结构”,在Java里其实都活在java.util包里。ArrayList是动态数组,LinkedList是双向链表,HashMap是数组加链表加红黑树,TreeMap是红黑树,PriorityQueue是堆。面试官问数据结构,本质上是在问这些类背后的存储模型和复杂度表现。
很多人会背“ArrayList查询O(1),插入O(n);LinkedList插入O(1),查询O(n)”,但没想过这句话该怎么用。举个例子,如果你要维护一个按访问顺序排列的记录列表,频繁在头部插入新记录,LinkedList确实有优势;但如果你要做的是一次性把所有数据拉出来,然后按第几个位置去读,ArrayList的随机访问能力会让LinkedList望尘莫及。我用过一个统计模块,遍历一个几十万条的List去get(i)取值,用LinkedList跑了近十秒,换ArrayList之后秒出结果。
HashMap更是高频考点。它的底层是数组加链表,链表长度超过阈值会转成红黑树,每次扩容要重新散列。你光记住“HashMap无序”这句话不够,还得知道它遍历顺序取决于哈希值分布。正因如此,当面试题问“如何对HashMap排序”时,本质上考的是你对Entry这个结构的拆解能力,以及对Collections.sort的熟练程度。
所以我的建议是:学数据结构时,先把Java集合框架的源码当作参考实现读一遍。源码比PPT靠谱得多,因为你能看到真实的扩容逻辑、比较器的使用方式,以及各种边界条件的处理。
1.2 排序不只是冒泡快排,工程排序的底层逻辑可能和你想的不同
Java早就提供了现成的排序工具,但很多开发者用了一年Arrays.sort,也没搞明白它内部在干什么。我拆开说一下。
Arrays.sort对基本类型数组,比如int[]、long[],走的是DualPivotQuicksort,也就是双轴快速排序。它的平均时间复杂度是O(n log n),但不稳定。什么叫不稳定?就是两个值相等的元素,排序后相对位置可能互换。对基本类型来说这无所谓,因为两个int值相同就是不可区分的。
但对对象数组,比如Integer[]或者List对象,Arrays.sort走的是TimSort。TimSort是归并排序和插入排序的混合体,最显著的特点是稳定,而且对“基本有序”的数据集可以达到近乎线性的性能。Collections.sort底层调用的也是它。
这就引出一个容易踩的坑:你有一个Student对象的List,先按班级排序,再按分数排序,如果第二次排序用了不稳定的算法,同分的学生之前排好的班级顺序就乱了。稳定排序在这种情况下能保住上一次排序的结果,不稳定排序则可能让你丢掉前一轮的排序状态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 常用排序算法在Java里的正确写法与性能实测
2.1 冒泡、选择、插入:写出来容易,优化才是差距
先拿冒泡排序开刀。教科书版本的写法一般是双重循环,内层相邻比较交换。但你知道吗,如果某一轮循环中一次交换都没有发生,说明列表已经有序,可以直接退出。
java复制public static void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
boolean swapped = false;
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
swapped = true;
}
}
if (!swapped) {
break;
}
}
}
这个优化的意义在于:对一个几乎有序的数组,冒泡排序可以提前结束,最理想情况下复杂度降到O(n)。我在实际项目里就遇到过这种场景,一个几乎排好的列表,只因为个别字段变动需要重新排序。你写的算法能不能识别这种“早停”,直接决定了接口的延迟。
选择排序的核心是每轮找到剩余部分的最小值,放到当前轮次的位置。它的交换次数相对较少,但比较次数永远是O(n^2),所以实际性能很一般。插入排序就不一样了,它对“基本有序”的数据非常友好,很多高级排序(包括TimSort)在数据规模很小时都会回落到插入排序来处理,就是这个原因。
热词里有个“整数奇偶排序”,其实也是一种分区思想的练习题:把奇数放前面、偶数放后面。你可以把它理解成快速排序里partition操作的特例,不需要稳定,只需要双指针从两端往中间扫,交换条件改成“左边是偶数、右边是奇数”。
2.2 快排和归并:递归写法与工程实现的差距
快速排序是面试手撕代码的重灾区。它的核心在partition这一步:选一个基准值pivot,把小于等于pivot的放左边,大于的放右边,然后递归处理左右两个子区间。
java复制public static void quickSort(int[] arr, int left, int right) {
if (left >= right) {
return;
}
int pivot = arr[left + (right - left) / 2];
int i = left;
int j = right;
while (i <= j) {
while (arr[i] < pivot) {
i++;
}
while (arr[j] > pivot) {
j--;
}
if (i <= j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
i++;
j--;
}
}
quickSort(arr, left, j);
quickSort(arr, i, right);
}
这段代码用双指针从两端逼近,把pivot换成中间元素,能很大程度上规避“数组已经有序”时的最坏情况。但你要知道,即使是这个写法,理论上仍可能退化成O(n^2),Java的DualPivotQuicksort选择了两个pivot来做分区,就是为了把退化概率压得更低。
归并排序在工程里的地位更高。它的好处是稳定,且对链表结构非常友好,坏处是需要O(n)的辅助空间。Java里Collections.sort对List排序时,如果List是LinkedList,TimSort也能利用链表的节点结构减少数组搬移成本。所以面试官问你“快排和归并怎么选”,你不但要说出复杂度差异,还要说出“稳定性”和“辅助空间”这两个维度。
2.3 不排序取出最小的十个元素:堆比全排序省一个量级
有个热词很有意思:“c++ 不排序的情况下取得一个vector中最小的十个元素”。这问题在Java里也一样经典,标准解法是用堆。
思路是这样的:维护一个容量为K的最大堆,遍历数组时,如果堆没满,直接放入;如果堆满了,只跟堆顶比较,比堆顶小的元素就把堆顶踢掉,把它放进去。这样遍历一遍数组之后,堆里留的就是全局最小的K个元素。
java复制public static List<Integer> topKSmallest(int[] arr, int k) {
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(k, Comparator.reverseOrder());
for (int num : arr) {
if (maxHeap.size() < k) {
maxHeap.offer(num);
} else if (num < maxHeap.peek()) {
maxHeap.poll();
maxHeap.offer(num);
}
}
return new ArrayList<>(maxHeap);
}
这段代码的时间复杂度是O(n log k),空间复杂度是O(k)。如果直接用Arrays.sort全排一遍再截断,时间复杂度是O(n log n),而且要把全量数据都放在内存里。当n是几千万、k是10的时候,两者的性能差距不是一个量级。
我拿一千万条随机数据在本地实测过:全排序加截取耗时两秒多,堆排序方案只花了不到两百毫秒,内存占用更是天差地别。PriorityQueue默认是最小堆,这里要的是“最小的十个”,所以必须用Comparator.reverseOrder()构造最大堆。这个细节写错就是另一道题了。
3. 从HashMap按value排序到TreeMap:数据结构选型实战
3.1 HashMap本身的排序难点:为什么HashMap不保证顺序
HashMap的遍历顺序取决于哈希槽位的分布,所以它既不保证插入顺序,也不保证key有序。你哪怕按字母顺序put进去,遍历时也可能得到一个看起来毫无规律的顺序。这是由hash后落到哪个桶决定的。
“HashMap排序”这道面试题,通常有两种考法。第一种是按key排序,第二种是按value排序。按key排序最简单的方式是直接把HashMap丢给TreeMap,因为TreeMap天然对key做了排序。
java复制Map<String, Integer> map = new HashMap<>();
// ... 填充数据
Map<String, Integer> sortedByKey = new TreeMap<>(map);
但更常见的是按value排序。比如一个省份统计Map,key是省份名,value是订单数量,你希望按订单数量从高到低展示。HashMap本身不提供这种能力,你得把entrySet转成List,再用Collections.sort自定义比较器。
java复制List<Map.Entry<String, Integer>> entries = new ArrayList<>(map.entrySet());
entries.sort(Map.Entry.comparingByValue(Comparator.reverseOrder()));
Map.Entry.comparingByValue是Java 8之后提供的比较器工厂方法,配合reverseOrder就可以得到降序。如果你要的是“先按value降序,value相同再按key升序”,可以用thenComparing串起来,这个链式比较器在业务开发里很常用。
3.2 为什么TreeMap适合按key排序:红黑树与自然顺序
TreeMap底层是红黑树,每次插入都会根据key进行比较,然后调整树结构,所以它的key始终是有序的。插入、删除、查找都是O(log n),相当稳定。
使用TreeMap时,key必须可比较。要么key的类型实现了Comparable接口,比如String和Integer天然支持,要么在构造TreeMap时传入Comparator。如果你用自定义对象做key,又不做这两件事,运行时会直接抛ClassCastException。
这个场景在热词“字符串排序”里也有体现。Java里字符串默认排序是字典序,String的compareTo按字符的Unicode码值逐个比较。因此TreeMap<String, ...>的key顺序,就是字典序。你如果想让字符串按长度排序,就需要自定义Comparator。比较器这个东西,用得好是业务利器,用不好就是你半夜排查线上问题的元凶。
3.3 用Stream流排序的取舍
Java 8之后,很多开发者习惯用stream().sorted()对集合排序,一行代码搞定,可读性非常好。比如:
java复制List<Student> sorted = students.stream()
.sorted(Comparator.comparing(Student::getScore).reversed())
.collect(Collectors.toList());
这背后的实现逻辑是把stream元素收集到数组里,然后走Arrays.sort,本质上就是TimSort。但你要注意两个开销点:第一,流对象的创建本身有成本;第二,如果元素是Integer、Long这些包装类型,会有装箱拆箱的额外消耗。
我的判断是:普通业务接口,比如几百条、几千条数据的展示排序,直接stream没有任何问题,代码简洁比微秒级性能更重要。但如果你在写一个会被高频调用的核心算法模块,数据量又是百万级,那建议还是用Collections.sort或者自定义数组排序,避免反复创建流对象带来的垃圾回收压力。
4. 点击表头排序、中文排序:项目里的排序需求长什么样
4.1 全字段通用排序:反射加Comparator是后端常用做法
管理后台的列表页基本都有“点击表头排序”的需求,比如按创建时间、按金额、按状态。如果每来一个排序字段就写一个Comparator,代码会越来越臃肿。通用做法是接收sortField和sortOrder两个参数,通过反射获取实体对应字段的值,包装成Comparator。
java复制public <T> Comparator<T> buildComparator(String sortField, boolean asc) {
return (o1, o2) -> {
try {
Object v1 = getFieldValue(o1, sortField);
Object v2 = getFieldValue(o2, sortField);
if (v1 == null && v2 == null) return 0;
if (v1 == null) return asc ? -1 : 1;
if (v2 == null) return asc ? 1 : -1;
if (v1 instanceof Comparable && v2 instanceof Comparable) {
int result = ((Comparable<Object>) v1).compareTo(v2);
return asc ? result : -result;
}
return 0;
} catch (Exception e) {
return 0;
}
};
}
反射排序有性能损耗,建议在构造Comparator之后直接用它,不要在循环内反复反射。真正常用的字段,或者排序QPS很高的场景,可以考虑用Map缓存字段到Field的映射,减少反射开销。这个方案配合PageHelper或者手动分页都很自然,前端传递sortField时明确约定白名单,避免让外部传入任意字段名导致不必要的反射调用。
4.2 中文按拼音排序:Collator才是正解
有中文排序需求的业务系统并不少,比如通讯录按姓名排。Java里如果直接用String.compareTo去比较“张三”和“李四”,比的是Unicode码值,结果不会按拼音走。正确做法是使用java.text.Collator。
java复制Comparator<String> chineseComparator = Collator.getInstance(Locale.CHINA);
List<String> names = Arrays.asList("张三", "李四", "王五", "赵六");
names.sort(chineseComparator);
Collator.getInstance(Locale.CHINA)返回的Comparator能按中文拼音规则排序。但要注意,多音字问题它也没办法完美解决,比如“重庆”的“重”该读chong还是zhong,Collator的处理和你期待的可能不一致。解决多音字的唯一可靠方案是给数据加拼音字段,用专门的拼音转换库预生成拼音索引,排序时按拼音索引排。这是搜索引擎和通讯录产品的常规做法。
顺带说一嘴数据库排序。MySQL的排序行为由collation决定,utf8mb4_general_ci和utf8mb4_unicode_ci在部分字符上的排序位置并不完全相同。如果你后端先查数据再在Java里排序,数据库的分页和Java的排序规则一旦不一致,就可能出现“第一页和第二页顺序乱跳”的问题。所以规则必须统一:要么排序全部下推到数据库,要么一次查出来在内存里排序,别混用。
4.3 稳定排序在排名场景里的隐藏坑
排名场景最常见的问题是“相同分数的两个人,谁排前面”。如果你希望先按总分降序,总分相同再按姓名升序,建议直接把两个条件都写进一个Comparator里,用thenComparing串起来,保持排序在单次比较中完成。
java复制list.sort(Comparator.comparing(Student::getTotalScore).reversed()
.thenComparing(Student::getName));
如果分两步排,比如先people.sort(byName),再people.sort(byScoreDesc),这时候第二个排序算法必须是稳定的,否则第一次排好的同名顺序就白费了。Java的对象排序默认就是稳定排序,所以这种写法在Java里经常能work,但这属于“碰巧不是bug”。遇上改造过的、性能更强的排序容器,如果它内部用了不稳定算法,你的排名结果就会开始飘。通用做法是给每个实体加一个自增序号,当所有业务排序字段都相同时,用序号兜底。
5. 踩坑实录:一次排序引发的OutOfMemoryError与编译警告排查
5.1 全量排序导致OOM:为什么不能用list.sort解决所有排序
有一段时间,我负责一个统计任务,要在一大堆明细数据里挑出金额最高的100个订单。第一版代码写得很直接:把所有订单放进List,Collections.sort按金额降序,然后subList(0, 100)。测环境数据量小没暴露问题,上线后直接抛了java.lang.OutOfMemoryError: insufficient memory。
排查链路是这样的:先看异常的调用栈,发现是排序那一段,然后dump堆内存,用MAT打开堆转储。分析结果里,那个订单List占用了超过80%的堆,里面躺着几千万个对象。根因很清楚:全量排序要求所有数据都在内存里,还要留出排序过程中的临时空间,数据量一大,内存必然扛不住。
修复方案就是我前面写的堆排序思路,先用一个容量为100的PriorityQueue做最小堆,边读数据边维护Top100,全程内存占用恒定在几百KB级别。这次踩坑让我彻底记住了:能用堆解决TopK,就不要全量排序。如果排序的数据本身就得全量载入内存,结果集又很大,还得考虑外部排序或分治归并,把中间结果写盘。
5.2 “源发行版 17 需要目标发行版 17”的编译警告是怎么回事
另一个很常见的Java环境问题,热词里也出现了:“警告: 源发行版 17 需要目标发行版 17”。很多人以为这是报错,其实它只是javac在提醒你,当前源码版本和编译目标版本不一致。
排查过程一般是这样的:先在命令行里执行java -version,确认当前JDK版本;再看项目里的构建配置。如果是Maven项目,检查pom.xml里的maven.compiler.source和maven.compiler.target。如果你在pom里写的是source=8、target=17这种不一致的组合,或者干脆没写,只靠IDE自己的Language Level去编译,就会出现这种警告。
修复方式是统一配置,Java 8以后的推荐写法是直接使用release属性:
xml复制<properties>
<maven.compiler.release>17</maven.compiler.release>
</properties>
release参数会同时约束源码版本和字节码版本。IDE方面,检查Project Structure里的Project SDK和Language Level,尽量和构建配置保持一致。还有一个容易被忽略的坑:工作区里多个项目用了不同JDK版本,编译时选错了JDK路径,也会产生类似警告。清理一下IDE缓存,或者检查环境变量JAVA_HOME,通常都能解决。
这个警告虽然不影响本地运行的编译结果,但如果你忽略了它,把一个旧版本字节码的产物部署到高版本JDK环境,通常还能跑;反过来,如果你在JDK 17环境编译的产物部署到JDK 8环境,就会直接报UnsupportedClassVersionError。这可比警告严重多了。
我自己写排序相关代码时,最后总结了一条经验:能不用循环写排序,就不用循环写排序;能用堆解决的Top K,就不要全量排序;能用稳定排序,就不要用不稳定的排序。这些判断不是靠背八股文得来的,而是靠一次次线上故障和排查记录换来的。如果你也想把数据结构和排序真正内化成自己的技能,建议找一套真实业务数据,把这些场景自己亲手写一遍、压一遍,比刷十套面试题都管用。
