Lecture 1: C++ 基础回顾与 STL 容器入门(C++ Fundamentals & STL Containers:Syntax, Functions, std::string, Vector & Grid, Testing)(对应课程真实讲座 L01–L04)
Lecture 1: C++ 基础回顾与 STL 容器入门(C++ Fundamentals & STL Containers:Syntax, Functions, std::string, Vector & Grid, Testing)(对应课程真实讲座 L01–L04)
概述
本讲是 CS106B 的“开机课”:把大家从 CS106A 的 Python 世界平稳地接进 C++,先讲清一门语言的两副面孔——语法与语义——再讲程序从源码到运行的全过程,然后系统复习变量、循环、分支、函数、字符串等基础构件,最后引入第一批“抽象数据类型(Abstract Data Type,ADT)”容器 Vector 与 Grid,并介绍函数分解与单元测试的思想。全讲为后续栈、队列、集合、映射等容器铺好语言地基。 对应官方 L01(6/22,Welcome!)、L02(6/23,C++ Fundamentals)、L03(6/24,C++ Strings)、L04(6/25,Testing, Vectors, and Grids)四讲。
核心概念与算法原理
1. 语法 vs 语义、编译与执行
问题定义:新手学 C++ 常把“编译器报不报错”和“程序对不对”混为一谈,需要先分清两个层面。 直观解释:语法(syntax)是“怎么说才合规矩”,语义(semantics)是“这句话到底什么意思”。就像中文句子“把书放上书架”语法正确,但语义上我们明白说的是“放上书架的那本书”,而不是“放一个书架上坐着的人”。官方在第 2 讲正是用这种自然语言的例子引入这对术语。 步骤分解:写代码时先满足语法(编译器才肯放行),再保证语义(程序才做对的事)。语法错误在编译期被揪出来;语义错误往往要等到运行期甚至悄悄潜伏。
问题定义:C++ 源码是如何变成可运行程序的? 直观解释:编译器(compiler)是一个“翻译程序”:把整份 C++ 源码一次性翻译成机器能执行的指令;这与 Python 的解释器(interpreter)逐行边翻译边执行的方式不同(官方第 2 讲对比过这一点)。
hello.cpp(源码,人写的)
│ ① 预处理:展开 #include
▼
│ ② 编译(compiler):逐行检查语法,翻译成机器指令
▼
可执行文件(机器指令,0 和 1)
│ ③ 操作系统加载它,并从 main() 开始执行
▼
屏幕输出:Hello, world!
操作要点:编译期错误(compile-time error,多为语法/类型问题)在点“运行”之前就会被报告;运行期错误(runtime error,如越界崩溃)则发生在程序真正跑起来之后。
2. main、注释、#include、命名空间、cout/endl
- 问题定义:每个 C++ 程序都需要一个统一的“入口”和一套“打招呼”的仪式。
- 直观解释:
main()是唯一特殊的函数——程序一启动就自动调用它,无需任何人手动调用;一个没有main()的程序无法通过编译(官方第 2 讲强调“main is special”)。main()最后return 0;表示“零错误”,这个值会交还给操作系统。 - 步骤分解:
#include <库名>(标准库用尖括号)把别人写好的库“搬”进来;using namespace std;让我们不必在每个标准库名字前敲std::;cout << 内容 << endl;向屏幕输出,endl负责换行。注释分两种://单行注释与/* ... */块注释,注释是写给人的,编译器完全忽略。 - 一句话注明:课程官方在 L01/L02 使用
#include "console.h"弹出课程专用终端,其输出机制等价于标准库<iostream>的std::cout。
3. 变量与数据类型、未初始化陷阱
问题定义:C++ 是强类型语言——每个变量必须先声明、带一个固定类型,之后不能改类型。 直观解释:变量像贴了标签的储物盒,标签(类型)决定盒子里能放什么形状的东西。常用类型:int(整数)、double(浮点数)、char(单字符,单引号)、bool(真假)、string(字符串,双引号)。 步骤分解:int a = 5; 声明并初始化;之后改值只需 a = 7; 而不能再写 int a = 7;(那是重复声明,报错)。 关键陷阱(官方第 2 讲专门点名):基本类型变量若不初始化,里面装的是“垃圾值”(garbage)——C++ 不会自动帮你清零;唯一的例外是 string,默认自动初始化为空串 ""。大多数编译器会放行未初始化代码,但结果是不可预测的,务必养成“声明即初始化”的习惯。
4. 循环:while / for / range-for
问题定义:如何反复执行同一段逻辑? 步骤分解:
while (条件) { ... }:先判断再执行,条件为假就退出;记得在循环体里推进条件,否则死循环。for (int i = 0; i < n; i++) { ... }:把“初始化、条件、步进”收拢到一行,适合“按次数/按下标”遍历。i++是i = i + 1的简写。- range-based for(又称 for-each):
for (char ch : s) { ... }直接“逐个掏出元素”,无需下标。适合 vector、string、set 等容器;代价是拿不到当前下标。
5. 分支与布尔逻辑、短路求值
问题定义:如何让程序“看情况行事”? 步骤分解:if (条件) ... else if (条件) ... else ...。比较运算符 == != < <= > >=;布尔运算符 &&(且)、\|\|(或)、!(非)。 机制要点(短路 short-circuiting):&& 左侧为假时右侧根本不会执行;\|\| 左侧为真时右侧也不会执行。这个特性常被用来“先安检再动手”,例如先检查下标合法再访问数组:if (i >= 0 && i < v.size() && v[i] > 0)——若 i 越界,后面的 v[i] 压根不会被访问,从而避免崩溃。官方第 2 讲还提醒过常见笔误 if (numCupcakes == 1 \|\| 2),这里的 2 恒为真,条件永远成立。
6. 函数:原型、传值 vs 传引用
问题定义:把一段逻辑命名并复用,同时明确它“吃什么(参数)、吐什么(返回值)”。 直观解释:C++ 编译器从上往下逐行看代码,所以调用一个“还没见过”的函数会报错。两种解法(官方第 3 讲):把函数定义挪到 main() 之前;或在其上方放一个函数原型(prototype)——即“函数签名 + 分号”,例如 int square(int x);,相当于提前告诉编译器“这个函数长什么样,具体实现稍后见”。 关键区分(传值 vs 传引用):默认是传值(pass-by-value)——调用时把实参拷贝一份给形参,函数里改的是副本,外面的变量毫发无损;若形参写作 int& n(传引用,pass-by-reference),形参就成了指向实参的“虫洞/传送门”,函数里动它等于直接动调用者的变量。传引用还有第二个动机:容器可能很大,逐元素拷贝既费时又费内存,引用只花几十比特建立“纽带”。官方第 3 讲用“倒空海盗宝藏”的比喻演示了两种传参的天壤之别(传值清空不了原宝藏,传引用可以)。
| 对比维度 | 传值 pass-by-value | 传引用 pass-by-reference |
|---|---|---|
| 形参写法 | void f(int n) | void f(int& n) |
| 是否拷贝数据 | 是,生成独立副本 | 否,形参是实参的别名 |
| 函数内修改 | 只改副本,实参不变 | 直接改实参 |
| 适用场景 | 小数据、不希望改动实参 | 要“带出”多个结果、大容器省拷贝 |
| 时空开销 | 拷贝 O(n) | 建立引用 O(1) |
内存示意(左侧传值、右侧传引用):
传值: 传引用:
main() 的 n [ 3 ] main() 的 n [ 4 ]
foo() 的 n [ 3→4 ](副本) foo() 的 n ──虫洞──► main() 的 n
7. std::string:对象、成员函数与逐字符处理
问题定义:文本处理是编程日常,需要一套趁手的字符串工具。 直观解释:C++ 的 string 不是基本类型而是对象:它内部是一块连续存放字符的内存(本质是字符数组),同时“随身携带”一批现成函数,用点号 . 调用,称为成员函数(member function)。它与 Python/Java 的一大不同是可修改(mutable):s[0] = 'Y' 能直接改掉第 0 个字符。 常用成员函数:s.length()(字符个数)、s[i](按下标读写字符)、s += "xx" 或 s + t(拼接)、s.substr(起点, 长度)(截子串)、s.find(子串)(返回首次出现下标,找不到返回 string::npos)、s.insert(位置, 文本)、s.erase(位置, 长度)、s.replace(位置, 长度, 新文本)。
字符串 "hello" 的内存布局(数组本质):
+----+----+----+----+----+
|'h' |'e' |'l' |'l' |'o' |
+----+----+----+----+----+
0 1 2 3 4 ← 下标从 0 到 length()-1
逐字符遍历:用普通 for 按下标 s[i] 访问(可改),或 range-for 取出每个 char 副本(只读遍历更简洁,但拿不到下标、也改不了原串)。
8. 字符处理:ASCII 与 cctype、类型转换
问题定义:字符在计算机里只是数字,需要一套“字符—数字”对照表和现成的判断函数。 直观解释:char 背后就是整数——'A' 是 65,'a' 是 97,'0' 是 48(这就是 ASCII 标准)。所以字符可以比较大小、做算术,也可以用函数式类型转换 int(ch) 把它“现出原形”。 步骤分解:<cctype> 库提供一族的 isXxx(ch) 判断函数:isalpha(字母)、isdigit(数字)、isupper/islower(大小写)、isspace(空白)等,以及转换函数 toupper(ch)/tolower(ch)(注意它们是传值:返回新字符,不改原变量)。 风格要点(官方第 3 讲):别把 96、65 这类“魔数(magic number)”直接写死在代码里——int(ch) - ('a' - 1) 比 int(ch) - 96 自解释得多。能用 isalpha 等库函数表达意图时,就不要再手写 ch >= 'a' && ch <= 'z' 这种比较。
9. Vector:顺序容器、扩容、add vs insert(0,·)
问题定义:需要一个能自动伸缩、按下标快速访问的“列表”。 直观解释:Vector 是“同质、有序、按下标 0..n-1 索引”的容器,底层是连续内存数组——可以类比浏览器的标签页:有先后顺序,能增能减。课程官方使用 Stanford 的 Vector<T>,与标准库 std::vector<T> 等价(官方第 4 讲特意提醒两者大小写不同;本笔记一律用 std 版本)。 常用操作对照:v.push_back(x)(官方 add,末尾追加)、v.insert(v.begin()+i, x)(官方 insert(i,x),在 i 前插入并右移后续元素)、v.erase(v.begin()+i)(官方 remove(i),删除并左移)、v.size()、v.empty()(官方 isEmpty)、v[i] 下标访问、v.clear()。
push_back(末尾追加,快): insert(0,x)(头部插入,慢):
[15][20][18] 加 33 [15][20][18][33] 插入 90
[15][20][18][33] [ ][15][20][18][33] ← 先把 4 个元素整体右移
[90][15][20][18][33] ← 再写新值
运行时对比(官方第 4 讲核心实验):add 只是往末尾“放一个”,偶尔后台扩容一次;insert(0, x) 则每次都要把已有的每一个元素往右挪一格。官方在课上用计时工具实测:规模 5 万时 insert 版比 add 版慢约 17 倍,规模到 50 万时差距膨胀到三百多倍。规模每翻倍,insert 版的工作量也翻倍式增长——这正是下一讲“大 O 记号”要量化的现象。这里的“扩容”概念先记住:vector 满了会申请一块更大的内存并把所有元素拷过去(偶尔发生,均摊后 add 仍是 O(1)),细节留待第 4 讲与实现篇展开。
10. 二维容器:Grid(用 vector<vector> 表示)
问题定义:表格、棋盘、像素图……都需要“行列”二维数据。 直观解释:官方 Grid<T>(行, 列) 就是矩形网格,元素按 g[r][c] 访问(行在前的“row-major”,记忆口诀:row 是“major(主要)”,所以行号永远先写)。用标准库表示就是“vector 的 vector”:std::vector<std::vector<int>> g(rows, vector<int>(cols, 0))——外层是行、内层是列。官方 numRows()/numCols() 对应 g.size() 与 g[0].size()。 遍历方式:双层 for(外层行、内层列)打印;range-for 会按 row-major 顺序把所有元素平铺输出(拿不到行列号)。适用场景:井字棋棋盘、图像像素、乘法表等。
11. 函数分解与测试理念;ADT 概念引入
问题定义:怎么让一段正确但“又长又乱”的代码变得可读、可测、可维护? 直观解释(官方第 4 讲):把大任务拆成“各司其职”的小函数叫函数分解(functional decomposition);函数名用动词短语,变量名有意义,注释只解释“为什么/怎么做”,而不是把代码逐行翻译成英文。好的副产品是:每个小函数都能被独立、严格地测试。 测试理念:官方在 L04 引入 SimpleTest 框架(STUDENT_TEST 写用例、EXPECT_EQUAL 断言、runSimpleTests 批量跑),并展示了为 extractAlpha 设计的“边界场景清单”:全是字母/没有字母/字母在头中尾/空串/长度 1、2、3/超长串……本笔记不引入任何 Stanford 库,用自写的迷你断言函数(见示例 3 附近说明)演示同一思想:对边界条件穷举、断言期望值、一次运行全量验证。 ADT 是什么:抽象数据类型 = “数据 + 允许的操作”打包成的契约。使用者只需知道“能 push 什么、pop 得到什么”,不必关心内部是数组还是链表——这正是官方第 4 讲“先从客户端视角使用、后几讲再亲手实现”的教学路线。
代码示例与实现详解
示例 1:一门 C++ 程序的“骨架课”——变量、三种循环、分支与短路
// 文件: basics_demo.cpp
// 演示: 注释、变量类型、cout/endl、while/for/range-for、if 与短路求值
#include <iostream> // 标准库用尖括号: 提供 cout/endl
#include <string> // 提供 std::string(写全可不加,<iostream> 常间接引入,但显式更稳)
using namespace std; // 免写 std:: 前缀(std = standard 命名空间)
int main() // 程序的唯一入口,操作系统自动调用它
{
// (1) 变量: 声明即初始化,类型一旦定下不可更改
int age = 20; // 整数
double pi = 3.14159; // 浮点数
char grade = 'A'; // 单字符必须单引号
string name = "Ada"; // 字符串必须双引号
bool likesCS = true; // 布尔
// (2) cout 输出 << endl 换行(不写 endl 所有输出会黏在一起)
cout << "Hello, " << name << "!" << endl;
cout << "age=" << age << ", grade=" << grade << ", pi=" << pi << endl;
// (3) while: 先判断再执行
int i = 1;
while (i <= 3) {
cout << "count " << i << endl;
i++; // 推进条件,否则死循环
}
// (4) for: 初始化/条件/步进三合一;循环变量 j 只在循环内有效
int sum = 0;
for (int j = 0; j < 5; j++) {
sum += j; // 0+1+2+3+4
}
cout << "0..4 之和 = " << sum << endl;
// (5) range-based for: 逐字符掏出,拿不到下标
for (char ch : name) {
cout << "[" << ch << "]";
}
cout << endl;
// (6) if + 短路: age>=18 为假时,&& 右边根本不会执行
if (age >= 18 && likesCS) {
cout << "成年,且热爱计算机科学。" << endl;
} else {
cout << "条件不满足。" << endl;
}
return 0; // main 返回 0 = "零错误",交还操作系统
}
【代码做什么】:(1) 声明五种常见类型的变量并输出;(2) 用 while 数 1 到 3;(3) 用 for 求 0..4 的和;(4) 用 range-for 把名字逐字打上括号;(5) 用 && 组合两个条件。整体把本讲的“骨架语法”在一份可运行程序里串了一遍。
【实现机制解说】:注意 for (int j = 0; j < 5; j++) 里 j 的作用域只在循环内——出了右花括号 j 就不存在,这是 C++ 的块作用域规则。sum += j 是 sum = sum + j 的简写。age >= 18 && likesCS 演示短路:若 age >= 18 为假,整个表达式立即为假,右侧 likesCS 不再求值——若右侧是 v[i] > 0 这类可能越界的访问,短路正好保护它不被执行。
示例 2:字符串、字符与函数——成员函数、原型、传值 vs 传引用
// 文件: string_func_demo.cpp
// 演示: string 成员函数、逐字符处理、cctype、函数原型、传值与传引用
#include <cctype> // isspace / toupper 等字符判断与转换
#include <iostream>
#include <string>
using namespace std;
// —— 函数原型区: 放在 main 之前,让编译器先“认识”这些函数 ——
string makeAcronym(const string& phrase); // 取每个单词首字母拼缩写(大写)
int asciiSum(const string& s); // 求字符串各字符 ASCII 码之和
void toUpperInPlace(string& s); // 引用参数: 就地改成大写
int main()
{
// (1) string 成员函数: length()/[]/substr()/+= (字符串是可改对象)
string phrase = "abstract data type";
cout << "长度: " << phrase.length() << endl; // 18
cout << "第 0 个字符: " << phrase[0] << endl; // 'a'
cout << "substr(0,3): " << phrase.substr(0, 3) << endl; // "abs"
// (2) 传 const 引用: 省拷贝且保证不被改动(想改也改不了)
string acr = makeAcronym(phrase);
cout << "缩写: " << acr << endl; // "ADT"
cout << "ASCII 码和: " << asciiSum(acr) << endl; // A=65 D=68 T=84 → 217
// (3) 传引用: 函数内改动会“穿透”到 main 里的 acr
toUpperInPlace(acr); // acr 已是 "ADT",再转一次不变(留作观察点)
string lower = "hello";
toUpperInPlace(lower);
cout << "toUpperInPlace 后: " << lower << endl; // "HELLO"
return 0;
}
// —— 函数定义区 ——
string makeAcronym(const string& phrase)
{
string result;
bool newWord = true; // 下一个字符是否是单词首字母
for (char ch : phrase) {
if (isspace(ch)) {
newWord = true; // 遇到空白 → 下一个非空白是词首
} else if (newWord) {
result += toupper(ch); // 首字母转大写并拼接(注意: 需 string 打底)
newWord = false;
}
}
return result; // 返回新串,phrase 本身未动
}
int asciiSum(const string& s)
{
int total = 0;
for (char ch : s) {
total += int(ch); // 类型转换: char 露出 int 真身
}
return total;
}
void toUpperInPlace(string& s) // & 表示引用: 形参是实参的别名
{
for (int i = 0; i < (int)s.length(); i++) {
s[i] = toupper(s[i]); // 逐个字符原地改写(字符串可修改)
}
}
【代码做什么】:makeAcronym 把 “abstract data type” 缩成 “ADT”(体会逐字符扫描 + 词首判断);asciiSum 用 int(ch) 累加字符的 ASCII 码;toUpperInPlace 借引用形参原地把字符串改成大写。main 里依次展示 length/下标/substr、传值式库函数(toupper 返回新字符)与引用式自定义函数。
【实现机制解说】:result += toupper(ch) 这一行有两个细节:(a) result 是 string,+= 把右侧 char 拼到末尾,这就是“字符串可原地增长”;(b) 若写成 result = result + toupper(ch) 也等价,但若两边都是裸字符串字面量(如 "abc" + "xyz")则编译不过——C++ 里双引号字面量是 C 风格字符串,必须至少一边是 C++ 的 string。再看传参:makeAcronym(const string& phrase) 的 const & 是“只读借用”,既省去整串拷贝(若 1GB 文本按值传就要再拷 1GB),又由编译器保证函数内改不了它;toUpperInPlace(string& s) 没有 const,函数内 s[i] = ... 会直接改到调用者的变量——这正是“传值 vs 传引用”的分界线:有 & 是虫洞,没 & 是复印件。另外 s.length() 返回无符号类型,与 int 比较时建议先 (int) 强转,避免有符号/无符号比较的隐形坑(官方第 3 讲练习也提醒这类细节)。
示例 3:std::vector 与二维 vector——“add vs insert(0,·)”计时 + Grid 表示
// 文件: vector_grid_demo.cpp
// 演示: vector 常用操作、add vs insert(0,·) 运行时对比、二维容器表示 Grid
#include <chrono> // 计时
#include <iostream>
#include <vector>
using namespace std;
double timeAdd(int n); // 原型: n 次 push_back 耗时(毫秒)
double timeInsertHead(int n); // 原型: n 次 insert(begin()) 耗时(毫秒)
int main()
{
// (1) 常用操作(std 版; 官方 Stanford Vector 的 add/insert/remove 同名不同拼)
vector<int> v = {15, 20, 18}; // 初始化列表
v.push_back(33); // 对应 add(33) → {15,20,18,33}
v.insert(v.begin() + 2, 90); // 对应 insert(2,90) → {15,20,90,18,33}
v.erase(v.begin()); // 对应 remove(0) → {20,90,18,33}
for (int i = 0; i < (int)v.size(); i++) cout << v[i] << " ";
cout << endl;
// (2) 计时对比: 规模翻倍时,add 版近似线性,insert 头部版近似“翻倍再翻倍”
cout << "n push_back insert(begin)" << endl;
for (int n : {5000, 10000, 20000, 40000}) {
double tAdd = timeAdd(n);
double tIns = timeInsertHead(n);
cout << n << " " << tAdd << " ms " << tIns << " ms"
<< " (慢 " << tIns / tAdd << " 倍)" << endl;
}
// (3) 二维 vector 模拟 Grid: 3 行 4 列,row-major(行先列后)
const int ROWS = 3, COLS = 4;
vector<vector<int>> g(ROWS, vector<int>(COLS, 0)); // 全部初始化为 0
g[2][3] = 18; // 第 2 行第 3 列
for (int r = 0; r < (int)g.size(); r++) { // 外层行
for (int c = 0; c < (int)g[r].size(); c++) { // 内层列
cout << g[r][c];
if (c + 1 < (int)g[r].size()) cout << ", ";
}
cout << endl;
}
return 0;
}
double timeAdd(int n)
{
vector<int> v;
auto t0 = chrono::steady_clock::now();
for (int i = 0; i < n; i++) v.push_back(i); // 每次只写末尾一个位置
auto t1 = chrono::steady_clock::now();
return chrono::duration<double, milli>(t1 - t0).count();
}
double timeInsertHead(int n)
{
vector<int> v;
auto t0 = chrono::steady_clock::now();
for (int i = 0; i < n; i++) v.insert(v.begin(), i); // 每次都要把所有元素右移
auto t1 = chrono::steady_clock::now();
return chrono::duration<double, milli>(t1 - t0).count();
}
【代码做什么】:(1) 用 std 容器复刻官方 Vector 的 add/insert/remove 三连操作并打印;(2) 用 <chrono> 对“n 次末尾追加”和“n 次头部插入”分别计时,规模从 5000 翻倍到 40000,观察两者差距如何被拉大(官方 L04 用 SimpleTest 的 TIME_OPERATION 做过同款实验,规模到 50 万时差距达数百倍);(3) 用 vector<vector<int>> 造一张 3×4 网格并 row-major 打印——这就是官方 Grid 类的标准库替身。
【实现机制解说】:v.insert(v.begin(), i) 每执行一次,要把当前所有元素整体右移一格腾出第 0 位,因此第 i 次插入要搬 i 个元素,n 次总共约搬 1+2+…+n ≈ n²/2 次——规模翻倍,搬移次数约翻 4 倍,计时结果会直观地“翻倍再翻倍”。push_back 则不同:末尾有空位时只写一个位置;只有当容量(capacity)耗尽,vector 才会“扩容”——申请一块更大的连续内存、把旧元素全部拷过去再释放旧的(这就是示例说明里提到的后台扩容),因为扩容不常发生,均摊下来 add 仍接近 O(1)。这正是官方 L04 强调“insert(0,·) 危险地慢、add 相当快”的底层原因。网格部分:vector<vector<int>> g(ROWS, vector<int>(COLS, 0)) 先造好 3 个“行”,每行是一个长度为 4 的全 0 列向量;g[2][3] = 18 先取第 2 行的 vector(引用语义),再改其第 3 个元素——两层下标 [][] 与官方 g[r][c] 一一对应。
复杂度分析
| 操作 | 复杂度 | 简要原因 |
|---|---|---|
v[i] 按下标访问 | O(1) | 连续内存 + 首地址 + 偏移量直接定位 |
push_back(add)平均 | O(1) 均摊 | 末尾写入;仅容量耗尽时偶尔整块拷贝扩容 |
insert(begin(), x)(insert(0,·)) | O(n) | 必须把已有 n 个元素全部右移 |
erase(begin())(remove(0)) | O(n) | 必须把后续元素全部左移填补空位 |
s[i]、s.length() | O(1) | 字符串同数组本质,长度被缓存 |
| 逐字符遍历 string/vector | O(n) | 每个元素恰好处理一次 |
| 按值传参给函数(大容器) | O(n) | 需要整体拷贝一份 |
关键要点
- 先语法后语义:编译器只把关“合不合规矩”,程序“对不对”永远靠你自己想清楚,再靠测试验证。
- 声明即初始化:C++ 不会替你清零基本类型变量,未初始化的
int里装的是垃圾值。 - 想改实参就用引用
&,只想省拷贝就用const &;大容器一律不要按值传。 - 字符串与容器都从 0 开始编号、都可用 range-for 遍历;先想清要不要下标、要不要修改,再选 for 还是 for-each。
add(末尾追加)均摊 O(1) 快到飞起,insert(0,·)每回都要“全体右移”慢得吓人——以后写循环优先往末尾堆数据。
常见陷阱与注意事项
- 未初始化变量:
int a; cout << a;打印垃圾值。规避:声明时立刻给初值。 - 越界访问:
s[10](字符串长度 5)可能打印乱码甚至段错误崩溃,v[v.size()]同理。规避:始终让下标落在[0, size),必要时先判断再访问。 - 引号用错:字符用单引号
'A',字符串用双引号"A",混用会导致编译错误或歧义。 - 类型不可变:
int a = 5; string a = "hi";是重复声明;改值只写a = 7;。 ==写成=:if (a = 5)是赋值而非比较,条件恒真。规避:条件里坚持用==。- 短路误用/漏用:
if (x \|\| y)若 x 恒真则 y 永不执行;反之访问数组前不检查边界可能崩。规避:把“安检”放在&&左侧。 - 魔数:
int(ch) - 96让人看不懂。规避:用('a' - 1)或isalpha/toupper这类自解释写法。 - 注释废话:
// 打印 hello这种把代码翻译成中文的注释是噪音。规避:注释只写“为什么/整体在干嘛”,函数名取动词短语。 - 循环里改 size:
for (int i = 0; i < v.size(); i++)若循环体里 push_back 会越跑越多。规避:先缓存原始大小或改用 while + 明确退出条件。
思考题(带答案)
问题 1:string s = "hello"; for (char ch : s) { ch = toupper(ch); } cout << s; 输出是什么?为什么? 答案:输出 hello。range-for 里的 ch 是每个字符的拷贝,改 ch 只改副本,原字符串不受影响;想真正修改要写成 for (int i = 0; i < s.length(); i++) s[i] = toupper(s[i]);(即示例 2 的 toUpperInPlace 思路)。
问题 2:声明 void mystery(int& b, int c) 后调用 int x = 5; mystery(x, x);,函数内 b++、c++,回到 main 后 x 是多少?为什么? 答案:x 变成 6。b 是引用,b++ 直接改 main 里的 x;c 是传值,c++ 只改函数内副本,与 x 无关。要点:同名实参传给引用和值两个形参时,只有引用那一路会“穿透”。
问题 3:手头有一个很大的 vector<string>,只想统计里面有多少个元素,函数签名写成 int countAll(vector<string> v) 有什么问题?怎么改最好? 答案:按值传参会把整个 vector 逐元素拷贝一遍,时间 O(n)、内存翻倍,纯属浪费。改成 int countAll(const vector<string>& v):const 表明只读不改,& 避免拷贝,调用方与函数语义都不变,开销降为 O(1)。
