Matrix Wall

排序算法(一)

回顾基础的排序算法

在数据结构这门课程里,学校的老师们是较侧重于教学生如何利用诸如C语言来实现线性表,队列,栈,树这些简单的数据结构,理解这些数据结构的用法,而往往轻视了后半块关于图论,排序,查找等偏算法分析方面的内容的教学。

虽然课程的名字叫做数据结构,但很多CS强校和国外的著作都习惯地把它称作“数据结构与算法分析”。说到底,数据结构只能算是对编程语言使用的一个延伸,很多程序设计入门书诸如《C Primer Plus》和《C和指针》的后半段都会谈一些链表,树等内容,所以现在回过头来看,前面数据结构的学习还是为了在后面实现一些复杂的算法的时候,能够更好地利用这些结构来处理数据和实现算法中的思想。

在期末考试中,我们最后的大题目往往都是解答题,要求学生用编程语言实现诸如快速排序这样的算法,那么这样来看的话,独立实现这些复杂的算法,便就是真正考验这门课程是否学好的重要标准之一。

这一篇就先回顾排序算法


冒泡排序(Bubble Sort)

冒泡排序是一种比较简单的排序算法,通常被用来对于刚入门学习程序设计的学生介绍算法的概念。

算法原理:

  1. 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
  3. 针对所有的元素重复以上的步骤,除了最后一个。
  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

算法分析:

  • 若文件的初始状态是正序的,一趟扫描即可完成排序。所需的关键字比较次数(n-1)和记录移动次数(0)均达到最小值。
    所以,冒泡排序最好的时间复杂度为O(n)。

  • 若初始文件是反序的,需要进行趟排序。每趟排序要进行次关键字的比较(1≤i≤n-1),且每次比较都必须移动记录三次来达到交换记录位置。在这种情况下,比较和移动次数均达到最大值:O(n²)

  • 冒泡排序的最坏时间复杂度为O(n²)。

综上,因此冒泡排序总的平均时间复杂度为O(n²)。

算法稳定性:

由于冒泡算法的核心思想就是把小的元素往前调,大的元素往后调,相同的元素不做处理,所以即使相同的元素中间还隔着其他元素,这两个元素也不会发生交换,所以冒泡排序是一种稳定的排序算法。

算法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef int ElemType;

void BubbleSort( ElemType A[], int N )
{
int i, j;
ElemType Tmp;
for( i = 0; i < N - 1; i++)
for( j = 0; j < N - 1 - i; j++)
{
if( A[ j ] > A[ j + 1] )
{
Tmp = A[ j ];
A[ j ] = A[ j + 1];
A[ j + 1] = Tmp;
}
}
}

插入排序(Insertion Sort)

插入排序由N-1趟排序组成,并且利用了这样的事实:位置0到位置P-1上的元素是排过序的。

它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

算法原理:

  1. 从第一个元素开始,该元素可以认为已经被排序。
  2. 取出下一个元素,在已经排序的元素序列中从后向前扫描。
  3. 如果该元素(已排序)大于新元素,将该元素移到下一位置。
  4. 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置。
  5. 将新元素插入到该位置后
  6. 重复步骤2~5

算法分析:

  • 由于嵌套循环的每一个都花费N次迭代,因此插入排序为O(n²),而且这个界是精确的,因为以反序输入同样可以达到该界。
  • 如果输入数据是有序的,那么运行时间为O(n),因为内层的for循环的检测总是立即判定不成立而终止。

综上,插入排序的平均情形也是O(n²)。

算法稳定性:

插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。当然,刚开始这个有序的小序列只有1个元素,就是第一个元素。比较是从有序序列的末尾开始,也就是想要插入的元素和已经有序的最大者开始比起,如果比它大则直接插入在其后面,否则一直往前找直到找到它该插入的位置。如果碰见一个和插入元素相等的,那么插入元素把想插入的元素放在相等元素的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定的。

算法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
typedef int ElemType;

void InsertionSort( ElemType A[], int N )
{
int j, P;

ElemType Tmp;
for( P = 1; P < N; P++ )
{
Tmp = A[ P ];
for( j = P; j > 0 && A[ j - 1 ] > Tmp; j-- )
A[ j ] = A[ j - 1 ];
A[ j ] = Tmp;
}
}
```


---

# 希尔排序:(Shell Sort)
希尔排序是插入排序的一种更高效的改进版本,通过比较相距一定距离的元素来工作,各趟比较所用的距离随着算法的进行而减小,直到只比较相邻元素的最后一趟排序为止,所以希尔排序有时也叫缩小增量排序。

增量是希尔排序的重要部分,只要最终增量为1任何增量序列(*increment sequence*)都可以工作。算法最开始以一定的增量进行排序。然后会继续以一定增量进行排序,最终算法以增量为1进行排序。当增量为1时,算法变为插入排序,这就保证了数据一定会被排序。

## 算法原理:
先取一个小于n的整数d1作为第一个增量,把文件的全部记录分成d1个组。所有距离为d1的倍数的记录放在同一个组中。先在各组内进行直接插入排序;然后,取第二个增量`d2<d1`重复上述的分组和排序,直至所取的增量`dt=1(dt<dt-l<…<d2<d1)`,即所有记录放在同一组中进行直接插入排序为止。

增量的选择有很多,并且关乎算法的效率。增量序列的一种流行选择是使用Shell建议的序列,`d1 = [ N / 2]`和`d(t) = [ d(t-1)/2 ]`。

已知的最好步长串行是由Sedgewick提出的 `(1, 5, 19, 41, 109,...)`,该串行的项来自 `9 * 4^i - 9 * 2^i + 1` 和 `4^i - 3 * 2^i + 1` 这两个算式,这项研究也表明“比较在希尔排序中是最主要的操作,而不是交换。”用这样步长串行的希尔排序比插入排序和堆排序都要快,甚至在小数组中比快速排序还快,但是在涉及大量数据时希尔排序还是比快速排序慢。

## 算法分析:
#### 优劣

希尔排序是基于插入排序的一种算法,在此算法基础之上增加了一个新的特性,提高了效率。希尔排序的时间复杂度与增量序列的选取有关,例如希尔增量时间复杂度为O(n²),而Hibbard增量的希尔排序的时间复杂度为O(n^3/2),但是现今仍然没有人能找出希尔排序的精确下界。希尔排序没有快速排序算法快 O(n(logn)),因此中等大小规模表现良好,对规模非常大的数据排序不是最优选择。但是比O(n²)复杂度的算法快得多。并且希尔排序非常容易实现,算法代码短而简单。

#### 时间性能

1. 增量序列的选择
Shell排序的执行时间依赖于增量序列。
好的增量序列的共同特征:
- 最后一个增量必须为1
- 应该尽量避免序列中的值(尤其是相邻的值)互为倍数的情况。
有人通过大量的实验,给出了较好的结果:当n较大时,比较和移动的次数约在nl.251.6n1.25之间。

2. Shell排序的时间性能优于直接插入排序
希尔排序的时间性能优于直接插入排序的原因:
①当文件初态基本有序时直接插入排序所需的比较和移动次数均较少。
②当n值较小时,n和的差别也较小,即直接插入排序的最好时间复杂度O(n)和最坏时间复杂度0()差别不大。
③在希尔排序开始时增量较大,分组较多,每组的记录数目少,故各组内直接插入较快,后来增量di逐渐缩小,分组数逐渐减少,而各组的记录数目逐渐增多,但由于已经按di-1作为距离排过序,使文件较接近于有序状态,所以新的一趟排序过程也较快。

因此,希尔排序在效率上较直接插入排序有较大的改进。

## 算法稳定性:
由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的。

## 算法实现:
```C
typedef int ElemType;

void Shellsort( ElemType A[], int N )
{
int i, j, Increment;
ElemType Tmp;

for( Increment = N / 2; Increment > 0; Increment /= 2 )
for( i = Increment; i < N; i++ )
{
Tmp = A[ i ];
for( j = i; j >= Increment; j -= Increment )
if( Tmp < A[ j - Increment ] )
A[ j ] = A[ j - Increment];
else
break;
A[ j ] = Tmp;
}
}

选择排序(Selection sort)

选择排序(Selection sort)是一种简单直观的排序算法。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

算法原理:

  1. 从未排序的数组中选择最小的元素,组成有序序列。
  2. 从无序序列中继续挑选最小的元素,插入有序序列。
  3. 以此类推,直到全部转化为有序序列。

算法分析:

选择排序的交换操作介于0和(n-1)次之间。选择排序的比较操作为n(n-1)/2次之间。选择排序的赋值操作介于0和3(n-1)次之间。

比较次数O(n^2),比较次数与关键字的初始状态无关,总的比较次数N=(n-1)+(n-2)+...+1=n*(n-1)/2。 交换次数O(n),最好情况是,已经有序,交换0次;最坏情况是,逆序,交换n-1次。 交换次数比冒泡排序少多了,由于交换所需CPU时间比比较所需的CPU时间多,n值较小时,选择排序比冒泡排序快。

算法稳定性:

选择排序是给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推,直到第n-1个元素,第n个元素不用选择了,因为只剩下它一个最大的元素了。那么,在一趟选择,如果一个元素比当前元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。比较拗口,举个例子,序列5 8 5 2 9,我们知道第一遍选择第1个元素5会和2交换,那么原序列中2个5的相对前后顺序就被破坏了,所以选择排序不是一个稳定的排序算法。

算法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef int ElemType;

void Selectsort( ElemType A[], int N )
{
int i, j, loc;
ElemType min, Tmp;

for( i = 0; i < N; i++)
{
min = A[ i ];
for( j = i; j < N; j++)
{
if( A[ j ] <= min )
{
min = A[ j ];
loc = j;
}
}
Tmp = A[ i ];
A[ i ] = A[ loc ];
A[ loc ] = Tmp;
}
}

堆排序(Heapsort)

堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。

堆节点的访问

通常堆是通过一维数组来实现的。在起始数组为 0 的情形中:

  • 父节点i的左子节点在位置 (2*i+1);
  • 父节点i的右子节点在位置 (2*i+2);
  • 子节点i的父节点在位置 floor((i-1)/2);

堆的操作

在堆的数据结构中,堆中的最大值总是位于根节点。堆中定义以下几种操作:

  • 最大堆调整(Max_Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点
  • 创建最大堆(Build_Max_Heap):将堆所有数据重新排序
  • 堆排序(HeapSort):移除位在第一个数据的根节点,并做最大堆调整的递归运算

算法原理:

  1. 先将无序的数组建立成一个最大堆(即最大值位于根结点的完全二叉树)。
  2. 依次从最大堆中删除最大值,放入数组尾部,并将最大堆重新调整。直到全部删除为止。

算法分析:

堆排序的时间,主要由建立初始堆和反复重建堆这两部分的时间开销构成,它们均是通过调用PercDown(下滤算法)实现的。

平均性能 O(N*logN)。

其他性能

  • 由于建初始堆所需的比较次数较多,所以堆排序不适宜于记录数较少的文件。
  • 堆排序是就地排序,辅助空间为O(1).
  • 在最坏的情况下,堆排序最多使用2NlogN - O(N)次比较。

算法稳定性:

它是不稳定的排序方法。

算法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

#define LeftChild(i) (2*(i)+1)
typedef int ElemType;

void PercDown( ElemType A[], int i, int N )
{
int Child;
ElemType Tmp;

for( Tmp = A[ i ]; LeftChild( i ) < N; i = Child )
{
Child = LeftChild( i );
if( Child != N - 1 && A[ Child + 1 ] > A[ Child ])
Child++;
if( Tmp < A[ Child ])
A[ i ] = A[ Child ];
else
break;
}
A[ i ] = Tmp;
}

void Heapsort( ElemType A[], int N )
{
int i;
ElemType Tmp;
for( i = N / 2; i >= 0; i-- )
PercDown( A, i, N);
for( i = N - 1; i > 0; i-- )
{
Tmp = A[ 0 ];
A[ 0 ] = A[ i ];
A[ i ] = Tmp;
PercDown( A, 0, i );
}
}