排序
归并排序
归并排序是分治法的典型应用。归并排序的思想就是先递归分解数组,再合并数组。将数组分解最小之后,然后合并两个有序数组,基本思路就是比较两个数组的最前面的数,谁更小就先取谁,取了后相应的指针就往后移动一位,直至其中一个数组为空,最后把另外一个数组的剩余部分复制到新数组的后面。
那么当序列的元素个数为奇数时,就有一组数组的元素会多一个。这样当其他数组都拆分为一个元素时,这个数组还需要再进行一次元素的拆分。
归并排序的分析
归并排序首先是不断从中间位置进行拆分切片的过程:
接着对拆分成个的元素进行大小比较并合并:这时候,因为每个组中有两个元素,因此需要借助两个指针来帮忙,一个left指向左边数组的第一个元素,一个right指向右边数组的第一个元素,再比较两者大小,小的放到最前面。
整个过程的思想就是,先把序列一直拆分,拆到只有一个元素时,开始比较和合并。
因为是不断拆分,再不断合并的过程,操作是重复进行的,所以这里需要引入递归的思想:将拆分后的序列作为对象调用原函数,不断迭代进行。这里还需要注意的是:递归调用函数,并不是对原序列进行操作,而是对原序列进行切片后,生成两个新的子序列。
既然是加入递归的思想,因此我们可以不管中间过程是如何去排序的,只需要将最后得到的有序子序列作为最初切片得到的无序子序列的返回值。
动画演示过程
代码实现
def merge_sort(alist):
"""归并排序"""
n=len(alist)
if n<=1:
return alist
mid=n//2
#left采用归并并排序后形成有序的新列表
left_li=merge_sort(alist[:mid])
#right采用归并并排序后形成有序的新列表
right_li=merge_sort(alist[mid:])
#将两个有序的子序列合并为一个新的整体
#merge(left,right)
left_pointer,right_pointer=0,0
result=[]#创建一个新的列表接收排序后的列表
while left_pointer<len(left_li) and right_pointer<len(right_li):
if left_li[left_pointer]<=right_li[right_pointer]:#比较左右两指针指向元素大小
result.append(left_li[left_pointer])#尾插left指针指向的元素
left_pointer+=1
else:
result.append(right_li[right_pointer])#尾插right指针指向的元素
right_pointer+=1
result+=left_li[left_pointer:]
result+=right_li[right_pointer:]
return result
if __name__=="__main__":
li = [54, 26, 93, 17, 77, 31, 44, 55, 20]
print(li)
sorted_li=merge_sort(li)
print(li)
print(sorted_li)
运行结果:
由此可见,最后有序列表是一个新的列表,原来列表的序列并没有发生改变。
归并排序性能分析
- 最优时间复杂度:O(nlogn)
- 最坏时间复杂度:O(nlogn)
- 稳定性:稳定
归并算法中,合并是由上一级到下一级的执行次数是不断的除以2,一共有n个元素,那么执行次数就是logn;切片和比较大小最终分成一个元素一个元素进行比较,所以执行次数是n次。
因此最终的时间复杂度是O(nlogn)
归并排序始终从中轴线去进行拆分和合并,因此不存在特殊情况,最优最坏时间复杂度都应该是一样的。在判断左右元素大小的时候,我们规定当两个元素相等时,默认先合并左边序列的元素,这样归并算法。就是稳定的
从时间复杂度上看,归并排序的时间复杂度是最小的,但是在执行了归并排序后,返回的是一个新的列表,说明空间复杂度不为1(空间上有额外的开销)。
常见排序算法效率比较
排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
插入排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
希尔排序 | O(nlogn) ~ O(n2) | O(n1.3) | O(n2) | O(1) | 不稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
快速排序 | O(nlogn) | O(nlogn) | O(n2) | O(nlogn) ~ O(n) | 不稳定 |
查找
查找是在一个项目集合中找到一个特定项目的算法过程。查找通常返回值为真或者假,表示该项目是否存在在集合中。查找的几种常见方法:顺序查找、二分查找、二叉树查找和哈希查找。
二分法查找
二分查找又称折半查找,优点是比较次数少,查找速度快,平均性能好;缺点是要求待查表为有序表,且插入删除困难。因此,折半查找方法适用于不经常变动而查找频繁的有序列表。首先,假设表中元素是按照升序排列,将表中间位置记录的关键字与查找关键字比较,如果两者相等,则查找成功,否则利用中间位置记录来将表分成前后两个子表,如果中间位置记录的关键字大于查找关键字,则进一步查找前一子表,否则进一步查找后一子表。重复以上步骤,直到找到满足条件的记录,说明查找成功。或直到子表查找完为止,说明查找不成功。
二分查找的特点:查找对象必须是已经有序的序列;对象的存储位置必须是相邻的存储,也就是说,支持下标索引—列表(顺序表)只能作用到有序的顺序表。
二分查找法,我们可以考虑使用递归的思想去完成,也可以用非递归的思想去完成。各有各的优势:递归的思想代码简单且效率高,但是会有额外空间开销;非递归思想逻辑更清晰,且没有额外的空间开销。
但是需要注意的是,我们在进行二分查找时,需要清楚起始位置和终止位置,这样才可以不断的去进行二分。
使用递归的二分法
使用递归的算法时:递归调用后是切片成新的列表,就可以直接用n/2来计算二分点,因为此时的列表序列是新的序列,n是新列表的值。
def binary_search(alist,item):
"""二分查找,递归"""
n=len(alist)
if n>0:#递归终止条件(判断n的情况:列表不可以为空,因此是n要大于0)
mid=n//2
if alist[mid]==item:
return True
elif item<alist[mid]:
return binary_search(alist[:mid],item)#递归调用函数
else:
#注意递归调用函数时,需要返回值,才能将数据返回到函数,进而继续调用
return binary_search(alist[mid+1:],item)
return False
if __name__=="__main__":
li = [17,20,26,31,44,54,55,77,93]#一定要是有序的序列(顺序表)
print(binary_search(li,54))
print(binary_search(li,100))
运行结果:
使用非递归的二分查找
非递归的二分查找不需要申请新的列表,直接在原列表上操作,因此在对原列表操作的时候,需要清楚每一次操作的起始和终止下标。
def binary_search_2(alist,item):
"""二分查找,非递归"""
n=len(alist)
first=0
last=n-1
while first<=last:#循环终止条件
mid=(first+last)//2
if alist[mid]==item:
return True
elif item<alist[mid]:
last=mid-1
else:
first=mid+1
return False
if __name__=="__main__":
li = [17,20,26,31,44,54,55,77,93]
print(binary_search_2(li,54))
print(binary_search_2(li,100))
非递归和递归的区别是递归有递归的思想:函数中去调用函数,调用函数时会产生新的列表;非递归需要确定查找的范围,(和快排类似)循环中不断去修正起始和终止的地址,去缩小范围。
二分查找时间复杂度
- 最优时间复杂度:O(1)
最优时间复杂度是,目标元素就是第一次二分时的中间元素,所以时间复杂度是常数。 - 最坏时间复杂度:O(logn)
最坏时间复杂度是在查找过程中,一直都是在二分二分,因此我们的操作次数就是对列表长度取2的对数。