在上一篇文章《巧解约瑟夫环问题》中,我们提到了链表。部分同学可能对链表的数据结构还不太熟悉。今天,我们就一起来尝试手写一个链表类实现。然后,再一起来看一看与链表相关的那些算法题。
本篇文章我们依然使用Java语言实现,如果你对其它语言的实现感兴趣,请在微信公众号”欧阳锋工作室“中给我留言
常见的线性链表分为两种:单向链表与双向链表。所谓单向链表,即链表只能通过单向访问,从上一个节点可以访问到下一个节点,而下一个节点不能逆向访问到上一个节点。双向链表则没有这个限制,它既可以从上一个节点访问到下一个节点,也可以从下一个节点访问到上一个节点。
我们先来尝试实现一个单向链表,一个完整的单向链表数据结构如下图所示:
链表中的每一个元素称之为链表的节点,开始的节点叫做头节点,结束的节点叫做尾节点。
在单向链表中,节点中应该至少包含两个元素:数据(value)以及指向下一个节点的指针(next)。
参照上图,我们先构建节点数据结构:
// 为了保证通用性,这里我们使用
// 泛型参数表示节点数据的数据类型
public class Node {
public E value;
public Node next;
public Node(E value) {
this.value = value;
}
}
接下来,我们创建链表集合类,新增主要的增删改查等相关方法:
public class LinkedList {
public void add(E e) {
}
public void remove(E e) {
}
public void set(int index, E e) {
}
public E get(int index) {
}
public int size() {
}
}
由于是单向链表,这里我们只保存头节点的引用:
// 单向链表
public class LinkedList {
private Node head;
// 始终指向最新添加的节点
private Node current;
private int size;
public void add(E e) {
Node node = new Node<>(e);
// 第一次添加,将头节点指向该元素
if (null == head) {
head = node;
current = head;
} else {
current.next = node;
current = node;
}
size ++;
}
public void remove(E e) {
if (null == head) return;
// 如果当前元素恰好是头节点,直接将头节点置空
if (e == head.value) {
head = null;
size --;
return;
}
// 由于我们已知的信息只有头节点,我们必须通过
// 遍历找到对应的节点,这就是为什么说List的查询
// 效率比LinkedList效率高的原因
Node prev = head;
Node next = head.next;
while (next != null) {
if (next.value == e) {
prev.next = next.next;
size --;
// 这里要注意,如果当前节点恰好是被移除的节点
// 需要将当前节点的值指向上一个节点
if (current == next) {
current = prev;
}
break;
}
prev = next;
next = next.next;
}
}
public void set(int index, E e) {
if (index < 0 || index > size - 1) {
throw new IndexOutOfBoundsException("Valid index 0 ~ " + (size - 1) + ", current: " + index);
}
Node current = head;
for (int i = 0; i < size; i ++) {
if (index == i) {
current.value = e;
break;
}
current = current.next;
}
}
public E get(int index) {
if (index < 0 || index > size - 1) {
throw new IndexOutOfBoundsException("Valid index 0 ~ " + (size - 1) + ", current: " + index);
}
Node current = head;
for (int i = 0; i < size; i ++) {
if (index == i) {
return current.value;
}
current = current.next;
}
return null;
}
public int size() {
return size;
}
}
以上就是单向链表的完整代码了,解题的关键在于你需要在脑海里构建链表的数据结构模型,需要弄清楚节点之间是如何连接在一起的,只要搞清楚了这些关系,问题也就引刃而解了。
按照同样的思路,接下来我们开始尝试实现双向链表。
双向链表与单向链表不一样的地方在于,节点还需要持有上一个节点的引用。对应的数据模型可以用下面的图形来表示:
可以看到,在上面的图形中,我们使用last
引用指向上一个节点。同时,增加了一个尾节点引用tail
,方便逆向遍历。
按照上面的数据模型,完整代码如下:
// 双向链表
public class LinkedList {
private Node head;
private Node tail;
private int size;
public void add(E e) {
Node node = new Node<>(e);
if (null == head) {
head = node;
tail = node;
} else {
tail.next = node;
tail = node;
}
size ++;
}
public void remove(E e) {
if (null == head) return;
// 如果当前元素恰好是头节点,直接将头节点置空
if (e == head.value) {
head = null;
size --;
return;
}
Node prev = head;
Node next = head.next;
while (next != null) {
if (next.value == e) {
prev.next = next.next;
size --;
// 如果当前节点恰好是尾节点,需要将尾节点上移一位
if (next == tail) {
tail = prev;
}
break;
}
prev = next;
next = next.next;
}
}
public void set(int index, E e) {
if (index < 0 || index > size - 1) {
throw new IndexOutOfBoundsException("Valid index 0 ~ " + (size - 1) + ", current: " + index);
}
Node current = head;
for (int i = 0; i < size; i ++) {
if (index == i) {
current.value = e;
break;
}
current = current.next;
}
}
public E get(int index) {
if (index < 0 || index > size - 1) {
throw new IndexOutOfBoundsException("Valid index 0 ~ " + (size - 1) + ", current: " + index);
}
Node current = head;
for (int i = 0; i < size; i ++) {
if (index == i) {
return current.value;
}
current = current.next;
}
return null;
}
public int size() {
return size;
}
}
在上面的文章中,我们动手编写了单向链表与双向链表的代码实现。接下来,我们一起来看一下与链接相关的那些算法题吧。
这个问题可以使用两个指针p1、p2,p2先走5步,指向第6个节点。然后两个指针开始同步出发,一直到p2指向尾节点,p1恰好指向倒数第5个元素。
这个方法可以通过一次循环就获取到倒数第5个元素,时间复杂度最低,完整代码如下:
public void f(Node head) {
Node p1 = head;
Node p2 = head;
int i = 0;
while (i < 5) {
i ++;
p2 = p2.next;
}
while (p2 != null) {
p1 = p1.next;
p2 = p2.next;
}
System.out.println(p1.value);
}
这个问题可以使用一个经典的解法“快慢指针”的方式解决。
所谓的“快慢指针”,即同时使用两个指针p1、p2同步前进,p2的速度比p1快。这样就形成了“一快一慢”的效果,因此,我们将其称之为“快慢指针”。“快慢指针”在链表的算法题中起着关键性的作用,常常能够让很多看似复杂的问题简单化。例如,这个问题我们就可以通过“快慢指针”的方式解决。
具体要怎么做呢?我们一起来看一下。
假设有一个如上图所示的单向链表,链表中仅有5个元素,3、4、5形成了一个环。
想象一下,如果我们使用两个指针p1、p2,我们让p1、p2同时出发,但p1每次只向前移动一个节点,而p2每次向前移动两个节点,两者有可能相遇吗?
在没有环的情况下,很显然一定不会相遇,但在有环的情况下,一定会相遇。因为当两者同时进入链表环中时,如果有一个指针快,一个指针慢,当两者的步数差恰好是一个环的大小时二者就相遇了,这跟时钟的时针和分针一定会相遇是一样的道理。
按照这个思路,判断链表是否有环的完整代码如下:
public boolean hasRing(Node head) {
Node p1 = head;
Node p2 = head;
while (p2 != null && p2.next != null) {
p1 = p1.next;
p2 = p2.next.next;
if (p1 == p2) {
return true;
}
}
return false;
}
由此可见,“快慢指针”的确是一个非常有效的解决方案,在上述两个问题的解答中就起到了决定性的作用。大家一定要牢记这个解决方案,以便面试官问到相关的问题的时候能够迅速脱口而出。
问题二中我们可以知道一个链表中是否存在环,但如何知道这个环的大小呢。
我们继续回想时针与分针相遇的原理,分针比时针快,从第一次相遇开始,分针走的快,继续往前走,为了再次追上时针,分针应该至少多走一圈。因此,第二次相遇时分针和时针所在的距离差恰好就是环的大小。
按照这个原理,获取链表环大小的完整代码如下:
public int getRingSize(Node head) {
Node p1 = head;
Node p2 = head;
int size = 0;
int index = 0;
// 标记是否已经出现第一次相遇
int hasMeeted = false;
while (p2 != null && p2.next != null) {
p1 = p1.next;
p2 = p2.next.next;
if (hasMeeted) {
size += 1;
}
if (p1 == p2) {
// 再次相遇发现hasMeeted为true
// 表示已经是第二次相遇了,直接跳出循环
if (hasMeeted) {
break;
}
hasMeeted = true;
}
}
return size;
}
从尾到头打印链表有点像栈的数据结构模型。因此,这里我们可以使用一个栈去保存链表中的所有节点,然后pop栈顶元素,打印即可。但这不仅增加了一定的空间复杂度,也增加了一定的时间复杂度。
这个问题无非就是打印链表中的值而已,如果我们能够按照栈调用的方式对其进行调用,问题不就迎刃而解了吗。
递归恰好就是一个栈调用的方式,因此,我们完全可以使用递归巧妙地解决这个问题。
以下是使用递归调用反向打印链表数据的完整代码:
public void printReverse(Node node) {
if (node != null) {
printReverse(node.next);
System.out.println(node.value);
} else {
return;
}
}
这个问题初看起来似乎有点眼熟,是的,没错!它跟问题一比较像。在问题一中我们需要找到倒数第5个节点,而在这个问题中,我们需要找到链表的中间节点。
但这个问题的难点在于如何保证其中较慢的指针恰好停留在中间节点的位置。
其实,这很简单,我们依然可以使用“快慢指针”的方式进行处理。只要设置快指针每次走两步,而慢指针每次走一步即可。完整代码如下:
public void findMidNode(Node head) {
Node p1 = head;
Node p2 = head;
while (p2 != null && p2.next != null) {
p1 = p1.next;
p2 = p2.next.next;
}
// 这里的p1节点恰好就是中间节点
System.out.println(p1.value);
}
示例:
1->2->3->4->5->NULL
5->4->3->2->1->NULL
按照正常思维,反转链表,我们需要将链表的值先保存起来,然后再构建新的链表,再逐一连接起来。
但实际上,上面的动作其实我们可以同步进行,具体思路如下:
1)声明变量prev、curr分别指向前一个节点、当前遍历节点。
2)如果当前节点不为空,则先设置临时变量next指向curr的下一个节点。
3)让curr的下一个节点指向pre,然后再让pre指向当前节点curr, 最后让当前节点curr指向next。
4)重复以上过程,判断条件是当前节点curr不为空。
完整代码如下:
public void reverseLink(Node head) {
Node prev = null;
Node curr = head;
while (null != curr) {
Node next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
}
这道题似乎依然可以用“快慢指针”的方式解决,但问题是:如何让指针相遇的点恰好落在环的入口处呢,这似乎是一个难题。
我们继续沿用问题二中的图:
在问题二中我们使用了两个指针p1、p2,p1每次前进一步,p2每次前进两步,如果两者相遇,则说明链表中有环。
上图中整个链表只有5个元素,链表的入口节点是3,p1、p2指针第一次相遇的节点位置是4。在这个位置,p2所走的距离恰好比p1多出一个环的距离。
这是按照问题二中的设计思路推算出来的相遇节点,接下来我们的难点是尝试找到一种方法让指针恰好在入口位置处相遇。
这里我们假设:
1)头节点距离入口节点的节点数是a
2)指针第一次相遇的位置距离入口节点的节点数是b
3)相遇位置节点走到入口节点的节点数是x
4)环的节点数是r
如果p1指针走过的距离是s
,那么p2指针走过的距离应该是2s
(p2指针的速度是p1的两倍)。观察上图,我们得到如下的等式:
s = a + b
2s = a + b + r
等式二减去等式一得到s = r
。
由此我们可以知道,第一次相遇的时候,指针p1恰好走过了一个环的距离。
继续观察上图,可以得到r = b + x
。
由此我们得到几种非常重要的公式:
s = a + b
s = r
r = b + x
结合上述三个等式,去掉b、s,最终得到:
x = a
这说明一个问题:从相遇节点到头节点的距离与开始节点到头节点的距离是相等的。
所以,如果我们在指针相遇后,让p2指针重新回到头节点,并且每次只前进一步。那么,当两个指针再次相遇的时候,其节点恰好就是入口节点。
以上的推导可能有点绕,如果你还不能理解,请在微信公众号“欧阳锋工作室”给我留言。
知道了原理之后,代码就简单了。以下是上述思路的完整代码实现:
public Node getRingEntryNode(Node head) {
Node p1 = head;
Node p2 = head;
while (p2 != null && p2.next != null) {
p1 = p1.next;
p2 = p2.next.next;
if (p1 == p2) {
break;
}
}
// 第一相遇后,我们让p2节点重新指向头节点
p2 = head;
// 再次进入循环,直到二者再次相遇
while (p1 != null) {
p1 = p1.next;
p2 = p2.next;
// 两者再次相遇,相遇节点即是环
// 的入口节点
if (p1 == p2) {
return p1;
}
}
return null;
}
示例:
输入:
1->3->5
2->3->6
输出:
1->2->3->3->5->6
这道题完全可以使用循环的方式处理,先取出两个链表中的第一个元素,取第一个元素较小的值放在表头,并且将该链表作为目标链表。逐一对比,拼接到链表指定位置,直到某个链表为空。如果被对比链表更长,则将剩余部分的元素直接连接到目标链表即可。
这是一种常规的解法,事实上,这道题还可以使用递归的方式进行处理。实现的思路与循环类似,只是代码层面更容易理解,且代码量更少,其完整实现如下:
public Node mergeOrderedLink(Node head1, Node head2) {
// 先判断存在空链表的情况
if (null == head1) {
return head2
} else if (null == head2) {
return head1
} else {
Node newHead = null;
if (head1.data < head2.data) {
newHead = head1;
newHead.next = mergeOrderedLink(head1.next, head2);
} else {
newHead = head2;
newHead.next = newHead.next = mergeOrderedLink(head1, head2.next);
}
return newHead;
}
}
以上就是我们在面试中可能会遇到的常见的八道链表算法面试题。最后,我给大家准备了一道压轴题,大家先尝试做一下。答案可以在微信公众号“欧阳锋工作室”中回复“链表”获得。
阅读更多文章,请关注公众号“欧阳锋工作室”