摘要:初学者快速上手数据结构与算法。
阅前须知:本文仅提供学习数据结构与算法的一个初步的知识框架,仅供参考,实际效果因人而异。该文章所参考的内容绝大部分来自《算法图解》,少部分来自《算法导论》,因此你也可以认为这是《算法图解》的读书笔记。本文所有的代码示例均用 Python 语言编写,全文也只有五处很短的代码块,落地程度不高。
我承认,精准且严谨的定义固然是重要的,因为所有与之相关的理解都要建立在这个定义的基础上。但有时有些晦涩的定义直接上手可能会非常地棘手,因此可以先从定义的相关的应用或实例入手,通过 合理恰当的打比方的方式,先对定义产生初步的了解,再通过练习不断地修正,力图接近真实的定义,最终可以达到所谓事半功倍的效果。
因此,我想表达的是,文章可能会出现几处严谨性欠佳的描述,我会在那后面力争把它“圆”回来,并附上一个或者多个认可度较高的定义及出处。
何为算法
《算法导论》对算法的定义为:非形式地说,算法就是任何良性定义的计算过程,该过程取某个值或值的集合作为输入并产生某个值或值的集合作为输出。这样的算法就是把输入转换成输出的计算步骤的一个序列。
讲真,上面这段定义看不看懂不重要,算法其实就是一组完成任务的指令,任何代码片段都可视为算法。如果还是没有感觉,可以姑且认为算法就是函数三要素中的对应法则 f 即可,我们只需知道算法的重要性以及应用就够了。
算法可谓是现代信息技术的灵魂,它指挥着各种各样可处理的数据。一个优秀的算法要求在尽可能短的时间和空间内结束并返回正确的结果。于是,评判一个算法优劣的指标——时间复杂度和空间复杂度,应运而生。其中,时间复杂度常常用大 O 表示法进行合理地量化并表示。
算法思想
枚举,搜索,贪心,随机,递归,二分,分治,动态规划。这些都是一些最基础的算法思想,它们本身不仅是具体落地的算法,更是算法框架。这些框架之间相互组合,就又能延伸出非常多五彩斑斓的算法。这其中思想的奇妙性就在于,它并不是一个确实存在的东西,却又能任意转化成令人醍醐灌顶的产物,简洁但深刻。
论暴力,目前恐怕谁都比不过电子计算机的运算能力。一台普通的 Laptop,每秒也能进行上亿次计算。上面说的所有的算法,本质都是在计算机变态一般的计算力的基础上,对数据的存储及其处理所做的一些列的优化过程。
枚举
枚举也是要讲究方法的。就比如,明明这样的情况已经非法,但如果还是在一味地枚举,就徒劳地增加了计算机运算负担。通过有限次的枚举,最终返回正确结果的过程叫做搜索。而中途遇到非法情况,从而退回重置到原来的某一个步骤的过程叫做回溯。枚举时所用的数据一般是特意选择后得到的,这样的选择策略一共有两种:贪心 以及 随机。贪心既是贪取利益最大的选择,随机则是通过随机的方式进行选择。
分治
分治,分而治之的简称,是将大问题分为子问题 以大化小的算法思路,也是使用频率非常高的算法。比如当我们翻阅字典时,除了依靠目录查询,人们更喜欢将字典从中间摊开放在桌面上,进行所谓的二分查找。这里的二分查找就用到了分治的思想。又比如之后要介绍的动态规划算法,即将问题拆分为子问题,在计算过程中利用之前计算过的结果,避免重复计算。
分治大家庭中,递归的地位也比较高。这是个非常有意思的算法思想。形式上就是一系列相似的问题,通过我调用我自己,像套娃一样层层拆分,直到达到基线条件。调用栈的层数越多,问题就越简单的,且最上层的问题是可以直接解出来的,但调用栈层数过多时,递归的效率就会非常低下。某种程度上,递归就是以大博小,动态规划则是以小博大。递归典型的例子就是汉诺塔问题,当然更具体的内容之后会安排上。
大 O 表示法
设待处理的数据规模为 n,我们不关心计算机每次处理一份数据时所花的时间到底有多长,只关心计算机处理这 n 个数据时所耗费的总操作数。一般用大写的 “O” 表示。
下面是常见算法的时间复杂度,其中 O(log n) 中的 log n 习惯认为是以 2 为底的对数。
可能一个算法的实际时间复杂度为 O( 3*n! + 0.5*n log n + 7 )。那么规定该算法的时间复杂度就是当 n 趋于无穷大时,在上面常见时间复杂度中与其同阶无穷大的时间复杂度。这样化简后的时间复杂度表示为 O(n!) 即可。值得一提的是,O(1) 表示常量时间,意为无论数据规模有多大,这个算法只需要一步就能搞定。
前面提到的《算法导论》中,有句话特别在理:Having a solid base of algorithm knowledge and technique is one characteristic that separates the truly skilled programmers from the novices.
意为,是否具有扎实的算法知识和技术基础,是区分真正熟练的程序员与新手的一项重要特征。
数组与链表
你需要将内存空间想象成宾馆储物抽屉柜,每个抽屉只能储存一个物品。如下图所示。
你可以选两种储存方式,一种是使用连续地进行储存,这种存储结构的管理方式好处和坏处都很明显,好处是你只需要记住第一个物品的位置,就可以瞬间知道第几个物品的位置在哪里。坏处是当你抽屉组的最前端和最后段都有物品时,如若需要增加物品,你只能把所有元素全部搬到更大的连续的抽屉组中,非常费力,当然你也可以提前申请足够大的空间,但如若最后用不上那么多,即是对空间的极度浪费。
另外一种是离散地进行储存,并且上一个抽屉储存着下一个抽屉的位置,即 当查看第 n 个抽屉的内容时,就顺势直到第 n+1 个抽屉的位置在哪里了。这种存储结构的优势是你没有前一种存储方式的顾虑,可以随意在任意位置添加元素,删除第 n 个元素时,只需将第 n-1 个抽屉记录第 n+1 个位置即可。但如若想知道第 n 个抽屉位置,就需要 n-1 个抽屉的位置,以此类推,需要全部遍历,效率比较低下,不支持随机访问。
第一种就像是数据结构中的数组,第二种就像是数据结构中的链表。如若经常需要从头遍历所有元素,用链表会更好一些,但若经常需要随机访问元素,则一般需要用数组。或者更一般地来说,如果打算创建下来之后不会再修改其长度,就用数组,否则用链表。
数组和链表还被用来实现其他数据结构,比如 Facebook 实际使用的是什么呢?很可能是十多个数据库,他们居于众多不同数据结构:散列表、B数等。数组和链表使这些更复杂的数据结构的基石。
递归
每个递归算法都由两个不可或缺的部分组成:基线条件(base case)和递归条件(recursivecase)。递归条件指的是函数调用自己,而基线条件则指的是函数不再调用自己,从而避免形成无限循环。
只要是数据结构,都有属于自己的操作。例如数组和链表就有读取、插入、删除、修改等操作。而对于栈来说,它的操作只有压入(push)和弹出(pop)。当一个栈用于存储多个函数的变量时,被称为调用栈。因为每当你调用函数时,计算机都将函数调用所涉及的变量的值存储到内存中。当一个函数调用返回时,就从栈的顶部弹出。
使用栈虽然很方便,但是也要付出代价:存储详尽的信息可能占用大量的内存。每个函数调用都要占用一定的内存,如果栈很高,就意味着计算机存储了大量函数调用的信息。在这种情况 下,你有两种选择。一种是将递归化为循环解决,另一种是尾递归,当然这就是另一个故事了。
分治
有时候,你可能会遇到使用任何已知的算法都无法解决的问题。优秀的 算法学家遇到这种问题时,不会就此放弃,而是尝试使用掌握的各种问题解决方法来找出解决方案。分而治之(divide and conquer,D&C)是你学习的第一种通用的问题解决方法。快速排序就是使用分而治之策略的一种优雅的排序算法。
D&C 思路是递归的,使用 D&C 解决问题的过程包括两个步骤:
- 找出简单的基线条件。
- 不断将问题分解(或者说缩小规模),直到符合基线条件。
编写涉及数组的递归函数时,基线条件通常是数组为空或只包含一个元素。陷入困境时,有必要检查一下基线条件是不是这样的。
函数式编程
诸如 Haskell 等函数式编程语言没有循环,因此你只能使用递归来编写类循环。例如,函数 sum 可以这样编写。
sum[] = 0
sum(x:xs) = x + (sum xs)
# 用函数式程序设计语言 Haskell 所编写的 sum 函数。
sum arr = if arr == []
then 0
else (head arr) + (sum (tail arr))
# 用循环编写 sum 函数。
归纳证明是一种证明算法行之有效的证明方式。它分为基线条件和归纳条件,在数学领域中被称为第一类数学归纳法。归纳证明常常与 D&C 协同发挥作用。
快速排序
C语言标准库中的 qsort 函数实现的就是快速排序,是一种比选择排序快得多的排序算法,快排的平均大 O 运行时间为 O(n*log n )。其基本步骤为:
- 数组的 pivot1的选取。
- 分别创建两个数组,其中包含除 pivot 外小于等于 pivot 的,以及大于 pivot 的。
- 分别再对这两个数组进行快排。
快速排序的实际运行时间很大程度上都由 pivot 决定,如果 pivot 一直选取数组第一个元素时,它的栈的高度就为 n,每层的操作数也为 n,最终的运行时间为 O(n^2^)。而如果每次都选取中间的元素时,栈的高度就是 log n,每层的操作数还是 n。
散列表
散列表,又称哈希表(hash table),由散列函数和数组构成。任意一个可处理的输入数据,经过散列函数后,可将输入数据映射到数组中某个位置的内存地址中,内存地址又可以另外指向其它具体的数据,这个数据则被称作是散列值,简称值。充当值的数据类型可以是任意类型。而前面输入数据则被称作是键,充当键的数据类型只能是不可变类型。 理想的散列函数能够保证所有不同的键映射的内存地址互不相同。但正因为是理想,所以现实情况中很难办到。当不同键映射到同一处内存地址时,就会产生冲突。要想化解冲突,只需将数组这个位置的内存地址指向一个链表即可,之后在这个链表中存储各自想要储存的值即可。如下图所示。
但如果某个链表的所涉及的元素过多,查找散列表的速度会比较慢,以至于在最糟情况时,散列表的各种操作的运行时间都是 O(n)。想要尽可能地避免冲突,需要有较低的装填因子以及良好的散列函数,当然这就是另一个故事了。
散列表(平均情况) | 散列表(最糟情况) | 数组 | 链表 | |
---|---|---|---|---|
读取 | O(1) | O(n) | O(1) | O(n) |
插入 | O(1) | O(n) | O(n) | O(1) |
删除 | O(1) | O(n) | O(n) | O(1) |
散列表的应用
-
将散列表用于查找
- 储存电话号码和联系人似乎用散列表是比较好的,这是个简单例子。不妨稍微谈一下高级话题,比如 DNS 解析(DNS resolution)。访问像 http://www.google.com 这样的网站时,计算机要先将域名转换为 IP 地址,即 74.125.239.133,转换的过程其实就是将网址映射到 IP 地址上去。因此用散列表的特性来做是极好的。
-
将散列表用作缓存
- 缓存是一种常用的加速方式,几乎任何的大型网站都会使用缓存,而缓存的数据则会储存在散列表中。当你访问 Facebook 的网页时,你会先接入他们的服务器,如果某个网页所有用户看到的都应该是一样的,那就会将事先保存好的网页展示给你,就像是所有人的登陆界面一样。否则服务器就对网页做些处理,将生成的网页展示给你,比如你的个人空间。这就是缓存,它能使用户更快地看到网页,也因此 Facebook 需要做的计算量更少。
- 防止重复
广度优先搜索
根据《算法图解》的作者所知道的算法中,图算法应该是最有用的,而广度优先搜索就是图算法的一种,它能够找出两样东西之间的最短距离。
最短路径问题
像从旧金山双子峰到金门大桥的最少换成次数,或者去你朋友家的最短路径,再或者是国际象棋中把对方将杀死的最少步数。都被称为是最短路径问题。
要解决最短路径问题通常需要两个步骤:
- 使用图来建立问题模型;
- 使用广度优先搜索解决问题。
何为图
图用于模拟不同的东西是如何相连的,它由节点和边组成,一个节点所直接指出去的所有节点统一称作是邻居。
何为广度优先搜索
广度优先搜索是一种用于图的查找算法,它可以解决两类问题:
- 从节点 A 出发,有通往节点 B 的路径吗?
- 如果有,那么从节点 A 出发,通往节点 B 的哪条路径最短?
以节点 A 为中心,节点 A 的邻居被称为是节点 A 的一度关系,邻居的邻居被称为是二度关系,以此类推。在广度优先搜索看来,一度关系胜过二度关系,二度胜过三度,因此它会先在一度关系中查找节点 B,一度没有就找二度,二度没有就去三度,直到找到为止或者所有的节点都遍历过一遍为止。
队列
队列的工作原理与现实生活中的队列完全相同,是一种数据结构。它只支持两种操作,即入队和出队。Python 中可以从 cllections 库中引入 deque 函数来创建一个双端队列。
为了实现广度优先搜索算法,我们需要先实现图,再去通过队列的先进先出的特性来实现算法。
实现图
没有邻居的节点被称为是有向图,互为邻居的节点被称为是无向图。像这样一个节点对应多个节点的结构像极了映射,因此我们可以用散列表来储存图,将每个节点设为键,每个节点的邻居设为值。
实现算法
先用队列按照几度关系的顺序依次将节点入队。具体步骤为:
- 创建一个搜索队列;
- 将中心节点的邻居添加至队列中;
-
只要搜索队列不是空的,建立循环;
- 将一个节点 popleft() 赋值给变量 P
-
检查 P 有没有被检查过
-
检查 P 是不是目标节点
- 如果是则返回 True
- 如果不是则将 P 的邻居添加到队列中
-
- 返回 False。
在最糟糕的情况下,你需要遍历整个图,也就是说每条边都要走一次,并且你需要对每个节点进行依次检查,于是时间复杂度为 O(V + E),其中 V(vertical)为节点数,E(eadg) 为边数。
狄克斯特拉算法
如果说广度优先搜索是解决最短路径问题,那么狄克斯特拉算法就是解决最快路径问题。应用狄克斯特拉算法时,图中每条边都有与之相关联的数字,这叫做权重。并且,带权重的图被称为是加权图,不带权重的图被称为时非加权图。值得一提的是狄克斯特拉算法只适用于有向无环图且无负权边的情况。
狄克斯特拉算法包含以下几个步骤:
- 设中心节点为 A,列出如下表格;
节点 | 父节点 | 耗时 |
---|---|---|
A 的邻居 B | A | α |
A 的邻居 C | A | β |
非 A 的邻居 D | 其它 | ∞ |
目标节点 | 其它 | ∞ |
-
循环开始;
- 找出所有节点中“耗时”最少的节点,在这里不妨设 α < β,因此我们很轻松地找出了节点 B。
- 计算 A 到 B 每个邻居的“耗时”,若 结果 小于 原有结果,则更新相应节点的“耗时”与“父节点”的值。
- 由于 B 的邻居都已更新完毕,因此 节点 B 不再参与下一轮的循环。直到所有节点的邻居更新完毕。
- 根据目标节点的父节点,向前依次追踪节点,直到某个节点的父节点为 中心节点 A,得出完整最短路线。
Q:为什么算法实现的充分条件是有向无环图?
A:若节点与节点之间相互成“环”,那么每循环“环”一次总耗时就要无意义地增加,这样的路径不可能是最快路径。
Q:为什么算法实现的充分条件是不存在负权边?
A:若含有负权边,那么在后续的循环中,有可能会更新之前已经淘汰掉的节点的“耗时”以及“父节点”的值,如上述的 节点 B,这时理应要再对 节点 B 进行一次检查,可是由于 节点 B 已经淘汰掉了,因此无法更新 B 的邻居们。所以可能会使真实结果和实际演算结果产生不小的出入(其实就是贪心的锅,导致只能求得近似解),狄克斯特拉算法看起来就像是出了 bug 一样。因此,应对这种情况,我们必须换一个算法来实现——贝尔曼-福德 算法,当然这就是另一个故事了。
关于实现
以表格作为算法的出发点,需要用到三个散列表来装填三种数据,即 graph , parents , costs,而其中 graph 还需要存储各个边的权重,因此需要再套一层散列表,即 第一层 = 键 : 值 = 节点 : 第二层散列表,第二层 = 键 : 值 = 邻居 : 边的权重。
贪心算法
贪心算法,又称贪婪算法,即 每步都选择的局部最优解,最后得到近似全局最优解或全局最优解,采取的是一种步步为“赢”的策略手段。
在一些问题中,有时你不得不需要计算出所有合法情况下的解。这种问题就被称作为 NP完全问题。在每次计算出的解集中,从中选出最符合题意条件的解,从而求得精确最优解。而往往这种暴力算法所带来的运行速度都是非常糟糕的,这时需要采用贪心算法。虽然多数情况下只能求得近似解,但时间复杂度要比纯暴力计算好看得多。
NP完全问题模型—集合覆盖问题
假设你办了个广播节目,要让全美 50个州的听众都收听得到。为此,你需要决定在哪些广播台播出。在每个广播台播出都需要支付费用,因此你力图在尽可能少的广播台播出。广播台的名单 demo 如下。
广播台 | 覆盖的州 |
---|---|
KONWE | ID,NV,UT |
KTWO | WA,ID,MT |
KTHREE | OR,NV,CA |
KFOUR | NV,UT |
KFIVE | CA,AZ |
每个广播台都覆盖特定的区域,不同广播台的覆盖区域可能重叠。而我们呢现在的问题是如何找出覆盖全美 50个州的最小广播台集合,这听起来很容易,但其实运算量极大,具体方法余下。
- 列出每个可能的广播台集合,这被称为 幂集(power set)。因此,可能的子集有 2^n^个(包括空集)。
- 在这些 2^n^个子集中,选中覆盖全美 50个州的最小集合。
当然,n 在 0~10 的区间范围内还好说,但当 n 超过了这个范围,实际操作数将急剧增加,更为痛苦的是,目前没有任何算法可以足够快地解决这个问题。这时就需要近似算法(approximation algorithm)解决类似的问题,具体方法如下。
- 选出这样一个广播台,即 它覆盖了最多的未覆盖州。即便这个广播台也同时覆盖了一些已覆盖的州,也没有关系。
- 重复第一步,直到覆盖了所有的州。
具体的 Python 代码实现如下。
states_needed = set(["mt", "wa", "or", "id", "nv", "ut", "ca","az"]) # 传入一个数组,并转换为集合。
arr = set([1, 2, 2, 3, 3, 3])
stations = {} # 需要有可供选择的广播台清单,使用散列表来表示它。
stations["kone"] = set(["id", "nv", "ut"])
stations["ktwo"] = set(["wa", "id", "mt"])
stations["kthree"] = set(["or", "nv", "ca"])
stations["kfour"] = set(["nv", "ut"])
stations["kfive"] = set(["ca", "az"])
final_stations = set() # 需要一个集合来储存最终选择的广播台。
while states_needed: # 不断地循环,直到states_needed为空。
best_station = None # 准备遍历所有的广播台,从中选择覆盖了最多的未覆盖州的广播台存储在 best_station 中。
states_covered = set()
for station, states in stations.items(): # for循环迭代每个广播台,并确定它是否是最佳的广播台。
covered = states_needed & states # covered 包含当前广播台覆盖的一系列还未覆盖的州
if len(covered) > len(states_covered): # 检查该广播台覆盖的州是否比 best_station 多。
best_station = station
states_covered = covered # states_covered 包含该广播台覆盖的所有未覆盖的州。
states_needed -= states_covered # 更新 states_needed。
final_stations.add(best_station)
print(final_stations) # 输出结果为 set(['ktwo', 'kthree', 'kone', 'kfive'])
NP完全问题模型—旅行商问题
有一位旅行商,他需要前往 n个城市,同时保证旅程最短。因此他需要考虑各种各样可能的顺序情况,而对于每种顺序,他都要计算总旅程,再挑选出旅程最短的路线,那么很明显,他需要考虑 n! 个顺序情况。
针对这种问题,当 n 比较大时,可以采取贪婪算法,具体步骤为。
- 选定一个起点城市。
- 去往离起点城市最近的城市,并将起点城市更新为该城市。
- 重复上述步骤。
如何识别NP完全问题
旅行商问题和集合覆盖问题有一些共同之处:你需要计算所有的解,并从中选出最小/最短的那个。这两个问题都属于NP完全问题。
通常来说,用 贪婪算法 解决一个 NP完全问题并不难,而真正难的部分则是意识到这是个 NP完全问题。下面列出几个判断 NP完全问题的经验性规律。
元素较少时算法的运行速度非常快,但随着元素数量的增加,速度会变得非常慢。
- 涉及“所有组合”的问题通常是NP完全问题。
- 不能将问题分成小问题,必须考虑各种可能的情况。这可能是NP完全问题。
- 如果问题涉及序列(如旅行商问题中的城市序列)且难以解决,它可能就是NP完全问题。
- 如果问题涉及集合(如广播台集合)且难以解决,它可能就是NP完全问题。
- 如果问题可转换为集合覆盖问题或旅行商问题,那它肯定是NP完全问题。
动态规划
动态规划相较于其它算法来说,是属于比较重要,但同时也比较麻烦的一种算法。因此这里先了解一个例子再根据这个例子具体说明可能会好理解一点。
背包问题
描述
假设你现在是一个不折不扣的小偷,晚上进商城准备偷东西,你的背包很神奇,它装东西本领只和这个东西的重量有关,不妨限定你的背包最多只能装 4kg的东西。下面是你想偷的东西的清单,每件商品只有一个。
商品 | 音响(S) | 笔记本电脑(L) | 吉他(G) | IPhone(I) |
---|---|---|---|---|
价格 | ¥3000 | ¥2000 | ¥1500 | ¥2000 |
重量 | 4kg | 3kg | 1kg | 1kg |
你的目标很明确,在有限的容量里装入尽可能多的有价值的物品。因为你明白这其中要满足某种配比才有可能是问题的最优解(比如,拿走一个 笔记本电脑 和一个 IPhone 才可能是最赚的),但你不确定。因此,你掏出纸和笔打算写一个动态规划网格。我承认这可能很突然,但先照着做一下。
商品/容量 | 1kg | 2kg | 3kg | 4kg |
---|---|---|---|---|
吉他 | ||||
音响 | ||||
笔记本电脑 | ||||
IPhone |
其中,网格的各行为商品,各列为不同容量(1~4kg)的背包。首先要声明的是,待会儿要填满这里所有的单元格,因为它们将帮助你计算背包的最大价值。你要做的事情是:逐行填写,填完一行再填下一行。填 a 行 b 列时,你假定现在只能偷 小于等于 a 行 的商品,且你的背包大小只有 b kg。举个例子,当你填 第二行 第三列 的单元格时,你只能偷 吉他 以及 音响,并且你的背包容量只有 3kg,这时将所能带走的最大价值填入所对应的单元格中。
揭晓
看完上面有点懵是正常的,觉得意义不明也有可能,因此需要适当“放”出来一点东西出来缓和以下。
动态规划讲究将原问题划分为 n 个子问题,所以我们才将背包容量这个限制条件划分成 4 大块。通过解决第 1 个子问题,保存结果,接着解决第 2 个子问题时可以调用之前的结果,类似于尾递归。因此填表的时候沿着一列往下走时,最大价值不可能降低。因为每次迭代时,你都存储了当前的最大价值,最大价值不可能比之前还要低。
值得一提的是,这 n 个子问题之间必须是相互独立且离散的。拿背包问题举例,如果拿了笔记本电脑之后再拿 IPhone 时电脑会爆炸,简言之就是电脑的价值会受手机的影响,这样的情况是用不了动态规划的。
设行数为 i,列数为 j,cell 表示表格, α 为当前物品的价值,β 为当前物品的所占空间,则:cell[i][j] = max{cell[i-1][j] , α + cell[i-1][j-β]}, 即
每个单元格的价值 = 上一个单元格的价值 与 当前商品的价值 + 剩余空间的价值 的两者的较大值。
按照上述公式所填写的完整的表格如下,其中最大价值为¥4000,也就是说当你的容量只有 4kg 的背包,装一个 IPhone 和一个笔记本电脑时,收益才能达到最大化。
商品/容量 | 1kg | 2kg | 3kg | 4kg |
---|---|---|---|---|
吉他(G) | ¥1500(G) | ¥1500(G) | ¥1500(G) | ¥1500(G) |
音响(S) | ¥1500(G) | ¥1500(G) | ¥1500(G) | ¥3000 |
笔记本电脑(L) | ¥1500(G) | ¥1500(G) | ¥2000(L) | ¥3500(LG) |
IPhone(I) | ¥3500(I) | ¥3500(IG) | ¥3500(IG) | ¥4000(IL) |
在背包问题中,任意调换某两行都不会影响最终结果,甚至逐列填写单元格也没关系,但在其它动态规划模型中就不一定成立了。你也可以在最后一行添加一个任意一个商品而不影响上面的计算结果,当然前提是这件商品的重量是个整数,如果不是整数则要重新划分价格区间。
动态规划问题
- 需要在给定约束条件下优化某种指标时。例如在背包问题中,你必须在背包容量给定的情况下,拿到价值最高的商品。
- 问题可分解为离散的子问题时。
动态规划的认知
- 每种动态规划解决方案都涉及网络。
- 单元格中的值通常就是你要优化的值。在背包问题中,单元格的值为商品的价值。
- 每个单元格都是一个子问题,因此你该考虑如何将问题分成子问题,这有助于你找出网格坐标轴。
- 没有放之四海而皆准的计算动态规划解决方案的公式。
动态规划问题建模
遇到动态规划问题时首先要问自己这么几个问题:
- 单元格中的值是什么?或者说 在动态规划中,你要将哪个指标最大化?
- 如何将这个问题划分为子问题?
- 网格的坐标轴是什么?
- 应该使用什么样的公式来填充每个单元格?
所谓费曼算法
动态规划实际上比较棘手的部分就是对动态规划问题的建模这一块,但有时你就是不确定具体到底该怎么做。这时,计算机科学家会开玩笑说:不妨就使用费曼算法(Feynman algorithm)。这个算法是以著名物理学家 理查德·费曼 命名的,算法具体步骤如下。
- 将问题写下来。
- 好好思考。
- 将答案写下来。
由此我们知道,有些算法并非精确的解决步骤,而是会帮助你理清思路的框架,本质还是一种工序上的优化问题。
接下来我们再来讨论三个动态规划问题,其中第一个和背包问题类似,其余两种则需要重新建模。
旅游行程最优化
加入你要去伦敦度假,假期两天,但你想去游览的地方有很多。由于你没法前往每个地方游览,因此如何在有限的时间内,游览地方的评分总和最高是你的目标。具体行程如下。
名胜 | 威斯敏斯特教堂(W) | 环球剧场(G) | 英国国家美术馆(N) | 大英博物馆(B) | 圣保罗大教堂(S) |
---|---|---|---|---|---|
时间 | 0.5天 | 0.5天 | 1天 | 2天 | 0.5天 |
评分 | 7 | 6 | 9 | 9 | 8 |
根据上面的清单以及背包问题的公式,我们可以非常轻松地填满如下表格。
名胜/时间 | 1/2 | 1 | 3/2 | 2 |
---|---|---|---|---|
威斯敏斯特教堂(W) | 7(W) | 7(W) | 7(W) | 7(W) |
环球剧场(G) | 7(W) | 13(WG) | 13(WG) | 13(WG) |
英国国家美术馆(N) | 7(W) | 13(WG) | 16(WN) | 22(WGN) |
大英博物馆(B) | 7(W) | 13(WG) | 16(WN) | 22(WGN) |
圣保罗大教堂(S) | 8(S) | 15(WS) | 21(WGS) | 24(WNS) |
最长公共子串
有必要提前声明的是, 最长公共子串讲究的是字符之间的连续性的,即 绝对顺序。相反, 最长公共子序列讲究的是字符之间的 相对顺序。举个例子,fosh 和 fish 的最长公共子串的长度为 2,最长公共子序列的长度为 3。fosh 和 fort 的最长公共子串的长度也是 2,但最长公共子序列的长度只有 2。
FISH 和 HISH 的最长公共子串的长度为多少?相信我们都可以一眼看出来,但对于计算机来说是要设计专门的算法来处理这样的问题的。
首先判断这本质上应该是个动态规划问题,毫无疑问,这些个单词都可以分为单个离散的字符。并且,对于 FISH 来说 HISH 本身就是约束条件,反过来也一样。所要优化的指标当然也就是两者的 公共子串的长度。
根据以上分析判断这是个动态规划问题之后直接上网格表。
H | I | S | H | |
---|---|---|---|---|
F | ||||
I | ||||
S | ||||
H |
针对单元格中索要填入的的值,思考公式是什么。再次强调一下,公式只是表象,其内部还是子问题的保存和调用的一个尾递归。
- 如果两个字母不相同时,值 = 0。
- 如果两个字母相同时,值 = 左上角单元格的值 + 1。
实现这个公式的伪代码类似于下面这样。
if word_a[j] == word_b[j]: # 两个字母相同
cell[i][j] = cell[i-1][j-1] + 1
else:
cell[i][j] = 0 # 两个字母不同
根据公式不难填满得到如下表格。
H | I | S | H | |
---|---|---|---|---|
F | 0 | 0 | 0 | 0 |
I | 0 | 1 | 0 | 0 |
S | 0 | 0 | 2 | 0 |
H | 0 | 0 | 0 | 3 |
同理,HISH 和 VISTA 的最长公共子串如下。
V | I | S | T | A | |
---|---|---|---|---|---|
H | 0 | 0 | 0 | 0 | 0 |
I | 0 | 1 | 0 | 0 | 0 |
S | 0 | 0 | 2 | 0 | 0 |
H | 0 | 0 | 0 | 0 | 0 |
需要注意的是,最终的答案并不总在最后一个单元格中。
最长公共子序列
同样,利用上面的分析方法,也可以得到动态规划最长公共子序列的公式。
- 如果两个字母不同,就选择上方和左边的单元格的值较大的哪个。
- 如果两个字母相同,就将当前单元格的值设置为左上方的单元格的值加 1。
公式伪代码如下。
if word_a[i] == word_b[j]: # 两个字母相同
cell[i][j] = cell[i-1][j-1] + 1
else: # 两个字母不同
cell[i][j] = max(cell[i-1][j],cell[i][j-1])
求 FOSH 和 FORT 的最长公共子序列的长度的网格如下。
F | O | S | H | |
---|---|---|---|---|
F | 1 | 1 | 1 | 1 |
O | 1 | 2 | 2 | 2 |
R | 1 | 2 | 2 | 2 |
T | 1 | 2 | 2 | 2 |
求 FOSH 和 FISH 的最长公共子序列的长度的网格如下。
F | O | S | H | |
---|---|---|---|---|
F | 1 | 1 | 1 | 1 |
I | 1 | 1 | 1 | 1 |
S | 1 | 1 | 2 | 2 |
H | 1 | 1 | 2 | 3 |
动态规划的实际应用
- 生物学家根据最长公共序列来确定DNA链的相似性,进而判断度两种动物或疾病有多相似。最长公共序列还被用来寻找多发性硬化症治疗方案。
- 你使用过诸如 git diff 等命令吗?它们指出两个文件的差异,也是使用动态规划实现的。
- 前面讨论了字符串的相似程度。编辑距离(levenshtein distance)指出了两个字符串的相似程度,也是使用动态规划计算得到的。编辑距离算法的用途很多,从拼写检查到判断用户上传的资料是否是盗版,都在其中。
- 你使用过诸如 Microsoft Word 等具有断字功能的应用程序吗?它们如何确定在什么地方断字以确保行长一致呢?使用动态规划!
K最近邻算法
有些人说网易云音乐和哔哩哔哩 背地里有着一些py交易。这其中的机制就和 K最近邻算法有关。
如上图,现在桌子上有一个水果,现在它只可能是橙子或者柚子中的一种,你不能直接问卖水果的人。手头上已知的条件是:柚子通常比橙子更大、更红。现在的问题是它最有可能是橙子还是柚子?
不妨在脑内想象一个这么样的图表,其中 O 为橙子,G 为柚子,横坐标为大小,纵坐标为颜色的深浅。注意到,坐标轴是连续的,而水果则抽象成了一些离散的点,或者说向量。
那么如何大致判断这个神秘的水锅到底是什么呢?其实非常简单,不妨找一下离 神秘水果所表示的点 最近的三个水果,然后比较以下那种水果的种类更多即可。若假定 3个水果中,有两个都是橙子,那么这个神秘水果很有可能就是橙子。
KNN算法
在判断那个水果的种类的过程中,其实已经使用了 K最近邻(k-nearest neighbours, KNN)算法。利用自然语言描述刚刚的 KNN算法过程如下。
- 需要对一个水果进行分类。
- 查看它 K个最近的邻居。
- 在这些邻居中,橙子多余柚子,因此它很可能是橙子。
要对东西进行分类时,可以首先考虑采用 KNN算法。你可以使用 KNN 来做两项基本工作——分类和回归(regression)。
- 分类就是编组;
- 回归就是预测结果。
特征抽取
抽取特征时,要挑准合适的特征。
- 抓准与之紧密相关的特征;
- 不偏不倚的特征(例如,如果只让用户给喜剧片打分,就无法判断它们是否喜欢动作片)。
在前面的水果示例中,你根据个头和颜色来比较水果,换言之,你比较的特征是个头和颜色。现在假设有三个水果,你可抽取它们的特征。很明显,A 和 B 直观看起来十分相似,但事实上具体多少相似我们还是可以通过 KNN算法 来解决。
横坐标表示 个头,纵坐标表示 红的程度,因此,我们可以把这三个点用坐标表示出来。
A | B | C |
---|---|---|
(2,2) | (2,1) | (4,5) |
再利用毕达哥拉斯公式不难算出每两点之间空间距离,并标在图表上。因为 A 与 B 两点之间的距离最短,因此它们俩是最像的,这也印证了之间的直觉。
回归
假定你在你的老家卖冰棍,由于是夏天,卖地比较火,因此每天都要进货一些新的冰棍。每天的进货量由下面几个指标进行预测。
- 天气指数 1~5(数字越大 代表 气温越高);
- 是否是周末或节假日(周末或节假日为 1,否则为 0);
- 有无活动(有为 1,否则为 0)。
之后,你测了 1个月的数据,记录了在各种不同的日子里卖出的冰棍数量。在这 30天的容量中,你选出 6天的数据作为样本,如下表所示。
A | B | C | D | E | F |
---|---|---|---|---|---|
(5,1,0) | (3,1,1) | (1,1,0) | (4,0,1) | (4,0,0) | (2,0,0) |
300根 | 225根 | 75根 | 200根 | 150根 | 50根 |
今天是周末,气温比较高,店里也没有办什么活动,点坐标为(4,1,0)。根据以上数据,你要预测今天大概能卖出去多少根冰棍。只要学过高中数学,就知道这就是个典型的回归问题,但有稍微有点不一样。由于之前说过 KNN算法就是用来解决编组和回归问题的,因此我们要用 KNN算法来解决。
利用毕达哥拉斯公式可以算出“今天”与A,B,C,D,E,F点之间的距离,如下所示。
A | B | C | D | E | F |
---|---|---|---|---|---|
1 | 2 | 9 | 2 | 1 | 5 |
K 取 4 时,与(4,1,0)最近的邻居为 A、B、D 和 E。将这些天售出的冰棍数平均,结果为 218.75。这就是回归(regression)。
余弦相似度
前面计算两位用户的距离时,使用的都是距离公式。但在实际工作中,则经常会使用余弦相似度(cosine similarity)来量化两位用户的相似程度。余弦相似度不计算两个矢量的距离,而比较它们角度,如果要深入研究,那就是另一个话题了。
到这里不妨试着猜想一下开头提及的原理。网易云首先为每个注册的用户生成一个用户画像(Persona),即 将用户的每个具体信息抽象成标签,利用这些标签将用户形象具体化,从而为用户提供有针对性的服务。其中将每个具体信息抽象成标签的过程可能就要用到 K最近邻算法,不过这里的空间维数可能要达到千以上的数量级,并且每一维都有各自的权重。同理,哔哩哔哩也可能采用这样机制。
但哔哩哔哩和网易云音乐可能没有直接的关系。不妨假定在网易云音乐中,用户A 与 用户B 之间的标签十分相似。相似到什么程度呢?一旦 A “喜欢”了一首歌,就会立即反映到当天 B 的推荐歌曲中。接下来的说法要建立在一个前提之上,就是 这种类型的歌曲的听众 可以映射到 哔哩哔哩 的一个较小的“圈子”中。比如,喜欢听 二刺螈 的曲子的人 在哔哩哔哩中可能是混“番剧”这个圈子的。那么就有这种可能,十月新番 在 哔哩哔哩 准时开播,假定 番剧 和 OP 是同时上架的,A 第一时间追了一集之后马上去网易云音乐对 OP 点了“喜欢”。在这之后,B 也追完了,无意打开网易云发现首页上竟然有 自己正在追的动画的 OP,于是在曲子的评论中不禁如下感叹。(当然这不是我,废话。)
简述机器学习
KNN算法在神奇的机器学习领域也占有一定的比重。机器学习皆在让计算机更聪明。创建推荐系统,就是个机器学习的例子。
OCR
OCR 指的是光学字符识别(optical character recognition),这意味着你可拍摄印刷页面的照片,计算机将自动识别出其中的文字。Google使用OCR来实现图书数字化。OCR是如何工作的呢?我们来看一个例子。请看下面的数字。
如何自动识别出这个数字是什么呢?我们当然可以使用 KNN。
- 浏览大量的数字图像,将这些数字的特征提取出来。这在机器学习中被称之为训练(training)。
- 遇到新图像时,提取该图像的特征,再找出它最近的邻居都是谁。
与前面的水果示例相比,OCR 中的特征提取明显要复杂得多得多,但再复杂的技术也是基于 KNN 等简单理念的。而这些理念也可用于语音识别和人脸识别。当你将照片你上传到 Facebook 时,它有时候能够自动标出照片中的任务,这正是机器学习在发挥作用。
创建垃圾邮件过滤器
垃圾邮件过滤器用的并非 KNN 算法,而是另一种基本算法——朴素贝叶斯分类器(Naive Bayes classifier)。大多数机器学习算法都包括训练的步骤:要让计算机完成任务,必须先训练它。垃圾邮件过滤器也不再例外,因此你需要使用一定量的数据对这个分类器进行训练。垃圾邮件过滤器可以计算出一个任意一个邮件为垃圾邮件的概率,其应用领域与 KNN 相似。
你甚至还可以用朴素贝叶斯分类器对之前提到的水果进行分类:假设有一个又红又大的水果,它是柚子的概率是多少呢?……
预测股票市场
未来很难预测,由于涉及的变量太多,这几乎是不可能完成的任务。
最后再介绍十种数据结构和算法
树
每当用户登录自己的 B 站 账号时,都要从庞大的数据中去查找,并判断是否存在这样 ID。倘若你要设计 B 站 用户的登陆系统,你会选择什么样的数据结构来存储用户 ID 以及 算法用来查找 ID。
如果使用二分查找,并使用极其庞大的数组来存储数据,那么注销和注册一个 ID 将是个极其麻烦的工作。为此,有人设计了一种名为二叉查找树(binary search tree)的数据结构,来减轻算法的负担。
二叉查找树类似于上面这样。这种数据结构形象地表示出来之后长相酷似“树”,因此命名为二叉查找树,其实是一种变异的“链表”。也拜这种结构所赐,使得数据的增删变得极其地方便。其中,树的最顶端被称为根节点,其余节点被称为子节点。而对于其中每个节点,左子节点的值都比它小,右子节点的值都比它大。
对于树的查找,其原理与二分查找相似,因此平均时间复杂度都为 O(log n)。举个例子,假设我要在上面 5个数据中查找“Maggie”。为此,我首先检查根节点是否为“Maggie”,发现匹配失败之后,发现“Maggie”应该排在“David”的后面,因此我往右边找。“Maggie”应该排在“Manning”前面,于是接着往左边找,最终找到了“Maggie”。
与其它大部分数据结构不同的是,树自己本身是存在优劣的,即 有好的“树”,也有“坏”的树。一颗树中两个分叉的子树数量比例越高,树的性能就越佳。
好的树↓
坏的树↓
第一棵树正在处于平衡状态,此时的最糟的运行时间为 O(log n)。第二棵树处于非平衡状态,此时的最遭的运行时间为 O(n),仿佛整棵树马上就要向左倒下来。由于树与链表相似,都不支持随机访问,二叉查找树处于平衡状态时,平均访问时间为 O(log n)。
下面给出一下数组和二叉查找树的性能。
数组 | 二叉查找树 | |
---|---|---|
查找 | O(log n) | O(log n) |
插入 | O(n) | O(log n) |
删除 | O(n) | O(log n) |
此外,还有几种特殊的二叉查找树,如 B树、红黑树、堆、伸展树 等,当然这就是另一个故事了。
反向索引
这里非常简单地说说搜索引擎的工作原理。假设你有三个网页,内容如下。
我们根据这些内容船舰一个散列表。
这个散列表的键为单词,值为包含指定单词的页面。现在假设有用户搜索 hi,在这种情况下,搜索引擎需要检查哪些页面包含 hi。
搜索引擎发现页面A和B包含hi,因此将这些页面作为搜索结果呈现给用户。现在假设用户搜索 there。你知道,页面 A 和 C 包含它。非常简单,不是吗?这是一种很有用的数据结构:一个散列表,将单词映射到包含它的页面。这种数据结构被称为反向索引(inverted index),常用于创建搜索引擎。如果对搜索感兴趣,从反向索引着手研究是不错的选择。
傅里叶变换
绝妙、优雅且应用广泛的算法少之又少,傅里叶变换算是一个。Better Explained 是一个杰出的网站,致力于以通俗易懂的语言阐释数学,它就傅里叶变换做了一个绝佳的比喻:给它一杯冰沙,它能告诉你其中包含哪些成分。换言之,给定一首歌曲,傅里叶变换能够将其中的各种频率分离出来。
这种理念虽然简单,应用却极其广泛。例如,如果能够将歌曲分解为不同的频率,就可强化你关心的部分,如强化低音并隐藏高音。傅里叶变换非常适合用于处理信号,可使用它来压缩音乐。为此,首先需要将音频文件分解为音符。傅里叶变换能够准确地指出各个音符对整个歌曲的贡献,让你能够将不重要的音符删除。这就是MP3格式的工作原理。
数字信号并非只有音乐一种类型。JPG也是一种压缩格式,也采用了刚才说的工作原理。傅里叶变换还被用来地震预测和DNA分析。
使用傅里叶变换可创建类似于 Shazam 这样的音乐识别软件。傅里叶变换的用途极其广泛,你遇到它的可能性极高。
接下来的三个主题都与可扩展性和海量数据处理相关。
并行算法
我们身处一个处理器速度越来越快的时代,如果你要提高算法的速度,可等上几个月,届时计算机本身的速度就会更快。但这个时代已接近尾声,因此笔记本电脑和台式机转而采用多核处理器。为提高算法的速度,你需要让它们能够在多个内核中并行地执行!
来看一个简单的例子。在最佳情况下,排序算法的速度大致为 O(n log n)。众所周知,对数组进行排序时,除非使用并行算法,否则运行时间不可能为O(n)。对数组进行排序时,快速排序的并行版本所需的时间为 O(n)。
并行算法设计起来很难,要确保它们能够正确地工作并实现期望的速度提升也很难。有一点是确定的,那就是速度的提升并非线性的,因此即便你的笔记本电脑装备了两个而不是一个内核,算法的速度也不可能提高一倍,其中的原因有两个。
- 并行性管理开销。假设你要对一个包含1000个元素的数组进行排序,如何在两个内核之间分配这项任务呢?如果让每个内核对其中500个元素进行排序,再将两个排好序的数组合并成一个有序数组,那么合并也是需要时间的。
- 负载均衡。假设你需要完成 10个任务,因此你给每个内核都分配5个任务。但分配给内核A的任务都很容易,10秒钟就完成了,而分配给内核B的任务都很难,1分钟才完成。这意味着有那么 50秒,内核B在忙死忙活,而内核A却闲得很!你如何均匀地分配工作,让两个内核都一样忙呢?
MapReduce
有一种特殊的并行算法正越来越流行,它就是分布式算法。在并行算法只需两到四个内核时,完全可以在笔记本电脑上运行它,但如果需要数百个内核呢?在这种情况下,可让算法在多台计算机上运行。MapReduce 是一种流行的分布式算法,你可通过流行的开源工具 Apache Hadoop 来使用它。
分布式算法为何很有用
假设你有一个数据库表,包含数十亿乃至数万亿行,需要对其执行复杂的SQL查询。在这种情况下,你不能使用MySQL,因为数据表的行数超过数十亿后,它处理起来将很吃力。相反,你需要通过Hadoop来使用MapReduce!
又假设你需要处理一个很长的清单,其中包含 100万个职位,而每个职位处理起来需要 10秒。如果使用一台计算机来处理,将耗时数月!如果使用 100台计算机来处理,可能几天就能完工。
分布式算法非常适合用于在短时间内完成海量工作,其中的MapReduce基于两个简单的理念:映射(map)函数和归并(reduce)函数。MapReduce使用这两个简单概念在多台计算机上执行数据查询。数据集很大,包含数十亿行时,使用 MapReduce 只需几分钟就可获得查询结果,而传统数据库可能要耗费数小时。
布隆过滤器和HyperLogLog
在假设你管理着 Google,要避免将用户重定向到恶意网站。你有一个清单,其中记录了恶意网站的 URL。你需要确定要将用户重定向到的 URL 是否在这个清单中。这些一类型的问题,通常都要涉及庞大的集合。给定一个元素,你需要判断它是否包含在这个集合中。为快速做出这种判断,可使用散列表。例如,Google可能有一个庞大的散列表,其中的键是已搜集的网页。
初始状态时,所有键所对应的值都是“False”。第一次判断 A 网页是否在该集合时,只需或许 A 网页所对应的值即可。随后发现不在这个集合,同时 A 的确是个恶意网页时,就将 “False” 改为 “True”,之后系统再次判断 A 网页是否是恶意网页时,可以先获取其对应的的值即可。散列表的平均查找时间为 O(1),即查找时间是固定的,这应该是个非常乐观的时间复杂度了。不过 Google 需要建立数万亿个网页的索引,因此这个散列表非常大,需要占用大量的存储空间。面临海量数据,我们可以选择非常有创造性的方案——布隆过滤器,来解决这种问题。
布隆过滤器
布隆过滤器提供了解决之道。布隆过滤器是一种概率型数据结构,它提供的答案有可能不对,但很可能是正确的。为判断网页以前是否已搜集,可不使用散列表,而使用布隆过滤器。使用散列表时,答案绝对可靠,而使用布隆过滤器时,答案却是很可能是正确的。具体表现为如下。
- 可能出现错报的情况,即 Google 可能指出“这个网站已搜集”,但实际上并没有搜集。
- 不可能出现漏报的情况,即如果布隆过滤器说“这个网站未搜集”,就肯定未搜集。
布隆过滤器的优点在于占用的存储空间很少。使用散列表时,必须存储 Google 搜集过的所有 URL,但使用布隆过滤器时不用这样做。布隆过滤器非常适合用于不要求答案绝对准确的情况。
HyperLogLog
HyperLogLog 是一种类似于布隆过滤器的算法。HyperLogLog近似地计算集合中不同的元素数,与布隆过滤器一样,它不能给出准确的答案,但也八九不离十,而占用的内存空间却少得多。面临海量数据且只要求答案八九不离十时,完全可以考虑使用概率型算法。
接下来的两个话题都与加密算法有关。
SHA算法
之前介绍的散列函数都是将键直接映射到内存地址上。而另一种散列函数,安全散列算法(secure hash algorithm,SHA)函数,则是将字符串类型的键直接映射到一个较短字符串上。比如 "hello" 这个字符串通过 SHA256
加密后的结果为2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
下面简述安全散列算法的安全体现在哪里。
- 这个散列函数不会产生冲突,因此不会出现因为明明输错密码还能够成功登录的情况。
- 这个散列函数没有反函数,意味着不能直接反破译出密码,因此密码校验都是用 SHA 加密结果进行比对。
-
就算原字符串只改变了一个字符,最终的 SHA 结果也会截然不同。因此攻击者无法通过比较散列值是否类似来破解密码。这时就称这种散列函数对全部敏感。
- "hellow"
SHA256
加密结果为d0bc381952d0827f36467818a9560eb5eb6fda8a64a422aa21fcda3f2263e8b4
- "hella"
SHA256
加密结果为70de66401b1399d79b843521ee726dcec1e9a8cb5708ec1520f1f3bb4b1dd984
- "hell"
SHA256
加密结果为0ebdc3317b75839f643387d783535adc360ca01f33c75f7c1e7373adcd675c0b
- "hellow"
SHA除了核对密码外,还可以判断文件是否为同一文件,即 将两个文件的 SHA 值计算出来进行比对即可。
SHA实际上是一系列算法:SHA-0 , SHA-1 , SHA-2 和 SHA-3。其中,SHA-0 和 SHA-1 已被发现存在一些缺陷。如果你要用SHA算法来计算密码的散列值,请使用 SHA-2 或 SHA-3。当前,最安全的密码散列函数是bcrypt,但没有任何东西是万无一失的。
Simhash
simhash 是对局部敏感的散列函数,表现为如果你对字符串做细微的修改,Simhash 生成的散列值也只存在细微的差别。这时,你可以通过比较散列值来判断两个字符串的相似程度。
- Google 使用 Simhash 来判断网页是否已搜集;
- 老师们可以使用 Simhash 来判断学生的论文是否是从网上抄的。
Diffie-Hellman 密钥交换
简单的加密算法就不再赘述了,因为加密程度比较低,因此非常容易暴力破解出来。Diffile-Hellman 算法,简称 DH 算法。它有以下两个优点。
- 双方无需知道加密算法。他们不必会面协商要使用的加密算法。
- 要破解加密的消息比登天还难。
DH 算法加密时会使用到两种密钥:公钥和私钥。顾名思义,公钥就是公开使用的密钥,被第三者获取了也无所谓。而私钥,则是最终解密的关键。具体 DH 算法是如何执行的,可以去 B 站搜索 av73112181,这个视频中用类比的方式比较直观地演示了密钥交换过程,个人也觉得既优雅又不难理解。DH 算法是算法加密的先驱者,它的后辈 RSA 算法依然在被广泛使用,当然这就是另一个故事了。
线性规划
但愿读者还能想起来自己高中还确实学过线性规划这么个东西。线性规划就是为了不断优化,最终达到最优解的工具,这与算法中优化的思想不谋而合,因此线性规划本质上也是可以算作是算法。所有的图算法都可使用线性规划来实现。线性规划是一个宽泛得多的框架,而图问题只是其中的一个子集!因此把如此牛掰的算法放最后讲也不是没有道理的。线性规划使用 Simplex 算法,不过这个算法很复杂,详细介绍就又称另一个故事了。
结语
这是我在思否落地的第一篇技术博文,首先感谢思否给予像我一样的平凡人记录自己所学的机会。国内像思否的博客系统有很多家,但我觉得都没有思否酷,所以最终就选择了思否。
可能会说你一个刚入坑的小屁孩写这种没水平的技术博客不是笑话吗(也许并没有人问,谁会看菜鸡写的博文呢,大概连喷都懒得喷吧)。Well,I can't even agree more.目前我写博文的目的只是为了产出,一味的输入而没有输出只会把自己变成书呆子。趁还是学生阶段,利用好周边富余的学习资源,通过写博文消化的同时还能复现,何乐而不为呢。
希望你可以在这个饱受争议的领域中,走出那个连曾经那个自己都不敢想象的路途,浮躁的情绪沉淀下来或成为你的决心。希望我可以一直娓娓道来曾经我还未讲述的故事,当然这就是另一个故事了。
- 基准值 ↩