LeetCode分类专题(一)——链表1

iwehdio的博客园:https://www.cnblogs.com/iwehdio/

学习自:labuladong的算法教程

本文包括链表leetcode相关题目:

  1. 92、25、234.
  2. 2、19、21、23、24、25、61、82、83、86.

1、数据结构的基本知识

数据结构的存储方式

  • 数据结构的存储方式只有两种:数组(顺序存储)和链表(链式存储)。
    • 数组由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N);而且你如果想在数组中间进行插入和删除,每次必须搬移后面的所有数据以保持连续,时间复杂度 O(N)。
    • 链表因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间。
  • 「队列」、「栈」这两种数据结构既可以使用链表也可以使用数组实现。用数组实现,就要处理扩容缩容的问题;用链表实现,没有这个问题,但需要更多的内存空间存储节点指针。
  • 「图」的两种表示方法,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并可以进行矩阵运算解决一些问题,但是如果图比较稀疏的话很耗费空间。邻接表比较节省空间,但是很多操作的效率上肯定比不过邻接矩阵。
  • 「散列表」就是通过散列函数把键映射到一个大数组里。而且对于解决散列冲突的方法,拉链法需要链表特性,操作简单,但需要额外的空间存储指针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些。
  • 「树」,用数组实现就是「堆」,因为「堆」是一个完全二叉树,用数组存储不需要节点指针,操作也比较简单;用链表实现就是很常见的那种「树」,因为不一定是完全二叉树,所以不适合用数组存储。为此,在这种链表「树」结构之上,又衍生出各种巧妙的设计,比如二叉搜索树、AVL 树、红黑树、区间树、B 树等等,以应对不同的问题。

数据结构的基本操作

  • 对于任何数据结构,其基本操作无非遍历 + 访问,再具体一点就是:增删查改。

  • 数据结构种类很多,但它们存在的目的都是在不同的应用场景,尽可能高效地增删查改

  • 如何遍历 + 访问?从最高层来看,各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的。

    • 线性就是 for/while 迭代为代表,非线性就是递归为代表。

    • 数组遍历框架,典型的线性迭代结构:

      void traverse(int[] arr) {
          for (int i = 0; i < arr.length; i++) {
              // 迭代访问 arr[i]
          }
      }
      
    • 链表遍历框架,兼具迭代和递归结构:

      /* 基本的单链表节点 */
      class ListNode {
          int val;
          ListNode next;
      }
      
      void traverse(ListNode head) {
          for (ListNode p = head; p != null; p = p.next) {
              // 迭代访问 p.val
          }
      }
      
      void traverse(ListNode head) {
          // 递归访问 head.val
          traverse(head.next);
      }
      
    • 二叉树遍历框架,典型的非线性递归遍历结构:

      /* 基本的二叉树节点 */
      class TreeNode {
          int val;
          TreeNode left, right;
      }
      
      void traverse(TreeNode root) {
          // 前序遍历
          traverse(root.left)
          // 中序遍历
          traverse(root.right)
          // 后序遍历
      }
      
    • 二叉树框架可以扩展为 N 叉树的遍历框架:

      /* 基本的 N 叉树节点 */
      class TreeNode {
          int val;
          TreeNode[] children;
      }
      
      void traverse(TreeNode root) {
          // 递归访问 root.val
          for (TreeNode child : root.children)
              traverse(child);
      }
      
    • N 叉树的遍历又可以扩展为图的遍历,因为图就是好几 N 叉棵树的结合体。图是可能出现环的,用个布尔数组 visited 做标记就行了。

2、链表

  • 链表题中的常用命名:
    • pred:前驱;
    • succ:后继;
    • cur:当前;
    • nxt:下一个;
    • head:头;
    • tail:尾。

递归反转链表的一部分

  • 单链表节点结构:

    // 单链表节点的结构
    public class ListNode {
        int val;
        ListNode next;
        ListNode(int x) { val = x; }
    }
    
  • 什么叫反转单链表的一部分呢,就是给你一个索引区间,让你把单链表中这部分元素反转,其他部分不变:

    image-20210110102040650

    • 注意这里的索引是从 1 开始的。
  • 迭代的思路大概是:

    • 先用一个 for 循环找到第 m 个位置,然后再用一个 for 循环将 m 和 n 之间的元素反转。

    • 具体的,用两个指针指向要反转的两个节点,用第三个指针指向下一个要反转的节点。prev指向反转完成的链表的头,third指向还未反转的链表的头,cur指向反转的节点。

      third = cur.next
      cur.next = prev
      prev = cur
      cur = third
      
    • 迭代实现思路看起来虽然简单,但是细节问题很多的,反而不容易写对。相反,递归实现就很简洁优美。

  • 递归思路:

    • 实现代码:

      ListNode reverse(ListNode head) {
          if (head.next == null) return head;
          ListNode last = reverse(head.next);
          head.next.next = head;
          head.next = null;
          return last;
      }
      
    • 对于递归算法,最重要的就是明确递归函数的定义。具体来说,我们的 reverse 函数定义是这样的:

      • **输入一个节点 head,将「以 **head 为起点」的链表反转,并返回反转之后的头结点
    • 明白了函数的定义,在来看这个问题。比如说我们想反转这个链表:

      image-20210110104302714

    • 那么输入 reverse(head) 后,会在这里进行递归:

      ListNode last = reverse(head.next);
      
    • 不要跳进递归,而是要根据刚才的函数定义,来弄清楚这段代码会产生什么结果

      image-20210110104422798

    • 这个 reverse(head.next) 执行完成后,整个链表就成了这样:

      image-20210110104521962

    • 并且根据函数定义,reverse 函数会返回反转之后的头结点,我们用变量 last 接收了。

    • 现在再来看下面的代码:

      head.next.next = head;
      

      image-20210110173619558

    • 接下来:

      head.next = null;
      return last;
      

      image-20210110104718902

    • 有两个地方需要注意:

      1. 递归函数要有 base case,也就是这句:

        if (head.next == null) return head;
        

        意思是如果链表只有一个节点的时候反转也是它自己,直接返回即可。

      2. 当链表递归反转之后,新的头结点是 last,而之前的 head 变成了最后一个节点,别忘了链表的末尾要指向 null:

        head.next = null;
        
  • 反转链表前n个节点:

    • 实现一个这样的函数:

      // 将链表的前 n 个节点反转(n <= 链表长度)
      ListNode reverseN(ListNode head, int n)
      
    • 比如说对于下图链表,执行 reverseN(head, 3)

      image-20210110105015552

    • 解决思路和反转整个链表差不多,只要稍加修改即可:

      ListNode successor = null; // 后驱节点
      
      // 反转以 head 为起点的 n 个节点,返回新的头结点
      ListNode reverseN(ListNode head, int n) {
          if (n == 1) { 
              // 记录第 n + 1 个节点
              successor = head.next;
              return head;
          }
          // 以 head.next 为起点,需要反转前 n - 1 个节点
          ListNode last = reverseN(head.next, n - 1);
      
          head.next.next = head;
          // 让反转之后的 head 节点和后面的节点连起来
          head.next = successor;
          return last;
      }
      
    • 具体的区别:

      1. base case 变为 n == 1,反转一个元素,就是它本身,同时要记录后驱节点
      2. 刚才我们直接把 head.next 设置为 null,因为整个链表反转后原来的 head 变成了整个链表的最后一个节点。但现在 head 节点在递归反转之后不一定是最后一个节点了,所以要记录后驱 successor(第 n + 1 个节点),反转之后将 head 连接上。
  • 反转列表的一部分:

    • 现在解决我们最开始提出的问题,给一个索引区间 [m,n](索引从 1 开始),仅仅反转区间中的链表元素。

      ListNode reverseBetween(ListNode head, int m, int n)
      
    • 首先,如果 m == 1,就相当于反转链表开头的 n 个元素嘛,也就是我们刚才实现的功能:

      ListNode reverseBetween(ListNode head, int m, int n) {
          // base case
          if (m == 1) {
              // 相当于反转前 n 个元素
              return reverseN(head, n);
          }
          // ...
      }
      
    • 如果 m != 1 怎么办?如果我们把 head 的索引视为 1,那么我们是想从第 m 个元素开始反转对吧;如果把 head.next 的索引视为 1 呢?那么相对于 head.next,反转的区间应该是从第 m - 1 个元素开始的;那么对于 head.next.next 呢……

    • 区别于迭代思想,这就是递归思想,所以我们可以完成代码:

      ListNode reverseBetween(ListNode head, int m, int n) {
          // base case
          if (m == 1) {
              return reverseN(head, n);
          }
          // 前进到反转的起点触发 base case
          head.next = reverseBetween(head.next, m - 1, n - 1);
          return head;
      }
      
  • 总结:

    • 递归的思想处理的技巧是:不要跳进递归,而是利用明确的定义来实现算法逻辑。这里的定义指的是,递归函数的返回值和递归函数中return的值。
    • 处理看起来比较困难的问题,可以尝试化整为零,把一些简单的解法进行修改,解决困难的问题。
    • 值得一提的是,递归操作链表并不高效。和迭代解法相比,虽然时间复杂度都是 O(N),但是迭代解法的空间复杂度是 O(1),而递归解法需要堆栈,空间复杂度是 O(N)。所以考虑效率的话还是使用迭代算法更好。
  • 92题 反转列表 Ⅱ:

    • 递归思路:

      /**
       * Definition for singly-linked list.
       * public class ListNode {
       *     int val;
       *     ListNode next;
       *     ListNode(int x) { val = x; }
       * }
       */
      class Solution {
          public ListNode reverseBetween(ListNode head, int m, int n) {
              if (m==1) return reverseStart(head, n);
              head.next=reverseBetween(head.next, m-1,n-1);
              return head;
          }
      
          ListNode succ = null;
          ListNode reverseStart(ListNode head, int n) {
              if (n==1) {
                  succ=head.next;
                  return head;
              }
              ListNode last = reverseStart(head.next, n-1);
              head.next.next = head;
              head.next=succ;
              return last;
          }
      }
      
    • 迭代思路:

      class Solution {
          public ListNode reverseBetween(ListNode head, int m, int n) {
              if(head==null) return null;
              ListNode pred  = null;
              ListNode cur = head;
              //移动到反转位置
              while(m-1>0){
                  pred = cur;
                  cur = cur.next;
                  m--;
                  n--;
              }
              //con是前一段链表尾,tail是反转部分的链表尾
              ListNode con = pred, tail = cur, temp = null;
              while(n>0){
                  temp = cur.next;
                  cur.next = pred;
                  pred = cur;
                  cur = temp;
                  n--;
              }
              //结束时,pred是反转部分的链表头,cur是下一段链表头
              if(con!=null){
                  con.next=pred;
              } else{
                  head=pred;
              }
              tail.next = cur;
              return head;
      }
      }
      

K个一组反转链表

image-20210110144840890

  • 子问题:

    • 这个问题的子问题其实就是将k个长度的链表反转。
    • 还需要考虑的是,反转后,与原链表的连接问题,所以需要维护指向前一段链表的尾和后一段链表的头的指针。
    • 最后,递归或迭代的终止条件,就是当不足k个,则不反转。
    • 例:k=2,前两个反转后,指向后续链表调用反转函数。

    image-20210110150207725

  • 递归向后,迭代反转:

    class Solution {
        public ListNode reverseKGroup(ListNode head, int k) {
            //保存下一段链表的头
            ListNode tail = head;
            for(int i=0; i<k; i++){
                if(tail==null) return head;
                tail = tail.next;
            }
            //返回的是反转部分的头
            ListNode newHead = reverse(head, k);
            //head是反转部分的尾
            head.next = reverseKGroup(tail,k);
            return newHead;
        }
    
        ListNode reverse(ListNode head, int num){
            //因为在上一层判断过了,所以head必然不为null
            ListNode cur = head, pred = null, temp = null;
            while(num-->0){
                temp = cur.next;
                cur.next = pred;
                pred = cur;
                cur = temp;
            }
            return pred;
        }
    }
    

回文链表

image-20210110153325442

  • 这道题的关键在于,单链表无法倒着遍历,无法使用双指针技巧。那么最简单的办法就是,把原始链表反转存入一条新的链表,然后比较这两条链表是否相同。

  • 其实,借助二叉树后序遍历的思路,不需要显式反转原始链表也可以倒序遍历链表

  • 树结构不过是链表的衍生。那么,链表其实也可以有前序遍历和后序遍历

    void traverse(ListNode head) {
        // 前序遍历代码
        traverse(head.next);
        // 后序遍历代码
    }
    
  • 如果想倒序遍历链表,就可以在后序遍历位置操作:

    /* 倒序打印单链表中的元素值 */
    void traverse(ListNode head) {
        if (head == null) return;
        traverse(head.next);
        // 后序遍历代码
        print(head.val);
    }
    
  • 模仿双指针实现回文判断:

    class Solution {
        ListNode left = null;
        public boolean isPalindrome(ListNode head) {
            left = head;
            return traverse(head);
        }
        boolean traverse(ListNode right){
            if(right==null) return true;
            boolean res = traverse(right.next);
            res = res &&(left.val==right.val);
            left = left.next;
            return res;
        }
    }
    
  • 这么做的核心逻辑是什么呢?实际上就是把链表节点放入一个栈,然后再拿出来,这时候元素顺序就是反的,只不过我们利用的是递归函数的堆栈而已。

  • 当然,无论造一条反转链表还是利用后序遍历,算法的时间和空间复杂度都是 O(N)。下面我们想想,能不能不用额外的空间,解决这个问题。

  • 优化思路:

    • 通过「双指针技巧」中的快慢指针来找到链表的中点。
    • 如果fast指针没有指向null,说明链表长度为奇数,slow还要再前进一步。
    • 总之,就是让slow指向后半个需要反转的链表头节点。

    image-20210110170209881

    • 最后反转链表后,比较值。
    class Solution {
        public boolean isPalindrome(ListNode head) {
            //设置快慢指针
            ListNode fast = head, slow = head;
            while(fast!=null && fast.next!=null){
                fast = fast.next.next;
                slow = slow.next;
            }
            if(fast!=null){
                slow = slow.next;
            }
            ListNode left = head;
            ListNode right = reverse(slow);
            while(right!=null){
                if(left.val!=right.val) return false;
                left = left.next;
                right = right.next;
            }
            return true;
        }
        //反转从head开始的节点,返回反转后的头结点
        ListNode reverse(ListNode head){
            ListNode cur = head, pred = null, temp = null;
            while(cur!=null){
                temp = cur.next;
                cur.next = pred;
                pred = cur;
                cur = temp;
            }
            return pred;
        }
    }
    
  • 但是,反转链表会破坏原来链表的结构。

    • 可以通过记录反转部分的头结点,和前半部分的尾节点,通过再进行一次反转恢复结构。

    image-20210110174111814

    p.next = reverse(q);
    

3、其他链表题目()

image-20210110174405438

两数相加

image-20210110174734087

image-20210110174740808

  • 直观解法非常简单,就是从后往前对链表中的值相加。

    • 需要考虑的一个是只要链表向后遍历的过程中有一个不是null,或者进位不是0,就说明需要新建一个链表节点。
    • 优化的话,如果其中一个链表为空并且进位为0了,就可以直接把另一个链表接到新链表后面。
    class Solution {
        public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
            ListNode head = new ListNode(0);
            int i = 0, add = 0, sum = 0;
            int v1 = 0, v2 = 0;
            ListNode curr = head;
            while(l1!=null || l2!=null || add>0){
                if(l1==null){
                    v1 = 0;
                } else {
                    v1 = l1.val;
                    l1 = l1.next;
                }
                if(l2==null){
                    v2 = 0;
                } else {
                    v2 = l2.val;
                    l2 = l2.next;
                }
                sum = v1 + v2 + add;
                add = sum / 10;
                curr.next = new ListNode(sum % 10);
                curr = curr.next;
            }
            return head.next;
        }
    }
    

删除链表的倒数第N个节点

image-20210110181118980

  • 首先n是有效的,也就是说对n的合法性不需要做判断。

  • 一趟扫描就要完成,需要用到多个指针,慢指针del与快指针last相距n-1。

    • 因为当n=1时,所要删除的就是最后一个节点。而要删除一个节点,最好使能获取其前一个节点的位置,直接把前一个节点的next指向next.next。
    • 然后快慢指针向后遍历,直到快指针的下一个为null,则删除此时慢指针指向的下一个元素。
    • 为了好处理快慢指针的初始化和删除链表第一个节点的问题,引入头哨兵header。
    class Solution {
        public ListNode removeNthFromEnd(ListNode head, int n) {
            ListNode header = new ListNode(0, head);
            ListNode del = header, last = header;
            while(n-->0) {
                last = last.next;
            }
            while(last.next!=null){
                last = last.next;
                del = del.next;
            }
            del.next = del.next.next;
            return header.next;
        }
    }
    

合并两个有序链表

image-20210111193058594

  • 迭代思路:

    • 首先同样构造一个头哨兵,从头哨兵开始,最后返回头哨兵的next。
    • 当l1和l2都不为空的时候向后遍历,比较值的大小。cur指向新链表的尾,l1和l2指向旧链表的头。
    • 当有一个为null时,直接将另一个接上就行了。
    class Solution {
        public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
            ListNode header = new ListNode(0);
            ListNode cur = header;
            while(l1!=null && l2!=null){
                if(l1.val <= l2.val) {
                    cur.next = l1;
                    l1 = l1.next;
                } else {
                    cur.next = l2;
                    l2 = l2.next;
                }
                cur = cur.next;
            }
            if(l1!=null) {
                cur.next = l1;
            } else {
                cur.next = l2;
            }
            return header.next;
        }
    }
    
  • 递归思路:

    • 递归基是为null时直接返回。但是因为用到了栈,空间复杂度不如迭代。
    class Solution {
        public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
            if (l1 == null) {
                return l2;
            } else if (l2 == null) {
                return l1;
            } else if (l1.val < l2.val) {
                l1.next = mergeTwoLists(l1.next, l2);
                return l1;
            } else {
                l2.next = mergeTwoLists(l1, l2.next);
                return l2;
            }
    
        }
    }
    

合并K个升序链表

image-20210111194350452

  • 常规想法,每次比较k个链表头的最小值,复杂度等于顺序合并:

    class Solution {
        public ListNode mergeKLists(ListNode[] lists) {
            ListNode header = new ListNode(0);
            ListNode cur = header;
            while(true) {
                ListNode min = null;
                int temp = -1;
                for(int i=0; i<lists.length; i++) {
                    if(lists[i]==null) {
                        continue;
                    } else {
                        if(min==null || lists[i].val<min.val) {
                            min = lists[i];
                            temp = i;
                        }
                    }               
                }
                if(temp == -1) {
                    break;
                }
                cur.next = min;
                cur = cur.next;
                lists[temp] = cur.next;
            }
            return header.next;
        }
    }
    
  • 相比于顺序合并两个相邻的链表,分治合并的效率更高。

    • 顺序合并:

      image-20210111202426685

    • 分治合并:

      image-20210111202442513

      image-20210111202454403

    • 分治时要注意,这里的递归基有两个,一个是如果区间lo和hi相等,就返回这个链表的指针。另一个是,如果输入数组为空就返回null。

    class Solution {
        public ListNode mergeKLists(ListNode[] lists) {
            return merge(lists, 0, lists.length-1);
        }
        ListNode merge(ListNode[] lists, int lo, int hi) {
            if(lo==hi) {
                return lists[lo];
            }
            if(lo>hi) {
                return null;
            }
            int mid = (lo+hi)/2;
            return mergeTwoLists(merge(lists, lo, mid), merge(lists, mid+1, hi));
        }
    
        ListNode mergeTwoLists(ListNode l1, ListNode l2) {
            ListNode header = new ListNode(0);
            ListNode cur = header;
            while(l1!=null && l2!=null){
                if(l1.val <= l2.val) {
                    cur.next = l1;
                    l1 = l1.next;
                } else {
                    cur.next = l2;
                    l2 = l2.next;
                }
                cur = cur.next;
            }
            if(l1!=null) {
                cur.next = l1;
            } else {
                cur.next = l2;
            }
            return header.next;
        }
    }
    

两两交换链表中的节点

image-20210111204106202

  • 递归形式,就是反转链表的变种:

    • 递归基是如果传入的链表只有一个节点或为null,就直接返回。
    • 否则,交换并链接头尾。
    class Solution {
        public ListNode swapPairs(ListNode head) {
            if(head==null || head.next==null){
                return head;
            } else {
                ListNode temp = head.next.next, ans = head.next;
                head.next.next = head;
                head.next = swapPairs(temp);
                return ans;
            }  
        }
    }
    
  • 迭代形式:

    • 迭代比较麻烦的就是处理null的问题。
    class Solution {
        public ListNode swapPairs(ListNode head) {
            if(head==null || head.next==null) return head;
            ListNode header = new ListNode(0, head), curr = head, tail = head.next.next, temp = null, ans=header;
            while(true) {
                temp = curr.next;
                temp.next = curr;
                curr.next = tail;
                header.next = temp;
                if(tail==null || tail.next==null){
                    return ans.next;
                } else {
                    header = header.next.next;
                    curr = tail;
                    tail = tail.next.next;
                    
                }
            }
        }
    }
    

旋转链表

image-20210111210100391

  • 最需要注意的一点就是,先首尾相连把链表变成环,再移动ans指针。不然的话需要单独处理shift为0的情况:

    class Solution {
        public ListNode rotateRight(ListNode head, int k) {
            if(head==null || head.next==null) return head;
            ListNode tail=head, ans=head, temp=null;
            int len = 1;
            while(tail.next!=null){
                tail = tail.next;
                len++;
            }
            tail.next = head;
            int shift = len - k % len;
            while(--shift>0){
                ans = ans.next;
            }
            temp = ans;
            ans = ans.next;
            temp.next = null;
            return ans;
        }
    }
    

删除链表中的重复元素

image-20210111212920821

  • 用递归的方式,每次传入处理好的链表尾和需要删除的链表头。

    • 检查链表头是否有重复的,有就删除。如果有的话需要特别注意null值的判定和删除后的指向。
    class Solution {
        public ListNode deleteDuplicates(ListNode head) {
            ListNode header = new ListNode(0, head);
            deleteD(header, head);
            return header.next;
        }
        void deleteD(ListNode header, ListNode head) {
            if(head==null || head.next==null) return;
            if(head.next!=null && head.val == head.next.val) {
                while(head.next!=null && head.val == head.next.val) {
                    head = head.next;
                }
                header.next = head.next;
                head = header;
            } 
            deleteD(head, head.next);
        }
    }
    

删除排序链表中的重复元素

image-20210111220639548

  • 与前一道题不同的是,重复的需要留下一个,其实就是把上一道题的header.next = head.next改为header.next = head。

    class Solution {
        public ListNode deleteDuplicates(ListNode head) {
            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;
        }
    }
    

分隔链表

image-20210111221254957

  • 分别用哨兵引出小于x和大于x的链:

    class Solution {
        public ListNode partition(ListNode head, int x) {
            ListNode header1 = new ListNode(0), header2 = new ListNode(0), cur1 = header1, cur2 = header2;
            while(head!=null) {
                if(head.val<x){
                    cur1.next = head;
                    cur1 = cur1.next;
                } else {
                    cur2.next = head;
                    cur2 = cur2.next;
                }
                head = head.next;
            }
            cur1.next = header2.next;
            cur2.next = null;
            return header1.next;
        }
    }
    

iwehdio的博客园:https://www.cnblogs.com/iwehdio/
原文地址:https://www.cnblogs.com/iwehdio/p/14264733.html