当前位置:   article > 正文

数据结构初阶--排序

数据结构初阶--排序

一、排序的概念及其运用

1.1排序的概念

排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次 序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排 序算法是稳定的;否则称为不稳定的。

内部排序:数据元素全部放在内存中的排序。

外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

1.2排序运用

 1.3 常见的排序算法

1.3.1 直接插入排序--升序

思想:

把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。

实际中我们玩扑克牌时,就用了插入排序的思想:

单趟:将x插入有序数组中,即x插入[0,end]有序区间中

概念:

若数组(arr)除最后一个元素外其余全部有序,设最后一个元素的下标为i,将arr[i]与前面的元素比较,前面的元素比他大则前面的元素向右移动,比他小则在该元素的后面插入。

演示过程动图:


实现思路:

 实现遍历比较的方法:

  • 从右往左遍历比较,遇到比x大的数,就把该数往后挪1。
  • 遇到比x小的数,就把x插在该数后面
  • 我们把每一次与x比较的数对应下标设为end,end从最右边开始每比较一次减1

循环结束标志:

  1. 插入的数字小于数组中的所有数组,从右往左遍历至下标为-1处插入

代码实现:
  1. void InsertSort(int* a, int n)
  2. {
  3. assert(a);
  4. int x;
  5. int end;
  6. while (end >= 0)//看解释1
  7. {
  8. if (x <= a[end])
  9. {
  10. a[end + 1] = a[end];
  11. end--;
  12. }
  13. else
  14. {
  15. break;//当遇到比x小的数时,就该结束循环了,后续再把x插入该数后面
  16. }
  17. }
  18. //x放到比它小的数的后面
  19. a[end + 1] = x;
  20. //代码执行到此位置有两种情况:
  21. //1.待插入元素找到应插入位置(break跳出循环到此)
  22. //2.待插入元素比当前有序序列中的所有元素都小(while循环结束后到此)
  23. }

 复合:将x插入无序数组中,即x插入[0,end]无序区间中

概念:

因为不知道数组中得到前几个元素是已经有序的,所以直接从第二个元素开始执行插入排序,单个过程与上述相同,将每个元素都进行一次插入排序。

演示过程动图:


实现思路:

 在待排序的元素中,假设前n-1个元素已有序,现将第n个元素插入到前面已经排好的序列中,使得前n个元素有序。按照此法对所有元素进行插入,直到整个序列有序。
  但我们并不能确定待排元素中究竟哪一部分是有序的,所以我们一开始只能认为第一个元素是有序的,依次将其后面的元素插入到这个有序序列中来,直到整个序列有序为止。在这里插入图片描述


步骤:

1.从第一个元素开始,该元素可以认为已经被排序
2.取下一个元素tem,从已排序的元素序列从后往前扫描
3.如果该元素大于tem,则将该元素移到下一位
4.重复步骤3,直到找到已排序元素中小于等于tem的元素
5.tem插入到该元素的后面,如果已排序所有元素都大于tem,则将tem插入到下标为0的位置
6.重复步骤2~5


代码实现:

  1. void InsertSort(int* arr, int n)
  2. {
  3. for (int i = 0; i < n - 1; ++i)//看解释1
  4. {
  5. //记录有序序列最后一个元素的下标
  6. int end = i;
  7. //x保存待插入的元素
  8. int x = arr[end + 1];
  9. //单趟排
  10. while (end >= 0)
  11. {
  12. //比插入的数大就向后移
  13. if (x < arr[end])
  14. {
  15. arr[end + 1] = arr[end];
  16. end--;
  17. }
  18. //比插入的数小,跳出循环
  19. else
  20. {
  21. break;
  22. }
  23. }
  24. //x放到比插入的数小的数的后面
  25. arr[x + 1] = tem;
  26. //代码执行到此位置有两种情况:
  27. //1.待插入元素找到应插入位置(break跳出循环到此)
  28. //2.待插入元素比当前有序序列中的所有元素都小(while循环结束后到此)
  29. }
  30. }

1️⃣为什么i<n-1而不是i<n?

因为x位置是i的下一个位置,为防止x越界,需要使 i < n-1。使得end只能抵达下标为n-2处


时间复杂度:最坏情况下为O(N2),此时待排序列为逆序,或者说接近逆序
      最好情况下为O(N),此时待排序列为升序,或者说接近升序。
空间复杂度:O(1)

 1.3.2 希尔排序(对直接排序进行优化)

希尔排序法又称缩小增量法。

基本思想:

  • 分组预排序
  • 直接插入排序
  • 重复上述操作至间隔最小

先选定一个整数gap,把待排序文件中所有记录分成gap个组,所有距离为gap的记录分在同一组内,并对每一组内的元素进行排序。

然后将gap逐渐减小重复上述分组和排序的工作。

当到达gap=1时,所有元素在统一组内排好序。


静态图演示:

动图演示: 

这里重要的是理解分组思想,每一个组其实就是一个插入排序,相当于进行多次插入排序。


代码实现:                                                                                                                            
  1. void ShellSort(int* a, int n)//希尔排序
  2. {
  3. int gap=n;
  4. while(gap>1)//看解释1,判断是否还需排序
  5. {
  6. gap=(gap/3)+1;//保证最后一次gap能取到1,直接插入排序
  7. for(int i=0;i<n-gap;++i)//进入循环,对每一组进行排序
  8. {
  9. int end=i;
  10. int tmp=a[end+gap];
  11. while(end>=0)
  12. {
  13. if(tmp<a[end])
  14. {
  15. a[end+gap]=a[end];
  16. end-=gap;
  17. }
  18. else
  19. {
  20. break;
  21. }
  22. a[end+gap]=tmp;
  23. }
  24. }
  25. }
  26. }
  1️⃣while(gap>1)为什么是大于1,而不是大于等于1?                                                              gap大于1的前一次进入循环时,通过 gap=(gap/3)+1 这一步操作,gap被赋值成了1,故 while 循环无需让 gap>=1, 会多跑一次循环,吃饱了没事儿干。同时进入后gap继续被赋值为1,符合while循环,故会变成死循环
时间复杂度:                                                                                                                          希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定。 这里只提供函数表达式:                                  最坏时间复杂度:F(N,gap)=(1+2+3+...+N/gap)*gap (gap越大,预排越快,预排后越不接近有序)                                                                                                                                     但目前的权威教材里面,希尔排序的时间复杂度大约为O(N1.3)                                               最好时间复杂度:O(N)

希尔排序与直接插入排序的测试效率比较

 1.3.3 选择排序--整体而言最差排序,因为无论什么情况时间复杂度都是O(N2)

概念:

每次从待排序列中选出一个最小值,然后放在序列的起始位置,直到全部待排数据排完即可。
实际上,我们可以一趟选出两个值,一个最大值一个最小值,然后将其放在序列开头和末尾,这样可以使选择排序的效率快一倍。


演示过程动图:在这里插入图片描述


代码实现:

错误代码:

  1. //交换
  2. void Swap(int* px, int* py)
  3. {
  4. int tmp = *px;
  5. *px = *py;
  6. *py = tmp;
  7. }
  8. // 选择排序
  9. void SelectSort(int* a, int n)
  10. {
  11. int begin = 0, end = n - 1;
  12. while (begin < end)
  13. {
  14. int min=begin, max=begin;
  15. for (int i = begin; i <= end; i++)
  16. {
  17. if (a[i] < a[min])
  18. {
  19. min = i;
  20. }
  21. if (a[i] > a[max])
  22. {
  23. max = i;
  24. }
  25. }
  26. Swap(&a[begin], &a[min]);
  27. Swap(&a[end], &a[max]);
  28. begin++;
  29. end--;
  30. }
  31. }

错误原因:

 改进:

  1. //交换
  2. void Swap(int* px, int* py)
  3. {
  4. int tmp = *px;
  5. *px = *py;
  6. *py = tmp;
  7. }
  8. // 选择排序
  9. void SelectSort(int* a, int n)
  10. {
  11. //看解释1
  12. int begin = 0, end = n - 1;
  13. while (begin < end)
  14. {
  15. int min=begin, max=begin;//max和min存放的是对应最值的下标
  16. for (int i = begin; i <= end; i++)
  17. {
  18. if (a[i] < a[min])
  19. {
  20. min = i;
  21. }
  22. if (a[i] > a[max])
  23. {
  24. max = i;
  25. }
  26. }
  27. Swap(&a[begin], &a[min]);
  28. //begin==max时,最大值被换走了,修正一下
  29. if (begin == max)
  30. {
  31. max = min;
  32. }
  33. Swap(&a[end], &a[max]);
  34. begin++;
  35. end--;
  36. }
  37. }

1️⃣begin和max的作用:

与每轮选出的最小值和最大值进行交换,每轮结束后begin+1存放次小,end-1存放次大

2️⃣运行逻辑:


时间复杂度:最坏情况:O(N^2)

                      计算方式:N + N-2 + N-4+...+(每次比前一次少遍历两个)
      最好情况:O(N^2)

                      计算方式:即便是有序的,也要照最坏情况遍历一遍


空间复杂度:O(1)

1.3.4 堆排序

 堆排序可看之间这篇博文----->[堆排]

1.3.5冒泡排序

冒泡排序应该是我们最熟悉的排序了,在C语言阶段我们就学习了冒泡排序。

他的思想也非常简单:

两两元素相比,前一个比后一个大就交换,直到将最大的元素交换到末尾位置。这是第一趟

一共进行n-1趟这样的交换将可以把所有的元素排好。

(n-1趟是因为只剩两个元素时只需要一趟就可以完成)


演示过程动画:


代码实现:

  1. // 冒泡排序
  2. void BubbleSort(int* a, int n)
  3. {
  4. assert(a);
  5. int i = 0;
  6. int flag = 0;
  7. //n-1趟排序
  8. for (i = 0; i < n-1; i++)
  9. {
  10. int j = 0;
  11. //一趟冒泡排序
  12. for (j = 0; j < n - i - 1; j++)
  13. {
  14. if (a[j] > a[j+1])
  15. {
  16. Swap(&a[j], &a[j+1]);
  17. flag = 1;
  18. }
  19. }
  20. //若某一趟排序中没有元素交换则说明所有元素已经有序,不需要再排序
  21. if (flag == 0)
  22. {
  23. break;
  24. }
  25. }
  26. }

时间复杂度:最坏情况:O(N^2)
      最好情况:O(N)
空间复杂度:O(1)

直接插入排序、选择排序、冒泡排序哪个最好?

横向对比:

选择最差,因为无论什么场景下都是O(N2) 

直接插入和冒泡,最坏才是O(N2) ,最好都是O(N)

已经有序数组排序,一样好

对接近有序数组排序,直接插入更好

综合而言,直接插入更好

举例:

1  2  3  4  6  5

冒泡:(n-1)+(n-2)+...+2+1

直接插入:n

1.3.6.快速排序

最终单趟排序达到这样的效果:key处在中间的某个位置,左边的数比key小,右边的数比key大

hoare版本(左右指针法)
思路:
1、选出一个key,一般是最左边或是最右边的。
2、定义一个begin(找大)和一个end(找小),begin从左向右走,end从右向左走。(需要注意的是:若选择最左边的数据作为key,则需要end先走,左右相遇时对应的数比key小;若选择最右边的数据作为key,则需要begin先走,左右相遇时对应的数比key大)。
3、在走的过程中,若end遇到小于key的数,则停下,begin开始走,直到begin遇到一个大于key的数时,将begin和right的内容交换,end再次开始走,如此进行下去,直到begin和end最终相遇,此时将相遇点的内容与key交换即可。(选取最左边的值作为key)
4.此时key的左边都是小于key的数,key的右边都是大于key的数
5.将key的左序列和右序列再次进行这种单趟排序,如此反复操作下去,直到左右序列只有一个数据,或是左右序列不存在时,便停止操作,此时此部分已有序


 单趟演示过程动画:在这里插入图片描述


单趟代码实现:

不完善的写法:

  1. //hoare版本--单趟
  2. //[left,right]
  3. int Partion(int* a, int left, int right)
  4. {
  5. //选左边做key
  6. int key = left;
  7. while (left < right)
  8. {
  9. //右边先走,找小。找到小,结束循环
  10. while (a[right] > a[key])
  11. {
  12. --right;
  13. }
  14. //再左边走,找大。找到大,结束循环
  15. while (a[left] > a[key])
  16. {
  17. left++;
  18. }
  19. //交换两个停下来时对应的数
  20. Swap(&a[left], &a[right]);
  21. }
  22. //交换两个相遇时key和对应的数
  23. Swap(&a[left], &a[key]);
  24. //返回它俩相遇的位置,即key对应的数组下标,left或right都可以
  25. return left;
  26. }

特殊场景一:存在越界问题

在这种情况下,不存在比key小的数,right会一直向左直到越过key对应下标,越界访问

改进方法:在每次比较前进行一次是否出界的判断left<right

特殊场景二:陷入死循环

在这种情况下,不存在比key小也不存在比key大的数,就会卡在原位置,在while中死循环

改进方法:反正和key相等的数放在key左边或右边无所谓,那我们就干脆一点,右边遇到不大于key的数,就a[right] >= a[key],左边遇到不小于key的数,就a[left] >= a[key]

正确的写法:

  1. //hoare版本--单趟
  2. //[left,right]
  3. int Partion(int* a, int left, int right)
  4. {
  5. //选左边做key
  6. int key = left;
  7. while (left < right)
  8. {
  9. //右边先走,找小。找到小,结束循环
  10. while (left < right && a[right] >= a[key])
  11. {
  12. --right;
  13. }
  14. //再左边走,找大。找到大,结束循环
  15. while (left < right && a[left] >= a[key])
  16. {
  17. left++;
  18. }
  19. //交换两个停下来时对应的数
  20. Swap(&a[left], &a[right]);
  21. }
  22. //交换两个相遇时key和对应的数
  23. Swap(&a[left], &a[key]);
  24. //返回它俩相遇的位置,即key对应的数组下标,left或right都可以
  25. return left;
  26. }
时间复杂度:O(N),从左往右和从右往左相加遍历的个数就是N

完整代码实现:

  1. //hoare版本--单趟
  2. //[left,right]
  3. int Partion(int* a, int left, int right)
  4. {
  5. //选左边做key
  6. int key = left;
  7. while (left < right)
  8. {
  9. //右边先走,找小。找到小,结束循环
  10. while (left < right && a[right] >= a[key])
  11. {
  12. --right;
  13. }
  14. //再左边走,找大。找到大,结束循环
  15. while (left < right && a[left] >= a[key])
  16. {
  17. left++;
  18. }
  19. //交换两个停下来时对应的数
  20. Swap(&a[left], &a[right]);
  21. }
  22. //交换两个相遇时key和对应的数
  23. Swap(&a[left], &a[key]);
  24. //返回它俩相遇的位置,left或right都可以
  25. return left;
  26. }
  27. //递归实现多趟
  28. void QuickSort(int* a, int left, int right)
  29. {
  30. //区间不存在的情况
  31. if (left >= right)
  32. {
  33. return;
  34. }
  35. int key = Partion(a, left, right);
  36. //数组分为:[left,key-1] key [key+1,right]
  37. //排序左区间
  38. QuickSort(a, left, key - 1);
  39. //排序右区间
  40. QuickSort(a, key + 1, right);
  41. }

把代码拆开,大概是这个样子: 

 是不是很像二叉树?如果每次都选到中位数,则是个满二叉树

时间复杂度:

  1. 最好的情况,每次返回的key在中间:一共右logN层,每层有N个数。故时间复杂度:
    在这里插入图片描述
    快速排序的过程类似于二叉树其高度为logN,每层约有N个数,如下图所示:在这里插入图片描述
  2. 最坏的情况,本身是有序的,每次返回的key在最左侧: 时间复杂度为O(N2)

递归程序的缺陷:

1.相比循环程序,性能差。(针对早期编译器是这样的,因为针对递归调用,建立栈帧优化不大。现在新编译器优化都很好,递归相比循环,性能差不了多少)

2.递归深度太深,会导致栈溢出


如何解决快排面对有序的选key问题:                                                                                    1.随机选key                                                                                                                               2.三数取中       左边  中间  右边  取不是最大,也不是最小的那个做key
  1. int GetMidIndex(int* a, int left, int right)
  2. {
  3. int mid = (left + right) / 2;
  4. }

1️⃣该种写法有缺陷,当left大于int最大值的一半,right也大于int最大值的一半,则会发生溢出。可以优化成以下形式:

	int mid = left + (right - left) / 2;
  1. int mid = left + ((right - left) >> 1);
  2. //移位运算符的优先级极低,比 + 还低,故要加个()把移位运算给构成个整体
  3. //>>1相当于除以2
  1. void Swap(int* px, int* py)
  2. {
  3. int tmp = *px;
  4. *px = *py;
  5. *py = tmp;
  6. }
  7. int GetMidIndex(int* a, int left, int right)
  8. {
  9. /*int mid = (left + right) / 2;*/
  10. int mid = left + ((right - left) >> 1);
  11. if (a[left] < a[mid])
  12. {
  13. if (a[mid] < a[right])
  14. {
  15. return mid;
  16. }
  17. else if (a[left] > a[right])
  18. {
  19. return left;
  20. }
  21. else
  22. {
  23. return right;
  24. }
  25. }
  26. else //a[left] > a[mid]
  27. {
  28. if (a[mid] > a[right])
  29. {
  30. return mid;
  31. }
  32. else if (a[left] < a[right])
  33. {
  34. return left;
  35. }
  36. else
  37. {
  38. return right;
  39. }
  40. }
  41. }
  42. //hoare版本--单趟
  43. //[left,right]
  44. int Partion(int* a, int left, int right)
  45. {
  46. //三数取中:面对有序最坏情况,变成选中位数做key,变成最好情况
  47. int mini = GetMidIndex(a, left, right);
  48. //将三数取中的结果与left互换,保证key从最左端开始
  49. Swap(&a[mini], &a[left]);
  50. //选左边做key
  51. int key = left;
  52. while (left < right)
  53. {
  54. //右边先走,找小。找到小,结束循环
  55. while (left < right && a[right] >= a[key])
  56. {
  57. --right;
  58. }
  59. //再左边走,找大。找到大,结束循环
  60. while (left < right && a[left] >= a[key])
  61. {
  62. left++;
  63. }
  64. //交换两个停下来时对应的数
  65. Swap(&a[left], &a[right]);
  66. }
  67. //交换两个相遇时key和对应的数
  68. Swap(&a[left], &a[key]);
  69. //返回它俩相遇的位置,left或right都可以
  70. return left;
  71. }
  72. void QuickSort(int* a, int left, int right)
  73. {
  74. //区间不存在的情况
  75. if (left >= right)
  76. {
  77. return;
  78. }
  79. int key = Partion(a, left, right);
  80. //数组分为:[left,key-1] key [key+1,right]
  81. //排序左区间
  82. QuickSort(a, left, key - 1);
  83. //排序右区间
  84. QuickSort(a, key + 1, right);
  85. }

递归调试技巧_一念男的博客-CSDN博客

挖坑法(hoare的变形)

5.2.1 递归
思路:
挖坑法思路与hoare版本(左右指针法)思路类似
1.选出一个数据(一般是最左边或是最右边的)存放在key变量中,在该数据位置形成一个坑
2、还是定义一个L和一个R,L从左向右走,R从右向左走。(若在最左边挖坑,则需要R先走;若在最右边挖坑,则需要L先走)

后面的思路与hoare版本(左右指针法)思路类似在此处就不说了

单趟动图如下:在这里插入图片描述

 


代码实现:

  1. //挖坑版本--单趟
  2. int Partion(int* a, int left, int right)
  3. {
  4. int key = a[left];
  5. int pivot = left;//pivot是坑
  6. while (left < right)
  7. {
  8. //右边找小,放到左边的坑里面
  9. while (left < right && a[right] >= key)
  10. {
  11. --right;
  12. }
  13. //坑交换
  14. a[pivot] = a[right];
  15. //新坑
  16. pivot = right;
  17. //左边找大,放到右边的坑里面
  18. while (left < right && a[left] <= key)
  19. {
  20. ++left;
  21. }
  22. a[pivot] = a[left];
  23. pivot = left;
  24. }
  25. //相遇点挖坑
  26. a[pivot] = key;
  27. return pivot;
  28. }
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/喵喵爱编程/article/detail/926862
推荐阅读
相关标签
  

闽ICP备14008679号