算法與數據結構(二):鏈表
上一篇簡單的開了一個頭,簡單介紹了一下所謂的時間復雜度與空間復雜度,從這篇開始將陸陸續續寫一下常用的數據結構:鏈表、隊列、棧、樹等等。
鏈表當初是我在學校時唯一死磕過的數據結構,那個時候自己還算是一個好學生,雖然上課沒怎麽聽懂,但是課後還是根據仔細調試過老師給的代碼,硬是自己給弄懂了,它是我離校時唯一能夠寫出實現的數據結構,現在回想起來應該是它比較簡單,算法也比較直來直去吧。雖然它比較簡單,很多朋友也都會鏈表。但是作為一個系列,如果僅僅因為它比較簡單而不去理會,總覺得少了點什麽,所以再這仍然將其列舉出來。
單向鏈表
單向鏈表是鏈表中的一種,它的特點是只有一個指向下一個節點的指針域,對單向鏈表的訪問需要從頭部開始,根據指針域依次訪問下一個節點,單向鏈表的結構如下圖所示
單向鏈表的創建
單向鏈表的結構只需要一個數據域與指針域,這個數據域可以是一個結構體,也可以是多個基本數據類型;指針域是一個指向節點類型的指針,簡單的定義如下:
typedef struct _LIST_NODE
{
int nVal;
struct _LIST_NODE *pNext;
}LIST_NODE, *LPLIST_NODE;
創建鏈表可以采用頭插法或者尾插法來初始化一個有多個節點的鏈表
頭插法的示意圖如下:
它的過程就像示意圖中展現的,首先使用新節點p的next指針指向當前的頭節點把新節點加入到鏈表頭,然後變更鏈表頭指針,這樣就在頭部插入了一個節點,用代碼來展示就是
p->next = head;
head = p;
我們使用一個函數來封裝就是
LPLIST_NODE CreateListHead() { LPLIST_NODE pHead = NULL; while (TRUE) { LPLIST_NODE p = (LPLIST_NODE)malloc(sizeof(LIST_NODE)); if (NULL == p) { break; } memset(p, 0x00, sizeof(LIST_NODE)); printf("請輸入節點值(為0時將退出創建節點):"); scanf_s("%d", &p->nVal); //這裏不需要對鏈表為空單獨討論 //當鏈表為空時pHead 的值為NULL, 這兩句代碼就變為 //p->pNext = NULL; //pHead = p; p->pNext = pHead; pHead = p; if (p->nVal == 0) { break; } } return pHead; }
采用尾插法的話,首先得獲得鏈表的尾部 pTail, 然後使尾節點的next指針指向新節點,然後更新尾節點,用代碼來表示就是
pTail->next = p;
pTail = p;
下面的函數是采用尾插法來構建鏈表的例子
//這個函數多定義了一個變量用來保存
// 可以不需要這個變量,這樣在插入之前需要遍歷一遍鏈表,以便找到尾節點
// 但是每次插入之前都需要遍歷一遍,沒有定義一個變量保存尾節點這種方式來的高效
LPLIST_NODE CreateListTail()
{
LPLIST_NODE pHead = NULL;
LPLIST_NODE pTail = pHead;
while (NULL != pTail && NULL != pTail->pNext)
{
pTail = pTail->pNext;
}
while (TRUE)
{
LPLIST_NODE p = (LPLIST_NODE)malloc(sizeof(LIST_NODE));
if (NULL == p)
{
break;
}
memset(p, 0x00, sizeof(LIST_NODE));
printf("請輸入節點值(為0時將退出創建節點):");
scanf_s("%d", &p->nVal);
//由於這種方法需要對尾節點的next域賦值,所以需要考慮鏈表為空的情況
if (NULL == pTail)
{
pHead = p;
pTail = pHead;
}else
{
pTail->pNext = p;
pTail = p;
}
if (p->nVal == 0)
{
break;
}
}
return pHead;
}
鏈表的遍歷
鏈表的每個節點在內存中不是連續的,所以它不能像數組那樣根據下標來訪問(當然可以利用C++中的運算符重載來實現使用下標訪問),鏈表中的每一個節點都保存了下一個節點的地址,所以我們根據每個節點指向的下一個節點來依次訪問每個節點,訪問的代碼如下:
void TraverseList(LPLIST_NODE pHead)
{
while (NULL != pHead)
{
printf("%d\n", pHead->nVal);
pHead = pHead->pNext;
}
}
鏈表的刪除
鏈表的每個節點都是在堆上分配的,在不再使用的時候需要手工清除每個節點。清除時需要使用遍歷的方法,一個個的刪除,只是需要在遍歷的指針移動到下一個節點前保存當前節點,以便能夠刪除當前節點,刪除的函數如下
void DestroyList(LPLIST_NODE pHead)
{
LPLIST_NODE pTmp = pHead;
while (NULL != pTmp)
{
pTmp = pHead->pNext;
delete pHead;
pHead = pTmp;
}
}
刪除單個節點
如上圖所示,假設我們要刪除q節點,那麽首先需要遍歷找到q的上一個節點p,將p的next指針指向q的下一個節點,也就是賦值為q的next指針的值,用代碼表示就是
p->next = q->next;
刪除節點的函數如下:
void DeleteNode(LPLIST_NODE* ppHead, int nValue)
{
if (NULL == ppHead || NULL == *ppHead)
{
return;
}
LPLIST_NODE p, q;
p = *ppHead;
while (NULL != p)
{
if (nValue == p->nVal)
{
if (*ppHead == p)
{
*ppHead = p->pNext;
free(p);
}else
{
q->pNext = p->pNext;
free(p);
}
p = NULL;
q = NULL;
break;
}
q = p;
p = p->pNext;
}
}
在上述代碼中首先來遍歷鏈表,找到要刪除的節點p和它的上一個節點q,由於頭節點沒有上一個節點,所以需要特別判斷一下需要刪除的是否為頭節點,如果為頭結點,則直接將頭指針指向它的下一個節點,然後刪除頭結點即可,如果不是則采用之前的方法來刪除。
任意位置插入節點
如上圖所示,如果需要在q節點之後插入p節點的話,需要兩步,將q的next節點指向q,然後將q指向之前p的下一個節點,這個時候需要註意一下順序,如果我們先執行q->next = p 的話,那麽之前q的下一個節點的地址就被覆蓋掉了,這個時候後面的節點都丟掉了,所以這裏我們要先執行p->next = q->next 這條語句,然後在執行q->next = p
下面是一個創建有序鏈表的例子,這個例子演示了在任意位置插入節點
LPLIST_NODE CreateSortedList()
{
LPLIST_NODE pHead = NULL;
while (TRUE)
{
LPLIST_NODE p = (LPLIST_NODE)malloc(sizeof(LIST_NODE));
if (NULL == p)
{
break;
}
memset(p, 0x00, sizeof(LIST_NODE));
printf("請輸入節點值(為0時將退出創建節點):");
scanf_s("%d", &p->nVal);
if (NULL == pHead)
{
pHead = p;
}else
{
if (pHead->nVal > p->nVal)
{
p->pNext = pHead;
pHead = p;
}else
{
LPLIST_NODE q = pHead;
LPLIST_NODE r = q;
q = q->pNext;
while (NULL != q && q->nVal < p->nVal)
{
r = q;
q = q->pNext;
}
p->pNext = r->pNext;
r->pNext = p;
}
}
if (p->nVal == 0)
{
break;
}
}
return pHead;
}
當確定新節點的值之後,首先遍歷鏈表,直到找到比新節點中數值大的節點,那麽這個新節點就是需要插入到該節點之前。在遍歷的時候使用r來保存之前的節點。這裏需要註意這些情況:
- 鏈表為空:這種情況下,直接讓頭指針指向當前節點
- 如果頭節點本身就是大於新節點的值,這種情況下采用頭插法,將新節點插入到頭部
- 如果鏈表中未找到比新節點的值更大的值,這種情況下直接采用尾插發
- 在鏈表中找到比新節點值更大的節點,這種情況下,在鏈表中插入
但是在代碼中並沒有考慮到尾部插入的情況,由於在尾部插入時,r等於尾節點,r->pNext 的值為NULL, 所以 p->pNext = r->pNext;r->pNext = p;
可以看成 p->pNext = NULL; r->pNext = p;
也就是將p的next指針指向空,讓其作為尾節點,將之前的尾節點的next指針指向新節點。
循環鏈表
循環鏈表是建立在單向鏈表的基礎之上的,循環鏈表的尾節點並不指向空,而是指向其他的節點,可以是頭結點,可以是自身,也可以是鏈表中的其他節點,為了方便操作,一般將循環鏈表的尾節點的next指針指向頭節點,它的操作與單鏈表的操作類似,只需要將之前判斷尾節點的條件變為 pTail->pNext == pHead
即可。這裏就不再詳細分析每種操作了,直接給出代碼
LPLIST_NODE CreateLoopList()
{
LPLIST_NODE pHead = NULL;
LPLIST_NODE pTail = pHead;
while(1)
{
LPLIST_NODE p = (LPLIST_NODE)malloc(sizeof(LIST_NODE));
if (NULL == p)
{
break;
}
memset(p, 0x00, sizeof(LIST_NODE));
printf("請輸入一個值:");
scanf_s("%d", &p->nVal);
if (NULL == pHead)
{
pHead = p;
p->pNext = pHead;
pTail = pHead;
}else
{
pTail->pNext = p;
p->pNext = pHead;
pTail = p;
}
if (0 == p->nVal)
{
break;
}
}
return pHead;
}
void TraverseLoopList(LPLIST_NODE pHead)
{
LPLIST_NODE pTmp = pHead;
if (NULL == pTmp)
{
return;
}
do
{
printf("%d, ", pTmp->nVal);
pTmp = pTmp->pNext;
} while (pTmp != pHead);
}
void DestroyLoopList(LPLIST_NODE pHead)
{
LPLIST_NODE pTmp = pHead;
LPLIST_NODE pDestroy = pTmp;
if (NULL == pTmp)
{
return;
}
do
{
pTmp = pDestroy->pNext;
free(pDestroy);
pDestroy = pTmp;
}while (pHead != pTmp);
}
判斷鏈表是否為循環鏈表
在上面說過,循環鏈表的尾指針不一定指向頭節點,它可以指向任何節點,那麽該怎麽判斷一個節點是否為循環鏈表呢?既然它可以指向任意的節點,那麽肯定是找不到尾節點的,而且堆內存的分配是隨機的,我們也不可能按照指針變量的大小來判斷哪個節點在前哪個在後。
回想一下在學校跑一千米的時候是不是回出現這樣的情況,跑的塊的會領先跑的慢的一周?根據這種情形我們可以考慮使用這樣一種辦法:定義兩個指針,一個一次走兩步也是就是p = p->next->next, 一個慢指針一次走一步,也就是q = q->next,如果是循環鏈表,那麽快指針在某個時候一定會領先慢指針一周,也就是達到 p == q 這個條件,否則就是非循環鏈表。根據這個思路,可以考慮寫下如下代碼:
bool IsLoopList(LPLIST_NODE pHead)
{
if (NULL == pHead)
{
return false;
}
LPLIST_NODE p = pHead;
LPLIST_NODE q = pHead->pNext;
while (NULL != p && NULL != q && NULL != q->pNext && p != q)
{
p = p->pNext;
q = q->pNext->pNext;
}
if (q == NULL || NULL == p || NULL == q->pNext)
{
return false;
}
return true;
}
雙向鏈表
之前在插入或者刪除的時候,需要定義兩個指針變量,讓其中一個一直更在另一個的後面,單向鏈表有一個很大的問題,不能很方便的找到它的上一個節點,為了解決這一個問題,提出了雙向鏈表,雙向鏈表與單向相比,多了一個指針域,用來指向它的上一個節點,也就是如下圖所示:
雙向鏈表的操作與單向鏈表的類似,只是多了一個指向前一個節點的指針域,它要考慮的情況與單向鏈表相似
刪除節點
刪除節點的示意圖如下:
假設刪除的節點p,那麽首先根據p的pre指針域,找到它的上一個節點q,采用與單向鏈表類似的操作:
q->next = p->next;
p->next->pre = q;
下面是刪除節點的例子:
void DeleteDNode(LPDLIST_NODE* ppHead, int nValue)
{
if (NULL == ppHead || NULL == *ppHead)
{
return;
}
LPDLIST_NODE p = *ppHead;
while (NULL != p && p->nVal != nValue)
{
p = p->pNext;
}
if (NULL == p)
{
return;
}
if (*ppHead == p)
{
*ppHead = (*ppHead)->pNext;
p->pPre = NULL;
free(p);
}
else if (p->pNext == NULL)
{
p->pPre->pNext = NULL;
free(p);
}else
{
p->pPre->pNext = p->pNext;
p->pNext->pPre = p->pPre;
}
}
插入節點
插入節點的示意圖如下:
假設新節點為p,插入的位置為q,則插入操作可以進行如下操作
p->next = q->next;
p->pre = q;
q->next->pre = p;
q->next = p;
也是一樣要考慮不能覆蓋q的next指針域否則可能存在找不到原來鏈表中q的下一個節點的情況。所以這裏先對p的next指針域進行操作
下面也是采用創建有序列表的例子
LPDLIST_NODE CreateSortedDList()
{
LPDLIST_NODE pHead = NULL;
while (1)
{
LPDLIST_NODE pNode = (LPDLIST_NODE)malloc(sizeof(DLIST_NODE));
if (NULL == pNode)
{
return pHead;
}
memset(pNode, 0x00, sizeof(DLIST_NODE));
printf("請輸入一個整數:");
scanf_s("%d", &pNode->nVal);
if(NULL == pHead)
{
pHead = pNode;
}else
{
LPDLIST_NODE q = pHead;
LPDLIST_NODE r = q;
while (NULL != q && q->nVal < pNode->nVal)
{
r = q;
q = q->pNext;
}
if (q == pHead)
{
pNode->pNext = pHead;
pHead->pPre = pNode;
pHead = pNode;
}else if (NULL == q)
{
r->pNext = pNode;
pNode->pPre = r;
}else
{
pNode->pPre = r;
pNode->pNext = q;
r->pNext = pNode;
q->pPre = pNode;
}
}
LPDLIST_NODE q = pHead;
LPDLIST_NODE r = q;
if (0 == pNode->nVal)
{
break;
}
}
return pHead;
}
鏈表還有一種是雙向循環鏈表,對於這種鏈表主要是在雙向鏈表的基礎上,將頭結點的pre指針指向某個節點,將尾節點的next節點指向某個節點,而且這兩個指針可以指向同一個節點也可以指向不同的節點,一般在使用中都是head的pre節點指向尾節點,而tail的next節點指向頭節點。這裏就不再詳細說明,這些鏈表只要掌握其中一種,剩下的很好掌握的。
算法與數據結構(二):鏈表