文章目录
二、线性结构
2.1 线性表的定义
线性表是具有相同数据类型的 n ( n ≥ 0 ) n(n \geq 0) n(n≥0)个数据元素的有限序列,n为表长。 n = 0 n=0 n=0时线性表是一个空表。
若用L命名线性表,一般表示为: L = ( a 1 , a 2 , . . . , a i , a i + 1 , . . . , a n ) L = (a_1,a_2,...,a_i,a_{i+1},...,a_n) L=(a1,a2,...,ai,ai+1,...,an)
式中, a 1 a_1 a1是唯一的“第一个”数据元素,又称为“表头元素”; a n a_n an是唯一的“最后一个”数据元素,又称为“表尾元素”。
除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继。
线性表是一种逻辑结构,表示元素之间一对一的相邻关系。顺序表和链表是指存储结构,两者属于不同层面的概念。
线性表的特点:
- 表中元素个数有限。
- 表中元素有逻辑上的顺序性,表中元素有先后次序。
- 表中元素都是数据元素,每个元素都是单个元素。
- 表中元素的数据类型都相同,每个元素占有相同大小的存储空间。
- 表中元素具有抽象性,即仅讨论元素间的逻辑关系,而不考虑元素究竟表示什么内容。
2.2 顺序表
2.2.1 顺序表
用一组地址连续的存储单元依次存储线性表中的数据元素,从而使得逻辑上相邻的两个元素在物理上也相邻。
线性表中元素的位序是从1开始的,数组中元素的下标是从0开始的。
i i i是元素 a i a_i ai在线性表中的位序。
C代码描述:
//静态分配
#define MaxSize 50
typedef struct{
ElemType data[MaxSize];
int length;
}SqList;
//类型定义
//动态分配
#define InitSize 100
typedef struct{
ElemType *data;
int MaxSize,length;
}SeqList;
L.data = (ElemType *) malloc(sizeof(ElemType) * InitSize);
注:动态分配不是链式存储,同样属于顺序存储结构,物理结构没有变化。
依然是随机存取的方式,只是分配的空间大小可以在运行时确定。
基本操作的实现:
-
插入 - O ( n ) O(n) O(n)
bool ListInsert(SqList &L,int i,ElemType e){ if(i < 1 || i > L.length + 1) return 0; if(L.length >= MaxSize) return 0; for(int j = L.length;j >= i;--j) L.data[j] = L.data[j - 1]; L.data[i - 1] = e; L.length++; return 1; }
-
删除 - O ( n ) O(n) O(n)
bool ListDelete(SqList &L,int i,ElemType &e){ //e中返回被删除元素 if(i < 1 || i > L.length) return 0; e = L.data[i - 1]; for(int j = i;j < L.length;++j) L.data[j - 1] = L.data[j]; L.length--; return 1; }
-
按值查找 - O ( n ) O(n) O(n)
int LocateElem(SqList &L,ElemType e){ for(int i = 0;i < L.length;++i){ if(L.data[i] == e) return i; } return 0; }
2.2.2 examples - 1
e.1
长度为n的非空线性表采用顺序存储结构,在表的第 i i i个位置插入一个元素,则 i i i的合法范围是: 1 ≤ i ≤ n + 1 1 \leq i \leq n+1 1≤i≤n+1
n + 1 n+1 n+1表示在表尾追加元素。
e.2
已知在一维数组 S [ m + n ] S[m+n] S[m+n]中依次存放两个线性表 A ( a 1 , a 2 , . . . , a m ) A(a_1,a_2,...,a_m) A(a1,a2,...,am) B ( b 1 , b 2 , . . . , b n ) B(b_1,b_2,...,b_n) B(b1,b2,...,bn)。编写一个函数,将两个顺序表的位置互换,即将B放到A的前面。
思想:现将整个数组 S 逆置,此时得到 ( b n , b n − 1 , . . . , b 1 , a m , a m − 1 , . . . , a 1 ) (b_n,b_{n-1},...,b_1,a_m,a_{m-1},...,a_1) (bn,bn−1,...,b1,am,am−1,...,a1),再对前n个数和后m个数分别逆置,从而实现两个表的顺序互换。
代码:
void Reverse(ElemType S[],int left,int right,int arraySize){
if(left >= right || right >= arraySize)return;
int mid = (left + right) / 2;
for(int i = 0;i <= mid - left;++i){
ElemType temp = S[left + i];
S[left + i] = S[right - i];
S[right - i] = temp;
}
}
void Exchange(ElemType S[],int m,int n,int arraySize){
Reverse(S,0,m+n-1,arraySize);
Reverse(S,0,n-1,arraySize);
Reverse(S,n,m+n-1,arraySize);
}
e.3
长度为 L ( L ≥ 1 ) L(L\geq 1) L(L≥1)的升序序列 S S S,处在第 ⌈ L / 2 ⌉ \lceil L/2\rceil ⌈L/2⌉个位置的数称为 S S S的中位数。现有两个等长升序序列 A A A和 B B B,设计一个算法找到两个序列的中位数。
思想:
分别求出两个序列 A , B A,B A,B的中位数 a , b a,b a,b,依次执行:
-
若 a = b a=b a=b,则 a a a或 b b b即是所求中位数。
-
若 a < b a<b a<b,则舍弃 A A A的较小的一半和 B B B的较大的一半,两段要相等。
-
若 a > b a>b a>b,则舍弃 A A A的较大的一半和 B B B的较小的一半,两段要相等。
在保留的序列中重复计算中位数并重复过程123,直到两个序列均含有一个元素为止,此时两个元素中的较小者就是中位数。
代码:
int M_Search(int A[], int B[], int n){
int l1, r1, a;
int l2, r2, b;
l1 = l2 = 0;
r1 = r2 = n - 1;
while(l1 != r1 || l2 != r2){ // 左闭右闭区间 相等即为单个元素
a = A[(l1 + r1) / 2];
b = B[(l2 + r2) / 2];
if(a == b)return a;
if(a < b){
l1 = (l1 + r1) / 2;
r2 = (l2 + r2) / 2;
if((r1 - l1 + 1) % 2 == 0) l1++; // 长度为偶数
} else {
r1 = (l1 + r1) / 2;
l2 = (l2 + r2) / 2;
if((r2 - l2 + 1) % 2 == 0) l2++; // 长度为偶数
}
}
if(A[l1] < B[l2]) return A[l1];
return B[l2];
}
时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n),空间复杂度为 O ( 1 ) O(1) O(1)。
e.4
整数序列 A = ( a 0 , a 1 , . . . , a n − 1 ) A = (a_0,a_1,...,a_{n-1}) A=(a0,a1,...,an−1),其中 0 ≤ a i < n ( 0 ≤ i < n ) 0 \leq a_i < n\ (0\leq i <n) 0≤ai<n (0≤i<n),如果存在元素出现超过 n / 2 n/2 n/2次,那么称该元素为主元素。设计算法,找出 A A A的主元素,若存在,输出主元素,若不存在,输出 − 1 -1 −1。
思路:
- 依次扫描数组中的每个元素,将第一个整数 N u m Num Num存到 c c c中,记录 c n t cnt cnt为 1 1 1。
- 扫描接下来的元素,如果遇到的数仍为 N u m Num Num,就让 c n t + 1 cnt+1 cnt+1,否则 c n t − 1 cnt-1 cnt−1; c n t cnt cnt减到 0 0 0时,将遇到的下一个整数存到 c c c中, c n t cnt cnt重新记为 1 1 1,开始新一轮计数。
- 重复2过程,直到扫描完全部数组元素。
- 判断 c c c中的元素是否真的是主元素。再次扫描数组,统计 c c c中的元素出现次数,若大于 n / 2 n/2 n/2,就是主元素;否则,序列中不存在主元素。
代码:
int Majority(int A[], int n){
int c, cnt = 1;
c = A[0];
for(int i = 1;i < n;++i){
if(A[i] == c) cnt++;
else if(cnt > 0) cnt--;
else {
c = A[i];
cnt = 1;
}
}
if(cnt > 0){
cnt = 0;
for(int i = 0;i < n;++i){
if(A[i] == c) cnt++;
}
}
if(cnt > n / 2) return c;
return -1;
}
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)。
2.3 链式表示
链式存储线性表时,不需要使用地址连续的存储单元,即不要求逻辑上相邻的元素在物理位置上也相邻,通过“链”建立起数据元素之间的逻辑关系。插入和删除操作不需要移动元素,而只需要修改指针。但失去随机存取的优点。
链表的每个结点都带有指针域,故而存储密度不够大,存储密度小于1。
2.3.1 单链表
通过一组任意的存储单元来存储线性表中的数据元素。
对每个链表节点,除存放元素自身的信息外,还需存放一个后继的指针。
类型描述:
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode, *LinkList;
单链表可以解决顺序表需要大量连续存储空间的缺点,但单链表附加指针域,也存在浪费存储空间的缺点。
单链表元素离散地分布在存储空间中,所以单链表是非随机存取的存储结构,即不能直接找到表中某个特定的节点,查找某个特定的节点时,需要从表头开始遍历,依次查找。
通常用头指针来标识一个单链表,头指针为NULL时表示一个空表。
为了操作上的方便,在单链表第一个节点之前附加一个结点,称为头结点。
头结点的数据域可以不设任何信息,也可记录表长等信息;头结点的指针域指向线性表的第一个元素结点。
不管带不带头结点,头指针始终指向链表的第一个节点,头结点是带头结点的链表的第一个节点。
引入头结点的优点:
- 第一个数据结点的位置被存放在头结点的指针域中,所以在链表的第一个位置上的操作和在表的其他位置上的操作一致,无需特殊处理。
- 无论链表是否为空,其头指针都指向头结点的非空指针,因此空表和非空表的处理也就得到了统一。
(增设头结点的目的是方便运算的实现)
头插法建立单链表:
LinkList List_HeadInsert(LinkList L,int A[],int n){
// 顺序相反 复杂度O(n)
// 含有头结点
LNode *s;
L = (LinkList)malloc(sizeof LNode);
L->next = NULL;
for(int i = 0;i < n;++i){
s = (LNode *)malloc(sizeof LNode);
s->data = A[i];
s->next = L->next;
L->next = s;
}
return L;
}
尾插法建立单链表:
LinkList List_TailInsert(LinkList L,int A[],int n){
// 顺序相同 复杂度O(n)
// 需要设置一个尾指针r
L = (LinkList)malloc(sizeof LNode);
LNode *s, *r;
r = L;
for(int i = 0;i < n;++i){
s = (LNode *)malloc(sizeof LNode);
s->data = A[i];
r->next = s;
r = x;
}
r->next = NULL;
return L;
}
按序号查找,按值查找:
// 复杂度均为O(n)
LNode *GetElem(LinkList L,int i){
// 按序号查找
if(i == 0) return L; // 返回头结点
if(i < 1) return NULL; // i无效
int cnt = 1; // 设置计数
LNode *p = L->next;
while(p && cnt < i){
p = p->next;
cnt++;
}
return p;
}
LNode *LocateElem(LinkList L,ElemType e){
// 按值查找 不存在返回NULL
LNode *p = L->next;
while(p != NULL && p->data != e) p = p->next;
return p;
}
插入操作:
bool Insert_Elem(LinkList L,int i,ElemType e){
// 后插操作 先找前驱结点
LNode *p = GetElem(L, i - 1);
if(p == NULL) return 0;
LNode *s;
s->data = e;
s->next = p->next;
p->next = s;
return 1;
}
bool Insert_Elem(LinkList L,int i,ElemType e){
// 前插操作 先找前驱结点 交换前驱结点和当前插入结点存放的数据
LNode *p = GetElem(L, i - 1);
if(p == NULL) return 0;
LNode *s;
s->next = p->next;
p->next = s;
s->data = p->data;
p->data = e;
return 1;
}
删除操作:
bool Delete_Elem(LinkList L,int i){
// 删除指定位置的元素
LNode *p = GetElem(L, i - 1);
if(p == NULL) return 0;
LNode *s;
s = p->next;
p->next = s->next;
free(s);
return 1;
}
// 删除给定的结点
bool Delete_Elem(LNode *p){
// 先将后继结点的值赋予自身 然后删除后继结点
p->data = p->next->data;
LNode *q = p->next;
p->next = q->next;
free(q);
}
求表长:
int GetLength(LinkList L){
int cnt = 0;
LNode *p = L->next;
while(p != NULL){
p = p->next;
cnt++;
}
return cnt;
}
2.3.2 双链表
双链表有两个指针prior和next,分别指向其前驱结点和后继结点。
类型描述:
typedef struct DNode{
ElemType data;
struct DNode *prior,*next;
}DNode, *DLinkList;
各个操作均与单链表类似,注意指针的变化情况。
2.3.3 循环链表
- 循环单链表
最后一个结点的指针不是NULL而改为指向头结点,从而整个链表形成一个环。
判空的条件不是头结点的指针是否为空,而是它是否等于头指针。
在任何一个位置插入都是等价的,无需判断表尾。
循环单链表可以从任意一个结点开始遍历整个表。
若操作主要是在表头表尾进行的,可以只设置尾指针,对表头表尾的操作均是 O ( 1 ) O(1) O(1)。
- 循环双链表
头结点的prior指针还要指向尾结点。
循环双链表为空链表时,头结点的prior和next都等于L。
2.3.4 静态链表
借助数组来描述线性表的链式存储结构,结点也有data和next两个域,这里的next指的是结点的相对地址(数组下标),又称游标。
和顺序表一样,静态链表也需要预先分配一块连续的内存空间。
类型描述:
#define MaxSize 50
typedef struct LinkList{
ElemType data;
int next;
} SLinkList[MaxSize];
静态链表通常以next == -1为结束标志。
常用操作与动态链表的相同,只需要修改指针而不需要移动元素。
2.3.5 双指针法合集
补充:找到环的入口的方法。在快慢指针相遇点和起点处同时设置一个指针,两个指针每次走一步,相遇处即为环的入口点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1ZXYWesD-1610883173661)(image-20201018193441089.png)]
2.3.6 examples - 2
e.1
某线性表用带头结点的循环单链表存储,头指针是head,当head->next->next == head成立时,线性表的可能长度是
0 0 0 或 1 1 1
e.2
设计一个递归算法,删除不带头结点的单链表L中所有值为x的点。
递归实现,函数 f(L,x) 的功能是删除以L为首节点指针的单链表中所有值为x的节点。边界为L为空。
代码:
void Delete_X(Linklist L, ElemType x){
// 使用引用,直接对原链表进行操作
LNode *p;
if(L == NULL)return;
if(L->data == x){
p = L;
L = L->next;
free(p);
Delete_X(L, x);
} else {
Delete_X(L->next, x);
}
}
e.3
设L为带头节点的单链表,试实现从尾到头反向输出每个结点的值。
递归输出较为方便。
代码:
void R_Print(LinkList L){
if(L->next != NULL) R_Print(L->next);
if(L != NULL) print(L->data);
}
e.4
给定两个单链表,编写算法找出两个链表的公共节点。
两个链表有公共节点,即两个链表从某一结点开始,它们的next都指向同一个节点。拓扑形状看起来像Y。
假设一个链表比另一个链表长k个结点,现在长的链表上遍历k个结点,然后再同步遍历,就能保证同时到达同一个节点。
时间复杂度O(len1 + len2)。
代码:
LNode* Search_First_Common(LinkList L1, LinkList L2) {
int len1 = Length(L1), len2 = Length(L2);
int dis = 0;
LNode *l1, *l2;
// l1 表示较长的 l2 表示较短的
if(len1 > len2){
l1 = L1->next;
l2 = L2->next;
dis = len1 - len2;
} else {
l1 = L2->next;
l2 = L1->next;
dis = len2 - len1;
}
while(dis--) l1 = l1->next;
while(l1 != NULL){
if(l1 == l2) return l1;
else {
l1 = l1->next;
l2 = l2->next;
}
}
return NULL;
}
e.5
两个整数序列 A = a 1 , a 2 , . . . , a m A = a_1,a_2,...,a_m A=a1,a2,...,am, B = b 1 , b 2 , . . . , b n B = b_1,b_2,...,b_n B=b1,b2,...,bn分别存入两个单链表中,设计一个算法,判断序列B是否是序列A的连续子序列。
朴素算法。操作从两个链表第一个结点开始,若对应数据相等,则后移指针,若不等,则A链表从上次开始比较结点的后继开始,B链表仍从第一个结点开始比较。
B链表到尾则表示匹配成功,A到尾而B没到尾则表示失败。
可优化。
代码:
int Pattern(LinkList A, LinkList B) {
LNode *p = A, *q = B;
LNode *pre = p;
while(p && q){
if(p->data == q->data){
p = p->next;
q = q->next;
} else {
pre = pre->next;
p = pre;
q = B;
}
}
if(q == NULL) return 1;
return 0;
}
e.6
设计一个算法,判断一个链表是否有环,如果有,找出环的入口并返回,否则返回NULL。
设置快慢两个指针 fast 和 slow ,初始时都指向链表头head。
fast 每次走两步,slow每次走一步。fast 和 slow 指定会相遇。
接下来继续双指针法找到环的入口。
代码:
LNode* FindLoopStart(LinkList L){
LNode *fast, *slow;
fast = L;
slow = L;
while(fast != NULL) {
slow = slow->next;
fast = fast->next;
if(fast != NULL) fast = fast->next;
if(slow == fast) break;
}
if(slow == NULL || fast->next == NULL) return 0;
//没有环,走到了NULL
LNode *p1 = L;
LNode *p2 = slow;
while(p1 != p2){
p1 = p1->next;
p2 = p2->next;
}
return p1;
}
e.7
设线性表 L = ( a 1 , a 2 , . . . , a n − 1 , a n ) L = (a_1,a_2,...,a_{n-1},a_n) L=(a1,a2,...,an−1,an)采用带头节点的单链表保存,链表中结点定义如下
typedef struct node{
int data;
struct node *next;
}NODE;
设计一个算法,空间复杂度为 O ( 1 ) O(1) O(1),时间上尽可能高效,重新排列L中的各个结点,得到线性表 L ′ = ( a 1 , a n , a 2 , a n − 1 , a 3 , a n − 2 . . . ) L' = (a_1,a_n,a_2,a_{n-1},a_3,a_{n-2}...) L′=(a1,an,a2,an−1,a3,an−2...)。
双指针法,找到线性表的中间点。
后半段原地逆置。
前后两个半段依次选取节点,按要求重排。
代码:
void change_list(NODE* L){
NODE *p, *q;
p = L;
q = L;
while(q->next != NULL){
p = p->next;
q = q->next;
if(q != NULL) q = q->next;
}
q = p->next;
p->next = NULL;
NODE *r;
// 头插法逆置
while(q != NULL){
r = q->next;
q->next = p->next;
p->next = q;
q = r;
}
NODE *s = L->next;
q = p->next;
p->next = NULL;
while(q != NULL){
r = q->next;
q->next = s->next;
s->next = q;
s = q->next;
q = r;
}
}
2.4 顺序表和链表的比较
顺序表 | 链表 | |
---|---|---|
存取(读写方式) | 可以顺序存取,也可随机存取。 | 只能从表头开始顺序存取元素。 |
逻辑结构与物理结构 | 逻辑上相邻的元素对应的物理存储位置也相邻 | 逻辑上相邻元素物理存储位置不一定相邻;逻辑关系通过指针来表示 |
按值查找 | 无序 O ( n ) O(n) O(n);有序 O ( l o g 2 n ) O(log_2n) O(log2n) | O ( n ) O(n) O(n) |
按序号查找 | O ( 1 ) O(1) O(1) | O ( n ) O(n) O(n) |
插入、删除 | 需要移动元素 O ( n ) O(n) O(n) | 只需修改相关结点的指针域 O ( n ) O(n) O(n) |
空间分配 | 预分配过大导致大量空间闲置;过小会发生溢出。动态分配需要移动大量元素,导致操作效率变低。 | 只要有内存空间就可以分配,操作灵活、高效 |
如何选取存储结构
顺序表 | 链表 | |
---|---|---|
基于存储考虑 | 难以估计线性表长度或存储规模时 | |
基于运算考虑 | 常按序号访问元素、较稳定的线性表 | 常插入、删除操作(虽然也要找位置,但主要是基于比较操作) |
基于环境考虑 | 任何高级语言中均有数组类型 | 基于指针,实现较为复杂 |