测试开发基础之算法(3):8种经典排序算法

最经典的、最常用的:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。
他们的时间复杂度如下:
在这里插入图片描述
图片来自:数据结构与算法之美

1、如何评价一个排序算法的优劣?

主要有三种角度:

  • 1、最好、最坏和平均时间复杂度,另外要知道最好、最坏时间复杂对应的原始数据是什么样的
  • 2、实际的软件开发中,排序的往往是小规模数据,所以要考虑时间复杂度的系数、常数 、低阶
  • 3、在基于比较的排序算法中,要考虑比较的次数和数据搬移的次数。
  • 4、是不是原地排序,也就是空间复杂度为O(1)
  • 5、是不是稳定的排序。也就是待排序的序列中如果存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序是否有变。没变化则是稳定的排序。

针对前四点,大家都容易理解。针对第五点可能不好理解,为什么稳定的排序算法更受欢迎呢?

举个例子说明,给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间t,另一个是订单金额s。如果我们现在有 10 万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。

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

2、冒泡排序(Bubble Sort)

def bubble_sort(nums):
    """
    思路:相邻两个元素比较,一共比较length轮冒泡。当前一个元素比后一个元素大时,就交换它们。当某一轮发现没有要交换的元素时,可以提前结束冒泡。
    空间复杂度:O(1),是一个原地排序算法。
    时间复杂度:O(n*n)。当nums本身就有序时,时间复杂度是O(n),当nums本身完全倒序时,时间复杂度最大是O(n*n)。
    平均情况下的时间复杂是多少呢?逆序度 = 满有序度(n*(n-1)/2) - 有序度。冒泡排序包含两个操作原子,比较和交换,交换的次数就是逆序度。
    最坏情况下,初始状态的有序度是 0,所以要进行 n*(n-1)/2 次交换。最好情况下,初始状态的有序度是 n*(n-1)/2,就不需要进行交换。我们可以取个中间值 n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。所以平均情况下的时间复杂度就是 O(n2)。
    """
    length = len(nums)
    if length <= 1:
        return
    for i in range(length):
        flag = False
        for j in range(length - i - 1):
            if nums[j] > nums[j + 1]:
                nums[j], nums[j + 1] = nums[j + 1], nums[j]
                flag = True
        if not flag:
            break

3、插入排序

def insertion_sort(nums):
    """
    将待排序数组分为两部分,【已排序区域】和【未排序区域】。初始已排序区域只有包含第一个元素。核心思想:从未排序区域中取一个数,在已排序区域中找到一个合适位置将其插入,并保证已排序区域一直有序。重复这个过程,直到未排序区域中元素为空,算法结束。
    插入排序包含两种操作,一种是元素的【比较】,一种是元素的【搬移】。
    空间复杂度O(1),是原地排序。
    如果原数组有序,我们并不需要搬移任何数据。如果我们【从尾到头】在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好时间复杂度为 O(n)。
    如果数组是倒序的,最坏时间复杂度是O(n*n)。
    在数组中插入一个数据的平均时间复杂度是 O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O(n*n)。
    """
    length = len(nums)
    if length <= 1:
        return
    for i in range(1, length):
        value = nums[i]
        j = i - 1
        # while循环查找插入的位置
        while value < nums[j] and j >= 0:
            nums[j + 1] = nums[j]  # 数据移动
            j = j - 1
        nums[j + 1] = value  # 数据插入

4、选择排序(性能比较差)

def selection_sort(nums):
    """
    一种很差的排序方法。
    与插入排序类似,将原始数组分成已排序区间和未排序区间。在未排序区间选择最小的,放到已排序区间最开头。重复,直到未排序区间长度为0
    缺点:两层循环每一层循环都需要完全的执行,所有在任何情况下都慢。
    空间复杂度是O(1),最好最坏和平均时间复杂度是O(n*n)。
    它是一种不稳定的排序算法。选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。它是一种非稳定的排序算法。
    """
    count = 0
    length = len(nums)
    if length <= 1:
        return
    for i in range(0, length - 1):
        min_index = i
        for j in range(i + 1, length):
            count = count + 1
            if nums[j] < nums[min_index]:
                min_index = j
        nums[i], nums[min_index] = nums[min_index], nums[i]
    print(f"循环了{count}")

前面介绍了三种时间复杂度都是O(nn)的算法,时间复杂度上虽然是一样的,都是 O(nn),但是这三种算法之间我们首选插入排序,因为从性能上插入好于冒泡好于选择。冒泡排序的数据交换要比插入排序的数据移动要复杂,执行耗时更多。
上面这三种排序算法,实现代码都非常简单,对于小规模数据的排序,用起来非常高效。但是在大规模数据排序的时候,我们更倾向于选择时间复杂度是O(n*logn)的快速排序。

5、归并排序

def merge(l, r):
    """
    归并函数。合并左右两个子数组
    :param l: 左边数组
    :param r: 右边数组
    :return: 合并后数组
    """
    result = []
    i, j = 0, 0
    # 从左边子数组和右边子数组中挑选小的放入result数组中,下标右移1
    while i < len(l) and j < len(r):
        if l[i] <= r[j]:  # 保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。
            result.append(l[i])
            i = i + 1
        else:
            result.append(r[j])
            j = j + 1
    # 如果左边子数组中的所有数据都放入result数组中了,就右边子数组中的数据依次加入到result数组的末尾
    if i == len(l):
        result.extend(r[j:])
    # 如果右边子数组中的所有数据都放入result数组中了,就左边子数组中的数据依次加入到result数组的末尾
    if j == len(r):
        result.extend(l[i:])
    return result


def merge_sort(nums):
    """
    算法原理是使用分治思想,将原始数组拆分成前后两部分,分别对两部分进行排序,最后将结果合并。主要涉及到拆分和合并操作。
    归并排序用的是分治思想,分治思想可以用递归来实现。归并排序其实就是用递归代码来实现的。写递归代码的技巧是,先找到递推公式,再找到终止条件。
    归并算法的递推公式是:merge_sort(left,right) = merge(merge_sort(left,i), merge_sort(i+1,right))
    归并算法的终止条件时:拆分后的数组长度<=1
    https://www.runoob.com/python3/python-merge-sort.html
    归并排序的时间复杂度任何情况下都是 O(nlogn)
    但是空间复杂度是O(n),因此它不是原地排序,所以不如快速排序应用广泛。
    归并函数中,保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。
    """

    length = len(nums)
    if length <= 1:
        return nums
    middle = length // 2
    left = merge_sort(nums[:middle])
    right = merge_sort(nums[middle:])
    return merge(left, right)

6、快速排序

def partition(nums, left, right):
    """
    寻找分区点下标
    :param nums:
    :param left:
    :param right:
    :return: 分区点的下标i
    """
    # 定义分区点是数组的最后一个元素。
    pivot = nums[right]
    i = left  # 最开始已处理区域只包含一个元素
    for j in range(left, right):
        if nums[j] < pivot:  # 从未处理区域比pivot小的数,放到已处理区域尾部
            nums[i], nums[j] = nums[j], nums[i]
            i = i + 1
    # 分区点nums[right]与i的下标对应的只进行交换,将分区点放到i位置
    nums[i], nums[right] = nums[right], nums[i]
    return i

def quick_sort(nums, left, right):
    """
    快速排序使用的也是分治思想,也是用递归来实现的。
    先选取一个分区点pivot,通常是nums的最后一个元素,将比pivot小的数放到pivot左边,比pivot大的数放到pivot右边,pivot放中间。
    我们需要一个分区点函数,求出pivot应该所处的下标,比如i。然后利用递归的思想,分别对nums[left:i]和nums[i+1:right]进行排序。
    递归公式是quick_sort(nums, left, right)=quick_sort(nums, left, i)+quick_sort(nums, i+1, right)
    递归终止条件是left>=right
    快速排序并不是一个稳定的排序算法。快排的时间复杂度也是 O(nlogn),在极端情况下退化到 O(n*n)。
    可以发现,归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。
    :param nums:待排序数组
    :param left:数组起始下标
    :param right:数组终止下标
    """
    if left >= right:
        return
    p = partition(nums, left, right)
    quick_sort(nums, left, p - 1)
    quick_sort(nums, p + 1, right)

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

def find_kth_largest(nums: list, k: int) -> int:
    """
    解题思路:1)参考快速排序中使用的分治思想。先进行快速排序,再取右边数第k个元素。2)利用python包heapq
    :param nums:
    :param k:
    :return:
    """
    left = 0
    right = len(nums) - 1
    quick_sort(nums, left, right)
    # return heapq.nlargest(k, nums)[-1]
    return nums[-k]

练习题2:将 10 个较小的日志文件,合并为 1 个日志文件,合并之后的日志仍然按照时间戳从小到大排列。注意每个日志文件大小约 300MB,内存只有1G。

def merge_files():
    """
    思路:从10个文件中读取一批数据到内存中(不要超过内存大小就行)放入一个数组,在内存中进行O(1)空间复杂度的快速排序,排序完成后一次性写入文件。再读取一批数据重复前面的动作。
    :return:
    """
    pass

前面学习了5中排序算法,最快也就是O(nlogn),比较适合数据量不太大的场景。
如果是海量数据排序,O(nlogn)的时间复杂度还是太高了。针对这种情况,可以选择使用桶排序、计数排序、基数排序,他们的时间复杂度都是O(n)。不过它们对要排序的数据都有比较苛刻的要求,应用不是非常广泛。但是如果数据特征比较符合这些排序算法的要求,应用这些算法,会非常高效。

7、桶排序

def bucket_sort(nums):
    """
    桶排序。时间复杂度O(n)
    算法原理:将待排序的数据存放到几个有序的桶里,对每个桶里的数据单独进行排序,桶内的排序完成后,再按照桶的顺序从里面取数,组成的新数组就是有序的了。
    适用条件:
    1、待排序的数据容易分成m个桶里面,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。
    2、其次,数据在各个桶之间的分布是比较均匀的。否则,极端情况都分到同一个桶里,那就变成快速排序了。
    典型场景:桶排序比较适合用在外部排序中。就是数据存在磁盘中,内存有限不能装下所有的待排序数据时。
    举个例子:
    1、对10GB的订单数据按订单金额进行排序,内存只有几百兆,如何排序?
    2、根据年龄给 100 万用户排序
    """
    pass
    # 1.扫描订单数据,找到订单金额的范围,比如1元~10万元。
    # 2.按订单金额均分到100个桶。第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。
    # 3.如果按照上面均匀划分之后,存在比较大的文件,我们可以继续划分。

8、计数排序

def counting_sort(nums):
    """
    计数排序。一种特殊的桶排序。时间复杂度O(n)
    算法原理:找到待排序的最大值,比如是max_score,建立max_score个桶。每个桶内的数据值都是相同的。
    适用条件:
    1、待排序的数值范围不大。如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了
    2、计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
    举个例子:50万考生成绩排名。成绩范围是0-600。
    :param nums: 考生成绩
    """
    length = len(nums)
    if length <= 1:
        return
    max_score = max(nums)
    c = [0] * (max_score + 1)  # 桶,下标对应分数,值对应的是人数。
    # 计算每个成绩的人数,num是成绩,c[num]是人数
    for num in nums:
        c[num] = c[num] + 1

    # 依次累加
    for j in range(1, max_score + 1):
        c[j] = c[j - 1] + c[j]

    r = [0] * length  # 临时数组r,存储排序之后的结果
    for k in range(length - 1, -1, -1):  # 从后往前扫描原数组
        index = c[nums[k]] - 1  # 个数减1就是下标
        r[index] = nums[k]  # 将成绩放到下标处
        c[nums[k]] = c[nums[k]] - 1  # 人数减1

    return r

9、基数排序

def radix_sort():
    """
    基数排序。时间复杂度O(n)
    算法原理:比较两个数,我们只需要比较高位,高位相同的再比较低位。
    适用条件:
    1、基数排序要求待排序的数据可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。
    2、每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
    3、排序的数据不等长的的话,在后面补"0"
    4、按照每位来排序的排序算法要是稳定的,如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。
    举个例子:有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序。
    """
    pass
    # 先按照最后一位来排序手机号码,
    # 再按照倒数第二位重新排序,以此类推,
    # 最后按照第一位重新排序。
    # 经过 11 次排序之后,手机号码就都有序了。
发布了187 篇原创文章 · 获赞 270 · 访问量 172万+

猜你喜欢

转载自blog.csdn.net/liuchunming033/article/details/103037853