C++数据结构算法(二)排序算法集合

排序算法(Sorting Algorithm) 的作用在于对于给定的一个元素序列,输出满足某种顺序的该序列的一个排列。

C++数据结构算法(二)排序算法集合_第1张图片

代码实现 —— 数最小值

数组最小值

首先,如何找到n个元素的最小值,并记录它的位置?

  • 最开始,我们默认最小值出现在数组的第1位,所以,用于记录最小值位置的变量min_pos初始值为1

  • 然后,枚举数组中的每个元素,并且将当前记录的最小值和枚举到的第i个元素作比较,如果当前枚举到的元素更小,说明最小值不可能出现在原来的min_pos位置,而更有可能出现的位置i。所以,将min_pos更新为i

  • 当扫描完整个元素后,min_pos中的位置就是最小值出现的位置。

#include 
using namespace std;
int a[1010];
int n;

int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i]; 
    //注意:为保持认知一致,我们直接从数组第2个元素开始。数组第二个元素索引为1,不是从索引为0的元素开始哦。
    
    int min_pos = 1;    // 设置最小值位置的初始值为0,即a[1] = 0
    for (int i = 2; i <= n; ++i) {
        if (a[i] < a[min_pos])  // 比较当前枚举到的元素与当前记录位置的元素
            min_pos = i;        // 如果当前记录位置的元素更小,则更新最小值出现的位置
    }
    cout << "minimum value = " << a[min_pos] << endl;    // 输出最小值
    cout << "minimum value pos = " << min_pos << endl;   // 输出最小值的位置
    return 0;
}

int main() {
    
    int min_pos = 1;    // 设置最小值位置的初始值为0,即a[1] = 0
    for (int i = 2; i <= n; ++i) {
    // TODO 请补全代码找到最小值
        if(a[i]C++数据结构算法(二)排序算法集合_第2张图片

我们发现,这里我们使用了将原问题转换为规模更小的子问题的思路。

所以在选择排序的过程,相当于每次将当前序列未排序部分的最小元素归位,将未排序的序列长度缩小一位。同样,如果我们能够通过某种方式,把序列中的最大值移动到序列最后面,也可以起到将未排序序列长度缩小一位的效果。

 C++数据结构算法(二)排序算法集合_第3张图片

所以,这节课我们设计的排序过程分为两步:

  1. 通过“体育老师交换方法”,使得每次我们都能把最大值移动到序列的最后一位。
  2. 利用上面的步骤进行问题转换,将未排序的序列长度缩减一个。

这就是冒泡排序的具体思路,而上面提到的“将最大值移动到最后一位”的操作,就叫做冒泡操作。

冒泡排序的思路

  • 总结冒泡排序的思路:
  1. 冒泡排序分为n-1个阶段。
  2. 在第1个阶段,通过“冒泡”,将前n个元素的最大值移动到序列的最后一位。此时只剩前n-1个元素未排序。
  3. 在第i个阶段,此时序列前n-i+1个元素未排序。通过“冒泡”,我们将前n-i+1个元素中的最大值移动到最后一位。此时只剩前n-i个元素未排好序。
  4. 最终到第n-1个阶段,前2个元素未排序。将其中的较大值移动到后一位,则整个序列排序完毕。

单独冒泡:

if (a[i] > a[i + 1]) swap(a[i], a[i + 1]);

单独的冒泡过程:

#include 
#define N 1010
using namespace std;
int n, a[N];
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    
    // 冒泡阶段:连续交换过程
    for (int i = 1; i < n; ++i)    // 枚举两两交换的前一个元素序号
        if (a[i] > a[i + 1]) swap(a[i], a[i + 1]);    // 如果前一个元素大于后一个,就进行交换
    
    // 输出
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
    return 0;
}

完整的冒泡过程: 

#include 
#define N 1010
using namespace std;
int n, a[N];
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    
    // 冒泡排序
    for (int i = 1; i < n; ++i) { 	// 一共n-1个阶段,在第i个阶段,未排序序列长度从n-i+1到n-i。
        for (int j = 1; j <= n - i; ++j)	// 将序列从1到n-i+1的最大值,移到n-i+1的位置
            if (a[j] > a[j + 1]) 			// 其中j枚举的是前后交换元素的前一个元素序号
                swap(a[j], a[j + 1]);
    }
    
    // 输出
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
    return 0;
}

复杂度分析

从代码中,我们可以看到冒泡排序的主干部分有两层循环,并且每一层的循环次数都在O(n)O(n)左右的数量级。

所以完整的冒泡排序时间复杂度是O(n^2)O(n2)。

总结

  • 冒泡排序和选择排序一样,都是将原问题转换为长度减一的子问题的过程。

  • 冒泡排序分为n-1个阶段,每个阶段通过“冒泡”的过程,将未排序序列中的最大值移动到最后一位。

  • 冒泡的过程,具体是通过一段连续交换过程使得最大元素被“传送”到最后一位

练习4·代码题

明明的随机数_冒泡排序

明明想在学校中请一些同学一起做一项问卷调查,为了实验的客观性,他先用计算机生成了N个1到1000之间的随机整数(N≤100),对于其中重复的数字,只保留一个,把其余相同的数去掉,不同的数对应着不同的学生的学号。然后再把这些数从小到大排序,按照排好的顺序去找同学做调查。请你协助明明完成“去重”与“排序”的工作。

输入描述:

每组输入有2行,第1行为1个正整数,表示所生成的随机数的个数N,第2行有N个用空格隔开的正整数,为所产生的随机数。

输出描述:

每组输出也是2行,第1行为1个正整数M,表示不相同的随机数的个数。第2行为M个用空格隔开的正整数,为从小到大排好序的不相同的随机数。

示例 1:

输入:

10
20 40 32 67 40 20 89 300 400 15

输出:

8
15 20 32 40 67 89 300 400

 

代码:

#include 
#include 
#include 
#include 
#define N 110
using namespace std;
int a[N], n, cnt;

int main() {
    scanf("%d", &n);
    for (int i = 0; i < n; ++i) scanf("%d", &a[i]);
    
    // TODO 请补全下述代码,使用冒泡排序完成排序
    for (int i=1; ia[j+1]) swap(a[j] , a[j+1]);
        }
    }

    cnt = 0;
    for (int i = 0; i < n; ++i) 
        if (i == 0 || a[i] != a[i - 1]) 
            a[cnt++] = a[i];
            
    printf("%d\n", cnt);
    for (int i = 0; i < cnt; ++i) printf("%d ", a[i]); 
    return 0;
}

插入排序:

详细算法描述

  • 整理插入排序算法描述如下:
  1. 枚举序列中第2~n个元素。
  2. 当枚举元素i时,前i-1个元素已经有序。将第i个元素插入到前i-1个元素的有序序列中,形成长度为i的有序序列。
  3. 枚举过程结束后,整个序列有序。

所以,我们总结一下插入操作的算法描述:

  1. 假设序列1~(i-1)已经有序, 从i1枚举分界线的下标j;

  2. 如果分界线前面的元素a[j-1]大于x,说明a[j-1]应该在分界线后面。所以将a[j-1]移动到a[j],分界线前移变成j-1

  3. 如果分界线前面没有元素(j=1),就将x放在数组第1位。否则如果碰到一个j-1号元素小于等于x,说明分界线位置正确,就将x插到j位。

完整代码:

#include 
#define N 1550
using namespace std;
int a[N], n;

int main() {
    // 输入
    cin >> n; 
    for (int i = 1; i <= n; ++i) cin >> a[i];
	
    // 插入排序
    for (int i = 2; i <= n; ++i) {    // 按照第2个到第n个的顺序依次插入
        int j, x = a[i];    // 先将i号元素用临时变量保存防止被修改。

        // 插入过程,目的是空出分界线位置j,使得所有j的部分>x。
        // 循环维持条件,j>1,并且j前面的元素>x。
        for (j = i; j > 1 && a[j - 1] > x; --j) {   
            // 满足循环条件,相当于分界线应向前移,
            // 分界线向前移,就等于将分界线前面>x的元素向后移
            a[j] = a[j - 1];              
                                                    
        }
        // 找到分界线位置,插入待插入元素x
        a[j] = x;                         
    }
	
    // 输出
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
    return 0;
}

快速排序:

C++数据结构算法(二)排序算法集合_第4张图片

快速排序的基本思想

  1. 当需要将1nn个数排序时,我们通过分解,将该问题分解为两个将n/2个数排序的子问题;

  2. 在每个子问题中,我们继续分解,直到最后子问题长度为1;

  3. 此时,整个序列就完成排序了。

所以该算法的总复杂度变成了n^2/4 + 2nn2/4+2n。可以发现,在该算法中,多分解了一层,而复杂度也进一步减少了。

由此我们可以产生一个感觉,分解得越复杂度越小。所以,我们完全可以进一步分解,直到最后每个单位排序的长度为1。

C++数据结构算法(二)排序算法集合_第5张图片

任意长度为n的序列排序

当然快速排序也可用来给任意n个数的序列排序。但是与和1~n排序不同的是,对于任意n个数的序列,我们在划分子段的时候并不能很容易找到整个序列的“中位数”。所以只能在序列中任意取一个数。比如

  • 取整个序列中最左边的数。
  • 取整个序列中最右边的数。
  • 在整个序列中随机一个位置并取该位置上的数。

都是常见的取数策略。

但由于不能保证每次取的数字都刚好是中位数,所以每次划分时也不能保证左边子段长度和右边子段长度非常平均。如果“不幸”选到不合适的数(比如整个子段中最小的数或最大的数),整个算法的效率会降低很多。

在此,我们详细描述一下给任意n个数排序的快速排序算法:

  1. 假设我们要对数组a[1..n]排序。初始化区间[1..n]

  2. lr分别为当前区间的左右端点。下面假设我们对lr子段内的数字进行划分。取pivot = a[l]为分界线,将的数字移到左边,>pivot的数字移到右边,然后将pivot放在中间。假设pivot的位置是k

  3. 如果左边区间[l..k-1]长度大于1,则对于新的区间[l..k-1],重复调用上面的过程。

  4. 如果右边区间[k+1..r]长度大于1,则设置新的区间[k+1, r],重复调用上面的过程。

  5. 当整个过程结束以后,整个序列排序完毕。

递归函数实现快速排序:

// 该代码参考 https://www.geeksforgeeks.org/quick-sort/
#include 
#define N 100010 
using namespace std; 
int n; 
int a[N]; 
 
void quick_sort(int l, int r) { 	
    // l和r分别代表当前排序子段在原序列中左右端点的位置
    // 设置最右边的数为分界线
    int pivot = a[r];
    int k;
    
    /* 此处省略了元素移动和确定分界线新位置k的过程 */
    
    if (l < k - 1) quick_sort(l, k - 1); // 如果序列的分界线左边的子段长度>1,排序
    if (k + 1 < r) quick_sort(k + 1, r); // 如果序列的分界线右边的子段长度>1,排序
    // 上面的过程结束后,到这里左子段和右子段已经分别排好序。又因为确定分界线以后的移动操作
    // 保证了左子段中的元素都小于等于分界线,右子段中的元素都大于分界线。所以整个序列也是有序的。
} 
 
int main() { 
    // 输入
    scanf("%d", &n); 
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]); 
     
    // 快速排序
    quick_sort(1, n); 
    
    // 输出
    for (int i = 1; i <= n; ++i) printf("%d ", a[i]);  
    return 0; 
} 

 快速排序完整代码:

// 该代码参考 https://www.geeksforgeeks.org/quick-sort/
#include 
#define N 100010 
using namespace std; 
int n; 
int a[N]; 
 
void quick_sort(int l, int r) { 
    // 设置最右边的数为分界线
    int pivot = a[r];
    
    // 元素移动
    int k = l - 1;
    for (int j = l; j < r; ++j)
        if (a[j] < pivot) swap(a[j], a[++k]); 
    swap(a[r], a[++k]); 
    
    if (l < k - 1) quick_sort(l, k - 1); // 如果序列的分界线左边的子段长度>1,排序
    if (k + 1 < r) quick_sort(k + 1, r); // 如果序列的分界线右边的子段长度>1,排序
    // 上面的过程结束后,到这里左子段和右子段已经分别排好序。又因为确定分界线以后的移动操作
    // 保证了左子段中的元素都小于等于分界线,右子段中的元素都大于分界线。所以整个序列也是有序的。
} 
 
int main() { 
    // 输入
    scanf("%d", &n); 
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]); 
     
    // 快速排序
    quick_sort(1, n); 
    
    // 输出
    for (int i = 1; i <= n; ++i) printf("%d ", a[i]);  
    return 0; 
} 

复杂度分析

 

空间复杂度

首先该算法的空间复杂度是O(n)O(n),具体来说,在整个排序过程中,元素的移动都在原始数组中进行。所以快速排序是一种原地排序算法。

时间复杂度

可以看出,在「详细算法描述」中,我们的算法分为若干层。每一层中都是分治法的三个步骤:我们首先进行问题拆分,然后进入下一层,下一层的问题解决后,我们返回这一层进行子问题解的合并。

C++数据结构算法(二)排序算法集合_第6张图片

我们首先分析对1~nn个数字进行快速排序的情况。

在每一层中,问题拆分的复杂度是O(n)O(n),因为我们移动数组元素的时候,需要将每个子段扫一遍。那么把所有层的子段一起看,就相当于在每一层都把整个序列完整扫了一遍。对于子段解的合并,其复杂度是O(1)O(1),因为有分界线的存在,当我们把左边和右边都排好序后,它们和分界线元素一起天然形成了原序列的完整排序。

那么一共有多少层呢?因为每次我们都知道当前子段的中位数,所以可以保证每次划分,两个字段长度比较平衡,所以下一层子段的长度都比上一层减少了一半,直到长度为1算法停止。所以整个算法有\log nlogn层。

那么我们分析出在这种情况下,算法的复杂度是O(n\log n)O(nlogn)。这样,在1秒之内,计算机能非常轻松地排序10^6106及以上的数据。

但对于任意n个数的排序,每次划分情况取决于选取的分界线情况。如果每次分界线刚好取到最小值或者最大值,会导致划分时所有数字都会移动到同一边,整个算法的复杂度也会下降为O(n^2)O(n2)。如下图:

 C++数据结构算法(二)排序算法集合_第7张图片

我们很容易想到两种尽量避免出现这种情况的方法:

  1. 在排序之前,先把整个数组随机打乱顺序。
  2. 在选取分界线时,与之前固定选取某个位置的方法相比,我们换成随机选择分界线的位置。

这两种方法都能极大概率避免上面提到的极端情况的发生。

分治思想:

快速排序用到的一个很重要的思想就是分治思想,也是分治法运用在排序中的很重要的实例。

分治思想是一种“分而治之”的思想,反应在解决问题当中,就是将一个复杂问题不断分解为规模更小、更容易解决的问题,从而提升解决问题的效率。而分治法就是基于分治思想得到的解决问题的方法,它分为下面三个步骤:

  1. 问题的拆分。例如在快速排序中,我们以某个元素为分界线,将待排序的数字分为两部分。
  2. 解决子问题。例如在快速排序中,如果子问题的规模为1,我们就直接解决它,否则,我们就使用和划分主问题同样的办法继续划分子问题直到子问题规模达到很容易直接解决为止。
  3. 合并子问题的解。例如在快速排序中,我们将左边右边分别排序后,将前后排好序的部分与中间的分界线连接,形成主问题的解。

分治思想在算法领域有非常广泛的应用,在很多分解和合并都非常容易的问题上,分治法都能够提升其算法效率。

总结

  • 快速排序是一种基于分治法的排序。其基本思想在于固定一个分界线,将整个序列按照小于分界线和大于分界线划分,然后再分别对划分好的子段进行排序。

  • 快速排序的时间复杂度在理想情况下是O(n \log n)O(nlogn),但如果选取分界线每次都是子段中的最大值或最小值的话,时间复杂度可能会退化到O(n^2)O(n2)。在内存使用上,因为整个移动过程都在原数组中进行的,所以属于原地排序。

  • sort函数是C++标准模板库(STL)中一种对快速排序的优化实现,可以通过传入头指针、尾指针和比较函数来对数组中的对象进行排序。

  • 快速排序示例:

    将数组{2, 3, 1, 5, 4}从小到大排列。

  • 不使用sort函数

    将「整体框架」和「移动元素」进行合并,我们得到快速排序完整代码:

// 该代码参考 https://www.geeksforgeeks.org/quick-sort/
#include 
#define N 100010 
using namespace std; 
int n; 
int a[N]; 
 
void quick_sort(int l, int r) { 
    // 设置最右边的数为分界线
    int pivot = a[r];
    
    // 元素移动
    int k = l - 1;
    for (int j = l; j < r; ++j)
        if (a[j] < pivot) swap(a[j], a[++k]); 
    swap(a[r], a[++k]); 
    
    if (l < k - 1) quick_sort(l, k - 1); // 如果序列的分界线左边的子段长度>1,排序
    if (k + 1 < r) quick_sort(k + 1, r); // 如果序列的分界线右边的子段长度>1,排序
    // 上面的过程结束后,到这里左子段和右子段已经分别排好序。又因为确定分界线以后的移动操作
    // 保证了左子段中的元素都小于等于分界线,右子段中的元素都大于分界线。所以整个序列也是有序的。
} 
 
int main() { 
    // 输入
    scanf("%d", &n); 
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]); 
     
    // 快速排序
    quick_sort(1, n); 
    
    // 输出
    for (int i = 1; i <= n; ++i) printf("%d ", a[i]);  
    return 0; 
} 
  • 使用sort函数

    sort函数有三个参数,分别为头指针、尾指针和比较函数,其中如果排序对象定义了小于号的话,比较函数可省略。例如对于一个长为n的数组排序:

#include 
using namespace std;
int a[10] = {2, 3, 1, 5, 4};
int n = 5;
int main() {
    sort(a, a + n);  //sort函数的两个参数,头指针和尾指针
    for (int i = 0; i < n; ++i) cout << a[i] << ' ';
    cout << endl;
}

例题:

C++数据结构算法(二)排序算法集合_第8张图片

归并排序: 

归并排序算法过程

所以,我们总结一下归并排序的算法过程:

  • 假设我们要对数组a[1..n]排序。初始化左端点l=1,右端点r=n
  • 下面假设我们对lr子段内的数字进行划分。取lr的中点mid,将lmid的元素看成第一个子段的部分,将mid+1r的部分看成第二个子段的部分。两边分别进入下一层,重复调用上面的过程。直到子段长度为1,返回上一层。
  • 当算法阶段返回到当前层时,使用归并操作合并下一层的左右两个有序序列,形成本层的有序序列,继续返回上一层。
  • 当整个过程结束以后,整个序列排序完毕。

 归并排序时我们仍然使用递归函数的方式。具体框架如下:

#include 
#define N 100010
using namespace std;
int n;
int a[N];

void merge_sort(int l, int r) { // l和r分别代表当前排序子段在原序列中左右端点的位置
    if (l >= r) return;         // 当子段为空或者长度为1,说明它已经有序,所以退出该函数
    int mid = l + r >> 1;       // 取序列的中间位置,并将序列分成两部分(左右长度相差最多为1)
                                // l + r 的值右移1位,相当 l + r 的值除以2取整。
    merge_sort(l, mid);         // 对``l``到``mid``第一个子段进行归并操作
    merge_sort(mid + 1, r);     // 对``mid+1``到``r``第二个子段子段进行归并操作
	
    /* 这里省略将数组a[l..mid]和数组a[(mid+1)..r]合并的过程。 */
}

int main() {
    // 输入
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
	
    // 归并排序
    merge_sort(1, n);
	
    // 输出
    for (int i = 1; i <= n; ++i) printf("%d ", a[i]); 
    return 0;
}

归并排序完整代码

将前两步骤中「整体框架」和「归并操作」进行合并,就能得到完整的归并排序代码:

#include 
#define N 100010
using namespace std;
int n;
int a[N], b[N];

// 合并操作
void merge(int l, int r) {
    for (int i = l; i <= r; ++i) b[i] = a[i]; // 将a数组对应位置复制进辅助数组
    
    int mid = l + r >> 1;           // 计算两个子段的分界线
    int i = l, j = mid + 1;         // 初始化i和j两个指针分别指向两个子段的首位
    for (int k = l; k <= r; ++k) {  // 枚举原数组的对应位置
        if (j > r || i <= mid && b[i] < b[j]) a[k] = b[i++]; // 上文中列举的条件
        else a[k] = b[j++];
    }
}

void merge_sort(int l, int r) { // l和r分别代表当前排序子段在原序列中左右端点的位置
    if (l >= r) return;         // 当子段为空或者长度为1,说明它已经有序,所以退出该函数
    int mid = l + r >> 1;       // 取序列的中间位置,并将序列分成两部分(左右长度相差最多为1)
    merge_sort(l, mid);
    merge_sort(mid + 1, r);
    merge(l, r);                // 将l..mid和mid+1..r两个子段合并成完整的l..r的有序序列
}

int main() {
    // 输入
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
	
    // 归并排序 
    merge_sort(1, n);
	
    // 输出
    for (int i = 1; i <= n; ++i) printf("%d ", a[i]); 
    return 0;
}

代码实现 —— stable_sort函数使用

同样,归并排序实现起来也并不容易,所以STL中也有对归并排序的优化实现,函数名为stable_sort。使用方法与sort一样,见下例:

#include 
using namespace std;
int a[10] = {0, 2, 3, 1, 5, 4}; // 1-base,0号元素无意义
int n = 5;
bool cmp(int x, int y) {        // 比较函数,函数的参数是当前比较的两个数组中的元素
    return x > y;               // x和y分别为排序数组中的两个元素。
}                               // 当函数返回值为true时,x应该排在y的前面。
int main() {
    stable_sort(a + 1, a + n + 1, cmp);    // 比较函数作为第三个参数传入sort函数
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
}

之所以该函数叫做stable_sort,是因为归并排序是稳定排序,而快速排序不是稳定排序(这是因为选择作为分界线的点在不同实现中位置可能不一样。对于有些实现,当i被选为分界点,且该位置的值是a[i]时,它右边和a[i]相等的元素很有可能被换到i的左边,这时就破坏了稳定性)。回忆稳定排序的概念:

 稳定性描述的是对于有重复元素的序列,如果排序前后,重复的元素相对位置不变。这种叫做稳定算法,否则就是不稳定。参考下面的示意图:

C++数据结构算法(二)排序算法集合_第9张图片

复杂度分析

空间复杂度

首先该算法的空间复杂度是O(n)O(n),但尽管如此,在整个排序过程中,元素的移动借助了另一个辅助数组。所以归并排序是一种非原地排序算法。

时间复杂度

因为归并排序有着和快速排序一样的框架,所以我们仍然通过分别分析每一层的时间复杂度和总层数来分析总时间复杂度。

在每一层中,问题拆分的复杂度是O(1)O(1),这是因为我们只是单纯分解,并没有枚举或者移动元素,唯一的操作仅是计算位置的分界线。对于子段解的合并,其复杂度是O(n)O(n),因为对于每个子段,我们需要将其枚举每个位置进行填写。而如果我们同时考虑整层的操作,总枚举的范围就是整个数组的范围。

那么一共有多少层呢?因为归并排序每次都是将序列平分,所以下一层子段的长度一定比上一层减少了一半,直到长度为1算法停止。所以整个算法有\log nlogn层。

所以归并排序的复杂度在任何情况下都是O(n\log n)O(nlogn)。

总结

  • 和快速排序一样,归并排序也是基于分治法的排序算法。其基本思想在于将待排序序列分成长度相差不超过1的两份,分别对左右子段进行同样的划分和排序过程,最后将两个已经排好序的子段合并成整个完整的有序序列。

  • 归并排序的时间复杂度是O(n\log n)O(nlogn),在实现时,需要辅助数组帮助合并子段,所以是一种非原地排序算法。

  • 和快速排序不同的是,归并排序是一种稳定排序,即相同元素在排序前后的数组中相对位置不变。

  • stable_sort函数是C++标准模板库(STL)中一种对归并排序的优化实现,可以通过传入头指针、尾指针和比较函数来对数组中的对象进行排序。

  • 归并排序示例:

    将数组{2, 3, 1, 5, 4}从小到大排列。

  • 不使用stable_sort函数

    将「整体框架」和「归并操作」进行合并,我们得到归并排序完整代码:

 

#include 
#define N 100010
using namespace std;
int n;
int a[N], b[N];

// 合并操作
void merge(int l, int r) {
    for (int i = l; i <= r; ++i) b[i] = a[i]; // 将a数组对应位置复制进辅助数组
    
    int mid = l + r >> 1;           // 计算两个子段的分界线
    int i = l, j = mid + 1;         // 初始化i和j两个指针分别指向两个子段的首位
    for (int k = l; k <= r; ++k) {  // 枚举原数组的对应位置
        if (j > r || i <= mid && b[i] < b[j]) a[k] = b[i++]; // 上文中列举的条件
        else a[k] = b[j++];
    }
}

void merge_sort(int l, int r) { // l和r分别代表当前排序子段在原序列中左右端点的位置
    if (l >= r) return;         // 当子段为空或者长度为1,说明它已经有序,所以退出该函数
    int mid = l + r >> 1;       // 取序列的中间位置,并将序列分成两部分(左右长度相差最多为1)
    merge_sort(l, mid);
    merge_sort(mid + 1, r);
    merge(l, r);                // 将l..mid和mid+1..r两个子段合并成完整的l..r的有序序列
}

int main() {
    // 输入
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
	
    // 归并排序 
    merge_sort(1, n);
	
    // 输出
    for (int i = 1; i <= n; ++i) printf("%d ", a[i]); 
    return 0;
}

  • 使用stable_sort函数

#include 
using namespace std;
int a[10] = {0, 2, 3, 1, 5, 4}; // 1-base,0号元素无意义
int n = 5;
bool cmp(int x, int y) {        // 比较函数,函数的参数是当前比较的两个数组中的元素
    return x > y;               // x和y分别为排序数组中的两个元素。
}                               // 当函数返回值为true时,x应该排在y的前面。
int main() {
    stable_sort(a + 1, a + n + 1, cmp);    // 比较函数作为第三个参数传入sort函数
    for (int i = 1; i <= n; ++i) cout << a[i] << ' ';
    cout << endl;
}

计数排序:

计数排序的基本思想:

假设我们已知在待排序的序列中,值都是整数并且出现在一个很小的范围内,例如[0..1000]。那么,我们可以通过:

  1. 分别统计每一种可能的值在序列中出现的次数。
  2. 从小到大(假设要求将序列从小到大排序)枚举所有值,按照出现次数输出对应个数。

 

计数排序算法描述

给定长度为n的序列,假设已知序列元素的范围都是[0..K]中的整数,并且K的范围比较小(例如10^6106,开长度为10^6106左右的int类型数组所占用的内存空间只有不到4M)。解决该问题的计数排序算法描述如下:

  1. 使用整数数组cnt统计[1..K]范围内所有数字在序列中出现的个数。
  2. 使用变量i枚举1K,如果i出现了cnt[i]次,那么在答案序列的末尾添加cnt[i]i

 下图是一个n=6, K=3的例子:

C++数据结构算法(二)排序算法集合_第10张图片

 值得一提的是,如果元素的范围可以被很容易转换到[0..K],我们也可以使用计数排序。如果元素范围是[A..B],我们可以通过简单的平移关系将其对应到[0..B-A]上。或者所有数值均为绝对值不超过100的两位小数,那么我们可以通过将所有数字放大100倍将其转换为整数。

算法描述如下:

  1. 统计原序列中每个值的出现次数,记为cnt数组。
  2. 从小到大枚举值的范围,对cnt数组求前缀和,记为sum数组。
  3. 从后往前枚举每个元素a[i],分配其在答案中的位置idx[i]为当前的sum[a[i]],也就是将其放在所有值等于a[i]中的最后一个。并且将sum[a[i]]减少1,保证下次再遍历到同样的值时,它分配的位置正好在idx[i]前面一个。

计数排序代码实现

下面我们给出计数排序的简单实现:

#include 
#define N 1000005
#define K 1000001    // 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K];
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        ++cnt[a[i]];    // 这里通过计数数组cnt来维护每一种值出现的次数
    }
    
    // 维护最终有序序列
    for (int i = 0, j = 0; i < K; ++i)      // 枚举每一种值i,指针j用来枚举填充答案数组中的位置
        for (int k = 1; k <= cnt[i]; ++k)   // 根据该值出现的次数
            b[++j] = i;                     // 添加对应个数的i到答案序列
	
    // 输出
    for (int i = 1; i <= n; ++i)
        cout << b[i] << ' ';
    cout << endl;
    
    return 0;
}

其中:

  • 在计数排序的输入部分,我们用cnt数组统计了每种值出现的个数。
  • 在维护最终有序序列的部分,我们按照值从小到大的顺序,放置相应cnt个元素到答案数组里。

代码实现 —— 计数排序2

找出原序列中的元素和答案数组中的对应

这里,我们给出另外一种计数排序的实现方法。其中

  • 在输入部分,我们统计每一种值出现的次数
  • 在求原序列和答案序列的位置对应关系的部分,我们对cnt数组求前缀和,并存储在sum中。回忆上一节提到,对于一个值xsum[x]的含义是“小于等于x的数字个数”,同时,也可以看作指向答案序列中最后一个x出现的位置的指针。
  • 然后,我们从后向前枚举原序列的每个元素x,将sum[x]指向的位置分配给它,存在idx数组中,然后将sum[x]前移。这里“从后向前”是因为考虑到对于同一个值,分配位置的顺序是从后向前。所以,我们从后向前枚举原序列,可以保证在值相同的情况下,在原序列中出现在后面的元素会被分配到更大的位置,也就保证列排序的稳定性。
  • 因为原序列中i位置的数字,在答案序列中出现在idx[i]处。所以我们据此生成答案序列。

#include 
#define N 1000005
#define K 1000001    // 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K], sum[K];
int idx[N];    // 用来记录原序列中每个元素在新序列中的位置
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        ++cnt[a[i]];    // 这里通过计数数组cnt来维护每一种值出现的次数
    }
    
    // 求原序列和答案序列中的位置对应
    sum[0] = cnt[0];               // 假设最小值为0
    for (int i = 1; i < K; ++i)    // 求cnt的前缀和
        sum[i] = sum[i - 1] + cnt[i];
    for (int i = n; i; --i)        // 给每个元素分配位置
        idx[i] = sum[a[i]]--;      // 之所以倒循环,是因为对于相等的元素我们是从后向前分配位置
                                   // 这样我们可以保证排序的稳定性
    
    // 根据求出的位置将每个元素放进答案序列中
    for (int i = 1; i <= n; ++i)
        b[idx[i]] = a[i];
	
    // 输出
    for (int i = 0; i <= n; ++i)
        cout << b[i] << ' ';
    cout << endl;
    
    return 0;
}

复杂度分析

计数排序代码简单实现

这里我们分析第一种计数排序实现方法。

 

#include 
#define N 1000005
#define K 1000001	// 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K];
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        ++cnt[a[i]];	// 这里通过计数数组cnt来维护每一种值出现的次数
    }
    
    // 维护最终有序序列
    for (int i = 0, j = 0; i < K; ++i)      // 枚举每一种值i,指针j用来枚举填充答案数组中的位置
        for (int k = 1; k <= cnt[i]; ++k)   // 根据该值出现的次数
            b[++j] = i;                     // 添加对应个数的i到答案序列
	
    // 输出
    for (int i = 1; i <= n; ++i)
        cout << b[i] << ' ';
    cout << endl;
    
    return 0;
}

其中

  • 在计数排序的输入部分,我们用cnt数组统计了每种值出现的个数。
  • 在维护最终有序序列的部分,我们按照值从小到大的顺序,放置相应cnt个元素到答案数组里。

找出原序列中的元素和答案数组中的对应

空间复杂度

因为在上面的代码中一共开了3个数组,长度分别为O(N)O(N)(对于ab)和O(K)O(K)(对于cnt)。整个空间复杂度为O(N + K)

时间复杂度

容易发现,算法的输入输出部分所占时间复杂度为O(n)O(n)。

在“维护有序序列”的部分,我们首先考虑最外层循环,因为它遍历了所有[0..K]的数字,所以它的复杂度是O(K)O(K)。

其次,我们考虑内层循环的循环次数,其在外层循环为i时为cnt[i]。因为对于不同的输入,以及外层循环枚举到的不同的icnt[i]差别很大。但如果我们把所有i对应的内层循环次数相加,即可得到:

\text{内层循环总次数} = \sum_{i = 1}^{K} cnt[i] = n内层循环总次数=i=1∑K​cnt[i]=n

所以,整个算法的复杂度为O(n + K)O(n+K)。

我们提到过,有一条结论

所有基于比较的排序算法的时间复杂度都为\Omega(n\log n)Ω(nlogn)。(\OmegaΩ和OO记号类似,但OO表示的是“不会超过”,而\OmegaΩ表示的是“不会少于”)。

我们看到当K = O(n)K=O(n)时,整个算法的时间复杂度为O(n)O(n)。之所以计数排序可以达到比O(n\log n)O(nlogn)更好的时间复杂度,就是因为它并不是基于比较的排序。

对于基于原序列和答案序列位置对应设计的计数排序,经过分析可以发现其复杂度和第一种一样。大家可以自己尝试分析一下。

 

总结

  • 计数排序的基本思想是通过统计序列中不同的值出现的次数来排序。因为要用数组统计个数,所以要求在计数排序之前,整个序列中的元素需转换成在很小范围[0..K]的非负整数。

  • 计数排序的算法描述:

  1. 统计原序列中每个值的出现次数,记为cnt数组。
  2. 从小到大枚举值的范围,对cnt数组求前缀和,记为sum数组。
  3. 从后往前枚举每个元素a[i],分配其在答案中的位置idx[i]为当前的sum[a[i]],也就是将其放在所有值等于a[i]中的最后一个。并且将sum[a[i]]减少1,保证下次再遍历到同样的值时,它分配的位置正好在idx[i]前面一个。

计数排序代码实现1

#include 
#define N 1000005
#define K 1000001    // 假设非负整数最大元素范围为1000000
using namespace std;
int a[N], n, b[N];
int cnt[K];
int main() {
    // 输入
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        ++cnt[a[i]];    // 这里通过计数数组cnt来维护每一种值出现的次数
    }
    
    // 维护最终有序序列
    for (int i = 0, j = 0; i < K; ++i)      // 枚举每一种值i,指针j用来枚举填充答案数组中的位置
        for (int k = 1; k <= cnt[i]; ++k)   // 根据该值出现的次数
            b[++j] = i;                     // 添加对应个数的i到答案序列
	
    // 输出
    for (int i = 1; i <= n; ++i)
        cout << b[i] << ' ';
    cout << endl;
    
    return 0;
}

其中:

  • 在计数排序的输入部分,我们用cnt数组统计了每种值出现的个数。
  • 在维护最终有序序列的部分,我们按照值从小到大的顺序,放置相应cnt个元素到答案数组里。
  • 上述计数排序实现方法的时间和空间复杂度都是O(n+K)O(n+K)。正因为它不是基于比较的排序,所以才能达到比O(n\log n)O(nlogn)更好的时间复杂度。

  • 计数排序的基本思想还可以拓展成桶排序和基数排序。使用桶排序和基数排序的,可以对更大范围内的,甚至不是整数的序列进行排序。

你可能感兴趣的:(C++语法,笔记,数据结构与算法,c++,算法,数据结构,排序算法)