数据结构和算法
Contents
💠
💠 2024-12-04 16:30:24
数据结构和算法
数据结构是指一组数据的存储结构 算法就是操作数据的方法 数据结构和算法是相辅相成的,数据结构是为算法服务的,而算法要作用在特定的数据结构之上
相关书籍和资源
-
大话数据结构
-
算法图解 Github
-
算法的乐趣
-
算法导论(英文原版更好) 麻省理工有公开课
-
数据结构与算法分析 C C++ Java 三种语言版本
-
数据结构 严蔚敏
-
数据结构与算法解析 高一凡
-
剑指Offer
-
编程珠玑 对大量数据问题的算法研究
-
编程之美 微软面试官所著,难度较高
-
计算机程序设计艺术
-
算法帝国
-
数学之美
-
算法之美
王争的课程对应源码
这里主要也是他的课程内容
Github:TheAlgorithms有各种编程语言的算法实现
《编程之法》
DataStructures.pdf notes.pdf notes data structures.pdf Data structure.pdf algorithms
离散数学基础: 集合、偏序集、良序、数学归纳法、级数、递归、递推 概率基础: 随机分布、概率、伯努利实验、数学期望、期望值的线性率
- 常用数据结构:
- 数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie树
- 常用算法
- 递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法。
Algorithms Java实现的算法库
基础概念
算法的重要特征
- 有穷性:保证执行有限步骤之后结束
- 确切性:每一步骤都有确切的定义
- 输入:每个算法有一个或多个输入,以刻画运算对象的初始情况。所谓零个输入是指算法本身舍弃了初始条件
- 输出:每个算法有一个或多个输出,显示对应输入数据加工后的结果。没有输出的算法是毫无意义的
- 可行性:在原则上算法能够精确地运行,进行有限次运算即可完成一次运算
时间复杂度
所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比。
- T(n) = O(f(n))
- T(n) 表示代码执行的时间;
- n 表示数据规模的大小;
- f(n) 表示每行代码执行的次数总和。
因为这是一个公式,所以用 f(n) 来表示。公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。
大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity) 简称时间复杂度
时间复杂度分析
- 只关注循环执行次数最多的代码
- 加法原则: 相加时的结果是: 总复杂度等于量级最大的那段代码的复杂度
- 乘法原则: 嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
- 常见的时间复杂度
- 常量阶 O(1)
- 对数阶 O(log n)
- 线性阶 O(n)
- 线性对数阶 O(n log n)
- 平方阶 O(n^2) 立方阶O(n^3 ) k次方阶 O(n^k)
用 ^ 表示次方
- 指数阶 O(2^n)
- 阶乘阶 O(n!)
O(1)
一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)
O(logn)、O(nlogn)
|
|
对数之间是可以互相转换的,log3 n 就等于 log3 2 * log2 n
,所以 O(log3 n) = O(C * log2 n)
,其中 C=log3 2 是一个常量。
基于我们前面的一个理论:在采用大 O 标记复杂度的时候,可以忽略系数,即 O(Cf(n)) = O(f(n))
所以,O(log2 n) 就等于 O(log3 n)。因此,在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为 O(logn)。
O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)。
- 个人看法: 当循环中的循环变量的增长是以指数形式增长, 从而达到循环退出条件, 那么时间复杂度就是对数形式的
O(m+n)、O(m*n)
时间复杂度由两个数据的规模来决定, 原来的加法法则需要改为 T1(m) + T2(n) = O(f(m) + g(n)), 不清楚 m n 大小关系, 所以只能同时评估
四个复杂度分析方面的知识点,最好情况时间复杂度(best case time complexity)、最坏情况时间复杂度(worst case time complexity)、平均情况时间复杂度(average case time complexity)、均摊时间复杂度(amortized time complexity)。
为了表示代码在不同情况下的不同时间复杂度,我们需要引入三个概念:最好情况时间复杂度,最坏情况时间复杂度和平均情况时间复杂度
最好最坏时间复杂度
|
|
顾名思义,最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度。就像我们刚刚讲到的,在最理想的情况下,要查找的变量x正好是数组的第一个元素,这个时候对应的时间复杂度就是最好情况时间复杂度 O(1)。
同理,最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。就像刚举的那个例子,如果数组中没有要查找的变量x,我们需要把整个数组都遍历一遍才行,所以这种最糟糕情况下对应的时间复杂度就是最坏情况时间复杂度 O(n)。
平均时间复杂度
要查找的变量 x 在数组中的位置,有 n+1 种情况:在数组的 0~n-1 位置中和不在数组中
。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1,就可以得到需要遍历的元素个数的平均值
(1+2+3+…+n+n) / (n+1) = n(n+3) / 2(n+1)
时间复杂度的大 O 标记法中,可以省略掉系数、低阶、常量,所以,咱们把刚刚这个公式简化之后,得到的平均时间复杂度就是 O(n)。
我们知道,要查找的变量 x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便, 假设在数组中与不在数组中的概率都为 1/2。
另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。
所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)。
因此,前面的推导过程中存在的最大问题就是,没有将各种情况发生的概率考虑进去。如果我们把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样:
1 * 1/2n + 2 * 1/2n + n * 1/2n + 1/2 * n = (3n+1)/4
这个值就是概率论中的加权平均值
,也叫作期望值
,所以平均时间复杂度的全称应该叫加权平均时间复杂度
或者期望时间复杂度
。
引入概率之后,前面那段代码的加权平均值为 (3n+1)/4。用大 O 表示法来表示,去掉系数和常量,这段代码的加权平均时间复杂度仍然是 O(n)。
你可能会说,平均时间复杂度分析好复杂啊,还要涉及概率论的知识。实际上,在大多数情况下,我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。
很多时候,我们使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。
均摊时间复杂度
|
|
这段代码实现了一个往数组中插入数据的功能。当数组满了之后,也就是代码中的 count == array.length 时,我们用 for 循环遍历数组求和,并清空数组,
将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。
最理想的情况下,数组中有空闲空间,我们只需要将数据插入到数组下标为 count 的位置就可以了,所以最好情况时间复杂度为 O(1)。
最坏的情况下,数组中没有空闲空间了,我们需要先做一次数组的遍历求和,然后再将数据插入,所以最坏情况时间复杂度为 O(n)。
假设数组的长度是 n,根据数据插入的位置的不同,我们可以分为 n 种情况,每种情况的时间复杂度是 O(1)。
除此之外,还有一种“额外”的情况,就是在数组没有空闲空间时插入一个数据,这个时候的时间复杂度是 O(n)。
而且,这 n+1 种情况发生的概率一样,都是 1/(n+1)。所以,根据加权平均的计算方法,我们求得的平均时间复杂度就是
1 * 1/(n+1) + 1 * 1/(n+1)+…+ n * 1/(n+1) = O(1)
首先,find() 函数在极端情况下,复杂度才为 O(1)。但 insert() 在大部分情况下,时间复杂度都为 O(1)。只有个别情况下,复杂度才比较高,为 O(n)。这是 insert()第一个区别于 find() 的地方。
我们再来看第二个不同的地方。对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。
所以,针对这样一种特殊场景的复杂度分析,我们并不需要像之前讲平均复杂度分析方法那样,找出所有的输入情况及相应的发生概率,然后再计算加权平均值。
针对这种特殊的场景,我们引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度我们起了一个名字,叫均摊时间复杂度。
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。
均摊时间复杂度就是一种特殊的平均时间复杂度,我们没必要花太多精力去区分它们。你最应该掌握的是它的分析方法,摊还分析.
|
|
最好是O(1),最差是O(n), 均摊是O(1)
空间复杂度
类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。
基础数据结构
线性表
数组, 链表, 队列, 栈
数组
一组连续的内存空间,存放一组相同数据类型的数据, 由于CPU访问存储器的局部性原理, 所以数组比链表高效
访问: 根据下标任意访问的时间复杂度为O(1), 插入: 从最好O(1) 最坏O(n) 平均O(n) 删除: 从最好O(1) 最坏O(n) 平均O(n)
多次删除集中在一起,提高删除效率 记录下已经被删除的数据,每次的删除操作并不是搬移数据,只是记录数据已经被删除,当数组没有更多的存储空间时,再触发一次真正的删除操作。即JVM标记清除垃圾回收算法。
|
|
以上代码会死循环执行, 栈设计采用小端模式的系统才会出现
例子中死循环的问题跟编译器分配内存和字节对齐有关 数组3个元素 加上一个变量a 。4个整数刚好能满足8字节对齐 所以i的地址恰好跟着a2后面 导致死循环
如果数组长度为4 且循环5次 则这里不会出现死循环。因为编译器64位操作系统下 默认会进行8字节对齐 变量i的地址就不紧跟着数组后面了。
存疑1: 当长度为4 无法解释. 压栈顺序: i,a[2],a[1],a[0], 所以 a[3]访问到的是i
存疑2: gcc有一个编译选项(-fno-stack-protector)用于关闭堆栈保护功能。默认情况下启动了堆栈保护,不管i声明在前还是在后,i都会在数组之后压栈,只会循环4次;如果关闭堆栈保护功能,则会出现死循环.
容器和数组
众多语言都有对数组封装好的容器类
Java 中 ArrayList 和 数组的对比
优势
- 对数组操作的细节进行封装,
- 支持动态扩容, 空间不够会扩容为原大小的1.5倍 劣势
- ArrayList 无法存放基础数据类型, 只能用包装类型, 这样就涉及到了 自动拆装箱,影响性能
- 表示二维结构数据时, 没有数组直观
最好在创建 ArrayList的时候设置好初始大小,避免不必要的数据搬运 对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。
链表
单链表 双向链表 循环链表
数组和链表对比
操作\时间复杂度 | 数组 | 链表 |
---|---|---|
插入/删除 | O(n) | O(1) |
随机访问 | O(1) | O(n) |
分治算法 动态规划 最值 极值 不直接找问题, 而是根据你的输入, 和答案之前关系的规律
柯里化, continuation 高阶函数, 尾递归
树
Radix 树 这是一种基于二进制表示的键值的查找树,尤其适合处理非常长的、可变长度的键值
并查集 DSU disjoint-set-union
Disjoint Set Union Data Structure | Baeldung on Computer Science
java - 并查集算法 - Algorithms, Part I, week 1 UNION-FIND - COURSERA-算法公开课-普林斯顿 - SegmentFault 思否
匹配算法
字符串相似度
字符串相似度计算:
- Levenshtein算法 编辑距离
- 三角比较算法
- Diff Match Patch
- 余弦相似度 > 使用余弦相似度算法计算文本相似度 - 广告流程自动化
向量相似度
欧氏距离,余弦相似度,修正余弦相似度
排序算法
搜索算法
Trie
字典树
哈希算法 Hash
也称 散列法 关键字地址计算法
基本思想: 在元素的关键字 k 和元素的存储位置 p 之间建立一个对应关系 H, 使得 p = H(k),则 H 被称为哈希函数
-
在创建哈希表时, 把关键字为 k 的元素放到地址为 H(k) 的单元
-
在查找时则根据关键字 k 计算出地址, 直接获取到元素, 时间复杂度达到 O(1)
-
MD5
-
SHA
- SHA家族算法有SHA-1、SHA-224、SHA-256、SHA-384和SHA-512(后四者通常并称SHA2)
-
CRC
- 循环冗余校验, CRC32(12、16、32等值均是指多项式的最高阶N次幂)
-
MurmurHash
- 是一种非加密型哈希函数,和其它流行哈希函数相比,对于规律性较强的 key 随机分布特性表现更良好,很多开源的软件项目使用(Redis,Memcached,Cassandra,HBase,Lucene)
-
Bcrypt
慢Hash函数
smhasher Hash函数性能测试对比
Hash函数 构造
设计哈希函数常考虑
- 哈希函数的时间复杂度
- 关键字长度
- 哈希表大小
- 关键字分布的情况
- 记录查找的频率
常见设计思路:
数字分析法
事先知道关键字集合,并且每个关键字的位数比哈希表的地址码位数多时, 可以从关键字中选出分布较均匀的若干位, 构成哈希地址
平方取中法
当无法确定关键字中哪几位分布较均匀时, 可以先求出关键字的平方值, 然后依据需要取平方值的中间几位作为哈希地址
因为平方运算后中间几位和关键字中每一位都相关,所以散列程度比较高
分段叠加法
按哈希表地址位数, 将关键字分成位数相等的几部分, 后面多余的部分可以截断, 然后将这几部分移位相加或者折叠相加, 求得哈希值
除留余数法
哈希表长为m, p为小于等于m的最大素数, 哈希函数则是 H(k)=k%p
该方法容易产生哈希冲突
伪随机数法
直接使用随机数函数 H(k)=random(k)
JDK中的HashMap实现方式
取键值的hashCode, 然后高低16位做异或运算, 然后再与哈希表大小
做与运算, 才得到哈希地址
HASH 冲突
参考: 一种高级的DoS攻击-Hash碰撞攻击 Application vulnerability due to Non Random Hash Functions
开放定址法
-
开放定址法有一个公式:
Hi=(H(key)+di) % m; i=1,2,...,k(k<=m-1)
- 其中 m 为哈希表的表长 di 是产生冲突的时候的
增量序列
- 其中 m 为哈希表的表长 di 是产生冲突的时候的
-
线性探查法
- 如果di值可能为1,2,3,…m-1, 顺序查看表单元, 直到找到空单元。
-
平方探测法
- 如果di取1,则每次冲突之后,向后移动1个位置. 如果di取值可能为
1,-1^2,2^2,-2^2,...k*k,-k*k (k<=m/2)
- 如果di取1,则每次冲突之后,向后移动1个位置. 如果di取值可能为
-
伪随机探测
- di取值是伪随机数列
优点
缺点
- 当冲突多的时候数据容易堆集在一起,这时候对查找不友好;
- 删除结点的时候不能简单将结点的空间置空,否则将截断在它填入散列表之后的同义词结点查找路径。因此如果要删除结点,只能在被删结点上添加删除标记,而不能真正删除结点;
- 如果哈希表的空间已经满了,还需要建立一个溢出表,来存入多出来的元素。
再 HASH 法
基本思想是构造多个不同的哈希函数, 当发生冲突时,使用第二个、第三个哈希函数计算地址,直到无冲突。
缺点
增加了时间复杂度
链地址/拉链 法
将所有哈希地址为 i 的元素构成一个同义词链
的单链表, 因此适用于频繁插入删除的情况
JDK中的HashMap(JDK8链表部分引入红黑树),Golang的map 均采用该方式
优点
- 处理冲突的方式简单,且无堆集现象,非同义词绝不会发生冲突,因此平均查找长度较短;
- 由于拉链法中各链表上的结点空间是动态申请的,所以它更适合造表前无法确定表长的情况;
- 删除结点操作易于实现,只要简单地删除链表上的相应的结点即可。
缺点
- 需要额外的存储空间
公共溢出区
- 假设哈希函数的值域为
[0,m-1]
, 则设向量HashTable[0..m-1]
为基本表,另外设立存储空间向量OverTable[0..v]
用以存储发生冲突的记录。
凡是和基本表发生冲突的元素一律填入溢出表
缺点
- 查找冲突数据的时候,需要遍历溢出表才能得到数据
安全相关
- HASH攻击,例如针对Java中HashMap的算法,通过构造特定的参数,使得HashMap退化为链表,浪费服务器资源
- 不同的参数进入Hash函数运行时间会不一致,理论上利用这一点可以提高爆破密码的准确性,所以JDK中实现尽量保证了hash时间均匀
密码学
Diffie-Hellman Key Exchange算法
Whitfield Diffie 和 Martin Hellman ,他们于2015年获得了计算机科学领域的最高奖:图灵奖
最后神奇的魔法发生了, 我们两个得到了同样的值 s = 10!
- 这个s 的值只有我们两个才知道, 其实就是密钥了, 可以用来做加密解密了( 当然,这只是一个例子,实际的密钥不会这么短), 我们俩的通讯从此就安全了。
- “数学家小帅哥说了, 原因很简单,(gx mod p)y mod p 和 (gy mod p)x mod p 是相等的! ”
- “那黑客不能从公开传输的 p = 17, g = 3, a = 6 , b = 12 推算出s = 10 吗?” 我问道。
- “当然不能, 不过前提是需要使用非常大的p , x, y, 这样以来,即使黑客动用地球上所有的计算资源, 也推算不出来。 ”
实际问题
例如存储一个部门关系, 上下级, 以及同级要有序, 并且, 这个关系树是能随意调整结构的, 每个节点和节点之间任意断开和连接
name/id, parent, index
斐波那契数列
斐波那契数列: 递归, 离散数学, 斐波那契博弈 数据结构: 斐波那契堆, 算法: 分治, 动态规划, 并行算法, 矩阵积, fft 操作系统 线程, 线程级并行, openmp
二分搜索: 斐波那契数列实际上体现的是自然界某些情形下更本质的平权意识,尤其是当表面上的「对半法」实际上并不能做到平权时。自然情况下,对半分不一定就是平权,而黄金分割才是更本质意义下的平权
mid = (hi + low) /2
=> mid = low + fib.get() - 1;
Author Kuangcp
LastMod 2018-11-21