排序就是重新排列表中的元素,使得表中的元素满足按关键字有序的过程,为了查找方便,通常希望计算机中的表是按关键字有序的。
排序定义:
输入:n个记录 R 1 , R 2 , . . . , R n R_1,R_2,...,R_n R1,R2,...,Rn,对应的关键字为 k 1 , k 2 , . . . , k n k_1,k_2,...,k_n k1,k2,...,kn
输出:输入序列的一个重排 R 1 ′ , R 2 ′ , . . . , R n ′ R_1',R_2',...,R_n' R1′,R2′,...,Rn′,使得 k 1 ′ < k 2 ′ < . . . < k n ′ k_1'k1′<k2′<...<kn′ (其中小于号可以换成其他符号)
算法稳定性,若待排序表中两个元素a,b其对应的关键字相同,且a在b前面,若在某一算法排序之后,a仍然在b前面(相对位置不变),则可以说这个算法稳定,若相对位置发生改变,则算法不稳定
稳定性并能评判一个算法的优劣,主要是对算法性质描述,若一个序列中不允许重复元素出现,则稳定性的概念也就不存在了
在排序过程中,根据元素是否完全在内存中可分为内部排序和外部排序
内部排序:是指在排序期间元素全部存放在内存中的排序
外部排序: 是指在排序区间元素无法全部同时存放在内存中,必须在排序过程中根据要求不断的在内、外存之间移动的排序。
在这里我们只讨论内部排序,一般情况下,内部排序都要进行两种操作:移动和比较(基数排序除外)
每种算法都有其自身的优劣性,一般我们通过时空复杂度以及稳定性来客观了解每一种算法。时间复杂度一般都是由移动和比较次数决定的。
插入排序是一种简单直观的方法,主要思想是,将无序的元素逐个插入有序序列中,知道全部插入完成。
#include
int swap(int *a, int *b){ //交换两个元素位置
*a = *a ^ *b;
*b = *a ^ *b;
*a = *a ^ *b;
}
void sort(int *a, int n){ //传入参数为数组地址,以及元素个数
for(int i = 0; i < n - 1; i++){ //设置有序序列的边界
for(int j = i + 1; j >= 0; j-- ) // 取无序序列中第一个数进行比较移动的插入操作
if(a[j] < a[j - 1]){ //将无序数与有序序列依次比较
swap(&a[j], &a[j - 1]); //若满足调教进行交换
}
else{
break; //因为是有序序列,一旦没有触发交换,说明已经有序,终止比较
}
}
}
int main(){
int n = 0;
int array[25] = {0};
while(n<10){
scanf("%d", &array[n]); //输入十个数据
n++;
}
sort(array, n); //对十个数据进行排序
for(int i = 0; i < n; i++ ){
printf("%d ",array[i]);
}
}
空间复杂度:很明显为常数级别,当然我们虽然没有用到中间变量,而是通过异或操作实现
时间复杂度:****最好情况是有完全有序,所以我们不需要移动,但仍需要遍历整个表进行比较,所以时间复杂度为O(N);最坏情况是元素完全逆序,则我们需要进行比较约 n 2 − n 2 \frac{n^2-n}{2} 2n2−n,移动次数也差不多,所以总的时间复杂度为 O ( n 2 ) O(n^2) O(n2),所以平均情况下为 O ( n 2 ) O(n^2) O(n2)。
**稳定性:**因为每次插入都是先比较再移动,所以不会出现相同元素相对位置发生改变的现象
**适用性:**适合顺序存储和链式存储的线性表(大部分算法都仅适用于此)
折半插入排序实际上就是用折半查找的的方法找到插入的位置,然后插入。这样的话,寻找比较的时间变成了O(logn)但是移动的次数仍未发生改变,所以其时间复杂度仍然为 O ( n 2 ) O(n^2) O(n2),折半插入排序也是一种稳定排序
void insert_sort(int *num, int n){
int i, j, low, high, mid, key;
for (i = 1; i < n; i++)
{
if (num[i - 1] > num[i]){ // 判断当前数据是否需要进行插入
key = num[i]; // 获取需要插入的数据
low = 0; // 初始查找范围为 [0, i-1](有序)
high = i - 1;
while (low <= high){ // 进行折半二分算法查找插入的位置
mid = (low + high) / 2; // 获取中间下标
if (key <= num[mid]){
high = mid - 1; // 如果key小于中间值,则缩小查找范围到左子序列
}
else{
low = mid + 1; // 如果key大于中间值,则缩小查找范围到右子序列
}
}
// 整体后移
for ( j = i - 1; j >= high + 1; j--){
num[j + 1] = num[j];
}
// 插入数据
num[high + 1] = key;
}
}
}
直接插入排序中,我们可以知道当数据完全有序的时候时间复杂度可以从O( n 2 n^2 n2)提高到O(n),由此可见,在数据基本有序且数据量不大的情况下直接插入排序要更适合。正是基于此,希尔排序有直接插入排序改进而来。也称缩小增量排序
希尔排序的基本思想
在一组数据中取一个数字间隔,然后按照所取的增量把数组划分成若干组,然后在组内进行排序,然后取步长值(小于第一次的步长值),如此往复,直到步长值为1,这时数组已经基本有序,使用直接插入排序
例: 49 38 65 97 76 13 27 49 55 4 ============== 取步长值为5
13 27 49 55 4 49 38 65 97 76 ============ 步长值取3 (下面同一字体样式的数字为一组)
13 4 49 38 27 49 55 65 97 76 ============ 这时已经基本有序取步长值为1,直接插入排序
4 13 27 38 49 49 55 65 76 97
希尔排序代码在此进行实现了(有点懒我之前没打)
空间复杂度:因为希尔排序是组内进行交换所以为O(1)
时间复杂度:这是个玄学问题,还有数学上尚未解决的问题,所以其复杂度在O(n)~O(n 2 ^2 2)之间
稳定性:很明显在例子中两个49的相对位置就发生了改变,所以是不稳定的
仅适用于线性表为顺序存储的情况
交换排序是指两个元素进行比较然后进行位置交换,基于交换的排序有冒泡排序、快排等…
冒泡排序一般来讲会是我们接触排序学习的第一个排序算法,因为他理解以及代码实现都比较简单。
冒泡排序主要思想:
从前往后或是从后往前两两相互比较,这里以从后往前为例,当后面的数据小于前面的就进行交换,这样完成一次就成功完成把该数组最小的数据放到了数组首位,如此往复此过程防止第二位、第三位…当完成第n-1此排序时,就完成了排序过程。我们可以理解为每一次排序都是在把无序部分最小的元素放到有序数组后面,所以说每一次排序都会确定一个元素的最终位置。
int bubble_sort(int *a, int n){
for(int i = 0 ; i < n - 1; i++){
bool flag = 0; //加了一个小优化
for(int j = n - 1 ; j >= i; j-- ){
if(a[j - 1] > a[j]){
swap(&a[j - 1], &a[j]);
flag = 1;
}
}
if(flag == 0){ //当这次冒泡排序的过程中没有发生交换,则说明数组已然有序可以终止排序
break;
}
}
}
空间复杂度:O(1)
时间复杂度:在加了上面优化的情况下,我们可以知道,在最好的情况下,时间复杂度为O(n),当最坏情况完全逆序,时间复杂度为O( n 2 n^2 n2),这一点从代码中也很容易看出。其平均复杂度也是O(N 2 ^2 2)
稳定性:相同元素相对位置并不会发生改变,所是一种稳定排序
而且冒泡排序所形成的子序列是全局有序的,即每一次排序都会确定一个数据的位置。
快速排序虽然也是交换排序,但是他的思想却与冒泡排序有很大差异。
快排的思想:
快排采用的是一种递归的思想,首先要先选定一个基准,然后根据基准把整个数组分为大于它和小于它的两个部分。然后再在分好的子序列中,重新按照刚才的方法选定基准进行快排,如此往复,直至整个序列有序。当然在交换的过程中,基准是与数组头部和尾部交替进行比较交换,最终确定自己的位置,把数组划分为两个部分。因此在选择基准上,找到一个合适的基准可以极大的缩短排序的时间,在我的代码中呢,还是以数组第一个元素为基准
void q_sort(int *num, int l, int r){ //传入待排数组指针,以及左边界和右边界
if( l >= r){ //当左右边界重合或大于时,排序完成(设置递归终止条件)
return ;
}
int temp = num[l]; //设置基准
int ll = l, rr = r; //存储左右边界
while(ll < rr){ //进行交换过程,基准与数组头部和尾部向里依次比较和交换
while(ll < rr && num[rr] >= temp){
rr--;
}
if(ll < rr){
num[ll] = num[rr]; //比基准小的移到左边
}
while(ll < rr && num[ll] <= temp){
ll++;
}
if(ll < rr){
num[rr] = num[ll]; //比基准大的移到右边
}
}
num[ll] = temp; //完成交换后,一次快排完成,确定选定基准在数组中的位置
q_sort(num, l, ll-1);
q_sort(num, ll + 1, r);
}
空间复杂度:因为快排时递归的,所以需要栈来辅助实现,其容量最大深度最坏情况下为O(n);最好情况以及平均情况为O(log 2 n _2n 2n)
时间效率:最坏情况下,即在数组完全有序的情况下,时间复杂度为O( n 2 n^2 n2),在理想情况下时间复杂度为O( n l o g 2 n nlog_2n nlog2n),即在每次划分子区间的长度都不大于n/2时。
稳定性:快速排序会在排序过程中改变相同大小元素的相对位置,所以时不稳定排序
快速排序时所有内部排序算法中平均性能最优的排序算法
快排在每次基准把表划分两个长度相近的表时,速度最快,当表中本身有序(正序或逆序),速度最慢
选择排序顾名思义,每次都选择表中最大或最小的元素,放到表为或表首。每一趟确定一个元素位置,当进行第n-1趟时完成排序
遍历整个数组,选择最小的元素与第一个元素进行交换
在这里也不代码实现了,使用两个for循环,第一个循环时遍历n次。每次确定一个元素位置,第二个循环是表内元素遍历选择最小元素。
空间复杂度:O(1)
时间复杂度:因为用了两层循环很明显是O( n 2 n^2 n2)
稳定性:在选择排序过程中,可能会改变相同大小元素的相对位置所以是不稳定排序。
堆实际上是用顺序表实现的二叉树,有自己的排序规则。根据规则我们也可以把堆分为大顶堆和小顶堆
大顶堆 L(i) >= L(2i) && L(i) >=(2i + 1)
小顶堆 L(i) <= L(2i) && L(i) <=(2i + 1)
其中1<=i<=n/2
简单讲大顶堆就是满足双亲节点大于自己的孩子节点,小顶堆的双亲节点小于自己的孩子节点
堆排序的思想:(大顶堆为例)
首先是先建堆,由于其本身特性,则堆顶元素就是最大值。先输出堆顶元素,然后堆顶数据空了,我们让数组中的最后一位也就是堆底元素送入堆顶,这时候堆的性质不满足,则调整堆,把堆顶向下调整,直至堆再次平衡,然后输出平衡后的堆顶。如此往复,输出的序列则是有序的。
在这个过程中我们可以发现,其实我们需要解决的问题主要是堆的建立和堆的调整两个问题。
void buildMaxHeap(int *a , int len){
for(int i = len / 2; i > 0; i--){ // 反复调整堆,因为采用顺序存储结构,
headAdjust(a, i , len)
} // 而且应该自下向上调整
}
void headAdjust(int *a, int k, int len){
//函数将元素为k为根的子树进行调整
a[0] = a[k]; //树的顺序存储结构中,我们一般是从下标为1的位置开始存储
for(i = 2 * k; i <= len; i *= 2){ //一个双亲节点有两个孩子节点,而且对应顺序存储下标
if(i < len && a[i] < a[i + 1]){ //在两个孩子节点中选取大的元素
i++;
}
if( a[0] > = a[i]) { //如果当前子树根节点大于两个孩子节点则说明有序,无需调整
break;
}
else{
a[k] = a[i]; //如需要调整,把数值大的记录在根节点
k = i;
}
a[k] = a[0]; // 再把根节点数据记录到当前孩子节点,完成调整
}
}
void heapSort(int *a, int len){
buildMaxHeap(a, len); // 建堆
for(int i = len; i > 1; i--){ // 排序过程
swap(a[i], a[1]);
}
heapAdjust(a, 1, i-1); // 排序时破坏了堆,所以调整堆
}
在排序过程中,从代码中我们可以看到,只是从后往前遍历了整个数组,然后与堆顶进行了交换。那么其排序的原理就是我们采用的是顺序存储结构,且我们实现的是大顶堆,所以堆顶一定是数值最大的应该在数组最后,当交换完成后,数组从后往前依次有序,则有序的部分已经有序不再进行任何操作,所以,在对堆调整的函数传参的时候我们传入的右边界也是动态的。而在交换之后,堆已经被破坏,所以,我们需要重新调整堆,使得其重新成为大顶堆,则再重复上述过程,到最后我们会得到一棵有序的数组。即将堆进行分层遍历得到的序列是从小到大排列
空间复杂度:我们仅仅再调整堆的时候使用了,顺序表的未使用空间。所以为O(1)
时间复杂度:建堆的过程为O(n),之后又进行了n-1次向下调整的操作,每次调整的时间与高度有关,所以为O(h),所以在最好,最坏和平均情况下,堆排序的时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)
稳定性:进行堆排序的时候有可能会改变相同数值元素的相对位置,所以,是一个不稳定排序
归并排序与上述的排序方式思想不一样,“归并”是指,将两个或连个以上的有序表组合成一个新的有序表。就像是那个奥利奥的消息一样,一个人拿“奥”, 一个人拿“利”,先自己把自己的“奥”和“利”从小到大拍好,然后两个人再依次拿出来比较当前“奥”和“利”的大小然后依次排序。
归并排序的话可能看起来有一点快排的影子,快排是把一个大序列逐渐化成小序列,而归并排序是先把其划成小序列,然后两两归并成大序列。
归并排序:
假设待排列表中有n个元素,我们可以视为有n个有序的子表,每个子表长度为1,然后两两归并,得到n/2个长度1或2的有序表,然后继续两两归并,直到成为一个长度为n有序表。
如下表排序过程:
[49] [38] [65] [97] [76] [13] [27] ===n个子表
[38 49] [65 97] [13 76] [27] ===两两合并
[38 49 65 97] [13 27 76] ====继续两两合并
[13 27 38 49 65 76 97] ==== 最终合并为一个子表
void m_sort(int *num, int l, int r, int *temp){ //需要传入一个辅助数组
if(l >= r){
return ;
}
int mid = (l +r) / 2; //取中间值划分两个部分
m_sort(num, l, mid, temp); // 对左侧子序列继续进行归并
m_sort(num, mid + 1, r, temp); // 对右侧侧子序列继续进行归并
int p1 = l, p2 = mid + 1, n = r - l + 1; // 获取两个子序列的左右边界
for( int i = 0; i < n; i++){
if(p2 > r || p1 <= mid && num[p1] <= num[p2]){ //排序过程
temp[i] = num[p1];
p1++;
}else{
temp[i] = num[p2];
p2++;
}
}
for( int i = 0, j = l; i < n ;i++, j++){
num[j] = temp[i];
}
}
空间复杂度:因为我们用到一个辅助序列,所以空间复杂度O(n)
时间复杂度:每次归并的时间复杂度为O(n),需要进行 l o g 2 n log_2n log2n趟归并,所以算法时间复杂度 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
稳定性:归并排序不会改变相同数值元素的相对位置,所以是一种稳定排序
基数排序是一种特别的排序,它与其他基本的内部排序不同,它并不基于比较和移动,而是基于关键字各位大小的影响。基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序的方法。
实现基数排序通常有两种方法,第一种是最高位优先,另一种是最低位优先。
我们以最高位举例:
空间复杂度:一次排序需要的辅助存储空间为r(r个队列:r个队头指针和r个队尾指针),但会重复使用。所以复杂度为 O ( r ) O(r) O(r)
时间复杂度:基数排序需要进行d趟分配和收集,一趟分配需要O(n),一趟收集为 O ( r ) O(r) O(r),所以基数排序的时间复杂度为O(n + r)
稳定性:对于基数排序来讲,很重要的一点就是按位排序时必须是稳定的,所以它是稳定排序