前言:
从本篇博客开始,我们会逐渐接触一些数据结构,并且我们将会用C语言实现这些数据结构。我希望从这篇博客开始,读者能够学会画好图在写代码,代码运行出错进行自主调试来分析错误,如果你能够养成这两个良好习惯,那么不仅对你后续的数据结构学习有很大的帮助,而且还会对未来从事本行业有很大的帮助!
目录
1.什么是顺序表
2.顺序表的增删改查
3.顺序表的优缺点
4.顺序表相关的OJ题
一.什么是顺序表:
//静态顺序表
#define N 7
typedef int SLTDataType;
typedef struct SeqList
{
SLTDataType a[N];
int size;
}SeqList
所以说在实际的开发过程中使用的更多的是动态的顺序表,使用动态开辟的数组存储。
//动态顺序表的结构
typedef int SLDateType;
typedef struct SeqList
{
SLDateType* a;
size_t size;
size_t capacity; // unsigned int
}SeqList;
2.顺序表的增删改查
了解了顺序表的结构,接下来我们进行顺序表的增删改查,我们实现的是动态的顺序表,使用的计算机语言是C语言。和标准的工程一样,我们创建三个文件:
头文件:SeqList.h---->存放结构体的定义和函数的接口声明
源文件 SeqList.c----->实现函数的功能的源文件
Test.c---->测试函数功能的源文件
下面是SeqList.h的内容:
#pragma once//防止头文件重复包含
#include
#include //断言函数所需要的头文件
#include
typedef int SLDateType;
typedef struct SeqList
{
SLDateType* a;
size_t size;
size_t capacity; // unsigned int
}SeqList;
// 对数据的管理:增删查改
void SeqListInit(SeqList* ps);
//释放顺序表
void SeqListDestory(SeqList* ps);
//打印顺序表
void SeqListPrint(SeqList* ps);
//尾插数据
void SeqListPushBack(SeqList* ps, SLDateType x);
//头插数据
void SeqListPushFront(SeqList* ps, SLDateType x);
//头删数据
void SeqListPopFront(SeqList* ps);
//尾删数据
void SeqListPopBack(SeqList* ps);
// 顺序表查找
int SeqListFind(SeqList* ps, SLDateType x);
// 顺序表在pos位置插入x
void SeqListInsert(SeqList* ps, size_t pos, SLDateType x);
// 顺序表删除pos位置的值
void SeqListErase(SeqList* ps, size_t pos);
//检查容量的接口
void SeqListCheckCapacity(SeqList* ps);
我们接下来按顺序逐一实现每个函数:
首先SeqListInit的作用是对顺序表进行初始化,显然开始的顺序表里什么都没有,所以我们可以这样初始化:
void SeqListInit(SeqList* ps)
{
assert(ps);
ps->a = NULL;
ps->size = 0;
ps->capacity = 0;
}
这里我们传递的是结构体指针而非结构体。原因有二:1.我们要修改对应的结构体就必须要传递地址才能对它起到真正的修改作用!2.结构体指针传参的传递效率高(次要原因)而我们因为要涉及对结构体指针的解引用,所以我们要对ps指针断言,防止对空指针的解引用!
2.因为动态顺序表使用的是动态内存管理的相关知识,所以在不需要顺序表了以后要释放内存,防止造成内存泄露,所以我们写了SeqListDestroy函数来释放顺序表:
void SeqListDestory(SeqList* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->size = 0;
ps->capacity = 0;
}
3.打印顺序表的接口很简单,直接上代码不做过多解释:
void SeqListPrint(SeqList* ps)
{
assert(ps);
for (size_t i = 0; i < ps->size; i++)
{
printf("%d ", ps->a[i]);
}
printf("\n");
}
4.顺序表尾插数据
假设我们有这样的一个顺序表:[1,2,3,4],接下来我们要在尾部插入数据5,我们应该怎么做呢?首先,我们知道顺序表除了动态分配的指针外,还有记录有效数据数量大小的size以及顺序表容量capacity,不难可以观察出size总是指向当前顺序表最后一个元素的下一个位置,因此我们直接就可以在下标为size的地方放元素。但是我们当size==capacity的情况要进行扩容,所以在每一次插入元素之前都要进行检查,所以我们可以把检查容量也封装成一个接口:
//检查容量的接口
void SeqListCheckCapacity(SeqList* ps)
{
assert(ps);
if (ps->size == ps->capacity)
{ //处理第一次是0的特殊情况
size_t NewCapacity = ps->capacity == 0 ? 2 : ps->capacity * 2;
SLDateType* tmp = (SLDateType*)realloc(ps->a, NewCapacity * sizeof(SLDateType));
if (NULL == tmp)
{
printf("realloc fail\n");
exit(-1);
}
else
{
ps->a = tmp;
ps->capacity = NewCapacity;
}
}
}
void SeqListPushBack(SeqList* ps, SLDateType x)
{
assert(ps);
SeqListCheckCapacity(ps);
ps->a[ps->size] = x;
ps->size++;
}
这就是顺序表尾插元素的实现,相对来说比较简单,尾插数据的时间复杂度是O(1),所以对于顺序表尾插数据是非常快速的。
5.顺序表的头插数据:
相比较于尾插数据,头插数据就会复杂一点,那么头插要在顺序表的头部插入数据,而顺序表为了保持数据连续存储的特点,因此要对数据进行挪动,我们先来画图分析怎么挪动
我们发现,这种从前向后挪动的方式会把后面的数据给覆盖,因此我们需要从最后一个数据开始进行挪动,我们定义一个end游标完成这个动作,具体的执行动作如下:
那么画出了逻辑图,接下来写代码就比较容易了:
void SeqListPushFront(SeqList* ps, SLDateType x)
{
assert(ps);
SeqListCheckCapacity(ps);
size_t end = ps->size;
while (end > 0)
{
ps->a[end] = ps->a[end - 1];
--end;
}
ps->a[0] = x;
ps->size++;
}
好了,到这里我们就写好了头插和尾插,这里在提一提我的这个函数的命名。这里的头插和尾插你也可以叫做insertfront或者是insertback,但是千万不要用拼音,面试官看到你会对你的印象分大打折扣,也会被同事嘲笑!!至于这里我取名pushback和pushfront的原因是因为C++的STL就是这样的命名头插和尾插的。
讲了头插和尾插,对应的我们就要介绍头删和尾删,先来介绍尾删:
尾删就是从尾部删除数据,但是需要注意的是,这个数据并不是真正地被删除!类似于函数栈帧的释放,我们所谓的删除数据的本质就是这个数据可以被覆盖!这点要特别注意:
尾删的代码很简单,只要size--即可,但是要注意当size成0的时候就不要在执行自减操作了
void SeqListPopBack(SeqList* ps)
{
assert(ps);
//assert(ps->size);暴力检查
if (ps->size > 0)
{
ps->size--;
}
}
头删:和头插一样,进行头删操作的时候同样需要挪动数据,所有的数据都要向前挪动一位,我们同样通过画图分析我们应该怎么挪动数据
画完逻辑图以后,我们就可以上手写头删的代码
void SeqListPopFront(SeqList* ps)
{
assert(ps);
size_t begin = 1;
if (ps->size > 0)
{
while (begin < ps->size)
{
ps->a[begin - 1] = ps->a[begin];
++begin;
}
ps->size--;
}
}
在有些的应用场景下,我们要在顺序表的任意位置插入和删除元素,所以我们还需要提供这样的一类的接口,我们把插入的方法命名为Insert和Erase(c++标准库的命名规范)
Insert方法可以指定插入的下标pos,我们可以讲向指定的位置插入元素,具体的实现过程如下图:
那么结合图片,我们就可以写出如下的代码:
void SeqListInsert(SeqList* ps, size_t pos, SLDateType x)
{
assert(ps);
SeqListCheckCapacity(ps);
//处理非法位置
if (pos > ps->size)
{
printf("pos非法:: pos=%d\n", pos);
}
size_t end = ps->size;
//挪动数据
while (end > pos)
{
ps->a[end] = ps->a[end - 1];
--end;
}
ps->a[pos] = x;
ps->size++;
}
我们接下来实现在 任意位置删除的方法Erase,类比于前面的尾删和头删,顺序表的删除的本质是将原来位置的数据覆盖,和Insert方法类似,Erase方法也需要挪动元素,我们可以画图分析:
有了图的分析,接下来我们就可以实现代码了
// 顺序表删除pos位置的值
void SeqListErase(SeqList* ps, size_t pos)
{
assert(ps);
//assert(pos < ps->size);暴力检查
if (pos > ps->size)
{
printf("pos越界,pos:: %u\n", pos);
return;
}
//size大于0才调用删除
if (ps->size > 0)
{
size_t begin = pos + 1;
//挪动pos+1以后的数字
while (begin < ps->size)
{
ps->a[begin - 1] = ps->a[begin];
++begin;
}
ps->size--;
}
else
{
printf("顺序表为空\n");
return;
}
}
现在只剩下了find没有完成了,这个函数就是遍历顺序表,代码如下:
// 顺序表查找
int SeqListFind(SeqList* ps, SLDateType x)
{
for (int i = 0; i < ps->size; i++)
{
if (x == ps->a[i])
{
return i;
}
}
return -1;
}
那么到这里,一份简易的动态顺序表就写好了。但是,其实我们的头插尾插以及头删尾删是可以复用Insert和Erase的,即:
void SeqListPushBack(SeqList* ps, SLDateType x)
{
//在size处调用Insert
SeqListInsert(ps, ps->size, x);
}
void SeqListPushFront(SeqList* ps, SLDateType x)
{
//在pos==0处用Insert
SeqListInsert(ps, 0, x);
}
void SeqListPopFront(SeqList* ps)
{
//在pos==0处调用Erase
SeqListErase(ps, 0);
}
void SeqListPopBack(SeqList* ps)
{
//在pos==ps->size-1处调用Erase
SeqListErase(ps, ps->size-1);
}
3.顺序表的优缺点
从结构可以看出,顺序表的一个最大的优点就是我们访问顺序表的元素的时间复杂度是O(1),但它的缺点也很明显,就是除了在尾部插入和删除元素是o(1)的时间复杂度,其他位置的插入和删除元素的时间复杂度是O(n)!另外顺序表扩容的时候也不可避免地会带来性能的消耗和空间的浪费
4.顺序表的OJ题
前面,我们 了解了顺序表的增删查改,大多数真实的面试的场景下并不会直接让你上手写增删查改功能,而是通过一些OJ题来考察增删查改,下面我们就来看几道顺序表相关的OJ题:
1.删除有序数组中的重复项
https://leetcode-cn.com/problems/remove-duplicates-from-sorted-array/
那么如果考虑使用额外空间的话,那么想法就比较简单:
我们思路是新开一个空间,用一个指针指向源数组的元素,另一个指针指向目标空间的元素,如果没有重复就拷贝,最后再把目标空间拷贝到原空间,最后返回目标空间的数组长度就可以了。很显然这样做并不满足题目中O(1)的
解决方案:双指针法,以案例2画图分析为例:
画好图,接下来写代码就能一气呵成,写出的代码一跑就能过:
int removeDuplicates(int* nums, int numsSize){
//快慢双指针
int dst=0,src=1;//slow表示有效答案的下标
while(src
2.原地移除值为val的元素:https://leetcode-cn.com/problems/remove-element/
这道题的思路和第一道题相似,利用快慢指针就可以处理,我这里就不画图了,直接上代码
int removeElement(int* nums, int numsSize, int val){
//双指针法
int slow=0,fast=0;
while(fast
3.合并两个有序数组:https://leetcode-cn.com/problems/merge-sorted-array/
方法2的代码如下:
void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n){
//从后向前遍历,取较大的插入新的数组的末尾
int end1=m-1;
int end2=n-1;
int end=m+n-1;
while(end1>=0 && end2>=0)
{
if(nums1[end1]>nums2[end2])
{
nums1[end--]=nums1[end1--];
}
else
{
nums1[end--]=nums2[end2--];
}
}
//处理nums2没结束的情况
while(end2>=0)
{
nums1[end--]=nums2[end2--];
}
}
这里我们只需要处理nums2还没有结束的情况,因为我们是合并到nums1所以我们并不需要处理nums1为空的情况,所以我们只要处理nums2不为空的情况就可以了。
基于顺序表有这在插入和删除方面的性能的消耗,所以说我们后续会使用一个方便插入和删除的数据结构----->链表,敬请期待