排序的基本概念
- **排序(sorting)**的功能是将一个数据元素的任意序列,重新排列成一个按关键字有序的序列
- 在待排序的序列中存在多个具有相同关键字的元素。
- 假设Ki=Kj(1≤ i≤ n,1≤ j≤ n,i≠j),若在排序之前的序列中Ri在Rj之前,经过排序后得到的序列中Ri仍然在Rj之前,则称所用的排序方法是稳定的
- 当相同关键字元素的前后关系在排序中发生变化,则称所用的排序方法是不稳定的。
插入类排序
直接插入排序
它的基本思想是:仅有一个元素的序列总是有序的,因此,对n 个记录的序列,可从第二个元素开始直到第n 个元素,逐个向有序序列中执行插入操作,从而得到n 个元素按关键字有序的序列。以关键字序列{ 26 , 53 , 48 , 11 , 13 , 48 , 32 , 15}为例,直接插入排序的过程如图9-1 所示。
- 直接插入排序的时间复杂度为O(n2),并且是一个稳定的排序方法。
折半插入排序
- 排序元素数量n 很大,则不宜采用直接插入排序方法
- 在有序序列中确定插入位置,则可以不断二分有序序列来确定插入位置,即搜索插入位置的方法可以使用折半查找实现。
算法9-2 binInsertSort
输入:数据元素数组r,数组r 的待排序区间[low…high]
输出:数组r 以关键字有序
代码:
public void binInsertSort(Object[] r, int low, int high){
for (int i=low+1; i<=high; i++){
Object temp = r[i]; //保存待插入元素
int hi = i-1; int lo = low; //设置初始区间
while (lo<=hi){ //折半确定插入位置
int mid = (lo+hi)/2;
if(strategy.compare(temp,r[mid])<0)
hi = mid - 1;
else lo = mid + 1;
}
for (int j=i-1;j>hi;j--) r[j+1] = r[j]; //移动元素
r[hi+1] = temp; //插入元素
}//for
}
- 折半插入排序所需的辅助空间与直接插入排序相同,从时间上比较,折半插入排序仅减少了元素的比较次数,但是并没有减少元素的移动次数,因此折半插入排序的时间复杂度仍为O(n2)。
希尔排序
希尔排序又称为“缩小增量排序”,它也是一种属于插入排序类的排序方法,是一种对直接插入排序的改进,但在时间效率上却有较大的改进。
- 直接插入排序的时间复杂度为O(n2),但是在待排序元素序列有序时,其时间复杂度可提高至O(n)
- 希尔排序的基本思想是:首先将待排序的元素分为多个子序列,使得每个子序列的元素个数相对较少,对各个子序列分别进行直接插入排序,待整个待排序序列“基本有序”后,再对所有元素进行一次直接插入排序。
- 希尔排序的排序过程:
- 选择一个步长序列t1,t2,…,tk,其中ti>tj(i<j),tk=1;
- 按步长序列个数 k,对待排序元素序列进行 k趟排序;
- 每趟排序,根据对应的步长ti,将待排序列分割成ti个子序列,分别对各子序列进行直接插入排序。
希尔排序的过程如图9-2 所示
- 在每趟排序过程中子序列的划分并不是简单的逐段划分,而是将间隔某个步长的元素组成一个子序列。
算法9-3 shellSort
输入:数据元素数组r,数组r 的待排序区间[low…high],步长序列delta
输出:数组r 以关键字有序
代码:
public void shellSort(Object[] r, int low, int high, int[] delta){
for (int k=0;k<delta.length;k++)
shellInsert(r, low, high, delta[k]); //一趟步长为delta[k]的直接插入排序
}
private void shellInsert(Object[] r, int low, int high, int deltaK){
for (int i=low+deltaK; i<=high; i++)
if (strategy.compare(r[i],r[i-deltaK])<0){ //小于时,需将r[i] 插入有序表
Object temp = r[i];
int j = i-deltaK;
for(; j>=low&&strategy.compare(temp,r[j])<0; j=j-deltaK)
r[j+deltaK] = r[j]; //记录后移
r[j+deltaK] = temp; //插入到正确位置
}
}
- 希尔排序的时间复杂度为Ο(n3/2),其中t为希尔排序的趟数,1≤k≤t≤⎣log (n+1)⎦。
- 实际的应用中,在选择步长序列时应当注意:应使步长序列中的步长值互质,并且最后一个步长值必须等于1。
交换类排序
冒泡排序
算法9-4 bubbleSort
输入:数据元素数组r,数组r 的待排序区间[low…high]
输出:数组r 以关键字有序
代码:
public void bubbleSort(Object[] r, int low, int high){
int n = high - low + 1;
for (int i=1;i<n;i++)
for (int j=low;j<=high-i;j++)
if (strategy.compare(r[j],r[j+1])>0)
{
Object temp = r[j];
r[j] = r[j+1];
r[j+1] = temp;
}
}//end of bubbleSort
- 起泡排序的时间复杂度为Ο(n2)
快速排序
快速排序是将分治法运用到排序问题中的一个典型例子,快速排序的基本思想是:通过一个枢轴(pivot)元素将n 个元素的序列分为左、右两个子序列Ll 和Lr,其中子序列Ll中的元素均比枢轴元素小,而子序列Lr 中的元素均比枢轴元素大,然后对左、右子序列分别进行快速排序,在将左、右子序列排好序后,则整个序列有序,而对左右子序列的排序过程直到子序列中只包含一个元素时结束,此时左、右子序列由于只包含一个元素则自然有序。
用分治法的三个步骤来描述快速排序的过程如下:
- 划分步骤:通过枢轴元素 x 将序列一分为二, 且左子序列的元素均小于x,右子序列的元素均大于x;
- 治理步骤:递归的对左、右子序列排序;
- 组合步骤:无
对待排序序列进行划分的做法是:使用两个指针low 和high 分别指向待划分序列r 的范围,取low 所指元素为枢轴,即pivot = r[low]。划分首先从high 所指位置的元素起向前逐一搜索到第一个比pivot 小的元素,并将其设置到low 所指的位置;然后从low 所指位置的元素起向后逐一搜索到第一个比pivot 大的元素,并将其设置到high 所指的位置;不断重复上述两步直到low = high 为止,最后将pivot 设置到low 与high 共同指向的位置。
- 图9-4 说明了一次划分的过程。
算法9-5 partition
输入:数据元素数组r,划分序列区间[low…high]
输出:将序列划分为两个子序列并返回枢轴元素的位置
代码:
private int partition(int[] nums,int low,int high) {
//使用r[low]作为枢轴元素
int privot = nums[low];
//从两端交替向内扫描
while (low < high) {
while (low<high&&nums[high]>=privot) high--;
//将比pivot 小的元素移向低端
nums[low] = nums[high];
while (low<high&&nums[low]<=privot) low++;
//将比pivot 大的元素移向高端
nums[high] = nums[low];
}
//设置枢轴
nums[low] = privot;
//返回枢轴元素位置
return low;
}
算法9-6 quickSort
输入:数据元素数组r,数组r 的待排序区间[low…high]
输出:数组r 以关键字有序
代码:
//快排
private void quikSort(int[] nums,int low,int high) {
if (low < high) {
int privot = partition(nums, low, high);
quikSort(nums,low,privot-1);
quikSort(nums,privot+1,high);
}
}
效率分析
时间效率:快速排序算法的运行时间依赖于划分是否平衡,即根据枢轴元素pivot 将序列划分为两个子序列中的元素个数,而划分是否平衡又依赖于所使用的枢轴元素。下面我们在不同的情况下来分析快速排序的渐进时间复杂度。
算法的时间复杂度T(n) = Tp(n) + T(n-1),其中Tp(n)是对具有n个元素的序列进行划分所需的时间,由以上划分算法的过程可以得到Tp(n) = Θ(n)。T(n) =Θ(n) + T(n-1) =Θ(n2)。
在待排序序列本身已经有序或逆向有序时,快速排序的时间复杂度为Ο(n2),而在有序时插入排序的时间复杂度为Ο(n)。
- 快速排序的最好情况是在每次划分时,都将序列一分为二,正好在序列中间将序列分成长度相等的两个子序列,算法的时间复杂度T(n) = Tp(n) + 2T(n/2),由于Tp(n) = Θ(n),由master method知道T(n) = Θ(n log n)。
在平均情况下,快速排序的时间复杂度T(n) = kn ㏑ n,其中k 为某个常数,经验证明,在所有同数量级的排序方法中,快速排序的常数因子k 是最小的。因此就平均时间而言,快速排序被认为是目前最好的一种内部排序方法。
空间效率:虽然从时间上看快速排序的效率优于前述算法,然而从空间上看,在前面讨论的算法中都只需要一个辅助空间,而快速排序需要一个堆栈来实现递归。若每次划分都将序列均匀分割为长度相近的两个子序列,则堆栈的最大深度为log n,但是,在最坏的情况下,堆栈的最大深度为n。
选择类排序
简单选择排序
简单选择排序的基本思想非常简单,即:第一趟,从n 个元素中找出关键字最小的元素与第一个元素交换;第二趟,在从第二个元素开始的n-1 个元素中再选出关键字最小的元素与第二个元素交换;如此,第k 趟,则从第k 个元素开始的n-k+1 个元素中选出关键字最小的元素与第k 个元素交换,直到整个序列按关键字有序。
效率分析
- **空间效率:**显然简单选择排序只需要一个辅助空间。
- **时间效率:**在简单选择排序中,所需移动元素的次数较少,在待排序序列已经有序的情况下,简单选择排序不需要移动元素
- **算法改进思想:**从上述效率分析中可以看出,简单选择排序的主要操作是元素间的比较操作,因此改进简单选择排序应从减少元素比较次数出发。
树型选择排序
基本思想是:先把待排序的n个元素两两进行比较,取出较小者,若轮空则直接进入下一轮比较;然后在⎡n/2⎤个较小者中,采用同样的方法进行比较,再选出较小者;如此反复,直到选出关键字最小的元素为止。
这个过程可以使用一颗具有n个结点的完全二叉树来表示,最终选出的关键字最小的元素就是这棵二叉树的根结点。
例如图9-6(a)给出了对8 个元素的关键字{ 26 , 53 , 48 , 11 , 13 , 48 , 32 , 15}选出最小关键字元素的过程。
效率分析
时间效率:在树型选择排序过程中为找到关键字最小的元素一共进行了n-1 次比较。叶子结点的完全二叉树其高度为⎡log n⎤,由此可知除最小关键字外,每选择一个次小关键字需要进行⎡log n⎤次比较。因此树型选择排序的时间复杂度T(n) = (n-1) ⎡log n⎤ +(n-1) = Ο(n log n)。
**空间效率:**与简单选择排序相比,使用了更多的辅助空间
算法改进思想:树型选择排序的缺点是使用了较多的辅助空间,以及和∞进行多余比较,为弥补树型选择排序的这些缺点,J.W.J.Williams 在1964 年提出了进一步的改进方法,即堆排序。
堆排序
- n个元素的序列{k1 , k2 , … , kn},当且仅当满足下列关系时,称之为堆。
① { k i ≤ k 2 i k i ≤ k 2 i + 1 或 ② { k i ≥ k 2 i k i ≥ k 2 i + 1 其 中 i = 1 , 2 , … , ⌊ n / 2 ⌋ ①\left\{\begin{array}{l} k_i\leq k_{2i} \\ k_i\leq k_{2i+1} \end{array}\right. \quad 或②\left\{\begin{array}{l} k_i\geq k_{2i} \\ k_i\geq k_{2i+1} \end{array}\right. \quad 其中i=1 , 2 , … , \lfloor n/2 \rfloor ①{ki≤k2iki≤k2i+1或②{ki≥k2iki≥k2i+1其中i=1,2,…,⌊n/2⌋
若满足条件①,则称为小顶堆,若满足条件②,则称为大顶堆。
如果将序列**{k1 , k2 , … , kn}对应为一维数组**,且序列中元素的下标与数组中下标一致,即数组中下标为0 的位置不存放数据元素,此时该序列可看成是一颗完全二叉树,则堆的定义说明,在对应的完全二叉树中非终端结点的值均不大于(或不小于)其左右孩子结点的值。
例如图9-7 显示了两个堆,其对应的元素序列分别为{45 , 26 , 18 , 23 , 19 , 5 , 11 , 14}、{13 , 32 , 15 , 40 , 51 , 38}。其中(a)是一个大顶堆,(b)是一个小顶堆。
将这n 个元素按关键字建成堆,将堆顶元素输出,得到n 个元素中关键字最大(或最小)的元素。然后,再将剩下的n-1 个元素重新建成堆,再输出堆顶元素,
- 实现对排序时需要解决两个问题:
- 如何将n 个元素的序列按关键字建成堆;
- 输出堆顶元素后,怎样调整剩余n-1 个元素,使其按关键字成为一个新堆。
我们首先第二个问题,即输出堆顶元素后,对剩余元素重新建成堆的调整过程。
设有一个具有m 个元素的堆,输出堆顶元素后,剩下m-1 个元素。具体的调整方法是:首先,将堆底元素(最后一个元素)送入堆顶,此时堆被破坏,其原因仅是根结点不满足堆的性质,而根结点的左右子树仍是堆。然后,将根结点与左、右子女中较大(或较小)的进行交换。若与左孩子交换,则左子树堆被破坏,且仅左子树的根结点不满足堆的性质;若与右孩子交换,则右子树堆被破坏,且仅右子树的根结点不满足堆的性质。继续对不满足堆性质的子树进行上述交换操作,直到叶子结点,则堆被重建。我们称这个自根结点到叶子结点的调整过程为筛选。
例如图9-8(a)为一个大顶堆,在输出堆顶元素53 之后,将堆底元素26 送入堆顶,如图9-8(b)所示;然后将26 与36、42 中大的元素交换,交换后,以26 为根的子树已是一个堆;此时筛选结束,得到一个新堆,如图9-8(c)所示。如果继续输出堆顶元素42,然后重建堆,则结果如图9-8(d)所示。
对于待排序的初始序列{28 , 26 , 17 , 36 , 20 , 42 , 11 , 53},初始建堆的过程如图9-9 所示。(a)是由初始序列得到的完全二叉树;初始建堆首的过程,以按层从下到上的第一个非叶子结点开始,即从36 开始,对36 进行调整,过程如图(b)所示,调整结果如图(c)所示;然后对下一个非叶子结点17 进行调整,调整过程如图(c),结果如图(d)所示;继续上述过程直到根结点28 为止,对28 进行调整后,即得到一个大顶堆,结果如图(f)所示。
算法9-8 heapAdjust
输入:数据元素数组r,数组r 的待调整区间[low…high]
输出:调整r[low…high]使之成为大顶堆
代码:
//已知r[low..high]中除r[low]之外,其余元素均满足堆的定义
private void heapAdjust(Object[] r, int low, int high){
Object temp = r[low];
for (int j=2*low; j<=high; j=j*2){ //沿关键之较大的元素向下进行筛选
//j 指向关键之较大的元素
if (j<high&&strategy.compare(r[j],r[j+1])<0) j++;
//若temp 比其孩子都大,则插入到low 所指位置
if (strategy.compare(temp,r[j])>=0) break;
r[low] = r[j]; low = j; //向下筛选
}
r[low] = temp;
}
算法9-8 heapSort
输入:数据元素数组r
输出:对r[1…length-1]排序
代码:
public void heapSort(Object[] r){
int n = r.length - 1;
for (int i=n/2; i>=1; i--) //初始化建堆
heapAdjust(r,i,n);
for (int i=n; i>1; i--){ //不断输出堆顶元素并调整r[1..i-1]为新堆
Object temp = r[1]; //交换堆顶与堆底元素
r[1] = r[i];
r[i] = temp;
heapAdjust(r,1,i-1); //调整
}
}
注:为了代码的易读性,在算法9-8 中对数组r 进行排序时,排序的范围是[1…length-1],这一点和前面的算法是不一样的,前面的算法其排序范围是由参数指定的。当然堆排序也可以对指定范围内的元素进行排序,只不过在对下标进行操作之前都必须进行相应的处理,读者可自行设计实现指定范围的堆排序算法。
效率分析
**空间效率:**显然堆排序只需要一个辅助空间。
时间效率:首先,对于深度为k的堆,heapAdjust算法中所需执行的比较次数至多为2k次。则在初始建堆的过程中,对于具有n个元素、深度为h的堆而言,由于在i层上最多有2i个结点,以这些结点为根的二叉树深度最大为h-i,那么⎣n/2⎦次调用heapAdjust时总共进行的关键字比较次数Tinit为:
T
init
=
∑
i
=
1
−
1
0
2
i
⋅
2
(
h
−
i
)
=
O
(
∑
j
=
1
h
2
h
−
j
⋅
j
)
=
O
(
n
∑
j
=
1
h
j
2
j
)
=
O
(
n
)
因
为
∑
i
=
0
∞
i
2
i
=
2
T_{\text {init }}=\sum_{i=1-1}^{0} 2^{i} \cdot 2(h-i)=O\left(\sum_{j=1}^{h} 2^{h-j} \cdot j\right)=O\left(n \sum_{j=1}^{h} \frac{j}{2^{j}}\right)=O(n)\quad因为\sum_{i=0}^{\infty}\frac{i}{2^{i}}=2
Tinit =i=1−1∑02i⋅2(h−i)=O(j=1∑h2h−j⋅j)=O(nj=1∑h2jj)=O(n)因为i=0∑∞2ii=2
初始化需要执行的比较操作的次数为Ο(n)。
每输出一次堆顶元素需要进行一次调整,而每次调整所需的比较次数为Ο(log n),因此n 次输出总共需要的比较次数为Ο(n log n)。
归并排序
归并排序的基本思想是基于合并操作,即合并两个已经有序的序列是容易的,不论这两个序列是顺序存储还是链式存储,合并操作都可以在Ο(m+n)时间内完成(假设两个有序表的长度分别为m 和n)。为此,由分治法的一般设计步骤得到归并排序的过程为:
- 划分:将待排序的序列划分为大小相等(或大致相等)的两个子序列;
- 治理:当子序列的规模大于1 时,递归排序子序列,如果子序列规模为1 则成为有序序列;
- 组合:将两个有序的子序列合并为一个有序序列。
图9-10 显示了归并算法的执行过程。假设待排序序列为{4, 8, 9, 5, 2, 1, 4, 6},如图所示,归并排序导致了一系列递归的调用,而这一系列调用过程可以由一个二叉树来表示。
算法9-9 merge
输入:数据元素数组a,a 待合并的两个有序区间[p…q]以及[q+1…r]
输出:将两个有序区间合并为一个有序区间
代码:
private void merge(Object[] a, int p, int q, int r){
Object[] b = new Object[r-p+1];
int s = p;
int t = q+1;
int k = 0;
while (s<=q&&t<=r)
if (strategy.compare(a[s],a[t])<0)
b[k++] = a[s++];
else
b[k++] = a[t++];
while (s<=q) b[k++] = a[s++];
while (t<=r) b[k++] = a[t++];
for (int i=0; i<b.length; i++)
a[p+i] = b[i];
}
假设待合并的两个子序列总长为n,则这n 个元素在从数组a 移动到b 的过程中,每个元素移动一次,而每次元素移动最多只需要一次比较;最后从数组b 移回a 也只需要n 次移动操作即可,因此,算法merge 的时间复杂度为Θ(n)。
算法9-10 mergeSort
输入:数据元素数组r
输出:对r[low…high]排序
代码:
public void mergeSort(Object[] r, int low, int high){
if (low<high){
mergeSort(r,low,(high+low)/2);
mergeSort(r,(high+low)/2+1,high);
merge(r,low,(high+low)/2,high);
}
}
- **空间效率:**在归并排序中,为了将子序列合并需要使用额外的存储空间,这个辅助存储空间的最大值不超过n,因此归并算法的空间复杂度为Θ(n)。
- **时间效率:**归并算法是一个典型的分治算法,因此,它的时间复杂度可以用Master Method 来求解。通过对算法的分析我们写出算法时间复杂度的递推关系式:T(n) = 2T(n/2) + Θ(n)
- 该递推式满足Master Method 的第二种情况,因此T(n) = Ο(n log n)。
基于比较的排序的对比
排序方法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|
直接插入排序 | Ο(n2) | Ο(n2) | Ο(1) | 稳定 |
起泡排序 | Ο(n2) | Ο(n2) | Ο(1) | 稳定 |
快速排序 | Ο(n log n) | Ο(n2) | Ο(log n) | 不稳定 |
简单选择排序 | Ο(n2) | Ο(n2) | Ο(1) | 不稳定 |
堆排序 | Ο(n log n) | Ο(n log n) | Ο(1) | 不稳定 |
归并排序 | Ο(n log n) | Ο(n log n) | Ο(n) | 稳定 |
快速排序在最坏情况下的时间性能不如堆排序和归并排序。这一点可以通过对快速排序进行改进来避免,一种通过随机选择枢轴元素的随机快速排序,可以使得出现最坏情况出现的几率非常小,在实际的运用中可以认为不存在