一、排序的概念与分类
1.1 排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2 常见的排序算法及分类
本文介绍选择排序中的冒泡排序和快速排序。下面是其他排序算法的博客链接:
排序算法(一)_rao_xuanxuan的博客-CSDN博客
排序算法(二) 选择排序和堆排序_rao_xuanxuan的博客-CSDN博客
二、冒泡排序
2.1 冒泡排序的基本思想
所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。而冒泡排序就是像水中的气泡一样,从某个位置开始反复交换相邻的未按次序排序的元素,使得整个数组有序。
2.2 冒泡排序的伪代码和执行流程
如图a,开始时,i = 0,j 从当前位置 A.length-1 开始往前交换相邻未排序的元素直到 j = i+1 = 1,此时A[0]位置就是最小元素所处位置。如图b,第二次排序,i = 1,j 从当前位置 A.length-1 开始往前交换未排序的元素直到 j = i+1 = 2,此时A[1]位置就是第二小元素所处位置。如图c,第三次排序,i = 2,j从当前位置 A.length-1 开始往前交换未排序的元素直到 j = i+1 = 3,此时A[2]位置就是第三小元素所处位置,子数组A[0...2]已经有序并且是整个数组中最小的元素。一直重复直到 i=A.length-2,此时数组A排序完成。
2.3 冒泡排序的具体代码
为了提高效率,避免不必要的循环判断,我们引入一个标记swapFlag,对于每次外层循环,若是其内层循环并未发生交换,那么显然此时数组A已经有序,就可以直接跳出循环,排序完成。
void bubbleSort(int* nums, int numsSize)
{
for (int i = 0; i < numsSize - 1; i++)
{
int swapFlag = 0;
for (int j = numsSize - 1; j > i; j--)
{
if (nums[j] < nums[j - 1]) {
swap(&nums[j - 1], &nums[j]);
swapFlag = 1;
}
}
if (!swapFlag) return;
}
}
2.4 冒泡排序的循环不变式和正确性分析
为证明BUBBLESORT(A)的正确性,我们使用如下的循环不变式(记为式1):
算法1到4行的for循环每次迭代的开始,子数组A[0...i-1]都已经按非降序排序,且子数组A[0...i-1]中的 i 个元素为整个数组A中的最小的 i 个元素。
为了证明上述的循环不变式,这里对算法 2-4 行的内循环提出以下的循环不变式(记为式2):
对于每一次2-4行for循环迭代开始时,保证A[j]为A[j..n-1]中最小的元素,且A[j..n-1]的元素都由数组A原来的元素组成。
于是我们需要证明式2在第一次循环前为真,并且每次循环迭代都维持不变,当循环结束时,算法的正确性便可由这一不变量证明。
1. 初始化。对于某个i,在内循环第一次开始时,循环变量 j = n-1,子数组A[n-1]只有整个数组A中的最后一个元素,显然成立。
2. 保持。当循环变量 j 不为 n-1 和 i 时,循环不变式2成立。由于A[j]为A[j...n-1]中的最小元素,根据3、4行,若是A[j-1] > A[j],则将二者互换,否则不进行操作,于是经过3、4行后A[j-1]一定是A[j-1...n-1]中的最小元素并且A[j-1...n-1]的元素都来自数组A。由于内循环的循环变量 j 每次减1,于是在下一次循环迭代 j-1开始前,循环不变式仍然成立。
3. 终止。导致循环的终止条件为 j = i,在循环终止时,最后一次执行循环的 j 值为i+1,此时代码执行完毕将保证如果A[i]小于A[i+1],则A[i]将与A[i+1]置换,这个逻辑保证了代码执行过后的A[i]将为A[i..n-1]中最小的元素。此外,内循环中元素值的改变仅来自序列中相邻元素值的交换,不存在外值引入的途径,因此子序列全部元素来自该序列本身得以论证。
至此,循环不变式2得证,接下来我们根据以上结论证明循环不变式1。我们需要证明式1在第一次循环前为真,并且每次循环迭代都维持不变,当循环结束时,BUBBLESORT的正确性便可由这一不变量证明。
1. 初始化。在第一次循环开始时,A[0..i-1]为空数组,故循环不变式成立。
2. 保持。当循环变量 i 不为 0 或 n-1 时,循环不变式1成立。于是A[0...i-1]都已经按非降序排序,根据循环不变式2本次循环的2-4行保证了A[i]将为A[i..n-1]中最小的元素,而A[i...n-1]中任一的元素都比A[0...i-1]中的元素大,于是A[i]是整个数组A中第i+1小的元素,A[0...i]都按照非降序排序并且A[0...i]中的元素都来自数组A。由于1-4行for循环的循环变量 i 每次加1,于是在下一次循环迭代 i+1 开始前,循环不变式仍然成立。
3. 终止。当循环终止时,i = n-1。根据循环不变式1可得子数组A[0...n-2]以按照非降序排序并且子数组A[0...n-2]中的元素是整个数组A中最小的 n-1 元素,那么A[n-1]自然是整个数组中最大的元素,因此A[0...n-1]已经按照非降序排序并且每个元素都来自数组A。于是,BUBBLESORT的正确性得证。
2.4 冒泡排序的性能分析
1. 时间复杂度:在最坏的情况(数组逆序排列),外循环一共要进行 n-1 次,而每个 i 对应的内循环总是要执行 n-i-1 次,于是可以得到最坏情况下的时间复杂度为,于是BUBBLESORT的时间复杂度为Θ(n^2)。
2. 空间复杂度:BUBBLESORT的所有操作都在原数组A上进行,因此只使用了常数级的额外空间,因此空间复杂度为O(1)。
3. 稳定性。BUBBLESORT是一种不稳定的算法。
三、快速排序
3.1 快速排序的基本思想和执行流程
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。因此,快速排序用到了分治的思想。
下面给出对一个典型的子数组A[p...r]进行快速排序的三步分治过程。
1. 分解。数组A[p...r]被划分为两个子数组(可能为空)A[p...q-1]和A[q+1...r],使得A[p..q-1]中的每个元素都小于等于A[q],而A[q]也小于等于A[q+1...r]中的每个元素。其中,计算下标q是极其关键的一步,称为PARTITION,在第二节给出详细说明。
2. 解决。通过递归调用快排程序,对子数组A[p...q-1]和A[q+1...r]进行排序。
3. 合并。由于在快排中子数组都是有序的,因此不需要合并操作,A[p...r]已经有序。
以下程序可以实现快速排序:
如图a,第一次选取 x = 4,将数组划分为两个部分,划分结果如图b所示,A[0...2]的元素都小于x,A[4...7]的元素都大于x,然后如图c,进行分治,对A[0...2]和A[4...7]进行划分,得到的划分结果如图d所示,然后如图e,当划分得到的子数组长度为1结束,否则继续进行划分,直到得到的子数组长度为1(如图f),分治结束,数组A有序。
3.2 数组的划分
PARITION算法有三种实现方式:挖坑法、左右指针法和前后指针法,这里重点介绍前后指针法。
3.2.1 PARITION的伪代码和执行流程(前后指针法)
前后指针法划分数组的伪代码如下:
前后指针法的PATITION算法的核心就是快指针 j(cur) 和慢指针 i(pre)的移动关系。
为了方便描述,下图默认 p = 1。
开始时,如图a,选取 x = A[l],以x作为主元(pivot element)将数组A划分为两部分,i指向 0,j指向 1,由于A[j] < x(j = 1),为了使得前半部分小于x,将 i 前移一步(i = 1),然后将A[i] 与 A[j] 互换。接下来,如图b,j 继续后移,i 保持不变,直到A[j] < x(j = 5),将 i 前移一步(i = 2),然后将A[i] 与 A[j]互换。接下来,如图c,j 继续后移,i 保持不变,直到A[j] < x(j = 6),将 i 前移一步(i = 3),然后将A[i] 与 A[j]互换。接下来,j 继续后移,i保持不变,直到 j > r,此时将A[i] 与 x互换,循环结束,数组划分完成。如下图,A[0...2]所有元素小于x,x小于A[3...r]的所有元素。
3.2.2 PATITION的具体代码(前后指针法)
void partSort(int* nums, int left, int right)
{
int i = left, j = left + 1;
int pivot = left;
while (j <= right)
{
if (nums[j] < nums[pivot] && (++i) != j)
swap(&nums[i], &nums[j]);
j++;
}
swap(&nums[i], &nums[pivot]);
return i;
}
3.2.3 PATITION的循环不变式和正确性分析
观察3.2.2节的图示,我们可以发现,在算法执行过程中,通过 i 、j 可以把数组A[p...r]按照与x的大小关系划分为4个区域(可能为空)。对于任一下标k,有:
1. 当 k = p,A[k] == x(黄色区域);
2. 当 p < k <= i,A[k] < x(蓝色区域);
3. 当 i < k < j,A[k] >=x(红色区域);
4. 当 k >= j,A[k]与x的大小关系未知(白色区域)。
我们将上述的区域划分性质作为循环不变量,我们需要证明这一循环不变量在第一轮迭代前是成立的,并且每一轮迭代后仍然成立。在循环结束时,该循环不变式可以为证明算法正确性提供重要依据。
1. 初始化:在循环的第一轮迭代开始前,i = p,j = p+1。因为子数组A[p+1...i]和A[i+1...j-1]为空,算法第一行的赋值满足了第一条性质,于是循环不变量在初始状态成立。
2. 保持:当循环变量 j 不为 p+1 时,循环不变量成立。如下图,我们考虑两种情况:当A[j] < x时,如图a,则将 i 加1,并且将A[i](这里的 i 是移动后的值,下同) 与 A[j]互换,进而有互换后 A[i]< x,从而下一次循环迭代开始前,子数组A[p+1...i+1](注意,这里的 i 是移动前的值)中的任一元素严格小于x,在下一次迭代开始时,如图,j 增加1,满足第二条性质;同理,由于A[i] >= x,因此互换后A[j] < x,于是子数组A[i+1...j]的任一元素大于等于x,在下一次迭代开始时,如图,j 增加1,满足第三条性质。当A[j] >= x时,如图b,i 保持不变,A[i+1...j-1]的任一元素大于等于x,因此A[i+1...j]的任一元素大于等于x,在下一次迭代开始时,由于 i 没有改变,j 增加1,因此第二条性质和第三条性质得以保持。对于第一条性质由于我们没有对A[p]进行修改显然成立,而第四条性质也可以基于以上三条性质的成立得以满足。至此,我们证明了迭代过程中循环不变量的保持。
3. 终止:当终止时,如下图,j = r+1。由循环不变量,数组中的每个元素必然属于满足循环不变量前三条的区域内,没有元素与 x 的大小关系是不确定的,我们得到三个集合:包含所有大于等于x元素的集合,包含所有小于x的元素的集合和只有一个元素x的集合。
在PARTITION的最后两行中,如下图,A[i]和x进行了交换,就可以将主元 x 移动到数组中正确位置上,并且返回主元的在数组中的新下标。至此,PARTITION的正确性得证。
3.2.4 PARTITION的复杂度分析
由于PARTITION内部for循环的时间复杂度为r-p+1 = O(n),执行其余操作消耗常数项时间,因此PARTITION的时间复杂度为Θ(n) 。由于排序和交换都是在原数组A上进行,只需要常数项的额外空间,因此空间复杂度为O(1)。
3.2.5 PARTITION的其他实现方式
PARTITION除了前后指针外的另外两种实现方式很直观,其正确性显然成立,在这里就简单介绍并且给出代码。
1. 左右指针法:右指针 j 先从 r 出发,寻找A[j] < x,左指针 i 然后从 p 出发,寻找A[i] > x,然后将A[i] 与 A[j] 互换。一直重复以上操作,直到,i >= j 时,将A[i] 与 x 互换,此时划分结束。
左右指针法的代码如下:
int partSort(int* nums, int left, int right)
{
int begin = left, end = right;
int pivot = left;
while (begin < end)
{
while (begin < end && nums[end] >= nums[pivot])
end--;
while (begin > end && nums[begin] <= nums[pivot])
begin++;
swap(&nums[begin], &nums[end]);
}
swap(&nums[begin], &nums[pivot]);
return begin;
}
2. 挖坑法:每次选取一个坑位pivot,按顺序将大于或小于 x 的元素放入坑中。开始时,后指针 j = r,如图a,从后开始找A[j] < x,找到小于 x 的元素后,如图b,将A[j] 放到坑pivot上,然后重新挖坑,新坑的位置为 pivot = j 。 然后如图c,i 从0开始找 A[i] > x,找到大于 x 的元素后,如图d,将A[i] 放到坑pivot上,然后重新挖坑,新坑的未知为 pivot = i 。之后,i 、j 从当前位置出发,按照先 j 后 i 的顺序重复以上过程,直到 i >= j,此时,将主元x放到坑 pivot = i 上。至此,数组被划分为两个部分。
挖坑法代码如下:
int partSort(int* nums, int left, int right) {
int i = left, j = right;
int pivot = left;
int x = nums[pivot];
while (i < j)
{
// from right to find the element less than x
while (i < j && nums[end] >= x)
j--;
// put the found element into the pivot and create new pivot
nums[pivot] = nums[j];
pivot = j;
// from left to find the element bigger than x
while (i < j && nums[i] <= x)
i++;
// put the found element into the pivot and create new pivot
nums[pivot] = nums[i];
pivot = i;
}
pivot = i;
nums[pivot] = x;
return pivot;
}
3.3 快速排序的性能分析
快速排序的运行时间依赖划分是否平衡,而划分平衡与否又依赖于用于划分的元素(pivot element)。如果划分是平衡的,那么快速排序算法性能将与归并排序(MERGESORT)相同;如果划分是不平衡的,那么快速排序的性能接近插入排序(INSERTIONSORT)。下面给出对以上两种情况快速排序性能的分析。
3.3.1 最坏情况分析
当对一个含n个元素的问题划分为含0个元素和n-1个元素的子问题时,快速排序的最坏情况发生。非形式化的,不妨假设算法的每一次递归调用都出现了这种不平衡的划分,根据3.2.4节对PARTITION的性能分析,每次划分操作的时间复杂度都为Θ(n)。由于对一个大小为0的数组进行递归调用会直接返回,因此T(0) = Θ(1),于是算法运行时间的递归式可表示为:
对递归逐层累加,可以得到递归式的解为Θ(n^2)。因此,若是算法在每一层递归的划分都是较不平衡的,那么算法的时间复杂度就是Θ(n^2)。此外,在数组已经有序或者逆序排列的情况下,快速排序的时间复杂度仍然为Θ(n^2)。
接下来对这一结论进行严格证明。
下面证明快速排序的时间复杂度为O(n^2),假设 T(n) 是最坏情况下QUICKSORT在输入规模为n的数据所花费的时间,根据3.2.4节对PARTITION的性能分析,有以下递归式:
,其中q为划分位置
由于PARTITION算法生成的两个子问题的规模总和为 n-1,因此 q 的取值范围是0到n-1。我们不妨猜测 T(n) <= c*n^2 成立,代入上式,有:
我们可以选择一个足够大的常数c,使得c(2n-1)项显著大于Θ(n),因此,,即T(n) = O(n^2)。
接下来证明递归式还有另一个解 T(n) = Ω(n^2)。我们用数学归纳法进行证明:
(1) 当 n = 1,T(1) = 2T(0) + Θ(1),当c足够小,就有T(1) >= c成立。
(2) 当 n > 1,假设对 1、2、.... 、n-1 成立,则有:
我们可以选择一个足够小的常数c,使得c(2n-1)项显著小于Θ(n),因此,即T(n) = Ω(n^2)。综上,快速排序的(最坏情况)运行时间是Θ(n^2)。
3.3.2 最好划分分析
在可能的最平衡划分中,PARTITION得到的两个子问题的规模都不大于n/2,这是因为任何一个子问题的规模为,而另一个子问题的规模为。在这种情况下,快速排序有最好的性能,算法的递归式为:T(n) = T(n/2) + Θ(n),这是忽略了一些余项及减1操作的影响,根据主定理的情况2,上述递归式的解为T(n) = Θ(nlgn)。通过在每层递归上平衡划分子数组,我们得到了一种渐进时间上更快的算法。
3.3.3 平均情况分析
在平均情况下,PARTITION所产生的划分同时混有"好"和"坏"的情况,此时,早于PARTITION平均情况执行过程对应的递归树中,好和差的划分是随机分布的,这里我们简化为好和差的划分交替出现,并且好的划分都是最好的情况,差的划分都是最差的情况。下图显示了递归树上连续两层的划分情况。
(a)是一棵递归树的两层,在根节点对这一层划分的代价是 n,产生了一个"坏"的划分:两个子数组的大小分别为0和n-1。对大小为n-1的子数组划分的代缴是 n-1,并产生了一个"好"的划分:两个子数组的大小分别为(n-1)/2-1 和 (n-1)/2。(b)是一棵非常平衡的递归树的一层,对根节点这一层进行划分的代价是 n,产生了一个"好"的划分。可以看出,在两棵树中,待解决的子问题(矩形区域)规模相同,并且(a)的划分代价为Θ(n) + Θ(n-1) = Θ(n),并不比(b)的划分代价Θ(n)差。因此,当好和差的划分交替出现时,快速排序的时间复杂度与最好情况相同,都为O(nlgn)。
3.4 快速排序的优化
3.4.1 三数取中优化
根据3.3节对快速排序最坏情况和平均情况的分析,我们可以知道,如果每次选择的主元x不是最小值或最大值,那么在最坏情况下快速排序的时间复杂度就可以降为O(nlgn)。因此,我们考虑每次在选取主元的时候从三个数中选取中间大小的数,这样就可以保证出现平衡的划分。
不失一般性,在PARTITION(A,p,r)中,我们从A[p]、A[r]、A[(p+r)/2]这三个数中选取中间大的数,这一过程通过辅助函数 getMidIndex(A,p,r)实现,其功能是返回以上三个数中中间大的数的下标索引 mid,之后通过交换A[p]与A[mid],就实现了三数取中优化。
优化后的PARTITION代码如下:
void partSort(int* nums, int left, int right)
{
int mid = getIndex(nums, left, right);
swap(&nums[mid], &nums[left]);
int i = left, j = left + 1;
int pivot = left;
while (j <= right)
{
if (nums[j] < nums[pivot] && (++i) != j)
swap(&nums[i], &nums[j]);
j++;
}
swap(&nums[i], &nums[pivot]);
return i;
}
3.4.2 快速排序的随机化版本
随机化的快速排序与之前的不同在于每次选取主元 x 时,不是固定取A[p],而是从子数组A[p...r]中随机选择一个元素作为主元,为达到这个目的,我们将A[p]与这个随机选取的主元进行交换,然后进行PARTITION即可。由于主元选取的随机,因此我们可以认为对数组A的划分是比较平衡的。其实现过程与三数取中类似(或者三数取中优化是随机化的一种特殊情况),在这里给出伪代码:
接下来说明随机化快排进行期望运行时间为O(nlgn)。显然,RANDOMIZED-QUICKSORT与QUICKSORT除了主元的选取上其他操作完全相同,因此我们在QUICKSORT和PARTITION的基础上进行讨论即可。
QUICKSORT的运行时间由PARTITION操作花费时间决定,每次PARTITION的执行都会选取一个主元并且该主元不会出现在后续QUICKSORT和PARTITION的调用中。因此,整个快速排序算法至多调用PARTITION的次数为 n。调用PARTITION的时间为O(1)再加上循环时间,这段循环时间与PARTITION算法内部3-6行for循环的迭代次数成正比,这一for循环每次迭代都要进行第4行的比较:比较当前主元 x 和数组A中的另一个元素,因此通过比较次数就可以得到for循环所消耗时间。以下给出引理:
当一个含有 n 个元素的数组A进行QUICKSORT时,若PARTITION内进行比较的总次数为X,则QUICKSORT的运行时间为O(n+X)。
由于QUICKSORT至多对PARTITION调用 n 次,每次调用包括固定的工作量和若干次for循环,每次for循环都要执行比较操作,因此引理成立。
因此,我们只需要统计X的值就可以得到QUICKSORT的运行时间,接下来我们直接讨论PARTITION的总比较次数的一个界,而不是讨论每次PARTITION的比较次数。为此,我们研究两个数的比较条件。这里按照大小关系对A进行重排,按从小到大把每个元素记为z1、z2、... 、zn,定义。
那么什么时候zi和zj会被比较呢?首先,由于之前提到的各个元素只与主元比较,结束某次PARTITION结束后该主元就不会与其他元素比较了。这里定义指示器随机变量,其中我们考虑比较操作是否发生在任何时期,而不是局限在某次PARTITION的调用。因此,比较总次数如下:
对上式取期望,有:
接下来计算Pr{zi compared with zj},假设RANDOMIZE-PARTITION随机选取主元,并且数组A元素互异。一旦某个满足 的主元x被选取,那么这次RANDOMIZE-PARTITION完成后zi 和 zj 就会被划分到两个数组中,zi和 zj就不会被比较。如果 zi 在集合 Zij 的所有元素之前被选为主元,那么 zi 将与集合 Zij 中的除自身外的所有元素比较,类似的,如果 zj 在集合 Zij 的所有元素之前被选为主元,那么 zj 将与集合 Zij 中的除自身外的所有元素比较。于是,我们得到如下结论:
zi 与 zj 相比较 当且仅当 集合Zij 中第一个被选为主元的元素为zi 或 zj。于是有:
将(2)代入(1),有:,进行变量代换,用 k 代替 j-i ,可得:
因此,使用RANDOMIZED-PARTITION,在输入元素互异的情况下,快速排序的预期运行时间为O(n+nlgn) = O(nlgn)。
3.5 快速排序的具体代码
这里介绍两种实现方式:递归法、用栈模拟递归。
3.5.1 递归实现
void quickSort(int* nums, int left, int right)
{
if (left >= right) return;
int keyIndex = partSort(nums, left, right);
quickSort(nums, left, keyIndex - 1);
quickSort(nums, keyIndex + 1, right);
}
3.5.2 用栈模拟递归
void quickSort(int* nums, int left, int right)
{
Stack* st = StackInit();
StackPush(st, numsSize - 1);
StackPush(st, 0);
while (!StackEmpty(st))
{
int left = StackTop(st);
StackPop(st);
int right = StackTop(st);
StackPop(st);
int keyIndex = partSort1(nums, left, right);
if (keyIndex+1 < right) {
StackPush(st, right);
StackPush(st, keyIndex + 1);
}
if (left < keyIndex - 1) {
StackPush(st, keyIndex - 1);
StackPush(st, left);
}
}
StackDestroy(st);
}
写在最后:感谢我的同学 lhx 在3.4.2节计算期望时间中对于求解 的渐进上界的帮助。