C++算法实战:自定义排序解决最小数拼接问题

📅 2026/8/13 16:22:11
C++算法实战:自定义排序解决最小数拼接问题
1. 问题引入从一道经典面试题说起最近在带新人学习C基础算法时我总会抛出一个看似简单实则能考察多个知识点的题目给定n个一位数0-9如何将它们排列组合形成一个最小的整数比如给你数字3, 1, 4, 1, 5你能组成的最小数是11345而不是11435或54311。这个问题在力扣LeetCode和一些公司的笔试中经常以变体形式出现它远不止是排序那么简单。很多初学者第一反应是“把数字从小到大排序然后拼起来”但如果数字里有0呢比如0, 1, 2排序拼接得到012这显然不是一个合法的整数前导0会被忽略实际是12而正确答案应该是102。这个小小的“陷阱”正是这道题的价值所在——它强迫你思考数字的字符串表示与数值表示的区别以及如何在满足数学规则的前提下进行排序。从热词来看“c面试”、“c八股”、“c算法”都是高频词汇说明大家对于通过具体问题来夯实基础、应对考核有强烈需求。这个问题完美契合它涉及数组/向量操作、排序规则定制、字符串处理、边界条件判断等多个C核心概念。今天我们就抛开枯燥的教科书用解决这个实际问题的完整链路带你重新理解这些知识点是如何串联起来工作的。我会从最朴素的思路开始逐步优化并分享我在实际编码和面试中遇到的坑。2. 核心思路拆解为什么不能简单排序我们先来剖析一下“组成最小数”这个目标的本质。给定一组数字我们要找到一个排列使得其对应的整数值最小。在数学上比较两个由相同数字集组成的数的大小实际上是在比较它们字典序的先后。但这里的“字典序”需要根据数字拼接成字符串后的结果来定义。2.1 字典序排序的陷阱与修正最简单的想法是把数字转换成字符然后对所有字符按升序排序最后拼接。我们用代码验证一下这个想法的问题。#include iostream #include vector #include algorithm #include string using namespace std; string minNumber_wrong(vectorint nums) { vectorstring strs; for (int num : nums) { strs.push_back(to_string(num)); // 数字转字符串 } sort(strs.begin(), strs.end()); // 默认字典序升序排序 string result; for (string s : strs) { result s; } return result; } int main() { vectorint test1 {3, 1, 4, 1, 5}; vectorint test2 {0, 1, 2}; cout 测试1无0结果: minNumber_wrong(test1) endl; // 输出 11345 cout 测试2有0结果: minNumber_wrong(test2) endl; // 输出 012 }运行上述代码你会发现对于测试用例{0, 1, 2}输出是012。在整数语境下012就是12。但这是最小的吗我们手动枚举一下所有排列012(12),021(21),102(102),120(120),201(201),210(210)。显然102才是数值最小的。所以默认的字典序排序在这里失效了。根本原因在于当比较两个字符串a和b时默认的字典序是从左到右逐个字符比较。对于”0“和”1“”0“”1“所以”0“会排在”1“前面。但在拼接成数字时”0“放在首位会导致整个数字的位数效应失效前导0无效。因此我们需要一种新的比较规则使得排序后的字符串数组在拼接时能自然产生最小的数值。2.2 关键比较规则拼接比较法正确的思路是对于两个数字字符串a和b我们不应该孤立地比较a和b本身而应该比较两种拼接方式a b和b a。如果a bb a这里的是字符串字典序比较那么我们认为在最终的拼接序列中a应该排在b的前面。反之则b应该排在a的前面。为什么这样是有效的我们以a”0“,b”1“为例a b “01”b a “10”比较字符串”01“和”10“字典序上”01“”10“。因此按照规则”0“应排在”1“前面等等这和我们之前枚举的结果102最小矛盾吗不矛盾。让我们用三个数{0, 1, 2}来验证这个规则。我们需要对[“0”, “1”, “2”]按照上述规则排序。比较”0“和”1“”01“”10“所以”0“在”1“前。比较”0“和”2“”02“”20“所以”0“在”2“前。比较”1“和”2“”12“”21“所以”1“在”2“前。最终排序结果为[“0”, “1”, “2”]拼接为”012“。问题依旧我在这里第一次实现时也踩了这个坑。关键在于这个比较规则需要作用于整个排序过程并且排序后的序列必须满足传递性。对于{0, 1, 2}仅仅两两比较(ab) 和 (ba)得到的顺序在拼接后可能并不是全局最优。实际上我们需要寻找的是一种排序规则使得对于排序后的任意两个相邻字符串s[i]和s[i1]都满足s[i] s[i1] s[i1] s[i]。这听起来像是自定义排序比较函数Comparator的典型场景。然而一个更深入的问题是对于0 如果它和其他任何数字x比较”0x“永远小于”x0“因为”0x“以0开头而”x0“以x开头只要x0”0“就小于x这个字符。这会导致0被排到最前面。这正是前导0问题的根源。所以仅仅依靠ab和ba的比较是不够的我们还需要一个后处理步骤处理前导0。3. 完整解决方案与代码实现经过上面的分析我们得出一个清晰的两步走策略自定义排序将数字转换为字符串并按照(ab) (ba)的规则进行排序。这一步可以确保在忽略前导0的情况下拼接后的字符串字典序最小。处理前导0将排序后拼接好的字符串其开头可能有一个或多个’0‘。我们需要找到第一个非’0‘字符的位置然后返回从该位置开始的子串。如果整个字符串都是’0‘即所有输入数字都是0则直接返回”0“。3.1 代码实现与逐行解析下面给出完整的C实现并附上详细注释。#include iostream #include vector #include algorithm #include string using namespace std; class Solution { public: string minNumber(vectorint nums) { // 1. 转换为字符串数组方便拼接和比较 vectorstring strs; for (int num : nums) { strs.push_back(to_string(num)); } // 2. 自定义排序核心比较规则 sort(strs.begin(), strs.end(), [](const string a, const string b) { return a b b a; // 如果 ab 的字典序小于 ba则 a 排在 b 前面 }); // 3. 拼接排序后的字符串 string result; for (string s : strs) { result s; } // 4. 处理前导零这是一个非常关键的步骤 // 找到第一个不是0的字符 int firstNonZero 0; while (firstNonZero result.size() result[firstNonZero] 0) { firstNonZero; } // 情况1: 整个字符串都是0例如输入为[0,0,0] if (firstNonZero result.size()) { return 0; } // 情况2: 有前导零返回去掉前导零后的子串 // 注意substr的第一个参数是起始位置第二个参数是长度。如果省略长度则取到末尾。 return result.substr(firstNonZero); } }; // 测试函数 int main() { Solution sol; vectorint test1 {3, 1, 4, 1, 5}; vectorint test2 {0, 1, 2}; vectorint test3 {0, 0, 0}; vectorint test4 {10, 2}; // 注意题目说是一位数这里是测试边界扩展 vectorint test5 {}; cout 测试1 [3,1,4,1,5]: sol.minNumber(test1) endl; // 期望 11345 cout 测试2 [0,1,2]: sol.minNumber(test2) endl; // 期望 102 cout 测试3 [0,0,0]: sol.minNumber(test3) endl; // 期望 0 // 对于非一位数我们的算法依然有效因为to_string和比较规则是通用的 cout 测试4 [10,2]: sol.minNumber(test4) endl; // 期望 102 (因为 102 210) cout 测试5 []: sol.minNumber(test5) endl; // 期望 (空向量)实际返回空字符串根据题目要求可能需返回0这里未处理 }代码要点解析to_string的使用这是C11标准引入的非常方便的函数可以将各种算术类型转换为字符串。避免了手动通过stringstream或者sprintf进行转换的麻烦。Lambda表达式自定义比较器sort函数的第三个参数是一个比较函数或函数对象。这里我们使用了Lambda表达式[](const string a, const string b) { return a b b a; }。它捕获列表为空[]接受两个常量字符串引用并返回布尔值。这里必须注意比较规则要满足严格弱序即非自反性comp(a, a)为 false。非对称性若comp(a, b)为 true则comp(b, a)为 false。传递性若comp(a, b)为 true 且comp(b, c)为 true则comp(a, c)为 true。 我们的规则ab ba是满足严格弱序的可以安全用于sort。字符串拼接的效率在循环中使用result s进行拼接。在C中std::string的操作符通常会有优化例如SSO - Small String Optimization或预留空间对于这种场景效率足够。如果数字量极大例如上百万可以考虑先用reserve预分配足够内存。前导零处理的细节while循环寻找第一个非’0‘字符。注意边界条件firstNonZero result.size()要写在前面防止越界。特判全零情况如果firstNonZero走到了result.size()说明没找到非零字符即全零应返回”0“。使用substr截取子串。substr(firstNonZero)表示从firstNonZero位置开始截取到字符串末尾。3.2 算法正确性证明简要为什么这样两步走是对的排序步骤假设最优排列是S s1 s2 ... sn。如果存在相邻的si和s(i1)满足si s(i1) s(i1) si字符串比较那么交换si和s(i1)会得到一个字典序更小的字符串S’这与S是最优解矛盾。因此最优排列中任意相邻字符串必须满足si s(i1) s(i1) si。我们的自定义排序恰好能得到满足这个性质的序列。去零步骤在整数表示中前导零没有意义。去掉它们能得到数值上更小的表示例如012-12。但有一个例外如果数字本身就是0那么应该输出”0“而不是空字符串””。4. 深入探讨边界条件、陷阱与优化在实际编码和面试中只写出核心算法是不够的。面试官往往会追问边界条件、时间空间复杂度以及可能的优化。这部分就是体现你工程思维和严谨性的地方。4.1 必须考虑的边界条件输入为空数组题目通常保证n 1但如果没保证呢我们的代码中如果nums为空strs为空sort不会执行result为空字符串while循环不会进入firstNonZero为0且等于result.size()也为0因此会返回”0“。这符合常理吗空数组应该返回什么这可能需要和出题人确认。一个更健壮的实现可以在开头判断if (nums.empty()) return “0”;或者根据题目要求返回空串。全零输入我们已经处理了返回”0“。大数输入数字位数很多题目限定为“一位数”所以不存在。但如果扩展问题数字可能很大例如[123, 456, 789]我们的算法依然有效因为比较的是字符串拼接。但要注意拼接字符串ab和ba会产生临时字符串如果原始字符串很长这会带来额外的内存和时间开销。输入包含负数一位数通常是非负整数0-9。如果包含负数问题会变得复杂因为负号’-‘参与字符串比较会打破之前的规则例如”-1“和”2“”-1“”2“ “-12“,”2“”-1“ “2-1“字典序比较”-12“”2-1“但-12在数值上小于2-1吗这需要重新定义规则通常需要将正数和负数分开处理。4.2 时间复杂度与空间复杂度分析时间复杂度 O(n log n)主要开销在排序。假设有n个数字平均每个数字转换为字符串后长度为k本题中k1。在排序的比较过程中每次比较需要拼接两个字符串时间复杂度为O(k)。因此整个排序的时间复杂度为O(n log n * k)。由于k是常数一位数所以是O(n log n)。空间复杂度 O(n)我们需要一个额外的字符串数组strs来存储所有数字的字符串形式其大小为n。排序可能使用O(log n)的栈空间递归深度。拼接结果字符串result的长度最大为n*k也属于O(n)。因此总空间复杂度为O(n)。4.3 一个常见的实现陷阱比较函数的设计切记不要这样写比较函数// 错误示例试图通过直接比较字符串来模拟拼接比较 sort(strs.begin(), strs.end(), [](const string a, const string b) { if (a[0] ! b[0]) return a[0] b[0]; // 错误只比较了首字符 else return a b; // 错误没有考虑拼接后的效果 });这种写法对于{“3”, “32”, “321”}这样的输入就会出错。正确的最小排列是”321323“但上述错误比较可能会得到”323321“。一定要牢牢抓住ab和ba比较这个核心。4.4 优化思路避免频繁的字符串拼接在比较函数中ab ba会创建两个临时字符串。如果字符串很长或数据量很大这会成为性能瓶颈。一个优化思路是不创建临时字符串而是在比较函数中直接逐字符比较。我们可以这样实现比较函数同时遍历字符串a和b但将它们视为循环拼接后的无限长字符串。具体来说对于位置i我们比较的字符是a[i % len_a]和b[i % len_b]直到比较出大小为止。sort(strs.begin(), strs.end(), [](const string a, const string b) { int len_a a.size(), len_b b.size(); int i 0; // 最多比较 len_a len_b 次就能分出胜负 while (i len_a len_b) { char ca a[i % len_a]; char cb b[i % len_b]; if (ca ! cb) { return ca cb; } i; } // 如果循环结束还没分出大小说明 ab ba谁在前都行返回false保持稳定 return false; });这种实现避免了字符串拼接将每次比较的时间复杂度从O(k)降到了O(1)平均但代码稍复杂。对于一位数这种简单场景性能提升微乎其微但作为一种优化思路和面试时的谈资值得了解。5. 从问题到举一反三相关的算法思维训练这道“最小数”问题虽然简单但它蕴含的思维可以迁移到很多地方。5.1 变体问题最大数如果把问题改成“组成最大数”该如何修改很简单只需要将自定义比较函数中的改为即可。即排序规则变为如果ab ba则a排在b前面。前导0的处理通常不再是问题因为大数不会以0开头除非全零但全零的特判依然需要。5.2 扩展问题非一位数/任意整数如果输入不是一位数而是任意非负整数例如[3, 30, 34, 5, 9]我们的算法完全适用不需要修改。这正是这个算法强大之处。你可以用[10, 2]测试算法会正确得出”102“”210“从而将2排在10前面得到”210“等等这里有个坑。对于[10, 2]a”10“, b”2“ab”102“,ba”210“”102“”210“所以”10“排在”2“前面排序结果为[“10”, “2”]拼接为”102“。而如果2在前得到”210“显然102210。所以算法得到”102“是正确的最大数不我们要的是最大数吗我们讨论的是最小数。对于最小数102确实小于210所以”10“在”2“前是正确的。这验证了算法的通用性。5.3 思维连接自定义排序Comparator的广泛应用这道题的核心技巧是自定义排序规则。这在算法问题中极其常见区间问题如合并区间需要按区间起点排序。调度问题如安排会议可能需要按结束时间排序贪心。字符串排序如字母异位词分组可以将字符串排序后作为key。复杂对象排序如对一组学生先按成绩降序成绩相同按姓名升序。掌握如何为一个复杂问题设计出正确的比较规则是算法能力的重要体现。关键是要明确排序的目标并确保比较规则满足严格弱序这样才能作为sort函数的有效比较器。6. 工程实践中的注意事项与调试技巧当你把这段代码应用到实际项目或在线判题系统OJ时还需要注意以下几点6.1 输入输出格式OJ上的题目通常有严格的输入输出要求。我们的Solution类中的minNumber函数接口是通用的。但在实际读取输入时你可能需要处理如下格式第一行数字个数 n 第二行n个用空格隔开的一位数对应的主函数可能是int main() { int n; cin n; vectorint nums(n); for (int i 0; i n; i) { cin nums[i]; } Solution sol; cout sol.minNumber(nums) endl; return 0; }务必注意如果题目描述中数字是“一位数”但输入可能包含多位数或者数字之间用逗号分隔你需要根据实际情况调整输入解析逻辑。仔细阅读题目描述和样例输入输出是避免“Wrong Answer”的第一步。6.2 使用调试工具以VSCode为例从热词“vscode配置c/c环境”可以看出很多朋友在用VSCode。这里分享一个快速调试本例的方法安装C/C扩展。在项目目录下创建.vscode/launch.json和.vscode/tasks.json进行配置网上教程很多。在关键行如排序前、排序后、处理前导零前设置断点。使用“调试控制台”或“变量监视”窗口查看strs数组在排序前后的变化以及result字符串的生成过程。这对于理解算法流程和排查边界条件错误非常有帮助。6.3 单元测试的重要性不要只依赖题目给的样例。自己构造一些边缘用例进行测试{0}{0, 0, 0}{5, 5, 5}{1}{9, 8, 7, 6, 5, 4, 3, 2, 1, 0}{1, 0, 0, 1}(包含重复的0和1)编写一个简单的测试函数自动运行这些用例并对比预期输出能极大提高代码的可靠性。7. 总结与个人心得回顾回顾整个解题过程从最初“简单排序”的直觉到发现“前导零”陷阱再到推导出“拼接比较”的核心规则最后完善边界处理这是一个非常典型的算法问题解决路径。我个人的体会是面对这类问题一定要先用手动枚举小样例特别是包含0和重复数字的样例去验证你的初步想法。很多错误在纸笔演算阶段就能发现。其次深刻理解工具。比如std::sort的比较器要求严格弱序为什么我们的ab ba满足因为它等价于比较两个字符串的字典序而字典序本身满足严格弱序。如果不确定可以尝试证明其传递性。最后工程化思维。写出能通过样例的代码只是第一步。考虑异常输入、分析复杂度、思考优化空间这些才是从“做题”到“解决问题”的关键跃升。这道题虽然基础但它像一颗棱镜折射出了C字符串处理、STL算法应用、自定义排序、边界条件处理等多个知识点。把它吃透远比刷十道模糊的题目更有价值。在后续的学习中你可以尝试用同样的思维去解决力扣上的“最大数”179. Largest Number问题或者更复杂的自定义排序场景。编程能力的提升正是在这样一个个具体问题的深入剖析和举一反三中积累起来的。