插入排序(insertion sort)
插入排序由N-1趟排序组成,对于P = 1到P = N-1趟,插入排序保证从位置0到位置P上的元素为已排序状态。插入排序的平均情形是θ(N^2)
假设不存在重复的元素,有以下定理:
【定理】N个互异的数的数组的平均逆序数是N(N-1)/4
【定理】通过交换相邻元素进行排序的任何算法平均需要Ω(N^2)
void insertionSort(int a[], int n)
{
int i, j, tmp;
for(i = 1; i < n; i++)
{
tmp = a[i];
for(j = i; (j > 0) && (a[j - 1] > tmp); j--)
a[j] = a[j - 1];
a[j] = tmp;
}
}
希尔排序(Shell sort)
希尔排序也叫缩小增量排序(diminishing increment sort),它使用一个序列h1,h2,...,ht,叫做增量序列(increment sequence)。在使用增量h k的一趟排序之后对于每一个i我们有A[i] <= A[i + hk],即所有相隔hk的元素都被排序,此时称文件是hk-排序的。希尔排序的一个重要性质是,一个hk-排序的文件(下一步是h k-1-排序)保持它的hk-排序性。hk-排序的一般做法是,对于h k,h k+1,...,N-1中的每一个位置i,把其上的元素放到i,i-hk,i-2hk...中间的正确位置上。一趟hk-排序的作用就是对h k个独立的子数组进行一次插入排序
【定理】使用希尔增量时,希尔排序的最坏情形运行时间为θ(N^2)
希尔增量的问题在于,这些增量未必互质,因此较小的增量可能影响很小。Hibbard提出一个稍微不同的增量序列,它在实践中(并且在理论上)能给出更好的结果。他的增量是1,3,7,...,2^k-1
【定理】Hibbard增量的希尔排序的最坏情形运行时间为θ(N^3/2)
希尔排序的性能在实践中是完全可以接受的,即使是对于数以万计的N仍是如此。编程的简单特点使得它称为对适度的大量输入数据经常选用的算法
void shellSort(int a[], int n)
{
int i, j, increment;
int tmp;
for(increment = n / 2; increment >= 1; increment /= 2)
for(i = increment; i < n; i++)
{
tmp = a[i];
for(j = i; (j >= increment) && (a[j - increment] > tmp); j -= increment)
a[j] = a[j - increment];
a[j] = tmp;
}
}
堆排序
优先队列可以用O(N log N)的时间进行排序,基于这种想法的算法叫堆排序。尽管它的O运行时间优于希尔排序,但在实践中却慢于Sedgewick增量序列的希尔排序。该算法的主要问题在于它使用了一个附加的数组,因此存储需求增加了一倍。避免使用第二个数组的聪明做法是,因为在每次DeleteMin之后堆缩小了1,所以缩小的单元可以用来存放刚刚删掉的元素
经验指出,堆排序是一个非常稳定的算法:它平均使用的比较只比最坏情形界指出的略少
void swap(int* a, int* b)
{
int t = *a;
*a = *b;
*b = t;
}
void percDown(int a[], int i, int N)
{
int child, tmp;
for(tmp = a[i]; 2*i + 1 < N; i = child)
{
child = 2*i + 1;
if((child + 1 < N) && (a[child] < a[child + 1]))
child++;
if(tmp < a[child])
a[i] = a[child];
else
break;
}
a[i] = tmp;
}
void heapSort(int a[], int N)
{
int i;
for(i = N / 2; i >= 0; i--) // 从N/2开始,只要保证向前所有的节点都大于自己的儿子节点,则堆就成功建立。注意要从N/2向前建立堆
percDown(A, i, N);
for(i = N - 1; i >= 0; i--)
{
swap(&a[0], &a[i]);
percDown(A, 0, i); // 将原来堆的最后一个元素移动到正确的位置
}
}
归并排序的基本操作是合并两个已排序的表,它的最坏情形运行时间是O(N log N)。具体算法是递归地将前半部分数据和后半部分数据各自归并排序,得到排序后的两部分数据,再将它们进行合并。虽然归并排序的运行时间是O(N log N),但它很难用于主存排序,因为合并两个各排序的表需要线性附加内存,同时对数据的频繁移动也带来了附加工作,结果严重放慢了排序速度。合并排序的例程大多数是外部排序
void Msort(int a[], int* tmpArr, int start, int end)
{
if(start < end)
{
int center = (start + end) / 2;
Msort(a, tmpArr, start, center);
Msort(a, tmpArr, center + 1, end);
Merge(a, tmpArr, start, center, end);
}
}
void Merge(int a[], int* tmpArr, int left, int leftEnd, int rightEnd)
{
int right = leftEnd + 1;
int i;
while((left <= leftEnd) && (right <= rightEnd))
{
if(a[left] < a[right])
tmpArr[i++] = a[left++];
else
tmpArr[i++] = a[right++];
}
while(left <= leftEnd)
tmpArr[i++] = a[left++];
while(right <= rightEnd)
tmpArr[i++] = a[right++];
for(i = 0; i <= rightEnd; i++)
a[i] = tmpArr[i];
}
void mergeSort(int a[], int N) // 之所以将Msort和mergeSort写成两个函数是为了避免重复为tmpArr分配空间而耗尽内存
{
int* tmpArr = (int*)malloc(N * sizeof(int));
if(tmpArr == NULL)
{
printf("Not enough space\n");
exit(1);
}
Msort(a, tmpArr, 0, N - 1);
free(tmpArr);
}
快速排序是在实践中最快的已知排序算法,它的平均运行时间是O(N log N)。尽管最坏情形的性能为O(N^2),但稍加努力就可以避免这种情形。将数组S进行快速排序基本的算法由4个步骤组成:
1. 如果S中的元素个数为0或1,返回
2. 取S中的任意元素v,称为枢纽元(pivot)
3. 将S中除pivot外所有的元素分为两个不相交的集合S1和S2, S1中的所有元素都小于pivot,S2中所有的元素都大于pivot
4. 返回quickSort(S1),pivot,quickSort(S2)
直观地看,我们希望把等于pivot的关键字平均分配到S1和S2中
选取pivot:不能选择第一个元素或前两个互异的关键词中的较大者作为pivot,因为输入可能是预排序的或是反序的。常用的安全做法是使用最左端,最右端和中心位置上的三个元素的中值作为pivot
分割策略:第一步是将pivot与最后的元素交换使得pivot离开要被分割的数据段,然后将所有小于pivot的元素向左移动,大于pivot的元素向右移动。如下图所示,i右移,j左移,当i和j停止时i指向一个大的元素而j指向一个小的元素,此时将这两个元素交换,直到i、j交错为止。如果i/j遇到的关键字和pivot相等,则i/j停止。最后,将pivot与i所指向的元素交换
对于很小的数组(N <= 20),快速排序不如插入排序
在实际操作中,选取pivot最好的方法是对a[start],a[end]和a[center]适当地排序,将最小者放到a[start],最大者放到a[end],将pivot放到a[end - 1],这样不仅简化了比较,还可以避免j越界
#define CUTOFF 3
void swap(int* a, int* b)
{
int t = *a;
*a = *b;
*b = t;
}
int median(int a[], int start, int end)
{
int center = (start + end) / 2;
if(a[start] > a[center])
swap(&a[start], &a[center]);
if(a[start] > a[end])
swap(&a[start], &a[end]);
if(a[center] > a[end])
swap(&a[center], &a[end]);
swap(&a[center], &a[end - 1]);
return a[end - 1];
}
void insertionSort(int a[], int start, int end)
{
int i, j;
if(end - start > 0)
{
for(i = start + 1; i <= end; i++)
{
int tmp = a[i];
for(j = i; (j > start) && (a[j - 1] > tmp); j--)
a[j] = a[j - 1];
a[j] = tmp;
}
}
}
void Qsort(int a[], int start, int end)
{
if(start + CUTOFF <= end)
{
int pivot = median(a, start, end);
int i = start;
int j = end - 1;
while(1)
{
while(a[++i] < pivot); // 注意此处不能用 while(i < pivot) i++;
while(a[--j] > pivot);
if(i < j)
swap(&a[i], &a[j]);
else
break;
}
swap(&a[i], &a[end - 1]);
Qsort(a, start, i - 1);
Qsort(a, i + 1, end);
}
else
insertionSort(a, start, end);
}
void quickSort(int a[], int size)
{
Qsort(a, 0, size - 1);
}
快速排序的最坏情形是O(N^2),最好情况和平均情况都是O(N log N)
大型数据结构的中,由于交换两个结构可能是非常昂贵的操作,所以实际的做法是让数组包含指向结构的指针
对于一般的内部排序应用,选用的方法一般是插入排序,希尔排序或快速排序,主要根据输入的大小来决定。如果需要对一些大型文件排序,那么应该选用快速排序(或者希尔排序也可以)
外部排序
当外部存储设备中的数据太多无法一次全部载入内存时,需要进行外部排序
简单算法:一次读入M个记录,在内部将记录排序称为顺串(run),交替写出到两路外设再进行归并排序
多路合并:类似简单算法,只是写出到多路。找出k个元素中的最小者稍微有些复杂,可以通过优先队列实现。但k路合并需要2k个外设
多相合并:用k+1个外设实现k-路合并。将顺串不均等地分配到外设中。如果顺串个数是斐波那契数Fn,最好的分配方法是分成F n-1和F n-2;否则需要添加哑顺串(dummy run)来进行填补
产生顺串的算法:
替换选择(replacement selection):将M个记录读入内存并放到一个优先队列中,执行一次DeleteMin将最小的记录写出,再从外设中读取一个新的记录;如果它比刚刚写出的大,则加入优先队列,否则存入优先队列但变成死区(dead space),直到优先队列大小为零,则顺串构建完成,再建立新的顺串
有可能替换选择并不比标准算法好,但由于数据常常是几乎排序的,此时替换选择仅仅产生数量很少的长顺串