本文旨在讲解基本的数据结构知识(主要是线性表),使用C/C++语言从零实现各种数据结构,适合于代码能力薄弱的初学者以及想要复习数据结构的读者们。笔者能力有限,所讲述的内容也无法面面俱到,如有表述错误,欢迎指正!
一、线性表
1、逻辑结构与物理结构
学习每一种数据结构,一定不能将“逻辑结构”与“物理结构”这两个概念抛弃,接下来对这两个概念进行阐述:
(1)所谓“逻辑结构”,便是数据与数据之间的关系,主要有:一对一(线性结构)、一对多(树形结构),多对多(图形结构)和集合结构(无序),而我们更关心的是前三种逻辑结构。
(2)所谓“物理结构”,便是数据结构的具体实现方式,主要有:顺序结构、链式结构、索引结构和哈希结构(哈希表)。
不管是哪一种“逻辑结构”,其基本实现方式都离不开顺序结构和链式结构,也就是数组和链表,包括索引结构和哈希结构的底层实现也是数组和链表,所以学好数组和链表是学好数据结构的基本要求。
2、线性表的基本概念
线性表是最基本的符合“线性结构”的数据结构,数据与数据之间有前后顺序之分(有序),一般将线性表中元素的个数n定义为线性表的长度,当n=0时为空表。
针对于非空的线性表,有以下特点:
(1)存在唯一的一个“第一个”元素和一个“最后一个”元素。
(2)除第一个元素外,其他元素都有且仅有一个前驱(前一个元素)。
(3)除最后一个元素外,其他元素都有且仅有一个后继(后一个元素)。
3、线性表的实现
线性表的逻辑结构为线性结构,物理结构有顺序存储方式(数组实现)和链式存储方式(链表实现)。
二、顺序表
1、基本概念
顺序表是指使用顺序存储结构的线性表,使用一组地址连续的存储单元依次存储各个元素。对于顺序表,逻辑上相邻的元素在物理位置上也是相邻的。
假设每个元素占用L个存储单元,则对于第i个元素a[i]的存储位置有以下关系:
根据以上公式可以得出,若要确定顺序表中的某一个元素a的地址,只需要知道首元素的地址与该元素a的次序(索引)即可通过运算获得。所以对于顺序表,其中的任意一个数据元素都可以随机存取(按下标存取)。
2、结构定义
基于上述的公式与顺序表的特点,可以使用数组实现顺序表,以下是顺序表的结构定义:
const int MAXSIZE = 100; //顺序表最大容量
typedef int ElemType; //顺序表元素的数据类型
/* 顺序表 */
typedef struct
{
ElemType* elem; //元素数组首地址
int length; //顺序表长度
}SqList;
3、初始化
顺序表的初始化操作主要是针对结构定义中的两个结构变量初始化,对于指针elem可以使用C++的new关键字进行初始化,长度length则初始化为0,以下是初始化代码:
void InitList(SqList& L)
{
L.elem = new ElemType[MAXSIZE];
if (!L.elem)
return;
L.length = 0;
}
4、增删改查
(1)查:
如果非要问增删改查四个操作中哪个操作最重要,那笔者认为应该是查操作,因为在进行增删改操作前,必须要先查找到对应的元素。
查找有两种类型:按值查找(找到对应的值,存在返回位置)、按位查找(找到对应位置的元素,存在返回元素的值)。对于顺序表而言,按位查找只需要输入索引(位置,从1开始)即可,而按值查找则需要从头遍历查找(如果是有序数组可以采用二分查找),以下是实现代码:
//按位查找
ElemType GetElem(const SqList& L, int index)
{
if (index < 1 || index > L.length)
return -1;
return L.elem[index - 1];
}
//按值查找
int LocateElem(const SqList& L, ElemType e)
{
for (int i = 0; i < L.length; i++)
if (L.elem[i] == e)
return i + 1;
return -1;
}
(2)改:
改是在查的基础上进行的,同样有按位修改和按值修改,不过按值修改可以转换为按位修改(先使用按值查找找到对应位置,再进行按位修改),后续只讲述按位的增删改(按值的增删改可转换为按位增删改),接下来是按位修改的代码:
void setElem(SqList& L, int index, ElemType e)
{
if (index < 1 || index > L.length || L.length == MAXSIZE)
return;
L.elem[index - 1] = e;
}
(3)增:
顺序表的增操作需要对应的移动元素,一般而言,假设当前表长度为n,若想在第i个位置插入一个元素,则需要将原本第i个到第n个元素均向后移动一个单位(从第n个开始移动,一直到第i个移动完毕),之后将需要插入的元素放入空出来的第i个位置。
注意!在插入数据前需判断表长度是否达到最大容量,若达到则不允许插入,否则会造成溢出错误。
void ListInsert(SqList& L, int index, ElemType e)
{
if (index < 1 || index > L.length + 1 || L.length == 0)
return;
for (int i = L.length - 1; i >= index - 1; i--)
L.elem[i + 1] = L.elem[i];
L.elem[index - 1] = e;
++L.length; //表长度加1
}
(4)删:
顺序表的删操作同样需要移动元素,一般而言,假设当前表长度为n,若想删除第i个位置的元素,则需要将原本第i+1个到第n个元素均向前移动一个单位(从第i+1个开始移动,一直到第n个移动完毕),之后将原本第n个位置的元素释放。
注意!在删除数据前需判断表是否为空,若为空不允许删除。
int ListDelete(SqList& L, int index)
{
if (index<1 || index>L.length)
return -1;
int e = L.elem[index - 1];
for (int i = index; i < L.length; i++)
L.elem[i - 1] = L.elem[i];
--L.length; //表长度减1
return e; //返回删除元素的值
}
5、遍历
所谓遍历,即是对表中所有元素均访问一遍。对于顺序表而言有正序遍历与逆序遍历两种方式,以下选择的是正序遍历,并在遍历的过程中打印数据。
void printList(const SqList& L)
{
if (!L.elem || L.length == 0)
return;
for (int i = 0; i < L.length; i++)
cout << L.elem[i] << " ";
cout << endl;
}
6、应用
(1)逆转顺序表:已知顺序表LA,将该表逆转(逆序),且要求空间复杂度为O(1)。
解题思路:
本题要求是构造一个与原来的表LA逆序的表,且要求空间复杂度为O(1),即要求不使用另外的存储空间,若LA的长度为n,则所能使用的空间即为n。为此可以采用先删除尾元素(最后一个元素),再将该元素插入头元素(第一个元素)的方式,这样的一组操作使得表长始终为n,而删除与插入操作使用上述的顺序表增删操作即可。之后便是循环以上操作,直到所有元素均逆转完毕。实现代码如下:
//逆转顺序表
void RotateList_Sq(SqList& L)
{
if (Empty(L))
return;
int e, n = Length(L);
for (int i = 0; i < n - 1; i++)
{
e = ListDelete(L, n);
ListInsert(L, i + 1, e);
}
}
(2)合并有序顺序表:已知两个有序顺序表LA和LB,将两个顺序表合并为有序的LC。
解题思路:
首先初始化LC,即设定LC的长度为LA和LB长度之和,并使用new操作初始化LC的元素指针,并定义一个指针pc指向LC的首元素。之后设定4个指针,分别为pa、pa_last、pb、pb_last,分别指向LA的首元素与尾元素、LB的首元素与尾元素。
之后比较pa与pb指向的元素的大小,小的一方(例如pa)赋值给pc,并更新pc和小的一方的指针(pa),循环此操作,直到pa==pa_last(表示pa移到到LA的终点)或pb==pb_last(表示pb移动到LB的终点)。
最后将未完全遍历完的表的剩余部分接到LC最后(例如此时pb!=pb_last,则表示LB未完全遍历完,将pb后续的部分接到LC最后)。
实现代码如下:
//合并有序顺序表
void MergeList_Sq(SqList LA, SqList LB, SqList& LC)
{
LC.length = LA.length + LB.length;
LC.elem = new int[LC.length];
int* pc = LC.elem;
int* pa = LA.elem,
* pa_last = LA.elem + LA.length - 1;
int* pb = LB.elem,
* pb_last = LB.elem + LB.length - 1;
while ((pa <= pa_last) && (pb <= pb_last))
{
if (*pa <= *pb) //判断pa和pb指向的元素哪个小,小者赋值给pc
*pc++ = *pa++;
else
*pc++ = *pb++;
}
while (pa <= pa_last) //LA未遍历完,将剩余部分接到LC最后
*pc++ = *pa++;
while (pb <= pb_last) //LB未遍历完,将剩余部分接到LC最后
*pc++ = *pb++;
}
三、单链表
1、基本概念
链表是指使用链式存储结构的线性表,主要有单向链表(也叫单链表)、双向链表、循环链表等(本文主要介绍单链表),链表不要求逻辑上相邻的元素在物理存储上也相邻,相邻的元素之间使用指针链接。
链表的核心结构是链表结点LNode,对于每一个链表结点,包括数据域data,与指针域next,数据域表示当前结点存储的数据,而指针域表示指向下一个结点的指针。(对于双向链表的指针域有两个指针,一个指向后继结点next,一个指向前驱结点prior)。
2、结构定义
笔者这里对数据的类型采用了int型,读者可根据自己需要修改数据的类型。以下是对单链表结构定义的样例代码:
/* 链表 */
typedef struct LNode
{
int data;
struct LNode* next;
}LNode, * LinkList;
3、初始化
一般使用指向链表的头结点的指针L表示链表,链表的初始化主要是对L和L指向的头结点初始化,对L使用new关键字初始化,并初始化L->next为NULL,表示当前链表为空(头结点不计入链表长度计算)。实现代码如下:
void InitList(LinkList& L)
{
L = new LNode;
if (!L)
return;
L->data = 0;
L->next = NULL;
}
4、增删改查
(1)查:
对单链表的查找包括按值查找和按位查找,但两种方式均需要从头开始遍历,只不过是循环的判断条件不同,以下是实现的代码:
//按位查找
int GetElem(const LinkList& L, int index)
{
LNode* p = L->next;
int i = 1;
while (p && i < index)
{
p = p->next;
++i;
}
if (!p || i > index)
return -1;
return p->data;
}
//按值查找
LNode* LocateElem(const LinkList& L,int e)
{
LNode* p = L->next;
while (p && p->data != e)
p = p->next;
return p;
}
(2)改:
按位修改的实现与按位查找的实现类似,只不过将返回数据变为修改数据。
void setElem(LinkList& L, int index, int e)
{
LNode* p = L->next;
int i = 1;
while (p && i < index)
{
p = p->next;
++i;
}
if (!p || i > index)
return;
p->data = e;
}
(3)增:
void ListInsert(LinkList& L, int index, int e) //index从0开始
{
LNode* p = L;
int i = 0;
while (p && i < index - 1)
{
p = p->next;
++i;
}
if (!p || i > index - 1)
return;
LNode* s = new LNode;
s->data = e;
s->next = p->next;
p->next = s;
++(L->data);
}
(4)删:
LNode* ListDelete(LinkList& L, int index)
{
LNode* p = L;
int i = 0;
while (p->next && i < index - 1)
{
p = p->next;
++i;
}
if (!(p->next) || i > index - 1)
return NULL;
LNode* q = p->next;
p->next = q->next;
--(L->data);
return q;
}
5、单链表的创建
单链表的创建方式有头插法(新结点插在链表头部)和尾插法(新结点插在链表尾部)。
(1)对于头插法:
首先初始化头指针L(L->next赋值NULL),定义指针p指向新插入的结点,并对p指向的结点的数据域进行赋值操作(需要插入的数据)。
之后令p->next=L->next(若此时链表为空,则新结点p成为第一个结点,若链表非空,则新结点p的下一个结点为原本链表的第一个结点)。
最后更新L->next,令其指向链表新的首结点p,即令L->next=p。
void CreateList_H(LinkList& L, int nums[], int len) //nums数组为待插入的数据
{
L = new LNode;
L->next = NULL;
for (int i = 0; i < len; i++)
{
LNode* p = new LNode;
p->data = nums[i];
p->next = L->next;
L->next = p;
}
}
(2)对于尾插法:
首先初始化头指针L,同时定义尾指针r(始终指向链表最后一个结点),并初始化r=L,即初始状态下,首指针和尾指针均指向头结点。
之后定义指向新结点的指针p,并对p的数据域赋值,令p->next=NULL(p指向的结点为链表最后一个结点,故其下一个结点为空),令尾结点的下一个结点为结点p,即r->next= p。
最后更新尾指针r,令r指向新结点p,即r=p。
void CreateList_R(LinkList& L, int nums[], int len)
{
L = new LNode;
L->next = NULL;
LNode* r = L;
for (int i = 0; i < len; i++)
{
LNode* p = new LNode;
p->data = nums[i];
p->next = NULL;
r->next = p;
r = p;
}
}
6、遍历
对单链表的遍历只能从头开始,不能像顺序表那样正序和逆序遍历。以下是链表遍历代码:
void printList(const LinkList& L)
{
LNode* p = L->next;
while (p)
{
cout << p->data << " ";
p = p->next;
}
cout << endl;
}
7、应用
(1)逆转链表:逆转一个链表L,要求空间复杂度为O(1)。
解题思路:
解决该问题采用每次均删除第一个结点再使用头插法构造新链表(头插法构造的链表顺序是插入顺序的逆序),具体代码如下:
//逆转链表
void RotateList_L(LinkList& L)
{
LNode* p = L->next;
L->next = NULL;
while (p)
{
LNode* q = p->next;
p->next = L->next;
L->next = p;
p = q;
}
}
(2)合并有序链表:将两个有序链表LA和LB合并为新的有序链表LC,要求空间复杂度为O(1)。
解题思路:
首先定义两个指针pa和pb分别指向LA和LB的第一个结点(头结点的下一个结点,头结点不计入链表长度计算),令LC=LA(不使用另外的存储空间),定义指针pc=LC。
之后比较pa的数据和pb的数据,若pa指向的数据小,则令pc的下一个结点为pa指向的结点(pc->next=pa),并更新pc和pa(pc=pa,pa=pa-next),pb指向数据小的情况相同。循环以上操作,直到pa或pb有一个为NULL(有一个链表遍历完毕)。
最后将未遍历完的链表剩余的部分接到新链表最后。
//合并有序链表
void MergeList_L(LinkList& LA, LinkList& LB, LinkList& LC)
{
LNode* pa = LA->next;
LNode* pb = LB->next;
LC = LA;
LNode* pc = LC;
while (pa && pb)
{
if (pa->data <= pb->data)
{
pc->next = pa;
pc = pa;
pa = pa->next;
}
else
{
pc->next = pb;
pc = pb;
pb = pb->next;
}
}
pc->next = pa ? pa : pb;
delete LB;
}