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 上,这会输出:

image

请记住,长度表示向量中的元素数量,在此情况下即栈中元素的数量。

与下标运算符 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

现在当我们再次运行程序时,得到以下输出:

image

这不对——不知怎么的,栈顶突然冒出一堆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;
}

在作者的机器上,这段代码输出:

image

你可以看到调用 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;
}

image

在上例中,我们有一个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;
}

image

该程序允许用户输入测试分数,并将每个分数添加到向量中。当用户完成分数输入后,向量中的所有值都会被打印出来。

请注意,在这个程序中,我们完全不需要进行计数、索引操作或处理数组长度!我们可以专注于实现程序所需的逻辑,而让向量自动处理所有存储问题!


测验时间

问题 #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;
}

image

posted @ 2026-01-08 09:29  游翔  阅读(36)  评论(0)    收藏  举报