排序算法一览

假设按非递减排序

一、插入排序
插入排序对于少量数据是有效的算法,该算法在原址上需要常数个额外空间。该算法的思想是,在已有顺序的基础上新加入一个元素,将该元素放至恰当的位置并移动其他元素。为保证插入排序是稳定的算法,只有当新加入的元素小于已有的元素时才进行交换。
伪代码:

Insertion_Sort(A)
    for j = 2 to A.length
        key = A[j]
        //将key插入到已经排序好的子数组A[1..j-1]中
        i = j-1
        while i > 0 and key < A[i]
            A[i+1] = A[i]
            i = i-1
        A[i+1] = key

代码正确性:
(1)循环开始前只有A[1],所以是已经排序好的子数组
(2)每一步循环,对于for循环,将A[j]放入到恰当的位置,使得A[1..j]保持是已经排序好的;对于while循环,开始前i=j-1,一旦key比A[i]小将A[i]向后移动一位,直到A[i]不小于key,将key放至A[i]后一位,即放至A[i+1]
(3)终止时j == A.length,已经遍历一遍数组,所以结束时数组已经有序。

时间复杂度为O(n*n),最佳时间复杂度为O(n),空间复杂度为O(n + 1)。

二、选择排序
选择排序的思想是,从待排序的数组中选取一个最小的,放至已经排序子数组的后面一位上。为了保证排序的稳定性,两个数相同时选择下标小的。
伪代码:

Selection_Sort(A)
    for j=1 to A.length-1
        for i = j+1 to A.length
            if A[i] < A[j]
                swap(A[i], A[j])

代码正确性:
(1)循环开始前可视为有一个长度为0的子数组,已经是排序好的
(2)每一步循环均挑选未排序的数组中最小的元素,放至已排序好的数组后面一位
(3)当j == A.length – 1之后,未排序的数只剩一个,即A[A.length],不用更换位置

平均时间复杂度为O(n * n),最佳时间复杂度也是O(n * n),空间复杂度为O(n + 1)。

三、冒泡排序
冒泡排序的思想是,比较连续的两个数的大小决定是否进行交换,每一轮的交换会将最大的数“冒”到下标最大的位置上,所以称为冒泡排序。
伪代码:

Bullet_Sort(A)
    for j=1 to A.length-1
        for i=1 to A.length-1-j
            if(A[i] > A[i+1])
                swap(A[i], A[i+1])

代码正确性:
(1)循环之前可视为有一个长度为0的数组(可视数组有0个值为正无穷的数),已经排序好
(2)外层的for循环,每一步将未排序数组中的最大元素交换到剩余数组的最大下标中,并放至已经排序好的数组前,则加入之后保持是排序好的数组;内层for循环保证了选出的是最大的值
(3)进行A.length – 1次之后,未排序数组只剩一个,且是最小的数,放至A[1],即完成了排序。

平均时间复杂度为O(n * n),最佳时间复杂度也是O(n * n),空间复杂度也是O(n + 1)。

以上三种排序均是稳定的。

四、归并排序
归并排序的思想是“分而治之”,即将一个大的数组分成小的数组,然后小的数组排序好之后再组合成一个大的数组。
伪代码:

Merge_Sort(A, p, r)
    if p < r
        q = (p+r)/2
        Merge_Sort(A, p, q)
        Merge_Sort(A, q+1, r)
        Merge(A, p, q, r)//合并过程

Merge(A, p, q, r)
// 将A[p..q]和A[q+1..r]合并为一个数组,需要额外空间
    n1 = q - p + 1
    n2 = r - q
    Let L[1..n+1] and R[1..n+2] be new arrays
    for i=1 to n1
        L[i] = A[p+i-1]
    for j=1 to n2
        L[j] = A[q + j]
    L[n1 + 1] = R[n2 + 1] = ∞ //无穷大,用来标注
    i = j = 1
    for k = p to r
        if L[i] <= R[j] //当L[i] == R[j] 时,因为L[i]的原始下标小,所以排前面
            A[k] = L[i]
            i = i+1
        else
            A[k] = R[j]
            j = j+1 

时间复杂度:O(n * lgn),空间复杂度O(2*n) = O(n)。分的过程分为lgn层,每层的并耗时n,故总时间复杂度为O(n * lgn),每层需要额外存储空间n+2,但是各层的并不同时进行,故总的额外空间为n+2。归并排序也是稳定的。

归并排序可以由递归改为迭代。

五、堆排序
(二叉)堆是一个数组A[1..n],它可以看成一个近似的完全二叉树,树上的每个节点对应数组里的每个元素。A.length通常是给出的数组元素的个数,但是A.heap_size表示有多少个对元素存放在数组中,A.heap_size <= A.length。
对于下标从1开始的数组A[1..n],节点i的父节点,左孩子和右孩子节点的下标很容易求得:parent(i) = i/2,left(i) = i * 2, right(i) = i * 2 + 1。(注意范围就好)
用于排序的堆有最大堆和最小堆,由于本篇讨论的是非递减,所以用最大堆。最大堆是指除了根节点之外所有节点元素的值不大于其父亲节点,即A[parent(i)] >= A[i]。

堆排序的思想是:保持最大堆的性质,每次将根节点与下标为A.heap_size的节点交换(A.heap_size的值会递减)这样堆中最大的元素放至了已经排序好的数组的前一个位置,交换之后让其余元素组成的堆再回复成最大堆。重复进行,直至堆中元素个数为1,排序完成。
所以堆排序分为两个主要部分,(1)堆排序的框架主体,(2)构建最大堆。

从低向上实现,构建最大堆:
(a)首先假设数组A[1..n]的一部分满足最大堆的性质,即存在一个整数Q,1 < Q <= n,当Q <= i <= n时,A[i] >= A[left(i)] 且A[i] >= A[right(i)],然后考虑元素A[Q-1],若A[Q-1]比他的左孩子元素大也比他的右孩子元素大,则A[Q-1..n]也满足最大堆的性质;否则,令j = Q-1,并将A[j]与A[left(j)],A[right(j)]中的最大值A[largest]交换,并令j=largest,递归此过程直到j为叶子节点(即left(j) > A.heap_size)或者j比其孩子节点元素都大。该过程是维护最大堆性质的重要过程,称为Max_heapify。
(b)构建最大堆即是反复调用Max_heapify的过程,从第一个非叶节点开始,downto 根节点,此过程称为Build_max_heap。
(c)现在是关键一步,即排序过程。首先是建堆,(此时A.heap_size == A.length)然后运行A.length-1次下面的过程:将根元素A[1]与堆中下标最大的元素A[A.heap_size]交换,并将A.heap_size自减1(注意,这与Max_heapify中的A.heap_size同步),然后维护最大堆性质(根据步骤a可知,此时A[2..A.heap_size]中任意元素已经满足了最大堆性质,因此只需处理根节点即可)

伪代码:

//假设A可以访问A.heap_size(堆的大小), A.length(原始数组大小)
Max_heapify(A, i)
    l = left(i)
    r = right(i)
    if l <= A.heap_size and A[i] < A[l]
        largest = l
    else largest = i
    if r <= A.heap_size and A[largest] < A[r]
        largest = r
    if i != largest
        swap(A[i], A[largest])
        Max_heapify(A, largest)

Build_max_heap(A)
    A.heap_size = A.length
    for i = A.length/2 downto 1
        Max_heapify(A, i)

Heap_Sort(A)
    Build_max_heap(A)
    while A.heap_size > 1
        swap(A[1], A[A.heap_size])
        A.heap_size = A.heap_size - 1
        Max_heapify(A, 1)

时间复杂度:O(nlgn)。一次构建最大堆的时间与n次的维护最大堆性质的时间之和,构建最大堆的时间复杂度为O(n),(推导过程,Σ(n/2^(h+1))*h, h=1 to lgn),维护最大堆性质的时间复杂度约为O(lgn),所以整体时间复杂度为O(n+nlgn)即O(nlgn)。
空间复杂度:堆排序是原址排序,空间复杂度是O(n)。
堆排序的稳定性。堆排序无法保证稳定性,说明:假如开始时X = A[x] == A[y] = Y,不妨设x < y,在构建最大堆的时候,或许可以保持X的下标依然在Y的下标之前(因为只有子节点元素大于父节点时才交换,相等时不交换),但是如果某次A[1](非X)与Y交换之后,Y将在X之前,因此不能保证稳定性。

题外话,最大堆最小堆这种性质可以用于动态的任务调度

六、快速排序
快速排序与归并排序一样,用到了分治思想,但是快排不需要合并的步骤,也不需要额外的空间(除了交换元素之外)
快速排序也是一种“比较–交换”排序算法,其思想是,从待排数组中选一个基准元素,不妨设为Key,将所有不大于Key的元素放至Key之前,所有大于Key的元素放至Key之后,然后分别处理不大于Key的元素和大于Key的元素,当这些“部分数组”的元素个数为1的时候就完成了排序。针对Key的不同选取,快速排序有多个变种(因为快排的平均速度是所有比较交换排序算法中最快的,但是其最差时间复杂度比较高,某些变种便是解决最差时间复杂度高这个问题)。

快排的步骤也很简单,第一步是分解数组,将数组A[p..r]分解为三部分A[p..q-1], A[q], A[q+1..r],其中两个子数组可能为空,第二部是分别对两个子数组进行分解。(貌似就一个步骤)因此,快排最重要的步骤就是数组的划分了。

伪代码:

Partition(A, p, r)
//  返回值为q,且本过程结束之后满足,A[i] <= A[q](p <= i < q)与A[j] > A[q](q < j <= r)
 key = A[r]
 i = p-1
 for j=p to r-1
 if A[j] <= key
 i = i + 1
 swap(A[i], A[j])
 // 此处j会自加1
 swap(A[i+1], A[r])
 return i+1

Quick_Sort(A, p, r)
 if p < r
 q = Partition(A, p, r)
 Quick_Sort(A, p, q-1)
 Quick_Sort(A, q+1, r)

伪代码说明:
仅说明分解数组。用变量i代表数组中所有比基准元素小的下标(若i < p,则其余元素都不小于基准元素),变量j用来遍历数组中的所有非基准元素(此方法选择A[r]作为基准元素),一旦某个元素A[j] 不大于key,令i自加1,交换A[i]与A[j],此时依然保证部分数组A[p..i]中任意元素的值不大于key。当所有元素遍历结束后,数组A的分布为A[p..i]所有元素不大于A[r],A[i+1..j]中所有元素大于A[r],此时只要将A[i+1]与A[r]交换,则可以将数组A分为三个部分,A[p..q-1], A[q], A[q+1..r],其中q=i+1。

时间复杂度,这个分析比较复杂。极端情况下,每次划分时q == r,这样时间复杂度为O(n * n),而若每次按比例划分,则T(n) = T(a * (n-1)) + T((1-a) * (n-1)) + cn,其中cn是划分所用时间a代表划分的比例,可以证明,不管a取何值,T(n)均可以表示为O(nlgn),只是不同的a值其系数不同。所以平均情况下时间复杂度为O(nlgn)。
由于快排没有合并这一过程且每次划分数组之后需要排列的元素个数减了1,所以快排的nlgn系数要小于归并排序与堆排序的系数。
空间复杂度为O(n)。

快排的变种。随机快速排序。只需要改动数组划分的代码中基准元素的选择。

Random_Partition(A, p, r)
    // i是从[p, r]区间上一随机整数
    i = Random(p, r)
    swap(A[i], A[r])
    return Partition(A, p, r)

这样加入随机之后,虽然出现最差运行时间的概率在理论上没有减少,但是考虑普通快排最差运行时间总是出现在数组是已经基本有序的情况,而这种情况在实际中出现的概率较多,所以随机快速排序在实际中出现最差运行时间的概率会减少。

快排的稳定性:快速排序是非稳定的。

七、计数排序
前六种排序都是比较排序,且对需要比较的元素没有限制。
考虑对数组元素的关键字为自然数的排序这一特殊情况,比如对[0, k]内的整数进行排序,那只要记录数组中某个整数出现的次数即可将数组排序。比如知道1出现了2次,2出现了0次,3出现了1次,那么排序之后的结果为1,1,2。但是考虑到排序不仅是对关键字进行操作,还要操作元素的其他域,所以计数排序需要一个额外的输出空间和一个用于计数的存储空间。

不妨设输入的数组是A[1..n],n = A.length,输出数组为B[1..n],计数的数组为C[0..k](前提是A中元素的关键字范围是[0, k])。首先是清零计数数组,遍历数组A,将每个元素关键字对应的个数自加1,这样得出每个自然数所对应的元素个数;然后遍历数组C,从前向后累计,即i = 1 to k, C[i] += C[i-1],这样C[i]的含义就由数组A中关键字等于i的元素个数变成了数组A中关键字不大于i的元素个数,最后一步是输出,这一步要既要保证排序的正确性也要保证排序的稳定性,从后向前遍历数组A,即令j = A.length to 1,那么C[A[j]]的意思是数组A中所有不大于A[j]的元素个数总和,令X=A[j],又知A[j]是所有等于X的元素中排在最后的一个,因此令B[C[A[j]]] = A[j]并令C[A[j]]自减1,当j==1的时候排序完成。

伪代码

Counting_Sort(A, B, k)
    let C[0..k] be a new array
    for i=0 to k
        C[i] = 0
    for j=1 to A.length
        C[A[j]] = C[A[j]] + 1
    for i=1 to k
        C[i] = C[i-1] + C[i]
    for j=A.length downto 1
        B[C[A[j]]] = A[j]
        C[A[j]] = C[A[j]] - 1

显然计数排序的时间复杂度和空间复杂度都为O(n+k),所以当k的值相对于n来说不是很大的时候,该算法时间耗时很短,当然额外空间比基于原址的排序要大。

八、基数排序
如果数组A有多个关键字Key1, Key2, …,且Keyi的权重大于Keyi+1,即如果A[j].Key1 < A[k].Key1,不管A[j]中其余Keyi的值是多少,排序之后A[j]对应元素要放在A[k]之前。对于这类问题的排序需要依次对不同的关键字进行排序,这样的排序叫基数排序。

基数排序有两个关键问题,一是关键字选取的先后问题,二是对每个关键字排序时排序算法的选择。对于第一个问题,直觉上是先排权重大的关键字,但是先排序的关键字受到后排序关键字的影响,所以是先排权重小的关键字(这个在使用Excel的时候也可以用到)。对于第二个问题,需要选择稳定的排序算法(主要是处理相同权值时的先后位置),假如是不稳定的算法,对于两个元素比如a1和a2,不妨设a1.Key1 = a2.Key1, a1.Key2 > a2.Key2,则依据Key2排序之后,a1在a2之后,然后根据Key1排序,如果算法是不稳定的,因为a1.Key1 = a2.Key1,所以有可能导致排序之后a1又在a2之前,这将导致排序错误,所以针对每个关键字排序时需要用稳定的排序算法。

伪代码

Raidx_Sort(A, d)
    for i=d downto 1
        //Key1的权重最高
        use a stable sort to sort array A on Keyd 

基数排序的时间复杂度是对每个关键字排序的时间复杂度之和。

基数排序的特殊情况,假设数组A只有一个关键字排序Key,且key是自然数,但是Key有多个位(b个位),因此我们可以人为地将这b位按每组r位进行拆分,每一组进行计数排序。时间复杂度为O( (b/r)* (n + 2^r)),每组的排序时间复杂度为O(n+k),k=2^r。

(曾经谷歌创始人拉里•佩奇问奥巴马对100万个无符号整数排序用什么方法比较快,奥巴马机智地说反正不是冒泡排序,实际上答案就是基数排序)

九、桶排序
回想计数排序,使用限制是待排序数组的关键字为整数;现在设想另一种特殊情况,即待排序数组的关键字服从均分分布,并将其归一化为[0,1)半开区间的实数上。
桶排序的思想是这样的:不妨设待排序数组A的关键字在[0, 1)上服从均匀分布,数组A的元素个数为n = A.length,设置n个桶,即数组B[0..n-1],这样数组A中任意一个元素x都能放入某个桶中,规则是将x放入到对n*x进行下取整的对应的桶中,如果桶中已经有元素,则进行插入排序。(因此桶排序在数据结构上比较复杂,涉及到链表的操作)

伪代码

Bucket_Sort(A)
    n = A.length
    let B[0..n-1] be a new array
    for i=0 to n-1
        make B[i] an empty list
    for i=1 to n
        insert A[i] into list B[(int)(n*A[i])] using insertion sort
    concatenate the list B[0], B[1], ... B[n-1] together in order   

桶排序的时间复杂度的期望值为O(n),因为有个前提条件A上元素的关键字是均匀分布的(所以,对应于不同的概率分布可以构造不同的桶,以使得每个桶中含有元素的期望均为1),但是桶排序使用了额外的存储空间,数组B,以及将原始的数组改成了链表,数据结构变得复杂。

桶排序可以改变桶的个数,具体时间复杂度与空间复杂度会随着桶数而不同。

以上九种排序是基本的排序算法,有些算法可以进行改进以获得更快速度(其实快排是根据冒泡排序改进得到的,只不过快排已经是基本排序算法了),有些算法可以相互结合以适应不同的输入和运行条件(也因此有了内部排序与外部排序之分)。

十、插入排序的扩展
分析前三种思想简单的排序算法,插入排序,选择排序和冒泡排序,在数组大小不大的时候效率都比较高(因为n*n的系数小),而在数组已经基本有序的情况下插入排序需要比较的次数最少,因此可以对插入排序进行一些扩展。

10.1 希尔排序
希尔排序在某些层面与归并排序接近,与归并排序不同的是,希尔排序是非稳定排序。
希尔排序的思想:对数组A排序,选择一个增量的严格降序数组D[1..t],注意D[t] = 1,D[1] < A.length。逐个选择增量D[j](j=1 to t),将子数组A[i], A[i+D[j]], … A[i+k * D[j]]进行插入排序(i+k * D[j] <= A.length < i+(k+1) * D[j])。

其中D[j]的选择是一个数学难题,且希尔排序的时间复杂度的分析也很复杂。

10.2 图书馆排序
图书馆排序用到了这样一个事实,如果我们对图书馆中的书进行排序,为减少书籍的移动会在书之间预留一些空隙,所以将此用到插入排序。

10.3 大规模数据排序
对于非常庞大的数据,比如数据源与结果均可以存放至硬盘上,而无法一次性将结果放至内存中,就可以用到分治的思想,将原始数据切分为2^t部分,使得每个部分可以使用内部排序,将每个部分的排序结果放回硬盘。然后是合并。

而即使数据量没有如此庞大,也可以先将原数组还分为若干部分,每个部分采用插入排序算法进行排序,也可以获得比较好的时间复杂度。

以上内容为作者自己整理与思考,如有错误请慷慨指正。

点赞