四、归并排序(尾插法的再次邂逅)
1.归并排序
1.归并排序思想:
归并都是用于两端序列的归并,我们数组如果要使用归并来进行排序的话,毫无疑问,也是需要将数组分为两段,将他们合并到一个tmp数组里面,然后将tmp数组里面的内容归到原来的数组arr里面。所以思路还是很简单的,因为我们以前链表就做过两个链表合并为一个链表这样的题,所以这里我们在理解上应该是没有什么问题的。
合并的前提是两段序列得是有序的,但没有这样的条件啊,怎么办呢?我们想到了递归,一个数当然可以认为是有序的,我们可以将两个数进行归并,我们将这两个数可以看作两段有序序列,这样便可以解决问题了。
void _MergeSort(int* arr, int begin, int end, int* tmp) { if (begin >= end) { return; } int mid = (begin + end) / 2; _MergeSort(arr, begin, mid , tmp); _MergeSort(arr, mid + 1, end, tmp); int i = begin; int begin1 = begin, end1 = mid; int begin2 = mid + 1, end2 = end; while (begin1 <= end1 && begin2 <= end2) { if (arr[begin1] <= arr[begin2])//等于时我们取前一个,保证算法的稳定性 { tmp[i++] = arr[begin1++]; } else { tmp[i++] = arr[begin2++]; } } while (begin1 <= end1) { tmp[i++] = arr[begin1++]; } while (begin2 <= end2) { tmp[i++] = arr[begin2++]; } memcpy(arr+begin, tmp+begin, sizeof(int) * (end - begin + 1)); } void MergeSort(int* arr, int begin, int end) { int* tmp = (int*)malloc(sizeof(int) * (end - begin)); _MergeSort(arr, begin, end-1, tmp); free(tmp); tmp = NULL; }
2.代码细节说明:
a. 因为我们递归到子区间的时候,将tmp拷贝回arr拷贝的位置并不始终都是从tmp开头的位置拷贝的,有可能我们左边区间处理完毕,开始处理右半区间的时候,尾插到tmp时也是从tmp的右半区间开始处理的,所以拷贝时拷贝的位置应该是arr+begin和tmp+begin
b. 在tmp尾插时,同样容易犯错误的一个地方就是,习惯将i定义为0,和上面相同,我们处理的区间不仅仅只有左半区间,在处理右半区间的时候,也应该从begin下标位置开始处理,因为begin代表的是区间的头位置,我们尾插时当然也要从tmp相对应arr的位置开始尾插
c. 我在写这个代码时,受前面快排的影响,在定义end1时,不小心定义成mid-1了,这样就把mid位置的元素落下了,结果代码就挂了,我们递归可不是前面key那样啊,每一个元素都不可以落下的,要真正实现归并,每一个元素都要参与尾插的过程。
2.非递归—归并排序(最大的大佬在这儿呢)
1.非递归排序思想
如果你要用栈或队列实现非递归的话,其实是非常难搞的,因为你入栈左或右区间之后,想要继续,那肯定得pop掉原来你入进去的区间,然后重新去入新的子区间,正是由于你pop掉原来的区间,也就导致了无法进行两个大的区间合并,所以利用这两个数据结构是无法完成我们的操作的。
我们可以不借助其他数据结构,直接对序列进行归并排序。
定义一个rangeN代表需要归并的两个区间内各自的数据个数,从1开始,因为1个数据我们可以认为是有序的,然后就两个两个区间开始进行归并,每遍历完一遍数组之后,开始下一轮的归并,我们这时候增大rangeN,保证最后几轮的时候,将两个大区间直接归并成一个完整的序列,这时候我们的while循环就可以停止了,因为序列的最大两个区间已经归并成一个完整的升序序列了。
//写法一: void MergeSortNonR(int* arr, int n)//栈和队列进行非递归都非常不好搞 { int* tmp = (int*)malloc(sizeof(int) * n); if (tmp == NULL) { perror("malloc fail"); exit(-1); } //rangeN代表归并的每组数据个数,从1开始,因为一个数认为是有序的,可以直接进行归并。 int rangeN = 1; while (rangeN < n) { for (int i = 0; i < n; i += 2 * rangeN) { //这里面要实现两个组数据的归并 // [begin1,end1][begin2,end2] 归并 int begin1 = i, end1 = i + rangeN - 1;//归并区间1 int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;//归并区间2,加上2倍的rangeN之后,就跳到下一组了,-1正好就是本组的最后一个元素 printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2); int j = i; //end1 begin2 end2越界 if (end1 >= n) { //修正区间 ->拷贝数据可以整体拷贝,也可以归并每组拷贝,因为无论哪种,tmp中的数据不会存在随机值的情况 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 (arr[begin1] <= arr[begin2])//加个等号,遇到相同数字,取前一个。 { tmp[j++] = arr[begin1++]; } else { tmp[j++] = arr[begin2++]; } } while (begin1 <= end1) { tmp[j++] = arr[begin1++]; } while (begin2 <= end2) { tmp[j++] = arr[begin2++]; } //强烈推荐归并一点,我们就往原数组拷贝回去一点,别一次性就全都拷贝回去 memcpy(arr + i, tmp + i, sizeof(int) * (end2 - i + 1)); //拷贝的个数是2*rangeN是不可以的,因为数据有可能不是恰好分成整数个组,我们有时可能只拷贝一个数据。 } //也可以整体归并完了,再统一将tmp数组拷贝回arr里。 //memcpy(arr, tmp, sizeof(int) * n);//这就是整体拷贝。 rangeN *= 2; } free(tmp);//防止内存泄露 tmp = NULL; } //rangeN是归并的每组的数据个数,然后我们两两为一组,进行归并。 //然后我们每次让range扩大二倍,最后的两组进行归并,结束后,我们就可以得到一个完整的归并序列了。 //先前有问题的逻辑: //但到了10个测试数据的时候,由于他不是2的n次方个,无法被两两分成一个归并组,出现越界访问。例如rangeN==2时,两两分为一组,肯定有两个 //数据被落下,无法和其他数据凑成一个归并组。除了这样的情况之外,还有很多其他的越界情况,我们需要一一分析 //如果你是修正了区间,以防越界访问的话,那既可以整体拷贝,也可以部分拷贝。 //如果你遇到越界访问,选择跳出归并循环的话,那就不可以整体拷贝,只能部分拷贝。 //写法二: void MergeSortNonR(int* arr, int n)//栈和队列进行非递归都非常不好搞 { int* tmp = (int*)malloc(sizeof(int) * n); if (tmp == NULL) { perror("malloc fail"); exit(-1); } //rangeN代表归并的每组数据个数,从1开始,因为一个数认为是有序的,可以直接进行归并。 int rangeN = 1; while (rangeN < n) { for (int i = 0; i < n; i += 2 * rangeN) { //这里面要实现两个组数据的归并 // [begin1,end1][begin2,end2] 归并 int begin1 = i, end1 = i + rangeN - 1;//归并区间1 int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;//归并区间2,加上2倍的rangeN之后,就跳到下一组了,-1正好就是本组的最后一个元素 printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2); int j = i; //end1 begin2 end2越界 if (end1 >= n) { //如果你不修正直接走break,那你是不能整体拷贝的,因为tmp中有一部分值会是随机值, break; } else if (begin2 >= n) { break; } else if (end2 >= n) { end2 = n - 1;//对end2进行修正 } while (begin1 <= end1 && begin2 <= end2) { if (arr[begin1] <= arr[begin2])//加个等号,保证归并排序是稳定的,遇到相同数字,我们取前一个 { tmp[j++] = arr[begin1++]; } else { tmp[j++] = arr[begin2++]; } } while (begin1 <= end1) { tmp[j++] = arr[begin1++]; } while (begin2 <= end2) { tmp[j++] = arr[begin2++]; } memcpy(arr + i, tmp + i, sizeof(int) * (end2 - i + 1)); //拷贝的个数是2*rangeN是不可以的,因为数据有可能不是恰好分成整数个组,我们有时可能只拷贝一个数据。 } rangeN *= 2; } free(tmp);//防止内存泄露 tmp = NULL; }
2.代码细节说明:
a. 正因为分成的归并组的情况是未知的,所以我们才会出现多种多样的越界情况,又由于每种越界情况不同,所以我们要单独拉出来每一种情况进行解决。
当我们有8个数据,并且没有对越界情况进行处理时,可以看到,有许多归并区间存在越界情况,所以我们不得不进行分批处理。
b. 我个人还是比较推荐前两种越界情况break解决的,因为这样还是比较省事,但是拷贝的时候我们只能在for循环里面进行拷贝,也就是部分拷贝,如果在for循环外面进行拷贝的话,arr中就会出现随机值,因为你break的时候,其实并没有给tmp数组进行赋值,所以tmp数组中的部分值是随机值。c. 再给tmp数组进行尾插时,我们需要一个新的循环变量j,如果你不小心用i的话(没错就是博主本人😢😢😢),for循环的条件就被你改了,程序一定会出问题。
五、非比较排序—计数排序
1.计数排序思想:
我们利用一个统计数组来统计原数组中每个数字出现的次数,统计数组中非0元素的下标就是我们arr中所出现的元素。
所以我们最后再遍历一遍统计次数的数组,将每个元素赋值到arr数组即可。
总共需要遍历三遍数组。
第一遍:遍历arr数组,确定arr数组中的最大值和最小值,以此来确定一个范围,arr中的数据基本都在哪个范围,待会儿我们可以利用相对映射,通过countA数组的下标取到arr中出现的数据。
第二遍:遍历arr数组,将每个数字出现的次数统计出来并存到countA数组里面
第三遍:遍历countA数组,利用下标+相对映射的关系,从左到右,也就是从小到大将每个元素重新赋值到arr数组里面去。
void CountSort(int* arr, int n) { int max = arr[0], min = arr[0]; for (int i = 0; i < n; i++) { if (arr[i] > max) { max = arr[i]; } if(arr[i]<min) { min = arr[i]; } } int range = max - min + 1;//[0,2]左闭右闭的情况下,数据的个数一定是要+1的。如果是开区间自然不用,(0,2]数据个数为2。 //统计次数 int* countA = (int*)calloc(range, sizeof(int)); if (countA == NULL) { perror("malloc fail\n"); exit(-1); } for (int i = 0; i < n; i++)//再次遍历arr数组,统计其中数据出现的次数 { countA[arr[i] - min]++;//这里就统计出数据出现的次数了 } //遍历统计次数的数组,对原数组进行计数排序 int j = 0; for (int i = 0; i < range; i++) { while (countA[i]--)//n--走n次,--n走n-1次。 { arr[j++] = i + min; //这里又犯了一个严重的错误,利用了循环变量i,可千万不敢用循环变量i啊,要不然循环的情况就被你改了,代码又出问题了,你妹的。 } } free(countA); countA = NULL; } //时间复杂度:O(N+range),虽然有两层循环嵌套在一起,但不是×的关系,其实是加的关系。 //空间复杂度:O(range) //适合数据范围集中,也就是range小 //只适合整型数据的排序,不适合浮点数、字符串等类型。 //如果数据较为集中的话,计数排序还是非常牛逼的,因为N大于range的话,我们的时间复杂度就是O(N),老天爷,这性能不妥妥的很强么。
2.代码细节说明:
a. 最后一个遍历,对arr进型计数排序时,由于我们用的是相对映射,所以给arr赋值时,countA的每一个下标都要加上min来给arr进行赋值。
b. 在开辟countA数组时,对于它的大小,大家一定要牢记,左闭右闭开辟空间时一定要+1,就比如100到106有几种数字呢?答案是7种数字,所以我们在开辟空间时也要开辟max-min+1大小的空间。
六、排序总结
排序的稳定性:
a. 有很多人初次见到稳定性肯定想到的是,某个排序算法对于多种多样的数据分布能否呈现较稳定的效率,也就是某个算法能否在多种多样的数据里面仍然保持着高效的算法性能。包括我刚开始也是这么理解的,但其实并不是这样的。
b.定义: 如果某个排序算法在排序过程中,没有打乱原有序列中相同数据的相对位置关系,我们就称这个算法是稳定的。
七、时空复杂度
1.时间复杂度
时间是一去不复返的,累计的
时间复杂度算的就是基本操作的执行次数。
递归情况下就是算出每一个函数栈帧中的执行次数并且累加起来。
2.空间复杂度
空间是可以重复利用的,不累计
空间复杂度算的就是,这个算法在执行过程中所占用的最大存储空间。一般情况下都是O(1),
我这里重点想说的是二叉树的空间复杂度,以前我一直以为画出来的一棵二叉树,它有多少个节点这个算法就要开辟多少个函数栈帧,但实际上不是这样的,他是边销毁边开辟函数栈帧,并且重新开辟函数栈帧利用的也是之前销毁的那块儿空间,这也就是为什么说空间是可以重复利用的,不累计。至于画出来的二叉树,那只是逻辑结构,具体的物理结构也是边销毁边开辟,二叉树只是我们人为想出来的逻辑结构,方便我们学习。
所以一棵二叉树的空间复杂度就是O(logN),因为空间最大最大的时候,也就是顶满了,使用的也是二叉树高度个函数栈帧,后面的每一棵子树或节点都是利用之前递归到最深开始返回时,这一过程中所开辟的函数栈帧。
空间复杂度算的就是利用空间的峰值,也就是递归最深总共开辟logN个函数栈帧,那这颗二叉树的空间复杂度就是O(logN)。
比如某个算法空间复杂度是O(1),另一个算法是O(N),因为峰值那一下所需空间是O(N),如果栈很小或者其他条件下,那O(1)的算法就可以跑过,O(N)的就无法跑过。