第4章_数据结构与算法(一)

22 阅读21分钟

@TOC

1.选择排序

选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理是每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,所以称为:选择排序。1

def selection_sort(arr):
    """选择排序"""
    # 第一层for表示循环选择的遍数
    for i in range(len(arr) - 1):
        # 将起始元素设为最小元素
        min_index = i
        # 第二层for表示最小元素和后面的元素逐个比较
        for j in range(i + 1, len(arr)):
            if arr[j] < arr[min_index]:
                # 如果当前元素比最小元素小,则把当前元素角标记为最小元素角标
                min_index = j
        # 查找一遍后将最小元素与起始元素互换
        arr[min_index], arr[i] = arr[i], arr[min_index]
    return arr

selection_sort([11, 99, 33 , 69, 77, 88, 55, 11, 33, 36,39, 66, 44, 22])
#返回结果 [11, 11, 22, 33, 33, 36, 39, 44, 55, 66, 69, 77, 88, 99]

选择排序和冒泡排序很类似,但是选择排序每轮比较只会有一次交换,而冒泡排序会有多次交换,交换次数比冒泡排序少,就减少cpu的消耗,所以在数据量小的时候可以用选择排序,实际适用的场合非常少。

  1. 比较性:因为排序时元素之间需要比较,所以是比较排序

  2. 稳定性:因为存在任意位置的两个元素交换,比如[5, 8, 5, 2],第一个5会和2交换位置,所以改变了两个5原来的相对顺序,所以为不稳定排序。

  3. 时间复杂度:我们看到选择排序同样是双层循环n*(n-1)),所以时间复杂度也为:O(n^2)

  4. 空间复杂度:只需要常数个辅助单元,所以空间复杂度也为O(1)

  5. 记忆方法:选择对象要先选最小的,因为嫩,哈哈

其动图演示见2

2.插入排序

原理3

  • 从第二个元素开始和前面的元素进行比较,如果前面的元素比当前元素大,则将前面元素 后移,当前元素依次往前,直到找到比它小或等于它的元素插入在其后面

  • 然后选择第三个元素,重复上述操作,进行插入

  • 依次选择到最后一个元素,插入后即完成所有排序

def insertion_sort(arr):
    """插入排序"""
    # 第一层for表示循环插入的遍数
    for i in range(1, len(arr)):
        # 设置当前需要插入的元素
        current = arr[i]
        # 与当前元素比较的比较元素
        pre_index = i - 1
        while pre_index >= 0 and arr[pre_index] > current:
            # 当比较元素大于当前元素则把比较元素后移
            arr[pre_index + 1] = arr[pre_index]
            # 往前选择下一个比较元素
            pre_index -= 1
        # 当比较元素小于当前元素,则将当前元素插入在 其后面
        arr[pre_index + 1] = current
    return arr

insertion_sort([11, 11, 22, 33, 33, 36, 39, 44, 55, 66, 69, 77, 88, 99])

# 返回结果[11, 11, 22, 33, 33, 36, 39, 44, 55, 66, 69, 77, 88, 99]

插入排序的适用场景:一个新元素需要插入到一组已经是有序的数组中,或者是一组基本有序的数组排序。

  • 比较性:排序时元素之间需要比较,所以为比较排序
  • 稳定性:从代码我们可以看出只有比较元素大于当前元素,比较元素才会往后移动,所以相同元素是不会改变相对顺序
  • 时间复杂度:插入排序同样需要两次循坏一个一个比较,故时间复杂度也为O(n^2)
  • 空间复杂度:只需要常数个辅助单元,所以空间复杂度也为O(1)
  • 记忆方法:想象成在书架中插书:先找到相应位置,将后面的书往后推,再将书插入

动图演示见4

3.冒泡排序

原理5

  • 从第一个和第二个开始比较,如果第一个比第二个大,则交换位置,然后比较第二个和第三个,逐渐往后
  • 经过第一轮后最大的元素已经排在最后,所以重复上述操作的话第二大的则会排在倒数第二的位置。
  • 那重复上述操作n-1次即可完成排序,因为最后一次只有一个元素所以不需要比较
def bubble_sort(arr):
    """冒泡排序"""
    # 第一层for表示循环的遍数
    for i in range(len(arr) - 1):
        # 第二层for表示具体比较哪两个元素
        for j in range(len(arr) - 1 - i):
            if arr[j] > arr[j + 1]:
                # 如果前面的大于后面的,则交换这两个元素的位置
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
    return arr

bubble_sort([11, 99, 33 , 69, 77, 88, 55, 11, 33, 36,39, 66, 44, 22])
#返回结果 [11, 11, 22, 33, 33, 36, 39, 44, 55, 66, 69, 77, 88, 99]

冒泡排序是一种简单直接暴力的排序算法,为什么说它暴力?因为每一轮比较可能多个元素移动位置,而元素位置的互换是需要消耗资源的,所以这是一种偏慢的排序算法,仅适用于对于含有较少元素的数列进行排序。

  • 稳定性:我们从代码中可以看出只有前一个元素大于后一个元素才可能交换位置,所以相同元素的相对顺序不可能改变,所以它是稳定排序
  • 比较性:因为排序时元素之间需要比较,所以是比较排序
  • 时间复杂度:因为它需要双层循环n*(n-1)),所以平均时间复杂度为O(n^2)
  • 空间复杂度:只需要常数个辅助单元,所以空间复杂度为O(1),我们把空间复杂度为O(1)的排序成为原地排序(in-place)
  • 记忆方法:想象成气泡,一层一层的往上变大

动图演示见6

4.归并排序

归并排序采用分而治之的原理:7

  • 将一个序列从中间位置分成两个序列;
  • 在将这两个子序列按照第一步继续二分下去;
  • 直到所有子序列的长度都为1,也就是不可以再二分截止。这时候再两两合并成一个有序序列即可。
def merge_sort(arr):
    """归并排序"""
    if len(arr) == 1:
        return arr
    # 使用二分法将数列分两个
    mid = len(arr) // 2
    left = arr[:mid]
    right = arr[mid:]
    # 使用递归运算
    return marge(merge_sort(left), merge_sort(right))


def marge(left, right):
    """排序合并两个数列"""
    result = []
    # 两个数列都有值
    while len(left) > 0 and len(right) > 0:
        # 左右两个数列第一个最小放前面
        if left[0] <= right[0]:
            result.append(left.pop(0))
        else:
            result.append(right.pop(0))
    # 只有一个数列中还有值,直接添加
    result += left
    result += right
    return result

merge_sort([11, 99, 33 , 69, 77, 88, 55, 11, 33, 36,39, 66, 44, 22])

# 返回结果[11, 11, 22, 33, 33, 36, 39, 44, 55, 66, 69, 77, 88, 99]

特点:

  • 比较性:排序时元素之间需要比较,所以为比较排序
  • 稳定性:我们从代码中可以看到当左边的元素小于等于右边的元素就把左边的排前面,而原本左边的就是在前面,所以相同元素的相对顺序不变,故为稳定排序
  • 时间复杂度: 复杂度为O(nlog^n)
  • 空间复杂度:在合并子列时需要申请临时空间,而且空间大小随数列的大小而变化,所以空间复杂度为O(n)
  • 记忆方法:所谓归并肯定是要先分解,再合并

动图演示见8

5.快速排序

原理:9

  • 在数列之中,选择一个元素作为”基准”(pivot),或者叫比较值。
  • 数列中所有元素都和这个基准值进行比较,如果比基准值小就移到基准值的左边,如果比基准值大就移到基准值的右边
  • 以基准值左右两边的子列作为新数列,不断重复第一步和第二步,直到所有子集只剩下一个元素为止。
class Solution:
    def quickSortV1(self,array):
        if len(array) < 2:
            return array
        # 数组只有一个值则返回
        mid = array[len(array) // 2]
        # 选取基准,随便选哪个都可以
        left, right = [], []
        # 定义基准值左右两个数列
        array.remove(mid)
        # 从原始数组中移除基准值
        for item in array:
            if item >= mid:
                right.append(item)
                # 大于基准值放右边
            else:
                left.append(item)
                # 小于基准值放左边
        return self.quickSortV1(left) + [mid] + self.quickSortV1(right)
        # 递归对新数组的左右两边进行排序
    
    def quickSortV2(self,array):
        pivot = array[0]
        # 默认设置第一个为基准值
        left , right = 0 , len(array)-1
        # 初始化左右指针
        while (left<right):
        # 两指针不重合
            while (left<right) and (array[right]>=pivot):
            # 两指针不重合且当前右指针大于等于基准值
                right -= 1
                # 右指针左移
            array[left] = array[right]
            # 直到右指针小于基准值,将右指针覆盖到左指针
            # 此时右指针的值意义,等待左指针覆盖
            while (left<right) and (array[left]<=pivot):
            # 两指针不重合且当前左指针小于等于基准值
                left += 1
                # 左指针右移
            array[right] = array[left]
            # 直到左指针大于基准值,将左指针覆盖到右指针
            # 此时左指针的值意义,等待右指针覆盖
        array[left] = pivot
        # 跳出循环后,两指针重合,左边小于基准值,右边大于基准值
        # 此时两指针的值无意义,将基准值覆盖到左指针
        if(len(array[0:left])>1):
            array[0:left] = self.quickSortV2(array[0:left])
        if(len(array[left+1:len(array)])>1):
            array[left+1:len(array)] = self.quickSortV2(array[left+1:len(array)])
        return array
        
solve = Solution()

%timeit solve.quickSortV1([92, 72, 53, 22, 53, 60, 93])
%timeit solve.quickSortV2([92, 72, 53, 22, 53, 60, 93])

# In [4]: %timeit solve.quickSortV1([92, 72, 53, 22, 53, 60, 93])
# 5.77 µs ± 198 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

# In [5]: %timeit solve.quickSortV2([92, 72, 53, 22, 53, 60, 93])
# 3.99 µs ± 313 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

特点:

  • 稳定性:快排是一种不稳定排序,比如基准值的前后都存在与基准值相同的元素,那么相同值就会被放在一边,这样就打乱了之前的相对顺序
  • 比较性:因为排序时元素之间需要比较,所以是比较排序
  • 时间复杂度:快排的时间复杂度为O(nlogn)
  • 空间复杂度:排序时需要另外申请空间,并且随着数列规模增大而增大,其复杂度为:O(nlogn)
  • 归并排序与快排 :归并排序与快排两种排序思想都是分而治之,但是它们分解和合并的策略不一样:归并是从中间直接将数列分成两个,而快排是比较后将小的放左边大的放右边,所以在合并的时候归并排序还是需要将两个数列重新再次排序,而快排则是直接合并不再需要排序,所以快排比归并排序更高效一些,可以从示意图中比较二者之间的区别。
  • 快速排序有一个缺点就是对于小规模的数据集性能不是很好。

动图演示见10

6.希尔排序

原理:11 希尔排序的整体思想是将固定间隔的几个元素之间排序,然后再缩小这个间隔。这样到最后数列就成为了基本有序数列,而前面我们讲过插入排序对基本有序数列排序效果较好。

  • 计算一个增量(间隔)值
  • 对元素进行增量元素进行比较,比如增量值为7,那么就对0,7,14,21…个元素进行插入排序
  • 然后对1,8,15…进行排序,依次递增进行排序
  • 所有元素排序完后,缩小增量比如为3,然后又重复上述第2,3步
  • 最后缩小增量至1时,数列已经基本有序,最后一遍普通插入即可
def shell_sort(arr):
    """希尔排序"""
    # 取整计算增量(间隔)值
    gap = len(arr) // 2
    while gap > 0:
        # 从增量值开始遍历比较
        for i in range(gap, len(arr)):
            j = i
            current = arr[i]
            # 元素与他同列的前面的每个元素比较,如果比前面的小则互换
            while j - gap >= 0 and current < arr[j - gap]:
                arr[j] = arr[j - gap]
                j -= gap
            arr[j] = current
        # 缩小增量(间隔)值
        gap //= 2
    return arr

shell_sort([11, 11, 22, 33, 33, 36, 39, 44, 55, 66, 69, 77, 88, 99])

# 返回结果[11, 11, 22, 33, 33, 36, 39, 44, 55, 66, 69, 77, 88, 99]

特点 

  • 比较性:排序时元素之间需要比较,所以为比较排序
  • 稳定性:因为希尔排序是间隔的插入,所以存在相同元素相对顺序被打乱,所以是不稳定排序
  • 时间复杂度: 最坏时间复杂度O(n^2)平均复杂度为O(n^1.3)
  • 空间复杂度:只需要常数个辅助单元,所以空间复杂度也为O(1)
  • 记忆方法:插入排序是每轮都是一小步,希尔排序是先大步后小步,它第一个突破O(n2)的排序算法。联想起阿姆斯特朗登月之后说:这是我个人一小步,却是人类迈出的一大步。

动图演示见12

7.堆排序

堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了13

代码参考菜鸟教程,做了一些更改,原代码似乎有误:14

def heapify(arr, n, i): 
    """
    在大小为n的完全二叉树arr当中,只有最上面的三个节点不符合堆
    因此,对根节点i及其左右节点进行调整,即可对整个树建堆
    进行调整后,破坏了下面的堆,递归调用自身,恢复下面的堆
    """

    largest = i
    # 设最大值的下标为根节点
    left = 2 * i + 1
    # 左节点的下标
    right = 2 * i + 2
    # 右节点的下标

    if( (left < n) and (arr[largest] < arr[left]) ): 
    # 存在左节点,且左节点大于最大值
        largest = left
        # 更新最大值的下标
  
    if( (right < n) and (arr[largest] < arr[right]) ): 
    # 存在右节点,且右节点大于最大值
        largest = right
        # 更新最大值的下标
  
    if(largest != i): 
    # 如果最大值不是根节点
        arr[i],arr[largest] = arr[largest],arr[i]
        # 交换两者的值
        heapify(arr, n, largest)
        # 由于进行了交换,所以最大值为根的堆遭到破坏
        # 所以要对这个子树进行重建,保证其为堆

# 这里应当是属于递归的,但是似乎并不需要特意设置出口
# 因为这个函数的操作是调整数组,并不携带返回值
# 也就是说只需要等待其调整完成,函数自然结束即可
# 所以其“递”序就是函数末尾再调用进行压栈
# 而“归”序是从栈顶一级一级自然结束并由系统返回

def buildHeap(arr, n):
    """
    将大小为n的完全二叉树arr建造为堆
    自底向上进行建堆,保证底部为堆,每次仅调整顶部
    """
    LastNode = n - 1
    # 最后一个叶子节点的下标
    parentNode = (LastNode - 1) // 2
    # 最后一个父节点的下标
    for i in range(parentNode, -1, -1): 
    # 从最后一个父节点开始建堆
        heapify(arr, n, i)
        # 对子树的顶部三个节点进行调整

def heapSort(arr): 
    """
    利用建堆进行排序
    """
    n = len(arr)
    # n为二叉树的大小
    buildHeap(arr, n)
    # 对整个完全二叉树进行建堆,是自下而上的
    # 建堆完成后,二叉树已经是一个大顶堆
    # 也就是符合大顶堆的定义,子节点总是小于父节点
    for i in range( n-1, -1, -1 ): 
        arr[i], arr[0] = arr[0], arr[i]
        # 交换两者的值,以进行砍断操作
        heapify(arr, i, 0)
        # 树的大小减一,砍掉尾部的最大值
        # 对这个被破坏的、新的大顶堆进行修正
        # 此时只有顶部被破坏,底部仍然为堆
        # 修正可以使得整个二叉树继续为堆
  
arr = [ 12, 11, 13, 5, 6, 7] 
heapSort(arr) 
for item in arr: 
    print (item)

详细的解释见bilibili的视频15,也可参考静态图解16

8.计数排序
class Solution:
    def countSortV1(self, arr: list) -> list :
        """
        根据差值来确定计数数组的长度
        遍历计数数组对原数组进行排序
        """
        Max, Min = max(arr), min(arr)
        # 求出最大最小值
        count = [0] * (Max-Min+1)
        # 根据数组的长度建立计数数组
        for arrI in arr:
        # 对数组的每个元素进行计数
            count[arrI-Min] += 1
            # arrI的计数+1
        result = []
        # 结果数组
        for index in range(Max-Min+1):
        # 根据计数数组还原顺序
            while(count[index]>0):
            # 如果计数不为0
                result.append(index+Min)
                # 加入结果数组
                count[index] -= 1
                # 计数-1
        return result
    
    def countSortV2(self, arr: list) -> list :
        """
        根据差值来确定计数数组的长度
        遍历原始数组进行排序
        """
        Max, Min = max(arr), min(arr)
        # 求出最大最小值
        count = [0] * (Max-Min+1)
        # 根据数组的长度建立计数数组
        for arrI in arr:
        # 对数组的每个元素进行计数
            count[arrI-Min] += 1
            # arrI的计数+1
        for index in range(1,(len(count))):
        # 从第二项开始对计数数组变形
            count[index] += count[index-1]
            # 计数数组每一项都累加前面的值
            # 现在计数数组的值就是排序的位置
        result = [0] * len(arr)
        # 结果数组
        for arrI in arr:
        # 遍历原始数组
            result[count[arrI-Min]-1] = arrI
            # 将数据填入排序后的位置,位置-1才是下标
            # 最小值至少出现一次,所以最小为0,不会越界
            count[arrI-Min] -= 1
            # 更改计数数组的值
            # 再有相同的数值就向前一位顺延
            # 因为计数数组当中的值是相同的数据的个数
            # 所以累加后是相同数字当中最后一个的下标
            # 那么我们在往结果数组填充时就要-1向前
            # 如果想要结果数组当中相同数字的位置与原始数组一致
            # 我们可以换方向,即从后向前遍历原始数组,arr[::-1]
        
        return result

solve = Solution()
arr = [92, 72, 53, 22, 53, 60, 93]
print(solve.countSortV1(arr))
print(solve.countSortV2(arr))

虽然它可以将排序算法的时间复杂度降低到O(N),但是有两个前提需要满足:一是需要排序的元素必须是整数,二是排序元素的取值要在一定范围内,并且比较集中。只有这两个条件都满足,才能最大程度发挥计数排序的优势。17

图解及详细解释等见17

9.桶排序

划分多个范围相同的区间,每个子区间自排序,最后合并。18

def bucketSort(arr) :
    min_num = min(arr)
    # 取最小值
    max_num = max(arr)
    # 取最大值
    bucket_range = (max_num-min_num) / (len(arr)-1)
    # 计算桶的范围
    count_list = [ [] for _ in range(len(arr))]
    # 初始化桶
    for arrI in arr:
        count_list[int( (arrI-min_num)//bucket_range )].append(arrI)
        # 将数据放入对应的桶
    arr.clear()
    # 清除原列表
    for count in count_list:
    # 对于每一个桶
        for item in sorted(count):
            arr.append(item)
        # 桶内排序后加入新列表
    return arr

arr = [92, 72, 53, 22, 53, 60, 93]
print(bucketSort(arr))

关于如何计算桶的间隔及初始化桶,见下图:

stateDiagram-v2

    min_num --> min_num+bucket_range:bucket_0
    min_num+bucket_range --> min_num+2*bucket_range: bucket_1
    min_num+2*bucket_range --> min_num+3*bucket_range: bucket_2
    min_num+3*bucket_range --> max_num(min_num+4*bucket_range): bucket_3
    max_num(min_num+4*bucket_range) --> min_num+5*bucket_range: bucket_4
    
    %%note left of min_num : This is the note to the left.

一般来说,我们会按照原数据的长度来初始化桶,即桶的数量等于原数组长度,当然也有其他的方法。这里我们假设原数组有5个值,需要初始化5个桶。

桶大小的计算是根据桶的数量来的,即(最大值 - 最小值)/ (桶的数量 - 1)。从图中我们可以看到,最大值和最小值之间有4个桶,所以差值除以4就得到了桶内间隔。

每个桶都是左闭右开的,例如第一个桶包含min_num但不包含min_num+bucket_range;而最后一个桶包含max_num(min_num+4*bucket_range)但不包含min_num+5*bucket_range。实际上来说,原数组当中最大的就是max_num(min_num+4*bucket_range),所以说最后一个桶其实只有一个最大值。

需要注意点是:

  • 桶数量的划分
  • 桶内排序的算法
10.基数排序

基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。19

class Solution:
    def radixSort(self, arr: list[int]) -> list :
        """基数排序"""
        bit_length = len(str(max(arr)))
        # 记录最大值的位数
        for bit in range(bit_length):
        # 对每一位进行排序
            bucket_list =[[] for _ in range(10)]
            # 初始化桶数组
            for arrI in arr:
            # 对数据分桶
                bucket_list[int(arrI / (10**bit)) % 10].append(arrI)
                # 先将此轮排序的这一位推到个位,(arrI / (10**(bit+1)))
                # 再用%10取出这一位,将arrI放入这一位对应的桶
            arr.clear()
            # 重置原始数组
            for bucket in bucket_list:
                for value in bucket:
                    arr.append(value)
        return arr
    
solve = Solution()
arr = [92, 72, 53, 22, 53, 60, 93]
print(solve.radixSort(arr))

动图演示见19

11.排序算法的稳定性

考察排序算法的时候有一个很重要的特性,就是算法的稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,ri=rj,且rirj之前,而在排序后的序列中,ri仍在rj之前,则称这种排序算法是稳定的;否则称为不稳定的。20

稳定的排序有:

  • 直接插入排序
  • 冒泡排序
  • 归并排序
  • 基数排序

不稳定的排序有:

  • 希尔排序
  • 快速排序
  • 直接选择排序
  • 堆排序

算法稳定性到底为什么如此重要?上面提到的八种算法可以看到,其实很多算法都是可以实现稳定和不稳定两种情形的,那为什么选择稳定?一个基本原因就是减少元素交换次数,但是也有像归并排序这样的算法,与交换无关,那么稳定算法的意义在哪里呢?   稳定算法在单次排序的时候,意义并不显著,虽然上面提到减少元素交换,其实链表是可以避免这个消耗的,只不过操作比较复杂,其意义显示在基数排序中,即,我们要对多个关键词多次排序,这个时候,就一定要使用稳定算法。20

具体解释详见20

[^]:

[^]:

Footnotes

  1. Python排序算法(三)——选择排序

  2. Python 选择排序

  3. Python排序算法(四)——插入排序

  4. Python 插入排序

  5. Python排序算法(二)——冒泡排序

  6. Python 冒泡排序

  7. Python排序算法(六)——归并排序(MERGE-SORT)

  8. Python 归并排序

  9. Python排序算法(一)——快速排序

  10. Python 快速排序

  11. Python排序算法(五)——希尔排序(Shell’s Sort)

  12. Python 希尔排序

  13. 图解排序算法(三)之堆排序

  14. Python 堆排序

  15. 堆排序(heapsort)

  16. 图解排序算法(三)之堆排序

  17. 一文弄懂计数排序算法! 2

  18. 【排序】图解桶排序

  19. 1.10 基数排序 2

  20. 【DS】排序算法的稳定性 2 3