【C++进阶】STL算法与函数对象 - 04 find、count和any_of把查询写成意图

博主介绍:程序喵大人

好文推荐:

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

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

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

【C++进阶】STL算法与函数对象 - 03 谓词和比较器把判断逻辑交给算法

在一个订单列表里找出 bob 下的那一单,手写版本大概长这样:

cpp 复制代码
const Order* bob = nullptr;
for (const Order& order : orders) {
  if (order.user == "bob") {
    bob = ℴ
    break;
  }
}

这段代码能工作,但它身上挂着一串活动部件:结果指针先置空,循环里逐个比对,命中要立刻 break,出循环后还得判空才能使用。四个动作写错任何一个,行为就悄悄变了:漏掉 break,找到的从第一单变成最后一单;漏掉判空,下一次解引用就是一颗定时炸弹。查询逻辑本身只有一行 order.user == "bob",其余全是围绕它的流程管理。管理代码越多,业务代码就越难被一眼看见,这是手写查询循环的通病。

算法版本把流程管理整体收走:

cpp 复制代码
const auto bob = std::find_if(orders.begin(), orders.end(),
                              [](const Order& order) {
                                return order.user == "bob";
                              });

三行代码各自说清一件事:find_if 这个名字声明意图是按规则查找,前两个参数划定查询范围,lambda 给出判断规则,返回值约定"没找到就返回 last"。本章要展开的就是这个家族:findcountany_of 这批查询算法为什么值得把哪怕三行的循环也替换掉,它们的返回值该怎么用,边界又在哪里。第 01 章讲的范围、第 03 章讲的谓词,到这一章会全部落成具体的调用形态。

查询算法还是整个算法家族里最温和的一类。它们只读取元素,不改写元素,更不会改变容器的大小,调用前后数据原封不动。这个性质让查询成为建立算法直觉的最佳起点:先把"给范围、给规则、拿结果"这套调用节奏练熟,下一章遇到会写输出、会挪数据的算法时,需要警惕的东西才真正多起来。这个家族按结果形态可以分成三支:find 一支返回位置,count 一支返回数量,any_of 一支返回是非,本章的展开顺序就跟着这个分类走。

find 找的是值,find_if 找的是规则

std::find 的提问方式最直接:这段范围里,第一个等于给定值的元素在哪里。它用 == 逐个比较,命中即返回指向该元素的迭代器。find_if 把"等于某值"放宽成"满足某规则",提问方式变成:第一个让谓词返回 true 的元素在哪里。一个找值,一个找规则,覆盖的场景完全不同:找数值 42 用 find 足够,找金额超过 500 的订单就得请出 find_if。结构体没有重载 == 时,这种差别会更明显:find 根本无从提问,find_if 却可以按任意字段、任意组合条件表达匹配。

find_if 的谓词还有一个自由度值得一提:规则不必写死在代码里。阈值从配置读入、用户名从参数传入,lambda 把这些外部状态捕获进来,谓词就成了带上下文的判断。这是 lambda 捕获能力的第一次实弹使用,第 08 章会把它彻底展开,本章只需记住一个事实:查询规则可以是运行期才确定的,算法的形态完全不受影响。

家族里还有两个变体值得知道。find_if_not 反向提问,找第一个不满足规则的元素,做输入校验时它天然表达"第一个不合规的位置"。find_first_of 接受一组候选值,找第一个命中候选集的元素。它们的共同点比差异更重要:都返回迭代器,落空时都返回 last,这个约定到返回值那一节还要放大。

找到位置之后能做什么,也值得提前想好。迭代器不只是答案的容器,还是继续工作的起点:想读出元素就解引用,想知道序号就用 distance 换算下标,想从命中处继续扫描就把起点推进一格。找全部匹配的惯用写法正建立在这最后一点上:

cpp 复制代码
auto it = orders.begin();
while ((it = std::find_if(it, orders.end(), IsRisky)) != orders.end()) {
  Handle(*it);
  ++it;
}

每命中一次就处理一次,再把起点移到命中位置的下一位,直到返回 last 为止。线性扫描被拆成算法负责的查找和你负责的处理,两边各行其职,没有标志位,没有手工边界。这个模式在日志筛查、异常巡检里出现频率极高,值得练到随手能写。

查询范围同样不必是整个容器。只想在前半段里找,就把前半段的迭代器对传进去;刚找到过一个命中,想接着找下一个,就从命中位置的下一位重新圈定范围。范围思维在查询算法上和第 01 章完全一致:算法处理你画出的那段区间,不多也不少,边界永远由调用方决定。

查询算法对迭代器的要求也是整个算法家族里最低的一档:能读、能前进、能判断终点就够用,输入迭代器即可上岗。这意味着 find_if 不只服务于 vectorlist,甚至能直接在输入流迭代器上查找,连容器都可以不存在。和第 02 章站在能力阶梯顶端的 sort 对照,查询家族待在阶梯最底层,能使用它们的场合也最多,这是它们适合作为算法入门第一站的技术原因。

count 家族把"数一遍"说成一个词

计数查询的提问同样分两种形态。count 数等于某值的元素个数,count_if 数满足规则的元素个数。count 可以看作 count_if 配上相等谓词的特例,两者的关系就像 findfind_if:值匹配够用就用值版本,条件一复杂就换规则版本。手写计数循环把 if (cond) ++n; 嵌在 for 里,活动部件同样有初始化、边界、自增、条件四样,count_if 把它压缩成一次调用,谓词原样保留,脚手架全部消失。

返回值是迭代器的差值类型,一个有符号整数。选有符号是设计使然:迭代器相减本来就会产生负值,差值类型如实反映这一点。副作用随之而来,拿它和无符号类型比较或打印时会触发编译器警告,本章示例里的 static_cast<int> 就是为此准备的。这个小细节在真实项目里天天遇到,早习惯早省事。

计数还有一个容易忽略的性质:它必须看完全部元素。find_if 命中即停,count_if 没有提前结束的可能,答案取决于每一个位置。这个性质把它的复杂度钉死在线性,也让谓词在 count_if 里对每个元素恰好被调用一次。第 03 章关于谓词纯度的要求在这里原样成立:规则只做判断,统计的事交给算法,两边都不越界。

日常代码里还有一种计数误用值得点名:拿计数结果当布尔用,if (std::count(v.begin(), v.end(), x)) 想表达"是否存在"。语义上它没错,成本上它亏大了,存在性本来可以在第一次命中时收工,计数却必须走完全程。表达"存在"有专门的工具,就是下一节的 any_of,把计数留给真正需要数量的场合。再往深走一步,计数其实是一种最朴素的归约:遍历整个范围,按规则把每个元素折叠进一个累计值,第 07 章的数值算法会把这条线彻底展开。

any_of、all_of、none_of 把整段范围汇成布尔

这三个算法回答的是关于整段范围的全局问题。any_of 问"存在满足条件的元素吗",all_of 问"所有元素都满足条件吗",none_of 问"没有任何元素满足条件吗"。它们各自对应手写代码里一种经典的标志位模式:先设一个布尔变量,循环里按条件翻转,出循环再检查。以"是否全部合格"为例,手写版本要带一个 all_ok 标志、一次翻转、一个提前退出,算法版本只剩一行:

cpp 复制代码
const bool all_ok = std::all_of(orders.begin(), orders.end(),
                                [](const Order& order) {
                                  return order.amount > 0;
                                });

调用点读过去就是一句完整的业务提问。校验类逻辑是这三个算法的主场:all_of 检查配置项全部合法,none_of 确认黑名单特征零命中,any_of 探测风险信号是否存在,名字本身就写清了校验的期望方向。函数开头用它们做护栏,主流程可以保持平直,不必缩进在一层又一层的条件判断里。

只想知道"在不在"时,any_of 也比 find_if 更合适。find_if(...) != end() 能表达存在性,但它带回来一个你并不需要的迭代器,读者还要多想一步"拿到位置准备干嘛"。any_of 直接返回布尔,问题形态和答案形态严丝合缝。需要位置时用 find_if,只需要是非时用 any_of,各归各位。

空范围上的行为值得单独记。any_of 对空范围返回假,不存在满足条件的元素;none_of 返回真,确实一个满足的都没有;all_of 返回真,逻辑学上称为 vacuous truth,所有零个元素都满足条件。all_of 这一条最常出人意料:对空集合问"全都合格吗",答案是合格。写校验逻辑时先想清楚空集合算不算通过,再决定要不要把它单独拦在查询之前。

三者之间还有一层表达上的取舍。!any_of(...)none_of(...) 逻辑等价,!none_of(...)any_of(...) 等价,all_of 配上取反的谓词也能改写 none_of。标准库把三个名字都提供出来,是为了让调用点永远能选最直白的那个。肯定句永远比双重否定好读,"没有任何风险订单"写成 none_of,就别写成 !any_of,读代码的人不必在脑子里做 De Morgan 变换。

短路语义还给这三个算法的谓词加了一条隐性约束:调用次数不确定。答案提前出现,谓词就少调几次,次数完全由数据内容决定。所以这里的谓词比 count_if 里更依赖纯度,任何依赖调用次数的写法都是自欺欺人,第 03 章的纯度要求在这一族算法身上是硬约束。

返回值是查询合同的一半

查询算法的结果分两种形态。布尔型的 any_of 一家开箱即用,迭代器型的 find 一家则附带一条铁律:返回值可能是 last,它不指向任何元素,只是"没找到"的哨兵。拿到 find_if 的结果,先和 end() 比较,确认有效再解引用,这个顺序不能反。直接解引用哨兵是未定义行为,它不会礼貌地报错,只会在某个遥远的时刻让程序表现得莫名其妙。

哨兵为什么能合法存在,要回到第 01 章的半开区间。end() 指向末尾元素之后的位置,这个位置可以参与比较、可以当作边界,就是不能解引用。find_if 落空时返回它,等于借用了范围边界的现成位置来表达"没有答案",不需要引入特殊值或异常。理解了半开区间,哨兵就不再是怪癖,而是范围模型的自然推论。

C++17 给这条纪律配了一个顺手的语法:if (auto it = std::find(v.begin(), v.end(), key); it != v.end()) 把查找和检查写进同一个语句,迭代器的作用域被限制在 if 内部,用完即走,不存在把哨兵迭代器带出作用域误用的机会。老代码里常见的模式是先声明迭代器、再查找、再检查,三行散落,新写法把它们收拢成一处。

查到的位置也不只是终点答案,它常常是下一个操作的锚点:找到了目标,顺手在那个位置 eraseinsert,删除和插入的章节很快就会用到这个衔接。但迭代器有严格的时效:它只在容器保持原样时有效,vector 一旦发生扩容,先前返回的迭代器全部作废,继续解引用和解引用哨兵是同一类事故。正确的节奏是查完就用,用完就丢,需要长期记住某个元素时,记录它的键或下标。迭代器失效的完整图谱在《图解 STL 容器与迭代器》里有专门一章,这里先建立"迭代器是短期凭证"的直觉。

手写代码里常见的另一种结果约定是用下标,找不到返回 -1 或 npos。这套约定依赖下标存在,只有支持随机访问的容器玩得起,链表只能旁观。迭代器约定不假设任何访问能力,找到返回位置,找不到返回 last,对所有容器一视同仁。标准库选择迭代器作为查询结果的通用货币,原因和选择迭代器表达范围边界是同一个:抽象层级一致,能力要求最低。

类似的成员函数约定也值得放在一起记。std::string 的成员 find 用下标加 npos 表达找不到,关联容器的成员 find 返回迭代器加 end(),泛型算法返回迭代器加 last。形态不同,纪律相同:查不到有一个明确的哨兵值,使用结果前必须先排除哨兵。把"先查哨兵再用结果"练成肌肉记忆,查询类 bug 会少掉一大半。

查询过程本身也有中断的方式:谓词抛出异常。异常会从算法内部直接传播出来,遍历停在半路,算法不做任何收拾现场的工作,因为查询本来就没有改动过任何数据,现场本来就是干净的。这是只读算法的又一个隐性福利:异常安全几乎免费,不修改就没有需要回滚的状态。

代码示例

本章的示例文件是 代码示例/04-query-algorithms/query_algorithms.cc,在一批订单上连用三个查询算法:定位指定用户的订单、探测风险、统计某用户的单数。

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

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

struct Order {
  std::string user;
  int amount = 0;
  bool risk = false;
};

int main() {
  const std::vector<Order> orders{
      {"alice", 120, false}, {"bob", 900, true}, {"alice", 60, false}};

  const auto bob = std::find_if(orders.begin(), orders.end(),
                                [](const Order& order) {
                                  return order.user == "bob";
                                });
  if (bob != orders.end()) {
    std::cout << "bob amount: " << bob->amount << '\n';
  }

  const bool has_risk = std::any_of(orders.begin(), orders.end(),
                                    [](const Order& order) {
                                      return order.risk;
                                    });
  const int alice_orders = static_cast<int>(std::count_if(
      orders.begin(), orders.end(),
      [](const Order& order) { return order.user == "alice"; }));

  std::cout << "has risk: " << has_risk << '\n';
  std::cout << "alice orders: " << alice_orders << '\n';
}

第一组调用找 bob 的订单。find_if 从头扫描,命中第二条记录即返回,程序先比较 bob != orders.end(),确认有效才打印金额,输出 bob amount: 900。谓词只负责回答"这一单是不是 bob 的",扫描、停止、边界全由算法管理,返回值纪律被原样演示了一遍。注意 orders 是常量容器,find_if 返回的是只读迭代器,解引用后只能读不能写,查询不修改数据这条边界由类型系统直接背书。

第二组是两个不同形态的查询。any_of 问是否存在风险订单,扫到 bob 那一单即得到肯定答案并停止,输出 has risk: 1count_if 数 alice 的订单数,它必须走完全程,输出 alice orders: 2。三个查询共用同一段范围,各自的规则互不干扰,返回值各按约定使用:迭代器先判哨兵,布尔直接用,计数转完类型再打印。

拿这三条记录还能把短路的账算实。find_if 找 bob,比较 2 次收工;any_of 探风险,同样在第二条停止,谓词被调用 2 次;count_if 数 alice 的订单,3 条记录全部过问,谓词被调用 3 次。数据量放大到十万条时,前两个查询的成本取决于命中位置,第三个永远全程。三个谓词的写法也值得对照:找用户是比较一个字段,order.user == "bob";探风险是直接返回布尔字段,order.risk 本身已经是答案。谓词可以简单到一次字段访问,也可以复杂到多条件组合,签名的约定却不变:接收元素,返回布尔。

输出里还有一个迟早会碰到的小问题:布尔值默认打印成 10,想看到 truefalse,给流插上 std::boolalpha 即可,这属于格式化范畴,与算法无关。真正相关的是 has_risk 的类型选择:用 bool 接住结果,后续的 if 判断语义最干净;用 int 接住也能编译,意图却被稀释了一层,查询算法返回什么,就用最贴合的类型接什么。

简单查询也值得换算法的原因

回到本章的核心问题:三五行就能写完的循环,为什么还值得换成算法。第一个理由是错误表面积。手写查询循环挂着结果变量、边界判断、命中退出、落空处理一串活动部件,每个都是出错的机会;算法版本的活动部件只剩范围和规则,其余语义由标准背书。代码评审时审一个 find_if 调用只需看谓词写得对不对,审一个手写循环却要推演全部控制流。

第二个理由是名字的沟通价值。find_if 出现在代码里,读者零成本知道这里在做查找;一段 for 循环出现在代码里,读者要读完循环体才能反推出意图。意图直接可见的代码,维护时省下的是每一次阅读的时间,连"找出 bob 的订单"这类注释都变得多余,函数名已经说完了。性能则不构成换不换的考量:谓词会被内联,算法版本和手写版本的机器码相差无几,选择依据只剩下清晰度和正确性。

第三个理由是需求演化的成本。查询条件从"用户等于 bob"变成"金额超过 500",手写循环要打开循环体做外科手术,算法版本只需换一个谓词,调用结构原封不动。规则的演化被限制在一个参数里,遍历骨架零改动,需求越常变,这条优势越值钱。

还有一个反向诱惑要警惕:既然循环还在手上,不如顺便多干几件事。一个循环里既查找又计数还顺手收集结果,看起来省了一趟遍历,实际上把三个查询的错误表面积叠到了一起,读的人要同时跟踪三路状态。一趟遍历省下的时间微乎其微,三路状态纠缠带来的理解和排错成本却是实打实的。一个循环只表达一个意图,查询就该一次一个,这条纪律比省遍历重要得多。

手写循环保留席位的场景同样明确:查询过程要维护复杂的中间状态,命中后的处理与查找纠缠不清,或者要在一次遍历里完成无法拆开的复合操作。判断的分寸和前几章一致:能用算法名字说清楚的查询就交给算法,说不清楚的部分留在循环里。留下来的循环因为稀少反而更醒目,读者一看就知道这里有标准算法表达不了的结构。

短路是查询算法的免费午餐

多数查询算法天生带着短路语义。find_if 命中即停,any_of 遇到第一个满足条件的元素即返回真,all_of 遇到第一个不满足的即返回假,none_of 遇到第一个满足的即返回假。答案一旦确定,剩余元素一概不看。手写循环要获得同样的行为,得自己记得写 break,而忘记写 break 的查询循环在代码评审里几乎每周都能见到。

短路也把家族成员分成两类。findfind_ifany_of 一家可以提前交卷,countcount_if 必须全程在场。这个区分在性能敏感路径上有实际意义:只想知道"有没有"时写成 count_if(...) > 0,会让算法白白扫完全程,换成 any_of 语义相同,却能在命中时立刻收工。先想清楚问题形态,再选对应的名字,短路是自然获得的收益。

严谨地说,标准对这些算法承诺的是"最多对谓词调用 n 次",提前停止是所有主流实现的普遍行为,也和短路语义的自然读法一致。谓词保持纯净时,这个区别根本无从观察,你尽可按短路理解;谓词里塞了副作用,调用次数就会变成可观察行为,自欺就开始了。这是第 03 章纯度要求换了一个角度的回响。

短路的收益大小还取决于数据本身。命中位置越靠前,提前收工省下的扫描越多,所以热点数据值得排在容器前部,这是数据布局层面的优化,与算法选择正交。反过来说,对必然全扫的 count_if,数据顺序就无关紧要。判断一次查询的成本时,把算法语义和数据分布放在一起看,结论才完整。

把查询接进数据处理流

真实代码里的查询很少单发。校验一批订单时,常见的组合是先用 all_of 确认字段完整,再用 any_of 探测风险,接着用 count_if 汇总异常数量,最后用 find_if 定位第一条异常记录做详情展示。每个算法回答一个问题,组合起来就是一段完整的查询叙述,读起来像逐条核对一份清单:字段齐不齐,有没有风险,有几个异常,第一个是谁。

查询算法还有一个调度角色:做门卫。重型处理之前先用廉价查询探路,any_of 发现风险再走完整审核流程,find_if 定位到目标才加载详情。查询本身是线性扫描,常常还能提前结束,它挡住的是后面贵得多的处理。把"先问后做"写成习惯,数据流的开销结构会清晰很多:便宜的提问在前,昂贵的动作在后,每一层都有明确的放行条件。

把查询写成护栏还有一个连带收益:失败路径变得醒目。all_of 不通过就提前返回,any_of 命中就进入风险流程,每个分支的触发条件都写在函数名上,排查问题时顺着名字就能还原当时的判断顺序。手写循环混在流程里时,这些判断点往往埋在嵌套条件深处,事后还原要靠运气。

查询家族怎么选

选型可以按"想要什么形态的结果"一步定位。要位置,用 findfind_if,拿到迭代器记得查哨兵;要数量,用 countcount_if,接受它必须扫完全程;要一个是非答案,用 any_ofall_ofnone_of,顺便享受短路。结果形态想清楚,函数名自己就会浮出来。

极值查询同样属于这个家族。max_elementmin_element 返回最大、最小元素的位置,接受自定义比较器,规则形态和 sort 完全一致。找最贵的一单、最早的截止时间,都是一次线性扫描的事,返回值照旧是迭代器,照旧要先查哨兵。找金额最高的订单就是一行调用:max_element 配上按金额比较的 lambda,得到的是位置而非金额本身,想拿金额还要解引用一次。这个位置语义常常比直觉中的"直接给我最大值"更有用:打印详情、标记处理、从容器中移除,都发生在同一个锚点上。它们再次印证本章的主线:问题形态决定算法选择,返回值纪律贯穿始终。

本章没展开的亲戚也值得知道名字。search 在范围里找一段子序列,回答"这一串元素出现在哪里";mismatch 并排扫描两段范围,回答"从哪个位置开始不一样";equal 回答两段范围是否逐元素相同。它们同样是查询,只是比较的对象从单个元素升级成了范围对范围,返回值纪律与 find 一家相同。

查文档的顺序也可以固定下来:先看返回值类型,确认结果形态是位置、数量还是布尔;再看前提,确认范围是否要有序、谓词是否有纯度要求;最后看复杂度,确认会扫几遍、能否提前结束。这三步和本章的展开结构一一对应,养成习惯之后,面对 <algorithm> 里任何一个陌生名字,都能很快判断它适不适合手头的场景。

还有两条边界补全这个工具箱。范围已经有序时,线性查找应当让位给 lower_boundbinary_search,对数时间的背后是第 02 章讲过的随机访问前提;目标是关联容器时,优先用容器自己的成员 findmap 的成员查找是对数时间,unordered_map 平均接近常数,泛型 find 在它们上面只会逐对线性扫描。成员版本利用结构,泛型版本通行万物,这个选择原则在整个标准库里反复出现。查询到这里告一段落了,但查到的元素接下来往往要被转换、筛选复制、逐个处理,下一章就进入修改与输出类算法的世界。

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

相关推荐
豆沙沙包?1 小时前
c++中引用(P7-P11)
java·c++·算法
Livia要学习1 小时前
Python装饰器
开发语言·python
一直都在5721 小时前
LangChain4j精讲
开发语言·人工智能
wuminyu1 小时前
虚拟线程底层ForkJoinPool的工作窃取算法机制
java·linux·c语言·jvm·c++
吹什么轩1 小时前
c++复习:c++11:lambda表达式
开发语言·c++
不会代码的小猴2 小时前
标准模板库(STL)
开发语言·c++·笔记·算法
ZhouDevin2 小时前
算法论文/高效微调4——DoRA:权重分解的低秩适配方法
算法
AI探索先锋2 小时前
A* 路径规划:四种算法的进化史-学习
学习·算法