【前言】此篇是《数据结构和算法》的第七章读书笔记 :排序
本篇总结数组中元素的排序问题。根据元素的规模,通常的排序可以在内存中完成,规模太大而必须在磁盘等存储上完成的排序称为外排序。任何通用的排序算法都需要 Ω(NlogN) 次比较。
一、几种简单排序算法
数组的一个逆序指数组中位置 i 和 j 满足 i<j 但 A[i]>A[j] 的序偶。 N 个互异数的数组的平均逆序数为 N(N−1)/4 。
每次交换相邻元素可以消除一个逆序,通过交换相邻元素进行排序的任何算法平均需要 Ω(N2) 时间。
插入排序
插入排序是最简单的排序算法。插入排序由 N−1 趟排序组成,对于第 i 趟,保证位置0到 i 的元素为已排序的状态,即将位置 i 上的元素前移到它在前 N+1 个元素中的正确位置。
1 void insertion_sort(int a[], int n) 2 { 3 int i, j; 4 int t; 5 6 for (i = 1; i < n; i++) { 7 t = a[i]; 8 for (j = i; j > 0 && a[j-1] > t; j--) 9 a[j] = a[j-1]; 10 a[j] = t; //上面那句代码仅是替换掉前面一个 11 //swap(a[j] , a[j-1]);此条语句可以替换上面两条语句,但是调用了库函数 12 } 13 }
插入排序的平均运行时间为 Θ(N2) ,如果输入元素已经预先排序,那么运行时间为 O(N) 。
冒泡排序
冒泡排序每趟从后到前比较相邻元素的大小,将较小值交换到前面,就像冒泡一样。冒泡排序和插入排序的交换次数相同。最坏时间复杂度:O(N2)
1 void bubble_sort(tp a[], int n) 2 { 3 int i, j; 4 5 for (i = 0; i < n - 1; i++) 6 for (j = n - 1; j > i; j--) 7 if (a[j] < a[j-1]) 8 swap(&a[j], &a[j-1]); 9 }
选择排序
选择排序和冒泡排序有些类似,但交换次数较少,每趟记住位置 i 应放置的最小值并进行交换,最多交换 N−1次,但比较次数仍为 Θ(N2) 。
1 void selection_sort(tp a[], int n) 2 { 3 int i, j, min; 4 5 for (i = 0; i < n - 1; i++) { 6 min = i; 7 for (j = n - 1; j > i; j--) 8 if (a[j] < a[min]) 9 min = j; 10 swap(&a[i], &a[min]); 11 } 12 }
希尔排序
希尔排序通过比较相距一定间隔的元素来工作,每趟比较所用的间隔随算法的进行而减小,直到最后比较相邻元素,因此也称为缩小增量排序。它使用一个增量序列 h1,h2,...,ht ,只要 h1=1 ,任何增量序列都可行。使用增量 hk排序后,对每个 i 都有 A[i]≤A[i+hk] ,即所有相隔 hk 的元素都被排序,称为是 hk -排序的。希尔排序的一个性质是 hk -排序的数组保持它的 hk -排序性。
一趟 hk -排序的作用就是对 hk 个独立的子数组执行一次插入排序。增量序列的一个常见选择是希尔增量, ht=⌊N/2⌋ 和 hk=⌊hk+1/2⌋ ,但这并不算是一个好的选择。
1 void shell_sort(tp a[], int n) 2 { 3 int i, j, incr; 4 tp t; 5 6 for (incr = n / 2; incr > 0; incr /= 2) 7 for (i = incr; i < n; i++) { 8 t = a[i]; 9 for (j = i; j >= incr && a[j-incr] > t; j -= incr) 10 a[j] = a[j-incr]; 11 a[j] = t; 12 } 13 }
使用希尔增量时希尔排序的最坏运行时间为 Θ(N2) 。
希尔增量因为未必互素,所以较小的增量可能影响很小。Hibbard提出了另一个增量序列, 1,3,...,2k−1 ,可以得到更好的结果。使用Hibbard增量的希尔排序的最坏运行时间为 Θ(N3/2) ,模拟显示平均运行时间为 O(N5/4) 。Sedgewick提出了几种更好的序列,其中最好的是 1,5,19,41,109,... ,序列中的项为 9∗4i−9∗2i+1 或 4i−3∗2i+1 。 Θ(N3/2) 的界适用于广泛的增量序列。
希尔排序编程简单,性能也可以接受,因此是一个常用的排序算法。
堆排序
堆排序利用堆数据结构进行排序,它可以达到 O(NlogN) 的运行时间。堆排序使用了一个技巧,构建一个最大堆,然后删除最大元素并把它放到堆最后新空出来的位置上,这样就避免了使用额外的数组。最后得到元素由小到大的排序。有一个创建堆的过程,其时间复杂度o(n)。具体可参考前面的一篇博客“堆”,里面的应用部分就是写的堆排序。
static void perc_down(tp a[], int i, int n) { int j; tp t; for (t = a[i]; i*2 + 1 < n; i = j) { j = i * 2 + 1; if (j != n - 1 && a[j+1] > a[j]) j++; if (t < a[j]) a[i] = a[j]; else break; } a[i] = t; } void heap_sort(tp a[], int n) { int i; for (i = n / 2; i >= 0; i--) /* build max-heap */ perc_down(a, i, n); for (i = n - 1; i > 0; i--) { swap(&a[0], &a[i]); perc_down(a, 0, i); } }
注意这里的堆不使用标记,数据从位置0开始,因此位置 i 的元素的左右儿子分别在位置 2i+1 和 2i+2 。
堆排序是一种非常稳定的算法,它最多使用 2NlogN−O(N) 次比较,最少使用 NlogN−O(N) 次比较。在实践中,堆排序要慢于使用Sedgewick增量序列的希尔排序。
归并排序
归并排序以 O(NlogN) 的最坏运行时间运行,所使用的比较次数几乎是最优的。归并排序中的基本操作是合并两个已排序的数组,这是线性时间的。排序时,递归地将前半部分和后半部分的数据进行归并排序,得到的排序后的两部分再用上述的基本操作合并。
static void merge(tp a[], tp ta[], int l, int r, int rend)
{
int lend, t, i;
lend = r - 1;
t = i = l;
while (l <= lend && r <= rend) {
if (a[l] <= a[r])
ta[t++] = a[l++];
else
ta[t++] = a[r++];
}
while (l <= lend)
ta[t++] = a[l++];
while (r <= rend)
ta[t++] = a[r++];
for (; i <= rend; i++)
a[i] = ta[i];
}
static void merge_sort_rec(tp a[], tp ta[], int l, int r)
{
int m;
if (l < r) {
m = (l + r) / 2;
merge_sort_rec(a, ta, l, m);
merge_sort_rec(a, ta, m+1, r);
merge(a, ta, l, m+1, r);
}
}
void merge_sort(tp a[], int n)
{
tp *ta;
ta = malloc(sizeof(tp)*n);
if (ta == NULL)