说明
本文记录了笔者自学《图解算法》的过程,旨在帮助学习过的读者复习这本书的知识,或者帮助未学过的读者入门。整体内容按照书中的目录顺序展开,主要以分点和表格的形式记录概念,混淆点等,为之后刷算法题打基础。
算法简介
算法是一组完成任务的指令。 算法的速度指的并非时间,而是操作数的增速。(算法的速度表示随着输入的增加,其运行时间将以怎样的速度增加) 算法的运行时间用大O表示,大O表示法指出了最糟情况下的运行时间。 按照由快到慢的顺序列出常见的五种大O运行时间:
大O运行时间 描述 算法 O(n) 线性时间 简单查找 O(log n) 对数时间 二分查找 O(n*log n) 二次方时间 快速排序 O(n^2) 指数时间 选择排序 O(n!) 阶乘时间 旅行商问题
选择排序
将多项数据存储到内存时,有两种方式:数组和链表,数组中的数据在内存中地址相连,所以要求连续的内存空间,而链表的每个元素都存储了下一个元素的地址,可以使随机的内存地址串联。对比数组和链表操作的运行时间:
操作 数组 链表 读取 O(1),数组支持随机访问 O(n),链表只能顺序访问 插入 O(n),必须将后面的元素都向后移 O(1),只需修改前面元素指向的地址 删除 O(n),必须将所有后面的元素都向前移 O(1),只需修改前面元素指向的地址
选择排序算法:每次检查n个元素,需要的时间为O(n),这样的操作需要执行n次,才能得到一个有序列表,所以排序算法的运行时间是O(n^2)。示例:将数组元素按照从小到大的顺序排列:
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 ] ) )
递归
基线条件和递归条件:每个递归函数都有两部分组成,递归条件(recursive case)是指函数调用自己,基线条件(base case)指函数不再调用自己,从而避免陷入无限循环。 调用栈(call stack):一种简单的数据结构,只有两种操作:压入(插入)和弹出(删除并读取),栈可以看作是一个待办事项清单,插入的事项放在最上面,读取待办事项时,只读取最上面的事项并将其删除。 所有函数调用都进入调用栈。 如果递归函数进入无限循环,会占用大量内存,直至栈溢出而终止运行。
快速排序
分而治之(divide and conquer, D&C):一种广泛使用的递归策略,分为两步: 找出尽可能简单的基线条件;(编写数组相关的递归问题时,常常使用的基线条件是数组为空或只包含一个元素); 必须不断缩小问题规模,直到符合基线条件。 使用D&C策略的几个实例:
def sum ( lst) :
if lst == [ ] :
return 0
else :
return lst[ 0 ] + sum ( lst[ 1 : ] )
def count ( lst) :
if lst == [ ] :
return 0
else :
return 1 + count( lst[ 1 : ] )
def max ( lst) :
if len ( lst) == 2 :
return lst[ 0 ] if lst[ 0 ] > lst[ 1 ] else lst[ 1 ]
sub_max = max ( lst[ 1 : ] )
return lst[ 0 ] if lst[ 0 ] > sub_max else sub_max
二分查找的基线条件和递归条件:二分查找的基线条件是数组只包含一个元素,如果要查找的值与这个元素相同,就找到了,否则说明它不在数组中。在二分查找的递归条件中,把数组分成两半,一半丢弃,对另一半执行二分查找。 快速排序的基准条件是数组为空或只包含一个元素。快速排序用D&C策略,工作原理如下:首先选出一个基准值(pivot),然后将其他值与基准值比较分区(partitioning)得到小于基准值和大于基准值组成的两个无序子数组,最后对两个子数组递归地调用快速排序。
def quicksort ( array) :
if len ( array) < 2 :
return array
else :
pivot = array[ 0 ]
less = [ i for i in array[ 1 : ] <= pivot]
greater = [ i for i in array[ 1 : ] > pivot]
return quicksort( less) + [ pivot] + quicksort( greater)
快速排序的最糟运行时间和平均运行时间:快速排序的性能高度依赖于基准值的选择,如果总是将第一个元素作为基准值,且要处理的数组是有序的,这会导致调用栈很长,为O(n);在调用栈的每一层的操作时间都为O(n),所以最糟运行时间为O(n^2)。而如果每次都随机选择一个元素作为基准值,栈长就会变成O(logn),此时快速排序算法的最佳运行时间为O(nlogn),最佳情况也是平均情况。
6.快速排序和归并排序的异同: 归并排序和快速排序都是利用分治的思想,代码都通过递归来实现,过程非常相似。 归并排序非常稳定,时间复杂度始终都是O(nlogn),但不是原地排序;快速排序虽然最坏情况下时间复杂度为 O(n^2),但平均情况下时间复杂度为 O(nlogn),最糟情况发生的概率也比较小,而且是原地排序算法,因此应用得更加广泛。
散列表
散列表是一种功能强大的数据结构,散列表通过散列函数模拟映射关系,散列函数将不同的输入一致地映射到不同的索引,python提供的散列表就是字典 ; 散列表的查找、插入和删除非常快,可用于查找、缓存/记住数据、防止重复。 散列表的性能:良好的散列函数(能否将键均匀地映射到散列表的不同位置),填装因子较低(散列表包含的元素数/位置总数,一旦填装因子超过0.7,就该调整散列表的长度)。
散列表(平均情况) 散列表(最糟情况) 数组 链表 查找 O(1) O(n) O(1) O(n) 插入 O(1) O(n) O(n) O(1) 删除 O(1) O(n) O(n) O(1)
广度优先搜索
解决最短路径问题需要两步:第一步建立图模型,第二步利用广度优先搜索; 广度优先搜索是一种用于图的查找算法,回答两个问题:从A出发能否到B以及如果有,最短路径是什么。 图由节点(node)和边(edge)构成,直接相连的点互为邻居。 有向图中的边为箭头,箭头方向指定了关系的方向;无向图的边不带箭头,其中关系是双向的。 队列(queue)是一种先进先出的数据结构,类似于栈(栈是后进先出的数据结构),不支持随机访问队列中的元素,只支持两种操作:入队和出队。 需要按照顺序检查列表中的元素,否则找到的就不是最短路径,因此搜素列表必须是队列,而且对于检查过的人务必不要再去检查,否则进入无限循环。 广度优先搜索的运行时间为O(V+E),V为节点数,E为边数。
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" )
迪克斯特拉算法
在迪科斯特拉算法中,每条边都分配了权重,因此该算法找到的是总权重最小的路径。 迪科斯特拉算法包括四个步骤: (1) 找出最便宜的节点,即可在最短时间内前往的节点; (2)对于该节点的邻居,检查是否有前往它们的更短路径,如果有,就更新其开销; (3)重复这个过程,直到对每个节点都这样做; (4)计算最终路径。 迪科斯特拉算法只适用于有向(无环)图。 不能将迪科斯特拉算法用于包含负权边的图,因为迪科斯特拉算法假设:对于处理过的节点,没有前往该节点的最短路径,这种假设仅在没有负权边时才成立。 代码实现迪科斯特拉算法:其中,节点的开销指的是从起点出发到该节点需要多久,
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 = [ ]
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)
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
贪婪算法
贪婪算法是一种近似算法,广度优先搜索和迪科斯特拉算法都是贪婪算法。 贪婪算法寻找局部最优解,企图用这种方式获得全局最优解。 对于NP完全问题,还没有快速解决方案,最佳的做法就是使用近似算法。 判断是不是NP完全问题的方法: (1)元素较少时算法运行非常快,但随着元素数量增加,算法速度会变得非常慢; (2)涉及“所有组合”的问题通常是NP完全问题; (3)不能将问题分成小问题,必须考虑各种可能的情况,可能是NP完全问题; (4)如果问题涉及序列(如旅行商问题中的城市序列)且难以解决,可能是NP完全问题; (5)如果问题涉及集合(如广播台集合)且难以解决,可能是NP完全问题; (6)如果问题可以转换为集合覆盖问题或者旅行商问题,那肯定是NP完全问题。
动态规划
需要在给定约束条件下优化某种指标时,动态规划很有用,每种动态规划的解决方案都涉及网格;网格中的值就是要优化的值,每个格子都是一个子问题。 需要注意的是,以背包问题为例:各行的排列顺序(放东西的先后顺序)无关紧要;增加更小的商品需要调整网格的粒度;动态规划没法判断是否拿走商品的一部分,但贪婪算法可以解决;当且仅当每个子问题都是离散的时,动态规划才有用。
K最近邻算法
要使用K最近邻算法,一定要了解余弦相似度。 在创建推荐系统时,如果有N个用户,应该考虑sqrt(N)个邻居。
接下来如何做
本书未介绍的十种算法:
二叉查找树:对于其中的每个节点,左子节点的值都比它小,右子节点的值都比它大,但二叉查找树不支持随机访问。
二叉查找树 数组 查找 O(logn) O(logn) 插入 O(logn) O(n) 删除 O(logn) O(n)
反向索引:搜索引擎的工作原理 傅立叶变换 并行算法 分布式算法 布隆过滤器和HyperLogLog SHA算法:安全散列算法,SHA根据字符串生成另一个字符串,可以用来判断两个文件是否相同,也可以用来检查密码(单向,可以根据字符串计算出散列值,但无法根据散列值推断出原始字符串) 局部敏感的散列算法:Simhash算法,可以通过比较散列值来判断两个字符串的相似程度 Diffie-Hellman密钥交换:一种加密算法 线性规划:所有的图算法都可以用线性规划实现,线性规划使用Simplex算法。