算法和数据结构解析-6 : 链表问题

Posted 鮀城小帅

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了算法和数据结构解析-6 : 链表问题相关的知识,希望对你有一定的参考价值。

链表(Linked List)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。

由于不必须按顺序存储,链表在插入的时候可以达到 O(1)的复杂度,比另一种线性表 —— 顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要 O(n) 的时间,而顺序表相应的时间复杂度分别是 O(n) 和 O(1)。

链表允许插入和移除表上任意位置上的节点,但是不允许随机存取。链表有很多种不同的类型:单向链表,双向链表以及循环链表。

1. 反转链表

1.1 题目说明

反转一个单链表。

示例:

输入: 1->2->3->4->5->NULL

输出: 5->4->3->2->1->NULL

进阶:

你可以迭代或递归地反转链表。你能否用两种方法解决这道题?

1.2 分析

链表的节点结构ListNode已经定义好,我们发现,反转链表的过程,其实跟val没有关系,只要把每个节点的next指向之前的节点就可以了。

从代码实现上看,可以有迭代和递归两种形式。

 1.3 方法一:迭代

    /**
     * 方法一:迭代
     * @param head
     * @return
     */
    public ListNode reverseList(ListNode head)
        // 定义两个指针,指向当前访问的节点,以及上一个节点
        ListNode curr = head;
        ListNode prev = null;

        // 依此迭代链表中的节点,将next指针指向prev
        while(curr != null)
            // 假如有 1->2->3
            // 临时保存当前节点的下一个节点,curr=1,那么curr.next=2
            ListNode tempNode = curr.next;
            // 反转,将curr=1->2(next)反转为curr=1->null(prev)
            curr.next = prev;
            // 更新指针,当前指针变为之前的next,上一个指针变为curr。此时head=1->null已经反转好,将prev=curr=1,作为下一个基点反转的next
            prev = curr;
            // 此时,将curr=1.next=2,进入下一轮继续反转。
            curr = tempNode;
        
        return prev;
    

复杂度分析

时间复杂度:O(n),假设 n 是链表的长度,时间复杂度是 O(n)

空间复杂度:O(1)

1.4 方法二:递归

    /**
     * 方法二:递归
     * @param head
     * @return
     */
    public ListNode reverseList2(ListNode head)

        // 当head.next==null,说明是最后一个节点,比如5->null
        if(head == null || head.next == null)
            return head;
        
        
        // 获取当前节点的下一个节点,比如当前是1,next就是2
        ListNode listRest = head.next;
        // 递归调用,因为2->3,所以继续调用
        ListNode listNodeRest = reverseList2(listRest);
        
        // 递归调用return,此时将 node(m+1) -> node(m),也就是反转。即 4->5反转为 5->4 
        listRest.next = head;
        // 头结点指向null 4->null,因为会不断跳出,在return后,继续反转,所以4->3。直到2->1,此时1->null。然后结束
        head.next = null;
        
        return listNodeRest;
    

 2.合并两个有序链表

2.1 题目说明

将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。 

示例:

输入:1->2->4, 1->3->4

输出:1->1->2->3->4->4

2.2 分析

链表节点结构已经定义好,而且已经做了升序排列。现在我们需要分别遍历两个链表,然后依次比较,按从小到大的顺序生成新的链表就可以了。这其实就是“归并排序”的思路。

2.3 方法一:迭代

最简单的想法,就是逐个遍历两个链表中的节点,依次比对。

我们假设原链表为list1和list2。只要它们都不为空,就取出当前它们各自的头节点就行比较。值较小的那个结点选取出来,加入到结果链表中,并将对应原链表的头(head)指向下一个结点;而值较大的那个结点则保留,接下来继续做比对。

另外,为了让代码更加简洁,我们可以引入一个哨兵节点(sentinel),它的next指向结果链表的头结点,它的值设定为-1。

    /**
     * 方法一:迭代
     * @param l1
     * @param l2
     * @return
     */
    public ListNode mergeTwoLists(ListNode l1, ListNode l2) 

        // 定义一个哨兵节点
        ListNode sentinel = new ListNode(-1);
        // 保存当前结果链表里的最后一个节点,作为判断比较的“前一个节点”
        ListNode prev = sentinel;

        // 迭代遍历两个链表,直到至少有一个为Null
        while (l1 != null && l2 != null)
            // 比较当前两个链表的头节点,较小的那个按在结果链表末尾
            if (l1.value < l2.value)
                prev.next = l1;     // 将l1头节点连接到prev后面
                prev = l1;          // 指针向前移动,以下一个节点作为链表头节点
                l1 = l1.next;
            else
                prev.next = l2;     // 将l2头节点连接到prev后面
                prev = l2;          // 指针向前移动,以下一个节点作为链表头节点
                l2 = l2.next;
            
        
        // 循环结束,最多还有一个链表没有遍历完成,因为已经排序,可以直接把剩余节点接到结果链表上
        prev.next = (l1 == null)? l2:l1;
        return sentinel.next;
    

复杂度分析

时间复杂度:O(n + m) ,其中 n 和 m 分别为两个链表的长度。因为每次循环迭代中,l1 和 l2 只有一个元素会被放进合并链表中, 因此 while 循环的次数不会超过两个链表的长度之和。所有其他操作的时间复杂度都是常数级别的,因此总的时间复杂度为 O(n+m)。

空间复杂度:O(1)。我们只需要常数的空间存放若干变量。

2.4 方法二:递归

用递归的方式同样可以实现上面的过程。

当两个链表都不为空时,我们需要比对当前两条链的头节点。取出较小的那个节点;而两条链其余的部分,可以递归调用,认为它们已经排好序。所以我们需要做的,就是把前面取出的那个节点,接到剩余排好序的链表头节点前。

    /**
     * 方法二:递归
     * @param l1
     * @param l2
     * @return
     */
    public ListNode mergeTwoLists2(ListNode l1, ListNode l2) 
        // 基准情况
        if (l1 == null) return l2;
        if (l2 == null) return l1;

        // 比较头节点
        if (l1.value <= l2.value)
            // l1头节点比较小,直接提取出来,剩下的l1(l1头节点的next节点)和l2继续合并,接在l1后面
            l1.next = mergeTwoLists(l1.next, l2);
            return l1;
        else 
            l2.next = mergeTwoLists(l1, l2.next);
            return l2;
        
    

复杂度分析

时间复杂度:O(n + m),其中 nn 和 m 分别为两个链表的长度。因为每次调用递归都会去掉 l1 或者 l2 的头节点(直到至少有一个链表为空),函数 mergeTwoList 至多只会递归调用每个节点一次。因此,时间复杂度取决于合并后的链表长度,即 O(n+m)。

空间复杂度:O(n + m),其中 n 和 m 分别为两个链表的长度。递归调用 mergeTwoLists 函数时需要消耗栈空间,栈空间的大小取决于递归调用的深度。结束递归调用时 mergeTwoLists 函数最多调用 n+m 次,因此空间复杂度为 O(n+m)。

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

3.1 题目说明

给定一个链表,删除链表的倒数第 n 个节点,并且返回链表的头结点。

示例:

给定一个链表: 1->2->3->4->5, 和 n = 2.

当删除了倒数第二个节点后,链表变为 1->2->3->5.

说明:

给定的 n 保证是有效的。

3.2 分析

在链表中删除某个节点,其实就是将之前一个节点next,直接指向当前节点的后一个节点,相当于“跳过”了这个节点。

当然,真正意义上的删除,还应该回收节点本身占用的空间,进行内存管理。这一点在java中我们可以不考虑,直接由JVM的GC帮我们实现。

3.3 方法一:计算链表长度(二次遍历)

最简单的想法是,我们首先从头节点开始对链表进行一次遍历,得到链表的长度 L。

然后,我们再从头节点开始对链表进行一次遍历,当遍历到第 L-N+1 个节点时,它就是我们需要删除的倒数第N个节点。

这样,总共做两次遍历,我们就可以得到结果。

    /**
     * 方法一:计算链表长度(二次遍历)
     * @param head
     * @param n
     * @return
     */
    public ListNode removeNthFromEnd(ListNode head, int n)
        // 1.遍历链表,得到长度l
        int l = getLength(head);

        // 2.从前到后继续遍历,找到正数第l-n+1个元素
        // 定义一个哨兵节点,next指向头节点
        ListNode sentinel = new ListNode(-1);
        sentinel.next = head;

        ListNode curr = sentinel;
        for (int i = 0 ; i < l - n; i++)
            curr = curr.next;
        

        curr.next = curr.next.next;
        return sentinel.next;
    

    private static int getLength(ListNode head) 
        int length = 0;
        while (head != null)
            length++;
            head = head.next;
        
        return length;
    

3.4 方法二:利用栈

另一个思路是利用栈数据结构。因为栈是“先进后出”的,所以我们可以在遍历链表的同时将所有节点依次入栈,然后再依次弹出。

这样,弹出栈的第 n 个节点就是需要删除的节点,并且目前栈顶的节点就是待删除节点的前驱节点。这样一来,删除操作就变得十分方便了。

    /**
     * 方法二:计算链表长度(二次遍历)
     * @param head
     * @param n
     * @return
     */
    public ListNode removeNthFromEnd2(ListNode head, int n)

        // 2.从前到后继续遍历,找到正数第l-n+1个元素
        // 定义一个哨兵节点,next指向头节点
        ListNode sentinel = new ListNode(-1);
        sentinel.next = head;

        ListNode curr = sentinel;

        // 定义一个栈
        Stack<ListNode> stack = new Stack<>();

        // 将所有节点入栈
        // 入栈依次为 null、1、2、3、4、5
        while (curr != null)
            stack.push(curr);
            curr = curr.next;
        

        // 弹栈n次,假如n=2,出栈为5、4
        for (int i = 0 ; i < n; i++)
            stack.pop();
        

        // 栈顶元素为3,将3.next->5
        // (3.next->4.next->5),则 3->5
        stack.peek().next = stack.peek().next.next;
        return sentinel.next;
    

复杂度分析

时间复杂度:O(L),其中 L是链表的长度。我们压栈遍历了一次链表,弹栈遍历了N个节点,所以应该耗费O(L+N)时间。N <= L,所以时间复杂度依然是O(L),而且我们可以看出,遍历次数比两次要少,但依然没有达到“一次遍历”的要求。

空间复杂度:O(L),其中 L 是链表的长度。主要为栈的开销。

3.5 方法三:双指针(一次遍历)

我们可以使用两个指针 first 和 second 同时对链表进行遍历,要求 first 比 second 超前 N 个节点。

这样,它们总是保持着N的距离,当 first 遍历到链表的末尾(null)时,second 就恰好处于第L-N+1,也就是倒数第 N 个节点了。

    /**
     * 方法三:双指针(一次遍历)
     * @param head
     * @param n
     * @return
     */
    public ListNode removeNthFromEnd3(ListNode head, int n)

        // 1.从前到后继续遍历,找到正数第l-n+1个元素
        // 定义一个哨兵节点,next指向头节点
        ListNode sentinel = new ListNode(-1);
        sentinel.next = head;

        // 定义前后双指针
        ListNode first = sentinel, second = sentinel;

        // first先走n+1步
        for (int i = 0 ; i < n+1; i++)
            first = first.next;
        
        // first、second同时前进,当first变为null,second就是倒数第n+1个节点
        while (first != null)
            first = first.next;
            second = second.next;
        

        second.next = second.next.next;
        return sentinel.next;
    

复杂度分析

时间复杂度:O(L),其中 L是链表的长度。这次真正实现了一次遍历。

空间复杂度:O(1)。

以上是关于算法和数据结构解析-6 : 链表问题的主要内容,如果未能解决你的问题,请参考以下文章

打卡算法23合并K个升序链表 算法解析

数据结构与算法之深入解析“合并两个有序链表”的求解思路与算法示例

算法和数据结构解析-6 : 链表问题

算法和数据结构解析-6 : 链表问题

数据结构与算法之深入解析“排序链表”的求解思路与算法示例

算法系列——合并K个升序链表