数据结构与算法笔记2

本笔记记录王争专栏数据结构与算法之美的学习记录,以便自己复习回顾,代码部分均已经过验证,可直接使用

排序算法

大部分编程语言,都会提供排序函数,平常项目,也会经常使用排序

最经典,最常用的排序:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序和桶排序

排序算法 时间复杂度 是否基于比较
冒泡、插入、选择 O(n²)
快排、归并 O(nlogn)
桶、计数、基数 O(n)

思考题:插入排序和冒泡排序的时间复杂度相同,都是O(n2),在实际的软件开发里,为什么我们更倾向于使用插入排序算法而不是冒泡排序算法呢?


1. 如何分析一个“排序算法”?

学习排序算法,我们除了学习它的算法原理、代码实现之外,更重要的是要学会如何评价、分析一个排序算法。分析一个排序算法,要从哪几个方面入手呢?

排序算法的执行效率

一般会从这几个方面来衡量:

1. 最好情况、最坏情况、平均情况时间复杂度

要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。

为什么要区分这三种时间复杂度呢?

第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。

第二,对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。

2. 时间复杂度的系数、常数 、低阶

时间复杂度反应的是数据规模n很大的时候的一个增长趋势,实际的软件开发中,我们排序的可能是10个、100个、1000个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。

3. 比较次数和交换(或移动)次数

基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。

排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量

针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是O(1)的排序算法。我们今天讲的三种排序算法,都是原地排序算法。

排序算法的稳定性

针对排序算法,我们还有一个重要的度量指标,稳定性。如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。

  • 为什么要考察排序算法的稳定性呢?

真正软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,我们需要按照对象的某个key来排序。

比如说,我们现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果我们现在有10万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。对于这样一个排序需求,我们怎么来做呢?

最先想到的方法是:我们先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂

借助稳定排序算法,这个问题可以非常简洁地解决。解决思路是这样的:我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。

稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,我们用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。

2 冒泡排序

1. 概念

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。

2. 代码实现

public class BubbleSort {
  // 冒泡排序,a表示数组,n表示数组大小
  public static void bubbleSort(int[] a,int n){
    if(n<=1) return;
    for(int i=0;i<n;i++){
      // 设立退出循环的标志位
      boolean flag = false;
      for(int j=0;j<n-i-1;j++){
        if(a[j]>a[j+1]){
          // 交换
          int temp = a[j];
          a[j] = a[j+1];
          a[j+1] = temp;
          // 标志位
          flag = true;
        }
      }
      if(!flag) break;//没有数据交换,提前退出
    }
  }

  public static void main(String[] args) {
    int[] a = {1,2,7,5,9,3};
    bubbleSort(a,a.length);
    for (int i = 0; i < a.length; i++) {
      System.out.println(a[i]);
    }
  }
}

3. 问题描述

1. 冒泡排序是原地排序算法吗?

只涉及相邻数据的交换操作,只需要长良机的临时空间,空间复杂度为O(1),原地排序

2. 冒泡排序是稳定的排序算法吗?

只有交换才改变两个元素的前后顺序,为保证冒泡算法稳定性,相邻两个元素大小相等时,不做交换,相同大小的数据在排序前后顺序不变,稳定

3. 冒泡排序的时间复杂度

最好情况,有序,只需要进行一次冒泡,就可以结束,时间复杂度为O(n);最坏,倒序排列,n次冒泡,最坏时间复杂度为O(n²)

平均时间复杂度,对于包含n个数据的数组,n个数据有n!种排列方式。不同排列方式,执行时间不同。有一种思路,通过“有序度”和“逆序度”两个概念分析

有序度是数组中具有有序关系的元素对的个数。数学表达式:有序元素对:a[i]<=a[j],如果i<j

对于倒序排列的数组,有序度为0;完全有序的,有序度n*(n-1)/2,完全有序的有序度叫²

逆序度相反,默认从小到大为有序。逆序元素对:a[i]>a[j],如果i<j

逆序度=满有序度-有序度,排序过程就是增加有序度,减少逆序度,最后达到满有序度

冒泡排序包含两个操作原子,比较和交换。每交换一次,有序度加1,交换次数确定,也就是逆序度,为n*(n-1)/2-初始有序度

对于包含n个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏情况,初始有序度0,进行n*(n-1)/2 次交换。最好情况,初始有序度为n*(n-1)/2 ,不需要交换,取中间值n*(n-1)/4 ,表示初始有序度的平均情况

也就是说,平均情况,需要n*(n-1)/4 次交换,比较操作肯定比交换操作多,而复杂度的上限为O(n²),因此平均时间复杂度就是O(n²)

3. 插入排序(Insertion Sort)

一个有序的数组,往里面添加一个新的数据,如何继续保持数据有序呢?只要遍历数组,找到数据应该插入的位置将其插入。

这是个动态过程,动态的往有序集合添加数据,通过该方法保持集合中的数据一直有序。而对于静态数据,也可以借鉴上面的过程。

插入排序具体是如何借助上面的思想实现排序呢?

首先,将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间的元素,在已排序区间找合适的插入位置将其插入,并保证已排序区间数据一直有序。重复该过程,直到未排序区间中元素为空,算法结束。

插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。当我们需要将一个数据a插入到已排序区间,需要拿a与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点后,需要将插入点之后的元素顺序往后移动一位,腾出位置给a插入。

对于不同的查找插入点方法(从头到尾,从尾到头),元素比较次数有区别。但对于一个给定的初始序列,移动操作的次数固定,等于逆序度。

看马士兵老师的视频,非常形象,比喻为打扑克,斗地主,接牌后怎么给手里的牌排序?就是插入排序!

代码实现

public class InsertionSort {
  // 插入排序,a表示数组,n表示数组大小
  public static void insertionSort(int[] a, int n) {
    if (n <= 1) return;

    for (int i = 1; i < n; i++) {
      int value = a[i];
      // 指针,从i-1开始,每次都和后边的值比大小,挪位置,直到确定位置
      int j = i - 1;
      // 查找插入的位置
      for (; j >= 0; j--) {
        if (a[j] > value) {
          a[j + 1] = a[j]; //数据移动
        } else {
          break;
        }
      }
      a[j + 1] = value; // 插入数据
    }
  }

  public static void main(String[] args) {
    int[] a = {1,3,7,9,2,4};
    insertionSort(a,a.length);
    for (int i = 0; i < a.length; i++) {
      System.out.println(a[i]);
    }
  }
}

3. 三个问题描述

1. 插入排序是原地排序吗

插入排序的运行不需要额外的存储空间,空间复杂度为O(1),原地排序

2. 插入排序稳定吗

在插入排序中,对于值相同的元素,可以选择将后面出现的元素,插入到前面出现元素的后边,保持原有的前后顺序不变,稳定。

3. 插入排序的时间复杂度

如果已经有序,从尾到头查找插入位置,每次只需要比较一个数据就能确定插入的位置,最好时间复杂度为O(n),注意:这是从尾到头遍历已经有序的数据。

如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,需要移动大量的数据,最坏时间复杂度为O(n²)

我们在数组中插入一个数据的平均时间复杂度为O(n),对于插入排序,每次插入操作都相当于在数组中插入一个数据,循环执行n次插入操作,平均时间复杂度为O(n²)

4. 选择排序(Selection Sort)

实现思路类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找最小的元素,将其放到已排序区间的末尾。

快排的时间复杂度为O(1),是一种原地排序算法。选择排序的最好时间复杂度、最坏和平均时间复杂度都是O(n²)

选择排序不稳定,每次都要找剩余未排序元素的最小值,并和前面的元素交换位置,破坏了稳定性。

比如5,8,5,2,9,使用选择排序,第一次找到最小元素2,与第一个5交换位置,第一个5和中间的5的顺序就变了,不稳定。

5. 解答开篇

冒泡排序和插入排序的时间复杂度都是O(n²),都是原地排序算法,为什么插入排序比冒泡排序更受欢迎

二者的元素交换的次数不管怎么优化都是固定值,原始数据的逆序度。

但是,从代码实现上,冒泡排序的数据交换比插入排序的数据移动更复杂,冒泡排序需要3个赋值操作,而插入排序只需要1个。

// 冒泡排序
if (a[j] > a[j+1]) { // 交换
   int tmp = a[j];
   a[j] = a[j+1];
   a[j+1] = tmp;
   flag = true;
}
// 插入排序
if (a[j] > value) {
  a[j+1] = a[j];  // 数据移动
} else {
  break;
}

我们把执行一个赋值语句的时间粗略统计为单位时间(unit_time),分别用冒泡排序和插入排序对同一个逆序度为k的数组进行排序。用冒泡排序,需要K次交换,每次需要3个赋值语句,交换总耗时3*K单位时间,而插入排序只需要K个单位时间。

插入排序的优化,希尔排序。

6. 归并排序(Merge Sort)

1. 概念

归并排序的核心思想:如果要排序一个数组,先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并起来,这样整个数组就有序了

归并排序使用的是分治思想。分治,分而治之,将一个大问题分解成若干个小问题,小问题解决,大问题也就解决了。

分治思想一般都是用递归实现。分治是解决问题的处理思想,递归是一种编程技巧。

2. 实现

如何用递归代码实现归并排序?

归并的递推公式
merge_sort(p...r) = merge(merge_sort(p...q),merge_sort(q+1...r))
终止条件
p>=r 不再继续分解

merge_sort(p…r)是给下标从p到r之间的数组排序。将该问题转化为两个子问题,merge_sort(p…q)和merge_sort(q+1…r),其中下标q等于p和r的中间位置,也就是(p+r)/2。当两个子数组都排好序,合并到一起,下标从p到r之间的数据就排好序了。

合并的具体过程:

申请一个临时数组temp,大小和a相同,用两个指针i和j,分别指向a[p…q]和a[q+1…r]的第一个元素,比较两个元素a[i]和a[j],如果a[i]<=a[j],就把a[i]放入到临时数组temp,并且i后移一位,否则将a[j]放入数组temp,j后移一位。

继续上述过程,直到其中一个子数组的所有数据都放入到临时数组,再把另一个数组的数据依次加入临时数组的末尾,最后,将临时数组拷贝到原数组。

public class MergeSort {
  // 归并排序算法,a为数组,n为数组大小
  public  void mergerSort(int[] a,int n){
    merge2sort(a,0,n-1);
  }

  private void merge2sort(int[] a, int p, int r) {
    // 递归终止条件
    if(p>=r) return;

    // 取p到r之间的中间位置q,防止(p+r)的和超过int类型最大值
    int q = p+ (r-p)/2;
    // 分治递归
    merge2sort(a,p,q);
    merge2sort(a,q+1,r);
    // 将a[p...q]和a[q+1...r]合并为a[p...r]
    merge(a,p,q,r);
  }

  // 合并逻辑
  private void merge(int[] a, int p, int q, int r) {
    int i = p;
    int j = q+1;
    int k = 0;//初始化变量i,j,k
    int[] temp = new int[r-p+1];//申请一个大小跟a[p..r]大小一样的临时数组
    while(i<=q && j<=r){
      // 如果a[i]<=a[j] 就把a[i]放入临时数组temp,并且i后移一位,否则将a[j]放入数组temp,j后移一位
      if(a[i]<=a[j]){
        temp[k++] = a[i++];
      }else{
        temp[k++] = a[j++];
      }
    }

    // 判断哪个子数组中有剩余的数据
    int start = i;
    int end = q;
    if(j<=r){
      start = j;
      end = r;
    }

    // 将剩余的数据拷贝到临时数组temp
    while(start <= end){
      temp[k++] = a[start++];
    }

    // 将temp中的数组拷贝回a[p...r]
    for(i =0;i<=r-p;++i){
      a[p+i] = temp[i];
    }
  }

  public static void main(String[] args) {
    int[] a = {1,3,5,7,9,2,6,4};
    new MergeSort().mergerSort(a,a.length);
    for (int i = 0; i < a.length; i++) {
      System.out.println(a[i]);
    }
  }
}

3. 性能分析

1. 是否稳定排序算法

归并排序是否稳定关键看merge()函数,也就是两个有序字数组合并为一个有序数组的那部分代码

合并过程中,如果两个子数组存在值相同的元素,可以先把a[p…q]中的元素放入temp数组,保证值相同的元素,合并前后先后顺序不变,稳定排序

2. 归并排序的时间复杂度

涉及递归,如果问题a可以分解为多个子问题b、c,求解问题a就可以分解为求解b、c,解决后,再把b、c的结果合并成a的结果。

如果求解a的时间为T(a),求解b、c的时间为T(b) 、T©,T(a)=T(b)+T(c)+K

K为将两个子问题b、c的结果合并为a的结果消耗的时间。

不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。

分析归并排序的时间复杂度:

假设对n个元素进行归并排序需要时间T(n),那么分解成两个子数组排序的时间都是T(n/2),merge()函数合并两个有序子数组的时间复杂度为O(n),套用前面的公式,时间复杂度计算公式为

T(1) = C; n=1时,只需要常量级的执行时间,表示为C
T(n) = 2*T(n/2) +n; n>1

如何求解T(n)?

T(n) = 2*T(n/2) +n
	= 2*(2*T(n/4) +n/2)+n = 4*T(n/4)+2*n
	= 4*(2*T(n/8) +n/4)+2*n = 8*T(n/8)+3*n
	= 8*(2*T(n/16) +n/8)+3*n = 16*T(n/16)+4*n
	...
	= 2^k *T(n/2^k) +k*n

这样,我们就得到了T(n)=2k*T(n/2k)+kn。当T(n/2k)=T(1)时,也就是n/2k=1,得到k=log₂n,将k值代入公式,得到T(n)=Cn+nlog₂n。用大O标记法,T(n)等于O(nlogn),也就是归并排序的时间复杂度为O(nlogn)

因此,归并排序的执行效率与要排序的原始数组的有序程度无关,时间复杂度非常稳定,最好最坏和平均都是O(nlogn)

3. 归并排序的空间复杂度

归并排序的时间复杂度任何情况都是O(nlogn),但是致命弱点是:不是原地排序算法

因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间,那么空间复杂度如何求解?

合并完成后,临时开辟的内存空间被释放掉,任意时刻,CPU只有一个函数在执行,只有一个临时的内存空间在使用。空间复杂度为O(n)

7. 快速排序(quicksort)

1. 概念

快排,利用的也是分治思想。快排的核心思想是:如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)

遍历p到r之间的数据,将小于pivot的放左边,大于pivot的放右边,pivot放中间,数组p到r之间的数据就分为三个部分:前面p到q-1之间的都是小于pivot的,中间是pivot,后边的q+1到r之间的是大于pivot的

根据分治、递归的处理思想,用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据,直到区间缩小为1,说明所有的数据都有序了。

2. 代码实现

递推公式
quick_sort(p...r)=quick_sort(p...q-1)+quick_sort(q+1,r)
终止条件
p>=r

将递推公式转化成递推代码,见下边代码实现。

归并排序有个merge()合并函数,快排则是partition()分区函数。该分区函数就是随机选择一个元素为pivot(一般情况,选择p到r区间的最后一个元素),然后对a[p…r]分区,函数返回pivot的下标。

如果希望快排是原地排序算法,空间复杂度为O(1),那么partition()分区函数不能占用太多的内存空间,需要在a[p…r]的原地完成分区操作。原地分区函数的实现见下边代码

处理和选择排序优点像。通过指针i将a[p…r-1]分为两部分。a[p…i-1]的元素都是小于pivot,叫“已处理区间”,a[i…r-1]是“未处理区间”。每次都从未处理区间a[i…r-1]中取出一个元素a[j],和pivot比较,如果小于pivot,加入到已处理区间的尾部,也就是a[i]的位置。

如何插入?采用交换,将a[i]和a[j]交换,实现O(1)的时间复杂度内将a[j]放到下标为i的位置。

public class QuickSort {

  // 快速排序算法,a为数组,n为数组大小
  public void quickSort(int[] a,int n){
    quickSortInternally(a,0,n-1);
  }

  // 快排递归函数,p,r为下标
  private void quickSortInternally(int[] a, int p, int r) {
    if(p>=r) return;

    int q = partition(a,p,r); // 获取分区点
    quickSortInternally(a,p,q-1);
    quickSortInternally(a,q+1,r);
  }

  private int partition(int[] a, int p, int r) {
    int pivot = a[r];
    int i = p;
    for(int j=p;j<r;j++){
      if(a[j]<pivot){
        if(i==j){
          i++;
        }else{
          int temp = a[i];
          a[i] = a[j];
          a[j] = temp;
        }
      }
    }
    int temp = a[i];
    a[i] = a[r];
    a[r] = temp;
    return i;
  }

  public static void main(String[] args) {
    int[] a = {1,3,5,7,9,2,6,4};
    new QuickSort().quickSort(a,a.length);
    for (int i = 0; i < a.length; i++) {
      System.out.println(a[i]);
    }
  }
}

分区的过程涉及到交换的操作,如果数组中有两个相同的元素,如6,8,7,6,3,5,9,在第一次分区后,两个6的相对先后顺序会改变,所以快排不稳定

3. 快排和归并的区分

  1. 归并的处理过程是由下到上,先处理子问题,再合并;快排相反,处理过程由上到下,先分区,再处理子问题。
  2. 归并稳定,但不是原地排序算法快排通过设计原地分区函数,可以实现原地排序,解决占用内存过多的问题。

4. 快排的性能分析

快排也是利用递归实现。如果每次分区,都能将数组正好氛围大小接近相等的两个小区间,时间复杂度和归并相同,是O(nlogn)

但实际情况很难实现,假设极端例子,数组中的数据原来已经有序,如1,3,5,6,8。如果每次选择最后一个元素作为pivot,每次分区得到两个区间不均等。需要进行大约n此分区完成快排。每次分区平均扫描n/2个元素,快排时间复杂度从O(nlogn)退化为O(n²)

结论:大部分情况下时间复杂度可以做到O(nlogn),极端情况才会退化到O(n²),而且也有很多方法将这个概率降到很低。
如何优化?

选取合适的分区点 ,最理想的分区点:被分区点分开的两个分区,数据的数量差不多。

  1. 三数取中法
  2. 随机法

8. 问题

O(n)时间复杂度内求无序数组中的第K大元素。如4,2,5,12,3这样一组数据,第3大元素就是4

解答

利用分区的思想,选择数组区间A[0…n-1]的最后一个元素A[n-1]作为pivot,对数组A[0…n-1]原地分区,数组分为三部分,A[0…p-1]、A[p]、A[p+1…n-1]

如果p+1=K,那么A[p]就是要求解的元素;如果K>p+1,说明第K大元素出现在A[p+1…n-1]区间,再按照上面思路递归的在A[p+1…n-1]这个区间找。同理,如果K<p+1,就在A[0…p-1]区间查找。

为什么解决思路的时间复杂度为O(n)

第一次分区查找,需要对大小为n的数组执行分区操作,遍历n个元素。第二次分区查找,只需要对大小为n/2的数组执行分区操作,需要遍历n/2个元素。以此类推,分区遍历元素的个数分别为n/2 n/4 n/8 …直到区间缩小为1

如果把每次分区遍历的元素个数加起来,就是n+n/2+n/4+…+1,等比数列求和2n-1,时间复杂度为O(n)

9. 桶排序(Bucket sort)

1. 概念

桶排序,用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

桶排序的时间复杂度为什么是O(n)?

如果要排序的数据为n个,把他们均匀划分到m个桶里,每个桶里有k=n/m个元素,每个桶内部使用快排,时间复杂度为O(k*logk)。m个桶排序的时间复杂度为O(m*k*logk),因为k=n/m,所以整个桶排序的时间复杂度为O(n*log(n/m)) 。当桶的个数m接近数据个数n时,log(n/m)就是一个非常小的常量,这时桶排序的时间复杂度接近O(n)。

2. 优缺点

桶排序对要排序的数据的要求:

  1. 要排序的数据要很容易就能划分为m个桶,并且,桶与桶之间天然有大小顺序,这样,每个桶内的数据都排序好之后,桶与桶之间的数据不需要再排序。
  2. 数据在各个桶之间的分布比较均匀。如果经过桶划分后,有些桶的数据非常多,有些非常少,很不均匀,桶内数据排序的时间复杂度就不是常量级了。极端情况下退化为O(nlogn)

桶排序比较适合用在外部排序。所谓外部排序,就是数据存储在外部磁盘,数据量较大,内存有限,无法将数据全部加载进内存。

案例:有10GB的订单数据,希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,没办法一次性加载10GB数据到内存,怎么办?

借助桶排序的处理思想解决

先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描后得到,订单金额最小1元,最大10万,将所有订单根据金额划分到100个桶,第一桶存储1-1000元以内订单,第二桶存储1001-2000元订单,以此类推。每个桶对应一个文件。并且按照金额范围的大小编号命名(00,01,02…99)

理想情况,如果订单金额在1到10万之间均匀分布,订单被均匀划分到100个文件。每个小文件存储100MB的订单数据。可以将这100个小文件依次放到内存,用快排排序。所有文件排序完成,只需要按照文件编号,从小到大依次读取每个小文件的订单数据,并将其写入到一个文件,这个文件中存储的就是按照金额从小到大排序的订单数据。

如果某个金额区间的数据特别多,划分后对应的文件会很大,没法一次读取,可以继续划分,如,订单金额在1到1000元之间的比较多,将这个区间继续划分为10个小区间,如果不够,继续划分。

10. 计数排序(Counting sort)

其实,计数排序是桶排序的特殊情况。当要排序的n个数据,所处的范围并不大时,如最大值是K,可以把数据划分成K个桶,每个桶内的数据值都是相同的,省掉了桶内排序的时间。

案例:高考查分,如果所在省有50万考生,如何通过成绩快速排序得出名次?

考生满分为900分,最小0分,数据范围很小,可以分为901个桶,对应分数从0分到900分。根据成绩,将50万考生划分到901个桶中,桶内的数据都是分数相同的考生,不需要再排序,只需要依次扫描每个桶,将桶内的考生依次输出到一个数组,实现50万个考生的排序,因为只涉及扫描遍历操作,时间复杂度为O(n)

为什么叫计数排序?计数的含义是如何来的?

需要看其实现原理。对模型简化,假设只有8个考生,分数0-5分之间,成绩放到数组A[8]中,分别是2,5,3,0,2,3,0,3

考生成绩从0到5,使用大小为6的数组C[6]表示桶,下标对应分数。不过C[6]中存储的是考生的个数。只需要遍历考生分数,就可以得到C[6]的值。

分数为3分的考生有3个,小于3分的有4个,所以,成绩为3分的考生在排序之后的有序数组R[8]中,会保存下标4,5,6的位置。

如何快速计算每个分数的考生在有序数组中对应的存储位置?

思路:对C[6]数组顺序求和,C[6]存储的数据,存储小于等于分数k的考生个数。

数据准备后,从后往前依次扫描数组A,比如,当扫描到3时,从数组C中取出下标为3的值7,也就是说,到目前为止,包括该元素,分数小于等于3的考生有7个,也就是说3是数组R中的第7个元素(也就是R中下标为6的位置)。当3放入数组R中小于等于3的元素只剩下6个,对应的C[3]要减一,变为6。

以此类推,扫描到第2个分数为3的考生时,把它放到R中第6个元素的位置(也就是下标为5的位置)。当扫描完整个数组A后,R内的数据就是按照分数从小到大有序排列了。

代码如下

public class CountingSort {

  // 计数排序,数组a,数组大小n,假设数组中存储的都是非负整数
  public void countingSort(int[] a,int n){
    if(n<=1) return;

    // 查找数组中数据的范围
    int max = a[0];
    for (int i = 1; i < n; i++) {
      if(max<a[i]){
        max = a[i];
      }
    }

    // 申请一个计数数组c,下标大小[0,max]
    int[] c = new int[max+1];
    for (int i = 0; i <= max; i++) {
      c[i] = 0;
    }

    // 计算每个元素的个数,放入c中
    for (int i = 0; i < n; i++) {
      c[a[i]]++;
    }

    // 依次累加
    for (int i = 1; i <=max; i++) {
      c[i] = c[i-1]+c[i];
    }

    // 临时数组r,存储排序之后的结果
    int[] r = new int[n];
    // 计算排序的关键步骤
    for(int i=n-1;i>=0;i--){
      int index = c[a[i]]-1;
      r[index] = a[i];
      c[a[i]]--;
    }

    // 将结果拷贝给a数组
    for (int i = 0; i < n; i++) {
      a[i] = r[i];
    }
  }

  public static void main(String[] args) {
    int[] a = {2,5,3,0,2,3,0,3};
    new CountingSort().countingSort(a,a.length);
    for (int i = 0; i < a.length; i++) {
      System.out.println(a[i]);
    }
  }
}

这样其实就是利用另外一个数组来计数,所以叫计数排序

计数排序只能用在数据范围不大的场景中,如果数据范围K比要排序的数据n大很多,就不适合计数排序。而且只能给非负整数排序。

11. 基数排序(Radix sort)

假设有10万个手机号码,希望将这10万个手机号码从小到大排序,有什么比较快速的排序方法?

手机号码有11位,范围太大,不适合桶排序、计数排序,针对这个排序问题,有没有时间复杂度为O(n)的算法?基数排序

这个问题有这样的规律:假设比较两个手机号码a,b的大小,如果前几位,a已经比b大了,其他位数就不用看了。

先按照最后一位来排序手机号码,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序,经过11次排序,手机号码就有序了。

简化为字符串排序,要确保稳定排序,根据每一位来排序,可以用桶排序或者计数排序,时间复杂度O(n),如果要排序的数据有K位,需要K次桶排序或者计数排序,总的时间复杂度为O(k*n),当k不大,基数排序时间复杂度近似于O(n)

有时候要排序的数据不是等长的,可以采用把所有的单词补齐到相同长度,位数不够的在后面补“0”,因为根据ASCII码,所有字母都大于“0”,不会影响原有排序,可以继续使用基数排序。

基数排序对要排序的数据有要求,需要可以分割出独立的“位”比较,且位之间有递进的关系,如果a的高位比b大,低位就不用比较。此外,每一位的数据范围不能太大,要可以用线性排序算法排序,否则,基数排序时间复杂度无法做到O(n)。

12. 排序优化

如何选择合适的排序算法

时间复杂度 是否稳定 是否原地排序
冒泡排序 O(n²)
插入排序 O(n²)
选择排序 O(n²) ×
快速排序 O(nlogn) ×
归并排序 O(nlogn) ×
计数排序 O(n+k)k是数据范围 ×
桶排序 O(n) ×
基数排序 O(dn) d是维度 ×

线性排序算法应用场景较为特殊

对小规模数据,可选择时间复杂度O(n²)的算法;对大规模排序,时间复杂度 O(nlogn),为了兼容,一般首选时间复杂度 O(nlogn)的算法。

猜你喜欢

转载自blog.csdn.net/wjl31802/article/details/91065162
今日推荐