各位 Kotlin 爱好者们,早上好,我相信各位已经特别精通排序了,毕竟谁没有刷过几个月 leetcode 呢。
但我依然希望你花五分钟时间,看看在 Kotlin 中,排序功能是如何完成的,在这个时代,没准你也会为你的 AI 伙伴优化几行代码,甚至在 AGENTS.md 文件中写上几句:如果使用排序,请使用如下方法。
排序
Kotlin 中给集合排序,看起来并不是什么复杂的事情:升序用 sorted(),降序用 sortedDescending(),按某个字段排序就用 sortedBy()。
如果继续往下看,就会发现“调整元素顺序”其实至少包含三件完全不同的事情:
- 排序:按照某种规则重新排列元素;
- 反转:把集合当前的顺序倒过来;
- 洗牌:随机打乱元素顺序。
这三类操作的结果、复杂度以及是否修改原集合都不一样。尤其是 sortedDescending() 和 reversed(),虽然有时看起来能得到相同结果,但它们表达的含义完全不同。
这篇文章就从最常用的 API 开始,一点点看进 Kotlin 标准库的实现。
排序、反转和洗牌
假设有下面这个集合:
val numbers = listOf(3, 1, 2)
分别调用三个函数:
println(numbers.sorted()) // [1, 2, 3]
println(numbers.reversed()) // [2, 1, 3]
println(numbers.shuffled()) // 每次结果都可能不同
sorted() 会根据元素的大小重新排序;reversed() 不关心元素大小,它只做一件事儿——把原来的顺序倒过来;shuffled() 则会生成一个随机排列。
因此,如果一个集合原本已经按照升序排列:
val sorted = listOf(1, 2, 3)
println(sorted.sortedDescending()) // [3, 2, 1]
println(sorted.reversed()) // [3, 2, 1]
两者碰巧得到了相同的结果。但只要原集合不是升序排列,这种等价关系就不存在了。
自然顺序排序
Kotlin 提供了两个最基础的排序函数:
sorted():按照自然顺序升序排列;sortedDescending():按照自然顺序降序排列。
例如:
val names = listOf("rock", "byte", "coding")
println(names.sorted())
// [byte, coding, rock]
println(names.sortedDescending())
// [rock, coding, byte]
这里的“自然顺序”,来自元素自身实现的 Comparable。
String、Int 等常见类型本身已经实现了 Comparable,所以可以直接调用 sorted()。如果是我们自己定义的类型,也可以通过实现 Comparable 给它定义一个默认顺序:
data class Developer(
val name: String,
val experience: Int,
) : Comparable<Developer> {
override fun compareTo(other: Developer): Int {
return experience.compareTo(other.experience)
}
}
现在,Developer 的自然顺序就是按照工作年限升序排列:
val developers = listOf(
Developer("compose", 5),
Developer("kotlin", 3),
Developer("android", 7),
)
println(developers.sorted())
// [Developer(name=kotlin, experience=3),
// Developer(name=compose, experience=5),
// Developer(name=android, experience=7)]
不过,实现 Comparable 意味着我们在表达:这个类型存在一个清晰、稳定并且足够通用的默认顺序。
如果排序规则只适用于某个页面或某个业务场景,就没有必要为了排序去修改数据类型。这时使用 Comparator 通常更加合适,后面会详细讲到。
还有一点需要注意:sorted() 和 sortedDescending() 都会返回新的 List,不会修改原集合。
val source = mutableListOf(3, 1, 2)
val result = source.sorted()
println(source) // [3, 1, 2]
println(result) // [1, 2, 3]
如果希望直接修改 MutableList 或数组,应该使用 sort()、sortDescending() 等原地排序函数。
Kotlin 的大部分函数,可以从英文命名方式来猜测其行为,例如 sorted() 表示返回的结果已经经过排序了,而 sort() 表示对当前结构进行排序。
sorted() 内部做了什么
下面是 Kotlin/JVM 标准库中 Iterable<T>.sorted() 的核心实现。为了方便阅读,这里省略了部分注解:
public fun <T : Comparable<T>> Iterable<T>.sorted(): List<T> {
if (this is Collection) {
if (size <= 1) return this.toList()
@Suppress("UNCHECKED_CAST")
return (toTypedArray<Comparable<T>>() as Array<T>)
.apply { sort() }
.asList()
}
return toMutableList().apply { sort() }
}
它并没有一上来就对所有 Iterable 使用同一种处理方式,而是先判断当前对象是不是 Collection。
Collection:先转数组,再排序
Collection 有明确的 size,标准库可以提前知道需要多大的存储空间。因此,当元素数量大于 1 时,它会沿着下面这条路径执行:
Collection -> Array -> 原地排序 -> List
具体可以分成三步:
- 调用
toTypedArray(),把集合复制到一个临时数组中; - 对这个临时数组调用
sort(),原地完成排序; - 调用
asList(),把排序后的数组包装成List返回。
asList() 不会再次复制数组,而是返回一个由该数组支持的只读 List 视图。由于这个临时数组没有暴露给调用者,所以从外部使用时,可以把返回值理解为一次独立的排序结果。
当集合只有 0 个或 1 个元素时,根本没有排序的必要,因此直接调用 toList() 返回。
这里仍然会创建一个结果列表,而不是简单地返回 this。这是因为 sorted() 的返回类型是只读 List,并且它不应该把原来的可变集合直接泄漏出去。
普通 Iterable:先收集到 MutableList
Iterable 只保证可以获取迭代器,并不保证能提前知道元素数量,也不保证底层数据支持随机访问。
因此,标准库会走另一条路径:
return toMutableList().apply { sort() }
它先完整遍历 Iterable,把所有元素收集到一个新的 MutableList 中,然后再原地排序。
无论走哪条路径,排序都不可能只看一部分数据。要确定一个元素最终应该放在哪里,就必须把参与排序的元素都拿到手。因此,排序本质上是一个需要保存全部数据的有状态操作。
Sequence
在很多情况下,Sequence 都很特殊。
Sequence<T> 并没有继承 Iterable<T>,它拥有自己的一套 sorted()、sortedBy() 和 sortedWith() 扩展函数:
val sequence = sequenceOf(3, 1, 2)
val result: Sequence<Int> = sequence.sorted()
Sequence.sorted() 返回的仍然是 Sequence,所以从 API 表面看,它是一个惰性的中间操作。不过,它同时也是一个有状态操作:真正开始迭代时,依旧需要先读取全部元素并完成排序,然后才能产出第一个结果。
这也意味着不要对无限序列调用 sorted()。因为它永远收集不完所有元素,自然也永远无法给出第一个排序结果。
这一点,非常重要!!!
快速排序
sorted() 并不总会进入双轴快速排序。实际上,在 JVM 上需要区分两类数组:
IntArray、LongArray等基本类型数组,JDK 常使用双轴快速排序等实现;Array<T>这样的对象数组,排序要求保持稳定,常见 JDK 实现使用 TimSort 一类的稳定排序算法。
Iterable<T>.sorted() 转换出来的是对象数组,因此更接近第二种情况。
当然,标准库源码最终采用哪一种具体算法,属于平台和 JDK 实现细节,未来也可能变化。对业务代码来说,更值得依赖的是 API 明确承诺的性质:Kotlin 的这些对象排序函数是稳定排序。
什么是稳定排序
稳定排序,各位可能比我懂得多了。
它的大致意思是:如果两个元素在比较器看来相等,那么排序之后,它们仍然会保持原来的相对顺序。
例如:
data class Developer(
val name: String,
val experience: Int,
)
val developers = listOf(
Developer("Alice", 5),
Developer("Bob", 3),
Developer("Charlie", 5),
)
val result = developers.sortedBy { it.experience }
结果为:
Bob(3), Alice(5), Charlie(5)
Alice 和 Charlie 的工作年限相同,所以它们排序后的先后关系没有发生变化。
这一点在多级排序和分阶段排序时尤其重要。不过在 Kotlin 中,如果规则明确,通常还是建议把多个条件写进同一个 Comparator,这样更容易读懂。
使用 sortedWith()
当对象没有自然顺序,或者当前场景需要一套临时规则时,可以使用 sortedWith():
val sortedByMultiple = developers.sortedWith(
compareByDescending<Developer> { it.experience }
.thenBy { it.name }
)
这段代码表达了两个排序条件:
- 先按照工作年限降序排列;
- 如果工作年限相同,再按照名字升序排列。
输出结果类似下面这样:
Developer(name=android, experience=7)
Developer(name=compose, experience=5)
Developer(name=kotlin, experience=3)
compareBy()、compareByDescending()、thenBy() 和 thenByDescending() 本质上都在帮助我们组合 Comparator。
如果手动编写,大概会是这样:
val comparator = Comparator<Developer> { left, right ->
val experienceResult =
right.experience.compareTo(left.experience)
if (experienceResult != 0) {
experienceResult
} else {
left.name.compareTo(right.name)
}
}
显然,使用标准库提供的比较器工厂更简洁,也不容易把升序和降序写反。
sortedWith() 的源码
sortedWith() 的实现和 sorted() 几乎一样:
public fun <T> Iterable<T>.sortedWith(
comparator: Comparator<in T>,
): List<T> {
if (this is Collection) {
if (size <= 1) return this.toList()
@Suppress("UNCHECKED_CAST")
return (toTypedArray<Any?>() as Array<T>)
.apply { sortWith(comparator) }
.asList()
}
return toMutableList().apply {
sortWith(comparator)
}
}
唯一真正发生变化的地方,是排序时多传入了一个 Comparator。
对于 Collection,它仍然会先转成数组,然后调用数组的 sortWith(comparator);对于普通 Iterable,则先转成 MutableList,再调用列表的 sortWith(comparator)。
这也是 Kotlin 标准库中很常见的一种设计方式:先提供一个能力完整的底层函数,再通过一些更易用的函数去组合它,而不是把同一套排序逻辑复制很多遍。
我们自己写的代码,最终也会形成一套基本逻辑,然后各个业务通过基本逻辑进行衍生、变化、发展。
更好用的 sortedWith() —— sortedBy
如果只是想按照某个字段排序,直接使用 sortedWith() 会稍微有些啰嗦:
developers.sortedWith(
compareBy { it.experience }
)
因此 Kotlin 又提供了 sortedBy():
val sortedByExperience = developers.sortedBy {
it.experience
}
它的源码非常简单:
public inline fun <T, R : Comparable<R>> Iterable<T>.sortedBy(
crossinline selector: (T) -> R?,
): List<T> {
return sortedWith(compareBy(selector))
}
sortedBy() 自己不负责排序,它只做了两件事:
- 使用
compareBy(selector)创建一个Comparator; - 把这个
Comparator交给sortedWith()。
因此,sortedBy() 会自动继承 sortedWith() 的实现和优化路径。
sortedByDescending() 也是同样的思路:
public inline fun <T, R : Comparable<R>>
Iterable<T>.sortedByDescending(
crossinline selector: (T) -> R?,
): List<T> {
return sortedWith(compareByDescending(selector))
}
至于 sortedDescending(),它同样没有重新实现一套降序排序逻辑:
public fun <T : Comparable<T>>
Iterable<T>.sortedDescending(): List<T> {
return sortedWith(reverseOrder())
}
reverseOrder() 会生成一个与自然顺序相反的 Comparator,再由 sortedWith() 完成真正的排序。
可以把它们的关系简单理解为:
sortedDescending() ─┐
sortedBy() ├─> 构造 Comparator ─> sortedWith()
sortedByDescending()┘
sortedBy() 的选择器可能执行很多次
sortedBy { it.experience } 看起来像是先把每个元素转换成 experience,然后再对这些值排序,但实际并不是这样。
compareBy(selector) 创建的比较器,在每次比较两个元素时,都会分别调用一次 selector。而一个元素在完整排序过程中通常会参与多次比较,所以 selector 也可能被重复执行很多次。
如果选择器只是读取一个字段,完全不必担心:
developers.sortedBy { it.experience }
但如果里面包含数据库查询、文件读取、复杂解析或昂贵计算,就不适合直接这样写:
items.sortedBy { calculateExpensiveKey(it) }
更稳妥的做法是先计算一次排序键:
val result = items
.map { item -> item to calculateExpensiveKey(item) }
.sortedBy { (_, key) -> key }
.map { (item, _) -> item }
这会增加一份中间数据,但能够避免排序过程中重复计算昂贵的键。
是否值得这样做,取决于集合大小和选择器的成本,这就依赖开发者经验了。
排序时如何处理 null
sortedBy() 的选择器可以返回可空类型:
data class User(
val name: String,
val score: Int?,
)
默认情况下,compareBy { it.score } 会把 null 视为比非空值更小,因此升序排列时 null 会出现在前面。
如果业务规则要求把 null 放到最后,可以显式指定:
val comparator = compareBy<User, Int?>(
nullsLast(naturalOrder())
) { it.score }
val result = users.sortedWith(comparator)
比起依赖默认行为,把 null 的位置写出来往往更容易理解,尤其是在比较规则比较复杂时。
编写 Comparator 时不要直接做减法
有些代码会使用两个整数相减来实现比较器:
// 不推荐
val comparator = Comparator<Developer> { a, b ->
a.experience - b.experience
}
我相信各位开发者们应该经常这样写。
对于普通的小数字,它看起来没有问题。但如果两个值接近 Int 的上下边界,减法可能溢出,最终得到错误的顺序。
应该使用 compareTo() 或 compareValues():
val comparator = Comparator<Developer> { a, b ->
a.experience.compareTo(b.experience)
}
除此之外,一个正确的比较器还应该保持规则一致。例如,如果 a > b、b > c,那么结果也应该满足 a > c。否则,排序结果可能不可预测,某些平台实现甚至会直接抛出异常。
reversed():反转的是当前顺序
排序讲完之后,再来看反转。
val list = listOf("kotlin", "Android", "skydoves")
val reversedList = list.reversed()
println(reversedList)
// [skydoves, Android, kotlin]
reversed() 不会比较任何元素。它只关心元素当前所处的位置,然后把第一个和最后一个交换、第二个和倒数第二个交换,直到走到中间。
Iterable<T>.reversed() 的核心实现如下:
public fun <T> Iterable<T>.reversed(): List<T> {
if (this is Collection && size <= 1) {
return toList()
}
val list = toMutableList()
list.reverse()
return list
}
可以看到,它的过程非常简单直接:
- 先处理 0 个或 1 个元素的情况;
- 调用
toMutableList()创建一个副本; - 对副本调用
reverse(); - 返回反转后的结果。
所以 reversed() 不会修改原集合:
val source = mutableListOf(1, 2, 3)
val result = source.reversed()
println(source) // [1, 2, 3]
println(result) // [3, 2, 1]
如果希望直接修改可变列表,则使用 reverse():
val source = mutableListOf(1, 2, 3)
source.reverse()
println(source) // [3, 2, 1]
函数名只差一个字母 d,行为却完全不同:
| 函数 | 返回结果 | 是否修改接收者 |
|---|---|---|
reversed() | 新的 List | 否 |
reverse() | Unit | 是 |
JVM 上的 Collections.reverse()
在 JVM 平台,MutableList.reverse() 最终会使用 java.util.Collections.reverse()。它并不是不加判断地使用下标访问,而是会根据列表类型选择不同路径。
其核心逻辑可以简化为:
public static void reverse(List<?> list) {
int size = list.size();
if (size < REVERSE_THRESHOLD || list instanceof RandomAccess) {
for (int i = 0, mid = size >> 1, j = size - 1;
i < mid;
i++, j--) {
swap(list, i, j);
}
} else {
ListIterator front = list.listIterator();
ListIterator back = list.listIterator(size);
for (int i = 0, mid = size >> 1; i < mid; i++) {
Object value = front.next();
front.set(back.previous());
back.set(value);
}
}
}
支持随机访问的列表
ArrayList 实现了 RandomAccess,按下标读取和写入元素都是常数时间,因此最适合使用左右指针交换:
[A, B, C, D]
↑ ↑
i j
先交换 A 和 D,再交换 B 和 C,整个过程只需要遍历一半元素,时间复杂度仍然是 O(n)。
不支持随机访问的列表
LinkedList 没有实现 RandomAccess。如果仍然反复调用 get(i) 和 set(i),每一次下标访问都可能重新遍历链表,最终很容易退化为 O(n²)。
所以 Collections.reverse() 会在列表较大时改用两个 ListIterator:一个从前往后走,另一个从后往前走。这样仍然只需要线性遍历。
这段实现会根据数据结构的访问特性选择合适的遍历方式,这种特殊化的做法,在前一篇文章中,亦有讲解。
数组的 reversed() 和 reverse()
数组同样提供 reversed():
public fun <T> Array<out T>.reversed(): List<T> {
if (isEmpty()) return emptyList()
val list = toMutableList()
list.reverse()
return list
}
注意它返回的是新的 List<T>,并不会修改原数组。
而数组的 reverse() 会直接修改数组本身。它的核心实现就是经典的双指针交换:
public fun <T> Array<T>.reverse() {
val midPoint = (size / 2) - 1
if (midPoint < 0) return
var reverseIndex = lastIndex
for (index in 0..midPoint) {
val tmp = this[index]
this[index] = this[reverseIndex]
this[reverseIndex] = tmp
reverseIndex--
}
}
对于奇数长度的数组,中间元素不需要移动;对于偶数长度的数组,刚好交换 size / 2 次。
整个操作的时间复杂度是 O(n),如果忽略临时交换变量,占用的额外空间是 O(1)。
asReversed():不复制数据的反向视图
如果接收者是 List,Kotlin 还提供了一个容易被忽略的函数:asReversed()。
val source = mutableListOf(1, 2, 3)
val view = source.asReversed()
println(view) // [3, 2, 1]
source += 4
println(view) // [4, 3, 2, 1]
reversed() 会复制元素并生成一个独立结果;asReversed() 返回的则是原列表的反向视图。它不需要复制整份数据,但原列表发生变化时,视图也会随之变化。
因此两者并没有绝对的好坏:
- 需要独立快照时,使用
reversed(); - 需要避免复制,并且能接受结果与原列表联动时,使用
asReversed()。
shuffled():随机打乱顺序
最后来看洗牌:
val frameworks = listOf("Compose", "Flutter", "React")
println(frameworks.shuffled())
// 每次输出都可能不同
shuffled() 的实现和 reversed() 有些相似:先创建可变副本,再在副本上执行原地操作。
public fun <T> Iterable<T>.shuffled(): List<T> =
toMutableList().apply { shuffle() }
因此,它同样不会修改原集合。
如果手里本来就是 MutableList,并且希望直接打乱它,可以调用 shuffle():
val frameworks = mutableListOf("Compose", "Flutter", "React")
frameworks.shuffle()
shuffle and shuffled,此时此刻,恰如 sort and sorted。
Fisher–Yates 洗牌算法
Kotlin 的洗牌实现采用的是现代 Fisher–Yates 算法。它的思路是从后往前遍历,每次从尚未确定的位置中随机选出一个元素,放到当前位置。
核心代码如下:
public fun <T> MutableList<T>.shuffle(random: Random) {
for (i in lastIndex downTo 1) {
val j = random.nextInt(i + 1)
this[j] = this.set(i, this[j])
}
}
第一次看到最后一行,可能会觉得有些绕:
this[j] = this.set(i, this[j])
set(i, value) 在写入新值的同时,还会返回位置 i 原来的元素。因此,这一行代码其实完成了一次交换:
val oldValueAtI = this.set(i, this[j])
this[j] = oldValueAtI
为了看清楚整个过程,假设现在有一个列表:
[A, B, C, D]
第一次循环,i = 3,从 0..3 中随机得到 j = 1,交换 B 和 D:
[A, D, C, B]
此时最后一个位置已经确定,后续不会再动它。
第二次循环,i = 2,从 0..2 中随机选择一个位置。假设选中了 j = 2,那么 C 与自己交换,列表保持不变:
[A, D, C, B]
第三次循环,i = 1,从 0..1 中随机得到 j = 0,交换 A 和 D:
[D, A, C, B]
循环结束后,索引 0 的元素也自然确定下来。
对于基于数组的列表,这个过程只需要从后向前遍历一次,因此时间复杂度是 O(n)。原地洗牌本身只需要常数级额外空间;而 shuffled() 还需要先复制出一个新的列表,所以整体需要 O(n) 的结果空间。
在 JVM 上,无参数的 MutableList.shuffle() 会直接委托给 java.util.Collections.shuffle()。OpenJDK 会检查列表是否支持随机访问:对于 ArrayList 这类实现了 RandomAccess 的列表,它会直接通过下标交换元素;对于元素数量达到阈值的非随机访问列表,例如 LinkedList,则会先将元素复制到数组中完成洗牌,再通过迭代器写回,从而避免频繁的下标访问导致 O(n2) 的性能问题。
数组的 shuffle()
数组的洗牌逻辑几乎一样,只是交换元素时使用了一个临时变量:
public fun <T> Array<T>.shuffle(random: Random) {
for (i in lastIndex downTo 1) {
val j = random.nextInt(i + 1)
val copy = this[i]
this[i] = this[j]
this[j] = copy
}
}
数组本身支持常数时间的下标访问,因此每次交换都是 O(1),整个算法仍然是 O(n)。
Fisher–Yates 的重要性质是:只要随机数生成器本身没有偏差,每一种可能的排列出现的概率都相同。
测试时如何得到可重复的结果
默认的 shuffled() 每次都可能产生不同结果,这在实际使用中没有问题,但会让单元测试变得不稳定。
Kotlin 允许传入指定的随机数生成器:
val result = frameworks.shuffled(Random(42))
固定种子后,同一平台和实现下可以得到可重复的随机序列,更适合测试和问题复现。
不过,kotlin.random.Random 并不是为密码、令牌等安全场景设计的。如果随机结果涉及安全性,应该使用平台提供的密码学安全随机数生成器。
到底应该选择哪个函数
最后把这些函数放在一起看:
| 需求 | 返回新结果 | 原地修改 |
|---|---|---|
| 按自然顺序升序排列 | sorted() | sort() |
| 按自然顺序降序排列 | sortedDescending() | sortDescending() |
| 按字段排序 | sortedBy() / sortedByDescending() | sortBy() / sortByDescending() |
| 使用复杂比较规则 | sortedWith() | sortWith() |
| 反转当前顺序 | reversed() | reverse() |
| 获取反向视图 | asReversed() | — |
| 随机打乱顺序 | shuffled() | shuffle() |
它们的常见复杂度如下:
| 操作 | 时间复杂度 | 新结果所需空间 | 是否稳定 |
|---|---|---|---|
| 排序 | O(n log n) | O(n) | 是 |
| 反转 | O(n) | O(n) | 不涉及比较 |
| 洗牌 | O(n) | O(n) | 不涉及比较 |
表中的空间复杂度针对 sorted()、reversed() 和 shuffled() 这类返回新结果的函数。原地版本不需要再创建一份完整结果,但具体排序算法仍可能使用额外的临时空间。
一点想法
Kotlin 提供了 sorted、sortedBy、sortedWith、reversed 和 shuffled 等集合操作函数,可以灵活地调整集合中元素的排列顺序。它们不仅支持自定义比较器,还可以组合多个排序条件,让我们能够以函数式的方式处理数据,代码也更加简洁、清晰。
有人可能会问:现在 AI 写代码已经这么普遍,还有必要花时间去看这些标准库的内部实现吗?
我的答案是:依然有必要。AI 大多数时候只是把功能跑通,它倾向于给出一个能工作的答案,而不会主动替你思考“这个选择器会被调用很多次,是不是该先把排序键算出来”“这个列表不支持随机访问,原地洗牌会不会退化”。这些细节,恰恰是工程经验真正发挥作用的地方。一个优秀的工程师,对性能和代码味道的敏感度,往往超过 AI——不是因为他记得更多 API,而是因为他知道一行代码背后发生了什么。
所以,读源码的意义不在于背诵实现,而在于建立一种判断力:知道哪些地方 AI 给的方案够用,哪些地方需要自己再推一把。这种判断力,正是 AI 时代里工程师最该保留的东西。