快速排序
1、定义
- 快速排序的基本思想是,通过一轮的排序将序列分割成独立的两部分,其中一部分序列的关键字(这里主要用值来表示)均比另一部分关键字小。
- 继续对长度较短的序列进行同样的分割,最后到达整体有序。在排序过程中,由于已经分开的两部分的元素不需要进行比较,故减少了比较次数,降低了排序时间。
总结:
- 1、先从数列中取出一个数作为基准数
- 2、分区过程,将比这个数大的数全放到它的右边,小于或等于它的数全放到它的左边
- 3、再对左右区间重复第二步,直到各区间只有一个数
2、特征
特征如下:
-
1、
最优时间复杂度O(nlogn)、最差时间复杂度为O(n2)
-
2、快速排序需要栈空间来实现递归,如果数组按局等方式被分割时,则最大的递归深度为 log n,需要的栈空间为 O(log n)。最坏的情况下在递归的每一级上,数组分割成长度为0的左子数组和长度为 n - 1的右数组。这种情况下,递归的深度就成为n,需要的
栈空间为 O(n)
。 -
3、因为快速排序在进行交换时,只是根据比较基数值判断是否交换,且不是相邻元素来交换,在交换过程中可能改变相同元素的顺序,因此是一种
不稳定的排序算法
。
最差排序场景:数组有序
- 当基数值不能很好地分割数组,即基准值将数组分成
一个子数组中有1个记录
,而另一个子组组有 n -1 个记录
。 - 下一次的子数组只比原来数组小1,这是快速排序的最差的情况。
- 如果这种情况发生在每次划分过程中,那么快速排序就退化成了冒泡排序,
其时间复杂度为O(n2)
。
最坏的情况,每次划分都得到一个子序列,时间复杂度为:
T(n) = cn + T(n-1)
= cn + c(n-1) + T(n - 2) = 2cn -c + T(n-2)
= 2cn -c + c(n - 2) + T(n-3) = 3cn -3c + T(n-3)
……
= c[n(n+1)/2-1] + T(1) = O(n2) 其中cn 是一次划分所用的时间,c是一个常数
最好排序场景
- 如果基准值都能
将数组分成相等的两部分
,则出现快速排序的最佳情况。 - 在这种情况下,我们还要对每个大小约为 n/2的两个子数组进行排序。
- 在一个大小为 n 的记录中确定一个记录的位置所需要的时间为O(n)。 若T(n)为对n个记录进行排序所需要的时间,则每当一个记录得到其正确位置,整组大致分成两个相等的两部分时,我们得到快速排序算法的
最佳时间复杂性 O(nlogn)
。
T(n) <= cn + 2T(n/2) c是一个常数
<= cn + 2(cn/2+2T(n/4)) = 2cn+ 4T(n/4)
<= 2cn + 4(cn/4+ 2T(n/8)) = 3cn + 8T(n/8)
…… ……
<= cnlogn + nT(1) = O(nlogn) 其中cn 是一次划分所用的时间,c是一个常数
快速排序的时间复杂度在平均情况下介于最佳与最差情况之间,假设每一次分割时,基准值处于最终排序好的位置的概率是一样的,基准值将数组分成长度为0 和 n-1,1 和 n-2,……的概率都是 1/n。在这种假设下,快速排序的平均时间复杂性为:
T(n) = cn + 1/n(T(k)+ T(n-k-1)) T(0) = c, T(1) = c
这是一个递推公式,T(k)和T(n-k-1)是指处理长度为 k 和 n-k-1 数组是快速排序算法所花费的时间, 根据公式所推算出来的时间为 O(nlogn)。因此快速排序的平均时间复杂性为O(nlogn)。
3、实现过程
下面我们通过一个案例来演示一下快速排序的基本步骤: 以序列 46 30 82 90 56 17 95 15 共8个元素
初始状态: 46 30 82 90 56 17 95 15 选择46 作为基准值,i = 0, j = 7
i = 0 j = 7
15 30 82 90 56 17 95 46 15 < 46, 交换 15 和 46,移动 i, i = 1
i = 1 j = 7
15 30 82 90 56 17 95 46 30 < 46, 不需要交换,移动 i , i = 2
i = 2 j = 7
15 30 46 90 56 17 95 82 82 > 46, 交换82 和 46,移动 j , j = 6
i = 2 j = 6
15 30 46 90 56 17 95 82 95 > 46, 不需要交换,移动 j , j = 5
i = 2 j = 5
15 30 17 90 56 46 95 82 17 < 46, 交换46 和 17,移动 i, i = 3
i = 3 j = 5
15 30 17 46 56 90 95 82 90 > 46, 交换90 和 46,移动 j , j = 4
3 = i j = 4
15 30 17 46 56 90 95 82 56 > 46, 不需要交换,移动 j , j = 3
i = j = 3
i = j = 3, 这样序列就这样分割成了两部分,左边部分{15, 30, 17} 均小于 基准值(46);右边部分 {56, 90,95,82},均大于基准值。
这样子我们就达到了分割序列的目标。在接着对子序列用同样的办法进行分割,直至子序列不超过一个元素,那么排序结束,整个序列处于有序状态。
4、C++代码
递归方式
#include<iostream>
#include<vector>
using namespace std;
void swap(vector<int>&arr, int l, int r)//元素交换函数
{
int temp = arr[l];
arr[l] = arr[r];
arr[r] = temp;
}
vector<int> partition(vector<int>&arr, int left, int right)
{
int less = left - 1;//选准左边界
int more = right;//右边界
int temp = arr[right];//选定基准位置
while (left < more)
{
if (arr[left] < temp)
{
swap(arr, ++less, left++);//当前元素小于基准元素,左边界内扩
}
else if (arr[left] > temp)//当前元素大于基准元素,右边界内扩,左边界不变
{
swap(arr, --more, left);
}
else
left++;
}
swap(arr, more, right);//最后一个基准位置交换
return{less+1 ,more};//如果存在元素相等情况,返回相同元素两侧的边界索引
}
void quickSort(vector<int>&arr, int left, int right)//递归方法
{
if (left > right)//递归结束条件
return;
vector<int> p = partition(arr, left, right);//找到partition位置元素
quickSort(arr, left, p[0] - 1);//左边调整排序
quickSort(arr, p[1] + 1, right);//右边调整排序
}
int main()
{
vector<int>arr = { 7,6,5,3,2,5,1,9 ,4,3,10,9 };
cout << "Input array:" << endl;
for (int i = 0; i <arr.size(); i++)
cout << arr[i] << " ";
cout << endl;
int len = arr.size();
quickSort(arr, 0, len - 1);//快排启动
cout << "Output array:" << endl;
for (int i = 0; i < len; i++)
cout << arr[i] << " ";
cout << endl;
system("pause");
return 0;
}
非递归版本
#include<iostream>
#include<vector>
#include<stack>
using namespace std;
void swap(vector<int>&arr, int l, int r)//元素交换函数
{
int temp = arr[l];
arr[l] = arr[r];
arr[r] = temp;
}
vector<int> partition(vector<int>&arr, int left, int right)
{
int less = left - 1;//选准左边界
int more = right;//右边界
int temp = arr[right];//选定基准位置
while (left < more)
{
if (arr[left] < temp)
{
swap(arr, ++less, left++);//当前元素小于基准元素,左边界内扩
}
else if (arr[left] > temp)//当前元素大于基准元素,右边界内扩,左边界不变
{
swap(arr, --more, left);
}
else
left++;
}
swap(arr, more, right);//最后一个基准位置交换
return{less+1 ,more};//如果存在元素相等情况,返回相同元素两侧的边界索引
}
void quickSort(vector<int>&arr, int left, int right)
{
stack<int> st;
int pos = 0;
st.push(left);
st.push(right);
while (!st.empty())
{
right = st.top();
st.pop();
left = st.top();
st.pop();
vector<int>pos = partition(arr, left, right);
if (pos[1] + 1<right)//先入基准右边的,如果基准右边只有一个元素的时候,就不用入了
{
st.push(pos[1] + 1);
st.push(right);
}
if (pos[0] - 1>left)//再入基准左边的,如果基准左边只有一个元素的时候,就不用入了
{
st.push(left);
st.push(pos[0] - 1);
}
}
}
int main()
{
vector<int>arr = { 7,6,5,3,2,5,1,9 ,4,3,10,9 };
cout << "Input array:" << endl;
for (int i = 0; i <arr.size(); i++)
cout << arr[i] << " ";
cout << endl;
int len = arr.size();
quickSort(arr, 0, len - 1);//快排启动
cout << "Output array:" << endl;
for (int i = 0; i < len; i++)
cout << arr[i] << " ";
cout << endl;
system("pause");
return 0;
}
5、优化
1 固定基准数
上面的那种算法,就是一种固定基准数的方式。如果输入的序列是随机的,处理时间还相对比较能接受。但如果数组已经有序,用上面的方式显然非常不好,因为每次划分都只能使待排序序列长度减一。这真是糟糕透了,快排沦为冒泡排序,时间复杂度为 O(n²)。因此,使用第一个元素作为基准数是非常糟糕的,我们应该立即放弃这种想法。
2 随机基准数
这是一种相对安全的策略。由于基准数的位置是随机的,那么产生的分割也不会总是出现劣质的分割。但在数组所有数字完全相等的时候,仍然会是最坏情况。实际上,随机化快速排序得到理论最坏情况的可能性仅为1/(2^n)。所以随机化快速排序可以对于绝大多数输入数据达到 O(nlogn) 的期望时间复杂度。
3 三数取中
事实上,随机性并没有多大的帮助,因此一般的做法是使用左端、右端和中心位置上的三个元素的中值作为基准元。显然使用三数中值分割法消除了预排序输入的不好情形,并且减少快排大约 5% 的比较次数。
4、打乱排序性,也就是打乱原数组的元素顺序
6、快速排序 Vs. 归并排序 Vs. 堆排序
1、快排和堆排的比较
- 快速排序的最直接竞争者是堆排序(Heapsort)。堆排序通常比快速排序稍微慢,但是最坏情况的运行时间总是O(n log n)。快速排序是经常比较快,除了introsort变化版本外,仍然有最坏情况性能的机会。
- 堆排序也拥有重要的特点,仅使用固定额外的空间(堆排序是原地排序),而即使是最佳的快速排序变化版本也需要Θ(log n)的空间。然而,堆排序需要有效率的随机存取才能变成可行。
堆排序的最大致命点:缓存命中率
一句话就是:因为堆排序下,数据读取的开销变大
。在计算机进行运算的时候,数据不一定会从内存读取出来,而是从一种叫cache的存储单位读取。原因是cache相比内存,读取速度非常快,所以cache会把一部分我们经常读取的数据暂时储存起来,以便下一次读取的时候,可以不必跑到内存去读,而是直接在cache里面找。
一般认为读取数据遵从两个原则:temporal locality
,也就是不久前读取过的一个数据,在之后很可能还会被读取一遍;另一个叫spatial locality
,也就是说读取一个数据,在它周围内存地址存储的数据也很有可能被读取到。因此,在读取一个单位的数据(比如1个word)之后,不光单个word会被存入cache,与之内存地址相邻的几个word,都会以一个block为单位存入cache中。另外,cache相比内存小得多,当cache满了之后,会将旧的数据剔除,将新的数据覆盖上去。
在进行堆排序的过程中,由于我们要比较一个数组前一半和后一半的数字的大小,而当数组比较长的时候,这前一半和后一半的数据相隔比较远,这就导致了经常在cache里面找不到要读取的数据,需要从内存中读出来,而当cache满了之后,以前读取的数据又要被剔除。
简而言之快排和堆排读取arr[i]这个元素的平均时间是不一样的。
1、快排和归并排序的比较
- 快速排序也与归并排序(Mergesort)竞争,这是另外一种递归排序算法,但有
坏情况O(n log n)
运行时间的优势。 - 不像快速排序或堆排序,归并排序是一个稳定排序,且可以轻易地被采用在链表(linked list)和存储在慢速访问媒体上像是磁盘存储或网络连接存储的非常巨大数列。
- 尽管快速排序可以被重新改写使用在炼串列上,但是它通常会因为无法随机存取而导致差的基准选择。归并排序的主要缺点,是在最佳情况下需要Ω(n)额外的空间。
原因分析:个人认为是当数据量越来越大时,尽管归并排序的比较次数较少,但是归并排序后期的合并操作所花费的时间便越来越大,合并操作对整体的效率影响越来越明显,包括后面大量数据的赋值操作等。所以当数据量变大时,不需要专门合并的快速排序的优势就变得越发明显。
参考
1、https://www.cnblogs.com/surgewong/p/3381438.html
2、https://blog.youkuaiyun.com/code_ac/article/details/74158681
3、https://www.cnblogs.com/still-smile/p/11547248.html
4、https://www.jianshu.com/p/671981540727