《算法图解》学习笔记—第1-4章
第1章 算法简介
算法是一组完成任务的指令,任何代码片段都可视为算法。
1.1 二分查找
二分查找是一种算法,其输入是一个有序的元素列表。如果要查找的元素包含在列表中,二分查找返回其位置;否则返回null。
应用实例:在电话簿中找一个名字以K打头的人;登录Facebook时,Facebook必须核实你是否有其网站的账户,须在其数据库中查找你的用户名;以最少的次数猜到一个1~100之间的数字。
对于包含n个元素的列表,用二分查找最多需要
l
o
g
2
n
log_2^n
log2n步,而简单查找最多需要
n
n
n步。二分查找的Python代码如下:
def binary_search(list,item):
low=0
high=len(list)-1
while low <= high:
mid = (low + high) /2 #如果(low + high)不是偶数,Python自动将mid向下取整
guess = list[mid]
if guess == item: #找到了元素
return mid
if guess > item: #猜的数字大了
high = mid - 1
else: #猜的数字小了
low = mid + 1
return None #没有指定元素
1.2 大O表示法
大O表示法是一种特殊的表示法,指出了算法的速度有多快。
仅知道算法需要多长时间才能运行完毕还不够,还需知道运行时间如何随列表增长而增加。这正是大O表示法的用武之地。
大O表示法指出了算法有多快。例如,假设列表包含n个元素。简单查找需要检查每个元素,因此需要执行n次操作。使用大O表示法,这个运行时间为O(
n
n
n)。单位秒呢?没有——大O表示法指的并非以秒为单位的速度。大O表示法让你能够比较操作数,它指出了算法运行时间的增速。
大O 表示法指出了最糟情况下的运行时间。
一些常见的大O运行时间
-
O( log n \log n logn),也叫对数时间,这样的算法包括二分查找。
-
O( n n n),也叫线性时间,这样的算法包括简单查找。
-
O( n ∗ l o g n n * log n n∗logn),这样的算法包括快速排序——一种速度较快的排序算法。
-
O( n 2 n^2 n2),这样的算法包括选择排序——一种速度较慢的排序算法。
-
O( n ! n! n!),这样的算法包括旅行商问题的解决方案——一种非常慢的算法。
-
算法的速度指的并非时间,而是操作数的增速。
-
谈论算法的速度时,我们说的是随着输入的增加,其运行时间将以什么样的速度增加。
-
算法的运行时间用大O表示法表示。
-
O( l o g n log n logn)比O( n n n)快,当需要搜索的元素越多时,前者比后者快得越多。
-
大O表示法不考虑乘以、除以、加上或减去的数字。下面这些都不是正确的大O运行时间:O( n n n + 26)、O( n n n - 26)、O( n n n * 26)、O( n n n / 26),它们都应表示为O( n n n),通常不考虑常量。
第2章 选择排序
2.1内存的工作原理
fe0ffeeb是一个内存单元的地址。
需要将数据存储到内存时,你请求计算机提供存储空间,计算机给你一个存储地址。需要存储多项数据时,有两种基本方式——数组和链表。
2.2 数组和链表
有时候,需要在内存中存储一系列元素。假设你要编写一个管理待办事项的应用程序,为此需要将这些待办事项存储在内存中。
使用数组意味着所有待办事项在内存中都是相连的(紧靠在一起的)。再添加代办事项时,需要请求计算机重新分配一块可容纳4个待办事项的内存,再将所有待办事项都移到那里。
链表中的元素可存储在内存的任何地方。链表的每个元素都存储了下一个元素的地址,从而使一系列随机的内存地址串在一起。在链表中添加元素很容易:只需将其放入内存,并将其地址存储到前一个元素中。
需要随机地读取元素时,数组的效率很高,因为可迅速找到数组的任何元素。在链表中,元素并非靠在一起的,你无法迅速计算出第五个元素的内存地址,而必须先访问第一个元素以获取第二个元素的地址,再访问第二个元素以获取第三个元素的地址,以此类推,直到访问第五个元素。
- 元素的位置称为索引。因此,不说“元素20的位置为1”,而说“元素20位于索引1处”
中间插入
需要在中间插入元素时,使用链表,插入元素很简单,只需修改它前面的那个元素指向的地址。
而使用数组时,则必须将后面的元素都向后移。如果没有足够的空间,可能还得将整个数组复制到其他地方!因此,当需要在中间插入元素时,链表是更好的选择。
删除
如果你要删除元素呢?链表也是更好的选择,因为只需修改前一个元素指向的地址即可。而使用数组时,删除元素后,必须将后面的元素都向前移。
运行时间
下面列出了常见的数组和链表操作的运行时间。
2.3 选择排序
将数组元素按从小到大的顺序排列。先编写一个用于找出数组中最小元素的函数。
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)) #pop 移除列表中一个元素并返回该元素的值
return newArr
选择排序的时间为O(
n
2
n^2
n2)。第一次需要检查n个元素,但随后检查的元素数依次为
n
−
1
n - 1
n−1,
n
–
2
n – 2
n–2, …, 1。平均每次检查的元素数为1/2 ×
n
n
n,因此运行时间为O(
n
×
1
/
2
×
n
n × 1/2 × n
n×1/2×n)。但大O表示法省略诸如1/2这样的常数,因此简单地写作O(
n
×
n
n × n
n×n)或O(
n
2
n^2
n2)。
第3章 递归
算法是一组完成任务的指令,任何代码片段都可视为算法。
3.1 递归介绍
盒子中找钥匙
解决方案一:while 循环
def look_for_key(main_box):
pile = main_box.make_a_pile_to_look_through()
while pile is not empty:
box = pile.grab_a_box()
for item in box:
if item.is_a_box():
pile.append(item)
elif item.is_a_key():
print "found the key!"
解决方案二:递归——函数调用自己
def look_for_key(box):
for item in box:
if item.is_a_box():
look_for_key(item)
elif item.is_a_key():
print "found the key!"
3.2 基线条件和递归条件
编写递归函数时,必须告诉它何时停止递归。正因为如此,每个递归函数都有两部分:基线条件(base case)和递归条件(recursive case)。递归条件指的是函数调用自己,而基线条件则指的是函数不再调用自己,从而避免形成无限循环。
3.3 栈
栈是一种简单的数据结构。有两种操作:压入(插入)和弹出(删除并读取)。
调用栈
栈用于存储多个函数的变量时,被称为调用栈(call stack)。
每当你调用函数时,计算机会将函数调用涉及的所有变量的值存储到内存中。计算机使用一个栈来表示这些内存块,其中第二个内存块位于第一个内存块上面。当调用另一个函数时,当前函数暂停并处于未完成状态。
递归调用栈
下面是计算阶乘的递归函数。
def fact(x):
if x == 1:
return 1
else:
return x * fact(x-1)
- 栈顶的方框指出了当前执行到了什么地方。
- 每个fact调用都有自己的x变量。在一个函数调用中不能访问另一个 x x x的变量。
使用栈虽然很方便,但是也要付出代价:存储详尽的信息可能占用大量的内存。每个函数调用都要占用一定的内存,如果栈很高,就意味着计算机存储了大量函数调用的信息。解决方法:1.重新编写代码,转而使用循环。2.使用尾递归
第4章 快速排序
4.1 分而治之
分而治之(divide and conquer,D&C)——一种著名的递归式问题解决方法。D&C并非可用于解决问题的算法,而是一种解决问题的思路。D&C的工作原理:
(1) 找出简单的基线条件;
(2) 确定如何缩小问题的规模,使其符合基线条件。
示例:给定一个数字数组,需要将这些数字相加,并返回结果。
使用循环很容易完成这种任务。
def sum(arr):
total = 0
for x in arr:
total += x
return total
如何使用递归函数来完成这种任务呢?
**第一步:找出基线条件。**如果数组不包含任何元素或只包含一个元素,计算总和将非常容易。
第二步:每次递归调用都必须离空数组更近一步。
给函数sum传递的数组变的更短。换言之,这缩小了问题的规模!
编写涉及数组的递归函数时,基线条件通常是数组为空或只包含一个元素。陷入困境时,请检查基线条件是不是这样的。
s u m sum sum函数代码:
def sum(list):
if list == []:
return 0
return list[0] + sum(list[1:])
练习
编写一个递归函数来计算列表包含的元素数。
def count(list):
if list == []:
return 0
return 1 + count(list[1:])
找出列表中最大的数字。
def max(list):
if len(list) == 2:
return list[0] if list[0] > list[1] else list[1]
sub_max = max(list[1:])
return list[0] if list[0] > sub_max else sub_max
4.2 快速排序
快速排序是一种常用的排序算法,比选择排序快得多。
使用快速排序对数组进行排序。对排序算法来说,最简单的数组什么样呢?基线条件为数组为空或只包含一个元素。在这种情况下,只需原样返回数组——根本就不用排序。
使用D&C,需要将数组分解,直到满足基线条件。下面介绍快速排序的工作原理。假设一个包含四个元素的数组如下:
首先,从数组中选择一个元素,这个元素被称为基准值(pivot)。接下来,找出比基准值小的元素以及比基准值大的元素。
这被称为分区(partitioning)。现在你有:
- 一个由所有小于基准值的数字组成的子数组;
- 基准值;
- 一个由所有大于基准值的数组组成的子数组。
只要对这两个子数组进行快速排序,再合并结果,就能得到一个有序数组!
快速排序步骤如下:
- 选择基准值。
- 将数组分成两个子数组:小于基准值的元素和大于基准值的元素。
- 对这两个子数组进行快速排序。
快速排序的代码如下:
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)
4.3 再谈大O表示法
快速排序在最糟情况下,其运行时间为O(
n
2
n^2
n2),与选择排序一样慢!但这是最糟情况。在平均情况下,快速排序的运行时间为O(
n
l
o
g
n
n log n
nlogn)。还有一种名为合并排序(merge sort)的排序算法,其运行时间为O(
n
l
o
g
n
n log n
nlogn)。
比较合并排序和快速排序
假设有下面这样打印列表中每个元素的简单函数。
def print_items(list):
for item in list:
print item
这个函数遍历列表中的每个元素并将其打印出来。它迭代整个列表一次,因此运行时间为O( n n n)。现在假设你对这个函数进行修改,使其在打印每个元素前都休眠1秒钟。
from time import sleep
def print_items2(list):
for item in list:
sleep(1)
print item
它在打印每个元素前都暂停1秒钟。假设你使用这两个函数来打印一个包含5个元素的列表。
虽然使用大O表示法表示时,这两个函数的速度相同,但实际上print_items的速度更快。在大O表示法O(
n
n
n)中,
n
n
n实际上指的是这样的。
c
c
c是算法所需的固定时间量,被称为常量。大O表示通常不考虑这个常量,因为有时常量根本没有什么影响。参考简单查找和二分查找的大O表示:
但有时候,常量的影响可能很大,对快速查找和合并查找来说就是如此。实际上,快速查找的速度确实更快,因为相对于遇上最糟情况,它遇上平均情况的可能性要大得多。
平均情况和最糟情况
快速排序的性能高度依赖于你选择的基准值。假设你总是将第一个元素用作基准值,且要处理的数组是有序的。由于快速排序算法不检查输入数组是否有序,因此它依然尝试对其进行排序。
现在假设你总是将中间的元素用作基准值,在这种情况下,调用栈如下。
调用栈短得多!因为你每次都将数组分成两半,所以不需要那么多递归调用。你很快就到达了基线条件,因此调用栈短得多。
第一个示例展示的是最糟情况,而第二个示例展示的是最佳情况。在最糟情况下,栈长为
O(
n
n
n),有O(
n
n
n)层,因此该算法的运行时间为O(
n
n
n) * O(
n
n
n) = O(
n
2
n^2
n2)。而在最佳情况下,栈长为O(
l
o
g
n
log n
logn),层数为O(
l
o
g
n
log n
logn),因此整个算法需要的时间为O(
n
n
n) * O(
l
o
g
n
log n
logn) = O(
n
l
o
g
n
n log n
nlogn)。
最佳情况也是平均情况。只要你每次都随机地选择一个数组元素作为基准值,快速排序的平均运行时间就将为O( n l o g n n log n nlogn)。