链表
很久没做了,再上手发现链表部分还是很熟的,居然所有的都做出来了,也可能是简单吧。
链表部分我觉得的一些重点:
- 注意添加一个
dummyNode
的做法。有的时候头节点可能不太方便,需要额外考虑,这个时候可以创建一个dummyNode
指向头节点,作为一个空头节点。这样写代码的时候不用特殊处理头节点情况会比较方便。不过最后返回链表的时候,不要忘记返回dummyNode.next
! - 链表中删除元素的方法要比较熟悉。保存
prev curr next
三个节点,如果要删除curr
的话,让prev.next = next
。不保存前后节点,单链表实现就比较麻烦。 - 快慢指针在找链表中的一些结构的时候会非常有用,比如环,链表相交,通过快慢指针的不同起点或者速度让它们在我们想要的位置相遇。
- 链表反转也要掌握,也是保存
prev curr next
三个节点.
160. 相交链表
判断两链表是否相交。

方便的做法自然是弄一个 HashSet 存储所有节点,然后同时遍历两个链表,如果发现有一个节点无法存入 HashSet 已经存在,这个节点就是相交的起始点。不过这个方法时空复杂度都不是特别好。
第二种做法就是双指针,感觉链表很多题都可以双指针解决。第一次我们遍历两个链表,并记下两个链表到达结尾的总长度,比如上图中一个是 5 一个是 6.那么我们可以判断出,B 链表长于 A 链表的部分肯定不会是相交的起始点出现的地方。因为两个链表相交之后的部分长度相等。所以我们可以再次遍历两个链表,B 链表从 b2 开始遍历,A 链表从 a1 开始遍历,两个指针每次同时往后挪动一个单位后判断是否相等。也就是说把长链表过长的那部分去掉了,变成一个和短链表等长的链表,然后两个链表比对到底是在什么地方最先出现的相交点。
时间复杂度 O(n) 空间复杂度 O(1)。
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
int lenA = 0, lenB = 0;
ListNode tempA = headA, tempB = headB;
while(tempA != null){
lenA++;
tempA = tempA.next;
}
while(tempB != null){
lenB++;
tempB = tempB.next;
}
ListNode longerListNode = lenA > lenB? headA : headB;
ListNode shorterListNode = lenA > lenB? headB : headA;
for(int i =0;i<Math.abs(lenA-lenB);i++)longerListNode = longerListNode.next;
while(longerListNode != null){
if(longerListNode == shorterListNode)return longerListNode;
longerListNode = longerListNode.next;
shorterListNode = shorterListNode.next;
}
return null;
}
}
206. 反转链表

不限制空间复杂度的前提下,这道题还是非常好做的。我们一边读取原链表,一边创造一个新链表,不过新链表不是从头到尾逐渐添加元素的,而是每次在头的位置添加一个新的头元素,从尾到头添加。时空复杂度均为 O(N)。
如果要求空间复杂度 O(1) 就是在原来的链表基础上反转了,从头遍历地柜实现。主要难点就是需要 prev curr next
三个指针,每次遍历的时候 curr->next = prev, curr = next, prev = curr
。不用额外变量保存前后节点就会更复杂。
class Solution {
public ListNode reverseList(ListNode head) {
if(head == null || head.next == null)return head;
ListNode pre = head, cur = head.next, tmp = head.next;
pre.next = null;
while(cur != null){
tmp = cur.next;
cur.next = pre;
pre = cur;
cur = tmp;
}
return pre;
}
}
234. 回文链表
判断一个链表是否回文。

不限制空间复杂度的话也很简单,可以创建个集合记录一下顺序,然后再次遍历看集合出栈顺序和原链表遍历顺序是否一致。都是 O(N)。
限制空间复杂度常数的话,可以用快慢指针实现。慢指针移动速度是快指针的二分之一,这样快指针走完了慢指针才刚走到链表中间。然后再创建一个慢指针2号从头遍历,慢指针1号反转后半部分链表,反转完成后慢指针1号和2号同步遍历看前后两部分链表是否构成回文。
用到了之前反转链表的思想。
class Solution {
public boolean isPalindrome(ListNode head) {
ListNode fastPtr = head, slowPtr = head, prev =new ListNode(-1, head);
while (fastPtr != null && fastPtr.next != null) {
fastPtr = fastPtr.next.next;
slowPtr = slowPtr.next;
prev = prev.next;
}
ListNode cur = slowPtr, tmp = slowPtr.next;
// 截断前半部分和后半部分数组。不然的话就是要从 head 比较到 tail 了,我这样截断之后就从 head 比较到 mid 就行。
// 不过这道题最大长度也就是9,没感觉出来太多区别。
prev.next = null;
prev = null;
while (cur != null) {
tmp = cur.next;
cur.next = prev;
prev = cur;
cur = tmp;
}
while (head != null && prev != null) {
if (head.val != prev.val)
return false;
head = head.next;
prev = prev.next;
}
return true;
}
}
慢指针1号在快指针到达结尾的时候刚好会到达后半部分回文子串的开始。
141. 环形链表
判断链表中是否有环。

非常经典的题了,快慢指针,快指针走得比慢指针快一倍,看会不会相遇。如果会,说明有环。
public class Solution {
public boolean hasCycle(ListNode head) {
ListNode slowPtr = head, fastPtr = head;
while (fastPtr != null && fastPtr.next != null) {
fastPtr = fastPtr.next.next;
slowPtr = slowPtr.next;
if (fastPtr == slowPtr) {
return true;
}
}
return false;
}
}
142. 环形链表 II
简单说就是不仅要判断是否为环形链表,还要找到入环点。

不考虑空间复杂度的话,还是弄个 Set 存点,第一个存不进的重复元素就是入环点了呗。
考虑空间复杂度的话,这道题会有一个比较有意思的解法。
官方题解图示如下:

首先我们还是快慢指针遍历。两者相遇的时候,慢指针走了 a+b,快指针走了 a+b+n(b+c) (多走了 n 圈)。
我们要求 a:
2x=a+b+n(b+c)x=a+ba=(n−1)b+nc
2x = a+b+n(b+c)\\
x = a+b\\
a = (n-1)b+nc
2x=a+b+n(b+c)x=a+ba=(n−1)b+nc
也就是说 a 部分的路程相当于 n 圈的路程 -c。
那么我们就让一个指针从紫色的快慢指针相遇点开始转圈,一个指针从原点出发。那么两者就一定会在入环点相遇。原点出发的点走了距离 a 到达了入环点,绕圈的点走了 n 圈+c,不过绕圈点本身起点就是距离入环点 c 距离的位置,所以这个时候也会在入环点。
这样我们两次遍历就找到入环点了,时间复杂度 O(N)。
public class Solution {
public ListNode detectCycle(ListNode head) {
// a: 环前的元素
// loop: 环长度
// x: slowPtr 走过的距离
// b: slowPtr 在环中走过的距离
// x = a + b
// 2*x = a + n*(b+c) + b
// loop = b+c
// x = n*(b+c)
// a = (n-1)*b + n*c = (n-1) * (b+c) + c = (n-1)*b+n*c
ListNode slowPtr = head, fastPtr = head;
while(fastPtr != null && fastPtr.next != null){
fastPtr = fastPtr.next.next;
slowPtr = slowPtr.next;
if(slowPtr == fastPtr)break;
}
if(fastPtr == null || fastPtr.next == null)return null;
ListNode slowPtr1 = head;
while(slowPtr1 != slowPtr){slowPtr = slowPtr.next; slowPtr1 = slowPtr1.next;}
return slowPtr1;
}
}
21. 合并两个有序链表

没啥难度,就双指针遍历嘛,创建一个新链表作为输出结果,或者直接修改原来链表元素。
这个题主要是给后面归并排序做铺垫呢。
class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
if (list1 == null && list2 == null)
return null;
ListNode newList = new ListNode();
ListNode pre = newList;
while (list1 != null || list2 != null) {
if (list1 == null || (list2 != null && list2.val <= list1.val)) {
newList.next = list2;
list2 = list2.next;
} else {
newList.next = list1;
list1 = list1.next;
}
newList = newList.next;
newList.next = null;
}
return pre.next;
}
}
2. 两数相加

就是类似一个进位器机制,有一定计组基础的同学应该很容易做,就是用一个 carry 变量存储之前两次链表求和的进位。最后遍历完两个链表了别忘了再检查一下 carry 是否为空。
空间复杂度上呢,要么就自己新建个链表,要么就麻烦点挑那个更长的链表在其基础之上修改。
class Solution {
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
// if(l1 == null && l2 == null)return null;
ListNode preListNode = new ListNode();
ListNode tmp = preListNode;
int carry = 0;
while (l1 != null || l2 != null) {
if (l1 == null) {
tmp.next = new ListNode((carry + l2.val) % 10);
carry = (carry + l2.val) / 10;
l2 = l2.next;
} else if (l2 == null) {
tmp.next = new ListNode((carry + l1.val) % 10);
carry = (carry + l1.val) / 10;
l1 = l1.next;
} else {
tmp.next = new ListNode((carry + l1.val + l2.val) % 10);
carry = (carry + l1.val + l2.val) / 10;
l1 = l1.next;
l2 = l2.next;
}
tmp = tmp.next;
}
if (carry != 0) {
tmp.next = new ListNode(carry);
}
return preListNode.next;
}
}
19. 删除链表的第 n 个节点

这个仍然没啥难度,主要是给后面的难题做铺垫呢。主要还是要保存 prev 和 next 节点进行拼接。
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode slowPtr = new ListNode(0, head), fastPtr = head;
for (int i = 0; i < n; i++) {
if (fastPtr == null)
return head;
fastPtr = fastPtr.next;
}
while (fastPtr != null) {
slowPtr = slowPtr.next;
fastPtr = fastPtr.next;
}
if (slowPtr.next == head) {
return slowPtr.next.next;
} else if (slowPtr.next != null)
slowPtr.next = slowPtr.next.next;
return head;
}
}
我这里命名不好,其实应该 prev 和 curr 的。别在意。
24. 两两交换链表节点

这个其实也不难,我直接递归实现了,空间复杂度有点麻烦,迭代可能会好些。就是2个2个元素交换,然后处理其之前和之后的元素的指向拼接。
class Solution {
public ListNode swapPairs(ListNode head) {
if(head == null || head.next == null)return head;
ListNode tmpHead = head.next;
head.next = head.next.next;
tmpHead.next = head;
if(head.next != null){
head.next = swapPairs(head.next);
}
return tmpHead;
}
}
25. K 个一组翻转链表

这个是在之前那道题上的进阶。两两交换还很简单,多了就得用到反转链表的方法了。
反转链表,还有前后链表段处理这些前面都有出现过,没什么非常新奇的东西,就是写起来麻烦,不要犯错就行。
class Solution {
public ListNode reverseKGroup(ListNode head, int k) {
ListNode dummyNode = new ListNode(-1, head);
ListNode prev = dummyNode;
ListNode currNode = head;
while (currNode != null) {
for (int i = 0; i < k - 1 && currNode != null; i++) {
currNode = currNode.next;
}
if (currNode == null)
break;
ListNode next = currNode.next;
currNode.next = null;
prev.next = reverseSubGroup(prev.next);
while (prev.next != null)
prev = prev.next;
prev.next = next;
currNode = next;
}
return dummyNode.next;
}
public ListNode reverseSubGroup(ListNode head) {
if (head == null || head.next == null)
return head;
ListNode prev = null;
ListNode currNode = head;
while (currNode != null) {
ListNode tmpNode = currNode.next;
currNode.next = prev;
prev = currNode;
currNode = tmpNode;
}
return prev;
}
}
138. 随机链表的复制
从这道题开始有点意思。

深拷贝一个链表比较简单,但是这个链表中还有 random,这个 random 我们不一定知道是指向链表中哪一个元素。
我想到的比较蠢的实现方法就是遍历链表去记录 random 指向的链表元素的索引,然后我在我自己深拷贝的数组里也找到对应索引的位置,用 random 指向这些位置。时间复杂度炸完了,O(n2) 了吧。
不过其实最简单的方法是哈希表映射。我用 key value 分别对应原链表的某一个节点和对应的新链表的节点。然后我发现有一个 B 节点 random 指向 A 节点,我去 map 中看看 A 节点对应的新链表中是什么节点, 发现是 A’ 节点。那我新链表中的 B’.random 就指向 A’ 节点即可。
class Solution {
HashMap<Node, Node> map = new HashMap<>();
public Node copyRandomList(Node head) {
if(head == null)return null;
Node tmpPtr = head;
Node resPtr = new Node(1);
Node curPtr = resPtr;
if(map.get(tmpPtr)==null){
curPtr.next = new Node(tmpPtr.val);
curPtr = curPtr.next;
map.put(tmpPtr, curPtr);
curPtr.next = copyRandomList(tmpPtr.next);
curPtr.random = copyRandomList(tmpPtr.random);
return resPtr.next;
}
else {
return map.get(tmpPtr);
}
}
}
148. 排序链表
题目挺简单的。

实现方法有很多,比如插入排序,每次插入一个节点;Collections.sort
帮我排序;不过题目中有提到:建议尝试 O(nlogn) 的时间复杂度,常数级空间复杂度的做法。这个数字比较眼熟,很明显是要挑战链表的归并排序。
归并排序简单说就是把链表分成两段,分别排序,然后这两段再合并起来排序。比如链表总长度16,我们自底向上归并排序,首先是16个一元元素(不用排序)。然后两两合并,变为8个2元元素,每个都要遍历2次(要合并的两个元素各访问一次)也就是遍历16次。然后两两合并,4个4元数组,遍历16次。然后合并。2个8元数组,遍历16次。最后一个16元素数组不用遍历。整个过程中需要拆分合并的次数近似等于 Log2n,所以时间复杂度总的是 O(nlogn)。
具体实现……你要说有啥难点,其实也没啥难点,都是之前的知识,比如截断后两两排序,递归排序返回头结点什么的。就是小问题一堆,写起来也麻烦
class Solution {
public ListNode sortList(ListNode head) {
int len = 0;
ListNode tmpHead = head;
ListNode dummyHead = new ListNode(-1, head);
while (tmpHead != null) {
tmpHead = tmpHead.next;
len++;
}
for (int subLength = 1; subLength < len; subLength <<= 1) {
// prev: 之前已经排好序的链表
// head1 head2: 本轮循环准备归并排序的两部分链表
// next: 之后的链表
// 找到 prev
ListNode prev = dummyHead, cur = dummyHead.next;
while (cur != null) {
// 找到 head1 head2
ListNode head1 = cur;
for (int i = 1; i < subLength && cur.next != null; i++)
cur = cur.next;
ListNode head2 = cur.next;
cur.next = null;
// 找到 next
cur = head2;
for (int i = 1; i < subLength && cur != null && cur.next != null; i++)
cur = cur.next;
ListNode next = null;
if (cur != null) {
next = cur.next;
// 并且截断归并排序的第二个链表
cur.next = null;
}
ListNode merged = merge(head1, head2);
prev.next = merged;
while (prev.next != null)
prev = prev.next;
cur = next;
}
}
return dummyHead.next;
}
public ListNode merge(ListNode node1, ListNode node2) {
ListNode dummyNode = new ListNode(-1);
ListNode currNode = dummyNode;
while (node1 != null && node2 != null) {
if (node1.val < node2.val) {
currNode.next = node1;
node1 = node1.next;
} else {
currNode.next = node2;
node2 = node2.next;
}
currNode = currNode.next;
}
if (node1 != null)
currNode.next = node1;
else if (node2 != null)
currNode.next = node2;
return dummyNode.next;
}
}
我这个主要瓶颈在于合并之后 prev 要疯狂遍历找到这部分合并链表的结尾作为新的 prev ,除此之外符合归并排序。用递归可能会好些。
23. 合并 K 个升序列表

其实这个也是考验递归。
先写一个两链表合并的方法,然后每次放两个链表进去排序。如果是结果返回值链表每次拿一个链表集合中的元素到自己这里“插入排序”,那么时间复杂度会很高,还是两两归并效率会高很多。
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
return mergeTwoSets(lists, 0, lists.length-1);
}
public ListNode mergeTwoSets(ListNode[] lists, int l, int r){
if(l == r)return lists[l];
else if(l > r)return null;
int mid = (l+r)>>1;
return mergeTwoLists(mergeTwoSets(lists, l, mid),mergeTwoSets(lists, mid+1, r));
}
public ListNode mergeTwoLists(ListNode head1, ListNode head2){
ListNode dummyHead = new ListNode(-1, null);
ListNode curNode = dummyHead;
while(head1!=null && head2 != null){
if(head1.val<=head2.val){
curNode.next = head1;
head1 = head1.next;
}else {
curNode.next = head2;
head2 = head2.next;
}
curNode = curNode.next;
}
if(head1!=null){
curNode.next = head1;
}
else if(head2!=null){
curNode.next = head2;
}
else if(curNode!=null)curNode.next = null;
return dummyHead.next;
}
}
LRU 缓存
看似中等,实则真正的困难!
让我们实现一个类似操作系统中 LRU(least recently used)缓存的算法。比如缓存大小设置为10,现在存入了10个缓存了,然后又要存入一个新缓存,就要把最久没用过的那个淘汰掉。

我们可以采取类似 LinkedHashMap 的做法,既保存了顺序又可以依靠哈希表快速访问。
当用户 put 新添加一个元素进缓存的时候,我们就将其存入 HashMap 和双链表,双链表用于记载其顺序位置,头是最新存入的或者最近访问的,尾是最久没用的,HashMap 用于根据这个键值对的键快速找到对应对象的引用,用双链表实现就是头尾可以快速访问,不然我们要删除尾部元素的话每次都要 O(n) 遍历到结尾。添加完元素之后判断一下当前容量是否超出最大容量,如果超出则删除尾部元素并删除其在 HashMap 中的映射。
用户 get 一个元素的时候,先去 HashMap 里查找看有没有,快速定位,如果有则把这个元素在链表中删掉,放到头节点位置并返回 value 值给用户。如果没有直接返回 -1。缓存穿透问题无法避免吧,哈哈。必要的话可以缓存空对象,不过这里做题就不用考虑了。
用户 put 一个已经存在的键值对的时候,更新该元素,然后和 get 一样,将其从链表中间删去,放到头节点。
如下未完整代码。说实话,这个也没有什么高深的算法,都是之前我们练习过的,但是重点还是两点:1. 思想,要能够想到这种用法。2. 细节,我估计真面试遇到了我得写上30分钟都不一定纠正得了所有错误。所以还是要常练。
class LRUCache {
class DoubleLinkedNode {
int key;
int value;
DoubleLinkedNode prev;
DoubleLinkedNode next;
public DoubleLinkedNode() {
}
public DoubleLinkedNode(int _key, int _value) {
key = _key;
value = _value;
}
}
private Map<Integer, DoubleLinkedNode> cache = new HashMap<>();
private int capacity;
private int size;
private DoubleLinkedNode head,tail;
public LRUCache(int capacity) {
this.size = 0;
this.capacity = capacity;
head = new DoubleLinkedNode();
tail = new DoubleLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DoubleLinkedNode getNode = cache.get(key);
if(getNode == null)return -1;
int res = getNode.value;
deleteNodeInCache(getNode);
updateHeadNodeInCache(getNode);
return res;
}
public void put(int key, int value) {
DoubleLinkedNode getNode = cache.get(key);
if(getNode == null){
DoubleLinkedNode newHeadNode = new DoubleLinkedNode(key, value);
cache.put(key, newHeadNode);
size++;
if(size>capacity){
int lastKey = tail.prev.key;
deleteLastNodeInCache();
cache.remove(lastKey);
size--;
}
updateHeadNodeInCache(newHeadNode);
}
else {
getNode.value = value;
deleteNodeInCache(getNode);
updateHeadNodeInCache(getNode);
}
}
private void deleteNodeInCache(DoubleLinkedNode node){
DoubleLinkedNode prev = node.prev;
DoubleLinkedNode next = node.next;
prev.next = next;
next.prev = prev;
}
private void updateHeadNodeInCache(DoubleLinkedNode node){
DoubleLinkedNode oldFirst = head.next;
oldFirst.prev = node;
node.prev = head;
node.next = oldFirst;
head.next = node;
}
private void deleteLastNodeInCache(){
deleteNodeInCache(tail.prev);
}
}
/**
* Your LRUCache object will be instantiated and called as such:
* LRUCache obj = new LRUCache(capacity);
* int param_1 = obj.get(key);
* obj.put(key,value);
*/