链表算法题目总结

文章目录

    • 前言
    • 1.需要操作单链表中倒数某个指定节点
      • 1.1 解题思路及代码模板
      • 1.2 练习题目
    • 2. 回文链表 && 环形链表类型算法
      • 2.1 解题思路及代码模板
    • 3. 链表排序 && 合并链表
      • 3.1 解题思路
    • 4.链表交换类型题目
    • 最后

前言

最近也是在刷算法题,从刷题刷的开始怀疑人生,觉得自己好’菜’,到后来慢慢的找到了写算法的感觉,然后再到对算法产生了极大的兴趣。其实一开始做算法确实挺痛苦的,那种挫败感让我很沮丧,但是最近两个星期做下来,感觉算法没有想象的那么恐怖吧,因为我们不需要去创造算法,我们只需要掌握解题的思路和方法,然后加以刻意训练都是可以掌握的。

这里我建议大家,如果刷算法题不要直接从LeetCode列表上开始刷,因为算法题实在是太多了刷根本是不可能刷完的,所以我建议大家可以从某一类的题型开始刷,我建议先从链表或者树开始去做,这里我给出我的理由:

第一、像数组类型的算法题数量实在是太多,而且题型考察的形式也非常多,短时间内你看不到自己的进步很容易放弃,树和链表的算法题型比较少,考察的形式也不是很多,掌握起来相对还是比较容易的,短时间内可以提升自己做算法题的信心。

第二、像单链表的一些题目,它对指针的使用是非常多的,像我们做算法题比较难的还是对于指针的一些使用。虽然有些算法题可以借助一些语言提供的工具类来做,但是会额外的使用多余的内存空间,可能某些算法题要求你必须使用O(1)的空间复杂度,这种情况下就必须使用指针来做了。

第三、当你刷完单链表的题目,你对于指针的使用会初步的有一定感觉,对于后面一些算法题目是很好的帮助。

所以我建议大家如果开始刷题,建议从链表题目开始刷起。

1.需要操作单链表中倒数某个指定节点

1.1 解题思路及代码模板

这类算法题目,核心的关键是需要定位到题目所给的具体某个节点的位置,只要找到这个指定的节点位置,剩下的问题也就很好解决了。我下面列出了几道LeetCode上的算法题,一起感受一下。

  1. 返回一个链表的倒数第 k 个节点
  2. 删除链表的倒数第N个节点
  3. 旋转链表

我们做这类的题目,思路基本上都差不多,我们可以把这种类似的题目总结出来一个模板:这类题经常使用的就是快慢指针来解题,通过快慢指针定位到我们想要操作的节点位置,找到了相应的位置,根据题目对其进行相应的操作就可以了。这里我简单的做了一个动图,先让快指针走k步,然后让快慢指针一起向后移动,就找到了倒数第二个链表节点。

链表算法题目总结_第1张图片

其模板代码如下:

public static ListNode method(ListNode head, int k) {
        //快指针,我们先让它走k步,
        ListNode fast = head;
        for (int i = 0; i < k; i++) {
            fast = fast.next;
        }
        //然后将快的指针和慢的指针一起走,当快指针走到链表末尾,慢指针指向的就是倒数第K个节点
        ListNode temp = head;
        //你要找的倒数第k个节点的前一个节点。因为如果你要操作第k个节点,一般需要找到第K个节点的前前驱节点
        //像上面第2道的算法题,删除链表的第K个节点就需要用到preNode
        ListNode preNode = null;
        while (fast != null ) {
            preNode = temp;
            temp = temp.next;
            fast = fast.next;
        }
        return temp;
    }

第一道算法题,我们利用上面这个模板代码就可以搞定。

第二道算法题,需要删除倒数第k个节点,我们还是利用上面的代码模板找到,不过这里要操作倒数第k个节点,一定要先找到这个节点的之前节点。、

第三道算法题,在Leetcode上是一道中等难度的题,其思路和前面两道算法题大致思路都是一样的,我这里就不把原题copy过来了,上面的链接直接就能跳转到题目。题意是:给定一个链表,旋转链表,将链表每个节点向右移动 k 个位置,其中 k 是非负数。

这个题目没有限制说旋转的长度不能超过链表的长度,所以说旋转的长度可能会超过链表的长度,所以这道算法题主要的思路还是要找到需要旋转的节点:

  1. 计算链表的长度
  2. 判断旋转的长度是多少,这里利用 k % length;
  3. 利用我们上面说的方法定位到需要旋转的节点。
  4. 得到我们想要的结果。

其完整代码如下:

 public ListNode rotateRight(ListNode head, int k) {
      if(head ==null||head.next ==null){
            return head; 
        }
        //先拿到链表的长度。
        int nodeLength = 0;
        ListNode d = head;
        while (d != null) {
            nodeLength++;
            d = d.next;
        }
        //拿到长度以后,计算需要移动几个节点。
        int remaind =k  % nodeLength ;
        //如果mid等于0,相当于没有移动。
        if (mid == 0) {
            return head;
        }
        d = head;

        //这里就是我们上面所说的快慢指针,先让快的指针走remaind
        for (int i = 0; i < remaind; i++) {
            d = d.next;
        }
        ListNode temp = head;
        //记录慢指针指向的前一个节点
        ListNode prev = null;
        //记录快指针指向的前一个节点
        ListNode tailPrev = null;
        while (d != null) {
            prev = temp;
            tailPrev = d;
            temp = temp.next;
            d = d.next;
        }
        prev.next = null;
        tailPrev.next = head;
        return temp;
    }

除了这类需要找到倒数第k个节点以外,其实还有一类也是需要找到指定的节点位置,这个节点是比较特殊的一个中间节点,这样类似的题目我们也需要利用快慢指针就可以解决,我们让快指针每次走两步,我们让慢指针每次走一步,这样当快指针走到链表的末尾,慢指针就指向了链表的中间节点。

public static ListNode findMidNode(ListNode head) {
        ListNode fastNode = head;
        ListNode slowNode = head;
        ListNode prev = null;
        while (fastNode != null && fastNode.next != null) {
            prev = slowNode;
            fastNode = fastNode.next.next;
            slowNode = slowNode.next;
        }
        return slowNode;
    }

1.2 练习题目

看完上面的,那就拿这个题来练一练,原题目在这里:重排链表。

给定一个单链表 L:L0→L1→…→L**n-1→Ln你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。示例 1:给定链表 1->2->3->4, 重新排列为 1->4->2->3. 示例 2:给定链表 1->2->3->4->5, 重新排列为 1->5->2->4->3

首先我说一下我的思路,其实看到这道题我们就会发现,其实这道题就是以链表的中点为准,将链表分成了两部分,将链表的后半部分倒序以后,挨个插入到前半部分的链表中。这样的话就可以用我们上边所说的那个模板,拿到两部分的链表了,得到前后两部分的链表以后,就合并这两部分链表就可以了。这里如何将后半部分的链表翻转,也是有模板可以套用的。

翻转链表的思路其实有两种一种是在原始链表上,另外一种利用栈这种数据结构,当空间复杂度为O(1)的时候,我们不能使用额外的数据结构,只能使用指针来进行操作链表进行翻转。

public static ListNode reverseList(ListNode head) {
        ListNode curr = head, prev = null;
        ListNode temp;
        while (curr != null) {
            temp = curr.next;
            curr.next = prev;
            prev = curr;
            curr = temp;
        }
        return prev;
}

另外一种方法就是利用栈来进行,写法比较简单,这里大家可以自己写一下。下面是重排链表完整的代码

public class Solution {  
	public void reorderList(ListNode head) {
        if (head == null || head.next == null || head.next.next == null) {
            return;
        }
        //找到链表的中间节点位置
        ListNode fast = head;
        ListNode slow = head;
        ListNode prev = null;
        while (fast != null && fast.next != null) {
            prev = slow;
            slow = slow.next;
            fast = fast.next.next;
        }
        //这里就改变了原链表 head,head是前半部分链表
        prev.next = null;
        //slow 就是后半部分的链表,这里将后半部分链表反转。这里可以利用栈。
        ListNode listNode = reverseList(slow);
        
        //这里就将两个链表合并成一个,这里你可以新建一个链表去做,也可以直接在head的上操作。但是我测试性能的效果没差,
        //在head上操作可能会稍微复杂一些,需要你用好几个指针来做,不熟练的可以直接新建一个链表,因为指针指来指去的容易把自己弄迷了。
        //利用栈也是可以实现的,这里自己可以试一下。
        ListNode dummy = new ListNode(-1);
        ListNode curr = dummy;
        while(head != null && listNode != null){
            curr.next = head;
            head = head.next;
            curr.next.next = listNode;

            listNode = listNode.next;
            curr = curr.next.next;
        }
        //这里需要注意的是判断后半部分的链表是否为空,可以知道链表是奇数节点还是偶数节点。
        //如果是奇数节点的话,链表的后半部分会比前半部分多一个节点,因为我们把中间的那个节点算到的后半部分里面。
        if(listNode != null){
            curr.next = listNode;
        }
        head = dummy.next;
    }

    public static ListNode reverseList(ListNode head) {
        ListNode curr = head, prev = null;
        ListNode temp;
        while (curr != null) {
            temp = curr.next;
            curr.next = prev;
            prev = curr;
            curr = temp;
        }
        return prev;
    }
}

2. 回文链表 && 环形链表类型算法

回文链表和环形链表的题目相对还是简单一些。回文链表的判断就和第一节我们说的那样,利用快慢指针找到中点的位置,然后对比后半部分和前半部是否是逆序的就可以了。这里我们就重点来说一下环形链表的解题思路。

2.1 解题思路及代码模板

LeetCode上原题:判断一个链表是否是环形链表

第一种思路,我们可以利用hash表,我们遍历整个链表,如果存在环,那肯定会出现一个节点访问两次的情况。

 public static boolean hasCycle2(ListNode head) {
  	 HashSet set = new HashSet();
     while (head != null) {
         if (set.contains(head)) {
             return true;
         } else {
             set.add(head);
         }
         head = head.next;
     }
     return false;
}

利用hash表这种解法,其时间复杂度为O(n),空间复杂度也是O(n)。这种方法是最简单也是最容易想到的。

第二种思路,利用快慢指针,我们让快指针的速度是慢指针的两倍,这样去遍历整个链表,如果单链表中存在有环,那么两个指针一定会在某一个节点相遇。代码如下:

public  boolean hasCycle(ListNode head) {
    ListNode slow = head;
    ListNode fast = head;
    while (fast != null && fast.next != null) {
        fast = fast.next.next;
        slow = slow.next;
        if (fast == slow) {
            return true;
        }
    }
    if (head == null || head.next == null) {
        return false;
    }
    return false;
}

环形链表的进阶题: 返回一个链表入环的第一个节点,如果没有环返回Null

链表算法题目总结_第2张图片

如果一个单链表存在环,那么肯定会在环上的某一个节点两个指针相遇。当两个指针相遇时,慢指针走了len + s1 的长度;快指针走了len+n(s1+s2)+s1的长度,因为快指针一直在环上循环所以是n(s1+s2)。因为快指针是慢指针速度的两倍,相同时间内走过的长度肯定是相等的,所以可以得出公式:2(len+S1) = len + n(S1+S2)+S1,化简之后得到公式len = (n-1)S1 + nS2。n代表的是快指针和慢指针相遇的时候,快指针在环上转了几圈。这个等式是恒成立的,可以自己自行带入测试一下哦。当我们忽略 n 这个变量的时候,让n =1 ,公式就变成了 len = s2。那么就是当两个指针相遇以后,分别再让两个指针从A和C节点同时出发,每次移动一个节点,当再次相遇的时候就是环形链表的入口节点了。代码如下:

public  ListNode hasCycle(ListNode head) {
    ListNode slow = head,fast = head;
    while (fast != null && fast.next != null) {
        fast = fast.next.next;
        slow = slow.next;
        if (fast == slow) {
            break;
        }
    }
    if (fast == null || fast.next == null) return null;
    fast = head;
    while (fast != slow) {
        fast = fast.next;
        slow = slow.next;
    }
    return fast;
}

3. 链表排序 && 合并链表

算法题目:

  1. 链表排序
  2. 合并两个有序链表
  3. 合并K个有序链表

3.1 解题思路

第一道算法题,链表排序和正常的数据排序是一样解题思路,因为链表不能随机访问链表中的节点,我们只能通过遍历整个链表,这里我们可以利用归并排序的方法对链表进行排序,相信大家对归并排序已经很熟悉了,这里我们直接来上代码。

对链表进行归并排序,我们首先要找到链表的中点将链表分成两部分,对这两部分链表进行排序。然后分别在对链表的这两部分重复前面的动作,直到结束。

public static ListNode sortList(ListNode head) {
    if (head == null || head.next == null) {
        return head;
    }
    ListNode fast = head.next, slow = head;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    ListNode tmp = slow.next;
    slow.next = null;
    ListNode left = sortList(head);
    ListNode right = sortList(tmp);
    return merge(left, right) ;
}
//合并左右两部分
private static ListNode merge(ListNode l1, ListNode l2) {
    ListNode temp1 = l1, temp2 = l2;
    ListNode dummy = new ListNode(-1);
    ListNode prev = dummy;
    while (temp1 != null && temp2 != null) {
        if (temp1.val > temp2.val) {
            dummy.next = temp2;
            temp2 = temp2.next;
        } else {
            dummy.next = temp1;
            temp1 = temp1.next;
        }
        dummy = dummy.next;
    }
    dummy.next = temp1 != null ? temp1 : temp2;
    return prev.next;
}

其实合并两个有序链表,是我们第一道算法题中的一部分,合并两个有序链表的代码就直接可以用到第一道算法题的后面部分。这里需要注意的是,此方法只能用于合并两个有序链表

那像第三道算法题合并K个有序链表该怎么做呢?这里其实有很多种方法,比如:

1.利用Java中的优先队列PriorityQueue来实现,因为PriorityQueue底层使用的是二叉堆来实现的,最小的元素永远在堆顶,这样我们就可以多个有序链表合并了。不过这里要注意的是,多个链表是有序的我们才可以这样做,当合并多个无序的链表,我们就不能这样做了。

2.我们可以还是继续利用我们第二题的思路来,不管合并几个链表,我们可以将他们两两合并,最后汇总成一个链表,这种解法无论链表是有序还是无序的,我们都可以这样操作。

  1. 优先队列
public static ListNode mergeKLists(ListNode[] lists) {
    Queue<ListNode> pq = new PriorityQueue<>((v1,v2)->v1.val - v2.val);
    for (ListNode node : lists) {
        if (node != null) {
            pq.offer(node);
        }
    }

    ListNode dummyHead = new ListNode(-1);
    ListNode tail = dummyHead;
    while (!pq.isEmpty()) {
        ListNode minNode = pq.poll();
        tail.next = minNode;
        tail = minNode;
        if (minNode.next != null) {
            pq.offer(minNode.next);
        }
    }
    return dummyHead.next;
}
  1. 两两合并
public static ListNode mergeKLists2(ListNode[] lists) {
    if (lists == null || lists.length == 0) {
        return null;
    }
    return helper(lists, 0, lists.length - 1);
}

private static ListNode helper(ListNode[] lists, int begin, int end) {
    if(begin==end) {
        return lists[begin];
    }
    int mid = begin+(end-begin)/2;
    ListNode left = helper(lists,begin,mid);
    ListNode right = helper(lists,mid+1,end);
    return merge(left,right);
}

//合并两个有序链表
private static ListNode merge(ListNode l1, ListNode l2) {
    ListNode temp1 = l1, temp2 = l2;
    ListNode dummy = new ListNode(-1);
    ListNode prev = dummy;
    while (temp1 != null && temp2 != null) {
        if (temp1.val > temp2.val) {
            dummy.next = temp2;
            temp2 = temp2.next;
        } else {
            dummy.next = temp1;
            temp1 = temp1.next;
        }
        dummy = dummy.next;
    }
    dummy.next = temp1 != null ? temp1 : temp2;
    return prev.next;
}

4.链表交换类型题目

  1. 翻转指定节点位置的链表
  2. 两两交换链表中的节点
  3. K个一组翻转链表

这类型的题目没有特别固定模板可以套用,你唯一需要做的就是掌握到解决这种题目的感觉。虽然说没有固定的模板可以套用,但是还是有重点可以画出来的。第一步:我们需要判断出什么时候需要交换两个节点。就拿上面的第一道算法题来说,需要交换的节点是第三个至第五个节点。第二步:然后找到需要交换节点的前驱节点和后继节点。这样你才能成功移动节点。第三步:这一步就需要判断如何移动指针,可以满足你后续循环动作。

链表算法题目总结_第3张图片

我们就拿第一道算法题来说,为了方便理解,可以参考上面的动画,按照我们上面说的一起来看一下时候解决问题。

第一步:根据题意,我们找到需要交换的节点位置。 这里题目给出了需要交换 k —> n 之间的节点,那之外的节点是不需要动的。所以这里我们需要判断我们遍历的链表长度是否是在k - n 之间的节点。

第二步:找到需要交换节点的前驱和后继节点。这里我遍历的是3-5之间的链表节点。那既然已经确定了需要改变的节点位置,那就找到需要改变节点的前驱和后继节点,利用两个指针preNodenextNode 保存前驱和后继节点。然后将链表交换到我们想要让它去的位置。

第三部:我们将节点交换完成以后,我们还需要将我们需要用到的指针也做相应的移动。

完整代码如下:

public static ListNode reverseBetween(ListNode head, int m, int n) {
    ListNode dummy = new ListNode(-1);
    dummy.next = head;
    ListNode prev = dummy;
    int length = 0;
    ListNode temp;
    while (head.next != null) {
        length++;
        //找到需要交换节点的条件
        if (length >= m && length < n) {
            //保存head 的后继节点
            temp = head.next;
            //head.next 指向后继节的后继节点
            head.next = temp.next;
            temp.next = prev.next;
            prev.next = temp;
        } else {
            if (length < m) {
                prev = prev.next;
            }
            head = head.next;
        }
    }
    return dummy.next;
}

第二道算法题,两两交换链表,这都题目其实很简单,这里最主要的还是控制好指针指向的位置。

第三道算法题在LeetCode上是一道困难的算法题,其实我们抛开题的难度,把题目分解一下就会发现其实也很简单。题目的要求是K个一组翻转链表,且K是小于链表的长度的。其实K个一个翻转链表,其实和翻转单个链表没有什么两样,只不过说我们要把K个链表分为一组进行翻转,然后将每个翻转以后的链表再拼接起来就可以了。

public static ListNode reverseKGroup(ListNode head, int k) {
    ListNode dummy = new ListNode(-1);
    dummy.next = head;
    ListNode pre = dummy;
    ListNode end = dummy;
    while (end.next != null) {
        for (int i = 0; i < k && end != null; i++) {
            end = end.next;
        }
        if (end == null) {
            break;
        }
        ListNode start = pre.next;
        ListNode next = end.next;
        end.next = null;
        pre.next = reverse(start);
        start.next = next;
        pre = start;
        end = start;
    }
    return dummy.next;
}
public static ListNode reverse(ListNode head) {
    ListNode curr = head, prev = null;
    ListNode temp;
    while (curr != null) {
      temp = curr.next;
      curr.next = prev;
      prev = curr;
      curr = temp;
    }
    return prev;
}

这里就不把所有的类型都一一给列举出来了,大部分的算法题目都是差不多的,我这里把他们大致分为了:链表指定节点的操作、链表排序、环形链表、链表节点交换、链表查找和删除、链表的反转等类型,思路是一样的,但是还是要多多的练习,有思路和能写出来是完全两码事。

最后

这里就不把所有的类型都一一给列举出来了,大部分的算法题目都是差不多的,我这里把他们大致分为了:链表指定节点的操作、链表排序、环形链表、链表节点交换、链表查找和删除、链表的反转等类型,思路是一样的,但是还是要多多的练习,有思路和能写出来是完全两码事。

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