链表是一种基本数据结构,因为链表使用过程中指来指去的指针让大家抓狂,以至于大家面试前总是要特意看下链表相关知识。今天,我来带大家学习「链表」(Linked list) 这个数据结构。
我们总是拿链表和数组来进行比较,不同于数组需要连续内存,链表并不需要一块连续的内存,它通过「指针」将一组零散的内存块串联起来使用。
了解了链表的官方定义后,我们来看看链表有那些结构。链表包括单链表、循环链表、双向链表、双向循环链表、静态链表。我们先来看最简单、最常用的单链表。
链表通过指针将一组零散的内存块串联起来。其中,内存块称为链表的「结点」。为了将所有结点串起来,每个链表的结点除了存储数据外,还需要记录链上的下一个结点位置的指针。对于单链表,我们把记录下一个结点位置的指针叫做后继指针 next。
从我给你的单链表插图我们可以看出第一个结点和最后一个结点比较特殊。它们分别被称为头结点和尾节点。头结点用来记录链表的基地址,我们可以遍历得到整条链表。而尾节点的指针不是指向下一个结点,而是指向一个空地址 Null,表示这是链表上最后一个结点。
对于链表的插入和删除操作,由于只涉及前后结点指针的改变,所以对应的时间复杂度为 O(1)。但是,链表要是想要随机访问第 k 个元素就没那么简单了,和数组靠寻址公式随机访问不同。我们需要根据指针遍历得到相应结点。
其实链表相当于一个队伍,队伍里面的每一个人只知道自己的前面一个人和后面一个人,当我们去找某个人时,需要从第一个人往后数来找到某个特定的人。所以链表随机访问第 k 个元素的时间复杂度是 O(n)。
循环链表其实就是一种特殊的单链表。单链表的尾节点指向 null。循环链表的尾节点指向头结点,就像贪吃蛇吃了自己的尾巴一样,形成了一个环。所以我们管它叫循环链表。循环链表适用于解决约瑟夫问题。
双向链表不同于单向链表只有一个 next 指针指向后面的结点,双向链表不只有一个 next 指针指向后面的结点,还有一个前驱指针 prev 指向前面的结点。
那么单链表和双向链表到底有什么区别呢?我举链表的删除操作给你举例。
在实际的软件开发中链表的删除操作无外这非两种情况:
- 删除结点中「值等于某个给定值」的结点;
- 删除给定指针指向的结点。
对于第一种情况,单链表和双链表都需要从头结点开始遍历对比,直到找到等于给定值的结点,然后通过指针操作将其删除。
单纯的删除操作时间复杂度是 O(1),但遍历查找结点的时间是主要耗时点,对应的时间复杂度为 O(n)。根据时间时间度的加法原则,删除操作的时间复杂度为 O(n)。这种情况两种链表没有多大区别。
第二种情况则不一样,我们已经知道了给定的结点了,对于单链表来说,假设要删除的结点为 q, 我们还要知道 q 的前驱结点 p,我们还是要遍历链表,直到 p->q,找到 p 结点。对于双向链表情况则有所不同,我们已经有了记录前驱结点的前驱指针 prev,所以我们执行删除操作的时间复杂度为 O(1)。看出来了吧,在删除给定指针指向的结点的情况下,我们选择双向链表存储数据在 O(1) 的时间复杂度下就搞定了!而单链表需要 O(n) 的时间复杂度。
同理,对于在链表的某个特定结点 q 前插入一个新的结点,双向链表执行插入操作的时间复杂度为 O(1)。而单链表则为 O(n)。你可以自己分析一下原因。
对于有序链表,双向链表的查询效率也高于单链表。因为我们可以上次查询的结点 p, 每次查找时,我们决定往前查找还是往后查,所以平均只需要一半的数据。
学习完了链表的基础知识,下面我们进入重点,讲解面试中常考的链表相关知识。
单链表反转,合并有序链表,检测链表中是否有环。这些都是高频考点,今天我带你逐个击破。
在开始上代码前,我带你了解下写链表代码的基础知识。
我们知道指针存储了变量的内存地址,指向了这个变量,我们通过这个指针就能找到这个变量。
上面这段话可能有点拗口,没关系,我们这样理解。我们写链表时,经常会写 p->next = q。这是指 p 结点的 next 指针存储了 q 结点的内存地址,也就是 p 指向了 q。类似的还有 p->next = p->next->next。这句代码的意思是我们删除了结点 p 的后继结点(即 p 后面的那个结点)。因为我们把 p 结点的 next 指针指向了存储 p 结点的下下一个结点的内存地址。
好吧,你可能看完之后更晕了。你可以返回去多看几遍,还可以在纸上画出来。我用的是 C 语言的指针概念,没学过的同学把它理解成 Java 的引用。
在写链表代码时我们重点要留意边界条件,主要是空链表和只有一个结点的链表,我们的代码是否还能正常工作。
好了,激动人心的时刻来了,上代码吧!
单链表反转:
我用的是 Java 语言实现的,用其他语言的同学不用害怕,思路都是一样的,我会加上注释的。
public static Node reverse(Node head) {
Node previousNode = null;
Node headNode = null; // 要返回的新的头结点
Node currentNode = head; // 头结点指向 currentNode
while(currentNode != null) {
Node Next = currentNode.next; // 当前结点的下一个结点
// 如果下一个结点指向 null, 我们把当前结点给 headNode,返回 headNode。
if( Next == null ) { headNode = currentNode; }
currentNode.next = previousNode;
previousNode = currentNode;
currentNode = Next;
} return headNode;
}
单链表中检测是否有环
public boolean checkCircle(Node head) {
// 边界条件
if(head == null || head.next == null ) { return false; }
// 快慢指针,如果有环,在环内结点相遇,即 slow == fast
Node fast = head;
Node slow = head;
while(fast != null && fast.next != null ) {
fast = fast.next.next;
slow = slow.next;
if( slow == fast) {
return true; }
}
return false;
}
有序链表合并
public static Node merge(Node p1, Node p2) {
// 还是边界条件,面试中检查边界条件特别重要,切记。
if( p1 == null) { return p2; }
if(p2 == null ) { return p1; }
// 确定头结点
Node p = p1;
Node q = p2;
Node headNode;
if(p.data < q.data ) {
headNode = p;
p = p.next;
} else {
headNode = q;
q = q.next;
}
// 结点 r 穿针引线把新的有序链表连起来。
Node r = headNode;
while( p != null && q != null) {
if( p.data < q.data) {
r.next = p;
p = p.next;
} else {
r.next = q;
q = q.next;
}
// 做出一轮比较后,更新 r 结点。
r = r.next;
}
// 把剩下的结点连起来
if(q != null )
{
r.next = q;
} else {
r.next = p; }
return headNode;
}
好了,如果你能看懂并且把我上面的三个链表操作写熟练,熟能生巧,我保证你不会再害怕手写链表了。
最后留一个思考题:在如何检测环的基础上,给一个链表,若其中包含环,请找出该链表的环的入口结点,否则,输出null。思考一下,在评论区给我答案。
对你有帮助的话,给我个喜欢!