当我们学完数组后,我们知道,数组的存取必须使用连续的内存空间,并会预留一部分空间方便扩展。我们假设下面的场景:如果在电影院分配座位时,也通过这样的规则进行分配,会发生什么情况?
如上图所示,所有人都希望自己和朋友坐一起,并且为之后可能到来的朋友预留位置。这样的做法会大大降低影院的入座率。在内存中的话,同样会大大降低内存的使用率,这种事情肯定是不会被科学家所接受的。所以出现了一种新的数据结构,叫做链表。
文章目录
1.链表的创建
链表的发明是对数组的一种补充,因此它的功能和数组十分相似,都是用于存储一系列相同类型的数组,并且都有增删查改的功能。也就是说,数组能做的事情,一般都能用链表来实现,只是他们的存储方式不同,导致他们的实现逻辑也不同,同样时间复杂度也将不同。下面我们就具体分析一下链表的特性:
节点
链表里的每一个数称之为一个节点,节点由两部分组成:节点内容和下一个节点地址。
为什么每个节点都要存储下一个节点地址呢?
在数组中,由于是连续内存空间存储,我们可以通过
start_address + item_size * i
计算出元素的位置。 而在链表中,存储空间是分散存储的,所以我们需要每个节点保存下一个节点的位置。 也就是可以通过节点 1 找到节点 2,节点 2 找到节点 3…依次遍历所有的节点信息。
在这个数据结构中,他将数据分散存储在内存中,以达到内存最大利用率。
我们可以创建一个Node.java
类,来实现链表节点的创建。
public class Node {
private int content;//链表内容
private Node next;//链表指针,指向下一节点
public Node() {
}
public Node(int content, Node next) {
this.content = content;
this.next = next;
}
public int getContent() {
return content;
}
public void setContent(int content) {
this.content = content;
}
public Node getNext() {
return next;
}
public void setNext(Node next) {
this.next = next;
}
}
链表
完成了节点的创建,接下来便可以实现链表。
我们首先创建一个根元素,然后从链表末尾开始创建链表。首次添加只需要让根元素指向新创建的节点,之后每次创建的新的节点对象,都令其指向链表已有元素的头部节点,使其成为新的头部节点,再令根元素指向新添加的头部节点,最后返回根结点,我们便得到了一个链表,根节点指向的就是链表的第一个节点。
public static Node createLinkedList(int[] array) {
// 创建一个根节点
Node root = null;
// 从末尾元素开始依次创建Node节点
for (int i = array.length - 1; i >= 0; i--) {
Node node = new Node(array[i], null);
// 创建两个节点的连接关系
if (root != null) {
node.setNext(root);
root = node;
} else {
root = node;
}
}
return root;
}
public static void main(String[] args) {
int[] array = {9, 2, 4};
Node root = createLinkedList(array);
System.out.println(root.getContent());
}
得出结果:9,我们的链表创建成功,根节点内容为9。
2.链表的读取与查找
首先回顾一下在数组中如何进行数据的读取:
因为数组是连续内存空间存储的,我们通过
start_address + item_size * i
便能计算出索引地址,然后 1 步便完成内容的读取,时间复杂度是O(1)
。
那在链表中,我们应该如何操作呢?链表在计算机中是分散存储的,我们现在只有第一个节点的地址,因此需要依次遍历三个节点的地址:
时间复杂度:
从上面图中我们看到,链表中的读取时间复杂度和索引值有关。最好的情况是读取第一个节点,只需要一步。最坏的情况是读取最后一个节点,需要N步。 因此链表的读取时间复杂度为O(N)
。
查找过程与遍历相似,如果要查找的是第一个节点,只需要一步。最坏的情况是查找最后一个节点,需要N步。 因此链表的查找时间复杂度为也O(N)
。
接下来的过程中,我们通过所学内容的实践,自己仿写一个LinkList
方法。我们在链表中加入两个属性:root
表示存储链表的第一个节点,size
表示链表的长度。
public YKDLinkedList(int[] array) {
this.root = YKDLinkedList.createLinkedLNode(array);
this.size = array.length;
}
本节我们完成查找功能:
// 获取长度
public int size() {
return 0;
}
// 获取某个索引节点内容
public int get(int index) {
return 0;
}
// 查找某个值的索引值,默认不存在为-1
public int find(int value) {
return -1;
}
代码演练——获取长度
在构造函数中我们已经设置了链表的长度为数组长度,因此获取长度只需要返回size值即可:
// 获取长度
public int size() {
return this.size;
}
代码演练——获取索引节点内容
给定索引获取节点,实际上就是将指针移动索引值的次数后指向的节点,因此我们可以遍历链表,每次遍历将索引减一,当索引值为零时结束遍历;也可以设置自增指针i,当i的值与索引相等时结束循环:
// 获取某个索引节点内容
public int get(int index) {
int i = 0;
Node node = this.root;
// 依次往前推进
while (i < index) {
node = node.getNext();
i++;
}
return node.getContent();
}
代码演练——获取节点元素索引值
查找某个值的索引值,即为查找节点元素脚标,我们只需要一次遍历数组并储存索引值,当指针命中内容后返回索引值即可:
// 查找某个值的索引值,默认不存在为-1
public int find(int value) {
Node node = this.root;
// index 保存当前的索引
int index = 0;
// 依次遍历链表,找到内容等于value的node,返回index
while (node != null) {
if (node.getContent() == value) {
return index;
}
node = node.getNext();
index++;
}
return -1;
}
3.链表的插入
假设我们在为周末做规划,规划如下:
eat breakfast // 吃早饭
shopping // 购物
have lunch // 吃午饭
have dinner // 吃晚饭
头部插入
现在突然想在吃早饭之前,加入一项取快递(take express),应该如何插入呢?
如上图所示,分两部分执行:
- 新节点的next指向原来的root节点;
- root指针指向新节点。
时间复杂度为O(1)
。
中间插入
如果想在购物之后再取快递,应该怎么处理呢?
如上图所示,分两部分执行:
- 新节点的next指向原来前置节点的next节点;
- 原来前置节点的next指向新节点。
看起来好像也只需要O(1)
就能完成插入,但是这样的插入有一个前置条件,也就是我们知道插入节点的前置节点。 否则,我们只能根据索引通过N步找到前置节点。
尾部插入
如果想在吃完晚饭后看电影,应该怎么处理呢?
和中间插入罗伊完全一样,只是指向的下一个节点为null:
- 新节点的next指向原来前置节点的next节点(null);
- 原来前置节点的next指向新节点。
同样的,我们要先通过N步找到前置节点,在进行上面的操作。
总结:
如果已经知道插入节点的前置节点,那么链表的插入时间复杂度为
O(1)
。普通情况(根据索引值插入节点),插入的时间复杂度为O(N)
这个和数组是一样的。 但有趣的是,链表插入的最好和最差的情况刚好和数组相反,链表在头部插入和方便,但是数组开头插入确很麻烦。而数组在尾部插入很方便,但链表尾部插入要先扫描再插入。
接下来我们便完善我们的LinkList
类,本次完成如下三个函数:
// 末尾添加元素
public boolean add(int value) {
return true;
}
// 头部插入元素
public boolean addFirst(int value) {
return true
}
// 插入元素
// index = -1,表示在头部插入
// index = 0, 表示在第一个元素之后插入
// index = n,表示在 索引n 位置之后插入
public boolean add(int index, int value) {
return true;
}
代码演练——插入元素
需要判断链表为空和索引溢出的情况,链表为空则直接插入,索引溢出则返回失败;其次判断index值,-1表示在头部插入:
/**
* 在链表中指定位置插入一个新节点
* @param index 新节点需要插入的位置,如果为-1,则在链表头部插入
* @param value 新节点的值
* @return 插入成功则返回true,否则返回false
*/
public boolean add(int index, int value) {
//判断元素是否溢出
if (index < -1 || index > this.size - 1){
return false;
}
// 如果需要在链表头部插入新节点
if (index == -1) {
// 判断链表是否为空,如果不为空,则在头部插入
if (this.root != null) {
this.root = new Node(value, this.root);
} else {
// 如果链表为空,直接将新节点作为头节点
this.root = new Node(value, null);
}
} else {
// 如果需要在链表中插入新节点
Node pre = this.root;
// 通过循环定位到要插入节点的位置
while (index > 0) {
// 如果链表长度不足直接返回false
if (pre.getNext() == null) {
return false;
}
pre = pre.getNext();
index--;
}
// 如果要插入节点的位置为null,则不插入,直接返回false
if (pre == null) {
return false;
}
// 如果要插入节点的位置pre不为null,则在其后面插入新节点
Node newNode = new Node(value, pre.getNext());
pre.setNext(newNode);
}
// 插入成功后,链表长度加一,返回true
this.size++;
return true;
}
代码演练——末尾添加元素
上面的分析已经很详细了,我们只需要先遍历到末尾即可:
public boolean add(int value) {
return this.add(this.size - 1, value);
}
代码演练——末尾添加元素
令索引值为-1:
public boolean addFirst(int value) {
return this.add(-1, value);
}
4.链表的删除
掌握了链表的插入,删除就简单多了。我们还以周末规划为例:
eat breakfast // 吃早饭
shopping // 购物
have lunch // 吃午饭
have dinner // 吃晚饭
头部删除
如果我周末想睡懒觉,不想吃早饭了,应该怎么删除吃早饭?
如上图所示,分两个部分执行:
- root指针指向第二个节点;
- 原始root节点的next指针设为null。
时间复杂度为O(1)
。
中间删除
我今天比较饿,吃完早饭后没吃饱,想直接吃午饭,不想购物了,应该怎么操作?
如上图所示,分两个部分执行:
- 待删除节点的前置节点的next指针指向待删除节点的后置节点;
- 待删除节点的next指针设为null。
时间复杂度为O(N)
。
尾部删除
我今天吃的有点撑了,我晚上不想吃饭了,我应该怎么操作?
如上图所示,我们只需要执行如下步骤:
- 待删除节点的前置节点的next指针指向null。
总结
删除操作其实就是插入操作的反操作,只要能理解这一点,弄懂删除操作的原理并不难。
如果已经知道待删除节点的前置节点,那么链表的删除时间复杂度为
O(1)
。普通情况(根据索引值删除节点),因为需要新找到前置节点,所以的时间复杂度为O(N)
这个和数组是一样的。 和链表插入一样,链表删除的最好和最差的情况刚好和数组相反,链表在头部删除和方便,但是数组开头删除确很麻烦。而数组在尾部删除很方便,但链表尾部删除要先扫描再删除。
代码演练——删除元素
要判断链表为空和索引值溢出的情况,如有则直接返回false。
// 删除最后一个元素
public boolean removeLast() {
return this.remove(this.size-1);
}
// 删除第一个元素
public boolean removeFirst() {
return this.remove(0);
}
// 删除元素
// index = 0, 表示删除第 1 个元素
// index = n,表示删除第 n+1 个元素
public boolean remove(int index) {
//删除第一个元素
if(index == 0){
//判断链表是否为空
if (this.root!=null){
Node node = this.root;
//如不为空,让root指向next的下一个节点
this.root=this.root.getNext();
//让头结点的next置空
node.setNext(null);
}else {
//若为空则返回false
return false;
}
}else {
//删除第index个元素
Node pre = this.root;
while (index>1){
//判断index是否溢出
if (pre.getNext()==null){
return false;
}
//若未溢出则指针后移
pre=pre.getNext();
//减少下标,代表前移一位
index--;
}
//判断链表是否为空
if (pre == null) {
//为空则返回false
return false;
}
//设置node为pre的next节点
Node node = pre.getNext();
//设置pre节点的next指针为node的next节点
pre.setNext(node.getNext());
//将node的next指针置空
node.setNext(null);
}
//链表长度减一
this.size--;
return true;
}
代码演练——删除头部元素
// 删除第一个元素
public boolean removeFirst() {
return this.remove(0);
}
代码演练——删除尾部元素
// 删除最后一个元素
public boolean removeLast() {
return this.remove(this.size-1);
}
5.链表vs数组
链表和数组在性能上到底有什么区别?
我们可以通过如下表格观察:
数组 | 链表 | |
---|---|---|
存储 | 连续内存存储 | 分散内存存储 |
读取 | O(1) | O(N) |
插入 | O(N) | O(N) |
插入最好情况 | 数组末尾插入 | 链表开头插入 |
插入最坏情况 | 数组开头插入 | 链表末尾插入 |
删除 | O(N) | O(N) |
删除最好情况 | 数组末尾删除 | 链表开头删除 |
删除最坏情况 | 数组开头删除 | 链表末尾删除 |
表面上看起来,链表除了在存储上面有优势,其他的好像并不比数组强,那为何我们还有使用链表?
我们设想下面一个场景:
现在爆发了丧尸危机,但是还在初期可控期,因此我们现在要寻找拥有丧尸的城市并炸掉,然后再数据库中删除掉这个城市信息,最后按顺序整理起来看看还剩多少城市。
在这样的场景下,我们应该用什么数据结构来存储城市信息呢?
我们设想一下操作这个数据库最关键的两个步骤:遍历和删除。
遍历
我们需要遍历所有城市,以找到爆发丧尸危机的城市。数组和链表的遍历时间复杂度都为O(N)。
删除
当我们成功炸毁城市并打算删除时,如果在数组中删除,那么我们需要依次移动红棉的所有元素,所以每次删除时间复杂度都为O(N)。
而如果使用链表的话,我们在遍历时就已经拿到了这个节点的前置节点,所以在这个场景下,每次删除的时间复杂度都为O(1)。
总结
从这个例子我们可以看出,如果我们需要频繁的插入和删除,那么链表相对于数组有绝对的优势。
6.链表经典算法题——多指针的使用
链表是初级数据结构中最喜欢涉及的数据结构,基于链表的算法更是层出不穷。下面我们来学习一个经典场景——多指针在链表算法中的应用。
我们以剑指offer中的一道题目为例:
如何查找单向链表中倒数第K个节点?
单向链表,我们无法从尾部开始遍历,只能从头开始遍历,因而导致单项链表只知道自己后面的节点,那如何取到倒数第k个节点呢?
- 暴力法
我们可以通过两次循环获取倒数第k个节点的索引。
- 第一次循环,获取到链表中的节点个数,记为n;
- 第二次循环,只需要推进到n-k次即可。
代码如下:
// 找寻倒数第 k 个节点内容
public static int findK(Node node, int k) {
// 第一步,计算链表的长度
int size = 0;
Node p = node;
while (p != null) {
p = p.getNext();
size++;
}
// 第二步,推进 size - k 步
Node c = node;
int index = size - k;
while (index > 0) {
c = c.getNext();
index--;
}
return c.getContent();
}
那么有没有办法可以减少时间复杂度呢?
- 双指针法
我们可以使用两个指针完成这个任务:
第一步,先声明一个先驱指针,探索到链表的边界。我们将先驱指针先移动k-1位,然后声明一个实际指针,用来寻找目标节点。
第二步,同时移动两个指针,寻找目标节点。
第三步,当先驱指针移动到链表结尾的时候,此时的实际指针就是我们要寻找的倒数第k个节点。
代码如下:
// 找寻倒数第 k 个节点内容
public static int findK(Node node, int k) {
// 移动先驱指针
int pre = k;
Node pioneer = node;
while(pre > 1){
pioneer = pioneer.getNext();
pre--;
}
// 创建实际指针
Node current = node;
while(pioneer.getNext() != null){
current = current.getNext();
pioneer = pioneer.getNext();
}
return current.getContent();
}
接下来,我们将题目进行变形:现在我们要获取一个链表的中心节点的内容。如果数组是奇数个,则返回中心节点内容;如果是偶数个,则返回中心右侧节点的内容。如此又该如何操作呢?
其实也很简单,我们先分析一下题目:如果链表为奇数个,我们只需要让实际指针移动一个的同时,令先驱指针移动两个。如此当先驱指针移动到结尾时,实际指针正好指在中心元素。
而当链表元素为偶数个时,我们需要考虑如下情况:在先驱指针移动最后一步时,剩余的链表元素仅剩一个,不足以让指针移动两步,此时便会报出空指针异常。此时我们应该怎么解决呢?
我们可以将先驱指针的移动分为两步:第一步,和实际指针一起移动一步;第二步,判断下节点是否为空,若为空则直接返回节点内容,若不为空则再往前走一步,继续循环。
我在第一次写demo的时候,让偶数个元素也采用了先驱指针一次移动两步的方式。我的思路如下:判断先驱指针的下个节点和下下个节点是否为空,都不为空则向前移动,若下下个节点为空,则让实际指针向前移动一步,然后返回节点内容。但是这样有一个问题:我没有考虑下个节点为空的情况,导致了系统报出了空指针异常,因为此时无法判断下下节点是否为空。大家可以思考一下这个过程。
实现代码如下:
// 找寻中心节点内容
public static int findCenter(Node node) {
// 创建先驱指针
Node pioneer = node;
// 创建实际指针
Node current = node;
// 每次遍历需要实际指针移动一步,先驱指针移动二步
while(pioneer.getNext() != null){
current = current.getNext();
pioneer = pioneer.getNext();
if(pioneer.getNext() != null){
pioneer = pioneer.getNext();
}
}
return current.getContent();
}
有环链表
有环链表,也就是链表中出现了环状结构。在之前我们看到的链表都是一条直线的,但在实际情况下对于Node
这种节点,很容易会造成有环链表。
如下图两个模式,都属于是有环链表。
那有什么方法判断一个链表是否是有环链表么?我们尝试完善一下代码:
// 查询链表是否有环,true是有环,false是无环
public static boolean hasCircle(Node node) {
return false;
}
给大家提示下思路,我们考虑一个生活场景:
假设两个人在一条笔直的马路上赛跑,一个人跑得快,一个人跑的慢,那么理论上从开始比赛到终点,两个人永远不会相遇(除了起跑的时候),直到跑到终点。 那如果两个人在一个环形的操场上赛跑呢?那么两位同学肯定会再次相遇,当跑得快的同学超过跑的慢的同学刚好 1 圈的时候。
如果把这两位同学,当做两个指针,那就可以判断环形情况了。
// 查询链表是否有环,true是有环,false是无环
public static boolean hasCircle(Node node) {
// 移动先驱指针
if (node == null || node.getNext() == null) {
return false;
}
// first 每次前进一步
Node first = node;
// second 每次前进两步
Node second = node.getNext();
// 核心逻辑:如果链表中有环,两个指针用不同速度运行,最终second会追上first,也就是 first == second。
while (first != null && second != null && first != second) {
first = first.getNext();
second = second.getNext();
if (second != null) {
second = second.getNext();
}
}
// 如果相等,并且不为空,则表示有环
if (first != null && first == second) {
return true;
}
return false;
}
7.双向链表
我们前面的问题,如果可以使用双向链表,那么问题便会迎刃而解。
比如查找中间元素,我们可以设置头尾双指针,当左>=右时,返回左指针指向的节点值。
这种结构的链表,便叫作双向链表。
这种链表的特性如下:
- 对于每个节点,由三部分组成,
prev
(指向前一个节点),next
(指向后一个节点),content
(节点内容)。- 对于双向链表,为了方便遍历,我们需要同时保留第一个节点和最后一个节点。
LinkList
在之前我们自己实现过YKDLinkedList
,如果大家以后仔细阅读LinkedList.java
的源码,我们会发现LinkedList
实际上就是利用双向链表实现的,在源码中我们可以找到如下代码:
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
transient int size = 0;
/**
* Pointer to first node.
* Invariant: (first == null && last == null) ||
* (first.prev == null && first.item != null)
*/
transient Node<E> first;
/**
* Pointer to last node.
* Invariant: (first == null && last == null) ||
* (last.next == null && last.item != null)
*/
transient Node<E> last;
/**
* Constructs an empty list.
*/
public LinkedList() {
}
......
}
这里我们只需要关注两个变量first
和last
,分别表示双向链表的头结点和尾节点。