1. 插入排序
1.1 直接插入排序
直接插入排序(Straight Insertion Sort)的基本思想是:
把n个待排序的元素分为有序和无序两个集合.起初有序集合只有1个元素,无序集合有n-1个.排序过程中每次从无序集合中取出第一个元素,将它插入到有序集合中的适当位置,使之成为新的有序集合,重复n-1次可完成排序过程.
思路:
- 将区间[0,n]分为两部分,[0,end]有序,[end+1, n]无序.则end+1是要插入的元素x下标.
- 插入的位置有两种:
- 在中间:把比x大的元素都往后挪动一个位置,插入
- 在两端:遍历完无序集合后插入
- 尽管插入的位置有两种,但它们都要插入,不妨把在两端插入当做在中间插入的一种特殊情况,即判断是否符合在中间插入的条件,符合则挪动,否则跳过判断,直接在两端插入.
代码:
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
注意事项:
- 如何将[end+1, n]无序集合中的第一个元素插入[0,end]有序集合?
用循环控制有序集合的[0,end],那么无序集合第一个元素就是有序集合的下一个元素,即end+1.注意end范围[0,n-1].
- 挪动必须从有序集合的的尾部挪动一个位置,避免有序集合的数据被覆盖.但这样会造成无序集合的第一个元素被覆盖,所以需要提前保存该元素,但不能使用下标保存.
特点
- 当数据接近有序,直接插入效率越高.时间复杂度接近O(N).所以插入排序适合处理接近有序的数据.
- 时间复杂度:O(N2)
- 空间复杂度:O(1)
- 稳定性:稳定
关于各排序算法的稳定性,将会在总结时介绍.
1.2 希尔排序
希尔排序(Shellsort),也称递减增量排序算法,是插入排序的一种更高效的改进版本.
希尔排序通过全部元素分为长度相同几个区域来提升插入排序的性能.这样可以让一个元素可以一次性地朝最终位置前进一大步.然后再取越来越小的步长进行排序,步长为1时就是普通的插入排序,但是到了这步,数据已经是接近有序的了. 也就是说,希尔排序是有步长(>=1)的插入排序. 而步长的选择是希尔排序中的重要部分.
思路:
- 预排序(gap>1):刚开始以一个较大的gap(步长)值对数据进行预排序,每预排序一次,gap按某种规则减小
- 直接插入排序(gap=1):当gap(步长)值为1时,数据已经接近有序,对其进行直接插入排序.
通过对上图中最后的四组数据中各个数字(尤其是两端的数字)的位置的分析,我们可以知道:6和1从一端走到另一端仅仅用了两次!而中间的其他数字从原始位置走到正确位置也仅仅用了2~3次,这极大地提高了排序的效率.试想6和1在插入排序中走的次数可是数组长度,而实际上数组的长度往往不会很小. 通过动手实现部分预排序,可以知道实际上这就是步长为gap步的插入排序,插入排序的gap为1. 先写出gap=3的一次预排序.
void ShellSort(int* a, int n)
{
int gap = 3;
for (int i = 0; i < n - gap; i += gap)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
InsertSort(a, n);
}
被大多数人所接受的gap的上限以及递减的方式:gap = gap / 3 + 1 ,后面的+1 的作用是确保gap最后能取到1.假如gap = gap / 2 ,则不用+1 . 简化代码:之前是按gap分组,一组一组地插入排序,而现在遍历数据的步长不是gap而是1,且保持插入排序的步长为gap,最终也能达到相同的效果.
即前者为红红红,蓝蓝蓝,紫紫紫;后者为红蓝紫,红蓝紫,红蓝紫,gap组数据交替地做插入排序.
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
注意事项:
- 同插入排序
- 注意循环的边界是[0,n-gap],原因同插入排序.是为了避免越界,因为是用有序区间的下一个元素(它属于无序区间)和有序区间的第一个元素比较的.
小结:
- 步长>1,预排序:gap起初较大,能让极端数据以更大的步长到达两端,也就是更快地到达正确位置,但是这会让数据偏离有序.让gap以很快的速度(所以不是递减某个数)减小,依次来优化下一次的插入排序,因为前面的排序已经让极端数据接近或已经到达正确位置了,所以接下来就是让gap减小,让数据接近有序.因此它的时间复杂度不会很大.
- 步长=1,直接插入排序.充分发挥插入排序的优势.
以自己的理解,得出希尔排序的时间复杂度的近似值:
实际上,希尔排序的时间复杂度涉及一些未解决的数学难题,因此只能近似估算其时间复杂度. 对于N个数据,如果gap很大很大,N次;而第一个循环为O(log3N),则时间复杂度近似值为O(N*logN). 官方时间复杂度近似值为O(N1.3)
特点
- 希尔排序的思想十分巧妙,从纵向看:前面的每次预排序都是让极端数据以更大的步长更快速的到达正确位置,下一次的预排序都是上一次的优化,使得每次排序都更接近有序.其每一次的排序都是有正向关联的.
- 时间复杂度:O(N1.3)
- 空间复杂度:O(1)
- 稳定性:不稳定
2. 选择排序
2.1 选择排序
选择排序(Selection sort)的基本思想是:
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾.以此类推,直到所有元素均排序完毕.
[优化]思路:
- 每次遍历数组找到最小值和最大值,通过和首尾部元素交换的方式分别放在数组的首部和尾部
- 每找到一对,都要放在之前找到的最值的后面
- 直到只剩一个数据
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int maxi = begin, mini = begin;
for (int i = begin + 1; i <= end; i++)
{
if (a[maxi] < a[i])
{
maxi = i;
}
if (a[mini] > a[i])
{
mini = i;
}
}
Swap(&(a[begin]), &(a[mini]));
if (begin == maxi)
{
maxi = mini;
}
Swap(&(a[end]), &(a[maxi]));
begin++;
end--;
}
}
注意事项:
- 寻找最值和交换位置都是通过控制下标完成的.
- 在两个交换函数中间需要修正maxi(最大值的下标)的值.因为begin有可能等于maxi.试想:54321要排成12345,第一轮:begin=0,end=4,maxi=1,mini=4.第一个交换函数将1(下标为mini)放在首部(下标为begin)的位置上,第二次交换把5(下标为maxi)放在尾部(下标为end)的位置上,但是上一次已经把1和5的位置换了,它们的下标也交换了,所以要修正下标.
特点
- 选择排序的优点是容易理解,缺点是不论数据的形式如何,选择排序的算法都要对其遍历,也就是说,它在任何时候都没有优势,因而没有被人广泛使用.
- 时间复杂度:O(N2)
- 空间复杂度:O(1)
- 稳定性:不稳定
2.2 堆排序
个人认为这里已经讲的很详细了,欢迎交流.
特点
- 堆排序利用二叉树,达到二分数据的效果.虽然不受数据形式影响,但因其算法的优越性,数据形式不会对效率产生太大影响.
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
3. 交换排序
3.1 冒泡排序
冒泡排序(Bubble Sort)又称为泡式排序,是一种简单的排序算法.
它重复地遍历要排序的数列,一次比较两个元素,如果它们的顺序错误就交换它们的位置.直到该数列有序.之所以被称之为冒泡排序,是因为这个算法让越小的数字像泡泡一样"浮"到数列一端.
思路:
- 比较相邻元素.如果第一个比第二个大,就交换它们两个.
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对.每遍历一遍,最后的元素会是最大的数,而下一次遍历元素将会少一个.
- 对所有的元素重复以上的步骤,除了最后一个.
- 每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较,即为有序.
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n; i++)
{
int exchange = 0;
for (int j = 0; j < n - i - 1; j++)
{
if (a[j + 1] < a[j])
{
Swap(&(a[j + 1]), &(a[j]));
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
优化: 冒泡排序的效率不被数据顺序影响,所以即使数据原本是有序或者遍历几次就有序,剩下的元素则无需再遍历,所以程序中使用了标记变量exchange ,以示数据是否被处理过.
特点
- 冒泡排序因为其算法的形象,是初学者最先接触的排序算法之一
- 时间复杂度:O(N2).等差数列求和
- 空间复杂度:O(1)
- 稳定性:稳定
3.2 快速排序
快速排序(Quicksort)的基本思想:
最重要的是分治思想–理解它非常重要!!! 将数列中的某一个元素作为基准值,每排序一次,基准值前面都是比它小的元素,后面都是比它大的元素.然后以基准值为分界点,递归地重复上述步骤,直至子区间不可分割为止.
思路:
- 选出基准值key
- 对基准值排序,小的放前面,大的放后面
- 递归地对左右子区间重复此操作
快速排序主要有三种方法,后面两种是优化版本
3.2.1 hoare分割法
hoare大佬写发明了快速排序算法 思路:
- 选出一个基准值key(一般是端点值)
- 一次排序达到的效果:key左边的值比它小,右边的值比它大.也就是说,每排序一次,基准值都一定会到正确位置上
- 递归地对左右子数组重复上述操作,直到子数组长度为1或0,此时所有元素都在正确位置.
- hoare版本的快速排序的递归结构类似二叉树的前序遍历
int PartSort1(int* a, int begin, int end)
{
if (begin >= end)
return;
int left = begin;
int right = end;
int keyi = left;
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
Swap(&(a[right]), &(a[left]));
}
Swap(&(a[left]), &(a[keyi]));
keyi = left;
return keyi;
}
注意事项:
- 要判断左右端点下标的关系,这是往下递归结束的条件
- 在遍历的同时也要限制下标不能出界
- key的左小右大,取补集对应大于等于和小于等于
- 如果是if语句控制左右指针的移动,条件需要稍作调整
难点: 确定了左端点元素为基准值key,那么就得让右指针先走,左指针后走,否则可能会漏掉某个元素
通过一个例子可以看到,让某一端点作为基准值,就得让另一端先走.
3.2.2 挖坑法
hoare版本有些抽象,为了能理解"分治",学习挖坑法和前后指针版本的快速排序是必要的. 思路:
- 将基准值用变量存起来,然后让它的位置作为一个坑
- 如果左右指针符合移动的条件,将该位置放入上一次留下来的坑位,该位置则作为下一次的新坑
- 重复上述操作直至左右指针相遇
- 把之前保存的基准值放入最后一个坑位,此时基准值的左边都是小于它的,右边都是大于它的
int PartSort2(int* a, int begin, int end)
{
if (begin >= end)
return;
int left = begin;
int right = end;
int piti = begin;
int key = a[piti];
while (begin < end)
{
while (begin < end && key <= a[end])
{
end--;
}
a[piti] = a[end];
piti = end;
while (begin < end && key >= a[begin])
{
begin++;
}
a[piti] = a[begin];
piti = begin;
}
a[piti] = key;
return piti;
}
注意事项:
挖坑法的优点在于它容易理解,免去了操作指针先后顺序的问题
3.2.3 前后指针法
思路:
- 选好基准值key
- prev指针从数组首部开始,cur指向prev的下一个位置.用prev指针维护比基准值小的元素,用cur指针遍历数组
- cur每走一步,判断cur指向的值是否小于key.符合条件则将cur指向的元素放入prev的范围
- 最后交换prev指向的元素和基准值
int PartSort3(int* a, int begin, int end)
{
if (begin >= end)
return;
int prev = begin;
int cur = prev + 1;
int keyi = begin;
while (cur <= end)
{
if (a[cur] < a[keyi])
{
prev++;
if (prev != cur)
{
Swap(&(a[cur]), &(a[prev]));
}
}
cur++;
}
Swap(&(a[keyi]), &(a[prev]));
keyi = prev;
return keyi;
}
注意事项:
- 在将符合条件的cur指向的元素放入prev维护的区间之前,要保证prev的下一个位置不是cur,过滤自己和自己换的情况
3.2.4 优化快速排序
key取值优化
快速排序的三种方法都用到了"基准值",实际上,基准值太大或太小都会影响该次排序的效率:
- 数据量稍大,会栈溢出(递归层数太深)
- 当数据有序或接近有序,时间复杂度是O(N2)(等差)
有两种办法可以解决这个问题:
- key取随机数
- 三数取中
在每次排序之前,选出数组首,尾,中三者中的中间值作为基准值,这便是三数取中.
- 三数取中的对象是数组的首尾中三个元素,不是数组中的大小关系,是位置关系
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return end;
}
else
{
return begin;
}
}
else
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return begin;
}
else
{
return end;
}
}
}
虽然可以用数个判断语句达到三数取中的效果,但这样做既不高效,也不美观.使用if...else 语句,层层过滤,每一个语句都有联系.
小区间优化
当子区间长度较小时,不再使用递归转而使用直接插入排序或其他排序对小区间处理,能大大减少递归的次数.
将递归过程看作二叉树遍历,假设这是一个深度为k的满二叉树.其叶子结点的个数为2k-1 是总结点个数的1/2,理论上如果用插入排序处理长度为2的子区间,能减少50%的递归次数. 当然实际情况下递归结构是满二叉树的可能性不大,但如果用插入排序处理长度为2~10(这个长度)的子区间,是能大大减少递归次数从而提高排序的总体效率的. 从上面的例子可以看出,递归带来的副作用也是很大的,超过一半的递归次数都被用在小区间数组的排序上.
3.2.5 非递归快速排序
为何要将递归转化为非递归?
上面提到的递归层数在IDE的优化下对效率的影响甚微,递归的主要副作用在于如果递归层数过深,会造成栈溢出. 由于函数是在栈区递归的,结合栈区先进后出的特性以及可以用堆模拟实现栈,进而达到在大内存的堆区实现栈区的特性. ps:内存中栈区的大小通常是2M~8M,而内存中堆区的大小很大,通常向系统申请较大的内存也没问题.
由于快速排序的递归结构类似二叉树的前序遍历,即当前结点,左区间,右区间,别忘了它还是从上到下的顺序.所以使用堆时,顺序需要反过来,即右区间,左区间,当前结点,从下到上入栈,出栈则符合递归结构的顺序. 注意:入栈的对象是区间 思路:
- 按按顺序将数组端点压入栈中
- 得到数组的基准值,以它为分界点,将左右子区间按顺序压入栈中.另外得到基准值的同时,该子区间已经是有序的了
- 使用判断条件让压栈操作停止
void QuickSortNonR(int* a, int begin, int end)
{
ST st;
StackInit(&st);
StackPush(&st, end);
StackPush(&st, begin);
while (!StackEmpty(&st))
{
int left = StackTop(&st);
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
int keyi = PartSort3(a, left, right);
if (keyi - left > 1)
{
StackPush(&st, keyi - 1);
StackPush(&st, left);
}
if (right - keyi > 1)
{
StackPush(&st, right);
StackPush(&st, keyi + 1);
}
}
StackDestory(&st);
}
注意事项:
- 栈的初始化将数组端点压入,在压栈出栈循环中不断分割区间
- 严格控制区间的入栈顺序
- 由于栈数据结构C语言实现的,注意内存泄漏
特点
- 时间复杂度:最好:O(N*logN)(二叉树结构);最坏:O(N2)
- 空间复杂度:最好:O(logN)(深度);最坏:O(N)
- 稳定性:不稳定
4. 归并排序
归并排序(Merge sort)的思想:
分治思想:
- 分割:将数组递归地分为两部分,直到长度为1或0
- 归并:在保持元素顺序的同时将上一次分割得到的子数组集成到一起
归并操作:将两个有序的子数组合并为一个有序的数组
值得注意的是归并排序的对象是"有序数组",所以首先要保证子数组有序.
4.1 递归的归并排序
思路:
- 申请空间大小为两个子序列大小之和,即数组长度
- 使用三指针分别维护三个数组,都指向起始位置
- 比较子序列指针指向的元素,将小的放入新数组,新数组的指针后移
- 重复步骤2和3,直到至少一个数组遍历完毕
- 将另一个数组剩下的元素(如果有)直接链接到新数组
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);
}
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
_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;
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, (end - begin + 1) * sizeof(int));
}
注意事项:
- 每次递归都要更新起始和终止以及中间元素的下标
- 新数组的下标通过传入的数组起始位置确定
- 最后将新数组的内容回写到原数组
- 注意内存泄漏
小结: 归并排序分为两个部分,首先是将区间分割到长度为1或0;然后是归并,从最后一层开始,只有一个元素就已经是有序的,所以可以使用归并算法.层层往上,上一层总是有序的.
4.2 非递归的归并排序
快速排序的递归结构类似二叉树的前序遍历,所以可以用栈的结构特性模拟实现前序遍历.但归并排序并非如此,因为归并排序的递归结构类似二叉树的后序遍历. 用栈能够模拟实现前序遍历,是因为前序遍历用栈可以完整地处理所有区间和中间结点;而后序遍历用栈不能将它们处理完整,因为左右子区间出栈以后,无法处理中间结点. 思路: 手动归并: 递归的归并排序的分割步骤是从上到下的,直到分割到不能分割为止.而手动归并操作的步骤就是递归的归并排序中的归并步骤.借用希尔排序中的"步长",步长gap 从1增长到数组长度n,每次归并都能让n/gap 组小区间的元素有序.
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(n * sizeof(int));
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
int gap = 1;
while (gap < n)
{
for (int i = 0; i < n; i += gap * 2)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + gap * 2 - 1;
int j = begin1;
if (end1 >= n)
{
end1 = n - 1;
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
begin2 = n;
end2 = n - 1;
}
else if(end2 >= n)
{
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
}
memcpy(a, tmp, n * sizeof(int));
gap *= 2;
}
free(tmp);
}
注意事项:
- 所有跟gap有关的边界都有可能出界.即end1,begin2,end2.它们会让拷贝步骤出现问题.
- end1出界:直接将它修正为最后一个元素的下标.后面的[begin2,end2]区间一定出界,所以将它"修正"为不存在的区间,让这个区间不进入后面的while循环.
- end1不出界,begin2出界:即[begin2,end2]区间出界,步骤同上.
- end1和begin2不出界,end2出界:将end2修正为最后一个元素的下标.
- 每以某个gap值归并一次,需要将新数组回写
- 注意内存泄漏
特点
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N).这是归并排序的缺点.
- 稳定性:稳定
5. 非比较排序
5.1 计数排序
**计数排序(Counting sort)**的思想:
鸽巢原理:
- 若有n个笼子和n+1只鸽子,所有的鸽子都被关在鸽笼里,那么至少有一个笼子有至少2只鸽子.
- 一种表达是这样的:如果要把n个对象分配到m个容器中,必有至少一个容器容纳至少个对象.
–维基百科
思路:
- 找出数组中的最大最小值,开辟一个长度为[最值之差+1]的新数组
- 统计数组中元素出现的次数
- 按出现的次数写回原数组
void CountSort(int* a, int n)
{
int max = a[0], min = a[0];
for (int i = 1; i < n; i++)
{
if (max < a[i])
max = a[i];
if (min > a[i])
min = a[i];
}
int range = max - min + 1;
int* tmp = (int*)malloc(range * sizeof(int));
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
memset(tmp, 0, range * sizeof(int));
for (int i = 0; i < n; i++)
{
tmp[a[i] - min]++;
}
for (int i = 0; i < range; i++)
{
int j = 0;
while (tmp[i])
{
a[j++] = i + min;
tmp[i]--;
}
}
}
注意事项:
- 用来计数的新数组的长度是最值之差再+1
- 计数用当前数减去min,能保证能从新数组的0下标开始存储计数
- 回写用当前数加上min,以还原原数组
特点
- 缺点:适合用在范围集中的数据上,反言之如果一个数组的数据之间相差很大,开辟的数组会浪费很多空间,影响效率.且只适用于整数(下标是整数).
- 时间复杂度:O(max{n,范围})
- 空间复杂度O(范围)
- 稳定性:稳定
6. 总结
6.1 稳定性
在待排序的序列中存在相同的元素,其相对位置如果在排序后仍保持不变,则称之为稳定,否则不稳定. 而交换操作会破坏相同元素的相对关系,即包含交换操作的排序算法都不稳定
6.2 【表】
排序算法 | 最好时间 | 最坏时间 | 一般时间 | 空间 | 稳定性 |
---|
插入排序 | O(N) | O(N2) | O(N2) | O(1) | 稳定 | 希尔排序 | O(N1.3) | O(N2) | O(N*logN)~O(N2) | O(1) | 不稳定 | 选择排序 | O(N2) | O(N2) | O(N2) | O(1) | 不稳定 | 堆排序 | O(N*logN) | O(N*logN) | O(N*logN) | O(1) | 不稳定 | 冒泡排序 | O(N) | O(N2) | O(N2) | O(1) | 稳定 | 快速排序 | O(N*logN) | O(N2) | O(N*logN) | O(logN)~O(N) | 不稳定 | 归并排序 | O(N*logN) | O(N*logN) | O(N*logN) | O(1) | 稳定 | 计数排序 | O(max{n,范围}) | | | O(范围) | 稳定 |
22/7/14
gif动图来源于数据结构和算法动态可视化
|