目录
单链表 Singly-Linked List
链表是一种常见的基础数据结构,结构体指针在这里得到了充分的利用。链表可以动态的进行存储分配,也就是说,链表是一个功能极为强大的数组,他可以在节点中定义多种数据类型,还可以根据需要随意增添,删除,插入节点。链表都有一个头指针,一般以head来表示,存放的是一个地址。链表中的节点分为两类,头结点和一般节点,头结点是没有数据域的。链表中每个节点都分为两部分,一个数据域,一个是指针域。说到这里你应该就明白了,链表就如同车链子一样,head指向第一个元素:第一个元素又指向第二个元素;……,直到最后一个元素,该元素不再指向其它元素,它称为“表尾”,它的地址部分放一个“NULL”(表示“空地址”),链表到此结束。
作为有强大功能的链表,对他的操作当然有许多,比如:链表的创建,修改,删除,插入,输出,排序,反序,清空链表的元素,求链表的长度等等。
顺序表的缺陷
-
realloc是有空间损耗的,在realloc运行的时候,首先进行判断,空间不够就会异地扩容,如果空间足够就会原地扩容。增容是要付出代价的。
-
为避免频繁扩容,空间满了一般会扩二倍,可能就会导致一定的空间浪费。
-
顺序表要求顺序从开始位置连续存储,我们在头部或者中间位置插入删除数据,就需要挪动数据,效率不高
但顺序表支持随机访问,有些算法,需要结构支持随机访问。比如,二分查找,比如优化的快排。
针对顺序表的缺陷我们就设计出来链表
链表的优缺点
优点
-
按需申请空间,不用了就释放空间,会合理的利用空间。
-
头部中间插入删除数据,不需要挪动数据,从而不存在空间浪费。
缺点
-
每存放一个数据,都要存一个指针去链接后面的数据节点。
-
不支持数据的随机访问(如同顺序表里直接用a[i] 去访问第i个数据)
单链表的缺陷还是很多的,单纯单链表的增删查找的意义不大。
-
很多OJ题目考查的都是单链表
-
单链表更多的是做更复杂的数据结构的子结构,哈希桶、临接表
链表存储数据还是要看双向链表。
链表的实现
定义结构体
typedef int SLTDataType; typedef struct SListNode { SLTDataType data; struct SListNode *next; } SLTNode;
链表的打印 SListPrint
关键就是
cur = cur->next
因为这个链表里面的两个数据分别是
typedef struct SListNode { SLTDataType data; struct SListNode *next; }SLTNode;
cur被赋给了cur的next,实际上这个next里存放的就是下一个结构体的地址。
void SListPrint(SLTNode *phead) { SLTNode *cur = phead; while(cur!=NULL) { printf("%d->", cur->data); cur = cur->next; } }
我们一般学习链表分为逻辑图和物理图,在我们的想象里,一般是phead去指向链表的第一个结构体,然后第一个结构体指向第二个结构体,以此类推,但是物理图并非如此,每个都是单独存放。
链表的尾插 SListPushBack
要尾插,首先就要找到这个尾巴。需要找到最后一个节点。我们需要找到这个原来的尾,去指向新的NewNode。
如何找到呢?
尾的标志就是下一个节点为空
SLTNode *tail = phead; while(tail->next !=NULL) { tail = tail->next; }
直到找到了尾节点,再找到新节点
SLTNode *newnode = (SLTNode *)malloc(sizeof(SLTNode)); newnode->next = NULL; newnode-> data = x; tail->next = newnode;
最后一步是通过修改尾的指向,指向新的结构体newnode内存块。
但是发现运行不起来,程序会崩掉,这是因为如果我们直接去判断
while (tail->next!= NULL)
会造成对NULL的解引用,从而导致了程序崩溃。
有一个问题,形参phead的改变没有影响实参plist。
我们是把plist的值拷贝了一份给phead,因为二者都是一级指针,如果想要改变plist实参的数值,你需要传递plist的地址,然后用二级指针接收。类似下面:
&plist = pphead //改变*pphead 就改变了plist的值
所以我们可以把新空间块放在前面扩建,然后再去判断传过来的地址是不是空,如果是空的话,证明我们的链表整个为空。
头插 SListPushFront
我们知道pphead形参是plist实参的地址,如果我们头插,就是把*pphead(plist的地址) 放到新组建的newnode组块的next里,然后在把newnode的地址传给 解引用后的pphead。
这样就可以做到plist指向newnode,而newnode->又指向原来plist指向的值。这个时候即便去验证*pphead为空的情况,可以发现并没有影响,所以不用单独列出考虑。
void SListPushFront(SLTNode **pphead, SLTDataType x) { SLTNode *newnode = BuyListNode(x); newnode->next = *pphead; *pphead = newnode; }
尾删 SListPopback
尾删首先要做到的就是找到尾巴,这个很简单,我们只需要使用
SLTNode *tail = *pphead; while(tail->next) { prev = tail; tail = tail->next; }
就可以找到尾,随后很多同学就直接用
free(tail); tail = NULL;
free掉tail就结束函数了,这是不正确的。因为tail之前的一个块是没有置空的,这样就会导致野指针。从而程序出错。正确做法应该是记录一个指针变量prev,每次记录tail之前的块,然后最后找到尾后置空prev->next即可。
void SLitstPopBack(SLTNode **pphead) { if(*pphead ==NULL) { return; } //assert(*pphead!=NULL)//比较粗暴 if(((*pphead)->next)==NULL) { free((*pphead)->next); *pphead = NULL; } SLTNode *prev = NULL; SLTNode *tail = *pphead; while(tail->next) { prev = tail; tail = tail->next; } free(tail); tail = NULL; prev->next = NULL; }
头删 SListPopFront
头删相对于尾删来说简单一些,只需要一个tmp变量存储中间值,然后free掉*pphead即可,不过要注意plist传过来为空指针的情况
void SLitstPopFront(SLTNode **pphead) { if (*pphead == NULL) { return; } SLTNode *tmp = (*pphead)->next; free(*pphead); *pphead = tmp; }
查找 SListFind
查找本身不会改变plist的值,所以无需传递二级指针。
SLTNode* SListFind(SLTNode *phead, SLTDataType x) { SLTNode *pos = phead; while(pos) { if(pos->data == x) { return pos; } pos = pos->next; } return NULL; }
查找本身很简单,如何实现多次查找呢( 也就是如果想查找的值在链表内多次出现的话)
SLTNode* pos = SListFind(plist,2); int i = 1; while(pos) { printf("第%d个pos节点: %p->%d\n", i++, pos, pos->data); pos = SListFind(pos->next, 2); }
查找的同时也可以进行修改,很简单,此处就不贴代码了。
插入 SListInsert
插入接口函数需要先设计好Find函数,先查找到具体的值的位置pos,然后插入到pos位置之前。
但是由于单向链表是无法直接找到pos前一个位置的,所以我们需要再定义一个posPrev,然后找到posPrev,随后让posPrev指向newnode,newnode再指向pos。
但是我们需要注意posPrev在pos之前,如果pos就是plist指向的结构体的地址的话,程序会崩溃。在这个条件的时候,变成头插了,所以直接复用头插即可。
void SListInsert(SLTNode **pphead, SLTNode *pos, SLTDataType x) { SLTNode *newnode = BuyListNode(x); //找到pos前一个位置 if (pos == *pphead) { SListPushFront(pphead, x); } else { SLTNode *posPrev = *pphead; while (posPrev->next != pos) { posPrev = posPrev->next; } posPrev->next = newnode; newnode->next = pos; } }
我们发现,在pos前面插入是非常麻烦的,这是因为我们需要确定pos之前的位置,也就是posPrev的值,但是posPrev的值又需要考虑区分为:pos就是*pphead时(posPrev没法取值),posPrev可以取值,两种情况,非常麻烦,所以我们可以考虑在pos的后面插入。
void SListInsertAfter(SLTNode *pos, SLTDataType x) { SLTNode *newnode = BuyListNode(x); newnode->next = pos->next; pos->next = newnode; }
删除 SListErase
删除相对于插入是非常类似的。我们需要判断pos的位置来确定是否头删,如果是头删直接复用头删函数,或者很简单的贴两行代码如下:
if(*pphead == pos) { *pphead = pos->next; free(pos); }
随后需要进行的就是正常位置的删除,也就是中间删除。只需要用posPrev去找到pos的前一个位置,然后改变指向即可。
SLTNode *posPrev = *pphead; while (posPrev->next != pos) { posPrev = posPrev->next; } posPrev->next = pos->next; free(pos);
至此我们可以考虑一下如果是尾删的话会不会需要再单独列出来呢?
尾删时,pos->next = NULL; 把空指针传过去给posPrev->next完全没有问题,所以不需要单独列出来。实际上,我们在写单链表的时候,只需要考虑头部情况即可。
一个问题:在free后不是紧接着要跟一个置空吗?
free(pos); pos = NULL;
但是在接口函数里,pos只是形参,是实参的一份临时拷贝,改变pos不会影响实参的值,所以可以不加。但是为了好的习惯,建议在free后都加置空。同样的,观看整体的函数,可以发现需要讨论情况,比较繁琐。
void SListErase(SLTNode **pphead, SLTNode *pos) { assert(pphead); //头删 if (pos == *pphead) { *pphead = pos->next; free(pos); } else { SLTNode *posPrev = *pphead; while (posPrev->next != pos) { posPrev = posPrev->next; } posPrev->next = pos->next; free(pos); } }
所以我们可以使用SListEraseAfter函数,删除pos位置的后一个接口即可。
销毁 SListDestroy
销毁实际上就是把每个内存块都进行free,所以有多种做法。
比如我们可以复用尾删函数进行销毁,如下:
void SListDestroy(SLTNode **pphead) { assert(pphead); while (pphead) { SLitstPopBack(pphead); } }
同样的,我们也可以直接进行销毁,如下:
void SListDestroy(SLTNode **pphead) { assert(pphead); SLTNode *cur = pphead; while(cur) { SLTNode *next = cur->next; free(cur); cur = next; } *pphead = NULL; }
总结
单向链表是比较简单的,不外乎主要的增删查改功能,而在对单向链表的考察中,也一般是增删查改的变形,我们需要做的就是记住链表的特点,并利用其特点和优点去实现简单链表和完成相应的题目。