02_线性存储结构小结
- 线性表是存储数据中具有一对一关系的一种存储结构。
- 注意这里的线性表指的是存储结构(顺序表、链表),而不是存取结构(栈、队列等)。存取结构也可以基于存储结构来实现,比如顺序栈、链栈
- 线性存储结构共同点是将数据按照顺序用一根线串联起来。
- 所存储的数据类型必须相同。
- 对于顺序表而言。每一个结点元素的类型都是相同的,因为需要通过索引来找到元素的位置,则每个结点元素的长度必须相同,符合相同结构体定义的数据才能被线性表的统一规则正确访问。
- 对于链表而言。链表对结点的访问依赖于指针而不依赖于索引,即是不依赖于结点的相同性。但归根结底,链表也是数据存储结构,对结点的访问终究是要访问到具体的数据。两个结点如果属于不同的结构体,那么通过结构体变量访问数据项的时候,不同的数据类型所占的长度也是不一样的,无法通过统一规则访问。所以链表也要求数据类型必须相同。
1 顺序表
-
通过元素的顺序存储来反映数据间的逻辑关系。
-
可以通过索引随机存取顺序表的元素。
-
索引访问的优势。定义顺序存储结构时,结点首地址与结点大小是已知的,借助于地址空间的连续性,拿到索引(偏移量)就可以精确计算出访问元素的位置。因此更适合查询或更新元素多的场景。时间复杂度O(1)
-
索引访问的不足。所有的插入和删除操作需要通过移动元素来实现,因为需要保证存储结构所定义的地址空间连续性,元素通过索引访问的正确性。移动元素是一个非常耗性能的操作。若顺序的长度为n,那么平均时间复杂度O(n/2)。
-
-
存储结构定义。
- 由于顺序表是顺序存储,且通过索引访问元素,则用一个数组来存储它的元素。
- 涉及到插入元素可能超过数组容量,所以需要实现动态数组的功能。
- 用指针来存储数组首地址。
- 用一个length字段来存储当前元素个数。
- 动态数组最大容量,是判断是否需要扩容的依据。
- 达到扩容条件时候,每次扩容需要增量。
// 定义初始大小和增量 #define LIST_INIT_SIZE 150 #define LIST_INCREMENT 20 // 定义返回状态及返回码 #define Status int #define OK 1 #define ERROR 0 // 定义存储的元素类型 #define ElementType int // 顺序表的存储结构定义 typedef struct { // 线性表存储空间地址 ElementType* elem; // 线性表当前存放元素的个数 int length; // 当前线性表的容量 int listSize; // 约定每次扩容的增量 int incrementSize; }SqList;
-
常用操作注意项。
-
初始化 -> 分配内存需要注意失败的情况。
/* 01_顺序表——创建空表:没有元素,长度为0,默认容量和增量*/ Status createList_Sq(SqList& sL, int initSize = LIST_INIT_SIZE, int incrementSize = LIST_INCREMENT) { // 开辟顺序表存储空间 sL.elem = (ElementType*)malloc(initSize * sizeof(ElementType)); // 创建失败,返回错误码 if (!sL.elem) { return ERROR; } sL.length = 0; sL.listSize = initSize; sL.incrementSize = incrementSize; return OK; }
-
插入元素
/* 05_顺序表——插入元素:在指定索引 index 之前插入一个元素*/ Status insertElem_Sq(SqList& sL, int index, ElementType elem) { // 索引不合法 if (index < 0) { return ERROR; } // 是否进行扩容 if (sL.length + 1 >= sL.listSize) { sL.elem = (ElementType*)malloc((sL.listSize + (size_t)sL.incrementSize) * sizeof(ElementType)); // 扩容失败 if (!sL.elem) { return ERROR; } sL.listSize += sL.incrementSize; } // 在末尾添加 if (index >= sL.length) { //sL.elem[sL.length] = elem; *(sL.elem + sL.length) = elem; } else { // 插入前从后往前移动元素,注意索引取值 /*for (int i = sL.length - 1; i >= index; i--) { sL.elem[i + 1] = sL.elem[i]; }*/ for (ElementType* p = sL.elem + sL.length + 1; p >= sL.elem + index; p--) { *(p + 1) = *p; } //sL.elem[index] = elem; *(sL.elem + index) = elem; } // 不要忘记长度 ++sL.length; return OK; }
- 插入索引在当前长度范围外的定义。
- 注意判断是否达到扩容条件,以及扩容失败后的操作。
- 插入成功后不要忘记当前元素长度字段。
-
删除元素 -> 同插入。只是少了扩容条件,多了返回元素的功能性定义。
/* 08_顺序表——删除:删除指定索引的元素*/ ElementType deleteElem_Sq(SqList& sL, int index) { // 如果不在元素索引范围内 if (index < 0 || index >= sL.length) { return -1; } ElementType toDelete = *(sL.elem + index); // 数组方式 /*for (int i = index; i < sL.length - 1; i++) { sL.elem[i] = sL.elem[i + 1]; }*/ // 指针方式 ElementType* p = sL.elem + index; ElementType* q = sL.elem + sL.length - 1; while (p < q) { *p = *(p + 1); p++; } // 别忘记长度减一 sL.length--; return toDelete; }
-
清空和销毁。
-
清空是置为初始化状态,存储结构定义还在。
/* 06_顺序表——清空*/ Status clearList_Sq(SqList& sL) { // 先清空数组中的值 delete[] sL.elem; // 重新分配 sL.elem = (ElementType*)malloc(LIST_INIT_SIZE * sizeof(ElementType)); if (!sL.elem) { return ERROR; } // 其余长度置为初始 sL.length = 0; sL.listSize = LIST_INIT_SIZE; sL.incrementSize = LIST_INCREMENT; return OK; }
-
销毁需要释放空间。
/* 07_顺序表——销毁*/ Status destroyList_Sq(SqList& sL) { // 回收数组空间 delete[] sL.elem; //free(sL.elem); // 其余控制元素置为0 sL.length = 0; sL.listSize = 0; sL.incrementSize = 0; return OK; }
-
-
2 链表
-
通过链式存储来反映元素之间的一对一关系。
-
通过指针构成的链遍历链表的元素。
-
链式存储的优势。对于插入和删除操作,只需要针对插入/删除元素与其相邻的元素进行操作,不存在大量移动元素的耗性能的情景。
-
链式存储的不足。对于更新和查询操作,需要从表头指针开始向后访问,即是以遍历的方式进行顺序访问,访问不方便。
-
主要通过单链表来记录链式存储结构的定义和常用操作,再此基础上类比循环链表和双向链表。
2.1 单链表
-
存储结构定义
-
对于数据而言。数据是零散存储的,每个元素结点只需要维护该结点的元素。
-
对于数据间的一对一关系,需要指针进行维护。所以需定义指向当前元素结点类型的指针,完成对链的定义。
-
对于链表结构,初始访问地址需要一个指针,拿到它才能对该链表进行操作。这里将这个指针的存储位置不同,将单链表分为带头结点和不带头结点。
- 带头结点的单链表。即是以下结构体后的HNode类型。
- 数据域的ElementType一般不用,或是维护该链表信息用,比如当前元素个数等。
- 指针域 next指向真正存储的第一个结点元素,即首元结点。
- 不带头节点的单链表。即是以下结构中的*SL,指向的元素即为第一个元素(首元)。
- 带头结点的单链表。即是以下结构体后的HNode类型。
// 定义返回状态及返回码 #define Status int #define OK 1 #define ERROR 0 // 定义存储的元素类型 #define ElementType int /* 定义单链表结点*/ typedef struct SL_Node { // 存储的元素 ElementType data; // 指向下一个结点的指针 struct SL_Node* next; }HNode, *SL;
-
常用操作注意项
2.1.1 初始化操作
-
带头结点。即是需要构造出一个头节点,并且返回或初始化该头节点的地址作为头指针。有两种方式创建头节点。
-
通过指针的创建。由以上定义,SL指向该类型结点的指针。此处head即是指向头结点类型的指针。由于指针在定义的时候,我们是不知道该头指针的地址的,当它分配完内存后我们才能得知地址,涉及的操作需要修改指向头结点的head指针的值,我们必须将头指针作为引用类型传入,是得修改能对该变量生效。
/* 01_单链表——初始化_带头结点 */ Status initList_SLh(SL& head) { // 指向头节点的指针 head = (SL)malloc(sizeof(SL_Node)); if (!head) { return ERROR; } head->data = NULL; head->next = NULL; return OK; }
-
通过指向头节点的二级指针创建。通过以上指针方式创建的讲述,应该会更好理解指向结点的二级指针方式创建头结点。
/* 18_单链表——初始化_带头结点2*/ Status initList_SLh2(HNode** hNode) { *hNode = (HNode*)malloc(sizeof(SL_Node)); if (!*hNode) { return ERROR; } (*hNode)->data = NULL; (*hNode)->next = NULL; return OK; }
-
同顺序表,两种方式内存分配都需要考虑分配失败的情况。
-
-
不带头结点。即是需要初始化一个指向首元的指针,初始化时候没有元素,所以指针给一个NULL值即可。
/* 02_单链表——初始化_无头结点*/ Status initList_SL(SL& sL) { sL = NULL; return OK; }
2.1.2 查询、更新、求长度、遍历操作
需要从第一个元素依次往后查找。区分带头结点和不带头节点,这里举出求长度的操作。
-
带头结点。头指针指向的是头节点,首元是头节点的next域指向的结点。
/* 03_单链表——长度_带头结点*/ int listLength_SLh(SL head) { // 初始迭代长度 int length = 0; // 指向头结点的变量 SL p = head->next; while (p) { length++; p = p->next; } return length; }
-
不带头节点。头指针指向的即是首元结点。
/* 04_单链表——长度_无头结点*/ int listLength_SL(SL sL) { int length = 0; // 从头指针开始迭代 SL p = sL; while (p) { length++; p = p->next; } return length; }
2.1.3 插入元素方式
这里采用批量插入方式作为说明,对应的单个元素插入方式即为后插法、前插法。
同样,插入元素需要判断内存分配结果。
1 尾插法
这里是用于批量建立的过程。
整个过程只需要两个指针(待插入元素的指针s;其插入后的前驱位置元素指针p)
类比为单个元素的后插法,与前插法比较,后插法不需要额外的指针。两个操作的顺序不可逆,可画图理解。
// 1. 将【待插入元素】指向【前驱元素】的后继 s->next = p -> next; // 2. 将【前驱元素】指向【待插入元素】 p->next = s;
-
带头结点
/* 07_单链表——尾插法建立_带头节点*/ Status tailInsert_SLh(SL& head, ElementType *datas, int length) { // 指针p存放上一个结点 SL p = head; // 用于新建的结点 SL s = NULL; for (int i = 0; i < length; i++) { // 指针p,每次循环在最后一个结点指针域,进行结点创建 s = (SL)malloc(sizeof(SL_Node)); if (!s) { return ERROR; } s->data = *(datas + i); s->next = NULL; // 新结点连接到上一个结点 p->next = s; // 迭代 p = s; s = s->next; } return OK; }
-
不带头结点
- 对于头指针的方法,需要判断是否为第一个元素。
- 因为插入元素需要保存上一个结点,不带头结点的链表,头指针无上一个结点,所以需要单独处理。
/* 08_单链表——尾插法建立_不带头节点*/ Status tailInsert_SL(SL& sL, ElementType* datas, int length) { // 指针p,用来保存上一个结点 SL p = sL; // 指针s,用于创建新结点 SL s = NULL; for (int i = 0; i < length; i++) { // 指针p,每次循环在最后一个结点指针域,进行结点创建 s = (SL)malloc(sizeof(SL_Node)); if (!s) { return ERROR; } s->data = *(datas + i); s->next = NULL; // 不为第一个结点时 if (i) { // 将新节点连到p后面 p->next = s; } // 为第一个结点时s新创建的地址赋值给链表标识sL,以及上一个结点标识p else { sL = s; } // 新节点作为上一个结点 p = s; // 指针q,再移动到新结点创建位置 s = s->next; } return OK; }
- 对于头指针的方法,需要判断是否为第一个元素。
2 头插法
同样,这里是用于批量建立过程。
下面两个方法是批量插入的方法,每一个插入的节点都是在头结点之后,即是head -> next,所以不需要额外的只针来保存该节点。如果是单个元素,则插入到指定位置前,需要从头找到前置节点。
将该方法类比为单个元素的前插法。
- 除了提供的插入元素指针s和插入位置的指针p,p向前找不能找到前置元素,需要从头找到p结点元素的前置元素。
- 前插操作的两个语句可逆,因为三个结点都有指针进行保存。
// 1. 找到p结点元素的前置结点 // 头指针,指向头结点 SL head; // 注意这里带头结点,从头结点开始;如果没有头结点,p为第一个元素的时候,需要特殊处理。 SL q = head; while(q->next != p) { q = q->next; } // 2. 找到q后即开始前插操作。两个语句顺序可逆。 s->next = p; q->next = s;
-
带头结点
/* 09_单链表——头插法建立_带头结点*/ Status headInsert_SLh(SL& head, ElementType* datas, int length) { // 指针p,用作保存上一个创建的结点,使得头节点指向的新创建结点指向上一个结点。 SL p = head; // 指针s,用于创建新的结点 SL s = NULL; for (int i = 0; i < length; i++) { // 每次通过头结点创建新结点 s = (SL)malloc(sizeof(SL_Node)); if (!s) { return ERROR; } s->data = *(datas + i); // 元素接到头结点后 head->next = s; // 第一个结点插入 if (!i) { // 第一个结点即为表尾结点 s->next = NULL; } // 非第一个结点 else { // 结点前置为头结点 head->next = s; // 结点后继为上一个结点 s->next = p; } // 指针p保存当次插入的结点位置 p = s; } return OK; }
-
不带头节点
/* 10_单链表——头插法建立_不带头结点*/ Status headInsert_SL(SL& sL, ElementType* datas, int length) { // 同上,指针p用来保存上一个创建的结点 SL p = sL; // 指针s,用来创建新的节点 SL s = NULL; for (int i = 0; i < length; i++) { s = (SL)malloc(sizeof(SL_Node)); if (!s) { return ERROR; } s->data = *(datas + i); // 第一个结点 if (!i) { // 第一个结点作为表尾结点 s->next = NULL; } else { // 连接到上一次插入的结点 s->next = p; } // 连接到开始 sL = s; // 保存为上一个结点 p = s; } return OK; }
2.1.4 删除元素
删除指定值的元素并返回。每次必须通过前驱结点的next判断该结点,上前驱结点才好更改指针指向。
-
带头节点 -> 带头结点的好处是,不用额外考虑元素个数。
- 多一个头结点就刚好满足了每一个待判断的结点都有前驱,首元结点的前驱即是头结点。
- 若链表没有元素,则不用进入循环体,没有额外的考虑。
/* 14_单链表——删除结点_带头结点*/ void deleteElem_SLh(SL& head, ElementType data) { // 保存待删除结点的上一个结点 SL p = head; // 保存待删除结点 SL q = NULL; while (p->next) { // 指针p总是在上一个结点的时候判断出下一个结点的条件 if (p->next->data == data) { q = p->next; // 指针q若无后继结点,不影响 p->next = q->next; free(q); return; } // 否则继续查找 p = p->next; } }
-
不带头节点
- 不太方便。
- 第一个元素没有前驱结点,需单独判断。
- 但是否有第一个元素还需要判断。带头结点的删除则可以直接进入循环体中判断。
/* 15_单链表——删除结点_不带头结点*/ void deleteElem_SL(SL& sL, ElementType data) { // 指针p,保存待删除结点的前一个结点 SL p = sL; // 指针q,指向p的后继结点 SL q = p->next; // 如果链表没有结点,直接返回 if (!p) { return; } // 如果第一个结点就是待删除结点 else if (p && p->data == data) { sL = q; free(p); return; } // 两个以上结点 while (p->next) { // 指针p总是在上一个结点的时候判断出下一个结点的条件 if (p->next->data == data) { q = p->next; p->next = q->next; free(q); return; } p = p->next; } }
- 不太方便。
2.1.5 原地逆置单链表
『单链表原地逆置』个人理解。
- 大思路是将所有结点分为两种状态,『已完成逆置』和『待逆置』;
- 刚开始的状态,所有结点都是待逆置状态。
- 除了首元结点外,后续结点在执行逆置过程中都有结点处于已完成逆置状态。
- 为了方便理解,且不将『首元结点的逆置』与『其余结点逆置』过程作为不同子过程单独处理,需在首元结点逆置过程中『虚拟出一个空结点』作为『已完成逆置状态』的结点。
- 此次虚拟空结点作为『已完成逆置状态结点』,更方便将循环体划分出来。
- 难点在于划分出单轮次的循环过程。
-
带头结点
/* 16_单链表——原地逆置_带头节点*/ void invertList_SLh(SL& head) { // 指针p保存下一个带改变指向的元素,保证断开后头结点的指针能找到 SL p = head->next; // 用指针p保存了,此处一定要置空,作为虚拟出的空结点 head->next = NULL; // 指针s是要改变指向的元素 SL s = NULL; // 特别注意理解第一轮的过程,虚拟一个NULL结点在头结点和首元结点之间,作为【已完成逆置】的元素。 while (p) { // 当前【待逆置的元素】完成,设置【下一个待逆置元素】为【当前待逆置】; s = p; // 指针p作为【下一个待逆置】 p = p->next; // 当前待逆置元素,指向上一个逆置完成的元素(第一个结点的上一个元素即是虚拟出的空结点,此处在进入循环前置空) s->next = head->next; // 当前元素逆置完成,【头结点指针指向的元素】则是【上一轮逆置完成的元素】 head->next = s; } }
-
不带头结点
- 实现上与带头结点的没有不同,仅仅头指针与头结点next域的替换。
/* 17_单链表——原地逆置_不带头节点*/ /* 过程同上,原理无不同,只有head->next替换为sL头指针*/ void invertList_SL(SL& sL) { SL p = sL; sL = NULL; SL s = NULL; while (p) { s = p; p = p->next; s->next = sL; sL = s; } }
2.2 循环链表
-
与单链表唯一的区别是,表尾的结点指向不是NULL。
-
带头结点
- 表为结点指向头结点
-
不带头结点
- 表尾结点指向首元结点
-
-
基于这样一种变化,对于查询、遍历等循环结束条件,与链表是否为空的判断条件,由头结点next域或头指针是否为NULL,改成是否为头结点或首元结点。
2.3 双向循环链表
-
相对于单链表
- 优势是,对于头插法的操作,不需要从表头开始进行遍历查找,可以直接找到前驱。
- 劣势是,多维护一个前驱的关系,在某些情况可能比单链表耗性能。
-
存储结构定义
- 基于单链表结构的优化,反映链式存储关系;
- 数据域定义与单链表无不同;
- 指针域的定义,除了有指向后继结点元素的只针,还有指向前驱结点元素的只针;
// 定义返回状态及返回码 #define Status int #define OK 1 #define ERROR 0 // 定义存储的元素类型 #define ElementType int /* 双向链循环表存储结点*/ typedef struct DuL_Node { // 指向前驱节点指针 DuL_Node* prior; // 存储元素 ElementType data; // 指向后继节点指针 DuL_Node* next; }DuNode, *DuL;
常用操作
2.3.1 初始化操作
-
前驱与后继指针都指向头结点
/* 01_双向循环链表——初始化_带头结点*/ Status initList_DuL(DuL& duL) { duL = (DuL)malloc(sizeof(DuL_Node)); if (!duL) { return ERROR; } duL->data = NULL; // 循环链表,初始前置和后继时候指向自己 duL->prior = duL; duL->next = duL; return OK; }
2.3.2 指定索引位置前插入元素
-
不需要通过前驱结点来判断索引位置结点,找到后就直接对索引位置进行操作。
/* 02_双向循环链表——在指定索引位置前插入元素*/ Status insertByIndex_DuL(DuL& duL, int index, ElementType data) { // 定义:插入之前的元素结点 DuL beforeInsert = duL; // 先找到待插入为值的前置结点 for (int i=0;i<index;i++) { // 如果索引大于链表元素个数,则直接放最后 if (beforeInsert->next != duL) { beforeInsert = beforeInsert->next; } } // 插入后的后继节点 DuL afterInsert = beforeInsert->next; // 建立插入结点 DuL curInsert = (DuL)malloc(sizeof(DuL_Node)); if (!curInsert) { return ERROR; } curInsert->data = data; // 与前置结点关联 beforeInsert->next = curInsert; curInsert->prior = beforeInsert; // 与后继结点关联 curInsert->next = afterInsert; afterInsert->prior = curInsert; return OK; }
2.3.3 遍历
访问方法简单定义
/* 05_双向循环链表——访问*/ void visit(DuNode duNode) { printf("duNode data -> %d\n", duNode.data); }
1. 正向遍历
-
通过next后继指针遍历
/* 03_双向循环链表——正序遍历*/ void traverseList_DuL(DuL duL) { DuL p = duL->next; while (p != duL) { visit(*p); p = p->next; } }
2. 反向遍历
-
通过prior前驱指针遍历
/* 04_双向循环链表——反向遍历*/ void backTraverseList_DuL(DuL duL) { DuL p = duL->prior; while (p != duL) { visit(*p); p = p->prior; } }
2.3.4 批量插入元素到表尾
-
访问正向或反向的表尾元素很简单,直接通过头节点的prior即可。
-
相对于单链表而言,不需要从表头开始遍历到表尾。
/* 06_双向循环链表——批量插入数据到表尾*/ Status batchInsertToTail_DuL(DuL& duL, ElementType* datas, int length) { // 待插入的位置 DuL tail = duL->prior; // 待插入的结点迭代 DuL cur = NULL; for (int i = 0; i < length; i++) { // 每次创建一个结点 cur = (DuL)malloc(sizeof(DuNode)); // 插入失败则返回错误 if (!cur) { return ERROR; } cur->data = *(datas + i); // 连接前置结点 tail->next = cur; cur->prior = tail; // 连接后继结点 cur->next = duL; duL->prior = cur; // 下一个 tail = cur; } return OK; }
2.3.5 删除指定索引的元素
-
不需要通过前驱结点访问到当前结点,因为双向的优势可以直接找前驱或者后继。
/* 07_双向循环链表——删除指定索引的元素*/ ElementType deleteByIndex_DuL(DuL& duL, int index) { ElementType data; DuL cur = duL->next; // 直接找到该结点,不用找前置 for (int i = 0; i < index; i++) { if (cur->next == duL) { return NULL; } cur = cur->next; } // 保存返回值 data = cur->data; // 将前置节点和后继结点连接起来 cur->prior->next = cur->next; cur->next->prior = cur->prior; // 释放空间 free(cur); return data; }
暂时先补充到这里,如果有常见的操作会更新加到这里。