链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
顺序表的存储位置可以用一个简单直观的公式表示,它可以随机存取表中任意一个元素,但插入和删除需要移动大量元素。链式存储线性表时,不需要使用地址连续的存储单元,即不要求逻辑上相邻的元素在物理位置上也相邻,它通过“链”建立起元素之间的逻辑关系,因此插入和删除操作不需要移动元素,只需要修改指针,这也意味着链表失去了可随机存取的特点。
链表结构种类多样,可以按照是否带头、是否循环、单向或者双向大致分类。
(1)带头结点和不带头结点
(2)单向链表和双向链表
(3)循环链表和非循环链表
以上情况组合就有8中链表结构,但实际中应用最多的链表结构是无头单向非循环链表和带头双向循环链表。下面介绍两种链表的基本实现。
为了建立数据元素之间的线性关系,链表结点除了存放数据,还需要存放一个指向其后继的指针。单链表可以解决顺序表需要大量连续存储单元的缺点。由于单链表的元素离散地分布在存储空间中,所以单链表时非随机存取的存储结构。
单链表的结点类型描述如下。
typedef int SLTDataType; typedef struct SListNode { SLTDataType data; struct SListNode* next; }SLTNode;
为了观感上更贴近单链表的定义,打印时先打印结点的值,“->”表示链表的指针,打印完所有元素后再打印“NULL”。
void SLTPrint(SLTNode* phead) { SLTNode* cur = phead; while (cur != NULL) { printf("%d->", cur->data); cur = cur->next; } printf("NULL\n"); }
销毁时传入的是一级指针
void SLTDestroy(SLTNode* phead) { SLTNode* cur = phead; while (phead) { cur = phead; phead = phead->next; free(cur); cur = NULL; } printf("success\n"); }
使用头插法插入新结点时,不用考虑链表是否为空,因为不涉及空指针的引用,直接插入即可。
void SLTPushFront(SLTNode** pphead, SLTDataType x) { assert(pphead); SLTNode* newnode = BuySLTNode(x); newnode->next = *pphead; *pphead = newnode; }
使用尾插法插入新结点时需要判断链表是否为空,因为插入过程中涉及到了空指针的引用。
当链表为空时,新结点即链表第一个结点;当链表不为空时,先找到链表的尾结点,然后直接将新结点插到尾结点后面。
void SLTPushBack(SLTNode** pphead, SLTDataType x) { assert(pphead); SLTNode* newnode = BuySLTNode(x); if (*pphead == NULL) *pphead = newnode; else { SLTNode* tail = *pphead; while (tail->next != NULL) { tail = tail->next; } tail->next = newnode; } }
删除之前需要判断链表是否为空,链表不为空时,删除第一个结点。
void SLTPopFront(SLTNode** pphead) { assert(pphead); assert(*pphead); SLTNode* del = *pphead; *pphead = (*pphead)->next; free(del); del = NULL; }
删除之前同样需要判断链表是否为空,此外还需要判断链表是否只有一个结点,因为删除过程中涉及到空指针的引用。如果链表只有一个结点,直接将该结点删除释放;如果链表有多个元素,找到倒数第二个结点,然后删除该结点的后继。
void SLTPopBack(SLTNode** pphead) { assert(pphead); assert(*pphead); if ((*pphead)->next == NULL) { free(*pphead); *pphead = NULL; } else { SLTNode* tail = *pphead; while (tail->next->next != NULL) { tail = tail->next; } free(tail->next); tail->next = NULL; } }
从头开始依次对比,如果找到值为x的结点则返回该结点的地址,如果没找到则返回空指针。
SLTNode* SLTFind(SLTNode* phead, SLTDataType x) { assert(phead); SLTNode* cur = phead; while (cur) { if (cur->data == x) return cur; cur = cur->next; } return NULL; }
插入分为链表只有一个结点和有多个结点这两种情况。当链表只有一个元素或者指定位置为第一个结点时,相当于头插法;当链表有多个元素或者指定位置为其他结点时,找到指定位置的前驱,然后修改其前驱的后继以及新结点的后继。
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) { assert(pphead); assert(pos); if (pos == *pphead) SLTPushFront(pphead, x); else { SLTNode* posPrev = *pphead; SLTNode* newnode = BuySLTNode(x); while (posPrev->next != pos) { posPrev = posPrev->next; } posPrev->next = newnode; newnode->next = pos; } }
在指定位置之后插入不用考虑链表有几个结点,直接插入即可。注意,要先修改新结点的后继,再修改指定位置结点的后继。
void SLTInsertAfter(SLTNode* pos, SLTDataType x) { assert(pos); SLTNode* newnode = BuySLTNode(x); newnode->next = pos->next; pos->next = newnode; }
当链表只有一个元素或者指定位置为第一个结点时,相当于链表的头删;当链表有多个元素或者指定位置为其他结点时,找到指定位置结点的前驱,并修改其后继,再删除并释放指定位置结点。
void SLTErase(SLTNode** pphead, SLTNode* pos) { assert(pphead); assert(pos); if (pos == *pphead) SLTPopFront(pphead); else { SLTNode* posPrev = *pphead; while (posPrev->next != pos) { posPrev = posPrev->next; } posPrev->next = pos->next; free(pos); pos = NULL; } }
在指定位置之后删除不用考虑链表有几个结点,直接删除即可。
void SLTEraseAfter(SLTNode* pos) { assert(pos); SLTNode* del = pos->next; pos->next = pos->next->next; free(del); del = NULL; }
单链表结点中只有一个指向其后继的指针,使得单链表只能从头结点依次顺序地向后遍历。要访问某个结点的前驱结点时,只能从头开始遍历,访问后继结点的时间复杂度为O(1),访问前驱结点的时间复杂度为O(n)。为了克服单链表的这些缺点,引入双链表,双链表中有两个指针prev和next,分别指向前驱结点和后继结点。
双链表中结点类型的描述如下:
typedef int LTDataType; typedef struct ListNode { LTDataType data; struct ListNode* prev; struct ListNode* next; }LNode;
双链表可以很方便找到前驱结点,因此,插入、删除操作的时间复杂度为O(1)。
此外,链表带头结点,无论链表是否为空,其头指针都是指向头结点的非空指针,因此在链表的插入和删除中空链表和非空链表的处理得到了统一。
为了减少二级指针的使用,在初始化双链表时,使用一级指针定义并返回头结点。因此初始化时直接建立一个头结点并返回头结点的地址。
因为后续实现链表插入时还需要建立新结点,为了简化代码以及增强代码的可读性,定义一个建立新结点的函数,后续需要建立新结点时直接调用该函数即可。建立新结点函数如下。
LTNode* BuyLTNode(LTDataType x) { LTNode* newnode = (LTNode*)malloc(sizeof(LTNode)); if (newnode == NULL) { perror("malloc fail"); return NULL; } newnode->next = NULL; newnode->prev = NULL; newnode->data = x; return newnode; }
初始化双链表函数如下。
LTNode* Init() { LTNode* head = BuyLTNode(-1); head->next = head; head->prev = head; return head; }
需要注意的是,打印时从phead的next开始打印。为了观感上更贴近双链表的定义,打印时先打印“guard”表示头结点,“<==>”表示链表的双指针,打印完所有元素后再打印一次“guard”表示尾结点的链接到头结点。
void LTPrint(LTNode* phead) { LTNode* cur = phead->next; printf("guard<==>"); while (cur != phead) { printf("%d<==>", cur->data); cur = cur->next; } printf("guard\n"); }
双链表的销毁同单链表的销毁类似,这里使用的仍是一级指针,所以需要用户在调用完销毁函数后,手动置空头结点。
void LTDestory(LTNode* phead) { LTNode* cur = phead->next; while (cur != phead) { LTNode* next = cur->next; free(cur); cur = next; } }
在使用头插法插入新结点时,要注意指针修改顺序,指针顺序虽然不是唯一的,但也不是任意的。新结点前驱和后继的修改必须在头结点后继的修改之前进行,否则头结点的后继结点的指针就会丢掉,导致插入失败。如图所示,1和2必须在4之前进行。
void LTPushFront(LTNode* phead, LTDataType x) { assert(phead); LTNode* newnode = BuyLTNode(x); newnode->next = phead->next; phead->next->prev = newnode; newnode->prev = phead; phead->next = newnode; }
还有一种方法,可以不用考虑指针修改的先后顺序,就是重新定义一个结点存放头结点的后继结点,这样就不用担心头结点的后继指针丢失的问题了。
void LTPushFront(LTNode* phead, LTDataType x) { assert(phead); LTNode* newnode = BuyLTNode(x); LTNode* first = phead->next; newnode->prev = phead; phead->next = newnode; newnode->next = first; first->prev = newnode; }
使用尾插法时,不用像单链表那样遍历找尾,头结点的前驱结点就是尾结点。插入时同样需要注意指针的修改顺序,1和2必须要在3之前进行。
和头插法类似,重新定义一个结点保存头结点的前驱结点,就不用考虑指针修改的顺序了。
void LTPushBack(LTNode* phead, LTDataType x) { assert(phead); LTNode* newnode = BuyLTNode(x); LTNode* tail = phead->prev; tail->next = newnode; newnode->prev = tail; phead->prev = newnode; newnode->next = phead; }
在删除之前需要将头结点的后继结点保存起来,否则无论在修改指针之前free后继结点还是在修改指针之后free后继结点都会出现错误。
注意,当链表为空时(只有头结点)时不能继续删除,所以在删除之前需要判断链表是否为空。
bool LTEmpty(LTNode* phead) { assert(phead); return phead->next == phead; }
void LTPopFront(LTNode* phead) { assert(phead); assert(!LTEmpty(phead)); LTNode* first = phead->next; phead->next = first->next; first->next->prev = phead; free(first); }
在删除之前需要将尾结点保存起来,为了方便,将尾结点的前驱结点也保存一下。与头删法同样,在删除前需要判断双链表是否为空,如果为空则不能继续删除。
void LTPopBack(LTNode* phead) { assert(phead); assert(!LTEmpty(phead)); LTNode* tail = phead->prev; LTNode* tailPrev = tail->prev; tailPrev->next = phead; phead->prev = tailPrev; free(tail); }
需要注意的是,从头结点的后继结点开始查找,并且停止查找的条件是当前结点不等于头结点。
LTNode* LTFind(LTNode* phead, LTDataType x) { LTNode* cur = phead->next; while (cur != phead) { if (cur->data == x) return cur; cur = cur->next; } return NULL; }
在插入之前先保存pos的前驱结点,这样在插入时就不用考虑指针的修改顺序了。
void LTInsert(LTNode* pos, LTDataType x) { LTNode* newnode = BuyLTNode(x); LTNode* posPrev = pos->prev; posPrev->next = newnode; newnode->prev = posPrev; newnode->next = pos; pos->prev = newnode; }
头插法和尾插法插入结点可以通过该函数的复用实现。
//头插法 void LTPushFront(LTNode* phead, LTDataType x) { assert(phead); LTInsert(phead->next, x); } //尾插法 void LTPushBack(LTNode* phead, LTDataType x) { assert(phead); LTInsert(phead, x); }
注意,头插法传入的pos为头结点的后继结点;尾插法传入的pos为头结点,因为头结点的前驱结点就是尾结点。
在删除之前先保存pos的前驱结点和后继结点。
void LTErase(LTNode* pos) { LTNode* posPrev = pos->prev; LTNode* posNext = pos->next; free(pos); posPrev->next = posNext; posNext->prev = posPrev; }
同样地,头删法和尾删法删除结点可以通过该函数的复用实现。
//头删法 void LTPopFront(LTNode* phead) { assert(phead); assert(!LTEmpty(phead)); LTErase(phead->next); } //尾删法 void LTPopBack(LTNode* phead) { assert(phead); assert(!LTEmpty(phead)); LTErase(phead->prev); }
注意,头删法传入的pos是头结点的后继结点;尾删法传入的pos是头结点的前驱结点。
顺序表和链表的逻辑结构都是线性结构,都属于线性表。
但是二者的存储结构不同。顺序表采用顺序存储,具有随机存取、逻辑上相邻的两个元素在物理位置上也相邻的特点,因此查找指定元素的时间复杂度为O(1),但在插入和删除时需要移动大量元素。此外,因为每个结点只存储数据元素,所以顺序表的存储密度较高。在分配空间时,顺序表有静态分配和动态分配两种方式,静态分配不能扩充,需要预先分配合适的空间大小;动态分配虽然可以扩充但需要移动大量元素,而且若内存中没有更大的连续存储空间则会导致分配失败。
链表采用链式存储,逻辑上相邻的元素物理位置上不一定相邻,不支持随机存取,元素之间的逻辑关系通过指针链接来表示,因此插入和删除时只需修改相关结点的指针域即可,不需要移动元素,但在查找指定元素时需要从表头开始依次遍历。此外,每个结点除了要存储数据元素,还有指针域,所以链表的存储密度较低。链式存储的节点空间只在需要时申请分配,只要内存有空间就可以分配,操作更加灵活、高效。
由于采用不同的存储方式实现,二者基本操作的实现效率也不同。当插入一个元素时,顺序表需要把插入位置后的所有元素向后移动一个位置,链表只需修改插入元素和其前驱结点的指针域;当删除一个元素时,顺序表需要把删除位置后的所有元素向前移动一个位置,链表只需修改删除元素前驱结点的指针域并释放删除元素;当查找一个元素时,顺序表在O(1)时间内就可以找到指定元素,而链表需要从头结点开始遍历。
总之两种存储结构各有长短。通常较稳定的线性表选择顺序存储,而频繁进行插入、删除操作的线性表宜选择链式存储。