【Algorithm】经典排序算法

罗睿识
2023-12-01

1 前言

本文对于排序算法的原理不会做过多的介绍,仅用于总结同时方便记忆每种算法的原理

解释一些术语

  • 稳定:a 与 b 相等,排序前:a 在 b 前;排序后:a 与 b 的顺序不变
  • 不稳定:a 与 b 相等,排序前:a 在 b 前;排序后:a 与 b 的顺序可能会发生改变
  • 内部排序:所有排序操作都在内存中完成
  • 外部排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行

这里所谓的外部排序并不是说一定只能适用于不能在内存中完成排序的状况,才被称作外部排序。而是因为根据其算法的特性,可以用作处理需要外部排序的状况。而且有些情况下,外部排序的算法效率更加优于内部排序算法。

另外,如果没有特别指出,以下默认实现的为升序排序(实现出来与降序的区别也没差到哪去)

2 内部排序算法

2.1 冒泡排序

冒泡排序(Bubble Sort),通过比较交换的方式,每一轮冒一个最小(最大)到顶端。

  • 平均时间复杂度 O ( n 2 ) O(n^2) O(n2),最好情况 O ( n ) O(n) O(n),最坏情况 O ( n 2 ) O(n^2) O(n2)
  • 额外空间复杂度 O ( 1 ) O(1) O(1)
  • 稳定

C++ 经典写法

template<typename T>
void BubbleSort(T arr[], int len) {
    for (int i = 0; i < len; i++) {
        for (int j = 1; j < len - i; j++) { //每轮冒一个放到最后
            if (arr[j - 1] > arr[j]) {
                swap(arr[j - 1], arr[j]);
            }
        }
    }
}

这种经典的写法,可以进行一个小小的改进:当某轮未发生任何交换时,即可中止算法

template<typename T>
void BubbleSort(T arr[], int len) {
    for (int i = 0; i < len; i++) {
        bool flag = false;
        for (int j = 1; j < len - i; j++) { //每轮冒一个放到最后
            if (arr[j - 1] > arr[j]) {
                swap(arr[j - 1], arr[j]);
                flag = true;
            }
        }
        if (!flag) break;
    }
}

2.2 选择排序

选择排序(Selection Sort),这里指直接选择排序,每一轮找到一个最小(最大)往前放。与冒泡的区别就是,冒泡排序是通过不断地比较交换完成“冒泡”这个操作;而选择排序则是一轮比较完后再进行交换。

  • 平均时间复杂度 O ( n 2 ) O(n^2) O(n2),最好情况 O ( n 2 ) O(n^2) O(n2),最坏情况 O ( n 2 ) O(n^2) O(n2)
  • 额外空间复杂度 O ( 1 ) O(1) O(1)
  • 不稳定

C++ 经典写法

template<typename T>
void SelectionSort(T arr[], int len) {
    for (int i = 0; i < len - 1; i++) {
        int min = i;
        for (int j = i + 1; j < len; j++) {
            if (arr[j] < arr[min]) min = j;
        }
        swap(arr[i], arr[min]);
    }
}

2.3 插入排序

插入排序(Insertion Sort),插入排序就和打扑克理牌一样,这里我们通常指的是直接插入排序

  • 平均时间复杂度 O ( n 2 ) O(n^2) O(n2),最好情况 O ( n ) O(n) O(n),最坏情况 O ( n 2 ) O(n^2) O(n2)
  • 额外空间复杂度 O ( 1 ) O(1) O(1)
  • 稳定

C++ 经典写法

template<typename T>
void InsertionSort(T arr[], int len) {
    for (int i = 1; i < len; i++) {
        T tmp = arr[i];
        int j = i - 1;
        while ((j >= 0) && (tmp < arr[j])) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = tmp;
    }
}

2.4 希尔排序

希尔排序(Shell’s Sort)是插入排序的一种又称缩小增量排序(Diminishing Increment Sort),是直接插入排序算法的一种更高效的改进版本。

  • 平均时间复杂度 O ( n 1.5 ) O(n^{1.5}) O(n1.5),最好情况 O ( n 1.3 ) O(n^{1.3}) O(n1.3),最坏情况 O ( n 2 ) O(n^2) O(n2)
  • 额外空间复杂度 O ( 1 ) O(1) O(1)
  • 不稳定

C++ 经典写法

  • 这里我的代码写成两部分更方便理解,直接插入排序可以自由发挥。
  • 统一了代码风格,下面的直接插入排序(改)与前面直接插入排序的一致,只是增量变成了 gap
  • 希尔排序的增量初始设置为 n / 2 n/2 n/2,往后也每次减半
template<typename T>
void ShellInsertionSort(T arr[], int len, int start, int gap) {
    for (int i = start + gap; i < len; i += gap) {
        T tmp = arr[i];
        int j = i - gap;
        while ((j >= 0) && (tmp < arr[j])) {
            arr[j + gap] = arr[j];
            j -= gap;
        }
        arr[j + gap] = tmp;
    }
}

template<typename T>
void ShellSort(T arr[], int len) {
    for (int gap = len / 2; gap > 0; gap /= 2) {
        for (int i = 0; i < gap; i++) {
            ShellInsertionSort(arr, len, i, gap);
        }
    }
}

2.5 归并排序

归并排序(Merge Sort)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。另,归并排序也是一种外部排序。

  • 平均时间复杂度 O ( n l o g n ) O(nlogn) O(nlogn),最好情况 O ( n l o g n ) O(nlogn) O(nlogn),最坏情况 O ( n l o g n ) O(nlogn) O(nlogn)
  • 额外空间复杂度 O ( n ) O(n) O(n)
  • 稳定

注:通过查询资料我们可以知道,有一种改进的归并排序可以使最好情况的时间复杂度降至 O(n),甚至可以完成原地归并即空间复杂度为 O(1)。但我们这里仅讨论经典的方法。

C++ 经典写法

  • 关于取 mid 时,直接计算一般为 int mid = (left + right) / 2 但这不是一个好方法,此法容易导致溢出。有一个不会导致溢出的方法,养成好习惯 int mid = (right - left) / 2 + left
template<typename T>
void Merge(T arr[], int left, int mid, int right) {
    int i = 0, p1 = left, p2 = mid + 1, tmpLen = right - left + 1;
    T *tmp = new T[tmpLen]; //额外空间最大来源
    while (p1 <= mid && p2 <= right) {
        if (arr[p1] <= arr[p2]) { //这里比较要写 <= 否则会不稳定
            tmp[i] = arr[p1];
            p1++;
        }
        else {
            tmp[i] = arr[p2];
            p2++;
        }
        i++;
    }
    while (p1 <= mid) {
        tmp[i] = arr[p1];
        i++; p1++;
    }
    while (p2 <= right) {
        tmp[i] = arr[p2];
        i++; p2++;
    }
    for (i = 0; i < tmpLen; i++) {
        arr[left + i] = tmp[i];
    }
    delete[]tmp; //这种方法不要忘记释放数组
}

template<typename T>
void MergeSort(T arr[], int left, int right) {
    if (left >= right) return;
    int mid = (right - left) / 2 + left; //此写法防止 int 溢出
    MergeSort(arr, left, mid);
    MergeSort(arr, mid + 1, right);
    Merge(arr, left, mid, right);
}

2.6 快速排序

快速排序(Quick Sort)是对冒泡排序的一种改进。每一轮都能确定 pivot 的正确位置。

  • 平均时间复杂度 O ( n l o g n ) O(nlogn) O(nlogn),最好情况 O ( n l o g n ) O(nlogn) O(nlogn),最坏情况 O ( n 2 ) O(n^2) O(n2)
  • 额外空间复杂度 O ( l o g n ) O(logn) O(logn)
  • 不稳定

算法流程如下:

  1. Find Pivot 首先设定一个分界值,通过该分界值将数组分成左右两部分;
  2. Partition 根据分界值,小于分界值的放一边,大于分界值的放一边
  3. Recursion 左边递归,右边递归

C++ 经典写法

template<typename T>
int PartitionStandard(T arr[], int left, int right) {
    T pivot = arr[left];
    while (left < right) {
        while (left < right && arr[right] >= pivot) {
            right--;
        }
        arr[left] = arr[right];
        while (left < right && arr[left] <= pivot) {
            left++;
        }
        arr[right] = arr[left];
    }
    arr[left] = pivot;
    return left;
}

template<typename T>
void QuickSort(T arr[], int left, int right) {
    if (left >= right) return;
    int pivot = PartitionStandard(arr, left, right);
    QuickSort(arr, left, pivot - 1);
    QuickSort(arr, pivot + 1, right);
}

2.7 堆排序

堆排序(Heap Sort)是指利用堆这种数据结构所设计的一种排序算法。

  • 平均时间复杂度 O ( n l o g n ) O(nlogn) O(nlogn),最好情况 O ( n l o g n ) O(nlogn) O(nlogn),最坏情况 O ( n l o g n ) O(nlogn) O(nlogn)
  • 额外空间复杂度 O ( 1 ) O(1) O(1)
  • 不稳定

我们这里以使用大根堆为例,算法包括以下三个重点:

  • 最大堆调整(Max Heapify):向下调整,调整最大堆,使得子节点永远小于父节点
  • 创建最大堆(Build Max Heap):初始化建立一个大根堆,向上调整
  • 堆排序(Heap Sort):不断将当前最大值从堆顶往后放,然后将前面调整为最大堆,直到排序结束

在这里堆(二叉树的结构)是用数组模拟的,我在这里提示几个写代码的重点

  • 最后一个父节点的位置:len/2 - 1,len 为数组长度
  • 节点的左孩子的位置:parent*2 + 1,parent 为当前节点的位置

C++ 经典写法

template<typename T>
void MaxHeapify(T arr[], int start, int end) {
    int parent = start, child = parent * 2 + 1;
    while (child <= end) {
        if (child + 1 <= end && arr[child] < arr[child + 1]) { //比较两个子节点大小,选择大的
            child++;
        }
        if (arr[parent] > arr[child]) return; //父节点大于两个子节点,调整完毕
        else { //否则,父子节点交换,然后沿着子节点向下继续调整
            swap(arr[parent], arr[child]);
            parent = child;
            child = parent * 2 + 1;
        }
    }
}

template<typename T>
void HeapSort(T arr[], int len) {
    for (int i = len / 2 - 1; i >= 0; i--) { //BuildMaxHeap,从最后一个父节点开始,然后向上调整
        MaxHeapify(arr, i, len - 1);
    }
    for (int i = len - 1; i > 0; i--) { //HeapSort core code
        swap(arr[0], arr[i]); //把最大的放在数组尾部
        MaxHeapify(arr, 0, i - 1); //向下调整
    }
}

3 外部排序算法

若没有明确指出,接下来, k k k 指代桶的数量

3.1 计数排序

计数排序(Counting Sort)是一个非基于比较的排序算法,这种排序其实就是利用空间换时间,可以理解为使用额外的 hash 数组完成排序。一般状况下,它的效率优于所有基于比较的排序。但需要注意的是,当 O ( k ) > O ( n l o g n ) O(k)>O(nlogn) O(k)>O(nlogn) 的时候其效率反而不如基于比较的排序(基于比较的排序的时间复杂度在理论上的下限是 O ( n l o g n ) O(nlogn) O(nlogn), 如归并排序,堆排序)

  • 平均时间复杂度 O ( n + k ) O(n+k) O(n+k),最好情况 O ( n + k ) O(n+k) O(n+k),最坏情况 O ( n + k ) O(n+k) O(n+k)
  • 额外空间复杂度 O ( k ) O(k) O(k)
  • 稳定

C++ 经典写法

  • 这里只保证了排序单个基本元素的方法,进一步暂时不加以讨论
  • 为了表述简单和清晰,这里默认排序的均为非负数(如果有负数也没关系,实现的时候映射偏移一下就可以了)
void CountingSort(int arr[], int len) {
    int max = arr[0];
    for (int i = 1; i < len; i++) { //找到最大值
        if (arr[i] > max) max = arr[i];
    }
    int *hash = new int[max + 1];
    memset(hash, 0, sizeof(int) * (max + 1));
    for (int i = 0; i < len; i++) { //对应计数
        hash[arr[i]]++;
    }
    for (int i = 0, j = 0; j < max + 1; j++) {
        while (hash[j] > 0) { //写回数组
            arr[i] = j;
            hash[j]--;
            i++;
        }
    }
    delete[]hash;
}

3.2 桶排序

桶排序(Bucket Sort)也称箱排序,可以认为是计数排序的升级版。计数排序一个坑只能让同值的站,而桶排序一个桶里面放的是一个范围内的值,桶内再排序。

  • 平均时间复杂度 O ( n + n ( l o g n − l o g k ) ) O(n + n(logn-logk)) O(n+n(lognlogk)),最好情况 O ( n ) O(n) O(n),最坏情况 O ( n + k ) O(n + k) O(n+k)
  • 额外空间复杂度 O ( n + k ) O(n+k) O(n+k)
  • 稳定

了解了原理之后,我们就会明白

  • 在额外空间充足的情况下,尽量增大桶的数量 k k k
  • 映射函数最好能够将数据均匀的分到每个桶内

这是我的一种写法,也算不上纯 C++ 风格,仅供参考,有几点需要注意的

  • 桶的数量 bucketNum 是自行设定的,根据这个数量自动计算出每个桶容纳的值范围
  • 桶里是用链表实现的,在往桶里放值的时候是按序插入的
template<typename T>
struct ListNode {
    T key;
    ListNode* next;
    ListNode(T v = 0) :key(v), next(nullptr) {}
};

template<typename T>
void InsertInOrder(ListNode<T>*& head, T val) {
    ListNode<T>* dummy = new ListNode<T>(), * newNode = new ListNode<T>(val);
    ListNode<T>* pre, * curr;
    dummy->next = head;
    pre = dummy;
    curr = head;
    while (nullptr != curr && curr->key <= val) { //按序插入
        pre = curr;
        curr = curr->next;
    }
    newNode->next = curr;
    pre->next = newNode;
    head = dummy->next;
    delete dummy; //释放内存
}

template<typename T>
int CalculateBucketRange(T arr[], int len, int bucketNum) {
    T max = arr[0];
    for (int i = 1; i < len; i++) { //找最大值
        if (arr[i] > max) max = arr[i];
    }
    return max / (bucketNum-1); //这里要除 bucketNum-1
}

template<typename T>
void BucketSort(T arr[], int len, int bucketNum) {
    ListNode<T>** bucket = new ListNode<T> * [bucketNum]; //指针数组
    int bucketRange = CalculateBucketRange(arr, len, bucketNum);
    for (int i = 0; i < bucketNum; i++) { //初始化指针数组
        bucket[i] = nullptr;
    }
    for (int i = 0; i < len; i++) { //遍历数组,把数组放桶里
        InsertInOrder(bucket[arr[i] / bucketRange], arr[i]);
    }
    for (int i = 0, j = 0; i < bucketNum; i++) { //遍历每个桶,把值摘下来
        if (bucket[i] == nullptr) continue;
        ListNode<T>* p = bucket[i], * tmp = nullptr;
        while (p != nullptr) {
            arr[j] = p->key;
            tmp = p;
            p = p->next;
            delete tmp; //赋值之后清除内存
            j++;
        }
        bucket[i] = nullptr;
    }
    delete[]bucket; //释放指针数组
}

int main() {
    int arr[] = { 45,12,34,77,90,11,2,4,5,55,6,6,6,7,7,7,20,20,99,99,199};
    int len = sizeof(arr) / sizeof(int);
    int bucketNum = 10;
    PrintArray(arr, len);
    BucketSort(arr, len, bucketNum);
    PrintArray(arr, len);
}

3.3 基数排序

基数排序(Radix Sort),将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列

  • 平均时间复杂度 O ( n k l o g r ) O(nklogr) O(nklogr) r r r 为所采取的基数, k k k 为桶数
  • 额外空间复杂度 O ( n + k ) O(n+k) O(n+k)
  • 稳定

C++ 写法

int GetMaxbit(int arr[], int len) { //数据最大位数
    int maxbit = 1; //保存最大的位数
    int t = 10;
    for (int i = 0; i < len; i++) {
        while (arr[i] >= t) {
            t *= 10;
            maxbit++;
        }
    }
    return maxbit;
}

void RadixSort(int arr[], int len) //基数排序
{
    int maxbit = GetMaxbit(arr, len);
    int cnt[10], * tmp = new int[len]; // cnt计数,tmp暂存数值
    int radix = 1;
    for (int i = 0; i < maxbit; i++) { //进行maxbit次排序
        memset(cnt, 0, sizeof(cnt)); //计数清 0
        for (int j = 0; j < len; j++) { //统计每个桶中的记录数
            int index = (arr[j] / radix) % 10;
            cnt[index]++;
        }
        for (int j = 1; j < 10; j++) { //将tmp中的位置依次分配给每个桶
            cnt[j] = cnt[j - 1] + cnt[j];
        }
        for (int j = len - 1; j >= 0; j--) { //将所有桶中记录依次收集到tmp中
            int index = (arr[j] / radix) % 10;
            tmp[cnt[index] - 1] = arr[j];
            cnt[index]--;
        }
        for (int j = 0; j < len; j++) { //将临时数组的内容复制到data中
            arr[j] = tmp[j];
        }
        radix *= 10;
    }
    delete[]tmp;
}

4 总结

排序名称平均时间复杂度最好情况最坏情况额外空间复杂度类型稳定性
冒泡排序O(n^2)O(n)O(n^2)O(1)内部稳定
选择排序O(n^2)O(n^2)O(n^2)O(1)内部不稳定
插入排序O(n^2)O(n)O(n^2)O(1)内部稳定
希尔排序O(n^1.5)O(n^1.3)O(n^2)O(1)内部不稳定
归并排序O(nlogn)O(nlogn)O(nlogn)O(n)外部稳定
快速排序O(nlogn)O(nlogn)O(n^2)O(logn)内部不稳定
堆排序O(nlogn)O(nlogn)O(nlogn)O(1)内部不稳定
计数排序O(n+k)O(n+k)O(n+k)O(k)外部稳定
桶排序O(n+n(logn-logk))O(n)O(n+k)O(n+k)外部稳定
基数排序O(nklogr)--O(n+k)外部稳定
 类似资料: