相关知识
排序算法的稳定性
待排序序列中含有A、B两个元素,且满足A=B,在排序前,序列中A领先于B,如果排序后序列中A仍领先于B,则称排序方法是稳定的;反之如果排序后序列中A不一定领先于B则称排序方法是不稳定的
内部排序和外部排序
在实际应用中排序算法要处理的数据量可能很小也可能很大,当排序算法要处理的数据量很小的时候,可以将待排序记录存放在计算机的随机存储器中进行排序,我们将这个过程称之为内部排序。而当排序算法要处理的数据量很大的时候,内存无法一次容纳全部记录,所以在排序过程中需要对外存进行访问,我们称这种排序过程为外部排序。
这篇博客的内容全部是内部排序
插入排序
插入排序使用了增量方法,它的思想很简单,从头到尾遍历序列,每次遍历将当前的元素同它前面的元素依次比较 ,然后将该元素“插入”到合适的地方。排序过程如图下所示:
C++代码
//排序长度为10000的整数数组
void InsertSort(array<int,10000> &list) {
for (int i = 1; i < list.size(); i++) {
int sentry = list[i];
int j = i;
for (j; j > 0 && sentry <list[j-1]; j--) {
list[j] = list[j - 1];
}
list[j] = sentry;
}
}
算法分析
空间复杂度:插入排序只需要一个记录的辅助空间(上述代码中的sentry )所以空间复杂度为
O
(
1
)
O(1)
O(1)
时间复杂度:最坏情况(待排列元素逆序)
O
(
n
2
)
O(n^2)
O(n2),最好情况(待排列元素正序)
O
(
n
)
O(n)
O(n),平均情况与最坏情况大致一样差为
O
(
n
2
)
O(n^2)
O(n2)。(参照算法导论第二章)
另外,插入排序是稳定的。
插入排序只适合待排序元素数量较少的时候使用
其他插入排序
折半插入排序
插入排序的基本操作可以分成“查找”和“插入”,可以把“查找”的过程使用折半查找的方式实现,从而提升排序过程中的查找速度,提高算法效率.
C++代码
//排序长度为10000的整数数组
void InsertSort_BinarySearch(array<int, 10000> &list) {
for (int i = 1; i < list.size(); i++) {
int sentry = list[i];
int beg = 0, end = i;
while (beg <= end) {
int cen = (beg + end) / 2;
if (sentry < list[cen]) {
end = cen - 1;
}
else if (sentry >= list[cen]) {
beg = cen + 1;
}
}
int j = i;
for (j; j > end+1; j--) {
list[j] = list[j - 1];
}
list[j] = sentry;
}
}
算法分析
折半插入排序大部分情况下会减少排序时的比较次数,但是元素的移动次数不变,所以它的时间复杂度和空间复杂度相对于直接插入排序来说并没有改变。
折半插入排序也是稳定的
二路插入排序
二路插入排序基于折半插入排序改进,通过减少排序过程中元素交换的次数从而达到提高算法效率的目的。怎么做呢,这里采用使用空间代价换取时间代价的策略,需要与待排序数组同等大小的数组进行辅助。
先设一个与待排序数组同样大小的辅助数组,并将其当做循环向量使用,然后将待排序数组的首元素赋值给辅助数组的首元素,并将该元素看成是排好序序列中处于中间位置的记录,然后从待排序数组中的第2个记录起依次插入到辅助数组的首元素之前或之后的有序序列中。先将待插记录与辅助数组的首元素比较,如果它比辅助数组的首元素小,则插入辅助数组的首元素之前的有序表中,反之插入到辅助数组的首元素之后的有序表中。
为了方便理解,我个人把二路插入排序的核心步骤不太准确地分为三步:
- 选择一个值作为“中间元素”,原算法选择了待排序序列的第一个元素
- 排序时先判断当前元素是否小于“中间元素”,如果是,将它插入“中间元素”之前的序列;反之将其插入“中间元素”之后的序列
- 对“中间元素”之前或之后(插入新元素的部分)的序列进行折半插入排序
如果这么解释还是有点费解,下面看一个例子,假设有待排序数组 [49 38 65 97 76 13 27 49],则使用二路排序的过程如下图所示
C++代码
//本人水平有限,可能写的复杂了,以下代码仅供参考
//排序长度为10000的整数数组
void InsertSort_TwoWay(array<int, 10000> &list) {
array<int, 10000> templist = {};
int head = 0, tail = 0;
templist[0] = list[0];
for (int i = 1; i < list.size(); i++) {
int sentry = list[i];
if (sentry > templist[0]) {
tail++;
int beg = 0, end = tail;
while (beg <= end) {
int cen = (beg + end) / 2;
if (sentry < templist[cen]) {
end = cen - 1;
}
else if (sentry >= templist[cen]) {
beg = cen + 1;
}
}
int j = tail;
for (j; j > end + 1; j--) {
templist[j] = templist[j - 1];
}
templist[j] = sentry;
}
else {
head--;
int beg = templist.size() + head, end = templist.size() - 1;
while (beg <= end) {
int cen = (beg + end) / 2;
if (sentry < templist[cen]) {
end = cen - 1;
}
else if (sentry >= templist[cen]) {
beg = cen + 1;
}
}
int j = templist.size() + head;
for (j; j < end; j++) {
templist[j] = templist[j + 1];
}
templist[j] = sentry;
}
}
int ind = 0;
for (int i = list.size() + head; i < templist.size(); i++) {
list[ind] = templist[i];
ind++;
}
for (int i = 0; i <= tail; i++) {
list[ind] = templist[i];
ind++;
}
}
算法分析
在二路插入排序中,移动元素的次数约为
n
2
8
\frac{n^2}8
8n2,所以二路插入排序只能减少移动次数。而且我们将待排序列的首元素当做序列排好序后处于中间位置的元素,如果该元素是最大或最小元素,二路插入排序将完全失去效果。
表插入排序
二路插入排序只能一定程度上减少元素移动的次数,但不能做到不移动记录。表插入排序则不同,可以做到排序时不移动记录。
首先,表插入排序引入了新的数据结构,还是以排序长度为10000的整数数组为例,数据结构定义如下:
#define SIZE 10000
struct Node {
int record;
int next;
};
struct LinkList {
Node nodes[SIZE + 1];//因为0号节点是表头节点,不算在排序节点之内所以这里的SIZE加1
int length = SIZE + 1;
};
可见表插入排序采用了数组实现的链表结构,即定义一个数组,数组的每个元素包含两个属性:一个是整数的值,另一个是下一个元素的下标,这样就定义了一个数组实现的链表结构。之后待排序列的存储,排序都是在这个链表上进行。
首先,令链表的首元素为表头,表头的记录为一个足够大的整数,而下标1到10000的分量用来存储待排序序列。另外表头的next指向下标为1的分量,而下标为1的分量的next为0,使下标为1的分量和表头节点构成一个循环链表。其结构如下
然后将下标2到10000的分量“插入”表中(调整它们的下标)。最后为了使排序后的链表能够随机访问,对链表的元素顺序进行调整,使其按照next指示的顺序排列。
C++代码
//与其他排序算法不同,并非排序长度为10000的整数数组 ,而是排序长度10000的基于数组实现的链表。
default_random_engine e;
uniform_int_distribution<int> u(0, 10000);
LinkList sortingarray;
for (int i = 1; i < 10001;i++) {
sortingarray.nodes[i].record = u(e);
}
sortingarray.nodes[0].record = 0x3f3f3f3f;
sortingarray.nodes[0].next = 1;
sortingarray.nodes[1].next = 0;
//排序算法
void InserSort_Table(LinkList &list) {
for (int i = 2; i < list.length; i++) {
Node sentry = list.nodes[i];
int pre = 0,current = list.nodes[0].next;
for (current; current > 0 && sentry.record > list.nodes[current].record;pre = current,current = list.nodes[current].next);
list.nodes[i].next = current;
list.nodes[pre].next = i;
}
}
//链表重排算法
void ArrangTable(LinkList &list) {
int current = list.nodes[0].next;
for (int i = 1; i < list.length;)
{
int pre = current;
if (current > i) {
Node temp = list.nodes[i];
list.nodes[i].record = list.nodes[current].record;
list.nodes[i].next = current;
current = list.nodes[current].next;
list.nodes[list.nodes[i].next] = temp;
i++;
}
else if (current == i) {
current = list.nodes[current].next;
i++;
}
else {
current = list.nodes[current].next;
}
}
}
算法分析
表插入排序的基本操作同一般的插入排序一样,都是将当前元素插入到已排序好的序列中。但是表插入排序以修改
2
n
2n
2n (
n
n
n 为待排序元素数)次指针代替移动元素。它一定程度上提高了排序的效率,但是由于排序过程进行比较的次数与直接插入排序相同,所以时间复杂度仍为
O
(
n
2
)
O(n^2)
O(n2) 。
希尔排序
希尔排序(Shell’s Sort)又称“缩小增量排序”,它也是一类插入排序类的方法,但是时间效率优于一般的插入排序。
对于插入排序,存在两个特点:
- 当待排序列已经是正序时,排序的时间复杂度为 O ( n ) O(n) O(n),所以不难想象,如果待排序列已经基本正序时,排序效率会大大提升。
- 待排序列中元素较少时,排序的效率相对较高
希尔排序就从这两点入手,对算法进行改进。它首先将待排序列分成若干个子序列进行插入排序,然后合并子序列,对合并后的子序列排序,排序后在进行合并,直到所有子序列合并在一起,此时对序列全体在进行插入排序。其排序过程如图下所示:
由上图可以看出希尔排序的过程中,子序列不是简单地逐段分割,而是相隔某个增量组成子序列。上图中第一遍排序的增量为5,第二遍排序的增量为2,第三遍为1.
C++代码
//排序长度为10000的整数数组
void ShellSort(array<int, 10000> &list) {
int increment = 5;
while (increment > 0)
{
for (int i = increment; i < list.size(); i++) {
int sentry = list[i];
int j = i;
for (j; j > increment-1 && sentry < list[j - increment]; j -= increment) {
list[j] = list[j - increment];
}
list[j] = sentry;
}
increment /= 2;
}
}
算法分析
希尔排序的时间是所取增量序列的函数,希尔排序的增量序列可以有各种取法,但是必须使增量序列中的值没有除1之外的公因子,并且最后一个增量值必须等于1。
另外有人基于大量实验指出,当
n
n
n在某个特定的范围内,希尔排序所需的比较和移动次数约为
n
1.3
n^{1.3}
n1.3,即算法的时间复杂度为
O
(
n
1.3
)
O(n^{1.3})
O(n1.3),而且当
n
n
n趋于无穷时可以减少到
n
(
l
o
g
2
n
)
2
n(log_2n)^2
n(log2n)2。
总结
算法 | 时间复杂度(最坏) | 时间复杂度(最好) | 时间复杂度(平均) | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
插入排序 | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
希尔排序 | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | O ( n 1.3 ) O(n^{1.3}) O(n1.3) | O ( 1 ) O(1) O(1) | 不稳定 |
下面这些算法仅给出最坏的时间复杂度(绝不是没找到资料又偷懒没自己算)
算法 | 时间复杂度(最坏) | 空间复杂度 | 稳定性 |
---|---|---|---|
折半插入排序 | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 稳定 |
二路插入排序 | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | 稳定 |
表插入排序 | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | 稳定 |
参考文献
《算法导论》
《数据结构》(严蔚敏)
十大经典排序算法