C++ 进阶知识点实战详解(新手指南)
本文专为刚入门的学习者设计,旨在帮助你把学到的知识真正用到实际编程中。
每个知识点都会先讲是什么,再讲为什么(实战中有什么用),最后用带详细自然语言说明的代码展示怎么做,每一步都会解释清楚目的、效果和含义。
对于二叉树、进制转换等概念性较强的内容,我们只讲原理和应用,不写代码,避免干扰。
1. 代码复杂度分析——帮你在实战中写出高效的代码
1.1 什么是复杂度?
复杂度就是衡量程序运行时间(时间复杂度)和占用内存(空间复杂度)的指标。用 大 O 符号 表示,它描述当输入数据量(用 (n) 表示)变得很大时,程序增长的速度。
1.2 为什么实战中要关心?
- 用户等待时间:如果程序太慢,用户会受不了。
- 服务器成本:低效的算法会消耗更多 CPU 和内存,增加服务器费用。
- 面试/竞赛:复杂度分析是考核重点。
1.3 常见复杂度及实战例子
| 复杂度 | 名称 | 实战场景 | 例子 |
|---|---|---|---|
| (O(1)) | 常数阶 | 根据索引取数组元素 | arr[5] |
| (O(\log n)) | 对数阶 | 在有序数据中查找 | 二分查找(如电话簿查找) |
| (O(n)) | 线性阶 | 遍历列表 | 求数组总和 |
| (O(n \log n)) | 线性对数阶 | 排序 | 快速排序、归并排序(如排序 100 万条记录) |
| (O(n^2)) | 平方阶 | 双层循环 | 冒泡排序、简单的图形碰撞检测 |
| (O(2^n)) | 指数阶 | 穷举所有子集 | 旅行商问题的暴力解法(n=20 就爆了) |
实战选择:
- 处理 10 万条数据,(O(n^2)) 会慢到无法接受,而 (O(n \log n)) 通常没问题。
- 写代码前先估算数据量,选择合适算法。
2. 前缀和与后缀和——快速回答“区间和”问题
2.1 实战场景
- 股票分析:快速计算某段时间内的总收益。
- 游戏统计:查询玩家某几天的得分总和。
- 地图区域:二维前缀和用于计算矩形区域内的像素总和。
2.2 前缀和详解(一步步来)
问题:给你一个数组 arr,要频繁查询子数组 [l, r] 的和。如果每次都重新加一遍,每次查询是 (O(n)),太慢。前缀和可以把每次查询降到 (O(1))。
逻辑:
- 创建一个新数组
prefix,长度比原数组多 1,prefix[0] = 0。 prefix[i]表示原数组前i个元素的和(即arr[0] + arr[1] + ... + arr[i-1])。- 那么子数组
arr[l] + ... + arr[r]的和 =prefix[r+1] - prefix[l]。
为什么?
因为 prefix[r+1] 是 arr[0] 到 arr[r] 的和,减去 arr[0] 到 arr[l-1] 的和(即 prefix[l]),就剩下 arr[l] 到 arr[r] 的和。
代码(每一步都有详细说明):
#include <vector>using namespace std;
// 这个函数会返回一个前缀和数组,之后用这个数组可以快速算出任何区间的和vector<int> buildPrefixSum(const vector<int>& arr) { // 先拿到原数组有多少个数字 int n = arr.size(); // 新建一个数组,长度比原数组多 1,所有元素初始为 0 // 我们把这个数组叫 prefix(前缀和数组) vector<int> prefix(n + 1, 0);
// 开始循环,i 从 0 走到 n-1,代表原数组的每一个位置 for (int i = 0; i < n; i++) { // prefix[i+1] 要存储“原数组从开头到第 i 个数字的和” // 这个和等于“前 i 个数字的和”加上“第 i 个数字” // 前 i 个数字的和正好就是 prefix[i] // 第 i 个数字就是 arr[i] // 所以公式就是 prefix[i+1] = prefix[i] + arr[i] prefix[i + 1] = prefix[i] + arr[i]; } // 循环结束后,prefix 数组就准备好了,把它返回给调用者 return prefix;}
// 这个函数用前缀和数组快速求出原数组中从 l 到 r 的区间和int rangeSum(const vector<int>& prefix, int l, int r) { // 根据数学推导:arr[l] + ... + arr[r] = prefix[r+1] - prefix[l] // 因为 prefix[r+1] 包含了从 0 到 r 的所有数字和 // 减去 prefix[l](包含从 0 到 l-1 的所有数字和) // 剩下的就是从 l 到 r 的和 return prefix[r + 1] - prefix[l];}使用示例:
vector<int> arr = {3, 1, 4, 1, 5}; // 原数组vector<int> pre = buildPrefixSum(arr); // 调用函数,得到前缀和数组cout << rangeSum(pre, 1, 3) << endl; // 输出 1+4+1 = 62.3 后缀和
后缀和是从当前位置到末尾的和。常用于需要“从后往前”累加的场景,比如统计某天之后的总销量。
逻辑:
suffix[i]表示arr[i] + arr[i+1] + ... + arr[n-1]。- 从右向左递推:
suffix[i] = arr[i] + suffix[i+1],并令suffix[n] = 0方便边界。
代码(思路与前缀和对称):
vector<int> buildSuffixSum(const vector<int>& arr) { int n = arr.size(); // 后缀和数组长度也是 n+1,最后一个位置 suffix[n] 设为 0 vector<int> suffix(n + 1, 0); // 从最后一个元素开始往前推 for (int i = n - 1; i >= 0; i--) { // suffix[i] = 当前元素 arr[i] + 它后面所有元素的和(即 suffix[i+1]) suffix[i] = suffix[i + 1] + arr[i]; } return suffix;}// 查询从下标 l 到末尾的和int suffixSum(const vector<int>& suffix, int l) { return suffix[l];}3. 二分查找——在有序数据中快速定位
3.1 实战场景
- 查找电话簿中的联系人(已排序)。
- 在已排序的订单列表中查找某价格附近的订单。
- 游戏排行榜中查找某个分数的排名。
3.2 标准二分查找:在区间 [l, r] 中找 x
逻辑(核心思想:每次砍掉一半):
- 确定当前查找的区间是
[l, r],初始 l = 0,r = n-1。 - 只要
l <= r,就继续:- 取中间位置
mid = (l + r) / 2(为防止大数溢出,写为l + (r-l)/2)。 - 比较
arr[mid]与x:- 相等 → 找到,返回
mid。 - 小于 → 说明
x在右边,所以l = mid + 1。 - 大于 → 说明
x在左边,所以r = mid - 1。
- 相等 → 找到,返回
- 取中间位置
- 循环结束还没找到,返回 -1。
代码(每一步都解释清楚):
// 在数组 arr 的闭区间 [l, r] 中查找 x,返回下标,找不到返回 -1int binarySearch(const vector<int>& arr, int x, int l, int r) { // 只要左指针没有超过右指针,就说明还有元素没检查 while (l <= r) { // 计算中间位置的下标。用 l + (r-l)/2 可以避免 (l+r) 可能太大而溢出 int mid = l + (r - l) / 2;
// 情况1:中间的元素正好等于我们要找的 x if (arr[mid] == x) { return mid; // 找到了,直接返回下标 } // 情况2:中间的元素比 x 小,说明 x 一定在右半部分 else if (arr[mid] < x) { l = mid + 1; // 把左边界移到 mid 的右边,继续在右半部分找 } // 情况3:中间的元素比 x 大,说明 x 一定在左半部分 else { r = mid - 1; // 把右边界移到 mid 的左边,继续在左半部分找 } } // 循环结束还没找到,说明 x 不在数组中 return -1;}3.3 lower_bound:找第一个 ≥ x 的位置
场景:有重复元素,想知道第一个不小于 x 的元素下标。例如:[1,2,2,2,3] 中找第一个 ≥ 2 的位置,答案是 1(因为 arr[1]=2)。
逻辑(使用左闭右开区间 [l, r)):
- 初始 l = 0,r = n。
- 当 l < r 时:
- mid = l + (r-l)/2
- 如果
arr[mid] >= x,则第一个 ≥ x 的位置可能在 mid 或左边,所以 r = mid。 - 否则
arr[mid] < x,则答案一定在右边,所以 l = mid + 1。
- 循环结束时,l 就是第一个 ≥ x 的位置(可能为 n,表示所有元素都小于 x)。
代码:
int lowerBound(const vector<int>& arr, int x) { int l = 0, r = arr.size(); // 区间是 [l, r),一开始包含所有元素 while (l < r) { int mid = l + (r - l) / 2; // 如果中间元素 >= x,那么第一个 >= x 的位置不可能在 mid 右边 // 它可能是 mid 本身,也可能在左边,所以把右边界缩小到 mid if (arr[mid] >= x) { r = mid; } else { // 中间元素 < x,那么第一个 >= x 的位置一定在 mid 右边 l = mid + 1; } } // 循环结束时 l 和 r 相等,就是我们要的位置 return l;}3.4 upper_bound:找第一个 > x 的位置
场景:第一个大于 x 的元素下标。例如 [1,2,2,2,3] 中找第一个 > 2 的位置,答案是 4(arr[4]=3)。
逻辑:与 lower_bound 类似,只是条件改为 arr[mid] > x。
int upperBound(const vector<int>& arr, int x) { int l = 0, r = arr.size(); while (l < r) { int mid = l + (r - l) / 2; // 如果中间元素 > x,那么第一个 > x 的位置可能在 mid 或左边 if (arr[mid] > x) { r = mid; } else { // 中间元素 <= x,那么第一个 > x 的位置一定在 mid 右边 l = mid + 1; } } return l;}实战技巧:
- 利用 lower_bound 和 upper_bound 可以快速得到等于 x 的元素范围:
[lower_bound(x), upper_bound(x))。 - 在 STL 中,
std::lower_bound和std::upper_bound直接可用,返回迭代器。
4. 结构体——把相关数据打包成整体
4.1 实战场景
- 游戏开发:每个敌人有坐标、血量、类型等属性。
- 数据库记录:学生信息(姓名、学号、成绩)。
- 网络编程:消息结构(类型、长度、数据)。
4.2 结构体定义与使用(逐句解释)
#include <iostream>#include <string>using namespace std;
// 定义一个结构体,名字叫 Studentstruct Student { // 下面是成员变量,它们会出现在每个 Student 对象中 string name; // 学生的名字,是一个字符串 int age; // 学生的年龄,是一个整数 double score; // 学生的成绩,是一个小数(浮点数)
// 这是构造函数,专门用来创建 Student 对象的 // 它接收三个参数:n, a, s,分别对应名字、年龄、成绩 // 后面的 : name(n), age(a), score(s) 叫做初始化列表 // 意思是把参数 n 赋值给 name,a 赋值给 age,s 赋值给 score Student(string n, int a, double s) : name(n), age(a), score(s) {}
// 这是一个成员函数,用来显示学生的信息 // 末尾的 const 表示这个函数不会修改任何成员变量 // 这样即使一个对象是 const 的,也可以调用这个函数 void display() const { // 输出学生信息,用空格隔开 cout << name << " " << age << " " << score << endl; }};如何使用:
int main() { // 创建一个 Student 对象,名字叫 stu1,调用构造函数给它赋值 Student stu1("张三", 20, 95.5); // 调用 stu1 的 display 函数,它会输出 "张三 20 95.5" stu1.display();
// 结构体的成员默认是公开的,所以可以直接修改 // 把 stu1 的成绩改成 98.0 stu1.score = 98.0; // 再次显示,现在成绩变了 stu1.display();
// 也可以直接用花括号创建对象,这是 C++11 的列表初始化 Student stu2 = {"李四", 22, 88.0}; stu2.display();
return 0;}实战中:结构体经常配合 vector 使用,例如 vector<Student> 存储全班学生,方便排序、查找。
5. 进制转换与编码(纯概念,实战中用于理解计算机底层)
5.1 十进制转二进制
方法:除 2 取余,倒序排列。
例子:13 ÷ 2 = 6 余 1(最低位),6 ÷ 2 = 3 余 0,3 ÷ 2 = 1 余 1,1 ÷ 2 = 0 余 1(最高位) → 1101₂。
5.2 二进制转十进制
方法:按权展开。
例子:1101₂ = 1×2³ + 1×2² + 0×2¹ + 1×2⁰ = 8+4+0+1 = 13。
5.3 N 进制转十进制
方法:将每一位数字乘以 N 的相应次幂,然后相加。
例子:八进制 237 = 2×8² + 3×8¹ + 7×8⁰ = 128+24+7 = 159。
5.4 原码、反码、补码(计算机如何表示整数)
- 原码:最高位为符号位(0 正 1 负),其余位为数值的绝对值。如 +5 = 00000101,-5 = 10000101。
- 反码:正数同原码,负数符号位不变,其余位取反。如 -5 = 11111010。
- 补码:正数同原码,负数反码 +1。如 -5 = 11111011。
为什么用补码?
因为补码可以将减法统一为加法,且只有一个 0,硬件实现简单。
实战意义:理解补码有助于排查数据溢出、位运算错误等问题。
6. 位运算与排列组合——高效处理集合与状态
6.1 常用位运算技巧
| 操作 | 作用 | 实战场景 |
|---|---|---|
x & 1 | 判断奇偶 | 快速判断数字是奇数还是偶数 |
x >> 1 | 除以 2 | 二分查找、递归折半 |
x << 1 | 乘以 2 | 快速计算 2 的幂 |
x & -x | 获取最低位 1 | 树状数组(Fenwick Tree) |
x & (x-1) | 清除最低位 1 | 统计二进制中 1 的个数 |
6.2 用位运算枚举子集(排列组合)
实战场景:在状态压缩动态规划(如旅行商问题、子集背包)中,用整数表示集合。
逻辑:假设有 n 个元素,每个元素选或不选,可以用一个 n 位的二进制数 mask 表示。第 i 位为 1 表示选择元素 i。
遍历所有子集:for (int mask = 0; mask < (1 << n); mask++)。
代码(逐行解释):
#include <iostream>using namespace std;
int main() { int n = 3; // 假设集合包含数字 {0, 1, 2} // 总共有 2 的 n 次方个子集,这里 n=3,所以 2³=8 个子集 // mask 从 0 到 7(二进制 000 到 111) for (int mask = 0; mask < (1 << n); mask++) { cout << "{ "; // 检查 mask 的每一位,i 从 0 到 n-1 for (int i = 0; i < n; i++) { // 如果 mask 的第 i 位是 1,说明这个子集包含元素 i // mask >> i 把 mask 右移 i 位,然后 & 1 取出最低位 if ((mask >> i) & 1) { cout << i << " "; // 输出这个元素 } } cout << "}\n"; } return 0;}实战技巧:这种技巧常用于需要枚举所有组合的暴力解法或 DP 预处理。
7. 动态规划(DP)——实战中解决最优化问题
7.1 什么是动态规划?
动态规划是把大问题拆成小问题,记住小问题的答案,避免重复计算的一种方法。
实战场景:最短路径、背包问题、文本编辑距离、股票买卖等。
7.2 经典入门:斐波那契数列(帮助理解 DP 思想)
问题:求第 n 个斐波那契数(0,1,1,2,3,5,8,…)。
暴力递归(慢):
int fib(int n) { if (n <= 1) return n; return fib(n-1) + fib(n-2);}为什么慢?因为重复计算了很多子问题,例如 fib(5) 要算两次 fib(3)。
动态规划(自底向上):
int fib(int n) { // 如果 n 是 0 或 1,直接返回 n 本身 if (n <= 1) return n;
// 创建一个数组 dp,大小是 n+1,用来存放每个位置的斐波那契数 vector<int> dp(n + 1);
// 初始条件:第 0 个斐波那契数是 0,第 1 个是 1 dp[0] = 0; dp[1] = 1;
// 从 i=2 开始,一直计算到 i=n for (int i = 2; i <= n; i++) { // 斐波那契数的定义:当前数等于前两个数之和 dp[i] = dp[i-1] + dp[i-2]; }
// 最后 dp[n] 就是我们要的答案 return dp[n];}逻辑:从最小问题开始,逐步推到目标,每个子问题只计算一次,时间复杂度 O(n)。
实战:这个思想可以推广到很多问题,比如爬楼梯、最小花费爬楼梯等。
7.3 实战案例:0/1 背包问题
问题:有 n 个物品,每个物品有重量 w 和价值 v,背包容量 W,求能装的最大总价值(每个物品最多选一次)。
DP 思路:
- 定义
dp[w]表示容量为 w 的背包能装的最大价值。 - 初始化
dp[0..W] = 0。 - 对于每个物品 i,我们考虑要不要把它放进背包。如果放,那么容量 w 下,价值为
dp[w - w_i] + v_i。取dp[w]和这个值的较大者。 - 为了避免一个物品被重复使用,必须从大到小遍历 w。
代码(每一步都有详细说明):
int knapsack(int W, const vector<int>& wt, const vector<int>& val) { int n = wt.size(); // 一共有多少个物品 // dp 数组,长度是 W+1,下标表示容量,值表示该容量下的最大价值 // 一开始什么物品都没放,价值都是 0 vector<int> dp(W + 1, 0);
// 外层循环:依次考虑每个物品 for (int i = 0; i < n; i++) { // 内层循环:从最大容量 W 往下遍历到当前物品的重量 wt[i] // 为什么要从大到小?因为如果从小到大,同一个物品| 方法 | 作用 | 说明 ||------|------|------|| `stack<T> st;` | 创建一个空的栈,元素类型为 `T` | 例如 `stack<int> st;` || `st.push(x);` | 将元素 `x` 压入栈顶 | 无返回值 || `st.pop();` | 弹出栈顶元素(删除) | 无返回值;**必须先确保栈非空**,否则未定义行为 || `st.top();` | 返回栈顶元素的引用 | 不删除元素;**必须先确保栈非空** || `st.empty();` | 判断栈是否为空 | 返回 `bool`,空为 `true` || `st.size();` | 返回栈中元素的个数 | 返回 `size_t` 类型 |可能会被多次使用(完全背包) // 这里每个物品只能用一次,所以必须从大到小,保证更新时用到的 dp[w - wt[i]] 是没放过当前物品的状态 for (int w = W; w >= wt[i]; w--) { // 对于当前容量 w,有两种选择: // 1. 不装这个物品,价值保持原来的 dp[w] // 2. 装这个物品,价值 = dp[w - wt[i]] + val[i](腾出 wt[i] 空间,加上物品价值) // 我们取两者中较大的一个 dp[w] = max(dp[w], dp[w - wt[i]] + val[i]); } } // 最终 dp[W] 就是容量为 W 时能获得的最大价值 return dp[W];}例子:W=5,物品1(2,3),物品2(3,4),物品3(4,5)
- 处理物品1后:dp[2]=3, dp[3]=3, dp[4]=3, dp[5]=3
- 处理物品2:w=5: dp[5]=max(3, dp[2]+4=7)=7; w=4: dp[4]=max(3, dp[1]+4=4)=4; w=3: dp[3]=max(3, dp[0]+4=4)=4
- 处理物品3:w=5: dp[5]=max(7, dp[1]+5=5)=7; w=4: dp[4]=max(4, dp[0]+5=5)=5
最终 dp[5]=7,选物品1和物品2(总重5,价值7)。
实战中:背包问题衍生出无数变种,如完全背包、多重背包,但核心都是状态转移。
8. 栈——后进先出的数据结构
8.1 实战场景
- 函数调用(系统栈)。
- 表达式求值(中缀转后缀)。
- 括号匹配。
- 撤销操作(Undo)。
| 方法 | 作用 | 说明 |
|------|------|------|
|
stack<T> st;| 创建一个空的栈,元素类型为T| 例如stack<int> st;| |st.push(x);| 将元素x压入栈顶 | 无返回值 | |st.pop();| 弹出栈顶元素(删除) | 无返回值;必须先确保栈非空,否则未定义行为 | |st.top();| 返回栈顶元素的引用 | 不删除元素;必须先确保栈非空 | |st.empty();| 判断栈是否为空 | 返回bool,空为true| |st.size();| 返回栈中元素的个数 | 返回size_t类型 |
8.2 C++ 栈的使用(头文件 <stack>)
#include <stack>#include <iostream>using namespace std;
int main() { // 创建一个存放 int 类型数据的栈 stack<int> st;
// 入栈:把 10 放到栈顶 st.push(10); // 入栈:把 20 放到栈顶,现在栈顶是 20 st.push(20);
// 获取栈顶元素:top() 返回栈顶的值,但不删除它 int topVal = st.top(); // 现在 topVal = 20 cout << "栈顶元素是:" << topVal << endl;
// 弹出栈顶:删除栈顶元素,没有返回值 st.pop(); // 现在栈顶变成了 10
// 判断栈是否为空:empty() 返回 true 表示空,false 表示非空 if (st.empty()) { cout << "栈为空" << endl; } else { cout << "栈不为空" << endl; }
// 获取栈中元素个数:size() 返回当前栈里的元素数量 int sz = st.size(); // 此时只有一个元素 10,所以 sz = 1 cout << "栈的大小是:" << sz << endl;
return 0;}关键点:
pop()不返回元素,必须先top()获取再pop()。- 使用前必须确保栈非空,否则
top()和pop()会导致程序崩溃。
8.3 实战应用:括号匹配(详细逻辑)
问题:判断字符串中的括号是否合法,如 "([{}])" 合法,"([)]" 不合法。
思路:
- 遍历字符串的每个字符。
- 如果遇到左括号
([{,就将其压入栈。 - 如果遇到右括号
)]},则检查:- 如果栈为空,说明没有对应的左括号,非法。
- 否则弹出栈顶元素,检查是否与当前右括号匹配。不匹配则非法。
- 遍历结束后,如果栈为空,则所有括号都匹配;否则有未匹配的左括号,非法。
代码(逐行解释):
bool isValid(string s) { // 创建一个字符栈,用来存放左括号 stack<char> st;
// 遍历字符串中的每一个字符 for (char c : s) { // 如果当前字符是左括号之一,就把它压入栈中 if (c == '(' || c == '[' || c == '{') { st.push(c); } else { // 否则,当前字符是右括号 // 首先检查栈是否为空:如果栈空,说明没有左括号来匹配,直接返回 false if (st.empty()) return false;
// 取出栈顶的左括号(但不删除,先看看) char top = st.top(); // 然后把这个左括号弹出(因为已经匹配上了) st.pop();
// 检查当前右括号是否和弹出的左括号匹配 // 如果匹配,继续循环;如果不匹配,返回 false if ((c == ')' && top != '(') || (c == ']' && top != '[') || (c == '}' && top != '{')) { return false; } } } // 所有字符处理完后,如果栈为空,说明所有括号都匹配了;否则有未匹配的左括号 return st.empty();}实战中:这个例子展示了栈的典型用法——处理嵌套结构。
##队列(Queue)
8.4 队列——先进先出的数据结构
8.4.1 实战场景
- 任务调度:操作系统按顺序执行进程。
- 消息队列:生产者消费者模型。
- 广度优先搜索(BFS):层序遍历图或树。
| 方法 | 作用 | 说明 |
|------|------|------|
|
queue<T> q;| 创建一个空的队列,元素类型为T| 例如queue<int> q;| |q.push(x);| 将元素x插入队尾 | 无返回值 | |q.pop();| 删除队首元素 | 无返回值;必须先确保队列非空 | |q.front();| 返回队首元素的引用 | 不删除元素;必须先确保队列非空 | |q.back();| 返回队尾元素的引用 | 不删除元素;必须先确保队列非空 | |q.empty();| 判断队列是否为空 | 返回bool,空为true| |q.size();| 返回队列中元素的个数 | 返回size_t类型 |
8.4.2 C++ 队列的使用(头文件 <queue>)
#include <queue>#include <iostream>using namespace std;
int main() { // 创建一个存放 int 类型数据的队列 queue<int> q;
// 入队:把 10 放到队列尾部 q.push(10); // 入队:把 20 放到队列尾部,现在队首是 10,队尾是 20 q.push(20);
// 获取队首元素:front() 返回队首的值,但不删除它 int frontVal = q.front(); // frontVal = 10 cout << "队首元素是:" << frontVal << endl;
// 获取队尾元素:back() 返回队尾的值,也不删除 int backVal = q.back(); // backVal = 20 cout << "队尾元素是:" << backVal << endl;
// 出队:删除队首元素,没有返回值 q.pop(); // 现在队首变成 20
// 判断队列是否为空:empty() 返回 true 表示空,false 表示非空 if (q.empty()) { cout << "队列为空" << endl; } else { cout << "队列不为空" << endl; }
// 获取队列中元素个数:size() 返回当前队列里的元素数量 int sz = q.size(); // 此时只有一个元素 20,所以 sz = 1 cout << "队列的大小是:" << sz << endl;
return 0;}关键点:
pop()不返回元素,必须先front()获取再pop()。- 使用前必须确保队列非空,否则
front()、back()、pop()会导致程序崩溃。
9. 递归——函数自己调用自己
9.1 什么是递归?
递归是一种编程技巧,函数在内部调用自身。适合解决可以分解为相似子问题的问题。
9.2 实战场景
- 树/图的遍历(如文件系统遍历)。
- 分治算法(如快速排序、归并排序)。
- 回溯算法(如八皇后、迷宫求解)。
9.3 递归的两个核心
- 终止条件:什么时候不再递归,直接返回结果。
- 递归调用:如何把问题缩小,并调用自身。
9.4 实战例子:阶乘(帮助理解递归流程)
// 计算 n! 的递归函数int factorial(int n) { // 终止条件:如果 n 是 0 或 1,阶乘就是 1 // 此时不再调用自己,直接返回 1 if (n <= 1) return 1;
// 递归调用:n! = n * (n-1)! // 这里调用自己,传入 n-1,让问题规模缩小 return n * factorial(n - 1);}执行过程(factorial(3)):
- 调用 factorial(3):n=3 >1,执行
return 3 * factorial(2) - 调用 factorial(2):n=2 >1,执行
return 2 * factorial(1) - 调用 factorial(1):n=1,满足终止条件,返回 1
- 回到 factorial(2):2 * 1 = 2,返回 2
- 回到 factorial(3):3 * 2 = 6,返回 6
实战注意:
- 递归深度过大会导致栈溢出(例如递归 10 万层)。
- 可以改用循环或尾递归优化(C++ 不保证优化)。
- 很多树形结构天然适合递归(如遍历目录)。
10. 二叉树(只讲概念,实战中用于层次化数据)
10.1 什么是二叉树?
二叉树是一种树形结构,每个节点最多有两个子节点,分别称为左子节点和右子节点。
10.2 为什么需要二叉树?
- 快速查找:二叉搜索树(BST)可以在 (O(\log n)) 时间内完成查找、插入、删除。
- 层次化数据:如文件系统、HTML 文档结构。
- 表达式解析:编译器用语法树表示表达式。
10.3 常见的遍历方式(概念)
- 前序遍历:先访问根节点,再递归遍历左子树,最后递归遍历右子树。
- 中序遍历:先遍历左子树,再访问根节点,最后遍历右子树。在二叉搜索树中,中序遍历得到升序序列。
- 后序遍历:先遍历左子树,再遍历右子树,最后访问根节点。常用于释放树的内存。
- 层序遍历:从上到下、从左到右逐层访问(用队列实现)。
实战应用:
- 前序遍历:复制一棵树。
- 中序遍历:输出排序后的元素(二叉搜索树)。
- 后序遍历:删除树、计算表达式值。
- 层序遍历:打印树的层次结构、求树宽。
(二叉树的具体代码实现会在后续课程中讲解,这里只需理解概念和用途。)
11. 动态数组 vector——实战中最常用的容器
11.1 什么是 vector?
vector 是 C++ 标准库提供的动态数组,可以自动扩容,支持随机访问,是处理可变长度数据的首选。
11.2 实战场景
- 存储不定数量的数据(如用户输入)。
- 作为数组替代品,避免手动管理内存。
- 与算法库配合,进行排序、查找等操作。
vector 常用操作速查表
| 操作 | 函数/方法 | 说明 | 时间复杂度 |
|---|---|---|---|
| 创建空 vector | vector<T> v; | 创建一个空的动态数组 | O(1) |
| 创建指定大小 | vector<T> v(n); | 创建 n 个元素,默认值为 T() | O(n) |
| 创建并初始化 | vector<T> v(n, val); | 创建 n 个元素,每个初始化为 val | O(n) |
| 列表初始化 | vector<T> v = {a,b,c}; | 用花括号列表初始化 | O(n) |
| 尾部添加元素 | v.push_back(x); | 在末尾添加一个元素 | 均摊 O(1) |
| 删除尾部元素 | v.pop_back(); | 删除末尾元素(无返回值) | O(1) |
| 访问元素(下标) | v[i] | 不检查边界,越界未定义 | O(1) |
| 安全访问 | v.at(i) | 检查边界,越界抛异常 | O(1) |
| 获取第一个元素 | v.front() | 返回第一个元素的引用 | O(1) |
| 获取最后一个元素 | v.back() | 返回最后一个元素的引用 | O(1) |
| 元素个数 | v.size() | 返回当前元素个数 | O(1) |
| 容量 | v.capacity() | 返回当前分配的内存可容纳元素数 | O(1) |
| 判空 | v.empty() | 返回是否为空 | O(1) |
| 清空 | v.clear() | 删除所有元素,size=0,capacity 可能不变 | O(n) |
| 预分配空间 | v.reserve(n) | 确保至少能容纳 n 个元素,避免多次扩容 | O(n) |
| 调整大小 | v.resize(n, val) | 改变 size 为 n,新增元素用 val 填充 | O(n) |
| 遍历(下标) | for (int i=0; i<v.size(); ++i) ... | 传统方式 | O(n) |
| 遍历(范围 for) | for (auto x : v) ... | C++11 简洁方式 | O(n) |
| 排序 | sort(v.begin(), v.end()) | 需包含 <algorithm> | O(n log n) |
| 反转 | reverse(v.begin(), v.end()) | 需包含 <algorithm> | O(n) |
| 二分查找(需排序) | lower_bound(v.begin(), v.end(), x) | 返回第一个 ≥ x 的迭代器 | O(log n) |
11.3 常用操作(逐行解释)
#include <vector>#include <iostream>using namespace std;
int main() { // 创建一个空的 vector,里面存放 int 类型的数据 vector<int> v1;
// 创建一个有 5 个元素的 vector,每个元素默认是 0 vector<int> v2(5);
// 创建一个有 5 个元素的 vector,每个元素初始化为 10 vector<int> v3(5, 10);
// 直接用花括号初始化,里面放 1,2,3,4 vector<int> v4 = {1, 2, 3, 4};
// 在 v1 的尾部添加一个元素 100 v1.push_back(100); // 再添加一个元素 200 v1.push_back(200);
// 删除尾部元素(删除 200) v1.pop_back();
// 通过下标访问元素(不检查边界,如果下标越界会导致未定义行为) int a = v1[0]; // a = 100 // 通过 at() 访问元素(会检查边界,如果越界会抛出异常) int b = v1.at(0); // b = 100 // 获取第一个元素 int first = v1.front(); // first = 100 // 获取最后一个元素 int last = v1.back(); // last = 100(因为只有一个元素)
// 获取当前 vector 中元素的个数 int size = v1.size(); // size = 1 // 获取当前 vector 的容量(已经分配的内存能容纳多少个元素,可能大于 size) int cap = v1.capacity(); // 可能大于 1 // 判断 vector 是否为空 bool empty = v1.empty(); // false,因为有一个元素
// 清空 vector 中的所有元素(size 变为 0,但容量可能不变) v1.clear();
// 预分配空间:确保至少能容纳 100 个元素,避免多次扩容 v1.reserve(100);
// 遍历 vector 的两种方式 // 方式1:用下标 for (int i = 0; i < v1.size(); i++) { cout << v1[i] << " "; } // 方式2:范围 for 循环(C++11) for (int x : v1) { cout << x << " "; }
return 0;}11.4 与算法库配合(排序、查找)
#include <algorithm>vector<int> v = {5, 2, 8, 1, 9};
// 排序:将 v 中所有元素从小到大排列sort(v.begin(), v.end()); // 现在 v 变成 {1, 2, 5, 8, 9}
// 反转:将 v 中所有元素的顺序颠倒reverse(v.begin(), v.end()); // 现在 v 变成 {9, 8, 5, 2, 1}
// 二分查找:返回第一个 >= 5 的位置(下标)int pos = lower_bound(v.begin(), v.end(), 5) - v.begin(); // pos = 2(因为 v[2]=5)实战注意:
- 频繁在中间插入/删除时,
vector效率低(O(n)),可考虑list或deque。 vector<bool>是特化版本,占用位而不是字节,可能不符合预期,一般用vector<char>代替。
12. 深度优先搜索(DFS)与广度优先搜索(BFS)——图/树的遍历
12.1 图的表示(邻接表)
实战中,图常用邻接表存储:vector<vector<int>> graph,graph[u] 包含所有与 u 相邻的顶点。
int n = 5; // 顶点数 0~4vector<vector<int>> graph(n);// 添加无向边 0-1graph[0].push_back(1);graph[1].push_back(0);12.2 深度优先搜索(DFS)——一条路走到黑
实战场景:
- 检测连通分量。
- 拓扑排序。
- 寻找路径。
- 回溯算法(如八皇后)。
递归实现(逐行解释):
// 创建一个布尔数组 visited,大小等于顶点数,初始全为 false// visited[u] 表示顶点 u 是否已经被访问过vector<bool> visited(n, false);
// DFS 函数,参数 u 是当前正在访问的顶点void dfs(int u) { // 标记当前顶点 u 为已访问 visited[u] = true; // 这里可以执行对 u 的操作,比如输出它 cout << u << " ";
// 遍历所有与 u 相邻的顶点 for (int v : graph[u]) { // 如果邻居 v 还没有被访问过,就递归访问它 if (!visited[v]) { dfs(v); } } // 当所有邻居都处理完后,函数返回,回溯到上一层}执行过程:从起点出发,一直深入直到无法继续,然后回溯到上一个节点继续。
非递归实现(用栈)(可选了解):
void dfsIter(int start) { // visited 数组记录是否访问过 vector<bool> visited(n, false); // 创建一个栈,存放待访问的顶点 stack<int> st; st.push(start);
while (!st.empty()) { int u = st.top(); st.pop(); // 取出栈顶元素 if (visited[u]) continue; // 如果已经访问过,跳过 visited[u] = true; // 标记已访问 cout << u << " "; // 处理当前顶点
// 将所有未访问的邻居压入栈 for (int v : graph[u]) { if (!visited[v]) st.push(v); } }}12.3 广度优先搜索(BFS)——层层推进
实战场景:
- 无权图的最短路径。
- 层序遍历树。
- 二分图判定。
队列实现(逐行解释):
#include <queue>void bfs(int start) { // visited 数组记录是否已访问 vector<bool> visited(n, false); // 队列,用来存储待访问的顶点,先进先出 queue<int> q; // 把起点放入队列 q.push(start); // 标记起点已访问 visited[start] = true;
while (!q.empty()) { // 取出队首元素 int u = q.front(); q.pop(); // 处理当前顶点 u(比如输出) cout << u << " ";
// 遍历 u 的所有邻居 for (int v : graph[u]) { // 如果邻居还没访问过 if (!visited[v]) { // 标记为已访问,并加入队列尾部 visited[v] = true; q.push(v); } } }}如果需要记录最短距离:
vector<int> dist(n, -1); // dist[u] 表示从起点到 u 的最短距离,-1 表示未访问queue<int> q;dist[start] = 0; // 起点到自己的距离是 0q.push(start);
while (!q.empty()) { int u = q.front(); q.pop(); for (int v : graph[u]) { if (dist[v] == -1) { // 如果 v 还没被访问过 dist[v] = dist[u] + 1; // 距离 = 父节点距离 + 1 q.push(v); } }}12.4 DFS vs BFS 实战选择
| 需求 | 推荐算法 |
|---|---|
| 找一条路径(不要求最短) | DFS(代码简单) |
| 找最短路径(无权图) | BFS |
| 检测是否有环 | DFS(用颜色标记) |
| 拓扑排序 | DFS(后序)或 BFS(Kahn) |
| 层序遍历树 | BFS |
| 穷举所有解(回溯) | DFS |
总结
本文从实战角度出发,详细讲解了 12 个 C++ 进阶知识点,每个都包含:
- 概念:是什么。
- 实战场景:在哪里用。
- 代码实现:每一步都有自然语言说明,逻辑清晰。
- 注意事项:避免踩坑。
学习这些知识后,你可以:
- 分析算法效率,写出高性能代码。
- 用前缀和快速处理区间查询。
- 用二分查找在有序数据中闪电定位。
- 用结构体管理复杂数据。
- 理解进制与编码,减少低级错误。
- 用位运算高效处理集合。
- 用动态规划解决最优化问题。
- 用栈处理嵌套结构。
- 用递归简化分治与回溯。
- 掌握二叉树概念,为树结构打基础。
- 用 vector 轻松管理动态数组。
- 用 DFS/BFS 遍历图与树。
下一步建议:动手实现这些代码,尝试修改参数、增加功能,遇到问题用调试器单步执行,观察变量变化。编程是实践的艺术,多敲代码才能真正内化为技能。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









