《算法图解》笔记

自己总结的一些相关博客

使用Python实现常见的数据结构之原理讲解

使用Python实现常见的数据结构

Python实现基本的排序算法

Python实现高级的排序算法

使用Python实现一个简单的LRUCache

利用队列Queue实现一个多并发“线程池”效果的Socket程序

堆栈相关的几个练习——堆栈效率高于递归

算法数据结构等(旧归结)

第1章 算法简介

二分查找

二分查找是一种算法,其输入是一个有序的元素列表(必须有序的原因稍后解释)。如果要查找的元素包含在列表中,二分查找返回其位置;否则返回None。

一般而言,对于包含n个元素的列表,用二分查找最多需要log2n步,而简单查找最多需要n步。

对数

你可能不记得什么是对数了,但很可能记得什么是幂。log10100相当于问“将多少个10相乘的结果为100”。答案是两个:10 × 10=100。因此,log10100=2。对数运算是幂运算的逆运算。

49a71cbf2292aaad4944afb22a6865c4.png

对数的大O表示法

本书使用大O表示法(稍后介绍)讨论运行时间时,log指的都是log2。使用简单查找法查找元素时,在最糟情况下需要查看每个元素。因此,如果列表包含8个数字,你最多需要检查8个数字。而使用二分查找时,最多需要检查log n个元素。如果列表包含8个元素,你最多需要检查3个元素,因为log 8=3(23= 8)。如果列表包含1024个元素,你最多需要检查10个元素,因为log1024=10(210=1024)。

代码实现

def binary_search(lst:list,item:int):
	low = 0
	high = len(lst) - 1
    # 循环条件有等于 如果只有1个元素并且又是恰好要找的数的情况 返回的下标正好是0
	while low <= high: 
		mid = (low + high) // 2
		guess = lst[mid]
		if guess == item:
			return mid
		elif guess < item:
			low = mid + 1
		elif guess > item:
			high = mid - 1
	return None

lst1 = [1,2,3,4,6,7,8,11,123,222]
print(binary_search(lst1,8)) # 6

大O表示法

大O表示法是一种特殊的表示法,指出了算法的速度有多快。谁在乎呢?实际上,你经常要使用别人编写的算法,在这种情况下,知道这些算法的速度大有裨益。本节将介绍大O表示法是什么,并使用它列出一些最常见的算法运行时间。

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

再来看一个例子。为检查长度为n的列表,二分查找需要执行log n次操作。使用大O表示法,这个运行时间怎么表示呢?O(log n)。一般而言,大O表示法像下面这样。

72b77b4801d8303d6ec0c7fe788fbd59.png

这指出了算法需要执行的操作数。之所以称为大O表示法,是因为操作数前有个大O。这听起来像笑话,但事实如此!

画网格实例

d9610f0d74c5dac857fcac1652bddd68.png

算法1

一种方法是以每次画一个的方式画16个格子。记住,大O表示法计算的是操作数。在这个示例中,画一个格子是一次操作,需要画16个格子。如果每次画一个格子,需要执行多少次操作呢 —— 画16个格子需要16步。

算法2

折纸操作

fc171bd1bdbacf63d10a983a2cff7077.png

折4次后再打开,便得到了漂亮的网格!每折一次,格子数就翻倍,折4次就能得到16个格子!

b8bcf51c2bea5e1f90fc2403dd62035d.png

你每折一次,绘制出的格子数都翻倍,因此4步就能“绘制”出16个格子。这种算法的运行时间是多少呢?请搞清楚这两种算法的运行时间之后,再接着往下读。

—— 算法1的运行时间为O(n),算法2的运行时间为O(log n)。

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

假设你使用简单查找在电话簿中找人。你知道,简单查找的运行时间为O(n),这意味着在最糟情况下,必须查看电话簿中的每个条目。如果要查找的是Adit——电话簿中的第一个人,一次就能找到,无需查看每个条目。考虑到一次就找到了Adit,请问这种算法的运行时间是O(n)还是O(1)呢?

简单查找的运行时间总是为O(n)。查找Adit时,一次就找到了,这是最佳的情形,但大O表示法说的是最糟的情形。因此,你可以说,在最糟情况下,必须查看电话簿中的每个条目,对应的运行时间为O(n)这是一个保证——你知道简单查找的运行时间不可能超过O(n)。

最糟情况与平均情况

除最糟情况下的运行时间外,还应考虑平均情况的运行时间,这很重要。最糟情况和平均情况将在第4章讨论。

一些常见的大O运行时间

下面按从快到慢的顺序列出了你经常会遇到的5种大O运行时间(还有其他的运行时间,但这5种是最常见的)。

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

假设你要绘制一个包含16格的网格,且有5种不同的算法可供选择,这些算法的运行时间如上所示。如果你选择第一种算法,绘制该网格所需的操作数将为4(log16=4)。假设你每秒可执行10次操作,那么绘制该网格需要0.4秒。如果要绘制一个包含1024格的网格呢?这需要执行10(log 1024=10)次操作,换言之,绘制这样的网格需要1秒。这是使用第一种算法的情况。

第二种算法更慢,其运行时间为O(n)。即要绘制16个格子,需要执行16次操作;要绘制1024个格子,需要执行1024次操作。执行这些操作需要多少秒呢?

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

c70b823c2cce007c31736dd0639419a9.png

大O表示法需要注意的问题

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

第1章小结

  • 二分查找的速度比简单查找快得多。
  • O(log n)比O(n)快。需要搜索的元素越多,前者比后者就快得越多。
  • 算法运行时间并不以秒为单位。
  • 算法运行时间是从其增速的角度度量的。
  • 算法运行时间用大O表示法表示。

第2章 选择排序

本章内容

  • 学习两种最基本的数据结构——数组和链表,它们无处不在。第1章使用了数组,其他各章几乎也都将用到数组。数组是个重要的主题,一定要高度重视!但在有些情况下,使用链表比使用数组更合适。本章阐述数组和链表的优缺点,让你能够根据要实现的算法选择合适的一个。
  • 学习第一种排序算法。很多算法仅在数据经过排序后才管用。还记得二分查找吗?它只能用于有序元素列表。本章将介绍选择排序。很多语言都内置了排序算法,因此你基本上不用从头开始编写自己的版本。但选择排序是下一章将介绍的快速排序的基石。快速排序是一种重要的算法,如果你熟悉其他排序算法,理解起来将更容易。

内存的工作原理

计算机就像是很多抽屉的集合体,每个抽屉都有地址。

9820992a3591ee4bf6b28581ff8629f6.png

需要将数据存储到内存时,你请求计算机提供存储空间,计算机给你一个存储地址。需要存储多项数据时,有两种基本方式——数组和链表。但它们并非都适用于所有的情形,因此知道它们的差别很重要。接下来介绍数组和链表以及它们的优缺点。

数组与链表

待办事项例子

要编写一个管理待办事项的应用程序,为此需要将这些待办事项存储在内存中。应使用数组还是链表呢?鉴于数组更容易掌握,我们先将待办事项存储在数组中。使用数组意味着所有待办事项在内存中都是相连的(紧靠在一起的)

现在假设你要添加第四个待办事项,但后面的那个抽屉放着别人的东西!

79cc34f1318a4af5c7985cd9c57842e2.png

这就像你与朋友去看电影,找到地方就坐后又来了一位朋友,但原来坐的地方没有空位置,只得再找一个可坐下所有人的地方。在这种情况下,你需要请求计算机重新分配一块可容纳4个待办事项的内存,再将所有待办事项都移到那里。

如果又来了一位朋友,而当前坐的地方也没有空位,你们就得再次转移!真是太麻烦了。同样,在数组中添加新元素也可能很麻烦。如果没有了空间,就得移到内存的其他地方,因此添加新元素的速度会很慢。

一种解决之道是“预留座位”:即便当前只有3个待办事项,也请计算机提供10个位置,以防需要添加待办事项。这样,只要待办事项不超过10个,就无需转移。这是一个不错的权变措施,但你应该明白,它存在如下两个缺点:

1. 你额外请求的位置可能根本用不上,这将浪费内存。你没有使用,别人也用不了。
2. 额外请求的空间太少了,你还得转移!

使用链表解决待办事项的问题

链表中的元素可存储在内存的任何地方。

链表的每个元素都存储了下一个元素的地址,从而使一系列随机的内存地址串在一起。

8b44423bc983ef29fcb7ede6f5164013.png

这犹如寻宝游戏。你前往第一个地址,那里有一张纸条写着“下一个元素的地址为123”。因此,你前往地址123,那里又有一张纸条,写着“下一个元素的地址为847”,以此类推。在链表中添加元素很容易:只需将其放入内存,并将其地址存储到前一个元素中。

使用链表时,根本就不需要移动元素。这还可避免另一个问题。假设你与五位朋友去看一部很火的电影。你们六人想坐在一起,但看电影的人较多,没有六个在一起的座位。使用数组时有时就会遇到这样的情况。假设你要为数组分配10000个位置,内存中有10000个位置,但不都靠在一起。在这种情况下,你将无法为该数组分配内存!链表相当于说“我们分开来坐”,因此,只要有足够的内存空间,就能为链表分配内存。

链表VS数组的优劣比较

链表的优势在插入元素方面,数组的优势在于随机读取元素。

链表在需要读取链表的最后一个元素时,你不能直接读取,因为你不知道它所处的地址,必须先访问元素#1,从中获取元素#2的地址,再访问元素#2并从中获取元素#3的地址,以此类推,直到访问最后一个元素。需要同时读取所有元素时,链表的效率很高:你读取第一个元素,根据其中的地址再读取第二个元素,以此类推但如果你需要跳跃,链表的效率真的很低

数组与此不同:你知道其中每个元素的地址。例如,假设有一个数组,它包含五个元素,起始地址为00,那么元素#5的地址是多少呢?只需执行简单的数学运算就知道:04。

需要随机地读取元素时,数组的效率很高,因为可迅速找到数组的任何元素。

在链表中,元素并非靠在一起的,你无法迅速计算出第五个元素的内存地址,而必须先访问第一个元素以获取第二个元素的地址,再访问第二个元素以获取第三个元素的地址,以此类推,直到访问第五个元素。

操作复杂度比较

5aa0c082a647ccf2ca25c5c6b14df431.png

选择排序

将数组元素按从小到大的顺序排列。时间复杂度为O(n log n)。

# 找到最小的元素
def find_smallest(lst:list)->int:
	# 存最小的值
	smallest = lst[0]
	# 存最小值元素的索引
	smallest_index = 0
	for i in range(1,len(lst)):
		if lst[i] < smallest:
			smallest = lst[i]
			smallest_index = i
	# 返回最小元素的索引
	return smallest_index

# 主函数
def selection_sort(lst:list)->list:
	new_lst = list()
	for i in range(len(lst)):
		smallest_index = find_smallest(lst)
		# pop(方法接收的是下标)
		new_lst.append(lst.pop(smallest_index))
	return new_lst


lst = [11,2,33,45,6,7]
lis = selection_sort(lst)
print(lis) # [2, 6, 7, 11, 33, 45]

第2章小结

  • 计算机内存犹如一大堆抽屉。
  • 需要存储多个元素时,可使用数组或链表。
  • 数组的元素都在一起。
  • 链表的元素是分开的,其中每个元素都存储了下一个元素的地址。
  • 数组的读取速度很快。
  • 链表的插入和删除速度很快。
  • 在同一个数组中,所有元素的类型都必须相同(都为int、double等)。

第3章 递归

学习如何将问题分成基线条件和递归条件。第4章将介绍的分而治之策略使用这种简单的概念来解决棘手的问题。

盒子套盒子找钥匙问题

94d893f2265459a0b8faf69eee23ab1b.png

基线条件与递归条件

由于递归函数调用自己,因此编写这样的函数时很容易出错,进而导致无限循环。

编写递归函数时,必须告诉它何时停止递归。正因为如此,每个递归函数都有两部分:基线条件(base case)递归条件(recursive case)

递归条件指的是函数调用自己,而基线条件则指的是函数什么条件下不再调用自己,从而避免形成无限循环

6f6396ba5a8b6d194f6f402515951fdc.png

本节将介绍一个重要的编程概念——调用栈(call stack)。调用栈不仅对编程来说很重要,使用递归时也必须理解这个概念。

便条清单:插入的待办事项放在清单的最前面;读取待办事项时,你只读取最上面的那个,并将其删除。因此这个待办事项清单只有两种操作:压入(插入)和弹出(删除并读取)。

6baa20daeeacaf12cb84bff8aab1fb05.png

调用栈

(用一个例子详细讲解了计算机如何为函数分配与调用内存空间的。。。)

递归调用栈

计算阶乘的函数:

def fact(x:int)->int:
	if x <= 1:
		return 1
	else:
		return x * (x-1)

print(fact(7)) # 42
print(fact(3)) # 6

(书中详细介绍了递归调用栈的内存分配与调用的过程。。。。。。。)

第3章小结

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

第4章快速排序

快速排序(一种重要的D&C算法)是分而治之的策略。

分而治之

假设你是农场主,有一小块土地。你要将这块地均匀地分成方块,且分出的方块要尽可能大。显然,下面的分法都不符合要求。

48f57c3455390ee25d6686b8d26d5516.png

如何将一块地均匀地分成方块,并确保分出的方块是最大的呢?使用D&C策略!D&C算法是递归的。使用D&C解决问题的过程包括两个步骤。

(1) 找出基线条件,这种条件必须尽可能简单。
(2) 不断将问题分解(或者说缩小规模),直到符合基线条件。

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

实例

D&C并非可用于解决问题的算法,而是一种解决问题的思路。我们再来看一个例子。

给定一个数字数组:lst = [2,4,6],将这些数字相加并返回结果,使用循环很容易完成:

def sum(lst:list)->int:
    total = 0
    for i in lst:
        total += i
    return total

但如何使用递归函数来完成这种任务呢?

第一步:找出基线条件。最简单的数组什么样呢?请想想这个问题,再接着往下读。如果数组不包含任何元素或只包含一个元素,计算总和将非常容易。

56483dc9ae61ce5d1056b42a2202e95e.png

第二步:每次递归调用都必须离空数组更近一步。如何缩小问题的规模呢?下面是一种办法。

a387cbf3477bb0d0526f1674d3344fd9.png

这两个版本的结果都为12,但在第二个版本中,给函数sum传递的数组更短。换言之,这缩小了问题的规模!

4a6bd00976ac741747144fba3257f7be.png

这个函数的运行过程如下(注意,递归记录了状态)。

7c766dfa804b5eef68f2539f2d8ee708.png

def sum(lst:list)->int:
	if len(lst) == 0:
		return 0
	return lst[0] + sum(lst[1:])


lis = [1,2,3,4]
print(sum(lis)) # 10

编写涉及数组的递归函数时,基线条件通常是数组为空或只包含一个元素。陷入困境时,请检查基线条件是不是这样的。

快速排序

快速排序是一种常用的排序算法,比选择排序快得多。例如,C语言标准库中的函数qsort实现的就是快速排序。快速排序也使用了D&C。

下面来使用快速排序对数组进行排序。对排序算法来说,最简单的数组什么样呢?还记得前一节的“提示”吗?就是根本不需要排序的数组。

def quick_sort(lst:list)->list:
    if len(lst) < 2:
        return lst

因此,基线条件为数组为空或只包含一个元素。在这种情况下,只需原样返回数组——根本就不用排序。

我们来看看更长的数组。对包含两个元素的数组进行排序也很容易。

bc6bdecf07064640c314be2efb03d86e.png

包含三个元素的数组呢?别忘了,你要使用D&C,因此需要将数组分解,直到满足基线条件。下面介绍快速排序的工作原理:

(1) 从数组中选择一个元素,这个元素被称为基准值(pivot)。(稍后再介绍如何选择合适的基准值。我们暂时将数组的第一个元素用作基准值。)

(2) 接下来,找出比基准值小的元素以及比基准值大的元素,将他们分别放在对应的数组中。

41f54eb963a102fb892b768958505587.png

这被称为分区(partitioning)。现在你有:

  • 一个由所有小于基准值的数字组成的子数组;
  • 基准值;
  • 一个由所有大于基准值的数组组成的子数组。

这里只是进行了分区,得到的两个子数组是无序的。但如果这两个数组是有序的,对整个数组进行排序将非常容易。

20a522651c8c61048fca300432b17b1e.png

如何对子数组进行排序呢?对于包含两个元素的数组(左边的子数组)以及空数组(右边的子数组),快速排序知道如何将它们排序,因此只要对这两个子数组进行快速排序,再合并结果,就能得到一个有序数组!

8c12f357fcb4759b77ff64e3247e31bf.png

不管将哪个元素用作基准值,这都管用。假设你将15用作基准值。

071d511e42171e8927b073346194588b.png

这个子数组都只有一个元素,而你知道如何对这些数组进行排序。现在你就知道如何对包含三个元素的数组进行排序了,步骤如下:

(1) 选择基准值。
(2) 将数组分成两个子数组:小于基准值的元素和大于基准值的元素。
(3) 对这两个子数组进行快速排序。

基准值的选取

假设有下面这样一个包含五个元素的数组。

lst = [3,4,2,1,4]

根据选择的基准值,对这个数组进行分区的各种可能方式如下:

2821269849e0a2fd9370a78b8c235f8d.png

不管如何选择基准值,你都可对划分得到的两个子数组递归地进行快速排序。

将任何元素用作基准值都可行,因此你能够对包含五个元素的数组进行排序。同理,你能够对包含六个元素的数组进行排序,以此类推。

快速排序的代码实现

def quick_sort(lst:list)->list:
	# 基线条件,为空或只包含一个元素的数组是“有序”的
	if len(lst) < 2:
		return lst
	else:
		# 递归条件
		pivot = lst[0]
		# 由所有小于等于基准值的元素组成的数组
		less = [i for i in lst[1:] if i <= pivot]
		# 由所有大于等于基准值的元素组成的数组
		greater = [i for i in lst[1:] if i > pivot]
		# 返回拼接的结果
		return quick_sort(less) + [pivot] + quick_sort(greater)

ret = quick_sort([12,2,33,14])
print(ret) # [2, 12, 14, 33]

再谈大O表示法

最常见的大O运行时间:

ee5940361ca74b172b15b0f522f14a6f.png

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

对于每种运行时间,本书还列出了相关的算法。来看看第2章介绍的选择排序,其运行时间为O(n2),速度非常慢。

还有一种名为合并排序(merge sort)的排序算法,其运行时间为O(n log n),比选择排序快得多!快速排序的情况比较棘手,在最糟情况下,其运行时间为O(n2)。

与选择排序一样慢!但这是最糟情况。在平均情况下,快速排序的运行时间为O(nlog n)。你可能会有如下疑问。

1、这里说的最糟情况和平均情况是什么意思呢?
2、若快速排序在平均情况下的运行时间为O(n log n),而合并排序的运行时间总是O(n log n),为何不使用合并排序?它不是更快吗?

平均情况与最糟情况

快速排序的性能高度依赖于你选择的基准值。

假设你总是将第一个元素用作基准值,且要处理的数组是有序的。由于快速排序算法不检查输入数组是否有序,因此它依然尝试对其进行排序。

9528fe3572cb6dfc61c7a3f66a212ac0.png

注意,数组并没有被分成两半,相反,其中一个子数组始终为空,这导致调用栈非常长。现在假设你总是将中间的元素用作基准值,在这种情况下,调用栈如下

44a0f8ff49f00aff104032ac9367cb2c.png

调用栈短得多!因为你每次都将数组分成两半,所以不需要那么多递归调用。你很快就到达了基线条件,因此调用栈短得多。

第一个示例展示的是最糟情况,而第二个示例展示的是最佳情况。在最糟情况下,栈长为O(n),而在最佳情况下,栈长为O(logn)。

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

第4章小结

  • D&C将问题逐步分解。使用D&C处理列表时,基线条件很可能是空数组或只包含一个元素的数组。
  • 实现快速排序时,请随机地选择用作基准值的元素。快速排序的平均运行时间为O(n log n)。
  • 大O表示法中的常量有时候事关重大,这就是快速排序比合并排序快的原因所在。
  • 比较简单查找和二分查找时,常量几乎无关紧要,因为列表很长时,O(logn)的速度比O(n)快得多。

第5章 散列表

  • 学习散列表——最有用的基本数据结构之一。散列表用途广泛,本章将介绍其常见的用途。
  • 学习散列表的内部机制:实现、冲突和散列函数。这将帮助你理解如何分析散列表的性能。

散列函数

散列函数是这样的函数,即无论你给它什么数据,它都还你一个数字。

如果用专业术语来表达的话,我们会说,散列函数“将输入映射到数字”。你可能认为散列函数输出的数字没什么规律,但其实散列函数必须满足一些要求:

  1. 它必须是一致的。例如,假设你输入apple时得到的是4,那么每次输入apple时,得到的都必须为4。如果不是这样,散列表将毫无用处。
  2. 它应将不同的输入映射到不同的数字。例如,如果一个散列函数不管输入是什么都返回1,它就不是好的散列函数。最理想的情况是,将不同的输入映射到不同的数字。
dic = {
        "apple":1,
        "pear":1.5,
        "banana":0.5,
    }

散列函数准确地指出了价格的存储位置,你根本不用查找!之所以能够这样,具体原因如下:

  1. 散列函数总是将同样的输入映射到相同的索引。每次你输入avocado,得到的都是同一个数字。因此,你可首先使用它来确定将鳄梨的价格存储在什么地方,并在以后使用它来确定鳄梨的价格存储在什么地方。
  2. 散列函数将不同的输入映射到不同的索引。avocado映射到索引4, milk映射到索引0。每种商品都映射到数组的不同位置,让你能够将其价格存储到这里。
  3. 散列函数知道数组有多大,只返回有效的索引。如果数组包含5个元素,散列函数就不会返回无效索引100。

散列表的意义

在你将学习的复杂数据结构中,散列表可能是最有用的,也被称为散列映射映射字典关联数组

散列表的速度很快!还记得第2章关于数组和链表的讨论吗?你可以立即获取数组中的元素,而散列表也使用数组来存储数据,因此其获取元素的速度与数组一样快。

Python提供的三列表为字典。

dic = dict()

散列表的使用

1. 模拟映射关系
2. 防止重复
3. 缓存/记住数据,以免服务器再通过处理来生成它们。

用于查找

创建一个电话薄:

  • 添加联系人及其电话号码。
  • 通过输入联系人来获悉其电话号码。

用散列表实现:

  1. 创建映射
  2. 查找

防止重复

可以利用python字典中的key不能重复的特性。

将散列表用于缓存

CACHE = dict()

def get_page(url):
    if CACHE.get(url):
        return CACNE[url]
    else:
        data = get_data_from_server(url)
        CACHE[url] = data
        return data

还有我自己的例子:
使用python实现简单的LRUcache

散列表的冲突

冲突(collision): 给两个键分配的位置相同。

这是个问题。如果你将鳄梨的价格存储到这个位置,将覆盖苹果的价格,以后再查询苹果的价格时,得到的将是鳄梨的价格!冲突很糟糕,必须要避免。处理冲突的方式很多,最简单的办法如下:如果两个键映射到了同一个位置,就在这个位置存储一个链表:

425cdb38adb6020a432b1d618da45739.png

在这个例子中,apple和avocado映射到了同一个位置,因此在这个位置存储一个链表。在需要查询香蕉的价格时,速度依然很快。但在需要查询苹果的价格时,速度要慢些:你必须在相应的链表中找到apple。如果这个链表很短,也没什么大不了——只需搜索三四个元素。但是,假设你工作的杂货店只销售名称以字母A打头的商品。

5187b3995a20b07fe00d9c76611d8ab8.png

等等!除第一个位置外,整个散列表都是空的,而第一个位置包含一个很长的列表!换言之,这个散列表中的所有元素都在这个链表中,这与一开始就将所有元素存储到一个链表中一样糟糕:散列表的速度会很慢。

这里的经验教训有两个:

  • 散列函数很重要。前面的散列函数将所有的键都映射到一个位置,而最理想的情况是,散列函数将键均匀地映射到散列表的不同位置。
  • 如果散列表存储的链表很长,散列表的速度将急剧下降。然而,如果使用的散列函数很好,这些链表就不会很长!

散列函数很重要,好的散列函数很少导致冲突。那么,如何选择好的散列函数呢?这将在下一节介绍!

散列的性能

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

00f2dc448076fab6c96db2a6969e47c9.png

我们来将散列表同数组和链表比较一下:

5d6d827ddc77177d77f2535649614031.png

因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。而要避免冲突需要有:

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

(下面讲的是散列的底层实现 —— 暂时略过)

第5章小结

你几乎根本不用自己去实现散列表,因为你使用的编程语言提供了散列表实现。你可使用Python提供的散列表,并假定能够获得平均情况下的性能:常量时间。

散列表是一种功能强大的数据结构,其操作速度快,还能让你以不同的方式建立数据模型。你可能很快会发现自己经常在使用它。

  • 你可以结合散列函数和数组来创建散列表。
  • 冲突很糟糕,你应使用可以最大限度减少冲突的散列函数。
  • 散列表的查找、插入和删除速度都非常快。
  • 散列表适合用于模拟映射关系。
  • 一旦填装因子超过0.7,就该调整散列表的长度。
  • 散列表可用于缓存数据(例如,在Web服务器上)。
  • 散列表非常适合用于防止重复。

第6章广度优先搜索

  • 学习使用新的数据结构来建立网络模型。
  • 学习广度优先搜索,你可对图使用这种算法回答诸如“到X的最短路径是什么”等问题。
  • 学习有向图和无向图。
  • 学习拓扑排序,这种排序算法指出了节点之间的依赖关系。

本章将介绍图。首先,我将说说什么是图(它们不涉及X轴和Y轴),再介绍第一种图算法——广度优先搜索(breadth-first search, BFS)。

广度优先搜索让你能够找出两样东西之间的最短距离,不过最短距离的含义有很多!使用广度优先搜索可以:

  • 编写国际跳棋AI,计算最少走多少步就可获胜;
  • 编写拼写检查器,计算最少编辑多少个地方就可将错拼的单词改成正确的单词,如将READED改为READER需要编辑一个地方;
  • 根据你的人际关系网络找到关系最近的医生。

在我所知道的算法中,图算法应该是最有用的。请务必仔细阅读接下来的几章,这些算法你将经常用到。

图介绍

5055e43c1b51f6bb05aa4aca0fda471a.png

还有其他前往金门大桥的路线,但它们更远(需要四步)。这个算法发现,前往金门大桥的最短路径需要三步。这种问题被称为最短路径问题(shorterst-pathproblem)。你经常要找出最短路径,这可能是前往朋友家的最短路径,也可能是国际象棋中把对方将死的最少步数。

解决最短路径问题的算法被称为广度优先搜索。

766b606900862ac811b585a4d3bb3978.png

160b4fc60fd9014f27679e29b9e719c9.png

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

在前面的欠钱图中,Rama是Alex的邻居。Adit不是Alex的邻居,因为他们不直接相连。但Adit既是Rama的邻居,又是Tom的邻居。

广度优先搜索

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

  • 第一类问题:从节点A出发,有前往节点B的路径吗?
  • 第二类问题:从节点A出发,前往节点B的哪条路径最短?

有路径吗

假设你经营着一个芒果农场,需要寻找芒果销售商,以便将芒果卖给他。在Facebook,你与芒果销售商有联系吗?为此,你可在朋友中查找。

首先,创建一个朋友名单;

然后,依次检查名单中的每个人,看看他是否是芒果销售商。

da9a439e324104f62a6b4997526a7bb9.png

假设你没有朋友是芒果销售商,那么你就必须在朋友的朋友中查找。

检查名单中的每个人时,你都将其朋友加入名单。

这样一来,你不仅在朋友中查找,还在朋友的朋友中查找。别忘了,你的目标是在你的人际关系网中找到一位芒果销售商。因此,如果Alice不是芒果销售商,就将其朋友也加入到名单中。这意味着你将在她的朋友、朋友的朋友等中查找。使用这种算法将搜遍你的整个人际关系网,直到找到芒果销售商。这就是广度优先搜索算法。

查找最短路径

再说一次,广度优先搜索可回答两类问题。

  • 第一类问题:从节点A出发,有前往节点B的路径吗?(在你的人际关系网中,有芒果销售商吗?)
  • 第二类问题:从节点A出发,前往节点B的哪条路径最短?(哪个芒果销售商与你的关系最近?)

还拿上面的例子:

例如,朋友是一度关系,朋友的朋友是二度关系。在你看来,一度关系胜过二度关系,二度关系胜过三度关系,以此类推。

因此,你应先在一度关系中搜索,确定其中没有芒果销售商后,才在二度关系中搜索。广度优先搜索就是这样做的!在广度优先搜索的执行过程中,搜索范围从起点开始逐渐向外延伸,即先检查一度关系,再检查二度关系。顺便问一句:将先检查Claire还是Anuj呢?Claire是一度关系,而Anuj是二度关系,因此将先检查Claire,后检查Anuj。

广度优先搜索不仅查找从A到B的路径,而且找到的是最短的路径。

注意,只有按添加顺序查找时,才能实现这样的目的。换句话说,如果Claire先于Anuj加入名单,就需要先检查Claire,再检查Anuj。如果Claire和Anuj都是芒果销售商,而你先检查Anuj再检查Claire,结果将如何呢?找到的芒果销售商并非是与你关系最近的,因为Anuj是你朋友的朋友,而Claire是你的朋友。因此,你需要按添加顺序进行检查。有一个可实现这种目的的数据结构,那就是队列(queue)。

使用散列实现图

cf4747c6788a46701915ee9b4e819fd9.png

c100976919945203ad87f7ad69a70804.png

顺便问一句:键—值对的添加顺序并不重要!

Anuj、Peggy、Thom和Jonny都没有邻居,这是因为虽然有指向他们的箭头,但没有从他们出发指向其他人的箭头。这被称为有向图(directed graph),其中的关系是单向的。因此,Anuj是Bob的邻居,但Bob不是Anuj的邻居。无向图(undirected graph)没有箭头,直接相连的节点互为邻居。例如,下面两个图是等价的。

bb1460b7b18cc2f6f4d97a4c46bcc8a2.png

使用队列实现广度优先算法。

队列介绍

如果你将两个元素加入队列,先加入的元素将在后加入的元素之前出队。因此,你可使用队列来表示查找名单!这样,先加入的人将先出队并先被检查。

队列是一种先进先出(First In First Out, FIFO)的数据结构,而栈是一种后进先出(Last In First Out, LIFO)的数据结构。

a21192c9b6ce1ed825a1e257dc418ba5.png

工作原理

5961deff51b57d03e38bacce289f57fb.png

代码实现

# deque是线程安全的 —— 双端队列
from collections import deque


def person_is_seller(name):
      # 简单做一下判断
      return name[-1] == 'm'


# 使用散列实现的图
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"] = []

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为边数

第6章小结

  • 广度优先搜索指出是否有从A到B的路径。
  • 如果有,广度优先搜索将找出最短路径。
  • 面临类似于寻找最短路径的问题时,可尝试使用图来建立模型,再使用广度优先搜索来解决问题。
  • 有向图中的边为箭头,箭头的方向指定了关系的方向,例如,rama→adit表示rama欠adit钱。
  • 无向图中的边不带箭头,其中的关系是双向的,例如,ross - rachel表示“ross与rachel约会,而rachel也与ross约会”。
  • 队列是先进先出(FIFO)的。
  • 栈是后进先出(LIFO)的。
  • 你需要按加入顺序检查搜索列表中的人,否则找到的就不是最短路径,因此搜索列表必须是队列。
  • 对于检查过的人,务必不要再去检查,否则可能导致无限循环。

第7章 狄克斯特拉算法

  • 介绍加权图——提高或降低某些边的权重(weight)。
  • 介绍狄克斯特拉算法,让你能够找出加权图中前往X的最短路径。
  • 介绍图中的环,它导致狄克斯特拉算法不管用。

c0f8a16d333311bbaf22736b0a7a1f22.png

最短路径指的并不一定是物理距离,也可能是让某种度量指标最小 —— 狄克斯特拉算法(Dijkstra's algorithm)(注意只适用于有向无环图(directedacyclic graph, DAG))。

狄克斯特拉算法讲解

(具体见书中说明)

狄克斯特拉算法包含4个步骤:

(1) 找出“最便宜”的节点,即可在最短时间内到达的节点,并确保没有到该节点的更便宜的路径!
(2) 更新该节点的邻居的开销,其含义将稍后介绍。
(3) 重复这个过程,直到对图中的每个节点都这样做了。
(4) 计算最终路径。

相关术语

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

90af13739b59cebc2f317f65b76faaab.png

带权重的图称为加权图(weighted graph),不带权重的图称为非加权图(unweighted graph)

5c5c523161de028ee0f26dd67ddeb081.png

要计算非加权图中的最短路径,可使用广度优先搜索。要计算加权图中的最短路径,可使用狄克斯特拉算法。图还可能有环,而环类似右面这样。

d9d30d3ec753edbba1de7f4f89adb444.png

在无向图中,每条边都是一个环。

狄克斯特拉算法只适用于有向无环图(directedacyclic graph, DAG)

换钢琴问题 —— 无负权边

(具体见书中介绍)

9185e2d076d374fb9d001b34c7a48f4d.png

换钢琴问题 —— 有负权边

(具体见书中介绍)

0ee09719febbc9bfc967129257df0a12.png

第二种方式的开销少2美元,他应采取这种方式。然而,如果你对这个图运行狄克斯特拉算法,Rama将选择错误的路径——更长的那条路径。如果有负权边,就不能使用狄克斯特拉算法。因为负权边会导致这种算法不管用。下面来看看对这个图执行狄克斯特拉算法的情况。首先,创建开销表。

这时候需要用另外一种算法:贝尔曼-福德算法(Bellman-Ford algorithm)

狄克斯特拉算法的代码实现

(详细见书中讲解)

# the 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"] = {}


# the costs table
infinity = float("inf")
costs = {}
costs["a"] = 6
costs["b"] = 2
costs["fin"] = infinity

# the parents table
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
    # Go through each node.
    for node in costs:
        cost = costs[node]
        # If it's the lowest cost so far and hasn't been processed yet...
        if cost < lowest_cost and node not in processed:
            # ... set it as the new lowest-cost node.
            lowest_cost = cost
            lowest_cost_node = node
    return lowest_cost_node

# Find the lowest-cost node that you haven't processed yet.
node = find_lowest_cost_node(costs)
# If you've processed all the nodes, this while loop is done.
while node is not None:
    cost = costs[node]
    # Go through all the neighbors of this node.
    neighbors = graph[node]
    for n in neighbors.keys():
        new_cost = cost + neighbors[n]
        # If it's cheaper to get to this neighbor by going through this node...
        if costs[n] > new_cost:
            # ... update the cost for this node.
            costs[n] = new_cost
            # This node becomes the new parent for this neighbor.
            parents[n] = node
    # Mark the node as processed.
    processed.append(node)
    # Find the next node to process, and loop.
    node = find_lowest_cost_node(costs)

print("Cost from the start to each node:")
print(costs)

第7章小结

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

第8章 贪婪算法

  • 学习如何处理不可能完成的任务:没有快速算法的问题(NP完全问题)。
  • 学习识别NP完全问题,以免浪费时间去寻找解决它们的快速算法。
  • 学习近似算法,使用它们可快速找到NP完全问题的近似解。
  • 学习贪婪策略——一种非常简单的问题解决策略。

第9章 动态规划

学习动态规划,这是一种解决棘手问题的方法,它将问题分成小问题,并先着手解决这些小问题。

最长公共子串

  • 动态规划可帮助你在给定约束条件下找到最优解。在背包问题中,你必须在背包容量给定的情况下,偷到价值最高的商品。
  • 在问题可分解为彼此独立且离散的子问题时,就可使用动态规划来解决。

要设计出动态规划解决方案可能很难,这正是本节要介绍的。下面是一些通用的小贴士。

  1. 每种动态规划解决方案都涉及网格。
  2. 单元格中的值通常就是你要优化的值。在前面的背包问题中,单元格的值为商品的价值。
  3. 每个单元格都是一个子问题,因此你应考虑如何将问题分成子问题,这有助于你找出网格的坐标轴。

下面再来看一个例子。假设你管理着网站dictionary.com。用户在该网站输入单词时,你需要给出其定义。但如果用户拼错了,你必须猜测他原本要输入的是什么单词。例如,Alex想查单词fish,但不小心输入了hish。在你的字典中,根本就没有这样的单词,但有几个类似的单词。

在这个例子中,只有两个类似的单词,真是太小儿科了。实际上,类似的单词很可能有数千个。Alex输入了hish,那他原本要输入的是fish还是vista呢?

绘制网格

用于解决这个问题的网格是什么样的呢?要确定这一点,你得回答如下问题。

 1.单元格中的值是什么?
 2.如何将这个问题划分为子问题?
 3.网格的坐标轴是什么?

在动态规划中,你要将某个指标最大化。在这个例子中,你要找出两个单词的最长公共子串。hish和fish都包含的最长子串是什么呢?hish和vista呢?这就是你要计算的值。

别忘了,单元格中的值通常就是你要优化的值。在这个例子中,这很可能是一个数字:两个字符串都包含的最长子串的长度。

如何将这个问题划分为子问题呢?你可能需要比较子串:不是比较hish和fish,而是先比较his和fis。每个单元格都将包含这两个子串的最长公共子串的长度。这也给你提供了线索,让你觉得坐标轴很可能是这两个单词。因此,网格可能类似于下面这样。

44f995209d7f597d85c9205023dab556.png

填充网格

最终的网格如下:

d09e6572e907015195e7b137539f5240.png

实现这个公式的伪代码如下:

# 两个字母相同
if word_a[i] == word_b[j]:
    cell[i][j] = cell[i-1][j-1] + 1
# 两个字母不同
else:
    cell[i][j] = 0

查找单词hish和vista的最长公共子串时,网格如下。

568f6517fb28cec106e68fdf132e21f9.png

需要注意的一点是,这个问题的最终答案并不在最后一个单元格中!对于前面的背包问题,最终答案总是在最后的单元格中。但对于最长公共子串问题,答案为网格中最大的数字——它可能并不位于最后的单元格中。

我们回到最初的问题:哪个单词与hish更像?hish和fish的最长公共子串包含三个字母,而hish和vista的最长公共子串包含两个字母。因此Alex很可能原本要输入的是fish。

最长公共子序列

假设Alex不小心输入了fosh,他原本想输入的是fish还是fort呢?我们使用最长公共子串公式来比较它们。

55d449acecbac0eecb421802c94bc29f.png

最长公共子串的长度相同,都包含两个字母!但fosh与fish更像。

bafe238ca5f62af28c8637d65a4b44e9.png

这里比较的是最长公共子串,但其实应比较最长公共子序列:两个单词中都有的序列包含的字母数。如何计算最长公共子序列呢?下面是用于计算fish和fosh的最长公共子序列的网格的一部分。

2aaffa4ddd95a103cd2406cd07da043a.png

最长公共子序列的解决方案

最终的网格如下

0d8a6f2823bf6411a0291a0f04f27957.png

下面是填写各个单元格时使用的公式。

f0d419ec63e2e816df5df723c2a09fde.png

伪代码如下:

# 两个字母相同
if word_a[i] == word_b[j]:
    cell[i][j] = cell[i-1][j-1] + 1
# 两个字母不同
else:
    cell[i][j] = max(cell[i-1][j] ,cell[i][j-1])

第9章小结

  • 需要在给定约束条件下优化某种指标时,动态规划很有用。
  • 问题可分解为离散子问题时,可使用动态规划来解决
  • 每种动态规划解决方案都涉及网格。
  • 单元格中的值通常就是你要优化的值。
  • 每个单元格都是一个子问题,因此你需要考虑如何将问题分解为子问题。
  • 没有放之四海皆准的计算动态规划解决方案的公式。

第10章 K最近邻算法

  • 学习使用K最近邻算法创建分类系统。
  • 学习特征抽取。
  • 学习回归,即预测数值,如明天的股价或用户对某部电影的喜欢程度。
  • 学习K最近邻算法的应用案例和局限性。

第11章 进阶内容

反向索引

傅里叶变换

并行算法

MapReduce

布隆过滤与HyperLogLog

SHA算法

局部敏感的散列算法

Diffie-Hellman密钥交换

线性规划

posted on 2020-07-28 22:40  江湖乄夜雨  阅读(331)  评论(0编辑  收藏  举报