数据结构入门:顺序表与链表的深度解析
在编程的世界里,如何高效地存储和管理数据是核心问题。线性表(Linear List)作为最基础、最常用的数据结构,是我们必须掌握的第一课。
线性表在逻辑上是一条连续的直线,但在物理存储上却分为两大流派:顺序表和链表。今天我们就来深入剖析顺序表。
文章目录
-
-
- [1. 静态顺序表 vs 动态顺序表](#1. 静态顺序表 vs 动态顺序表)
- 第一部分:顺序表代码实现
-
- [1. 项目文件划分](#1. 项目文件划分)
- [2. 类型重定义与结构体优化](#2. 类型重定义与结构体优化)
- [3. 初始化与扩容机制](#3. 初始化与扩容机制)
- [4. 增删改查接口实现](#4. 增删改查接口实现)
- [第二部分:LeetCode 经典例题实战](#第二部分:LeetCode 经典例题实战)
-
- [1. 移除元素 (LeetCode 27)](#1. 移除元素 (LeetCode 27))
- [2. 删除有序数组中的重复项 (LeetCode 26)](#2. 删除有序数组中的重复项 (LeetCode 26))
- [3. 合并两个有序数组 (LeetCode 88)](#3. 合并两个有序数组 (LeetCode 88))
- [3. 顺序表的痛点与展望](#3. 顺序表的痛点与展望)
-
什么是顺序表?
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构。简单来说,它的底层就是数组。
你可以把它想象成一家米其林餐厅:
- 数组就像是原材料(比如炒西蓝花)。
- 顺序表则是对数组进行了封装,增加了"摆盘"和"服务"(即增删改查接口),变成了一道精致的菜(比如绿野仙踪)。
1. 静态顺序表 vs 动态顺序表
- 静态顺序表:使用定长数组存储。缺陷在于空间给少了不够用,给多了造成浪费,不够灵活。
- 动态顺序表 :按需申请空间。这是我们在实际开发中更常用的形式。它包含三个核心要素:
a:指向数据的指针。size:当前有效数据的个数。capacity:当前空间的总容量。
c
// 动态顺序表结构体定义
typedef struct SeqList {
SLDataType* a; // 指向动态开辟的数组
int size; // 有效数据个数
int capacity; // 空间容量
} SL;
正如前文所述,顺序表是对数组进行了封装,增加了增删改查的接口。接下来,我们就来一步步实现这些功能。
第一部分:顺序表代码实现
1. 项目文件划分
为了方便管理和维护,我们将代码分为三个文件:
SL.h:公共接口(声明函数、定义结构体)。SL.c:具体实现(#include "SL.h",编写函数体)。test.c:测试逻辑(#include "SL.h",调用函数进行验证)。
2. 类型重定义与结构体优化
为了避免后续修改数据类型时产生大量冗余工作,我们使用 typedef 对数据类型和结构体进行重命名。
c
// 将元素类型重命名,方便后续统一修改
typedef int SLDataType;
// 动态顺序表结构体定义
typedef struct SeqList {
SLDataType* a; // 指向动态开辟的数组
int size; // 有效数据个数
int capacity; // 空间容量
} SL;
3. 初始化与扩容机制
由于后续操作需要修改结构体内部的数据,因此传参一律采用传址调用(指针)。同时,为了防范野指针,我们引入 assert.h 进行断言检查。
初始化函数:
c
void SLinit(SL* ps)
{
assert(ps);
ps->a = NULL;
ps->size = 0;
ps->capacity = 0;
}
扩容函数:
每次进行插入操作前,都需要检查空间是否充足。扩容策略为:若初始容量为0,则默认开辟4个空间;否则扩大为原来的2倍。同时,必须妥善处理 realloc 可能返回 NULL 的隐患,防止原数据丢失。
c
void SLdilat(SL* p)
{
assert(p);
int newcapacity = (p->capacity == 0 ? 4 : p->capacity * 2);
// 使用临时指针接收 realloc 的返回值,防止扩容失败导致原指针丢失
SLDataType* tmp = (SLDataType*)realloc(p->a, newcapacity * sizeof(SLDataType));
if (tmp == NULL)
{
perror("realloc fail : ");
return;
}
p->a = tmp;
p->capacity = newcapacity;
}
4. 增删改查接口实现
尾部增加元素:
c
void SLtailadd(SL* p, SLDataType x)
{
assert(p);
if (p->size == p->capacity)
{
SLdilat(p);
}
p->a[p->size++] = x; // 后置++使得代码更加简洁美观
}
尾部删除元素:
删除操作只需将有效数据个数 size 减 1 即可,无需真正清除内存中的数据。
c
void SLtaildel(SL* p)
{
assert(p);
if (p->size == 0) return;
p->size--;
}
头部增加元素:
需要将原有数据整体向后挪动一位,再在首位置赋值。
c
void SLheadadd(SL* p, SLDataType x)
{
assert(p);
if (p->size == p->capacity)
{
SLdilat(p);
}
for (int i = p->size; i > 0; i--)
{
p->a[i] = p->a[i - 1];
}
p->a[0] = x;
p->size++;
}
头部删除元素:
将第二个元素开始的数据依次向前覆盖,最后更新 size。
c
void SLheaddel(SL* p)
{
assert(p);
if (p->size == 0) return;
for (int i = 0; i < p->size - 1; i++)
{
p->a[i] = p->a[i + 1];
}
p->size--;
}
查找指定数据:
遍历数组寻找目标数据,找到返回其索引,找不到返回 -1。
c
int SLfind(SL* p, SLDataType x)
{
assert(p);
for (int i = 0; i < p->size; i++)
{
if (p->a[i] == x)
return i;
}
return -1;
}
在指定位置前插入数据:
结合查找函数,先定位索引,再将目标位置及之后的数据整体后移。
c
void SLfixadd(SL* p, SLDataType x, int pos)
{
assert(p);
if (p->size == p->capacity)
{
SLdilat(p);
}
for (int i = p->size; i > pos; i--)
{
p->a[i] = p->a[i - 1];
}
p->a[pos] = x;
p->size++;
}
打印函数(用于测试):
c
void SLprint(SL* p)
{
assert(p);
for (int i = 0; i < p->size; i++)
{
printf("%d\t", p->a[i]);
}
printf("\nsize: %d\tcapacity: %d\n", p->size, p->capacity);
}
测试代码示例:
c
#include "SL.h"
int main()
{
SL s;
SLinit(&s);
SLtailadd(&s, 8);
SLprint(&s);
SLheadadd(&s, 6);
SLprint(&s);
printf("请输入你指定的数字:");
SLDataType i; scanf("%d", &i);
int pos = SLfind(&s, i);
if (pos == -1) {
printf("你提供的数值不在该顺序表里面\n");
} else {
printf("请输入你想要插入的数字:");
SLDataType j; scanf("%d", &j);
SLfixadd(&s, j, pos);
SLprint(&s);
}
return 0;
}
第二部分:LeetCode 经典例题实战
掌握了顺序表的基础操作后,我们通过三道经典题目来巩固"双指针法"这一核心思想。
1. 移除元素 (LeetCode 27)
思路 :使用快慢双指针。sou 指针负责遍历数组,当遇到不等于 val 的值时,将其赋值给 des 指针指向的位置,然后 des 后移。最终 des 与起始位置的差值即为新数组的长度。
c
int removeElement(int* nums, int numsSize, int val)
{
int* sou = nums;
int* des = nums;
for(int i = 0; i < numsSize; i++)
{
if(*sou != val)
{
*des = *sou;
des++;
}
sou++;
}
return des - nums;
}
2. 删除有序数组中的重复项 (LeetCode 26)
思路 :同样使用双指针。des 初始在首位,sou 从第二位开始遍历。当 sou 指向的值与 des 不同时,des 先自增,再接收 sou 的值。注意:由于是先自增再赋值,最终返回的长度需要 des - nums + 1(或者在循环外处理)。
c
int removeDuplicates(int* nums, int numsSize){
if (numsSize == 0) return 0;
int* des = nums;
int* sou = nums + 1;
for(int i = 1; i < numsSize; i++)
{
if(*sou != *des)
{
des++;
*des = *sou;
}
sou++;
}
return des - nums + 1;
}
3. 合并两个有序数组 (LeetCode 88)
思路 :为了避免从头开始比较带来的数据搬移开销,我们采用"逆向双指针"法。从两个数组的有效尾部开始比较,将较大的元素放到 nums1 的最终尾部,依次向前填充。
c
void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n)
{
int end = nums1Size - 1;
while(m > 0 && n > 0)
{
if(nums1[m - 1] > nums2[n - 1])
{
nums1[end--] = nums1[m - 1];
m--;
}
else
{
nums1[end--] = nums2[n - 1];
n--;
}
}
// 如果 nums2 还有剩余,直接拷贝到 nums1 前面
while(n > 0)
{
nums1[end--] = nums2[n - 1];
n--;
}
}
3. 顺序表的痛点与展望
虽然顺序表支持随机访问(下标访问,时间复杂度 O ( 1 ) O(1) O(1)),但它也有明显的短板:
- 头部/中间插入删除效率低 :需要搬移大量数据,时间复杂度为 O ( N ) O(N) O(N)。
- 扩容成本高:当空间不足时,需要申请新空间、拷贝数据、释放旧空间。
- 空间浪费:扩容通常呈2倍增长,如果插入少量数据后不再操作,剩余空间会被白白浪费。
那么,有没有办法解决这些痛点呢?有的!这就需要用到链表了。不过,那就是我们下一篇博客要探讨的内容了,敬请期待!