解剖线性表
文章目录
- 1.线性表
- 2.顺序表
- 2.1 概念与结构
- 2.2 分类
- 2.2.1 静态顺序表
- 2.2.2 动态顺序表
- 3.动态顺序表的实现
- 3.1初始化
- 3.2尾插
- 3.3头插
- 3.4尾删
- 3.5头删
- 3.6查找数据
- 3.7指定位置前插入数据
- 3.8指定位置删除数据
- 3.9销毁
- 4两道算法
- 4.1移除元素
- 4.2删除有序数组中的重复项
1.线性表
线性表(linear list)是n个具有相同特性的数据元素的有限序列。 线性表是⼀种在实际中广泛使 用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串…线性表在逻辑上是线性结构,也就说是连续的⼀条直线。但是在物理结构上并不⼀定是连续的, 线性表在物理上存储时,通常以数组和链式结构的形式存储。
2.顺序表
2.1 概念与结构
概念:顺序表是用⼀段物理地址连续的存储单元依次存储数据元素的线性结构,⼀般情况下采用数组存储。
顺序表和数组的区别?
顺序表的底层结构是数组,对数组的封装,实现了常用的增删改查等接口
我们在学习后续的数据结构的时候,需要大家具备的C语言知识包含(下面是我之前总结的博客链接,大家可以参考复习)
- 一级指针、二级指针
- 结构体指针
- 动态内存管理
2.2 分类
2.2.1 静态顺序表
概念:使用定长数组存储元素
静态顺序表缺陷:空间给少了不够用,给多了造成空间浪费
- Seq:是 “Sequence” 的缩写,意为 “序列、顺序”
- List:意为 “列表”
我们以后就用SeqList
来为顺序表命名。
我们后面实现顺序表的时候,会创建3个文件
SeqList.h
用来声明头文件SeqList.c
用来实现顺序表操作函数test.c
用来测试这些函数
2.2.2 动态顺序表
typedef struct SeqList {SLDataType* arr;int size;//有效数据个数int capacity;//空间容量
}SL;
动态顺序表的逻辑是,我们先创建一个指针变量a
,后面我们使用动态内存分配函数(例如malloc)向操作系统申请一块空间,然后将动态内存分配函数返回的这块空间首元素的地址赋值给这个指针变量a
,此时a
就可以像数组名一样使用,我们就创建了一个动态数组,后面还可以通过例如realloc
函数等,对这个数组进行增容操作。上面对顺序表类型的创建都是在SeqList.h
文件中的。
3.动态顺序表的实现
3.1初始化
这时我们将来到test.c
文件中,创建主函数入口以及测试函数。
#include"SeqList.h"
void SLTest()
{SL s1;}
int main()
{SLTest();return 0;
}
同时还要注意包含头文件SeqList.h
,否则编译器识别不了SL
,接下来我们创建顺序表S1
再在SeqList.c
文件中实现初始化函数SLInit()
我们马上就会遇到一个问题,初始化时我们是传入顺序表的变量名、还是顺序表的地址?
大家记住:是否选择传址调用(或传引用调用),核心就在于是否需要让函数内部对参数的修改影响到函数外部的实参。
很明显,我们初始化就是要直接改变实参的值,所以初始化函数要使用传址调用。
后面我们会使用malloc
还有断言等,所以要记得在SeqList.h
中包含头文件
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
接下来在SeqList.c
实现
#include"SeqList.h"
void SLInity(SL* ps)
{ps->arr = NULL;ps->size = ps->capacity = 0;
}
最后在test.c
中传参
#include"SeqList.h"
void SLtest()
{SL s1;SLInity(&s1);
}
int main()
{SLtest();return 0;
}
3.2尾插
void SLPushBack();
接下来我们在SeqList.c
实现尾插函数,并且要在头文件中声明这个函数方便后续使用。
首先,我们这个尾插函数一定会改变顺序表(传址调用),所以第一个参数就是SL
类型的指针,另外我们还需要尾插的数据作为第二个参数,所以:
void SLPushBack(SL* pa ,SLDatatype x)
进入函数之后,后面我们将会多次使用顺序表里面的参数,会不断对这个顺序表的地址进行解引用,所以我们要防止pa
是空指针
assert(pa);
接下来,我们要判断这个顺序表是否有空间给我们进行尾插,如果没有我们要进行扩容
if (pa->size == pa->capacity)
{}
这是会出现一个问题,扩容的话,我们应该增加多少容量呢?扩容少了我们就需要频繁扩容,扩容多了可能会造成空间的浪费
从概率学上讲,一次扩容两倍是最合适的,另外如果扩容的空间足够,realloc
就直接返回原来空间首地址,如果空间不够,realloc
会找到新空间,拷贝旧数据,释放旧空间,返回新空间首地址。
同时也要考虑到特殊情况,如果说这个顺序表容量是0,那对它*2之后还是0,所以在capacity
是0的情况下,我们直接将容量的初始值改为4。
if (pa->size == pa->capacity)//判断是否存在尾插空间
{int newcapacity = pa->capacity == 0 ? 4 : 2 * pa->capacity;SLDatatype* tmp = (SLDatatype*)realloc(pa->arr, newcapacity * sizeof(SLDatatype));if (tmp == NULL){perror("realloc fail");exit(-1);}pa->arr = tmp;pa->capacity = newcapacity;
}
还要判断一下,realloc
是否申请空间成功,最后将申请好的空间地址赋值给顺序表里边的arr
和新的容量赋值给capacity
最后一步,将需要尾插的数据插入有效数据size
的最后一个
pa->arr[pa->size++] = x;
最后给出尾插完整代码
void SLPushBack(SL* pa ,SLDatatype x)
{//防止后面对空指针解引用等操作,会产生未知行为assert(pa);if (pa->size == pa->capacity)//判断是否存在尾插空间{int newcapacity = pa->capacity == 0 ? 4 : 2 * pa->capacity;SLDatatype* tmp = (SLDatatype*)realloc(pa->arr, newcapacity * sizeof(SLDatatype));if (tmp == NULL){perror("realloc fail");exit(-1);}pa->arr = tmp;pa->capacity = newcapacity;}//走到这里说明有空间来尾插pa->arr[pa->size++] = x;
}
经过我们的测试,尾插成功。
3.3头插
头插也需要空间足够才能插入数据,所以我们遇到的第一个问题数组可能就是容量不够的问题,我们在刚才的尾插中同样遇到了这个问题。
void SLCheckCapacity(SL* pa)
{if (pa->size == pa->capacity)//判断是否存在尾插空间{int newcapacity = pa->capacity == 0 ? 4 : 2 * pa->capacity;SLDatatype* tmp = (SLDatatype*)realloc(pa->arr, newcapacity * sizeof(SLDatatype));if (tmp == NULL){perror("realloc fail");exit(-1);}pa->arr = tmp;pa->capacity = newcapacity;}
}//这个函数包含了完整的容量检查以及扩容操作
后续对顺序表进行增删改查操作的时候,都有可能涉及到容量不够的问题,所以我们将这部分代码封装成函数,后续使用直接调用避免代码冗余。
头插的逻辑是将原有的数据向后移动一位,然后再给首元素插入你想要的数据。
void SLPushFront(SL* ps,SLDatatype x)
{assert(ps);SLCheckCapacity(ps);for (int i = ps->size; i > 0; i--){ps->arr[i] = ps->arr[i - 1];}ps->arr[0] = x;ps->size++;
}
别忘了要将有效数据size
增加一位。
可以看到,头插我们使用了n次循环,所以头插的时间复杂度是O(n),之前尾插的时间复杂度是O(1)。
3.4尾删
尾删的操作非常简单
void PopBack(SL* ps)
{assert(ps && ps->size);ps->size--;
}
- 首先断言要加上有效数据
size
不能让它为0,如果去掉assert( ps->size);
这行代码,当size
为0
时,执行--ps->size;
会将size
变为-1
。这会导致后续与size
相关的操作(比如访问ps->arr[ps->size]
这样的元素)出现越界访问的问题 。 - 其次,你可能会疑问仅仅只是把
size--
,那数据不还是没有删掉吗?当我们下次再使用顺序表时,新增的数据会将原来的尾部数据覆盖掉,还有我们后面使用打印函数SLPrint
的时候,像打印函数SLPrint
这类遍历顺序表的操作,是以size
作为循环结束的条件(比如for (int i = 0; i < ps->size; i++)
)。当size
被减 1 后,SLPrint
会 “自然跳过原尾部元素”,只打印[0, size - 1]
范围内的有效元素。 - 顺序表的 “元素有效范围” 是
[0, size - 1]
,将size
减 1 后,原本的 “最后一个元素” 会被 “逻辑上忽略”(后续新增元素会覆盖它,无需手动清空内存)。
3.5头删
void SLPopFront(SL* ps)
{assert(ps && ps->size);for (int i = 0; i < ps->size - 1; i++){ps->arr[i] = ps->arr[i + 1];}
}
为了方便我们后面的观察,我们将数组打印函数也写上SLPrint
void SLPrint(SL* ps)
{for (int i = 0; i < ps->size; i++){printf("%d\n", ps->arr[i]);}
}
给大家分享一下写代码过程中的两个小技巧
- 你选中一行代码,按下
Ctrl + D
,这行代码就会被复制,并且粘贴到当前行的下方 - 函数前缀+
Tab
,可以快速补全这个函数
3.6查找数据
在指定位置之前插入数据,我们首先需要知道这个位置对应的下标所以我们要写一个SLFind
函数,用来找到指定位置,并返回它的下标
void SLFind(SL* ps, SLDatatype x)
{for (int i = 0; i <= ps->size; i++){if (ps->arr[i] == x){return i;}}//返回无效下标return -1;
}
后续在使用的时候记得先进行判断返回的是不是有效下标。
3.7指定位置前插入数据
void SLInsert(SL* ps,int pos,SLDatatype x)
{assert(ps);assert(pos >= 0 && pos <= ps->size);SLCheckCapacity(ps);for (int i = ps->size; i > pos; i--){ps->arr[i] = ps->arr[i-1];}ps->arr[pos] = x;ps->size++;
}
我们使用的第二个assert
,是为了确保pos
在有效范围之内。
核心逻辑还是,将pos之后的数据整体向后挪动一位,再在pos处插入你想要的数据。
这样插入的时间复杂度是O(N).
3.8指定位置删除数据
void SLErease(SL* ps, int pos)
{assert(ps);assert(pos >= 0 && pos <= ps->size);for (int i = pos; i < ps->size; i++){ps->arr[i] = ps->arr[i + 1];}ps->size--;
}
将pos之后的数据整体向前挪动一位
3.9销毁
void SLDesTroy(SL* ps)
{assert(ps);free(ps->arr);ps->arr = NULL;ps->size = ps->capacity = 0;
}
4两道算法
4.1移除元素
27. 移除元素 - 力扣(LeetCode)
4.2删除有序数组中的重复项
26. 删除有序数组中的重复项 - 力扣(LeetCode)