在 map 里找一个 key,有人写成 std::find_if(m.begin(), m.end(), ...),十万个元素就要跑十万次比较;换成 m.find(key),同样十万个元素只要几十次 ------ 两者名字里都有 find,复杂度却差了一个数量级。这篇文章从这条落差讲起,把查找族算法的语义、返回值约定和短路口诀一次讲清,重点解释这个落差的根源:STL 算法只拿得到迭代器,看不见容器的内部结构。
1. 引子:一个把 O(log n) 写成 O(n) 的反例
先看一段你在真实项目里大概率见过、甚至写过的代码:
cpp
// 反例,不要这么写:在有序关联容器上用通用算法做 key 查找
// 违反了「让容器用自己的内部结构」这一原则,白白把 O(log n) 退化成 O(n)
// std::map<int, std::string> dict{{1, "one"}, {2, "two"}, {3, "three"}};
//
// const auto it = std::find_if(dict.begin(), dict.end(),
// [](const auto& kv) { return kv.first == 3; });
// if (it != dict.end()) { /* ... */ }
//
// 正确写法(容器成员函数,走红黑树):
// const auto it2 = dict.find(3); // O(log n),不用 std::advance
这段代码能编译、能跑对、单元测试也过,唯一的毛病是慢:std::find_if 从 begin() 开始一个节点一个节点地走,直到撞上目标;而 dict.find(3) 从树根开始每次比较砍掉一半候选。元素越多,差距越大。
为什么同样的语义,一个能砍半、一个只能傻走?因为 std::find_if 的函数签名里只有两个迭代器,它根本不知道背后是 map、是 vector、还是别的什么东西。
2. 查找族算法速查表
先把这一族算法的语义和返回值摊开,这些细节全靠一遍一遍查文档不如记一张表:
| 算法 | 语义 | 复杂度 | 返回什么 | 典型用途 |
|---|---|---|---|---|
find(first, last, v) |
找第一个等于 v 的元素 |
O(n) | 迭代器,找不到返回 last |
无序容器 / vector 里的值查找 |
find_if(first, last, p) |
找第一个使谓词 p 为真的元素 |
O(n) | 迭代器,找不到返回 last |
按条件查找(最常用) |
find_if_not(first, last, p) |
找第一个使谓词 p 为假的元素 |
O(n) | 迭代器,找不到返回 last |
找「第一个不满足条件」的分界点 |
count(first, last, v) |
统计等于 v 的元素个数 |
O(n) | difference_type(整数) |
计数 |
count_if(first, last, p) |
统计使谓词为真的元素个数 | O(n) | difference_type(整数) |
条件计数 |
any_of(first, last, p) |
是否存在 一个满足 p |
O(n),短路 | bool |
「有没有」判断,命中即返回 |
all_of(first, last, p) |
是否全部 满足 p |
O(n),短路 | bool |
校验、断言式检查 |
none_of(first, last, p) |
是否全部不 满足 p |
O(n),短路 | bool |
排除性检查 |
adjacent_find(first, last) |
找第一对相邻且相等的元素 | O(n) | 迭代器,指向这一对的第一个 | 已排序区间的判重 |
search(first, last, s_first, s_last) |
在区间里找子序列 | O(n·m) | 迭代器,指向子序列起点 | 找连续片段 |
min_element / max_element |
找最小 / 最大元素 | O(n) | 迭代器,空区间返回 last |
极值 |
mismatch(first1, last1, first2) |
找第一处不同 | O(n) | pair<迭代器, 迭代器> |
逐元素比较、找分歧点 |
官方文档:std::find / std::find_if / std::find_if_not、std::all_of / any_of / none_of、std::count / std::count_if
有一列值得单独记住:min_element / max_element 在空区间上返回 last(也就是 end()),不会抛异常、也不会返回空指针 。所以它也遵循「和 end() 比」这条统一规则。
空区间返回 end() 这件事看起来无趣,但它定义了这一族算法的统一契约:返回值永远是一个合法迭代器。
3. 返回值契约:永远和 end() 比
std::find 不抛异常、不返回指针、也不返回 bool。它只有一种失败表示法 ------ 返回第二个参数 (对整段区间来说就是 end())。
text
std::find(v.begin(), v.end(), 42)
v = [ 7 ][ 13 ][ 42 ][ 42 ][ 88 ][ 5 ]
↑ ↑
begin() end() ← 这是「一个元素的后面」这个位置,
不指向任何元素,解引用它是 UB
find 返回 含义
───────────────────────────────────────────────────────────
指向 index 2 的迭代器(第一个 42) 找到了
v.end() ────────────────┐ 没找到
(对子区间调用时是 last)─┘ ------注意不是 nullptr
───────────────────────────────────────────────────────────
判空必须写成: if (it != v.end()) ✓
不要写成: if (it) ✗ 迭代器没有 operator bool
不要写成: if (it != nullptr) ✗ 编译不过
更不要: printf("%d", *it); ✗ 可能是 *end(),UB
cpp
// 反例,不要这么写:不检查返回值就解引用,没找到时是未定义行为
// auto it = std::find(v.begin(), v.end(), 42);
// std::printf("%d\n", *it); // 如果 42 不存在,这里读的是 end() 位置
这条契约配一个可以直接跑的例子,顺便把「返回的迭代器怎么换算成下标」也演示掉:
cpp
// find_basics.cpp --- 编译: g++ -std=c++17 -Wall -O2 find_basics.cpp -o demo
#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <vector>
int main() {
const std::vector<int> v{4, 8, 15, 16, 23, 42};
// 找到了:返回指向该元素的迭代器
const auto it = std::find(v.begin(), v.end(), 16);
std::printf("16 在下标 %td 处,前面有 %td 个元素\n",
it - v.begin(), std::distance(v.begin(), it));
// 没找到:返回 end(),必须拿 end() 来比
const auto missing = std::find(v.begin(), v.end(), 99);
if (missing == v.end()) {
std::printf("99 不在序列里(返回的就是 end())\n");
}
// find_if:谓词换成任意条件,这里是找第一个偶数
const auto first_even = std::find_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; });
std::printf("第一个偶数是 %d\n", *first_even);
// find_if_not:找第一个「不是偶数」的,也就是第一个奇数
const auto first_odd = std::find_if_not(v.begin(), v.end(),
[](int x) { return x % 2 == 0; });
std::printf("第一个奇数是 %d\n", *first_odd);
// 反向查找:找最后一个 2
const std::vector<int> dup{1, 2, 3, 2, 1};
const auto last_two = std::find(dup.rbegin(), dup.rend(), 2);
// rbegin() 找到的迭代器 .base() 指向它右边一格,所以下标要减 1
std::printf("最后一个 2 的下标 = %td\n",
std::distance(dup.begin(), last_two.base()) - 1);
}
text
16 在下标 3 处,前面有 3 个元素
99 不在序列里(返回的就是 end())
第一个偶数是 4
第一个奇数是 15
最后一个 2 的下标 = 3
4. 关键对比:算法看不见容器的内部结构
现在回到引子里的落差。STL 的核心设计哲学叫算法与容器解耦(algorithms are decoupled from containers) :算法只接收迭代器区间,容器只提供迭代器。这样 std::find 一份实现能作用于 vector、deque、list、set、甚至裸数组 ------ 代价是算法彻底失去了关于数据组织方式的信息。
text
同一个「在 100000 个元素里找一个 key」的任务,三条搜索路径:
① std::vector<int> + std::find ------ 只能一格一格地走
[ 0 ][ 2 ][ 4 ][ 6 ][ 8 ] .... [ 199998 ] 一块连续内存,顺序访问
↑ ↑ ↑ ↑ ↑ ↑
1 2 3 4 5 .... 100000 次比较(最坏情况)
算法手里只有 begin 和 end 两个迭代器,
它不知道这块内存背后还有没有别的索引 ------ 所以只能线性扫。
② std::map<int,int>::find ------ 沿红黑树下降,每步砍掉一半
[ 100000 ]
/ \
[ 50000 ] [ 150000 ]
/ \ / \
... ... ... ... 树高 ≈ 2*log2(N) ≈ 34
每个节点是独立分配的一小块内存,节点之间靠指针连 ------ 有「指针追逐」开销,
但比较次数从 100000 降到几十次;N 一大,这笔账立刻划算。
③ std::unordered_map<int,int>::find ------ 一次哈希跳到桶
key=199998 ──hash──▶ bucket[199998 % 桶数] ──▶ 命中,比较 1 次
平均 O(1);代价是内存占用更大、元素无序、最坏 O(n)(哈希全撞一个桶)。
把这条路走一遍,用计数版谓词 / 计数版比较器把「到底比较了几次」量出来:
cpp
// member_vs_algorithm.cpp --- 编译: g++ -std=c++17 -Wall -O2 member_vs_algorithm.cpp -o demo
#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <functional>
#include <map>
#include <unordered_map>
#include <vector>
namespace {
constexpr int kSize = 100000; // 元素个数
constexpr int kStep = 2; // key 是 0, 2, 4, ...
// 计数版比较器:红黑树每次比较 key 都会经过这里
struct CountingLess {
std::size_t* counter{nullptr};
bool operator()(int lhs, int rhs) const {
++(*counter);
return lhs < rhs;
}
};
// 计数版相等判断:哈希表每次比较 key 都会经过这里
struct CountingEqual {
std::size_t* counter{nullptr};
bool operator()(int lhs, int rhs) const {
++(*counter);
return lhs == rhs;
}
};
} // namespace
int main() {
const int target = (kSize - 1) * kStep; // 199998,最后一个 key(线性扫描最坏情况)
// ① 连续内存 + 线性扫描
std::vector<int> keys;
keys.reserve(kSize);
for (int i = 0; i < kSize; ++i) {
keys.push_back(i * kStep);
}
std::size_t linear_cmp = 0;
const auto hit = std::find_if(keys.begin(), keys.end(), [&linear_cmp, target](int x) {
++linear_cmp;
return x == target;
});
// ② 红黑树
std::size_t tree_cmp = 0;
std::map<int, int, CountingLess> tree(CountingLess{&tree_cmp});
for (int i = 0; i < kSize; ++i) {
tree.emplace(i * kStep, i);
}
tree_cmp = 0; // 只统计查找阶段的比较,不把建树算进去
const auto tree_hit = tree.find(target);
// ③ 哈希表
std::size_t hash_cmp = 0;
std::unordered_map<int, int, std::hash<int>, CountingEqual> hash(
16, std::hash<int>{}, CountingEqual{&hash_cmp});
for (int i = 0; i < kSize; ++i) {
hash.emplace(i * kStep, i);
}
hash_cmp = 0;
const auto hash_hit = hash.find(target);
std::printf("数据量 N = %d,查找 key = %d\n", kSize, target);
std::printf("std::find_if 线性扫描: %zu 次比较(返回 %d)\n", linear_cmp, *hit);
std::printf("std::map::find: ~~ 次比较(返回 %d)\n", tree_hit->second);
std::printf("std::unordered_map::find: ~~ 次相等比较(返回 %d)\n", hash_hit->second);
}
text
数据量 N = 100000,查找 key = 199998
std::find_if 线性扫描: 100000 次比较(返回 199998)
std::map::find: ~~ 次比较(返回 99999)
std::unordered_map::find: ~~ 次相等比较(返回 99999)
后两行的具体数字用 ~~ 占位,因为红黑树的比较次数取决于实现细节,标准只保证 O(log n)。实测这个版本是 21 次 (std::map)和 1 次 (std::unordered_map)。但量级关系是铁定的:线性扫描 100000 次,红黑树几十次(≈ 2·log₂N),哈希表 1 次。
选型不能只看复杂度,还要看常数因子和内存布局:
| 场景 | 推荐 | 复杂度 | 为什么 |
|---|---|---|---|
| 元素很少(几十个以内),数据在连续内存里 | std::find / find_if 线性扫描 |
O(n) 但常数极小 | 顺序访问能整块命中 CPU 缓存,没有指针追逐;此时树反而更慢 |
| 元素多、需要按 key 查找 | 容器成员 find (map / set) |
O(log n) | 每次比较排除一半候选,比较次数与 N 呈对数关系 |
| 元素多、只判断相等、不在意顺序 | unordered_map::find / unordered_set::find |
平均 O(1) | 一次哈希直接定位桶;代价是内存更大、最坏 O(n) |
| 只想知道「有没有」 | any_of |
O(n),命中即停 | 短路求值,跟 count(...) != 0 相比少扫后半段 |
最后一行容易忽略:判断「存不存在」用 any_of 而不是 count 。count 必须扫完整段区间才能给出总数,any_of 找到一个就立刻返回。
还有一条边界要注意:std::find 作用在 std::set / std::map 上也能编译、也能跑对,但复杂度退化成 O(n) 。容器不会拦你,编译器也不会警告 ------ 这是那种「代码评审时才被发现」的性能坑。同理,std::find 作用在 std::list 上不会因为链表就变快,它照样一个个走。
5. any_of / all_of / none_of:短路求值的价值
这三个算法的共同点是一旦结论确定就立刻返回,不会把区间走完。区别只在「什么时候算结论确定」:
| 算法 | 什么时候可以提前返回 | 最坏情况扫描量 |
|---|---|---|
any_of |
遇到第一个使谓词为真的元素 | 全部(一个都没命中时) |
all_of |
遇到第一个使谓词为假的元素 | 全部(全部命中时) |
none_of |
遇到第一个使谓词为真的元素 | 全部(一个都没命中时) |
谓词调用次数是最好的证据 ------ 在谓词里加个计数器就能看见短路:
cpp
// predicates.cpp --- 编译: g++ -std=c++17 -Wall -O2 predicates.cpp -o demo
#include <algorithm>
#include <cstdio>
#include <vector>
namespace {
int g_predicate_calls = 0; // 统计谓词被调用了多少次
bool isNegative(int x) {
++g_predicate_calls;
return x < 0;
}
} // namespace
int main() {
const std::vector<int> v{3, 7, -1, -5, 9, 11}; // 共 6 个元素
g_predicate_calls = 0;
const bool has_neg = std::any_of(v.begin(), v.end(), isNegative);
std::printf("any_of(有负数) = %s,谓词调用 %d 次\n",
has_neg ? "true" : "false", g_predicate_calls);
g_predicate_calls = 0;
const bool all_pos = std::all_of(v.begin(), v.end(), [](int x) {
++g_predicate_calls;
return x > 0;
});
std::printf("all_of(全为正) = %s,谓词调用 %d 次\n",
all_pos ? "true" : "false", g_predicate_calls);
g_predicate_calls = 0;
const bool none_huge = std::none_of(v.begin(), v.end(), [](int x) {
++g_predicate_calls;
return x > 100;
});
std::printf("none_of(全都 <= 100) = %s,谓词调用 %d 次\n",
none_huge ? "true" : "false", g_predicate_calls);
}
text
any_of(有负数) = true,谓词调用 3 次
all_of(全为正) = false,谓词调用 3 次
none_of(全都 <= 100) = true,谓词调用 6 次
三次调用次数正好对应三种结局:any_of 在第 3 个元素(-1)命中就停了;all_of 同样在第 3 个元素发现反例就停了;none_of 没有任何元素满足「大于 100」,所以老老实实扫完 6 个。只有「扫完全场」的那一次是白费的。
顺带一提,这三个算法在 C++17 前的等价写法是 std::find_if(...) != end() 之类的组合,能读但意图不明显。语义明确的写法值得优先,因为读代码的人一眼就知道你想问什么。
6. 完整示例:日志查询
把上面几节拼起来 ------ 结构体上的 find_if、两个维度的 count_if、排序后 adjacent_find 判重、unique 去重(它也不改变 size(),下一篇细讲)、以及 max_element 找最慢请求:
cpp
// log_query.cpp --- 编译: g++ -std=c++17 -Wall -O2 log_query.cpp -o demo
#include <algorithm>
#include <cstddef>
#include <cstdio>
#include <string>
#include <utility>
#include <vector>
namespace {
enum class Level : int { debug = 0, info = 1, warn = 2, error = 3 };
struct LogEntry {
Level level{Level::info};
std::string tag;
int latency_ms{};
};
constexpr int kSlowMs = 100; // 超过这个延迟就算慢请求,别写魔法数字
} // namespace
int main() {
const std::vector<LogEntry> logs{
{Level::info, "db", 12}, {Level::error, "db", 340}, {Level::info, "net", 8},
{Level::warn, "db", 55}, {Level::error, "net", 210}, {Level::info, "db", 12},
{Level::warn, "net", 140},
};
// ① 按条件查找:第一条 ERROR 日志
const auto first_error = std::find_if(logs.begin(), logs.end(),
[](const LogEntry& e) { return e.level == Level::error; });
if (first_error != logs.end()) {
std::printf("首个 ERROR: tag=%s latency=%dms,下标 %td\n",
first_error->tag.c_str(), first_error->latency_ms,
first_error - logs.begin());
}
// ② 两个维度分别计数
const auto error_count = std::count_if(logs.begin(), logs.end(),
[](const LogEntry& e) { return e.level == Level::error; });
const auto slow_count = std::count_if(logs.begin(), logs.end(),
[](const LogEntry& e) { return e.latency_ms >= kSlowMs; });
std::printf("ERROR 条数 = %td,慢请求(>= %dms) 条数 = %td\n",
error_count, kSlowMs, slow_count);
// ③ 判重:把 (level, latency) 当签名,排序后扫相邻元素 ------ O(n log n)
std::vector<std::pair<Level, int>> signatures;
signatures.reserve(logs.size());
for (const auto& entry : logs) {
signatures.emplace_back(entry.level, entry.latency_ms);
}
std::sort(signatures.begin(), signatures.end());
const auto dup = std::adjacent_find(signatures.begin(), signatures.end());
if (dup != signatures.end()) {
std::printf("存在重复记录: level=%d latency=%dms(位置 %td)\n",
static_cast<int>(dup->first), dup->second, dup - signatures.begin());
} else {
std::printf("没有重复记录\n");
}
// ④ 去重:unique 只把不重复的往前挪,返回新的逻辑结尾(size() 不变)
const auto logical_end = std::unique(signatures.begin(), signatures.end());
std::printf("去重后逻辑长度 = %td,容器的 size() 仍是 %zu\n",
std::distance(signatures.begin(), logical_end), signatures.size());
// ⑤ 极值:找最慢的那条
const auto slowest = std::max_element(logs.begin(), logs.end(),
[](const LogEntry& lhs, const LogEntry& rhs) {
return lhs.latency_ms < rhs.latency_ms;
});
std::printf("最慢请求: tag=%s latency=%dms\n",
slowest->tag.c_str(), slowest->latency_ms);
}
text
首个 ERROR: tag=db latency=340ms,下标 1
ERROR 条数 = 2,慢请求(>= 100ms) 条数 = 3
存在重复记录: level=1 latency=12ms(位置 1)
去重后逻辑长度 = 6,容器的 size() 仍是 7
最慢请求: tag=db latency=340ms
三个值得回看的细节:
- 判重走的是排序 +
adjacent_find,而不是双重循环。双重循环是 O(n²),排序是 O(n log n),而且排序后的内存是连续访问的,缓存友好。这也是「先排序再处理」这套套路在 STL 里反复出现的原因。 unique那行的输出暴露了size()没变 ------ 它不是算法写错了,而是 STL 算法本来就不知道容器是什么。第 7 节会专门拆这件事。max_element传了个只比较latency_ms的 lambda:默认比较器要求LogEntry有operator<,而这里我们只想比一个字段,用 lambda 表达「比什么」比给类型加一个语义可疑的operator<更好。
7. 延伸阅读
- std::find / find_if / find_if_not --- cppreference:注意页面上明确写着「返回
[first, last)中第一个满足条件的元素,或last」,这就是本文第 3 节那条契约的原文 - std::all_of / std::any_of / std::none_of --- cppreference:Notes 里有一句「实现会在结论确定时短路返回」,这是第 5 节可以直接依赖的行为
- std::count / std::count_if --- cppreference:想确认「
count不会短路、any_of会」时对照它俩的复杂度声明看 - std::adjacent_find --- cppreference:判重套路的主角,配合
sort使用 - std::map::find --- cppreference / std::unordered_map::find:两个成员函数的复杂度声明放在一起看,O(log n) 与平均 O(1) 的差别一目了然
- C++ Core Guidelines --- SL.con.1 / 标准库算法:关于「优先用标准库算法而不是手写循环」的总纲,本文第 6 节就是照这个思路组织的
8. 一句话总结
查找族算法的统一契约是「返回迭代器,找不到返回 last,永远和 end() 比 」;any_of / all_of / none_of 会短路,判断「有没有」优先用它们而不是 count;算法只看得见迭代器、看不见容器 ,所以在 map / set / unordered_map 上做 key 查找必须用容器成员函数,std::find 上去就是 O(n);元素很少且内存连续时,线性扫描的缓存优势反而可能赢过树,选型要看 N 而不是背复杂度。