Java 中的链表分析

容器

我们平时都经常遇到容器这个词,那么 Java 集合中的容器指的是什么呢?**容器就是利用某种特定的数据结构来存储数据的。**在研究 Java 集合源码中时,我发现理解容器的关键要素很重要,因为这些关键元素在各个容器之间是通用的。

关键要素:

  • 物理结构

    数据结构分物理结构、逻辑结构。物理结构就是数据在计算机中是怎么存储的,有数组和链表两种方式。数组是内存中一块连续的存储空间,所以可以随机访问(利用索引就可以访问)。链表是内存中离散的一些存储空间,所以必须要通过头节点来顺序访问。

  • 容器的大小

    利用数组来实现的容器,它的容器大小就是数组的长度。利用链表来实现的容器,它的容器大小其实就是下面提到的 size。

  • 容量(capacity)

    达到容量进行扩容。 记住这一点。HashMap 可不是元素的个数达到数组的大小才进行扩容,它是达到容量(默认为 0.75 * table.length)就进行扩容的。为什么这样?我认为这是跟 HashMap 的底层是利用 hash 算法有关。当 hash 表中的元素达到某个数量时,随着元素的增多,冲突也越来越多,性能就会下降。

  • 容器中的元素个数(size)

    方便定位到容器中最后一个元素的位置

时间复杂度

这里以 Java 集合中的 LinkedList 为例分析一下时间复杂度。LinkedList 的实现是一个双向的链表,同时有头指针(引用)和尾指针。

注:我们平时看到的时间复杂度一般都为最坏时间复杂度。

addLast(E e)

O(1)

addFirst(E e)

O(1)

add(int index,E e)

O(n)

添加新节点需要循环遍历链表找到 index-1 位置上的节点,时间复杂度应该为 O(n)。

问题

addLast(E e) 为什么时间复杂度是 O(1) 呢?

我们一般在链表的尾部插入一个新的节点不是需要一个循环遍历链表找到最后一个节点,然后修改相应引用的指向吗?那时间复杂度应该是 O(n) 呀。确实是这样的,但是在 Java 的 LinkedList 中它利用了一个尾指针(引用) 记录了链表最后一个节点的位置,不需要再去遍历链表,所以时间复杂度为 O(1)。

应用

对一个链表的头部进行插入和删除就实现了栈的后进先出。

队列

对一个链表的头部进行插入,尾部进行删除就实现了队列的先进先出。

技巧

利用面向对象思维

插入的操作合二为一。

 private class Node {
        public E e;
        public Node next;

        public Node(E e, Node next) {
            this.e = e;
            this.next = next;
        }

        public Node() {
            this(null, null);
        }
    }
// ......
Node prev = head;
// ......
// 通过遍历链表找到要插入的位置的前一个链表节点

// 插入操作的步骤:
//1. 新节点指向 prev 的下一个节点
//2. prev 指向下一个节点的引用修改为新节点
// 一句话做了插入的所有操作。
prev.next = new Node(e, prev.next);

引入虚拟头节点

在没有虚拟头节点时,插入、删除操作需要额外考虑对头节点的处理逻辑,因为头节点的处理逻辑和中间节点的处理逻辑是不一样的。我以插入操作为例,下面是示例代码:

public void add(int index, E e) {
        if (index<0 || index > size)
            throw new IllegalArgumentException("Add failed. Illegal index");
        if (index == 0) {
            addFirst(e);
        } else {
            Node prev = dummyHead;
            for (int i = 0; i < index - 1; i++)
                prev = prev.next;
            prev.next = new Node(e, prev.next);
            size++;
        }
}

为了解决这个问题,我们引入了虚拟头节点。这样我操作头节点和中间节点都是一个逻辑,不需要去考虑太多的边界问题了。我以插入操作为例,下面是添加虚拟头节点后的示例代码:

public void add(int index, E e) {
        if (index<0 || index > size)
            throw new IllegalArgumentException("Add failed. Illegal index");
        Node prev = dummyHead;
        for (int i = 0; i < index; i++)
            prev = prev.next;
        prev.next = new Node(e, prev.next);
        size++;
}

巧用临时引用

对于删除操作,要用一个临时引用来暂时保存一下待删除节点的位置。临时引用可以让你更好的理解操作的逻辑。

public E remove(int index) {
        if (index<0 || index>=size)
            throw new IllegalArgumentException("Remove failed. Index is illegal.");
        Node prev = dummyHead;
        for (int i = 0; i < index; i++)
            prev = prev.next;
        // 临时引用
        Node retNode = prev.next;
        prev.next = retNode.next;
        retNode.next = null;
        size--;
        return retNode.e;
}

头插法

在没有尾引用指向链表的最后一个链表时,头插法的时间复杂度为 O(1),效率更好。

删除多个值

删除以后不需要移动 prev,因为删除后接下来的节点可能也与 val 相等。

public ListNode removeElements(ListNode head, int val) {
        ListNode dummyHead = new ListNode(-1);
        dummyHead.next = head;

        ListNode prev = dummyHead;
        while (prev.next != null) {
            if (prev.next.val == val) {
                ListNode delNode = prev.next;
                prev.next = delNode.next;
                delNode.next = null;
            } else {
                prev = prev.next;
            }
        }
        return dummyHead.next;
    }

觉得文章不错的朋友,欢迎关注我的公众号 Java知其所以然
在这里插入图片描述

展开阅读全文

没有更多推荐了,返回首页