我们经常会使用到动态数组为底层封装的数据结构,比如ArrayList,然而其底层真是动态的吗?其实动态数组的内部是通过一个“resize”方法进行的处理,算不上真正的动态,接下来我们就来探究下真正的动态数据结构基础–链表。
要点
一、链表基础
1、概念:
通过节点存储数据,通过节点内的节点引用使节点间联系起来形成一条链。
2、节点介绍:
Node{
E e;// 数据成员
Node next;// 指向下一节点指针
}
Node 节点中的成员e用来存储数据,成员next用来保存下一个节点的引用,指向下一节点(类似C++指针)
3、模型图:
get到了
- 最后一个Node的next = null
- 真正的动态,不需要处理固定容量问题
- 丧失到随机访问的能力(必须从头开始找)
为什么丧失了随机访问的能力?
数组靠的是连续的内存地址,通过索引就可以访问,链表考得是next的指向,要从头开始一个next一个next的寻找。
二、含有头结点链表的设计
1、节点的设计(成员内部类)
/**
* Create by SunnyDay on 2019/02/08
* 不含有虚拟头结点
*/
public class LinkedListDemo<E> {
// 节点的设计
private class Node {
E e; // 元素
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();
}
}
}
2、链表其他成员
private Node head;// 头结点
private int size;//链表内存的元素个数
设计链表头节点(链表必有的属性),链表的head和数组的size一样具有特殊意义:
0、头结点顾名思义就是代表首个元素
1、数组的size用于跟踪尾元素下一位置
2、链表的head用于跟踪首个元素注意:
1、遍历时使用head不要改变head,head要始终指向头部的元素
2、添加、删除等使用head时不要修改head指向,否则就失去意义啦。
3、链表添加数据设计
(1) 首先设计addFirst
链表头添加元素的步骤:
1 、声明节点(创建)
2 、节点的元素赋值
3、 修改头结点使新节点为头结点
/**
* 链表头部添加元素
*
* @param e 要添加的元素
*/
public void addFirst(E e) {
Node node = new Node(); // 1、创建节点
node.e = e;//2、节点数据域赋值赋值
node.next = head;// 此处赋值需要留意下
head = node;// 3、更改头结点
size++;
}
但是我们发现我们的Node有不同的构造,于是我们改进 addFirst代码得到优雅写法(其实我们仔细品味会发现下面的一步代码就是上面的三步代码的拆开写法)
/**
* 链表头部添加元素
*
* @param e 要添加的元素
*/
public void addFirst(E e) {
head = new Node(e, head);
size++;
}
(2)addLast
思考:head节点用于指向头部的,我们不能使用这个值进行遍历操作!!!这时我们应该找个临时值。
步骤:
链表判空1、为空:相当于头部插入,直接复用。
2、非空:1、创建head节点的副本
2、通过副本遍历链表,找出尾部节点。
3、插入尾部
/**
* 添加元素到尾部
*
* @param e 要添加的元素
*/
public void addLast(E e) {
// 思路:循环遍历链表,加到尾部即可
if (size == 0) { // head ==null 也行
addFirst(e); // 代码复用
} else {
Node node = head;
while (node.next != null) {
node = node.next;
}
node.next = new Node(e,null);
size++;
}
}
(3) add方法的设计(任意位置添加)
思考:循环遍历找最后一个元素时我们可以通过 node.next 是否为空来确定哪一个为最后一个节点。但是如果从头开始找链表中的中间节点再使用上述的方法就行不通啦!正好用户插入时,需要提供要插入的位置,这时我们可以借助索引进行遍历。
方案:
1、创建头结点的副本
2、通过副本遍历节点,找到要插入节点的上一节点(preNode)
3、进行插入1、创建新节点node
2、新节点数据域赋值
3、preNode的next指向新节点node
/**
* 任意有效位置处添加元素
*
* @param index 要添加的位置
* @param e 要添加的元素
*/
public void add(int index, E e) {
if (index < 0 || index > size) { // 数值可以为size的,因为为size使相当于插入末尾
throw new IndexOutOfBoundsException("index outOf bounds ");
}
if (size == 0) {//空集合时特殊处理
addFirst(e);
} else {
Node preNode = head; // head 节点的副本
for (int i = 0; i < index - 1; i++) { // 从头遍历到要插入节点的上一节点
preNode = preNode.next;
}
/** 完整写法:
* Node node = new Node();
node.e = e;
node.next = preNode.next;
preNode.next = node;
*/
preNode.next = new Node(e, preNode.next); // 优化写法
size++;
}
}
可以看出我们理解了思路很容易就设计出了方法,也没啥需要特别注意的,只要注意头部添加的特殊处理,循环遍历留意下就ojbk了。
三、带虚拟头结点的链表
思考:上文中我们设计了含有头结点的MyLinkedList栗子,每当我们执行add方法任意位置添加元素时,都要先判断下链表是否为空(为空时头结点也是为null的即head=null)这样对我们的add方法来说要加额外的判断,其实删除方法也是,元素删除完毕后head也要置空。其实基于这种问题,也有解决方案,那就是为链表添加个虚拟头结点。
1、最大优点
增删方便,不用考虑头结点为空的情况。
2、原理图:
3、含有虚拟头结点的 LinkedList设计
/**
* Create by SunnyDay on 2019/02/08
* <p>
* 含有虚拟头结点
*/
public class LinkedList<E> {
// 节点的设计
private class Node {
E e; // 元素
Node next; // 指向下一个节点
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return e.toString();
}
}
private Node dummyHead; // 虚拟头结点
private int size; // 链表大小
/**
* 链表初始化
* 添加了个虚拟头结点,但是集合的大小仍是0。对用户进行屏蔽。他不必知道有这么个头结点。
* 只是我们自己使用时方便增删。不必判断空链表的情况。
*/
public LinkedList() {
dummyHead = new Node(null, null);//链表中默认有个节点---虚拟头结点
size = 0;
}
}
4、add 代码设计
/**
* @param index 要插入位置的索引
* @param e 要插入的元素
* @function添加元素
*/
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("index is out of bounds");
}
Node preNode = dummyHead;
for (int i = 0; i < index; i++) {
preNode = preNode.next;
}
/**
完整写法:
Node newNode = new Node(); // 创建新节点
newNode.e = e;// 节点赋值
newNode.next = preNode.next; // 节点指针指向
preNode.next = newNode; // 上一节指针指向
*/
preNode.next = new Node(e, preNode.next);// 优化写法
size++;
}
思路总结:
1、创建虚拟头结点的副本结点
2、修改副本结点指针,找到要插入节点的上一节点preNode
3、进行修改思路:
1、创建新节点newNode
2、新节点的数据域,指针域赋值。
3、preNode的指针域修改
ps:以上三步为完整思路,使用Node的两个参数构造可一步完成。4、集合大小+1
5、删除remove的设计(指定索引删除)
有了虚拟头结点后删除也是方便多啦,而且上文的添加元素的遍历方式我们刚写过,遍历都是一样的。只需找到preNode节点,进行删除即可。
/**
* 删除指定索引元素
*/
public E remove(int index) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("index is out of bounds");
}
Node preNode = dummyHead;
for (int i = 0; i < index; i++) {
preNode = preNode.next;
}
Node deleteNode = preNode.next; // 1、创建要删除节点的副本 (记录要删除的节点)
preNode.next = deleteNode.next; // 2、修改指针,使要删除节点的上一节点的指针,指向要删除节点的下一位置。
deleteNode.next = null; // 3、要删除的节点指针域置空
size--; // 调整集合大小
return deleteNode.e;
}
思路总结:
1、创建虚拟头结点的副本节点,
2、遍历链表,找到要删除节点的上一节点preNode
3、创建临时节点,保存要删除节点
4、执行删除(也就是改下指针)
5、集合大小修改
6、删除任意元素
/**
* 删除任意元素
*
* @param e
*
* ps:设计有弊端,本方法只删除首次碰到的元素。也就是第一次出现的。
*
*/
public void removeE(E e) {
Node prev = dummyHead;
while (prev.next != null) {
if (prev.next.e.equals(e)) {
break;
}
prev = prev.next;
}
//要删除的元素非空时
if (prev.next != null) {
Node delNode = prev.next;
prev.next = delNode.next;
delNode.next = null;
}
}
注意:
1、此方法删除从链表头开始,首次出现的元素
2、注意遍历的条件。只有根据索引添加、删除的操作我们才从虚拟头结点开始遍历。其他遍历还是从真正节点遍历。
5、链表元素查找get方法设置
/**
* 查询
*
* @param index 索引
* 根据索引 查找元素
*/
public E get(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("index is illegal");
}
Node currentElement = dummyHead.next;//从索引为0也就是第一个元素开始遍历
// 注意与add遍历的区别,两者遍历目的不同一个找节点,一个找元素。
for (int i = 0; i < index; i++) {
currentElement = currentElement.next;
}
return currentElement.e;
}
6、 修改set的api设计
/**
* @param index 索引
* @param e 元素
* 修改元素
*/
public void set(int index, E e) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("index is illegal");
}
Node currentElement = dummyHead.next;//从索引为0也就是第一个元素开始遍历
for (int i = 0; i < index; i++) {
currentElement = currentElement.next;
}
currentElement.e = e;
}
没啥需要注意的了,和get几乎类似。
7、 链表的遍历
(1)toString
/**
* toString重写:链表的遍历
*/
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("[");
Node tempNode = dummyHead.next;
while (tempNode != null) {
sb.append(tempNode.e);
if (tempNode.next != null) {
sb.append(",");
}
tempNode = tempNode.next;
}
sb.append("]");
return sb.toString();
}
(2) contain
/**
* 是否包含某元素
*
* @param e 元素
*/
public boolean contain(E e) {
// 遍历每个元素 发现返回true(第一次出现)
Node currentElement = dummyHead.next;
while (currentElement != null) {
//equals元素的放置位置
if (currentElement.e.equals(e)) {
return true;
}
currentElement = currentElement.next;
}
return false;
}
8 、总结
1、根据索引添加、删除元素时创建的是,dummyHead的副本。进行的遍历。
2、遍历、查找、修改等这些操作遍历时创建的是dummyHead.next的副本。即从真正的节点开始遍历。
3、创建虚拟头结点只是方便了根据索引添加删除的情况。
四、使用链表实现栈
思考:
栈的概念后进先出,栈允许我们操作的是一端,我们只能从某一端添加元素,也只能从这一端删除元素,于是我们使用链表的头部作为栈的一端正好可以设计实现
/**
* Create by SunnyDay on 2019/02/10
* 链表实现栈
* <p>
* 思想吧链表的头部当成栈顶处理即可
*/
public class LinkedListStack<E> implements Stack<E> {
private LinkedList<E> linkedList;
public LinkedListStack() {
linkedList = new LinkedList<E>();
}
@Override
public void push(E e) {
linkedList.addFirst(e);
}
@Override
public E pop() {
return linkedList.removeFirst();
}
@Override
public E peek() {
return linkedList.getFirst();
}
@Override
public int getSize() {
return linkedList.getSize();
}
@Override
public boolean isEmpty() {
return linkedList.isEmpty();
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("Stack top ");
sb.append(linkedList);
return sb.toString();
}
}
有了链表我们很容易就封装出了队列
数组队列和建表队列的区别:
1不同之处: 动态数组内部需要不断的扩容,而链表内部需要不断new节点。
2相同之处; 二者具有相同的时间复杂度
五、使用链表实现队列
分析:队列和栈不同,允许我们操作的是不同端的元素,如果使用链表实现我们要思考了:一端添加,另一点移除,我们知道有了head的设计我们很容易就可以在头部添加、删除元素,因此我们如上图再设计为节点标志tail,这时我们有了tail添加数据很方便,删除元素麻烦。但是这些已经可以满足我们设计队列了----链表尾部做入队,链表头部做出队。
LinkedListQueue的设计:
package LinkedList;
import queue.Queue;
/**
* Create by SunnyDay on 2019/02/10
* <p>
* 链表实现队列
* 由于使用了尾指针 故不再复用链表
*/
public class LinkedListQueue<E> implements Queue<E> {
// 节点的设计
private class Node {
E e;
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 Node tail; // 尾节点 最后一个节点的引用
private int size;
public LinkedListQueue() {
head = null;
tail = null;
size = 0;
}
@Override
public E dequeue() {
if (isEmpty()) {
throw new IllegalArgumentException("can not dequeue,queue is empty");
}
Node temp = head; // 保存变量
head = head.next;
temp.next = null;
// 只存在一个元素时
if (head == null) {
tail = null;
}
size--;
return temp.e;
}
@Override
public void enqueue(E e) {
// 期初栈为空 tail = head
if (tail == null) {
tail = new Node(e);
head = tail;
} else {
// 修改指向
tail.next = new Node(e);
tail = tail.next;
}
size++;
}
@Override
public E getFront() {
if (isEmpty()) {
throw new IllegalArgumentException("can not dequeue,queue is empty");
}
return head.e;
}
@Override
public int getSize() {
return size;
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("Queue front");
Node current = head;
while (current != null) {
sb.append(current.e+"->");
current = current.next;
}
sb.append("tail null");
return sb.toString();
}
}
由于添加了新的成员tail,我们就不在复用写好的链表,又由于不涉及中间操作,都是首尾端于是我们不在使用虚拟头结点
小结
经过一番探讨,我们大概了解了链表,这时我们会发现有了这些基础,我们看java原生的集合框架又便利了很多,好像那些底层就是数据结构实现的,哈哈没错看了我们已经可以看到大牛的背影了,为了赶上大牛继续努力。
The end
站的更高 看得更远