文章目录
6.线性表(上)
我们最常用的,并且也是最自然的数据结构就是线性结构了。
(1).线性表的定义与ADT
#1.基本定义
虽然我们好像都知道线性表是什么,但是还是要看看定义:具有相同类型的n个数据元素 k 0 , k 1 , … , k n − 1 k_0,k_1,\dots,k_{n-1} k0,k1,…,kn−1的有限序列,记为 ( k 0 , k 1 , … , k n − 1 ) (k_0,k_1,\dots,k_{n-1}) (k0,k1,…,kn−1),很简单,其中元素的个数n称为线性表的长度,当n等于0时该表称为空表。
在线性表中, k i k_i ki是 k i + 1 k_{i+1} ki+1的直接前驱元素, k i + 1 k_{i+1} ki+1是 k i k_i ki的直接后驱元素,对于一个非空的线性表,存在唯一的第一个、最后一个元素;除了第一个元素外,每个数据元素都只有一个直接前驱,除了最后一个之外,每个都只有一个直接后驱。
#2.结点与键
我们也可以把线性表中的数据元素称为结点(记录),它可以是任意的类型,如果是由若干个数据项组成的复合类型,我们也称数据项为字段或域,字段这个说法在数据库里非常常见了,在这里就不多说了。
因为线性表中的结点可以由若干字段构成,因此我们把能够唯一标识某个结点的字段称为关键字,也称作键;这个与数据库当中的主键(主码) 是一样的。
#3.ADT
线性表的抽象数据类型定义如下:
ADT List {
数据对象: D = a i ∣ a i ∈ E l e m S e t , i = 0 , 1 , 2 , . . . , n − 1 , n ≥ 0 D = {a_i|a_i \in ElemSet, i=0,1,2,...,n-1, n\ge0} D=ai∣ai∈ElemSet,i=0,1,2,...,n−1,n≥0
数据关系: R = { < a i − 1 , a i > ∣ a i ∈ D , i = 1 , 2 , . . . , n − 1 , n ≥ 0 } R = \{<a_{i-1},a_i>|a_i \in D, i=1,2,...,n-1, n \ge 0\} R={<ai−1,ai>∣ai∈D,i=1,2,...,n−1,n≥0}
基本操作: 创建、查找、插入、删除、遍历、排序、分解、合并等
}
好了,基本知识了解完了,接下来就要开始尝试去实现我们的线性表了。
(2).顺序存储
#1.基本内容
顺序存储顺序存储,和链式存储的最大区别也就在顺序,如果我们的线性表中的元素存储在连续的数据单元中,则这个线性表就是采取顺序存储的方式实现的。
没错,我们平时常用的数组就是一种顺序存储的线性表,它的优点显而易见:顺序存储的线性表可以轻松地随机访问对应位置的元素。
#2.创建与遍历
创建顺序存储的线性表算是相当方便的了,我们只需要:
constexpr int n = 100;
int list[n]{ 0 };
就可以创建线性表了!当然你也可以照着我在C++系列博客中说的,用模板对原生数组进行一个封装:
template<typename T, size_t size>
struct array
{
T _arr[size];
size_t _size;
};
不过记得这个写法要对模板参数size=0的情况做一个模板特化,因为int arr[0];是不合法的,数组的大小必须大于等于0,这样封装之后的好处是这个数组至少是知道自己的容量是多少,并且我们可以不退化地把数组引用传入函数当中,不过这不是我们在数据结构当中要讨论的问题。
当然,上面说的创建过程是在栈区创建,如果你的数组开的比较大,你就可能需要在内存堆区创建数组,这就需要用到动态内存分配了:
// C++风格动态内存分配
int n = 100;
int* list = new int[n]{ 0 };
// C风格动态内存分配
#include <stdlib.h> // 或者cstdlib
int n = 100;
int* list = (int*)malloc(n * sizeof(int));
如果我们用动态内存分配,就会存在C和C++两种不同的风格,C++是基于分配器实现的,而C语言则是基本的内存分配,因此C++的new关键字可以在分配内存的同时兼顾初始化,而malloc则不行。
还有一件事,动态分配来的数组一定要记得在最后手动释放,但不一定要随用随丢,因为这可能影响到运行时的效率,原因与C++博客系列并发篇中的线程创建和销毁类似,频繁的分配与释放内存会影响性能。
然后是遍历,遍历比较简单,在你知道数组长度的情况下,你只需要:
for (int i = 0; i < n; i++) {
... list[i];
}
这样即可,因为我们的数组是连续存储的,直接通过下标访问即可;同理,只要我们知道下标,我们也可以通过这种方式完成随机访问,所以顺序存储的随机访问时间复杂度就是 O ( 1 ) O(1) O(1)了,因为只有简单的指针计算与跳转嘛。
#3.插入与删除
插入与删除操作是顺序存储线性表的常用操作,对于插入,我们指定某个下标,然后将这个下标及后继元素全都往后移一位,再把要插入的元素赋给对应下标的位置上,就完成了插入操作,最终线性表长度从n变为了n+1。
删除操作也是一样,我们把从某个下标后面的一位起,全部向前移一位,最终线性表长度从n变为了n-1。
我们首先来写代码:
int insert(int list[], int& cnt, int i, int _val)
{
// 正常插入返回1,否则返回0
// 如果线性表已满,插入操作会自动去除最后一个元素
if (i < 0 || i > cnt) return 0;
if (cnt == MAXSIZE) cnt--;
for (int j = cnt; j > i; j--) {
list[j] = list[j - 1];
}
list[i] = _val;
cnt++;
return 1;
}
就是这么简单,我们只要做一个移动的操作即可,删除也是类似:
int del(int list[], int& cnt, int i)
{
// 正常插入返回1,否则返回0
if (i < 0 || i > cnt) return 0;
for (int j = i + 1; j < cnt; j++) {
list[j - 1] = list[j];
}
cnt--;
return 1;
}
不过如果你仔细观察代码,你会发现,在插入的时候我们是从最后开始一个个往后挪,而删除的时候则是从要删除的起一个个往前挪,其实想想就知道为什么会这样了:如果插入的时候从前往后一个个挪,那么第一个就会覆盖第二个,这样一套操作下来,后面的值就全都等于被插入位置原来的值了;同理,删除也是,如果我们从后往前一个个挪,就会让后面的值全都等于最后一个值。
#4.应用
数组的应用其实没啥好说的,我们做的非常多题目都可以用一般的数组实现,可能这种题比较常见:
计算两个一元多项式的乘积。
输入格式
每行两个多项式,以一个空格分隔,多项式格式为 a n x n + … + a 1 x + a 0 a_n x^n+ \ldots +a_1x+a_0 anxn+…+a1x+a0。
每行长度不超过 100 , 0 < n < 50 100,0<n<50 100,0<n<50
输出格式
每组数据一行,根据次数由高到低顺序输出两个多项式乘积的非零项系数,两个系数之间由一个空格分隔。
样例
input
x+1 x-1
x^2-3x+1 x^3
x+2 2
output
1 -1
1 -3 1
2 4
可以自己尝试一下,我们可以用一个简单的数组来表示一个一元多项式:
int coef[50]{ 0 };
然后这个系数数组的每一个元素存的都是对应次项的系数,之后再去尝试做乘法即可。
#5.各操作时间复杂度
顺序存储就基本到这儿了,我们来总结一下顺序存储的访问、插入和删除的时间复杂度,访问很简单,就是
O
(
1
)
O(1)
O(1)。
对于插入,我们得出总操作次数N满足以下关系:
N
=
∑
i
=
0
n
−
1
p
i
(
n
−
i
)
N = \sum_{i=0}^{n-1}p_i(n-i)
N=i=0∑n−1pi(n−i) 其中
p
i
p_i
pi为插入第
i
i
i个位置的概率,那么我们假定
P
∼
U
(
0
,
n
)
P \sim U(0, n)
P∼U(0,n),因此
p
i
=
1
n
p_i = \frac{1}{n}
pi=n1,所以:
N
=
∑
i
=
0
n
−
1
n
−
i
n
=
n
−
1
n
∑
i
=
0
n
−
1
i
=
n
−
n
−
1
2
=
n
+
1
2
N = \sum_{i=0}^{n-1}\frac{n-i}{n}=n - \frac{1}{n}\sum_{i=0}^{n-1}i=n-\frac{n-1}{2}=\frac{n+1}{2}
N=i=0∑n−1nn−i=n−n1i=0∑n−1i=n−2n−1=2n+1
即插入操作的平均时间复杂度为
O
(
n
)
O(n)
O(n),其实也好理解,因为我们每次插入的过程都要涉及到大量元素的移动。
同理,对于删除,总操作次数N满足:
N
=
∑
i
=
0
n
−
1
p
i
(
n
−
i
−
1
)
N = \sum_{i=0}^{n-1}p_i(n-i-1)
N=i=0∑n−1pi(n−i−1) 假定删除任意一个节点的概率相同,则:
N
=
∑
i
=
0
n
−
1
n
−
i
−
1
n
=
n
−
1
n
∑
i
=
0
n
−
1
(
i
+
1
)
=
n
−
n
+
1
2
=
n
−
1
2
N = \sum_{i=0}^{n-1}\frac{n-i-1}{n}=n - \frac{1}{n}\sum_{i=0}^{n-1}(i+1)=n-\frac{n+1}{2}=\frac{n-1}{2}
N=i=0∑n−1nn−i−1=n−n1i=0∑n−1(i+1)=n−2n+1=2n−1
即删除操作的平均时间复杂度为
O
(
n
)
O(n)
O(n),我们有了以下的表格:
操作 | 时间复杂度 |
---|---|
访问 | O ( 1 ) O(1) O(1) |
插入 | O ( n ) O(n) O(n) |
删除 | O ( n ) O(n) O(n) |
(3).链式存储
#1.数组不好吗?
数组非常好,在各种计算机语言中都有对它的原生支持,我们就以C++为例,你会发现,有的时候栈空间可能不够,那你就去申请堆空间的内存,但问题是堆区的内存不像栈空间一样是简单的、线性的,如果大量碎片存在,你可能并不能成功申请到一块如你所希望的那么大的连续空间来。
#2.先别急,先听个报告
所以我们在现实生活中遇到某些问题的时候,可能会采用找空位的方式来解决,例如听一个教授的报告,大家都是采取随机的方式就座,这时候来了相互认识的10个人,他们想连着坐在一起,可是全场一个连着的十个座位都找不到,那或许他们有两种解决方案,第一种是找到至少是有十个座的地方,然后挑坐的人最少的一排让他们换个地方坐,这样听起来不太公平? 为什么你们来得晚,就为了坐在一起,我就要给你们让座呢?因此还有另一个比较正常的办法,那就是—别坐一起了,分开坐吧,自己看哪儿有座就去哪儿。
然后我们做一个很奇怪的假设,假设这些人每个人都只能和两个人通信,他们如果希望十个人能够通过某一种方式掌握到所有人的座位,怎么做呢?很简单,所有人可以构成一个逻辑上的环形结构,然后每个人掌握ta逻辑上的左边和逻辑上的右边的人的座位,之后我们就可以通过这一个环来传递每个人的座位信息了,虽然这听起来有点低效,但是好歹我们是在这个苛刻的条件下把每个人都连起来了,对吧?
所以我上面提到的例子,以及对于这个例子的奇怪假设,就分别对应了链表和环形双向链表的基本思路,我们把数据存在不连续的空间中,然后让每个结点只记录下一个结点或同时记录上一个和下一个结点的位置,从而把所有的数据组织起来。
太棒了!我可以摸鱼了,下面这张图是我在一个C语言的基本教程中对于链表的图形化描述:
这个图做起来还是有点麻烦的,好了好了先不提这个,所以我们可以看到,链表基本上就是做这件事,一个结点中有Value和next两个部分,Value就是存储当前结点的值,而next则是存储下一个结点的指针,不过这种链表在后续的实现中涉及到初始化链表的插入以及只有一个结点的删除的时候,会有相当麻烦的问题。
如果我们不对链表进行封装,即只用一个node*作为链表使用,其实也是可以的,但是,在我说的上述操作中,我们必须在函数传参的时候使用node**,或者说是node*&,先不说node**用起来有多烧脑了,这个node*&我估计你看多了也得高血压,虽然说,我们可以:
using list = node*;
但它的本质还是两重指针,没有任何区别,所以或许我们可以增加一个锚点,这样一来,无论我们怎么增删,到最后总有这个结点被保留下来,就像这样:
这个head就是我们要的锚点,因为只做导航功能,所以它的Value域是闲置的,我们可以用它来存这个链表中有多少个元素,听起来会非常方便。
上面提到的这种只存下一个结点指针的,叫做单链表,我们发现,它的尾结点的next总是指向nullptr,如果我们希望让这个链表循环起来,就可以让尾结点的next指向head,就像这样:
这样的链表叫做环形单向链表,我们可以利用它来模拟一些环形的问题,例如约瑟夫环问题,当然具体有什么用,还要等待你来探索;除了单向链表,还有双向链表和环形双向链表:
这是双向链表,它增加了一个prev域,用于存储上一个结点的指针,它的好处是如果你知道了某个结点,你可以很轻松地找到它的上一个,而不用付出额外的代价,这个我们之后会在查找与访问当中提到,还有一个就是环形双向链表:
这个图就复杂了,不过其实还是差不多的,我们只是让头结点的prev指向尾结点,让尾结点的next指向头,具体用法等我们之后再说,接下来我们就要开始写代码实现这些链表了。
#3.怎么实现一个单链表呢?
链表的实现就不如数组那么简单了,因为没有内置的链表类型(当然,其实C++的STL里是有的,但是STL也是人实现的,为什么我们不能自己实现一个呢),所以我们需要完全自己实现,不过只要记住链表的两个关键,就不难实现了:分布在内存各处的结点和找到下一个结点的办法,所以我们就能直接给出结点的写法了:
struct node
{
int value;
node* next;
};
简单!其实单单这一个结点我们就已经可以用来当做链表使用了,因为它已经具备了我们说的两个要求:分布在内存各处的结点和找到下一个结点的办法,所以你可以直接简单地写:
using list = node*;
只是它可能不太好用,所以有的时候我们会做一个封装:
struct list
{
node* anchor;
node* tail;
list() : anchor(new node{0, nullptr}), tail(nullptr) {}
~list()
{
node* node_ptr{ anchor->next };
while (node_ptr) {
anchor->next = node_ptr->next;
delete node_ptr;
node_ptr = anchor->next;
}
delete anchor;
}
};
利用面向对象的思想去实现这些数据结构,会使得我们的代码可读性大大提高,并且还可以让我们充分利用C++的RAII特性,有效避免忘记释放内存。
#4.双向链表,环形链表和环形双向链表
双向链表要实现也没那么麻烦哈,我们只要给node加一个prev域就好:
struct node
{
node* prev;
int value;
node* next;
};
相应的,我们把list的定义也改成:
struct list
{
node* anchor;
node* tail;
list() : anchor(new node{nullptr, 0, nullptr}), tail(nullptr) {}
~list()
{
node* node_ptr{ anchor->next };
while (node_ptr) {
anchor->next = node_ptr->next;
delete node_ptr;
}
delete anchor;
}
};
除了结点稍微变一变以外,几乎没有什么太大的差别,而环形链表在结构上没有变化,只是让头尾结点指针指向的内容变一变,这里就不再给出代码演示,请自己尝试实现一遍,之后我的代码都会基于无环有表头单链表来实现,其余类型的链表其实都只需要做一些很小的改动即可,因此你需要自己手动尝试实现一下。
#5.创建与遍历
创建一个链表嘛,还是比较容易的,例如:
struct node
{
int value;
node* next;
};
struct list
{
node* anchor;
node* tail;
list() : anchor(new node{0, nullptr}), tail(nullptr) {}
~list()
{
node* node_ptr{ anchor->next };
while (node_ptr) {
anchor->next = node_ptr->next;
delete node_ptr;
}
delete anchor;
anchor = nullptr;
tail = nullptr;
}
node* at(int index);
node* find(int val);
node* push_back(int val);
node* insert(int val, int idx);
node* pop_back();
node* del(int idx);
};
我们这里用到了列表初始化,对头和尾两个结点进行初始化,因为anchor是始终存在的,所以我们需要在初始化的时候给它分配好内存,方便我们之后使用。
还有就是链表的销毁是需要一点技巧的,其实和删除的流程差不多,我们要记得避免删了自己,导致链表的链断掉,从而丢失掉对于后面的内容的连接。
遍历,我们在数组里一般这么写:
for (int i = 0; i < n; i++) {
...a[i];
}
但是链表是不支持我们随机访问的,所以我们得换个想法,比如这个i++,它和ptr=ptr->next是类似的,所以我们可以写出这样的代码:
node* ptr = anchor->next;
for (; ptr->next; ptr = ptr->next) {
...ptr->value;
}
判断循环结束的条件是可以任意写的,在这里举的例子是遍历到链表尾,当ptr->next为空的时候,遍历也就结束了,如果要继续遍历,那就让ptr=ptr->next,这样遍历用的指针就指向下一个结点了。
#6.查找和访问
查找的操作就比较经典了,无论是对于找到对应下标的结点,还是找到对应值的结点,基本都需要用到我们前面说的遍历操作:
node* list::at(int index)
{
if (index > anchor->value) {
return nullptr; // Error: index out of range
}
else {
node* ptr{ anchor->next };
for (int i = 0; ptr->next && i < index; i++) {
ptr = ptr->next;
}
return ptr;
}
}
node* list::find(int val)
{
node* ptr{ anchor->next };
while (ptr) {
if (ptr->value == val) {
return ptr;
}
else ptr = ptr->next;
}
return ptr;
}
简单,对吧?我们只要这么遍历一轮就可以得到结果了,如果找不到,就会返回一个空指针,查找操作的平均时间复杂度我们可以分析一下了,对于每个结点被找到的可能性一致,所以它的查找操作时间复杂度是 O ( n ) O(n) O(n),这也算是链表的一大缺陷了,因为内存分布的随机,我们不可能在不付出额外代价的情况下达到 O ( 1 ) O(1) O(1)时间复杂度的随机访问。
#7.插入与删除
插入和删除其实更加简单,看看图你就知道了:
我们只要找到插入位置之前的结点,然后创建新结点,让新结点的next指向插入前这个结点的下一个结点,然后让当前结点的next指向新的结点即可,我们来操作一下:
node* list::push_back(int val)
{
if (tail) {
node* new_node{ new node{val, nullptr} };
tail->next = new_node;
tail = new_node;
anchor->value++;
return new_node;
}
else {
tail = new node{ val, nullptr };
anchor->next = tail;
anchor->value++;
return tail;
}
}
node* list::insert(int val, int idx)
{
if (idx < anchor->value) {
if (idx != anchor->value - 1) {
node* ptr{ at(idx - 1) };
node* new_node{ new node{val, ptr->next} };
ptr->next = new_node;
anchor->value++;
return new_node;
}
else return push_back(val);
}
else return nullptr;
}
为了方便链表的操作,我们附加了一个push_back方法,它的作用是在链表的最后插入一个元素,在insert当中我们对于尾部插入的情况,特别采用了push_back,目的就是解决tail的问题,如果是空链表插入,那么要改tail,如果是有元素的链表插入尾部,那就要把tail指向新的结点。
删除结点的操作其实也差不多,看看图先:
我们首先找到要删除位置的上一个结点,然后记下当前结点的下一个结点,再让当前结点的next指向被删除结点的next,然后再释放掉被删除结点,就可以了,这里一定要注意的是,我们不能直接让当前结点指向被删除结点的下一个,因为虽然我们做到了删除的效果,但这样会导致结点丢失,内存泄漏,内存泄漏的危害不用我再说了,我们一定要记得释放掉内存,接下来看看实现:
node* list::pop_back()
{
if (anchor->value != 0) {
if (anchor->next == tail) {
delete tail;
tail = nullptr;
anchor->next = tail;
}
else {
node* ptr{ at(anchor->value - 2) };
delete tail;
tail = ptr;
ptr->next = nullptr;
}
anchor->value--;
return tail;
}
else return nullptr;
}
node* list::del(int idx)
{
if (idx < anchor->value) {
if (idx != anchor->value - 1) {
node* ptr{ at(idx - 1) };
node* to_del{ ptr->next };
ptr->next = to_del->next;
delete to_del;
anchor->value--;
return ptr->next;
}
else return pop_back();
}
else return nullptr; // delete failed
}
这里还是加一个pop_back函数辅助操作,剩下的其实都和我之前说的想法差不多,这里也就不多说了,然后插入和删除的时间复杂度我们要分析的话,首先要记得剔除查找过程,因为查找毕竟是 O ( n ) O(n) O(n)的流程,而插入和删除的过程本身其实并不包含查找,所以插入和删除的时间复杂度都是 O ( 1 ) O(1) O(1),只是我们具体写这两个函数的时候因为要涉及找到对应的结点,所以时间复杂度会被查找影响,变成 O ( n ) O(n) O(n)。
我们这里写的删除的思路是找到要被删除的结点的上一个,那我们如果想找到要被删除的结点再删,有没有办法呢?当然有,有三种办法,一种时间慢,一种费空间,还有一种算是投机取巧,能在不额外花费时间空间的情况下做到,但是也有条件限制。
第一种方法就是,找到这个结点之后,再找到它的上一个,哈哈,这个做法就跟我们直接去找上一个没有任何区别了,所以我说它费时。第二种方法则是使用双向链表,如果我们保存了上一个结点的信息,想找到上一个就是轻而易举的事情了,不过这样每个结点的内存都要增长8个字节,这可是有点费空间的。
第三个办法则比较巧妙:我们找到被删除结点,把下一个结点的值复制过来,然后把下一个结点删掉:
哦这太让人悲伤了,它为了活着,冒用了后面结点的身份,让后面的结点因此被销毁掉,就是这么一回事,相当于我们通过借用后面的结点来完成了前面我们说的找到被删除结点上一个的操作,这种方法非常巧妙,但也有局限性:比如某道题要求你在链表操作后需要保持正确的结点地址,这种方法虽然保证了值是对的,但是地址发生了变化,这就不行了。
#8.各操作时间复杂度
好了,我们已经把上面说的各种操作都提了一遍了,接下来直接总结就好了:
操作 | 时间复杂度 |
---|---|
访问 | O ( n ) O(n) O(n) |
插入 | O ( 1 ) O(1) O(1) |
删除 | O ( 1 ) O(1) O(1) |
#9.*侵入式链表
我们上面提到的链表都是非侵入式链表,每个next存的都是下一个结点的指针,而结点包含了值和next两个域,这就限制了我们一种链表只能存一种类型的值,侵入式链表则是常见于Linux内核中的一种链表,它的基本形式是这样:
它的ListNode* next很简单,只保存下一个结点里存的ListNode*,那你可能好奇,这要怎么找到数据呢?其实很简单,因为结构体的排布比较固定,我们只要把next这个变量本身的地址减掉对应的字节数,就可以找到数据了,这样一来,我们就可以在不同的结点里存储不同的数据了,在这里我就不去具体实现了,毕竟不是数据结构的重点嘛。
#10.*关于结点缓存的思考
如果你写的链表被用于一个大型项目当中,你肯定会觉得非常自豪,然后在这个系统当中,你的链表会被频繁的插入和删除,作为负责人,你肯定会想:我有没有什么办法优化一下这个链表呢?
我们假定这样一个场景:这个大型项目中,插入和删除的操作数量几乎相同,并且结点数量不会很多,所以或许我们可以:建立一个结点池用来缓存结点,如果要插入,就从结点池取出最前面的结点,如果要删除,就把结点插入结点池的末尾,这样一来,我们可以减少成千上万次的new和delete操作!
所以我们可以设计一个具备结点缓存的链表以优化链表的操作效率,你可以试着实现一下。
小结
我发现这一节要一口气写完真是太长了,所以分成上下两个部分,下半部分我会讲一讲栈和队列的相关内容。