前言
最近二刷了数据结构顺序表的视频课程,第一遍学的时候,C语言结构体、动态内存分配、指针这些基础掌握得并不扎实,只是跟着敲了一遍代码,似懂非懂。
这次特意从头重学,课件作为核心思路参考,所有代码全程自己手写、调试、改错、优化,把动态顺序表从基础实现到一步步排错,再到封装通用打印函数的完整过程走了一遍。
这是我的数据结构学习系列第一篇,先从最基础的头尾增删开始,后续会陆续更新指定位置插入/删除、按值查找、按位置修改等进阶操作,最终会基于顺序表实现完整的通讯录项目,把学到的知识真正落地。另外我的C语言基础专栏也快要更新收尾了,打好C语言地基,再循序渐进啃数据结构,才是最稳妥的学习节奏。
一、学习心得:为什么要二刷顺序表?
- 顺序表是数据结构第一个线性结构,底层完全依赖C语言基础,如果结构体、指针、动态内存没学牢,根本学不透;
- 第一遍只追求"跑起来就行",忽略了代码规范、边界校验、内存泄漏、函数封装这些细节;
- 这次重学坚持自己手写每一行代码,出Bug自己排查,真正理解每一步扩容、插入、删除的逻辑;
- 刻意做了代码优化:把打印函数改成通用可适配类型,后续改顺序表存储类型,不用改打印逻辑。
二、动态顺序表核心原理(结合代码讲解)
2.1 什么是顺序表?
顺序表是线性表 的一种,它的特点是逻辑上连续,物理存储上也连续,底层本质就是一个数组。我们对数组进行了一层封装,加上了"有效数据个数"和"容量"的管理,就变成了顺序表。
和原生数组相比,顺序表的优势是:
- 自动管理容量,满了自动扩容
- 统一的增删改查接口,不用自己手动计算下标
- 自带边界校验,减少非法访问
2.2 动态顺序表结构体定义
这是我自己定义的动态顺序表结构体,核心三个成员,分工明确:
cpp
// 顺序表存储类型,后续改类型只改这里
typedef int SLType;
// 动态顺序表结构体
typedef struct SeqList
{
SLType* a; // 指向动态数组的指针,真正存储数据
int size; // 当前有效数据个数
int capacity; // 当前数组总容量,满了就自动扩容
}SL;
SLType* a:用指针指向堆上开辟的动态数组,这样才能实现按需扩容int size:永远记录当前顺序表里有多少个有效数据,所有增删操作都要更新它int capacity:记录当前数组最多能存多少个数据,当size == capacity时触发扩容
2.3 核心操作总览
本篇先实现顺序表最基础的7个核心操作,对应7个函数:
| 函数名 | 功能 | 时间复杂度 |
|---|---|---|
SLInit |
初始化空顺序表 | O(1) |
Destroy |
销毁顺序表,释放内存 | O(1) |
SLExp |
检测并自动扩容 | O (n)(扩容时需要拷贝数据) |
PushBack |
尾部插入 | O (1)(均摊) |
PushFront |
头部插入 | O(n) |
SLDeleteBack |
头部删除 | O(n) |
SLDeleteBehind |
尾部删除 | O(1) |
Print |
遍历打印顺序表 | O(n) |
后续更新预告 :下一篇会补充实现SLInsert(指定位置插入)、SLErase(指定位置删除)、SLFind(按值查找)、SLModify(按位置修改)4个进阶接口,让顺序表功能更完整。
三、我的代码迭代 & 排错优化过程(全程手写踩坑记录)
这部分是我这次学习收获最大的地方,每一个错误都是自己实际写代码时踩过的坑,不是凭空想象的。
3.1 初期手写遇到的5个硬性Bug
Bug1:头删函数数组越界崩溃
错误代码:
cpp
// 错误写法
void SLDeleteBack(SL* ps)
{
assert(ps);
assert(ps->size);
// 错误:循环条件写成了 i < ps->size
for (int i = 0; i < ps->size; i++)
{
ps->a[i] = ps->a[i + 1];
}
ps->size--;
}
错误原因 :当i = ps->size - 1时,i + 1 = ps->size,访问了数组下标为size的元素,这是非法内存,直接导致程序崩溃。
正确代码:
cpp
// 正确写法
void SLDeleteBack(SL* ps)
{
assert(ps);
assert(ps->size);
// 正确:循环到 size-2 即可,最后一个元素不用移动
for (int i = 0; i < ps->size - 1; i++)
{
ps->a[i] = ps->a[i + 1];
}
ps->size--;
}
Bug2:删除函数多余参数
错误代码:
cpp
// 错误写法:多了无用参数x
void SLDeleteBack(SL* ps, SLType x)
void SLDeleteBehind(SL* ps, SLType x)
错误原因:删除操作只需要知道要删除哪个位置的元素,不需要传入数据值。这个多余的参数会导致调用时必须传一个没用的数,否则编译报错。
正确代码:
cpp
// 正确写法:删掉参数x
void SLDeleteBack(SL* ps)
void SLDeleteBehind(SL* ps)
Bug3:缺少指针防护断言
错误代码:
cpp
// 错误写法:没有加 assert(ps)
void SLInit(SL* ps)
{
ps->a = NULL;
ps->capacity = 0;
ps->size = 0;
}
错误原因 :如果不小心传入了NULL指针,会直接野指针访问,程序崩溃。
正确代码:
cpp
// 正确写法:第一行加断言校验
void SLInit(SL* ps)
{
assert(ps);
ps->a = NULL;
ps->capacity = 0;
ps->size = 0;
}
Bug4:扩容逻辑写错
错误代码:
cpp
// 错误写法:用 size 计算新容量
int Newspace = ps->capacity == 0 ? 4 : 2 * ps->size;
错误原因 :size是当前有效数据个数,capacity才是数组的总容量。虽然在满容时size == capacity,数值上巧合相等,但逻辑上必须用capacity计算。
正确代码:
cpp
// 正确写法:用 capacity 计算新容量
int Newspace = ps->capacity == 0 ? 4 : 2 * ps->capacity;
Bug5:打印函数初始版本漏洞
错误代码:
cpp
// 错误写法
void Print(SL* ps)
{
assert(ps);
assert(ps->size); // 错误1:空表不能打印?
// 错误2:少打印最后一个元素
for (int i = 0; i < ps->size - 1; i++)
{
printf("%d ", ps->a[i]); // 错误3:格式符写死
}
}
错误原因:
- 空表也应该能打印,直接输出空行即可,不能断言崩溃
- 循环条件
i < ps->size - 1会少打印最后一个元素 - 格式符写死为
%d,改SLType类型就要改打印代码
3.2 关键优化:通用打印函数实现
为了解决"改SLType类型就要改printf格式符"的问题,我用宏定义做了一层封装,实现了真正通用的打印函数。
第一步:头文件添加打印格式宏
cpp
// 通用打印格式宏,切换类型只改这里
#define SLTYPE_PRINT_FMT "%d"
// 顺序表存储类型
typedef int SLType;
第二步:通用打印函数
cpp
// 通用打印函数,适配任意SLType类型
void Print(SL* ps)
{
assert(ps);
for (int i = 0; i < ps->size; i++)
{
// 直接引用宏,自动匹配对应格式
printf(SLTYPE_PRINT_FMT " ", ps->a[i]);
}
printf("\n");
}
切换类型示例 :如果想改成存储float类型,只需要改头文件两行:
cpp
#define SLTYPE_PRINT_FMT "%f"
typedef float SLType;
其他所有代码(包括打印函数)完全不用动,直接编译运行即可。
3.3 工程化优化:多文件拆分
我严格按照C语言工程规范,把代码拆分成了三个独立文件:
SeqList.h:头文件,存放结构体定义、类型别名、宏定义、函数声明SeqList.c:源文件,存放所有功能函数的具体实现test.c:测试文件,单独存放主函数,专门用来测试所有接口
这样拆分的好处是:
- 代码结构清晰,便于维护
- 接口和实现分离,后续修改实现不影响接口
- 测试代码和业务代码解耦,不会互相干扰
四、完整最终代码(本人手写版)
4.1 SeqList.h 头文件
cpp
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
// 通用打印格式宏,切换类型只改这里
#define SLTYPE_PRINT_FMT "%d"
// 顺序表存储类型
typedef int SLType;
// 动态顺序表结构体
typedef struct SeqList
{
SLType* a;
int size; // 有效数据个数
int capacity; // 储存空间总容量
}SL;
// 初始化和销毁
void SLInit(SL* ps);
void Destroy(SL* ps);
void Print(SL* ps);
// 扩容检测
void SLExp(SL* ps);
// 头部操作
void PushFront(SL* ps, SLType x); // 头部插入
void SLDeleteBack(SL* ps); // 头部删除
// 尾部操作
void PushBack(SL* ps, SLType x); // 尾部插入
void SLDeleteBehind(SL* ps); // 尾部删除
4.2 SeqList.c 功能实现文件
cpp
#include"SeqList.h"
// 初始化顺序表
void SLInit(SL* ps)
{
assert(ps);
ps->a = NULL;
ps->capacity = 0;
ps->size = 0;
}
// 销毁顺序表,释放动态内存
void Destroy(SL* ps)
{
assert(ps);
if (ps->a)
{
free(ps->a);
ps->a = NULL;
ps->capacity = 0;
ps->size = 0;
}
}
// 检测并自动扩容
void SLExp(SL* ps)
{
assert(ps);
if (ps->capacity == ps->size)
{
int Newspace = ps->capacity == 0 ? 4 : 2 * ps->capacity;
SLType* Newps = (SLType*)realloc(ps->a, Newspace * sizeof(SLType));
if (Newps == NULL)
{
perror("Failed to apply for space");
exit(1);
}
ps->a = Newps;
ps->capacity = Newspace;
}
}
// 尾部插入
void PushBack(SL* ps, SLType x)
{
assert(ps);
SLExp(ps);
ps->a[ps->size++] = x;
}
// 头部插入
void PushFront(SL* ps, SLType x)
{
assert(ps);
SLExp(ps);
// 元素后移,从最后一个元素开始往前移
for (int i = ps->size; i > 0; i--)
{
ps->a[i] = ps->a[i - 1];
}
ps->a[0] = x;
ps->size++;
}
// 头部删除
void SLDeleteBack(SL* ps)
{
assert(ps);
assert(ps->size);
// 元素前移覆盖第一个元素
for (int i = 0; i < ps->size - 1; i++)
{
ps->a[i] = ps->a[i + 1];
}
ps->size--;
}
// 尾部删除
void SLDeleteBehind(SL* ps)
{
assert(ps);
assert(ps->size);
// 逻辑删除:只需要把有效数据个数减1
ps->size--;
}
// 通用打印函数,适配任意SLType类型
void Print(SL* ps)
{
assert(ps);
for (int i = 0; i < ps->size; i++)
{
printf(SLTYPE_PRINT_FMT " ", ps->a[i]);
}
printf("\n");
}
4.3 test.c独立测试文件
cpp
#include "SeqList.h"
int main()
{
// 定义顺序表变量
SL sl;
// 初始化
SLInit(&sl);
printf("===== 顺序表初始化完成 =====\n\n");
// 测试尾插
printf("----- 测试尾插 -----\n");
PushBack(&sl, 10);
PushBack(&sl, 20);
PushBack(&sl, 30);
PushBack(&sl, 40);
printf("尾插 10 20 30 40 后:");
Print(&sl);
printf("\n");
// 测试头插
printf("----- 测试头插 -----\n");
PushFront(&sl, 5);
PushFront(&sl, 1);
printf("头插 5 1 后:");
Print(&sl);
printf("\n");
// 测试尾删
printf("----- 测试尾删 -----\n");
SLDeleteBehind(&sl);
SLDeleteBehind(&sl);
printf("尾删2次后:");
Print(&sl);
printf("\n");
// 测试头删
printf("----- 测试头删 -----\n");
SLDeleteBack(&sl);
printf("头删1次后:");
Print(&sl);
printf("\n");
// 释放内存
printf("===== 顺序表销毁完成 =====\n");
Destroy(&sl);
return 0;
}
五、编译运行&输出结果
5.1编译命令
cpp
gcc test.c SeqList.c -o SeqList
5.2运行输出
cpp
===== 顺序表初始化完成 =====
----- 测试尾插 -----
尾插 10 20 30 40 后:10 20 30 40
----- 测试头插 -----
头插 5 1 后:1 5 10 20 30 40
----- 测试尾删 -----
尾删2次后:1 5 10 20
----- 测试头删 -----
头删1次后:5 10 20
===== 顺序表销毁完成 =====
六、学习总结与后续更新计划
- 基础是重中之重:顺序表卡壳,本质是C语言结构体、指针、动态内存没学牢。二刷补完短板后,写代码顺畅很多,也能理解为什么要这么写。
- 拒绝复制粘贴:课件只看思路,代码自己手写、自己排错,印象远比抄代码深刻。每一个 Bug都是一次宝贵的学习机会。
- 注重代码质量:不只是实现功能,还要考虑健壮性(assert断言)、可扩展性(通用打印宏)、工程规范(多文件拆分)。
后续更新计划
- 下一篇 :实现顺序表进阶接口------
SLInsert(指定位置插入)、SLErase(指定位置删除)、SLFind(按值查找)、SLModify(按位置修改) - 再下一篇:基于完整的顺序表接口,实现一个可增删改查的通讯录项目
- 长期规划:继续学习链表、栈、队列、二叉树等数据结构,坚持每一个知识点都手写实现+踩坑记录的形式,沉淀扎实的基础
初学数据结构不要追求进度,慢一点、写一遍、改一遍、优化一遍,远比快速刷完视频有用得多。