最近打算学习一些算法,这里先对一些基础算法只是进行重温,对算法的知识点进行一些总结,毕竟有文字输出效果会更好。

概论

选择排序

递归

递归是一种非常优雅的解决问题的方式。它可以把复杂的问题逐步分解成简单的问题,逐步得到结果。
递归就是在函数中,满足递归条件,就调用函数自身,直到满足基线条件,递归返回。
每个递归函数都有两部分:

  • 基线条件:函数不再调用自己,避免形成无限循环。
  • 递归条件:函数调用自己。


栈是计算机中一种非常常见的数据结构,可以进行数据的压入(插入)和弹出(删除并读取)。

调用栈
当函数调用时,计算机首先会为该函数调用分配一块内存,存储函数调用设计的所有变量值。当函数内部调用其他函数时,计算机就会为内部调用函数也分配一块内存,第二块内存位于第一块内存的上面,当函数调用返回,第二块内存就会从栈顶弹出。这就被称为调用栈。

递归调用栈
递归调用栈中包含所有未完成的函数调用。在递归中,在最终返回结果前,每个函数都还未完成,所以栈中会存储每个调用函数的信息,每个函数调用都要占用一定的内存。所以如果栈很高,就会占用非常多的内存资源。解决方法如下:

  • 重新编写代码,使用循环。
  • 使用尾递归,这个貌似比较高级,待深究。

快速排序

这里要用到一种分而治之的思想(divide and conquer, D&C),即递归式问题解决方法。

D&C原理

D&C的工作原理:

  • 找出简单的基线条件;
  • 确定如何缩小问题的规模,使其符合基线条件。

这里有一个好玩的问题:

假设你是一个农场主,有一块168*64的土地。
现在要你均匀分成正方形土地,且分的方块要尽可能大,你会怎样分?

这里涉及到一个欧几里得算法的问题:又称辗转相除法,是指用于计算两个正整数a,b的最大公约数。

我们可以先从这块地中划出两个最大的方块,然后对剩下的一小块地再次划出最大的方块,就这样递归,直到没有剩下地块为止。这样我们就使用的递归的方式来解决问题。

快速排序

工作原理:

  • 从数组中选择一个元素,这个元素成为基准值(pivot);
  • 找出比基准值小的元素以及比基准值大的元素,这被称为分区。分区后得到一个由所有小于基准值的数组成的子数组,一个由所有大于基准值的数组成的子数组,两个子数组是无序的;
  • 然后对两个子数组进行快速排序。

下面是快速排序的代码:

1
2
3
4
5
6
7
8
9
10
11
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])

时间复杂度
快速排序的性能高度依赖你选择的基准值。

  • 最糟情况:始终选择第一个值作为基准值,则调用栈的高度为n;
  • 最佳情况:始终将中间值作为基准值,则调用栈的高度为log n。

因为每层栈需要的时间为O(n),所以时间复杂度方面:

  • 最佳情况:O(n) * O(log n) = O(n log n)
  • 最糟情况:O(n) * O(n) = O(n2)

最佳情况也是平均情况,只要每次都随机选择一个数组元素作为基准值,快速排序的平均运行时间将为O(n log n)。快速排序是最快的排序算法质疑,也是D&C典范。

散列表

散列表我们非常熟悉,在Python中就是字典,在JavaScript中就是对象,等。每一种语言中都有自己对于散列表的实现。这里简单总结一下散列表的实现原理。

散列表平均情况下的时间复杂度是个常量:O(1)

实现原理
将输入通过散列函数输出一个数组的索引,在数组的该索引位置上存储一个值,这样就可以快速得出我们想要的结果。

避免冲突
不够理想的散列函数可能会将不同的键映射到数组的同一个位置,这样就需要在这个位置上存储一个链表,就会导致性能下降。

要提升散列表的性能,需要:

  • 较低的装填因子;
  • 良好的散列函数。

装填因子就是 散列表包含的元素数/数组位置总数。所以装填因子越小,冲突的可能性就越小,装填因子过大,我们可以调整数组长度,通常将数组增加一倍。一个不错的经验规则是:一旦装填因子大于0.7,就调整散列表的长度。

良好的散列函数可以让数组中的值呈均匀分布。

广度优先搜索(宽度优先搜索)

什么是图?
图由节点和边组成,一个节点可能和众多节点相连接,这些节点称为邻居。

广度优先搜索是一种用于图的查找算法,帮助回答两个问题:

  • 从节点A出发,有前往节点B的路径吗?
  • 从节点A出发,前往节点B的哪条路径最短?

广度优先搜索的基本思路就是现在一级节点中搜索,如果找到目标,则完成,如果没有找到目标,继续在二级节点中搜索,依次是三级、四级…直到返回搜索结果。

在检查的过程中要按照添加顺序进行,所以我们要用到队列。队列是一种先进先出(FIFO)的数据结构,而栈是一种后进先出(LIFO)的数据结构。

关于图的代码实现,我们可以使用散列表来实现,例如下图:
img

我们可以用如下代码实现:

1
2
3
4
5
6
7
8
9
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"] = []

算法实现原理:
img

算法代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from collections import deque

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 seller'
return True
else:
search_queue += graph[person]
searched.append(person)

return False

def person_is_seller(name):
//随意定义一个判断函数
return name[-1] == 'm'

search('you')

小结

  • 面对寻找最短路径的问题,可以先用图来建立模型,再使用广度优先搜索来解决问题。
  • 有向图中的边有箭头,箭头的方向指定了关系的方向。
  • 无向图中的边没有箭头,其中的关系是双向的。
  • 你需要按照加入顺序进行检查,否则找到的可能就不是最短路径,因此搜索列表必须是队列。
  • 对于检查过的人务必不要再去检查,否则可能导致无限循环,例如一个无向图。

狄克斯特拉算法(Dijkstra)

前一章中使用广度优先搜索来查找两点之间的最短路径,那是的最短路径的意思是段数最少。在狄克斯特拉算法中,给每段都分配了数字或权重,因此狄克斯特拉算法找出的是总权重最小的路径。

狄克斯特拉算法只适用于有向无环图。
狄克斯特拉算法不能用于包括负权边的图。

实现
我们以下图中的例子来进行实现:
img

首先,我们需要定义三个散列表来记录图的相关信息。
graph:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// graph用来记录图的信息,包括节点的邻居和前往邻居的开销
graph = {}

graph['start'] = {}
graph['start']['a'] = 6
graph['start']['b'] = 2

graph['a'] = {}
graph['a']['fin'] = 1

graph['b'] = {}
graph['b']['a'] = 3
graph['b']['fin'] = 5

graph['fin'] = {}

costs:
1
2
3
4
5
6
//costs用于存储每个节点的开销
infinity = float('inf')
costs = {}
costs['a'] = 6
costs['b'] = 2
costs['fin'] = infinity

parents:
1
2
3
4
5
// parents用于存储父节点
parents = {}
parents['a'] = 'start'
parents['b'] = 'start'
parents['fin'] = None

还需要一个数组用于存储已处理过的节点
1
processed = []

完成了各种数据结构的创建,接下来我们开始算法。
img
代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 未处理的节点中找到开销最小的节点
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)


def find_lowest_cost_node(costs):
lowest_cost = float('inf')
lowest_cost_node = None

for node in costs:
cost = cost[node]
if cost < lowest_cost and node not in processed:
lowest_cost = cost
lowest_cost_node = node

return lowest_cost_node

以上就是代码实现的全部内容。

小结

  • 非加权图查找最短路径使用广度优先搜索;加权图中查找最短路径使用狄克斯特拉算法;
  • 仅当权重为正时,狄克斯特拉算法才管用;图中含负权边时,使用贝尔曼-福德算法。

贪婪算法

贪婪算法是指,在对问题求解时,总是做出在当前看来是最好的选择。就是说,你每步都选择局部最优解,不从整体最优上加以考虑。贪婪算法不能得出最优解,但非常接近最优解。

集合覆盖问题
举个例子:
你要办一个广播节目,让全美50个州都能收听得到。为此,你需要决定在哪些广播台播出。每个广播台都需要支付费用,因此你力图在尽可能少的广播台播出。

算法:

  1. 选出这样一个广播台,即它覆盖了最多的未覆盖州。即使已覆盖一些州,也没关系;
  2. 重复上一步,知道覆盖了所有州。

这就是一种近似算法,因为获得精确解需要的时间太长。判断近似算法优劣的标准如下:

  • 速度有多快;
  • 得到的近似解与最优解的接近程度。

代码实现
出于简化考虑,智力要覆盖州、广播台没有那么多。

构建原始数据结构

1
2
3
4
5
6
7
8
9
10
11
12
// 创建一个集合,包含要覆盖的州
states_needed = set(['mt', 'wa', 'or', 'id', 'nv'])

// 键为广播站名,值为广播站覆盖的州
stations = {}
stations['kone'] = set(['id', 'nv'])
stations['ktwo'] = set(['wa', 'id', 'mt'])
stations['kthree'] = set(['or', 'nv'])
stations['kfour'] = set(['nv'])

// 存储最终选择的广播台
final_stations = set()

逻辑代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
while states_needed: // 只要需要覆盖的州不为空,一直循环

// 每一次循环都是通过遍历所有广播台,找出覆盖剩余需要覆盖州最多的电台
best_station = None // 覆盖州最多的广播台
states_covered = set() // 覆盖州的集合

for station, states in stations.items(): // 遍历所有广播台
covered = states_needed & states // 当前广播台覆盖州和需要覆盖州的交集
if len(covered) > len(states_covered): // 判断当前广播台覆盖的州是否是最多的
states_covered = covered
best_station = station

states_needed -= states_covered // 需要覆盖州与当前广播台所覆盖的州取差集
final_stations.add(best_station) // 添加当前广播台

NP完全问题
NP完全问题定义为,以难解著称的问题,根本不可能编写出可快速解决这些问题的算法。

例如,要找出A、B两点之间的最短路径是很容易得出,但如果要找出指定几个点的最短路径,就是商旅问题,这就是一个NP完全问题。
一般判断一个问题是否为NP问题依据:

  • 元素较少时算法运算速度非常快,但随着元素数量的增加,速度会变得非常慢;
  • 涉及“所有组合”的问题通常是NP完全问题;
  • 不能将问题分成小问题,必须考虑各种可能的情况;
  • 问题涉及序列且难以解决,如商旅问题;
  • 问题涉及集合且难以解决,如广播台集合。

动态规划

K最近邻算法

K-nearest neighbor,简称KNN算法。堪称你进入机器学习领域的领路人。

K最近邻算法可以用于创建分类系统,通过特征值的计算,找出最接近的邻居数据,就可归为相同类别。

通过这样的算法,可以构建一个推荐系统。

挑选正确的特征值对于算法的准确性非常重要。

朴素贝叶斯分类器应用领域与KNN算法相似,通过计算概率来做预测。

小结

  • KNN算法用于分类和回归,需要考虑最近的邻居;
  • 分类就是编组;回归就是预测结果;
  • 特征抽取意味着将物品转化为一系列可比较的数字;
  • 能否挑选合适的特征事关KNN算法的成败。

更多算法介绍

二叉树对于其中的每一个节点,左子节点的值都比它小,右子节点的值都比它大。

二叉树查找节点时,平均运行时间O(log n),最糟糕情况下所需时间O(n)。二分法最糟糕情况下的时间只有O(log n),可能会认为二分法比二叉树更佳。然而,二叉树插入和删除操作速度更快,平均运行时间都为O(log n)。

二叉树缺点就是不能随机访问。

另外,还有B树、红黑树、堆、伸展树。

反向索引

主要用于搜索引擎, 通过构建一个散列表,键值为关键词,值为关键词对应的页面,将单词映射到包含它的页面。

傅里叶变换

并行算法

用于改善性能和拓展性。

MapReduce

一种流行的分布式算法,可以通过开元工具Apache Hadoop来使用它。当需要几个内核时,可以使用并行算法,但当需要使用上百个内核时,就需要在多态计算机上运行。
基于两个简单的理念:映射(map)函数和归并(reduce)函数。

布隆过滤器和HyperLogLog

这两种算法都属于概率性算法,不能保证结果百分百准确,但八九不离十,对于海量数据非常有用,减少存储空间的占用。

SHA算法

安全散列算法(secure hash algorithm)函数。给定一个字符串,SHA返回其散列值。

可以根据生成的散列值,对判断两个文件是否相同。

对密码进行加密。在存储用户密码时,之存储密码的SHA散列值,用户输入密码后,计算其SHA散列值,将结果通数据库中的散列值进行比较。这样即使密码泄露,也不会泄露原始密码。因为SHA算法是单向的,根据散列值无法推断出原始字符串。

局部敏感的散列算法

SHA算法的一个重要特征就是局部不敏感。如果你修改了字符串中的一个字符,计算的散列值截然不同。

但一些情况下我们希望散列函数是局部敏感的,可以使用Simhash。对字符串做细微修改,生成的散列值只存在细微的差别,这样可以判断字符串的相似程度。

场景:

  • 判断网页是否已搜索;
  • 论文查重。

Diffie-Hellman秘钥交换

就是我们熟知的公私钥加密算法,它的替代者RSA也一样广泛使用。

线性规划

结束语:
整篇文章主要是我对于《图解算法》这本书的阅读总结,后续会更深入地了解更多算法。