← 返回题目列表

自定义排序如何设计比较键?多关键字排序有哪些坑?

高频 中等 第 14 / 26 题 更新于 2026/07/30
排序自定义排序比较器多关键字

简化版

自定义排序先明确排序键和优先级:先比主键,主键相同再比次级键,仍相同再决定是否保留原顺序或继续比较唯一键。比较器必须满足自反、反对称、传递性,不能写成随状态变化的逻辑,也不要用整数相减避免溢出。多关键字题常见模式是“频率降序 + 数值升序”“结束时间升序 + 开始时间升序”“分数降序 + 原下标升序”。

详细版

很多排序面试题难点不在排序算法,而在比较器设计。比较器要回答两个问题:先按什么排?相等时怎么打破平局?例如“按频率从高到低,频率相同按数值从小到大”,就先比较频率,频率不同返回高频在前;频率相同再比较数值。

items.sort((a, b) -> {
    if (freq.get(a) != freq.get(b)) {
        return Integer.compare(freq.get(b), freq.get(a)); // 频率降序
    }
    return Integer.compare(a, b); // 数值升序
});

面试要特别说明比较器合法性。不能写 return b.score - a.score,因为可能溢出;不能在比较过程中修改参与比较的字段;不能让 a < bb < cc < a 同时成立。若要求稳定排序,可以使用语言提供的稳定排序,或把原始下标作为最后一个 tie-breaker。

完整版教学

一、先把需求翻译成排序键

自定义排序的第一步不是写 lambda,而是把自然语言需求拆成有序的 key 列表。比如“成绩高的在前,成绩相同年龄小的在前,仍相同保持原顺序”,对应 key 是 score descage ascoriginalIndex asc。只要 key 列表清楚,比较器就会非常稳定。

如果不拆 key,很容易写出互相冲突的判断。比如“VIP 优先、价格低优先、时间早优先”,要先确认 VIP 是否比价格更重要。如果 VIP 是主键,那么普通用户价格再低也排在 VIP 后;如果价格是主键,那 VIP 只在价格相同时生效。面试中主动澄清优先级,会显得很专业。

需求描述主键次级键备注
分数高优先,时间早优先分数降序时间升序排行榜常见
频率高优先,数值小优先频率降序数值升序Top 频率题常见
区间结束早优先结束升序开始升序贪心区间题常见

二、比较器必须满足一致性

排序库通常假设比较器是一个合法的“严格弱序”或全序近似。用面试话术讲,就是比较结果要稳定一致:同一对元素反复比较结果不能变;如果 a 应排在 b 前,b 应排在 c 前,那么 a 也应该排在 c 前;相等元素要返回 0 或继续用 tie-breaker 明确顺序。

反例很常见:比较器里调用随机数,或者根据当前时间决定顺序,会导致同一对元素前后结果不一致。另一类反例是循环偏好,比如石头剪刀布式比较:石头胜剪刀、剪刀胜布、布胜石头。这种关系不能排序,因为不存在线性顺序。

合法排序需要避免:
a < b
b < c
c < a

记忆钩子:比较器不是“判断一次谁赢”,而是给所有元素安排一条不会自相矛盾的队伍。

三、不要用减法写比较结果

很多人喜欢写 return b.score - a.score 表示降序,但这有整数溢出风险。若 b.score = Integer.MAX_VALUEa.score = -1,相减会溢出成负数,排序方向直接错。更稳妥的写法是 Integer.compare(b.score, a.score) 或语言对应的安全比较函数。

还有一个坑是浮点数比较。浮点里可能有 NaN-0.0 等特殊值,直接用减法转 int 更危险。面试中如果字段可能溢出或是浮点,主动说用安全比较函数,会比手写减法更可靠。

// 不推荐
return b.score - a.score;

// 推荐
int cmp = Integer.compare(b.score, a.score);
if (cmp != 0) return cmp;
return Integer.compare(a.age, b.age);

四、多关键字排序的代码结构

多关键字排序推荐写成“逐层比较,非 0 立即返回”。这样逻辑和需求一一对应,也便于审查。降序可以交换参数位置,升序按自然顺序比较。字符串可以用 compareTo,布尔值可以映射成 0/1 或用比较函数。

例如排序学生:分数降序、年龄升序、姓名字典序升序。若 A 分数 95、年龄 20,B 分数 95、年龄 18,那么 B 应排前,因为主键相同后比较年龄。若年龄也相同,再比姓名,避免相等元素顺序在不稳定排序中漂移。

students.sort((a, b) -> {
    int c1 = Integer.compare(b.score, a.score); // 分数降序
    if (c1 != 0) return c1;
    int c2 = Integer.compare(a.age, b.age);     // 年龄升序
    if (c2 != 0) return c2;
    return a.name.compareTo(b.name);            // 姓名升序
});

五、稳定排序和原始下标

如果题目要求“相同 key 保持原顺序”,需要稳定排序。若语言库保证当前排序稳定,可以直接依赖;如果不确定,或者底层排序不稳定,就给每个元素附加原始下标作为最后一个比较键。这样即使排序算法不稳定,也能模拟稳定效果。

举例:原始输入 A(90), B(90), C(80),按分数降序且同分保持原顺序。比较器最后加 originalIndex asc,A 的下标小于 B,所以 A 在 B 前。这个技巧在日志排序、排行榜同分排序、分页结果一致性中很常见。

方法是否依赖排序稳定性额外成本适用
使用稳定排序语言明确保证稳定
原下标 tie-breaker存一个下标需要跨语言稳妥
不处理平局平局顺序无要求

六、排序键预计算能避免重复成本

比较器会被调用 O(n log n) 次,如果每次比较都重新计算昂贵 key,会拖慢排序。例如按字符串解析后的日期排序,若在比较器里反复 parseDate,同一条记录可能被解析很多次。更好的做法是先把 key 预计算出来,再排序引用或包装对象。

带数字估算:n=100000 时,排序比较次数约 100000 * log2(100000) ≈ 166万。如果每次比较都解析两个日期,就可能产生 300 多万次解析;预计算只需要 10 万次。这个优化在自定义排序题里经常作为追问出现。

原始做法:比较时 parse(a.date), parse(b.date)
优化做法:预先 dateKey = parse(date),比较时直接比 dateKey

七、常见误区与追问

  • 误区:比较器返回 true/false 就够了。 大多数排序接口需要负数、0、正数来表达小于、等于、大于,布尔值无法表达完整关系。
  • 误区:用 b-a 写降序最简单。 整数可能溢出,应该用 Integer.compareLong.compare 或安全比较函数。
  • 误区:相等时随便返回 1。 相等元素如果不返回 0 或明确 tie-breaker,会破坏比较器一致性。
  • 追问:如何保证同分保持原顺序? 使用稳定排序,或把原始下标作为最后一个升序比较键。
  • 追问:比较器里能查哈希表吗? 可以,但哈希表内容必须在排序期间不变;昂贵 key 最好预计算。
  • 追问:多关键字排序怎么验证? 构造主键不同、主键相同次级键不同、所有 key 相同三类用例。

八、加强记忆

自定义排序的核心是“先列 key,再写比较器”。主键决定大方向,次级键负责平局,原始下标负责稳定性。比较器必须一致、可传递、无副作用;降序不要用减法,昂贵 key 要预计算。遇到多关键字排序题,就把需求写成 key1 asc/desc -> key2 asc/desc -> tie-breaker,代码自然不会乱。