LeetCode 01-算法入门与数组-③数组排序
一. 冒泡排序
1. 冒泡排序算法思想
冒泡排序(Bubble Sort)基本思想:
经过多次迭代,通过相邻元素之间的比较与交换,使值较小的元素逐步从后面移到前面,值较大的元素从前面移到后面。
这个过程就像水底的气泡一样从底部向上「冒泡」到水面,这也是冒泡排序法名字的由来。
接下来,我们使用「冒泡」的方式来模拟一下这个过程。
- 首先将数组想象是一排「泡泡」,元素值的大小与泡泡的大小成正比。
- 然后从左到右依次比较相邻的两个「泡泡」:
- 如果左侧泡泡大于右侧泡泡,则交换两个泡泡的位置。
- 如果左侧泡泡小于等于右侧泡泡,则两个泡泡保持不变。
- 这 1 1 1 趟遍历完成之后,最大的泡泡就会放置到所有泡泡的最右侧,就像是「泡泡」从水底向上浮到了水面。
<1>
<2>
<3>
<4>
<5>
<6>
<7>
2. 冒泡排序算法步骤
假设数组的元素个数为 n n n 个,则冒泡排序的算法步骤如下:
- 第 1 1 1 趟「冒泡」:对前 n n n 个元素执行「冒泡」,从而使第 1 1 1 个值最大的元素放置在正确位置上。
- 先将序列中第 1 1 1 个元素与第 2 2 2 个元素进行比较,如果前者大于后者,则两者交换位置,否则不交换。
- 然后将第 2 2 2 个元素与第 3 3 3 个元素比较,如果前者大于后者,则两者交换位置,否则不交换。
- 依次类推,直到第 n − 1 n - 1 n−1 个元素与第 n n n 个元素比较(或交换)为止。
- 经过第 1 1 1 趟排序,使得 n n n 个元素中第 i i i 个值最大元素被安置在第 n n n 个位置上。
- 第 2 2 2 趟「冒泡」:对前 n − 1 n - 1 n−1 个元素执行「冒泡」,从而使第 2 2 2 个值最大的元素放置在正确位置上。
- 先将序列中第 1 1 1 个元素与第 2 2 2 个元素进行比较,若前者大于后者,则两者交换位置,否则不交换。
- 然后将第 2 2 2 个元素与第 3 3 3 个元素比较,若前者大于后者,则两者交换位置,否则不交换。
- 依次类推,直到对 n − 2 n - 2 n−2 个元素与第 n − 1 n - 1 n−1 个元素比较(或交换)为止。但是少时诵诗书所所所所是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒是撒
- 经过第 2 2 2 趟排序,使得数组中第 2 2 2 个值最大元素被安置在第 n n n 个位置上。
- 依次类推,重复上述「冒泡」过程,直到某一趟排序过程中不出现元素交换位置的动作,则排序结束。
我们以 [ 5 , 2 , 3 , 6 , 1 , 4 ] [5, 2, 3, 6, 1, 4] [5,2,3,6,1,4] 为例,演示一下冒泡排序的整个过程。
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(i−1)=2n(n−1) 次元素之间的比较,因此,冒泡排序算法的最坏时间复杂度为 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 个,则选择排序的算法步骤如下:
- 初始状态下,无已排序区间,未排序区间为 [ 0 , n − 1 ] [0, n - 1] [0,n−1]。
- 第 1 1 1 趟选择:
- 遍历未排序区间 [ 0 , n − 1 ] [0, n - 1] [0,n−1],使用变量 m i n ‾ i min\underline{}i mini 记录区间中值最小的元素位置。
- 将 m i n ‾ i min\underline{}i mini 与下标为 0 0 0 处的元素交换位置。如果下标为 0 0 0 处元素就是值最小的元素位置,则不用交换。
- 此时, [ 0 , 0 ] [0, 0] [0,0] 为已排序区间, [ 1 , n − 1 ] [1, n - 1] [1,n−1](总共 n − 1 n - 1 n−1 个元素)为未排序区间。
- 第 2 2 2 趟选择:
- 遍历未排序区间 [ 1 , n − 1 ] [1, n - 1] [1,n−1],使用变量 m i n ‾ i min\underline{}i mini 记录区间中值最小的元素位置。
- 将 m i n ‾ i min\underline{}i mini 与下标为 1 1 1 处的元素交换位置。如果下标为 1 1 1 处元素就是值最小的元素位置,则不用交换。
- 此时, [ 0 , 1 ] [0, 1] [0,1] 为已排序区间, [ 2 , n − 1 ] [2, n - 1] [2,n−1](总共 n − 2 n - 2 n−2 个元素)为未排序区间。
- 依次类推,对剩余未排序区间重复上述选择过程,直到所有元素都划分到已排序区间,排序结束。
我们以 [ 5 , 2 , 3 , 6 , 1 , 4 ] [5, 2, 3, 6, 1, 4] [5,2,3,6,1,4] 为例,演示一下选择排序的整个过程。
<1>
<2>
<3>
<4>
<5>
<6>
<7>
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 n−i 次元素之间的比较。因此,整个排序过程需要进行的元素之间的比较次数都相同,为 ∑ i = 2 n ( i − 1 ) = n ( n − 1 ) 2 ∑^n_{i=2}(i - 1) = \frac{n(n−1)}{2} ∑i=2n(i−1)=2n(n−1) 次。
- 空间复杂度: 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 个,则插入排序的算法步骤如下:
- 初始状态下,有序区间为 [ 0 , 0 ] [0, 0] [0,0],无序区间为 [ 1 , n − 1 ] [1, n - 1] [1,n−1]。
- 第 1 1 1 趟插入:
- 取出无序区间 [ 1 , n − 1 ] [1, n - 1] [1,n−1] 中的第 1 1 1 个元素,即 n u m s [ 1 ] nums[1] nums[1]。
- 从右到左遍历有序区间中的元素,将比 n u m s [ 1 ] nums[1] nums[1] 小的元素向后移动 1 1 1 位。
- 如果遇到大于或等于 n u m s [ 1 ] nums[1] nums[1] 的元素时,说明找到了插入位置,将 n u m s [ 1 ] nums[1] nums[1] 插入到该位置。
- 插入元素后有序区间变为 [ 0 , 1 ] [0, 1] [0,1],无序区间变为 [ 2 , n − 1 ] [2, n - 1] [2,n−1]。
- 第 2 2 2 趟插入:
- 取出无序区间 [ 2 , n − 1 ] [2, n - 1] [2,n−1] 中的第 1 1 1 个元素,即 n u m s [ 2 ] nums[2] nums[2]。
- 从右到左遍历有序区间中的元素,将比 n u m s [ 2 ] nums[2] nums[2] 小的元素向后移动 1 1 1 位。
- 如果遇到大于或等于 n u m s [ 2 ] nums[2] nums[2] 的元素时,说明找到了插入位置,将 n u m s [ 2 ] nums[2] nums[2] 插入到该位置。
- 插入元素后有序区间变为 [ 0 , 2 ] [0, 2] [0,2],无序区间变为 [ 3 , n − 1 ] [3, n - 1] [3,n−1]。
- 依次类推,对剩余无序区间中的元素重复上述插入过程,直到所有元素都插入到有序区间中,排序结束。
我们以 [ 5 , 2 , 3 , 6 , 1 , 4 ] [5, 2, 3, 6, 1, 4] [5,2,3,6,1,4] 为例,演示一下插入排序的整个过程。
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=n−1,并不需要移动元素(记录),这是最好的情况。
- 最差时间复杂度: O ( n 2 ) O(n^2) O(n2)。最差的情况下(初始时区间已经是降序排列),每个元素 n u m s [ i ] nums[i] nums[i] 都要进行 i − 1 i - 1 i−1 次元素之间的比较,元素之间总的比较次数达到最大值,为 ∑ i = 2 n ( i − 1 ) = n ( n − 1 ) 2 ∑^n_{i=2}(i − 1) = \frac{n(n−1)}{2} ∑i=2n(i−1)=2n(n−1)。
- 平均时间复杂度: 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.length≤100。
- 输出结果可能非常大,所以你需要返回一个字符串而不是整数。
- 拼接起来的数字可能会有前导
0
,最后结果不需要去掉前导0
。
示例:
输入:[3,30,34,5,9]
输出:"3033459"
1.2 解题思路
思路 1:自定义排序
本质上是给数组进行排序。假设 x
、y
是数组 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 1≤nums.length≤104。
- − 2 31 ≤ n u m s [ i ] ≤ 2 31 − 1 -2^{31} \le nums[i] \le 2^{31} - 1 −2