场景 同一个网页链接被包含在多个页面中时,会导致爬虫在爬取的过程中,重复爬取相同的网页,如何避免这些重复的爬取呢? 解决方案 记录已经爬取过的URL,爬取新网页前查看是否爬取过 已爬取过则忽略 未爬取过则先爬取后记录 注意点: 查询和添加操作要高效 上亿的网页,消耗内存非常高,存储效率要高 满足上述需求的数据结构有: 散列表 红黑树 跳表 在支持快速查询和插入的同时需要关注内存的消耗。 假设有10亿网页,一条URL平均长度64byte,全部存储起来需要大约60GB的内存。散列表需要保证较低的装填因子,采用链表法则还需要保存指针,实际使用的存储空间可能需要超过100GB。大型搜索引擎会使用分治的思想将这100GB的数据保存在多台服务器上。 分治+散列表的思路可以实现,那么在添加、查询和内存消耗上是否还有优化空间? 注意:时间复杂度只是表示执行时间随数据规模的变化趋势,并不能度量在特定的数据规模下,代码执行时间的多少。在数据量很大的情况下,常数、系数和低阶的优化都能带来非常可观的收益。 性能优化 执行效率 分治+散列表思路中耗时的点在于:通过哈希函数定位到某个链表之后,还要依次比对每个链表中的 URL。 一方面,链表中的结点在内存中不是连续存储的,不能一下子加载到 CPU 缓存中,没法很好地利用到 CPU 高速缓存,所以数据访问性能方面会打折扣。 另一方面,链表中的每个数据都是 URL,而 URL 不是简单的数字,是平均长度为 64 字节的字符串,要让待判重的 URL,跟链表中的每个 URL,做字符串匹配。这样一个字符串匹配操作,比起单纯的数字比对,要慢很多。 内存消耗 在内存消耗方面的优化,可以将散列表更换为布隆过滤器。布隆过滤器基于位图,且对位图进行了改进。 位图 问题 如何在数据范围是1~1亿之间的1千万个整数中快速查找某个整数是否存在。 散列表可以解决,位图也可以解决。 解决方案 使用位图来解决。 申请一个大小为 1 亿、数据类型为布尔类型(true 或者 false)的数组 将这 1 千万个整数作为数组下标,将对应的数组值设置成 true,如整数 5 对应下标为 5 的数组值设置为 true(array[5]=true) 查询某个整数 K 是否存在,只要将对应的数组值 array[K] 取出来判断是否为true 如果等于 true,说明存在整数 K 如果不等于true,说明不存在整数 K 注意:很多编程语言中提供的布尔类型,大小是 1 byte,并不能节省太多内存空间。实际上,表示 true 和 false 两个值,只需要用一个二进制位(bit)就可以。 如何通过编程语言,来表示一个二进制位(bit)可以使用位运算。 借助编程语言中提供的数据类型,比如 int、long、char 等类型,通过位运算,用其中的某个位表示某个数字。如下示例: // byte占1个字节,8bit type BitMap struct { bytes []byte nbits int } func NewBitMap(bits int) *BitMap { return &BitMap{ bytes: make([]byte, bits/8+1), nbits: bits, } } func (b *BitMap) set(k int) { if k > b.nbits { return } byteIndex := k / 8 bitIndex := k % 8 b.bytes[byteIndex] |= 1 << bitIndex } func (b *BitMap) get(k int) bool { if k > b.nbits { return false } byteIndex := k / 8 bitIndex := k % 8 return (b.bytes[byteIndex] & (1 << bitIndex)) != 0 } 位图通过数组下标来定位数据,所以访问效率非常高 每个数字用一个二进制位(bit)表示,在数字范围不大的情况下,需要的内存空间非常节省 数据范围是1~1亿之间的1千万数据: 散列表存储:每个数据占32位(4个字节),总共有1千万个数据需要大约40MB 位图存储:每个数据占1位,数据最大范围1亿需要大约12.5MB 如果数据的范围更大,是1~10亿,数据量还是1千万,那么位图存储的空间是125MB,这就远大于散列表需要的存储空间。 这是就需要布隆过滤器来解决数据范围非常大这个问题了。 布隆过滤器 数据范围是1~10亿之间的1千万数据,使用布隆过滤器的处理策略是: 创建一个 1 亿个二进制大小的位图 通过哈希函数(如f(x)=x%n,n表示位图大小,即对数字跟位图的大小进行取模求余),对数字进行处理,让它落在这 1 到 1 亿范围内 注意:哈希会存在冲突,如一亿零一和 1 两个数字最终都存储在1这个位置。为了降低这种冲突概率,可以设计一个复杂点、随机点的哈希函数。 布隆过滤器的处理方法是:一个哈希函数可能会存在冲突,用多个哈希函数一块儿定位一个数据,来降低冲突的概率。 使用 K 个哈希函数,对同一个数字进行求哈希值,那会得到 K 个不同的哈希值,分别记作 X1,X2,X3,…,XK 把这 K 个数字作为位图中的下标,将对应的 BitMap[X1],BitMap[X2],BitMap[X3],…,BitMap[XK]都设置成 true,用 K 个二进制位,来表示一个数字的存在 查询某个数字是否存在的时候,用同样的 K 个哈希函数,对这个数字求哈希值,分别得到 Y1,Y2,Y3,…,YK 看这 K 个哈希值,对应位图中的数值是否都为 true 如果都是 true,则说明这个数字存在 如果其中任意一个不为 true,说明这个数字不存在 对于两个不同的数字来说,经过一个哈希函数处理,可能会产生相同的哈希值。但是经过 K 个哈希函数处理之后,K 个哈希值都相同的概率就非常低了。 尽管采用 K 个哈希函数之后,两个数字哈希冲突的概率降低了,但是,这种处理方式又带来了新的问题,那就是容易误判。 bloom filter: False is always false. True is maybe true. 布隆过滤器只会对存在有误判: 如果某个数字经过布隆过滤器判断不存在,那这个数字真的不存在,不会误判 如果某个数字经过布隆过滤器判断存在,这时有可能误判,可能数字并不存在 只要调整哈希函数的个数、位图大小跟要存储数字的个数之间的比例,就可以将这种误判的概率降到非常低。 布隆过滤器的误判率,主要跟哈希函数的个数、位图的大小有关。 往布隆过滤器中不停地加入数据之后,位图中不是 true 的位置就越来越少了,误判率就越来越高。所以,对于无法事先知道要判重的数据个数的情况,需要支持自动扩容的功能 当布隆过滤器中,数据个数与位图大小的比例超过某个阈值,就重新申请一个新的位图。后面来的新数据,会被放置到新的位图中 如果要判断某个数据是否在布隆过滤器中已经存在,就需要查看多个位图,相应的执行效率就降低了一些 一般不用来删除,如果非要支持删除,可以再弄个数据结构记录删除的数据。 应用 尽管布隆过滤器会存在误判,但是,这并不影响它发挥大作用。很多场景对误判有一定的容忍度。 比如要解决的爬虫判重这个问题,即便一个没有被爬取过的网页,被误判为已经被爬取,对于搜索引擎来说,也并不是什么大事情,是可以容忍的,毕竟网页太多了,搜索引擎也不可能 100% 都爬取到。 布隆过滤器非常适合这种不需要 100% 准确的、允许存在小概率误判的大规模判重场景。 比如,统计一个大型网站的每天的 UV(Unique Visitor,独立访客) 数,就可以使用布隆过滤器,对重复访问的用户进行去重。 存储优化 使用布隆过滤器解决爬虫去重问题: 用布隆过滤器来记录已经爬取过的网页链接 假设需要判重的网页有 10 亿,那可以用一个 10 倍大小的位图(100 亿个二进制位,约 1.2GB)来存储 用散列表判重,需要至少 100GB 的空间 相比来讲,布隆过滤器在存储空间的消耗上,降低了非常多 执行优化 布隆过滤器用多个哈希函数对同一个网页链接进行处理 CPU 只需要将网页链接从内存中读取一次,进行多次哈希计算,理论上讲这组操作是 CPU 密集型的 在散列表的处理方式中,需要读取散列值相同(散列冲突)的多个网页链接,分别跟待判重的网页链接,进行字符串匹配。这个操作涉及很多内存数据的读取,所以是内存密集型的 CPU 计算要比内存访问更快速,所以,理论上布隆过滤器的判重方式,更加快速 工业实现 Java 中的 BitSet 类是一个位图 Redis 提供了 BitMap 位图类 Google 的 Guava 工具包提供了 BloomFilter 布隆过滤器的实现
0.1. 贪心算法 0.1.1. 例子 0.1.2. 定义 0.1.3. 实战 0.1.3.1. 分糖果 0.1.3.2. 钱币找零 0.1.3.3. 区间覆盖 0.1.4. 霍夫曼编码 0.2. 分治算法 0.2.1. 分治算法应用 0.2.2. 分治算法海量数据应用 0.3. 回溯算法 0.3.1. 经典应用 0.3.1.1. 0-1背包 0.3.1.2. 正则表达式 0.4. 动态规划 0.4.1. 动态规划问题模型 0.4.1.1. 0-1背包问题 0.4.1.1.1. 回溯解题 0.4.1.1.2. 动态规划解题 0.4.1.2. -1背包问题升级版 0.4.2. 动态规划理论 0.4.2.1. 具体问题 0.4.3. 动态规划解题思路 0.4.3.1. 状态转移表法 0.4.3.2. 状态转移方程法 0.4.4. 动态规划实战 0.4.4.1. 编程计算莱文斯坦距离 0.4.4.2. 编程计算最长公共子串长度 0.5. 四种算法比较 0.5.1. 回溯 0.5.2. 动态规划 0.5.3. 分治 0.5.4. 贪心 0.1. 贪心算法 贪心算法有很多经典的应用,比如: 霍夫曼编码(Huffman Coding) Prim Kruskal 最小生成树算法 Dijkstra 单源最短路径算法 0.1.1. 例子 假设有一个可以容纳 100kg 物品的背包,可以装各种物品。 有以下 5 种豆子,每种豆子的总量和总价值都各不相同。 为了让背包中所装物品的总价值最大,我们如何选择在背包中装哪些豆子?每种豆子又该装多少呢? 物品 总量(KG) 总价值(元) 黄豆 100 100 绿豆 30 90 红豆 60 120 黑豆 20 80 青豆 50 75 先算一算每个物品的单价,按照单价由高到低依次来装 单价从高到低排列,依次是:黑豆、绿豆、红豆、青豆、黄豆 所以往背包里装 20kg 黑豆、30kg 绿豆、50kg 红豆 这个问题的解决思路本质上就是贪心算法。 0.1.2. 定义 第一步,当看到这个问题的时候,首先要联想到贪心算法:针对一组数据(5种豆子),定义了限制值(100kg)和期望值(总价值),希望从中选出几个数据,在满足限制值的情况下,期望值最大。 第二步,尝试下这个问题是否可以用贪心算法解决:每次选择当前情况下,在对限制值同等贡献量(重量相同)的情况下,对期望值(总价值)贡献最大的数据。 第三步,举几个例子看贪心算法产生的结果是否是最优。大部分情况下,举几个例子验证一下就可以了。严格地证明贪心算法的正确性,是非常复杂的,需要涉及比较多的数学推理。而且,从实践的角度来说,大部分能用贪心算法解决的问题,贪心算法的正确性都是显而易见的,也不需要严格的数学推导证明。 实际上,用贪心算法解决问题的思路,并不总能给出最优解。 在一个有权图中,从顶点 S 开始,找一条到顶点 T 的最短路径(路径中边的权值和最小)。 贪心算法的解决思路是,每次都选择一条跟当前顶点相连的权最小的边,直到找到顶点 T。 按照这种思路,求出的最短路径是 S->A->E->T,路径长度是 1+4+4=9。 但是,这种贪心的选择方式,最终求的路径并不是最短路径,因为路径 S->B->D->T 才是最短路径,路径的长度是 2+2+2=6。 在这个问题上,贪心算法不工作的主要原因是,前面的选择,会影响后面的选择。如果第一步从顶点 S 走到顶点 A,那接下来面对的顶点和边,跟第一步从顶点 S 走到顶点 B,是完全不同的。所以,即便第一步选择最优的走法(边最短),但有可能因为这一步选择,导致后面每一步的选择都很糟糕,最终也就无缘全局最优解了。 0.1.3. 实战 0.1.3.1. 分糖果 有 m 个糖果和 n 个孩子,现在要把糖果分给这些孩子吃,但是糖果少,孩子多(m<n),所以糖果只能分配给一部分孩子。每个糖果的大小不等,这 m 个糖果的大小分别是 s1,s2,s3,……,sm。除此之外,每个孩子对糖果大小的需求也是不一样的,只有糖果的大小大于等于孩子的对糖果大小的需求的时候,孩子才得到满足。假设这 n 个孩子对糖果大小的需求分别是 g1,g2,g3,……,gn。如何分配糖果,能尽可能满足最多数量的孩子? 把这个问题抽象成: 一组数据:从 n 个孩子中,抽取一部分孩子分配糖果 限制值:糖果个数 m 期望值:让满足的孩子的个数是最大的 用贪心算法来解决。 对于一个孩子来说,如果小的糖果可以满足,就没必要用更大的糖果,这样更大的就可以留给其他对糖果大小需求更大的孩子。对糖果大小需求小的孩子更容易被满足,所以从需求小的孩子开始分配糖果。因为满足一个需求大的孩子跟满足一个需求小的孩子,对期望值的贡献是一样的。 每次从剩下的孩子中,找出对糖果大小需求最小的,然后发给他剩下的糖果中能满足他的最小的糖果,这样得到的分配方案,也就是满足的孩子个数最多的方案。 0.1.3.2. 钱币找零 假设有 1 元、2 元、5 元、10 元、20 元、50 元、100 元这些面额的纸币,它们的张数分别是 c1、c2、c5、c10、c20、c50、c100。现在要用这些钱来支付 K 元,最少要用多少张纸币呢? 把这个问题抽象成: 一组数据:若干纸币 限制值:K元 期望值:纸币数量最少 用贪心算法来解决。在贡献相同期望值的情况下,希望多贡献金额。 每次选择小于K元的面额的纸币中面额最大的 选择该面额若干张,保证面额总和小于K 对剩余的值继续上述两个操作 0.1.3.3. 区间覆盖 假设有 n 个区间,区间的起始端点和结束端点分别是[l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。从这 n 个区间中选出一部分区间,这部分区间满足两两不相交(端点相交的情况不算相交),最多能选出多少个区间呢? 把这个问题抽象成: 一组数据:n个区间 限制值:区间不相交 期望值:区间数量最多 用贪心算法来解决。 假设这 n 个区间中最左端点是 lmin,最右端点是 rmax。这个问题就相当于选择几个不相交的区间,从左到右将[lmin, rmax]覆盖上 按照起始端点从小到大的顺序对这 n 个区间排序 每次选择左端点跟前面的已经覆盖的区间不重合的,右端点又尽量小的,这样可以让剩下的未覆盖区间尽可能的大,就可以放置更多的区间 0.1.4. 霍夫曼编码 霍夫曼编码,利用贪心算法来实现对数据压缩编码,有效节省数据存储空间。 假设有一个包含 1000 个字符的文件,每个字符占 1 个 byte(1byte=8bits),存储这 1000 个字符就一共需要 8000bits,那有没有更加节省空间的存储方式呢? 假设通过统计分析发现,这 1000 个字符中只包含 6 种不同字符,假设它们分别是 a、b、c、d、e、f 而 3 个二进制位(bit)就可以表示 8 个不同的字符,所以,为了尽量减少存储空间,每个字符用 3 个二进制位来表示 存储这 1000 个字符只需要 3000bits 就可以了,比原来的存储方式节省了很多空间 a(000)、b(001)、c(010)、d(011)、e(100)、f(101) 还有更加节省空间的存储方式,霍夫曼编码,一种十分有效的编码方法,广泛用于数据压缩中,其压缩率通常在 20%~90% 之间。 霍夫曼编码不仅会考察文本中有多少个不同字符,还会考察每个字符出现的频率,根据频率的不同,选择不同长度的编码。 霍夫曼编码试图用这种不等长的编码方法,来进一步增加压缩的效率。 如何给不同频率的字符选择不同长度的编码呢? 把这个问题抽象成: 一组数据:给定的文本 限制值:单个字符的长度 期望值:存储空间最小 用贪心算法来解决。 把出现频率比较多的字符,用稍微短一些的编码 把出现频率比较少的字符,用稍微长一些的编码 对于等长的编码来说,解压缩起来很简单。比如上面例子中abcdef用 3 个 bit 表示一个字符。在解压缩的时候,每次从文本中读取 3 位二进制码,然后翻译成对应的字符。 霍夫曼编码是不等长的,每次应该读取读取的位数无法确定,这个问题就导致霍夫曼编码解压缩起来比较复杂。为了避免解压缩过程中的歧义,霍夫曼编码要求各个字符的编码之间,不会出现某个编码是另一个编码前缀的情况。 假设这 6 个字符出现的频率从高到低依次是 a、b、c、d、e、f。 编码成如下表格,任何一个字符的编码都不是另一个的前缀,在解压缩的时候,每次会读取尽可能长的可解压的二进制串,所以在解压缩的时候也不会歧义。 经过这种编码压缩之后,这 1000 个字符只需要 2100bits 就可以了。 字符 出现频率 编码 总二进制位数 a 450 1 450 b 350 01 700 c 90 001 270 d 60 0001 150 f 20 00000 100 霍夫曼编码中,如何根据字符出现频率的不同,给不同的字符进行不同长度的编码,这里的处理稍微有些技巧。 把每个字符看作一个节点,并且附带着把频率放到优先级队列中 从队列中取出频率最小的两个节点 A、B,然后新建一个节点 C(频率小的放在左边) 把频率设置为两个节点的频率之和,并把这个新节点 C 作为节点 A、B 的父节点 再把 C 节点放入到优先级队列中 重复这个过程,直到队列中没有数据 给每一条边加上一个权值: 指向左子节点的边统统标记为 0 指向右子节点的边,统统标记为 1 从根节点到叶节点的路径就是叶节点对应字符的霍夫曼编码。 实际上,贪心算法适用的场景比较有限。这种算法思想更多的是指导设计基础算法。比如最小生成树算法、单源最短路径算法,这些算法都用到了贪心算法。 0.2. 分治算法 分治算法(divide and conquer)的核心思想其实就是四个字,分而治之,将原问题划分成 n 个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。 分治算法是一种处理问题的思想,递归是一种编程技巧。实际上,分治算法一般都比较适合用递归来实现。分治算法的递归实现中,每一层递归都会涉及这样三个操作: 分解:将原问题分解成一系列子问题 解决:递归地求解各个子问题,若子问题足够小,则直接求解 合并:将子问题的结果合并成原问题 分治算法能解决的问题,一般需要满足下面这几个条件: 原问题与分解成的小问题具有相同的模式 原问题分解成的子问题可以独立求解,子问题之间没有相关性(这是分治与动态规划的明显区别) 具有分解终止条件,当问题足够小时,可以直接求解 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果 0.2.1. 分治算法应用 使用分治算法解决排序问题。在排序算法中,用有序度来表示一组数据的有序程度,用逆序度表示一组数据的无序程度。 假设有 n 个数据,期望数据从小到大排列,那完全有序的数据的有序度就是 n(n-1)/2,逆序度等于 0;相反,倒序排列的数据的有序度就是 0,逆序度是 n(n-1)/2。除了这两种极端情况外,通过计算有序对或者逆序对的个数,来表示数据的有序度或逆序度,如何编程求出一组数据的有序对个数或者逆序对个数。 解法一:将每个数字与后面的数字比较,看有几个比它小,记作k,依次类推,每个数都考察一遍,将k值球和,得到逆序对个数。时间复杂度\(O(n^2)\)。 解法二:分治算法,将数组分为前后两部分(A1,A2),分别求逆序对个数(k1,k2),再计算A1和A2之间的逆序对个数k3,求和k1+k2+k3。 分治算法的一个要求是,子问题合并的代价不能太大,否则就起不了降低时间复杂度的效果了。 解法二中如何快速计算出两个子问题 A1 与 A2 之间的逆序对个数呢?可以使用归并排序。归并排序中非常关键的操作,就是将两个有序的小数组,合并成一个有序的数组。实际上,在这个合并的过程中,就可以计算这两个小数组的逆序对个数了。每次合并操作,都计算逆序对个数,把这些计算出来的逆序对个数求和,就是这个数组的逆序对个数了。 0.2.2. 分治算法海量数据应用 分治算法思想的应用是非常广泛的: 不仅限于指导编程和算法设计 还经常用在海量数据处理的场景中 大部分数据结构和算法都是基于内存存储和单机处理。 但是,如果要处理的数据量非常大,没法一次性放到内存中,这个时候,这些数据结构和算法就无法工作了。 比如,给 10GB 的订单文件按照金额排序这样一个需求,看似是一个简单的排序问题,但是因为数据量大,有 10GB,而单机的内存可能只有 2、3GB,无法一次性加载到内存,也就无法通过单纯地使用快排、归并等基础算法来解决了。 要解决数据量大到内存装不下的问题,利用分治的思想。 将海量的数据集合根据某种方法,划分为几个小的数据集合 每个小的数据集合单独加载到内存来解决 再将小数据集合合并成大数据集合 实际上,利用这种分治的处理思路,不仅仅能克服内存的限制,还能利用多线程或者多机处理,加快处理的速度。 上面的例子,先扫描一遍订单,根据订单的金额,将 10GB 的文件划分为几个金额区间。 比如订单金额为 1 到 100 元的放到一个小文件 101 到 200 之间的放到另一个文件 以此类推 每个小文件都可以单独加载到内存排序,最后将这些有序的小文件合并,就是最终有序的 10GB 订单数据了。 如果订单数据存储在类似 GFS 这样的分布式系统上,当 10GB 的订单被划分成多个小文件的时候,每个文件可以并行加载到多台机器上处理,最后再将结果合并在一起,这样并行处理的速度也加快了很多。但是,注意:数据的存储与计算所在的机器是同一个或者在网络中靠的很近(比如一个局域网内,数据存取速度很快),否则就会因为数据访问的速度,导致整个处理过程不但不会变快,反而有可能变慢。 实际上: MapReduce 框架只是一个任务调度器 底层依赖 GFS 来存储数据 依赖 Borg 管理机器 它从 GFS 中拿数据,交给 Borg 中的机器执行,并且时刻监控机器执行的进度,一旦出现机器宕机、进度卡壳等,就重新从 Borg 中调度一台机器执行。 可以用来处理数据与数据之间存在关系的任务,比如 MapReduce 的经典例子,统计文件中单词出现的频率 可以用来处理数据与数据之间没有关系的任务,比如对网页分析、分词等,每个网页可以独立的分析、分词,而这两个网页之间并没有关系 0.3. 回溯算法 回溯算法在软件开发场景中的应用: 深度优先搜索 正则表达式匹配 编译原理中语法分析 回溯算法在数学中的应用: 数独 八皇后 0-1背包 图的着色 旅行商问题 全排列 笼统地讲,回溯算法很多时候都应用在“搜索”这类问题上。这里的搜索,并不是狭义的指图的搜索算法,而是广义上的在一组可能的解中,搜索满足期望的解。 回溯的处理思想,有点类似枚举搜索。枚举所有的解,找到满足期望的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。 为了有规律地枚举所有可能的解,避免遗漏和重复,把问题求解的过程分为多个阶段。每个阶段,都会面对一个岔路口,先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。 举一个经典的回溯例子,八皇后问题。有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子。 第一幅图是满足条件的一种方法,第二幅图是不满足条件的。八皇后问题就是期望找到所有满足这种要求的放棋子方式。 把这个问题划分成 8 个阶段,依次将 8 个棋子放到第一行、第二行、第三行……第八行。 在放置的过程中,不停地检查当前放法,是否满足要求。 如果满足,则跳到下一行继续放置棋子; 如果不满足,那就再换一种放法,继续尝试。 回溯算法非常适合用递归代码实现。 0.3.1. 经典应用 0.3.1.1. 0-1背包 0-1 背包是非常经典的算法问题,很多场景都可以抽象成这个问题模型。 这个问题的经典解法是动态规划,另一种简单但没有那么高效的解法就是回溯算法。 有一个背包,背包总的承载重量是 Wkg。有 n 个物品,每个物品的重量不等,并且不可分割。现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大? 显然,这个问题已经无法通过贪心算法来解决了。 用回溯算法如何来解决。 对于每个物品来说,都有两种选择,装进背包或者不装进背包。 对于 n 个物品来说,总的装法就有 \(2^n\) 种,去掉总重量超过 Wkg 的,从剩下的装法中选择总重量最接近 Wkg 的。 如何才能不重复地穷举出这 \(2^n\) 种装法呢? 把物品依次排列,整个问题就分解为了 n 个阶段,每个阶段对应一个物品怎么选择。 先对第一个物品进行处理,选择装进去或者不装进去,然后再递归地处理剩下的物品。 这里还稍微用到了一点搜索剪枝的技巧,就是当发现已经选择的物品的重量超过 Wkg 之后,就停止继续探测剩下的物品。 0.3.1.2. 正则表达式 正则表达式里最重要的一种算法思想就是回溯。 正则表达式中,最重要的就是通配符,通配符结合在一起,可以表达非常丰富的语义。 假设正则表达式中只包含“*”和“?”这两种通配符,并且对这两个通配符的语义稍微做些改变,其中: “*”:匹配任意多个(大于等于 0 个)任意字符 “?”:匹配零个或者一个任意字符 用回溯算法,判断一个给定的文本,能否跟给定的正则表达式匹配? 依次考察正则表达式中的每个字符,当是非通配符时,直接跟文本的字符进行匹配 如果相同,则继续往下处理 如果不同,则回溯 如果遇到特殊字符,有多种处理方式了,也就是所谓的岔路口 比如“*”有多种匹配方案,可以匹配任意个文本串中的字符,就先随意的选择一种匹配方案,然后继续考察剩下的字符 如果中途发现无法继续匹配下去了,就回到这个岔路口,重新选择一种匹配方案 然后再继续匹配剩下的字符 0.4. 动态规划 动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低时间复杂度,提高代码的执行效率。 0.4.1. 动态规划问题模型 0.4.1.1. 0-1背包问题 对于一组不同重量、不可分割的物品,选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢? 使用回溯的解决方法,穷举搜索所有可能的装法,然后找出满足条件的最大值。但是,回溯算法的复杂度比较高,是指数级别的。 寻找规律从而有效降低时间复杂度。 0.4.1.1.1. 回溯解题 假设背包的最大承载重量是 9 有 5 个不同的物品,重量分别是 2,2,4,6,3 使用回溯求解过程,用递归树画出来,就是下面这个样子: 递归树中的每个节点表示一种状态,用(i,cw)来表示。其中: i 表示将要决策第几个物品是否装入背包 cw 表示当前背包中物品的总重量 比如,(2,2)表示将要决策第 2 个物品是否装入背包,在决策前,背包中物品的总重量是 2。 从递归树中可以看出,有些子问题的求解是重复的,比如图中 f(2, 2) 和 f(3,4) 都被重复计算了两次。 因此,可以使用缓存的方式来优化,记录已经计算好的 f(i,cw),当再次计算到重复的 f(i,cw) 的时候,可以直接从缓存中取出来结果,不用再递归计算了,这样就可以避免冗余计算。 使用缓存的解决方法非常好。它已经跟动态规划的执行效率基本上没有差别。 时间复杂度:\(O(2^n)\) 0.4.1.1.2. 动态规划解题 把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中 每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点 把每一层重复的状态(节点)合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合 通过合并每一层重复的状态,这样就保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量) 成功避免了每层状态个数的指数级增长。用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态 具体过程: 第 0 个(下标从 0 开始编号)物品的重量是 2,要么装入背包,要么不装入背包 决策完之后,会对应背包的两种状态,背包中物品的总重量是 0 或者 2 用 states[0][0]=true 和 states[0][2]=true 来表示这两种状态 第 1 个物品的重量也是 2,基于之前的背包状态,在这个物品决策完之后,不同的状态有 3 个,背包中物品总重量分别是 0(0+0),2(0+2 or 2+0),4(2+2) 用 states[1][0]=true,states[1][2]=true,states[1][4]=true 来表示这三种状态 以此类推 直到考察完所有的物品后,整个 states 状态数组就都计算好 把整个计算的过程画了出来,图中 0 表示 false,1 表示 true。只需要在最后一层,找一个值为 true 的最接近 w(这里是 9)的值,就是背包中物品总重量的最大值。 动态规划解决问题的思路:把问题分解为多个阶段,每个阶段对应一个决策。记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。 时间复杂度:O(n×w),n 表示物品个数,w 表示背包可以承载的总重量。 尽管动态规划的执行效率比较高,但是需要额外申请一个 n 乘以 w+1 的二维数组,对空间的消耗比较多。所以,动态规划是一种空间换时间的解决思路。 0.4.1.2. -1背包问题升级版 引入物品价值,对于一组不同重量、不同价值、不可分割的物品,选择将某些物品装入背包,在满足背包最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢? 这个问题依旧可以用回溯算法来解决。画出递归树。在递归树中,每个节点表示一个状态,需要 3 个变量(i, cw, cv)来表示一个状态。其中,i 表示即将要决策第 i 个物品是否装入背包,cw 表示当前背包中物品的总重量,cv 表示当前背包中物品的总价值。 在递归树中,有几个节点的 i 和 cw 是完全相同的,比如 f(2,2,4) 和 f(2,2,3)。在背包中物品总重量一样的情况下,f(2,2,4) 这种状态对应的物品总价值更大,可以舍弃 f(2,2,3) 这种状态,只需要沿着 f(2,2,4) 这条决策路线继续往下决策就可以。也就是说,对于 (i, cw) 相同的不同状态,那我们只需要保留 cv 值最大的那个,继续递归处理,其他状态不予考虑。 使用动态规划来解决这个问题,把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个阶段决策完之后,背包中的物品的总重量以及总价值,会有多种情况,也就是会达到多种不同的状态。 用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。不过这里数组存储的值是当前状态对应的最大总价值。把每一层中 (i,cw) 重复的状态(节点)合并,只记录 cv 值最大的那个状态,然后基于这些状态来推导下一层的状态。 0.4.2. 动态规划理论 一个模型:多阶段决策最优模型(用动态规划解决最优解问题,解决的过程经历多个决策阶段,每个决策阶段对应一组状态,我们寻找一组决策序列,能够阐释最终期望求解的最优值) 三个特征: 最优子结构:问题的最优解包含子问题的最优解(即通过子问题的最优解,推导出问题的最优解),也就是后面阶段的状态可以通过前面阶段的状态推导出来 无后效性: 在推导后面阶段的状态的时候,只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的 某阶段状态一旦确定,就不受之后阶段的决策影响 重复子问题:不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态 0.4.2.1. 具体问题 假设有一个 n 乘以 n 的矩阵 w[n][n]。 矩阵存储的都是正整数。 棋子起始位置在左上角,终止位置在右下角。 将棋子从左上角移动到右下角。每次只能向右或者向下移动一位。 从左上角到右下角,会有很多不同的路径可以走。 把每条路径经过的数字加起来看作路径的长度。那从左上角移动到右下角的最短路径长度是多少呢? 从 (0,0) 走到 (n-1,n-1),总共要走 2*(n-1) 步,也就对应着 2*(n-1) 个阶段。每个阶段都有向右走或者向下走两种决策,并且每个阶段都会对应一个状态集合。把状态定义为 min_dist(i, j),其中 i 表示行,j 表示列。min_dist 表达式的值表示从 (0,0) 到达 (i,j) 的最短路径长度。这个问题是一个多阶段决策最优解问题,符合动态规划的模型。 把从起始位置 (0,0) 到 (i,j) 的最小路径,记作 min_dist(i, j)。因为只能往右或往下移动,所以,只有可能从 (i,j-1) 或者 (i-1,j) 两个位置到达 (i,j)。也就是说,到达 (i,j) 的最短路径要么经过 (i,j-1),要么经过 (i-1,j),而且到达 (i,j) 的最短路径肯定包含到达这两个位置的最短路径之一。换句话说就是,min_dist(i, j) 可以通过 min_dist(i,j-1) 和 min_dist(i-1,j) 两个状态推导出来。这就说明,这个问题符合“最优子结构”。 如果走到 (i,j) 这个位置,只能通过 (i-1,j),(i,j-1) 这两个位置移动过来,也就是说,想要计算 (i,j) 位置对应的状态,只需要关心 (i-1,j),(i,j-1) 两个位置对应的状态,并不关心棋子是通过什么样的路线到达这两个位置的。而且,仅允许往下和往右移动,不允许后退,所以,前面阶段的状态确定之后,不会被后面阶段的决策所改变,所以,这个问题符合“无后效性”这一特征。 使用回溯算法来解决这个问题。画出递归树就会发现,递归树中有重复的节点。重复的节点表示,从左上角到节点对应的位置,有多种路线,说明这个问题中存在重复子问题。 min_dist(i, j) = w[i][j] + min(min_dist(i,j-1), min_dist(i-1,j)) 0.4.3. 动态规划解题思路 0.4.3.1. 状态转移表法 回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 - 将填表过程翻译成代码。 一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决。 当拿到问题的时候,先用简单的回溯算法解决,然后定义状态,每个状态表示一个节点,然后对应画出递归树 从递归树中,很容易看是否存在重复子问题,以及重复子问题是如何产生的 以此来寻找规律,看是否能用动态规划解决 找到重复子问题之后,有两种处理思路 第一种是直接用回溯加缓存的方法,来避免重复子问题。从执行效率上来讲,这跟动态规划的解决思路没有差别 第二种是使用动态规划的解决方法:状态转移表法 先画出一个状态表。状态表一般都是二维的,把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。 根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态 最后,将这个递推填表的过程,翻译成代码,就是动态规划代码 尽管大部分状态表都是二维的,但是如果问题的状态比较复杂,需要很多变量来表示,那对应的状态表可能就是高维的,比如三维、四维。那就不适合用状态转移表法来解决了。 一方面是因为高维状态转移表不好画图表示 另一方面因为人脑确实很不擅长思考高维的东西 如何用这个状态转移表法,来解决上面那个矩阵最短路径的问题。 从起点到终点,有很多种不同的走法。可以穷举所有走法,然后对比找出一个最短走法 如何才能无重复又不遗漏地穷举出所有走法,可以用回溯算法这个比较有规律的穷举算法 有了回溯代码,要画出递归树,以此来寻找重复子问题 在递归树中,一个状态(也就是一个节点)包含三个变量 (i, j, dist),其中 i,j 分别表示行和列,dist 表示从起点到达 (i,j) 的路径长度 从图中看出,尽管 (i, j, dist) 不存在重复的,但是 (i, j) 重复的有很多 对于 (i,j) 重复的节点,只需要选择 dist 最小的节点,继续递归求解,其他节点就可以舍弃了 既然存在重复子问题,就可以尝试用动态规划来解决。 画出一个二维状态表,表中的行、列表示棋子所在的位置,表中的数值表示从起点到这个位置的最短路径。按照决策过程,通过不断状态递推演进,将状态表填好。 0.4.3.2. 状态转移方程法 找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码。 状态转移方程法有点类似递归的解题思路。 分析某个问题如何通过子问题(最优子结构)来递归求解 根据最优子结构,写出递归公式(状态转移方程) 有了状态转移方程,代码实现就非常简单了,一般情况下,我们有两种代码实现方法 一种是递归加缓存 另一种是迭代递推 还是以上面的例子为例,状态转移方程min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))。 这里我强调一下,状态转移方程是解决动态规划的关键。 如果能写出状态转移方程,那动态规划问题基本上就解决一大半了,而翻译成代码非常简单 但是很多动态规划问题的状态本身就不好定义,状态转移方程也就更不好想到 0.4.4. 动态规划实战 量化两个字符串的相似度:编辑距离。 编辑距离:将一个字符串转化成另一个字符串,需要的最少编辑操作次数(比如增加一个字符、删除一个字符、替换一个字符)。编辑距离越大,说明两个字符串的相似程度越小;相反,编辑距离就越小,说明两个字符串的相似程度越大。对于两个完全相同的字符串来说,编辑距离就是 0。 根据所包含的编辑操作种类的不同,编辑距离有多种不同的计算方式,比较著名的有: 莱文斯坦距离(Levenshtein distance):允许增加、删除、替换字符这三个编辑操作;莱文斯坦距离的大小,表示两个字符串差异的大小 最长公共子串长度(Longest common substring length):允许增加、删除字符这两个编辑操作;而最长公共子串的大小,表示两个字符串相似程度的大小 两个字符串 mitcmu 和 mtacnu 它们的莱文斯坦距离是 3,最长公共子串长度是 4。 0.4.4.1. 编程计算莱文斯坦距离 这个问题是求把一个字符串变成另一个字符串,需要的最少编辑次数。整个求解过程,涉及多个决策阶段,依次考察一个字符串中的每个字符,跟另一个字符串中的字符是否匹配,匹配的话如何处理,不匹配的话又如何处理。所以,这个问题符合多阶段决策最优解模型(贪心、回溯、动态规划可以解决的问题,都可以抽象成这样一个模型)。 回溯是一个递归处理的过程: 如果 a[i]与 b[j]匹配,递归考察 a[i+1]和 b[j+1] 如果 a[i]与 b[j]不匹配,有多种处理方式可选: 可以删除 a[i],然后递归考察 a[i+1]和 b[j] 可以删除 b[j],然后递归考察 a[i]和 b[j+1] 可以在 a[i]前面添加一个跟 b[j]相同的字符,然后递归考察 a[i]和 b[j+1] 可以在 b[j]前面添加一个跟 a[i]相同的字符,然后递归考察 a[i+1]和 b[j] 可以将 a[i]替换成 b[j],或者将 b[j]替换成 a[i],然后递归考察 a[i+1]和 b[j+1] 根据回溯算法的实现画出递归树,看是否存在重复子问题。如果存在重复子问题,就可以考虑能否用动态规划来解决;如果不存在重复子问题,那回溯就是最好的解决方法。 在递归树中,每个节点代表一个状态,状态包含三个变量 (i,j,edist),其中,edist 表示处理到 a[i]和 b[j]时,已经执行的编辑操作的次数。 在递归树中,(i,j) 两个变量重复的节点很多,比如 (3, 2) 和 (2, 3)。对于 (i,j) 相同的节点,只需要保留 edist 最小的,继续递归处理就可以了,剩下的节点都可以舍弃。 所以,状态就从 (i,j,edist) 变成了 (i,j,min_edist),其中,min_edist 表示处理到 a[i]和 b[j],已经执行的最少编辑次数。 这个问题符合动态规划,但是,状态转移方式要复杂很多,状态 (i,j) 可能从 (i-1,j),(i,j-1),(i-1,j-1) 三个状态中的任意一个转移过来。基于刚刚的分析,把状态转移的过程,用公式写出来,就是状态转移方程。 // 如果:a[i]!=b[j],那么:min_edist(i,j)就等于: min(min_edist(i-1,j)+1, min_edist(i,j-1)+1, min_edist(i-1,j-1)+1) // 如果:a[i]==b[j],那么:min_edist(i,j)就等于: min(min_edist(i-1,j)+1, min_edist(i,j-1)+1,min_edist(i-1,j-1)) // 其中,min表示求三数中的最小值。 了解了状态与状态之间的递推关系,画出一个二维的状态表,按行依次来填充状态表中的每个值。 既有状态转移方程,又理清了完整的填表过程,代码实现就非常简单了。 关于复杂算法问题的解决思路的一些经验和技巧: 当拿到一个问题的时候,先不思考,计算机会如何实现这个问题,而是单纯考虑“人脑”会如何去解决这个问题。人脑比较倾向于思考具象化的、摸得着看得见的东西,不适合思考过于抽象的问题。 把抽象问题具象化,可以实例化几个测试数据,通过人脑去分析具体实例的解,然后总结规律,再尝试套用学过的算法,看是否能够解决。 看到问题,立马想到能否用动态规划解决,然后直接就可以寻找最优子结构,写出动态规划方程,然后将状态转移方程翻译成代码。 0.4.4.2. 编程计算最长公共子串长度 最长公共子串作为编辑距离中的一种,只允许增加、删除字符两种编辑操作。从本质上来说,它表征的也是两个字符串之间的相似程度。这个问题的解决思路,跟莱文斯坦距离的解决思路非常相似,也可以用动态规划解决。 每个状态包括三个变量 (i, j, max_lcs),max_lcs 表示 a[0…i]和 b[0…j]的最长公共子串长度。 回溯的处理思路: 从 a[0]和 b[0]开始,依次考察两个字符串中的字符是否匹配 如果 a[i]与 b[j]互相匹配,将最大公共子串长度加一,并且继续考察 a[i+1]和 b[j+1] 如果 a[i]与 b[j]不匹配,最长公共子串长度不变,这个时候,有两个不同的决策路线: 删除 a[i],或者在 b[j]前面加上一个字符 a[i],然后继续考察 a[i+1]和 b[j] 删除 b[j],或者在 a[i]前面加上一个字符 b[j],然后继续考察 a[i]和 b[j+1] 反过来也就是说,要求 a[0…i]和 b[0…j]的最长公共长度 max_lcs(i,j),只有可能通过下面三个状态转移过来: (i-1,j-1,max_lcs),其中 max_lcs 表示 a[0…i-1]和 b[0…j-1]的最长公共子串长度 (i-1,j,max_lcs),其中 max_lcs 表示 a[0…i-1]和 b[0…j]的最长公共子串长度 (i,j-1,max_lcs),其中 max_lcs 表示 a[0…i]和 b[0…j-1]的最长公共子串长度 把这个转移过程,用状态转移方程写出来,就是下面这个样子: // 如果:a[i]==b[j],那么:max_lcs(i, j)就等于: max(max_lcs(i-1,j-1)+1, max_lcs(i-1, j), max_lcs(i, j-1)); // 如果:a[i]!=b[j],那么:max_lcs(i, j)就等于: max(max_lcs(i-1,j-1), max_lcs(i-1, j), max_lcs(i, j-1)); // 其中max表示求三数中的最大值。 0.5. 四种算法比较 贪心、回溯、动态规划可以归为一类:解决问题的模型,都可以抽象成多阶段决策最优解模型 分治单独作为一类:解决的问题尽管大部分也是最优解问题,但是,大部分都不能抽象成多阶段决策模型 0.5.1. 回溯 回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决 回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解 回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了 0.5.2. 动态规划 尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决 能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题 0.5.3. 分治 在重复子问题这一点上,动态规划和分治算法的区分非常明显 分治算法要求分割成的子问题,不能有重复子问题 而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题 0.5.4. 贪心 贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。 它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里不强调重复子问题)。其中,最优子结构、无后效性跟动态规划中的无异。 “贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。
0.1. 单模式串匹配算法 0.1.1. BF算法 0.1.2. Rk算法 0.1.2.1. 时间复杂度 0.1.2.2. 散列冲突 0.1.3. BM算法 0.1.3.1. 坏字符规则 0.1.3.2. 好后缀规则 0.1.3.3. 实现过程优化 0.1.3.4. 性能分析 0.1.4. KMP算法 0.2. 多模式串匹配算法 0.2.1. Trie树 0.2.1.1. 实现Trie树 0.2.1.2. 时间复杂度 0.2.1.3. 空间复杂度 0.2.1.4. 空间优化 0.2.1.5. Trie 树与散列表、红黑树的比较 0.2.2. AC自动机 0.2.2.1. 场景:敏感词过滤 0.2.2.2. 构建AC自动机 0.2.2.3. 匹配AC自动机 0.2.2.4. 时间复杂度 如果要在字符串 A(长度n)中查找字符串 B(长度m),那么: 主串:A 模式串:B n>m 0.1. 单模式串匹配算法 0.1.1. BF算法 BF 算法中的 BF 是 Brute Force 的缩写,中文叫作暴力匹配算法,也叫朴素匹配算法。 BF 算法的思想在主串中,检查起始位置分别是 0、1、2…n-m 且长度为 m 的 n-m+1 个子串,看有没有跟模式串匹配的。极端情况下,算法的时间复杂度是O(n*m) 虽然理论上时间复杂度较高,但在实际开发中挺常用的: 大部分情况下,模式串和主串的长度都不会太长。而且每次模式串与主串中的子串匹配的时候,当中途遇到不能匹配的字符的时候,就可以就停止了,统计意义上,大部分情况下,算法执行效率要比O(n*m)高很多 思想简单、代码简单。简单意味着不容易出错,如果有 bug 也容易暴露和修复。在工程中,在满足性能要求的前提下,简单是首选(KISS原则) 所以,在实际的软件开发中,绝大部分情况下,朴素的字符串匹配算法就够用了,处理小规模的字符串很好用。 0.1.2. Rk算法 RK 算法的全称叫 Rabin-Karp 算法,是由它的两位发明者 Rabin 和 Karp 的名字来命名的。RK 算法是 BF 算法的改进,它巧妙借助了哈希算法,让匹配的效率有了很大的提升。 模式串长度为 m 主串长度为 n 主串中有 n-m+1 个长度为 m 的子串 对比这 n-m+1 个子串与模式串,找出主串与模式串匹配的子串 每次检查主串与子串是否匹配,需要依次比对每个字符,时间复杂度高,引入哈希算法,时间复杂度立刻就会降低。 RK 算法的思路: 通过哈希算法对主串中的 n-m+1 个子串分别求哈希值 逐个与模式串的哈希值比较大小 如果某个子串的哈希值与模式串相等,那就匹配了(先不考虑散列冲突) 哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了。 但是,通过哈希算法计算子串的哈希值时,需要遍历子串中的每个字符。尽管模式串与子串比较的效率提高了,但是,算法整体的效率并没有提高。 继续改进,优化哈希算法的设计。 假设要匹配的字符串的字符集中只包含 K 个字符,用一个 K 进制数来表示一个子串,这个 K 进制数转化成十进制数,作为子串的哈希值。 比如要处理的字符串只包含 a~z 这 26 个小写字母,就用二十六进制来表示一个字符串。把 a~z 这 26 个字符映射到 0~25 这 26 个数字。 在十进制的表示法中,一个数字的值是通过下面的方式计算出来的: \[657=6×10^2+5×10^1+7×10^0\] 对应到二十六进制,一个包含 a 到 z 这 26 个字符的字符串,计算哈希的时候,只需要把进位从 10 改成 26 就可以: \[cba=c×26^2+b×26^1+a×26^0=2×26^2+1×26+0×1=1353\] 这就是改进后的哈希算法,对应的哈希值就是26进制数转为10进制数后的字面量。这种哈希算法有一个特点,在主串中,相邻两个子串的哈希值的计算公式有一定关系。 相邻两个子串 s[i-1]和 s[i](i 表示子串在主串中的起始位置,子串的长度都为 m),对应的哈希值计算公式有交集,可以使用 s[i-1]的哈希值很快的计算出 s[i]的哈希值。 h[i-1]对应子串s[i,i+m-2]的哈希值 × 26 ==h[i]对应子串s[i,i+m-2]的哈希值 \(h[i]=(h[i-1]-s[i-1]×26^{m-1})×26+s[i+m-1]\) 注意,\(26^{m-1}\) 这部分的计算,可以通过查表的方法来提高效率。 事先计算好 \(26^0\)、\(26^1\)、\(26^2\)……\(26^{m-1}\),并且存储在一个长度为 m 的数组中,公式中的“次方”就对应数组的下标。 需要计算 26 的 x 次方的时候,就可以从数组的下标为 x 的位置取值,直接使用,省去了计算的时间。 0.1.2.1. 时间复杂度 整个 RK 算法包含两部分: 计算子串哈希值:利用上面的哈希算法,只需要扫描一遍主串就能计算出所有子串的哈希值了,所以时间复杂度是 O(n) 模式串哈希值与子串哈希值之间比较:比较操作的时间复杂度是 O(1),总共需要比较 n-m+1 个子串的哈希值,所以,时间复杂度是O(n) 所以,RK 算法整体的时间复杂度就是 O(n)。 0.1.2.2. 散列冲突 如果模式串很长,相应的主串中的子串也会很长,通过上面的哈希算法计算得到的哈希值就可能很大,如果超过了计算机中整型数据可以表示的范围,那该如何解决呢? RK算法基于进制来表示一个字符串的,这样的哈希算法是没有散列冲突的,也就是说,一个字符串与一个二十六进制数一一对应,不同的字符串的哈希值肯定不一样。 实际上,为了能将哈希值落在整型数据范围内,可以牺牲一下,允许散列冲突。如上面的哈希算法不基于进制求字符串,而是将每一个字符求和作为字符串的哈希值。这种哈希算法产生的哈希值的数据范围就相对要小很多了,但是冲突概率也就高很多。 稍微优化一下,将每一个字母从小到大对应一个素数,而不是 1,2,3……这样的自然数,这样冲突的概率就会降低一些。 当存在散列冲突后,哈希值虽然是相同,但是两者本身并不匹配,此时再对比一下子串和模式串本身就好了。 所以,哈希算法的冲突概率要相对控制得低一些,如果存在大量冲突,就会导致 RK 算法的时间复杂度退化,效率下降。极端情况下,如果存在大量的冲突,每次都要再对比子串和模式串本身,那时间复杂度就会退化成 O(n*m)。一般情况下,冲突不会很多,RK 算法的效率还是比 BF 算法高的。 0.1.3. BM算法 BM(Boyer-Moore)算法是一种非常高效的字符串匹配算法,有实验统计,它的性能是著名的KMP 算法的 3 到 4 倍。 BM 算法,本质上就是在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。 BM 算法包含两部分,分别是: 坏字符规则(bad character rule) 好后缀规则(good suffix shift) 0.1.3.1. 坏字符规则 BF与RK算法在匹配的过程中,都是按模式串的下标从小到大的顺序,依次与主串中的字符进行匹配的 BM 算法的匹配顺序是按照模式串下标从大到小的顺序,倒着匹配的 从模式串的末尾往前倒着匹配,当发现某个字符没法匹配时,把这个没有匹配的字符叫作坏字符(主串中的字符)。 当遇到坏字符发生不匹配时,把坏字符对应的模式串中的字符下标记作 si 如果坏字符在模式串中存在,把这个坏字符在模式串中的下标记作 xi 如果不存在,把 xi 记作 -1 那模式串往后移动的位数就等于 si-xi,注意,这里说的下标,都是字符在模式串的下标。 注意:如果坏字符在模式串里多处出现,在计算 xi 时,选择最靠后的那个,因为这样不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过。 利用坏字符规则,BM 算法在最好情况下的时间复杂度非常低,是 \(O(n/m)\)。 只使用坏字符规则还是不够的。因为根据 si-xi 计算出来的移动位数,有可能是负数,比如主串是 aaaaaaaaaaaaaaaa,模式串是 baaa。不但不会向后滑动模式串,还有可能倒退。所以,BM 算法还需要用到“好后缀规则”。 0.1.3.2. 好后缀规则 好后缀规则跟坏字符规则的思路很类似。从模式串的末尾往前倒着匹配,把已经匹配的字符串叫作好后缀,记作{u}。 拿{u}在模式串中查找 如果找到了另一个跟{u}相匹配的子串{u*},就将模式串滑动到子串{u*}与主串中{u}对齐的位置 如果找不到另一个等于{u}的子串,那一步步滑动模式串,当模式串滑动到前缀与主串中{u}的后缀有部分重合,并重合的部分相等时,就有可能会存在完全匹配的情况 针对这种情况,不仅要看好后缀在模式串中,是否有另一个匹配的子串,还要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的 从好后缀的后缀子串中,找一个最长的并且能跟模式串的前缀子串匹配的,假设是{v},然后将模式串滑动到如图所示的位置 所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串就包括 c, bc 所谓前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串有 a,ab 当模式串和主串中的某个字符不匹配的时候,分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。这可以避免根据坏字符规则,计算得到的往后滑动的位数,有可能是负数的情况。 0.1.3.3. 实现过程优化 坏字符规则: 当遇到坏字符时,要计算往后移动的位数 si-xi,其中 xi 的计算是重点。 如果拿坏字符,在模式串中顺序遍历查找,这样算法的性能较低,可以使用散列表进行优化 将模式串中的每个字符及其下标都存到散列表中。这样就可以快速找到坏字符在模式串的位置下标 假设字符串的字符集不是很大,每个字符长度是 1 字节,用大小为 256 的数组,来记录每个字符在模式串中出现的位置。数组的下标对应字符的 ASCII 码值,数组中存储这个字符在模式串中出现的位置。 好后缀规则: 好后缀规则中最核心的内容: 在模式串中,查找跟好后缀匹配的另一个子串 在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串 因为好后缀也是模式串本身的后缀子串,所以,在模式串和主串正式匹配之前,通过预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配子串的位置。 后缀子串: 后缀子串的最后一个字符的位置是固定的,下标为 m-1,只需要记录后缀子串的长度,通过长度,可以确定一个唯一的后缀子串。 引入变量 suffix 数组: 数组的下标 k:表示后缀子串的长度 下标对应的数组值存储的是:在模式串中跟好后缀{u}相匹配的子串{u*}的起始下标值 如果模式串中有多个子串跟后缀子串{u}匹配,那 suffix 数组中存储模式串中最靠后的那个子串的起始位置,也就是下标最大的那个子串的起始位置。 还要在好后缀的后缀子串中,查找最长的能跟模式串前缀子串匹配的后缀子串。引入boolean 类型的 prefix 数组,来记录模式串的后缀子串是否能匹配模式串的前缀子串。 计算并填充这两个数组:拿下标从 0 到 i 的子串(i 可以是 0 到 m-2)与整个模式串,求公共后缀子串。如果公共后缀子串的长度是 k,就记录 suffix[k]=j(j 表示公共后缀子串的起始下标)。如果 j 等于 0,也就是说,公共后缀子串也是模式串的前缀子串,就记录 prefix[k]=true。 在模式串跟主串匹配的过程中,遇到不能匹配的字符时,根据好后缀规则,计算模式串往后滑动的位数。 假设好后缀的长度是 k。先拿好后缀,在 suffix 数组中查找其匹配的子串。如果 suffix[k]不等于 -1(-1 表示不存在匹配的子串),那就将模式串往后移动 j-suffix[k]+1 位(j 表示坏字符对应的模式串中的字符下标)。如果 suffix[k]等于 -1,表示模式串中不存在另一个跟好后缀匹配的子串片段。 好后缀的后缀子串 b[r, m-1](其中,r 取值从 j+2 到 m-1)的长度 k=m-r,如果 prefix[k]等于 true,表示长度为 k 的后缀子串,有可匹配的前缀子串,这样我们可以把模式串后移 r 位。 如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串,就将整个模式串后移 m 位。 0.1.3.4. 性能分析 内存消耗,整个算法用到了额外的 3 个数组,其中: bc 数组的大小跟字符集大小有关(如果运行的环境对内存要求苛刻,可以只使用好后缀规则,不使用坏字符规则,这样就可以避免 bc 数组过多的内存消耗) suffix 数组和 prefix 数组的大小跟模式串长度 m 有关 执行效率:基于当前版本,在极端情况下,预处理计算 suffix 数组、prefix 数组的性能会比较差。比如模式串是 aaaaaaa 这种包含很多重复的字符的模式串,预处理的时间复杂度就是 \(O(m^2)\)。只使用好后缀规则的 BM 算法效率会下降一些。 0.1.4. KMP算法 KMP 算法跟 BM 算法的本质是一样的,全称是 Knuth Morris Pratt 算法。 在模式串和主串匹配的过程中,把不能匹配的那个字符仍然叫作坏字符,把已经匹配的那段字符串叫作好前缀。 当遇到坏字符的时候,把模式串往后滑动,在滑动的过程中,只要模式串和好前缀有上下重合,前面几个字符的比较,就相当于拿好前缀的后缀子串,跟模式串的前缀子串在比较。 KMP 算法就是在试图寻找一种规律:在模式串和主串匹配的过程中,当遇到坏字符后,对于已经比对过的好前缀,能否找到一种规律,将模式串一次性滑动很多位? 只要拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟好前缀的前缀子串匹配的。假设最长的可匹配的那部分前缀子串是{v},长度是 k。把模式串一次性往后滑动 j-k 位,相当于,每次遇到坏字符的时候,就把 j 更新为 k,i 不变,然后继续比较。 把好前缀的所有后缀子串中,最长的可匹配前缀子串的那个后缀子串,叫作最长可匹配后缀子串 对应的前缀子串,叫作最长可匹配前缀子串 KMP 算法提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标。把这个数组定义为 next 数组,或者叫失效函数(failure function)。 数组的下标是每个前缀结尾字符下标,数组的值是这个前缀的最长可以匹配前缀子串的结尾字符下标。 0.2. 多模式串匹配算法 搜索引擎的搜索关键词提示功能,底层最基本的原理就是Trie 树这种数据结构。 与单模式匹配算法相比,多模式匹配算法只需要扫描一遍主串,就能在主串中一次性查找多个模式串是否存在,从而大大提高匹配效率。 0.2.1. Trie树 Trie 树,也叫“字典树”,是一个树形结构。它是一种专门处理字符串匹配的数据结构,用来解决在一组字符串集合中快速查找某个字符串的问题。 字符串匹配问题也可以用:散列表、红黑树,或者上面的字符串匹配算法来解决,但是,Trie 树在这个问题的解决上,有它特有的优点。 假设有6个字符串:how,hi,her,hello,so,see,需要在里面多次查找某个字符是否存在。如果每次查找,都是拿要查找的字符串跟这 6 个字符串依次进行字符串匹配,那就太没有效率了。 先对这 6 个字符串做一下预处理,组织成 Trie 树的结构 每次查找,都是在 Trie 树中进行匹配查找。 Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起。 最后构造出如下图所示的Trie树: 根节点不包含任何信息 每个节点表示一个字符串中的字符 从根节点到红色节点的一条路径表示一个字符串(注意:红色节点并不都是叶子节点) 具体的Trie树构造过程如下图所示,相当于往Trie树中插入一个字符串,当所有字符串插入完成,Trie树就构造好了。 当在 Trie 树中查找一个字符串时: 查找字符串“her”, 将要查找的字符串分割成单个的字符 h,e,r, 从 Trie 树的根节点开始匹配。 如下图所示,绿色的路径就是在 Trie 树中匹配的路径。 查找字符串“he” 拆分单词 从根节点开始,沿着某条路径来匹配 如下图所示,绿色的路径,是字符串“he”匹配的路径。但是,路径的最后一个节点“e”并不是红色的。也就是说,“he”是某个字符串的前缀子串,但并不能完全匹配任何字符串。 0.2.1.1. 实现Trie树 Trie 树主要有两个操作: 将字符串集合构造成 Trie 树(将字符串插入到 Trie 树的过程) 在 Trie 树中查询一个字符串 Trie 树是一个多叉树,经典的存储方式,借助散列表的思想,通过一个下标与字符一一映射的数组,来存储子节点的指针,如下图所示。 假设字符串中只有从 a 到 z 这 26 个小写字母 数组中下标为 0 的位置,存储指向子节点 a 的指针 数组中下标为 1 的位置,存储指向子节点 b 的指针 以此类推 数组中下标为 25 的位置,存储的是指向的子节点 z 的指针 如果某个字符的子节点不存在,在对应的下标的位置存储 nil type TrieNode struct { data rune children [26]*TrieNode } 在 Trie 树中查找字符串时,通过字符的 ASCII 码减去“a”的 ASCII 码,迅速找到匹配的子节点的指针。 0.2.1.2. 时间复杂度 如果要在一组字符串中,频繁地查询某些字符串,用 Trie 树会非常高效。 构建 Trie 树的过程,需要扫描所有的字符串,时间复杂度是 O(n)(n 表示所有字符串的长度和) 构建成功后,后续的查询操作会非常高效 每次查询时,如果要查询的字符串长度是 k,只需要比对大约 k 个节点,就能完成查询操作,跟原本那组字符串的长度和个数没有任何关系 构建好 Trie 树后,在其中查找字符串的时间复杂度是 O(k),k 表示要查找的字符串的长度。 0.2.1.3. 空间复杂度 Trie 树是一种非常独特的、高效的字符串匹配方法(空间换时间),在实现的时候,用数组来存储一个节点的子节点的指针。 假设字符串中只有从 a 到 z 这 26 个小写字母 节点都要存储一个长度为26的数组 每个数组元素包含一个字符(4字节)和一个指针(1字节) 因此,每个节点26×5byte=130byte 如果字符串中不仅包含小写字母,还包含大写字母、数字、甚至是中文,那需要的存储空间就更多了。 Trie 树的本质是避免重复存储一组字符串的相同前缀子串,但是,在重复的前缀并不多的情况下,Trie 树不但不能节省内存,还有可能会浪费更多的内存。 0.2.1.4. 空间优化 可以稍微牺牲一点查询的效率,将每个节点中的数组换成其他数据结构,来存储一个节点的子节点指针。比如: 有序数组:数组中的指针按照所指向的子节点中的字符的大小顺序排列,查询的时候,使用二分查找的方法,在插入数据时,维护数组有序性会导致效率降低 跳表 散列表 红黑树 Trie 树的变体有很多,都可以在一定程度上解决内存消耗的问题。比如,缩点优化: 对只有一个子节点的节点 而且此节点不是一个串的结束节点 可以将此节点与子节点合并 这样可以节省空间,但却增加了编码难度。 0.2.1.5. Trie 树与散列表、红黑树的比较 字符串的匹配问题,其实就是数据的查找问题。 对于支持动态数据高效操作的数据结构,比如散列表、红黑树、跳表等, 也可以实现在一组字符串中查找字符串的功能。 在上面的场景中,在一组字符串中查找字符串,Trie 树实际上表现得并不好。它对要处理的字符串有及其严苛的要求。 第一,字符串中包含的字符集不能太大(如果字符集太大,那存储空间可能就会浪费很多即便可以优化,但也要付出牺牲查询、插入效率的代价) 第二,要求字符串的前缀重合比较多,不然空间消耗会变大很多 第三,如果要用 Trie 树解决问题,就要从零开始实现一个 Trie 树,还要保证没有 bug(这个在工程上是将简单问题复杂化,除非必须,一般不建议这样做) 第四,通过指针串起来的数据块是不连续的,而 Trie 树中用到了指针,所以,对缓存并不友好,性能上会打个折扣。 综合这几点,针对在一组字符串中查找字符串的问题,在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,不需要自己去实现,直接利用编程语言中提供的现成类库就行了。 Trie 树不适合精确匹配查找,这种问题更适合用散列表或者红黑树来解决 Trie 树适合前缀匹配查找,比如自动输入补全(搜索栏,IDE代码、浏览器地址、命令行、输入法) 0.2.2. AC自动机 0.2.2.1. 场景:敏感词过滤 对敏感词字典进行预处理,构建成 Trie 树结构。这个预处理的操作只需要做一次,如果敏感词字典动态更新了,比如删除、添加了一个敏感词,只需要动态更新一下 Trie 树。 当用户输入一个文本内容后,把用户输入的内容作为主串,从第一个字符(假设是字符 C)开始,在 Trie 树中匹配。 当匹配到 Trie 树的叶子节点,或者中途遇到不匹配字符的时候,将主串的开始匹配位置后移一位,也就是从字符 C 的下一个字符开始,重新在 Trie 树中匹配。 基于 Trie 树的这种处理方法,有点类似单模式串匹配的 BF 算法。 单模式串匹配算法中,KMP 算法对 BF 算法进行改进,引入了 next 数组,让匹配失败时,尽可能将模式串往后多滑动几位。 借鉴单模式串的优化改进方法,对多模式串 Trie 树进行改进,进一步提高 Trie 树的效率,这就要用到 AC 自动机算法。 0.2.2.2. 构建AC自动机 AC 自动机算法,全称是 Aho-Corasick 算法。 Trie 树跟 AC 自动机之间的关系,就像单串匹配中朴素的串匹配算法,跟 KMP 算法之间的关系一样,只是针对的是多模式串。 AC 自动机是在 Trie 树之上,加了类似 KMP 的 next 数组,只不过此处的 next 数组是构建在树上。 AC 自动机的构建,包含两个操作: 将多个模式串构建成 Trie 树 在 Trie 树上构建失败指针(相当于 KMP 中的失效函数 next 数组) 构建好 Trie 树之后,如何在它之上构建失败指针?如下示例:有 4 个模式串,分别是 c,bc,bcd,abcd;主串是 abcd。 Trie 树中的每一个节点都有一个失败指针,它的作用和构建过程,跟 KMP 算法中的 next 数组极其相似。 假设沿 Trie 树走到 p 节点(下图中的紫色节点),那 p 的失败指针就是从 root 走到紫色节点形成的字符串 abc 与所有模式串前缀匹配的最长可匹配后缀子串,就是箭头指的 bc 模式串 最长可匹配后缀子串:字符串 abc 的后缀子串有两个 bc,c,拿它们与其他模式串匹配,如果某个后缀子串可以匹配某个模式串的前缀,就把这个后缀子串叫作可匹配后缀子串。从可匹配后缀子串中,找出最长的一个,就是最长可匹配后缀子串。 将 p 节点的失败指针指向那个最长匹配后缀子串对应的模式串的前缀的最后一个节点,就是下图中箭头指向的节点 计算每个节点的失败指针:把树中相同深度的节点放到同一层,那么某个节点的失败指针只有可能出现在它所在层的上一层。 像 KMP 算法那样,当求某个节点的失败指针时,通过已经求得的、深度更小的那些节点的失败指针来推导。 可以逐层依次来求解每个节点的失败指针。所以,失败指针的构建过程,是一个按层遍历树的过程。 首先 root 的失败指针为 NULL,也就是指向自己 当已经求得某个节点 p 的失败指针之后,寻找它的子节点的失败指针 假设节点 p 的失败指针指向节点 q,看节点 p 的子节点 pc 对应的字符,是否也可以在节点 q 的子节点中找到 如果找到了节点 q 的一个子节点 qc,对应的字符跟节点 pc 对应的字符相同,则将节点 pc 的失败指针指向节点 qc 如果节点 q 中没有子节点的字符等于节点 pc 包含的字符,则令 q=q->fail(fail 表示失败指针) 继续上面的查找,直到 q 是 root 为止 如果还没有找到相同字符的子节点,就让节点 pc 的失败指针指向 root 通过按层来计算每个节点的子节点的失效指针,最后构建完成之后的 AC 自动机就是下面这个样子: 0.2.2.3. 匹配AC自动机 AC 自动机构建完成后,在 AC 自动机上匹配主串。 在匹配过程中,主串从 i=0 开始,AC 自动机从指针 p=root 开始,假设模式串是 b,主串是 a。 如果 p 指向的节点有一个等于 b[i]的子节点 x,就更新 p 指向 x,这个时候通过失败指针,检测一系列失败指针为结尾的路径是否是模式串 处理完之后,将 i 加一,继续这两个过程 如果 p 指向的节点没有等于 b[i]的子节点,那失败指针就派上用场了,让 p=p->fail,然后继续这 2 个过程 0.2.2.4. 时间复杂度 Trie树构建的时间复杂度是O(m*len),len表示敏感词的平均长度,m表示敏感词的个数 假设 Trie 树中总的节点个数是 k,每个节点构建失败指针的时候,最耗时的环节是 while 循环中的 q=q->fail,每运行一次这个语句,q 指向节点的深度都会减少 1,而树的高度最高也不会超过 len,所以每个节点构建失败指针的时间复杂度是 O(len)。整个失败指针的构建过程就是 O(k*len)。 for 循环依次遍历主串中的每个字符,for 循环内部最耗时的部分也是 while 循环,而这一部分的时间复杂度也是 O(len),所以总的匹配的时间复杂度就是 O(n*len)。 因为敏感词并不会很长,时间复杂度可能近似于 O(n),所以 AC 自动机做敏感词过滤,性能非常高。 实际上,因为失效指针可能大部分情况下都指向 root 节点,所以绝大部分情况下,在 AC 自动机上做匹配的效率要远高于上面理论的时间复杂度。只有在极端情况下,如图所示,AC 自动机的性能才会退化的跟 Trie 树一样。
0.1. 图定义 0.2. 存储图 0.2.1. 邻接矩阵 0.2.2. 邻接表 0.3. 搜索算法 0.3.1. 广度优先搜索 0.3.2. 深度优先搜索 0.1. 图定义 树一种非线性表数据结构,树中的元素称为节点 图(Graph)另一种非线性表数据结构,图中的元素叫作顶点(vertex) 图比树更加复杂,图中的一个顶点可以与任意其他顶点建立连接关系,这种建立的关系叫作边(edge)。 以微博为例,把每个用户看作一个顶点。如果两个用户之间互相关注,那就在两者之间建立一条边。所以,整个微博的粉丝关系就可以用一张图来表示。其中,每个用户有多少个粉丝,对应到图中,就叫作顶点的度(degree),就是跟顶点相连接的边的条数。 如果用户 A 关注了用户 B,就在图中画一条从 A 到 B 的带箭头的边,来表示边的方向。 如果用户 A 和用户 B 互相关注,就画一条从 A 指向 B 的边,再画一条从 B 指向 A 的边。 这种边有方向的图叫作“有向图”。以此类推,边没有方向的图就叫作“无向图”。 无向图中有“度”这个概念,表示一个顶点有多少条边。在有向图中,把度分为入度(In-degree)和出度(Out-degree)。 顶点的入度,表示有多少条边指向这个顶点;入度就表示有多少粉丝 顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点;出度就表示关注了多少人 再来看QQ,它的社交关系要更复杂一点。QQ 不仅记录了用户之间的好友关系,还记录了两个用户之间的亲密度,如果两个用户经常往来,那亲密度就比较高;如果不经常往来,亲密度就比较低。 这里就要用到带权图(weighted graph),每条边都有一个权重(weight),可以通过这个权重来表示 QQ 好友间的亲密度。 0.2. 存储图 0.2.1. 邻接矩阵 图最直观的一种存储方法就是,邻接矩阵(Adjacency Matrix)。 邻接矩阵的底层依赖一个二维数组。 对于无向图,如果顶点 i 与顶点 j 之间有边,就将 A[i][j]和 A[j][i]标记为 1 对于有向图:如果顶点 i 到顶点 j 之间: 有一条箭头从顶点 i 指向顶点 j 的边,就将 A[i][j]标记为 1 有一条箭头从顶点 j 指向顶点 i 的边,就将 A[j][i]标记为 1 对于带权图,数组中就存储相应的权重 用邻接矩阵来表示一个图,虽然简单、直观,但是比较浪费存储空间。 对于无向图来说,如果 A[i][j]等于 1,那 A[j][i]也肯定等于 1。实际上,只需要存储一个就可以了,另外一半白白浪费掉了。 如果存储的是稀疏图(Sparse Matrix),顶点很多,但每个顶点的边并不多,那邻接矩阵的存储方法就更加浪费空间了。 邻接矩阵的优点: 存储方式简单、直接,因为基于数组,所以在获取两个顶点的关系时,就非常高效 方便计算,因为可以将很多图的运算转换成矩阵之间的运算。比如求解最短路径问题时会提到一个Floyd-Warshall 算法,就是利用矩阵循环相乘若干次得到结果 0.2.2. 邻接表 针对邻接矩阵比较浪费内存空间的问题,使用邻接表(Adjacency List)来存储图。乍一看,邻接表有点像散列表,每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。 有向图的邻接表存储方式,每个顶点对应的链表中,存储的是指向的顶点 无向图的邻接表存储方式,每个顶点对应的链表中,存储的是跟这个顶点有边相连的顶点 邻接矩阵存储起来比较浪费空间,但是使用起来比较节省时间。 邻接表存储起来比较节省空间,但是使用起来就比较耗时间。 如果链过长,为了提高查找效率,可以将邻接表中的链表改成平衡二叉查找树。实际开发中,可以选择用红黑树。这样,可以更加快速地查找两个顶点之间是否存在边了。 二叉查找树可以换成其他动态数据结构,比如跳表、散列表等。 还可以将链表改成有序动态数组,可以通过二分查找的方法来快速定位两个顶点之间否是存在边。 使用邻接表存储出度,使用逆邻接表存储入度。 0.3. 搜索算法 算法是作用于具体数据结构之上的,深度优先搜索算法和广度优先搜索算法都是基于“图”这种数据结构的。 图这种数据结构的表达能力很强,大部分涉及搜索的场景都可以抽象成“图”。 图上的搜索算法,最直接的理解就是,在图中找出从一个顶点出发,到另一个顶点的路径。具体方法有很多,比如两种最简单、最“暴力”的深度优先、广度优先搜索,还有 A*、IDA* 等启发式搜索算法。 图有两种主要存储方法,邻接表和邻接矩阵,下面用邻接表来存储图。 深度优先搜索算法和广度优先搜索算法,既可以用在无向图,也可以用在有向图上。 0.3.1. 广度优先搜索 广度优先搜索(Breadth-First-Search,BFS)。它是一种“地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。 尽管广度优先搜索的原理挺简单,但代码实现还是稍微有点复杂度。 代码里面有三个重要的辅助变量 visited、queue、prev。 visited 是用来记录已经被访问的顶点,用来避免顶点被重复访问。如果顶点 q 被访问,那相应的 visited[q]会被设置为 true。 queue 是一个队列,用来存储已经被访问、但相连的顶点还没有被访问的顶点。因为广度优先搜索是逐层访问的,也就是说,只有把第 k 层的顶点都访问完成之后,才能访问第 k+1 层的顶点。当访问到第 k 层的顶点的时候,需要把第 k 层的顶点记录下来,稍后才能通过第 k 层的顶点来找第 k+1 层的顶点。所以,用这个队列来实现记录的功能。 prev 用来记录搜索路径。当从顶点 s 开始,广度优先搜索到顶点 t 后,prev 数组中存储的就是搜索的路径。不过,这个路径是反向存储的。prev[w]存储的是,顶点 w 是从哪个前驱顶点遍历过来的。为了正向打印出路径,需要递归地来打印,使用print()函数的实现。 时间复杂度:最坏情况下,终止顶点 t 离起始顶点 s 很远,需要遍历完整个图才能找到。这个时候,每个顶点都要进出一遍队列,每个边也都会被访问一次,所以,广度优先搜索的时间复杂度是 O(V+E),其中,V 表示顶点的个数,E 表示边的个数。对于一个连通图(图中的所有顶点都是连通的)来说,E 肯定要大于等于 V-1,所以,广度优先搜索的时间复杂度也可以简写为 O(E) 空间复杂度:空间消耗主要在几个辅助变量 visited 数组、queue 队列、prev 数组上。这三个存储空间的大小都不会超过顶点的个数,所以空间复杂度是 O(V) 0.3.2. 深度优先搜索 深度优先搜索(Depth-First-Search,DFS),最直观的例子就是“走迷宫”。 在图中应用深度优先搜索,来找某个顶点到另一个顶点的路径。搜索的起始顶点是 s,终止顶点是 t,希望在图中寻找一条从顶点 s 到顶点 t 的路径。 用深度递归算法,把整个搜索的路径标记出来了。这里面实线箭头表示遍历,虚线箭头表示回退。从图中可以看出,深度优先搜索找出来的路径,并不是顶点 s 到顶点 t 的最短路径。 实际上,深度优先搜索用的是一种比较著名的算法思想,回溯思想。这种思想解决问题的过程,非常适合用递归来实现。 深度优先搜索代码实现也用到了 prev、visited 变量以及 print() 函数,它们跟广度优先搜索代码实现里的作用是一样的。 不过,深度优先搜索代码实现里,有个比较特殊的变量 found,它的作用是,当我们已经找到终止顶点 t 之后,就不再递归地继续查找了。 时间复杂度:从上图可以看出,每条边最多会被访问两次,一次是遍历,一次是回退。所以,深度优先搜索算法的时间复杂度是 O(E),E 表示边的个数 空间复杂度:消耗内存主要是 visited、prev 数组和递归调用栈。visited、prev 数组的大小跟顶点的个数 V 成正比,递归调用栈的最大深度不会超过顶点的个数,所以总的空间复杂度就是O(V) 广度优先搜索和深度优先搜索是图上的两种最常用、最基本的搜索算法,比起其他高级的搜索算法,比如 A、IDA 等,要简单粗暴,没有什么优化,所以,也被叫作暴力搜索算法。所以,这两种搜索算法仅适用于状态空间不大,也就是说图不大的搜索。
0.1. 堆 0.1.1. 实现堆 0.1.1.1. 存储方式 0.1.1.2. 往堆中插入一个元素 0.1.1.3. 删除堆顶元素 0.2. 基于堆实现排序 0.2.1. 建堆 0.2.2. 排序 0.3. 应用 0.3.1. 优先级队列 0.3.1.1. 合并有序小文件 0.3.1.2. 高性能定时器 0.3.2. 求TopK 0.3.2.1. 静态数据 0.3.2.2. 动态数据 0.3.3. 求中位数 0.3.4. 求百分位数据 堆是一种特殊的数据结构,应用场景很多,堆排序是一种原地排序,时间复杂度O(nlogn)。 0.1. 堆 堆是一种特殊的树,满足以下两点就是一个堆: 堆是一个完全二叉树(除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列) 堆中每一个节点的值都必须大于等于(或小于等于)其左右子节点的值 对于每个节点的值都大于等于子树中每个节点值的堆,叫作“大顶堆” 对于每个节点的值都小于等于子树中每个节点值的堆,叫作“小顶堆” 0.1.1. 实现堆 0.1.1.1. 存储方式 堆是完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。因为不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。 从图中可以看到,数组中下标为 i 的节点: 左子节点:是下标为 i∗2 的节点 右子节点:是下标为 i∗2+1 的节点 父节点:是下标为 \(\frac{i}{2}\) 的节点 0.1.1.2. 往堆中插入一个元素 如果往堆尾插入一个元素后,需要继续满足堆的两个特性,这时就需要进行调整,称为堆化。堆化有两种: 从下往上 从上往下 堆化非常简单,就是顺着节点所在的路径,向上或者向下,对比,然后交换。 下面的例子是从下往上的堆化。 新插入的节点与父节点对比大小 如果不满足子节点小于等于父节点的大小关系,就互换两个节点 一直重复这个过程,直到父子节点之间满足刚说的那种大小关系 0.1.1.3. 删除堆顶元素 根据堆的定义,任何节点的值都大于等于(或小于等于)子树节点的值,所以,堆顶元素存储的就是堆中数据的最大值或者最小值。 假设构造的是大顶堆,堆顶元素就是最大的元素。 当删除堆顶元素后,就需要把第二大的元素放到堆顶 第二大元素肯定会出现在左右子节点中 然后再迭代地删除第二大节点 以此类推 直到叶子节点被删除 出现左侧叶子节点为空的情况,这样的删除思路导致堆不满足完全二叉树的特性。 换一下思路来解决这个问题。 当删除堆顶元素后,把最后一个节点放到堆顶 然后利用同样的父子节点对比方法 对于不满足父子节点大小关系的,互换两个节点 并且重复进行这个过程,直到父子节点之间满足大小关系为止 这就是从上往下的堆化方法。 因为移除的是数组中的最后一个元素,而在堆化的过程中,都是交换操作,不会出现数组中的“空洞”,所以这种方法堆化之后的结果,肯定满足完全二叉树的特性。 一个包含 n 个节点的完全二叉树,树的高度不会超过 \(log_2n\)。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 \(O(logn)\)。 插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 \(O(logn)\)。 0.2. 基于堆实现排序 借助于堆这种数据结构实现的排序算法,就叫作堆排序,它的时间复杂度非常稳定,是 \(O(nlogn)\),并且它还是原地排序算法。把堆排序的过程大致分解成两个大的步骤,建堆和排序。 0.2.1. 建堆 首先将数组原地建成一个堆。所谓“原地”就是,不借助另一个数组,就在原数组上操作。建堆的过程,有两种思路。 第一种是按照在堆中插入一个元素的思路。假设起初堆中只包含一个数据,就是下标为 1 的数据。然后,调用插入操作,将下标从 2 到 n 的数据依次插入到堆中。这样就将包含 n 个数据的数组,组织成了堆。这种建堆思路的处理过程是: 从前往后处理数组数据 并且每个数据都是从下往上堆化 第二种实现思路,跟第一种截然相反: 从后往前处理数组 并且每个数据都是从上往下堆化 举个例子操作第二种实现思路的建堆分解步骤图。因为叶子节点往下堆化只能自己跟自己比较,所以直接从第一个非叶子节点开始,依次堆化就行了。 对于完全二叉树来说,下标从 \(\frac{n}{2}+1\) 到 n 的节点都是叶子节点,不需要参与堆化的过程,所以对下标从 \(\frac{n}{2}\) 开始到 1 的数据进行堆化。 每个节点堆化的时间复杂度是 O(logn),\(\frac{n}{2}+1\)个节点堆化的总时间复杂度就是O(nlogn),更精确一点,堆排序的建堆过程的时间复杂度是 O(n)。 因为叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始。每个节点堆化的过程中,需要比较和交换的节点个数与该节点的高度 k 成正比。 将每个非叶子节点的高度求和: 把公式左右都乘以 2,就得到另一个公式 S2,将 S2 错位对齐,并且用 S2 减去 S1,可以得到 S。 S 的中间部分是一个等比数列,所以最后可以用等比数列的求和公式来计算。 因为 \(h=log2_n\),代入公式 S,就能得到 S=O(n),所以,建堆的时间复杂度就是 O(n)。 0.2.2. 排序 建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。 把堆顶元素跟最后一个元素交换,那最大元素就放到了下标为 n 的位置,然后开始堆化。 这个过程类似“删除堆顶元素”的操作,当堆顶元素移除之后,把下标为 n 的元素放到堆顶,然后再通过堆化的方法,将剩下的 n−1 个元素重新构建成堆。 堆化完成之后,再取堆顶的元素,放到下标是 n−1 的位置 一直重复这个过程 直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了 整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。 堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。 堆排序是不稳定排序算法,因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。 上面都是假设,堆中的数据是从数组下标为 1 的位置开始存储。那如果从 0 开始存储,实际上处理思路是没有任何变化的,唯一变化的,可能就是,代码实现的时候,计算子节点和父节点的下标的公式改变了。 如果节点的下标是 i,那左子节点的下标就是 2∗i+1,右子节点的下标就是 2∗i+2,父节点的下标就是 (i-1)/2。 0.3. 应用 在实际开发中,快速排序要比堆排序性能好: 堆排序数据访问的方式没有快速排序友好 对于快速排序来说,数据是顺序访问的(对CPU友好) 对于堆排序来说,数据是跳着访问的(堆排序中,最重要的一个操作就是数据的堆化) 对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序 对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。 堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。 0.3.1. 优先级队列 优先级队列是一个队列,最大的特性就是先进先出。在优先级队列中,数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。 实现一个优先级队列方法有很多,用堆来实现是最直接、最高效的。因为,堆和优先级队列非常相似。一个堆就可以看作一个优先级队列。很多时候,它们只是概念上的区分而已。 往优先级队列中插入一个元素,就相当于往堆中插入一个元素 从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素 优先级队列的应用场景非常多。很多语言中,都提供了优先级队列的实现,比如: Java 的 PriorityQueue C++ 的 priority_queue 举两个具体的例子,来感受一下优先级队列。 0.3.1.1. 合并有序小文件 假设有 100 个小文件, 每个文件的大小是 100MB, 每个文件中存储的都是有序的字符串 希望将这 100 个小文件合并成一个有序的大文件,使用优先级队列,整体思路有点像归并排序中的合并函数。 从这 100 个文件中,各取第一个字符串,放入数组中,然后比较大小,把最小的那个字符串放入合并后的大文件中,并从数组中删除 假设,这个最小的字符串来自于 13.txt 这个小文件,就再从这个小文件取下一个字符串,放到数组中,重新比较大小,并且选择最小的放入合并后的大文件,将它从数组中删除 依次类推,直到所有的文件中的数据都放入到大文件为止 这里用数组这种数据结构,来存储从小文件中取出来的字符串。每次从数组中取最小字符串,都需要循环遍历整个数组,显然,这不是很高效。 将数组换成优先级队列(堆)。 将从小文件中取出来的字符串放入到小顶堆中,堆顶的元素就是优先级队列队首的元素,就是最小的字符串 将这个字符串放入到大文件中,并将其从堆中删除 再从小文件中取出下一个字符串,放入到堆中 循环这个过程,将 100 个小文件中的数据依次放入到大文件中 删除堆顶数据和往堆中插入数据的时间复杂度都是 O(logn),n 表示堆中的数据个数,这里就是 100。 0.3.1.2. 高性能定时器 假设有一个定时器,定时器中维护了很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。 时间 任务 2018.11.28.17:30 A 2018.11.28.19:20 B 2018.11.28.15:31 C 2018.11.28.13:55 D 但是,每过 1 秒就扫描一遍任务列表的做法比较低效,主要原因有两点: 第一,任务的约定执行时间离当前时间可能还有很久,这样前面很多次扫描其实都是徒劳的; 第二,每次都要扫描整个任务列表,如果任务列表很大的话,势必会比较耗时。 针对这些问题,可以用优先级队列来解决。 按照任务设定的执行时间,将这些任务存储在优先级队列中 队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务 定时器就不需要每隔 1 秒就扫描一遍任务列表了。它拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔 T。这个时间间隔 T 就是,从当前时间开始,需要等待多久,才会有第一个任务需要被执行 这定时器就可以设定在 T 秒之后,再来执行任务。从当前时间点到T-1秒这段时间里,定时器都不需要做任何事情 当 T 秒时间过去之后,定时器取优先级队列中队首的任务执行 再计算新的队首任务的执行时间点与当前时间点的差值,把这个值作为定时器执行下一个任务需要等待的时间 这样,定时器既不用间隔 1 秒就轮询一次,也不用遍历整个任务列表,性能也就提高了。 0.3.2. 求TopK 把求 Top K 的问题抽象成两类。 针对静态数据集合,也就是说数据集合事先确定,不会再变。 针对动态数据集合,也就是说数据集合事先并不确定,有数据动态地加入到集合中。 0.3.2.1. 静态数据 针对静态数据,在一个包含 n 个数据的数组中,查找前 K 大数据,可以通过维护一个大小为 K 的小顶堆。 顺序遍历数组,从数组中取出数据与堆顶元素比较 如果比堆顶元素大,就把堆顶元素删除,并且将这个元素插入到堆中 如果比堆顶元素小,则不做处理,继续遍历数组 等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了。 遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,时间复杂度就是 O(nlogK)。 0.3.2.2. 动态数据 针对动态数据求得 Top K 就是实时 Top K。 动态数据集合有两个操作: 添加数据 询问当前的前 K 大数据 如果每次询问前 K 大数据,都基于当前的数据重新计算的话,那时间复杂度就是 O(nlogK),n 表示当前的数据的大小。 实际上,可以一直都维护一个 K 大小的小顶堆: 当有数据被添加到集合中时,拿它与堆顶的元素对比 如果比堆顶元素大,就把堆顶元素删除,并且将这个元素插入到堆中 如果比堆顶元素小,则不做处理 这样,无论何时查询当前的前 K 大数据,都可以立刻返回。 0.3.3. 求中位数 求数据集合中的中位数(处在中间位置的那个数),假设数据是从 0 开始编号: 如果数据的个数是奇数,把数据从小到大排列,那第 \(\frac{n}{2}+1\) 个数据就是中位数; 如果数据的个数是偶数,那处于中间位置的数据有两个,第 \(\frac{n}{2}\) 个和第 \(\frac{n}{2}+1\) 个数据,任意取一个作为中位数(比如取两个数中靠前的那个,就是第 \(\frac{n}{2}\) 个数据) 对于一组静态数据,中位数是固定的,先排序,第 \(\frac{n}{2}\) 个数据就是中位数,每次询问中位数的时候,直接返回这个固定的值就好了。虽然排序的代价比较大,但是边际成本会很小。 如果面对的是动态数据集合,中位数在不停变动,每次询问中位数的时候,都要先进行排序,那效率就不高了。 借助堆可以不排序的情况下,就非常高效地实现求中位数操作。 维护两个堆: 大顶堆:存储前半部分数据 小顶堆:存储后半部分数据 小顶堆中的数据都大于大顶堆中的数据。 如果有 n 个数据,: n 是偶数,从小到大排序,那前 \(\frac{n}{2}\) 个数据存储在大顶堆中,后 \(\frac{n}{2}\) 个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是中位数 n 是奇数,大顶堆就存储 \(\frac{n}{2}+1\) 个数据,小顶堆中就存储 \(\frac{n}{2}\) 个数据 数据是动态变化的,当新添加一个数据时调整两个堆,让大顶堆中的堆顶元素继续是中位数: 如果新加入的数据小于等于大顶堆的堆顶元素,就将这个新数据插入到大顶堆 否则,就将这个新数据插入到小顶堆 这个时候就有可能出现,两个堆中的数据个数不符合前面约定的情况: n 是偶数,两个堆中的数据个数都是 \(\frac{n}{2}\) n 是奇数,大顶堆有 \(\frac{n}{2}+1\) 个数据,小顶堆有 \(\frac{n}{2}\) 个数据 这个时候,可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。 于是,就可以利用两个堆,一个大顶堆、一个小顶堆,实现在动态数据集合中求中位数的操作。插入数据因为需要涉及堆化,所以时间复杂度变成了 O(logn),但是求中位数只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是 O(1)。 0.3.4. 求百分位数据 利用两个堆快速求其他百分位的数据,原理与求中位数类似。 如何快速求接口的99%的响应时间? 中位数的概念就是将数据从小到大排列,处于中间位置,就叫中位数,这个数据会大于等于前面 50% 的数据。 99百分位数的概念可以类比中位数,如果将一组数据从小到大排列,这个 99 百分位数就是大于前面 99% 数据的那个数据。 如果有 100 个接口访问请求,每个接口请求的响应时间都不同,比如 55 毫秒、100 毫秒、23 毫秒等,把这 100 个接口的响应时间按照从小到大排列,排在第 99 的那个数据就是 99% 响应时间,也叫 99 百分位响应时间。 如果有 n 个数据,将数据从小到大排列之后,99 百分位数大约就是第 n*99% 个数据,同类,80 百分位数大约就是第 n*80% 个数据。 假设当前总数据的个数是 n,求 99% 响应时间,只需要维护两个堆: 大顶堆:保存 n*99% 个数据 小顶堆:保存 n*1% 个数据 大顶堆堆顶的数据就是要找的 99% 响应时间。 每次插入一个数据时,要判断这个数据跟大顶堆和小顶堆堆顶数据的大小关系,然后决定插入到哪个堆中。 如果这个新插入的数据比大顶堆的堆顶数据小,那就插入大顶堆 如果这个新插入的数据比小顶堆的堆顶数据大,那就插入小顶堆 为了保持大顶堆中的数据占 99%,小顶堆中的数据占 1%,在每次新插入数据之后,要重新计算大顶堆和小顶堆中的数据个数,是否还符合 99:1 这个比例。如果不符合,就将一个堆中的数据移动到另一个堆,直到满足这个比例。 通过这样的方法,每次插入数据,可能会涉及几个数据的堆化操作,所以时间复杂度是 O(logn)。每次求 99% 响应时间的时候,直接返回大顶堆中的堆顶数据即可,时间复杂度是 O(1)。
递归代码的时间复杂度分析起来很麻烦。利用递推公式求解,但是,有些情况,用递推公式的话,会涉及非常复杂的数学推导。除了用递推公式这种比较复杂的分析方法,还可以借助递归树来分析递归算法的时间复杂度。 递归树与时间复杂度分析 递归的思想就是,将大问题分解为小问题来求解,然后再将小问题分解为小小问题。这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。 如果把这个一层一层的分解过程画成图,其实就是一棵树(递归树)。 如下是一棵斐波那契数列的递归树,节点里的数字表示数据的规模,一个节点的求解可以分解为左右子节点两个问题的求解。 归并排序是一个自上而下的分治过程,每次会将数据规模一分为二。把归并排序画成递归树,如下所示: 每次分解都是一分为二,所以代价很低,把时间上的消耗记作常量1。 比较耗时的是归并操作,也就是把两个子数组合并为大数组。 从图中可以看出,每一层归并操作消耗的时间总和是一样的,跟要排序的数据规模有关。把每一层归并操作消耗的时间记作n。 现在,只需要知道这棵树的高度h,用高度h乘以每一层的时间消耗n,就可以得到总的时间复杂度\(O(n∗h)\)。 从归并排序的原理和递归树,可以看出来,归并排序递归树是一棵满二叉树。满二叉树的高度大约是\(log_2n\),所以,归并排序递归实现的时间复杂度就是\(O(nlogn)\)。
0.1. 平衡二叉查找树定义 0.2. 红黑树 0.2.1. 近似平衡的红黑树 二叉查找树是最常用的一种二叉树,它支持快速插入、删除、查找操作,各个操作的时间复杂度跟树的高度成正比,理想情况下,时间复杂度是 O(logn)。 二叉查找树在频繁的动态更新过程中,可能会出现树的高度远大于 log2n 的情况,从而导致各个操作的效率下降。极端情况下,二叉树会退化为链表,时间复杂度会退化到 O(n)。 为了解决这个复杂度退化的问题,需要设计一种平衡二叉查找树。 0.1. 平衡二叉查找树定义 平衡二叉树的定义:二叉树中任意一个节点的左右子树的高度相差不能大于1。 从这个定义来看,完全二叉树、满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。 平衡二叉查找树不仅满足上面平衡二叉树的定义,还满足二叉查找树的特点。 最先被发明的平衡二叉查找树是AVL树,它严格符合平衡二叉查找树的定义,即任何节点的左右子树高度相差不超过 1,是一种高度平衡的二叉查找树。 但是很多平衡二叉查找树并没有严格符合上面的定义(树中任意一个节点的左右子树的高度相差不能大于 1),比如红黑树,它从根节点到各个叶子节点的最长路径,有可能会比最短路径大一倍。 发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。所以,平衡二叉查找树中“平衡”的意思,是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。 0.2. 红黑树 平衡二叉查找树其实有很多,比如: Splay Tree(伸展树) Treap(树堆)等, 但是提到平衡二叉查找树,基本都是红黑树,甚至默认平衡二叉查找树就是红黑树。 红黑树的英文是“Red-Black Tree”,简称 R-B Tree,它是一种不严格的平衡二叉查找树。红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求: 根节点是黑色的 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据(简化代码而设置的要求) 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点 0.2.1. 近似平衡的红黑树 平衡二叉查找树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题。所以: “平衡”的意思可以等价为性能不退化 “近似平衡”就等价为性能不会退化的太严重
0.1. 树的定义 0.2. 二叉树 0.2.1. 存储二叉树 0.2.1.1. 链式存储 0.2.1.2. 顺序存储 0.2.2. 遍历二叉树 0.2.3. 二叉查找树 0.2.3.1. 查找操作 0.2.3.2. 插入操作 0.2.3.3. 删除操作 0.2.3.4. 其他操作 0.2.3.5. 时间复杂度分析 0.2.4. 支持重复数据的二叉查找树 0.2.4.1. 插入操作 0.2.4.2. 查找操作 0.2.4.3. 删除操作 0.2.5. 二叉查找树与散列表的对比 0.2.5.1. 数据有序性 0.2.5.2. 稳定性 0.2.5.3. 执行效率 0.2.5.4. 复杂度 0.2.5.5. 存储空间利用率 树是一种非线性表结构比线性表的数据结构要复杂得多。 0.1. 树的定义 “树”中每个元素叫作“节点”;用来连线相邻节点之间的关系,叫作“父子关系”。 如下图所示: A 节点就是 B 节点的父节点,B 节点是 A 节点的子节点。 B、C、D 这三个节点的父节点是同一个节点,所以它们之间互称为兄弟节点。 没有父节点的节点叫作根节点,如图中的节点 E。 没有子节点的节点叫作叶节点,如图中的 G、H、I、J、K、L 都是叶子节点。 树还有三个比较相似的概念: 高度(Height):节点到叶子节点的最长路径(边数),从下往上看,起点为0 深度(Depth):根节点到这个节点所经历的边的个数,从上往下看,起点为0 层(Level):节点深度+1,从上往下看,起点为1 树的高度:根节点的高度+1 0.2. 二叉树 二叉树,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。 二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。 如上图所示: 编号 2 的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫作满二叉树。 编号 3 的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫作完全二叉树。 0.2.1. 存储二叉树 要理解完全二叉树定义的由来,需要先了解,如何表示(或者存储)一棵二叉树。想要存储一棵二叉树,有两种方法: 一种是基于指针的二叉链式存储法 一种是基于数组的顺序存储法 0.2.1.1. 链式存储 从图可以看到,每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。 只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式比较常用,大部分二叉树代码都是通过这种结构来实现的。 0.2.1.2. 顺序存储 基于数组的顺序存储法。 把根节点存储在下标 i=1 的位置 左子节点存储在下标 2*i=2 的位置 右子节点存储在下标 2*i+1=3 的位置 以此类推 所以,图中 D 节点的左子节点存储在 2*i=2*2=4 的位置,右子节点存储在 2*i+1=2*2+1=5 的位置。 如果节点 X 存储在数组中下标为 i 的位置: 下标为 2*i 的位置存储的就是左子节点 下标为 2*i+1 的位置存储的就是右子节点 下标为 i/2 的位置存储就是它的父节点 通过这种方式,我们只要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。 如果是一棵完全二叉树只会浪费下标为0的存储位置,如果是一棵非完全二叉树会浪费较多存储空间。 所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。 这也是为什么完全二叉树要求最后一层的子节点都靠左的原因。堆其实就是一种完全二叉树,最常用的存储方式就是数组。 0.2.2. 遍历二叉树 如何将所有节点都遍历打印出来,经典的方法有三种,前序遍历、中序遍历和后序遍历。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序。 前序遍历:对于树中的任意节点,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。 中序遍历:对于树中的任意节点,先打印它的左子树,然后再打印它本身,最后打印它的右子树。 后序遍历:对于树中的任意节点,先打印它的左子树,然后再打印它的右子树,最后打印它本身。 实际上,二叉树的前、中、后序遍历就是一个递归的过程。 递归的关键是递推公式和终止条件,递推公式的关键是问题拆分,如果要解决问题 A,就假设子问题 B、C 已经解决,然后再来看如何利用 B、C 来解决 A。 # 前序遍历的递推公式: preOrder(r) = print r->preOrder(r->left)->preOrder(r->right) # 中序遍历的递推公式: inOrder(r) = inOrder(r->left)->print r->inOrder(r->right) # 后序遍历的递推公式: postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r # 伪代码 void preOrder(Node* root) { if (root == null) return; print root // 此处为伪代码,表示打印root节点 preOrder(root->left); preOrder(root->right); } void inOrder(Node* root) { if (root == null) return; inOrder(root->left); print root // 此处为伪代码,表示打印root节点 inOrder(root->right); } void postOrder(Node* root) { if (root == null) return; postOrder(root->left); postOrder(root->right); print root // 此处为伪代码,表示打印root节点 } 二叉树遍历的时间复杂度是O(n)。 0.2.3. 二叉查找树 二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。 散列表也是支持这些操作的,并且散列表的这些操作比二叉查找树更高效,时间复杂度是 O(1)。 二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树,是为了实现快速查找而生的。它还支持快速插入、删除一个数据。这些都依赖于二叉查找树的特殊结构。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。 0.2.3.1. 查找操作 先取根节点,如果它等于我们要查找的数据,那就返回 如果要查找的数据比根节点的值小,那就在左子树中递归查找 如果要查找的数据比根节点的值大,那就在右子树中递归查找 0.2.3.2. 插入操作 插入过程有点类似查找操作。 新插入的数据一般都是在叶子节点上,所以只需要从根节点开始,依次比较要插入的数据和节点的大小关系 如果要插入的数据比节点的数据大 如果节点的右子树为空,就将新数据直接插到右子节点的位置 如果节点的右子树不为空,就再递归遍历右子树,查找插入位置 如果要插入的数据比节点数值小 如果节点的左子树为空,就将新数据插入到左子节点的位置 如果不为空,就再递归遍历左子树,查找插入位置 0.2.3.3. 删除操作 删除操作就比较复杂。针对要删除节点的子节点个数的不同,分三种情况来处理: 第一种情况是,如果要删除的节点没有子节点,只需要直接将父节点中,指向要删除节点的指针置为 null。比如图中的删除节点 55。 第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点 13。 第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,可以应用上面两条规则来删除这个最小节点。比如图中的删除节点 18。 实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。 0.2.3.4. 其他操作 二叉查找树中快速地查找最大节点和最小节点、前驱节点和后继节点。 中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效。因此,二叉查找树也叫作二叉排序树。 0.2.3.5. 时间复杂度分析 图中第一种二叉查找树,根节点的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了 O(n)。 大部分情况下,不管操作是插入、删除还是查找,时间复杂度其实都跟树的高度成正比,也就是 O(height)。将高度转为层数,第 K 层包含的节点个数就是 2^(K-1)。最后一层的节点个数在 1 个到 2^(L-1) 个之间(假设最大层数是 L)。 所以得到如下公式: n >= 1+2+4+8+...+2^(L-2)+1 n <= 1+2+4+8+...+2^(L-2)+2^(L-1) 借助等比数列的求和公式,可以计算出,L 的范围是[log2(n+1), log2n +1]。 需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,这就是平衡二叉查找树。平衡二叉查找树的高度接近 logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是 O(logn)。 0.2.4. 支持重复数据的二叉查找树 在实际的软件开发中,二叉查找树中存储的,是一个包含很多字段的对象。利用对象的某个字段作为键值(key)来构建二叉查找树,把对象中的其他字段叫作卫星数据。 0.2.4.1. 插入操作 如果存储的两个对象键值相同,有两种解决方法: 第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。 第二种方法比较不好理解,不过更加优雅。每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,就将这个要插入的数据放到这个节点的右子树,即把这个新插入的数据当作大于这个节点的值来处理。 0.2.4.2. 查找操作 当要查找数据的时候,遇到值相同的节点,并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。 0.2.4.3. 删除操作 对于删除操作,需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。 0.2.5. 二叉查找树与散列表的对比 散列表的插入、删除、查找操作的时间复杂度可以做到常量级的 O(1),非常高效 二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是 O(logn) 0.2.5.1. 数据有序性 散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序 二叉查找树只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列 0.2.5.2. 稳定性 散列表扩容耗时很多,当遇到散列冲突时,性能不稳定 二叉查找树的性能不稳定,但在工程中最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn) 0.2.5.3. 执行效率 散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快 加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高 0.2.5.4. 复杂度 散列表的构造比二叉查找树要复杂,需要考虑的东西很多,比如: 散列函数的设计 冲突解决办法 扩容 缩容 平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。 0.2.5.5. 存储空间利用率 为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。 平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。在实际的开发过程中,需要结合具体的需求来选择使用哪一个。
0.1. 哈希算法定义 0.1.1. MD5 0.2. 哈希算法的应用 0.2.1. 安全加密 0.2.2. 唯一标识 0.2.3. 数据校验 0.2.4. 散列函数 0.2.5. 负载均衡 0.2.6. 数据分片 0.2.6.1. 统计“搜索关键词”出现的次数 0.2.6.2. 快速判断图片是否在图库 0.2.7. 分布式存储 0.2.8. 解决用户密码存储问题 0.1. 哈希算法定义 将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。 一个优秀的哈希算法需要满足如下要求: 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法) 对输入数据非常敏感,哪怕原始数据只修改了1个Bit,最后得到的哈希值也大不相同 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值 0.1.1. MD5 MD5 的哈希值是 128 位的 Bit 长度 (16进制长度为32位)。 sugoi@sugoi:~$ echo "今天我来讲哈希算法" | md5sum eb4f874d1ef429470bb2808ae32323dc - sugoi@sugoi:~$ echo "今天我来讲哈希算法!" | md5sum a2cbc0646f7ce8ba5bb9d74821d4a6c9 - sugoi@sugoi:~$ echo "jiajia" | md5sum 4ced003def1157c81e8a1a2832c2f9f7 - 输出的结果长度固定 输入数据有细微差别,输出结果也差别很大 0.2. 哈希算法的应用 安全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储,后三个应用与分布式系统有关。 0.2.1. 安全加密 最常用于加密的哈希算法是 : MD5(MD5 Message-Digest Algorithm,MD5 消息摘要算法) SHA(Secure Hash Algorithm,安全散列算法) 其他加密算法 DES(Data Encryption Standard,数据加密标准) AES(Advanced Encryption Standard,高级加密标准) 当哈希算法用于加密时,很难根据哈希值反向推导出原始数据和散列冲突的概率要很小这两点就显得尤为重要。 加密的目的就是防止原始数据泄露,所以很难通过哈希值反向推导原始数据,这是一个最基本的要求。 不管是什么哈希算法,只能尽量减少碰撞冲突的概率,理论上是没办法做到完全不冲突的。为什么哈希算法无法做到零冲突? 抽屉原理:如果有 10 个鸽巢,有 11 只鸽子,那肯定有 1 个鸽巢中的鸽子数量多于 1 个,换句话说就是,肯定有 2 只鸽子在 1 个鸽巢内。 哈希算法产生的哈希值的长度是固定且有限的。如 MD5 的哈希值是固定的 128 位二进制串,能表示的数据是有限的,最多能表示 2^128=340282366920938463463374607431768211456 个数据,而需要哈希的数据是无穷的。 基于抽屉原理,如果对 2^128+1 个数据求哈希值,就必然会存在哈希值相同的情况。所以,一般情况下,哈希值越长的哈希算法,散列冲突的概率越低。 虽然哈希算法存在散列冲突的情况,但是因为哈希值的范围很大,冲突的概率极低,所以相对来说还是很难破解的。像 MD5,有2^128个不同的哈希值,这个数据已经是一个天文数字了,所以散列冲突的概率要小于1/2^128。 如果拿到一个 MD5 哈希值,希望通过毫无规律的穷举的方法,找到跟这个 MD5 值相同的另一个数据,那耗费的时间应该是个天文数字。所以,即便哈希算法存在冲突,但是在有限的时间和资源下,哈希算法还是被很难破解的。 没有绝对安全的加密。越复杂、越难破解的加密算法,需要的计算时间也越长。比如 SHA-256 比 SHA-1 要更复杂、更安全,相应的计算时间就会比较长。在实际的开发过程中,需要权衡破解难度和计算时间,来决定使用哪种加密算法。 0.2.2. 唯一标识 如果要在海量的图库中,搜索一张图是否存在,不能单纯地用图片的元信息(比如图片名称)来比对,因为可能存在名称相同但图片内容不同,或者名称不同图片内容相同的情况。 任何文件在计算中都可以表示成二进制码串,所以,可以拿要查找的图片的二进制码串与图库中所有图片的二进制码串一一比对。但是,每个图片小则几十KB、大则几MB,转化成二进制是一个非常长的串,比对起来非常耗时。 可以给每一个图片取一个唯一标识(或信息摘要)。比如: 从图片的二进制码串开头取 100 个字节 从中间取 100 个字节 从最后再取 100 个字节 然后将这 300 个字节放到一块 通过哈希算法(比如 MD5),得到一个哈希字符串,用它作为图片的唯一标识 通过这个唯一标识来判定图片是否在图库中,这样就可以减少很多工作量。 继续提高效率: 把每个图片的唯一标识和相应的图片文件在图库中的路径信息,都存储在散列表中 查看某个图片是不是在图库中的时候,先通过哈希算法对这个图片取唯一标识 然后在散列表中查找是否存在这个唯一标识 如果不存在,那就说明这个图片不在图库中 如果存在,再通过散列表中存储的文件路径,获取到这个已经存在的图片,跟现在要插入的图片做全量的比对,看是否完全一样 如果一样,就说明已经存在 如果不一样,说明两张图片尽管唯一标识相同,但是并不是相同的图片 0.2.3. 数据校验 BT 下载的原理是基于 P2P 协议的。 从多个机器上并行下载一个 2GB 的电影,这个电影文件可能会被分割成很多文件块(比如可以分成 100 块,每块大约 20MB)。等所有的文件块都下载完成之后,再组装成一个完整的电影文件。 网络传输是不安全的,下载的文件块有可能是被宿主机器恶意修改过的,又或者下载过程中出现了错误,导致下载的文件块可能不是完整的。如果没有能力检测这种恶意修改或者文件下载出错,就会导致最终合并后的电影无法观看,甚至导致电脑中毒。 BT 协议很复杂,校验方法也有很多,可以通过哈希算法,对 100 个文件块分别取哈希值,并且保存在种子文件中。 哈希算法有一个特点,对数据很敏感。只要文件块的内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,可以通过相同的哈希算法,对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再重新从其他宿主机器上下载这个文件块。 0.2.4. 散列函数 散列函数也是哈希算法的一种应用。 散列函数是设计一个散列表的关键。 它直接决定了散列冲突的概率和散列表的性能。 相对哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,都可以通过开放寻址法或者链表法解决。 散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是,一组数据是否能均匀地散列在各个槽中。 散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。 0.2.5. 负载均衡 负载均衡算法有很多,比如轮询、随机、加权轮询等。 实现一个会话粘滞(session sticky)的负载均衡算法,也就是说,同一个客户端上,在一次会话中的所有请求都路由到同一个服务器上。 最直接的方法就是,维护一张映射关系表,这张表的内容是客户端 IP 地址或者会话 ID 与服务器编号的映射关系。客户端发出的每次请求,都要先在映射表中查找应该路由到的服务器编号,然后再请求编号对应的服务器。 这种方法简单直观,但也有几个弊端: 如果客户端很多,映射表可能会很大,比较浪费内存空间; 客户端下线、上线,服务器扩容、缩容都会导致映射失效,这样维护映射表的成本就会很大; 如果借助哈希算法,可以非常完美地解决。通过哈希算法,对客户端 IP 地址或者会话 ID 计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。这样,就可以把同一个 IP 过来的所有请求,都路由到同一个后端服务器上。 0.2.6. 数据分片 哈希算法用于数据的分片。 0.2.6.1. 统计“搜索关键词”出现的次数 在1T的日志文件中,记录用户搜索关键词,如何快速统计每个关键词被搜索的次数? 难点: 日志量大,无法放在一台服务器的内存中 只用一台服务器处理,耗时太长 解决方案: 对数据进行分片 多台服务器处理,提高处理速度 具体思路: 为了提高处理的速度,用 n 台机器并行处理 从搜索记录的日志文件中,依次读出每个搜索关键词,并且通过哈希函数计算哈希值,然后再跟 n 取模,最终得到的值,就是应该被分配到的机器编号 同一个搜索关键词会被分配到同一个机器上,每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果 实际上,这里的处理过程也是 MapReduce 的基本设计思想。 0.2.6.2. 快速判断图片是否在图库 在唯一标识的应用中,给每个图片取唯一标识(或者信息摘要),然后构建散列表,来判断图片是否存在。 当图片数量巨大如上亿张图片时,在单台服务器上构建散列表是行不通的。因为单台机器的内存有限,而上亿张图片构建散列表显然远远超过了单台机器的内存上限。 同样对数据进行分片,然后采用多机处理。 准备 n 台服务器,让每台机器只维护某一部分图片对应的散列表 每次从图库中读取一个图片,计算唯一标识,然后与机器个数 n 求余取模,得到的值就对应要分配的机器编号 将这个图片的唯一标识和图片路径发往对应的机器构建散列表 当判断一个图片是否在图库中时,通过同样的哈希算法,计算这个图片的唯一标识,然后与机器个数 n 求余取模。假设得到的值是 k,那就去编号 k 的机器构建的散列表中查找。 假设图片数量是1亿张,散列表中每个数据单元包含哈希值和图片文件的路径。使用MD5算法生成的哈希值是128位(16字节),文件路径长度的上限是256字节(假设平均长度128字节)。使用链表法来解决冲突,每个指针占用8个字节,所以散列表一个数据单元大概152字节。 假设一台机器内存2GB,散列表装填因子0.75,那么一台机器可以给大约1000万(2GB×0.75./152B)图片构建索引。所以大概需要十几台机器。 借助数据分片的思路,可以突破单机内存、CPU等资源资源限制。 0.2.7. 分布式存储 面对海量数据和用户,为了提高数据的读写能力,一般都采用分布式的方式来存储数据,比如分布式缓存。对于海量数据的缓存,需要将缓存分布在多台机器上。 同样借助数据分片的思想,通过哈希算法对数据取哈希值,然后对机器个数取模,这个最终值就是应该存储的缓存机器编号。 但是随着数据的增多就需要扩容,这里的扩容并不是简单的增加机器,就像散列表的扩容一样,会涉及到数据缓存位置的重新计算,然后数据迁移到新的机器上。这样就相当于,缓存数据一下子全部失效了。所有的数据请求都会穿透缓存,直接去请求数据库,发生雪崩效应,压垮数据库。 需要一种方法,使得在新加入一个机器后,并不需要做大量的数据搬移,这就是一致性哈希算法。 假设有 k 个机器,数据的哈希值的范围是[0, MAX]。将整个范围划分成 m 个小区间(m 远大于 k),每个机器负责 m/k 个小区间。当有新机器加入的时候,将某几个小区间的数据,从原来的机器中搬移到新的机器中。这样,既不用全部重新哈希、搬移数据,也保持了各个机器上数据数量的均衡。 除了分布式缓存,实际上,一致性哈希算法的应用非常广泛,在很多分布式存储系统中,都可以见到一致性哈希算法的影子。 0.2.8. 解决用户密码存储问题 通过哈希算法,对用户密码进行加密之后再存储,选择相对安全的加密算法,比如 SHA 等(因为 MD5 已经号称被破解了)。 字典攻击:维护一个常用密码的字典表,把字典中的每个密码用哈希算法计算哈希值,然后拿哈希值跟脱库后的密文比对。如果相同,基本上就可以认为,这个加密之后的密码对应的明文就是字典中的这个密码。 针对字典攻击,可以引入一个盐(salt),跟用户的密码组合在一起,增加密码的复杂度,将组合之后的字符串做哈希算法加密,将它存储到数据库中,进一步增加破解的难度。 安全和攻击是一种博弈关系,不存在绝对的安全。所有的安全措施,只是增加攻击的成本而已。
0.1. 应用 0.2. 散列函数 0.3. 散列冲突 0.3.1. 开放寻址法 0.3.1.1. 线性探测 0.3.1.2. 二次探测 0.3.1.3. 双重散列 0.3.2. 链表法 0.4. 设计散列表 0.4.1. 常用散列函数设计方法 0.4.2. 避免装载因子过大 0.4.3. 避免低效扩容 0.4.3.1. 对于插入操作 0.4.3.2. 对于查询操作 0.4.4. 散列冲突解决办法 0.4.4.1. 开放寻址法 0.4.4.2. 链表法 0.5. 工业级散列表 0.5.1. LRU缓存淘汰算法 0.5.2. Redis有序集合 0.5.3. Java LInkedHashMap 散列表用的是数组支持按照下标随机访问数据的特性,时间复杂度是O(1),所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。 通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。 按照键值查询元素时,用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。 散列表两个核心问题是散列函数设计和散列冲突解决。散列函数设计的好坏决定了散列冲突的概率,也就决定散列表的性能。 0.1. 应用 Microsoft Word中拼写检查功能的实现。 常用的英文单词有 20 万个左右,假设单词的平均长度是 10 个字母,平均一个单词占用 10 个字节的内存空间,那 20 万英文单词大约占 2MB 的存储空间,就算放大 10 倍也就是 20MB。 对于现在的计算机来说,这个大小完全可以放在内存里面,所以可以用散列表来存储整个英文单词词典。 当用户输入某个英文单词时,拿用户输入的单词去散列表中查找 如果查到,则说明拼写正确 如果没有查到,则说明拼写可能有误,给予提示 借助散列表这种数据结构,可以轻松实现快速判断是否存在拼写错误。 0.2. 散列函数 散列函数在散列表中起着非常关键的作用,它是一个函数,把它定义成 hash(key),其中 : key 表示元素的键值 hash(key) 的值表示经过散列函数计算得到的散列值 散列函数设计的三点基本要求: 散列函数计算得到的散列值是一个非负整数:因为数组下标从0开始 如果 key1 = key2,那 hash(key1) == hash(key2) 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2) 对于第三点看起来合情合理,但是在真实的情况下,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。 即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。 MD5(MD5 Message-Digest Algorithm):密码散列函数,可以产生出一个128位的散列值,用于确保信息传输完整一致,曾被用于文件校验,但存在明显缺陷。 SHA(Secure Hash Algorithm):密码散列函数家族,能计算出一个数字消息所对应到的,长度固定的字符串(又称消息摘要)的算法。 CRC(Cyclic redundancy check):根据网络数据包或电脑文件等数据产生简短固定位数校验码的散列函数,主要用来检测或校验数据传输或者保存后可能出现的错误。 所以几乎无法找到一个完美的无冲突的散列函数,即便能找到,付出的时间成本、计算成本也是很大的,所以针对散列冲突问题,需要通过其他途径来解决。 0.3. 散列冲突 再好的散列函数也无法避免散列冲突,常用的散列冲突解决方法有两类: 开放寻址法(open addressing) 链表法(chaining) 0.3.1. 开放寻址法 开放寻址法的核心思想是,如果出现了散列冲突,就重新探测一个空闲位置,将其插入。 0.3.1.1. 线性探测 如何重新探测新的位置,比较简单的探测方法,线性探测(Linear Probing)。 当往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。 在散列表中查找元素的过程有点儿类似插入过程。 通过散列函数求出要查找元素的键值对应的散列值 然后比较数组中下标为散列值的元素和要查找的元素 如果相等,则说明就是要找的元素 否则就顺序往后依次查找 如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中 散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。 对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。不能单纯地把要删除的元素设置为空。 因为在查找的时候,一旦通过线性探测方法,找到一个空闲位置,就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。 因此将删除的元素,特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测。 线性探测法存在很大问题,当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。 极端情况下,可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。 0.3.1.2. 二次探测 为了解决开放寻址冲突的问题,所谓二次探测,跟线性探测很像: 线性探测:每次探测的步长是 1,探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2…… 二次探测:探测的步长就变成了原来的“二次方”,探测的下标序列就是 hash(key)+0,hash(key)+1^2,hash(key)+2^2…… 0.3.1.3. 双重散列 为了解决开放寻址冲突的问题,所谓双重散列,就是不仅要使用一个散列函数。 使用一组散列函数 hash1(key),hash2(key),hash3(key)…… 先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。 不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,会尽可能保证散列表中有一定比例的空闲槽位,用装载因子(load factor)来表示空位的多少。 装载因子的计算公式是:散列表的装载因子=填入表中的元素个数/散列表的长度,装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。 0.3.2. 链表法 链表法是一种更常用的散列冲突解决办法,相比开放寻址法,它要简单很多。 如下图所示,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中。 插入时,只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。 查找/删除时,通过散列函数计算出对应的槽,然后遍历链表查找或者删除。 查找或删除这两个操作的时间复杂度跟链表的长度 k 成正比即O(k)。对于散列比较均匀的散列函数来说,理论上讲,\(k=\frac{n}{m}\),其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。 0.4. 设计散列表 散列表的查询效率并不能笼统地说成是 O(1),这与散列函数、装载因子、散列冲突等都有关系。如果散列函数设计得不好,或者装载因子过高,都可能导致散列冲突发生的概率升高,查询效率下降。 在极端情况下,恶意攻击者通过精心构造的数据,使得所有的数据都散列到同一个槽里。如果使用的是基于链表的冲突解决方法,那散列表就会退化为链表,查询的时间复杂度就从 O(1) 急剧退化为 O(n)。 如果散列表中有 10 万个数据,退化后的散列表查询的效率就下降了 10 万倍。 如果之前运行 100 次查询只需要 0.1 秒,那现在就需要 10000 秒。 这样有可能因为查询操作消耗大量 CPU 或者线程资源,导致系统无法响应其他请求,从而达到拒绝服务攻击(DoS)的目的。这就是散列表碰撞攻击的基本原理。 需要设计一个可以应对各种异常情况的工业级散列表,来避免在散列冲突的情况下,散列表性能的急剧下降,并且能抵抗散列碰撞攻击。 散列函数设计的好坏,决定了散列表冲突的概率大小,也直接决定了散列表的性能: 散列函数的设计不能太复杂:散列函数太复杂,会消耗很多计算时间,间接的影响到散列表的性能 散列函数生成的值要尽可能随机并且均匀分布:这样才能避免或者最小化散列冲突,即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况 实际工作中,还需要综合考虑各种因素:关键字的长度、特点、分布、还有散列表的大小等。 0.4.1. 常用散列函数设计方法 数据分析法:例如,分析参赛编号的特征,把编号中的后两位作为散列值;手机号码前几位重复的可能性很大,但是后面几位就比较随机,可以取手机号的后四位作为散列值。 设计函数:例如,Microsoft Word中拼写检查功能中,将单词中每个字母的ASCll 码值“进位”相加,然后再跟散列表的大小求余、取模,作为散列值。比如,英文单词 nice:hash("nice")=\(\frac{(('n'-'a')*26*26*26+('i'-'a')*26*26+('c'-'a')*26+('e'-'a'))}{78978}\) 直接寻址法 平方取中法 折叠法 随机数法 0.4.2. 避免装载因子过大 装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率就越大: 插入的过程要多次寻址或者遍历很长的链表 查找的过程同样很慢 对于没有频繁插入和删除的静态数据集合,根据数据的特点、分布等,设计出完美的、极少冲突的散列函数。 对于动态散列表,数据集合频繁变动,事先无法预估将要加入的数据个数,所以无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。 当装载因子过大时,进行动态扩容重新申请一个更大的散列表,将数据搬移到新散列表中。 假设每次扩容都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是 0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成了 0.4。 针对数组的扩容,数据搬移操作比较简单。 针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,需要通过散列函数重新计算每个数据的存储位置。 散列表中插入一个数据: 最好情况下,不需要扩容,最好时间复杂度是 O(1)。 最坏情况下,散列表装载因子过高,启动扩容重新申请内存空间,重新计算哈希位置,并且搬移数据,时间复杂度是 O(n)。 用摊还分析法,均摊情况下,时间复杂度接近最好情况,就是 O(1)。 实际上,对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多: 如果对空间消耗敏感,可以在装载因子小于某个值之后,启动动态缩容 如果对执行效率敏感,能够容忍多消耗一点内存空间,就不用费劲缩容 当散列表的装载因子超过某个阈值时,就需要进行扩容,装载因子阈值需要选择得当: 如果太大,会导致冲突过多 如果太小,会导致内存浪费严重 装载因子阈值的设置要权衡时间、空间复杂度: 如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值 如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于 1 0.4.3. 避免低效扩容 0.4.3.1. 对于插入操作 大部分情况下,动态散列表插入数据的效率很高,只有在装载因子到达阈值需要先扩容在插入,这时插入数据很慢甚至无法接受。散列表中已有的数据越多,扩容越慢,需要重新计算每一个值的位置。 为了解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,只申请新空间,但并不将老的数据搬移到新散列表中。 当新数据要插入时,将新数据插入新散列表中,并从旧散列表中拿出一个数据放入到新散列表 每插入一个数据都重复上述过程 多次插入后,旧散列表中数据就全部搬移到新散列表中了 这样没有集中的一次性数据搬移,插入操作就都变得很快了。 0.4.3.2. 对于查询操作 为了兼容了新、旧散列表中的数据: 先从新散列表中查找 如果没有找到,再去旧散列表中查找 通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度都是 O(1)。 0.4.4. 散列冲突解决办法 在实际的软件开发中,开放寻址法和链表法是非常常用的两种解决散列冲突的方法,如: Java 中 LinkedHashMap 采用链表法解决冲突 Java 中 ThreadLocalMap 采用线性探测的开放寻址法解决冲突 下面对这两种冲突解决版本优劣和适用场景进行比较。 0.4.4.1. 开放寻址法 优点:数据都存储在数组中,有效地利用 CPU 缓存加快查询速度。这种方法实现的散列表,序列化起来比较简单(链表法包含指针,序列化起来就没那么容易),序列化在很多场合都会用到。 缺点:删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。所有的数据都存储在一个数组中,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。 当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的 ThreadLocalMap 使用开放寻址法解决散列冲突的原因。 0.4.4.2. 链表法 优点: 对内存的利用率比开放寻址法要高,因为链表结点在需要时才创建,不需要事先申请好。 对大装载因子的容忍度更高,开放寻址法只能适用装载因子小于 1 的情况,对于链表法,只要散列函数的值随机均匀,即便装载因子变成 10,也就是链表的长度变长了而已,虽然查找效率有所下降,但是比起顺序查找还是快很多。 缺点: 链表要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,可能会让内存的消耗翻倍。(如果存储的对象的大小远远大于一个指针的大小(4或8字节),那链表中指针的内存消耗可以忽略)。 链表中结点是零散分布在内存中的,对 CPU 缓存不友好,这对于执行效率也有一定的影响。 实际上,对链表法稍加改造,可以实现更加高效的散列表,将链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O(logn),这样有效避免了散列碰撞攻击。 基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。 0.5. 工业级散列表 Java中的HashMap。 初始化大小:默认的初始大小是 16,这个默认值可以设置,如果事先知道大概的数据量,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高 HashMap 的性能。 装填因子和动态扩容:最大装载因子默认是 0.75,当元素个数超过 0.75*capacity(capacity表示散列表的容量)时,会启动扩容,每次扩容都会扩容为原来的两倍大小。 散列冲突解决方法:底层采用链表法来解决冲突,即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。在 JDK1.8 中,对 HashMap 做进一步优化,引入了红黑树。当链表长度太长(默认超过 8)时,链表就转换为红黑树。当红黑树结点个数少于 8 个时,又会将红黑树转化为链表。 散列函数: int hash(Object key) { int h = key.hashCode(); // 返回 Java 对象的 hash code return (h ^ (h >>> 16)) & (capicity -1); // capicity表示散列表的大小 } public int hashCode() { int var1 = this.hash; if(var1 == 0 && this.value.length > 0) { char[] var2 = this.value; for(int var3 = 0; var3 < this.value.length; ++var3) { var1 = 31 * var1 + var2[var3]; } this.hash = var1; } return var1; } 利用红黑树快速增删改查的特点,提高 HashMap 的性能。在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。 工业级的散列表应该具有的特性: 支持快速的查询、插入、删除操作 内存占用合理,不能浪费过多的内存空间 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况 实现这样的散列表从这三个方面来考虑: 设计一个合适的散列函数 定义装载因子阈值,并且设计动态扩容策略 选择合适的散列冲突解决方法 还要结合具体的业务场景、具体的业务员数据来具体分析。 0.5.1. LRU缓存淘汰算法 借助散列表,可以把 LRU 缓存淘汰算法的时间复杂度降低为 O(1)。 通过链表实现 LRU 缓存淘汰算法的,需要维护一个按照访问时间从大到小有序排列的链表结构。 当缓存空间不够,需要淘汰一个数据,就直接将链表头部的结点删除。 当要缓存某个数据时,先在链表中查找这个数据。如果没有找到,则直接将数据放到链表的尾部;如果找到了,就把它移动到链表的尾部。查找数据需要遍历链表,所以单纯用链表实现的 LRU 缓存淘汰算法的时间复杂是 O(n)。 一个缓存(cache)系统主要包含下面这三个操作: 往缓存中查找一个数据; 从缓存中删除一个数据; 在缓存中添加一个数据。 这三个操作都要涉及“查找”操作,如果单纯采用链表,时间复杂度只能是 O(n)。 如果将散列表和链表组合使用,可以将这三个操作的时间复杂度都降低到 O(1),如下图所示: 使用双向链表存储数据,链表中的每个结点包括: 存储数据data 前驱指针prev 后继指针next 特殊字段hnext LRU中的散列表是通过链表法解决散列冲突的,所以每个结点会在两条链中: 一个链是双向链表:前驱和后继指针是为了将结点串在双向链表中 一个链是散列表中的拉链:hnext指针是为了将结点串在散列表的拉链中 使用散列表和双向链表的组合存储结果,实现一个换成系统三个操作的时间复杂度是O(1): 查找数据:散列表中查找数据的时间复杂度接近O(1),找到数据后将它移动到双向链表的尾部(LRU是从头开始删除的) 删除数据:借助散列表查找待删除数据时间复杂度O(1),借助双向链表删除数据时间复杂度O(1) 添加数据: 先查找,存在则移至尾部 不存在则判断链表是否已满, 满则删除双链表头部,然后添加到尾部 不满则直接添加到尾部 整个过程中,查找操作都借助散列表在O(1)完成,添加和删除操作都借助双向链表在O(1)完成。所以通过散列表和双向链表的组合使用,实现了一个高效的、支持 LRU 缓存淘汰算法的缓存系统原型。 0.5.2. Redis有序集合 在有序集合中,每个成员对象有两个重要的属性,key(键值)和 score(分值)。不仅会通过 score 来查找数据,还通过 key 来查找数据。 所以Redis有序集合的操作包括: 添加一个成员对象; 按照键值来删除一个成员对象; 按照键值来查找一个成员对象; 按照分值区间查找数据; 按照分值从小到大排序成员变量; 如果仅按照分值将成员对象组织成跳表的结构,那按照键值来删除、查询成员对象就会很慢。 解决方法与 LRU 缓存淘汰算法的解决方法类似。 按照分值构建跳表后再按照键值构建散列表,这样按照 key 来删除、查找一个成员对象的时间复杂度就变成了 O(1)。同时,借助跳表结构,其他操作也非常高效。 实际上,Redis 有序集合的操作还有查找成员对象的排名(Rank)或者根据排名区间查找成员对象。这个功能单纯用这种组合结构就无法高效实现了。 0.5.3. Java LInkedHashMap HashMap 底层是通过散列表实现的 LinkedHashMap 底层是通过散列表和双向链表组合在一起实现的(Linked指双向链表而不是用链表解决散列冲突) 在新建LinkedHashMap是可以传参设定是否按照访问时间排序,即是否启用LRU缓存淘汰策略。 散列表和链表经常一起使用的原因: 散列表虽然支持非常高效的数据插入、删除、查找操作,但数据都是通过散列函数打乱之后无规律存储的,无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,就需要将数据拷贝到数组中,然后排序,再遍历。 散列表是动态数据结构,不停地有数据的插入、删除,所以每当希望按顺序遍历散列表中数据时,都需要先排序,那效率势必会很低。为了解决这个问题,将散列表和链表(或者跳表)结合在一起使用。
0.1. 跳表定义 0.1.1. 时间复杂度 0.1.2. 空间复杂度 0.1.3. 高效的动态插入和删除 0.1.3.1. 插入 0.1.3.2. 删除 0.1.4. 索引动态更新 因为二分查找底层依赖的是数组随机访问的特性,所以只能用数组来实现。 如果数据存储在链表中,如何使用二分查找算法呢?实际上,只需要对链表稍加改造,就可以支持类似“二分”的查找算法。改造之后的数据结构叫作跳表(Skip list)。 跳表是一种各方面性能都比较优秀的动态数据结构,可以支持快速的插入、删除、查找操作,写起来也不复杂,甚至可以替代红黑树(Red-black tree)。 Redis 中的有序集合(Sorted Set)就是用跳表来实现的。红黑树同样也可以实现快速的插入、删除和查找操作。那 Redis 为什么会选择用跳表来实现有序集合呢? Redis 中的有序集合支持的核心操作主要有下面这几个: 插入一个数据; 删除一个数据; 查找一个数据; 按照区间查找数据(比如查找值在[100, 356]之间的数据); 迭代输出有序序列。 其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。 对于按照区间查找数据这个操作,跳表可以做到 \(O(logn)\) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。 其他原因: 跳表更容易代码实现 虽然跳表的实现也不简单,但比起红黑树还是好懂、好写,简单就意味着可读性好,不容易出错 跳表更加灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗 跳表也不能完全替代红黑树。 红黑树比跳表的出现要早一些,很多编程语言中的 Map 类型都是通过红黑树来实现的,拿来就用 跳表并没有一个现成的实现,在开发中,必须自己实现 0.1. 跳表定义 对于一个单链表,即使链表中存储的数据是有序的,如果要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 \(O(n)\)。 为了提高查找效率,如下图所示,对链表建立一级“索引”,查找起来就会更快一些,每两个结点提取一个结点到上一级,把抽出来的那一级叫作索引或索引层。图中的down指针,指向下一级结点。 此时要查找16,首先遍历索引层,读取到13和下一个节点17,定位到16的位置,在读取13的down指针后读取到16节点。 从原来访问单链表的10个节点减少为访问7个节点。 进一步优化,在第一级索引的基础上再每两个节点提取一个第二级索引,如下图所示。 此时还是查找16,首先遍历第二级索引,读取到13节点,然后读取down指针,读取到17节点,定位到16节点的位置,再读取down指针,后读取到16节点。 访问节点数又下降一个,所以随着链表节点数的增加,不断增加索引级别,访问效率会有很大提高。这种链表加上多级索引的结构,就是跳表。 0.1.1. 时间复杂度 在一个单链表中查询某个数据的时间复杂度是 \(O(n)\)。 索引的生成规则是每两个节点就提取一个节点作为上一级索引的节点。 第一级索引的节点数:\(\frac{n}{2}\) 第二级索引的节点数:\(\frac{n}{4}\) 第三级索引的节点数:\(\frac{n}{8}\) 依此类推 第k级索引的节点数:\(\frac{n}{2^k}\) 假设索引共有h级,最高级索引有2个节点,根据上面推倒\(\frac{n}{2^h}=2\),得到\(h=log2^(n-1)\),加上原始链表这一次层,整个跳表的高度数\(log2^n\)。 在条表中查询某个数据时,如果每一层都要遍历m个结点,则时间复杂度是\(O(m*logn)\)。 假设要查找的数据是 x: 在第 k 级索引中,遍历到 y 结点之后,发现 x 大于 y,小于后面的结点 z 通过 y 的 down 指针,从第 k 级索引下降到第 k-1 级索引 在第 k-1 级索引中,y 和 z 之间只有 3 个结点(包含 y 和 z),所以,在 K-1 级索引中最多只需要遍历 3 个结点 依次类推,每一级索引都最多只需要遍历 3 个结点 通过上面的分析,得到 m=3,所以在跳表中查询任意数据的时间复杂度就是 \(O(logn)\)。 这个查找的时间复杂度跟二分查找是一样的,因此,基于单链表就实现了二分查找。虽然这种查询效率的提升,但前提是建立了很多级索引,这是一种空间换时间的设计思路。 0.1.2. 空间复杂度 跳表需要存储多级索引,空间复杂度比较高: 原始链表节点数:\(n\) 第一级索引的节点数:\(\frac{n}{2}\) 第二级索引的节点数:\(\frac{n}{4}\) 依此类推 第k级索引的节点数:\(\frac{n}{2^k}\) 每上升一级,节点数就减半,直到剩下2个节点,每一层的节点数是一个等比数列。所以,全部索引的节点数总和是\(\frac{n}{2}+\frac{n}{4}+\frac{n}{8}…+8+4+2=n-2\),得到跳表的空间复杂度是\(O(n)\),相比于单链表需要的存储空间翻了一倍。 如果每3个节点提取一个结点到上级索引,那么: 第一级索引的节点数:\(\frac{n}{3}\) 第二级索引的节点数:\(\frac{n}{9}\) 依此类推 第k级索引的节点数:\(\frac{n}{3^k}\) 通过等比数列求和公式,总的索引结点大约就是 \(\frac{n}{3}\)+\(\frac{n}{9}\)+\(\frac{n}{27}\)+…+9+3+1=\(\frac{n}{2}\)。虽然空间复杂度还是\(O(n)\),但是相对于上面那种构建索引的方法,存储空间节约了一半。 在实际开发中,不必太在意索引占用的额外空间。在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。 0.1.3. 高效的动态插入和删除 跳表的动态插入和删除的时间复杂度是\(O(logn)\)。 0.1.3.1. 插入 单链表在确定位置后插入操作的时间复杂度是\(O(1)\) 跳表为了保证有序性,需要先遍历链表,确定待插入的位置这个时间复杂度是\(O(logn)\) 确定位置后以时间复杂度\(O(1)\)插入数据,此时先忽略更新索引 0.1.3.2. 删除 删除的过程与上面类似,只是需要如果待删除的节点刚好在索引上,那么除了删除原始链表中的节点,还需要在索引中将该节点删除。 在遍历获取删除节点位置时,需要记录前驱节点的指针,如果是双链表就不没这个问题了。 0.1.4. 索引动态更新 向跳表中不断添加数据的过程中,如果不更新索引,就会出现某2个索引节点之间的数据过多,极端情况,跳表就退化成单链表了。 跳表作为一种动态数据结构,需要某种手段来维护索引与原始链表大小之间的平衡,即如果链表中节点多了,索引节点也相应地增加一些,避免复杂度退化,以及查找、插入和删除操作性能下降。 红黑树、AVL树这样平衡二叉树,通过左右旋的方式保持左右子树的大小平衡,而跳表是通过随机函数来维护“平衡性”。 当向跳表中插入数据的时候,选择同时将这个数据插入到部分索引层中。通过一个随机函数,来决定将这个结点插入到哪几级索引中,如随机函数生成了值 K,那就将这个结点添加到第一级到第 K 级这 K 级索引中。 随机函数的选择很有讲究,从概率上来讲,能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。 至于随机函数的选择,参见 Redis 中关于有序集合的跳表实现。
0.1. 定义 0.2. 时间复杂度 0.3. 最简单情况的代码实现 0.4. 应用场景的局限性 0.5. 常见变形问题的代码实现 0.5.1. 查找第一个值等于给定值的元素 0.5.2. 查找最后一个值等于给定值的元素 0.5.3. 查找第一个大于等于给定值的元素 0.5.4. 查找最后一个小于等于给定值的元素 0.1. 定义 针对有序数据集合的查找算法:二分查找(Binary Search)算法,也叫折半查找算法。 假设有10个数据:8,11,19,23,27,33,45,55,67,98,快速找到19这个值是否在数据中。 利用二分思想,每次都与区间的中间数据比对大小,缩小查找区间的范围。过程如下图所示,其中,low 和 high 表示待查找区间的下标,mid 表示待查找区间的中间元素下标。 二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。 每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。 0.2. 时间复杂度 假设数据大小是 n,每次查找后数据都会缩小为原来的一半,也就是会除以 2。最坏情况下,直到查找区间被缩小为空,才停止。 这是一个等比数列。 其中 \(\frac{n}{2^k}=1\) 时,k 的值就是总共缩小的次数。 每一次缩小操作只涉及两个数据的大小比较,所以,经过了 k 次区间缩小操作,时间复杂度是 \(O(k)\)。 通过 \(\frac{n}{2^k}=1\),求得 \(k=log2n\),时间复杂度是 \(O(logn)\)。 \(O(logn)\) 这种对数时间复杂度,是一种极其高效的时间复杂度,有时甚至比时间复杂度是常量级 \(O(1)\) 的算法还要高效。 0.3. 最简单情况的代码实现 最简单的情况就是有序数组中不存在重复元素。 创建三个变量low、high、mid 都是数组下标, 其中 low 和 high 表示当前查找的区间范围,初始 low=0, high=n-1。 mid 表示\([low, high]\)的中间位置。 通过对比 \(a[mid]\)与 value 的大小,来更新接下来要查找的区间范围,直到找到或者区间缩小为 0,就退出。 注意: 循环退出条件:是 low>=high,而不是 low>igh mid的取值:\(mid=\frac{low+high}{2}\)在数据较大时可能溢出,改成\(low+\frac{(high-low)}{2}\),优化性能可以将除法运算改为位运算low+((high-low)>>1) low和high的更新:low=mid+1,high=mid-1,如果直接写成 low=mid 或者 high=mid,就可能会发生死循环 也可以使用递归实现: 递推公式:binarySearch(p...r,value)=binarySearch(p...mid-1,value) or binarySearch(mid+1...r,value) 终止条件:A[mid]=value or low >= high 0.4. 应用场景的局限性 二分查找依赖的是顺序表结构(简单点说就是数组):因为二分查找算法需要按照下标随机访问元素 二分查找针对的是有序数据:如果数据没有序,需要先排序,排序的时间复杂度最低是 \(O(nlogn)\)。所以,如果针对一组静态数据(没有频繁地插入、删除),可以进行一次排序,多次二分查找。这样排序的成本可被均摊,二分查找的边际成本就会比较低 数据量太小不适合二分查找:数据量太小,遍历数组就足够(如果数据之间的比较操作非常耗时,需要尽可能减少比较次数,推荐使用二分查找) 数据量太大也不适合二分查找:底层依赖数组,数组实现随机访问需要内存中连续的空间,这个条件比较苛刻,所以太大的数据用数组存储就比较吃力,就不能用二分查找 0.5. 常见变形问题的代码实现 有序数据集合中不存在重复的数据。 0.5.1. 查找第一个值等于给定值的元素 a[mid]跟要查找的 value 的大小关系有三种情况: 对于 a[mid]>value 的情况,更新 high=mid-1 对于 a[mid]<value 的情况,更新 low=mid+1 对于 a[mid]=value 的情况 如果查找的是任意一个值等于给定值的元素,当 a[mid] 等于要查找的值时,a[mid]就是要找的元素。 如果查找的是第一个值等于给定值的元素,当 a[mid] 等于要查找的值时,就需要确认一下这个 a[mid]是不是第一个值等于给定值的元素。 func BinarySearchFirst(list []int, value int) int { low := 0 high := len(list) - 1 for low <= high { mid := low + ((high - low) >> 1) if list[mid] < value { low = mid + 1 } else if list[mid] > value { high = mid - 1 } else { if mid == 0 || list[mid-1] != value { return mid } else { high = mid - 1 } } } return -1 } 看第 12 行代码。 如果 mid 等于 0,那这个元素已经是数组的第一个元素; 如果 mid 不等于 0,但 a[mid] 的前一个元素 a[mid-1] 不等于 value,那也说明 a[mid]就是要找的第一个值等于给定值的元素。 如果经过检查之后发现 a[mid]前面的一个元素 a[mid-1]也等于 value,那说明此时的 a[mid]肯定不是要查找的第一个值等于给定值的元素。更新 high=mid-1,要找的元素肯定出现在[low, mid-1]之间。 0.5.2. 查找最后一个值等于给定值的元素 基于上面的分析: 将第12行代码中if的判定条件修改为mid == len(a)-1 || list[mid+1] != value 将第15行代码修改为low=mid+1 0.5.3. 查找第一个大于等于给定值的元素 func BinarySearchGE(list []int, value int) int { length := len(list) low := 0 high := length - 1 for low <= high { mid := low + ((high - low) >> 1) if list[mid] >= value { if mid == 0 || list[mid-1] < value { return mid } else { high = mid - 1 } } else { low = mid + 1 } } return -1 } 看第7、8行代码: 如果list[mid]>=value,且mid==0,那就是要查找的值 如果list[mid]的前一个值小于value,那就是要查找的值 否则说明要查找的值在[low,mid-1]之间 如果list[mid]<value,说明要查找的之在[mid+1,high]之间 0.5.4. 查找最后一个小于等于给定值的元素 func BinarySearchLE(list []int, value int) int { length := len(list) low := 0 high := length - 1 for low <= high { mid := low + ((high - low) >> 1) if list[mid] > value { high = mid - 1 } else { if mid == len(list)-1 || list[mid+1] > value { return mid } else { low = mid + 1 } } } return -1 } 实际上,求“值等于给定值”的二分查找确实不怎么会被用到,二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显。 上述这些变体的二分查找算法很容易因为细节处理不好而产生 Bug,这些容易出错的细节有: 终止条件 区间上下界更新方法 返回值选择
0.1. 分析排序算法 0.1.1. 执行效率 0.1.2. 内存消耗 0.1.3. 稳定性 0.2. 常见排序算法 0.2.1. 冒泡排序 0.2.1.1. 有序度 0.2.2. 插入排序 0.2.3. 选择排序 0.2.4. 插入比冒泡更受欢迎 0.2.5. 三者对比 0.2.6. 归并排序 0.2.6.1. 性能分析 0.2.7. 快速排序 0.2.7.1. 性能分析 0.2.8. 归并与快排对比 0.3. 线性排序算法 0.3.1. 桶排序 0.3.2. 计数排序 0.3.2.1. 例子 0.3.3. 基数排序 0.4. 排序优化 0.4.1. 选择合适的排序算法 0.4.2. 优化快速排序 0.4.3. 实例 0.1. 分析排序算法 0.1.1. 执行效率 对于排序算法执行效率的分析,一般会从这几个方面来衡量: 最好情况、最坏情况、平均情况时间复杂度(最好、最坏情况对应的待排序原始数据是什么样的?):有序度不同的数据,对排序的执行时间有影响 时间复杂度的系数、常数 、低阶:实际开发中,待排序的数据规模是 10/100/1000 这样的小规模,所以,在对同一阶时间复杂度的排序算法性能对比时,要把系数、常数、低阶也考虑进来 比较次数和交换(或移动)次数:基于比较的排序算法的执行过程,会涉及元素比较和元素交换/移动的操作。所以,在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去 0.1.2. 内存消耗 算法的内存消耗通过空间复杂度来衡量,针对排序算法的空间复杂度,要引入了一个新的概念,原地排序(Sorted in place),原地排序算法,就是特指空间复杂度是 \(O(1)\) 的排序算法。 0.1.3. 稳定性 仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法,还有一个重要的度量指标,稳定性(如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变)。 排序算法是否稳定的影响,主要是在实际的软件开发中,使用对象的某个key来排序而非一个整数(key的先后顺序有实际的业务意义,而一个整数的先后顺序没有影响)。看下面具体的例子来感受一下。 现在要给电商交易系统中的“订单”排序。订单有两个属性: 下单时间 订单金额 排序的需求: 现在有 10 万条订单数据,按照金额从小到大对订单数据排序 对于金额相同的订单,按照下单时间从早到晚有序 最先想到的方法是: 先按照金额对订单数据进行排序 再遍历排序之后的订单数据,对于每个金额相同的小区间按照下单时间排序 这种排序思路理解起来不难,但是实现起来会很复杂。借助稳定排序算法,这个问题可以非常简洁地解决。 解决思路是这样的: 先按照下单时间(注意是按照下单时间,不是金额)给订单排序 排序完成之后,用稳定排序算法,按照订单金额重新排序 两遍排序之后,得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。 因为,稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。 第一次排序之后,所有的订单按照下单时间从早到晚有序 在第二次排序中,使用稳定排序算法,经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序 0.2. 常见排序算法 0.2.1. 冒泡排序 冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。 假设待排序数据为4,5,6,3,2,1,从小到大进行排序,第一次冒泡的详细过程如下图: 要完成所有数据的排序,需要进行6次冒泡操作: 将上述冒泡过程进行优化,当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再执行冒泡操作,如下示例: 在冒泡的过程,只涉及相邻数据的交换操作,只需要常量级的临时空间,空间复杂度为 \(O(1)\),是原地排序算法。 在冒泡的过程中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,不做交换,相同大小的数据在排序前后不会改变顺序,是稳定排序算法。 时间复杂度: 最好情况,待排序数据有序(满有序度),只需要进行一次冒泡,时间复杂度是\(O(n)\) 最坏情况,待排序数据倒序(有序度为0),要进行n次冒泡,时间复杂度是\(O(n^2)\) 平均情况,根据数据初始的有序度假设为满有序度的一般即\(\frac{n*(n-1)}{4}\),时间复杂度是\(O(n^2)\) 0.2.1.1. 有序度 有序度是数组中具有有序关系的元素对的个数。有序元素对用数学表达式表示:a[i] <= a[j], 如果i < j 逆序度的定义正好跟有序度相反(默认从小到大为有序):a[i] > a[j], 如果i < j 逆序度 = 满有序度 - 有序度。排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。 如{2,4,3,1,5,6}的有序度是11,{6,5,4,3,2,1}的有序度是0。 冒泡排序包含两个操作原子,比较和交换。每交换一次,有序度就加 1。 不管算法怎么改进,交换次数总是确定的,即为逆序度,也就是\(\frac{n*(n-1)}{2}\)减去初始有序度。 0.2.2. 插入排序 使用动态排序向一个已经有序的数据集合中添加数据,插入后依然保持数据有序,即遍历数组,找到数据应该插入的位置将其插入即可。 对于一组静态数据(即数据集合的元素个数不变),借助上面的思想实现插入排序。 将数组中的数据分为两个区间,已排序区间和未排序区间 初始已排序区间只有一个元素,就是数组的第一个元素。 插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。 重复这个过程,直到未排序区间中元素为空,算法结束。 假设有待排序数据为4,5,6,3,2,1,左侧为已排序区间,右侧为未排序区间。 插入排序也包含两种操作: 元素的比较 元素的移动 当需要将一个数据 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入。 对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。 插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 \(O(1)\),是原地排序算法 在插入排序中,对于值相同的元素,可以将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定排序算法 时间复杂度 最好情况,待排序数据有序(满有序度),只需从尾到头遍历一遍有序数据,时间复杂度是\(O(n)\) 最坏情况,待排序数据倒序(有序度为0),则每次插入都相当于在数组的第一个位置插入新的数据,时间复杂度是\(O(n^2)\) 平均情况,(数组这种数据结构插入数据的平均时间复杂度是\(O(n)\))对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度是\(O(n^2)\) 0.2.3. 选择排序 选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。 选择排序算法运行过程中不需要额外的空间,空间复杂度为 \(O(1)\),是原地排序算法。 在选择排序中,对于值相同的元素,选择先出现的值为最小值,这样就可以保持原有的前后顺序不变,但是,最小值会和前面的数据交换位置,这会导致其他的相同值的位置改变,所以选择排序是不稳定排序算法 时间复杂度: 最好情况,待排序数据有序(满有序度),每个数据都要与数据集合中剩下的数据进行比较,时间复杂度\(O(n^2)\) 最坏情况,待排序数据倒序(有序度为0),每个数据都要与数据集合中剩下的数据进行比较,时间复杂度\(O(n^2)\) 平均情况,对选择排序来说,没有最好最坏情况,所有数据都要进行比较,时间复杂度\(O(n^2)\) 0.2.4. 插入比冒泡更受欢迎 冒泡排序和插入排序的时间复杂度都是 \(O(n^2)\),都是原地排序算法: 冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度 插入排序不管怎么优化,元素移动的次数是一个固定值,是原始数据的逆序度 从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。 // 冒泡排序中数据的交换操作: if (a[j] > a[j+1]) { // 交换 a[j], a[j+1] = a[j+1], a[j] flag = true; } // 插入排序中数据的移动操作: if (a[j] > value) { a[j+1] = a[j]; // 数据移动 } else { break; } 把执行一个赋值语句的时间粗略地计为单位时间(unit_time),然后分别用冒泡排序和插入排序对同一个逆序度是 K 的数组进行排序。 冒泡排序,需要 K 次交换操作,每次需要 3 个赋值语句,所以交换操作总耗时就是 3*K 单位时间。 插入排序,数据移动操作只需要 K 个单位时间。 // 测试数据集合 []int{4, 5, 1, 6, 2, 3, 4, 5, 1, 6, 2, 3, 4, 5, 1, 6, 2, 3} // 冒泡排序,每秒调用6659475次 goos: linux goarch: amd64 pkg: deal/sort BenchmarkBubbleSort BenchmarkBubbleSort-4 6659475 183 ns/op PASS // 插入排序,每秒调用11622802次 goos: linux goarch: amd64 pkg: deal/sort BenchmarkInsertionSort BenchmarkInsertionSort-4 11622802 99.2 ns/op PASS 实际结果相差两倍,所以,虽然冒泡排序和插入排序在时间复杂度上是一样的,都是 \(O(n^2)\),但是如果希望把性能优化做到极致,那肯定首选插入排序,它的算法思路也有很大的优化空间,如希尔排序。 0.2.5. 三者对比 特定算法依赖特定的数据结构,这三种算法都依赖数组实现。 算法 是否原地排序 是否稳定 最好 最坏 平均 冒泡 Y Y \(O(n)\) \(O(n^2)\) \(O(n^2)\) 插入 Y Y \(O(n)\) \(O(n^2)\) \(O(n^2)\) 选择 Y N \(O(n^2)\) \(O(n^2)\) \(O(n^2)\) 冒泡排序、插入排序、选择排序这三种排序算法,它们的平均时间复杂度都是 \(O(n^2)\),比较高,适合小规模数据的排序。下面是两种时间复杂度为 \(O(nlogn)\) 的排序算法,归并排序和快速排序,都用到了分治的思想,适合大规模数据排序。 0.2.6. 归并排序 归并排序的核心思想是: 先把数组从中间分成前后两部分 然后对前后两部分分别排序 再将排好序的两部分合并在一起,这样整个数组就都有序了 归并排序使用的就是分治思想,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。 分治思想和递归思想很像,分治算法一般都是用递归来实现。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。 归并排序使用分治思想,用递归实现: 分析递推公式:MergeSort(p...r)=merge(mergeSort(p...q)+mergeSort(q+1...r)),q=(p+r/2) 找到终止条件:p>=r 将递推公式翻译成递归代码 在递推公式中,mergeSort()函数是被分解后的子问题,merge()函数是将已经有序的前后两部分合并为一个整体有序的数据集合,具体过程如下: 在函数内申请一个临时数组 分别从头开始比较已经有序的前后两部分 将较小的那一个先放入临时数组中 继续整个过程直到一个子数组中没有数据 将剩下的数组中全部数据直接插入临时数组末尾 将临时数组中的数据拷贝到原数组中 0.2.6.1. 性能分析 归并排序稳不稳定关键要看 merge() 函数,即两个有序子数组合并成一个有序数组的那部分代码,可以保证值相同的元素,在合并前后的先后顺序不变,是稳定排序算法。 递归的是时间复杂度公式:\(T(a)=T(b)+T(c)+K\),其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。所以归并排序的时间复杂度的计算公式是: # n=1时,只需要常量级的执行时间,所以表示为C。 T(1) = C # n>1 T(n) = 2*T(n/2) + n; # 继续分解 T(n) = 2*T(n/2) + n = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n ...... = 2^k * T(n/2^k) + k * n ...... # 得到结果 T(n) = 2^k × T(n/2^k) + kn # 当n=1时 T(n/2^k)=T(1) n/2^k=1 k=log2n # 将k带入公式 T(n)=Cn+nlog2n 归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 \(O(nlogn)\)。 不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。 在merge()函数中,创建了一个临时数据(额外申请的临时内存空间)用于存储中间结果。因此,归并排序不是原地排序算法,空间复杂度是\(O(n)\)。因为,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小。 0.2.7. 快速排序 快速排序,简称快排,利用的也是分治思想,有点像归并排序,但是思路完全不一样。快排的思想是: 要排序数组中下标从 p 到 r 之间的一组数据,选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。 遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。 经过这上述步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。 分治的思想,可以用递归的方式来实现: 递推公式:QuickSort(p...r)=quickSort(p...q)+quickSort(q+1...r) 终止条件:p>=r 递推公式翻译为递归代码 // 快速排序,A是数组,n表示数组的大小 quick_sort(A, n) { quick_sort_c(A, 0, n-1) } // 快速排序递归函数,p,r为下标 quick_sort_c(A, p, r) { if p >= r then return q = partition(A, p, r) // 获取分区点 quick_sort_c(A, p, q-1) quick_sort_c(A, q+1, r) } 实现的关键就是分区函数partition():随机选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 \(A[p…r]\)分区,函数返回 pivot 的下标。 在不考虑空间复杂度的情况下,申请两个临时数组分别存放大于和小于pivot的数据,最后将两个数组的数据以此拷贝到原数组中。 希望快排是原地排序算法,即空间复杂度得是 \(O(1)\),所以 partition() 分区函数就不能占用太多额外的内存空间,就需要在 \(A[p…r]\)的原地完成分区操作。 func partition(list []int, head int, tail int) int { pivot := list[tail] i := head for j := head; j < tail; j++ { if list[j] < pivot { list[i], list[j] = list[j], list[i] i++ } } // 遍历一遍数组,说明i之前的都是小于pivot // 交换i与pivot的值 list[i], list[tail] = list[tail], list[i] return i } 这里的处理有点类似选择排序: 通过游标 i 把 \(A[p…r-1]\)分成两部分 \(A[p…i-1]\)的元素都是小于 pivot 的,称为“已处理区间”,\(A[i…r-1]\)是“未处理区间” 每次都从未处理的区间 \(A[i…r-1]\) 中取一个元素 \(A[j]\),与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 \(A[i]\) 的位置 数组的插入操作,需要搬移数据,非常耗时。有一种处理技巧,就是交换,在 \(O(1)\) 的时间复杂度内完成插入操作。这里借助这个思想,只需要将 \(A[i]\) 与 \(A[j]\) 交换,就可以在 \(O(1)\) 时间复杂度内将\(A[j]\)放到下标为 i 的位置。 因为分区的过程涉及交换操作,如果数组中有两个相同的元素,那么元素的位置可能发生改变,所以快速排序不是稳定的排序算法。 0.2.7.1. 性能分析 时间复杂度 快排也是递归实现的,根据归并的分析思路,快排的时间复杂度也是\(O(nlogn)\): T(1) = C; // n=1时,只需要常量级的执行时间,所以表示为C。 T(n) = 2*T(n/2) + n; // n>1 这里的前提条件是,pivot的选择很合适刚好可以将数组分成比较均匀的两部分,这是一种最好情况。 那么在最坏情况下,pivot不能将数组划分的较均匀,如原数组已经有序,需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 \(\frac{n}{2}\) 个元素,这种情况下,快排的时间复杂度就从 \(O(nlogn)\) 退化成了 \(O(n^2)\)。 空间复杂度:\(O(1)\),不稳定的原地排序算法。 0.2.8. 归并与快排对比 归并排序的处理过程是由下到上,先处理子问题,然后再合并。 快速排序的处理过程是由上到下,先分区,然后再处理子问题。 归并排序虽然是稳定的、时间复杂度很稳定为 \(O(nlogn)\) 的排序算法,但它是非原地排序算法(合并函数无法在原地执行),所以应用没有快排广泛。 快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。 算法 是否原地排序 是否稳定 最好 最坏 平均 归并 N Y \(O(nlogn)\) \(O(nlogn)\) \(O(nlogn)\) 快排 Y N \(O(nlogn)\) \(O(n^2)\) \(O(nlogn)\) 0.3. 线性排序算法 时间复杂度都是线性的,因为是非基于比较的排序算法,不涉及元素之间的比较操作,但是对要排序的数据要求很苛刻,所以重点的是掌握这些排序算法的适用场景。 0.3.1. 桶排序 桶排序核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。 时间复杂度: 假设待排序的数据有 n 个, 把它们均匀地划分到 m 个桶内,每个桶里就有 \(k=\frac{n}{m}\) 个元素。 每个桶内部使用快速排序,时间复杂度为 \(O(k *logk)\)。 m 个桶排序的时间复杂度就是 \(O(m*k*logk)\),因为 \(k=\frac{n}{m}\),所以整个桶排序的时间复杂度就是 \(O(n*log(\frac{n}{m}))\)。 当桶的个数 m 接近数据个数 n 时,\(log(\frac{n}{m})\) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 \(O(n)\) 桶排序对数据的要求非常苛刻: 待排序数据需要容易划分成 m 个桶,并且,桶与桶之间有天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。 数据在各个桶之间的分布要比较均匀,否则桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 \(O(nlogn)\) 的排序算法了。 桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。 0.3.2. 计数排序 计数排序是桶排序的一种特例。当要排序的 n 个数据,这n个数据的范围并不大,如最大值是 k,把数据划分成 k 个桶,那么每个桶内的数据的值都是相同的,只是每个桶内的数据量不同,这样省掉了桶内排序的时间,只需要遍历数据并放入对应的桶中。 0.3.2.1. 例子 假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩放在一个数组 \(A[8]\)中,它们分别是:2,5,3,0,2,3,0,3。 考生的成绩从 0 到 5 分,使用大小为 6 的数组 \(C[6]\)表示桶,其中下标对应分数。\(C[6]\)内存储的并不是考生,而是对应的考生个数,只需要遍历一遍考生分数,就可以得到 \(C[6]\)的值。 分数为 3 分的考生有 3 个,小于 3 分的考生有 4 个,所以,成绩为 3 分的考生在排序之后的有序数组 \(R[8]\)中,会保存下标 4,5,6 的位置。 A[8]={2,5,3,0,2,3,0,3} C[6]={2,0,2,3,0,1} R[8]={0,0,2,2,3,3,3,5} 如何快速计算出,每个分数的考生在有序数组\(R[8]\)中对应的存储位置呢?这个处理方法非常巧妙,很不容易想到。 思路是这样的:对 \(C[6]\)数组顺序求和,\(C[6]\)存储的数据就变成了下面这样子。\(C[k]\)里存储小于等于分数 k 的考生个数。 C[6]={2,2,4,7,7,8} 数据准备完成后开始计数排序: 从后到前(算法稳定的根源)依次扫描数组 A,当扫描到 3 时,从数组 C 中取出下标为 3 的值 7 到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个 也就是说, 3 是数组 R 中的第 7 个元素 也就是说,数组 R 中下标为 6 的位置 把 3 放入到数组 R 中 此时小于等于 3 的元素就只剩下了 6 个了,所以相应的 \(C[3]\)要减 1,变成 6 以此类推,扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的 这种利用另外一个数组来计数的实现方式就很巧妙了。 计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。 计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。 如果数据精确到小数点后一位,需要将数据都乘以10转化为整数;如果数据是\([-1000,1000]\)需要将数据都加1000转化为整数。 0.3.3. 基数排序 如何实现手机号码的排序,借助稳定排序算法,这里有一个巧妙的实现思路: 先按照最后一位来排序手机号码, 再按照倒数第二位重新排序, 以此类推, 最后按照第一位重新排序。 经过 11 次排序之后,手机号码就都有序了。 注意,这里按照每位来排序的排序算法要是稳定的,否则最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。 根据每一位来排序,可以用桶排序或者计数排序,它们的时间复杂度是 \(O(n)\)。 如果要排序的数据有 k 位,就需要 k 次桶排序或者计数排序,总的时间复杂度是 \(O(k*n)\)。当 k 不大的时候,基数排序的时间复杂度就近似于 \(O(n)\)。 对于非等长数据排序,如字典中的英文单词。可以把所有的单词补齐到相同长度,位数不够的可以在后面补“0”,然后就可以使用基数排序。 基数排序要求待排序的数据是可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。 每一位的数据范围不能太大,要可以用线性排序算法(桶排序、计数排序)来排序,否则,基数排序的时间复杂度就无法做到 \(O(n)\) 了。 0.4. 排序优化 0.4.1. 选择合适的排序算法 算法 时间复杂度 稳定 原地 冒泡 \(O(n^2)\) Y Y 插入 \(O(n^2)\) Y Y 选择 \(O(n^2)\) Y Y 归并 \(O(nlogn)\) Y N 快排 \(O(nlogn)\) N Y 桶 \(o(n+k)\),k是数据范围 Y N 计数 \(o(n)\) Y N 基数 \(o(dn)\),d是维度 Y N 线性排序算法的时间复杂度比较低,适用场景比较特殊 如果对小规模数据进行排序,选择时间复杂度是 \(O(n^2)\) 的算法 如果对大规模数据进行排序,选择时间复杂度是 \(O(nlogn)\) 的算法更加高效 归并排序空间复杂度是\(O(n)\),消耗的内存空间翻倍,应用不广泛 快速排序在最坏情况下的时间复杂度是 \(O(n^2)\),需要优化一下,降低出现最坏情况的概率 0.4.2. 优化快速排序 如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,那快速排序算法就会变得非常糟糕,时间复杂度就会退化为 \(O(n^2)\),主要原因是分区点选的不够合理。 最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。 如果很粗暴地直接选择第一个或者最后一个数据作为分区点,不考虑数据的特点,肯定会出现最坏情况,时间复杂度是 \(O(n^2)\)。 为了提高排序算法的性能,要尽可能地让每次分区都比较平均。下面是两种比较常见和简单的分区算法: 三数取中法:从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点(如果要排序的数组比较大,可用“五数取中”或者“十数取中”) 随机法:每次从要排序的区间中,随机选择一个元素作为分区点,从概率的角度来看,不大可能会出现每次分区点都选的很差的情况 更多其他的方法 快速排序使用递归实现,因此递归存在的问题,快排也需要注意警惕堆栈溢出: 限制递归深度 通过在堆上模拟手动实现函数调用栈手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制 0.4.3. 实例 glibc中的qsort()函数: qsort() 会优先使用归并排序来排序输入数据(数据量小,空间换时间,归并时间复杂度稳定) 要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序(数据量大,时间换空间) 通过实现堆上栈来解决递归过深导致堆栈溢出的问题 在快排过程中,当待排序区间中,元素的个数<=4 时,qsort()退化为插入排序(小规模数据量,\(O(n^2)\) 时间复杂度并不一定比 \(O(nlogn)\) 执行时间长) 算法的性能可以通过时间复杂度来分析,但是,这种复杂度分析是比较偏理论的,实际上时间复杂度并不等于代码实际的运行时间。时间复杂度代表的是一个增长趋势。对于小数据量的排序,可以选择比较简单、不需要递归的插入排序算法。 假设 k=1000,c=200,当对小规模数据(比如 n=100)排序时,\(n^2\)的值实际上比 \(knlogn+c\) 还要小。 knlogn+c = 1000 * 100 * log100 + 200 远大于10000 n^2 = 100*100 = 10000 // 对于小规模数据的排序,O(n^2) 的排序算法并不一定比 O(nlogn) 排序算法执行的时间长
0.1. 递归定义 0.1.1. 递归需要满足的三个条件 0.2. 编写递归代码 0.3. 递归遇到的问题 0.3.1. 警惕堆栈溢出 0.3.2. 警惕重复计算 递归代码调试 0.4. 递归改写为非递归 0.1. 递归定义 递归是一种应用非常广泛的算法(或者编程技巧)。很多数据结构和算法的编码实现都要用到递归,比如: DFS深度优先搜索 前中后序二叉树遍历 标准的递归求解问题的分解过程: 去的过程叫“递”, 回来的过程叫“归”。 基本上,所有的递归问题都可以用递推公式来表示,如f(n)=f(n-1)+1,其中f(1)=1转换为代码: func f(n int) int { if n == 1 { return 1 } return f(n-1) + 1 } 0.1.1. 递归需要满足的三个条件 只要同时满足以下三个条件,就可以用递归来解决。 一个问题的解可以分解为几个子问题(数据规模更小的问题)的解 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样 存在递归终止条件:把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环 0.2. 编写递归代码 写递归代码最关键的是写出递推公式,找到终止条件,剩下将递推公式转化为代码就很简单了。 假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢? 实际上,可以根据第一步的走法把所有走法分为两类: 第一类是第一步走了 1 个台阶 另一类是第一步走了 2 个台阶 所以 n 个台阶的走法就等于先走 1 阶后,n-1 个台阶的走法,加上先走 2 阶后,n-2 个台阶的走法。用公式表示就是:f(n) = f(n-1)+f(n-2)。 有了递推公式,递归代码基本上就完成了一半。 再来看下终止条件,当有一个台阶时,不需要再继续递归,就只有一种走法,所以 f(1)=1。 可以用 n=2,n=3 这样比较小的数试验一下,上述递归终止条件是否满足。 n=2 时,f(2)=f(1)+f(0)。如果递归终止条件只有一个 f(1)=1,那 f(2) 就无法求解了 所以,还要有 f(0)=1,表示走 0 个台阶有一种走法,不过这样子看起来就不符合正常的逻辑思维了 所以,可以把 f(2)=2,作为一种终止条件,表示走 2 个台阶,有两种走法,一步走完或者分两步来走 n=3 时,f(3)=f(2)+f(1),求解为3,终止条件正确。 综上,递推公式和终止条件并转化为代码: /* 递推公式:f(n)=f(n-1)+f(n-2) 终止条件:f(1)=1,f(2)=2 */ func f(n int) int { if n == 1 { return 1 } if n == 2 { return 2 } return f(n-1) + f(n-2) } 写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。 当递归调用只有一个分支,即“一个问题只需要分解为一个子问题”时,很容易能够想清楚“递“和”归”的每一个步骤,所以写起来、理解起来都不难。但是,当一个问题要分解为多个子问题的情况,递归代码就没那么好理解了,人脑几乎没办法把整个“递”和“归”的过程一步一步都想清楚。 计算机擅长做重复的事情,所以递归正和它的胃口,而人脑更喜欢平铺直叙的思维方式。当我们看到递归时,总想把递归平铺展开,脑子里就会循环,一层一层往下调,然后再一层一层返回,试图想搞清楚计算机每一步都是怎么执行的,这样就很容易被绕进去。对于递归代码,这种试图想清楚整个递和归过程的做法,实际上是进入了一个思维误区。很多时候,理解起来比较吃力,主要原因就是自己给自己制造了这种理解障碍。 正确的思维方式应该是:如果一个问题 A 可以分解为若干子问题 B、C、D,可以假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A。而且,只需要思考问题 A 与子问题 B、C、D 两层之间的关系即可,不需要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。 因此,编写递归代码的关键是,只要遇到递归,就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。 写出递推公式 找到终止条件 翻译成递归代码 0.3. 递归遇到的问题 0.3.1. 警惕堆栈溢出 在实际的软件开发中,编写递归代码时,会遇到很多问题,比如堆栈溢出。而堆栈溢出会造成系统性崩溃,后果会非常严重。 回顾上一节内容“栈”,函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。 以Java为例,将JVM的堆栈大小设置为1KB,在求解较大数据规模时会出现如下堆栈报错Exception in thread "main" java.lang.StackOverflowError。 可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度(比如 1000)之后,就不继续往下再递归了,直接返回报错。 但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。如果实时计算,代码过于复杂,就会影响代码的可读性。所以,如果最大深度比较小,比如 10、50,就可以用这种方法,否则这种方法并不是很实用。 0.3.2. 警惕重复计算 使用递归时还会出现重复计算的问题,例如上面求解楼梯走法的问题: 从图中,可以直观地看到: 计算 f(5),需要先计算 f(4) 和 f(3) 计算 f(4) 还需要计算 f(3) f(3) 就被计算了很多次 这就是重复计算问题。 为了避免重复计算,可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算。 将上面求解楼梯走法的代码优化: var process = make(map[int]int) func f(n int) int { if n == 1 { return 1 } if n == 2 { return 2 } if process[n-1] != 0 { return process[n-1] + f(n-2) } process[n-1] = f(n - 1) return f(n-1) + f(n-2) } // 求解30层楼梯,从4ms下降到56us,已经不是一个数量级了 在时间复杂度上,递归多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本 在空间复杂度上,递归调用一次就会在内存栈中保存一次现场数据,所以需要额外考虑这部分的开销 递归代码调试 平时调试代码使用 IDE 的单步跟踪功能,像规模比较大、递归层次很深的递归代码,几乎无法使用这种调试方式。 常用的做法: 打印日志发现,递归值 结合条件断点,golang中Delve调试器使用delve debug进入 break设置断点 condition设置条件 0.4. 递归改写为非递归 递归操作有点像遍历单链表,需要有指针记录前一个节点,递归代码的非递归实现,也需要有变量记录上一个步骤的结果。 递归有利有弊: 利:是递归代码的表达力很强,写起来非常简洁 弊:是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题 所以,在开发过程中,要根据实际情况来选择是否需要用递归的方式来实现。 func f(n int) int { if n == 1 { return 1 } if n == 2 { return 2 } pre := 2 prepre := 1 ret := 0 for i := 3; i <= n; i++ { ret = pre + prepre prepre = pre pre = ret } return ret } // 求解30层楼梯,在490ns 笼统地讲,所有的递归代码都可以改为这种迭代循环的非递归写法。因为递归本身就是借助栈(系统或者虚拟机本身提供的)来实现的。 如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决如递归堆栈溢出或重复计算的问题,徒增了实现的复杂度。
0.1. 顺序队列和链式队列 0.2. 循环队列 0.3. 阻塞队列和并发队列 0.4. 应用 先进者先出,这就是典型的“队列”,最基本的操作也是两个: 入队 enqueue(),放一个数据到队列尾部 出队 dequeue(),从队列头部取一个元素 队列跟栈一样,也是一种操作受限的线性表数据结构。 0.1. 顺序队列和链式队列 队列可以用数组来实现,也可以用链表来实现。 用数组实现的队列叫作顺序队列 用链表实现的队列叫作链式队列 相比于栈只需要一个栈顶指针,队列需要两个指针: head指针:指向队头 tail指针:指向队尾 随着不停地进行入队、出队操作,head 和 tail 都会持续往后移动。当 tail 移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。因此需要进行数据搬移,每次进行出队操作都相当于删除数组下标为 0 的数据,要搬移整个队列中的数据,这样出队操作的时间复杂度就会从原来的 O(1) 变为 O(n)。 实际上,在出队时可以不用搬移数据。如果没有空闲空间了,只需要在入队时,再集中触发一次数据的搬移操作。 当队列的 tail 指针移动到数组的最右边后,如果有新的数据入队,可以将 head 到 tail 之间的数据,整体搬移到数组中 0 到 tail-head 的位置。 这种实现思路中,出队操作的时间复杂度仍然是 O(1),但入队操作的时间复杂度还是 O(1)。 基于链表的实现,同样需要两个指针:head 指针和 tail 指针。它们分别指向链表的第一个结点和最后一个结点。 如图所示: 入队时,tail->next= new_node, tail = tail->next 出队时,head = head->next 0.2. 循环队列 使用数组实现队列时,当tail==n,会有数据搬移操作,这样入队操作的性能会受到影响。可以使用循环队列解决这个问题。 原本数组是有头有尾的,是一条直线。现在把首尾相连,扳成了一个环。如下图所示: 图中队列的大小为 8,当前 head=4,tail=7。 当有一个新的元素 a 入队时,放入下标为 7 的位置。此时,不把 tail 更新为 8,而是将其在环中后移一位,到下标为 0 的位置。 当再有一个元素 b 入队时,将 b 放入下标为 0 的位置,然后 tail 加 1 更新为 1。 在 a,b 依次入队之后,循环队列中的元素就变成如下图所示: 这样可以避免数据搬移操作。循环队列实现的关键是,确定好队空和队满的判定条件。 数组实现的非循环队列中, 队满的判断条件是 tail == n 队空的判断条件是 head == tail 数组实现的循环队列中, 队满的判断条件是 (tail + 1)%n == head 队空的判断条件是 head == tail 当队满即(tail+1)%n=head时,tail 指向的位置实际上是没有存储数据的。所以,循环队列会浪费一个数组的存储空间。 0.3. 阻塞队列和并发队列 一些具有特殊特性的队列在实际开发中应用广泛,如阻塞队列和并发队列。 阻塞队列:在队列基础上增加了阻塞操作, 在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回 如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回 可以使用阻塞队列,轻松实现一个“生产者 - 消费者模型”,这种基于阻塞队列实现的“生产者 - 消费者模型”,可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。 基于阻塞队列,还可以通过协调“生产者”和“消费者”的个数,来提高数据的处理效率。如配置多个“消费者”,来应对一个“生产者”。 在多线程情况下,会有多个线程同时操作队列,这个时候就会存在线程安全问题,线程安全的队列叫作并发队列。 最简单直接的实现方式是直接在 enqueue()、dequeue() 方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。 基于数组的循环队列,利用 CAS(compare and swap,比较并交换) 原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因。 比较并交换(compare and swap, CAS),是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。 该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。 0.4. 应用 CPU 资源是有限的,任务的处理速度与线程个数并不是线性正相关。相反,过多的线程反而会导致 CPU 频繁切换,处理性能下降。所以,线程池的大小一般都是综合考虑要处理任务的特点和硬件环境,来事先设置的。 当线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理?一般有两种处理策略。 第一种是非阻塞的处理方式,直接拒绝任务请求; 另一种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。 如何存储排队的请求呢?我们希望公平地处理每个排队的请求,先进者先服务,所以队列这种数据结构很适合来存储排队请求。 队列有基于链表和基于数组这两种实现方式。这两种实现方式对于排队请求又有什么区别呢? 基于链表的实现方式,可以实现一个支持无限排队的无界队列(unbounded queue),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。 基于数组实现的有界队列(bounded queue),队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来说,就相对更加合理。设置一个合理的队列大小,也是非常有讲究的: 队列太大导致等待的请求太多 队列太小会导致无法充分利用系统资源、发挥最大性能 队列应用在线程池请求排队的场景,队列也可以应用在任何有限资源池中,用于排队请求,比如数据库连接池等。 实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。
0.1. 实现栈 0.2. 动态扩容 0.3. 栈的应用 0.3.1. 栈在函数中的应用 0.3.2. 栈在表达式求值中的应用 0.3.3. 栈在括号匹配中的应用 0.3.4. 栈在浏览器中的应用 后进者先出,先进者后出,这就是典型的“栈”结构。 栈是一种“操作受限”的线性表,只允许在一端插入和删除数据 从功能上来说,数组或链表确实可以替代栈,但特定的数据结构是对特定场景的抽象,而且,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错。 当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,就应该首选“栈”这种数据结构。 0.1. 实现栈 栈主要包含两个操作: 入栈:在栈顶插入一个数据 出栈:从栈顶删除一个数据 栈既可以用数组来实现,也可以用链表来实现。 用数组实现的栈,我们叫作顺序栈 用链表实现的栈,我们叫作链式栈 不管是顺序栈还是链式栈,存储数据只需要一个大小为 n 的数组就够了。 入栈和出栈过程中,只需要一两个临时变量存储空间,所以空间复杂度是 O(1)。 入栈和出栈过程中,只涉及栈顶个别数据的操作,所以时间复杂度都是 O(1)。 注意,存储数据需要一个大小为 n 的数组,并不是说空间复杂度就是 O(n)。因为,这 n 个空间是必须的,无法省掉。所以说空间复杂度的时候,是指除了原本的数据存储空间外,算法运行还需要额外的存储空间。 0.2. 动态扩容 顺序栈在初始化时需要事先指定栈的大小 链式栈大小虽然不受限制,但是存储next指针,内存消耗相对较多 数组动态扩容:当数组空间不够时,我们就重新申请一块更大的内存,将原来数组中数据统统拷贝过去。 如果要实现一个支持动态扩容的栈,只需要底层依赖一个支持动态扩容的数组就可以了。实际上,支持动态扩容的顺序栈,平时开发中并不常用到。此时出栈的时间复杂度是O(1),入栈的时间复杂度分为有空间O(1)和需要扩容O(n),入栈的均摊时间复杂度为O(1)。 0.3. 栈的应用 0.3.1. 栈在函数中的应用 栈比较经典的一个应用场景就是函数调用栈。 操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。 func main() { a, ret, res := 1, 0, 0 ret = add(3, 5) res = a + ret fmt.Printf("%d", res) } func add(x, y int) int { sum := 0 sum = x + y return sum } 上面代码mian()函数调用add()函数,获取计算结果,并且与临时变量a相加,最后打印res的值。在执行add()函数时,函数调用栈的情况如下图: 0.3.2. 栈在表达式求值中的应用 编译器通过两个栈实现表达式求值,其中一个栈保存操作数,另一个栈保存运算符。 从左向右遍历表达式 当遇到数字,直接压入操作数栈 当遇到运算符,就与运算符栈的栈顶元素比较 如果比栈顶元素优先级高,就将当前运算符压入栈 如果比栈顶元素优先级低或相同,从运算符栈中取栈顶元素,从操作数栈中取两个操作数,进行计算 把计算结果压入栈操作数栈 继续 如下示例为计算3+5*8-6: 0.3.3. 栈在括号匹配中的应用 假设表达式中只包含三种括号: 圆括号() 方括号[] 花括号{} 它们可以任意嵌套。 比如: 合法格式:{[] ()[{}]}或[{()}([])]等 不合法格式:{[}()]或[({)]等 用栈来判断一个包含三种括号的表达式字符串是否合法。 用栈来保存未匹配的左括号,从左到右依次扫描字符串。 当扫描到左括号时,则将其压入栈中; 当扫描到右括号时,从栈顶取出一个左括号。 如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。 如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。 当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。 0.3.4. 栈在浏览器中的应用 使用两个栈实现浏览器中前进后退的功能,假设两个栈分别为X和Y。 把首次浏览的页面压入栈X 当点击后退按钮时,再依次从栈X中出栈,并将出栈的数据依次压入栈Y中 当点击前进按钮时,再依次从栈Y中出栈,并将出栈的数据依次压入栈X中 当X中没有数据,说明没有可以继续后退浏览的页面 当Y中没有数据,说明没有可以继续前进浏览的页面
0.1. 应用场景 0.1.1. 基于链表实现LRU淘汰算法 0.2. 链表结构 0.2.1. 单链表 0.2.2. 循环链表 0.2.3. 双向链表 0.2.4. 删除操作 0.2.4.1. 删除某个值对应的节点 0.2.4.2. 删除某个指针对应的节点 0.3. 链表与数组性能比较 0.1. 应用场景 一个经典的链表应用场景,那就是 LRU(Least Recently Used,最近最少使用) 缓存淘汰算法。 缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见: CPU缓存 数据库缓存 浏览器缓存等 缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留,由缓存淘汰策略来决定。常见的策略有三种: 先进先出策略 FIFO(First In,First Out) 最少使用策略 LFU(Least Frequently Used) 最近最少使用策略 LRU(Least Recently Used) 缓存就是利用了空间换时间的设计思想。 如果把数据存储在硬盘上,会比较节省内存,但每次查找数据都要询问一次硬盘,会比较慢。但如果通过缓存技术,事先将数据加载在内存中,虽然会比较耗费内存空间,但是每次数据查询的速度就大大提高了。 对于执行较慢的程序,通过消耗更多的内存(空间换时间)来进行优化 对于消耗过多内存的程序,通过消耗更多的时间(时间换空间)来降低内存的消耗 0.1.1. 基于链表实现LRU淘汰算法 维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,从链表头开始顺序遍历链表。 如果此数据之前已经被缓存在链表中了,遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。 如果此数据没有在缓存链表中,又可以分为两种情况: 如果此时缓存未满,则将此结点直接插入到链表的头部; 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。 无论缓存是否满了,都需要遍历一遍链表,所以基于链表的实现思路,缓存访问的时间复杂度为 O(n)。 继续优化这个实现思路,比如引入散列表(Hash table)来记录每个数据的位置,将缓存访问的时间复杂度降到 O(1)。 0.2. 链表结构 对比链表与数组的底层存储结构: 数组需要一块连续的内存空间来存储,对内存的要求比较高。如果申请一个 100MB 大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 100MB,仍然会申请失败。 链表并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用。如果申请 100MB 大小的链表,根本不会有问题。 链表结构五花八门,最常见的三种链表结构:单链表、双向链表和循环链表。 0.2.1. 单链表 链表通过指针将一组零散的内存块串联在一起,其中的内存块称为链表的“结点”。 为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。如图所示,我们把这个记录下个结点地址的指针叫作后继指针next。 图中有两个结点是比较特殊的,它们分别是第一个结点(头结点)和最后一个结点(尾结点)。 头结点用来记录链表的基地址。有了它,就可以遍历得到整条链表。 尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点。 与数组一样,链表也支持数据的查找、插入和删除操作。 在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移,所以时间复杂度是 O(n)。而在链表中插入或者删除一个数据,并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的,只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O(1)。 链表要想随机访问第 k个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。所以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。 0.2.2. 循环链表 循环链表是一种特殊的单链表。与单链表唯一的区别就在尾结点。 单链表的尾结点指针指向空地址,表示这就是最后的结点了。 循环链表的尾结点指针是指向链表的头结点。它像一个环一样首尾相连,所以叫作“循环”链表。 和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表(如约瑟夫问题)。尽管用单链表也可以实现,但是用循环链表实现的话,代码就会简洁很多。 0.2.3. 双向链表 单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。 双向链表,支持两个方向,每个结点有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。 从图中看出来,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。 如果存储同样多的数据,双向链表要比单链表占用更多的内存空间 两个指针比较浪费存储空间,但支持双向遍历操作更灵活 从结构上来看,双向链表可以支持 O(1) 时间复杂度找到前驱结点,这使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。 0.2.4. 删除操作 在实际开发中,从链表中删除一个数据无外乎这两种情况: 删除结点中“值等于某个给定值”的结点 删除给定指针指向的结点 0.2.4.1. 删除某个值对应的节点 不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过指针操作将其删除。 删除操作时间复杂度是 O(1),但遍历查找的时间是主要的耗时点,对应的时间复杂度为 O(n)。根据时间复杂度分析中的加法法则,删除值等于给定值的结点对应的链表操作的总时间复杂度为 O(n)。 0.2.4.2. 删除某个指针对应的节点 已知要删除的结点的指针,但是删除某个结点 q 需要知道其前驱结点: 单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点,所以删除操作需要 O(n) 的时间复杂度 双向链表中的结点已经保存了前驱结点的指针,不需要再遍历,所以删除操作只需要 O(1) 的时间复杂度 同理在某个节点前插入节点也分两种情况,单链表时间复杂度O(n),双链表时间复杂度O(1)。 对于有序链表,双向链表的按值查询的效率也要比单链表高。因为,我们可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。 这就是为什么在实际的软件开发中,双向链表尽管比较费内存,但还是比单链表的应用更加广泛的原因。这也是用空间换时间的设计思想。 如果内存空间充足,更加追求代码的执行速度,可以选择空间复杂度相对较高、但时间复杂度相对很低的算法或者数据结构。 如果内存比较紧缺,比如代码跑在手机或者单片机上,就要反过来用时间换空间的设计思路。 0.3. 链表与数组性能比较 数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别,它们插入、删除、随机访问操作的时间复杂度正好相反。 操作 数组 链表 插入删除 O(n) O(1) 随机访问 O(1) O(n) 数组和链表的对比,并不能局限于时间复杂度。在实际的软件开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。 数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。 链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。 数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。 链表本身没有大小的限制,天然地支持动态扩容,这也是它与数组最大的区别。 除此之外,如果代码对内存的使用非常苛刻,那数组就更适合你。 因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。 而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,就有可能会导致频繁的 GC(Garbage Collection,垃圾回收)。 所以,在实际的开发中,针对不同类型的项目,要根据具体情况,权衡究竟是选择数组还是链表。
0.1. 如何实现随机访问 0.2. 低效的插入和删除 0.2.1. 插入操作 0.2.2. 删除操作 0.2.3. 警惕数组的访问越界问题 0.3. 容器能否完全替代数组 数组不仅是一种编程语言中的数据类型,还是一种最基础的数据结构。 在大部分编程语言中,数组都是从 0 开始编号的,但你是否下意识地想过,为什么数组要从 0 开始编号,而不是从 1 开始呢? 从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。如果用 a 来表示数组的首地址,a[0]就是偏移为 0 的位置,也就是首地址,a[k]就表示偏移 k 个 type_size 的位置,所以计算 a[k]的内存地址只需要用这个公式:a[k]_address = base_address + k * type_size。 但是,如果数组从 1 开始计数,那计算数组元素 a[k]的内存地址就会变为:a[k]_address = base_address + (k-1)*type_size。 对比两个公式,不难发现,从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。 数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。 可能还有历史原因,从C语言使用0作为下标开始,其他语言借鉴了这个设计,如Python还是负数下标。 0.1. 如何实现随机访问 数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。 线性表(Linear List),就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。链表、队列、栈等也是线性表结构。 非线性表,比如二叉树、堆、图等。在非线性表中,数据之间并不是简单的前后关系。 连续的内存空间和相同类型的数据。正是因为这两个限制,有利也有弊: 优势:随机访问 劣势:很多操作变得非常低效,如,删除、插入一个数据(为了保证连续性,就需要做大量的数据搬移工作) 创建一个长度为10的整型数组a := [10]int{},数组内默认十个0。如下图所示,计算机给数组a,分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 base_address = 1000。 计算机会给每个内存单元分配一个地址,通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址: a[i]_address = base_address + i * data_type_size data_type_size:表示数组中每个元素的大小(上面的例子中,存储的是int,所以是4个字节) 数组支持随机访问,根据下标随机访问的时间复杂度是O(1)。 0.2. 低效的插入和删除 数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。 0.2.1. 插入操作 假设数组的长度为 n,如果要将一个数据插入到数组中的第 k 个位置。为了把第 k 个位置腾出来,给新来的数据,我们需要将第 k~n 这部分的元素都顺序地往后挪一位,所以,插入操作的时间复杂度是: 最好:O(1) 最坏:O(n) 平均:O(n) 均摊:O(n) 如果数组中的数据是有序的,在某个位置插入一个新的元素时,就必须按照上面的方法搬移 k 之后的数据。 如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。在这种情况下,如果要将某个数据插入到第 k 个位置,为了避免大规模的数据搬移,有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。利用这种处理技巧,在特定场景下,在第 k 个位置插入一个元素的时间复杂度就会降为 O(1)。 0.2.2. 删除操作 跟插入数据类似,如果要删除第 k 个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了,所以删除操作的时间复杂度是: 最好:O(1) 最坏:O(n) 平均:O(n) 均摊:O(n) 实际上,在某些特殊场景下,并不一定非得追求数组中数据的连续性。如果将多次删除操作集中在一起执行,删除的效率就会提高不少。 为了避免后续数据会被多次搬移,可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。JVM 标记清除垃圾回收算法的核心思想。 0.2.3. 警惕数组的访问越界问题 根据语言的不同,数组越界的问题不同,在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。根据前面讲的数组寻址公式,越界的数组访问了不属于数组的内存地址上,得到上个内存地址上保存的值。 数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误,这种情况下,一般都会出现莫名其妙的逻辑错误。 很多计算机病毒正是利用到了代码中的数组越界可以访问非法地址的漏洞,来攻击系统,所以写代码的时候一定要警惕数组越界。 但并非所有的语言都像 C 一样,把数组越界检查的工作丢给程序员来做,像 Java 本身就会做越界检查,抛出java.lang.ArrayIndexOutOfBoundsException。 0.3. 容器能否完全替代数组 针对数组类型,很多语言都提供了容器类,比如 Java 中的 ArrayList、C++ STL 中的 vector。 以Java为例,ArrayList 优势: 将数组操作的细节封装起来(如插入、删除数据时需要搬移其他数据等) 支持动态扩容(存储空间不够时,不需要关心底层的扩容逻辑,会将空间自动扩容为 1.5 倍大小) 注意:因为扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小。 也存在一些局限性: Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。 表示多维数组时,用数组往往会更加直观,Object[][] arrayVSArrayList<ArrayList<object>>array。 对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。
0.1. 数据结构与算法 0.2. 分析、统计算法的执行效率和资源消耗 0.2.1. 事后统计法 0.2.2. 大O复杂度表示法 0.2.3. 时间复杂度分析 0.2.3.1. 只关注循环执行次数最多的一段代码 0.2.3.2. 加法法则:总复杂度等于量级最大的那段代码的复杂度 0.2.3.3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积 0.2.3.4. 常见时间复杂度分析 0.2.3.4.1. \(O(1)\) 0.2.3.4.2. \(O(logn)\)&\(O(nlogn)\) 0.2.3.4.3. \(O(m+n)\)&\(O(m*n)\) 0.2.4. 空间复杂度分析 0.3. 最好、最坏情况时间复杂度 0.4. 平均情况时间复杂度 0.5. 均摊时间复杂度 0.1. 数据结构与算法 从广义上讲: 数据结构:一组数据的存储结构。 算法:操作数据的一组方法。 从狭义上讲: 指某些著名的数据结构和算法,比如队列、栈、堆、二分查找、动态规划等。 数据结构和算法是相辅相成的。数据结构是为算法服务的,算法要作用在特定的数据结构之上。 因此,我们无法孤立数据结构来讲算法,也无法孤立算法来讲数据结构。 数据结构是静态的,它只是组织数据的一种方式。如果不在它的基础上操作、构建算法,孤立存在的数据结构就是没用的。 数据结构和算法解决的是如何更省、更快地存储和处理数据的问题,因此,我们就需要一个考量效率和资源消耗的方法,这就是复杂度分析方法。 最常用的10个数据结构: 数组 链表 栈 队列 散列表 二叉树 堆 跳表 图 Trie树 最常用的10个算法: 递归 排序 二分查找 搜索 哈希算法 贪心算法 分治算法 回溯算法 动态规划 字符串匹配算法 学习数据结构与算法的: 来历 自身特点 适合解决的问题 实际的应用场景 0.2. 分析、统计算法的执行效率和资源消耗 数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。 0.2.1. 事后统计法 把代码跑一遍,通过统计、监控,就能得到算法执行的时间和占用的内存大小,这种评估算法执行效率的方法是正确的,但是有很大的局限性。 测试结果非常依赖测试环境 测试结果受数据规模的影响很大 我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法。这就是时间、空间复杂度分析方法。 0.2.2. 大O复杂度表示法 算法的执行效率,粗略地讲,就是算法代码执行的时间。如下示例,求1...n的累加和: 1 func cal(n int) int { 2 sum := 0 3 i := 1 4 for ; i <= n; i++ { 5 sum += i 6 } 7 return sum 8 } 从 CPU 的角度来看,这段代码的每一行都执行着类似的操作:读数据->运算->写数据。 尽管每行代码对应的 CPU 执行的个数、执行的时间都不一样,但是,我们这里只是粗略估计,所以可以假设每行代码执行的时间都一样,为unit_time。 在这个假设的基础之上,这段代码的总执行时间是: 第2、3行代码分别需要1个unit_time的执行时间 第4、5行代码都运行了n遍,需要的时间是\(2n×unit\_time\)的执行时间 整段代码总执行时间是\((2n+2)×unit\_time\) 如下示例,两重for循环: 1 func cals(n int) { 2 sum := 0 3 i := 1 4 j := 1 5 for ; i <= n; i++ { 6 j = 1 7 for ; j <= n; j++ { 8 sum = sum + i*j 9 } 10 } 11 } 这段代码的总执行时间T(n)是: 第 2、3、4 行代码,每行都需要 1 个 unit_time 的执行时间 第 5、6 行代码循环执行了 n 遍,需要 \(2n × unit\_time\) 的执行时间 第 7、8 行代码循环执行了 \(n^2\) 遍,所以需要 \(2n^2 × unit\_time\) 的执行时间 整段代码总的执行时间是\((2n^2+2n+3) × unit\_time\) 所有代码的执行时间 T(n) 与每行代码的执行次数成正比。 总结成公式:\(T(n)=O(f(n))\) T(n):表示代码执行的时间 n:表示数据规模的大小 f(n):表示每行代码执行的次数总和 O:表示代码的执行时间T(n)与f(n)表达式成正比 所以,第一个例子\(T(n)=O(2n+2)\),第二个例子\(T(n)=O(2n^2+2n+3)\)。 大O时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐近时间复杂度(asymptotic time complexity),简称时间复杂度。 当 n 很大时,可以把它想象成 10000、100000。而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。只需要记录一个最大量级就可以了,如果用大 O 表示法表示刚讲的那两段代码的时间复杂度,就可以记为:\(T(n)=O(n)\); \(T(n)=O(n^2)\)。 0.2.3. 时间复杂度分析 0.2.3.1. 只关注循环执行次数最多的一段代码 大O复杂度表示法只是表示一种变化趋势。通常会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级。 所以,在分析一个算法、一段代码的时间复杂度时,只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。 0.2.3.2. 加法法则:总复杂度等于量级最大的那段代码的复杂度 从时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量的执行时间多大,都可以忽略掉,因为它本身对增长趋势并没有影响。 总的时间复杂度就等于量级最大的那段代码的时间复杂度。 \[T1(n)=O(f(n))\] \[T2(n)=O(g(n))\] \[T(n)=T1(n)+T2(n)=max(O(f(n))\] \[O(g(n))=O(max(f(n),g(n)))\] 0.2.3.3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积 \[T1(n)=O(f(n))\] \[T2(n)=O(g(n))\] \[T(n)=T1(n)*T2(n)=O(f(n))*O(g(n))=O(f(n)*g(n))\] 把乘法法则看成是嵌套循环。 0.2.3.4. 常见时间复杂度分析 复杂度多项式量级: 常量阶:\(O(1)\) 对数阶:\(O(logn)\) 线性阶:\(O(n)\) 线性对数阶:\(O(nlogn)\) 平方阶:\(O(n^2)\) 立方阶:\(O(n^3)\) k次方阶:\(O(n^k)\) 复杂度非多项式量级: 指数阶:\(O(2^n)\) 阶乘阶:\(O(n!)\) 时间复杂度为非多项式量级的算法问题叫作 NP(Non-Deterministic Polynomial,非确定多项式)问题。 当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。 0.2.3.4.1. \(O(1)\) 代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度都记作 O(1)。一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)。 0.2.3.4.2. \(O(logn)\)&\(O(nlogn)\) 对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度。 1 i := 1 2 for i <= n { 3 i *= 2 4 } 5 } 第三行代码是循环执行次数最多的,代表整段代码的时间复杂度。 从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2,当大于 n 时,循环结束。实际上,变量 i 的取值就是一个等比数列,\(2^0,2^1,...,2^k,...2^x=n\)。只要知道x的值,就知道代码的执行次数。通过\(2^x=n\)求解x得\(x=log2^n\),所以代码的时间复杂的就是\(O(log2^n)\)。 实际上,不管是以 2 为底、以 3 为底,还是以 10 为底,我们可以把所有对数阶的时间复杂度都记为\(O(logn)\)而直接忽略对数的底。因为对数之间可以相互转换,即\(log3^n=log3^2×2log2^n\),所以\(O(log3n)=o(C×log2^n)\),其中\(C=log3^2\)是一个常量,可以忽略。 根据乘法法则,如果一段代码时间复杂度是\(O(logn)\),那么它循环执行n遍的时间复杂度就是\(O(nlogn)\)。 0.2.3.4.3. \(O(m+n)\)&\(O(m*n)\) 另一种时间复杂度,由两个数据的规模来决定。 1 func cal(m, n int) int { 2 sum1 := 0 3 i := 1 4 for ; i < m; i++ { 5 sum1 += i 6 } 7 8 sum2 := 0 9 j := 1 10 for ; i < n; j++ { 11 sum2 += j 12 } 13 14 return sum1 + sum2 15 } m 和 n 是表示两个数据规模,无法事先评估 m 和 n 谁的量级大,所以在表示复杂度时,不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是 \(O(m+n)\)。 针对这种情况,原来的加法法则就不正确了,需要将加法规则改为:\(T1(m)+T2(n)=O(f(m)+g(n))\)。但是乘法法则继续有效:\(T1(m)×T2(n)=O(f(m)×f(n))\)。 0.2.4. 空间复杂度分析 空间复杂度全称就是渐近空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。 1 func print(n int) { 2 i := 0 3 a := make([]int, n) 4 for ; i < n; i++ { 5 a[i] = i * i 6 } 7 8 for i = n - 1; i >= 0; i++ { 9 print(a[i]) 10 } 11 } 第 2 行代码中,申请一个空间存储变量 i,它是常量阶跟数据规模 n 无关,可以忽略 第 3 行申请了一个大小为 n 的 int 类型切片 其他代码都没有占用空间,所以整段代码的空间复杂度是\(O(n)\) 常见的空间复杂度就是 \(O(1)\)、\(O(n)\)、\(O(n^2)\),像 \(O(logn)\)、\(O(nlogn)\) 这样的对数阶复杂度平时都用不到。 0.3. 最好、最坏情况时间复杂度 // n 代表数组array的长度 func find(array []int, n, x int) int { i := 0 pos := -1 for ; i < n; i++ { if array[i] == x { pos = i } } return pos } // 在一个无序的数组(array)中,查找变量 x 出现的位置。如果没有找到,就返回 -1 // 时间复杂度O(n) 在数组中查找一个数据,并不需要每次都把整个数组都遍历一遍,因为有可能中途找到就可以提前结束循环了,上面代码写得不够高效。可以这样优化一下这段查找代码。 // n 代表数组array的长度 func find(array []int, n, x int) int { i := 0 pos := -1 for ; i < n; i++ { if array[i] == x { pos = i break } } return pos } 那么问题来了,优化完之后,这段代码的时间复杂度还是 \(O(n)\) 吗? 要查找的变量 x 可能出现在数组的任意位置,不同的情况下,这段代码的时间复杂度是不一样的。 如果数组中第一个元素正好是要查找的变量 x,那就不需要继续遍历剩下的 n-1 个数据了,那时间复杂度就是 \(O(1)\)。 如果数组中不存在变量 x,那就需要把整个数组都遍历一遍,时间复杂度就成了 \(O(n)\)。 最好情况时间复杂度:在最理想的情况下,执行这段代码的时间复杂度。 最坏情况时间复杂度:在最糟糕的情况下,执行这段代码的时间复杂度。 0.4. 平均情况时间复杂度 最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。为了更好地表示平均情况下的复杂度,引入平均情况时间复杂度,简称为平均时间复杂度。 使用上面的查找X的例子,有 n+1 种情况:n种情况,在数组的 \(0~n-1\) 位置中以及1种情况,不在数组中。把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1,就可以得到需要遍历的元素个数的平均值,即: 省略掉系数、低阶、常量,把这个公式简化后,得到的平均时间复杂度就是 \(O(n)\)。 上述计算过程存在一些问题,忽略了各种情况出现的概率。 假设在数组中与不在数组中的概率都为 \(\frac{1}{2}\) 要查找的数据出现在 \(0~n-1\) 这 n 个位置的概率也是一样的,为 \(\frac{1}{n}\) 根据概率乘法法则,要查找的数据出现在 \(0~n-1\) 中任意位置的概率就是 \(\frac{1}{2n}\)。 如果把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样: 这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。 引入概率之后,前面那段代码的加权平均值为 \(\frac{3n+1}{4}\)\(/4\)。用大 O 表示法来表示,去掉系数和常量,这段代码的加权平均时间复杂度仍然是 \(O(n)\)。 实际上,在大多数情况下,并不需要区分最好、最坏、平均情况时间复杂度三种情况。很多时候,使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,才会使用这三种复杂度表示法来区分。 0.5. 均摊时间复杂度 平均复杂度只在某些特殊情况下才会用到,而均摊时间复杂度应用的场景比它更加特殊、更加有限。 var array = make([]int, n) var count = 0 func insert(val int) { if count == len(array) { sum := 0 for i := 0; i < len(array); i++ { sum += array[i] } array[0] = sum count = 1 } array[count] = val count++ } 这段代码实现了一个往数组中插入数据的功能。 当数组满了之后,也就是代码中的 count == len(array) 时, 用 for 循环遍历数组求和,并清空数组, 将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。 如果数组一开始就有空闲空间,则直接将数据插入数组。 时间复杂度: base case:\(O(1)\) worst case: \(O(n)\) average case:\(O(1)\),绝大部分情况都是\(O(1)\),只有一种情况是\(O(n)\),发生的概率都是\(\frac{1}{n+1}\) 分析上面的find和这里的insert函数,发现: 区别1:find函数在极端情况下,复杂度才为 \(O(1)\)。但insert在大部分情况下,时间复杂度都为\(O(1)\) 区别2:insert 函数,\(O(1)\) 时间复杂度的插入和 \(O(n)\) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 \(O(n)\) 插入之后,紧跟着 n-1 个 \(O(1)\) 的插入操作,循环往复。 针对这样一种特殊场景的复杂度分析,并不需要找出所有的输入情况及相应的发生概率,然后再计算加权平均值。引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度叫均摊时间复杂度。 以上面insert函数为例,每一次 \(O(n)\) 的插入操作,都会跟着 n-1 次 \(O(1)\) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 \(O(1)\)。 对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。 均摊时间复杂度就是一种特殊的平均时间复杂度。
0.1. Redis中数据结构 0.1.1. 键和值的结构组织 0.1.2. 哈希表操作阻塞 0.1.3. 集合数据操作效率 0.1.3.1. 压缩列表 0.1.3.2. 跳表 0.1.4. 操作的效率 0.1.4.1. 单元素操作 0.1.4.2. 范围操作 0.1.4.3. 统计操作 0.1.4.4. 例外情况 Redis的快主要表现在,它接受到一个键值对操作后,能以微秒级别的速度找到数据,并快速完成操作。 Redis是内存数据库,所有操作都是在内存中完成,内存的访问速度本身就很快 Redis中的键值对是按一点的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作,高效的数据结构是Redis快速处理数据的基础 0.1. Redis中数据结构 底层数据结构共6种: 简单动态字符串 双向链表 压缩列表 哈希表 跳表 整数数组 从上图可以看出,String类型的底层实现只有一种数据结构,就是简单动态字符串,List、Hash、Set、Sorted Set底层都有两种实现结构,它们的特点是一个键对应了一个集合的数据,这些数据结构都是值的底层实现。 0.1.1. 键和值的结构组织 为了实现从键到值的快速访问,Redis使用了一个哈希表来保存所有键值对。 一个哈希表其实就是一个数组,数组的每个元素称为一个哈希桶,所以一个哈希表由多个哈希桶组成,每个哈希桶中保持了键值对数据。哈希桶中实际上并不保存值本身,而是指向具体值的指针。 如下图所示,哈希桶中的entry元素中保存了*key和*value指针,分别指向了实际的键和值,这样即使值是一个集合,也可以通过*value指针被查找到。 这个哈希表中保存了所有的键值对,称为全局哈希表,有了它只需要O(1)的时间复杂度来快速查找到键值对。 0.1.2. 哈希表操作阻塞 某一时刻,可能哈希表的操作变慢了,因为存在哈希冲突问题和refresh可能带来操作阻塞。 随着数据量的增加,哈希冲突不可避免,Redis使用链表法(同一个桶中多个元素用一个链表来保存)来解决冲突。 如上图所示,entry1、entry2、entry3都需要保存在哈希桶3中,导致了哈希冲突,因此使用*next指针来链接它们。 哈希冲突链上的元素只能通过指针逐一查找来操作,随着数据的增大,冲突变多,链表过长,进而导致这个链表上元素的查找耗时长,效率低下。 为了解决链表过长导致查找效率低下这个问题,Redis对哈希表做refresh操作(增加现有桶的数量),让逐渐增多的entry元素能在更多的桶之间分散保存,减少单个桶中元素数量,减少单个桶中的冲突。 为了是refresh操作更高效,Redis默认使用两个全局哈希表(哈希表1和哈希表2): 刚插入数据时,默认使用哈希表1,此时哈希表2并没有被分配空间 随着数量逐步增多,Redis开始执行refresh 给哈希表2分配更大空间(哈希表1的2倍) 把哈希表1中的数据重新映射并拷贝到哈希表2中(这里涉及大量数据拷贝,优化:渐进式,每处理一个请求,从哈希表1的第一个索引位置开始,将所有entry拷贝到哈希表2中) 释放哈希表1的空间 refresh中第二步的优化操作,如上图所示,巧妙的将一次性大量拷贝的开销,分摊到了多次处理请求的过程中。 0.1.3. 集合数据操作效率 Redis的键和值是通过哈希表组织的, String类型的数据,找到哈希桶就能直接增删改查,操作复杂度O(1) 集合类型的数据 通过全局哈希表查找到对应的哈希桶位置 在集合中再增删改查 集合类型的数据: 操作效率与集合底层数据结构有关。使用哈希表实现的集合要比使用链表实现的集合访问效率高。 操作效率与操作本身的执行特点有关。读写一个元素的操作要比读写所有元素的效率高。 总共有5中底层的数据结构: 数据结构 操作特征 操作复杂度 整数数组 顺序读写,通过下标访问 O(n) 双向链表 顺序读写,通过指针逐个访问 O(n) 哈希表 通过计算哈希值访问 O(1) 压缩列表 可快速定位头尾 O(n) 跳表 通过多级索引访问 O(logn) 0.1.3.1. 压缩列表 类似数据,数组中每一个元素都对应保存一个数据,不同点在于表头有zlbytes(列表长度)、zktail(列表尾的偏移量)、zllen(类别中entry个数),在列表尾部zlend(列表结束符)。 在压缩列表中: 查找第一个和最后一个元素:通过表头三个字段和长度直接定位,时间复杂度O(1) 查找其他元素:逐个查找,时间复杂度O(n) 0.1.3.2. 跳表 有序链表只能逐一查找元素,导致操作缓慢,跳表在链表的基础上,增加多级索引,通过索引位置的几个跳转,实现数据的快速定位,时间复杂度O(logn) 0.1.4. 操作的效率 集合类型的操作很多: 读写单个集合元素:HGET、HS 操作多个元素:SADD 对整个集合进行遍历:SMEMBERS 单元素操作时基础,范围操作非常耗时,统计操作通常高效、例外情况只有几个。 0.1.4.1. 单元素操作 每一种集合类型对单个数据实现的增删改查操作。 Hash类型的HGET、HSET、HDEL:O(1) Set类型的SADD、SREM、SRANDMEMBR:O(1) 这次操作的复杂度由集合采用的数据结构决定。 集合类型支持同时多多个元素进行增删改查,此时时间复杂度由当元素操作的时间复杂度和元素个数决定: Hash类型的HMGET、HMSEZT:O(m),同时操作m个元素 Set类型的SADD 0.1.4.2. 范围操作 集合类型中的遍历操作,可以返回集合中所有数据: Hash类型的HGETALL Set类型的SMEMBERS 也可以返回一个范围内的部分数据: List类型的LRANGE:O(n) ZSet类型的ZRANGE:O(n) 2.8版本,增加HSCAN操作,渐进式遍历,避免了一次性返回所有元素导致Redis阻塞。 0.1.4.3. 统计操作 集合类型对集合中所有元素个数的记录。例如LLEN和SCARD,时间复杂度O(1),因为当集合类型采用压缩列表、双向链表、整数数组时,这些数据结构中会专门记录元素的个数。 0.1.4.4. 例外情况 某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。 对于List类型的LPOP、RPOP、LPUSH、RPUSH操作时在列表的头尾进行增删元素,通过偏移量直接定位,时间复杂度是O(1)。