课程《玩转数据结构》学习
- 链表与数组
- 使用虚拟头节点
- 链表的增删改查
- 链表的时间复杂度分析
- 使用链表实现栈与队列
链表与数组
链表是典型的线性动态数据结构,也是学习树形数据结构的敲门砖。与数组不同,链表的意义在于动态二字。再回顾一下什么是数组:在内存中开辟一段连续的存储空间的相同数据类型元素存储的集合 。数组并不具备动态的能力,为了让数组具有动态的特性,我们可以实现自己的数组,让其具备自动扩容以及缩容(resize)的能力。动态数组。
而对于栈,与队列这两种具备特殊功能的线性数据结构,可以使用数组作为底层原理来实现。对于栈的特性即LIFO,使用动态数组作为底层实现满足了栈各个功能的时间复杂度为O(1)。而队列的特性为:FIFO,如果使用数组作为底层,在队列的出队操作时,这一项功能的时间复杂度就为O(n)。使用循环队列的思想,则可以将出队操作优化至O(1)。
链表则是一种真正的动态数据结构。因为数组在内存的空间是连续的,所以最大的优势 是支持“随机访问”,而链表最大的优点则是“真正的动态”。链表不会浪费多余的内存空间,不需要处理容量的问题,但是也丧失了数组的随机访问的能力。
上图表示的就是一个链表,图中的圆形表示为链表中的一个节点,每个节点都存储着下一个节点的引用。链表的尾部,也就是链中最后一个节点,是没有指向下一个节点的,所以自然指向了Null。要想实现 “一个节点存储着下一个节点的引用”功能并不难,我们只需要在链表类中,增加一个内部类Node即可:
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;
}
// 获取链表中元素的个数
public int getSize(){
return size;
}
// 判断链表是否为空
public boolean isEmpty(){
return size==0;
}
}
复制代码
链表中每一个节点都存储着下一个节点的引用,那么谁来存储链表头部的引用呢?所以,与数组不同,链表需要额外去维护一个变量,这个变量我们称作head,用于存储链表头的引用。
使用虚拟头节点
现在向链表添加元素。
我们需要考虑两种情况,第一种情况为:向链表头部添加元素。
在向链表头部添加元素时,我们需要:
1:newNode.next = head;// 将添加的节点的next指向head
2: head = newNode;// 将head再次指向头部
复制代码
实现代码为:
public void addFirst(E e){
head = new Node(e,head);
size++;
}
复制代码
还有一种情况是:在链表任意位置添加元素,这一点和在链表头部添加元素略有不同。(普遍来讲,当你选择了链表这种数据结构时,往往不会涉及向链表的中间添加元素,实现此功能是为了更加深入地学习链表)
我们考虑一下,在链表中间插入元素时,假设插入位置的"索引"称作index。我们首先需要将插入的节点指向index处的节点,本图为向index==2的位置插入元素99。然后再将index位置前的一个节点指向被插入的节点,那么我们如何获取index-1处的节点呢?答案就是遍历。我们需要一个特殊的变量,让它指向index-1处的节点,现在用prev表示这个变量,最初让
prev=head
,每次让
prev=prev.next
,遍历index-1次,就可以获得index-1处的,也就是待插入位置的前一个位置的索引处的节点。插入的过程为:
1: newNode.next = prev.next
2: prev.next = newNode
复制代码
代码为:
public void add(int index,E e){
if(index<0 || index>size)
throw new IllegalArgumentException("Index is Illegal");
if(index==0){
// 如果在链表头部添加元素
addFirst(e);
}else{
Node prev = head;
for(int i=0;i<index-1;i++){
prev = prev.next;
}
prev.next = new Node(e,prev.next);
size++;
}
}
复制代码
如果使用head这个变量去维护链表头自然是可以的,但是我们看到了,我们的链表在头部添加元素时,和在其他位置添加元素的思路是不一样的。有没有办法能够将链表进行优化,使得链表的头部同链表的其他位置在增删改查的操作一致呢?使用虚拟头节点就可以优化链表,解决这样的一个问题。
如上图所示,我们在原本的head前使用一个dummyHead这样的一个变量,让它指向原本的head,这样对于我们来讲,链表中所有的节点都满足了“有指向它的节点”这样一个特性。
链表的增删改查
有了dummyHead虚拟头节点后,链表的增删改查都会变的非常容易。
向链表中添加元素
public void add(int index,E e){
if(index<0 || index>e)
throw new IllegalArgumentException("Index is Illegal");
Node prev = dummyHead;
for(int i=0;i<index;i++){
prev = prev.next;
}
prev.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);
}
复制代码
向链表中删除元素
public E remove(int index){
if(index<0 || index>=size)
throw new IllegalArgumentException("index is Illegal");
Node prev = dummyHead;
for(int i=0;i<index;i++){
prev = prev.next;
}
E delNode = prev.next;
prev.next = prev.next.next; // prev.next = delNode.next;
delNode.next = null;
return delNode.e;
}
// 从链表中删除第一个元素,并返回
public E removeFirst(){
return remove(0);
}
// 从链表中删除最后一个元素,并返回
public E removeLast(){
return remove(size-1);
}
复制代码
向链表中查询及修改元素
// 改
public void set(int index,E e){
if(index<0 || index>=size)
throw new IllegalArgumentException("Index is Illegal");
Node prev = dummyHead;
for(int i=0;i<index;i++){
prev = prev.next;
}
prev.next.e = e;
}
// 查
public E get(int index){
if(index<0 || index>=size)
throw new IllegalArgumentException("Index is Illegal");
Node prev = dummyHead;
for(int i=0;i<index;i++){
prev = prev.next;
}
return prev.next.e;
}
// 获得链表的第一个元素
public E getFirst(){
return get(0);
}
// 获得链表的最后一个元素
public E getLast(){
return get(size-1);
}
复制代码
链表的时间复杂度分析
我们现在来看一下链表的增删改查各个操作的时间复杂度:
- 向链表中添加元素
与动态数组相反,在动态数组的末尾添加元素的时间复杂度为O(1),在头部添加元素则需要将数组整体向后挪动,需要O(n)的时间复杂度。链表的添加元素操作中,在链表头添加元素的时间复杂度为O(1),在链表尾部添加元素,需要将链表遍历一遍,所以时间复杂度则为O(n)。 - 向链表中删除元素
在链表头部删除元素非常简单,时间复杂度为O(1)。删除链表尾部还是需要将链表整体遍历,所以时间复杂度为O(n)。 - 链表中查询元素
链表不具备数组的下标索引这种快速查询的机制,所以对于链表来说,查询元素的时间复杂度为O(n)。因为只有将链表进行遍历,才能知道链表中是否有你想要查询的元素,对于链表来说,查询元素这个功能是不利的,事实上,也确实如此。选择了链表这种数据结构主要的操作都是在增删上,而数组这种数据结构则更加适合查询操作,因为数组的索引特性使得查询操作为O(1)的时间复杂度。 - 链表中修改元素
对于链表的修改元素这一操作来说,在链表头操作的时间复杂度为O(1),在链表尾修改元素的时间复杂度则是O(n)。
使用链表实现栈与队列
栈与队列是两种特殊的线性数据结构,它们都是基于某种线性数据结构作为底层进行实现的。动态数组作为底层可以实现栈与队列,并且我们使得栈这种数据结构的各个操作均为O(1)的时间复杂度,而队列在使用数组作为底层实现时,出队操作的时间复杂度为O(n),但是循环队列则做出了改进,将队列的各个操作优化至O(1)。我们再回顾一下栈与队列的接口方法:
Stack
public interface Stack<E> {
// 入栈
void push(E e);
// 出栈
E pop();
// 查看栈顶元素
E peek();
int getSize();
boolean isEmpty();
}
复制代码
Queue
public interface Queue<E> {
// 入队
void enqueue(E e);
// 出队
E dequeue();
// 查看队首的元素
E getFront();
int getSize();
boolean isEmpty();
}
复制代码
如果将栈与队列的底层变为链表,那么如何进行实现呢?
LinkedListStack
对于链表来说,在链表头操作元素均为O(1)的时间复杂度,而栈是一种仅在栈顶进行push与pop的特殊的数据结构。所以我们的思路非常简单,将链表头作为栈顶就可以使得栈的相关操作为O(1)的时间复杂度了,因为代码比较简单,所以直接给出链接,不再叙述:链接。
LinkedListQueue
队列和栈不同,因为FIFO的这种特性,就需要在队列的两头进行操作(从一端添加元素,从另一端删除元素)。对于数组和链表两种数据结构来说,无论是哪一种,在两端进行操作的时间复杂度必是O(1)和O(n)。对于数组来说,我们使用了循环队列这种思想对出队操作进行优化,对于链表也必然有优化的方法,试想一下,在链表头部进行操作的时间复杂度为O(1),如果在链表的尾部也添加一个变量进行维护,那么每次在添加元素时,只需要让尾部指向新添加的元素,并且再次让维护链表尾部的这个变量指向最后一个元素不就可以了吗?假设维护链表尾部的这个变量叫做"tail",在每次向链表中添加元素时,我们只需要tail.next = newNode;tail = newNode
就可以了,这样在链表尾部添加元素就会变为一个时间复杂度为O(1)的操作。
而链表头无论是删除元素还是添加元素都是O(1),我们可以将链表尾部变为队列尾,将链表头当作队列头。 我们只看入队操作和出队操作:
// 链表为底层的队列:入队
@Override
public void enqueue(E e){
Node node = new Node(e);
if(isEmpty()){
head = node;
tail = node;
}else{
tail.next = node;
tail = tail.next;
}
size++;
}
复制代码
// 链表为底层的队列:出队
@Override
public E dequeue(){
if(isEmpty())
throw new IllegalArgumentException("Queue is Empty");
Node retNode = head;
if(head==tail){
head = null;
tail = null;
}else{
head = head.next;
}
size--;
retNode.next = null;
return retNode.e;
}
复制代码