2. LeetCode题解 - 排序算法


1. 冒泡排序

冒泡排序(Bubble Sort)是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。

算法步骤

  1. 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
  3. 针对所有的元素重复以上的步骤,除了最后一个。直到没有任何一对数字需要比较。

动图演示

img

public void bubbleSort(int[] a){
    for(int i = 0 ; i < a.length - 1 ; i++){
        // 设定一个标记,若为true,则表示此次循环没有进行交换,也就是待排序列已经有序,排序已经完成。
        boolean flag = true;
        for(int j = 0 ; j < a.length - 1 - i ; j++){ //额外注意这里的减1,因为每次都是和后一个元素比较
            if(a[j] > a[j+1]){
                int temp = a[j];
                a[j] = a[j+1];
                a[j+1] = temp;
                flag = false;
            }
        }
        if(flag) break;
    }
}

时间复杂度: O(n^2 ),空间复杂度: O(1 )

2. 选择排序

选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。

算法步骤

  1. 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
  2. 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
  3. 重复第二步,直到所有元素均排序完毕。

动图演示

img

public void sort(int[] arr){
    // 总共要经过 N-1 轮比较
    for (int i = 0; i < arr.length - 1; i++) {
        int min = i; //记录最小数的索引
        // 每轮需要比较的次数 N-i
        for (int j = i + 1; j < arr.length; j++) {
            if (arr[j] < arr[min]) {
                // 记录目前能找到的最小值元素的下标
                min = j;
            }
        }
        // i 不是最小数时,将找到的最小值和i位置所在的值进行交换
        if (i != min) {
            int tmp = arr[i];
            arr[i] = arr[min];
            arr[min] = tmp;
        }
    }
}

复杂度分析

  • 时间复杂度:O(N^2),这里 N 是数组的长度;
  • 空间复杂度:O(1),使用到常数个临时变量。

「选择排序」看起来好像最没有用,但是如果在交换成本较高的排序任务中,就可以使用「选择排序」

3. 插入排序

插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入

算法步骤

在其实现过程使用双层循环,外层循环对除了第一个元素之外的所有元素,内层循环对当前元素前面有序表进行待插入位置查找,并进行移动。

  1. 从第一个元素开始,该元素可以认为已经被排序;
  2. 取出下一个元素,在已经排序的元素序列中从后向前扫描;
  3. 如果该元素(已排序)大于新元素,将该元素移到下一位置;
  4. 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
  5. 将新元素插入到该位置后;
  6. 重复步骤2~5

动图演示

img

优化:「将一个数字插入一个有序的数组」这一步,可以不使用逐步交换,使用先赋值给「临时变量」,然后「适当的元素」后移,空出一个位置,最后把「临时变量」赋值给这个空位的策略(就是上面那张图的意思)。

public void insertSort(int[] arr){
    for(int i = 1 ; i < a.length ; i++){
        int temp = arr[i];
        int j = i ;
        while(j > 0 && a[j-1] > temp){
            a[j] = a[j-1]; //将该元素移到下一位置
            j--;
        }
        a[j] = temp;//跳出循环后,将 temp 插入到相应位置
    }
}

插入排序的平均时间复杂度也是 O(n^2),空间复杂度为常数阶 O(1),具体时间复杂度和数组的有序性也是有关联的。

插入排序中,当待排序数组是有序时,是最优的情况,只需当前数跟前一个数比较一下就可以了,这时一共需要比较 N-1 次,时间复杂度为 O(N)。最坏的情况是待排序数组是逆序的,此时需要比较次数最多,最坏的情况是 O(n^2)

由于「插入排序」在「几乎有序」的数组上表现良好,特别地,在「短数组」上的表现也很好。因为「短数组」的特点是:每个元素离它最终排定的位置都不会太远。为此,在小区间内执行排序任务的时候,可以转向使用「插入排序」。

4. 希尔排序(了解)

希尔排序(Shell Sort)是插入排序的一种,它是针对直接插入排序算法的改进。它通过比较相距一定间隔的元素来进行,各趟比较所用的距离随着算法的进行而减小,直到只比较相邻元素的最后一趟排序为止。

过程图示:

在此我们选择增量 step=length/2,缩小增量以 step = step/2 的方式,用序列 {n/2,(n/2)/2…1} 来表示。

(1)初始增量第一趟 step = length/2 = 4

img

(2)第二趟,增量缩小为 2

img

(3)第三趟,增量缩小为 1,得到最终排序结果

img

思想: 简单插入排序的改进版。它与插入排序的不同之处在于,为了使得交换减少,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序

  1. 把记录按下标的一定增量分组,对每组使用直接插入排序算法排序;随着增量逐渐减少,每组包含的关键词越来越多,
  2. 当增量减至1时,整个数组恰好被分成一组
  3. 增量的选取 从 数组长度的一半开始 ,每次减少一半
  4. 循环里面的交换过程与插入排序是一样的
public void shellSort(int[] a){
    int step = a.length / 2;
    while(step >= 1){
        for(int i = step ; i < a.length ; i += step){
            int temp = a[i];
            int j = i;
            while(j - step >= 0 && a[j - step] > temp){
                a[j] = a[j-step];
                j -= step;
            }
            a[j] = temp;
        }
        step = step / 2;
    }
}

希尔排序时间复杂度是 O(n^(1.3-2)),空间复杂度为常数阶 O(1)。希尔排序没有时间复杂度为 O(n(logn)) 的快速排序算法快 ,因此对中等大小规模表现良好,但对规模非常大的数据排序不是最优选择,总之比一般 O(n^2 ) 复杂度的算法快得多。

5. 归并排序(重点)

归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用**分治法(Divide and Conquer)**的一个非常典型的应用。

作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法:

  • 自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法);
  • 自下而上的迭代;

和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 O(nlogn) 的时间复杂度。代价是需要额外的内存空间

算法步骤

分而治之(divide - conquer);每个递归过程涉及三个步骤

第一, 分解: 把待排序的 n 个元素的序列分解成两个子序列, 每个子序列包括 n/2 个元素.

第二, 治理: 对每个子序列分别调用归并排序sort, 进行递归操作

第三, 合并: 合并两个排好序的子序列,生成排序结果。

合并过程

  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
  3. 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
  4. 重复步骤 3 直到某一指针达到序列尾;
  5. 将另一序列剩下的所有元素直接复制到合并序列尾。

动图演示

img

public static void mergeSort(int[] arr) {
    sort(arr, 0, arr.length - 1);
}

public static void sort(int[] arr, int left, int right) {
    if(left == right) {
        return;
    }
    int mid = left + ((right - left) / 2);//分解
    sort(arr, left, mid);
    sort(arr, mid + 1, right);    //递归
    merge(arr, left, mid, right); //合并
}

public static void merge(int[] arr, int left, int mid, int right) {
    int[] temp = new int[right - left + 1]; //申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
    int i = 0;
    //设定两个指针,最初位置分别为两个已经排序序列的起始位置;
    int p1 = left;
    int p2 = mid + 1;
    // 比较左右两部分的元素,哪个小,把那个元素填入temp中
    while(p1 <= mid && p2 <= right) {
        temp[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
    }
    // 上面的循环退出后,把剩余的元素依次填入到temp中
    // 以下两个while只有一个会执行
    while(p1 <= mid) {
        temp[i++] = arr[p1++];
    }
    while(p2 <= right) {
        temp[i++] = arr[p2++];
    }
    // 把最终的排序的结果复制给原数组
    for(i = 0; i < temp.length; i++) {
        arr[left + i] = temp[i];
    }
}

「归并排序」比「快速排序」好的一点是,它借助了额外空间,可以实现「稳定排序」,Java 里对于「对象数组」的排序任务,就是使用归并排序(的升级版 TimSort,在这里就不多做介绍)。

复杂度分析

  • 时间复杂度:O*(Nlog*N),这里 N 是数组的长度;
  • 空间复杂度:O*(*N),辅助数组与输入数组规模相当。

6. 快速排序(重点)

快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。快速排序每一次都排定一个元素(这个元素呆在了它最终应该呆的位置),然后递归地去排它左边的部分和右边的部分,依次进行下去,直到数组有序;

快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。与「归并排序」不同,「快速排序」在「分」这件事情上不想「归并排序」无脑地一分为二,而是采用了 partition 的方法,因此就没有「合」的过程。

算法步骤:

  1. 从数列中挑出一个元素,称为 “基准”(pivot);
    在这里插入图片描述

  2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;

  3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;

动图演示:

img

实现细节(注意事项):(针对特殊测试用例:顺序数组或者逆序数组)一定要随机化选择切分元素(pivot),否则在输入数组是有序数组或者是逆序数组的时候,快速排序会变得非常慢(等同于冒泡排序或者「选择排序」);

public class QuickSort implements IArraySort {
	private static final Random RANDOM = new Random();
    
    public void sort(int[] arr)  {
        return quickSort(arr, 0, arr.length - 1);
    }

    private int[] quickSort(int[] arr, int left, int right) {
        if (left < right) {
            int partitionIndex = partition(arr, left, right);
            //递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序
            quickSort(arr, left, partitionIndex - 1);
            quickSort(arr, partitionIndex + 1, right);
        }
    }

    private int partition(int[] arr, int left, int right) {
        //优化!随机化选择切分元素(pivot)
        int randomIndex = RANDOM.nextInt(right - left + 1) + left;
        swap(nums, left, randomIndex);
        int pivot = left; //将第一个元素设为基准值(轴心点)
        int index = pivot + 1; //存储指数为轴心点+1
        //对轴心点右侧的数进行遍历
        for (int i = index; i <= right; i++) {
            //如果比轴心数小的就将它摆放在轴心前面
            if (arr[i] < arr[pivot]) {
                swap(arr, i, index);
                index++;
            }
        }
        //遍历完成后将基准值放到轴心位置
        swap(arr, pivot, index - 1);
        return index - 1;
    }

    private void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

}

复杂度分析

  • 时间复杂度O(NlogN),这里 N 是数组的长度;
  • 空间复杂度O(logN),这里占用的空间主要来自递归函数的栈空间。

快速排序在平均状况下,排序 n 个项目要 Ο(nlogn) 次比较。在最坏状况下则需要 Ο(n^2) 次比较,但这种状况并不常见。事实上,快速排序通常明显比其他 Ο(nlogn) 算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地被实现出来。

快速排序的名字起的是简单粗暴,因为一听到这个名字你就知道它存在的意义,就是快,而且效率高!它是处理大数据最快的排序算法之一了。虽然 Worst Case 的时间复杂度达到了 O(n²),但是人家就是优秀,在大多数情况下都比平均时间复杂度为 O(nlogn) 的排序算法表现要更好,可是这是为什么呢,我也不知道。好在我的强迫症又犯了,查了 N 多资料终于在《算法艺术与信息学竞赛》上找到了满意的答案:

快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。


7. 堆排序(重要)

参考链接:https://www.cnblogs.com/chengxiao/p/6129630.html

预备知识

堆排序是利用这种数据结构而设计的一种排序算法,堆排序是一种**选择排序,**它的最坏,最好,平均时间复杂度均为O(nlogn),它也是不稳定排序。首先简单了解下堆结构。

  • 堆是一种相当有意思的数据结构,它在很多语言里也被命名为「优先队列」。它是建立在数组上的「树」结构,类似的数据结构还有「并查集」「线段树」等。

堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:

img

同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子

img

该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:

大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]

小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]

ok,了解了这些定义。接下来,我们来看看堆排序的基本思想及基本步骤:

堆排序基本思想及步骤

堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了

步骤一 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。

1.假设给定无序序列结构如下

img

2.此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的6结点),从右至左,从下至上进行调整。

img

3.找到第二个非叶节点4,由于[4,9,8]中9元素最大,4和9交换。

img

4.这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6。

img

此时,我们就将一个无需序列构造成了一个大顶堆。

步骤二 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。

1.将堆顶元素9和末尾元素4进行交换

img

2.重新调整结构,使其继续满足堆定义

img

3.再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.

img

后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序

img

再简单总结下堆排序的基本思路:

a.将无需序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;

b.将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;

c.重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。

整体实现过程如下面的动图所示:

fig2

fig3

代码实现

class Solution {
    public int[] sortArray(int[] arr) {
        int len = arr.length;
        buildMaxHeap(arr, len); //先构建大顶堆
		// 循环不变量:区间 [0, i] 堆有序
        for (int i = len - 1; i > 0; i--) {
            swap(arr, 0, i); //将堆顶元素与末尾元素进行交换
            len--;  ///由于末尾元素已经排好,所以令数组长度减1
            heapify(arr, 0, len);///重新对堆进行调整
        }
        return arr;
    }

    private void buildMaxHeap(int[] arr, int len) {
        //从右至左,从下至上进行调整。
        for (int i = (len - 1) / 2; i >= 0; i--) {
            heapify(arr, i, len);
        }
    }

    //调整大顶堆
    private void heapify(int[] arr, int i, int len) {
        int left = 2 * i + 1;
        int right = 2 * i + 2;
        int largest = i;

        if (left < len && arr[left] > arr[largest]) {
            largest = left;
        }

        if (right < len && arr[right] > arr[largest]) {
            largest = right;
        }

        if (largest != i) {
            swap(arr, i, largest);
            heapify(arr, largest, len); //调整子根的结构
        }
    }

    private void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }
}

复杂度分析

  • 时间复杂度:O*(NlogN),这里 N* 是数组的长度;
  • 空间复杂度:O(1)。

堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆两部分组成。其中构建初始堆经推导复杂度为O(n),在交换并重建堆的过程中,需交换n-1次,而重建堆的过程中,根据完全二叉树的性质,[log2(n-1),log2(n-2)…1]逐步递减,近似为nlogn。所以堆排序时间复杂度一般认为就是O(nlogn)级


3 种「非比较」的排序算法

这三种排序的区别与上面的排序的特点是:一个数该放在哪里,是由这个数本身的大小决定的,它不需要经过比较。也可以认为是哈希的思想:由数值映射地址。

因此这三种算法一定需要额外的空间才能完成排序任务,时间复杂度可以提升到 O(N),但适用场景不多,主要是因为使用这三种排序一定要保证输入数组的每个元素都在一个合理的范围内

这三种算法还有一个特点是:都可以实现成稳定排序,无需稳定化。

8. 计数排序

计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中,把每个出现的数值都做一个计数,然后根据计数从小到大输出得到有序数组。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

计数排序不是比较排序,排序的速度快于任何比较排序算法。由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。例如:计数排序是用来排序0到100之间的数字的最好的算法,但是它不适合按字母顺序排序人名。但是,计数排序可以用在基数排序中的算法来排序数据范围很大的数组。

算法的步骤如下:

  • (1)找出待排序的数组中最大和最小的元素
  • (2)统计数组中每个值为i的元素出现的次数,存入数组C的第i项
  • (3)对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
  • (4)反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1

img

public class CountingSort implements IArraySort {

    @Override
    public int[] sort(int[] arr){
        int maxValue = getMaxValue(arr);
        return countingSort(arr, maxValue);
    }

    private int[] countingSort(int[] arr, int maxValue) {
        int bucketLen = maxValue + 1;
        int[] bucket = new int[bucketLen];

        for (int value : arr) {
            bucket[value]++; //z存储每个元素j出现的次数
        }

        int sortedIndex = 0;
        for (int j = 0; j < bucketLen; j++) {
            while (bucket[j] > 0) { //如果存在这个元素,就将它放回arr中,j代表元素,bucket[j]为该元素出现的次数
                arr[sortedIndex++] = j;
                bucket[j]--; //每放一个元素就将bucket(j)减去1
            }
        }
        return arr;
    }

    private int getMaxValue(int[] arr) {
        int maxValue = arr[0];
        for (int value : arr) {
            if (maxValue < value) {
                maxValue = value;
            }
        }
        return maxValue;
    }
}

9 桶排序

一句话总结:划分多个范围相同的区间,每个子区间自排序,最后合并

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

桶排序需要尽量保证元素分散均匀,否则当所有数据集中在同一个桶中时,桶排序失效。

它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:

  1. 在额外空间充足的情况下,尽量增大桶的数量
  2. 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中

同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。

  • 什么时候最快:当输入的数据可以均匀的分配到每一个桶中。
  • 什么时候最慢:当输入的数据被分配到了同一个桶中。

示意图

img
public class BucketSort implements IArraySort {

    private static final InsertSort insertSort = new InsertSort();
    
    public int[] sort(int[] arr) {
        return bucketSort(arr, 5);
    }

    private int[] bucketSort(int[] arr, int bucketSize) {
        if (arr.length == 0) {
            return arr;
        }

        int minValue = arr[0];
        int maxValue = arr[0];
        for (int value : arr) {
            if (value < minValue) {
                minValue = value;
            } else if (value > maxValue) {
                maxValue = value;
            }
        }

        int bucketCount = (int) Math.floor((maxValue - minValue) / bucketSize) + 1;
        int[][] buckets = new int[bucketCount][0];

        // 利用映射函数将数据分配到各个桶中
        for (int i = 0; i < arr.length; i++) {
            int index = (int) Math.floor((arr[i] - minValue) / bucketSize);
            buckets[index] = arrAppend(buckets[index], arr[i]);
        }

        int arrIndex = 0;
        for (int[] bucket : buckets) {
            if (bucket.length <= 0) {
                continue;
            }
            // 对每个桶进行排序,这里使用了插入排序
            bucket = insertSort.sort(bucket);
            for (int value : bucket) {
                arr[arrIndex++] = value;
            }
        }
        return arr;
    }

    /**
     * 自动扩容,并保存数据
     *
     * @param arr
     * @param value
     */
    private int[] arrAppend(int[] arr, int value) {
        arr = Arrays.copyOf(arr, arr.length + 1);
        arr[arr.length - 1] = value;
        return arr;
    }
}

下面就总结一下排序算法的各自的使用场景和适用场合。

img

1. 从平均时间来看,快速排序是效率最高的,但快速排序在最坏情况下的时间性能不如堆排序和归并排序。而后者相比较的结果是,在n较大时归并排序使用时间较少,但使用辅助空间较多。

2. 上面说的简单排序包括除希尔排序之外的所有冒泡排序、插入排序、选择排序。其中直接插入排序最简单,但序列基本有序或者n较小时,直接插入排序是好的方法,因此常将它和其他的排序方法,如快速排序、归并排序等结合在一起使用

3. 基数排序的时间复杂度也可以写成O(d*n)。因此它最使用于n值很大而关键字较小的的序列。若关键字也很大,而序列中大多数记录的最高关键字均不同,则亦可先按最高关键字不同,将序列分成若干小的子序列,而后进行直接插入排序。

4. 从方法的稳定性来比较,基数排序是稳定的内排方法,所有时间复杂度为O(n^2)的简单排序也是稳定的。但是快速排序、堆排序、希尔排序等时间性能较好的排序方法都是不稳定的。稳定性需要根据具体需求选择。

5. 上面的算法实现大多数是使用线性存储结构,像插入排序这种算法用链表实现更好,省去了移动元素的时间。具体的存储结构在具体的实现版本中也是不同的。


LeetCode相关题目及题解

215. 数组中的第K个最大元素(中等)

在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

输入: [3,2,1,5,6,4] 和 k = 2
输出: 5
输入: [3,2,3,1,2,4,5,5,6] 和 k = 4
输出: 4

方法一:基于快速排序的选择方法

我们可以用快速排序来解决这个问题,先对原数组排序,再返回倒数第 k 个位置,这样平均时间复杂度是O(nlogn),但其实我们可以做的更快。

每次经过「划分」操作后,我们一定可以确定一个元素的最终位置,即 x 的最终位置为 q,并且保证 a[l⋯q−1] 中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r] 中的每个元素。所以只要某次划分的 q 为倒数第 k 个下标的时候,我们就已经找到了答案。 我们只关心这一点,至于 a[l⋯q−1] 和 a[q+1⋯r] 是否是有序的,我们不关心。

因此我们可以改进快速排序算法来解决这个问题:在分解的过程当中,我们会对子数组进行划分,如果划分得到的 q 正好就是我们需要的下标,就直接返回 a[q];否则,如果 q 比目标下标小,就递归右子区间,否则递归左子区间。这样就可以把原来递归两个区间变成只递归一个区间,提高了时间效率。这就是「快速选择」算法。

class Solution {
    Random random = new Random();
    
    public int findKthLargest(int[] nums, int k) {
        return quickSelect(nums, 0, nums.length - 1, nums.length - k);
    }

    public int quickSelect(int[] a, int l, int r, int index) {
        int q = randomPartition(a, l, r);
        //改进部分
        if (q == index) {
            return a[q];
        } else {
            return q < index ? quickSelect(a, q + 1, r, index) : quickSelect(a, l, q - 1, index);
        }
    }

    public int randomPartition(int[] a, int l, int r) {
        int i = random.nextInt(r - l + 1) + l;
        swap(a, i, r);
        return partition(a, l, r);
    }

    public int partition(int[] a, int l, int r) {
        int x = a[r], i = l - 1;
        for (int j = l; j < r; ++j) {
            if (a[j] <= x) {
                swap(a, ++i, j);
            }
        }
        swap(a, i + 1, r);
        return i + 1;
    }

    public void swap(int[] a, int i, int j) {
        int temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
}

复杂度分析

  • 时间复杂度:O(n),如上文所述,证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。

  • 空间复杂度:O(logn),递归使用栈空间的空间代价的期望为 O(logn)。


方法二:基于堆排序的选择方法

我们也可以使用堆排序来解决这个问题——建立一个大根堆,做 k - 1次删除操作后堆顶元素就是我们要找的答案。在很多语言中,都有优先队列或者堆的的容器可以直接使用,但是在面试中,面试官更倾向于让更面试者自己实现一个堆。所以建议读者掌握这里大根堆的实现方法,在这道题中尤其要搞懂「建堆」、「调整」和「删除」的过程。

求第一个最大值,需要将最大值换到堆顶直接返回nums[0],进行0次删除;

求第二个最大值时,需要将第二个最大值换到堆顶返回nums[0],进行一次删除(第一次的堆顶)……

所以求第k个最大值,需要进行k-1次删除操作后返回堆顶元素nums[0]

class Solution {
    public int findKthLargest(int[] nums, int k) {
        int heapSize = nums.length;
        buildMaxHeap(nums, heapSize);
        //注意这里的改进
        for (int i = nums.length - 1; i >= nums.length - k + 1; --i) {
            swap(nums, 0, i);
            --heapSize;
            maxHeapify(nums, 0, heapSize);
        }
        return nums[0];
    }

    public void buildMaxHeap(int[] a, int heapSize) {
        for (int i = heapSize / 2; i >= 0; --i) {
            maxHeapify(a, i, heapSize);
        } 
    }

    public void maxHeapify(int[] a, int i, int heapSize) {
        int l = i * 2 + 1, r = i * 2 + 2, largest = i;
        if (l < heapSize && a[l] > a[largest]) {
            largest = l;
        } 
        if (r < heapSize && a[r] > a[largest]) {
            largest = r;
        }
        if (largest != i) {
            swap(a, i, largest);
            maxHeapify(a, largest, heapSize);
        }
    }

    public void swap(int[] a, int i, int j) {
        int temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
}

复杂度分析

  • 时间复杂度:O(nlogn),建堆的时间代价是 O(n),删除的总代价是 O(klogn),因为 k<n,故渐进时间复杂为O(n+klogn)=O(nlogn)。
  • 空间复杂度:O(logn),即递归使用栈空间的空间代价。

347. 前K个高频元素(中等)

给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。

输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
输入: nums = [1], k = 1
输出: [1]

解题思路:堆排序

这道题目主要涉及到如下三块内容:

  • 要统计元素出现频率
  • 对频率排序
  • 找出前K个高频元素

首先统计元素出现的频率,这一类的问题可以使用map来进行统计。

然后是对频率进行排序,这里我们可以使用一种 容器适配器 就是优先级队列

什么是优先级队列呢?

其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。

而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?

缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)。

什么是堆呢?

堆是一颗完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆

本题我们就要使用优先级队列来对部分频率进行排序。

此时要思考一下,是使用小顶堆呢,还是大顶堆?

有的同学一想,题目要求前 K 个高频元素,那么果断用大顶堆啊。

那么问题来了,定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前K个高频元素呢。

所以我们要用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素

  • 如果堆的元素个数小于 k,就可以直接插入堆中。
  • 否则,就弹出堆顶,并将当前值插入堆中。
  • 遍历完成后,堆中的元素就代表了「出现次数数组」中前 k 大的值。

寻找前k个最大元素流程如图所示:(图中的频率只有三个,所以正好构成一个大小为3的小顶堆,如果频率更多一些,则用这个小顶堆进行扫描)

347.前K个高频元素.jpg
class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        int[] result = new int[k];
        HashMap<Integer, Integer> map = new HashMap<>();
        for (int num : nums) {
            map.put(num, map.getOrDefault(num, 0) + 1);
        }

       // int[] 的第一个元素代表数组的值,第二个元素代表了该值出现的次数
        PriorityQueue<int[]> queue = new PriorityQueue<int[]>(new Comparator<int[]>() {
            // 根据map的value值正序排,相当于一个小顶堆
            public int compare(int[] m, int[] n) {
                return m[1] - n[1];
            }
        });
        for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
            int num = entry.getKey(), count = entry.getValue();
            queue.offer(new int[]{num, count});
            if (queue.size() > k) {
                queue.poll();
            }
        }
        for (int i = k - 1; i >= 0; i--) {
            result[i] = queue.poll()[0];
        }
        return result;
    }
}

451 按照字符出现频率排序(中等)

给定一个字符串,请将字符串里的字符按照出现的频率降序排列。

输入:"tree"
输出:"eert"
解释:
'e'出现两次,'r'和't'都只出现一次。
因此'e'必须出现在'r'和't'之前。此外,"eetr"也是一个有效的答案。
输入:"cccaaa"
输出:"cccaaa"
解释:
'c'和'a'都出现三次。此外,"aaaccc"也是有效的答案。
注意"cacaca"是不正确的,因为相同的字母必须放在一起。

解题思路

题目要求将给定的字符串按照字符出现的频率降序排序,因此需要首先遍历字符串,统计每个字符出现的频率,然后每次得到频率最高的字符,生成排序后的字符串。

然后第一种方法可以利用最大堆的方式,对哈希表的结果按照频率进行降序排列,则每次从堆顶取出来的字符一定是重复次数最多的,代码如下:

class Solution {
    public String frequencySort(String s) {
        int len = s.length();
        Map<Character, Integer> map = new HashMap<>();
        for(char c : s.toCharArray()){
            map.put(c, map.getOrDefault(c, 0) + 1);
        }
        //降序排列,构建大顶堆
        PriorityQueue<Map.Entry<Character, Integer>> queue = new PriorityQueue<>((a, b) -> b.getValue() - a.getValue());
        // 入堆
        for (Map.Entry<Character, Integer> entry: map.entrySet()) {
            queue.offer(entry);
        }
        StringBuilder result = new StringBuilder();
        while (!queue.isEmpty()) {
            //每次取堆顶元素的字符则是出现频率最高的
            Map.Entry<Character, Integer> top = queue.poll();
            for (int i = 0; i < top.getValue(); i++) {
                result.append(top.getKey());
            }
        }
        return result.toString();
    }
}

第二种方法可以使用哈希表记录每个字符出现的频率,将字符去重后存入列表,再将列表中的字符按照频率降序排序

生成排序后的字符串时,遍历列表中的每个字符,则遍历顺序为字符按照频率递减的顺序。对于每个字符,将该字符按照出现频率拼接到排序后的字符串。例如,遍历到字符 c,该字符在字符串中出现了 freq 次,则将 freq 个字符 c拼接到排序后的字符串。

class Solution {
    public String frequencySort(String s) {
        Map<Character, Integer> map = new HashMap<>();
        for(char c : s.toCharArray()){
            map.put(c, map.getOrDefault(c, 0) + 1);
        }
        List<Character> list = new ArrayList<>(map.keySet());
        Collections.sort(list, (a, b) -> map.get(b) - map.get(a));

        StringBuilder sb = new StringBuilder();
        int size = list.size();
        for (int i = 0; i < size; i++) {
            char c = list.get(i);
            int frequency = map.get(c);
            for (int j = 0; j < frequency; j++) {
                sb.append(c);
            }
        }
        return sb.toString();
    }
}

Java中Map的遍历方式

entrySet:entrySet是 java中 键-值 对的集合,Set里面的类型是Map.Entry,一般可以通过map.entrySet()得到。

  • entrySet实现了Set接口,里面存放的是键值对。一个K对应一个V。

可以通过 getKey()得到 K,getValue得到 V。

Set<Map.Entry<String, String>> entryseSet = map.entrySet();
for (Map.Entry<String, String> entry: entryseSet) {
    System.out.println(entry.getKey() + "," +entry.getValue()); 
}

//推荐,尤其是容量大时  或者直接简写为
for (Map.Entry<String, String> entry : map.entrySet()) {
    System.out.println("key= " + entry.getKey() + " and value= " + entry.getValue());
}

keySet:还有一种是keySet, keySet是键的集合,Set里面的类型即key的类型

Set<String> set = map.keySet();
for (String s: set) {
    System.out.println(s + "," +map.get(s)); 
}

//第一种:普遍使用,二次取值
for (String key : map.keySet()) {
    System.out.println("key= "+ key + " and value= " + map.get(key));
}


75. 颜色分类(中等)

给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。

此题中,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。

输入:nums = [2,0,2,1,1,0]
输出:[0,0,1,1,2,2]
输入:nums = [2,0,1]
输出:[0,1,2]

解题思路

说明:使用库函数排序和手写计数排序都可以完成这道问题,这里省略。

什么是 partition ?

我们在学习 快速排序 的时候知道,可以选择一个标定元素(称为 pivot ,一般而言随机选择),然后通过一次扫描,把数组分成三个部分:

  • 第 1 部分严格小于 pivot 元素的值;
  • 第 2 部分恰好等于 pivot 元素的值;
  • 第 3 部分严格大于 pivot 元素的值。

第 2 部分元素就是排好序以后它们应该在的位置,接下来只需要递归处理第 1 部分和第 3 部分的元素。经过一次扫描把整个数组分成 3 个部分,正好符合当前问题的场景。写对这道题的方法是:把循环不变量的定义作为注释写出来,然后再编码

循环不变量:声明的变量在遍历的过程中需要保持定义不变。

设计循环不变量的原则:不重不漏。

  • len 是数组的长度;
  • 变量 zero 是前两个子区间的分界点,一个是闭区间,另一个就必须是开区间;
  • 变量 one 是循环变量,一般设置为开区间,表示 i 之前的元素是遍历过的;
  • two 是另一个分界线,我设计成闭区间。

如果循环不变量定义如下:

  • 所有在子区间 [0, zero]的元素都等于 0;
  • 所有在子区间 (zero, one) 的元素都等于 1;
  • 所有在子区间 [two, len - 1] 的元素都等于 2。

于是编码要解决以下三个问题:变量初始化应该如何定义;在遍历的时候,是先加减还是先交换;什么时候循环终止。处理这三个问题,完全看循环不变量的定义。

编码的时候,zero 和 two 初始化的值就应该保证上面的三个子区间全为空;在遍历的过程中,「下标先加减再交换」、还是「先交换再加减」就看初始化的时候变量在哪里;退出循环的条件也看上面定义的循环不变量,在 one == two 成立的时候,上面的三个子区间就正好 不重不漏 地覆盖了整个数组,并且给出的性质成立,题目的任务也就完成了。

class Solution {
    public void sortColors(int[] nums) {
        int len = nums.length;
        if (len < 2) {
            return;
        }
        
        // all in [0, zero] = 0
        // all in (zero, one) = 1
        // all in [two, len - 1] = 2
        
        // 循环终止条件是 i == two,那么循环可以继续的条件是 i < two
        // 为了保证初始化的时候 [0, zero] 为空,设置 zero = -1,
        // 所以下面遍历到 0 的时候,先加,再交换
        int zero = -1;

        // 为了保证初始化的时候 [two, len - 1] 为空,设置 two = len
        // 所以下面遍历到 2 的时候,先减,再交换
        int two = len;
        int one = 0;
        // 当 0ne == two 上面的三个子区间正好覆盖了全部数组
        // 因此,循环可以继续的条件是 i < two
        while (one < two) {
            if (nums[one] == 0) {
                zero++;
                swap(nums, one, zero);
                one++;
            } else if (nums[one] == 1) {
                one++;
            } else {
                two--;
                swap(nums, one, two);
            }
        }
    }

    private void swap(int[] nums, int index1, int index2) {
        int temp = nums[index1];
        nums[index1] = nums[index2];
        nums[index2] = temp;
    }
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值