Lecture 1: C++ 基础回顾与 STL 容器入门(C++ Fundamentals & STL Containers:Syntax, Functions, std::string, Vector & Grid, Testing)(对应课程真实讲座 L01–L04)

目录 · ← l0 · l2 →

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 讲):别把 9665 这类“魔数(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 += jsum = 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”(体会逐字符扫描 + 词首判断);asciiSumint(ch) 累加字符的 ASCII 码;toUpperInPlace 借引用形参原地把字符串改成大写。main 里依次展示 length/下标/substr、传值式库函数(toupper 返回新字符)与引用式自定义函数。

【实现机制解说】result += toupper(ch) 这一行有两个细节:(a) resultstring+= 把右侧 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/vectorO(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 这种把代码翻译成中文的注释是噪音。规避:注释只写“为什么/整体在干嘛”,函数名取动词短语。
  • 循环里改 sizefor (int i = 0; i < v.size(); i++) 若循环体里 push_back 会越跑越多。规避:先缓存原始大小或改用 while + 明确退出条件。

思考题(带答案)

问题 1string 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)。