[算法系列] - 常用排序算法的Python实现

219 阅读5分钟

《数据结构与算法之美》笔记

1. 冒泡排序(Bubble Sort)

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。

当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。如图所示:

Python 代码实现:

def bubble_sort(a):
    length = len(a)
    if length <= 1:
        return a
    for i in range(length-1):
        swap = False
        for j in range(length-1-i):
            if a[j] > a[j+1]:
                a[j], a[j+1] = a[j+1], a[j]
                swap = True
        if not swap:
            break
    return a

(1). 冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法

(2). 在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法

(3). 最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n2)

2. 插入排序(Insertion Sort)

首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。

Python 代码实现:

def insertion_sort(a):
    length = len(a)
    if length <= 1:
        return a
    
    for i in range(1, length):
        value = a[i]
        for j in range(i-1, -1, -1):
            if a[j] > value:
                a[j], a[i] = a[i], a[j]
                i -= 1
            else:
                break
    return a

(1). 插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法

(2). 在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法

(3). 如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n2)

3. 选择排序(Selection Sort)

选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

Python 代码实现:

def selection_sort(a):
    length = len(a)
    if length <= 1:
        return a
    
    for i in range(length-1):
        min_index = i
        for j in range(i+1, length):
            if a[min_index] > a[j]:
                min_index = j
        a[i], a[min_index] = a[min_index], a[i]
    return a

(1). 选择排序空间复杂度为 O(1),是一种原地排序算法

(2). 选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n2)

(3). 选择排序是一种不稳定的排序算法

4. 归并排序(Merge Sort)

如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。如图所示:

Python 代码实现:

def merge(left, right):
    result = []
    while left and right:
        if left[0] <= right[0]:
            result.append(left.pop(0))
        else:
            result.append(right.pop(0))
    result += left if left else right
    return result

def merge_sort(a):
    length = len(a)
    if length <= 1:
        return a
    mid = length // 2
    left = a[:mid]
    right = a[mid:]
    return merge(merge_sort(left), merge_sort(right))

(1). 归并排序稳不稳定关键要看 merge() 函数,也就是两个有序子数组合并成一个有序数组的那部分代码。在合并的过程中,如果 A[p...q]和 A[q+1...r]之间有值相同的元素,那我们可以先把 A[p...q]中的元素放入 tmp 数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法

(2). 归并排序的时间复杂度是 O(nlogn)

(3). 归并排序不是原地排序算法。这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)

5. 快速排序(Quick Sort)

快排的思想是这样的:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。

方式一: 空间复杂度大,使用了两个列表解析式,而且每次选取进行比较时需要遍历整个序列。快排就不是原地排序算法了。

Python 代码实现:

def fake_quick_sort(a):
    if len(a) <= 1:
        return a
    else:
        pivot = a[0]
        left = [x for x in a[1:] if x <= pivot]
        right = [x for x in a[1:] if x > pivot]
        return fake_quick_sort(left) + [pivot] + fake_quick_sort(right)

方式二:原地排序,不稳定的排序算法

Python 代码实现:

def quick_sort(array, l, r):
    if l < r:
        q = partition(array, l, r)
        quick_sort(array, l, q - 1)
        quick_sort(array, q + 1, r)
    return array
 
def partition(array, left_ind, right_ind):   
    pivot = array[right_ind]
    i = left_ind
    for j in range(left_ind, right_ind+1):
        if array[j] < pivot:
            array[i], array[j] = array[j], array[i]
            i += 1
    array[i], array[j] = array[j], array[i]
    return i
    
print(quick_sort([2,5,3,6,6,6], 0, 5))    

(1). 快排的时间复杂度也是 O(nlogn)

6. 桶排序(Bucket Sort)

核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。

桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

7. 计数排序(Counting Sort)

计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。

8. 拓扑排序(Topological Sort)

我们知道,一个完整的项目往往会包含很多代码源文件。编译器在编译整个项目的时候,需要按照依赖关系,依次编译每个源文件。比如,A.cpp 依赖 B.cpp,那在编译的时候,编译器需要先编译 B.cpp,才能编译 A.cpp。

我们可以把源文件与源文件之间的依赖关系,抽象成一个有向图。每个源文件对应图中的一个顶点,源文件之间的依赖关系就是顶点之间的边。

Python 代码实现:

from collections import deque
from itertools import filterfalse

class Graph:
    def __init__(self, num_vertices: int):
        self._num_vertices = num_vertices
        self._adjacency = [[] for _ in range(num_vertices)]
    
    def add_edge(self, s: int, t: int) -> None:
        self._adjacency[s].append(t)
        
    def tsort_by_kahn(self):
        """如果 s 需要先于 t 执行,那就添加一条 s 指向 t 的边
           找出一个入度为 0 的顶点,将其输出到拓扑排序的结果序列中(对应代码中就是把它打印出来),
           并且把这个顶点从图中删除(也就是把这个顶点可达的顶点的入度都减 1)。
           我们循环执行上面的过程,直到所有的顶点都被输出。
           最后输出的序列,就是满足局部依赖关系的拓扑排序。
        """        
        in_degree = [0] * self._num_vertices #计算入度
        for v in range(self._num_vertices):
            if len(self._adjacency[v]):
                for neighbour in self._adjacency[v]:
                    in_degree[neighbour] += 1
                    
        q = deque(filterfalse(lambda x: in_degree[x], range(self._num_vertices))) #入度为0的点的队列
        while q:
            v = q.popleft() #取出入度为0的点
            print(f"{v} -> ", end="")
            for neighbour in self._adjacency[v]:
                in_degree[neighbour] -= 1 #与其相连接的点入度减1
                if not in_degree[neighbour]: #如果入度为0,加入队列
                    q.append(neighbour)
        print("\b\b\b  ")

    def tsort_by_dfs(self):
        """(1). 通过邻接表构造逆邻接表。
           邻接表中,边 s->t 表示 s 先于 t 执行,也就是 t 要依赖 s。
           在逆邻接表中,边 s->t 表示 s 依赖于 t,s 后于 t 执行。
           (2). 递归处理每个顶点。对于顶点 vertex 来说,
           我们先输出它可达的所有顶点,也就是说,先把它依赖的所有的顶点输出了,然后再输出自己。           
        """
        inverse_adjacency = [[] for _ in range(self._num_vertices)]
        for v in range(self._num_vertices):
            if len(self._adjacency[v]):
                for neighbour in self._adjacency[v]:
                    inverse_adjacency[neighbour].append(v)
        visited = [False] * self._num_vertices
        
        def dfs(v):
            if len(inverse_adjacency[v]):
                for neighbour in inverse_adjacency[v]:
                    if not visited[neighbour]:
                        visited[neighbour] = True
                        dfs(neighbour)
            print(f"{v} -> ", end="")
                        
        for v in range(self._num_vertices):
            if not visited[v]:
                visited[v] = True
                dfs(v)          
        print("\b\b\b   ")                   
        
if __name__ == "__main__":
    dag = Graph(4)
    dag.add_edge(1, 0)
    dag.add_edge(2, 1)
    dag.add_edge(1, 3)
    dag.tsort_by_kahn()
    dag.tsort_by_dfs()