链表
1:概念
链表(Linked list)是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一 个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
2:存储结构
相比数组,链表是一种稍微复杂一点的数据结构。对于初学者来说,掌握起来也要比数组稍难一些。这两个非常基础、非常常用的数据结构,我们常常将会放到一块儿来比较。所以我们先来看,这两者有什么区别。
首先我们从底层存储结构来看:
数组:需要一块连续的存储空间,对内存的要求比较高,比如我们要申请一个1000M 的数组,如果内存中没有连续的足够大的存储空间则会申请失败,即便内存的剩余可用空间大于1000M,仍然会申请失败。
链表:与数组相反,它并不需要一块连续的内存空间,它通过指针将一组零散的内存块串联起来使用,所以如果我们申请一个 1000M 大小的链表,只要内存剩余的可用空间大于 1000M,便不会出现问题。
下方图片对比了链表和数组的存储结构
此时我们在来回顾我们刚刚提到的链表的概念:物理存储单元上非连续、非顺序,元素的逻辑顺序是通过链表中的指针链接次序实现的,链表由一系列结点组成,每 个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
当然了这只是我们提到的链表的最基本的存储形式,其实链表有很多种不同的类型,分别对应了不同的结构,接下来我们就来分析不同的链表类型。
3:链表类型
3.1:单链表
所谓的单链表就是我们刚刚讲到的链表的最基本的结构,链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。
如图所示,我们把这个记录下个结点地址的指针叫作后继指针 next ,如果链表中的某个节点为 p,p 的下一个节点为 q,我们可以表示为:p->next=q 下面的图更加详细的描述了单链表的存储结构
从单链表图中,应该可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址,有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。
与数组一样,链表也支持数据的查找、插入和删除操作。
我们知道,在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移。而在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的。
为了方便理解,从下图中我们可以看出,针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变。
但是,有利就有弊。链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据针一个结点一个结点地依次遍历,直到找到相应的结点,所以,链表随机访问的性能没有数组好。
3.2:循环链表
循环链表是一种特殊的单链表。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从我画的循环链表图中,你应该可以看出来,它像一个环一样首尾相连,所以叫作“循环”链表,和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表,循环链表的结构如图所示
3.3:双向链表
单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点,如图所示
从图中可以看出来,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。
3.4:双向循环链表
了解了循环链表和双向链表,如果把这两种链表整合在一起就是一个双向循环链表
4:链表和数组性能比较
通过前面内容的学习,我们已经知道,数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别,它们插入、删除、随机访问操作的性能正好相反数组简单易用,在实现上使用的是连续的内存空间,缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。
链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与数组最大的区别。你可能会说,我们 Java 中的 ArrayList 容器,也可以支持动态扩容啊?我们上一节课讲过,当我们往支持动态扩容的数组中插入一个数据时,如果数组中没有空闲空间了,就会申请一个更大的空间,将数据拷贝过去,而数据拷贝的操作是非常耗时的。
所以:数组的优势是查询速度非常快,但是增删改慢;链表的优势是增删改快,但是查询慢。
5:链表的应用
学习数组的时候我们分析了基于数组而实现的 ArrayList 集合,那 java 中也有基于链表实现的集合 LinkedList,接下来我们就来分析一下 LinkedList 的底层源码。
5.1:LinkedList源码分析
首先我们写一段最简单的代码如下
public static void main(String[] args) {
// 构建容器
List<String> list = new LinkedList<String>();
// 向容器中添加数据
list.add("itheima");
list.add("itcast");
}
然后我们以断点调试的方式进入底层源码进行查看,我们主要关注容器的构建和元素的添加。
5.2:容器构建及添加元素
1:点开 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;// 节点中存储的数据
Node<E> next;//节点中存储的后继指针 next
Node<E> prev;//节点中存储的前驱指针 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 容器中获取数据。
5.3:获取元素
从 LinkedList 中获取元素的代码如下:
public static void main(String[] args) {
//构建容器
List<String> list = new LinkedList<String>();
//向容器中添加数据
list.add("itheima");
list.add("itcast");
//获取元素
String s = list.get(1);
System.out.println(s);
}
查看 LinkedList 中的 get 方法,源码如下:
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) {
// assert isElementIndex(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 的源码解析就告一段落了,本课程中只分析了构建集合,向集合中添加元素以及根据索引从集合中获取元素的相关方法,其他方法的分析大家可以尝试着自己分析一下。
5.4:面试题:
⑴、LinkedList 和 ArrayList 的比较
前面我们分析过 ArrayList 的源码,现在又分析了 LinkedList 源码,接下来将二者进行比较:
1:ArrayList 的实现基于数组,LinkedList 的实现基于双向链表。
2:对于随机访问,ArrayList 优于 LinkedList,ArrayList 可以根据下标对元素进行随机访问。而 LinkedList 的每一个元素都依靠地址指针和它后一个元素连接在一起,在这种情况下,查找某个元素只能从链表头开始查询直到找到为止。
3:对于插入和删除操作,LinkedList 优于 ArrayList,因为当元素被添加到LinkedList 任意位置的时候,不需要像 ArrayList 那样重新计算大小或者是更新索引。
4:LinkedList 比 ArrayList 更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。
⑵、反转单链表
上一小节我们分析了 LinkedList 的底层源码,那对于数据结构和算法来说我们不仅仅要掌握理论知识点,还要能够根据我们掌握的理论知识点去解题,达到对我们思维上的训练,接下来我们就以一道跟链表相关的面试来训练一下我们的思维及代码编写能力。
反转单链表:
英文版:https://leetcode.com/problems/reverse-linked-list/
中文版:https://leetcode-cn.com/problems/reverse-linked-list/
leetcode 编码参考答案:
/**
* Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode(int x) { val = x; } * }
*/
class Solution {
public ListNode reverseList(ListNode head) {
ListNode prev = null; //前指针节点
ListNode curr = head; //当前指针节点
//每次循环,都将当前节点指向它前面的节点,然后当前节点和前节点后移
while (curr != null) {
ListNode nextTemp = curr.next; //临时节点,暂存当前节点的下一
节点,用于后移
curr.next = prev; //将当前节点指向它前面的节点
prev = curr; //前指针后移
curr = nextTemp; //当前指针后移
}
return prev;
} }