一、算法复杂度
1、时间复杂度:
用来评估算法运行效率(时间)的一个式子(单位)。
(1)一般来说,时间复杂度高的算法比复杂度低的算法慢。
(2)常见的时间复杂度(按效率排序)
O(1)<O(logn)<O(n)<O(nlogn)<O(n^2)<0(n²logn)<O(n³)
(3)快速判断算法复杂度(适用于绝大多数简单情况):
- 确定问题规模n
- 循环减半过程→logn
- k层关于n的循环→n^k
- 复杂情况:根据算法执行过程判断
2、空间复杂度:
用来评估算法内存占用大小的式子
空间复杂度的表示方式与时间复杂度完全一样 - 算法使用了几个变量: O(1)
- 算法使用了长度为n的一维列表: O(n)
- 算法使用了m行n列的二维列表: O(m*n)
- “空间换时间”
二、递归
1、递归的两个特点:
- 终止条件
- 调用自身
2、递归的位置不同,得到的结果会不同【程序自上而下打印】
例:以长横条表示print;以方框表示递归;最外面的方框表示程序框架
(1)
def fun1(x):
if x>0:
print(x)
fun1(x-1)
fun1(3) # 输出 3 2 1
(2)
def fun1(x):
if x>0:
fun1(x-1)
print(x)
fun1(3) # 输出 1 2 3
3、汉诺塔问题:
将A柱上从小到大排的所有盘子移动到C柱上,移动过程可借助B柱,但要保证所有柱子上的盘子每个时刻都是按从小到大排的
(1) n=2时:
▶1.把小圆盘从A移动到B
▶2.把大圆盘从A移动到C
▶3.把小圆盘从B移动到C
(2)n个盘子时:【将前n-1个小圆盘看作一个整体】
▶1.把n-1个小圆盘从A经过C移动到B # h(n-1)
▶2.把第n个大圆盘从A移动到C # 1
▶3.把n-1小圆盘从B经过A移动到C # h(n-1)
(3)代码实现:
def hanoi(n,a,b,c): # 参数:n个盘子,盘子从a经过b,移动到c
if n > 0:
hanoi(n-1,a,c,b) #
print("moving from %s to %s"%(a,c))
hanoi(n-1,b,a,c)
hanoi(3,'A','B','C')
(4)结果输出:
moving from A to C
moving from A to B
moving from C to B
moving from A to C
moving from B to A
moving from B to C
moving from A to C
(5)汉诺塔移动次数的递推式:h(n) = h(n-1) +1 +h(n-1) = 2h(n-1)+1
三、查找
- 查找:在一些数据元素中,通过一定的方法找出与给定关键字相同的数据元素的过程。
- 列表查找(线性表查找):从列表中查找指定元素
▷输入:列表、待查找元素
▷输出:元素下标(未找到元素时一般返回None或-1) - 内置列表查找函数:index()
1、顺序查找(Linar Search)
顺序查找:也叫线性查找,从列表第一个元素开始,顺序进行搜索,直到找到元素或搜索到列表最后一个元素为止。
▷时间复杂度:O(n)
# 顺序查找
def linear_search(li,val):
for i,v in enumerate(li): # i:index. v:value
if v == val:
return i
return None # 注意None的位置
##测试 :输出 1
li = [1,2,3,4,5]
print(linear_search(li,2))
2、二分查找
⚠️前提是有序序列
二分查找(Binary Searh):又叫折半查找
从有序列表的初始候选区li[0:n]开始,通过对待查找的值与候选区中间值的比较,可以使候选区减少一半。
▷时间复杂度:O(logn)
# 二分查找
def binary_search(li,val):
left = 0 # 左指针
right = len(li)-1 # 右指针
while left <= right: # 候选区有值
mid = (left+right)//2 # 取整数
if li[mid] < val: # 待查找值在mid右侧
left = mid + 1 # 左指针右移
elif li[mid] > val: # 待查找值在mid左侧
right = mid -1 # 右指针左移
else: # li[mid] == val
return mid
return None # val不在li中,则返回None
##测试 :输出 2
li = [1,2,3,4,5]
print(binary_search(li,3))
四、列表排序
🐷【比较排序】
要求:将从大到小的排序改成从小到达的排序,或者反过来;
操作:直接将程序中所有比较的地方换成相反的符号
排序:将一组“无序”的记录序列调整为“有序”的记录序列。
▷列表排序:将无序列表变为有序列表
输入:列表
输出:有序列表
▷升序与降序
▷内置排序函数:sort()
常见排序算法
1、排序lowB三人组:
▷冒泡排序
▷选择排序
▷插入排序
2、排序NB三人组
▷快速排序
▷堆排序
▷归并排序
3、其他排序
▷希尔排序
▷技术排序
▷同排序
1、冒泡排序
冒泡排序(Bubble Sort):原地排序【不需要添加新的列表】
※列表每两个相邻的数,如果前面比后面大,则交换这两个数。(从小到达排序:大的数往后排)
※一趟排序完成后,则无序区(靠前)减少一个数,有序区(靠后)增加一个数。
代码关键点:趟、无序区范围
▷时间复杂度:O(n^2)
import random
# 冒牌排序
def bubble_sort(li):
n = len(li)
for i in range(n-1): #共n-1趟,因为最后一趟只剩一个数,不用排序
for j in range(n-i-1):# 第i趟中,指针可移动的最大位置(无序区的范围)
if li[j] > li[j+1]: # 从小到大排序
# if li[j] < li[j + 1]: # 从大到小排序
li[j],li[j+1] = li[j+1],li[j] # 交换li[j],li[j+1]
# return li
#参数是列表,列表是可变对象,如果传进函数中作了修改,默认自身修改,就像指针一样,所以不用return
##测试 :输出 排序前:[21, 99, 34, 96, 14]
#排序后:[14, 21, 34, 96, 99]
li = [random.randint(0,100) for i in range(5)]
print("排序前:%s" % li)
bubble_sort(li)
print("排序后:%s" % li)
冒泡排序的改进
因为在排序趟数未达到n-1时,就有可能已经排好序,所以可以在每一趟添加一个标志位,一旦排好序后,就不用继续排序,可以减少排序趟数。
# 冒牌排序的改进
def bubble_sort(li):
n = len(li)
for i in range(n-1): # 共n-1趟,因为最后一趟只剩一个数,不用排序
exchange = False # 设立标志
for j in range(n-i-1): # 第i趟中,指针可移动的最大位置(无序区的范围)
if li[j] > li[j+1]:
li[j],li[j+1] = li[j+1],li[j] # 交换li[j],li[j+1]
exchange = True # 发生交换就置为True
print("排序中:%s" % li)
if not exchange: # 每一趟排序后都判断一次,如果没有发生交换,证明已经排好序了
return
2、选择排序
选择排序(Select Sort):【记录每一趟排序中最小的数,放到每一趟的第一个位置】
▶一趟排序记录最小的数,放到第一个位置(最小值)
▶再一趟排序记录列表无序区最小的数,放到第二个位置(次小值)
▶……
▶算法关键点:有序区和无序区、无序区最小数的位置
▶时间复杂度:O(n^2)
# 选择排序
def select_sort(li):
n = len(li)
for i in range(n-1): # 共n-1趟,因为最后一趟只剩一个数,不用排序
min_loc = i # 每一趟都将无序区的第一个数先当作最小值
for j in range(i+1,n): # 无序区的范围
if li[j] < li[min_loc]:
min_loc = j # 确定最小值的位置
li[i],li[min_loc] = li[min_loc],li[i] # 交换最小值与无序区的第一个数
li = [9, 8, 7, 1, 2, 3, 4, 5, 6]
select_sort(li)
print(li)
选择排序的改进
在每一趟中,确定了最小值的位置后,将最小值的位置与该趟无序区第一个数的位置 i 比较,如果相等,则不用交换,可以减少交换次数。
# 选择排序的改进
def select_sort(li):
n = len(li)
for i in range(n-1): # 共n-1趟,因为最后一趟只剩一个数,不用排序
min_loc = i # 每一趟都将无序区的第一个数先当作最小值
for j in range(i+1,n): # 无序区的范围
if li[j] < li[min_loc]:# 不等要交换;相等不用交换
min_loc = j # 确定最小值的位置
if min_loc != i:
li[i],li[min_loc] = li[min_loc],li[i] # 交换最小值与无序区的第一个数
3、插入排序
▶初始时手里(有序区)只有一张牌
▶每次(从无序区)摸一张牌,插入到手里已有牌的正确位置
▶时间复杂度:O(n^2)
# 插入排序
def insert_sort(li):
n = len(li)
for i in range(1,n): # # 共n-1趟,因为初始时手里(有序区)已有一张牌,所以从1开始摸牌(无序区的范围)
tmp = li[i] # 因为每趟手里的牌先要后移一位(为插入牌tmp腾位置),会覆盖摸出那张牌tmp的位置,所以先暂存摸出牌tmp的值
j = i -1 # j指针是最靠右的手里牌的下标
while j >=0 and li[j] > tmp: # 当手里牌的值>摸出牌的值时,但摸出牌的值不能超出手里牌的左边界0:
li[j+1] = li[j] # 先将手里牌li[j]后移一位,到j+1位置上
j -= 1 # 摸出牌的值与手里牌的值逐个比较(j指针左移)
li[j+1] = tmp # 因为循环中j左移了一位,所以tmp放在j+1的位置
## 测试:输出[ 3, 4, 5, 6, 7]
li = [4, 5,7,6,3]
insert_sort(li)
print(li)
4、快速排序:
快速排序思路:
▶取一个元素p,(第一个元素),使元素p归位;
▶列表被p分成两部分,左边都比p小,右边都比p大;
递归完成排序。
▶算法关键点: 双指针;只要 left== right ,循环就结束。
▶时间复杂度:O(nlogn)
🐷n个元素可分成logn层(类似二叉树划分),每层patition函数的时间复杂度是O(n)
▶最坏情况:列表逆序时
🐷解决方案:随机取第一个待归位元素
# 4、快速排序
## 框架
def quick_sort(li,left,right):
if left < right: # 至少两个元素,若只有0个或1个元素,则不用递归
mid = partition(li,left,right) # 第一个元素归位,将列表分成两部分
quick_sort(li,left,mid-1) # 左边递归
quick_sort(li,mid+1,right) # 右边递归
## 每一趟归位一个元素的过程(指针一右一左移动)
def partition(li,left,right):
tmp = li[left] # 从左边开始取出待归位元素tmp
while left < right: # 只要 left== right ,循环就结束
while left < right and li[right] >= tmp: # 从右侧找比tmp小的值 (因为循环中right值在减小,一旦 left== right ,循环就结束。)
right -= 1 # 往左找
li[left] = li[right] # 把在右侧找到的值写到左边的空位上
# print(li,"right")
while left < right and li[left] <= tmp: # 从左侧找比tmp大的值 (因为循环中left值在增大,一旦 left== right ,循环就结束。)
left += 1 # 往右找
li[right] = li[left] # 把在左侧找到的值写到右边的空位上
# print(li, "left")
li[left] = tmp # 当left==right 时,tmp归位
return left # 返回tmp元素归位的位置
## 测试 输出[1, 2, 3, 4, 5, 6, 7, 8, 9] 排序完成
li = [5,7,4,6,3,1,2,9,8]
quick_sort(li,0,len(li)-1)
print(li,"排序完成")
树与二叉树
树
▶树是一种数据结构 比如:目录结构
▶树是一种可以递归定义的数据结构
▶树是由n个节点组成的集合
▶如果n=0,那这是一棵空树;
▶如果n>0,那存在1个节点作为树的根节点,其他节点可以分为m个集合,每个集合本身又是一棵树。
▶一些概念
根节点、叶子节点
树的深度(高度):节点的层数
节点的度:该节点的分叉数
树的度:整棵树中最大节点的度(这棵树最多分几个叉)
孩子节点/父节点:
子树:类似于树杈
二叉树
▶二叉树:度不超过2的树
▶每个节点最多有两个孩子节点
▶两个孩子节点被区分为左孩子节点和右孩子节点
▶满二叉树
▶完全二叉树:拿掉满二叉树的最后几个节点
(a)是满二叉树 (b) 是完全二叉树
▶二叉树的存储方式(表示方式)
- 链式存储方式
- 顺序存储方式(用列表存储)
在顺序存储方式(用列表存储)下:
▶父节点下标为i: 则左孩子节点下标为2i+1;右孩子节点下标为2i+2
▶孩子节点下标为i: 则父节点下标为(i-1) // 2。
堆
堆:一种特殊的完全二叉树结构(还要看节点数值)
▶大根堆:一棵完全二叉树,满足任一节点都比其孩子节点大
▶小根堆:一棵完全二叉树,满足任一节点都比其孩子节点小
堆的向下调整性
▶假设根节点的左右子树都是堆,但根节点不满足堆的性质
▶可以通过一次向下的调整来将其变成一个堆。
5、堆排序
思想:挨个出数
过程:
▶1.建立堆。(从最后一个非叶子节点开始调整)
▶2.得到堆顶元素,为最大元素
▶3.去掉堆顶,将堆最后一个元素放到堆顶,此时可通过一次调整重新使堆有序。
▶4.堆顶元素为第二大元素。
▶5.重复步骤3,直到堆变空。
▶时间复杂度:O(nlogn)
🐷sift函数的时间复杂度:O(logn)
⚠️堆排序没有用到递归
# 第3步:向下调整
def sift(li,low,high):
"""
:param li: l列表
:param low: 堆顶位置
:param high: 堆最后一个元素的位置
:return:
"""
tmp = li[low] # 取出堆顶位置
i = low # i指向堆的根节点
j = 2 * i + 1 # j先指向左孩子,移动过程中指向 max{左孩子,右孩子} 的位置
while j <= high: # j位置有数
if j+1 <= high and li[j+1] > li[j]: # 如果有右孩子并且右孩子>左孩子
j = j + 1 # j指向右孩子
if li[j] > tmp: # 此时j指向 max{左孩子,右孩子} 的位置
li[i] = li[j] # li[j]放到 i 这个空位上
i = j # 此时j位置为空,所以往下看一层,将i指向j
j = 2 * i + 1 # j更新
else: # li[j] < tmp
li[i] = tmp # 把tmp放到 i 位置上
break
li[i] = tmp # 此时i指向空位,j>high,越界,跳出循环,将tmp值放到空位上(叶子节点)
def heap_sort(li):
## 第1步:建堆(从最后一个非叶子节点构成的小堆开始调整)
## 最后一个叶子节点的下标是n-1 ,因为最后一个非叶子节点是其父节点,所以最后一个非叶子节点的下标是(n-2)//2
n = len(li)
for i in range((n-2)//2,-1,-1): # i表示建堆过程中,构成的小堆的堆顶下标 逆序(range不包括-1)
sift(li,i,n-1) # 由于小堆在不断变化,它的high很难确定,可以用整个大堆的high来保证不会越界
## 建堆完成
for j in range(n-1,-1,-1): # j先指向整个堆的最后一个元素
li[0],li[j] = li[j],li[0] # 交换整个堆的堆顶和堆的最后一个元素(原本应该是去掉堆顶,将堆最后一个元素放到堆顶,现在是为了节省存储空间)
sift(li,0,j-1) # j-1是要调整的堆(不包括原本要去掉的堆顶值)的high
## 测试:输出[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
li = [9,8,7,6,5,0,1,2,4,3]
heap_sort((li))
print(li)
堆排序:top k问题
问题描述:现在有n个数,设计算法得到前k大的数。
解决思路:
▶1.排序后切片: O(nlogn)
▶2.排序lowB三人组: O(kn)
▶3.堆排序: O(nlogk) 【n很大时最快】
🐷sift函数不动,将heap_sort函数改写成topk函数
算法流程:
▶取列表前k个元素建立一个小根堆。堆顶就是目前第k大的数。
▶依次向后遍历原列表,对于列表中的元素,如果小于堆顶,则忽略该元素;
如果大于堆顶,则将堆顶更换为该元素,并且对堆进行一次调整;
▶遍历列表所有元素后,倒序弹出堆顶。
6、归并排序
归并
假设现在的列表分两段有序,如何让其合成为一个有序列表,这种操作称为一次归并。
归并流程:
▶分解:将列表越分越小,直至分成一个元素。
▶终止条件:一个元素是有序的。
▶合并:将两个有序列表归并,列表越来越大。
▶时间复杂度:O(nlogn)
🐷每次合并merge函数的时间复杂度:O(n),总共有logn层(分解)。
▶空间复杂度:O(n) 【开辟了ltmp变量空间进行存储】
# 合并
def merge(li,left,mid,right): # 切片li[left,right+1]
i = left # i:指向左有序列表的第一个数
j = mid + 1 # j:指向右有序列表的第一个数
ltmp = [] # 存放已排序部分
while i <= mid and j <= right: # 左、右两个有序列表都有数
if li[i] < li[j]:
ltmp.append(li[i])
i += 1 # 指针向后一位
else: # li[i] >= li[j]
ltmp.append(li[j])
j += 1 # 指针向后一位
## while执行完,肯定有一侧列表没数了,把
while i <= mid: # 左有序列表还有数
ltmp.append(li[i])
i += 1
while j <= right: # 右有序列表还有数
ltmp.append(li[j])
j += 1
li[left:right+1] = ltmp # 放回到从li中切出来的位置
# 测试
# li = [2,4,5,7,1,3,6,8]
# merge(li,0,3,7)
# print(li)
def merge_sort(li,left,right):
if left < right: # 至少有两个元素,递归
mid = (left + right)//2 ## mid:划分点
merge_sort(li,left,mid) # 递归左边
merge_sort(li,mid+1,right) # 递归右边
merge(li,left,mid,right) # 合并
## 测试 输出[1, 2, 3, 4, 5, 6, 7, 8, 9]
li = [2,5,7,8,9,1,3,4,6]
merge_sort(li,0,8)
print(li)
NB三人组小结
▶三种排序算法的时间复杂度都是O(nlogn)
▶一般情况下,就运行时间而言:
快速排序<归并排序<堆排序
▶三种排序算法的缺点:
- 快速排序:极端情况下排序效率低
- 归并排序:需要额外的内存开销
- 堆排序:在快的排序算法中相对较慢
🎍1、冒泡排序的最好情况就是已经排好序。
🎍2、快速排序的最坏情况是完全逆序。
🎍3、快速排序的空间复杂度是递归所占用的空间,每层递归占用O(1),平均情况下可划分成logn层;最坏情况下可划分成n层。
🎍4、归并排序开辟了ltmp变量空间,为O(n)。
🎍5、空间复杂度为O(1)的说明是原地排序。
🎍6、稳定性:当遇到相同元素时,能保证这两个数的相对位置不变。
辨别方式:交换时,挨个交换的就是稳定排序;跳跃着交换的就是不稳定排序。
7、希尔排序
▶希尔排序(Shell Sort)是一种分组插入排序算法。
▶首先取一个整数d1=n/2,将元素分为d1个组,每组相邻量元素之间距离为d1在各组内进行直接插入排序;
▶取第二个整数d2=d1/2,重复上述分组排序过程,直到di=1,即所有元素在同一组内进行直接插入排序。
▶希尔排序每趟并不使某些元素有序,而是使整体数据越来越接近有序;最后一趋排序使得所有数据有序。
▶希尔排序的时间复杂度讨论比较复杂,并且和选取的gap序列(d1,d2,……,di)有关。
方法一
将插入排序insert_sort函数增加一个参数gap,并且将代码中的1全部改为gap,然后再写一个函数,定义gap序列的取值,并调用insert_sort函数。
def insert_sort_gap(li,gap):
for i in range(gap,len(li)):
tmp = li[i]
j = i-gap
while j>= 0 and li[j] > tmp:
li[j + gap] = li[j]
j -= gap
li[j+gap] = tmp
def insert_shell_sort(li):
d = len(li)//2
while d >= 1:
insert_sort_gap(li,d)
d //= 2
方法二
完整的希尔排序
def shell_sort(li):
gap = len(li)//2
while gap >= 1:
for i in range(gap, len(li)):
tmp = li[i]
j = i - gap
while j >= 0 and li[j] > tmp:
li[j + gap] = li[j]
j -= gap
li[j + gap] = tmp
gap //= 2
8、计数排序
对列表进行排序,已知列表中的数范围都在0到100之间。设计时间复杂度为O(n)的算法。
def count_sort(li,count_max):
"""
:param li:
:param count_max: 数的最大范围
:return:
"""
count = [0 for _ in range(count_max+1)] # count列表用来存放每个值出现的次数,从1开始对应,所以count_max+1个0
for val in li:
count[val] += 1
li.clear() # 清空li,用来存放要输出的列表
for ind,val in enumerate(count):
for i in range(val):
li.append(ind) # count列表中有val个ind值
# 测试 输出[1, 1, 1, 2, 2, 3, 3, 3, 4, 5]
li = [1,3,2,4,1,2,3,1,3,5]
count_sort(li,10)
print(li)
🐷弊端:要先知道数的范围,并且数越大,越占用空间。
9、桶排序
桶排序(Bucket Sort)是对计数排序的改进.
▶当元素的范围非常大时,首先将元素分在不同的桶中,再对每个桶中的元素排序。
▶桶排序的时间复杂度取决于数据的分布,也就是需要对不同数据排序时采取不同的分桶策略。
def bucket_sort(li,n,max_num):
"""
:param li:
:param n: 桶的数量
:param max_num: 元素的最大范围
:return:
"""
buckets = [[] for _ in range(n)] #创建桶
for val in li:
i = min(val//(max_num//n),n-1) # i: 表示val要放的桶位置,防止最后一个数max_num越界,把它放到最后一个桶中
buckets[i].append(val) # 把val放到桶里面
## 保持桶内有序
for j in range(len(buckets[i])-1,0,-1): # 逆序比较,因为除了刚插进来的这个数,桶内其它数应该是有序的
if buckets[i][j] < buckets[i][j-1]:
buckets[i][j],buckets[i][j - 1] = buckets[i][j-1], buckets[i][j] # 比较交换
else:
break # 否则就是已经放到了正确的位置上了
sorted_li = []
for buc in buckets:
sorted_li.extend(buc) # 在sorted_li列表末尾一次性追加buc列表里的所有数(用新列表扩展原来的列表)
return sorted_li
## 测试 输出[3, 9, 21, 25, 29, 37, 43, 49]
li = [29,25,3,49,9,37,21,43]
print(bucket_sort(li,5,49))
10、基数排序
🐷多关键字排序
🎍桶有序;
🎍有几个关键字,就做几次进桶、出桶操作;
🎍每完成一次进桶、出桶操作,对应的关键字就排好序了
🎍先比较小的关键字
例:对32,13,94,52,17,54,93排序
(1)个位最小,作为第一个关键字,进桶
(2)依次出桶,得到32,52,13,93,94,54,17【个位数已排好序】
(3)十位数作为关键字,进桶
(4)依次出桶,得到13,17,32,52,54,93,94【十位数也已排好序】
复杂度分析:
时间复杂度:O(kn)
空间复杂度:O(k+n)
k表示数字位数
def radix_sort(li,base): # base为桶的个数
## 根据最大值确定关键字位数
max_num = max(li)
it = 0 # it 为关键字位数,初始为0
while it ** base <= max_num: # 当base=10时,9->1; 88->2;1000->4
## 创建桶
buckets = [[] for _ in range(base)]
for val in li:
digit = (val // (base ** it)) % base # 取出当前关键字位数上的数字
# 如:it = 1, 987 //(10**1)->98, 98 %10->8
buckets[digit].append(val) # 放到对应的桶里
# 分桶完成
## 把数重新写回li
li.clear()
for buc in buckets:
li.extend(buc) # 在li列表末尾一次性追加buc列表里的所有数(用新列表扩展原来的列表)
it += 1 # 有几个关键字,入桶、出桶就操作几次
## 测试 输出[13, 17, 32, 52, 54, 93, 94]
li = [32,13,94,52,17,54,93]
radix_sort(li,10)
print(li)