數據結構 : 線性表

一、線性表的定義

線性表:零個或多個數據元素的有限序列。

幾個關鍵的地方。
首先它是一個序列。也就是說,元素之間是有順序的,若元素存在多個,則第一個元素無前驅,最後一個元素無後繼,其他每個元素都只有一個前驅和後繼。

然後,線性表強調是有限的。事實上,在計算機中處理的對象都是有限的,那種無限的數列,只存在於數學的概念中。

如果用數學語言來定義。可如下:
若將線性表記爲(a1,…,ai-1,ai,ai+1,…,an),則表中ai-1領先於ai,ai領先於ai+1,稱ai-1是ai的直接前驅元素,ai+1是ai的直接後繼元素。當i = 1,2,…,n-1時,ai有且僅有一個直接後繼,當i = 2 , 3 , … , n時,ai有且僅有一個直接前驅。

所以線性表元素的個數n(n ≥ 0)定義爲線性表長度,當n=0時,稱爲空表。

在非空表中的每個數據元素都有一個確定的位置,如a1是第一個數據元素,an是最後一個數據元素,ai是第i個數據元素,稱i爲數據元素ai在線性表中的位序。

舉幾個例子,來判斷是否是線性表。
第一個:一年的星座列表,是不是線性表呢?

答:當然是,星座通常都是白羊座開頭,雙魚座收尾,當中的星座都有前驅後繼,而且一共才12個,所以完全符合線性表的定義。

第二個:公司的組織交媾,總經理管理幾個總監,每個總監管理幾個經理,每個經理管理各自的下述和員工。這樣的組織架構是不是線性關係呢?

答:不是,爲什麼不是呢?因爲每一個元素,都有不止一個後繼,所以它不是線性表。

第三個:班級同學的友誼關係,是不是線性表呢?

答:不是,因爲每個人都可以和多個同學建立友誼,不滿足線性的定義。

第四個:班級同學的點名冊,是不是線性表?是不是點名冊?

答:是,這和剛纔的友誼關係是完全不同的,因爲它是有限序列,也滿足類型相同特點,這個點名冊中,每個元素除學生的學號外,還可以有同學的姓名、性別、出生年月什麼的,這其實就是我們之前將的數據項。在較複雜的線性表中,一個數據元素可以由若干個數據項組成。

二、線性表的抽象數據類型

線性表的抽象數據類型定義如下:

ADT 線性表(List)
Data
    線性表的數據對象集合爲{a1,a2,....,an},每個元素的類型均爲DataType。其中除了,第一個元素a1外,每一個元素有且只有一個直接前驅元素,除最後一個元素an外,每一個元素有且只有一個直接後繼元素。數據元素之間的關係是一對一的關係。

Operation
    InitList(*L):初始化操作,建立一個空的線性表。
    ListEmpty(L):若線性表爲空,返回true,否則返回false。
    ClearList(*L):線性表清空。
    GetElem(L,i,*e):將線性表L中第i個位置元素返回給e。
    LocateElem(L,e):在線性表L中查找與給定值e相等的元素,如果
        查找成功,返回該元素在表中的序列號;否則,返回0表示失敗。
    ListInsert(*L,i,e):在線性表的第i個位置插入元素e。
    ListDelete(*L,i,*e):刪除線性表L中的第i個元素,並用e返回其值
    ListLength(L):返回線性表L的元素個數。
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

對於不同的應用,線性表的操作時不同的,上述操作時最基本的,問題中設計的關於線性表的更復雜操作,完全可以用這些基本操作的組合來實現。

比如,要實現兩個線性表集合A和B的並集操作。即要使得集合A = A ∪ B,說白了,就是把存在集合B中但並不存在中的數據元素插到A中即可。

仔細分析一下這個操作,發現我們只要循環集合B中的元素,判斷是否存在A中,若不存在,則插到A中即可。思路應該是很容易想到的。

假設我們La表示集合A,Lb表示集合B,則實現代碼如下:

//將所有的在線性表Lb中但不在La中的元素插入到La中
void unionL(List *La , List Lb)
{
    int La_len,Lb_len,i;
    ElemType e;
    La_len = ListLength(*La);
    Lb_len = ListLength(*Lb);
    for(i = 0 ;i ≤ Lb;i++)
    {
        GetElem(Lb,i,*e);//取出Lb中第i個數據元素賦給e
        if(!LocateElem(*La,e))//La中不存在和e元素相同的數據元素
        {
            ListInsert(La,++La_len,e);//插入
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

這裏我們對於union操作,用到了前面線性表基本操作ListLength、GetElem、LocateElem,ListLength等,可見,對於複雜的個性化的操作,其實就是把基本操作組合起來實現的。

三、線性表的順序存儲結構

1.順序存儲定義

說了這麼多的線性表,我們來看線性表的物理結構第一種——順序存儲結構。

線性表的順序存儲結構,指定的是用一段地址連續的存儲單元一次存儲線性表的數據元素。

2.順序存儲方式

線性表的順序存儲方式,說白了,就是在內存中找了一塊地方,把一定內存空間佔了,然後把相同數據類型的數據元素一次存在在裏面。既然線性表的數據元素的類型都相同,所以用C語言的一維數組來實現順序存儲結構,即把第一個數據元素存儲到數組下表爲0的位置中,接着把線性表相鄰的元素存儲在數組中相鄰的位置。

爲了建立一個線性表,要在內存中找一塊地,於是這塊地的第一個位置就非常關鍵,它是存儲空間的起始位置。

線性表中,我們估算這個線性表的最大存儲容量,建立一個數組,數組的長度就是最大存儲容量。

我們已經有了起始位置,也有了最大的容量,於是我們可以在裏面增加數據了。隨着數據的插入,我們線性表的長度開始變大,不過線性表的當前長度不能超過存儲容量,即數組的長度。

來看線性表的順序存儲的結構代碼。

 #define MAXSIZE 20 //存儲空間初始分配量
 typedef int ElemType;//ElemType根據實際情況而定,這裏假設爲int
 typedef struct
 {
     ElemType data[MAXSIZE];//數組存儲數據元素,最大值爲MAXSIZE
     int length;//線性表當前長度
 }SqList;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

這裏我們發現描述順序存儲結構需要三個屬性
存儲空間的起始位置:數組data,它的存儲位置就是存儲空間的存儲位置。

線性表的最大存儲容量:數組長度MaxSize。

線性表的當前長度:length。

3.數組長度與線性表長度區別

數組長度是存放線性表的存儲空間的長度,存儲空間分配完一般是不變的

線性表長度是線性表中元素數據的個數,隨着線性表插入和刪除操作的進行,這個量是變化的

在任意時刻,線性表的長度應該小於等於數組的長度。

4.地址計算方法

線性表的起始是從1開始的,可數組卻是從0開始第一個下標的,於是線性表中第i個元素,存儲在數組下標爲i - 1的位置。

用數組存儲順序表意味着要分配固定長度的數組空間,由於線性表中可以進行插入和刪除操作,因此分配的數組空間要大於等於當前線性表的長度。

由於每個數據元素,不管他是整型、實型還是字符型,它都是需要佔用一定的存儲空間的。假設佔用的是c個存儲單元,那麼線性表中第i + 1個元素的存儲位置和第i個數據元素的存儲位置滿足下列關係(LOC表示獲得存儲位置的函數)。

LOC(ai+1) = LOC(ai) + c
  • 1

所以對於第i個數據元素ai的存儲位置可以由a1推算得出:

LOC(ai) = LOC(ai) + (i - 1) * c
  • 1

通過這個公式,隨時可以算出線性表中任意位置的地址,不管他是第一個還是最後一個,都是相同的事件。那麼我們對每個線性表位置的存入或者取出數據,對於計算機來說都是相等的時間,也就是一個常數,因此我們算法中學到的時間複雜度的概念來說,它的存取時間的性能爲O(1)。我們通常把具有這一特點的存儲結構稱爲隨機存取結構。

四、順序存儲結構的插入與刪除

1.獲得元素操作

對於線性表的順序存儲結構來說,我們要實現GetElem操作,即將線性表L中的第i個位置元素返回,其實是非常簡單的。就程序而言,只要第i個元素在下標範圍內,就是把數組第i - 1下表值返回即可。
來看代碼:

#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
typedef int Status;
//Status是函數的類型,其值是函數結果狀態代碼,如OK等
//初始條件:順序線性表L已經存在,1 ≤ i ≤ ListLength(L)
//操作結果:用e返回L中第i個元素的值
Status GetElem(SqList L,int i,ElemType *e)
{
    if(L.length == 0 || i < 1 || i > L.length)
        return ERROR;
    *e = L.data[i - 1];
    return OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

注意這裏返回值類型Status是一個整型,返回OK代表1,ERROR代表0。

2.插入操作

剛纔我們也談到,這裏的時間複雜度爲O(1)。我們現在來考慮,如果我們要實現ListInsert(*L,i,e),即在線性表L中第i個位置插入新元素e,應該如何操作?

插入算法的思路

如果插入位置不合理,拋出異常

如果線性表長度大於等於數組長度,則拋出異常或動態增加容量

從最後一個元素開始向前遍歷到第i個元素,分別將它們都向後移一位

將要插入元素填入位置i處

表長加1
實現代碼如下:

//初始條件:順序線性表L已存在,1 ≤ i ≤ ListLength(L)
//操作結果:在L的第i個位置插入新的數據元素e,L的長度加1
Status ListInsert(SqList *L,int i,ElemType e)
{
    int k;
    if(L->length == MAXSIZE)//當線性表已滿
        return ERROR;
    if(i < 1 || i >L->length + 1)//當i不在範圍內時
    {
        return ERROR;
    }
    if(i <= L->length)//若插入數據位置不在表尾
    {
        for(k = L->length-1;k > i-1;k--)
        {
            L->data[k + 1] = L->data[k];
        }
    }
    L->data[i - 1] = e;//將新元素插入
    L->length++;
    return OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

3.刪除操作

刪除算法的思路:
如果刪除位置不合理,拋出異常

取出刪除元素

從刪除元素位置開始遍歷到最後一個元素位置,分別將它們向前移動一個位置

表長減1

實現代碼如下:

//初始條件:順序線性表L已經存在,1 <= i <= ListLength(L)
//操作結果:刪除L的第i個元素,並用e返回其值,L的長度減1
Status ListDelete(SqList *L ,int i , ElemType *e)
{
    int k;
    if(L->length == 0)//線性表爲空
        return ERROR;
    if(i < 1 || i > L->length)//刪除位置不正確
        return ERROR;
    *e = L->data[i];
    if(i < L->length)
    {
        for(k = i;k < L->length;k++)
            L->data[k - 1] = L->data[k];
    }
    L->length--;
    return OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

現在,我們來分析一下,插入和刪除的事件複雜度。
現在我們來看最好的情況,如果一個元素要插入到最後一個位置,或者刪除最後一個位置,此時時間複雜度爲O(1),因爲不需要移動元素的。

最壞的情況呢,如果元素要插入到第一個位置或者刪除第一個元素,此時時間複雜度是多少呢?那就意味着所有元素向後或者向前,所以這個時間複雜度爲O(n)

至於平均的情況,由於元素插入到第i個位置,或者刪除第i個元素,需要移動n - i個元素,每個位置插入或刪除元素的可能性是相同的,也就是位置靠前,移動元素多,位置靠後,移動元素少。最終平均移動次數和最中間那個元素的移動次數相等,爲(n - 1)/ 2。

根據時間複雜度的推導,平均時間複雜度還是O(n)。

這說明說明?線性表的順序存儲結構,在存、讀數據時,不管是哪個位置,時間複雜度都是O(1);而插入或刪除時,時間複雜度都是O(n)。這就說明,它比較適合元素個數不太變化,而更多是存取數據的應用

4線性表順序存儲結構的優缺點

優點
無須爲表中元素之間的邏輯關係而增加額外的存儲空間

可以快速地存取表中任一位置的元素

缺點
插入和刪除需要移動大量元素

當線性表長度變化較大時,難以確定存儲空間的容量

造成存儲空間的“碎片”

五、線性表的鏈式存儲結構

1.線性錶鏈式存儲結構定義

線性表的鏈式存儲結構的特點是用一組任意的存儲單元存儲線性表的數據元素,這組存儲單元可以是連續的,也可以是不連續的。這就意味着,這些元素可以存在內存未被佔用的任意位置。

以前在順序結構中,每個元素數據只需要存儲數據元素信息就可以了。現在在鏈式結構中,除了要存數據元素信息外,還要存儲它的後繼元素的存儲地址。

因此,爲了表示每個數據元素ai與其直接後級元素ai+1之間的邏輯關係,對數據元素ai來說,除了存儲其本身的信息之外,還需存儲一個指示其直接後繼的信息(即直接後繼的存儲位置)。我們把存儲數據元素信息的域稱爲數據域,把存儲直接後繼位置的域稱爲指針域。指針域中存儲的信息稱作指針或鏈。這兩部分信息組成數據元素ai的存儲映像,稱爲結點(Node)

n個結點(ai的存儲映像)鏈結成一個鏈表,即爲線性表(a1,a2,….,an)的鏈式存儲結構,因爲此鏈表的每個結點中只包含一個指針域,所以叫做單鏈表。單鏈表正是通過每個結點的指針域將線性表的數據元素按其邏輯次序鏈接在一起。

對於線性表來說,總得有個頭有個尾,鏈表也不例外。我們把鏈表中第一個結點的存儲位置叫做頭指針,那麼整個鏈表的存取就必須是從頭指針開始進行了。之後的每一個結點,其實就是上一個的後繼指針指向的位置。

最後一個,當然意味着直接後繼不存在了,所以我們規定,線性鏈表的最後一個結點指針爲“空”(通常用NULL或“^”符號表示)。

有時,我們爲了更加方便地對鏈表進行操作,會在單鏈表的第一個結點前附設一個結點,稱爲頭結點。頭結點的數據域可以不存儲任何信息,也可以存儲如線性表的長度等附加信息,頭結點的指針域存儲指向第一個結點的指針。

2.頭指針與頭結點的異同

頭指針與頭結點的異同點。
頭指針
① 頭指針是指鏈表指向第一個結點的指針,若鏈表有頭結點,則是指向頭結點的指針

②頭指針具有標識作用,所以常用頭指針冠以鏈表的名字

③無論鏈表是否爲空,頭指針均不爲空。頭指針是鏈表的必要元素。

頭結點
①頭結點是爲了操作的統一和方便而設立的,放在第一元素的結點之間,其數據域一般無意義。

②有了頭結點,對在第一元素結點前插入結點,其操作與其它結點的操作就統一了。

③頭結點不一定是鏈表必須要素。

3.線性鏈表式存儲結構代碼描述

若線性鏈表爲空表,則頭結點的指針域爲“空”。

單鏈表中,我們在C語言中可用結構指針來描述。

//線性表的單鏈表存儲結構
typedef struct Node
{
    ElemType data;
    struct Node *next;
}Node;
typedef struct Node *LinkList;//定義LinkList
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

在這個結構定義中,我們也就知道,結點由存放數據元素的數據域和存放後繼結點地址的指針域組成。 假設p是指向線性表第i個元素的指針,則該結點ai的數據域我們可以用p->data來表示,p->data的值是一個數據元素,結點ai的指針可以用p->next來表示,p->next的值是一個指針。p->next指向誰呢?當然是指向第i + 1個元素,即指向ai+1。也就是說p->data = ai,那麼p->next->data=ai+1

六、單鏈表的讀取

在線性表的順序存儲結構中,我們要計算任意一個元素的存儲位置使很容易的。但在單鏈表中,由於第i個元素到底在哪?沒辦法一開始就知道,必須從頭開始找。因此,對於單鏈表實現獲取第i個元素的操作GetElem,在算法上,相對麻煩一些。

獲得鏈表第i個數據的算法思路:
1. 聲明一個指針p指向鏈表第一個結點,初始化j從1開始。
2. 當j < i 時,就遍歷鏈表,讓p的指針向後移動,不斷指向下一結點,j累加1;
3. 若鏈表末尾p爲空,則說明第i個結點不存在;
4. 否則查找成功,返回結點p的數據。
實現代碼如下:

//初始條件:順序線性表L已存在,1 ≤ i ≤ ListLength(L)
//操作結果:用e返回L中第i個數據元素的值
Status GetElem(LinkList L,int i,ElemType *e)
{
    int j;
    LinkList p;//聲明一指針
    p = L->next;//讓p指向鏈表L的第一個結點
    j = 1;//j爲計數器
    while(p && j < i)//p不爲空且計數器j還沒有等於i時,循環繼續
    {
        p = p->next;//讓p指向下也結點
        ++j;
    }
    if(p || j > i)
        return ERROR;//第i個結點不存在
    *e = p->data;//取第i個結點的數據
    return OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

說白了,就是從頭開始找,直到第i個結點爲止。由於這個算法複雜度取決於i的位置,當i = 1時,不需要變量,而當i = n時則遍歷n - 1次纔可以。因此最壞情況的時間複雜度是O(n)。

由於單鏈表的結構沒有定義表長,所以不知道事先循環多少次,因此也就不方便使用for來控制循環。其主要核心思想是“工作指針後移”,這其實也是很多算法常用技術。

八、單鏈表的插入與刪除

1.單鏈表的插入

假設存儲元素e的結點爲s,要實現結點p、p->next和s之間的邏輯關係的變化,只需要將s插到結點p和p->next之間即可。
根本不需要驚動其他結點,只需要讓s->next和p->next的指針做一點改變。

//下面兩句不可交換順序
s->next = p->next;
p->next = s;
  • 1
  • 2
  • 3

單鏈表第i個數據插入結點的算法思路:
1. 聲明一指針p指向鏈表頭結點,初始化j從1開始;
2. 當j < i時,就遍歷鏈表,讓p的指針向後移動,不斷指向下一結點,j累加1
3. 若到鏈表末尾p爲空,則說明第i個結點不存在;
4. 若查找成功,在系統中生成一個空節點s;
5. 將數據元素e賦給s->data;
6. 單鏈表的插入標準語句s->next = p->next; p->next = s;
7. 返回成功

實現代碼算法如下:

//初始條件:順序線性表L已存在,1≤i≤ListLength(L)
//操作結果:在L中第i個結點位置之前插入新的數據元素,L的長度加1
Status ListInsert(LinkList *L , int i , ElemType e)
{
    int j = 1;
    LinkList p,s;
    p = *L;
    while( p && j < i) //尋找第i個結點
    {
        p = p->next;
        ++j;
    }
    if( !p || j > 1)
    {
        return ERROR;//第i個結點不存在
    }
    s = (LinkList)malloc(sizeof(Node));//生成新結點
    s->data = e;
    s->next = p->next;//將p的後繼結點賦值給s的後繼
    p->next = s;//將s賦給p的後繼
    return OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

在這段算法代碼中,我們用到了C語言的malloc標準函數,它的作用就是生成一個新的結點,其類型與Node是一樣的,其實質就是在內存中開闢了一段空間,用了存放數據e的s結點。

2.單鏈表的刪除

現在我們再來看單鏈表的刪除。設存儲元素ai的結點爲q,要實現將結點q刪除單鏈表的操作,其實就是將它的前繼結點的指針繞過,指向他的後繼結點即可。

我們所要做的,實際上就是一步,p->next = p->next->next;,用q來取代p->next即是:

q = p->next;
p->next = q->next;
  • 1
  • 2

也就是說把p的後繼結點改成p的後繼的後繼結點。

單鏈表第i個數據刪除結點的算法思路:
1. 聲明一指針p指向鏈表頭指針,初始化j從1開始;
2. 當j < i時,就遍歷鏈表,讓p的指針向後移動,不斷指向下一個結點,i累加1;
3. 若到鏈表末尾p爲空,則說明第i個結點不存在;
4. 否則查找成功,將欲刪除的結點p->next 賦給q;
5. 單鏈表的刪除標準與p->next = q->next;
6. 將q結點中的數據賦給e,作爲返回;
7. 釋放q結點
8. 返回成功

實現代碼算法如下:

//初始條件:順序線性表L已存在,1≤ i ≤ListLength(L)
//操作結果:刪除L的i個結點,並用e返回其值,L的長度減1
Status ListDelete(LinkList *L,int i,ElemType *e)
{
    int j;
    Link p,q;
    p = *L;
    j = 1;
    while(p->next && j < i)//遍歷尋找第i - 1個結點
    {
        p = p->next;
        ++j;
    }
    if( !(p->next) || j > i)
        return ERROR;//第i個結點不存在
    q = p->next;
    p->next = q->next;//將q的後繼賦給p的後繼
    *e = q->data;//將q結點中的數據給e
    free(q);//讓系統回收此結點,釋放內存
    return OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

分析一下剛纔我們講解的單鏈表插入和刪除算法,我們發現,它們其實都是由兩部分組成:第一部分就是遍歷查找第i個結點;第二部分就是插入和刪除結點。

從整個算法來說,我們很容易推導出:它們的時間複雜度都是O(n)。
如果我們不知道第i個結點的指針位置,單鏈表數據結構在插入和刪除操作上,與線下順序存儲結構是沒有太大優勢的。但如果,我們希望從第i個位置,插入10個結點,對於順序結構意味着,每次都要移動n - i個結點,每次都是O(n)。而單鏈表,我們只需在第一次時,找到第i個位置的指針,此時爲O(n),接下來只是簡單地通過賦值移動指針而已,事件複雜度爲O(1)。
顯然,對於插入和刪除數據越頻繁的操作,單鏈表的優勢就越明顯

八、單鏈表的整表創建

順序存儲結構的創建,其實就是一個數組的初始化,即聲明一個類型和大小的數組並賦值的過程。而單鏈表和順序存儲結構就不一樣,它不像順序存儲結構這麼幾種,它可以很散,是一種動態結構。對於每個鏈表來說,它所佔用空間的大小和位置使不需要預先分配劃定的,可以根據系統的情況和實際的需求即可生成。

所以創建單鏈表的過程就是一個動態生成鏈表的過程。即從“空表”的初始狀態起,一次建立各元素結點,並逐個插入鏈表。
單鏈表整表創建的思路算法:

  1. 聲明一指針p和計數器變量1;
  2. 初始化一空鏈表;
  3. 讓L的頭結點的指針指向NULL,即建立一個帶頭結點的單鏈表;
  4. 循環:

    生成一新結點賦值給p;
    隨機生成一數字賦給p的數據域p->data;
    將p插到頭結點與前一個新節點之間的位置。

實現代碼如下:

//隨機產生n個元素的值,建立帶表頭結點的單鏈表線性表L(頭插法)
void CreateListHead(LinkList *L,int n)
{
    LinkList p;
    int i;
    srand(time(0));//初始化隨機數種子
    *L = (LinkList)malloc(sizeof(Node));
    (*L)->next = NULL;//先建立一個帶頭結點的單鏈表
    for(i = 0;i < n;i++)
    {
        p = (LinkList)malloc(sizoef(Node));//生成新的結點
        p->data = rand() % 100 + 1;//隨機生成100以內的數字
        p->next = (*L)->next;
        (*L)->next = p; //插入到表頭
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

這段代碼裏,我們始終讓新結點在第一的位置上,我們把這種算法簡稱爲頭插法。

可事實上,我們還可以把新結點放在最後。這纔是排隊時的正常思維。我們每次新結點都插在終端結點的後面,這種算法稱之爲尾插。

實現代碼算法如下:

//隨機產生n個元素的值,建立帶表頭結點的單鏈線性表L(尾插法)
void CreateListTail(LinkList *L,int n)
{
    LinkList p,r;
    int i;
    srand(time(0));//初始化隨機數種子
    *L = (LinkList)malloc(sizeof(Node));//爲整個線性表
    r = *L;//r爲指向尾部的結點
    for(i = 0;i < n;i++)
    {
        p = (Node *)malloc(sizeof(Node));//生成新結點
        p->data = rand() % 100 + 1;//隨機生成100以內的數字
        r->next = p;//將表尾終端結點的指針指向新結點
        r = p; //就那個當前新結點定義爲表尾終端結點
    }
    r->next = NULL;//表示當前鏈表結束
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

注意L與r的關係,L指整個單鏈表,而r指向尾節點的變量,r會隨着循環不斷地變化結點,而L則是隨着循環增長爲一個多結點的鏈表。

這裏需要解釋一下,r->next = p的意思,其實就是將剛纔的表尾終端結點r的指針指向新結點p。

九、單鏈表的整表刪除

當我們不打算使用這個單鏈表時,我們需要把它銷燬,其實也就是在內存中將它釋放掉,以便於留出空間給其他程序或軟件使用。

單鏈表整表刪除的算法思路如下:
1. 聲明一結點p和q;
2. 將第一個結點賦值給p;
3. 循環
將下一結點賦值給q;
釋放p;
將q賦值給p。

實現代碼算法如下:

//初始條件:順序線性表L已經存在,操作結果:將L重置爲空表
Status ClearList(LinkList *L)
{
    LinkList p,q;
    p = (*L)->next;//p指向第一個結點
    while(p)//沒到表尾
    {
        q = p->next;
        free(p);
        p = q;
    }
    (*L)->next = NULL;//頭結點指針域爲空
    return OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

十、單鏈表結構與順序存儲結構優缺點

簡單地對單鏈表結構和順序存儲結構作對比。
1、存儲分配方式
順序存儲結構有一段連續的存儲單元依然存儲線性表的數據元素。
單鏈表採用鏈式存儲結構,用一組任意的存儲單元存放線性表的玩意。

2、時間性能
查找:
- 順序存儲結構O(1)
- 單鏈表O(n)

插入與刪除
- 順序存儲結構需要平均移動表長一半的元素,時間爲O(n)
- 單鏈表在線出某位置的指針後,插入和刪除時間僅爲O(1)

3、空間性能
- 順序存儲結構需要預分配存儲空間,分大了,浪費,分小了易發生上溢。
- 單鏈表不需要分配存儲空間,只要有就可以分配,元素個數也不受限制。

通過上面的對比,我們可以得出一些經驗性的結論:


若線性表需要頻繁查找,很少進入插入和刪除操作時,宜採用順序存儲結構
若需要頻繁插入和刪除時,宜採用單鏈表結構
比如遊戲開發中,對於用戶註冊的個人信息,除了註冊時插入數據外,絕大多數情況下都是讀取,所以應該考慮用順序存儲結構。而遊戲中的玩家的武器或者裝備列表,隨着玩家遊戲過程中,可能隨時增加或刪除,此時應該用單鏈表比較合適。當然,這只是簡單地類比。現實生活中的軟件開發,要考慮的問題會複雜得多。

當線性表中的元素個數變化較大或者根本不知道有多大時,最好用單鏈表結構,這樣可以不用考慮存儲空間大小問題
如果事先知道線性表的大致長度,比如一年12個月,這種用順序存儲結構效率會高很多

總之,線性表的順序存儲結構和單鏈表結構各有其優點,不是簡單地說哪個不好,需要根據實際情況,來綜合平衡採用哪種數據更能滿足和達到需求和性能。

十一、靜態鏈表

C語言具有指針能力,使得它可以非常容易地操作內存中的地址和數據,這比其他高級語言更加方便靈活。
後來的面嚮對象語言,如Java、C#等,雖不使用指針,但因爲啓用了對象引用機制,從某種角度上也間接實現了指針的某些作用。但對於一些語言,如Basic、Fortran等早期的編程高級語言,由於沒有指針,鏈表結構就沒辦法實現。

有人想出用數組來代替指針,來描述鏈表。

首先我們用數組的元素都是由兩個數據域組成,data和cur。也就是說,數組的每個下表都對應一個data和一個cur。數據域data,用來存放數據元素,也就是通常我們要處理的數據;而cur相當於單鏈表中的next指針,存放該元素後繼在數組中的下表,我們把cur叫做遊標。

我們把這種用數組描述的鏈表叫靜態鏈表這種描述方法還有起名叫做遊標實現法

爲了我們方便插入數據,我們通常會把數組建立得大一些,以便有一些空閒空間可以方便插入不至於溢出。

//線性表的靜態鏈表存儲結構
#define MAXSIZE 1000//假設鏈表的最大長度1000
typedef struct
{
    ElemType data;
    int cur;//遊標(Cursor),爲0時表示無指向
}Component,StaticLinkList[MAXSIZE];
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

另外我們對數組的第一個和最後一個元素作爲特殊元素處理,不存數據。我們通常把未被使用的數組元素稱爲備用鏈表。而數組第一個元素,即下標爲0的元素的cur就存放備用鏈表的第一個結點的下表;而數組的最後一個元素的cur則存放第一個有數值的元素的下表,相當於單鏈表中的頭結點作用,當整個鏈表爲空時,則爲0²。

//將一維數組space中個分量鏈成一備用鏈表
//space[0].cur爲頭指針,"0"表示空指針
Status InitList(StaticLinkList space)
{
    int i;
    for(i = 0;i < MAXSIZE - 1;i++)
        space[i].cur = i + 1;
    space[MAXSIZE - 1].cur = 0;//目前靜態鏈表爲空
    return OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

1.靜態鏈表的插入操作

靜態鏈表中要解決的是:如何用靜態模擬動態鏈表結構的存儲空間的分配,需要時申請,不需要時釋放。

我們前面說過,在動態鏈表中,結點的申請和釋放分別借用malloc()和free()兩個函數來實現。在靜態鏈表中,操作的是數組,不存在像動態鏈表一樣的申請和釋放問題,所以我們需要自己實現這兩個函數。

爲了辨明數組中哪些分量未被使用,解決的辦法是將所有未被使用過的以及已被刪除的分量用遊標鏈成一個備用的鏈表,每當進行插入時,便可從備用鏈表上取得第一個結點作爲待插入的新結點。

//若備用空間鏈表爲空,則返回分配的結點下表,否則返回0
int Malloc_SLL(StaticLinkList space)
{
    int i = space[0].cur;//當前數組的第一個元素的Cur存的值
    if(space[0].cur)
    {
        space[0].cur = space[i].cur;//由於要拿出一個分量來使用
                        //所以我們,就得把它的下一個分量用作備用
    }
    return i;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

插入元素

//在L中第i個元素之前插入新的數據元素e
Status ListInsert(StaticLinkList L, int i,ElemType e)
{
    int j,k,l;
    k = MAX_SIZE - 1;//注意k首先是最後一個元素的下表
    if(i < 1 || i > ListLength(L) + 1)
        return ERROR;
    j = Malloc_SSL(L);//獲得空閒分量的下標
    if(j)
    {
        L(j).data = e;//將數據賦值給此分量的下表
        for(l = 1;l <= i - 1;l++)//相當於循環鏈表,找到第i-1位
        {
            k = L[k].cur;
        }
        L[j].cur = L[k].cur;//新的第i個元素元素指向原本第i個元素
        L[k].cur = j;//第i - 1個元素指向新的第i個元素
        return OK;
    }
    return ERROR;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

就這樣,我們實現了在數組中,實現不移動元素,卻插入了數據的操作。

2.靜態鏈表的刪除操作

和前面一樣,刪除元素時,原來是需要釋放結點的函數free()。我們也要自己實現它。

//刪除在L中第i個數據元素e
Status ListDelete(StaticLinkList L,int i)
{
    int j , k;
    if(i < 1 || i > ListLength(L))
        return ERROR;
    k = MAX_SIZE - 1;
    for(j = 1;j < = i - 1;j++)//相當於遍歷鏈表
    {
        k = L[k].cur;
    }
    j = L[k].cur;//把要刪除的數組下標賦值給j
    Free_SLL(L,j);//調用刪除函數
    return OK;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
//將下表爲k的空閒結點回收到備用鏈表
void Free_SSL(StaticLinkList space,int k)
{
   space[k] = space[0].cur;//把原來第一位指向的下標賦給新第一位
   space[0].cur = k;//要刪除的分量賦給第一個元素cur
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

靜態鏈表也就是相應其他操作的相關實現。比如ListLength

//初始條件:靜態鏈表L已存在。操作結果:返回L中數據元素個數
int ListLength(StaticLinkList L)
{
    int j = 0;
    int i = L[MAXSIZE - 1].cur;
    while(i)
    {
        i = L[i].cur;
        j++;
    }
    return j;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

3.靜態鏈表優缺點

優點:在插入和刪除操作時,只要修改遊標,不需要移動元素,從而改進了在順序存儲結構中的插入和刪除操作需要移動大量元素的缺點。

缺點:
①沒有解決連續存儲分配帶來表長難以確定的問題
②失去了順序存儲結構隨機存儲的特性

十二、循環鏈表

對於單個鏈表,由於每個結點只存儲了向後的指針,到了尾標誌就停止了向後鏈的操作,這樣當中某一結點就無法找到它的前驅結點了。

將單鏈表中終端結點的指針由空指針改爲指向頭結點,就使整個單鏈表形成一個環, 這種頭尾相接的單鏈表稱爲單循環鏈表,簡稱循環鏈表

循環鏈表解決了一個很麻煩的問題,如何從當中一個結點出發,訪問到鏈表的全部結點。

循環鏈表和單鏈表的主要差異就是在於循環的判斷條件上,原來是判斷p->next是否爲空,現在則是p->next不等於頭結點,則循環未結束。

十三、雙向鏈表

在單鏈表中,有了next指針,這就使得我們要查找下一結點的事件複雜度爲O(1)。可是如果我們要查找的是上一節點的話,那最壞的時間複雜度就是O(n)了,因爲我們每次都要從頭開始遍歷尋找。

爲了克服單向性這一缺點,設計出了雙向鏈表。雙向鏈表(double linked list)是在單鏈表的每個結點中,再設置一個指向其前驅結點的指針域。所以在雙向鏈表中的結點都有兩個指針域,一個指向直接後繼,一個指向直接前驅。

//線性表的雙向鏈表存儲結構
typedef struct DulNode
{
    ElemType data;
    struct DuLNode *prior;//直接前驅指針
    struct DuLNode *next;//直接後繼指針
}DulNode,*DuLinkList;
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

既然單鏈表可以有循環,那麼雙向鏈表當然可以是循環表。

由於是雙向鏈表,對於鏈表中某一結點p,它的後繼的前驅是它自己。它的前驅的後繼自然也是它自己。即:

p->next->prior = p = p->prior->next
  • 1

插入操作時,其實並不複雜,但是順序很重要。
假設存儲元素e的結點爲s,要實現將結點s插入到p和p->next之間需要下面幾部。

s->prior = p;//把p賦給s的前驅
s->next = p->next;//把p->next賦給s的後繼
p->next->prior = s;//把s賦給p->next的前驅
p->next = s;//把s賦給p的後繼
  • 1
  • 2
  • 3
  • 4

如要刪除結點p,只要下面兩步驟。

p->prior->next = p->next;//把p->next賦給p->prior的後繼
p->next->prior = p->prior;//把p->proir賦給p->next的前驅
free(p);//釋放p的空間
  • 1
  • 2
  • 3

雙向鏈表對於單鏈表來說,要更復雜一些,對於插入和刪除時,需要小心。
另外由於它每個結點需要幾輪兩份指針,所以在空間上是要佔用略多一些的。不過由於良好的對稱性,使得對某個結點的前後結點的操作,帶來了方便,可以有效提高算法的時間性能

說白了,也就是空間換時間

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章