一、算法简介
1.1 二分法
二分查找的工作原理。 我随便想一个1~100的数字。以最少的次数猜到这个数字。你每次猜测后,我会说小了、大了或对了。 假设你从1开始依次往上猜,猜测过程会是这样。 从 50 开始。 若小了,数字不在1~50范围内。接下来,你猜75。 若大了,数字不在1~50范围内。接下来,你猜25。 那余下的数字又排除了一半!使用二分查找时,你猜测的是中间的数字,从而每次都将余下的数字排除一半。 这就是二分查找。
![]()
一般而言,对于包含n个元素的列表,用二分查找最多需要log2n步,而简单查找最多需要n步。
def binary_search(list, item): low = 0 high = len(list)—1 while low <= high: mid = (low + high) guess = list[mid] if guess == item: return mid elif guess > item: high = mid - 1 else: low = mid + 1 return None my_list = [1, 3, 5, 7, 9]
1.2 二分法运行时间
运行时间:一般而言,应选择效率最高的算法,以最大限度地减少运行时间或占用空间。
◆ 简单查找逐个地检查数字,如果列表包含100个数字,最多需要猜100次。
即猜测的次数与列表长度相同,这被称为线性时间(linear time)。
◆ 二分查找则不同。如果列表包含100个元素,最多要猜7次;
1.3 大 O 表示法及运行时间
◆ 大O表示法是一种特殊的表示法,衡量算法的速度有多快。
◆ 大O表示法指出了算法有多快。
例如,假设列表包含n个元素。简单查找需要检查每个元素,因此需要执行n次操作。
这个运行时间为O(n)。大O表示法指的并非以秒为单位的速度。
大Ο表示法让你能够比较操作数,它指出了算法运行时间的增速。
二分查找需要执行
次操作。使用大
表示法,这个运行时间就是:
简单查找找需要执行
次操作。运行时间总是为
1.4 常见的大 O 运行时间
,也叫对数时间,这样的算法包括二分查找。
,也叫线性时间,这样的算法包括简单查找。
,合并排序。包括 快速排序——一种速度较快的排序算法。
,贪婪算法。包括 选择排序——一种速度较慢的排序算法。
,阶乘函数。解决旅行商问题的方案——一种非常慢的算法
示例:绘制一个包含16格的网格,假设你每秒可执行10次操作,以下5种不同的算法的运行时间:
二、选择排序
2.1 数组和链表
◆ 链表:
假设你与五位朋友去看一部很火的电影。你们六人想坐在一起,但看电影的人较多,没有六个在一起的座位。
链表说“我们分开来坐”,因此,只要有足够的内存空间,就能为链表分配内存。
优点:插入元素时,根本就不需要移动元素。
删除元素时,只需修改前一个元素指向的地址即可,后面的元素都向前移。
说明:删除元素总能成功。插入元素,如果内存中没有足够的空间,插入操作可能失败。
缺点:链表只能顺序访问:要读取链表的第十个元素,得先读取前九个元素,并沿链接找到第十个元素。
需要同时读取所有元素时,链表的效率很高
◆ 数组:
例如,显示十大电视反派时,整个排行榜分布在不同网页,而是先显示第十大反派(Newman)。
你必须在每个页面中单击Next,才能看到第一大反派(Gustavo Fring)。
优点:数组中,你知道每个元素的地址,可直接访问,读取速度很快
2.2 选择排序
假设你的计算机存储了很多乐曲。对于每个乐队,你都记录了其作品被播放的次数。
你要将这个列表按播放次数对你喜欢的乐队排序。
办法:遍历这个列表,找出作品播放次数最多的乐队,并将该乐队添加到一个新列表中。
要找出播放次数最多的乐队,必须检查列表中的每个元素,这需要的时间为O(n)。
因此对于这种时间为 的操作,你需要执行 次。需要的总时间为 ,即 。
将数组元素按从小到大的顺序排列。(不使用排序函数)
def findSmallest(arr):
smallest = arr[0] #最小值
smallest_index = 0 #最小值索引
for i in range(1, len(arr)):
if arr[i] < smallest:
smallest = arr[i]
smallest_index = i
return smallest_index
排序算法:
def selectionSort(arr): #对数组进行排序
newArr = []
for i in range(len(arr)):
smallest = findSmallest(arr) #找出最小值
newArr.append(arr.pop(smallest))
return newArr
print selectionSort([5, 3, 6, 2, 10])
三、递归
3.1 递归
盒子里找钥匙:这个盒子里有盒子,而盒子里的盒子又有盒子。钥匙就在某个盒子中。
为找到钥匙,你将使用什么算法?
算法思路 1:
(1) 创建一个要查找的盒子堆。
(2) 从盒子堆取出一个盒子,在里面找。
(3) 如果找到的是盒子,就将其加入盒子堆中,以便以后再查找。
(4) 如果找到钥匙,则大功告成!
(5) 回到第二步。
算法思路 2:
(1) 检查盒子中的每样东西。
(2) 如果是盒子,就回到第一步。
(3) 如果是钥匙,就大功告成!
如果使用循环,程序的性能可能更高;如果使用递归,程序可能更容易理解
3.2 基线条件和递归条件
由于递归函数调用自己,因此编写这样的函数时很容易出错,进而导致无限循环。
因此:编写递归函数时,必须告诉它何时停止递归。每个递归函数都有两部分:
基线条件(base case)和递归条件(recursive case)。
递归条件指的是函数调用自己,
基线条件则指的是函数不再调用自己,从而避免形成无限循环。
def countdown(i):
print i
if i <= 0: #基线条件
return
else: #递归条件
countdown(i-1)
countdown(-2)
3.3 栈
◆ 编程概念——调用栈(call stack)。
插入的待办事项放在清单的最前面;读取待办事项时,你只读取最上面的那个,并将其删除。
因此这个待办事项清单只有两种操作:压入(插入)和弹出(删除并读取)。
◆ 调用栈(调用函数)
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!")
greet('zhangsan')
输出:hello, zhangsan!
how are you, zhangsan?
getting ready to say bye...
ok bye!
3.3.2 递归调用栈
递归函数也使用调用栈!
递归函数factorial的调用栈。factorial(5)写作5!,其定义如下:5! = 5 * 4 * 3 * 2 * 1。
同理,factorial(3)为3 * 2 * 1。下面是计算阶乘的递归函数。
def fact(x):
if x == 1:
return 1
else:
return x * fact(x-1)
调用栈可能很长,这将占用大量的内存。
四、快速排序
4.1 分而治之
如何将一块地均匀地分成方块,并确保分出的方块是最大的呢?使用D&C策略!D&C算法是递归的。
使用D&C解决问题的过程包括两个步骤。
(1) 找出基线条件,这种条件必须尽可能简单。
(2) 不断将问题分解(或者说缩小规模),直到符合基线条件
D&C并非可用于解决问题的算法,而是一种解决问题的思路
你需要将这些数字相加,并返回结果
def sum(arr):
total = 0
for x in arr:
total += x
return total
print (sum([1, 2, 3, 4]))
4.2 快速排序
快速排序是一种常用的排序算法,比选择排序快得多。
步骤如下。
(1) 选择基准值(pivot)。
(2) 将数组分成两个子数组:小于基准值的元素和大于基准值的元素。
(3) 对这两个子数组进行快速排序,再合并结果,就能得到一个有序数组!
将任何元素用作基准值都可行,都是可以通过子数组再排序得到有序数组
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)
array = [10, 5, 2, 3] # 待排序数组
print (quicksort(array))
4.3 再谈大 O 表示法
快速排序的独特之处在于,其速度取决于选择的基准值。
最常见的大O运行时间。
合并排序(merge sort)的排序算法,其运行时间总是为O(n log n),比选择排序快得多!
快速排序的情况比较棘手,
在平均情况下,运行时间为O(n log n)。
在最糟情况下,运行时间为O(n2)。
4.3.1 比较合并排序和快速排序
定义函数遍历列表中的每个元素并将其打印出来。它迭代整个列表一次,因此运行时间为O(n)。
为方便观察,使其在打印每个元素前都休眠1秒钟。
from time import sleep
def print_items2(list1):
for item in list:
sleep(1)
print item
list1 = [2,4,6,8,10]
print_items2(list1)
4.3.2 平均情况和最糟情况
快速排序的性能高度依赖于你选择的基准值。
假设你总是将第一个元素用作基准值,且要处理的数组是有序的。由于快速排序算法不检查输入数组是否有序,
因此它依然尝试对其进行排序。
假设你总是将中间的元素用作基准值,调用栈短得多!
在这个示例中,层数为O(log n)(用技术术语说,调用栈的高度为O(log n)),而每层需要的时间为O(n)。
因此整个算法需要的时间为O(n) * O(log n) = O(n log n)。这就是最佳情况。
在最糟情况下,有O(n)层,因此该算法的运行时间为O(n) * O(n) = O(n2)。
五、散列表
5.1 散列函数(映射)
散列函数:一个元素映射到另一个唯一元素”。
◆ 散列函数总是将同样的输入-------映射------->相同的索引。
◆ 散列函数将不同的输入映射到不同的索引。散列函数来确定元素的存储位置。
5.2 散列表(hash table)
结合散列函数 和 数组创建的了一种数据结构。
散列表:也被称为散列映射、映射、字典和关联数组。获取元素的速度与数组一样快。
Python提供的散列表实现为字典,你可使用函数dict来创建散列表。
散列表与字典一样 是无序的,因此添加键—值对的顺序无关紧要。
散列表使用:
用于查找(与数组速度一样快)
防止重复(防止投票作弊)
用作缓存(缓存/记住数据,以免服务器再通过处理来生成它们)
5.3 冲突
例如:key1 ---> value1
key2 ---> value1
key3 ---> value1
不同的key值映射的值相同(它们的地址相同)
这里的经验教训有两个。
◆ 最理想的情况是,散列函数将键均匀地映射到散列表的不同位置。
◆ 如果散列表存储的链表很长,散列表的速度将急剧下降。如果使用的散列函数很好,
这些链表就不会很长!
5.4 性能
不同查找方法的操作时间:
在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速度与链表一样快,
因此它兼具两者的优点!但在最糟情况下,散列表的各种操作的速度都很慢。
因此,在使用散列表时,避开最糟情况至关重要。
避免冲突:
较低的填装因子;
良好的散列函数
填装因子
假设你要在散列表中存储100种商品的价格,而该散列表包含100个位置。那么在最佳情况下,
每个商品都将有自己的位置。这个散列表的填装因子为1。
如果这个散列表只有50个位置呢?填充因子将为2。不可能让每种商品都有自己的位置,
因为没有足够的位置!填装因子大于1意味着商品数量超过了数组的位置数。需要在散列表中添加位置,
调整长度(resizing)。------> 一旦填装因子超过0.7,就该调整散列表的长度。
调整散列表长度的工作需要很长时间!调整长度的开销很大,因此你不会希望频繁地这样做。但平均而言,
即便考虑到调整长度所需的时间,散列表操作所需的时间也为O(1)。
良好的散列函数
良好的散列函数让数组中的值呈均匀分布。
六、广度优先搜索
假设你居住在旧金山,要从双子峰前往金门大桥。你想乘公交车前往,并希望换乘最少。可乘坐的公交车如下。
解决最短路径问题的算法被称为广度优先搜索。
查找最短路径
例如,朋友是一度关系,朋友的朋友是二度关系。
队列
队列是一种先进先出(First In First Out,FIFO)的数据结构,
栈是一种后进先出(Last InFirst Out,LIFO)的数据结构。
实现图
散列表让你能够将键映射到值。
在这里,你要将节点映射到其所有邻居:
实现算法
找到一位芒果销售商
伪代码:
from collections import deque
def person_is_seller(name):
return name[-1] == 'm'
def search(name):
search_queue = 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)
return False
graph = ["alice", "bob","claire"] #邻居列表
search("you")
七、狄克斯特拉算法
从A点到B点是最快路径。
7.1 使用狄克斯特拉算法
狄克斯特拉算法
你知道:
前往节点B需要2分钟;
前往节点A需要5分钟;
前往终点需要6分钟。
狄克斯特拉算法中,你给每段都分配了一个数字或权重,因此狄克斯特拉算法找出的是总权重最小的路径。
狄克斯特拉算法包含4个步骤。
(1) 找出最便宜的节点,即可在最短时间内前往的节点。
(2) 对于该节点的邻居,检查是否有前往它们的更短路径,如果有,就更新其开销。
(3) 重复这个过程,直到对图中的每个节点都这样做了。
(4) 计算最终路径
狄克斯特拉算法只适用于有向无环图(directed acyclicgraph,DAG)。
将狄克斯特拉算法不能用于包含负权边的图。
在包含负权边的图中,要找出最短路径,可用——贝尔曼-福德算法(Bellman-Fordalgorithm)
#找出最低消耗的节点
#开销表
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
while node is not None:
cost = costs[node]
neighbors = graph[node] #graph?
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)
广度优先搜索用于在非加权图中查找最短路径。
狄克斯特拉算法用于在加权图中查找最短路径。
仅当权重为正时狄克斯特拉算法才管用。
如果图中包含负权边,请使用贝尔曼福德算法。
八、贪婪算法
8.1 教室调度问题
假设有如下课程表,你希望将尽可能多的课程安排在某间教室上。
8.2 背包问题
假设你是个贪婪的小偷,背着可装35磅(1磅≈0.45千克)重东西的背包.
在商场伺机盗窃各种可装入价值最高的商品
8.3 集合覆盖问题
假设你办了个广播节目,要让全美50个州的听众都收听得到。为此,你需要决定在哪些广播台播出。
在每个广播台播出都需要支付费用,因此你力图在尽可能少的广播台播出
(1) 列出每个可能的广播台集合,这被称为幂集(power set)。可能的子集有2^n个。
(2) 在这些集合中,选出覆盖全美50个州的最小集合。
由于可能的集合有2^n个,因此运行时间为O(2^n)。
近似算法(贪婪算法)
贪婪算法可化解危机!使用下面的贪婪算法可得到非常接近的解。
(1) 选出这样一个广播台,它覆盖了最多的未覆盖州。
(2) 重复第一步,直到覆盖了所有的州。
贪婪算法是不错的选择,它们不仅简单,而且通常运行速度很快。
在这个例子中,贪婪算法的运行时间为O(n^2),其中n为广播台数量。
1. 准备工作
#首先,创建一个列表,其中包含要覆盖的州。
states_needed = set(["mt", "wa", "or", "id", "nv", "ut","ca", "az"]) #转成集合去重
#广播台清单,使用散列表来表示它
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() #最终选择的电台
#你不断地循环,直到states_needed为空。这个循环的完整代码如下。
while states_needed:
best_station = None
states_covered = set()
# 遍历所有广播台,从中选择覆盖了最多的未覆盖州的广播台。将其存储在best_station中。
for station, states in stations.items():
covered = states_needed & states #取交集
# 检查该广播台覆盖的州是否比best_station多。
if len(covered) > len(states_covered):
best_station = station
states_covered = covered
# 更新states_needed。由于该广播台覆盖了一些州,因此不用再覆盖这些州。
states_needed -= states_covered
final_stations.add(best_station)
print (final_stations)
输出:
{'kone', 'kfive', 'ktwo', 'kthree'}
8.4 NP 完全问题
旅行商问题详解
这被称为阶乘函数(factorial function),5! = 120。
假设有10个城市,可能的路线有条 10! = 3 628 800 呢!
旅行商问题和集合覆盖问题有一些共同之处:你需要计算所有的解,并从中选出最小/最短的那个。
这两个问题都属于NP完全问题。
NP完全问题判别方法:
元素较少时算法的运行速度非常快,但随着元素数量的增加,速度会变得非常慢。
涉及“所有组合”的问题通常是NP完全问题。
不能将问题分成小问题,必须考虑各种可能的情况。这可能是NP完全问题。
如果问题涉及序列(如旅行商问题中的城市序列)且难以解决,它可能就是NP完全问题。
如果问题涉及集合(如广播台集合)且难以解决,它可能就是NP完全问题。
如果问题可转换为集合覆盖问题或旅行商问题,那它肯定是NP完全问题。
8.5 小结
贪婪算法寻找局部最优解,企图以这种方式获得全局最优解。
对于NP完全问题,还没有找到快速解决方案。
面临NP完全问题时,最佳的做法是使用近似算法。
贪婪算法易于实现、运行速度快,是不错的近似算法。
九、动态规划
9.1 背包问题
每增加一件商品,需要计算的集合数都将翻倍!这种算法的运行时间为 ,真的是慢如蜗牛。
动态规划
动态规划先解决子问题,再逐步解决大问题
十、其它算法
10.1 反向索引
简而言之:value----->key(通过值寻找键)
10.2 并行算法
并行性管理开销。
假设你要对一个包含1000个元素的数组进行排序,如果让每个内核对其中500个元素进行排序,
再将两个排好序的数组合并成一个有序数组,那么合并也是需要时间的
负载均衡。
假设你需要完成10个任务,因此你给每个内核都分配5个任务。但分配给内核A的任务都很容易,
10秒钟就完成了,而分配给内核B的任务都很难,1分钟才完成。这意味着有那么50秒,
内核B在忙死忙活,而内核A却闲得很!你如何均匀地分配工作,让两个内核都一样忙呢?
10.3 MapReduce
分布式算法。
例如:在并行算法只需两到四个内核时,完全可以在笔记本电脑上运行它,但如果需要数百个内核呢?
在这种情况下,可让算法在多台计算机上运行。MapReduce是一种流行的分布式算法,你可通过流行的开源工具Apache Hadoop来使用它。