排序算法(二) 选择排序和堆排序

一、排序的概念与分类

1.1 排序的概念

        排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

        稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

        内部排序:数据元素全部放在内存中的排序。

        外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

1.2 常见的排序算法及分类

本文介绍选择排序中的直接选择排序堆排序

二、直接选择排序

2.1 直接选择排序的核心思想

       选择排序的基本思想是每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置(结束位置),直到全部待排序的数据元素排完 。

2.2 直接选择排序的伪代码和流程

        第一次进入第一行for循环时,minIndex指向 i 所在位置,通过4、5行min指向数组A[0...n-1]的最小元素(如图a),第6行交换A[i]与A[minIndex](如图b)。第二次进入第1行for循环,minIndex指向i所在位置,通过4、5行得到子数组A[1...n-1]的最小元素,也就是整个数组A的第二小元素(如图c),第6行交换A[i]与A[minIndex],此时子数组A[0...i]已经按照从小到大的顺序排序(如图d)。一直执行该过程(如图e、f)直到最后一次循环 i = A.length-2,minIndex指向i所在位置,通过4、5行得到子数组A[i...n-1]的最小元素,也就是整个数组A[0...n-1]的第i+1小元素(如图g),第6行交换A[i]和A[minIndex], 此时子数组A[0...i]已经按照从小到大的顺序排序(如图h)。之后,i = A.length-1,for循环结束,由于此时剩下为排序的就是整个数组最大的元素并且位于数组最后,于是A按照从小到大的顺序排序完成。

2.3 直接选择排序的具体代码

void selectSort(int* nums, int numsSize) {
	for (int i = 0; i < numsSize - 1; i++)
	{
		int minIndex = i;
		for (int j = i + 1; j < numsSize; j++)
		{
			if (nums[minIndex] > nums[j])
				minIndex = j;
		}
		swap(&nums[minIndex], &nums[i]);
	}
}

2.4 直接选择排序的循环不变式和正确性分析 

        为证明SELECTION-SORT(A)的正确性,我们使用如下的循环不变式:

在第1行每次for循环的开始,子数组A[0...i]已按从小到大排序,并且A[0...i-1]由整个数组A中的最小的i个元素组成。

        于是我们需要证明这一不变量在第一次循环前为真,并且每次循环迭代都维持不变,当循环结束时,算法的正确性便可由这一不变量证明。

        1. 初始化。首先证明在第一次循环迭代之前(当 i = 0 )时,循环不变式成立。子数组A[0]由一个元素组成,自然是已经排序好的,A[0...i-1]为空。这表明第一次循环迭代之前循环不变式成立。

        2. 保持。由循环不变式,第i次for循环开始前子数组A[0...i]已按从小到大排序,并且A[0...i-1]由整个数组A中的最小的 i 个元素组成,于是A[i...n-1]中最小的元素是整个数组A中第 i+1 小的元素,for循环的2、3、4、5行操作选出了A[i...n-1]中最小的元素,通过第6行将其与A[i]交换,这使得子数组A[0...i]由整个数组A中最小的 i+1 个元素组成,那么自然A[i+1]>=A[i],于是子数组A[0...i+1]已经按照从小到大排序。在第1行的for循环里循环变量 i 每次加1,那么对于下一次循环迭代开始前循环不变式仍然保持成立。

        3. 终止。当for循环终止时,i = A.length-1,代入循环不变式中可得:数组A[0...n-1]已经按从小到大排序,于是SELECTION-SORT(A)的正确性得证。

2.5 直接选择排序的性能分析

        1. 时间复杂度:对于每个A[i],第3行的for循环获取minIndex都要经过 n-1-i 次迭代,于是排序总消耗为\sum_{i=0}^{n-2}c*(n-1-i),其中c为常数,因此直接选择排序的时间复杂度为Θ(n^2)

        2. 空间复杂度:直接选择排序只需要常数的额外空间,因此空间复杂度为O(1)

        3. 稳定性:直接选择排序时一种不稳定的排序算法。

三、堆排序

3.1 堆

3.1.1 堆的概念与分类

        堆的逻辑结构是一棵完全二叉树,物理结构是数组。如下图所示,将完全二叉树按层序遍历顺序依次填入到数组中,就可以得到一个通过能下标来确定各个节点的父子关系的数组。

父子节点之间的下标关系如下:

leftChild = parent * 2 + 1

rightChild = parent * 2 + 2

parent = (child-1) / 2

        堆是一种特殊的完全二叉树, 其任一节点的关键字是其子树所有节点的最大值或者最小值。其中,父节点的值小于子节点的堆称为最小堆或小顶堆(MinHeap),父节点的值大于子节点的堆称为最大堆或大顶堆(MaxHeap)。

3.1.2 堆的性质

        根据堆的定义方式,自然可以得到如下关于堆的性质: 

        在最大堆中,除了根节点以外的所有节点i都要满足:A[parent(i)] >= A[i],也就是说,某个根节点的值至多与其父节点一样大;因此,最大堆中的最大元素存放在根节点中,并且任一子树中该子树包含的所有根节点的值都不大于该子树根节点的值。最小堆的性质正好相反,在最小堆中,除了根节点以外所有的节点i都要满足:A[parent(i)] <= A[i],也就是说,某个根节点的值最小与其父节点一样大;因此,最小堆中的最小元素存放在根节点中,并且任一子树中该子树包含的所有根节点的值都不小于该节点子树根节点的值。

        如果把堆看成一棵树,定义一个堆中节点的高度就为该节点到叶节点最长简单路径边上的边数,进而我们可以把堆的高度定义为根节点的高度。于是一个包含n个元素的堆可以看成一棵高为[lgn](不超过lgn的最大整数)的完全二叉树。

3.1.3 堆性质的维护

        HEAPIFY即向下调整算法是用于维护堆性质的重要算法,它的输入为一个数组A和一个下标i,效果为在根节点为LEFT(i)和RIGHT(i)的二叉树都是堆的前提下,通过让A[i]在堆中逐级下降,从而使以下标i为根节点的子树重新满足堆的性质。接下来以维护最大堆的算法MAX-HEAPIFY为例进行说明。

        当输入为MAX-HEAPIFY(A, 1)时,执行过程如下:

        在初始状态(如图a),节点i=1处,违反了最大堆的性质,于是交换A[i]和A[largest],并更新i进入下一次MAX-HEAPIFY(A, largest)(如图b)。

         在图b中,通过交换A[1]和A[3]的值,恢复了节点1的最大堆性质,但这也导致节点3违背了最大堆的性质,于是继续交换A[i]和A[largest],此时i = 3,如图c。此时,节点i没有孩子,即LEFT(i)和LEFT(j)都大于等于A.heapSize,递归结束,最大堆维护完成。

         具体实现MAX-HEAPIFY的时候可以按照伪代码通过递归的方式,也可以直接循环。

void MaxHeapify(int* nums, int numsSize, int root)
{
	int left = 2 * root + 1;
	int right = 2 * root + 2;
	int largest;
	if (left < numsSize && nums[left] > nums[root])
		largest = left;
	else    largest = root;
	if (right < numsSize && nums[right] > nums[largest])
		largest = right;
	if (largest != root){
		swap(&nums[largest], &nums[root]);
		MaxHeapify(nums, numsSize, largest);
	}
}
void MaxHeapify(int* nums, int numsSize, int root)
{
	int left = 2 * root + 1;
	int right = 2 * root + 2;
	int largest;
	while (left < numsSize)
	{
		if (nums[left] > nums[root])
			largest = left;
		else    largest = root;
		if (right < numsSize && nums[right] > nums[largest])
			largest = right;
		if (root != largest) {
			swap(&nums[largest], &nums[root]);
			root = largest;
            left = 2 * root + 1;
		    right = 2 * root + 2;
		}
        else    return;
	}
}

 3.1.4 堆的建立

        堆的建立采用自底向上的方法通过HEAPIFY把一个大小为 n = A.length 的数组A[0....n-1]转化为一个堆。当建立最大堆时,只要从倒数第二层的节点(叶子节点的上一层)开始,从右向左,从下到上地调用MAX-HEAPIFY不断进行调整即可。伪代码如下:

        为证明BUILD-MAX-HEAP(A)的正确性,我们使用如下的循环不变量:

 在第2-3行中每一次for循环的开始,节点i+1,i+2,....,A.length-1都是一个最大堆的根节点      

        于是我们需要证明这一不变量在第一次循环前为真,并且每次循环迭代都维持不变,当循环结束时,算法的正确性便可由这一不变量证明。

        1.初始化。在第一次循环迭代前,i = PARNET(A.length-1),而下标大于i的节点都是叶子节点,因此循环不变量在初始时成立。

        2.保持。对于一个下标为i的节点,其孩子节点的下标一定比i大,根据循环不变量,它们都是最大堆的根,这也是MAX-HEAPIFY方法的前提条件。并且,MAX-HEAPIFY(A, i)维护了节点i,...,A.length-1的最大堆性质。在for循环里i每次减1,这为下一次循环重新建立了循环不变量。

        3.终止。过程终止时,i = -1。带入循环不变量,得到节点0,1,...,A.length-1都是一个最大堆的根,特别的,i = 0就是整个最大堆的根。于是BUILD-MAX-HEAP的正确性得以证明。

        具体代码如下:

void buildMaxHeap(int* nums, int numsSize)
{
	for (int i = (numsSize - 1 - 1) / 2; i >= 0; i--)
		MaxHeapify(nums, numsSize, i);
}

3.2 堆排序算法

3.2.1 堆排序算法的核心思想

        作为一种特殊的选择排序,其核心还是在如何选取最大值/最小值上。

        根据3.1节内容,堆排序算法通过BUILD-MAX-HEAP将数组A[0...n-1]建成一个最大堆,其中n=A.length。3.1.2节中最大堆的性质,max = A[0]就是数组的最大元素,通过把A[0]和A[n-1]互换,我们可以让该元素放到正确的位置上并且排除已经取出的最大值(记为max)对之后选取最大值的影响。当取出最大值并完成互换后,我们需要选取除max外的最大元素并放到A[n-2]上,由于A[0...n-1]可能已经失去了最大堆的性质,我们需要对A[0]进行MAX-HEAPIFY(A, 0)以从A[0]处获取所需最大元素(A[n-1]就是第一次被选出的最大值,于是只需要考虑0到n-2的元素即可),并将A[0]与A[n-2]互换。重复这一过程直到需要维护的堆的大小下降为1。

3.2.2 堆排序算法的伪代码及流程

        伪代码如下:

         在第一次循环前,如图a,已将数组A建为大根堆,在第一次进入for循环时,i = A.length-1,之后如图b,将A[0]与A[i]交换,i--,并且维护以下标0为根节点的子数组A[0...i]的最大堆性质,使得A[0...i]为大根堆,A[0]为整个数组A第二大的元素,A[n-1]处为整个数组最大元素,A[n-1]已经排序完成。

         在第二次循环前,如图a,已将数组A[0...i]建为大根堆,在第一次进入for循环时,如图b,将A[0]与A[i]交换,i--,并且维护以下标0为根节点的子数组A[0...i]的最大堆性质,使得A[0...i]为最大堆,A[0]为整个数组A中第三大的元素,子数组A[i+1...n-1]为数组A中已经排序好的部分。

        一直循环,直到 i=0 时,子数组A[1...n-1]为数组A已经排序好的部分,A[0]为整个数组第n小的元素,于是整个数组A排序完成。结果如下:

3.2.3 堆排序算法的具体代码

void heapSort(int* nums, int numsSize)
{
	buildMaxHeap(nums, numsSize);
	for (int i = numsSize-1; i>0; i--){
		swap(&nums[0], &nums[i]);
		MaxHeapify(nums, i, 0);
	}
}

3.2.4 堆排序的循环不变式和正确性分析         

        为证明HEAPSORT(A)的正确性,我们使用如下的循环不变式:

在算法的第2-5行for循环每次迭代开始时,子数组A[0...i]是一个包含了数组A[0...n-1]中第 i+1小元素的最大堆,而子数组A[i+1....n-1]中包含数组A[0...n-1]中从小到大排序的 n-i-1 个最大元素。

         于是我们需要证明这一不变量在第一次循环前为真,并且每次循环迭代都维持不变,当循环结束时,算法的正确性便可由这一不变量证明。

        1.初始化。此时i = n-1 ,子数组A[0...i]就是整个数组A,由于在循环前已经将A建为了最大堆,而子数组A[i+1...n-1]为空,因此循环不变量在初始时成立。

        2.保持。当i不为0和n-1时,即处于第n-i次循环时,循环不变式为真。那么此时子数组A[0...i]包含了整个数组A中第 i+1 小的元素,并且A[0...i]是一个最大堆,因此A[0]保存了子数组A[0.. i]中的最大元素,也就是整个数组A[0...n-1]中第n-i大的元素。for循环的3行是将整个数组A中第 n-i 大的元素A[0]取出与最大堆A[0...i]的末尾A[i]互换,此时A[i]成为了整个数组A中第 n-i 大的元素,而A[i+1...n-1]中包含整个数组A中从小到大排序的共 n-i-1 个最大元素,于是A[i]与A[i+1...n-1]共同构成的A[i...n-1]包含整个数组A中从小到达排序的共 n-i 个最大元素,因此在进入下一次迭代 i-1 前,循环不变式的后半部分(子数组A[i+1....n-1]中包含数组A[0...n-1]中从小到大排序的 n-i-1 个最大元素)为真。上一步将最大堆A[0.. i]中的最大元素取出,并且将堆的末尾元素交换到了A[0]的位置,因此堆的大小减小了1,于是执行for循环的4、5行,保持最大堆的性质,此时最大堆为A[0...i-1],并且它包含了整个数组A中第i小的元素,于是进入下一次迭代 i-1 前,循环不变式的前半部分为真(子数组A[0...i]是一个包含了数组A[0...n-1]中第 i+1小元素的最大堆)。至此,循环不变式的保持得以证明。

        3.终止。过程终止时,i = 0。根据循环不变式,子数组A[0]是一个包含数组A[0...n-1]中最小元素的最小堆,也就是A[0]是数组A[0...n-1]的最小元素,而子数组A[1...n-1]中包含了数组A[0...n-1]中从小到大顺序排序的n-1个最大元素。自然A[0...n-1]就是按照从小到大顺序排序的数组,于是heapSort的正确性得证。

3.3 算法的性能分析

3.3.1 时间复杂度和空间复杂度分析

        按照之前的分析,HEAPSORT算法的时间复杂度取决于MAX-HEAPIFY的时间复杂度和执行次数。以下是对MAX-HEAPIFY的时间复杂度分析。

        对于一棵以 i 为根节点、大小为n的子树,MAX-HEAPIFY的时间代价包括:调整A[i]、A[LEFT(i)]、A[RIGHT(i)]的时间代价Θ ​​​​​​(1),加上在以 i 的一个孩子为根节点的子树上运行时间代价(这里假设递归发生)。因为每个孩子的子树大小为 2*n/3(树的最底层恰好半满,这一结论在3.4节中补充说明),我们用下面这个递推式刻画MAX-HEAPIFY的运行时间:T(n) \leqslant T(2n/3) + \Theta(1) 。根据主定理,上述递归式的解为T(n) = O(lgn)。在3.1.2节提到:一个包含n个元素的堆可以看成一棵高为Θ(lgn)的完全二叉树。因此对于一个高度为h的节点 i 来说,MAX-HEAPIFY(A, i)的时间复杂度是O(h),由于只需要常数大小的额外空间,因此空间复杂度为O(1)。

        接下来,我们确定BUILD-MAX-HEAP的时间消耗,注意到,不同节点 i 的MAX-HEAPIFY(A,i)的运行时间与该节点的高度h有关,于是对于高度为h的一层节点来说,最多包含节点数为\frac{n}{2^{h+1}},因此该层所有节点MAX-HEAPIFY的运行时间总和为\frac{n}{2^{h+1}}*O(h),于是构建一个高度为 h ,大小为 n 的最大堆的时间消耗为:\sum_{0}^{lgn}\frac{n}{2^{h+1}}*O(h) = O(n\sum_{0}^{lgn}\frac{h}{2^{h}}),由于\sum_{0}^{\infty}\frac{h}{2^h} = 2,代入可得BUILD-MAX-HEAP的时间复杂度:O(n),由于只需要常数大小的额外空间,因此空间复杂度为O(1)。

        在确定MAX-HEAPIFY和BUILD-MAX-HEAP的时间复杂度和空间复杂度后,我们就可以分析HEAPSORT的时间复杂度和空间复杂度。HEAPSORT在for循环前的建堆消耗为O(n)属于低阶项,for循环的时间消耗才是决定算法时间复杂度的部分。我们只考察建堆后的排序时间,每次迭代交换A[0]和A[i]后,堆的大小变为 i ,并且高度为 lgi,然后调用MAX-HEAPIFY来维护堆的性质,最坏情况下A[0]会被降至底层,下降次数为此时堆的高度,因此最坏情况下HEAPSORT的总开销为:\sum_{i =1}^{n-1}[lgi] \geq \Theta (nlgn),即Ω(nlgn) 。(此处数学推导在3.4节补充说明)

3.4 补充说明

3.4.1 对于一棵大小为n的完全二叉树,下标为 i 结点的子树大小至多为2n/3。

        对于一棵大小确定为 n 的完全二叉树,下标为 i 结点的最大子树显然在二叉树底层半满的情况下出现,如图:

        不妨假设左子树节点数量大于右子树,设底层节点数量为x,那么若在底层补上x个节点便可构成一棵满二叉树,根据满二叉树的性质有:

x + n = 2 * (2 * x) - 1  => x = (n + 1) / 3

        进而左子树的节点数量为构造的(满二叉树大小-1)/2,即(x+n-1) / 2 = 2n/3 - 1/3 < 2n / 3。

3.4.2 主定理求解递推式

        主定理的内容如下:

        对于MAX-HEAPIFY的递推式T(n) \leqslant T(2n/3) + \Theta(1),f(n) = Θ(1) ,属于情况2,代入主定理可得T(n) = Θ(lgn)

3.4.3 证明\sum_{i = 1}^{n-1}[lgi]的渐进下界

        首先给出引理:lg(n!) = Θ(nlgn)。下面对该式进行证明:

        1. 先证:lg(n!) = O(nlgn)

         2.再证:lg(n!) = Ω(nlgn)

        3.综上,lg(n!) = Θ(nlgn)

        之后,根据[lgi]的定义(不超过lgi的最大整数)进行放缩,lg[i] >= lgi-1,最后代入lg(n!) = Θ(nlgn)即可。具体过程如下:

       \sum_{i =1}^{n-1}[lgi] \geq \sum_{i =1}^{n-1} (lgi-1) = lg(n-1)! -(n-1)= \Theta (nlgn)

  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ChenxuanRao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值