算法与数据结构
计算机程序的基本组成就是:数据集和指令集。在抽象层次更高的地方,我们称之为:程序是由数据结构与算法构成的,其中的数据结构对应的就是底层中的数据集,而算法其实就是指令集。 这两者相辅相成,分开谈论这两者是没有意义的。
好比,接下来会讲到的二分查找算法,二分查找算法的前提是:输入数据是一个有序数组的数据结构,否则算法无法正确工作;还有图论中的Dijkstra算法,若输入的数据中是含有负权重的边的数据结构,算法就无法得出正确结论。
因此,我们可以说,对于计算机程序而言
- 算法只有作用于特定的数据结构上,才能得到正确的结果
- 数据结构只有被特定的算法作用,才能体现其存在的价值
虽说不能将算法和数据结构分开来看待,但是通常我们可以认为:算法是可以独立于程序本身的。这句话的意思是,对于计算机而言,算法是脱离于特定的平台、特定的语言而存在的。因为,要知道算法并不是计算机领域特有的,小到加减乘除,大到阿波罗登月计划,其背后都有算法影子,甚至按照《未来简史》作者的观点来看,我们这个世界都是由算法决定,由数据驱动的。
大 O O O表示法
分析一个算法的性能,主要是分析算法的时间复杂度和空间复杂度。一般情况下,考虑时间复杂度的情况更多些;空间复杂度会在数据特别大的时候会考虑。还有,在一些书中说的算法复杂度,一般指的是时间复杂度的意思。
虽然可以直接将算法放到计算机中跑一遍,然后根据CPU占用、内存占用、时间消耗等信息中,得出该算法的实际运行性能的情况。但是,很多情况下是不方便的,而且,受限于硬件性能的原因、受限于可拿来测试的数据集大小的原因,这样的做法只能得出十分局限的分析结果,并不能体现出一个算法的本质。
因此,计算机中采用大 O O O表示法,来体现一个算法的时间复杂度。
大O表示法有以下几个特点
- 大 O O O 表示法,表达的是一个算法的增速,而不是算法具体消耗的时间。
- 大 O O O 表达式中,只保留算法的高阶项式子,不保留算法的低阶项式子,也不保留常数项
- 大 O O O 表示法,指出的是算法运行最糟糕的情况下的时间复杂度,而不是最好的情况
下面列出一些常见算法的大 O O O 运行时间
运行时间 | 常见算法 |
---|---|
O ( l o g n ) O(log n) O(logn),也叫对数时间 | 二分查找算法等 |
O ( n ) O(n) O(n),也叫线性时间 | 简单查找算法等 |
O ( n l o g n ) O(n log n) O(nlogn) | 快速排序算法等 |
O ( n 2 ) O(n^2) O(n2) | 选择排序算法等 |
O ( n ! ) O(n!) O(n!) | 旅商问题的算法 |
大 O O O 表达式中的 l g lg lg,指的都是以 2 2 2为底数的简写,这与数学中的 l g lg lg 表示以10为底数的简写不同。因此,计算机科学中,凡是看到 l g n lg n lgn,指的就是以 2 2 2为底数的 n n n的对数的意思。当然了,一些书上 l o g n log n logn ,表达的也是以2为底数的 n n n 的对数的意思。
下面就来分析几个常见的算法与数据结构的关系
1. 简单查找
简单查找是查找算法中,最简单同时也是最暴力的一种查找算法
- 数据结构:一个数组
- 算法步骤:
- 从头到尾遍历整个数组
- 将每个索引位置的元素取出来与查找的目标值比较
- 如果相等,则返回索引位置;否则,继续查找下一个元素
- 若遍历完整个数组都没有结果,则返回None
Python代码实现
def simple_search(array, item):
for index in range(len(array)):
if array[index] == item:
return index
return None
array = [i for i in range(1, 2**20)]
result = simple_search(array, 18927)
if result is None:
print("没有找到该元素!")
else:
print("找到该元素!索引是:%d,被找到的元素是:%d" % (result, array[result]))
print(simple_search(array, -1))
# 找到该元素!索引是:18926,被找到的元素是:18927
# None
从代码上来看,简单查找算法很依赖于输入数组是否有特定的顺序。当数组的第一个索引位置的元素就是需要找的目标元素时,此时就是算法最好的情况,这样算法的时间复杂度为: O ( 1 ) O(1) O(1)。
然而我们上面提过了,对于大 O O O表示法而言,考虑的是算法最糟糕的情况下的时间复杂度。毫无疑问,对于简单查找而言,算法最糟糕的情况就是目标元素在数组的最后一个位置或数组中没找到该元素,这两种情况下,算法的时间复杂度都是: O ( n ) O(n) O(n) 。
事实上,最好和最坏的极端情况都是少数,一般而言我们可以认为被搜索的元素,是随机放置在数组中的,那么此时的算法时间复杂度就可以取一个平均值: O ( n 2 ) O(\frac {n}{2}) O(2n) 。
那么这个算法的时间复杂度究竟是多大呢?回到上面讲的大O表示法的定义可知,大O表示法关注的是表达式中的高阶增长项,不关心常数项的增长。对于简单查找的平均时间复杂度而言,其前面的常数项 1 2 \frac {1}{2} 21 是可以被忽略的。
因此,简单查找的时间复杂度的大O表示法为: O ( n ) O(n) O(n)。
2. 二分查找算法
设计一个二分查找的程序,需要两个部分
- 数据结构:一个有序的数组(在Python中用列表来表示),其中有序的规则,按照从小到大或者从大到小都可以。
- 算法步骤:
- 不断地获取有序数组中间那个索引对应的值,与需要查找的目标值比较
- 若中间索引值大于或小于目标值,则缩小一半的查找范围
- 若两者数值相等,则返回索引位置
- 若搜寻完整个数组依然没有找到,则返回None
Python代码实现
def binary_search(array, item):
first = 0
last = len(array) - 1
while first <= last:
mid = (first + last) // 2 #需要使用“地板除”,否则简单除法的结果是一个float对象
guess = array[mid]
if guess == item:
return mid
elif guess > item:
last = mid - 1
else:
first = mid + 1
return None
array = [i for i in range(1, 2**20)]
result = binary_search(array, 18273)
if result is None:
print("没有找到该元素!")
else:
print("找到该元素!索引是:%d,被找到的元素是:%d" % (result, array[result]))
print(binary_search(array, -1))
# 找到该元素!索引是:18272,被找到的元素是:18273
# None
对于二分查找而言,因为其输入的是有序数组这一数据结构,所以就能保证算法每次最关键的操作之一——取当前数组中间索引的值,能准确地将数组分为两部分:在中间值之前的元素小于中间值,在中间值之后的元素大于中间值,然后将中间值与要查找的元素比较大小,就能判断出要查找的值属于两部分中的哪个范围了。
若算法输入的数据结构不是一个有序的数组,就无法保证每次将数组元素分成特定的两部分,继而将中间值与要查找得到元素比较大小的操作,也就没有意义了。
最后,二分查找算法的时间复杂度为: O ( l o g n ) O( log n ) O(logn) 。
大 O O O表示法的分析
下面列出简单查找与二分查找,在不同的数量级间的比较(这里假设每执行一步消耗时间是1毫秒,当然了,实际上计算机速度比这快的多)
数组大小 | 简单查找 | 二分查找 |
---|---|---|
1024 | 1024 毫秒 | 10 毫秒 |
1048576 | 17 分钟 | 20 毫秒 |
1073741824 | 12 天 | 30 毫秒 |
可以看出,随着输入数据规模的增大,简单查找所消耗的时间增长地越来越多,而二分查找消耗的时间增长非常小。
其实,从数学函数图像角度分析也可以得出该结论。
- 简单查找的函数图像,是一个斜率为 1 1 1的函数,其结果 f ( x ) f(x) f(x) 的增速是保持不变的,或者称它的导函数是不变的,因此x非常大时, f ( x ) f(x) f(x) 也会非常大!
- 二分查找是一个对数函数图像,当x越来越大时,对数函数图像几乎就是一条平行于x轴的直线了;也就是说,当x非常大时,对数函数的值 f ( x ) f(x) f(x) 几乎没有太大变化,增速非常慢
本文算是笔者读的《算法图解》系列笔记第一篇吧,后续还有几篇关于这本书的读书笔记。个人觉得,这本书还是很不错的,对于一些就经典的算法都做了深入浅出的剖析,对于比较复杂的算法,原书的作者也给出了一个进阶指南。
不过,个人觉得算法老司机就不用看了,算法菜鸟入入门,还是值得向朋友们推荐一波的!