链表(Linked List)是不同于数组的另一种数据结构,它的存储单元(即结点或元素)除了包含任意类型的数据之外,还需要包含指向另一个结点的引用,后文会用术语链接表示对结点的引用。
下面会列出链表与数组的具体不同:
(1)数组需要一块连续的内存空间来存储;而链表则恰恰相反,通过指针将零散的内存串联在一起。
(2)数组在插入和删除时,会做数据搬移,其时间复杂度是 O(n);而链表只需考虑相邻结点的指针变化,因此时间复杂度是 O(1)。
(3)当随机访问第 K 个元素时,数据可根据首地址和索引计算出对应的内存地址,其时间复杂度为 O(1);而链表则需要让指针依次遍历链接的结点,因此时间复杂度是 O(n)。
本系列中面试例题来源于LeetCode、《剑指Offer》等渠道。像下面这样以“面试题”为前缀的题目,其解法大都来源于《剑指Offer》一书。
面试题5 替换空格。合并数组,从后往前合并,减少数字移动次数。
一、链表结构
链表包含三种最常见的链表结构:单链表、双向链表和循环链表。
1)单链表
单链表的结点结构如下所示,其中next是后继指针,可链接下一个结点。
class Node { constructor(key=null) { this.next = null; this.key = key; } }
而单链表又可以细分为有头结点的单链表和无头结点的单链表,其中头结点不存储任何数据,如下图1所示。
图 1
下面以有头结点的单链表为例,演示单链表的插入、遍历和删除。
class List { constructor() { this.header = new Node(); //头结点 } add(node) { //插入 if (!this.header.next) { this.header.next = node; return; } let current = this.header; while (current.next != null) { current = current.next; } current.next = node; } traverse() { //遍历 let current = this.header.next; while (current) { console.log(current.key); current = current.next; } } del(node) { //删除 let current = this.header.next, //当前结点 prev = this.header; //前驱结点 while (current != node) { current = current.next; prev = prev.next; } if (current) { prev.next = current.next; current.next = null; } } }
尽管删除操作的时间复杂度是 O(1),但遍历查找是主要的耗时点,复杂度为 O(n)。因为在删除时需要知道前驱结点,而单链表不能直接读取,只能从头开始遍历。
面试题6 从尾到头打印链表。每经过一个结点,就放到栈中。当遍历完后,从栈顶输出。
面试题18 删除链表的结点。将结点 j 覆盖结点 i,结点 i 的next指针指向 j 的下一个结点,这样能避免获取结点 i 的前置结点。
面试题52 两个链表的第一个公共结点。分别把两个链接的结点放入两个栈中,尾结点就是两个栈的顶部,如果相同就接着比较下一个栈顶,直至找到最后一个相同结点。
2)双向链表
双向链表顾名思义包含两个方向的指针:前驱和后继,结点结构如下所示。
class Node { constructor(key = null) { this.prev = null; this.key = key; this.next = null; } }
双向链表比单链表要占更多的内存空间,依托用空间换时间的设计思想,双向链表要比单链表更加的高效。
例如之前的删除,由于已经保存了前驱结点,也就避免了多余的遍历(如下所示)。当希望在某个结点之前插入结点,双向链表的优势也很明显。
class List { add(node) { //插入 if (!this.header.next) { this.header.next = node; node.prev = this.header; return; } let current = this.header; while (current.next != null) { current = current.next; } current.next = node; node.prev = current; } del(node) { //删除 let current = this.header.next; //当前结点 while (current != node) { current = current.next; } if (current) { current.prev.next = current.next; current.next = null; } } }
3)循环链表
循环链表是一种特殊的单链表,它的尾结点的后继结点是头结点,适合处理具有环形结构的问题,例如约瑟夫环。
面试题62 圆圈中最后剩下的数字。用环形链表模拟圆圈,每删除一个数字需要 m 步运算,共有 n 个数字,时间复杂度O(mn)。
二、经典例题
1)单链表逆序
从链表的第二个结点开始,把遍历到的结点插入到头结点的后面,直至结束,例如head→1→2→3变为 head→2→1→3。
采用递归的方式完成单链表的逆序,如下所示。例题:LeetCode的206. 反转链表。
class List { reverse() { //逆序 this.recursive(this.header.next); } recursive(node) { if (!node) return; const current = node, next = current.next; if (!next) { //头结点指向逆序后链表的第一个结点 this.header.next = current; return; } this.recursive(next); /************************************ * 移动结点 * 例如Node(2).next.next就是Node(3) * 巧妙的将Node(3).next链接为Node(2) ************************************/ current.next.next = current; current.next = null; } }
2)链表中环的检测
第一种思路是缓存每个经过的结点,每到一个新结点,就判断当前序列中是否存在,如果存在,就说明访问过了。
第二种思路是使用两个指针,快指针每次前移两步,慢指针每次前移一步,当两个指针指向相同结点时,就证明有环,否则就没有环,如下所示。例题:LeetCode的141. 环形链表。
class List { isLoop() { //检测环 let fast = this.header.next, slow = this.header.next; while (fast && fast.next) { slow = slow.next; fast = fast.next.next; if (slow == fast) return true; } return false; } }
3)合并两个有序链表
用两个指针遍历两个链表,如果head1指向的数据小于head2的,则将head1指向的结点归入合并后的链表中,否则用head2的,如下所示。例题:LeetCode的21. 合并两个有序链表。
function merge(head1, head2) { let cur1 = head1.next, cur2 = head2.next, cur = null, //合并后的尾结点 head = null; //合并后的头结点 //合并后链表的头结点为第一个结点元素最小的那个链表的头结点 if (cur1.key > cur2.key) { head = head2; cur = cur2; cur2 = cur2.next; } else { head = head1; cur = cur1; cur1 = cur1.next; } //每次找链表剩余结点的最小值对应的结点连接到合并后链表的尾部 while (cur1 && cur2) { if (cur1.key > cur2.key) { cur.next = cur2; cur = cur2; cur2 = cur2.next; } else { cur.next = cur1; cur = cur1; cur1 = cur1.next; } } //当遍历完一个链表后把另外一个链表剩余的结点链接到合并后的链表后面 if (cur1 != null) cur.next = cur1; if (cur2 != null) cur.next = cur2; return head; }
4)找出链表倒数第 n 个结点
使用两个指针,快指针比慢指针先前移 n 步,然后两个指针同时移动。当快指针到底后,慢指针的位置就是所要找的结点,如下所示。例题:LeetCode的剑指 Offer 22. 链表中倒数第k个节点。
class List { findLast(n) { //删除链表倒数第 n 个结点 let slow = null, fast = null; slow = fast = this.header.next; let i = 0; //前移 n 步 while (i < n && fast) { fast = fast.next; i++; } while (fast) { fast = fast.next; slow = slow.next; } return slow; } }
5)求链表的中间结点
使用两个指针一起遍历链表。慢指针每次走一步,快指针每次走两步。那么当快指针到达链表的末尾时,慢指针必然处于中间位置,如下所示。例题:LeetCode的876. 链表的中间结点。
class List { middle() { //求链表的中间结点 let slow = this.header.next, fast = this.header.next; while (slow && fast && fast.next) { slow = slow.next; fast = fast.next.next; } return slow; } }