如何分析评价一个排序算法?
- 最好、最坏情况和平均时间复杂度
- 空间复杂度,这里有一个专有名词原地排序,指的是空间复杂度为O(1)的排序
- 排序的稳定性
这里需要详细讲一下第三点,排序的稳定性:假设现在我们有一组数据{4,3,1,2,1}需要排序,排序后为{1,1,2,3,4},如果排序前后两个1的前后顺序没有改变,那么就说这个排序算法是稳定的排序算法。
这个稳定性有什么用?假设现在要为学校的学生的考试总成绩进行排序,分数相同的按照学号的大小顺序进行排序。
比较容易想到的做法是,直接按照成绩进行排序,然后在遍历数据,成绩相同的数据再按照学号大小排序,这种方法实现起来会比较复杂。
如果是稳定的排序算法,这个问题可以很好地解决:先将所有数据按照学号大小进行排序,再对排序好的数据按照成绩进行排序。第一次排序后,所有数据按照学号大小排好了,进行第二次排序,由于稳定排序可以保持排序前后成绩相同的两个数据前后顺序不变,所以第二次排序完成后,成绩相同的两个数据他们的学号大小也是排好的。
时间复杂度为O(n^2)的排序
冒泡排序
数组下标 | 原始数据 | ||||
---|---|---|---|---|---|
4 | 1 | 1 | 1 | 1 | 5 |
3 | 2 | 2 | 2 | 5 | 1 |
2 | 4 | 4 | 5 | 2 | 2 |
1 | 3 | 5 | 4 | 4 | 4 |
0 | 5 | 3 | 3 | 3 | 3 |
假设现在要为数组{5,3,4,2,1}进行排序,上面就是第一次进行冒泡的操作,从下标为0开始,比较这个位置和下一个位置的数组值的大小,如果大于就交换,否则就不交换,直到遍历完数组,这就完成了一次冒泡。可以看到在第一次冒泡的过程中,最大的值5慢慢往上冒,直到到达数组的最后的位置,这个过程很像气泡冒泡的过程,因此也叫作冒泡排序。
一次冒泡就会将最大值排到数组最后的位置,所以有n个数据就需要n次冒泡,每次冒泡排好一个数据。
数组下标 | 原始数据 | 第一次冒泡 | 第二次冒泡 | 第三次冒泡 | 第四次冒泡 | 第五次冒泡 |
---|---|---|---|---|---|---|
4 | 1 | 5 | 5 | 5 | 5 | 5 |
3 | 2 | 1 | 4 | 4 | 4 | 4 |
2 | 4 | 2 | 1 | 3 | 3 | 3 |
1 | 3 | 4 | 2 | 1 | 2 | 2 |
0 | 5 | 3 | 3 | 2 | 1 | 1 |
上面列出了5次冒泡的过程,可以看到最后一次的冒泡是不需要的,因为数组已经排好序了,所以我们可以优化代码,当没有数据交换时结束排序。
template<typename T>
void bubbleSort(std::vector<T> &vec) {
int n = vec.size();
if (n <= 1)
return;
for (int i = 0; i < n; i++) {
printVec(vec); //打印过程
bool flag = false; //flag表示是否有数据交换(冒泡)
for (int j = 0; j < n - i - 1;j++) { //为什么n减i还要减1,因为要访问j+1
if (vec[j] > vec[j + 1]) {
T tmp = vec[j];
vec[j] = vec[j + 1];
vec[j + 1] = tmp;
flag = true; //如果有数据可以交换,那么将flag置为true
}
}
if (!flag)break; //如果没有数据交换,说明vec已经排好序了,直接跳出循环,算是一种优化
}
return;
}
template<typename T>
void printVec(std::vector<T> &vec) {
for (const auto& v : vec)
std::cout << v << " ";
std::cout << std::endl;
}
代码还是比较简单的,这里就不解释了。
下面进行算法分析:
- 冒泡排序是原地排序,只用了一个临时变量tmp进行数据交换
- 冒泡排序是稳定排序,因为在进行比较时,a[i]>a[i+1]才交换,等于时不交换,相同的数据在排序前后顺序不变。(如果是a[i]>=a[i+1]时交换就不是稳定排序了)
- 最好的情况是数据已经是有序的,此时只需要进行一次冒泡,时间复杂度是O(n);最坏的情况是数据倒序,此时需要进行n次冒泡,时间复杂度为O(n^2); 平均时间复杂度是O(n^2)。
插入排序
插入排序的思路也比较简单:将数据分割为有序区和无序区,初始时有序区就是数组的第一个元素,也就是下标为0的位置,无序区就是下标为1的位置开始到数组的末尾。有序区末尾的下标=有序区起始下标-1。
遍历无序区的元素,选到这个元素value后,从有序区的末尾开始往前遍历,找到第一个小于value的元素的位置,这个位置的后一个位置就是插入value的位置。
上图红色区域表示有序区,蓝色区域表示无序区,遍历无序区,每次都会拿到无序区的第一个元素,然后在有序区里找到插入的位置,执行插入操作。
template<typename T>
void insertionSort(std::vector<T>& vec) {
for (int i = 1; i < vec.size(); i++) { //vec[i]是待排序的目标,在有序区中找位置插入
printVec(vec);
int value = vec[i]; //将vec[i]先存储起来,后面可能会覆盖掉
int j = i - 1; //有序区的末尾
for ( ; j >= 0; j--) { //从末尾往前找
if (vec[j]>value) {
vec[j + 1] = vec[j]; //vec[i]在这里可能会被覆盖掉
}
else { //在有序区找到第一个value>=vec[j]的位置,vec[j+1]就是value要插入的位置
break;
}
}
vec[j+1] = value;
}
printVec(vec);
}
下面是算法的分析:
- 插入排序是原地排序,只需要一个value存储要插入的元素,空间复杂度为O(1)
- 插入排序是稳定排序,我们可以将后面出现的元素插入到前面出现的元素的后面,这样就保持了相同元素的前后顺序不变了
- 最好的情况是数据已经有序,每次在有序区从后往前比较,那么每次只需要比较一次就可以break了,所以最好情况下时间复杂度为O(n);最坏情况是数据倒序,每次都是在数组头部插入,有序区所有元素都要往后移动一次,最坏情况时间复杂度为O(n^2); 平均时间复杂度为O(n^2)。
选择排序
选择排序和插入排序有点像,也是分为有序区和无序区,但是有序区一开始是没有元素的,遍历数据,在无序区中找到最小的元素,与无序区的头部交换。
红色区域表示有序区,蓝色区域表示无序区。遍历无序区找到最小元素1,然后和无序区头部3交换,交换后就1变成了有序区。
template <typename T>
void selectSort(std::vector<T>& vec) {
for (int i = 0; i < vec.size(); i++) { //i就是无序区的头部,每次循环都从[i+1,size)中找出最小的值,然后交换到i处
printVec(vec);
int min_index = i;
for (int j = i + 1; j < vec.size(); j++) {
if (vec[j] < vec[min_index])
min_index = j;
}
T tmp = vec[i];
vec[i] = vec[min_index];
vec[min_index] = tmp;
}
}
下面是算法分析:
- 选择排序是原地排序,只用到一个临时变量tmp用来交换,空间复杂度为O(1)
- 选择排序不是稳定排序,因为每次交换可能会把原先排到前面的元素交换到后面去,有可能会破坏相同元素的前后顺序。例如对{3,3,2,1}进行排序,一开始就把第一个3和末尾的1进行交换,破坏了顺序。
- 最好情况,最坏情况和平均时间复杂度都是O(n^2)
时间复杂度为O(nlogn)的排序
归并排序
归并排序用了分治的思想,分而治之,将大问题分解成小问题进行解决。归并排序就是将整个数组进行分解成一个一个的元素,然后再进行合并。
template <typename T>
void merge(std::vector<T>& vec,T tmp[], int left, int mid, int right) {
int i = left, j = mid + 1;
int tmp_index = 0;
while (i <= mid && j <= right) {
if (vec[i] <= vec[j])
tmp[tmp_index++] = vec[i++];
else
tmp[tmp_index++] = vec[j++];
}
while (i <= mid)
tmp[tmp_index++] = vec[i++];
while (j <= right)
tmp[tmp_index++] = vec[j++];
for (i = left; i <= right; i++)
vec[i] = tmp[i-left]; //注意这里tmp是从0开始的,所以下标是i-left
printVec(vec);
}
template <typename T>
void merge_sort(std::vector<T>& vec, T tmp[], int left, int right) {
if (left >= right)return;
int mid = left + (right - left) / 2;//防止(right+left)/2时right+left溢出
merge_sort(vec, tmp, left, mid);
merge_sort(vec, tmp, mid + 1, right);
merge(vec, tmp, left, mid, right);
}
template <typename T>
void mergeSort(std::vector<T> &vec) {
printVec(vec);
T* tmp = new T[vec.size()];
merge_sort(vec, tmp, 0, vec.size()-1);
delete[] tmp;
}
我们可以看到mergeSort只是一个入口,在这里申请了一个vec.size()个的空间,然后进入merge_sort中进行递归。
merge_sort中先要确定递归终止的条件:left >= right,递归直到分割成单个的元素结束,这个应该比较好理解。
然后分别对分解的两个区间[left,mid]和[mid+1,right]继续递归分解,分解完后调用merge进行合并。合并的过程是将两个区间[left,mid]和[mid+1,right]的元素按照大小放在tmp中,然后再从tmp拷贝回vec里。
下面是算法分析:
- 归并排序不是原地排序,额外申请了n个数据空间的内存,空间复杂度为O(n),这也是归并排序没有快排应用广泛的原因
- 归并排序是稳定排序,我们在merge合并时,如果有相同的元素,我们先将前面的元素放入tmp,所以可以保证相同元素的前后顺序一致(在第一个while循环里的第一个if条件判断,if (vec[i] <= vec[j]) ,如果是<而不是<=就不是稳定排序了)
- 时间复杂度为O(nlogn)
时间复杂度的推导:假设归并排序的数组只有一个元素,那么只需要常数C次操作。当需要排序n个元素时,分解为两个n/2的区间,合并时需要n次操作;分解为4个n/4的区间,合并时需要2n次操作;每分解多一层,就需要多一次合并操作(一次合并需要n)。假设分解k次之后,就不能再分了,所有区间都只有一个元素,然后计算得到k=log2n,代入回T(n)得到T(n)=Cn+nlog2n,所以时间复杂度为O(nlogn)。
我们也可以画出递归树:
快速排序
快速排序,也称为快排,也是用的分治思想。整体的思路是这样:假设要排序的数组下标在[left,right]区间,在这个区间内任意选择一个数据作为pivot(分区点),然后遍历[left,right]进行分区,将所有小于pivot的放在pivot左边,将所有大于pivot的放在pivot右边,pivot放中间,那么整个数组就分成了三部分(假设pivot的下标为mid):[left,mid-1],mid,[mid+1,right]。然后接着对左右两部分的数组按照上述思路递归处理,直到区间缩小到只有一个元素。
那怎么进行分区呢?即将所有小于pivot的放在pivot左边,将所有大于pivot的放在pivot右边呢?
比较容易想到的做法是像归并排序那样申请一个额外的空间,用两个游标i和j,一个从新空间头开始往后走,一个从新空间的末尾开始往前走,遍历数据,当遇到小于pivot的放在头部,遇到大于pivot的放在尾部,最后剩下的位置放pivot,最后把新空间的数据拷贝到原空间中。这样的做法可以,但是需要额外的空间,空间复杂度是O(n)。
这里有一个原地分区的算法比较巧妙:同样也是使用两个下标 i 和 j,都是从0开始,i 表示下一个可以用来交换的空间(i的位置是用来放小于pivot的元素的),j是用来遍历整个数组的,在用 j 遍历数组时,将arr[j]和pivot进行比较,如果arr[j]<pivot,就将arr[j]和arr[i]互换,同时 i 自加变成下一个可以用来放小于pivot的元素的位置,j 因为是遍历用的所以就算不交换 j 也要自加。
这里贴上上述原地分区的代码,结合着看应该能看懂。
template<typename T>
int partition(std::vector<T>& vec, int left, int right) {
int pivot = vec[right]; //pivot取末尾的元素
int i =0 , j = 0; //i表示下一个可以交换的位置,j用来遍历[left,right-1]中比pivot小的值,然后和vec[i]交换
while (j < right) {
if (vec[j] < pivot) {
std::swap(vec[i], vec[j]);
i++;
}
j++;
}
std::swap(vec[i], vec[right]);
return i; // i就是pivot的位置
}
让我们来看一个简单的例子:现在按照上面的思路对{5,6,2,4,1,3}进行快排(pivot选择要排序的数组的最后一个元素)
递归次数 | 处理的数组 | pivot | 处理后的数组 |
---|---|---|---|
1 | {5,6,2,4,1,3} | 3 | {2,1,3,4,6,5} |
2 | {2,1} | 1 | {1,2} |
3 | {4,6,5} | 5 | {4,5,6} |
首先第一次递归,选取3 作为pivot,那么原地分区将小于3的放在左边,大于3的放在右边,于是处理后变成{2,1,3,4,6,5},那么变成了三部分,分别是{2,1},3,{4,6,5},然后分别对左右两部分进行递归也就是{2,1}和{4,6,5}进行递归处理。
所以第二次递归是对{2,1}进行处理,pivot是1,处理后变成{1,2},只剩两个部分了,1和{2},区间缩小到只剩一个元素,不用再继续递归了。
因此第三次递归对{4,6,5}进行处理,pivot是5,处理后变成了{4,5,6},变成了3部分:{4},5,{6},区间也是缩小到只剩下一个元素,也不必继续对左右两个部分递归了。
至此,快排结束,数据也排序完成:{1,2,3,4,5,6}。
下面是完整代码:
template<typename T>
int partition(std::vector<T>& vec, int left, int right) {
int pivot = vec[right];
int i =0 , j = 0; //i表示下一个可以交换的位置,j用来遍历[left,right-1]中比pivot小的值,然后和vec[i]交换
while (j < right) {
if (vec[j] < pivot) {
std::swap(vec[i], vec[j]);
i++;
}
j++;
}
std::swap(vec[i], vec[right]);
return i; // i就是pivot的位置
}
template<typename T>
void quick_sort(std::vector<T>& vec, int left, int right) {
if (left >= right)return;
printVec(vec);
int mid = partition(vec, left, right);
quick_sort(vec, left, mid - 1);
quick_sort(vec, mid + 1, right);
}
template<typename T>
void quickSort(std::vector<T>& vec) {
quick_sort(vec, 0, vec.size() - 1);
}
可以看到quickSort也只是一个入口,真正递归的函数是quick_sort。
递归结束的条件是区间缩小到只剩一个元素,因此递归结束条件是left >= right。然后用分区函数partition对整个数组进行分区,也就是将所有小于pivot的放在pivot左边,将所有大于pivot的放在pivot右边,mid就是pivot的位置,根据mid将整个数组分成三部分,对左右两个部分[left,mid-1]和[mid+1,right]继续递归处理直到递归结束。
下面是对快排的算法分析:
- 快排是一种原地排序算法,使用上面提到的原地分区算法,空间复杂度为O(1)
- 快排是一种不稳定的排序,pivot放到中间的时候或者分区时候的交换可能会导致相同数据的前后顺序变化
- 快排最好情况和平均时间复杂度为O(nlogn),最坏情况时间复杂度为O(n^2)
下面进行时间复杂度的推导:
最好的情况就是选择的pivot每次都能分区成两个长度相等的区间,设一次递归需要的时间为P(n),我们可以看到一次递归的时间主要是用在分区函数partition上,这个函数里比较的次数为n-1(n为递归处理的数据的数量,选择了vec[right]为pivot,所以每次都是[left,right-1]的数据和pivot比较,所以是n-1次比较),那么我们简化就计P(n)=n-1。
设对n个数据进行快排需要的时间为T(n),也就是quick_sort对一个长度为n的数组排序所需要的时间,quick_sort里调用了一次partition和两次quick_sort(但是规模从n变成了n/2),这也是下图中T(n)=P(n)+2T(n/2)的由来:
也可以画出递归树进行分析:
再来看看最坏情况,举个例子,对已经有序的数组{1,2,3,4,5,6}进行排序,每次选择数组末尾的元素作为pivot,会导致分区的极度不平衡,而且只能分成{1,2,3,4,5}和6两个部分,所以每次分区都是长度为【处理的数组长度-1】和1两个部分。
下面是推导过程,其中P(n)=n-1,跟上面最好情况的一样。
同样的也可以画出递归树:
注意递归树上不是数组的长度,而是分区的代价。比如根结点是n-1,指的是长度为n的数组在partition分区时比较的次数是n-1,因为pivot不需要和自己比较,所以递归树的根结点是n-1。
平均情况这里有个链接的答案写的比较好:算法 | 快速排序平均时间复杂度分析
时间复杂度为O(n)的排序
桶排序
桶排序,就是将数据分到有序的桶中,然后每个桶再各自排序,最后就能得到有序的数据了。举个例子,现在要为整个年级的学生的数据根据数学成绩进行排序,我们可以用10个桶,1-10分的分一个桶,11-20分的一个桶…91-100分的一个桶,然后每个桶里再单独进行排序,最后把每个桶里的数据按顺序取出来,就能得到有序的数据了。
为什么桶排序的时间复杂度为O(n)呢?假如要排序的数据有n,分m个桶,假设数据在每个桶里分布均匀,那么每个桶就有n/m个数据,每个桶内部使用快排,那么一个桶排序需要O(n/m * log(n/m) ),m个桶总共需要O(m* n/m * log(n/m) )=O(nlog(n/m)),当n/m是一个比较小的数的时候,log(n/m)是一个比较小的数(log10000的结果也才13.2左右),可以看成常量C,所以O(nlog(n/m))=O(n)。
在最坏的情况下,所有数据都分到一个桶里,那么桶排序就退化成单个的快排,时间复杂度也退化到O(nlogn)。
桶排序时间复杂度为O(n)看起来好像很优秀的样子,但是实际上要满足上述所说的分布均匀的情况还是让使用的场景得到了很大的限制。
template <typename DataType>
void bucketSort(std::vector<DataType> &vec , size_t bucket_num) {
DataType min = *std::min_element(vec.begin(), vec.end());
DataType max = *std::max_element(vec.begin(), vec.end());
DataType range = max - min + 1;
size_t each_bucket_size = (range - 1) / bucket_num + 1; //相当于(range/bucket_num)向上取整,这是每个桶的大小
std::vector<std::vector<DataType>> buckets(bucket_num); //创建bucket_num个桶
for (auto b : buckets)
b.reserve(2 * each_bucket_size); //但是数据在每个桶未必都是均匀分布,所以这里预留了两倍的空间,如果不够vector会自动扩容的
for (int i = 0; i < vec.size(); i++) {
int idx = (vec[i] - min) / each_bucket_size; //vec[i]应该放在哪个桶
buckets[idx].emplace_back(vec[i]);
}
int count = 0;
for (auto b : buckets) {
printVec(vec);
std::sort(b.begin(), b.end()); //每个桶内排序
std::copy(b.begin(), b.end(), vec.begin() + count); //将每个桶复制回vec
count += b.size();
}
printVec(vec);
}
桶排序比较适合用在外部排序,也就是数据存储在磁盘里,内存有限,无法将所有的数据一次性读入内存。
举个例子,现在有10G的订单数据,可用的内存只有200MB,要按照订单金额对数据进行排序。由于内存不够,我们无法一次性将10G数据读入内存进行快排或者其他排序,我们可以使用桶排序,我们先扫描一遍数据文件,得到所有数据的范围,也就是最大和最小金额,假设金额在[1,10w]之间,我们可以分100个桶,每1000元分一个桶,假设分布均匀的情况,每个桶有100M数据,可以读入内存进行快排,排好序后写入单独的文件,最后再将这些单独的文件按照顺序一个个写入汇总的文件中,就能得到排好序的数据文件了。
那如果是分布不均匀的情况呢?假设第一个桶[1,1000]的数据特别多,内存装不下,我们可以再对这个桶分桶,假设每100元一个桶;如果划分后还是不均匀,假设[1,100]的数据特别多,我们就继续对这个桶分桶,每10元一个桶,以此类推,直到分到内存能够装得下。
基数排序
基数排序适用于数据能够按位比较的:例如根据手机号码进行排序。
思路和文章开头所说的稳定排序的思路一致,先按照最后一位进行排序,接着倒数第二位进行排序…最后再按照第一位进行排序,注意需要使用稳定的排序算法。假如我们对每一位使用的是桶排序,每一位排序的时间复杂度是O(n),手机号码有11位,因此时间复杂度为O(11n),当数据量n很大时,可以忽略不计,因此时间复杂度为O(n)。
如果数据不等长的情况,例如为字典里的单词排序或者人名排序,我们可以给所有单词或者人名后面补0,补到和最长的单词或者最长的人名一样的长度,再进行基排序。