文章目录
一、什么是数据结构
在介绍什么是数据结构之前,先拿绘画做个类比。一般人画画通常就直接画了,比如要画水里的两条金鱼就直接从某一个部分开始,画到后来比例不对,而且常常画得不像。专业画家不是这么画的,他们会把金鱼分解成几个简单的几何图形,比如头是一个大圆和两个小圆(即两个眼睛),身子是一个椭圆,鳍和尾巴是几个三角形。如果是几条金鱼,他会先把位置安排好,为了画面美观,主要景物的布置可能要符合一些几何图形的形状,这些都用淡淡的铅笔或者淡墨勾好了,才把圆、椭圆和三角形经过平滑过渡,画成金鱼,不掌握这种绘画方法,永远走不进专业的大门。
在绘画和摄影中,这些基本的几何图形使用的远比一般人想象的多得多,特别是在构图上,只不过当最终的作品完成时,你看不太出来最早的构图框架而已。对于计算机科学来讲,写一个能够完成特定功能的程序,就相当于是作一幅画,在理解具体需求之后,抽象出具体的基本几何形状这样的基础块,然后用算法将这些模块进行组合,写出符合需求的程序。这些程序中基本的几何图形,就是计算机的数据结构。如果说一幅画是点的有机组合,几何图形反应出点之间常用具体的关系,那么在计算机科学中,数据就等同于点,数据结构就是数据中常用的具体关系。
计算机科学主要是利用计算机解决我们现实生活中遇到的各种问题或需求的技术,我们要想让计算机能解决现实生活中遇到的问题或需求,首先需要将现实问题抽象为数学模型,转换为对数据的计算和处理,计算机才能够发挥作用。
将现实世界的数据组织成为一些具有特定关系的逻辑结构,再把这些逻辑结构的数据映射到计算机的物理存储结构中,这便是计算机科学中的数据结构要解决的问题。数据按照一定的关系结构映射到计算机内存中后,需要在内存中处理这些数据结构,如何在内存中操作这些数据结构就是算法要解决的问题了。对同一个现实问题,使用不同的数据结构和算法进行存储和计算,表现出来的效率是不一样的,如何评价这些数据组织与存储结构及其对应的操作方法,这便需要引入时间和空间复杂度这把标尺了。
数据结构的组成框架如下图所示:
对于不同的数据结构有不同的处理方法(也即算法),所以数据结构和算法是相辅相成的。数据结构是算法操作的对象或载体,算法要作用在特定的数据结构上才能发挥作用。世界著名计算机科学家、因为发明PASCAL而获得图灵奖的N.Wirth教授写过一本书:《数据结构 + 算法 = 程序》,很好的讲清楚了这三者之间的关系。
1.1 数据的物理存储结构
前面介绍了现实世界的数据要想交给计算机去处理,需要以计算机能识别易操作的方式进行。将现实世界的数据组织成具有特定关系的逻辑结构,是为了方便我们理解这些数据之间的逻辑关系,这些数据要想方便计算机处理,还需要映射为方便计算机存储和处理的结构。
计算机在处理数据时,是将数据暂时存储在内存中的,处理器从内存中获取计算局部数据,并将计算结果再存储到内存中。内存的最基本单位叫做存储单元(一般为一个字节),存储单元相当于一个空盒子,可以放置数据,为了便于管理,盒子会给一个编号,这些存储单元的编号就是地址。我们把存储单元的编号或地址都编成0、1、2…这样,这些编号或地址的取值范围就称为地址空间,这个地址空间跟一维坐标轴一样,所以是一维地址空间。
一般一个存储单元可以放置一个单位的数据,假如需要放置多个数据,我们有两种放置方案(物理存储方案):一个挨一个的连续放置数据;不连续的放置数据。一个数据结构的多个数据间包含特定的关系,存储到内存中这个关系当然也需要保留。连续放置的数据天然存在邻接关系,可通过地址偏移量(索引、下标)互相访问;不连续放置的数据就需要赋予其相互关系的属性,比如在一个数据中标记下一个数据的地址或编号(指针),通过这个指向关系,我们可以在不连续的放置方案中依次查找我们所需要的数据。为什么会有不连续的放置方案呢?原因很简单,一个主要原因是,内存的空间利用率高、碎片少、删除旧有的数据很容易。
由于计算机内存是一维线性地址空间,计算机的物理存储结构或方案只有两种:
- 连续存储(也称顺序存储结构):包含数据间的邻接关系,随机访问元素很快,但从中间插入或删除元素较慢,且地址空间大小在创建时指定,后面几乎不可更改,缺乏弹性;
- 不连续存储(也称链式存储结构):包含数据间的指向关系,地址空间大小可根据需要动态调整,从中间插入或删除元素很快,但随机访问元素较慢,且需要额外存储元素间的指向关系。
1.2 数据的逻辑组织结构
了解了不同数据在计算机内存中的物理存储结构,那么如何将现实世界中各式各样的数据放入到物理内存中?我们可以分两步走,第一步是将现实世界中的数据组织成具有特定关系的逻辑结构,第二步再把逻辑结构的数据映射到物理结构中。
数据的逻辑结构是从现实世界抽象出来的,可以直观反映数据之间的逻辑关系。比如,线性表这种最基本的抽象数据结构,最早起源于商业上的办公自动化,在商业上,报表是一种最常见的数据组织形式,而在管理上最多见的则是人员或者物资的记录等,它们被抽象为线性的数据,然后按照1、2、3…的顺序排列出来便于后期检索查阅。为此,很有必要设计一种抽象的数据结构概括所有这些顺序排列和存储的数据,它就是线性表。当然,随着计算机数据规模的扩张,未必能给所有数据都赋予一个编号、结构化的存储,但是它们依然遵循一个顺序的特征,比如电商交易的日志记录按照所发生的时间顺序一条条线性的记录,因此线性表的性质它们都有。
线性表本身是一个抽象的逻辑结构,映射到物理存储结构中,可以使用顺序存储结构实现,也可以使用链式存储结构实现。 使用顺序存储结构实现的线性表最常见的就是数组,数组这种邻接关系保证了数据的查询很快,从中间增加删除很慢(需要移动后面的数据占据或腾出空间),且容易因为连续存储地址空间不足导致扩展比较麻烦。链表这种指向关系正好可以跟数组形成互补,从中间增加删除很简单(只需要改变前后数据的指向关系),不需要连续存储地址空间比较方便扩展,但数据的查询比较慢(只能依据数据指向关系从链表头逐个遍历查询)。
数据的逻辑结构主要有两个属性:一个属性是数据的值,另一个属性是数据之间的关系。数据之间的逻辑关系除了上面介绍的线性关系,还有更复杂的树状关系、网状关系(图)、归属关系(集合)等,这些逻辑关系映射到物理存储结构中,既可以通过顺序存储结构实现,也可以通过链式存储结构实现,根据现实需求与两种物理存储结构的优缺点选择采用哪种物理存储方案实现。
二、线性表
前面已经介绍了线性表这种最基本数据结构的起源,将现实世界的数据抽象为前接后继的线性逻辑关系。同时介绍了线性表使用顺序存储结构与链式存储结构的优缺点,下面分别介绍这两种物理存储结构是如何实现线性表的。
2.1 顺序存储结构实现:顺序表
数组我们已经很熟悉了,我们创建一个数组类型,只需要声明数组元素的类型和数目就可以了。因为数组元素间是连续存储的邻接关系,我们只需要知道该数组在内存中存储的首地址和该数组中某元素的下标(实际上是地址偏移量),便可以知道该数组中某元素的物理存储地址,便可以顺利访问该数组元素了。数组元素间的线性关系如下:
我们初始化一个数组,数组名a就保存了该数组在内存中存储的起始地址(线性起点),后面跟数组元素下标(比如a[0]),就可以访问该元素。数组元素下标为何从0开始呢?既然把下标作为数组元素相对首地址的偏移量,首元素存储地址相比该数组起始地址的偏移量为0,数组首地址加数组某元素相对首元素的地址偏移量(由下标乘以该元素类型占用字节数获得)可以直接得到该数组元素的物理存储地址(a[k]_address = base_address + k * type_size)。数组元素的操作方法示例如下:
// datastruct\array_demo.c
int main(void)
{
// Create a one-dimensional array
int a[6] = {
1, 3, 4, 5};
printf("a = %X, *a = %d\n", a, *a);
printf("a[0] = %d, a[1] = %d\n", a[0], a[1]);
// Modifying array element values
a[1] = 7;
// Accessing array element values
for(int i = 0; i < 6; i++)
printf("a[%d] = %d\n", i, a[i]);
printf("\n");
return 0;
}
上面数组操作示例程序运行结果如下:
对某种数据结构的操作方法主要有五种:增、删、改、查、排序,根据需要还可以提供初始化构造函数与释放析构函数。对于数组我们最常使用的操作方法是修改和随机访问(见上面的示例代码),前面介绍排序算法时,也是以数组方式存储数据的,所以数组的排序操作也比较方便。因为数组从中间新增或删除元素效率较低,所以这两种操作方法不常用(如果想用也可以自己实现),这里就不展示了。
- 多维数组
我们经常使用的Excel是以二维表格的形式管理的,常用的字符串数组也是以二维数组的形式管理的(一个字符串是以一维字符数组的形式保存的),我们学过的矩阵也是以二维甚至多维的形式表示的。
多维数组的物理存储结构依然是连续的,也即多维数组中的所有元素在物理内存中都是线性连续排列的。多维数组只是一种逻辑组织结构,相当于把一个一维数组分割成几段分别管理。比如二维数组就是把一维数组分割成多段,每一段称作一行,每行相同序号的元素共同构成一列,图示如下:
多维数组这种逻辑组织结构是为了方便我们更最直观理解数据,实际在内存中是以一维线性连续方式存储的(在内存中不体现维度)。多维数组的操作跟一维数组类似,下面给出一个参考示例程序:
// datastruct\array_demo.c
#include <stdio.h>
int main(void)
{
// Create a two-dimensional array
int b[][3] = {
{
1, 2}, {
3, 4, 5}, {
7}};
printf("b = %X, *b = %X, **b = %d\n", b, *b, **b);
printf("b[0] = %X, b[0][0] = %d, b[1][1] = %d\n", b[0], b[0][0], b[1][1]);
// Modifying array element values
b[1][1] = 11;
// Accessing array element values
for(int i = 0; i < 3; i++)
{
for(int j = 0; j < 3; j++)
printf("b[%d][%d] = %d\t", i, j, b[i][j]);
printf("\n");
}
printf("\n");
return 0;
}
上面二维数组的示例程序执行结果如下:
数组算是最常用的一种数据结构了,依序存取、随机访问都比较快速方便,但想要在中间插入或删除元素就会比较麻烦,为了保证元素间的邻接关系,元素之间不能存在空位,因此要从中间插入或删除一个元素,后面的所有元素都要向后或向前挪动位置,以腾出空间或占据空位,这就很影响效率了。对于需要经常在中间插入或删除数据的场景怎么办呢?这就需要采用链式存储结构来实现线性表了。
2.2 链式存储结构实现:链式表
采用链式存储结构实现线性表,每个元素就需要显式存储数据之间的指向关系了(顺序存储结构具有隐式的邻接关系)。线性表中数据之间的关系是线性关系,也即前接后继的关系,要能从头部数据沿着某个方向逐个遍历到线性表中的所有数据。元素间的线性关系要求前一个元素能找到后一个元素,最简单的就是保存后一个元素的物理存储地址,指针变量正好存储的是地址值,很适合拿来保存下一个数据的存储地址,也即指针可以显式保存线性表中数据之间的指向关系。
按照上面的分析,链表中的元素至少包含两个变量:一个用来存放数据的值;另一个用来存放指向下一个元素地址的指针。如果把链表中的某个元素称为结点(Node),每个结点包含数据域与指针域两部分,数据域用来存放该结点的数据信息,指针域用来存放该结点与其它结点的指向关系信息,这两部分需要封装为一个整体共同保存该结点的全部信息。什么数据类型具有封装功能呢?C语言的结构体和C++的类都具有封装功能,对于面向过程的C语言来说,自然使用结构体来封装链表结点的全部信息,封装示例如下:
struct Element
{
DataType data; //DataType根据实际元素类型决定
struct Element *next; //next存储下一个元素的地址
}
struct Element a = {
data,NULL};
struct Element *List = &a; //这个List就是一个“链表”
为了便于访问一个链表中的任一元素,常需要在链表头部放置一个头结点(链表头),作为该链表的名称(一般称head,类似于数组名)。链表头可以作为第一个结点(第一个数据域保存用户数据的结点)使用,也可以在链表头数据域存放该链表的一些特征信息(比如元素个数),该链表头的指针域则指向第一个结点,后者更为常用。链表元素间的逻辑结构如下图所示:
链表头独立于其余元素,保存整个链表的统计信息能带来不少好处。首先链表头数据域保存链表元素个数,当我们查询链表中元素数目时,就不必遍历一遍,可以直接返回,特别是对元素数目很大的情况下更是如此。
链表头的数据域保存指向第一个结点的指针,可以方便往链表首部也即第一个结点前插入结点(独立的链表头永远在最前面);如果需要经常往链表末尾插入结点,每次遍历到链表末尾结点再操作其指针域就比较耗时了,可以在链表头指针域内再新增一个指向最后一个结点地址的指针变量,这样从链表首尾插入新的结点都比较快了。
带独立链表头的链表结点数据结构如下(把指针域放前面为了便于强制类型转换):
//Node是普通结点,pNode是指向普通结点的指针
struct Node {
struct Node *next; //指向下一个元素的地址
ElementType Element; //可以是数据集合或其它的数据结构
};
typedef struct Node *pNode;
//HeadNode特用于头结点,List也只能指向头结点
struct HeadNode {
struct Node *next; //指向第一个结点的地址
int size; //保存链表中有效元素数目
};
typedef struct HeadNode *List;
了解了链表的数据结构描述,接下来看链表支持的操作方法。前面说了,链表的插入、删除操作比较方便,链表的查找、访问相比数组低效些。由于链表不支持随机访问,因此不适合进行排序操作,所以链表主要的操作就是创建、插入、查找、删除这三种。
- 链表创建
链表创建实际上就是创建链表头结点,主要分为两步,先为头结点分配内存空间,再初始化头结点的各成员变量,最后返回链表头结点指针。参考实现代码如下:
// datastruct\slist.c
List Create(void)
{
List ptr = malloc(sizeof(struct HeadNode));
if(ptr == NULL)
printf("Out of space!");
ptr->size = 0;
ptr->next = NULL;
return