第八章 排序(中)【归并,基数,计数,桶排序】

1. 归并排序 (Merge Sort)

1.1 概念

归并排序是建立在归并操作上的一种有效,稳定的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。

归并排序的基本概念如下:

  • 分割:将待排序的序列不断地二分为两个子序列,直到每个子序列只剩下一个元素。
  • 归并:把两个或多个已经有序的序列合并成⼀个有序序列。

m路归并,每选出⼀个元素需要对⽐关键字 m-1 次,在内部排序中⼀ 般采⽤2路归并

1.2 基本思想

归并排序是一种基于分治思想的排序算法,它的基本思想是将待排序的序列不断地二分为两个子序列,直到每个子序列只剩下一个元素。然后,将两个子序列归并成一个有序序列,不断地归并,直到最终得到一个有序序列。

1.3 代码实现

1.3.1 递归方式的归并排序实现:

void merge(vector<int> &nums, int left, int mid, int right)
{
    vector<int> temp(right - left + 1); // 生成临时数组保存合并后的元素
    int i = left, j = mid + 1, k = 0;
    while (i <= mid && j <= right)  // 同时遍历左右两边
    {   // 选取较小的一边存放到临时数组
        if (nums[i] <= nums[j])    // 两个元素相等时,优先使⽤靠前的那个(稳定性)
            temp[k++] = nums[i++];
        else
            temp[k++] = nums[j++];
    }
    // 将没遍历到的元素全部复制到临时数组
    while (i <= mid)
        temp[k++] = nums[i++];
    while (j <= right)
        temp[k++] = nums[j++];
    for (int m = 0; m < k; m++) // 将临时数组复制到原数组
    {
        nums[left + m] = temp[m];
    }
}

void mergeSort(vector<int> &nums, int left, int right)
{
    if (left >= right)  // 划分到只剩一个元素
        return;
    int mid = left + (right - left) / 2;
    mergeSort(nums, left, mid);      // 划分合并左边
    mergeSort(nums, mid + 1, right); // 划分合并右边
    merge(nums, left, mid, right);  // 归并
}

1.3.2非递归方式的归并排序实现:

void merge(vector<int>& nums, int left, int mid, int right) {
    vector<int> temp(right - left + 1);
    int i = left, j = mid + 1, k = 0;
    while (i <= mid && j <= right) {
        if (nums[i] <= nums[j]) temp[k++] = nums[i++];
        else temp[k++] = nums[j++];
    }
    while (i <= mid) temp[k++] = nums[i++];
    while (j <= right) temp[k++] = nums[j++];
    for (int m = 0; m < k; m++) {
        nums[left + m] = temp[m];
    }
}

void mergeSort(vector<int>& nums) {
    int n = nums.size();
    for (int size = 1; size < n; size *= 2) {    // 子区间长度从1,2,4这样递增
        for (int left = 0; left < n - size; left += 2 * size) {
            int mid = left + size - 1;
            int right = min(left + 2 * size - 1, n - 1);
            merge(nums, left, mid, right);
        }
    }
}

以上两种实现方式都包含了归并排序的基本思想,即分割和归并。在实际应用中,通常采用非递归方式的实现,主要有以下几个原因:

  1. 非递归方式实现的空间复杂度更低:递归方式需要使用系统栈来保存函数调用信息,当递归深度较大时,可能会导致栈溢出。而非递归方式可以使用循环和迭代来实现,不需要使用额外的空间,因此空间复杂度更低。

  2. 非递归方式实现的效率更高:递归方式需要频繁地进行函数调用和返回操作,每次调用和返回都会带来额外的开销。而非递归方式只需要进行简单的循环和迭代,效率更高。

  3. 非递归方式实现的代码更易于理解和调试:递归方式实现的代码比较难以理解和调试,因为递归过程中函数的调用顺序比较复杂。而非递归方式实现的代码结构更加清晰,易于理解和调试。

1.4 优化

归并排序是一种比较高效的排序算法,但是在实际应用中还是可以进行一些优化的,下面介绍几种常见的优化方式:

  1. 优化归并操作:在归并操作时可以采用一些优化策略,比如当待归并的两个子序列已经有序时,就可以直接将它们合并,不需要再进行归并操作。另外,在合并两个有序子序列时,可以采用双指针的方式,避免频繁的数组拷贝操作。

  2. 优化辅助数组的使用:在归并排序过程中需要使用一个辅助数组来存储排序结果,可以考虑将辅助数组作为参数传入递归函数中,避免频繁的申请和释放空间。另外,可以在归并过程中将辅助数组的元素进行复制,减少访问数组的次数。

  3. 优化递归深度:归并排序采用递归的方式实现,当数据量较大时,递归深度可能会比较大,影响排序的效率。可以考虑在数据量较小的情况下采用插入排序等其他排序算法,减少递归深度。

  4. 优化数据的存储方式:归并排序对数据的存储方式比较敏感,采用不同的存储方式可能会影响排序的效率。可以采用 cache-aware 排序算法或者对数据进行预处理,使得数据更加适合在内存中进行排序。

综上所述,通过优化归并操作、辅助数组的使用、递归深度和数据的存储方式等方面,可以进一步提高归并排序的效率。

 1.5 归并排序性能分析

1.5.1 时间复杂度O(nlogn)

一趟归并,我们需要把遍历待排序序列遍历一遍,时间复杂度O(n)。

而归并排序的过程,需要把数组不断二分,这个时间复杂度是O(logn)。

所以归并排序的时间复杂度是O(nlogn)。

1.5.2 空间复杂度

使用了一个临时数组来存储合并的元素,空间复杂度O(n)。

1.5.3 稳定性——稳定

两个元素相等时,优先使 ⽤靠前的那个(稳定性),归并排序是一种稳定的排序方法。

2.基数排序 (Radix Sort)

2.1 概念

基数排序(radix sort)属于“分配式排序”(distribution sort),又称“桶子法”(bucket sort)或bin sort,顾名思义,它是透过键值的部份资讯,将要排序的元素分配至某些“桶”中,藉以达到排序的作用,基数排序法是属于稳定性的排序,其时间复杂度为O (nlog(r)m),其中r为所采取的基数,而m为堆数,在某些时候,基数排序法的效率高于其它的稳定性排序法。

基数排序可以说是桶排序的一个进化

2.2 基本思想

基数排序是一种非比较排序算法,它的基本思想是将待排序的元素分别按照位数切割成不同的数字,然后按照每个位数的大小进行排序。一般的实现方法是先按照个位数排序,然后按照十位数排序,接着按照百位数排序,直到最高位数排完后,排序完成。

具体的实现步骤如下:

  1. 找出待排序数组中最大的数,确定最大数的位数,作为排序的轮数;

  2. 对于每一位数,用计数排序或桶排序进行排序;

  3. 将排序后的数组按照位数依次组合起来,得到最终结果。

基数排序的时间复杂度为O(nk),其中k为最大数的位数,n为数组元素个数。当k比较小的时候,基数排序的效率较高。但是当k比较大时,需要分配较大的桶或计数器,空间复杂度会变高。

 

2.3 代码实现

void radixSort(vector<int>& arr) {
    int maxVal = *max_element(arr.begin(), arr.end());

    for (int exp = 1; maxVal / exp > 0; exp *= 10) {
        vector<int> count(10, 0);

        for (int i = 0; i < arr.size(); i++) {
            int digit = (arr[i] / exp) % 10;
            count[digit]++;
        }

        for (int i = 1; i < count.size(); i++) {
            count[i] += count[i - 1];
        }

        vector<int> temp(arr.size());
        for (int i = arr.size() - 1; i >= 0; i--) {
            int digit = (arr[i] / exp) % 10;
            temp[count[digit] - 1] = arr[i];
            count[digit]--;
        }

        for (int i = 0; i < arr.size(); i++) {
            arr[i] = temp[i];
        }
    }
}

基数排序是通过分离元素的各个位来进行排序的,因此它不会受到排序数据中的数字大小的限制。

2.4 优化

  1. 优化桶的大小:桶的大小可以根据实际数据的范围来设定,过大会造成空间浪费,过小则会影响排序效率。

  2. 选择合适的排序算法:基数排序的最后一步需要使用其他的排序算法进行排序,选择一个高效的排序算法可以提高整个基数排序的效率。

  3. 优化对数据的访问:对于需要进行频繁访问的数据,可以将其放置在缓存友好的位置,减少访问时间。

2.5  基数排序性能分析

2.5.1 时间复杂度

一共进行d趟分配收集,一趟分配需要O(n),一趟收集需要O(r),时间复杂度为O\left \lfloor d(n+r) \right \rfloor,且与序列的初始状态无关。

2.5.2 空间复杂度

 空间复杂度O(r),其中r为辅助队列数量。

2.5.3 稳定性——稳定

因为基数排序过程,每次都是将当前位数是哪个相同数值的元素统一分配到桶中,并不交换位置,所以基数排序是稳定的。

3. 计数排序

3.1 概念

计数排序是一个非比较类的排序方法。,该算法于1954年由 Harold H. Seward 提出。计数排序是一种线性时间复杂度的排序,利用空间来换时间,它的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k)(其中k是整数的范围),快于任何比较排序算法。当然这是一种牺牲空间换取时间的做法,而且当O(k)>O(n*log(n))的时候其效率反而不如基于比较的排序(基于比较的排序的时间复杂度在理论上的下限是O(n*log(n)), 如归并排序,堆排序)。

3.2 基本思想

计数排序对输入的数据有附加的限制条件:

1、输入的线性表的元素属于有限偏序集S;

2、设输入的线性表的长度为n,|S|=k(表示集合S中元素的总数目为k),则k=O(n)。

在这两个条件下,计数排序的复杂性为O(n)。

在计数排序算法中,需要进行三个步骤:

计数每个元素出现的次数:遍历原始数组,对每个元素进行计数。
计算前缀和:将每个元素出现的次数累加到其前面所有元素出现次数的和中。
将元素放入输出数组中:倒序遍历原始数组,将每个元素放入对应位置上,同时将其出现次数减1。
最后,将输出数组复制到原始数组中,排序完成

3.3 代码实现

#include <iostream>
#include <vector>

using namespace std;

void countingSort(vector<int>& arr, int maxValue) {
    vector<int> count(maxValue + 1, 0);
    vector<int> output(arr.size(), 0);

    // 计数每个元素出现的次数
    for (int i = 0; i < arr.size(); i++) {
        count[arr[i]]++;
    }

    // 计算前缀和
    for (int i = 1; i <= maxValue; i++) {
        count[i] += count[i-1];
    }

    // 将元素放入输出数组中
    for (int i = arr.size() - 1; i >= 0; i--) {
        output[count[arr[i]]-1] = arr[i];
        count[arr[i]]--;
    }

    // 将输出数组复制到原始数组中
    for (int i = 0; i < arr.size(); i++) {
        arr[i] = output[i];
    }
}

int main() {
    vector<int> arr{9, 5, 7, 3, 1, 2, 6, 8, 4, 0};

    cout << "Before sorting: ";
    for (int x : arr) {
        cout << x << " ";
    }
    cout << endl;

    countingSort(arr, 9);

    cout << "After sorting: ";
    for (int x : arr) {
        cout << x << " ";
    }
    cout << endl;

    return 0;
}

这段代码中,首先声明了一个count数组和一个output数组,count数组用于记录每个元素出现的次数,output数组用于存储排序后的结果。

将count进行前缀和累加后,count数组的坐标为元素值,count数组的对应的值为下标对应元素应该第几个输出,由于数组从0开始存储,所以output数组的坐标为count数组的对应的值-1,

以[6,8,5,1,2,2,3]为例

  • 首先,找到数组中最大的数,也就是8,创建一个最大下标为8的空数组arr

  • 遍历数据,将数据的出现次数填入arr对应的下标位置中

 

  • 对所有的计数累加(从arr中的第一个元素开始,每一项和前一项相加);
for (int i = 1; i < arr.size(); i++) {
    arr[i] += arr[i-1];
}
  •  反向填充目标数组:倒序遍历原始数组,将每个元素放入对应位置上,将每个元素i放在新数组的第arr(i)项,每放一个元素就将arr(i)减去1。
 for (int i = arr.size() - 1; i >= 0; i--) {
        output[arr[num[i]]-1] = num[i];
        arr[num[i]]--;
    }

或者不进行前缀和计算,初始化arr值均为0,计数后, 从前往后遍历arr,如果对应的值非0,输出数组元素的下标值,元素的值是几,就输出几次

4.桶排序

4.1 概念

桶排序 (Bucket sort)或所谓的箱排序,是一个排序算法,工作的原理是将数组分到有限数量的桶子里。每个桶子再个别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序)。桶排序是鸽巢排序的一种归纳结果。当要被排序的数组内的数值是均匀分配的时候,桶排序使用线性时间(O(n))。但桶排序并不是 比较排序,他不受到O(nlogn) 下限的影响。

桶排序的实现我们要考虑几个问题:

  • 桶该如何表示?

  • 桶的数量怎么确定?

  • 桶内排序用什么方法?

4.2 基本思想

将待排序的元素划分成若干个桶,每个桶中的元素都比桶内其他元素小,然后对每个桶中的元素进行排序,最后将所有桶中的元素合并成一个有序序列。

桶排序的实现步骤如下:

  1. 确定桶的数量和范围。将待排序的元素划分为n个桶,每个桶存放的元素的值域范围为[bi, bi+1)。

  2. 将元素放入桶中。遍历待排序的元素,将元素放入相应的桶中。

  3. 对每个桶中的元素进行排序。可以使用任意排序算法,比如插入排序、快速排序等。

  4. 合并所有桶中的元素。按照桶的顺序,将每个桶中的元素按顺序放入一个数组中。

  5. 返回有序序列。

桶排序的时间复杂度为O(n+k),其中k为桶的数量。如果桶的数量足够大,可以认为桶排序的时间复杂度是线性的。但是,桶排序的空间复杂度比较高,需要额外的存储空间来存放桶。

4.3 代码实现

void bucketSort(vector<int>& arr, int bucketSize) {
    if (arr.empty()) {
        return;
    }

    // 获取最大值和最小值
    int minValue = arr[0];
    int maxValue = arr[0];
    for (int i = 1; i < arr.size(); ++i) {
        if (arr[i] < minValue) {
            minValue = arr[i];
        } else if (arr[i] > maxValue) {
            maxValue = arr[i];
        }
    }

    // 计算桶的数量
    int bucketCount = (maxValue - minValue) / bucketSize + 1;
    vector<vector<int>> buckets(bucketCount);

    // 将元素分配到桶中
    for (int i = 0; i < arr.size(); ++i) {
        int bucketIndex = (arr[i] - minValue) / bucketSize;
        buckets[bucketIndex].push_back(arr[i]);
    }

    // 对每个桶中的元素进行排序
    for (int i = 0; i < bucketCount; ++i) {
        sort(buckets[i].begin(), buckets[i].end());
    }

    // 将排序好的元素依次放回原数组中
    int index = 0;
    for (int i = 0; i < bucketCount; ++i) {
        for (int j = 0; j < buckets[i].size(); ++j) {
            arr[index++] = buckets[i][j];
        }
    }
}

桶排序的时间复杂度为O(n),是一种非常快速的排序算法,但是它的空间复杂度比较高,需要开辟足够的空间存储桶。

4.4 优化

桶排序的时间复杂度主要取决于桶的个数和桶内部排序所采用的算法。如果桶的个数较少,桶内部元素较多,则可以采用其他的排序算法,比如快速排序或归并排序,来对每个桶内部进行排序,以提高排序效率。

另外,如果待排序的数据是比较集中的,可以采用按区间划分桶的方式,使得每个桶内的元素数量大致相同,从而减少桶内部排序所需要的时间。

void bucketSort(vector<int>& arr, int bucketSize) {
    if (arr.empty()) {
        return;
    }

    int minValue = arr[0];
    int maxValue = arr[0];
    for (int i = 1; i < arr.size(); i++) {
        if (arr[i] < minValue) {
            minValue = arr[i];
        } else if (arr[i] > maxValue) {
            maxValue = arr[i];
        }
    }

    int bucketCount = (maxValue - minValue) / bucketSize + 1;
    vector<vector<int>> buckets(bucketCount);

    for (int i = 0; i < arr.size(); i++) {
        int bucketIndex = (arr[i] - minValue) / bucketSize;
        buckets[bucketIndex].push_back(arr[i]);
    }

    arr.clear();
    for (int i = 0; i < buckets.size(); i++) {
        if (buckets[i].empty()) {
            continue;
        }

        if (bucketSize == 1) {
            for (int j = 0; j < buckets[i].size(); j++) {
                arr.push_back(buckets[i][j]);
            }
        } else {
            bucketSort(buckets[i], bucketSize - 1);
            for (int j = 0; j < buckets[i].size(); j++) {
                arr.push_back(buckets[i][j]);
            }
        }
    }
}

这个桶排序算法采用了按区间划分桶的方式,并且在每个桶内部采用了递归的方式进行排序。其中 bucketSize 表示每个桶内部的元素数量,可以通过调整这个参数来改变算法的性能表现。 

4.5 桶排序性能分析

  • 时间复杂度

桶排序最好的情况,就是元素均匀分配到了每个桶,时间复杂度O(n),最坏情况,是所有元素都分配到一个桶中,时间复杂度是O(n²)。平均的时间复杂度和技术排序一样,都是O(n+k)。

  • 空间复杂度

桶排序,需要存储n个额外的桶,桶中又要存储k个元素,所以空间复杂度是O(n+k)。

  • 稳定性

稳定性得看桶中排序用的什么排序算法,桶中用的稳定排序算法,那么就是稳定的。用的不稳定的排序算法,那么就是不稳定的。

5.内部排序算法总结

5.1 内部排序算法比较

 算法种类最好时间复杂度最坏时间复杂度平均时间复杂度空间复杂度稳定性
直接插入排序O(n)O(n^{2})O(n^{2})O(1)稳定
冒泡排序O(n)O(n^{2})O(n^{2})O(1)稳定
简单选择排序

O(n^{2})

O(n^{2})O(n^{2})O(1)不稳定
希尔排序O(n)O(n^{2})O(n^{1.3-2})O(1)不稳定
快速排序O(nlog_{2}n)O(n^{2})O(nlog_{2}n)O(nlog_{2}n)不稳定
堆排序O(nlog_{2}n)O(nlog_{2}n)O(nlog_{2}n)O(1)不稳定
2路归并排序O(nlog_{2}n)O(nlog_{2}n)O(nlog_{2}n)O(n)稳定
基数排序O\left \lfloor d(n+r) \right \rfloorO\left \lfloor d(n+r) \right \rfloorO\left \lfloor d(n+r) \right \rfloorO(r)稳定
计数排序O(n+k)O(n+k)O(n+k)O(n)稳定
桶排序O(n+k)O(n²)O(n)O(n+k)稳定

5.2 排序算法的选择:

5.2.1选取排序方法需要考虑的因素:

  1. 待排序的元素数目n。
  2. 元素本身信息量的大小。
  3. 关键字的结构及其分布情况。
  4. 稳定性的要求。
  5. 语言工具的条件,存储结构及辅助空间的大小等。

5.2.2 具体排序算法的选择

  1. 若n较小,可采用 直接插入排序 或 简单选择排序。由于直接插入排序所需的记录移动次数较简单选择排序的多,因而当记录本身信息量较大时,用简单选择排序较好。
     
  2. 若文件的初始状态已按关键字基本有序,则选用直接插入排序或冒泡排序为宜。
  3. 若n较大,则应采用时间复杂度为的排序方法:快速排序、堆排序 或 归并排序。快速排序被认为是目前基于比较的内部排序方法中最好的方法,当待排序的关键字随机分布时,快速排序的平均时间最短。堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况,这两种排序都是不稳定的。若要求排序稳定且时间复杂度为,则可选用归并排序。但本章介绍的从单个记录起进行两两归并的排序算法并不值得提倡,通常可以将它和直接插入排序结合在一起使用。先利用直接插入排序求得较长的有序子文件,然后两两归并。直接插入排序是稳定的,因此改进后的归并排序仍是稳定的。
  4. 在基于比较的排序方法中,每次比较两个关键字的大小之后,仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明:当文件的n个关键字随机分布时,任何借助于“比较”的排序算法,至少需要的时间。
  5. 若n很大,记录的关键字位数较少且可以分解时,采用 基数排序 较好。
  6. 当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。
  • 16
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值