Leetcode记录:排序算法
选择排序
选择排序是一种简单的排序算法,其基本思想是在待排序的序列中依次选出最小(或最大)的元素,存放到序列的起始位置,然后再从剩余未排序元素中继续寻找最小(或最大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
过程简单描述: 首先,找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置(如果第一个元素就是最小元素那么它就和自己交换)。其次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此往复,直到将整个数组排序。这种方法我们称之为选择排序。

性质:
- 时间复杂度:$O(n^2)$
- 空间复杂度:$O(1) $
- 非稳定排序:在交换元素的时候可能会把前面的交换到后面。
- 原地排序
算法实现:
void selectionSort(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) {
swap(arr[i], arr[minIndex]);
}
}
}
冒泡排序
冒泡排序是一种简单的排序算法,它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小(或越大)的元素会经由交换慢慢“浮”到数列的顶端,就像水中的气泡一样上升到水面。
冒泡排序流程:
- 首先比较数组中的相邻两个元素。如果第一个比第二个大,则交换这两个元素的位置。这样,较大的数就会逐渐“浮”到数组的末尾。
- 接下来,对数组进行下一轮比较,从开始到结尾,但排除已经排序好的最大数。这一轮中,较大的数会被继续交换到数组的末尾。
- 持续进行上述步骤,直到整个数组有序排列。在每一轮中,都会有一个元素被“冒泡”到正确的位置。

性质:
- 时间复杂度:$O(n^2)$
- 空间复杂度:$O(1)$
- 稳定排序
- 原地排序
算法实现:
void bubbleSort(vector<int>& arr) {
int n = arr.size();
for (int i = 0; i < n - 1; i++) {
// flag用于标记这次循环是否发生了交换
bool flag = false;
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 如果当前元素比后一个元素大,则交换它们
swap(arr[j], arr[j + 1]);
flag = true; // 发生了交换,将flag设置为true
}
}
// 如果这次遍历没有发生交换,说明数组已经有序,直接跳出循环
if (!flag) {
break;
}
}
}
插入排序
插入排序是一种简单直观的比较排序算法,它的工作原理是构建有序序列。它通过将元素插入到已经排序好的序列中来进行排序。这个算法在实践中对于小数组或部分有序的数组往往表现得非常好。
过程简单描述:
- 从数组第2个元素开始抽取元素。
- 把它与左边第一个元素比较,如果左边第一个元素比它大,则继续与左边第二个元素比较下去,直到遇到不比它大的元素,然后插到这个元素的右边。
- 继续选取第3,4,…,n个元素,重复步骤2,选择适当的位置插入。

性质:
- 时间复杂度:$O(n^2)$
- 空间复杂度:$O(1)$
- 稳定排序
- 原地排序
算法实现:
void insertionSort(vector<int>& arr) {
int n = arr.size();
for (int i = 1; i < n; i++) {
int key = arr[i]; // 当前要插入的元素
int j = i - 1; // 已经排序好的序列的最后一个元素的索引
// 将比key大的元素往后移动
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j]; // 移动元素
j--; // 向前移动索引
}
// 将key插入到正确的位置
arr[j + 1] = key;
}
}
希尔排序
希尔排序是插入排序的变种,它的基本思想是将待排序的记录序列分割成若干个子序列,每个子序列都是由相隔某个“增量”的记录组成的。然后对这些子序列分别进行直接插入排序,接着逐步缩小增量,直到整个序列变得“基本有序”,最后对全体记录进行一次直接插入排序以完成排序。
算法实现:
void shellSort(vector<int>& arr) {
// 初始化增量为数组长度的一半
for (int gap = arr.size() / 2; gap > 0; gap /= 2) {
// 对每个子数组进行直接插入排序
for (int i = gap; i < arr.size(); i++) {
int temp = arr[i];
int j;
// 比较相距gap的元素,并交换位置
for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
arr[j] = arr[j - gap];
}
arr[j] = temp;
}
}
}
性质:
- 时间复杂度:$O(n\log n)$
- 空间复杂度:$O(1) $
- 非稳定排序: 虽然插入排序是稳定的,但是希尔排序在插入的时候是跳跃性插入的,有可能破坏稳定性。例:7558->5578.
- 原地排序
归并排序
归并排序是一种分而治之的算法,它将数组分割成更小的数组,然后将它们排序,最后将排序后的数组合并。归并排序的一个优点是它是稳定的,并且有很好的性能。
归并排序流程:
- 分解:将数组分为两部分,如果数组长度为n,则分为n/2和n/2的两部分
- 归并:对每一部分进行归并排序,即从小到大排序。
- 合并:将排序好的两部分合并为一个有序的整体。

算法实现:
void merge(vector<int>& arr, int left, int mid, int right) {
int n1 = mid - left + 1; // 左侧子数组的大小
int n2 = right - mid; // 右侧子数组的大小
// 创建临时数组
vector<int> L(n1), R(n2);
// 复制数据到临时数组中
for (int i = 0; i < n1; i++)
L[i] = arr[left + i];
for (int j = 0; j < n2; j++)
R[j] = arr[mid + 1 + j];
// 合并临时数组
// 初始化索引
int i = 0;
int j = 0;
int k = left;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
// 稳定排序
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}
// 复制剩下的元素
while (i < n1) {
arr[k] = L[i];
i++;
k++;
}
// 复制剩下的元素
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
}
// 归并排序的主函数
void mergeSort(vector<int>& arr, int left, int right) {
// 如果 left == right,表示数组只有一个元素,则不用递归排序
if (left < right) {
int mid = left + (right - left) / 2;
// 对左侧子数组进行归并排序
mergeSort(arr, left, mid);
// 对右侧子数组进行归并排序
mergeSort(arr, mid + 1, right);
// 合并两个已排序的子数组
merge(arr, left, mid, right);
}
}
性质:
- 时间复杂度:$O(n\log n)$
- 空间复杂度:$O(n) $
- 稳定排序
- 非原地排序
非递归代码版本:
void mergeSort(vector<int>& arr) {
int n = arr.length;
// 子数组的大小分别为1,2,4,8...
// 刚开始合并的数组大小是1,接着是2,接着4....
for (int i = 1; i < n; i += i) {
//进行数组进行划分
int left = 0;
int mid = left + i - 1;
int right = mid + i;
//进行合并,对数组大小为 i 的数组进行两两合并
while (right < n) {
// 合并函数和递归式的合并函数一样
merge(arr, left, mid, right);
left = right + 1;
mid = left + i - 1;
right = mid + i;
}
// 还有一些被遗漏的数组没合并,千万别忘了
// 因为不可能每个字数组的大小都刚好为 i
if (left < n && mid < n) {
merge(arr, left, mid, n - 1);
}
}
}
快速排序
快速排序是一种分而治之的算法,它选择一个基准值(pivot)并围绕它对数组进行分区,将小于基准值的元素移到其左侧,将大于基准值的元素移到其右侧。然后递归地对基准值左右两边的子数组进行相同的操作,直到整个数组排序完成。快速排序的平均时间复杂度为$O(nlogn)$,但在最坏情况下,时间复杂度可以退化到$O(n^2)$。
快速排序流程:
- 选择基准值:从数组中选择一个元素作为基准(pivot),通常选择数组的第一个元素或者最后一个元素。
- 分区操作:通过基准值,将数组分成两个部分,一部分包含所有小于基准值的元素,另一部分包含所有大于或等于基准值的元素。这一步确保了数组的一部分是有序的。
- 递归排序:对基准值左右两边的子数组分别进行快速排序,这一过程通过递归实现。
- 重复过程:重复步骤1到3,直到整个数组有序。

算法实现:
int partition(vector<int>& arr, int low, int high) {
int pivot = arr[high]; // 选择最右侧的元素作为基准
int i = (low - 1); // 小于基准的元素的索引
for (int j = low; j <= high - 1; j++) {
// 如果当前元素小于或等于基准
if (arr[j] <= pivot) {
i++; // 增加小于基准元素的索引
swap(arr[i], arr[j]); // 交换元素
}
}
swap(arr[i + 1], arr[high]); // 交换基准元素到正确的位置
return (i + 1);
}
// 快速排序的主函数
void quickSort(vector<int>& arr, int low, int high) {
if (low < high) {
// pi是分区索引,arr[pi]现在位于正确的位置
int pi = partition(arr, low, high);
// 独立地对基准左侧和右侧的元素进行快速排序
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
}
性质:
- 时间复杂度:平均为$O(n\log n)$,最坏为$O(n^2)$。
- 空间复杂度:$O(\log n)$,这是因为递归调用栈的深度为$O(\log n)$,每一层递归调用都需要一定的空间来存储局部变量和函数调用信息。
- 非稳定排序
- 原地排序
堆排序
堆排序是一种基于比较的排序算法,它利用了二叉堆(一种特殊的完全二叉树)的性质来进行排序。在最大堆中,每个节点的值都不小于其子节点的值;在最小堆中,每个节点的值都不大于其子节点的值。
堆排序流程:
- 建堆:从最后一个非叶子节点(通常是最后一个元素的父节点)开始,自底向上、自右向左进行下沉调整,确保每个节点都满足堆的性质。最终整个序列成为一个大顶堆(或小顶堆)。
- 堆排序:将堆顶元素(最大或最小元素)与堆尾元素交换。堆长度减一,表示移除了已排序的最大(或最小)元素。然后重新对剩余元素进行下沉调整,恢复堆性质。重复上述步骤,直至堆长度为1,整个序列有序。

void heapify(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) {
swap(arr[i], arr[largest]); // 交换根节点和最大值节点
// 递归地对受影响的子树进行堆化
heapify(arr, n, largest);
}
}
// 堆排序的主函数
void heapSort(vector<int>& arr) {
int n = arr.size();
// 构建堆(重新排列数组)
// n/2 --- n-1 都是叶子节点
for (int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
// 逐个提取元素
for (int i = n - 1; i > 0; i--) {
// 将当前根节点移到末尾
swap(arr[0], arr[i]);
// 调用heapify在减少的堆上
heapify(arr, i, 0);
}
}
最大堆排序是从小到大,最小堆是从大到小。 对应C++数据结构是priority_queue, 默认的是std::less比较,堆顶是最大的元素。
性质:
- 时间复杂度:最坏为$O(n\log n)$
- 空间复杂度:$O(1)$
- 非稳定排序
- 原地排序
计数排序
计数排序是一种线性时间排序算法,特别适合于处理具有一定范围的整数数组。它的工作原理是计算每个元素在数组中出现的次数,然后根据这些计数来确定元素在排序数组中的位置。计数排序不是比较排序,因此其时间复杂度为O(n)。
计数排序流程:
- 找出待排序的数组中最大和最小的元素。 2。 统计数组中每个值为 i 的元素出现的次数,存入数组 C 的第 i 项。
- 对所有的计数累加(从 C 中的第一个元素开始,每一项和前一项相加)。
- 反向填充目标数组:将每个元素 i 放在新数组的第 C(i) 项,每放一个元素就将 C(i) 减去1。

算法实现:
void countingSort(vector<int>& arr) {
// 如果数组为空,则直接返回
if (arr.empty())
return;
// 找到数组中的最大值以确定计数数组的大小
int max_val = *std::max_element(arr.begin(), arr.end());
// 初始化计数数组,并将所有元素设置为0
vector<int> count(max_val + 1, 0);
// 遍历数组,计算每个元素的出现次数
for (int value : arr) {
count[value]++;
}
// 修改计数数组,使其每个元素都包含小于或等于其索引值的元素数量
for (size_t i = 1; i < count.size(); i++) {
count[i] += count[i - 1];
}
// 创建输出数组
vector<int> output(arr.size());
// 构建输出数组
for (int i = arr.size() - 1; i >= 0; i--) {
output[--count[arr[i]]] = arr[i];
}
// 将输出数组复制回原数组
arr = output;
}
性质:
- 时间复杂度:$O(n+k)$, $k$ = count.size().
- 空间复杂度:$O(k)$
- 稳定排序
- 非原地排序
桶排序
桶排序是一种分布式排序算法,它将元素分布到多个“桶”中,每个桶内部再分别进行排序(可以使用其他排序算法或递归地使用桶排序)。桶排序特别适合用于数据分布较均匀且可以均匀划分到各个桶中的情况。其平均时间复杂度为$O(n + k)$,其中 $n$ 是元素数量,$k$ 是桶的数量。
算法实现:
void bucketSort(vector<int>& arr) {
// 如果数组为空,则直接返回
if (arr.empty())
return;
// 找到数组中的最大值和最小值
int max_val = *max_element(arr.begin(), arr.end());
int min_val = *min_element(arr.begin(), arr.end());
// 计算桶的数量
int bucket_num = (max_val - min_val) / arr.size() + 1;
// 创建桶
vector<std::vector<int>> buckets(bucket_num);
// 将元素分布到各个桶中
for (int value : arr) {
int bucket_index = (value - min_val) / arr.size();
buckets[bucket_index].push_back(value);
}
// 对每个桶进行排序,并将结果收集到原数组中
int index = 0;
for (auto& bucket : buckets) {
sort(bucket.begin(), bucket.end()); // 可以使用其他排序算法或递归调用桶排序
for (int value : bucket) {
arr[index++] = value;
}
}
}
性质:
- 时间复杂度:$O(n+k)$, $k$ = count.size().
- 空间复杂度:$O(k)$
- 稳定排序
- 非原地排序
基数排序
基数排序是一种非比较型整数排序算法,它按照数字的每一位(或字符)来进行排序。基数排序的基本思想是将所有元素按照某个位上的数字进行排序,接着按照更高位进行排序,依此类推,直到最低位。 它智能处理全为正数的情况,对于正负数混合的数组,需要先分为两组再进行基数排序。
基数排序流程:
- 确定最大位数:首先找出待排序数组中的最大数的位数,以确定需要按多少位进行排序。
- 按位分配:从最低位开始,根据该位的值将数组中的元素分配到不同的“桶”中。
- 收集元素:将各个桶中的元素按照顺序合并回原数组,此时数组已经按照最低有效位进行了排序。
- 重复排序过程:对次低有效位、第三位有效位…直到最高有效位重复进行排序和合并的过程。
- 获得最终结果:当最高有效位排序完成后,数组中的所有元素已经按照从最低位到最高位的顺序排好序。

int getMaxDigit(const std::vector<int>& arr) {
int max_val = *std::max_element(arr.begin(), arr.end());
int digit = 0;
while (max_val > 0) {
max_val /= 10;
digit++;
}
return digit;
}
// 基数排序函数
void radixSort(std::vector<int>& arr) {
int max_digit = getMaxDigit(arr);
int mod = 10;
int dev = 1;
vector<vector<int>> counter(10); // 因为我们有十个数字(0-9)
for (int i = 0; i < max_digit; i++, dev *= 10, mod *= 10) {
// 清空计数器
for (auto& bucket : counter) {
bucket.clear();
}
// 根据当前位数将元素分配到计数器中
for (int value : arr) {
int bucket_index = (value % mod) / dev;
counter[bucket_index].push_back(value);
}
// 将计数器中的元素收集回原数组
int index = 0;
for (auto& bucket : counter) {
for (int value : bucket) {
arr[index++] = value;
}
}
}
}
std::sort
使用的是Introsort(内省排序)算法,这是一种混合排序算法。它结合了快速排序、堆排序和插入排序的优点,能够在大多数情况下提供高效的排序性能,并且可以避免快速排序最坏情况下的性能问题。
Introsort 的工作原理
Introsort 会根据递归深度和数组大小选择不同的排序方法:
-
快速排序:在排序开始时,
std::sort
使用快速排序,因其平均时间复杂度为 ( O(n \log n) ) 且性能通常很好。 -
堆排序:当递归深度超过一定阈值时,快速排序可能会退化为最坏情况下的 ( O(n^2) ) 复杂度。为了防止这种情况,
std::sort
会切换到堆排序,堆排序的时间复杂度是 ( O(n \log n) )。 -
插入排序:当数组分区的大小变得很小(通常是16个元素或更少)时,
std::sort
会使用插入排序。插入排序在小规模数组上性能很好,因为其额外的比较和移动操作开销较低。
复杂度
- 平均时间复杂度:( O(n \log n) )
- 最坏时间复杂度:( O(n \log n) )(通过切换到堆排序来避免 ( O(n^2) ) 的情况)
为什么选择 Introsort?
Introsort 结合了三种排序算法的优点,避免了单一排序算法在某些情况下的低效表现。快速排序适用于大部分情况,堆排序保证了最坏情况下的性能,插入排序则适合小规模数组。
堆排序和deque结合求滑动窗口中位数
为了实现实时插入 (time, value)
对并计算10分钟内价格的中位数,可以使用以下方法:
- 使用一个滑动窗口数据结构来保存10分钟内的价格。
- 使用双端队列来存储滑动窗口内的元素,这样我们可以轻松地在时间过期时移除旧数据。
- 使用两个堆(最大堆和最小堆)来保持滑动窗口的中位数,这样插入和删除操作的效率较高。
代码实现
#include <queue>
#include <deque>
#include <vector>
#include <utility>
#include <iostream>
class StockPriceMedian {
private:
std::deque<std::pair<int, int>> window; // 滑动窗口存储(time, value)
std::priority_queue<int> maxHeap; // 最大堆,存储左半部分
std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap; // 最小堆,存储右半部分
// 平衡两个堆,保证最大堆的大小始终 >= 最小堆的大小
void balanceHeaps() {
if (maxHeap.size() > minHeap.size() + 1) {
minHeap.push(maxHeap.top());
maxHeap.pop();
} else if (minHeap.size() > maxHeap.size()) {
maxHeap.push(minHeap.top());
minHeap.pop();
}
}
// 插入新值到两个堆中
void insertPrice(int price) {
if (maxHeap.empty() || price <= maxHeap.top()) {
maxHeap.push(price);
} else {
minHeap.push(price);
}
balanceHeaps();
}
// 从堆中删除指定值
void removePrice(int price) {
// 标记为懒删除,等到堆顶出现过期元素时移除
if (!maxHeap.empty() && price <= maxHeap.top()) {
// 将最大堆中的元素移除
maxHeap.pop();
} else {
// 将最小堆中的元素移除
minHeap.pop();
}
balanceHeaps();
}
public:
double insert(int time, int price) {
// 插入新数据
window.push_back({time, price});
insertPrice(price);
// 移除超过10分钟的数据
while (!window.empty() && window.front().first <= time - 600) {
removePrice(window.front().second);
window.pop_front();
}
// 计算中位数
if (maxHeap.size() == minHeap.size()) {
return (maxHeap.top() + minHeap.top()) / 2.0;
} else {
return maxHeap.top();
}
}
};
代码解析
window
:一个双端队列,用于存储滑动窗口中的(time, price)
对,方便在时间超出10分钟时移除旧数据。- 最大堆和最小堆:用于维护滑动窗口的中位数,最大堆保存左半部分的元素,最小堆保存右半部分的元素。
balanceHeaps()
:保持两个堆的平衡,使得最大堆的大小始终大于或等于最小堆。insertPrice
和removePrice
:分别用于向堆中插入和移除价格。insert
方法:在插入新的(time, price)
对后,自动维护10分钟内价格的中位数,返回当前窗口的中位数。
时间复杂度
- 插入和删除的复杂度:由于堆的插入和删除的复杂度为 ( O(\log n) )。
- 返回中位数的复杂度为 ( O(1) ),因为堆顶元素可直接获取。