数据结构与算法(Java实现)
我的学习资料:
视频:尚硅谷Java数据结构与java算法(Java数据结构与算法)
书籍:《大话数据结构》
笔记中包括学习的内容,代码,同时自己总结了知识点速记(部分会带页内跳转,可点击跳转)供快速回顾和记忆学到的知识点。
十(补)树快速复习
主要是二叉排序树:
- 二叉排序树BST的产生主要是为了高效的查找,时间复杂度O (logn),只要中序遍历就可以从小到大输出
- 散列表的查找是常量级O1的,但是加上散列冲突,不一定比BST优秀
- Redis为什么要用跳表来实现有序集合而不用红黑树
先说什么是跳表
我们在顺序表排序后,可以利用元素的有序性,通过二分查找来实现查找的时间复杂度是logn的操作
对于链表来说,即使元素是有序的,要想查找一个数据,也需要从头到尾去遍历,效率低是On
跳表就是在单链表的基础上,将一些结点往上提,构成索引,在索引值之上,再拿出一些索引,层层构建,这样链表+多个索引层,就是跳表,它的查找的时间复杂度是O(logn),空间复杂度是O(n)
跳表的插入:
根据索引来定位,然后插入,插入后如果不动索引,就会因为底层链表塞太多,跳表性能退化成链表,所以需要一个随机函数来确定一个K,插入这个值,算出K,在1-K层索引中加入这个值做索引。随机函数的选取要考虑索引大小和数据大小的平衡性,使得其性能不能过度退化。
跳表的删除:
定位后删除,如果索引中有它,也要删除索引中的。
跳表维护平衡性:随机函数
红黑树(以及AVL树)维护平衡性:左右旋
二叉排序树(二叉查找树)的定义
左子树仅包含小于当前结点的值
右子树仅包含大于当前结点的值
左右子树每个也必须是二叉查找树
优点:
中序遍历即可从小到大输出,理想状态下增删查时间复杂度O(logn),
缺点:
极端情况下倾斜,退化为链表,查的复杂度变为O(n)
因此出现了
二叉平衡树(AVL)
任何节点的左右子树的高度差不大于1的二叉查找树,是一种高度平衡的二叉查找树
因为增删查的操作都与树的高度挂钩,因此二叉平衡树就是通过左右旋来保证左右子树的高度差不多,使得树的高度接近O(logn),防止性能的退化。(一棵及其平衡的二叉树的高度大约是log2n)
红黑树
二叉平衡树的一种,严格的二叉平衡树维护平衡的代价很高,很复杂,因此弱平衡的红黑树,或者说近似平衡经常被使用到
定义:
- 每个节点都是红或者黑
- 根必须是黑色
- 没有相邻的两个红色节点
- 对于每个节点,这个节点到达它能到达的叶子结点的所有路径,都包含相同数目的黑色节点
在红黑树的创建过程中,3.4点可能会被打破
因此采用左右旋来维持平衡性
首先,插入的节点必须是红色
选择跳表而不是红黑树:
5. 跳表的性能与红黑树近似,但是跳表代码实现更简单
6. 有序集合除了插入、删除、查询,还有一个范围查询的功能,由于跳表的索引可以很快的定位范围,在这个范围遍历输出即可,而红黑树的范围输出性能会弱一些
Hash散列表快速复习
通过哈希函数,将键映射到数组对应的位置中
hash冲突解决方式:开放寻址或者拉链法
开放寻址法中最简单是线性探测:一旦有冲突就往下找,直到找到空位为止(ThreadLocalMap就用的这个)
然后是二次探测,步长变长,+1 +2 变成+1的平方 +2的平方
双重散列:多订几个哈希函数,第一个有冲突就用第二个
拉链法:如果遇到冲突,就以链表的形式坠到hash桶后面
装载因子:填入表中的元素个数/散列表的长度
装载因子越大,空闲位置越少,冲突越多
打造工业级的散列表
装载因子过大了怎么办 -> 扩容
散列表扩容:容量变为2倍,数据进行搬移,一次性搬移延迟较高,可以分摊,扩容时不搬移旧数据,插入新数据时先插入新表,同时搬移一条旧数据,期间的查询,先在新表中查,查不到再去旧表。
选择冲突解决方法
开放寻址法:
优点:
4. 散列表中的数据都存在数组中,可以有效利用CPU加快查询速度
5. 序列化简单(链表中有指针,序列化不容易)
缺点:
6. 删除数据麻烦
7. 冲突的代价更高,装载因子上限不能太大,因此浪费内存空间
数据量小,装载因子小,用开放寻址法——Java的ThreadLocalMap使用开放寻址来解决散列冲突的原因
拉链法(链表法)
优点:
1.内存利用率高,不需要提前申请空间
2.对大装载因子的容忍度高
缺点:
1.额外耗费内存,要存储指针,对小对象来说比较消耗内存
2.链表节点零散分布在内存中,不连续,对CPU缓存不友好
大对象、大数据量的散列表,用拉链法,并且它支持更多的优化策略,比如用红核数代替链表
HashMap分析
默认大小:16
装载因子和动态扩容:0.75,当HashMap中元素个数超过0.75*capacity(capacity是散列表的容量)就会扩容,扩容为两倍的大小。
散列冲突解决方法:
JDK1.8之前:数组+链表
JDK1.8之后:数组+链表+红黑树
当链表长度大于8之后,就会变为红黑树,因为数据量小的时候,红黑树维护平衡性的策略是左右旋,比起链表,性能优势不明显
散列函数:
int hash(Object key) {
int h = key.hashCode();
return (h ^ (h >>> 16)) & (capitity -1); //capicity 表示散列表的大小
}
其中,hashCode() 返回的是 Java 对象的 hash code。比如 String 类型的对象的 hashCode()就是下面这样:
public int hashCode() {
int var1 = this.hash;
if(var1 == 0 && this.value.length > 0) {
char[] var2 = this.value;
for(int var3 = 0; var3 < this.value.length; ++var3) {
var1 = 31 * var1 + var2[var3];
}
this.hash = var1;
}
return var1;
}
因此设计一个工业级别的散列表需要考虑:
- 支持查询、插入、删除工作
- 合适的散列函数(使得值在散列中随机且均匀分布,还要兼顾性能,复杂了会耗时)
- 合适的装载因子和动态扩容策略
- 合适的散列冲突方法(小装载因子小数据量开放寻址、大数据量拉链法)
十、树结构的实际运用
10.1 堆排序
10.1.1 堆排序基本介绍
- 堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序,它的最坏,最好,平均时间复杂度均为 O(nlogn),它也是不稳定排序。
- 堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆, 注意 : 没有要求结点的左孩子的值和右孩子的值的大小关系。
- 每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆
- 大顶堆举例说明
- 小顶堆举例说明
- 一般升序采用大顶堆,降序采用小顶堆
10.1.2 堆排序基本思想
堆排序的基本思想是:
- 将待排序序列构造成一个大顶堆
- 此时,整个序列的最大值就是堆顶的根节点。
- 将其与末尾元素进行交换,此时末尾就为最大值。
- 然后将剩余 n-1 个元素重新构造成一个堆,这样会得到 n 个元素的次小值。如此反复执行,便能得到一个有序序列了。
可以看到在构建大顶堆的过程中,元素的个数逐渐减少,最后就得到一个有序序列了.
10.1.3 堆排序步骤图解说明
要求:给你一个数组 {4,6,8,5,9} , 要求使用堆排序法,将数组升序排序。
步骤一 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。原始的数组 [4, 6, 8, 5, 9]
- .假设给定无序序列结构如下
- .我们将序列变成一个大顶堆,其实就是从下往上、从右到左,将每个非终端结点(非叶结点)当做根结点,将其和子树调整成大顶堆。由完全二叉树的性质:
- 性质:二叉树的根结点从0开始编号,则i号结点的双亲编号为【
(i-1)/2
】,左孩子的编号为【2i+1
】,右孩子的编号为【2i+2
】。
从下到上,从右到左处理第一个非叶结点:
- 首先,从下到上,从左到右的第一个非叶结点是最后一个结点的双亲。
- 下标从0开始,一共有arr.length个元素,最后一个结点的编号是 i = arr.length-1,由性质,它的双亲编号是(i-1)/2=( arr.length-1-1)/2=arr.length/2-1。
- 因此我们从最后一个非叶结点(标号arr.length/2-1=5/2-1=2-1=1号)开始从下往上、从右到左当做根结点进行调整,但是调整时是从所定的根结点开始从上到下来调整的,调整的范围是被改动过的范围。
- 1号结点的值是6,根据大顶堆的定义,大顶堆根结点值要大于等于它的孩子结点,因此将6与它的左右孩子5和9中较大者进行比较即可,9>5,因此根结点6与9比较,6<9,因此这个根树中6和9互换位置完成这个根结点的大顶堆。
- .找到第二个非叶节点,它的标号是【上一个非叶结点标号 - 1】 即【1-1=0】号,值为4,同理,由于[4,9,8]中左孩子 9 最大,根4 和 9 交换。
- 这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中 6 最大,交换 4 和 6。
此时,我们就将一个无序序列构造成了一个大顶堆。
- 步骤二 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。
- .将堆顶元素 9 和末尾元素 4 进行交换
- .重新调整结构,使其继续满足堆定义
- .再将堆顶元素 8 与末尾元素 5 进行交换,得到第二大元素 8.
- 后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序
再简单总结下堆排序的基本思路:
1).将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
2).将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
3).重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
10.1.4 堆排序代码
二叉树用顺序存储的数组实现,标号从0开始
- 从右到左,从下到上取非叶结点为根将其调整为一个大顶堆(adjustHeap),【右→左,下→上】->【arr.length/2-1 递减→标号0】
重复以下过程: - 顶端(标号0)与末尾j的元素交换,这些元素中最大值被沉到末尾“取出”,所以末尾向前定义一位【j- -】
- 将剩余的元素继续调整为大顶堆
- 直到末尾 j=0
循环调整为大顶堆(adjustHeap)的过程:
- 指针i指向【要调整的位置】,开始是【根的位置】,将根的值存入【temp】
- 循环调整开始:
- 指针k指向值较大的左或右孩子结点(如果有左右孩子的话),因此k以【2*k+1】方式递增
- 判断是否有右孩子,如果有,则将k指向值较大的孩子
- 判断【temp】与孩子谁大:
-
- 孩子大,不符合大顶堆定义,需要调整,指针i指向【要调整的位置】,因此将孩子的值给要调整的位置 arr[i] =arr[k],孩子的位置发生了改变,需要以孩子为根向下重新调整,因此将孩子的位置赋值给要调整的位置 i=k,进入下一轮循环。
-
- temp大,即temp已经大于左右孩子,复合大顶堆的定义,无需调整,退出循环。
- 循环过后:
- i已经指向了需要调整的最终位置,将temp值放入位置i
调整为大顶堆的过程类似将最开始的根值temp备份视为第一个要调整的位置,然后从其孩子开始看,如果temp值比大孩子的值小,那么孩子的值先拽过去覆盖原来要调整的位置,同时这个孩子的位置变为下一个待调整位置,从孩子的孩子继续看,一直这样有大的就拽上来,最终一直到temp大于它的左右孩子结点,符合大顶堆的定义,那么就可以退出循环,将temp值放入上一次的待调整位置。
public class HeapSort {
public static void main(String[] args) {
int[] arr ={
4, 6, 8, 5, 9};
heapSort(arr);
System.out.println(Arrays.toString(arr));
}
public static void heapSort(int[] arr){
//从右到左,从下到上的第一个非叶结点开始先调整为一个大顶堆
for (int i=arr.length/2-1;i>=0;i--){
adjustHeap(arr,i,arr.length-1);
}
//调整后将大顶堆的顶端与当前末尾交换位置,继续调整为大顶堆
for(int j=arr.length-1;j>0;j--){
int temp = arr[0];
arr[0] = arr[j];
arr[j] = temp;
adjustHeap(arr,0,j-1);
}
}
//将数组arr[],非叶结点标号i,元素最大标号length
public static void adjustHeap(int[] arr,int i,int length){
//i指向【要调整的位置】,开始是根结点的位置
int temp = arr[i];//将根结点的值进行存储
//从2*i+1是标号i的左孩子,k指向值较大的左孩子或者右孩子
for (int k=2*i+1;k<=length;k=k*2+1){
if (k+1<=length && arr[k+1]>arr[k]){
k++;//如果右孩子大于左孩子,那么k指向右孩子
}
//如果左或右孩子的值大于根的值,不符合大顶堆,需要调整
//将孩子值赋值给待调整位置的值,相当于我们对【以孩子为根的树】进行了调整,所以将i指向孩子的位置k
//进行下一轮调整
if (arr[k]>temp){
arr[i]=arr[k];
i=k;
}else {
break;//如果左或右孩子的值小于根的值,符合大顶堆,无需调整,退出循环
}
}
//循环过后,i已经指向了最后的待调整位置,将temp放入
arr[i]=temp;