定义模板代码
模板代码中的几个方法主要用于比较数组元素的大小,交换数组中的元素,展示数组中的元素,以及判断数组是否排好序。接下来的排序讲解将调用这些辅助的方法,本篇文章所有的排序都按数组升序排列进行处理。
//用于比较数组中两个元素的大小
private static boolean less(Comparable v,Comparable w){
return v.compareTo(w)<0;
}
//用于交换i、j索引位置的元素
private static void exch(Comparable[] a, int i, int j){
Comparable t = a[i];
a[i]=a[j];
a[j]=t;
}
//用于展示数组中的元素
private static void show(Comparable[] a){
for (int i = 0; i < a.length; i++) {
StdOut.print(a[i]+" ");
}
StdOut.println();
}
//判断数组是否有序
public static boolean isSorted(Comparable[] a){
for (int i = 1; i < a.length; i++) {
if(less(a[i],a[i-1])) return false;
}
return true;
}
1.冒泡排序
1.1 算法步骤:
- 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
- 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
1.2 代码片段:
//冒泡排序
public static void sort(Comparable[] a){
int N = a.length;
for (int i = 0; i < N-1; i++) {
for (int j = 0; j < N-i-1; j++) {
if(less(a[j+1],a[j])){//比较a[j+1]和a[j]的大小
exch(a,j,j+1);//交换索引j和j+1位置的元素
}
}
}
}
1.3 代码分析: 对于这段冒泡排序的代码,其实就两个地方需要说明一下,一是外层for循环的i<N-1,二是内层for循环的j<N-i-1。
举个栗子:
我们对一个length=5的数组进行排序
排序的过程如下图的流程:
从上图可以看出外层for循环执行了4次,所以i<N-1=5-1=4;然后我们再来看内层for循环的 j<N-i-1; 当i=0时,既执行第一次外层for循环,这时我们有5个元素需要比较4次。j<N-i-1=5-0-1=4 (j取值为0到3,刚好比较4次),依次执行,当执行第4次for循环的时候(既i=3,j<N-i-1=5-3-1=1),这时就只剩下两个元素需要比较。
1.4 冒泡排序的时间复杂度分析:
1).最坏时间复杂度:T(n)=O(n^2)。
分析:最坏的情况即所有元素都为倒序排列,那么每两个元素在进行比较的时候都要进行交换。每次交换需要执行3行代码,所以T(n)=O(1x3+2x3+…(n-1)x3)=O(n^2)。
2).最优时间复杂度:T(n)=O(n^2)。
分析:最优的情况即对一个有序数组进行排序,每两个元素都只是进行比较而没有交换元素的位置,所以T(n)=O(1+2+…(n-1))=O(n^2)。
3).平均时间复杂度:T(n)=O(n^2)。
分析:整体来看,数组在排序时,可能交换的次数为0,1,2…n(n-1)/2,所以T(n)=O((1x3+2x3+…n(n-1)/2*3)/n(n-1)/2)=O(n^2)。
1.5 空间复杂度: S(n)=O(1)。
1.1 改进后的冒泡排序
代码片段:
public static void sort(Comparable[] a){
int N = a.length;
for (int i = 0; i < N-1; i++) {
boolean flag = true;
for (int j = 0; j < N-i-1; j++) {
if(less(a[j+1],a[j])){//比较a[j+1]和a[j]的大小
exch(a,j,j+1);//交换索引j和j+1位置的元素
flag=false;
}
}
if(flag){
break;
}
}
}
时间复杂度:
1).最坏时间复杂度:T(n)=O(n^2)。
2).最优时间复杂度:T(n)=O(n)。
3).平均时间复杂度:T(n)=O(n^2)。
空间复杂度: S(n) = O(1)。
2.选择排序
2.1 算法步骤:
1.找到数组中最小的那个元素,将它和数组第一个位置的元素交换位置。
2.在剩下的元素中找到最小的元素,将它与数组中第个位置的元素交换位置。
3.如此往复,直到将整个数组排序。
2.2 代码片段:
//选择排序
public static void sort(Comparable[] a){
int N=a.length;
for (int i = 0; i < N-1; i++) {
for (int j = i+1; j < N; j++) {
if(less(a[j],a[i])){
exch(a,i,j);
}
}
}
}
2.3 代码分析: 我仍然用一个例子进行说明。我们对一个length=5的数组进行排序。
排序的过程如下图的流程:
2.4 选择排序的时间复杂度分析:
1).最坏时间复杂度:T(n)=O(n^2)。
分析:最坏的情况,length=N的数组倒序排列,对于长度为N的数组,每两个元素相互比较,比较的总次数为1+2+…(n-1),每次比较都进行了交换元素的操作,所以,T(n)=O(3(1+2+3+…+(n-1)))=O(n^2)。
2).最优时间复杂度:T(n)=O(n^2)。
分析:最优情况,length=N的数组正序排列,比较的总次数仍为1+2+3…+(n-1),只是每次比较不再交换元素,所以T(n)=O(1+2+3+…+(n-1))=O(n^2)。
3).平均时间复杂度:T(n)=O(n^2)。
分析:整体来看,数组在排序时,可能交换的次数为0,1,2…n(n-1)/2,所以T(n)=O((1x3+2x3+…n(n-1)/2*3)/n(n-1)/2)=O(n^2)。
2.5.空间复杂度: S(n)=O(1)。
3.插入排序
3.1 算法步骤:
- 将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
- 从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
3.2 代码片段:
//插入排序
public static void sort(Comparable[] a){
int N=a.length;
for (int i = 1; i < N; i++) {
for (int j = i; j > 0 && less(a[j],a[j-1]); j--) {
exch(a,j,j-1);
}
}
}
3.3 代码分析: 我仍然用一个例子进行说明。我们对一个length=5的数组进行排序。
排序的过程如下图的流程:
3.4 插入排序的时间复杂度分析:
1).最坏时间复杂度:T(n)=O(n^2)。
分析:最坏的情况,length=N的数组倒序排列,对于长度为N的数组,每两个元素相互比较都进行交换元素的操作,需要比较的次数和交换的次数都为:1+2+3+…(n-1),所以,T(n)=O((1+2+3+…(n-1))x2)=O(n^2)。
2).最优时间复杂度:T(n)=O(n)。
分析:当数组正序排列的时候,那么每个元素都只和它左边的元素进行比较,并且不发生交换。所以,T(n)=O(n-1)=O(n)。
3).平均时间复杂度:T(n)=O(n^2)。
分析:对于一个length=N的数组,可能发生的比较次数为(n-1)~n(n-1)/2,可能发生的交换次数为0 ~ n(n-1)/2,对这两项进行大致的计算可得T(n)=O(n^2)。
3.5 空间复杂度:
S(n)=O(1)。
3.6 不需要交换的插入排序
要大幅提高插入排序的速度并不难,只需要在内循环中将较大的元素都向右移动,而不总是交换两个元素(这样访问数组的次数就能减半)。
代码片段:
//不需要交换的插入排序
public static void sort(Comparable[] a){
int N = a.length;
for(int i=1;i<N;i++){
Comparable temp = a[i];
int j;
for(j=i;j>=1 && less(temp, a[j-1]); j--){
a[j] = a[j-1];
}
a[j] = temp;
}
}
4. 希尔排序
希尔排序是一种基于插入排序的快速的排序算法,对于大规模乱序数组插入排序很慢,因为它只会交换相邻的元素,因此元素只能一点一点地从数组的一端移动到另一端。例如,如果主键最小的元素正好在数组的尽头,要将它移动到正确的位置就需要N-1次的移动。希尔排序为了加快速度简单地改进了插入排序,交换不相邻的元素以对数组的局部进行排序,并最终使用插入排序将局部有序的数组排序。
希尔排序的思想是使数组中任意间隔为h的数组都是有续的。这样的数组被称为h有序数组。换句话说,一个h有序数组就是h个互相独立的有续数组编织在一起组成的一个数组。
4.1 算法步骤:
1. 将插入排序代码中移动元素的距离由1改为h。
2. 相隔为h的元素将会分到同一组,那么待排序的一个数组就会分成h个子数组。
3.各个相距为h的元素在子数组内部分别执行插入排序。
4.缩小间距h(例如:h=h/3),这时待排序数组会分成h/3个子数组,各个子数组内部执行插入排序。
5.直到h=1,这时排序完毕,数组即为有序数组。
4.2 代码片段:
1). 需要交换的希尔排序
//需要交换的希尔排序
public static void sort(Comparable[] a){
int N = a.length;
int h = 1;
while(h<N/3)h=3*h+1;
while(h>=1){
for(int i=h; i<N; i++){//将a[i]插入到a[i-h],a[i-2h],a[i-3*h]...之中
for (int j = i; j>=h && less(a[j],a[j-h]); j-=h) {
exch(a,j,j-h);
}
}
h=h/3;
}
}
2).不需要交换的希尔排序
// 不需要交换的希尔排序
public static void sort(Comparable[] a){
int N = a.length;
int h = 1;
while(h<N/3)h=3*h+1;
while(h>=1){
for(int i=h; i<N; i++){//将a[i]插入到a[i-h],a[i-2h],a[i-3*h]...之中
Comparable temp = a[i];
int j;
for (j = i; j>=h && less(temp,a[j-h]); j-=h) {
//exch(a,j,j-h);
a[j]=a[j-h];
}
a[j]=temp;
}
h=h/3;
}
}
4.3 代码分析:
我仍然举例说明,这一次我用一个length=10的数组进行排序。
对于上图数组中的10个元素,我采用代码中的做法来决定h的值,即h=4。那么间隔为4的元素都将被分到一组。
由上图可得,间距为4元素即相同颜色被分在了一组比较,组内部执行插入排序,间隔为4。
- 执行第1次while循环(h=4)后:
由上图可以看出,虽然A和B两个元素最初在数组的末端,但是它们不在像执行插入排序时那样,有数组的末端一次移动一个位置,缓慢的移动到数组的头部。
- 执行第2次while循环(h=1)后:
h=1即插入排序,将数组排序完毕。
4.4 希尔排序的时间复杂度分析:
这个不知道怎么分析!直接写个百科上抄来的结论。
T(n)=O(n^(1.3-2))。
4.5 空间复杂度:
S(n)=O(1).
5.归并排序
分治思想的典型应用
要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。归并排序最吸引人的性质是它能够保证将任意长度为N的数组排序所需时间和NlogN成正比,它的主要缺点是它所需的额外空间和N成正比。
5.1 自顶向下的归并排序
5.1.1 算法步骤:
1.申请一个辅助数组(将辅助数组声明为成员变量),长度和原有数组一样为N。
2.将原有数组不断的拆分,直至每个子数组只有一个元素。(递归操作就是先递后归,拆分数组就是一个不断传递拆分的过程)。
3.借助辅助数组就行归并,将原数组排序。a[0]和a[1]进行归并,a[2]和a[3]进行归并…a[N-2]和a[N-1]进行归并。(递归操作的归过程,先两两归并排序,然后是四四归并,一次次翻倍归并,直至将原数组排序完毕)。
5.1.2 代码片段:
1. 自顶向下的归并排序:
//原地归并的抽象方法
public static void merge(Comparable[] a, int lo, int mid, int hi){
int i = lo, j = mid+1;
for(int k = lo; k <= hi; k++){
aux[k] = a[k];
}
for(int k = lo; k <= hi; k++){
if(i > mid){
a[k] = aux[j++];//辅助数组将值赋值给待排序数组,自身指针右移
}else if(j > hi){
a[k] = aux[i++];
}else if(less(aux[j],aux[i])){
a[k] = aux[j++];
}else{
a[k] = aux[i++];
}
}
}
//自顶向下的归并排序
private static void sort(Comparable[] a, int lo, int hi){
if(hi <= lo){
return;
}
int mid = (lo + hi)/2;
sort(a,lo,mid);//将左半边排序
sort(a,mid+1,hi);//将右半边排序
merge(a,lo,mid,hi);//归并结果
}
5.1.3 代码分析:
上述代码中原地归并的抽象方法在归并时进行了4个条件判断:
1. 左半边用尽(取右半边的元素)。
2. 右半边用尽(取左半边的元素)。
3. 右半边的当前元素小于左半边的当前元素(取右半边的元素)。
4. 右半边的当前元素大于等于左半边的当前元素(取左半边的元素)。
这次我用一个length=8的数组演示排序的过程。
排序的过程如下图的流程:
在拆和归并的过程中,一定是左边(索引小的)元素先完成拆到归并为有序的过程,当左边完成排序后,才进行右边(索引大的)元素的拆和归并的过程。对一个length=8的数组进行归并排序,过程就如图片中红色数字的顺序一般执行。
5.1.4 自顶向下归并排序的时间复杂度分析:
这里我用一个树来分析归并排序比较的次数。
对于一个length=N的数组,树的深度n=logN(底数为2)。
比较的次数:
对于一个2叉树,第k层有2^k个子数组, 每个子数组的长度为2^(n-k)。每个子数组归并比较的次数范围为 [(2^(n-k))/2, 2^(n-k)-1]。 每层有2^k个子数组,总共有n层, 所以比较的次数的范围为
n2^k [ (2^(n-k))/2, 2^(n-k)-1], (这里为了便于计算,将2^(n-k)-1去掉了减1),n=lgN,替换得范围为[(N/2)lgN, NlgN]。
复制数组的次数:
每执行一次归并会复制两次数组,首先计算归并的次数,树的第1层会归并1(2^0)次, 树的第2层会归并2(2^(2-1))次, 树的第3层会归并4(2^(3-1))次,… 树的第n层会归并2^(n-1)次, 所以归并的次数总和为等比数列求和,a1*(1-q^n)/(1-q)。 代入数值得1*(1-2^n)/(1-2) = 2^n-1=N-1,所以复制数组的次数为2N-2。
所以由比较的次数和复制数组的次数得出:
1).最坏时间复杂度:T(N)=O(NlgN)。
2).最优时间复杂度:T(N)=O(NlgN)。
3).最优时间复杂度:T(N)=O(NlgN)。
5.1.5 空间复杂度
S(N)=O(N)。
5.2 自底向上的归并排序
自底向上的归并排序比较适合用链表组织的数据,想象一下将链表先按大小为1的子链表进行排序,然后是大小为2的子链表,然后是大小为4的子链表等。这种方法只需要重新组织链表链接就能将链表原地排序(不需要创建任何新的链表结点)
5.2.1 算法步骤:
1.申请一个辅助数组(将辅助数组声明为成员变量),长度和原有数组一样为N。
2.首先我们进行两两归并,即a[0]和a[1]进行归并,a[2]和a[3]进行归并,…a[N-2]和a[N-1]进行归并。然后我们进行四四归并,八八归并,直到将整个数组归并排序完毕。
5.2.2 代码片段:
//自底向上的归并排序
public static void sort(Comparable[] a){
int N = a.length;
aux = new Comparable[N];
for (int sz = 1; sz < N; sz=sz+sz) {//sz子数组的大小
for (int lo = 0; lo < N - sz; lo += sz+sz) {//lo:子数组索引
merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));//同自顶向下归并中的merge方法
}
}
}
这次我用一个length=8的数组演示排序的过程。
排序的过程如下图的流程:
比较的次数:
和自顶向下的范围一样[N/2lgN,NlgN]。
复制数组的次数:
和自顶向下的范围一样为2N-2。
所以由比较的次数和复制数组的次数得出:
5.2.3 自底向上归并排序的时间复杂度分析:
1).最坏时间复杂度:T(N)=O(NlgN)。
2).最优时间复杂度:T(N)=O(NlgN)。
3).最优时间复杂度:T(N)=O(NlgN)。
5.2.4 空间复杂度
S(N)=O(N)。
6. 快速排序 - 分治的排序算法
快速排序是一种分治的排序算法,它将一个数组分成两个子数组,将两部分独立地排序。快速排序将数组排序的方式是当两个子数组都有序时整个数组也就自然有序了。在快速排序中,切分的位置取决于数组的内容。
该方法的关键在于切分,这个过程使得数组满足下面三个条件:
1). 对于某个j,a[j]已经排定;
2). a[lo]到a[j-1]中的所有元素都不大于a[j];
3). a[j+1]到a[hi]中的所有元素都不小于a[j]。
6.1 算法步骤
- 先随意地取a[lo]作为切分元素,即那个将会被排定的元素。
- 然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素, 再从数组的右端开始向左扫描直到找到一个小于等于它的元素,交换这两个元素的位置。
- 重复步骤2,可以保证左指针i的左侧元素都不大于切分元素,右指针j的右侧元素都不小于切分元素。
- 当左右指针相遇时,我们只需要将切分元素a[lo]和左子数组最右侧的元素a[j]交换然后返回j,j位置的元素已经排好序。
- 递归将a[j]左右侧的元素排好位置。
6.2 代码片段
//快速排序切分
private static int partition(Comparable[] a, int lo, int hi){
int i = lo, j = hi + 1;
Comparable v = a[lo];
while(true){
//扫描左右,检查扫描是否结束并交换元素
while(less(a[++i],v)) if(i==hi) break;
while(less(v,a[--j])) if(j==lo) break;
if(i >= j) break;
exch(a, i , j);
}
exch(a, lo, j);
return j;
}
//快速排序
private static void sort(Comparable[] a, int lo, int hi){
if(hi <= lo){
return;
}
int j = partition(a, lo, hi);
sort(a, lo, j-1);
sort(a, j+1, hi);
}
public static void sort(Comparable[] a){
sort(a, 0, a.length-1);
}
6.3 代码分析
我仍然用一个length=8的数组演示排序的过程。
排序的过程如下图的流程:
6.4 快速排序的时间复杂度分析
理想的情况是,每次划分所选择的中间数恰好将当前序列几乎等分,经过log2n趟划分,便可得到长度为1的子表。这样,整个算法的时间复杂度为O(nlog2n)。
最坏的情况是,每次所选的中间数是当前序列中的最大或最小元素,这使得每次划分所得的子表中一个为空表,另一子表的长度为原表的长度-1。这样,长度为n的数据表的快速排序需要经过n趟划分,使得整个排序算法的时间复杂度为O(n2)。
为改善最坏情况下的时间性能,可采用其他方法选取中间数。通常采用“三者值取中”方法,即比较H->r[low].key、H->r[high].key与H->r[(10w+high)/2].key,取三者中关键字为中值的元素为中间数。
可以证明,快速排序的平均时间复杂度也是O(nlog2n)。
所以:
1).最坏时间复杂度:T(n)=O(n^2)。
2).最优时间复杂度:T(n)=O(nlgn)。
3).平均时间复杂度:T(n)=O(nlgn)。
6.4 快速排序的空间复杂度分析
就地快速排序使用的空间是O(1)的,也就是个常数级;而真正消耗空间的就是递归调用了,因为每次递归就要保持一些数据;
最优的情况下空间复杂度为:O(logn) ;每一次都平分数组的情况
最差的情况下空间复杂度为:O( n ) ;退化为冒泡排序的情况