数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out ofmemory) ”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容。
你可能会说,我们 Java 中的 ArrayList 容器,也可以支持动态扩容啊?我们上一节课讲过,当我们往支持动态扩容的数组中插入一个数据时,如果数组中没有空闲空间了,就会申请一个更大的空间,将数据拷贝过去,而数据拷贝的操作是非常耗时的。我举一个稍微极端的例子。如果我们用 ArrayList 存储了了 1GB 大小的数据,这个时候已经没有空闲空间了,当我们再插入数据的时候, ArrayList 会申请一个 1.5GB 大小的存储空间,并且把原来那1GB的数据拷贝到新申请的空间上。听起来是不是就很耗时?
除此之外,如果你的代码对内存的使用非常苛刻,那数组就更适合你。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是Java语言,就有可能会导致频繁的GC(Garbage Collection,垃圾回收)。
所以,在我们实际的开发中,针对不同类型的项目,要根据具体情况,权衡究竟是选择数组还是链表。
2、单向链表
链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。
为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。
如下图所示,我们把这个记录下个结点地址的指针叫作后继指针 next。
从图中可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。
第一个结点叫作头结点,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。
把最后一个结点叫作尾结点。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点。
插入、修改、查找
与数组一样,链表也支持数据的查找、插入和删除操作。我们知道,在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移,所以时间复杂度是O(n)。
在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。
所以,在链表中插入和删除一个数据是非常快速的。从下图中我们可以看出,针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是O(1)。
链表要想随机访问第k个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。
所以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。
3、循环链表
循环链表是一种特殊的单链表。
它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。
而循环链表的尾结点指针是指向链表的头结点。如下图所示,它像一个环一样首尾相连,所以叫作“循环”链表。
和单链表相比, 循环链表的优点是从链尾到链头比较方便。
当要处理的数据具有环型结构特点时,就特别适合采用循环链表。
4、双向链表。