资讯详情

C++线性表详解:顺序表与链表的实现、复杂度与工程选型

📅 2026/10/19 10:56:02 | 华诺云谱 👁 阅读
C++线性表详解:顺序表与链表的实现、复杂度与工程选型
说实话写了这么多年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的结构体那顺序表在插入删除时的搬移代价会非常高因为还要连带拷贝对象内部的所有数据。这种情况即使访问频繁点也必须认真考虑链表。反过来如果元素只是几个字节的整数拷贝代价几乎可以忽略顺序表基本总是更优。工程上经常使用折中方案用顺序表存储指向大对象的指针也就是vectorshared_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_mapint, 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的memcheckWindows下可以用Visual Studio的调试CRT或性能分析器。不过说实话工具只是辅助更重要的是写代码时就想清楚这个节点的释放责任归谁。我在上面的实现里给的析构函数把每个节点都delete了这已经是最低要求。真实项目里更推荐用std::unique_ptr来管理链表的节点所有权让RAII承担释放责任。不过面试的时候手写裸指针版本依然是必要的因为面试官想看的就是你对内存管理的掌握程度。5.3 边界条件空表、单节点、头尾操作链表题最经典的翻车点永远是边界条件。空链表插入和删除要什么行为size为0时调用remove(0)应该返回false而不是崩溃。单节点的链表删除后链表是否为空头节点操作后要不要更新头指针尾部插入后链表的尾节点是否指向nullptr我建议每个链表操作都先自问空表怎么处理、单节点怎么处理、头尾怎么处理。这个“三问法”能覆盖绝大多数边界问题。写代码前先把这些情况在纸上过一遍再动手写效率远高于先写代码再靠测试去撞bug。测试代码里也必须有这些边界用例而不是只测正常情况。很多初学者写完链表就测了一个插入正常、删除正常然后就认为万事大吉实际上边界用例往往才是真正的分水岭。5.4 一个实用调试技巧打印链表演示优化链表出bug时看指针地址意义不大最好把链表内容打印出来。我习惯写一个debugDump函数从头到尾打印每个节点的值。在每一步操作后调用它就能清楚地看出链表的结构变化是否符合预期。这一步看起来简单但在复杂操作反转、合并、排序里几乎是最有效的排错方法。反转链表后打印一下看看顺序对不对。插入后打印一下看看链表有没有意外断开。删除后打印一下看看节点有没有真的少了一个。视觉反馈比脑内推演快得多。这个习惯我一直保持着即使是线上问题也会在本地写一个小demo复现并打印定位速度比对着日志猜快好几倍。我个人强烈建议你也把这个习惯建立起来你会在后面的树、图、排序算法调试中反复受益。写在最后的几句体会讲真线性表在教科书里就那么几十页但它在整个数据结构体系里的分量远远超过篇幅。后面学栈和队列你会看到它们本质上就是“阉割版”的线性表只是操作被限制在某个口子上反而让你对“保护性操作”这个概念有了直观感受。而那些看似复杂的树和图存储结构拆开一看还是那一堆节点加指针的组合游戏。这一篇我把顺序表和链表的底子打得比较厚。下一讲我会把栈和队列串起来讲重点放在它们在线性表基础上的“受限操作”设计以及一些实际场景里非常妙的应用比如表达式求值、括号匹配、树层序遍历这些。你如果能把这篇里的代码自己从头敲一边最好是在不看参考的情况下敲出来那下一讲你会轻松非常多。数据结构的功夫真的全在手上和脑子里的那张图上。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑