【数据结构】排序(sort) -- 交换排序(冒泡快排)
目录
一、交换排序
二、冒泡排序(bubble sort)
1. 基本思想
2. 思路介绍
3. 代码实现
4. 冒泡排序的优化
5. 特性总结
三、快速排序(quick sort)
1. 基本思想
2. 划分思路的分类
(1)hoare版本
a. 思路介绍
b. 代码实现
(2)挖坑法
a. 思路介绍
b .代码实现
(3)前后指针版本
a. 思路介绍
b. 代码实现
3. 对快速排序的优化
(1)优化的原因
(2)优化办法
4. 快速排序的非递归实现
5. 特性总结
四、总结
一、交换排序
常见的排序算法有:
而本篇文章要介绍的是交换排序。
插入排序可以分为 冒泡排序 和 快速排序 。本文重点介绍了快速排序的认识
二、冒泡排序(bubble sort)
1. 基本思想
通过多次遍历待排序的序列,依次比较相邻元素,并根据大小关系交换它们的位置,使得每一轮遍历后,当前未排序部分的最大(或最小)元素“冒泡”到正确的位置。
2. 思路介绍
思路:
- 将待排序的数据划分为有序区和无序区,初始时有序区为空,无序区包含所有待排序数据。
- 对于无序区从前向后依次相邻数据进行比较,,若返序则交换,从而时值较小的数据向前移动,值较大的数据向后移动。(就像水中的气泡,体积大的现浮上来)
- 重复步骤2,直到无序区没有返序的数据。
对于一组数据:3,44,38,5,47,15,36,26,27,2,46,4,19,50,48 它的排序个过程如下:
排序动态图如下:
3. 代码实现
C语言代码实现:
void Swap(int* p1, int* p2)
{int tmp = *p1;*p1 = *p2;*p2 = tmp;
}// 冒泡排序
void BubbleSort(int* a, int n)
{for (int i = 0; i < n; i++)// n个数据比较n-1趟{for (int j = 1; j < n - i; j++)// 每一趟比较n-1-i{if (a[j - 1] > a[j]){Swap(&a[j - 1], &a[j]);}}}
}
4. 冒泡排序的优化
上述冒泡排序的代码中,可以发现无论数据是否有序,它都会消耗O(n^2)的时间性能。如果要解决这一问题,可以增加一个判断标志,判断是否已经有序。
// 冒泡排序
void BubbleSort(int* a, int n)
{for (int i = 0; i < n; i++)// n个数据比较n-1趟{bool exchange = false;for (int j = 1; j < n - i; j++)// 每一趟比较n-1-i{if (a[j - 1] > a[j]){//如果已经进入了需要交换的这一步,说明数据还存在无序的数据Swap(&a[j - 1], &a[j]);exchange = true;}}if (exchange == false){break;}}
}
5. 特性总结
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
三、快速排序(quick sort)
1. 基本思想
快速排序是一种高效的分治排序算法,其核心思想是通过选择一个基准元素(pivot),将序列划分为两部分,使得基准元素的左边部分的所有元素小于等于基准值,基准元素的右边部分的所有元素大于等于基准值,然后递归地对这两部分进行排序,左右子序列重复该过程,直到所有元素都排列在相应位置上为止。最终完成整个序列的排序。
排序基本逻辑:
首先选择基准元素key值,基准值下标为keyi。然后进行划分,使得基准元素的左边部分的所有元素小于等于基准值,基准元素的右边部分的所有元素大于等于基准值,此时key所在位置的数据位置就定下来了。再分别对左右两边的区间 [left, keyi-1] 和 [keyi+1, right] 分别进行划分,当然每次划分后都会出现这两种区间,重复此过程,直到每个划分区间只有一个元素就停止划分。如图所示:
可以发现这二叉树有点相似。
代码逻辑实现(递归):
// 假设按照升序对a数组中[left, right]区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{if (left >= right)return;// 按照基准值对a数组的 [left, right]区间中的元素进行划分int keyi = partion(array, left, right);// 划分成功后以div为边界形成了左右两部分 [left, keyi-1] 和 [keyi+1, right]// 递归排[left, keyi-1]QuickSort(array, left, keyi - 1);// 递归排[keyi+1, right]QuickSort(array, keyi + 1, right);
}
2. 划分思路的分类
知道了快排的逻辑方法,只要通过划分找到每一次划分后的key的位置就可以完成这个快排算法了。对于划分方法,又可以分为多种,如:hoare法,挖坑法,前后指针法。但它们都有一个共同点:都需要选取一个位置的元素为基准值,我们选取的是最左边的位置或者最右边的位置,不会选取中间的位置。对于不同的排序,需要注意:
- 排升序:
- 如果选取左边做key,就要从右边先走,找小
- 如果选取右边做key,就要从左边先走,找大
- 排降序:
- 如果选取左边做key,就要从右边先走,找大
- 如果选取右边做key,就要从左边先走,找小
以下版本都是以选取左边做key,就要从右边先走,找小,排升序为例的。
(1)hoare版本
a. 思路介绍
划分操作:
对于区间 [left, right ] 进行一次划分(如果选取左边做key,就要从右边先走,找小--排升序为例):
此时key所在位置是左边第一个元有位置。右边的right先开始向左移动,找比key值更小的元素,找到后停下,然后,左边的left才向右开始移动,找比key值大的元素,找到后停下,然后交换此时left和right所在的元素。重复操作,直到left和right相遇,相遇后就将key所在位置的元素与相遇位置的元素进行交换。此时,key的左边数据就都小于或等于key,右边数据就大于或等于key,将原来的区间划分为了两部分。
这样就完成了一次划分
如图是选取左边做key,右边取小的一次划分的一个动态图示例:
划分详细过程如下:
b. 代码实现
对于划分部分的代码实现:
//hoare版本
int partion(int* a, int left, int right)
{//对[left,right]这部分区间的数据进行划分,并返回划分后的key值int keyi = left;//初始时,key值在该区间最左边;keyi为key值的下标,因为通过下标才能修改数组的内容while (left < right){//右边找小while (left < right && a[right] >= a[keyi]){right--;}//左边找大while (left < right && a[left] <= a[keyi]){left++;}Swap(&a[left], &a[right]);//交换左右两边的值}//此时left=rightSwap(&a[keyi], &a[left]);keyi = left;return keyi;
}
(2)挖坑法
a. 思路介绍
进行划分的操作是:
对于区间 [left, right ] 进行一次划分(如果选取左边做key,就要从右边先走,找小--排升序为例):
将基准值(左边第一个元素)放在一个临时变量key中,此时在第一个元素位置就形成了一个坑位,然后从右边(即另一边)开始找比key值更小的值,找到后,将找到的值放入上次的坑中,此时该位置有形成了一个坑,再从左边开始找比可以key值更大的值,找到后,将找到的值放入上一次形成的坑中。重复次操作,直到left与right相遇。相遇时,相遇为值又是一个坑,这时就将key值填入该位置。此时key的左边数据就都小于或等于key,右边数据就大于或等于key,将原来的区间划分为了两部分。
如图是挖坑法的选取左边做key,右边取小的一次划分的一个动态图示例:
b .代码实现
C语言实现:
//挖坑法
int partion(int* a, int left, int right)
{//对[left,right]这部分区间的数据进行划分,并返回划分后的key值所在的位置int key = a[left];//初始时,key值在该区间最左边;将a[left]的值用变量key存储起来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;//此时形成新的坑位}//此时left=right=holea[hole] = key;return hole;
}
(3)前后指针版本
a. 思路介绍
对于区间 [left, right ] 进行一次划分的思路:
它需要两个指针prev和cur。初始时prev指向该序列最前面的开头,而cur指向prev的后一个位置。
然后开始判断cur指向的数据与key的大小:
- 如果cur指向的数据比key小,找到后,prev后移一位,再将prev和cur指向的数据相互互换,最后cur再后移一位。
- 如果cur指向的数据比key大,则只用将cur后移一位。
重复上述操作,直到cur越界,越界后就将prev指向的位置的数据与key所在位置进行交换。
这样也保证了此时key的左边数据就都小于或等于key,右边数据就大于或等于key,将原来的区间划分为了两部分。
说明:prev一定是在cur的前面的。
如图是前后指针法的选取左边做key,右边取小的一次划分的一个动态图示例:
b. 代码实现
C语言代码实现:
//前后指针法
int partion(int* a, int left, int right)
{//对[left,right]这部分区间的数据进行划分,并返回划分后的key值int keyi = left;//初始时,key值在该区间最左边;keyi为key值的下标,因为通过下标才能修改数组的内容//在数组中,prev和cur用下标表示比较方便int prev = left;int cur = prev + 1;while (cur <= right){if (a[cur] < a[keyi]){prev++;Swap(&a[cur], &a[prev]);}cur++;//不论a[cur]比key大还是小,都会将cur后移}//此时cur已经越界Swap(&a[keyi], &a[prev]);keyi = prev;return keyi;
}
3. 对快速排序的优化
(1)优化的原因
时间复杂度最好情况:
理想状态下,快排每次在划分完后,都会产生2个大小相等的区间 [left, keyi-1] 和 [keyi+1, right],而这2个区间再次进行划分,又会产生4个大小相等的区间,这4个区间继续进行划分,又会产生8个大小相等的区间,重复划分下去,直到每个区间中只有一个元素时才停止划分。
这种情况就和满二叉树的递归相似。这样子的时间性能就是快排中最好的情况。
时间复杂度是O(N*logN)。
时间复杂度最坏情况:
但是,如果我们要排序的是一个逆序的数据。我们还是选择第一个数据转为基准值key,那么这时在进行划分时,就不会有两个区间了。因为是逆序,那么就有:如果要排升序,而此时是一个降序的话,key仍然选择第一个数据,当进行第一次划分后,key就就处在最后一个位置了,此时,假如有n个数,那么key前面就有n-1个数据,后面就没有数据了,此时第一次划分就结束了。然后再对前面这个区间进行划分,如此重复的话,直到只剩第一个数据时排序才结束。如图:
同理,如果对一个升序,排降序也是如此。
当然这种情况是最坏的情况了,时间复杂度为。
原因:
以对一个降序,要排升序为例。由于是一个降序,所以第一个元素一定是最大的一个元素,最大的元素如果在升序中,一定是处于最后一个元素的,所以,对于第一次划分,就是将第一个元素固定位置,只是要固定的位置是最后一个元素而已,同样的,对于后续的区间划分也是如此。如此重复,就会出现这种情况了。
解决办法;
如果遇到这种情况,就需要对所选的基准值key进行调整,尽量不让它处于最大或最小,如果比较处在中间大小就很好了。
(2)优化办法
方法一:随机选key
由于我们所选的key的位置一定是在第一个或者最后一个,即位置不能变,所以就需要找一个值来与它进行交换。
那么就可以通过产生随机下标,让产生的随机下标对应的值与第一个位置的值进行交换。
C语言代码实现如下:
//交换
void Swap(int* p1, int* p2)
{int tmp = *p1;*p1 = *p2;*p2 = tmp;
}
//hoare版本
int partion1(int* a, int left, int right)
{// 随机选keyint randi = left + (rand() % (right - left));Swap(&a[left], &a[randi]);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]);//交换左右两边的值}//此时left=rightSwap(&a[keyi], &a[left]);keyi = left;return keyi;
}//挖坑法
int partion2(int* a, int left, int right)
{// 随机选keyint randi = left + (rand() % (right - left));Swap(&a[left], &a[randi]);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;//此时形成新的坑位}//此时left=right=holea[hole] = key;return hole;
}//前后指针法
int partion3(int* a, int left, int right)
{// 随机选keyint randi = left + (rand() % (right - left));Swap(&a[left], &a[randi]);int keyi = left;//在数组中,prev和cur用下标表示比较方便int prev = left;int cur = prev + 1;while (cur <= right){if (a[cur] < a[keyi]){prev++;Swap(&a[cur], &a[prev]);}cur++;//不论a[cur]比key大还是小,都会将cur后移}//此时cur已经越界Swap(&a[keyi], &a[prev]);keyi = prev;return keyi;
}
方法二:三数取中
让待排序数组这个区间中的第一个数据、中间的数据以及最后一个数据,进行比较,选择中间的数据做key,当然这得到的数据也要和这个区间的第一个数据进行交换。
C语言代码实现如下:
//得到中间数
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;}}
}
//交换
void Swap(int* p1, int* p2)
{int tmp = *p1;*p1 = *p2;*p2 = tmp;
}
//hoare版本
int partion1(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]);//交换左右两边的值}//此时left=rightSwap(&a[keyi], &a[left]);keyi = left;return keyi;
}//挖坑法
int partion2(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;//此时形成新的坑位}//此时left=right=holea[hole] = key;return hole;
}//前后指针法
int partion3(int* a, int left, int right)
{//三数取中int midi = GetMidNumi(a, left, right);if (midi != left)//避免重复{Swap(&a[midi], &a[left]);}int keyi = left;//在数组中,prev和cur用下标表示比较方便int prev = left;int cur = prev + 1;while (cur <= right){if (a[cur] < a[keyi]){prev++;Swap(&a[cur], &a[prev]);}cur++;//不论a[cur]比key大还是小,都会将cur后移}//此时cur已经越界Swap(&a[keyi], &a[prev]);keyi = prev;return keyi;
}
4. 快速排序的非递归实现
快速排序的非递归实现需要借助 栈 这一数据结构来实现。
使用栈,其中在栈中存储的元素类型是区间。初始时,将待排序的数据的区间先入栈,然后通过取栈顶的操作,来得到待划分区间,然后间其出栈,再对所取的栈顶元素进行划分,划分完后,会产生两个区间,如果这两个区间中有元素个数一定大于1的区间,就将该元素个数一定大于1的区间入栈。后续重复次操作,直到栈为空才停止。这时就排完序了。
对于,某一数据进行的非递归快排的操作如图所示:
代码实现:
使用栈的结构,就需要使用到栈的处理函数。
栈的知识链接为:【数据结构】栈(stack)_数据结构栈-CSDN博客
则C语言代码实现:
void QuickSortNotRecursive(int* a, int left, int right)
{Stack st;Init(&st);//存储区间Interval tmp = { left,right };//将初始数据区间入栈Push(&st, tmp);//直到栈空才停止while (!IsEmpty(&st)){Interval cur = GetTop(&st);//取栈顶Pop(&st);//出栈int keyi= partion1(a, cur.left, cur.right);//划分区间//划分后:[cur.left,keyi-1] [keyi+1,cur.right]//判断是否有元素个数大于1的区间,若有,则将它们入栈if (cur.left < keyi - 1){Interval tmp = { cur.left,keyi - 1 };Push(&st, tmp);}if (keyi + 1 < cur.right){Interval tmp = { keyi + 1,cur.right };Push(&st, tmp);}}Destroy(&st);//销毁动态顺序表
}
5. 特性总结
快速排序的特性总结
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
- 时间复杂度:O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
四、总结
- 冒泡排序通过多次遍历序列,比较相邻元素并交换位置,时间复杂度O(n²),空间复杂度O(1)。
- 快速排序采用分治思想,选取基准值将序列划分为两部分递归排序,平均时间复杂度O(nlogn),但最坏情况会退化为O(n²),文章详细介绍了hoare、挖坑法和前后指针三种划分方法,并提出了随机选key和三数取中两种优化方案,最后还给出了快速排序的非递归实现。
感谢各位观看!希望能多多支持!