基础算法之排序算法总结(python版)

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/qq_20141867/article/details/81132020

 常见排序算法包括有:冒泡,简单选择,直接插入,快排,归并排序,堆排序,希尔排序

目录

冒泡排序

思想:

复杂度分析:

 选择排序

思想:

复杂度分析

直接插入排序

思想:

复杂度分析

希尔排序

思想

复杂度分析

快速排序

思想:

复杂度分析

 快排优化

 归并排序

思想

复杂度分析

归并优化

堆排序

思想

复杂度分析

 总结


冒泡排序

思想:

冒泡排序应该是最耳熟能详的排序算法,它的基本思想是,比较相邻两个数字,将较大的交换至后面,每一轮比较结束,都会将当前比较序列的最大值甩到最后。

复杂度分析:

如果序列初始状态即为升序,那么只需要一轮比较,即n-1次。最坏的情况下是当初始状态为降序,那么需要比较(n-1)+(n-2)+...+1=\frac{n(n-1)}{2}次比较,并且还要做同等次数的交换。因此总的时间复杂度为O(n^2)

# 冒泡排序,相邻两数比较,每次排序最大值沉到最后
def bubblesort(nums):
    n = len(nums)
    changed = True
    for i in range(n):
        # 增加标志位,如果上一次排序没有交换,说明已经排序成功,提前结束
        if changed:
            changed = False
            # 每此排序之后最大值会沉到最后,不参与下一轮比较
            for j in range(0, n - 1 - i):
                if nums[j] > nums[j + 1]:
                    changed = True
                    # 将较大值甩到后面
                    nums[j], nums[j + 1] = nums[j + 1], nums[j]
    return nums

 选择排序

思想:

每次从待排序的数组中选择一个最小的,与数组第一个数字交换。

复杂度分析

与冒泡排序相比,选择排序并没有减少比较次数,并且它不像冒泡排序一样,可以判断是否已经排好序提前结束,所以比较次数永远都是\frac{n(n-1)}{2}次。仅仅是减少了元素移动的次数,最好的时候不需要移动任何元素,最坏的时候也仅需要交换n-1次。因此总的时间复杂度为O(n^2)。但是总体上选择排序性能是要略优于冒泡的。


# 选择排序,每次选择最小的与第一位交换
def selectsort(nums):
    n = len(nums)
    for i in range(n):
        minindex = i
        for j in range(i + 1, n):
            if nums[j] < nums[minindex]:
                minindex = j
        if minindex != i:
            nums[i], nums[minindex] = nums[minindex], nums[i]
    return nums


直接插入排序

思想:

假设已有部分排好序的数组,对每一个新数,在这个数组中找到它能够插入的合适位置,使数组仍保持有序。类似理扑克牌

复杂度分析

当数组初始有序时,只需要每个数字与它前一个数字比较一次,总共n-1次的比较。最坏当数组初始逆序时,每个数字需要与他前面i-1个数字比较,并且前i-1个数字都要后移一次。总的比较次数和移动次数都是\frac{n(n-1)}{2}。因此总的时间复杂度为O(n^2)

# 插入排序,第i个数跟前i-1个已排好序的数字比较,放在合适的位置
def insertsort(nums):
    n = len(nums)
    for i in range(1, n):
        if nums[i] < nums[i-1]:
            # 暂存需要插入的数据
            temp = nums[i]
            j = i - 1
            # 一边比较一边后移
            while j >= 0 and nums[j] > temp:
                nums[j + 1] = nums[j]
                j -= 1
            # 跳出循环时表示j>0 或者nums[j] <= temp,那么应该插在后一个位置
            nums[j + 1] = temp
    return nums

希尔排序

思想

希尔排序是在插入排序基础上进行做出的改进,之前说到插入排序是将一个数字插入有序数组,那么如果整个数组基本有序时,再对整个数组进行插入排序就会简单很多。

希尔排序的思想是将相距某个“增量”的记录组成一个子序列,在子序列内部进行插入排序,逐步减小增量,直到增量减为1。仔细观察代码可以发现,代码的6-13行部分其实与插入排序的4-14行基本相同,只不过将增量increment换为1。

复杂度分析

希尔排序的时间复杂度与增量的选择有关,目前最好的结果是O(n^{3/2})

# 希尔排序
def shellsort(nums):
    n = len(nums)
    increment = n / 2
    while increment >= 1:
        for i in range(increment, n):
            if nums[i] < nums[i - increment]:
                temp = nums[i]
                j = i - increment
                while j >= 0 and temp < nums[j]:
                    arr[j+increment] = arr[j]
                    j -= increment
                nums[j+increment] = temp
        increment /= 2

快速排序

思想:

快排的基本思想是找到一个枢轴,经过一轮排序之后达到,比枢轴小的数都在枢轴的左边,比枢轴大的数都在枢轴的右边。再对左右两边的数组循环这个过程,以使整个序列有序。

下面以数组[6,1,5,8,3,7,4,9,2]做一次排序的演示(一般以第一个元素作为枢轴值):

复杂度分析

快排的时间性能取决于递归的深度,如果每次选择的枢轴可以实现将数组“对半分”,那么只需要递归O(log_{2}^{n})次,每一轮的partition需要扫描一次数组,也就是n次比较,那么最好情况下的时间复杂度为O(nlog_{2}^{n})。最坏的情况下,也就是当数组初始有序或者逆序,每次划分得到的两部分长分别是0,n-1,也就是说递归树是一个单支树,时间复杂度为O(n^2)

而快排的空间消耗主要来自于递归造成的栈空间消耗,最好情况只需要O(log_{2}^{n})的空间,最快情况则需要O(n)的空间。

def quicksort(nums, low, high):
    if low < high:
        pivot = patition(nums, low, high)
        quicksort(nums, low, pivot-1)
        quicksort(nums, pivot+1, high)

# 非递归实现
def quicksort(nums, low, high):
    para_stack = []
    if low < high:
        para_stack.append(low)
        para_stack.append(high)
        while len(para_stack) > 0:
            high = para_stack.pop()
            low = para_stack.pop()
            mid = partition(nums, low, high)
            if mid - 1 > low:
                para_stack.append(low)
                para_stack.append(mid - 1)
            if mid + 1 < high:
                para_stack.append(mid + 1)
                para_stack.append(high)

def patition(nums, low, high):
    # 以第一个元素作为枢轴值
    base = nums[low]
    while low < high:
        # high指针从后往前移,找到比枢轴值小的数字
        while low < high and nums[high] >= base:
            high -= 1
        # 将找到的数字复制至低位
        nums[low] = nums[high]
        # low指针从前往后移,找到比枢轴值大的数字
        while low < high and nums[low] <= base:
            low += 1
        # 将找到的数字复制至高位
        nums[low] = nums[low]
    # 跳出循环时,两指针重合,将枢轴值放入
    nums[low] = base
    return low

 快排优化

对于快排的优化主要是考虑如何减少递归深度。一种方法是优化枢轴的选择,通常情况下是固定选择待排序数组的第一个元素作为枢轴值,如果它刚好是数组中最大或者最小的数值,显然是很不合理的。因此考虑三数取中,也就是先选择三个数进行排序,选择中间数作为枢轴(一般是选择最左,最右,中间),这在概率上来说,极大减少取最大或者最小的数值作为枢轴值的可能。第二种方法是采用尾递归减少递归深度 ,修改代码如下,将其中一个递归改为迭代,减少堆栈深度。

def quicksort(nums, low, high):
    while low < high:
        pivot = patition(nums, low, high)
        quicksort(nums, low, pivot - 1)
        low = pivot + 1

 归并排序

思想

归并排序有两路归并和多路归并,这里只介绍两路归并。它的核心思想是将两个有序数组合并为一个有序数组。它的合并过程类似于一个倒置的完全二叉树。

复杂度分析

同样的,它的时间效率取决于递归深度,由于归并每次将数组从中间二分,递归树是完全二叉树,所以递归深度永远为\left \lceil log_{2}^{n} \right \rceil,每次合并需要遍历一次数组,所以总的时间复杂度为O(nlog_{2}^{n})。归并排序需要一个辅助数组暂时存放合并后的结果,再加上递归所需要的堆栈空间,所以空间复杂度为O(n+log_{2}^{n})

# 归并排序
def mergesort(nums, low, high, helparr):
    if high > low:
        mid = (low+high) / 2
        # 将数组不断二分,直到每组只剩一个元素
        mergesort(nums, low, mid, helparr)
        mergesort(nums, mid+1, high, helparr)
        merge(nums, low, mid, high, helparr)


# 合并两个有序数组
def merge(num, low, mid, high, helparr):
    # 前半截数组的下标
    firstindex = low
    # 后半截数组的下标
    secondindex = mid+1
    # 辅助数组的下标
    helpindex = low
    while firstindex <= mid and secondindex <= high:
        # 将较小值复制到辅助数组中
        if num[firstindex] <= num[secondindex]:
            helparr[helpindex] = num[firstindex]
            firstindex += 1
        else:
            helparr[helpindex] = num[secondindex]
            secondindex += 1
        helpindex += 1
    # 将较长的那个数组剩余部分直接复制到辅助数组中
    if firstindex <= mid:
        helparr[helpindex:high + 1] = num[firstindex:mid + 1]
    if secondindex <= high:
        helparr[helpindex:high + 1] = num[secondindex:high + 1]
    # 将合并后的结果放回原数组
    num[low:high+1] = helparr[low:high+1]


归并优化

 归并算法的时间复杂度已经很高了,而且很稳定,但是递归产生的堆栈空间消耗还有优化空间。所以可以考虑把递归改为迭代。

堆排序

思想

在描述堆排序的思想之前,回顾一下完全二叉树的一些性质:

  1. 如果完全二叉树有n个节点,那么树的深度为\left \lfloor log_{2}^{n} \right \rfloor+1,(\left \lfloor x \right \rfloor表示不大于x的整数)
  2. 对完全二叉树从根开始,按照 0,1,2,3\cdots开始编号,那么编号为i的结点,其左右孩子分别是2i+1, 2i+2,其双亲结点是\left \lfloor (i-1)/2 \right \rfloor

堆在完全二叉树的基础上增加一条性质:每个结点的值都大于或等于(小于或等于)其左右孩子的结点值,称为大顶堆(小顶堆)。如果能够将待排序数组构造成一个大顶堆,那么根结点就是当前二叉树最大的数。将根结点提出,重新构造剩余数组,就可以依次将当前最大数找出。这就是堆排序。

那么问题就只有一个:如何根据数组构造大顶堆。这里以{50,10,90,30,70,40,80,60,20}为例演示调整过程:

0. 将数组建立为初始二叉树

1. 由于叶子结点不需要调整,那么从以最后一个叶结点的双亲结点,也即是最后一个非叶结点,为根结点的子树{30,60,20}开始调整。7号结点值最大,那么30和60互换

2. 接下来是以倒数第二个非叶结点为根结点的子树{90,40,80},此时根结点已经是最大值,不需要调整

3. 接下来是以1号结点的子树,由于70最大,所以将70和10互换

4. 最后是根结点,将50和90交换

5. 这里需要注意,由于50下放之后,导致2号结点的子树不满足大顶堆条件,所以需要接着调整,将50和80交换

6. 大顶堆构造结束,此时数组最大数已经到达堆顶。将堆顶元素和最后一个叶结点交换

7。 由于交换之后的根结点不满足大根堆条件,接着调整(也就是重复步骤4-6),不一样的是,已经被选出来的90不需要再次参与堆的调整。

复杂度分析

堆排序的时间主要消耗在初始建堆和重建堆的反复筛选上。

因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为O(n)

在正式排序时,第i 次取堆顶记录重建堆需要用O(log^{i})的时间(完全二叉树的某个结点到根结点的距离为\left \lfloor log^{i}\right \rfloor+1,并且需要取n-1 次堆顶记录,因此,重建堆的时间复杂度为O(nlog^{n})

# 堆排序
def heapsort(nums):
    n = len(nums)
    # 建立大顶堆
    for i in range(n / 2 - 1, -1, -1):
        heapAjust(nums, i, n - 1)
    for i in range(n - 1, -1, -1):
        # 堆顶元素为最大值,交换至数组末尾
        nums[0], nums[i] = nums[i], nums[0]
        # 调整剩下数组仍为大顶堆
        heapAjust(nums, 0, i - 1)


def heapAjust(nums, start, end):
    temp = nums[start]
    # 记录较大的那个孩子下标
    child = 2 * start + 1
    while child <= end:
        # 比较左右孩子,记录较大的那个
        if child + 1 <= end and nums[child] < nums[child+1]:
            # 如果右孩子比较大,下标往右移
            child += 1
        # 如果根已经比左右孩子都大了,直接退出
        if temp >= nums[child]:
            break
        # 如果根小于某个孩子,将较大值提到根位置
        nums[start] = nums[child]
        # nums[start], nums[child] = nums[child], nums[start]
        # 接着比较被降下去是否符合要求,此时的根下标为原来被换上去的那个孩子下标
        start = child
        # 孩子下标也要下降一层
        child = child * 2 + 1
    # 最后将一开始的根值放入合适的位置(如果前面是交换,这句就不要)
    nums[start] = temp

 总结

从待排序记录的个数上来说,待排序的个数n 越小,采用简单排序方法越合适。

此对于数据量不是很大而记录的关键字信息最较大的排序要求,简单排序算法是占优的。另外,记录的关键字信息量大小对那四个改进算法影响不大。

猜你喜欢

转载自blog.csdn.net/qq_20141867/article/details/81132020