算法导论之——排序

排序

1、初级排序算法

1.1、选择排序

基本思想:首先,找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置(如果第一个元素就是最小的元素那么它就和自己交换)。再次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此往复,直到整个数组排序。它在不断地选择剩余元素之中的最小者

def selection_sort(nums):   #选择排序
    #将 nums 中的元素按升序排序
    N = len(nums)       #列表长度
    for i in range(N):#将 nums[i] 和 nums[i+1...N] 中最小的元素交换
        min = i     #最小元素的索引
        for j in range(i+1,N):
            if nums[j] < nums[min]:
                min = j
        nums[i],nums[min] = nums[min],nums[i]       #交换位置

选择排序的内循环只是在比较当前元素与目前已知的最小元素(以及将当前索引加 1 和检查是否代码越界)。交换元素在内循环之外,每次交换能够排定一个元素,因此交换的总次数为 N所以算法的时间效率取决于比较的次数

命题A 对于长度为 N 的数组,选择排序需要大约 N^2/2 次比较和 N 次交换

证明 0 到 N-1 的任意 i 都会进行一次交换和 N-1-i 次比较,因此总共有 N 次交换以及 (N-1)+(N-2)+…+2+1 = N(N-1)/2 ~ N^2/2 次比较

总的来说,选择排序是一种容易理解和实现的简单排序算法,它有两个很明显的特点:

  1. 运行时间和输入无关(和数列的初始状态无关)。为了找出最小的元素而扫描一遍数组并不能为下一遍扫描提供什么信息。一个已经有序的数组或是主键全部相等的数组和一个元素随机排序的数组所用的排序时间一样长!
  2. 数据移动是最少的。每次交换都会改变两个数组元素的值,因此选择排序用了 N 次交换——交换次数和数组的大小是线性关系

1.2、插入排序

为了给要插入的元素腾出空间,我们需要将其余所有元素在插入之前都向右移动一位。这种做法叫插入排序。

与选择排序一样,当前索引左边的所有元素都是有序的,但它们的最终位置还不确定,为了给更小的元素腾出空间,它们可能会移动。但是索引到达数组的右端时,数组排序就完成了。和选择排序不同的是,插入排序所需的时间取决于输入中的元素的初始顺序(即数列的初始状态)。例如,对一个很大且其中的元素已经有序(或接近有序)的数组进行排序将会比随机顺序的数组或是逆序数组进行排序要快的多。

命题B 对于随机排列的长度为 N 且主键不重复的数组,平均情况下插入排序需要 ~N^2/4 次比较以及 ~N^2/4 次交换。最坏情况下需要 ~N^2/2 次比较和 ~ N^2/2 次交换,最好情况下需要 N-1 次比较和 0 次交换。

def insertion_sort(nums):
    for i in range(1,len(nums)):    #将 nums 按升序排序
        if nums[i - 1] > nums[i]:		#只有在前面一个数大于索引处的数时才开始循环
            for j in range(i,0,-1):     	#将 nums[i] 插入 nums[i-1]、nums[i-2]、...之中
                if nums[j] < nums[j-1]:
                    nums[j],nums[j-1] = nums[j-1],nums[j]

如果数组中倒置的数量小于数组大小的某个倍数,那么我们说这个数组是部分有序的。下面是几种典型的部分有序的数组:

  • 数组中每个元素距离它的最终位置都不远;
  • 一个有序的大数组接一个小数组;
  • 数组中只有几个元素的位置不正确。

插入排序度这样的数组很有效,而选择排序则不然。

命题C 插入排序需要的交换操作和数组中倒置的数量相同,需要的比较次数大于等于倒置的数量,小于等于倒置的数量加上数组的大小再减一。

要大幅提高排序的速度并不难,只需要在内循环中将较大的元素都向右移动而不总是交换两个元素(这样访问数组的次数就能减半):

def insertion_sort_plus(nums):
    for i in range(1,len(nums)):
        if nums[i-1] > nums[i]:
            temp = nums[i]  # 插入值
            j = i - 1  # 记录插入位置  j+1 代表插入的位置
            while j >= 0:  # 不用 for 循环是因为 j 为 -1 的情况
                if nums[j] > temp:
                    nums[j + 1] = nums[j]
                else:
                    break
                j -= 1
            nums[j + 1] = temp

插入排序对于部分有序的数组十分高效,也很适合小规模数组

1.2.1、比较两种随机算法

性质D 对于随机排序的无重复主键的数组,插入排序和选择排序的运行时间是平方级的,两者之比应该是一个较小的常数。

1.3、希尔排序

对于大规模乱序数组插入排序很慢,因为它只会交换相邻的元素,因此元素只能一点一点地从数组的一端移动到另一端。希尔排序为了加快速度简单地改进了插入排序,交换不相邻的元素以对数组的局部进行排序,并最终用插入排序将局部有序的数组排序

希尔排序的思想是使数组中任意间隔为 h 的元素都是有序的。这样的数组被称为 h 有序数组。话句话说,一个 h 有序数组就是 h 个互相独立的有序数组编织在一起组成的一个数组。

希尔排序的实现就转换成了一个类似于插入排序但使用不同增量的过程。希尔排序的高效性的原因是它权衡了子数组的规模和有序性。同样,它也是唯一一个无法准确描述其对于乱序的数组的性能特性的排序方法

def Shell_sort(nums):   #将 nums 按照升序排序
    N = len(nums)
    h = 1
    while(h < N/3):
        h = 3*h + 1
    while(h >= 1):  #将数组变为 h 有序
        for i in range(h,N):    #将 nums[i] 插入到 nums[i-h]、nums[i-2*h]... 之中
            j = i
            while(j >= h and nums[j] > nums[j-h]):
                nums[j],nums[j-h] = nums[j-h],nums[j]
                j -= h
        h = h//3

希尔排序比插入排序和选择排序要快得多,并且数组越大,优势越大。希尔排序的算法,它的运行时间达不到平方级别。

一个重要理念:通过提升速度来解决其他方式无法解决的问题是研究算法的设计和性能的主要原因之一。

性质E 使用递增序列 1,4,13,40,121,364… 的希尔排序所需的比较次数不会超过 N 的若干倍乘以递增序列的长度。


2、归并排序

要将一个数组排序,可以先(递归地)将它分为两半分别排序,然后将结果归并起来。归并排序最吸引人的性质的它能保证将任意长度为 N 的数组排序所需时间和 NlogN 成正比;它的主要缺点是它所需的额外空间和 N 成正比

2.1、原地归并排序的抽象方法

注意:此算法适用于以 mid 为界的两个有序子数组!!!

def merge_sort(nums,lo,mid,hi): #将 nums[lo..mid] 和 nums[mid+1..hi] 归并
    i = lo
    j = mid + 1
    aux = [0]*(hi-lo+1)
    for k in range(lo,hi+1): #将 nums[lo..hi] 复制到 aux[lo..hi] 之中
        aux[k] = nums[k]
    for k in range(lo,hi+1):    #归并到 nums[lo..hi]
        if i > mid:
            nums[k] = aux[j]
            j += 1
        elif j > hi:
            nums[k] = aux[i]
            i += 1
        elif aux[j] <= aux[i]:
            nums[k] = aux[j]
            j += 1
        elif aux[j] > aux[i]:
            nums[k] = aux[i]
            i += 1
#参数 mid 是两个有序子数组的分界线,并不是数组长度的中间位置            

该方法先将所有的元素复制到 aux[ ] 中,然后再归并回 nums[] 中。方法在归并时(第二个 for 循环)进行了 4 次条件判断:左半边用尽(取右半边元素)、右半边用尽(取左半边元素)、右半边的当前元素小于左半边的当前元素(取右半边的元素)以及右半边的当前元素大于等于左半边的当前元素(去左半边的元素)

2.2、自顶向下的归并排序

以下的算法是基于原地归并的抽象实现了另一种递归归并,这也是应用高效算法设计中分治思想的最典型的一个例子。这段递归代码是归纳证明算法能够正确地将数组排序的基础:如果它能将两个子数组排序,它就能够通过归并两个子数组来将整个数组排序。

def Merge_sort_UpToDown(nums):
    # aux = [0]*len(nums)
    Merger_sort_UpToDown(nums,0,len(nums)-1)

def Merger_sort_UpToDown(nums,lo,hi):   #将数组 nums[lo..hi] 排序
    if hi <= lo:
        return
    mid = lo + (hi - lo)//2
    Merger_sort_UpToDown(nums,lo,mid)
    Merger_sort_UpToDown(nums,mid+1,hi)
    merge_sort(nums,lo,mid,hi)		#调用上面的函数

要对子数组 a[lo…hi] 进行排序,先将它分为 a[lo…mid] 和 a[mid+1…hi] 两部分,分别通过递归调用将它们单独排序,最后将有序的子数组归并为最终的排序结果

命题F 对于长度为 N 的任意数组,自顶向下的归并排序需要 1/2 NlgN 至 NlgN 此比较。

命题G 对于长度为 N 的任意数组,自顶向下的归并排序最多访问数组 6NlgN 次。

命题 F 和命题 G 告诉我们归并排序所需的时间和 NlgN 成正比它表明我们只需要比遍历整个数组多个对数因子的时间就能够将一个庞大的数组排序,可以用归并排序处理数百万甚至更大规模的数组,这是插入排序或者选择排序做不到的。归并排序的主要缺点是辅助数组所使用的额外空间和 N 的大小成正比

2.3、自底向上的归并排序

实现归并排序的另一种方法是先归并那些微型数组,然后再成对归并得到的子数组,如此这般,知道我们将整个数组归并到一起。首先,我们进行的是两两归并(把每个元素想象成一个大小为 1 的数组),然后是四四归并(将两个大小为 2 的数组归并成一个有 4 个元素的数组),然后是八八归并,一直下去。

#java 伪代码
public static void Merge_sort_DownToUp(Comparable[] a)
{
    
    
	int N = a.length;
	aux = new Comparable[N];
	for (int sz = 1; sz < N; sz = sz+sz)	//sz 子数组大小
		for (int lo = 0;lo < N-sz; lo += sz+sz)		//lo:子数组索引
            merge_sort(a,lo,lo+sz-1,Math.min(lo+sz+sz-1,N-1));
}
def Merge_sort_DownToUp(nums):
    N = len(nums)
    # aux = [0]*N
    sz = 1
    while sz < N:
        lo = 0
        while lo < N - sz:
            merge_sort(nums,lo,lo+sz-1,min(lo+sz+sz-1,N-1))
            lo += sz+sz
        sz = sz+sz

自底向上的归并排序会多次遍历整个数组,根据子数组大小进行两两归并。子数组的大小 sz 的初始值为 1,每次加倍。最后一个子数组的大小只有在数组大小为 sz 的偶数倍的时候才会等于 sz(否则它会比 sz 小)

命题H 对于长度为 N 的任意数组,自底向上的归并排序需要 1/2NlgN 至 NlgN 次比较,最多访问数组 6NlgN 次。

2.4、排序算法的复杂度

研究复杂度的第一步是建立一个计算模型。

命题I 没有任何基于比较的算法能够保证使用少于 lg(N!) ~ NlgN 次比较将长度为 N 的数组排序。


3、快速排序

快速排序流行的原因是它实现简单、适用于各种不同的输入数据且在一般应用中比其他排序算法都要块得多。快速排序引人注目的特点包括它是原地排序(只需要一个很小的辅助栈),且将长度为 N 的数组排序所需的时间和 NlgN 成正比。另外,快速排序的内循环比大多数排序算法都要短小,这意味着它无论是在理论上还是在实际中都要更快。它的主要缺点是非常脆弱,在实现时非常小心才能避免低劣的性能

3.1、基本算法

快速排序是一种分治的排序算法。它将一个数组分成两个子数组,将两部分独立地排序。快速排序和归并排序是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序而快速排序将数组排序的方式则是当两个子数组都有序时整个数组也就自然有序了。在第一种情况中,递归调用发生在处理整个数组之前;在第二种情况中,递归调用发生在处理整个数组之后。在归并排序中,一个数组被等分为两半;在快速排序中,切分(partition)的位置取决于数组的内容。

def Quick_sort(nums):
    quick_sort(nums,0,len(nums)-1)

def quick_sort(nums,lo,hi):
    if hi <= lo:
        return
    j = partition(nums,lo,hi)
    quick_sort(nums,lo,j-1)		#将左半部分进行排序
    quick_sort(nums,j+1,hi)		#将右半部分进行排序

快速排序递归地将子数组 a[lo…hi] 排序,先用 partition() 方法将 a[j] 放到一个合适位置,然后再用递归调用将其他位置的元素排序。

该方法的关键在于切分,这个过程使得数组满足下面的三个条件:

  1. 对于某个 j,a[j] 已经排序;
  2. a[lo] 到 a[j-1] 中的所有元素都不大于 a[j];
  3. a[j+1] 到 a[hi] 中的所有元素都不小于 a[j]。

我们就是递归地调用切分来排序的。

def partition(nums,lo,hi):
    v = nums[lo]    #切分元素
    while lo < hi:
        while lo < hi and nums[hi] >= v:
            hi -= 1
        if lo < hi:
            nums[lo] = nums[hi]
            lo += 1
        while lo < hi and nums[lo] <= v:
            lo += 1
        if lo < hi:
            nums[hi] = nums[lo]
            hi -= 1
    nums[lo] = v
    return lo

这段代码按照 a[lo] 的值 v 进行切分。当指针 lo 和 hi 相遇时主循环退出。在循环中,a[lo] 小于 v 时我们增大 lo,a[hi] 大于 v 时我们减小 hi,然后交换 a[lo] 和 a[hi] 来保证 lo 左侧的元素都不大于 v,hi 右侧的元素都不小于 v。当指针相遇时交换 a[lo] 和 a[hi],切分结束(这样切分值就留在 a[j] 中了)。

3.2、一些问题

  • 原地切分

    如果使用一个辅助数组,我们很容易实现切分,但将切分后的数组复制回去的开销也许会使我们得不偿失。如果将空数组创建在递归的切分方法中,这会大大降低排序的速度。

  • 别越界

    如果切分的元素是数组中最小或最大的那个元素,我们就要小心别让扫描指针跑出数组的边界。partition() 实现可进行明确地检测来预防这种情况。测试条件 (j == lo) 是多余的,因为切分元素就是 a[lo],它不可能比自己小。

  • 保护随机性

    数组元素的顺序是被打乱过的。子数组也都是随机排序的,这对于预测算法的运行时间很重要。保持随机性的另一种方法是在 partition() 中随机选择一个切分元素。

  • 终止循环

    保证循环结束需要格外小心。快速排序的切分循环也不例外。正确地检测指针是否越界需要一点技巧。一个常见的错误就是没有考虑到数组中可能包含和切分元素的值相同的其他元素。

  • 处理切分元素值有重复的情况

    左侧扫描最好是在遇到大于或等于切分元素值的元素时停下,右侧扫描则是遇到小于或等于切分元素值的元素时停下。尽管这样可能会不必要地将一些等值的元素交换,但在某些典型应用中,它能够避免算法的运行时间变成平方级别的。

  • 终止循环

    保证递归总是能够结束也是需要小心的。实现快速排序时一个常见的错误就是不能保证将切分的元素放在正确的位置,从而导致程序在切分元素正好是子数组的最大或是最小元素时陷入无限的递归循环之中。

命题K 将长度为 N 的无重复数组排序,快速排序平均需要 ~2NlnN 次比较(以及 1/6 的交换)。

尽管快速排序有很多优点,它的基本实现仍然有一个潜在的缺点:在切分不平衡时这个程序可能会极为低效。例如,如果第一次从最小的元素切分,第二次从第二小的元素切分,如此这般,每次调用只会移除一个元素。这会导致一个大子数组需要切分很多次。

命题L 快速排序最多需要约 N^2/2 次比较,但随机打乱数组能够预防这种情况。

3.3、算法的改进

3.3.1、切换到插入排序

和大多数递归排序算法一样,改进快速排序性能的一个简单办法基于以下两点:

  • 对于小数组,快速排序比插入排序慢;
  • 因为递归,快速排序的 sort() 方法在小数组中也会调用自己。

因此,在排序小数组时应该切换到插入排序:

def quick_sort(nums,lo,hi):
    if hi <= lo + M:	#转换参数 M 的最佳值是和系统相关的,但是 5 ~ 15 之间的任意值在大多数情况下都能让人满意
        insertion_sort(nums)
    j = partition(nums,lo,hi)
    quick_sort(nums,lo,j-1)		#将左半部分进行排序
    quick_sort(nums,j+1,hi)		#将右半部分进行排序

3.3.2、三取样切分

改进快速排序性能的第二个办法是使用子数组的一小部分元素的中位数来切分数组。但代价是需要计算中位数。人们发现将取样大小设为 3 并用大小居中的元素切分的效果最好。我们还可以将取样元素放在数组末尾作为 “哨兵” 来去掉 partition() 中的数组边界测试。

3.3.3、熵最优的排序

实际应用中经常会出现含有大量重复元素的数组。在有大量重复元素的情况下,快速排序的递归性会使元素全部重复的子数组经常出现,这就有很大的改进潜力,将当前实现的线性对数级的性能提高到线性级别。

一个简单的想法是将数组切分为三部分,分别对应小于、等于和大于切分元素的数组元素。这种切分实现起来比我们目前使用的二分法更复杂。

def Quick_sort_plus(nums,lo,hi):
    if hi <= lo:
        return
    lt = lo
    i = lo + 1
    gt = hi
    v = nums[lo]
    while i <= gt:
        if nums[i] < v:
            nums[lt],nums[i] = nums[i],nums[lt]
            lt += 1
            i += 1
        elif nums[i] > v:
            nums[i],nums[gt] = nums[gt],nums[i]
            gt -= 1
        else:
            i += 1
    # 现在 nums[lo..hi-1] < v = nums[lt..gt] < nums[gt+1..hi] 成立
    Quick_sort_plus(nums,lo,lt - 1)
    Quick_sort_plus(nums,gt + 1,hi)

命题M 不存在任何基于比较的排序算法能够保证在 NH-H 次比较之内将 N 个元素排序,其中 H 为由主键出现频率定义的香农信息量。

猜你喜欢

转载自blog.csdn.net/qq_36879493/article/details/108588708