Java链表数据结构刷题笔记总结
本篇文章参考了《labuladong 的算法小抄》,整理了一些自己的笔记及感想,大家可以关注他的公众号。本人很菜555,才刚刚开始刷题,在这里记录一下自己的刷题笔记嘿嘿~
链表的特性
链表的种类
一般分为单向链表、双向链表、环形链表
链表的特点
- 一个节点链着下一个节点
- 每个节点包含两部分,一部分是节点本身的值val,另一部分是指向下一个节点的地址next。
- 只能顺序访问,不能随机访问
- 存储单元不一定是连续的,每个节点都可以存储在内存中的不同位置。
- 链表的长度不是固定的。
- 插入、删除的效率高,只需要考虑相邻结点的指针改变,不需要搬移数据,时间复杂度是 O(1)。
- 随机查找效率低,需要根据指针一个结点一个结点的遍历查找,时间复杂度为O(n)。
- 与内存相比,链表的空间消耗大,因为每个结点除了要存储数据本身,还要储存上(下)结点的地址。
链表和数组的区别
- 链表是链式的存储结构;数组是顺序的存储结构。
- 数组在内存中,是一块连续的内存区域;链表是由不连续的内存空间组成;
- 链表的插入删除元素相对数组较为简单,不需要移动元素,且较为容易实现长度扩充,但是寻找某个元素较为困难;
- 数组随机访问性强,查找速度快,但插入与删除比较复杂,由于最大长度需要再编程一开始时指定,故当达到最大长度时,扩充长度不如链表方便,内存空间要求高,必须有足够的连续内存空间。
链表刷题总结
链表类题目常用的方法:
-
1.迭代、双指针。双指针(例如快慢指针)使用会比较多,特别是在有两条链表、环形链表、寻找、删除第k个节点时。
此类比较典型的题目有:
-
2.递归。其实链表的许多迭代都可以用递归来做,虽然有时候复杂度可能比不上迭代,但可以训练一下递归思维。
链表类题目一些技巧
-
1.设置伪头结点
一般为了返回一条链表的头部,就需要设置伪头结点。因为在迭代过程中,指针会随着迭代过程向后推进,这样就没办法再向前寻找头结点返回了。因此可以在一开始设置伪节点,使伪节点的next指向该条链表的头结点,这样就可以通过
dummyHead.next
返回该条链表了。//类似情况 ListNode dummyHead = new ListNode(-1); ListNode cur = dummyHead; //...cur.next = ... return dummyHead.next;
-
2.判断链表中是否有环
如果链表中有环,那么说明链表在迭代遍历到最后时还会回到前面的某一个节点继续迭代。可以使用快慢指针来解决。当快指针跟慢指针相等时,说明快指针比慢指针多走了一整个环,链表中有环;如果快指针最终遇到空指针,那说明没有环。
其他关于链表中有环的题目都需要先判断是否有环。
public boolean hasCycle(ListNode head) { ListNode fast = head, slow = head; while(fast != null){ fast = fast.next.next; slow = slow.next; if(fast == slow) return true; } return false; }
注意:当使用快指针时,循环终止条件不仅要写
fast != null
还要写fast.next != null
,因为fast = fast.next.next;
链表热门题目解析
206. 反转链表(简单)
热门链表题目都是以这个为基础的。题意非常简单易懂。可以使用两种方法:迭代和递归。
链表的递归一般是尾递归,也就是递归到最尾部进行操作后再往前继续操作。能够递归的问题说明这个问题能够分成很多相同的子问题,当我们明确了相同的子问题后,那么我们也就明确了这个递归函数的定义。例如本题,相同的子问题就是:**输入一个节点,将这个节点为起点的链表反转,并返回头结点。**那么假设我们有一条链表1→2→3→4→5,函数的定义是反转整条链表,那就等价于1与reverseList(2→3→4→5)进行反转,也就是1与5→4→3→2反转并且返回的是5;那么reverseList(2→3→4→5)等价于2与reverseList(3→4→5)……以此类推,就可以明白递归的含义。当递归到base case
或完成一个递归函数时,就会返回上一个递归的进入点,进行核心代码的运行。
注意递归方法都要有一个base case
作为递归的终点。在本题中,base case
是head.next == null
,也就是递归传入最后一个节点,此时该节点的下一位是null,也就不必再与下一位进行反转,同时作为整条链表反转后的头结点返回,接下来返回的递归函数中都是返回该头结点last
。
本题中递归反转的核心操作代码为head.next.next = head; head.next = null;
当链表递归反转之后,新的头结点是 last
,而之前的 head
变成了最后一个节点,别忘了链表的末尾要指向 null。
在学习递归时,不要跳入递归中去抓细节,而是明确递归函数的定义,在整体层面上去看递归的作用。
链表的迭代比较直观简单。需要记录前一个节点进行反转,需要记录后一个节点进行迭代。
//递归
public ListNode reverseList(ListNode head) {
if(head == null || head.next == null)
return head;
ListNode last = reverseList(head.next);
head.next.next = head;
head.next = null;
return last;
}
//迭代
public ListNode reverseList(ListNode head) {
ListNode pre = null;
while(head != null){
ListNode next = head.next;
head.next = pre;
pre = head;
head = next;
}
return pre;
}
92. 反转链表 II
这一题其实就是在反转一整条链表的基础上做的,我们只需要把这一题往反转整条链表题上转换就行。区别点就是,一开始并不是从头开始反转的,那么我们就迭代或递归到反转链表的开头就可以套用反转链表的代码;另外就是反转链表后还有剩下的链表,那么反转链表后也就是从null变成了剩下的链表,我们只需要记录下反转链表后面链表的开头,再使反转链表指向它就可以了。上一题是head.next = null;
这一题需要变成head.next = next;
。
使用迭代法的时候要特别注意,这个与反转一整条链表的迭代稍有不同。
- 定位到要反转部分的头节点 2,head = 2;前驱结点 1,pre = 1;
- 当前节点的下一个节点3调整为前驱节点的下一个节点 1->3->2->4->5,
- 当前结点仍为2, 前驱结点依然是1,重复上一步操作。。。
- 1->4->3->2->5.
//递归
class Solution {
public ListNode reverseBetween(ListNode head, int left, int right) {
if(left == 1){
head = reverse(head,right);
return head;
}
head.next = reverseBetween(head.next,left-1,right-1);
return head;
}
ListNode next = new ListNode(0);
ListNode reverse(ListNode head, int right){
if(right == 1){
next = head.next;
return head;
}
ListNode last = reverse(head.next,right-1);
head.next.next = head;
head.next = next;
return last;
}
}
//迭代
class Solution {
public ListNode reverseBetween(ListNode head, int left, int right) {
ListNode cur = head;
ListNode pre = new ListNode(0);
pre.next = cur;
for(int i = 1; i < left; i++){
cur = cur.next;
pre = pre.next;
}
for(int i = left; i < right; i++){
ListNode next = cur.next;
cur.next = next.next;
next.next = pre.next;
pre.next = next;
}
return head;
}
}
25. K 个一组翻转链表
如果有了以上两题的基础,理解了递归的含义,那么本题不难解,只需要注意一点细节就好了。下面来写一下本小白的分析思路,如果有错误或者更好的思路想法可以指出~
首先看题目,k个一组翻转链表,那么就可分解成一个个子问题,前k个翻转的链表加上后面k个一组翻转链表就是答案——reverse(1→2)→reverseKGroup(3→4→5)
,也就是可以用递归。
那么明确了我们递归的大体思路,就可以定义我们的base case
。这里递归方法的base case
有两种,一种是刚刚好整除,那么递归到最底层head
就是null
,返回null
即可;另一种是不整除,例如该例子还剩一个5,那么5在循环到下一个k的时候就会出现cur == null
,这一段就不需要反转,那么直接返回没有反转的这一段,也就是原来的head
回去就好了。也就是说先判断它的长度是否大于等于 k
。若是,我们就翻转这部分链表,否则不需要翻转。
base case
有了,那么就回到需要反转的那些层,每一层都相当于反转前k个,再链接到后面的链表继续反转前k个。例如最底层5返回后,当前的head
就是3->4->5
,反转前k个后则变成4->3->5
,以此类推。那么也就是每一层都返回反转后的head
,上一层对这一层进行链接,也就是上一层最后一个数的next
链接这一层的返回。
明确了思路后,剩下的就是细节问题了,例如需要保存每一组头结点、尾结点,如何进入递归连接到下一组,这些我相信看代码也能看明白。
迭代法
可以参考官方题解
//递归
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
if(head == null) //base case刚好k个返回null就行
return null;
ListNode pre = new ListNode(-1);
ListNode cur = head;
pre.next = cur;
for(int i = 0; i < k; i++){
if(cur == null) //如果没满k个则直接返回头结点,不需要再反转了
return head;
cur = cur.next;
pre = pre.next; //先迭代到这一组的最后一个
}
pre.next = reverseKGroup(cur,k); //这一组最后一个节点连接到下一组的头结点
return reverseN(head,k); //反转这一组的前k个节点并返回头结点
}
ListNode next = new ListNode(0);
ListNode reverseN(ListNode head, int k){
if(k == 1){
next = head.next;
return head;
}
ListNode last = reverseN(head.next,k-1);
head.next.next = head;
head.next = next;
return last;
}
}
//迭代
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
ListNode hair = new ListNode(0);
hair.next = head;
ListNode pre = hair;
while (head != null) {
ListNode tail = pre;
// 查看剩余部分长度是否大于等于 k
for (int i = 0; i < k; ++i) {
tail = tail.next;
if (tail == null) {
return hair.next;
}
}
ListNode nex = tail.next;
ListNode[] reverse = myReverse(head, tail);
head = reverse[0];
tail = reverse[1];
// 把子链表重新接回原链表
pre.next = head;
tail.next = nex;
pre = tail;
head = tail.next;
}
return hair.next;
}
public ListNode[] myReverse(ListNode head, ListNode tail) {
ListNode prev = tail.next;
ListNode p = head;
while (prev != tail) {
ListNode nex = p.next;
p.next = prev;
prev = p;
p = nex;
}
return new ListNode[]{tail, head};
}
}
24. 两两交换链表中的节点
学完了上一题后会发现这一题真的小case,因为这一题相当于上一题的k=2
,虽然思路一样,但我们的代码可以简洁很多。这里直接看注释就好。
//递归
class Solution {
public ListNode swapPairs(ListNode head) {
if (head == null || head.next == null) { //整除或不整除的情况都包括了
return head;
}
ListNode last = head.next; //记录下这一组的最后一个节点
head.next = swapPairs(last.next); //交换下一组,并将这一组原本的头节点与下一组返回的头结点连接
last.next= head; //这两行代码都是进行交换
return last; //返回这一组的头节点
}
}
//迭代
class Solution {
public ListNode swapPairs(ListNode head) {
ListNode dummyHead = new ListNode(0);
dummyHead.next = head;
ListNode temp = dummyHead;
while (temp.next != null && temp.next.next != null) {
ListNode node1 = temp.next;
ListNode node2 = temp.next.next;
temp.next = node2;
node1.next = node2.next;
node2.next = node1;
temp = node1;
}
return dummyHead.next;
}
}
143. 重排链表
本题也是在以上题目的基础上做出来的,基本上我们找出题目的规律就可以完成这道题目了,难度不大。注意到目标链表即为将原链表的左半端和反转后的右半端合并后的结果。因此我们只需要运用双指针技巧找到中点,运用反转链表的技巧反转后面的链表,再利用双指针技巧合并两条链表即可。
class Solution {
public void reorderList(ListNode head) {
// 找到中点
ListNode fast = head, slow = head;
while(fast != null && fast.next != null){
fast = fast.next.next;
slow = slow.next;
}
// 反转中点后的链表
ListNode midHead = slow.next;
slow.next = null; //记得要把第一条链中点后设为null,不然后面合并会出现环
ListNode pre = null;
while(midHead != null){
ListNode next = midHead.next;
midHead.next = pre;
pre = midHead;
midHead = next;
}
// 合并两条链表
ListNode next1;
ListNode next2;
ListNode cur1 = head;
ListNode cur2 = pre;
while(cur1 != null && cur2 != null){
next1 = cur1.next;
next2 = cur2.next;
cur1.next = cur2;
cur1 = next1;
cur2.next = cur1;
cur2 = next2;
}
}
}
总结
链表类题目整体来说还是比较简单的,充分了解链表的特性后可以作为新手入门刷数据结构的首选。通过链表可以了解到递归、分解问题以及层层深入的一些思想,例如反转链表→反转一部分链表→反转k个一组链表,由这几个题目由浅入深,可以培养我们的做题思维,如果直接做反转k个一组链表会比较难,但如果做了前面两个简单的题目后就会豁然开朗。
笔者目前还处于刚开始刷题的阶段,所以很多代码写得不是很漂亮或者不够简洁,或者有更高效率的算法我还不会的,大家可以直接指出来顺便教教我哈哈哈~