死磕算法第二弹——栈、队列、链表(4)
本文整理来源 《轻松学算法——互联网算法面试宝典》/赵烨 编著
链表
虽然在很多的高级语言中,链表已经尽量的被隐藏起来,而且其应用之处还有很多的。
什么是链表
链表与数据结构有些不同。栈和队列都是申请一段连续的空间,然后按顺序存储数据;链表是一种物理上的非连续、非顺序的存储结构,数据元素之间的顺序是通过每个元素的指针关联的。
链表由一系列节点组成,每个节点一般至少会包含两部分信息;一部分是元素数据本身,另一部分是指向下一个元素的指针。这样的存储结构让链表相比其他线性的数据结构来说,操作会复杂一些。
相比数组,链表具有其他优势;链表克服了数组需要提前设置长度的缺点,在运行时可以根据需要随意添加元素;计算机的存储空间并不总是连续可用的,而链表可以灵活地使用存储空间,还能更好地对计算机的内存进行动态管理。
链表分为两种类型:单向链表和双向链表。我们平时说的链表指单向链表。双向链表的每个节点除存储元素数据本身外,还额外存储两个指针,分别是上一个节点和下一个节点的地址。
链表的存储结构
对于链表来说,我们只需要关心链表之间的关系,不需要关系链表实际存储位置,所以在表示一个链表关系时,一般使用箭头来关联两个联系的元素节点。
从链表的存储结构可知,链表的每个节点包含两个部分,分别是数据(叫做data)和指向下个节点地址的指针(叫做next)。在存储了一个链表之后怎么找到它?这里需要一个头节点,这个头节点是一个链表的第1个节点,它的指针指向下一个节点的地址,以此类推,知道指针指向为空时,边表示没有下一个元素了。
链表的操作
链表的操作有:创建、插入、删除、输出。
这里提出的插入、删除操作,其位置并不一定是开头或者结尾。由于链表特殊结构,在链表中间进行数据元素的插入与删除也是很容易实现的。
创建操作就是空间分配,把头、尾指针以及链表信息初始化。
1. 插入操作
插入操作分为三种情况,分别是头插入、尾插入、中间插入。
头插入的操作,实际上是增加一个新的节点,然后把新增的节点的指针指向原来头指针指向的元素,再把头指针指向的元素指向新增的节点。
尾插入的操作,也就是增加一个指针为空的节点,然后把原尾指针指向节点的指针向新增的节点,
中间插入元素的操作会稍微复杂一些。首先新增一个节点,然后把新增的节点的指针指向插入位置的后一个位置的节点,把插入位置的前一个节点的指针指向新增的节点。
2. 删除操作
删除操作与插入操作类似,也有三种情况,分别是头删除、尾删除、中间删除。
删除头元素,先把头指针指向下一个节点,然后把原头结点的指针置空。
删除尾元素时,首先找到链表中倒数第2个元素,然后把尾指针指向的这个元素,接着把原倒数第2个元素的指针置空
删除中间元素时会相对复杂一些,首先要把删除的节点的之前一个节点的指针要指向删除节点的下一个节点,接着要把删除节点的指针置空。
public class Link<T> {
private int size = 0;
private Node<T> first;
private Node<T> last;
public Link() {
}
/**
* 链表后部插入
*
* @param data 插入元素
*/
public void addLast(T data) {
if (size == 0) {
//为空初始化前后元素
fillStart(data);
} else {
Node<T> node = new Node<>();
node.setData(data);
last.setNext(node);
//把最后插入的元素设置为链表尾部的元素
last = node;
}
size++;
}
/**
* 链表头部插入元素
*
* @param data 插入元素
*/
public void addFirst(T data) {
if (size == 0) {
fillStart(data);
} else {
Node<T> node = new Node<>();
node.setData(data);
//把元素的下一个位置的指针指向头元素
node.setNext(first);
//把刚插入的元素设置为链表头元素
first = node;
}
size++;
}
/**
* 在链表的指定位置后面插入
*
* @param data 插入元素
* @param index 下表,从0开始
*/
public void add(T data, int index) {
if (size > index) {
if (size == 0) {
//为空初始化前后元素
fillStart(data);
size++;
} else if (index == 0) {
addFirst(data);
} else if (size == index + 1) {
addLast(data);
} else {
Node<T> temp = get(index);
Node<T> node = new Node<>();
node.setData(data);
node.setNext(temp.getNext());
temp.setNext(node);
size++;
}
} else {
throw new IndexOutOfBoundsException("链表没有那么长");
}
}
/**
* 删除头元素
*/
public void removeFirst() {
if (size == 0) {
throw new IndexOutOfBoundsException("链表没有元素");
} else if (size == 1) {
//只剩下一个时需要清除first和last
clear();
} else {
Node<T> temp = first;
first = temp.getNext();
size--;
}
}
/**
* 删除尾元素
*/
public void removeLast() {
if (size == 0) {
throw new IndexOutOfBoundsException("链表没有元素");
} else if (size == 1) {
clear();
} else {
//获取最后一个元素之前的一个元素
Node<T> temp = get(size - 2);
temp.setNext(null);
size--;
}
}
/**
* 删除链表中间的元素
* @param index 下标
*/
public void removeMiddle(int index){
if(size == 0){
throw new IndexOutOfBoundsException("链表没有元素");
}else if (size == 1){
//只剩下一个时需要清除first和last
clear();
}else {
if (index == 0){
removeFirst();
}else if (size == index - 1){
removeLast();
}else {
Node<T> temp = get(index - 1);
Node<T> next = temp.getNext();
temp.setNext(next.getNext());
size--;
}
}
}
private void clear() {
first = null;
last = null;
size = 0;
}
public int size(){
return size;
}
public Node<T> get(int index) {
Node<T> temp = first;
for (int i =0; i< index ; i++){
temp = temp.getNext();
}
return temp;
}
private void fillStart(T data) {
first = new Node<>();
first.setData(data);
last = first;
}
public void printAll(){
Node<T> temp = first;
System.out.println(temp.getData());
for (int i = 0 ; i < size -1; i++){
temp = temp.getNext();
System.out.println(temp.getData());
}
}
private class Node<V> {
private V data;
private Node<V> next;
public V getData() {
return data;
}
public void setData(V data) {
this.data = data;
}
public Node<V> getNext() {
return next;
}
public void setNext(Node<V> next) {
this.next = next;
}
}
}
测试代码
public class LinkTest {
@Test
public void main(){
Link<Integer> link= new Link<>();
link.addFirst(2);
link.addFirst(1);
link.addFirst(4);
link.addFirst(5);
link.add(3,1);
link.printAll();
link.removeFirst();
link.removeLast();
link.removeMiddle(1);
link.printAll();
link.removeFirst();
link.removeFirst();
Assert.assertEquals(0,link.size());
}
}
链表的实现逻辑有点复杂,在程序中存在抛异常的情况,在中间插入和删除也考虑到index为头和尾的情况,这样避免调用方法失误而导致程序出错。
链表的特点
链表由于本身存储结构的原因,有以下几个特点:
1. 物理空间不连续,空间开销大。
链表的最大一个特点就是在物理空间上可以不连续。这样的有点可以利用操作系统的动态内存管理,缺点是需要更多的存储空间去存储指针信息。
2. 运行时可以动态添加
由于数组需要初始化时设定长度,所以在使用数组时往往会出现长度不够的情况,这时只能再声明一个更长的数组,然后把旧数据的数据复制进去,在前面栈的实现中已经看到这一点。使用链表,则不会出现空间不够用的情况。
3. 查找元素需要顺序查找
通过上面的代码可以看出,查找元素时,需要逐个遍历往后查找元素。其实在测试代码中采用循环队列的方法的效率并不高,尤其是当链表很长时,所需要查找的元素的位置越靠后,效率越低。在执行删除操作时,也会遇到类似问题。
4. 操作稍显复杂
在增加和删除时,不需要处理数据,还需要处理指针。从代码上看,删除最后一个元素时,获取最后一个元素很方便,但是由于操作需要实现倒数第二个元素的next指向设置为空,所以只能从头遍历并获取倒数第二个元素之后在进行删除操作。
链表的适用场景
现在计算机的空间越来越大,物理空间的开销已经不再是我们要关心的问题,运行效率才是我们在开发中需要考虑的问题。
我们在前面提到,链表除了单向链表,还有双向链表。一般情况下我们会使用双向链表,因为多使用的那个指针所占的空间对于现在的计算机资源来说并不重要。双向链表相对于单向链表的一个优势就是 ,不需要从头还是从尾查找,操作都是一样的。因此对于尾操作进行操作时就不用逐个从头遍历了,可以直接从尾往前查找元素。
链表可以在运行时动态添加元素,这对于不确定长度的顺序存储来说很重要。集合(列表)采用数组实现,在空间不够时需要换更大的数组,然后进行复制操作。这时如果采用链表就非常方便了。
链表的劣势就是在查找中间元素时就需要遍历。一般而言,链表也经常配合其他结构一同使用,如散列表、栈、队里等。
一般的程序里可能会使用一个简单的队列进行消息缓冲,而队列的操作只能从头、尾进行,所以这时使用链表(双向链表)去实现就非常方便了。
链表的性能分析
一般分析性能时,将单向链表作为分析对象。链表的插入分为三种:头插、尾插、和中间插。头、尾是能够直接插入的,其时间复杂度为O(1);而中间插需要遍历链表,所以时间复杂度为O(L),L为插入下标。链表的删除也分为三种:头删、尾删、和中间删。头删是能够直接删除的,其时间复杂度为O(1);而中间删需要遍历链表,所以时间复杂度为O(L),L为删除下标。尾删的时间复杂度则达到了O(N),N为链表长度。
对于查询来讲,时间复杂度为O(L),L一样是下标。
所以对于链表来说,我们可以发现,链表的头插和头删都是O(1)的时间复杂度,这和栈很想,所以栈可以直接使用单向链表实现。
面试举例:如何反转链表
一般在数据结构或者算法的面试题当中,尽量不使用额外的空间去实现,尽管现在的计算机空间很充足,但是面试考察的还是对整体性能的考虑。
方法其实有很多,我们可以依次遍历链表,然后依次使用头插入的方法来达到目的。
其中有个简单的方法,就是把链表的每个指针反转。
/**
* 反转链表
*/
public void reverse(){
Node<T> temp = first;
last = temp;
Node<T> next = first.getNext();
for (int i = 0 ; i < size - 1; i++){
//下下个
Node<T> nextNext = next.getNext();
next.setNext(temp);
temp = next;
next = nextNext;
}
last.setNext(null);
first = temp;
}