链表是面试里面经常涉及到的考点,因为链表的结构相比于 Hashmap
、Hashtable
、Concurrenthashmap
或者图等数据结构简单许多,对于后者更多面试的侧重点在于其底层实现。比如 Hashmap
中 Entry<k,v>
等操作、如何扩容、容量的设定等。链表的考察更侧重于代码的书写和思路的形成。虽然说,链表的结构简单,但是涉及到指针的操作,容易引申出一些挑战性的考题,其中也牵涉到诸多小的细结的考虑,更能看出代码书写的能力和功底。
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
链表的存取从头指针(Head
)开始,头指针表示链表中第一个结点的存储位置。同时由于最后一个结点没有后续数据,则线性链表中最后一个结点的指针为“空”(NULL
)。
反转链表,又可以称为翻转或逆置链表,它们表达的是同一个意思。把上图所示的有序链表反转后,得到新的有序链表。
常用的实现链表反转的方案有四种,这里分别将它们称为迭代反转法、递归反转法、头插法和原地逆置法。
关于链表的增删改查问题,我在另一篇博客《【面试分享】嵌入式面试题常考难点之关于单链表的增删改查》中做了详细的讨论,欢迎有需要的小伙伴查阅。
一、迭代反转法
从当前链表的第一个结点开始遍历整个链表,期间逐个改变所遍历的结点的指针域,令其指向前一个结点。具体实现方法需要借助三个结点指针,如下图,分别把三个指针命名为 beg
、mid
、end
。
遍历链表的过程就是三个指针一起向链表结尾逐步偏移,直到 end
指针指向 NULL
为止。这个过程中,由mid
指针改变它所指向的结点的指针域,令其指向beg
所指向的结点。如下图:
最后只需改变Head
头指针的指向,令其和mid
同向,就实现了链表的反转。
代码实现如下:
//Demo1.c
#include <stdio.h>
typedef struct Node
{
int data;
struct Node *next;
}NODE;
void printLink(NODE *phead)
{
while (phead != NULL) {
printf("%d ", phead->data);
phead = phead->next;
}
printf("\n");
}
NODE *iteration(NODE *phead)
{
if (phead == NULL || phead->next == NULL) //判断头指针是否指向链表头结点,或者链表是否存在
return phead;
NODE *beg = NULL;
NODE *mid = phead;
NODE *end = phead->next;
while (1) {
mid->next = beg; //修改mid指向的结点的指针域,令其指向beg所指的结点
if (end == NULL) //判断end是否为空,如果为空表示链表已经遍历完毕
break;
beg = mid; //整体偏移三个指针
mid = end;
end = end->next;
}
phead = mid; //遍历结束后,链表完成反转,修改头指针指向新的链表头结点
return phead;
}
int main()
{
NODE t5 = {5, NULL}; //只为做验证,用了静态创建链表的方式,实际编程中不会用这种方式
NODE t4 = {4, &t5};
NODE t3 = {3, &t4};
NODE t2 = {2, &t3};
NODE t1 = {1, &t2};
NODE *head = &t1; //创建头指针,用此方法反转链表可以不需要用到头指针,后面的函数的形参可以直接使用&t1,结果是一样的
printf("链表反转前:\n");
printLink(head);
printf("链表反转后:\n");
head = iteration(head);
printLink(head);
return 0;
}
运行结果:
二、递归反转法
递归反转法的实现思想是从链表的尾结点开始,依次向前遍历,遍历过程依次改变各结点的指向,即令其指向前一个结点。这种方法一般会在函数中建立一个新的头指针,通过层层递进的方式找到链表尾结点,然后将新的头指针指向尾结点,再层层返回把每个遍历后的结点都指向上一个结点,最后令原先的头结点指向 NULL
,使其成为链表反转后,新链表的尾结点,并返回新的头指针。
代码实现如下:
// Demo2.c
// 代码与 Demo1.c 重复,这里只展示代码片断,替换到 Demo1.c 效果一致。
NODE *iteration(NODE *phead)
{
if (phead == NULL || phead->next == NULL) {
return phead;
} else {
/*一直递归,直到找到链表中最后一个结点
*当逐层退出时,new_head一直指向原链表中最后一个结点
*而函数中phead指针在每层退出后,都指向上一个结点
*/
NODE *new_head = iteration(phead->next);
phead->next->next = phead; //每退出一层,都需要改变phead->next结点指针域的指向
phead->next = NULL; //同时令phead所指结点的指针域为NULL
return new_head;
}
}
三、头插法
头插法一般用于链表的创建,这里用的头插法大致相同,就是将原链表的结点从链表头逐个取下,并用头插法创建链表的方式重新建立一个反向排序的链表,已达到反转链表的效果。具体做法如下图:
代码实现如下:
// Demo3.c
NODE *iteration(NODE *phead)
{
NODE *new_head = NULL; //创建一个新的头指针
NODE *temp = NULL; //临时的结点指针,用于转接
if (phead == NULL || phead->next == NULL)
return phead;
while (phead != NULL) {
temp = phead; //旧链表头指针指向的结点放进temp
phead = phead->next; //头指针指向下一个结点,相当于上个结点已经剔除于链表之外,
temp->next = new_head; //被剔除的结点从新链表头部插入
new_head = temp; //新链表的头指针指向新加入的结点
}
return new_head;
}
四、原地逆置法
这个方法和头插法类似,区别在于头插法是通过摘除旧的结点重新排列成新的链表,而原地逆置法则是直接对原链表做修改。这就需要借助两个结点指针进行链表结点的标记,再通过遍历链表逐个逆置结点。具体过程如下图:
代码实现如下:
// Demo4.c
NODE *iteration(NODE *phead)
{
NODE *beg = NULL;
NODE *end = NULL;
if (phead == NULL || phead->next == NULL)
return phead;
beg = phead;
end = phead->next;
while (end != NULL) {
beg->next = end->next; //将end所指向的结点拿出
end->next = phead; //将end所指向的结点放在链表头
phead = end; //头指针指向现在的链表头
end = beg->next; //调整end的指向,指向下一个要拿出的结点
}
return phead;
}
总结
- 使用迭代反转法实现时,初始状态忽略头结点(直接将
mid
指向首元结点),仅需在最后一步将头结点的next
改为和mid
同向即可; - 使用头插法或者原地逆置法实现时,仅需将要插入的结点插入到头结点之前即可;
- 递归法并不适用反转有头结点的链表(但并非不能实现),该方法更适用于反转无头结点的链表。