我们继续接着插入排序法往下聊。
直接插入排序法的时间复杂度问题
插入排序法(或者叫做直接插入排序法)的原理很简单,也很自然,而且也是后面很多排序法的基础,是不得不会的。它的思想是,每一次将待排序的数据插入到已排好顺序的数组中去。所以一开始,任何一个数据都认为是已排好序的。也就是数组的首元素A[0],设数组长度为n,那么第一次插入是从A[1]开始的,先将A[0]赋值给临时变量key.这是一个临时保存待插入数据的新内存空间,防止数据丢失的,而将A[0]赋值给A[1].key与A[0]比较,若大,则将key赋值给A[1],否则将key赋值给A[0].如此就完成了A[1]的插入操作。这是完全可行的。如果现在是A[j] (j<n)要插入,那么一定是A[:j]已经排好序了,而插入A[j]也是要将它赋值给key,将A[j-1]赋值给A[j],相当于数据后移,先后移,再进行比较,若key大,则key赋值给A[j],否则与A[j-1]比较,这里涉及到一个迭代过程,用i标记前j个元素的下标,将j当做一个常数,i递减。只有当i<0或者key大于等于某个A[i] 时循环才会退出。将key的值赋值给A[i+1],操作完成。
最后,由于数组长度时有限的,当k遍历到n时,整个插入排序的算法结束。下面是算法导论中提供的伪码:
插入排序法最坏和平均情况下时间复杂度都是O(n^2),最好情况下时间复杂度为O(n).下面就来解释一下为什么会是这样。
我们所理解的时间复杂度一般都是在while循环和for循环下,但严格意义上,每一行语句都要耗费时间。如果一行语句没有循环,那么它所耗费的时间就是常数,与数组长度无关。比如说a=0,b=1这样的简单赋值。然而一旦涉及到循环体,它所包含的语句就不是简单的一句了,而是有限步相同的操作,而这,往往与数组规模有关,但也不全是跟数组规模有关,因为有的可能压根不涉及到数组的操作。算法导论中对此做了十分详细的解释。
如上图,其中第四行有误,应改作“i<- j-1”.可以看到,每一行都都有对应的运行时间,他们都是常数。关键在于后面的次数,第一个是n,虽然j遍历2到n,但是只有到n+1的时候循环才会结束,所以需要执行n次条件判断。所以次数为n。而在循环体内部执行的是j从2到n的条件下的相关操作,所以比while循环条件判断行始终少一次(这里需要注意因为是伪码所以是符合自然语言的说法,而在程序中数组的索引从0开始的,这里从1开始了,符合自然习惯)。严格的时间复杂度是将每一行的运行时间与次数相乘再相加。也就是T(n):
再看第五行的tj.tj表示插入A[j]的比较次数。它是不可常量化的,甚至一个准确的数学表达式也没有办法得到,因为它有很多种可能,也许比较一次就够了,也许要比较j次,他们分别对应最好情况和最坏情况。最好情况tj=1,第五行就是times一栏就是n,最坏情况tj = j,
或者写作
6,7两行也很容易得出比较次数为
自己可以在草稿纸上多算算(合并同类项),这里直接给出算法导论的答案看起来的确很不漂亮,但它确是真正意义上的时间复杂度。
现实中这样一个一个算显然是不现实的,要清楚我们真正关心的是什么。为什么我们总是要考虑时间复杂度和空间复杂度。这是因为我们的计算机尽管计算如飞,可算力毕竟也是有限的。总有它们的极限。生活中处理的向量运算其规模往往在百万数量级以上,这种运算量人是永远达不到的,即使是计算机也不是能秒出答案的。面对这种问题,人类一方面从硬件入手,提高计算机的算力,以至于现在很多的超级计算机横空出世。这是一个办法。但更多地(或者说更简单地),就是设计一个算法让计算机能少算几步。这就涉及到运算次数的问题。大家可千万不要小看少算几步的意义,在规模动辄百万千万的数量级下,哪怕是节省一行代码,也就帮助计算机少算了几百万次,如果计算阶是O(n^2)那更是万亿级别的计算代价。所以这也是为什么能线性实现就不要用二次实现,快速排序算法的O(nlogn)为什么备受推崇,就是因为在最坏情况下(在实际中很容易遇到最坏情况)计算数量级O(n ^2)对于绝大多数计算机而言计算量是难以承受的,而O(nlogn)这样的数量级即使比不上O(n),但也比起O(n ^2)好过太多。
因此,在计算规模n足够大的时候,我们更关心运行次数而不是单位运行时间。而根据无穷大的运算阶原则,我们只选择次数最高的项数作为时间复杂度的表示,比如O(n), O(n^(1/2))等,甚至于最高项的系数我们也简化成1来看(所以有O(1)这种表示,说明时间复杂度跟计算规模没有关系,是常数)。
再解释一个平均时间复杂度的概念。最好,最坏时间复杂度都是有一个确定的次数的,比如说这里,最好情况下需要比较n次,这是因为A[j]插入到A[:j]时只要跟A[j-1]做一次比较且A[j]>A[j-1],因而达到了第五行中跳出循环的条件了,而j从2遍历到n+1(包含跳出循环的条件判断),所以时间复杂度为
按照最高次数运算阶原则,这也就是整个算法的时间复杂度了。换句话说,最好情况下插入排序算法的时间复杂度为O(n).最坏情况下的算法时间复杂度前面已经说了,是O(n^2).
有时候平均复杂度相较于最好最坏这两种极端情况更受关注,尤其是对于排序算法,一般情况下不会遇到所有的数都排列好了的情况,也不会遇到所有的数都倒序排列的情况,这是两个极端情况,在实际排序的过程中不大可能发生。这就要考虑平均情况。
书上《图解数据结构(吴灿铭)》说最坏情况和平均情况下比较次数都是(n-1)n/2次,我对此论断表示怀疑。如果认为我的推算有何问题的,敬请指出,不胜感激。
书上说的很好,这种算法只适用于大多数数据已经排列好的情况,一般情况下,由于时间复杂度的阶数太高,是很难应用到大规模数据上去的。但是对于插入排序法的思想,我们不得不知,后面也是针对于此所做的改进。
合并排序法
合并(或归并)排序法(Merge_Sort)的工作原理是针对已排序好的两个或两个以上的数列,通过合并的方式将其组合成一个大的且已排好序的数列,步骤如下:
(1)将N个长度为1的数成对地合并成N/2个长度为2的数对;
(2)将N/2个长度为2的数对成对地合并成N/4个长度为4的数对;
(3)将数对不断地合并,知道合并成一组长度为N的数组为止。
设有一组数据63,92,27,36,45,71,58,7.它的合并排序算法示意图如下:
对于两个有序数组,不同于插入排序,合并它们的方法是这样的:
如图,为了合并L和R,分别用i和j标记L和R的下标,一开始i=j=0,比较L[0],R[0],较小的元素插入到A最小的下标中,然后i或j自增,再比较L[i]和R[j],直到待排序的数组已满,这里L和R的末尾放入了一个非常大的数作为哨兵,当i和j同时遍历到哨兵时,说明合并算法已经完成。这种双指针方法的时间复杂度显然是O(n)的,如果n是待排序数组的长度的话。下面是合并算法merge的伪码,这里p,q,r分别作为整个数组的第p,第q和第r个位置:
整个的merge_sort算法伪码如下:
完整的python代码如下:
MAX = 99999
def merge_sort(A, p, r):
if r > p:
q = (p+r)//2
merge_sort(A, p, q)
merge_sort(A, q+1, r)
return merge(A, p, q, r)
def merge(A, p, q, r):
left = [] #建立两个内存空间用来存储两个待合并数组
right = []
for i in range(p, q+1):
left.append(A[i])
left.append(MAX) #哨兵,防止指针溢出
for j in range(q+1, r+1):
right.append(A[j])
right.append(MAX) #哨兵,防止指针溢出
i = 0
j = 0
k = 0
while i < len(left)-1 or j < len(right)-1:
if left[i] < right[j]:
A[p+k] = left[i]
i += 1
else:
A[p+k] = right[j]
j += 1
k += 1
return A
if __name__ == "__main__":
lists = [63, 92, 27, 36, 45, 71, 58, 7]
print("排序前序列为: ")
for i in lists:
print(i, end=" ")
print("\n 排序后的结果为: ")
merge_sort(lists, 0, len(lists)-1)
for i in lists:
print(i, end=" ")
输出结果如下:
合并排序算法的时间复杂度
对一个长度为n的数组,合并的次数是log2(n)(一般写作logn),每次合并的时间复杂度是O(n),而且不管是最好情况,最坏情况,平均情况都是如此(双指针方法的特点)。所以它在任何情况下的时间复杂度都是O(nlogn).空间复杂度为O(n),因为在排序中需要一个与待排序数组长度相同的内存空间用来存储两个待合并数组。
参考文献:
【1】图解数据结构;吴灿铭
【2】python程序员算法宝典;张波,楚秦
【3】算法导论
另外知乎和csdn上很多同学的见解也给了我莫大的帮助,在此一并感谢。
来源:CSDN
作者:weixin_43712228
链接:https://blog.csdn.net/weixin_43712228/article/details/103984372