数据结构

数据结构

最初接触数据结构,往往从存储结构去认识去学习。
而不管是什么数据结构,我们在选择的时候往往是由时间复杂度来决定的。

本篇从如何选择数据结构的角度,来介绍各个数据结构。

数据结构的基石

数组结构 和 链表结构,
由于数组长度固定,插入和删除需要移动,满了需要扩容。时间复杂度对比如下:

顺序插入随机插入随机查询删除扩容
数组1n1nn
链表1nn10

所以数组更适合做读操作,链表更适合处理修改操作。

此外 从内存的角度思考:

数组在初始化时,开辟相应长度的内存空间。
链表则是动态的申请内存。

所以

  1. 数组长度内的增删操作,快于链表的增删。
  2. 链表内存利用率100%,数组的要小于等于100%

数组的应用:

栈的实现

栈特性,后入先出。 所以只会在末尾处新增和删除。 数组和链表都可以实现。
数组的实现方式:

  • 新建一个数组,入栈放进数组,出栈从当前存储数量-1的位置删除。
    需要一个额外空间保存当前栈内元素的数量。
    优点:
    1. 提前申请一块固定大小的数组空间,后续不用申请。
  • 链表的实现方式:
    入栈作为头结点, 出栈移除头结点,下一节点作为头。
    缺点:
    1. 链表的内存空间是动态获取的。
    2. 同规模的链表比数组占有空间多。

jdk中Stack类的实现,是继承自 Vector类
stack类图

链表的应用

队列的实现

队列特性,先入先出。 假设出口A,即队列的头部,入口B,队列的尾部。

jdk对于Queue的实现提供了数组和链表两种方式:

数组方式:

ArrayQueue
基本思想: (下标增大为前进,下标减小为后退)

  1. AB初始索引=0;
  2. 头部追加,A向后退一位,数组越界,则跳到数组尾部继续后退。
  3. 尾部追加,B向前进一位。
  4. 当AB相遇,说明数组已经存满。可以进行扩容。扩容过程大致为 在AB间插入原数组长度。
  5. 出队时,A向前进一位。遇到B说明队列为空。数组越界则跳到数组头部继续前进。

数据分布图:

(没找到合适的作图工具,先欠着)自行脑补

类图:
在这里插入图片描述
优点:

  • 不用动态申请释放内存。入队出队速度略快于链表。

缺点:

  • 存在扩容,扩容时时间复杂度为n。
  • 内存利用率低于链表。
链表实现

jdk中链表实现示例:
LinkedList
基本思想:

  1. 双向链表
  2. 保存头尾双节点
  3. 支持头插,尾插
  4. pop方法 从头节点出,add方法从尾节点入。

链表方式比较常规,不做详细介绍。

类图:
在这里插入图片描述

散列表(哈希表)

实现了O1的快速查找。
每当遇到一个要求时间复杂度为O1的算法时,第一想到的就是数组或该结构。

基本结构:

数组+链表。

基本思想:

散列思想依赖于 java中通过hashCode()和equals()方法标识唯一。

在java中,如果某个对象A的hashCode()返回值等于另一个对象B。且 A.equals(B) == true; 则AB标识唯一。 例如:新旧两个身份证,代表同一个人。

与此思想相关的还有不变性等设计思想(为何String设计成final)。有兴趣可以拓展看一下。

  1. 通过hash值对数组长度取模,找到该key对应的数组位置
  2. 如果数组位置为null。创建一个链表,保存此key
  3. 如果数组位置有值,判断equals,相同覆盖,否则加入链表
    此时加入链表,在jdk,hashMap中,1.7与1.8实现不同,后面文章会提到。

散列表,除了在时间复杂度上的优势以外,也是常见的分治算法思想

比如:求1000000个数中,出现频率最高的数,可以对数求hash,分配到不同的桶中,求各桶出现最多的数以及次数,然后合并各桶结果。

也是被用来处理并发问题的好结构

jdk1.8版本的concuurentHashMap,基于散列表结构,来降低锁的粒度 和 完成并发场景帮助扩容(helpTransfer方法)

树结构是生活场景中最常见的结构,比如:书的目录,电脑的文件结构,部门的组织架构等等。

常规的树结构中,每个节点可以有多个子节点。

节点结构可以表示为:
Node{
	int val;
	List<Node> childNodeList;
}

数学上的树更多为二叉树。

二叉树,顾名思义每个节点至多有两个子节点

所有的二叉树,都可以用数组来存储。
在这里插入图片描述

上图解释:
空置0下标,从1开始存储树的根节点,则有如下规律

  1. 左子节点 = 父节点下标 * 2
  2. 右子节点 = 父节点下标 * 2 + 1
  3. 父节点 = 当前节点 / 2
  4. 当子节点为空时, 对于下标位置存储null
  5. 数组的长度 = 对应的完全二叉树的节点数量 = 2的树深度次方。

二叉搜索树

在二叉树的定义上,加了新的性质:

每一个节点的值,都要大于它的左子节点,并且小于他的右子节点。

二叉搜索树最大的应用: 二分查找

二分查找
因为要查找的值,要么在当前节点左边,要么在当前节点右边,所以每次查询能够去掉一半的选择,
所以:二叉查找树的平均时间复杂度 为 logN
但是
最终的查询次数,取决于节点的分布情况,当所有的节点都是右节点或左节点,树就退化成了链表,
所以:此时的实际时间复杂度为 n
为了解决这个问题,引入了平衡二叉搜索树的概念。

平衡二叉搜索树

在二叉搜索树的定义上,加了新的性质,平衡性。
平衡二叉树的查询时间复杂度logN。

平衡性,简单来说,就是各节点在根结点的左右两边的分布是接近的(非准确答案)

在此性质上,实际的查询时间复杂度分布,就接近于平均时间复杂度了。

目前常见的平衡二叉搜索树有: AVL树 和 红黑树。
他们对平衡性有不同的定义,因此有不同的平衡效果,以及各自的优势。
链接: https://www.cnblogs.com/ajianbeyourself/p/11173851.html

平衡二叉搜索树,分为两部分:

  1. 使用一些定义特性来保证其平衡性
  2. 提供了平衡性被破坏时的,恢复平衡的算法。

比如:

  1. 红黑树的5个性质,定义了其是 非高度平衡的;
  2. 红黑树完成自平衡的最小单元是 子父爷
  3. 在子父爷场景内, 所有的破坏平衡性操作,都可以通过左旋、右旋、内旋、外旋的基本操作回归平衡

跳表 (研究不深,提供思路,勿轻信)

跳表,是基于链表结构,对链表的查询时间复杂度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 …

所以,为什么使用随机算法来确定是否建立索引,也就比较好理解了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

伞_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值