有时候比较容易感慨,要是我大学里知道读一些这样的书,那该多好啊。大学里学的很多课只是老师讲到哪里学到哪里,也没有很大的兴趣,很多东西也没有深刻理解,就死记应付考试。
现在越来越对这些感兴趣了,想着如果当初有人给一些建议、对人生道路有一些指导建议,如果当初自己也能更加地知道自己想要的是什么、而不是随波逐流,也会比现在更好吧。
二分查找是一种算法,其输入是一个有序的元素列表。
如果要查找的元素包含在列表中,二分查找返回其位置;否则返回null。
仅当列表是有序的时候,二分查找才管用。
例如,电话簿中的名字是按字母顺序排列的,因此可以使用二分查找来查找名字。
import random
def binary_search(listarr, target):
low = 0
high = len(listarr) - 1
count = 1
while low <= high:
mid = (low + high) >> 1
guess = listarr[mid]
if guess == target:
print(f'by search {count} times, index of target num {target} is {mid}')
return mid
if guess < target:
low = mid + 1
else:
high = mid - 1
count += 1
return None
if __name__ == '__main__':
# 随机生成一个有序的列表,
test_list = sorted([random.randint(1, 100) for i in range(10)])
print(test_list)
# 在有序随机列表中,随机挑选一个数字,用二分法找到其下标
binary_search(test_list, random.choice(test_list))
每次介绍算法时,都将讨论其运行时间。一般而言,应选择效率最高的算法,以最大限度地减少运行时间或占用空间。
对于二分查找,使用它可节省多少时间呢?
简单查找逐个地检查数字,如果列表包含100个数字,最多需要猜100次。如果列表包含40亿个数字,最多需要猜40亿次。最多需要猜测的次数与列表长度相同,这被称为线性时间(linear time)。
二分查找则不同,如果列表包含100个元素,最多要猜7次;如果列表包含40亿个数字,最多需猜32次。二分查找的运行时间为对数时间(或log时间)。
大O表示法是一种特殊的表示法,指出了算法的速度有多快。
通常,仅知道算法需要多长时间才能运行完毕还不够,还需知道运行时间如何随列表增长而增加。
例如,假设列表包含n个元素:简单查找需要检查每个元素,因此需要执行n次操作。使用大O表示法,这个运行时间为O(n)。没有单位——大O表示法指的并非以秒(或者其他时间单位)为单位的速度。大O表示法让你能够比较操作数,它指出了算法运行时间的增速。
简单查找的运行时间为O(n),这意味着在最糟情况下,必须查看电话簿中的每个条目。如果要查找的是Adit——电话簿中的第一个人,一次就能找到,无需查看每个条目。考虑到一次就找到了Adit,请问这种算法的运行时间是O(n)还是O(1)呢?
简单查找的运行时间总是为O(n)。查找Adit时,一次就找到了,这是最佳的情形,但大O表示法说的是最糟的情形。因此,可以说在最糟情况下,必须查看电话簿中的每个条目,对应的运行时间为O(n)。这是一个保证——知道简单查找的运行时间不可能超过O(n)。
从快到慢的顺序列出经常会遇到的5种大O运行时间:
O(log n)
,也叫对数时间,这样的算法包括二分查找。O(n)
,也叫线性时间,这样的算法包括简单查找。O(n * log n)
,这样的算法包括快速排序——一种速度较快的排序算法。O(n2)
,这样的算法包括选择排序——一种速度较慢的排序算法。O(n!)
,这样的算法包括旅行商问题的解决方案——一种非常慢的算法。小结:
O(log n)
比O(n)
快,当需要搜索的元素越多时,前者比后者快得越多。将数据存储到内存时,需要请求计算机提供存储空间,计算机给一个存储地址。需要存储多项数据时,有两种基本方式——数组和链表。
读取元素:
如果需要随机地读取元素时,数组的效率很高,可迅速找到数组的任何元素;
如果需要同时读取所有元素时,链表的效率很高:读取第一个元素,根据其中的地址再读取第二个元素,以此类推。但如果需要跳跃,链表的效率真的很低;
在链表中,元素并非靠在一起的,你无法迅速计算出第五个元素的内存地址,而必须先访问第一个元素以获取第二个元素的地址,再访问第二个元素以获取第三个元素的地址,以此类推,直到访问第五个元素。
删除元素:
常见的数组和链表操作的运行时间:
数组 | 链表 | |
---|---|---|
读取 | O(1) | O(n) |
插入 | O(n) | O(1) |
删除 | O(n) | O(1) |
需要指出的是,仅当能够立即访问要删除的元素时,删除操作的运行时间才为O(1)。通常我们都记录了链表的第一个元素和最后一个元素,因此删除这些元素时运行时间为O(1)。
数组和链表哪个用得更多?显然要看情况。
但数组用得很多,因为它支持随机访问。有两种访问方式:随机访问和顺序访问。顺序访问意味着从第一个元素开始逐个地读取元素。链表只能顺序访问:要读取链表的第十个元素,得先读取前九个元素,并沿链接找到第十个元素。随机访问意味着可直接跳到第十个元素。经常说数组的读取速度更快,这是因为它们支持随机访问。很多情况都要求能够随机访问,因此数组用得很多。
import random
def find_smallest(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 select_sort(arr):
res = []
for i in range(len(arr)):
smallest_index = find_smallest(arr)
res.append(arr.pop(smallest_index))
return res
if __name__ == '__main__':
arr = [random.randint(1, 100) for i in range(10)]
print(select_sort(arr))
有的情况,递归也可以写成循环的形式。
如果使用循环,程序的性能可能更高;如果使用递归,程序可能更容易理解。如何选择要看什么对你来说更重要。
每个递归函数都有两部分:
小结:
快速排序使用分而治之(divide and conquer, D&C)的策略,比选择排序快得多。
最简单的数组:不需要排序的数组,比如数组为空或只包含一个元素。这种情况,可以原样的形式返回"排好序的"数组。(通常这种情况,作为基准条件/函数出口)
当数组不是最简单的数组形式时:
首先,从数组中选择一个元素,这个元素被称为基准值(pivot)
接下来,找出比基准值小的元素以及比基准值大的元素。此时得到:
这里只是进行了分区,得到的两个子数组是无序的。但如果这两个数组是有序的,对整个数组进行排序将非常容易。
分别对两个子数组快速排序
对于基准值左边的子数组以及右边的子数组(子数组可能为空),只要对这两个子数组进行快速排序,再合并结果,就能得到一个有序数组。
import random
def quick_sort(arr):
if arr == [] or len(arr) == 1:
return arr
base = arr[0]
smaller_arr = []
larger_arr = []
for i in arr[1::]: # 取出作为base的元素,再分区
if i < base:
smaller_arr.append(i)
else:
larger_arr.append(i)
return quick_sort(smaller_arr) + [base] + quick_sort(larger_arr)
if __name__ == '__main__':
arr = [random.randint(1, 100) for i in range(10)]
print(quick_sort(arr))
import time
def print_item(arr):
for i in arr:
print(i)
def print_item2(arr):
for i in arr:
time.sleep(1)
print(i)
两个函数遍历列表中的每个元素并将其打印出来。迭代整个列表一次,因此运行时间都为O(n)。不同的是,函数2在打印每个元素前都休眠1秒钟。
比较哪个更快呢?print_items要快得多,因为它没有在每次打印元素前都暂停1秒钟。因此,虽然使用大O表示法表示时,这两个函数的速度相同,但实际上print_items的速度更快。
在大O表示法O(n)中,n实际上指的是这样的:n = c * n
,其中c是一个固定的时间量。通常不考虑这个常量,因为如果两种算法的大O运行时间不同,这种常量将无关紧要。
拿二分查找和简单查找来举例说明:
固定时间常量c | 运行时间 | 在40亿个元素的列表中查找所需时间 | |
---|---|---|---|
简单查找 | 10 毫秒 | 10 毫秒 * n | 10 毫秒 * 40 亿 = 463 天 |
二分法查找 | 1 秒 | 1 秒 * log n | 1 秒 * log 40 亿 = 32 秒 |
可以看出,二分查找的速度还是快得多,常量根本没有什么影响。因为两者不是一个数量级的,当 n 趋向于无穷大时,log n / n
的极限为0,这种情况下,和 n 相比,log n 大小不算啥。
但有时候,常量的影响可能很大,对快速查找和合并查找来说就是如此。快速查找的常量比合并查找小,因此如果它们的运行时间都为O(n log n),快速查找的速度将更快。实际上,快速查找的速度确实更快,因为相对于遇上最糟情况,它遇上平均情况的可能性要大得多。
快速排序的性能高度依赖于选择的基准值。假设总是将第一个元素用作基准值,且要处理的数组是有序的。由于快速排序算法不检查输入数组是否有序,因此它依然尝试对其进行排序。
以有序数组为例,两种情况:每次都选择第一个作为基准值、选择中间的元素作为基准值。
算法的运行时间为O(n) * O(n) = O(n2)
,这是最糟的情况,栈长为O(n),在调用栈的每层都涉及O(n)个元素;
整个算法需要的时间为O(n) * O(logn) = O(nlogn)
,这是最佳的情况,栈长为O(logn),每次也将涉及O(n)个元素。
最佳情况也是平均情况。只要每次都随机地选择一个数组元素作为基准值,快速排序的平均运行时间就将为O(n log n)。快速排序是最快的排序算法之一,也是D&C典范。
小结:
散列函数:是这样的函数,即无论你给它什么数据,它都还你一个数字。
结合使用散列函数和数组创建了一种被称为散列表(hash table)的数据结构。
散列表是一种包含额外逻辑的数据结构。数组和链表都被直接映射到内存,但散列表更复杂,它使用散列函数来确定元素的存储位置:
散列表可能是最有用的,也被称为散列映射、映射、字典和关联数组。散列表的速度很快!
模拟映射关系
防止重复
假设有一个投票站,每人只能投一票,但如何避免重复投票呢?有人来投票时,你询问他的全名,并将其与已投票者名单进行比对。
如果将已投票者姓名存储在列表中,这个函数的速度终将变得非常慢,因为它必须使用简单查找搜索整个列表。但如果存储在了散列表中,能够迅速知道来投票的人是否投过票。使用散列表来检查是否重复,速度非常快。
缓存
缓存是一种常用的加速方式,所有大型网站都使用缓存,而缓存的数据则存储在散列表中。
比如,请求访问一个页面时,客户端会将页面数据保存下来,当再次访问时,会首先检查散列表中是否存储了该页面,如果存在则直接返回缓存,减轻服务器压力。
cache = {}
def get_page(url):
if cache.get(url):
return cache[url]
else:
data = get_data_from_server(url)
cache[url] = data
return data
散列函数总是将不同的键映射到数组的不同位置。—— 这几乎是不可能的。
良好的散列函数让数组中的值呈均匀分布。
在平均情况下,散列表执行各种操作的时间都为O(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) |
在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速度与链表一样快,因此它兼具两者的优点。
但在最糟情况下,散列表的各种操作的速度都很慢。因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。避免冲突,需要:
填装因子 = 散列表包含的元素数 / 位置总数
举例:
速度与链表一样快,因此它兼具两者的优点。
但在最糟情况下,散列表的各种操作的速度都很慢。因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。避免冲突,需要:
《算法图解》后续部分,都是通过例子来讲解的,没法详细整理,建议看书比较好。