数据结构
最初接触数据结构,往往从存储结构去认识去学习。
而不管是什么数据结构,我们在选择的时候往往是由时间复杂度来决定的。
本篇从如何选择数据结构的角度,来介绍各个数据结构。
数据结构的基石
数组结构 和 链表结构,
由于数组长度固定,插入和删除需要移动,满了需要扩容。时间复杂度对比如下:
顺序插入 | 随机插入 | 随机查询 | 删除 | 扩容 | |
---|---|---|---|---|---|
数组 | 1 | n | 1 | n | n |
链表 | 1 | n | n | 1 | 0 |
所以数组更适合做读操作,链表更适合处理修改操作。
此外 从内存的角度思考:
数组在初始化时,开辟相应长度的内存空间。
链表则是动态的申请内存。
所以
- 数组长度内的增删操作,快于链表的增删。
- 链表内存利用率100%,数组的要小于等于100%
数组的应用:
栈的实现
栈特性,后入先出。 所以只会在末尾处新增和删除。 数组和链表都可以实现。
数组的实现方式:
- 新建一个数组,入栈放进数组,出栈从当前存储数量-1的位置删除。
需要一个额外空间保存当前栈内元素的数量。
优点:- 提前申请一块固定大小的数组空间,后续不用申请。
- 链表的实现方式:
入栈作为头结点, 出栈移除头结点,下一节点作为头。
缺点:- 链表的内存空间是动态获取的。
- 同规模的链表比数组占有空间多。
jdk中Stack类的实现,是继承自 Vector类
链表的应用
队列的实现
队列特性,先入先出。 假设出口A,即队列的头部,入口B,队列的尾部。
jdk对于Queue的实现提供了数组和链表两种方式:
数组方式:
ArrayQueue
基本思想: (下标增大为前进,下标减小为后退)
- AB初始索引=0;
- 头部追加,A向后退一位,数组越界,则跳到数组尾部继续后退。
- 尾部追加,B向前进一位。
- 当AB相遇,说明数组已经存满。可以进行扩容。扩容过程大致为 在AB间插入原数组长度。
- 出队时,A向前进一位。遇到B说明队列为空。数组越界则跳到数组头部继续前进。
数据分布图:
(没找到合适的作图工具,先欠着)自行脑补
类图:
优点:
- 不用动态申请释放内存。入队出队速度略快于链表。
缺点:
- 存在扩容,扩容时时间复杂度为n。
- 内存利用率低于链表。
链表实现
jdk中链表实现示例:
LinkedList
基本思想:
- 双向链表
- 保存头尾双节点
- 支持头插,尾插
- pop方法 从头节点出,add方法从尾节点入。
链表方式比较常规,不做详细介绍。
类图:
散列表(哈希表)
实现了O1的快速查找。
每当遇到一个要求时间复杂度为O1的算法时,第一想到的就是数组或该结构。
基本结构:
数组+链表。
基本思想:
散列思想依赖于 java中通过hashCode()和equals()方法标识唯一。
在java中,如果某个对象A的hashCode()返回值等于另一个对象B。且 A.equals(B) == true; 则AB标识唯一。 例如:新旧两个身份证,代表同一个人。
与此思想相关的还有不变性等设计思想(为何String设计成final)。有兴趣可以拓展看一下。
- 通过hash值对数组长度取模,找到该key对应的数组位置
- 如果数组位置为null。创建一个链表,保存此key
- 如果数组位置有值,判断equals,相同覆盖,否则加入链表
此时加入链表,在jdk,hashMap中,1.7与1.8实现不同,后面文章会提到。
散列表,除了在时间复杂度上的优势以外,也是常见的分治算法思想,
比如:求1000000个数中,出现频率最高的数,可以对数求hash,分配到不同的桶中,求各桶出现最多的数以及次数,然后合并各桶结果。
也是被用来处理并发问题的好结构
jdk1.8版本的concuurentHashMap,基于散列表结构,来降低锁的粒度 和 完成并发场景帮助扩容(helpTransfer方法)
树
树结构是生活场景中最常见的结构,比如:书的目录,电脑的文件结构,部门的组织架构等等。
常规的树结构中,每个节点可以有多个子节点。
节点结构可以表示为:
Node{
int val;
List<Node> childNodeList;
}
数学上的树更多为二叉树。
二叉树,顾名思义每个节点至多有两个子节点
所有的二叉树,都可以用数组来存储。
上图解释:
空置0下标,从1开始存储树的根节点,则有如下规律
- 左子节点 = 父节点下标 * 2
- 右子节点 = 父节点下标 * 2 + 1
- 父节点 = 当前节点 / 2
- 当子节点为空时, 对于下标位置存储null
- 数组的长度 = 对应的完全二叉树的节点数量 = 2的树深度次方。
二叉搜索树
在二叉树的定义上,加了新的性质:
每一个节点的值,都要大于它的左子节点,并且小于他的右子节点。
二叉搜索树最大的应用: 二分查找
二分查找
因为要查找的值,要么在当前节点左边,要么在当前节点右边,所以每次查询能够去掉一半的选择,
所以:二叉查找树的平均时间复杂度 为 logN
但是
最终的查询次数,取决于节点的分布情况,当所有的节点都是右节点或左节点,树就退化成了链表,
所以:此时的实际时间复杂度为 n
为了解决这个问题,引入了平衡二叉搜索树的概念。
平衡二叉搜索树
在二叉搜索树的定义上,加了新的性质,平衡性。
平衡二叉树的查询时间复杂度logN。
平衡性,简单来说,就是各节点在根结点的左右两边的分布是接近的(非准确答案)
在此性质上,实际的查询时间复杂度分布,就接近于平均时间复杂度了。
目前常见的平衡二叉搜索树有: AVL树 和 红黑树。
他们对平衡性有不同的定义,因此有不同的平衡效果,以及各自的优势。
链接: https://www.cnblogs.com/ajianbeyourself/p/11173851.html
平衡二叉搜索树,分为两部分:
- 使用一些定义特性来保证其平衡性
- 提供了平衡性被破坏时的,恢复平衡的算法。
比如:
- 红黑树的5个性质,定义了其是 非高度平衡的;
- 红黑树完成自平衡的最小单元是 子父爷
- 在子父爷场景内, 所有的破坏平衡性操作,都可以通过左旋、右旋、内旋、外旋的基本操作回归平衡
跳表 (研究不深,提供思路,勿轻信)
跳表,是基于链表结构,对链表的查询时间复杂度n,做了优化,理想时间复杂度为logN
在jdk中对于跳表结构应用的比较少。
为什么呢? 以下为猜测:
jvm中,数据结构和数据是分开存放的。
例如:一个Student数组,开辟一块数组空间,空间内存放的是Student对象的地址。 而具体的Student对象是存放在其他内存区域的。
所以在这个场景里,底层是数组的二叉树存储方式,时间复杂度和跳表一致。二叉树数组在大多数场景可以替代跳表。
那跳表在哪里使用呢?
跳表的应用,一般体现在中间件的存储上。
例如: redis的Zset结构。 es的索引结构。
这可能是因为,对象直接放在了存储结构里。
还是用数组来举例, 开辟了一个数组,那么对象的存储,就在这数组的一个个空间里。
由于对象的占用内存空间不固定
所以在这种场景,动态申请内存释放内存是必要的,因此链表比数组更合适,从而跳表比二叉树更合适。
终极问题: 那为什么不使用B+树呢? B+树的叶子节点也是链表节点啊?
外部链接:https://blog.csdn.net/z69183787/article/details/89470896
jdk中
有ConcurrentSkipListMap (没用过)
跳表结构:
跳表创建索引的随机算法,具体含义是:
// 理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。
// 因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。
// 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
// 50%的概率返回 1
// 25%的概率返回 2
// 12.5%的概率返回 3 …
所以,为什么使用随机算法来确定是否建立索引,也就比较好理解了。