目录
插入排序
希尔排序(缩小增量排序 )
选择排序
堆排序
冒泡排序
快速排序
快排的递归实现
1. hoare版本
2. 挖坑法
3. 前后指针版本
快排的非递归实现
归并排序
递归实现归并排序
非递归实现归并排序
计数排序
1.排序的概念及其运用
排序的概念 排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次 序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
直接插入排序是一种简单的插入排序法,其基本思想是:
将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
动图演示:
当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与 array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。
代码:
void InsertSort(int* a,int size)
{
for (int i = 0; i < size - 1; i++)//end的边界条件为size-2,end+1=size-1
{
int end = i;//记录有序序列的最后一个元素的下标
int x= a[end + 1];//待插入的元素
while (end >= 0)
{
if (a[end] > x)
{
a[end + 1] = a[end];//如果插入的数比end小,把end下标的值往后挪
--end; //end--后将end+1为下标的地方空出来,继续循环,直到找到合适的位置插入
}
else
{
break;
}
}
//插入的值比end下标的值大,或者end=-1(end走到了数组的最前面),把要插入的值放到end的后面
a[end + 1] = x;
}
}
直接插入排序的特性总结:
1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1),它是一种稳定的排序算法
4. 稳定性:稳定
希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。
基本思想:
先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后重复上述分组和排序的工 作。当到达=1时,所有记录在统一组内排好序。
动图演示:
数字颜色相同的为一组
前两趟就是希尔排序的预排序,最后一趟就是希尔排序的直接插入排序。
gap越大,预排越快,预排后越不接近于有序;
gap越小,预排越慢,预排后越接近于有序。
代码:
void ShellSort(int* a, int n)
{
//gap>1预排序 gap=1 直接插入排序
int gap = n;
while (gap > 1)
{
//gap = gap / 2;
gap=gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int x = a[end + gap];
while (end >= 0)
{
if (a[end] > x)
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = x;
}
}
}
希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
3. 希尔排序的时间复杂度不好计算,需要进行推导,推导出来平均时间复杂度:
O(N^1.3—N^2)
4. 稳定性:不稳定
选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。
基本思想:
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
重复第二步,直到所有元素均排序完毕。
动图演示:
代码:
遍历一遍选出最大和最小的值,分别放到第一个位置和最后一个位置。比起遍历一遍选最大或最小值,有质的变化,但是没有产生量变。
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int max = begin;//用下标记录最大值、最小值
int min = begin;
for (int i = begin; i <= end; i++)
{
if (a[min] > a[i])
min = i;
if (a[max] < a[i])
max = i;
}
Swap(&a[begin], &a[min]);
//begin = max时,最大值被换走了,需要修正max的位置
if (begin == max)
max = min;
Swap(&a[end], &a[max]);
begin++;
end--;
}
}
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
基本思想:
创建一个堆 H[0……n-1];
把堆首(最大值)和堆尾互换;
把堆的尺寸缩小 1,并调用AdjustD,目的是把新的数组顶端数据调整到相应位置;
重复步骤 2,直到堆的尺寸为 1。
动图演示:
代码:
关于向下调整,堆那一篇博客有详细讲解。
void AdjustDown(int* a, int n, int parent)
{
int child = 2 * parent + 1;
while (child < n)
{
//选出左右孩子较大的
if (child + 1 < n && a[child] < a[child + 1])
{
++child;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
//排升序,建大堆
//构建大堆(向下调整,从最后一个非叶子节点,最后一个非叶子节点是最后一个节点的父亲)
//O(N)
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a,n ,i );
}
//依次选数,调堆
//O(N*logn)
for (int end = n - 1; end > 0; --end)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
}
}
冒泡排序(Bubble Sort)也是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。
作为最简单的排序算法之一,冒泡排序给我的感觉就像 Abandon 在单词书里出现的感觉一样,每次都在第一页第一位,所以最熟悉。冒泡排序还有一种优化算法,就是立一个 flag,当在一趟序列遍历中元素没有发生交换,则证明该序列已经有序。但这种改进对于提升性能来说并没有什么太大作用。
基本思想:
比较相邻的元素。如果第一个比第二个大,就交换他们两个。
对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
针对所有的元素重复以上的步骤,除了最后一个。
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
动图演示
void BubbleSort(int* a, int n)
{
int end = n - 1;
while (end > 0)
{
int exchange = 0;
for (int i = 0; i < end; i++)
{
if (a[i] > a[i + 1])
{
exchange = 1;
Swap(&a[i], &a[i + 1]);
}
}
if (exchange == 0)
break;
end--;
}
}
快速排序由于排序效率在同为O(N*logN)的几种排序方法中效率较高,因此经常被采用,再加上快速排序思想----分治法也确实实用,因此很多软件公司的笔试面试,包括像腾讯,微软等知名IT公司都喜欢考这个,还有大大小的程序方面的考试如软考,考研中也常常出现快速排序的身影。
快速排序是C.R.A.Hoare于1962年提出的一种划分交换排序。它采用了一种分治的策略,通常称其为分治法(Divide-and-ConquerMethod)。
基本思想:
快排的单趟排序:
单趟排序后的目标:左边的值比key要小,右边的值比key要大。
这种方法要注意两种特殊情况:
当要排序的数组是有序的时候:5 ,6 ,7 ,8, 9,L或者R可能会越界.
当数组里都是同一个数的时候:2,2,2,2,2,可能会陷入死循环,left<=right.
代码:
int Partion(int* a,int left,int right)
{
int keyi = left;
while (left < right)
{
//右边先走,找小
while (left < right && a[right] >= a[keyi])//防止特殊情况
right--;
//左边再走,找大
while (left < right && a[left] <= a[keyi])
left++;
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
return keyi;
}
void QuickSort(int *a,int left,int right)
{
if (left >= right)
return;
int keyi = Partion(a, left, right);
//[left, keyi - 1] keyi [keyi + 1, right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
这种选key值的方法存在一定的缺陷:
1.当选到的key值都是中位数,快排效率最好,类似于二分。
2. 当选到的key值都是最大或最小数,快排效率会很慢。
那么如何解决这个问题呢?
1.随机选数,
2.三数取中。选取左边,中间,右边,既不是最大,也不是最小的那个数做key。(面对最坏的情况,选中位数做key,变成最好的情况)
三数取中代码:
int GetMidIndex(int* a, int left, int right)
{
//int mid = (left + right) / 2;
int mid = left + ((right - left) >> 1);
if (a[left] > a[mid])
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
{
return right;
}
}
else//arr[left] < arr[mid]
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return left;
}
else
{
return right;
}
}
}
int Partion1(int* a,int left,int right)
{
//三数取中
int m = GetMidIndex(a, left, right);
Swap(&a[m], &a[left]);
int keyi = left;
while (left < right)
{
//右边先走,找小
while (left < right && a[right] >= a[keyi])//防止特殊情况
right--;
//左边再走,找大
while (left < right && a[left] <= a[keyi])
left++;
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
return left;
}
void QuickSort(int *a,int left,int right)
{
if (left >= right)
return;
int keyi = Partion1(a, left, right);
//[left, keyi - 1] keyi [keyi + 1, right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
代码:
int GetMidIndex(int* a, int left, int right)
{
//int mid = (left + right) / 2;
int mid = left + ((right - left) >> 1);
if (a[left] > a[mid])
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right])
{
return left;
}
else
{
return right;
}
}
else//arr[left] < arr[mid]
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return left;
}
else
{
return right;
}
}
}
//挖坑法
int Partion2(int* a, int left, int right)
{
//三数取中
int mini = GetMidIndex(a, left, right);
Swap(&a[mini], &a[left]);
int key = a[left];
int pivot = left;
while (left < right)
{
//右边找小,放到左边的坑里面
while (left < right && a[right] >= key)
{
--right;
}
a[pivot] = a[right];
pivot = right;
//左边找到大,放到右边的坑里面
while (left < right && a[left] <= key)
{
++left;
}
a[pivot] = a[left];
pivot = left;
}
a[pivot] = key;
return pivot;
}
void QuickSort(int *a,int left,int right)
{
if (left >= right)
return;
int keyi = Partion2(a, left, right);
//[left, keyi - 1] keyi [keyi + 1, right]
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
最左边的值做key示意图:
代码:
//前后指针法
int Partion3(int* a, int left, int right)
{
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]);
return prev;
}
小区间优化
对快排做一些优化:
小区间优化,当分割到小区间时,不再用递归分割的思路给这段子区间排序,直接使用插入排序。
对于递归快排,减少递归次数,因为最后几层的递归几乎占了递归的绝大部分。
代码:
//递归实现快速排序O(N*logN)
void QuickSort(int* arr, int left, int right)
{
if (left >= right)
return;
//小区间优化,当分割到小区间时,不再用递归分割的思路让这段子区间有序
//对于递归快排,减少递归次数
if (right - left + 1 < 10)//区间可以不确定,保证是比较小的区间就可以了
{
InsertSort(arr + left, right - left + 1);
}
else
{
int keyi = Partion3(arr, left, right);
//[left,keyi] keyi [keyi+1,right]
QuickSort(arr, left, keyi - 1);
QuickSort(arr, keyi + 1, right);
}
}
递归的快排当数据过大时会发生栈溢出,所以掌握非递归的快排是非常有必要的。任何递归的本质,实际上就是入栈出栈的过程。也就是说只要是递归的,都可以改成非递归,因此快排也可以通过栈来实现。
基本思路:
1.对原数组进行一次划分,分别将最左边的元素下标和最右边的 元素下标入栈 stack。
2.判断 stack 是否为空,若是,直接结束;若不是,将栈顶元素下标取出,进行一次划分。
3.判断左边的元素长度(这里指 right - left + 1)大于 1,将左边的元素下标入栈;同理,右边的元素下标。
4.循环步骤 2、3。
代码:
void QuickSortRon(int* a, int left, int right)
{
ST st;
StackInit(&st);
StackPush(&st,left);
StackPush(&st, right);
while (!StackEmpty(&st))
{
int end = StackTop(&st);
StackPop(&st);
int begin = StackTop(&st);
StackPop(&st);
int keyi = Partion3(a, begin, end);//也可以用Partion1、Partion2
//[left, keyi - 1] keyi [keyi + 1, right]
if (keyi + 1 < end)
{
StackPush(&st, keyi + 1);
StackPush(&st, end);
}
if (begin < keyi - 1)
{
StackPush(&st, begin);
StackPush(&st, keyi - 1);
}
}
StackDestroy(&st);
}
快速排序的特点及性能
快速排序是在冒泡排序的基础上改进而来的,冒泡排序每次只能交换相邻的两个元素,而快速排序是跳跃式的交换,交换的距离很大,因此总的比较和交换次数少了很多,速度也快了不少。
但是快速排序在最坏情况下的时间复杂度和冒泡排序一样,是O(n2)
,实际上每次比较都需要交换,但是这种情况并不常见。我们可以思考一下如果每次比较都需要交换,那么数列的平均时间复杂度是O(nlogn)
,事实上在大多数时候,排序的速度要快于这个平均时间复杂度。这种算法实际上是一种分治法思想,也就是分而治之,把问题分为一个个的小部分来分别解决,再把结果组合起来。
快速排序只是使用数组原本的空间进行排序,所以所占用的空间应该是常量级的,但是由于每次划分之后是递归调用,所以递归调用在运行的过程中会消耗一定的空间,在一般情况下的空间复杂度为O(logn)
,在最差的情况下,若每次只完成了一个元素,那么空间复杂度为O(n)
。所以我们一般认为快速排序的空间复杂度为O(logn)
。
快速排序是一个不稳定的算法,在经过排序之后,可能会对相同值的元素的相对位置造成改变。
虽然快排有一个致命缺陷:当需要排序的数全部相同时,快排会非常慢,而且这个缺陷无法解决,但是快速排序基本上被认为是相同数量级的所有排序算法中,平均性能最好的。
归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法:
基本思想:
申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
设定两个指针,最初位置分别为两个已经排序序列的起始位置;
比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
重复步骤 3 直到某一指针达到序列尾;
将另一序列剩下的所有元素直接复制到合并序列尾。
动图演示:
代码:
//递归实现归并排序
//子函数
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)
{
return;
}
int mid = (left + right) / 2;
// [left, mid] [mid+1, right] 有序
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int i = left;
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++];
}
// tmp 数组拷贝回a
for (int j = left; j <= right; ++j)
{
a[j] = tmp[j];
}
}
//主函数
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
调用Merge将相邻的子表归并时,必须对表的特殊情况进行特殊处理:
若子表个数为奇数,则最后一个子表无须和其他子表归并(即本趟处理轮空):若子表个数为偶数,则要注意到最后一对子表中后一个子表区间的上限为n-1。
begin1由循环控制,不会越界,但是end1、begin2、end2都有可能越界,这时就需要对代码做一些处理。
//非递归实现归并排序
// 时间复杂度:O(N*logN)
// 空间复杂度:O(N)
void MergeSortNonR(int* arr, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += 2 * gap)
{
// [i,i+gap-1] [i+gap,i+2*gap-1]
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
// 核心思想:end1、begin2、end2都有可能越界
// end1越界 或者 begin2 越界都不需要归并
if (end1 >= n || begin2 >= n)
{
break;
}
// end2 越界,需要归并,修正end2
if (end2 >= n)
{
end2 = n - 1;
}
int index = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
else
{
tmp[index++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
// 把归并小区间拷贝回原数组
for (int j = i; j <= end2; ++j)
{
arr[j] = tmp[j];
}
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
1. 计数排序的特征
当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 Θ(n + k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。
由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。例如:计数排序是用来排序0到100之间的数字的最好的算法,但是它不适合按字母顺序排序人名。但是,计数排序可以用在基数排序中的算法来排序数据范围很大的数组。
通俗地理解,例如有 10 个年龄不同的人,统计出有 8 个人的年龄比 A 小,那 A 的年龄就排在第 9 位,用这个方法可以得到其他每个人的位置,也就排好了序。当然,年龄有重复时需要特殊处理(保证稳定性),这就是为什么最后要反向填充目标数组,以及将每个数字的统计减去 1 的原因。
基本思想:
动图演示:
代码:
//时间复杂度:O(Max(N,Range))
//空间复杂度:O(Range)
//适合范围比较集中的整数数组 范围较大,或者是浮点数等都不适合排序
void CountSort(int* arr, int n)
{
int max = arr[0], min = arr[0];
for (int i = 1; i < n; ++i)
{
if (arr[i] > max)
max = arr[i];
if(arr[i]