选择排序快速排序
1. 选择排序
1.1 基本思想
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
1.2 直接选择排序
这个原理还是比较好理解的,所以我们直接给出代码
// 直接选择排序
void selectionSort(std::vector<int>& arr) {int n = arr.size();for (int i = 0; i < n - 1; ++i) {int minIndex = i;for (int j = i + 1; j < n; ++j) {if (arr[j] < arr[minIndex]) {minIndex = j;}}if (minIndex != i) {std::swap(arr[i], arr[minIndex]);}}
}
在selectionSort
函数里,外层循环对每个位置进行遍历,内层循环找出从当前位置开始的最小元素的索引,之后把最小元素和当前位置的元素进行交换
特性总结:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
相比之下,我们下面来看这个堆排序应用还是比较广泛的,而且效率相当高哈哈哈
1.3 堆排序
堆排序(Heap Sort)是一种基于堆这种数据结构的高效排序算法,它结合了堆的特性和排序的需求,具有时间复杂度为 O ( n l o g n ) O(n log n) O(nlogn) 的良好性能,并且是一种原地排序算法(即不需要额外的大量存储空间)。下面从堆的概念、堆排序的原理、算法步骤、代码实现和复杂度分析几个方面详细介绍堆排序。
堆的概念
堆是一种特殊的完全二叉树,分为最大堆和最小堆:
- 最大堆:每个节点的值都大于或等于其子节点的值。这意味着堆的根节点是整个堆中的最大值。
- 最小堆:每个节点的值都小于或等于其子节点的值。所以堆的根节点是整个堆中的最小值。
堆排序的原理
堆排序的核心思想是利用堆的性质,将待排序序列构造成一个堆,然后不断地从堆中取出最大(或最小)元素,重新调整堆,直到堆为空,从而得到一个有序序列。具体来说,通常使用最大堆来实现升序排序,基本步骤如下:
- 构建最大堆:将待排序的数组转换为一个最大堆,使得堆的根节点(即数组的第一个元素)是数组中的最大值。
- 交换元素:将堆的根节点(最大值)与数组的最后一个元素交换位置,此时最大值就被放到了数组的末尾。
- 调整堆:将剩余的元素重新调整为一个最大堆,重复步骤2和3,直到整个数组有序。
算法步骤
1. 构建最大堆
从最后一个非叶子节点开始,依次对每个节点进行堆化操作,确保每个子树都满足最大堆的性质。最后一个非叶子节点的索引为 n/2 - 1
,其中 n
是数组的长度。
2. 交换和调整
- 将堆顶元素(最大值)与数组的最后一个元素交换位置。
- 减少堆的大小(即排除已经排好序的最后一个元素),并对新的堆顶元素进行堆化操作,以恢复最大堆的性质。
- 重复上述步骤,直到堆的大小为1。
以下是使用C++实现的堆排序代码:
#include <iostream>
#include <vector>// 调整堆,使其满足最大堆的性质
void heapify(std::vector<int>& arr, int n, int i) {int largest = i; // 初始化根节点为最大值int left = 2 * i + 1; // 左子节点int right = 2 * i + 2; // 右子节点// 如果左子节点比根节点大,则更新最大值索引if (left < n && arr[left] > arr[largest]) {largest = left;}// 如果右子节点比当前最大值大,则更新最大值索引if (right < n && arr[right] > arr[largest]) {largest = right;}// 如果最大值不是根节点,则交换它们,并继续调整子树if (largest != i) {std::swap(arr[i], arr[largest]);heapify(arr, n, largest);}
}// 堆排序函数
void heapSort(std::vector<int>& arr) {int n = arr.size();// 构建最大堆for (int i = n / 2 - 1; i >= 0; --i) {heapify(arr, n, i);}// 一个个交换元素for (int i = n - 1; i > 0; --i) {// 将堆顶元素(最大值)与当前未排序部分的最后一个元素交换std::swap(arr[0], arr[i]);// 调整剩余元素,使其重新成为最大堆heapify(arr, i, 0);}
}// 打印数组
void printArray(const std::vector<int>& arr) {for (int num : arr) {std::cout << num << " ";}std::cout << std::endl;
}int main() {std::vector<int> arr = {12, 11, 13, 5, 6, 7};std::cout << "Original array: ";printArray(arr);heapSort(arr);std::cout << "Sorted array: ";printArray(arr);return 0;
}
示意图
以下是一个堆排序的示意图,以数组 [4, 6, 8, 5, 9]
为例构建大顶堆进行升序排序:
初始状态
将给定的无序序列看作是一个完全二叉树,按照数组下标顺序依次对应二叉树的节点。
4/ \6 8/ \ / \5 9
构建大顶堆
- 从最后一个非叶子节点开始调整,即从节点
6
(数组下标为1
,因为节点总数为5
,最后一个非叶子节点的索引是5 / 2 - 1 = 1
)开始。比较6
的左右子节点5
和9
,9
更大,交换6
和9
的位置。
4/ \9 8/ \ / \5 6
- 接着处理节点
4
,比较4
的左右子节点9
和8
,9
更大,交换4
和9
的位置。此时交换后4
作为9
的子节点,比9
的另一个子节点6
小,需要继续交换4
和6
的位置。
9/ \8 6/ \ / \5 4
至此,大顶堆构建完成。
排序过程
- 将堆顶元素
9
与末尾元素4
交换,此时9
到达数组末尾,成为已排序部分,剩余元素[8, 6, 5, 4]
继续构建大顶堆。
交换后: 4/ \8 6/ \ /5 9重新调整为大顶堆: 8/ \5 6/ \ /4 9
- 再次将堆顶元素
8
与当前末尾元素4
交换,然后对剩余元素[6, 5, 4]
构建大顶堆。
交换后: 4/ \5 6/ /8 9重新调整为大顶堆: 6/ \4 5/ /8 9
- 继续将堆顶元素
6
与末尾元素4
交换,对[5, 4]
构建大顶堆。
交换后: 4/ \5 6/ /8 9重新调整为大顶堆: 5/ \4 6/ /8 9
- 最后将堆顶元素
5
与末尾元素4
交换,得到有序序列[4, 5, 6, 8, 9]
。
交换后: 4/ \5 6/ /8 9最终有序序列: 4 5 6 8 9
通过不断地构建大顶堆和交换堆顶元素与末尾元素,最终实现了数组的升序排序。如果是降序排序,则需要构建小顶堆,过程类似。
复杂度分析
- 时间复杂度:堆排序的时间复杂度为 O ( n l o g n ) O(n log n) O(nlogn),其中构建最大堆的时间复杂度为 O ( n ) O(n) O(n),每次交换和调整堆的时间复杂度为 O ( l o g n ) O(log n) O(logn),总共需要进行 n − 1 n - 1 n−1 次交换和调整。
- 空间复杂度:堆排序是一种原地排序算法,只需要常数级的额外空间,因此空间复杂度为 O ( 1 ) O(1) O(1)。
优缺点
- 优点:
- 时间复杂度稳定,无论输入数据的初始状态如何,堆排序的时间复杂度都是 O ( n l o g n ) O(n log n) O(nlogn)。
- 空间复杂度低,只需要常数级的额外空间。
- 缺点:
- 堆排序的常数因子较大,在实际应用中,其性能可能不如快速排序等其他排序算法。
- 堆排序是一种不稳定的排序算法,即相等元素的相对顺序在排序后可能会发生改变。
所以我们下面就来看一个排序很快的排序算法,简单明了的名字——快速排序
2. 快速排序
快排是的交换排序的一种,另一种是仅具有教学实践意义的冒泡排序,我想也不必多说了吧哈哈,前面的文章也有提到过
2.1 基本思想
所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
快速排序(Quick Sort)是由东尼·霍尔所发展的一种排序算法,在平均状况下,排序 n n n 个项目要 O ( n l o g n ) O(n log n) O(nlogn) 次比较。在最坏状况下则需要 O ( n 2 ) O(n^2) O(n2) 次比较,但这种状况并不常见。快速排序通常明显比其他 O ( n l o g n ) O(n log n) O(nlogn) 算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地被实现出来。
这也是在面试上喜欢被问到的一种算法哦。
下面从算法原理、步骤、代码实现、复杂度分析等方面详细介绍快速排序。
2.2 算法原理
快速排序采用了分治法(Divide and Conquer)的策略。它的基本思想是通过选择一个基准元素(pivot),将数组分为两部分,使得左边部分的所有元素都小于等于基准元素,右边部分的所有元素都大于等于基准元素,然后分别对左右两部分递归地进行快速排序,最终得到一个有序的数组。
2.3 算法步骤
- 选择基准元素:从数组中选择一个元素作为基准元素(pivot)。基准元素的选择可以有多种方式,常见的是选择数组的第一个元素、最后一个元素、中间元素或者随机元素。
- 分区操作:将数组中所有小于等于基准元素的元素放到基准元素的左边,所有大于等于基准元素的元素放到基准元素的右边。这个过程称为分区(partitioning),分区结束后,基准元素就处于其最终排序的位置。
- 递归排序:分别对基准元素左边和右边的子数组递归地进行快速排序,直到子数组的长度为 0 或 1,此时子数组已经有序。
2.4 代码实现
这个是递归的代码
#include <iostream>
#include <vector>// 分区函数,将数组分为两部分,左边部分小于等于基准元素,右边部分大于基准元素
int partition(std::vector<int>& arr, int low, int high) {// 选择最后一个元素作为基准元素int pivot = arr[high];int i = low - 1;// 遍历数组,将小于等于基准元素的元素交换到左边for (int j = low; j < high; ++j) {if (arr[j] <= pivot) {++i;std::swap(arr[i], arr[j]);}}// 将基准元素放到正确的位置std::swap(arr[i + 1], arr[high]);return i + 1;
}// 快速排序函数,递归地对数组进行排序
void quickSort(std::vector<int>& arr, int low, int high) {if (low < high) {// 进行分区操作,获取基准元素的最终位置int pi = partition(arr, low, high);// 递归排序基准元素左边的子数组quickSort(arr, low, pi - 1);// 递归排序基准元素右边的子数组quickSort(arr, pi + 1, high);}
}// 打印数组元素
void printArray(const std::vector<int>& arr) {for (int num : arr) {std::cout << num << " ";}std::cout << std::endl;
}int main() {std::vector<int> arr = {10, 7, 8, 9, 1, 5};int n = arr.size();std::cout << "Original array: ";printArray(arr);// 调用快速排序函数quickSort(arr, 0, n - 1);std::cout << "Sorted array: ";printArray(arr);return 0;
}
下面是非递归的:
void QuickSortNonR(int* a, int left, int right){Stack st;StackInit(&st);StackPush(&st, left);StackPush(&st, right);while (StackEmpty(&st) != 0){right = StackTop(&st);StackPop(&st);left = StackTop(&st);StackPop(&st);if(right - left <= 1)continue;int div = PartSort1(a, left, right);// 以基准值为分割点,形成左右两部分:[left, div) 和 [div+1, right)StackPush(&st, div+1);StackPush(&st, right);StackPush(&st, left);StackPush(&st, div);}StackDestroy(&s);}
2.5 复杂度分析
- 时间复杂度:
- 平均情况:快速排序的平均时间复杂度为 O ( n l o g n ) O(n log n) O(nlogn)。在平均情况下,每次分区操作都能将数组大致分成两个相等的子数组,递归树的深度为 l o g n log n logn,每层需要进行 n n n 次比较,因此总的时间复杂度为 O ( n l o g n ) O(n log n) O(nlogn)。
- 最坏情况:当数组已经有序(升序或降序),并且每次都选择第一个或最后一个元素作为基准元素时,快速排序的性能会退化为 O ( n 2 ) O(n^2) O(n2)。因为每次分区操作只能将数组分成一个长度为 n − 1 n - 1 n−1 的子数组和一个长度为 0 的子数组,递归树的深度为 n n n,每层需要进行 n n n 次比较,所以总的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
- 最好情况:当每次分区操作都能将数组分成两个长度相等的子数组时,快速排序的时间复杂度为 O ( n l o g n ) O(n log n) O(nlogn)。
- 空间复杂度:
- 平均情况:快速排序的平均空间复杂度为 O ( l o g n ) O(log n) O(logn),主要是递归调用栈的空间开销。递归树的深度为 l o g n log n logn,因此空间复杂度为 O ( l o g n ) O(log n) O(logn)。
- 最坏情况:在最坏情况下,递归树的深度为 n n n,因此空间复杂度为 O ( n ) O(n) O(n)。
2.6 优化策略
为了避免最坏情况的发生,可以采用以下优化策略:
- 随机选择基准元素:随机选择基准元素可以减少最坏情况发生的概率,使得快速排序在大多数情况下都能达到平均时间复杂度 O ( n l o g n ) O(n log n) O(nlogn)。
- 三数取中法:选择数组的第一个元素、中间元素和最后一个元素中的中位数作为基准元素,这样可以在一定程度上避免最坏情况的发生。
- 小数组使用插入排序:对于小规模的子数组,插入排序的性能可能比快速排序更好。因此,当子数组的长度小于某个阈值时,可以使用插入排序来排序子数组。
在这里我给出三数取中的优化代码
#include <iostream>
#include <vector>// 交换两个元素的值
void swap(int& a, int& b) {int temp = a;a = b;b = temp;
}// 获取三个数的中位数
int medianOfThree(std::vector<int>& arr, int left, int right) {int mid = left + (right - left) / 2;if ((arr[left] <= arr[mid] && arr[mid] <= arr[right]) || (arr[right] <= arr[mid] && arr[mid] <= arr[left])) {return mid;} else if ((arr[mid] <= arr[left] && arr[left] <= arr[right]) || (arr[right] <= arr[left] && arr[left] <= arr[mid])) {return left;} else {return right;}
}// 分区函数
int partition(std::vector<int>& arr, int left, int right) {// 使用三数取中法选择基准元素int pivotIndex = medianOfThree(arr, left, right);swap(arr[pivotIndex], arr[right]);int pivot = arr[right];int i = left - 1;for (int j = left; j < right; ++j) {if (arr[j] <= pivot) {++i;swap(arr[i], arr[j]);}}swap(arr[i + 1], arr[right]);return i + 1;
}// 快速排序函数
void quickSort(std::vector<int>& arr, int left, int right) {if (left < right) {int pivotIndex = partition(arr, left, right);quickSort(arr, left, pivotIndex - 1);quickSort(arr, pivotIndex + 1, right);}
}// 打印数组
void printArray(const std::vector<int>& arr) {for (int num : arr) {std::cout << num << " ";}std::cout << std::endl;
}int main() {std::vector<int> arr = {10, 7, 8, 9, 1, 5};std::cout << "Original array: ";printArray(arr);quickSort(arr, 0, arr.size() - 1);std::cout << "Sorted array: ";printArray(arr);return 0;
}
partition
函数:借助 medianOfThree
函数选择基准元素,把基准元素放到数组末尾,之后进行常规的分区操作。
2.7 优缺点
- 优点:
- 平均时间复杂度为 O ( n l o g n ) O(n log n) O(nlogn),在大多数情况下性能较好。
- 原地排序,只需要常数级的额外空间。
- 具有良好的缓存局部性,在现代计算机体系结构上表现出色。
- 缺点:
- 最坏情况下时间复杂度为 O ( n 2 ) O(n^2) O(n2),需要采用优化策略来避免。
- 快速排序是一种不稳定的排序算法,即相等元素的相对顺序在排序后可能会发生改变。
很遗憾吧,它也是一种不稳定的排序算法,那么在下一篇也是最后一篇关于排序的总结文章,我们来看看稳定的排序——归并排序
那么咱们下期文章再见😎