一、排序的概念及其生活中的应用
1.1排序的概念
排序:就是使一串记录,按照某个关键字的大小,按升序或者降序的方式进行排列的操作。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2排序的运用
一般在生活中我们在网购时看到购物app上的商品可以按照价格排序以及按照销量来排序,这就是使用排序算法实现的这一功能,由此可见排序算法在各种app上还是很重要的。
1.3常见的排序算法
常见的排序算法有插入排序,选择排序,交换排序归并排序等,插入排序中又分为直接插入排序和希尔排序,希尔排序的效率要比直接插入排序高很多。选择排序中又分为选择排序和堆排序,堆排序也是一种比较高效率的排序算法,交换排序中有冒泡排序和快速排序,但是它们之间的效率却是差了很多,冒泡排序的效率是远低于快速排序的。下面我们来详细介绍一下各种排序算法的实现以及测试。
二、常见排序算法的实现
2.1插入排序
2.1.1基本思想
直接插入排序是一种简单的插入排序法,它的基本思想是把待排序的记录按其关键的码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录都插入完为止,得到一个新的有序数列。
2.1.2直接插入排序
直接插入排序的思想跟上面的思想是一样的,这里我们进行代码的实现以及动态效果图。
上面的动图就是插入排序的实现结果下面我们开始具体的代码实现:
//插入排序
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + 1] = a[end];
--end;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
从上面的代码可以看出插入排序的基本思路,如果end+1位置的数据小于end下标的的时候将end下标位置的值给end+1的位置,然后将end的值减一, 最后end+1的位置就是原来end的位置把tmp里面保存的原来end+1位置的值给到现在的end+1位置,这样就完成了移动操作,直到end<0或者end+1位置的值的大于end的值时,单趟排序就完成了。
这就是插入排序中的一种--直接插入排序下面我们来看插入排序的另一种更加高效的排序算法--希尔排序。
下面关于直接插入排序进行一个总结:
1.元素集合越接近有序,直接插入排序的效率就越高。
2.时间复杂度:o(N^2)
3.空间复杂度:o(1),是一种稳定的排序算法
4.稳定性:稳定
2.1.3希尔排序
希尔排序又叫做缩小增量排序,希尔排序的基本思想就是,先选定一个整数,把待排序的文件中的所有记录分成组,所有距离相同的分在同一个组中,并对每一组中的记录进行排序。然后,取,重复上述的分组和排序的工作当选定的数等于1时,所有的记录就在统一组中排好序了。
这个选定的值我们一般定义为变量gap,gap越大,数据跳的就越快,大的更快到后面,小的更快到前面,但是gap越大越不接近有序,gap越小,数据跳的越慢,越接近有序,gap == 1时,就是插入排序。
关于gap值的取法,可以先将gap给为n, 然后利用while循环每一次,gap = gap / 3 + 1,
加一是为了gap保证gap不为零。
了解的大致思路后我们就来进行具体的代码实现:
//希尔排序
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
从上面的代码可以看出代码逻辑和思路和直接插入排序是一样的,当gap == 1时, 就和直接插入排序是一模一样的,之所以要用gap不断缩小的方法进行预排序,就是为了让待排序的数据接近有序,前边我们说到了,直接插入排序的数据元素越接近有序,直接插入排序的效率就会越高,进行预排序的目的就是提高直接插入排序的效率。
希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. 当gap>1时都是预排序,目的是让数据更加接近有序,当gap == 1时 ,数据就已经很接近有序了,这样直接插入排序就会很快。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法有很多,导致很难去确定它的时间复杂度,因此在很多树中给出的希尔排序的时间复杂度都不固定。按照knuth的取gap的方法(gap == gap / 3 + 1)的话,时间复杂度大概是o(n^1.3)
4. 稳定性:不稳定
2.2选择排序
2.2.1基本思想
每一次从待排序的数据元素中选出最小或最大的元素,存放在序列的起始位置,直到全部待排序的数据元素排完。
2.2.2直接选择排序
下面是直接选择排序的原理图:
下面我们来进行具体的代码实现:
//选择排序
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin;
for (int i = begin + 1; i <= end; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[begin], &a[mini]);
if (maxi == begin)
{
maxi = mini;
}
Swap(&a[end], &a[maxi]);
begin++;
end--;
}
}
从上面的代码可以看出每一次都选出了最大值的下标和最小值的下标,然后对begin加一和end减一,这样就完成了把最小的放在最前面将最大的放在最后面。
直接选择排序的特性总结:
1. 直接选择排序思路比较好理解但是效率不是很好,因此在实际中很少使用
2. 时间复杂度o(n^2)
3. 空间复杂度:o(1)
4. 稳定性:不稳定
2.3.3堆排序
堆排序(Heapsort)是利用堆这种数据结构所设计的一种排序算法,它是选择排序的一种,它是通过堆进行选择数据的。需要注意的是排升序要建大堆,排降序建小堆。堆排序使用了堆进行选数据,效率很高。
下面我们就具体对堆排序进行代码实现:
void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
//假设法求出左右中较大的孩子
if (child + 1 < n && a[child + 1] > a[child])
{
++child;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
//用数组建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
实现堆排序时我们要先实现向下调整算法,通过向下调整进行建堆,当然也可以使用向上调整建堆,但是向上调整建堆时间复杂度要比向下调整建堆高出很多,这里不进行太多的讲解,如果有兴趣的话可以去研究一下向上调整建堆的时间复杂度。要是排升序的话就建大堆,堆顶元素就是最大值,让堆顶元素即a[0]与a[end]交换,然后end--,就将最大的数给排好了,然后再次进行循环依次将次大的数给排进去,这就是堆排序的基本思路,堆排序是一种效率很高的排序。
堆排序的特性总结:
1. 堆排序使用堆来选数据,效率高了很多。
2. 时间复杂度:o(N*logN)
3. 空间复杂度:o(1)
4.稳定性:不稳定
2.3交换排序
基本思想:所谓交换,就是根据序列中的两个数据的比较结果进行对换这两个数据在序列中的位置,交换排序的特点就是,将较大的数据向序列的尾部移动,较小的数据向序列前移动。
2.3.1冒泡排序
冒泡排序就是典型的交换排序,并且是一种非常容易理解的排序算法,但是冒泡排序的效率不够高,因此在实际中冒泡排序也不常使用。
下面我们来看一下冒泡排序的原理图:
从图中我们可以看出冒泡排序的第一趟排序将最大的数排向最后一个位置,然后后面的每次依次将次大的数给排到后面。
下面我们就对冒泡排序进行具体的代码实现:
//冒泡排序
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int flag = 0;
for (int j = 0; j < n - i - 1; j++)
{
if (a[j + 1] < a[j])
{
Swap(&a[j + 1], &a[j]);
flag = 1;
}
}
if (flag == 0)
{
break;
}
}
}
我们可以看到在代码中我们加入了一个标志变量flag,当有进行交换操作时flag就会改变,只有在没有发生交换操作时flag才会一直保持原来的值,没有进行交换操作就意味着这些数据已经是有序的了,不需要再进行循环。依次当经过循环却没有改变flag的值时我们就直接break出去。但是即便是这样冒泡排序也基本都是最坏情况。
冒泡排序的特性总结:
1. 冒泡排序是一种非常容易理解的排序。
2. 时间复杂度:o(n^2)
3. 空间复杂度o(1)
4. 稳定性:稳定
2.3.2快速排序
快速排序是hoare在1962年提出的一种二叉树结构的交换排序方法,它的基本思想是任取待排序Y元素序列中的某元素作为基准值,按照该排序码,将待排序的数据分割为两个子序列,左子序列中的所有元素均小于基准值,右子序列的所有元素均大于基准值,然后左右序列重复这个过程,直到所有元素都排列在相应位置上为止。
而且快速排序还有一些优化的版本,我们一个一个讲起。
1.hoare版本
这里以左边第一个元素为key,升序的话左边找大,右边找小, 降序的话左边找小右边找大。单趟排序,达到的目的,key放到了最终位置,同时左边比key小,右边比key大。但是相遇的位置一定比key小,这是右边先走保证的。
下图就是hoare版本的快速排序的原理图:
下面我们来进行下hoare版本的快速排序的代码实现:
void QuickSort1(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int begin = left;
int end = right;
int keyi = left;
while (left < right)
{
//right先走找小
while (left < right && a[right] >= a[keyi])
{
--right;
}
//left后走找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
QuickSort1(a, begin, keyi - 1);
QuickSort1(a, keyi + 1, end);
}
在上面的代码中还有可以改进的地方,在小区间中快速排序的效率不够明显,但是插入排序就能够很好的解决这个问题。下面我们来对hoare版本的快速排序进行一个优化。
2.3.3快速排序的优化
1.在选取key时如果数据是最大或者最小的是那么就会就开辟更多的函数栈帧,递归的消耗大,这时就会导致快排的效率不够高,因此我们一会在优化时采用三数取中的方法取得key。
2.刚才我们提到快速排序在小区间中的效率不够明显,这是因为递归小区间的消耗大,不如直接使用插入排序,这个问题我们也会进行优化。
三数取中:我们从left, right, mid这三个下标位置的数据中取大小居中的那个值,有序的序列的话mid就在中位数的位置,这中取key的方法针对有序和随机的综合情况。
下面我们进行具体的代码优化:
void QuickSort1(int* a, int left, int right)
{
if (left >= right)
{
return;
}
//如果是小区间则使用插入排序
if (right - left + 1 < 10)
{
InsertSort(a + left, right - left + 1);
}
else
{
int begin = left;
int end = right;
// 三数取中
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;
while (left < right)
{
//right先走找小
while (left < right && a[right] >= a[keyi])
{
--right;
}
//left后走找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
QuickSort1(a, begin, keyi - 1);
QuickSort1(a, keyi + 1, end);
}
}
基本思路和原来的hoare版本是一样的,就是加上了三数取中和小区间使用插入排序。
下面我把三数取中的具体实现代码也给放上来:
//三数取中
int GetMidi(int* a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right])
{
return left;
}
else
{
return right;
}
}
else //a[left] > a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[right] > a[left])
{
return left;
}
else
{
return right;
}
}
}
2.3.4前后指针版本快速排序
除了hoare版本的快速排序,还有很多其他的快速排序实现方法,我在这里再介绍一种前后指针版本的,这个版本的快速排序相比起hoare版本容易理解一些。
下面我们就来看一下前后指针版本的快速排序的原理图:
下面我们来进行这个版本的快速排序的具体代码实现:
// 快速排序前后指针法
void QuickSort2(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
QuickSort2(a, left, keyi - 1);
QuickSort2(a, keyi + 1, right);
}
可以看出这里定义了两个指针,一个前指针prev, 还有一个遍历指针cur,prev的初始位置在keyi,cur指针的初始位置就在prev的后一个位置。
cur指针找小,如果cur >= key, 那么++cur, 如果cur < key, ++prev, 交换prev和cur位置的值然后cur在往后走即++cur。这样走下来第一趟走完的结果就是prev和cur之间都是比key大的值。走完第一趟后然后交换key和prev的值,然后进行左右区间的递归。
这就是快速排序的前后指针版本。我们知道如果待排序的数据太多的话那么递归的栈的压力还是比较大的,有可能会发生栈溢出,因此我们要想一个办法来解决这个问题,于是就出现了非递归版快速排序,下面我们来介绍一下这个非递归版快速排序。
2.3.5非递归版本的快速排序
上面我们提到了因为递归的原因,导致当数据达到一定多时可能会出现栈溢出的问题,所以出现了非递归版本的快速排序来解决这个问题。但是这个非递归的版本该如何实现原来版本的效果呢?
其实我们可以借助栈来辅助完成这个伪递归的过程,也就是用栈来模拟递归,我们知道栈是先进后出,后进先出的,那么我们便就可以把right先入栈然后将left在入栈那样出栈的时候先出来的就是left,然后再借助循环来实现递归的过程。
下面我们来具体的实现以下非递归版本的快速排序:
//快速排序非递归版本
void QuickSortNonR(int* a, int left, int right)
{
ST st;
STInit(&st);
STPush(&st, right);
STPush(&st, left);
while (!STEmpty(&st))
{
int begin = STTop(&st);
STPop(&st);
int end = STTop(&st);
STPop(&st);
int keyi = begin;
int prev = begin;
int cur = begin + 1;
while (cur <= end)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
if (keyi + 1 < end)
{
STPush(&st, end);
STPush(&st, keyi + 1);
}
if (begin < keyi - 1)
{
STPush(&st, keyi - 1);
STPush(&st, begin);
}
}
}
上面的代码的具体思路是采用的前后指针版本的, 然后再后面进行手动进栈,只要栈内不为空就一直循环,栈内为空时那么排序就完成了。
关于快速排序我就介绍到这里了,下面我们对快排的性质进行一个总结。
快速排序的特性总结:
1. 快速排序是有hoare提出的。
2. 快速排序的整体性能和使用场景都是比较好的,所以才能够称之为快速排序。
3. 时间复杂度o(N*logN)
4. 空间复杂度:o(logN)
5. 稳定性: 不稳定
2.4归并排序
基本思想:归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个典型的应用。将已经有序的子序列进行合并,得到完全有序的序列;即先使每个子序列有序,再使子序列合并成一个有序序列,称为二路合并。
进行归并时要开辟临时数组tmp, 然后通过另一个函数进行递归,进行归并的前提是左右区间有序,核心逻辑:取小的尾插到tmp数组,一次排序。
下面我们来进行归并排序的具体代码实现:
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin == end)
return;
int mid = (begin + end) / 2;
//分治
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
//归并
int begin1 = begin, end1 = mid;
int begin2 = end1 + 1, end2 = end;
int i = begin;
//一次比较将较小的尾插
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
//归并排序
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
以上代码就是归并排序递归版本的具体实现,那么我们之前提到过递归的缺点,那么我们下面来实现以下归并排序的非递归版本。
归并排序的非递归版本:通过循环实现,定义一个变量gap,gap是每组数据归并的数据个数。每走一层,控制一下两个gap组的归并,非递归归并存在一个问题就是容易越界,如果是end1越界或者begin1越界的话这一组就不用归并直接break出去,如果是仅有end2越界,则修正end2,这个子序列需要进行归并。
下面我们来具体实现一下非递归版本的归并排序:
//归并排序非递归版
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1;
while (gap < n)
{
for (int j = 0; j < n; j += 2 * gap)
{
int begin1 = j, end1 = begin1 + gap - 1;
int begin2 = end1 + 1, end2 = begin2 + gap - 1;
int i = j;
if (end1 >= n || begin2 >= n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
memcpy(a + j, tmp + j, sizeof(int) * (end2 - j + 1));
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
下面再附上归并排序的原理图:
归并排序的特性总结:
1. 归并的缺点是需要o(n)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:o(n*logn)
3. 空间复杂度:o(n)
4. 稳定性:稳定
2.5非比较排序
非比较排序顾名思义及时不用进行比较的排序算法,其中有计数排序,基数排序,桶排序等,我们在这里只介绍一个计数排序,因为本身非比较排序就不太常用,计数排序是其中较为常用并且比较容易理解的一种。
计数排序思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。
基本步骤:
1.统计相同元素出现的次数
2. 根据统计的结果将序列回收到原来的序列中
计数排序适合对数据的范围集中的数据进行排序,不适合分散的数据排序。
局限性:计数排序只适合整型排序,对于浮点型,字符串,以及结构体等不能进行排序。
这也导致了非比价排序在实际中并不常用。
下面我们对计数排序进行一个具体的代码实现:
//计数排序
void CountSort(int* a, int n)
{
int min = a[0], max = a[0];
for (int i = 0; i < n; i++)
{
if (a[i] < min)
{
min = a[i];
}
if (a[i] > max)
{
max = a[i];
}
}
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
perror("malloc fail");
return;
}
memset(count, 0, sizeof(int) * range);
//统计次数
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
//排序
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
a[j++] = i + min;
}
}
}
这就是计数排序的代码实现,计数排序前先找到最大值和最小值,通过遍历既可得到,通过max-min+1来确定范围,建立一个计数数组count,存放每个数据出现的次数,至于为什么在存放时下标要减去min,是因为减少开辟的空间,能够节约空间,在排序时放回a数组中时在加会min就行。
以上就是计数排序的基本介绍。下面我们对计数排序进行一个总结。
计数排序的特性总结:
1. 计数排序的数据范围集中时,效率很高,但是使用范围以及实际运用场景有限。
2. 时间复杂度:o(max(n,范围))
3. 空间复杂度:o(范围)
4.稳定性:稳定
结语
关于排序算法就讲到这里了,这篇博客到这里也就结束了,希望这篇博客能给大家在数据结构的学习中带来一些帮助。