排序算法无论是在实际应用还是在工作面试中,都扮演着十分重要的角色。最近刚好在学习算法导论,所以在这里对常见的一些排序算法的基本原理、代码实现和时间复杂度分析做一些总结 ,也算是对自己知识的巩固。
插入排序将一个位置数组seq分为两个部分:已排序的部分seq1和未排序的部分seq2。程序依次遍历seq2中的元素seq2[i],在seq1中寻找合适的位置插入。
为了方便元素插入时的移位操作,首先将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; // 插入待排序元素
}
}
插入排序的时间复杂度分析比较简单。从原理中可以看出,插入排序最多需要进行两层遍历,即每遍历到一个元素,均需要对它前面的有序元素进行一次遍历,找到相应的插入位置;从代码中也可以看出程序需要执行两层循环,故插入排序的时间复杂度为O(n2)。
快速排序采用“分治”的思想,即首先从输入的数组seq中选出一个元素x作为主元,然后将所有小于等于x的元素放在x的左边,组成一个子数组;大于x的元素放在x的右边,组成另一个子数组。接着对这两个子数组重复上述的步骤,直至子数组中只有一个元素。由于在“分”的过程中,各个元素的大小顺序就已经确定,故不需要合并的操作。
根据原理,快速排序的基本过程用伪代码可表示为:
QuickSort(A, p, r)
if p < r
q = Partition(A, p, r)
QuickSort(A, p, q-1)
QuickSort(A, q+1, r)
可见快速排序的实现关键就在于对数组的分割Partition的实现。下面介绍两种方法来实现这一步骤。
将数组分为四个区域,从前往后依次为小于等于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);
}
法二来自于网上看到的一篇博客,其基本思想与法一有一些类似。选择待排序数组的第一个元素作为主元,数组从前往后依次为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);
}
}
设规模为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)。待排序的数组越有序,快速排序的性能越差。
为避免最坏的情况出现,我们在选择主元时进行了随机化处理。虽然在理论上仍然有可能出现最坏情况,但可能性已经微乎其微。当然,我们要避免让快速排序处理元素完全相同的输入序列。
归并排序同样采用“分治”的思想。算法将待排序的序列平均分为两个子序列,然后将这两个子序列再分别平均分为两个子序列,重复该过程,直至序列中只有一个元素,此时序列是有序的。最后再将两个已排好序的子序列合并,产生已排序的结果。过程如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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)
由上述分析可知,归并排序的重点在于如何实现对两个有序子数组的合并。
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);
}
用递归树来分析归并排序的时间复杂度,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sYsEjRug-1587104796636)(https://ws1.sinaimg.cn/large/ebe836bagy1fvtptyhogej20go0d0wg5.jpg)]
由上图可知,递归树高度为1 + lgn,每层的总代价为cn,则原问题的总代价为cnlgn + cn,故归并排序的时间复杂度可表示为θ(nlgn)。
冒泡排序应该是我们最早接触的,也是最为简单的排序算法。它从前向后地遍历除最末尾的数的数组中的每一个数,当遍历到某一个数seq[i]时,便与它后面的一个数seq[i+1]作比较,若seq[i]较小,则seq[i]的位置不变,再遍历下一个数seq[i+1];若seq[i]较大,则将它的位置与seq[i+1]的位置对调,再遍历下一个数seq[i+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;
}
}
}
}
第一种方法不依赖于输入的序列,无论输入的序列怎样排列,时间复杂度均为θ(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--; // 末尾元素已经有序,无需遍历
}
}
在前两种方法中,只要某一次遍历开始了,就一定会遍历完所有待遍历的元素。如果待遍历的元素中有一部分已经是按照从小到大的顺序排列了,则遍历这部分元素显然会产生时间上的浪费,故可对第二种方法继续优化。
在某一次遍历中,我们将最后一次元素交换发生的位置记为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;
}
}
}
}
法一的方法完全不依赖输入的序列,无论输入的序列如何排列,时间复杂度均为θ(n2)。法二和法三的时间复杂度依赖于输入序列的排列情况,当输入序列的情况较好,即存在部分已经排好序的序列,则运行时间会降低;排列情况最差,即输入序列中的所有元素按照从大到小排列时,时间复杂度为θ(n2)。故三种冒泡排序方法的时间复杂度可统一为O(n2)。
堆排序是一种利用堆的性质进行排序的排序算法。堆的性质不在此赘述。我们可以使用最大堆进行从小到大排序,使用最小堆进行从大到小排序。下面以最大堆为例:
对于一个最大堆,每一个非叶节点值都比它的左/右儿子节点的值要大,故堆顶元素是堆中元素最大的元素。将堆顶元素,即堆中最大的元素,与堆中的最后一个元素进行交换,此时最大堆的性质遭到破坏,故需要维护最大堆性质(此时最后一个元素,即最大的元素,已不算在堆中,是已排好序的元素);从堆中最后一个元素开始,将每一个元素与堆顶元素交换,并维护最大堆性质,直至遍历到堆顶,即可完成堆排序。
由堆排序的原理可知,堆排序分为三个部分:使用待排序数组的元素建立一个最大堆,逐个将最大堆中的最后一个元素与堆顶元素交换,维护最大堆性质。
/* 交换两个变量的值 */
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);
}
}