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);
                }
            }
        }
    }
    

    外部排序

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

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

  • 相关阅读:
    css文档流
    gitolite搭建
    Packets out of order. Expected 1 received 27...
    前端常见跨域解决方案
    跨时代的分布式数据库 – 阿里云DRDS详解
    Redis持久化机制
    redis实现消息队列
    队列
    ide-helper
    Bitmap 位操作相关
  • 原文地址:https://www.cnblogs.com/cpcpp/p/14529045.html
Copyright © 2011-2022 走看看