算法图解阅读笔记

213 阅读22分钟

大O表示法

大O表示法讨论运行时间时,log指的都是log2。

大O表示法是一种特殊的表示法,指出了算法的速度有多快。例如,假设列表包含n个元素。简单查找需要检查每个元素,因此需要执行n次操作。使用大O表示法,这个运行时间为O(n)。单位秒呢?没有——大O表示法指的并非以秒为单位的速度。大O表示法让你能够比较操作数,它指出了算法运行时间的增速。

操作数指的是操作内存的次数!更深入的说,是操作寄存器的次数!

大O表示法指出了最糟情况下的运行时间。

除最糟情况下的运行时间外,还应考虑平均情况的运行时间,这很重要。

一些常见的大O运行时间

下面按从快到慢的顺序列出了你经常会遇到的5种大O运行时间。

  • O(log n),也叫对数时间,这样的算法包括二分查找。
  • O(n),也叫线性时间,这样的算法包括简单查找。
  • O(n * log n),这样的算法包括第4章将介绍的快速排序——一种速度较快的排序算法。
  • O(n*n),这样的算法包括第2章将介绍的选择排序——一种速度较慢的排序算法。
  • O(n!),这样的算法包括接下来将介绍的旅行商问题的解决方案——一种非常慢的算法。

n! 表示 n 的阶乘。 n!=1×2×3×...×n。

下面按从快到慢的顺序列出了使用这些算法绘制网格所需的时间:

运行时间

上述图表中的时间是基于每秒执行10次操作计算得到的。这些数据并不准确,这里提供它们只是想让你对这些运行时间的差别有大致认识。实际上,计算机每秒执行的操作远不止10次。

二分查找算法

# 二分查找,数组是有序的
def binary_search(list, item):
	low = 0
	high = len(list) - 1
	while low <= high:
		mid = int((low + high) / 2)
		guess = list[mid]
		if guess == item:
			return mid
		if guess > item:
			high = mid - 1
		else:
			low = mid + 1
	return None

my_list = [3, 5, 2, 7, 34, 26]

print(binary_search(my_list,7))

小结

  • 算法的速度指的并非时间,而是操作数的增速。
  • 谈论算法的速度时,我们说的是随着输入的增加,其运行时间将以什么样的速度增加。
  • 算法的运行时间用大O表示法表示。
  • O(log n) 比 O(n) 快,当需要搜索的元素越多时,前者比后者快得越多。

数组与链表

数组的特点:

  • 数组中所有元素的类型都必须相同(都为int、double等)。
  • 数组中的元素在内存中都是相连的。
  • 数组支持顺序访问和随机访问。
  • 数组的读取速度很快。

链表的特点:

  • 链表的每个元素都存储了下一个元素的地址,从而使一系列随机的内存地址串在一起。
  • 在链表中添加元素很容易:只需将其放入内存,并将其地址存储到前一个元素中。
  • 链表只支持顺序访问。
  • 链表的插入和删除速度很快。

常见数组和链表操作的运行时间

数组链表
读取O(1)O(n)
插入O(n)O(1)
删除O(n)O(1)

递归

如果使用循环,程序的性能可能更高;如果使用递归,程序可能更容易理解。如何选择要看什么对你来说更重要。 递归只是让解决方案更清晰,并没有性能上的优势。

基线条件和递归条件

由于递归函数调用自己,因此编写这样的函数时很容易出错,进而导致无限循环。所以编写递归函数时,必须告诉它何时停止递归。

正因为如此,每个递归函数都有两部分:基线条件递归条件。递归条件指的是函数调用自己,而基线条件则指的是函数不再调用自己,从而避免形成无限循环。

调用栈

计算机在内部使用被称为调用栈的栈。我们来看看计算机是如何使用调用栈的。

def greet(name):
		print "hello, " + name + "!"
		greet2(name)
		print "getting ready to say bye..."
		bye()

def greet2(name):
		print "how are you, " + name + "?"

def bye():
		print "ok bye"

在Python中,print是一个函数,但出于简化考虑,这里假设它不是函数。你也这样假设就行了。

现在,栈顶的内存块是函数greet的,这意味着你返回到了函数greet。当你调用函数greet2时,函数greet只执行了一部分。这是本节的一个重要概念:**调用另一个函数时,当前函数暂停并处于未完成状态。该函数的所有变量的值都还在内存中。**执行完函数greet2后,你回到函数greet,并从离开的地方开始接着往下执行:首先打印 getting ready to say bye...,再调用函数bye。

小结

  • 递归指的是调用自己的函数。
  • 每个递归函数都有两个条件:基线条件和递归条件。
  • 栈有两种操作:压入和弹出。
  • 所有函数调用都进入调用栈。
  • 调用栈可能很长,这将占用大量的内存。

快速排序

分而治之

分而治之 (divide and conquer,D&C),一种著名的递归式问题解决方法。

使用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]))

小结

  • 实现快速排序时,请随机地选择用作基准值的元素。快速排序的平均运行时间为O(n log n)。

  • 快速排序在最糟糕的情况下,算法的运行时间为 O(n) * O(n) = O(n2n^2)。

  • 大O表示法中的常量有时候事关重大,这就是快速排序比合并排序快的原因所在。

  • 比较简单查找和二分查找时,常量几乎无关紧要,因为列表很长时,O(log n)的速度比O(n) 快得多。

散列表(哈希表)

哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

hash就是找到一种数据内容和数据存放地址之间的映射关系。

当使用哈希表hashtable(key,value) 进行查询的时候,就是使用哈希函数将关键码key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位。

Hash Table的查询速度非常的快,几乎是**O(1)**的时间复杂度。

应用

  • 将散列表用于查找,例如:DNS解析
  • 防止重复
  • 将散列表用作缓存

哈希冲突

哈希冲突(hash collision):哈希函数将两个不同的键映射到同一个索引的情况。

哈希冲突是不可避免的,如果遇到冲突,最常用的解决办法就是开放定址法链地址法

开放定址法

开放定址法是遇到冲突的时候查找顺着原来哈希地址查找下一个空闲地址然后插入。

但是也有一个问题就是如果空间不足,那他无法处理冲突也无法插入数据,因此需要装填因子(空间/插入数据)>=1。

开放定址法

链地址法

链地址法的原理时如果遇到冲突,他就会在原地址新建一个空间,然后以链表结点的形式插入到该空间。

链地址法

性能

在平均情况下,散列表执行各种操作的时间都为O(1)。O(1)被称为常量时间。你以前没有见过常量时间,它并不意味着马上,而是说不管散列表多大,所需的时间都相同。

在最糟情况下,散列表所有操作的运行时间都为O(n)——线性时间,这真的很慢。我们来将散列表同数组和链表比较一下。

数组链表散列表平均情况散列表最糟情况
查找O(1)O(n)O(1)O(n)
插入O(n)O(1)O(1)O(n)
删除O(n)O(1)O(1)O(n)

在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速度与链表一样快,因此它兼具两者的优点!但在最糟情况下,散列表的各种操作的速度都很慢。因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。而要避免冲突,需要有:

  • 较低的填装因子
    • 填装因子:散列表元素数 / 位置总数
    • 填装因子大于1意味着元素数量超过了数组的位置数。
    • 一旦填装因子开始增大,你就需要在散列表中添加位置,这被称为调整长度(resizing),通常将数组增长一倍。
    • 一个不错的经验规则是:一旦填装因子大于0.7,就调整散列表的长度。
  • 良好的散列函数
    • 最理想的情况是:散列函数将键均匀地映射到散列表的不同位置。
    • 如果散列表存储的链表很长,散列表的速度将急剧下降。然而,如果使用的散列函数很好,这些链表就不会很长!

广度优先搜索

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

  • 是否存在问题:从节点A出发,有前往节点B的路径吗?

  • 最短路径问题:从节点A出发,前往节点B的哪条路径最短?

图简介

图用来模拟一组连接。图由节点和边组成。一个节点可能与众多节点直接相连,这些节点被称为邻居。

最短路径:假设你居住在旧金山,要从双子峰前往金门大桥。你想乘公交车前往,并希望换乘最少。可乘坐的公交车如下

img

这就构成一个图,地点是图中的节点,连接表示不同节点之间是否存在公交车的关系。当然,这只是图的一个例子,图可以表示不同节点之间的关系,节点可以有多种含义。

你希望从最短的路径,花最少的时间到达金山大桥,按照这个图,你需要走最少三步,这就是最短路径,如下所示:

img

广度优先搜索

是否存在问题

从节点A,查找节点A的所有邻居中是否有B,如果没有再查找A的邻居的邻居中是否有B,一直查找下去,直到找到B或者遍历完。

以寻找芒果销售商的例子说明:

假设你经营着一个芒果农场,需要寻找芒果销售商,以便将芒果卖给他。为此,你可在朋友中查找。首先,创建一个朋友表单,依次检查名单中的每个人,看看他是否是芒果销售商?如果你的朋友中没有销售商,那就检查名单中每个朋友的朋友有没有销售商。这样,你就能知道你的关系网中有没有销售商,这样,你就解决了存不存在的问题。

最短路径问题

在上面的例子中,如果你的朋友定义为一度关系,你的朋友的朋友定义为二度关系,依此而定,一度关系比二度关系更加亲密,距离更短。为了寻找最短路径,一般先从一度关系开始找起,若一度关系中找不到想要的,再开始找二度关系,编程中如何实现这种顺序,则是利用队列来完成。

寻芒果商

编程实现

队列-控制顺序

队列是一种先进先出的数据结构,它支持入队和出队两种操作,并且先进先出。

img

图的实现-散列表

散列表根据键值的方式存储节点间的相邻关系。对于寻芒果商的那幅图来说,代码如下:

graph = dict()
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

import collections

# 广度优先搜索

def getGraph():
  graph = dict()
  graph["you"] = ["alice", "bob", "claire"]
  graph["bob"] = ["anuj", "peggy"]
  graph["alice"] = ["peggy"]
  graph["claire"] = ["thom", "jonny"]
  graph["anuj"] = []
  graph["peggy"] = []
  graph["thom"] = []
  graph["jonny"] = []
  return graph

def person_is_seller(name):
  return name[-1] == 'm'

def search(name):
  graph = getGraph()
  search_queue = collections.deque()
  search_queue += graph[name]
  searched = []
  while search_queue: # 只要队列不为空
    person = search_queue.popleft() # 就取出第一个人
    if not person in searched: # 确定这个人还没有检查过,已检查过的就略过
      if person_is_seller(person): # 检查这个人是不是销售商
        print(person + " is a mango seller!" ) # 是
        return True
      else:
        search_queue += graph[person] # 不是,将这个人的朋友都加入队列
        searched.append(person) # 将这个人加入已检查列表
  print("none is a mango seller") # 如果到了这里,说明队列中没有人是芒果销售商
  return False

search('you')

运行时间

广度优先搜索的运行时间为 O(人数 + 边数),这通常写作 O(V + E),其中V为顶点数,E为边数。

小结

  • 广度优先搜索解决是否存在从A到B的路径的问题,如果有,广度优先搜索将找出最短路径。
  • 寻找最短路径的问题,可以建立图关系,利用广度优先搜索算法求解
  • 广度优先搜索,利用队列的结构,先从开始节点的邻居开始遍历,先检索一个节点是否满足要求,若满足要求,则结束搜索,若不满足则将该节点弹出队列,将该节点的邻居加入队列,最终完成遍历或找到满足要求的节点。

狄克斯特拉算法

狄克斯特拉算法解决的是带权重的有向图上从一个顶点到其余各顶点的最短路径问题,该算法有一个限制条件即:所有边的权重都必须为非负数

使用狄克斯特拉算法

用下图举个例子:

img

该算法的四个步骤:

  1. 找出"最便宜的节点",即可在最短时间内到达的节点,先找出
  2. 更新该节点的邻居的开销
  3. 重复这个过程,直到对图中的每个节点都这样做了
  4. 计算最终路径

第一步:找出最便宜的节点,假设终点需要时间无穷大,节点B是最近的——2分钟。

img

第二步:计算经节点B前往其各个邻居所需要的时间。

img

找到一条前往节点A的更短路径。

对于节点B的邻居,如果找到前往它的更短路径,就更新其开销。在这里,你找到了:

前往节点A的更短路径(时间从6分钟缩短到5分钟);前往终点的更短路径(时间从无穷大缩短到7分钟)。

第三步:重复!

重复第一步:找出可在最短时间内前往的节点。你对节点B执行了第二步,除节点B外,可在最短时间内前往的节点是节点A。

img

重复第二步:更新节点A的所有邻居的开销。

img

你发现前往终点的时间为6分钟! 你对每个节点都运行了狄克斯特拉算法(无需对终点这样做)。现在,你知道:前往节点B需要2分钟;前往节点A需要5分钟;前往终点需要6分钟。

img

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

比较图:

img

术语

狄克斯特拉算法用于每条边都有关联数字的图,这些数字称为权重

带权重的图称为加权图,不带权重的图称为非加权图。

要计算非加权图中的最短路径,可使用广度优先搜索。要计算加权图中的最短路径,可使用狄克斯特拉算法

对于可能有环的图,绕环的路径不可能是最短的路径,在无向图中,每条边都是一个环。狄克斯特拉算法只适合于有向无环图

代码实现

#整个图的散列表
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"] = {}

#散列表是开销
infinity = float("inf")
costs = {}
costs["a"] = 6
costs["b"] = 2
costs["fin"] = infinity

#散列表是父子节点
parents = {}
parents["a"] = "start"
parents["b"] = "start"
parents["fin"] = None

processed = []

def find_lowest_cost_node(costs):
  lowest_cost = float("inf")
  lowest_cost_node = None
  for node in costs:                                    # 遍历所有的节点
    cost = costs[node]
    if cost < lowest_cost and node not in processed:    # 如果当前节点的开销更低 且未处理过
      lowest_cost = cost                                # 就将其视为开销最低的节点
      lowest_cost_node = node
  return lowest_cost_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)
print(parents)

小结

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

贪婪算法

贪婪算法(贪心算法)是指在对问题进行求解时,在每一步选择中都采取最好或者最优(即最有利)的选择,从而希望能够导致结果是最好或者最优的算法。

贪心算法总是做出在当前时刻看起来最优的决策,即期望通过局部最优决策导致问题的全局最优解。

贪婪算法所得到的结果往往不是最优的结果(有时候会是最优解),但是都是相对近似(接近)最优解的结果。

  • 贪婪算法并没有固定的算法解决框架,算法的关键是贪婪策略的选择,根据不同的问题选择不同的策略。

  • 必须注意的是策略的选择必须具备无后效性,即某个状态的选择不会影响到之前的状态,只与当前状态有关,所以对采用的贪婪的策略一定要仔细分析其是否满足无后效性。

比如前边介绍的最短路径问题(广度优先、狄克斯特拉)都属于贪婪算法,只是在其问题策略的选择上,刚好可以得到最优解。

集合覆盖问题

假设你办了个广播节目,要让全美50个州的听众都收听得到。为此,你需要决定在哪些广播台播出。在每个广播台播出都需要支付费用,因此你力图在尽可能少的广播台播出。现有广播台名单如下:

img

每个广播台都覆盖特定的区域,不同广播台的覆盖区域可能重叠。

img

如何找出覆盖全美50个州的最小广播台集合呢?听起来很容易,但其实非常难。具体方法如下。

  1. 列出每个可能的广播台集合,这被称为幂集。可能的子集有2n2^n个。
  2. 在这些集合中,选出覆盖全美50个州的最小集合。

问题是计算每个可能的广播台子集需要很长时间。由于可能的集合有2n个,因此运行时间为O(2n2^n)。如果广播台不多,只有5~10个,这是可行的。但如果广播台很多,结果将如何呢?随着广播台的增多,需要的时间将激增。

近似算法

**贪婪算法可化解危机!**使用下面的贪婪算法可得到非常接近的解。

  1. 选出这样一个广播台,即它覆盖了最多的未覆盖州。即便这个广播台覆盖了一些已覆盖的州,也没有关系。
  2. 重复第一步,直到覆盖了所有的州。

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

  • 速度有多快

  • 得到的近似解与最优解的接近程度

在上面的例子中,贪婪算法的运行时间为O(n2n^2),其中n为广播台数量。

代码实现

# 所有需要覆盖的州
states_needed = set(["mt", "wa", "or", "id", "nv", "ut", "ca", "az"])
# 可供选择的广播台清单
stations = {}
stations["1"] = set(["id", "nv", "ut"])
stations["2"] = set(["wa", "id", "mt"])
stations["3"] = set(["or", "nv", "ca"])
stations["4"] = set(["nv", "ut"])
stations["5"] = set(["ca", "az"])
# 最终选择的广播台列表
final_stations = set()

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): # 检查该广播台覆盖的州是否比 best_station 多。
			best_station = station  # 是,将 best_station 设置为当前广播台
			states_covered = covered
	states_needed -= states_covered # 更新 states_needed ,移除已覆盖的州
	final_stations.add(best_station) # 将当前的 best_station 添加到最终的广播台列表中

print(final_stations)

NP完全问题

NP完全问题的简单定义是,以难解著称的问题,如旅行商问题和集合覆盖问题。

如何识别NP完全问题

  • 元素较少时算法的运行速度非常快,但随着元素数量的增加,速度会变得非常慢。
  • 涉及“所有组合”的问题通常是NP完全问题。
  • 不能将问题分成小问题,必须考虑各种可能的情况。这可能是NP完全问题。
  • 如果问题涉及序列(如旅行商问题中的城市序列)且难以解决,它可能就是NP完全问题。
  • 如果问题涉及集合(如广播台集合)且难以解决,它可能就是NP完全问题。
  • 如果问题可转换为集合覆盖问题或旅行商问题,那它肯定是NP完全问题。

小结

  • 贪婪算法寻找局部最优解,企图以这种方式获得全局最优解。
  • 对于NP完全问题,还没有找到快速解决方案。
  • 面临NP完全问题时,最佳的做法是使用近似算法。
  • 贪婪算法易于实现、运行速度快,是不错的近似算法。

动态规划

动态规划是通过把原来的大问题分解为相对简单的子问题,先解决子问题,然后再逐步解决大问题的方法。

动态规划常常适用于有重叠子问题最优子结构性质的问题。

基本思想

对于给定的问题,先找出它的子问题的解,再合并子问题的解以得出原问题的解。通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量: 一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。

分治与动态规划

共同点:二者都要求原问题具有最优子结构性质,都是将原问题分而治之,分解成若干个规模较小(小到很容易解决的程序)的子问题.然后将子问题的解合并,形成原问题的解.

不同点:

  • 分治法将分解后的子问题看成相互独立的,通过用递归来做。
  • 动态规划将分解后的子问题理解为相互间有联系,有重叠部分,需要记忆,通常用迭代来做。

问题特征

最优子结构:当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。

重叠子问题:在用递归算法自顶向下解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只解一次,而后将其解保存在一个表格中,在以后尽可能多地利用这些子问题的解。

印象笔记-漫画-什么是动态规划

小结

  • 动态规划可帮助你在给定约束条件下找到最优解。
  • 在问题可分解为彼此独立且离散的子问题时,就可使用动态规划来解决
  • 每种动态规划解决方案都涉及网格
  • 单元格中的值通常就是你要优化的值。
  • 每个单元格都是一个子问题,因此你应考虑如何将问题分成子问题,这有助于你找出网格的坐标轴。

k近邻算法

K最近邻(k-Nearest Neighbor,KNN)分类算法,是一个理论上比较成熟的方法,也是最简单的机器学习算法之一。该方法的思路是:如果一个样本在特征空间中的k个最相似(即特征空间中最邻近)的样本中的大多数属于某一个类别,则该样本也属于这个类别。

通过 KNN 可以创建一个推荐系统!

小结

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