排序算法快速排序_快速排序稳定吗

排序算法快速排序_快速排序稳定吗这里介绍了排序算法的各种场景,以及相应的优化

目录

排序算法

十大排序算法总览

image-20211203144219453

快速排序(重点)

基本思路

  • 快速排序每一次都排定一个元素(这个元素被放在了它最终应该呆的位置) , 然后递归地去排它左边的部分和右边的部分。 依次进行下去, 知道数组有序。
  • 算法思想: 分治思想。 和【归并排序】不同, 快排在【分】这个事情上不像【归并排序】一样一分为二, 而是利用partition的方法, 因此就没有【合】的过程。
  • 实现细节(如果是顺序数组或者逆序数组)一定要随机化选择切分元素, 否则在输入数组是有序数组或者是逆序数组的时候, 快速排序就会变得非常慢。
经典题目

数组中的第k个最大元素

颜色分类

快排的基本做法

#include<iostream> using namespace std; // 选择基准 int partition(int array[], int low, int high){ 
    while(low < high){ 
    // low 位置为bound, low 右边的不小于low左边的 // 从右往左找一个小于6的数, 再从左往右找一个大于6的数 while(low < high && array[low] <= array[high]){ 
    high--; } int tmp = array[low]; array[low] = array[high]; array[high] = temp; while(low < high && array[low] <= array[high]){ 
    low++; } tmp = array[low]; array[low] = array[high]; array[high] = temp; } return low; } void quickSortHelp(int array[], int low , int high) { 
    if(low < high) { 
    int location = parition(array, low, high); quickSortHelp(array, low,location - 1); quickSortHelp(array, location + 1, high); } } void quickSort(int array[], int n){ 
    qickSortHelp(array, 0, n-1); } 

快排的随机化版本

通过随机化选择基准数, 避免出现O(n^2)的情况。 当然也可以使用STL的三点中值, 取整个序列的头、尾、中央三个位置的元素, 以其中值(median)作为基准数。 这个就是median-of-three partition

class solution{ 
    public: vector<int> sorrArray(vector<int>& nums){ 
    srand(time(0)); quicksort(nums, 0, nums.size()); return nums; } inline int partition(vector<int>& nums, int left, int right){ 
    int x = nums[right], i = left - 1; for(int j = left;j < right;++j){ 
    if(nums[j] <= x){ 
    swap(nums[++i], nums[j]); } } swap(nums[i+1], nums[right]); return i+1; } inline int randomParition(vector<int>& nums, int left, int right){ 
    int i = rand() % (right - left + 1) + left; swap(nums[i], nums[right]); return partition(nums, left , right); } void quicksort(vector<int>& nums, int left, int right) { 
    if(left < right) { 
    int location = randomPartition(nums, left, right); quicksort(nums, left, location-1); quicksort(nums, location+1, right); } } } 
如果随机化版本也是O(n^2)

在原始快排中, 如果输入的序列是正序的或者是逆序的, 那么它就会到达O(n^2)的时间复杂度, 于是我们是用来随机化算法, 来生成基准数。 但是即便是引入了随机化算法, 依旧有可能会到达O(n^2)。 这时候可以通过检查递归深度, 将之转换为堆排序。

根据数据量大小来使用快排

在STL中的sort算法, 数据量大是采用快速排序 ,分段递归排序;

一旦分段后的数据量小于某个门槛, 为避免快排的递归调用带来过大的额外负荷(会有额外空间消耗), 就改用插入排序

如果递归层次过深, 还会改用堆排序, 因为快排不稳定, 在数据量大的时候很有可能退化成O(n^2)的复杂度。

怎么看这个递归深度(怎么自我检测)

在SGI中, 是通过IntroSort来实现。 而Introsort可以通过判断递归深度来决定转化为堆排序

template <class RandomAccessIterator, class T, class Size> void __introsort_loop(RandomAccessIterator first, RandomAccessIterator last, T*, Size depth_limit) { 
    while(last - first > __stl_threshold) { 
    if(depth_limit == 0) //这时候, 可用的递归深度已经被用完了 { 
    partial_sort(first, last, last); return ; } --depth_limit; // 接下来是 median-of-3 partition , 选择一个够好的枢轴并决定分裂点 RandomAccessIterator cut = __upguarded_partition(first, last, T(__median(*first, *(first + (last - first)/2), *(last- 1)))); // 对右半边递归进行sort __introsort_loop(cut, last, value_type(first), depth_limit); last = cut; //回到while循环, 准备对左半段递归进行sort } } // 其中的depth_limit的参数 template<class RandomAccessIterator> inline void sort(RandomAccessIterator first, RandomAccessIterator last) { 
    if(first != last) { 
    __introsort_loop(first, last, value_type(first), __lg(last-first)*2); __final_insertion_sort(first, last); } } 

其中控制分割恶化情况的就是

template <class Size> inline Size __lg(Size n) { 
    Size k; for(k = 0;n > 1;n >>= 1) ++k; return k; } // 当Size 为40, 2^5 <= 40; 所以当数据量为40的时候, 递归深度不能够超过5*2 = 10; 
多小的数据量使用插入排序

在STL中,其实5-20的区间内差别不大。 通常在这个区间内转化位插入排序

而插入排序在小数据量的时候相比于快排

  • 没有额外的内存的申请和释放开销
  • 没有递归栈的开销

为什么快排是不稳定的

稳定性指的是:两个值相同的元素在排序前后是否有位置变化。 如果前后位置变化, 排序算法是不稳定的, 否则是稳定的。

那为什么快排不稳定?为什么相同数值的元素会发送前后位置的变化。

原因:

1、 如果我们设置到bound(选定号的基数)它有相同的数值。 这就可以打乱顺序if(nums[i] <= x)。 但是并不是以相同的数值直接交换, 而是和比它大的值交换swap(nums[i+1], nums[rihgt]) 。而这个bound放到了相同数值的最后面。 (如果不使用随机化便可以避免这个问题)

2、 即便我们不是设置的bound没有相同的数值。 |lower|higher|unvisted| bound。 也就是说待排序的范围为(|lower|higher|unvisted| ) lower就是比bound小的值, higher是比bound大的值。 ‘

实例: |3 1 2|9 7 8 9 | 4 6 3 | 5, 当这时候遍历到了4。 那么9和4会进行交换变为 |3 1 2|4 7 8 9 9| 6 3 | 5 。这时候就发生了交换(4和9)之间。

// 在随机化后的快排, 我们把选中的数字放到排序区间的外面, 待排序区间为[left, right) inline int partition(vector<int>& nums, int left, int right){ 
    int x = nums[right], i = left - 1; for(int j = left;j < right;++j){ 
    if(nums[j] <= x){ 
    swap(nums[++i], nums[j]); } } swap(nums[i+1], nums[right]); return i+1; } 

快排的稳定版本

常规的快速排序算法是一个不稳定的算法, 也就是两个相等的数排序之后的顺序可能在原序列中的顺序不同。 那要实现一个稳定版本的快速排序可能吗?

思路:

  1. 第一遍先把小于pivot的元素按照先后顺序放在tmp中。 然后把pivot放到它的正确位置tmp[k]。
  2. 第二遍把大于pivot的元素按先后顺序放到tmp里。 这样处理pivot以前的其他元素, 都保持了和原序列中一样的顺序。
  3. 第三遍把tmp赋值给原数组A
int stable_partition(vector<int>& nums, int left, int right) { 
    vector<int> tmp(right - left + 1); int pivot = nums[left]; int index = 0; for(int i = left+1;i < right+1;++i) { 
    if(nums[i] < pivot) { 
    tmp[index] = nums[i]; index = index + 1; } } tmp[index] = pivot; int position = index + left; index = index + 1; for(int i = left+1;i<right+1;++i) { 
    if(nums[i] >= pivot){ 
    tmp[index] = nums[i]; index = index + 1; } } index = 0; for(int i = left;i < right+1;++i) { 
    nums[i] = tmp[index]; ++index; } return position; } void quickSort(vector<int>& nums, int left, int right) { 
    if(left < right){ 
    int location = stable_partition(nums, left, right); quickSort(nums, left, location-1); quickSort(nums, location+1, right); } } vector<int> sortArray(vector<int>& nums) { 
    quickSort(nums, 0, nums.size()-1); return nums; } int main() { 
    vector<int> arr = { 
   3,1,2,9,7,8,9,4,6,3,5}; cout << arr.size() << endl; sortArray(arr); for(int i = 0; i <arr.size();++i) { 
    cout << arr[i]; } cout << endl; } 

当数据较小, 却又有较多的重复元素

**面对有大量重复元素的场景, 使用原始的快速排序, 会让时间复杂度退化成O(n^2) 。不管是当条件是大于等于还是小于等于v,**当数组中重复元素非常多的时候,等于v的元素太多,那么就将数组分成了极度不平衡的两个部分,因为等于v的部分总是集中在数组的某一边。


一种优化方式就是双路快排

由代码可以得知, 当i的元素小于v的时候继续向后扫描, 直到碰到某个元素大于等于v。 同理, 直到碰到某个元素小于等于v。

而后,只要交换i和j的位置就可以了, 然后i++, j–就行了。

/* * @Date: 2021-12-07 17:24:33 * @LastEditors: kafier * @LastEditTime: 2021-12-07 17:46:26 */ #include<iostream> #include <algorithm> #include <vector> using namespace std; template<typename T> int partition(vector<T>& arr, int left, int right) { 
    T pivot = arr[left]; int i , j; i = left + 1, j = right; while(true) { 
    while(arr[i] < pivot && i <= right) ++i; while(j>=left+1 && arr[j] > pivot) --j; if(i > j) { 
    break; } swap(arr[i], arr[j]); ++i; --j; } swap(arr[left], arr[j]); return j; } template <typename T> void __quickSort2(vector<T>& arr, int left, int right) { 
    if(left >= right) { 
    return; } int position = partition(arr, left, right); __quickSort2(arr, left, position-1); __quickSort2(arr, position+1, right); } template<typename T> void quickSort(vector<T>& arr, int n) { 
    __quickSort2(arr, 0, n-1); } int main() { 
    vector<int> arr = { 
   3,1,2,9,7,8,9,4,6,3,5}; cout << arr.size() << endl; // sortArray(arr); quickSort(arr, arr.size()); for(int i = 0; i <arr.size();++i) { 
    cout << arr[i]; } cout << endl; } 

三路快排

双路快排讲整个数组分成了小于v, 大于v的两部分, 而三路快排则是将数组分成了小于v, 等于v,大于v的三个部分。 当递归处理的时候, 遇到等于v的元素直接不用管, 只需处理小于v, 大于v的元素就好了

#include<iostream> #include<algorithm> using namespace std; t 

归并排序(重点)

基本思路

借助额外空间,合并两个有序数组, 得到更长的有序数组。(怎么合并两个有序数组, 去看合并两个有序数组

而后利用递归逐渐分解这个问题。

常规代码

排序数组

class MergeSolution { 
    public: vector<int> tmp; // 需要一个额外的数组来存储原数组 vector<int> sortArray(vector<int>& nums) { 
    tmp.resize((int)nums.size(), 0); mergeSort(nums, 0, (int)nums.size() - 1); return nums; } void mergeSort(vector<int>& nums, int left, int right) { 
    if(left >= right){ 
    return ; } int mid = left + (right - left)/2; mergeSort(nums, left, mid); mergeSort(nums, mid+1, right); int i = left, j = mid + 1; int cnt = 0; while(i <= mid && j <= right) { 
    if(nums[i] <= nums[j]) { 
    tmp[cnt++] = nums[i++]; } else{ 
    tmp[cnt++] = nums[j++]; } } // 如果右边的区间先被放完了 while(i <= mid){ 
    tmp[cnt++] = nums[i++]; } // 如果是左边的区间先被放完了 while(j <= right) { 
    tmp[cnt++] = nums[j++]; } for(int i = 0;i < right - left + 1;++i){ 
    nums[i+left] = tmp[i]; } } }; 
#include<iostream> using namespace std; void Merge(int arr[], int l, int q, int r){ 
    int n=r-l+1;//临时数组存合并后的有序序列 int* tmp=new int[n]; int i=0; int left=l; int right=q+1; while(left<=q && right<=r) tmp[i++] = arr[left]<= arr[right]?arr[left++]:arr[right++]; while(left<=q) tmp[i++]=arr[left++]; while(right<=r) tmp[i++]=arr[right++]; for(int j=0;j<n;++j) arr[l+j]=tmp[j]; delete [] tmp;//删掉堆区的内存 } void MergeSort(int arr[], int l, int r){ 
    if(l==r) return; //递归基是让数组中的每个数单独成为长度为1的区间 int q = (l + r)/2; MergeSort(arr, l, q); MergeSort(arr, q + 1, r); Merge(arr, l, q, r); } int main(){ 
    int a[8] = { 
   3,1,2,4,5,8,7,6}; MergeSort(a,0,7); for(int i=0;i<8;++i) cout<<a[i]<<" "; } 
经典归并排序题目

合并两个有序数组 其实Merge这个函数所做之事(使用双指针)

数组中的逆序对

计算右侧小于当前元素的个数

数组中的逆序对

归并排序分为三个步骤

  • 分解: 将待排序的区间为[l, r], 令 m = [l+r/2], 我们把[l, r]分成[l, m]和[m+1, r]
  • 解决: 使用归并排序递归地排序两个子序列
  • 合并:把两个排好序的子序列[l, m]和[m+1, r]合并起来

逆序对和归并排序的关系

关键在于【归并】中的【并】的过程。

L = [8, 12, 16, 22, 100] R = [9, 26, 55, 64, 91] M = [8, 9] | | lPtr rPtr 

现在要考虑12这个lptr的贡献值。 因为之前的rptr(9)比12小, 所以12和9构成了逆序对。

这个贡献就是rptr相对R首位置的偏移(即右边只有一个数比12小, 所以只有它和12构成了逆序对)。

这种【算贡献】的思想在合并的过程中计算逆序对的数量的时候, 只能在lptr右移的时候计算。

是基于这样的事实 : 当前lptr指向的数组比rptr小,但是比R中[0 ... rptr-1]的其他数字大, [0 .. rptr-1]的其他数字应该排在lptr对应数字的左边。但是比R中[0 ... rptr - 1]的其他数字大。 [0 .. rptr -1]的其他数字应该排在lptr对应的数字左边。 但是它排在了右边。 所以可以贡献rptr个逆序对

class Solution { 
    public: int reversePairs(vector<int>& nums) { 
    int n = nums.size(); vector<int> tmp(n); return mergeSort(nums, tmp, 0, n-1); } int mergeSort(vector<int>& nums, vector<int> &tmp, int left, int right) { 
    if(left >= right){ 
    return 0; } int mid = left + (right - left) / 2; int inv_cnt = mergeSort(nums, tmp, left, mid) + mergeSort(nums, tmp, mid+1, right); int i=left, j = mid+1, cnt = left; while(i <= mid && j<=right) { 
    if(nums[i] <= nums[j]) //这个是一个要格外注意的点。 即便是 nums[i] == nums[j], 也可以进行清算 { 
    tmp[cnt++] = nums[i++]; inv_cnt += (j-(mid+1)); } else{ 
    tmp[cnt++] = nums[j++]; } } while(i <= mid) { 
    tmp[cnt++] = nums[i++]; inv_cnt += (j-(mid+1)); } while(j <= right) { 
    tmp[cnt++] = nums[j++]; } copy(tmp.begin() + left, tmp.begin() + right +1, nums.begin() + left); return inv_cnt; } }; 

堆排序

堆的定义

  • 堆是一个完全二叉树, 因此堆也被称为二叉堆
  • 堆中节点的值都大于等于其子节点的值(大顶堆) 或者小于等于其子节点的值(小顶堆)

堆的底层如何表示?

堆是一颗完全二叉树, 可以使用数组来表示。而完全二叉树除了叶子节点的部分, 就是满二叉树, 也就是只有叶子节点没必要是满的。

如果根节点为 i, 那么左节点为2i, 右节点为2i + 1(这是针对其实元素是从1开始的) ,如果是从0开始, 那么左节点为 2 * i +1, 右节点为2*i + 2。

删除堆顶元素可能会产生的问题(数组空洞)

假设操作的堆是大顶堆, 则删除堆顶元素, 要找到原堆中第二大的元素来填补堆顶元素, 第二大的元素无疑是在根节点中的左右节点当中, 假设是左节点, 则用左节点来填补堆顶元素, 左节点就空了, 之后的过程就是进行不断地循环。

但是在调整的过程中, 会产生数组空洞, 如图
在这里插入图片描述解决方案: 用最后一个元素覆盖堆顶元素, 然后再自上而下调整堆, 让其满足大顶堆的要求, 就可以解决数组空洞的问题。
在这里插入图片描述因为将堆顶元素和最后一个元素进行了交换。 再自上而下地去调整堆, 就可以避免数组空洞问题

堆排序思想

  • 堆排序可以说是选择排序的优化, 选择排序需要在未排定的部分通过【打擂台】的方式选出最大的元素, 而【堆排序】将未排定的部分构建成了一个堆。 这样就可以以O(logN)的方式选出最大元素。
  • 堆是建立在数组上的【树】结构。 其中有最大堆, 最小堆, 规则也很简单(最大堆, 父节点比子节点的值大)
class HeapSolution { 
    public: vector<int> sortArray(vector<int>& nums) { 
    heapSort(nums); return nums; } void maxHeapify(vector<int>& nums, int i, int len) { 
    // (i<<1) == i*2 while((i<<1)+1 <= len) { 
    int lson = (i<<1) + 1; int rson = (i<<1) + 2; int large; // 如果i的左子节点大于根节点 large = lson, 否则 large = i if(lson <= len && nums[lson] > nums[i]) { 
    large = lson; } else{ 
    large = i; } //如果右节点比现在(根节点和左节点)都要大, large = rson if(rson <= len && nums[rson] > nums[large]) { 
    large = rson; } // 根节点不大。  if(large != i) { 
    swap(nums[i], nums[large]); i = large; } else{ 
    break; } } } void buildMaxHeap(vector<int>& nums, int len) { 
    // 如果有n个节点, 由二叉树的性质。假设最后一层的节点的数量为k, 那么上层的节点为 k/2 + k/4+ .. + 1, 化为等差数列可以得到k-1 // 所以二叉树的一半 + 1就是叶子节点的数量 // 那么第一个非叶子节点的索引就是 (len - 1)/2; for(int i = len/2; i>=0;--i) { 
    maxHeapify(nums, i, len); } } void heapSort(vector<int>& nums) { 
    // 循环不变量:区间[0,i]堆有序 int len = (int)nums.size() - 1; buildMaxHeap(nums, len); // 把堆顶元素(当前最大)交换到数组末尾 for(int i = len;i >= 1;--i) { 
    swap(nums[i], nums[0]); // 减少堆有序的部分 len -=1; maxHeapify(nums, 0, len); } } }; 

堆的时间复杂度分析

堆的排序分为两步, 即初始化堆, 调整堆


两个步骤都需要调用一个调整节点顺序的函数NodeSort, 以大根堆为例。

  1. 如果父亲节点num[a], 和它的两个孩子节点nums[2a+1] , nums[2a+2]满足, nums[a] > max{nums[2a+1], nums[2a+2]} , 那么返回
  2. 如果不满足堆的性质, 那么将父亲节点nums[a] 和 较大孩子节点max{nums[2a+1], nums[2a+2]}交换。
  3. 将原来较大节点的孩子节点作为父亲节点,重复上述操作, 知道孩子节点是叶子节点为止
初始化堆的时间复杂度分析

设元素个数为n, 堆的高度为 K = log(n + 1) = log(n), 非 叶子节点的个数为 2^(k-1) -1

假设每个非叶子节点都需要调整, 第i层的非叶子节点 需要操作的次数为k – i

第i层共有 2^(i-1)个节点, 第i层的所有节点所做的操作为k* 2 ^ (i-1) - i*2^(i-1), 总共k-1层非叶子节点, 总的操作次数为

∑ i = 1 k − 1 ( k ∗ 2 i − 1 − i ∗ 2 i − 1 ) = ∑ i = 1 k − 1 ( k − i ) ( 2 i − 1 ) = 2 k − k + 1 \sum_{i=1}^{k-1}(k*2^{i-1} – i * 2^{i-1}) = \sum_{i=1}^{k-1}(k-i)(2^{i-1}) = 2^k – k + 1 i=1k1(k2i1i2i1)=i=1k1(ki)(2i1)=2kk+1

将k = log(n)代入, 那么得到 n − l o g ( n ) + 1 n – log(n) + 1 nlog(n)+1

所以, 初始化堆的复杂度为O(n)


调整堆的时间复杂度分析

假设根节点和排到最后序号为m的叶子节点交换, 并进行调整, 那么调整的次数 = 原来m节点所在的层数 = 堆的高度

共n个节点。 调整的总次数为 ∑ m − 1 n − 1 l o g ( m ) \sum_{m-1}^{n-1}log(m) m1n1log(m)

化简得到 l o g ( n − 1 ) ! = n ∗ l o g ( n ) log(n-1)! = n * log(n) log(n1)!=nlog(n)

所以调整堆的时间复杂度为 n ∗ l o g ( n ) n * log(n) nlog(n)

今天的文章
排序算法快速排序_快速排序稳定吗分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/60138.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注