本文主要学习了《算法》(Robert Sedgewick Kevin Wayne)中的排序章节,并对所有的重要知识点进行总结
1、选择排序
算法:从数组中找到最小的元素,和第一个元素交换;在剩余N-1个数中找到最小的元素,和第二个元素交换。
特点:1) 运行时间和输入无关,需要N^2/2次比较和N次交换;2) 数据移动是最小的,交换次数和数组大小是线性关系。
void sort(int[] a){
int n = a.length;
for(int i=0; i<n;i++){
int min = i;
for(int j=i+1; j<n; i++){
if(a[j]<a[min])
min = j;
}
int temp = a[i];
a[i] = a[min];
a[min] = temp;
}
}
2、插入排序
算法:从第二个元素开始,将元素和其前一个元素进行比较、交换,直至其找到合适位置。
特点:1) 对有序数据进行排序时达到最好情况,需要N-1次比较和0次交换;2) 最坏情况为原始完全倒序时,需要大约N^2/2次比较和交换;3) 对部分有序的数组较有效,也很适合小规模数组。
void sort(int[] a){
int n = a.length;
for(int i=1; i<n;i++){
for(int j=i; j>0 && a[j]<a[j-1]; j--){
int temp = a[j];
a[j] = a[j-1];
a[j-1] = temp;
}
}
}
3、希尔排序
算法:这是基于插入排序的改进,插入排序只能和相邻的数进行比较。希尔排序引入参数h,使得数组中任意间隔为h的元素都有序,从而保证随着h值的递减,最终整个数据都是有序的。
特点:1)权衡了子数组的规模和有序性;2) 算法性能取决于h;3) 适用于大型数组,但具体性能难以评价。代码量小,不需要额外内存空间,运行时间可接受。
void sort(int[] a){
int n = a.length;
int h=1;
while(h < n/3)
h = 3*h+1;
while (h >= 1){
for(int i=1; i<n;i++){
for(int j=i; j>0 && a[j]<a[j-h]; j-=h){
int temp = a[j];
a[j] = a[j-h];
a[j-h] = temp;
}
}
h = h/3;
}
}
4、归并排序
算法:递归地将数组分成两半分别排序,然后将结果归并起来。算法实现分为递归调用sort对分割的两个子数组进行排序,然后使用merge函数将两个数组进行合并。初始调用时使用sort(a,0,a.length-1)表示整个数组。
特点:1) 能够保证排序时间和NlogN成正比;2) 但是需要的额外空间也和N成正比;3) 缺点是辅助数组使用的空间和原始数组大小成正比。
下面给出自顶向下递归调用的归并排序。
void merge(int[] a, int lo, int mid, int hi){
//将a[lo:mid]和a[mid:hi]两个已经排序好的子数组归并为一整个数组
int i = lo,j=mid+1;
for(int k=lo;k<=hi;k++)
aux[k] = a[k]; //辅助数组aux,初始化时分配和a一样大的空间以供之后使用
//开始进行归并
for(int k=lo;k<=hi;k++){
if(i>mid) a[k] = aux[j++]; //左侧子数组中所有元素均已汇入大数组时,直接取右半边数组中的元素
else if(j>hi) a[k] = aux[i++]; //右侧子数组中所有元素均已汇入大数组时,直接取左半边数组中的元素
else if(a[j]<a[i]) a[k] = aux[j++]; //当前两个子数组索引所指位置,将较小值先加入到大数组中
else a[k] = aux[i];
}
}
void sort(int[] a, int lo, int hi){
if(hi<=lo) return;
int mid = lo + (hi - lo)/2;
//递归调用sort函数,分别对左半边数组和右半边数组进行排序
sort(a,lo,mid);
sort(a,mid,hi);
//调用merge函数进行合并
merge(a,lo,mid,hi);
}
优化点:1) 对小规模的数组使用插入排序,而不是递归调用sort函数;2) 通过a[mid]和a[mid+1]的大小比较来测试数组是否已经有序,如果有序可提前停止递归调用;3) 不将元素复制到辅助数组?
还有一种自底向上的归并,先解决小数组问题,然后慢慢整合成一个大的数组,这样的方法不需要递归,对应的merge并未改变,而sort函数改变如下:
void sort(int[] a){
for(int sz=1; sz<a.length;sz=sz+sz)
for(int lo=0;lo<a.length-sz;lo+=sz+sz)
merge(a,lo,lo+sz-1,Math.min(lo+sz+sz-1,a.length-1));
}
如果所有元素相同,归并的运行时间是线性的。
5、快速排序
算法:先使用partition找到pivot所处位置,保证其之前的数字均小于pivot,其之后的数字均大于pivot;然后递归调用将其他位置的元素进行排序。
和归并算法的区别:和归并是互补关系。1) 归并算法将数组分为子数组进行排序然后合并进行排序,递归调用在整个数组合并之前调用;快速排序将数组变为前半部分中数字均小于后半部分,从而使得子数组排序后直接整个数组排序完毕,递归调用在处理整个数组之后调用。2) 归并算法的切分点为1/2处,快排的切分点取决于pivot位置。
特点:1)能够保证排序时间和NlogN成正比;2) 原地排序,只需要一个很小的辅助栈;3) 内循环短小;4) 但是比较脆弱,需要避免低劣的性能。
int partition(int[] a, int lo, int hi){
int i = lo, j=hi+1;
int pivot = a[lo];
while(true){ //找到比pivot大的值和比pivot小的值并互换位置,保证前面的值小于后面的值
while(a[++i]<pivot)
if(i==hi)
break;
while(pivot<a[--j])
if(j==lo)
break;
if(i>=j)
break;
int temp = a[i];
a[i] = a[j];
a[j] = temp;
} //最终跳出循环时1) i==j指向同一个数字;2)j=i-1,此时a[:j]均小于等于pivot,a[i:]均大于等于pivot
int temp = a[lo];
a[lo] = a[j];
a[j] = temp;
return j;
}
void sort(int[] a, int lo, int hi){
if(hi<=lo) return;
int j = partition(a, lo, hi);
//递归调用sort函数,分别对左半边数组和右半边数组进行排序
sort(a,lo, j-1;
sort(a,j+1,hi);
}
6、优先队列
算法:主要目的不是全排序,而是从N个事物中找到最大(或最小)的M个事物,N值可能很大或者在不断扩大,全排序的计算压力过大。所以使用优先队列,基于删除最大(最小)元素和插入元素两种操作,来维护这M个事物。堆排序是其中的重要算法。
基础的优先队列可以通过数组(有序或无序)、链表来实现插入和删除操作。二叉堆是能很好地实现优先队列的基本操作。
二叉堆:基于完全二叉树,从根节点往下,一层一层由上向下、从左至右,每个节点下方连接两个更小的节点。这样的完全二叉树也可以存储在数组中,根节点置于位置1,其子节点置于位置2,3,依次往下,从而保证位置k的节点的父节点在位置k/2上,其子节点在2k和2k+1位置处。使用数组时,不使用下标为0处的位置,而从1开始存储根节点,从而保证上述性质的满足,所以存储N个元素需要N+1大小的数组。
基于二叉堆的两个基础操作为上浮和下沉,上浮是指在末端添加一个节点时,需要不断和其父节点进行比较、交换,从而将其置于合适位置,这是从末端向根节点的上浮过程;而下沉是对中间某个节点进行改变,变成一个较小值时,需要不断往下搜索,找到合适位置,是从根节点出发的下沉过程。对应的代码如下:
void swim(int k){
while(k>1 && a[k/2]<a[k]){
int temp = a[k];
a[k] = a[k/2];
a[k/2] = temp;
k = k/2;
}
}
void sink(int k){
while(2*k<=N){
int j = k*k;
if(j<N && a[j]<a[j+1]) j++; //下沉时,将子节点中较大的节点和父节点进行交换
if(!less(k,j)) break;
int temp = a[k];
a[k] = a[j];
a[j] = temp;
k = j;
}
}
这样的基本操作就能够实现上文提到的优先队列中的两个基本操作:1) 插入元素:在末端插入元素,并使用swim函数进行上浮操作至合适位置;2) 删除最大元素:从顶端删除最大元素,并将数组的最后一个元素放到顶端,调用sink函数进行下沉操作至合适位置。
void insert(int v){
a[++N] = v;
swim(N);
}
int delMax(){
int max = a[1];
//N为所有节点的个数,将最末端的元素调至根节点,并将最后一个置为null,对应元素个数减一
a[1] = a[N];
a[N] = null;
N--;
sink(1);
return max;
}
Example: multiway(有多个输入流,将其归并成一个有序的输出流)也可以使用优先队列解决:首先将每个输入流的第一个数取出,每次执行insert操作从而构成最小堆,调用delMin删除最小数,并读入这个最小数对应流中的下一个数,调整堆;以此类推,每一都可以得到一个最小数,并读入一个新数。。。
而堆排序也可以使用这两个基本操作完成,这里我们将sink函数的参数改变为sink(a,N,k),第二个参数代表当前数组的大小,k代表需要下沉的元素的下标。
void sort(int[] a){
int N = a.length;
//先使用下沉构造堆
for(int k=N/2,k>0;k--)
sink(a,N,k);
//每次将根节点置于当前的末端,也就是将最大元素放在最后,不断调整堆,最后数组中即为排序后的数组
while(N>1){
int temp = a[1];
a[1] = a[N];
a[N] = temp;
N--;
sink(a,N,1);
}
}
稳定性:能够保证重复元素在数组中的相对位置。 插入排序和归并排序可以保证稳定性,其他的不能。
Example:找到数组中的第K小的元素
1) 全排序,然后找到第K个
2) 使用堆排序,依次找到最小、第2小。。。。。第K小,相当于topK的变形
3) 使用快速排序中的partition函数的变形可以在线性时间内完成。每一次找到的pivot的索引j,那么前j个数一定是是top-j,所以需要在后面的数组中找到第(K-j-1)个,所以我们只需要找到索引为K位置对应的数值即可。对应代码如下:
int select(int[] a, int k){
int lo = 0, hi = a.length-1;
while(hi>lo){
int j = partition(a,lo,hi);
if (j==k)
return a[k];
else if (j > k){
hi = j-1;
} else if (j < k){
lo = j+1;
}
}
return a[k];
}