ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++按绝对值大小排序:std::sort比较器与边界坑详解

C++按绝对值大小排序:std::sort比较器与边界坑详解 看到“输入一组数字按照其绝对值从大到小进行排序C”这个需求我第一反应是这道题我熟。但带新人和做 code review 的次数多了以后我越来越发现越是这种看着简单的题目越能暴露基本功。排序本身谁都会关键是你怎么把“按绝对值”这个条件干净地传进排序算法。这篇文章不讲废话直接给一套能跑的完整代码再把背后的原理、边界坑、扩展用法一次讲清楚。适合正在学 C 的初学者也适合想把手头代码写得更稳的开发者。你拿到题目后别急着敲代码先花两分钟想想几个问题输出的是原值还是绝对值用什么排序算法比较器怎么写才能避开隐蔽的崩溃想清楚这些代码写起来反而更快。1. 需求拆解与整体思路1.1 先搞清楚题目真正要你做什么题目描述很简洁给一组数字先取每个数的绝对值再按绝对值从大到小排序最后输出。很多人第一次做这道题会下意识写出“先算绝对值、再排序、再输出绝对值”的版本结果完全跑偏。举个例子输入-5 3 -2 7正确答案是7 -5 3 -2而不是7 5 3 2。符号信息必须保留排序依据只是“绝对值大小”这个标尺而已。这题的隐藏考点其实是“自定义比较规则”。C 标准库里的std::sort默认按元素自身的从小到大排你要改成按绝对值排序就等于告诉排序算法不要用默认规则换一套。这个“告诉”的动作就是给std::sort传第三个参数——比较器。初学者最常见的问题是只会写sort(a.begin(), a.end())一遇到“按绝对值排”这种变体就慌本质上是没理解比较器的位置和作用。把这一点想通整个题就通了一半。还有个小细节输入的数字是整数还是浮点数题目一般默认是整数但后面我会专门聊浮点数的坑。先按整数处理最稳妥代码清晰也不会被类型问题分心。1.2 方案选型为什么优先 STL 而不是手写排序很多人看到“排序”两个字第一反应是冒泡排序、选择排序、快速排序背一遍。实际上工程解法根本不需要手写排序算法。STL 的std::sort是内省排序综合了快速排序、堆排序和插入排序的优点平均复杂度 O(n log n)最坏情况也能做到 O(n log n)而且在数据量小的时候会切到插入排序常数极小。自己写一个快排数据分布糟糕时可能退化成 O(n²)甚至递归深度过大导致栈溢出。所以我的选择很明确数据放进std::vector用std::sort加 lambda 写比较器。为什么是 vector因为读入个数不确定时动态扩容最方便为什么用 lambda因为比较规则只在这个场景用一次写在排序调用旁边读代码的人一眼就能看到“哦这里是按绝对值从大到小排”。如果规则要在多个函数里复用再考虑抽成命名函数或函数对象这个下面细说。2. 核心实现一段能直接跑的完整代码2.1 最小可用版本先把完整代码放出来你可以直接复制编译运行。#include iostream #include vector #include algorithm #include cstdlib // std::llabs int main() { int n; std::cout 请输入数字个数: ; std::cin n; std::vectorlong long nums(n); std::cout 请输入 n 个数字用空格或换行分隔: ; for (int i 0; i n; i) { std::cin nums[i]; } std::sort(nums.begin(), nums.end(), [](const long long a, const long long b) { return std::llabs(a) std::llabs(b); }); std::cout 按绝对值从大到小排序结果: ; for (const auto x : nums) { std::cout x ; } std::cout std::endl; return 0; }用long long而不是int不是小题大做是为了规避一个特别阴的溢出问题具体在第 3.2 节讲。你如果只是做题改回int也能跑但我建议把类型习惯养好。2.2 代码逐段拆解输入、排序、输出先从输入说起。std::cin n读个数然后构造vectorlong long nums(n)一次性开出 n 个元素的容量再用循环读入。这里有个小知识点vectorlong long nums(n)会把每个元素默认初始化成 0你后面逐个覆盖没问题。如果你不想预先开大小也可以vectorlong long nums; nums.reserve(n);然后push_back但既然 n 已知直接开大小更省事。排序这行是核心。std::sort(nums.begin(), nums.end(), lambda)前两个参数是迭代器区间第三个参数是“排序规则”。规则返回true表示第一个参数应该排在第二个参数前面。lambda 里写return std::llabs(a) std::llabs(b);意思是绝对值越大越靠前。如果哪天题目改成“从小到大”把换成就行。输出部分用范围 for 遍历nums打印的是原值不是llabs(x)。如果你在输出时手滑写了std::cout std::llabs(x)那结果就成了绝对值序列符号全丢了。这是初学者最容易犯的错误之一我亲眼见过有人排查好久才发现是输出写错。还有一个容易忽略的点std::sort是原地排序直接修改nums。如果你不希望原始数据被打乱做法是先拷贝一份再排std::vectorlong long sorted nums; std::sort(sorted.begin(), sorted.end(), comp);原始数据保留在nums里排好的结果在sorted里。真实业务场景里“既要有原始顺序、又要有一份拍好序的副本”很常见比如报表展示时需要保留用户录入顺序同时榜单要按规则排序。2.3 比较器写法Lambda、普通函数、函数对象怎么选同一个比较规则至少有三种写法。第一种lambda 表达式上面已经用过。优点是就地定义逻辑紧凑缺点是匿名复用性差如果多个地方都用同一套规则到处写一样的 lambda 很冗余。第二种普通函数。先定义bool absDesc(long long a, long long b)再把函数名传给sortbool absDesc(long long a, long long b) { return std::llabs(a) std::llabs(b); } std::sort(nums.begin(), nums.end(), absDesc);注意传的是函数名不是函数调用后面不能加括号。普通函数的好处是可以在多个排序点复用也方便单测缺点是“规则”和“调用处”分离了读代码时要跳转一下才能看到规则。第三种函数对象也叫仿函数struct AbsDesc { bool operator()(long long a, long long b) const { return std::llabs(a) std::llabs(b); } }; std::sort(nums.begin(), nums.end(), AbsDesc{});函数对象适合比较规则特别复杂、需要携带额外状态的情况。比如你想在比较时参考一个外部阈值普通函数做不到lambda 可以捕获函数对象可以通过成员变量携带。不过这道题没这么复杂lambda 是最合适的。还有一个细节lambda 的参数我用的是const long long。对这种基础类型写成[](long long a, long long b)直接值传递也没问题性能几乎一样。但如果你以后排的是结构体、大对象用const T就能避免无谓的拷贝这个习惯值得养成。3. 边界情况与隐藏坑这些坑我全踩过3.1 比较器必须满足严格弱序这是标准库排序算法的前置条件。术语叫“严格弱序”听着唬人拆开就三条对任意元素 a不能出现comp(a, a)为真这叫不可自反。如果comp(a, b)为真那么comp(b, a)必须为假这叫反对称。如果comp(a, b)为真且comp(b, c)为真那么comp(a, c)必为真这叫传递性。只要你的比较器满足这三条std::sort就能正常工作。最典型的错误是写成return std::llabs(a) std::llabs(b);用了而不是。为什么错因为当a和b绝对值相等时comp(a, a)也是真违反了第一条规定。标准库拿到这种非法比较器轻则排序结果全乱重则死循环。我见过一个同事在排序代码里用线上服务经常无规律卡死排查到最后就是这一行的问题。记住这条铁律比较器只在“明确应该排前面”时返回true等于的情况一律返回false。3.2 std::abs 的整数溢出陷阱这个坑特别隐蔽没踩过的人很难意识到。C 和 C 里的std::abs(int)返回int如果传入INT_MIN即 -2147483648它的绝对值是 2147483648但 int 类型最大只能表示 2147483647差了一位。这时行为是未定义的可能返回一个错误值也可能直接崩掉。在标准库比较器里面出现这种未定义行为后果不可预测。举例说明输入数据里包含 -2147483648你用int存写的是std::sort(nums.begin(), nums.end(), [](int a, int b){ return std::abs(a) std::abs(b); });程序可能在排序过程中得到乱七八糟的中间结果甚至越界访问。这真不是理论是实战会遇到的坑尤其数据来自业务侧或文件流你根本控制不了边界值。解决方案很简单要么用更大的类型做绝对值计算要么用long long类型的容器。我上面代码里直接用了vectorlong long和std::llabs在绝大多数场景下就安全了。std::llabs就是 long long 版本的求绝对值函数头文件是cstdlib。如果你用的编译器比较老也可以写std::abs(static_castlong long(a)) std::abs(static_castlong long(b))效果一样。严格来说LLONG_MIN也存在同样问题但实际数据量级到不了那里可以忽略。真遇到极端情况可以自己写一个基于符号判断的安全比较器用无符号类型参与运算不过这就属于极少见的特殊需求了日常把long long用上已经足够。3.3 稳定性与多关键字排序std::sort是不稳定排序意思是两个“按比较器判定相等”的元素排序前后相对位置可能变化。举个例子输入是-5 5 3绝对值从大到小-5 和 5 比较时comp都是假它们可以任意交换最终输出可能是-5 5 3也可能被排成5 -5 3。如果题目只要求“按照绝对值从大到小”不关心绝对值相同的顺序那用std::sort没问题。如果要保证“绝对值相同时保持原输入顺序”你可以用std::stable_sort它是有稳定性的排序算法。不过日常开发里我更推荐加第二关键字让“相等”不再存在。比如要求绝对值相同的情况下按原值从小到大排列那比较器可以写成std::sort(nums.begin(), nums.end(), [](const long long a, const long long b) { long long absA std::llabs(a); long long absB std::llabs(b); if (absA ! absB) return absA absB; return a b; });这样排序结果完全可预测不会因为 STL 实现版本不同而产生不同的相对顺序对测试和排查都友好。多关键字排序在实际业务里特别常用比如“先按分数降序分数相同按学号升序”本质就是一层一层比较下去。这道题虽然简单背后这套思路以后会反复用到。3.4 浮点数排序与 NaN 问题如果输入数字是double或floatlambda 参数类型要改比较器里要用浮点版本的绝对值。C 的cmath提供了std::fabs不过现在std::abs对浮点数也有重载写std::abs(double)也能编译。头文件别漏cmath必须包含进去。浮点数最大的坑是 NaN。排序算法内部会做大量比较如果数据里混入一个NaN你写的比较器很可能不满足严格弱序。因为NaN x恒为假x NaN也恒为假两个方向都是假就相当于任意 NaN 都“等价”于任何数但实际又不符合传递性排序结果会非常混乱。工程上如果数据源可能产生 NaN应该在排序之前过滤掉或统一处理。竞赛题和课堂作业一般不会出现这种数据但你会做工程后这个雷早晚会碰上。4. 进阶扩展从这道题到真实工程4.1 不同容器下的排序差异vector 只是最常见的容器实际项目里数据可能存在于不同容器中。如果你拿到的是 C 风格数组long long arr[n]std::sort照样能用因为数组名可以退化成指针指针本身满足随机访问迭代器的要求std::sort(arr, arr n, comp);如果数据在std::deque、std::array里用法和 vector 完全一样。但如果数据在std::list里就不能用std::sort因为 list 的迭代器是双向迭代器不支持随机访问。list 有自己的成员函数list.sort(comp);很多新手在这里栽跟头看到编译错误“no match for operator-”一头雾水。其实核心就是一个概念不同的迭代器能力决定了能调用哪些算法。还有一种情况你并不想排完整个容器而是需要持续维护一个“当前绝对值最大”的状态。这时应该用std::priority_queue优先队列构造时同样可以传比较器。每次 push 一个元素进去堆顶自动就是最大值复杂度是 O(log n)比每次重新全排序 O(n log n) 高效得多。这种用法在事件流处理、TopK 动态榜单里非常常见。4.2 比较器里重复算 abs性能被低估了std::sort的时间复杂度是 O(n log n)比较器被执行 O(n log n) 次。如果你的比较器里每次调用std::llabs做一次计算虽然这个函数本身很快但如果你排序的是一批结构体而比较规则需要计算一个较重的指标那开销会被放大很多倍。优化思路很简单预先算好存下来。比如元素是结构体结构体里带上已经计算好的绝对值字段struct Item { long long value; long long absValue; }; std::vectorItem items; for (auto v : nums) items.push_back({v, std::llabs(v)}); std::sort(items.begin(), items.end(), [](const Item a, const Item b) { return a.absValue b.absValue; });代价是多花 O(n) 的额外空间换来的是比较器里不再有重计算。这叫“空间换时间”工程上很常见。还有另一种做法是拍下标数组原数据完全不动只对下标排序用下标去访问原数组取值比较。这在需要同时保留多套排序结果时特别有用比如同一批学生信息一套按成绩排序一套按学号排序排下标是最省事的方式。4.3 TopK 场景下的替代方案如果需求不是“把所有数字按绝对值从大到小排出来”而是“只取绝对值最大的前 k 个”全排序就有点浪费了。STL 提供了std::partial_sort和std::nth_element两个工具。std::partial_sort的时间复杂度是 O(n log k)排完保证前 k 个是有序的适合“需要前三名榜单”这种业务。std::nth_element平均 O(n)它只保证第 k 个位置上的元素恰好是“第 k 大”左边的都大于等于它右边的都小于等于它但左右两侧内部不一定有序。理解这些区别你就会明白为什么老工程师常说我么“看清需求再动手”——都是排序全排、拍一部分、只要第 k 个是三种完全不同的成本和写法。5. 常见问题与排查实录5.1 编译报错速查几个一眼就能定位的坑我整理了一份速查表都是实际编译时报过的错看到对应提示直接对照即可。症状常见原因修复sort is not a member of std没包含algorithm补上#include algorithmabs was not declared in this scope用错了头文件或者忘了std::整数用cstdlib浮点用cmath写std::abs或std::llabs给std::sort传了nums nvector 不支持这种指针写法用nums.begin()和nums.end()lambda 参数类型和容器元素类型对不上比如容器是long longlambda 参数写int改成const long long或const autono match for operator-想用std::sort排 std::list改用list.sort(comp)这里再说一句lambda 参数如果写成const auto a, const auto b是 C14 的泛型 lambda可以在不同容器类型之间复用省得频繁改参数类型。但可读性稍微差一点团队项目里要不要用看你们约定。5.2 排序结果不对从哪开始查如果程序能编译能跑结果就是不对先别急着怀疑编译器。我遇到过的“看起来没问题结果全错”场景不外乎四种。第一种排序方向反了。验证方法很简单输入一组简单数据比如1 -2 3 -4 5手算预期是5 -4 3 -2 1跑一下如果输出是1 -2 3 -4 5说明比较器里应该用结果写成了。方向错了改符号就行一分钟解决。第二种输出的是绝对值而不是原值。检查输出循环确认打印的是x而不是std::llabs(x)。我教过的好几个学员都在这里栽过因为眼睛盯在排序逻辑上没意识到输出层也改了数据。第三种边界溢出。数据里如果有 -2147483648int配合std::abs会出诡异问题。遇到这种情况检查存储类型是不是long long求绝对值是不是用了std::llabs。把这两处改对问题通常直接消失。第四种比较器写成了。这种错误最隐蔽因为不是每次必然出错可能恰好这次数据没问题下批数据就卡死。排查方法是把比较器的符号重新审视一遍确认“相等时返回 false”。我在实际干活时的调试手段是写一个独立的小函数把排序调用抽出来再用一个非常笨但一定正确的循环比较法当“基准答案”然后随机生成大批数据对比std::sort的结果和基准答案。只要有一组不一致就一定能揪出问题。这个方法效率极高建议你以后遇到任何排序逻辑异常都这么干成本比盯代码低太多了。5.3 顺带聊聊 VSCode 和编译环境最近总有同学在 VSCode 里写 C 排序代码编译不过来找我最后发现根本是环境问题不是代码问题。比如在 VSCode 里点了运行但项目没有配置编译任务或者默认调用的不是 g 而是系统残留的其他编译器。我的建议很朴素不熟悉 VSCode 的编译配置之前直接在终端里跑命令最省心。g -stdc11 main.cpp -o main ./main保证能跑通之后再回头折腾编辑器集成也不迟。另外很多竞争者习惯写#include bits/stdc.h这个万能头文件在特定在线评测环境里确实省事但它不是标准 C 头文件换个环境可能编译不过。工程代码里老老实实写iostream、vector、algorithm、cstdlib也就多敲几行换来的是可移植性。调试时也别只会看输出。如果排序结果很诡异可以用gdb或 IDE 的调试器打个断点看第 k 轮 swap 后数组长什么样再配合上面说的“随机数据对照笨办法”绝大多数问题都能快速定位。实在不行还可以把数据量缩小到三五个手工模拟一遍排序过程往往一眼就看穿问题在哪。我自己在实际写这类代码时还有一个习惯排序前先把原始数组打印一份排完再打印一份两相对比生成满足“绝对值降序”的直觉判断。这个习惯帮我挡了不少低级错误尤其是比较器方向这种反人类的地方。你把这个习惯带到所有排序场景里会发现排查速度比一般人快一个档次。最后分享一个小技巧VSCode 里用 C 开发时可以在.vscode/tasks.json里配好编译任务绑定快捷键以后按一下就能编译运行不用每次敲命令。配置内容不复杂网上模板很多核心是确认command指向正确的 g 路径args里带上-stdc11。环境稳定之后你就能把精力全部放到算法和边界条件上而不是被工具折腾。
返回列表