排序是将一组随机数组排成有序的。关于排序算法,也分有很多种,具体分类如下图:
下面我们来看一下以下这些排序的区别及代码实现,本篇文章的测试用例一律采用升序排序。
插入排序
直接插入排序
这是最常见的也是最常用的排序方法,也是在数组元素少的时候效率最佳的方法。因为这个方法是在是太常见了,所以一般来说我们平时所说的插入排序,实际就是指直接插入排序。
- 核心思想:从第二个元素开始,将之前的所有元素都看作是有序的,依次与前一个元素相比较。若比前一元素大则不变,继续下一个元素的插入排序;若比前一元素小,则交换两个位置的元素,继续与再前一个元素比较,直到比较的结果是前面的元素比该元素小或是等于该元素则停止比较。所以总体看起来,直接插入排序是将一个新元素插入到一个有序数组中,插入在比它小的元素和比它大的元素的中间位置。
- 稳定性:注意,这里说的稳定是指,在排序的过程中,原有元素的顺序不会改变。比如有一个序列
int a[10]={2,5,4,9,5,6,8,5,1,0}
,我们要对该序列进行排序。这个数组中有三个5,那么稳定性就是指在排序完之后,这三个5的相对顺序不能发生变化。我们姑且称这三个5按目前顺序是A5,B5,C5。在排完序后的数组是int a[10]={0,1,2,4,5,5,5,6,8,9}
,那么此时这三个5如果还是A5,B5,C5的顺序则是稳定的,若不是,比如B5,C5,A5,则是不稳定的。显然,直接插入排序是稳定的排序。 - 时间复杂度:直接插入排序的平均时间复杂度是O(N^2),最好情况即本身是有序的情况是O(N),最坏情况即本身是逆序的情况是O(N^2)。
空间复杂度:直接插入排序的空间复杂度为O(1)。它只是在原数组上改动。
下面是关于直接插入排序的测试代码
//这里先写一个打印数组的函数,在后面的测试用例中就不继续写了
void Print(int* a,size_t n)
{
assert(a);
for (size_t i = 0; i < n; ++i)
{
cout << a[i] << " ";
}
cout << endl;
}
void InsertSort(int* a,size_t n)
{
assert(a);
/*思想:从第二个数开始,认为前面所有的数都是有序的,
如果当前数的后一个数比它大,则两者交换,再向前比较*/
for (size_t i = 0; i < n-1; ++i)//这里因为后面要写到j+1,所以i<n-1
{
for (size_t j = i; j >= 0; --j)
{
if (a[j + 1] < a[j])
{
swap(a[j + 1], a[j]);
}
else
{
break;
}
}
}
}
//测试函数
int main()
{
int a[] = { 2,5,4,9,3,6,8,7,1,0 };
const size_t n = sizeof(a) / sizeof(a[0]);
InsertSort(a,n);
Print(a, n);
system("pause");
return 0;
}
希尔排序
希尔排序实际上是对直接插入排序在最坏情况下的一个优化。
核心思想:通过一个增量gap(gap=n/2,n是数组元素的个数)对一个数列进行分组,一组中的每个成员在数列中的下标相差gap,然后在每一组内重新定义一个(gap=gap/2)继续分组,直到最后gap=1的时候,就是一个直接插入排序。具体的过程如下图
稳定性:显然,希尔排序是一个不稳定的排序。因为在分组的过程中,相同的元素可能被分组不同的组,它们的相对顺序就可能不一样了。
- 时间复杂度:希尔排序的平均时间复杂度大约为O(N^1.3),最好时间复杂度是O(N),最坏时间复杂度是O(N^2)。
- 空间复杂度:希尔排序的空间复杂度是O(1),它也只是在原数组上改动。
下面是关于希尔排序的代码:
void ShellSort(int* a, size_t n)
{
assert(a);
int gap = n;
/*对于增量gap的选择,第一次选择n/2,往后就是(n/2)/2,以此类推,
直到为1的时候,即最后一趟就是一个直接插入排序*/
while (gap > 1)
{
gap = gap / 2;
for (size_t i = 0; i < n - gap; i++)//因为下面要用到i+gap,所以这里i<n-gap
{
int end = i+gap;//从下标为i+gap开始算,和i是一组
while (end >= 0&&end-gap >= 0)
{
if (a[end] < a[end - gap])
{
swap(a[end], a[end - gap]);
}
else
{
end -= gap;//与前一个同组元素比较,直到比较到同组的第一个元素
}
}
}
}
}
//测试函数
int main()
{
int a[] = { 2,5,4,9,3,6,8,7,1,0 };
const size_t n = sizeof(a) / sizeof(a[0]);
ShellSort(a,n);
Print(a, n);
system("pause");
return 0;
}
选择排序
选择排序
选择排序应该是最简单最能让人理解的排序了。
- 核心思想:选择一个最小的数放在下标为0的位子上,再对剩余的数选择最小的数放在下标为1的位子上,以次类推
- 稳定性:很显然这也是一个不稳定的排序。因为在一趟选择最小数的过程中发生着交换元素的动作,这很容易造成相同元素相对顺序的改变。比如[5,5,3],第一趟选择就将第一个5余3交换位置,则两个5的相对位置就变了。
- 时间复杂度: 选择排序的平均、最好、最坏时间复杂度都是O(N^2)。无论什么情况,它都要选择一遍。
空间复杂度: 选择排序的空间复杂度是O(1)。只是在原数组上改动。
下面是关于选择排序的代码:
void SelectSort(int* a, size_t n)
{
assert(a);
/*选择一个最小的数放在下标为0的位子上,再对剩余的数选择最小的数放在下标为1的位子上,以次类推*/
for (size_t i = 0; i < n-1; ++i)
{
for (size_t j = i; j < n - 1; ++j)
{
if (a[i] > a[j + 1])
{
swap(a[i], a[j + 1]);
}
}
}
}
//测试函数
int main()
{
int a[] = { 2,5,4,9,3,6,8,7,1,0 };
const size_t n = sizeof(a) / sizeof(a[0]);
SelectSort(a,n);
Print(a, n);
system("pause");
return 0;
}
堆排序
堆排序是利用建一个大堆实现的排序算法。(注意不是小堆,很多人误认为这里是建小堆)
- 核心思想:关于堆的介绍及实现,以及向下调整算法的分析,可通过另一篇博客堆的实现及应用了解。
- 稳定性 :堆排是不稳定的,不稳定的!
- 时间复杂度 :堆排的平均、最好、最坏时间复杂度都是(O(N*lgN))
空间复杂度 :堆排的空间复杂度是O(1),本质也是在原数组上改动。
下面是关于堆排的代码:
void AdJustDown(int a[], size_t n, int root)
{
if (a == NULL)
{
return;
}
int parent = root;
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n&&a[child] < a[child + 1])
{
child = child + 1;
}
if (a[parent] < a[child])
{
swap(a[parent], a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int a[], size_t n)
{
if (a == NULL)
{
return;
}
for (int i = (n - 2) / 2; i >= 0; --i)
{
AdJustDown(a, n, i);
}
int end = n - 1;
for (size_t i = 0; i < n; ++i)
{
swap(a[0], a[end]);
AdJustDown(a, end, 0);
--end;
}
}
//测试函数
int main()
{
int a[] = { 2,5,4,9,3,6,8,7,1,0 };
const size_t n = sizeof(a) / sizeof(a[0]);
HeapSort(a,n);
Print(a, n);
system("pause");
return 0;
}
交换排序
冒泡排序
冒泡排序是最为程序所知的,最出名的排序算法。
- 核心思想:从第一个数开始,两两比较,将小的放到前面,经过一轮比较,最大的数被放到了最后面,再经过一轮比较,次大的数就放到了最大数的前面,以此类推。这个过程就像是在吐泡泡一样,就被称为冒泡排序。
- 稳定性: 冒泡排序是稳定的!因为当两个相同的元素进行比较时,是不交换的,而且因为它是逐对比较的,所以相同元素的相对顺序是不会变化的。
- 时间复杂度 :冒泡排序的平均时间复杂度是O(N^2),但它的最好时间复杂度是O(N),即数组本身有序的情况,最坏时间复杂度是O(N^2)。
- 空间复杂度 :空间复杂度为O(1),也是在原数组上改动。
下面是关于冒泡排序的代码
void BubbleSort(int* a, size_t n)
{
assert(a);
/*想法:从第一个数开始,两两比较,将小的放到前面,经过一轮比较,最大的数被放到了最后面,再经过一轮比较,次大的数就放到了最大数的前面,以此类推*/
int count = 0;//通过计数来优化冒泡排序
for (size_t i = 0; i < n-1; ++i)//这里最后两个数排完序后已经有序,不用再对最后一个数排序
{
for (size_t j = 0; j < n -1- i; ++j)//这里排完一轮之后最后一个数就不用比较了,所以j<n-1-i
{
if (a[j] > a[j + 1])
{
swap(a[j], a[j + 1]);
count++;
}
}
if (count == 0)//到这表示该数组以此交换都没有发生过,即本身就是有序的,直接返回
{
return;
}
}
}
//测试函数
int main()
{
int a[] = { 2,5,4,9,3,6,8,7,1,0 };
const size_t n = sizeof(a) / sizeof(a[0]);
BubbleSort(a,n);
Print(a, n);
system("pause");
return 0;
}
快速排序
总体关于快速排序的内容,因为关于快速排序有很多的优化,所以另写一篇博客来单独讲快排快排的三种方式及快排的优化
这里只写一段最基础的快排的方法。
void QuickSort(int* a, int left, int right)
{
assert(a);
int i, j;
if (left < right)
{
i = left + 1;//以第一个数left作为基准数,从left+1开始作比较
j = right;
while (i < j)
{
if (a[i] > a[left])//如果比较的数比基准数大
{
swap(a[i], a[j]);//把该比较数放到数组尾部,并让j--,比较过的数就不再比较了
j--;
}
else
{
i++;//如果比较的数比基准数小,则让i++,让下一个比较数进行比较
}
}
//跳出while循环后,i==j
//此时数组被分成两个部分,a[left+1]-a[i-1]都是小于a[left],a[i+1]-a[right]都是大于a[left]
//将a[i]与a[left]比较,确定a[i]的位置在哪
//再对两个分割好的部分进行排序,以此类推,直到i==j不满足条件
if (a[i] >= a[left]) //这里必须要用>=,否则相同时会出现错误
{
i--;
}
swap(a[i], a[left]);
QuickSort(a, left, i);
QuickSort(a, j, right);
}
}
//测试函数
int main()
{
int a[] = { 2,5,4,9,3,6,8,7,1,0 };
const size_t n = sizeof(a) / sizeof(a[0]);
QuickSort(a,0,n-1);
Print(a, n);
system("pause");
return 0;
}
归并排序
归并排序
归并排序是采用分治法的一个非常典型的应用。
- 核心思想:先将当前区间一分为二,即求分裂点;再递归地对两个子区间和进行归并排序;最后将已排序的两个子区间归并为一个有序的区间。
- 稳定性 :归并排序是稳定的。两个相同的元素无论是分在同一组或是在不同组,它们的相对位置肯定和分组之前的相对位置是一样的。
- 时间复杂度 :归并排序的平均、最好和最坏时间复杂度都是O(N^lgN),无论怎么样,都要对原数组进行lgN次分组。
- 空间复杂度 :归并排序的空间复杂度是O(N)。
下面是关于归并排序的代码:
void Merge(int* a, int left,int mid, int right, int* temp)
{
int i, j, k;
i = left;
j = mid + 1;//避免重复比较a[mid]
k = 0;
while (i <= mid&&j <= right)
{
if (a[i] <= a[j])
{
temp[k++] = a[i++];//把两者中小的那个放到临时数组中
}
else
{
temp[k++] = a[j++];
}
}
while (i <= mid)//表示数组a[mid,right]这个右区间的序列已经全部在temp之中了,左区间的序列还有剩余
{
temp[k++] = a[i++];
}
while (j <= right)//表示数组a[left,mid]这个左区间的序列已经全部在temp之中了,右区间的序列还有剩余
{
temp[k++] = a[j++];
}
for (i = 0; i < k; ++i)
{
a[left+i] = temp[i];//这里应从left+i开始赋值
}
}
void MergeSort(int* a,int left,int right,int* temp)
{
assert(a);
/*先将当前区间一分为二,即求分裂点;再递归地对两个子区间和进行
归并排序;最后将已排序的两个子区间归并为一个有序的区间。*/
if (left < right)
{
int mid = (left + right) / 2;
MergeSort(a, left, mid, temp);//这一层递归完之后左边有序
MergeSort(a, mid+1, right, temp);//这一层递归完之后右边有序
Merge(a, left, mid,right, temp);//合并两个有序序列
}
}
//测试函数
int main()
{
int a[] = { 2,5,4,9,3,6,8,7,1,0 };
const size_t n = sizeof(a) / sizeof(a[0]);
int temp[n];
MergeSort(a,0,n-1,temp);
Print(a, n);
system("pause");
return 0;
}
其他排序
计数排序
计数排序是一个非基于比较的排序算法,它适用于范围比较集中的数的排序(范围越不集中,浪费的空间越大)。总的来说它是一种牺牲空间复杂度来换取时间复杂度的排序算法。
核心思想:对于给定的输入序列中的每一个元素x,确定该序列中值小于x的元素的个数(此处并非比较各元素的大小,而是通过对元素值的计数和计数值的累加来确定)。一旦有了这个信息,就可以将x直接存放到最终的输出序列的正确位置上
稳定性:计数排序是稳定的,在最后只是对原数组的每个元素进行了一个数组,相同元素的相对顺序并不会改变。
- 时间复杂度:计数排序的时间复杂度是O(N+K),K代表数组中最大数与最小数的差值,当K很小时,它差不多就是O(N),最坏的情况就是对于int类型的数来说,最大值是2^32-1,最小值是0,那这样K就很大了。
- 空间复杂度:计数排序的时间复杂度是O(N+K),一样建立数组的开销花了很大的空间。
下面是关于计数排序的代码:
void CountSort(int* a,int min,int range,size_t n,int* count)
{
for (size_t i = 0; i < n; ++i)
{
count[a[i]-min]++;//以a数组元素与min的差值作为计数数组的下标
}
size_t index = 0;
for (size_t i = 0; i < range; ++i)
{
while (count[i]--)
{
a[index++] = i + min;//将count数组中的值重新拷贝到原数组中
}
}
}
//测试函数
int main()
{
int a[] = { 2,5,4,9,3,6,8,7,1,0 };
const size_t n = sizeof(a) / sizeof(a[0]);
int min = a[0];
int max = a[0];
for (size_t i = 1; i < n; ++i)//先求出一个最小值和最大值
{
if (a[i] < min)
{
min = a[i];
}
if (a[i] > max)
{
max = a[i];
}
}
int range = max - min+1;//用最大值与最小值的差值作为计数数组count的大小
int* count = new int[range];
for (size_t i = 0; i < range; ++i)
{
count[i] = 0;//将计数数组初始化为0
}
CountSort(a,min,range,n,count);
system("pause");
return 0;
}
基数排序
基数排序属于分配式排序,它适用于位数比较集中的数组,比如都是两位数。
核心思想 :把待排序的整数按位分,分为个位,十位…..从小到大依次将位数进行排序。实际上分为两个过程:分配和收集。首先依据个位将数组分给到对应个位的桶,然后依据个位依次收集,以此类推。
稳定性 :基数排序是稳定的,相同的元素一定按照原有的相对顺序放到相应的桶中然后被收集
- 时间复杂度:分配的时间复杂度为O(n),收集的时间复杂度是O(radix),分配和收集共需要distance趟,所以时间复杂度是O(d(n+r))
- 空间复杂度 :空间复杂度为O(n+r)
下面是关于基数排序的代码
int MaxDigit(int*a, size_t n)//求数组中最大位数的函数
{
int d = 1;//一开始是一位数
int p = 10;
for (size_t i = 0; i < n; ++i)
{
while (a[i] >= p)
{
p *= 10;//如果有两位数则把p设为100,以此类推
++d;//每多一个位数,++d
}
}
return d;
}
void RadixSort(int*a, size_t n)
{
int d = MaxDigit(a, n);
int* temp = new int[n];//临时数组,用来存放数据
int* count = new int[10];//计数器,计算每一个十位数有多少值,比如11,12,则为2个10-19的数
int i, j, k;
int radix = 1;//基数
for (i = 0; i < d; ++i)//进行d次排序
{
for (j = 0; j < 10; ++j)
{
count[j] = 0;//每次排序前先把count中的数清0
}
for (j = 0; j < n; ++j)
{
k = (a[j] / radix) % 10;
count[k]++;//统计count计数数组中0-9每个下标的元素个数
}
for (j = 1; j < 10; ++j)
{
count[j] = count[j - 1] + count[j];
}
for (j = n - 1; j >= 0; --j)//将所有桶中记录的数依次收集到temp
{
k = (a[j] / radix) % 10;
temp[count[k] - 1] = a[j];
count[k]--;
}
for (j = 0; j < n; ++j)
{
a[j] = temp[j];//从临时数组拷贝到原数组
}
radix *= 10;
}
delete []count;
delete []temp;
}
//测试函数
int main()
{
int a[] = { 73,22,93,43,55,14,28,65,39,81};
const size_t n = sizeof(a) / sizeof(a[0]);
int temp[n];
RadixSort(a,0,n-1,temp);
Print(a, n);
system("pause");
return 0;
}
下面对各种算法的时间复杂度、空间复杂度及稳定性做一个总结