链表
链表类的题有有很多相似的题目,主要可以使用递归和迭代两种方法。同时,在做题的时候也有一些小技巧,这里暂时列出几项新。
- 递归求解;
- 使用虚拟头结点求解;
- 创建一个临时结点,其next指向需要保存的结点,就像交换函数中的temp一个道理;
- 使用快慢指针,让快指针先走部分距离,之后快慢指针一起走,快指针到结尾时慢指针正好离尾指针相同距离,可以解决一轮访问单向链表的倒数n个结点的问题。
- 如果需要对链表表尾对其,可以使用栈实现。
(持续更新)
下面是题解。
1. 找出两个链表的交点。
链接:找出两个链表的交点(简单)
解答:
两个链表虽然在分开处不等长,但相交后等长。假设链表A相交前的长度为a,链表B相交前的长度为b,相交后的长度为c,那么链表A的总长度为a+c,链表B的总长度为b+c。
此时做如下操作:
- 让访问链表A的指针ptr1在访问到尾部的时候继续访问链表B的头部;
- 让访问链表B的指针ptr2在访问到尾部的时候继续访问链表A的头部。
那么当ptr1访问到相交点C时,ptr1访问长度为a+c+b;同理ptr2访问交点时访问长度为b+c+a,因此ptr1和ptr2的访问长度是相等的。那么,使用一个while循环每次让ptr1和ptr2访问一个节点,最后两个指针重合的时候(即ptr1==ptr2),重合的节点一定是相交点。
如果不存在重合,那么当两个指针都访问完两个链表后,都会指向Null,也会相等。
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode ptr1 = headA;
ListNode ptr2 = headB;
while(ptr1 != ptr2){
ptr1 = (ptr1!=null) ? ptr1.next : headB;
ptr2 = (ptr2!=null) ? ptr2.next : headA;
}
return ptr1;
}
2. 反转链表
链接:反转链表(简单)
这道题有两种解答方式,递归和迭代。
递归
递归将子任务交给递归方程完成,本任务只需要衔接子任务的输出和初始的输入的结合,即可保证结果正确。这里子递归输入next节点,输出已反转的子链表,本任务实现head与递归输出的逆转即可。
public ListNode reverseList(ListNode head) {
if(head == null || head.next == null) return head;
ListNode revList = reverseList(head.next);
head.next.next = head;
head.next = null;
return revList;
}
迭代
- 使用一个cur指针指向当前节点,由于需要反转链表,所以需要记录前一个节点pre(即反转后的链表头节点)。
- 当前节点需要反转方向时,为了不丢失原本的下一个节点,需要用一个next指针事先保存。
- 当前节点的指向反转后,这个节点就成了反转链表的头节点,此时pre节点和cur都往后移一位即可,先移pre后移cur,防止当前节点地址丢失。
public ListNode reverseList(ListNode head) {
ListNode pre = null;
ListNode cur = head;
while(cur!=null){
ListNode next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
}
3. 归并两个有序的链表
递归
比较两个头结点大小,选择小结点的next与大结点的链表做递归的归并,再做一个小结点next指向递归结果即可。
public ListNode mergeTwoLists(ListNode l1, ListNode l2){
if(l1==null) return l2;
if(l2==null) return l1;
if(l1.val<l2.val){
l1.next = mergeTwoLists(l1.next, l2);
return l1;
} else{
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}
迭代
- 使用一个虚拟头结点dummyHead作为归并的头结点,使用一个指针ptr3表示尾指针。
- 每次让尾指针next接到链表1和链表2访问指针ptr1、ptr2较小的值,再移动较小值的指针即可。
- 最后其中一个链表访问完,剩下的直接接上即可。
public ListNode mergeTwoLists(ListNode l1, ListNode l2){
ListNode ptr1 = l1, ptr2 = l2;
ListNode ptr3 = new ListNode(-1), dummyHead = ptr3;
while(ptr1!=null && ptr2!=null){
if(ptr1.val<ptr2.val){
ptr3.next = ptr1;
ptr1 = ptr1.next;
} else{
ptr3.next = ptr2;
ptr2 = ptr2.next;
}
ptr3 = ptr3.next;
}
ptr3.next = ptr1 !=null ? ptr1 : ptr2;
return dummyHead.next;
}
4. 从有序链表中删除重复节点
递归
递归解决子任务,head和subHead比较是否相同,若相同则跳过subHead。
public ListNode deleteDuplicates(ListNode head) {
if(head == null || head.next == null) return head;
ListNode subHead = deleteDuplicates(head.next);
if(head.val == subHead.val){
subHead = subHead.next;
}
head.next = subHead;
return head;
}
迭代
这里使用一个指针即可,若该指针下一个节点和本结点的值相等,那么跳过。
public ListNode deleteDuplicates(ListNode head) {
if(head == null) return head;
ListNode ptr = head;
while(ptr.next!=null){
if(ptr.val == ptr.next.val){
ptr.next = ptr.next.next;
} else{
ptr = ptr.next;
}
}
return head;
}
5. 删除链表的倒数第 n 个节点
这里使用了一个叫快慢指针的技巧,先让快指针往前走距离n,然后快指针和慢指针同时移动,当快指针到尾部的时候,慢指针此时正好离尾部距离n。
public ListNode removeNthFromEnd(ListNode head, int n) {
//快慢指针
if(head == null) return null;
ListNode dummyHead = new ListNode(-1, head);
ListNode slow = dummyHead, fast = dummyHead.next;
int count = n;
while(count>0){
fast = fast.next;
count--;
}
while(fast!=null){
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummyHead.next;
}
6. 交换链表中的相邻结点
递归
和普通递归单向链表题一样的做法。
public ListNode swapPairs(ListNode head) {
if(head == null || head.next == null) return head;
ListNode subHead = swapPairs(head.next.next);
ListNode ptr = head.next;
head.next = subHead;
ptr.next = head;
return ptr;
}
迭代
这里使用一个虚拟头结点,设置三个指针pre、first、second,让first和second的结点next改变后,再改变三个指针的指向。
public ListNode swapPairs(ListNode head) {
ListNode dummy = new ListNode(-1, head);
ListNode pre = dummy;
while(pre.next != null && pre.next.next != null){
ListNode first = pre.next, second = first.next;
first.next = second.next;
second.next = pre.next;
pre.next = second;
pre = first;
}
return dummy.next;
}
7. 链表求和
链接:链表求和(中等)
像这种做加减操作而位数没有对齐的题目,就可以使用栈来解决,通过压栈使得两个数的位数对齐。若其中一个数没有高位数,直接置0即可。
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
Stack<Integer> stack1 = getStackFromList(l1);
Stack<Integer> stack2 = getStackFromList(l2);
int carry = 0;
ListNode ptr = null;
while(!stack1.empty() || !stack2.empty() || carry!=0){
int val1 = !stack1.empty() ? stack1.pop() : 0;
int val2 = !stack2.empty() ? stack2.pop() : 0;
int val = val1 + val2 + carry;
carry = val/10;
val %= 10;
ListNode node = new ListNode(val);
node.next = ptr;
ptr = node;
}
return ptr;
}
private Stack<Integer> getStackFromList(ListNode head){
Stack<Integer> stack = new Stack<Integer>();
while(head!=null){
stack.push(head.val);
head = head.next;
}
return stack;
}
8. 回文链表
链接:回文链表(简单)
方法一:使用栈,但是时间复杂度会很高,O(n)。
public boolean isPalindrome(ListNode head) {
Stack<Integer> stack = new Stack<>();
ListNode node = head;
int count = 0;
while(node!=null){
stack.push(node.val);
count++;
node = node.next;
}
int i = 0;
while(i<count/2){
int val1 = head.val;
int val2 = stack.pop();
if(val1!=val2) return false;
head = head.next;
i++;
}
return true;
}
方法二:
- 先用快慢指针找到链表的中间结点,再将链表断开。
- 将后半段链表反转。
- 用前半段的链表的值和后半段反转后的链表值比较。
- 如果需要恢复链表,再反转一次后半段,并重新接上即可。
这种方法的空间复杂度为O(1),是方法一的优化。
public boolean isPalindrome(ListNode head) {
if(head == null || head.next == null) return true;
Stack<Integer> stack = new Stack<>();
ListNode slow = new ListNode(-1, head), fast = head;
//将链表分成两段
//slow停在第一段的最后一个节点
while(fast!=null && fast.next!=null){
fast = fast.next.next;
slow = slow.next;
}
//奇数节点,让slow指向下一个节点(中间节点)
if(fast!=null){
slow = slow.next;
}
//断开两端节点,slow指向第二段第一个节点
ListNode tmp = slow;
slow = slow.next;
tmp.next = null;
//反转后半段链表
ListNode revHead = reverse(slow);
while(revHead!=null){
if(head.val!=revHead.val) return false;
head = head.next;
revHead = revHead.next;
}
return true;
}
private ListNode reverse(ListNode head){
ListNode pre = null;
while(head!=null){
ListNode next = head.next;
head.next = pre;
pre = head;
head = next;
}
return pre;
}
8. 分割链表
链接:分割链表(中等)
- 由于分隔成的每个部分的长度和原始链表的长度有关,因此需要首先遍历链表,得到链表的长度。
- 得到长度后可以通过整除和取余的方式得到每部分的size。
- 之后用for循环遍历单向链表,每到一个size的大小做切割即可。
public ListNode[] splitListToParts(ListNode head, int k) {
int count = 0;
ListNode ptr = head;
//计算链表长度
while(ptr!=null){
count++;
ptr = ptr.next;
}
int size = count / k;
int mod = count % k;
ListNode[] ret = new ListNode[k];
ptr = head;
for(int i = 0; ptr!=null && i<k; i++){
ret[i] = ptr;
int curSize = size + (mod-- > 0 ? 1 : 0);
for(int j = 0; j<curSize-1; j++){
ptr = ptr.next;
}
ListNode tmp = ptr;
ptr = ptr.next;
tmp.next = null;
}
return ret;
}
10. 链表元素按奇偶聚集
链接:链表元素按奇偶聚集(中等)
用两个指针,一奇一偶,while循环时不断变换指向即可,记得要把偶数链表头先记录下来。
public ListNode oddEvenList(ListNode head) {
if(head == null || head.next == null) return head;
ListNode odd = head, even = head.next, secHead = head.next;
while(even!=null && even.next!=null){
odd.next = even.next;
odd = odd.next;
even.next = odd.next;
even = even.next;
}
odd.next = secHead;
return head;
}
11. 链表中环的入口节点
链接:链表中环的入口节点
一个非常直观的思路是:我们遍历链表中的每个节点,并将它记录下来;一旦遇到了此前遍历过的节点,就可以判定链表中存在环。借助哈希表可以很方便地实现。使用HashSet避免重复。
public ListNode detectCycle(ListNode head) {
ListNode pos = head;
Set<ListNode> visited = new HashSet<ListNode>();
while (pos != null) {
if (visited.contains(pos)) {
return pos;
} else {
visited.add(pos);
}
pos = pos.next;
}
return null;
}
另一个方法:这道题本质上就是求一个相交点,一般来说这种题尝试使用快慢指针常常可以解决问题。我们设置一个快指针fast,一个慢指针slow,fast的速度是slow的两倍,两个指针都是从head出发。
我们假设两个指针在上图紫色点处相遇,设定路程a, b, c。
- 我们可以列出,fast所走的路程为a+n*(b+c)+b,slow所走的路程为a+b,n为fast在环中走过的圈数。
- 又因为fast的速度是slow的两倍,我们可以根据上述公式得到:a+n*(b+c)+b = 2*(a+b)。
- 对上式化简得a = (n-1)*(b+c)+c。也就是说a的距离是n-1倍圈的路程+c的距离。
- 那此时我们在head新建立一个指针ptr,让slow和ptr同时移动。ptr走完a时走到环的入口结点,此时slow正好走完n-1倍圈的路程+c的距离,也是环的入口节点。他们相交的点就必然是环的入口节点。
public ListNode detectCycle(ListNode head) {
if(head==null) return null;
ListNode fast = head, slow = head;
while(fast!=null){
slow = slow.next;
if(fast.next!=null){
fast = fast.next.next;
} else{
return null;
}
if(fast == slow){
ListNode ptr = head;
while(slow!=ptr){
slow = slow.next;
ptr = ptr.next;
}
return ptr;
}
}
return null;
}
这里需要注意的是,题目并没有保证一定有环,所以需要对fast判断是否为null。而且在快指针移动的时候,一定要判断是否会跳过null,否则很可能会把null当作节点调用方法,结果报错。同时也要注意边界条件,例如节点为null或者只有一个的情况。
12. LRU缓存
链接:LRU缓存
- LRU缓存指的是最近最少使用(Least Recently Used)缓存,是一种页面置换算法。当某段内存页被访问或者被刚刚创建到缓存时,就会放在头部;当缓存空间不够时,就会将最不常用的尾部数据清除。
- 这种特性使用HashMap和双向链表结合可以实现。HashMap以O(1)的时间复杂度来找key,双向链表用来来改变访问后的缓存顺序。HashMap的value用来装Node。
- 这里使用虚拟头节点和虚拟尾节点,可以少考虑一些边界条件,写起程序来更高效。
class LRUCache {
class Node{
int key;
int value;
Node left;
Node right;
public Node(){}
public Node(int key, int value){
this.key = key;
this.value = value;
}
}
Map<Integer, Node> map = new HashMap<>();
int capacity;
int size;
Node head;
Node tail;
public LRUCache(int capacity) {
this.capacity = capacity;
size=0;
head = new Node();
tail = new Node();
head.right = tail;
tail.left = head;
}
public int get(int key) {
if(!map.containsKey(key)) return -1;
Node node = map.get(key);
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
//如果已经有这个key
if(map.containsKey(key)){
Node node = map.get(key);
//那么更新对应value
node.value = value;
//并把该Node放到链表头
moveToHead(node);
} else{
//没有key那么添加节点
Node node = new Node(key, value);
map.put(key, node);
addToHead(node);
size++;
//如果容量已满
if(capacity < size){
//把map中的<k, v>去掉
map.remove(tail.left.key);
//把链表尾的节点去掉
removeTail();
size--;
}
}
}
public void addToHead(Node node){
node.right = head.right;
node.left = head;
head.right.left = node;
head.right = node;
}
public void removeTail(){
Node node = tail.left;
removeNodeLink(node);
node.left = null;
node.right = null;
}
public void removeNodeLink(Node node){
node.left.right = node.right;
node.right.left = node.left;
}
public void moveToHead(Node node){
removeNodeLink(node);
addToHead(node);
}
}