1. 程式人生 > >C語言資料結構之連結串列

C語言資料結構之連結串列

目錄

1.什麼是連結串列

連結串列是一種物理儲存單元上非連續、非順序的儲存結構,資料元素的邏輯順序是通過連結串列中的指標連結次序實現的。連結串列由一系列結點(連結串列中每一個元素稱為結點)組成,結點可以在執行時動態生成。每個結點包括兩個部分:一個是儲存資料元素的資料域,另一個是儲存下一個結點地址的指標域。

2.連結串列的定義

1.n個節點離散分配

2.彼此通過指標相連

3.每個節點只有一個前驅節點,每個節點只有一個後續節點,首節點沒有前驅節點,尾節點沒有後續節點。

  • 專業術語

首節點:第一個有效節點

尾節點:最後一個有效節點

頭節點:

第一個有效節點之前的那個節點;頭結點不存放有效資料;頭節點的作用是為了方便對連結串列的操作。

頭指標:指向頭結點的指標變數

尾指標:指向尾節點的指標變數

簡圖如下所示:

確定一個連結串列需要幾個引數?

答:只需要一個引數,可以通過頭指標可以推算出連結串列的其他所有資訊。

3.連結串列的分類

  • 單鏈表
  • 雙鏈表:每一個節點有兩個指標域
  • 迴圈連結串列:能通過任何一個節點找到其他所有的節點
  • 非迴圈列表

 4.動態記憶體分配

  • 傳統陣列的缺點:

1.陣列的長度必須事先制定,且只能是常整數,不能是變數

例子:

int a[5];   //正確

int len = 5;   int a[len];    //錯誤

2.傳統形式定義的陣列,該陣列的記憶體程式設計師無法手動釋放,在一個函式執行期間,系統為該函式中的陣列所分配的記憶體會一直存在,直到該函式執行完畢,陣列的空間才會被系統釋放。

3.陣列的長度一旦確定,其長度不能更改,陣列的長度不能在函式執行過程中動態的擴充或減小

4.A函式定義的陣列,在A函式執行期間可以被其他函式使用,但A函式執行完畢之後,A函式中的陣列將無法在其他函式使用,傳統定義的陣列不能跨函式使用。

例子:

  • 為什麼需要動態記憶體分配?

動態陣列很好的解決了傳統陣列的這四個缺陷,傳統陣列也叫靜態陣列。

4.1.malloc函式

malloc是memory(記憶體)allocate(分配)的縮寫!

動態記憶體分配舉例-動態陣列的構造

假設動態構造一個int型一維陣列

int  *p =(int *)malloc(int len);

1.本語句分配了兩塊記憶體,一塊是動態分配的,總共len個位元組,另一塊是靜態分配的。

2.(int *) 強制把首地址強制轉換成  int * 型別,告訴你的機器,返回地址指向的資料佔幾個位元組。

例子1:

int main(void)
{
	int i = 5;   //靜態分配了四個位元組的空間
	int *p = (int *)malloc(4);
	/*
		1.使用malloc函式需要新增malloc.h標頭檔案
		2.malloc只有一個形參,且為整型
		3.4,代表的是請求系統為本程式跟配4個位元組的記憶體
		4.malloc函式只能返回第一個位元組的地址(注意是第一個位元組,只有一個位元組)
		5.malloc函式所在的一行,一共分配了8個位元組的空間,p指標變數佔4個位元組,p所指向的記憶體也佔四個位元組
		6.p本身所佔的記憶體也是靜態分配的,p所指向的記憶體是動態分配的
	*/
	*p = 5; //p是一個int * 型別的,那麼 *p 就是int 型別的,所以能進行賦值運算,且操作的正是動態分配的記憶體
	free(p);   //free(p)表示把p所指向的記憶體釋放掉,注意p本身的記憶體是靜態的,不能由程式設計師手動釋放。
	printf("hello\n");
	system("pause");
	return 0;

}

例子2:

void f(int * q)
{
	//*p =200;    //錯誤,*p不存在
    //q  =200;    //錯誤,q是int * 型別的
	*q = 200;   //OK
	free(q);   //把q所指向的記憶體釋放掉
}
int main(void)
{
	int * p = (int *)malloc(sizeof(int));//sizeof(int)返回值是int所佔的位元組數,為4

	*p = 10;   //把10賦值給以p的內容為地址的變數,即為動態分配的記憶體

	printf("%d\n", *p);
	f(p);
	printf("%d\n", *p);
	system("pause");

}

預測一下這個程式的執行結果:

可以看到*p既然成了一個垃圾值,而不是200?為什麼?

分析:

4.2.動態陣列的構造

1.malloc只有一個int型的形參,表示要求系統分配的位元組數

2.malloc函式的功能是請求系統len個位元組的記憶體空間,如果請求分配成功,則返回第一個位元組的地址,如果分配不成功,返回NULL.

注意:malloc函式能且只能返回第一個位元組的地址,所以我們需要把這個無任何實際意義的第一個位元組的地址(稱為乾地址)轉化成一個有實際意義的地址,因此,malloc前面必須加(資料型別 *),表示把這個無實際意義的第一個位元組的地址轉化成相應型別的地址。如:

int *p = (int *)malloc(50);

表示將系統分配好的50個位元組的第一個位元組的地址轉化成int *型別的地址,更精確的說是把第一個位元組的地址轉化成四個位元組的地址,這樣p就指向了第一個四個位元組,p+1就指向了第2個四個位元組,p+i就指向了第i+1個的第4個位元組。p[0]就是第一個元素,p[i]就是第i+1個元素。

double *p =(double *)malloc(80);

表示將系統分配好的80個位元組的第一個位元組的地址轉化成double *型別的地址,更精確的說是把第一個位元組的地址轉化成8個位元組的地址,這樣p就指向了第一個8個位元組,p+1就指向了第2個8個位元組,p+i就指向了第i+1個的第8個位元組。p[0]就是第一個元素,p[i]就是第i+1個元素。

例子:

4.3.動態記憶體和靜態記憶體的比較

  • 靜態記憶體是系統自動分配的,由系統自動釋放。
  • 靜態記憶體是在棧內分配的。
  • 動態記憶體是由程式設計師手動分配的,手動釋放。
  • 動態記憶體是在堆分配的

跨函式使用記憶體的問題:

  • 靜態記憶體不可以跨函式使用
  • 所謂靜態記憶體不可以跨函式使用準確的說法:靜態記憶體在函式執行期間可以被其他函式使用,靜態記憶體在函式執行完畢之後就不能被其他函式使用了
  • 動態陣列可以跨函式使用:動態記憶體在函式執行完畢之後任然可以被其他函式使用。

4.連結串列的建立

首先建立一個結構體用於存放連結串列一些靜態記憶體分配的資料,如下:

typedef struct Node
{
	int data;           //資料域
	struct Node * pNext;//指標域
}NODE,* PNODE;          //NODE等價於struct Node,PNODE 等價於 struct Node *

連結串列的建立程式碼如下:

PNODE create_list(void)   //建立連結串列,返回一個PNODE型別的值,為頭結點指標
{
	int len;
	int i;
	int val; //用來存放臨時節點的數值
     //分配了一個不存放資料的頭結點
	PNODE Phead=(PNODE)malloc(sizeof(NODE));  //建立一個頭節點
	if(NULL==Phead)     //
	{
		printf("記憶體分配失敗,程式終止!\r\n");
		exit(-1);
	}
	PNODE Ptail=Phead; //建立一箇中間節點,然後然頭節點指向空
	Ptail->pNext=NULL;  
	printf("請輸入要建立連結串列節點的個數 len= ");
	scanf("%d",&len);
   for(i=0;i<len;i++)
   {
	   printf("請輸入第%d個節點的值",i+1);
	   scanf("%d",&val);

	   PNODE Pnew = (PNODE)malloc(sizeof(NODE));
	   
	   if(NULL==Pnew)
	   {
		printf("記憶體分配失敗,程式終止!\r\n");
		exit(-1);
	   }
	   Pnew->data=val;      //給新的塊的資料賦值,其地址為Pnew
	   Ptail->pNext=Pnew;   //Ptail 第一次儲存的是Phead ,然後下一次
	   Pnew->pNext=NULL;    //儲存的是Phead->PNext(也是Pnew)
	   Ptail=Pnew;  //以此類推,每一次會把當前的地址更新
   }

  return Phead;
}

程式碼分析:

首先使用malloc函式動態分配記憶體,建立一個頭結點:

 //分配了一個不存放資料的頭結點
	PNODE Phead=(PNODE)malloc(sizeof(NODE));  //建立一個頭節點
	if(NULL==Phead)     //
	{
		printf("記憶體分配失敗,程式終止!\r\n");
		exit(-1);
	}

動態分配一個siziof(NODE) ,也就是NODE結構體大小的動態記憶體,然後強制轉換成 (NODE *) 型別,即為:PNODE 型別

返回分配記憶體的首地址給Phead。而Phead的資料型別是PNODE的,其存放的是NODE型別變數的地址。

在這裡需要注意,此處分配了兩塊記憶體,一個是靜態分配的,一個是動態分配的。

Phead 就是靜態分配的,資料型別是PNODE,  malloc分配是出來的記憶體是動態的,大小是NODE結構體大小。

那麼Phead所佔的大小是多少?

一個指標變數到底佔用幾個位元組的記憶體空間?

預備知識

使用函式:sizeof(資料型別)

功能:返回值就是該資料型別所佔的位元組數。

例子:

sizeof(int) =4    sizeof(char) =1     sizeof(double) =8

sizeof(變數名)

功能:返回值是該變數所佔的位元組數。

 假設p指向char型別變數(一個位元組),q指向int型別變數(4個位元組),r指向double型別變數(8個位元組)

請問:p q r本身所佔的位元組數是否有區別?

結論:一個指標變數,無論它指向的變數佔幾個位元組,該指標變數本身只佔四個位元組,一個變數的 地址是用該變數首位元組的地址來表示。

值得注意的是,在硬體中每個地址都有一個編號,而且都是一個位元組一個編號的。

為什麼一個變數的地址用首位元組表示,那為什麼指標變數需要使用四個位元組?

舉例:

房子的大小和房子的編號是沒有關係的,就像是變數的大小和變數地址所佔內容大小是沒有關係的,因為一個變數的地址僅僅用首地址來表示。

  • 如果現在我的房子只有100間的話,那麼我的房間編號用8個位(一個位元組)表示就夠咯,如上(0-255)。無論在哪個位置我都可以用一個位元組來表示你的位置。
  • 但是如果你的房間有2的32次方(等於4G的空間)那麼大,你的一個位元組還夠表示嗎?一個位元組最大隻能表示255啊,後面的房間編號就表示不了了,所以此時你需要的房間編號數量應該大於或者等於房間的數量吧,那麼就應該就是2的32次方個編號咯,轉換成位元組就是4個位元組。所以用四個位元組表示地址,最大的記憶體是4G,就是這個道理!

注意:通過上面知道,無論表示哪一個房間號都應該用四個位元組的地址,即使表示的是第一個,例如第1個表示的地址為:0x0001,第16個:0x000F。

接著執行下面的語句,創建出首節點,然後對首節點賦值,接著,把頭結點和首節點相連起來

這一個迴圈執行完了,如果還有下一次迴圈呢,這個程式是如何執行的呢?

下一次迴圈又是執行下面的語句:

         PNODE Pnew = (PNODE)malloc(sizeof(NODE));

		if (NULL == Pnew)
		{
			printf("記憶體分配失敗,程式終止!\r\n");
			exit(-1);
		}
		//給新的塊的資料賦值,其地址為Pnew
		Pnew->data = val;
		//Ptail 第一次儲存的是Phead ,然後下一次
		Ptail->pNext = Pnew;  
		//儲存的是Phead->PNext(也是Pnew)
		Pnew->pNext = NULL; 
		//以此類推,每一次會把當前的地址更新
		Ptail = Pnew;         

5.遍歷連結串列

連結串列的遍歷就是把所有連結串列裡面的資料逐一輸出。

當我們成功建立了一個連結串列之後,則資料應該是如下面簡圖所示:

頭結點是不存放任何資料的,所以我們只需要判斷頭結點的指標域(pNext)是否為NULL,如果不為空說明有資料,那就輸出,如果為NULL,說明已經到了尾節點,就不輸出了。

程式碼如下:

void traveser_list(PNODE Phead)  //遍歷連結串列
{
	PNODE p = Phead->pNext;
	while (NULL != p) //連結串列不為空
	{
		printf("%d ", p->data);
		p = p->pNext;

	}
	printf("\n");
	return;
}

執行流程,如下簡圖所示: 

6.連結串列的插入

先說一說節點插入的一個演算法和思路:

  • 演算法1:

假如我們現在的連結串列簡圖如下:

現在想在序號為①的塊後面插入一個塊(假設p指向這個塊)

既然需要在①和②之間插入一個塊的話,就需要把它們斷開,如果直接斷開的話,你就找不到咯,因為最後還是要把它們連線起來的,所以找一箇中間變數 r 把它儲存起來。

然後把①的指標域指向p指向的這個塊也就是想要插入的塊咯;

這樣就剩下最後一步,把插入的塊(p指向的塊)的指標域指向塊②就OK了。

這樣子就把一個塊插入我們想要的位置了,但是這種演算法顯得複雜一點,我們也可以使用另一種演算法,先把想要插入的塊的指標域儲存塊②(指向塊②)的地址,然後把塊①的指標域指向要插入的塊。

  • 演算法二

演算法二相對演算法一來說會簡介很多。

整體程式碼如下:

bool insert_list(PNODE Phead,int pos,int val)//pos從1開始
{
    int i=0;
    PNODE p=Phead;
	while(NULL!=p->pNext && i<pos-1)//此函式的目的是進行指向定位
	                           //定位到插入元素前面一個元素
	{
		p=p->pNext;                 
		i++;                  //統計pos前面
	}
	if(i>pos-1 ||p->pNext==NULL)
	 return false;
	PNODE Pnew=(PNODE)malloc(sizeof(NODE)); //分配一個新的塊
	if(Pnew==NULL)
	{
		printf("內部分配失敗,終止執行\n");
		exit(-1);
	}
	Pnew->data=val;  //給新元素賦值
	Pnew->pNext=p->pNext;
	p->pNext=Pnew;
    return true;
}

此函式需要三個引數,第一個是指令哪個連結串列的頭指標,第二個是插入的位置,第三個是插入的值。

首先,在函式中新建一個指標變數p儲存連結串列的頭指標:PNODE p=Phead;

其次,也是比較重要的一個步驟,就是定位到我們需要插入塊的前面的序號

假如此時我想插入一個塊到第3個位置,也就是在pos=2這個位置的後邊,那我呼叫的函式就是

insert_list(Phead,3,10);  //在3的位置,插入一個值為10的資料

然後再進行迴圈判斷,這兩個條件仍然是成立的,繼續

執行完這一段後,i已經是等於2了,不滿足i<2這個條件了,所以跳出while迴圈,繼續向下執行

if(i>pos-1 ||p->pNext==NULL)
	 return false;

對 i 的值和p->pNext的值進行判斷是否能進行插入工作

此時  i=2 不滿足 i >pos-1=2,此時p->pNext 應該是pos=3這個位置,所以也是不成立的,所以不返回false,繼續向下執行。

假設現在我只有三個存放資料的塊,但是我卻想插入位置為pos=4的塊可以嗎?

可以接著上面的分析

可以看到此時while裡面的兩個條件仍然是成立的,所以可以繼續執行迴圈語句

執行完上面的一次,兩個條件都不成立了,那麼就退出迴圈了,接著向下執行

if(i>pos-1 ||p->pNext==NULL)
	 return false;

此時i的值為3,不滿足i>pos-1=3,但是p->pNext已經是NULL了,所以這個條件是成立的,直接就返回false了。

所以說如果只有三個有效節點的話,想插入第四個的話肯定是不成功的。

同理,可以看一看能否插入7的位置,答案肯定是不能的,因為根本沒地方插進去,還是看看程式分析的邏輯吧

此時可以繼續往下執行:

因為此時p->pNext 已經為NULL,所以迴圈不成立,所以跳出迴圈了,接著往下執行程式

if(i>pos-1 ||p->pNext==NULL)
	 return false;

此時i=3,不滿足i>pos-1=6 ,但是滿足p->pNext=NULL,所以返回的是false。不能進行插入工作

如果確認了可以插入塊可,那麼就動態分配一個新的資料塊

PNODE Pnew=(PNODE)malloc(sizeof(NODE)); //分配一個新的塊
	if(Pnew==NULL)
	{
		printf("內部分配失敗,終止執行\n");
		exit(-1);
	}

然後對新的塊進行賦值

Pnew->data=val;  //給新元素賦值

使用如上的插入演算法2進行插入

Pnew->pNext=p->pNext;
p->pNext=Pnew;

通過上面我們已經把指標變數  p 定位到要插入位置的前一個塊上 

插入過程如下圖所示:

7.連結串列節點的刪除

連結串列節點的刪除思路和連結串列的插入差不多,只要熟悉了連結串列的插入的邏輯,理解刪除相對來說會容易很多。

刪除節點的思路,我們同樣是需要三個引數,第一個:操作的連結串列;第二個:刪除節點的位置;第三個:被刪除的數值。

首次使用一個指標變數,定位到需要刪除節點的上一個節點,判斷是否能進行刪除操作:

程式如下:

bool delete_list(PNODE Phead,int pos,int *pval)
{
  int i=0;
    PNODE p=Phead;
	while(NULL!=p->pNext && i<pos-1)  //此函式的目的是進行指向定位
	                           //定位到刪除元素前面一個元素
	{
		p=p->pNext;                 
		i++;                  //統計pos前面
	}
	if(i>pos-1 ||p->pNext==NULL)
	 return false;

	PNODE r=p->pNext;     //先記住刪除的節點,待會釋放
	*pval=r->data;       //記住刪除的值
	p->pNext=p->pNext->pNext;
	free(r);
	r->pNext=NULL;
    return true;

}

前面一段進行定位和判斷是否能刪除節點,詳細看上一節吧

while(NULL!=p->pNext && i<pos-1)  //此函式的目的是進行指向定位
	                           //定位到刪除元素前面一個元素
	{
		p=p->pNext;                 
		i++;                  //統計pos前面
	}
	if(i>pos-1 ||p->pNext==NULL)
	 return false;

因為我們刪除的是p所指向的塊的下一個節點,此時我們需要去記住我們要刪除的節點,當我們操作完成之後再釋放這塊被我們刪除的記憶體(這也是動態記憶體分配的好處)。

使用 r指標變數,記住將被刪除塊的地址:r = p->pNext;

然後為了驗證,把刪除的值也傳出來      :*pval=r->data;

然後使用語句: p->pNext = p->pNext->pNext;  刪除一個節點

 

接著執行:free(r);   釋放r所指向的記憶體空間

此時還有一句:r->pNext=NULL; 在這裡要注意,free(r);釋放的是r所指向的空間,不是釋放r的控制元件,r本身是系統靜態分配的,程式設計師無法手動釋放,只能在系統執行結束之後自動釋放。