前言
List 的两个重要实现类 ArrayList 和 LinkedList,各自的特点都是非常明显的。
ArrayList:
- 底层是数组,在逻辑上和物理上来说都是连续的,也就是说数组在内存上是存储在连续的空间里面的,这种存储的优势是通过 index(索引)可以实现快速查找和随机访问,操作复杂度只是O(1)。
- ArrayList 的缺点也很显然。对于插入和删除操作,每次插入或者删除元素,都需要对插入位置后面的元素进行后移或前移操作;如果数组已满还需要进行空间申请、拷贝数据、释放空间的一系列扩容操作,最差时间复杂度达到了O(N),对于规模大的数组来说不太友好。
LinkedList:
- 相比于ArrayList来说有着很大的不同。它将每个元素定义为一个节点,在节点中又申请了一块空间用于存放下一个节点的指针(pointer)。由于有指针的存在,链表在内存中一般来说都不是连续的,充分利用了内存中的碎片空间。
- 链表的插入、删除操作时间复杂度由于指针的存在变成了O(1)。但是,链表是不支持随机访问的,每次查找或者访问都需要遍历链表,从最差角度和平均角度来看,时间复杂度是O(N)。
LinkedList 和 ArrayList 作为基本的数据结构,在程序设计中经常用到,根据场景的不同使用不同的数据结构也是能够有很好的效果。相比较而言链表更抽象,更难理解,所以对链表 LinkedList 的实现以及 leetcode 刷过的所有链表题型进行一个归纳总结。
一、LinkedList(链表)
链表总体来说有很多类型,这里只是记录一下常用的单向链表、双向链表以及循环链表。
1. 单向链表
单向链表比较简单,每个节点只有两个域:数据域(val)和指针域(next),指针域 next 指向下一个节点,尾结点的 next 是 null 。
1.1 单向链表节点定义
/**
* Definition for singly-linked list.
*/
class SinglyNode {
int val;// 数据域
SinglyNode next;// 指针域
public SinglyNode(int val) {
this.val = val;
}
}
1.2 单向链表的实现
public class MySinglyLinkedList {
/**
* Definition for singly-linked list.
*/
class SinglyNode {
int val;
SinglyNode next;
public SinglyNode(int val) {
this.val = val;
}
}
public SinglyNode head;// 头结点
/**
* 头插法
*/
public void addFirst(int val) {
SinglyNode node = new SinglyNode(val);
// 第一次插入结点
if (this.head == null) {
this.head = node;
return;
}
node.next = this.head;
this.head = node;
}
/**
* 尾插法
*/
public void addLast(int val) {
SinglyNode node = new SinglyNode(val);
// 第一次插入结点
if (this.head == null) {
this.head = node;
return;
}
// 遍历到尾部
SinglyNode cur = this.head;
while (cur.next != null) {
cur = cur.next;
}
// 插入结点
cur.next = node;
}
/**
* 任意位置插入
*/
public void addIndex(int index, int val) {
// index 不合法
if (index < 0 || index > size()) return;
// 下标为 0, 头插法
if (index == 0) {
addFirst(val);
return;
}
// 查找 index 的前驱
SinglyNode prev = searchPrev(index);
SinglyNode node = new SinglyNode(val);
// 插入
node.next = prev.next;
prev.next = node;
}
/**
* 查找 index 结点的前驱
*/
public SinglyNode searchPrev(int index) {
int count = 0;
SinglyNode cur = this.head;
while (count < index-1) {
cur = cur.next;
count++;
}
return cur;
}
/**
* 查找在单链表当中是否包含关键字 key
*/
public boolean contains(int val) {
SinglyNode cur = this.head;
while (cur != null) {
if (cur.val == val) {
return true;
}
cur = cur.next;
}
return false;
}
/**
* 删除第一次出现关键字为key的节点
*/
public void remove(int val) {
if (this.head.val == val) {
this.head = this.head.next;
return;
}
SinglyNode cur = this.head;// 遍历查找关键字
SinglyNode prev = null;// 遍历结点的前驱结点
// 查找关键字
while (cur != null) {
if (cur.val == val) {
prev.next = cur.next;
return;
}else {
prev = cur;
cur = cur.next;
}
}
}
/**
* 删除所有值为key的节点
*/
public void removeAllKey(int val) {
SinglyNode prev = this.head;
SinglyNode cur = this.head.next;
while (cur != null) {
if (cur.val == val) {
prev.next = cur.next;
}else {
prev = cur;
}
cur = cur.next;
}
// 判断头结点是否是删除结点
if (this.head.val == val) {
this.head = this.head.next;
}
}
/**
* 获取单链表的结点个数
*/
public int size() {
SinglyNode cur = this.head;
int count = 0;
while (cur != null) {
count++;
cur = cur.next;
}
return count;
}
/**
* 打印单链表
*/
public void display() {
SinglyNode cur = this.head;
while (cur != null) {
System.out.print(cur.val + " ");
cur = cur.next;
}
System.out.println();
}
/**
* 删除单链表所有结点
*/
public void clear() {
this.head = null;
}
public static void main(String[] args) {
MySinglyLinkedList mySinglyList = new MySinglyLinkedList();
// 添加元素
mySinglyList.addFirst(1);
mySinglyList.addFirst(2);
mySinglyList.addIndex(0,1);
mySinglyList.addIndex(3,4);
mySinglyList.addLast(1);
mySinglyList.addLast(5);
// 打印单链表
mySinglyList.display();// 1 2 1 4 1 5
// 查找在单链表当中是否包含关键字 key
System.out.println(mySinglyList.contains(5));// true
System.out.println(mySinglyList.contains(6));// false
// 删除第一次出现关键字为key的节点
mySinglyList.remove(5);
mySinglyList.display();// 1 2 1 4 1
// 删除所有出现的关键字
mySinglyList.removeAllKey(1);
mySinglyList.display();// 2 4
// 清空单链表
mySinglyList.clear();
mySinglyList.display();//
}
}
2. 双向链表
双向链表就较为复杂了,一个节点有三个域:一个数据域(val)和两个指针域(prev、next),其中 prev 指向当前节点的前一个节点(前驱节点),next 指向当前节点的下一个节点(后继节点)。当然,头部节点的前驱节点是 null,尾部节点的后继节点是 null。
2.1 双向链表节点的定义
/**
* Definition for doubly-linked list.
*/
class DoublyNode {
int val;// 数据域
DoublyNode next;// 指向后继节点
DoublyNode prev;// 指向前驱节点
public DoublyNode(int val) {
this.val = val;
}
}
2.2 双向链表的实现
public class MyDoublyLinkedList {
/**
* Definition for doubly-linked list.
*/
class DoublyNode {
int val;
DoublyNode next;
DoublyNode prev;
public DoublyNode(int val) {
this.val = val;
}
}
public DoublyNode head;// 头结点
public DoublyNode rear;// 尾结点
/**
* 头插法
*/
public void addFirst(int val) {
DoublyNode node = new DoublyNode(val);
// 第一次插入
if (this.head == null) {
this.head = node;
this.rear = node;
}else {
node.next = this.head;
this.head.prev = node;
this.head = node;
}
}
/**
* 尾插法
*/
public void addLast(int val) {
DoublyNode node = new DoublyNode(val);
// 第一次插入
if (this.head == null) {
this.head = node;
this.rear = node;
}else {
this.rear.next = node;
node.prev = this.rear;
this.rear = node;
}
}
/**
* 任意位置插入
*/
public void addIndex(int index, int val) {
// 插入下标非法
if (index < 0 || index > size()) return;
// index = 0
if (index == 0) {
addFirst(val);
return;
}
if (index == size()) {
addLast(val);
return;
}
// 查找前驱结点
DoublyNode prev = searchPrev(index);
DoublyNode node = new DoublyNode(val);
if (prev == null) return;
node.next = prev.next;
prev.next.prev = node;
prev.next = node;
node.prev = prev;
}
/**
* 查找 index 的前驱结点
*/
public DoublyNode searchPrev(int index) {
DoublyNode cur = this.head;
while (index != 0) {
cur = cur.next;
index--;
}
return cur;
}
/**
* 查找在单链表当中是否包含关键字 key
*/
public boolean contains(int key) {
DoublyNode cur = this.head;
while (cur != null) {
if (cur.val == key) return true;
cur = cur.next;
}
return false;
}
/**
* 删除第一次出现关键字为key的节点
*/
public void remove(int key) {
if (this.head.val == key) {
this.head = this.head.next;
return;
}
DoublyNode cur = this.head;
DoublyNode prev = null;// 当前结点的前驱结点
while (cur != null) {
if (cur.val == key) {
prev.next = cur.next;
return;
}else {
prev = cur;
}
cur = cur.next;
}
}
/**
* 删除所有关键字为 key 的节点
*/
public void removeAllKey(int key) {
DoublyNode cur = this.head.next;
DoublyNode prev = this.head;
while (cur != null) {
if (cur.val == key) {
prev.next = cur.next;
}else {
prev = cur;
}
cur = cur.next;
}
// 头部结点判断是否需要删除
if (this.head.val == key) {
this.head = this.head.next;
return;
}
}
/**
* 得到双链表的长度
*/
public int size() {
int count = 0;
DoublyNode cur = this.head;
while (cur != null) {
count++;
cur = cur.next;
}
return count;
}
/**
* 打印双链表
*/
public void display() {
DoublyNode cur = this.head;
while (cur != null) {
System.out.print(cur.val + " ");
cur = cur.next;
}
System.out.println();
}
/**
* 清空双链表
*/
public void clear() {
this.head = null;
this.rear = null;
}
public static void main(String[] args) {
MyDoublyLinkedList myDoublyLinkedList = new MyDoublyLinkedList();
// 添加元素
myDoublyLinkedList.addFirst(3);
myDoublyLinkedList.addFirst(2);
myDoublyLinkedList.addFirst(1);
myDoublyLinkedList.addIndex(0,0);
myDoublyLinkedList.addIndex(0,1);
myDoublyLinkedList.addIndex(5,5);
myDoublyLinkedList.addLast(4);
myDoublyLinkedList.addLast(1);
// 打印双链表
myDoublyLinkedList.display();// 1 0 1 2 3 5 4 1
// 是否包含某个关键字
System.out.println(myDoublyLinkedList.contains(1));// true
System.out.println(myDoublyLinkedList.contains(6));// false
// 删除第一次出现关键字为key的节点
myDoublyLinkedList.remove(1);
myDoublyLinkedList.display();// 0 1 2 3 5 4 1
// 删除所有出现的关键字 key
myDoublyLinkedList.removeAllKey(1);
myDoublyLinkedList.display();// 0 2 3 5 4
// 清空双链表
myDoublyLinkedList.clear();
myDoublyLinkedList.display();//
}
}
3. 循环链表
将链表的尾结点指向头结点(首尾相连)就是循环链表了,。其实也相当于无头无尾,单向链表和双向链表都可以变为环形链表,这是很好理解的。接下来通过相关的题型进一步了解。
二、LinkedList(链表)相关题型及解法
1. 常用方法
1.1. 多指针(双指针)
当需要跟踪保存多个节点时,使用多指针实现。最常用的就是双指针,巧妙地使用双指针某些时候可以有奇效,很好地降低了空间时间复杂度。
对于双指针的使用又有快慢指针、首尾指针以及普通的双指针。
对于快慢指针简单的模板:
// Initialize slow and fast pointers
ListNode fast = head;
ListNode slow = head;
// move
while(fast != null && fast.next != null) {
fast = fast.next.next;// fast pointer move two steps each time.
slow = slow.next;// slow pointer move one step each time.
}
1.2. 傀儡头结点
当有可能对头部节点做出一些操作,使得头节点发生改变的时候,定义一个傀儡头结点可能更易于实现。
1.3. 模拟
通过对于题目的一步步解析,使用代码去模拟每一步实现相关操作。
2. 题型解析
2.1. 双指针
首先是可以看作方法的两道题型:
- leetcode 876.链表的中间节点:快慢指针。
快指针 fast 每次走两步,慢指针 slow 每次走一步,当快指针走到链表尾部的时候,慢指针刚好位于链表中间。
public ListNode middleNode(ListNode head) {
if (head == null) return null;
ListNode fast = head;
ListNode slow = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
return slow;
}
- leetcode 206.反转链表:双指针。
指针 cur 用于遍历链表,指针 prev 指向 cur 的前驱节点;每一次的反转就是将 cur.next 指向了自己的前驱节点 prev.
public ListNode reverseList(ListNode head) {
if (head == null) return null;
ListNode cur = head;
ListNode prev = null;
while (cur != null) {
ListNode curNext = cur.next;
cur.next = prev;
prev = cur;
cur = curNext;
}
return prev;
}
然后就是双指针的其他题型:
-
leetcode 19.删除链表的倒数第 k 个节点:快慢指针
既然需要找倒数第 k 个节点,而 k 一定有效。那就先让 fast 先走 k 步,如果此时 fast=null 说明要删除的就是头节点;反之就让 slow 和 fast 一起一起同速走,当 fast 再走 size-k 步到达尾结点的时候,slow 刚好走到待删除节点的前驱节点,直接删除! -
leetcode 86.分割链表:双指针+傀儡头结点
两个操作:一是分割;而是合并。
定义两个傀儡头节点 beforeDummy 和 afterDummy 分别用于存放小于 x 的节点和其他节点;分别使用 before 和 after 给两个链表连接节点,而原链表之后没用了,可以直接使用 head 遍历。遍历完原链表之后,将两个链表连接起来,beforeDummy 当然在前。 -
leetcode 92.反转链表Ⅱ:双指针
定义 cur 是待反转节点,prev 指向待反转节点的前驱节点。
cur 先走 m-1 步到第一个待反转节点,保存反转部分的前驱 first;然后向后反转 n-m+1 个节点,保存反转部分的下一个节点 last。反转之后需要重新连接,first 下一个节点是反转部分的尾结点,last 的前一个节点是反转部分的头节点。 -
leetcode 109.有序链表转二叉搜索树:快慢指针
二叉搜索树中序遍历有序。
使用快慢指针查找中间节点将链表分割成两部分,将中间节点作为二叉搜索树的根节点,递归地去查找根节点的左右子树。 -
leetcode 141.环形链表:快慢指针
使用快慢指针遍历链表,如果存在环快慢指针肯定在环里面相遇了;反之不相遇,而是 fast 走到了尾部。 -
leetcode 142.环形链表Ⅱ:快慢指针+双指针
如果存在环了两个指针肯定在环里面。然后令 fast 和 slow 分别从相遇点和 head 出发每次走一步向前,两个指针相遇点就是环入口处。 -
leetcode 143.重排链表:快慢指针+首尾指针
快慢指针查找中间节点 + 反转后半部分链表。然后从两头遍历链表,将后面的指针 slow 指向的节点插入到前面指针 fast 的后面。重复执行直到两个指针相遇(节点个数是奇数)或相邻(节点个数是偶数)。在插入过程中为了防止走丢,插入之前将原本的 next 节点记录下来。可以称为四指针? -
leetcode 160.Romantic相交链表:双指针
两个链表相交一定是 Y 型的。使用 指针1号 先走链表 1 再走链表 2;使用 指针2号 先走链表 2,再走链表 1。这路程是一样的,而如果相交了又是 Y 型,这后半辈子不就是一块走?
使用指针 pA 由 A 到 B,pB 由 B 到 A,如果两个指针相等的时候为 null,那就没相遇,反之肯定相遇了。 -
leetcode 203.移除链表元素:双指针
指针 cur 指向当前节点(初始为head.next),prev 指向 cur 的前一个节点(初始为head),和给定 val 相等就删除。最后判断一下头节点相等否? -
leetcode 234.回文链表:快慢指针
通过快慢指针寻找到中间节点,然后反转后半部分链表。这倒好,两个方法都用了。然后使用两个指针分别从两头同速遍历链表进行比较,直到相遇了(节点个数奇数)或者相邻了(节点个数偶数)还没找到不同的,就是回文链表了。 -
leetcode 328.奇偶链表:双指针
方法1: 构建两个新链表遍历连接即可。
方法2: 奇偶总是相邻的,所以直接将 head 作为奇链表头,head.next 作为偶链表头,使用双指针 odd 和 even 进行连接:奇数节点的下一个节点是偶数节点的下一个节点,后移;偶数节点的下一个节点是奇数节点的下一个节点,后移。直到遍历完将偶链表连接在奇链表之后即可。
2.2. 其他题型
-
leetcode 2.两数相加:模拟
链表逆序存储数字,那不正好?直接对应位置相加向高位进位,然后继续模拟两个数字相加就好。 -
leetcode 21.合并两个有序链表:傀儡头节点
定义一个傀儡头节点用于储存合并之后的链表。使用各自的头节点作为指针遍历两个链表比较当前节点的值,然后将较小的添加到新链表中。直到有一个链表为 null 就不需要继续了,另一个链表直接添加到新链表。 -
leetcode 23.合并 k 个有序链表:归并 / 优先级队列
方法1: k 个链表两个一组 + 合并两个有序链表,循环进行直到链表有序。
方法2: 将 k 个链表的头节点装入优先级队列(小根堆),每次弹出最小的节点连接,并将该节点所在链表的下一节点装入优先级队列。重复直到队列为空。 -
leetcode 24.两两交换链表中的节点:递归 / 迭代
方法1: 递归。两个节点作为一组,每一层反转这两个节点。反转之后的尾结点连接下一层的头节点,每一层将反转之后的头节点返回给上一层,当剩余 0/1 个节点不需要反转终止。
方法2: 迭代。需要定义傀儡头节点 dummyHead
每一次初始:prev 是待交换部分的前一个节点,cur 是交换的第一个节点;
每一次操作:将第二个节点插入到第一个结点之前。 -
leetcode 25.k 个一组反转链表:分治
方法1: k 个一组进行反转,反转之后重新连接:反转之后的尾部连接未反转部分,头部连接在已反转链表之后。
方法2: 直接反转。
(1)分组之后,对于每一组将待反转节点插入到小组的头节点和其前驱节点之间。
(2)修改指针指向,进行下一组反转;
(3)重复前两步直到剩余节点个数不足一组。 -
leetcode 61.旋转链表:模拟 / 环形链表
方法1: 将一部分前面的节点重新连接到另一部分的后面,由于存在 k > size 的可能,所以肯定是需要 k=k%size。如果 k是 0 就不用变,否则将前 size-k 个元素断开重新连接到另一部分的尾节点后面,然后整体链表的尾节点的 next 置为 null 即可。
方法2: 结环在断开。把尾节点的 next 指向头节点,然后从 head 开始移动 size-k%size 位就是尾结点了,下一节点是头节点,断开。 -
leetcode 82.删除排序链表中的重复元素Ⅱ:模拟 / 递归
方法1: 定义傀儡头节点用于储存新链表。遍历链表,遇到重复节点就跳过,需要注意多跳过一个(重复节点的最后一个和下一个也不相同);反之添加到新链表。
方法2: 递归。每一层都需要判断当前节点和下一节点是否相等,相等就跳过(多跳一次);否则连接到上一层的 next,也就是说每一层返回值是当前的不重复节点。直到遍历完链表。 -
leetcode 83.删除排序链表中的重复元素:模拟
遍历链表,遇到重复节点就删除。 -
leetcode 138.复制带随机指针的链表:Map
将原链表节点作为 key,根据原链表节点值实例的新节点作为 value存储;通过 key 将原链表节点的 next 和 random 复制到 key所对应的 value 相对应的位置也就是新链表中的 next 和 random 中即可。
(1)next :map.get(key).next = map.get(key.next)
(2)random :map.get(key).random = map.get(key.random ) -
leetcode 147.对链表进行插入排序:插入排序
定义一个傀儡头节点便于可能会插入到头节点之前。使用指针 cur 遍历链表排序:两种情况:
- 待排序节点大于等于前一个结点,那就不用管了,下一个;
- 否则,使用指针 temp 从傀儡头节点开始比较查找第一个大于待排序节点的节点,插入在它的前面(比较 temp.next 和 cur)。
-
leetcode 148.链表排序:归并排序
合并两个有序链表,刚刚好。
定义一个傀儡头节点连接每一次排序后的头节点。subLen 作为每次的分组链表长度(初始为 1),每次是前一次的二倍。根据 subLen 进行分组,每一组链表都要断开,当然,需要考虑到最后一组可能长度不够不存在第二个链表。 -
leetcode 237.删除链表中的节点:
修改节点值 OK! -
leetcode 430.扁平化多级链表:递归 / 二叉树遍历
方法1: 递归。递!每个节点没有 child 向后走,有 child 就向下一层走,直到最后一层遍历完整个"子链表";归!将上一层 “父节点” 的下一节点连接在 “子链表” 之后即可。每一层在经过 child 以后需要将 child 置为 null。
方法2: 二叉树。将 child 看作是左子树,next 是右子树,进行前序遍历。需要使用prev 指向栈顶节点的前驱节点 + 栈:将弹出的栈顶节点连接在 prev 之后,使用栈存储该节点的右子树(next)和左子树(child)(如果不为 null),同时将 child 置为 null。 -
leetcode 445.两数相加Ⅱ:模拟
方法1: 1.反转两个链表,2.两数相加,3.反转结果链表。
方法2: 使用栈,先进后出恰好!1.压栈, 2.出栈两数相加,3.将每个结果插入到新链表的头节点后面。 -
leetcode 707.设计链表
实现单向链表以及带有虚拟头尾节点的双向链表。 -
leetcode 725.分隔链表:模拟
求出链表长度 size, 每组有 n=size/k 个节点,可能会有 m=size%k(m<k) 个节点多出来,那就需要在前 m 组中每组多放入一个节点。也就是前 m 组每组 n+1 个节点,后面每组 n 个节点。最后需要注意的就是分隔的时候需要将每组最后一个节点的 next 置为 null。 -
leetcode 817.链表组件:模拟
遍历链表,当前节点在列表 G,组件数+1,向后遍历直到下一个连续节点不在列表 G 中结束该组件,进行下一个组件的判断。 -
leetcode 1019.链表中的下一个更大节点:暴力 / 单调栈
方法1: 暴力、取出所有节点值顺序装入 list,两重循环寻找第一个大于自身的值即可。
方法2: 单调栈、维护一个单调递增栈:将所有不大于当前节点值的节点弹出栈;栈为空说明对应数组位置为 0,否则就是栈顶结点值。最后将当前节点压栈。 -
leetcode 1171.从链表中删去总和值为 0 的连续节点:Map
总和值相等的两个节点直接的总和值为 0.
第一次 map:遍历链表计算总和值作为 key,并保存对应节点,总和值相同时覆盖更新;
第二次 map:遍历遍历计算总和值获取 map 中存取的相应节点连接该节点的下一个节点。 -
leetcode 1367.二叉树中的列表:递归
需要使用两个递归函数:一个用于遍历二叉树,另一个判断以当前二叉树节点开始向下的路径是否全部和链表相同。 -
leetcode 1290.二进制数转整数:暴力 / 位运算
方法1: 暴力。取出所有节点的值连接为字符串,调用Integer.parseInt()转换为十进制。
方法2: 位运算。遍历节点的每次后移相当于前面所有节点都左移了一位,所以在前面结果左移 1 之后加上当前的节点值就 OK. -
leetcode 1669.合并两个链表:模拟
先删除 [a, b] 之间的节点,保存 a-1 和 b+1 节点用于连接另一个链表。
注意的只有一点:这个下标是从 0 开始的。 -
leetcode 1670.设计前中后队列:list / 双向链表
方法1: List 实现直接使用 List 方法。
方法2: 双向链表实现带有虚拟头尾节点。 -
剑指Offer 06.从尾到头打印链表:栈 / 反向装入
方法1: 使用栈"先进后出"特性。
方法2: 遍历求出链表长度以后根据反向的下标装入节点值。 -
面试题 0201.移除重复结点:Set去重 / 数组
方法1: Set去重。Set 中存在就删除该节点。
方法2: 数组。节点值对应下标有元素就删除;没有就加进去。