关于排序

排序

目录 Content

  • 概述
  • 计数排序(桶排)
  • 冒泡排序
  • 插入排序
  • 选择排序
  • 归并排序
  • 快速排序
  • 其他排序
  • 总结

参考资料:OI-Wiki排序

Part 1 概述

排序是一种让若干无序的(不满足某种排序规则的)元素变得有序的方法。

排序算法种类很多,优点各自不同,但最基本的操作几乎一样——交换(swap)

排序算法的应用广泛,是许多算法中一个必须的步骤(比如在贪心中)。

对于排序算法,它的生命线首先就是时间复杂度与稳定性,其次是空间复杂度

Part 2 计数排序

这是最方便于理解的排序算法。它通过建立一个计数数组,统计每个元素出现次数。利用每个元素本身的大小关系进行排序。

过程如下:

  1. 建立数组
  2. 遍历每一个元素,统计出现次数
  3. 从小到大遍历数组,对于每一个非空的元素,输出对应个数。

动图:图源OI-wikiOI-wiki

时空复杂度

时间复杂度\(O(n+w)\),其中w是值域大小,这是输出的时间复杂度。

空间复杂度\(O(w)\)

优点

  • 实现简单,易于理解
  • 时间复杂度较好

缺点

  • 值域大时空间复杂度高,浪费度高

问题 可不可以离散化呢?离散化本身就需要排序,显然为了排序而排序是肯定不划算的。

  • 不适用于所有数据类型。适配其他类型可能需要哈希,反而浪费。

实现

const int W=10005;//值域
const int N=10005;//数量
int cnt[W],n,num[N];
void counting_sort(){
	int maxn=0;
	for(int i=1;i<=n;i++){
		cnt[num[i]]++;
		maxn=max(maxn,num[i]);
   }
   for(int i=1;i<=maxn;i++){
		for(int j=1;j<=cnt[i];j++)cout<<i<<" ";
	}
   return;
}

Part 2 冒泡排序

冒泡排序(英语:Bubble sort)是一种简单的排序算法。由于在算法的执行过程中,较小的元素像是气泡般慢慢「浮」到数列的顶端,故叫做冒泡排序。

过程

它的工作原理是每次检查相邻两个元素,如果前面的元素与后面的元素满足给定的排序条件,就将相邻两个元素交换。当没有相邻的元素需要交换时,排序就完成了。

时空复杂度

平均时间复杂度\(O(n^2)\)空间复杂度\(O(n)\)

这个算法是稳定的。

在慢慢将较大数放到后面,较小数放到前面的过程中,可以想像,在最坏情况下:

  • 第一次排序一定会把最大的数放到最后,因为没有数比他更大
  • 第二次一定会把第二大的放在第二位...

如此循环,每一次遍历至少都会复位一个元素,故最坏时间复杂度为\(O(n^2)\)

优点

  • 实现简单,易于理解

缺点

实现

// OI-Wiki Version
// 假设数组的大小是n+1,冒泡排序从数组下标1开始
void bubble_sort(int *a, int n) {
  bool flag = true;
  while (flag) {
    flag = false;
    for (int i = 1; i < n; ++i) {
      if (a[i] > a[i + 1]) {
        flag = true;
        int t = a[i];
        a[i] = a[i + 1];
        a[i + 1] = t;
      }
    }
  }
}

Part 3 插入排序

插入排序(英语:Insertion sort)是一种简单直观的排序算法。它的工作原理为将待排列元素划分为「已排序」和「未排序」两部分,每次从「未排序的」元素中选择一个插入到「已排序的」元素中的正确位置。

一个与插入排序相同的操作是打扑克牌时,从牌桌上抓一张牌,按牌面大小插到手牌后,再抓下一张牌。 OI-wiki

过程如下:

  • 建立"已排序"集合,最初这个集合里没有数。
  • 遍历每一个"未排序"的数。
  • 对于每一个未排序的数,找到“已排序“集合里比他大的第一个数并把这个未排序的元素插入在这个数之前。
  • 循环往复,直到没有未排序的数。

时空复杂度

平均时间复杂度\(O(n^2)\)空间复杂度\(O(n)\)

这个算法是稳定的。

动图:图源OI-wikiOI
为什么不用二分找到位置?这样可不可以把一个n化成log?

答:想得美。确实可以二分找到应该插入的位置,可是你要如何插入呢?插入操作使得其必须遍历以腾出空间。即使是STl Vector的insert方法也不是\(O(1)\)

优点

  • 实现简单,易于理解

缺点

实现

void InsertSort(int a[], int n)
{
    for(int i= 1; i<n; i++){
        if(a[i] < a[i-1]){//若第 i 个元素大于 i-1 元素则直接插入;反之,需要找到适当的插入位置后在插入。
            int j= i-1;
            int x = a[i];
            while(j>-1 && x < a[j]){  //采用顺序查找方式找到插入的位置,在查找的同时,将数组中的元素进行后移操作,给插入元素腾出空间
                a[j+1] = a[j];
                j--;
            }
            a[j+1] = x;      //插入到正确位置
        }
    }
}

Part 4 选择排序

选择排序是一种简单直观的排序算法。它的工作原理是每次找出第i小的元素(也就是还没有排序数组中最小的元素),然后将这个元素与数组第i个位置上的元素交换。

时空复杂度

平均时间复杂度\(O(n^2)\)空间复杂度\(O(n)\)

这个算法是稳定的。

动图:图源OI-wikiOI

优点

  • 实现简单,易于理解

缺点

实现

// OI-Wiki Version
void selection_sort(int* a, int n) {
  for (int i = 1; i < n; ++i) {
    int ith = i;
    for (int j = i + 1; j <= n; ++j) {
      if (a[j] < a[ith]) {
        ith = j;
      }
    }
    std::swap(a[i], a[ith]);
  }
}

Part 5 归并排序

归并排序是一种高效的排序算法。

过程:

  • 将原数组分成左右两个部分
  • 递归地再次向下对左右两个部分进行排序
  • 将原数组合并起来(每一次将两边最小的一个复制过去)并复制到一个辅助数组中
  • 将辅助数组复制回原序列

该算法的核心是如何合并左右两个已经排序的数组。

基本过程是:

  • 设i,j,k分别为前段的头、后段的头、当前位于辅助数组中的位置

即i=k=l,j=mid

  • 若当前后段为空或者前段非空且前段的最小值小于后段的最小值,那么就把前段的当前位置复制到复制辅助数组中当前k的位置,并把前一段的头指针后移一位。
  • 否则就把后段的当前位置复制到辅助数组中当前k的位置,并把后一段的头指针后移一位。
  • 将辅助数组的指针k后移一位。

时空复杂度

平均时间复杂度\(O(n log n)\)空间复杂度\(O(n)\)

这个算法是稳定的。

优点

  • 实现简单

缺点

  • 不易理解

实现

// OI-wiki version
void merge(int l, int r) {
  if (r - l <= 1) return;
  int mid = l + ((r - l) >> 1);
  merge(l, mid), merge(mid, r);
  for (int i = l, j = mid, k = l; k < r; ++k) {
    if (j == r || (i < mid && a[i] <= a[j]))
      tmp[k] = a[i++];
    else
      tmp[k] = a[j++];
  }
  for (int i = l; i < r; ++i) a[i] = tmp[i];
}

逆序对

逆序对是形如\((i,j)\)且其中\(i<j,a_i>a_j\)的有序数对。

只需要把ans+=mid-i+1添加在a[j++]后面即可

Tips:
mid-i+1代表着什么?

就是从i到中间的一段里面的元素数量。

为什么是这一段呢?因为前、后段都已经排序,若a[i]>a[j]意味着,i及其以后直到mid的元素都大于a[j]因此将会有mid-i+1个关于a[j]的逆序对形成。然而这次判断之后a[j]就被直接跳过了,所以必须现在加上mid-i+1。

Part 6 快速排序

快速排序是一种快速的排序方法。但他是不稳定的。

快速排序有多种实现方法。但是基本思想是:

  • 随机取一个基准元素,将序列划分为几个部分
  • 进行调整,看看元素是否放对了位置
  • 递归到两边继续排序

可见,快速排序是分治思想的一个应用。

由于快速排序的不稳定性,朴素的快速排序可能会被特殊数据卡到\(O(n^2)\)

我们在这里只讲一种优化版本,三路快速排序。

其实,三路快速排序也有许多实现方式,这里选了比较容易理解的一种

其基本实现过程:

  1. 随机选定一个基准
  2. 设置指针

\(lt=\)最后一个小于基准的元素,\(rt=\)第一个大于基准的元素,\(i=\)当前操作数

\(l=\)左边界,\(r=\)右边界

  1. 对于每一个k指向的元素,考虑如下情况:

注意:原图来自@涛少&。在此基础上进行了编辑。图中gt=rt

  • 这个元素等于基准:
    那么只需要移动到下一个元素即可,不需要交换。如图所示:
  • 这个元素小于基准:
    那么将lt+1与k交换,之后把lt++,i++。如图所示:
  • 这个元素大于基准:
    那么将rt-1和k交换,之后把rt--。如图所示:
  • 直到rt和i重合时结束。但是此时l的位置上还有基准元素没有归位,所以只要交换l和lt就好
  1. 最后递归到两边继续快排

时空复杂度

平均时间复杂度\(O(n log n)\)

最坏时间复杂度\(O(n^2)\)

空间复杂度\(O(n)\)

由于其可能交换两个相同的元素,所以是不稳定的。

优点

缺点

  • 不易理解
  • 不稳定

实现

//@涛少&的代码
template<typename T>
void __quickSort3Ways (T arr[], int left, int right)
{
    if(right <= left) return;//edit
    std::swap(arr[left], arr[std::rand() % (right - left + 1) + left]);  // 随机化找到一个元素作为"基准"元素
    T v = arr[left];

    int lt = left;       // 将<v的分界线的索引值lt初始化为第一个元素的位置(也就是<v部分的最后一个元素所在位置)
    int gt = right + 1;  // 将>v的分界线的索引值gt初始化为最后一个元素right的后一个元素所在位置(也就是>v部分的第一个元素所在位置)
    int i = left + 1;    // 将遍历序列的索引值i初始化为 left+1

    while (i < gt) {     // 循环继续的条件
        if (arr[i] < v) {
            std::swap(arr[i], arr[lt + 1]);  // 如果当前位置元素<v,则将当前位置元素与=v部分的第一个元素交换位置
            i++;                             // i++  考虑下一个元素
            lt++;                            // lt++  表示<v部分多了一个元素
        }
        else if (arr[i] > v) {               // 如果当前位置元素>v,则将当前位置元素与>v部分的第一个元素的前一个元素交换位置
            std::swap(arr[i], arr[gt - 1]);  // 此时i不用动,因为交换过来的元素还没有考虑他的大小
            gt--;                            // gt--  表示>v部分多了一个元素
        }
        else {              //  如果当前位置元素=v   则只需要将i++即可,表示=v部分多了一个元素
            i++;
        }
    }

    std::swap(arr[left], arr[lt]);   // 上面的遍历完成之后,将整个序列的第一个元素(也就是"基准"元素)放置到合适的位置
                                     // 也就是将它放置在=v部分即可
    __quickSort3Ways<T>(arr, left, lt - 1); // 对<v部分递归调用__quickSort3Ways函数进行三路排序
    __quickSort3Ways<T>(arr, gt, right);    // 对>v部分递归调用__quickSort3Ways函数进行三路排序
}
template<typename T>
void quickSort3Ways (T arr[], int count)
{
    std::srand(std::time(NULL));               /* 种下随机种子 */
    __quickSort3Ways<T>(arr, 0, count-1);    /* 调用__quickSort3Ways函数进行三路快速排序 */
}

Part 7 其他排序

正常的:

  • 锦标赛排序 稳定\(O(n log n)\)
  • 希尔排序 不稳定最优\(O(n)\)
  • 堆排序 稳定\(O(n log n)\)

其中有两个排序既稳定,复杂度也低(况且堆排序还实现简单priority_queue)

为什么不常用呢?

首先,锦标赛排序实现复杂思想不易理解,不适合用于OI等场景。

其次,实际上他们的实际速度在计算机上会比快速排序慢。

资料:C++ 性能榨汁机之局部性原理 - I'm Root lee !

乱搞的:
- 无限猴子排序 最好O(n)最坏O(∞)

Part 8 总结

在数据值域小时建议桶排,其他请用归并或快速。

EOF

感谢观看。\(\huge QwQ\)

posted @ 2023-06-17 22:33  haozexu  阅读(20)  评论(0)    收藏  举报