上周技术群里有人问:“C++11 以下能用 std::reverse 吗?”我第一反应是这问题有什么好问的,但再往下想,这个问题其实很有嚼头——它不只是版本边界的问题,还牵扯到迭代器分类、swap 语义、容器内部布局,甚至编译器优化。正好前阵子在一个三年没升级编译器的老项目里折腾过 reverse 相关的代码,借着这个机会,我把 reverse-skill 完整梳理成一篇实操笔记,从标准版本、性能实测到各类边界场景一次性讲透。
如果你是刚接触 C++ 的新手,这篇文章可以帮你把std::reverse的底层机制和使用边界搞明白;如果你在维护老代码,那“C++98 能不能用 std::reverse”“为什么这里 reverse 这么慢”“为什么 reverse 之后迭代器指向的内容变了”这些问题,应该都能在下面找到答案。
1. 先说结论:std::reverse 从 C++98 就有了,但“能用”和“用对”是两回事
直接回答热词里的那个问题:std::reverse是 C++98 标准库的一部分,C++11 以下完全可以使用,不存在“C++11 之后才引入”这回事。
// C++98 / C++03 完全合法 #include <algorithm> #include <vector> #include <iostream> int main() { int arr[] = {1, 2, 3, 4, 5}; std::reverse(arr, arr + 5); std::vector<int> v; v.push_back(10); v.push_back(20); v.push_back(30); std::reverse(v.begin(), v.end()); for (int i = 0; i < 5; ++i) std::cout << arr[i] << ' '; std::cout << '\n'; for (std::vector<int>::iterator it = v.begin(); it != v.end(); ++it) std::cout << *it << ' '; std::cout << '\n'; }上面这段代码放到任何一个支持 C++98 的编译器里都能编译运行。这是第一个结论。
但“能用”不代表“用对”。这个函数简单到只有一行签名,可它背后的约束条件非常多。首先是迭代器要求:std::reverse需要的是双向迭代器(BidirectionalIterator),不是随便一个迭代器都能用。std::forward_list这种单链表就不行,因为它的迭代器只能往前走,没法从尾部往头部走。这一点在选容器的时候就要想清楚,别等到编译报错才回头查。
其次是区间语义:std::reverse处理的是[first, last)这个半开区间,last指向的是最后一个元素的下一个位置。这个和所有 STL 算法一致,但因为“反转”这个动作天然让人想到“头尾互换”,很多人会下意识地写出std::reverse(v.begin(), v.end() - 1),结果最后一个元素纹丝不动。
还有复杂度:标准规定std::reverse恰好执行(last - first) / 2次交换,注意是恰好,不是“最多”。这个特点在算法里不太常见,排序算法通常给最坏复杂度,但 reverse 的实现路径非常固定:从两头往中间走,每步交换一对元素。理解这个复杂度,对后面评估性能很有帮助。
2. 版本边界再挖一层:C++11 以下的 reverse 到底受什么影响
既然std::reverse在 C++98 里就有,那“C++11 以下能用吗”这个问题的完整答案要比表面复杂一点。关键在于:函数存在和函数表现良好,是两个维度的问题。
C++98/03 和 C++11 对std::reverse的核心要求一致:接收双向迭代器、恰好交换(last - first) / 2次。但 C++11 引入移动语义后,标准库内部的swap行为发生了变化。C++98 里std::reverse的交换基本靠元素类型的拷贝构造和赋值,两个自重很大的对象互相交换,就要发生两次深拷贝。C++11 之后,很多容器的swap变成了“只交换内部指针”的轻量操作,比如std::string、std::vector,它们的std::swap特化是 O(1) 的。
这意味着什么?如果在 C++98 模式下用std::reverse翻转一个std::vector<std::string>,因为std::string自己就重载了高效的swap(C++98 里 string 的 swap 已经是指针交换了),所以体验不出来。但如果你自定义了一个大体积的结构体,没有写高效的swap函数,那么在 C++98 下 reverse 一个一万元素的数组,就是一万次深拷贝,性能会很难看。
struct HeavyObject { char buffer[1024]; int id; // C++98 下没有移动语义,也没写 swap 特化 };所以在老项目里用 reverse,最好遵循一条原则:确保元素类型支持高效的 swap。这是“C++11 以下能用”背后最容易忽视的性能前提。
还有一个版本差异:C++11 提供了std::begin、std::end、std::rbegin、std::rend这四个自由函数,C++98 下没有,操作原生数组要多写一点代码。比如上面的例子,std::reverse(arr, arr + 5)就得自己算长度,不能写std::reverse(std::begin(arr), std::end(arr))。不过在 C++98 里,std::vector的成员函数begin()/end()/rbegin()/rend()是早就齐了的,容器操作不受影响。
标准的演进时间线大致是这样:
| 标准版本 | std::reverse 相关变化 |
|---|---|
| C++98 / C++03 | 标准库原生提供,复杂度、迭代器要求沿用至今 |
| C++11 | 无签名变化,但移动语义让基础 swap 更高效;新增 cbegin/crbegin 方便只读反转遍历 |
| C++17 | 增加带 ExecutionPolicy 的并行重载 |
| C++20 | 开始支持 constexpr |
| C++23 | 增加了 std::ranges::reverse |
我看到有人在社区里讨论“C++11 以下能不能用 std::reverse”,最典型的原因是用了老旧的编译器标准模式,比如 GCC 4.8 配-std=c++98,或者 VC6 时代的 STL。实测下来,这些环境都能用,但在 VC6 那种老旧的实现里,标准库对模板的实例化比较敏感,容易出现“明明代码没问题,编译却报找不到重载”的情况。遇到这种问题别怀疑算法本身,先确认是不是#include <algorithm>没写。
3. 性能不是“头尾交换”那么简单:容器类型决定了你能跑多快
很多人对std::reverse的性能印象是“O(n) 而已,肯定很快”。这话对,但不够准确。同样是反转一百万个元素,容器不同,实际耗时可能差出一个数量级。
我拿一千万个int做过一次粗略测试(极端简化,仅供参考),结果大致是:std::vector<int>最快,std::deque<int>慢两三倍,std::list<int>会慢到让你怀疑人生。原因要从内存布局说起。
std::vector的元素是连续内存,缓存友好,std::reverse从两头往中间遍历,每次访问的两个元素大概率都在同一块 cache line 上,交换成本极低。std::deque是分段连续内存,虽然也能从两头遍历,但每跨一段就要跳一次指针,缓存命中率下降,迭代器自增也多了边界判断。std::list的节点分散在堆上,每个节点独立分配,遍历时基本是“满内存乱跳”,局部性差到极致,而且节点本身还要存前后指针,内存占用也大。
| 容器 | 一千万 int 反转耗时(相对值) | 说明 |
|---|---|---|
| std::vector | 1x | 连续内存,缓存友好 |
| std::deque | 2~3x | 分段连续,跨段访问开销 |
| std::list | 20x 以上 | 节点分散,且建议改用成员函数 reverse |
| std::vector std::string | 视字符串长度 | swap 只换指针时非常快 |
这里要特别强调一个细节:std::list有专门的成员函数reverse(),它是通过调整每个节点的prev和next指针来反转的,不搬动元素本身。虽然复杂度也是 O(n),但避免了对元素值的一次次交换,对存储重型对象的 list 来说差别巨大。所以遇到std::list要反转,直接调用lst.reverse(),别用std::reverse(lst.begin(), lst.end()),后者是可以编译的,但性能上完全是下策。
再说一个冷门但好用的点:C++17 之后std::reverse支持并行执行策略。如果你的容器足够大、元素交换成本不低,可以试试:
#include <execution> #include <algorithm> std::reverse(std::execution::par, v.begin(), v.end());但注意,并行版本并没有把复杂度从 O(n/2) 变低,只是把交换操作分摊到多个线程上。元素太少时线程调度开销反而更大,所以别看到par就无脑用。实测起码要几百万元素的规模,才能看出并行收益。
也有很多人问,std::reverse能不能优化成memcpy级别的速度。对于int、double这种 POD 类型,编译器通常能把循环优化得相当干净,但本质上它还是在做“交换元素”而不是“整块拷贝”,所以不要指望它像memcpy那样一梭子把内存倒过来。如果你真在做一个对性能极端敏感的场景,比如要反转一个超大的数值数组,可以考虑手写指针双端交换,或者用 SIMD 按块处理,但那是另一个层面的优化,普通业务代码完全没必要。
4. reverse 家族的另一半:reverse_copy、reverse_iterator 和 base() 陷阱
std::reverse是原地反转。如果你不想改动原数据,或者想把反转结果直接输出到另一个容器,标准库里还有一个std::reverse_copy。
template<class InputIt, class OutputIt> OutputIt reverse_copy(InputIt first, InputIt last, OutputIt d_first);它和std::reverse最大的区别是:输入迭代器只要求InputIterator,不要求双向。什么意思?意味着std::reverse处理不了的std::forward_list,reverse_copy可以处理——因为拷贝过程只需要从头到尾读一遍,不需要从尾部往回走,只要把读到的元素往前插入输出位置就行。
常见用法是配合back_inserter:
std::vector<int> src; std::vector<int> dst; // C++98 写法 std::reverse_copy(src.begin(), src.end(), std::back_inserter(dst)); // C++11 也可以 std::reverse_copy(src.begin(), src.end(), std::back_inserter(dst));注意输出位置要保证有足够空间,或者用back_inserter自动扩展,否则直接传dst.begin()会越界。
如果你只是想反向遍历一遍数据,不想生成任何新容器,更轻量的方案是reverse_iterator。C++98 时代就有rbegin()/rend(),比如:
// C++98 for (std::vector<int>::reverse_iterator rit = v.rbegin(); rit != v.rend(); ++rit) { // 从尾到头访问 }这种遍历方式和std::reverse不冲突,一个是“逻辑上的倒着看”,一个是“物理上的倒过来放”。在 C++11 之前没有crbegin()/crend(),想只读遍历就声明const_reverse_iterator,或者直接用const std::vector<int>&再去取rbegin(),返回的就是const_reverse_iterator。
reverse_iterator这里有一个经典到我每次都要提醒自己的坑——base()的语义。很多人以为rbegin()的base()是end(),rend()的base()是begin(),这个方向感没错,但一旦涉及插入和删除,就很容易翻车。
看个例子:
std::vector<int> v; v.push_back(1); v.push_back(2); v.push_back(3); v.push_back(4); v.push_back(5); std::vector<int>::reverse_iterator rit = std::find(v.rbegin(), v.rend(), 3); // rit 指向的值是 3,正向位置是 index 2 v.insert(rit.base(), 99);直觉上你可能觉得rit.base()指向的就是 3,插入会插到 3 前面。但实际不是。rit.base()是正向视角中3 后面那个位置(也就是 index 3,值 4 的位置)。所以v.insert(rit.base(), 99)的结果是{1, 2, 3, 99, 4, 5}。
为什么?因为 reverse_iterator 的语义是:rit指向的元素等价于*(rit.base() - 1)。对着这个公式,rit.base()天然就是偏右一位的。删除的时候更危险,直接v.erase(rit.base())会删掉 4,不是 3。想删 3,得写v.erase((rit + 1).base()),或者干脆先转成正向迭代器再操作。
提示:和
reverse_iterator打交道时,不管插入还是删除,先在心里画出正向 index 图,或者写一个*(rit.base() - 1) == *rit的注释在旁边,能救你不少时间。
5. 实战踩坑清单:字符数组、vector 、嵌套容器与迭代器失效
这一节全是实际项目里见过的问题,每一项都是“编译能过、逻辑跑偏”的隐蔽 bug,值得一条条过。
第一个坑:字符数组把'\0'也算进去了。对 C 风格字符串做 reverse,很多人会直接写:
char str[] = "hello"; std::reverse(str, str + sizeof(str)); // 错!sizeof(str)是 6,因为末尾还有个'\0'。结果反转完之后字符串变成了"\0olleh",第一个字符就是终止符,输出直接为空。正确写法是:
std::reverse(str, str + std::strlen(str));同理,const char* s = "hello"这种字符串字面量指向的是只读内存,传给std::reverse要么编译报错、要么运行崩溃。想反转 C 字符串,一定要用可修改的字符数组或std::string。
第二个坑:std::vector<bool>的代理引用问题。这是 C++ 标准库里最著名的特化之一:vector<bool>为了省内存,每个 bool 只占 1 个 bit,所以operator[]返回的不是真正的bool&,而是代理对象。某些老编译器上,std::reverse(v.begin(), v.end())会触发难以理解的编译错误,因为标准库内部的交换逻辑依赖元素引用的可赋值性。不同标准库实现对这个特化的处理不完全一样,现状是主流编译器基本能编译,但这类代码的可移植性确实差。我的建议是:生产环境别用vector<bool>,真想省内存就用vector<char>或者自定义位数组,这不是std::reverse的问题,是vector<bool>本身的历史包袱。
第三个坑:reverse 不会导致迭代器失效,但“不失效”不等于“指向不变”。std::reverse不改变容器大小,所以理论上原来拿到的迭代器、引用都不会失效。但在嵌套容器里容易出现认知偏差。比如你有一个vector<vector<int>>,外层反转时,内层 vector 对象会整个交换位置,指向某个内层 vector 的迭代器虽然没失效,但它指向的内容已经变了——你原本持有的“第 2 行”的引用,在反转后可能变成了“第 4 行”的数据。写代码前先想清楚:你反转的是外层还是内层?
std::vector<std::vector<int> > data; // 反转行的顺序 std::reverse(data.begin(), data.end()); // 反转每一行内部 for (std::vector<std::vector<int> >::iterator it = data.begin(); it != data.end(); ++it) std::reverse(it->begin(), it->end());这两个语义完全不一样,业务场景里经常有人想“翻转矩阵的每一行”,结果不小心把行的顺序也改了。
第四个坑:迭代器范围写错。这看着基础,实际很常见。比如std::reverse(v.begin() + 1, v.end())是想跳过第一个元素,这没问题;但std::reverse(v.begin(), v.end() - 1)是少转最后一个,很多人写完根本意识不到。所有 STL 算法都需要在心里默念“左闭右开”,reverse 也不例外。
第五个坑:std::reverse和std::reverse_copy混用时的空间分配问题。上面说过reverse_copy可以用back_inserter,但如果输出容器已经预先resize好,你直接传dst.begin()是没问题的。问题在于reverse_copy是从源头部开始拷贝到目标头部,也就是说,源头的第一个元素会写到目标第一个位置,源头的最后一个元素写到目标最后位置。如果你心里的预期是“把源倒序后整体贴到 dst 末尾”,那就得配合back_inserter,否则会覆盖已有数据。
6. reverse 的进阶套路:回文判断、数组旋转和位反转
std::reverse本身不起眼,但它是很多经典算法题的基础积木。掌握这几个套路,平时写代码和面试都能用到。
套路一:回文判断。判断一个字符串是不是回文,最直觉的做法是把原字符串拷贝一份再 reverse,然后比较。但更优雅的方式是用std::equal配合反向迭代器,只比较前半段和后半段的反向视图:
template <typename T> bool is_palindrome(const T& container) { return std::equal(container.begin(), container.begin() + container.size() / 2, container.rbegin()); }C++11 以下的容器都有rbegin(),所以这套代码在老项目里也能跑。关键点在于不要反转原字符串再比较,那样会污染原始数据。用反向迭代器做逻辑上的“反转”是零成本。
套路二:数组旋转。经典的“把数组右移 k 位”问题,最优解是三次 reverse。以右移 k 位为例(假设 k 已经对 n 取过模):
std::reverse(v.begin(), v.end()); std::reverse(v.begin(), v.begin() + k); std::reverse(v.begin() + k, v.end());为什么三次 reverse 能完成旋转?第一次全量反转,把后面的元素整体挪到前面,但顺序是反的;第二次反转前 k 个,让它们恢复原来的相对顺序;第三次反转后 n-k 个,让剩余部分也恢复原来的相对顺序。这个思想比循环移位优雅得多,时间复杂度 O(n),额外空间 O(1)。值得注意begin() + k这个位置是边界,k 如果等于 0,begin() + 0和begin()是同一个位置,std::reverse会安全地什么都不做。
类似技巧在很多工程场景里也有用:协议数据包的字节序调整、日志缓冲区的顺序重排,本质都是“让一段区间反过来”。下次遇到类似问题,先想想能不能用 reverse 组合解决,不要上来就写复杂的 for 循环。
套路三:位反转(bit reversal)。std::reverse只能反转容器里的元素,不能直接反转一个整数里的 bit。但如果我们把一个整数的每一位抽到 bool 数组里,再用std::reverse反转这个数组,读起来非常直白:
unsigned char reverse_bits(unsigned char byte) { bool bits[8]; for (int i = 0; i < 8; ++i) bits[i] = (byte >> i) & 1; std::reverse(bits, bits + 8); unsigned char result = 0; for (int i = 0; i < 8; ++i) result |= bits[i] << i; return result; }这段代码性能不极致,但可读性极高,适合写算法原型或维护性优先的场景。生产环境追求吞吐量的话,还是用 256 字节查表法或者专门的位反转指令。位反转在 FFT、CRC 校验、图像处理里都属于高频操作,用std::reverse的思维模式去理解它,至少能让你拿到一段逻辑正确的实现,再逐步优化。
最后分享一个我个人的实践习惯:凡是遇到“把一段数据反过来处理”的需求,先看一眼容器类型。连续内存的优先std::reverse;list 用成员函数;只读遍历用rbegin()/rend();不想改原数据用reverse_copy。这四个选择定下来,剩下要警惕的就是区间边界和base()。老项目里没有auto、没有 range-for,代码是啰嗦一点,但std::reverse这个函数从 C++98 到 C++23 一直都很稳定,你大可以放心用。