数据结构-线性表第一篇

前言

最近二刷了数据结构顺序表的视频课程,第一遍学的时候,C语言结构体、动态内存分配、指针这些基础掌握得并不扎实,只是跟着敲了一遍代码,似懂非懂。

这次特意从头重学,课件作为核心思路参考,所有代码全程自己手写、调试、改错、优化,把动态顺序表从基础实现到一步步排错,再到封装通用打印函数的完整过程走了一遍。

这是我的数据结构学习系列第一篇,先从最基础的头尾增删开始,后续会陆续更新指定位置插入/删除、按值查找、按位置修改等进阶操作,最终会基于顺序表实现完整的通讯录项目,把学到的知识真正落地。另外我的C语言基础专栏也快要更新收尾了,打好C语言地基,再循序渐进啃数据结构,才是最稳妥的学习节奏。

一、学习心得:为什么要二刷顺序表?

  1. 顺序表是数据结构第一个线性结构,底层完全依赖C语言基础,如果结构体、指针、动态内存没学牢,根本学不透;
  2. 第一遍只追求"跑起来就行",忽略了代码规范、边界校验、内存泄漏、函数封装这些细节;
  3. 这次重学坚持自己手写每一行代码,出Bug自己排查,真正理解每一步扩容、插入、删除的逻辑;
  4. 刻意做了代码优化:把打印函数改成通用可适配类型,后续改顺序表存储类型,不用改打印逻辑。

二、动态顺序表核心原理(结合代码讲解)

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:格式符写死
	}
}

错误原因

  1. 空表也应该能打印,直接输出空行即可,不能断言崩溃
  2. 循环条件i < ps->size - 1会少打印最后一个元素
  3. 格式符写死为%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

===== 顺序表销毁完成 =====

六、学习总结与后续更新计划

  1. 基础是重中之重:顺序表卡壳,本质是C语言结构体、指针、动态内存没学牢。二刷补完短板后,写代码顺畅很多,也能理解为什么要这么写。
  2. 拒绝复制粘贴:课件只看思路,代码自己手写、自己排错,印象远比抄代码深刻。每一个 Bug都是一次宝贵的学习机会。
  3. 注重代码质量:不只是实现功能,还要考虑健壮性(assert断言)、可扩展性(通用打印宏)、工程规范(多文件拆分)。

后续更新计划

  • 下一篇 :实现顺序表进阶接口------SLInsert(指定位置插入)、SLErase(指定位置删除)、SLFind(按值查找)、SLModify(按位置修改)
  • 再下一篇:基于完整的顺序表接口,实现一个可增删改查的通讯录项目
  • 长期规划:继续学习链表、栈、队列、二叉树等数据结构,坚持每一个知识点都手写实现+踩坑记录的形式,沉淀扎实的基础

初学数据结构不要追求进度,慢一点、写一遍、改一遍、优化一遍,远比快速刷完视频有用得多。

相关推荐
学渣676561 小时前
AA-Clip复现笔记
笔记
東隅已逝,桑榆非晚1 小时前
深⼊理解指针(6)
c语言·笔记
risc1234561 小时前
外用抗生素(比如克林霉素、夫西地酸、红霉素)在祛痘治疗中的作用机制
笔记
晓蓝WQuiet2 小时前
《鸟哥的Linux私房菜》笔记 第七至十六章
linux·运维·笔记
ljt27249606612 小时前
Vue笔记(一)--模板
前端·vue.js·笔记
山岚的运维笔记2 小时前
Bash 专业人员笔记 -- 第 11 章:`true`、`false` 和 `:` 命令
linux·运维·服务器·开发语言·笔记·学习·bash
·心猿意码·2 小时前
OCCT源码解析(二):NCollection解析
数据结构·c++
优化控制仿真模型2 小时前
【2026年】初中英语考纲词汇表(1600词)PDF电子版
经验分享·pdf
Honker_yhw2 小时前
大数据管理与应用系列丛书《数据挖掘》(吕欣等著)读书笔记-偏相关分析
笔记·学习