【C++进阶】STL算法与函数对象 - 02 sort为什么需要随机访问迭代器

博主介绍:程序喵大人

好文推荐:

【AIAgent项目】从零构建一个代码PRAgent

【C++进阶】STL算法与函数对象 - 01 让算法去处理一段迭代器范围

std::vector 换成 std::list,只改容器类型,再调用同一个 std::sort,编译器会立刻报错。很多人是在这里第一次发现:标准库算法并不接受所有容器。findcopy 用在链表上没问题,轮到 sort 却过不了编译,差别到底卡在哪里。

下面这段最小代码可以直接复现这个现象:

cpp 复制代码
#include <algorithm>
#include <list>
#include <vector>

int main() {
  std::vector<int> scores{88, 95, 80};
  std::sort(scores.begin(), scores.end());  // 编译通过

  std::list<int> linked_scores{88, 95, 80};
  std::sort(linked_scores.begin(), linked_scores.end());  // 编译失败
}

vector 那两行能顺利编过;list 那行会在模板展开时报错,常见信息会提到迭代器不支持减法,或者不能对迭代器做 + n。算法不看容器叫什么名字,它只核对迭代器能做什么:sort 需要随机访问这一档,vector 的迭代器满足,list 的迭代器只到双向这一档。

本章就回答这一个问题:std::sort 为什么能排 vector,却不能直接排 list。这条边界表面上只是一个 API 的使用限制,实际牵涉标准库的一整套设计逻辑:算法用迭代器类别标明自己需要的能力,容器用存储结构决定自己能提供的能力,两边对得上才编译得过。把这件事想透,以后遇到"这个算法能不能用在这个容器上"的问题,都有了一套稳定的自查方法。

排序要在范围里来回跳跃

先看清楚排序算法在范围里做什么。基于比较的排序,无论教科书里的快速排序、归并排序还是堆排序,核心动作都绕不开三件事:跳到范围中间的某个位置,把相隔很远的两个元素拿来比较,必要时交换它们的位置,然后在新划分出的子区间里重复这个过程。以典型的分区步骤为例,算法选定基准元素之后,从两端同时向中间扫描,左侧找到不小于基准的元素,右侧找到不大于基准的元素,两者交换,指针继续靠拢,直到整段范围被分成大小两半。这类动作翻译成迭代器语言,就是频繁的 it + nit - n、两个迭代器相减求距离,以及隔着任意距离交换所指元素。

把这套动作落到一段具体数据上会更直观。假设范围里躺着 8 个分数,分区步骤选中一个基准值,左迭代器从头部出发,越过所有小于基准的元素,右迭代器从尾部出发,越过所有大于基准的元素,两个迭代器各自停下时交换所指元素,然后继续向中间靠拢。整轮分区结束时,基准左侧不再有大于它的值,右侧不再有小于它的值,算法接着对左右两个子区间递归执行同样的事。注意这个过程中迭代器的轨迹完全没有规律,它由数据内容决定,先跳到这里,再跳到那里,任何只支持顺序访问的结构都承接不了这种跳法。

这套访问模式和顺序扫描是两种完全不同的工作方式。find 从头到尾每次只走一步,count_if 同样如此,它们对迭代器的要求低到只要能自增、能解引用、能判断终点就够了。排序则相反,它的效率建立在想到哪里就到哪里的前提上。分区类策略每一轮都把问题规模砍掉一半,全程大约进行 n log n 次比较。如果每次定位中间位置都要从头一步步挪过去,单是移动迭代器这个动作就要花掉线性时间,整体复杂度会直接从 n log n 滑向平方级。标准文档写明 std::sort 平均 n log n 次比较的复杂度,这个承诺只有随机访问迭代器才兜得住。

比较排序还有一个理论下界:任何只靠两两比较获取顺序信息的算法,最坏情况下都逃不开 n log n 量级的比较次数,因为要区分 n 个元素的所有可能排列,决策树就必须长到那么深。标准库把 sort 的复杂度定在 n log n,意味着它贴着这个理论极限工作,留给实现的优化空间只剩常数因子。常见的标准库实现把常数因子做得更细致,以广泛使用的内省式排序为例,先用快速排序的思路做分区,递归深度超过安全线时切换成堆排序防止退化,范围缩小到十几个元素时再换插入排序收尾。这属于实现细节而非标准承诺,但它说明了一件事:排序算法的现实性能,从设计上就押注在随机访问的成本模型上。

vector 的迭代器为什么能跳

vector 把元素紧凑地放在一段连续内存里,第 i 个元素的地址就是首地址加上 i 乘以元素大小。它的迭代器在行为上等价于一个带类型的指针,it + n 展开成一次地址运算,不触碰任何中间元素,耗时与 n 无关。算法想跳到正中间、想从尾部倒退三个位置、想知道两个位置之间隔着多少个元素,全都是常数时间的事。arraystringvector 同属连续存储,排序它们和排序 vector 走的是同一条路径。

deque 值得单独说一句。它的内部通常是若干段固定大小的缓冲区加一张映射表,迭代器跳跃时要先算出落在哪一段,再在段内偏移,比 vector 的纯地址运算多一层间接,但对外仍然保持常数时间。所以 std::sort 同样接受 deque,只是相同数据量下常数因子更大一些。这个例子把迭代器类别的本质讲得很清楚:类别描述的是能力契约,只要每项操作的代价有常数上界,底层布局可以有自己的脾气。

先把"随机访问"的含义说清楚。它对应一组严格的操作承诺:迭代器支持加减整数、支持两个迭代器相减、支持下标取值、支持相互比大小,并且这些操作全部是常数时间。标准按能力把迭代器分成几档,输入迭代器只能单遍向前读取,前向迭代器可以在同一范围里走多遍,双向迭代器能进也能退,随机访问迭代器在前面所有能力之上再加任意跳跃。C++20 又在这一档之上细分出连续迭代器,用来标记元素在内存里真正相邻的情况,本章只需知道它存在。

每个算法都声明自己要求的最低档位。findcount_if 只要输入迭代器,reverse 要双向迭代器,sortbinary_search 要随机访问迭代器。传进去的迭代器只要达标就能正常工作,能力超出要求的部分算法并不在乎。这张能力阶梯就是本章开头那个编译错误的判决书:sort 站在最高一档上要能力,list 的迭代器交不出来。

list 的迭代器只能一步一步走

链表是另一幅图景。std::list 的每个元素单独住在堆上的节点里,节点之间靠指针前后相连,第 5 个节点和第 500 个节点在内存里可能隔着很远的距离。从迭代器当前位置想到达第 n 个元素之后,唯一的办法是把自增执行 n 遍,沿途经过每一个中间节点。所以 list 的迭代器停在双向这一档:能前进,能后退,仅此而已。让它支持 it + n,语法上并非写不出来,而是代价必然正比于 n,这正好踩中排序的命门。链表的结构优势和排序的访问需求天生错位,它用节点换来了任意位置插入删除不挪其他元素的稳定性,也就交出了随机跳跃的能力。

链表的慢还有第二层原因,即使纯顺序扫描它也吃亏。vector 遍历顺着连续内存走,缓存行一次预取就能服务后面好几个元素;链表每个节点都是一次独立分配,下一个节点在哪里完全不可预测,几乎每访问一个元素都可能触发一次缓存未命中。所以即便某个算法对链表来说在线性时间内可做,实际耗时也常常输给连续容器。节点结构换来的,是插入删除时其他元素纹丝不动这一项核心优势,迭代稳定性和内存局部性从来难以兼得。

标准库对能力不匹配的处理方式很干脆:要求不满足,编译就不过。std::sort 的模板内部会直接对迭代器做减法和加法,用 list 的迭代器实例化时,这些运算根本不存在,编译器在模板深处抛出一长串错误。这解释了新手常有的困惑:只是用错一个算法,报错为什么有好几屏。错误信息冗长是模板实例化的老毛病,C++20 的概念机制已经在明显改善这类诊断,但设计意图始终如一,能力不匹配必须在编译期暴露,总好过悄悄编译出一个复杂度离谱的程序。读这种报错时不要被长度吓住,第一条错误附近通常就写着缺失的那个操作,比如迭代器不支持减法,读懂这一行就够了。

有人会追问,标准库为什么不给弱迭代器准备一个慢速版本的 sort,能跑总比报错好。这恰恰违反标准库的一条设计原则:算法的复杂度要写在脸上。如果同一个函数名在 vector 上是 n log n,在 list 上悄悄变成平方级,性能陷阱就被埋进了看不见的角落。标准库宁可让你在编译期碰壁,也要逼你直面选择:换容器、换算法,或者显式付出一次复制的代价。难看的报错信息,恰恰是诚实的接口。

还要分清一条边界:链表进不了 std::sort,不等于链表不能排序。std::list 自己带一个成员函数 sort,它了解节点结构,排序时只改节点之间的链接关系,元素本身一个都不搬动。std::forward_list 只有前向迭代器,比双向还低一档,它同样提供成员 sort。遇到排序链表的需求,正确动作是调用容器自己的 sort,至于它为什么能绕开随机访问的限制,代码示例那一节会连同语义差异一起讲清楚。

比较器和交换是排序交给调用方的两个口子

std::sort 自己不知道什么叫小。它每需要决定两个元素谁先谁后,就调用一次比较器,问的永远是同一个问题:第一个参数是否应该排在第二个参数前面。默认比较器是小于号,效果等于按升序排;传入自定义 lambda 或函数对象,就能按任意字段、任意方向排序。这条规则必须构成严格弱序,通俗地说,元素和自己比必须返回假,ab 前并且 bc 前就必须推出 ac 前。最常见的踩线写法是把 < 误写成 <=,相等元素互相声称自己在前,排序算法的内部假设当场破产,轻则结果错乱,重则访问越界。比较器的完整契约是下一章的主题,这里先建立一个认识:排序过程中算法只负责调度,所有判断都来自调用方给的那条规则。

另一个口子是交换。排序过程中元素要不断换位置,标准库通过移动或交换操作完成搬运,元素类型得支持移动构造和移动赋值。普通的数据结构天然满足这一点,只有被刻意锁死移动能力的类型才会在这里卡住。交换的频率还解释了一个常见优化:给装着大对象的 vector 排序时,人们常把大对象换成指针或索引再排,让算法搬动轻量句柄,重物原地不动,排完再按句柄顺序取用。这个技巧不改变算法语义,只是把交换成本从对象本身挪到了间接层上,代价是多一次解引用。

排序的真实开销由两部分构成:比较次数乘以单次比较的代价,交换次数乘以单次交换的代价。元素小巧、比较便宜时,两部分都低,std::sort 快得理所当然;元素体积一大,交换部分开始主导,搬运效率就变得关键。理解这个结构之后,优化的方向也就清楚了:要么压低单次交换的代价,要么减少交换发生的次数,指针排序技巧压的正是前一项。

移动语义在这笔账里扮演救场角色。现代 C++ 类型的移动构造通常只是接管内部指针,几百字节的对象搬一次也只是一次浅层交接,成本几乎与对象大小无关。真正危险的是只有深拷贝可用的类型,排序过程会把拷贝成本放大到 n log n 量级,大数据量下清晰可见。给一个将要频繁排序的类型补全移动能力,收益常常比更换排序算法更大。

顺带可以看清 std::sort 相对 C 语言 qsort 的优势来源。qsort 通过函数指针接收比较规则,每次比较都是一次无法内联的间接调用,元素类型信息也被 void* 抹掉了。std::sort 把比较器当作模板参数,lambda 或函数对象的调用在编译期就内联进排序循环,类型检查全程有效。同样的 n log n 次比较,一边带着间接调用开销,一边被编译器压平成直接指令,数据量大时这份差距会非常真实地体现在耗时上。这也是标准库坚持用模板表达算法的回报之一。

代码示例

本章的示例文件是 代码示例/02-sort-random-access/sort_random_access.cc,刻意保持短小:同一批记录、同一条比较规则,分别走 std::sortlist 成员 sort 两条路,对比两条路的能力边界。

cpp 复制代码
// Copyright (c) 2026 yus3nable
// SPDX-License-Identifier: MIT

#include <algorithm>
#include <iostream>
#include <list>
#include <string>
#include <vector>

struct Record {
  std::string name;
  int score = 0;
};

void Print(const auto& records) {
  for (const auto& record : records) {
    std::cout << record.name << '=' << record.score << ' ';
  }
  std::cout << '\n';
}

int main() {
  std::vector<Record> vector_records{{"Bob", 80}, {"Alice", 95}, {"Tom", 88}};
  std::sort(vector_records.begin(), vector_records.end(),
            [](const Record& a, const Record& b) {
              return a.score < b.score;
            });
  Print(vector_records);

  std::list<Record> list_records{{"Bob", 80}, {"Alice", 95}, {"Tom", 88}};
  list_records.sort([](const Record& a, const Record& b) {
    return a.score < b.score;
  });
  Print(list_records);
}

运行这段程序,两次打印的结果完全相同,都是按分数升序的 Bob=80 Tom=88 Alice=95。调用方看到的部分一模一样:一个比较分数的 lambda,回答"a 该不该排在 b 前面"。真正的差异发生在算法脚下。std::sort 拿到 vector 的随机访问迭代器,分区、跳跃、交换全都在连续内存上直接完成;list.sort() 是容器成员函数,它不走通用迭代器运算,而是利用自己对节点结构的了解,通过重连指针把节点按顺序串好。Printconst auto& 接收参数,两种容器都能传进来,这个写法本身也在演示:遍历打印对迭代器的要求很低,排序的要求很高,同一份数据可以同时满足前者,却只被后者的一半路径接受。

这段程序还藏着第三个对照点。两次调用的参数形态几乎一致,都是一段范围加一条规则,区别只在 std::sort 把范围作为参数传进来,成员 sort 把容器自身当作范围。这个形态差异就是两种机制的签名:泛型算法对容器一无所知,必须把能力要求写在迭代器上;成员函数出生在容器内部,天然知道节点怎么连接,排序时才能用重连代替搬运。看到同名功能同时存在泛型版和成员版时,先比较它们各自利用了什么结构信息,选型答案通常自己就浮出来了。

成员 sort 这个实现方式带来两个实际好处。第一,排序前后指向元素的迭代器、指针和引用全部保持有效,因为元素从头到尾没有挪动,变的只是链接关系;连续容器排序之后,旧迭代器指向的位置已经换了元素,两种语义在工程代码里差别巨大。第二,成员 sort 保证稳定,相等元素的相对先后保持原样,这是标准对 std::list::sort 的明确承诺。代价同样存在:节点重连是指针操作,访存模式分散,缓存表现比不上连续内存,数据量大时这份差距会真实地体现在耗时上。

迭代器有效性的对比值得再推进一层。std::sort 作用在 vector 上时只做元素搬运,容器的大小和存储位置都不变,迭代器不会因为排序而失效,但它们指向的位置已经住进了新元素,排序前记下的某个位置在排序后对应的是另一条记录。list 的成员 sort 连这一层都省了,迭代器始终跟着元素走。工程代码里需要记住某些元素身份时,排序前把迭代器换成元素的句柄或键,比在排序后回去找旧位置可靠得多。

想要稳定顺序要付出缓冲

稳定性本身是个容易被忽略的需求。按分数排序时,同分的人要不要保持原来的先后?std::sort 的回答是不保证,它只承诺把范围排成有序,相等元素落在哪算哪。需要保持原序时换 std::stable_sort,它承诺相等元素的相对位置不变。这个保证不是白来的:典型实现使用归并策略,需要额外缓冲区暂存元素,内存充足时是 n log n 次比较,申请不到足够内存时复杂度退化,要多付一个对数因子。用工程语言概括,稳定性是用空间和时间上的余量换回来的承诺。

稳定性的价值在多键排序里体现得最充分。想按分数排序、同分时再按姓名字典序,一种经典写法是先按姓名排一遍,再用 stable_sort 按分数排一遍:后一轮排序不打乱前一轮在相等元素间建立的顺序,姓名次序就被完整保留进了同分段内。更直接的做法是把两个键写进一个比较器里一次排完,两种思路下一章讲比较器时还会再遇到。这里先记住,稳定性是一种可以付费购买的顺序承诺,stable_sort 就是收费窗口。

反过来也要知道什么时候不必为稳定性付费。排序的目的是检索、去重、找极值时,相等元素的相对先后根本无所谓,std::sort 就足够,让它自由发挥通常还能快一点。日志按时间戳排序用于定位问题,同一秒内的几条记录谁先谁后,排查的人并不关心;报表数据按键聚合计数,键的相对次序对结果没有任何影响。这类场景占工程实践的大多数,稳定排序的缓冲区开销能省则省。把稳定性当成按需购买的承诺,另一层意思就是默认不买,需要时才掏这笔钱。

排序家族里还有几个常被低估的成员,各自对应一类真实需求。排行榜只展示前十时,partial_sort 把最小的前十排好就停手,后面的元素一概不碰;求中位数或百分位时,nth_element 把分界元素放到它最终应在的位置,左侧都不大于它、右侧都不小于它,两侧内部则完全不排,期望代价从 n log n 降到线性级别;想验证一段数据是否已经被上游排过序,is_sorted 一遍扫描就给出答案。这几个算法和 sort 共享同一个随机访问前提,划分动作本身就要在范围里反复跳跃,所以这张菜单同样只对连续容器和 deque 开放。选型时先问自己需要多强的顺序承诺,是完全有序、部分有序,还是只要一个分界,答案会直接指向具体算法。

排好序的范围本身就是一种资产

排序的价值常常在排完之后才开始兑现。一段有序范围立刻解锁一批新算法:binary_search 用对数时间回答元素在不在,lower_boundupper_bound 给出某个值应该插入的位置,equal_range 一次圈出所有等于某值的元素。这些算法同样要求随机访问迭代器,并且把范围已有序当作调用前提,前提不满足时结果没有任何保证。排序相当于一次性付款,之后的每次查询都按对数价结算,查询越频繁,这笔首付款越值得。

还有一组算法把有序当作工作方式。merge 把两段有序范围并成一段有序范围,set_unionset_intersection 这类集合算法在两段有序范围上同步推进,一次线性扫描完成并集或交集。最常见的搭档关系出现在去重场景:unique 只能移除相邻的重复元素,想彻底去重就得先 sort 让相同元素聚到一起,再用 unique 配合 erase 收尾。排序在这里承担的是预处理角色,相同元素先被排列动作聚拢,后续算法才能在线性时间内完成各自的工作。

理解这层关系之后,看代码的眼光会多一个维度。看到一个范围被排了序,可以顺着往后找它的二分查询和集合操作;看到有人对有序范围做线性查找,多半是没意识到二分家族的存在。有序性是一种可以被反复消费的结构性资产,这就是 sort 家族在整个算法体系里的真实位置。

不必每次都重新排序

工程代码里还有一种常见浪费:数据每插入一条就全量重排一次。如果需求是始终维护一段有序范围,更省的做法是用 lower_bound 找到插入位置,把新元素直接插进正确位置,单次代价是一次对数查找加一次插入移动。vector 的中间插入要挪动后续元素,但比起每次 n log n 量级的全量重排,多数场景仍然划算。有序性在这里被当成持续维护的不变量,而不是每次从零重建的结果。

维护成本再高一个量级,就该重新考虑容器了。插入极其频繁、查询也不少时,mapset 用树结构天然维持有序,插入查询都是对数时间;只关心反复取最大值时,priority_queue 的堆结构比全序更便宜。排序算法、有序容器、堆结构,三者解决的是同一族问题的不同形态:一次性整理、持续维护、只要极值。判断依据始终是数据的读写模式,这与《图解 STL 容器与迭代器》里按访问模式选容器的原则一脉相承。

反过来,数据几乎只写不查、偶尔才需要一次有序视图时,定期全量 sort 仍然是最简单的答案。维护有序不变量要求每次写入都走查找路径,写入端长期为查询端付费,查询却迟迟不来,这笔账就亏了。结构选择没有绝对优劣,只有收支是否平衡,把写入频率和查询频率摆到桌面上,答案通常自己就显现了。

先按迭代器能力对一遍再动手

这一章的结论可以压缩成一套固定动作:拿到排序需求,先确认容器迭代器的类别,再核对算法要求的最低类别,两边匹配再写调用。vectordequearraystring 直接进 std::sortlistforward_list 用各自的成员 sortmapset 这类关联容器本身按键有序,通常根本不需要再排。既想要链表的插入稳定性、又想要通用排序时,常见做法是把元素复制进 vector,排完再决定要不要写回去,用一次线性复制换算法全家桶的使用权,多数场景这笔账都划算。

复制方案值得把账算细。复制本身是线性时间加线性空间,排序是 n log n 量级,合计代价仍然由排序主导,通常不会成为瓶颈。更彻底的做法是把数据长期放在 vector 里,用下标稳定性的损失换所有泛型算法的使用权,代码往往更干净。容器选型决定算法菜单,这条因果链在真实代码库里出现得极其频繁,每次声明容器类型时都值得想一遍。

还有一个常被遗忘的事实:std::sort 处理的是任意范围,不一定是整个容器。只排中间一段、跳过已经有序的前缀、对一个原生数组排序,都是合法用法,把对应的迭代器对传进去就行。范围思维在这里和第 01 章完全汇合:算法从不假设自己面对整个容器,边界永远由调用方画出。

剩下的问题都集中在规则层。范围和能力对上了,排序结果的好坏就全部取决于比较规则写得对不对:<<= 的一字之差为什么足以让程序崩溃,多字段排序怎样组织比较逻辑,谓词和比较器这对孪生概念各自服务哪些算法。下一章就把这条规则层完整拆开,从判断逻辑怎样交给算法讲起。

码字不易,欢迎大家点赞,关注,评论,谢谢!

相关推荐
hanlin031 小时前
动态规划专练:力扣第121、122题
笔记·算法·leetcode
SomeB1oody1 小时前
【RustyML入门】2.6. 线性判别分析
开发语言·后端·机器学习·rust·教程
To_OC1 小时前
LC 74 搜索二维矩阵:换皮的二分查找,我居然一开始没看出来
javascript·算法·leetcode
文心快码BaiduComate1 小时前
文心快码荣获“中国优秀软件产品”,实力再获国家级认可
算法·代码规范
玖玥拾1 小时前
LeetCode 189 轮转数组
算法·leetcode
ZhangShao06071 小时前
题解:CF2249B
c++·算法
小玮看世界1 小时前
[Python]线段树与二分法
数据结构·算法
用户昵称1002 小时前
C/C++编程-工程实践-本地存储log的工程意义
c语言·开发语言
吹什么轩3 小时前
c++复习:map和set的使用
开发语言·c++