数据结构与算法6:排序算法

文章目录

    • 排序算法的分析角度
    • 冒泡排序 bubble sort
    • 插入排序 insert sort
    • 选择排序 select sort
    • 归并排序 merge sort
    • 快速排序 quick sort
    • 桶排序 bucket sort
    • 计数排序 counting sort
    • 基数排序 radix sort
    • 总结比较

排序算法的分析角度

  • 执行效率(时间复杂度)
    • 最好情况,最坏情况,平均情况时间复杂度及其对应的输入数据
    • 比较同一阶时间复杂度的排序算法时,考虑系数,常数,低阶
    • 对于基于比较的排序算法,考虑比较次数和交换(移动)次数
  • 内存消耗(空间复杂度)
    • 原地排序(sorted in place):空间复杂度为O(1)的排序算法
  • 稳定性
    • 如果待排序序列中存在值相等的元素,排序后相等元素之间原有的先后顺序不变
    • 举例:对电商交易中的订单排序,订单有下单时间和金额两个属性,需要按金额从小到大排序,对于金额相等的订单按照下单时间从早到晚排序。做法:先按照下单时间排序,再使用稳定排序算法按照金额排序。

冒泡排序 bubble sort

  • **从前到后依次比较前后两个元素的值,若前面元素大于后面元素的值,则交换两个元素的位置。**每一轮比较都会至少确定一个元素的最终位置,在每一轮比较之后检查当前轮是否发生元素交换,若无则说明序列已排好序,无需后续操作。
  • 时间复杂度分析:
    • 最坏情况:原始序列倒序,比较和交换的次数为: ( n − 1 ) + ( n − 2 ) + . . . + 1 = n ∗ ( n − 1 ) / 2 (n-1)+(n-2)+...+1=n*(n-1)/2 (n1)+(n2)+...+1=n(n1)/2,即 O ( n 2 ) O(n^2) O(n2)
    • 最好情况:原始序列正序,第一轮比较后发现无元素交换,退出后续操作,比较的次数为(n-1),交换的次数为0,即O(n)
    • 平均情况:原始序列有序对和逆序对数目相当,比较的次数为:(n-1)+(n-2)+...+1=n*(n-1)/2,交换的次数n*(n-1)/4,即 O ( n 2 ) O(n^2) O(n2)
  • 稳定性分析:为了保证算法的稳定性,当前后元素相等时不进行交换。
  • 空间复杂度分析:可能需要存储的变量有两次循环的轮次i,j,元素发生交换的标记flag,交换元素的临时变量temp,额外空间复杂度为O(1)
  • 应用场景:数据量不大时
    def bubble_sort(self, nums):
        """冒泡排序
        从前到后依次比较前后两个元素的值,若前面元素大于后面元素的值,则交换两个元素的位置。每一轮比较都会至少确定一个元素的最终位置,在每一轮比较之后检查当前轮是否发生元素交换,若无则说明序列已排好序,无需后续操作。时间复杂度O(n^2),空间复杂度O(1),原地排序,稳定排序
        :return: 排好序的数组
        """
        if len(nums) <= 1:
            return nums
    
        for i in range(len(nums)):
            flag = 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]
                    flag = True
            if not flag:
                break
        return nums
    

插入排序 insert sort

  • 将原始序列中的第一个元素划分为已排序部分,剩余元素划分为未排序部分。每一轮从未排序部分取其首个元素,在已排序部分找到合适的位置将其插入。排序过程将该元素与已排序部分元素按照从尾到头或从头到尾的顺序依次比较寻找插入位置,直到未排序部分元素为空。
  • 时间复杂度分析:
    • 最坏情况:原始序列倒序,对于从尾到头的比较顺序,比较和交换的次数为1+2+...+(n-1)=n*(n-1)/2,即O(n^2);对于从头到尾的比较顺序,比较次数为1+1+...+1=(n-1),交换次数为0+0+...+0=0,即O(n).故对于待排序元素逆序对远远多于正序对的情况,应该使用从头到尾的比较顺序,反之应该使用从尾到头的比较顺序。
    • 最好情况:原始序列正序,对于从尾到头的比较顺序,比较次数为1+1+...+1=(n-1),交换次数为0+0+...+0=0,即O(n);对于从头到尾的比较顺序,比较和交换的次数为1+2+...+(n-1)=n*(n-1)/2,即O(n^2)
    • 平均情况:在数组中插入一个元素的平均时间复杂度为O(n),n次插入操作的平均时间复杂度为O(n^2)
  • 稳定性分析:对于值相同的元素,我们将后面出现的元素插入到前面元素的后面来保证稳定性
  • 空间复杂度分析:可能需要存储的变量有两次循环的轮次i,j,交换元素的临时变量temp,额外空间复杂度为O(1)
  • 应用场景:数据量不大时
    def insert_sort(nums):
        """插入排序
        将原始排序元素划分为已排序部分和未排序部分,每次取未排序部分的首个元素依次和已排序部分的元素进行比较,将其插入合适的位置,当未排序部分没有元素时结束。
        """
        if len(nums) <= 1:
            return nums
        # 从第二个值开始,依次和前面的比较
        for i in range(1, len(nums)):
            flag = False
            for j in range(i, 0, -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
    

选择排序 select sort

  • **将原始待排序序列分为已排序区间和未排序区间。初始已排序区间为空,每次从未排序区间选择最小的元素放到已排序区间的末尾,实为从未排序区间选择最小值,将其与未排序区间的第一个元素交换位置,并将此位置划分给已排序区间。**包括未排序区间最小值的寻找和元素交换两步。
  • 时间复杂度分析:所有情况下,寻找最小值需要比较的次数都为(n-1)+(n-2)+...+1=n*(n-1)/2。最好情况下元素交换的次数为0,最坏情况下元素交换的次数为1+1+...+1=(n-1),平均情况下元素交换的次数为(n-1)/2。总的时间复杂度为O(n^2)
  • 稳定性分析:排序过程涉及到元素位置的交换,不稳定
  • 空间复杂度分析:可能需要存储的变量有:两次循环的轮次i,j,每轮中获得的最小值min及其下标index,交换元素的临时变量temp,额外空间复杂度为O(1)
  • 应用场景:数据量不大时
    def select_sort(nums):
        """选择排序
        将原始待排序数组划分为已排序部分和未排序部分,每次遍历未排序部分,从中选出最小值,将其添加到已排序部分,实为与未排序部分第一个元素交换位置(不稳定),并将此位置划分为已排序部分。时间复杂度为O(n^2),空间复杂度为O(1),不稳定
        """
        if len(nums) <= 1:
            return nums
        for i in range(len(nums)):
            # 找最小值
            index_min = -1
            for j in range(i, len(nums)):
                if nums[j] <= nums[index_min]:
                    index_min = j
            # 交换位置
            nums[i], nums[index_min] = nums[index_min], nums[i]
        return nums
    

归并排序 merge sort

  • 采用分治思想,将待排序序列平均划分为两个子序列,分别对其进行排序,排序完成后再合并两个有序子数组,分为子序列排序和有序子序列合并两步。
  • 时间复杂度分析:最坏、最好、平均情况下都相同
    • 假设对n个元素进行归并排序需要的时间为T(n),那么分解为两个子数组排序的时间都为T(n/2),merge()函数合并两个有序子数组的时间复杂度为O(n),则时间复杂度递推公式为: T ( 1 ) = c , T ( n ) = 2 ∗ T ( n / 2 ) + n , n > 1 T(1)=c, T(n) = 2*T(n/2)+n, n>1 T(1)=c,T(n)=2T(n/2)+n,n>1
    • 推导:
      T ( n ) = 2 ∗ T ( n / 2 ) + n = 2 ∗ ( 2 ∗ T ( n / 4 ) + n / 2 ) + n = 4 ∗ T ( n / 4 ) + 2 ∗ n = 2 ∗ ( 2 ∗ ( 2 ∗ T ( n / 8 ) + n / 4 ) + n / 2 ) + n = 8 ∗ T ( n / 8 ) + 3 ∗ n = . . . = ( 2 k ) ∗ T ( n / ( 2 k ) ) + k ∗ n T(n) = 2*T(n/2)+n = 2*(2*T(n/4)+n/2)+n = 4*T(n/4)+2*n = 2*(2*(2*T(n/8)+n/4)+n/2)+n = 8*T(n/8)+3*n = ... = (2^k)*T(n/(2^k)) + k*n T(n)=2T(n/2)+n=2(2T(n/4)+n/2)+n=4T(n/4)+2n=2(2(2T(n/8)+n/4)+n/2)+n=8T(n/8)+3n=...=(2k)T(n/(2k))+kn
    • 2 k = n 2^k=n 2k=n时, k = l o g 2 ( n ) k=log2(n) k=log2(n),则 T ( n ) = 2 k ∗ T ( 1 ) + k ∗ n = c ∗ n + n ∗ l o g 2 ( n ) = n l o g n + c ∗ n T(n)=2^k*T(1)+k*n = c*n +n*log2(n) = nlogn+c*n T(n)=2kT(1)+kn=cn+nlog2(n)=nlogn+cn,时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)
  • 稳定性分析:在对两个子数组进行合并时,当两个子数组中有相同的元素时,我们可以先将前面的元素复制到临时数组中,以此来保证稳定性
  • 空间复杂度分析:可能需要存储的变量有:合并数组时使用的临时数组temp,额外空间复杂度为O(n),不是原地排序算法
  • 应用场景:大数据量排序,对存储空间没有要求
    def merge_sort(nums):
        """归并排序
        将待排序数组平均划分为两个数组,分别进行归并排序,再将两个排好序的数组合并。
        """
        if len(nums) <= 1:
            return nums
        mid = len(nums)//2
        nums1 = self.merge_sort(nums[:mid])
        nums2 = self.merge_sort(nums[mid:])
        return self._merge(nums1, nums2)
    
    def _merge(nums1, nums2):
        """合并两个已排序数组
        1.有一个为空;2.有一个数组元素全部小于另一个数组;3.
        """
        nums = []
        i, j = 0, 0
        # 对两个数组按照元素大小链接,直到其中一个为空
        while i < len(nums1) and j < len(nums2):
            if nums1[i] <= nums2[j]:
                nums.append(nums1[i])
                i += 1
            else:
                nums.append(nums2[j])
                j += 1
        # 将剩下的数组添加到结果尾部
        if i == len(nums1):
            nums.extend(nums2[j:])
        else:
            nums.extend(nums1[i:])
        return nums
    

快速排序 quick sort

  • 对于待排序序列中下标为p:r的一段数据,选择任意一个数据作为分区点pivot。遍历p:r之间的数据,小于pivot的放在左边,大于pivot的放在右边,pivot放在中间。再对p:q-1和q+1:r的数据分别重复这个过程。对于将数组按照分区点左右划分的过程,可以使用两个数组分别存储小于pivot和大于pivot的数据,空间复杂度为O(n);一种更加巧妙的做法是:遍历数组中的元素,若小于分区点则将其与数组第i个元素交换(i初始化为0),i++,遍历完成后将a[i]与pivot交换(pivot一般设为数组最后一个元素)。
  • 时间复杂度分析:大部分情况下为O(nlogn),极端情况下为O(n^2)
    • 最坏情况:原始序列正序,选取最后一个元素为分区点,每次分区极其不均衡,时间复杂度退化为 O ( n 2 ) O(n^2) O(n2)
    • 最好情况:每次分区极其均衡,时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)
    • 平均情况:时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)
  • 稳定性分析:分区过程中涉及元素的交换,不稳定
  • 空间复杂度分析:可能需要存储的变量有:分区下标start,mid,end,分区点的值pivot,两层循环次数i,j,额外空间复杂度为O(1),是原地排序算法
  • 应用场景:大数据量,对稳定性没有要求
    def quick_sort(nums):
        """
        快速排序,将划分函数独立出去,使用O(1)的空间复杂度
        """
        self._quick_sort_between(nums, 0, len(nums) - 1)
    
    def _quick_sort_between(a, start, end):
    	"""对数组a在[start: end]区间进行快速排序"""
        if start < end:
            mid = self._partition(a, start, end)
            self._quick_sort_between(a, start, mid - 1)
            self._quick_sort_between(a, mid + 1, end)
    
    def _partition(a, start, end):
    	"""划分函数"""
        pivot = a[end]
        i = start
        for j in range(start, end):
            if a[j] < pivot:
                a[i], a[j] = a[j], a[i]
                i += 1
        a[i], a[end] = a[end], a[i]
        return i
    
  • O(n)时间复杂度内求无序数组的第K大元素
    • 将无序数组a进行分区,选择最后一个元素a[n-1]作为分区点,将大于分区点的数据划分到左边,小于分区点的数据划分到右边,分区点所在的下标为p。若p+1=K,则分区点为第K大元素;若p+1K,则在左边区间[:p-1]继续上述操作。
    • 时间复杂度分析:每一次对分区遍历的次数为n,n/2,n/4,...,1,此等比数列的和为 ( 1 − 2 ( l o g n + 1 ) ) / ( 1 − 2 ) = 2 n − 1 (1-2^{(logn+1)})/(1-2)=2n-1 (12(logn+1))/(12)=2n1,时间复杂度为 O ( n ) O(n) O(n)

桶排序 bucket sort

  • 桶排序假设输入由一个随机过程产生,该过程将元素一致地分布在区间[0,1)上。桶排序的思想就是把区间[0,1)划分为n个相同大小的子区间,再把区间内的元素划分到对应的桶里面,对每个桶的数据单独进行排序(如使用快速排序算法)。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成序列。
  • 时间复杂度分析:将要排序的n个数据均匀的划分到m个桶里,每个桶里有k=n/m个元素。每个桶内部使用快速排序,时间复杂度为O(k*logk)m个桶排序的时间复杂度为O(m*k*logk),即O(n*log(n/m))。当m接近n时,将log(n/m)看做非常小的常量,这时桶排序的时间复杂度接近O(n)
  • 应用场景:
    • 要排序的数据能够比较容易地划分为m个桶,而且桶之间存在天然的大小关系。如年龄,考试分数,订单金额。
    • 数据在各个桶之间的分布比较均匀。如果各个桶内的分布非常不均匀,那桶内数据排序的时间复杂度就不再是常量级了。而如果全部数据划分到一个桶内部这种极端情况下,桶排序就退化为O(nlogn)的排序算法了。
    • 桶排序适合应用在外部排序中:数据量大,无法一次性加载到内存中的情况。
    def bucket_sort(nums, bins = 10):
        """桶排序,每个桶内部使用快速排序 """
        num_max = nums.max()
        num_min = nums.min()
        step = (num_max - num_min)/bins
        ans = []*bins
        for i in range(len(nums)):
            bucket_index = int((nums[i]-num_min)//step)
            ans[bucket_index].append(nums[i])
        # 对每个桶运行排序算法
        res = []
        for j in range(bins):
            ans[j] = quick_sort(ans[j])
            res.extend(ans[j])
        return res
    

计数排序 counting sort

  • 计数排序是一种特殊的桶排序,当要排序的n个数据范围变化不大时,可以按照数据取值k进行分桶,将相同的数据划分到一个桶中。
  • 应用场景:
    • 数据数量大,但是取值范围不大,存在相同元素
    • 非负整数:对于其它类型数据,需要在不改变其相对大小的情况下,转换为非负整数
  • 实现:
  1. 确定数据范围,最大值(和最小值)
  2. 构造临时数组c存储每个数据出现的次数
  3. 从前到后依次累加每个数据出现的次数
  4. 从后往前遍历原始数组,将其在数组c中对应的值减去1后作为下标存在一个新建的临时数组r中,同时数组c中相应的count值减1
  5. 将已排序数组r拷贝到原始数组a中
  • 时空复杂度分析:假设原始数据数量为n,数据范围为0-k的整数,其中n>>k
  1. 获取原始数据最大值,时间复杂度为O(n),空间复杂度为O(1)
  2. 构造数组c,统计每个数据出现的次数,时间复杂度为O(n),空间复杂度为O(k)
  3. 累加每个元素出现的次数,时间复杂度为O(n),空间复杂度为O(1)
  4. 构造临时数组r,保存排序结果,时间复杂度为O(n),空间复杂度为O(n)
  5. 将数组r拷贝到数组a,删除数组r,时间复杂度为O(n),空间复杂度为O(1)
    总的时间复杂度为O(n+k),空间复杂度为O(n+k)
    def count_sort(nums):
        """计数排序
        按照元素取值范围划分,统计每个桶的数量,拼接为最终结果。适用于元素取值范围有限的输入数组。时间复杂度O(n),空间复杂度O(n)
        """
        # 确定数组范围
        num_max = nums.max()
        num_min = nums.min()
        # 统计元素个数
        count = np.zeros((num_max - num_min + 1,), dtype=int)
        for num in nums:
            count[num-num_min] += 1
        # 结果拼接
        res = []
        for i in range(len(count)):
            if count[i] != 0:
                res.extend([num_min+i]*count[i])
        return res
    

基数排序 radix sort

  • 应用场景:
    • 排序数据可以分割出独立的“位”进行比较,而且位之间有递进的关系,比如字符串,手机号码,单词
    • 每一位的数据范围不能过大,要可以使用线性排序算法来排序
  • 实现方法:
  1. 对于不等长的排序数据,需要补齐到相同长度,如英文单词后面补0
  2. 将要排序的数据先按照最后一位来排序,再按照倒数第二位来排序,以此类推,最后按照第一位来排序
  3. 可以使用桶排序,计数排序等线性稳定排序算法对每一位进行排序
  • 时间复杂度分析:假设一共有k位,数据量为n,时间复杂度为O(k*n)

总结比较

排序算法 最坏 平均 最好 稳定性 原地排序 是否基于比较 代码复杂度
冒泡排序 O(n^2) O(n^2) O(n) 稳定 简单
插入排序 O(n^2) O(n^2) O(n^2) 稳定 简单
选择排序 O(n^2) O(n^2) O(n^2) 不稳定 简单
归并排序 O(nlogn) O(nlogn) O(nlogn) 稳定 较复杂
快速排序 O(n^2) O(nlogn) O(nlogn) 不稳定 较复杂
桶排序 O(nlogn) O(nlog(n/m)) O(n) 稳定 较复杂
计数排序 O(n+k) O(n+k) O(n+k) 稳定 较复杂
基数排序 O(k*n) O(k*n) O(k*n) 稳定 较复杂

你可能感兴趣的:(数据结构与算法)