排序算法 | 时间复杂度 | 最坏/好时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|---|
冒泡排序 | / | 稳定 | ||
选择排序 | / | 不稳定 | ||
插入排序 | / | 稳定 | ||
快速排序 | / | 不稳定 | ||
归并排序 | / | 或 | 稳定 | |
堆排序 | / | 不稳定 | ||
希尔排序 | 与步长 有关 / | |||
基数排序 |
1. 冒泡
从后往前, 相邻的数据两两比较, 一趟完成后, 第一个元素为最大/小值
时间复杂度:
空间复杂度:
稳定性: 是稳定的, 主要就是遇到相等的元素时不要进行交换操作即可
void bubble_sort(vector &input){
for(int i=0; ii; bubble--){
if(input[bubble-1] < input[bubble] ){ //为了保证排序的稳定性, 这里不要用 <=
int temp = input[bubble];
input[bubble] = input[bubble-1];
input[bubble-1] = temp;
}
}
}
}
2. 选择(交换)排序
时间复杂度:
空间复杂度:
稳定性: 因为选择排序在交换两个元素时, 是不考虑其他元素的相对位置的, 所以, 不管怎么样, 只要发生交换, 就一定会造成不稳定. 但是!!! 如果使用链表或者新开辟一个数组的话, 选择排序也是稳定的, 但实际上这种方法就没有交换的过程, 对于链表来说, 是把节点从链表中拿出来, 组成新的链表, 而不会对原来链表中的元素进行交换. 对于新开辟数组来说, 相当于是用空间换取稳定性, 同样也没有交换过程.
3. 插入排序
将数据分为两个部分, 有序部分和无序部分, 一开始有序部分包含只包含第一个元素, 依次将无序部分的元素插入到有序部分 ( 插入的时间复杂度为 ), 直到所有元素插入完毕.
插入分为数组插入和链表插入(其实堆排序也算是一种插入排序)
下面的时间和空间复杂度均指 数组直接插入, 对于链表, 时间和空间都是
时间复杂度:
空间复杂度:
稳定性: 只要在插入遇到相等元素时, 将新插入的放在最后, 那么就是稳定的.
4. 快排
时间复杂度:
快排的期望时间复杂度为 , 最坏时间复杂度为 , 为了避免出现最坏的情况, 可进行如下改进:
- 哨兵元素的选择: 为了使每次划分的数不至于使两边相差过大, 我们可以选择三者取中法选择哨兵, 一般根据首尾元素和中间元素进行选择.
- 小数据量的改进: 递归的快排大概在n<13的时候比插入要慢, 所以我们在n<13的时候可以采用插入排序
- 相同数字的改进, 在存在大量相同数字的时候, 可以用两个指针保存相同数字的信息, 在划分时不用把它们算进去(这啥意思?//TODO)
- 递归的优化. 快排有两次递归调用, 我们可以用循环代替后面的一次递归.
空间复杂度:
对于就地快排来说, 它本身使用的空间是 的, 但是快排在 递归调用过程中, 就需要消耗一定的空间来保存哨兵及两端元素的下标, 而对于快排的非递归实现中, 需要借助两个栈来模拟系统对于数组low
和high
的存储情况(递归中的哨兵下标实际上会作为下一次递归的low
或者high
), :
- 最优的情况下空间复杂度为: , 每一次都平分数组
- 最差的情况下空间复杂度为: , 每一次两边都极度失衡 (主要与进行迭代的次数有关, 迭代次数多了, 占用的内存就多了)
稳定性:
因为快排在划分两边的元素时, 会直接交换某两个元素的位置, 并且这种交换与其他元素的值没有关系, 因此, 如果刚好有相同元素, 很容易就会破坏稳定性.
注意: 最好写出对输入的low和high进行越界检查的部分!! 这个有时候需要特别跟面试官提一声
递归实现:
要知道Partition内部使用<=的原因所在, 写成<, 会造成死循环(当遇到相等元素时, 会无限交换)
另外, 如果令 P=input[low]
, 那么一定要 high--
在前, 否则会造成元素覆盖
void quickSort(vector& input, int low, int high){
if(input.size()==0 || low<0 || low>=input.size() || high<0 || high>=input.size()){
cout<<"error";
exit(0);
}
int mid = Partition(input, low, high);
if(midlow) quickSort(input, low, mid-1);
}
int Partition(vector& input, int low, int high){
int p = input[low];
while(low=input[low]) low++;
input[high] = input[low];
}
input[low] = p;
return low;
}
这里Partition用的是<=,那么在high位元素和p相等时,并不会执行交换,而是会high--, 如果忘了写等号, 就会陷入死循环。
非递归实现:
int partition(vector &input, int low, int high){
int P = input[low];
while(low= input[low]) low++;
input[high] = input[low];
}
input[low] = P;
return low;
}
void quick_sort(vector &input, int low, int high){
if(input.size()==0 || low<0 || low>=input.size() || high<0 || high>=input.size()){
cout<<"error";
exit(0);
}
stack qs_stack;
qs_stack.push(high); //入栈顺序一定要注意, 要与后面的操作对应好
qs_stack.push(low);
while(!qs_stack.empty()){
low = qs_stack.top(); qs_stack.pop(); // low后入栈, 所以就应该先出栈
high = qs_stack.top(); qs_stack.pop();
if(low >= high) continue;
int mid = partition(input, low, high);
if(low
5. 归并排序
核心思想是将两个有序数列进行合并,使其成为一个新的有序数列(时间复杂度为 ):
- 将序列没相邻的两个数组进行归并(merge)操作, 形成
floor(n/2)
个序列, 排序后每个序列包含两个元素 - 将上述序列在此归并, 形成
floor(n/2)
个序列, 每个序列包含四个元素 - 重复上述归并操作, 直到所有元素归并完毕
时间复杂度: 归并排序即使在最坏情况下, 时间复杂度也是 , 这是它的一大优点.
空间复杂度: 或 常见的归并排序实现算法都是 的空间复杂度, 因为它会额外申请一个与待排序数组相同大小的空间用来进行合并操作. 但是, 合并操作可以被优化成原地合并 (耗时会增加, 但是时间复杂度不变), 此时的空间复杂度就变成了 .
稳定性: 在归并排序过程中, 相同元素有可能出现在同一组内或者不同组内, 如果在同一组内, 则默认就是稳定的, 如果在不同组内, 则根据组的前后位置来判断相同元素的顺序. 因此它是稳定的.
关于归并排序的复杂度:
- 归并排序的时间复杂度不论是最优还是平均还是最差, 都是
在合并时,由两种选择,一种是不使用额外空间的插入合并,这样会增加时间开销。另一种是使用额外空间的合并,这样不增加时间开销,但是需要额外空间。(当然,如果使用的是链表,则没有这种情况,可以既不增加时间,也不增加空间开销)
常规归并排序:
void mergesort(vector& a,int first, int last){
if(first& a, int first1,int last1,int first2,int last2){
vector temp;
int i = first1;
int j = first2;
while(i<=last1 && j<=last2){
if(a.at(i) < a.at(j)){
temp.push_back(a.at(i));
i++;
}
else{
temp.push_back(a.at(j));
j++;
}
}
while(i<=last1){
temp.push_back(a.at(i));
i++;
}
while(j<=last2){
temp.push_back(a.at(j));
j++;
}
for(int i = 0; i
原地归并排序, 空间复杂度:
void swap_memory(vector &data, int start1, int end1, int start2, int end2){
std::reverse(data.begin()+start1, data.begin()+end1);
std::reverse(data.begin()+start2, data.begin()+end2);
std::reverse(data.begin()+start1, data.begin()+end2);
}
void merge_sort(vector &data, int start, int end){
int mid = (start+end)/2;
if(start < end){
merge_sort(data, start, mid);
merge_sort(data, mid+1, end);
merge_inplace(data, start, mid, mid+1, end);
}
}
void merge_inplace(vector &data, int start1, int end1, int start2, int end2){
int i = start1;
int j = start2;
while(i
6. 堆排序
堆简介
堆排序与快速排序,归并排序一样都是时间复杂度为 的几种常见排序方法
堆(二叉堆)可以视为一棵完全的二叉树,完全二叉树的一个“优秀”的性质是,除了最底层之外,每一层都是满的,这使得堆可以利用数组来表示(普通的一般的二叉树通常用链表作为基本容器表示),每一个结点对应数组中的一个元素。
如下图,是一个堆和数组的相互关系:
因此,对于给定的某个节点的下标i, 可以很容易计算出这个节点的父节点, 子节点的下标
- Parent(i) = floor((i-1)/2),i 的父节点下标
- Left(i) = 2i + 1,i 的左子节点下标
- Right(i) = 2(i + 1),i 的右子节点下标
堆一般分为两种,大顶堆和小顶堆, 前者每个节点的值都大于它的子节点,后者反之
大顶堆:
小顶堆:
堆排序原理
堆排序就是把最大堆堆顶的最大数取出,将剩余的堆继续调整为最大堆,再次将堆顶的最大数取出,这个过程持续到剩余数只有一个时结束。在堆中定义以下几种操作:
- 最大堆调整(Max-Heapify):将堆作调整,使得子节点永远小于父节点
- 创建最大堆(Build-Max-Heap):将堆所有数据重新排序,使其成为最大堆
- 堆排序(Heap-Sort):移除位在第一个数据的根节点,并做最大堆调整的递归运算
基于上面的三种操作,可以进行堆的插入和删除:
- 插入: 将新元素放置在数组末尾,然后进行堆调整
- 删除: 移除堆顶,然后将数组末尾元素置于堆顶,然后进行堆调整(删除主要用于排序)
最大堆调整(MAX‐HEAPIFY)的作用是保持最大堆的性质,是创建最大堆的核心子程序,作用过程如图所示:
代码实现:
// 递归实现
void max_heapify(vector &vec, int index, int heap_size){
int imax = index //mark max index
int ileft = index*2+1; // left child
int iright = index*2+2; // right child
if (ileft < heap_size && vec[imax] < vec[ileft]){
imax = ileft;
}
if(iright < heap_size && vec[imax] < vec[iright]){
imax = iright;
}
if( imax != index){
std::swap(vec[imax], vec[index]);
max_heapify(vec, imax, heap_size); //由于变换了当前节点,因此子树的堆结构可能被破坏,
//递归调整, 这里imax的坐标是左右子节点的下标之一(因为进行了交换)
}
// 堆是自下而上进行调整的,所以在调整当前节点符合堆要求之前,子树已经符合堆要求,
//除非进行了节点交换,否则子树的堆结构不会被破坏, 无需进行额外处理
}
//非递归实现
void max_heapify(vector &vec, int index, int heap_size){
while(true){
int imax = index;
int ileft = 2*index+1;
int iright = 2*index+2;
if(ileft < heap_size && vec[imax] < vec[ileft]){
imax = ileft;
}
if(iright < heap_size && vec[imax] < vec[iright]){
imax = iright;
}
if( imax != index ){
std::(vec[imax], vec[index]);
index = imax; //产生了交换, 可能破坏了左右子树的堆结构, 令index为左右子树之一的下标, 继续调整
}else{
break; //如果没有交换,说明当前结构的堆结构已经完成,直接跳出
}
}
}
创建最大堆(Build-Max-Heap)的作用是将一个数组改造成一个最大堆,接受数组和堆大小两个参数,Build-Max-Heap 将自下而上的调用 Max-Heapify 来改造数组,建立最大堆。因为 Max-Heapify 能够保证下标 i 的结点之后结点都满足最大堆的性质,所以自下而上的调用 Max-Heapify 能够在改造过程中保持这一性质。如果最大堆的数量元素是 n,那么 Build-Max-Heap 从 Parent(n) 开始,往上依次调用 Max-Heapify。
代码实现:
//创建堆
void build_maxheap(vector &vec){
int lasti_parent = std::floor((vec.size()-1)/2);
for( int i = lasti_parent ; i>=0 ; i--){
max_heapify(vec, i , vec.size()) //从下到上对每个节点进行堆调整,无需从叶子节点开始
//堆的size需要传整个size过去,因为下标从针对整个堆而言的
}
}
创建好堆以后,就可以通过移除堆顶来进行排序,每次将堆顶元素和数组末尾元素进行交换(这样可以不借助额外空间完成排序),然后对数组的前n-1个元素重新进行堆调整构成新的大顶堆, 重复此过程知道堆中只剩下一个元素, 如下图所示:
//代码实现
void heap_sort(vector &vec){
build_maxheap(vec);
for(int i = vec.size()-1 ; i > 0; i--){ //重复n-1次
std::swap(vec[0] , vec[i]) //
Heapify(vec, 0, i); //堆的大小变为i, 所以必须要设置一个变量来标识堆的size,而不是用vec.size()
}
}