算法图解

算法简介

二分查找

二分查找也称折半查找(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算法,这个算法很复杂,因此本书没有介绍。如果你对最优化感兴趣,就研究研究线性规划吧!

posted @ 2020-05-11 23:10  多弗朗强哥  阅读(20113)  评论(0编辑  收藏  举报