前面我们介绍了另一种线性结构-数组,并且用它实现了队列,但是出队的时间复杂度是O(n),本篇文章介绍链表并基于单向链表实现队列,使其入队和出队的时间复杂度都是O(1)
链表是什么
- 是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存着下一个节点的指针(Pointer)
- 由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间
- 本篇文章以单向链表为主要介绍对象,所出现的链表,均表示单向链表
链表的特点
- 前面我们基于数组来实现的栈或队列,都需要靠手动的resize来解决固定容量的问题,链表是真正的动态数据结构,且是最简单的动态数据结构,不需要处理固定容量的问题
- 可以帮助更深刻的理解引用的概念
- 丧失的随机访问的能力,不能像数组一样根据索引快速定位
链表操作
在链表头加入元素
- 前面介绍我们知道链表的每一个节点存着下一个节点的引用(指针),也可以理解,他们之间用指针连接着,所以在表头添加元素的时候我们只需要将新元素中下一个节点的引用指向原来头节点即可
newNode.next = head; head = newNode
- 这个操作我们看到,由于没有对其他元素操作,且能快速的定位head节点,所以时间复杂度是O(1)
在链表中间添加元素
- 由于链表不能像数组那样根据索引位置快速定位,所以也就没有了索引的概念,那么如果想在链表的指定位置添加元素要怎么做呢?
- 只能从头节点依次向后遍历,找到所谓的"指定位置"
- 小伙伴们观察上面单向链表的图可以思考一下,如果我们想在99 和 37 之间 插入一个元素,要怎么做呢?我们先找到99这个元素节点,注意 插入总共分二步,并且顺序不能错乱,小伙伴们可以仔细体会:
- 第一步:新插入元素中的下一个元素指针 指向37元素节点
- 第二步:99元素节点中的下一个元素指针 指向新插入元素
newNode.next = prev.next; prev.next = newNode
- 关键是找到添加节点的前一个节点,也就是99这个元素节点
- 为什么顺序不能乱呢,我们试想一下,如果我们先将99元素节点的next指向新元素的话,那么新元素的下一个节点应该是指向99元素节点原来的下一个节点 37。但是刚刚我们已经修改了99的next指针,所以 37元素节点就找不到了,所以这里的顺序不能乱
删除头元素
- 删除头元素相对还是容易的,只是需要将头节点的下一个元素指针清除,并将头节点位置指向下一个元素即可
- 只是移动一个指针的位置,所以时间复杂度是O(1)
删除中间元素
- 有了上面添加元素的经验,我们知道,需要找到删除元素的前一个元素。并且删除步骤不能乱
prev.next = delNode.next; delNode.next = null
代码实操
- 使用单向链表来实现队列
- 探索入队和出队的时间复杂度
- 我们观察前面的添加元素,删除元素的理论分析,可以看到在头部删除和添加的时间复杂度是O(1)的,那么在尾部的删除元素呢,由于需要是从头遍历到尾部的位置进行删除,所以时间复杂度是O(n)的,但是在尾部的插入元素只需要移动一个指针的位置,所以是O(1)的
- 观察上面的时间复杂度分析,我们看到,尾部的添加操作时间复杂度O(1),在头部的添加和删除时间复杂度都是O(1),所以我们约定,入队是在队尾添加元素,出队实在队首删除元素,这样
java
public class LinkedListQueue<E>{
private class Node{
public E e;
public Node next;
public Node(E e, Node next){
this.e = e;
this.next = next;
}
public Node(E e){
this(e, null);
}
public Node(){
this(null, null);
}
@Override
public String toString(){
return e.toString();
}
}
//声明队首和队尾的引用
private Node head, tail;
private int size;
public LinkedListQueue(){
head = null;
tail = null;
size = 0;
}
//入队操作,我们观察单向链表的结构可以知道,队列空的时候尾部节点没有值
//所以先判断队列是否是空
public void enqueue(E e){
//队列是空,初始化head和tail
if(tail == null){
tail = new Node(e);
head = tail;
}else{
//队列不是空,向队尾添加一个元素
tail.next = new Node(e);
tail = tail.next;
}
size ++;
}
//出队,删除链表头元素
public E dequeue(){
if(size == 0){
throw new IllegalArgumentException("队列是空");
}
Node temoHeadNode = head;
head = head.next;
temoHeadNode.next = null;
if(head == null){
tail = null;
}
size --;
return temoHeadNode.e;
}
@Override
public String toString(){
StringBuilder res = new StringBuilder();
res.append("Queue: front ");
Node cur = head;
while(cur != null) {
res.append(cur + "->");
cur = cur.next;
}
res.append("NULL tail");
return res.toString();
}
}
- 代码的实操,小伙伴可以细细体会一下入队和出队的操作,下面探索一下各个操作的时间复杂度
链表的时间复杂度分析
- 添加操作
- 在头添加:O(1)
- 在尾部添加:O(n)
- 在任意位置添加:O(n)
- 删除操作
- 删除头元素:O(1)
- 删除尾部元素:O(n)
- 删除任意位置元素:O(n)
- 修改操作:O(n)
- 查找操作:O(n)其中查找头元素是O(1)
- 通过以上的时间复杂度分析,也解释了上面我们选择实现队列的方式的原因。
到这里,整篇文章就结束了,本文只是基于链表中的单向链表来介绍,感兴趣的同学可以看看双向链表和循环链表,本质也是对单向链表的变种,换汤不换药