十大经典排序分别为直接插入排序、希尔排序、起泡(冒泡)排序、快速排序、直接选择排序、堆排序、归并排序、计数排序、桶排序、基数排序
而排序分为比较排序和非比较排序,其分类如下图所示
其时间、空间复杂度如下图所示:
稳定性与不稳定性
稳定代表着排序后,数字相同的元素(对象),经过排序后,其先后顺序保持不变(即:在数组前后两个等值(比较的值)的元素A、B,经过排序后,其先后顺序仍然为A、B)
不稳定于此相反,数字相同的元素(对象),经过排序后,其先后顺序无法得到保障(即:在数组前后两个等值(比较的值)的元素A、B,经过排序后,其先后顺序可能会变成B、A)
注意:以下排序均为从小到大为例
为了方便理解,讲解十大经典排序时,加入了排序的gif,先上图后讲解,
比较排序顾名思义,通过两两比较的方式来确定其顺序,目前常用的比较排序有七种:直接插入排序、希尔排序、起泡排序、快速排序、选择排序、堆排序、归并排序
其中希尔排序、快速排序、堆排序、归并排序都是对排序优化后的结果,时间复杂度高的问题就出现在逆序排序上,因此他们通过跳跃性交换排序,大幅度减少了查找交换的次数,因此时间复杂度能够从O(n^2)降到更低
直接插入排序排序步骤
1.将第一个元素默认为有序队列,并从第二个元素开始进行插入排序
2.从第i个元素开始向前对比,保存当前元素的值
3.发现前面的元素比自己大(从小到大排序),则将前一个元素赋值到当前元素轮转位置,将当前元素赋值为前1位,重复步骤3;否则,结束本次比较
4.将自己赋值到当前位置
5.当最后一个元素向前对比插入完毕时,此排序结束
复杂度介绍
时间复杂度:
最坏为O(n^2),为逆序排序过程,每次排序实际都要向前比较一轮
最好为O(n),为正序排序过程,内部向前比较过程,每轮只会走一次,因此内部没有受到n的限制
平均为O(n^2),默认内外两层到n的循环
空间复杂度:
为O(1), 由于只创建了几个常数项,与n无关,(即使虽然每轮循环都会赋值一次,由于临时变量保存到栈区,本轮循环结束,临时变量即释放)
代码实现
#pragma mark --直接插入排序void sortByStraightInsertion(int list[], int length) { for (int i = 1; i < length; i++) { int pre, current = list; for (pre = i - 1; pre >= 0 && list
> current; pre--) { list[pre + 1] = list; } list[pre + 1] = current; }}复制代码希尔排序希尔排序基于插入排序,设置步长gap,缩小其增量n,进行排序,可以步长gap进行更改(这里默认每次步长减少为原来的一半),以调整改善排序速度,因此其复杂度根据gap的不同而不同
排序步骤
1.设置步长为gap(即向前比较的间隔),默认为半个列表的长度,每一轮减少为现在步长一半
2.通过步长gap将默认列表虚拟地分为若干组,每组长度每轮长度为length/2、length/4、length/8...
3.按照指定步长(间隔)开始进行带间隔的插入排序,从第gap个元素开始,保存为当前元素
4.将当前元素与前面相隔gap距离的元素进行对比,发现前面的比自己大(从小到大排序),则比较的前面第gap个元素赋值到当前元素轮转位置,当前元素赋值为前面第gap位,重复步骤4;否则结束本次比较
5.将步长gap减少为原来的现在的一半,重复步骤3,进行带间隔的插入排序,直到步长为1那一轮执行完毕结束
复杂度介绍
时间复杂度:
通过设置步长的方式来,大幅度降低逆序时的时间复杂度,其时间复杂度根据步长不同变化很大,平均在O(nlog2n ~ n^2),通过gap优化一些人计算约为O(n^1.3)
空间复杂度:
为O(1), 由于只创建了几个常数项,与n无关
代码实现
#pragma mark --希尔排序(缩小增量排序)void sortByShell(int list[], int length) { //设置分组长度,每组对应的值分别别叫,然后缩小步长(这里默认每次步长减少为原来的一半),直到为组长1结束,注意每次缩小间距的过程,插入排序的内比较过程也会跟着缩小 for (int gap = length/2; gap > 0; gap /= 2) { for (int i = gap; i < length; i++) { int pre, current = list; for (pre = i - gap; pre >= 0 && list> current; pre -= gap) { list[pre + gap] = list; } list[pre + gap] = current; } }}
起泡排序排序步骤
1.从第一个元素开始,向后面一个元素进行比较
2.如果后面元素比自己小(从小到大排序),则进行交换,否则不交换,比较元素索引各向后移一位
3.重复步骤2,一直比较到最后一个元素,最大的一个元素被交换到了最后(下次比较到当前元素前一位)
4.重复步骤1~3,对前面n-1个进行交换排序,直到不可交换即可(需要交换的元素只剩下了1个)
复杂度介绍
时间复杂度:
最坏为O(n^2),为逆序排序过程,每次排序实际都要向前比较一轮
最好为O(n),为正序排序过程,通过在里层设置是否交换标识,发现一轮下去没有交换,则直接结束
平均为O(n^2),默认内外两层到n的循环,
空间复杂度:
为O(1), 由于只创建了几个常数项,与n无关
代码实现
#pragma mark --起泡排序void sortByBubble(int list[], int length) { for (int i = 0; i < length; i++) {// int noSort = true; for (int j = 0, last = length - i - 1; j < last; j++) { if (list[j] > list[j + 1]) { int tem = list[j + 1]; list[j + 1] = list[j]; list[j] = tem;// noSort = false; } }// if (noSort) break; //优化措施,可以最好情况优化为O(n),一旦正序则直接结束 }}
快速排序
快速排序是在起泡排序的基础上进行了,其设置一个基准值(一般是第一个),将比基准值小的放到其左侧,大的放到右侧,然后将两侧再取基准值,如此往复,一直到只有一个元素即可
注意:事实上,快速排序通常明显比其他 Ο(nlog2n) 算法更快,因为它的内部循环可以在大部分的架构上很有效率地被实现出来,且空间占用也不大,所以一般面试考察较多
排序步骤
这里巧妙的利用了选出来的基准值元素,不停的进行前后换位,减少了一些空间浪费
1.判断分割序列区间是否还能再分割,即:如果区间首尾一致,首尾区间参数为low、height,则结束,否则继续,进入下一步
2.设置第一个变量为基准值,和首尾索引一并保存一份l、h,基准值变量所在位置即空闲
3.遍历队列,开始以基准值进行分割序列
4.从后h开始向前l查找,每次索引h减少,发现小于基准值,将其赋值到l(第一次基准值位置,当时l位置空闲)战争用,当前位置进入空闲状态,进入下一步;如果发现h==l,则分割完毕直接结束,否则进行步骤6
5.从前l后向h查找,每次索引l增加,发现大于基准值,将其赋值到h(当时h位置为空闲)占用,当前位置进入空闲状态,回到步骤4;如果发现h==l,则分割完毕直接结束,否则进行步骤6
6.步骤4、5经过后l==h,此时其为基准值实际位置(且空闲),将基准值赋值过去
7.对分割的左右区间,继续进行分割,前区间为[ low, l-1],后区间为[ l+1, height],重复步骤1,直到结束
复杂度介绍
时间复杂度:
最坏为O(n^2),基准值一般设置为第一个,逆序或者正序排序的情况,每次用基准值分割较为极端,已经演变成了起泡排序
最好为O(nlog2n),由于每次都是选择一个基准值分离序列,以基准值将序列分为左大右小的情况,每轮下去,对子序列继续选基准值分离序列,因此最好的情况为每次选的刚好为中间数,每次平均分成两份直到结束
平均为O(nlog2n),由于基准值极少全为极端情况,大部分情况分割都是相对均匀
空间复杂度:
为O(nlog2n),由于是以函数栈的形式,接近二分法式地不停的设置基准线,向下分割序列,中间的变量会有此堆积无法立即释放,由于一直如此下去一直到到单个,因此内存结果累计则为O(nlog2n)级别
代码实现
#pragma mark --快速排序 //low最小索引,height最大索引,第一轮为0、length-1void quickSortList(int list[], int low, int hight) { //如果大小不合适直接结束 if (hight <= low) return; int l = low, h = hight; //保存始末位置用于变更 int pri = list[l]; //选定初始值,用于作为中间值来分组 while (l < h) { //从后向前查找小于指定值的索引,赋值给最小值 while (l < h && list[h] >= pri) h--; if (l == h) break; list[l] = list[h]; //从前向后查找小于指定值的索引,赋值给最小值 while (l < h && list[l] <= pri) l++; if (l == h) break; list[h] = list[l]; } //当low和hight指针指向同一个索引的时候,一轮结束,然后递归划分 list[l] = pri; quickSortList(list, low, l - 1); quickSortList(list, l + 1, hight);}
直接选择排序排序步骤
1.设定第1个元素为最小元素,和后面进行比较,如果后面有比其小的,则交换,一轮下去选出最小的到最前面
2.然后设定第2个元素为最小元素,重复步骤1,直到最后一个元素,则排序结束
复杂度介绍
时间复杂度:
最好、最坏、平均均为O(n^2),由于其会从每轮经过n次比较选出最小的一个,放到前面,经过n轮结束,因此时间复杂度固定为O(n^2)
空间复杂度:
为O(1), 由于只创建了几个常数项,与n无关
代码实现
#pragma mark --直接选择排序void sortByStraightSelect(int list[], int length) { for (int i = 0; i < length; i++) { int min = i; for (int j = i + 1; j < length; j++) { if (list[min] > list[j]) { min = j; } } int tem = list; list = list[min]; list[min] = tem; }}复制代码堆排序堆排序,按照堆的特性(大头堆、小头堆,父节点总是比子节点大、小),先前文章有介绍过堆,每次调整堆的过程把堆头选择出来,放到最后,重新调整堆重复选择,以此往复进行排序
排序步骤
1.现将列表看成一个完全二叉树,将其转化成大头堆(从小到大排序),从下到上进行调整堆,更新序列
2.将堆头和最后一个元素进行替换(即:选出最大元素到最后),选出元素不参与下次堆的调整
3.重新调整堆为大头堆,重复步骤2,直到堆中元素全部被选择出来
复杂度介绍
时间复杂度:
最好、最坏、平均均为O(nlog2n),调整堆的过程相当于二分法进行比较,进行了n轮,所以复杂度为O(nlog2n)
空间复杂度:
为O(1), 由于只创建了几个常数项,与n无关
代码实现
#pragma mark --堆排序//调整堆为正常堆(调整成大头堆,以便于选出最大的到最后)void heapShift(int list[], int start, int end) { int ori = list[start]; //保存默认根节点的值 int sub = start * 2 + 1; //表示的是根节点的第一个子节点 //子节点索引要在需调整范围内 while (sub <= end) { //右节点也不能大于索引,并且选出最小的一个和根节点比较 if (sub < end && list[sub] < list[sub + 1]) sub++; if (ori >= list[sub]) break; //不比根节点小,结束 list[start] = list[sub]; start = sub; //更新,用于同步更新根节点,以便于保持堆结构 sub = sub * 2 + 1; } list[start] = ori;}//堆排序(堆结构为完全二叉树结构,父节点和子节点与2有不解之缘,左子节点 = 父节点索引 * 2 + 1, 右子节点 = 父节点索引 * 2 + 2)//首先了解堆的概念,堆分为大根堆和小根堆,就是根节点比子节点要大或者小,每个父节点都是这样(根节点的两个子节点大小以及各自子节点的孩子节点大小就不能作比较了)void sortByHeap(int oriList[], int length) { int *list = copyList(oriList, length); //将原有数组调整成大头堆,由于调整是从父节点开始,根据其索引两倍关系,从一半开始调整到根节点即可形成大头堆 //从最下面调整,那么当顶部大于底部子节点的时候,由于下方子节点已经堆结构了,所以可以直接结束,因此倒序调整最佳 for (int i = length / 2; i >= 0; i--) { //length / 2因为完全二叉树子节点索引最小为2n+1,所以最小也得length/2才可能会有子节点 heapShift(list, i, length -1); } for (int i = length - 1; i > 0; i--) { //第一个和最后一个交换,相当于选出最值到之后,每一轮会选出一个最值到后面 int tem = list; list = list[0]; list[0] = tem; heapShift(list, 0, i - 1); //每次选出的最后一个不参与比较(可以看出是和选择排序逻辑很像) } showSortResult(list, length, "堆排序");}复制代码归并排序归并排序,默认整个集合每个元素为一组,每一轮将相邻的两个组进行合并(从小到大,有序合并),以此往复直到合并成一个组,即排序完毕
因此可以的出结论,合并过程每一组中都是一个小型的有序序列(后续会用到)
排序步骤
1.默认每个元素自己一组,标记目前每组1个,创建长度为n的结果数组
2.从第一个开始,每相邻两组进行对比合并,从小到大
3.由于合并过程每一组中都是一个小型的有序序列,两个组从前往后一个一个进行对比,小的加入到结果数组中,然后后移一位,接着进行对比,直到有一方放置完毕,再降另一方按顺序放入结果数组中
4.重复步骤3,直到当前一轮组两两合并完毕
5.标记目前每组为2个,重复步骤2,直到所有分组合并成一个组
复杂度介绍
时间复杂度:
最好、最坏、平均均为O(nlog2n),由于是使用二分法一轮一轮进行合并,每次合并次数与n正相关,所以复杂度为O(nlog2n)
空间复杂度:
为O(n), 由于创建了一个长度为n的结果数组,用于合并组生成并更新每一轮的结果
代码实现
#pragma mark --归并排序//list系统给的list, length列表长度, group当前组有几何,下次合并则结果组间隔为2倍void mergeList(int list[], int temList[], int length, int group) { //当每组组长已经大于等于length的时候说明最后一波已经合并完毕了 if (group >= length) return; int k = 0; //temList放入新数组的最后一个索引,开区间 //按照组长,每次数组分成 两两一组 的 N 组(length / (2*group)),一定要取到最后一组,不然最后一组合并不进去 for (int i = 0, last = ceil(length / (2 * group)); i <= last; i++) { //每 两两一组的开始和结束索引 int start, startEnd, end, endEnd; start = 2 * i * group; startEnd = start + group > length ? length : start + group; //开区间 end = startEnd; endEnd = end + group > length ? length : end + group; //如果是最后一组且为单组的情况不用合并了 if (end > length) break; //把两两一组的新排序结果 有序合并保存到新数组中(这里是顺序表,如果是链表的话就不需要了,直接后者断链,插入到合适的位置即可,节省很多开销) int i = start, j = end; for (; i < startEnd && j < endEnd; k++) { if (list <= list[j]) { temList[k] = list[i++]; }else { temList[k] = list[j++]; } } if (i >= startEnd) { while (j < endEnd) { temList[k++] = list[j++]; } }else { while (i < startEnd) { temList[k++] = list[i++]; } } } //更新原有list数组,就不使用新的了,毕竟重新创建释放数组也需要占用开销 for (int i = 0; i < k; i++) { list = temList; } mergeList(list, temList, length, group * 2);}//归并排序(二路归并)void sortByMerge(int list[], int length){ //首先吧每一个选项看成一组,然后两两有序合并,单数作为单独一个使用,如此反复(后边的会存在数量不一样的合并),如此下去便可以得出最后结果(比较适用于链表操作) int temList[length]; mergeList(list, temList, length, 1);}复制代码非比较排序顾名思义,不通过两两比较的方式来确定其顺序,目前常用的非比较排序有三种:计数排序、桶排序、基数排序
其通过数字的特性,另辟蹊径解决排序问题,根据使用场景进行使用,有时可以大幅度降低时间复杂度和空间复杂度
计数排序比较适合用于数字相对比较密集的整数排序,例如:将一亿个 1-1000 之间的正数数字进行排序
排序步骤
1.计数排序前需要提前知道里面的最大值和最小值(或者提前知道排序队列的数值域)
2.创建排序所用的临时数组,通过最大最小值的差来确定临时数组的大小
3.遍历一次数组,将对应的数字,按照其数值大小,减去最小值,映射到数组对应位置,对应位置标记+1
4.遍历一次临时数组,将从小到大,将有值的数字依次拿出(每个索引每拿出一个数字标记-1,直到为0),拿出的值为 索引+最小值,依次放到结果数组即可
复杂度介绍
时间复杂度:
最好、最坏、平均均为O(n+k),其中k为数组域之间间隔大小,映射值过程经历n次,拿出值的时候,会经历k次遍历,由于可能值全一样,所以最多为O(n+k)
由此可得,如果数组区间过大,则会造成空间的严重浪费,比较适合用于数字相对比较密集的排序,例如:将一亿个 1-1000 之间的数字进行排序,则非常合适
空间复杂度:
为O(n+k),其中包括域值间隔k和对象本身,对象的形式中间,则需要一个保存n个对象,避免无法正确取出元素
代码实现
- <font size="4">#pragma mark --计数排序void sortByCounting(int list[], int length) { //分别计算出最大值和最小值, 用于确定k区间 int min, max; min = max = 0; for (int i = 1; i < length; i++) { if (list[min] > list[i]) min = i; if (list[max] < list[i]) max = i; } min = list[min]; max = list[max]; if (min == max) { showSortResult(list, length, "计数排序"); return; } int dk = max - min + 1; //间隔为最大值-最小值+ 1 int temList[dk]; //创建k长度的池子,对应的索引为list对应值-min,并初始化基数问题 for (int i = 0; i < dk; i++) { temList[i] = 0; } for (int i = 0; i < length; i++) { temList[list[i] - min]++; } int k = 0; for (int i = 0; i < dk; i++) { while (temList[i]-- > 0) { list[k++] = i + min; } }}</font>
可以看出元素根据其值的关系,将每个桶分成若干个值一样的区间(桶),将符合条件的数字放入对应桶内,分别对桶内元素排序,拿出即可
可以看出桶排序比较适合数据分布均匀的序列(如果一个元素一个桶则演化成计数排序)
排序步骤
1.求出序列中的数字的最大最小值,并根据最大最小值
2.将最大值最小值所在区间,分割成若干个区域(桶),创建数组,便于容纳排序元素
3.减去最小值,通过取商法来,来将元素放入对应桶内
4.对桶内元素进行内排序(起泡、选择、插入)
5.从桶内依次取出元素
复杂度介绍
时间复杂度:
最坏为O(n^2),所有元素都放到一个桶内,遵循桶内内排序最坏时间复杂度
最好为O(n),一个有序序列放到一个桶内,则最低可能为O(n),例如插入、起泡排序
平均为O(n + k),默认内外两层到n的循环,假设元素分布相对平均,k = m * (n/m) * log2(n/m) = nlog2(n/m) = n(log2n - log2m),再加上n次拿出
空间复杂度:
为O(n+k),包括桶的大小和结果数据本身大小,因为桶内装着所有元素,即:元素大小+桶大小 O(n+k)
代码实现
#pragma mark --桶排序//桶排序,和计数排序很像,只不过按照范围映射到了若干个桶里,节省了部分内存开销,需要用到动态数组不然可能空间复杂度要到O(n + k * n ),即每个桶大小都是N//如果不是数字分布比较均匀只是顺序打乱的数组,如果不是,不太建议使用这个排序,毕竟其原理就是为了平局分割开数组进行桶内排序,桶太密集的话会接近计数排序void sortByBucket(int list[], int length) { //分别计算出最大值和最小值, 用于确定k区间 int min, max; min = max = 0; for (int i = 1; i < length; i++) { if (list[min] > list) min = i; if (list[max] < list) max = i; } min = list[min]; max = list[max]; if (min == max) { showSortResult(list, length, "桶排序"); return; } int dk = max - min; //中间间隔数 int bucket = 5; //默认5个吧 int bucketSize = ceil(dk / bucket) + 1;//因为可能存在整倍的问题 //这里的数组没有优化,默认最长顺序表,可以自行优化,或者其他语言自带的数组 LSListNode *p[bucket]; for (int i = 0; i < bucket; i++) { p = NULL; } for (int i = 0; i < length; i++) { int group = (list - min) / bucketSize; //往对应组的数组内加入数据 p[group] = pushNode(p[group], list); } //然后对每个组内成员进行排序,在有序copy到原数组即可 int k = 0; for (int i = 0; i < bucket; i++) { int count = getListCount(p); //这里使用其他内排对一个桶进行内排序(这里直接先写一个冒泡吧,避免打印干扰) int temList[count]; for (int j = 0; j < count; j++) { temList[j] = geListNode(p, j)->data; } for (int x = 0; x < count; x++) { for (int y = 0, last = count - x - 1; y < last; y++) { if (temList[y] > temList[y+1]) { int tem = temList[y+1]; temList[y+1] = temList[y]; temList[y] = tem; } } } //按桶的顺序放到原数组 for (int j = 0; j < count; j++) { list[k++] = temList[j]; } }}
基数排序
即:将一定位数的数组,按照指定位数(m),从小到大进行类似计数排序,映射到分别为0~9的数组中,依次拿出,经过k轮排序,则生成最终结果
排序步骤
1.如果给出最大数字位数,则直接使用,否则求出最大值,并计算数字位数(10进制的话,每次除以10即可)
2.以十进制为例,将数字0~9,分割为10个映射数组,用于将数组某一位符合的数字映射入座
3.从个位数开始,进行排序
4.通过取余法,拿出十进制对应位数,将其按照顺序,依次映射到提前创建的0~9的映射数组中
5.按照顺序从小到大取出值放到数组中,以便于下一轮使用
6.从十位数继续,重复步骤4,直到进行到最高位为止
复杂度介绍
时间复杂度:
最好、最坏、平均均为O(nk),其中k为单个数字区间(0~9)大小10,乘以数字的最大位数m,即10mn,即O(nk)
空间复杂度:
为O(n+k),类似桶排序和计数排序,只需要额外增加(0~9)个数组区间,累计容纳n个元素,即为O(n+k)
代码实现
#pragma mark --基数排序void sortByRadixSort(int list[], int length, int maxBit) { if (maxBit < 1) { //如果不给最大值位数d,那么求出来 maxBit = 0; for (int i = 1; i < length; i++) { if (list[maxBit] < list) { maxBit = i; } } int max = list[maxBit]; maxBit = 1; while (max / 10) { max /= 10; maxBit++; } } int d = 10; //1~10; LSListNode *queue[d]; for (int i = 0; i < maxBit; i++) { //初始化每一组,避免出现干扰 for (int x = 0; x < d; x++) { queue[x] = NULL; } int bitNumber = pow(10, i); for (int k = 0; k < length; k++) { int num = list[k] / bitNumber % 10; queue[num] = pushNode(queue[num], list[k]); } for (int k = 0, j = 0; k < d; k++) { LSListNode *q = queue[k]; int count = getListCount(q); if (count < 1) continue; while (q) { list[j++] = q->data; q = shiftNode(q); } } }}