语言 | C/C++ |
---|---|
数据结构 | vector |
数据类型 | int |
代码实现 | 函数模板 |
排序(Sorting)算法是计算机程序设计中的一种重要操作,其功能是将任意序列的一组元素重新排列成关键字有序的序列。
其最重要的逻辑就是比较概念,例如根据字符的 ASCII 码大小的排序,根据数字大小的排序,只要制定了比较逻辑,都可以进行排序。
稳定性是一个特别重要的评估标准。稳定的算法在排序的过程中不会改变元素彼此的位置的相对次序,反之不稳定的排序 算法经常会改变这个次序,这是我们不愿意看到的。我们在使用排序算法或者选择排序算法时,更希望这个次序不会改变,更加稳定,所以排序算法的稳定性,是一个特别重要的参数衡量指标依据。就如同空间复杂度和时间复杂度一样,有时候甚至比时间复杂度、空间复杂度更重要一些。所以往往评价一个排序算法的好坏往往可以从下边几个方面入手:
1) 时间复杂度:即从序列的初始状态到经过排序算法的变换移位等操作变到最终排序好的结果状态的过程所花费的时间度量。
2) 空间复杂度:就是从序列的初始状态经过排序移位变换的过程一直到最终的状态所花费的空间开销。
3) 使用场景:排序算法有很多,不同种类的排序算法适合不同种类的情景,可能有时候需要节省空间对时间要求没那么多,反之,有时候则是希望多考虑一些时间,对空间要求没那么高,总之一般都会必须从某一方面做出抉择。
4) 稳定性:稳定性是不管考虑时间和空间必须要考虑的问题,往往也是非常重要的影响选择的因素。
下面,我将以vector(数据类型为整数)这种数据结构作为例子来讲解一下
为了一定程度上保证代码的可移植性,本博客种的代码都是使用C++ 的模板写成。
本博客中的所有代码均有极为详尽的注释,尽量保证每个人都可以看懂
冒泡排序是将较小的元素往前调或者把较大的元素往后调。
例如:将 4、3、2、1四个数从小到大排列,冒泡排序的做法是:
1) 将 4 调整到最后一个位置, 3调整到倒数第二个位置,以此类推,每次循环都会将最大(最小)的数调整到当前循环的最后(最前)的位置,就像冒泡一样,这也是冒泡排序的名称由来
2) 在每一次循环时,当前循环的数中最大数的位置向后调整,其实现操作为从前往后,相邻位置进行比较,如果前面的数大于后面的数,则两数位置进行交换。
#ifndef XY_BUBBLE_SORT
#define XY_BUBBLE_SORT
template<class datasType>
void bubbleSort(datasType& datas)
{
auto tempData = datas[0];
for (int i = datas.size() - 1; i >= 1; --i)
{
//内存循环,比较相邻两个数的大小,如果 datas[j] > datas[j + 1],则交换二者
//循环执行完毕后,最大的元素已经移动到当前操作的最右边。
for (int j = 0; j < i; ++j)
{
if (datas[j] > datas[j + 1])
{
tempData = datas[j];
datas[j] = datas[j + 1];
datas[j + 1] = tempData;
}
}
}
}
#endif // !XY_BUBBLE_SORT
若文件的初始状态是正序的,一趟扫描即可完成排序。所需的关键字比较次数 和记录移动次数 均达到最小值: C m i n C_{min} Cmin = n - 1, M m i n M_{min} Mmin = 0。
所以冒泡排序的最好时间复杂度为 O(n);
若初始文件是反序的,需要进行 n - 1 趟排序。每趟排序要进行 n - i 次关键字的比较(1≤i≤n-1),且每次比较都必须移动记录三次来达到交换记录位置。在这种情况下,比较和移动次数均达到最大值:
C m a x C_{max} Cmax = n ( n − 1 ) / 2 n(n - 1)/2 n(n−1)/2 = O( n 2 n^2 n2);
M m a x M_{max} Mmax = 3 n ( n − 1 ) / 2 3n(n - 1)/2 3n(n−1)/2 = O( n 2 n^2 n2);
冒泡排序的最坏时间复杂度为 O( n 2 n^2 n2)。
综上,因此冒泡排序总的平均时间复杂度为 O( n 2 n^2 n2)。
由于不存在函数的递归,除了偶尔的几个用于循环和条件判断的变量,几乎用不到额外的空间,因此冒泡排序算法的空间复杂度为O(1)。
由于遇到相等的值时,没有交换操作,因此冒泡排序算法是一种稳定的排序算法。
根据冒泡排序算法的原理,如果某次外层循环(依次将最大值移动到右边,或者将最小值移动到最左边)进行完毕,却没有进行过数据的交换,这说明数据已经有序,没必要进行数据移动了,排序已经完成。因此冒泡排序可以进行一定程度的优化,不过个人觉得意义不大。
#ifndef XY_BUBBLE_SORT
#define XY_BUBBLE_SORT
//下面实现冒泡排序的优化代码
template<class datasType>
void bubbleSortEx(datasType& datas)
{
auto tempData = datas[0];
for (int i = datas.size() - 1; i >= 1; --i)
{
bool hasExchanged = false;//先假设此次外循环的过程中不会发生数据交换
for (int j = 0; j < i; ++j)
{
if (datas[j] > datas[j + 1])
{
tempData = datas[j];
datas[j] = datas[j + 1];
datas[j + 1] = tempData;
//一旦发生了数据交换,则将 hasExchanged 设为 真。
hasExchanged = true;
}
}
//如果变量 hasExchanged 的值为 flase
//这说明这次外层循环的过程中没有发生数据交换,则break终止循环
if (hasExchanged == false)
break;
}
}
#endif // !XY_BUBBLE_SORT
优化后的冒泡排序的复杂度和稳定性与优化之前是一样的,甚至在多数时候,因为内部的判断语句,优化后的程序反而要多执行某几条语句好多次。这种优化属实是鸡肋。
首先,找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置(如果第一个元素就是最小元素那么它就和自己交换)。其次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此往复,直到将整个数组排序。这种方法我们称之为选择排序。
#ifndef XY_SELECT_SORT
#define XY_SELECT_SORT
template<class datasType>
void selectSort(datasType& datas)
{
size_t datasSize = datas.size();
size_t min = 0;
auto tempData = datas[0];
//外层循环,每次循环都将当前操作范围内的最小元素移动到操作范围的最前面的位置
//例如:第一次操作,将 datas[min] 和 datas[0] 交换
// 第二次操作,将剩余元素的 datas[min] 和 datas[1] 交换
// ......
//当 i = datasSize-1 时,只剩datas的最后一个元素 datas[datasSize-1] 时
//其它所有元素均已排好序,所以此时 datas[datasSize-1] 就是最大值
//因此循环的退出条件是 i < datasSize-1; i = datasSize-1时,循环不必进行了
for (size_t i = 0; i < datasSize-1; ++i)
{
min = i;
//内层循环
//假定当前操作范围的第一个元素 datas[i] 假定为最小元素
//记 size_t min = i;
//将 datas[min] 与 datas[i+1, datasSize-1] 中的元素进行比较,
//如果遇到 datas[j] < datas[min],则 min = j,然后继续比较,
//直到此次循环完成,即可得到当前范围内的最小元素 datas[min]
//然后将 datas[i] 与 datas[min]交换。
for (size_t j = i+1; j < datasSize; ++j)
{
if (datas[j] < datas[min])
{
min = j;
}
}
tempData = datas[i];
datas[i] = datas[min];
datas[min] = tempData;
}
}
#endif //!XY_SELECT_SORT
选择排序的交换操作介于 0 和 (n - 1) 次之间。选择排序的比较操作为 n (n - 1)/ 2 次之间。选择排序的赋值操作介于 0 和 3 (n - 1) 次之间。
比较次数 O( n 2 n^2 n2),比较次数与关键字的初始状态无关,总的比较次数 N=(n-1)+(n-2)+…+1=n*(n-1)/2。交换次数 O(n),最好情况是,已经有序,交换0次;最坏情况交换n-1次,逆序交换n/2次。交换次数比冒泡排序少多了,由于交换所需CPU时间比比较所需的CPU时间多,n值较小时,选择排序比冒泡排序快。
时间复杂度O( n 2 n^2 n2)。
由于不存在函数的递归,除了偶尔的几个用于循环和条件判断的变量,几乎用不到额外的空间,因此选择排序算法的空间复杂度为O(1)。
选择排序是给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推,直到第 n-1 个元素,第 n 个元素不用选择了,因为只剩下它一个最大的元素了。那么,在一趟选择,如果一个元素比当前元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。比较拗口,举个例子,序列5 8 5 2 9,我们知道第一遍选择第1个元素5会和2交换,那么原序列中两个5的相对前后顺序就被破坏了,所以选择排序是一个不稳定的排序算法。
插入排序类似于打扑克牌时我们将牌的顺序码好的动作,原理是假定前面的 datas[i] 之前的所有元素均已排好序,然后使用 datas[i]与前面的所有元素从大到小进行比较,如果有 datas[j] > datas[i],则 datas[j + 1] = datas[j];(元素后移),当 datas[j] <= datas[i] 时,datas[j + 1] = datas[i](此时datas[j+1] 之后的所有元素均已右移一个位置)。
举个例子:将 数据 {4,3,2,1} 排好顺序,插入排序法的操作方法是这样的
1) 将第2个元素 “3” 取出,然后和第1个元素 “4” 比较,因为 “3” 比 “4” 小,所以将 “4” 放到第2个位置,然后将 3 插入到首位,这样就完成了第 1 次插入。
2) 将第3个元素 “2” 取出,然后和前面已经排序好的前两个元素 “3” 和 “4” 比较,因为元素 “2” 比 这两个都小,所以元素 “3” 和 “4” 都向后移动一位,然后将元素 “2” 放置在首位。
3) 重复之前的操作,直到最后一个元素。
#ifndef XY_INSERT_SORT
#define XY_INSERT_SORT
template<class datasType>
void insertSort(datasType& datas)
{
auto tempData = datas[0];
int datasSize = datas.size();
int i = 0;
int j = 0;
//外层循环:遍历 datas[1] ~ datas[datas.size() - 1]
//例如: 第一次操作,将 datas[1] 取出,然后用它和 datas[0] 比较并完成插入动作
// 第二次操作,将 datas[2] 取出,然后用它和 datas[0]、datas[1]比较,并完成插入动作
for (i = 1; i < datasSize; ++i)
{
//使用临时变量保存 datas[i]
tempData = datas[i];
//使用 tempData 和 datas[0]~datas[i-1]比较(从右至左)
//如果 tempData < datas[j]时,datas[j+1] = datas[j];元素向右移动一个位置
for (j = i-1; j >= 0 && tempData < datas[j]; --j)
{
datas[j + 1] = datas[j];
}
datas[j + 1] = tempData;
}
}
#endif //!XY_INSERT_SORT
如果目标是把n个元素的序列升序排列,那么采用插入排序存在最好情况和最坏情况。最好情况就是,序列已经是升序排列了,在这种情况下,需要进行的比较操作需(n-1)次即可。最坏情况就是,序列是降序排列,那么此时需要进行的比较共有n(n-1)/2次。插入排序的赋值操作是比较操作的次数加上 (n-1)次。平均来说插入排序算法的时间复杂度为O( n 2 n^2 n2)。因而,插入排序不适合对于数据量比较大的排序应用。但是,如果需要排序的数据量很小,例如,量级小于千,那么插入排序还是一个不错的选择。
由于不存在函数的递归,除了偶尔的几个用于循环和条件判断的变量,几乎用不到额外的空间,因此插入排序算法的空间复杂度为O(1)。
使用插入排序算法时,后来的元素会放置在相等元素的后面,因此不会造成像等元素之间的位置变化,插入排序算法是稳定的排序算法。
将直接插入排序中寻找A[i]的插入位置的方法改为采用折半比较,即可得到折半插入排序算法。在处理 A[i] 时,A[0]……A[i-1] 已经排好序。所谓折半比较,就是在插入 A[i] 时,取 A[i-1/2] 与 A[i] 进行比较,如果 A[i] < A[i-1/2],则说明 A[i] 只能插入 A[0] 到 A[i-1/2] 之间,故可以在 A[0] 到 A[i-1/2-1] 之间继续使用折半比较;否则只能插入 A[i-1/2] 到 A[i-1] 之间,故可以在 A[i-1/2+1] 到 A[i-1] 之间继续使用折半比较。如此反复,直到最后能够确定插入的位置为止。一般在 A[k] 和 A[r] 之间采用折半,其中间结点为 A[k+r/2],经过一次比较即可排除一半记录,把可能插入的区间减小了一半,故称为折半。
#ifndef XY_BINARY_INSERT_SORT
#define XY_BINARY_INSERT_SORT
template<class datasType>
void binaryInsertSort(datasType& datas)
{
auto tempData = datas[0