常见排序方法的时间与空间复杂度
平均情况时间复杂度
最坏和最好情况是极端情况,发生的概率并不大。为了更有效的表示平均情况下的时间复杂度,引入另一个概念:平均情况时间复杂度,全称叫加权平均时间复杂度或者期望时间复杂度。(引入各自情况发生的概率再具体分析)
多数情况下,我们不需要区分最好、最坏、平均情况时间复杂度。只有同一块代码在不同情况下时间复杂度有量级差距,我们才会区分3种情况,为的是更有效的描述代码的时间复杂度。
均摊时间复杂度
应用场景:均摊时间复杂度和摊还分析应用场景较为特殊,对一个数据进行连续操作,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度较高。而这组操作其存在前后连贯的时序关系。
计算:我们将这一组操作放在一起分析,将高复杂度均摊到其余低复杂度上,所以一般均摊时间复杂度就等于最好情况时间复杂度。
举例 : 有一个长度为n的数组,如果数组没满,就往里插入一个数,如果数组满了,就遍历求和.那么绝大多数情况下都是O(1),只有最后一次是O(n),均摊以后就是O(1)。
// array 表示一个长度为 n 的数组
// 代码中的 array.length 就等于 n
int[] array = new int[n];
int count = 0;
void insert(int val) {
if (count == array.length) {
int sum = 0;
for (int i = 0; i < array.length; ++i) {
sum = sum + array[i];
}
array[0] = sum;
count = 1;
}
array[count] = val;
++count;
}
一、插入排序
通过构建有序序列,对于未排序的序列,在已排序的序列中从后向前扫描,找到相应位置插入。
1)从第一个元素开始,该元素可以被认为已经被排序。
2)取出下一个元素,在已经排序的元素序列中从后向前扫描。
3)如果该元素(已排序)大于新元素,将该元素移到下一位置。
4)重复步骤3),直到找到已排序的元素小于或者等于新元素的位置。
5)将新元素插入到该位置后。
6)重复步骤2)~5)。
def InsertSort(myList):
#获取列表长度
length = len(myList)
for i in range(1,length):
#设置当前值前一个元素的标识
j = i - 1
temp = myList[i]
#继续往前寻找,如果有比临时变量大的数字,则后移一位,直到找到比临时变量小的元素或者达到列表第一个元素
while j>=0 and myList[j] > temp:
myList[j+1] = myList[j]
j = j-1
#将临时变量赋值给合适位置
myList[j+1] = temp
myList = [49,38,65,97,76,13,27,49]
InsertSort(myList)
print(myList)
二、冒泡排序
1)比较相邻的元素,如果第一个比第二个大,就交换他们两个。
2)对每一对相邻元素做同样的工作,从开始第一对到结尾最后一对。这步做完后,最后的元素会是最大的元素。
3)持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
def bubble_sort(lists):
# 冒泡排序
count = len(lists)
for i in range(0, count - 1):
for j in range(0, count - 1 - i):
if lists[j] > lists[j + 1]:#if语句的判断是包含在时间复杂度的计算中。
a = lists[j]
lists[j] = lists[j+1]
lists[j+1] = a
return lists
myList = [49,38,65,97,76,13,27,49]
bubble_sort(myList)
print(myList)
优化算法(在最优算法下冒泡排序的最优时间复杂度为O(n))
def bubble_sort(items):
for i in range(len(items) - 1):
flag = False
for j in range(len(items) - 1 - i):
if items[j] > items[j + 1]:
items[j], items[j + 1] = items[j + 1], items[j]
flag = True
if not flag:
break
return items
三、选择排序
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
import sys
def select_sort(lists):
for i in range(len(lists)):
min_idx = i
for j in range(i+1, len(lists)):
if lists[min_idx] > lists[j]: #if语句的判断是包含在时间复杂度的计算中。
min_idx = j
lists[i], lists[min_idx] = lists[min_idx], lists[i]
return lists
四、希尔排序
利用插入排序的简单,克服插入排序每次只能交换相邻两个元素的缺点
排序思想:
1) 定义增量序列DM > DM-1 > … > D1 = 1
2)对每个DK进行“ DK ”间隔排序(k = M , M-1 , … 1)
原始希尔排序:
注:“DK”间隔有序的数列,在执行“DK-1”间隔排序后,仍然保持“DK”间隔有序的。增量元素不互质,小增量可能在后面的排序过程中不起作用。
Hibbard 增量序列 – DK = 2k - 1(保证了增量元素不互质) , 最坏情况下的时间复杂度为O(N3/2).
def shell_sort(alist):
"""希尔排序"""
n = len(alist)
gap = n // 2
while gap >= 1:
for j in range(gap, n):
i = j
while (i - gap) >= 0:
if alist[i] < alist[i - gap]:
alist[i], alist[i - gap] = alist[i - gap], alist[i]
i -= gap
else:
break
gap //= 2
if __name__ == '__main__':
alist = [54, 26, 93, 17, 77, 31, 44, 55, 20]
print("原列表为:%s" % alist)
shell_sort(alist)
print("新列表为:%s" % alist)
五、堆排序
堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。
堆排序基本思路:
1)将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
2)将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
3)重新调整结构,使剩余元素满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
图解见链接:https://www.cnblogs.com/chengxiao/p/6129630.html
注:
a. 完全二叉树:对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。
b.堆的定义:
1)任意节点小于(或大于)它的所有后裔,最小元(或最大元)在堆的根上(堆序性)。
2)堆总是一棵完全树。即除了最底层,其他层的节点都被元素填满,且最底层尽可能地从左到右填入。
c.时间复杂度的推算:主要在初始化堆过程和每次选取最大数后重新建堆的过程。利用等比数列求和公式计算初始化建堆过程时间为O(n)。更改堆元素后重建堆时间为O(nlogn),所以总的时间复杂度为O(nlogn)。 因为堆排序是就地排序,空间复杂度为常数:O(1)。
#_*_coding:utf-8_*_
import time,random
def sift_down(arr, node, end):
root = node
#print(root,2*root+1,end)
while True:
# 从root开始对最大堆调整
child = 2 * root +1 #left child
if child > end:
#print('break',)
break
#print("v:",root,arr[root],child,arr[child])
#print(arr)
# 找出两个child中较大的一个
if child + 1 <= end and arr[child] < arr[child + 1]: #如果左边小于右边
child += 1 #设置右边为大
if arr[root] < arr[child]:
# 最大堆小于较大的child, 交换顺序
tmp = arr[root]
arr[root] = arr[child]
arr[child]= tmp
# 正在调整的节点设置为root
#print("less1:", arr[root],arr[child],root,child)
root = child #
#[3, 4, 7, 8, 9, 11, 13, 15, 16, 21, 22, 29]
#print("less2:", arr[root],arr[child],root,child)
else:
# 无需调整的时候, 退出
break
#print(arr)
print('-------------')
def heap_sort(arr):
# 从最后一个有子节点的孩子开始调整最大堆
first = len(arr) // 2 -1
#生成最大堆
for i in range(first, -1, -1):
sift_down(arr, i, len(arr) - 1)
#[29, 22, 16, 9, 15, 21, 3, 13, 8, 7, 4, 11]
print('--------end---',arr)
# 将最大的放到堆的最后一个, 堆-1, 继续调整排序
for end in range(len(arr) -1, 0, -1):
arr[0], arr[end] = arr[end], arr[0]
sift_down(arr, 0, end - 1)
#print(arr)
def main():
# [7, 95, 73, 65, 60, 77, 28, 62, 43]
# [3, 1, 4, 9, 6, 7, 5, 8, 2, 10]
#l = [3, 1, 4, 9, 6, 7, 5, 8, 2, 10]
#l = [16,9,21,13,4,11,3,22,8,7,15,27,0]
array = [16,9,21,13,4,11,3,22,8,7,15,29]
#array = []
#for i in range(2,5000):
# #print(i)
# array.append(random.randrange(1,i))
print(array)
start_t = time.time()
heap_sort(array)
end_t = time.time()
print("cost:",end_t -start_t)
print(array)
#print(l)
#heap_sort(l)
#print(l)
if __name__ == "__main__":
main()
六、快速排序
-
快速排序采用了一种分治的策略,通常称其为分治法。分治法的基本思想是:将原问题分解为若干个规模更小但结构与原问题相似的子问题。递归地解这些子问题,然后将这些子问题的解组合为原问题的解。
-
利用分治法可将快速排序分为三步:
1)在数据集之中,选择一个元素作为”基准”(pivot)。
2)所有小于”基准”的元素,都移到”基准”的左边;所有大于”基准”的元素,都移到”基准”的右边。这个操作称为分区 (partition) 操作,分区操作结束后,基准元素所处的位置就是最终排序后它的位置。
3)对”基准”左边和右边的两个子集,不断重复第一步和第二步,直到索引low,high之间的元素只剩下一个为止。 -
递归算法的时间复杂度求法:代入法(代入法首先要对这个问题的时间复杂度做出预测,然后将预测带入原来的递归方程,如果没有出现矛盾,则是可能的解,最后用数学归纳法证明),迭代法, 差分方程法。
-
递归算法的时间复杂度:
a.在最优情况下,Partition每次都划分得很均匀,如果排序n个关键字,快速排序算法的时间复杂度为O(nlogn)。
-
借助递归树求解递归算法的时间复杂度(归并排序,快速排序, 斐波那契数列, 全排列):https://www.jianshu.com/p/6fa5a8ddd65f
def quick_sort_standord(array,low,high):
''' realize from book "data struct" of author 严蔚敏
'''
if low < high:
key_index = partion(array,low,high)
quick_sort_standord(array,low,key_index)
quick_sort_standord(array,key_index+1,high)
def partion(array,low,high):
key = array[low]#选择一个元素作为”基准”
while low < high:
while low < high and array[high] >= key:
high -= 1
if low < high:
array[low] = array[high]
while low < high and array[low] < key:
low += 1
if low < high:
array[high] = array[low]
array[low] = key
return low
if __name__ == '__main__':
array2 = [9,3,2,1,4,6,7,0,5]
print(array2)
quick_sort_standord(array2,0,len(array2)-1)
print(array2)
七、归并排序
a. 采用分治法
分割:递归地把当前序列平均分割成两半。
归并:在保持元素顺序的同时将上一步得到的子序列归并到一起。
b. 具体归并操作:递归法(Top-down)
1)申请空间s,该空间用来存放合并后的序列。
2)比较两个排序序列起始位置指向的数,将较小的元素删除(pop)并添加到s中(append)。
3)重复步骤2直到其中一序列为空。
4)将另一序列剩下的所有元素直接复制到合并序列尾。
c.利用递归树方便求得时间复杂度为O(nlogn)
https://zh.wikipedia.org/wiki/归并排序#Python
# Recursively implementation of Merge Sort
#归并
def merge(left, right):
result = []
while left and right:
if left[0] <= right[0]:
result.append(left.pop(0))
else:
result.append(right.pop(0))
if left:
result += left
if right:
result += right
return result
#划分
def merge_sort(L):
if len(L) <= 1:
# When D&C to 1 element, just return it
return L
mid = len(L) // 2
left = L[:mid]
right = L[mid:]
left = merge_sort(left)
right = merge_sort(right)
# conquer sub-problem recursively
return merge(left, right)
# return the answer of sub-problem
if __name__ == "__main__":
test = [1, 4, 2, 3.6, -1, 0, 25, -34, 8, 9, 1, 0]
print("original:", test)
print("Sorted:", merge_sort(test))
八、桶排序
桶排序(Bucket sort)或所谓的箱排序,是一个排序算法,工作的原理是将数组分到有限数量的桶bucket里,桶的数量是数组中的最大数,遍历数组,将元素值作为索引并将bucke对应的值加一,最后按索引顺序取出bucket中对应元素大于1的索引即得到最后的有序序列。
def bucket_sort(array):
if not array:
return False
max_len = max(array)+1
book = [0 for x in range(0,max_len)]
for i in array:
book[i] += 1
return [i for i in range(0,max_len) for j in range(0,book[i])]
def main():
array = [5,4,4,74,90,2]
array = bucket_sort(array)
print(array)
if __name__ == '__main__':
main()
缺点:无法排负数,无法排小数,book所占的空间由输入数组的最大值确定
针对存在负数的情况
def bucket_sort(array):
if not array:
return False
offset = min(array)
max_len = max(array) - offset + 1
book = [0 for x in range(0,max_len)]
for i in array:
book[i - offset] += 1
return [i + offset for i in range(0,max_len) for j in range(0,book[i])]
def main():
array = [5,4,4,-2,-9,74,90,2]
array = bucket_sort(array)
print(array)
if __name__ == '__main__':
main()
针对存在小数与负数的情况
# 可对小数排序
def bucket_sort(array):
if not array:
return False
# 保留两位小数
accuracy = 100.
offset = int(min(array) * accuracy)
max_len = int(max(array) * accuracy - offset + 1)
book = [0 for x in range(0,max_len)]
for i in array:
book[int(i * accuracy - offset)] += 1
return [(i + offset) / accuracy for i in range(0,max_len) for j in range(0,book[i])]
def main():
array = [5,4,4,-2,-9,74,90,2]
array = bucket_sort(array)
print(array)
if __name__ == '__main__':
main()
sort(),sorted()
底层实现就是归并排序,速度比我们自己写的归并排序要快很多(10~20倍),所以说我们一般排序都尽量使用sorted和sort。最坏与平均情况下的时间复杂度为O(nlogn)。
注:
- 快排对越混乱的数据,排序效果越好,对一个基本有序的序列排序却更复杂(它要交换很多次才能排好)。因为这样会导致每次轴划分出的两个子序列,一个趋近于1的数量级,一个趋近于n数量级,那么递归快排就近似总是对n做排序,时间复杂度O(n²),而且非常不符合快排的思想。比较好的情况是每次递归大致平分成两个n/2数量级的子序列,时间复杂度O(nlogn)。
- 对基本有序的序列比较适合适用冒泡排序。
- 若n较小(如n≤50),可采用直接插入或直接选择排序。当记录规模较小时,直接插入排序较好;否则因为直接选择移动的记录数少于直接插入,应选直接选择排序为宜。
- 若文件初始状态基本有序(指正序),则应选用直接插人、冒泡或随机的快速排序为宜。
- 若n较大,则应采用时间复杂度为O(nlgn)的排序方法:快速排序、堆排序或归并排序。
- 稳定性判断:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
- 有大量重复元素时使用三分快排:
提出的算法是: 对于每次切分:从数组的左边到右边遍历一次,维护三个指针lt,gt,i,其中lt指针使得元素(arr[0]-arr[lt-1])的值均小于切分元素;gt指针使得元素(arr[gt+1]-arr[N-1])的值均大于切分元素;i指针使得元素(arr[lt]-arr[i-1])的值均等于切分元素,(arr[i]-arr[gt])的元素还没被扫描,切分算法执行到i>gt为止。
每次切分之后,位于gt指针和lt指针之间的元素的位置都已经被排定,不需要再去移动了。之后将(lo,lt-1),(gt+1,hi)分别作为处理左子数组和右子数组的递归函数的参数传入,递归结束,整个算法也就结束。
- 排序算法动态演示:https://www.cnblogs.com/onepixel/p/7674659.html
- TOP k的解法:
a) 用堆排来解决Top K :先建立一个包含K个元素的大顶堆,然后遍历集合,如果集合的元素比堆顶元素小(说明它目前应该在K个最小之列),那就用该元素来替换堆顶元素,同时维护该堆的性质,那在遍历结束的时候,堆中包含的K个元素是不是就是我们要找的最小的K个元素。
速记: 最小的K个用最大堆,最大的K个用最小堆。
堆排时间复杂度为n*logK。
速记: 堆排的时间复杂度是nlogn,这里相当于只对前Top K个元素建堆排序,想法不一定对,但一定有助于记忆。
不会占用太多的内存空间(事实上,一次只读入一个数,内存只要求能容纳前K个数即可)。这也决定了它特别适合处理海量数据。
b) 用快速排序来解决Top K:我们知道,分治函数会返回一个position,在position左边的数都比第position个数小,在position右边的数都比第position大。我们不妨不断调用分治函数,直到它输出的position = K-1,此时position前面的K个数(0到K-1)就是要找的前K个数。
快排时间复杂度为n。
既然要交换元素的位置,那么所有元素必须要读到内存空间中,所以它会占用比较大的空间,至少能容纳整个数组;数据越多,占用的空间必然越大,海量数据处理起来相对吃力。