本文是算法与数据结构的学习笔记第三篇,将持续更新,欢迎小伙伴们阅读学习 。有不懂的或错误的地方,欢迎交流
数据结构是指相互之间存在一种或多种特定关系的数据元素的集合,不同的数据结构在不同的应用场景中往往会带来不一样的处理效率。本笔记将通过图解的方式对以下八大数据结构进行理论上的介绍和讲解,以方便大家掌握数据结构。
数据结构可以分别按逻辑结构和物理结构两种角度进行分类。
逻辑结构是从具体问题中抽象出来的模型,是抽象意义上的结构,表现对象中数据元素之间的相互关系。常见的逻辑结构有线性结构和非线性结构(集合结构、树形结构、图形结构)。
物理结构又称为存储结构,是逻辑结构在计算机中真正的表示方式。常见的物理结构有顺序存储结构、链式存储结构,表示内存结构;索引存储结构、散列存储结构,表示外存与内存交互结构。
数组是一种线性表数据结构,用一组连续的内存空间来存储一组相同类型的数据,可以说是最基本的数据结构。
如上图所示,数据是按照顺序存储在内存的连续空间内,0,1,2,… 代表下标,数组相邻元素之间的内存地址的间隔一般就是数组数据类型的大小,所以每个数据的内存地址(在内存上的位置)都可以通过数组下标计算出来,从而可以直接访问目标数据,达到随机访问的目的。按照数据元素的类型,数组可以分为整型数组、字符型数组、浮点型数组、指针数组和结构数组等。数组还可以有一维、二维以及多维等表现形式。
从数组的模型上来看的话,"下标"最确切的定义应该是 “偏移(offset)”。也就是说,如果用 a
来表示数组的话,a[0]
就表示偏移为 0 的位置,也就是首地址,a[k]
就表示偏移为 k 个 type_size的位置,所以计算 a[k]
的内存地址只需要使用下面的这个公式即可:
a[k]
的内存地址就成了:
因为数组中的数据是有序的,我们在某一位置插入一个新的元素,就必须搬移后面的数据,最好的情况是 Ω ( 1 ) \Omega(1) Ω(1),最坏的情况是 O ( N ) O(N) O(N),因为我们在每个位置插入元素的概率是一样,所以平均情况时间复杂度是 ( 1 + 2 + ⋅ ⋅ ⋅ + n ) / n = Θ ( n ) (1+2+\cdot\cdot\cdot+n)/n = \Theta(n) (1+2+⋅⋅⋅+n)/n=Θ(n)。
在 C 语言中,数组是一种基本的数据类型,可以通过声明一个数组变量来创建一个数组。可以使用下标来访问数组中的元素。下面是一个简单的示例代码:
int arr[10]; // 声明一个包含10个元素的整型数组
arr[0] = 1; // 给第一个元素赋值为1
链表,是一种数据元素按照链式存储结构进行存储的数据结构,这种存储结构具有在物理上存在非连续的特点。链表由一系列数据结点构成,每个数据结点包括数据域和指针域两部分。其中,指针域保存了数据结构中下一个元素存放的地址。
上图所示的是一般常见的有头有尾的单向链表,增加反向链接指针域或者链接头尾,还可以形成双向链表或者单向循环链表。
由于是通过指针进行下一个数据元素的查找和访问,使得链表的自由度更高。这表现在对节点进行增加和删除时,只需要对上一节点的指针地址进行修改,而无需变动其它的节点。不过事物皆有两极,指针带来高自由度的同时,自然会牺牲数据查找的效率和多余空间的使用。
访问效率较低。
实际上,上图所示的链表结构并不完整。一个完整的链表需要由以下几部分构成: 1. 头指针:一个普通的指针,它的特点是永远指向链表第一个节点的位置。很明显,头指针用于指明链表的位置,便于后期找到链表并使用表中的数据; 2. 节点:链表中的节点又细分为头节点、首元节点和其他节点: 头节点:其实就是一个不存任何数据的空节点,通常作为链表的第一个节点。对于链表来说,头节点不是必须的,它的作用只是为了方便解决某些实际问题; 首元节点:由于头节点(也就是空节点)的缘故,链表中称第一个存有数据的节点为首元节点。首元节点只是对链表中第一个存有数据节点的一个称谓,没有实际意义; 其他节点:链表中其他的节点;
注意:链表中有头节点时,头指针指向头节点;反之,若链表中没有头节点,则头指针指向首元节点。
链表中每个节点的具体实现,需要使用 C 语言中的结构体,具体实现代码为:
typedef struct Linklist{
int elem;//代表数据域
struct Linklist *next;//代表指针域,指向直接后继元素
}Linklist; //link为节点名,每个节点都是一个 link 结构体
一般创建链表我们都用 typedef struct,因为这样定义结构体变量时,我们就可以直接可以用 LinkList *a; 定义结构体类型变量了。
创建一个链表需要做如下工作: 1. 声明一个头指针(如果有必要,可以声明一个头节点); 2. 创建多个存储数据的节点,在创建的过程中,要随时与其前驱节点建立逻辑关系;
例如,创建一个存储 {1,2,3,4 } 且无头节点的链表,C 语言实现代码如下:
linklist * initlinklist(){
linklist * p=NULL;//创建头指针
linklist * temp = (linklist*)malloc(sizeof(linklist));//创建首元节点
//首元节点先初始化
temp->elem = 1;
temp->next = NULL;
p = temp;//头指针指向首元节点
//从第二个节点开始创建
for (int i=2; i<5; i++) {
//创建一个新节点并初始化
linklist *a=(linklist*)malloc(sizeof(linklist));
a->elem=i;
a->next=NULL;
//将temp节点与新建立的a节点建立逻辑关系
temp->next=a;
//指针temp每次都指向新链表的最后一个节点,其实就是 a节点,这里写temp=a也对
temp=temp->next;
}
//返回建立的节点,只返回头指针 p即可,通过头指针即可找到整个链表
return p;
}
向链表中增添元素,根据添加位置不同,可分为以下 3 种情况:1. 插入到链表的头部(头节点之后),作为首元节点;2. 插入到链表中间的某个位置;3. 插入到链表的最末端,作为链表中最后一个数据元素;
虽然新元素的插入位置不固定,但是链表插入元素的思想是固定的,只需做以下两步操作,即可将新元素插入到指定的位置: 1. 将新结点的 next 指针指向插入位置后的结点; 2. 将插入位置前结点的 next 指针指向插入结点。
实现链表插入元素的操作的 C 语言代码如下:
//p为原链表,elem表示新数据元素,add表示新元素要插入的位置
linklist * insertElem(linklist * p,int elem,int add){
linklist * temp=p;//创建临时结点temp
//首先找到要插入位置的上一个结点
for (int i=1; inext;
}
//创建插入结点c
linklist * c=(linklist*)malloc(sizeof(linklist));
c->elem=elem;
//向链表中插入结点
c->next=temp->next;
temp->next=c;
return p;
}
从链表中删除指定数据元素,实则就是将存有该数据元素的节点从链表中摘除,但作为一名合格的程序员,要对存储空间负责,对不再利用的存储空间要及时释放。因此,从链表中删除数据元素需要进行以下 2 步操作: 1. 将结点从链表中摘下来; 2. 手动释放掉结点,回收被结点占用的存储空间;
其中,从链表上摘除某节点的实现非常简单,只需找到该节点的直接前驱节点 temp,执行一行程序:
temp->next=temp->next->next;
因此,链表删除元素的 C 语言实现如下所示:
//p为原链表,add为要删除元素的值
linklist * delElem(linklist * p,int add){
linklist * temp=p;
//temp指向被删除结点的上一个结点
for (int i=1; inext;
}
linklist * del=temp->next;//单独设置一个指针指向被删除结点,以防丢失
temp->next=temp->next->next;//删除某个结点的方法就是更改前一个结点的指针域
free(del);//手动释放该结点,防止内存泄漏
return p;
}
我们可以看到,从链表上摘下的节点 del 最终通过 free 函数进行了手动释放。
在链表中查找指定数据元素,最常用的方法是:从表头依次遍历表中节点,用被查找元素与各节点数据域中存储的数据元素进行比对,直至比对成功或遍历至链表最末端的 NULL(比对失败的标志)。
因此,链表中查找特定数据元素的 C 语言实现代码为:
//p为原链表,elem表示被查找元素、
int selectElem(linklist * p,int elem){
//新建一个指针t,初始化为头指针 p
linklist * t=p;
int i=1;
//由于头节点的存在,因此while中的判断为t->next
while (t->next) {
t=t->next;
if (t->elem==elem) {
return i;
}
i++;
}
//程序执行至此处,表示查找失败
return -1;
}
注意:遍历有头节点的链表时,需避免头节点对测试数据的影响,因此在遍历链表时,建立使用上面代码中的遍历方法,直接越过头节点对链表进行有效遍历。
更新链表中的元素,只需通过遍历找到存储此元素的节点,对节点中的数据域做更改操作即可。直接给出链表中更新数据元素的 C 语言实现代码:
//更新函数,其中,add 表示更改结点在链表中的位置,newElem 为新的数据域的值
linklist *amendElem(linklist * p,int add,int newElem){
linklist * temp=p;
temp=temp->next;//在遍历之前,temp指向首元结点
//遍历到被删除结点
for (int i=1; inext;
}
temp->elem=newElem;
return p;
}
跳表是一种神奇的数据结构,因为几乎所有版本的大学本科教材上都没有跳表这种数据结构,而且神书《算法导论》、《算法 第四版》这两本书中也没有介绍跳表。但是跳表插入、删除、查找元素的时间复杂度跟红黑树都是一样量级的,时间复杂度都是 O ( log n ) O(\log n) O(logn),而且跳表的实现比红黑树简单,在并发环境下,跳表的操作更加局部性一些,跳表的性能更好。所以在工业中,跳表也会经常被用到。
链表虽然通过增加指针域提升了自由度,但是却导致数据的查询效率恶化。特别是当链表长度很长的时候,对数据的查询还得从头依次查询,这样的效率会更低。跳表的产生就是为了解决链表过长的问题,通过增加链表的多级索引来加快原始链表的查询效率,如下图所示
如上图所示,跳表在原有的有序链表上面增加了多级索引,一级索引是 n / 2 n/2 n/2 个,二级的索引是 n / 4 n/4 n/4 个,三级索引是 n / 8 n/8 n/8 个,···通过向上提取索引增加了查找的效率,其实这也是一个“空间换时间”的算法。因此,跳表实质就是一种可以进行二分查找的有序链表。
在一个单向链表中查询的时间复杂度是 O ( n ) O(n) O(n),现在分析一下有 n n n 个节点,有多少级索引?
按照每两个节点抽出一个节点作为上一级索引的节点,那么第一级索引节点大约是 n / 2 n/2 n/2 个,第二级的索引大约是 n / 4 n/4 n/4 个,以此类推,那么第 k k k 级索引点的个数: n / 2 k n/2{^{k}} n/2k。
假设索引有 h h h 级,最高级的索引是 2 个节点,通过上面的例子可以得到: n 2 h = 2 \frac{n}{2^h}=2 2hn=2求得: h = log 2 n − 1 h=\log_2n-1 h=log2n−1。如果包含原始链表这一层,那么整个跳表的高度是: log 2 n \log_2n log2n。
我们在跳表查询某个数据的时候,如果每一层都要遍历 m m m 个节点,那在跳表查询一个数据的时间复杂度是: O ( m ∗ log n ) O(m*\log n) O(m∗logn), m m m 的值是多少呢?
假设我们要找的数据是 x x x,在第 k k k 级索引中,我们遍历到 y y y 节点之后,发现 x > y , x < z x > y,x < z x>y,x<z,所以通过 y y y 的 down 指针,从第 k k k 级索引到第 k − 1 k - 1 k−1 级索引。在第 k − 1 k - 1 k−1 级索引中, y y y 和 z z z 之间再找 1 个节点,所以我们在第 k − 1 k - 1 k−1 级索引中,最多只遍历 3 个节点,以此类推,每一级索引最多只需要遍历 3 个节点。
通过上面的分析,我们得到 m m m 的值是 3。忽略系数,所以在跳表中查找任意数据的时间复杂度是 O ( log n ) O(\log n) O(logn),查找的时间复杂度和二分查找是一样的。
跳表通过建立很多级索引,来提高查找元素的效率,就是典型的“空间换时间”的思想,所以在空间上做了一些牺牲,那空间复杂度到底是多少呢?
假如原始链表包含 n n n 个元素,则一级索引元素个数为 n / 2 n/2 n/2、二级索引元素个数为 n / 4 n/4 n/4、三级索引元素个数为 n / 8 n/8 n/8···所以,索引节点的总和是: n / 2 + n / 4 + n / 8 + … + 8 + 4 + 2 = n − 2 n/2 + n/4 + n/8 + … + 8 + 4 + 2 = n-2 n/2+n/4+n/8+…+8+4+2=n−2,空间复杂度是 O ( n ) O(n) O(n)。
插入数据看起来也很简单,跳表的原始链表需要保持有序,所以我们会像查找元素一样,找到元素应该插入的位置。所以,插入整个时间复杂度为查找元素的时间复杂度 O ( l o g n ) O(logn) O(logn) 加上元素插入到单链表的时间复杂度为 O ( 1 ) O(1) O(1),最终,时间复杂度为 O ( l o g n ) O(logn) O(logn)。
删除操作中,我们除了删除原始链表中的节点,还需要删除索引中的点。
跳表中,删除元素的时间复杂度是多少呢?
删除元素的过程跟查找元素的过程类似,只不过在查找的路径上如果发现了要删除的元素 x,则执行删除操作。跳表中,每一层索引其实都是一个有序的单链表,单链表删除元素的时间复杂度为 O ( 1 ) O(1) O(1),索引层数为 log n \log n logn 表示最多需要删除 log n \log n logn 个元素,所以删除元素的总时间包含 查找元素的时间 加 删除 log n \log n logn 个元素的时间为 O ( log n ) + O ( log n ) = 2 O ( log n ) O(\log n) + O(\log n) = 2 O(\log n) O(logn)+O(logn)=2O(logn),忽略常数部分,删除元素的时间复杂度为 O ( log n ) O(\log n) O(logn)。
如果我们不停的向跳表中插入元素,就可能会造成两个索引点之间的结点过多的情况。结点过多的话,我们建立索引的优势也就没有了。所以我们需要维护索引与原始链表的大小平衡,也就是结点增多了,索引也相应增加,避免出现两个索引之间节点过多的情况,查找效率降低。
跳表是通过一个随机函数来维护这个平衡的,当我们向跳表中插入数据的的时候,我们可以选择同时把这个数据插入到索引里,那我们插入到哪一级的索引呢,这就需要随机函数,来决定我们插入到哪一级的索引中。
比如以在每次新插入元素的时候,尽量让该元素有 1/2 的几率建立一级索引、1/4 的几率建立二级索引、1/8 的几率建立三级索引,以此类推,这样就能很有效的防止跳表退化,而造成效率变低。
跳表的 C 语言实现可参考【LeetCode.1206】设计跳表 以及https://blog.csdn.net/pcj_888/article/details/110723507。
数组,链表和跳表的异同点也是面试中高频的考察点之一。下面是一个比较数组、链表和跳表的表格,希望能够帮助你更好地理解它们之间的区别和联系:
存储方式 | 数据长度 | 插入/删除操作 | 访问操作 | |
---|---|---|---|---|
数组 | 连续的内存空间 | 长度固定,一般不可动态拓展 | O ( n ) / O ( n ) O(n) / O(n) O(n)/O(n) | 随机访问 O ( 1 ) O(1) O(1) |
链表 | 非连续的内存空间,通过指针连接节点 | 长度可动态变化 | O ( 1 ) / O ( 1 ) O(1) / O(1) O(1)/O(1) | 依次访问 O ( n ) O(n) O(n) |
跳表 | 多级索引结构,类似于平衡树 | 长度可动态变化,需额外空间存储索引 | O ( log n ) / O ( log n ) O(\log n) / O(\log n) O(logn)/O(logn) | 快速访问 O ( log n ) O(\log n) O(logn) |