16-11 std::vector 和堆栈的行为
假设你正在编写一个程序,用户需要输入一组数值(例如一堆考试分数)。此时,用户输入的数值数量在编译时无法确定,且每次运行程序时都可能不同。你将把这些数值存储在 std::vector 中以便显示和/或处理。
基于前文讨论,可采用以下几种方案:
首先,可先询问用户数据条目数量,创建相应长度的向量,再引导用户输入相应数量的值。
此法虽可行,但要求用户必须预先精确知晓条目数量且计数无误。手动计数超过十到二十项相当繁琐——既然我们本应代为统计输入值的数量,何必让用户自行计数?
另一种方案是预设用户不会输入超过某个阈值(例如30个值),直接创建(或调整大小)一个包含该数量元素的向量。此时可引导用户持续输入数据直至完成(或达到30条记录上限)。由于向量长度默认反映实际使用元素数,我们可根据用户实际输入的值调整向量大小。
此方案的弊端在于用户被限制在30条记录内,且无法判断该数量是否合理。若用户需要输入更多值,那只能遗憾作罢。
解决方法是添加逻辑:当用户达到最大值数量时自动扩展向量。但这意味着我们必须将数组大小管理与程序逻辑混合处理,这将显著增加程序复杂度(并不可避免地导致错误)。
真正的症结在于:我们试图预判用户可能输入的元素数量,从而相应调整向量大小。对于确实无法预知输入元素数量的情境,存在更优的解决方案。
但在探讨该方案前,我们需要先作一个简短的旁注说明。
什么是栈?
来个类比!想象食堂里一摞餐盘。由于某种未知原因,这些盘子格外沉重,每次只能搬动一个。由于盘子堆叠且沉重,你只能通过两种方式修改这摞盘子:
在摞顶添加新盘子(若存在底层盘子则将其遮盖)
移除顶部餐盘(露出下方餐盘,若存在)
禁止在摞中或底部增减餐盘,因这将导致同时搬动多块餐盘。
摞中元素的增减顺序遵循后进先出last-in, first-out(LIFO)原则。最后添加的餐盘将最先被移除。
编程中的栈
在编程中,栈stack是一种容器数据类型,其元素的插入和移除遵循后进先出(LIFO)原则。这通常通过名为压入(push)和弹出(pop)的两个操作来实现:
| Operation Name | Behavior | Required? | Notes |
|---|---|---|---|
| Push | Put new element on top of stack | Yes | |
| Pop | Remove the top element from the stack | Yes | May return the removed element or void |
许多栈实现还可选地支持其他有用的操作:
| Operation Name | Behavior | Required? | Notes |
|---|---|---|---|
| Top or Peek | Get the top element on the stack | Optional | Does not remove item |
| Empty | Determine if stack has no elements | Optional | |
| Size | Count of how many elements are on the stack | Optional |
栈在编程中很常见。在第3.9节——使用集成调试器:调用栈中,我们讨论了调用栈,它记录了哪些函数已被调用。调用栈就是……一个栈!(我知道,这个揭示令人失望)。当函数被调用时,包含该函数信息的条目会被添加到调用栈顶部。函数返回时,包含该函数信息的条目会被从调用栈顶部移除。因此调用栈顶部始终代表当前正在执行的函数,而后续每个条目则代表先前执行的函数。
例如,以下简短代码片段展示了栈的压入与弹出机制:
(Stack: empty)
Push 1 (Stack: 1)
Push 2 (Stack: 1 2)
Push 3 (Stack: 1 2 3)
Pop (Stack: 1 2)
Push 4 (Stack: 1 2 4)
Pop (Stack: 1 2)
Pop (Stack: 1)
Pop (Stack: empty)
C++中的栈
在某些语言中,栈被实现为独立的容器类型(与其他容器分离)。然而这种设计存在显著局限性。试想我们需要在不修改栈的情况下打印所有元素,纯栈接口并不能提供直接实现方法。
在C++中,开发者选择将栈式操作作为成员函数添加到现有标准库容器类中(如支持高效单端插入/删除的std::vector、std::deque和std::list)。这使得这些容器在保留原生功能的同时,也能充当栈使用。
顺带一提……:
盘子堆叠的比喻虽好,但我们能构思出更贴切的类比,帮助理解如何用数组实现栈结构。与其想象一个可变容量的盘子堆,不如考虑一列层层叠放的邮箱。每个信箱只能容纳一件物品,且初始均为空。信箱底部用钉子固定在下方信箱上,信箱顶部覆盖着毒刺,因此无法在任意位置插入新信箱。
既然无法改变邮箱数量,如何实现栈的行为?
首先,我们用标记物(如便签纸)来记录栈顶位置——它始终是最低处的空邮箱。初始时栈为空,标记物置于底层邮箱。
当向邮箱栈压入物品时,将其放入标记邮箱(即最底层空邮箱),并将标记向上移动一个邮箱。当从栈中弹出项目时,将标记向下移动一个邮箱(使其指向顶层非空邮箱),并从该邮箱移除物品使其变为空邮箱。
标记下方的位置视为“栈内”,标记及标记上方的位置则不属于栈内。
现在,我们将标记称为长度,信箱数量称为容量...
本节后续内容将解析std::vector的栈接口工作原理,最后通过实例展示它如何帮助我们解决本节开头提出的挑战。
std::vector 的栈行为
std::vector 中的栈行为通过以下成员函数实现:
| Function Name | Stack Operation | Behavior | Notes |
|---|---|---|---|
| push_back() | Push | Put new element on top of stack | Adds the element to end of vector |
| pop_back() | Pop | Remove the top element from the stack | Returns void, removes element at end of vector |
| back() | Top or Peek | Get the top element on the stack | Does not remove item |
| emplace_back() | Push | Alternate form of push_back() that can be more efficient (see below) | Adds element to end of vector |
让我们看一个使用这些函数的示例:
#include <iostream>
#include <vector>
void printStack(const std::vector<int>& stack)
{
if (stack.empty()) // if stack.size == 0
std::cout << "Empty";
for (auto element : stack)
std::cout << element << ' ';
// \t is a tab character, to help align the text
std::cout << "\tCapacity: " << stack.capacity() << " Length " << stack.size() << "\n";
}
int main()
{
std::vector<int> stack{}; // empty stack
printStack(stack);
stack.push_back(1); // push_back() pushes an element on the stack
printStack(stack);
stack.push_back(2);
printStack(stack);
stack.push_back(3);
printStack(stack);
std::cout << "Top: " << stack.back() << '\n'; // back() returns the last element
stack.pop_back(); // pop_back() pops an element off the stack
printStack(stack);
stack.pop_back();
printStack(stack);
stack.pop_back();
printStack(stack);
return 0;
}
在 GCC 或 Clang 上,这会输出:

请记住,长度表示向量中的元素数量,在此情况下即栈中元素的数量。
与下标运算符 operator[] 或 at() 成员函数不同,push_back()(以及 emplace_back())会增加向量的长度,若容量不足以插入值,则会触发重新分配。
在上例中,向量经历了三次重新分配(容量从0增至1、1增至2、2增至4)。
关键要点:
push_back()和emplace_back()会增加std::vector的长度,若容量不足以容纳新值则会触发重新分配。
额外容量来自推入操作
在上面的输出中,请注意当最后一次重新分配发生时,容量从2跃升至4(尽管我们只推入了一个元素)。当推入操作触发重新分配时,std::vector通常会分配额外容量,以便后续添加元素时无需再次触发重新分配。
额外容量的大小取决于编译器对 std::vector 的实现,不同编译器通常有不同做法:
GCC 和 Clang 会将当前容量翻倍。当最后一次重新分配被触发时,容量从 2 变为 4。
Visual Studio 2022 将当前容量乘以1.5。当最后一次触发重新分配时,容量从2变为3。
因此,先前程序的输出结果可能因编译器不同而略有差异。
调整向量大小无法实现栈行为
重新分配向量会消耗大量计算资源(与向量长度成正比),因此在合理情况下应避免重新分配。在上例中,若在程序开始时手动将向量容量调整为3,即可避免向量被重新分配3次。
让我们看看将上述示例第18行改为以下内容会发生什么:
std::vector<int> stack(3); // parenthesis init to set vector's capacity to 3
现在当我们再次运行程序时,得到以下输出:

这不对——不知怎么的,栈顶突然冒出一堆0值!问题在于括号初始化(用于设定向量初始大小)和resize()函数同时设置了容量和长度。我们的向量初始容量为3(符合预期),但长度也被设为3。因此向量初始包含3个值为0的元素。后续压入的元素会被堆叠在这些初始元素之上。
当我们计划使用下标访问元素时(因有效索引需小于长度),resize()成员函数修改向量长度并无问题;但将其作为栈使用时就会引发问题。
我们真正需要的是在不改变长度(这会导致栈中新增元素)的前提下调整容量(以避免后续重新分配内存)。
reserve() 成员函数用于改变容量(但不改变长度)
reserve() 成员函数可用于重新分配 std::vector 的存储空间,同时保持当前长度不变。
以下是之前示例的变体,新增了 reserve() 调用以设置容量:
#include <iostream>
#include <vector>
void printStack(const std::vector<int>& stack)
{
if (stack.empty()) // if stack.size == 0
std::cout << "Empty";
for (auto element : stack)
std::cout << element << ' ';
// \t is a tab character, to help align the text
std::cout << "\tCapacity: " << stack.capacity() << " Length " << stack.size() << "\n";
}
int main()
{
std::vector<int> stack{};
printStack(stack);
stack.reserve(6); // reserve space for 6 elements (but do not change length)
printStack(stack);
stack.push_back(1);
printStack(stack);
stack.push_back(2);
printStack(stack);
stack.push_back(3);
printStack(stack);
std::cout << "Top: " << stack.back() << '\n';
stack.pop_back();
printStack(stack);
stack.pop_back();
printStack(stack);
stack.pop_back();
printStack(stack);
return 0;
}
在作者的机器上,这段代码输出:

你可以看到调用 reserve(6) 将容量改为 6,但未影响长度。由于 std::vector 足够容纳所有要插入的元素,因此不再发生重新分配。
关键要点:
resize() 成员函数会改变向量的长度,并在必要时调整容量。
reserve()成员函数仅调整容量(如有必要)
技巧:
增加std::vector元素数量的方法:
通过索引访问向量时使用resize()。这会改变向量长度,确保索引有效。
使用栈操作访问向量时使用reserve()。这仅增加容量而不改变向量长度。
push_back() vs emplace_back()
push_back() 和 emplace_back() 都会将元素压入栈中。若待压入的对象已存在,两者效果相同,此时应优先使用 push_back()。
但在需要创建临时对象(与向量元素同类型)以便将其推入向量时,emplace_back() 可能更高效:
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
class Foo
{
private:
std::string m_a{};
int m_b{};
public:
Foo(std::string_view a, int b)
: m_a { a }, m_b { b }
{}
explicit Foo(int b)
: m_a {}, m_b { b }
{};
};
int main()
{
std::vector<Foo> stack{};
// When we already have an object, push_back and emplace_back are similar in efficiency
Foo f{ "a", 2 };
stack.push_back(f); // prefer this one
stack.emplace_back(f);
// When we need to create a temporary object to push, emplace_back is more efficient
stack.push_back({ "a", 2 }); // creates a temporary object, and then copies it into the vector
stack.emplace_back("a", 2); // forwards the arguments so the object can be created directly in the vector (no copy made)
// push_back won't use explicit constructors, emplace_back will
stack.push_back({ 2 }); // compile error: Foo(int) is explicit
stack.emplace_back(2); // ok
return 0;
}

在上例中,我们有一个Foo对象的向量。通过push_back({ “a”, 2 })操作,我们创建并初始化了一个临时Foo对象,随后将其复制到向量中。对于复制成本较高的类型(如std::string),这种复制操作可能导致性能下降。
使用 emplace_back() 时,无需创建临时对象进行传递。相反,我们直接传递本用于创建临时对象的参数,emplace_back() 会通过完美转发机制将参数转发给向量,在向量内部完成对象的创建与初始化。这避免了原本需要执行的复制操作。
需注意:push_back()不会调用显式构造函数,而emplace_back()会。这使得emplace_back更具风险——更容易意外调用显式构造函数执行不合逻辑的转换操作。
在C++20之前,emplace_back()不支持聚合初始化。
最佳实践:
当需要创建临时对象添加至容器,或必须调用显式构造函数时,优先使用 emplace_back()。其他情况下优先使用 push_back()。
本文对此最佳实践有更详细的阐述。
使用栈操作应对挑战
现在应该很清楚如何应对本节开头提出的挑战了。如果事先不知道将向 std::vector 中添加多少个元素,使用栈函数来插入这些元素是最佳方案。
以下是一个示例:
#include <iostream>
#include <limits>
#include <vector>
int main()
{
std::vector<int> scoreList{};
while (true)
{
std::cout << "Enter a score (or -1 to finish): ";
int x{};
std::cin >> x;
if (!std::cin) // handle bad input
{
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
continue;
}
// If we're done, break out of loop
if (x == -1)
break;
// The user entered a valid element, so let's push it on the vector
scoreList.push_back(x);
}
std::cout << "Your list of scores: \n";
for (const auto& score : scoreList)
std::cout << score << ' ';
return 0;
}

该程序允许用户输入测试分数,并将每个分数添加到向量中。当用户完成分数输入后,向量中的所有值都会被打印出来。
请注意,在这个程序中,我们完全不需要进行计数、索引操作或处理数组长度!我们可以专注于实现程序所需的逻辑,而让向量自动处理所有存储问题!
测验时间
问题 #1
编写一个程序,能够压入和弹出值,并输出以下序列:
(Stack: empty)
Push 1 (Stack: 1)
Push 2 (Stack: 1 2)
Push 3 (Stack: 1 2 3)
Pop (Stack: 1 2)
Push 4 (Stack: 1 2 4)
Pop (Stack: 1 2)
Pop (Stack: 1)
Pop (Stack: empty)
显示答案
#include <iostream>
#include <vector>
void printStackValues(const std::vector<int>& v)
{
std::cout << "\t(Stack:";
for (auto e : v)
std::cout << ' ' << e;
if (v.empty()) // if v.size == 0
std::cout << " empty";
std::cout << ")\n";
}
void pushAndPrint(std::vector<int>& v, int val)
{
v.push_back(val);
std::cout << "Push " << val;
printStackValues(v);
}
void popAndPrint(std::vector<int>& v)
{
v.pop_back();
std::cout << "Pop ";
printStackValues(v);
}
int main()
{
std::vector<int> v {};
printStackValues(v);
pushAndPrint(v, 1);
pushAndPrint(v, 2);
pushAndPrint(v, 3);
popAndPrint(v);
pushAndPrint(v, 4);
popAndPrint(v);
popAndPrint(v);
popAndPrint(v);
return 0;
}


浙公网安备 33010602011771号