归并排序的原理
先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
分治思想,分治算法一般都是用递归来实现的。写递归代码的技巧就是:
1)分析递推公式
2)找到终止条件
3)将递推公式翻译成递归代码
递推公式
merge_sort(p...r) = merge(merge_sort(p...q), merge_sort(q...r))
终止条件
p >= r, 不用再继续分解
根据递推公式,转化成代码:
def merge_sort(data_list):
length = len(data_list)
merge_sort_c(data_list, 0, length)
def merge_sort_c(data_list, p, r):
if p+1 >= r: return
else:
q = (p+r)/2
merge_sort_c(data_list, p, q)
merge_sort_c(data_list, q, r)
merge(data_list, p, q, r)
def merge(data_list, p, q, r):
tmp = []
i = p
j = q
while i < q and j < r:
if data_list[i] <= data_list[j]:
tmp.append(data_list[i])
i += 1
else:
tmp.append(data_list[j])
j += 1
while i < q:
tmp.append(data_list[i])
i += 1
while j < r:
tmp.append(data_list[j])
j += 1
for tmp_index, index in enumerate(range(p, r)):
data_list[index] = tmp[tmp_index]
if __name__ == "__main__":
data_list = [38, 50, 63, 27, 89, 94, 11, 82, 9, 13]
print(data_list)
merge_sort(data_list)
print(data_list)
性能分析:
- 时间复杂度:归并排序不关心数组的初始状态,因此最好、最坏、平时时间复杂度都是一样的,为O(nlogn),关于专栏中是这样求解时间复杂度的,非常有学习的价值。
不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。
我们假设对 n 个元素进行归并排序需要的时间是 T(n),那分解成两个子数组排序的时间都是 T(n/2)。我们知道,merge() 函数合并两个有序子数组的时间复杂度是 O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:
T(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C。
T(n) = 2*T(n/2) + n; n>1
通过这个公式,如何来求解 T(n) 呢?还不够直观?那我们再进一步分解一下计算过程。
T(n) = 2*T(n/2) + n
= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
......
= 2^k * T(n/2^k) + k * n
......
通过这样一步一步分解推导,我们可以得到 T(n) = 2^k T(n/2^k)+kn。当 T(n/2^k)=T(1) 时,也就是 n/2^k=1,我们得到 k=log2n 。我们将 k 值代入上面的公式,得到 T(n)=Cn+nlog2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)。所以归并排序的时间复杂度是 O(nlogn)。
2、空间复杂度:O(n),因此它不是一个原地排序算法。递归代码的空间复杂度并不能像时间复杂度那样累加。尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
3、稳定性:稳定。我们对数组分成左右两部分,对于两边相同的值,我们可以选择将右部分的值归并后放在左边相同值的后面,因此它是稳定的排序算法。
使用哨兵优化性能
在上述 merge 函数中有三处使用了 while 循环,第一个 while 循环条件中还有两个范围判断语句,当数据量非常大时,这些过多的判断势必会影响算法的性能。
我们知道,在编程中可以借助哨兵来简单条件判断,从而可以写出 bug 更少的代码,进而优化性能。
上述中的 merge 函数主要目的主是合并两个有序数组,但是为了在比较的过程中防止越界,加入了 i < r 和 j < q 来防止左右部分越界,最后防止某部分有剩余元素从而多写了两个 while 循环。
其实在大多数情况下,越界的情况是非常少的,那么每一次循环对越界的检查也会浪费 CPU 资源,而哨兵就是优化条件判断的。
思考:
1、如果左右部分的最后一个元素都是最大且相等,那么当左边的元素循环结束时,右边也必定结束,这样只用一个 while 就可以搞定,而且只需要一个 i < r 就够了,节省一个条件判断。
2、范围比较 i < r 需要 cpu 对每个二进制位进行比较,如果换成不等于判断,只要一个二进制位不同,就可以得出结果,理论上也可以更快些。
使用哨兵的 merge 函数如下所示:
def merge2(data_list,p,r,q):
'''
利用哨兵的归并函数
例如 data_list[p:q] = [...,1,4,2,3,...]
part_left = [1,4]
part_right = [2,3]
归并后 data_list[p:q] = [...,1,2,3,4,...]
'''
part_left = [data_list[index] for index in range(p,r)] #临时数组保存左部分
part_right = [data_list[index] for index in range(r,q)] #临时数组保存右部分
#对左边部分或右边部分增加哨兵,哨兵为待排序数据的最大值如99999
max_value = 99999
part_left.append(max_value)
part_right.append(max_value)
i = 0
j = 0
k = p
# while i != r-p: # 也可以这样写,看自己喜好
while part_left[i] != max_value:
if part_left[i] <= part_right[j]:
data_list[k] = part_left[i]
i += 1
else:
data_list[k] = part_right[j]
j += 1
k +=1 #依次从左边部分和右边部分按顺序抽取数据
分别在左部分和右部分的最后加入最大值的哨兵,可以减化 merge 函数的编码,使用哨兵有以下三点优化:
1、减少了 while 的个数,简化了编码过程
2、减少了 while 循环的条件判断
3、将范围判断改为不等于判断
小结:分治是一种解决问题的处理思想,递归是一种编程技巧。哨兵是一种不错的编程技巧,使用它也可减少 bug,某种程度上,也提高了代码的性能。