【数据结构】动态顺序表(C语言实现)

简介: 【数据结构】动态顺序表(C语言实现)

1. 线性表


   线性表(linear list)是n个具有相同特性的数据元素的有限序列。 线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串…


   线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的,线性表在物理上存储时,通常以数组和链式结构的形式存储。


72bfcd8ee018ece4ffadbd3d77e62e4f.png



2. 顺序表


2.1 概念及结构


顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。在数组上完成数据的增删查改。


注:顺序表本质上就是数组,但是在数组的基础上,它还要求数据是连续存储,不能跳跃间隔。


顺序表一般可以分为:

  1. 静态顺序表:使用定长数组存储。
  2. 动态顺序表:使用动态开辟的数组存储。
// 顺序表的静态存储
#define N 100
typedef int SLDataType;// 让数据存储多种类型
typedef struct SeqList
{
    SLDataType a[N]; // 定长数组
    int size; // 有效数据个数
}SeqList;
// 顺序表的动态存储
typedef struct SeqList
{
    SLDataType* array; // 指向动态开辟的数组
    int size ; // 表示数组中存储了多少个数据
    int capicity ; // 数组实际能存数据的空间容量是多大
}SeqList


注:


   静态顺序表使用#define定义长度,让大小修改起来更方便。


   为存储多种类型的数据,将数据类型typedef重命名为SLDataType,这样更加直观。当存储类型想改变时,直接更改数据类型即可。


   顺序表的类型很繁琐,可以用typedef重命名为SL,更加简洁。


两种顺序表的对比:


静态顺序表的最大的缺陷就是空间问题,它只适用于确定知道要存多少数据的场景。静态顺序表的特点是如果顺序表满了,就不让插入元素。静态顺序表的缺点也很直观:空间开辟多了,浪费空间。空间开辟少了,空间不足。到底给多大的空间?这个很难确定。


所以现实中静态顺序表很少使用,我们基本都是使用动态顺序表,根据需要动态的分配空间大小。下面我们使用C语言实现动态顺序表。



3. 动态顺序表的实现


我们实现一个动态顺序表,分为三部分:


  1. SeqList.h:头文件,结构、常量的定义,接口函数的声明…
  2. test.c:用于顺序表功能的测试
  3. SeqList.c:接口函数的实现

接下来我们的功能主要在SeqList.c中实现~


3.1 定义结构


相比较于静态顺序表,动态顺序表的结构由三个成员组成:


  1. 指向动态开辟空间的data指针
  2. 记录当前有效数据个数的size变量
  3. 记录当前容量的capacity变量


typedef struct SeqList
{
  SLDataType* a;
  int sz;// 表示数组中存储了多少个数据
  int capacity;// 数组实际能存数据的空间容量是多大
}SL;


我们比静态顺序表多了一个capacity成员。我们用这个成员记录当前顺序表的最大容量,当有效数据个数size和capacity相等时。说明当前顺序表空间已满,需要进行扩容。




3.2 接口函数总览


我们实现动态顺序表,就要实现对应的功能,例如增删查改等等…


这些功能我们会封装成函数,而当我们调用时,就会对接到对应函数,所以我们称这些函数为接口函数


动态顺序表的实现需要如下接口:

void SeqListInit(SL* ps);// 初始化
void SeqListCheckCapacity(SL* ps);// 检查增容
void SeqListPushBack(SL* ps, SLDataType x);// 尾插
void SeqListPopBack(SL* ps);// 尾删
void SeqListPushFront(SL* ps, SLDataType x);// 头插
void SeqListPopFront(SL* ps);// 头删
void SeqListPrint(SL* ps);// 打印
void SeqListDestory(SL* ps);// 销毁
int SeqListFind(SL* ps, SLDataType x);// 查找
void SeqListInsert(SL* ps, int pos, SLDataType x);// 指定下标位置插入
void SeqListErase(SL* ps, int pos);// 指定下标位置删除
void SeqListModify(SL* ps, int pos, int x);// 修改


这里需要提一下,为什么我函数接口的名称起的这么繁杂:

这其实是C++中STL的命名风格,STL库中,也有数据结构的相关函数。为了以后学习STL更加轻松。所以提前适应起来,方便以后学习。



3.3 初始化


顺序表在进行操作之前,需要先将其内容进行初始化,防止之后操作时出错。

void SeqListInit(SL* ps);


在实现这个接口之前,我们思考一下,参数可不可以为结构体?比如这样:

void SeqListInit(SL ps)
{
  ps.a = NULL;
  ps.sz = ps.capacity = 0;
}


这时绝对不行的!要知道实参在传参时,会形成一份临时拷贝,叫做形参。当我们在函数中对形参的内容进行修改时,不会影响到实参,所以,不可行!

想要正确的初始化结构体,我们需要传sl的地址,通过指针对结构体内容进行修改:

void SeqListInit(SL* ps)
{
  assert(ps);
  ps->a = NULL;// 置空
  ps->sz = ps->capacity = 0;// 初始大小和容量设定为0
}


3.4 检查增容


当顺序表需要插入数据时,可能会遇到三种情况:


  1. 整个顺序表没有空间。
  2. 空间不够,需要扩容。
  3. 空间足够,直接插入数据。



如果顺序表空间足够,那么不需要扩容,通过相关操作插入数据;如果空间不足或者根本没有空间,那么就得扩容。

当顺序表没有空间时,我们开辟四个空间;当顺序表空间不足,我们将当前空间扩大为两倍(扩两倍是为了防止扩容过度,或扩小了频繁扩容,消耗过大)。

当顺序表空间足够,不进行任何操作,if语句判断后,直接返回。


void SeqListCheckCapacity(SL* ps)
{
  assert(ps);
  // 顺序表没有空间or顺序表空间不足
  if (ps->sz == ps->capacity)
  {
    // 没扩容,扩四个整形;空间不足,扩两倍
    int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
    // realloc在起始地址为空指针时,和malloc一样效果
    SLDataType* tmp = (SLDataType*)realloc(ps->a, newcapacity * sizeof(SLDataType));
    if (tmp == NULL)
    {
      printf("realloc fail\n");
      exit(-1);
    }
    ps->a = tmp;
    ps->capacity = newcapacity;
  }
}


3.5 尾插


在顺序表尾部插入数据:

void SeqListPushBack(SL* ps, SLDataType x);

在插入数据前,需要检查空间情况,所以要先调用SeqListCheckCapacity函数,对异常状况进行操作。

void SeqListPushBack(SL* ps, SLDataType x)
{
  assert(ps);
  SeqListCheckCapacity(ps);// 检查增容
  ps->a[ps->sz] = x;// 在尾部插入
  ps->sz++;
}


3.6 尾删


在顺序表尾部插入数据:


void SeqListPopBack(SL* ps);

要实现尾删,那么我们只需要让sz--即可,因为sz标识了顺序表存了多少个有效数据。直接减少sz,那么之后的元素想操作也没办法了。


但是请注意,当顺序表没有元素,也就是sz为0时,不可删除。


为什么这么说,举个例子:


假如顺序表已有五个数据,我尾删六个数据,那么这时,我的顺序表sz = -1。这时候调用打印的接口,由于 sz = -1 并不会进行打印,所以不会出错。


如果我继续尾插数据,这时就会出现问题,我们就会访问到-1下标,也就是会在-1下标插入数据,这就越界访问了,进行了越界写。这时程序仍然不会报错。


但是当我们销毁顺序表时,程序会奔溃。因为程序在平时是几乎不对内存进行检查的,当销毁时,会对空间进行检查是否越界。


这也是为什么数组越界总在 free 处报错的原因。


所以我们需要谨记:free 主动报错只有两种情况


   free 释放的为野指针。


   free 释放的内存不是动态开辟的内存,或者释放的空间不完整。


   如果这两个都没有错误,那一定是越界访问,但是 free 不一定报错。


void SeqListPopBack(SL* ps)
{
  assert(ps);
  // 温柔处理
  //if (ps->sz > 0)// 不做出反应
  //{
  //  //ps->a[ps->sz - 1] = 0 ;// 不需要,sz标识顺序表的元素个数
  //  ps->sz--;
  //} 
  // 暴力处理
  assert(ps->sz > 0);// 直接终止程序,报错
  ps->sz--;
}


3.7 头插


在顺序表头部插入数据:

void SeqListPushFront(SL* ps, SLDataType x);

要实现这一功能,需要保证原来的数据不能被覆盖。因此,我们将数据从后往前依次后挪,使用一个end下标来实现。

image-20221001204302946.png

但是这里也要考虑增容的问题,否则会越界访问,在销毁顺序表时,程序会奔溃。

void SeqListPushFront(SL* ps, SLDataType x)
{
  assert(ps);
    SeqListCheckCapacity(ps);// 检查增容
    // 挪动数据
    int end = ps->sz - 1;
  while (end >= 0)// 一直挪到0下标
  {
    ps->a[end + 1] = ps->a[end];
    end--;
  }
  ps->a[0] = x;
  ps->sz++;
}



3.8 头删


从顺序表头部删除数据:

void SeqListPopFront(SL* ps);

要实现头删,那么我们可以给定一个begin下标,让数据从前往后依次前挪,保证原来的数据不被覆盖。

image-20221001204331923.png

void SeqListPopFront(SL* ps)
{
  assert(ps);
  assert(ps->sz > 0);// 暴力处理,sz为0时,不能头删
  // 挪动数据
  int begin = 1;
  while (begin <= ps->sz - 1)
  {
    ps->a[begin - 1] = ps->a[begin];
    begin++;
  }
    // memmove(ps->a, ps->a + 1, (ps->sz - 1) * sizeof(SLDataType));
    // 这样也可以,将1下标开始的元素,向前移动sz-1个单位
    // 但是并不推荐,还是自己动手实现比较好
  ps->sz--;
}


3.9 查找


查找一个元素在顺序表中的位置,返回下标:

int SeqListFind(SL* ps, SLDataType x);

这个就非常简单了,使用循环遍历顺序表,找到数据返回对应下标,找不到数据返回-1。


int SeqListFind(SL* ps, SLDataType x)
{
  assert(ps);
  // 找到了就返回元素出现的第一个位置
  for (int i = 0; i < ps->sz; i++)
  {
    if (ps->a[i] == x)
    {
      return i;
    }
  }
  return -1;
} 


3.10 指定下标位置插入


在指定pos下标处插入元素:

void SeqListInsert(SL* ps, int pos, SLDataType x);


要实现这一功能,我们依然需要一个end下标,数据从后往前依次后挪,直到pos下标移动完毕。另外,别忘了检查容量。


image-20221002133930359.png

void SeqListInsert(SL* ps, int pos, SLDataType x)
{
  assert(ps);
  // 温柔处理
  //if (pos > ps->sz || pos < 0)
  //{
  //  printf("pos invaild\n");
  //  return;
  //}
  // 暴力处理
  // 断言表达式为真,相安无事
  // 为假,直接报错
  // 这两个表达式只要有一个不满足条件,便报错
  assert(pos >= 0 && pos <= ps->sz);// 包含头插和尾插
  SeqListCheckCapacity(ps);// 检查增容
  int end = ps->sz - 1;
  // 从后往前依次向后挪
  while (end >= pos)
  {
    ps->a[end + 1] = ps->a[end];
    end--;
  }
  ps->a[pos] = x;
  ps->sz++;
}

该功能其实也可以实现头插和尾插,所以我们可以在头插尾插复用该功能:

// 头插
void SeqListPushFront(SL* ps, SLDataType x)
{
  SeqListInsert(ps, 0, x);
}
// 尾插
void SeqListPushBack(SL* ps, SLDataType x)
{
  SeqListInsert(ps, ps->sz, x);
}


相关文章
|
12月前
|
算法 数据处理 C语言
C语言中的位运算技巧,涵盖基本概念、应用场景、实用技巧及示例代码,并讨论了位运算的性能优势及其与其他数据结构和算法的结合
本文深入解析了C语言中的位运算技巧,涵盖基本概念、应用场景、实用技巧及示例代码,并讨论了位运算的性能优势及其与其他数据结构和算法的结合,旨在帮助读者掌握这一高效的数据处理方法。
500 1
|
12月前
|
存储 算法 搜索推荐
【趣学C语言和数据结构100例】91-95
本文涵盖多个经典算法问题的C语言实现,包括堆排序、归并排序、从长整型变量中提取偶数位数、工人信息排序及无向图是否为树的判断。通过这些问题,读者可以深入了解排序算法、数据处理方法和图论基础知识,提升编程能力和算法理解。
167 4
|
9月前
|
定位技术 C语言
c语言及数据结构实现简单贪吃蛇小游戏
c语言及数据结构实现简单贪吃蛇小游戏
|
10月前
|
搜索推荐 C语言
数据结构(C语言)之对归并排序的介绍与理解
归并排序是一种基于分治策略的排序算法,通过递归将数组不断分割为子数组,直到每个子数组仅剩一个元素,再逐步合并这些有序的子数组以得到最终的有序数组。递归版本中,每次分割区间为[left, mid]和[mid+1, right],确保每两个区间内数据有序后进行合并。非递归版本则通过逐步增加gap值(初始为1),先对单个元素排序,再逐步扩大到更大的区间进行合并,直至整个数组有序。归并排序的时间复杂度为O(n*logn),空间复杂度为O(n),且具有稳定性,适用于普通排序及大文件排序场景。
|
10月前
|
机器学习/深度学习 存储 C++
【C++数据结构——线性表】顺序表的基本运算(头歌实践教学平台习题)【合集】
本文档介绍了线性表的基本运算任务,涵盖顺序表和链表的初始化、销毁、判定是否为空、求长度、输出、查找元素、插入和删除元素等内容。通过C++代码示例详细展示了每一步骤的具体实现方法,并提供了测试说明和通关代码。 主要内容包括: - **任务描述**:实现顺序表的基本运算。 - **相关知识**:介绍线性表的基本概念及操作,如初始化、销毁、判定是否为空表等。 - **具体操作**:详述顺序表和链表的初始化、求长度、输出、查找、插入和删除元素的方法,并附有代码示例。 - **测试说明**:提供测试输入和预期输出,确保代码正确性。 - **通关代码**:给出完整的C++代码实现,帮助完成任务。 文档
292 5
|
11月前
|
数据库
数据结构中二叉树,哈希表,顺序表,链表的比较补充
二叉搜索树,哈希表,顺序表,链表的特点的比较
数据结构中二叉树,哈希表,顺序表,链表的比较补充
|
12月前
|
存储 缓存 算法
在C语言中,数据结构是构建高效程序的基石。本文探讨了数组、链表、栈、队列、树和图等常见数据结构的特点、应用及实现方式
在C语言中,数据结构是构建高效程序的基石。本文探讨了数组、链表、栈、队列、树和图等常见数据结构的特点、应用及实现方式,强调了合理选择数据结构的重要性,并通过案例分析展示了其在实际项目中的应用,旨在帮助读者提升编程能力。
346 5
|
12月前
|
并行计算 算法 测试技术
C语言因高效灵活被广泛应用于软件开发。本文探讨了优化C语言程序性能的策略,涵盖算法优化、代码结构优化、内存管理优化、编译器优化、数据结构优化、并行计算优化及性能测试与分析七个方面
C语言因高效灵活被广泛应用于软件开发。本文探讨了优化C语言程序性能的策略,涵盖算法优化、代码结构优化、内存管理优化、编译器优化、数据结构优化、并行计算优化及性能测试与分析七个方面,旨在通过综合策略提升程序性能,满足实际需求。
389 1
|
存储 算法
非递归实现后序遍历时,如何避免栈溢出?
后序遍历的递归实现和非递归实现各有优缺点,在实际应用中需要根据具体的问题需求、二叉树的特点以及性能和空间的限制等因素来选择合适的实现方式。
265 59
|
5月前
|
编译器 C语言 C++
栈区的非法访问导致的死循环(x64)
这段内容主要分析了一段C语言代码在VS2022中形成死循环的原因,涉及栈区内存布局和数组越界问题。代码中`arr[15]`越界访问,修改了变量`i`的值,导致`for`循环条件始终为真,形成死循环。原因是VS2022栈区从低地址到高地址分配内存,`arr`数组与`i`相邻,`arr[15]`恰好覆盖`i`的地址。而在VS2019中,栈区先分配高地址再分配低地址,因此相同代码表现不同。这说明编译器对栈区内存分配顺序的实现差异会导致程序行为不一致,需避免数组越界以确保代码健壮性。
100 0
栈区的非法访问导致的死循环(x64)