定義:用一組任意的存儲單元存儲線性表的數(shù)據(jù)元素,這組存儲單元可以存在內(nèi)存中未被占用的任意位置。
在鏈?zhǔn)酱鎯Y(jié)構(gòu)中,每個數(shù)據(jù)元素除了要存儲數(shù)據(jù)元素信息外,還要存儲它的后繼元素的存儲地址(指針).我們把存儲數(shù)據(jù)元素信息的域稱為數(shù)據(jù)域,把存儲直接后繼位置的域稱為指針域。指針域中存儲的信息稱為指針或鏈。這兩部分信息組成數(shù)據(jù)元素稱為存儲印象,稱為結(jié)點(Node)。
n個結(jié)點鏈接成一個鏈表,即為線性表(a1,a2,a3,…,an)的鏈?zhǔn)酱鎯Y(jié)構(gòu)
因為此鏈表的每個結(jié)點中只包含一個指針域,所以叫做單鏈表
我們把鏈表中的開始結(jié)點的存儲位置叫做頭指針,最后一個結(jié)點的指針為空(NULL)
1.頭指針、頭結(jié)點、開始結(jié)點
開始結(jié)點:指鏈表中的第一個結(jié)點,它沒有直接前驅(qū)
頭指針:指向開始結(jié)點的指針(沒有頭結(jié)點的情況下,若鏈表有頭結(jié)點,則是指向頭結(jié)點的指針),一個單鏈表可以由其頭指針唯一確定,一般用其頭指針來命名單鏈表,無論鏈表是否為空,頭指針均不為空,頭指針是鏈表的必要元素
頭結(jié)點:頭結(jié)點是為了操作的統(tǒng)一和方便而設(shè)立的,放在第一個元素的結(jié)點之前,其數(shù)據(jù)域一般無意義(但也可以用來存放鏈表的長度),有了頭結(jié)點,對在第一元素節(jié)點前插入節(jié)點和刪除第一節(jié)點起操作與其它節(jié)點的操作就統(tǒng)一了,頭結(jié)點不一定是鏈表的必須元素
2.單鏈表的定義
//結(jié)點的定義
typedef struct Node{
ElemType data;//數(shù)據(jù)域
struct Node *next;//指針域
}Node;
typedef struct Node *LinkList;
我們可以看到節(jié)點由存放數(shù)據(jù)元素的數(shù)據(jù)域和存放后繼結(jié)點地址的指針域組成
問題:
如果p->data = ai,那么p->next->data = ?
答案: p->next->data = ai+1
注意:下方的所有算法都是基于該單鏈表存在頭結(jié)點
3.單鏈表的讀取
獲取鏈表第i個數(shù)據(jù)的算法思路:
- 聲明一個結(jié)點p指向鏈表第一個結(jié)點,初始化j從1開始
- 當(dāng)j<i時,就遍歷鏈表,讓p的指針向后移動,不斷指向下一個結(jié)點,j++
- 若到鏈表末尾p為空,則說明第i個元素不存在
- 否則查找成功,返回結(jié)點p的數(shù)據(jù)
/**
使用頭指針表示單鏈表 LinkList L表示頭指針,頭指針指向頭結(jié)點
*/
Status GetElem(LinkList L,int i,ElemType *e){
LinkList p = L->next;
int j = 1;
while (p && j < i) {
p = p->next;
j++;
}
if (!p || j > i) {
return ERROR;
}
*e = p->data;
return OK;
}
可以看出,此算法的時間復(fù)雜度為O(n),此算法的核心思想叫做”工作指針后移”,這其實也是很多算法的常用技術(shù)
4.單鏈表的插入
單鏈表第i個數(shù)據(jù)插入結(jié)點的算法思路:
- 聲明一個節(jié)點p指向鏈表頭結(jié)點,初始化j從1開始
- 當(dāng)j<i時,就遍歷鏈表,讓p的指針向后移動,不斷指向下一結(jié)點,j累加1
- 若到鏈表末尾p為空,則說明第i個元素不存在
- 否則查找成功,在系統(tǒng)中生成一個空結(jié)點s
- 將數(shù)據(jù)元素e復(fù)制給s->data
- 將i的next賦值給s,將i的next指向s
Status insertElem(LinkList L,int i,ElemType e){
LinkList p = L;//獲取到頭結(jié)點
int j = 1;
while (p && j < i) {
p = p->next;
j++;
}
if (!p || j > i) {
//超出范圍了
return ERROR;
}
//分配空間,并插入
LinkList s = (LinkList)malloc(sizeof(Node));
s->data = e;
s->next = p->next;
p->next = s;
return OK;
}
5.單鏈表的刪除
單鏈表第i個數(shù)據(jù)刪除結(jié)點的算法思路:
- 聲明結(jié)點p指向鏈表的頭結(jié)點,初始化j=1
- 當(dāng)j<i時,就遍歷鏈表,讓p的指針向后移動,不斷指向下一個結(jié)點,j累加1
- 若到鏈表末尾p為空,則說明第i個元素不存在
- 否則查找成功,將欲刪除結(jié)點p->next賦值給q
- 單鏈表的刪除標(biāo)準(zhǔn)語句p->next = q->next
- 將q結(jié)點中的數(shù)據(jù)復(fù)制給e,作為返回
- 釋放q結(jié)點
Status deleteElem(LinkList L,int i,ElemType *e){
//頭指針,指向頭結(jié)點
LinkList p = L;
int j = 1;
while (p && j < i) {
p = p->next;
j++;
}
if (!p || j > i) {
return ERROR;
}
LinkList q = p->next;
//返回值
*e = q->data;
p->next = q->next;
//釋放
free(q);
return OK;
}
6.單鏈表與線性表的順序存儲結(jié)構(gòu)的效率PK
在單個元素插入與刪除算法中,單鏈表的時間復(fù)雜度為O(n),線性表的順序存儲結(jié)構(gòu)的時間復(fù)雜度也為O(n),效率差別不大。
但是,如果我們希望從第i個位置開始,插入連續(xù)10個元素,對于順序存儲結(jié)構(gòu)而言,每次插入都要移動n-i個位置,所以每次都是O(n)
而單鏈表,我們只需要在第一次時候,找到第i個位置的指針,此時為O(n),接下來只是簡單的通過賦值移動指針而已,時間復(fù)雜度為O(1)
顯然,對于插入或刪除數(shù)據(jù)越頻繁的操作,單鏈表的效率優(yōu)勢就越是明顯
7.單鏈表的整表創(chuàng)建
順序存儲結(jié)構(gòu)的線性表的整表創(chuàng)建,我們可以用數(shù)組的初始化來直觀理解。單鏈表不同,它的數(shù)據(jù)可以是分散的,它的增長是動態(tài)的,所以它的創(chuàng)建是根據(jù)系統(tǒng)情況和實際需求即時生成。
創(chuàng)建單鏈表的過程是一個動態(tài)生成鏈表的過程,從“空表”的初始狀態(tài)起,依次建立各元素結(jié)點并逐個插入鏈表。
創(chuàng)建單鏈表的算法思路:
- 聲明一結(jié)點p和計數(shù)器變量i
- 初始化一個空鏈表L
- 讓L的頭結(jié)點的指針指向NULL,即建立一個帶頭結(jié)點的單鏈表
- 循環(huán)實現(xiàn)后繼結(jié)點的賦值和插入
.頭插法建立單鏈表
頭插法是從一個空表開始,生成新結(jié)點,讀取數(shù)據(jù)存放到新結(jié)點的數(shù)據(jù)域中,然后將新結(jié)點插入到當(dāng)前鏈表的表頭上,直到結(jié)束為止。
簡單來講,就是把新加入的元素放在表頭后的第一個為止:
- 先讓新結(jié)點的next指向頭結(jié)點之后
- 然后讓表頭的next指向新節(jié)點
void createListHead(LinkList *L,int n){
//創(chuàng)建頭結(jié)點
LinkList p = (LinkList)malloc(sizeof(Node));
p->next = NULL;
//將頭結(jié)點的指針賦值給單鏈表
*L = p;
srand(time(0));
//循環(huán)從頭部插入
for (int i = 0; i < n; i++) {
p = (LinkList)malloc(sizeof(Node));
p->data = rand()%100+1;
//核心代碼:新結(jié)點插入在頭結(jié)點之后
p->next = (*L)->next;
(*L)->next = p;
}
}
.尾插法創(chuàng)建單鏈表
頭插法建立鏈表雖然算法簡單,但生成的鏈表中結(jié)點的次序與輸入的順序相反
尾插法剛好相反,每次把新結(jié)點都插入到最后,這種算法稱之為尾插法
void createListTail(LinkList *L,int n){
//創(chuàng)建頭結(jié)點
LinkList p = (LinkList)malloc(sizeof(Node));
p->next = NULL;
//將頭結(jié)點的指針賦值給單鏈表
*L = p;
//中間變量
LinkList r = p;
srand(time(0));
//循環(huán)從尾部插入
for (int i = 0; i < n; i++) {
//創(chuàng)建新結(jié)點
p = (LinkList)malloc(sizeof(Node));
p->data = rand()%100 + 1;
//將前面結(jié)點的next指向新結(jié)點
r->next = p;
//讓中間變量r指向新結(jié)點,作為后面創(chuàng)建結(jié)點的前驅(qū)
r = p;
}
//最后一個節(jié)點的next置為NULL
r->next = NULL;
}
8.單鏈表的整表刪除
單鏈表整表刪除的算法思路如下:
- 聲明結(jié)點p和q
- 將第一個結(jié)點賦值給p,下一結(jié)點賦值給q
- 循環(huán)執(zhí)行釋放p和將q賦值給p的操作
Status clearList(LinkList *L){
//獲取到第一個結(jié)點
LinkList p = (*L)->next;
LinkList q;
while (p) {
q = p->next;
free(p);
p = q;
}
(*L)->next = NULL;
//釋放頭結(jié)點
free((*L));
//頭指針置為NULL,防止懸空指針
*L = NULL;
return OK;
}
9.單鏈表結(jié)構(gòu)與順序存儲結(jié)構(gòu)的優(yōu)缺點
存儲分配方式:
- 順序存儲結(jié)構(gòu)用一段連續(xù)的存儲單元依次存儲線性表的數(shù)據(jù)元素
- 單鏈表采用鏈?zhǔn)酱鎯Y(jié)構(gòu),用一組任意的存儲單元存放線性表的元素
時間性能:
-
查找
. 順序存儲結(jié)構(gòu)O(1)
. 單鏈表O(n)
-
插入和刪除
. 順序存儲結(jié)構(gòu)需要平均移動表長一般的元素,時間為O(n). 單鏈表在計算出某位置的指針后,插入和刪除時間僅為O(1)
空間性能:
-- 順序存儲結(jié)構(gòu)需要預(yù)分配存儲空間,分大了,容易造成空間浪費,分小了,容易發(fā)生溢出
-- 單鏈表不需要分配存儲空間,只要有就可以分配,元素個數(shù)也不受限制
結(jié)論:
若線性表需要頻繁查找,很少進(jìn)行插入和刪除操作時,宜采用順序存儲結(jié)構(gòu)
若需要頻繁插入和刪除時,宜采用單鏈表結(jié)構(gòu)
10.單鏈表小結(jié)騰訊面試題
.題目:快速找到未知長度的單鏈表的中間節(jié)點
普通方法思路:
首先遍歷一遍單鏈表以確定單鏈表的長度L,然后再次從頭結(jié)點觸發(fā)循環(huán)L/2次找到單鏈表的中間節(jié)點,其算法復(fù)雜度為:O(L+L/2) = O(3L/2)
巧妙方法:
利用快慢指針原理:設(shè)置兩個指針search、mid都指向單鏈表的頭結(jié)點。其中search的移動速度是mid的兩倍,當(dāng)*search指向末尾結(jié)點的時候,mid正好就在中間了,這也是標(biāo)尺的思想
/*
快速找到未知長度的單鏈表的中間節(jié)點
*/
Status GetMiddleNode(LinkList L,ElemType *e,int *index){
//使用快慢指針方法 search遍歷到末尾,mid速率是search的一半
LinkList search,mid;
mid = search = L;
*index = 0;
while (search->next != NULL) {
(*index)++;
if (search->next->next != NULL) {
search = search->next->next;
mid = mid->next;
} else {
search = search->next;
break;
}
}
*e = mid->data;
return OK;
}
11.將單鏈表翻轉(zhuǎn)(倒序)
思路一:一個個的將單鏈表的結(jié)點剝下來,然后使用頭插法插在頭結(jié)點之后,這樣就實現(xiàn)了單鏈表的翻轉(zhuǎn)
void reverseLinkList(LinkList *L){
//pre表示上一個剝解下來的結(jié)點
LinkList pre = (*L)->next;
//next表示單鏈表的下一個要剝離出來的結(jié)點,初始化為NULL
LinkList next = NULL;
(*L)->next = NULL;
//將所有結(jié)點一個個剝離下來,按照頭插法插入
while (pre) {
next = pre->next;
pre->next = (*L)->next;
(*L)->next = pre;
pre = next;
}
}
思路二:建立一個數(shù)據(jù)為單鏈表結(jié)點的棧,利用棧后進(jìn)先出的特性,先一個個將結(jié)點加入棧,然后再一個個推出棧,這樣也實現(xiàn)了翻轉(zhuǎn)。(算法略)
12、判斷單鏈表中有環(huán)
有環(huán)的定義是,鏈表的尾結(jié)點指向了鏈表中的某個結(jié)點
那么,判斷單鏈表中是否有環(huán),主要有以下兩種方法:
- 使用p、q兩個指針,p總是向前走,但q每次都從頭開始走,對于每個節(jié)點,看p走的步數(shù)是否和q一樣。如圖,當(dāng)p從6走到3時,用了6步,此時若q從head觸發(fā),則只需兩步就到3,因而步數(shù)不等,出現(xiàn)矛盾,存在環(huán)。
- 使用p、q兩個指針,p每次向前走一步,q每次向前走兩步,若在某個時候p == q,則存在環(huán)
方法一:
int isHasLoop1(LinkList L){
LinkList p = L,q;
int pos1 = 0,pos2;
while (p) {
q = L;
pos2 = 0;
while (q) {
if (p == q) {
//兩個指向的結(jié)點相同
if (pos1 == pos2) {
//步數(shù)相同 也就是走到p的位置上,則重新開始遍歷
break;
} else {
//步數(shù)不相同,確定有環(huán)
printf("環(huán)的位置在第%d個結(jié)點處",pos2);
return 1;
}
}
q = q->next;
pos2++;
}
p = p->next;
pos1++;
}
//如果走到了最后一個結(jié)點,那么說明沒有環(huán),返回false
return 0;
}
方法二:
int isHasLoop2(LinkList L){
//p是一步步走的,q是兩步兩步走的
LinkList p = L,q = L;
while (p && q && q->next) {
p = p->next;
if (q->next) {
q = q->next->next;
}
printf("p:%d, q:%d \n", p->data, q->data);
if (p == q) {
return 1;
}
}
return 0;
}