• 适合初学者的神经网络理论到实践(2):理论结合python编程实践介绍反向传播,为何说深度学习离不开反向传播?


    适合初学者的神经网络理论到实践(2):理解并实现反向传播及验证神经网络是否正确

    专栏中《零神经网络实战》系列持续更新介绍神经元怎么工作,最后使用python从0到1不调用任何依赖神经网络框架(不使用tensorflow等框架)来实现神经网络,梯度下降、反向传播、卷积神经网络CNN、循环神经网络RNN。从0基础角度进行神经网络实战。 上一篇:零基础神经网络实战(1):单个神经元+随机梯度下降学习逻辑与规则
    作者: 

    @Ai酱

    实例介绍反向传播,为何说深度学习离不开反向传播?

    我们专栏之前介绍了单个神经元如何利用随机梯度下降自己调节权重。深度学习指的是数以百千层的层数很深的神经网络,每层又有数以百千个神经元。那么深度学习是否也可以使用这种形式的梯度下降来进行调节权重呢?答:很难。为什么?主要原因是太深了。为何“深”用梯度下降解会有问题呢?主要是因为链式法则导致计算损失函数对前面层权重的导数时,损失函数对后面层权重的导数总是被重复计算,反向传播就是将那些计算的值保存减少重复计算。不明白?那这篇文章就看对了。接下来将解释这个重复计算过程。反向传播就是梯度下降中的求导环节,它从后往前计算导数重复利用计算过的导数而已。。梯度下降不懂或者神经网络不懂请先阅读这个文章单个神经元+随机梯度下降学习逻辑与规则

    文章结构:

    1. 为何要使用反向传播?
    2. 反向传播优化神经网络权重参数实践。

    为何要使用反向传播?

    我们用一个最简单的三层神经网络(无激活函数)来进行解释为何需要使用反向传播,所使用的三层神经网络如下所示:

    我们神经网络是可以用公式表示。对于整个网络而言,x​是输入,e​是输出。对于神经网络的各层而言,第一层神经网络的输入是x​输出是y​。那么其用公式表示就是:y=w1*x​.第二层神经网络用公式表示就是:z = w2 * y​。第三层用公式表示是:e = w3 * z​。假设hat e​是真实值,那么损失函数L​可以写成这样:L(w1,w2,w3)=frac{1}2 (e-hat e )^2​这个1/2 只是为了抵消二次方求导所产生的2。其实可以不写的。

    这里我们有三个要优化的参数:w1,w2,w3

    我们先用传统的梯度下降方法来看看哪些地方重复计算了,而反向传播就是将这些重复计算的地方计算结果保存,并且向前面层传播这个值以减少运算复杂度。梯度下降不懂或者神经网络不懂请先阅读这个文章单个神经元+随机梯度下降学习逻辑与规则。梯度下降需要先得求损失函数对w1,w2,w3的导数,然后根据导数来不断迭代更新w1,w2,w3的值。

    1. 第1层:求损失函数L(w1,w2,w3)​w1​的导数(更规范的讲是偏导数)。

    我们先看看损失函数L(w1,w2, w3)w1之间的关系是什么。

    L(w1,w2,w3)=frac{1}2 (e-hat e )^2

    e = w3 * z​

    z= w2*y​

    y = w1 *x ​

    所以这是个复合函数的求导。根据高中学习到的复合函数求导法则(也是大家可能经常听到的链式法则),复合函数求导等于各级复合函数导数的乘积,也就是这样frac{dL} {dw1} =frac{dL} {de} * frac{de} {dz} * frac{dz} {dy} * frac {dy} {dw1}​

    1. 第2层:求损失函数L(w1,w2, w3 )w2的导数。

    我们再看看损失函数L(w1,w2, w3)​w2​之间的关系是什么。

    L(w1,w2, w3)=frac{1}2 (e-hat e )^2 ​

    e = w3 * z

    z= w2*y​

    根据复合函数求导可得:frac{dL} {dw2} = frac{dL} {de} * frac{de} {dz} * frac{dz} {dw2}​

    1. 第3层:求损失函数L(w1,w2, w3 )w3的导数。

    我们再看看损失函数L(w1,w2, w3)​w3​之间的关系是什么。

    L(w1,w2, w3)=frac{1}2 (e-hat e )^2 ​

    e = w3 * z

    根据复合函数求导可得:frac{dL} {dw3} = frac{dL} {de} * frac{de} {dw3}

    我们将这三层的损失函数对相应层权重导数列在一起看看哪儿重复计算了:

    • 第1层:frac{dL} {dw1} =frac{dL} {de} * frac{de} {dz} * frac{dz} {dy} * frac {dy} {dw1}​
    • 第2层:frac{dL} {dw2} = frac{dL} {de} * frac{de} {dz} * frac{dz} {dw2}​
    • 第3层:frac{dL} {dw3} = frac{dL} {de} * frac{de} {dw3}

    我们会发现,最前面的那层即第1层使用的frac{dL} {de}​frac{de} {dz}​已经在后面的两层中计算过了。并且每层都重复计算了frac{dL} {de}​。为了更清晰的突出哪些是重复计算的,我将那些重复计算部分提取出来。

    • 第1层:frac{dL} {de} * frac{de} {dz} * frac{dz} {dy} ​
    • 第2层:frac{dL} {de} * frac{de} {dz} ​
    • 第3层:frac{dL} {de} ​

    那么,我们是不是把这些公共部分的计算从最后一层开始计算导数,并把结果往前面传?可以计算完了第3层的result3 = frac{dL} {de},就把它传到第2层。计算完了第2层的result2 = frac{dL} {de} * frac{de} {dz} =result3 * frac{de} {dz},就将结果传到第1层。然后第一层就可以这样写:result1 =frac{dL} {de} * frac{de} {dz} * frac{dz} {dy}=result2 *  frac{dz} {dy}。这样就可以不重复计算那些已经算出来的导数。

    我们再看看这个图:

    所搭建的最简单的三层神经网络

    你会发现,如果将损失函数也看做是一层的话。即我们认为e->L(w1,w2,w3)这也算一层。

    找规律:

    • 第3层传给第2层的公共导数值 frac{dL} {de} ​=e- hat e 可以视作,是最后一层的输出对输入的导数
    • 第2层传给第1层的公共导数值 frac{dL} {de} * frac{de} {dz} ​ 可以视作,后一层传过来的的公共导数值*后一层的输出对输入的导数
    • 第1层的公共导数值 frac{dL} {de} * frac{de} {dz} * frac{dz} {dy} ​(如果可以再往前传的话)可以视作,后一层传过来的的公共导数值*后一层的输出对输入的导数
    • 规律:第i层的公共导数值 = 后一层传过来的公共导数值*后一层的输出对输入的导数

    反向传播过程理解

    前面我们提到了可以从后面往前面计算,将公共部分的导数往前传。这只是解决了求导问题,那怎么进行参数更新呢?答:参数更新跟梯度下降完全一样,都是这个公式w_i = w_i - alpha frac{dL} {dw_i}。反向传播就是梯度下降中的求导环节,它重复利用计算过的导数而已。

    我们看看反向传播版的使用梯度下降进行参数更新是怎样的。

    损失函数L是这样:L(w1,w2, w3)=frac{1}2 (e-hat e )^2

    其他的几层函数如下所示:

    • 第3层:e = w3 * z​
    • 第2层:z= w2*y​
    • 第1层:y = w1 *x ​

    在这篇文章“单个神经元+随机梯度下降学习逻辑与规则”介绍了,权重更新是一个不断猜(迭代更新)的过程。下一次权重值 = 本次权重值 - 学习率*损失函数对该权重的导数。定义学习率为alpha. 接下来我们只需要知道怎么利用后面层数传递过来的值来求“损失函数对当前层权重w_i​的导数”即可。

    则各层网络更新权重的步骤为如下所示:

    1. 更新第3层权重参数w3

    计算损失函数对第3层权重的导数frac{dL} {dw3}​,用公式表示是这样,frac{dL} {dw3} = frac{dL} {de} * frac{de} {dw3}​。由于frac{dL} {de} ​还会被前面几层用到,我们用result3​保存它。即令result3 = frac{dL} {de} = (e-hat e)​。所以frac{dL} {dw3} = result3 * frac{de} {dw3} = result3 * z​

    • 所以,权重w3​的迭代更新表达式为:w3 = w3 - alpha * frac{dL} {dw3} = w3 - result3 * z​
    • 然后result3 == frac{dL} {de},传递给上一层(即第2层)。省得它们又重新计算一次,这就是反向传播(backpropagation)。这也是大家常说的BP神经网络。BP是backpropagation的简写。
    • 更新第2层权重参数w2​

    利用后一层(即第3层)传递过来的值result3 = frac{dL} {de} ​来计算损失函数对w2​的导数frac{dL} {dw2} = frac{dL} {de} * frac{de} {dz} * frac{dz} {dw2} ​。直接使用了result3​这样就不用再计算frac{dL} {de}​这个导数。由于前一层(第1层)会使用frac{dL} {de} * frac{de} {dz}​,于是乎我们将这个公共导数保存。令result2 = frac{dL} {de} * frac{de} {dz} = result3 * frac{de} {dz} ​

    • 那么,frac{dL} {dw2} = result2 * frac{dz} {dw2} = result2 * y​。权重w2​的迭代更新表达式为:w2 = w2 - alpha * frac{dL} {dw2} = w2 - result2 * y​
    • result2 == frac{dL} {de} * frac{de} {dz} ​传递给前一层(第1层)。
    • 更新第1层权重参数w1 ​

    到了第一层就不用再向前传播导数了。损失函数对第一层权重w1的导数为frac{dL} {dw1} =frac{dL} {de} * frac{de} {dz} * frac{dz} {dy} * frac {dy} {dw1} = result2 * frac{dz} {dy} * frac {dy} {dw1} = result2 * w2 * x

    • 权重w1​的迭代更新表达式为:w1 = w1 - alpha * frac{dL} {dw1} = w1 - result2 * w2 *x​
    • 由于已经是最前面这层,所以不用向前面传播。

    所以,将上面3步用伪代码写可以写成下面这样。(吐槽知乎对打公式的不友好支持,不得不截图)

    终于可以告别可怕的公式了,越难的东西你坚持了,你的不可替代性就越强。加油你不是一个人在奋斗。

    反向传播实践

    我们将上面的伪代码转成Python代码。我们希望这个神经网络能自己学习到的功能是输入x输出的e=-x

    我们提供训练集数据(我们只有两条数据):

    输入数据x 数据标签hat {e} = -x​​ 
    1 -1 ​
    -1 1

    重复训练次数epoch = 160。

    好开工实现它。

    # -*- coding: UTF-8 -*-
    """
    @author 知乎:@Ai酱
    """
    class NeuralNetwork:
    
        def __init__(self):
            self.LEARNING_RATE = 0.05 # 设置学习率
            # 初始化网络各层权重(权重的初试值也会影响神经网络是否收敛)
            # 博主试了下权重初始值都为0.2333是不行的
            self.w3 = -0.52133
            self.w2 = -0.233
            self.w1 = 0.2333
            self.data = [1, -1] # 输入数据
            self.label= [-1, 1]
    
        def train(self):
            epoch = 160
            for _ in range(epoch):
                # 逐个样本进行训练模型
                for i in range(len(self.data)):
                    x = self.data[i]
                    e_real = self.label[i]
    
                    self.y = self.w1 * x #计算第1层输出
                    self.z = self.w2 * self.y # 计算第2层输出
                    self.e = self.w3 * self.z # 计算第3层输出
    
                    # 开始反向传播优化权重
                    self.result3 = self.e - e_real
                    self.w3 = self.w3 - self.LEARNING_RATE * self.result3 * self.z
    
                    self.result2 = self.result3 * self.w3
                    self.w2 = self.w2 - self.LEARNING_RATE * self.result2 * self.y
    
                    self.w1 = self.w1 - self.LEARNING_RATE * self.result2 * self.w2 * x
    
        def predict(self,x):
            self.y = self.w1 * x #计算第1层输出
            self.z = self.w2 * self.y # 计算第2层输出
            self.e = self.w3 * self.z # 计算第3层输出
            return 1 if self.e>0 else -1
    nn = NeuralNetwork()
    nn.train()
    print(1,',',nn.predict(1))
    print(-1,',',nn.predict(-1))
    '''
    输出:
    1 , -1
    -1 , 1
    '''
    

    如何检验反向传播是否写对?

    手动推导,人工判断

    前面提到了反向传播本质是梯度下降,那么关键在于导数必须对。我们现在网络比较小可以直接手动计算导数比对代码中对各权重导数是否求对。

    比如上面代码中三个参数的导数将代码中的result*展开表示就是:

    dw3 = (self.e - e_real) * self.z
        = (self.e - e_real) * self.w2 * self.y
        =  (self.e - e_real) * self.w2 * self.w1 * x
        = (self. w3 * self.w2 * self.w1 * x - e_real) * self.w2 * self.w1 * x
    
    dw2 = (self.e - e_real) * self.w3* self.y
        = (self.e - e_real) * self.w3* self.w1 * x
        = (self. w3 * self.w2 * self.w1 * x - e_real) * self.w3* self.w1 * x
    
    dw3 =  self.result3 * self.z
        = (self.e - e_real) * self.w2 * self.w1 * x
        = (self. w3 * self.w2 * self.w1 * x - e_real)* self.w2 * self.w1 * x
    

    而损失函数展开可以表示为:L(w1,w2,w3) = frac {1} 2 (w3 * w2 * w1 * x - hat e)^2​

    对各权重参数求导为:

    frac{dL} {dw3} = (w3 * w2 * w1 * x - hat e) * w2 * w1

    frac{dL} {dw2} = (w3 * w2 * w1 * x - hat e) * w3 * w1

    frac{dL} {dw1} = (w3 * w2 * w1 * x - hat e) * w3 * w2​

    可以发现我们代码展开,与我们实际的公式求导是一致的证明我们代码是正确的。

    但是,一旦层数很深,那么我们就不能这么做了

    我们需要用代码自动判断是否反向传播写对了。

    代码自动判断反向传播的导函数是否正确

    这个和手工判断方法类似。反向传播是否正确,关键在于frac {dL} {dw_i}是否计算正确。根据高中学过的导数的定义,对于位于点	heta的导数f'(	heta)有:

    f'(	heta) = lim_{epsilon	o 0} frac{f(	heta + epsilon) + f(	heta - epsilon) } {2epsilon}

    所以我们可以看反向传播求的导函数值和用导数定义求的导函数值是否接近。

    即我们需要让代码判断这个式子是否成立:frac{dL} {dwi} approx frac {L(wi+ 10^{-4}) - L(wi- 10^{-4})} {2* 10^{-4}}​

    左边的frac{dL} {dwi}是反向传播求得,右边是导数定义求的导数值。这两个导数应当大致相同。如果对你有帮助,希望点个赞支持下博主。

    实践:程序自动检验导函数是否正确:

    新增了一个梯度检验函数check_gradient(),如下所示:

    # -*- coding: UTF-8 -*-
    """
    @author 知乎:@Ai酱
    """
    class NeuralNetwork:
    
        def __init__(self):
            self.LEARNING_RATE = 0.05 # 设置学习率
            # 初始化网络各层权重(权重的初试值也会影响神经网络是否收敛)
            # 博主试了下权重初始值都为0.2333是不行的
            self.w3 = -0.52133
            self.w2 = -0.233
            self.w1 = 0.2333
    
            self.data = [1, -1] # 输入数据
            self.label= [-1, 1]
        def L(self,w1,w2,w3,x,e_real):
            '''
            损失函数 return 1/2 * (e - e_real)^2
            '''
            return 0.5*(w1*w2*w3*x - e_real)**2
    
        def train(self):
            epoch = 160
            for _ in range(epoch):
                # 逐个样本进行训练模型
                for i in range(len(self.data)):
                    x = self.data[i]
                    e_real = self.label[i]
    
                    self.y = self.w1 * x #计算第1层输出
                    self.z = self.w2 * self.y # 计算第2层输出
                    self.e = self.w3 * self.z # 计算第3层输出
    
                    # 开始反向传播优化权重
                    self.result3 = self.e - e_real
                    self.w3 = self.w3 - self.LEARNING_RATE * self.result3 * self.z
    
                    self.result2 = self.result3 * self.w3
                    self.w2 = self.w2 - self.LEARNING_RATE * self.result2 * self.y
    
                    self.w1 = self.w1 - self.LEARNING_RATE * self.result2 * self.w2 * x
                    self.check_gradient(x,e_real)
    
        def check_gradient(self,x,e_real):
            # 反向传播所求得的损失函数对各权重的导数
            dw3 = self.result3 * self.z
            dw2 = self.result2 * self.y
            dw1 = self.result2 * self.w2 * x
    
            # 使用定义求损失函数对各权重的导数
            epsilon = 10**-4 # epsilon为10的4次方
            # 求损失函数在w3处的左极限和右极限
            lim_dw3_right = self.L(self.w1, self.w2, self.w3+epsilon, x, e_real)
            lim_dw3_left = self.L(self.w1, self.w2, self.w3-epsilon, x, e_real)
            # 利用左右极限求导
            lim_dw3 = (lim_dw3_right - lim_dw3_left)/(2*epsilon)
    
            lim_dw2_right = self.L(self.w1, self.w2+epsilon, self.w3, x, e_real)
            lim_dw2_left = self.L(self.w1, self.w2-epsilon, self.w3, x, e_real)
            lim_dw2 = (lim_dw2_right - lim_dw2_left)/(2*epsilon)
    
            lim_dw1_right = self.L(self.w1+epsilon, self.w2, self.w3, x, e_real)
            lim_dw1_left = self.L(self.w1-epsilon, self.w2, self.w3, x, e_real)
            lim_dw1 = (lim_dw1_right - lim_dw1_left)/(2*epsilon)
    
            # 比对反向传播求的导数和用定义求的导数是否接近
            print("dl/dw3反向传播求得:%f,定义求得%f"%(dw3,lim_dw3))
            print("dl/dw2反向传播求得:%f,定义求得%f"%(dw2,lim_dw2))
            print("dl/dw1反向传播求得:%f,定义求得%f"%(dw1,lim_dw1))
    
        def predict(self,x):
            self.y = self.w1 * x #计算第1层输出
            self.z = self.w2 * self.y # 计算第2层输出
            self.e = self.w3 * self.z # 计算第3层输出
            return 1 if self.e>0 else -1
    nn = NeuralNetwork()
    nn.train()
    print(1,',',nn.predict(1))
    print(-1,',',nn.predict(-1))
    '''
    输出:
    dl/dw1反向传播求得:-0.026729,定义求得-0.026727
    dl/dw3反向传播求得:0.003970,定义求得0.004164
    dl/dw2反向传播求得:-0.032617,定义求得-0.033257
    dl/dw1反向传播求得:-0.027502,定义求得-0.027499
    dl/dw3反向传播求得:0.004164,定义求得0.004367
    dl/dw2反向传播求得:-0.033272,定义求得-0.033932
    dl/dw1反向传播求得:-0.028291,定义求得-0.028288
    dl/dw3反向传播求得:0.004367,定义求得0.004579
    dl/dw2反向传播求得:-0.033947,定义求得-0.034625
    dl/dw1反向传播求得:-0.029097,定义求得-0.029094
    ... ...
    1 , -1
    -1 , 1
    '''
    

    可以发现反向传播求得损失函数对各参数求得的导数和我们用高中学的定义法求导数,两者基本一致,证明我们反向传播求得的导数没有问题。

    附上上面的易读版代码的github代码下载地址:https://github.com/varyshare/newbie_neural_network_practice/blob/master/backpropagation.py

    框架化反向传播

    每个程序员都有一个写框架的梦想,不如我们将前面的代码写个类似TensorFlow这种框架的反向传播简单框架吧。附上框架版的代码github下载地址:https://github.com/varyshare/newbie_neural_network_practice/blob/master/backpropagation%5Cbackpropagation_framework.py

    # -*- coding: utf-8 -*-
    """
    框架化反向传播编程
    @author: 知乎@Ai酱
    """
    
    import random
    class Layer(object):
        '''
        本文中,一层只有一个神经元,一个神经元只有一个输入一个输出
        '''
        def __init__(self,layer_index):
            '''
            layer_index: 第几层
            '''
            self.layer_index = layer_index
            # 初始化权重[0,1] - 0.5 = [-0.5,0.5]保证初始化有正有负
            self.w = random.random() - 0.5 
            # 当前层的输出
            self.output = 0
            
        def forward(self,input_var):
            '''
            前向传播:对输入进行运算,并将结果保存
            input_var: 当前层的输入
            '''
            self.input = input_var
            self.output = self.w * self.input
            
        
        def backward(self, public_value):
            '''
            反向传播:计算上层也会使用的导数值并保存
            假设当前层的计算规则是这样output = f(input),
            而 input == 前一层的输出,
            因此,根据链式法则损失函数对上一层权重的导数 = 后面层传过来的公共导数* f'(input) * 前一层的导数
            也就是说,后面层传过来的公共导数值* f'(input) 是需要往前传的公用的导数值。
            由于本层中对输入做的运算为:output = f(input) = w*input
            所以, f'(input) = w.
            public_value: 后面传过来的公共导数值
            '''
            # 当前层要传给前面层的公共导数值 = 后面传过来的公共导数值 * f'(input)
            self.public_value = public_value * self.w
            # 损失函数对当前层参数w的导数 = 后面传过来的公共导数值 * f'(input) * doutput/dw
            self.w_grad = self.public_value * self.input
        
        def upate(self, learning_rate):
            '''
            利用梯度下降更新参数w
            参数迭代更新规则(梯度下降): w = w - 学习率*损失函数对w的导数
            learning_rate: 学习率
            '''
            self.w = self.w - learning_rate * self.w_grad
        
        def display(self):
            print('layer',self.layer_index,'w:',self.w)
    
    class Network(object):
        def __init__(self,layers_num):
            '''
            构造网络
            layers_num: 网络层数
            '''
            self.layers = []
            # 向网络添加层
            for i in range(layers_num):
                self.layers.append(Layer(i+1))#层编号从1开始
        
        def predict(self, sample):
            '''
            sample: 样本输入
            return 最后一层的输出
            '''
            output = sample
            for layer in self.layers:
                layer.forward(output)
                output = layer.output
            return 1 if output>0 else -1
        
        def calc_gradient(self, label):
            '''
            从后往前计算损失函数对各层的导数
            '''
            # 计算最后一层的导数
            last_layer = self.layers[-1]
            # 由于损失函数=0.5*(last_layer.output - label)^2
            # 由于backward中的public_value = 输出对输入的导数
            # 对于损失函数其输入是last_layer.output,损失函数对该输入的导数=last_layer.output - label
            # 所以 最后一层的backward的public_value = last_layer.output - label
            last_layer.backward(last_layer.output - label)
            public_value = last_layer.public_value
            for layer in self.layers:
                layer.backward(public_value) # 计算损失函数对该层参数的导数
                public_value= layer.public_value
                
        def update_weights(self, learning_rate):
            '''
            更新各层权重
            '''
            for layer in self.layers:
                layer.upate(learning_rate)
            
        
        def train_one_sample(self, label, sample, learning_rate):
            self.predict(sample) # 前向传播,使得各层的输入都有值
            self.calc_gradient(label) # 计算各层导数
            self.update_weights(learning_rate) # 更新各层参数
            
        def train(self, labels, data_set, learning_rate, epoch):
            '''
            训练神经网络
            labels: 样本标签
            data_set: 输入样本们
            learning_rate: 学习率
            epoch: 同样的样本反复训练的次数
            '''
            for _ in range(epoch):# 同样数据反复训练epoch次保证权重收敛
                for i in range(len(labels)):#逐样本更新权重
                    self.train_one_sample(labels[i], data_set[i], learning_rate)
    
    nn = Network(3)
    data_set = [1,-1]
    labels   = [-1,1]
    learning_rate = 0.05
    epoch = 160
    nn.train(labels,data_set,learning_rate,epoch)
    print(nn.predict(1)) # 输出 -1
    print(nn.predict(-1)) # 输出 1

    如果对你有帮助,希望点个赞支持下博主。

    看了这篇文章的人还看了:

    难道现在真的大学除了计算机软件之类出路都不好吗?

    计算的本质是什么?

    国内研究生怎么读?

    计算机生态圈是怎样的

    导师给了一个很前沿的研究课题,平时主要靠自己,要如何尽快找到突破点?

    知乎 https://www.zhihu.com/people/yuanmuou/activities
  • 相关阅读:
    linux下LD_PRELOAD的用处
    三个通用的脚本,处理MySQL WorkBench导出表的JSON数据进SQLITE3
    ubuntu 18.04下,KMS_6.9.1服务器启动后,客户端连接一段时间因为libnice而crash的问题修复
    Daliy Algorithm(线段树&组合数学) -- day 53
    Daliy Algorithm(链表&搜索&剪枝) -- day 52
    Daliy Algorithm(二分&前缀和) -- day 51
    每日算法
    动态规划--01背包模型
    每日算法
    每日算法
  • 原文地址:https://www.cnblogs.com/ailitao/p/11047309.html
Copyright © 2020-2023  润新知