前言
在这一章,我将介绍另外一种非常重要的线性数据结构--链表。在之前介绍的动态数组,栈和队列这三种数据结构,底层其实依托于静态数组。
链表的概括
虽然动态数组靠resize方法解决静态数组固定容量的问题,但依旧摆脱不了仍是静态数组的事实,而链表则与上述线性数据结构都不同,是一种真正的动态数据结构。
链表为何如此重要
- 最简单的动态数据结构
- 更深入的理解引用(或者指针)
- 更深入的理解递归
- 辅助组成其他数据结构
注:链表本身也是有它非常清晰的递归结构的,由于它天身这种递归性质,可以帮助大家更加深入的理解递归机制相应的数据结构。
具体来看看什么是链表
- 链表,通常数据存储在**“节点”(Node)**中。
- 对链表的节点来说只有两部分,一是存储真正的数据,而另一部分是node类型的对象next(next本身又是一个节点,连接起了下一个节点)。
类比火车,每个节点是一节车厢,在车厢中存储真正的数据。而车厢和车厢还要进行连接,以使得所有数据是整合在一起的。用户可以方便地在对这些数据上查询等进行其他操作,而数据和数据之间连接就是由这个next来完成的。
简单的图示一下
- 比如,第一个节点存放了元素1,同时它有一个指向next(也就是用一个箭头来表示),指向了下一节点(node)
- 第二个节点存放了元素2,以此类推……
- 到最后个节点的next存储的是NULL
- 如果一个节点的next是NULL,那就说明这个节点一定是最后一个节点,这就是链表。
链表的优缺点
优点
- 真正的动态,不需要处理固定容量的问题
不必像静态数组一样,需要考虑开辟多少空间出来,同时还要考虑这个空间是否够用。对于链表来说,你需要存储多少个数据,你就可以生成多少个节点,把它们挂接起来,这便是动态的含义
缺点
-
丧失了随机访问的能力
链表不能像数组那样,从数组的索引中直接取出元素。在底层机制上,数组所开辟的空间,在内存中是连续分布的,所以可以直接寻找这个索引对应的偏移,直接计算出相应的数据所存储的内存地址,用O(1)的复杂度把这个元素取出来
但是链表不同。链表是靠next一层层连接,所以在计算机的底层,每一个节点所在的内存位置是不同的。因此必须通过遍历,一层一层找到这个元素,这便是链表最大的缺点。
数组和链表的对比
- 数组支持快速查询
- 链表便是支持动态
链表的实现
对于链表来说,我们想要访问这个链表中所有的节点,就必须把链表的头(head)存储起来。
代码实现
public class LinkedList<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;
private int size;
public LinkedList(){
head = null;
size = 0;
}
}
在链表头添加元素
下面我们来看下链表最重要的操作--如何为链表头添加元素
- 假设,要将666这个元素添加到链表中,
- 相应的需要在node节点里存放666这个元素,以及相应的next(指向)
- 然后node节点的next指向链表的头,即
node.next=head
(将head赋值给node.next) - 最后head也指向存放666的node节点,即
head=node
注: 整个过程在一个函数中执行,函数结束之后,相应node变量的块作用域也就结束了
代码实现
public void addFirst(E e){
Node node = new Node(e);
node.next = head;
head = node;
}
在链表的中间添加新的元素
现在来处理稍微复杂一点的问题,在链表的中间添加新的元素
- 对于这个链表,要在这个链表索引(链表是无索引概念,只是借用索引这个概念来阐述)为2的地方添加一个新的元素666
- 首先遍历找到索引为2的前一个节点prev,
- 然后
prev.next
指向存放2元素的节点,同时存放666节点node.next
也指向它,因此得到node.next=prev.next
(将prev.next赋值给node.next) - 之后存放666节点node挂接起下一个节点,即
prev.next=node
(将node赋值给prev.next) - 经过这样的操作,完成了对在链表中间添加新的元素
代码实现
// 在链表的index(0-based)位置添加新的元素e
// 在链表中不是一个常用的操作,通常仅供练习
public void add(int index, E e){
if(index < 0 || index > size){
throw new IllegalArgumentException("Add failed. Illegal index.");
}
if(index == 0){
addFirst(e);
}else{
Node prev = head;
for(int i = 0; i < index - 1; i++){
// 把当前prev存的这个节点的下一个节点放进prev这个变量中
// prev这个变量在链表中会一直向前移动,直到移动到index-1这个位置
// 最后就找到了待插入的那个节点的前一个节点
prev = prev.next;
}
Node node = new Node(e);
node.next = prev.next;
prev.next = node;
size++;
}
}
为链表设立虚拟头节点
在链表添加元素的过程中,我们遇到了在链表任意位置添加元素和在链表头添加元素,逻辑上有所不同。究其原因,是在链表添加过程中需要找到相应的前一个节点。因此,需要在链表中造一个虚拟头节点(dummy head)。
代码实现
// 虚拟头节点
private Node dummyHead;
private int size;
public LinkedList() {
dummyHead = new Node(null, null);
size = 0;
}
// 在链表的index(0-based)位置添加新的元素e
// 在链表中不是一个常用的操作,通常练习用
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed. Illegal index.");
}
Node prev = dummyHead;
for (int i = 0; i < index; i++) {
// 把当前prev存的这个节点的下一个节点放进prev这个变量中
// prev这个变量在链表中会一直向前移动,直到移动到index-1这个位置
// 最后就找到了待插入的那个节点的前一个节点
prev = prev.next;
}
Node node = new Node(e);
node.next = prev.next;
prev.next = node;
// 或者三面三行改成 perv.next= new Node(e, prev.next);
size++;
}
// 在链表头添加新的元素e
public void addFirst(E e) {
add(0, e)
}
// 在链表末尾添加新的元素e
public void addLast(E e) {
add(size, e);
}
链表的查询、更新与遍历
继续为我们的链表添加更多的操作。那么,首先是获得链表的第index个元素。
代码实现
// 获取在链表的index(0-based)位置的元素e
// 在链表中不是一个常用的操作,通常练习用
public E get(int index){
if(index < 0 || index >= size){
throw new IllegalArgumentException("Get failed. Illegal index.");
}
Node cur = dummyHead.next;
for(int i = 0; i < index; i++){
cur.next = cur;
}
return cur.e;
}
// 获得链表的第一个元素
public E getFirst(){
return get(0);
}
// 获得链表的最后一个元素
public E getLast(){
return get(size - 1);
}
// 修改在链表的index(0-based)位置的元素e
// 在链表中不是一个常用的操作,通常练习用
public void set(int index, E e){
if(index < 0 || index >= size){
throw new IllegalArgumentException("Update failed. Illegal index.");
}
Node cur = dummyHead.next;
// 需要遍历到index节点
for(int i = 0; i < index; i++){
cur = cur.next;
}
// 再对index节点进行赋值
cur.e = e;
}
// 查找链表是否含有元素e
public boolean contains(E e){
Node cur = dummyHead.next;
// 判断cur节点是否为空,就意味着cur节点为有效节点
while (cur != null){
if(cur.e.equals(e)){
return true;
}
cur = cur.next;
}
return false;
}
链表元素的删除
介绍了为链表添加元素,查询、更新元素,现在就插最后一个从链表中删除元素
- 要想删除索引为2的元素,需要找到索引为2的上一个节点
prev
,而prev.next
便是待删除的节点(可称为delNode) - 让
prev.next
指向delNode.next
,即prev.next=delNode.next
。也就是跳过了delNode节点 - 为了使得能够回
收delNode
节点的空间,因此需要将delNode.next
置空,即delNode=null - 这样一来,就完成了整个链表元素的删除操作
代码实现
// 从链表中删除index(0-based)位置的元素,返回删除的元素
// 在链表中不是一个常用的操作,通常仅供练习
public E remove(int index){
if(index < 0 || index >= size){
throw new IllegalArgumentException("Remove failed. Illegal index.");
}
Node prev = dummyHead;
for(int i = 0; i < index; i++){
prev = prev.next;
}
Node retNode = prev.next;
prev.next = retNode.next;
// 将retNode.next节点置空以便回收
retNode.next = null;
size--;
return retNode.e;
}
// 从链表中删除第一个元素,返回删除的元素
public E removeFirst(){
return remove(0);
}
// 从链表中删除最后一个元素,返回删除的元素
public E removeLast(){
return remove(size - 1);
}
链表的时间复杂度分析
最后简单的分析一下,这个链表的时间复杂度。
- 首先我们来看添加操作。如果向链表尾添加一个元素,则必须从链表头开始遍历每一个元素,因此是O(n)的时间复杂度
- 但是如果向链表头添加一个元素,它是O(1)的时间复杂度
- 对于删除操作来说是基本一样的分析过程。
- 如果想要删除最后一个元素,就需要链表头遍历一次,因此时间复杂度是O(n)。
- 而删除第一个元素,需要O(1)的时间可以搞定了。
- 但是如果想要删除任意位置节点的话,平均来看是O(n/2)~O(n)
- 由于链表不支持随机访问,所以要想修改某个位置的元素,就必须从头遍历,所以这个修改操作的时间复杂度是O(n)
如图所示: