1.快速排序
思路框架:
在有了前面冒泡选择插入希尔排序之后,人们就在想能不能再快一点,我们知道排序算法说人话就是把大的往后放小的往前放,问题就在于如何更快的把大的挪到数组队尾小的挪到数组前面。这里我们先总结一下上集前面几种排序算法,冒泡排序一轮单趟排序相当于只完成了把一个最大的挪到后面;选择排序可以做到一轮单趟把一个最小的往前放一个最大的往后放;插入排序是从队头开始重新建立一个有序数组,一轮单趟完成了插入一个元素;希尔排序是一轮完成了一个组的排序,在前面排序的基础上完成了优化,大的元素只需要两到三步就可以跳到队伍的后端,小的元素也是同理,因此比前面那些排序时间复杂度要快出一个量级。
在有了前面这些排序的基础上,我们开始想单趟排序能不能一步到位,也不要像希尔排序那样分两三步,直接定义一个基准线,找到比key大的和比key小的同时交换,这样既完成了一次性既把大的往后放了也把小的往前面挪了,又只需要交换这一步就完成了元素挪动,这也是交换排序种类的魅力之一。接下来基于这个思想,我们介绍三种常见版本的写法
版本一:发明者hoare版本
我们先如上图定义最左边的元素为key也就是基准线(图中的红色),定义一个左一个右两个变量来同时往中间走,right找比基准线小的left找比基准线大的,找到之后就交换left和right,重复此动作知道left和right相遇停止。最后再交换key和left,完成一轮单趟排序。
下面我们一步一步从最基础的想法开始,跳过经典的坑逐渐丰富修正细节完成实现:
void PartSort(int* a, int left, int right)
{int key = left;while (left < right){while (a[right] > a[key]){--right;}while (a[left] < a[key]){++left;}swap(&a[left], &a[right]);}swap(&a[left], &a[key]);
}
按照上面的思想我们完成了第一步最基础的实现,但是这里有一个潜在的问题,也就是死循环和等号问题。如果我们给出如下数组:
[6 1 6 7 9 6 10 8]
当left和right同时走到中间两个6的时候,就会出现死循环走不下去的情况,所以我们要加上等于号
修改后版本如下:
void PartSort(int* a, int left, int right)
{int key = left;while (left < right){while (a[right] >= a[key]){--right;}while (a[left] <= a[key]){++left;}swap(&a[left], &a[right]);}swap(&a[left], &a[key]);
}
这里又出现了新的潜在问题,那就是由于key值的选择越界问题,例如如果我们修改一下刚刚的数组例子,令第一个元素等于0,也就是key=0
[0 1 6 7 9 6 10 8]
这个时候right从右边一直走会一直找不到比0小的数,从而走出了数组导致越界访问的问题,所以我们还要增加一个控制越界的判断条件。
修改版本如下:
void PartSort(int* a, int left, int right)
{int key = left;while (left < right){while (left<right && a[right] >= a[key]){--right;}while (left<right && a[left] <= a[key]){++left;}swap(&a[left], &a[right]);}swap(&a[left], &a[key]);
}
这里我们需要注意判断顺序,一定是先判断是否越界再去访问,c语言对于越界访问是一种类似于抽查的行为,不一定能够检测的到,但是一旦我们这里换成vector类的话,检测就变得很严格,无论是访问还是干啥都能检测到。至此我们的单趟排序就已经完成了,下面我们思考两个问题:
1.为什么两个指针相遇的位子的数一定比key小
这里其实我们在问的是最后和left和key交换这一动作的合理性,我们分两种情况来讨论这个问题:
情况一:如果最后是right在移动和left相遇了,这里又分为两种情况
1.left一次没有动过,right直接从数组尾部一直找到left,这个时候left的位置就是key的位置,所以满足left位置一定比key小
2.前面已经经过一些轮次的交换了,这时候right再往左找比key小的时候遇到left,由于前面已经交换过了,所以left所在的位置已经是前面轮次right找到的比key小的数交换过来了,所以也满足left这个位置一定比key小
情况二:如果最后是left在移动和left相遇了
由于是先走的right,而right已经停下来了,所以肯定满足比key小,这个时候和left相遇也满足一定比key小
2.左右两边哪个先动有关系吗
根据上面的逻辑我们不难看出下面的规律:
1.如果左边作为key,那就要右边先走,保障了相遇位置的值比key小
2.如果右边作为key,那就要左边先走,保障了相遇位置的值比key大
至此我们完成了单趟排序的所有细节问题,我们来考虑整体的排序之前先想想单趟排序完成了啥:
一次单趟排序所完成的任务是:
1.key就已经排好了,就在它现在的位子以后都不用动了
2.如果左区间有序,右区间有序那整个数组就是有序了(递归分治思想)
所以我们根据这个思路给出整体排序的实现:
void QuickSort(int* a, int begin, int end)//这里传入区间的开始和结束的位置,方便递归
{if (begin >= end)//两种情况1.只有一个值2.区间不存在return;int key = PartSort(a, begin, end);QuickSort(a, begin, key - 1);QuickSort(a, key + 1, end);
}
下面引用一下网上的动图演示:
版本二:挖坑法
在完成了初始hoare版本的实现以后,不难看出这种实现坑有点多,后来又出现一种相对理解实现简单一点的挖坑法。我们先给出引用的动图,大家看图就能理解了,就不再作过多文字阐述:
下面先给出实现:
int PartSort2(int* a, int left, int right)
{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;
}
需要注意的是这种挖坑法和第一种hoare版本的单趟排序结果可能不同,但其本质都是交换前后元素,时间复杂度是一样的。由于基本逻辑和前面基本相似所以不做过多解释。
版本三:前后指针法
这种方法本质上有别于前面两种,也是相对来说不好理解的一种方式,我们来具体解释一下。
这里cur指针是无论什么情况都往前走一格,用来找到比key小的数,找到之后和prev交换,如果cur和prev重合则无事发生,只有当cur走过了比key大的数的时候,再遇到比key小的数的时候发生交换,此时他们之间的数都是比key大的数,这个时候的交换就相当于把大的数一路翻滚到后面
前后指针法与前后交换法的区别:
特性 | 前后指针法 | 前后交换法 |
---|---|---|
扫描方式 | 单向(左到右) | 双向(两端向中间) |
基准值位置 | 通常选择最后一个元素 | 通常选择第一个元素 |
交换方式 | 只交换小于基准值的元素与大于基准值的元素 | 直接交换找到的不符合基准值的两个元素 |
操作次数 | O(n) | O(n) |
适用场景 | 通常适用于快速分区且实现简洁 | 常用于理解双向扫描,适合手动模拟操作 |
稳定性 | 不稳定 | 不稳定 |
int PartSort3(int* a, int left, int right)
{int prev = left;int cur = left+1;int key = left;while (left <= right){if (a[cur] < a[key] && ++prev != cur){++prev;swap(&a[prev], &a[cur]);}cur++;}swap(&a[prev], &a[key]);key = prev;return key;
}
非递归栈版本
例如还是这个例子,在我们实现非递归版本之前先搞清楚递归版本的顺序,再模拟成栈的顺序即可。下面是递归的区间顺序:区间 0-9
->区间 0-6
->区间 8-9
->区间 0-3
->区间 5-6
->区间 0-2
->区间 0-1。因此模拟栈的思路就是每次从栈里面拿出来栈顶的一个区间,然后再让该区间的左右区间入栈。下面给出实现:
void QuickSortIterative(int* a, int n) {std::stack<std::pair<int, int>> stack;stack.push({0, n - 1}); // 初始区间while (!stack.empty()) {int left = stack.top().first;int right = stack.top().second;stack.pop();if (left < right) {int pivotIndex = Partition(a, left, right);// 将右区间压入栈中stack.push({pivotIndex + 1, right});// 将左区间压入栈中stack.push({left, pivotIndex - 1});}}
}
1. 初始化栈
- 创建一个
std::stack
来模拟递归过程。 - 初始时,将整个数组的区间
[0, n-1]
压入栈中。栈中存储的每个区间用一对整数表示,其中第一个整数是区间的起始索引left
,第二个整数是区间的结束索引right
。
2. 循环处理栈中的区间
-
进入
while
循环,只要栈不为空,就表示还有未排序的区间需要处理。-
弹出栈顶区间:
- 使用
stack.top()
获取栈顶元素,分别取出当前区间的起始和结束位置left
和right
。 stack.pop()
将这个区间从栈中移除,因为我们即将处理它。
- 使用
-
检查区间是否有效:
- 检查
left < right
,确保当前区间至少包含两个元素。如果left >= right
,则说明这个区间已经排序好(一个元素或空区间),不需要进一步处理。
- 检查
-
将子区间压入栈:
- 将左右两个子区间压入栈中,继续处理。注意先将右子区间
[pivotIndex + 1, right]
压入栈,再将左子区间[left, pivotIndex - 1]
压入栈。这样栈顶的元素总是左子区间,确保以“先处理左侧,再处理右侧”的顺序执行,和递归版本一致。
- 将左右两个子区间压入栈中,继续处理。注意先将右子区间
-
极端情况与优化版本:
其实我们现在这种版本的快排在绝大多数情况下是比其他排序算法要快的,因此各大语言的sort算法也基本是按照快排来写的,但是唯一的缺陷就是怕遇到极端情况,例如数组在接近有序的情况下就显的非常慢甚至不如插入排序。在有序情况下就会出现如下情况,每次选key选是最小(最大)的数,就造成了每层只能排好一个数O(N),需要排N层,很明显这是一个等差数列,通过公式得出最坏的情况时间复杂度是
因此我们给出解决方案有两种:1随机数取key 2三数取中作为key
随机数只需要添加一行即可:
int pivotIndex = left + rand() % (right - left + 1);
三数取中这里我们添加一个取中函数:
int MedianOfThree(int* a, int left, int right) {int mid = left + (right - left) / 2;// 对 left, mid, right 三个元素排序,选择中间值作为基准if (a[left] > a[mid]) std::swap(a[left], a[mid]);if (a[left] > a[right]) std::swap(a[left], a[right]);if (a[mid] > a[right]) std::swap(a[mid], a[right]);// 将中间值移到最右边,作为基准std::swap(a[mid], a[right]);return a[right];
}
小区间插入优化:
我们还可以做进一步优化,我们知道二叉树最后一层叶子结点占了整个树的结点个数大部分,这里递归调用也是如此,因此会造成递归深度太深的问题,因此为了解决这个问题我们可以设置一个阈值,如果小于该阈值的时候直接调用插入排序即可,插入排序在接近有序的情况下还是很好用的。
if (right - left <= threshold)
{InsertionSort(a, left, right);continue;
}
至此快速排序的所有内容结束!
2.归并排序
还是先给出引用的动图方便理解:
可以看到归并排序的思想也是分治,想要整体有序,先让左区间右区间有序,以此递归下去。和快排不同的是,快排是自顶向下的,而归并是自下而上的,先分到不能再分为止然后再一层一层往上合并。下面给出具体的实现:
void PartMergeSort(int* a, int begin, int end, int* tmp)
{if (begin >= end)return;int mid = (begin + end) / 2;PartMergeSort(a, begin, mid, tmp);PartMergeSort(a, mid + 1, end, tmp);int beginl = begin, endl = mid;//左区间int beginr = mid + 1, endr = end;//右区间//合并两个有序数组int index = begin;while (beginl <= endl && beginr <= endr){if (a[beginl] < a[beginr]){tmp[index++] = a[beginl++];}else{tmp[index++] = a[beginr++];}}//解决某一个已经结束的情况,处理剩下的数据//由于不知道哪个先结束所以只能两个都判断一遍while (beginl <= endl)tmp[index++] = a[beginl++];while (beginr <= endr)tmp[index++] = a[beginr++];//最后拷贝回原数组memmove(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}void MergeSort(int* a, int n)
{int* tmp = (int*)malloc(sizeof(int) * n);PartMergeSort(a, 0, n - 1, tmp);free(tmp);
}
需要注意的是,这里由于我们要申请临时的拷贝空间,所以定义了一个子函数。
至此常见的排序算法结束力,感谢您看到这里!