说实话,写了这么多年C++,面试过不少人,也带过一些刚入门的朋友,几乎所有人对“线性表”的态度都出奇一致:太基础了,跳过去吧。结果一提问就露馅——顺序表的插入删除为什么是O(n)?链表头节点加不加哨兵有什么区别?快慢指针怎么证明不会死循环?这块要是没想透,后面的栈、队列、树、图全都摇摇欲坠。这篇就是系列的第一讲,专门把线性表这个地基彻底说清楚。
先说明白这篇适合谁看。如果你刚开始学数据结构,代码还在抄书阶段,那这篇可以帮你把底层逻辑理顺;如果你已经在刷题或者准备面试,这篇也能帮你把一些“知其然不知其所以然”的细节补齐。我会从线性表的定义讲起,重点放在顺序表和链表的C++实现细节、复杂度分析、以及我实际踩过的坑上。该给的代码我直接贴,该解释的原理我不绕弯。
1. 线性表不是“数组加链表”这么简单
1.1 线性表到底在说什么
很多人学完数据结构,对线性表的理解就是“数组和链表”。这话没错,但太粗糙了。线性表不是一个具体的存储结构,而是一种逻辑结构:n个数据元素的有限序列,元素之间是一对一的线性关系。这里的“线性”指的就是每个元素至多有一个直接前驱,至多有一个直接后继。听起来像废话,但这就是后面所有操作的理论依据。
举个例子。一个学生信息管理系统,里面存着学号、姓名、成绩。这些数据在逻辑上就是一个线性表:学号2024001的记录后面跟着2024002,不会有哪个记录同时挂在两条记录后面。至于底层是拿一段连续内存存(顺序表),还是拿节点串起来存(链表),那是存储结构的事。逻辑结构和存储结构分离,这是理解一切数据结构的前提。
很多人写代码时纠结“该用vector还是list”,其实就是把逻辑问题与物理问题混在了一起。先问自己:业务上需不需要频繁在中间插入删除?再问:我能不能接受扩容时的拷贝开销?这两个问题想清楚了,选择自然就出来了。这篇文章后面会专门对比这两种存储方案的取舍标准。
1.2 两大存储阵营:一段连续内存 vs 一串独立节点
顺序表说白了就是一块连续的内存空间,数据一个挨一个放着。C++里最常见的形态就是原生数组和std::vector,当然你也可以用new一块堆内存自己管理。连续内存带来两个核心收益:随机访问O(1),缓存命中率极高。因为CPU在加载一个元素时会把相邻的内存也装进缓存,顺序遍历时性能非常可观。
链表就完全反过来。每个节点单独分配,节点之间用指针连接,逻辑上相邻的两个元素在物理上可能隔着十万八千里。好处是插入删除只要改指针,不需要搬动数据;坏处是你要访问第k个元素,只能从头一个个跳过去,O(n)的时间成本躲不掉。而且每个节点还要额外存一个指针,内存开销天然比顺序表大。
这两者没有绝对的好坏,取决于你用在哪里。我在实际项目里见过有人用std::list存一个需要频繁随机访问的配置表,结果每次查找都是从头遍历,性能惨不忍睹。也有反过来的,用vector硬扛高频中间插入,插入一次挪动几万个元素,直接卡死线上服务。工具本身不背锅,选错工具才要命。
1.3 为什么线性表是数据结构的第一个坎
因为线性表几乎是所有高阶结构的骨架。栈是受限的线性表,队列是受限的线性表,字符串是字符组成的线性表。树和图的存储表示里,邻接表本质上就是一组链表。哪怕是哈希表,解决冲突时拉链法用的也是链表。你如果线性表没学扎实,后面学什么都是在沙子上盖楼。
还有一层原因:线性表的实现细节里浓缩了C++最核心的工程问题。动态扩容考的是内存管理,插入删除考的是元素搬移与指针操作,析构函数考的是内存释放。这些恰恰是面试和工作中最容易翻车的地方。我见过很多“会写”链表的人,却说不清为什么删除节点时要先保存后继指针。这种理解上的空洞,往往会在一两道题里就暴露得干干净净。
2. 顺序表:把“挨着坐”这件事做到极致
2.1 顺序表的结构设计和三个核心字段
先写一个完整的动态顺序表骨架。我能不用vector就不用,因为vector封装了太多细节,掩盖了顺序表真正的工作方式。自己用原生指针实现一遍,才能看清扩容、搬移、释放这三件事的本质。
template <typename T> class SeqList { private: T* data; // 指向堆上分配的连续内存 int capacity; // 当前容量 int size; // 当前元素个数 public: SeqList() : data(nullptr), capacity(0), size(0) {} explicit SeqList(int initCap) { data = new T[initCap]; capacity = initCap; size = 0; } ~SeqList() { delete[] data; data = nullptr; capacity = 0; size = 0; } bool isEmpty() const { return size == 0; } int length() const { return size; } void pushBack(const T& val) { if (size >= capacity) { expand(); } data[size++] = val; } void expand() { int newCap = capacity == 0 ? 4 : capacity * 2; T* newData = new T[newCap]; for (int i = 0; i < size; ++i) { newData[i] = data[i]; } delete[] data; data = newData; capacity = newCap; } T& operator[](int index) { // 实际工程中这里应该有越界检查 return data[index]; } bool insert(int pos, const T& val) { if (pos < 0 || pos > size) return false; if (size >= capacity) expand(); for (int i = size; i > pos; --i) { data[i] = data[i - 1]; } data[pos] = val; ++size; return true; } bool remove(int pos) { if (pos < 0 || pos >= size) return false; for (int i = pos; i < size - 1; ++i) { data[i] = data[i + 1]; } --size; return true; } };三个字段缺一不可。data指向真正存储数据的内存块,capacity表示这块内存最多能装多少个元素,size表示当前已经用掉了多少。初学者最容易犯的错就是把capacity和size当成一回事。capacity是“这间教室有多少座位”,size是“现在坐了多少人”。扩容不是因为你坐了超过教室容量的人,而是因为你想继续加人但座位不够了。
2.2 插入和删除:挪数据才是灵魂
顺序表的核心操作就是insert和remove,代价全在元素搬移上。插入时,从最后一个元素开始,逐一往后挪一个位置,腾出目标位置再赋值。为什么要从后往前挪?因为从前往后会覆盖还没处理的数据。这个细节我几乎每次都会被问到,回答“因为从前往后会覆盖数据”就够了。
删除则是反过来的:从删除位置的下一个元素开始,逐一往前挪,把洞填上。注意这里不需要把最后一个位置的数据清空,只需要让size减一。下次写入时自然会被覆盖。有些同学非要delete一下,那是多此一举,还可能导致运行时开销。
时间复杂度的关键在于分析循环次数。插入到位置i时,需要搬移size-i个元素。如果等概率插入到任意位置(包括表尾),平均搬移量是n/2,所以平均时间复杂度O(n)。很多人好奇:为什么pushBack明明是O(1)却有人说顺序表插入是O(n)?因为插入到中间和插入到尾部是两种不同场景,讨论复杂度必须说明是哪个操作。pushBack均摊下来O(1),insert(i, val)则是O(n)。面试时把这个区分讲清楚,比背一堆结论有用得多。
2.3 动态扩容的策略与均摊分析
扩容是顺序表最关键的机制之一。我的实现里用了最经典的两倍扩容:新容量等于旧容量的两倍,重新分配内存,逐个拷贝旧元素到新内存,然后释放旧内存。为什么是两倍而不是加固定大小?这背后是均摊分析的经典结论:每次扩容拷贝n个元素,而扩容频率大约是每n次pushBack一次(因为容量翻倍),总代价摊到每个pushBack上只有常数级别,均摊O(1)。如果每次只扩一个位置,那每次pushBack都要整个搬一遍,复杂度退化成O(n)。
扩容时还有个隐蔽的坑:new T[newCap]会先调用T的默认构造函数,然后再逐个赋值,这意味着T必须可默认构造。如果你的T没有默认构造函数,编译直接报错。更优雅的做法是用operator new分配原始内存 + placement new构造对象,但那是进阶话题。工程实践中如果你用std::vector就不用操心这些,vector内部通过allocator机制解决了这个问题。自己写顺序表更多是为了理解原理,真实项目里优先考虑标准容器。
另一个坑是异常安全。如果T的拷贝构造函数在扩容途中抛异常,旧数据还在原内存里,但new出来的新内存还没来得及释放,会造成内存泄漏。严格的实现应该用try-catch包裹拷贝循环,失败时先delete[]新内存再往上抛。面试能提到这个层面,基本就把多数候选人甩开了。
2.4 顺序表实战中那些不得不说的事
先说容量初始化。我给的是一个很保守的默认值4,实际项目里要根据业务预估量来定。如果你知道大概要存十万条记录,直接初始化成十万的容量,省掉中间好几次扩容拷贝。容量太小频繁扩容伤性能,容量太大一次性占用内存也不划算。稳妥的做法是设置增长因子,比如1.5倍或2倍,然后配合一个最小初始容量。
再说越界检查。上面的代码里operator[]没做越界检查,这是为了极致的性能。生产环境里我一般会加一个at()方法,越界时抛出异常,让调用方早发现早改。Debug模式下开启断言,Release模式下关掉,两头兼顾。这是C++工程里很典型的思路:性能和安全之间找到一个平衡点,而不是二选一。
还有析构函数里的delete[]。为什么必须用delete[]而不是delete?因为new[]申请的内存块是“数组版”,delete[]会正确调用每个元素的析构函数。如果你写成delete,只有第一个元素会被析构,剩下的直接内存泄漏。这问题老生常谈,但架不住真的有人写错。我审查代码时看到这种bug的概率比想象中高不少。
3. 链表:指针串起来的玩具,拆开全是细节
3.1 万变不离其宗的单链表
单链表是最基础的链式结构。每个节点存一个数据域和一个指针域,指针指向下一个节点。最后一个节点的指针指向nullptr,作为链表结束的标志。C++里最常见的写法是结构体套指针:
struct ListNode { int val; ListNode* next; explicit ListNode(int x) : val(x), next(nullptr) {} };这里有个重要区别:带不带头节点。带头节点的链表里,头节点是固定的哨兵,不存储有效数据,真正的第一个数据节点在头节点之后。不带头节点的链表则直接让头指针指向第一个数据节点。为什么要设哨兵头节点?因为这样在头部插入、删除时,操作逻辑与中间节点完全一致,不需要单独维护头指针。尤其是删除节点时,带头节点能避免“删除的是头节点”这种特判。
我个人的建议是:刷题和工程中尽量带头节点。虽然多占了一个节点的内存,但对代码逻辑的统一性帮助极大。面试手写链表时如果不带头节点,插入删除必须处理头指针可能被修改的情况,很容易在边界条件上翻车。下面的实现我都采用带头节点的写法,你可以感受一下代码流畅度。
3.2 单链表插入、删除、反转的完整实现
插入的核心就是先把新节点接好后路,再断前路。顺序不能反。先执行newNode->next = prev->next,再执行prev->next = newNode。如果把前路的断线先做了,新节点就找不到后路了,整条链表就断了。这是单链表操作的第一大铁律。
删除的套路是用一个pre指针始终指向当前节点的前驱。从头开始找到目标,然后执行pre->next = target->next,再delete target。这里要特别提醒:释放内存前必须先保存target->next,否则你先delete再把指针取出来读就是访问野指针。
class LinkedList { private: ListNode* dummyHead; int size; public: LinkedList() { dummyHead = new ListNode(0); size = 0; } ~LinkedList() { ListNode* cur = dummyHead; while (cur) { ListNode* next = cur->next; delete cur; cur = next; } } bool insert(int index, int val) { if (index < 0 || index > size) return false; ListNode* pre = dummyHead; for (int i = 0; i < index; ++i) { pre = pre->next; } ListNode* newNode = new ListNode(val); newNode->next = pre->next; pre->next = newNode; ++size; return true; } bool remove(int index) { if (index < 0 || index >= size) return false; ListNode* pre = dummyHead; for (int i = 0; i < index; ++i) { pre = pre->next; } ListNode* target = pre->next; pre->next = target->next; delete target; --size; return true; } void reverse() { ListNode* pre = nullptr; ListNode* cur = dummyHead->next; while (cur) { ListNode* next = cur->next; cur->next = pre; pre = cur; cur = next; } dummyHead->next = pre; } };迭代反转是最容易写错的地方。核心是三个指针:pre跟踪已反转部分的头,cur是当前正在处理的节点,next保存cur的后继。每次循环里先把next记下来,再把cur指向pre,然后三个指针整体后移。你看,又是“先保存后继”这个铁律。可以说链表的每个关键操作都绕不开这一条,想明白一次,终身受用。
我见过一些同学递归写反转很顺溜,迭代反而卡壳。这是因为递归版本代码很短,背下来了,但不知道展开过程。建议你把链表画出来,用一张纸一支笔手动模拟一遍迭代反转。三个指针怎么走、什么时候断链、什么时候续上,走一遍就通了。这个钱(时间)值得花。
3.3 双链表和循环链表的C++实现要点
双链表在单链表基础上加了一个prev指针,每个节点都能直接访问前驱,删除节点时不需要借助前驱指针了。代价是每个节点多一个指针的开销,插入删除时更新的指针数量翻倍。经典的双链表节点结构:
struct DListNode { int val; DListNode* prev; DListNode* next; explicit DListNode(int x) : val(x), prev(nullptr), next(nullptr) {} };双链表的插入要更新四个指针:新节点的prev和next,前驱的next,后继的prev。顺序很关键:先设置新节点的两个指针,再修改前驱的next和后继的prev。如果先改前驱的next,新节点就拿不到原来的后继了。删除则更简单:cur->prev->next = cur->next; cur->next->prev = cur->prev; 然后delete cur。两句就能搞定,这也是双链表相对单链表在删除上的核心优势。
循环链表则把尾节点的next指向头节点(或第一个节点),形成一个环。注意循环链表没有天然的nullptr终止条件,遍历时一定要设置停止标志,否则就是死循环。有一种常见变体是双向循环链表,头节点的prev指向尾节点,尾节点的next指向头节点。这种结构在LRU缓存、操作系统任务队列里大量使用。STL的std::list底层就是一个双向循环链表。
循环链表最经典的一个问题是约瑟夫环。n个人围成一圈,从第一个人开始报数,每次数到m的人出列,问最后留下的是谁。一个模拟实现的思路就是循环链表:遍历到第m个节点就删除,继续从头数。不需要数组的挪动,逻辑上非常自然。
3.4 链表经典算法:快慢指针与链表判环
快慢指针是链表问题里最常用的技巧。声明两个指针,慢指针每次走一步,快指针每次走两步。如果链表无环,快指针会先走到nullptr;如果有环,快指针最终会追上慢指针。这是判断链表是否有环的经典方案。
为什么不写成快指针走三步?因为步长太大可能直接跳过慢指针所在节点,导致永远碰不上。步长差为1时,两个指针的相对速度是1步,只要链表有环就一定会追上。环的数学性质是:让快指针一次走两步,慢指针走一步,如果存在环,两者必然在有限步内相遇。这个证明可以用追及模型理解:想象跑圈,慢的在前,快的在后,快的每次逼近一步,最终必然追上。
找到相遇点后,如果要找环的入口,还有一个推导过程:从链表头到环入口的距离等于相遇点到环入口的距离(在特定假设下)。具体做法是让两个指针分别从链表头和相遇点同时出发,都是每次一步,相遇处就是环的入口。这个结论很多人知道但推导不出来,我建议你亲手推导一遍,面试时能讲清楚原理会大大加分。
此外还有求链表中间节点:快指针到末尾时,慢指针正好在中间。原因是快指针速度是慢指针两倍,这个“k倍速度=两倍路径”的关系非常直观。求倒数第k个节点则可以让快指针先走k步,然后两个指针同步走,快指针到末尾时慢指针正好停在倒数第k个。这些都是同一类思想的变体,掌握一套方法就能打通一系列题目。
4. 顺序表与链表,到底怎么选才对得起你的数据
4.1 一组复杂度对比,帮你建立量化直觉
这张表每次面试前都值得默背一遍。我直接给一份精确到操作层面的对比:
| 操作 | 顺序表 | 单链表 |
|---|---|---|
| 随机访问第k个 | O(1) | O(n) |
| 头部插入 | 全部后移,O(n) | 改一个指针,O(1) |
| 尾部插入 | 均摊O(1),可能触发扩容 | 需要遍历到尾部,O(n) |
| 中间插入 | 平均搬移n/2个,O(n) | 查找O(n) + 插入O(1) |
| 删除头部 | O(n) | O(1) |
| 删除尾部 | O(1),size减一即可 | O(n),需要找到尾节点的前驱 |
| 内存占用 | 只有数据本身,可能预留未用空间 | 每个节点多一个指针,且分配零散 |
| 缓存友好度 | 极高,连续内存 | 极低,节点分散各处 |
| 存储密度 | 高 | 低 |
注意最后两行。顺序表虽然可能有未使用的容量,但总体是连续的一块大内存,遍历时CPU缓存命中率极高。链表每个节点单独new,地址分散,遍历时几乎每次都要访存,缓存命中率很差。现代CPU上这个差距有时候比复杂度分析的差距还大。我在某个模拟项目中实测过,数据量到百万级时,顺序表顺序遍历比链表快一个数量级都不奇怪。
4.2 工程与面试中的选型标准
选型没有万能答案,但有清晰的标准。如果你主要做随机访问,比如按下标查数据、按位置更新数据,顺序表是唯一合理的选择。如果数据量动态变化剧烈,且高频在中间插入删除,那么链表更合适,比如任务队列、编辑器里的撤销历史(操作不多时)。
如果元素非常大,比如每个元素是一个几十KB的结构体,那顺序表在插入删除时的搬移代价会非常高,因为还要连带拷贝对象内部的所有数据。这种情况即使访问频繁点,也必须认真考虑链表。反过来,如果元素只是几个字节的整数,拷贝代价几乎可以忽略,顺序表基本总是更优。
工程上经常使用折中方案:用顺序表存储指向大对象的指针,也就是vector<shared_ptr >。搬移指针的代价远小于搬移大对象本身,又能享受连续内存的缓存优势。这招在游戏引擎的实体管理、图形学的批次渲染里非常普遍。类似的思维也可以迁移到其他领域:当你把“拷贝代价”纳入考量时,很多选型难题会豁然开朗。
面试里如果你能主动问面试官“数据规模多大?插入删除的频率高不高?元素拷贝成本高不高?”,比你闷头写一个链表然后说“我觉得链表好”强太多。好的软件工程师在选结构时不只考虑理论复杂度,还要结合具体场景估算实际开销。
4.3 一个综合案例:手写一个简单LRU缓存
用双链表加哈希表实现的LRU缓存,是检验线性表理解深度的经典案例。这个结构正好用到了双链表为什么适合“中间删除 + 尾插头移”的直觉题。
核心思路:哈希表负责O(1)查找节点位置,双链表负责维护访问顺序。每次get某个key,如果存在就把它移动到链表头部(表示最近被使用);每次put新key,如果容量已满就淘汰链表尾部的节点。移动节点到头部需要两步:先从当前位置拆下来,再插到头节点之后。双链表让“拆”变得极其简单,因为节点可以直接通过自己的prev和next找到前后节点,不需要从头遍历。
class LRUCache { private: struct Node { int key; int value; Node* prev; Node* next; }; std::unordered_map<int, Node*> cache; Node* dummyHead; Node* dummyTail; int capacity; void removeNode(Node* node) { node->prev->next = node->next; node->next->prev = node->prev; } void addToHead(Node* node) { node->next = dummyHead->next; node->prev = dummyHead; dummyHead->next->prev = node; dummyHead->next = node; } public: explicit LRUCache(int cap) : capacity(cap) { dummyHead = new Node(); dummyTail = new Node(); dummyHead->next = dummyTail; dummyTail->prev = dummyHead; } int get(int key) { auto it = cache.find(key); if (it == cache.end()) return -1; Node* node = it->second; removeNode(node); addToHead(node); return node->value; } void put(int key, int value) { auto it = cache.find(key); if (it != cache.end()) { Node* node = it->second; node->value = value; removeNode(node); addToHead(node); return; } if (cache.size() >= capacity) { Node* tail = dummyTail->prev; removeNode(tail); cache.erase(tail->key); delete tail; } Node* node = new Node(); node->key = key; node->value = value; cache[key] = node; addToHead(node); } };注意这个实现里我为链表专门设置了两个哨兵:dummyHead和dummyTail。这样无论链表为空还是只有一个节点,插入删除的逻辑都不用特判边界。这是我对“哨兵节点消除边界特判”这句话的实战诠释。很多同学觉得哨兵多余,等你处理过链表为空时的插入就会明白,不用特判的代码,行数少一半,bug率也少一半。
5. 常见问题与排查技巧实录
5.1 悬空指针:链表 bug 的最大来源
悬空指针指的是一个指针指向的内存已经被释放,但你还在通过它读写。链表里最常见的场景是:删除节点后,外部某个指针还保存着被删节点的地址。比如先执行delete target,后面又访问target->val。这属于未定义行为,程序可能崩溃,可能数据错乱,也可能看起来一切正常。看起来正常最可怕,因为问题会潜伏到下一次改动才爆发。
我排查链表bug时,第一件事就是检查所有delete之后还有没有代码使用这个指针。另一个常见场景是删除头节点后,没有正确更新头指针,导致整个链表访问非法内存。遇到这种情况,画图是最好的排查方式。把链表节点一个个画出来,标上指针指向,对照代码走一遍,基本都能定位。
如果真的担心悬空指针,可以养成一个习惯:每次delete之后立刻把指针置为nullptr。这是一种很好的防御性编程习惯。虽然不能解决所有问题,但至少能让你在Debug时立刻收到“空指针访问”的报错,而不是摸不着头脑的垃圾数据。
5.2 内存泄漏:new了不delete,迟早要还
内存泄漏在链表中尤其隐蔽。删除链表节点时,你需要逐个节点delete。如果我写的析构函数里差了一个循环,或者中途某个节点new失败,这部分内存就永远丢失了。更隐蔽的是在插入时new成功但后续操作抛异常,导致这个节点没被挂到链表上,也没有delete,内存直接泄漏。
排查内存泄漏最实用的办法是借助工具。Linux下可以用valgrind的memcheck,Windows下可以用Visual Studio的调试CRT或性能分析器。不过说实话,工具只是辅助,更重要的是写代码时就想清楚这个节点的释放责任归谁。我在上面的实现里给的析构函数,把每个节点都delete了,这已经是最低要求。真实项目里,更推荐用std::unique_ptr来管理链表的节点所有权,让RAII承担释放责任。不过面试的时候手写裸指针版本依然是必要的,因为面试官想看的就是你对内存管理的掌握程度。
5.3 边界条件:空表、单节点、头尾操作
链表题最经典的翻车点永远是边界条件。空链表插入和删除要什么行为?size为0时调用remove(0)应该返回false而不是崩溃。单节点的链表删除后链表是否为空?头节点操作后要不要更新头指针?尾部插入后链表的尾节点是否指向nullptr?
我建议每个链表操作都先自问:空表怎么处理、单节点怎么处理、头尾怎么处理。这个“三问法”能覆盖绝大多数边界问题。写代码前先把这些情况在纸上过一遍,再动手写,效率远高于先写代码再靠测试去撞bug。测试代码里也必须有这些边界用例,而不是只测正常情况。很多初学者写完链表就测了一个插入正常、删除正常,然后就认为万事大吉,实际上边界用例往往才是真正的分水岭。
5.4 一个实用调试技巧:打印链表演示优化
链表出bug时,看指针地址意义不大,最好把链表内容打印出来。我习惯写一个debugDump函数,从头到尾打印每个节点的值。在每一步操作后调用它,就能清楚地看出链表的结构变化是否符合预期。这一步看起来简单,但在复杂操作(反转、合并、排序)里几乎是最有效的排错方法。
反转链表后打印一下,看看顺序对不对。插入后打印一下,看看链表有没有意外断开。删除后打印一下,看看节点有没有真的少了一个。视觉反馈比脑内推演快得多。这个习惯我一直保持着,即使是线上问题,也会在本地写一个小demo复现并打印,定位速度比对着日志猜快好几倍。我个人强烈建议你也把这个习惯建立起来,你会在后面的树、图、排序算法调试中反复受益。
写在最后的几句体会
讲真,线性表在教科书里就那么几十页,但它在整个数据结构体系里的分量,远远超过篇幅。后面学栈和队列,你会看到它们本质上就是“阉割版”的线性表,只是操作被限制在某个口子上,反而让你对“保护性操作”这个概念有了直观感受。而那些看似复杂的树和图,存储结构拆开一看,还是那一堆节点加指针的组合游戏。
这一篇我把顺序表和链表的底子打得比较厚。下一讲我会把栈和队列串起来讲,重点放在它们在线性表基础上的“受限操作”设计,以及一些实际场景里非常妙的应用,比如表达式求值、括号匹配、树层序遍历这些。你如果能把这篇里的代码自己从头敲一边,最好是在不看参考的情况下敲出来,那下一讲你会轻松非常多。数据结构的功夫,真的全在手上和脑子里的那张图上。