标准库函数的名字不总能准确表达语义:边界不代表命中,划分不等于排序,逻辑删除不改变容器大小,保存回调也不一定拥有它访问的数据。本文按六个系列整理这些区别,重点说明返回值、前置条件和常见误用。代码省略头文件与 main;ranges 接口及 std::erase 等需要 C++20,其他版本要求单独注明。

二分查找:边界不代表命中

三个接口的区别

在默认升序比较下:

函数返回值value 不存在时
lower_bound首个 >= value 的位置首个 > value 的位置,或 end()
upper_bound首个 > value 的位置与 lower_bound 重合
equal_range两者组成的半开区间 [lo, up)空区间,lo == up

例如 {1, 2, 3, 3, 3, 5, 7} 查找 3,lower_bound 指向下标 2,upper_bound 指向下标 5;查找不存在的 4,两者都指向 5,而不是返回“未找到”。upper_bound 也不是最后一个等于目标值的位置。

前置条件:按比较关系划分

严格说,二分算法要求区间按相应比较关系划分,不一定要求整个区间有序:lower_bound 要求满足 comp(elem, value) 的元素在前;upper_bound 要求满足 !comp(value, elem) 的元素在前。equal_range 要求两种划分都成立,并满足 comp(elem, value) 成立时 comp(value, elem) 不成立。

实际使用通常先排序,并让排序与查找使用一致的比较关系。用 > 排序却用默认 < 查找,或对不满足划分条件的区间查找,都会违反前置条件,导致未定义行为;算法不会替你检查。

正确判断存在性

lower_bound 找的是插入边界,只检查 it != end() 不足以证明命中,还必须判等:

1
2
3
std::vector<int> v{1, 2, 4, 5};
auto it = std::lower_bound(v.begin(), v.end(), 3);
bool found = it != v.end() && *it == 3; // false,it 指向 4

自定义比较器下,“相等”应理解为比较器等价,即两边都不小于对方。lower_bound 已保证 !comp(*it, value),因此只需再检查 it != last && !comp(value, *it);这不一定等同于 operator==。

若需要全部等价元素,直接使用 equal_range:

1
2
3
4
5
6
7
8
std::vector<int> v{1, 2, 3, 3, 3, 5, 7};
auto [lo, up] = std::equal_range(v.begin(), v.end(), 3);
if (lo != up) {
auto count = up - lo; // 3;相减要求随机访问迭代器
auto last_match = std::prev(up);
// *lo 和 *last_match 都是 3
}
// 按需删除全部命中:v.erase(lo, up);

目标大于所有元素或区间为空时,边界是 end(),不可解引用;目标小于所有元素时,边界是 begin(),迭代器有效但并未命中。判等前先检查末尾,用 && 短路避免越界。

复杂度与容器选择

比较次数为 O(log n),但只有随机访问迭代器才能保证整体 O(log n);其他迭代器的推进次数可能为 O(n)。因此 set、map、multiset、multimap 应用成员 lower_bound / upper_bound / equal_range,保持对数复杂度。unordered_* 不提供这些成员接口,也不保证满足二分算法的划分条件。

up - lo 的计数只适用于随机访问迭代器;对树容器使用 std::distance(lo, up),计数仍要线性遍历命中区间。找到边界的复杂度也不包含后续删除的成本。

排名选择:nth_element 不会完成排序

第 k 小与前 k 个

nth_element 原地重排区间,使 nth 上的元素与完全排序后该位置的元素按比较器等价,并保证左侧元素不排在右侧元素之后;两侧内部都不保证有序。重复元素分别占据排名,同键对象不保证选中哪一个,也不保持原相对顺序。

算法定义在 <algorithm> 中,需要随机访问迭代器和可重排元素;普通非并行重载平均 O(n),不是最坏 O(n) 保证。比较器必须满足严格弱序,不能使用 <= 或 >=。

取第 k 小,位置是 k - 1;取第 k 大,改用 std::greater<>{}:

1
2
3
4
5
6
std::vector<int> v{9, 1, 7, 3, 5, 3};
constexpr int k = 3; // 要求 1 <= k <= n,构造迭代器前检查

auto nth = v.begin() + (k - 1);
std::nth_element(v.begin(), nth, v.end());
std::cout << *nth; // 3

若要前 k 个,半开区间的右边界则是 begin() + k。需要这个前缀有序时,再排序:

1
2
3
4
5
6
std::vector<int> v{9, 1, 7, 3, 5, 3};
constexpr int k = 3; // 要求 0 <= k <= n
auto cut = v.begin() + k;
std::nth_element(v.begin(), cut, v.end());
std::sort(v.begin(), cut);
// [v.begin(), cut) 为 {1, 3, 3};平均 O(n + k log k)

nth == last 是合法调用,但不能解引用,也没有排序保证;空区间只能传 first == nth == last。上例 k = n 时,是随后的 sort 将全区间排序。边界值重复时,前缀只选出恰好 k 个,不会包含所有同值元素。

ranges:支持投影,但返回末尾

C++20 的 std::ranges::nth_element 可接受区间,也可接受 first, nth, last,并支持投影:先提取键,再比较。nth 仍是迭代器,不是整数下标。

1
2
3
4
5
6
7
8
9
10
11
struct Player {
std::string name;
int score;
};
std::vector<Player> players{
{"A", 80}, {"B", 95}, {"C", 70}, {"D", 90}
};
auto nth = players.begin() + 1; // 第 2 高分
auto end = std::ranges::nth_element(
players, nth, std::ranges::greater{}, &Player::score);
std::cout << nth->score; // 90;end == players.end()

算法重排的是整个对象,不是只移动投影字段。传统版本返回 void,ranges 版本返回对应 last 的末尾迭代器,不是 nth;区间重载对非 borrowed 的临时区间返回 std::ranges::dangling。选中元素始终通过事先传入的 nth 读取。

偶数长度的中位数

选择下标 n / 2 后,奇数长度得到中位数,偶数长度得到中间较大的值。较小者不是 *(nth - 1):左半区无序,应该取其最大值。

1
2
3
4
5
6
7
8
9
10
std::vector<int> v{9, 1, 7, 3, 5, 3};  // 必须非空
auto nth = v.begin() + v.size() / 2;
std::nth_element(v.begin(), nth, v.end());

double median = static_cast<double>(*nth);
if (v.size() % 2 == 0) {
auto lo = std::max_element(v.begin(), nth);
median = (static_cast<double>(*lo) + median) / 2.0;
}
// median == 4.0;先转为 double 再相加,避免 int 溢出
需求算法结果保证
第 k 小 / 第 k 大nth_element,位置取 k - 1该位置排名正确
无序的前 k 个nth_element,边界取 k前缀已选出,但内部无序
有序的前 k 个partial_sort,边界取 k前缀有序,通常约 O(n log k) 比较(k > 1)
整个区间有序sort全区间有序

不要把 nth_element 的结果直接交给要求有序或特定划分条件的二分查找。

删除:remove 搬移,erase 才缩小容器

逻辑末尾不是容器末尾

std::remove 把不等于指定值的元素按原相对顺序压缩到前部,remove_if 则移除谓词为 true 的元素。它们定义在 <algorithm> 中,返回新的逻辑末尾,不会改变容器大小;尾部对象仍存在,但值不可依赖,也不代表“被删除元素的集合”。

1
2
3
4
5
std::vector<int> v{1, 2, 3, 2, 4};
auto new_end = std::remove(v.begin(), v.end(), 2);
// [v.begin(), new_end) 为 {1, 3, 4},v.size() 仍为 5
v.erase(new_end, v.end());
// v 为 {1, 3, 4},size() 为 3

这就是 erase-remove 惯用法。按条件删除时换成 remove_if 即可。它要求可写的前向迭代器与可移动赋值的元素,复杂度 O(n);对 vector 擦除压缩后的尾部无需再搬移保留元素,避免循环擦除中间元素的最坏 O(n²) 成本。缩小的是 size(),不是 capacity()。

C++20:容器接口与 ranges 接口

直接删除容器元素,优先使用定义在对应容器头文件中的 std::erase / erase_if;返回值是删除数,不是迭代器:

1
2
3
4
std::vector<int> v{1, 2, 3, 2, 4};
auto removed = std::erase(v, 2); // 2;v 为 {1, 3, 4}
removed = std::erase_if(v, [](int x) { return x > 2; });
// removed == 2,v 为 {1}

std::erase(v, value) 按值删除全部匹配项,v.erase(pos) 按位置删除;关联容器按键删除仍用成员 erase(key),按条件删除可用 std::erase_if。

std::ranges::remove / remove_if 仍然只压缩,但返回的是待擦除的尾部子区间,不是保留区间;还支持投影:

1
2
3
4
std::vector<int> v{1, 2, 3, 2, 4};
auto tail = std::ranges::remove(v, 2);
v.erase(tail.begin(), tail.end());
// v 为 {1, 3, 4}

三个易错点

  • 必须范围擦除。 v.erase(new_end) 只删除一个元素;没有匹配项时 new_end == end(),单位置擦除还违反前置条件。v.erase(new_end, v.end()) 可以安全处理空区间和全部匹配。
  • 删除值不能被压缩过程改写。 remove(..., v[0]) 与 std::erase(v, v[0]) 都可能在搬移时覆盖比较基准。先保存独立副本,例如 const int target = v[0];谓词引用捕获区间元素作为基准时也有相同风险。
  • 位置不等于对象身份。 压缩可能把另一个元素移动赋入原位置;随后的 vector::erase 使删除起点及之后的迭代器、引用和旧 end() 失效,不要再使用旧的 new_end。

固定大小数组也能压缩,但只能使用返回的逻辑区间,不能缩小物理长度。set / map 的键不可写,不适用 erase-remove;链表应使用成员 remove / remove_if,直接删除节点并更新大小。

去重:unique 只折叠相邻等价元素

相邻去重不是全局去重

std::unique 保留每个连续等价组的第一个元素,把结果压缩到前部;默认用相等比较,自定义版本使用等价谓词。它不会排序,也不会查找不相邻的重复值。返回与擦除规则和 remove 相同,复杂度 O(n)。

1
2
3
std::vector<int> v{1, 1, 2, 1, 1, 3, 3};
v.erase(std::unique(v.begin(), v.end()), v.end());
// v 为 {1, 2, 1, 3},不是 {1, 2, 3}

全局去重且允许重排,可以先排序:

1
2
3
4
std::vector<int> v{3, 1, 2, 1, 3};
std::sort(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end());
// v 为 {1, 2, 3};整体 O(n log n)

排序不是 unique 的前置条件,而是把等价元素聚到一起的手段。若必须保留首次出现的顺序,应使用已见键集合,并按输入顺序收集首次出现的元素,不能先排序。

谓词必须表示等价关系

第三个参数表示“两个元素是否等价”,不是排序比较器。它必须满足自反性、对称性和传递性;<、<= 或不具传递性的浮点“距离小于 epsilon”都不符合要求。

按字段全局去重时,排序键与等价键要一致。若还要保留同键记录在原输入中的首条,可用 stable_sort;ranges 版本通过投影指定键,并返回待擦除的尾部子区间:

1
2
3
4
5
6
7
8
9
10
11
12
struct Record {
int id;
std::string text;
};
std::vector<Record> rows{
{2, "first-2"}, {1, "first-1"}, {2, "second-2"}
};
std::ranges::stable_sort(rows, {}, &Record::id);
auto tail = std::ranges::unique(
rows, std::ranges::equal_to{}, &Record::id);
rows.erase(tail.begin(), tail.end());
// rows 为 {{1, "first-1"}, {2, "first-2"}}

稳定排序只保持组内原序,最终不同键仍按键排列。ranges 的概念约束也不能证明谓词具有等价关系;ranges::remove 和 ranges::unique 的区间重载对非 borrowed 临时区间都返回 dangling。

不改源区间的相邻去重用 unique_copy,输入与输出不能重叠;输出到空 vector 用 std::back_inserter,不能向 out.begin() 写入不存在的元素。链表成员 unique 则直接删除节点,不必再 erase。

调用包装:参数绑定与类型擦除是两件事

std::bind 和 std::function 都定义在 <functional> 中:前者改写参数绑定,返回具体类型的函数对象;后者统一调用签名,以类型擦除方式存储不同目标。要分别检查创建时持有什么、复制时复制什么、调用时如何传参。

std::bind:默认保存值,不是借用原变量

bind 按 decay 后的类型保存可调用对象和绑定参数:左值通常复制,右值可以移动,数组和函数退化为指针。借用原对象要显式使用 std::ref / cref,但它们不延长生命周期。

1
2
3
4
5
6
7
8
9
int n = 5;
auto increment = [](int& x) { return ++x; };
auto snapshot = std::bind(increment, n);
auto borrowed = std::bind(increment, std::ref(n));

n = 10;
int a = snapshot();
int b = borrowed();
std::cout << a << ' ' << b << ' ' << n; // 6 11 11

成员函数同理:std::bind(&T::method, obj, ...) 保存对象副本;传 std::ref(obj) 或 &obj 才访问原对象,且必须保证其存活。复制指针只复制地址,不获得所指对象的所有权。

其他容易忽略的调用规则:

  • _1、_2 指包装器调用时的第几个实参,可以重排目标参数。
  • 占位符实参按原值类别转发;普通绑定状态按左值传入,即使写成 std::move(wrapper)() 也不会自动移交状态。因此能保存 unique_ptr,不代表能把它按值交给目标函数。
  • 嵌套 bind 会先执行,再把结果交给外层目标,不是普通函数对象参数。
  • 未使用的额外实参仍会求值;callback(x, expensive_work()) 不会因为第二项被忽略就省掉它,也不保证实参从左到右求值。

通常优先用 lambda,捕获与参数关系更明确。只绑定前几个参数可用 C++20 的 bind_front:它按包装器的 cv 限定和值类别转发保存状态,因此右值包装器可以移交只移动资源:

1
2
3
4
5
auto consume = std::bind_front(
[](std::unique_ptr<int> p, int delta) { return *p + delta; },
std::make_unique<int>(40));
// consume(2); // 错误:不能从左值状态复制 unique_ptr
std::cout << std::move(consume)(2); // 42,资源已移交

std::function:可复制包装器要求可复制目标

构造 function 时会保存目标对象;复制包装器时也会复制目标。值捕获状态通常各自独立,引用捕获仍指向同一对象,捕获 shared_ptr 则共享底层对象;复制目标不等于深拷贝全部数据。

1
2
3
4
5
6
std::function<int()> f = [n = 0]() mutable { return ++n; };  // C++14
auto g = f;
int a = f();
int b = g();
int c = f();
std::cout << a << ' ' << b << ' ' << c; // 1 1 2

目标必须可复制,即使只打算移动 function 也一样。捕获 unique_ptr 的闭包不能直接存入 std::function,std::move 不会绕过此要求;用 std::ref(closure) 只能借用闭包,不是拥有式替代。

还需注意:

  • 空调用抛异常。 默认构造的 function 没有目标,调用抛 std::bad_function_call;若接口允许空回调,可先用 if (callback) 检查。
  • 可能分配,但没有可移植的 SBO 阈值。 不能只凭闭包大小判断是否动态分配,也不能断言每次调用一定间接调用或一定比 lambda 慢;性能取决于实现与优化。
  • 引用返回不延长生命周期。 std::function<const int&()> f = [] { return 42; }; 在旧标准下可能通过构造检查,却返回悬空引用;C++23 的规则禁止这种构造,但库实现可能滞后。应返回实际存活对象的引用,显式引用返回类型也不能修复悬空对象。
  • const 不代表目标只读。 const std::function 仍能调用 mutable lambda;并发修改目标或捕获对象时,依然需要同步。
需求选择主要约束
即时调用,不需要统一类型存储模板参数持有或借用由接口决定
固定、重排参数或显式捕获lambda引用捕获不延长生命周期
只绑定前几个参数C++20 bind_front按包装器值类别转发状态
使用占位符或嵌套绑定规则bind普通绑定状态按左值传入
统一签名存储,回调可复制function目标必须可复制;空调用抛异常
统一签名存储,回调只移动C++23 move_only_function需标准库支持;空调用违反前置条件

使用 move_only_function 前应检查 __cpp_lib_move_only_function >= 202110L,不能只看编译器是否接受 C++23。它能拥有捕获 unique_ptr 的闭包,也支持带 cv、引用限定和 noexcept 的调用签名;空调用不保证抛异常,C++23 下属于未定义行为。

异步执行:async 不是 fire-and-forget

明确启动策略

std::async 定义在 <future> 中,保存任务与参数,返回关联结果共享状态的 std::future<R>。结果可以是返回值,也可以是异常。

策略执行方式
launch::async如同在新执行线程中调用,不依赖等待方触发
launch::deferred首次 get() / wait() 时,在调用该操作的线程中执行
async | deferred实现从允许的策略中选择

省略策略等价于允许 async | deferred,不是默认一定开线程。需要任务自行开始时,显式指定 launch::async,保存句柄,再取结果:

1
2
3
4
5
auto first = std::async(std::launch::async, [] { return 20; });
auto second = std::async(std::launch::async, [] { return 22; });
int left = first.get();
int right = second.get();
std::cout << left + right; // 42

两次启动具备并发机会,不保证执行时间一定重叠。显式 async 在无法启动线程时可直接抛 std::system_error,状态分配或参数构造也可能失败;这些与任务异常经 get() 重抛是不同的失败阶段。

future:有效、就绪与成功不同

操作作用是否消费状态
valid()检查是否关联共享状态,不表示完成否
wait()等待就绪,不取得结果或重抛任务异常否
wait_for() / wait_until()返回 ready、timeout 或 deferred否
get()等待,取得结果或重抛任务异常是,之后无效

ready 也可能意味着异常已保存;等待完成不等于检查成功。future<void> 同样需要 get() 来观察异常。get() 即使重抛异常也消费状态,不能再次取值或等待;默认构造、被移走或 share() 后的原句柄也无效,不要依赖重复 get() 一定抛诊断异常。

定时等待不会执行尚未启动的 deferred 任务:

1
2
3
4
5
6
7
int calls = 0;
auto task = std::async(std::launch::deferred, [&calls] {
return ++calls;
});
auto status = task.wait_for(std::chrono::seconds{0});
// status == std::future_status::deferred,calls == 0
int result = task.get(); // result == 1,此时才执行

一直轮询定时等待不会让 deferred 任务推进,销毁句柄也不会替你执行。timeout 不取消任务,也不是执行时长上限。

析构等待与借用边界

丢弃 async 返回值不会让任务脱离调用方。 对 launch::async 的共享状态,最后一个持有者释放状态时需满足关联线程完成的同步要求;未保存的临时 future 因此可能在语句结束时等待,使连续两次调用串行化:

1
2
3
(void)std::async(std::launch::async, [] { return 20; });
// 临时 future 已销毁,关联线程已经完成
(void)std::async(std::launch::async, [] { return 22; });

不是所有 future 都有这种等待:来自 promise / packaged_task 的状态没有 async 带来的析构等待,deferred 状态释放也不触发执行。转成 shared_future 不能绕过它,最后一个副本仍可能等待;覆盖或移动赋值释放旧状态时也可能等待。

析构等待不会重抛任务异常。多个任务要分别观察结果,不能把第一个 get() 抛出后的其他句柄析构,当成已处理其失败。

参数保存也不等于借用原变量:async 按 decay 后的类型保存状态,左值通常复制,右值可以移动,任务执行时保存值按右值参与调用。要访问原对象需显式使用 std::ref / cref:

1
2
3
4
5
int value = 10;
auto task = std::async(std::launch::async,
[](int& target) { ++target; }, std::ref(value));
task.get(); // 完成并同步后再读取
std::cout << value; // 11

任务完成前,被借用对象必须存活,冲突访问必须同步。引用捕获、裸指针、string_view、span 同样不拥有底层数据;不要返回仍借用局部变量的任务句柄。

多个消费者读取同一结果可用 shared_future:重复 get() 不重算,一般结果返回 const R&,引用不能比共享状态活得更久。大量短任务、并发限制与队列调度应使用执行器或线程池;持续运行与协作停止需要受管理的线程。async 不提供取消,也没有可配置的标准线程池契约。

标准依据