数据结构入门:从零手搓顺序表

很多同学刚啃完 C 语言的数组、结构体和动态内存分配,总会有个疑问:数组我已经会用了,为什么还要学顺序表?

其实顺序表就是数组的「升级版」—— 它在数组的基础上,封装了一套标准化的增删改查接口,还能自动扩容,让数据管理更规范、更易用。它也是我们接触的第一个线性数据结构,是后续栈、队列、链表的基础。

一、基础概念

1.1 什么是线性表?

线性表是n 个相同类型数据元素的有限序列,逻辑上是一条连续的直线。我们常说的顺序表、链表、栈、队列、字符串,本质都属于线性表。

线性表有两个特点:

  • 逻辑上连续:元素之间是一对一的前后关系
  • 物理上不一定连续:存储时可以用数组(连续),也可以用链式结构(不连续)

顺序表,就是用物理地址连续的存储单元依次存放数据的线性表,底层一般用数组实现。

1.2 顺序表和数组有什么区别?

你可以这么理解:

  • 数组是「 原材料」,只能通过下标访问,没有任何封装
  • 顺序表是「成品容器」,在数组之上封装了插入、删除、查找、自动扩容等全套功能

就像同样是西蓝花,数组是菜市场买回来的 原食材,顺序表是餐厅里做好的成品菜 —— 功能更完善、使用更规范。

二、顺序表的两种实现方式

顺序表分为静态和动态两种,核心差异在于存储空间是否可变。

2.1 静态顺序表

静态顺序表使用定长数组存储数据,空间大小在编译时就固定死了。

typedef int SLDataType;//给int类型起别名,方便后续更改要存储的数据类型
#define N 7  // 定长数组大小

typedef struct SeqList
{
    SLDataType a[N];  // 定长数组
    int size;         // 有效数据个数
}SL;

缺陷:空间大小固定,给少了不够用,给多了造成浪费。只适合数据量完全确定的极特殊场景。

2.2 动态顺序表

动态顺序表使用动态开辟的数组存储,空间不够就自动扩容,真正做到「按需申请」,这也是我们学习和使用的重点。

typedef int SLDataType;

typedef struct SeqList
{
    SLDataType* a;    // 指向动态开辟的数组
    int size;         // 当前有效数据个数
    int capacity;     // 当前数组的最大容量
}SL;

三个成员的含义:

  • a:指针,指向堆上申请的数组空间
  • size:现在已经存了多少个数据
  • capacity:这块空间最多能存多少个,满了就要扩容

三、实现动态顺序

实现顺序表,就是把初始化、增删改查、销毁这些操作,一个个写成函数。

3.1 前置准备:头文件结构

我们一般把结构定义和函数声明放在 SeqList.h 中,函数实现放在 SeqList.c 中。

// SeqList.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int SLDataType;
#define INIT_CAPACITY 4  // 初始容量

// 动态顺序表结构
typedef struct SeqList
{
    SLDataType* a;
    int size;
    int capacity;
}SL;

// 接口声明
void SLInit(SL* ps);          // 初始化
void SLDestroy(SL* ps);       // 销毁
void SLPrint(SL* ps);         // 打印

void SLCheckCapacity(SL* ps); // 检查扩容

void SLPushBack(SL* ps, SLDataType x);  // 尾插
void SLPopBack(SL* ps);                 // 尾删
void SLPushFront(SL* ps, SLDataType x); // 头插
void SLPopFront(SL* ps);                // 头删

void SLInsert(SL* ps, int pos, SLDataType x); // 指定位置插入
void SLErase(SL* ps, int pos);                // 指定位置删除
int SLFind(SL* ps, SLDataType x);             // 查找元素

typedef int SLDataType 重定义数据类型,以后想存字符、浮点数,只改这一行就行,非常灵活。

3.2 初始化与销毁

初始化

给顺序表赋初始值,初始可以开辟 4 个元素的空间。

void SLInit(SL* ps)
{
    assert(ps);  // 断言指针不为空,防止传NULL
    
    ps->a = (SLDataType*)malloc(INIT_CAPACITY * sizeof(SLDataType));
    if (ps->a == NULL)
    {
        perror("malloc fail");
        exit(-1);
    }
    
    ps->size = 0;
    ps->capacity = INIT_CAPACITY;
}
销毁

动态申请的空间必须手动释放,防止内存泄漏。

void SLDestroy(SL* ps)
{
    assert(ps);
    
    free(ps->a);   // 释放数组空间
    ps->a = NULL;  // 置空指针,避免野指针
    ps->size = 0;
    ps->capacity = 0;
}

3.3 核心:自动扩容函数

所有插入操作前,都要先检查空间够不够,不够就扩容。

void SLCheckCapacity(SL* ps)
{
    assert(ps);
    
    // 有效数据等于容量,说明满了
    if (ps->size == ps->capacity)
    {
        // 扩容为原来的2倍
        int newCapacity = ps->capacity * 2;
        SLDataType* tmp = (SLDataType*)realloc(ps->a, newCapacity * sizeof(SLDataType));
        if (tmp == NULL)
        {
            perror("realloc fail");
            exit(-1);
        }
        
        ps->a = tmp;
        ps->capacity = newCapacity;
    }
}
  • 扩容策略一般按 2 倍增长,平衡扩容次数和空间浪费
  • realloc 扩容,它会自动处理「原地扩」还是「异地扩」

3.4 尾部插入 & 删除

尾插:在数组末尾加一个元素
void SLPushBack(SL* ps, SLDataType x)
{
    assert(ps);
    SLCheckCapacity(ps);  // 先检查扩容
    
    ps->a[ps->size] = x;
    ps->size++;       //有效个数+1
}
  • 时间复杂度:O(1),直接在末尾放数据,效率极高
尾删:删除数组最后一个元素
void SLPopBack(SL* ps)
{
    assert(ps);
    assert(ps->size > 0);  // 空表不能删
    
    ps->size--;  // 直接让有效个数减1,相当于逻辑删除
}
  • 时间复杂度:O(1)

3.5 头部插入 & 删除

头插:在数组开头加一个元素

头插是顺序表的痛点:必须把所有元素从后往前依次往后挪一位,腾出第一个位置。

void SLPushFront(SL* ps, SLDataType x)
{
    assert(ps);
    SLCheckCapacity(ps);
    
    // 从最后一个元素开始,往后挪
    for (int i = ps->size - 1; i >= 0; i--)
    {
        ps->a[i + 1] = ps->a[i];
    }
    
    ps->a[0] = x;
    ps->size++;
}
  • 注意:必须从后往前挪,从前往后会导致数据覆盖
  • 时间复杂度:O(N),数据越多越慢
头删:删除数组第一个元素

把第二个元素开始,从前往后依次往前覆盖。

void SLPopFront(SL* ps)
{
    assert(ps);
    assert(ps->size > 0);
    
    for (int i = 0; i < ps->size - 1; i++)
    {
        ps->a[i] = ps->a[i + 1];
    }
    
    ps->size--;
}
  • 时间复杂度:O(N)

3.6 指定位置插入 & 删除

在 pos 下标之前插入元素
void SLInsert(SL* ps, int pos, SLDataType x)
{
    assert(ps);
    assert(pos >= 0 && pos <= ps->size);  // pos范围合法
    
    SLCheckCapacity(ps);
    
    // pos及之后的元素往后挪
    for (int i = ps->size - 1; i >= pos; i--)
    {
        ps->a[i + 1] = ps->a[i];
    }
    
    ps->a[pos] = x;
    ps->size++;
}

有了这个通用插入函数,尾插和头插都可以复用它:

  • 尾插:SLInsert(ps, ps->size, x)
  • 头插:SLInsert(ps, 0, x)
删除 pos 下标位置的元素
void SLErase(SL* ps, int pos)
{
    assert(ps);
    assert(pos >= 0 && pos < ps->size);
    
    // pos之后的元素往前挪
    for (int i = pos; i < ps->size - 1; i++)
    {
        ps->a[i] = ps->a[i + 1];
    }
    
    ps->size--;
}

同理,尾删和头删也可以复用:

  • 尾删:SLErase(ps, ps->size - 1)
  • 头删:SLErase(ps, 0)

3.7 查找元素

遍历数组,找到返回对应下标,没找到返回 -1。

int SLFind(SL* ps, SLDataType x)
{
    assert(ps);
    
    for (int i = 0; i < ps->size; i++)
    {
        if (ps->a[i] == x)
        {
            return i;
        }
    }
    return -1;
}
  • 时间复杂度:O(N)

3.8 打印函数

方便调试测试用。

void SLPrint(SL* ps)
{
    assert(ps);
    for (int i = 0; i < ps->size; i++)
    {
        printf("%d ", ps->a[i]);
    }
    printf("\n");
}

五、顺序表优缺点

优点

  1. 支持随机访问:通过下标 O (1) 直接定位元素,适合频繁查询的场景
  2. 缓存命中率高:物理空间连续,CPU 缓存友好,读取速度快

缺点

  1. 头部 / 中间插入删除效率低:需要搬移大量元素,时间复杂度 O (N)
  2. 扩容有损耗:扩容需要申请新空间、拷贝数据、释放旧空间,有一定开销
  3. 存在空间浪费:按 2 倍扩容后,用不满的部分就浪费了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值