Python数据结构和算法(一):基于内存的五大排序算法!

文章目录

      • 前文
        • 冒泡排序
          • 性能分析
        • 插入排序
          • 性能分析
        • 选择排序
          • 性能分析
        • 快速排序
          • 性能分析
        • 归并排序
          • 性能分析
      • 快排和归并的比较
      • 总结

前文

  学习数据结构和算法很久了,坚持刷题了半年多,自己也一直想总结关于Python数据结构和算法,于是,就从这篇开始吧!《数据结构和算法》作为程序员的必修课,一直是大家不断努力的方向,不过网上针对该门课的大牛大多数都是java、c++之类的,很少有用到Python来介绍的,所以我就出一系列依据Python来介绍的博客,希望大家都能有所收获!

  我也是看了比较多的课程和书籍开始做分享,而这些书籍都给了我很大的帮助:《大话数据结构》、《算法图解》、《Python数据结构和算法》、极客时间的《数据结构与算法之美》,以及不断地刷题,有兴趣的朋友可以去看看这些书,还是很有帮助的。

  该系列的分享会包含数据结构和算法,并且结合Python自身的数据结构和leetcode的题目来说,在我总结的同时,希望能给大家带来触类旁通的效果!

  作为系列的第一章,从排序说起!也许大多数都会从复杂度分析说起,但我把它放到后面,而在排序这一章我会用复杂度来说明,这样大家可以带着问题往后看。排序算法现在主流分为很多种排序算法,比如希尔排序、桶排序、堆排序、快排、归并排序等等,但如此之多的排序反而增大了程序员的学习难度,于是这里就介绍最常用的选择、冒泡、插入、快排、归并五大排序算法

  在《大话数据结构》里介绍上述的五种是基于内存排序的,也就是我们的编程语言所用的,比如Python和Java的sort排序是基于Timsort,而Timsort是工业级的算法,要兼容非常多的情况,本身也是基于归并排序和插入排序以及二分法等等,这里就不详细介绍,由此可见,这五种排序的掌握无论是面试还是后续底层的探究都是非常有帮助的!另外就是掌握算法必须得掌握其复杂分析,如果复杂分析都说不清楚,那这个算法你会写也只能算懂了一半!
  评价一个排序算法,我们可以从算法的执行效率、算法的内存消耗、算法的稳定性三者来判断。其中稳定性表示在待排序列表中,如果前后两者相等没有被调换位置,那就说明其是稳定性的算法。而评价算法的执行效率即是时间复杂度,从最好、最坏、平均角度来判断,这三者跟数据的有序度有关,有序度是待排序列表中元素对是否有序的数量,比如3,2,4一共有(3,2),(3,4),(2,4)三个元素对,其中有序的有2个,所以有序度是2个。如果一个有序度是0,则说明其是倒序的!

冒泡排序

  首先必须讲冒泡排序,作为面试官最常出的最简单题型,可见其重要性和普及性。冒泡排序顾名思义,就是一组排序列表,从头到尾两两比较,大的排在后方,代码如下:

def bubble_sort(l):
    for i in range(len(l),-1,-1):  #从后往前遍历,这样可以不用再对最后已排好序的重新排序
        for j in range(1,i):  #从前往后,末尾是i,也就是还未还排好序的
            if l[j-1] > l[j]: #如果前面大,就和后面的调换顺序
                l[j-1],l[j] = l[j],l[j-1]
    return l

  可以看到用Python写出的冒泡排序仅仅6行就解决了,并且代码非常的简单,但这里有个缺点,也就是无论是最好情况还是最坏情况都是O(n^2)的时间复杂度,那实际上可以这样优化。如果是最好情况,也就是数组本身已经排好序的话,那表示未交换过,所以通过标志位来标记当一轮排序通过后未交换过,则说明已经排好序即可。优化后的代码:

def bubble_sort(l):
    for i in range(len(l),-1,-1):
        flag = 1   #每次新一轮开始就增加标志位
        for j in range(1,i):
            if l[j-1] > l[j]:
                l[j-1],l[j] = l[j],l[j-1]
                flag = 0 #如果交换则标志位为0
        if flag == 1: #判断标志位决定是否有必要继续往下遍历
            return l
    return l
性能分析

  那分析下时间复杂度和空间复杂度!因为排序都是在数组里进行交换排序的,所以空间复杂度为O(1),也就是内存消耗为0(原地排序);时间复杂度的话最好是比较n-1次,所以为O(N),最坏则是有序度为0,则为n*(n-1)/2,即O(n2)的时间复杂度。那平均是多少呢?这里可以用估算的方法,假如以交换来说,最好时是没有交换,最坏是比较了n*(n-1)/2,所以平均是n*(n-1)/4,那比较肯定比交换要多几倍,所以最大不会超过O(n2)。另外因为冒泡比较时相等的不会交换,所以其是稳定算法!

插入排序

  相比于冒泡排序,插入排序可以说是非常具有实用性的语言,并且现在很多语言还是会利用插入排序作为其排序的一部分来实现!
  插入排序就是从左到右分为两堆,左边是排好序的,右边是待排序的,每次从右边取一个数依次跟左边相比较,小于哪个数就排在哪个数前面,直到碰到比它大的数为止!代码如下:

def insert_sort(l):
	for i in range(1,len(l)):
		for j in range(i,0,-1): #从左边的最后一个数开始比较
			if l[j-1] > l[j]: #如果比最后一个数小,则排在其前边
				l.insert(j-1,l.pop(j))
			else:   #如果比最后一个数大,则排在其后边
				break
	return l
性能分析

  三个角度分析插入排序,首先没有开辟空间,所以是原地排序,O(1)的空间复杂度;由于比较时不比较相等的情况,所以相等的情况不会交换位置,即是稳定的排序算法;那时间复杂度呢?最好当然是满有序度(即排好序的),则每次比较右边的都比左边的大,break出来,则仅需O(n)次比较,最坏是有序度为0,则每次相当于都要比较一遍全数组,即为n*(n+1)/2为O(n2)的时间复杂度,平均来说,相当于每次都是往数组中插入一个数,而往数组中插入一个数为O(n),所以n个数需要O(n2)的时间复杂度。

选择排序

  选择排序作为三大排序中的最基础,其没有多少可讲,而且因为无论最好、最坏、平均都是O(n2)的时间复杂度,所以我们了解即可。
  选择排序就是每次从左到右选第一个数依次比较到最后,所以即使是有序的,每个数也都要比较n次,那么n个数就要比较n2的次,即O(n2)时间复杂度,代码如下:

def choose_sort(l):
	for i in range(len(l)):
		for j in range(i+1,len(l)): #从左边那个数的后面一个知道最后依次比较
            if l[i] > l[j]:  #不断地比较,把最小的数移到最左边
                l[i],l[j] = l[j],l[i]
    return l
性能分析

  从三个角度分析,以此类推,则为原地排序,O(1)的空间复杂度;因为未涉及相等的判断,所以是稳定性排序,而时间复杂度则最好、最好、平均都是O(n2)。

快速排序

  快速排序简称快排,可以说是面试官最喜欢考得排序算法之一了!和冒泡并列,而相对于冒泡,快速排序的思想要更好得多!核心思想即是分而治之!即选取一个基准数,以之为基准将列表分为left、right两部分,左边小于基准,右边大于基准,然后以此递归将left、right再分为两部分,直到每部分只有1个元素则返回。
  用Python来实现快排是非常的轻松,代码和注释如下:

def quick_sort(l):
	if len(l) <= 1:return l
	#基准数就随机选列表的第一个
	pivot = l[0]
	#构建快排的核心 左右两个列表,左边比基准小,右边比基准大
	left  = [x for x in l[1:] if x <= pivot]
	right = [x for x in l[1:] if x > pivot]
	#然后循环递归,可以看到基线条件是当左列表、右列表只有1个元素时,则此时就返回
	return quick_sort(left) + [pivot] + quick_sort(right)

  代码非常的简单,但是这样的排序是标准的快排吗?很明显不是,首先构造了两个数组来存储,使得不是原地排序;其次在数组里需要left、right每次都遍历整个数组,也造成时间复杂度过高!在这之上,我们可以优化:

def quick_sort(l):
    left = 0
    right = len(l)-1
    return q_sort(l, left, right)

def q_sort(l, left, right):
    if left<right:
        pivot = partition(l,left,right) #partition负责选取基准位置,将列表分为两半
        q_sort(l, left, pivot-1) #递归左半部分
        q_sort(l,pivot + 1,right) #递归右半部分
    return l

def partition(l, left, right):
    pivot = l[left]
    while left < right:
        while left <right and l[right] >= pivot: #当右边大于基准则右边递减
            right -= 1
        l[left] = l[right]
        while left < right and l[left] <= pivot:
            left += 1
        l[right] = l[left]
    l[left] = pivot
    return left
性能分析

  分析下该排序,首先是空间复杂度,代码优化了空间,为原地排序。那是否是稳定的排序算法呢?我们知道比基准线小的在左边,比基准线大的在右边,而如果列表里两个排序好的小的,第1个先在左边,第2个再排到第1个的左边,所以是不稳定的排序算法!时间复杂度以最好来说,每次都刚好左边两边平均一半,即O(nlog2n),如果每次都是一边n个数,而另一边0个数,则退化成O(n2),极端情况非常少,平均时间复杂度是O(nlog2n)

归并排序

  归并排序是纯粹的分而治之,即每次都选列表的中间,将列表分为两部分,针对这两部分进行排序合并;然后再分别针对两部分递归再分为两部分,然后排序合并,而基线条件是也是当左或右部分的长度为1则递归返回。
  掌握归并排序的核心在和排序合并,即如何快速的将两部分已排序好的合并成一部分,代码如下:

def merge(left,right): #合并left、right两个有序数组
    i = j = 0
    res = []
    while i < len(left) and j < len(right):  
    	#构造一个数组用于存储结果,然后两部分分别从第一个开始比较,小的进入数组,
    	#当其中一部分比较完后就直接将剩余的加上数组即可!
        if left[i] <= right[j]:
            res.append(left[i])
            i += 1
        else:
            res.append(right[j])
            j += 1
    res += left[i:]
    res += right[j:]
    return res

def sort1(l):  #将列表直接从中间分成左右两部分
    if len(l) <= 1:
        return l
    mid = len(l) // 2
    # print(start,end,mid)
    left = sort1(l[:mid])
    right = sort1(l[mid:])
    return merge(left,right)
性能分析

  从空间上看,构造了一个数组,因为同一时间cpu只能有一个函数在处理,所以是O(n)的空间复杂度;从稳定性看,因为每次切割都是从中间切割开,所以小的永远再左边,大的再右边,为稳定性排序;从时间复杂度来看,因为每次都是均匀分割,所以最好、最坏、平均都是O(nlog2n)的时间复杂度

快排和归并的比较

  快排相对归并可以说知名度更高、应用更广,并且面试官也最爱考,这是为啥?从性能来说,归并是稳定的、时间复杂度最好最坏平均都是O(nlog2n),而快排是不稳定的,时间复杂度最坏的时候会达到O(n2)。但是归并有个致命的问题,就是不是原地排序,当排序的数量达到几G甚至几十G的量时,那所要耗用的内存空间也是O(n),这在工业上基本是很少去应用的,不过如果对内存不那么care的话,归并还是非常合适的。

总结

  作为数据结构和算法的第一章,我以排序为起点,后续的章节不会按正常的书籍一样从易到难,更多的是我笔记的总结,我觉得有意义的就会不断地归入该系列!谢谢观看~

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