ARTICLE DETAIL

资讯详情

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

C++按绝对值排序:自定义比较器与严格弱序详解

C++按绝对值排序:自定义比较器与严格弱序详解 在C里做按绝对值从大到小排序看起来是一个入门级的排序练习题但实际上它牵扯到std::sort的自定义比较器、严格弱序strict weak ordering、比较函数的签名要求这些进阶问题。很多初学者第一次写这道题都是默认排一下再取负或者直接写sort(a.begin(), a.end())结果发现负数全跑到前面去了。这篇博文就围绕这个题目把C排序里最容易被忽略的几个核心细节讲透从比较器的写法、排序原理到稳定性问题和浮点精度坑一次性整理好。什么时候会用到这种排序业务里很常见按某个指标偏差值排名离目标越远越靠前、按信号振幅筛选、按数值差异大小聚类预处理。只要排序依据不是数据本身而是数据通过某种变换后的结果你就得自定义比较规则。这篇文章适合刚开始学C sort的新手也适合写了好几年代码但习惯性回避自定义比较器的人。看完你会知道为什么直接sort排不对、lambda怎么写最安全、为什么比较器不能随便写以及遇到排序结果错乱时该从哪里排查。1. 需求拆解为什么默认的sort满足不了这个题目先明确问题本身给一组数字按绝对值从大到小排序。输入可能是[-3, 5, -1, 2, -7]期望输出[-7, 5, -3, 2, -1]或者[-7, 5, -3, 2, -1]原值的符号要保留也就是说排序的依据是abs(元素)而不是元素本身。1.1 默认sort到底做了什么std::sort默认使用operator比较两个元素。对vectorint来说就是直接比较数值大小。所以默认排序[-3, 5, -1, 2, -7]会得到[-7, -3, -1, 2, 5]——负数因为数值更小被排到了最前面。这显然不符合题目要求-7按绝对值确实是最大的应该排最前面但-3的绝对值是3它应该排在5和-7之后而不是-1前面。这里有一个关键认知std::sort本身并不知道你要按绝对值排序它只会一遍遍调用你提供的比较函数用它来决策两个元素的先后顺序。你把比较规则告诉sortsort负责按照这个规则把序列整理成有序的。所以这个题的核心不是排序算法本身而是如何写一个正确的比较规则。只要比较规则写对了用什么排序算法根本不重要。1.2 原来的数据值为什么会干扰排序如果直接在比较函数里写成return abs(a) abs(b)这当然可以但要注意的是abs接收的参数类型要和你的容器元素类型一致。vectorint用absvectordouble建议用fabs。C17之后std::abs对浮点数也有重载所以std::abs(3.14)也能编译通过但如果你的代码要兼容 C11/14最好明确区分。另一个隐蔽的干扰是符号的保留。题目要求的是按照绝对值从大到小排序没有要求把负数变成正数再输出。所以你只需要在比较时取绝对值输出时还是原值。有人会先拷贝一个绝对值版本再排序最后再映射回原值这样绕了一圈还容易出错。直接写自定义比较器原值输出逻辑上最干净。1.3 三种常见的自定义比较器写法C里给sort传比较规则有三种常见姿势写普通函数、写函数对象仿函数、写lambda表达式。三种都能完成任务但使用场景略有差异。第一种最传统的写法bool absGreater(int a, int b) { return std::abs(a) std::abs(b); } std::sort(vec.begin(), vec.end(), absGreater);第二种函数对象struct AbsGreater { bool operator()(int a, int b) const { return std::abs(a) std::abs(b); } }; std::sort(vec.begin(), vec.end(), AbsGreater());第三种lambdaC11起std::sort(vec.begin(), vec.end(), [](int a, int b) { return std::abs(a) std::abs(b); });从代码维护的角度如果这个比较规则只在一个地方用到lambda最合适因为不需要额外定义一个函数名阅读代码时比较逻辑就在sort旁边一目了然。如果这个比较规则会被多个排序场景复用比如一会儿对vector 排序一会儿对list 排序list有独立的sort成员函数但比较器可以共用那定义一个普通函数或仿函数更好避免重复写同一段lambda。函数对象和普通函数的区别在于函数对象可以携带状态。比如你想支持在排序时顺便记录每个元素被比较了多少次函数对象可以利用成员变量计数。这种需求在调试排序算法、分析比较次数时很实用。普通函数则没法保存状态除非用全局变量或静态变量那样并发环境就麻烦了。2. 比较器背后的排序机制与理论约束很多人写完return std::abs(a) std::abs(b);这段代码测试也通过了就觉得完事了。但实际上C标准对sort的比较器有一个强制要求不满足的话排序结果不仅有可能是错的还可能是未定义行为。这个要求就是严格弱序。2.1 严格弱序为什么比较器不能随便写严格弱序是一个数学概念听上去很玄乎但对写代码的人来说只需要记住它有几个核心要求反自反性comp(a, a)必须返回false非对称性如果comp(a, b)为true那么comp(b, a)必须为false传递性如果comp(a, b)为true且comp(b, c)为true那么comp(a, c)必须为true等价关系的传递性如果comp(a, b)为false且comp(b, a)为false即a和b等价comp(b, c)为false且comp(c, b)为false即b和c等价那么a和c也必须是等价的我们写的abs(a) abs(b)天然满足这些性质因为本身就是严格弱序而abs是一个函数变换函数变换不会破坏底层比较的顺序关系。所以用abs(a) abs(b)是绝对安全的。但等你以后写更复杂的比较器时就要小心了。最常见的坑是这样的比较器bool badCompare(int a, int b) { return std::abs(a - b) 5; // 差小于5就认为a小于b }这个比较器完全不满足传递性。举个例子a-10, b0, c10abs(-10-0)10不小于5返回falseabs(0-10)10不小于5返回false所以 -10 和 10 等价。再看-10和5abs(-10-5)15不小于5二者等价的。但-10和-4abs(-10-(-4))6也不小于5等价。看起来还行问题是-4和1abs(-4-1)5不小于5等等5不小于5falseabs(1-(-4))5不小于5false等价。但-1和-4abs(-1-(-4))3小于5comp(-1,-4)返回true同时comp(-4,-1)也是true算一下abs(-4-(-1))3小于5也返回true。这就违反了非对称性——两个方向都返回true等于说 -1 既小于 -4 又大于 -4排序算法直接懵了。这种比较器一旦传进sort轻则结果错乱重则程序崩溃或无限循环。标准里明确说这是未定义行为。所以写自定义比较器时一个原则是如果拿不准就用最简单、可推导的方式不要自己发明聪明的比较规则。实在要用浮点误差、区间模糊这类近似比较需要提前设计好离散化策略不能直接把模糊规则塞进sort。2.2 等价性sort怎么判断两个元素相同sort内部在做排序时会把序列分成多个有序区间它需要知道a是否排在b前面以及a和b是否等价。等价不等于相等而是指两个元素在比较规则下不分先后。比如按绝对值排序时3和-3的绝对值相同在比较器abs(a) abs(b)下comp(3, -3)为falsecomp(-3, 3)也为false所以它们等价。等价元素在排序后谁在前谁在后这就涉及稳定性了。std::sort的官方表述是不保证稳定意思是等价元素的相对顺序可能改变也可能不变具体取决于底层实现。常见标准库实现里std::sort当元素数量较少时使用插入排序稳定数据多时使用快速排序不稳定。所以同样的输入在 debug 和 release 模式下可能因为排序算法分支不同等价值的前后顺序就不一样。如果业务要求按绝对值相同时保持原来的先后顺序那就必须用std::stable_sort。它的算法通常是归并排序折中一下时间复杂度和稳定性。对应上面例子里[3, -3, 2]按绝对值降序排stable_sort出来的结果一定是[3, -3, 2]或[-3, 3, 2]取决于输入里谁先出现但sort可能会把顺序调换。清楚了这一点后面调试为什么每次排序结果顺序不一样时就知道该往哪个方向排查了。2.3 比较器的签名要求与性能陷阱std::sort的第三个参数类型是Compare它要求这个可调用对象的调用形式是bool(const T, const T)或者能接受两个T类型的值。如果你给sort传一个普通函数函数参数写int a, int b按值传参是可以用的因为int的拷贝开销可以忽略。但如果你的容器元素是复杂结构体比如struct Record { std::string name; double absValue; std::vectorint tags; };比较函数如果按值传Record a, Record b每一次比较都会构造两个拷贝对象把tags整个vector拷贝一遍排序n个元素的时间复杂度本身是O(n log n)乘以每次比较的拷贝开销直接量级爆炸。正确做法是参数用const Recordbool compareRecord(const Record a, const Record b) { return a.absValue b.absValue; }对于结构体比较大、排序频繁的场景这个细节能决定你的排序是几毫秒还是几百毫秒。lambda同样支持引用参数写法上把(Record a, Record b)改成(const Record a, const Record b)即可。3. 从零手写完整代码与关键参数选择现在把思路落实到代码。假设输入是从标准输入读取的一行整数用空格或换行分隔程序读取后按绝对值从大到小排序并输出。为了演示得更充分我用double类型模拟更通用的场景测量值、误差值经常带小数同时标注如果不小心会把整数的坑踩成什么样。3.1 完整示例代码可直接编译运行#include iostream #include vector #include algorithm #include cmath #include sstream int main() { std::cout 请输入一组数字空格或换行分隔输入非数字或EOF结束\n; std::vectordouble nums; double val; while (std::cin val) { nums.push_back(val); } if (nums.empty()) { std::cout 未读取到有效数字。\n; return 0; } // 按绝对值从大到小排序绝对值相同时保持原有相对顺序用 stable_sort std::stable_sort(nums.begin(), nums.end(), [](const double a, const double b) { return std::fabs(a) std::fabs(b); }); std::cout 排序结果\n; for (const auto n : nums) { std::cout n ; } std::cout std::endl; return 0; }编译命令Linux/macOSg -stdc11 -Wall -Wextra -o abs_sort abs_sort.cppWindows下用VS或者MinGW同样支持。加-Wall -Wextra打开警告如果比较器或类型有问题编译器通常会在这一步提示你。3.2 lambda捕获方式与const引用的细节说明上面代码里lambda写的是[](const double a, const double b)没有用捕获列表。那如果你需要在比较时引用一个外部变量呢比如想实现一个offset参数比较abs(a - offset)和abs(b - offset)。lambda的写法要加上捕获double offset 2.5; std::sort(nums.begin(), nums.end(), [offset](const double a, const double b) { return std::fabs(a - offset) std::fabs(b - offset); });捕获了offset的lambda会生成一个匿名的函数对象内部存储了offset的副本。这对简单double没问题但如果捕获的是大对象或者想避免副本开销用引用捕获[offset]。不过要小心引用捕获的lambda如果被保存到函数外部返回那引用就悬空了行为未定义。一般比较器的作用域就在sort这一行引用捕获是安全的。稳妥起见捕获值更让人放心。3.3 为什么我用stable_sort而不是sort我在示例里主动用了std::stable_sort不是因为它总是最优而是因为这道题的典型场景——包含等绝对值元素时保持原输入顺序往往更符合直觉。比如输入[3, -3, 1]如果按原顺序[3, -3, 1]稳定排序的结果[3, -3, 1]里3还是在-3前面如果输入是[-3, 3, 1]结果就是[-3, 3, 1]。这种尽量不改变原始相对次序的行为在按指标排名时尤其重要。你想一下两个人在某项指标上的偏差绝对值完全相同排名如果不考虑原始并列顺序就可能随机颠倒这在一个正式的报告里是难以接受的。代价是std::stable_sort的时间复杂度是O(n log n)没错但它通常需要额外的内存做归并当n特别大、内存吃紧时sort更合适。我的建议默认先用sort只有当等价元素保持原顺序成为明确需求时才切换到stable_sort。示例里我直接用了stable_sort是为了让读者看到一种更稳妥的写法。3.4 int和double的绝对值函数选择与溢出问题如果你的数据是int用std::abs是自然的。但有一个经典的溢出坑在32位平台上int的最小值是-2147483648它的绝对值是2147483648超过了int能表达的最大值2147483647。这时abs(-2147483648)的行为是未定义或溢出回绕结果可能还是个负数比较逻辑瞬间失效。举个实际的例子输入数据是传感器采集的温度差值范围可能在[-2000000000, 2000000000]之间。某个采集点恰好是-2147483648不是不可能如果用int承接了某些通讯协议的原始字段排序时abs一出错后面全乱。解决方法有两种比较时用long long先把两个数都提升成long long再取绝对值[](int a, int b) { long long la static_castlong long(a); long long lb static_castlong long(b); return (la 0 ? -la : la) (lb 0 ? -lb : lb); }或者容器本身存long long直接无死角。针对浮点数std::fabs可以正确处理负零和常规负数不存在整数那种溢出问题。但浮点比较要注意NaN。如果输入数据里混入了NaN比如从文件读取时nan被解析成功std::fabs(NaN) std::fabs(x)永远返回false因为NaN参与任何比较都是false。这会导致NaN被排到最后但它的相对位置不确定。如果业务要求NaN出现在特定位置排最前或最后需要在lambda里先判断[](double a, double b) { bool aNaN std::isnan(a); bool bNaN std::isnan(b); if (aNaN || bNaN) { return aNaN bNaN ? false : (aNaN ? false : true); } return std::fabs(a) std::fabs(b); }这段逻辑把NaN放在末尾。想放到开头把sstd::isnan判断优先顺序反过来即可。4. 排序实现中的常见问题与排查实录写代码绕不开坑我这里整理几个在实际项目中真实遇到过的、和这道绝对值排序直接相关的排查案例。有些坑表面上看不出来和排序有关实际上全是比较器没写对导致的。4.1 负数全部排在前面怎么回事新手最容易犯的错直接用sort没有传任何比较器。上面说过了默认规则是原值升序所以负数的绝对值再大也会因为原值更小而排前面。解决方法是加比较器。另一个衍生情况是写了比较器但写反了方向用了std::abs(a) std::abs(b)这是升序但题目要求降序结果就是从最小绝对值往最大绝对值排。层主的建议是先想清楚第一个元素该是谁然后写比较器让sort把第一个元素放在最前面。如果要最大值开头比较器里就返回a大于b;如果要最小值开头就返回a小于b。4.2 用std::fabs和std::abs混排导致的编译警告C11里std::abs对double、float有没有重载有的但早期编译器、某些嵌入式编译环境里abs只管整型浮点要用fabs。你在写跨平台代码时尽量统一用std::fabs处理浮点用std::abs处理整型。一个有趣的细节如果把int传给std::fabs会发生隐式类型转换把int变成double精度没问题但多了一次转换操作。对大量排序比较来说这个转换会拖慢速度。尽量保持比较器的参数类型和容器元素类型一致避免无谓的转换。4.3 排序结果不稳定同一份数据两次运行顺序不同假设你已经用了 sort且数据里有多个绝对值相等的元素。某次运行输出[5, -5, 3]另一次输出[-5, 5, 3]你就怀疑程序是不是有随机性。其实这正是sort不稳定的表现。尤其是容器数据量小的时候有些标准库实现直接走插入排序这可能保持顺序数据量一大sort进入快排分支等价值元素的相对次序就乱了。排查方法是如果想保持顺序从sort换stable_sort。另一个办法给比较器增加次级排序依据比如绝对值相同则按原值大小排这样任何两个元素都不会等价排序结果就是确定性的[](double a, double b) { double fa std::fabs(a); double fb std::fabs(b); if (fa ! fb) return fa fb; return a b; // 额外依据原值大小 }4.4 浮点数绝对值相同的判断误差业务场景里经常出现0.1 0.2和0.3这种经典精度问题。假设有一组数据[0.30000000000000004, 0.3, -0.3]它们在double存储下是0.30000000000000004和0.3绝对值不完全相等。直接std::fabs(a) std::fabs(b)判断会得到 false于是排序时它们被当作不同绝对值顺序可能不符合直觉。如果业务上要求精度在一定范围内视作相同绝对值比较器就要改成带容差的形式。但要注意带容差的比较器要额外小心严格弱序的传递性问题。之前已经举过不满足传递性的反例。一个可行的妥协方案是先对数据进行离散化处理比如四舍五入到小数点后两位再对离散化后的值排序把模糊规则从比较器里移出去。4.5 数据量巨大时比较器调用频繁导致的性能瓶颈这个题目看起来数据量不大但在实际项目中如果排序列表来自一个大日志文件或者高频数据流比较器会被调用O(n log n)次。每次在比较器里算std::fabs虽然不慢但也绝不是免费的。如果想压榨性能一个常见优化是预计算绝对值即先为每个元素算好绝对值做成一个pairdouble, double原值、绝对值按绝对值排序排序完成后需要哪个值取哪个。这种方法把绝对值计算从O(n log n)次比较降到了O(n)次预处理一次排序就快很多std::vectorstd::pairdouble, double valueAndAbs; for (double x : nums) { valueAndAbs.emplace_back(x, std::fabs(x)); } std::sort(valueAndAbs.begin(), valueAndAbs.end(), [](const auto a, const auto b) { return a.second b.second; }); // 输出原值valueAndAbs[i].first代价是多一倍内存。对几十万的vector来说这个内存开销通常可接受。我的经验是在数据规模超过十万、且绝对值计算本身有点开销时预计算的收益就非常明显。如果嫌内存不够还有一个折中自定义一个轻量结构体而不是pair把两个double放进去布局上比pair更可控。4.6 用自定义类对象做排序时的常见坑如果你的容器元素是一个带多个字段的类比如Student有name、score但你的比较规则是按score的绝对值降序。这种情况下推荐在类内部重载operator或者提供一个成员函数返回比较结果而不是在类外裸写一个lambda访问私有字段。前者才是这个类的排序语义方便多个排序场景复用。示例struct Student { std::string name; double score; bool operator(const Student other) const { return std::fabs(score) std::fabs(other.score); } }; std::sort(students.begin(), students.end());注意operator里面取的是绝对值这和普通意义上的分数比较不同。如果未来有人看到studentA studentB以为比的是分数原值会闹出误会。这种情况下我更推荐显式写一个比较器传入sort而不是重载operator。重载操作符要谨慎语义必须和直觉一致否则代码可读性会变差。5. 算法之外的思考什么时候用std::partial_sort这个题目是给一组数字全排序。但如果场景变成只关心绝对值最大的前K个元素你会用全排序吗显然浪费。std::partial_sort能解决取前K大的场景它只需要O(n log K)时间几乎相当于线性。举个例子我们处理100万个信号的振幅绝对值只关心振幅最大的前10个信号。全排序要O(n log n)而 partial_sort 用一个小顶堆维护当前最大的K个内存占用也小。这是实际工程里真正的性能优化点题目本身可能用不着但很多人在做完排序练习题后会问我按绝对值排好了接下来怎么取前几这时你就要反问自己是不是一开始就不该全排序。std::partial_sort(nums.begin(), nums.begin() 3, nums.end(), [](double a, double b) { return std::fabs(a) std::fabs(b); }); // nums前3个就是绝对值最大的3个但不保证它们内部有序以外的部分有序partial_sort的排序范围是[first, middle)这个范围会变成有序[middle, end)是无序的剩余元素。如果你既要前K个又要它们按降序排好partial_sort完全满足。如果你只要前K个但顺序无所谓那还有std::nth_element它更快O(n)期望复杂度直接把第K大的放在位置K左边都比它大按自定义规则右边都比它小。但这个函数不保证部分区间的整体顺序。能根据需求选对工具比会写sort本身更能反映水平。6. 扩展模型演进与多键排序的实战思路如果题目扩展成按绝对值从大到小排绝对值相同时按原值从小到大排比较器就需要多级判断。代码不复杂[](int a, int b) { int absA std::abs(a); int absB std::abs(b); if (absA ! absB) return absA absB; return a b; }这个模式可以扩展到任何多字段排序场景。需要注意的是多键比较器里的任何一个判断分支都必须重新考虑严格弱序的约束。比如absA ! absB这里用!来判断两个浮点数是否相等之前讨论过浮点相等是危险的。如果是 double 类型应该改成if (absA absB) return true; if (absA absB) return false;用和两次比较替代!这样既规避了浮点相等的精度问题又满足严格弱序的传递性要求。还有一个更复杂的扩展数据不是简单数组而是关联容器。std::map的 key 不是你想怎么排就怎么排的因为 map 的底层是红黑树它的比较函数在声明时就固定下来了。如果要用 map 存绝对值到值的映射并且想按绝对值降序遍历那就得自定义 map 的比较器struct AbsDescCompare { bool operator()(int a, int b) const { return std::abs(a) std::abs(b); } }; std::mapint, std::string, AbsDescCompare dict;注意map 的比较器同样必须满足严格弱序。而且一旦用了自定义比较器map 的所有查找操作find、lower_bound等都要用同样的比较规则否则就是未定义行为。有一个操作频繁踩坑dict[3] x和dict[-3] y在同一个按绝对值降序的map里居然指向同一个键因为abs(3)和abs(-3)等价。这种隐蔽bug一般在业务上线很久后才被发现。如果你遇到map的键莫名消失或被覆盖先检查比较器是不是把不该当成等价的键判成等价了。7. 最后分享一点经验这道题我第一次做的时候也犯过错当时给std::sort传了一个比较器里面直接return std::abs(a) std::abs(b);觉得答案呼之欲出。测试数据选的也巧全是正数加一个零怎么跑都正确。后来把一段真实业务数据灌进去-2147483648突然出现程序直接崩了。排查了半天才意识到是abs的溢出问题。从那以后我养成了一个习惯凡是涉及绝对值的比较器一律先把数据类型看清楚如果是int就用long long中转如果是double就看fabs是否发挥到位。另一个习惯是写排序代码之前先问自己一句这里的顺序是不是稳定排序才能满足需求该用stable_sort就别用sort硬扛。做C排序不要只背API花点时间把比较器的约束、稳定性、数据类型这些底层东西吃透以后遇到任何按某个变换后的大小排的题目——按长度排、按频率排、按距离排——都是同一个套路换汤不换药。这套思路不管是做算法题还是写生产代码都很值钱。
返回列表