冒泡排序
它重复地走访过要排序的元素列,依次比较两个相邻的元素,如果顺序(如从大到小、首字母从从Z到A)错误就把他们交换过来。走访元素的工作是重复地进行直到没有相邻元素需要交换,也就是说该元素列已经排序完成。
这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端(升序或降序排列),就如同碳酸饮料中二氧化碳的气泡最终会上浮到顶端一样,故名“冒泡排序”。
- 原理:比较两个相邻的元素,将值大的元素交换到右边
- 思路:依次比较相邻的两个数,将比较小的数放在前面,比较大的数放在后面。
由上图可知:
- 第一次比较:首先比较第一和第二个数,将小数放在前面,将大数放在后面。
- 比较第2和第3个数,将小数 放在前面,大数放在后面。
- 如此继续,知道比较到最后的两个数,将小数放在前面,大数放在后面,重复步骤,直至全部排序完成
- 在上面一趟比较完成后,最后一个数一定是数组中最大的一个数,所以在比较第二趟的时候,最后一个数是不参加比较的。
- 在第二趟比较完成后,倒数第二个数也一定是数组中倒数第二大数,所以在第三趟的比较中,最后两个数是不参与比较的。
- 依次类推,每一趟比较次数减少依次
# Bubble Sort
def bubble_sort(nums):
for i in range(len(nums) - 1): # 这个循环负责设置冒泡排序进行的次数
for j in range(len(nums) - i - 1): # j为列表下标
if nums[j] > nums[j + 1]:
nums[j], nums[j + 1] = nums[j + 1], nums[j]
return nums
print(bubble_sort([45, 32, 8, 33, 12, 22, 19, 97]))
# 输出:[8, 12, 19, 22, 32, 33, 45, 97]
# Second
def bubble(bubbleList):
listLength = len(bubbleList)
while listLength > 0:
for i in range(listLength - 1):
if bubbleList[i] > bubbleList[i+1]:
bubbleList[i], bubbleList[i+1] = bubbleList[i+1], bubbleList[i]
listLength -= 1
print bubbleList
if __name__ == '__main__':
bubbleList = [3, 4, 1, 2, 5, 8, 0]
bubble(bubbleList)
算法分析:
-
由此可见:N个数字要排序完成,总共进行N-1趟排序,每i趟的排序次数为(N-i)次,所以可以用双重循环语句,外层控制循环多少趟,内层控制每一趟的循环次数
-
冒泡排序的优点:每进行一趟排序,就会少比较一次,因为每进行一趟排序都会找出一个较大值。如上例:第一趟比较之后,排在最后的一个数一定是最大的一个数,第二趟排序的时候,只需要比较除了最后一个数以外的其他的数,同样也能找出一个最大的数排在参与第二趟比较的数后面,第三趟比较的时候,只需要比较除了最后两个数以外的其他的数,以此类推……也就是说,没进行一趟比较,每一趟少比较一次,一定程度上减少了算法的量。
-
时间复杂度
-
如果我们的数据正序,只需要走一趟即可完成排序。所需的比较次数C和记录移动次数M均达到最小值,即:Cmin=n-1;Mmin=0;所以,冒泡排序最好的时间复杂度为O(n)。
-
如果很不幸我们的数据是反序的,则需要进行n-1趟排序。每趟排序要进行n-i次比较(1≤i≤n-1),且每次比较都必须移动记录三次来达到交换记录位置。在这种情况下,比较和移动次数均达到最大值:
综上所述:冒泡排序总的平均时间复杂度为:O(n2) ,时间复杂度和数据状况无关。
选择排序
第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾。以此类推,直到全部待排序的数据元素的个数为零。选择排序是不稳定的排序方法。
- 原理: 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。重复第二步,直到所有元素均排序完毕。
- 思路: 从头至尾扫描序列,找出最小的一个元素,和第一个元素交换,接着从剩下的元素中继续这种选择和交换方式,最终得到一个有序序列。
-
初始状态:序列为无序状态。
-
第1次排序:从n个元素中找出最小(大)元素与第1个记录交换
-
第2次排序:从n-1个元素中找出最小(大)元素与第2个记录交换
-
第i次排序:从n-i+1个元素中找出最小(大)元素与第i个记录交换
-
以此类推直到排序完成
# Selection sort
def selectionSort(arr):
for i in range(len(arr) - 1):
# 记录最小数的索引
minIndex = i
for j in range(i + 1, len(arr)):
if arr[j] < arr[minIndex]:
minIndex = j
# i 不是最小数时,将 i 和最小数进行交换
if i != minIndex:
arr[i], arr[minIndex] = arr[minIndex], arr[i]
return arr
算法分析:
- 由此可见: 每经过一轮排序,有序区域内的元素数量增加1个。 如果待排序的元素个数为N。需要经历N-1轮排序。
- 选择排序的优点:n 个记录的文件的直接选择排序可经过n-1 趟直接选择排序得到有序结果。移动数据的次数已知(n-1 次);
- 时间复杂度:
- 简单选择排序的比较次数与序列的初始排序无关。假设待排序的系列有N个元素,则比较次数总是 N(N-1)/2而移动次数与系列的初始排序有关,当排序正序时,移动次数最少,为0
- 当序列反序时,移动次数最多,为 3N(N-1)/2
所以,综上,简单排序的时间复杂度为 O(N*N)
- 选择排序的简单和直观名副其实,这也造就了它”出了名的慢性子”,无论是哪种情况,哪怕原数组已排序完成,它也将花费将近n²/2次遍历来确认一遍。
即便是这样,它的排序结果也还是不稳定的。 唯一值得高兴的是,它并不耗费额外的内存空间。
综上所述:选择排序总的平均时间复杂度为:O(n2) ,时间复杂度和数据状况无关。
插入排序
插入排序的基本操作就是将一个数据插入到已经排好序的有序数据中,从而得到一个新的、个数加一的有序数据,算法适用于少量数据的排序,时间复杂度为O(n^2)。
-
原理:
插入排序始终在列表的较低位置维护一个排序的子列表,遇到新的项将它插入到原来的子列表,使得排序的子列表称为一个较大的项。插入算法把要排序的数组分成两部分:第一部分包含了这个数组的所有元素,但将最后一个元素除外(让数组多一个空间才有插入的位置),而第二部分就只包含这一个元素(即待插入元素)。在第一部分排序完成后,再将这个最后元素插入到已排好序的第一部分中。
-
思路:
每步将一个待排序的记录,按其关键码值的大小插入前面已经排序的文件中适当位置上,直到全部插入完为止。
-
从第一个元素开始,该元素可认为已排序。
-
取出下一个元素,在排序好的元素序列中从后往前扫描
-
如果元素(已排序)大于新元素,将该元素移到下一位置
-
重复3.直到找到已排序的元素小于或等于新元素的位置
-
将新元素插入该位置后
-
重复2-5直到排序完成
# Insertion sort
def insert_sort(lst):
for i in range(1,len(lst)):
tmp = lst[i]
j = i - 1
while j >= 0 and tmp < lst[j]:
lst[j + 1] = lst[j]
j = j - 1
lst[j + 1] = tmp
return lst
算法分析:
- 插入排序是一种简单直观的排序算法,工作原理为构建有序序列,对于未排序元素,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间,直到排序完成,如果碰见一个和插入元素相等的,那么插入元素把想插入的元素放在相等元素的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定的。理解了插入排序的思想后,我们便能够得到它的时间复杂度。对于n个元素,一共需要进行n-1轮比较,而第k轮比较需要进行k次数组元素的两两比较,因此共需要进行的比较次数为:1+2 + … + (n-1),所以插入排序的时间复杂度同冒泡排序一样,也为O(n^2)。
- 对比冒泡和插入的代码,冒泡排序的数据交换比插入排序的数据移动要复杂,冒泡排序需要三次赋值操作,而插入排序只需要一次。
因此,我们对逆序度为K的数组进行排序,用冒泡排序需要进行3*K次单元时间,而插入排序只需要K次单元时间。因此插入排序性能更优。 - 时间复杂度:
- 最好:如果序列已经是排好序的,那么就是最好的情况。
此时外层循环执行n-1次,每次中内循环体执行1次,赋值语句执行一次,则T(n) = n-1,所以时间复杂度为O(n)。 - 最坏:如果序列正好是逆序的,那么就是最坏的情况。
此时外层循环执行n-1次,对应的内循环体分别执行n-(n-1),n-(n-2),n-(n-3)…,n-3,n-2,n-1,T(n)= n(n-1)/2,所以时间复杂度为O(n)。 - 平均: 平均执行的次数 = n-1 + n(n-1)/2 = 1/2n^2 + 1/2n -1,则平均时间复杂度为O(n^2)。
序列中两个相等的元素在排序之后,它们的相对位置不会发生改变。因此插入排序算法是稳定的算法。
综上所述:选择排序总的平均时间复杂度为:O(n2) ,时间复杂度和数据状况无关。
快速排序
快速排序(Quicksort)是对冒泡排序的一种改进。
快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
-
原理:
先从数据序列中选一个元素,并将序列中所有比该元素小的元素都放到它的右边或左边,再对左右两边分别用同样的方法处之直到每一个待处理的序列的长度为1,处理结束 -
思路:
先从数列中取出一个数作为基准数。分区过程,将比这个数大的数全放到它的右边,小于或等于它的数全放到它的左边。再对左右区间重复第二步,直到各区间只有一个数
-
求第一趟划分后的结果。
-
关键码 序列递增。
-
以第一个元素为划分基准。(自己设置的排序初始值,这里我选择第一个)
-
将两个指针i,j分别指向表的起始和最后的位置。
反复操作以下两步: -
j 逐渐减小,并逐次比较 j 指向的元素和目标元素的大小,若 p( j ) < T(准基) 则交换位置。
-
i 逐渐增大,并逐次比较 i 指向的元素和目标元素的大小,若 p( i ) > T(准基) 则交换位置。
-
直到i,j指向同一个值,循环结束。
返回规则是:左边分区+基准值+右边分区
# Quick Sort
def quick_sort(data):
"""quick_sort"""
if len(data) >= 2:
mid = data[len(data)//2]
left,right = [], []
data.remove(mid)
for num in data:
if num >= mid:
right.append(num)
else:
left.append(num)
return quick_sort(left) + [mid] + quick_sort(right)
else:
return data
a = [2,3,4,1,45,6,6,7,8,7,9,10,18,20,30,12]
print(quick_sort(a))
[1, 2, 3, 4, 6, 6, 7, 7, 8, 9, 10, 12, 18, 20, 30, 45]
算法分析:
-
快速排序算法的时间复杂度和各次标准数据元素的值关系很大。如果每次选取的标准元素都能均分两个子数组的长度,这样的快速排序过程是一个完全二叉树结构。(即每个结点都把当前数组分成两个大小相等的数组结点,n个元素数组的根结点的分解次数就构成一棵完全二叉树)。这时分解次数等于完全二叉树的深度log2n;每次快速排序过程无论把数组怎样划分、全部的比较次数都接近于n-1次,所以最好情况下快速排序算法的时间复杂度为O(nlog2n):快速排序算法的最坏情况是数据元素已全部有序,此时数据元素数组的根结点的分需次数构成一棵二叉退化树(即单分支二叉树),一棵二叉退化树的深度是n,所以最坏情况下快速排序算法的时间复杂度为O(n2)。般情况下
,标准元素值的分布是随机的,数组的分邮大数构成模二又树,这样的二叉树的深度接近于log2n,
所以快速排序算法的平均(或称期望)时间复杂度为O(nlog2n) -
时间复杂度:
-
快速排序最优的情况就是每一次取到的元素都刚好平分整个数组。
此时的时间复杂度公式则为:T[n] = 2T[n/2] + f(n);T[n/2]为平分后的子数组的时间复杂度,f[n] 为平分这个数组时所花的时间 -
最差的情况就是每一次取到的元素就是数组中最小/最大的,这种情况其实就是冒泡排序了(每一次都排好一个元素的顺序)
这种情况时间复杂度就好计算了,就是冒泡排序的时间复杂度:T[n] = n * (n-1) = n^2 + n;
平均时间复杂度为O(nlog2n)
堆排序
堆排序是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
什么是 堆?
堆是一种特殊的完全二叉树
树: 树是不包含回路的连通无向图(任意的两个节点仅有唯一的一条路径连通,在一棵树中加一条边会构成图)
二叉树: 是一种特殊的树,只要不为空,就由根节点、左子树和右子树组成,左子树和右子树分别是一棵二叉树
完全二叉树: 完全二叉树和满二叉树都是一种特殊的二叉树,两者可以一起记,如下图,左边为满二叉树:每个分支节点都有左子树和右子树,所有叶子都在同一层上。右边为完全二叉树,是从右向左减少叶子节点的满二叉树
堆分为:
大顶堆:所有父节点都比子节点大
小顶堆:所有父节点都比子节点小
堆排序就是利用堆(假设利用大顶堆)进行排序的方法。
原理:
将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根节点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的 n-1 个序列重新构造成一个堆,这样就会得到 n 个元素中次大的值。如此反复执行,便能得到一个有序序列了。
一般升序采用大顶堆,降序采用小顶堆
还有个简易版的:
假设
- 根节点的左右子树都是堆,但根节点不满足堆的性质(大根堆,小根堆)
- 可以通过一次向下的调整来将其变成一个堆 (下图)
堆排序过程:
步骤:
- 建立堆
- 得到堆顶元素,为最大元素
- 掉堆顶,将堆最后一个元素放到堆顶,此时可通过一次调整重新使堆有序。
- 堆顶元素为第二大元素
- 重复步骤3,直到堆变空
这回理解了吧。
堆排序的实现过程中,其实大部分过程都在调整过程(建立堆,排序的时候去掉堆顶,重新调整)
所以我们先来实现调整堆的代码
def heap_sort(li):
"""
实现堆排序过程:
1、先根据传入的列表构建堆(大根堆)
2、再埃个出数
:param li:
:return:
"""
n = len(li)
# 1、构建堆--包围框
for i in range((n-2)//2, -1, -1):
# i 就是每个父节点的位置
# (n-2)//2 是根据堆的最后一个叶子节点位置推算其父节点位置的公式:(i-1)//2
# 而 i 又等于 n - 1, 所以 (i-1)//2 == ((n - 1)-1)//2 == (n-2)//2
# 第一个 -1 ,因为要从最后一个根节点循环到第一个根节点也就是列表中下标为0的元素,range 为开头闭尾
# 第二个 -1, 倒序
sift(li, i, n-1)
# 2、埃个出数,完成列表排序,
# 原地排序,每次都根节点都和最后一个叶子节点互换,然后调整位置, 再进行如上循环操作,直到堆为空
for i in range(n-1, -1, -1):
# i是每次循环堆里面最后一个数
li[0], li[i] = li[i], li[0]
sift(li, 0, i-1) # 边界值下标, 因为出数完之后,最后原i位置上的元素是出数的结果,所以i位置上的元素不参加调整,故边界值为i-1.
# 书写完毕进行测试
li = [i for i in range(100)]
import random
random.shuffle(li)
print(li)
heap_sort(li)
print(li)
[26, 16, 81, 29, 11, 75, 73, 0, 35, 21, 95, 37, 72, 79, 80, 46, 76, 1, 93, 45, 25, 48, 92, 77, 42, 40, 82, 10, 6, 67, 30, 96, 47, 51, 38, 60, 4, 61, 64, 97, 33, 71, 8, 59, 87, 49, 19, 22, 83, 44, 9, 28, 27, 99, 69, 12, 2, 34, 24, 85, 32, 53, 14, 88, 90, 41, 55, 39, 20, 94, 63, 23, 7, 66, 84, 74, 62, 58, 56, 70, 86, 17, 89, 13, 18, 65, 50, 98, 5, 54, 15, 78, 3, 57, 31, 52, 68, 43, 36, 91]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99]
第二种
def bucket_sort(s):
"""桶排序"""
min_num = min(s)
max_num = max(s)
# 桶的大小
bucket_range = (max_num-min_num) / len(s)
# 桶数组
count_list = [ [] for i in range(len(s) + 1)]
# 向桶数组填数
for i in s:
count_list[int((i-min_num)//bucket_range)].append(i)
s.clear()
# 回填,这里桶内部排序直接调用了sorted
for i in count_list:
for j in sorted(i):
s.append(j)
if __name__ == '__main__':
a = [3.2,6,8,4,2,6,7,3]
bucket_sort(a)
print(a) # [2, 3, 3.2, 4, 6, 6, 7, 8]
算法分析:
优点:
堆排序的效率与快排、归并相同,都达到了基于比较的排序算法效率的峰值(时间复杂度为O(nlogn))
除了高效之外,最大的亮点就是只需要O(1)的辅助空间了,既最高效率又最节省空间,只此一家了
堆排序效率相对稳定,不像快排在最坏情况下时间复杂度会变成O(n^2)),所以无论待排序序列是否有序,堆排序的效率都是O(nlogn)不变(注意这里的稳定特指平均时间复杂度=最坏时间复杂度,不是那个“稳定”,因为堆排序本身是不稳定的)
缺点:
(从上面看,堆排序几乎是完美的,那么为什么最常用的内部排序算法是快排而不是堆排序呢?)
最大的也是唯一的缺点就是——堆的维护问题,实际场景中的数据是频繁发生变动的,而对于待排序序列的每次更新(增,删,改),我们都要重新做一遍堆的维护,以保证其特性,这在大多数情况下都是没有必要的。(所以快排成为了实际应用中的老大,而堆排序只能在算法书里面顶着光环,当然这么说有些过分了,当数据更新不很频繁的时候,当然堆排序更好些…)
- 时间复杂度:
若有n个元素的序列,将元素接腰序组成一棵完全二叉树,当且仅当满足下列条件时称为堆。大根堆是指所有结点的值大于或等于左右子结点的值;小掇堆是指所有结点的值小于或等于左右子结点的值。在调整建堆的过程中,总是将根结点值与左、右子树的根结点进行比较,若不满足堆的条件,则将左、右子树根结点值中的大者与根结点值进行交换。堆排序最坏情况需要0(nlogn)次比较,所以时间复杂度是0(nlogn)
初始化建堆的时间复杂度为O(n),排序重建堆的时间复杂度为nlog(n),所以总的时间复杂度为O(n+nlogn)=O(nlogn)。另外堆排序的比较次数和序列的初始状态有关,但只是在序列初始状态为堆的情况下比较次数显著减少,在序列有序或逆序的情况下比较次数不会发生明显变化。
归并排序
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
-
原理:
归并排序是一种递归算法,不断将列表拆分为一半,如果列表为空或有一个项,则按定义进行排序。如果列表有多个项,我们分割列表,并递归调用两个半部分的合并排序。一旦对两半排序完成,获取两个较小的排序列表并将它们组合成单个排序的新列表的过程 -
思路:
归并排序中,我们会先找到一个数组的中间下标mid,然后以这个mid为中心,对两边分别进行排序,之后我们再根据两边已排好序的子数组,重新进行值大小分配。
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
data = [45,3,2,6,3,78,5,44,22,65,46]
# 合并函数,将相邻的两个区间合并为一个
def merge(a, b):
result = []
i = j = 0
while i<len(a) and j<len(b):
if a[i] < b[j]:
result.append(a[i])
i += 1
else:
result.append(b[j])
j += 1
result += a[i:]
result += b[j:]
return result
def sorts(data):
length = len(data)
if length <=1:
return data
# 按照平分当前列表的方式将data递归分为很多个平均的列表
mid = length/2
a = sorts(data[:mid])
b = sorts(data[mid:])
# 递归分到每个列表只有一个数字后合并
return merge(a, b)
print sorts(data)
算法分析:
优点:
归并排序的效率达到了巅峰:时间复杂度为O(nlogn),这是基于比较的排序算法所能达到的最高境界
归并排序是一种稳定的算法(即在排序过程中大小相同的元素能够保持排序前的顺序,3212升序排序结果是1223,排序前后两个2的顺序不变),这一点在某些场景下至关重要
归并排序是最常用的外部排序方法(当待排序的记录放在外存上,内存装不下全部数据时,归并排序仍然适用,当然归并排序同样适用于内部排序…)
缺点:
归并排序需要O(n)的辅助空间,而与之效率相同的快排和堆排分别需要O(logn)和O(1)的辅助空间,在同类算法中归并排序的空间复杂度略高
时间复杂度:
- 对于数组:[5,3,6,2,0,1]
序列可以分为:[5,3,6]和[2,0,1] - 对上面的序列分别进行排序,结果为:
[3,5,6]和[0,1,2]
然后将上面的两个序列合并为一个排好序的序列
合并的方法是:设置两个指针,分别指着两个序列的开始位置,如下所示
[3,5,6] [0,1,2]
/|\ /|\ - 开始的时候两个指针分别指向3和0,这时我们找到一个空数组,将3和0中较小的值复制进这个 数组中,并作为第一个元素。新数组:[0,]
- 后面数组的指针后移一位,如下所示
[3,5,6] [0,1,2]
/|\ /|
将1和3进行比较,1小于3,于是将1插入新数组:[0,1,…] - 后面数组的指针后移一位,如下所示
[3,5,6] [0,1,2]
/|\ /|
将2和3进行比较,2小于3,于是将2插入新数组:[0,1,2,…] - 将剩余的左边已经有序的数组直接复制进入新数组中去,可以得到新数组:[0,1,2,3,5,6]
- 有master公式(递归公式):T(n)=2T(n/2)+O(N) 可以得出时间复杂度为:O(N*logN)
无论最好还是最坏均为: O(nlgn); 空间复杂度为 O(n); 是一种稳定的排序算法;