以下所有的排序均按照从小到达排序,用python实现
排序算法的稳定性指的是比如有两个相等的数字5,排序后第一个5永远会在第二个5的前面,这两个数字的相对位置不会发生变化。
关于以下三种算法使用情况的分析的博客,堆排序的最好最好时间复杂度都是O(nlong n),空间复杂度为O(1),那么为什么我们在做题的时候,调用的是快排函数呢?下面这边博客给出了解释,希望对你的理解有所帮助:
快速排序 Vs. 归并排序 Vs. 堆排序——谁才是最强的排序算法。
1. 冒泡排序 (Bubble Sort) 稳定的
(1)算法过程
从数组的第一个数字开始往后遍历,比较相邻的两个数字,如果前面的数组大于后面的数字,交换两个数字的位置,一直到倒数第二个数字,因为最后一个数字没必要再去比较了,它后面没有要比较的数字了,此时第一遍遍历完成。然后开始第二遍,同样重复上述操作,但是这次是一直比较到倒数第三个数字,因为此时最后一个数字一定是最大的了,没有必要再去比较了。比如4,3,2,1,第一遍遍历后变成了3,2,1,4,下一次再比较的时候,就只比较3,2,1了。该算法是每次将排好的数字调整到末尾。
(2)稳定性
稳定的
(3)时间和空间复杂度
时间复杂度最坏的情况为O(n^2),比如是一个完全大到小的数组。最好的情况时间复杂度为O(n),比如是一个已经按照从小到大排好序的数组,算法只需要遍历一遍数组不需要任何数字的交换,空间复杂度为O(1)。如果算法是按照下面这样实现,算法最好最坏的情况下都会是O(n^2)
from typing import List
class Solution:
def bubble_sort(self, nums: List[int]) -> List[int]:
for i in range(len(nums) - 1):
for j in range(len(nums) - 1 - i):
if nums[j] > nums[j + 1]:
nums[j], nums[j + 1] = nums[j + 1], nums[j]
did_swap = True
return nums
我们给算法添加一个变量,判断是否在当前这次遍历中,发生了数字的交换,没有发生,则说明数组是已经排好序的,算法结束,此时算法就只进行了O(n)的遍历而已。修正过的算法如下,实现了最坏情况下O(n^2),最好的情况是O(n)。
(4)代码实现
from typing import List
class Solution:
def bubble_sort(self, nums: List[int]) -> List[int]:
for i in range(len(nums) - 1):
did_swap = False
for j in range(len(nums) - 1 - i):
if nums[j] > nums[j + 1]:
nums[j], nums[j + 1] = nums[j + 1], nums[j]
did_swap = True
if not did_swap:
return nums
return nums
2. 选择排序(Selection Sort)不稳定
(1)算法过程
遍历整个数组,每次查找最小的数字放到数组前面。比如第一次将最小的数字放到nums[0]的位置,第二次遍历从num[1]开始往后找最小的数字,然后放到nums[1]的位置,依次类推,直到遍历到最后一个数字。
(2)稳定性
选择排序是不稳定的,比如这种情况,(7),2,3,[7],1
经过选择排序后,(7)就会跑到了[7]的后面。排好序的数组变成了这样1,2,3,[7],(7)
(3)时间和空间复杂度
时间复杂度最好最坏的情况都是O(n^2), 空间复杂度为O(1)
(4)代码实现
from typing import List
class Solution:
def bubble_sort(self, nums: List[int]) -> List[int]:
for i in range(len(nums) - 1):
min_index = i
for j in range(i + 1, len(nums)):
if nums[j] < nums[min_index]:
min_index = j
nums[min_index], nums[i] = nums[i], nums[min_index]
return nums
3. 插入排序 (Insertion Sort)稳定
(1)算法过程
先选择第一个数字作为已经排好的序列,然后从第二个数开始,从后往前遍历之前已经排好序的序列,直到找到合适的位置才插入进去。用前面的数字去覆盖后面的数字。
插入排序的效果要比冒泡排序的效果好,虽然他们两个的最好最坏时间复杂度一样,空间复杂度也一样,但是实际运行代码的过程中,冒泡排序的每次交换数字的操作要比插入排序每次移动的操作次数多。最直观的来看,冒泡排序每次交换的过程需要3次操作,而插入排序的移动过程只需要一次操作。
# 冒泡的交换
if nums[j] > nums[j + 1]:
temp = nums[j]
nums[j] = nums[j+1]
nums[j+1] = nums[j]
did_swap = True
# 插入排序的数字移动过程
while pre >=0 and curr_num < nums[pre]:
nums[pre+1] = nums[pre]
pre -= 1
(2)稳定性
稳定的
(3)时间和空间复杂度
时间复杂度最坏为O(n^2),比如5,4,3,2,1。最好的情况是O(n),如1,2,3,4,5.
空间复杂度为O(1)
(4)代码实现
from typing import List
class Solution:
def insertion_sort(self, nums: List[int]) -> List[int]:
for i in range(1, len(nums)):
pre = i - 1
curr_num = nums[i]
while pre >= 0 and curr_num < nums[pre]:
nums[pre + 1] = nums[pre]
pre -= 1
nums[pre + 1] = curr_num
return nums
4. 希尔排序(Shell Sort)不稳定
(1)算法过程
希尔排序就是在插入排序的基础上进行了改进,是第一个突破O(n^2)的算法。该算法的思想就是每次划分一个gap,将原来的数组划分成了多个子数组,然后分别对子数组进行插入排序。gap每次的变化都是 gap = gap // 2。最后的gap一定会划分到1,当gap=1的时候,其实就是直接的插入排序了,这也保证了算法最后一定能排好序。我感觉算法的整体思想其实就是经过多次的划分,每个gap下插入排序后变得比之前的序列更加有序,这样在不划分很多次的情况下(O(log n),因为每次gap都除2),尽可能的使序列变得有序,这样最后进行的直接插入排序不需要移动大量的数就能完成排序。
参考https://www.cnblogs.com/chengxiao/p/6104371.html
(2)稳定性
不稳定
(3)时间和空间复杂度
当gap=1的时候,希尔排序蜕变成了直接插入排序。最坏情况O(n^2), 最好情况要大于O(n),所以平均下来的话,时间复杂度介于O(n)与O(n^2)之间。空间复杂度为O(1)。这个地方具体理解的不是很透彻,还望评论里能够指点一下。
(4)代码实现
from typing import List
class Solution:
def shell_sort(self, nums: List[int]) -> List[int]:
gap = len(nums) // 2
while gap > 0:
for i in range(gap, len(nums)):
pre = i - gap
curr_num = nums[i]
while pre >= 0 and curr_num < nums[pre]:
nums[pre + gap] = nums[pre]
pre -= gap
nums[pre + gap] = curr_num
gap //= 2
return nums
5. 归并排序(Merge Sort)稳定
(1)算法思路
分治(divide and conquer)的思想。将两个已经排好序的两个数组合并成一个数组。每次合并的两个都必须是已经排好序的数组。
参考 菜鸟教程
(2)稳定性
稳定
(3)时间和空间复杂度
时间复杂度最好最坏都是O(nlog n)。空间复杂度为O(n)
(4)算法实现
from typing import List
class Solution:
def merge_sort(self, nums: List[int]) -> List[int]:
if len(nums) == 1:
return nums
temp = [0] * len(nums)
mid = len(nums) // 2
left, right = nums[:mid], nums[mid:]
return self.merge(self.merge_sort(left), self.merge_sort(right))
def merge(self, left, right):
result = []
while left and right:
if left[0] <= right[0]:
result.append(left.pop(0))
else:
result.append(right.pop(0))
while left:
result.append(left.pop(0))
while right:
result.append(right.pop(0))
return result
6. 快排 (Quick Sort)
(1)算法思路
每次选取一个数作为中间数,将数组分成两部分,最终使得调整完后的数组,该数字的左边的所有数字都比该数字小,该数字右边的所有数字都比该数字大。然后使用分治的思想去解决。
参考 https://wiki.jikexueyuan.com/project/easy-learn-algorithm/fast-sort.html
https://blog.csdn.net/zcpvn/article/details/78150692
(2)稳定性
不稳定
(3)时间和空间复杂度
时间复杂度最坏为O(n^2),平均时间复杂度O(nlog n)。空间复杂度为O(log n)~O(n)
(4)代码实现
from typing import List
class Solution:
def quick_sort(self, nums: List[int], left: int, right: int) -> List[int]:
if left >= right:
return
key = nums[left]
i, j = left, right
while i != j:
while i < j and nums[j] >= key:
j -= 1
while i < j and nums[i] <= key:
i += 1
if i < j:
nums[i], nums[j] = nums[j], nums[i]
nums[left] = nums[i]
nums[i] = key
self.quick_sort(nums, left, i - 1)
self.quick_sort(nums, i + 1, right)
return nums
7. 堆排序
(1)算法思路
利用堆的性质。将所有的数字创建成一个最大堆,然后将堆顶的元素与堆的最后一个数字交换,堆的长度减一,从堆顶开始向下调整堆。直到只剩下最后一个元素。
(2)稳定性
不稳定
(3)时间和空间复杂度
最好最坏的时间复杂度都是O(nlog n). 空间复杂度为O(1)。
(4)代码实现
from typing import List
class Solution:
def heap_sort(self, nums: List[int]) -> List[int]:
length = len(nums) - 1
def shift_down(index):
finished = False
max_i = index
temp_i = index * 2 + 1
while temp_i <= length and not finished:
if nums[temp_i] > nums[index]:
max_i = temp_i
temp_i += 1
if temp_i <= length and nums[temp_i] > nums[max_i]:
max_i = temp_i
if max_i != index:
nums[index], nums[max_i] = nums[max_i], nums[index]
index = max_i
temp_i = 2 * index + 1
else:
finished = True
def create():
i = (length - 1) // 2
while i >= 0:
shift_down(i)
i -= 1
create()
while length > 0:
nums[length], nums[0] = nums[0], nums[length]
length -= 1
shift_down(0)
return nums
8.计数排序(Counting Sort)
(1)算法思路
顾名思义,该算法需要计数,需要统计每个数字出现的次数。该算法只能排列[0, k]的非负数字,k是这个数组里面最大的数字。统计每个数字出现的次数,然后将数字对应到新创建数组的索引,个数对应该索引下的值,比如6这个数字出现了2次,那么new_array[6] = 2。
这个new_array是新创建的数组,长度为k+1。在统计完所有数字的个数后,遍历这个new_array数组,如果对应索引下的值大于0,则说明原数组里面有这个数字,然后修改原数组,从索引0开始,每次修改一个数,索引+1,知道遍历完整个new_array。
(2)稳定性
稳定的
(3)时间和空间复杂度
最好最坏的时间复杂度都为O(n + k),空间复杂度为O(k + 1).。缺点就是太浪费空间了,并且排的数字只能大于等于0。其实我们可以找到最小值,然后在修改数组的时候,可以从最小的数字的索引开始,这样可以节省一部分时间。这个算法不能排负数,感觉其实也可以,如果是负的数的话,存到另外一个数组里,也就是说创建两个新数组,一个统计正的数字,另一个统计负的数字,最后合并两个数组。
(4)代码实现
from typing import List
class Solution:
def counting_sort(self, nums: List[int], max_val: int) -> List[int]:
new_array = [0] * (max_val + 1)
for num in nums:
new_array[num] += 1
index = 0
for i, cnt in enumerate(new_array):
while cnt:
nums[index] = i
index += 1
cnt -= 1
return nums
9. 桶排序(Bucket Sort)
(1)算法思路
我们创建n个桶,然后将所有的数字均匀的分不到n个桶里面,然后分别对每个桶进行排序(这里使用其他的排序算法,比如插入排序),最后合并n个桶。桶的个数越多,效率肯定也越高,但是内存消耗也越大。该算法的关键点是如果将这些数字映射到n个桶里面,
不同的桶排序差别就在这里。比如排序0到99以内的数,我们创建10个桶,第一个桶放得数的范围是0-9,第二个桶放得数的范围是10-19,一次类推,然后对每个桶进行插入排序,将10个桶进行合并。
桶排序其实是计数排序的特殊情况,我们在计数排序中用了一个大小为(k+1)的数组,其实就是(k+1)个桶,只不过是每个桶里面只放了一个数字。桶排序中桶的个数越多效率越高,当然对应的消耗内存也就越大了,计数排序是桶排序的上限,当只有一个桶的时候,桶排序就退化成了其他的排序算法。
10. 基数排序(Radix Sort)
(1)算法思路
就是每次根据数字的位数排序,比如数组里面的数字最大有3位,那么先根据个位数将数字放到对应的新创建的数组里,然后在根据十位,然后再根据百位。数组buckets[0]里面放位数是0的数字,buckets[1]里面放位数值1的数字。原理其实就是从低位向高位排序。
动画展示 菜鸟教程
(2)稳定性
稳定
(3)时间和空间复杂度
最好最坏的时间复杂度都是O(n*k),k是数组中最高的位数。空间复杂度为O(n+10).
(4)代码实现
from typing import List
class Solution:
def radix_sort(self, nums: List[int], max_digit) -> List[int]:
buckets = [[] for i in range(10)]
digit = 1
for i in range(max_digit):
for num in nums:
temp_digit = num // digit % 10
buckets[temp_digit].append(num)
index = 0
for i in range(10):
while buckets[i]:
nums[index] = buckets[i].pop(0)
index += 1
digit *= 10
return nums