数据结构
数据结构和算法:
什么是数据结构?
数据结构就是指一组数据的存储格式
什么是算法?
算法就是操作数据的一组方法
数据结构分类:
1)线性结构
特点:有 0-1 个直接前继和直接后继。当线性结构非空时,有唯一 首元素和尾元素,除两者外 , 所有的元素都有唯一的直接前继和直接后继
线性结构包括顺序表、链表、栈、队列等,其中栈和队列是访问受限的结构。栈是后进先 出,即 Last-In, First-Out,简称 LIFO;队列是先进先出 ,即 First-In, First-Out,简称 FIFO。
2)树结构
特点:有0至1个直接前继和0至n个直接后继(n大于或等于2)
3)图结构
特点:有0 至 n 个直接前继和直接后继( n 大于或等于 2 )。图结构包括简 单图、多重图、有向图和无向图等。
4)哈希结构
没有直接前继和直接后继。晗希结构通过某种特定的晗希函数 将索引与存储的值关联起来 , 它是一种查找效率非常高的数据结构。
数据结构大致介绍:
数组:将相同类型的元素存储于连续内存空间的数据结构,其长度不可变。
长度固定数组:int[] array = new int[5];
可变数组:List<Integer> array = new ArrayList<>(); 其基于数组和扩容机制实现,相比普通数组更加灵活
如: int[] array={2,3,1,0,2}
链表:链表以节点为单位,每个元素都是一个独立对象,在内存空间的存储是非连续的。链表的节点对象具有两个成员变量:值val,及后继节点引用next。
Java:
class ListNode { int val; // 节点值 ListNode next; // 后继节点引用 ListNode(int x) { val = x; } }
Go:
type ListNode struct { Val int Next *ListNode }
如:
栈:是一种先进后出的抽象数据结构,可使用数组或链表实现。
Stack<Integer> stack = new Stack<>(); 或 LinkedList<Integer> stack = new LinkedList<>();
常用操作「入栈 push()」,「出栈 pop()」
注意:通常情况下,不推荐使用 Java 的 Vector 以及其子类 Stack ,而一般将 LinkedList 作为栈来使用
队列:是一种先进先出的抽象数据结构,可使用链表实现.
Queue<Integer> queue = new LinkedList<>();
常用操作「入队 push()」,「出队 pop()」
树:一种非线性数据结构,根据子节点数量可分为 二叉树 和 多叉树。最顶层的节点称为 根节点root。以二叉树为例,每个节点包含三个成员变量:值 val,左子节点left,右子节点 right。
class TreeNode { int val; // 节点值 TreeNode left; // 左子节点 TreeNode right; // 右子节点 TreeNode(int x) { val = x; } }
完全二叉树:设二叉树的深度为k,若二叉树除第k层外的其他各层(第1至k-1层)的节点达到最大个数,且处于k层的节点都集中在最左边,则称此二叉树为完全二叉树。完全二叉树要求最后一个叶子节点之前的所有节点都齐全。
满二叉树:除最后一层无任何子节点外,每一层上的所有结点都有两个子结点的二叉树。简单来说,满二叉树的每个分支都是满的。
图:一种非线性数据结构,由节点 和 边 组成,每条边连接一对顶点。根据边的方向有无,图可分为 有向图 和 无向图。
散列表:非线性数据结构,通过利用hash函数将指定的key映射到对应的value上,以实现高效的元素查找。
堆:一种基于【完全二叉树】的数据结构,可使用数组实现。
以堆为原理的排序算法称为【堆排序】,基于堆实现的数据结构为【优先队列】。
堆分为大顶堆和小顶堆。大顶堆:任意节点的值不大于其父节点的值;小顶堆:任意节点的值小于其父节点的值。
如下图所示,为包含 1, 4, 2, 6, 8 元素的小顶堆。将堆(完全二叉树)中的结点按层编号,即可映射到右边的数组存储形式:
通过使用「优先队列」的「压入 push()」和「弹出 pop()」操作,即可完成堆排序,实现代码如下:
// 初始化小顶堆
Queue<Integer> heap = new PriorityQueue<>();
// 元素入堆
heap.add(1);
heap.add(4);
heap.add(2);
heap.add(6);
heap.add(8);
// 元素出堆(从小到大)
heap.poll(); // -> 1
heap.poll(); // -> 2
heap.poll(); // -> 4
heap.poll(); // -> 6
heap.poll(); // -> 8
数据结构详细介绍:
1、散列表(Hash Table)
即哈希表,散列表用的是数组支持下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。
将键值映射为数组下标的方法叫做散列函数(哈希函数);
散列函数计算得到的值叫做散列值(哈希值);
散列函数设计的三点基本要求:
1、散列函数计算得到的散列值是一个非负整数;
2、如果key1=key2,那么 hash(key1) == hash(key2);
3、如果key1≠key2,那么 hash(key1) ≠ hash(key2);
因为数组下标是从0开始的,所以散列函数生成的散列值也要是非负整数;相同的key,经过散列函数得到的散列值也应该是相同的;
但是,第三点来说,要想找到一个不同的key对应的散列值都不一样的散列函数,几乎是不可能的。即便业界著名的MD5,SHA,CRC等哈希算法,也无法完全避免这种哈希冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。
散列冲突(哈希冲突)
再好的散列函数也无法避免散列冲突。常用的散列冲突解决方案有开放寻址法和链表法。
1、开放寻址法
开放寻址法的核心思想是,如果出现了散列冲突,就重新探测一个空闲位置,将其插入。探测方法是线性探测;
即,当向散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,那就从当前位置开始,依次往后查找,看是否有空闲位置,如果遍历到尾部都没有找到空闲位置,那就从散列表表头开始找,直到找到为止。
在散列表中查找元素时,先通过哈希函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,说明就是我们要找的元素;如果不相等,那就顺序往后依次查找,如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。
在使用线性探测法来解决冲突的散列表中删除元素时,不能简单的将要删除的元素设置为空。因为使用线性探测法来解决冲突的散列表在查找元素时,一旦找到一个空闲位置,就会认为散列表中不存在这个数据。但是,如果这个位置是我们之前删除的,就会导致原来的查找算法失效。因此,在删除时,将删除的元素特殊标记为deleted,当线性探测查找的时候,遇到标记为deleted的空间,并不是停下来,而是继续往下探测。
线性探测的问题:当散列表中插入的元素越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。
不过当数据量比较小,装载因子小的时候,适合采用开放寻址法。ThreadLocalMap采用的就是开放寻址法解决散列冲突。
除了线性探测还有二次探测和双重散列。二次探测即每次探测的步长是线性探测的二次方1,4,8等
双重散列的意思是使用一组多个散列函数,先用第一个散列函数,如果得到的散列值对应的存储位置已经被占用,再使用第二个散列函数,一次类推,直到找到空闲的存储位置。
装载因子:
当散列表中空闲位置不多的时候,散列冲突的概率就会增大。为了尽可能保证散列表的操作效率,一般要尽可能保证散列表中有一定比例的空闲槽位。用装载因子(load factor)来表示空位的多少。装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
散列表的装载因子=填入表中的元素个数/散列表的长度
2、链表法
链表法是一种比开发寻址更加常用且简单的散列冲突解决办法。在散列表中每个“槽slot”对应一个链表,所有散列值相同的元素我们都放到相同槽对应的链表中。
当插入元素的时候,只需要通过散列函数计算出对应的散列槽位,将其插入到对应的链表中即可。所以插入元素的时间复杂度为O(1)。当查找、删除一个元素时,同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。
当装载因子比较大的时候,适合使用链表法。不过当链表的长度比较大的时候,查找和删除的效率就会急剧下降。时间复杂度为O(n),n为链表的长度。而且链表由于要存储指向下个元素的指针,因此链表长度太大的话,会占据更多的存储空间!
因此,需要对链表法进行稍加改造,使其更加高效。可以将链表法中的链表改成其他高效的数据结构,使查询,删除更快;比如跳表、红黑树;查找的时间复杂度就变成了logn。也一定的避免了哈希碰撞攻击。
哈希碰撞攻击:在极端情况下,有些恶意的攻击者,还有可能通过精心构造的数据,使得所有的数据经过散列函数之后,都散列到同一个槽里。如果我们使用的是基于链表的冲突解决方法,那这个时候,散列表就会退化为链表,查询的时间复杂度就从 O(1) 急剧退化为 O(n)。
所以,基于链表法解决散列冲突的方法比较适合存储大对象、大数据量的散列表。如果用红黑树代替链表,性能更高。HashMap就是使用链表法来解决哈希冲突。JDK1.8中的hashMap在一个槽内的链表长度大于8的时候就自动将链表转成红黑树;当红黑树结点个数小于8的时候又会转化为链表。因为在数据量比较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。
2、树
树的高度,深度和层
二叉树(Binary Tree)
每个节点只有两个叉,也就是最多只有两个节点,分别是左子节点和右子节点。
除了叶子节点外,每个节点都有左右两个子节点的二叉树,叫 满二叉树。
叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫完全二叉树。
想要存储一棵二叉树,有两种方式,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。
链式存储法:每个节点有三个字段,其中一个存储数据,另外两个指向左右子节点的指针。
数组存储法:按照层级顺序把二叉树的节点放到数组中对应的位置上,如果某一个节点的左孩子或右孩子空缺,那么数组的相应位置也会空缺出来。
规律:那么如果节点X存储在数组中下标为i的位置,则节点X的左子节点在数组中的下标为2*i+1,节点X的右子节点在数组中的下标为2*i+2;
通过这种方式,我们只要知道根节点存储的位置,则整棵树都可以串起来。
如果是一棵非完全二叉树,则会浪费比较多的数组存储空间,因为如果某个节点的左孩子或者右孩子空缺,那么数组相应的位置也会空出来。
如果某棵树是一棵完全二叉树,那么数组存储无疑是最节省内存的方式,因为数组存储不需要像链式存储那样需要额外存储左右子节点的指针。这也是完全二叉树要求最后一层的子节点都靠左的原因。
堆其实就是一棵完全二叉时,最常用的存储方式是数组。
二叉树的遍历:
前序遍历:根左右<
中序遍历:左根右^
后序遍历:左右根>
层序遍历:按照从根节点到叶子节点的层次关系,一层一层横向遍历各个节点。遍历时需要借助队列。
二叉树的遍历分为两类:
1、深度优先遍历:前序遍历、中序遍历、后序遍历
2、广度优先遍历:层序遍历
实际上,二叉树的前、中、后序遍历就是一个递归的过程;
前序遍历:先打印根节点,然后递归打印左子树,最后递归打印右子树。
二叉查找树(Binary Search Tree)
二叉查找树也叫二叉搜索树,是为了实现快速查找而生的,不仅仅支持快速查找一个数据,还支持插入、删除一个数据。这依赖于二叉查找树的数据结构:在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
1、二叉查找树的查找操作
先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点大,那就在右子树中递归查找。
2、二叉查找树的插入操作
与查找类似,新插入的数据最终一般都是再叶子节点上,从根节点开始,依次比较要插入的数据和节点的大小关系;如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插入到右子节点的位置;如果不为空,那就递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数据值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就递归遍历左子树,查找插入位置。
中序遍历二叉查找树,可以输出有序的数据序列。时间复杂度为O(n),非常高效。所以二叉查找树又叫二叉排序树。
二叉堆
二叉堆本质上是一种完全二叉树,它分为两个类型
1、最大堆 : 任何一个父节点的值,都大于或等于它左、右孩子节点的值
2、最小堆:最小堆的任何一个父节点的值,都小于或等于它左、右孩子节点的值
二叉堆的存储方式非链式存储,而是顺序存储,即二叉堆的所有节点都存储在数组中。如果一个节点的下标是 i,那么它的左孩子是 2i+1,右孩子是2i+2。
二叉堆的操作:
插入节点:插入到完全二叉树的最后一个位置,然后和父节点进行比较,根据最大堆或最小堆的性质,如果不满足则和父节点进行交换(上浮),直到满足条件为止。
删除节点:删除堆顶的节点,将最后一个节点补到堆顶位置,然后和左右孩子节点比较,不满足则和最小(最小堆)或最大(最大堆)进行交换(下浮),直到符合堆的条件为止
构建二叉堆:也就是将一个完全的二叉树调整为二叉堆,本质是让所有非叶子节点依次下沉。
红黑树(Red Black Tree )
二叉查找树在频繁的动态更新过程中,可能会出现树的高度远大于log2n的情况,从而导致各个操作的效率下降。在极端情况下,二叉树会退化成链表,时间复杂度会退化为O(n)。要解决这个复杂度退化的问题,需要一种能平衡左右节点的二叉查找树。红黑树就是这种结构,因此实际上能用到二叉查找树的地方都会用红黑树(平衡二叉查找树)。
平衡二叉树:二叉树中任意一个节点的左右子树的高度差不能大于1。完全二叉树和满二叉树都是平衡二叉树。
平衡二叉查找树不仅满足平衡二叉树的定义,还满足二叉查找树的特点。最早的平衡二叉查找树是AVL树。
红黑树:一种不严格的平衡二叉查找树
1、根节点是黑色的
2、每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据
3、任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的
4、每个节点,从该节点到达其可达叶子结点的所有路径,都包含相同数目的黑色节点
为了维持平衡,需要两个重要的操作,左旋,右旋;左旋:围绕某个节点的左旋;右旋:围绕某个节点的右旋;
插入操作的平衡操作
红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上。所以插入操作的平衡调整有下面两种特殊情况:
1、如果插入节点的父节点是黑色的,那我们什么都不做,它仍然满足红黑树定义。
2、如果插入的节点是根节点,那直接改变它的颜色,将其变成黑色即可。
除此之外,其他情况都会违背红黑树的定义,需要进行调整,调整的过程包含两种基础的操作:左右旋转和改变颜色。
3、图
和树比起来,图(Graph)是一种更复杂的非线性数据结构
树中的元素我们称为节点,图中的元素我们就叫做顶点(vertex)。从我画的图中可以看出来,图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫做边(edge)
和顶点相连的边的条数,叫做顶点的度(degree)
有向图:
把有方向的图,叫做有向图;以此类推,没有方向的图叫做无向图。
如,在微博中,A关注了B,就对应一条从A指向B带箭头的边。无向图中有“度”这个概念,表示一个顶点有多少条边。在有向图中,我们把度分为入度(In-degree)和出度(Out-degree)。
顶点的入度,表示有多少条边指向这个顶点;顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点。对应到微博的例子,入度就表示有多少粉丝,出度就表示关注了多少人。
带权图:
在带权图(Weighted graph)中,每条边都有一个权重(weight)。如,QQ关系里的权重可以表示好友关系的亲密度。
图的存储
1)邻接矩阵
邻接矩阵的底层依赖一个二维数组。
无向图:如果顶点 i 与顶点 j 之间有边,我们就将 A[i][j]和 A[j][i]标记为 1 ,否则标记0
有向图:对于有向图来说,如果顶点 i 到顶点 j 之间,有一条箭头从顶点 i 指向顶点 j 的边,那我们就将 A[i][j]标记为 1。同理,如果有一条箭头从顶点 j 指向顶点 i 的边,我们就将 A[j][i]标记为 1 ,否则标记0
带权图:数组中存储相应的权重值
优点:存储方式简单、直接,因为基于数组,所以在获取两个顶点的关系时,就非常高效。另外方便计算,可以将很多图的原酸转换成矩阵之间的运算
缺点:比较浪费存储空间,如:对于无向图来说,如果 A[i][j]等于 1,那 A[j][i]也肯定等于 1。实际上,我们只需要存储一个就可以了
2)邻接表
邻接表每个订单对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。有点像散列表
优点:存储节省空间
缺点:获取关系时,时间复杂度较高
END.
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
· AI与.NET技术实操系列(六):基于图像分类模型对图像进行分类