手撕算法之冒泡选择插入希尔排序
Posted BMGx
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了手撕算法之冒泡选择插入希尔排序相关的知识,希望对你有一定的参考价值。
网上关于十大排序算法的文章很多,其实思想很好懂,但实际敲出来也不太容易,特别是想一次性敲对,难。个人觉得,难点或易错点主要在条件边界的控制和循环退出点的选择。不过熟能生巧,因为算法有点像恢复魔方,讲究套路。一个算法打发明起,到被广泛应用,越来越成熟优化的同时也意味着模式越来越固定。作为普通人,一辈子无缘发明新算法,能做到的就是反复操练会用代码实现现成的算法。
下面根据算法思路手打冒泡、选择、插入和希尔四种排序。它们的共性:①都是基于比较和交换。因此时间复杂度衡量上也主要考虑比较和交换操作。而比较交换带来的最大好处是单次操作仅申请常数量级的临时内存(空间复杂度O(1)),即不需要复制原始数据或申请相应规模的内存空间,从而实现了原地排序。②都是基于数组,所以可以通过下标快速访问,代码写起来简单。而数组原地排序最大的技巧就是双指针,通过快慢指针一边区分已排和未排,一边重复遍历比较大小。
Bubble_sort
1# 冒泡排序代码实现@dra
2
3def bubble_sort(lst: list)->list:
4 n = len(lst)
5 if n <= 1:
6 return lst
7 for i in range(0, n):
8 mark = 0
9 for j in range(0, n-i-1):
10 if lst[j] > lst[j+1]:
11 tmp = lst[j+1]
12 lst[j+1] = lst[j]
13 lst[j] = tmp
14 mark = 1
15 if not mark:
16 break
17 return lst
上边代码的整体思路就是“每一次循环筛选出最大值,放到序列尾部,这样一共执行n层,总共的运算规模是n*n次”。
比较容易出错的地方是内层嵌套循环的end位是“n-i-1”,借助和外层循环的关联,达到了“气泡浮出水面”的动态下降。
可以优化的地方是增加局部变量mark,利用它的开关作用,在每一次冒泡过程中都检查一次序列有序度,如果已经有序则不用执行后边循环,直接跳出并返回。这样可以节省计算资源。
还有一个彻底优化的途径,即引入另一种算法——鸡尾酒排序。参见小灰的文章。
鸡尾酒排序的元素比较和交换过程是双向的。
小灰,公众号:程序员小灰
Selection_sort
1# 选择排序实现代码@dra
2
3def selection_sort(lst: list)->list:
4 n = len(lst)
5 if n <= 1:
6 return lst
7 for i in range(n-1):
8 mini = i
9 for j in range(i+1, n):
10 if lst[j] < lst[mini]:
11 mini = j
12 if i != mini:
13 lst[i], lst[mini] = lst[mini], lst[i]
14 return lst
上边代码的整体思路是“通过遍历,动态确定最小数据的位置,并将最小值添加到有序部分末尾(实际上置换两个数位置)”。
比较容易出错的地方是外层循环的end位是“n-1”,而不是n。因为外层循环是用来初始化最小值的,它后边(也就是内层循环的start位之后)都是无序待排的,所以不能把外层循环end设为n。
选择排序的平均时间复杂度是O(n^2),可以使用快排或堆排优化,quick_sort和heap_sort的平均时间复杂度都是O(nlogn)。而快排核心的分组过程和堆排序核心的堆化过程都涉及“选择”。
所谓选择类排序和冒泡(及鸡尾酒)排序的区别是,前者是点对点交换,而后者是逐一连续交换。
Insertion_sort
1# 插入排序代码实现@dra
2
3def insertion_sort(lst: list)->list:
4 n = len(lst)
5 if n <= 1:
6 return lst
7 for i in range(1, n):
8 value = lst[i]
9 j = i - 1
10 while j >= 0:
11 if lst[j] > value:
12 lst[j+1] = lst[j]
13 j -= 1
14 else:
15 break
16 lst[j+1] = value
17 return lst
上边代码的整体思路是“遍历数组,将当前位置的值插入到前边部分合适的位置”。要想实现原地排序,就需要整体向后一位搬移数据,从而错出一个空,然后把当前位数据插入空位。
比较容易出错的地方是内层循环退出条件是指针小于零,而不是小于等于零。另一处是,插空位指针是“j+1”,而不是j。
插入排序是一种直接插入,效率不高。希尔排序是对它的改进优化。
Shell_sort
1# 希尔排序实现代码一@dra
2
3def shellsort(lst):
4 n = len(lst)
5 if n <= 1:
6 return lst
7 step = n//2
8 while step > 0:
9 for i in range (step, n):
10 value = lst[i]
11 j = i - step
12 while lst[j] > value and j >= 0:
13 lst[j+step] = lst[j]
14 j -= step
15 # temp = lst[i]
16 # lst[i] = lst[i-step]
17 # lst[i-step] = temp
18 # i -= step
19 lst[j+step] = value
20 step //= 2
21 return lst
1# 希尔排序实现代码二@dra
2
3def shell_sort(lst):
4 n = len(lst)
5 if n <= 1:
6 return lst
7 step = n//2
8 while step > 0:
9 for i in range (step, n):
10 while lst[i-step] > lst[i] and i >= step:
11 temp = lst[i]
12 lst[i] = lst[i-step]
13 lst[i-step] = temp
14 i -= step
15 step //= 2
16 return lst
上边代码是最普通的一种希尔排序,其整体思路是“通过增量步长对序列分组,各组组内排序;然后不断减半增量步长,重复前边过程”。各组组内排序时用到的方法就是直接插入,可以说直接插入排序实际上是希尔排序的一种特例,就是步长step为1的情形。
比较容易出错的地方是外层循环的指针区间一定是(step,n),而不能是(0,step)。因为,如果n为偶数,两者效果确实相等;但n若为奇数,(0,step)会遗漏序列最后一个数。
希尔排序的优化基本上就是对增量数列的优化。增量步长使用1/(2^k)作系数是最常见的,它的平均时间复杂度是O(n^1.5)。如果使用Sedgewick大神的算法,将使复杂度缩减为O(n^1.3),可见下面这篇文章。
目前已知的最好步长序列,最坏情况时间复杂度为O(n^[4/3]),是在1986年由Robert Sedgewick提出的。1,5,19,41,109...
http://www.cocoachina.com/articles/121517?filter=ios
你甚至还可以见到各种稀奇古怪的递减增量数列,如下:
增量序列在希尔排序中是很重要的。一般好的增量序列都有两个共同的特征:1. 最后一个增量必须为1,保证最后一趟是一次普通的插入排序;2. 应该尽量避免序列中的值(尤其是相邻的值)互为倍数的情况
倪升武,公众号:武哥聊编程
综上,可以看出增量数列的选择将决定希尔排序的效率。那么,怎样的增量方式是更加有效的?规律是:要尽量保证数列中每个值之间彼此“互质”,即是说两两之间没有除1外的公约数。(我一直觉得算法的底层是数学,普通算法常用到数论、拓扑,机器学习算法常用到线性代数、贝叶斯概率,等等。所以,数学能力决定编程上限。 不过多说一句,计算机之于数学同物理之于数学不太一样。纵观物理学史或是力学史,充满了数学定理的诞生记录,因为物理学研究为数学工具的拓展提供了土壤,或者也可以理解为物理学探索天体、探索宇宙、探索宏微观物质世界,而数学规律则存在于大自然背后,等待被发现。计算机则偏应用,拿来已有的数学公式或理论作算法支撑,并封装成编译器或虚拟机的api供上层使用。当然计算机的超强算力使穷举成为可能,可以帮助完成某些数学证明。)
排序算法分析
名称 | 时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|
冒泡 | O(n^2) | 原地排序 | 稳定 |
选择 | O(n^2) | 原地排序 | 不稳定 |
插入 | O(n^2) | 原地排序 | 稳定 |
希尔 | O(n^1.3) | 原地排序 | 不稳定 |
以上是关于手撕算法之冒泡选择插入希尔排序的主要内容,如果未能解决你的问题,请参考以下文章