链表是物理存储单元上非连续的、非顺序的存储结构,它是由一个个结点,通过指针来联系起来的,其中每个结点包括数据和指针,如下图中单链表的表示。
文中统一使用以下单链表的定义:
public class Node {
//节点next引用
public Node next;
//节点的数值域
public int val;
public Node() {
}
public Node(int val) {
this.val = val;
}
}
链表的表示,头结点即表示数据结点,如下表示方法。
头结点使用哨兵节点,如下表示方法。
定义哨兵节点的好处,我们看看如果不定义哨兵节点,在链表尾部添加节点时操作。我们可以看到,对于每一个节点的加入,都需要判断head是否为空,其实只有第一个元素加入列表时这样的判断才是有意义的,后续的元素都是多此一举,还增加了额外的开销,而且不就是往末尾加一个元素吗?咋还有if else?,我们接下来看看有哨兵节点时addNode是怎么样的?
public void addNode(int val) {
//如果头结点为空,则创建头结点
if (head == null) {
head = new Node(val);
} else {
//不为空时依次向后找到根节点,在根节点的后面执行插入
Node tmp = head;
while (tmp.next != null) {
tmp = tmp.next;
}
tmp.next = new Node(val);
}
}
有哨兵节点时。有没有很简单?而且插入时还省去了额外的空判断。后续都基于有哨兵头结点的情况来进行说明。
public class ListNode {
//头结点,哨兵节点
Node head = new Node();
public ListNode() {
}
/**
* 尾插法插入节点
*
* @param val
*/
public void addNode(int val) {
//找到根节点
Node tmp = head;
while (tmp.next != null) {
tmp = tmp.next;
}
tmp.next = new Node(val);
}
}
接下来我们看下头插法插入节点,即每一次都在头部进行插入。如下图所以,要 插入节点 1。假如新加入的节点定义为newNode
(1)先执行newNode.next = head.next;
(2)执行head.next = newNode;
综上所述,头插法的代码如下。
/**
* 头插法
*
* @param val
*/
public void headInsert(int val) {
//待插入的节点
Node newNode = new Node(val);
//code-1
newNode.next = head.next;
//code-2
head.next = newNode;
}
上述代码中 code-1 和 code-2处的代码能交换位置吗?答案是肯定不行的如果先执行 code-2,即先执行head.next =newNode,会导致后续节点丢失。如下图。所以写链表相关的题目时一定得注意节点丢失的情况。必要时可以借助画图来辅助理解,当图划出来时,问题自然就清晰明了了。
删除给定节点。如下图,如果我们要删除 值为 1 的节点(node)。需要找到 待 删除节点的前一个结点(preNode),然后执行preNode.next = node.next 和 node.next = null 即可。
但是有没有更快的办法呢?如果node.next = null,表示是尾结点,那没得办法,只能找到node节点的前一个结点,如果node.next 节点不为空,表示node.next 节点 不是尾结点,那还有更快的办法。分析如下。如果要删除值为 1 的节点 node。
我们可以把node.next.val 的值 设置给node.val,如下图。
然后此时问题就相当于转换为删除node.next节点,此时完全不需要遍历找到 待删除结点node.next 的前一个节点,因为node本就是node.next的前一个结点。经分析代码如下。
/**
* 删除给定节点
*
* @param node 给定的节点,假设节点一定存在于链表中
*/
public void deleteNode(Node node) {
//表示node是尾结点
if (node.next == null) {
Node tmp = head;
while (tmp.next != node) {
tmp = tmp.next;
}
// 找到尾结点的前继结点,把尾结点删除
tmp.next = null;
} else {
//获取node的next节点
Node nodeNext = node.next;
// 将删除结点的后继结点的值赋给被删除结点
node.val = nodeNext.val;
//问题转换为删除nodeNext结点
node.next = nodeNext.next;
//删除node.next节点
nodeNext.next = null;
}
}
这种删除的是不是很巧妙?时间复杂度是O(1)
接下来我们看LeetCode上的题目,返回倒数第k个结点,此处假设k是有效的。如下链表。若 k = 2,则返回结点 4 。
如果我们要求正数的第k个结点,那比较好处理,倒数的第k个结点就稍微比较麻烦了。有如下几个方向可以考虑
1、维护一个表示链表节点数量的属性length,则倒数第k,就可以转换为正数第length-k+1。
public class ListNode {
//表示节点数量
int length = 0;
//头结点,哨兵节点
Node head = new Node();
public ListNode() {
}
//省略部分代码
}
2、遍历两遍,第一遍获取获取链表的节点数量length,然后又转换为和方法一一样的处理办法。
3、那能否一遍搞定?双指针(快慢指针)出场。其实我们可以分析下,如果我们定义一个指针slow指向倒数第k个结点,指针fast指向倒数第一个结点(尾结点),此处我们假设k为2,看下什么情况,如下图。
发现没,slow再走 k-1 步就可以与fast相遇了,那如果slow 和 fast同时指向头结点,如果fast先走 k-1 步,然后slow 和 fast再同时走,当fast走到尾结点时,slow指向的节点即为倒数第k个结点。
(1)首先定义两个节点分别指向第一个有效节点(即排除哨兵节点)
(2)fast节点先走k-1步(此处假设k为2),如下图。
(3)然后slow 和 fast同步走,当fast走到结尾时,slow节点即为倒数第k个结点,如下图。
经上面分析,代码如下。
public Node kthToLast(int k) {
//fast 和 slow 节点指向第一个结点
Node fast = head.next;
Node slow = head.next;
//节点fast 走 k -1 步
while (k > 1 && fast != null) {
fast = fast.next;
k--;
}
//fast 和 slow节点同步走,直到fast节点走到尾结点
while (fast.next != null) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
时间复杂度为O(n),空间复杂度为O(1)。
接下来看下链表中一个比较经典的问题,链表反转。
翻转前:
翻转后
其实这个题目本身还是比较好理解的。但是处理起来还是稍微有点点麻烦。但是只要想通逻辑就比较简单了。接下来从递归和迭代两个方面处理这个问题。
1、基于递归的解决方案
我们可以看下,如果除第一个元素外其他都已经翻转了,那直接把指针改一下就好。其实对于后续的每一个元素都是类似的,所以可以定义一个递归函数。
递归函数:
/**
* 翻转链表
*
* @return
*/
public void reverseList() {
Node node = reverseList(head.next);
head.next = node;
}
/**
* 翻转链表
*
* @param node
* @return
*/
private Node reverseList(Node node) {
return null;
}
递归终止条件:如果只有一个元素,如元素5时,直接返回即可。
最终的代码如下
/**
* 翻转链表
*
* @return
*/
public void reverseList() {
Node node = reverseList(head.next);
head.next = node;
}
/**
* 翻转链表
*
* @param node
* @return
*/
private Node reverseList(Node node) {
if (node.next == null) {
return node;
}
Node newHead = reverseList(node.next);
node.next.next = node;
//避免形成回路
node.next = null;
return newHead;
}
2、基于迭代的解决方案:还是使用双指针解决。我们可以看下以下链表。
(1)如果执行fast.next = slow 时会出现什么情况?如下图
(2)发现没,fast指针指向的节点与后续的节点断开了,所以我们执行fast.next = slow 时要先使用一个指针nextNode 把 关联关系记录下来,同时需要执行 slow.next = null,否则会形成环,如上图。所以修改后效果如下如。
(3)接着执行slow = fast 和 fast = nextNode,执行后如下
再接着执行(2),直到fast 为空,如下图
此时执行head.next = slow 即可完成整个链表的交换。经上面分析,代码如下,结合代码和图,应该比较好理解:
public void reverseList() {
Node slow = head.next;
//说明是空链表
if (slow == null) return;
Node fast = head.next.next;
//需要执行slow.next = null,避免形成环
slow.next = null;
while (fast != null) {
Node nextNode = fast.next;
//fast指向slow
fast.next = slow;
//slow 和 fast 后移
slow = fast;
fast = nextNode;
}
//最终执行head.next = slow 即可
head.next = slow;
}
我们接下来再来看个题目,获取链表的中间节点,如果链表有两个中间节点,即链表个数为偶数时返回第二个节点。如下图所示,第一条链表节点数量为奇数,所以直接返回中间节点3,第二条链表,节点个数为偶数个,中间节点为2 和 3 ,返回第二个即 节点3。
其实这个问题,还是可以使用双指针解决,比较巧妙。如下图,假设一段路程长度为L,有甲 乙 两个人从起点一起出发,假设甲的的速度为 v ,乙的行走速度为 2v,则乙走到终点时,甲刚好走到一半。其实这个题目本身也是类似的,我们可以定义两个节点,一个为slow每次走一步,一个为fast一次走两步,这时fast走到终点时,slow大概就指向中间位置。但是节点个数为奇数时 和 节点个数为偶数时边界条件稍微有点点区别。下面单独分析下。
如果节点个数为奇数时。起始时slow 和 fast同时从起点出发,终止条件是fast.next = null。
如果节点个数为偶数时,起始时slow 和 fast同时从起点出发,当节点走到下面这种情况时,起始还不满足我们的要求,因为有两个中间节点时我们需要的是第二个。
再走一步看看,如下图,其实终止条件就是fast == null
经过上面的分析,代码如下。
public Node middleNode() {
//起始时slow和fast指向起始节点,此处head是哨兵节点
Node slow = head.next;
Node fast = head.next;
//对应奇偶的终止条件
while (fast != null && fast.next != null) {
//slow走一步,fast走两步
slow = slow.next;
fast = fast.next.next;
}
//最终slow指向的位置就是我们需要的节点
return slow;
}
判断链表是否有环,见博文环形链表