九大常用排序算法(C++版)

九大排序算法(C++版)

排序算法(sorting algorithm)用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更有效地查找、分析和处理。

排序数据的评估维度:

  • 运行效率:我们期望排序算法的时间复杂度尽量低,且总体数量较少(即时间复杂度中的常数项降低)。对于大数据量情况,运行效率显得尤为重要。
  • 就地性:顾名思义,原地排序通过在原数组上直接操作实现排序,无需借助额外的辅助数组,从而节省内存。通常情况下,原地排序的数据搬运操作较少,运行速度也更快。
  • 稳定性:稳定排序在完成排序后,相等元素在数组中的相对顺序不发生改变。

选择排序

工作原理

选择排序(Selection Sort)是一种简单直观的排序算法。它的工作原理如下:

  1. 在未排序序列中找到最小(或最大)元素,存放到排序序列的起始位置。
  2. 再从剩余未排序元素中继续寻找最小(或最大)元素,然后放到已排序序列的末尾。
  3. 重复第二步,直到所有元素均排序完毕。

这种排序方法的基本操作就是不断地进行选择。每一次循环,它从未排序的元素中选出最小的元素,将其放到已排序序列的末尾。这样,经过一轮循环后,最小的元素就会被移到正确的位置上。然后,再对剩余的元素重复这个过程,直到所有的元素都被正确地排序。

优点

选择排序的优点在于其实现简单,易于理解。它的工作原理简单明了,对于初学者来说是一个不错的入门算法。此外,选择排序在处理小规模数据时,其效率也是可以接受的。

  1. 实现简单:选择排序的实现相对简单,只需要遍历数组一次,无需其他复杂的计算或者嵌套循环。
  2. 易于理解:选择排序的工作原理清晰,容易让人理解。即通过每次找出未排序部分的最小(或最大)值,然后放到已排序部分的末尾。
  3. 小规模数据效率可接受:对于小规模的数据,选择排序的效率是可以接受的。虽然其时间复杂度为O(n^2),但对于只有几百个元素的数组来说,这个效率是可以接受的。

缺点

  1. 效率低:选择排序的最坏时间复杂度为O(n^2),这意味着随着数据规模的增大,其执行时间会急剧增加。因此,对于大规模数据的排序,我们通常会选择更高效的算法,如快速排序、归并排序等。
  2. 不稳定:选择排序是不稳定的排序算法,也就是说相等的元素可能会因为比较的顺序不同而改变他们的在数组中相对位置。这可能会导致一些意想不到的结果。
  3. 空间复杂度高:选择排序需要额外的存储空间来保存每次迭代的最小(或最大)元素的位置。这会导致其在空间利用上不如一些原地排序算法。

排序实现

/* 选择排序 */
void selectionSort(vector<int> &nums) {
    int n = nums.size();
    // 外循环:未排序区间为 [i, n-1]
    for (int i = 0; i < n - 1; i++) {
        // 内循环:找到未排序区间内的最小元素
        int k = i;
        for (int j = i + 1; j < n; j++) {
            if (nums[j] < nums[k])
                k = j; // 记录最小元素的索引
        }
        // 将该最小元素与未排序区间的首个元素交换
        swap(nums[i], nums[k]);
    }
}	

冒泡排序

工作原理

冒泡排序的基本思想是:重复地遍历要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。遍历数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

具体来说,冒泡排序的过程如下:

  1. 比较相邻的两个元素。如果第一个比第二个大(即 array[j] > array[j+1]),就交换它们两个。
  2. 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
  3. 针对所有的元素重复以上的步骤,除了最后一个。
  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

优点

  1. 简单易懂:冒泡排序的原理和实现都非常简单,对于初学者来说是一个不错的入门排序算法。
  2. 稳定性:冒泡排序是一种稳定的排序算法,相同元素的相对位置在排序前后不会改变。

缺点

  1. 效率低:冒泡排序的时间复杂度为O(n^2),在处理大量数据时效率非常低。例如,对于包含1000个元素的数组进行排序,冒泡排序需要进行1000 * 999次比较操作。
  2. 占用空间:冒泡排序需要额外的空间来存储临时变量,因此它的空间复杂度为O(1)。虽然这并不影响它的效率,但是在某些内存受限的环境中可能会成为问题。

代码实现

/* 冒泡排序(标志优化)*/
void bubbleSortWithFlag(vector<int> &nums) {
    // 外循环:未排序区间为 [0, i]
    for (int i = nums.size() - 1; i > 0; i--) {
        bool flag = false; // 初始化标志位
        // 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
        for (int j = 0; j < i; j++) {
            if (nums[j] > nums[j + 1]) {
                // 交换 nums[j] 与 nums[j + 1]
                // 这里使用了 std::swap() 函数
                swap(nums[j], nums[j + 1]);
                flag = true; // 记录交换元素
            }
        }
        if (!flag)
            break; // 此轮冒泡未交换任何元素,直接跳出
    }
}

插入排序

工作原理:

插入排序是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。

优点:

  1. 插入排序适用于少量数据的排序,时间复杂度为O(n^2);
  2. 插入排序是稳定的排序算法;
  3. 插入排序不需要额外的存储空间,因此空间复杂度为O(1)。

缺点:

  1. 插入排序的效率不高,当处理大量数据时,其性能远不如其他高级排序算法;
  2. 插入排序不适合对具有重复值的数据进行排序;
  3. 插入排序在数据已经部分有序的情况下,其效率会比完全无序的情况低。

代码实现:

/* 插入排序 */
void insertionSort(vector<int> &nums) {
    // 外循环:已排序元素数量为 1, 2, ..., n
    for (int i = 1; i < nums.size(); i++) {
        int base = nums[i], j = i - 1;
        // 内循环:将 base 插入到已排序部分的正确位置
        while (j >= 0 && nums[j] > base) {
            nums[j + 1] = nums[j]; // 将 nums[j] 向右移动一位
            j--;
        }
        nums[j + 1] = base; // 将 base 赋值到正确位置
    }
}

快速排序

工作原理:

快速排序 quick sort是一种基于分治策略的排序算法,运行高效,应用广泛。

快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。

  1. 从数列中挑出一个基准值(pivot),通常选择第一个元素或者最后一个元素。
  2. 重新排列数列,所有比基准值小的元素摆放在基准值前面,所有比基准值大的元素摆在基准值的后面(相同的数可以到任何一边)。在这个分区结束之后,该基准值就处于数列的中间位置。这个称为分区(partition)操作。
  3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。

优点:

  1. 平均时间复杂度为O(nlogn),在大多数情况下表现良好。
  2. 原地排序(in-place),不需要额外的存储空间。
  3. 稳定性好,即相同大小的元素在排序后保持原有的相对位置。
  4. 原语函数式编程的好例子。

缺点:

  1. 最坏时间复杂度为O(n^2),当输入数据已经有序或逆序时,效率非常低。这是快速排序的一个已知缺陷。
  2. 不稳定的排序,相同大小的元素可能会改变原有的相对位置。这可以通过随机选择基准值来避免,但这样会增加算法的复杂性。
  3. 对于大规模数据的排序,可能需要更多的内存空间来进行递归调用。虽然原地排序减少了额外的存储需求,但如果数据规模过大,可能会导致栈溢出等问题。

代码实现:

/* 元素交换 */
void swap(vector<int> &nums, int i, int j) {
    int tmp = nums[i];
    nums[i] = nums[j];
    nums[j] = tmp;
}

/* 哨兵划分 */
int partition(vector<int> &nums, int left, int right) {
    // 以 nums[left] 作为基准数
    int i = left, j = right;
    while (i < j) {
        while (i < j && nums[j] >= nums[left])
            j--; // 从右向左找首个小于基准数的元素
        while (i < j && nums[i] <= nums[left])
            i++;          // 从左向右找首个大于基准数的元素
        swap(nums, i, j); // 交换这两个元素
    }
    swap(nums, i, left); // 将基准数交换至两子数组的分界线
    return i;            // 返回基准数的索引
}

/* 快速排序 */
void quickSort(vector<int> &nums, int left, int right) {
    // 子数组长度为 1 时终止递归
    if (left >= right)
        return;
    // 哨兵划分
    int pivot = partition(nums, left, right);
    // 递归左子数组、右子数组
    quickSort(nums, left, pivot - 1);
    quickSort(nums, pivot + 1, right);
}


归并排序

工作原理:

归并排序采用了分治的思想,将待排序的序列分成两个子序列,分别对子序列进行递归排序。然后将已排序的两个子序列合并为一个有序序列。具体步骤如下:

  1. 将待排序序列从中间切分为两个子序列;
  2. 对每个子序列递归地进行归并排序;
  3. 将排好序的子序列合并为一个有序序列。

这样,归并排序通过递归地将问题规模减半,最终将整个序列排序完成。

优点:

  1. 稳定的排序结果:归并排序是一种稳定的排序算法,即相同元素的相对位置不会改变。这对于某些应用场景非常重要,如稳定性要求较高的冒泡排序等。
  2. 时间复杂度优秀:归并排序的时间复杂度为O(nlogn),在所有比较排序算法中是效率较高的一种。对于大规模数据的排序任务,归并排序通常具有较好的性能。
  3. 空间复杂度可控:归并排序的空间复杂度为O(n),其中n为待排序序列的长度。虽然在最坏情况下,空间复杂度可能达到O(nlogn),但一般情况下可以控制在O(n)范围内。这使得归并排序在一些受限资源的环境中仍然可行。

缺点:

  1. 需要额外的存储空间:归并排序需要额外的存储空间来存储临时的子序列,因为归并操作需要将两个子序列合并为一个有序序列。这可能导致对空间利用不够高效的场景下不适用。
  2. 递归深度较大:归并排序是一种递归算法,因此可能会遇到递归深度较大的情况。当待排序数据的规模非常大时,可能会导致栈溢出等问题。为了解决这个问题,可以使用迭代版本的归并排序算法。
  3. 随机化性能:尽管归并排序的时间复杂度优秀,但在实际应用中,其性能可能会受到输入数据随机性的影响。例如,当待排序数据中存在大量重复元素时,会导致频繁的子序列合并操作,从而影响性能。

代码实现:

/* 合并左子数组和右子数组 */
// 左子数组区间 [left, mid]
// 右子数组区间 [mid + 1, right]
void merge(vector<int> &nums, int left, int mid, int right) {
    // 初始化辅助数组
    vector<int> tmp(nums.begin() + left, nums.begin() + right + 1);
    // 左子数组的起始索引和结束索引
    int leftStart = left - left, leftEnd = mid - left;
    // 右子数组的起始索引和结束索引
    int rightStart = mid + 1 - left, rightEnd = right - left;
    // i, j 分别指向左子数组、右子数组的首元素
    int i = leftStart, j = rightStart;
    // 通过覆盖原数组 nums 来合并左子数组和右子数组
    for (int k = left; k <= right; k++) {
        // 若“左子数组已全部合并完”,则选取右子数组元素,并且 j++
        if (i > leftEnd)
            nums[k] = tmp[j++];
        // 否则,若“右子数组已全部合并完”或“左子数组元素 <= 右子数组元素”,则选取左子数组元素,并且 i++
        else if (j > rightEnd || tmp[i] <= tmp[j])
            nums[k] = tmp[i++];
        // 否则,若“左右子数组都未全部合并完”且“左子数组元素 > 右子数组元素”,则选取右子数组元素,并且 j++
        else
            nums[k] = tmp[j++];
    }
}

/* 归并排序 */
void mergeSort(vector<int> &nums, int left, int right) {
    // 终止条件
    if (left >= right)
        return; // 当子数组长度为 1 时终止递归
    // 划分阶段
    int mid = (left + right) / 2;    // 计算中点
    mergeSort(nums, left, mid);      // 递归左子数组
    mergeSort(nums, mid + 1, right); // 递归右子数组
    // 合并阶段
    merge(nums, left, mid, right);
}

堆排序

工作原理:

堆排序 heap sort是一种基于堆数据结构实现的高效排序算法。

  1. 首先,将待排序的序列构造成一个大顶堆(或小顶堆),此时整个序列的最大值(或最小值)位于根节点。
  2. 然后,将堆顶元素与最后一个元素交换,并将剩余的元素重新调整为大顶堆(或小顶堆)。
  3. 重复步骤2,直到整个序列有序。

优点:

  1. 时间复杂度为O(nlogn),与归并排序相当,但常数因子较小。
  2. 空间复杂度为O(1),不需要额外的辅助空间。
  3. 不稳定排序,相同元素的相对顺序可能会改变。
  4. 原地排序,不需要额外的存储空间。

缺点:

  1. 不稳定排序,相同元素的相对顺序可能会改变。
  2. 当序列中存在大量重复元素时,堆排序的性能会受到影响。
  3. 对于大规模数据的排序,堆排序的时间复杂度较高,可能不如其他线性时间复杂度的排序算法(如快速排序、归并排序等)。

代码实现:

/* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
void siftDown(vector<int> &nums, int n, int i) {
    while (true) {
        // 判断节点 i, l, r 中值最大的节点,记为 ma
        int l = 2 * i + 1;
        int r = 2 * i + 2;
        int ma = i;
        if (l < n && nums[l] > nums[ma])
            ma = l;
        if (r < n && nums[r] > nums[ma])
            ma = r;
        // 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
        if (ma == i) {
            break;
        }
        // 交换两节点
        swap(nums[i], nums[ma]);
        // 循环向下堆化
        i = ma;
    }
}

/* 堆排序 */
void heapSort(vector<int> &nums) {
    // 建堆操作:堆化除叶节点以外的其他所有节点
    for (int i = nums.size() / 2 - 1; i >= 0; --i) {
        siftDown(nums, nums.size(), i);
    }
    // 从堆中提取最大元素,循环 n-1 轮
    for (int i = nums.size() - 1; i > 0; --i) {
        // 交换根节点与最右叶节点(即交换首元素与尾元素)
        swap(nums[0], nums[i]);
        // 以根节点为起点,从顶至底进行堆化
        siftDown(nums, i, 0);
    }
}

桶排序

工作原理:

桶排序(Bucket Sort)是一种分布式排序算法,它的原理是将待排序的数据分布到多个有序的桶中,然后按照顺序依次将每个桶中的数据进行收集,最终得到有序的结果。

  1. 首先确定数据的范围和桶的数量。根据数据的取值范围,选择合适的桶数量,通常选择一个较小的数,如桶的数量为10、50、100等。
  2. 对每个桶中的数据进行计数排序。遍历待排序的数据,将每个数据放入对应的桶中,并记录每个桶中数据的数量。
  3. 收集桶中的数据。从第一个桶开始,依次将每个桶中的数据取出,直到所有桶都被收集完毕。如果某个桶中还有剩余数据,则将其重新放入下一个桶中。
  4. 合并桶。将所有桶中的数据按顺序合并起来,得到最终的有序结果。

优点:

  1. 空间复杂度较低。由于桶的数量通常比数据的范围要小很多,因此桶排序的空间复杂度相对较低,适用于内存有限的场景。
  2. 时间复杂度取决于数据的范围和桶的数量。在最坏的情况下,桶排序的时间复杂度为O(n + k),其中n为待排序数据的数量,k为桶的数量。通常情况下,k是一个较小的常数,因此桶排序的时间复杂度较低。
  3. 适应性强。桶排序可以应用于各种类型的数据,包括整数、浮点数、字符串等。只要数据之间存在一定的关系,就可以使用桶排序进行排序。

缺点:

  1. 不适用于数据分布不均匀的情况。如果数据在各个桶中的分布极不均匀,可能会导致某些桶中的数据过多或过少,从而影响排序结果的准确性。
  2. 需要额外的计算开销。在进行计数排序时,需要对每个桶中的数据进行计数操作,这会带来一定的计算开销。同时,在收集桶中的数据时,也需要进行多次遍历操作,增加了算法的复杂性。
  3. 只能用于部分有序的数据。由于桶排序是基于计数排序的思想,因此在处理部分有序的数据时效果较差。如果待排序数据本身就是有序的,或者数据之间的相关性较大,可以考虑使用其他更高效的排序算法。

代码实现:

/* 桶排序 */
void bucketSort(vector<float> &nums) {
    // 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
    int k = nums.size() / 2;
    vector<vector<float>> buckets(k);
    // 1. 将数组元素分配到各个桶中
    for (float num : nums) {
        // 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
        int i = num * k;
        // 将 num 添加进桶 bucket_idx
        buckets[i].push_back(num);
    }
    // 2. 对各个桶执行排序
    for (vector<float> &bucket : buckets) {
        // 使用内置排序函数,也可以替换成其他排序算法
        sort(bucket.begin(), bucket.end());
    }
    // 3. 遍历桶合并结果
    int i = 0;
    for (vector<float> &bucket : buckets) {
        for (float num : bucket) {
            nums[i++] = num;
        }
    }
}

计数排序

工作原理:

计数排序是一种非比较排序算法,它的基本思想是对每一个输入元素x,确定小于x的元素的个数。这样我们就可以直接把x放到它在输出数组中的位置上。

具体步骤如下:

  1. 找出待排序的数组中最大和最小的元素;
  2. 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
  3. 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
  4. 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。

优点:

  1. 计数排序是一种稳定的排序算法,即相等的元素在排序后保持原有的相对顺序。
  2. 计数排序的时间复杂度为O(n),空间复杂度为O(k),其中n是待排序元素的个数,k是待排序元素的取值范围。
  3. 计数排序适合于元素值在一定范围内的整数排序。

缺点:

  1. 计数排序只适用于元素值为非负整数的情况,对负整数和实数无法进行有效排序。
  2. 如果待排序的数组中存在大量重复的元素,那么计数排序的效率会降低,因为它需要对每个元素进行计数操作。

代码实现:

/* 计数排序 */
// 简单实现,无法用于排序对象
void countingSortNaive(vector<int> &nums) {
    // 1. 统计数组最大元素 m
    int m = 0;
    for (int num : nums) {
        m = max(m, num);
    }
    // 2. 统计各数字的出现次数
    // counter[num] 代表 num 的出现次数
    vector<int> counter(m + 1, 0);
    for (int num : nums) {
        counter[num]++;
    }
    // 3. 遍历 counter ,将各元素填入原数组 nums
    int i = 0;
    for (int num = 0; num < m + 1; num++) {
        for (int j = 0; j < counter[num]; j++, i++) {
            nums[i] = num;
        }
    }
}

基数排序

工作原理:

基数排序(Radix Sort)是一种非比较型整数排序算法,其工作原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。具体来说,它从最低位开始(个位),依次向右进行到最高位(十亿位、百亿位等)。在排序过程中,通过建立计数数组来统计每个位数上的数字出现次数,以便将它们放到正确的位置。

优点:

  1. 稳定性:基数排序是稳定的排序算法,相同数值的元素在排序后保持原有的相对顺序。
  2. 并行性:基数排序可以很容易地并行化,因为每个位数的排序都可以独立进行,不需要进行比较操作。
  3. 空间复杂度:基数排序的空间复杂度较低,只需要维护一个与输入规模相同的计数数组。
  4. 适用范围广:基数排序适用于正整数排序,不适用于负数和零。

缺点:

  1. 时间复杂度:基数排序的时间复杂度受输入数据的最大值影响,最大值越大,排序所需的时间越长。在最坏情况下,时间复杂度为O(nk),其中n是输入数据的个数,k是数字的最大位数。
  2. 溢出问题:当输入数据中存在极大值时,可能会导致计数数组溢出。为了解决这个问题,可以采用模运算将计数数组的大小限制在一个固定的范围内。
  3. 需要额外空间:虽然基数排序的空间复杂度较低,但在实际应用中,可能需要额外的空间来存储计数数组和其他辅助数据结构。

代码实现:

/* 获取元素 num 的第 k 位,其中 exp = 10^(k-1) */
int digit(int num, int exp) {
    // 传入 exp 而非 k 可以避免在此重复执行昂贵的次方计算
    return (num / exp) % 10;
}

/* 计数排序(根据 nums 第 k 位排序) */
void countingSortDigit(vector<int> &nums, int exp) {
    // 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
    vector<int> counter(10, 0);
    int n = nums.size();
    // 统计 0~9 各数字的出现次数
    for (int i = 0; i < n; i++) {
        int d = digit(nums[i], exp); // 获取 nums[i] 第 k 位,记为 d
        counter[d]++;                // 统计数字 d 的出现次数
    }
    // 求前缀和,将“出现个数”转换为“数组索引”
    for (int i = 1; i < 10; i++) {
        counter[i] += counter[i - 1];
    }
    // 倒序遍历,根据桶内统计结果,将各元素填入 res
    vector<int> res(n, 0);
    for (int i = n - 1; i >= 0; i--) {
        int d = digit(nums[i], exp);
        int j = counter[d] - 1; // 获取 d 在数组中的索引 j
        res[j] = nums[i];       // 将当前元素填入索引 j
        counter[d]--;           // 将 d 的数量减 1
    }
    // 使用结果覆盖原数组 nums
    for (int i = 0; i < n; i++)
        nums[i] = res[i];
}

/* 基数排序 */
void radixSort(vector<int> &nums) {
    // 获取数组的最大元素,用于判断最大位数
    int m = *max_element(nums.begin(), nums.end());
    // 按照从低位到高位的顺序遍历
    for (int exp = 1; exp <= m; exp *= 10)
        // 对数组元素的第 k 位执行计数排序
        // k = 1 -> exp = 1
        // k = 2 -> exp = 10
        // 即 exp = 10^(k-1)
        countingSortDigit(nums, exp);
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值