排序算法

[TOC]

前言

这是《Java数据结构与算法》一书中关于排序算法部分的读书笔记。

最近想看看算法方面的东西,便先整理下以前的笔记,发现关于排序算法这块以前的笔记太潦草,这几日重新看书修整一番,现在应该可以看入眼了,如果还是写得不好,那是体育老师的锅。

不变性:在很多算法中,有些条件在算法执行时是不变的,这些条件被称为不变性。如冒泡中,out右边的所有数据项为有序,在算法的整个运行过程中这个条件始终为真。(一开始,out右边数为0)

初级

冒泡排序

执行非常慢,概念上最简单。

int out,in;
for(out = size- 1;out > 1;out--){    //outer loop(backward)
     for(in = 0;in < out;in++){           //inter loop(forward)
        if(a[in] > a[in + 1]){          //out of order?
            swap(in,in + 1);             //swap them
        }
   }
}

在内部循环中就是一个冒泡,从头到尾,比较左右两个,如果逆序就交换位置,最大的那一项会一直被交换,直到排最后。1,2比较,(交换),2,3比较,3和4比较,4和5比较。这里的数字指的是位置,假如2是5个数据中最大的一个,1和2比较,不变,2和3比较,二者交换位置,最大值到了3位置,3和4比较,最大值到了4位置,再到5位置。冒泡,冒上来。
在外层循环中,是给内层循环设置数据比较的最后一项,因为下标大于out的数据项都已经排好序了,是最大的放后面,不需要再比较了。变量out在每完成一个内层循环后,就左移一位。

冒泡的效率: 比较的次数,(N-1) + (N-2) + (N-3) + ...+ 1= N*(N-1)/2,交换的次数取平均NxN/4,这个算法0(N^2)

选择排序

我觉得名字起得不好,体现不出这个算法的思想(主要是冒泡算法太形象了)。
思想:比较所有的数据项,取出最小值,放左边。比较剩下的数据,取最小,放最左。。。。
内层循环中,每一个in的新位置,数据项a[in]和a[min]比较,如果a[in]更小,则min被赋值为in的值,这里只是下标,没交换。到一次内层循环结束,再交换数据项。这样,最小数据项就会一直被放在左边。

比较的次数和冒泡是一样的,但是交换的次数小。因为交换数据需要在内存中移动,时间上要多(java语言中,影响不大,只是改变引用位置而已)

int out,in,min;
for(out = 0;out<nElems - 1;out++){           //outer loop
   min = out;                                //minimum
   for(in = out + 1;in < nElems;in++){       //inner loop
   if(a[in] < a[min]){                  //if min greater
     min = in;                        //we have a new min
   } 
   swap(out,min);                       //swap them
   }
}

插入排序

思想:假设左边部分已经排序好了,从某个位置(比如10)开始无序,将10赋值给一临时值,然后和前面的数据比较,如果9位置比10大,就9右移一位,继续和8比较。。。直到到数据的最左边或找到比10位置数据小的某数据,放在它的右边,10位置的数据就排好了。
在大多数情况下,插入算法仍然需要0(N^2)的时间,但比冒泡快一倍,比选择排序也还要快一点。经常被用在较复杂的排序算法的最后阶段,例如快速排序。

int in,out;
for(out = 1;out < nElems; out++){    //out is dividing line
   long temp = a[out];               //remove marked item
   in = out;                         //start shifts at out
   while(in > 0 && a[in - 1] >= temp){   //until one is smaller,
     a[in] = a[in -1]                //shift item right
     --in;
   }
   a[in] = temp;                   //insert marked item
}

外层的for循环中,out变量从1开始,向右移动,它标记了未排序部分的最左端的数据。
而在内层while循环中,in变量从out变量开始,向左移动,知道temp变量小于in所指的数组数据项,或者它已经不能再往左移动为止。while循环的每一次都向右移动了一个排序的数据项。

在每次结束时,在将temp位置的项插入后,比outer变量下标号小的数据项都是局部有序的。
比较次数,1+2+3+...+(N-1) = Nx(N-1)/2,而因为每一次排序发现插入点之前,平均只有全体数据项的一半真的进行了比较,所以是N*(N-1)/2 /2
复制的次数大致等于比较的次数。复制和交换的时间耗费不同。相对于随机数据,这个算法比冒泡快一倍,比选择排序略快。
对于已经有序或基本有序的数据来说,插入排序要好得多。对于逆序排列的数据,每次比较和移动都会执行,所以插入排序不比冒泡排序快。

中级

归并排序

只要O(N * logN),而冒泡排序,选择排序,插入排序都要用O(N * N);.
归并排序的思想是:将一个数组分成两半,然后分别排序,然后将数组的两半合并,合并的时候需要比较大小(合并的时候还要考虑两小数组还有没有数据,即有可能一边有,另一边没有)。至于如何排序1/2半的数组,当然是再分成两个1/4数组,再排序。。。直到分割的小数组只有1个数据项,不用排序了。这是用到递归的思想
归并排序的缺点,需要在存储器中有另一个大小等于被排序的数据项数目的空间,用来复制分割出来的小数组。
归并算法的效率由来:需要复制log2N层(分子数组),每一个层都是N个数据,所以是NxlogN.

int[] workSpace = new int[source.length];
recMergerSort(source,workSpace,0,length-1);

private static void recMergeSort(int[] source ,int[] workSpace,int lowerBound, int upperBound){

    if (lowerBound == upperBound){
       return; // if range is 1,no use sorting
    } else {
       int mid = (lowerBound + upperBound) / 2; //find midpoint
       recMergeSort(source,workSpace,lowerBound,mid); // sort low half
       recMergeSort(source,workSpace,mid + 1,upperBound); // sort high half
       merge(source,workSpace,lowerBound,mid+1 ,upperBound); //merge them
    }
}

private static void merge(int[] source,int[] workPlace, int lowPtr, int highPtr, int upperBound) {

   int j = 0; // workspace index
   int lowerBound = lowPtr;
   int mid = highPtr - 1;
   int n = upperBound - lowerBound + 1; // size of items
   while (lowPtr <= mid && highPtr <= upperBound){
      if (source[lowPtr] < source[highPtr]){
          workPlace[j++] = source[lowPtr++];
      } else {
          workPlace[j++] = source[highPtr++]
      }
   }

   while (lowPtr <= mid){
       workPlace[j++] = source[lowPtr++];
   }

   while (highPtr <= upperBound){
       workPlace[j++] = source[highPtr++];
   }

   for (j = 0;j < n; j++) {
      source[lowerBound + j] = workPlace[j];
   }

} 

高级

希尔排序,快速排序
希尔排序大约需要O(Nx(logN)^2)时间,快速排序需要O(N*logN)时间。
而这两种算法都不需要大量的辅助存储空间,不同于归并算法。
快速排序是所有通用排序算法中最快的一个排序算法。

希尔排序
希尔排序基于插入排序,但增加了一个新的特性,大大地提高了插入排序的执行效率。(希尔是个人名。。。)
改进的地方:插入算法中,如果一个数据比较小而居于最右边,那么它需要一个一个地移动所有中间的数据项,效率比较低。
希尔排序通过加入插入排序中元素之间的间隔,并在这些有间隔的元素中进行插入排序,从而使数据项能大跨度地移动。当这些数据项排过一趟序后,减小数据项的间隔。再进行排序,依次进行下去。间隔被称为增量,用h表示.
进行过几次增量排序后,所有的元素离它再最终有序序列中的位置相差不大,数组达到"基本有序",这时再来插入排序,移动量非常小。

当h值很大的时候,数据项每一趟排序需要移动元素的个数很少,但数据项移动的距离很长,这是非常有效率的。当h减少时,每一趟排序需要移动的元素的个数增多,但是此时数据项已经接近于它们排序后最终的位置,这对于插入排序可以更有效率。

其中,h = h x 3 +1, h = (h -1) / 3,是经验值。
java代码实现:

class ArraySh{

    private long[] theArray;
    private int nElems;

    public ArraySh(int max){
         theArray = new long[max];
         nElems = 0;
    }

    public void insert(long value){
        theArray[nElems] = value;
        nElems++;
    }

    public void shellSort(){
       int inner,outer;
       long temp;
       int h = 1;

       while(h <= nElems / 3){
           h = h * 3 + 1;
       }

       while(h > 0){
          for(outer=h;outer<nElems;outer++){
              temp = theArray[outer];
              inner = outer;

              while(inner > h - 1 && theArray[inner - h] >= temp){
                  theArray[inner] = theArray[inner - h];
                  inner -= h;
              }
               theArray[inner] = temp;
          }
          h = (h-1)/3;
       }
    }

}

划分算法

划分是快速排序的根本机制,但是划分本身也是一个有用的操作,这里单讲划分的算法。
划分数据就是把数据分为两组,使所有关键字大于特定值的数据项在一组,使所有关键字小于特定值的数据项在另一组。

划分算法由两个指针开始工作,两个指针分别指向数组的两头。在左边的指针,leftPtr,向右移动,而在右边的指针,rightPtr,向左移动。
实际上,leftPtr初始化时是在第一个数据项的左边一位(-1),rightPtr是在最后一个数据项的右边一位(size),这是因为在工作之前,它们都要分别的加一和减一。
停止和交换:当leftPtr遇到比枢纽(特定值,划分值)小的数据项时,它继续右移,因为这个数据项的位置已经处在数组的正确一边了。但是,当遇到比枢纽大的数据项时,它就停下来。类似的,当rightPtr遇到小于枢纽的数据项时,它也停下来。两个内层的while循环,控制这个扫面过程,当两个都停下来时,要么指针到头要么遇到错误的数据(大小比较不对),做交换(更换位置,正确排列了)。
当两个指针最终相遇的时候,划分过程结束,并且推出这个外层的while循环。

java代码实现:

class ArrayPar{
    private long[] theArray;
    private int nElems;

    public ArrayPar(int max){
         theArray = new long[max];
         nElems = 0;
    }

    public void insert(long value){
         theArray[nElems] = value;
         nElems++;
    }

    public int partitionIt(int left,int right,long pivot){
         int leftPtr = left - 1;
         int rightPtr = right + 1;

         while(true){
//这里需要检查边界,povit是外界设定,对效率影响很大,在快速排序中有更巧妙的设定方法
             while(leftPtr < right && theArray[++leftPtr] < povit);   //find bigger item
             while(rightPtr > left && theArray[--rightPtr] > povit);  //find smaller item
             if(leftPtr >= rightPtr){      //if pointers cross, partition done
                 break;
             } else {
                 swap(leftPtr,rightPtr);
             }
         } 
          return leftPtr;
    }

}

精巧的代码分析
这个while循环中的代码相当精巧。举例来说,想要从内部循环条件中除去加1操作符,并且用这个加1操作符代替空操作指令语句(空操作指令指只包括一个分号的语句,它表示不做任何操作)。
可以把如下代码

while(leftPtr < right && theArray[++leftPtr] < pivot);

改为

while(leftPtr < right && theArray[leftPtr] < pivot) { 
    ++leftPtr 
};

这些改变使指针的初始值分别设为left,right,比设为left-1,right+1要更为清晰。
但是,这些改变导致只有在满足条件的情况下指针才会加1.而指针在任何情况下都必须移动,所以空操作指令是最有效的解决办法。

快速排序

最流行的排序算法,时间复杂度为O(N*logN)。虽然不觉得这种行为好,但有的公司喜欢笔试时让人手写快排(一些开发者如是说)。
原理是:把一个数组划分为两个子数组,这里用到划分算法,左子数组的数据项都小于右子数组的数据项,然后递归地调用自身为每个子数组进行快速排序来实现,最后使用插入排序。

在这个算法中划分的关键值(枢纽)的选择非常重要。
最初思想,选用数组最右边的值为pivot,进行一次划分,划分的结果就是left->mid-1, mid->right-1, right(这个位置的值是pivot),三部分,然后交换mid和right的值(划分算法的leftPtr在停止时会停在mid位置),这样pivot就到中间,而小于pivot的值全在左边,大于的值全在右边,数组的排序不受影响。
下面的排序从left到pivot-1,pivot+到right。中间项不参与划分。

最初思想的java代码实现:

class ArrayIns{
    private long[] theArray;
    private int nElems; // elements number, or size

    public ArrayIns(int max){
         theArray = new long[max];
         nElems = 0;
    }

    public void insert(long value){
         theArray[nElems] = value;
         nElems++;
    }

    public void quickSort(){
         recQuickSort(0,nElems - 1);
    }

    private void recQuickSort(int left,int right){

         if(right - left <= 0) {          // if size <= 1, already sorted
              return;
         } else {
              long pivot = theArray[right];     // rightmost item
              int partition = partitionIt(left,right,pivot);
             recQuickSort(left,partition - 1);    //sort left side
             recQuickSort(partition+1,right);     //sort right side
         }
    } // end recQuickSort()

    private int partitionIt(int left,int right,long pivot){
       int leftPtr = left - 1;
       int rightPtr = right;    //这里设定最右为pivot,所以从right-1开始划分,下面代码会-1

       while(true){
             while(theArray[++leftPtr] < pivot);
             while(rightPtr > 0 && theArray[--rightPtr] > pivot);
             if(leftPtr >= rightPtr){
                 break;
             } else { 
                 swap(leftPtr,rightPtr);
             }
       } // end while(true)

       swap(leftPtr,right);    //restore pivot  当0->right-1划分好了之后,交换right和leftPtr的位置,将pivot移动到中间,
       return leftPtr;         //return pivot location
    }

}

最初思想中,使用最右为pivot,如果数据本身有序,那么pivot会是最小or最大(数组逆序or正序),不能起划分作用,效率低下。
(为什么不扫描要排序的全部数据,取中间值,因为这个做法比排序本身还要费时间,不可行)
进一步优化:"三数据项取中(median-of-three)",找数组里第一个,最后一个,中间位置数据项的居中数据项值。
三数据取中的一个好处:可以在第二个内部while循环中取消rightPtr>left的判断(left是数组的最左,避免rightPtr跑出数组),因为三数据取中时,也对三个数据项进行了排序,已经有序。
还有一个好处:对左端,右端中间的数据项排序之后,划分过程就不需要再考虑这三个数据项了。划分可以从left+1,right-1开始。
再一步提升,使用三数据项取中划分方法,则必须要遵循快速排序算法不能执行三个或者少于三个数据项的划分的规则。数字3被称为切割点。

本例子中处理小划分的方法是manualSort(),代码很简单,只是排序3个数据项或以下的数据。
处理小划分的另一个选择是使用插入排序,数量小插入排序效率也很高。也可以把切割点设定为别的数,推荐使用9.最好的选择取决于计算机,操作系统...等。替换掉recQuickSort()中的if(size <= 3) manualSort(),改用if(size <= 9) insertSort();

class ArrayIns{
    private long[] theArray;
    private int nElems;

    private ArrayIns(int max){
         theArray = new long[max];
         nElems = 0;
    }

    public void insert(long value){
         theArray[nElems] = value;
         nElems++;
    }

    public void quickSort(){
         recQuickSort(0,nElems-1);
    }

    private void recQuickSort(int left,int right){
         int size = right - left + 1;
         if(size <= 3) {
             manualSort(left,right);
         } else {
             long median = median0f3(left,right);
             int partition = partitionIt(left,right,median);
             recQuickSort(left,partition - 1);
             recQuickSort(partition + 1,right);
         }
    }

    private long medianOf3(int left,int right){
         int center = (left + right) / 2;
         if(theArray[left] > theArray[center])  swap(left,center);
         if(theArray[left] > theArray[right])  swap(left,right);
         if(theArray[center] > theArray[right]) swap(center,right);

         swap(center,right - 1);   // put pivot on right
         return theArray[right -1];
    }

    private int partitionIt(int left,int right,long pivot){
         int leftPtr = left;         //right of first elem
         int rightPtr = right -1;    //left of pivot
         while(true){
              while(theArray[++leftPtr] < pivot);
              while(theArray[--rightPtr] > pivot);
              if(leftPtr >= rightPtr ){
                   break;
              } else {
                 swap(leftPtr,rightPtr);
              }
          }
          swap(leftPtr,right-1);
          return leftPtr;
    }

    private void manualSort(int left,int right){
 int size = right - left + 1;
 if(size <= 1) return;
 if(size == 2) {
    if(theArray[left] > theArray[right]) swap(left,right);return;
 } else {   //size is 3
    if(theArray[left] > theArray[right -1]) swap(left,right-1);
    if(theArray[left] > theArray[right])  swap(left,right);
    if(theArray[right-1]> theArray[right])  swap(right-1,right);

 }

    }

}

也可以选择,对数组整个使用快速排序,不去考虑小于界限的划分的排序。当快速排序结束时,数组已经是基本有序了,然后对整个数组应用插入排序,也是非常快,很多专家推荐这种方法。
如,把界限设为10,那么快速排序到10就结束,每一块10个数据里都是无序的,但每一块之间都有序了。再插入排序。

还有一个提升,消除递归。但这是以前计算机性能不好时好用,现在的提升已经不明显了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容

  • 概述:排序有内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部...
    每天刷两次牙阅读 3,726评论 0 15
  • Ba la la la ~ 读者朋友们,你们好啊,又到了冷锋时间,话不多说,发车! 1.冒泡排序(Bub...
    王饱饱阅读 1,787评论 0 7
  • 概述 排序有内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部...
    蚁前阅读 5,164评论 0 52
  • 文/画:拾穗 七夕 何良宵 直叫天下有情人为之疯狂 玫瑰娃娃唯恐天下不乱 满天飞舞 难怪牛郎织女鹊桥难相会 如此美...
    拾穗的时光阅读 181评论 2 4
  • 在古龙笔下的江湖里,有个温暖的人,叫花满楼。 花满楼是个瞎子,景致再美,不过无边黑夜。 世人看来,目不能视物,本该...
    小火乐阅读 165评论 0 1