数据结构十大排序
排序可分为稳定排序和非稳定排序(如果 a a a在 b b b的前面,且 a = b a=b a=b,当我们通过某种排序方法进行排序之后, a a a仍然在 b b b的前面,这种排序方法就是稳定的,否则就是不稳定的),排序也可以分为原地排序和非原地排序(如果一个排序方法额外申请了一个内存空间,这种排序就是非原地排序,否则就是原地排序),衡量一个排序方法的好坏可以根据其时间复杂度和空间复杂度来综合考虑。
稳定排序
我们先介绍三种稳定排序:冒泡排序,插入排序,归并排序
一、冒泡排序
冒泡排序的思想:每次比较相邻的两个元素,如果第一个数比第二个大则交换他们的位置,经过一趟之后,最后一个元素一定是最大的,经过多趟这样的操作,最后就会得到一个递增的序列。冒泡排序是稳定的,这是因为如果在比较的过程中如果两个元素相等,我们并没有交换他们的顺序。
5 \color{red}{5} 5 3 \color{blue}{3} 3 2 \color{blue}{2} 2 4 \color{blue}{4} 4 -> 3 \color{blue}{3} 3 5 \color{red}{5} 5 2 \color{blue}{2} 2 4 \color{blue}{4} 4 -> 3 \color{blue}{3} 3 2 \color{blue}{2} 2 5 \color{red}{5} 5 4 \color{blue}{4} 4 -> 3 \color{blue}{3} 3 2 \color{blue}{2} 2 4 \color{red}{4} 4 5 \color{blue}{5} 5
以上展示了第一趟排序的过程,根据这个排序原理,不难写出核心代码
void bubbleSort(vector<int>& v){//冒泡排序
for(int i = 0; i < v.size(); i++){
for(int j = 0; j < v.size() - i - 1; j++){
if(v[j] > v[j + 1])
swap(v[j], v[j + 1]);
}
}
}
从以上代码可看出其时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( 1 ) O(1) O(1),由于没有申请额外的内存空间, 所以冒泡排序是原地排序。
二、插入排序
插入排序的思想:在一个已经有序的序列中插入一个新的元素,将该新元素插入到一个正确的位置, 比如将 4 \color{red}{4} 4 插入到以下序列中
1 \color{blue}{1} 1 3 \color{blue}{3} 3 5 \color{blue}{5} 5 7 \color{blue}{7} 7 -> 1 \color{blue}{1} 1 3 \color{blue}{3} 3 4 \color{red}{4} 4 5 \color{blue}{5} 5 7 \color{blue}{7} 7
对于一个序列进行插入排序的过程就是:把该序列中的第一个元素看成一个有序小序列,然后从第二个元素开始依次插入这个小序列,以下展示对4,3,5,1进行一个插入排序的过程
4 \color{blue}{4} 4 -> 3 \color{red}{3} 3 4 \color{blue}{4} 4 -> 3 \color{blue}{3} 3 4 \color{blue}{4} 4 5 \color{red}{5} 5 -> 1 \color{red}{1} 1 3 \color{blue}{3} 3 4 \color{blue}{4} 4 5 \color{blue}{5} 5
其核心代码如下,其中要注意的是while循环出来之后 j 是哪一个元素的下标
void insertSort(vector<int>& v){
for(int i = 1; i < v.size(); i++){
if(v[i] < v[i - 1]){
int temp = v[i]; //储存要插入的这个元素
int j = i - 1;
while(temp < v[j] && j >= 0){ //找到第一个小于temp的元素,并且j是该元素的下标
v[j + 1] = v[j];//大于temp的元素都往后移动
j--;
}
v[j + 1] = temp; //将temp插入到正确的位置
}
}
}
从代码不难看出插入排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( 1 ) O(1) O(1),由于插入排序是直接在原序列上操作,并没有申请额外的内存空间, 所以它是原地排序。
三、归并排序
我们先解释什么是归并,比如对于两个已经排好序的小序列,再将这两个有序的小序列排成一个有序的大序列,这就是一次归并。
1 \color{blue}{1} 1 3 \color{blue}{3} 3 5 \color{blue}{5} 5 7 \color{blue}{7} 7 , 2 \color{red}{2} 2 4 \color{red}{4} 4 6 \color{red}{6} 6 8 \color{red}{8} 8 - 1 \color{blue}{1} 1 2 \color{red}{2} 2 3 \color{blue}{3} 3 4 \color{red}{4} 4 5 \color{blue}{5} 5 6 \color{red}{6} 6 7 \color{blue}{7} 7 8 \color{red}{8} 8
基于这个思想,对于一个序列 v, v[low] 到 v[mid]、v[mid+1] 到 v[high]是两个有序小序列,将v[low] 到 v[high] 进行一次归并代码如下
//注意:这里的v[low]到v[high]是之间是两个排好序的序列,
//v[low] -- v[mid], v[mid + 1] -- v[high]是两个排好序的序列
//比如: v = {2, 10, 1, 4, 6, 3, 5, 7, 9},进行merge(v, 2, 8)后
//v变为{2, 10, 1, 3, 4, 5, 6, 7, 9}
void merge(vector<int>& v, int low, int high){ //将v中low到high变为有序的
int i = low;
int mid = (low + high) / 2;
int j = mid + 1;
vector<int> temp(v.size(), 0);//临时储存排好序的数
int k = low;
while(i <= mid && j <= high){
if(v[i] > v[j]){
temp[k] = v[j];
j++;
}
else{
temp[k] = v[i];
i++;
}
k++;
}
while( i <= mid){
temp[k] = v[i];
i++;
k++;
}
while(j <= high){
temp[k] = v[j];
j++;
k++;
}
for(int i = low; i <= high; i++)//将v[low]到v[high]变为有序的
v[i] = temp[i];
}
归并排序的思想:把一个待排序的序列分成若干个有序的小序列,然后再将这些小序列两两归并成一个大序列。
具体实现:将待排序序列通过递归分割成只有一个元素的序列,这个时候每个小序列都是有序的(因为只有一个元素的序列肯定是有序的),然后再递归把这些小序列两两归并
代码如下:
void mergeSort(vector<int>& v, int low, int high){ //利用
if(low >= high) //不能再分割了
return ;
int mid = (low + high) / 2;
mergeSort(v, low, mid);//利用merge递归将v[low] 到 v[mid]排好序
mergeSort(v, mid + 1, high);//利用merge递归将v[mid + 1] 到 v[high]排好序
//最后v[low] -- v[mid] 和v[mid + 1] -- v[high]都是有序的,直接用merge
merge(v, low, high);
}
归并排序的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),由于再归并过程中需要开辟一个数组来储存已排好序的元素,所以它是非原地排序,且空间复杂度为 O ( n ) O(n) O(n)。
非稳定排序
接下来介绍七种非稳定排序方法:选择排序、快速排序、堆排序
一、选择排序
选择排序的思想:给每个位置选择当前最小的选择,也就是:第一个位置选择全局最小的, 第二个位置选择当前最小、全局第二小的元素…, 依次类推,直到第 n -1 个位置被确定, 此次第 n个位置的数肯定是最大的
5 \color{blue}{5} 5 2 \color{blue}{2} 2 3 \color{blue}{3} 3 4 \color{blue}{4} 4 -> 2 \color{red}{2} 2 5 \color{blue}{5} 5 3 \color{blue}{3} 3 4 \color{blue}{4} 4 -> 2 \color{red}{2} 2 3 \color{red}{3} 3 5 \color{blue}{5} 5 4 \color{blue}{4} 4 -> 2 \color{red}{2} 2 3 \color{red}{3} 3 4 \color{red}{4} 4 5 \color{blue}{5} 5
代码如下
//选择排序
void selectSort(vector<int>& v){
for(int i = 0; i < v.size() - 1; i++){
int index = i;
for(int j = i + 1; j < v.size(); j++){
if(v[j] < v[index])
index = j;
}
swap(v[i], v[index]);
}
}
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2),空间复杂度
O
(
1
)
O(1)
O(1),原地排序。
非稳定排序,比如
5
\color{red}{5}
5
2
\color{black}{2}
2
5
\color{blue}{5}
5
1
\color{black}{1}
1第一次排序后
1
\color{black}{1}
1
2
\color{black}{2}
2
5
\color{blue}{5}
5
5
\color{red}{5}
5,两个 5 的相对位置发生了改变。
二、快速排序
快速排序思想:
- 选取序列中第一个数为基准,把小于该基准的数交换到前面,大于该基准的数交换到后面。
- 这个时候得到两个子序列,对这两个子序列重复操作 1,又会得到 4 个小序列,对这 4 个小序列再重复操作1,依次类推,直至得到的小序列只有一个元素为止。
接下来我们用 5 \color{black}{5} 5 2 \color{black}{2} 2 3 \color{black}{3} 3 1 \color{black}{1} 1 6 \color{black}{6} 6 8 \color{black}{8} 8 7 \color{black}{7} 7 演示一遍快速排序的流程
5 \color{red}{5} 5 2 \color{black}{2} 2 3 \color{black}{3} 3 1 \color{black}{1} 1 7 \color{black}{7} 7 8 \color{black}{8} 8 6 \color{black}{6} 6 -> 2 \color{black}{2} 2 3 \color{black}{3} 3 1 \color{black}{1} 1 5 \color{red}{5} 5 7 \color{black}{7} 7 8 \color{black}{8} 8 6 \color{black}{6} 6 -> 1 \color{black}{1} 1 2 \color{red}{2} 2 3 \color{black}{3} 3 5 \color{red}{5} 5 6 \color{black}{6} 6 7 \color{red}{7} 7 8 \color{black}{8} 8
代码如下
//快速排序
int partition(vector<int> &v, int left, int right){//一次快排,返回值为分割点下标
int temp = v[left];
while(left < right){
while(left < right && v[right] >= temp)
right--;
v[left] = v[right];
while(left < right && v[left] <= temp)
left++;
v[right] = v[left];
}
v[left] = temp;
return left;
}
void quick_sort(vector<int> &v, int left, int right){//递归的使用快排
if(left >= right)
return;
int mid = partition(v, left, right);
quick_sort(v, left, mid - 1);
quick_sort(v, mid + 1, right);
}
时间复杂度 O ( n l o g n ) O(nlogn) O(nlogn),空间复杂度 O ( l o g n ) O(logn) O(logn),非原地排序。
三、堆排序
堆排序思想:
假设数组 v 有 n+1个元素
- 将数组 v 从v[0] 到 v[n] 建立成一个堆,堆顶(v[0])是最大的一个元素。
- 将堆顶元素(v[0])与堆尾元素 ( v[n] ) 互换,v 中最后一个元素就是最大的一个元素。
- 再对 v[0] 到 v[n - 1] 进行建堆,堆顶就是第二大元素,将堆顶元素(v[0])与堆尾元素 ( v[n - 1] ) 互换,此时 v 中倒数第二个元素就是第二大的一个元素。
- 重复2、3操作,直到这个数组排好序为止。
向下调整函数 HeadAdjust():假设现在有一个二叉树,其左子树和右子树都是一个堆,现在我们需要调整这个头结点的元素使得整个二叉树成为一个堆,这就是向下调整函数
建堆过程的重点就是向下调整函数,向下调整函数代码如下
void HeadAdjust(vector<int> &v, int begin, int end){//建堆时的向下调整函数
int i = begin;
int j = 2 * i + 1;//i的左孩子
int temp = v[begin];
while(j <= end){
if(j + 1 <= end && v[j + 1] > v[j]) //j指向i的左右孩子中最大的一个孩子
j = j + 1;
if(v[j] > temp){
v[i] = v[j];
i = j;
j = 2 * i + 1;
}
else{
v[i] = temp;//如果堆顶均比其左右孩子值大,直接退出
break;
}
}
v[i] = temp;
}
我们从最后一个非叶子节点开始递归使用这个向下调整函数,最后就会得到一个堆。
然后交换堆顶和堆尾元素之后再次建堆
//最后一个非叶子节点下标为(L - 2) / 2
void Head_sort(vector<int> &v){
int L = v.size();
for(int i = (L - 2) / 2; i >= 0; i--)//从最后一个非叶子节点开始建堆
HeadAdjust(v, i, L- 1);
//此时v中元素已经建好了堆
for(int i = L - 1; i >= 0; i--){
swap(v[i], v[0]);//交换堆顶和堆尾元素
HeadAdjust(v, 0, i - 1);//对v[0] 到v[i - 1]再建堆
}
}
时间复杂度 O ( n l o g n ) O(nlogn) O(nlogn),空间复杂度 ( O ( 1 ) (O(1) (O(1),是原地排序。
四、希尔排序
在插入排序中,如果每一次插入时的最大值都在第一位,那么都需要将这个最大值移动到末位,这是非常消耗时间的。于是我们在插入排序上进行一些改进便形成了希尔排序,所以希尔排序其实是插入排序的变种。希尔排序思想如下:
- 首先取一个整数 d 1 = n / 2 d_1 = n/2 d1=n/2,将元素分为 d 组,分别对每一组进行插入排序
- 再取
d
2
=
d
1
/
2
d_2 = d_1 / 2
d2=d1/2,重复上述过程,直到
d
n
=
1
d_n = 1
dn=1
希尔排序是改进的插入排序,所以其代码在插入排序的基础上加以改进即可
//希尔排序
void insertSortGap(vector<int>& v, int gap){
for(int i = gap; i < v.size(); i++){
if(v[i] < v[i - gap]){
int temp = v[i]; //储存要插入的这个元素
int j = i - gap;
while(temp < v[j] && j >= 0){ //找到第一个小于temp的元素,并且j是该元素的下标
v[j + gap] = v[j];//大于temp的元素都往后移动
j -= gap;
}
v[j + gap] = temp; //将temp插入到正确的位置
}
}
}
void shellSort(vector<int>& v){
int d = v.size() / 2;
while(d >= 1){
insertSortGap(v, d);
d /= 2;
}
}
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1),是原地排序。
五、计数排序
对 0 到 100 之间整数进行排序,我们可以选择计数排序。计数排序并不是比较排序,所以他的速度相比于其他排序来说是比较快的,其思想比较简单:将各个元素出现的次数记录在一个更大的数组中,该数组的下标就是元素大小,下标对应的值就是该元素出现的次数,然后遍历这个数组, 元素出现几次我们就输出几次。
//计数排序
void countSort(vector<int>& v){
int m = *max_element(v.begin(), v.end()) + 1;
vector<int> temp(m, 0);
for(auto& a : v){
temp[a]++;
}
v.clear();//
for(int i = 0; i < m; i++){
while(temp[i] > 0){
v.emplace_back(i);
temp[i]--;
}
}
}
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( n ) O(n) O(n),非原地排序。
桶排序
当 n 很小时,对 0 到 n 用计数排序效率是最高的, 但是当 n 很大时,用计数排序需要开辟一个很大的空间,这是非常占用内存的。这个时候我们就考虑用桶排序,桶排序的思想是:把 0 到 分成多个区间,每个区间看成一个桶,把每个数字按照大小放入桶里, 保证第 i i i 个桶的数都没有第 i + 1 i + 1 i+1 个桶的数大,然后再对每个桶进行排序。
代码如下
//桶排序
void bucketSort(vector<int>& v){
int defaultSize = 3; //设定桶的数量为3
int maxValue = *max_element(v.begin(), v.end());
int minValue = *min_element(v.begin(), v.end());
vector<vector<int>> buckets(defaultSize);
int temp = minValue + (maxValue - minValue) / defaultSize;
for(auto& a : v){
buckets[a / temp].emplace_back(a);//对桶初始化
}
v.clear();
for(auto& a : buckets){
if(!a.empty()){
selectSort(a);//对每个桶用插入排序来排序
for(auto& b : a)
v.emplace_back(b);
}
}
}
时间复杂度 O ( n + k ) O(n+k) O(n+k),空间复杂度 O ( n ) O(n) O(n),非原地排序。
基数排序
基数排序的实现原理就是进行多次桶排序,只不过基数排序中桶的数量被固定成了10个,这里我们用 v[10]来表示桶,其思想是:首先找到最大的一个数,假设这个数是 n 位数,第一次按照每个数的个位数字将其放入对应的桶中(比如个位是 0 的元素放在 v[0]中,个位是1的元素放入v[1]中…,个位是9的元素放在v[9]中),然后按照放入桶的顺序依次取出,第二次再按照每个数的十位数字将其放入对应的桶中(同理,十位是 0 的元素放在 v[0]中,十位是1的元素放入v[1]中…,十位是9的放在v[9]中)。
代码如下
//基数排序
void radixSort(vector<int>& v){
int maxValue = *max_element(v.begin(), v.end());
//计算maxValue是几位数
int d = 0;
while(maxValue){
maxValue /= 10;
d++;
}
//接下来需要做d次桶排序
vector<vector<int>> count(10);//
int it = 1;
for(int i = 1; i <= d; i++){
for(int i = 0; i < 10; i++){//注意对二维数组count清空不能直接用count.clear()
count[i].clear();
}
//放入对应的桶中
for(int j = 0; j < v.size(); j++){
int temp = (v[j] / it) % 10;
count[temp].push_back(v[j]);
}
v.clear();
//从桶中取出依次放入v中以便下一次进行桶排序
for(int i = 0; i < 10; i++){
if(!count[i].empty()){
for(int j = 0; j < count[i].size(); j++){
v.push_back(count[i][j]);
}
}
}
it *= 10;
}
}
时间复杂度 O ( n k ) O(nk) O(nk),空间复杂度 O ( n + k ) O(n+k) O(n+k),非原地排序。