蓝桥杯之基础算法(Python版)-爆肝-7W字长文

文章目录

  • 前言
  • Python微操
    • List初始化
    • 自定义Node
    • 日期datetime
    • 堆和队列
  • 基本套路
    • 递归的基本思路
    • 搜索的基本思路
      • 深度搜索
      • BFS搜索
      • 格局(虚节点)
    • 分块
    • 动态规划
    • 贪心
  • 基本模板
    • 排序
      • 快速排序
      • 归并排序
    • KMP
    • 图的表示
      • 邻接矩阵
      • 邻接表
        • 结构
      • Dijkstra算法
        • 朴素版本
      • Bellman-Ford 算法
      • SPFA 算法
        • 实现
        • 判断负环
    • 小结(最短路)
    • 最小生成树
      • Prim算法
    • Kruskra算法
    • 二分图
      • 二分图性质
      • 染色法
        • 原理
        • 实现
      • 例题
      • 匈牙利算法
        • 匹配
        • 算法原理
        • 实现
      • 案例
    • 数论
      • 质数判断
      • 求约数
      • 求取区间质数
      • 埃氏筛法
      • 线性筛法
      • 分解质因数
      • 欧拉
      • 欧拉函数
        • 求取单个数
        • 线性筛法求取
      • 欧拉定理
      • 求逆元
        • 快速幂/幂取模
      • 欧几里得算法
        • 求最小公约数
        • 拓展欧几里得算法
        • 求解同余方程
      • 求解方程组
        • 高斯消元
        • 扩展欧几里得求解
        • 中国剩余定理(CRT)
      • 简单博弈论(SG函数)
    • 动态规划模型
      • 背包
        • 01 背包
        • 完全背包
        • 分组背包
          • 二进制优化
          • 贪心
      • 核心
      • 数字三角形模型
        • 没事”走两遍“
      • 切割模型
      • LIS/LCS
        • LIS(最长上升子序列)
        • LCS(最长公共子序列)
          • 最少编辑距离
      • 区间模型
        • 不变
        • 会变
  • 数据结构(区间操作)
    • 前后缀
    • 差分
      • 一维差分
      • 二维差分
    • 树状数组
      • lowbit操作
      • 数组划分
      • 操作
      • 单点修改,区间查询
      • 区间修改,点单查询
      • 区间修改,区间查询
    • 线段树
      • 特点
      • 数据存储
  • 总结

前言

蓝桥杯要到了,来点模板压压惊~
由于这个是Python版本的,因此,这边也是把Python的一些坑给说一下,免得下次踩坑。当然的话,这个自从去年,蓝桥杯出题组换了之后的话,有一说一,这个蓝桥杯的难度确实是上去了,从2022以前的题目的话,说它是暴力杯确实没什么问题,而且好像每年必考,联通量这样的模板题,填空题一个,然后大题一两个。然后是数论,说实话,去年连Python B组的题目一来就是中国剩余定理我都是没想到的。所以说,今年的难度肯定是不比前年,可能和2022差不多。像A组的题目,真的就是会的就会,不会就不会,在B组的话,也有点难度,但是可以保证能骗到分,容易想到暴力骗分,但是真的AC确实还是有难度的。然后最大的不同点就是,题目难度的设置不是线性的,难题可能出现在前面,以前2022以前是大部分都是线性的,但是2022不是,所以今年可能也不是,所有,一定要先全部审题,能做的先做

此外,虽然本篇博文的内容很多,但是并没有做深入的探索,一个是篇幅问题,还有一个是除了刷leetcode,洛谷的一些题目的时候,蓝桥杯没怎么见过复杂的,比如搜索,还要上双向BFS,还要上Astart的。当然也可能是我刷的不够,但是我必须要再声明一下:2022年以后,换出题组了!!!以前是暴力杯,现在是正常的,有一定难度的比赛,参考今年题目和难度的时候以2022年为标准,以前的就算了吧。

Python微操

ok, 我们先来说一下这个Python需要注意的细节,这个是非常重要的。比如初始化,然后一些第三方的包。
输入输出的话,这里就不说了。我们主要说两个,一个就是初始化的问题,还有一个就是这个第三方包的一些问题,有哪些坑。说实话,用这玩意刷了一个月的题目,唯一的我感受到的最大的优点在这方面就只剩下语法简洁了。

List初始化

首先第一个问题就是,我们的这个List的初始化问题。
这里的话我想说的就是,算毕竟是算法比赛,我们是知道这个数据范围的,所以的话,我们尽可能去用这个静态的这个List,也就是,我们少用这个append()方法。

那么问题就来了,如果我们选择先初始化一个比较大的List的话,那么我们要如何操作会快一点。

这里的话直接说结论:

[0] * int(1e10)

这种类型的方法是最快的,其次是:

[0 for _ in range(int(1e10))]

但是第一种方案,只适合这个不可变类型的初始化,因为本质上来说,它是内存引用的复制,但是对不可变类型来说的话,它是值的复制,所以如果你有复杂类型,比如你自己定义的对象,或者快速生成一个二维数组的话,那么只适用于第二种方案。

自定义Node

然后就是自定义Node的一些问题,这里的话如果你要自定义一个”结构体“。那么建议你这样做:

class Node:
	def __init__(self,a,b,c):
		self.a = a
		self.b = b
		self.c = c	

具体原因的话,我就不解释了,要说的东西比较多。

日期datetime

然后在Python里面比较好用的这个日期处理包,大概主要用法有这几个:

import datetime

常用的几个模块如下:

datetime: 用于处理日期和时间的模块,提供了丰富的日期和时间操作函数,例如获取当前时间、获取当前日期、创建日期时间对象、计算两个日期时间之间的距离等。

time: 用于处理时间的模块,提供了访问系统时间、计算两个时间之间的差异等功能。

date: 用于处理日期的模块,提供了获取当前日期、计算两个日期之间的差异等功能。

timedelta: 用于处理时间间隔的模块,提供了计算两个日期时间之间相差的时间间隔等功能。

用法如下:

datetime

import datetime

# 获取当前时间    
now = datetime.datetime.now()

# 获取当前日期    
today = datetime.datetime.today()

# 获取当前年份    
current_year = datetime.datetime.now().year

# 获取当前月份    
current_month = datetime.datetime.now().month

# 创建日期时间对象    
datetime_object = datetime.datetime(2023, 2, 18, 12, 00, 00)

# 设置日期时间对象    
datetime_object = datetime.datetime.now()    
datetime_object.settime(12, 00, 00)  

然后的话,这个time和date对象类似,其实datetiem是他们两个的结合体。

之后我要说的是这个timedelta对象。

class datetime.timedelta(days=0, seconds=0, microseconds=0, milliseconds=0, minutes=0, hours=0, weeks=0)

他里面有这些东西,我们可以很轻松的构架计算一个时间差。

import datetime

# 计算两个日期时间之间相差的时间间隔    
timedelta_object = datetime.timedelta(days=10)    
delta_seconds = timedelta_object.seconds    

堆和队列

之后的话是Python的heapq和collections里面的deque的问题。

首先是这个堆不支持大顶堆,所以的话,再写一些问题的时候比较难受,这个时候可以使用负号,出来的时候再加上负号处理。
例如这是对顶堆的写法:

import heapq

class MiddleFinde():

    def __init__(self):

        self.size = 0
        self.minTop = []
        self.maxTop = []

    def add(self,a):
        self.size+=1
        if(self.size==0):
            heapq.heappush(self.minTop,a)
        if(a>self.minTop[0]):
            heapq.heappush(self.minTop,a)
            if(len(self.minTop)>len(self.maxTop)+1):
                heapq.heappush(self.maxTop,-heapq.heappop(self.minTop))
        else:
            heapq.heappush(self.maxTop,a)
            if(len(self.maxTop)>len(self.minTop)):
                heapq.heappush(self.minTop,-heapq.heappop(self.maxTop))
    def getMiddle(self):

        if(self.size%2):
            return self.minTop[0]
        else:
            return (self.minTop[0]-self.maxTop[0])/2

之后的话就是这个队列,这个队列的话是双端队列,但是popleft的时间复杂度比较高,要么避免使用这个popleft,要么就自己实现一个队列,为什么:在 Python 标准库中,deque 的底层实现采用了一个双向链表。这个链表被分割成了多个块,每个块通常包含数千个元素。当我们执行 popleft() 操作时,deque 会从链表的左端删除一个元素,并将其它元素向左移动。但是如果整个 deque 块大小小于等于 512 ,那么在执行 popleft() 操作前,Python 会尝试将左右两端的块合并为一个更大的块。这个操作在某些情况下可能会导致 deque 块数量过多,从而使得 popleft() 的时间复杂度变为 O(n)。

那么我们自己实现的话可以这样做,如果我们需要追求这个性能的话(原因是我在刷leetcode的时候用deque超时了,但是用自己写的就AC了)

class Queue:
    def __init__(self, size):
        self.queue = [None] * size
        self.head = 0
        self.tail = 0
    
    def is_empty(self):
        return self.head == self.tail
    
    def is_full(self):
        return (self.tail + 1) % len(self.queue) == self.head
    
    def enqueue(self, item):
        if not self.is_full():
            self.queue[self.tail] = item
            self.tail = (self.tail + 1) % len(self.queue)
        else:
            raise ValueError("Queue is full")
    
    def dequeue(self):
        if not self.is_empty():
            item = self.queue[self.head]
            self.head = (self.head + 1) % len(self.queue)
            return item
        else:
            raise ValueError("Queue is empty")
    
    def size(self):
        return (self.tail - self.head + len(self.queue)) % len(self.queue)

基本套路

ok,我们聊了聊Python的一些问题,那么我们来说一下基本的一些思想,或者套路,这个主要是搜索的套路,然后是递归,回溯的套路,当然还有简单动态规划的套路。

递归的基本思路

我们先来聊一聊递归的基本套路,或者说是思考步骤,这个非常重要,我们必须从过程式思想完成到函数式思想的转化。因为递归可以解决很多问题,我们待会要说的一些搜索,dp啥的都是需要从这里出发。

先说点看起来不是人的话,以前其实我也不是很了解,总觉得这些话都是“屁话”后来发现,这些话说的真精辟:

  1. 边界条件:首先需要明确递归的结束条件,即边界条件。在处理递归问题时,通常我们需要判断某些特殊情况来终止递归,例如序列为空、值为零、节点为空等。因此,在编写递归函数时,需要首先考虑哪些情况需要直接返回结果。

  2. 确定问题规模:对于一个递归问题,通常大问题可以被拆分成若干个小问题。因此,在处理递归问题时,需要明确每次递归所需要处理的问题规模。这个规模需要比上一层递归规模更小,因为如果规模没有变化,则会进入无限递归,导致堆栈溢出。

  3. 寻找递归关系:递归问题的最重要的一步是确定递归关系。递归关系指的是如何将大问题拆分成小问题,并将小问题通过函数的递归调用解决。通常递归关系体现在函数的参数传递上。

  4. 处理子问题:在处理每个子问题时,需要使用相同的算法思路来求解,只是具体的输入和输出有所不同。因此,在编写递归函数时,需要将相同的算法思路应用到不同的子问题上。

  5. 合并结果:最后,我们需要将所有子问题的结果合并成一个最终的结果。在递归问题中,通常使用回溯的思想来完成结果的合并,即在递归调用结束后,将子问题的结果进行合并。

以前觉得这些话真官方,没啥用,后来发现挺好的,当初是我菜,现在也菜,但是终于能看懂大哥写的东西了。

当然在实际做题的时候呢,我们这样想:

从操作上面想:

1.问题是什么
2.假设我们现在已经处理到了当前第i步(i=0,中间任何一步,最后一步),我们先从中间任何一步出发,假设我们要转移到i+1或者i-1或者是其他满足要求的 ±j``步。我们看一下可以怎么操作。
3.然后我直接假设有一个函数,已经处理好了前面的步骤,然后你看一下,转移要怎么写,不重不漏的写出来。
4. 再思考一下,i=0或者最后的时候,确定我们的边界情况,也就是函数出口~

从规模上面想:
有些情况下呢,就比如排序,我们就算不写递归,我们都可以排序。大不了我就insert,冒泡啥的,真的是谁看不起谁。

但是呢,有些时候呢,我们可以想想,全部直接排序的时间复杂度可能比较高,我们为什么不可以先分成一块一块处理,然后合并一下解呢。没错就是归并排序,
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第1张图片
这个也是比较重要的一种分批,分治的思想
(分治和分块不太一样:

分块和分治是两种常用的算法思想,它们都可以用来解决一些类似的问题,但在实现过程中有着不同的特点和适用场景。

  1. 分块(Block Partition)

分块算法,也叫块状链表(Block List)算法,是一种将一个大数据集合拆分为若干个小数据块,然后对每个小块进行处理的算法。这些小数据块通常是均等的划分,而且可以通过索引结构进行高效的存储和查找。在分块算法中,通常会用到一些数据结构,比如桶、堆、哈希表等,来支持各种操作。分块算法的重点是如何定义每个小数据块,并针对每个小块进行有效的操作。

分块算法的应用非常广泛,例如在数据库、操作系统等领域都有着很重要的地位。例如,在计算最大值、最小值等聚合函数时,可以将数据分成多个块,然后在每个块中计算相应的函数值,最终再将所有结果合并,以减少计算量。

  1. 分治(Divide and Conquer)

分治算法是一种将问题划分为若干个简单子问题,然后对每个子问题进行递归求解,并将所有子问题的结果合并起来得到整个问题的解的算法。分治算法通常应用于可以分成若干个规模相等的子问题的场合。分治算法包括三个步骤:分、治、合,其中分为递归的划分子问题,治为递归求解每个子问题,合为将各个子问题的结果进行合并得到最终结果。

分治算法的经典应用包括排序算法(如快速排序、归并排序)、查找算法(如二分查找)、线性代数中的矩阵乘法等。这些算法都是将大问题划分为若干规模相等的子问题,然后通过组合子问题的结果得到大问题的解。

总之,分块和分治虽然都是将大问题划分为若干个小问题,但它们所划分的方式、针对的问题类型、使用的数据结构等方面都有所不同。在实际应用中需要根据具体问题的特点来选择合适的算法思想。

所以的话,关于这个问题的思考的话就是这样的:

1. 先划分
2. 计算里面的小块
3. 合并小块的值

所以的话,我们这个递归解决问题的时候呢,我们是这样的:
明确:
你是要边转移边搞
还是单纯划分,合并计算

所以从这个角度出发,我们可以引出DP和分治(分治也是有重复的操作的,比如我们归并排序,我们只是对每一个小块都进行排序,这个操作是一样的)

反正,我们在想问题的时候,我们心中时刻有一颗树

我们想的时候,就只需要想其中第i层和第i+j,或者i-j层就好了(j=1)反正,我们只考虑两层,反正不是全部。
然后注意边界就好了。

      f(4)
    /  |   |   \
f(3) f(2) f(1) f(0)
  |     | 
f(2)  f(1)
  |
f(1)

之后的话是回溯,这个回溯其实和递归的思考过程是一样的区别的话在这儿:

          A
     /    |   \
    B     C    D
  /   \     \
 E     F     G
      / \
     H   I
        /
       J

好吧这里看不出啥,其实,这个就是往回走的趋势,有恢复现成的操作,比如全排列。

def permute(nums):
    """
    :type nums: List[int]
    :rtype: List[List[int]]
    """
    def backtrack(first=0):
        if first == n:
            res.append(nums[:])
        for i in range(first, n):
            nums[first], nums[i] = nums[i], nums[first]
            backtrack(first + 1)
            nums[first], nums[i] = nums[i], nums[first]

    n = len(nums)
    res = []
    backtrack()
    return res

搜索的基本思路

深度搜索

首先我们趁热打铁,我们来一个基于递归/回溯的这个思路的深度搜索。
在思路上的话,这个和咱们刚刚说的那个递归的第一个类型是类似的,但是第一个类型的递归可以做好多事情,基本上DP都是从那个来的。

ok,不扯那么多,我深度搜索就记住一句话,从当前出发,到下一步可以怎么走,不重不漏把所有的情况都给出来,最后考虑边界就好了,其他的不用多想。

举一个最经典的求联通量的一个代码,题目我就不给了,这个都烂大街了,我也找不到了:

def dfs(graph, node, visited):
    """
    定义深度优先搜索函数
    :param graph: 无向图的邻接表表示
    :param node: 当前节点
    :param visited: 节点是否被访问过的标记
    """
    visited[node] = True
    print(node, end=' ')
    for neighbor in graph[node]:
        if not visited[neighbor]:
            dfs(graph, neighbor, visited)

def connected_components(graph):
    """
    寻找无向图中的连通分量
    :param graph: 无向图的邻接表表示
    """
    visited = {node: False for node in graph}
    # 遍历所有节点并进行 DFS
    for node in graph:
        if not visited[node]:
            print('New component:', end=' ')
            dfs(graph, node, visited)
            print()

# 无向图的邻接表表示,这样也可以,但是我们后面会给另一种更加通用的,不仅仅只是Python用
graph = {
    1: [2, 4],
    2: [1, 3],
    3: [2, 4, 5],
    4: [1, 3],
    5: [3]
}

# 寻找无向图中的连通分量
connected_components(graph)

我们仔细解释一下代码,为什么可以完成这个操作:

当计算机执行函数时,会根据函数的调用顺序在内存中维护一个函数调用栈。每当进入一个新的函数时,计算机都会在栈顶处保存该函数的返回地址和一些必要的上下文信息,以方便后续恢复执行现场。当函数返回时,计算机会弹出当前函数的栈帧,并跳转到之前保存的返回地址处,继续执行调用该函数的代码。这个过程被称为函数调用(Function
Call)和返回(Return)。

在以上求最大连通分量的 DFS 算法中,我们可以观察到该算法递归调用 dfs
函数实现深度优先搜索,利用函数的调用栈来保存每个递归调用的现场信息,在得到第一个连通分量后,程序从当前递归层次退出并返回到上一层次中。

以求解以下图的最大联通量为例:

     1 -- 2
     |    |
     3 -- 4 -- 5 ```

邻接表表示法如下:

graph = {
     1: [2, 3],
     2: [1, 4],
     3: [1, 4],
     4: [2, 3, 5],
     5: [4] } 

在代码中首先初始化 visited 数组,然后对所有未被访问过的节点进行 dfs 遍历。首先,从节点 1 开始遍历。对于节点 1,设置
visited[1] = True,并递归调用 dfs 函数进行遍历。在节点 2 上,因为 visited[2] =
False,继续递归调用 dfs(2)。在节点 3 上,同样递归调用 dfs(3)。在节点 4 上,因为 visited[4] =
False,继续递归调用 dfs(4)。在节点 5 上,因为 visited[5] = False,递归调用 dfs(5)。在节点 5
上,因为它没有未被访问的邻居节点,结束遍历并返回到节点 4。在节点 4 上,因为它的所有邻居节点已经被遍历过了,结束遍历并返回到节点
3。在节点 3 上,因为它的邻居节点 1 已经被遍历过了,结束遍历并返回到节点 1。最后,在节点 1 上,完成第一次 dfs
遍历并输出结果:“1 2 4 3 5”。程序现在已经回到了第一个递归层次中。

接着算法把 visited 数组恢复为所有元素为 False,并对所有未被遍历的节点进行 dfs
遍历。这一步是为了找出图中其他连通分量。由于节点 1 已经被访问过,程序不会从节点 1 开始遍历,在节点 2 上同样不会继续遍历,但在节点
3 上,因为它是一个未访问过的节点,程序继续递归调用 dfs(3)。在节点 4 上,程序同样会继续遍历,并返回到节点 3
上。最后,算法再次输出结果:“3 1 2 4 5”。

可以看出,该算法通过函数调用栈实现了深度优先搜索,利用 returned value 来记录每个连通分量的大小,也就是每次 dfs
函数返回的 size 值,并比较求出最大联通量。

BFS搜索

这个东西的话其实类似,而且有一个非常明显的特点,就是两段性。其实就是刚刚的想的考虑i和i+1。我们举两个例子一个是迷宫,还有一个就是树的层次遍历。然后这个i到i+1的话我们叫扩散

首先是迷宫的例子。

初始状态:

+-----------------------+
| S |       |   |     |  |
|---+---|   |---|  X  |  |
|     |   |           |  |
|     |---+-------------|
|           |         |  |
|-----------|---------|  |
|           |         | G|
+-----------------------+

队列:(S)
扩散过程:S

第 1 步(插入相邻的节点):

+-----------------------+
| S |. . . .|. . .|. . .|  |
|---+---|. .|. .-|  X  |  |
|     |. .|     |     |  |
|     |---+-------------|
|       . . |         |  |
|-----------|---------|  |
|           |         | G|
+-----------------------+

队列:(1, 2, 3)
扩散过程:S -> 1 -> 2 -> 3

第 2 步(插入相邻的节点):

+-----------------------+
| S |. . . .|. . .|. . .|  |
|---+---|. .|. .-|  X  |  |
|     |. .|. . .|. . .|  |
|     |---+-------------|
|       . . |    . .  |  |
|-----------|-. . .-.-|  |
|           |. . . . .| G|
+-----------------------+

队列:(4, 7)
扩散过程:S -> 1 -> 2 -> 3 -> 4 -> 7

第 3 步(插入相邻的节点):

+-----------------------+
| S |. . . .|. . .|. . .|  |
|---+---|. .|. .-|  X  |  |
|     |. .|. . .|. . .|  |
|     |---+-------------|
|       . . |. .  . .|  |
|-----------|-. . .-.-|  |
|           |. . . . .| G|
+-----------------------+

队列:(5, 6, 8)
扩散过程:S -> 1 -> 2 -> 3 -> 4 -> 7 -> 5 -> 6 -> 8

第 4 步(插入相邻的节点):

+-----------------------+
| S |. . . .|. . .|. . .|  |
|---+---|. .|. .-|  X  |  |
|     |. .|. . .|. . .|  |
|     |---+-------------|
|       . . |. .  . .|  |
|-----------|-. .-. .|- |
|           |. . . . .| G|
+-----------------------+

队列:(9)
扩散过程:S -> 1 -> 2 -> 3 -> 4 -> 7 -> 5 -> 6 -> 8 -> 9

现在我们已经到达了终点 G,因此可以停止搜索。从上面的扩散过程可以看出,我们使用 BFS 算法成功地找到了从起点 S 到终点 G 的最短路径,该路径如下所示:

S -> 3 -> 6 -> G
import queue

# 迷宫地图
maze = [
    ['S', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'X', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'G'],
]

# 节点类
class Node:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __hash__(self):
        return hash((self.x, self.y))

# 获取指定节点的所有相邻节点
def get_neighbors(node):
    neighbors = []
    if node.x > 0 and maze[node.x - 1][node.y] != 'X':
        neighbors.append(Node(node.x - 1, node.y))
    if node.x < len(maze) - 1 and maze[node.x + 1][node.y] != 'X':
        neighbors.append(Node(node.x + 1, node.y))
    if node.y > 0 and maze[node.x][node.y - 1] != 'X':
        neighbors.append(Node(node.x, node.y - 1))
    if node.y < len(maze[0]) - 1 and maze[node.x][node.y + 1] != 'X':
        neighbors.append(Node(node.x, node.y + 1))
    return neighbors

# 使用 BFS 算法寻找最短路径
def bfs(start, end):
    visited = set()
    q = queue.Queue()
    q.put((start, []))

    while not q.empty():
        node, path = q.get()
        
        if node == end:
            # 找到终点,返回路径
            return path + [node]

        if node not in visited:
            visited.add(node)
            for neighbor in get_neighbors(node):
                q.put((neighbor, path + [node]))

    # 没有找到合适的路径,返回空
    return []

# 测试示例
start = Node(0, 0)
end = Node(8, 9)
path = bfs(start, end)

if not path:
    print("没有找到可行的路径!")
else:
    print("找到了一条从起点到终点的最短路径:")
    for node in path:
        maze[node.x][node.y] = '.'
    for row in maze:
        print(' '.join(row))

然后是树的层次遍历。这个就可以非常好的去体现这个两段性了。
我这里不需要使用到队列都可以实现,这个其实就是i–>i+1 的扩散过程。

# 定义二叉树节点类
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 层次遍历函数
def level_order(root):
    # 创建队列
    q1 = []
    q2 = []
    # 将根节点放入队列中
    q1.append(root)
    # 遍历队列
    while len(q1) > 0:
        # 获取队列中所有节点并输出其值
        while len(q1) > 0:
            node = q1.pop(0)
            print(node.val, end=' ')
            if node.left:
                q2.append(node.left)
            if node.right:
                q2.append(node.right)
        print()
        # 交换队列
        q1, q2 = q2, q1

# 测试示例
root = TreeNode(1, TreeNode(2, TreeNode(4), TreeNode(5)), TreeNode(3, TreeNode(6), TreeNode(7)))
print("树的层次遍历结果为:")
level_order(root)

格局(虚节点)

ok,现在我们再提示一下,我们的格局,那就是你的节点何必只是节点呢,上面意思,我们为什么不可以把一些状态看成一个节点呢。从一个节点到另一个节点的扩散过程。比如有字符串S–》SB的一个过程,我们完全可以把S–》S1—》S2----》SB的一个过程看作扩散的过程嘛。然后这些东西还能变成图,然后走spfa啥的。
(这里我就不细说了,我很难展开,算法进阶指南可以参考一下)

那么这里我要说的就是另一个重要的点:

如果对于给定的搜索问题,BFS搜索算法可以保证在搜索到结果时是最优的,那么我们可以说BFS首先搜索到的第一个结果就是最优路径。

在使用 BFS 进行搜索时,我们可以保证当前节点到起点的距离是单调不减的。也就是说,如果我们首次访问目标节点时,它已经被加入到 BFS
的队列中,那么这个目标节点的深度一定是最小的,因为 BFS 在搜索过程中是按照节点距离起点的距离逐层向外进行的,先搜索到的节点距离起点更近。

另外,如果问题满足以下两个条件,我们可以使用 BFS 算法来搜索最优解:

  1. 在搜索问题中,我们要找到所有可行解中的全局最优解,而非局部最优解;
  2. 所有边的代价都相等。

对于这种情况下,BFS 算法能够搜索到的第一个结果就是最优路径,因为 BFS
搜索过程中是按照从起点到当前节点的距离逐层向外进行的,当搜索到终点时,终点所在的层数就是最短路径的长度。

为什么我们可以这样证明:

当所有边的代价都相等时,BFS 可以搜索得到最优解的原因是 BFS 算法的特殊性质:当 BFS
遍历到一个节点时,该节点到起点的距离是当前访问到的深度(即该节点所在层数)。

假设存在另一条路径,它比 BFS 搜索到的路径更短。则在 BFS 进行搜索时,这条路径上的节点必须先于 BFS
路径上的节点被访问到。但是,由于所有边的代价都相等,这意味着这条路径上的节点与 BFS
路径上的节点处于同一层,且它们之间的距离相等。因此,在 BFS 继续进行搜索时,这条路径上的节点会被同时考虑,而不会有任何节点被漏掉。由于
BFS 是按层遍历的,所以只要有一条路径能够到达目标节点,BFS 就能够找到这条路径,且这条路径一定是最短路径。

另一方面,如果边的代价不相等,则 BFS
不再具有此特殊性质,即节点到起点的距离不再等于其所在层数。例如,如果某一段边的代价很大,那么沿着这条边往下走的代价就超过了其他路径的代价,而
BFS 并不能立即发现这一点,因为它每次只考虑当前层的节点。这种情况下,BFS 可能会搜索到非最优解。

因此,当所有边的代价都相等时,BFS 能够保证找到最短路径;而如果边的代价不相等,则不能保证 BFS 能够找到最优解。

严谨一点就是:

设存在两个节点 A A A B B B d ( A ) d(A) d(A) 表示节点 A A A 到起点的距离(也就是 BFS 遍历到节点 A A A
时所在的层数), d ( B ) d(B) d(B) 表示节点 B B B 到起点的距离。若 d ( A ) < d ( B ) d(A)d(A)<d(B) 且 BFS 搜到节点 B B B 的路径长度比 BFS
搜到节点 A A A 的路径长度更短,则可以得到如下结论:

d ( A ) + c o s t ( A , B ) < d ( B ) d(A) + cost(A, B) < d(B) d(A)+cost(A,B)<d(B)

其中, c o s t ( A , B ) cost(A, B) cost(A,B) 表示边 ( A , B ) (A, B) (A,B) 的代价。

由于所有边的代价都相等,即 c o s t ( A , B ) cost(A, B) cost(A,B) 相等,因此上述不等式可以进一步化简为:

d ( A ) < d ( B ) d(A) < d(B) d(A)<d(B)

这与假设矛盾,因此假设不成立,即 BFS 搜到节点 B B B 的路径长度不可能比 BFS 搜到节点 A A A
的路径长度更短。换言之,当所有边的代价都相等时,BFS 搜到的第一个目标节点必定是最优解。

此证明了当所有边的代价都相等时,BFS 能够搜索得到最优解的原因。

分块

这个的话,也是高级操作了,至少对我来说是这样的。然后的话:

分块(block)是一种经典的算法设计技巧,其基本思想是将问题分解成若干个子问题,然后对每个子问题使用不同的算法或数据结构进行处理。常见的分块技巧有以下几种基本套路:

  1. 均分法:将问题划分成若干等长的块,然后对每个块使用同样的算法或数据结构进行处理,再将处理结果合并。

  2. 物理意义法:将问题看作一个物理系统,每个块都表示一个小粒子,然后找出相互作用最强的小粒子,并将它们合并成一个更大的块,然后重复执行这个过程,直到只剩下一个块为止。

  3. 平衡二叉树法:将元素按照某种方式分成若干块,然后对每个块维护一个平衡二叉树,利用平衡二叉树的性质进行查询和修改操作。这种方法常用于数据结构优化中。

  4. 离线查询法:将所有查询操作预处理出来,并按照某种方式分成若干块,再对每个块内的查询操作进行排序,最后按块顺序执行所有查询操作。
    它能够将复杂度从
    O ( n 2 ) O(n^2) O(n2) 优化到 O ( n n ) O(n\sqrt{n}) O(nn ) 或者更低,是算法设计中非常有价值的技巧之一。

当然这方面还有更复杂的东西,说不下去这个。

然后的话,这个我们可以规定一下大致的操作:

from typing import List

# 分块块大小
BLOCK_SIZE = 500


# 预处理(根据实际问题进行修改)
def pre_processing(nums: List[int]) -> None:
    pass


# 区间查询(根据实际问题进行修改)
def query(left: int, right: int) -> int:
    pass


# 区间修改(根据实际问题进行修改)
def modify(left: int, right: int) -> None:
    pass


# 分块操作
def block_operation(nums: List[int], queries: List[List[int]]) -> List[int]:
    # 预处理
    pre_processing(nums)

    # 将所有查询按照所在块编号排序
    queries = sorted(queries, key=lambda x: (x[0] // BLOCK_SIZE, x[1]))

    # 初始化结果数组
    res = [0] * len(queries)

    # 定义左右指针、计数器、当前块编号和块内元素个数
    l, r, cnt, blk_num, blk_cnt = 0, -1, 0, -1, 0

    # 处理所有查询
    for i, (ql, qr) in enumerate(queries):
        # 移动左指针
        while l > ql:
            l -= 1
            cnt += 1
            modify(nums[l], 1)

        # 移动右指针
        while r < qr:
            r += 1
            cnt += 1
            modify(nums[r], 1)

        # 移动左指针
        while l < ql:
            cnt -= 1
            modify(nums[l], -1)
            l += 1

        # 移动右指针
        while r > qr:
            cnt -= 1
            modify(nums[r], -1)
            r -= 1

        # 计算当前块的结果
        if blk_num != ql // BLOCK_SIZE:
            blk_num = ql // BLOCK_SIZE
            blk_cnt = 0
            # TODO: 初始化块内状态
        while blk_cnt < cnt:
            # TODO: 处理块内状态
            blk_cnt += 1
        while blk_cnt > cnt:
            # TODO: 恢复块内状态
            blk_cnt -= 1

        # 记录结果
        res[i] = query(left=ql, right=qr)
    return res

在模板代码中,分块的具体实现分为两部分:预处理和块操作。

预处理阶段根据实际问题进行调整,例如读入数据或初始化某些变量等。

块操作阶段是分块算法的核心部分,包括对于区间查询和修改的具体实现。其中,按照块编号排序的查询列表 queries 可以使用 Python 内置函数 sorted() 进行排序,排序依据是首先按照所在块编号升序排列,其次按照右端点升序排列。同时,我们还需要定义左右指针、计数器、当前块编号和块内元素个数等变量,用于块操作过程中动态调整。

在块操作过程中,我们需要实现四个移动指针的循环,以达到移动左右指针的目的。同时,我们还需要计算当前块的结果,这一部分需要根据实际问题进行实现。最后,我们需要记录每个查询操作的结果,并将结果返回。

当然,具体实现还需要根据实际问题进行修改。

动态规划

> 动态规划是一种利用历史状态来推导当前解的算法,它适用于那些具有重叠子问题和最优子结构性质的问题。动态规划通常包括以下步骤:

  1. 确定状态:将原问题转化为离散的子问题,并定义状态表示原问题或子问题的解。

  2. 确定状态转移方程:根据子问题之间的关系,写出状态转移方程。

  3. 初始条件与边界情况:确定初始状态或边界条件。

  4. 计算顺序:确定计算状态的顺序,一般采用递推方法,从小规模问题一直推导到原问题规模。

> 从递归的角度来看,动态规划的基本套路可以总结为以下几点:

  1. 子问题:将原问题划分成若干个子问题,这些子问题之间应该满足无后效性,即某个子问题的解只与该子问题的状态有关,而与其它的状态和子问题无关。

  2. 状态:定义状态表示原问题或子问题的解。状态的选取应该满足包含所有可能的合法解,而且状态必须具备可以递推的性质。

  3. 状态转移方程:根据子问题之间的关系,写出状态转移方程。状态转移方程是描述子问题之间的递推关系的数学公式。

  4. 初始条件与边界情况:确定初始状态或边界条件。在进行递推计算之前,需要先分析边界情况,为递推提供基础。

  5. 计算顺序:确定计算状态的顺序,一般采用递推方法,从小规模问题一直推导到原问题规模。在实际应用中,我们通常使用动态规划表或滚动数组来存储状态,便于递推计算。

其实也是结合我们提到的这个递归,这个状态转移其实和我们刚刚说的 从 i —> i+1 这个状态没有任何区别。而且由于存在重复计算,所有如果我们可以把原来计算过的结果存起来,那么不得了,我们就可以减少很多运算了,这个就是 递归—》 记忆搜索—》递推(也就是经常见到的dp)

为什么,原因是啥?

从集合的角度出发,动态规划可以看作是对于一个问题的所有可能状态的求解,并通过状态之间的转移完成最终状态的求解。 具体来说,若原问题可以表示为 P ( x ) P(x) P(x),其中 x x x 为问题的输入变量, S S S x x x 的取值范围, f ( x ) f(x) f(x)
表示原问题的最优解,则可以将问题分解为若干个子问题,每个子问题对应一个状态,状态集合为 V V V,从而将原问题转化为对于状态集合 V V V
的求解问题。在这个状态集合中,每个状态对应一个子问题的解,即对应于 P ( x ) P(x) P(x) 在某些前提条件下的解,称之为子状态。
在这个状态集合中,存在一些特殊状态,它们是我们所关注的最优解状态,称之为目标状态。以最优解为例,我们可以定义一个目标集合
T ⊆ V T\subseteq V TV,所有目标状态的解都构成了原问题的最优解。 接下来,我们需要考虑如何求解目标集合
T T T。首先,我们需要明确目标状态之间的关系,这个关系通常被称为状态转移方程,即如何从一个目标状态转移到另一个目标状态。其次,我们需要确定初始状态集合,即什么时候开始求解,即根据问题不同,可以将源状态集定义为某些特定的状态,也可以将源状态集定义为所有满足一定条件的状态集合。最后,我们需要寻找一种优化的方式来计算出目标集合
T T T
中的所有状态对应的最优解,这可以通过某种状态计算顺序来完成,通常采用递推或记忆化搜索等方法,根据一个状态的前驱状态的解来计算该状态的解。
总之,动态规划就是利用子问题间的重叠关系,将问题分解成若干个子问题,每个子问题对应一个状态及其解,然后使用递推或记忆化搜索的方式,求解目标状态集合
T T T 中的所有状态的最优解,从而得到原问题的最优解。

ok,既然如此,那么和我们提到的回溯又有啥区别呢?其实区别的话,回溯是一种模拟,一种对过程的模拟,dp是一种对结果的依赖求取。

我们用01背包举例子。

import functools

def knapsack(W, wt, val):
    n = len(wt)

    @functools.lru_cache(maxsize=None)
    def dfs(i, w):
        if i < 0:
            return 0
        if wt[i - 1] > w:
            return dfs(i - 1, w)
        else:
            return max(dfs(i - 1, w - wt[i - 1]) + val[i - 1], dfs(i - 1, w))

    return dfs(n, W)

我们使用 Python 的内置函数 functools.lru_cache 来实现记忆搜索。它会帮助我们自动缓存结果,避免重复计算。
我们定义了背包容量 W、物品重量列表 wt 和物品价值列表 val。然后,我们定义了一个内部递归函数 dfs,它接受当前物品编号 i 和当前背包容量 w 作为参数。首先,我们判断当前背包容量是否为零,如果为零,则无论选取哪些物品,最终的价值都为零。如果当前物品编号为零,则无法再选取物品。接着,我们从缓存中查找子问题的解,如果有缓存结果,则直接返回。如果缓存中没有结果,则判断当前背包容量是否能够装下物品 i。如果不能装下,则必须继续递归到下一个物品,否则,我们需要选择或不选择物品 i,然后分别递归搜索下一个物品。最后,我们返回函数调用 dfs(n, W) 的结果,即在前 n 个物品中选取一些物品放入容量为 W 的背包中,使得它们的价值最大。

这边我们就是假设我们有一个函数 dfs它可以求的直接就是值。

那么对比回溯的话,我们模拟就是,当前的这个东西拿不拿,拿了之后咋办是吧。那么这个就是模拟思路,当然模拟思路转化为“依赖”思路。

回溯算法实现的 01 背包问题的 Python 代码:

def knapsack(W, wt, val):
    n = len(wt)
    res = 0

    def backtrack(i, w, v):
        nonlocal res
        if i == n or w == 0:
            res = max(res, v)
            return
        if w >= wt[i]:
            backtrack(i + 1, w - wt[i], v + val[i])
        backtrack(i + 1, w, v)

    backtrack(0, W, 0)
    return res

贪心

贪心算法是一种常用的算法思想,在许多实际问题中都可以得到有效的应用

基本步骤(先背个书先):

1. 确定贪心策略:贪心策略是指在每一步中,选择能够带来最大收益的选项(或者最小收益,具体情况而定)。通常需要根据问题特点设计合理的贪心策略。
2. 证明贪心策略的正确性:贪心策略的正确性通常需要使用数学证明。一般来说,需要证明贪心策略所得到的解是问题的一个子集,并且这个子集满足问题的某些性质。如果能够证明贪心策略的正确性,那么贪心算法的正确性也就得到了保障。
3. 排序优化:贪心算法通常需要对数据进行排序以便更好地实现贪心策略,例如选择带来最大收益的选项、删除不能选的选项等。因此,排序优化也是贪心算法的一个重要环节。根据问题特点选择合适的排序算法能够提高算法的效率。
4. 防止局部最优:贪心算法可能会出现选择局部最优解的情况,而这并不一定是全局最优解。为了避免这种情况,通常需要添加一些限制条件或者特判处理。
5. 细节问题:贪心算法在实现时需要注意一些细节问题,例如处理特殊情况、避免越界等。

那么这里的话,其实贪心和这个DP有点像。

从集合的角度来看,贪心算法和动态规划算法都是从一个集合的子集中寻找最优解。两者的主要区别在于子集的构造方式不同。

  • 贪心算法:从未选取的元素集合中每次选取一个或一些元素加入到当前子集中,然后调整子集使其满足问题的限制条件。贪心算法是具有贪心选择性质的,即每次选择对当前最优化有贡献的元素。
  • 动态规划算法:将原问题分解为若干个子问题,逐个求解子问题并存储子问题的结果,从而得到原问题的解。动态规划算法需要使用记忆搜索或自底向上的方式求解子问题。

从马尔可夫性的角度来看,贪心算法和动态规划算法在计算过程中是否具有马尔可夫性质也是一个主要的区别:

  • 贪心算法通常不具有马尔可夫性质,因为在进行贪心选择的过程中,前面的选择可能会影响后面的选择,导致最终的结果不是全局最优解。例如,在某些情况下,如果先选择了一个非常优秀的解,那么后面就不可能再选择更好的解了。
  • 动态规划算法则通常具有马尔可夫性质,因为它是通过子问题的最优解来推导出更大规模问题的最优解的。即,每个子问题的最优解都只与其更小规模的子问题的最优解相关,与其它的状态无关。

这里给出一个规范

def greedy_algorithm():
    # 1. 初始化问题的解和当前状态
    solution = []
    state = ...

    # 2. 循环进行贪心选择
    while not is_termination(state):
        # 3. 贪心选择
        choice = make_greedy_choice(state)

        # 4. 更新问题的解和当前状态
        solution.append(choice)
        state = update_state(state, choice)

    # 5. 返回最终的解
    return solution
  1. 初始化问题的解和当前状态。根据具体问题的特点和要求,初始化问题的解和当前状态,并作为贪心算法的基础。
  2. 循环进行贪心选择。在每次迭代中,检查当前状态是否满足结束条件;如果不满足,则进行贪心选择。
  3. 贪心选择。根据问题的限制条件和贪心策略,从可选的元素集合中选择能够带来最大(或最小)收益的元素,并将其加入到当前解中。
  4. 更新问题的解和当前状态。将贪心选择所得到的元素加入到问题的解中,并更新当前状态以便进行下一轮迭代。
  5. 返回最终的解。当结束条件满足时,返回贪心算法得到的最终解。

需要注意的是,在实际应用中,贪心算法需要根据具体问题的特点和要求设计合适的贪心策略,以保证算法的正确性和有效性。此外,在进行贪心选择时需要注意避免局部最优解的问题,并考虑排序优化、限制条件等因素。

当然这些都是简单的最基本的,复杂的完全的,这一两篇博文是说不清楚的,我又不是某些标题党。

基本模板

ok,说完了一些这个基本的一些套路啥的,那么我们终于可以来聊一聊这个基本模板了。这里的话内容也是挺多的,抓好了,发车了。

排序

排序的话,我这里就给出两个。

快速排序

快速排序(Quick Sort)是一种高效的排序算法,其基本思路可以简单概括为“分治”和“递归”。具体来说,快速排序的基本流程如下:

  1. 从数列中挑出一个元素,作为基准(pivot)。
  2. 重新排列数列,将比基准元素小的元素放在基准的左边,比基准元素大的元素放在右边。此时,基准元素所处的位置即为最终排序结果中它所应该处于的位置。
  3. 对左右两个子序列分别进行上述步骤的递归操作,直到每个子序列只剩下一个元素为止(递归终止条件)。

在实际实现中,快速排序的核心在于找到合适的基准元素并对数列进行划分。常用的方法有三种:

  1. 取数列的第一个元素为基准元素(Hoare 分区)。
  2. 随机选取数列中的一个元素为基准元素(随机化快排)。
  3. 取数列的中位数作为基准元素(三数取中法)。

一般来说,第一种方法最为常用,因为它实现简单,但也存在一些性能问题。为了解决这些问题,可以采用后两种方法来提高算法的效率。

我们这边就给出第一种了。


def quick_sort(nums,l,r):

    if(l>=r):
        return
    i,j = l-1,r+1
    key = nums[l]

    while(i<j):

        i+=1
        j-=1
        while(nums[i]<key):
            i+=1
        while(nums[j]>key):
            j-=1
        if(i<j):
            temp = nums[i]
            nums[i] = nums[j]
            nums[j] = temp

    quick_sort(nums,l,j)
    quick_sort(nums,j+1,r)

    
if __name__=="__main__":

    nums = [4,1,25,4]
    quick_sort(nums,0,len(nums)-1)
    print(nums)

如果我们需要找第K小的数的话就这样:


def quick_sort(nums, l, r, k):
    if l >= r:
        return

    i, j = l - 1, r + 1
    key = nums[l]

    while i < j:
        i += 1
        while nums[i] < key: # 修改为寻找小于 key 的值
            i += 1
        j -= 1
        while nums[j] > key: # 修改为寻找大于 key 的值
            j -= 1

        if i < j:
            nums[i], nums[j] = nums[j], nums[i]

    if j >= k - 1:
        return quick_sort(nums, l, j, k)
    else:
        return quick_sort(nums, j + 1, r, k)

if __name__ == "__main__":
    nums = [4, 1, 25, 4]
    k = 2  # 要求找出第k小的数(本例中即为第二小)
    quick_sort(nums, 0, len(nums) - 1, k)
    print(nums[k-1])

之后是找出第K小的数,这个我们也可以用堆

import heapq

def find_kth_smallest(nums, k):
    if k > len(nums):
        return None
    heap = [] # 创建一个空堆
    for i in range(k): # 加入前k个元素
        heapq.heappush(heap, -nums[i])
    for i in range(k, len(nums)): # 迭代剩余元素
        if nums[i] < -heap[0]: # 如果当前元素比堆顶元素小
            heapq.heappop(heap) # 弹出堆顶元素
            heapq.heappush(heap, -nums[i]) # 将当前元素加入堆中
    return -heap[0]

if __name__ == "__main__":
    nums = [4, 1, 25, 4]
    k = 1 # 要求找出第k小的数
    res = find_kth_smallest(nums, k)
    print(res)

找第K大的话,快排的改起来有点麻烦,我们直接使用这个堆的,这个直接换成小顶堆就好了。

归并排序

蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第2张图片

"""
这是一个归并排序的模板
同时在排序的过程当中,我们会求取逆序对,不需要删掉即可

"""

# 这里记录的是哪个数字的

result = 0
maps ={}

def merge_sort(nums,temp,l,r,maps):

    global result
    
    if(l>=r):
        return
    mid = (l+r)//2
    merge_sort(nums,temp,l,mid,maps)
    merge_sort(nums,temp,mid+1,r,maps)

    i,j = l,mid+1
    k = l
    while(i<=mid and j<=r):

        if(nums[i]<=nums[j]):

           temp[k] = nums[i]
           i+=1
           
        else:
           result += mid - i +1
           t = str(nums[j])+"-"+str(j)
           maps[t] = mid+1-i
           temp[k] = nums[j]
           j+=1
       
        k+=1

    while(i<=mid):
           temp[k] = nums[i]
           i+=1
           k+=1
    while(j<=r):
           temp[k]=nums[j]
           j+=1
           k+=1
    i = l  
    while(i<=r):
        nums[i] = temp[i]
        i+=1
          

if __name__=="__main__":
    nums = [4,2,3,5,7]
    temp = [0]*(len(nums))
    merge_sort(nums,temp,0,len(nums)-1,maps)
    print(nums)
    print(maps)

KMP

之后是字符串匹配的这个算法


def getNe(s):

    m = len(s)
    ne = [0]*(m+10)

    i = 1
    j = 0
    while(i<m):

        while(j>0 and s[i]!=s[j]):
            j = ne[j-1]
        if(s[i]==s[j]):
            j+=1
        ne[i] = j

        i+=1
    return ne


def KMP(s1,s2):
    """
    s1是母串
    s2是子串
    """

    m_s1 = len(s1)
    m_s2 = len(s2)
    ne = getNe(s2)
    
    i = 0
    j = 0

    while(i<m_s1):

        while(j>0 and s1[i]!=s2[j]):
            j = ne[j-1]
        if(s1[i]==s2[j]):
            j+=1
        if(j==m_s2):
            return i - m_s2 + 1
        i+=1

    return -1

if __name__ == "__main__":

    s1 = "abcbbb"
    s2 = "bb"
    print(KMP(s1,s2))
    

这个的话,其实挺简单的,搞清楚它的一个核心思想其实就很简单了,我们先不管什么前缀,后缀,什么乱七八糟的概念。从简,我就就直接看到最朴素的做法。然后从朴素的做法里面去找到它的一个思想,就是怎么偷懒。
在这里插入图片描述

这样的话,我们不就偷到一点懒了嘛。
这个不就是大部分男人滴最爱“KMP”嘛(像博主一样及其关注了博主的帅小伙除外)

那么搞清楚了,我们要想办法用这种方式来偷懒,那么我们就可以很好滴搞清楚这个KMP算法了。

OK,那么我们要做的呢就是说如何去想办法知道,最长的玩意,那么我们这边有一个数组叫做next数组,在kmp算法里面。

那么这个数组是什么玩意呢,他其实是这样的:

next[i]=j

这个是什么意思呢,就是说这样

Str[1~j]=Str[i-j+1,i]

假设从开始,第i个字符前的j个字符连起来和字符一开始到第j个字符是一样的。

这个就是所谓next数组,那么这个时候的话,有两个点第一个点就是怎么用这个next,然后就是如何求取next。
首先是怎么用。用的话非常简单,其实就是说,对比被,假设P是我们的被匹配的字符串,S是我们用来匹配的字符串(就是图里面的蓝色的线)。当在P当中的第i个字符和我们匹配字符串的第j个字符不相等的时候,那么前面的j-1字符是和前面的i-1都是相等的对吧。假设现在next[j-1]=3,那么意味着前面三个字符和头部的三个字符都是相等的对吧,再假设下标是从0开始的,那么这个时候,我们P字符串的第i个元素,是不是只需要从S字符串的下标为3的那个字符再开始比较就好了。因为第i-1,i-2,i-3和我们的S的0,1,2(下标)的字符是相等的。

之后的话是我们的图算法,其实图的话,也是我肤浅了,树的话也是我肤了,图这玩意儿还是得抽象,树这玩意是优化很顶,图这玩意用来做搜索无敌,例如KMP算法中的next数组可以看作一个状态转移图,对于每个前缀P[0:i],都对应着一个状态,其最大border长度next[i]就是该状态的值。因此,这个状态转移图从初始状态(即空串)开始,依次转移到后继状态,直到遍历了整个模式串P。在匹配字符串T和模式串P时,我们可以利用这个状态转移图,通过状态之间的转移关系,快速地确定下一步的操作,从而避免了对已经匹配过的前缀进行不必要的比较。

也就是说,我们可以把图的节点看成"虚点",因为图不就是 点 和 边嘛。

例如这里拓展一下这个:

图神经网络将图中的节点和边都看作一个可学习的向量或张量,并通过对这些向量或张量进行变换和聚合,从而完成对整个图的特征提取和预测任务。在图神经网络中,每个节点通常被表示为一个向量或张量,这个向量或张量可以包含节点自身的属性特征以及与之相邻节点的关系等信息。通过多个层次的特征变换和聚合,每个节点的特征张量会不断更新和改变,从而最终表示出整个图的特征。
在图神经网络中,节点信息可以从多个方面进行建模,例如节点的属性特征、拓扑结构、图形结构等等。这些信息被编码成不同的节点特征向量,并在图神经网络的不同层次中逐渐融合和整合。例如,在第一层中,节点特征向量可能只考虑节点自己的属性特征;在后续的层次中,节点特征向量也会考虑它与其它节点之间的关系和交互。当然除了节点特征,图神经网络还可以考虑边的特征,从而更好地利用节点之间的关系来推理和处理图结构。例如,一个边可以被表示为两个相邻节点的特征向量的连接,从而更好地表达它们之间的关系和交互。

也就是:

设一个有向图 G = ( V , E ) G=(V,E) G=(V,E),其中 V V V 表示节点集合, E E E 表示边集合。每个节点 v i ∈ V v_i \in V viV 都有一个对应的 d d d 维特征向量 h i ( 0 ) h_i^{(0)} hi(0),表示节点 v i v_i vi 的初始特征。

图神经网络由 L L L 层组成,每一层都包含了节点和边的信息传递、特征提取和表示的过程。在第 l l l 层中,每个节点和边都被赋予了一个 d l d_l dl 维的特征向量 h i ( l ) h_i^{(l)} hi(l) e i j ( l ) e_{ij}^{(l)} eij(l),分别表示节点和边在该层的特征表示。具体来说,节点特征向量可以通过它当前层的邻居节点特征向量进行聚合:

h i ( l ) = σ ( ∑ j ∈ N i W ( l ) h j ( l − 1 ) + b ( l ) ) h_i^{(l)} = \sigma\left(\sum_{j\in N_i} W^{(l)}h_j^{(l-1)} + b^{(l)}\right) hi(l)=σ jNiW(l)hj(l1)+b(l)

其中 σ \sigma σ 是激活函数, W ( l ) W^{(l)} W(l) b ( l ) b^{(l)} b(l) 分别表示该层的权重和偏置。边特征向量可以通过连接两个相邻的节点特征向量进行计算:

e i j ( l ) = g ( h i ( l − 1 ) , h j ( l − 1 ) , a i j ) e_{ij}^{(l)} = g\left(h_i^{(l-1)}, h_j^{(l-1)}, a_{ij}\right) eij(l)=g(hi(l1),hj(l1),aij)

其中 a i j a_{ij} aij 表示从节点 v i v_i vi v j v_j vj 的边的权重, g g g 是一个可学习的函数。通过多层次的特征变换和聚合,每个节点的特征向量会不断更新和改变,从而最终表示出整个图的特征:

h v = pooling ( { h i ( L ) ∣ i ∈ V } ) h_v = \text{pooling}\left(\{ h_i^{(L)} | i\in V \}\right) hv=pooling({hi(L)iV})

其中 pooling \text{pooling} pooling 是一个池化操作,常用的有求和、求平均、最大池化等。

当然扯远了。

图的表示

邻接矩阵

关于咱们这个图的话,我们有两种方案进行存储,一个就是非常传统的方案,就是这个使用邻接矩阵,这个的话非常简单,没什么好说的。然后存有相无相图,都是一样的。
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第3张图片

邻接表

之后的是我们第二种方式进行存储,也就是使用到咱们的这个邻接表。当然使用这个邻接表的话也是有非常多的一种方案。
但是总体的意思呢就是这样的:
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第4张图片
那么在这里的话,那么对于这个的话,我们也是有一个不错的,也在用的一个模板,只要维护三个数组就好了。当然你选择别的模板,如果是Python,Java,C++ 之类的,你直接用一个大数组,然后数组里面的每一个元素是这个表示边节点的一个数组也是可以的。按照图中的也可以,我们待会儿的模板是按照这个来的。

结构

okey,那么我们这边提供的模板,其实就是先前提到的用数组模拟链表,现在再用这个链表来串成一个图,仅此而已。那么好处的话,就是修改一些边方便,因为模拟的链表它具备,查询和修改的优势嘛。

int N = 100000;
int idx;
int e[N]; 
int w[N];
int ne[N]; 
int h[N] 初始化的值为-1

h[]: 表示的是,边的头部,也就是 a-->b  a表示的就是以a节点
开头的最新的一条边是在我们这边存储的第条边

ne[]  表示的是存储的上一条边是啥,通过顺藤摸瓜的方式我们可以拿到所有对应的
边,然后进行计算

e[] 表示以边对应的另一个节点的名称,这是非常重要的对于寻找相连接的边的时候


w[] 表示的是,当前第idx条边,我们所存储的权值是是啥,运势 如果,我们可以这样遍历
整个图。

然后的话:

visited = [False] * n
def dfs(a):

    i = h[a]

    while(i!=-1):

        j = e[i]

        if(not visited[j]):

            print(j)
            visited[j] = True
            dfs(j)
        
        i = ne[i]

"""
接下来就是调用这个程序了

visited[1] = True
print(1)
dfs(1)
假设我们的节点的编号是从1开始的


下面是BFS的模板
"""

def bfs(a):

    from collections import deque

    q = deque()

    q.append(1)
    visited[1] = True
    print(1)
    while(len(q)):

        t = q.popleft()

        i = h[a]
        while(i!=-1):

            j = e[i]
            if(not visited[j]):
                visited[j] = True
                print(j)
                q.append(j)

            i=ne[i]

Dijkstra算法

okey,我们先来简单朴素一点的算法。就是这个算法,算法原理其实是和咱们提到的多源最短路径的原理是类似的,只是说现在更新的只有一个点A。那么同样的我们假设我们使用的是邻接矩阵进行存储的一个东西,此时我们定义这几个东西:

int N = 10000;
bool st[N];
int dist[N];
int g[N][N];

dist 这个玩存储的意思是,1号点到其他点的最短距离。如果你要算的是2号点,那么你就初始化的时候先初始化2号点。
st[i] 表示1号点到i号点的最短距离已经确定了

朴素版本

那么这个的话,其实就是这个算法的核心,那么接下来就是编写代码了。


def Dijkstra(g):

  
    dist = [float("inf")] * (n+1)
    dist[1] = 0
    used = [False] * (n+1)


    for _ in range(n-1):

        t = -1

        for j in range(1,n+1):

            if(not st[j] and (t==-1 or dist[t]>dist[j])):

                t = j

        for j in range(n+1):

            dist[j] = min(dist[j],dist[t]+g[t][j])

        used[t] = True
        
    if(dist[n]==float("inf")):
        #这个时候说明无法联通
	return False

Bellman-Ford 算法

接下来就是这个,刚刚我们提到的Dijkstra算法呢,针对的是正权边,但是在针对负的权边的时候,就用不了了,那么这个是时候的话,这个算法就出来了,而且这个算法的实现其实非常简单,同时它的时间复杂度在O(mn),并且它是有特殊含义的,在针对带有约束问题的时候,这个算法可能是唯一的解。

它的原理话非常直接,就是直接遍历全部的边,然后找到最短的边就好了。

"""
这里和我们先前的算法不太一样的是,我们这次会用到自定义的一个结构
"""


class Node:

    def __init__(self,a=0,b=0,w=0):

        self.a = a
        self.b = b
        self.w = w


def BellManFloyd():

    Nodes = [Node() for _ in range(m)]
    dist = [float("inf")] * (n+1)
    dist[1] = 0
    for i in range(n):

        for j in range(m):

            node = Nodes[j]

            dist[b] = min(dist[b],dist[a]+node.w)

    


用这个算法的话,我们可以直接使用结构体来进行存储,同时这个算法第一个for循环的含义是,最多经过n个点,它的dist可以得到的最短距离。同时用这个算法可以用来判断是否存在负权回路。判断原理就是多循环一次,如果多循环一次之后dist[n]的值有变小,说明存在。
原因的话因为这个for循环的含义,他表示的是最多经过n个点进行中转之后的最短距离,如果存在负的回路的话,再走一次中转,那么肯定会变小。

SPFA 算法

OK,那么接下来的话,就到了咱们基本上可以通杀的算法了,每次就是这个算法,这个算法的话基本上都能用,如果有限制,中转的点不能超过K个,那么没办法就用Bellman,但是如果没有限制,那么的话可以直接先考虑这个算法,如果这个算法过不了,那么有可能是因为他是个稠密图,并且n~m^2 去了,那么这个时候那就只能走朴素版本的Djikstra算法了。因为它的时间复杂度是O(m) 最糟糕是 nm.

那么之后的话是这个算法的实现,这个算法的话其实和堆优化的算法有点相似,但原理的话是在Bellman的基础上进行改进,用队列进行优化,因为有些点存在重复计算的问题。

实现


def spfa():

    from collections import deque

    dist = [float("inf")] * (n+1)

    inQueue = [Flase] * (n+1)
    
    dist[1] = 0
    inQueue[1] = True
    q.append(1)

    while(len(q)):

        t = q.popleft()
        inQueue[t] = False
        i = h[t]
        while(i!=-1):

            j = e[i]

            if(dist[j]>dist[t]+w[i]):

                dist[j] = dist[t]+w[i]
                if(not inQueue[j]):
                    q.append(j)
                    inQueue[j] = True

            i = ne[i]
    # 这个自己取一个比较大的数
    if(dist[n]>=100000):
        return False


判断负环

之后的话,我们这个spfa,算法还有一个非常光荣的任务,就是判断当前的这个图当中是不是存在负的环(当然题目当中如果可以直接使用到这些算法当然是不存在的,但是有些题目或者需求,可能就是需要你去判断一下图当中有没有负的环,那么的话就是需要使用到这个算法了)

这里的话也是使用到了抽屉原理进行一个判断,但是无所谓,我们只要把这个代码稍微改动一下就好了。



def spfa2():

    #判断负环

    from collections import deque

    dist = [float("inf")] * (n+1)

    cnt = [0] * (n+1)

    inQueue = [Flase] * (n+1)
    inQueue[1] = True
    q.append(1)

    for i in range(1,n+1):

        q.append(i)
        inQueue[i] = True


    while(len(q)):

        t = q.popleft()
        inQueue[t] = False
        i = h[t]
        while(i!=-1):

            j = e[i]

            if(dist[j]>dist[t]+w[i]):
                cnt[j] = cnt[i]+1
                dist[j] = dist[t]+w[i]
                if(not inQueue[j]):
                    q.append(j)
                    inQueue[j] = True
                if(cnt[j]>=n):
                    #此时存在负环
                    return True

            i = ne[i]
    
    

    return False



同样的如果你想要搞Python版本的话,改一下就好了,改动不是特别大。

小结(最短路)

okey,这里的话介绍了几个这方面的算法,那么上半部分算是结束了。那么现在的话我们来稍微小结一下这部分的内容。那么这里的话我们大概的话其实是有5个算法在这里,并且针对了两大类问题。一个是单源的最短路径问题,然后是多源的最短路径问题。大概就是这样的导图:
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第5张图片

然后结合他们的特点,基本上,如果spfa解决不了,那么说明被卡了,那这个时候尝试Djikstra.如果是有其他要求,那么结合上面的正对问题,再去使用特定算法。

最小生成树

okey, 那么接下来我们进入另一个关于图的版块。那就是最小生成树。

一个连通图可以有多个生成树;
一个连通图的所有生成树都包含相同的顶点个数和边数; 生成树当中不存在环;
移除生成树中的任意一条边都会导致图的不连通, 生成树的边最少特性;
在生成树中添加一条边会构成环。
对于包含n个顶点的连通图,生成树包含n个顶点和n-1条边;
对于包含n个顶点的无向完全图最多包含 颗生成树。

说人话就是,找到一个图当中,能够连通,并且距离最小的路径。当然我们这边这个时候,从1号节点出发就可以了。

同样的,我们这里面其实也是有两种算法,一个是适用于稠密图的Prim算法,还有就是适用于稀疏图的Kruskal算法。

这两个算法的话比较简单,也是比较经典的算法。

Prim算法

这个算法的原理和Djikstra算法代码的实现其实很像。
当然这里的话是找点,给定一个图,我先选择一个点,加入到咱们的生成树当中,然后,找到第二个点,离我们的生成树距离最近的点,然后加入到我们的集合,直到我们把这个全部的点都加入到了生成树当中。


def Prime(g):
    """
    这里的话还是使用邻接矩阵的
    :return:
    """
    dist = [float("inf") for _ in range(100)]
    st = [False for _ in range(100)]
    res = 0
    for i in range(100):

        t = -1
        for j in range(1,101):
            if((t == -1 and not st[j]) or dist[t]>dist[j]):
                t = j

        if(i and dist[t] == float("inf")):
            return float("inf")

        if(i):
            res+=dist[t]

        for j in range(100):
            dist[j] = min(dist[j],g[t][j])
    return res

同样的,由于这个,我们发现就是找这个t时候时间复杂度比较高,那么这个时候我们也可以使用堆进行优化,但是堆优化的问题在于,如果想要达到合适的时间复杂度的话,需要考虑到使用邻接表,那么问题来了,使用邻接表的话我们有别的算法,就是这个Kruskra算法。时间复杂度也类似,当然这个Krushra 的时间复杂度主要集中在这个排序当中。

Kruskra算法

OK,我们来看看这个算法,这个算法的话核心,很简单,就是把所有的边进行排序,然后排序完毕之后的话,我们按照这个边进行组装成树就好了。并且我们使用到并查集进行一个合并,这里的时间复杂可以达到O(1)。

这里的话,我们会给出Python版本的一个模板。

首先的话,我们同样是需要这个p数组的,因为我们需要使用到并查集嘛,当然Python的选择是非常多的,直接使用set来做也是可以的。没办法,不得不承认,Python有时候就是流氓。

首先的话,我们这样,同样我们存边,但是我们是这样的。我们的边直接用list存储。

edges = [(a,b,c)]

这个a,b 表示边,c表示边长

其实可以注意到,这个并查集在找到过程当中,实现了这个路径压缩的一个效果。

def find(a):
	if(p[a]!=a):
		p[a] = find(p[a])
	return p[a]

def Kruskal():
	 res = 0
	 cnt = 0
	 sorted(edgs,lambda x:x[2])
	 for i in range(1,n+1):
	 	p[i] = i
	 
	 for i in range(m):
	 	a,b,c = edges[i]
		#看看这两货有没合在一起,没有合在一起
	 	a = find(a)
	 	b = find(b)
	 	if(a!=b):
	 		p[a] = b; 
	 		res+=c
	 		cnt+=1
	 if(cnt<n-1):
	 	return float("inf")
	 		

二分图

之后的话就是二分图了,这个玩意是图里面比较抽象的东西,因为一般情况下都是抽象建模成一个二分图来做的。然后这一类题目比较灵活。

二分图性质

首先我们来看到什么是二分图。
形如这样的图:
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第6张图片

就叫做二分图,并且是无向的,这个图有什么特点呢,第一在图中,我们发点集能分成两个独立的点集。那么这个的话其实就是一个二分图,然后的话,他的重要的充要条件就是:无向图G为二分图的充分必要条件是,G至少有两个顶点,且其所有回路的长度均为偶数。
同时这里注意的是:需要注意的是,二分图不一定要连通,比如上面的右边这张图,并不连通,但是其仍然是一张二分图

反正大概的话,一个二分图大概是长这样的。那么我们在这里需要做的有两个点,第一个是解决如何判断这个图是一个二分图,之后第二个点,是解决知道了这个是二分图的情况下,找出这个二分图的最大匹配。那么第二点的话,我们待会再说,我们先来看到如何去判断一个图是不是二分图。

染色法

原理

OK,我们先来解决第一个问题,就是这个如何判断是不是一个二分图,这里比较常用的做法就是使用染色法来进行一个判断,判断这个图是不是二分图。因为我们知道我们的这个图有一个性质,就是说可以把全部点分成两个不同的集合。就如上面那张图,所以这个时候,我们可以这样把左边的染成黑色,右边的染成白色。换一句话说,如果一个图,我可以把他们进行染色,并且可以用两种颜色就进行划分,那么这个图的话就是一个二分图。

并且我们拿到这个图为例:

蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第7张图片
我们发现就是说,6号点和10,11号点相连,同时我们染色的话, 6,10,11号当中,6号点的演示是不同于10,11号的。并且观察其他的点,我们发现一个规律就是,如果要上色的话,我们把这个点和他直接相连接的点进行染色成相反的颜色就可以了。那么染色法的话,大概就是基于这个原理来的。

实现

然后我们直接看到实现,同样的,这里的话,我们是存入这个边,也就是用那个来做的,当然在这个版块的话,你用啥其实都可以,你怎么舒服怎么来,但是算法的模板要记住,知道它的一个含义就好。
首先我们还是老三样,我们用邻接表法。

def upColor(color, a, c):
    color[a] = c
    i = h[a]
    while (i != -1):
        j = e[i]
        if (not color[j]):
            if (not upColor(st, j, 3 - c)):
                return False
        elif (color[j] == c):
            return False
        i = ne[i]
    return True


def check():
    for i in range(1, n + 1):
        if (not upColor(st, i, 1)):
            return False
    return True

那么这个代码的话转化为python代码也比较简单,那么这个的话就不给出python代码了。

例题

https://leetcode-cn.com/problems/possible-bipartition
之后的话我们还是来看到相关的题目来,比较好理解,这个题目的话也很简单,直接套用这个模板就可以做了。

给定一组 N 人(编号为 1, 2, …, N), 我们想把每个人分进任意大小的两组。

每个人都可能不喜欢其他人,那么他们不应该属于同一组。

形式上,如果 dislikes[i] = [a, b],表示不允许将编号为 a 和 b 的人归入同一组。

当可以用这种方法将每个人分进两组时,返回 true;否则返回 false。

示例 1:
输入:N = 4, dislikes = [[1,2],[1,3],[2,4]]
输出:true
解释:group1 [1,4], group2 [2,3]

示例 2:
输入:N = 3, dislikes = [[1,2],[1,3],[2,3]]
输出:false

示例 3:
输入:N = 5, dislikes = [[1,2],[2,3],[3,4],[4,5],[1,5]]
输出:false

这个题目的话,乍一看应该是比较抽象的(题解的话,这个我就不写了,套模板即可,文章篇幅太长了,顺便带点思考看本文~,绝对不是因为博主偷懒!!!)

okey,我们直接来看吧,首先的话,我们的二分图有个特点,就是说,可以把点分为两个集合u,v,并且在不同的集合当中,是没有直接相连的点的,对吧。那么如果我们划分人,去分组,那么分好组之后,是不是每个分组之间的人,都是不讨厌的,如果我们把讨厌关系看做是边,那么如果我们把 a,b两个人看做节点,相互讨厌看做是一条边,那么这不是建立了一个图吗。我只需要这个图是不是二分图不就完了吗。

okey,这个就是思路。

匈牙利算法

嗯,这个算法算是老朋友,大一数据建模的时候就学习到了这个算法,时间飞逝,一眨眼我就变成老油条了,咳。那么这个东西,在图当中,在这个二分图当中解决的是什么问题呢,就是匹配问题。那么我们先来看一下什么是匹配问题。

匹配

这个的话直接看到我们待会对应匈牙利算法的一个解释的时候也可以,这样的话会更明朗一点儿。
我们先来看到这个例子吧:
指在当前已完成的匹配下无法再通过增加未完成匹配的边的方式来增加匹配的边数
给定一个二分图G(X,E,Y),F为边集E的一个子集。如果F中任意两条边都没有公共端点,则称F是图G的一个匹配。

蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第8张图片

极大匹配(Maximal Matching)是指在当前已完成的匹配下,无法再通过增加未完成匹配的边的方式来增加匹配的边数。最大匹配(maximum matching)是所有极大匹配当中边数最大的一个匹配。选择这样的边数最大的子集称为图的最大匹配问题。
如果一个匹配中,图中的每个顶点都和图中某条边相关联,则称此匹配为完全匹配,也称作完备匹配。
求二分图最大匹配可以用最大流(Maximal Flow)或者匈牙利算法(Hungarian Algorithm)。

算法原理

首先明确一点,就是我们用这个算法的时候,条件是我们已经知道了这个图,或者说我们构建的这个图是一个二分图。这个是前提,然后再去做匹配。并且在做匹配之前,已经有了一些连接,或者说是前置条件。什么是已有连接呢,这个我们待会解释。

我们先拿一个案例来介绍这个匈牙利算法。这个匈牙利算法呢,又可以叫做“相亲算法”,“找老婆算法”。当然这个是戏称,因为有一个非常经典的案例,就是这个给定这样的一个任务:叫你当月老(财神爷)。

现在有这样一群男女嘉宾:
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第9张图片

然后捏,现在呢,我们通过一段时间的认识,有一些男女嘉宾互相看上了眼,这些连线就表示,嘉宾之间看上了眼。例如:B1,连接G2,G4。就是说,男一号看上了女2,4号。同样的看女二号,她和B1,B2连接了,也就是,女二号也看上了男1号和2号。此时你是月老,或者说你是相亲大会的主持人。你现在需要根据这个情况,去给这些男女去配对。争取让每一个男的都可以和对话心仪的女孩子配对。之所以需要我们安排配对呢原因很简单,因为有个别女的或者男滴,同时看上了好几对象,然后有些男滴A可能只是看上了一个对象,并且那个女的B已经配对上了一个男滴C,但是那个男滴C是吾辈楷模,他还有个备胎,并且愿意和备胎搞,那么这个时候女B就可以空闲出来了(当然那个女的B也是看上了,同时也看上了C,只是先和C配对了)所以这样的话,就给我们操作空间了。没办法双方的备胎都挺多的,给那些看起来稍微“老实一点人”机会。当然这个只是一个例子,请勿对号入座!

那么我们的这算法就是完成这个任务,那么如何完成呢,其实很简单,就是咱们刚刚说的。来举个例子:现在A看上了B,C这两个女嘉宾。现在假设我们让A和B进行配对。之后我们对D这个男嘉宾进行配对,假设男嘉宾D,他只看上了女嘉宾B,(女嘉宾同时看上了男嘉宾A,D,现在和男嘉宾A进行配对中)。这个时候就会去找到男嘉宾A,发现男嘉宾A还看上了女嘉宾K,并且愿意和K再配对。那么这个时候我们就让A和K配对,然后把D和B配对。但是如果男嘉宾A也只看上了B,那么没办法谁让人家先来呢,那么这个时候真没法配对了,就算了。

OK,这个的话就是我们的一个流程,那么代码的话其实也很好写,难的是怎么用。

实现

现在我们假设,我们已经准备好了两个集合,也就是说一个图,我们已经划分好了,或者说这个二分图我们已经搭建好了哈。

现在继续我们当相亲大赛主持人的身份。


def Match(st, match, a):
    i = h[a]
    while (i != -1):
        j = e[i]
        if (not st[j]):
            st[j] = True
            if (match[j] == 0 or Match(st, match, j)):
                match[j] = a
                return True
        i = ne[i]
    return False


def Hunager():
    res = 0  # 匹配个数
    Match = [0] * (n2+1)  # 右侧的集合元素和左侧的谁进行了匹配
    for i in range(1, n1 + 1):
        # 表示右边的那个集合元素有没有匹配到
        st = [False] * (n2+1)
        if (Match(st, match, i)):
            res += 1
    return res



当然这些都是很灵活的,不一样的是邻接表来写也是可以的。口诀的就是:

1. 来两个东西,记录匹配的边和记录一个东西也没有确定被匹配
2. 拿过来一个节点,看一看能够和这个节点匹配的节点。然后按照咱们的“相亲”规则进行判断
3. 全部“男嘉宾”进行处理,也就是另一半集合。

案例

我们来一个实际的题目吧:
https://ac.nowcoder.com/acm/contest/1062/B

给定一个N行N列的棋盘,已知某些格子禁止放置。求最多能往棋盘上放多少块的长度为2、宽度为1的骨牌。骨牌的边界与格线重合(骨牌占用两个格子),并且任意两张骨牌都不重叠。N,M≤100。

第一行为n,m(表示有m个删除的格子)
第二行到m+1行为x,y,分别表示删除格子所在的位置
x为第x行
y为第y列
输入
8 0

输出
32

题目大概就是这个样子。

提示是用二分图去做,并且解压转换为最大匹配问题,然后使用到匈牙利算法去解决这个问题。

二分图匹配的模型有两个要素:

1.节点能分成两个独立的集合,每个集合内部没有边互相连接。

2.每一个节点只能与另一个集合有一条匹配边相连

那么我们仔细看到题目的描述,描述怎么说的,在里面,有一个任意两张骨牌都不重叠,并且一个骨牌,占了两个小格子。好,第一个问题,如果重合了会发生什么明显的现象,因为骨牌占了两个格子,我们假设骨牌有头部和尾部组成,头部和尾部各占一个格子。那么如果这两个重合了,那么必然就是存在两个骨牌或者多个骨牌,他们的头部和头部重合了,或者头部和尾部重合了。那么如果要避免重合,那么首先就要保证,所有骨牌的头部和尾部在棋盘上面对应的小格子不能有重复。也就是说对应骨牌头部的格子和对于骨牌尾部的格子,不能在同一个集合,那么这里恰好划分了两个集合出来。并且两个集合当中的格子,通过同一块骨牌可以连起来。

OK,这个时候,好像已经符合了二分图的特点。那么问题来了,如何保证这个集合不会重复,或者说,我隐约感觉到了可以这样做,但是怎么做,这个二分图如何建立。

骨牌不能对角线放置对吧,并且我们的头部和尾部不能在同一个集合,要分开。所以对角线上面不可能出现头部和尾部,同时又要能够区分头部和尾部,那么既然如此,那么如果我按照对角线交叉放置头部和尾部的话,不就刚好可以把这两个家伙分开了么。

于是如下图进行放置:

蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第10张图片
我们把蓝色看成是男嘉宾,白色点看成是女嘉宾。然后题目问的是最多可以放置多少个骨牌,那么此时不就相当于,男女嘉宾可以匹配多少对了么。

这个案例的建模过程很抽象。

那么接下来就是实现:

n, t = map(int, input().split())
g = [[False] * (n + 1) for _ in range(n + 1)]
match = [[(0, 0)] * (n + 1) for _ in range(n + 1)]
res = 0

dx = [1, -1, 0, 0]
dy = [0, 0, -1, 1]

def find(x, y):
    for i in range(4):
        a, b = x + dx[i], y + dy[i]
        if st[a][b]:
            continue
        if g[a][b]:
            continue
        if a <= 0 or a > n or b <= 0 or b > n:
            continue
        st[a][b] = True
        if not match[a][b][0] or find(match[a][b][0], match[a][b][1]):
            match[a][b] = x, y
            return True
    return False

for i in range(t):
    a, b = map(int, input().split())
    g[a][b] = True

for i in range(1, n + 1):
    for j in range(1, n + 1):
        if (i + j) % 2 == 0 and not g[i][j]:
            st = [[False] * (n + 1) for _ in range(n + 1)]
            if find(i, j):
                res += 1

print(res)

最后的话还有一点就是关于初始化的时候,C++是有这个memset来快速完成这个初始化的,那么Python的话其实可以直接使用[0]*m 这种方式进行初始化,或者列表表达式,推荐前者。但是如果你是[[],[]]这种结构的话,使用表达式,因为前者是引用会出问题的。

数论

,除了图的模板之外,我们还需要数论的模板,蓝桥杯Python组的话还是挺喜欢数论的题目的,当然这边的数论模板肯定是没有那么完整的,因为实际上数论东西是真不少。所以这里可能只有几个常用的,以及我先前遇到的。

质数判断

朴素方式:

def isPrim(n):
	if(n==1):
		return False
	for i in range(2,n):
		if(n%i==0);
			return False
	return True

之后的话,由于除法的一些性质,当 n/d = d 的时候,d^2 = n 在这个循环过程当中 d^2 <= n 这个时候的话,是没有必要全部除去的,只需要一般就好了。

优化版本:

def isPrime(n):
	if(n==1):
		return 
	
	i = 2
	while(i<=(n//i)):
		if(n%i==0):
			return False
	return True

求约数

这个的话就是前面说的那句话的比较好的运用

def yueShu(n):
	i = 1
	while(i<=n//i):
		if(n%i==0):
			print(i)
			if(i!=n//i):
				print(n//i)
		i+=1

此外:
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第11张图片

求取区间质数

现在我们知道了如何判断一个质数,那么现在我们需要求取一个区间内的质数,例如,我们需要求取,从1~n之间所有的质数有哪些?

埃氏筛法

直接看到代码:

def getPrims(n):
	prime = [0] * (n+1):
	st = [False] * (n+1)
	cnt = 0
	for i in range(2,n+1):
		if(not st[i]):
			prime[cnt] = i
			cnt+=1
		
		j = i+i
		while(j<=n):
			st[j] = True
			j+=i
			

这个的话,就是把这个质数的倍数给筛掉。

线性筛法

埃氏筛法的效率还是可以的,但是还可以优化一下。

def getPrim(n):
	prime = [0] * (n+1):
	st = [False] * (n+1)
	cnt = 0
	for i in range(2,n+1):
	if(not st[i]):
		prime[cnt] = i
		cnt+=1
	
	j = 0
	while(prime[j] <=n//i):
		st[prime[j]*i] = True
		if(i%(prime[j])==0):
			break
		j+=1

这个的话就是把那个筛选的给优化了一下。

分解质因数

ok,接下来是分解质因数,这个分解质因数是很有用的东西。

N = p1^a1 * p2*a2 …pk^ak
对于一个数N都可以拆解为这样的样子,其中这个P是质数

def division(n):
	
	i = 2
	while(i<=n/i):
		if(n%i==0):
			a = 0
			while(n%i==0):
				n//=i
				a+=1
			print("当前质因数为:{},对于的幂是{}".format(i,a))
	if(n>1):
		print("当前质因数为:{},对于的幂是{}".format(n,1))

欧拉

这个东西的话和我们的这个质数还是有点关系的。

欧拉函数

欧拉函数φ(5) 表示从1~5 当中和5 互质的元素个数有几个。

求取单个数

同样的和我们刚刚的这个质数是类似的,可以直接求取这个欧拉函数,也有求取一个区间的所有欧拉函数值。
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第12张图片


def OuLa(n):
	res = n
	i = 2
	while(i<=(n//i)):
		if(n%i==0):
			while(n%i==0):
				n//=i
			#res = res* (1-(1/i)) 优化一下避免处于小数
			res = res // i *(i - 1)

	return res

线性筛法求取

这个的话就是求取这个1~n这个范围的所有的欧拉数

def ouLaLiner(n):
	prim = [0] * (n+1)
	st = [False] * (n+1)
	cnt = 0
	phi = [1] * (n+1) # φ(1) = 1
	for i in range(2,n+1):
		if(not st[i]):
			prim[cnt] = i
			i+=1
			phi[i] = phi[i-1]
	
		j = 0
		while(prim[j]<=n//i):
			st[prim[j]*i] = True
			if(i%prim[j]==0):
				phi[prim[j]*i] = phi[i] *(prim[j])
				break
			phi[prim[j]*i] = phi[i] *(prim[j]-1)
			j+=1 
	

欧拉定理

那么接下来就是这个欧拉定理,这个欧拉定理的话,可以用在我们的求逆元的时候用上。

这个定理非常简单。
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第13张图片

求逆元

蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第14张图片

那么这里的话,求解的时候的话,还需要一个求快速幂的算法

快速幂/幂取模

def KuaiSu(a,k):
	res = 1
	while(k):
		if(k%2==0):
			res*=a
		a*=a
		k//=2
	return res
def KuaiSu(a,k,p):
	res = 1
	while(k):
		if(k%2==0):
			res*=a % p
		a*=a % p
		k//=2
	return res

原理是这个:

(a + b) % p = (a % p + b % p) % p(1)

(a - b) % p = (a % p - b % p ) % p (2)

(a * b) % p = (a % p * b % p) % p .(3)

欧几里得算法

求最小公约数

这里的话主要是这个欧几里得算法,这个欧几里得算法的话作用还是非常大的,一个是求取最小公约数,然后的话就是用拓展欧几里得算法来求取这个同余方程(当然这块是先用裴蜀定理可以证明一下)

def gcd(a,b):
	if(b==0):
		return a
	return (b,a%b)

拓展欧几里得算法

蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第15张图片
我们使用这个拓展欧几里得算法的话可以求出这个来。
我们先来直接看到代码:

globe x=0,y=0
def exgcd(a,b,x,y):
	if(b == 0):
		x = 1,y = 0
		return a
    d = exgcd(b, a % b, y, x);
    y -= (a//b) * x;
	return d

这里的话这个y 是这样的:
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第16张图片

求解同余方程

那么这个拓展欧几里得算法的话就可以去求解同余方程以及我们刚刚的求逆元。

因为我们那个求逆元其实也就是解一个同余方程,只是那个方程比较特殊而已。

为什么可以怎么来的如下:
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第17张图片
这里的话就不给代码了,上面有。然后求逆的话是这样的:
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第18张图片

求解方程组

之后是求解方程组相关的内容,这部分的话也是挺重要的,因为去年直接来了个这个中国剩余定理。它本质上就是一个同余方程组,但是它是一个特殊情况,所以的话可以直接使用中国剩余定理。所以,本着会就会,不会就打表的原则,我们在这里追求大而全。

高斯消元

我们先来第一个简单的在这方面,就是高斯消元,高斯消元法是一种求解线性方程组的方法,它可以将一个复杂的线性方程组化为简单可解的形式。具体来说,高斯消元法通过使用一系列的消元操作,将原始的方程组转化为等价的上三角矩阵或行阶梯形矩阵,从而确定方程组的解。

假设有 n n n 个未知数和 m m m 个方程,方程组的一般形式为:

{ a 11 x 1 + a 12 x 2 + ⋯ + a 1 n x n = b 1 a 21 x 1 + a 22 x 2 + ⋯ + a 2 n x n = b 2 ⋯ ⋯ ⋯ a m 1 x 1 + a m 2 x 2 + ⋯ + a m n x n = b m \begin{cases} a_{11}x_1 + a_{12}x_2 + \cdots + a_{1n}x_n = b_1\\ a_{21}x_1 + a_{22}x_2 + \cdots + a_{2n}x_n = b_2\\ \cdots\cdots\cdots \\ a_{m1}x_1 + a_{m2}x_2 + \cdots + a_{mn}x_n = b_m \end{cases} a11x1+a12x2++a1nxn=b1a21x1+a22x2++a2nxn=b2⋯⋯⋯am1x1+am2x2++amnxn=bm

其中, a i j a_{ij} aij b i b_i bi 表示已知的系数和常量, x i x_i xi 表示未知变量。我们可以将这个方程组表示为增广矩阵的形式:

[ a 11 a 12 ⋯ a 1 n b 1 a 21 a 22 ⋯ a 2 n b 2 ⋮ ⋮ ⋱ ⋮ ⋮ a m 1 a m 2 ⋯ a m n b m ] \begin{bmatrix} a_{11} & a_{12} & \cdots & a_{1n} & b_1 \\ a_{21} & a_{22} & \cdots & a_{2n} & b_2 \\ \vdots & \vdots & \ddots & \vdots & \vdots \\ a_{m1} & a_{m2} & \cdots & a_{mn} & b_m \end{bmatrix} a11a21am1a12a22am2a1na2namnb1b2bm

高斯消元法的基本思路是将这个增广矩阵通过一系列消元操作,转换为一个上三角矩阵或行阶梯形矩阵。具体来说,我们可以依次对每一列(称为主元列)进行操作,将该列中下面的元素都变为 0 0 0。操作的过程中需要使用到初等变换(即交换两行、将某一行乘以一个非零常数、将某一行加上另一行的若干倍),以保证等价性。

经过一系列的消元操作,增广矩阵就可以被转化为上三角矩阵或行阶梯形矩阵的形式。这个过程中,一些方程可能会被消除,因此方程组的解也相应地发生变化。在上三角矩阵的情况下,我们可以通过回带法确定方程组的解。

代码如下:

def gauss(a, b):
    n = len(a)
    # 初等行变换,将增广矩阵转化为上三角矩阵
    for i in range(n):
        pivot = a[i][i]
        if pivot == 0:
            return None # 如果主元为 0,则无法消元
        for j in range(i + 1, n):
            factor = a[j][i] / pivot
            for k in range(i, n):
                a[j][k] -= factor * a[i][k]
            b[j] -= factor * b[i]
    # 判断是否有唯一解
    for i in range(n):
        if a[i][i] == 0 and b[i] != 0:
            return None # 存在冲突的方程
    # 回带法求解方程组的解
    x = [0] * n
    for i in range(n - 1, -1, -1):
        for j in range(i + 1, n):
            b[i] -= a[i][j] * x[j]
        x[i] = b[i] / a[i][i]
    return x

测试:

a = [[1, 1, 1], [1, -1, -1], [2, 4, -2]]
b = [3, 1, 2]
x = gauss(a, b)
print(x) # None

我们有如下的线性方程组:

 x + y + z = 3
 x - y - z = 1
2x + 4y - 2z = 2

将其表示为增广矩阵的形式:

1  1  1 | 3
1 -1 -1 | 1
2  4 -2 | 2

当结果为None时表示无解。

扩展欧几里得求解

ok,我们刚刚用扩展欧几里得解决同余方程,那么现在解决这个同余方程组。

一个线性同余方程组的形式如下:

{ a 1 x ≡ b 1   m o d   m 1 a 2 x ≡ b 2   m o d   m 2 ⋯ a n x ≡ b n   m o d   m n \begin{cases} a_1 x \equiv b_1 \bmod m_1 \\ a_2 x \equiv b_2 \bmod m_2 \\ \cdots \\ a_n x \equiv b_n \bmod m_n \end{cases} a1xb1modm1a2xb2modm2anxbnmodmn

其中, a 1 , a 2 , ⋯   , a n a_1, a_2, \cdots, a_n a1,a2,,an 是已知的系数, b 1 , b 2 , ⋯   , b n b_1, b_2, \cdots, b_n b1,b2,,bn 是已知的常量, m 1 , m 2 , ⋯   , m n m_1, m_2, \cdots, m_n m1,m2,,mn 是已知的模数。该方程组的解是一个整数 x x x,满足所有同余方程。

扩展欧几里得算法通过运用欧几里得算法和逆元的概念,可以求出线性同余方程组的解。我们首先以 m i m_i mi 为模数对 a i a_i ai 进行模意义下的逆元运算,得到 a i − 1 a_i^{-1} ai1,然后将其代入对应的同余方程中,得到新的形式:

{ x ≡ c 1   m o d   m 1 x ≡ c 2   m o d   m 2 ⋯ x ≡ c n   m o d   m n \begin{cases} x \equiv c_1 \bmod m_1 \\ x \equiv c_2 \bmod m_2 \\ \cdots \\ x \equiv c_n \bmod m_n \end{cases} xc1modm1xc2modm2xcnmodmn

其中, c i = b i ⋅ a i − 1   m o d   m i c_i = b_i \cdot a_i^{-1} \bmod m_i ci=biai1modmi

接着,我们可以使用扩展欧几里得算法求解上述方程组。假设某个同余方程的解为 x 0 x_0 x0,则该方程可表示成如下形式:

a x 0 + m y = b ax_0 + my = b ax0+my=b

其中, y y y 是任意整数。对于每一个同余方程,我们都可以表示成上述形式。我们可以通过求解相邻两个同余方程的最大公因数和唯一性解来求解 x 0 x_0 x0 y y y

具体来说,我们从后往前考虑方程组中的每一个同余方程,对于当前的同余方程 x ≡ c i   m o d   m i x \equiv c_i \bmod m_i xcimodmi,我们可以将其表示成如下形式:

x 0 + m y i = c i x_0 + my_i = c_i x0+myi=ci

其中, y i y_i yi 是任意整数, x 0 x_0 x0 是前面所有同余方程的解的线性组合。然后我们使用扩展欧几里得算法求解 x 0 x_0 x0 y i y_i yi 的最大公因数和一个唯一性解。接着,我们就可以递归地求解前面的同余方程的解,直到得到整个方程组的解。

以下是 Python3 的模板代码:

def ext_euclid(a: int, b: int) -> Tuple[int, int, int]:
    if b == 0:
        return a, 1, 0
    gcd, x, y = ext_euclid(b, a % b)
    return gcd, y, x - (a // b) * y

def linear_congruence_equations(a: List[int], b: List[int], m: List[int]) -> Tuple[Optional[int], Optional[int]]:
    """
    解决线性同余方程组
    :param a: list[int] 系数列表
    :param b: list[int] 常量列表
    :param m: list[int] 模数列表
    :return: tuple[int,int] 同余方程的解 x 和模数 M
    """
    # 检查输入是否符合要求
    if len(a) != len(b) or len(b) != len(m):
        return None, None

    n = len(m)
    M = 1
    # 计算所有模数的乘积
    for i in range(n):
        M *= m[i]

    x = 0
    # 求解同余方程组
    for i in range(n):
        Mi = M // m[i]
        _, t, _ = ext_euclid(Mi, m[i])
        x += a[i] * Mi * t * b[i]

    x %= M
    # 如果存在解,则 x 一定是正整数
    if x < 0:
        x += M
    # 检查解是否合法
    for i in range(n):
        if x % m[i] != b[i]:
            return None, None

    return x, M

例如:

a = [2, 3, 2]
b = [5, 7, 6]
m = [11, 13, 17]
x, M = linear_congruence_equations(a, b, m)
print(x, M)  # 5235 3141

在这个测试样例中,我们有如下的线性同余方程组:

{ 2 x ≡ 5   m o d   11 3 x ≡ 7   m o d   13 2 x ≡ 6   m o d   17 \begin{cases} 2x \equiv 5 \bmod 11 \\ 3x \equiv 7 \bmod 13 \\ 2x \equiv 6 \bmod 17 \end{cases} 2x5mod113x7mod132x6mod17

通过扩展欧几里得算法,我们可以求解该方程组的解为 x ≡ 5235   m o d   3141 x \equiv 5235 \bmod 3141 x5235mod3141。上述代码输出了求解得到的答案 5235 5235 5235 和模数 3141 3141 3141

中国剩余定理(CRT)

使用中国剩余定理的条件是,给定的模数必须满足两两互质。如果模数不互质,则可以通过拆分每个模数为若干个两两互质的模数,然后使用 CRT 求解。具体来说,我们可以将每个模数分解成若干个两两互质的模数的乘积,再分别求解每组同余方程的解,最后合并得到原问题的解。

例如,假设我们要求解如下同余方程组:

{ x ≡ 2   m o d   6 x ≡ 3   m o d   15 x ≡ 1   m o d   7 \begin{cases} x \equiv 2 \bmod 6 \\ x \equiv 3 \bmod 15 \\ x \equiv 1 \bmod 7 \end{cases} x2mod6x3mod15x1mod7

其中, 6 , 15 6, 15 6,15 7 7 7 不互质。我们可以将 6 6 6 分解成 2 ⋅ 3 2 \cdot 3 23,然后分别求解以下两个同余方程组:

{ x ≡ 2   m o d   2 x ≡ 2   m o d   3 x ≡ 3   m o d   15 x ≡ 1   m o d   7 \begin{cases} x \equiv 2 \bmod 2 \\ x \equiv 2 \bmod 3 \\ x \equiv 3 \bmod 15 \\ x \equiv 1 \bmod 7 \end{cases} x2mod2x2mod3x3mod15x1mod7

{ x ≡ 0   m o d   2 x ≡ 1   m o d   3 x ≡ 3   m o d   15 x ≡ 1   m o d   7 \begin{cases} x \equiv 0 \bmod 2 \\ x \equiv 1 \bmod 3 \\ x \equiv 3 \bmod 15 \\ x \equiv 1 \bmod 7 \end{cases} x0mod2x1mod3x3mod15x1mod7

可以发现,这两个同余方程组中的每个模数都是两两互质的。因此,我们可以分别使用 CRT 求解它们,最后再合并得到原问题的解。

具体来说,对于第一个同余方程组:

{ x ≡ 2   m o d   2 x ≡ 2   m o d   3 x ≡ 3   m o d   15 x ≡ 1   m o d   7 \begin{cases} x \equiv 2 \bmod 2 \\ x \equiv 2 \bmod 3 \\ x \equiv 3 \bmod 15 \\ x \equiv 1 \bmod 7 \end{cases} x2mod2x2mod3x3mod15x1mod7

我们有 M 1 = 3 ⋅ 15 ⋅ 7 = 315 , M 2 = 2 ⋅ 15 ⋅ 7 = 210 , M 3 = 2 ⋅ 3 ⋅ 7 = 42 , M 4 = 2 ⋅ 3 ⋅ 15 = 90 M_1 = 3 \cdot 15 \cdot 7 = 315, M_2 = 2 \cdot 15 \cdot 7 = 210, M_3 = 2 \cdot 3 \cdot 7 = 42, M_4 = 2 \cdot 3 \cdot 15 = 90 M1=3157=315,M2=2157=210,M3=237=42,M4=2315=90,以及它们关于模数的逆元:

y 1 ≡ 1   m o d   2 , y 1 ≡ 0   m o d   3 , y 1 ≡ 12   m o d   7 y 2 ≡ 1   m o d   3 , y 2 ≡ 0   m o d   2 , y 2 ≡ 15   m o d   7 y 3 ≡ 3   m o d   7 , y 3 ≡ 0   m o d   3 , y 3 ≡ 5   m o d   2 y 4 ≡ 1   m o d   15 , y 4 ≡ 0   m o d   6 , y 4 ≡ 6   m o d   7 \begin{aligned} & y_1 \equiv 1 \bmod 2, y_1 \equiv 0 \bmod 3, y_1 \equiv 12 \bmod 7 \\ & y_2 \equiv 1 \bmod 3, y_2 \equiv 0 \bmod 2, y_2 \equiv 15 \bmod 7 \\ & y_3 \equiv 3 \bmod 7, y_3 \equiv 0 \bmod 3, y_3 \equiv 5 \bmod 2 \\ & y_4 \equiv 1 \bmod 15, y_4 \equiv 0 \bmod 6, y_4 \equiv 6 \bmod 7 \end{aligned} y11mod2,y10mod3,y112mod7y21mod3,y20mod2,y215mod7y33mod7,y30mod3,y35mod2y41mod15,y40mod6,y46mod7

通过 CRT,我们可以求解该方程组的解为 x ≡ 112   m o d   315 x \equiv 112 \bmod 315 x112mod315

同样地,对于第二个同余方程组:

{ x ≡ 0   m o d   2 x ≡ 1   m o d   3 x ≡ 3   m o d   15 x ≡ 1   m o d   7 \begin{cases} x \equiv 0 \bmod 2 \\ x \equiv 1 \bmod 3 \\ x \equiv 3 \bmod 15 \\ x \equiv 1 \bmod 7 \end{cases} x0mod2x1mod3x3mod15x1mod7

我们有 M 1 = 3 ⋅ 15 ⋅ 7 = 315 , M 2 = 2 ⋅ 15 ⋅ 7 = 210 , M 3 = 2 ⋅ 3 ⋅ 7 = 42 , M 4 = 2 ⋅ 3 ⋅ 15 = 90 M_1 = 3 \cdot 15 \cdot 7 = 315, M_2 = 2 \cdot 15 \cdot 7 = 210, M_3 = 2 \cdot 3 \cdot 7 = 42, M_4 = 2 \cdot 3 \cdot 15 = 90 M1=3157=315,M2=2157=210,M3=237=42,M4=2315=90,以及它们关于模数的逆元:

y 1 ≡ 1   m o d   2 , y 1 ≡ 0   m o d   3 , y 1 ≡ 12   m o d   7 y 2 ≡ 1   m o d   3 , y 2 ≡ 0   m o d   2 , y 2 ≡ 15   m o d   7 y 3 ≡ 3   m o d   7 , y 3 ≡ 0   m o d   3 , y 3 ≡ 5   m o d   2 y 4 ≡ 1   m o d   15 , y 4 ≡ 0   m o d   6 , y 4 ≡ 6   m o d   7 \begin{aligned} & y_1 \equiv 1 \bmod 2, y_1 \equiv 0 \bmod 3, y_1 \equiv 12 \bmod 7 \\ & y_2 \equiv 1 \bmod 3, y_2 \equiv 0 \bmod 2, y_2 \equiv 15 \bmod 7 \\ & y_3 \equiv 3 \bmod 7, y_3 \equiv 0 \bmod 3, y_3 \equiv 5 \bmod 2 \\ & y_4 \equiv 1 \bmod 15, y_4 \equiv 0 \bmod 6, y_4 \equiv 6 \bmod 7 \end{aligned} y11mod2,y10mod3,y112mod7y21mod3,y20mod2,y215mod7y33mod7,y30mod3,y35mod2y41mod15,y40mod6,y46mod7

通过 CRT,我们可以求解该方程组的解为 x ≡ 873   m o d   315 x \equiv 873 \bmod 315 x873mod315

最后,我们需要使用合并得到的两个解,来求解原问题的解。我们可以通过求解如下同余方程,来得到原问题的解:

{ x ≡ 112   m o d   315 x ≡ 873   m o d   315 \begin{cases} x \equiv 112 \bmod 315 \\ x \equiv 873 \bmod 315 \end{cases} {x112mod315x873mod315

通过 CRT,我们可以求解该方程组的解为 x ≡ 237   m o d   315 x \equiv 237 \bmod 315 x237mod315,即原同余方程组的解。

ok ,上代码:

from typing import List, Tuple, Optional

def ext_euclid(a: int, b: int) -> Tuple[int, int, int]:
    if b == 0:
        return a, 1, 0
    gcd, x, y = ext_euclid(b, a % b)
    return gcd, y, x - (a // b) * y

def chinese_remainder_theorem(a: List[int], m: List[int]) -> Tuple[Optional[int], Optional[int]]:
    """
    解决一个线性同余方程组CRT
    :param a: list[int] 常量列表
    :param m: list[int] 模数列表
    :return: tuple[int,int] 同余方程的解 x 和模数 M
    """
    # 检查输入是否符合要求
    if len(a) != len(m):
        return None, None

    n = len(m)
    M = 1
    # 计算所有模数的乘积
    for i in range(n):
        M *= m[i]

    x = 0
    # 求解同余方程组
    for i in range(n):
        Mi = M // m[i]
        _, t, _ = ext_euclid(Mi, m[i])
        x += a[i] * Mi * t

    x %= M
    # 如果存在解,则 x 一定是正整数
    if x < 0:
        x += M
    return x, M

简单博弈论(SG函数)

SG 函数是博弈论中的重要概念,用于求解和局面游戏的必胜必败情况。对于一个局面,SG 函数是一个非负整数,表示该局面的必胜性。具体地说,如果 SG 函数为 0 0 0,则该局面是必败局面;否则,该局面是必胜局面。

。我们从简单的局面出发,通过向后推导,计算出所有可能的局面的 SG 函数值。

例如是零和游戏(Zero-Sum Game)。对于一个零和游戏,如果它是完美信息游戏,即双方都知道当前状态和之前的所有状态,那么它一定存在一个纳什均衡点,也就是一个双方策略到达的状态,并且在该状态下,双方不能改变自己的策略来获得更高的收益。在这种情况下,我们可以通过反推出每个状态的必胜/必败情况,来确定每个状态的 SG 函数值。

对于非零和博弈,我们通常将其转化为对应的零和博弈,然后再进行 SG 函数的求解。例如,对于存在先手必胜策略的非零和博弈,我们可以转化为存在后手必胜策略的零和博弈。

下面以 Nim 游戏为例,介绍 SG 函数的计算方法。

Nim 游戏是一种经典的零和二人博弈。游戏开始时有若干堆石子,每堆石子的数目不少于 1 1 1,双方轮流取石子,每次只能从一堆石子中取走至少 1 1 1 颗、最多取走该堆石子数目的一半。取走最后一颗石子的一方获胜。

对于 Nim 游戏,我们可以证明它是一个异或和博弈(XOR Game)。即对于每个局面,将所有的石子数目看作二进制下的位,然后依次计算每一位上的异或和。如果这个异或和为 0 0 0,则该局面是必败局面;否则,该局面是必胜局面。此时,该局面的 SG 函数值就等于这个异或和。具体来说,我们可以使用以下递归公式:

S G ( x 1 , x 2 , ⋯   , x n ) = mex ⁡ { S G ( x 1 ′ , x 2 ′ , ⋯   , x n ′ ) ∣ x i ′ < x i , x i ′ ≤ ⌊ x i / 2 ⌋ } SG(x_1, x_2, \cdots, x_n) = \operatorname{mex} \{ SG(x_1', x_2', \cdots, x_n') | x_i' < x_i, x_i' \le \lfloor x_i / 2 \rfloor \} SG(x1,x2,,xn)=mex{SG(x1,x2,,xn)xi<xi,xixi/2⌋}

其中, mex ⁡ ( S ) \operatorname{mex}(S) mex(S) 表示集合 S S S 中不属于 S S S 的最小非负整数(即 minimum excludant)。可以发现,这个递归公式枚举了所有可能的后继状态,然后选取它们的 SG 函数值的 mex 值。

下面给出一个 Python3 的代码实现:

def mex(s):
    i = 0
    while i in s:
        i += 1
    return i

def sg(x):
    if len(x) == 0:
        return 0
    s = set()
    for i in range(len(x)):
    #这个是可以转换的拿法
        for j in range(1, x[i] // 2 + 1):
            s.add(sg([j, x[i]-j]))
    return mex(s)

x = [3, 4, 5]
print(sg(x))  # 2

sg(x) 函数计算异或和博弈 ( x 1 , x 2 , ⋯   , x n ) (x_1, x_2, \cdots, x_n) (x1,x2,,xn) 的 SG 函数值。例如,对于三堆石子分别有 3 3 3 4 4 4 5 5 5 颗石子的情况,我们有 S G ( 3 , 4 , 5 ) = 2 SG(3, 4, 5) = 2 SG(3,4,5)=2,表示该局面是必胜局面。也就是,只要SG算到的结果为0,那么先手必败,反之。

我们可以简单画一个SG(3,4,5)的一个运算递归树

        (3, 4, 5)
          / | \
         /  |  \
        /   |   \
     *(2) *(3)  *(4)
     / |   |     | \
    /  |   |     |  \
   /   |   |     |   \
(1)  (0) (4)  (3,4) (3,5)

初始状态 (3, 4, 5)(3,4,5) 是根节点,它有三个后继状态,分别为 (2, 4, 5)(2,4,5)、(3, 3, 5)(3,3,5) 和 (3, 4, 4)(3,4,4)。我们需要计算它们的 SG 函数值,并取 mex 值作为根节点的 SG 函数值 SG(3,4,5)SG(3,4,5)。

对于每个中间状态,我们需要继续递归计算它的后继状态的 SG 值,直到到达基础状态。例如,对于中间状态 (2, 4, 5)(2,4,5),它有两个后继状态,分别为 (1, 4, 5)(1,4,5) 和 (2, 3, 5)(2,3,5)。我们需要计算它们的 SG 函数值,并取 mex 值作为中间状态 (2, 4, 5)(2,4,5) 的 SG 函数值 SG(2,4,5)SG(2,4,5)。

不断递归下去,最终我们可以求出所有叶子节点的 SG 函数值,从而得到整棵递归树。最终的 SG 函数值为根节点的 SG 函数值 SG(3,4,5)SG(3,4,5)。

这个是下面比较通用的模板。


# 计算SG函数的函数
def sg_function(n):
    if n == 0:
        return 0
    s = set()
    for i in range(1, n+1):
        s.add(sg_function(n-i))
    return mex(s)

# 计算集合中未出现过的最小非负整数
def mex(s):
    i = 0
    while i in s:
        i += 1
    return i

# 对两堆石子进行Nim游戏
def nim_game(a, b):
    res = sg_function(a) ^ sg_function(b)
    if res == 0:
        print("后手必胜")
    else:
        print("先手必胜")

# 假设有两堆石头,个数分别为3,4
a, b = 3, 4
nim_game(a, b)

动态规划模型

前面提了一些这个动态规划思想,但是咱们这个还不够,我们是为了解题,咱们这个化石得用用模型的,这样也是为了方便解题,有模型直接套不爽?

然后我会给出记忆搜索模板和其他大部分你在其他博文里面可以见到的那种递推模板.但是我的建议是掌握递归版本,也就是记忆搜索版本,然后你会了这个就自然会了递推版本,以及如何变形.

这里的话,主要就是这几块:

这些都是动态规划中的不同类型或者形式,下面简单介绍一下它们:

  1. 背包问题:

背包问题是动态规划中比较经典的问题之一,根据具体的约束条件,又可分为 0/1 背包、完全背包等多种变形。

  1. 线性DP:

线性 DP 通常是用于序列型动态规划问题,使用一个一维数组存储问题的状态,依次递推计算出每个状态的值。

  1. 区间DP:

区间 DP 主要是针对区间型动态规划问题,将问题的状态定义为区间中的某些属性,递归计算出所有子区间的状态,最终得到整个区间的状态值。

  1. 计数类DP:

计数类 DP 被广泛应用于组合数学和概率统计等领域,其基本思想是将问题转化为一个具有递归结构的计数问题,然后使用 DP
方法计算出问题的答案。

  1. 数位统计DP:

数位统计 DP 主要用于数字计数相关问题,在 DP 过程中依次考虑每个数位,计算出所有可能的候选数,并统计满足问题特定条件的数量。

  1. 状态压缩DP:

状态压缩 DP 常用于状态空间非常大的问题,采用合理的状态压缩技巧将状态空间压缩至合理范围内,然后使用常规的 DP 方法解决问题。

  1. 树形DP:

树形 DP 通常用于树型结构的问题,将问题的状态定义为某个节点的属性,递归计算出所有子节点的状态值,最终得到整棵树的状态值。

注意这里没有写状态搜索,为啥,都是从递归到记忆搜索然后再到了递推的,换一句话说,这些递归模板都可以写成记忆搜索。

那么这里的话,受限于篇幅,我们之说这几个:

  1. 背包模型
  2. 数字三角形模型
  3. 切割模型
  4. LIS/LCS
  5. 区间

其他的就暂时不写了。

并且,这里的话,我都会给出递归版本,因为理解了递归版本,我们才能更好地运用这个模型
递推只是对递归的一种等价优化而已。转化为递推之后可以进行很多优化,比如经常听到的,滚动数组优化,背包里面的。

背包

该问题的基本思想是,在给定容量的背包中,放入物品可以获得一定的价值,我们需要在不超过背包容量和物品数量的情况下,选择哪些物品可以获得最大的总价值。

对于每个物品,我们可以选择将其放入背包或者不放入背包,因此存在两种状态,称为“放”或“不放”状态。我们用一个二维数组来记录子问题的最优解,如 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示在从前 i i i 个物品中选择一些物品放入容量为 j j j 的背包中所能获得的最大价值,其中 i i i 表示物品数量, j j j 表示背包容量。

对于每个物品,我们通过比较“放”和“不放”两种状态下所能获得的价值,来更新状态转移方程。具体而言,如果当前物品的重量小于背包所剩容量,则可以考虑将该物品放入背包中,使得总价值增加 w i w_i wi(物品 i i i 的价值),同时可使用剩余容量装入前 i − 1 i-1 i1 个物品所能获得的最大价值,即 d p [ i − 1 ] [ j − w i ] dp[i-1][j-w_i] dp[i1][jwi] 与当前状态 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j] 取最大值;否则,必须舍弃该物品,只考虑前 i − 1 i-1 i1 个物品所能获得的最大价值,即 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j]

那么背包的话,可以做出很多推广,例如求解这个数量,方案数之类的。

那么背包模型的核心,其实就是当前这个物品,或者是当前第i步,拿不拿,处理不处理。如果处理怎么样,不处理怎么样,约束条件怎么变。

01 背包

ok,我们先来第一个。我们的核心就是当前这个东西,拿不拿,处理不处理。然后先前说思路的时候,也是有给到这个回溯和递归的写法,我们再仔细对比一下:

import functools

def knapsack(W, wt, val):
    n = len(wt)

    @functools.lru_cache(maxsize=None)
    def dfs(i, w):
        if i < 0:
            return 0
        if wt[i - 1] > w:
            return dfs(i - 1, w)
        else:
            return max(dfs(i - 1, w - wt[i - 1]) + val[i - 1], dfs(i - 1, w))

    return dfs(n, W)

回溯算法实现的 01 背包问题的 Python 代码:

def knapsack(W, wt, val):
    n = len(wt)
    res = 0

    def backtrack(i, w, v):
        nonlocal res
        if i == n or w == 0:
            res = max(res, v)
            return
        if w >= wt[i]:
            backtrack(i + 1, w - wt[i], v + val[i])
        backtrack(i + 1, w, v)

    backtrack(0, W, 0)
    return res

然后的话,我们再写一个递推模板:

def knapsack_01(w, v, c):
    n = len(w)
    dp = [[0] * (c + 1) for _ in range(n)]
    
    for j in range(c + 1):
        dp[0][j] = v[0] if j >= w[0] else 0
    
    for i in range(1, n):
        for j in range(c + 1):
            if j < w[i]:
                dp[i][j] = dp[i - 1][j]
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])
    
    return dp[n - 1][c]

现在的话我们再优化一下,用滚动数组优化:

def knapsack_01(w, v, c):
    n = len(w)
    dp = [0] * (c + 1)
    
    for i in range(n):
        for j in range(c, w[i] - 1, -1):
            dp[j] = max(dp[j], dp[j - w[i]] + v[i])
    
    return dp[c]

由于每次更新状态只需要用到上一行 d p dp dp 数组中的值,因此我们可以将二维数组压缩为一维数组,提高代码的效率和空间利用率。

具体而言,初始化一个长度为 c + 1 c+1 c+1 的一维数组 d p dp dp,遍历每个物品 i i i,在容量为 j j j 的背包中选择放入或不放入当前物品,对应的状态转移方程为:

d p [ j ] = max ⁡ ( d p [ j ] , d p [ j − w [ i ] ] + v [ i ] ) dp[j] = \max(dp[j], dp[j-w[i]]+v[i]) dp[j]=max(dp[j],dp[jw[i]]+v[i])

其中, d p [ j − w [ i ] ] + v [ i ] dp[j-w[i]]+v[i] dp[jw[i]]+v[i] 表示放入当前物品后背包剩余容量的最优解,加上当前物品的价值 v [ i ] v[i] v[i] 即为当前状态的最优解。

完全背包

import functools

def knapsack(W, wt, val):
    n = len(wt)

    @functools.lru_cache(maxsize=None)
    def dfs(i, w):
        if w == 0:
            return 0
        if i <= 0 or w < wt[i - 1]:
            return dfs(i - 1, w)
        else:
            return max(dfs(i, w - wt[i - 1]) + val[i - 1], dfs(i - 1, w))

    return dfs(n, W)

区别就是我们的物品可以多次选择

这里的区别就是第i个物品我可以拿,而且是拿多个,所以dfs(i)种的i表示的是拿编号为i的物品。那么我拿多个的时候,第i个物品拿了,那就是一定拿了,所以不是dfs(i-1…)而是dfs(i)

同样的我们再改为迭代。

def knapsack(W, wt, val):
    n = len(wt)
    dp = [0] * (W + 1)

    for i in range(1, n + 1):
        for w in range(1, W + 1):
            if w >= wt[i - 1]:
                dp[w] = max(dp[w], dp[w - wt[i - 1]] + val[i - 1])

    return dp[W]

分组背包

之后的话是我们的分组背包,在完全背包的基础之上的话,数量有限制,作为了一个分组。
这个的话和完全背包相比就是多了一个数量的约束条件。写出循环时这样的。

def knapsack(W, wt, val):
    n = len(wt)
    dp = [0] * (W + 1)

    for i in range(1, n + 1):
        for w in range(W, -1, -1):
            for j in range(len(wt[i - 1])):
                if w >= wt[i - 1][j]:
                    k = w // wt[i - 1][j]
                    for t in range(k + 1):
                        dp[w] = max(dp[w], dp[w - t * wt[i - 1][j]] + t * val[i - 1][j])

    return dp[W]

同样的我们写成记忆搜索。

def knapsack(W, wt, val):
    n = len(wt)
    dp = [0] * (W + 1)

    for i in range(1, n + 1):
        for w in range(W, -1, -1):
            for j in range(len(wt[i - 1])):
                if w >= wt[i - 1][j]:
                    k = w // wt[i - 1][j]
                    for t in range(k + 1):
                        dp[w] = max(dp[w], dp[w - t * wt[i - 1][j]] + t * val[i - 1][j])

    return dp[W]

二进制优化

当然我们还可以使用二进制进行优化这个分组背包。

将每个物品表示为若干个二进制数位上相同的物品,从而将每个物品数量压缩到 log ⁡ 2 s i \log_2 s_i log2si 个。

具体来说,对于第 i i i 组物品中第 j j j 个物品,我们可以将其分解成若干个重量和价值相等的物品,其数量分别为 2 0 , 2 1 , 2 2 , . . . , 2 k − 1 2^0, 2^1, 2^2, ... , 2^{k-1} 20,21,22,...,2k1,其中 k = ⌊ log ⁡ 2 s i ⌋ + 1 k = \lfloor \log_2 s_i \rfloor + 1 k=log2si+1。然后,我们只需要枚举每个物品的选择方案,就可以得到最优解。

def knapsack(W, wt, val):
    n = len(wt)
    dp = [0] * (W + 1)

    for i in range(1, n + 1):
        k = int(math.log2(len(wt[i - 1]))) + 1
        for j in range(k):
            new_wt = []
            new_val = []
            for l in range(len(wt[i - 1])):
                if l >> j & 1:
                    new_wt.append(wt[i - 1][l])
                    new_val.append(val[i - 1][l])
            for w in range(W, -1, -1):
                for m in range(len(new_wt)):
                    if w >= new_wt[m]:
                        dp[w] = max(dp[w], dp[w - new_wt[m]] + new_val[m])

    return dp[W]

在这个代码中,我们首先定义了背包容量 W、物品重量列表 wt 和物品价值列表 val。然后,我们遍历每个物品组别 i i i,并为第 i i i 组物品中的每个物品拆分成若干个二进制数位上相同的子物品。具体来说,我们对每个物品编号,然后将该编号转换成二进制数,然后将每个二进制数位 j j j 对应的子物品加入到新的重量和价值列表中。

接着,我们按照标准的 0-1 背包问题的方式迭代计算结果。具体来说,我们使用一个二重循环,分别遍历所有可能的物品选择方案和背包容量 w w w 的取值范围。在遍历每个选择方案时,需要将所有被选中的子物品的重量和价值求和,作为当前物品的总重量和总价值。然后,我们使用标准的 0-1 背包迭代式更新状态数组,即 d p [ w ] = max ⁡ ( d p [ w ] , d p [ w − w t ] + v a l ) dp[w] = \max(dp[w], dp[w - wt] + val) dp[w]=max(dp[w],dp[wwt]+val)

递归是这样的:

import functools

@functools.lru_cache(maxsize=None)
def knapsack(W, wt, val, i):
    if i == 0 or W == 0:
        return 0
    
    k = int(math.log2(len(wt[i - 1]))) + 1
    res = 0
    for j in range(k):
        new_wt = []
        new_val = []
        for l in range(len(wt[i - 1])):
            if l >> j & 1:
                new_wt.append(wt[i - 1][l])
                new_val.append(val[i - 1][l])
        for w in range(W, -1, -1):
            for m in range(len(new_wt)):
                if w >= new_wt[m]:
                    res = max(res, knapsack(W - new_wt[m], wt, val, i - 1) + new_val[m])
    
    return res

贪心

用DP当然没问题,但是的话,我们这个问题的话用贪心是比较好的解。
不过毕竟本节是动态规划嘛,所以先说的是动态规划。

def knapsack(W, wt, val):
    n = len(wt)
    items = []
    for i in range(n):
        for j in range(len(wt[i])):
            items.append((val[i][j], wt[i][j]))
    items.sort(key=lambda x: x[0]/x[1], reverse=True)

    res = 0
    for item in items:
        if W >= item[1]:
            res += item[0]
            W -= item[1]
        else:
            res += item[0] * W / item[1]
            break

    return res

我们首先将物品按照单位重量的价值从大到小排序,然后依次选择能够加入背包的物品,直到背包装满或所有物品处理完毕为止。
在使用贪心算法求解分组背包问题时,我们需要将每个物品拆分成若干个子物品,然后将其看作一个个单独的物品来处理,以便计算单位重量的价值。因此,在实际使用时,我们需要将二维列表 wtval 分别展开成一维列表,并将它们打包成元组 (val[i][j], wt[i][j]),表示第 i i i 组物品中的第 j j j 个子物品的价值和重量。

需要注意的是,尽管贪心算法效率高,但它并不能保证一定能够求解得到最优解。在某些情况下,贪心算法求解的结果可能会存在误差。因此,在实际使用时,我们需要根据具体问题结合贪心算法和动态规划等算法来求解分组背包问题,并确定其适用范围。

核心

那么在这里的话,其实发现,很多东西,其实都可以从01背包的递归里面找到影子,然后就是做优化呗。所以掌握背包来说,和其他的动态规划模型来说,一定要掌握它对应的递归。

数字三角形模型

数字三角形是一种经典的动态规划问题,其模型通常表示为一个三角形状的数组,其中每个元素表示一条路径上的权重。目标是从顶部出发,经过下一行相邻的元素一直走到底部,使得所经过的权重之和最大。

以下是一个示例数字三角形:

   7
  3 8
 8 1 0
2 7 4 4
4 5 2 6 5

对于这个数字三角形,从顶部到底部的最大权重之和为 30,即沿着路径 7 -> 3 -> 8 -> 7 -> 5 走到底部时所经过的权重之和。

在解决数字三角形问题时,我们可以使用动态规划的思想,从底部开始向上逐层求解。具体来说,在第 i i i 行中,每个元素 a i , j a_{i,j} ai,j 的最大权重之和可以由下一行相邻的两个元素 a i + 1 , j a_{i+1,j} ai+1,j a i + 1 , j + 1 a_{i+1,j+1} ai+1,j+1 相加得到,即 a i , j = max ⁡ ( a i + 1 , j , a i + 1 , j + 1 ) + a i , j a_{i,j}=\max(a_{i+1,j},a_{i+1,j+1}) + a_{i,j} ai,j=max(ai+1,j,ai+1,j+1)+ai,j。通过这样的方式,我们可以逐渐推导出从顶部到底部的最大权重之和。

def max_path_sum(triangle):
    n = len(triangle)
    dp = triangle[-1]
    
    for i in range(n - 2, -1, -1):
        for j in range(i + 1):
            dp[j] = max(dp[j], dp[j + 1]) + triangle[i][j]

    return dp[0]

他这个的话还有很多类似的问题,比如机器人走方格问题。

这个玩意有个很明显的特点:数字三角形模型通常用于描述一些需要经历多个阶段才能达成最终目标的问题,其中每个阶段对应着数字三角形中的一行。

没事”走两遍“

乍一看这个数字三角形还是挺简单,但是实际上它的变形还是挺多的,我们这边讲的只是最简单的一种情况,但是有些时候的话,我们可能需要走两边得到一个最优解,或者说,我们机器人走方格。就比如原来是这样的:
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第19张图片
现在变成了两个机器人走没并且要求,这两个机器人它走过的路是不能重复的,问有多少种路可以走。

对于一个的时候,我们直接用刚刚给的模板就好了,也就是这样:

def unique_paths(m, n):
    dp = [[0] * n for _ in range(m)]
    dp[0][0] = 1
    
    for i in range(m):
        for j in range(n):
            if i == 0 and j == 0:
                continue
            elif i == 0:
                dp[i][j] = dp[i][j-1]
            elif j == 0:
                dp[i][j] = dp[i-1][j]
            else:
                dp[i][j] = dp[i-1][j] + dp[i][j-1]
    
    return dp[m-1][n-1]

那么两个机器人如何做呢?我们其实可以这样,没错还是分析最后到时候,存在什么情况。如果假设还有障碍物,有些地方不能走怎么办?

存在这样一种可能,假设是机器人A,B,可能是A走上面过来,B从左边过来,或者说B上,A左,他们都是一样的,对结果不会有什么影响(就是相当于换了个字母嘛,当然不会有什么影响了)

如果从记忆搜索的角度出发,我们可以想到和刚刚一个机器人的想法是一样的,我们直接定义一个这样的函数 dfs(i,j,k,l)求的就是从两个机器人到那两个位置的时候的不同路径。我们可以直接怼:

def unique_paths_with_obstacles(m: int, n: int, obstacles: List[List[int]]) -> int:
    # 初始化记忆化数组 dp,并将起点的方案数设为 1
    dp = [[[[-1 for _ in range(m)] for _ in range(n)] for _ in range(m)] for _ in range(n)]
    dp[0][0][m-1][n-1] = 1
    
    # 标记障碍物
    obs = set()
    for x, y in obstacles:
        obs.add((x-1, y-1))
    
    # 定义记忆化搜索函数
    def dfs(i, j, k, l):
        if i < 0 or i >= n or j < 0 or j >= m or k < 0 or k >= n or l < 0 or l >= m:
            return 0
        if dp[i][j][k][l] != -1:
            return dp[i][j][k][l]
        if (i, j) in obs or (k, l) in obs:
            return 0
        if (i == k and j == l):
            return 0
        cnt = dfs(i+1, j, k-1, l) + dfs(i, j+1, k, l-1) + dfs(i-1, j, k+1, l) + dfs(i, j-1, k, l+1)
        dp[i][j][k][l] = cnt
        return cnt
    
    # 调用记忆化搜索函数,获取结果
    return dfs(0, 0, m-1, n-1)

的话我们显然还可以简单分析一下就是:
我们要求两个机器人同时抵达终点,因此,对于机器人A(i1,j2) 和机器人B(i2,j2)来说。他们到到达的位置,上必须满足i1+j1 = i2+j2。这样才能保证,在最后一步的时候,大家都只需要一步就可以同时抵达终点。
同时为了保证,他们之间不会有交点,所以他们必须是相互交错的。也就是这样:
i1<=i2,即第一个机器人的行数小于等于第二个机器人的行数,保证了两个机器人不会交叉穿插。
j1<=j2,即第一个机器人的列数小于等于第二个机器人的列数,保证了第一个机器人先走完一行再走下一行,第二个机器人也是如此。换一句话说,就是他们是沿着对角线走的,也就是从左下角到右上角的对角线(假设A,B是要从左上角到右下角)

def unique_paths_with_obstacles(m: int, n: int, obstacles: List[List[int]]) -> int:
    # 初始化记忆化数组 dp,并将起点的方案数设为 1
    dp = [[[-1 for _ in range(m)] for _ in range(n)] for _ in range(m)]
    dp[0][0][0] = 1
    
    # 标记障碍物
    obs = set()
    for x, y in obstacles:
        obs.add((x-1, y-1))
    
    # 定义记忆化搜索函数
    def dfs(i, j, k):
        if i < 0 or i >= m or j < 0 or j >= n or k < 0 or k >= m:
            return 0
        l = i + j - k
        if l < 0 or l >= n or ((i, j) in obs) or ((k, l) in obs):
            return 0
        if dp[i][j][k] != -1:
            return dp[i][j][k]
        cnt = 0
        if i > 0 and j > 0:
            cnt += dfs(i-1, j, k) + dfs(i, j-1, k)
        if k > 0 and l > 0:
            cnt += dfs(i, j, k-1) + dfs(i, j, k-1)
        dp[i][j][k] = cnt
        return cnt
    
    # 调用记忆化搜索函数,获取结果
    return dfs(m-1, n-1, m-1)

切割模型

这里的这个切割模型的话,其实就是这个《算法导论》里面的那个切钢条模型。

他有个特点就是划分,求解每一个划分左右两边可以拿到的最大值,然后组装成当前这个划分可以拿到的值,之后的话,去得到当前可以进行的所有划分,求出最大的。

那么原问题的话是这样的(直接搬运算法导论了哈):

一个长度为 n n n 的钢条,需要将其切割成若干段,使得切割后的每一段钢条都能够获得最大的总收益。

不同长度的钢条所对应的收益,如下表所示:

钢条长度 收益
1 1
2 5
3 8
4 9
5 10
6 17
7 17
8 20

假设我们现在需要将长度为 n = 8 n=8 n=8 的钢条切割成若干段,并且每段的长度都是钢条长度的整数倍,那么可能的切割方案有:

  • 8:只切一刀,获得收益 20 20 20
  • 4+4:切成两段长度为 4 4 4 的钢条,获得总收益 18 18 18
  • 2+6:切成两段长度为 2 2 2 6 6 6 的钢条,获得总收益 6 + 17 = 23 6+17=23 6+17=23
  • 2+2+4:切成三段长度为 2 2 2 2 2 2 4 4 4 的钢条,获得总收益 1 + 1 + 9 = 11 1+1+9=11 1+1+9=11
  • 2+2+2+2:切成四段长度为 2 2 2 的钢条,获得总收益 1 + 1 + 1 + 1 = 4 1+1+1+1=4 1+1+1+1=4

我们还是先写出递归

from typing import List
import functools

@functools.lru_cache(maxsize=None)
def cut_rod(n: int, prices: List[int]) -> int:
    """
    切钢铁模型的记忆化搜索版本

    Parameters:
        n (int): 钢条长度
        prices (List[int]): 钢条长度和收益之间的映射关系

    Returns:
        int: 钢条切割获得的最大总收益
    """
    if n == 0:
        return 0
    q = float('-inf')
    for i in range(1, n+1):
        q = max(q, prices[i-1] + cut_rod(n-i, prices))
    return q

然后的话,很多变形的话都是基于这个去改就好了,对于Python来说基本上递归写出来了,我们就结束了,Java或者C++的话用DP数组作为缓存即可.

这个迭代的话,也是好写的:

def cut_rod(n: int, prices: List[int]) -> int:
    dp = [0] * (n + 1)
    for i in range(1, n+1):
        for j in range(1, i+1):
            dp[i] = max(dp[i], prices[j-1] + dp[i-j])
    return dp[n]

LIS/LCS

LIS(最长上升子序列)

它的基本的问题描述是这样的:

最长上升子序列(Longest Increasing Subsequence,简称
LIS)是指一个序列中的最长的严格递增子序列。例如,对于序列 [ 10 , 9 , 2 , 5 , 3 , 7 , 101 , 18 ] [10,9,2,5,3,7,101,18] [10,9,2,5,3,7,101,18],它的 LIS 为
[ 2 , 5 , 7 , 101 ] [2,5,7,101] [2,5,7,101],长度为 4 4 4

一般是定义 d p [ i ] dp[i] dp[i] 表示以第 i i i 个元素结束的最长上升子序列长度。

对于第 i i i 个元素,需要遍历前面所有比它小的元素 j j j,如果 n u m s [ j ] < n u m s [ i ] nums[j]nums[j]<nums[i],则可以将第 i i i 个元素添加到第
j j j 个元素的最长上升子序列的末尾,形成一个更长的上升子序列。

因此,状态转移方程可以表示为:

d p [ i ] = max ⁡ j = 0 i − 1 ( d p [ j ] + 1 ) ( n u m s [ j ] < n u m s [ i ] ) dp[i] = \max_{j=0}^{i-1}(dp[j]+1)(nums[j] < nums[i]) dp[i]=j=0maxi1(dp[j]+1)(nums[j]<nums[i])

边界条件为 d p [ i ] = 1 dp[i]=1 dp[i]=1,因为最坏情况下仅有第 i i i 个元素本身构成最长上升子序列,长度为 1 1 1。最终的 LIS 长度为
max ⁡ i = 0 n − 1 ( d p [ i ] ) \max_{i=0}^{n-1}(dp[i]) maxi=0n1(dp[i])

同样的,我们还是从记忆搜索,也就是递归的角度去出发,我们可以直接定义一个dfs(i) ,这个函数表示的就是,以第i个数字结尾的子序列,它的长度是什么.那么这个时候我们就可以这样思考.以i为结尾的序列可能是以i-1前面的任何一个数字作为这个序列的倒数第二个数字,那么它的长度就是以倒数第二个数字为结尾的长度+1,然后这个倒数第二个数可能是前面任何一个比nums[i]小的数

所以我们的记忆搜索可以这样写

from functools import lru_cache

@lru_cache(None)
def dfs(nums, i):
    if i == 0:
        return 1
    max_len = 1
    for j in range(i):
        if nums[j] < nums[i]:
            max_len = max(max_len, 1 + dfs(nums, j))
    return max_len

def longestIncreasingSubsequence(nums):
    n = len(nums)
    max_len = 0
    for i in range(n):
        max_len = max(max_len, dfs(nums, i))
    return max_len

这里我们再改成递推:

def longestIncreasingSubsequence(nums):
    n = len(nums)
    dp = [1] * n
    for i in range(1, n):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j]+1)
    return max(dp)

当然我们也可以进行优化,但是这个就不说了.

LCS(最长公共子序列)

最长公共子序列(Longest Common Subsequence,简称 LCS)是指两个序列中的最长公共子序列。例如,对于序列
[ A C C G G T C G A G T G C G C G G A A G C C G G C C G A A ] [ACCGGTCGAGTGCGCGGAAGCCGGCCGAA] [ACCGGTCGAGTGCGCGGAAGCCGGCCGAA]
[ G T C G T T C G G A A T G C C G T T G C T C T G T A A A ] [GTCGTTCGGAATGCCGTTGCTCTGTAAA] [GTCGTTCGGAATGCCGTTGCTCTGTAAA],它们的 LCS 为
[ G T C G T C G G A A G C C G G C C G A A ] [GTCGTCGGAAGCCGGCCGAA] [GTCGTCGGAAGCCGGCCGAA],长度为 16 16 16

解决 LCS 问题的方法也是使用动态规划。具体来说,可以定义 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示以第一个序列中第 i i i 个元素和第二个序列中第
j j j 个元素结尾的最长公共子序列长度。

当第一个序列中第 i i i 个元素等于第二个序列中第 j j j 个元素时,它们必然属于 LCS 中,因此
d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1 dp[i][j]=dp[i-1][j-1]+1 dp[i][j]=dp[i1][j1]+1。否则, i , j i,j i,j 对应的元素不在 LCS 中,必须从 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j]
d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j1] 中选择一个更长的子序列。因此,状态转移方程可以表示为:

d p [ i ] [ j ] = { d p [ i − 1 ] [ j − 1 ] + 1 , x i = y j max ⁡ ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) , x i ≠ y j dp[i][j] = \begin{cases} dp[i-1][j-1]+1, & x_i=y_j \\ \max(dp[i-1][j], dp[i][j-1]), & x_i \neq y_j \end{cases} dp[i][j]={dp[i1][j1]+1,max(dp[i1][j],dp[i][j1]),xi=yjxi=yj

边界条件为 d p [ i ] [ 0 ] = d p [ 0 ] [ j ] = 0 dp[i][0]=dp[0][j]=0 dp[i][0]=dp[0][j]=0,因为如果其中一个序列的长度为 0 0 0,则 LCS 的长度也为 0 0 0。最终的 LCS
长度为 d p [ m ] [ n ] dp[m][n] dp[m][n],其中 m m m n n n 分别为第一个和第二个序列的长度。

老规矩,我们先从递归开始理解:

from functools import lru_cache

@lru_cache(None)
def LCS(s1,s2,m,n):
    if m == 0 or n == 0: # Base Case
        return 0

    if s1[m-1] == s2[n-1]: # 末尾字符相同,递归求解子问题
        return 1 + LCS(s1,s2,m-1,n-1)
    else: # 末尾字符不同,递归求解两个子问题的最大值
        return max(LCS(s1,s2,m,n-1),LCS(s1,s2,m-1,n))

其实思考的方式是一样的,就是"依赖".

那么递推版本的话,也很好改:

def LCS(s1, s2):
    m, n = len(s1), len(s2)
    dp = [[0] * (n+1) for _ in range(m+1)]

    # 动态转移
    for i in range(1, m+1):
        for j in range(1, n+1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = 1 + dp[i-1][j-1]
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])

    return dp[m][n]

最少编辑距离

那么这里的话,同样的还有一个拓展,就是这个最少编辑距离这个题目,这个就是这边来的.

给定两个字符串 s1 和 s2,通过插入、删除和替换等操作,将字符串 s1 转换为字符串 s2,求最小的操作数。

具体来说,给定字符串 s1 和 s2,设它们的长度分别为 m 和 n。我们可以对 s1 进行如下三种操作:

  • 插入一个字符。
  • 删除一个字符。
  • 替换一个字符。

每次操作都会对字符串 s1 进行改变,目标是将其转换为字符串 s2,并使得所有操作的总数最小。例如,将字符串 “kitten” 转换为字符串
“sitting” 需要进行 3 次操作,具体过程如下:

  1. 将 k 替换为 s;
  2. 在 i 和 t 之间插入字符 s;
  3. 将 e 替换为 g;

因此,最小编辑距离为 3。

同样可以从递归的角度先去思考,考虑当前位置i,然后怎么怎么样如何转移.就知道了,我们这样思考,现在是i,从i-1可以如何到i就好了,现在是两个字符为,就分别考虑,以i结尾和以结尾就好了

from functools import lru_cache

@lru_cache(None)
def minEditDistance(s1, s2):
    m, n = len(s1), len(s2)
    if m == 0:
        return n
    elif n == 0:
        return m

    if s1[m-1] == s2[n-1]:
        res = minEditDistance(s1, s2, m-1, n-1)
    else:
        insert_op = 1 + minEditDistance(s1, s2, m, n-1)
        delete_op = 1 + minEditDistance(s1, s2, m-1, n)
        replace_op = 1 + minEditDistance(s1, s2, m-1, n-1)
        res = min(insert_op, delete_op, replace_op)

    return res

然后改成递推:

def minEditDistance(s1, s2):
    m, n = len(s1), len(s2)
    dp = [[0] * (n+1) for _ in range(m+1)]

    # 初始化边界条件
    for i in range(m+1):
        dp[i][0] = i
    for j in range(n+1):
        dp[0][j] = j

    # 动态转移
    for i in range(1, m+1):
        for j in range(1, n+1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])

    return dp[m][n]

区间模型

在我们这边区间模型是这样的:

就是可以在一个区间上面进行操作的.然后我们分为两个类型,一个是不变类型,还有一个是会变类型:

不变

不变是啥意思,就是单纯的指,这个区间内元素不会改变,比如个数.(从模拟的角度上)
例如最长递增子序列、区间和、最小编辑距离、回文串等等。
我们的思考方式就是,假设一个函数dfs(i,j),这个函数可以帮助我们得到i,j这个区间内你想要的值,然后现在求i-1,j+1这个区间可以怎么做就好了

就比如这个回文问题:
给定一个字符串s,求它的最长回文子序列长度。例如,对于字符串s = “bbbab”,其最长回文子序列为“bbbb”或者“bbabb”,长度为4。

from functools import lru_cache

@lru_cache(None)
def dfs(i, j):
    if i > j:
        return 0
    if i == j:
        return 1
    if s[i] == s[j]:
        return dfs(i+1, j-1) + 2
    else:
        return max(dfs(i+1, j), dfs(i, j-1))

return dfs(0, n-1)

然后改递推:

def longestPalindromeSubseq(s):
    n = len(s)
    dp = [[0] * n for _ in range(n)]

    # 初始化
    for i in range(n):
        dp[i][i] = 1

    # 状态转移
    for length in range(2, n+1):
        for i in range(n-length+1):
            j = i + length - 1
            if s[i] == s[j]:
                dp[i][j] = dp[i+1][j-1] + 2
            else:
                dp[i][j] = max(dp[i+1][j], dp[i][j-1])

    return dp[0][n-1]

会变

从模拟的角度上分析,合并了之后,剩下的石头就会减少,发生改变
没错就是经典的"石子合并问题"

假设有一堆石子,每个石子都有一个权值,现在需要将这些石子合并成一堆。每次可以选择任意两堆石子合并,合并后新的一堆石子的权值为原来两堆石子的权值之和。合并的代价为新的一堆石子的权值之和。问最终将所有的石子合并为一堆的最小代价是多少。

例如,给定一个长度为 n 的数组 stones,表示每个石子的权值。假设 stones 的元素依次为 [4, 1, 2,
10],则将它们按照如下顺序合并:

  • 合并第 1、2 堆石子,得到权值为 5 的新石子堆;
  • 合并第 3、4 堆石子,得到权值为 12 的新石子堆;
  • 合并第 5、6 堆石子,得到权值为 17 的新石子堆;
  • 合并第 7、8 堆石子,得到权值为 27 的新石子堆。

因此,总共需要的代价为:5 + 12 + 17 + 27 = 61。

首先我们还是"依赖(赖皮,函数思想)"思想,我们就直接想,如果我合并好了[1~m] 和 [m+1 ~ n],并且我知道了他们的最小代价.那么当前合并为一个[1 ~ n]的一个区间,是不是就是那两个区间和+合并的代价.是的这个和切钢铁有点类似.

那么计算每一个区间的代价,这里是用到求和,所以我们这里还需要用到前缀和.

这里的话,我们再做一个简单的推广,没错,参考leetcode 上面的 题目,加入一个参数k,表示可以合并相邻的k个石头
(例如刚刚的例子就是k=2的情况):

刚刚的就这样写:

def merge_stones(stones):
    n = len(stones)
    
    # 计算前缀和
    prefix_sum = [0] * (n+1)
    for i in range(1, n+1):
        prefix_sum[i] = prefix_sum[i-1] + stones[i-1]
    
    # 定义状态数组
    f = [[float('inf')] * (n+1) for _ in range(n+1)]
    
    # 初始化状态
    for i in range(1, n+1):
        f[i][i] = 0
    
    # 状态转移
    for len_ in range(2, n+1):  # 枚举区间长度
        for i in range(1, n-len_+2):  # 枚举起点
            j = i+len_-1  # 终点
            for k in range(i, j):  # 枚举最后一次合并的位置
                f[i][j] = min(f[i][j], f[i][k]+f[k+1][j]+prefix_sum[j]-prefix_sum[i-1])
    
    return f[1][n]

加上K之后就这样:

def merge_stones(stones, k):
    n = len(stones)
    
    # 特判无法合并的情况
    if (n-1) % (k-1) != 0:
        return -1
    
    # 计算前缀和
    prefix_sum = [0] * (n+1)
    for i in range(1, n+1):
        prefix_sum[i] = prefix_sum[i-1] + stones[i-1]
    
    # 定义状态数组
    f = [[float('inf')] * (n+1) for _ in range(n+1)]
    
    # 初始化状态
    for i in range(1, n+1):
        f[i][i] = 0
    
    # 状态转移
    for len_ in range(2, n+1):  # 枚举区间长度
        for i in range(1, n-len_+2):  # 枚举起点
            j = i+len_-1  # 终点
            for t in range(i, j, k-1):  # 枚举合并后的新区间结尾位置
                f[i][j] = min(f[i][j], f[i][t] + f[t+1][j])
                
            if (j-i) % (k-1) == 0:  # 如果可以进行一次最终合并
                f[i][j] += prefix_sum[j] - prefix_sum[i-1]
    
    return f[1][n] if f[1][n] < float('inf') else -1

那么递归版本的话,是这样的:

from functools import lru_cache

@lru_cache(maxsize=None)
def merge_stones_memo(stones, k, start, end, prefix_sum):
    n = end - start + 1
    
    # 特判无法合并的情况
    if (n-1) % (k-1) != 0:
        return float('inf')
    
    if n == k:
        # 如果当前区间刚好可以合并成一堆
        res = sub_sum = prefix_sum[end+1] - prefix_sum[start]
        return res
    
    # 合并成一堆需要的代价
    merge_cost = sub_sum = prefix_sum[end+1] - prefix_sum[start]
    for i in range(start, end-(k-2), k-1):
        merge_cost = min(merge_cost, sub_sum + merge_stones_memo(stones, k, i+1, end, prefix_sum))
    
    # 拆分成若干堆需要的最小代价
    split_cost = float('inf')
    for i in range(start, end, k-1):
        left_cost = merge_stones_memo(stones, k, start, i, prefix_sum)
        right_cost = merge_stones_memo(stones, k, i+1, end, prefix_sum)
        split_cost = min(split_cost, left_cost + right_cost)
    
    return min(merge_cost, split_cost)


def merge_stones(stones, k):
    n = len(stones)
    
    # 特判无法合并的情况
    if (n-1) % (k-1) != 0:
        return -1
    
    # 计算前缀和
    prefix_sum = [0] * (n+1)
    for i in range(1, n+1):
        prefix_sum[i] = prefix_sum[i-1] + stones[i-1]
    
    res = merge_stones_memo(tuple(stones), k, 0, n-1, tuple(prefix_sum))
    return res if res < float('inf') else -1

ok,就先这样了,后面当然还有,但是,没办法,太多了.

数据结构(区间操作)

之后的话,就是我们这边的一个数据结构了,这里的话我们就简单一点儿,首先是一些基本的操作,比如前后缀,差分,树状数组,线段树之类的.因为这个真的太多了.所以这边,我们就只是说一下可以快速进行区间操作的内容.

就比如刚刚再做区间模型的时候,用到了一个前缀之和,这样就可以再O(1)的时间复杂度内求到一个区间的和.

前后缀

这个前后缀的话就是说维护从左边到i的信息或者右边到i的信息。
这个是最简单的。

def prefix_sum(arr):
    n = len(arr)
    prefix = [0] * n
    prefix[0] = arr[0]
    for i in range(1, n):
        prefix[i] = prefix[i-1] + arr[i]
    return prefix

def suffix_sum(arr):
    n = len(arr)
    suffix = [0] * n
    suffix[n-1] = arr[n-1]
    for i in range(n-2, -1, -1):
        suffix[i] = suffix[i+1] + arr[i]
    return suffix
    
def prefix_product(arr):
    n = len(arr)
    prefix = [0] * n
    prefix[0] = arr[0]
    for i in range(1, n):
        prefix[i] = prefix[i-1] * arr[i]
    return prefix

def suffix_product(arr):
    n = len(arr)
    suffix = [0] * n
    suffix[n-1] = arr[n-1]
    for i in range(n-2, -1, -1):
        suffix[i] = suffix[i+1] * arr[i]
    return suffix

此外的话,我们还可以维护更多的信息,这个我就不写了,因为我们看实际情况嘛,而且这个也写。

差分

差分是一个非常有用的东西,经常和别的结构组合。他有啥作用呢,就是可以在O(1)的时间复杂度内完成一个区间的修改,然后再O(n)的时间复杂度内完成查询,也就是得到修改后的数据。

然后这里分为一维度和二维的差分。

一维差分

假设原始数组为 a a a,差分数组为 d d d,则有:

d i = a i − a i − 1 d_i = a_i - a_{i-1} di=aiai1

其中, d 0 = a 0 d_0=a_0 d0=a0

通过上面的式子可以看出,差分数组存储的是相邻元素之间的差值。如果我们想要将原数组中某个区间 [ l , r ] [l,r] [l,r] 加上一个数 x x x,则只需要让
d l d_l dl 加上 x x x d r + 1 d_{r+1} dr+1 减去 x x x 即可。这个操作的正确性可以通过画图或者手推来理解。

事实上,这个操作等价于对差分数组中 d l d_l dl d r d_r dr 的元素都加上 x x x,而其余元素不变。具体证明如下:

对于 l ≤ i ≤ r l \leq i \leq r lir,有:

a i ′ = a i + x = ( a i − 1 + d i ) + x = a i − 1 + ( d i + x ) \begin{aligned} a_i' &= a_i + x \\ &= (a_{i-1} + d_i) + x \\ &= a_{i-1} + (d_i+x) \end{aligned} ai=ai+x=(ai1+di)+x=ai1+(di+x)

对于 i > r i > r i>r,有:

a i ′ = a i = ( a i − 1 + d i ) = a i − 1 + d i \begin{aligned} a_i' &= a_i \\ &= (a_{i-1} + d_i) \\ &= a_{i-1} + d_i \end{aligned} ai=ai=(ai1+di)=ai1+di

对于 i < l i < l i<l,有:

a i ′ = a i = ( a i − 1 + d i ) = a i − 1 + d i \begin{aligned} a_i' &= a_i \\ &= (a_{i-1} + d_i) \\ &= a_{i-1} + d_i \end{aligned} ai=ai=(ai1+di)=ai1+di

因此,对差分数组进行区间修改操作等价于对原数组进行区间加操作。

需要注意的是,在使用差分数组进行区间修改时,需要满足修改操作的次数远少于查询操作,并且不要频繁地修改同一个区间。

class DiffPrefixLiner():

    def __init__(self,nums):

        self.n = len(nums)
        self.date = [0]+nums
        self.b = [0]*(len(self.date)+1)

        for i in range(1,self.n+1):
            self.update(i,i,self.date[i])


    def update(self,l,r,k):

        self.b[l]+=k
        self.b[r+1]-=k


    def getAll(self):

        for i in range(1,self.n+1):
            self.date[i] = self.date[i-1]+self.b[i]
        return self.date[1:]

二维差分

这个的话就是对二维数组进行操作,也就是矩阵进行操作。

假设原始二维数组为 a a a,差分数组为 d d d,则有:

d i , j = a i , j − a i − 1 , j − a i , j − 1 + a i − 1 , j − 1 d_{i,j} = a_{i,j} - a_{i-1,j} - a_{i,j-1} + a_{i-1,j-1} di,j=ai,jai1,jai,j1+ai1,j1

其中, d 0 , 0 = a 0 , 0 d_{0,0}=a_{0,0} d0,0=a0,0

通过上面的式子可以看出,差分数组存储的是相邻元素之间的差值。如果我们想要将原数组中某个矩形区域 [ x 1 , y 1 , x 2 , y 2 ] [x_1,y_1,x_2,y_2] [x1,y1,x2,y2]
加上一个数 x x x,则只需要让 d x 1 , y 1 d_{x_1,y_1} dx1,y1 加上 x x x d x 2 + 1 , y 1 d_{x_2+1,y_1} dx2+1,y1 减去
x x x d x 1 , y 2 + 1 d_{x_1,y_2+1} dx1,y2+1 减去 x x x d x 2 + 1 , y 2 + 1 d_{x_2+1,y_2+1} dx2+1,y2+1 加上 x x x 即可。

这个操作比较难以理解,下面给出一个简单的例子:

假设有以下原始二维数组:

[ 2 3 1 5 7 8 3 6 9 ] \begin{bmatrix} 2 & 3 & 1 \\ 5 & 7 & 8 \\ 3 & 6 & 9 \end{bmatrix} 253376189

则它的差分数组为:

[ 2 1 − 2 3 2 − 2 − 2 − 2 3 ] \begin{bmatrix} 2 & 1 & -2 \\ 3 & 2 & -2 \\ -2 & -2 & 3 \end{bmatrix} 232122223

现在,我们想要将原数组中右下角的矩形区域 [ 2 , 1 , 3 , 2 ] [2,1,3,2] [2,1,3,2] 加上 2 2 2。根据上面的操作,我们可以得到:

[ 2 1 − 4 3 4 − 4 − 2 − 4 5 ] \begin{bmatrix} 2 & 1 & -4 \\ 3 & 4 & -4 \\ -2 & -4 & 5 \end{bmatrix} 232144445

在使用差分数组进行区域修改时,也需要满足修改操作的次数远少于查询操作,并且不要频繁地修改同一个区域。


class DiffPrefixMatrix():

    def __init__(self, nums):

        self.rows = len(nums)
        self.cols = len(nums[0])
        self.date = [[0] * (self.cols+1) for _ in range(self.cols+1)]
        self.b = [[0] * (self.cols+2) for _ in range(self.cols+2)]
        self.__init(nums)

    def __init(self,nums):

        for i in range(1,self.rows+1):
            for j in range(1,self.cols+1):

                self.date[i][j] = nums[i-1][j-1]

        for i in range(1,self.rows+1):
            for j in range(1,self.cols+1):
                self.update(i,j,i,j,self.date[i][j])


    def update(self, x1, y1,x2,y2,k):

        self.b[x1][y1]+=k
        self.b[x2+1][y1]-=k
        self.b[x1][y2+1]-=k
        self.b[x2+1][y2+1]+=k


    def getAll(self):

        for i in range(1,self.rows+1):
            for j in range(1,self.cols+1):
                self.date[i][j] = self.date[i-1][j]+self.date[i][j-1]+self.b[i][j]-self.date[i-1][j-1]
        res = [row[1:] for row in self.date[1:]]
        return res



树状数组

刚刚的话,我们发现这个差分的话,有点缺陷,那就是这个修改要大于查询,因为查询的时间复杂度是O(n)的,所以的话我们后面还有一个树状数组。

那么树状数组解决了哪些问题呢,大概可以解决这三种类型的问题:

  1. 区间查询, 单点修改
  2. 区间修改, 单点查询
  3. 区间修改, 区间查询

首先对于第一类问题,也就是使用树状数组最原始的问题,那么一开始,这个树状数组呢,其实是在平衡前缀和的功能,我们知道前缀和可以快速查询到当前位置i之前的和,这个时间复杂度是O(1) 的,但是当我们的原数组进行修改之后的话,我们的前缀数组就需要进行更新此时操作就是O(n)的,所以的话为了平衡这样的时间消耗,有这个树状数组,他的时间复杂度都是O(nlogn)的。

那么此时再配合差分,这样的话我们就可以实现这个区间修改的功能了。

lowbit操作

数组划分

那么整个树状数组的实现非常简单,那么其中一个比较重要的操作其实就是这个lowbit操作,那么这个操作的话其实和这个树状数组的原理有关。
首先我们知道一个数字(十进制)可以用二进制数字求和表示(我们的计算机就是这样操作的)因此对于一个长度为X的数组,我们可以通过二进制为进行划分。那么这里的话就要扯到这个玩意是如何划分的了:

假设一个数X=2^ki + 2^ki-1 + …+ 2^i
也就是一个数的二进制表示假设是这样的: 0100 1010
我们转换十进制的时候其实就是刚刚的公式
ki,ki-1 表示的都是在这个二进制表示当中为1的地方。

所以我们就可以这样把一个长度为X的区间,划分为(x-2^i, x],(x-2^i - 2^i2,x- 2^i]…
这样划分下去,那么显然lowbit的操作其实就是,找到最后的一个1的那个数b,然后X-b就可以划分出一个区间了。

那么此时我们的这个区间划分好了,我们的目的是为了实现这个区间查询和单点的修改。首先看到区间查询,我们要实现这个区间查询,然后区间被划分,所以的话我们就需要一个东西来记录原数组的值和区间的一个值。

红色的就是我们记录值的区间值的玩意。
蓝桥杯之基础算法(Python版)-爆肝-7W字长文_第20张图片
我们用一个大数组来存储区间维护的信息。
那么此时
C[1] = nums[1]
C[2] = C[1]+A[2] = A[1] + A[2]

然后就有个规律:
C[i] = A[i-2^k +1] + …+ A[i]
那么这个k的话就是我们二进制表示里面为1的地方。那么我们的lowbit操作就是这样的,找到这个数(十进制)。

操作

那么现在我们就来看看这个lowbit的操作,这个的话就是这个这个公式:
从位运算的角度我们得到的公式是这样的:

def lowbit(x):
	return x&(~x+1)

然后的话,假设x是正数,~x 得到了x作为负数的反码,之后反码+1得到了-x的补码,然后知道我们的计算机是通过补码运算的,所以的话,我们的函数可以优化为

def lowbit(x);
	return x&(-x)

那么当x为负数的时候 x相当于正数的-x,-x相当于原来正数的x,所以情况是一样的。当然你也可以自己写一遍看看,记住计算机是补码运算就好了。

单点修改,区间查询

ok,我们现在就直接看到代码了,因为代码比较简单,就算你不理解也没关系,你只需要知道怎么用就好了。


class TreeList():

    def __init__(self,nums,maxn=10000):
        self.maxn = maxn
        self.c = [0]*self.maxn
        self.n = len(nums)
        for i in range(1,self.n+1):
            self.update(i,nums[i-1])

    def update(self,i,k):

        while(i<=self.n):
            self.c[i]+=k
            i+=self.lowbit(i)

    def getSum(self,i):
    	# 1~i 之和
        ans = 0
        while(i>0):
            ans += self.c[i]
            i-=self.lowbit(i)
        return ans

    def getSumLR(self,L,R):
        ans = 0
        ans+=self.getSum(R)
        ans-=self.getSum(L-1)
        return ans

    def lowbit(self,x):
        return x&(-x)

区间修改,点单查询

加了个差分数组

class TreeList2():

    def __init__(self,nums,maxn=10000):
        self.maxn = maxn
        self.nums = [0]+nums
        self.c = [0]*self.maxn
        self.n = len(nums)
        self.d = [0]*self.maxn


        for i in range(1,self.n+1):
            self.d[i] = self.nums[i]-self.nums[i-1]
            self.insert(i,self.d[i])

    def insert(self,i,k):

        while(i<=self.n):
            self.c[i]+=k
            i+=self.lowbit(i)
    def update(self,l,r,k):
        self.insert(l,k)
        self.insert(r+1,-k)

    def get(self,i):
        ans = 0
        while(i>0):
            ans += self.c[i]
            i-=self.lowbit(i)
        return ans

    def lowbit(self,x):
        return x&(-x)

区间修改,区间查询

其实这个区间查询当L=R的时候,就是一个单点查询。

class TreeList3():

    def __init__(self,nums,maxn=10000):
        self.maxn = maxn
        self.nums = [0]+nums
        self.c = [0]*self.maxn
        self.n = len(nums)
        self.b = [0]*self.maxn
        for i in range(1,self.n+1):
            self.insert(i,self.nums[

你可能感兴趣的:(突发奇想,Letcode算法专篇,数据结构,算法,蓝桥杯)