圖說線性表-搞懂連結串列從這篇文章開始
上篇文章是資料結構的基礎部分,主要介紹了一些注意事項。
今天開始線性表部分的梳理,線性表主要分為了基礎概念和基本操作兩大部分,由於某些過程或概念比較抽象,我添加了部分圖示,希望能夠把這些抽象的東西直觀的表達出來。
基本操作模組重點主要在單鏈表和順序表兩部分,本文著重梳理了線性表插入、刪除、查詢等基礎方法並搭配了部分例項供參考。
1 基本概念
對於線性表來說,它是一組相同元素的有限序列,元素的個數就是線性表的長度,當元素個數為 0 時,線性表就是空表。
資料結構包括邏輯結構、儲存結構和演算法。線性表的基本概念這裡主要看線性表的邏輯結構和儲存結構就可以了。
1.1 線性表的邏輯結構
線性表的邏輯特性很好理解,由於是相同元素的有限序列,可以類比生活中的排隊場景:
-
只有一個表頭元素,表頭元素沒有前驅
-
只有一個表尾元素,表尾元素沒有後繼
-
除表頭表尾元素外,其他元素都只有一個前驅和一個後繼
1.2 線性表的儲存結構
線性表的儲存結構有兩類:順序表和連結串列。
-
順序表
將線性表的元素按照邏輯關係,儲存到指定位置開始的一塊連續的儲存空間。
特性:佔用一塊連續的儲存空間,隨機讀取,插入(刪除)時需要移動多個元素
-
連結串列
連結串列包含指標域與數值域兩部分,因此儲存不需要佔用連續空間,由指標來連線記錄結點位置資訊,通過前驅節點的指標找到後繼結點。
特性:動態分配空間,順序讀取,插入(刪除)時不需要移動元素。
連結串列的分類如下:
-
單鏈表
每個節點包含資料域與指標域,單鏈表分為帶頭節點的和不帶頭結點的。
-
帶頭結點的連結串列中,頭結點的值域不含任何儲存資料的資訊,從頭結點的下一個結點開始儲存資料資訊,頭結點的指標 head 始終不等於 NULL,當 head -> next 等於 NULL 時,此時連結串列為空
-
不帶頭結點的連結串列中,頭指標直接指向第一個結點,第一個結點就開始儲存資料資訊,當 head 等於 NULL 時連結串列為空。
注意區分頭結點和頭指標:
-
頭指標: 指向連結串列的第一個結點,無論帶不帶頭結點都有頭指標
-
頭結點:只有帶頭結點的連結串列才有,值域只存描述連結串列屬性的資訊,此時頭指標指向頭結點始終不為 NULL 。
-
雙鏈表
雙鏈表在單鏈表的基礎上新增一個指標域指向前驅結點,可以通過不同的指標域找到其前驅結點或後繼節點。
-
帶頭結點的雙鏈表,類似單鏈表,當 head -> next 為空連結串列為空
-
不帶頭結點的雙鏈表 當 head 為空時連結串列為空
-
迴圈單鏈表
在單鏈表的基礎上,將最後一個結點的指標域指向表頭結點即可。
-
帶頭結點的迴圈單鏈表,當 head 等於 head -> next 時 連結串列為空
-
不帶頭結點的迴圈單鏈表,當 head 為 空時連結串列為空
-
迴圈雙鏈表
在雙鏈表的基礎上,將最後一個結點的尾指標指向第一個結點,將第一個結點的頭指標指向最後一個結點。
-
不帶頭結點的迴圈雙鏈表 當 head 為空時 連結串列為空
-
帶頭結點迴圈雙鏈表 當 head -> next (尾指標) 和 head -> prior (頭指標) 任一一個等於 head 時 ,連結串列為空,事實上滿足以下任一條件,連結串列都為空:
head -> next = head head ->prior = head head -> next = head && head ->prior = head head -> next = head || head ->prior = head
-
靜態連結串列
靜態連結串列與一般連結串列不同,它一般來自於陣列,陣列中每個節點包含兩個分量,一個是資料元素,一個是指標分量。
連結串列分類可以理解成公路的分類,單鏈表像單行道,只能由表頭走向表尾;雙鏈表像雙行道可以從表頭走向表尾,也可以反過來;迴圈單鏈表像環形道,表頭表尾連結在一起;迴圈雙鏈表像環形立交橋,表頭表尾連線在一起,而且正向反向都可以。
1.3 順序表和連結串列的比較
順序表和連結串列的比較也算是面試中的經典題目了,這裡主要分為時間角度和空間角度進行對比:
-
時間角度-存取方式的區別
-
順序表支援隨機讀取(查詢快),時間複雜度為 O(1);
-
連結串列只能順序讀取(查詢慢),時間複雜度為 O(n)
-
時間角度-插入(刪除)時需要移動元素的個數區別
-
順序表需要平均需要移動近一般的元素,時間複雜度為 O(n),增刪慢
-
連結串列不需要移動元素,時間複雜度為 O(1),增刪快
-
空間角度-儲存分配方式的區別
-
順序表記憶體一次性分配完,佔用連續儲存空間
-
連結串列儲存空間需要多次分配,動態分配,來一個分配一個
-
空間角度-儲存密度區別 (儲存密度=結點值域所佔儲存量/結點結構所佔儲存量)
順序表儲存密度等於 1,連結串列儲存密度小於 1 (儲存指標)。
2 線性表的基本操作
操作模組主要為單鏈表和順序表兩部分,著重梳理它們插入、刪除、查詢等基礎方法。
2.1 結構體定義
-
順序表定義
#define maxSize 100; struct typedef { int data [maxSize];//定義順序表存放元素的資料 int length; //定義順序表的長度 }Sqlist; // 順序表型別定義
-
單鏈表定義
struct typedef ListNode { int data, // 值域 struct ListNode *next;//指標域 }ListNode; //定義連結串列的結點型別
-
雙鏈表定義
struct typedef DLNode { int data; //值域 struct DLNode *prior;//前驅結點指標 struct DLNode *next;//後繼結點指標 }DLNode;//定義雙鏈表結點型別
2.2 順序表的操作
操作部分就要結合例題來看了,順序表部分的操作類似 Java 中 陣列的操作十分類似。
-
順序表的插入操作
例1:已知一個順序表 L,其中元素遞增有序,設計一個演算法,插入一個元素 m (int 型),後保持該順序表仍然遞增有序排列。(假設每次插入都是成功的)
分析題目可以看出兩點: 1 原順序表 L 已經排序,遞增有序 2 插入 m 元素後仍然遞增有序,遞增排序不變 需要進行的步驟如下: 1 找出插入元素的位置 2 移動位置後面的元素 (從大下標的開始移動) 3 插入元素
程式碼:
/** * 查詢元素的方法 * l 順序表 * m 需要查詢的元素 */ int findElement(SqList l,int m) { int i; for(i=0;i<l.length;++i) { if(m < l.data[i]) { return i; // 找到第一個比 m 大的元素的位置返回 } } return i;//如果整個順序表都不大於m,則返回最後的位置 } /** * 新增元素的方法 * l 順序表 * m 需要新增的元素 */ void insertElement(SqList &l,int m) // 順序表本身需要發生變化所以傳入的是引用型 { int p,i; p = findElement(l,m); for(i=l.length-1;i>=p;--i) // 條件為 i>=p ,p位置的元素也需要移動 { l.data[i+1] = l.data[i];//從順序表的最後開始向右移動 } l.data[p] = m; ++(l.length); }
-
順序表的刪除操作
刪除操作與插入操作相反,刪除掉元素後,將後續元素都前移即可。
例2:刪除順序表L中下標為 p (0<=p<=l.length-1)的元素,成功返回 1,否則返回0,並將刪除的數值賦值給 e。
分析題目可知:
1 需要刪除的元素位置為 p
2 刪除元素前需要將值賦值給 e
需要進行的步驟如下:
1 找到需要刪除的元素的位置,題目已提供 p (如果沒有提供位置,需要迴圈查詢)
2 將刪除元素 p 賦值給元素 e
3 將P後的元素左移 (與插入不同,刪除要從小下標的開始移動)
程式碼:
/** * 刪除元素的方法 * l 順序表 * p 需要刪除元素的位置 * e 刪除元素賦值的變數 */ int deleteElement(SqList &l,int p,int &e)//需要改變的元素用引用變數 { int i; if( p < 0 || p > l.length -1) return 0; e = l.data[p]; for(i=p;i < l.length-1;++i){//判斷條件應為 i < l.length-1 ,如果為 i < l.length i+1 會下標越界 l.data[i] = l.data[i+1]; } --(l.length) return 1; }
2.3 單鏈表的操作
連結串列的相關操作是資料結構中比較常用的,這部分需要劃重點。
-
單鏈表的插入操作
單鏈表的插入主要有尾插法、頭插法兩種。
尾插法比較常規就是將新加的結點依次連結到連結串列最後一個結點。
尾插法: /** * C 準備要插入的連結串列 * a 陣列,要插入到連結串列中的元素 * n 將要插入的節點數 * * *&C 指標型變數在函式體中需要改變的寫法 * 順序表 &L ( 普通變數 &m )引用型變數需要改變的寫法 * */ void createListR(ListNode *&C,int a[],int n) // 要改變的變數傳引用型 { ListNode *s,*r; // 指標r 準備指向 C,s準備指向要插入的節點 int i; // 迴圈使用的變數 C = (ListNode*) malloc (sizeof(ListNode)); //申請 C 的頭結點空間 C -> next = NULL; // 申請頭結點空間時一定不要忘記將頭結點指標指向NULL r = C; //r 指向頭節點 for(i=0;i<n,++i) { s = (ListNode*)malloc(sizeof(ListNode));//s 指向新申請的節點 s -> data = a[i]; // 值域賦值 r->next = s; // 插入新的結點 r = r->next;// 指標移動到終端結點,準備在終端插入新結點 } r ->next = NULL;//插入完成後將 ,終端結點的指標域設定為NULL,C 建立完成 }
頭插法則是將新加的結點始終插入在頭結點的後面,因此越早插入的結點在連結串列中的位置實際上越靠後。
圖示:
頭插法: /** * C 準備要插入的連結串列 * a 陣列,要插入到連結串列中的元素 * n 將要插入的節點數 * * *&C 指標型變數在函式體中需要改變的寫法 * 順序表 &L ( 普通變數 &m )引用型變數需要改變的寫法 * */ void createlistF(ListNode *&C,int a[],int n) { ListNode *s; int i ; C = (ListNode *)malloc( sizeof(ListNode)); C -> next = NULL; for(i=0;i<n;++i) { s = (ListNode*)malloc(sizeof(ListNode)); s->data = a[i]; //頭插法 s->next = C->next;//圖中第二步 C->next = s;//圖中第三步 } }
-
單鏈表的刪除操作
連結串列的刪除操作就比較簡單了,要刪除第m個結點,需要找到第 m-1 個結點,將第 m-1個結點的指標指向 m+1 個結點就可以了。
相關操作:
q = p->next;//先將要刪除的結點賦值給q
p->next = p->next->next; //第二步操作
free(q);
-
單鏈表的查詢操作
例 3: 查詢連結串列 L(帶頭結點) 中是否有一個值為 m 的節點,如果有則刪除該節點,返回1,否則返回0.
/** * L 查詢的連結串列 * m 連結串列值域查詢的值 */ int deleteElement(ListNode *L,int m ) { ListNode *p,*q; // 定義一個指標 p,在連結串列中一直往下找 , q作為刪除節點的 p = L; while(p->next != NULL) { if(p->next->data == x){ // 注意此處是 p->next->data ==x,而不是 p->next == x break; } p = p -> next; } if(p -> next == NULL) { return 0; } else { q = p->next; // 要刪除的節點是 p->next ,q p->next = p->next->next; free(q); return 1; } }
-
單鏈表的合併操作
連結串列的基本的查詢 、插入、 刪除操作的重點部分已經回顧完了,下面來看看 leetCode 的例題---合併連結串列:
leetcode 21
題目如下:
將兩個升序連結串列合併為一個新的 升序 連結串列並返回。新連結串列是通過拼接給定的兩個連結串列的所有節點組成的。
示例:
輸入:1->2->4, 1->3->4 輸出:1->1->2->3->4->4
思路:
1 升序的兩個連結串列,合併成一個升序新連結串列
2 建立頭指標,使用尾插法迴圈比較 兩個連結串列的值,把值小的插入到頭結點後,移動指標
3 如果迴圈結束後某一個連結串列指標沒有移動到末尾,將新連結串列末尾指向這個指標的結點
圖解:
題解:
常規解法: /** * Definition for singly-linked list. * struct ListNode { * int val; * struct ListNode *next; * }; */ struct ListNode* mergeTwoLists(struct ListNode* l1, struct ListNode* l2){ struct ListNode *head = (struct ListNode*)malloc(sizeof(struct ListNode));//申請頭結點空間 struct ListNode *r = head;//定義移動指標 r ,r始終指向終端結點 while( l1 !=NULL && l2 != NULL){ if(l1 -> val <= l2 -> val){ r -> next = l1;//將 r->next指向 l1 l1 = l1->next; //l1 指標前移 r = r->next; //r 指標前移 }else{ r -> next = l2; l2=l2 -> next; r = r-> next; } } r->next = NULL; if(l1 != NULL){ // 如果迴圈插入結束後仍有剩餘結點,直接插入到末尾 r -> next = l1; } if(l2 != NULL){// 如果迴圈插入結束後仍有剩餘結點,直接插入到末尾 r -> next = l2; } return head ->next;//不用返回頭結點 }
上面的解法結果沒什麼問題,就是我們新建立了一個頭結點,如果置之不理的話,可能會導致記憶體洩漏。
下面是不建立頭結點的解法,只是再開始的時候巧妙的使用兩個連結串列中最小表頭為新連結串列的頭結點,後面操作類似
不申請頭結點解法: /** * Definition for singly-linked list. * struct ListNode { * int val; * struct ListNode *next; * }; */ struct ListNode* mergeTwoLists(struct ListNode* l1, struct ListNode* l2){ if(l1 == NULL) return l2; if(l2 == NULL) return l1; struct ListNode *head;//定義頭指標 if (l1->val < l2->val){ head = l1; //如果 l1 表頭元素值較小 ,將頭指標指向l1 l1 = l1->next;// l1 指標右移 }else{ head = l2; //如果 l2 表頭元素值較小 ,將頭指標指向l1 l2 = l2->next;//l2 指標右移 } struct ListNode *r = head; // l1,l2一直向後遍歷元素,向head中按序插入,直至l1或l2為NULL while(l1 && l2){ if(l1->val < l2->val){ r->next = l1; l1 = l1->next; r = r->next; }else{ r->next = l2; l2 = l2->next; r = r->next; } } // l1或l2為NULL,此時將不會空的連結串列接到最後即可 r->next = l1 ? l1 : l2; return head; }
以上不同的解法都是使用了連結串列的尾插法,因為尾插法正好符合題目的要求,新插入的結點也是依次遞增的。
如果題目要求變成要求 將兩個升序連結串列合併為一個新的 降序 連結串列並返回,這時使用頭插法就比較合適了。
合併為一個新的 降序 連結串列,頭插法: /** * Definition for singly-linked list. * struct ListNode { * int val; * struct ListNode *next; * }; */ struct ListNode* mergeTwoLists(struct ListNode* l1, struct ListNode* l2){ struct ListNode *head = (struct ListNode*)malloc(sizeof(struct ListNode));//申請頭結點空間 head ->next =NULL; struct ListNode *r;//定義移動指標 r ,r始終指向終端結點 while( l1 !=NULL && l2 != NULL){ if(l1 -> val <= l2 -> val){ r = l1; // r 指標指向 l1 結點 l1 = l1->next;//l1 結點右移 r->next = head -> next ;//r->next 指向頭結點的下一個結點,見頭插法圖 head ->next = r; // 將 r 賦值給頭結點的下一個結點 }else{ r = l2; l2 = l2->next; r->next = head->next; head->next = r; } } while(l1){ // 如果迴圈插入結束後仍有剩餘結點,迴圈插入到頭結點後 r = l1; l1 = l1->next; r->next = head -> next ; head ->next = r; } while(l2){// 如果迴圈插入結束後仍有剩餘結點,迴圈插入到頭結點後 r = l2; l2 = l2->next; r->next = head->next; head->next = r; } return head ->next;//不用返回頭結點 }
以上就是本文的所有內容了,最後的例題只是拋磚引玉,單鏈表的好多複雜的操作,有興趣的可以去找題刷刷~
最後,順序表和單鏈表的操作還是比較重要的,後續雙鏈表、迴圈連結串列的操作基本都是在單鏈表的基礎上演變而來的,搞懂以上基礎部分,其他的演變自然也就迎刃而解了。
PS: 關注“大資料江湖”公眾號, 後臺回覆 "連結串列合併",檢視更多精彩內容。
— THE END —