数据结构之链表
在数据结构中什么叫做链表呢,链表和普通的数组又有什么样子的区别呢?
链表顾名思义,就是一些数据在一个链子上,是一个和一个连在一起,在C语言中是存在指针,单向链表中存储的就是自己的值和指向的下一个地址,链表可以分为单向链表和双向链表,如果是单向链表那么只是存储的是指向下一个的地址和自己的值。双向链表存储的是他指向的下一个的地址值和他的上一个的地址值,还有他的值。
链表和数组的区别
链表在存储的时候是不连续的,他是指向下一个的位置的,他在存储的时候不需要物理空间的连续性,也就不需要去考虑扩容的问题。
数组需要连续的内存空间去存储数据,需要去考虑扩容问题。
因为这个特性一个链表在进行头部的插入时,复杂度是O(1),但是数组来说就是要更加的耗费资源。
但是数组来说数组只要知道元素中的第几个,就可以快速的查到位置,但是链表需要一个一个的去遍历,耗费系统的资源。
链表中的节点
链表是由什么样的数据结构组成的呢?
节点,链表是由节点组成的,一个结点我们在这里使用时单链表,而且Java中不存在指针,所以我们通过对象来标识,所以在定义节点(下文称作Node)时,我们定一个Node对象个和一个int值,一个存储他指向的下一个节点,一个存储的值
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() {
// TODO Auto-generated method stub
return e.toString();
}
}
上面的这一段代码是定义了一下这个Node,然后给了几个构造函数,一个是空的构造函数,一个是可以在里面放值的构造,还有一个是存放自己的值和下一个对象相当于是指向了下一个的数地址值。
链表的基本方法
实现链表的增删改查
在这里我需要去说一下,因为是链表,所以会有头结点这个概念,一个链表的头非常重要,但是在实现链表的增删改差功能的时候,如果头结点是属于链表的一部分我们的操作可能会变得复杂,但是如果我们去虚拟出一个头结点,这个头结点,指向链表的第一个元素,我们在实现链表头元素的操作是会变得更加的简单容易操作。
链表的定义
链表的结构和链表的构造函数
private Node dummyhead;//链表的虚拟头结点
private int size;//链表的长度
/**
* 初始化的时候因为链表为空所以head是null
*
*/
public LinkedLists() {
dummyhead=new Node(null, null);
size=0;
}
链表中获得链表中元素个数
/**
* 获取链表中个数
* @return
*/
public int getsize() {
return size;
}
判断链表中元素是否为空
/**
* 判断他是否为空
* @return
*/
public boolean isEmpty() {
return size==0;
}
元素的增加
/**
* 在链表表头添加元素
*/
public void addFirst( E e) {
add(0, e);
}
/**
* 找到待添加节点的前一个节点
* 1--->2--->3
* 在2的位置上添加一个节点4
* 需要做的就是先
* 4--->3先建立一个链接,
* 然后再使用2-->4建立另外一个链接
* 然后断开2-->3的链接
* node是新建元素
* node.next=pre.next
* pre.next=node
*
* 在链表的index(0~based)位置添加新的元素e
*
*/
public void add(int index,E e) {
if(index<0||index>size) {
//可以取得到size的位置
throw new IllegalArgumentException("index是非法的");
}
// if(index==0) {
// addFirst(e);
// }//之前是第一个元素没有最前面的节点,但是现在每个人都有一个最开始节
// else {
Node pre=dummyhead;
//这是为找得到位置之前的那一个位置
for (int i = 0; i < index; i++) {
pre=pre.next;
}
Node node=new Node(e);
//新建一个元素
node.next=pre.next;
//新建元素指向指定位置的元素
pre.next=node;
//上一个位置要直接指向新添加到额元素
/**
* 具体实现
* 1-->2-->3-->4
*
* 添加一个5在3的位置上
* 先让5-->3
* 再让2指向5
* 三行代码可以用一行来完成
* pre.next=new Node(e,pre.next);
*/
size++;
// }
}
//在链表的最后添加元素
public void addList(E e) {
add(size,e);
}
添加元素的原理,就是有一个新的元素,然后找到你需要找的元素的前一个的位置,新的元素指向指定位置的元素,前一个元素的指向新元素,这个添加的顺序是唯一的。
不能够先将指定位置的前一个元素指向新元素,新元素的下一个指向,指定位置的元素,那样的,当你改变指定位置前一个的元素指向的元素时,指定位置元素会丢失,后面会全变成null。
上面的这个图是添加元素的示例图
再添加元素中,因为我们需要去借助指定位置的前一个元素,所以虚拟头指针变得异常重要。
链表元素的删除
//删除:找到待删除的前一个,前一个==后一个,index的位置的那个是null
/**
* 有可能会存在bug
*
* @param index
* @return
*/
public E remove(int index) {
if(index<0||index>=size) {
//可以取得到size的位置
throw new IllegalArgumentException("index是非法的");
}
Node pre=dummyhead;
for (int i = 0; i < index; i++) {
pre=pre.next;
}
//pre是之前的元素,我应该怎么去处理,
//
// System.out.println(pre);
Node retNode=pre.next;
// System.out.println(retNode);
pre.next=retNode.next;
// System.out.println(pre);
// System.out.println(pre.next);
// System.out.println(retNode.next);
retNode.next=null;
size--;
return retNode.e;
}
public E removeFirst() {
return remove(0);
}
public E removeLast() {
return remove(size-1);
}
其实删除的逻辑并不复杂,但是如果使用代码来表示,可能我们会迷糊,会陷入迷茫,所以我在使用的时候多次使用输出语句查看是否和我的预期一样,大家在遇到这种强逻辑的题目的时候也可以这么去做,也可以使用Junit4来进行测试,或者是debug,我用图的形式来进行讲解。
元素的查询与更改
/**
* 找到第index个位置上的元素是什么
* @param index
* @return
*/
public E get(int index) {
if(index<0||index>=size) {
//可以取得到size的位置
throw new IllegalArgumentException("index是非法的");
}
Node cur=dummyhead.next;
for (int i = 0; i < index; i++) {
cur.next=cur;
}
return cur.e;
}
/**
*获得链表的第一个元素
* @return
*/
public E getfirst() {
return get(0);
}
/**
* 获得链表的最后的一个元素
* @return
*/
public E getlast() {
return get(size-1);
}
/**
* 这是单纯的练习使用的并不是正常使用的
* @param index
* @param e
*/
public void set(int index,E e) {
if(index<0||index>=size) {
//可以取得到size的位置
throw new IllegalArgumentException("index是非法的");
}
Node cur=dummyhead.next;//直接拿到第一个
for (int i = 0; i < index; i++) {
cur.next=cur;
}
cur.e=e;
}
逻辑不复杂我就不在这里进行多余的论述了
链表栈
我们在这里使用链表栈对于栈进行又一次的学习
因为是栈,所以元素进出的方式是先进后出,又因为是链表,一个指向另一个,如果想要制作链栈的话,需要我们去倒着将元素入栈,每一个元素进入栈,是加在链表的头部,移出栈是在链表的头部移出的栈。
链表栈的基本代码
链表栈的构造
private LinkedLists<E> list;
public LinkedListStack() {
list=new LinkedLists<>();
}
底层是一个链表
继承栈的接口,实现栈的功能的代码
@Override
public int getsize() {
// TODO Auto-generated method stub
return list.getsize();
}
@Override
public boolean isEmpty() {
// TODO Auto-generated method stub
return list.isEmpty();
}
@Override
public void push(E e) {
// TODO Auto-generated method stub
list.addFirst(e);
}
@Override
public E pop() {
// TODO Auto-generated method stub
return list.removeFirst();
}
@Override
public E peek() {
// TODO Auto-generated method stub
return list.getfirst();
}
@Override
public String toString() {
StringBuilder sb=new StringBuilder();
sb.append("Stack:top");
sb.append(list);
return sb.toString();
}
我们完成一个main函数来检验一下
public static void main(String[] args) {
LinkedListStack<Integer> stack =new LinkedListStack<Integer>();
for (int i = 0; i < 5; i++) {
stack.push(i);
System.out.println(stack);
}
stack.pop();
System.out.println(stack);
}
结果和我们预期相符合。
链表栈和动态数组栈的复杂度比较
ublic class LinkedComper {
private static double testStack(Stacks<Integer> stack,int opt) {
long start = System.nanoTime();
Random ran=new Random();
for (int i = 0; i < opt; i++) {
stack.push(ran.nextInt(Integer.MAX_VALUE));
}
for (int i = 0; i < opt; i++) {
stack.pop();
}
long end = System.nanoTime();
return (end-start)/1000000000.0;
}
public static void main(String[] args) {
int opt=100000;
ArrayStack<Integer> arr=new ArrayStack<Integer>();
double testStack = testStack(arr, opt);
System.out.println(testStack);
LinkedListStack<Integer> arr1=new LinkedListStack<Integer>();
double testStack1 = testStack(arr1, opt);
System.out.println(testStack1);
}
}
在这里有一个很有意思的事情当我们的int opt=100000;时我们的结果是
出现这个结果其实一点都不例外,因为数组栈是会有扩容的这个问题,扩容是一种很消耗系统资源的事情
但是如果把opt的值改为10000000时,
其实出现哪一种情况都是情有可原的,因为链表栈确实是不需要去扩容,但是链表栈需要去new Node这个对象,不同的版本的虚拟机不同,不同版本之间底层也就是不一样,我们在这里只是说一下这两种数据结构的性能的问题。
链表队
因为队的特殊的数据结构先进先出,所以我们需要去定义一个尾指针.
链表的构造和底层
private Node head,tail;
private int size;
public LinkedListQueue() {
head=null;
tail=null;
size=0;
}
因为是队列,所以需要去标识头和尾,数据时从链表的尾部进入,从链表的头部离开链表的。
链表继承的方法
/**
* 继承父类的方法,用来得到队的长度
*/
@Override
public int getsize() {
// TODO Auto-generated method stub
return size;
}
/**
* 判断队列是否为空
*/
@Override
public boolean isEmpty() {
// TODO Auto-generated method stub
return size==0;
}
/**
* 入队
*
*
*/
@Override
public void enqueue(E e) {
// TODO Auto-generated method stub
if(tail==null) {
tail=new Node(e);
head=tail;
}
//队是空的
else {
tail.next=new Node(e);
tail=tail.next;
}
size++;
}
/**
* 出队
*/
@Override
public E dequeue() {
// TODO Auto-generated method stub
if(isEmpty()) {
throw new IllegalArgumentException("队为空");
}
Node retnode=head;
head=head.next;
retnode.next=null;
if(head==null) {
tail=null;
}//对空
size--;
return retnode.e;
}
/**
* 拿到队首的元素
*/
@Override
public E getfront() {
// TODO Auto-generated method stub
if(isEmpty()) {
throw new IllegalArgumentException("队为空");
}
return head.e;
}
@Override
public String toString() {
StringBuilder sb=new StringBuilder();
sb.append("Queue:front");
Node cur=head;
while(cur!=null) {
sb.append(cur+"->");
cur=cur.next;
}
sb.append("NULL tail");
return sb.toString();
}
这一次是我把所有的继承的方法全都写上了,没有什么难度每一段都有自己的注释,需要注意的就是,用链表做队的时候就会有一个问题,每一次都是在链表的头部出队,在链表的尾部入队。
我们来完成一个main函数来检查我的代码
public static void main(String[] args) {
LinkedListQueue<Integer> arr=new LinkedListQueue<Integer>();
for (int i = 0; i < 10; i++) {
arr.enqueue(i);
System.out.println(arr);
if(i%3==2) {
arr.dequeue();
System.out.println(arr);
}
}
}
我们试验以后发现我们完成的功能是正确的。
复杂度分析
我们在这里使用代码对前边学习的数组队列,和循环队列进行一个比较。
public static void main(String[] args) {
int op=100000;
ArrayQueue<Integer> arraysQueue=new ArrayQueue<Integer>();
double test = test(arraysQueue, op);
System.out.println(test);
LoopQueue<Integer> LoopQueue=new LoopQueue<Integer>();
double test1 = test(LoopQueue, op);
System.out.println(test1);
LinkedListQueue<Integer> linkedlist=new LinkedListQueue<Integer>();
double test2 = test(linkedlist, op);
System.out.println(test2);
}
这个就是我们的使用的比较的main函数,具体的方法我在上面和之前的博客已经完成过,就是在传参数的时候传进去一个顶级接口,然后在我们后面自己调用的时候只需要传一个子类就可以了。