zoukankan      html  css  js  c++  java
  • 经典十大排序算法

    前言

    排序种类繁多,大致可以分为两大类:

    • 比较类排序:属于非线性时间排序,时间复杂度不能突破下界 (O(nlogn));

    • 非比较类排序:能达到线性时间 (O(n)),不是通过比较来排序,有基数排序、计数排序、桶排序。

    了解一个概念:排序的稳定性

    稳定是指相同大小的元素多次排序能保证其先后顺序保持不变。假设有一些学生的信息,我们先根据他们的姓名进行排序,然后我们还想根据班级再进行排序,如果这时使用的时不稳定的排序算法,那么第一次的排序结果可能会被打乱,这样的场景需要使用稳定的算法。

    堆排序、快速排序、希尔排序、选择排序是不稳定的排序算法,而冒泡排序、插入排序、归并排序、基数排序是稳定的排序算法。

    1、冒泡排序

    大多数人学编程接触的第一种排序,名称很形象。每次遍历排出一个最大的元素,将一个最大的气泡冒出水面。

    • 时间复杂度:平均:(O(n^2));最好:(O(n));最坏:(O(n^2))
    • 空间复杂度:(O(1))
    public static void bubbleSort(int[] arr) {
        /**
         * 总共走len-1趟即可,每趟排出一个最大值放在最后
         */
        for (int i = 0; i < arr.length - 1; i++) {
            for (int j = 0; j < arr.length - i - 1; j++) {
                if (arr[j] > arr[j + 1]) {
                    int tp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = tp;
                }
            }
        }
    }
    

    2、选择排序

    最直观易理解的排序算法,每次排出一个最小的元素。也是最稳定的算法,时间复杂度稳定为O(n^2)。需要一个变量记录每次遍历最小元素的位置。

    • 时间复杂度:(O(n^2))
    • 空间复杂度:(O(1))
    public static void selectSort(int[] arr){
        int n = arr.length;
        for (int i = 0; i < n; i++) {
            int maxIdx = 0;
            for(int j = 1; j < n - i; j++){
                if(arr[maxIdx] < arr[j]){
                    maxIdx = j;
                }
            }
            int tp = arr[maxIdx];
            arr[maxIdx] = arr[n - 1 - i];
            arr[n - 1 - i] = tp;
        }
    }
    

    3、插入排序

    一种直观的排序算法,从第二个元素开始,每次往前面遍历找到自己该在的位置。

    • 时间复杂度:平均:(O(n^2));最好:(O(n));最坏:(O(n^2))
    • 空间复杂度:(O(1))
    public static void insertSort(int[] arr){
        for (int i = 1; i < arr.length; i++) {
            for(int j = i; j > 0 && arr[j] < arr[j-1]; j--){
                int tp = arr[j];
                arr[j] = arr[j-1];
                arr[j-1] = tp;
            }
        }
    }
    

    4、希尔排序

    是第一个突破O(n^2)复杂度的算法,是插入排序改进版,又称缩小增量排序。选择一个增量g,每次将数据按g分隔分成g组,每次对组内进行插入排序。每组排完后,将g/2继续直到g=1。一般选择g=len/2,但这不是最优选择,还有很多增量选择方案。

    • 时间复杂度:基于不同增量选择--O(n1.3~n2)
    • 空间复杂度:O(1)
    public static void shellSort(int[] arr){
        for(int g = arr.length/2; g > 0; g /= 2){
            for(int i = g; i < arr.length; i++){
                for(int j = i; j - g >= 0 && arr[j] < arr[j - g]; j -= g){
                    int tp = arr[j];
                    arr[j] = arr[j-g];
                    arr[j-g] = tp;
                }
            }
        }
    }
    

    5、快速排序

    是冒泡排序的一种改进算法。

    基本思想是:

    1. 在数据集之中,选择一个元素作为"基准"(pivot)。
    2. 所有小于"基准"的元素,都移到"基准"的左边;所有大于"基准"的元素,都移到"基准"的右边。
    3. 对"基准"左边和右边的两个子集,不断重复第一步和第二步,直到所有子集只剩下一个元素为止。

    经典的快排选择第一个数为基准。当排的是一个近乎有序的序列时,经典快排会退化为(O(n^2)),这时采用随机快速排序则比较恰当,随机快排是在排序的数中随机选择一个做基准。快排可以做到原地实现。

    • 时间复杂度:平均(理想):(O(nlogn));最坏:(O(n^2));

      每一层递归都需要比较n次,如果理想情况都是五五开,那么递归深度就是(logn),如果最坏的情况递归深度就是n。

    • 空间复杂度:每次快排需要一个额外的空间,因此空间复杂度=递归深度=(O(logn))或者(O(n)).

    • 实现:

      形象点叫-填坑法,这个实现是《算法》里提到的,我觉得最简洁。

      public static void quick(int a[], int lo, int hi){
          if( lo >= hi)   return;
      
          int x = a[lo];
          int i = lo, j = hi;
      
          while (i < j){
              while (i < j && a[j] > x){
                  j--;
              }
              if(i < j){
                  a[i++] = a[j];
              }
              while (i < j && a[i] < x){
                  i++;
              }
              if(i < j){
                  a[j--] = a[i];
              }
          }
          a[i] = x;
          quick(a, lo, i-1);
          quick1(a, i+1, hi);
      }
      
    • 多路快排:

      当序列中有大量重复元素时,原算法也会退化,这时需要双路快速排序,甚至是三路快排,三路快排在重复元素时表现出很好的性能,在普通的,非大量重复时,速度略慢于普通快排,不过在可接受范围,因此一般使用三路快排。

    6、归并排序

    归并的思想是先使每个子序列有序,再使得子序列合并后有序。与快排一样,都基于分治思想,不过快排在于分,而归并在于合。

    是一个稳定的算法。一般归并的比较次数少于快排,而移动次数多于快排。(所以这是计算机中多用快排的原因,移动数据的代价更高)。对于本身具有一定有序性的序列,可以采用改进的归并,最好的情况能将复杂度降低至O(n).

    归并还有一个用途:用于求逆序数。

    • 时间复杂度:一直是(O(nlogn))
    • 空间复杂度:(O(n)),需要一个一样大的辅助数组

    《算法》上看到的写法,非常优雅:

    static void sort(int[] arr, int lo, int hi, int[] temp){
        if(lo >= hi)  return;
        int mid = lo + (hi - lo)/2;
        // 小技巧,递归时将数组反过来复制,temp->arr,这样不用每次都将排好序的temp复制回arr
        sort(temp, lo, mid, arr);
        sort(temp, mid + 1, hi, arr);
        merge(arr, lo, mid, hi, temp);
    }
    
    static void merge(int[] arr, int lo, int mid, int hi, int[] temp) {
        int i = lo, j = mid + 1;
        for (int k = lo; k <= hi; k++) {
            if (i > mid)             temp[k] = arr[j++];
            else if(j > hi)          temp[k] = arr[i++];
            else if(arr[i] > arr[j]) temp[k] = arr[j++];
            else                     temp[k] = arr[i++];
        }
    }
    

    7、堆排序

    这里特指二叉堆,二叉堆是一种完全二叉树,并且所有结点的值大于(小于)等于子节点的值,分别称为大(小)顶堆。下面以大顶堆为例讲述。

    image-20210916170808527

    首先我们要知道,完全二叉树和数组可以相互映射,通过下标可以找到节点的子节点,节点i的左子节点为:2i+1;右子节点为:2i+2。通常我们排序的是数组,就将其映射成完全二叉树来实现。

    堆排序就是给定了一个无序的序列,先将其构造成一个大顶堆,然后将大顶堆的堆顶元素与最后一个元素交换,那么第一个数就排好了,然后逐个排序

    • 建堆过程

      常见的建堆有两种方法,一种是应用于堆元素已经确定了的,另外一种是针对元素动态增加的。

      对于元素已经确定的我们通常做法是这样的:

      a. 先找到这颗完全二叉树的最后一个非叶子节点的位置 k:(size/2 - 1);如上图就是 9/2-1=3.

      b. 以这个节点作为根节点构造一个二叉堆,找出该节点和其子节点中最大的节点,并将堆顶交换为最大的那个节点。

      c. 然后前移找前一个非叶子节点,也重复步骤b。直到整个树都被构造成二叉堆,堆就建好了。

      public static void buildHeap(int[] arr){
          for (int i = arr.length / 2 - 1; i >= 0; i--) {
              adjustHeap(arr, i, arr.length);
          }
      }
      

      关键是调整代码:

      // 截取原来的二叉树,调整以 i 为根节点的堆
      private static void adjustHeap(int[] arr, int i, int len){
          int left = 2*i + 1, right = 2*i +2;
          int maxIndex = i;
      
          if(left<len && arr[left] > arr[maxIndex]){
              maxIndex = left;
          }
          if(right<len && arr[right] > arr[maxIndex]){
              maxIndex = right;
          }
          if(maxIndex != i){
              swap(arr,maxIndex,i);
              adjustHeap(arr, maxIndex, len);
          }
      }
      
    • 排序

      建好堆后,堆顶就是最大的元素,我们取堆顶元素,将其与堆末尾元素交换,然后我们将原本堆的末尾元素剔除,堆元素减一,此时由于交换使得堆需要再次调整。调整完后堆顶元素即为第二大元素,然后交换到第二末尾的位置,剔除,调整堆...直到堆的大小变为1,排序完成。

    完整代码:

    public static void heapsort(int[] arr) {
        buildHeap(arr);
        for (int i = arr.length - 1; i > 0; i--) {
            swap(arr,0,i);
            adjustHeap(arr, 0, i);
        }
    }
    
    private static void buildHeap(int[] arr){
        // len/2-1 是最后一个非叶子节点的下标
        for (int i = arr.length / 2 - 1; i >= 0; i--) {
            adjustHeap(arr, i, arr.length);
        }
    }
    
    private static void adjustHeap(int[] arr, int i, int len){
        int left = 2*i + 1, right = 2*i +2;
        int maxIndex = i;
    
        if(left<len && arr[left] > arr[maxIndex]){
            maxIndex = left;
        }
        if(right<len && arr[right] > arr[maxIndex]){
            maxIndex = right;
        }
        if(maxIndex != i){
            swap(arr,maxIndex,i);
            adjustHeap(arr, maxIndex, len);
        }
    }
    
    private static void swap(int[] arr, int i, int j){
        int t = arr[i];
        arr[i] = arr[j];
        arr[j] = t;
    }
    
    • 堆排序时间复杂度稳定在O(nlogn),看起来堆排序比快速排序还要好?

      但实际上计算机中排序还是没有采用它,它的实际效率还是低于快速排序。因为排序的时间不仅要考虑实际cpu计算的时间,还要考虑数据读取的时间,计算机中采用的cache机制使得一次不能读取很多数据,数据一般是分段读入cache的,快排进行计算时使用的数据具有良好的局部性(这些数据一般挨得很近),而堆排序由于经常同时操作树头和树尾的数据,这些数据通常隔得比较远,所以需要花很多时间在数据读取上,整体效率还下降了。

    • 时间复杂度:(O(nlogn))

      建堆的复杂度为(O(n)),排序时重建堆的复杂度为(O(nlogn)).

    • 空间复杂度:(O(1))

    8、计数排序

    计数排序是非比较型排序,适用于具有确定范围的序列。计数排序新建了一个k=maxval-minval大小的数组用来统计各个数出现次数。是一个稳定的排序算法,当序列比较集中时,它优于所有的比较排序算法。但当(O(k)>O(n*logn))时,效率反而不如比较型算法。

    • 时间复杂度:(O(n+k))
    • 空间复杂度:(O(k))
    public static void countSort(int[] arr) {
      	// 假设arr[i]都在0-99范围内
        int[] space = new int[100];
        for (int i = 0; i < arr.length; i++) {
            space[arr[i]]++;
        }
        for (int i = 0, j = 0; i < space.length; i++) {
            while (space[i]-- > 0){
                arr[j++] = i;
            }
        }
    }
    

    9、桶排序

    划分多个范围相同的区间,每个子区间自排序,最后合并

    桶排序是计数排序的扩展版本,计数排序可以看成每个桶只存储相同元素。而桶排序每个桶存储一定范围的元素,通过映射函数,将待排序数组中的元素映射到各个对应的桶中,对每个桶中的元素进行排序,最后将非空桶中的元素逐个放入原序列中。

    一般的做法是,找到排序的序列所在区间,平均分成几个区域(即几个桶),然后根据映射关系将数逐一放入桶内,对每个入桶的数据在该桶内还要进行排序(可以自选排序算法),然后依次取出桶中数据得到有序数列。

    当数据服从均匀分布时,该算法线性时间O(n),如果数据很集中,当所有数据集中在同一个桶中时,桶排序就失去了意义。

    桶排序的关键:

    元素值域的划分,也就是元素到桶的映射规则。映射规则需要根据待排序集合的元素分布特性进行选择,若规则设计的过于模糊、宽泛,则可能导致待排序集合中所有元素全部映射到一个桶上,则桶排序向比较性质排序算法演变。若映射规则设计的过于具体、严苛,则可能导致待排序集合中每一个元素值映射到一个桶上,则桶排序向计数排序方式演化。

    • 时间复杂度:

      对于待排序序列大小为 N,共分为 M 个桶,主要步骤有:

      N 次循环,将每个元素装入对应的桶中

      M 次循环,对每个桶中的数据进行排序(平均每个桶有 N/M 个元素)

      假如桶内排序算法时间复杂度为(O(NlogN)),那么总复杂度为:

      (O(N)+O(M*(N/M*log(N/M)))=O(N+N*log(N/M)))

      当M=N时,复杂度则为O(N),完全退化为计数排序。

    • 空间复杂度:(O(N+M))

    public static void bucketSort(int[] arr) {
      
        int max = Integer.MIN_VALUE,
            min = Integer.MAX_VALUE;
        for (int i : arr) {
            max = Math.max(max,i);
            min = Math.min(min,i);
        }
    
        int bucketNum = (max-min)/arr.length + 1;
        List<List<Integer>> bucketArr = new ArrayList<>(bucketNum);
        for (int i = 0; i < bucketNum; i++) {
            bucketArr.add(new ArrayList<>());
        }
    
        for (int i = 0; i < arr.length; i++) {
            int index = (arr[i] - min) / arr.length;
            bucketArr.get(index).add(arr[i]);
        }
    
        for (int i = 0; i < bucketArr.size(); i++) {
            Collections.sort(bucketArr.get(i));
        }
    
        for (int i = 0, j = 0; i < bucketArr.size(); i++) {
            for (int i1 = 0; i1 < bucketArr.get(i).size(); i1++) {
                arr[j++] = bucketArr.get(i).get(i1);
            }
        }
    
    }
    

    10、基数排序

    基数排序是逐位进行排序的算法。先按照低位先排序,排好后收集,再逐次升高位数进行排序,收集,直到最高位,收集后即为有序序列。相当于用十个桶进行排序,又称为桶子法。其排序效率取决于最大数的位数d,每位数字的分布范围k(0-9),一般k=10。

    • 时间复杂度:(O(d*(n+k)))
    • 空间复杂度:取决于桶的数量 (O(k+n))
    public static void baseSort(int[] arr) {
        // 取得最大数的位数
        int digits = 3;
        List<List<Integer>> tong = new ArrayList<>(10);
        for (int i = 0; i < 10; i++) {
            tong.add(new ArrayList<>());
        }
    
        int mod = 10, dev = 1;
        for (int i = 0; i < digits; i++, dev *= 10, mod *= 10) {
            for (int j = 0; j < arr.length; j++) {
                int index = (arr[j] % mod) / dev;
                tong.get(index).add(arr[j]);
            }
            for (int j = 0, index = 0; j < tong.size(); j++) {
                while (!tong.get(j).isEmpty()){
                    arr[index++] = tong.get(j).get(0);
                    tong.get(j).remove(0);
                }
            }
        }
    }
    

    外部排序

    前述排序都是指直接在内存中可以完成的,即内部排序。外部排序是指数据量很大,需要多次从磁盘读入的情况。

    外部排序一般使用归并算法,为了提高效率,并且通常是多路归并。

  • 相关阅读:
    【Codeforces 349B】Color the Fence
    【Codeforces 459D】Pashmak and Parmida's problem
    【Codeforces 467C】George and Job
    【Codeforces 161D】Distance in Tree
    【Codeforces 522A】Reposts
    【Codeforces 225C】Barcode
    【Codeforces 446A】DZY Loves Sequences
    【Codeforces 429B】Working out
    【Codeforces 478C】Table Decorations
    【Codeforces 478C】Table Decorations
  • 原文地址:https://www.cnblogs.com/cpcpp/p/14529045.html
Copyright © 2011-2022 走看看