我最早被C++数据结构与算法这套东西折磨的时候,心里只有一个念头:为什么没有人把每条路踩过的坑直接写出来?后来刷题、写项目、带新人,慢慢摸出套路了,发现这东西本可以不那么劝退。数据结构是组织数据的骨架,算法是操作数据的手艺,C++是那个把底层细节都暴露给你的放大镜。这篇博客我准备从学习路线、手写数据结构、算法落地、C++语法细节、环境配置到排错心得,全捋一遍,适合正在学C++的大学生、准备笔试面试的求职者,以及想补内功的自学党。看完你至少能少走三个月弯路。
我默认你已经有最基础的C++语法概念,比如变量、函数、类和指针,哪怕有点模糊也没关系,后面用到的时候我会把上下文拉出来讲。如果你连这些都没见过,建议先花两周过一遍语法,再回来读这篇。
1. 入门先别急着刷题,把三件事想清楚
1.1 环境选型:Dev C++、VSCode还是Visual Studio
很多刚入门的朋友被环境劝退。打开搜索引擎,一眼全是Dev C++、VSCode、Visual Studio,根本不知道选哪个。我的建议很简单:
- 只做课程作业、应付考试、刚接触编程:Dev C++够了。它绿色轻量,点开就能写,很多高校机房预装的就是它。缺点是调试器难用,代码补全约等于没有,工程能力约等于零。
- 想正经刷题、写点稍大的练习、以后还要做项目:VSCode + MinGW。这套组合免费、轻、跨平台,配置一次之后非常顺。
- 想跑Windows原生开发、要用MFC/Windows API、愿意忍受启动慢:Visual Studio。它的调试器是三者里最强的,但学习和磁盘占用成本也最高。
我当年就是从Dev C++起步,写到链表反转的时候,想单步调试看指针变化,发现Dev C++的调试体验太膈应了,果断转VSCode。如果你已经决定长期在C++这条路上走,直接上VSCode,别在Dev C++上浪费太多时间。
实测下来VSCode配置一次大约二十分钟,网上教程很多,但版本差异容易踩坑。我建议直接在官方文档里找C/C++插件那部分,配合tasks.json和launch.json配置,别去抄三四年前的博客配置,编译器路径大概率对不上。
1.2 数据结构与算法到底在解决什么问题
用图书馆类比。书摆得乱七八糟,找一本《数据结构与算法》可能要翻一小时,这是“数据组织方式”的问题;图书馆有了编号规则,你还得知道怎么按编号快速定位,这叫“查找算法”;新书到了怎么插进去不影响已有顺序,这叫“插入算法”。数据结构就是把数据按照某种规则摆放,算法就是在一套规则下高效完成增删改查、排序、匹配这些事。
C++学数据结构有一个天然优势:指针和内存模型让你能看到“数据到底怎么在内存里串起来的”。Java、Python里你很少直接操作内存,但链表、树、图这些结构的本质就是指针的舞蹈。C++的引用、指针、栈上和堆上分配,把这些概念赤裸裸地摆在你面前,学明白一遍,再去学任何别的语言的数据结构,都是降维打击。
顺带提一嘴热词里常出现的“王道408”。那是计算机统考408的参考书,里面数据结构部分编得确实不错,脉络清晰,例题也贴近考试。如果你是为了考研,拿它当主线没问题;如果是为了工作面试,我建议把重心放在手写代码和复杂度分析上,别沉迷刷那种选择题。
1.3 一条我实测过很多次的学习路线
零基础开始,按这个顺序推进,每一步都动手写代码,而不是光看书:
- C++语法基础:类型、运算符、流程控制、函数、数组、结构体、类、指针/引用。目标是能用C++写出“输入一棵数组,输出逆序”这种小题目。
- 线性结构:动态数组、链表、栈、队列。每个结构都要手写一遍,然后用STL写一遍。
- 树与图:二叉树、二叉搜索树、堆、平衡树概念、图的邻接矩阵和邻接表、DFS/BFS。
- 排序与查找:冒泡、选择、插入、快排、归并、堆排序,以及二分查找的各种变体。
- 字符串与模式匹配:朴素匹配、KMP。
- 递归、回溯、动态规划:这部分是算法的分水岭,前期数据结构是工具,到了这里开始真正玩状态和策略。
每阶段配20道左右的题。别贪多,以彻底理解为目标。我见过太多人数据结构看到树,链表还没写熟就去刷LeetCode,结果两边都没学扎实,回头还得补。
2. 手写数据结构:把内存和指针玩明白
2.1 数组、链表、栈、队列:最容易被忽视的边界细节
数组是连续内存,链表是离散内存加指针串联,所以数组支持O(1)随机访问,链表插入删除只需改动指针。这是最基础的区别,但很多人写代码时依然会出错。
手写单链表时,我最推荐三个技巧。第一个是哑结点(dummy node),在头结点前固定放一个哨兵节点,这样删除头结点和删除中间节点的逻辑就能统一,不用单独判断“如果删的是第一个节点”。第二个是头插法做逆序时,记得先保存next指针再改连接,不然链表直接断掉。第三个是反转链表,经典三指针prev、cur、next逐个翻转,这个操作面试高频,建议背到条件反射。
栈和队列其实只是操作受限的线性表。栈的特点是后进先出(LIFO),队列是先进先出(FIFO)。用数组模拟栈很简单,一个top下标就行;用数组模拟循环队列,要处理front和rear的循环追赶问题,核心是取模运算,长度通常设为n+1而不是n,用来区分空和满。
写链表最容易翻车的场景:在循环里把p = p->next写成了p->next = p。前者是往前走,后者是把自己指向自己。调试时一旦发现程序卡死或打印超长,第一反应查这类自指问题。
2.2 二叉树、堆与二叉搜索树:递归思维的主战场
二叉树是递归结构,树的每个子树又是一棵树,所以几乎所有二叉树操作都能用递归优雅地写出来。前序、中序、后序遍历的递归版本各三五行的样子,但面试里更常考非递归版本,因为要用栈模拟系统调用栈。
层序遍历(BFS)的代码模式非常经典:维护一个队列,根节点入队,然后循环:出队一个节点、处理它、左孩子入队、右孩子入队。这个模式吃透了,后面图的BFS、拓扑排序的队列版本、二叉树的右视图等等都是一脉相承。
堆是“用数组表示的完全二叉树”。大根堆的任意节点都大于等于它的孩子,所以堆顶一定是最大值。数组下标i的左右孩子分别是2i+1和2i+2,这个映射关系是堆操作的基础。插入时上浮,删除堆顶时把最后一个元素移到堆顶再下沉。堆排序就是反复取出堆顶并调整,时间复杂度稳定O(nlogn),但空间可以做到O(1)。优先队列(priority_queue)底层就是堆。
二叉搜索树(BST)的中序遍历结果是有序序列,这是它最漂亮的特性。但BST有一个先天缺陷:如果按有序序列插入,树会退化成长链表,所有操作退化到O(n)。所以实际工程里用的是红黑树、AVL树这种自平衡结构。理解BST的插入、删除逻辑后,再去研究平衡旋转会顺很多。
2.3 图论基础:邻接矩阵与邻接表的建模思维
图是很多实际问题的抽象:社交好友关系、地图导航、依赖关系都可以画成图。图的两种存储方式各有适用场景。
邻接矩阵用二维数组记录任意两点是否相连,查询两点之间是否有边是O(1),但空间是O(V^2),适合稠密图。邻接表对每个顶点维护一个链表或vector记录邻居,空间O(V+E),适合稀疏图。实际算法竞赛和工程里,邻接表写得多,因为大部分实际问题都是稀疏图。
图的遍历就两个框架:DFS用递归或栈,BFS用队列。DFS是一条路走到黑再回头,适合找路径、判断连通分量;BFS层层推进,天然能求无权图的最短路径。我练图算法时最大的体会是:不要试图背每个题的做法,而是把“DFS递归框架”和“BFS队列框架”吃透,后面你遇到A*寻路、拓扑排序、强连通分量,会发现都是在框架基础上加状态和剪枝。
2.4 哈希表与散列冲突:工程选型的关键理解
哈希表的核心是哈希函数加冲突处理。哈希函数把key映射成数组下标,理想情况下每个key对应唯一位置,但现实中不可避免冲突。两个经典解法是开放定址法和链地址法。
链地址法最简单:数组每个位置挂一个链表,冲突的key都挂在同一个链表上。负载因子(元素个数/桶数)越高,链表越长,效率越差,所以工程上哈希表会在负载因子超过阈值时扩容。开放定址法在冲突时往后找空位,删除操作比较麻烦,所以应用场景更窄。
手写一个简单哈希表会让你突然明白很多事:为什么unordered_map的key要求可哈希;为什么自定义结构体做key需要提供哈希函数;为什么扩容时所有元素要重新计算下标而不是直接复制。这些细节在刷题时不会遇到,但面试问底层原理、或者工程里遇到性能瓶颈时,就是拉差距的地方。
3. 核心算法与工程落地:排序、查找、字符串匹配
3.1 排序算法选型与实现陷阱
排序是算法入门必修课。冒泡排选择排序思路简单,但平均时间复杂度O(n^2),只适合小规模数据或教学演示。真正生产环境常用的排序是快速排序、归并排序和堆排序,我整理了一个对比表:
| 算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 快速排序 | O(n log n) | O(n^2) | O(log n) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
快速排序的实现有个大坑:选pivot的方式。如果固定选区间最后一个元素,而输入恰好是已排序数组,每次partition都极度不平衡,递归深度退化到O(n),最坏时间复杂度变成O(n^2)。工业级实现通常用三数取中或随机选pivot来规避。
归并排序的稳定性让它很有价值。稳定性指相同元素的相对顺序在排序后不变。比如按成绩排序,同分的按学号排,这种场景稳定排序就有意义。归并排序空间开销大,但它特别适合链表排序——链表不需要额外空间做数组归并,改指针就行。STL的stable_sort底层就有归并排序的思想。
堆排序实现起来细节多,但它的价值不仅在于排序本身,更在于“动态维护最值”的场景。比如一个不断插入新元素、随时要取前K大的场景,用堆比每次都排序高效得多。
建议把快排、归并、堆排序各手写五遍以上,写到闭着眼能写出来的程度。不是要背代码,而是要把partition、merge、heapify这些子过程内化成肌肉记忆。笔试时这些算法是随手起手式,不是思考题。
3.2 二分查找与边界问题的“死循环陷阱”
二分查找的代码只有十几行,但写对并不容易。最常见的错误是死循环和越界。
死循环的根源是区间划分不清。推荐统一使用左闭右开区间:[left, right)。初始left=0, right=n。循环条件是while(left < right),中点mid = left + (right - left) / 2。如果mid位置的值小于目标,说明目标在右半区间,left = mid + 1;否则right = mid。这个写法有个好处:mid不会等于right,所以right更新成mid不会造成死循环。
很多岗位笔试喜欢考二分查找的变体:旋转排序数组找目标、查找第一个等于目标的元素、查找最后一个小于目标的元素。这些题的本质是对二分框架的理解,而不是背题。你在一个有序数组中查找“第一个满足某条件的元素”,这就是STL中lower_bound做的事情。
二分查找不仅用在有序数组上。凡是“单调关系”能成立的场景都可以考虑二分:比如答案是整数且能判断某个候选值是否可行,就用二分答案法。这个技巧在实际编程题里出场率极高。
3.3 字符串匹配与KMP:next数组的本质
朴素字符串匹配在母串中逐个位置尝试匹配子串,一旦失配就右移一位重新开始,最坏时间复杂度O(n*m)。KMP的改进在于失配时利用已知信息,让子串指针不是回退到开头,而是跳到“最长相等前后缀”的位置。
理解“最长相等前后缀”是理解KMP的唯一钥匙。拿子串"ababc"举例,它的前缀集合是{"a","ab","aba","abab"},后缀集合是{"c","bc","abc","babc"},看最长相等的部分。next数组的每个值就代表:如果当前字符失配,我应该把模式串的指针回退到哪个位置。
求解next数组的代码本质是“自己匹配自己”。这个过程我第一次看的时候完全看不懂,后来手动画了三次状态转移才明白。动手画一遍比看十遍博客都有用。
面试里考KMP手写的话,只要写出next数组的构造和匹配主循环,加上“最长相等前后缀”的解释,基本就稳了。不要慌着背诵,把为什么next[i]表示“失配时跳转的位置”讲清楚,面试官会更认可你的理解深度。
3.4 递归、回溯与动态规划:从暴力到最优的思维跃迁
递归是最符合人类直觉的算法思维:把大问题拆成同类子问题。斐波那契数列是入门的递归题,但直接递归的指数级复杂度注定不可取,于是有了记忆化搜索(把重复子问题的结果存下来)。记忆化搜索再往前走一步,把递归改成自底向上的循环,就是动态规划。
动态规划的三要素是状态定义、转移方程、初始化与边界。我拿爬楼梯来说明:状态dp[i]表示爬到第i阶有几种方法,转移方程dp[i] = dp[i-1] + dp[i-2],边界dp[0]=1, dp[1]=1。这个例子简单到甚至有人觉得不值一提,但“状态、转移、边界”这三件事就是所有DP题的骨架。
真正拉开差距的是回溯算法。回溯的本质是“选择-深入-撤销选择”。全排列、组合求和、N皇后都是经典回溯模板:
void backtrack(vector<int>& path, vector<bool>& used, vector<vector<int>>& res, vector<int>& nums) { if (path.size() == nums.size()) { res.push_back(path); return; } for (int i = 0; i < nums.size(); i++) { if (used[i]) continue; used[i] = true; path.push_back(nums[i]); backtrack(path, used, res, nums); path.pop_back(); used[i] = false; } }“撤销选择”这一步经常被新手遗漏,一旦漏掉,结果就会指数级膨胀或者错误。写回溯题时要在内心默念:选它、递归、撤销、换下一个。
4. C++语法细节在算法实现中的作用
4.1 结构体重载运算符与自定义比较规则
刷题时经常需要对自定义结构体排序,比如“按照分数的降序,相同分数按学号升序”。STL的sort默认用小于运算符,所以你必须告诉它比较规则。两种做法:
第一种是重载结构体的小于运算符:
struct Node { int score; int id; bool operator<(const Node& other) const { if (score != other.score) return score > other.score; // 分数高的排前面 return id < other.id; // 分数相同时学号小的排前面 } };第二种是写一个仿函数或lambda传给sort。用lambda最简洁:
sort(v.begin(), v.end(), [](const Node& a, const Node& b) { if (a.score != b.score) return a.score > b.score; return a.id < b.id; });这个细节的重要性在于:很多人知道sort要传比较函数,但不知道set和map的底层是红黑树,它们也需要比较规则,而且是在构造容器时传入。同一个比较逻辑在sort、set、map里写法略有不同,能统一则统一,避免排序结果与预期不符的隐秘bug。
4.2 动态内存管理与智能指针的取舍
手写链表和树的时候,经典做法是new一个节点,用完delete。但new和delete配对这件事,人脑很容易出错。算法题里用裸指针倒还好,程序短、退出时内存由操作系统回收;工程化的代码就危险了,异常抛出、提前return都会导致delete被跳过,内存泄漏就这么来的。
C++11开始,智能指针对这个问题给出了很好的解。unique_ptr独占所有权,不许拷贝,只许移动;shared_ptr共享所有权,引用计数归零时自动释放。如果你写的数据结构要长期保存,优先用unique_ptr替代裸指针。刷题时我用裸指针多一些,因为写起来快,但我会在内心清楚:这是练习代码,不是产品代码。
传参建议:函数参数如果是只读的,用const引用;如果只是改这个变量本身,能用值传递就用值传递(代码清晰);如果需要修改外部对象,用引用或指针。算法题里最常见的问题是传值导致复制了一整个vector,性能白白浪费。
4.3 final、static、const到底怎么用
这几个关键字在算法题里不常出现,但在面经里是高频八股,这里顺便讲透。
static修成员变量时,这个变量属于类而不属于某个对象,所有对象共享同一份。static修饰局部变量时,变量在程序生命周期内只初始化一次,比如递归函数中想统计调用次数,就可以用static。
const修饰成员函数表示这个函数不会修改对象的成员变量。它写在函数名后面,形如void print() const。这个语法看着怪,但语义很关键:一个const对象只能调用const成员函数。
final用于类和虚函数。类上加final表示这个类不能作为基类被继承,虚函数上加final表示子类不能重写该虚函数。现代C++工程里这个关键字就像开关和锁,明确表达设计意图,防止别人误继承。
4.4 STL容器与算法竞赛的效率细节
vector是动态数组,底层是连续内存。它扩容的时候会allocator分配一个更大的内存,把旧元素全部挪过去,然后释放旧内存。这个过程O(n),如果频繁往尾部push_back,均摊下来总代价还是O(1),但每次扩容瞬间会有卡顿。提前调用reserve可以一次性预留容量。
再提一个很多人不知道的点:v.emplace_back(args)比v.push_back(构造参数列表)少一次临时对象的构造和移动。在性能敏感的场景下,push_back会先构造一个临时对象再移动进容器,emplace_back直接在容器内构造,省了一次移动。同时,循环里遍历能用引用就用引用:for (auto& x : v)而不是for (auto x : v),前者不会复制整个元素。
priority_queue默认是大根堆,要小根堆时传greater<>。set和map底层是红黑树,支持有序遍历,但常数大;unordered_set和unordered_map底层是哈希表,均摊O(1)但无序。我刷题时遵循一个简单原则:需要有序用set/map,需要去重或判断存在用unordered_set,需要最大最小值用priority_queue。
5. 环境配置与工程化流程
5.1 VSCode里配置一套能调试的C++环境
VSCode配C++开发环境这个热词常年居高不下,说明很多人都卡在这里。其实核心就是两个文件:tasks.json负责编译构建,launch.json负责启动调试。
一个最小可用的tasks.json长这样:
{ "version": "2.0.0", "tasks": [ { "label": "build", "type": "shell", "command": "g++", "args": [ "-g", "-std=c++17", "${file}", "-o", "${fileDirname}/${fileBasenameNoExtension}.exe" ], "group": "build", "problemMatcher": ["$gcc"] } ] }launch.json要配置调试器路径、程序路径和preLaunchTask。如果你用的MinGW,路径里一般有“mingw32-gdb.exe”或“gdb.exe”。配置好之后,按F5就能单步调试,观察递归调用栈、查看指针指向的变量值——这一步对学好数据结构的价值无可替代。
如果你用Windows,我强烈建议编译器用MinGW-w64(从mlogin或winlibs下载),不要用老旧的Dev C++自带的TDM-GCC 4.9。版本太老会缺很多C++17特性,比如std::filesystem没法用。
5.2 Windows下编译器、运行库与Visual Studio的关系
热词里有“Visual C++ Redistributable”,很多人把编译器和运行库混为一谈。简单说:编译器把源码变成可执行文件,运行库在程序跑起来时提供底层支持(比如new/delete、标准库实现)。你用Visual Studio编译的程序,目标机器上没有对应版本的运行库是跑不起来的,官网那个Redistributable安装包就是解决这个问题的。
MinGW用的是GCC工具链,编译出的程序在Windows上运行时依赖libgcc、libstdc++等动态库,所以有时你要把几个dll一起拷走。如果不想带一堆dll,可以加-static-libgcc -static-libstdc++静态链接。
5.3 从单文件刷题到多文件工程:CMake的必要一跃
算法练习基本都是单文件,一个cpp写完所有逻辑。但真实项目不这样,头文件、源文件、测试文件分离,还涉及第三方库链接。这时候CMake是标准答案。
一个最小CMakeLists.txt:
cmake_minimum_required(VERSION 3.10) project(MyAlgoProject) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(main main.cpp sort/quick_sort.h sort/quick_sort.cpp ) target_include_directories(main PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})编译流程是:cmake -S . -B build,然后cmake --build build。配好一遍就能持续用。我建议刷题刷到树和图阶段,就把数据结构封装成独立模块,用CMake组织起来,后续的调试和扩展都会顺滑很多。
6. 调试、排错与笔试实战经验
6.1 编译错误、运行错误、逻辑错误三类问题的定位
编译错误是最好解决的,编译器会告诉你文件行号和具体错误信息。新手最常见的困惑是看不懂报错,我建议直接看第一个error,别管后面几十行“note”和“candidate”,往往第一个错解决了,后面的都自动消失。
运行错误里最臭名昭著的是段错误。它百分之七八十是因为访问了非法内存:野指针解引用、数组越界、对空指针调用成员函数。排查手段从易到难:先检查所有数组下标是否有界,再检查指针是否经过初始化,最后用调试器定位崩溃点。Windows下VSCode调试器会直接告诉你崩溃在哪个函数哪一行,比printf大法快太多。
逻辑错误最隐蔽:程序能跑,结果不对。这时别瞎试,先构造小规模样例,手算期望输出,再用调试器单步看变量变化。最有效的技巧是“二分排查法”:把算法输出中间过程的每一阶段,找到从哪一步开始和手算不一致。
6.2 递归函数出错的定位技巧
递归出错通常有两种:栈溢出和结果错误。栈溢出多是因为递归深度太大或缺少终止条件。合法的递归深度一般有限制(比如1e6次调用就容易爆栈),这时要么改用迭代,要么把递归改写成尾递归。结果错误则大概率是状态在递归过程中被错误修改了。
排查递归问题我有个习惯:在函数入口打印参数和环境信息,比如“当前node的值、递归深度depth”。打印层数缩进,一眼就能看出递归树长什么样。如果发现某个状态被反复访问但结果矛盾,八成是状态变量被全局共享了,改成一个参数传下去即可。
6.3 从“过了样例”到“通过所有测试”的最后一公里
刷题平台最打击人的就是“样例过了,一提交错一片”。根因是样例只覆盖了常规形态,边界情况全没测到。我总结了一套边界检查清单:
- 空输入:数组为空、链表为空、树为空
- 单元素输入:只含一个元素的数组/链表
- 全相同元素:所有值相同,排序和去重时特别容易错
- 已有序序列:正序、逆序,快排和二分最容易在这暴露
- 极端值:INT_MAX、INT_MIN,看是否有溢出
- 大输入规模:能不能在规定时间内跑完
做题时先把这套清单过一遍再提交,通过率能明显提升。尤其是“已有序序列”和“全相同元素”,快排partition和二分查找是重灾区。
6.4 对C++八股和“面经”的正确态度
热词里的“C++八股”指那些高频面试题,比如虚函数表、智能指针、const/static/final的区别。我的观点是:纯记忆性八股没有意义,但如果面试官深挖你到底懂不懂,或者你在工程里写C++,这些内容恰恰是对语言底层机制的理解。
比如虚函数表(vtable)是什么?简单说,有虚函数的类会生成一张函数指针表,对象里有一个指向该表的虚指针。多态调用时,运行时查表找到实际应该调用的函数。这个机制解释了为什么virtual函数有动态绑定、为什么析构函数推荐public virtual(避免基类指针delete时漏调子类析构)。数据结构里的多态链表、工厂模式都依赖这些基础。
学数据结构和算法时,多问一个“为什么这样设计”,就把八股变成了内功。为什么STL的sort在某些情况下会退化成插入排序?因为快排在数据量小于16时递归开销巨大,插入排序常数小反而快。这种细节,背是背不完的,理解才能融会贯通。
7. 让这条学习路走得更远的几点实在建议
说点个人心得。见过太多人倒在“看了两个月书还在看第一章”这个魔咒里,原因是把看课和看书当成了学习本身。数据结构和算法是手艺活,手艺必须靠动手。看视频觉得懂了,手一写就卡壳,这太正常了,恰恰说明输入到输出之间还缺少大量练习的桥。
我自己的节奏是每学一个结构,立刻手写一个对应的小练习。学完链表,写一个多项式加法;学完队列,写一个约瑟夫环;学完树,把表达式树的前缀中缀后缀转换玩明白;学完图和堆,试试A*寻路算法做一个小地图。这些小项目能持续制造成就感,这是我扛过瓶颈期的核心方法。
再往后,如果你想做点更有意思的东西,可以试试用C++写点小游戏——贪吃蛇、俄罗斯方块、控制台版的简单寻路。这些题目听着和“数据结构与算法”没关系,真正做起来你会发现里面全是链表、队列、坐标状态、碰撞检测这些基础结构。
最后分享一个小技巧:写博客或者写笔记。不一定发到公开平台,就算写在自己的本地笔记里也有用。每学完一个算法,用你自己的话把思路讲一遍,比刷十道题更能检验是否真的懂了。那些难以用语言描述清楚的部分,正是你理解还薄弱的地方,回头补课效率会高很多。