数据结构与算法之美

461 阅读14分钟

常见的数据结构? 数组(祖宗):查找元素O(1),插入删除慢。 链表(祖宗):插入删除元素O(1),查找元素慢 栈:先进后出 队列:先进先出 跳表:RE 散列表 树 堆:堆是一种完全二叉树 图

排序: 冒泡:O(n) - O(n^2),稳定 插入:O(n) - O(n^2),稳定 选择:O(n^2),不稳定 归并:O(nlogn),稳定 快排:O(nlogn) - O(n^2),不稳定 堆排:O(n) + O(n*logn),不稳定

数组

数组用一组连续的内存空间,来存储一组具有相同类型的数据。所以数组可以通过base_addr + i * type_size指令运算来计算随机访问元素的地址。 【对于无序数组,如果我们想把一个数插入k位置,可以把第k个元素放到最后,然后把目标数直接插入k位置,避免移动数组。快排就是这种思想啊,先选出一个随机数放到数组末尾,然后通过双指针移动找到它应该在的位置,然后直接交换,就相当于直接插入,那把数据插入数组中的第k位置时间复杂度就为O(1)】 在删除元素时,我们可以不搬移数据,而是标记删除。当数组没有多余的空间存储数据时,再触发真正的删除。 数组是连续存储的,所以它还可以利用CPU缓存机制,预读数组中的数据。CPU在从内存中读取数据时,会先把读取到的数据加载到CPU缓存中,而它每次加载的是一个数据块,下次访问内存数据时会先进入CPU缓存中查找,若查找不到再去内存中去。而数组是连续存储的,所以在读取内存中的数组时,CPU就会提前拉取这个连续的数据块到CPU缓存中,就减少了CPU与内存的交互(CPU缓存机制是为了弥补内存访问速度过慢与CPU执行速度快之间的差异的)。 容器与数组:ArrayList其实就是封装了数组操作,而且它支持动态扩容

哈希表

装载因子表示了哈希表的装满程度。装载因子越大,表明哈希表的空间利用率越高,也就意味着哈希冲突变大,查找时间变大。我们在设计哈希结果时,若装载因子超过某个阈值时,就扩容;若装载因子小于某个阈值时,就缩容。但扩容时,对于某次put引发的单次扩容非常耗时,会影响用户体验。所以我们可以采取边插入边扩容,需要扩容时只创建一个新数组,每次插入元素到新数组中,顺带转移一个老元素。

LinkedHashMap采用链表法解决哈希冲突,ThreadLocalMap采用开放寻址法解决哈希冲突。开放寻址法的数据都存在数组中,连续存储可以利用CPU缓存,而且便于序列化、删除等。适合数据量小、装载因子小(哈希冲突小)的场景。链式地址法的内存利用率高,能够容忍大的装载因子,但它是利用指针解决哈希冲突,所以对于小内存哈希表,指针便成了额外消耗,而且节点不连续存储,对CPU缓存不友好。所以我们可以对链表进行改造延伸,比如跳表和红黑树,这样最坏情况下也不过是数据集中在一个桶中,退化成散列表,时间复杂度变为O(logn)。(这就是JDK1.8 HashMap的改造呀)

哈希算法

特点

哈希算法计算得到哈希值,但哈希值不能反推出原始数据

对输入的数据非常敏感,原始数据修改了1bit,最后得到的哈希值也不同。

哈希算法的执行尽量高效,哈希冲突的概率尽量小。 用途 加密算法MD5、SHA:因为哈希值无法反推,而且对数据敏感,可以做到对文件的校验。

唯一标识:取出图片的某些bit计算哈希,作为唯一标识。利用它哈希冲突低的特点。

散列函数:更加关注于哈希值能否平均分布,哈希函数是否耗时。

负载均衡:

如果我们要实现会话粘滞(同一个客户端在一次会话中的所有请求都达到同一个服务器上),如果要存储用户ID与channel的映射,会比较浪费空间,而且维护映射表的成本也不低。所以我们可以对会话ID计算哈希值,然后 % 服务器列表大小。因为哈希值固定,所以得到的服务器ID也固定。(数据分片也是同理,计算哈希然后取模,得到存储服务器ID)。

但是如果要增加机器,文件哈希不变但机器数变了(被余数变了),所以数据分片一定会变化,则就需要重新计算所有数据的哈希值并重新搬移,而且会导致短时间的缓存穿透和雪崩。所以我们要使用一致性哈希:我们让每台机器对2^32取模(IP地址是由四组8位二进制组成,即32个bit,有2^32中排序方式,所以对2^32取模可以保证每个IP地址都有唯一的映射),得到一个Hash环。但这样有可能取模后的节点都挤在一起呀,所以我们要设置虚拟节点。数据到来时,我们也是对其进行哈希取模计算,打到某个点上再顺时针移动直至遇到的第一个机器,即它所要在的机器。

比较字符串(散列表减少时间复杂度):我们可以对主串中可能的子串计算哈希,然后再对目标串计算哈希,直接比较哈希值,就不用依次比较两串的字符是否相等了。当然了为了解决可能会出现的哈希冲突,我们判断两串的哈希相同后,再比较一下两串本身即可 但是还有一个问题,若目标串太长,万一hash值太大超出整数范围了咋整?因为字符只在a-z中,所以我们可以用26进制呀。abc = a2626 + b*26 + c(a对应1,b对应2)。

某个表达式的计算开销大又使用频繁,我们可以预处理并缓存。

Collections.sort() / Arrays.sort()

若元素个数< 47个,则进行二分插入排序(即对插入排序中前面排好序的)

若 47 <元素个数<286,则使用双轴排序。

若元素个数> 286,再用归并排序TimSort.sort()

从头开始,把每个连续的升或降的序列作为分区,而且降序的也调整为升序并压入栈中。

若某个升降分区小于最小值,则会先用二分插入排序进行不足。

每次压入栈时,都要检查栈中已存在的分区是否可以合并,若可以则进行合并。

合并是指得到左尾在右分区的位置,右尾在左分区的位置,那么俩位置之外的元素就是有序的,所以就合并这俩元素之间的区域就可以了。

快排比堆排好? ①快排访问数据是顺序访问的,而堆排是跳着访问的。所以对CPU缓存不友好。 ②堆排序的数据交换层数更多。快排最多就是逆序数,而堆排首先得建堆,然后还得交换排序。

满二叉树:叶子节点都在最底层,除了叶子节点外,每个节点都有左右两个子节点。

完全二叉树:叶子节点在最下两层,最底层的叶子节点都靠左排列。除了最后一层,其他层的节点数都是满的(用于数组顺序存储法表示树时,可以省去空的存储位置)。堆就是一种完全二叉树。

二叉查找树(二叉搜索/排序树):树中的任意一个节点,其左子树的值要小于当前节点,其右子树的值要大于当前节点。它是为了支持更快速的查找、插入、删除操作。中序遍历二叉查找树,得到的是递增数据序列。 时间复杂度:O(n) - O(logn),即链表-完全二叉树,与树的高度有关。

平衡二叉查找树:二叉树中的任意一个节点的左右子树的高度差不能大于1,且每个节点都满足左小右大。但其实很多平衡二叉查找树都没有严格满足高度差不大于1,它的核心思想是解决普通二叉查找树在频繁插入删除时,出现的时间复杂度退化的问题。所以平衡二叉查找树中的平衡,指的是让树看起来比较对称,左子树与右子树高度相对持平。这样就能让整棵树的高度相对来说低一点,相应的操作效率就会高一点。 时间复杂度:O(logn)

前缀树:利用字符串的公共前缀,将重复的前缀合并在一起,形成一个多叉树结构。他其实就是预处理的思想,但是每个树节点要预存储一个26大小的字符数组(若不只是小写字母会更多),会浪费空间。那我们的解决思路就是TreeNode中可以改为跳表、红黑树等带代替数组这个伪散列结构。

红黑树

红黑树思想

红黑树是一种近似平衡的平衡二叉查找树,它的插入、删除、查找操作的时间复杂度非常稳定(O(logn))。红黑树的每个节点,从该节点到其可达叶子节点的所有路径,都包含相同数目的黑色节点。去掉红色节点的“黑树”,高度要低于完全二叉树的logn,那把红色节点加回去,且满足不相邻,所以高度会近似为2logn,所以红黑树的时间复杂度接近O(2logn)。所以红黑树是近似平衡的平衡二叉查找树。而且红黑树的插入删除等操作,变化的节点要比AVL树少很多,维护成本更低。

红黑树的左旋与右旋

左旋:关注节点的右子节点旋转为相对根节点,那关注节点就跟着变为相对根节点的左子节点,相对根节点原先的左子节点就移动到关注节点的右子节点。

右旋:关注节点的左子节点选择为相对根节点,那关注节点就跟着变为相对根节点的右子节点,相对根节点原先的右子节点就移动到关注节点的左子节点。

红黑树的插入

首先要明确红黑树满足的要求:

①根节点是黑色的

②每个叶子节点都是黑色的空节点(为了方便平衡调整)

③任何相邻的节点都不能为红色

④每个节点,从该节点到其可达叶子节点的所有路径,都包含相同数目的黑色节点。所以去掉红色节点的“黑树”,高度要低于完全二叉树的logn,那把红色节点加回去,且满足不相邻,所以高度会近似为2*logn。所以红黑树是近似平衡的平衡二叉查找树。而且红黑树的插入删除等操作,变化的节点要比AVL树少很多,维护成本更低。

⑤新插入的节点为红色节点

插入操作的平衡调整:

①若新插入节点的父节点为黑色则无需平衡调整;若插入根节点则直接变黑即可。

②若新插入节点的父节点为红色:

若叔叔节点为红色,则将父节点、叔叔节点都设置为黑色,将祖父节点设置为红色,关注节点变为祖父节点。

若叔叔节点为黑色,它是父节点的右子节点,则关注节点变为父节点,然后围绕关注节点左旋

若叔叔节点为黑色,它是父节点的左子节点,则围绕关注节点的祖父节点右旋,然后将关注节点的父节点与兄弟节点的颜色互换。

针对每次的关注节点,递归以上过程。

堆是一个完全二叉树,它的每个节点都满足大于或小于它的子树中的每一个节点。

b树 它是一个多路平衡查找树,每个节点可以有多个key值,这些key对应的value在节点中升序排序。每个节点也要满足左小右大的平衡特性。 n阶b树,每个节点最多有n-1个key,最少可以有n/2个key 时间复杂度:

b+树 非叶子节点只存储key,不存储value,且一个节点中的key升序排序,且满足左小右大的平衡特性。叶子节点本身根据key值升序排序,并通过链表相连。叶子节点内部的key升序排序,对应的value都存在叶子节点中。 时间复杂度:O(log/n m),n为分的叉数,m为层数

Top K

一亿个url怎么样找到其中重复top100的url

一亿个url不可能全部加载到内存中处理。所以可以采取分治的策略,我们把具有相同特性的url放到一个个小文件中(可以使用哈希取模算法,hash(url) % 1000,按照结果存到一个个的小文件中),也就是说相同的url必然在一个小文件中,然后把这个小文件拉入内存,使用HashMap记录重复次数。然后利用小顶堆,得到每个HashMap中的重复次数top100,。然后对每个小文件的top100聚合,最终得到最后的top100。【但这样有个很严重的问题啊,我们的hash()无法做到均分小文件,而且可能会有hash冲突,导致数据出错!】

首先可以把一个亿的url均分成几个小文件,然后利用内存对这几个小文件里的url进行排序。最后再通过内存,把多个小文件中的有序串聚合成一个大文件中的有序串。

海量视频,求播放量前K个

求最大解的TopK,用小顶堆:加入元素时,若元素值比小顶堆的堆顶还小,则不加入。

求最小解的TopK,用大顶堆:加入元素时,若元素值比大顶堆的堆顶还大,则不加入。

100亿个数求中位数

先得到这100个亿数的最大值和最小值,然后得到mid。根据mid把100亿个数分到两个文件中,那么中位数就在数量多的那个文件中。循环此过程即可。

或:根据每个数的最高位是0/1分文件,第一次分出正负,取数量多的文件。之后就分大小,去数量多直至得到。

或:快排,随机数分左小右大,则中位数一定在多的那一半。递归此过程

给定100万个数,每个数在[-100,100]之间,请按大小的顺序输出它们。

100万个数是可以拉到内存中的,所以可以用hashMap计数排序,key为-100100,value为数量;若拉不到内存,则可以用普通的外部桶排序:在-100100区间中有序创建几个桶文件,直至每个区间都能拉到内存中,然后对每个区间分别快排并写入磁盘中,然后按序合并这几个桶文件即可。

大文件如何平均地分到每个桶中

多线程?

合并10个文件,每个小文件中的数据都是有序的,合并成一个有序大文件。

起10个io流,每个IO流读取对应文件的第一个数据放到小顶堆中,取堆顶写入大文件中,然后对应堆顶元素的IO流继续拿下一个数据放入堆中。时间复杂度O(n)。