携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第17天,点击查看活动详情
前言
在介绍完复杂度以后,接下来就是数据结构的学习了,先从最简单的线性表入手,本文是基于C语言实现的。
本文就来分享一波作者对数据结构线性表的学习心得与见解。本篇属于第一篇,主要介绍线性表的顺序表的一些内容,下篇就讲链表了。
笔者水平有限,难免存在纰漏,欢迎指正交流。
线性表定义与理解
定义:线性表(linear list)是n个具有相同特性的数据元素的有限序列。
线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串... 线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的,线性表在物理上存储时,通常以数组和链式结构的形式存储 。
线性表强调的是有序和有限。
若将线性表记为(a1 ,...,ai-1 ,ai,ai+1,...,an),则ai-1 领先于ai ,ai 领先于ai+1 ,称ai-1 是ai 的直接前驱元素,ai+1 是ai 的直接后继元素。第一个元素无前驱,最后一个元素无后继,其他元素有且仅有一个前驱和后继。
所以线性表元素的个数n(n>=0)定义为线性表的长度,当n=0时,称为空表。在非空表中每个数据元素都有一个确定的位置,用下标(比如ai)来表示,称i为数据元素ai在线性表中的位序。
顺序表
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存 储。在数组上完成数据的增删查改等操作。
顺序表一般可以分为:静态顺序表和动态顺序表
静态顺序表:使用定长数组存储元素
数据结构中的命名要有一定的规范,我们把要用到的类型int重命名为DataType,方便日后修改,不过为了表明这是顺序表特有的,在前面加个前缀,即SLDataType,SL即sequent list(顺序表)的首字母大写缩写。接下来我们定义结构体,顺便重命名一下为SeqList,其中第一个成员是顺序表的主体——定长数组,这里用宏定义一个常量来作为数组长度,第二个成员就是顺序表中有效数据的个数size。
动态顺序表:使用动态开辟的数组存储
说明
静态顺序表只适用于确定知道需要存多少数据的场景。静态顺序表的定长数组一般需要N定大些,空间开多了浪费,而开少了又不够用。所以现实中基本都是使用动态顺序表,根据需要动态的分配空间大小,所以下面我们实现动态顺序表。
动态顺序表使用的是动态开辟的内存,通过realloc函数来实现扩容,那么问题来了,是不是每次增加一个元素我就扩容一格?这样效率太低了,因为使用realloc扩容是有一定代价的,你一次才扩容一个,次数太频繁了,realloc有两种扩容方式,一是原地扩容,这倒还好些,另一个是另寻空间,拷贝元素到新空间,再把旧空间释放,确实麻烦。那怎么设计扩容呢?
我们知道,扩容:一次扩多了,存在空间浪费;一次扩少了,需要频繁扩容,有效率的损失。
我们这里可以先暂时考虑每次扩充为容量的2倍,相对来说比较合适,不会太多,也不会太少。
顺序表的结构声明要有所变化了:数组用动态开辟的内存,新增一个记录顺序表容量大小的变量capacity。
初始化和销毁
在设计函数的时候,基本上都要传结构指针,因为要对顺序表做改动,如果传结构体的话无法修改,形参是实参的一份临时拷贝,改变形参并不会影响实参,除非传入实参的地址,解引用后修改。
初始化的话直接把顺序表中的指针置零,也可以在这里先用malloc开辟一段初始空间,不过我们这里先置为零。(从此往后的assert()是一个宏,如果括号内的表达式值为真就不操作,如果为假就会中断程序并报错,主要用来应对传入指针为空的情况)
void InitSeqList(SeqList* ps1)
{
assert(ps1);
ps1->array = NULL;
ps1->size = 0;
ps1->capacity = 0;
}
销毁顺序表的话也很简单,记得free释放动态开辟的内存,然后全部置为零即可。
void DestorySeqList(SeqList* ps1)
{
assert(ps1);
free(ps1->array);
ps1->array = NULL;
ps1->size = 0;
ps1->capacity = 0;
}
头插尾插
即顺序表的头部插入和尾部插入操作。先看看尾插,是不是感觉好像挺简单的嘛,直接在ps1->size位置处插入元素并让ps1->size自增1不就行了吗?但是你有没有想过一个问题,我们还没有开辟动态内存呢,而且满容了需要扩容怎么办,这些要综合考虑。
尾插前要先检查容量看是否需要扩容,这一部分其实不止会在尾插过程中出现,所以我们把它封装成一个函数先。
static void CheckCapacity(SeqList* ps1)
{
assert(ps1);
if (ps1->capacity == ps1->size)
{
int newCapacity = (ps1->capacity == 0) ? 4 : ps1->capacity * 2;
SLDataType* tmp = (SLDataType*)realloc(ps1->array, sizeof(SLDataType) * newCapacity);
if (tmp == NULL)
{
perror("realloc NULL");
return;
}
ps1->array = tmp;
ps1->capacity = newCapacity;
}
}
用static修饰是因为我希望该函数仅在当前文件中使用,作为其他操作函数的辅助函数。判断容量是否已满(如果当前有效数据个数达到容量就是满了),满了的话就扩容。如果是刚初始化的顺序表,就先扩容到四个元素大小,不然就让容量翻倍(乘以2)。对于动态内存的扩容,我们选用realloc函数,如果遇到刚初始化的顺序表,ps1->array还是NULL的话也不打紧,正好realloc函数传空指针后作用类同于malloc函数,会找一块空闲内存来开辟动态内存。
不过realloc后要记得检查一次是否返回空指针,不为空指针的话就把指针值交给ps1->array,同时容量更新。
接下来就是尾插函数的实现了,其实有了上面的检查容量的函数,剩下的就很简单了。
void PushBackSeqList(SeqList* ps1, SLDataType targ)
{
assert(ps1);
CheckCapacity(ps1);
ps1->array[ps1->size] = targ;
ps1->size++;
}
push就是推的意思,back在这里是指顺序表的尾部,连起来就是把数值推入表的尾部。
讲了尾插,紧接着就是头插函数了。先把第一个元素后面的所有元素挨个向后移动一位,注意要从后向前移动,从前向后会在中途覆盖掉一些值,然后把目标元素覆盖第一个元素。
void PushFrontSeqList(SeqList* ps1, SLDataType targ)
{
assert(ps1);
CheckCapacity(ps1);
SLDataType end = ps1->size;
while (end > 0)
{
ps1->array[end] = ps1->array[end - 1];
end--;
}
ps1->array[0] = targ;
ps1->size++;
}
由此可见,顺序表头插的时间复杂度为O(n),效率不高。
小经验:一般free或realloc报错是因为下标逻辑有误或者访问越界
头删尾删
删除就比插入简单了,对于尾删,只要把ps1->size-1不就行了吗?因为这个size表示表中有效数据个数,-1就是从后向前减少一个有效元素,那要不要把内容置为0呢?大可不必,下次要插入元素时会自动把它覆盖。
void PopBackSeqList(SeqList* ps1)
{
assert(ps1);
assert(ps1->size > 0);
ps1->size--;
}
pop在这里是弹出的意思,也就是把尾部元素弹出,不过要注意先检查一下ps1->size有没有可能这次删完就变为负数了,防止越界,这里用assert检测。
对于头删,直接让第一个元素往后的元素全部向前移动一位就行了,要注意从前向后往前移,覆盖掉第一个元素。同时也要注意begin下标不要越界。
void PopFrontSeqList(SeqList* ps1)
{
assert(ps1);
assert(ps1->size > 0);
SLDataType begin = 0;
for (begin = 1; begin < ps1->size; begin++)
{
ps1->array[begin - 1] = ps1->array[begin];
}
ps1->size--;
}
在pos位置插入或删除
我们这里的pos位置是基于数组下标的,具体如何插入的参考下图:
注意检测传入的pos是否小于等于ps1->size,为什么是小于等于而不是小于?因为我们要让end初始值为ps1->size,从最后一个元素后面的空位开始把元素一个一个向后移动,这样移动结束标志就是end等于pos,再怎样都不会越界。
void InsertSeqList(SeqList* ps1, size_t pos, SLDataType targ)
{
assert(ps1);
assert(pos <= ps1->size);
CheckCapacity(ps1);
size_t end = ps1->size;
while (end > pos)
{
ps1->array[end] = ps1->array[end - 1];
--end;
}
ps1->array[pos] = targ;
ps1->size++;
}
那删除呢?其实可以参考前面讲的头删,具体如图所示
注意要先检测pos是否会越界,然后就是pos往后的所有元素全部向前移动一位。
void EraseSeqList(SeqList* ps1, size_t pos)
{
assert(ps1);
assert(pos < ps1->size);
size_t begin = pos;
while (begin < ps1->size - 1)
{
ps1->array[begin] = ps1->array[begin + 1];
begin++;
}
ps1->size--;
}
修改元素
这个就很简单了,直接在对应位置上覆盖即可。注意一下pos的范围不要超出限制。
void ModifySeqList(SeqList* ps1, size_t pos, SLDataType targ)
{
assert(ps1);
assert(pos < ps1->size);
ps1->array[pos] = targ;
}
查找元素
在顺序表中查找目标元素,如果找到了就返回下标,如果找不到就返回-1。这里直接用遍历查找,因为对于较小的数据量而言,遍历查找便捷,有人可能想到二分查找时间复杂度为O(logn)而遍历查找为O(n)因而觉得用二分查找更好,其实不然,二分查找前提是数组要保证有序,我们的顺序表中本身就是无序的,若要使用二分查找还得先排个序,排序快的都要O(nlogn),数量级比O(n)大,没有必要用二分查找,这里遍历更好些,除非数据量很大。
int FindSeqList(SeqList* ps1, SLDataType targ)
{
assert(ps1);
size_t i = 0;
for (i = 0; i < ps1->size; i++)
{
if (targ == ps1->array[i])
return i;
}
return -1;
}
为什么不建议缩容
既然容量不够时需要扩容,那么容量较多的时候需不需要缩容以节省空间呢?并不建议这样做,在硬件较为发达的当下,时间资源相对于空间资源更加宝贵,况且这里缩容节省下来的空间相对而言没有多少,也不缺这点空间,但是缩容也是要付出代价的:使用realloc不管是扩容还是缩容,都有可能“异地扩”或“异地缩”,也就是另寻一块合适的空间,把数据拷贝过去,然后再销毁原来空间,这样做对效率是有消耗的。如果容量一有空余就缩容,下次插入不还得再扩容吗,这样会使得扩容、缩容使用realloc调整内存更加频繁,完全是用时间换空间的做法,我们并不提倡这样做。而不设计缩容的话,遇到满容就扩容,删除元素不缩容,下次再插入就有可能不用扩容(使用空余的容量),这是用空间换时间的做法,性价比更高。
顺序表的优缺点
优点
-
无须为表示表中元素之间的逻辑关系而增加额外的存储空间。
因为顺序表中元素的逻辑关系和物理关系一致。
-
可以快速地存取表中任意位置的元素。
直接根据下标可以找到表的任一元素所在位置而取出元素,也可以找到表的任一位置而放入元素,时间复杂度仅为O(1)。
缺点
- 插入和删除操作需要移动大量元素,时间复杂度为O(n)。
- 当顺序表长度变化较大时,难以确定容量大小,扩容的多了有可能浪费较多空间。
- 增容需要申请新空间,有可能“异地扩”,另寻空间,拷贝数据,释放旧空间。会有不小的消耗
oj刷题推荐
- 原地移除数组中所有的元素val,要求时间复杂度为O(N),空间复杂度为O(1)。27. 移除元素 - 力扣(LeetCode)
- 删除排序数组中的重复项。26. 删除有序数组中的重复项 - 力扣(LeetCode)
- 合并两个有序数组。88. 合并两个有序数组 - 力扣(LeetCode)
以上就是本文全部内容了,感谢观看,你的支持就是对我最大的鼓励~