算法图解
算法简介
二分查找
二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列。
首先,假设表中元素是按升序排列,将表中间位置记录的关键字与查找关键字比较,如果两者相等,则查找成功;否则利用中间位置记录将表分成前、后两个子表,如果中间位置记录的关键字大于查找关键字,则进一步查找前一子表,否则进一步查找后一子表。重复以上过程,直到找到满足条件的记录,使查找成功,或直到子表不存在为止,此时查找不成功。
#!/usr/bin/env python
# -*- coding:utf-8 -*-
def binary_search(list, item):
low = 0
high = len(list) - 1
while low <= high:
mid = (low + high) // 2
guess = list[mid]
if guess == item:
return mid
if guess > item:
high = mid - 1
else:
low = mid + 1
return -1
list = [1, 2, 3, 4, 5, 6, 7]
# 能找到对应的值
print (binary_search(list, 3))
print (binary_search(list, 6))
# 不能找到对应的值
print(binary_search(list, 8))
运行时间
回到前面的二分查找。使用它可节省多少时间呢?简单查找逐个地检查数字,如果列表包含100个数字,最多需要猜100次。如果列表包含40亿个数字,最多需要猜40亿次。换言之,最多需要猜测的次数与列表长度相同,这被称为线性时间 (linear time)
。
二分查找则不同。如果列表包含100个元素,最多要猜7次;如果列表包含40亿个数字,最多需猜32次。厉害吧?二分查找的运行时间为对数时间
(或log时间)。
大O表示法
大O表示法
是一种特殊的表示法,指出了算法的速度有多快。
大O表示法指出了算法有多快。例如,假设列表包含n 个元素。简单查找需要检查每个元素,因此需要执行n 次操作。使用大O表示法,这个运行时间为O(n)。单位秒呢?没有——大O表示法指的并非以秒为单位的速度。大O表示法让你能够比较操作数,它指出了算法运行时间的增速 。
为检查长度为n 的列表,二分查找需要执行log n 次操作。使用大O表示法,这个运行时间怎么表示呢?O(logn)。
一些常见的大O运行时间
O (log n),也叫对数时间 ,这样的算法包括二分查找。
O (n),也叫线性时间 ,这样的算法包括简单查找。
O (n * log n),这样的算法包括第4章将介绍的快速排序——一种速度较快的排序算法。
O (n^2),一种速度较慢的排序算法。
O (n!),这样的算法包括接下来将介绍的旅行商问题的解决方案——一种非常慢的算法。
数组和链表
- 数组的读取速度很快。
- 链表的插入和删除速度很快。
- 在同一个数组中,所有元素的类型都必须相同(都为int、double等)。
递归
递归伪代码:找盒子中的钥匙
def look_for_key(box):
for item in box:
if item.is_a_box():
look_for_key(item) ←------递归!
elif item.is_a_key():
print "found the key!"
编写递归函数时,必须告诉它何时停止递归。正因为如此,每个递归函数都有两部分:基线条件 (base case)和递归条件 (recursive case)。递归条件指的是函数调用自己,而基线条件则指的是函数不再调用自己,从而避免形成无限循环。
快速排序
D&C的工作原理:
(1) 找出简单的基线条件;
(2) 确定如何缩小问题的规模,使其符合基线条件。
D&C并非可用于解决问题的算法,而是一种解决问题的思路。
def quicksort(array):
if len(array) < 2:
return array ←------基线条件:为空或只包含一个元素的数组是“有序”的
else:
pivot = array[0] ←------递归条件
less = [i for i in array[1:] if i <= pivot] ←------由所有小于基准值的元素组成的子数组
greater = [i for i in array[1:] if i > pivot] ←------由所有大于基准值的元素组成的子数组
return quicksort(less) + [pivot] + quicksort(greater)
print quicksort([10, 5, 2, 3])
还有一种名为合并排序 (merge sort)的排序算法,其运行时间为O (n log n),比选择排序快得多!快速排序的情况比较棘手,在最糟情况下,其运行时间为O (n^2)。
在大O表示法O (n )中,n 实际上指的是这样的。
c*n
c 是算法所需的固定时间量,被称为常量 。
散列表
数组和链表都被直接映射到内存,但散列表更复杂,它使用散列函数来确定元素的存储位置。
散列表可能是最有用的,也被称为散列映射、映射、字典和关联数组。散列表的速度很快!
你可以立即获取数组中的元素,而散列表也使用数组来存储数据,因此其获取元素的速度与数组一样快。
对于同样的输入,散列表必须返回同样的输出,这一点很重要。如果不是这样的,就无法找到你在散列表中添加的元素!
使用散列表来检查是否重复,速度非常快。
将散列表用作缓存
假设你有个侄女,总是没完没了地问你有关星球的问题。火星离地球多远?月球呢?木星呢?每次你都得在Google搜索,再告诉她答案。这需要几分钟。现在假设她老问你月球离地球多远,很快你就记住了月球离地球238 900英里。因此不必再去Google搜索,你就可以直接告诉她答案。这就是缓存的工作原理:网站将数据记住,而不再重新计算。
如果你登录了Facebook,你看到的所有内容都是为你定制的。你每次访问facebook.com,其服务器都需考虑你感兴趣的是什么内容。但如果你没有登录,看到的将是登录页面。每个人看到的登录页面都相同。Facebook被反复要求做同样的事情:“当我注销时,请向我显示主页。”有鉴于此,它不让服务器去生成主页,而是将主页存储起来,并在需要时将其直接发送给用户。
这就是缓存 ,具有如下两个优点。
- 用户能够更快地看到网页,就像你记住了月球与地球之间的距离时一样。下次你侄女再问你时,你就不用再使用Google搜索,立刻就可以告诉她答案。
- Facebook需要做的工作更少。
缓存是一种常用的加速方式,所有大型网站都使用缓存,而缓存的数据则存储在散列表中!
Facebook不仅缓存主页,还缓存About页面、Contact页面、Terms and Conditions页面等众多其他的页面。因此,它需要将页面URL映射到页面数据。
当你访问Facebook的页面时,它首先检查散列表中是否存储了该页面。
冲突
当两个键映射到同一个值得时候,就会发生冲突
.
在这个例子中,apple和avocado映射到了同一个位置,因此在这个位置存储一个链表。在需要查询香蕉的价格时,速度依然很快。但在需要查询苹果的价格时,速度要慢些:你必须在相应的链表中找到apple。如果这个链表很短,也没什么大不了——只需搜索三四个元素。但是,假设你工作的杂货店只销售名称以字母A打头的商品。
等等!除第一个位置外,整个散列表都是空的,而第一个位置包含一个很长的列表!换言之,这个散列表中的所有元素都在这个链表中,这与一开始就将所有元素存储到一个链表中一样糟糕:散列表的速度会很慢。
这里的经验教训有两个。
- 散列函数很重要 。前面的散列函数将所有的键都映射到一个位置,而最理想的情况是,散列函数将键均匀地映射到散列表的不同位置。
- 如果散列表存储的链表很长,散列表的速度将急剧下降。然而,如果使用的散列函数很好 ,这些链表就不会很长!
性能
在平均情况下,散列表执行各种操作的时间都为O (1)。O (1)被称为常量时间 。
你知道的,简单查找的运行时间为线性时间。
二分查找的速度更快,所需时间为对数时间。
在散列表中查找所花费的时间为常量时间。
在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速度与链表一样快,因此它兼具两者的优点!但在最糟情况下,散列表的各种操作的速度都很慢。因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。而要避免冲突,需要有:
- 较低的填装因子;
- 良好的散列函数。
填装因子
散列表的填装因子很容易计算。
填装因子=散列表包含的元素数/位置总数
你可能在想,调整散列表长度的工作需要很长时间!你说得没错,调整长度的开销很大,因此你不会希望频繁地这样做。但平均而言,即便考虑到调整长度所需的时间,散列表操作所需的时间也为O (1)。
良好的散列函数
良好的散列函数让数组中的值呈均匀分布。
糟糕的散列函数让值扎堆,导致大量的冲突。
什么样的散列函数是良好的呢?你根本不用操心——天塌下来有高个子顶着。如果你好奇,可研究一下SHA函数。你可将它用作散列函数。
广度优先搜索
广度优先搜索 (breadth-first search,BFS)让你能够找出两样东西之间的最短距离,不过最短距离的含义有很多!使用广度优先搜索可以:
- 编写国际跳棋AI,计算最少走多少步就可获胜;
- 编写拼写检查器,计算最少编辑多少个地方就可将错拼的单词改成正确的单词,如将READED改为READER需要编辑一个地方;
- 根据你的人际关系网络找到关系最近的医生。
图简介
还有其他前往金门大桥的路线,但它们更远(需要四步)。这个算法发现,前往金门大桥的最短路径需要三步。这种问题被称为最短路径问题 (shorterst-path problem)。你经常要找出最短路径,这可能是前往朋友家的最短路径,也可能是国际象棋中把对方将死的最少步数。解决最短路径问题的算法被称为广度优先搜索 。
要确定如何从双子峰前往金门大桥,需要两个步骤。
(1) 使用图来建立问题模型。
(2) 使用广度优先搜索解决问题。
图由节点 (node)和边 (edge)组成。
就这么简单!图由节点和边组成。一个节点可能与众多节点直接相连,这些节点被称为邻居 。
广度优先搜索
广度优先搜索是一种用于图的查找算法,可帮助回答两类问题。
- 第一类问题:从节点A出发,有前往节点B的路径吗?
- 第二类问题:从节点A出发,前往节点B的哪条路径最短?
假设你没有朋友是芒果销售商,那么你就必须在朋友的朋友中查找。
检查名单中的每个人时,你都将其朋友加入名单。
这样一来,你不仅在朋友中查找,还在朋友的朋友中查找。别忘了,你的目标是在你的人际关系网中找到一位芒果销售商。因此,如果Alice不是芒果销售商,就将其朋友也加入到名单中。这意味着你将在她的朋友、朋友的朋友等中查找。使用这种算法将搜遍你的整个人际关系网,直到找到芒果销售商。这就是广度优先搜索算法。
查找最短路径
谁是关系最近的芒果销售商。例如,朋友是一度关系,朋友的朋友是二度关系。
在你看来,一度关系胜过二度关系,二度关系胜过三度关系,以此类推。因此,你应先在一度关系中搜索,确定其中没有芒果销售商后,才在二度关系中搜索。广度优先搜索就是这样做的!在广度优先搜索的执行过程中,搜索范围从起点开始逐渐向外延伸,即先检查一度关系,再检查二度关系。
队列
队列只支持两种操作:入队 和出队 。
队列是一种先进先出 (First In First Out,FIFO)的数据结构,而栈是一种后进先出 (Last In First Out,LIFO)的数据结构。
实现图
首先,需要使用代码来实现图。图由多个节点组成。
每个节点都与邻近节点相连,如果表示类似于“你→Bob”这样的关系呢?好在你知道的一种结构让你能够表示这种关系,它就是散列表 !
记住,散列表让你能够将键映射到值。在这里,你要将节点映射到其所有邻居。
表示这种映射关系的Python代码如下。
graph = {}
graph["you"] = ["alice", "bob", "claire"]
注意,“你”被映射到了一个数组,因此graph["you"] 是一个数组,其中包含了“你”的所有邻居。
图不过是一系列的节点和边,因此在Python中,只需使用上述代码就可表示一个图。
graph = {}
graph["you"] = ["alice", "bob", "claire"]
graph["bob"] = ["anuj", "peggy"]
graph["alice"] = ["peggy"]
graph["claire"] = ["thom", "jonny"]
graph["anuj"] = []
graph["peggy"] = []
graph["thom"] = []
graph["jonny"] = []
Anuj、Peggy、Thom和Jonny都没有邻居,这是因为虽然有指向他们的箭头,但没有从他们出发指向其他人的箭头。这被称为有向图 (directed graph),其中的关系是单向的。因此,Anuj是Bob的邻居,但Bob不是Anuj的邻居。无向图 (undirected graph)没有箭头,直接相连的节点互为邻居。例如,下面两个图是等价的。
实现算法
更新队列时,我使用术语“入队”和“出队”,但你也可能遇到术语“压入”和“弹出”。压入大致相当于入队,而弹出大致相当于出队。
def search(name):
search_queue = deque()
search_queue += graph[name]
searched = [] ←------------------------------这个数组用于记录检查过的人
while search_queue:
person = search_queue.popleft()
if person not in searched: ←----------仅当这个人没检查过时才检查
if person_is_seller(person):
print person + " is a mango seller!"
return True
else:
search_queue += graph[person]
searched.append(person) ←------将这个人标记为检查过
return False
search("you")
运行时间
如果你在你的整个人际关系网中搜索芒果销售商,就意味着你将沿每条边前行(记住,边是从一个人到另一个人的箭头或连接),因此运行时间至少为O (边数)。
你还使用了一个队列,其中包含要检查的每个人。将一个人添加到队列需要的时间是固定的,即为O (1),因此对每个人都这样做需要的总时间为O (人数)。所以,广度优先搜索的运行时间为O (人数 + 边数),这通常写作O (V + E ),其中V 为顶点(vertice)数,E 为边数。
狄克斯特拉算法
你在前一章使用了广度优先搜索,它找出的是段数最少的路径。如果你要找出最快的路径,该如何办呢?为此,可使用另一种算法——狄克斯特拉算法 (Dijkstra's algorithm)。
使用狄克斯特拉算法
狄克斯特拉算法包含4个步骤。
(1) 找出最便宜的节点,即可在最短时间内前往的节点。
(2) 对于该节点的邻居,检查是否有前往它们的更短路径,如果有,就更新其开销。
(3) 重复这个过程,直到对图中的每个节点都这样做了。
(4) 计算最终路径。
术语
狄克斯特拉算法用于每条边都有关联数字的图,这些数字称为权重 (weight)。
带权重的图称为加权图 (weighted graph),不带权重的图称为非加权图 (unweighted graph)。
要计算非加权图中的最短路径,可使用广度优先搜索 。要计算加权图中的最短路径,可使用狄克斯特拉算法 。图还可能有环 .
在无向图中,每条边都是一个环。狄克斯特拉算法只适用于有向无环图 (directed acyclic graph,DAG)。
负权边
如果有负权边,就不能使用狄克斯特拉算法 。因为负权边会导致这种算法不管用。
在包含负权边的图中,要找出最短路径,可使用另一种算法——贝尔曼-福德算法 (Bellman-Ford algorithm)。
实现
下面来看看如何使用代码来实现狄克斯特拉算法,这里以下面的图为例。
要编写解决这个问题的代码,需要三个散列表。
算法:
graph = {}
graph["start"] = {}
graph["start"]["a"] = 6
graph["start"]["b"] = 2
graph["a"] = {}
graph["a"]["finish"] = 1;
graph["b"] = {}
graph["b"]["a"] = 3
graph["b"]["finish"] = 5
graph["finish"] = {}
infinity = float("inf")
costs = {}
costs["a"] = 6
costs["b"] = 2
costs["finish"] = infinity
parents = {}
parents["a"] = "start"
parents["b"] = "start"
parents["finish"] = "None"
processed = []
print(costs)
def find_lowest_cost_node(costs) :
low_costs = float("inf")
low_costs_node = None
for node in costs :
cost = costs[node]
if cost < low_costs and node not in processed :
low_costs = cost
low_costs_node = node
return low_costs_node
node = find_lowest_cost_node(costs)
while node is not None :
cost = costs[node]
neighbors = graph[node]
for n in neighbors.keys() :
new_cost = cost + neighbors[n]
if costs[n] > new_cost:
costs[n] = new_cost
parents[n] = node
processed.append(node)
node = find_lowest_cost_node(costs)
print(costs)
- 广度优先搜索用于在非加权图中查找最短路径。
- 狄克斯特拉算法用于在加权图中查找最短路径。
- 仅当权重为正时狄克斯特拉算法才管用。
贪婪算法
用专业术语说,就是你每步都选择局部最优解 ,最终得到的就是全局最优解。
从这个示例你得到了如下启示:在有些情况下,完美是优秀的敌人。有时候,你只需找到一个能够大致解决问题的算法,此时贪婪算法正好可派上用场,因为它们实现起来很容易,得到的结果又与正确结果相当接近。
近似算法
假设你办了个广播节目,要让全美50个州的听众都收听得到。为此,你需要决定在哪些广播台播出。在每个广播台播出都需要支付费用,因此你力图在尽可能少的广播台播出。
使用下面的贪婪算法可得到非常接近的解。
(1) 选出这样一个广播台,即它覆盖了最多的未覆盖州。即便这个广播台覆盖了一些已覆盖的州,也没有关系。
(2) 重复第一步,直到覆盖了所有的州。
这是一种近似算法 (approximation algorithm)。在获得精确解需要的时间太长时,可使用近似算法。判断近似算法优劣的标准如下:
- 速度有多快;
- 得到的近似解与最优解的接近程度。
贪婪算法是不错的选择,它们不仅简单,而且通常运行速度很快。在这个例子中,贪婪算法的运行时间为O(n^2),其中n 为广播台数量。
NP完全问题
旅行商问题和集合覆盖问题有一些共同之处:你需要计算所有的解,并从中选出最小/最短的那个。这两个问题都属于NP完全问题。
NP完全问题的简单定义是,以难解著称的问题,如旅行商问题和集合覆盖问题。很多非常聪明的人都认为,根本不可能编写出可快速解决这些问题的算法。
NP完全问题无处不在!如果能够判断出要解决的问题属于NP完全问题就好了,这样就不用去寻找完美的解决方案,而是使用近似算法即可。但要判断问题是不是NP完全问题很难,易于解决的问题和NP完全问题的差别通常很小。例如,前一章深入讨论了最短路径,你知道如何找出从A点到B点的最短路径。
但如果要找出经由指定几个点的的最短路径,就是旅行商问题——NP完全问题。简言之,没办法判断问题是不是NP完全问题,但还是有一些蛛丝马迹可循的。
- 元素较少时算法的运行速度非常快,但随着元素数量的增加,速度会变得非常慢。
- 涉及“所有组合”的问题通常是NP完全问题。
- 不能将问题分成小问题,必须考虑各种可能的情况。这可能是NP完全问题。
- 如果问题涉及序列(如旅行商问题中的城市序列)且难以解决,它可能就是NP完全问题。
- 如果问题涉及集合(如广播台集合)且难以解决,它可能就是NP完全问题。
- 如果问题可转换为集合覆盖问题或旅行商问题,那它肯定是NP完全问题。
动态规划
动态规划先解决子问题,再逐步解决大问题。
每个动态规划算法都从一个网格开始。
假设你在杂货店行窃,可偷成袋的扁豆和大米,但如果整袋装不下,可打开包装,再将背包倒满。在这种情况下,不再是要么偷要么不偷,而是可偷商品的一部分。如何使用动态规划来处理这种情形呢?
答案是没法处理。使用动态规划时,要么考虑拿走整件商品,要么考虑不拿,而没法判断该不该拿走商品的一部分。
仅当每个子问题都是离散的,即不依赖于其他子问题时,动态规划才管用 。
-
动态规划可帮助你在给定约束条件下找到最优解。在背包问题中,你必须在背包容量给定的情况下,偷到价值最高的商品。
-
在问题可分解为彼此独立。
-
每种动态规划解决方案都涉及网格。
-
单元格中的值通常就是你要优化的值。在前面的背包问题中,单元格的值为商品的价值。
-
每个单元格都是一个子问题,因此你应考虑如何将问题分成子问题,这有助于你找出网格的坐标轴。
-
需要在给定约束条件下优化某种指标时,动态规划很有用。
-
问题可分解为离散子问题时,可使用动态规划来解决。
-
每种动态规划解决方案都涉及网格。
-
单元格中的值通常就是你要优化的值。
-
每个单元格都是一个子问题,因此你需要考虑如何将问题分解为子问题。
-
没有放之四海皆准的计算动态规划解决方案的公式。
感觉是有限元,网格划分。
K最近邻算法
特征提取,机器学习,
接下来如何做
数据结构
如果你对数据库或高级数据结构感兴趣,请研究如下数据结构:B树,红黑树,堆,伸展树。
搜索
如果你对搜索感兴趣,从反向索引着手研究是不错的选择。
傅里叶变换
绝妙、优雅且应用广泛的算法少之又少,傅里叶变换算是一个。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 )函数。
映射函数
映射函数很简单,它接受一个数组,并对其中的每个元素执行同样的处理。例如,下面的映射函数将数组的每个元素翻倍。
>>> arr1 = [1, 2, 3, 4, 5]
>>> arr2 = map(lambda x: 2 * x, arr1)
[2, 4, 6, 8, 10]
arr2 包含[2, 4, 6, 8, 10] :将数组arr1 的每个元素都翻倍!将元素翻倍的速度非常快,但如果要执行的操作需要更长的时间呢?请看下面的伪代码。
>>> arr1 = # A list of URLs
>>> arr2 = map(download_page, arr1)
在这个示例中,你有一个URL清单,需要下载每个URL指向的页面并将这些内容存储在数组arr2 中。对于每个URL,处理起来都可能需要几秒钟。如果总共有1000个URL,可能耗时几小时!
如果有100台计算机,而map 能够自动将工作分配给这些计算机去完成就好了。这样就可同时下载100个页面,下载速度将快得多!这就是MapReduce中“映射”部分基于的理念。
归并函数
归并函数可能令人迷惑,其理念是将很多项归并为一项。映射是将一个数组转换为另一个数组,而归并是将一个数组转换为一个元素,下面是一个示例。
>>> arr1 = [1, 2, 3, 4, 5]
>>> reduce(lambda x,y: x+y, arr1)
15
在这个示例中,你将数组中的所有元素相加:1 + 2 + 3 + 4 + 5 = 15!这里不深入介绍归并,网上有很多这方面的教程。
MapReduce使用这两个简单概念在多台计算机上执行数据查询。数据集很大,包含数十亿行时,使用MapReduce只需几分钟就可获得查询结果,而传统数据库可能要耗费数小时。
布隆过滤器和HyperLogLog
假设你管理着网站Reddit。每当有人发布链接时,你都要检查它以前是否发布过,因为之前未发布过的故事更有价值。
又假设你在Google负责搜集网页,但只想搜集新出现的网页,因此需要判断网页是否搜集过。
在假设你管理着提供网址缩短服务的bit.ly,要避免将用户重定向到恶意网站。你有一个清单,其中记录了恶意网站的URL。你需要确定要将用户重定向到的URL是否在这个清单中。
这些都是同一种类型的问题,涉及庞大的集合。
给定一个元素,你需要判断它是否包含在这个集合中。为快速做出这种判断,可使用散列表。例如,Google可能有一个庞大的散列表,其中的键是已搜集的网页,要判断是否已搜集adit.io,可在这个散列表中查找它。
adit.io 是这个散列表中的一个键,这说明已搜集它。散列表的平均查找时间为O(1),即查找时间是固定的,非常好!
只是Google需要建立数万亿个网页的索引,因此这个散列表非常大,需要占用大量的存储空间。Reddit和bit.ly也面临着这样的问题。面临海量数据,你需要创造性的解决方案!
布隆过滤器
布隆过滤器提供了解决之道。布隆过滤器是一种概率型数据结构 ,它提供的答案有可能不对,但很可能是正确的。为判断网页以前是否已搜集,可不使用散列表,而使用布隆过滤器。使用散列表时,答案绝对可靠,而使用布隆过滤器时,答案却是很可能是正确的。
- 可能出现错报的情况,即Google可能指出“这个网站已搜集”,但实际上并没有搜集。
- 不可能出现漏报的情况,即如果布隆过滤器说“这个网站未搜集”,就肯定未搜集。
布隆过滤器的优点在于占用的存储空间很少。使用散列表时,必须存储Google搜集过的所有URL,但使用布隆过滤器时不用这样做。布隆过滤器非常适合用于不要求答案绝对准确的情况,前面所有的示例都是这样的。对bit.ly而言,这样说完全可行:“我们认为这个网站可能是恶意的,请倍加小心。”
HyperLogLog
HyperLogLog是一种类似于布隆过滤器的算法。如果Google要计算用户执行的不同搜索的数量,或者Amazon要计算当天用户浏览的不同商品的数量,要回答这些问题,需要耗用大量的空间!对Google来说,必须有一个日志,其中包含用户执行的不同搜索。有用户执行搜索时,Google必须判断该搜索是否包含在日志中:如果答案是否定的,就必须将其加入到日志中。即便只记录一天的搜索,这种日志也大得不得了!
HyperLogLog近似地计算集合中不同的元素数,与布隆过滤器一样,它不能给出准确的答案,但也八九不离十,而占用的内存空间却少得多。
面临海量数据且只要求答案八九不离十时,可考虑使用概率型算法!
SHA算法
假设你有一个键,需要将其相关联的值放到数组中。你使用散列函数来确定应将这个值放在数组的什么地方。这样查找时间是固定的。当你想要知道指定键对应的值时,可再次执行散列函数,它将告诉你这个值存储在什么地方,需要的时间为O(1)。在这个示例中,你希望散列函数的结果是均匀分布的。散列函数接受一个字符串,并返回一个索引号。
比较文件
另一种散列函数是安全散列算法(secure hash algorithm,SHA)函数。给定一个字符串,SHA返回其散列值。
这里的术语有点令人迷惑。SHA是一个散列函数 ,它生成一个散列值 ——一个较短的字符串。用于创建散列表的散列函数根据字符串生成数组索引,而SHA根据字符串生成另一个字符串。
对于每个不同的字符串,SHA生成的散列值都不同。
你可使用SHA来判断两个文件是否相同,这在比较超大型文件时很有用。假设你有一个4 GB的文件,并要检查朋友是否也有这个大型文件。为此,你不用通过电子邮件将这个大型文件发送给朋友,而可计算它们的SHA散列值,再对结果进行比较。
检查密码
SHA还让你能在不知道原始字符串的情况下对其进行比较。例如,假设Gmail遭到攻击,攻击者窃取了所有的密码!你的密码暴露了吗?没有,因为Google存储的并非密码,而是密码的SHA散列值!你输入密码时,Google计算其散列值,并将结果同其数据库中的散列值进行比较。
Google只是比较散列值,因此不必存储你的密码!SHA被广泛用于计算密码的散列值。这种散列算法是单向的。你可根据字符串计算出散列值,但你无法根据散列值推断出原始字符串。
这意味着计算攻击者窃取了Gmail的SHA散列值,也无法据此推断出原始密码!你可将密码转换为散列值,但反过来不行。
SHA实际上是一系列算法:SHA-0、SHA-1、SHA-2和SHA-3。本书编写期间,SHA-0和SHA-1已被发现存在一些缺陷。如果你要使用SHA算法来计算密码的散列值,请使用SHA-2或SHA-3。当前,最安全的密码散列函数是bcrypt,但没有任何东西是万无一失的。
局部敏感的散列算法
SHA还有一个重要特征,那就是局部不敏感的。假设你有一个字符串,并计算了其散列值。
如果你修改其中的一个字符,再计算其散列值,结果将截然不同!
这很好,让攻击者无法通过比较散列值是否类似来破解密码。
有时候,你希望结果相反,即希望散列函数是局部敏感的。在这种情况下,可使用Simhash。如果你对字符串做细微的修改,Simhash生成的散列值也只存在细微的差别。这让你能够通过比较散列值来判断两个字符串的相似程度,这很有用!
- Google使用Simhash来判断网页是否已搜集。
- 老师可以使用Simhash来判断学生的论文是否是从网上抄的。
- Scribd允许用户上传文档或图书,以便与人分享,但不希望用户上传有版权的内容!这个网站可使用Simhash来检查上传的内容是否与小说《哈利·波特》类似,如果类似,就自动拒绝。
- 需要检查两项内容的相似程度时,Simhash很有用。
Diffie-Hellman密钥交换
这里有必要提一提Diffie-Hellman算法,它以优雅的方式解决了一个古老的问题:如何对消息进行加密,以便只有收件人才能看懂呢?
最简单的方式是设计一种加密算法,如将a转换为1,b转换为2,以此类推。这样,如果我给你发送消息“4,15,7”,你就可将其转换为“d,o,g”。但我们必须就加密算法达成一致,这种方式才可行。我们不能通过电子邮件来协商,因为可能有人拦截电子邮件,获悉加密算法,进而破译消息。即便通过会面来协商,这种加密算法也可能被猜出来——它并不复杂。因此,我们每天都得修改加密算法,但这样我们每天都得会面!
即便我们能够每天修改,像这样简单的加密算法也很容易使用蛮力攻击破解。假设我看到消息“9,6,13,13,16 24,16,19,13,5”,如果使用加密算法a = 1、b = 2等,转换结果将如下。
ifmmpxpsme
结果是一堆乱码。我们来尝试加密算法a = 2、b = 3等。
helloworld
结果对了!像这样的简单加密算法很容易破解。在二战期间,德国人使用的加密算法比这复杂得多,但还是被破解了。Diffie-Hellman算法解决了如下两个问题。
- 双方无需知道加密算法。他们不必会面协商要使用的加密算法。
- 要破解加密的消息比登天还难。
Diffie-Hellman使用两个密钥:公钥和私钥。顾名思义,公钥就是公开的,可将其发布到网站上,通过电子邮件发送给朋友,或使用其他任何方式来发布。你不必将它藏着掖着。有人要向你发送消息时,他使用公钥对其进行加密。加密后的消息只有使用私钥才能解密。只要只有你知道私钥,就只有你才能解密消息!
Diffie-Hellman算法及其替代者RSA依然被广泛使用。如果你对加密感兴趣,先着手研究Diffie-Hellman算法是不错的选择:它既优雅又不难理解。
线性规划
最好的东西留到最后介绍。线性规划是我知道的最酷的算法之一。
线性规划用于在给定约束条件下最大限度地改善指定的指标。例如,假设你所在的公司生产两种产品:衬衫和手提袋。衬衫每件利润2美元,需要消耗1米布料和5粒扣子;手提袋每个利润3美元,需要消耗2米布料和2粒扣子。你有11米布料和20粒扣子,为最大限度地提高利润,该生产多少件衬衫、多少个手提袋呢?
在这个例子中,目标是利润最大化,而约束条件是拥有的原材料数量。
再举一个例子。你是个政客,要尽可能多地获得支持票。你经过研究发现,平均而言,对于每张支持票,在旧金山需要付出1小时的劳动(宣传、研究等)和2美元的开销,而在芝加哥需要付出1.5小时的劳动和1美元的开销。在旧金山和芝加哥,你至少需要分别获得500和300张支持票。你有50天的时间,总预算为1500美元。请问你最多可从这两个地方获得多少支持票?
这里的目标是支持票数最大化,而约束条件是时间和预算。
你可能在想,本书花了很大的篇幅讨论最优化,这与线性规划有何关系?所有的图算法都可使用线性规划来实现。线性规划是一个宽泛得多的框架,图问题只是其中的一个子集。但愿你听到这一点后心潮澎湃!
线性规划使用Simplex算法,这个算法很复杂,因此本书没有介绍。如果你对最优化感兴趣,就研究研究线性规划吧!