链表的基础知识
链表是一种常见的基础数据结构。在链表中,每个节点包含指向下一个节点的指针,这些指针把节点连接成链状结构
在创建链表时无须事先知道链表的长度……
和数组相比,链表更适合用来存储一个大小动态变化的数据集……
链表的创建、插入节点、删除节点等操作都只需要 20 行左右的代码就能实现,其代码量比较适合作为面试题。由于链表是一种动态的数据结构,其操作是针对指针进行的,因此应聘者需要有较好的编程功底才能编写出正确的操作链表的代码。另外,链表这种数据结构很灵活,面试官可以用链表设计出具有挑战性的面试题。基于上述几个原因,与链表相关的题目深受面试官的青睐
大多数出现在算法面试题中的链表都是单向链表。单向链表的节点包含指向下一个节点的指针……
哨兵节点
哨兵节点是为了简化处理链表边界条件而引入的附加链表节点。哨兵节点通常位于链表的头部,它的值没有任何意义。在一个有哨兵节点的链表中,从第 2 个节点开始才真正保存有意义的信息
……
双指针
所谓双指针是指利用两个指针来解决与链表相关的面试题,这是一种常用思路。双指针思路又可以根据两个指针不同的移动方式细分成两种不同的方法。第 1 种方法是前后双指针,即一个指针在链表中提前朝着指向下一个节点的指针移动若干步,然后移动第 2 个指针。前后双指针的经典应用是查找链表的倒数第 k 个节点。先让第 1 个指针从链表的头节点开始朝着指向下一个节点的指针先移动 k-1 步,然后让第 2 个指针指向链表的头节点,再让两个指针以相同的速度一起移动,当第 1 个指针到达链表的尾节点时第 2 个指针正好指向倒数第 k 个节点
第 2 种方法是快慢双指针,即两个指针在链表中移动的速度不一样,通常是快的指针朝着指向下一个节点的指针一次移动两步,慢的指针一次只移动一步。采用这种方法,在一个没有环的链表中,当快的指针到达链表尾节点的适合慢的指针正好指向链表的中间节点
下面采用双指针思路解决几道典型的链表面试题
面试题 21:删除倒数第 k 个节点
题目:如果给定一个链表,请问如何删除链表中的倒数第 k 个节点?假设链表中节点的总数为 n,那么 1≤ k≤ n。要求只能遍历链表一次
public static ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode front = head, back = dummy;
for (int i = 0; i < n; i++) {
front = front.next;
}
while (front != null) {
front = front.next;
back = back.next;
}
back.next = back.next.next;
return dummy.next;
}
由于当 k 等于链表的节点总数时,被删除的节点为原始链表的头节点,上述代码的逻辑也可以简化,运用哨兵节点来避免单独处理删除头节点的情况
面试题 22:链表中环的入口节点
题目:如果一个链表中包含环,那么应该如何找出环的入口节点?从链表的头节点开始顺着 next 指针方向进入环的第 1 个节点为环的入口节点
private static ListNode getNodeInLoop(ListNode head) {
if (head == null || head.next == null) {
return null;
}
ListNode slow = head.next;
ListNode fast = slow.next;
while (slow != null && fast != null) {
if (slow == fast) return slow;
slow = slow.next;
fast = fast.next;
if (fast != null) fast = fast.next;
}
return null;
}
public static ListNode detectCycle(ListNode head) {
ListNode inLoop = getNodeInLoop(head);
if (inLoop == null) {
return null;
}
int loopCount = 1;
for (ListNode n = inLoop; n.next != inLoop; n = n.next) {
loopCount++;
}
ListNode fast = head;
for (int i = 0; i < loopCount; i++) fast = fast.next;
ListNode slow = head;
while (fast != slow) {
fast = fast.next;
slow = slow.next;
}
return slow;
}
假设相遇时慢的指针一共走了 k 步……因此在相遇时快的指针一共走了 2k 步……另外,两个指针相遇时快的指针比慢的指针在环中多转了若干圈。也就是说……k 一定是环中节点数目的整数倍,此时慢的指针走过的步数 k 也是环中节点数的整数倍
由于之前的慢指针是从 head 节点出发的,那么若(借助同步指针)再走一段从 head 节点到环的入口节点的路程 n,则原指针走了 n+k 的路程,应正指向环的入口,与同步指针指向相同的节点
面试题 23:两个链表的第 1 个重合节点
题目:输入两个单向链表,请问如何找出它们的第 1 个重合节点
public static ListNode getIntersectionNode(ListNode headA, ListNode headB) {
int count1 = countList(headA);
int count2 = countList(headB);
int delta = Math.abs(count1 - count2);
ListNode longer = count1 > count2 ? headA : headB;
ListNode shorter = count1 > count2 ? headB : headA;
ListNode node1 = longer;
for (int i = 0; i < delta; i++) {
node1 = node1.next;
}
ListNode node2 = shorter;
while (node1 != node2) {
node2 = node2.next;
node1 = node1.next;
}
return node1;
}
private static int countList(ListNode head) {
int count = 0;
while (head != null) {
count++;
head = head.next;
}
return count;
}
反转链表
单向链表的最大特点就是其单向性,只能顺着指向下一个节点的指针方向从头到尾遍历链表而不能反向遍历。这种特性用一句古诗来形容正合适:黄河之水天上来,奔流到海不复回
有些面试题只有从链表尾节点开始遍历到头节点才容易解决。这个时候可以先将链表反转,然后在反转的链表中从头到尾遍历,这就相当于在原来的链表中从尾到头遍历
下面介绍如何反转链表,以及如何利用反转链表来解决典型的算法面试题:
面试题 24:反转链表
题目:定义一个函数,输入一个链表的头节点,反转该链表并输出反转后链表的头节点
public static ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
}
在上述代码中,变量 cur 指向当前遍历到的节点,变量 prev 指向当前节点的前一个节点,而变量 next 指向下一个节点。每遍历一个节点之后,都让变量 prev 指向该节点。在遍历到尾节点之后,变量 prev 最后一次被更新,因此,变量 prev 最终指向原始链表的尾节点,也就是反转链表的头节点
显然,上述代码的时间复杂度是 O(n),空间复杂度是 O(1)
面试题 25:链表中的数字相加
题目:给定两个表示非负整数的单向链表,请问如何实现这两个整数的相加并且把它们的和仍然用单向链表表示?链表中的每个节点表示整数十进制的一位,并且头节点对应整数的最高位数而尾节点对应整数的个位数
public static ListNode addTwoNumbers(ListNode head1, ListNode head2) {
head1 = reverseList(head1);
head2 = reverseList(head2);
ListNode reversedHead = addReversed(head1, head2);
return reverseList(reversedHead);
}
private static ListNode addReversed(ListNode head1, ListNode head2) {
ListNode dummy = new ListNode(0);
ListNode sumNode = dummy;
int carry = 0;
while (head1 != null || head2 != null) {
int sum = (head1 == null ? 0 : head1.val)
+ (head2 == null ? 0 : head2.val) + carry;
carry = sum >= 10 ? 1 : 0;
sum = sum >= 10 ? sum - 10 : sum;
ListNode newNode = new ListNode(sum);
sumNode.next = newNode;
sumNode = sumNode.next;
head1 = head1 == null ? null : head1.next;
head2 = head2 == null ? null : head2.next;
}
if (carry > 0) {
sumNode.next = new ListNode(carry);
}
return dummy.next;
}
不可以使用链表转整数相加再转回的方法,因为这可能会导致溢出
面试题 26:重排链表
题目:给定一个链表,链表中节点的顺序是 L0→L1→L2→……→Ln-1→Ln,请问如何重排链表使节点的顺序变成 L0→Ln→L1→Ln-1→L2→Ln-2→……?
public static void reorderList(ListNode head) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode fast = dummy;
ListNode slow = dummy;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next;
if (fast.next != null) {
fast = fast.next;
}
}
ListNode temp = slow.next;
slow.next = null;
link(head, reverseList(temp), dummy);
}
private static void link(ListNode node1, ListNode node2, ListNode head) {
ListNode prev = head;
while (node1 != null && node2 != null) {
ListNode temp = node1.next;
prev.next = node1;
node1.next = node2;
prev = node2;
node1 = temp;
node2 = node2.next;
}
if (node1 != null) {
prev.next = node1;
}
}
面试题 27:回文链表
题目:如何判断一个链表是不是回文?要求解法的时间复杂度是 O(n),并且不得使用超过 O(1) 的辅助空间
public static boolean isPalindrome(ListNode head) {
if (head == null || head.next == null) {
return true;
}
ListNode slow = head;
ListNode fast = head.next;
while (fast.next != null && fast.next.next != null) {
fast = fast.next.next;
slow = slow.next;
}
ListNode secondHalf = slow.next;
if (fast.next != null) {
secondHalf = slow.next.next;
}
slow.next = null;
return equals(secondHalf, reverseList(head));
}
private static boolean equals(ListNode head1, ListNode head2) {
while (head1 != null && head2 != null) {
if (head1.val != head2.val) {
return false;
}
head1 = head1.next;
head2 = head2.next;
}
return head1 == null && head2 == null;
}
通过解决这几道典型的面试题可以发现,反转链表是面试中经常出现的操作,所以能熟练、正确地编写出反转链表的代码非常重要
双向链表和循环链表
由于单向链表只能从头节点开始遍历到尾节点,遍历的顺序受到限制,在很多场景下使用起来不是很方便,因此双向链表应运而生……
由于双向链表的每个节点多了一个指针,因此在双向链表中添加、删除节点等操作要稍微复杂一些。如果应聘者遇到双向链表的面试题,就要格外小心,确保节点的每个指针都指向了正确的位置
如果把链表尾节点指向下一个节点的指针指向链表的头节点,那么此时链表就变成一个循环链表,相当于循环链表的所有节点都位于一个环中。循环链表既可以是单向链表也可以是双向链表。即使一个循环链表是单向链表,也可以从任意节点出发到达另一个任意节点,因此,在循环链表中任意节点都可以当作链表的头节点
面试题 28:展平多级双向链表
题目:在一个多级双向链表中,节点除了有两个指针分别指向前后两个节点,还有一个指针指向它的子链表,并且子链表也是一个双向链表,它的节点也有指向子链表的指针。请将这样的多级双向链表展平成普通的双向链表,即所有节点都没有子链表
public static Node flatten(Node head) {
flattenGetTail(head);
return head;
}
private static Node flattenGetTail(Node head) {
Node node = head;
Node tail = null;
while (node != null) {
Node next = node.next;
if (node.child != null) {
Node child = node.child;
Node childTail = flattenGetTail(node.child);
node.child = null;
node.next = child;
child.prev = node;
childTail.next = next;
if (next != null) {
next.prev = childTail;
}
tail = childTail;
} else {
tail = node;
}
node = next;
}
return tail;
}
由于子链表中的节点也可能有子链表,因此这里的链表是一个递归的结构。在展平子链表时,如果它也有自己的子链表,那么它嵌套的子链表也要一起展平……
这种解法每个节点都会遍历一次,如果链表总共有 n 个节点,那么时间复杂度是 O(n)。函数 flattenGetTail 的递归调用次数取决于链表嵌套的层数,因此,如果链表的层数为 k,那么该节点的空间复杂度是 O(k)
面试题 29:排序的循环链表
题目:在一个循环链表中节点的值递增排序,请设计一个算法在该循环链表中插入节点,并保证插入节点之后的链表仍然是排序的
public static ListNode insert(ListNode head, int insertVal) {
ListNode node = new ListNode(insertVal);
if (head == null) {
head = node;
head.next = head;
} else if (head.next == head) {
head.next = node;
node.next = head;
} else {
insertCore(head, node);
}
return head;
}
private static void insertCore(ListNode head, ListNode node) {
ListNode cur = head;
ListNode next = head.next;
ListNode biggest = head;
while (!(cur.val <= node.val && next.val >= node.val) && next != head) {
cur = next;
next = next.next;
if (cur.val >= biggest.val) biggest = cur;
}
if (cur.val <= node.val && next.val >= node.val) {
cur.next = node;
node.next = next;
} else {
node.next = biggest.next;
biggest.next = node;
}
}
本章小结
本章详细讨论了链表这种基础数据结构。由于节点在内存中的地址不连续,访问某个节点必须从头节点开始逐个遍历节点,因此在链表中找到某个节点的时间复杂度是 O(n)
如果一个操作可能产生新的头节点,则可以尝试在链表的最前面添加一个哨兵节点来简化代码逻辑,降低代码出现问题的可能性
双指针是解决与链表相关的面试题的一种常用技术。前后双指针思路让一个指针提前走若干步,然后将第 2 个指针指向头节点,两个指针以相同的速度一起走。快慢双指针让快的指针每次走两步而慢的指针每次只走一步
大部分与链表相关的面试题都是考查单向链表的操作。单向链表的特点是只能从前往后遍历而不能从后往前遍历。如果不得不从后往前遍历链表,则可以把链表反转之后再遍历
如果链表中的节点除了有指向下一个节点的指针,还有指向前一个节点的指针,那么该链表就是双向链表。由于双向链表的操作牵涉到的指针比较多,因此应聘者在解决面试题的时候要格外小心,确保每个指针都指向了正确的位置
循环链表是一种特殊形态的链表,它的所有节点都在一个环中。在解决与循环链表相关的面试题时需要特别注意避免死循环,遍历链表时等所有节点都遍历完就要停止,不能一直在里面绕圈子出不来