数据结构-线性表-链表

587 阅读11分钟

链表

概念

链表(Linked list)是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中的每个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。

存储结构

相比于数组,链表是一种稍微复杂一点的数据结构。对于这两个非常基础、非常常用的数据结构,我们常常会放到一块儿来比较。所以我们先来看,这两者有什么区别。

首先我们从底层存储结构来看:

数组:需要一块连续的存储空间,对内存的要求比较高,比如我们要申请一个1000M的数组,如果内存中没有连续的足够大的存储空间则会申请失败,即便内存的剩余可用空间大于1000M,仍然会申请失败。

链表:与数组相反,它并不需要一块连续的内存空间,它通过指针将一组零散的内存块串联起来使用,如果我们需要申请一个1000M大小的链表,只要内存剩余的可用空间大于1000M,便不会出现问题。

下图对比了链表和数组的存储结构:

此时我们在来回顾我们刚刚提到的链表的概念:物理存储单元上非连续、非顺序,元素的逻辑顺序是通过链表中的指针链接次序实现的,链表由一系列结点组成,每个结点包括两个部分:一个是存储数据元素数据域,另一个是存储下一个结点地址的指针域

当然了这只是我们提到的链表的最基本的存储形式,其实链表有很多种不同的类型,分别对应了不同的结构,接下来我们就来分析不同的链表类型

链表类型

单链表

所谓的单链表就是我们刚刚讲到的链表的最基本的结构,链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。我们把这个记录下个结点地址的指针叫作后继指针next,如果链表中的某个结点为p,p的下一个结点为q,我们可以表示为:p->next=q

下面的图更加详细的描述了单链表的存储结构

从图中,应该可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址,有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。

与数组一样,链表也支持数据的查找、插入和删除操作。

我们知道,在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移。而在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移节点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的。

为了方便理解,我画了一张图,从图中我们可以看出,针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变即可。

但是,有利就有弊。链表想要随机访问第k个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点的一次遍历,直到找到相应的结点,所以,链表随机访问的性能没有数组好。

循环链表

循环链表是一种特殊的单链表。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从下图中,应该可以看出来,它像一个环一样首尾相连,所以叫作“循环”链表,和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表,循环链表的结构如图所示

双向链表

单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点,如图所示

从图中可以看出来,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。

双向循环链表

了解了循环链表和双向链表,如果把这两种链表整合在一起就是一个双向循环链表 。

链表和数组性能比较

数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别,它们插入、删除、随机访问操作的性能正好相反。

数组简单易用,在实现上使用的是连续的内存空间,缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空 间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。

链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与数组最大的区别。你可能会说,我们 Java 中的 ArrayList 容器,也可以支持动态扩容啊?我们之前已经说过,当我们往支持动态扩容的数组中插入一个数据时,如果数组中没有空闲空间了,就会申请一个更大的空间,将数据拷贝过去,而数据拷贝的操作是非常耗时的。

所以:数组的优势是查询速度非常快,但是增删改慢;链表的优势是增删改快,但是查询慢。

链表的应用

学习数组的时候我们分析了基于数组而实现的 ArrayList 集合,那 java 中也有基于链表实现的集合 LinkedList,接下来我们就来分析一下 LinkedList 的底层源码

LinkedList 源码分析

首先我们写一段最简单的代码如下 :

 public static void main(String[] args) {         
     // 构建容器
     List<String> list = new LinkedList<String>();   
     // 向容器中添加数据
     list.add("test01");         				
     list.add("test02");              
}

然后我们以断点调试的方式进入底层源码进行查看,我们主要关注容器的构建和元素的添加

容器构建及添加元素

点开 LinkedList 源码查看 LinkedList 类的声明及属性

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable{
    /** 链表的长度*/
	transient int size = 0;
    /** 存储整个链表的头结点*/
    transient Node<E> first;
    /** 存储整个链表的尾结点*/
    transient Node<E> last;
    	
    /**
     *LinkedList的构造函数
     */
    public LinkedList() {
    }
    
    /**
     *静态内部类Node,也就是我们链表中的概念;结点
     */
    private static class Node<E> {
        /** 结点中存储的数据*/
        E item;
        /** 结点中存储的后继指针next*/
        Node<E> next;
        /** 结点中存储的前驱指针prev*/
        Node<E> prev;
		
        /** 结点的构造函数*/
        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }
    
    /**
     *添加数据方法
     */
    public boolean add(E e) {
        linkLast(e);
        return true;
    }
    
    
    void linkLast(E e) {
        final Node<E> l = last;
        final Node<E> newNode = new Node<>(l, e, null);
        last = newNode;
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        size++;
        modCount++;
    }

}

调试过程如图所示:

通过分析我们可以知道,LinkedList 内部采用双向链表实现数据的存储,那接下来我们来分析一下,如何从 LinkedList 容器中获取数据

获取元素

从 LinkedList 中获取元素的代码如下:

public static void main(String[] args) {         
    	 // 构建容器
         List<String> list = new LinkedList<String>();         
         // 向容器中添加数据
         list.add("test01");         
         list.add("test02");         
         // 获取元素
         String s = list.get(1);         
         System.out.println(s);     
} 

查看 LinkedList 中的 get 方法,源码如下:

/**
 *通过索引获取数据
 *@params index 索引
 */
public E get(int index) {
    //判断索引是否越界
    checkElementIndex(index);
    return node(index).item;
}


private void checkElementIndex(int index) {
        if (!isElementIndex(index))
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}


private boolean isElementIndex(int index) {
    	//如果索引不在 [0 , size), 范围内就抛出索引越界异常,其中 size是集合的长度
        return index >= 0 && index < size;
}


// 根据索引从链表中获取节点
Node<E> node(int index) {    
   //如果索引小于集合长度的一半则从头节点开始遍历,否则从尾节点开始遍历,因为底层用的是双向链表支持
   // 前后查询遍历
   if (index < (size >> 1)) {
       Node<E> x = first;
       for (int i = 0; i < index; i++)
           x = x.next;
       return x;
   } else {
       Node<E> x = last;
       for (int i = size - 1; i > index; i--)
           x = x.prev;
       return x;
   }
}

代码跟踪效果如下:

至此,关于 LinkedList 的源码解析就告一段落了,我只分析了构建集合,向集合中添加元素以及根据索引从集合中获取元素的相关方法,其他方法的分析大家可以尝试着自己分析一下。

LinkedList和ArrayList的比较

前面我们分析过 ArrayList 的源码,现在又分析了 LinkedList 源码,接下来将二者进行比较:

1:ArrayList 的实现基于数组,LinkedList 的实现基于双向链表。

2:对于随机访问,ArrayList 优于LinkedList,ArrayList 可以根据下标对元素进行随机访问。而 LinkedList 的每一个元素都依靠地址指针和它后一个元素连接在一起,在这种情况下,查找某个元素只能从链表头开始查询直到找到为止。

3:对于插入和删除操作,LinkedList 优于 ArrayList,因为当元素被添加到LinkedList任意位置的时候,不需要像 ArrayList 那样重新计算大小或者是更新索引。

4:LinkedList 比 ArrayList 更占内存,因为LinkedList的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。

总结

通过今天的学习可以了解到:

概念:链表(Linked list)是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。

特点

  • 高效的插入和删除
  • 低效的随机访问

应用

  • LinkedList的源码分析(容器构建、添加元素及获取元素)

ArrayList和LinkedList的比较

至此,链表部分的学习就完成了。



本文已在csdn同步上传:数据结构-线性表-链表

样式来源:教你写出漂亮掘金文章样式