字符串、向量和数组

掌握 C++ 标准库中最常用的三种容器——string、vector、array,会声明、遍历、操作它们

学习目标

  • 能实现使用 stringvector 的创建、读写、拼接与遍历,并解释底层内存发生了什么
  • 能独立写出用迭代器和范围 for 遍历容器的代码,区分何时该用引用、何时该用拷贝
  • 能回答:vector<int> v; v[0] = 10; 这段代码出什么问题?把 vector 从空填到装满 1000 个数的过程中,底层动态内存发生了多少次重新分配?

为什么要学这三种「装数据的家伙」?

前面你已经会声明单个变量——int x = 5;double pi = 3.14;。但真实程序里很少只有一个数:你要存全班 50 个同学的成绩、要处理用户输入的一整行文字、要记住游戏里 100 个敌人的位置。一个变量装一个数据——装 100 个难道要写 100 个变量?不可能。

C++ 给出了三种专门「装一大堆东西」的工具,像工厂流水线上三种不同规格的箱子:第一种是专门装文字的箱子——灵活的字符串;第二种是能自动伸缩的箱子——想装多少就推多少进去的动态数组;第三种是规格写死但最朴素的箱子——C 语言传下来的固定大小数组。

这一章就教你打开这三种箱子:怎么往里放东西、怎么取出东西、怎么一个个遍历过去。没有这些箱子会怎样?你只能写一百行 int s0 = ...; int s1 = ...;,程序一改需求就得全盘重写——这在真实开发中根本走不通。

第一个箱子:专门装文字的 string

在 C 语言里,一段文字是这样存的:char name[] = "Alice";——栈上挤出 6 个格子,A、l、i、c、e、\0 排好。这六个格子写死了:你永远不能把 "Alice" 换成 "Alice Cooper"——格子的个数在出生时就定好了,改不了。

C++ 在标准库里给了你一个 类型——把它理解成「能自动伸缩的文字箱子」。你往里面倒 "Alice",它就装 "Alice";你接着倒 " Cooper",它就自己变长、把两段接起来。下面这张图把 C 的 char[]std::string 的底层差别画了出来:

C 风格 char s[] = "Hi";栈 (stack)'H''i'\0s[0]s[1]s[2]3 个字节在栈上连续排列\0 是字符串结束标记⚠ 大小固定,不能动态扩展⚠ s = "Hello World!" 放不下std::string s = "Hi";栈 (stack)指针data*大小2size容量2capacity堆 (heap)'H''i'\0✓ 堆上分配,大小可动态增长✓ s = "Hello World!" 完全没问题
C 风格数组(左)把字符连续排在栈上,大小写好就定死了。std::string(右)只在栈上存一根指针,实际数据躲在堆里——换一根更长的字符串,只要重新在堆上分配、把指针指过去就行,原来的栈上空间只占那三四个字段。

核心差异一目了然:C 的 char[] 所有字符挤在栈上,大小写死;std::string 只在栈上存一根指针加几个管理字段,(较长时)实际文字丢在里。(短字符串有些实现会直接内联存在对象自身里、不开堆,这叫小字符串优化 SSO;这里先按通用的「栈柄堆料」模型理解即可。)堆是程序运行时的一大片自由空地——堆上的东西大小可以随时变。所以 string 换长内容不需要你操心:自动在堆上找一块更大的空地、搬过去、老地方退还。这种「栈上是柄、堆里是料」的设计,让 string 用起来像变魔术,但魔术底下就是这么个简单的结构。

string 能做什么?看代码比看文字直观:

#include <string>
#include <iostream>
using std::string;
 
string s1 = "Hello";           // 从 C 字符串初始化
string s2("World");            // 另一种写法
string s3 = s1 + " " + s2;     // 拼接——s3 是 "Hello World"
bool eq = (s1 == s2);          // false——内容不同
bool lt = (s1 < s2);           // true——'H' < 'W',字典序比较
auto len = s3.size();          // 11——有多少个字符
char c = s3[0];                // 'H'——像数组一样按下标取字符

第二个箱子:能自动伸缩的 vector

你存全班 50 个成绩,用固定的语法写 50 个 int 变量是不可能的。你需要一个「格子串」——它得满足三件事:①能存很多个同类型的值;②不用提前知道到底存几个;③能把值轻松地塞进去、查出来、遍历一遍。官方这一节叫 Library vector Type(vector 类型);C++ 标准库给你准备了 这个容器。

把它理解成工厂流水线上一根「能自动变长的传送带」,同行只能放同一种零件:你推一个零件到带子末尾(push_back),如果带子满了它会自己接一段延长(扩容),你从任意位置取零件(v[i]),也能知道带子上现在有几个零件(v.size())。

push_back 过程中带子怎么自己变长,在 §5 Stepper 里会分步演示。先把 vector 的最基本语法看清楚:

#include <vector>
using std::vector;
 
vector<int> v1;              // 空 vector,size=0
vector<int> v2(5, 0);        // 5 个 0:{0, 0, 0, 0, 0}
vector<int> v3{10, 20, 30};  // 列表初始化:{10, 20, 30}
 
v1.push_back(42);            // 尾加 42
int n = v3[1];               // 下标访问:20
auto sz = v3.size();         // 3

下标 vs push_back——分清「改」和「加」

这两个操作很容易搞混,但它们做的事完全不同:

操作做什么改变 size?前置条件
v[i] = x把第 i 格的值换成 xi 必须 < v.size()
v.push_back(x)在末尾新开一格,放入 x+1无(总能加)

如果你在一个空的 vector 上写 v[0] = 10;,程序不会报错但已经发生了未定义行为——第 0 格根本不存在,你在往一个不存在的格子里写东西。结果可能是立刻崩溃,也可能是悄悄写坏了隔壁内存(更难排查)。所以铁律:加元素用 push_back,改已有元素用下标。 如果不确定,先用 push_back 把元素加上去再说。

第三个工具:帮你指着格子的——迭代器

你已经会用下标 v[i] 访问 vector 里的元素了。但下标不是万能的——很多高级容器(如 map、set、list)根本不支持 [i] 这种按下标寻址的方式,它们的数据不是连续排列的。C++ 想出了一个通用的概念:——把它理解成一根可以移动的指针,专门帮你「指着」容器里某个格子,还知道怎么移到下一格。

每个容器都提供两个关键迭代器:

  • v.begin()——返回一根指着第一个元素的迭代器
  • v.end()——返回一根指着最后一个元素之后位置的迭代器(叫,是一个哨兵标记,本身不指向任何真正存在的数据)
vector<int> v = {10, 42, 99, 7};10[v[0]]42[v[1]]99[v[2]]7[v[3]]v.begin()指向第一个元素v.end()指向尾后(不是最后一个元素!)end() 不指向任何元素——它标记容器末尾边界,遍历时遇到 end() 就停下
迭代器像一根「指向容器元素的指针」:begin() 指向第一个元素,end() 指向最后一个元素**之后**的位置(哨兵,不存数据)。遍历时只要迭代器不等于 end(),就说明还有元素。

用迭代器遍历 vector 的写法是这样的:

for (auto it = v.begin(); it != v.end(); ++it) {
    cout << *it << ' ';   // *it 拿到 it 当前指着的那个元素的值
}

这个循环里 auto it 让编译器自己推导 it 的类型,*it解引用——像指针的 *ptr 一样拿到被指着的东西。++it 把迭代器往前挪一格。当 it == v.end() 碰上尾后哨兵,循环结束。

第四个工具:C 语言传下来的——数组

这个你可能已经认识了——int scores[5] = {90, 85, 92, 78, 88};。这就是 C 风格的。它的特点是:大小是类型的一部分——int[5]int[10] 是不同的类型。一旦声明了 5 个格子,就永远只能装 5 个。

数组最坑人的一条特性叫指针退化:把数组当参数传给函数时,它悄悄退化成了一根指向首元素的指针——大小信息全部丢失

void print_size(int arr[10]) {
    cout << sizeof(arr);  // 输出 8(指针大小!不是 40)
}

所以老手常说:新代码优先用 vector;只在两种情况下用原生数组——跟 C 语言写的旧接口打交道时、或者在编译器能帮你把一切优化到极致的极端性能场景。初学阶段默认用 vector 就够了。

using 声明与多维数组

官方 Chapter 3 还把 namespace using declarations 放在最前面,并以 multidimensional arrays 收尾。这两块分别规定“名字怎样进入作用域”和“数组类型怎样嵌套”,不能被 string/vector 示例替代。

应优先写成 using std::string; using std::vector;。头文件通常不写全局 using,否则每个包含者都被迫接收这些名字,容易与自己的类型或函数冲突。源文件中也应把声明放在最小合理作用域。

例如 int matrix[3][4],其类型是“含 3 个元素的数组,每个元素又是含 4 个 int 的数组”。范围 for 若要保留内层数组类型,外层变量必须用引用:

int matrix[2][3]{{1, 2, 3}, {4, 5, 6}};
for (auto& row : matrix) {
    for (auto value : row) std::cout << value << ' ';
}
 
using Row = int[3];
Row* firstRow = matrix; // 指向含 3 个 int 的数组

省略外层 & 会让 row 发生 array-to-pointer conversion,范围 for 就失去内层数组边界。复杂声明可用 type alias 简化,但 alias 不改变实际连续布局和下标越界风险。

第五个工具:范围 for——C++ 给你最省心的遍历

如果每次遍历都要写一长串 for (auto it = v.begin(); it != v.end(); ++it) ,那也太啰嗦了。C++11 引入了——把它理解成「你只管说对谁、做什么,C++ 帮你把走到每格的事情全包了」。

对比一下三种遍历方式:

vector<int> v{10, 20, 30, 40};
 
// 方式一:下标遍历(老写法,有越界风险)
for (int i = 0; i < v.size(); ++i)
    cout << v[i] << ' ';
 
// 方式二:迭代器遍历(通用但啰嗦)
for (auto it = v.begin(); it != v.end(); ++it)
    cout << *it << ' ';
 
// 方式三:范围 for(最简洁,推荐)
for (auto x : v)
    cout << x << ' ';

三行 for (auto x : v) 达成同样的效果。如果你要修改容器里的元素,记得写引用 auto &x : v——不加 & 的话 x 只是原元素的副本,你怎么改副本,原容器纹丝不动。

动手:一步一步看 vector 怎么长大

你已经看到 push_back 能让 vector 变大——但底层到底发生了什么?为什么大家都说「扩容很贵」?下面这个分步演示,把一个 vector<int> v; 从空的初始状态开始,push_back 三次,每切一步,上方图示会高亮当前内存布局——哪些格子里有你的数据,哪些是预留的空位

猜一猜:push_back(3) 这一步,capacity 会变成 3 还是 4?翻到这个 step 看看你是对是错,再回去看原因。

分步1 / 4

① 初始状态:空 vector

刚创建的 vector<int> v; 还没分配任何空间——size = 0capacity = 0。此刻 v[0] 不存在,你不能用下标读写任何东西。就像一个还没接电的传送带——带子都还没铺出来。

代码逐段拆解:把所有工具串起来

这一节把本章五个工具(string、vector、迭代器、范围 for、数组)的核心用法逐段展示——每段一个主题,看完就能上手。

string 的日常操作

下面这段代码覆盖了 string 最常用的操作:初始化、读取输入、拼接、比较、取子串、遍历每个字符。

#include <iostream>
#include <string>
using std::string; using std::cout; using std::cin;
 
int main() {
    string name;
    cout << "你叫什么名字?";
    getline(cin, name);           // 读一整行(含空格)
 
    string greeting = "你好," + name;   // 拼接
    cout << greeting << '\n';
 
    if (name == "Alice")                 // 比较内容
        cout << "欢迎回来!\n";
 
    cout << "你的名字有 " << name.size() << " 个字符\n";
 
    for (auto c : name)                  // 范围 for 逐字符
        cout << '[' << c << "] ";        // [A] [l] [i] [c] [e]
    return 0;
}

要点:getline 读一行不打断空格(cin >> name 只会读到第一个空格前)。+ 拼接。.size() 返回字符数(不含 \0)。范围 for 一个字符一个字符地走。

vector 的增删改查

#include <vector>
#include <iostream>
using std::vector; using std::cout;
 
int main() {
    vector<int> scores;               // 空的,size=0
 
    scores.push_back(88);             // 尾加
    scores.push_back(95);
    scores.push_back(73);
 
    cout << "共有 " << scores.size() << " 个成绩\n";   // 3
 
    scores[0] = 90;                   // 把第一个改成 90
 
    int sum = 0;
    for (auto s : scores)             // 遍历求和
        sum += s;
 
    cout << "平均分:" << static_cast<double>(sum) / scores.size() << '\n';
    return 0;
}

这段展示了 vector 的标准生命周期:空箱创建 → push_back 填数据 → 下标修改 → 范围 for 遍历计算。static_cast<double> 是为了做浮点除法(两个 int 相除会丢掉小数)。

用迭代器完成一次「查找并替换」

// 在 vector 中找到第一个 99,把它改成 100
vector<int> v{10, 20, 99, 30, 40};
 
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it == 99) {           // *it 拿到当前元素的值
        *it = 100;             // 通过迭代器修改原元素
        break;                 // 只改第一个
    }
}
// 现在 v 是 {10, 20, 100, 30, 40}

迭代器 it 在循环里从头走到尾,*it 用来取值或改写值。++it 移至下一格。这种写法虽然比范围 for 啰嗦,但能更精细地控制——比如「只处理前三个元素」、「跳过第一个再开始」这种定制需求。而且它也是后面学习 map、set 等容器的统一入口。

数组的注意事项

int arr[5] = {1, 2, 3, 4, 5};      // 大小 = 5,写死了
cout << arr[0];                       // 1——和 vector 一样用 []
 
// 遍历:需要自己记住大小
for (int i = 0; i < 5; i++)          // 5 是自己记的!
    cout << arr[i] << ' ';
 
// 数组也可以范围 for(定义作用域内编译器知道大小)
for (auto x : arr)
    cout << x << ' ';                 // 1 2 3 4 5

但数组传给函数会退化——C 语言的历史包袱。如果你把 arr 传给一个函数处理,记得同时传大小void process(int arr[], int size)。vector 就没这个烦恼——.size() 走到哪都可用。

容易踩的坑

小结

  • string 是能自动伸缩的文字箱——支持 + 拼接、== 比较、.size() 查长度、getline 读整行,底层用「栈上指针 + 堆上数据」实现
  • vector 是能长大的数组——用 push_back 尾加、[i] 访问、.size() 查元素数;扩容按翻倍策略,均摊 O(1)
  • 迭代器 begin() 指向首元、end() 指向尾后(哨兵)——*it 取值、++it 前进,是所有容器统一的访问语言
  • 范围 for for (auto &x : v) 是最简洁的遍历方式——要修改原元素必加 &,否则只改副本
  • C 数组大小是类型的一部分且不可变,传参会退化成指针——新代码首选 vector,只在跟 C 接口交互时用原生数组

练习

问题 1(改代码型) 下面这段代码有四处错误。找出它们并解释每处会出什么错,然后给出修正后的完整代码。

#include <iostream>
#include <vector>
int main() {
    vector<int> v;
    v[0] = 100;
    for (int i = 0; i <= v.size(); i++)
        cout << v[i] << endl;
    return 0;
}

问题 2(独立实现题) 写一个函数 word_stats,接收一段文字(const string &text),返回一个 vector<string>,里面按文字中出现的顺序存放所有的英文单词(连续字母,遇到任何非字母字符——空格、标点、加号、撇号等——就切断当前单词)。单词全部转成大写。写完后用一个 "Hello, C++ World! Let's learn." 测试,输出应为 ["HELLO", "C", "WORLD", "LET", "S", "LEARN"](注意 C++ 因为 + 不是字母而被切成单独的 CLet's 因为撇号被切成 LETS)。

提示:用 isalpha() 判断一个字符是否是英文字母,用 toupper() 转大写。它们需要 #include <cctype>

问题 3(问答型) 在 §5 的 Stepper 里,push_back(3) 触发扩容时 capacity 变成了 4 而不是 3。为什么要多预留那一个格子?如果把扩容策略从「翻倍」改成「每次加 1」,把 1000 个数挨个 push_back 进一个空 vector,总共会发生多少次重新分配内存?翻倍策略下大约是多少次?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

string

C++ 标准库里的字符串类型(#include <string>)。能动态改变长度,想多长就多长。支持用 + 拼起来、用 == 比较内容是否一样、用 .size() 看有几个字符、用 getline(cin, s) 读一整行文字。底层实现:栈上存一根指针,真正的字符存在堆上。详见本章「第一个箱子」一节和 StringMemoryDiagram 图示。

vector

C++ 标准库里的动态数组(#include <vector>)。元素在内存里一排紧挨着放(和 C 数组一样),但大小可以在运行时用 push_back 动态增大——满了自动找更大的内存块搬过去,每次翻倍扩容让均摊开销接近常数时间。.size() 查有多少元素,[i] 像数组一样取值。详见本章「第二个箱子」一节和 §5 Stepper 演示。

迭代器(iterator)

一种「像个指针」的对象,指着一个容器中某个元素的位置,并且知道怎么走到下一个(++it)。用 *it 拿到当前元素的值。所有 C++ 标准容器都提供 begin()(指向第一个元素)和 end()(哨兵——指向最后一个元素之后的位置)。详见「第三个工具」一节和 IteratorDiagram 图示。

尾后迭代器(off-the-end iterator)

v.end() 返回的那个迭代器。它不指向任何一个真的元素,而是最后一个元素之后那一格(不存在的位置)。它的作用是当「停止信号」——遍历时碰到它就知道走完了。对它解引用 *v.end() 是未定义行为。详见「第三个工具」一节和 IteratorDiagram 中黄色箭头。

数组(array)

C/C++ 里最基础的数据集合:int arr[5]。大小在编译时就写死了,运行时不能改变。元素连续排布,用 arr[i] 下标访问。传给函数时会退化成指针,丢失大小信息。新代码里能用 vector 时优先用 vector。详见「第四个工具」一节和 ArrayVsVectorDiagram 图示。

范围 for 循环(range-based for loop)

C++11 引入的简写遍历语法:for (auto x : container),自动从第一个元素走到最后一个。auto x 每次拿出一个元素的拷贝;改成 auto &x 就是引用——可以直接修改原值。详见「第五个工具」一节。

size_type

容器内部定义的一种无符号整数类型,专门用来表示元素个数和下标。vector<int>::size_type 就是 vector 用来存 size 的类型。用 int 存 size 不够准确——size 不可能为负,而且在不同平台上 int 的位数可能不够。size_type 保证在任何平台上都够用。

push_back

vector 的成员函数,在末尾添加一个元素。每次 push_back 之后 size 加 1。如果 capacity 不够了,vector 会自动扩容(分配更大内存、拷贝旧元素、释放旧内存)。详见 §5 的 Stepper 演示和 VectorGrowthDiagram 图示。

指针退化(pointer decay)

C 风格数组作为函数参数时,悄悄从「数组」变成「指向首元素的指针」的行为。void f(int arr[10]) 里的 arr 实际上是一根 int* 指针,sizeof(arr) 返回指针大小(8 字节)而不是数组大小(40 字节)。这是推荐用 vector 替代原生数组的重要原因之一。详见「第四个工具」一节。

using 声明

把某个命名空间中的单一名字引入当前作用域,如 using std::string;;它不会导入命名空间内所有名字,头文件尤其应避免全局 using 指令。

多维数组

元素仍是数组的嵌套数组类型,如 int[2][3];各维长度属于类型,内存按行连续,范围 for 的外层变量需用引用保留数组类型。

资料与写作方式声明

本章以C++ Primer, Fifth Edition, Chapter 3: Strings, Vectors, and Arrays权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…