Skip to content

🧵09. String 字符串

0. char 的内存模型

在学 string 之前,我们先搞清楚一个问题:计算机里到底怎么存一段字符串?

0.1 内存就是一排带编号的柜子

可以把内存想象成一排带编号的格子,每个格子可以放一个字节(8 bit)的数据:

内存地址:  1000  1001  1002  1003  1004  1005  1006
数据:     [    ][    ][    ][    ][    ][    ][    ]

我们之前学的 char 类型占 1 个字节,所以一个 char 变量就是占了其中一个格子。

0.2 char 数组就是连续占了一排格子

cpp
char s[4] = {'A', 'B', 'C', 'D'};

它在内存中长这样:

地址:   1000    1001    1002    1003
       ┌──────┬──────┬──────┬──────┐
数据:  │ 'A'  │ 'B'  │ 'C'  │ 'D'  │
       └──────┴──────┴──────┴──────┘
         s[0]    s[1]    s[2]    s[3]

每个格子存一个字符,连续排列,通过下标访问。

0.3 核心问题:字符串到哪结束?

现在假设我们有一个 char 数组,只存了 3 个有效字符:

cpp
char s[5] = {'H', 'e', 'y'};  // 只填了前 3 个

内存里实际是这样:

地址:   2000    2001    2002    2003    2004
       ┌──────┬──────┬──────┬──────┬──────┐
数据:  │ 'H'  │ 'e'  │ 'y'  │ ???  │ ???  │
       └──────┴──────┴──────┴──────┴──────┘

像这样,如果我有一个函数要遍历这个字符串(比如打印它),它怎么知道该在第几个格子停下?

  • 它不知道数组长度(数组传参时会退化成指针,长度信息丢失)
  • 它不能看格子有没有值——因为没被初始化的格子里可能是随机垃圾数据

0.4 \0 就是字符串的"句号"

为了解决这个问题,C 语言约定了一个规则:所有字符串末尾必须加一个 \0(空字符,ASCII 码为 0),作为结束标记。

cpp
char s[6] = {'H', 'e', 'l', 'l', 'o', '\0'};

内存长这样:

地址:   3000    3001    3002    3003    3004    3005
       ┌──────┬──────┬──────┬──────┬──────┬──────┐
数据:  │ 'H'  │ 'e'  │ 'l'  │ 'l'  │ 'o'  │ '\0' │
       └──────┴──────┴──────┴──────┴──────┴──────┘
                                           ↑ 结束标记!

coutstrlen() 这类函数遍历字符串时,它们会一直走,直到碰到 \0 才停下来

cpp
cout << s;   // 从 s[0] 开始逐个输出,遇到 s[5] 的 '\0' 停下 → "Hello"

0.5 忘记 \0 会怎样?

cpp
char bad[5] = {'H', 'e', 'l', 'l', 'o'};  // 忘记加 '\0'
cout << bad;  // 危险!会一直读下去,直到在内存中碰巧遇到一个 0
内存:  4000    4001    4002    4003    4004    4005    4006    4007 ...
       ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
       │ 'H'  │ 'e'  │ 'l'  │ 'l'  │ 'o'  │ ?? │ ?? │ '\0' │
       └──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
                                           ↑ 越界了!读到垃圾数据

这就是所谓的缓冲区溢出——程序读了不该读的内存,轻则打印乱码,重则崩溃。

所以写 C 风格的字符串,永远记得给 \0 留一个位置char s[6] = "Hello"; 虽然只写了 5 个字母,但实际占了 6 个格子,最后一个就是编译器自动加的 \0

0.6 char 数组的日常痛点

了解内存模型后,你就能理解为什么用 char[] 处理字符串这么麻烦了:

操作char[] 的写法槽点
复制strcpy(dest, src)要确保 dest 空间够
拼接strcat(dest, src)同样要担心空间,还得先算长度
比较strcmp(a, b) == 0不能用==,否则比的不是内容而是地址
获取长度strlen(s)每次遍历一遍,O(n)
越界风险没有保护机制忘加\0 就崩

正因如此,C++ 的设计师才给我们准备了一个更好用的东西——string 类型

1. 什么是 string

string 是 C++ 标准库提供的一种字符串类型,它位于 <string> 头文件中。你可以把它理解成一个"升级版的字符串",比 C 风格的 char[]使用起来更加方便 。

cpp
#include <string>   // 使用 string 需要包含这个头文件

string 到底是什么?

细心的你可能已经发现了——string 和我们之前学的 intfloat 不太一样。

cpp
int a = 5;
// a.          ← 点开后什么也没有

string s = "Hi";
// s.          ← 点开后会看到一大堆功能

因为 string 不是基本数据类型,而是一个"对象"

对象可以简单理解为一个"有智慧的变量",它里面不仅存了数据,还自带了操作这些数据的方法。

比如 s.length() 就是在调用 string 内部自带的"计算长度"的函数。

那为什么 + 可以直接拼接字符串呢?因为 string 内部重新定义了 + 的含义——这在 C++ 里叫运算符重载。简单说就是告诉编译器:"当两个 string 用 + 连接时,把内容拼在一起"。

NOTE

这些概念(类、对象、成员函数、运算符重载)后面学到"类和对象"时会详细讲解。现在你只需要记住:string 是一种"高级类型",它自带了很多好用的功能。 这也是 C++ 比 C 更"现代化"的地方之一。

1.1 定义和初始化

cpp
string s1;                // 空字符串
string s2 = "Hello";      // 直接用字符串字面量初始化
string s3 = s2;           // 用另一个 string 初始化
string s4(5, 'A');        // 结果为 "AAAAA"

对比 char[]:定义 string 不需要指定长度,不用担心空间不够。

1.2 输入输出

cpp
string name;
cin >> name;              // 输入:Arin,遇到空格会停
getline(cin, name);       // 输入一整行,包括空格
cout << name << endl;     // 输出

NOTE

̀getline(cin, s) 可以读取包含空格的整行文本,而 cin >> s 遇到空格或换行就会停下。

2. string 的优点

2.1 不用管 \0

还记得字符数组必须用 \0 结尾吗?string 完全不需要,它内部自动管理结束标志。

cpp
char old[4] = {'A', 'B', 'C', '\0'};  // 忘了 \0 就出 bug
string s = "ABC";                      // 简单、安全

2.2 直接用 + 拼接

cpp
string a = "Hello";
string b = " World";
string c = a + b;            // c = "Hello World"
c += "!";                    // c = "Hello World!"

char[] 拼接?得先算长度、调 strcat、还得保证空间够……想想就头痛。

2.3 直接比较

cpp
string a = "apple", b = "banana";
if (a < b) cout << "apple 排在前面";   // 按字典序比较,没问题

char[] 不能直接用 < > 比较(会比较地址而不是内容),得调 strcmp

2.4 自动管理内存

string 会自动扩容,不用像 char[] 那样担心数组越界。

cpp
string s = "Hi";
s = "This is a much longer string...";  // 自动扩容,没问题!

2.5 丰富的成员函数

获取长度

cpp
string s = "Hello";
cout << s.length();   // 5
cout << s.size();     // 也是 5,和 length() 一样

查找子串

cpp
string s = "I love C++";
int pos = s.find("love");     // pos = 2(从 0 开始计数)
if (pos != string::npos) {    // string::npos 表示"没找到"
    cout << "找到了,位置在:" << pos;
}

提取子串

cpp
string s = "Hello World";
string sub = s.substr(6, 5);   // 从下标 6 开始取 5 个字符 → "World"

转换为 C 风格字符串

cpp
string s = "Hello";
const char* old = s.c_str();   // 转成 const char*,给需要 char[] 的函数用

3. 常用操作速查表

操作写法说明
定义string s;空字符串
拼接s1 + s2s += "xxx"直接相加
长度s.length()s.size()返回字符数
访问字符s[i]像数组一样用下标,从 0 开始
查找s.find("xx")返回位置,没找到返回string::npos
取子串s.substr(pos, len)从 pos 取 len 个字符
转 C 风格s.c_str()用于兼容需要char* 的场景
输入一行getline(cin, s)读一整行,包括空格
判空s.empty()空返回 true,否则 false

TIP

string 的下标和数组一样从 0 开始:s[0] 是第一个字符。

4. 例题:答题卡填涂

这是CPP二级考级的一道真题,如果用 char[] 来处理会非常麻烦,但用 string 就轻松很多。

题目描述

新浪微博上有网友发文称:"朋友买了本玻尔×海森堡的物理大佬同人本,送了300道高数题。更绝的是,要做完题目按照答案涂答题卡,涂出一个二维码,扫描二维码才能看到特典……"

现在请你根据答案帮助读者填写答题卡。

规则:

  • 二维码是一个 n × n 的网格,左下角坐标 (1, 1),右上角坐标 (n, n)
  • 每个答案对应一个格子位置
  • 答案可能是以下三种格式之一:
    • 整数(不超过 4 位):补足 4 位后,前两位 = 横坐标,后两位 = 纵坐标
    • 两小问 答案1;答案2:第一问答案 = 横坐标,第二问答案 = 纵坐标
    • 分数 分子/分母:分子 = 横坐标,分母 = 纵坐标

输入格式:

n m
答案1
答案2
...
答案m

输出格式: 输出一个 n × n 的网格,空格用 . 表示,涂了答案的黑格用 # 表示。

样例输入:

5 3
3
2;3
1/4

样例输出:

.....
..#..
..#..
.....
.#...

思路分析

这个题的关键在于解析不同格式的答案字符串,提取出坐标。用 string 来处理简直不要太合适:

  1. 读入一行答案 → 用 s.find(";") 判断是否包含 ;(两小问格式)
  2. s.find("/") 判断是否包含 /(分数格式)
  3. 都不包含 → 整数格式,补零到 4 位
  4. s.substr() 提取各部分
  5. stoi() 把字符串转为整数

如果用 C 风格的 char[]strtok 来做……代码会复杂得多,而且容易出错。

参考代码

cpp
#include <iostream>
#include <string>
using namespace std;

int main() {
    int n, m;
    cin >> n >> m;
  
    // 初始化网格,全部为 '.'
    string grid[100];            // 因为 n ≤ 90,开 100 够用
    for (int i = 0; i < n; i++) {
        grid[i] = string(n, '.');  // 用 string 构造 n 个 '.'
    }
  
    string ans;
    getline(cin, ans);  // 吃掉上一行的换行
  
    for (int i = 0; i < m; i++) {
        getline(cin, ans);      // 读入一整行答案
    
        int x, y;
    
        // 判断是不是两小问格式(包含 ;)
        if (ans.find(';') != string::npos) {
            int pos = ans.find(';');
            string sx = ans.substr(0, pos);          // 分号前的部分
            string sy = ans.substr(pos + 1);         // 分号后的部分
            x = stoi(sx);
            y = stoi(sy);
        }
        // 判断是不是分数格式(包含 /)
        else if (ans.find('/') != string::npos) {
            int pos = ans.find('/');
            string sx = ans.substr(0, pos);          // 分子
            string sy = ans.substr(pos + 1);         // 分母
            x = stoi(sx);
            y = stoi(sy);
        }
        // 否则是整数格式
        else {
            // 补足 4 位:前面补 0
            while (ans.length() < 4) {
                ans = "0" + ans;     // string 可以直接用 + 拼接!
            }
            string sx = ans.substr(0, 2);   // 前两位 = 横坐标
            string sy = ans.substr(2, 2);   // 后两位 = 纵坐标
            x = stoi(sx);
            y = stoi(sy);
        }
    
        // 填涂格子(注意坐标系转换:左下角是 (1,1))
        grid[n - y][x - 1] = '#';
    }
  
    // 输出网格
    for (int i = 0; i < n; i++) {
        cout << grid[i] << endl;
    }
  
    return 0;
}

代码亮点

看看这段代码里用了哪些 string 的绝活:

用法作用
string(n, '.')快速构造 n 个. 的字符串
ans.find(';')查找分隔符位置
ans.substr(0, pos)截取分号前的部分
"0" + ans+ 直接在字符串前面补零
stoi(s)把字符串转为整数
grid[i] = ...string 可以直接赋值,不用 strcpy

TIP

stoi() 是 "string to int" 的缩写,它能把 "123" 这样的字符串变成整数 123。使用时需要包含 <string> 头文件。

5. 总结

对比项char[](C 风格)string(C++ 风格)
需要\0✅ 是的❌ 不需要
拼接strcat(),麻烦++=,直接
比较strcmp()< > == 直接比
赋值strcpy()= 直接赋值
获取长度strlen().length()
自动扩容
安全性容易越界安全

NOTE

string 用起来这么方便,其实是因为它底层是一个类(class),封装了很多功能。学了"类和对象"之后,你就能理解它为什么这么好用了——到时候你也可以自己写一个像 string 一样好用的类型!

从这节课开始,我们写 C++ 程序处理字符串,就优先用 string 吧!🚀

💬 与我联系 QQ:774165314 | 微信:ArinRiver