Leetcode 01-算法入门与数组-③数组排序

LeetCode 01-算法入门与数组-③数组排序

一. 冒泡排序

1. 冒泡排序算法思想

冒泡排序(Bubble Sort)基本思想

经过多次迭代,通过相邻元素之间的比较与交换,使值较小的元素逐步从后面移到前面,值较大的元素从前面移到后面。

这个过程就像水底的气泡一样从底部向上「冒泡」到水面,这也是冒泡排序法名字的由来。

接下来,我们使用「冒泡」的方式来模拟一下这个过程。

  1. 首先将数组想象是一排「泡泡」,元素值的大小与泡泡的大小成正比。
  2. 然后从左到右依次比较相邻的两个「泡泡」:
    1. 如果左侧泡泡大于右侧泡泡,则交换两个泡泡的位置。
    2. 如果左侧泡泡小于等于右侧泡泡,则两个泡泡保持不变。
  3. 1 1 1 趟遍历完成之后,最大的泡泡就会放置到所有泡泡的最右侧,就像是「泡泡」从水底向上浮到了水面。

<1>

Leetcode 01-算法入门与数组-③数组排序_第1张图片

<2>

Leetcode 01-算法入门与数组-③数组排序_第2张图片

<3>

Leetcode 01-算法入门与数组-③数组排序_第3张图片

<4>

Leetcode 01-算法入门与数组-③数组排序_第4张图片

<5>

在这里插入图片描述

<6>

Leetcode 01-算法入门与数组-③数组排序_第5张图片

<7>

Leetcode 01-算法入门与数组-③数组排序_第6张图片

2. 冒泡排序算法步骤

假设数组的元素个数为 n n n 个,则冒泡排序的算法步骤如下:

  1. 1 1 1 趟「冒泡」:对前 n n n 个元素执行「冒泡」,从而使第 1 1 1 个值最大的元素放置在正确位置上。
    1. 先将序列中第 1 1 1 个元素与第 2 2 2 个元素进行比较,如果前者大于后者,则两者交换位置,否则不交换。
    2. 然后将第 2 2 2 个元素与第 3 3 3 个元素比较,如果前者大于后者,则两者交换位置,否则不交换。
    3. 依次类推,直到第 n − 1 n - 1 n1 个元素与第 n n n 个元素比较(或交换)为止。
    4. 经过第 1 1 1 趟排序,使得 n n n 个元素中第 i i i 个值最大元素被安置在第 n n n 个位置上。
  2. 2 2 2 趟「冒泡」:对前 n − 1 n - 1 n1 个元素执行「冒泡」,从而使第 2 2 2 个值最大的元素放置在正确位置上。
    1. 先将序列中第 1 1 1 个元素与第 2 2 2 个元素进行比较,若前者大于后者,则两者交换位置,否则不交换。
    2. 然后将第 2 2 2 个元素与第 3 3 3 个元素比较,若前者大于后者,则两者交换位置,否则不交换。
    3. 依次类推,直到对 n − 2 n - 2 n2 个元素与第 n − 1 n - 1 n1 个元素比较(或交换)为止。但是少时诵诗书所所所所是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒
    4. 经过第 2 2 2 趟排序,使得数组中第 2 2 2 个值最大元素被安置在第 n n n 个位置上。
  3. 依次类推,重复上述「冒泡」过程,直到某一趟排序过程中不出现元素交换位置的动作,则排序结束。

我们以 [ 5 , 2 , 3 , 6 , 1 , 4 ] [5, 2, 3, 6, 1, 4] [5,2,3,6,1,4] 为例,演示一下冒泡排序的整个过程。

Leetcode 01-算法入门与数组-③数组排序_第7张图片

3. 冒泡排序代码实现

class Solution:
    def bubbleSort(self, nums: [int]) -> [int]:
        # 第 i 趟「冒泡」
        for i in range(len(nums) - 1):
            flag = False    # 是否发生交换的标志位
            # 从数组中前 n - i + 1 个元素的第 1 个元素开始,相邻两个元素进行比较
            for j in range(len(nums) - i - 1):
                # 相邻两个元素进行比较,如果前者大于后者,则交换位置
                if nums[j] > nums[j + 1]:
                    nums[j], nums[j + 1] = nums[j + 1], nums[j]
                    flag = True
            if not flag:    # 此趟遍历未交换任何元素,直接跳出
                break
        
        return nums
    
    def sortArray(self, nums: [int]) -> [int]:
        return self.bubbleSort(nums)

4. 冒泡排序算法分析

  • 最佳时间复杂度 O ( n ) O(n) O(n)。最好的情况下(初始时序列已经是升序排列),只需经过 1 1 1 趟排序,总共经过 n n n 次元素之间的比较,并且不移动元素,算法就可以结束排序。因此,冒泡排序算法的最佳时间复杂度为 O ( n ) O(n) O(n)
  • 最坏时间复杂度 O ( n 2 ) O(n^2) O(n2)。最差的情况下(初始时序列已经是降序排列,或者最小值元素处在序列的最后),则需要进行 n n n 趟排序,总共进行 ∑ i = 2 n ( i − 1 ) = n ( n − 1 ) 2 ∑^n_{i=2}(i−1) = \frac{n(n−1)}{2} i=2n(i1)=2n(n1) 次元素之间的比较,因此,冒泡排序算法的最坏时间复杂度为 O ( n 2 ) O(n^2) O(n2)
  • 空间复杂度 O ( 1 ) O(1) O(1)。冒泡排序为原地排序算法,只用到指针变量 i i i j j j 以及标志位 f l a g flag flag 等常数项的变量。
  • 冒泡排序适用情况:冒泡排序方法在排序过程中需要移动较多次数的元素,并且排序时间效率比较低。因此,冒泡排序方法比较适合于参加排序序列的数据量较小的情况,尤其是当序列的初始状态为基本有序的情况。
  • 排序稳定性:由于元素交换是在相邻元素之间进行的,不会改变相等元素的相对顺序,因此,冒泡排序法是一种 稳定排序算法

二. 选择排序

1. 选择排序算法思想

选择排序(Selection Sort)基本思想

将数组分为两个区间:左侧为已排序区间,右侧为未排序区间。每趟从未排序区间中选择一个值最小的元素,放到已排序区间的末尾,从而将该元素划分到已排序区间。

选择排序是一种简单直观的排序算法,其思想简单,代码也相对容易。

2. 选择排序算法步骤

假设数组的元素个数为 n n n 个,则选择排序的算法步骤如下:

  1. 初始状态下,无已排序区间,未排序区间为 [ 0 , n − 1 ] [0, n - 1] [0,n1]
  2. 1 1 1 趟选择:
    1. 遍历未排序区间 [ 0 , n − 1 ] [0, n - 1] [0,n1],使用变量 m i n ‾ i min\underline{}i mini 记录区间中值最小的元素位置。
    2. m i n ‾ i min\underline{}i mini 与下标为 0 0 0 处的元素交换位置。如果下标为 0 0 0 处元素就是值最小的元素位置,则不用交换。
    3. 此时, [ 0 , 0 ] [0, 0] [0,0] 为已排序区间, [ 1 , n − 1 ] [1, n - 1] [1,n1](总共 n − 1 n - 1 n1 个元素)为未排序区间。
  3. 2 2 2 趟选择:
    1. 遍历未排序区间 [ 1 , n − 1 ] [1, n - 1] [1,n1],使用变量 m i n ‾ i min\underline{}i mini 记录区间中值最小的元素位置。
    2. m i n ‾ i min\underline{}i mini 与下标为 1 1 1 处的元素交换位置。如果下标为 1 1 1 处元素就是值最小的元素位置,则不用交换。
    3. 此时, [ 0 , 1 ] [0, 1] [0,1] 为已排序区间, [ 2 , n − 1 ] [2, n - 1] [2,n1](总共 n − 2 n - 2 n2 个元素)为未排序区间。
  4. 依次类推,对剩余未排序区间重复上述选择过程,直到所有元素都划分到已排序区间,排序结束。

我们以 [ 5 , 2 , 3 , 6 , 1 , 4 ] [5, 2, 3, 6, 1, 4] [5,2,3,6,1,4] 为例,演示一下选择排序的整个过程。

<1>

Leetcode 01-算法入门与数组-③数组排序_第8张图片

<2>

Leetcode 01-算法入门与数组-③数组排序_第9张图片

<3>
Leetcode 01-算法入门与数组-③数组排序_第10张图片

<4>

Leetcode 01-算法入门与数组-③数组排序_第11张图片

<5>

Leetcode 01-算法入门与数组-③数组排序_第12张图片

<6>

Leetcode 01-算法入门与数组-③数组排序_第13张图片

<7>

Leetcode 01-算法入门与数组-③数组排序_第14张图片

3. 选择排序代码实现

class Solution:
    def selectionSort(self, nums: [int]) -> [int]:
        for i in range(len(nums) - 1):
            # 记录未排序区间中最小值的位置
            min_i = i
            for j in range(i + 1, len(nums)):
                if nums[j] < nums[min_i]:
                    min_i = j
            # 如果找到最小值的位置,将 i 位置上元素与最小值位置上的元素进行交换
            if i != min_i:
                nums[i], nums[min_i] = nums[min_i], nums[i]
        return nums

    def sortArray(self, nums: [int]) -> [int]:
        return self.selectionSort(nums)

4. 选择排序算法分析

  • 时间复杂度 O ( n 2 ) O(n^2) O(n2)。排序法所进行的元素之间的比较次数与序列的原始状态无关,时间复杂度总是 O ( n 2 ) O(n^2) O(n2)
    • 这是因为无论序列中元素的初始排列状态如何,第 i i i 趟排序要找出值最小元素都需要进行 n − i n − i ni 次元素之间的比较。因此,整个排序过程需要进行的元素之间的比较次数都相同,为 ∑ i = 2 n ( i − 1 ) = n ( n − 1 ) 2 ∑^n_{i=2}(i - 1) = \frac{n(n−1)}{2} i=2n(i1)=2n(n1) 次。
  • 空间复杂度 O ( 1 ) O(1) O(1)。选择排序算法为原地排序算法,只用到指针变量 i i i j j j 以及最小值位置 m i n ‾ i min\underline{}i mini 等常数项的变量。
  • 选择排序适用情况:选择排序方法在排序过程中需要移动较多次数的元素,并且排序时间效率比较低。因此,选择排序方法比较适合于参加排序序列的数据量较小的情况。选择排序的主要优点是仅需要原地操作无需占用其他空间就可以完成排序,因此在空间复杂度要求较高时,可以考虑选择排序。
  • 排序稳定性:由于值最小元素与未排序区间第 1 1 1 个元素的交换动作是在不相邻的元素之间进行的,因此很有可能会改变相等元素的相对顺序,因此,选择排序法是一种 不稳定排序算法

三. 插入排序

1. 插入排序算法思想

插入排序(Insertion Sort)基本思想

将数组分为两个区间:左侧为有序区间,右侧为无序区间。每趟从无序区间取出一个元素,然后将其插入到有序区间的适当位置。

插入排序在每次插入一个元素时,该元素会在有序区间找到合适的位置,因此每次插入后,有序区间都会保持有序。

2. 插入排序算法步骤

假设数组的元素个数为 n n n 个,则插入排序的算法步骤如下:

  1. 初始状态下,有序区间为 [ 0 , 0 ] [0, 0] [0,0],无序区间为 [ 1 , n − 1 ] [1, n - 1] [1,n1]
  2. 1 1 1 趟插入:
    1. 取出无序区间 [ 1 , n − 1 ] [1, n - 1] [1,n1] 中的第 1 1 1 个元素,即 n u m s [ 1 ] nums[1] nums[1]
    2. 从右到左遍历有序区间中的元素,将比 n u m s [ 1 ] nums[1] nums[1] 小的元素向后移动 1 1 1 位。
    3. 如果遇到大于或等于 n u m s [ 1 ] nums[1] nums[1] 的元素时,说明找到了插入位置,将 n u m s [ 1 ] nums[1] nums[1] 插入到该位置。
    4. 插入元素后有序区间变为 [ 0 , 1 ] [0, 1] [0,1],无序区间变为 [ 2 , n − 1 ] [2, n - 1] [2,n1]
  3. 2 2 2 趟插入:
    1. 取出无序区间 [ 2 , n − 1 ] [2, n - 1] [2,n1] 中的第 1 1 1 个元素,即 n u m s [ 2 ] nums[2] nums[2]
    2. 从右到左遍历有序区间中的元素,将比 n u m s [ 2 ] nums[2] nums[2] 小的元素向后移动 1 1 1 位。
    3. 如果遇到大于或等于 n u m s [ 2 ] nums[2] nums[2] 的元素时,说明找到了插入位置,将 n u m s [ 2 ] nums[2] nums[2] 插入到该位置。
    4. 插入元素后有序区间变为 [ 0 , 2 ] [0, 2] [0,2],无序区间变为 [ 3 , n − 1 ] [3, n - 1] [3,n1]
  4. 依次类推,对剩余无序区间中的元素重复上述插入过程,直到所有元素都插入到有序区间中,排序结束。

我们以 [ 5 , 2 , 3 , 6 , 1 , 4 ] [5, 2, 3, 6, 1, 4] [5,2,3,6,1,4] 为例,演示一下插入排序的整个过程。

Leetcode 01-算法入门与数组-③数组排序_第15张图片

3. 插入排序代码实现

class Solution:
    def insertionSort(self, nums: [int]) -> [int]:
        # 遍历无序区间
        for i in range(1, len(nums)):
            temp = nums[i]
            j = i
            # 从右至左遍历有序区间
            while j > 0 and nums[j - 1] > temp:
                # 将有序区间中插入位置右侧的元素依次右移一位
                nums[j] = nums[j - 1]
                j -= 1
            # 将该元素插入到适当位置
            nums[j] = temp

        return nums

    def sortArray(self, nums: [int]) -> [int]:
        return self.insertionSort(nums)

4. 插入排序算法分析

  • 最佳时间复杂度 O ( n ) O(n) O(n)。最好的情况下(初始时区间已经是升序排列),每个元素只进行一次元素之间的比较,因而总的比较次数最少,为 ∑ i = 2 n 1 = n − 1 ∑^n_{i = 2}1 = n − 1 i=2n1=n1,并不需要移动元素(记录),这是最好的情况。
  • 最差时间复杂度 O ( n 2 ) O(n^2) O(n2)。最差的情况下(初始时区间已经是降序排列),每个元素 n u m s [ i ] nums[i] nums[i] 都要进行 i − 1 i - 1 i1 次元素之间的比较,元素之间总的比较次数达到最大值,为 ∑ i = 2 n ( i − 1 ) = n ( n − 1 ) 2 ∑^n_{i=2}(i − 1) = \frac{n(n−1)}{2} i=2n(i1)=2n(n1)
  • 平均时间复杂度 O ( n 2 ) O(n^2) O(n2)。如果区间的初始情况是随机的,即参加排序的区间中元素可能出现的各种排列的概率相同,则可取上述最小值和最大值的平均值作为插入排序时所进行的元素之间的比较次数,约为 n 2 4 \frac{n^2}{4} 4n2。由此得知,插入排序算法的平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)
  • 空间复杂度 O ( 1 ) O(1) O(1)。插入排序算法为原地排序算法,只用到指针变量 i i i j j j 以及表示无序区间中第 1 1 1 个元素的变量等常数项的变量。
  • 排序稳定性:在插入操作过程中,每次都讲元素插入到相等元素的右侧,并不会改变相等元素的相对顺序。因此,插入排序方法是一种 稳定排序算法

四. 练习题目1

1. 剑指 Offer 45. 把数组排成最小的数

1.1 题目大意

描述:给定一个非负整数数组 nums

要求:将数组中的数字拼接起来排成一个数,打印能拼接出的所有数字中的最小的一个。

说明

  • 0 < n u m s . l e n g t h ≤ 100 0 < nums.length \le 100 0<nums.length100
  • 输出结果可能非常大,所以你需要返回一个字符串而不是整数。
  • 拼接起来的数字可能会有前导 0,最后结果不需要去掉前导 0

示例

输入:[3,30,34,5,9]
输出:"3033459"

1.2 解题思路

思路 1:自定义排序

本质上是给数组进行排序。假设 xy 是数组 nums 中的两个元素。则排序的判断规则如下所示:

  • 如果拼接字符串 x + y > y + x,则 x 大于 y y 应该排在 x 前面,从而使拼接起来的数字尽可能的小。
  • 反之,如果拼接字符串 x + y < y + x,则 x 小于 y x 应该排在 y 前面,从而使拼接起来的数字尽可能的小。

按照上述规则,对原数组进行排序。这里使用了 functools.cmp_to_key 自定义排序函数。

思路 1:自定义排序代码

from functools import cmp_to_key

class Solution:
    def minNumber(self, nums: List[int]) -> str:
        nums = [*map(str, nums)]
        nums.sort(key=cmp_to_key(lambda x, y: - (x + y < y + x)))
        return "".join(nums)

思路 1:复杂度分析

  • 时间复杂度 O ( n × log ⁡ 2 n ) O(n \times \log_2n) O(n×log2n)。排序算法的时间复杂度为 O ( n × log ⁡ 2 n ) O(n \times \log_2n) O(n×log2n)
  • 空间复杂度 O ( 1 ) O(1) O(1)

2. 0283. 移动零

2.1 题目大意

描述:给定一个数组 nums

要求:将所有 0 移动到末尾,并保持原有的非 0 数字的相对顺序。

说明

  • 只能在原数组上进行操作。
  • 1 ≤ n u m s . l e n g t h ≤ 1 0 4 1 \le nums.length \le 10^4 1nums.length104
  • − 2 31 ≤ n u m s [ i ] ≤ 2 31 − 1 -2^{31} \le nums[i] \le 2^{31} - 1 231nums[i]2311

示例

输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]


输入: nums = [0]
输出: [0]

2.2 解题思路

思路 1:快慢指针

  1. 使用两个指针 slowfastslow 指向处理好的非 0 数字数组的尾部,fast 指针指向当前待处理元素。
  2. 不断向右移动 fast 指针,每次移动到非零数,则将左右指针对应的数交换,交换同时将 slow 右移。
  3. 此时,slow 指针左侧均为处理好的非零数,而从 slow 指针指向的位置开始, fast 指针左边为止都为 0

遍历结束之后,则所有 0 都移动到了右侧,且保持了非零数的相对位置。

思路 1:代码

class Solution:
    def moveZeroes(self, nums: List[int]) -> None:
        s = 0
        for f in range(len(nums)):
            if nums[f]:
                if f - s:
                    nums[s] = nums[f]
                    nums[f] = 0
                s += 1

思路 1:复杂度分析

  • 时间复杂度 O ( n ) O(n) O(n)
  • 空间复杂度 O ( 1 ) O(1) O(1)

3. 0912. 排序数组

3.1 题目大意

描述:给定一个整数数组 nums

要求:将该数组升序排列。

说明

  • 1 ≤ n u m s . l e n g t h ≤ 5 ∗ 1 0 4 1 \le nums.length \le 5 * 10^4 1nums.length5104
  • − 5 ∗ 1 0 4 ≤ n u m s [ i ] ≤ 5 ∗ 1 0 4 -5 * 10^4 \le nums[i] \le 5 * 10^4 5104nums[i]5104

示例

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


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

3.2 解题思路

思路 1:真 · 快速排序

真 · 快速排序基本思想

  • 调用API

思路 1:代码

class Solution:
    def sortArray(self, nums: List[int]) -> List[int]:
        nums.sort()
        return nums

思路 1:复杂度分析

  • 时间复杂度 O ( n × log ⁡ 2 n ) O(n \times \log_2 n) O(n×log2n)
  • 空间复杂度 O ( n ) O(n) O(n)

五. 归并排序

1. 归并排序算法思想

归并排序(Merge Sort)基本思想

采用经典的分治策略,先递归地将当前数组平均分成两半,然后将有序数组两两合并,最终合并成一个有序数组。

2. 归并排序算法步骤

假设数组的元素个数为 n n n 个,则归并排序的算法步骤如下:

  1. 分解过程:先递归地将当前数组平均分成两半,直到子数组长度为 1 1 1
    1. 找到数组中心位置 m i d mid mid,从中心位置将数组分成左右两个子数组 l e f t ‾ n u m s left\underline{}nums leftnums r i g h t ‾ n u m s right\underline{}nums rightnums
    2. 对左右两个子数组 l e f t ‾ n u m s left\underline{}nums leftnums r i g h t ‾ n u m s right\underline{}nums rightnums 分别进行递归分解。
    3. 最终将数组分解为 n n n 个长度均为 1 1 1 的有序子数组。
  2. 归并过程:从长度为 1 1 1 的有序子数组开始,依次将有序数组两两合并,直到合并成一个长度为 n n n 的有序数组。
    1. 使用数组变量 n u m s nums nums 存放合并后的有序数组。
    2. 使用两个指针 l e f t ‾ i left\underline{}i lefti r i g h t ‾ i right\underline{}i righti 分别指向两个有序子数组 l e f t ‾ n u m s left\underline{}nums leftnums r i g h t ‾ n u m s right\underline{}nums rightnums 的开始位置。
    3. 比较两个指针指向的元素,将两个有序子数组中较小元素依次存入到结果数组 n u m s nums nums 中,并将指针移动到下一位置。
    4. 重复步骤 3 3 3,直到某一指针到达子数组末尾。
    5. 将另一个子数组中的剩余元素存入到结果数组 n u m s nums nums 中。
    6. 返回合并后的有序数组 n u m s nums nums

我们以 [ 0 , 5 , 7 , 3 , 1 , 6 , 8 , 4 ] [0, 5, 7, 3, 1, 6, 8, 4] [0,5,7,3,1,6,8,4] 为例,演示一下归并排序的整个过程。

Leetcode 01-算法入门与数组-③数组排序_第16张图片

3. 归并排序代码实现

class Solution:
    # 合并过程
    def merge(self, left_nums: [int], right_nums: [int]):
        nums = []
        left_i, right_i = 0, 0
        while left_i < len(left_nums) and right_i < len(right_nums):
            # 将两个有序子数组中较小元素依次插入到结果数组中
            if left_nums[left_i] < right_nums[right_i]:
                nums.append(left_nums[left_i])
                left_i += 1
            else:
                nums.append(right_nums[right_i])
                right_i += 1
        
        # 如果左子数组有剩余元素,则将其插入到结果数组中
        while left_i < len(left_nums):
            nums.append(left_nums[left_i])
            left_i += 1
        
        # 如果右子数组有剩余元素,则将其插入到结果数组中
        while right_i < len(right_nums):
            nums.append(right_nums[right_i])
            right_i += 1
        
        # 返回合并后的结果数组
        return nums

    # 分解过程
    def mergeSort(self, nums: [int]) -> [int]:
        # 数组元素个数小于等于 1 时,直接返回原数组
        if len(nums) <= 1:
            return nums
        
        mid = len(nums) // 2                        # 将数组从中间位置分为左右两个数组
        left_nums = self.mergeSort(nums[0: mid])    # 递归将左子数组进行分解和排序
        right_nums =  self.mergeSort(nums[mid:])    # 递归将右子数组进行分解和排序
        return self.merge(left_nums, right_nums)    # 把当前数组组中有序子数组逐层向上,进行两两合并

    def sortArray(self, nums: [int]) -> [int]:
        return self.mergeSort(nums)

4. 归并排序算法分析

  • 时间复杂度 O ( n × log ⁡ n ) O(n \times \log n) O(n×logn)。归并排序算法的时间复杂度等于归并趟数与每一趟归并的时间复杂度乘积。子算法 merge(left_nums, right_nums): 的时间复杂度是 O ( n ) O(n) O(n),因此,归并排序算法总的时间复杂度为 O ( n × log ⁡ n ) O(n \times \log n) O(n×logn)
  • 空间复杂度 O ( n ) O(n) O(n)。归并排序方法需要用到与参加排序的数组同样大小的辅助空间。因此,算法的空间复杂度为 O ( n ) O(n) O(n)
  • 排序稳定性:因为在两个有序子数组的归并过程中,如果两个有序数组中出现相等元素,merge(left_nums, right_nums): 算法能够使前一个数组中那个相等元素先被复制,从而确保这两个元素的相对顺序不发生改变。因此,归并排序算法是一种 稳定排序算法

六. 希尔排序

1. 希尔排序算法思想

希尔排序(Shell Sort)基本思想

将整个数组切按照一定的间隔取值划分为若干个子数组,每个子数组分别进行插入排序。然后逐渐缩小间隔进行下一轮划分子数组和对子数组进行插入排序。直至最后一轮排序间隔为 1 1 1,对整个数组进行插入排序。

2. 希尔排序算法步骤

假设数组的元素个数为 n n n 个,则希尔排序的算法步骤如下:

  1. 确定一个元素间隔数 g a p gap gap
  2. 将参加排序的数组按此间隔数从第 1 1 1 个元素开始一次分成若干个子数组,即分别将所有位置相隔为 g a p gap gap 的元素视为一个子数组。
  3. 在各个子数组中采用某种排序算法(例如插入排序算法)进行排序。
  4. 减少间隔数,并重新将整个数组按新的间隔数分成若干个子数组,再分别对各个子数组进行排序。
  5. 依次类推,直到间隔数 g a p gap gap 值为 1 1 1,最后进行一次排序,排序结束。

我们以 [ 7 , 2 , 6 , 8 , 0 , 4 , 1 , 5 , 9 , 3 ] [7, 2, 6, 8, 0, 4, 1, 5, 9, 3] [7,2,6,8,0,4,1,5,9,3] 为例,演示一下希尔排序的整个过程。

<1>

Leetcode 01-算法入门与数组-③数组排序_第17张图片

<2>

Leetcode 01-算法入门与数组-③数组排序_第18张图片

<3>

Leetcode 01-算法入门与数组-③数组排序_第19张图片

<4>

Leetcode 01-算法入门与数组-③数组排序_第20张图片

<5>

Leetcode 01-算法入门与数组-③数组排序_第21张图片

<6>

Leetcode 01-算法入门与数组-③数组排序_第22张图片

<7>

Leetcode 01-算法入门与数组-③数组排序_第23张图片

3. 希尔排序代码实现

class Solution:
    def shellSort(self, nums: [int]) -> [int]:
        size = len(nums)
        gap = size // 2
        # 按照 gap 分组
        while gap > 0:
            # 对每组元素进行插入排序
            for i in range(gap, size):
                # temp 为每组中无序数组第 1 个元素
                temp = nums[i]
                j = i
                # 从右至左遍历每组中的有序数组元素
                while j >= gap and nums[j - gap] > temp:
                    # 将每组有序数组中插入位置右侧的元素依次在组中右移一位
                    nums[j] = nums[j - gap]
                    j -= gap
                # 将该元素插入到适当位置
                nums[j] = temp
            # 缩小 gap 间隔
            gap = gap // 2
        return nums

    def sortArray(self, nums: [int]) -> [int]:
        return self.shellSort(nums)

4. 希尔排序算法分析

  • 时间复杂度:介于 O ( n × log ⁡ 2 n ) O(n \times \log^2 n) O(n×log2n) O ( n 2 ) O(n^2) O(n2) 之间。

    • 希尔排序方法的速度是一系列间隔数 g a p i gap_i gapi 的函数,而比较次数与 g a p i gap_i gapi 之间的依赖关系比较复杂,不太容易给出完整的数学分析。
    • 本文采用 g a p i = ⌊ g a p i − 1 / 2 ⌋ gap_i = \lfloor gap_{i-1}/2 \rfloor gapi=gapi1/2 的方法缩小间隔数,对于具有 n n n 个元素的数组,如果 g a p 1 = ⌊ n / 2 ⌋ gap_1 = \lfloor n/2 \rfloor gap1=n/2,则经过 p = ⌊ log ⁡ 2 n ⌋ p = \lfloor \log_2 n \rfloor p=log2n 趟排序后就有 g a p p = 1 gap_p = 1 gapp=1,因此,希尔排序方法的排序总躺数为 ⌊ log ⁡ 2 n ⌋ \lfloor \log_2 n \rfloor log2n
    • 从算法中也可以看到,外层 while gap > 0 的循环次数为 log ⁡ n \log n logn 数量级,内层插入排序算法循环次数为 n n n 数量级。当子数组分得越多时,子数组内的元素就越少,内层循环的次数也就越少;反之,当所分的子数组个数减少时,子数组内的元素也随之增多,但整个数组也逐步接近有序,而循环次数却不会随之增加。因此,希尔排序算法的时间复杂度在 O ( n × log ⁡ 2 n ) O(n \times \log^2 n) O(n×log2n) O ( n 2 ) O(n^2) O(n2) 之间。
  • 空间复杂度 O ( 1 ) O(1) O(1)。希尔排序中用到的插入排序算法为原地排序算法,只用到指针变量 i i i j j j 以及表示无序区间中第 1 1 1 个元素的变量、间隔数 g a p gap gap 等常数项的变量。

  • 排序稳定性:在一次插入排序是稳定的,不会改变相等元素的相对顺序,但是在不同的插入排序中,相等元素可能在各自的插入排序中移动。因此,希尔排序方法是一种 不稳定排序算法

七. 练习题目2

4. 0506. 相对名次

4.1 题目大意

描述:给定一个长度为 n 的数组 score。其中 score[i] 表示第 i 名运动员在比赛中的成绩。所有成绩互不相同。

要求:找出他们的相对名次,并授予前三名对应的奖牌。前三名运动员将会被分别授予「金牌(Gold Medal)」,「银牌(Silver Medal)」和「铜牌(Bronze Medal)」。

说明

  • n = = s c o r e . l e n g t h n == score.length n==score.length
  • 1 ≤ n ≤ 1 0 4 1 \le n \le 10^4 1n104
  • 0 ≤ s c o r e [ i ] ≤ 1 0 6 0 \le score[i] \le 10^6 0score[i]106
  • score 中的所有值互不相同。

示例

输入:score = [5,4,3,2,1]
输出:["Gold Medal","Silver Medal","Bronze Medal","4","5"]
解释:名次为 [1st, 2nd, 3rd, 4th, 5th] 。


输入:score = [10,3,8,9,4]
输出:["Gold Medal","5","Bronze Medal","Silver Medal","4"]
解释:名次为 [1st, 5th, 3rd, 2nd, 4th]

4.2 解题思路

思路 1:排序

  1. 先对数组 score 进行排序。
  2. 再将对应前三个位置上的元素替换成对应的字符串:Gold Medal, Silver Medal, Bronze Medal

思路 1:代码

class Solution:
    def findRelativeRanks(self, score: List[int]) -> List[str]:
        mark=("Gold Medal", "Silver Medal", "Bronze Medal")
        for i, j in enumerate(sorted(range(len(score)), key=lambda x: -score[x])):
            score[j] = str(i + 1) if i > 2 else mark[i]
        return score

思路 1:复杂度分析

  • 时间复杂度 O ( n × log ⁡ 2 n ) O(n \times \log_2n) O(n×log2n)。因为采用了时间复杂度为 O ( n × log ⁡ 2 n ) O(n \times \log_2n) O(n×log2n) 的快速排序。
  • 空间复杂度 O ( n ) O(n) O(n)

5. 0088. 合并两个有序数组

5.1 题目大意

描述:给定两个有序数组 n u m s 1 nums1 nums1 n u m s 2 nums2 nums2

要求:将 n u m s 2 nums2 nums2 合并到 n u m s 1 nums1 nums1 中,使 n u m s 1 nums1 nums1 成为一个有序数组。

说明

  • 给定数组 n u m s 1 nums1 nums1 空间大小为 m + n m + n m+n 个,其中前 m m m 个为 n u m s 1 nums1 nums1 的元素。 n u m s 2 nums2 nums2 空间大小为 n n n。这样可以用 n u m s 1 nums1 nums1 的空间来存储最终的有序数组。
  • n u m s 1. l e n g t h = = m + n nums1.length == m + n nums1.length==m+n
  • n u m s 2. l e n g t h = = n nums2.length == n nums2.length==n
  • 0 ≤ m , n ≤ 200 0 \le m, n \le 200 0m,n200
  • 1 ≤ m + n ≤ 200 1 \le m + n \le 200 1m+n200
  • − 1 0 9 ≤ n u m s 1 [ i ] , n u m s 2 [ j ] ≤ 1 0 9 -10^9 \le nums1[i], nums2[j] \le 10^9 109nums1[i],nums2[j]109

示例

  • 示例 1:
输入:nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出:[1,2,2,3,5,6]
解释:需要合并 [1,2,3][2,5,6] 。
合并结果是 [1,2,2,3,5,6] ,其中斜体加粗标注的为 nums1 中的元素。
  • 示例 2:
输入:nums1 = [1], m = 1, nums2 = [], n = 0
输出:[1]
解释:需要合并 [1][] 。
合并结果是 [1]

5.2 解题思路

思路 1:快慢指针

  1. 将两个指针 index1index2 分别指向 nums1nums2 数组的尾部,再用一个指针 index 指向数组 nums1 的尾部。
  2. 从后向前判断当前指针下 nums1[index1]nums[index2] 的值大小,将较大值存入 num1[index] 中,然后继续向前遍历。
  3. 最后再将 nums2 中剩余元素赋值到 num1 前面对应位置上。

思路 1:代码

class Solution:
    def merge(self, nums1: List[int], m: int, nums2: List[int], n: int) -> None:
        m, n = m - 1, 1
        while nums2:
            if m >= 0 and nums1[m] > nums2[-1]:
                nums1[-n] = nums1[m]
                m -= 1
            else:
                nums1[-n] = nums2.pop()
            n += 1

思路 1:复杂度分析

  • 时间复杂度 O ( m + n ) O(m + n) O(m+n)
  • 空间复杂度 O ( m + n ) O(m + n) O(m+n)

6. 剑指 Offer 51. 数组中的逆序对

6.1 题目大意

描述:给定一个数组 nums

要求:计算出数组中的逆序对的总数。

说明

  • 逆序对:在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。
  • 0 ≤ n u m s . l e n g t h ≤ 50000 0 \le nums.length \le 50000 0nums.length50000

示例

输入: [7,5,6,4]
输出: 5

6.2 解题思路

思路 1:树状数组

数组 tree[i] 表示数字 i 是否在序列中出现过,如果数字 i 已经存在于序列中,tree[i] = 1,否则 tree[i] = 0

  1. 按序列从左到右将值为 nums[i] 的元素当作下标为nums[i],赋值为 1 插入树状数组里,这时,比 nums[i] 大的数个数就是 i + 1 - query(a)
  2. 将全部结果累加起来就是逆序数了。

思路 1:代码

import bisect

class BinaryIndexTree:

    def __init__(self, n):
        self.size = n
        self.tree = [0 for _ in range(n + 1)]

    def lowbit(self, index):
        return index & (-index)

    def update(self, index, delta):
        while index <= self.size:
            self.tree[index] += delta
            index += self.lowbit(index)

    def query(self, index):
        res = 0
        while index > 0:
            res += self.tree[index]
            index -= self.lowbit(index)
        return res

class Solution:
    def reversePairs(self, nums: List[int]) -> int:
        size = len(nums)
        sort_nums = sorted(nums)
        for i in range(size):
            nums[i] = bisect.bisect_left(sort_nums, nums[i]) + 1

        bit = BinaryIndexTree(size)
        ans = 0
        for i in range(size):
            bit.update(nums[i], 1)
            ans += (i + 1 - bit.query(nums[i]))
        return ans

思路 1:复杂度分析

  • 时间复杂度 O ( n × log ⁡ 2 n ) O(n \times \log_2n) O(n×log2n)
  • 空间复杂度 O ( n ) O(n) O(n)

八. 快速排序

1. 快速排序算法思想

快速排序(Quick Sort)基本思想

采用经典的分治策略,选择数组中某个元素作为基准数,通过一趟排序将数组分为独立的两个子数组,一个子数组中所有元素值都比基准数小,另一个子数组中所有元素值都比基准数大。然后再按照同样的方式递归的对两个子数组分别进行快速排序,以达到整个数组有序。

2. 快速排序算法步骤

假设数组的元素个数为 n n n 个,则快速排序的算法步骤如下:

  1. 哨兵划分:选取一个基准数,将数组中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧。
    1. 从当前数组中找到一个基准数 p i v o t pivot pivot(这里以当前数组第 1 1 1 个元素作为基准数,即 p i v o t = n u m s [ l o w ] pivot = nums[low] pivot=nums[low])。
    2. 使用指针 i i i 指向数组开始位置,指针 j j j 指向数组末尾位置。
    3. 从右向左移动指针 j j j,找到第 1 1 1 个小于基准值的元素。
    4. 从左向右移动指针 i i i,找到第 1 1 1 个大于基准数的元素。
    5. 交换指针 i i i、指针 j j j 指向的两个元素位置。
    6. 重复第 3 ∼ 5 3 \sim 5 35 步,直到指针 i i i 和指针 j j j 相遇时停止,最后将基准数放到两个子数组交界的位置上。
  2. 递归分解:完成哨兵划分之后,对划分好的左右子数组分别进行递归排序。
    1. 按照基准数的位置将数组拆分为左右两个子数组。
    2. 对每个子数组分别重复「哨兵划分」和「递归分解」,直到各个子数组只有 1 1 1 个元素,排序结束。

我们以 [ 4 , 7 , 5 , 2 , 6 , 1 , 3 ] [4, 7, 5, 2, 6, 1, 3] [4,7,5,2,6,1,3] 为例,演示一下快速排序的整个步骤。

我们先来看一下单次「哨兵划分」的过程。

<1>

Leetcode 01-算法入门与数组-③数组排序_第24张图片

<2>

Leetcode 01-算法入门与数组-③数组排序_第25张图片

<3>

Leetcode 01-算法入门与数组-③数组排序_第26张图片

<4>

Leetcode 01-算法入门与数组-③数组排序_第27张图片

<5>

Leetcode 01-算法入门与数组-③数组排序_第28张图片

<6>

Leetcode 01-算法入门与数组-③数组排序_第29张图片

<7>

Leetcode 01-算法入门与数组-③数组排序_第30张图片

在经过一次「哨兵划分」过程之后,数组就被划分为左子数组、基准数、右子树组三个独立部分。接下来只要对划分好的左右子数组分别进行递归排序即可完成排序。整个步骤如下:

Leetcode 01-算法入门与数组-③数组排序_第31张图片

3. 快速排序代码实现

import random

class Solution:
    # 随机哨兵划分:从 nums[low: high + 1] 中随机挑选一个基准数,并进行移位排序
    def randomPartition(self, nums: [int], low: int, high: int) -> int:
        # 随机挑选一个基准数
        i = random.randint(low, high)
        # 将基准数与最低位互换
        nums[i], nums[low] = nums[low], nums[i]
        # 以最低位为基准数,然后将数组中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧。最后将基准数放到正确位置上
        return self.partition(nums, low, high)
    
    # 哨兵划分:以第 1 位元素 nums[low] 为基准数,然后将比基准数小的元素移动到基准数左侧,将比基准数大的元素移动到基准数右侧,最后将基准数放到正确位置上
    def partition(self, nums: [int], low: int, high: int) -> int:
        # 以第 1 位元素为基准数
        pivot = nums[low]
        
        i, j = low, high
        while i < j:
            # 从右向左找到第 1 个小于基准数的元素
            while i < j and nums[j] >= pivot:
                j -= 1
            # 从左向右找到第 1 个大于基准数的元素
            while i < j and nums[i] <= pivot:
                i += 1
            # 交换元素
            nums[i], nums[j] = nums[j], nums[i]
        
        # 将基准节点放到正确位置上
        nums[i], nums[low] = nums[low], nums[i]
        # 返回基准数的索引
        return i

    def quickSort(self, nums: [int], low: int, high: int) -> [int]:
        if low < high:
            # 按照基准数的位置,将数组划分为左右两个子数组
            pivot_i = self.randomPartition(nums, low, high)
            # 对左右两个子数组分别进行递归快速排序
            self.quickSort(nums, low, pivot_i - 1)
            self.quickSort(nums, pivot_i + 1, high)

        return nums

    def sortArray(self, nums: [int]) -> [int]:
        return self.quickSort(nums, 0, len(nums) - 1)

4. 快速排序算法分析

快速排序算法的时间复杂度主要跟基准数的选择有关。本文中是将当前数组中第 1 1 1 个元素作为基准值。

在这种选择下,如果参加排序的元素初始时已经有序的情况下,快速排序方法花费的时间最长。也就是会得到最坏时间复杂度。

在这种情况下,第 1 1 1 趟排序经过 n − 1 n - 1 n1 次比较以后,将第 1 1 1 个元素仍然确定在原来的位置上,并得到 1 1 1 个长度为 n − 1 n - 1 n1 的子数组。第 2 2 2 趟排序进过 n − 2 n - 2 n2 次比较以后,将第 2 2 2 个元素确定在它原来的位置上,又得到 1 1 1 个长度为 n − 2 n - 2 n2 的子数组。

最终总的比较次数为 ( n − 1 ) + ( n − 2 ) + … + 1 = n ( n − 1 ) 2 (n − 1) + (n − 2) + … + 1 = \frac{n(n − 1)}{2} (n1)+(n2)++1=2n(n1)。因此这种情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2),也是最坏时间复杂度。

我们可以改进一下基准数的选择。如果每次我们选中的基准数恰好能将当前数组平分为两份,也就是刚好取到当前数组的中位数。

在这种选择下,每一次都将数组从 n n n 个元素变为 n 2 \frac{n}{2} 2n 个元素。此时的时间复杂度公式为 T ( n ) = 2 × T ( n 2 ) + Θ ( n ) T(n) = 2 \times T(\frac{n}{2}) + \Theta(n) T(n)=2×T(2n)+Θ(n)。根据主定理可以得出 T ( n ) = O ( n × log ⁡ n ) T(n) = O(n \times \log n) T(n)=O(n×logn),也是最佳时间复杂度。

而在平均情况下,我们可以从当前数组中随机选择一个元素作为基准数。这样,每一次选择的基准数可以看做是等概率随机的。其期望时间复杂度为 O ( n × log ⁡ n ) O(n \times \log n) O(n×logn),也就是平均时间复杂度。

下面来总结一下:

  • 最佳时间复杂度 O ( n × log ⁡ n ) O(n \times \log n) O(n×logn)。每一次选择的基准数都是当前数组的中位数,此时算法时间复杂度满足的递推式为 T ( n ) = 2 × T ( n 2 ) + Θ ( n ) T(n) = 2 \times T(\frac{n}{2}) + \Theta(n) T(n)=2×T(2n)+Θ(n),由主定理可得 T ( n ) = O ( n × log ⁡ n ) T(n) = O(n \times \log n) T(n)=O(n×logn)
  • 最坏时间复杂度 O ( n 2 ) O(n^2) O(n2)。每一次选择的基准数都是数组的最终位置上的值,此时算法时间复杂度满足的递推式为 T ( n ) = T ( n − 1 ) + Θ ( n ) T(n) = T(n - 1) + \Theta(n) T(n)=T(n1)+Θ(n),累加可得 T ( n ) = O ( n 2 ) T(n) = O(n^2) T(n)=O(n2)
  • 平均时间复杂度 O ( n × log ⁡ n ) O(n \times \log n) O(n×logn)。在平均情况下,每一次选择的基准数可以看做是等概率随机的。其期望时间复杂度为 O ( n × log ⁡ n ) O(n \times \log n) O(n×logn)
  • 空间复杂度 O ( n ) O(n) O(n)。无论快速排序算法递归与否,排序过程中都需要用到堆栈或其他结构的辅助空间来存放当前待排序数组的首、尾位置。最坏的情况下,空间复杂度为 O ( n ) O(n) O(n)。如果对算法进行一些改写,在一趟排序之后比较被划分所得到的两个子数组的长度,并且首先对长度较短的子数组进行快速排序,这时候需要的空间复杂度可以达到 O ( l o g 2 n ) O(log_2 n) O(log2n)
  • 排序稳定性:在进行哨兵划分时,基准数可能会被交换至相等元素的右侧。因此,快速排序是一种 不稳定排序算法

九. 堆排序

1. 堆结构

「堆排序(Heap sort)」是一种基于「堆结构」实现的高效排序算法。在介绍「堆排序」之前,我们先来了解一下什么是「堆结构」。

1.1 堆的定义

堆(Heap):一种满足以下两个条件之一的完全二叉树:

  • 大顶堆(Max Heap):任意节点值 ≥ 其子节点值。
  • 小顶堆(Min Heap):任意节点值 ≤ 其子节点值。

Leetcode 01-算法入门与数组-③数组排序_第32张图片

1.2 堆的存储结构

堆的逻辑结构就是一颗完全二叉树。而我们在「07.树 - 01.二叉树 - 01.树与二叉树的基础知识」章节中学过,对于完全二叉树(尤其是满二叉树)来说,采用顺序存储结构(数组)的形式来表示完全二叉树,能够充分利用存储空间。

当我们使用顺序存储结构(即数组)来表示堆时,堆中元素的节点编号与数组的索引关系为:

  • 如果某二叉树节点(非叶子节点)的下标为 i i i,那么其左孩子节点下标为 2 × i + 1 2 \times i + 1 2×i+1,右孩子节点下标为 2 × i + 2 2 \times i + 2 2×i+2
  • 如果某二叉树节点(非根结点)的下标为 i i i,那么其根节点下标为 ⌊ i − 1 2 ⌋ \lfloor \frac{i - 1}{2} \rfloor 2i1(向下取整)。
class MaxHeap:
    def __init__(self):
        self.max_heap = []

Leetcode 01-算法入门与数组-③数组排序_第33张图片

1.3 访问堆顶元素

访问堆顶元素:指的是从堆结构中获取位于堆顶的元素。

在堆中,堆顶元素位于根节点,当我们使用顺序存储结构(即数组)来表示堆时,堆顶元素就是数组的首个元素。

class MaxHeap:
    ......
    def peek(self) -> int:
        # 大顶堆为空
        if not self.max_heap:
            return None
        # 返回堆顶元素
        return self.max_heap[0]

访问堆顶元素不依赖于数组中元素个数,因此时间复杂度为 O ( 1 ) O(1) O(1)

1.4 向堆中插入元素

向堆中插入元素:指的将一个新的元素添加到堆中,调整堆结构,以保持堆的特性不变。

向堆中插入元素的步骤如下:

  1. 将新元素添加到堆的末尾,保持完全二叉树的结构。
  2. 从新插入的元素节点开始,将该节点与其父节点进行比较。
    1. 如果新节点的值大于其父节点的值,则交换它们,以保持最大堆的特性。
    2. 如果新节点的值小于等于其父节点的值,说明已满足最大堆的特性,此时结束。
  3. 重复上述比较和交换步骤,直到新节点不再大于其父节点,或者达到了堆的根节点。

这个过程称为「上移调整(Shift Up)」。因为新插入的元素会逐步向堆的上方移动,直到找到了合适的位置,保持堆的有序性。

<1>

Leetcode 01-算法入门与数组-③数组排序_第34张图片

<2>

Leetcode 01-算法入门与数组-③数组排序_第35张图片

<3>

Leetcode 01-算法入门与数组-③数组排序_第36张图片

<4>

Leetcode 01-算法入门与数组-③数组排序_第37张图片

<5>

Leetcode 01-算法入门与数组-③数组排序_第38张图片

<6>

Leetcode 01-算法入门与数组-③数组排序_第39张图片

<7>

Leetcode 01-算法入门与数组-③数组排序_第40张图片

class MaxHeap:
    ......
    def push(self, val: int):
        # 将新元素添加到堆的末尾
        self.max_heap.append(val)
        
        size = len(self.max_heap)
        # 从新插入的元素节点开始,进行上移调整
        self.__shift_up(size - 1)
        
    def __shift_up(self, i: int):
        while (i - 1) // 2 >= 0 and self.max_heap[i] > self.max_heap[(i - 1) // 2]:
            self.max_heap[i], self.max_heap[(i - 1) // 2] = self.max_heap[(i - 1) // 2], self.max_heap[i]
            i = (i - 1) // 2

在最坏情况下,「向堆中插入元素」的时间复杂度为 O ( log ⁡ n ) O(\log n) O(logn),其中 n n n 是堆中元素的数量,这是因为堆的高度是 log ⁡ n \log n logn

1.5 删除堆顶元素

删除堆顶元素:指的是从堆中移除位于堆顶的元素,并重新调整对结果,以保持堆的特性不变。

删除堆顶元素的步骤如下:

  1. 将堆顶元素(即根节点)与堆的末尾元素交换。
  2. 移除堆末尾的元素(之前的堆顶),即将其从堆中剔除。
  3. 从新的堆顶元素开始,将其与其较大的子节点进行比较。
    1. 如果当前节点的值小于其较大的子节点,则将它们交换。这一步是为了将新的堆顶元素「下沉」到适当的位置,以保持最大堆的特性。
    2. 如果当前节点的值大于等于其较大的子节点,说明已满足最大堆的特性,此时结束。
  4. 重复上述比较和交换步骤,直到新的堆顶元素不再小于其子节点,或者达到了堆的底部。

这个过程称为「下移调整(Shift Down)」。因为新的堆顶元素会逐步向堆的下方移动,直到找到了合适的位置,保持堆的有序性。

<1>

Leetcode 01-算法入门与数组-③数组排序_第41张图片

<2>

Leetcode 01-算法入门与数组-③数组排序_第42张图片

<3>

Leetcode 01-算法入门与数组-③数组排序_第43张图片

<4>

Leetcode 01-算法入门与数组-③数组排序_第44张图片

<5>

Leetcode 01-算法入门与数组-③数组排序_第45张图片

<6>

Leetcode 01-算法入门与数组-③数组排序_第46张图片

<7>

Leetcode 01-算法入门与数组-③数组排序_第47张图片

class MaxHeap:
    ......        
    def pop(self) -> int:
        # 堆为空
        if not self.max_heap:
            raise IndexError("堆为空")
        
        size = len(self.max_heap)
        self.max_heap[0], self.max_heap[size - 1] = self.max_heap[size - 1], self.max_heap[0]
        # 删除堆顶元素
        val = self.max_heap.pop()
        # 节点数减 1
        size -= 1 
        
        # 下移调整
        self.__shift_down(0, size)
        
        # 返回堆顶元素
        return val

    
    def __shift_down(self, i: int, n: int):
        while 2 * i + 1 < n:
            # 左右子节点编号
            left, right = 2 * i + 1, 2 * i + 2
            
            # 找出左右子节点中的较大值节点编号
            if 2 * i + 2 >= n:
                # 右子节点编号超出范围(只有左子节点
                larger = left
            else:
                # 左子节点、右子节点都存在
                if self.max_heap[left] >= self.max_heap[right]:
                    larger = left
                else:
                    larger = right
            
            # 将当前节点值与其较大的子节点进行比较
            if self.max_heap[i] < self.max_heap[larger]:
                # 如果当前节点值小于其较大的子节点,则将它们交换
                self.max_heap[i], self.max_heap[larger] = self.max_heap[larger], self.max_heap[i]
                i = larger
            else:
                # 如果当前节点值大于等于于其较大的子节点,此时结束
                break

「删除堆顶元素」的时间复杂度通常为 O ( log ⁡ n ) O(\log n) O(logn),其中 n n n 是堆中元素的数量,因为堆的高度是 log ⁡ n \log n logn

2. 堆排序

2.1 堆排序算法思想

堆排序(Heap sort)基本思想

借用「堆结构」所设计的排序算法。将数组转化为大顶堆,重复从大顶堆中取出数值最大的节点,并让剩余的堆结构继续维持大顶堆性质。

2.2 堆排序算法步骤

  1. 构建初始大顶堆

    1. 定义一个数组实现的堆结构,将原始数组的元素依次存入堆结构的数组中(初始顺序不变)。
    2. 从数组的中间位置开始,从右至左,依次通过「下移调整」将数组转换为一个大顶堆。
  2. 交换元素,调整堆

    1. 交换堆顶元素(第 1 1 1 个元素)与末尾(最后 1 1 1 个元素)的位置,交换完成后,堆的长度减 1 1 1
    2. 交换元素之后,由于堆顶元素发生了改变,需要从根节点开始,对当前堆进行「下移调整」,使其保持堆的特性。
  3. 重复交换和调整堆

    1. 重复第 2 2 2 步,直到堆的大小为 1 1 1 时,此时大顶堆的数组已经完全有序。

2.2.1 构建初始大顶堆

<1>

Leetcode 01-算法入门与数组-③数组排序_第48张图片

<2>

Leetcode 01-算法入门与数组-③数组排序_第49张图片

<3>

Leetcode 01-算法入门与数组-③数组排序_第50张图片

<4>

Leetcode 01-算法入门与数组-③数组排序_第51张图片

<5>

Leetcode 01-算法入门与数组-③数组排序_第52张图片

<6>

Leetcode 01-算法入门与数组-③数组排序_第53张图片

<7>

Leetcode 01-算法入门与数组-③数组排序_第54张图片

2.2.2 交换元素,调整堆

<1>

Leetcode 01-算法入门与数组-③数组排序_第55张图片

<2>

Leetcode 01-算法入门与数组-③数组排序_第56张图片

<3>

Leetcode 01-算法入门与数组-③数组排序_第57张图片

<4>

Leetcode 01-算法入门与数组-③数组排序_第58张图片

<5>

Leetcode 01-算法入门与数组-③数组排序_第59张图片

<6>

Leetcode 01-算法入门与数组-③数组排序_第60张图片

<7>

Leetcode 01-算法入门与数组-③数组排序_第61张图片

<8>

Leetcode 01-算法入门与数组-③数组排序_第62张图片

<9>

Leetcode 01-算法入门与数组-③数组排序_第63张图片

<10>

Leetcode 01-算法入门与数组-③数组排序_第64张图片

<11>

Leetcode 01-算法入门与数组-③数组排序_第65张图片

<12>

在这里插入图片描述

2.3 堆排序代码实现

class MaxHeap:
    ......
    def __buildMaxHeap(self, nums: [int]):
        size = len(nums)
        # 先将数组 nums 的元素按顺序添加到 max_heap 中
        for i in range(size):
            self.max_heap.append(nums[i])
        
        # 从最后一个非叶子节点开始,进行下移调整
        for i in range((size - 2) // 2, -1, -1):
            self.__shift_down(i, size)

    def maxHeapSort(self, nums: [int]) -> [int]:
        # 根据数组 nums 建立初始堆
        self.__buildMaxHeap(nums)
        
        size = len(self.max_heap)
        for i in range(size - 1, -1, -1):
            # 交换根节点与当前堆的最后一个节点
            self.max_heap[0], self.max_heap[i] = self.max_heap[i], self.max_heap[0]
            # 从根节点开始,对当前堆进行下移调整
            self.__shift_down(0, i)
        
        # 返回排序后的数组
        return self.max_heap
    
class Solution:
    def maxHeapSort(self, nums: [int]) -> [int]:
        return MaxHeap().maxHeapSort(nums)
        
    def sortArray(self, nums: [int]) -> [int]:
        return self.maxHeapSort(nums)
    
print(Solution().sortArray([10, 25, 6, 8, 7, 1, 20, 23, 16, 19, 17, 3, 18, 14]))

2.4 堆排序算法分析

  • 时间复杂度 O ( n × log ⁡ n ) O(n \times \log n) O(n×logn)
    • 堆积排序的时间主要花费在两个方面:「建立初始堆」和「下移调整」。
    • 设原始数组所对应的完全二叉树深度为 d d d,算法由两个独立的循环组成:
      1. 在第 1 1 1 个循环构造初始堆积时,从 i = d − 1 i = d - 1 i=d1 层开始,到 i = 1 i = 1 i=1 层为止,对每个分支节点都要调用一次调整堆算法,而一次调整堆算法,对于第 i i i 层一个节点到第 d d d 层上建立的子堆积,所有节点可能移动的最大距离为该子堆积根节点移动到最后一层(第 d d d 层) 的距离,即 d − i d - i di。而第 i i i 层上节点最多有 2 i − 1 2^{i-1} 2i1 个,所以每一次调用调整堆算法的最大移动距离为 2 i − 1 ∗ ( d − i ) 2^{i-1} * (d-i) 2i1(di)。因此,堆积排序算法的第 1 1 1 个循环所需时间应该是各层上的节点数与该层上节点可移动的最大距离之积的总和,即: ∑ i = d − 1 1 2 i − 1 ( d − i ) = ∑ j = 1 d − 1 2 d − j − 1 × j = ∑ j = 1 d − 1 2 d − 1 × j 2 j ≤ n × ∑ j = 1 d − 1 j 2 j < 2 × n \sum_{i = d - 1}^1 2^{i-1} (d-i) = \sum_{j = 1}^{d-1} 2^{d-j-1} \times j = \sum_{j = 1}^{d-1} 2^{d-1} \times {j \over 2^j} \le n \times \sum_{j = 1}^{d-1} {j \over 2^j} < 2 \times n i=d112i1(di)=j=1d12dj1×j=j=1d12d1×2jjn×j=1d12jj<2×n。这一部分的时间花费为 O ( n ) O(n) O(n)
      2. 在第 2 2 2 个循环中,每次调用调整堆算法一次,节点移动的最大距离为这棵完全二叉树的深度 d = ⌊ log ⁡ 2 ( n ) ⌋ + 1 d = \lfloor \log_2(n) \rfloor + 1 d=log2(n)⌋+1,一共调用了 n − 1 n - 1 n1 次调整堆算法,所以,第 2 2 2 个循环的时间花费为 ( n − 1 ) ( ⌊ log ⁡ 2 ( n ) ⌋ + 1 ) = O ( n × log ⁡ n ) (n-1)(\lfloor \log_2 (n)\rfloor + 1) = O(n \times \log n) (n1)(⌊log2(n)⌋+1)=O(n×logn)
    • 因此,堆积排序的时间复杂度为 O ( n × log ⁡ n ) O(n \times \log n) O(n×logn)
  • 空间复杂度 O ( 1 ) O(1) O(1)。由于在堆积排序中只需要一个记录大小的辅助空间,因此,堆积排序的空间复杂度为: O ( 1 ) O(1) O(1)
  • 排序稳定性:在进行「下移调整」时,相等元素的相对位置可能会发生变化。因此,堆排序是一种 不稳定排序算法

十. 练习题目3

7. 0075. 颜色分类

7.1 题目大意

描述:给定一个数组 nums,元素值只有 012,分别代表红色、白色、蓝色。

要求:将数组进行排序,使得红色在前,白色在中间,蓝色在最后。

说明

  • 要求不使用标准库函数,同时仅用常数空间,一趟扫描解决。
  • n = = n u m s . l e n g t h n == nums.length n==nums.length
  • 1 ≤ n ≤ 300 1 \le n \le 300 1n300
  • nums[i]012

示例

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


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

7.2 解题思路

思路 1:双指针 + 快速排序思想

快速排序算法中的 partition 过程,利用双指针,将序列中比基准数 pivot 大的元素移动到了基准数右侧,将比基准数 pivot 小的元素移动到了基准数左侧。从而将序列分为了三部分:比基准数小的部分、基准数、比基准数大的部分。

这道题我们也可以借鉴快速排序算法中的 partition 过程,将 1 作为基准数 pivot,然后将序列分为三部分:0(即比 1 小的部分)、等于 1 的部分、2(即比 1 大的部分)。具体步骤如下:

  1. 使用两个指针 leftright,分别指向数组的头尾。left 表示当前处理好红色元素的尾部,right 表示当前处理好蓝色的头部。
  2. 再使用一个下标 index 遍历数组,如果遇到 nums[index] == 0,就交换 nums[index]nums[left],同时将 left 右移。如果遇到 nums[index] == 2,就交换 nums[index]nums[right],同时将 right 左移。
  3. 直到 index 移动到 right 位置之后,停止遍历。遍历结束之后,此时 left 左侧都是红色,right 右侧都是蓝色。

注意:移动的时候需要判断 indexleft 的位置,因为 left 左侧是已经处理好的数组,所以需要判断 index 的位置是否小于 left,小于的话,需要更新 index 位置。

思路 1:代码

class Solution:
    def sortColors(self, nums: List[int]) -> None:
        n = len(nums)
        p0, p2 = 0, n - 1
        i = 0
        while i <= p2:
            while i <= p2 and nums[i] == 2:
                nums[i], nums[p2] = nums[p2], nums[i]
                p2 -= 1
            if nums[i] == 0:
                nums[i], nums[p0] = nums[p0], nums[i]
                p0 += 1
            i += 1

思路 1:复杂度分析

  • 时间复杂度 O ( n ) O(n) O(n)
  • 空间复杂度 O ( 1 ) O(1) O(1)

8. 0215. 数组中的第K个最大元素

8.1 题目大意

描述:给定一个未排序的整数数组 nums 和一个整数 k

要求:返回数组中第 k 个最大的元素。

说明

  • 要求使用时间复杂度为 O ( n ) O(n) O(n) 的算法解决此问题。
  • 1 ≤ k ≤ n u m s . l e n g t h ≤ 1 0 5 1 \le k \le nums.length \le 10^5 1knums.length105
  • − 1 0 4 ≤ n u m s [ i ] ≤ 1 0 4 -10^4 \le nums[i] \le 10^4 104nums[i]104

示例

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


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

8.2 解题思路

思路 1:快速排序

使用快速排序在每次调整时,都会确定一个元素的最终位置,且以该元素为界限,将数组分成了左右两个子数组,左子数组中的元素都比该元素小,右子树组中的元素都比该元素大。

这样,只要某次划分的元素恰好是第 k 个下标就找到了答案。并且我们只需关注第 k 个最大元素所在区间的排序情况,与第 k 个最大元素无关的区间排序都可以忽略。这样进一步减少了执行步骤。

思路 1:代码

import random

class Solution:
    # 从 arr[low: high + 1] 中随机挑选一个基准数,并进行移动排序
    def randomPartition(self, arr: [int], low: int, high: int):
        # 随机挑选一个基准数
        i = random.randint(low, high)
        # 将基准数与最低位互换
        arr[i], arr[low] = arr[low], arr[i]
        # 以最低位为基准数,然后将序列中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧。最后将基准数放到正确位置上
        return self.partition(arr, low, high)
    
    # 以最低位为基准数,然后将序列中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧。最后将基准数放到正确位置上
    def partition(self, arr: [int], low: int, high: int):
        pivot = arr[low]            # 以第 1 为为基准数
        i = low + 1                 # 从基准数后 1 位开始遍历,保证位置 i 之前的元素都小于基准数
        
        for j in range(i, high + 1):
            # 发现一个小于基准数的元素
            if arr[j] < pivot:
                # 将小于基准数的元素 arr[j] 与当前 arr[i] 进行换位,保证位置 i 之前的元素都小于基准数
                arr[i], arr[j] = arr[j], arr[i]
                # i 之前的元素都小于基准数,所以 i 向右移动一位
                i += 1
        # 将基准节点放到正确位置上
        arr[i - 1], arr[low] = arr[low], arr[i - 1]
        # 返回基准数位置
        return i - 1

    def quickSort(self, arr, low, high, k):
        size = len(arr)
        if low < high:
            # 按照基准数的位置,将序列划分为左右两个子序列
            pi = self.randomPartition(arr, low, high)
            if pi == size - k:
                return arr[size - k]
            if pi > size - k:
                # 对左子序列进行递归快速排序
                self.quickSort(arr, low, pi - 1, k)
            if pi < size - k:
                # 对右子序列进行递归快速排序
                self.quickSort(arr, pi + 1, high, k)

        return arr[size - k]

    def findKthLargest(self, nums: List[int], k: int) -> int:
        return self.quickSort(nums, 0, len(nums) - 1, k)

思路 1:复杂度分析

  • 时间复杂度 O ( n ) O(n) O(n)。证明过程可参考「算法导论 9.2:期望为线性的选择算法」。
  • 空间复杂度 O ( log ⁡ 2 n ) O(\log_2 n) O(log2n)。递归使用栈空间的空间代价期望为 O ( log ⁡ 2 n ) O(\log_2n) O(log2n)

9. 剑指 Offer 40. 最小的k个数

9.1 题目大意

描述:给定整数数组 arr,再给定一个整数 k

要求:返回数组 arr 中最小的 k 个数。

说明

  • 0 ≤ k ≤ a r r . l e n g t h ≤ 10000 0 \le k \le arr.length \le 10000 0karr.length10000
  • 0 ≤ a r r [ i ] ≤ 10000 0 \le arr[i] \le 10000 0arr[i]10000

示例

输入:arr = [3,2,1], k = 2
输出:[1,2] 或者 [2,1]


输入:arr = [0,1,2,1], k = 1
输出:[0]

9.2 解题思路

思路 1:排序

对原数组从小到大排序后取出前 k个数即可。

思路 1:代码

class Solution:
    def getLeastNumbers(self, arr: List[int], k: int) -> List[int]:
        arr.sort()
        return arr[:k]

思路 1:复杂度分析

  • 时间复杂度 O ( n log ⁡ 2 n ) O(n\log_2n) O(nlog2n)
  • 空间复杂度 O ( log ⁡ 2 n ) O(\log_2n) O(log2n)

十一. 计数排序

计数排序(Counting Sort)基本思想

通过统计数组中每个元素在数组中出现的次数,根据这些统计信息将数组元素有序的放置到正确位置,从而达到排序的目的。

2. 计数排序算法步骤

  1. 计算排序范围:遍历数组,找出待排序序列中最大值元素 n u m s ‾ m a x nums\underline{}max numsmax 和最小值元素 n u m s ‾ m i n nums\underline{}min numsmin,计算出排序范围为 n u m s ‾ m a x − n u m s ‾ m i n + 1 nums\underline{}max - nums\underline{}min + 1 numsmaxnumsmin+1

  2. 定义计数数组:定义一个大小为排序范围的计数数组 c o u n t s counts counts,用于统计每个元素的出现次数。其中:

    1. 数组的索引值 n u m − n u m s ‾ m i n num - nums\underline{}min numnumsmin 表示元素的值为 n u m num num
    2. 数组的值 c o u n t s [ n u m − n u m s ‾ m i n ] counts[num - nums\underline{}min] counts[numnumsmin] 表示元素 n u m num num 的出现次数。
  3. 对数组元素进行计数统计:遍历待排序数组 n u m s nums nums,对每个元素在计数数组中进行计数,即将待排序数组中「每个元素值减去最小值」作为索引,将「对计数数组中的值」加 1 1 1,即令 c o u n t s [ n u m − n u m s ‾ m i n ] counts[num - nums\underline{}min] counts[numnumsmin] 1 1 1

  4. 生成累积计数数组:从 c o u n t s counts counts 中的第 1 1 1 个元素开始,每一项累家前一项和。此时 c o u n t s [ n u m − n u m s ‾ m i n ] counts[num - nums\underline{}min] counts[numnumsmin] 表示值为 n u m num num 的元素在排序数组中最后一次出现的位置。

  5. 逆序填充目标数组:逆序遍历数组 n u m s nums nums,将每个元素 n u m num num 填入正确位置。

  6. 将其填充到结果数组 r e s res res 的索引 c o u n t s [ n u m − n u m s ‾ m i n ] counts[num - nums\underline{}min] counts[numnumsmin] 处。

  7. 放入后,令累积计数数组中对应索引减 1 1 1,从而得到下个元素 n u m num num 的放置位置。

我们以 [ 3 , 0 , 4 , 2 , 5 , 1 , 3 , 1 , 4 , 5 ] [3, 0, 4, 2, 5, 1, 3, 1, 4, 5] [3,0,4,2,5,1,3,1,4,5] 为例,演示一下计数排序的整个步骤。

Leetcode 01-算法入门与数组-③数组排序_第66张图片

3. 计数排序代码实现

class Solution:
    def countingSort(self, nums: [int]) -> [int]:
        # 计算待排序数组中最大值元素 nums_max 和最小值元素 nums_min
        nums_min, nums_max = min(nums), max(nums)
        # 定义计数数组 counts,大小为 最大值元素 - 最小值元素 + 1
        size = nums_max - nums_min + 1
        counts = [0 for _ in range(size)]
        
        # 统计值为 num 的元素出现的次数
        for num in nums:
            counts[num - nums_min] += 1
        
        # 生成累积计数数组
        for i in range(1, size):
            counts[i] += counts[i - 1]

        # 反向填充目标数组
        res = [0 for _ in range(len(nums))]
        for i in range(len(nums) - 1, -1, -1):
            num = nums[i]
            # 根据累积计数数组,将 num 放在数组对应位置
            res[counts[num - nums_min] - 1] = num
            # 将 num 的对应放置位置减 1,从而得到下个元素 num 的放置位置
            counts[nums[i] - nums_min] -= 1

        return res

    def sortArray(self, nums: [int]) -> [int]:
        return self.countingSort(nums)

4. 计数排序算法分析

  • 时间复杂度 O ( n + k ) O(n + k) O(n+k)。其中 k k k 代表待排序数组的值域。
  • 空间复杂度 O ( k ) O(k) O(k)。其中 k k k 代表待排序序列的值域。由于用于计数的数组 c o u n t s counts counts 的长度取决于待排序数组中数据的范围(大小等于待排序数组最大值减去最小值再加 1 1 1)。所以计数排序算法对于数据范围很大的数组,需要大量的内存。
  • 计数排序适用情况:计数排序一般用于整数排序,不适用于按字母顺序、人名顺序排序。
  • 排序稳定性:由于向结果数组中填充元素时使用的是逆序遍历,可以避免改变相等元素之间的相对顺序。因此,计数排序是一种 稳定排序算法

十二. 桶排序

1. 桶排序算法思想

桶排序(Bucket Sort)基本思想

将待排序数组中的元素分散到若干个「桶」中,然后对每个桶中的元素再进行单独排序。

2. 桶排序算法步骤

  1. 确定桶的数量:根据待排序数组的值域范围,将数组划分为 k k k 个桶,每个桶可以看做是一个范围区间。
  2. 分配元素:遍历待排序数组元素,将每个元素根据大小分配到对应的桶中。
  3. 对每个桶进行排序:对每个非空桶内的元素单独排序(使用插入排序、归并排序、快排排序等算法)。
  4. 合并桶内元素:将排好序的各个桶中的元素按照区间顺序依次合并起来,形成一个完整的有序数组。

我们以 [ 39 , 49 , 8 , 13 , 22 , 15 , 10 , 30 , 5 , 44 ] [39, 49, 8, 13, 22, 15, 10, 30, 5, 44] [39,49,8,13,22,15,10,30,5,44] 为例,演示一下桶排序的整个步骤。

Leetcode 01-算法入门与数组-③数组排序_第67张图片

3. 桶排序代码实现

class Solution:
    def insertionSort(self, nums: [int]) -> [int]:
        # 遍历无序区间
        for i in range(1, len(nums)):
            temp = nums[i]
            j = i
            # 从右至左遍历有序区间
            while j > 0 and nums[j - 1] > temp:
                # 将有序区间中插入位置右侧的元素依次右移一位
                nums[j] = nums[j - 1]
                j -= 1
            # 将该元素插入到适当位置
            nums[j] = temp
            
        return nums

    def bucketSort(self,  nums: [int], bucket_size=5) -> [int]:
        # 计算待排序序列中最大值元素 nums_max、最小值元素 nums_min
        nums_min, nums_max = min(nums), max(nums)
        # 定义桶的个数为 (最大值元素 - 最小值元素) // 每个桶的大小 + 1
        bucket_count = (nums_max - nums_min) // bucket_size + 1
        # 定义桶数组 buckets
        buckets = [[] for _ in range(bucket_count)]

        # 遍历待排序数组元素,将每个元素根据大小分配到对应的桶中
        for num in nums:
            buckets[(num - nums_min) // bucket_size].append(num)

        # 对每个非空桶内的元素单独排序,排序之后,按照区间顺序依次合并到 res 数组中
        res = []
        for bucket in buckets:
            self.insertionSort(bucket)
            res.extend(bucket)
        
        # 返回结果数组
        return res

    def sortArray(self, nums: [int]) -> [int]:
        return self.bucketSort(nums)

4. 桶排序算法分析

  • 时间复杂度 O ( n ) O(n) O(n)。当输入元素个数为 n n n,桶的个数是 m m m 时,每个桶里的数据就是 k = n m k = \frac{n}{m} k=mn 个。每个桶内排序的时间复杂度为 O ( k × log ⁡ k ) O(k \times \log k) O(k×logk) m m m 个桶就是 m × O ( k × log ⁡ k ) = m × O ( n m × log ⁡ n m ) = O ( n × log ⁡ n m ) m \times O(k \times \log k) = m \times O(\frac{n}{m} \times \log \frac{n}{m}) = O(n \times \log \frac{n}{m}) m×O(k×logk)=m×O(mn×logmn)=O(n×logmn)。当桶的个数 m m m 接近于数据个数 n n n 时, log ⁡ n m \log \frac{n}{m} logmn 就是一个较小的常数,所以排序桶排序时间复杂度接近于 O ( n ) O(n) O(n)
  • 空间复杂度 O ( n + m ) O(n + m) O(n+m)。由于桶排序使用了辅助空间,所以桶排序的空间复杂度是 O ( n + m ) O(n + m) O(n+m)
  • 排序稳定性:桶排序的稳定性取决于桶内使用的排序算法。如果桶内使用稳定的排序算法(比如插入排序算法),并且在合并桶的过程中保持相等元素的相对顺序不变,则桶排序是一种 稳定排序算法。反之,则桶排序是一种 不稳定排序算法

十三. 基数排序

1. 基数排序算法思想

基数排序(Radix Sort)基本思想

将整数按位数切割成不同的数字,然后从低位开始,依次到高位,逐位进行排序,从而达到排序的目的。

2. 基数排序算法步骤

基数排序算法可以采用「最低位优先法(Least Significant Digit First)」或者「最高位优先法(Most Significant Digit first)」。最常用的是「最低位优先法」。

下面我们以最低位优先法为例,讲解一下算法步骤。

  1. 确定排序的最大位数:遍历数组元素,获取数组最大值元素,并取得对应位数。
  2. 从最低位(个位)开始,到最高位为止,逐位对每一位进行排序
    1. 定义一个长度为 10 10 10 的桶数组 b u c k e t s buckets buckets,每个桶分别代表 0 ∼ 9 0 \sim 9 09 中的 1 1 1 个数字。
    2. 按照每个元素当前位上的数字,将元素放入对应数字的桶中。
    3. 清空原始数组,然后按照桶的顺序依次取出对应元素,重新加入到原始数组中。

我们以 [ 692 , 924 , 969 , 503 , 871 , 704 , 542 , 436 ] [692, 924, 969, 503, 871, 704, 542, 436] [692,924,969,503,871,704,542,436] 为例,演示一下基数排序的整个步骤。

Leetcode 01-算法入门与数组-③数组排序_第68张图片

3. 基数排序代码实现

class Solution:
    def radixSort(self, nums: [int]) -> [int]:
        # 桶的大小为所有元素的最大位数
        size = len(str(max(nums)))
        
        # 从最低位(个位)开始,逐位遍历每一位
        for i in range(size):
            # 定义长度为 10 的桶数组 buckets,每个桶分别代表 0 ~ 9 中的 1 个数字。
            buckets = [[] for _ in range(10)]
            # 遍历数组元素,按照每个元素当前位上的数字,将元素放入对应数字的桶中。
            for num in nums:
                buckets[num // (10 ** i) % 10].append(num)
            # 清空原始数组
            nums.clear()
            # 按照桶的顺序依次取出对应元素,重新加入到原始数组中。
            for bucket in buckets:
                for num in bucket:
                    nums.append(num)
                    
        # 完成排序,返回结果数组
        return nums
    
    def sortArray(self, nums: [int]) -> [int]:
        return self.radixSort(nums)

4. 基数排序算法分析

  • 时间复杂度 O ( n × k ) O(n \times k) O(n×k)。其中 n n n 是待排序元素的个数, k k k 是数字位数。 k k k 的大小取决于数字位的选择(十进制位、二进制位)和待排序元素所属数据类型全集的大小。
  • 空间复杂度 O ( n + k ) O(n + k) O(n+k)
  • 排序稳定性:基数排序采用的桶排序是稳定的。基数排序是一种 稳定排序算法

十四. 练习题目4

10. 1122. 数组的相对排序

10.1 题目大意

描述:给定两个数组,arr1arr2,其中 arr2 中的元素各不相同,arr2 中的每个元素都出现在 arr1 中。

要求:对 arr1 中的元素进行排序,使 arr1 中项的相对顺序和 arr2 中的相对顺序相同。未在 arr2 中出现过的元素需要按照升序放在 arr1 的末尾。

说明

  • 1 ≤ a r r 1. l e n g t h , a r r 2. l e n g t h ≤ 1000 1 \le arr1.length, arr2.length \le 1000 1arr1.length,arr2.length1000
  • 0 ≤ a r r 1 [ i ] , a r r 2 [ i ] ≤ 1000 0 \le arr1[i], arr2[i] \le 1000 0arr1[i],arr2[i]1000

示例

输入:arr1 = [2,3,1,3,2,4,6,7,9,2,19], arr2 = [2,1,4,3,9,6]
输出:[2,2,2,1,4,3,3,9,6,7,19]


输入:arr1 = [28,6,22,8,44,17], arr2 = [22,28,8,6]
输出:[22,28,8,6,17,44]

10.2 解题思路

思路 1:计数排序

因为元素值范围在 [0, 1000],所以可以使用计数排序的思路来解题。

  1. 使用数组 count 统计 arr1 各个元素个数。
  2. 遍历 arr2 数组,将对应元素num2 按照个数 count[num2] 添加到答案数组 ans 中,同时在 count 数组中减去对应个数。
  3. 然后在处理 count 中剩余元素,将 count 中大于 0 的元素下标依次添加到答案数组 ans 中。
  4. 最后返回答案数组 ans

思路 1:代码

class Solution:
    def relativeSortArray(self, arr1: List[int], arr2: List[int]) -> List[int]:
        # 计算待排序序列中最大值元素 arr_max 和最小值元素 arr_min
        arr1_min, arr1_max = min(arr1), max(arr1)
        # 定义计数数组 counts,大小为 最大值元素 - 最小值元素 + 1
        size = arr1_max - arr1_min + 1
        counts = [0 for _ in range(size)]

        # 统计值为 num 的元素出现的次数
        for num in arr1:
            counts[num - arr1_min] += 1

        res = []
        for num in arr2:
            while counts[num - arr1_min] > 0:
                res.append(num)
                counts[num - arr1_min] -= 1

        for i in range(size):
            while counts[i] > 0:
                num = i + arr1_min
                res.append(num)
                counts[i] -= 1
        
        return res

思路 1:复杂度分析

  • 时间复杂度 O ( m + n + m a x ( a r r 1 ) ) O(m + n + max(arr_1)) O(m+n+max(arr1))。其中 m m m 是数组 a r r 1 arr_1 arr1 的长度, n n n 是数组 a r r 2 arr_2 arr2 的长度, m a x ( a r r 1 ) max(arr_1) max(arr1) 是数组 a r r 1 arr_1 arr1 的最大值。
  • 空间复杂度 O ( m a x ( a r r 1 ) ) O(max(arr_1)) O(max(arr1))

11. 0220. 存在重复元素 III

11.1 题目大意

描述:给定一个整数数组 nums,以及两个整数 kt

要求:判断数组中是否存在两个不同下标的 ij,其对应元素满足 abs(nums[i] - nums[j]) <= t,同时满足 abs(i - j) <= k。如果满足条件则返回 True,不满足条件返回 False

说明

  • 0 ≤ n u m s . l e n g t h ≤ 2 ∗ 1 0 4 0 \le nums.length \le 2 * 10^4 0nums.length2104
  • − 2 31 ≤ n u m s [ i ] ≤ 2 31 − 1 -2^{31} \le nums[i] \le 2^{31} - 1 231nums[i]2311
  • 0 ≤ k ≤ 1 0 4 0 \le k \le 10^4 0k104
  • 0 ≤ t ≤ 2 31 − 1 0 \le t \le 2^{31} - 1 0t2311

示例

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


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

11.2 解题思路

思路 1:滑动窗口(固定长度)

  1. 使用一个长度为 k 的滑动窗口,每次遍历到 nums[right] 时,滑动窗口内最多包含 nums[right] 之前最多 k 个元素。只需要检查前 k 个元素是否在 [nums[right] - t, nums[right] + t] 区间内即可。
  2. 检查 k 个元素是否在 [nums[right] - t, nums[right] + t] 区间,可以借助保证有序的数据结构(比如 SortedList)+ 二分查找来解决,从而减少时间复杂度。

具体步骤如下:

  1. 使用有序数组类 window 维护一个长度为 k 的窗口,满足数组内元素有序,且支持增加和删除操作。
  2. leftright 都指向序列的第一个元素。即:left = 0right = 0
  3. 将当前元素填入窗口中,即 window.add(nums[right])
  4. 当窗口元素大于 k 个时,即 right - left > k,移除窗口最左侧元素,并向右移动 left
  5. 当窗口元素小于等于 k 个时:
    1. 使用二分查找算法,查找 nums[right]window 中的位置 idx
    2. 判断 window[idx] 与相邻位置上元素差值绝对值,若果满足 abs(window[idx] - window[idx - 1]) <= t 或者 abs(window[idx + 1] - window[idx]) <= t 时返回 True
  6. 向右移动 right
  7. 重复 3 ~ 6 步,直到 right 到达数组末尾,如果还没找到满足条件的情况,则返回 False

思路 1:代码

from sortedcontainers import SortedList

class Solution:
    def containsNearbyAlmostDuplicate(self, nums: List[int], k: int, t: int) -> bool:
        size = len(nums)
        window = SortedList()
        left, right = 0, 0
        while right < size:
            window.add(nums[right])
            
            if right - left > k:
                window.remove(nums[left])
                left += 1
            
            idx = bisect.bisect_left(window, nums[right])
            
            if idx > 0 and abs(window[idx] - window[idx - 1]) <= t:
                return True
            if idx < len(window) - 1 and abs(window[idx + 1] - window[idx]) <= t:
                return True

            right += 1

        return False

思路 1:复杂度分析

  • 时间复杂度 O ( n × log ⁡ 2 ( m i n ( n , k ) ) ) O(n \times \log_2(min(n, k))) O(n×log2(min(n,k)))
  • 空间复杂度 O ( m i n ( n , k ) ) O(min(n, k)) O(min(n,k))

12. 0164. 最大间距

12.1 题目大意

描述:给定一个无序数组 nums

要求:找出数组在排序之后,相邻元素之间最大的差值。如果数组元素个数小于 2,则返回 0

说明

  • 所有元素都是非负整数,且数值在 32 位有符号整数范围内。
  • 请尝试在线性时间复杂度和空间复杂度的条件下解决此问题。

示例

输入: nums = [3,6,9,1]
输出: 3
解释: 排序后的数组是 [1,3,6,9], 其中相邻元素 (3,6)(6,9) 之间都存在最大差值 3。


输入: nums = [10]
输出: 0
解释: 数组元素个数小于 2,因此返回 0

12.2 解题思路

思路 1:基数排序

这道题的难点在于要求时间复杂度和空间复杂度为 O ( n ) O(n) O(n)

这道题分为两步:

  1. 数组排序。
  2. 计算相邻元素之间的差值。

第 2 步直接遍历数组求解即可,时间复杂度为 O ( n ) O(n) O(n)。所以关键点在于找到一个时间复杂度和空间复杂度为 O ( n ) O(n) O(n) 的排序算法。根据题意可知所有元素都是非负整数,且数值在 32 位有符号整数范围内。所以我们可以选择基数排序。基数排序的步骤如下:

  • 遍历数组元素,获取数组最大值元素,并取得位数。
  • 以个位元素为索引,对数组元素排序。
  • 合并数组。
  • 之后依次以十位,百位,…,直到最大值元素的最高位处值为索引,进行排序,并合并数组,最终完成排序。

最后,还要注意数组元素个数小于 2 的情况需要特别判断一下。

思路 1:代码

class Solution:
    def radixSort(self, arr):
        size = len(str(max(arr)))

        for i in range(size):
            buckets = [[] for _ in range(10)]
            for num in arr:
                buckets[num // (10 ** i) % 10].append(num)
            arr.clear()
            for bucket in buckets:
                for num in bucket:
                    arr.append(num)

        return arr

    def maximumGap(self, nums: List[int]) -> int:
        if len(nums) < 2:
            return 0
        arr = self.radixSort(nums)
        return max(arr[i] - arr[i - 1] for i in range(1, len(arr)))

思路 1:复杂度分析

  • 时间复杂度 O ( n ) O(n) O(n)
  • 空间复杂度 O ( n ) O(n) O(n)

十五. 排序算法题目

冒泡排序题目

题号 标题 题解 标签 难度
剑指 Offer 45 把数组排成最小的数 网页链接、Github 链接 贪心、字符串、排序 中等
0283 移动零 网页链接、Github 链接 数组、双指针 简单

选择排序题目

题号 标题 题解 标签 难度
0215 数组中的第K个最大元素 网页链接、Github 链接 数组、分治、快速选择、排序、堆(优先队列) 中等

插入排序题目

题号 标题 题解 标签 难度
0075 颜色分类 网页链接、Github 链接 数组、双指针、排序 中等

希尔排序题目

题号 标题 题解 标签 难度
0912 排序数组 网页链接、Github 链接 数组、分治、桶排序、计数排序、基数排序、排序、堆(优先队列)、归并排序 中等
0506 相对名次 网页链接、Github 链接 数组、排序、堆(优先队列) 简单

归并排序题目

题号 标题 题解 标签 难度
0912 排序数组 网页链接、Github 链接 数组、分治、桶排序、计数排序、基数排序、排序、堆(优先队列)、归并排序 中等
0088 合并两个有序数组 网页链接、Github 链接 数组、双指针、排序 简单
剑指 Offer 51 数组中的逆序对 网页链接、Github 链接 树状数组、线段树、数组、二分查找、分治、有序集合、归并排序 困难
0315 计算右侧小于当前元素的个数 树状数组、线段树、数组、二分查找、分治、有序集合、归并排序 困难

快速排序题目

题号 标题 题解 标签 难度
0912 排序数组 网页链接、Github 链接 数组、分治、桶排序、计数排序、基数排序、排序、堆(优先队列)、归并排序 中等
0169 多数元素 网页链接、Github 链接 数组、哈希表、分治、计数、排序 简单

堆排序题目

题号 标题 题解 标签 难度
0912 排序数组 网页链接、Github 链接 数组、分治、桶排序、计数排序、基数排序、排序、堆(优先队列)、归并排序 中等
0215 数组中的第K个最大元素 网页链接、Github 链接 数组、分治、快速选择、排序、堆(优先队列) 中等
剑指 Offer 40 最小的k个数 网页链接、Github 链接 数组、分治、快速选择、排序、堆(优先队列) 简单

计数排序题目

题号 标题 题解 标签 难度
0912 排序数组 网页链接、Github 链接 数组、分治、桶排序、计数排序、基数排序、排序、堆(优先队列)、归并排序 中等
1122 数组的相对排序 网页链接、Github 链接 数组、哈希表、计数排序、排序 简单

桶排序题目

题号 标题 题解 标签 难度
0912 排序数组 网页链接、Github 链接 数组、分治、桶排序、计数排序、基数排序、排序、堆(优先队列)、归并排序 中等
0220 存在重复元素 III 网页链接、Github 链接 数组、桶排序、有序集合、排序、滑动窗口 困难
0164 最大间距 网页链接、Github 链接 数组、桶排序、基数排序、排序 困难

基数排序题目

题号 标题 题解 标签 难度
0164 最大间距 网页链接、Github 链接 数组、桶排序、基数排序、排序 困难
0561 数组拆分 贪心、数组、计数排序、排序 简单

其他排序题目

题号 标题 题解 标签 难度
0217 存在重复元素 网页链接、Github 链接 数组、哈希表、排序 简单
0136 只出现一次的数字 网页链接、Github 链接 位运算、数组 简单
0056 合并区间 网页链接、Github 链接 数组、排序 中等
0179 最大数 网页链接、Github 链接 贪心、数组、字符串、排序 中等
0384 打乱数组 数组、数学、随机化 中等
剑指 Offer 45 把数组排成最小的数 网页链接、Github 链接 贪心、字符串、排序 中等

你可能感兴趣的:(LeetCode,算法,leetcode,python,数据结构)