希尔排序
希尔排序也被称为“递减增量排序”,它是在插入排序的基础上做了改进,将列表分成多个子列表,并对每一个子列表进行插入排序。希尔排序并不是连续切分,而是使用增量i并选取所有间隔为i的元素组成的子列表进行排序,这个i有时也被称作步长。
下面我们来举一个例子,比如当前列表中有9个元素,如果增量为3,那么就存在3个子列表,分别是[54、17、44], [26、77、55], [93、31、20]。如下图所示:
如果我们将每一个子列表进行插入排序就会得到以下结果:
第一个子列表由54、17、44排序为17、44、54
第二个子列表由26、77、55排序为26、55、77
第三个子列表由93、31、20排序为20、31、93
最终以步长为3的排序结果为17、26、20、44、55、31、54、77、93
由此,我们也就减少了最终排序的次数,最后一次使用插入排序得到最终结果:
所以,如何切分列表才是希尔排序的关键。
用Python实现希尔排序
我们首先为个子列表排序,然后为个子列表排序,最终整个列表由基本的插入排序排好序。下图展示了采用这种增量后的第一批子列表:
代码示例:
def shellSort(alist):
subListCount = len(alist) / / 2
while subListCount > 0:
for startPosition in range(subListCount):
gapInsertionSort(alist, startPosition, subListCount)
print(*After increments of size*, subListCount, *The list is*, alist)
subListCount = subListCount / / 2
def gapInsertionSort(alist, start, gap):
for i in range(start + gap, len(alist), gap):
currentValue = alist[i]
position = i
while position >= gap and alist[position - gap] > currentValue:
alist[position] = alist[position - gap]
position = position - gap
alist[position] = currentValue
>>> alist = [54, 26, 93, 17, 77, 31, 44, 55, 20]
>>> shellSort(alist)
After increments of size 4 the list is [20, 26, 44, 17, 54, 31, 93, 55, 77]
After increments of size 2 the list is [20, 17, 44, 26, 54, 31, 77, 55, 93]
After increments of size 1 the list is [17, 20, 26, 31, 44, 54, 55, 77, 93]
希尔排序和插入排序比起来最大的优势就是,希尔排序对列表已经做了预处理。所以在最后一步进行插入排序时就不需要进行多次比较和移动了。也就是说,在每一轮遍历时都使列表变得更加有序了,这使得最后一步就变得特别高效。
最后,希尔排序的时间复杂度介于O(n)和O(n²)之间。
归并排序
归并算法是通过分治策略来改进一种排序算法。它是一个递归算法,他会每次将一个列表一分为二。
如果一个列表是空或者只有一个元素的话,那么我们认为它是有序的;如果列表不止一个元素,那么就将列表一分为二,并对这两部分都分别递归调用归并排序;当两部分都变为有序后,就进行归并这一基本操作。归并指的是将两个较小的有序列表并为一个有序列表的过程。
以列表54、26、93、17、77、31、44、55、20为例,下图展示了列表被拆分后的情况:
下图展示了归并后的有序列表:
归并排序的过程:
用Python实现归并排序
如果列表的长度小于或等于1,就说明它已经是一个有序列表了,所以不需要做任何处理;如果长度大于1,那么就通过切片得到列表的左半部分和右半部分。
def mergeSort(alist):
print("Splitting ", alist) //此处用于展示每次调用开始前,待排序列表中的内容
if len(alist) > 1:
mid = len(alist) / / 2
leftHalf = alist[:mid]
rightHalf = alist[mid:]
mergeSort(leftHalf)
mergeSort(rightHalf)
// 以下代码负责将两个小的有序列表归并为一个大的有序列表
i = 0
j = 0
k = 0
while i < len(leftHalf) and j < len(rightHalf):
if leftHalf[i] < rihtHalf[j]:
alist[k] = leftHalf[i]
i = i + 1
else:
alist[k] = rightHalf[j]
j = j + 1
k = k + 1
while i < len(leftHalf):
alist[k] = leftHalf[i]
i = i + 1
k = k + 1
while j < len(rightHalf):
alist[k] = rightHalf[j]
j = j + 1
k = k + 1
print("Merging ", alist ) // 此处用于展示归并过程
>>> b = [54, 26, 93, 17, 77, 31, 44, 55, 20]
>>> mergeSort(b)
Splitting [54, 26, 93, 17, 77, 31, 44, 55, 20]
Splitting [54, 26, 93, 17] // 左半部分
Splitting [54, 26] // 左半部分的左半部分
Splitting [54]
Merging [54]
Splitting [26]
Merging [26]
Merging [26, 54] // 左半部分的左半部分排序完成
Splitting [93, 17] // 左半部分的右半部分
Splitting [93]
Merging [93]
Splitting [17]
Merging [17]
Merging [17, 93] // 左半部分的右半部分排序完成
Merging [17, 26, 54, 93] // 左半部分整体排序完成
Splitting [77, 31, 44, 55, 20] // 右半部分
Splitting [77, 31] // 右半部分的左半部分
Splitting [77]
Merging [77]
Splitting [31]
Merging [31]
Merging [31, 77] // 右半部分的左半部分排序完成
Splitting [44, 55, 20] // 右半部分的右半部分
// 这里需要注意的是:由于[44, 55, 20]不会均分,所以需要将[44, 55, 20]分为两部分,第一部分是[44],第二部分是[55, 20]
Splitting [44]
Merging [44] // 第一部分默认有序
Splitting [55, 20] // 第二部分
Splitting [55]
Merging [55]
Splitting [20]
Merging [20]
Merging [20, 55] // 第二部分排序完成
Merging [20, 44, 55] // 右半部分的右半部分排序完成
Merging [20, 31, 44, 55, 77] // 右半部分排序完成
Merging [17, 20, 26, 31, 44, 54, 55, 77, 93] // 整个数组排序完成
// 这里需要注意的一点是:由于mergeSort函数需要额外的空间来存储经过切片操作得到的两个部分,那么当列表较大时,使用额外的空间可能会使排序出现问题。
我们在二分搜索时已经知道,当列表的长度为n时,能切分log以2为底次。
列表中的每一个元素最终都会得到处理并放到有序列表中,所以得到长度为的列表就需要次操作。
由此可知,我们需要进行次拆分,且每一次都需要进行次操作,所以一共是次操作。
最终得到,归并算法的时间复杂度是O()。
快速排序
快速排序和归并排序一样,也采用分治策略,但是不使用额外的空间。但是列表不会被一分为二,算法的效率会有所下降。
快速排序会首先选出一个基准值。它的作用是帮助切分列表。在最终的有序列表中,基准值的正确所在位置通常被称作分割点,我们会在分割点的左右两侧分别切分出列表,继续快速排序操作。
现在我们有一个列表[54、26、93、17、77、31、44、55、20],我们将54作为第一个基准值:
下一步我们需要找到54的正确所在位置,这个位置也就是我们说的分割点。我们需要进行分区操作。通过分区操作,会找到分割点的位置,并将大于基准值的所有元素放在一边,小的放在另一边。
分区操作首先要找到两个坐标--leftmark和rightmark,他们分别位于列表剩余元素的开头和结尾。分区的目的是根据待排序的元素和基准值比较大小,并把他们放到正确的一边,同时逐渐逼近分割点。
下图展示了为54寻找正确位置的过程:
首先我们在剩余列表的开头向右找到一个比基准值大的元素作为leftmark,再从结尾向左找到一个比基准值小的值作为rightmark。也就是例子中的93和20,我们互换他们的位置,继续重复上述过程。
当rightmark小于leftmark时,过程终止。这时,rightmark的位置就是分割点。将基准值与分割点的元素互换,就找到了基准值的正确位置。
如下图所示,此时,分割点左侧所有的元素都小于基准值,右侧都大于基准值。因此我们可以在分割点处将列表一分为二,并针对左右两部分递归的调用快速排序函数。
快速排序的过程:
用Python实现快速排序
在下面的代码中,快速排序函数quickSort调用了递归函数quickSortHelper,quickSortHelper首先处理和归并排序相同的基本情况,也就是如果列表的长度小于或等于1,说明他已经是有序列表,如果大于1,则进行分区操作并递归地排序。这里的分区函数partition实现了前面描述的过程。
def quickSort(alist):
quickSortHelper(alist, 0, len(alist) - 1)
def quickSortHelper(alist, first, last):
if first > last:
splitPoint = partition(alist, first, last)
quickSortHelper(alist, first, splitPoint - 1)
quickSortHepler(alist, splitPoint + 1, last)
def partition(alist, first, last):
pivotValue = alist[first]
leftmark = first + 1
rightmark = last
done = False
while not done:
while leftmark <= rightmark and alist[leftmark] <= pivotValue:
leftmark = leftmark + 1
while alist[rightmark] >= pivotValue and rightmark >= leftmark:
rightmark = rightmark - 1
if rightmark < leftmark:
done = True
else:
temp = alist[leftmark]
alist[leftmark] = alist[rightmark]
alist[rightmark] = temp
temp = alist[first]
alist[first] = alist[rightmark]
alist[rightmark] = temp
return rightmark
对于长度为n的列表,如果分区操作总是发生在列表的中部,那么就会切分次,为了找到分割点,个元素都要与基准值比较,所以时间复杂度是。
如果分割点不在中部而是偏向列表的某一端,那么含有n个元素的列表可能被分成一个不含元素的列表和一个含有n-1个元素的列表,然后含有n-1的列表可能会被分成不含元素的列表和一个含有n-2元素的列表,以此类推。所以会导致时间复杂度变为,因为还要加上递归的开销。
小结
1.无论列表是否有序,顺序搜索算法是时间复杂度都是。
2.对于有序列表来说,二分搜索算法在最坏情况下的时间复杂度是。
3.基于散列表的搜索算法可以达到常数阶。
4.冒泡排序、选择排序和插入排序都是算法。
5.希尔排序通过给子列表排序,改进了插入排序。它的时间复杂度介于和之间。
6.归并排序的时间复杂度是,但是归并过程需要用到额外的储存空间。
7.快速排序的时间复杂度是,但当分割点不靠近列表中部时会降到,它不需要使用额外的存储空间。