Algorithm - 排序算法

[TOC]

术语

排序算法类别

  • 比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破,因此也称为非线性时间比较类排序。
  • 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。

排序算法的稳定性

  • 稳定排序:在序列排序前,如果元素原本在元素前面,且,则排序之后仍然在的前面(排序前与排序后位置一致)。
  • 不稳定排序:在序列排序前,如果元素原本在元素前面,且,则排序之后可能出现在的后面(排序前与排序后位置相反)。

内排序与外排序

  • 内排序:在排序的整个过程中,待排序的所有记录全部被放置在内存中。插入排序,交换排序,选择排序和归并排序都归属于内排序。
  • 外排序:由于排序的记录个数太多,不能同时放置在内存中,整个排序过程需要在内外存之间多次交换数据才能进行。

经典排序算法

1)冒泡排序(Bubble Sort):是一种交换排序,其基本思想是:两两比较相邻记录的关键字,如果反序则交换位置,直到没有反序的记录为止。其运行规则如下图所示:

冒泡排序

从图中可以看出,冒泡排序的运行规则为:第一个元素与第二个元素进行比较,反序则互相交换;然后第二个元素与第三个元素进行比较,反序则互相交换······这样经历一轮交换后,最大的元素就置于最后一个位置。经历多轮交换后,越小的元素就会由于交换而慢慢地浮现到数列的顶端,而这也正是其被称为冒泡排序的缘由。

具体代码如下:

template
void swap(T *arr, int i, int j) {
    T temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

template
void bubbleSort(T arr[], const int length) {
    if (arr == nullptr) {
        return;
    }
    for (int i = 0; i < length - 1; ++i) {
        for (int j = 0; j < length - 1 - i; ++j) {
            if (arr[j] > arr[j + 1]) {
                swap(arr, j, j + 1);
            }
        }
    }
}

:理论上,冒泡排序最终会进行次遍历,但是如果中途有某次遍历无需进行交换,则表示此时序列已经有序,不必再进行后续遍历了。
因此,冒泡排序的一个效率更高的改进方法为:为每次遍历增加一个变量,来标记该次遍历是否进行了交换,没有则序列有序,结束冒泡。

template
void bubbleSort(T arr[], const int length) {
    if (arr == nullptr) {
        return;
    }

    for (int i = 0; i < length - 1; ++i) {
        bool hasSwap = false;
        for (int j = 0; j < length - 1 - i; ++j) {
            if (arr[j] > arr[j + 1]) {
                swap(arr, j, j + 1);
                hasSwap = true;
            }
            if (!hasSwap) {
                break;
            }
        }
    }
}

复杂度分析:冒泡排序的时间复杂度为。

2)简单选择排序(Simple Selection Sort):其基本思想是:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。然后,在按上述规则重复应用于剩余未排序序列元素。具体过程如下图所示:

简单选择排序

从图中可以看出,简单选择排序的运行规则为:在每一轮排序时,以序列当前起始点作为最小元素位置,然后依次与后续元素进行比较(但不交换),记录该轮最小元素位置,最后与起始位置数据进行交换即可。

也就是说,简单选择排序每次都会经历次比较,但只进行一次交换。而冒泡排序时每一次比较时都可能会进行交换。

具体代码如下所示:

template
void selectionSort(T arr[], const int length) {
    for (int i = 0; i < length - 1; ++i) {
        int min = i;
        for (int j = i + 1; j < length; ++j) {
            if (arr[j] < arr[min]) {
                min = j;
            }
        }
        if (min != i) {
            swap(arr, i, min);
        }
    }
}

复杂度分析:理论上选择排序的时间复杂度与冒泡排序一样,均为,但选择排序的最大特点就是交换移动数据次数相当少,因此其性能还是要略优于冒泡排序。

3)直接插入排序(Straight Insertion Sort):其工作原理是通过构建有序序列,对于要插入的数据,在已排序序列中从后向前进行比较,序列中数据大于插入数据时,将该数据进行后移,直到找到小于插入数据的位置,则该位置后一位即为数据插入位置。具体过程如下图所示:

直接插入排序

从图中可以看出,直接插入排序从乱序序列第二个元素开始作为插入数据,对于该插入数据前的子序列认为是已排序序列(也即第二个元素作为插入数据时,前面序列(即第一个元素)是已排序;第三个元素作为插入数据时,前面序列(即第一个元素和第二个元素)是已排序……)。每次排序时,从已排序序列的末尾位置开始扫描,并与插入数据进行比较,大于插入数据时,将该数据向后移动一位,直至扫描到不大于插入数据的元素。

具体代码如下:

template
void insertionSort(T arr[], const int length) {
    for (int i = 1; i < length; ++i) {
        T insertEle = arr[i]; // 要插入的元素
        int preIndex = i - 1; // 已排序序列最后索引
        while (preIndex >= 0 && insertEle < arr[preIndex]) {
            arr[preIndex + 1] = arr[preIndex];
            --preIndex;
        }
        arr[preIndex + 1] = insertEle;
    }
}

时间复杂度分析:直接插入排序的时间复杂度也为,如果排序序列是随机的,那么直接插入排序的平均比较和移动次数耗时相对于冒泡和简单选择排序的性能要好一些。

4)希尔排序(Shell Sort):希尔排序算法是对直接插入排序算法的改进,是第一个突破的排序算法。其算法的基本思想是:先将待排记录序列分割成为若干子序列分别进行插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行一次直接插入排序。如下图所示:

希尔排序

希尔排序的具体算法描述为:

  1. 选择一个增量序列,其中;
    增量 的选取非常关键(原因见下文),但目前为止还没有一种所谓最好的增量选择标准,比较常用的增量序列为希尔增量,即增量,缩小增量为。增量序列的最后一个增量必须等于 1,这样最后才会对整个序列进行直接插入排序。
  2. 按增量序列个数,对序列进行趟排序;
  3. 每趟排序,根据对应的增量,将待排序列分割成若干长度为的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

我们对上图进行分析,具体过程如下:

具体代码如下:

template
void shellSort(T arr[], const int length) {
    int gap = length >> 1;
    do {
        for (int i = gap; i < length; ++i) {
            T insertEle = arr[i];
            int preIndex = i - gap;
            while (preIndex >= 0 && insertEle < arr[preIndex]) {
                arr[preIndex + gap] = arr[preIndex];
                preIndex -= gap;
            }
            arr[preIndex + gap] = insertEle;
        }
        gap >>= 1;
    } while (gap >= 1);
}

希尔排序其实就是使用一个『增量』将记录分割为多个子序列,然后将子序列进行直接插入排序,实现跳跃式移动。当增量等于 1 时,此时得到的序列已达到基本有序,因此最后进行一次直接插入排序的效率就很高了,并且在最后一次插入排序后,整个序列就有序了。

:希尔排序中,增量 的目的是为了均匀获取整个序列区间的所有数据,这样最后构造完成的序列才比较有可能达到『基本有序』。而如果采用将整个序列切割为左右区间等方式,当左右区间数据差异很大时(比如,左区域数值普遍大于右区域),只能达到『局部有序』,不能满足基本有序。因此,增量 的选取很重要。

复杂度分析:希尔排序的时间复杂度为。

5)堆排序(Heap Sort):其基本思想是:将待排序序列构造成一个大(小)顶堆,此时,整个序列的最大(小)值就是堆顶的根结点。将其与末尾元素(即数组最后一个元素)进行交换,此时末尾就为最大(小)值。然后将剩余个元素重新构造成一个堆,这样会得到个元素的次大(小)值。如此反复执行,便能得到一个有序序列了。

在理解 堆排序 前,首先需要先了解下 这个数据结构:
是一棵完全二叉树,其可细分为 大顶堆小顶堆

  • 大顶堆:每个结点的值都大于或等于其左右孩子结点值;
  • 小顶堆:每个结点的值都小于或等于其左右孩子结点值;

把上图大顶堆按层序进行遍历,就可得到如下数组:


该数组从逻辑结构上来看,其就是一个大顶堆。

:从 的定义可以得知,根结点一定是堆中所有结点最大(小)值。
同时因为 是一棵完全二叉树,因此其结点满足以下关系:第个结点()的左孩子结点为,右孩子结点为(不超出数组长度前提下),也即:

  • 对于 大顶堆
  • 对于 小顶堆

下面对堆排序的具体算法进行描述:

  1. 首先对无序序列进行堆构建,一般升序使用大顶堆,降序使用小顶堆。其构建具体步骤如下(这里我们使用大顶堆构建一个升序序列):
    • 首先找到序列最后一个树枝结点索引:;
      :堆最后一个树枝结点索引表达式具体推导过程请查看附录。

    • 然后比较该树枝结点的左右子树数值,获取较大值的子结点,与该树枝结点进行比较。若子结点大于树枝结点,则将该两结点进行交换;

    • 回溯当前树枝结点的父结点(另一个树枝结点),重复上述步骤,即可完成父结点赋予到最大值;此时,由于构建过程中,可能存在父结点与其中一个子结点数据交换而导致子结点分支不符合大顶堆定义,因此此时还需定位当前父结点的左子树结点,继续递归进行大顶堆构建过程。

  2. 当堆构建完成后,就可以将堆顶元素与末尾元素进行交换,使末尾元素最大。然后排除末尾元素,将序列剩余元素继续进行堆构建,重复步骤1和2,直至构建完成。

下面举个例子进行堆排序讲解(实例源自互联网,侵删):

  • 假设存在下列无序序列,要求对其进行升序排序:

  • 步骤一:首先对上述无序序列进行大顶堆构建:

    • 找到序列最后一个树枝结点索引:,即最后一个树枝结点为:;
    • 对该树枝结点从左到右,从下至上进行大顶堆构建:由于 [6,5,9] 中 9 元素最大,因此 6 和 9 交换:
    • 回溯已完成结点的父结点,即第二个树枝结点4,进行构建:由于 [4,9,8] 中 9 元素最大,因此 4 和 9 交换:
    • 由于交换导致了子根 [4,5,6] 结构不符合大顶堆定义,需递归迭代子结点进行调整:这里对 [4,5,6] 进行调整:交换 4 和 6:
    • 到此,我们便已完成将一个无序序列构建成大顶堆了。
  • 步骤二:将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换:

    • 将堆顶元素 9 和末尾元素 4 进行交换:
    • 排除末尾元素 9,将序列剩余重新调整结构,使其继续满足大顶堆定义:
      :因此此前已经完成大顶堆的构建了,因此这里我们只需对交换元素的堆顶(交换元素导致堆顶不满足大顶堆定义)进行进行调整即可。
    • 再将堆顶元素 8 与末尾元素 5 进行交换,得到第二大元素 8:
    • 后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序:

具体代码如下:

template
void heapAdjust(T arr[], const int length, int curIndex) {
    // 左子树索引
    int leftChildIndex = (curIndex << 1) + 1;
    // 右子树索引
    int rightChildIndex = (curIndex << 1) + 2;
    // 最大值索引
    int maxIndex = curIndex;

    // 当前结点与左子树对比
    if (leftChildIndex < length && arr[leftChildIndex] > arr[maxIndex]) {
        maxIndex = leftChildIndex;
    }

    // 与右子树对比
    if (rightChildIndex < length && arr[rightChildIndex] > arr[maxIndex]) {
        maxIndex = rightChildIndex;
    }

    // 最大值不为当前结点索引,表明左或右子树大于当前结点
    if (maxIndex != curIndex) {
        // 当前结点与最大子结点进行交换
        swap(arr, curIndex, maxIndex);
        // 重新调整交换结点,使之符合堆定义
        heapAdjust(arr, length, maxIndex);
    }
}

template
void heapSort(T arr[], const int length) {
    // 对各个树枝结点进行堆构建
    for (int i = (length >> 1) - 1; i >= 0; --i) {
        heapAdjust(arr, length, i);
    }

    for (int i = length - 1; i > 0; --i) {
        // 交换堆顶元素与末尾元素
        swap(arr, i, 0);
        // 排除末尾元素,从堆顶继续进行堆构建
        // 因为经过步骤一的堆构建和步骤二的末尾元素交换后,
        // 此时序列剩余数据只有堆顶元素不满足大顶堆定义
        // 而其余结点都是满足的,因此直接对堆顶进行堆构建即可
        heapAdjust(arr, i, 0);
    }
}

前面介绍过,简单选择排序的工作原理就是每次在进行排序时,都会把该轮扫描到的最小数据替换到序列起始位置,它的特点就是比较次数很多,但是数据交换次数较少。它的缺点就是多轮扫描中,不保存比较结果,因此存在重复比较操作。因此,如果能在每一轮比较记录的同时,对比较结果做出记录,减少后续排序的比较次数,那么对于排序性能便会有很大的提升。而 堆排序 就是对简单选择排序的一种改进算法,并且改进效果非常明显。不过由于其对记录的比较和替换是跳跃式进行,因此堆排序是一种不稳定的排序方法。另外,由于初始构建堆时所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。

复杂度分析:堆排序的时间复杂度为

6)归并排序(Merge Sort):归并排序就是利用归并的思想实现的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行。其基本思想是:假设初始序列含有个记录,则可以看成是个有序的子序列,每个子序列的长度为1,然后两两归并,得到(表示不小于的最小整数)个长度为 2 或 1 的有序序列;再两两归并,……,如此重复,直至得到一个长度为的有序序列为止,这种排序方法称为 2路归并排序。其具体过程如下图所示:

归并排序

从图中可以看出,2路归并排序的运行规则为:

  • 首先,采用分治法,将无序序列划分为多份有序序列(最小划分到一个记录);
  • 将各个有序子序列进行两两归并,直至达到完整序列长度。

:归并排序的核心在于子序列的归并操作,但归并操作的前提是两个子序列(对于2路归并)必须是有序的,因此,在进行归并之前,我们需要通过分治法确保各个子序列是有序的(举个例子,比如对于无序序列,分成两个子序列和,则两个子序列都是各自有序的,因此可以直接进行归并操作,得到有序序列;但如果无序序列为),如果分成两个子序列和,则第一个子序列是无序的,因此不能直接对这两个子序列进行归并操作,此时还需要将子序列进一步划分为和两个子序列(分治法),然后进行归并,得到,再将该结果与子序列进行归并,才可得到最终有序序列)。

归并排序的核心操作为:拆分归并

  • 拆分:对原始序列进行划分,得到多个有序子序列;
  • 归并:两两合并子序列到一个大序列中,其具体操作步骤如下:
    1.申请新空间,大小为两个子序列长度之和,用于存放合并后的序列;
    2.设定两个指针,分别指向两个子序列的起始位置;
    3.比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动该指针到下一位置;
    4.重复步骤3,直至某个子序列遍历完成;
    5.将未遍历完的另一个子序列的剩余元素直接复制到合并空间末尾。

具体代码如下:

template
void merge(T source[], int L, int M, int R) {
    // 左子序列大小
    int leftSize = M - L + 1;
    // 右子序列大小
    int rightSize = R - M;
    T *left = new T[leftSize];
    T *right = new T[rightSize];

    // 左子序列填充源数据内容
    for (int i = L; i <= M; ++i) {
        left[i - L] = source[i];
    }
    // 右子序列填充源数据内容
    for (int i = M + 1; i <= R; ++i) {
        right[i - M - 1] = source[i];
    }
    int i = 0, j = 0, k = L;
    // 比较两个子序列,依数据大小复制到源空间中
    while (i < leftSize && j < rightSize) {
        if (left[i] <= right[j]) {
            source[k++] = left[i++];
        }
        else {
            source[k++] = right[j++];
        }
    }
    // 左子序列有剩余数据,直接将剩余数据依次复制到源空间末尾
    while (i < leftSize) {
        source[k++] = left[i++];
    }
    // 右子序列有剩余数据,直接将剩余数据依次复制到源空间末尾
    while (j < rightSize) {
        source[k++] = right[j++];
    }
    delete[] left;
    delete[] right;
}


template
void mergeSort(T arr[], const int L, const int R) {
    // 只有一个元素,则无需继续拆分
    if (L == R) {
        return;
    }
    // 取中间数,进行拆分
    int M = L + ((R - L) >> 1);
    // 递归拆分左区间
    mergeSort(arr, L, M);
    // 递归拆分右区间
    mergeSort(arr, M + 1, R);
    // 归并
    merge(arr, L, M, R);
}

7)快速排序(Quick Sort):其基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。其具体过程如下图所示:

快速排序

快速排序算法具体步骤描述如下:

  1. 基准值(pivot):第一步就是要选取一个基准值
    :基准值的选择一般来说没有特殊要求(尽量避免选取到极端值),为了方便,通常选择序列第一个元素作为基准值。

  2. 分区(partition):选取完基准值后,将所有比基准值小的字段放置到基准值前面,所有比基准值大的字段放置到基准值后面(与基准值相等可不进行操作,哪一边都可以)。

  3. 递归子序列:当分区完毕后,就会产生两个子序列(此时基准值处于分界位置),对这两个子序列递归重复步骤1,2,直至排序完成。

目前主流的快速排序实现方法大致有以下两种:

  • 挖坑法:其具体算法步骤描述如下:

    1. 首先选择基准值,比如这里将序列第一个元素作为基准值,然后将基准值所在位置作为一个“坑”。
    2. 从序列最后一个元素开始向前遍历(由右向左遍历),当遍历到的元素小于基准值时,将该元素放置到"坑"中,并把当前元素所在位置设置为新的”坑“。
    3. 从序列索引为0的位置向后遍历(由左向右遍历),找到大于基准值的元素,将该元素放置到上一个步骤产生的”坑“中,并把当前元素所在的位置设置为新的”坑“。
    4. 重复步骤2和3,直到遍历结束,即左边遍历索引等于右边遍历索引,此时将基准值复制给当前索引位置,完成一轮排序。
    5. 对产生的两个子序列分别递归进行前面所有步骤,直至排序完成。

    :对于步骤2和3,有的算法是在找到大于或小于基准值字段的时候,直接将其与基准值位置进行交换。好处就是”坑“的位置固定为基准值位置,思路更加直接,缺点就是交换次数多一倍。

  • 指针交换法:其具体算法步骤描述如下:

    1. 首先选择基准值,比如这里将序列第一个元素作为基准值。
    2. 构建一个左指针left,指向序列索引0位置;构建一个右指针right,指向序列最后元素位置。
    3. 右指针right向前遍历(由右向左遍历),直到找到小于基准值的元素,此时right指向该元素。
    4. 左指针left向后遍历(由左向右遍历),直到找到大于基准值的元素,此时left指向该元素。
    5. 交换leftright指向元素的两个值。
    6. 重复第3、4、5步骤,直至遍历结束。此时left == right,将基准值赋值给left所在索引元素,完成一轮排序。
    7. 对产生的两个子序列分别递归进行前面所有步骤,直至排序完成。

挖坑法 相对来说,比较难以理解,且性能较低(一次前后遍历需要进行两次交换)。而 指针交换法 思路更直接简单,且性能更佳(一次前后遍历只需一次交换)。

下面基于 指针交换法 阐述上图示例(这里只截取部分序列),如下所示:

  1. 首先,选择序列索引0作为基准值,即pivot=3,然后左指针left=0,右指针right=8。如下图所示:

  2. 右指针right向前遍历,大于基准值则--right,直到找到小于基准值的元素。如下图所示:

  3. 左指针left向后遍历,小于或等于基准值则++left,直至找到大于基准值的元素。如下图所示:

  4. 交换leftright指针指向的元素。如下图所示:

  5. 然后right继续向前遍历,此时找到1小于基准值3。如下图所示:

  6. 然后left继续向后遍历,此时找到8大于基准值3。如下图所示:

  7. 交换leftright指针指向的元素。如下图所示:

  8. 然后right继续向前遍历,找到1停止,此时leftright重叠。如下图所示:

  9. left == right时,将重叠所在元素与基准值元素进行交换,完成一轮遍历。如下图所示:

  10. 此时以基准值为界限,序列以分割成两个子序列,对该两个子序列分别递归重复上述步骤即可。

指针法 具体代码如下所示:

template
int partition(T arr[], int startIndex, int endIndex) {
    // 以序列第一个元素为基准值
    int pivot = arr[startIndex];
    // 左指针
    int left = startIndex;
    // 右指针
    int right = endIndex;

    while (left != right) {
        // 右指针左移,直至找到小于基准值的元素
        while (left < right && arr[right] >= pivot) {
            --right;
        }
        // 左指针右移,直至找到大于基准值元素
        while (left < right && arr[left] <= pivot) {
            ++left;
        }
        // 交换左右指针元素
        if (left < right) {
            swap(arr, left, right);
        }
    }
    // 左右指针重叠,与基准值元素交换
    if (startIndex != left) {
        swap(arr, startIndex, left);
    }
    return left;
}

template
void quickSort(T arr[], int startIndex, int endIndex) {

    // 递归结束条件:子序列只有一个元素
    if (startIndex >= endIndex) {
        return;
    }
    // 分区完成后,以基准元素为界面,将序列分割为 2 个子序列
    int pivotIndex = partition(arr, startIndex, endIndex);
    // 递归左子序列
    quickSort(arr, startIndex, pivotIndex - 1);
    // 递归右子序列
    quickSort(arr, pivotIndex + 1, endIndex);

}

复杂度分析:快速排序的时间复杂度为。
且在大多数情况下都比平均时间复杂度为 O(n logn) 的排序算法表现要更好。具体原因可查看:菜鸟 - 快速排序

附录

  • 堆最后一个树枝结点索引为推导如下所示:

    将序列转化为堆时,索引0即为根结点,索引1为根结点左子树,索引2为根结点右子树,依次类推,则最后一个结点的索引为。

    因为堆是完全二叉树,而完全二叉树的一个特性是:对于树枝结点,假设树枝结点对应序列的索引为,则其左孩子索引为,右孩子索引为。

    现在假设最后一个树枝结点索引为,则可能存在如下两种情况:

    1. 结点只有左孩子,则此时,所以
    2. 结点有左右孩子,则此时最后一个结点为右孩子,故有,也即

    然后,完全二叉树的另一条特性就是如果根结点编号为1,其他所有左结点编号都为偶数(即结点若为左孩子,则为偶数),所有右结点编号都为奇数(即结点若为右孩子,则为奇数)。

    所以,若结点只有左孩子,那么完全二叉树总结点数为偶数(即为偶数),此时结点的索引为。
    如果结点有左右孩子,那么完全二叉树总结点数为奇数(即为奇数)。此时,关键点就在这里,对于C++、Java等大多数编程语言,当无法整除时,就会向下取整,比如:5/2=2,这个效果就等于(5-1)/2=2,所以当为奇数时,就相当于。

    通过上述这种转化,就统一了完全二叉树最后一个树枝结点的索引获取过程。

参考

  • 《大话数据结构》
  • 漫画:“排序算法” 大总结
  • 图解排序算法(三)之堆排序

  • 归并排序就这么简单

  • 算法 3:最常用的排序——快速排序

你可能感兴趣的:(Algorithm - 排序算法)