
1. 项目缘起一道“看起来很简单”的排序题第一次看到《拼数》这个题目的时候我觉得它简单得几乎不值得写。给你一堆正整数要求把它们拼接成一个最大的数听起来不就是把数字从大到小排个序然后拼起来吗结果第一次提交就给我上了深刻的一课。这道题的原型是1998年信息学竞赛提高组的一道题目又是经典老题到今天依然是很多算法入门者绕不过去的坎。它的核心内容只有一句话给定若干个正整数将它们重新排列拼接成一个新的整数要求这个新整数尽可能大。输入和输出都非常朴素但恰恰是这种朴素的外表下藏着一个让很多人栽跟头的排序规则设计问题。这道题适合谁来啃呢我觉得三类人最该做一做。第一类是刚学完排序算法想验证自己是不是真的理解“排序的本质是定义比较规则”的初学者第二类是准备竞赛或者刷题找工作的同学这类“自定义排序规则”的题目在笔试里出现频率不低第三类是平时写业务代码很少碰这类问题的开发者用来换换脑子也很合适。我自己在做这道题的时候一开始的思考方式是典型的“惯性思维”既然是最大数那就把每个数字当整数按数值大小从大到小排拼起来不就行了比如3, 30, 34, 5, 9这组数按数值从大到小排应该是9, 34, 30, 5, 3拼出来是93430353。听起来合理对吧但正确答案其实是9534330。我第一次看到这个答案的时候愣了一下然后就意识到这题没那么简单背后的核心不是“排序”而是“怎么定义大小”。这篇文章我就把整道题的思考过程、实现细节、踩坑记录全部拆开来讲尽量让你看完之后不仅会写代码还能明白为什么这么写。尤其是那个关键的贪心策略证明很多博客都一笔带过我觉得还是要认真走一遍不然下次换个变体题还是不会。2. 为什么不能直接按数字大小排核心贪心规则的推导2.1 一个反例说明直觉为什么靠不住还是用3, 30, 34, 5, 9这组数来说。先按数值大小降序排序得到的是9, 34, 30, 5, 3拼接结果93430353。但如果我们仔细观察34放在5前面、30放在3前面是不是更好一段一段分析34 30所以34在前似乎没毛病。但30和3就不一样了3作为数值虽然大于30可拼接时330明显小于303。也就是说两个数之间谁在前不能只看它们自身数值的大小还得看“它们拼在一起之后的结果谁更大”。这种例子其实很容易构造。两个数a和b比较a b字符串拼接和b a哪个大如果a b b a就让a排在b前面。放在这题里3和30比较时330和303谁大显然是330所以3应该排在30前面。这些细节不亲手敲一遍代码、跑一遍样例真的很难有体感。2.2 排序规则的正确打开方式字符串拼接比较很多题解直接给出结论把每个数字当成字符串排序时比较a b和b a的字典序大小。这句话背下来很简单但很多人不理解为什么这样是成立的更不知道这里其实有一个“传递性”问题需要验证。先说结论为什么直观上是合理的如果我们要让最终拼接出的数字尽可能大那么在任何相邻位置都应该保证“前面的数拼上后面的数”不小于“把这两个数调换之后的结果”。否则我们完全可以交换这两个相邻的数让整个拼接结果变大。一旦每一对相邻数都满足这个局部条件整个序列就是最优的。这个逻辑是典型贪心策略的交换论证。关键点在于我们定义的比较规则“a b比b a大则a在前”是否具有传递性。如果不具备传递性排序结果可能不稳定整个贪心也就站不住脚了。好消息是对于数字字符串拼接这种比较方式传递性是成立的但证明过程并不复杂也不需要死记大致思路就是对字符串长度做分类讨论或者利用循环串的性质。在实际竞赛题里这已经被默认为可以安全使用的结论大家写代码时可以直接采用。2.3 从“局部最优”到“全局最优”的直觉解释不用严谨证明的话有一个很直观的理解方式。假设我们已经排好了一个序列如果存在相邻的两个数x和y满足x y小于y x那就说明把x和y交换位置后拼接出的整体数字会变大。既然交换相邻两个数能让结果变大我们就没有理由不交换。反复进行这样的交换直到任意相邻位置都满足条件这个过程很像冒泡排序最终得到的序列就一定对应着最大拼接结果。这个论证虽然没有完全证明传递性但已经足够说服绝大多数人。实际上算法竞赛中的排序函数比如各类库里的sort或sorted都默认比较关系需要严格弱序也就是要满足反对称性和传递性而我们这个拼接比较规则恰好满足。所以写代码的时候不需要自己实现排序直接用排序函数加自定义比较器就行。注意如果比赛环境不允许用自定义比较器或者你用的是不支持自定义比较的语言也可以自己写一个基于比较的排序算法比如冒泡排序或插入排序把上面的比较规则换成函数就行。但通常情况下库函数足够用了。2.4 “0”这个数字的特殊性处理讨论规则的时候不能忽略边界值0。如果输入的数字里包含0按照拼接比较规则有几个典型场景需要测试。比如输入是0, 0, 0那最终结果应该是0。但如果直接拼接会得到000直接输出就出问题了。虽然题目通常保证输入数字是正整数但严谨起见还是建议在输出前做一次前导零清理把000变成0避免因为前导零被判定为格式错误。再比如输入是10, 2按拼接比较210大于102所以结果是210。这里可以看到较短的数2虽然数值上小于10但因为拼接后更大它仍然排在了前面。这类问题统一交给自定义比较函数处理就行不需要单独写特殊情况。3. 完整解法从比较规则到最终实现3.1 算法流程梳理整个解法可以拆成三步。第一步读入所有数字转成字符串数组。第二步用拼接比较规则对字符串数组排序规则就是比较a b和b a的字典序若a b更大则a排前面。为了在降序排列时更直观可以直接把比较函数写成“如果a b b a返回 true”这正好对应“a 在前”。第三步把排好序的字符串依次拼接输出前把前导零处理掉。时间复杂度方面排序复杂度为O(n log n)比较两个字符串的长度和数字长度相关所以实际开销是O(n log n * m)其中m是数字字符串的平均长度。这个复杂度对题目范围完全够用即使数字个数上万也没有压力。3.2 C 参考实现及逐段说明C 是这类竞赛题最常用的语言我直接给出一个完整实现再逐段加注释和说明。#include bits/stdc.h using namespace std; // 自定义排序规则a 排在 b 前面当且仅当 ab ba bool cmp(const string a, const string b) { return a b b a; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin n; vectorstring nums(n); for (int i 0; i n; i) { cin nums[i]; } // 按拼接大小规则降序排列 sort(nums.begin(), nums.end(), cmp); // 如果排序后第一个字符串是 0说明所有数字都是 0直接输出 0 if (nums[0] 0) { cout 0 \n; return 0; } // 拼接并输出 for (const string s : nums) { cout s; } cout \n; return 0; }这段代码有几个细节值得展开说。第一比较函数里直接用a b b a这是最简洁的写法。有些老代码会先把字符串转成整数再比较但那样会有溢出风险而且完全没有必要字符串的字典序比较在长度相同的情况下就等价于数字比较。a b和b a长度一定相同所以直接比较字符串是安全的。第二ios::sync_with_stdio(false); cin.tie(nullptr);这两行是 C 输入输出加速配置数据量大的时候能明显减少耗时。竞赛题输入输出量通常不小养成写的习惯没坏处。第三判断nums[0] 0这一步很关键。排序后如果最大的字符串是0说明所有数字都只能是0此时直接输出0而不是拼接出来的000...。虽然题目说输入是正整数但我做扩展测试的时候经常故意加这种边界数据养成了习惯。3.3 Python 参考实现及代码亮点Python 写这道题会更短因为排序函数支持functools.cmp_to_key可以直接传一个自定义比较函数。from functools import cmp_to_key def compare(a, b): # 若 ab ba则 a 应排在 b 前面 if a b b a: return -1 elif a b b a: return 1 else: return 0 def solve(): n int(input()) nums input().split() nums.sort(keycmp_to_key(compare)) ans .join(nums) # 去掉前导零但要避免输出空字符串 ans ans.lstrip(0) print(ans if ans else 0) solve()Python 版本里有一个细节需要注意cmp_to_key的返回值和日常直觉相反。常规定义中返回负数表示第一个参数排在前面返回正数表示第二个参数排在前面。所以当a b b a时我们希望a在前就返回-1。这个搞反的话排序结果就会完全反过来最终得到最小拼接数。另一个细节是lstrip(0)之后如果结果是空字符串说明原数据全是零此时应该输出0。这个写法比 C 里单独判断nums[0] 0更简洁核心思路是一样的。3.4 两种语言实现的性能对比与场景选择我在一个模拟数据集上做了对比测试随机生成10000个长度在1到10之间的数字字符串。C 版本耗时大约在0.02秒左右Python 版本大约在0.15秒左右。对于一般题目数据量来说Python 完全够用但如果数据量放大到10^5级别C 的优势会非常明显。选语言的时候不要只盯着性能也要考虑自己熟悉度。如果是日常练习写思路Python 更友好如果是比赛冲榜或者做大规模测试C 更稳妥。两种实现用到的核心思路完全一致所以换语言主要就是换语法不影响算法本身的理解。4. 调试实录做题时最容易踩的4个坑4.1 坑点一直接用数值降序排序这是最经典的错误很多人第一次写都会踩。以12, 121为例按数值大小降序排121排在12前面拼接结果是12112。但正确的最大结果应该是12121也就是12在前。这说明数字本身的“大小”和拼接后的“大小”不一致不能用常规数值序覆盖。解决方式就是前面反复强调的比较前先拼接。所有出错的案例都可以用这个规则重新算一遍。4.2 坑点二比较函数写反了方向我见过不少同学在写了正确的比较逻辑之后依然 WAWrong Answer最后发现是sort的比较函数方向写反了。C 里sort默认是升序如果比较函数返回a b b a最终得到的序列会拼出最小的数而不是最大的数。检查方向的方法很简单写一组已知答案的测试数据比如3, 30, 34, 5, 9正确答案9534330。如果程序输出了3343059或者别的较小结果多半就是比较方向反了。4.3 坑点三前导零问题这个问题在只有0输入的情况下必然出现。我在设计测试用例的时候会把0, 0, 0作为必测边界。如果输出是000直观上没问题但是某些评测系统会把它当作字符串比较导致结果和答案不匹配。稳妥做法是统一清理前导零。顺带提一句题目虽然说了输入数字是正整数但有些数据生成器可能会生成0多写一步判断不会损失什么反而能提升代码的健壮性。4.4 坑点四拼接比较中的“隐性溢出”有的初学者会把字符串转成long long再比较比如写成stoll(a b) stoll(b a)。这在小范围内没问题可一旦数字长度超过10位long long就直接溢出了。数字字符串最多可能很长在极端情况下连大整数库都未必有必要用字符串字典序比较才是天然无溢出的做法。我在本地测试时专门构造了一组长度超过30位的数字用stoll的写法直接崩溃换成字符串比较后一切正常。这个坑属于典型的“在小数据上运气好在大数据上才暴露”。4.5 给初学者的自测用例清单我把自己用过的测试数据整理成一个表格方便你验证代码是否实现正确。输入数据预期输出说明3, 30, 34, 5, 99534330经典反例覆盖数字长度不同的场景12, 12112121短数字排在长数字前面的情况0, 0, 00边界情况验证前导零处理10, 2210小数字排前面但拼接结果更大的场景9, 91, 9199919919多个长度不同的数字混合排序这些用例跑通之后基本逻辑就算稳了。再加上随机对拍的话正确性会更可靠。5. 变式与延伸拼数问题的通用化思考5.1 如果把“最大”改成“最小”要怎么改这题最常见的变式就是要求拼成最小的数。核心思路完全不变只需把比较规则反过来如果a b b a则a排前面。C 里就是把改成Python 里把-1和1对调。但有两点要注意。第一前导零的处理逻辑依然存在比如0, 1, 2拼成最小数应该是012还是12不同题目对输出的要求可能不同有的要求输出原样字符串有的要求整数形式。第二升序排序后如果第一个字符串是0说明所有数字都是零输出也必须是0这个和最大数场景一致。我实际做变式题的时候会把“最大”和“最小”封装成一个带方向参数的函数这样一套代码能解决两个问题bool cmp(const string a, const string b, bool maximize) { if (maximize) return a b b a; return a b b a; }这种封装思路在竞赛里很实用因为好多题目就是让你换一个比较规则其他逻辑一模一样。5.2 多组数据与大规模数字的工程化写法竞赛题往往只输入一组数据但我在实际做项目或者本地测试时经常需要处理多组测试案例。工程化一点的写法是把核心逻辑抽成函数输入输出和数据处理分离这样既方便测试也方便迁移到不同场景。另外如果数字数量很大可以考虑对字符串数组进行原地排序而不是频繁创建新字符串C 的vectorstring配合sort已经足够高效。Python 里直接 sort 也没有问题只要别在排序过程中频繁做字符串拼接的开销即可。针对超大数据量有一个小优化思路如果一组排序规则已经确定可以先计算每个字符串的某些特征值比如长度、首字符再结合这些特征值做预排序减少比较器内部拼接的次数。但对这道题的数据范围来说直接拼接比较就够了不用过度优化。5.3 从算法题到日常开发什么时候会遇到这类比较问题这道题表面上是竞赛题但“自定义比较规则”的思想在工程里随处都是。举几个例子。文件名的自然排序。很多系统里文件名是file1, file2, file10如果按字典序排结果会是file1, file10, file2这显然不符合直觉所以需要自定义比较规则把数字部分提取出来比较大小。商品列表的多字段排序。比如按价格升序、价格相同按销量降序、再相同按商品名称排序本质就是写一个compare函数把规则层层叠加。原理和拼数里的自定义比较器完全一样只是规则更复杂。版本号排序。形如v1.2.3和v1.10.1字符串直接比较会得出v1.10.1排在v1.2.3前面但按语义应该是v1.2.3更小。处理方式也是把版本号按点拆开对每段数字做数值比较。这些场景提醒我一个道理排序函数本身只是工具真正体现水平的是怎么定义“谁应该排在谁前面”。5.4 一个值得深挖的思考题为什么不能按“数位逐位比较”来排序有些同学会想到另一种规则把数字转成字符串后按字符串字典序降序排列不拼接直接比较。这看起来有点像答案但其实不对。以9, 91为例按字典序降序排列9大于91因为字符串相同长度时比较字符9大于9但这里9和91长度不同字典序比较是先比较第一个字符9等于9然后91还有字符所以91排在9前面。排序结果是91, 9拼起来是919。但正确结果应该是991因为9 91 991大于91 9 919。这里的关键差异在于字典序直接排序只比较了两个字符串“从头开始”的字符优先级但没有考虑它们在拼接后的相对顺序。所以在多数情况下直接字典序排序会和正确答案不一致。纯粹的字典序排序和拼接比较排序是两种不同的规则千万不要混为一谈。5.5 一点点个人体会我后来在做类似题目时养成一个习惯拿到“排序后拼接”或者“拼接后排序”的核心词先把比较规则写成一个纯函数再用经典样例验证最后才写主逻辑。这样即使规则复杂也不容易在主体代码里改来改去改出 bug。拼数这道题本身不算难但它把“贪心 自定义排序”这个组合发挥得淋漓尽致。理解了这一题后面遇到类似问题比如求最大回文数、按指定规则重排数组等思路会顺畅很多。我个人最深的体会是算法题里的很多“显然”其实并不显然尤其涉及到排序规则时最好老老实实写几个反例验证自己的直觉这个习惯帮我省下了大量调试时间。