看到“输入一组数字按照其绝对值从大到小进行排序(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::vector<long 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读个数,然后构造vector<long long> nums(n),一次性开出 n 个元素的容量,再用循环读入。这里有个小知识点:vector<long long> nums(n)会把每个元素默认初始化成 0,你后面逐个覆盖,没问题。如果你不想预先开大小,也可以vector<long 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::vector<long 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),再把函数名传给sort:
bool 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类型的容器。我上面代码里直接用了vector<long long>和std::llabs,在绝大多数场景下就安全了。std::llabs就是 long long 版本的求绝对值函数,头文件是<cstdlib>。如果你用的编译器比较老,也可以写std::abs(static_cast<long long>(a)) > std::abs(static_cast<long 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或float,lambda 参数类型要改,比较器里要用浮点版本的绝对值。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::vector<Item> 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 <algorithm> |
abs was not declared in this scope | 用错了头文件,或者忘了std:: | 整数用<cstdlib>,浮点用<cmath>,写std::abs或std::llabs |
给std::sort传了nums + n | vector 不支持这种指针写法 | 用nums.begin()和nums.end() |
| lambda 参数类型和容器元素类型对不上 | 比如容器是long long,lambda 参数写int | 改成const long long&或const auto& |
no match for operator- | 想用std::sort排 std::list | 改用list.sort(comp) |
这里再说一句:lambda 参数如果写成const auto& a, const auto& b,是 C++14 的泛型 lambda,可以在不同容器类型之间复用,省得频繁改参数类型。但可读性稍微差一点,团队项目里要不要用,看你们约定。
5.2 排序结果不对,从哪开始查
如果程序能编译能跑,结果就是不对,先别急着怀疑编译器。我遇到过的“看起来没问题,结果全错”场景,不外乎四种。
第一种,排序方向反了。验证方法很简单,输入一组简单数据比如1 -2 3 -4 5,手算预期是5 -4 3 -2 1,跑一下如果输出是1 -2 3 -4 5,说明比较器里应该用>结果写成了<。方向错了改符号就行,一分钟解决。
第二种,输出的是绝对值而不是原值。检查输出循环,确认打印的是x而不是std::llabs(x)。我教过的好几个学员都在这里栽过,因为眼睛盯在排序逻辑上,没意识到输出层也改了数据。
第三种,边界溢出。数据里如果有 -2147483648,int配合std::abs会出诡异问题。遇到这种情况,检查存储类型是不是long long,求绝对值是不是用了std::llabs。把这两处改对,问题通常直接消失。
第四种,比较器写成了>=。这种错误最隐蔽,因为不是每次必然出错,可能恰好这次数据没问题,下批数据就卡死。排查方法是把比较器的符号重新审视一遍,确认“相等时返回 false”。
我在实际干活时的调试手段是:写一个独立的小函数,把排序调用抽出来,再用一个非常笨但一定正确的循环比较法当“基准答案”,然后随机生成大批数据,对比std::sort的结果和基准答案。只要有一组不一致,就一定能揪出问题。这个方法效率极高,建议你以后遇到任何排序逻辑异常都这么干,成本比盯代码低太多了。
5.3 顺带聊聊 VSCode 和编译环境
最近总有同学在 VSCode 里写 C++ 排序代码,编译不过来找我,最后发现根本是环境问题,不是代码问题。比如在 VSCode 里点了运行,但项目没有配置编译任务,或者默认调用的不是 g++ 而是系统残留的其他编译器。
我的建议很朴素:不熟悉 VSCode 的编译配置之前,直接在终端里跑命令最省心。
g++ -std=c++11 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里带上-std=c++11。环境稳定之后,你就能把精力全部放到算法和边界条件上,而不是被工具折腾。