第二章 数据结构基础
1.什么是数组
数组对应的英文是array,是有限个相同类型的变量组成的有序集合,数组中每个变量被称为元素。数组是最简单、最为常用的数据结构。
2.什么是链表
链表是一种在物理上非连续、非顺序的数据结构,由若干个节点(node)组成
1.单向链表
单向链表的每一个节点包含两个部分,一部分是存放数据的变量data,另一部分是指向下一个节点的指针next。
链表的第一个节点被称之为头节点,最后一个节点被称之为尾节点,尾结点的next指针指向空。
与数据按照下标来随机寻找元素不同,对于链表的其中一个节点A,我们只能根据节点A的next指针找到该节点的下个节点B,再根据B的next指针找到下个节点C…
2.双向链表
双向链表比单向链表稍微复杂一些,它的每个节点上除了data和next指针还有一个指向前一个节点的prev指针
3.随机储存
如果说数组是在内存中的储存方式是顺序储存,那么链表在内存中的储存方式则是随机储存。
数组在内存中占用了连续完整的存储空间。而链表采用了见缝插针的方式,链表的每个节点分布在内存的不同位置,依靠next指针关联起来。这样可以灵活有效地利用零散的碎片空间。
4.链表的操作
如下代码
/**
* @author: fancg
* @create: 2020-08-20
*
* 链表的查询、插入、删除、遍历
*/
public class MyLinkedNodeList {
private Node head;
private Node last;
private int size;
private static class Node{
int data;
Node next;
Node(int data) {
this.data = data;
}
}
/**
* @Description: 查询链表位置上的值
* @Author: fancg
* @Date: 2020/8/20
**/
public Node get(int index) {
if (index < 0 || index > size) {
throw new RuntimeException("链表长度有问题");
}
Node node = head;
for (int i = 0; i<index; i++) {
node = node.next;
}
return node;
}
public void outPut() {
Node node = head;
while (node != null) {
System.out.println(node.data);
node = node.next;
}
}
/**
* @Description: 链表插入
* @Author: fancg
* @Date: 2020/8/20
**/
public void insert(int data, int index) {
if (index < 0 || index > size) {
throw new RuntimeException("链表长度有问题");
}
Node node = new Node(data);
if (size == 0) {
//空链表
head = node;
last = node;
} else if (index == 0) {
//头部插入
node.next = head;
head = node;
} else if (index == size) {
//尾部插入
last.next = node;
last = node;
} else {
//中间插入
Node node1 = get(index - 1);
node.next = node1.next;
node1.next = node;
}
size++;
}
public void remove(int index) {
if (index < 0 || index > size) {
throw new RuntimeException("链表长度有问题");
}
if (index == 0) {
//删除头部
Node next = head.next;
head = next;
} else if (index == size - 1) {
//删除尾部
Node node = get(index - 1);
node.next = null;
last = node;
} else {
Node node = get(index - 1);
Node next = node.next.next;
node.next = next;
}
}
public static void main(String[] args) {
MyLinkedNodeList myLinkedNodeList = new MyLinkedNodeList();
myLinkedNodeList.insert(10,0);
myLinkedNodeList.insert(11,1);
myLinkedNodeList.insert(12,2);
myLinkedNodeList.insert(13,3);
myLinkedNodeList.insert(14,1);
myLinkedNodeList.remove(1);
myLinkedNodeList.outPut();
}
}
3.什么是栈
要弄明白什么是栈,我们需要先举一个生活中的例子。
假如有一个又细又长的圆筒,圆筒一端封闭,另一端开口。往圆筒里放入乒乓 球,先放入的靠近圆筒底部,后放入的靠近圆筒入口。
假如有一个又细又长的圆筒,圆筒一端封闭,另一端开口。往圆筒里放入乒乓 球,先放入的靠近圆筒底部,后放入的靠近圆筒入口。
栈(stack)是一种线性数据结构,它就像上图所示的放入乒乓球的圆筒容器,栈中的元素只能先进后出。最早的元素存放位置叫做栈底,最后进入的元素存放的位置叫做栈底。
- 入栈
入栈操作(push)就是把新元素放入栈汇总,只允许从栈顶一侧放入元素,新元素的位置将会成为新的栈顶。 - 出栈
出栈操作(pop)就是把元素从栈中取出,只能是栈顶的元素才允许出栈,出栈的前一个元素将会成功新的栈顶。
4.什么是队列
要弄明白什么是队列,我们同样可以用一个生活中的例子来说明。
假如公路上有一条单行隧道,所有通过隧道的车辆只允许从隧道入口驶入,从 隧道出口驶出,不允许逆行。
因此,要想让车辆驶出隧道,只能按照它们驶入隧道的顺序,先驶入的车辆先驶出,后驶入的车辆后驶出,任何车辆都无法跳过它前面的车辆提前驶出。
队列是一种线性数据结构,它的特征和行驶车辆的单行隧道很相似。不同于栈的先入后出,队列中元素只能先进后出。队列的出口端叫做队头,队列的入口叫做队尾。
- 循环队列
假设一个队列经过反复的入队和出队操作,还剩下2个元素,在物理上分布于数组的末尾位置。这时又新的元素要入队。
在数组不做扩容的前提下,如何让新元素入队并确定新的队尾位置呢?我们可以利用已出队元素留下的空间,让队尾指针重新指回数组的首位。
这样一来,整个队列的元素就循环起来了。在物理储存上,队尾的位置也可以在队头之前。当再有元素入队时,将其放入数组的首位,队尾指针继续后移即可。
一直到 (队尾下标+1)%数组长度 = 队头下标 时,代表此队列真的已经满了。需要注意的是,队尾指针指向的位置永远空出1位,所以队列最大容量比数组长度小1.
队列的操作如下代码:
/**
* @author: fancg
* @create: 2020-08-21
* 队列 入队、出队 遍历
*/
public class Queue {
private int[] array;
private int front;
private int rear;
public Queue(int capacity) {
array = new int[capacity];
}
/**
* @Description: 入队操作
* @Author: fancg
* @Date: 2020/8/21
**/
private void enQueue(int num) {
int i1 = rear + 1;
int i2 = array.length;
int i = i1 % i2;
if (i == front) {
throw new RuntimeException("队列已满");
}
array[rear] = num;
rear = i;
}
/**
* @Description: 出队操作
* @Author: fancg
* @Date: 2020/8/21
**/
public int deQueue(){
if (rear == front) {
throw new RuntimeException("队列已空");
}
int num = array[front];
int i = (front + 1) % array.length;
front = i;
return num;
}
public void outPut() {
for (int i = front; i != rear; i = (i+1)%array.length) {
System.out.println(array[i]);
}
}
public static void main(String[] args) {
Queue queue = new Queue(6);
queue.enQueue(1);
queue.enQueue(5);
queue.enQueue(6);
queue.enQueue(3);
queue.deQueue();
queue.deQueue();
queue.deQueue();
queue.enQueue(10);
queue.enQueue(13);
queue.enQueue(32);
queue.outPut();
}
}
- 双端队列
双端队列这种数据结构,可以说是综合了和队列的有点,对双端队列来说从队头一端可以入队和出队,从队尾一端也可以入队和出队。
- 优先队列
优先队列它遵循的不是先入先出,而是谁的优先级最高,谁先出队。
优先队列已经不属于线性数据结构的范畴了,它是基于二叉树来实现的。
5.什么是散列表
散列表也叫做哈希表(hash table),这种数据结构提供了键(key)和值(value)的映射关系。只要给出一个key,就可以高效查找到它所匹配的value,时间复杂度接近于 O ( 1 ) O(1) O(1)
1. 哈希函数
哈希函数其实就是一个“中转站”,通过某种方式,把key和数组下标进行转换。这个中转站就叫做哈希函数。
常见的哈希函数有一下几个:
- 直接定制法:直接以关键字k或者k加上某个常数(k+c)作为哈希函数。
特点:以关键字k的某个线性数值为哈希地址,实现方法简单,算法复杂度较小,不会产生冲突。但是会占用连续的地址空间,空间效率低,适用面比较窄 - 数字分析法:提取关键字中取值比较均匀的数字作为哈希地址
特点:适用于事先明确知道表中所有关键字每一位数值的分布情况。它完全依赖于关键字集合,如果换一个关键字集合,选择哪几位作为哈希地址要重新确定 - 除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址。
特点:除留余数法有效缩减散列地址空间的大小,是最常用的方法。 - 分段叠加法:按照哈希表地址位数将关键字分成位数相等的几部分,其中最后一部分可以比较短。然后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址。
特点:适用于长关键字,当关键字位数较多时,且关键字每一位的数字上的分布较均匀时,可使用这种方法获得哈希地址。 - 平方取中法:如果关键字各个部分分布都不均匀的话,可以先求出它的平方值,然后按照需求取中间的几位作为哈希地址。
例子:2589的平方值为6702921,可以取中间的029作为哈希地址。
特点:对数字分析法的改进,哈希地址与关键字的每一位都相关。 - 伪随机数法:采用一个伪随机数当作哈希函数
选择一随机函数,取关键字的随机值作为散列地址,及H(k) = random(k),(random(k)为伪随机数方法),通常用于关键字长度不同的场合 - 乘余取整法:公式:H(k)=[b ×(a × k mod 1)] ,(a,b均为常数,且0 < a < 1,b为整数),(a × k mod 1)取的是 a × k 的小数部分。以关键码k乘以a,取其小数部分,然后再放大b倍再取整,作为哈希地址。
例如,当元素关键码为2020, 小数a为0.6180339,整数b为10000,则散列地址计算为H(2020)=[10000×(0.6180339×2020%1)]=4284。
特点:乘余取整法不但会缩减散列地址空间的大小,还能极大减小冲突情况的发生几率。Knuth(唐纳德·克努特,算法和程序设计的先驱)对常数a的取法做了仔细的研究,发现虽然a取任何值都可以,但一般取黄金分割数0.6180339比较好。
2.哈希冲突
在进行散列表插入操作时,由于数组的长度是有限的,当插入的数据越多,不同的key根据哈希函数获取的下标有可能是相同的,这种情况我们称之为哈希冲突。
哈希冲突是无法避免的,既然不能避免,我们就要解决。解决哈希冲突的方法有以下几种:
- 开放定址法:开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入
- 链表法:将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部
- 再哈希法:当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。
- 建立公共溢出区:将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。
3.HashMap的数据结构
在不同语音中,哈希函数的实现方式是不一样的。这里以Java的常用集合HashMap为例,来看下哈希函数在Java中的实现方式。
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:查询容易,插入和删除困难;而链表的特点是:查询困难,插入和删除容易。上面我们提到过,常用的哈希函数的冲突解决办法中有一种方法叫做链表法,其实就是将数组和链表组合在一起,发挥了两者的优势,我们可以将其理解为链表的数组。 需要注意的是,关于HashMap的实现,JDK 8和以前的版本有着很大的不同。当 多个Entry被Hash到同一个数组下标位置时,为了提升插入和查找的效率,HashMap 会把Entry的链表转化为红黑树这种数据结构。
我们可以从上图看到,左边很明显是个数组,数组的每个成员是一个链表。该数据结构所容纳的所有元素均包含一个指针,用于元素间的链接。我们根据元素的自身特征把元素分配到不同的链表中去,反过来我们也正是通过这些特征找到正确的链表,再从链表中找出正确的元素。其中,根据元素特征计算元素数组下标的方法就是哈希算法,即本文的主角hash()函数(当然,还包括indexOf()函数)。
在同一个版本的Jdk中,HashMap、HashTable以及ConcurrentHashMap里面的hash方法的实现是不同的。再不同的版本的JDK中(Java7 和 Java8)中也是有区别的
在上代码之前,我们先来做个简单分析。我们知道,hash方法的功能是根据Key来定位这个K-V在链表数组中的位置的。也就是hash方法的输入应该是个Object类型的Key,输出应该是个int类型的数组下标。如果让你设计这个方法,你会怎么做?
其实简单,我们只要调用Object对象的hashCode()方法,该方法会返回一个整数,然后用这个数对HashMap或者HashTable的容量进行取模就行了。没错,其实基本原理就是这个,只不过,在具体实现上,由两个方法int hash(Object k)和int indexFor(int h, int length)来实现。但是考虑到效率等问题,HashMap的实现会稍微复杂一点。
hash :该方法主要是将Object转换成一个整型。
indexFor :该方法主要是将hash生成的整型转换成链表数组中的下标。
HashMap in JDK1.7
//用了很多的异或,移位等运算,对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
//这里针对String优化了Hash函数,是否使用新的Hash函数和Hash因子有关
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
从上面的操作看以看出,影响HashMap元素的存储位置的只有key的值,与value值无关。
通过hash函数得到散列值后,再通过indexFor进一步处理来获取实际的存储位置,其实现如下:
//返回数组下标
static int indexFor(int h, int length) {
return h & (length-1);
}
详解看下面连接:
小结
- 什么是数组
数组是由有限个相同类型的变量所组成的有序集合,它的物理存储方式是顺序储存,访问方式是随机访问。利用下标查找数组元素的时间复杂度为O(1),中间插入、删除数组元素的时间复杂度是O(n)。 - 什么是链表
链表是一种链式数据结构,是由若干个节点组成,每个节点包含指向下个节点的指针。链表的物理存储方式是随机存储,访问方式是顺序访问。查找链表节点的时间复杂度是O(n),中间插入、删除节点的时间复杂度是O(1)。 - 什么是栈
栈是一种线性逻辑结构,可以用数组实现,也可以用链表实现。栈包含入栈和出栈操作,遵循先入后出的原则。 - 什么是队列
队列也是一种线性逻辑结构,可以用数组实现,也可以用链表实现。队列包含入队和出队操作,遵循先入先出的原则。 - 什么是散列表
散列表也叫哈希表,是存储key-value映射的集合。对于某一个key,散列表可以在接近O(1)的时间内进行读写操作。散列表通过哈希函数实现key和数组下标的转换,哈希冲突一般使用链表和开发寻址法解决。