目录
一、排序的介绍
1.排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次 序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排 序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
2.排序的分类
二、冒泡排序
1.算法思想
- 比较相邻的两个数,大(小)的数往后换;
- 从第一对到最后一对循环比较后,最大(小)的数就在最后一个位置;
- 重复进行上述循环,依次选出第二大(小),第三大(小)的数放在后面。
2.图片演示
3.代码演示
void BubbleSort(int a[], int n)
{
for(int i =0 ; i< n-1; ++i)
{
for(int j = 0; j < n-i-1; ++j)
{
if(a[j] > a[j+1])
{
int tmp = a[j] ; //交换
a[j] = a[j+1] ;
a[j+1] = tmp;
}
}
}
}
三、选择排序
1.算法思想
- 在未排序序列中找到最大(小)元素,存放到排序序列的起始位置;
- 从剩余未排序元素中继续寻找最大(小)元素,然后放到已排序序列的末;
- 以此类推,直到所有元素均排序完毕。
2.图片演示
3.代码演示
void SelectionSort(int arr[], int n)
{
int MaxIndex, temp;
for (int i = 0; i < n - 1; i++)
{
MaxIndex = i;
for (var j = i + 1; j < n; j++)
{
if (arr[j] > arr[MaxIndex]) // 寻找最大的数
{
MaxIndex = j; // 将最大数的下标保存
}
}
temp = arr[i];
arr[i] = arr[MaxIndex];
arr[MaxIndex] = temp;
}
}
四、直接插入排序
1.算法思想
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤2~5。
2.图片演示
3.代码演示
void InsertSort(int a[], int n)
{
for (int i = 0; i < n-1; ++i)
{
//end表示已经排好序的尾标
int end = i;
//首先保存要排序的数,一会就会被覆盖了
int tmp = a[end + 1];
//只要前面的数大于end + 1,则前面的这些数都向后挪动一个位置
while (end >= 0 && a[end] > tmp)
{
a[end + 1] = a[end];
--end;
}
a[end + 1] = tmp;
}
}
五、希尔排序
1.算法思想
- 希尔排序是直接插入排序的升级版本;
- 希尔排序会优先选定一个间距值(gap),通过gap值将数组分为若干个子数组,对这几个子数组进行直接插入排序,每次完成后gap值减小(gap值的减少可以有不同的方式,如图片下是依次除2,但是代码里的gap值是除3加1);
- 当gap==1时,就跟直接插入排序是一样的。当gap>1时,先进行预排序,尽可能的让数组变为有序,最后gap==1时再进行直接插入排序的话就会更快。
2.图片演示
(图片来源:五分钟学算法)
3.代码演示
void ShellSort(int* a, int n)
{
assert(a);
int gap = n;
//不能写成大于0,因为gap的值始终>=1
while (gap > 1)
{
//只有gap最后为1,才能保证最后有序
//所以这里要加1
gap = gap / 3 + 1;
//这里只是把插入排序的1换成gap即可
//但是这里不是排序完一个分组,再去
//排序另一个分组,而是整体只过一遍
//这样每次对于每组数据只排一部分
//整个循环结束之后,所有组的数据排序完成
for (int i = 0; i < n - gap; ++i)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0 && a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
a[end + gap] = tmp;
}
}
}
六、堆排序
1.算法思想
- 先建堆,升序排序建大堆,降序排序建小堆,并且建堆的方式为向下建堆;
- 建完堆之后把最后一个节点和头节点进行交换,再进行向下调整建堆;
- 调整完之后把倒数第二个节点和节结点进行交换,再进行向下调整建堆,依次循环直到条件结束。(设有n个数组,则这里的循环条件是孩子的下标要小于数组数组最后的下标n-1。根节点第一大(小)的值和最后一位交换后放在数组的最后一位,n每次循环后要-1,把第二大(小)的值放在数组倒数第二位........)
1.1向上建堆和向下建堆
- 向上调整建堆(以建小堆为例)
- 向下调整建堆(以小堆为例)
- 向上调整建堆和向下调整建堆的区别:
- 向上调整建堆的时间复杂度:n*logn
- 向下调整建堆的时间复杂度:n
- 所以得出结论向下调整是优于向上调整的。
1.2大堆和小堆
- 大堆:每个节点的值都大于或等于他的左右孩子结点的值
- 小堆:每个节点的值都小于或等于他的左右孩子结点的值
- 为什么升序要建大堆? 堆的本质是数组,大堆的根节点是最大值(也就是i[0]),把根节点和最后的结点进行互换后,最大值就在数组的最后,根据大堆的规则,第二大的数字又会来到根节点。再结合算法思想的解释,就能保证最大值的位置依次在数组的最后位置并且不会随着堆的调整而改变。
2.图片演示
2.1向下建堆
以数组{4,6,3,7,2,1}为例,降序排序用小堆。
初步小堆建立完毕,我们发现此时仍然不是降序排序,所以还是要调整。
2.2向下调整排序
3.代码演示
void AdjustDown(int* a, int n, int root)
{
int parent = root;
int child = parent * 2 + 1;
while (child < n) {
if (child+1 < n
&& a[child+1] > a[child]) {
++child;
}
if (a[child] > a[parent]) {
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else{
break;
}
}
}
void HeapSort(int* a, int n)
{
assert(a);
// 建堆,先从最后两个叶子上的根(索引为(n - 2) / 2开始建堆
// 先建最小的堆,直到a[0](最大的堆)
// 这就相当于在已经建好的堆上面,新加入一个
// 根元素,然后向下调整,让整个完全二叉树
// 重新满足堆的性质
for (int i = (n - 2) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
//end:表示最后一个位置
int end = n - 1;
//只剩一个数时,就不需要调整了
while (end > 0)
{
//0位置和最后一个位置交换
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
七、快速排序
1.算法思想
- 任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
2.图片演示
1.1霍尔版
(此图只演示了第一次循环,当第一次循环结束后,key在数组的中间,在把key的左右两边依次递归,类似二叉树)
1.2前后指针版
(此图只演示了第一次循环,当第一次循环结束后,key在数组的中间,在把key的左右两边依次递归,类似二叉树)
3.代码演示
3.1霍尔版
void QuickSort1(int* a, int left, int right)
{
// 区间只有一个值或者不存在就是最小子问题
if (left >= right)
return;
int begin = left, end = right;
int keyi = left;
while (left < right)
{
// right先走,找小
while (left < right && a[right] >= a[keyi])
{
--right;
}
// left再走,找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
// [begin, keyi-1]keyi[keyi+1, end]
QuickSort1(a, begin, keyi - 1);
QuickSort1(a, keyi + 1, end);
}
3.2前后指针版
void QuickSort2(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = left;
int prev = left;
int cur = left+1;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
++cur;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
// [left, keyi-1]keyi[keyi+1, right]
QuickSort2(a, left, keyi - 1);
QuickSort2(a, keyi + 1, right);
}
4.快速排序的优化
4.1三数取中法
- 根据对快速排序的理解,我们发现有一种情况,当数组本身就接近有序的话(无论是逆序还是顺序),那么这个快速排序就类似于冒泡排序了,需要从左到右一个一个的排序,那么就大大的提高了排序的时间,从原先的n*logn变为了n^2;
- 既然有序的数组会让排序速度变慢,那么我们就可以把key值从3个数中选一个中间大小的值,虽然不能完全避免快速排序变为冒泡排序的可能,但也可以有效的提升运算效率;
- 每次都取数组最左边的和最右边的及位置中间的数,找出这三个数大小在中间的数作为数组最左边的基准值。
int GetMidi(int* a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return left;
}
else
{
return right;
}
}
else // a[left] > a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
{
return right;
}
}
}
// 时间复杂度:O(N*logN)
// 什么情况快排最坏:有序/接近有序 ->O(N^2)
void QuickSort1(int* a, int left, int right)
{
// 区间只有一个值或者不存在就是最小子问题
if (left >= right)
return;
int begin = left, end = right;
// 三数取中
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;
while (left < right)
{
// right先走,找小
while (left < right && a[right] >= a[keyi])
{
--right;
}
// left再走,找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
// [begin, keyi-1]keyi[keyi+1, end]
QuickSort1(a, begin, keyi - 1);
QuickSort1(a, keyi + 1, end);
}
4.2小区间优化法
- 根据对快速排序的理解,我们可以发现快速排序就类似于二叉树,把key值放到数组中间,再把数组的左右两边依次递归(就像二叉树,key值是根节点),但是当一个数组里的元素很多的话,也就意味着要递归很多次,每次递归都会产生栈帧,产生栈帧实际上也会造成空间的损耗。
- 为了减小损耗,有人发现可以把递归的最后几层不用递归排序,而是用其他的方法进行排序(因为最后几层的递归是消耗最多的)。
- 在其他的排序方法里优先使用插入法,希尔排序用在数组元素多的地方,而最后几次递归排序没有那么多的数组元素,所以没必要;堆排序需要建堆也没必要,冒泡和选择明显没有插入更快,所以选择插入。
void InsertSort(int a[], int n)
{
for (int i = 0; i < n-1; ++i)
{
//end表示已经排好序的尾标
int end = i;
//首先保存要排序的数,一会就会被覆盖了
int tmp = a[end + 1];
//只要前面的数大于end + 1,则前面的这些数都向后挪动一个位置
while (end >= 0 && a[end] > tmp)
{
a[end + 1] = a[end];
--end;
}
a[end + 1] = tmp;
}
}
void QuickSort1(int* a, int left, int right)
{
// 区间只有一个值或者不存在就是最小子问题
if (left >= right)
return;
// 小区间选择走插入,可以减少90%左右的递归
if (right - left + 1 < 10)//数组元素小于10个数
{
InsertSort(a + left, right - left + 1);
}
else
{
int begin = left, end = right;
int keyi = left;
while (left < right)
{
// right先走,找小
while (left < right && a[right] >= a[keyi])
{
--right;
}
// left再走,找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
// [begin, keyi-1]keyi[keyi+1, end]
QuickSort1(a, begin, keyi - 1);
QuickSort1(a, keyi + 1, end);
}
}
4.3三路划分法
- 有一种特殊情况,当一个数组里面有许多重复的值的时候。根据之前的算法,我们把比key值小的值放左边,key值在中间,把比key值大的数放在右边,但是对与key值相同的值却没有具体的划分;
- 因此衍生出一种新的优化算法,把与key值一样大的值放在中间。
- 具体方法:
- key默认取left的值
- left指向区间最左边,right指向区间最右边,cur指向left+1的位置
- cur遇到比key小的值后跟left交换,left++,cur++
- cur遇到比key大的值后跟right交换,right--
- cur遇到与key相等的值,cur++
- 直到cur>right,结束
void QuickSort2(int* a, int left, int right)
{
if (left >= right)
return;
int begin=left;
int end=right;
//随机数取key
int randi=left+(rand()%(right-left+1));
Swap(&a[left],&a[randi];
int key = left;
int cur = left+1;
while(cur<=right)
{
if(a[cur]<key)
{
Swap(&a[cur],&a[left]);
++left;
++cur;
}
else if(a[cur]>key)
{
Swap(&a[cur],&a[right]);
--right;
}
else
{
++cur;
}
}
// [begin, left-1]{left,right}[right+1, end]
QuickSort2(a, begin, left - 1);
QuickSort2(a, right + 1, end);
}
八、归并排序
1.算法思想
- 归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2路归并。
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
2.图片演示
(图片来源:五分钟学算法)
3.代码演示
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin == end)
return;
int mid = (begin + end) / 2;
// [begin, mid] [mid+1, end]
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid+1, end, tmp);
// 归并
int begin1 = begin,end1 = mid;
int begin2 = mid + 1,end2 = end;
int i = begin;
// 依次比较,取小的尾插tmp数组
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
memcpy(a+begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);//先开辟空间
if (tmp == NULL)
{
perror("malloc fail");
return;
}
_MergeSort(a, 0, n-1, tmp);
free(tmp);
tmp = NULL;
}
九、各个排序的时间复杂度,空间复杂度以及稳定性
排序算法 | 平均时间复杂度 | 最差时间复杂度 | 辅助空间 | 稳定性 |
冒泡排序 | O(n^2) | O(n^2) | O(1) | 稳定 |
选择排序 | O(n^2) | O(n^2) | O(1) | 不稳定 |
插入排序 | O(n^2) | O(n^2) | O(1) | 稳定 |
希尔排序 | O(n*logn)-O(n^2) | O(n^2) | O(1) | 不稳定 |
堆排序 | O(n*logn) | O(n*logn) | O(1) | 不稳定 |
快速排序 | O(n*logn) | O(n^2) | O(logn)-O(n) | 不稳定 |
归并排序 | O(n*logn) | O(n*logn) | O(n) | 稳定 |
- 希尔排序的最好时间复杂度为n^1,3;
- 快速排序的最坏情况类似于冒泡排序,为O(n^2),最好情况类似于二叉树,每次key值都是中间。