内部排序

需熟练掌握各种排序的过程和算法分析,要求能够手工模拟算法过程,另外还需要能手写快速排序的代码。

排序概念

稳定性

排序算法的 稳定性 是指:在排序过程中,如果两个元素的键值相等,排序后它们的 相对顺序保持不变,那么这个排序算法就是稳定的排序算法。

比如排序前的数组是这样:[... A ... B ...],并且 AB 的值相同。

如果排序后的数组是这样:[... B A ...],那么排序算法就是不稳定的,反之排序算法就是稳定的。

元素移动次数

排序算法中的 元素移动次数,指的是在排序过程中对 数组元素进行位置变换的次数

下表列出了各排序算法的元素移动次数:

排序算法元素移动次数说明
冒泡排序最坏 $O(n^{2})$每次交换都移动两个元素,次数较多
选择排序最多 $O(n)$每轮找到最小值,只交换一次,移动次数较少,但比较多
插入排序平均 $O(n^2)$可能需要把一个元素插入前面,移动一串元素
归并排序$O(n log_{2}n)$借助辅助数组,搬来搬去,数据复制较频繁
快速排序平均 $O(n log_{2}n)$,最坏 $O(n^{2})$每次划分区间时要交换元素
堆排序$O(n log_{2}n)$每次堆化需要交换元素,整体移动较快排少
希尔排序$O(n^{1.3 \sim 2})$分段插入,元素跳跃移动,次数难以精确界定
桶排序$O(n)$元素在桶中“复制”而不是交换,适合不比较的排序场景

可以观察到,选择排序桶排序 的元素移动次数是最少的,不涉及到大量的元素位置交换。

复杂度

排序方式时间复杂度空间复杂度是否稳定
冒泡排序$O(n^2)$$O(1)$稳定
选择排序$O(n^2)$$O(1)$不稳定
插入排序$O(n^2)$$O(1)$稳定
基数排序$O(d*(n+k))$$O(n+k)$稳定
归并排序$O(n*log(n))$$O(n)$稳定
希尔排序取决于增量序列
最坏情况为$O(n^2)$
$O(1)$不稳定
快速排序$O(n^2)$(最坏情况)
$O(n*log(n))$(平均情况)
$O(log(n))$不稳定
堆排序$O(n*log(n))$$O(n)$不稳定
BubbleSort(arr)
    n = arr.length
    for i from 0 to n-1
        for j from 0 to n-i-1
            if arr[j] > arr[j+1]
                swap(arr[j], arr[j+1])
InsertionSort(arr)
    n = arr.length
    for i from 1 to n-1
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key
            arr[j+1] = arr[j]
            j = j - 1
        arr[j+1] = key
SelectionSort(arr)
    n = arr.length
    for i from 0 to n-1
        minIndex = i
        for j from i+1 to n
            if arr[j] < arr[minIndex]
                minIndex = j
        swap(arr[i], arr[minIndex])
RadixSort(arr)
    Find the maximum number in arr, determine the number of digits in it, denoted as d
    for i from 1 to d
        Using a stable sort (e.g., counting sort) to sort arr based on the i-th digit
MergeSort(arr)
    if arr.length <= 1
        return arr
    mid = arr.length / 2
    left = arr[0 to mid-1]
    right = arr[mid to end]
    left = MergeSort(left)
    right = MergeSort(right)
    result = Merge(left, right)
    return result

Merge(left, right)
    result = []
    while left is not empty and right is not empty
        if left[0] <= right[0]
            append left[0] to result
            remove left[0] from left
        else
            append right[0] to result
            remove right[0] from right
    append remaining elements of left and right to result
    return result
ShellSort(arr)
    n = arr.length
    gap = n / 2
    while gap > 0
        for i from gap to n-1
            temp = arr[i]
            j = i
            while j >= gap and arr[j - gap] > temp
                arr[j] = arr[j - gap]
                j = j - gap
            arr[j] = temp
        gap = gap / 2
QuickSort(arr, low, high)
    if low < high
        pivotIndex = Partition(arr, low, high)
        QuickSort(arr, low, pivotIndex - 1)
        QuickSort(arr, pivotIndex + 1, high)

Partition(arr, low, high)
    pivot = arr[high]
    i = low - 1
    for j from low to high-1
        if arr[j] <= pivot
            i = i + 1
            swap arr[i] and arr[j]
    swap arr[i + 1] and arr[high]
    return i + 1
HeapSort(arr)
    BuildMaxHeap(arr) // 构建最大堆
    for i from n-1 down to 1
        swap arr[0] and arr[i] // 将最大值移到末尾
        MaxHeapify(arr, 0, i) // 修复剩余的堆

BuildMaxHeap(arr)
    n = arr.length
    for i from n/2 - 1 down to 0
        MaxHeapify(arr, i, n)

MaxHeapify(arr, i, n)
    largest = i
    left = 2*i + 1
    right = 2*i + 2

    if left < n and arr[left] > arr[largest]
        largest = left

    if right < n and arr[right] > arr[largest]
        largest = right

    if largest != i
        swap arr[i] and arr[largest]
        MaxHeapify(arr, largest, n)

冒泡排序

冒泡排序(Bubble Sort)的名称来源于它的操作方式:重复地遍历要排序的序列,比较每对相邻的项,若它们的顺序错误就交换过来,就像“小泡泡”从底部冒向顶部一样。

比如,对于数组 5, 1, 4, 2, 8的前两次冒泡过程如下:

  • 第一次(效果相当于将最大的数放到最后一个位置):

( 5 1 4 2 8 ) → ( 1 5 4 2 8 )

( 1 5 4 2 8 ) → ( 1 4 5 2 8 )

( 1 4 5 2 8 ) → ( 1 4 2 5 8 )

( 1 4 2 5 8 ) → ( 1 4 2 5 8 )

  • 第二次(效果相当于将第二大的数放在倒数第二个位置):

( 1 4 2 5 8 ) → ( 1 4 2 5 8 )

( 1 4 2 5 8 ) → ( 1 2 4 5 8 )

( 1 2 4 5 8 ) → ( 1 2 4 5 8 )

插入排序

插入排序(Insertion Sort)将数组分为未排序部分和已排序部分,每次选取未排序部分的第一个元素作为插入元素,在已排序序列中从后向前扫描,找到相应位置并插入。

对于数组 4, 3, 2, 10, 12, 1, 5, 6 执行插入排序的过程:

4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6
4
3
2
10
12
1
5
6

插入排序可以分为 直接插入排序折半插入排序,其不同点在于 寻找插入位置时 使用的是顺序查找还是折半查找。

选择排序

选择排序(Selection Sort)的思想是 从未排序的部分中找到最小(或最大)的元素,然后与未排序部分的第一个元素交换位置。这样,每一次选择操作后,未排序部分减少一个元素,而有序部分则增加一个元素。

以排序数组11, 25, 12, 22, 64为例:

有序子数组无序子数组无序子数组中的最小值
()(11, 25, 12, 22, 64)11
(11)(25, 12, 22, 64)12
(11, 12)(25, 22, 64)22
(11, 12, 22)(25, 64)25
(11, 12, 22, 25)(64)64
(11, 12, 22, 25, 64)()

归并排序

归并排序(Merge Sort)是一个典型的“分而治之”策略的应用。它将一个大问题分解成若干小问题分别解决,然后将这些小问题的解合并为大问题的解。归并排序的主要思想是将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。

基本思想:

  1. 分解:分解待排序的数据数组为两个长度相等(或几乎相等)的子数组。
  2. 递归:递归地排序两个子数组。
  3. 合并:合并(归并)两个已排序的子数组以产生排序好的数据数组。
38
27
43
3
9
82
10
38
27
43
3
9
82
10
38
27
43
3
9
82
10
38
27
43
3
9
82
10
27
38
3
43
9
82
10
3
27
38
43
9
82
10
3
9
10
27
38
43
82

快速排序

快速排序(Quick Sort)的核心思想是分而治之 (Divide and Conquer):

  1. 选择一个 “基准” 元素(Pivot):从数组中选择一个元素作为基准。
  2. 分区 (Partition):重新排列数组,使得所有小于基准的元素都在其左侧,所有大于基准的元素都在其右侧。在这个分区结束之后,该基准就处于数组的最终排序位置。
  3. 递归地排序子序列:递归地对基准左侧和右侧的子数组进行快速排序。
4
3
1
2
5
9
7
10
6
1
2
4
3
6
7
10
9
1
3
4
4
7
9
10
7
10
pivot
pivot
pivot
pivot
pivot
pivot
pivot
pivot
pivot

代码实现

快排代码包含两个函数,一个是 partition,用于选择基准元素,并对其进行分区。

其中分区的实现方式有很多种,此处使用双指针法实现分区,该算法比较简短,方便记忆。

// [low, high] 为分区的范围,返回值为分区后 pivot 在数组中的下表
int partition(int a[], int low, int high) {
    int pivot = a[low];     // 设置基准元素为 [low, high] 区间中的第一个元素
    int l = low, r = high;  // 设置双指针
    while (l < r) {
        while (l < r && a[r] >= pivot) r--; // 从右到左寻找小于 pivot 的元素
        while (l < r && a[l] <= pivot) l++; // 从左到右寻找大于 pivot 的元素
        if (l < r) swap(a[l], a[r]);        // 交换两个元素
    }
    swap(a[low], a[r]); // 将 pivot 置换到中间位置
    return r;
}

void quickSort(int a[], int low, int high) {
    // 递归的结束条件,不要遗漏
    if (low < high) {
        int pivotIdx = partition(a, low, high);
        // 递归地对两个
        quickSort(a, low, pivotIdx - 1);
        quickSort(a, pivotIdx + 1, high);
    }
}

快速排序的代码实现需要熟练掌握(能够手写代码),在数据结构的算法解答题中可以作为兜底的方案。

单向递归算法

每一次基于基准元素执行分区操作后,可以基于基准元素的位置将数组分为两个部分,这个时候基准元素的位置与最后有序数组中的位置相同。

基于这一点常常会做一些考察,比如如果我们需要寻找数组中第k大的数(即找到有序数组中下标为k的数),这个时候我们就不需要向两个方向递归,只需要向一个方向递归即可,如果当前基准被确定在第k个位置,即可退出递归。

// 分区算法实现与标准快排相同,这里不赘述
int partition(int a[], int low, int high) { ... }

// 寻找数组中第 k 大的数字,返回值为该元素
int quickSelect(int a[], int low, int high, int k) {
    if (low < high) {
        pivotIdx = partition(a, low, high);
        // 如果 pivotIndex 与 k 相等,那么 a[pivotIdx] 就是我们要找的第k大的数
        if (pivotIdx == k) {
            return a[pivotIdx];
        }
        // 如果 pivotIdx 小于 k,说明第 k 大的数在右边分区
        if (pivotIdx < k) {
            return quickSelect(a, pivotIdx+1, high, k);
        }
        // 否则在左边分区
        return quickSelect(a, low, pivotIdx-1, k);
    }
}

堆排序

堆排序的核心在于利用堆的性质进行排序,在了解堆排序过程之前,需要了解堆的基本概念。

在二叉树一节中我们提到,二叉树的存储方式 包含 链接存储 和 顺序存储 两种。 堆就是采用顺序存储的方式,在这种方式中,我们使用一个数组来模拟一颗二叉树,二叉树中的结点操作被转化为数组元素操作。

概念

满足以下性质的树 被成为 堆(Heap):

  1. 结构性质:堆总是一颗 完全二叉树,即除了最后一层之外的其他每一层都被元素填满,且最后一层的元素都尽可能地靠左排列。
  2. 有序性质:树中的每个结点 相对于 孩子结点都保证有序关系,要么父结点的值都 大于等于 子结点,要么都 小于等于 子结点,按照这种有序关系堆可以分为两种类型:
    • 大根堆 (Max-Heap):堆中的任意节点,其值都不小于其子节点的值。这意味着根节点是最大值。
    • 小根堆 (Min-Heap):堆中的任意节点,其值都不大于其子节点的值。这意味着根节点是最小值。
10
15
30
40
50
100
40
100
40
50
10
15
50
40
小根堆
大根堆

基于这些性质,堆常常被用于实现优先队列。对于大根堆,我们总是可以在 O(1) 的时间内得到最大的元素(即根节点),而对于小根堆,我们总是可以在 O(1) 的时间内得到最小的元素。

实现
100
19
36
17
3
25
1
2
7
二叉树结构
堆的数组顺序表示
100
19
36
17
3
25
1
2
7
0
1
2
3
4
5
6
7
8

堆通常使用数组来实现,而不需要使用真正的树或链表结构。利用数组实现堆时,每个元素都有一个固定的位置,而该位置与元素在树中的位置有关。

假设数组的起始索引为1,若某元素的索引为 i,则它的:

  • 左孩子的索引为 2i
  • 右孩子的索引为 2i + 1
  • 父节点的索引为 i/2 (整数除法)

如果数组的起始索引为0,若某元素的索引为 i,则它的:

  • 左孩子的索引为 2i + 1
  • 右孩子的索引为 2i + 2
  • 父节点的索引为 (i-1)/2(整数除法)
操作

对于堆,重点掌握以下三种操作的过程:

  • 构造初始堆:将一个数组初始化为堆,其步骤为从最后一个非叶结点开始,向下堆化每个节点。这种方法也叫做 “下沉法”,因为这种方法会将最小值不断下沉到二叉树的底部。
  • 插入:将一个新的元素添加到堆中,并保持堆的性质。
  • 删除:删除堆中的最大(或最小)元素,并保持堆的性质。

这三种操作的时间复杂度都为 $O(n)$。

其中堆化(heapify)是实现以上操作的核心,heapify 可以保证某个子树符合堆的性质。

// 调整 i 结点为根的子树,使其满足堆的性质
void heapify(int heap[], int size, int i) {
    int largest = i;       // 假设当前节点是最大的
    int left = 2 * i + 1;  // 左结点下标
    int right = 2 * i + 2; // 右结点下标
    if (left < size && heap[left] > heap[largest]) {
        largest = left;
    }
    if (right < size && heap[right] > heap[largest]) {
        largest = right;
    }
    // 如果最大值不是根结点
    if (largest != i) {
        swap(heap[i], heap[largest]);
        heapify(heap, size, largest);  // 递归堆化受影响的子树
    }
}

// 建立最大堆
void buildHeap(int heap[], int size) {
    // 从最后一个非叶节点开始,向前堆化每个节点
    for (int i = (size / 2) - 1; i >= 0; i--) {
        heapify(heap, size, i);
    }
}
// 向大小为 size 的堆中插入元素 value
void heapInsert(int heap[], int *size, int value) {
    heap[*size] = value;
    int current = *size;
    int parent = (current - 1) / 2;
    
    // 如果新插入的元素比父结点的元素大,需要将其向上移动
    while (current > 0 && heap[current] > heap[parent]) {
        swap(heap[current], heap[current]); // 交换当前节点和父节点
        current = parent;
        parent = (current - 1) / 2; // 重新计算当前结点和其父结点的位置
    }
  
    (*size)++;  // 增加堆的大小
}
// 删除堆根结点
void heapDelete(int heap[], int *size) {
    if (*size <= 0) {
        return;
    }
    heap[0] = heap[*size - 1]; // 将堆顶元素替换为最后一个元素
    (*size)--;   // 将堆大小减1
    heapify(heap, *size, 0);   // 调整堆
}

过程

堆排序(Heap Sort)其主要思想是将待排序的序列构造成一个大顶堆或小顶堆,然后交换堆顶和最后一个元素的位置,使最大或最小的元素放到序列的末尾。这样,就得到一个部分有序的序列。接下来,再对剩下的部分重新调整为大顶堆或小顶堆,并重复上述过程,直到整个序列有序。

堆排序的主要步骤:

  1. 构造初始堆:将给定无序序列构造成一个大顶堆(对于升序排序)或小顶堆(对于降序排序)。
  2. 交换数据:将堆顶元素与末尾元素交换,这样最大元素就位于序列的末尾。然后,将未排序的序列的长度减1,因为最后一个元素已经排序好了。
  3. 重建堆:对于剩下的未排序的序列,重新调整为大顶堆。
  4. 重复步骤2和3,直到整个序列有序。

调整堆过程:

  1. 选择一个节点 i,它的左子节点为 2i,右子节点为 2i+1。
  2. 如果节点 i 的值小于其子节点的值,则找到最大的子节点。
  3. 将节点 i 与最大的子节点交换。
  4. 交换后可能会破坏下一层的堆结构,所以需要对换到的子节点重复步骤1-3的调整,直到整个子树满足堆的性质。
24
23
18
19
14
24
23
18
19
14
24
23
18
19
14
23
19
18
14
24
23
19
18
14
24
19
14
18
23
24
19
14
18
23
24
18
14
19
23
24
18
14
19
23
24
14
18
19
23
24
14
18
19
23
24
14
18
19
23
24
Original Array
After Heap Sort
14
18
19
23
24

堆排序的代码实现如下所示:

void heapSort(int heap[], int size) {
    // 构建最大堆 buildHeap
    for (int i = (size / 2) - 1; i >= 0; i--) {
        heapify(heap, size, i);
    }
    
    // 一个个从堆中取出元素
    for (int i = size - 1; i >= 0; i--) {
        swap(heap[0], heap[i]); // 将当前最大元素(堆顶)移到数组末尾
        heapify(heap, i, 0);    // 调整堆以维护最大堆性质
    }
}

希尔排序

希尔排序(Shell Sort)是一种 基于插入排序 的改进算法,通过分组和逐步减小步长来提高效率。

希尔排序的过程如下所示:

  1. 确定初始步长(增量)
    • 选择一个初始步长(gap),通常可以 取数组长度的一半(例如 gap = n/2)。
  2. 分组插入排序
    • 将数组按步长 gap 分成若干组,每组内的元素相距 gap 个位置。
    • 对每组进行插入排序。例如,若 gap=4,比较和排序索引为 0,4,8… 的元素,1,5,9… 的元素,依此类推。
  3. 减小步长
    • 将步长缩小(通常除以 2 或按增量序列递减),例如 gap = gap/2。
    • 重复步骤 2,对新的分组进行插入排序。
  4. 重复直到步长为 1
    • 当步长减小到 1 时,相当于对整个数组进行一次标准插入排序。
    • 此时数组已接近有序,插入排序的效率较高。
  5. 排序完成
    • 步长为 1 的插入排序完成后,数组完全有序。

以数组 32, 95, 16, 82, 24, 66, 35, 19, 75, 54, 40, 43, 93, 68 为例进行选择希尔排序,数组长度为 14, 初始步长为 7,然后选择步长 3、1,依次按照步长对所有子数组排序。

32
95
16
82
24
66
35
19
75
54
40
43
93
68
19
75
16
40
24
66
35
32
95
54
82
43
93
68
0
1
2
3
4
5
6
7
8
9
10
11
12
13
original
gap = 7
19
24
16
35
32
43
40
68
66
54
75
95
93
82
gap = 3
gap = 1
16
19
24
32
35
40
43
54
66
68
75
82
93
95
同色的元素表示该轮次 shell sort 根据 gap 选择的子数组

桶排序

桶排序(bucket sort)核心思想是将数据映射到不同的桶中,然后对每个桶内部排序,最后合并所有桶中的数据。

桶排序的步骤如下:

  1. 选择合适桶数,并将数据放入桶中
  2. 桶内数据排序
  3. 合并所有桶

比如对 0.78, 0.17, 0.39, 0.26, 0.72 进行排序:

  1. 划分桶:
    • 0.0 - 0.2: [0.17]
    • 0.2 - 0.4: [0.26, 0.39]
    • 0.4 - 0.6: []
    • 0.6 - 0.8: [0.72, 0.78]
    • 0.8 - 1.0: []
  2. 对每个桶排序:
    • [0.17]
    • [0.26, 0.39][0.26, 0.39]
    • [0.72, 0.78][0.72, 0.78]
  3. 合并所有桶:[0.17, 0.26, 0.39, 0.72, 0.78]

基数排序

基数排序(Radix Sort)的核心思想是 将整数分解为单独的数字,然后进行多轮排序,最终使数据有序。

基数排序分为低位优先(LSD,Least Significant Digit first)和高位优先(MSD,Most Significant Digit first)两种方案。 这里重点掌握 LSD 的方案,MSD 的方式了解即可。

低位优先

  1. 先按照最低位进行桶排序(或计数排序)。
  2. 然后按照次低位进行桶排序,但保持上一轮的相对顺序(稳定排序)。
  3. 依次进行,直到最高位排序完成。
0
1
2
3
4
5
6
7
8
9
278
278, 109, 063, 930, 589, 184, 505, 269, 008, 083
109
063
930
599
184
505
269
008
083
930, 063, 083, 184, 505, 178, 008, 109, 599, 269
0
1
2
3
4
5
6
7
8
9
930
083
063
184
505
278
008
109
599
269
505, 008, 109, 930, 063, 269, 278, 083, 184, 599
0
1
2
3
4
5
6
7
8
9
505
008
109
930
063
269
278
083
008, 063, 083, 109, 184, 269, 278, 505, 599, 930
184
599
第一次排序,按照个位将数字加入桶中
第二次排序,按照十位将数字加入桶中
第三次排序,按照百位将数字加入桶中

以上排序的效果如下:

2
7
8
1
0
9
0
6
3
9
3
0
5
8
9
1
8
4
5
0
5
2
6
9
0
0
8
0
8
3
9
3
0
0
6
3
0
8
3
1
8
4
5
0
5
2
7
8
0
0
8
1
0
9
5
8
9
2
6
9
5
0
5
0
0
8
1
0
9
9
3
0
0
6
3
2
6
9
2
7
8
0
8
3
1
8
4
5
8
9
0
0
8
0
8
3
1
0
9
1
8
4
2
6
9
2
7
8
5
0
5
5
8
9
0
6
3
9
3
0

首先对最低位排序,然后对次低位排序,最后对最高位排序。通过三轮排序,可以保证结果序列是有序的。

高位优先

MSD(高位优先)基数排序的核心思想是从最高位开始,对数据进行递归分类,直到所有数字或字符串都排好序。

2
7
8
1
0
9
0
6
3
9
3
0
5
8
9
1
8
4
5
0
5
2
6
9
0
0
8
0
8
3
0
6
3
0
0
8
0
8
3
1
0
9
1
8
4
2
7
8
2
6
9
5
8
9
5
0
5
9
3
0
0
0
8
0
6
3
0
8
3
1
0
9
1
8
4
2
6
9
2
7
8
5
0
5
5
8
9

如上图所示,MSD 首先根据最高位对数据进行分组,然后再根据次高位对数据进行分组,以此类推,递归直到每组中只有一个元素。

MSD 基数排序适用于字符串或变长数据,因为它先处理最高位,可以提前分组。 适合字典序排序,如IP 地址、文件名、长整型数值等。

B
A
D
B
A
N
C
O
F
G
E
\0
N
E
R
\0
F
E
\0
C
O
M
P
A
R
I
S
O
N
\0
C
O
M
P
U
T
E
R
\0
M
I
D
N
I
G
H
T
\0
W
A
N
D
E
R
\0
M
A
R
D
R
O
B
E
\0
W
O
R
K
E
R
\0
使用 MSD 对字符串进行排序,绿色表示递归的范围

对比

方式低位优先(LSD)高位优先(MSD)
排序顺序从低位到高位从高位到低位
是否递归
适用数据等长数据(整数、固定长度字符串)变长数据(字符串、IP 地址)
适用场景大量数值排序变长字符串、字典序排序
实现难度较简单需要递归,较复杂