尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++静态分配顺序表:数组+长度实现插入删除查找

C++静态分配顺序表:数组+长度实现插入删除查找 1. 先说清楚静态分配的顺序表到底在解决什么问题不管是考研、面试、还是平时自己写点小工具顺序表永远是你躲不开的第一道坎。很多人觉得它简单不就是个数组吗但真让你五分钟手写一个支持插入、删除、查找的完整C实现能一次写对的人其实不多。C顺序表静态分配这件事本质上是让你用“固定大小数组 长度计数器”的方式把线性表的逻辑关系用物理上连续的内存表达出来。静态分配的含义很直白数组大小在编译期就固定了不涉及malloc、new、realloc这些动态内存操作。这个设计在数据结构的教学阶段几乎就是标准答案因为在学习阶段我们把所有注意力都放在“元素怎么存、怎么移动、怎么维护长度”这些核心逻辑上而不是去纠缠内存申请失败、扩容搬数据这类问题。适合参考这篇内容的人有三类刚接触数据结构、打算系统啃一遍基础的大一学生准备笔试面试、需要快速捡起手写代码能力的求职者以及想用一个最小可运行案例快速验证某个思路、不想引入vector等封装类型的C开发者。这篇内容会从设计思路讲到完整实现再讲到调试经验和扩展方向你照着敲一遍就能把顺序表这块地基打牢。我自己的体会是顺序表虽然简单但它承载了“线性表”这个抽象概念落地成代码的所有关键决策点。你理解了静态分配版本之后再看动态分配、再看链表、再看STL里的vector很多套路都是相通的。换句话说静态分配的顺序表不只是让你背一个代码模板它是帮你建立“数据结构到底怎么设计”的思维起点。2. 核心设计一个数组加一个长度这个结构为什么这么定2.1 结构体定义背后的设计逻辑静态顺序表最常见的定义方式是这样的#define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; // 用固定数组承载数据 int length; // 记录当前实际存储的元素个数 } SeqList;这段代码看起来只有两行但它的设计意图在数据结构里是基础中的基础。第一数组data[MAX_SIZE]负责真正存数据。MAX_SIZE就是容量上限编译期定死不讨论扩容。这个100到底该取多少取决于你的实际场景如果你知道最多只会存50个数据那定义成50或者64都行重点是它必须是一个编译期常量这样才能在栈上静态分配。第二为什么需要一个单独的length字段因为数组有“容量”和“实际大小”两个完全不同的概念。容量是MAX_SIZE代表这块内存最多能装多少实际大小是length代表现在里面真正有几个有效元素。没有length你根本没法知道数组里哪些位置是有效的哪些是垃圾数据。这个区分是顺序表设计里最容易被新手忽略、但最重要的点。你可以类比一个文件系统磁盘总容量是固定的但里面有多少个文件必须靠目录去记录length就相当于这里的目录。另外还有两个实现细节值得说明。一是为什么用typedef struct而不是直接struct SeqList这样做的目的是后续声明变量时可以直接写SeqList L;不用每次都带struct关键字代码更简洁。C里不写typedef也能直接用但保留了C语言风格的定义方式兼容性最好很多教材和考试代码都这么写。二是这个结构体现在看是int类型实际使用时完全可以把int换成double、char甚至自定义的结构体类型。因为顺序表本身关心的是“怎么组织数据”而不是“数据具体是什么”。当然换成不同类型后查找、比较这些操作需要相应调整这是泛型要解决的问题所以这里先用int把最核心的逻辑跑通。2.2 初始化、判空、判长所有操作的前置条件数据结构里的每个操作第一步永远是检查“现在是什么状态”。顺序表的初始化极其简单void initList(SeqList L) { L.length 0; // 只需把长度清零 }这里我特别想提醒一个新手非常容易犯的错误初始化一定不能忘了。C里参数如果不写引用传进来的是形参副本你在函数里把length改成0外面根本不会变。我见过太多人在这里翻车初始化函数写了但调用后L.length还是历史遗留的垃圾值然后所有后续操作全部错乱。如果你只想读取数据、不修改结构可以按值传参但凡是初始化、插入、删除这种改状态的函数一律传引用。接下来是几个工具函数bool isEmpty(SeqList L) { return L.length 0; } bool isFull(SeqList L) { return L.length MAX_SIZE; } int getLength(SeqList L) { return L.length; }这些函数实现都很简单它们存在的意义是让业务代码更可读。你写if (isEmpty(L))比写if (L.length 0)读起来语义清楚得多。而且如果后续你要修改“空”的判断标准比如加一个标志位只需要改这一个函数调用处完全不用动。这就是封装的价值即使在这个微型数据结构里也一样成立。2.3 插入和删除顺序表最关键的两个操作顺序表之所以叫“顺序表”核心特征就是数据在物理上连续存放。这个特征带来一个最直接的代价在中间插入或删除元素时必须批量移动数据。插入的逻辑是这样的把目标位置及之后的所有元素整体往后挪一格腾出空位再放入新元素。注意移动必须从最后一个元素开始从后往前搬。bool insertElem(SeqList L, int pos, int value) { if (pos 1 || pos L.length 1) { return false; // 位置不合法 } if (isFull(L)) { return false; // 表满插入失败 } for (int i L.length; i pos; i--) { L.data[i] L.data[i - 1]; // 从后往前逐个后移 } L.data[pos - 1] value; L.length; return true; }我先解释位置规则这里采用的教学约定是pos从1开始也就是第一个元素的位置是1。这个约定和数组下标从0开始存在偏移写代码时要格外小心。pos的合法范围是1到length 1为什么是length 1因为可以插入到最后一个元素后面也就是追加到表尾。对应到数组下标pos - 1的范围就是0到length恰好覆盖了数组data的全部合法索引。为什么移动要从后往前想象一下如果从前往后搬你把data[0]赋给data[1]data[1]原本的值就被覆盖了还没搬走呢数据就丢了。从后往前搬每一步都把前面的元素挪到一个已经空出来的位置上全程不会覆盖还没搬的原始数据。这是一个特别典型的思维陷阱可以和删除操作的移动方向对照着理解。删除操作正好反过来bool deleteElem(SeqList L, int pos, int e) { if (pos 1 || pos L.length) { return false; // 位置不合法 } e L.data[pos - 1]; // 先取出被删除的元素 for (int i pos - 1; i L.length - 1; i) { L.data[i] L.data[i 1]; // 从前往后逐个前移 } L.length--; return true; }删除时pos的合法范围是1到length注意这里不能是length 1因为根本没有“删除最后一个元素后面的位置”这回事。移动方向是往前覆盖从被删位置开始把后面的元素逐个往前搬最后一个位置的值虽然还残留在数组里但因为length已经减1它不会被访问到属于“逻辑上已删除”的无效数据。注意e参数刚才用了引用它的作用是回传被删的元素。这是一个常见的“输出参数”用法函数通过这个引用把额外信息带出去。很多面试题会专门问这个细节如果你写成deleteElem(SeqList L, int pos, int e)那删除的元素就带不回来了。时间复杂度上插入和删除都涉及大量移动。插入到第i个位置平均要移动n - i 1个元素插入到表尾时是O(1)插入到表头时是O(n)平均复杂度O(n)。删除同理。这就是顺序表的短板随机访问快但插入删除慢。2.4 查找与遍历顺序表的天然优势按位置查找是顺序表最强的地方。因为数据在物理上连续存放支持随机访问int getElem(SeqList L, int pos) { if (pos 1 || pos L.length) { return -1; // 位置不合法 } return L.data[pos - 1]; }这条操作的时间复杂度是O(1)也就是常数时间。不管表里有10个元素还是10万个元素找第50个元素都是直接拿数组下标去取。这是顺序存储结构最核心的优势也是链表做不到的。按值查找则需要遍历int findElem(SeqList L, int value) { for (int i 0; i L.length; i) { if (L.data[i] value) { return i 1; // 返回位置注意下标转位置 } } return 0; // 0表示未找到 }遍历查找的时间复杂度是O(n)。从前往后挨个比较最坏情况下要找的元素在最后一个或者根本不存在那就得全部看完才能下结论。这里返回值设计成位置而不是下标是为了和pos的位置定义保持一致。找不到返回0因为位置从1开始0天然就是“无效位置”的哨兵值。遍历输出也很直接void printList(SeqList L) { for (int i 0; i L.length; i) { cout L.data[i] ; } cout endl; }算上已经介绍过的判空判满一个静态顺序表的基本操作就齐了初始化、判空、判满、取长度、插入、删除、按位查找、按值查找、遍历输出。这些合在一起就是一个可以直接跑起来的完整数据容器。3. 完整实现一个能直接运行的静态顺序表3.1 全部代码与逐段说明把上面的函数整合起来就是一个能在VS Code、Visual Studio或者任意C编译器下直接编译运行的完整程序。完整代码如下#include iostream using namespace std; #define MAX_SIZE 100 typedef struct { int data[MAX_SIZE]; int length; } SeqList; void initList(SeqList L) { L.length 0; } bool isEmpty(SeqList L) { return L.length 0; } bool isFull(SeqList L) { return L.length MAX_SIZE; } int getLength(SeqList L) { return L.length; } bool insertElem(SeqList L, int pos, int value) { if (pos 1 || pos L.length 1) { return false; } if (isFull(L)) { return false; } for (int i L.length; i pos; i--) { L.data[i] L.data[i - 1]; } L.data[pos - 1] value; L.length; return true; } bool deleteElem(SeqList L, int pos, int e) { if (pos 1 || pos L.length) { return false; } e L.data[pos - 1]; for (int i pos - 1; i L.length - 1; i) { L.data[i] L.data[i 1]; } L.length--; return true; } int getElem(SeqList L, int pos) { if (pos 1 || pos L.length) { return -1; } return L.data[pos - 1]; } int findElem(SeqList L, int value) { for (int i 0; i L.length; i) { if (L.data[i] value) { return i 1; } } return 0; } void printList(SeqList L) { for (int i 0; i L.length; i) { cout L.data[i] ; } cout endl; } int main() { SeqList L; initList(L); insertElem(L, 1, 10); insertElem(L, 2, 20); insertElem(L, 3, 30); printList(L); // 输出: 10 20 30 insertElem(L, 2, 99); // 在位置2插入99 printList(L); // 输出: 10 99 20 30 int deleted; if (deleteElem(L, 2, deleted)) { cout 删除的元素: deleted endl; } printList(L); // 输出: 10 20 30 cout 元素30在位置: findElem(L, 30) endl; cout 当前长度: getLength(L) endl; return 0; }这段代码我建议你自己动手在编辑器里敲一遍不要复制粘贴。手敲的好处是你会被迫注意每个、每个下标偏移、每个循环边界这些细节沾一次手比看十遍都记得牢。3.2 每段代码的意图拆解#define MAX_SIZE 100用了宏定义而不是const int MAX_SIZE 100区别在于在C语言风格里宏定义会在预处理阶段直接做文本替换这个常量在编译期就确定了不会在运行时占用栈空间。C里你也可以用constexpr int MAX_SIZE 100;这两者在静态分配场景下等价。我写宏主要是为了兼容性很多考试环境、老教材都沿用这种写法。整个结构体定义在栈上SeqList L;这条语句执行时系统会在栈上分配MAX_SIZE * sizeof(int) sizeof(int)字节的空间而这MAX_SIZE * sizeof(int)就是那100个int元素的空间。栈上分配的特点是速度快、不需要手动释放但生命周期跟着作用域走。你如果要把这个表从函数里返回出去就会遇到问题因为函数结束后栈内存就失效了。这也是静态分配的一个重要限制必要的时候得考虑动态分配。main函数里的测试路径我设计成了先连续插入三个元素然后看中间的插入如何挪位置再看删除如何前移覆盖最后验证按值查找。这个顺序模拟了一个最典型的使用场景先搭建数据再在中间动数据最后查数据。跑完这段代码你对顺序表的行为过程就有了直觉。3.3 边界条件写对代码的关键在边界写完代码后我建议你按下面这张表逐条验证边界场景测试用例设计的价值不亚于实现本身测试场景操作预期结果对应检查代码空表插入insertElem(L, 1, 5)成功表长为1pos ≤ length 1允许1空表插入位置2insertElem(L, 2, 5)失败pos length 1触发表尾插入insertElem(L, length1, 99)成功追加位置合法范围的上界表头插入insertElem(L, 1, 99)成功全部后移移动循环从尾部开始删除最后一个元素deleteElem(L, length, e)成功移动循环不执行删除位置0deleteElem(L, 0, e)失败pos 1触发删除位置len1deleteElem(L, length1, e)失败pos length触发满表插入先插满100个再插失败isFull触发查找不存在的值findElem(L, 888)返回0循环自然结束为什么边界条件这么重要因为大部分顺序表程序的bug都不是业务逻辑错了而是边界漏了。位置下限检查漏了位置0会越界访问data[-1]位置上限检查漏了位置length2会写入一个越界位置移动循环的边界差一个要么漏移一个元素要么覆盖到下一个有效元素。写程序一定要把边界当成第一优先级这大概是我调试数据结构代码以来最深的体会。4. 常见问题与排查技巧实录4.1 新手高频踩坑汇总顺序表这个小代码踩坑点其实非常集中。我根据平时给同事review代码和帮初学者调bug的经验把最常见的几类问题整理如下。第一类忘记传引用。初始化、插入、删除这些操作里SeqList L的一旦漏掉函数内部的一切修改都只针对临时副本函数返回后表还是原样。典型症状是程序能编译、能运行、看不出报错但输出完全没变化。排查方法是在main里调用函数后打印L.length如果插入10个元素后length还是0基本就是引用丢了。第二类位置和下标混淆。pos从1开始数组下标从0开始两者差1。最容易出错的地方是在插入循环里写错边界。插入到位置pos实际下标是pos-1循环要从length开始一路挪到pos为止。很多人在这个循环里多算一格或者少算一格结果就是元素没腾对位置。第三类插入时从前往后移动。这个错误特别经典我当时学的时候也犯过。逻辑上如果从前往后搬前一个元素会覆盖还没搬走的元素数据直接丢。判断方法很简单插入操作移动方向必须是从后往前删除操作移动方向必须是从前往后。反过来就一定错。第四类没维护length。插入或者删除后忘记length或length--。这个bug的症状是第一次插入后一切正常第二次插入的内容会覆盖掉第一次的或者遍历时长了一截、短了一截。它是最好排查也最好修的bug根源就是你破坏了不变量。第五类数组越界。访问data[length]或者data[-1]都是越界。C不会像Java那样主动抛异常越界访问通常是“看起来还能跑但结果莫名其妙”或者在某些时候程序崩溃。这也是为什么MAX_SIZE要留够余量而且每次操作前都要先做合法性检查。4.2 实操心法用最笨的办法快速定位问题如果你写完代码发现运行结果不对我的建议是不要猜直接加打印。在插入、删除、查找的关键位置打印中间状态比如插入循环里每移动一个元素就打印一次当前数组内容只需要三轮操作你就能看出数据是在哪一步被覆盖、遗漏的。还有一个特别有效的办法写一个debugPrint函数在每次操作前和操作后都完整打印数组内容和length。手动模拟一下小的数据集合比如初始数组是[10, 20, 30]你手动算一遍插入99到位置2之后应该是什么样子然后和程序输出对比。这个“人肉模拟”虽然土但它能帮你建立对算法过程的直觉。调试数据结构题最忌讳的就是盯着代码凭空想象应该主动用最简数据把过程摊开看。4.3 面试官最爱追问的四个问题顺序表是面试高频考点光会写代码不够通常面试官会在你写完后立刻追问这些问题。问题一插入和删除的时间复杂度是多少答案是说清楚三个位置的情况表头插入是O(n)需要移动n个元素表尾插入是O(1)平均是O(n/2)也就是O(n)。这个计算过程每个位置插入涉及移动的元素个数和概率相乘求和之后得到平均移动次数约为n/2。问题二为什么说顺序表支持随机访问因为它用数组实现数组的每个元素地址可以通过基地址 下标 * 元素大小直接计算出来不需要任何遍历所以按位置查找是O(1)。想理解这个你只需要知道数组下标访问的本质是一个乘法加加法操作。问题三静态分配和动态分配有什么区别静态分配在编译期确定大小无法扩容可能浪费空间或不够用动态分配用malloc或new按需申请不够了可以用realloc扩容搬数据但需要手动管理容易内存泄漏。两者核心的“连续存储、移动元素”逻辑是一样的。问题四能不能用sizeof算这个结构体的大小可以sizeof(SeqList)算出来通常是MAX_SIZE * sizeof(int) sizeof(int)但注意可能会有内存对齐的填充字节具体数值和编译选项有关。这个问题其实是考你对内存布局有没有概念。5. 扩展方向从静态到动态从顺序表到链表5.1 改成动态分配解决扩容问题静态分配最大的痛点是容量定死。如果初始定义MAX_SIZE 100实际需要存1000个数据那就无解了。改成动态分配后结构体定义和扩容函数是这样的typedef struct { int *data; // 指针指向堆上动态分配的内存 int length; int capacity; } SeqList; void initList(SeqList L, int cap) { L.data (int *)malloc(cap * sizeof(int)); L.length 0; L.capacity cap; } bool expandList(SeqList L) { int newCapacity L.capacity * 2; int *newData (int *)realloc(L.data, newCapacity * sizeof(int)); if (newData nullptr) { return false; } L.data newData; L.capacity newCapacity; return true; }这个思路和STL里vector自动扩容的机制是相通的。vector每次容量不够时内部会申请一块更大的新内存、把旧数据搬过去、释放旧内存只不过它把这些细节封装好了。你现在手动实现一遍就知道vector背后在做什么了——所有的抽象不过是对一系列具体操作的封装。动态分配的缺点是你需要自己负责free否则堆内存泄漏realloc也可能失败失败后原指针仍然有效但扩容失败后新数据就进不去了。这些都是C里为什么更推荐直接用vector的原因但在学习数据结构阶段手动实现一遍动态扩容对你的成长价值非常大。5.2 顺序表的典型应用场景顺序表不只是教学工具在实际开发中用途很多。比如哈希表里解决冲突的“开放寻址法”底层的存储就是一个大数组操作系统的页表里页帧的管理很多用的是表结构再比如文本编辑器里如果文件内容不大用顺序表存行的起始位置就很常见。还有一个经典例子是求解一般集合的并集问题。思路是用两个顺序表分别存储集合A和集合B然后遍历集合B对于每个元素先检查它有没有出现在集合A中没出现过就在A的表尾追加。代码实现上你就复用了上面写的findElem和insertElem大概二十行就能搞定。这种“以现有基础操作搭建复杂逻辑”的思路就是你学顺序表的真正目的——不是背几个API而是学会在基础操作上做组合。5.3 顺序表 vs 链表怎么选面试里另一个高频问题是“顺序表和链表有什么区别”。这里我给你一个直接可以背下来的版本顺序表支持随机访问读取任意位置元素是O(1)但插入删除平均是O(n)而且空间要求连续容量固定链表不支持随机访问查找需要从头遍历但在已知节点指针的情况下插入删除是O(1)而且空间不连续、按需分配、理论上可以无限扩展。所以选型规律也很简单读多写少、数据规模固定、需要频繁按下标访问——用顺序表写多读少、数据规模不确定、节点本身要动态增减——用链表。实际工程里vector和list的选择标准也基本是这个逻辑。最后分享一个我个人的实操体会静态顺序表虽然简单但它是我见过的“性价比最高”的数据结构练手项目——代码量不大、逻辑足够完整、边界足够多、能延伸出动态分配、链表、vector源码等一整条知识链。把它吃透你后续理解数据结构的效率会高很多。建议你动手写一遍、测试一遍、再试着加上“排序”“去重”“合并”这些小功能你会发现那些看似基础的东西在实际写代码时依然会露出很多值得琢磨的细节。
返回列表