ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++ std::sort 与 cmp 函数完全指南:从基础原理到高级应用

C++ std::sort 与 cmp 函数完全指南:从基础原理到高级应用 1. 从“排序”这个日常需求说起如果你写过任何超过“Hello World”的程序几乎都绕不开“排序”这件事。无论是处理一批用户数据按年龄排列还是管理一堆文件按修改时间排序甚至是游戏里给玩家按得分排个名次排序都是最基础、最高频的操作之一。在C的世界里面对排序需求你第一个想到的不应该是自己吭哧吭哧写个冒泡或者快排而是直接掏出标准库里的“瑞士军刀”——std::sort。我见过太多新手包括几年前的我对std::sort的态度是“能用就行”结果在稍微复杂一点的场景下就栽了跟头。比如想降序排列却得到升序想按自定义对象的某个属性排序程序直接编译报错更头疼的是当排序规则需要多个条件时写出来的cmp函数逻辑混乱结果时对时错。这些坑本质上都是因为没真正搞懂sort函数尤其是它的灵魂——比较函数cmp。网上的资料很多但往往要么过于简略只给个sort(arr, arrn)的例子要么一上来就大谈特谈函数对象和Lambda表达式让初学者望而却步。这篇内容我想换种方式不堆砌语法而是结合我这些年踩过的坑和积累的经验把std::sort和cmp的用法掰开揉碎了讲清楚。目标很简单让你看完之后对C排序的需求能真正做到“一学就会一用就对”再也不用去搜索引擎里翻那些零碎的代码片段。2.std::sort的基本面貌与核心原理std::sort是C标准模板库STL中定义在algorithm头文件里的一个函数模板。它的强大之处在于其通用性和高效率。你不需要关心底层是快速排序、内省排序还是混合了插入排序STL的实现已经为你优化好了。你需要关心的只是两件事排谁以及按什么规则排。2.1 函数原型与基本用法std::sort最常见的一种函数原型是这样的template class RandomIt void sort( RandomIt first, RandomIt last ); template class RandomIt, class Compare void sort( RandomIt first, RandomIt last, Compare comp );RandomIt这是一个随机访问迭代器。简单理解就是像数组指针、vector的begin()/end()、deque的迭代器这类能快速“跳”到任意位置的迭代器。像list的迭代器就不行所以list有自己专用的sort成员函数。first,last定义了要排序的范围[first, last)左闭右开。last指向的是序列“末尾的下一个位置”。comp比较函数或函数对象、Lambda表达式等。它是决定排序顺序的关键。一个最基础的例子排序一个vectorint#include iostream #include vector #include algorithm int main() { std::vectorint nums {5, 2, 8, 1, 9}; std::sort(nums.begin(), nums.end()); for (int num : nums) { std::cout num ; } // 输出1 2 5 8 9 return 0; }这里只传入了两个迭代器sort会使用默认的“小于”操作符进行比较所以结果是升序排列。注意std::sort默认是不稳定排序。这意味着如果两个元素根据比较规则是“相等”的即!comp(a,b) !comp(b,a)那么它们在排序后的相对位置可能是任意的。如果你需要保持相等元素的原始相对顺序应该使用std::stable_sort。2.2 理解“比较”的本质严格弱序这是理解cmp函数如何工作的核心也是很多错误的根源。std::sort以及很多其他STL算法要求你提供的比较规则必须满足严格弱序。这听起来很学术但我们可以用更直观的三条规则来理解它。对于比较函数comp(a, b)非自反性comp(a, a)必须为false。一个元素不能“小于”它自己。非对称性如果comp(a, b)为true那么comp(b, a)必须为false。如果a在b前面那么b就不能在a前面。可传递性如果comp(a, b)为true且comp(b, c)为true那么comp(a, c)也必须为true。如果a在b前b在c前那么a一定在c前。此外由严格弱序可以推导出“等价”的概念如果!comp(a,b) !comp(b,a)成立我们就说a和b是等价的注意不一定是相等。在排序时等价的元素可以被任意排列除非用stable_sort。为什么必须遵守因为排序算法内部依赖这些逻辑假设来正确工作。如果你写了一个违反严格弱序的cmp函数比如在元素相等时返回true或者出现了ab和ba同时为真的矛盾情况会导致未定义行为——程序可能崩溃、死循环或者产生完全错误的排序结果。这是最需要警惕的坑。3. 构建cmp函数从简单到复杂cmp函数是sort的灵魂它接受两个参数通常是常量引用避免拷贝开销返回一个bool值。返回true意味着第一个参数应该排在第二个参数之前。3.1 基础类型与降序排列对于int,double,string等基础类型降序排列非常简单bool cmp_int_desc(int a, int b) { return a b; // 降序a b 时a排在b前面 } int main() { std::vectorint nums {5, 2, 8, 1, 9}; std::sort(nums.begin(), nums.end(), cmp_int_desc); // 或者直接使用标准库的函数对象 std::greaterint() // std::sort(nums.begin(), nums.end(), std::greaterint()); for (int num : nums) { std::cout num ; } // 输出9 8 5 2 1 return 0; }这里cmp_int_desc满足严格弱序吗检查一下aa为假满足非自反性。若ab为真则ba必为假满足非对称性。传递性也显然满足。所以是安全的。3.2 自定义结构体/类的排序这是cmp函数大显身手的地方。假设我们有一个Student结构体struct Student { std::string name; int score; int age; };我们想按分数从高到低排分数相同则按年龄从小到大排。写法一定义独立的比较函数bool cmp_student(const Student a, const Student b) { if (a.score ! b.score) { return a.score b.score; // 分数高的在前 } return a.age b.age; // 分数相同年龄小的在前 } int main() { std::vectorStudent students {{Alice, 90, 20}, {Bob, 85, 22}, {Charlie, 90, 19}}; std::sort(students.begin(), students.end(), cmp_student); // 排序后Charlie(90,19), Alice(90,20), Bob(85,22) return 0; }关键点分析参数使用const Student避免拷贝整个结构体的开销这是良好的习惯。比较逻辑是分层的先比较第一关键条件分数如果不相等直接返回结果如果相等再比较第二关键条件年龄。这种“瀑布式”判断是处理多条件排序的标准写法。这个函数也满足严格弱序可以放心使用。写法二重载结构体的小于运算符如果你希望这个排序规则是这个结构体的“默认”规则可以重载operator。这样直接调用std::sort(begin, end)就会使用这个规则。struct Student { std::string name; int score; int age; // 重载小于运算符 bool operator(const Student other) const { if (score ! other.score) { return score other.score; // 注意这里重载的是但逻辑是“分数高的小”这有点反直觉。 // 更常见的做法是如果希望默认是升序就在这里定义升序逻辑。 // 或者只为sort提供自定义cmp不重载operator。 } return age other.age; } }; // 使用std::sort(students.begin(), students.end());实操心得我个人更倾向于不轻易重载operator除非这个比较规则对于这个类型有明确、唯一、公认的“默认”语义比如Point按x坐标再按y坐标排序。对于像Student这样排序需求可能多变有时按分数有时按姓名的类型定义独立的cmp函数或者使用Lambda表达式更加灵活和清晰不会污染结构体的默认行为。3.3 使用Lambda表达式更现代的写法C11引入的Lambda表达式让写cmp函数变得极其方便尤其是在函数内部临时使用一次的情况std::vectorStudent students {...}; // 使用Lambda表达式按姓名升序排序 std::sort(students.begin(), students.end(), [](const Student a, const Student b) { return a.name b.name; }); // 更复杂的多条件排序分数降序同分者年龄升序 std::sort(students.begin(), students.end(), [](const Student a, const Student b) { if (a.score ! b.score) { return a.score b.score; // 注意是 降序 } return a.age b.age; // 升序 });Lambda表达式[](const Student a, const Student b) { return ...; }在这里就是一个匿名函数对象。它写起来更紧凑逻辑和上下文比如用到的局部变量结合得更紧密是现代C编程的首选方式。3.4 使用标准库函数对象对于简单的升降序STL已经提供了模板类无需自己写std::lessT() 使用operator比较升序默认。std::greaterT() 使用operator比较降序。std::greater_equalT()等但注意sort要求严格弱序和不满足非自反性绝对不能直接用作sort的比较器std::sort(vec.begin(), vec.end()); // 默认升序等价于 std::sort(vec.begin(), vec.end(), std::lessint()); std::sort(vec.begin(), vec.end(), std::greaterint()); // 降序4. 高级场景与深度避坑指南掌握了基本写法我们来看看那些容易出问题的高级场景。4.1 排序指针或智能指针容器有时我们容器里存放的不是对象本身而是指针原始指针或智能指针。这时排序的比较逻辑需要特别注意。struct Item { int id; int value; }; std::vectorItem* itemPtrs {new Item{2, 100}, new Item{1, 200}, new Item{3, 50}}; // 错误写法直接比较指针这比较的是内存地址毫无意义 // std::sort(itemPtrs.begin(), itemPtrs.end()); // 正确写法在cmp中解引用指针比较实际对象 std::sort(itemPtrs.begin(), itemPtrs.end(), [](Item* a, Item* b) { return a-value b-value; // 按value升序排序 }); // 对于智能指针也一样 std::vectorstd::shared_ptrItem itemSharedPtrs ...; std::sort(itemSharedPtrs.begin(), itemSharedPtrs.end(), [](const std::shared_ptrItem a, const std::shared_ptrItem b) { return a-value b-value; });核心要点比较的是指针所指向的对象而不是指针本身的值内存地址。4.2 复杂多条件排序与“字典序”技巧当排序条件非常多时if-else链会很长。一个更优雅的技巧是利用std::tie来构造元组利用元组内置的字典序比较。struct Record { std::string region; std::string department; int sales; int priority; }; // 传统多if写法 bool cmp_record_verbose(const Record a, const Record b) { if (a.region ! b.region) return a.region b.region; if (a.department ! b.department) return a.department b.department; if (a.sales ! b.sales) return a.sales b.sales; // 销售额降序 return a.priority b.priority; } // 使用std::tie的简洁写法注意所有条件必须同为升序或降序 bool cmp_record_tie(const Record a, const Record b) { // 注意sales是降序我们取负值转为“升序”比较 // 但前提是sales是非负整数否则取负可能溢出或不符合预期 // 更通用的方法是使用 std::make_tuple 并手动处理 return std::tie(a.region, a.department, -a.sales, a.priority) std::tie(b.region, b.department, -b.sales, b.priority); } // 更通用、安全的写法使用 std::make_tuple 并配合条件逻辑 bool cmp_record_tuple(const Record a, const Record b) { // 对于降序条件比较时交换a和b的位置即可实现“反向” return std::make_tuple(a.region, a.department, std::greaterint{}(a.sales, b.sales)? 0 : 1, a.priority) std::make_tuple(b.region, b.department, std::greaterint{}(b.sales, a.sales)? 0 : 1, b.priority); // 上述写法复杂了实际上对于混合升降序手动if链可能更清晰。 }经验之谈std::tie技巧在**所有排序条件方向一致同为升序**时非常简洁优雅。一旦条件方向不一致强行用tie会变得晦涩且容易出错。在这种情况下我宁愿使用清晰明了的if-else链虽然代码长一点但可读性和可维护性更好。不要为了“炫技”而牺牲代码的清晰度。4.3cmp函数中常见的“坑”违反严格弱序这是最严重的错误。// 错误示例试图实现“非递减”排序但相等时返回true bool bad_cmp(int a, int b) { return a b; // 当ab时返回true违反了非自反性(aa为真) } // 使用这个cmp会导致未定义行为。正确做法对于sort永远只实现“严格小于”的关系。如果需要“非递减”排序等价元素会被放在一起但顺序不确定。如果需要稳定排序用std::stable_sort。在cmp函数中修改元素cmp函数应该是一个“纯函数”只读不写。修改元素内容不仅逻辑混乱也可能破坏排序算法内部状态导致错误。// 极其糟糕的做法 bool evil_cmp(Student a, Student b) { // 非常量引用 a.access_count; // 修改了数据 return a.score b.score; }cmp函数性能不佳对于简单类型如int自定义cmp函数特别是通过函数指针调用可能比内置操作符稍慢。但对于复杂比较如字符串比较、多字段比较这点开销微不足道。真正的性能杀手通常是cmp函数内部做了昂贵的操作比如bool expensive_cmp(const std::string a, const std::string b) { // 每次比较都计算字符串长度如果字符串很长代价巨大 if (a.length() ! b.length()) { return a.length() b.length(); } return a b; }优化思路如果比较逻辑复杂或依赖昂贵计算可以考虑“预计算”。例如在排序前将需要频繁计算的属性提取出来存到结构体里或者使用一个pair或tuple的中间容器来排序。浮点数的比较浮点数有精度误差直接使用或!判断相等不可靠。在cmp函数中判断浮点数是否相等需要引入一个误差范围epsilon。#include cmath bool cmp_double(double a, double b) { const double eps 1e-9; if (fabs(a - b) eps) { return a b; // 在误差范围外认为不相等正常比较 } return false; // 在误差范围内认为相等返回false }注意这可能会轻微破坏严格弱序的传递性在极端接近epsilon的情况下但对于大多数实际应用场景是可接受的。更严格的排序可能需要使用std::isless等函数。5. 实战一个综合排序案例让我们通过一个稍微复杂的例子把上面的知识点串起来。假设我们要处理一个日志向量每条日志有时间戳、日志级别和消息。我们需要先按时间戳升序最早的在前时间戳相同则按日志级别排序ERRORWARNINFODEBUG级别再相同则按消息内容字典序升序。#include iostream #include vector #include algorithm #include string enum class LogLevel { DEBUG, INFO, WARN, ERROR }; struct LogEntry { time_t timestamp; // 时间戳 LogLevel level; std::string message; }; // 将日志级别转换为可比较的整数权重 int getLevelWeight(LogLevel level) { switch (level) { case LogLevel::DEBUG: return 0; case LogLevel::INFO: return 1; case LogLevel::WARN: return 2; case LogLevel::ERROR: return 3; default: return -1; } } bool cmp_log_entry(const LogEntry a, const LogEntry b) { // 第一条件时间戳升序 if (a.timestamp ! b.timestamp) { return a.timestamp b.timestamp; } // 第二条件日志级别降序ERROR优先级最高 // 通过权重值来实现降序比较 int weightA getLevelWeight(a.level); int weightB getLevelWeight(b.level); if (weightA ! weightB) { return weightA weightB; // 注意这里用 实现降序 } // 第三条件消息内容升序 return a.message b.message; } int main() { std::vectorLogEntry logs { {1700000000, LogLevel::INFO, System started}, {1700000000, LogLevel::ERROR, Disk failure}, {1700000005, LogLevel::DEBUG, Debug signal received}, {1700000000, LogLevel::WARN, High memory usage}, {1700000005, LogLevel::INFO, Task completed}, }; std::sort(logs.begin(), logs.end(), cmp_log_entry); for (const auto log : logs) { std::cout log.timestamp [ static_castint(log.level) ] log.message std::endl; } // 期望输出顺序 // 1700000000 [3] Disk failure (ERROR, 时间戳最早同时间戳中级別最高) // 1700000000 [2] High memory usage (WARN) // 1700000000 [1] System started (INFO) // 1700000005 [1] Task completed (INFO, 时间戳晚) // 1700000005 [0] Debug signal received (DEBUG) return 0; }这个案例的要点解析多条件混合方向时间戳升序、级别降序、消息升序。我们通过if分层处理在级别比较时通过比较转换后的权重值并反向来实现降序。枚举类型的比较直接比较LogLevel枚举值可能不符合语义ERROR的整数值可能比DEBUG小。我们通过一个辅助函数getLevelWeight将其映射为具有语义的权重值这是一个常用技巧。清晰的分层逻辑cmp函数的结构清晰地反映了排序的优先级易于阅读和维护。6. 性能考量与std::sort的局限性std::sort平均时间复杂度为 O(N log N)在绝大多数情况下都是最佳选择。但在某些特定场景下可能有更优方案数据几乎已排序如果数据已经基本有序std::sort的快速排序分区可能效率不高。此时可以考虑std::stable_sort通常是归并排序或std::inplace_merge。更激进的做法是如果数据是持续插入并需要保持有序应该考虑使用std::set或std::multiset。数据量极小当要排序的元素数量非常少比如少于16个快速排序的递归开销和函数调用开销可能比重更大。一些标准库实现会在内部对小数组切换到插入排序。如果你能确定数据量极小手动写一个插入排序可能更快但通常不需要操心这个编译器优化很强大。仅需前N个元素Top K问题如果你只需要最大的K个元素或最小的K个元素使用std::partial_sort或std::nth_element会比全排序快得多。std::vectorint data {9, 3, 6, 1, 7, 2, 8, 5, 4}; // 找出最小的3个元素并放在前三位顺序不一定 std::nth_element(data.begin(), data.begin() 3, data.end()); // data[0], data[1], data[2] 现在是整个数组中最小的三个元素但彼此无序 // 找出最小的3个元素并排序放在前三位 std::partial_sort(data.begin(), data.begin() 3, data.end()); // data[0] data[1] data[2] 是全局最小的三个元素稳定性要求记住std::sort不保证稳定。如果需要稳定排序必须使用std::stable_sort。cmp函数本身的成本如果cmp函数非常复杂例如涉及字符串哈希、数据库查询、网络请求——这很糟糕但有时难免那么排序的整体性能瓶颈就在cmp上。此时应该想方设法简化cmp或者预处理数据。7. 举一反三cmp在其他STL算法中的应用理解了cmp你就掌握了STL中一大批算法的钥匙。许多算法都接受一个可选的比较器参数其语义和sort中的cmp完全一致。std::lower_bound/std::upper_bound/std::binary_search在已排序的范围内进行二分查找。它们使用相同的比较规则来确定元素的顺序和等价性。非常重要传递给这些查找算法的比较规则必须和排序时使用的规则一致否则结果未定义。std::vectorStudent students ...; // 已按cmp_student规则排序 Student key {, 90, 20}; // 想找分数为90的学生 // 使用相同的cmp规则进行二分查找 auto it std::lower_bound(students.begin(), students.end(), key, cmp_student);std::min_element/std::max_element查找最小/最大元素。可以自定义比较器来定义什么是“小”和“大”。auto oldest std::max_element(students.begin(), students.end(), [](const Student a, const Student b) { return a.age b.age; });std::priority_queue优先队列堆的排序规则也通过比较器定义。需要注意的是priority_queue默认是最大堆使用std::less这意味着“最大”的元素在队首。如果你想要最小堆需要显式提供std::greater。// 最大堆分数高的优先级高 std::priority_queueStudent, std::vectorStudent, decltype(cmp_student) pq(cmp_student); // 注意priority_queue的cmp语义是如果a应该排在b后面则cmp(a,b)返回true。 // 这通常和sort的“a是否在b前面”语义是相反的但用我们定义的cmp_student分数高者前刚好符合最大堆需求。关联容器 (std::set,std::map,std::multiset,std::multimap)这些容器在构造时可以传入一个比较器对象用于维护内部元素的顺序。这个比较器必须满足严格弱序且在整个容器生命周期内保持不变。struct Point { int x; int y; }; auto point_cmp [](const Point a, const Point b) { return std::tie(a.x, a.y) std::tie(b.x, b.y); }; std::setPoint, decltype(point_cmp) pointSet(point_cmp);掌握cmp的构造绝不仅仅是为了sort。它是你理解并高效运用整个STL有序算法和容器的基石。从sort入手把严格弱序、多条件比较这些概念吃透再去看lower_bound、set、priority_queue你会发现它们都是一脉相承的学习成本大大降低。
返回列表