排序--数据结构初阶(4)(C/C++)
文章目录
前言
这是数据结构初阶的最后一期,虽然来说在C++的库函数里面有sort函数可以代替这里所有的方法,并且时间复杂度也是优于他们的,但是sort函数是由他们写出来的,因此,还是是有必要学习一下的
理论部分:
这里的代码实现都是按升序来的 排序的话建议先写单趟再写整体 这些排序在两数相等的时候一般是不进行操作的(一般这么写) 1.直接插入排序
就是目前在最后的那个数跟前面每个数比,看看要插哪
时间复杂度:O(n2)
最好的情况下是O(n)
在小段小段有序时有极大的优势(相对于选择排序跟冒泡排序)
代码实现: void InsertSort(int* a, int n) { for (int i = 0; i < n-1; i++) { int end = i; int tmp = a[i+1]; // 将tmp插入到[0,end]区间中,保持有序 while (end >= 0) { if (tmp < a[end]) { a[end + 1] = a[end]; --end; } else { break; } } a[end + 1] = tmp; } } 2.希尔排序
主要思想:先预排序,再直接插入排序(也就是对插入排序的一种优化)
这里的预排序就是以gap为间隔来分组去排,首个为i的话,那就是i,i+gap,……来先排一波
这里的gap的计算有些人取得gap/=2;有些人取得gap = gap/3+1
个人比较喜欢gap/=2
时间复杂度:O(n1.3) n是多少个数
void ShellSort(int* a, int n) { int gap = n; while (gap > 1) { gap /= 2; for (int i = 0; i < n - gap; i++) { int end = i; int tmp = a[i + gap]; while (end >= 0) { if (tmp < a[end]) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = tmp; } } } 3.直接选择排序
核心思想:遍历一遍选出当前遍历中最小的和最大的,把最小的放在头,最大的放在尾,然后这两个位置固定,重复操作即可
时间复杂度:O(n2)
代码实现: void SelectSort(int* a, int n) { int left = 0, right = n - 1; while (left < right) { int mini = left, maxi = left; for (int i = left + 1; i <= right; i++) { if (a[i] < a[mini]) { mini = i; } if (a[i] > a[maxi]) { maxi = i; } } Swap(&a[left], &a[mini]); // 如果left和maxi重叠,交换后修正一下 if (left == maxi) { maxi = mini; } Swap(&a[right], &a[maxi]);//这里的Swap是自己之前实现的那个 //不是C++的函数那个swap,所以这里传的是地址 ++left; --right; } } 4.堆排序
时间复杂度:O(n*logn)
void AdjustDown(int* a, int n, int parent) { 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) { // 建堆 -- 向下调整建堆 -- O(N) for (int i = (n - 1 - 1) / 2; i >= 0; --i) { AdjustDown(a, n, i); } //给数组排序 int end = n - 1; while (end > 0) { Swap(&a[end], &a[0]); AdjustDown(a, end, 0); --end; } } 5.冒泡排序
核心思想:前后俩个数依次比较,每次都选出最大的数到最后,然后end–;
时间复杂度:O(n2)
最好的情况下是O(n)
这个排序的话也就适合教学用用,实际中基本不会用(但也要记)
void BubbleSort(int* a, int n) { for (int j = 0; j < n; j++) { bool exchange = false; for (int i = 1; i < n-j; i++) { if (a[i - 1] > a[i]) { Swap(&a[i - 1], &a[i]); exchange = true; } } if (exchange == false) { break; } } } 6.快速排序
时间复杂度:O(n2)(没有加三数取中或随机取key优化时)
最好情况下是O(n*logn)
空间复杂度:O(logn)
快速排序有三种方法:霍尔(Hoare),挖坑法和前后指针法
三数取中: int GetMidNumi(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; } } } 随机取key: int randi = left + (rand() % (right - left)); Swap(&a[left], &a[randi]); 总结:最好使用三数取中 前后指针法的核心:
1.cur找到比key小的值,++prev,cur和prev位置的值交换,++cur
挖坑法的核心思想:
1.一边找到其对应的符合的值就把坑移到哪,然后轮到无坑的那个去找
2.最后把坑的位置里面的值填成key
Hoare的核心思想:
1.先找一边,一边找到了轮到另一边找
2.然后把相遇位置的值改成key
总结:实际过程中比较推荐使用前后指针法,不容易出错。但是那两种方法也要掌握
// Hoare int PartSort1(int* a, int left, int right) { // 这里以三数取中举例 int midi = GetMidNumi(a, left, right); if (midi != left) Swap(&a[midi], &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[keyi], &a[left]); keyi = left; return keyi; } // 挖坑法 int PartSort2(int* a, int left, int right) { // 这里以三数取中举例 int midi = GetMidNumi(a, left, right); if (midi != left) Swap(&a[midi], &a[left]); //这里最好换行一下,不然可读性差 int key = a[left]; int hole = left;//->注意刚开始坑的位置 while (left < right) { // 右边找小 while (left < right && a[right] >= key) --right; a[hole] = a[right]; hole = right; // 左边找大 while (left < right && a[left] <= key) ++left; a[hole] = a[left]; hole = left; } a[hole] = key; return hole; } // 前后指针法 int PartSort3(int* a, int left, int right) { // 这里以三数取中举例 int midi = GetMidNumi(a, left, right); if (midi != left) Swap(&a[midi], &a[left]); int keyi = left; int prev = left; int cur = left + 1; while (cur <= right) { if (a[cur] < a[keyi] && ++prev != cur)//->这里++prev!=cur非常巧妙 Swap(&a[cur], &a[prev]); ++cur; } Swap(&a[prev], &a[keyi]); keyi = prev; return keyi; } void QuickSort(int* a, int left, int right) { if (left >= right) return; int keyi = PartSort3(a, left, right);//这里以第三种举例 QuickSort(a, left, keyi - 1); QuickSort(a, keyi+1, right); } 相比上面那个,下面这个进行了小区间优化: void QuickSort(int* a, int left, int right) { if (left >= right) return; // 小区间优化--小区间直接使用插入排序--优化时间复杂度 if ((right - left + 1) > 10)//这个不一定取10,但是一般是取10左右是最好的 { int keyi = PartSort3(a, left, right); QuickSort(a, left, keyi - 1); QuickSort(a, keyi + 1, right); } else { InsertSort(a+left, right - left + 1);//插入排序 } } 在用快速排序时要注意:(Hoare和挖坑法要用到)
要左边做key,让右边先走,这样才能保证相遇位置的值比key要小或者相遇位置就是现在key所在的位置
证明:
相遇的几种情况:(R表示right,L表示left)
1.R找到小,L找大没有找到->L遇到R
2.R找小,没有找到->直接遇到L
…………之后就好证了
类似道理,右边做key,左边先走,相遇位置就比key要大
(降序的话,把这个改一下就行,但是还是要左边取大,右边取小)
快速排序的非递归形式: 核心:1.栈里面取一段区间,单趟排序 2.单趟分割子区间入栈 3.子区间只有一个值或者不存在就不入栈 void QuickSortNonR(int* a, int left, int right) { ST st; STInit(&st); STPush(&st, right); STPush(&st, left); while (!STEmpty(&st)) { int begin = STTop(&st); STPop(&st); int end = STTop(&st); STPop(&st); int keyi = PartSort3(a, begin, end); // [begin,keyi-1] keyi [keyi+1, end] if (keyi + 1 < end) { STPush(&st, end); STPush(&st, keyi+1); } if (begin < keyi-1) { STPush(&st, keyi-1); STPush(&st, begin); } } STDestroy(&st); } 递归改非递归是必备技能:
1.面试官会问
2.工作时如果用递归导致栈溢出了,需要改非递归
递归改非递归的常用方法:用栈,栈的特性很符合改递归成非递归(这个方法也有可能不行)
归并排序
时间复杂度:O(n*logn) n为元素个数
空间复杂度:O(n)
核心思想:把区间一直分,分到有序之后;再两个有序区间比较,小的尾插入tmp数组中,这样依次扩大有序区间
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); // [begin, mid] [mid+1,end]归并 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, sizeof(int) * (end - begin + 1)); //这里可以用for循环替代(memcpy一般都可以这样替代) //每次归并完了记得拷贝回去 } void MergeSort(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); if (tmp == NULL) { perror("malloc fail\n"); return; } _MergeSort(a, 0, n - 1, tmp); free(tmp); } 改成非递归: gap是归并中,每组数据的个数 void MergeSortNonR(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); if (tmp == NULL) { perror("malloc fail\n"); return; } int gap = 1; while (gap < n) { for (int i = 0; i < n; i += 2 * gap) { // [begin1,end1][begin2, end2],是这两个一起排 int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2 * gap - 1; //处理越界情况 if (end1 >= n || begin2 >= n) { break; } if (end2 >= n) { end2 = n - 1; } int j = i; 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+i, tmp+i, sizeof(int) *(end2-i+1)); } gap *= 2; } free(tmp); } 非比较排序
有1.计数排序 2.基数排序 3.桶排序 但是2和3在实际中没有应用价值,校招也不考,就不讲了
计数排序
适用范围:适合范围集中的整形数组排序(负数也行)
注意:要是整形才行,比如字符串,浮点数那些就不行
时间复杂度:O(N+range)–这样写好 空间复杂度:O(range)
核心思想:1.先统计每个数据出现的次数(相对位置法) 2.然后再排序即可
void CountSort(int* a, int n) { int max = a[0], min = a[0]; for (int i = 1; i < n; i++) { if (a[i] > max) { max = a[i]; } if (a[i] < min) { min = a[i]; } } int range = max - min + 1; int* countA = (int*)malloc(sizeof(int) * range); if (countA == NULL) { perror("malloc fail\n"); return; } memset(countA, 0, sizeof(int) * range); // 计数 for (int i = 0; i < n; i++) { countA[a[i] - min]++;//这里用的相对位置的那个方法,绝对位置那个不好用(而且负数不行) } // 排序 int j = 0; for (int i = 0; i < range; i++) { while (countA[i]--) { a[j++] = i + min; } } free(countA); } 排序里面对于稳定和不稳定概念的理解:(计数排序一般不谈这个)(会考多久不稳定) 稳定排序:如果排序算法能够保证相等的元素在排序后的顺序与排序前的顺序相同,则称该算法是稳定的 也就是假如两个都是5,在排序过程中第一个5永远在第二个5前面才行(注意是永远!) 注意!!!:稳定不稳定,是一种就事论事的问题;但是大家都会按照最初版本(自己这写的)去定型。 如果给出了代码,那就要按具体情况去分析 1.冒泡排序--稳定 2.选择排序--不稳定 场景: 本为2(1) 2(2) 1 1之后会变成2(2) 1 1 2(1) 3.直接插入排序--稳定 4.希尔排序--不稳定 场景:相同的数据可能会被分到不同的组去进行预排 5.堆排序--不稳定 场景: 9 9 8 7 8 (图在下面) 6.归并排序--稳定 7.快速排序--不稳定 场景:看下面的图 

作业部分
对n个元素执行快速排序,需要的额外空间的大小为( C ) A.O(1) B.O(n) C.O(logn) D.O(nlogn) 这里的递归里面没有占据空间,但是一次递归本身就会占用O(1)空间 (注意这里的递归的操作对象是那n个元素,这n个元素不需要每次递归都开辟) 下列选项中,(如果是升序的话)不可能是快速排序第2趟排序后的结果的是(C) A.2 3 5 4 6 7 9 B.2 7 5 6 4 3 9 C.3 2 5 4 7 6 9 D.4 2 3 5 7 6 9 技巧:因为第二趟完了,则应该有两个数的左边都比他小,右边都比他大 注意:没有说明的话,不能默认是升序 下列排序方法中,哪一种是不稳定的(C) A.直接插入排序 B.归并排序 C.选择排序 //当然选择排序也可以变成稳定的,只要保证相同的值选择第一个就可以 //但是默认写的那一种选择不行 D.冒泡排序 下列关于归并排序的说法中正确的是(C) A.归并排序不需要辅助空间 B.归并排序的时间复杂度是O(logn) C.归并排序是稳定排序 D.归并排序的操作方式类似二叉树的前序遍历//这个应该是后序遍历 在询问有关排序的问题时,一般默认是还没优化的(区分优化和不同实现方法),也没有递归改成非递归的 快速排序初始数据集的排列顺序对算法的性能也有影响 拓展:其他排序都是内排序(在内存中排序),归并排序既可以是内排序,也可以是外排序(在磁盘中排序) 工程级别的代码,逻辑性是最重要的 分类讨论可以简化问题!