常见排序算法的基本原理、代码实现和时间复杂度分析

  排序算法无论是在实际应用还是在工作面试中,都扮演着十分重要的角色。最近刚好在学习算法导论,所以在这里对常见的一些排序算法的基本原理、代码实现和时间复杂度分析做一些总结 ,也算是对自己知识的巩固。

一、插入排序

1、原理:

  插入排序将一个位置数组seq分为两个部分:已排序的部分seq1和未排序的部分seq2。程序依次遍历seq2中的元素seq2[i],在seq1中寻找合适的位置插入。

2、实现:

  为了方便元素插入时的移位操作,首先将seq2中待插入的元素seq2[i]保存为key。然后在seq1中从后往前地依次比较key与seq1[i]的大小,每遍历一个大于等于key的元素,就将其向后移动一位;当找到一个小于key的元素时,该元素不再移动,其后一个位置就是key要插入的位置。

void InsertionSort(vector<int>& nums) {
	if (nums.size() < 2) return;

	for (size_t i = 1; i < nums.size(); i++) {
		int key = nums[i];	// 未排序区域的第一个位置,即待插入元素;该位置在元素后移的过程中会被覆盖,故需先保存
		int j = i - 1;		// 已排序区域的最后一个位置

		while (j >= 0 && nums[j] > key) {	// 从后向前查找插入位置
			nums[j + 1] = nums[j];	// 将比key的元素后移
			j--;	// 向前查找插入位置 
		}
		nums[j + 1] = key;	// 插入待排序元素
	}
}

3、时间复杂度分析:

  插入排序的时间复杂度分析比较简单。从原理中可以看出,插入排序最多需要进行两层遍历,即每遍历到一个元素,均需要对它前面的有序元素进行一次遍历,找到相应的插入位置;从代码中也可以看出程序需要执行两层循环,故插入排序的时间复杂度为O(n2)。

二、快速排序

1、原理:

  快速排序采用“分治”的思想,即首先从输入的数组seq中选出一个元素x作为主元,然后将所有小于等于x的元素放在x的左边,组成一个子数组;大于x的元素放在x的右边,组成另一个子数组。接着对这两个子数组重复上述的步骤,直至子数组中只有一个元素。由于在“分”的过程中,各个元素的大小顺序就已经确定,故不需要合并的操作。

2、实现:

  根据原理,快速排序的基本过程用伪代码可表示为:

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

  可见快速排序的实现关键就在于对数组的分割Partition的实现。下面介绍两种方法来实现这一步骤。

(1)法一:

  将数组分为四个区域,从前往后依次为小于等于x的区域一,大于x的区域二,待操作的区域三以及主元x所在的区域四(即数组的最后一个位置)。从前往后依次遍历待操作的区域三的所有元素,若元素大于x,则位置不变,此时区域二就向后延长了一个位置,如下图中(a)所示;若元素小于等于x,则将该元素与区域一的后一个元素(即区域二的第一个元素)的位置互换(注意:区域一的所有元素均小于x,但区域一的元素并不是按从小到大排序的,故进行位置交换时不需要将整个区域向后移动),此时区域一就向后延长了一个位置,区域二整体向后移动了一个位置,如下图中(b)所示。将区域三所有的元素遍历之后,区域三即消失,此时再将主元x与区域二的第一个元素位置互换。至此,实现了以x为主元对数组的分割。
说明:为了防止数组的排列使算法的时间时间度最大的情况出现,可在每次分割前,随机从数组中选择一个数作为主元,再将主元与数组的最后一个元素进行位置互换(由于小于等于x的区域和大于x的区域是动态增长的,因此在进行分割操作前,x的位置一定位于数组的第一个或最后一个位置)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yTxoEpFI-1587104796629)(https://ws1.sinaimg.cn/large/ebe836bagy1fvtn1fnzitj20dn0betaq.jpg)]

代码实现如下:

/* 交换两个元素的值 */
inline void swap(int& a, int& b) {
	int tmp = a;
	a = b;
	b = tmp;
}

void partition(vector<int>& nums, int left, int right) {
	if (left < right) {

		/* 随机产生一个主元,防止出现快排的最坏情况 */
		srand((unsigned int)time(0));	// 随机数种子
		int idx = rand() % (right - left + 1) + left;	// 产生一个随机下标
		swap(nums[idx], nums[right]);

		/* 快速排序 */
		int i = left - 1;	// 小于主元元素的区域
		for (int j = left; j < right; j++) {
			if (nums[j] <= nums[right]) {
				swap(nums[++i], nums[j]);	// 将nums[j]放到小于主元的区域
			}
		}
		swap(nums[++i], nums[right]);		// 将主元放在大于主元元素区域的第一个位置

		partition(nums, left, i - 1);	// 主元的位置为i
		partition(nums, i + 1, right);
	}
}

void QuickSort1(vector<int>& nums) {
	if (nums.size() < 2) return;

	partition(nums, 0, nums.size() - 1);
}

(2)法二:

  法二来自于网上看到的一篇博客,其基本思想与法一有一些类似。选择待排序数组的第一个元素作为主元,数组从前往后依次为x所在的区域一、小于等于x的区域二、待操作的区域三和大于x的区域四。先从后往前的遍历区域三,直至找到一个小于x的元素,则该元素之后的区域三的所有位置均可划到区域四中,然后将该元素插入到区域三的第一个位置,则区域二向后延长了一个位置;再从前往后的遍历区域三,直至找到一个大于等于x的元素,则该元素之前区域三的所有位置均可划到区域二中,再将该元素插入到区域三的最后一个位置,则区域四向前延长了一个位置。当区域二和区域四相遇时,区域三消失,所有的元素均完成遍历操作,将主元插到区域二的最后一个位置。
  原文链接:https://blog.csdn.net/MoreWindows/article/details/6684558
  代码实现如下:

void QuickSort2(vector<int> &seq, int left, int right)
{
    if(left < right){
    	/*随机产生一个主元*/
        srand((unsigned int)time(0));
        int tmp = rand()%(right - left + 1) + left; 
        swap(seq[tmp], seq[left]);
        
        int x = seq[left];   //数组的第一个元素作为主元
        int i = left;
        int j = right;

        while(i < j){
            /*从后往前查找小于主元的数*/
            while(j > i && seq[j] >= x)
                j--;
            if(i < j){
                seq[i++] = seq[j];  //seq[i]为区域二的第一个元素
            }
            /*从前往后查找大于主元的数*/
            while(i < j && seq[i] < x)
                i++;
            if(i < j){
                seq[j--] = seq[i];    //seq[j]为区域二的最后一个元素
            }   //每一次循环结束,均有s[i] = s[j+1]
        }   	//至此,小于i的位置均小于主元,大于j的位置均大于等于主元
        seq[i] = x;	//将主元插入到区域一的最后一个位置

        QuickSort2(seq, left, i - 1);	//主元的位置为i
        QuickSort2(seq, i + 1, right);
    }
}

3、时间复杂度分析:

  设规模为n的问题的时间复杂度为T(n),则由前面的分析可知,T(n) = T(q) + T(n-q-1) + θ(n)成立,其中q为小于等于主元的子数组规模,θ(n)为Partition函数(本文将Partition整合到了快速排序的整个程序中了)的时间复杂度。求解快速排序的时间复杂度的关键在于了解Partition中每一个元素被比较的次数。对于该式的求解较为复杂,在此直接给出结论,对详细的求解过程有兴趣的读者可参考《算法导论》第七章《快速排序》的相关内容。
  快速排序排序的平均时间复杂度为O(nlgn)。
  快速排序的性能依赖于输入数据的排列情况,也即在“分”的过程中对数组的划分情况。下面简单的说明最好和最坏的情况划分。
  用递归树来分析前面的递推式,假设原问题的代价为cn,其中c为常数,n为问题规模,将原问题以常数比例(假设为9:1)划分为两个子问题,再将这两个子问题分别按照原比例划分,重复该过程,直至问题的规模降为1。过程如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UJfV9D3j-1587104796632)(https://ws1.sinaimg.cn/large/ebe836bagy1fvtn1hsjhmj20iv0c50ug.jpg)]

  由上图可知,递归树的每一层问题的总代价最大均为cn,则原问题的代价就取决于递归树的层数,层数越多,问题的代价就越大。显然,当递归树为满二叉树时,层数最少,为lgn,此时总代价为cnlgn;考虑极端情况,当递归树退化为线性结构,即每次将问题规模划分为0和n-1两个子问题时,层数最多,为n,此时总代价为cn2。故只要问题的规模按照常数比例划分,快速排序的时间复杂度均为O(nlgn),当问题规模按照0和n-1的比例划分时,快速排序的性能最差,时间复杂度为O(n2)。待排序的数组越有序,快速排序的性能越差。
  为避免最坏的情况出现,我们在选择主元时进行了随机化处理。虽然在理论上仍然有可能出现最坏情况,但可能性已经微乎其微。当然,我们要避免让快速排序处理元素完全相同的输入序列。

三、归并排序

1、原理:

  归并排序同样采用“分治”的思想。算法将待排序的序列平均分为两个子序列,然后将这两个子序列再分别平均分为两个子序列,重复该过程,直至序列中只有一个元素,此时序列是有序的。最后再将两个已排好序的子序列合并,产生已排序的结果。过程如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ckYqzdg7-1587104796635)(https://ws1.sinaimg.cn/large/ebe836bagy1fvtp50esfjj20i90a176k.jpg)]
用伪代码表示该过程为:

MergeSort(A, p, r)
	if p < r
		q = (p + r) / 2
		MergeSort(A, p, q)
		MergeSort(A, q+1, r)
		Merge(A, p, q, r)

  由上述分析可知,归并排序的重点在于如何实现对两个有序子数组的合并。

2、实现:

  Merge函数的输入序列是一个由两个长度相同的有序序列seq1和seq2组成的序列seq。首先比较seq1和seq2的第一个元素的大小,将其中较小的元素(假设为seq1[0])保存到临时序列tmp中,并将指向seq1元素的指针i向后移动一位,再比较seq1[1]与seq2[0]的大小,将其中较小的元素保存到tmp中seq1[0]之后的位置,重复该过程,直至seq1和seq2其中的一个序列的所有元素均被保存到tmp中,假设该序列为seq1,则此时seq2中元素可能没有被完全遍历,这些没有被遍历到的元素一定都大于此时tmp中的所有元素且是有序排列的,因此将seq2中这些没有遍历到的元素顺序不变的保存到tmp的尾部,即实现了对seq1和seq2这两个有序序列的有序合并。最后用tmp中的元素覆盖seq中的元素,就实现了对seq中所有元素的有序排列。代码实现如下:

/* 合并两个有序数组 */
void Merge(vector<int>& nums, int left, int mid, int right) {
	int i = left, j = mid + 1;
	vector<int> tmp;

	while (i <= mid && j <= right) {
		if (nums[i] < nums[j]) {
			tmp.push_back(nums[i++]);
		}
		else {
			tmp.push_back(nums[j++]);
		}
	}

	while (i <= mid) {
		tmp.push_back(nums[i++]);
	}
	while (j <= right) {
		tmp.push_back(nums[j++]);
	}

	for (size_t i = 0; i < tmp.size(); i++) {
		nums[left + i] = tmp[i];	// 用已排序的元素覆盖原数组中未排序的元素
	}
}

/* 将待排序数组分为两个子数组,分别排序后再合并 */
void Msort(vector<int>& nums, int left, int right) {
	if (left < right) {
		int mid = left + ((right - left) >> 1);
		Msort(nums, left, mid);
		Msort(nums, mid + 1, right);
		Merge(nums, left, mid, right);
	}
}

void MergeSort(vector<int>& nums) {
	if (nums.size() < 2) return;
	
	Msort(nums, 0, nums.size() - 1);
}

3、时间复杂度分析:

  用递归树来分析归并排序的时间复杂度,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sYsEjRug-1587104796636)(https://ws1.sinaimg.cn/large/ebe836bagy1fvtptyhogej20go0d0wg5.jpg)]
  由上图可知,递归树高度为1 + lgn,每层的总代价为cn,则原问题的总代价为cnlgn + cn,故归并排序的时间复杂度可表示为θ(nlgn)。

四、冒泡排序

1、原理:

  冒泡排序应该是我们最早接触的,也是最为简单的排序算法。它从前向后地遍历除最末尾的数的数组中的每一个数,当遍历到某一个数seq[i]时,便与它后面的一个数seq[i+1]作比较,若seq[i]较小,则seq[i]的位置不变,再遍历下一个数seq[i+1];若seq[i]较大,则将它的位置与seq[i+1]的位置对调,再遍历下一个数seq[i+1]。

2、实现:

  冒泡排序的实现较为简单。每次遍历之后,都会找出待遍历数中最大的一个数,并将其放在待遍历数的最后,则在下一次遍历时,就不再遍历之前已经被筛选出来的数。所以我们在编写程序时,要注意一下遍历结束时元素的下标。下面给出三种效率不同的实现方法。

(1)法一:

  第一种方法最为简单,暴力遍历每一个元素。

void BubbleSort1(vector<int>& nums) {
	if (nums.size() < 2) return;

	for (size_t i = 0; i < nums.size(); i++) {
		for (size_t j = 0; j < nums.size() -1 - i; j++) {
			if (nums[j] > nums[j + 1]) {
				int tmp = nums[j];
				nums[j] = nums[j + 1];
				nums[j + 1] = tmp;
			}
		}
	}
}

(2)法二:

  第一种方法不依赖于输入的序列,无论输入的序列怎样排列,时间复杂度均为θ(n2),显然对于某些输入序列,这种方法会产生时间的浪费。如果在某一次遍历中没有发生元素位置的交换,则说明所有的元素已经按照从小到大的顺序排列,那么排序工作就已经完成,不需要进行下一次遍历了。具体实现如下。

void BubbleSort2(vector<int>& nums) {
	if (nums.size() < 2) return;
	
	bool isChange = true;
	int end = nums.size() - 1;	// 每次遍历结束的位置
	while (isChange) {
		isChange = false;	// 每次遍历开始前还未发生交换

		for (int i = 0; i < end; i++) {
			if (nums[i] > nums[i + 1]) {
				int tmp = nums[i];
				nums[i] = nums[i + 1];
				nums[i + 1] = tmp;

				isChange = true;
			}
		}
		end--;	// 末尾元素已经有序,无需遍历
	}
}

(3)法三:

  在前两种方法中,只要某一次遍历开始了,就一定会遍历完所有待遍历的元素。如果待遍历的元素中有一部分已经是按照从小到大的顺序排列了,则遍历这部分元素显然会产生时间上的浪费,故可对第二种方法继续优化。
  在某一次遍历中,我们将最后一次元素交换发生的位置记为position,则position之后的元素一定是排好序的,且均大于position之前的元素。因此,在下一次遍历中,我们就不再遍历这部分元素。

void BubbleSort3(vector<int>& nums) {
	if (nums.size() < 2) return;

	int pos = nums.size() - 2;	
	while (pos > 0) {
		int end = pos;
		pos = 0;	// 清除上一次的记录,以防某次遍历未发生交换,则应结束遍历,而不是保留了上一次的记录
		for (int i = 0; i <= end; i++) {
			if (nums[i] > nums[i + 1]) {
				int tmp = nums[i];
				nums[i] = nums[i + 1];
				nums[i + 1] = tmp;

				pos = i;
			}
		}
	}
}

3、时间复杂度分析:

  法一的方法完全不依赖输入的序列,无论输入的序列如何排列,时间复杂度均为θ(n2)。法二和法三的时间复杂度依赖于输入序列的排列情况,当输入序列的情况较好,即存在部分已经排好序的序列,则运行时间会降低;排列情况最差,即输入序列中的所有元素按照从大到小排列时,时间复杂度为θ(n2)。故三种冒泡排序方法的时间复杂度可统一为O(n2)。

五、堆排序

1、原理:

  堆排序是一种利用堆的性质进行排序的排序算法。堆的性质不在此赘述。我们可以使用最大堆进行从小到大排序,使用最小堆进行从大到小排序。下面以最大堆为例:
  对于一个最大堆,每一个非叶节点值都比它的左/右儿子节点的值要大,故堆顶元素是堆中元素最大的元素。将堆顶元素,即堆中最大的元素,与堆中的最后一个元素进行交换,此时最大堆的性质遭到破坏,故需要维护最大堆性质(此时最后一个元素,即最大的元素,已不算在堆中,是已排好序的元素);从堆中最后一个元素开始,将每一个元素与堆顶元素交换,并维护最大堆性质,直至遍历到堆顶,即可完成堆排序。

2、实现:

  由堆排序的原理可知,堆排序分为三个部分:使用待排序数组的元素建立一个最大堆,逐个将最大堆中的最后一个元素与堆顶元素交换,维护最大堆性质。

/* 交换两个变量的值 */
inline void swap(int& a, int& b) {
	int tmp = a;
	a = b;
	b = tmp;
}

// 存储堆元素的数组下标从0开始时,对于数组中下标为i的元素,及第i + 1个元素:
// LeftChild(i) = 2*i + 1
// RightChild(i) = 2*i + 2
// Parent(i) = (i - 1) / 2
/* 维护最大堆性质 */
void Adjust(vector<int>& nums, int current, int end) {
	int left = 2 * current + 1;		// 当前节点的左儿子
	int right = 2 * current + 2;	// 当前节点的右儿子
	int MaxIdx = current;			// 当前遍历的非叶节点及其左右儿子中,最大的节点的

	/* 确定当前节点及其左右儿子中,最大的节点的下标,并记录为MaxIdx */
	if (left <= end && nums[MaxIdx] < nums[left]) {
		MaxIdx = left;
	}
	if (right <= end && nums[MaxIdx] < nums[right]) {
		MaxIdx = right;
	}

	/* 若最大节点不是当前节点,而是其子节点,则需要调整 */
	if (MaxIdx != current) {
		swap(nums[MaxIdx], nums[current]);
		Adjust(nums, MaxIdx, end);	// 逐级向下调整,直至不能调整(节点下标超出数组范围)
	}
}

/* 堆排序 */
void HeapSort(vector<int>& nums) {
	if (nums.size() < 2) return;

	/* 建立最大堆 */
	for (int i = nums.size() / 2 - 1; i >= 0; i--) {
		Adjust(nums, i, nums.size() - 1);	// 从最后一个非叶节点开始,依次对每一个非叶节点维护最大堆性质
	}

	/* 从后往前,依次将每一个元素与未排序的元素中的最大元素进行交换,然后维护最大堆性质,直至完成整个数组的排序*/
	for (int i = nums.size() - 1; i > 0; i--) {
		swap(nums[0], nums[i]);
		Adjust(nums, 0, i - 1);
	}
}

六、计数排序

1、原理:

2、实现:

七、基数排序

1、原理:

2、实现:

八、桶排序

1、原理:

2、实现:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值