链表
链表的基本原理
链表,顾名思义就是像锁链一样的把数据都串连在一起;他通过定义链表结构中的指针来把各个节点连接在一起。而节点就是用来存储信息的,数据和下一个节点的指针都是储存在上一个节点中。当然在链表中有一个特殊的节点——头结点(head)他里面不存数据,但是并不代表他不可以存数据,知识大家都这么约定好了不存数据,因为这样的话方便对于链表节点的统一操作,后面我们会说到(对于头部数据的操作,不使用虚拟头结点的话需要进行特殊处理)。
链表与数组的优劣
数组
- 优点:定长分配一次申请内存,减少多次申请内存的时间;内存相连,查找数据快,不需要一个一个的从头查找数据就可以直接获取数据信息。
- 缺点:定长分配和内存相连的缺点也非常的明显,这种方式分配内存如果多次申请的话,会造成内存的浪费有一些零碎的内存空间就无法使用到了。而对于数组的操作来说扩容和增删来说是非常麻烦的;扩容我们需要重新定义一个新的更大的数组,然后把原数组里面的数据全部转移到新数组中去,这是很花费时间的(所以一般都是成倍扩容,以避免多次操作)。对数组元素进行增删操作的话,对于数组来说也需要移动大量的元素(具体操作在我的上一篇博客中有进行详细的解释),同样很花费时间。
链表
- 优点:链表的优点其实有一点和数组是相对的,链表不分配固定内存,他们各个节点的位置都是分散在内存区域中的,通过节点之间的指针进行联系,所以对于内存的浪费问题就没有了。同样对于增删数据来说,链表是非常快的(当然我们这里不考虑查找到该位置的时间消耗),他只需要操作后一个和前一个节点的指针连接就可以实现,而不需要去操作整个链表。
- 缺点:同样缺点也是非常的明显的,就是链表的节点数据在内存中不是相连的了,所以我们想要查找数据的话,就必须要从第一个头结点开始一个一个往后查找,直到找到该元素位置。
链表的实现
其实链表的实现基本的都是和我上一篇文章中的动态数组的实现的架子是基本一致的,所以这里就只介绍对于链表的核心操作的部分。
链表节点的定义
每一个节点的定义都需要有存储元素和指向下一个节点的指针(头结点除外)
public class LinkedList<E> {
private int size = 0;
private Node<E> first; // 链表的节点类型,也是头结点
private class Node<E>{
E e; // 节点中存放的数据
Node<E> next; // 指向下一个节点的地址
public Node(E e, Node<E> next) {
this.e = e;
this.next = next;
}
}
头结点的创建
虚拟头节点的创建,他作为第一个节点出现在链表中,他不存任何东西,只是为了方便我们链表的后续操作。
/**
* 头结点的改造:原来是没有头节点的,但是现在我们需要在链表的最前面添加一个空的节点,然后统一我们的操作
*/
public LinkedList() {
first = new Node<>(null,null);
}
与动态数组重复的方法,这里就不再过多的赘述
/**
* 获取链表的元素个数
* @return
*/
public int size() {
return size;
}
/**
* 获取链表的相应位置的元素
* @param index
* @return
*/
public E get(int index) {
// 直接调用写的node(index)方法,通过索引获取节点,然后再通过节点获取值
return getNode(index).e;
}
/**
* 在链表的指定位置修改元素,并将旧元素返回
* @param index
* @return
*/
public E set(int index, E e) {
// 需要先拿到相应索引的节点保存下来,用来返回以前的值给调用者
Node<E> node = getNode(index);
E before = node.e;
node.e = e; // 把节点的值修改为新的值
return before;
}
/**
* 判断链表是否为空
* @return
*/
public boolean isEmpty() {
// 如果size大于0返回false非空,反之返回true为空
return size == 0;
}
/**
* 判断链表中是否包含该元素
* @param e
* @return
*/
public boolean contain(E e) {
// 这里我们可以直接调用indexOf()函数的结果,如果找到就为true
return indexOf(e) != -1;
}
/**
* 往链表的末尾添加元素
* @param e
*/
public void add(E e) {
add(size,e);
}
/**
* 清空该链表
*/
public void clear() {
size = 0;
first = null;
}
/**
* 由于错误抛出代码复用过多,进行封装,一般内部使用的方法设置为私有的
*/
private void checkMessage() {
throw new IndexOutOfBoundsException("索引越界");
}
private void Check(int index) {
if (index < 0 || index >= (size - 1)) {
checkMessage();
}
}
private void checkAdd(int index) {
if (index < 0 || index > size) {
checkMessage();
}
}
获取节点getNode方法
该方法需要从头节点开始一个一个的往后面查找,这里我们已经添加了头节点,所以只需要拿到头结点然后找到相应索引位的元素就可以了。
/**
* 由于需要多次依据索引找节点,所以抽取成方法
* @param index
* @return
*/ private Node<E> getNode(int index){
checkAdd(index);
// 索引满足检查,只需要从头结点first开始一个一个往后走即可
Node<E> node = first.next; // 这里的first指向的是链表第0个元素,再next就是第一个元素
for (int i=0; i<index; i++) {
node = node.next; // 这里把节点从第0个元素开始开始往后走
}
return node;
}
添加节点方法add
添加节点的我们就会看到加入虚拟头节点和没有加入的区别,如果没有加入的话我们就需要对头部添加的情况做特殊的处理,否则索引检查会报错。当我们添加了虚拟头节点之后我们就能统一操作,而不需要去操作头节点。
/**
* 链表的指定位置添加元素,支持存入null元素
* @param index
* @param e
*/
public void add(int index, E e){
// 索引检查依旧适用
checkAdd(index);
// if (index == 0) { // 在队首添加元素的时候,需要单独的分开,索引检查会报错
// first = new Node<>(e,first);
// }else {
// // 添加元素,需要获取到该位置的前一个元素才可以,而且新元素一定要先指向该位置元素,然后前一个再指向新元素
// Node<E> before = getNode(index-1);
// // 创建一个新的节点保存新的数据,并插入链表相应的位置
// before.next = new Node<>(e,before.next);
// // 创建新结点的时候就先把该位置的节点连接到新节点的后面,
// // 然后再断开前一个节点与该位置节点的连接,并在后面连接到新节点
// }
/**
* 经过头结点改造后的添加代码
*/
// 这一步是判断是否是在链表的第一位添加节点,是就把虚拟头结点赋值给先节点,不是就把前一个节点获取出来
Node<E> prev = (index == 0) ? first : getNode(index - 1);
// 拿到起前一个节点之后我们,就可以进行添加操作
prev.next = new Node<E>(e, prev.next);
/*
* 先把前一个节点的后续赋值给新的节点,然后把新结点赋值最初先前节点的后续
*/
size++;
}
删除节点remove
删除节点相对于添加节点的话是更加简单一点的,因为他只需要把前一个节点的next指针指向需要删除的节点的后一个节点即可。
/**
* 删除指定位置的元素
* @param index
* @return
*/
public E remove(int index) {
Check(index);
E old;
// if (index == 0) { // 同样处理为0的情况
// old = first.e; // 这里取值与返回值是相同的操作可以抽取到判断外面
// first = first.next;
// }else {
// // 只需要把指定位置的前一个节点指向该位置的后一个节点即可
// Node<E> before = getNode(index-1);
// old = before.next.e;
// before.next = before.next.next;
// }
/**
* 下面进行头结点的改造过后的删除部分
*/
// 这一步与add函数的作用是一样的,获取前一个节点,如果是第一个就获取虚拟头结点
Node<E> prev = (index == 0) ? first : getNode(index - 1);
// 获取需要删除的元素
old = getNode(index).e;
// 只需要把删除节点的后一个节点连接上删除节点的前一个节点
prev.next = prev.next.next;
size--;
return old;
}
双向链表
- 定义:对于双向链表来说其实他和单向链表其实本质是一样的,只不过在节点中多添加了一个指向本节点前驱的一个指针prev,也就是他可以从该节点往后或者往前都可以。而他的操作其实也和单链表差不多,只是需要注意他有两个指针就可以了。
总结
对于链表他的优点很明显,增删快,不浪费内存,但也不是所有的情况都适合使用链式结构,所以在实际生活中我们一般会把链表和其他的数据结构类型组合使用,来弥补不足。