☰
LeetCode 179最大数详解:自定义比较器与贪心排序的工程实践
2026/10/24 3:31:32 网站建设 项目流程

LeetCode 179这道“最大数”题目,我在面试里出过,也在刷题群里看别人因为答案不对半夜挠头。题目本身短得不能再短:给一组非负整数,重新排列它们的顺序,让这些数字拼出来的数最大。比如输入 [3, 30, 34, 5, 9],答案不是 9345330,而是 9534330。乍一看像是脑筋急转弯,真上手写代码就发现坑一个接一个。核心解法说穿了就是“自定义排序规则 + 贪心排序”,但很多人在比较器上翻车,在全 0 用例上翻车,在语言细节上也翻车。这篇文章把这道题的思路、证明、实现、边界用例和调试方法一次性理清,适合刚开始刷排序类题目的读者,也适合需要在半小时内把这道题讲明白的面试候选人。

1. 题目定位:不是简单排序,而是重新定义“谁该在前”

1.1 直觉陷阱:按数值大小降序为什么是错的

第一次拿到这道题,几乎所有人都会想:把数组从大到小排个序,然后拼起来不就行了?用 [3, 30, 34, 5, 9] 试一下,按数值降序得到 [9, 34, 5, 3, 30],拼出来是 9345330;而正确答案是 9534330。问题出在哪?看 34 和 5 这对数字。按数值大小,34 大于 5,所以降序里 34 排在了 5 前面。但“34 在前、5 在后”拼出来是 345,“5 在前、34 在后”拼出来是 534,反而是“较小的 5”放在前面能拼出更大的数。

这个反例会彻底击溃“按数值降序”的想法。它说明:两个数字谁该在前面,取决于它们拼接后的结果谁更大,而不是它们各自数值的大小。另一个经典反例是 [121, 12],按数值降序得到 12112,但真正最大的是 12121,也就是 12 在前。同一个数字的位数不同,数值大小和拼接优势的关系就完全改变了,这就是一个无法靠直觉记忆规则去覆盖的所有场景。

1.2 另一种容易走偏的方案:按首位数字分组

顺着直觉再往前走一步,有人会想到“按最高位数字分组”:所有以 9 开头的放一组,以 5 开头的放一组,组内从大到小,再按最高位从高到低把各组串起来。这个思路比起直接降序聪明了一些,但依然会漏掉关键细节。比如 [3, 30, 34]:三个数字最高位都是 3,组内怎么排?3 和 34 比较时,34 在前拼出 343,3 在前拼出 334,所以 34 应该排在前面;但 3 和 30 比较时,3 在前拼出 330,30 在前拼出 303,所以 3 又应该排在 30 前面。于是组内顺序变成 34, 3, 30。这里已经暴露出问题的复杂性:前缀相同的数字会存在多个层级嵌套比较,手写分组逻辑既要考虑前缀长度,又要考虑“循环”比较,几乎是在重新发明比较器。实际写下来代码复杂度远高于直接用统一规则。

更糟的是,分组思路在跨组比较时也会出错。比如 9 和 91,最高位不同,9 组一定在 91 组前面吗?拼一下试试:9 在前是 991,91 在前是 919,991 更大,所以确实 9 在 91 前面。但换一组数字,比如 8 和 89,8 在前是 889,89 在前是 898,898 更大,结果是 89 排到 8 前面。同样跨组,先后顺序却截然不同。这说明“先按最高位分组再拼接”本质上是在绕远路,而且容易漏掉这些交错比较的边界。

1.3 这道题到底在考什么

剥开题目外壳,它真正考察的是排序的比较器设计。常规排序算法的比较规则是数字大小:a 小于 b,a 就排在 b 前面。但在这道题里,比较规则需要重新定义:a 拼接 b 的结果大于 b 拼接 a,a 就排在 b 前面。这个定义看起来微小,却完全改变了排序的语义。它带来的连锁问题是:自定义比较器是否满足自反性、对称性、传递性?只有满足这些性质,任何排序算法才能给出稳定、确定的结果,否则排序过程的行为完全不可预期。

所以这道题适合用来检验两件事:第一,能不能想到把问题转化为排序;第二,能不能设计出正确的比较规则,并处理结果中的特殊情况。这两步都不涉及高深算法,但非常考验代码基本功。

2. 核心思路:让“相邻对”决定全局最优

2.1 两个数怎么比较:拼起来看

我们要把题目中的“最大数”当成一个排序目标来理解。假设已经有一个合法的排列,如果相邻的两个数字 x 在前、y 在后,拼接结果是 xy;把它们的顺序交换,结果是 yx。如果 yx 大于 xy,那么交换后整个数字会变大,因为两个排列除了这两位以外,前缀完全一致,交换后等于把中间这一段子串从 xy 换成了 yx,整体数值一定跟着变大。反过来说,如果 xy 大于 yx,那么 x 在前就是局部更优的选择。

由此得到核心比较规则:任意两个数字 x 和 y,比较 xy 与 yx 的大小;如果 xy 更大,x 就应该排在 y 前面;反之 y 排在 x 前面。这个规则直接用在排序算法的比较器里,最终排出来的完整序列就是能组成最大数字的顺序。这里的“最大”不是某个全局枚举结果,而是通过每个相邻对都满足“前面的拼接结果更大”来保证的,本质上是一种贪心排序思路。

2.2 为什么局部比较能推出全局最优

有人会问:相邻两个位置按这个规则排对了,真能保证整个序列是所有排列里最大的吗?关键在排序算法的作用。排序会把整个序列调整成一种状态:对于任意相邻元素,前面的元素拼接后面的元素,都不小于后面的元素拼接前面的元素。这个状态等价于“无论怎么交换相邻两个元素,结果只会变小或持平”。

由于任何排列变换都可以通过一系列相邻交换实现,而每一步相邻交换都不会让结果变大,因此排序后的排列就是所有排列里的最大值。这就是为什么只需要设计一个正确比较器,剩下的交给排序算法,而不需要手动枚举所有排列。这种“两个元素决定顺序”的思路,在很多拼接类问题里通用。

2.3 容易被忽略的数学细节:比较器的传递性

自己定义比较器时,最隐蔽的坑是传递性。如果比较器没有传递性,比如 a 应该排在 b 前,b 应该排在 c 前,但 c 又应该排在 a 前,排序算法按照不同数据分组就可能跑出不同的随机结果。这道题自带的拼接比较规则怎么确保传递性?一个便捷的证明方式是把每个数字 a 映射成 a / (10^len(a) - 1),例如“3”映射为 3/9,“30”映射为 30/99。比较 “a 拼接 b” 与 “b 拼接 a”,等价于比较这两个映射后数字的大小。因为每个数字都对应实数上的一个值,实数天然满足全序关系,所以这个比较器具备传递性,不会出现排序行为不确定的情况。

这个证明不需要写进代码,但它能解释为什么“两两拼一下”这个规则这么稳。实际工作中自己设计比较器时,遇到诡异的不稳定结果,第一反应就应该是检查比较器是否满足这些数学性质,而不是怀疑语言内置排序算法。

2.4 全 0 的特殊情况,一句话就能解释

数组里如果全是 0,比如 [0, 0, 0],按比较规则排序得到的字符串会是 “000”。但题目要的是一个数字,数字 0 的正常表示就是 “0”,而不是三个零。所以排序完成后,需要查看头一个元素,如果它是 “0”,直接返回 “0”。这是整道题最高频的边界用例,也是面试官最常设置的陷阱。处理方式写起来只有一行,但漏掉它的提交会直接判错。

3. 动手实现:多语言代码与关键细节

3.1 Python 写法与 cmp_to_key 的用法

Python 3 的 sort 方法默认没有比较器参数,需要通过 functools.cmp_to_key 把旧的比较函数转换成 key。比较函数接收两个字符串 x 和 y,如果 x + y 大于 y + x,返回 -1,表示 x 应该排在前面;如果小于,返回 1;相等返回 0。写成代码:

from functools import cmp_to_key from typing import List def compare(x: str, y: str) -> int: if x + y > y + x: return -1 if x + y < y + x: return 1 return 0 class Solution: def largestNumber(self, nums: List[int]) -> str: strs = [str(num) for num in nums] strs.sort(key=cmp_to_key(compare)) if strs[0] == "0": return "0" return "".join(strs)

这里有个很容易搞错的点:compare 返回值的方向。在 Python 中,sort 期望比较函数返回负值表示第一个参数排在前面。这和其他语言里 Comparator 的负数约定一致。如果写反了,结果就是最小数而不是最大数,而且某些用例看起来还是会“对”,因为个别简单测试恰好不区分正反,只有在反例上才会暴露。所以写完以后,至少要带上 [3, 30, 34, 5, 9] 和 [121, 12] 两组用例验证方向是否正确。

3.2 Java / C++ / JavaScript 的实现对比

Java 的 Arrays.sort 支持 Comparator,写法上更直接。注意不能用拼接后的数字做大小比较,因为 int 可能溢出,这也是使用字符串比较的另一个原因。示例:

import java.util.Arrays; class Solution { public String largestNumber(int[] nums) { String[] strs = new String[nums.length]; for (int i = 0; i < nums.length; i++) { strs[i] = String.valueOf(nums[i]); } Arrays.sort(strs, (a, b) -> (b + a).compareTo(a + b)); if (strs[0].equals("0")) { return "0"; } StringBuilder sb = new StringBuilder(); for (String s : strs) { sb.append(s); } return sb.toString(); } }

这段代码里(b + a).compareTo(a + b)的含义是:如果 b 拼接 a 更大,说明 a 不应该是当前的比较顺序,Comparator 返回正数,则 b 排在前面。本质上就是整个序列按“拼接后的降序”排列。C++ 的写法类似,lambda 里把两个字符串按 a + b 与 b + a 做字典序比较:

#include <bits/stdc++.h> using namespace std; class Solution { public: string largestNumber(vector<int>& nums) { vector<string> strs; for (int num : nums) strs.push_back(to_string(num)); sort(strs.begin(), strs.end(), [](const string& a, const string& b) { return a + b > b + a; }); if (strs[0] == "0") return "0"; string ans; for (const string& s : strs) ans += s; return ans; } };

JavaScript 和 Java 很像,但有一个本地化的坑:直接使用(b + a).localeCompare(a + b)时,localeCompare 在某些环境里可能受到地区设置影响,安全做法是手动比较字符串字典序,避免 “012” 和 “12” 之类的情况干扰。实现时可写成一个手动的字符串比较函数,或者在知道所有输入都是非负整数的情况下,直接利用>和<做比较后再返回正负值。

3.3 拼接比较为什么比数值放大更稳妥

有人会想,比较 a 和 b 时,直接把 a 扩大成与 b 相同的位数再比较,比如 a 是 12,b 是 121,可以比较 12 * 1000 + 121 和 121 * 100 + 12。这个思路没错,但存在两个问题:一是需要处理 10 的幂次,代码更繁琐;二是当数字很长时,a * 10^k 可能直接超出 int 或 long 的范围,导致溢出。字符串拼接就没有这个顾虑,位数再怎么长,字典序比较都能正确处理。实际工程中,凡是涉及大数拼接排序的场景,我都优先用字符串比较,省心且不易出错。

4. 复杂度与测试用例:写对之后还要算清楚

4.1 时间与空间复杂度拆解

假设数组长度为 n,每个数字转成字符串后的平均长度为 L。排序过程中,一次比较需要拼接两个字符串并做字典序比较,开销是 O(L)。整个排序的时间复杂度为 O(n log n),所以总时间复杂度是 O(n L log n)。这里 L 不只是单个数字的位数,因为拼接比较时实际上要扫描两个字符串,所以常数项是 2L,但在复杂度分析中统一记为 O(L) 即可。

空间方面,需要保存 n 个字符串,总空间是 O(nL),排序过程本身还需要 O(log n) 的递归栈,通常忽略不计。这个复杂度对于题目通常的数据范围完全够用,不需要引入更复杂的排序算法。

4.2 一份可以直接复用的测试用例清单

自己写完代码,建议直接跑下面这组测试,覆盖这道题绝大多数边界:

输入数组期望输出备注
[10, 2]210基础题,确认比较方向
[3, 30, 34, 5, 9]9534330经典用例,验证整体顺序
[121, 12]12121反“数值降序”的关键用例
[0, 0]0全零边界
[0, 1]10零不能放在开头
[432, 43]43432前缀相同的典型场景
[100, 10, 1]110100不同长度混合
[1]1单元素情况

这些用例里,[121, 12] 和 [432, 43] 最能检验比较器是否真的理解了题目。如果你实现的比较器在 [121, 12] 上给出 12112,那基本可以肯定是把“谁在前”的方向写反了,或者直接按数值比较了字符串。

4.3 前缀相同的数字:统一规则,不要记“谁长谁在前”

很多人喜欢总结经验,比如看到 [3, 30, 34] 的正确顺序是 34, 3, 30,就得出“数字长的在前面”的结论,但 [121, 12] 的正确顺序是 12, 121,长的反而排在后面。同一个结论在两组用例上就自相矛盾了。原因在于,前缀相同数字谁在前,取决于补位部分的比较:121 补上来是 12112,12 补上来是 12121,这是一个循环字典序问题,不能用“谁长谁靠前”这种简单规则覆盖。

最省心的做法是让比较器统一处理,完全不要记这种特判规则。你只需要确认“拼接后谁更大”这一个原则,所有复杂度都交给排序算法解决。这也是我在实际编码中反复强化的习惯:一旦发现自己在为比较器总结“也就是、其实就是”的规则,马上回头看看能不能收敛成一种通用的比较形式。

5. 高频错误与排查技巧实录

5.1 常见报错和错误答案速查表

这道题提交失败时,错误往往是下面几种模式,对号入座能快速定位:

错误现象大概率原因修复方向
输出的是最小数而不是最大数比较器方向返反检查比较函数里返回 -1 和 1 的时机
全零数组输出 “0000”忘记首位特判排序后判断第一个字符串是否为 “0”
个别用例答案比正确结果小一位两个数拼接时把顺序搞错重看比较规则:是 a+b 和 b+a 在比,不是 a 和 b 在比
数字长度很大时结果异常用整数拼接导致溢出改为字符串拼接比较
用 Java/Python 时提交不同环境结果不一致比较器缺传递性或 locale 影响细查比较器规则,JS 中避免直接 localeCompare

最隐蔽的一种错误是“看起来对了”。比如输入 [2, 10],无论按数值降序还是按拼接比较,结果都是 210,于是你以为自己写对了。这就是为什么准备一组有判别力的反例非常重要。

5.2 本地暴力枚举验证法:5 分钟写出反例检测

如果不确定自己的比较器是不是真的能得到全局最大,最直接的验证方式是写一个暴力枚举所有排列的脚本,在数组长度较小的时候逐一比对。Python 里可以用 itertools.permutations:

from itertools import permutations def brute_force(nums): best = "" for p in permutations(nums): cur = "".join(str(x) for x in p) if cur > best: best = cur return best def sorted_ans(nums): from functools import cmp_to_key strs = [str(x) for x in nums] strs.sort(key=cmp_to_key(compare)) return "0" if strs[0] == "0" else "".join(strs) test_cases = [ [3, 30, 34, 5, 9], [121, 12], [432, 43], [0, 0, 1], [83, 8, 88, 9] ] for case in test_cases: b = brute_force(case) s = sorted_ans(case) print(case, b, s, "OK" if b == s else "FAIL")

这个方法特别适合在对答案不确定时快速给出信心。我一般会随机生成几百组长度为 1 到 6 的小数组,跑一遍暴力对比,只要有一次对不上,就说明算法有问题。这是在本地不依赖评测系统的情况下最快发现缺陷的手段。

5.3 面试现场怎么把这道题讲清楚

面试官问这道题,不会只让你默写代码,更看重你的推导过程。我建议按三步走:第一步,举一个 [121, 12] 这样的反例,说明按数值大小排序行不通;第二步,提出比较规则:任意两个数 x 和 y,比较 xy 和 yx,谁大谁就在前面;第三步,说明这种局部比较通过排序能确保全局最优,并记得主动提“如果排完第一位是 0,就返回 0”。把这三步讲通,代码的正确性就水到渠成。如果面试官追问为什么,再把“相邻交换”和“传递性”的论证抛出来,就已经超出大多数候选人能回答的深度了。

6. 从这道题延伸出去:变体与真实工程场景

6.1 反向变体:构造最小数

一个常见变体是给出同样的非负整数数组,要求拼成最小的数。处理方式几乎一样,只要把比较器反向:如果 x + y 大于 y + x,就让 y 排在 x 前面。但全 0 场景的处理思路要反过来:如果得到的字符串第一个字符是 0,直接返回 “0”,比如 [0, 1] 需要返回的是 10 还是 01?题目要求结果是一个数,01 表示的仍然是 1,但按“最小数”要求,这里有个语义需要和面试官确认边界。通常是要求输出不带前导零的字符串,所以发现结果开头是 0 且后面有非零数字时,需要去掉前导 0,或者直接判断整个字符串定义为全 0 时输出 “0”。这些细节本身又是一道考题。

6.2 真实工程中的“拼接排序”场景

这种思路不只是刷题用。我遇到过日志分片文件排序的需求:一批日志按序号分片,序号可能是 1, 2, 10, 11,如果直接用字符串排序,会得到 1, 10, 11, 2,但业务上也许希望按数值顺序。如果分片名本身是“part-1.log”这种格式,排序问题很容易解决;但如果业务要求的是“把编号拼成一个业务标识”,比如渠道编码和订单号拼接去重,就需要自定义拼接顺序来保证结果字符串在字典序上的最优性。

另一个更贴近的场景是优惠券或 SKU 编码拼接:多个编码片段顺序不同,拼出的最终标识不同,而业务端希望标识字符串尽可能具有某种排序特性,以便在数据库索引上获得更好的前缀分布。这类需求本质上都落在这个题型的比较器设计模型里。先定义一个明确的“两个片段拼接后谁更大”规则,再排序,比在业务代码里写一堆 if 判断要可靠得多。

6.3 一种可复用的思维模型:先定比较规则,再交给排序算法

这道题给我最大的启发是:遇到“排列顺序影响最终结果”的问题,不要急着写循环去枚举顺序,而是先问自己能不能定义一个二元比较规则,让任意两个元素都能判定先后。如果可以,问题大概率能转化为一次排序。转化的关键在于证明局部比较能保证全局最优,这一步不一定需要严格的数学证明,但至少要能用自己的话说清楚“为什么相邻交换不会让结果变差”。

后续遇到类似问题时,我会先在草稿纸上写下两个候选元素的拼接结果,比较一下,然后看看推广到整体是否成立。这个思维模型比记住某个特定题型的答案更有用,它能迁移到字符串拼接、路径规划中的顺序优化、甚至某些任务调度场景。

就我个人而言,这道题刷了不止一遍。第一次写的时候我甚至把比较函数的方向搞反,又在全 0 用例上栽了一次,后来把比较器规则、边界特判、暴力验证脚本都固定成自己的“标准动作”,再遇到任何自定义排序题就顺畅多了。如果你也在刷题,建议把这套“先找反例、再定比较规则、最后暴力验证”的流程内化下来,它会比这道题本身更值得带走。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询