数据结构与算法之查找和排序
必备知识点
时间复杂度
时间复杂度是用来估算算法运行速度的一种方式,通常采用大O表示法。
需要注意以下几点:
- 时间复杂度指的不是算法运行的时间,而是算法运行的增速。
- 时间复杂度是估算,一些非必要的会省略。
- 通常表示为O(n),其中n为操作数。
快速判断时间复杂度的方法: - 如果发现循环数减半,那么复杂度就是logn。
- 有几层循环就是n的几次方,不要在意具体循环几次。
递归
递归比较容易理解,有以下两个特征:
- 调用自身
- 有 终止条件
我们可以通过sys模块来进行查看默认最大执行次数,同时 sys.setrecursionlimit() 也能进行更改,默认是1000次.
#递归实现斐波那契数列
def fibnacci(n):
if n=0 or n=1:
return 1
else:
return fibnacci(n-1)+fibnacci(n-2) #这就是递归的精髓,把复杂重复的运算抽丝剥茧,每递归一次就简化一次
#斐波那契数列可以用更简单的方法实现
def fibnacci(n):
a=b=c=1
for i in range(2,n+1):
c=a+b
a=b
b=c
return c
#递归实现汉诺塔
def hanoi(n, A, B, C):
if n > 0:
hanoi(n-1, A, C, B)
print('%s->%s' % (A, C))
hanoi(n-1, B, A, C)
查找
简单查找
简单查找就是按顺序查找,直到查到指定元素,时间复杂度为O(n)。
二分查找
二分查找是对简单查找的一种优化,但是操作的只能是有序数组,就是通过中间值和需求数比较,通过比较结果来改变左右范围。
需要注意的是,不要通过切片改变列表,那样会加大空间复杂度。
尾递归的定义:尾递归就是所有递归语句都是在函数的最后出现的,正常是相当于循环的复杂度,但是python内没有优化。
def bin_search(li, val):
low = 0
high = len(li)-1
while low <= high: # 只要候选区不空,继续循环
mid = (low + high) // 2
if li[mid] == val:
return mid
elif li[mid] < val:
low = mid + 1
else: # li[mid] > val
high = mid - 1
return -1
#递归实现二分法
lst = [1, 2, 33, 44, 555]
def func(left,right,n):
if left <= right:
mid = (right + left) // 2
if n > lst[mid]:
left = mid+1
if n < lst[mid]:
right = mid - 1
if n == lst[mid]:
return( "找到了")
else:
return("没找到")
return func(left,right,n)
ret = func(0,len(lst)-1,44)
print(ret)
#区别:本方法利用切片,改变原序列表,增加了空间复杂度
lst = [1, 2, 33, 44, 555]
count = 0
def func(lis,n):
left = 0
right = len(lis)-1
global count
count = count + 1
print(count)
if left <= right:
mid = (right + left) // 2
if n > lst[mid]:
return func(lis[mid+1:],n)
elif n < lst[mid]:
return func(lis[:mid], n)
else:
return( "找到了")
else:
return("没找到")
ret = func(lst,44)
一个小技巧
主要思想为:新建列表作为索引,如果一个数的索引存在,说明这个数也存在.
lis = [2,4,6,7] #就是计数排序的反向使用,计数排序会在线性排序模块里说
n = 3 #查找n
lst = [0,0,0,0,0,0,0,0] #创建一个元素均为0的列表,元素个数为lis中最大的数字加1
li = [0,0,1,0,1,0,1,1] #把 lis 中对应的数字值变为1
if li[3] == 1:
print("存在")
else:
print("不存在")
排序
排序算法是有稳定和不稳定之分。
稳定的排序就是保证关键字相同的元素,排序之后相对位置不变,所谓关键字就是排序的凭借,对于数字来说就是大小。
排序算法的关键点是分为有序区和无序区两个区域。
冒泡排序
冒泡排序思路:
- 比较列表中相邻的两个元素,如果前面的比后边的大,那么交换这两个数。
- 这就会导致每一次的冒泡排序都会使有序区增加一个数,无序区减少一个数。
- 可以认为得到一个排序完毕的列表需要n次或者n-1次。n-1次是因为最后一次不需要进行冒泡了,当然n或n-1的得到的列表是一样的。
冒泡排序是稳定的。
#基础冒泡
def bubble_sort(li):
for i in range(len(li)-1): #第一层循环代表处于第几次冒泡
for j in range(len(li)-i-1): #第二层循环代表无序区的范围
if li[j]>li[j+1]:
li[j],li[j+1]=li[k+1],li[j]
如果考虑冒泡的最好情况,也就是冒泡没有进行到n次的时候就已经不出现j>j+1了,那么排序已经进行完毕。
def bubble_sort(li):
for i in range(len(li)-1): #第一层循环代表处于第几次冒泡
a= 1
for j in range(len(li)-i-1): #第二层循环代表无序区的范围
if li[j]>li[j+1]:
li[j],li[j+1]=li[k+1],li[j]
a=2
if a=1:
break
选择排序
选择排序的思路:
- 一次遍历找出最小值,放到第一个位置。
- 再一次遍历找最小值,在放到无序区第一个位置。
- 与冒泡一样是进行n或n-1次
- 每次都会让有序区增加一个元素,无序区减少一个元素。那么进行第i次的时候,它的第一位置的索引就是i。注意是无序区。
选择排序是不稳定的,跨索引交换(对比于相邻)就是不稳定的。
def select_sort(li):
for i in range(len(li)-1):
min_pos=i #第几次,无序区的第一个位置的索引就为几减一
for j in range(i+1,len(li)):
if li[j]<li[min_pos]: #min_pos会随着循环变换值
min_pos=j
li[i],li[min_pos]=li[min_pos],li[i]
插入排序
插入排序的思路:
- 在最开始有序区就把列表的第一个元素就放入有序区(这种有序是相对有序)。
- 在无序区第一个位置取出一个元素与有序区本来存在的元素进行比较,根据大小插入。
- 插入排序需要n-1次得出结果。
- 每次进行插入比较就是一步步的往前进行比较,也就是位置所以要一次次的减1,可能出现位置在最前面也就是插入位置索引为0,也可能是在中间,所以有两种情况。
def insert_sort(li):
for i in range(1,len(li)): #i表示需要进行插入的元素的位置
j=i-1 #j的初始位置,也就是无序区第一个元素的位置
while j!=-1 and li[j]>li[i]:#只要能够与无序区元素进行比较,循环就不停止
#跳出循环的情况只有是有序区进行比较的元素没了,但是跳出循环时与li[j]<=li[i]时执行的语句是一样的,都是li[j+1]=li[i],所以进行一个合并,减少代码量
li[j+1]=li[j] #进行比较的有序区索引加一
j-=1 #进行比较元素的索引减一
li[j+1]=li[i] #也就是成为0号元素
快速排序
快排思路:
- 取第一个元素,使元素归位。
- 归位的意义为列表分为两部分,左边都比该元素小,右边都比该元素大。
- 递归,进行多次归位。
快速排序的时间复杂度为 nlogn。
def _quick_sort(li,left,right):
if left<right: #待排序区域至少有两个元素,left和right指的是索引
mid = partition(li,left,right)
_quick_sort(li,left,mid-1)
_quick_sort(li,mid+1,right)
def quick_sort(li): #包装一下,因为循环不能直接递归,会非常慢
_quick_sort(li,0,len[li]-1)
def partition(li,left,right): #此函数的意义是归位
tmp=li[left] #left为第一个元素的索引,也就是需要进行归位的元素的索引
while left<right:
#注意,小的在左边,大的在右边
while li[right]>tmp and left<right: #当right的值小于tmp是退出
right-=1 #进行下一个right
li[left]=li[right] #把left位置放比tmp小的right
while li[left]<=tmp and left<right:
left+=1
li[right]=li[left] #把right位置放比tmp大的left
li[left]=tmp #把tmp放在left=right时剩下的位置
return left
在Haskell中只需要6行
quicksort :: (Ord a) =>[a] ->[a]
quicksort [] = []
quicksort (x:xs) =
let smallersort = quicksort[a|a<- xs, a <=x] -- 属于xs,并且小于x
let biggersort = quick[a|a<- xs,a>x]
in smallersort ++ x ++ biggersort
堆排序
知识储备:
- 树是一种数据结构,可以通过递归定义。
- 树是由n个节点构成的集合
- 如果n=0,那么是一颗空树。
- 如果n>0,那么存在 一个根节点,其他的节点都可以单拎出来作为一个树。
- 根节点,就是回溯后存在的节点。
- 叶子节点,就是没有下层节点的节点。
- 树的深度可以粗略认为就是节点最多的分支有几个节点
- 度,度就是一个节点由几个分支,也就是说有几个子节点。
- 父节点和子节点索引的关系,若父节点的索引为i,左子节点索引为2i+1,右子节点的索引为2i+2,子节点找父节点,i=(n-1)//2
- 二叉树:度最多有两个的树。
- 满二叉树:一个二叉树,每一层的节点数都是最大值,也就是2,那么它就是满二叉树。
- 完全二叉树:叶子节点只能出现在最下层和次下层,并且最下面一层的节点都集中在该层最左侧,满二叉树是一种特殊的完全二叉树。
二叉树的存储方式:
- 链式存储,在之后的数据结构博客介绍。
- 顺序存储,顺序存储就是列表。结构就是从上到下从左到右。
- 堆:堆是一颗完全二叉树,分为大根堆和小根堆:
- 大根堆就是任何的子节点都比父节点小。
- 小根堆就是任何一个子节点都比父节点大。
- 堆的向下调整性质:当根节点的左右子树都是堆,那么就可以将其通过向下调整来建立一个堆
- 向下调整就是把根节点单独拿出来,让它子节点尽行大小比较,然后把根节点插入到子节点位置,子节点成为新的根节点,如此递归,直到满足堆的定义。
堆排序也就是优先队列,进行多次向下调整,得出一个根堆,然后根据索引从后往前挨个输出节点。
向下调整
def sift(li,low,high):
i=low #相对根节点
j=2*i+1 #它的左子节点位置
tmp=li[i] #根节点元素大小
while j<=high:
if j<high and li[j]<li[j+1]: #先判断左右节点大小,j<high是因为可能出现没有有节点的情况
j+=1
if tmp <li[j]: #再判断左节点或有节点与根节点的大小
li[i]=li[j] #把左右节点移动到根节点
i=j #把相对根节点移动到下一层
j=2*i+1 #新的子节点索引
else:
break
li[i]=tmp #最后把原来的根节点放到索引i上
从堆中取出节点元素
def heap_sort(li):
for low in range(len(li)//2-1,-1,-1): #构造堆,low的取值是倒序,从后面到0
sift(li,low,len(li)-1) #high被假设是固定的,因为它为最小对结果不会影响。
for high in range(len(li)-1,-1,-1): #取出时high一直是动态的,让取出的low不参加之后的调整,也就是构建新堆的过程
li[0],li[high]=li[high],li[0] #把得出的无序区最后一个值,放到根结点处进行构建新堆
sift(li,0,high-1) #
python的heapq内置模块
- nlargest,nsmallest 前几个最大或最小的数
- heapify(li) 构造堆
- heappush 向堆里提交然后构造堆
- heappop 弹出最后一个元素
归并排序
归并排序思路:
- 一次归并:含义就是给定一个列表,分为两段有序,让它成为一个整体有序的列表。
- 一次归并的方法:把两段有序列表,两两比较,把较小的那个元素拿出来,若一方元素数量为0,那么就将另一方所有元素取出。
- 归并排序: 先分解后合并,分解为单个元素,那么单个元素就是有序的,然后再两两一次归并,得到有序列表。
- 也就是把归并排序看成多次的一次归并。
def merge(li, low, mid, high): #mid为两段有序分界线左边第一个数的索引
# 列表两段有序: [low, mid] [mid+1, high]
i = low #i指向左半边列表进行比较的元素
j = mid + 1 #j指向右半边列表进行比较的元素
li_tmp = [] #比较出较小的元素暂存的位置
while i <= mid and j <= high: #当左右两侧比较的元素都不为空时
if li[i] <= li[j]: #左边小,左边元素拿到暂存li_tmp中,左边指针向右移动
li_tmp.append(li[i])
i += 1
else: #右边小,右边元素拿到li_tmp中,右边元素的指针向左移动
li_tmp.append(li[j])
j += 1
while i <= mid: #将剩余的元素都拿到li_tmp中
li_tmp.append(li[i])
i += 1
while j <= high:
li_tmp.append(li[j])
j += 1
for i in range(low, high+1): #把li_tmp中的元素放到li中
li[i] = li_tmp[i-low]
# 也可以这样移动: li[low:high+1] = li_tmp
def _merge_sort(li, low, high): #排序li从low到high的范围
if low < high:
mid = (low + high) // 2 #开始递归分散
_merge_sort(li, low, mid)
_merge_sort(li, mid+1, high)
merge(li, low, mid, high) #合并
希尔排序
希尔排序思路:
- 希尔排序是一种分组插入排序算法,可以理解为是对插入排序的优化。
- 首先去一个整数d1=n/2,将元素分为d1个组,每组相邻两个元素之间的距离为d1,在各组内直接插入排序。
- 接下来,去第二个元素d2=d1/2,重复上一步,直到di=1。
- 即所有元素在同一组内进行直接插入排序。
- 希尔排序每次都会让列表更加接近有序,在那过程中不会使某些元素变得有序。
def shell_sort(li):
gap = len(li) // 2
while gap > 0: #接下来进行的其实就是插入排序的代码,只不过无序区起始是从gap也就是从gap开始。
for i in range(gap, len(li)):
tmp = li[i]
j = i - gap
while j >= 0 and tmp < li[j]:
li[j + gap] = li[j]
j -= gap
li[j + gap] = tmp
gap /= 2
线性时间排序
线性时间排序都有局限性,并不常用。
计数排序
计数排序思路:
- 计数排序时间复杂度为O(n),是一种特殊的桶排序。
- 首先必须知道列表中数的范围
- 加入最大值的大小为d,那么就建立一个元素数目为d的值均为0的列表,这也是计数排序的局限性,如果最大数很大的化,计数排序会很占用内存。
- 遍历原列表,如果值为t的某个数出现了一次,那么就在新建列表中索引为t的元素的值上加1.
- 最后再遍历新建列表,把索引作为值,值作为出现次数依次列出。
def count_sort(li,max_num):
count = [0 for _ in range(max_num+1)] #列表表达式,之前的博客有写,和Haskell中一样_代表我们完全不再在乎它的值
for i in li:
count[i]+=1 #出现一次,值加一
li = []
for i,v in enumerate(count): #i是元素,v是出现的次数
for _ in range(v):
li.append(i)
桶排序
桶排序思路:
- 桶排序是对计数排序的一种改造,时间复杂度O(nk)
- 计数排序可以看成每个桶里只装一个元素的桶排序。
- 首先根据待排序列表的数据分布进行分桶,桶是有顺序且不间断的。
- 然后分别对桶里的元素进行排序
- 桶内元素排序完成之后,整体列表也就排序成功了
- 桶内排序可以采用任何算法
- 内嵌排序会由于桶排序而降低时间复杂度,也就是以空间换时间,乘法变加法。
- 得根据实际情况写代码,例子就不写了,思路很简单。
基数排序
要说基数排序,先说多关键字排序。
多关键字排序思路:
- 多关键字排序就是,在XX条件的基础上进行排序。
- 比如日常场景中的,对薪资相等的员工,由年龄从大到小进行排序。
- 排序的方式为 先有年龄从大到小进行排序,再对薪资进行排序。
- 其中年龄被称为低关键字,薪资为高关键字,也就是说,先行条件为高,先行条件的基础上的条件为低。
- 原因是稳定排序,大小相等的元素,其相对位置不变。
有了这个思路,再说基数排序: - 基数排序是针对整数的。
- 可以思考两位数的大小排序,个位数为低关键字,十位数就是高关键字。
- 基数排序就是把个位数0-9分成10个桶,依次进行排序。
- 因为桶的索引是从0-9,那么只要放进桶里就相当于已经完成了一次排序
预备知识:
- 如何获取一个整数的个位数、十位数,百位数。。。
- 首先,得出列表中的最大值,除以10取余可以获得个位数
- 其次,除以10取整,会舍去个位数,变成以原十位作为个位的数
- 思路就是不断的取整,取余
def get_dight(num,i):
#i为1时,取个位数,i为2时取十位数
return num//(10 * i)%10
还可以转成列表后根据索引得出,思路和上面是一样的,就当科普了。
#int转list
def int2list(num):
li=[]
while num>0:
li.append(num%10)
num //=10
li.reverse_list() #再科普一个列表反转吧
return li
列表反转思路:
- 把列表分为左右两边
- 对应的元素交换位置
def reverse_list(li):
n=len(li)
for i in range(n//2):
li[i],li[n-i-1] = li[n-i-1],li[i]
return li
- 说完列表反转再说一个int反转吧
- 整数有正负之分
- 首先求出num的余数a,num地板除得到b。
- a*10+b,得到后两位数的反转
- 重复上述两步,直到地板除为0。
def reverse_int(num):
is_neg = False
if num < 0:
is_neg = True
num = -1 * num
res = 0
while num >0:
res = res * 10
res = res + num%10
num = num // 10
if is_neg:
res = res * -1
return res
。。
直接上基数排序:
def max_nu(li):
for i in range(len(li)-1):
max_pos=i
for j in range(i+1,len(li)-1):
if li[i] < li[j]:
max_pos=j
def radix_sort(li):
max_num=max_nu(li)
i = 0 #从个位数开始,
while (10 ** i <= max_num): #这里注意是可以 = 的,要考虑 10 100 的情况
buckets = [[] for i in range(0,10)]
for val in li:
digit = li // (10 ** i)%10
buckets[digit].append[val] #以余数作为索引,放入桶内,第一次循环是个位数,第二次循环是十位数
li = []
for bucket in buckets:
for val in bucket: #在桶中依次取出数据
li.append(val) #进行一次排序后放回li中
i+=1