數據結構-線性表
2.1 線性表的定義和基本運算
2.1.1 線性表的邏輯定義
線性表(Linear List)是最簡單和最常用的一種數據結構,它是由 \(n\) 個數據元素(節點)\(a_{1},a_{2},...,a_{n}\) 組成的有限序列。其中元素的個數 n 為表的長度。當
n = 0
時,稱為空表,非空的線性表記為:
\( (a_{1},a_{2},...,a_{i-1},a_{i},a_{i+1},...,a_{n}) \)
線性表邏輯特征:
- 有且僅有一個稱為開始元素的 \(a_{1}\), 它沒有前趨,僅有一個直接后繼 \(a_{2}\);
- 有且僅有一個稱為終端元素的 \(a_{n}\), 它沒有后繼,僅有一個直接前趨;
- 其余元素 \(a_{i}(2<=i<=n-1)\) 稱為內部元素,它們都有且僅有一個直接前趨 \(a_{i-1}\) 和一個直接后繼 \(a_{i+1}\)
2.1.2 線性表的基本運算
1. 置空表 InitList(L) : 構造一個空的線性表
2. 求表長 ListLength(L) : 返回元素個數
3. 取表中的第 i 個元素 GetNode(L,i):返回第 i 個元素
4. 按值查找 LocateNode(L,x):按值查找,不存在返回0
5. 插入 InsertList(L,i,x):在第 i 個元素之前插入一個新元素
6. 刪除 DeleteList(L,i): 刪除表中第 i 個元素
2.2 線性表的順序存儲和基本運算的實現
2.2.1 線性表的順序存儲
線性表的順序存儲指的是將線性表的數據元素按其邏輯次序依次存入一組地址連續的存儲單元里,用這種方法存儲的線性表稱為順序表。
假設線性表中所有元素的類型相同,且每個元素需占用 d 個存儲單元,其中第一個單元的存儲位置(地址)就是該元素的存儲位置。那么,線性表中的第 i+1 個元素的存儲位置 \(LOC(a_i+1)\) 和第 i 個元素的存儲位置 \(LOC(a_i)\) 有關系:
一般來說,線性表的第 i 個元素 \(a_i\) 的存儲位置為:
其中,\(LOC(a_i)\) 是線性表的第 i 個元素的 \(a_i\) 的存儲位置,通常稱為基地址。
線性表的這種機內表示稱為線性表的順序存儲結構。它的特點是,元素在表中的相鄰關系,在計算機內也存在着相鄰關系。每個元素 \(a_i\) 的存儲地址是該元素在表中的位置 i 的線性函數,只要知道基地址和每個元素占用的單元數(元素的大小),就可求出任意元素的存儲地址。因此只要確定了線性表存儲的起始位置,線性表中任意一個元素都可隨機存取,所以順序表是一種隨機存取結構。
由於高級程序設計語言中的數組類型具有隨機存取的特性,因此,通常用數組來表述順序表。另外,除了存儲線性表的節點外,還需要一個變量來標識線性表的當前長度,所以用下面的結構類型來定義順序表類型:
#define ListSize 100
typedef int DateType;
typedef struct {
DateType data[ListSize];
int length;
}SeqList;
順序表在內存中的存儲表示如下圖,圖中存儲地址 b 為基地址\(LOC(a_1)\)。因為 C 語言的數組下標從 0 開始,因此使用時要特別注意。假設 L 是 SeqList 類型的順序表,則開始節點 \(a_1\) 存放在 L.data[0] 中,終端節點 \(a_n\) 存放在 L.data[n-1] 中。同理,若 p 為一個指定順序表的指針變量,則 \(a_1\) 和 \(a_n\) 分別存放在 p->data[0] 和 p->data[n-1] 中。
2.2.2 順序表上的基本運算的實現
隨機存取第 i 個節點,使用 L.data[i-1]
1. 插入運算
void InsertList(SeqList *L, int i, DataType x) {
//1. 首先判斷 i 節點是否在實際范圍內
if (i < 1) {
printf("i 節點非法: i = %d\n", i);
return;
}
//2. 判斷 i 添加之后是否在實際范圍內
if (i > L->length + 1) {
printf("overflow: i 節點大於數組實際容量\n");
return;
}
//3. 判斷數組實際長度是否大於數組容量
if (L->length >= ListSize) {
printf("數組實際容量超過或等於數組存儲容量\n");
return;
}
//4. 循環后移
for (int j = L->length - 1; j >= i - 1; j--) {
L->data[j + 1] = L->data[j];
}
//5. 添加元素
L->data[i - 1] = x;
L->length++;
}
2. 刪除運算
DataType DeleteList(SeqList *L, int i) {
int j;
DataType x;
if (i < 1) {
printf("index overflow!!!");
exit(0);
}
if (i >= L->length) {
printf("overflow!!!");
exit(0);
}
x = L->data[i];
for (int j = i; j <= L->length; ++j) {
L->data[j-1] = L->data[j];
}
L->length--;
return x;
}
3. 順序表上的其他運算舉例
轉置:
SeqList Converts(SeqList L){
DataType x;
int i,k;
k = L.length/2;
for (int i = 0; i < k; ++i) {
x = L.data[i];
L.data[i] = L.data[L.length-i-1];
L.data[L.length-i-1] = x;
}
return L;
}
最大值&最小值
void MaxMin(SeqList L,DataType *max,DataType *min,int *k,int *j){
int i;
*max = L.data[0];
*min = L.data[0];
*k = *j = 1;
for (int i = 1; i < L.length; ++i) {
if(L.data[i]>*max){
*max = L.data[i];
*k = i;
} else if(L.data[i]<*min){
*min = L.data[i];
*j = i;
}
}
}
2.3 線性表的鏈式存儲結構
線性表順序存儲結構的特點是,在邏輯關系上相鄰的兩個元素在物理位置上也是相鄰的,因此可以隨機存取表中的任一元素。But
,當經常需要插入和刪除操作運算時,則需要移動大量的元素,而采用鏈式存儲結構時就可以避免這些移動。然而,由於鏈式存儲結構存儲線性表數據元素的存儲空間可能是連續的,也可能是不連續的,因而鏈表的節點是不可以隨機存取的。鏈式存儲是最常用的存儲方式之一,不僅可以用來表示線性表,而且還可以是用來表示各種非線性的數據結構,在以后的文章中我們將反復使用這種存儲方式。
2.3.1 單鏈表(線性鏈表)
在使用鏈式存儲結構表示每個數據元素 \(a_i\) 時,除了存儲 \(a_i\) 本身的信息之外,還需要一個存儲指示其后繼元素 \(a_{i+1}\) 存儲位置的指針,由這兩部分組成元素 \(a_i\) 的存儲映像稱為結點
。它包括兩個域(字段):存儲數據元素的域稱為數據域,存儲直接后繼存儲地址的域稱為指針域。利用這種存儲方式表示的線性表稱為鏈表,鏈表中一個節點的存儲結構為:
n 個節點鏈成一個鏈表,即為線性表 \((a_1,a_2,...,a_n)\) 的鏈式存儲結構。由於這種鏈表的每個節點只包含一個指針域,又稱單鏈表,抽象表示如下圖:
顯然,單鏈表中每個節點的存儲地址時存放在其直接前趨節點的指針域(*next)中,而開始節點無直接前趨,因此設立指針 head 指向開始系欸但。又由於終端節點無后繼節結點,所以終端節點的指針域為空,即NULL。如果鏈表中一個節點也沒有,則為空鏈表,這是 head = NULL.
由此可見,一個單鏈表可由頭指針唯一確定,因此單鏈表可用頭指針的名字來命名。
typedef int DataType; // 元素類型
typedef struct node { // 節點類型定義
DataType data; // 節點數據字段
struct node *next; // 節點指針字段
} ListNode;
// 定義別名類型: linklist指針
typedef ListNode *LinkList;
ListNode *p;// 定義節點指針變量
LinkList head;// 定義鏈表頭指針變量
注意,這里的 LinkList 和 ListNode * 是不同名字的同一指針類型,取名的不同是為了在概念上更明確。特別值得注意的是指針變量和指針指向的變量(結點變量)這兩個概念。指針變量的值要么為空(NULL),不指向任何節點;要么非空,即它的值是一個節點的存儲地址。指針變量所指向的節點地址並沒有具體說明,而是在程序的執行過程中需要存放結點時才產生,是通過 C 語言的標磚函數 malloc()
實現的。例如,給指針變量 p 分配一個節點的地址: p = (ListNode * )malloc(sizeof(ListNode));
該語句的功能是申請分配一個類型為 ListNode 的結點的地址空間,並將其首地址存入指針變量 p 中。當結點不需要時可以用標磚函數 free(p)
釋放結點存儲空間。
鏈表中的結點變量是通過指針變量來訪問的。因為在 C 語言中使用 p->
來表示 p 所指向的變量的,又由於結點類型是一個結構類型,因此可用 p->data
和 p->next
分別表示結點的數據域變量和指針域變量。注意,當 p 值為空值時,則不指向任何結點,此時不能通過 p 來訪問結點,否則會引起程序錯誤。
2.3.2 單鏈表上的基本運算
1. 建立單鏈表
動態建立單鏈表的常用方法有兩種:頭插法和尾插法
1.1 頭插法
頭插法建表是從一個空表開始,重復讀入數據,生成新結點,將讀入的數據存放到新節點的數據域中,然后將新節點插入到當前鏈表的表頭上,知道讀入結束標志為止。
假設線性表中結點的數據域為字符型,具體算法如下:
LinkList CreateListF() {
LinkList head;
ListNode *p;
char ch;
head = NULL;
ch = getchar();
while (ch!='\n'){
p = (ListNode*)malloc(sizeof(ListNode));
p->data = ch;
p->next = head;
head = p;
ch = getchar();
}
return head;
}
例如,在空鏈表 head 中依次插入數據域分別為 a、b、c 的結點之后,將 x 為數據域的新結點 p 插入到當前鏈表表頭,其指針的修改變化如下圖:
1.2 尾插法
頭插法建立鏈表是將新結點插入在表頭,算法比較簡單,但新建鏈表中結點的次序是和輸入時的順序相反,理解是不太直觀。若需要和輸入次序一致,則可使用尾插法建立鏈表。該方法是將新節點插入在當前鏈表的表尾上,因此需要增設一個尾指針 rear,使其始終指向鏈表的尾結點。例如在 head 中依次插入 a、b、c 的結點之后,將 x 為數據域的新結點 p 插入到當前鏈表表尾,其指針的修改變化情況如下圖:
假設線性表中結點的數據域為字符型,具體算法如下:
LinkList CreateListR() {
LinkList head, rear;
head = rear = NULL;
ListNode *p;
char ch;
ch = getchar();
while (ch != '\n') {
p = (ListNode *) malloc(sizeof(ListNode));
p->data = ch;
if (head == NULL) {
head = p;
} else {
rear->next = p;
}
rear = p;
ch = getchar();
}
if (rear->next != NULL) {
rear->next = NULL;
}
return head;
}
為了簡化算法,方便操作,可在鏈表的開始結點之前附加一個結點,並稱其為頭節點。帶頭節點的單鏈表結構如下:
在引入頭結點后,尾插法建立單鏈表的算法可簡化為:
LinkList CreatListR1() {
LinkList head = (ListNode *) malloc(sizeof(ListNode));
ListNode *p, *r;
r = head;
DataType ch;
while ((ch = getchar()) != '\n') {
p = (ListNode *) malloc(sizeof(ListNode));
p->data = ch;
r->next = p;
r = p;
}
r->next = NULL;
return head;
}
2. 查找運算(帶頭節點)
在單鏈表中,任何兩個結點的存儲位置之間沒有固定的聯系,每個結點的存儲位置,包含在其前趨的指針域中。因此,在單鏈表中存儲第 i 個結點時,必須從表頭結點開始搜索,所以鏈表結構是非隨機存取的存儲結構。若鏈表帶頭結點時,就應特別注意頭結點和表頭結點(即開始結點)的區別。
1. 按結點序號查找
在單鏈表中要查找第 i 個結點時,必須從鏈表的第 1 個結點(開始結點:序號為1)開始,序號為 0 的是頭結點,p 指向當前結點,j 為計數器,其初始值為 1,當 p 掃描下一個結點時,計數器加1.當 i = j 時,指針 p 所指向的結點就是要找的結點。
算法如下:
ListNode *GetNodei(LinkList head, int i) {
ListNode *p;
int j;
p = head->next;
j = 1;
while (p != NULL && j < i) {
p = p->next;
++j;
}
if (j == i) {
return p;
}
return NULL;
}
2. 按結點值查找
在單鏈表中按值查找結點,就是從鏈表的開始結點出發,順鏈逐個將結點的值和給定值 k 進行比較,若遇到相等的值,則返回該結點的存儲位置,否則返回NULL。
ListNode *LocateNodek(LinkList head, DataType k) {
ListNode *p = head->next;
while (p && p->data != k) {
p = p->next;
}
return p;
}
3. 插入運算
從前面順序表的插入運算可知,插入運算是將值為 x 的新結點插入到表的第 i 個結點的位置上,即插入到 \(a_{i-1}\) 與 \(a_i\) 之間。然而,鏈表和順序表的插入運算是不同的,順序表在插入時要移動大量的結點,而鏈表在插入時不需要移動結點,但需要移動指針來進行位置查找。
其算法思想:先使 p 指向 \(a_{i-1}\) 的位置,然后生成一個數據域指為 x 的新節點 *s,再進行插入操作,插入過程如下:
具體算法如下:
void InsertList(LinkList head, int i, DataType x) {
ListNode *p, *s;
int j;
p = head;
j = 0;
while (p && j < i - 1) {
p = p->next;
++j;
}
if (p == NULL) {
printf("ERROR\n");
return;
} else {
s = (ListNode*) malloc(sizeof(ListNode));
s->data = x;
s->next = p->next;
p->next = s;
}
}
4. 刪除運算
刪除運算就是將鏈表的第 i 個結點從表中刪去。由於第 i 個結點的存儲地址是存儲第 \(i-1\) 個結點的指針域 next 中,因此要先使 p 指向第 \(i-1\) 個結點,然后使得 p-> next 指向第 i+1 個結點,再將第 i 個結點釋放掉。操作過程如下:
具體算法如下:
DataType DeleteList(LinkList head, int i) {
ListNode *p, *s;
DataType x;
int j;
p = head;
j = 0;
while (p && j < i - 1) {
p = p->next;
++j;
}
if(p==NULL){
printf("位置錯誤\n");
exit(0);
} else{
s = p->next;
p->next = s->next;
x = s->data;
free(s);
return x;
}
}
5. 單鏈表上運算舉例
例1:將一個頭結點指針為 a 的帶頭結點的單鏈表 A 分解稱兩個單鏈表 A 和 B,其中頭結點分別為 a 和 b,使得 A 鏈表中含有鏈表 A 中序號奇數的元素,而 B 鏈表中含有原鏈表中序號為偶數的元素,並保持原來的相對順序。
void split(LinkList a, LinkList b) {
ListNode *p, *r, *s;
p = a->next;
r = a;
s = b;
while (p) {
r->next = p;
r = p;
p = p->next;
if (p) {
s->next = p;
s = p;
p = p->next;
}
}
r->next = s->next = NULL;
}
例2: 假設頭指針為 La 和 Lb 的單鏈表(帶頭結點)分別為線性表 A 和 B 的存儲結構,兩個鏈表都是按結點數據值遞增有序的。試寫一個算法,將這兩個單鏈表合並為一個有序鏈表 Lc。
LinkList MergerList(LinkList La, LinkList Lb) {
ListNode *pa, *pb, *pc;
LinkList Lc;
pa = La->next;
pb = Lb->next;
Lc = pc = La;
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!=NULL?pa:pb;
free(Lb);
return Lc;
}
2.3.3 循環鏈表
循環鏈表是鏈式存儲結構的另一種形式。其特定是單鏈表的最后一個結點(終端結點)的指針域不為空,而是指向鏈表的頭節點,使整個鏈表構成一個環。因此,從表中任一結點開始都可以訪問表中其他結點。這種結構形式的鏈表稱為單循環鏈表。類似的還可以有多重鏈的循環鏈表。
再單循環鏈表中,為了使空表和非空表的處理一致,需要設置頭結點。非空的單循環鏈表如下:
循環鏈表的結點類型與單鏈表完全相同,在操作上也與單鏈表基本一致,差別僅在算法中循環的結束判斷條件不再是 p 或者 p->next 是否為空,而是它們是否等於頭部指針。
在用頭指針表示的單循環鏈表中,查找任何結點都必須從開始結點查起,而在實際應用中,表的操作常常會在表尾進行,此時若用尾指針表示單循環鏈表,可使某些操作簡化。僅設置尾指針的單循環鏈表如下:
例1:已知有一個結點數據域為整型,且按從大到小順序排列的頭結點指針為 L 的非空單循環鏈表,試寫一個算法插入一個結點(其數據域為x)至循環鏈表的適當位置,使之保持鏈表的有序性。
void InsertCycleList(LinkList L, int x) {
ListNode *s, *p, *q;
s = (ListNode *) malloc(sizeof(ListNode));
s->data = x;
p = L;
q = p->next;
while (q->data > x && q != L) {
p = p->next;
q = p->next;
}
p->next = s;
s->next = q;
}
2.3.4 雙向鏈表
以上討論的單鏈表和單循環鏈表的結點只設有一個其直接后繼的指針域,因此,從某個結點觸發只能順指針相后訪問其他接待你。若需要查找結點的直接前趨,則需要從頭指針開始查找某結點的直接前趨結點。若希望從表中快速確定一個結點的直接前趨,只要在單鏈表的結點類型中增加一個指向其直接前趨的指針域 prior 即可。這樣形成的鏈表中有兩條不同方向的鏈,因此稱之為雙向鏈表。雙向鏈表及其結點類型描述如下:
typedef struct dlnode {
DataType data;
struct dlnode *prior, *next;
} DLNode;
typedef DLNode *DLinkList;
同單鏈表類似,雙向鏈表一般也由指針 head 唯一確定。為了某些操作運算的方便,雙鏈表也增設一個頭結點,若將尾結點和頭結點鏈接起來也就構成循環鏈表。因此,我們所討論的往往是這種雙向循環鏈表,其結點結構、空雙向鏈表和非空雙向鏈表如下圖:
在單鏈表的給定結點前插入一個新結點,必須使用一指針指向給定結點的直接前趨,由於單鏈表是單向的,因此需要從表頭開始向后搜索鏈表才能找到已知結點的直接前趨。同理,在單鏈表上刪除給定結點的運算也同樣存在這樣的問題。而雙向鏈表既有向前鏈接的鏈,又有向后鏈接的鏈,所以在做鏈表上結點的插入和刪除操作時就顯得十分方便。
例如,在雙向鏈表的給定結點前插入一界定的操作過程如下圖:
實現算法如下:
void DLInsert(DLNode *p,DataType x){
DLNode *s = (DLNode*) malloc(sizeof(DLNode));
s->data = x;
s->prior = p->prior;
s->next = p->next;
p->prior->next = s;
p->prior =s;
}
因為不再需要查找指向刪除結點的前趨結點的指針,所以在雙向鏈表上刪除 *p 的算法更簡單,其刪除操作過程如下圖:
實現算法如下:
DataType DLDelete(DLNode *p){
DataType x;
p->prior->next = p->next;
p->next->prior = p->prior;
x = p->data;
free(p);
return x;
}
注意:與單鏈表的插入和刪除操作不同的是,在雙向鏈表中進行結點插入和刪除時,必須同時修改兩個方向上的指針。
例:[單項循環鏈表改造為雙向循環鏈表]
假設有一個頭結點指針為 head 的循環雙向鏈表,其結點類型包括三個域(字段):prior\data\next 其中,data 為數據域,next為指針域,指向其后繼結點,prior 也為指針域,其值為空(NULL),因此該雙向鏈表其實就是一個單循環鏈表。試寫一算法,將其實現真正的栓修改那個循環鏈表。
void Trans(DLinkList head) {
DLNode *p;
p = head;
while (p->next != head) {
p->next->prior = p;
p = p->next;
}
head->prior = p;
}
2.4 順序表和鏈表的比較
線性表有兩種存儲結構:
- 順序存儲結構(順序表)
- 鏈式存儲結構(鏈表)
順序表結構:可以隨機存取表中存取,插入和刪除操作時,需要移動大量元素。
鏈式存儲結構:無法隨機存取,解決了插入和刪除需要移動大量元素的問題。
以上兩種結構,各有特點,那么我們該如何選擇呢?通常我們要根據實際問題進行決定。
1. 時間性能
如果在實際問題中,對線性表的操作時經常性的查找運算,順序表形式存儲優先。
若需要經常性的插入和刪除元素,以鏈式存儲結構優先。
2. 空間性能
對數據量大小能事先知道的應用,適合使用順序存儲結構。
對數據量變化較大的應用,以鏈式存儲結構優先。
對於線性表結點的存儲密度問題,也是選擇存儲結構的一個重要依據。所謂存儲密度就是結點空間利用率:
結點存儲密度越大,存儲空間的利用率就越高。
順序表的存儲密度是 1,鏈表結點的存儲密度小於 1.