导航
线性表:
一、线性表定义:
线性表是n个类型相同数据元素的有限序列,通常记作(a0,a1,a2,a3,…,ai-1,ai,an-1)。
1.1 相同数据类型:
- 在线性表的定义中,我们看到从a0到an-1的n个数据元素是具有相同属性的元素。
- 比如说可以是数字,例如:(1,2,3,4)。
- 也可以是字符,例如:(A,B,C,D)。
- 但不能是既有数字,字母,同时还有学生。
- 相同类型的数据元素意外着在内存中存储时,每个数据元素占用相同的内存空间,便于后续的查询定位。
1.2 序列(顺序性):
- 相邻数据元素之间存着
序偶关系。
- 集合中必存在
唯一的一个“第一元素”。
- 集合中必存在
唯一的一个 “最后元素” 。
- 除最后一个元素(称为
表尾
)之外,均有唯一的后继(后件)。
- 除第一个元素之外(称为
表头
),均有唯一的前驱(前件)。
- 除了
表头
和表尾
之外,每个数据元素都有且仅有一个直接前驱和直接后继。
1.3 有限:
- 在线性表中数据元素的个数n定义为线性表的长度,n是一个有限值。
当n等于0是为空表。
- 在非空的信线性表中每个数据元素在线性表中都有
唯一确定的序号(索引)
,例如:a0的序号为0,ai的序号为i。 - 在一个具有
n>0
的数据元素的线性表中,数据元素序号的范围为[0,n-1]。
二、线性表的逻辑结构:
三、 线性表的存储结构:
分类图:
PS:循环链表又分为:
单向循环链表
和双向循环链表
。
3.1 顺序存储
3.1.1 顺序表–顺序存储结构:
特点:
- 使用
数组
实现,在内存中分配连续的空间
,只存储数据
,不需要存储地址信息
,位置就隐含着地址。 - 逻辑上相邻的元素,物理上也相邻。
数组大小有两种方式指定,一是
静态分配
,二是动态扩展
。
注:线性表从1开始,而数组从0开始。
优点:
-
节省存储空间
,因为分配给数据元素的存储单元全用存放节点的数据(不考虑c/c++语言中数据数组需指定大小的情况),结点之间的逻辑关系没有占用额外的存储空间。 -
随机访问特性
,查找O(1)时间
,存储密度高;逻辑上相邻的元素,物理上也相邻。 - 索引查找效率高(取值–通过索引进行查找,时间复杂度为
T(n)=O(1)
),每个数据元素对应一个序号,由该序号可以直接计算出该结点的存储地址。
假设:线性表的
每个数据
元素占用K个存储单元
,并以元素所占的第一个存储单元的地址
称为数据元素的存储地址
。则线性表中序号为i
的数据元素的存储地址LOC(ai)
和序号为i+1
的数据元素的存储地址LOC(ai+1)
的关系为:
LOC(ai+1)=LOC(ai)+K
通常来说:线性表中序号为i的数据元素ai的存储地址为:
LOC(ai)=LOC(a0)+i*K
其中LOC(a0)
为序号为0
的数据元素a0的存储地址
,通常称为线性表的起始地址。
缺点:
插入和删除需移动大量元素,效率低。
- 必须
提前分配固定数量的空间
,如果存储元素少,可能导致空间浪费。 按照内容查找效率低
,需要逐个比较判断。时间复杂度为T(n)=O(n)
。
顺序表相关的操作跟数组有关,一般都是
移动数组元素
。
举例: 长度为n的数组中删除元素,假设每个元素删除的概率是相同的,问时间复杂度是多少?
删除第n个元素,需要移动0个元素
删除第n-1个元素,需要移动1个元素
删除第n-2个元素,需要移动2个元素
…
删除第2个元素,需要移动n-2个元素
删除第1个元素,需要移动n-1个元素
所以平均时间频度是:01/n+11/n+2*1/n+…+(n-2)*1/n+(n-1)*1/n=(n-1)/2
其中n/1
表示每个数据元素删除的概率
;基本操作次数T(n)为(n-1)/2
;用大O渐进法
表示,它的时间复杂度为: O(n)
。
3.1.2 顺序表的实现:
模拟
ArrayList
底层通过数组
进行实现,先要更深入了解下ArrayList
底层是如何实现的小伙伴请参考这篇博客【史上最全的集合框架讲解 ----- Java 集合框架(2)---- List 相关类最全解析】
3.1.2.1 插入:
PS:底层通过
数组
进行实现,所有需要考虑扩容
,可以通过索引获取节点
,插入,删除需要移动节点。
分析:
- 插入: 只有在
index>=0||index<size
的范围内插入元素才需要做各自往后移动
操作。(另外index=size
的时有时直接插入到线性表的尾部
即可,不需要移动任何元素
)否则抛出 :IndexOutOfBoundsException - (index < 0 || index > size)
。
PS:可以插入的范围:
[0,size]
- 代码:
/**
* 将元素e插入到i位置
* @param i 插入位置
* @param e 插入元素
*/
public void insertBefore(int i,E e) {
throwException(i);
//数组的大小等于元素个数时,进行扩容
if(size==elementData.length){//数组满了
grow();
}
//PS:只有在i>=0||i<size的范围内插入才需要往后移动操作。
//将i以及i之后的元素各自往后移动一位
for(int j=size;j>i;j--){
//将索引为size的前一位的元素依次往后移动一位,直到索引为i就停止,相当于覆盖掉后一位元素
elementData[j]=elementData[j-1];
}
//给特定位置赋值
elementData[i]=e;
size++;
}
- 核心代码:
//将i以及i之后的元素各自往后移动一位
for(int j=size;j>i;j--){
//将索引为size的前一位的元素依次往后移动一位,直到索引为i就停止,相当于前一位元素覆盖掉后一位元素
elementData[j]=elementData[j-1];
}
PS:
插入到最后是往中间插入的一种特殊情况
。
- 插入步骤:
(1)先考是否需要扩容。
(2)将i(要插入的索引处)以及i之后的元素各自往后移一位,直到i位置为止,相当于前面一个元素覆盖掉后一个元素。
(3)给索引i位置进行赋值。
(4)元素数量+1。
3.1.2.2 扩容操作:
什么时候扩容?
当数组的长度等于元素个数。
分析:
- 代码:
//扩容步骤:
private void grow(){
//[1]重新定义一个按照规律扩容后的数组
Object[] newElementData=new Object[elementData.length*2];
//[2]将旧数组的值拷贝到中心数组中
for(int i=0;i<=size-1;i++){
newElementData[i]=elementData[i];
}
//[3]将指针指向新数组
elementData=newElementData;
}
- 扩容步骤:
(1)重新定义一个按照一定规律扩容后的数组。
(2)将旧数组的数据拷贝到新的数组中。
(3)将指针指向新的数组。
3.1.2.3 删除:
PS:
不需要考虑扩容的问题。
分析:
- 删除: 只有在
index>=0||index<size-1
的范围内删除元素才需要做各自往前移动
操作。(另外index=size-1
的时有时直接删除即可,不需要移动任何元素)否则抛出 :IndexOutOfBoundsException - (index < 0 || index > =size
。
PS:可以删除的范围:
[0,size-1]。
- 代码:
/**
* 删除i位置的元素
* @param i 删除的位置
*/
private void removeElement(int i){
if(i<0||i>=size){
throw new IndexOutOfBoundsException("数组越界--删除"+i);
}
//将i之后的元素进行往前移动一位
for(int j=i+1;j<size;j++){
//将i之后的元素依次往前移动一位,直到索引为size就停止,相当于覆盖掉前一位元素
elementData[j-1]=elementData[j];
}
//元素个数-1
size--;
}
- 核心代码:
//将i之后的元素进行往前移动一位
for(int j=i+1;j<size;j++){
//将i之后的元素依次往前移动一位,直到索引为size就停止,相当于覆盖掉前一位元素
elementData[j-1]=elementData[j];
}
- 删除步骤:
(1)将i之后的元素各自往前移一位,直到size位置为止,相当于后面一个元素覆盖掉前一个元素。
(2)元素数量-1。
3.1.2.4 修改:
PS:
不需要考虑扩容的问题和移动问题
。
分析:
- 修改: 只有在
index>=0||index<=size-1
的范围内修改元素才能进行修改操作。否则抛出 :IndexOutOfBoundsException - (index < 0 || index > =size)
。
PS:可以修改的范围:
[0,size-1]
。
- 代码:
@Override
public Object replace(int i, E e) {
if(i<0||i>=size){
throw new IndexOutOfBoundsException("数组越界--替换"+i);
}
//先查出索引为i的元素,再进行替换它
//[1]查出索引为i的元素,并保存下来,以便返回
E removeElement=convertToE(i);
//[2]替换它
elementData[i]=e;
return element;
}
- 修改步骤:
(1)1.获取索引处的元素并使用变量保存。
(2)替换为新的数据即可。
3.1.2.5 查询:
1)根据索引获取元素get(i)
@Override
public E get(int i) {
return convertToE(i);
}
2)获取元素第一次出现的位置indexOf(e)
/*抛出: RuntimeException - 数组不能为null或者数组长度不能为0)
有break,表示获取该元素第一次出现的位置
没有break,表示获取的是最后一次出现的位置
PS:存在元素则返回该元素所在的索引,否则返回-1
注意:需要和lastIndexOf()区别开,遍历数组的时候要加break
*/
@Override
public int indexOf(E e) {
//没存在该元素,则返回-1
int index=-1;
for(int i=0;i<=size-1;i++){
//比较内容,不能用==,否则比较的是地址
if(convertToE(i).equals(e)){
index=i;
break;
}
}
return index;
}
3)获取元素最后一次出现的位置lastIndexOf(e)
/*抛出: RuntimeException - 数组不能为null或者数组长度不能为0)
PS:存在元素则返回该元素所在的索引,否则返回-1
有break,表示获取该元素第一次出现的位置
没有break,表示获取的是最后一次出现的位置
注意:需要和indexOf()区别开,遍历数组的时候不要加break
*/
@Override
public int lastIndexOf(E e) {
//没存在该元素,则返回-1
int index=-1;
for(int i=0;i<=size-1;i++){
//比较内容,不能用==,否则比较的是地址
if(convertToE(i).equals(e)){
index=i;
}
}
return index;
}
4) 遍历数组:使用迭代器进行遍历
//获取迭代器对象
public MyIterator<E> iterator(){
return new Itr();
}
/**
* 内部类,具体迭代器类
* @author HKM
*
*/
private class Itr implements MyIterator<E>{
//游标
private int cursor;
@Override
public boolean hasNext() {
return cursor!=size;
}
@Override
public E next() {
//[1]先获取当前游标所指向的元素
int currCursor=cursor;
if (currCursor >= size){
throw new NoSuchElementException("数组越界,没有该元素");
}
//[2]再将游标移向下一个元素
cursor+=1;
return convertToE(currCursor);
}
}
3.1.2.6 拓展内容:
(1)addBefore(E obj, E e)
: 将一个元素插入到另一个元素的前面 ,范围:[0,size-1]
,相当于在 obj对应的索引i处插入数据e。
- 代码:
/*PS:将一个元素插入到另一个元素的前面范围[0,size-1]
将元素e插入到元素obj前面,插入成功返回true,否则返回false
元素obj必须存在,元素e不存在,是个新添加的元素
*/
@Override
public boolean addBefore(E obj, E e) {
if(contains(obj)){//obj元素存在
//[1]获取obj存在的位置
int i=indexOf(obj);
//[2]在i处插入e节点
insertBefore(i,e);
return true;
}
return false;
}
(2)addAfter(E obj, E e):
将一个元素插入到另一个元素的后面,范围:[0,size-1]
,相当于在obj对应的索引i后面i+1处插入数据e。
- 代码:
/*PS:将一个元素插入到另一个元素的后面范围[0,size-1]
将元素e插入到元素obj后面,插入成功返回true,否则返回false
元素obj必须存在,元素e不存在,是个新添加的元素
*/
@Override
public boolean addAfter(E obj, E e) {
if(contains(obj)){//obj元素存在
//[1]获取obj存在的位置
int i=indexOf(obj);
//[2]在i+1处插入e节点
insertAfter(i+1,e);
return true;
}
return false;
}
注意:前提是obj必须存在的情况下。
(3)封装一个转化为泛型的方法:
/**
* 转化为泛型
* @param i
* @return 泛型
*/
@SuppressWarnings("unchecked")
private E convertToE(int i){
return (E)elementData[i];
}
3.2 链式存储:
链表–链式存储结构:
3.2.1 单链表:
在使用单链表实现线性表的时候,为了使程序更加简洁,我们通常在单链表的最前面添加一个哑元结点
,也称为头结点
。
但头结点中不存储任何实质的数据对象
,其next 域指向线性表中 0 号元素所在的结点
, 可以对空表、非空表的情况以及对首元结点进行统一处理,编程更方便,常用头结点。
一个带头结点的单链表实现线性表的结构图如图所示。
特点:
- 用一组任意的存储空间存储线性表的数据元素,
这组存储空间可以是连续的,也可以是不连续的
。这就意味着,这些数据元素可以存在内存中任何未被占用的位置。 - 链式存储结构中,除了要
存数据元素信息
外,还要存储它的后继元素的存储地址
。元素之间的逻辑关系通过存储节点之间的链接关系反映出来。 - 一个结点由
数据域
和指针域
组成,我们把存储数据元素信息的域称为数据域
,把存储直接后继结点地址的域称为指针域
。指针域中存储的信息(下一个结点的地址)称做指针或链。这两部分信息组成数据元素ai的存储映像,称为结点。 - n个结点链结成一个链表,即为线性表的链式存储结构,因为此链表的
每个结点中只包含一个指针域
,所有叫做单链表
。 - 逻辑上相邻的元素,物理上不必相邻。
- 链表中
第一个结点的存储位置叫做头指针
,最后一个结点指针为“空”。
- 单链表的一个重要特性就是
只能通过前驱结点找到后续结点,而无法从后续结点找到前驱结点。
优点:
- 插入和删除灵活,(
不必移动节点,只需要改变指针指向的地址,但是需要定位到元素上
)。 - 有元素才会分配存储空间,不会有闲置的存储空间,也就是说不会浪费存储空间。
缺点:
- 比顺序存储结构的存储密度小(
每个节点都有数据源和指针域组成
,所以相同空间内假设全存满的话顺序比链式存储更多)。 - 查找节点时链式比顺序的效率要慢–>取值(
因为每个节点地址不连续,无规律,导致按照索引查询效率低下
)。
为了方便对链表进行操作,会在
单链表的第一个结点前附设一个结点
,称为头结点
。头结点的数据域可以不存储任何信息
,也可以存储如线性表的长度等附加信息。
3.2.2 单链表的实现:
PS:
底层不是通过数组进行实现,所以不需要考虑扩容
,不能通过索引获取节点,必须从头结点head
开始依次找。插入,删除操作不需要移动节点,只需要修改指针域即可。
3.2.2.1 插入:
分析:
- 插入: 只有在
index>=0||index<=size
的范围内插入元素,否则抛出 :IndexOutOfBoundsException - (index < 0 || index > size)
。
PS:可以插入的范围:
[0,size]
。
- 代码:
//插入一个结点,步骤如下:
@Override
public void add(int i, E e) {
throwException(i);
//[1]新建一个结点
Node<E> newNode=new Node<>(e);
//在[2]步骤前,需要先找出原来索引为i结点的前一个结点,从头结点开始找
Node<E> p=head;
for(int j=0;j<i;j++){
//PS:将指针移向下一个结点
p=p.next;
}
//[2]指明新结点的直接后继结点
newNode.next=p.next;
//[3]更改原来索引为i结点的前一个结点(也就是索引为i-1的结点)的直接后继结点
p.next=newNode;
//[4]结点个数+1
size++;
}
PS:
插入到最后是往中间插入的一种特殊情况
。
- 插入步骤:
(1)新建一个数据域不为空(带有数据)的结点s
。
(2)找出p(也就是插入处i的直接前驱结点)
,需要从头结点开始找
。
(3)指明s
的直接后继结点为p
原来的直接后继结点(p.next) -->s.next=p.next
。
(4)指明p
的直接后继结点为新结点-->p.next=s
。
(5) 结点个数+1。
PS:索引为i-1的结点:p , 新节点:s
3.2.2.2 删除:
分析:
- 删除: 只有在
index>=0||index<size
的范围内删除元素,否则抛出 :IndexOutOfBoundsException - (index < 0 || index > =size)
。
PS:可以删除的范围:
[0,size-1]
。
- 代码:
@Override
public E remove(int i) {
throwException(i);
//[1].找到i-1索引的结点,从头结点开始找
Node<E> p=head;
for(int j=0;j<i;j++){
//PS:将指针移向下一个结点
p=p.next;
}
//[2]记录删除的结点
Node<E> removeNode=p.next;
//[3]更改原来索引为i结点的前一个结点(也就是索引为i-1的结点)的直接后继结点
p.next=p.next.next;
//[4]结点个数-1
size--;
return removeNode.data;
}
-
删除步骤:
(1)找出p
(也就是插入处i-1的直接前驱结点
),需要从头结点开始找
。
(2)记录删除的结点q
(也就是索引为i的结点
)。
(3)- 第一种方法:更改
p
的直接后继结点为q
的直接后继结点(q.next)-->p.next=q.next
。 - 第二种方法:更改
p
的直接后继结点为自己本身
的直接后继结点的直接后继结点(p.next.next)-->p.next=p.next.next
。
- 第一种方法:更改
(4)结点个数-1。
PS:索引为i-1的结点:p ,索引为i的结点:q
3.2.2.3 修改:
分析:
- 第一种方式: 相当于先删除对应索引旧的结点,再往对应索引处插入新的结点即可。(效率较慢)。
//替换步骤:相当于删除i节点,再往i插入新的节点
E data=remove(i);
add(i,e);
- 第二种方式:
//[1]先得到对应索引处的结点元素
Node<E> node = getNode(i);
//[2]保存结点旧的数据
E oldVal = node.data;
//[3]替换成新的数据
node.data=e;
return oldVal;
3.2.2.4 查询:
1)根据索引获取元素get(i)
@Override
public E get(int i) {
//PS:因为head结点不存任何的数据元素,所以需要将指针移向第一个结点
Node<E> p=head;
for(int j=0;j<=i;j++){
//PS:将指针移向下一个结点
p=p.next;
}
return p.data;
}
2)获取元素第一次出现的位置indexOf(e)
@Override
public int indexOf(E e) {
//没存在该元素,则返回-1
int index=-1;
for(int i=0;i<=size-1;i++){
//获取索引对应的结点
E data=get(i);
//比较内容,不能用==,否则比较的是地址
if(data.equals(e)){
index=i;
break;
}
}
return index;
}
3)获取元素最后一次出现的位置lastIndexOf(e)
@Override
public int lastIndexOf(E e) {
//没存在该元素,则返回-1
int index=-1;
for(int i=0;i<=size-1;i++){
//获取索引对应的结点
Object data=get(i);
//比较内容,不能用==,否则比较的是地址
if(data.equals(e)){
index=i;
}
}
return index;
}
通过public Node<E> getNode(int i)
该方法可以定位到对应索引的结点对象---->head结点的下一个结点作为第一个结点
:
例如:
i=0, 获取第一个结点
i=1,获取第二个结点
依次类推 …
/**
* 获取对应索引处的结点对象
* @param i 索引处
* @return 结点对象
*/
private Node<E> getNode(int i){
//PS:因为head结点不存任何的数据元素,所以需要将指针移向第一个结点
Node<E> p=head;
for(int j=0;j<=i;j++){
//PS:将指针移向下一个结点
p=p.next;
}
return p;
}
3.2.2.5 拓展内容:
(1)addBefore(E obj, E e)
:将一个元素插入到另一个元素的前面 ,范围:[0,size-1]
,相当于在obj对应的索引i处插入数据e。
- 代码:
@Override
public boolean addBefore(E obj, E e) {
//[1]获取obj的索引
int i=indexOf(obj);
if(obj!=null&&i!=-1){
//[2]在i处插入e节点
add(i,e);
return true;
}
return false;
}
(2)addAfter(E obj, E e)
:将一个元素插入到另一个元素的后面,范围:[0,size-1]
,相当于在obj对应的索引i后面i+1处插入数据e。
- 代码:
@Override
public boolean addAfter(E obj, E e) {
//[1]获取obj的索引
int i=indexOf(obj);
if(obj!=null&&i!=-1){
//[2]在i+1处插入e节点
add(i+1,e);
return true;
}
return false;
}
注意:前提是obj必须存在的情况下。
(3)一个带有泛型的结点类(作为实现类的一个静态内部类
):
package com.sprjjs.datastructure.pojo;
/**
* 结点:一个结点(增加泛型)包括两个部分
* 1.数据域
* 2.指针域(指向下一个结点的地址)
* @author HKM
*
*/
private static class Node<E> {
//数据域
E data;
//指针域
Node<E> next;
Node(E data) {
super();
this.data = data;
}
Node() {
super();
}
@Override
public String toString() {
return "Node [data=" + data + ", next=" + next + "]";
}
}
3.2.3 双链表:
在使用双向链表实现链接表时,为使编程更加简洁,我们使用带两个哑元结点的双向链表
来实现链接表。
其中一个是头结点
,另一个是尾结点
,它们都不存放数据元素
,头结点的pre 为空,而尾结点的 Next 为空
。
特点:
- 单链表的一个优点是结构简单,但是它也有一个缺点,即在
单链表中只能通过一个结点的引用访问其后续结点,而无法直接访问其前驱结点,要在单链表中找到某个结点的前驱结点,必须从链表的首结点出发依次向后寻找
,但是需要Ο(n)
时间。 - 为此我们可以扩展单链表的结点结构,使得通过一个结点的引用,不但能够访问其
后续结点
,也可以方便的访问其前驱结点
。 - 扩展单链表结点结构的方法是,
在单链表结点结构中新增加一个域,该域用于指向结点的直接前驱结点。
扩展后的结点结构是构成双向链表的结点结构,如图所示。
分析:
在双向链表中同样需要完成数据元素的查找、插入、删除等操作。在双向链表中进行查找与在单链表中类似,只不过在双向链表中查找操作可以从链表的首结点开始,也可以从尾结点开始
,但是需要的时间和在单链表中一样,在使用双向链表实现链接表时,为使编程更加简洁,我们使用带两个哑元结点的双向链表来实现链接表。
其中一个是头结点
,另一个是尾结点
,它们都不存放数据元素
,头结点的pre 为空
,而尾结点的 Next 为空
,在具有头尾结点的双向链表中插入和删除结点,无论插入和删除的结点位置在何处,因为首尾结点的存在,插入、删除操作都可以被归结为某个中间结点的插入和删除;并且因为首尾结点的存在,整个链表永远不会为空
,因此在插入和删除结点之后,也不用考虑链表由空变为非空或由非空变为空的情况下 head 和 tail 的指向
问题;从而简化了程序。
3.2.4 双链表的实现:
模拟LinkedList
底层通过双向链表
进行实现,先要更深入了解下LinkedList
底层是如何实现的小伙伴请参考这篇博客【史上最全的集合框架讲解 ----- Java 集合框架(2)---- List 相关类最全解析】
PS:底层不是通过
数组
进行实现,所以不需要考虑扩容
,不能通过索引获取节点
,必须从第一个头结点first
或最后一个结点last
开始依次找。插入,删除操作不需要移动节点
,只需要修改指针域
即可,
3.2.4.1 插入:
3.2.4.1.1 插入到最后:
分析:
- 插入: 只有在
index>=0||index<=size
的范围内插入元素,否则抛出 :IndexOutOfBoundsException - (index < 0 || index > size)
。
PS:可以插入的范围:
[0,size]
。
- 代码:
/**
* 将元素插入到最后
* @param e 元素
*/
private void linkLast(E e) {
//注意:如果链表为空,则插入的结点既是first,也是last
//[1]获取最后一个结点lastNode,
Node<E> lastNode=last;
//[2]新建结点newNode,并指明next为null(因为新插入的结点永远为最后一个结点),prev为i-1的结点
Node<E> newNode=new Node<>(last, e, null);
//[3]将last指向新增的结点newNode(因为新插入的结点永远为最后一个结点)
last=newNode;
//[4]如果链表为空,则插入的结点是第一个结点,否则指明i-1结点的next为新增的结点newNode
if(lastNode==null){
first=newNode;
}else{
lastNode.next=newNode;
}
//结点数量+1
size++;
}
-
插入步骤:
(1)插入之前,先找到链表的最后一个结点并记录下来lastNode
。
(2)新建一个数据域不为空(带有数据)的结点s
,并指明next为null
(因为新插入的结点永远为最后一个结点
),prev为结点lastNode
。
(3)将last
指向新增的结点newNode
(因为新插入的结点永远为最后一个结点
)。
(4)判断链表是否为空
:- 如果为空,则插入的结点是第一个结点: 将新插入的结点指明为链表的
第一个结点first
。 - 如果不为空:指明
lastNode
结点的next
为新增的结点newNode
。
- 如果为空,则插入的结点是第一个结点: 将新插入的结点指明为链表的
(5) 结点个数+1。
PS:索引为size-1(没有插入之前链表的最后一个结点)的结点:lastNode ;新节点:newNode。
PS:
插入到最后是往中间插入的一种特殊情况。
3.2.4.1.2 插入到中间(也有可能是插入到最后一个):
分析:
- 插入: 只有在
index>=0||index<=size
的范围内插入元素,否则抛出 :IndexOutOfBoundsException - (index < 0 || index > size)
。
PS:可以插入的范围:
[0,size]
。
- 代码:
/** 将e插入到currNode之前
* @param e
* @param currNode
*/
private void linkBefore(E e,Node<E> currNode) {
//[1]获取当前结点currNode的前一个结点
Node<E> prevNode=currNode.prev;
//[2]新建一个结点,并指明next为当前结点currNode,prev为当前结点currNode的前一个结点pred
Node<E> newNode=new Node<>(prevNode, e, currNode);
/*[3]如果当前结点currNode为第一个结点,则将新增节点newNode设为第一个元素,
否则指定当前结点currNode的前一个结点prevd的next为新增节点newNode,
当前结点currNode的prev为新增节点newNode
*/
currNode.prev=newNode;
if(prevd==null){
first=newNode;
}else{
prevNode.next=newNode;
}
//[4]节点个数+1
size++;
}
-
插入步骤:
(1)获取当前结点currNode
的前一个结点prevNode
。
(2)新建一个结点newNode
,并指明其next为结点currNode
,prev为结点prevNode
。
(3)将currNode的prev指向currNode
。
(4)判断插入索引是否为0
:- 如果是,则
插入的结点就成为了第一个结点
: 将新插入的结点指明为链表的第一个结点first
。 - 如果不是:指明
prevNode 结点的next为新增的结点newNode
。
- 如果是,则
(5) 结点个数+1。
PS:索引为i(插入的索引处)的结点:currNode ;索引为i-1(插入的索引处的前一位)的结点:prevNode ;新节点:newNode。
3.2.4.2 删除:
分析:
- 删除: 只有在
index>=0||index<size
的范围内删除元素,否则抛出 :IndexOutOfBoundsException - (index < 0 || index > =size)
。
PS:可以删除的范围:
[0,size-1]
。
- 代码:
/**
* 删除结点的情况--要考虑释放删除节点的空间
* 1.是第一个结点
* 操作:[1]将当前结点i的后驱结点i+1变为first
* [2]将当前结点i的后驱结点i+1的prev置为null
* [3]将当前结点i的next和data置为null
*
* 2.是最后一个结点
* 操作:[1]将当前结点i的前驱结点i-1变为last
* [2]将当前结点i的前驱结点i-1的next置为null
* [3]将当前结点i的prev和data置为null
* 3.是中间的结点
* 操作:[1]将当前结点i的前驱结点i-1的next指向当前结点i的后驱结点i+1
* [2]将当前结点i的后驱结点i+1的prev指向当前结点i的前驱结点i-1
* [3]将当前结点i的prev,next和data置为null
*/
@Override
public E remove(int i) {
rangeCheck(i);
//[1]先找到当前使用i节点
Node<E> currNode = getNode(i);
E data = currNode.data;
//[2]获取当前结点的i的前驱结点i-1和后继结点i+1,以便判断删除的结点位置
Node<E> prevNode = currNode.prev;
Node<E> nextNode = currNode.next;
//[3]判断删除节点的位置
//[3.1]删除的是第一个结点
if(prevNode==null){
//[3.1.1]将当前结点i的后驱结点i+1变为first
first=nextNode;
}else{
//PS:删除的可能是最后或中间的节点
/*将当前结点i的前驱结点i-1的next指向当前结点i的后驱结点i+1,如果删除的
节点是最后一个,则nextNode为空,否则不为空
*/
prevNode.next=nextNode;
//[3.1.2]将当前结点i的prev置为null
currNode.prev=null;
}
//[3.2]删除的是最后一个结点
if(nextNode==null){
//[3.2.1]将当前结点i的前驱结点i-1变为last
last=prevNode;
}else{
//PS:删除的可能是第一个后或中间的节点
/*将当前结点i的后驱结点i+1的prev指向当前结点i的前驱结点i-1,如果删除的
节点是第一个,则prevNode为空,否则不为空
*/
nextNode.prev=prevNode;
//[3.1.2]将当前结点i的next置为null
currNode.next=null;
}
//[4]当前结点i的data置为null
currNode.data=null;
//[5]结点个数—1
size--;
return data;
}
-
删除步骤:
(1)找出currNode
,需要从头结点开始找。
(2)找出prevNode
和nextNode
,以便判断删除的结点位置。
(3)判断删除的结点位置:-
删除的是第一个结点:
[1]将nextNode
变为first
[2]将nextNode
的prev
置为null
[3]将currNode
的next
和data
置为null
-
删除的是最后一个结点:
[1]将prevNode
变为last
[2]将prevNode
的next
置为null
[3]将currNode
的prev
和data
置为null
-
(4)结点个数-1。
PS:索引为i(删除处索引)的结点:currNode ;索引为i-1(删除处索引前一个)的结点:prevNode ; 索引为i(删除处索引后一个)的结点:nextNode。
注意一点:
需要将删除结点占用的空间释放掉,也就是讲对象置为null。
3.2.4.3 修改
分析:
- 第一种方式:相当于先删除对应索引的旧结点,再往对应索引处插入新结点即可。
(效率较慢)
//替换步骤:相当于删除i节点,再往i插入新的节点
E data=remove(i);
add(i,e);
第二种方式:
//[1]先得到对应索引处的结点元素
Node<E> node = getNode(i);
//[2]保存结点旧的数据
E oldVal = node.data;
//[3]替换成新的数据
node.data=e;
return oldVal;
- 第二种方式:
//[1]先得到对应索引处的结点元素
Node<E> node = getNode(i);
//[2]保存结点旧的数据
E oldVal = node.data;
//[3]替换成新的数据
node.data=e;
return oldVal;
3.2.4.4 查询:
1)根据索引获取元素get(i)
@Override
public E get(int i) {
return getNode(i).data;
}
2)获取元素第一次出现的位置indexOf(e)
@Override
public int indexOf(E e) {
//没有则返回-1,否则返回该节点的索引
int index=-1;
for(int i=0;i<=size-1;i++){
//获取索引对应的结点
E data=get(i);
//比较内容,不能用==,否则比较的是地址
if(data.equals(e)){
index=i;
break;
}
}
return index;
}
3)获取元素最后一次出现的位置lastIndexOf(e)
@Override
public int lastIndexOf(E e) {
//没有则返回-1,否则返回该节点的索引
int index=-1;
for(int i=0;i<=size-1;i++){
//获取索引对应的结点
E data=get(i);
//比较内容,不能用==,否则比较的是地址
if(data.equals(e)){
index=i;
}
}
return index;
}
通过public Node<E> getNode(int i)
该方法可以定位到对应索引的结点对象---->first为第一个结点(不存在head头结点这种概念)
:
例如:
i=0, 获取第一个结点
i=1,获取第二个结点
依次类推 …
知识拓展:查找运用
折半查找法
,提高效率
/**
* 获取对应索引处的结点对象,运用折半查找法,提高效率
* @param i 索引处
* @return 结点对象
*/
private Node<E> getNode(int i){
Node<E> node=null;
if(i<=(size>>1)){//size>>1:相当于size/2
//[1]从第一个结点开始找
node=first;
for(int j=0;j<i;j++){
//[2]将指针移向下一个结点
node=node.next;
}
}else{
//[1]从最后一个开始找
node=last;
for(int j=size-1;j>i;j--){
//[2]将指针移向上一个结点
node=node.prev;
}
}
return node;
}
3.2.4.5 拓展内容:
(1)addBefore(E obj, E e)
:将一个元素插入到另一个元素的前面 ,范围:[0,size-1]
,相当于在obj对应的索引i处插入数据e。
- 代码:
//将e插入到obj的前面
@Override
public boolean addBefore(E obj, E e) {
//判断obj是否存在
if(contains(obj)){
add(indexOf(obj),e);
return true;
}
return false;
}
(2)addAfter(E obj, E e)
:将一个元素插入到另一个元素的后面,范围:[0,size-1]
,相当于在obj对应的索引i后面i+1处插入数据e。
- 代码:
@Override
public boolean addAfter(E obj, E e) {
//[1]获取obj的索引
int i=indexOf(obj);
if(obj!=null&&i!=-1){
//[2]在i+1处插入e节点
add(i+1,e);
return true;
}
return false;
}
前提:
obj必须存在的情况下。
(3)一个带有泛型的结点类(作为实现类的一个静态内部类
):
/**
* 结点:一个结点(增加泛型)包括三个部分
* 1.数据域
* 2.上一个结点指针域 prev
* 3.下一个结点指针域 next
* @author HKM
*
*/
private static class Node<E> {
//数据域
E data;
//下一个结点指针域
Node<E> next;
//上一个结点指针域
Node<E> prev;
Node(Node<E> prev,E data, Node<E> next) {
super();
this.data = data;
this.next = next;
this.prev = prev;
}
}
PS:增删改操作会涉及到链表的
第一个结点first
和最后一个结点last
概念(非常重要)。
3.2.5 单向循环链表:
什么是单向循环链表?
如果把单链表的最后一个节点的指针指向链表头部,而不是指向NULL,那么就构成了一个单向循环链表,通俗讲就是把尾节点的下一跳指向头结点。
单向链表的循环带头结点的空链表:
单向链表的循环带头结点的非空链表:
为什么要使用单向循环链表?
在单向链表中,头指针
是相当重要的,因为单向链表的操作都需要头指针,所以如果头指针丢失或者破坏,那么整个链表都会遗失,并且浪费链表内存空间
,因此我们引入了单向循环链表
这种数据结构。
单向循环链表我们需要注意一个问题:
在单链表
中我们使用的是虚拟头结点
,但是在循环链表
中我们在遍历的时候就会遇到麻烦,因此在单向循环链表中我们使用的是真实头结点。
3.2.6 单向循环链表的实现:
3.2.6.1 初始化:
只有一个头节点head的时候next就指向自己。
3.2.6.2 创建:
和单向链表差不多,区别就是最后一个节点的next指向的是head。
3.2.6.3 插入:
直接让新插入节点的next指向下一个节点就行。在最后的位置插入也是不矛盾的,因为已经构成了一个环,最后位置的next就是指向的头节点。
3.2.6.4 删除:
3.2.7 双向循环链表:
什么是双向循环链表?
最后一个节点的next指向head,而head的prior指向最后一个节点,构成一个环。
双向链表的循环带头结点的空链表:
双向链表的循环带头结点的非空链表:
3.2.8 双向循环链表的实现:
3.2.8.1 初始化:
初始化:只有一个头节点head,就让prior和next都指向自己。
3.2.8.2 创建:
与单向循环链表类似的,只是多了一个prior要考虑。
3.2.8.3 插入:
与单向循环链表类似,只是多了一个prior要考虑。这里就不需判断插入的位置是不是在最后了,已经构成一个环了。
3.2.8.4 删除:
3.2.9 静态链表:
什么是态链表?
静态链表是借助数组
来描述线性表的链式存储结构,节点也有数据域
和指针域
,这里的指针是节点的相对地址(数组下标)
,也需要预先分配一块连续的内存空间
。
特点:
- 插入删除和动态链表一样,以
next==-1
为结束标志。 - 静态链表中
指针表示数组下标或下一个元素的下标
。 - 静态链表既有
顺序存储
的优点,又有动态链表
的优点,因为其用游标cur来指示下一个数据元素的存储位置
,所以存取数据时静态链表同线性链表(单链表)是相似的
。也就是说,静态链表在存取表中第i个元素的时间同i是相关的。
- 静态链表中
能容纳的元素个数的最大数在表定义时就确定了,以后不能增加。
- 静态链表与动态链表在元素的插入、删除上类似,
不需做元素的移动
。
优点:
缺点:
3.3 散列存储:
3.3.1 哈希表:
3.3.1.1 哈希表的由来:
数组的特点是:寻址容易,插入和删除困难
;
而链表的特点是:寻址困难,插入和删除容易
。
那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表,哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法——拉链法
,我们可以理解为“链表的数组
”,如图:
左边很明显是个
数组
,数组的每个成员包括一个指针
,指向一个链表的头
,当然这个链表可能为空,也可能元素很多。我们根据元素的一些特征把元素分配到不同的链表中去,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。
3.3.1.2 什么是哈希表?
哈希表(Hash table,也叫散列表
),是根据关键码值(Key value)
而直接进行访问的数据结构。也就是说,它通过把hash值映射到表中一个位置来访问记录,以加快查找的速度
。
这个映射函数叫做散列函数
,存放记录的数组叫做散列表
。
3.3.1.3 什么是哈希函数?
对比之前博客讨论的二叉排序树
,二叉平衡树
,红黑树 B
, B+树
,它们的查找都是先从根节点进行查找
,从节点取出数据或索引与查找值进行比较。那么,有没有一种函数H
,根据这个函数和查找关键字key
,可以直接确定查找值所在位置,而不需要一个个比较。这样就“预先知道”key所在的位置,直接找到数据,提升效率。
即:
地址index=H(key)
说白了,hash函数就是根据关键字(key)计算出应该存储地址的位置,而哈希表是基于哈希函数建立的一种查找表。
3.3.1.4 Hash的应用:
-
Hash主要用于信息安全领域中加密算法,它把一些不同长度的信息转化成杂乱的128位的编码,这些编码值叫做
Hash值
, 也可以说,Hash就是找到一种数据内容和数据存放地址之间的映射关系。
-
查找:哈希表,又称为散列,是一种更加快捷的查找技术。我们之前的查找,都是这样一种思路:集合中拿出来一个元素,看看是否与我们要找的相等,如果不等,缩小范围,继续查找。而哈希表是完全另外一种思路:当我
知道key值以后,我就可以直接计算出这个元素在集合中的位置,根本不需要一次又一次的查找!
举一个例子,假如我的数组A中,第i个元素里面装的key就是i,那么数字3肯定是在第3个位置,数字10肯定是在第10个位置。哈希表就是利用利用这种基本的思想,建立一个从key到位置的函数,然后进行直接计算查找。
- Hash表在海量数据处理中有着广泛应用。
Hash Table的查询速度非常的快,几乎是O(1)的时间复杂度
。
hash就是找到一种数据内容和数据存放地址之间的映射关系。
散列法: 元素特征转变为数组下标的方法。
我想大家都在想一个很严重的问题:“如果两个不同字符串在哈希表中对应的位置相同怎么办?
”,毕竟一个数组容量是有限的,这种可能性很大。解决该问题的方法很多,我首先想到的就是用“链表”
。我遇到的很多算法都可以转化成链表来解决,只要在哈希表的每个入口挂一个链表,保存所有对应的字符串就OK了。
3.3.1.5 散列表的查找步骤 :
- 当存储记录时,通过
散列函数
计算出记录的散列地址。
- 当查找记录时,我们通过同样的是
散列函数
计算记录的散列地址
,并按此散列地址访问该记录。
关键字——散列函数(哈希函数)——散列地址
优点: 一对一的查找效率很高。
缺点:一个关键字可能对应多个散列地址
;需要查找一个范围时,效果不好。
散列冲突: 不同的关键字(key)经过散列函数的计算得到了相同的散列地址。
好的散列函数=计算简单+分布均匀(计算得到的散列地址分布均匀)
3.3.1.6 三种比较常用的散列法:
元素特征转变为数组下标的方法就是散列法。
- 除法散列法 (比较常用):
最直观的一种,上图使用的就是这种散列法,公式:
hash = hashcode % (位桶数组长度-1)
PS:位桶数组长度必须是2的整次幂
,计算得到的散列地址分布均匀, hash表示对应在位桶数组中的索引
。
学过汇编的都知道,求模数其实是通过一个除法运算得到的
,所以叫“除法散列法”。
- 平方散列法:
求index是非常频繁的操作,而乘法的运算要比除法来得省时(对现在的CPU来说,估计我们感觉不出来),所以我们考虑把除法换成乘法和一个位移操作
。公式:
hash = (value * value) >> 28 (右移,除以2^28。记法:左移变大,是乘。右移变小,是除。)
如果数值分配比较均匀的话这种方法能得到不错的结果,但我上面画的那个图的各个元素的值算出来的index都是0——非常失败。也许你还有个问题,value如果很大,value * value不会溢出吗?答案是会的,但我们这个乘法不关心溢出,因为我们根本不是为了获取相乘结果,而是为了获取index。
- 斐波那契(Fibonacci)散列法:
平方散列法的缺点是显而易见的,所以我们能不能找出一个理想的乘数,而不是拿value本身当作乘数呢?答案是肯定的。
1、对于16位整数而言,这个乘数是40503
2、对于32位整数而言,这个乘数是2654435769
3、对于64位整数而言,这个乘数是11400714819323198485
这几个“理想乘数”是如何得出来的呢?这跟一个法则有关,叫黄金分割法则
,而描述黄金分割法则的最经典表达式无疑就是著名的斐波那契数列
,即如此形式的序列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233,377, 610, 987, 1597, 2584, 4181, 6765, 10946,…。另外,斐波那契数列的值和太阳系八大行星的轨道半径的比例出奇吻合。
对我们常见的32位整数而言,公式:
hash = (value * 2654435769) >> 28
如果用这种斐波那契散列法
的话,那上面的图就变成这样了:
注:用斐波那契散列法
调整之后会比原来的取摸散列法
好很多。
优点:
- 不论哈希表中有多少数据,
查找
、插入
、删除(有时包括删除)
只需要接近常量的时间即O(1)
的时间级。实际上,这只需要几条机器指令。 - 哈希表运算得非常快,在计算机程序中,如果需要在一秒种内查找上千条记录通常使用哈希表(例如拼写检查器)
哈希表的速度明显比树快
,树的操作通常需要O(N)
的时间级。哈希表不仅速度快,编程实现也相对容易。 - 如果不需要有序遍历数据,并且可以提前预测数据量的大小。那么哈希表在速度和易用性方面是无与伦比的。
缺点:
- 它是
基于数组
的,数组创建后难于扩展,某些哈希表被基本填满时,性能下降得非常严重,所以程序员必须要清楚表中将要存储多少数据
(或者准备好定期地把数据转移到更大的哈希表中,这是个费时的过程)。
适用范围:
快速查找,删除的基本数据结构,通常需要总数据量可以放入内存。
基本原理及要点:
hash函数选择,针对字符串,整数,排列,具体相应的hash方法。
碰撞处理,一种是open hashing
,也称为拉链法
;另一种就是closed hashing
,也称开地址法
,opened addressing
。
3.3.1.7 散列冲突的解决方案:
- 建立一个
缓冲区
,把凡是拼音重复的人放到缓冲区中
。当我通过名字查找人时,发现找的不对,就在缓冲区里找。 - 进行再
探测
。就是在其他地方查找。探测的方法也可以有很多种。
(1)在找到查找位置的index的index-1
,index+1
位置查找,index-2
,index+2
查找,依次类推。这种方法称为线性再探测
。
(2)在查找位置index周围随机的查找
。称为随机在探测
。
(3)再哈希
。就是当冲突时,采用另外一种映射方式来查找。
总结:
- 这个程序中是通过
取模
来模拟查找到重复元素的过程。对待重复元素的方法就是再哈希
:对当前key的位置+7。最后,可以通过全局变量来判断需要查找多少次。 - 我这里通过依次查找26个英文字母的小写计算的出了总的查找次数。显然,当总的查找次数/查找的总元素数越接近1时,哈希表更接近于一一映射的函数,查找的效率更高。
3.3.2 哈希表的实现:
模拟HashMap
底层通过哈希表
进行实现,先要更深入了解下HashMap
底层是如何实现的小伙伴请参考这篇博客【史上最全的集合框架讲解 ----- Java 集合框架(3)---- Map 相关类最全解析】
Entry存储过程结构图:
Entry[]:位桶数组,长度必须为2的整次幂
。
一个Entry对象包括:
- hash:哈希值,存放到位桶数组的索引, 计算
key的hascode码&位桶数组长度-1。
- key:键名。
- value:键映射的值。
- next:下一个结点Entry对象。
3.3.2.1 插入:
分析:
- 插入:
(1)向哈希表中插入键值对元素的原理:当向集合中插入键值对元素时,首先会计算根据插入的key的hashCode
来进行计算出hash值,计算规则:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
(2)然后根据该值来作为索引去确定存放到位桶数组的位置,此时,要进行判断该索引处是否已久存在键值对元素(也就是是否发生了hash碰撞)
:
- 如果不存在,则直接插入到该位置上。
- 如果存在,则再进行判断新插入的键值对元素的key和当前位置上链表中已经存在的键值对元素的key是否等价,(
等价的条件是: 不同的引用变量指向同一个对象或不同的引用变量指向的对象的equals()方法返回true)。
怎么进行比较呢?就是将新插入的键值对元素的key与该位置上的链表中的键值对元素的key逐一进行equals方法比较
:
- 如果该equals方法都
返回false
,那么说明该位置上的链表中不存在该键值对元素
,将新添加的键值对元素追加到链表末尾。
- 如果有一个equals方法
返回true
,那么认为该位置上的链表已经存在该键值对元素了
,则将原来的键值对元素的value替换成新插入的键值对元素的value。
PS:在哈希表中
判断两个键值对元素的key是否重复要
:使用到hashCode()
和equals()
。hashCode决定键值对元素在位桶数组的存储位置,而equals判断该位置上链表中是否已经存在该键值对元素。
- 代码:
//添加结点
private boolean putVal(int hash, K key, V value) {
/*[2]判断该位置是否已经有Node了(即该索引上的链表不为空),如果有:则根据equals()判断是否存在key重复的情况,
否则直接加到链表中,如果key存在重复:则覆盖key映射的旧value,否则追加到链表的末尾
*/
Node<K, V> currNode = tables[hash];
//[3]新建一个结点
Node<K, V> newNode=new Node<>(hash,key,value,null);
//定义key是否重复的标志位
boolean isKeyRepeated=false;
//正在遍历的最后一个结点
Node<K, V> lastNode=null;
if(currNode==null){
//[2.1]将新Node加到链表中
tables[hash]=newNode;
//数组的容量-1
residueCap--;
System.out.println("位桶数组剩余容量"+residueCap);
}else{
//[2.2]判断是否存在key重复的情况,需要逐一遍历链表
while(currNode!=null){
//[2.3]如果相同,则覆盖key映射的旧value
if(currNode.key.equals(key)){
currNode.value=value;
isKeyRepeated=true;
return isKeyRepeated;
}
//[2.4]记录正在遍历的最后一个结点
lastNode=currNode;
//[2.5]将指针移向下一个Node
currNode=currNode.next;
}
//如果不存在key重复的情况,则将新节点追加到链表末尾
if(!isKeyRepeated){
//[4]将添加新结点之前的链表最后一个结点的next指向新结点
lastNode.next=newNode;
}
}
//[5]结点个数+1
size++;
return isKeyRepeated;
}
-
插入步骤:
(1)判断是否需要扩容?
(2)根据哈希函数计算出来的hash值,判断位桶数组存储位置是否有结点存在?
- 如果没有,则进行直接插入即可。
- 如果有,与当前存储位置的链表的结点使用
equals(key)
比较,相同则替换
,都不同
,追加到链表末尾
即可。
(3)结点元素+1。
3.3.2.2 删除:
分析:
-
删除: 根据要删除的键值对元素的
key的hash值和key
来确定要删除的键值对元素,因为这个条件可以唯一确定一个键值对元素。
-
代码:
/**
* 删除结点的情况--要考虑释放删除节点的空间
* 1.是第一个结点,且该索引处链表只存在一个结点
* 操作:[1]将位桶数组该索引处的值置为null
*
* 2.是第一个结点,且该索引处链表存在多个结点
* 操作:[1]将当前结点的后驱结点变成链表的第一个结点,即tables[i]=后驱结点
* [2]将当前结点置为null
*
* 3.是中间的结点
* 操作:[1]将当前结点的前驱结点的next指向当前结点的后驱结点
* [2]将当前结点置为null
*
* 4.是最后一个结点
* 操作:[1]将当前结点的前驱结点的next置为null
* [2]将当前结点置为null
*/
private void removeNode(Node<K, V> node) {
//索引处链表的第一个结点
Node<K, V> firstNode = tables[node.hash];
//[2]获取当前结点的后继结点,以便判断删除的结点位置
Node<K, V> nextNode = node.next;
//[3]判断删除节点的位置
//[3.1]删除的是第一个结点,且该索引处的链表只存在一个结点,或是最后一个结点
if(nextNode==null){
//[3.1.1]删除的是第一个结点,且该索引处的链表只存在一个结点
if(firstNode==node){
//[3.1.1.1]将位桶数组该索引处的值置为null
tables[node.hash]=null;
}
//[3.1.2]删除的是最后一个结点
else{
//[3.1.2.1]将当前结点的前驱结点的next置为null
getBeforeNode(node).next=nextNode;
//[3.1.2.2]将当前结点置为null
node=null;
}
}
//[3.2]删除是第一个结点,且该索引处链表存在多个结点,或是中间的结点
else{
//[3.2.1]删除的是第一个结点,且该索引处链表存在多个结点
if(firstNode==node){
//[3.2.1.1]将当前结点的后驱结点变成链表的第一个结点,即tables[i]=后驱结点
tables[node.hash]=getAfterNode(node);
//[3.2.1.2]将当前结点置为null
node=null;
}
//[3.2.2]删除的是最后一个结点
else{
//[3.2.2.1]将当前结点的前驱结点的next指向当前结点的后驱结点
getBeforeNode(node).next=getAfterNode(node);
//[3.2.2.2]将当前结点置为null
node=null;
}
}
//[5]结点个数—1
size--;
}
-
删除步骤:
(1)找出currNode
,根据hash值和key进行寻找。
(2)找出prevNode
和nextNode
,以便判断删除的结点位置。
(3)判断删除的结点位置:-
是
第一个结点
,且该索引处链表只存在一个结点
:[1]将位桶数组
该索引处的值置为null。
-
是
第一个结点
,且该索引处链表存在多个结点
:[1]将
nextNode
变成链表的第一个结点,即tables[i]=nextNode。
[2]将
currNode置为null。
-
是
中间的结点
:[1]将
prevNode
的next
指向nextNode。
[2]将
currNode置为null。
-
是
最后一个结点:
[1]将
prevNode
的next
置为null。
[2]将
currNode 置为null。
-
(4)结点个数-1。
PS:删除的结点:currNode ; 删除结点的前驱结点:prevNode ;删除结点的后继结点:nextNode。
注意一点: 需要将删除结点占用的空间释放掉,也就是将对象置为null,防止内存泄漏。
3.3.2.3 修改:
分析:
- 第一种方式: 相当于先删除对应索引的旧结点,再往对应索引处插入新结点即可。
(效率较慢)
。
//替换步骤:相当于删除i节点,再往i插入新的节点
E data=remove(i);
add(i,e);
- 第二种方式:
/**
* 删除key映射的结点
*/
@Override
public V remove(K key) {
//[1]根据key的hash值和key获取结点
Node<K, V> node = getNode(myHash(key),key);
//[2]存在该键,则返回key映射的value
if(node!=null){
//[3]移除该结点
removeNode(node);
return node.value;
}
//[4]不存在,返回null
return null;
}
3.3.2.4 获取:
1)调用entrySet()
获取键值对集合Set<Entry<K,V>>
:
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
2)调用keySet()
获取key集合Set<K>
:
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
return ks;
}
3)调用values()
获取值集合Collection<V>
:
public Collection<V> values() {
Collection<V> vs = values;
if (vs == null) {
vs = new Values();
values = vs;
}
return vs;
}
3.3.2.5 拓展内容:
(1)扩容操作:
什么时候扩容?当
位桶数组剩余容量==0或插入的hash值>位桶数组长度-1。
- 代码:
//扩容步骤:
private void resize(){
//[1]重新定义一个按照规律扩容后的位桶数组,且长度必须是2的整数幂,<<1:表示扩大一倍
Node<K, V>[] newTables=new Node[tables.length<<1];
System.out.println("扩容后的数组长度为"+newTables.length);
//[2]将旧位桶数组的值拷贝到新的位桶数组中
Node<K, V> oldNode=null;
Node<K, V> newNode=null;
for(int i=0;i<=tables.length-1;i++){
//[2.1]获取旧位桶数组对应索引处的链表
oldNode= tables[i];
//[2.2]将索引处的链表的第一个结点赋给新位桶数组
newTables[i]=oldNode;
//[2.3]对应索引处的链表正在遍历的下一个结点
newNode=newTables[i];
//[2.4]判断旧位桶数组对应索引处的链表是否还有下一个
while(oldNode!=null){
//[2.5]将旧位桶数组对应索引处的链表指针移向下一个Node
oldNode=oldNode.next;
/*[2.6]将新位桶数组对应索引处的链表的上一个结点的next指向旧位桶数组对应索引处的链表的下一个结点,
从而形成链表
*/
newNode.next=oldNode;
//将新位桶数组对应索引处的链表指针移向下一个Node
newNode=oldNode;
}
}
//[3]将指针指向新位桶数组
tables=newTables;
}
- 扩容步骤:
(1)重新定义一个按照一定规律扩容后的数组。
(2)将旧数组的数据拷贝到新的数组中
。
(3)将指针指向新的数组。
(2)带有泛型的Entry类
(作为实现类的一个静态内部类
):
/**
* 结点:一个结点(增加泛型)包括四个部分
* 1.hash值=key的hashcode码%位桶数组长度-1
* 2.key
* 3.value
* 4.下一个结点指针域 nxet
* @author HKM
*
*/
private static class Node<K,V> {
//key
K key;
//value
V value;
//hash值
int hash;
//下一个结点指针域
Node<K,V> next;
public Node( int hash,K key, V value, Node<K, V> next) {
super();
this.key = key;
this.value = value;
this.hash = hash;
this.next = next;
}
public Node() {
super();
}
public Node(int hash,K key, V value) {
super();
this.key = key;
this.value = value;
this.hash = hash;
}
}
(3)getNode(int hash,K key)
:获取当前节点对象
- 代码:
//根据hash值和key返回Node结点
private Node<K, V> getNode(int hash,K key){
//[1]通过key的hash值来获取到对应位置链表的第一个结点
Node<K, V> currNode=tables[hash];
//[2]根据equlas()比较key
if(currNode!=null){
//是否是对应索引处的链表的最后一个结点
while(currNode!=null){
//[3]有该key,返回对应的Node结点
if(currNode.key.equals(key)){
return currNode;
}
//[3]将指针移向下一个Node
currNode=currNode.next;
}
}
//[3]没有该key,返回null
return null;
}
- 获取步骤:
[1] 找通过key的hash值定到位桶数组的存储位置
,如果该位置的链表为空
,直接返回null
即可。
[2] 再使用equals(key)
与该位置链表的结点逐一进行比较。
[3] 若为true
,返回该结点
,否则返回null
。
(4)myHash(K key)
:获取hash值
/**
* 获取hash值
* @param key 键名
* @return
*/
public int myHash(K key){
//计算hash规则:hashCode值%位桶数组长度-1
return (key.hashCode())%(tables.length-1);
}
(5)getBeforeNode(Node<K, V> node)
;获取指定结点的前一个结点
/* 获取当前结点的上一个结点
* 如果是索引处链表第一个结点,则返回null
*/
private Node<K, V> getBeforeNode(Node<K, V> node){
//索引处链表当前指向的结点
Node<K, V> currNode = tables[node.hash];
//存在链表
while(currNode!=null){
//判断当前结点的next是否指向指定的结点,存在即返回上一个结点,否则返回null
if(currNode.next==node){
return currNode;
}
currNode=currNode.next;
}
//如果是索引处链表第一个结点,则返回null
return null;
}
(6)getAfterNode(Node<K, V> node)
;获取指定结点的后一个结点
/*
* 获取当前结点的下一个结点
* 如果是索引处链表最一个结点,则返回null
*/
private Node<K, V> getAfterNode(Node<K, V> node){
//索引处链表当前指向的结点
Node<K, V> currNode = tables[node.hash];
//存在链表
while(currNode!=null&&node!=null){
//判断当前结点的next是否指向指定的结点,存在即返回上一个结点,否则返回null
if(node.next==currNode){
return currNode;
}
currNode=currNode.next;
}
//如果是索引处链表最后一个结点,则返回null
return null;
}
四、大总结:
链表结构和顺序存储结构的对比:
顺序表和链表的比较:
- 顺序表可以
顺序存取
,也支持随机存取
;链表只能顺序存取
。 - 顺序表
逻辑上相邻的物理上也相邻
;而链表不一定,它是用指针来描述元素之间的关系。
- 顺序表
插入和删除要移动大量元素
;链表只需修改指针即可。
随机存取
就是直接存取,可以通过下标直接访问的那种数据结构
,与存储位置无关,例如数组。非随机存取
就是顺序存取了,不能通过下标访问了
,只能按照存储顺序存取,与存储位置有关,例如链表。
- 顺序存取就是存取第N个数据时,必须先访问前(N-1)个数据 (list),随机存取就是存取第N个数据时,不需要访问前(N-1)个数据,直接就可以对第N个数据操作 (array)。
线性结构都是有索引的
,因为链表
存储结构分配的空间是不连续的
,底层不是用数组
存储的,索引不能通过索引进行查找元素
,只能通过头结点依次寻找
。而顺序表就可以。
栈:
一、栈的定义:
栈(stack )又称堆栈
,它是运算受限的线性表。
其限制是仅允许在表的一端进行插入和删除操作
,不允许在其他任何位置进行插入、查找、删除等操作。
表中进行插入、删除操作的一端称为 栈顶(top)
,栈顶保存的元素称为栈顶
元素。 相对的,表的另一端称为栈底(bottom)
。
当栈中没有数据
元素时称为空栈
;
向一个栈插入元素
又称为进栈
或 入栈
;
从一个栈中删除元素
又称为 出栈
或 退栈
;
由于栈的插入
和删除
操作仅在栈顶
进行,后进栈
的元素必定先出栈。
所以又把堆栈
称为 后进先出表(Last In First Out,简称 LIFO)
。
生活案例: 摞盘子和取盘子、一摞书、酒杯塔(各层之间可以简单理解为栈,每层内部不是栈) 。
技术案例: Java的栈内存和堆内存详解:【(2020史上最全总结,跳槽必看),一篇带你立马搞定jvm内存,类加载机制全过程,java内存模型,分代垃圾回收机制,垃圾回收算法和垃圾收集器】
应用举例: 请参考:【数据结构与算法】 栈——栈的应用举例】
二、栈的存储结构:
和线性表类似,堆栈也有两种基本的存储结构:顺序存储
结构和链式存储
结构。
2.1 顺序栈 :
顺序栈是使用顺序存储
结构实现的堆栈
,即利用一组地址连续的存储单元
依次存放堆栈中的数据元素。
由于堆栈是一种特殊的线性表,因此在线性表的顺序存储结构的基础上,选择线性表的一端作为栈顶
即可。
根据数组操作的特性,选择数组下标大的一端
,即线性表顺序存储的表尾来作为栈顶
,此时入栈、出栈等操作可以在Ο(1)
时间完成。
由于堆栈
的操作都在栈顶
完成,因此在顺序栈
的实现中需要附设一个指针 top
来动态的指示栈顶元素在数组中的位置。
通常 top 可以用栈顶元素所在数组下标
来表示,top= -1 时表示空栈
。
2.2 链栈 :
链栈即采用链表
作为存储结构实现的栈。
当采用单链表存储线性表后,根据单链表的操作特性选择单链表的头部作为栈顶
,此时,入栈
、出栈
等操作可以在Ο(1)
内完成。
由于堆栈的操作只在线性表的一端进行
,在这里使用带头结点的单链表
或不带头结点的单链表
都可以。
使用带头结点
的单链表时,结点的插入
和删除
都在头结点之后
进行。
使用不带头结点
的单链表时,结点的插入
和删除
都在链表的首结点上
进行。
三、 拓展知识:
双向栈共享存储空间示意图:
栈接口,定义了栈的主要操作 :
记住针对栈的专业词汇:push
、pop
、peek
public interface Stack {
// 返回堆栈的大小
public int getSize();
// 判断堆栈是否为空
public boolean isEmpty();
// 数据元素 e 入栈
public void push(Object e);
// 栈顶元素出栈
public Object pop();
// 取栈顶元素
public Object peek();
}
队列:
一、队列定义:
队列(queue )简称队,它同堆栈
一样,也是一种运算受限的线性表
, 其限制是仅允许在表的一端进行插入
,而在表的另一端进行删除。
在队列中把插入数据元素的一端
称为 队尾(rear)
),删除数据元素
的一端称为 队首(front)
)。
向队尾插入元素
称为 进队
或入队
,新元素入队后成为新的队尾元素
; 从队列中删除元素
称为离队
或出队
,元素出队后,其后续元素成为新的队首元素。
由于队列的插入和删除操作分别在队尾和队首进行,每个元素必然按照进入的次序离队, 也就是说先进队的元素必然先离队,所以称队列为 先进先出表(First In First Out,简称FIFO)
。
生活案例: 排队打饭,排队进地铁站,上地铁 。
技术案例: 多线程中就绪队列和阻塞队列。
二、队列的存储结构:
2.1 顺序队列 :
方法1:使用普通数组作为存储结构:
缺点:
通过出队操作将数据弹出队列后,front之前的空间还能够再次得到吗?
不能。所以使用普通数组
实现队列,就再也不能使用front之前的空间了,这会导致大量空间丢失 。
方法2:使用循环数组作为存储结构:
为了解决这个问题,将普通数组换成循环数组
。在循环数组
中,末尾元素的下一个元素不是数组外,而是数组的头元素。
这样就能够再次使用front之前的存储空间
了 (相当于解决了假溢出
现象)。
2.2 链式队列 :
队列的链式存储
可以使用单链表
来实现。
为了操作实现方便,这里采用带头结点
的单链表结构。
根据单链表的特点,选择链表的头部作为队首
,链表的尾部作为队尾
。
除了链表头结点
需要通过一个引用
来指向之外,还需要一个对链表尾结点的引用
,以方便队列的入队
操作的实现。
为此一共设置两个指针
,一个队首指针
和一个队尾指针
,队首指针指向队首元素的前一个结点
,即始终指向链表空的头结点(不存放任何的数据元素,next指向首元素即可
),队尾指针指向队列当前队尾元素所在的结点。
当队列为空
时,队首指针与队尾指针均指向空的头结点 。
如图所示:
2.3 双端队列deque (double ended queue) 通常读为"deck" :
(1)所谓双端队列是指两端都可以进行进队和出队操作的队列
,如下图所示,将队列的两端分别称为前端
和后端
,两端都可以入队和出队。
其元素的逻辑结构仍是线性结构 。
在双端队列进队时:
前端进的元素排列在队列中后端进的元素的前面
,后端进的元素排列在队列中前端进的元素的后面
。在双端队列出队时,无论前端出还是后端出,先出的元素排列在后出的元素的前面。
(2)输出受限的双端队列,即一个端点允许插入和删除
,另一个端点只允许插入的双端队列。
(3)输入受限的双端队列,即一个端点允许插入和删除
,另一个端点只允许删除的双端队列。
PS:
双端队列
既可以用来队列
操作,也可以用来实现栈
操作(只操作一端就是栈了
) 。
三、java中的栈和队列:
Stack
类:栈类 ,过时
public class Stack<E> extends Vector<E>
Queue
:队列类
// 扩展了java.util.Collection接口
public interface Queue<E> extends Collection<E>
Deque
:双端队列(栈操作建议使用)
//继承于Queue类
public interface Deque<E> extends Queue<E>
特别注意: Queue使用时要尽量避免Collection的
add()
和remove()
方法,而是要使用offer()
来加入元素,使用poll()
来获取并移出元素。它们的优点是通过返回值可以判断成功与否
。add()
和remove()
方法在失败的时候会抛出异常
。 如果要使用前端而不移出该元素,使用element()
或者peek()
方法。
所以Java中实现栈
和队列
操作都可以通过使用LinkedList
类实现,当然底层使用的链表
,而ArrayDeque
是Deque
接口的大小可变数组
的实现 。
public class ArrayDeque<E> extends AbstractCollection<E> implements Deque<E>,
Cloneable, Serializabl
模拟生活中罗盘子案例 :
/**
* 功能:模拟生活中罗盘子案例
* 技能:LinkedList
*
* LinkedList既可以当做线性表处理,也可以当做栈、队列使用
* @author Administrator*
*/
public class TestDeque {
public static void main(String[] args) {
//创建一个栈
Deque deque = new LinkedList();
//罗盘子:入栈
// deque.addFirst("盘子1");
// deque.addFirst("盘子2");
// deque.addFirst("盘子3");
deque.push("盘子1");
deque.push("盘子2");
deque.push("盘子3");
//获取最上面的盘子:获取栈顶元素
// System.out.println(deque.getFirst());
// System.out.println(deque.getFirst());
// System.out.println(deque.getFirst());
System.out.println(deque.peek());
System.out.println(deque.peek());
System.out.println(deque.peek());
//拿走盘子:出栈
// System.out.println(deque.removeFirst());
// System.out.println(deque.removeFirst());
// System.out.println(deque.removeFirst());
System.out.println(deque.pop());
System.out.println(deque.pop());
System.out.println(deque.pop());
}
}
模拟生活中超市购物排队结算:
/**
* 功能:模拟生活中超市购物排队结算
* 技能:使用LinkedList实现队列的操作
*
* @author Administrator
*
*/
public class TestQueue {
public static void main(String[] args) {
//创建一个队列
java.util.Queue queue = new LinkedList();
//入队
queue.offer("张三");
queue.offer("李四");
queue.offer("王五");
//获取队头元素
System.out.println(queue.element());
System.out.println(queue.element());
System.out.println(queue.element());
//出队
System.out.println(queue.remove());
System.out.println(queue.poll());
queue.offer("赵六");
System.out.println(queue.poll());
System.out.println(queue.poll());
System.out.println(queue.poll());
System.out.println(queue.poll());
System.out.println(queue.poll());
}
}
借助栈实现进制转换(10----2):
/**
* 借助栈实现进制转换(10----2)
* @author Administrator
*
*/
public class TestConversion {
public static void main(String[] args) {
int n = 13;
int t = n;
//String str = "";
Deque<Integer> deque = new LinkedList<Integer>();
while(t>0){
//除以2得到余数作为二进制位
int mod = t%2;
//System.out.print(mod);
//str = mod + str;
deque.push(mod);
//除以2得到商作为被除数继续
int result = t/2;
t = result;
}
System.out.print(n+"--------->");
while(!deque.isEmpty()){
System.out.print(deque.pop());
}
}
}
好了,这篇文章我们大概了解到了线性结构
相关的概念,下篇文章我们将继续探讨 非线性结构
相关的知识。
如果博客中有什么不正确的地方,还请多多指点。如果这篇文章对您有帮助,请不要吝啬您的赞,欢迎继续关注本专栏。
谢谢观看。。。
友情提示:本博主纯手写的数据结构的源码我已上传到我的github:https://github.com/hkmhso/DataStructure.git