Leetcode分类解析:链表

Leetcode分类解析:链表


1.分类地图

按照惯例,首先来看一下链表在本系列所处的位置:

  1. 基础结构(Fundamentals)
    1.1 数组和链表(Array&List):插入、删除、旋转等操作。
    1.2 栈和队列(Stack&Queue):栈的典型应用。
    1.3 树(Tree):构建、验证、遍历、转换。
    1.4 字符串(String):转换、搜索、运算。
  2. 积木块(Building Block)
    2.1 哈希表(Hashing)
    2.2 分治(Divide-and-Conquer)
    2.3 排序(Sorting)
    2.4 二分查找(Binary Search)
  3. 高级算法(Advanced)
    3.1 组合算法(Combinatorial Algorithm)
    3.2 贪心算法(Greedy Algorithm):贪心的典型应用。
    3.3 动态规划(Dynamic Programming):广泛应用DP求最优解。
    3.4 扫描(Scan)
  4. 其他杂项(Misc)
    4.1 数学(Math)
    4.2 位运算(Bit Manipulation)
    4.3 矩阵(Matrix)
    4.4 区间(Interval)
    4.5 图(Graph)

因为数组本身并没有太多技巧,而更多地是在其基础上对其他更高级一些的数据结构和算法的应用,所以本文侧重链表部分的技巧和经验总结。


2.经验技巧总结

链表问题的特点和解决技巧整理如下:

  • 删除操作:技巧有dummy头结点和向前向后两种删除方式,注意删除和不删除情况下的指针移动
    • 83 Remove Duplicates from Sorted List
    • 82 Remove Duplicates from Sorted List II
    • 203 Remove Linked List Elements
    • 237 Delete Node in a Linked List
  • 反转操作:普通反转、“头插法”反转,注意两种方式的应用场景
    • 24 Swap Nodes in Pairs
    • 25 Reverse Nodes in k-Group
    • 61 Rotate List
    • 92 Reverse Linked List II
    • 206 Reverse Linked List
    • 234 Palindrome Linked List
  • 修改顺序:直接修改、新建链表两种方式
    • 86 Partition List
    • 147 Insertion Sort List
    • 328 Odd Even Linked List
  • 多指针移动:能够定位第K个结点,判断环位置等
    • 19 Remove Nth Node From End of List
    • 61 Rotate List
    • 141 Linked List Cycle
    • 160 Intersection of Two Linked Lists

3.解题思路分类整理

关于链表类问题的通用技巧,先要说的最关键一点就是:prev就是单向链表的生命线!prev是完成各种操作的必要条件,也是确定invariant的关键。所以我们一定要找好previous结点,围绕它展开编码。下面来具体说一下各个类型题各自的特点。


3.1 删除(Removal)

Dummy头的使用:当有可能操作头结点时,特别是删除操作,就要用到Dummy,这样能简化我们的处理逻辑。如果不会操作头结点则不需要,否则就画蛇添足了。例如第83题,删除duplicate,所以肯定不会删除头结点。

3.1.1 Dummy与删除方式

83-Remove Duplicates from Sorted List (Easy): Given a sorted linked list, delete all duplicates such that each element appear only once.
For example,
Given 1->1->2, return 1->2.
Given 1->1->2->3->3, return 1->2->3.

Hint: 从这道题我们可以学习一下删除结点的两种方法,可以说各种所长。第一种是向后比较结点,发现重复就删掉。因为可能会删掉后面的结点,所以一定要注意cur的判空条件。当正常遍历时,cur可能为空,当删掉了后面结点时cur.next可能为空,都要判断。第二种是向前比较结点,即用prev记录前一个结点的值,发现相同就删掉当前结点,判空条件简单一些,但一定注意prev和cur的更新。因为这道题肯定不会删除head,所以也就没用到dummy头结点。

    // Version-1: Compare cur and cur.next without prev
    // Note both cur and cur.next could reach null
    public ListNode deleteDuplicates(ListNode head) {
        if (head == null) {
            return null;
        }

        ListNode cur = head;
        while (cur != null && cur.next != null) {
            if (cur.val == cur.next.val) {
                cur.next = cur.next.next;
            } else {
                cur = cur.next;
            }
        }
        return head;
    }

    // Version-2: compare cur and prev
    // Note update of prev and cur
    public ListNode deleteDuplicates2(ListNode head) {
        if (head == null) {
            return null;
        }

        // Invariant: node prior to prev (inclusive) has no duplicates
        ListNode cur = head.next, prev = head;
        while (cur != null) {
            if (cur.val == prev.val) {
                prev.next = cur.next;
                cur = prev.next;
            } else {
                prev = cur;
                cur = cur.next;
            }
        }
        return head;
    }

3.1.2 其他习题

82-Remove Duplicates from Sorted List II (Medium): Given a sorted linked list, delete all nodes that have duplicate numbers, leaving only distinct numbers from the original list.
For example,
Given 1->2->3->3->4->4->5, return 1->2->5.
Given 1->1->1->2->3, return 2->3.

Hint: 因为要删除所有duplicate,而不是只保留一份,所以while循环里必须嵌套循环,发现duplicate就一删到底。如果只用一层循环,那么效果就跟第83题一样了,必须保留一份duplicate,否则下一轮循环时不知道之前重复的是哪个结点。

203-Remove Linked List Elements (Easy): Remove all elements from a linked list of integers that have value val.
Example:
Given: 1 –> 2 –> 6 –> 3 –> 4 –> 5 –> 6, val = 6
Return: 1 –> 2 –> 3 –> 4 –> 5

Hint: 非常简单,注意删除掉next结点的话就不用prev=prev.next了,删除结点时prev.next发生变化所以不用做什么,只有不删除时才向后移动,否则有可能空指针!

237-Delete Node in a Linked List (Easy): Write a function to delete a node (except the tail) in a singly linked list, given only access to that node. Supposed the linked list is 1 -> 2 -> 3 -> 4 and you are given the third node with value 3, the linked list should become 1 -> 2 -> 4 after calling your function.

Hint: 只给要被删除的结点和没有前继指针?怎么删?其实变相思考,不删结点而是交换值就好了。一开始把所有要删除结点之后的结点值都往前移动一位,实际上复杂了。只需将要删除结点的下一个结点值拷过来,然后删掉下一个结点就可以了。


3.2 反转与旋转(Reversal & Rotation)

“转”有很多种:Swap(in pair/in K group)交换(相当于两个结点的反转)、Reverse反转、Rotate旋转。最重要的是一定要注意:在操作之后不要留下Cycle,导致死循环。下面是Reverse的两道典型题,通过这两道题我们看一下Reverse的两种方法。

3.2.1 直接反转

206-Reverse Linked List (Easy): Reverse a singly linked list.

    // Iterative version
    public ListNode reverseList2(ListNode head) {
        if (head == null) {
            return null;
        }

        // prev->cur => prev<=cur, then move prev and cur off by one
        // invariant: nodes behind prev (inclusive) are reversed already
        ListNode prev = null, cur = head;
        while (cur != null) {
            ListNode tmp = cur.next;
            cur.next = prev;
            prev = cur;
            cur = tmp;
        }
        return prev;
    }

Hint: 这道题就不能用Dummy,一来没有用,处理后的head在尾部,二来处理不好还容易产生Cycle导致死循环。Invariant就是prev(包括它本身)之前的结点都是reverse完成的。

每次的执行操作是这样的:
开始状态:1->2->3
第一轮:prev=null,cur=1: null<-1 2->3
第二轮:prev=1, cur=2: null<-1<-2 3
第三轮:prev=2, cur=3: null<-1<-2<-3
结束:prev=3, cur=null

关于递归版本还动了些脑筋,reverseList()如果返回reverse后的头部,则与当前结点关联不上,如果返回尾部能关联上,但最后返回结果时又有问题。解决办法就是:依然返回reverse后的头部,但我们在递归调用之前先记住下一个结点,这样就相当于reverse后的头部和尾部都有了。

3.2.2 “头插法”

92-Reverse Linked List II (Medium): Reverse a linked list from position m to n. Do it in-place and in one-pass.
For example:
Given 1->2->3->4->5->NULL, m = 2 and n = 4,
return 1->4->3->2->5->NULL.
Note: Given m, n satisfy the following condition: 1 ≤ m ≤ n ≤ length of list.

下面来分析这第二道题,因为要Reverse的不是整个链表了,用前面的方法会非常麻烦!因为reverse最终的头部在末尾,我们要记录好区间前后的start和end位置,等待reverse后才能将start、reverse的结点、end串联起来。而且如果reverse区间包含第一个或最后一个还要额外判断,简直麻烦得要死啊…… 这时就要采用所谓的“头插法”,它能保证每一时刻链表都处于正确的reverse状态,而不用等待最后。这样dummy头又能用了,会少了很多判断。

以[1,2,3,4],m=2,n=4为例,对比看一下它的执行过程:
开始状态:dummy->1->2->3->4
第一轮:prev=1, cur=2, then=3: dummy->1->3->2->4
第二轮:prev=1, cur=2, then=4: dummy->1->4->3->2
结束:prev=1, cur=2, then=null

    public ListNode reverseBetween(ListNode head, int m, int n) {
        if (head == null) {
            return head;
        }

        ListNode dummy = new ListNode(0);
        dummy.next = head;

        // 1.Find node in front of range to be reversed
        ListNode prev = dummy;
        for (int i = 0; i < m - 1; i++) {
            prev = prev.next;
        }

        // 2.Do the reversion
        // Invariant: prev->...->cur->then->... => prev->then...->cur->... 
        // After that, "cur" stay the same, update "then"
        ListNode cur = prev.next;
        for (int i = 0; i < n - m; i++) {
            ListNode then = cur.next;
            cur.next = then.next;
            then.next = prev.next;
            prev.next = then;
        }
        return dummy.next;
    }

会了“头插法”真是多了一样武器,来看这道题的扩展,一道Hard级别的题,在“头插法”面前黯然失色,变得非常简单直接。

25-Reverse Nodes in k-Group (Hard): Given a linked list, reverse the nodes of a linked list k at a time and return its modified list. If the number of nodes is not a multiple of k then left-out nodes in the end should remain as it is. You may not alter the values in the nodes, only nodes itself may be changed. Only constant memory is allowed.
For example, Given this linked list: 1->2->3->4->5
For k = 2, you should return: 2->1->4->3->5
For k = 3, you should return: 3->2->1->4->5

Hint: 这道题用上面提到的“头插法”的话非常简单:因为最后一组保持不变,所以先获得链表长度看看需要reverse几组,然后对每组都执行“头插法”。把“头插法”提取为一个子方法,代码变得异常清晰!

    public ListNode reverseKGroup(ListNode head, int k) {
        if (head == null || k <= 1) {
            return head;
        }

        int len = 0;
        for (ListNode n = head; n != null; n = n.next) {
            len++;
        }

        ListNode dummy = new ListNode(0);
        dummy.next = head;

        ListNode prev = dummy;
        for (int i = 0; i < len / k; i++) {
            prev = reverseK(prev, k);
        }
        return dummy.next;
    }

    // Perform K-1 reversals for K group
    private ListNode reverseK(ListNode prev, int k) {
        ListNode cur = prev.next;
        if (cur != null) {      // when len is divisible by k, no node left in last batch
            while (k-- > 1) {
                ListNode then = cur.next;
                cur.next = then.next;
                then.next = prev.next;
                prev.next = then;
            }
        }
        return cur;
    }

3.2.3 应用:逆序插入或比较

如果说其他题是为了reverse而reverse的话,那么有两个题可以说是reverse的真实应用。143题要将链表Reorder成L1->Ln->L2->Ln-1->…这种顺序,而第234题则是判断Palindrome,即L1=Ln,L2=Ln-1…。可以看出这两道题非常相似,想顺序做是不可能的,因为它们都需要从Ln到Ln-1不断向前遍历。所以将链表的后半部分reverse之后,再进行插入或比较就可以了。

143-Reorder List (Medium): Given a singly linked list L: L0→L1→…→Ln-1→Ln, reorder it to: L0→Ln→L1→Ln-1→L2→Ln-2→…
You must do this in-place without altering the nodes’ values.
For example, Given {1,2,3,4}, reorder it to {1,4,2,3}.

Hint: 首先要找到中点,将前后两部分想办法合并成题目要求的顺序。尝试了一下直接合并,没有找到规律。于是还是老老实实地将后半部分Reverse之后再合并吧。关于找中点也有技巧,不需要遍历到末尾记住总个数,然后再来一遍找到中点。而是类似于141-Linked List Cycle那样,用快慢两个指针同时遍历,停下来时慢指针指的就是中点,巧妙吧!关于Corner Case就是链表只有1个或2个结点的情况,导致快慢指针没出发就停止了。Reverse后半部分时如果连着前半部分则容易糊涂,直接一刀切断就行了,这样后面合并时也比较清楚!

    public void reorderList(ListNode head) {
        if (head == null) {
            return;
        }

        // 1.Find middle node
        ListNode mid = head, fast = head;
        while (fast.next != null && fast.next.next != null) {
            mid = mid.next;
            fast = fast.next.next;
        }

        // 2.Reverse second half
        ListNode prev = mid, cur = prev.next;
        while (cur != null) {
            ListNode tmp = cur.next;
            cur.next = prev;
            prev = cur;
            cur = tmp;
        }
        mid.next = null;

        // 3.Merge two halves: p1 and p2 moves on two halves by turns
        // why only check p1...
        for (ListNode p1 = head, p2 = prev; p1 != null; ) {
            ListNode tmp = p1.next;
            p1 = p1.next = p2;
            p2 = tmp;
        }
    }

234-Palindrome Linked List (Easy): Given a singly linked list, determine if it is a palindrome.
Follow up: Could you do it in O(n) time and O(1) space?

Hint: 简单解法很容易,遍历链表时用一个ArrayList存所有值,然后检查ArrayList就行了。注意:ArrayList里的是Integer,判断相等要用equals而不能用==O(1)空间做法与第143题非常像!就是用快慢指针找到链表中点后,反转后半链表,然后从前后两个方向模仿数组那样检查Palindrome。注意:终止条件是right!=mid或left、right都不为空,如果只检查left!=right的话会空指针或死循环!同时关于reverse,不能用“头插法”,因为我们要在reverse完成后从后往前做比较,“头插法”是不合适的!

3.2.4 其他习题

24-Swap Nodes in Pairs (Easy): Given a linked list, swap every two adjacent nodes and return its head.
For example, given 1->2->3->4, you should return the list as 2->1->4->3.
Your algorithm should use only constant space. You may not modify the values in the list, only nodes itself can be changed.

Hint: 很简单的一道题,将swap(prev)单独拆出来作为一个函数会比较清晰。不变量是:prev(包括它自身)之前的结点都Swap完成,这样循环结束时(prev到达末尾)就完成了整个链表的Swap。


3.3 重排序(Reorder)

对于Reorder类的问题,比如让List前半部是奇数结点或者小于某个数等等,List比Array还有个优势,Array不能额外分配空间,所以只能定义好几个下标i、j、k之类,然后根据它们定义invariant。但对于List却可以直接将弄两个dummy结点,将原List拆成两部分最后合并到一起

3.3.1 直接操作

直接在一个链表中操作,即使是被移动的结点也一定要使用prev,不然结点被移动走后,它的前继结点还指向着它,可能最后会形成Cycle。在下一节要介绍的第二个方法就比较容易避免这个问题。

147-Insertion Sort List (Medium): Sort a linked list using insertion sort.

Hint: 数组版本的插入排序会不断向前比较,找到合适位置后右移其他大于当前的数据,然后再处理下一个。而链表版本则有两点不同:一是单向链表没法向前比较,所以变通一下,我们每次都从头往后比较,找到大于当前结点值的位置;其二是把当前结点插入到那个位置的操作可以说是普通插入的复杂版本,因为插入的元素本身也有前后结点连接着。所以,遍历当前结点的指针和前面找位置的指针都必须是前一个结点(prev),否则会产生Cycle导致死循环。还有个有趣的现象是,当把插入完成后,当前结点无需移动,因为后面的元素在一点点减少,循环会自己中止的。但注意如果当前结点在前半部分已是最大,无需移动时,这时需要手动移动当前指针以防死循环。

    public ListNode insertionSortList(ListNode head) {
        if (head == null) {
            return null;
        }

        ListNode dummy = new ListNode(-1);
        dummy.next = head;

        ListNode cur = head;
        while (cur.next != null) {
            // Find where to insert cur.next, or stop at cur
            ListNode pos = dummy;
            while (pos.next.val < cur.next.val) {
                pos = pos.next;
            }
            //  pos(a),pos.next(b),...cur(c),cur.next(d),cur.next.next(e)
            //  => a,d,b,...,c,e
            if (pos != cur) {
                ListNode tmp = pos.next;
                pos.next = cur.next;
                cur.next = cur.next.next;
                pos.next.next = tmp;
            } else {
                cur = cur.next; // error1: cur.next is updated already above, but it must update here!
            }
        }
        return dummy.next;
    }

3.3.2 新建链表

新建链表的好处就是不用考虑被移动结点的前继指向,因为最终原链表的所有结点都会被分类加入到新链表中,所以我们别忘了修改新链表中最后一个结点的指向就可以了。下面以第86题为例,来看一下这种方法,从代码上一眼就能看出这种方法的简洁。

86-Partition List: Given a linked list and a value x, partition it such that all nodes less than x come before nodes greater than or equal to x. You should preserve the original relative order of the nodes in each of the two partitions.
For example,
Given 1->4->3->2->5->2 and x = 3,
return 1->2->2->4->3->5.

    public ListNode partition(ListNode head, int x) {
        ListNode smallHead = new ListNode(0);
        ListNode largeHead = new ListNode(0);

        ListNode small = smallHead, large = largeHead;
        while (head != null) {
            if (head.val < x) {
                small = small.next = head;  // Nice!!!
            } else {
                large = large.next = head;
            }
            head = head.next;
        }
        small.next = largeHead.next;
        large.next = null;  // Note: don't worry about cycle, but remember this one!
        return smallHead.next;
    }

3.3.3 其他习题

328-Odd Even Linked List (Medium): Given a singly linked list, group all odd nodes together followed by the even nodes. Please note here we are talking about the node number and not the value in the nodes. You should try to do it in place. The program should run in O(1) space complexity and O(nodes) time complexity.
Example: Given 1->2->3->4->5->NULL, return 1->3->5->2->4->NULL.
Note: The relative order inside both the even and odd groups should remain as it was in the input. The first node is considered odd, the second node even and so on …

Hint: 这种不是删除结点而是移动重新插入结点的问题要注意:prev/cur等游标指针的移动,是不是后面结点被移走了就不用移动了,是不是后面结点移走了就null了等等。


3.4 多指针移动(Two Pointers)

用多个速度快慢的指针遍历链表能够解决很多奇妙的问题,典型的有找偏移K的结点、找中点、判断环位置等等。

K偏移的关键就是找准偏移位置。可关于这个位置还有不少定义,一定要看准题意:是从左从前还是从右从后数的第K个结点,是第K个结点还是第K个位置(第61题)。

下面以第19题为例,来看一下找偏移位置的方法。

3.4.1 K偏移

19-Remove Nth Node From End of List: Given a linked list, remove the nth node from the end of list and return its head. For example, given linked list: 1->2->3->4->5, and n = 2. After removing the second node from the end, the linked list becomes 1->2->3->5.
Note: Given n will always be valid. Try to do this in one pass.

分析:因为我们想要的结果是cur=null同时nprev指向Nth的前一个,也就是说两者之间有n个结点(这就是Invariant)。于是nprev等到cur已经走到n+1时再开始跟着走,这样就保证两者相隔n个结点,循环结束时也就达到我们想要的结果了。

    public ListNode removeNthFromEnd(ListNode head, int n) {
        ListNode dummy = new ListNode(-1);
        dummy.next = head;

        // 1.Find prev of nth node: there're n nodes between (nprev, cur)
        // -> when terminating (cur=null), nprev is previous node of nth
        // eg.[1,2,3,4,5], n=2: nprev=3, cur=null
        ListNode nprev = dummy, cur = dummy;
        for (int i = 0; cur != null; i++) {
            if (i >= n + 1) {       // Prove: nprev starts off when cur is already N+1 away -> #nodes in the middle = cur - nprev - 1 = n
                nprev = nprev.next;
            }
            cur = cur.next;
        }

        // 2.Delete nth node: nprev is at least dummy, which means delete first node
        // given n is always valid -> exclude the case n is too large or even negative
        nprev.next = nprev.next.next;
        return dummy.next;
    }

3.4.2 相遇

141-Linked List Cycle (Easy): Given a linked list, determine if it has a cycle in it. Follow up: Can you solve it without using extra space?

Hint: 代码很简单,关键是:思路太巧妙,实在想不到…… 感觉两个指针像是两个运动员在赛跑,两个“人”最终肯定会进入Cycle,并且快的一个最终将”套圈儿“追上慢的。第142题Linked List Cycle II则更进一步,变成了数学推导问题…… 所以我干脆把它放到数学分类去了……

    public boolean hasCycle(ListNode head) {
        ListNode fast = head, slow = head;
        while (fast != null && fast.next != null) { // no need to check slow
            slow = slow.next;
            fast = fast.next.next;
            if (fast == slow) {     // error: must put here
                return true;
            }
        }
        return false;
    }

3.4.3 其他习题

61-Rotate List (Medium): Given a list, rotate the list to the right by k places, where k is non-negative.
For example: Given 1->2->3->4->5->NULL and k = 2, return 4->5->1->2->3->NULL.

Hint: 务必注意!k指的不是旋转的Pivot位置,而是向右k个位置,也就是说k可能远大于链表长度!此外,实现旋转时我写的比较麻烦了,用两个指针相隔k(已经对长度len取余)一起往后跑直到快的指针到达末尾,再开始旋转。而技巧是:利用前面已经跑到链表末尾的指针,将链表首尾相连形成个环,然后继续跑len-k个位置再断开就可以了,比较巧妙!

160-Intersection of Two Linked Lists (Easy): Write a program to find the node at which the intersection of two singly linked lists begins.
For example, the following two linked lists:
A: a1 → a2

c1 → c2 → c3

B: b1 → b2 → b3
begin to intersect at node c1.
Notes:
1.If the two linked lists have no intersection at all, return null.
2.The linked lists must retain their original structure after the function returns.
3.You may assume there are no cycles anywhere in the entire linked structure.
4.Your code should preferably run in O(n) time and use only O(1) memory.

Hint: 一开始想得有些复杂了,想把A形成个环,然后将这道题转化成找Cycle起点的那道题。其实通过两个链表的长度差,通过两个指针就能找到这个Intersection的位置。有点像第61题将在第k个位置旋转链表,于是让一个指针先出发,另一个指针等待k个操作后再出发。还有种非常聪明的做法是:两个指针在两个链表上同时遍历,当到达末尾时就交换到另一个链表上继续遍历,当后一个指针发生交换后,两者就处于相同的位置了,可以一起继续遍历找相同结点了。代码非常简单,数学之美,非常聪明!

你可能感兴趣的:(LeetCode,算法)