强化学习-----DQN(Deep Q-network)

强化学习-----DQN(Deep Q-network)

一、什么是DQN

DQN(Deep Q-Network)是一种基于深度学习和强化学习的算法,由DeepMind提出,用于解决离散动作空间下的马尔科夫决策过程(MDP)问题。它是首个成功将深度学习应用于解决强化学习任务的算法之一。DQN,即深度Q网络(Deep Q-network),是指基于深度学习的Q-Learing算法。

那什么是Q-leaning?可以看上一篇文章

Q-learning是一种经典的强化学习算法,用于解决马尔可夫决策过程(Markov Decision Process,MDP)中的控制问题。它是基于值迭代(Value Iteration)的思想,通过估计每个状态动作对的价值函数Q值来指导智能体在每个状态下选择最佳的动作。

其算法的基本思想跟主要优势如下:

Q-Learning是强化学习算法中value-based的算法,Q即为Q(s,a),就是在某一个时刻的state状态下,采取动作a能够获得收益的期望,环境会根据agent的动作反馈相应的reward奖赏,所以算法的主要思想就是将state和action构建成一张Q_table表来存储Q值,然后根据Q值来选取能够获得最大收益的动作。

但是这种算法存在很大的局限性。在现实中很多情况下,强化学习任务所面临的状态空间是连续的,存在无穷多个状态,这种情况就不能再使用表格的方式存储价值函数。

为了解决这个问题,我们可以用一个函数Q(s,a;w)来近似动作-价值Q(s,a),称为价值函数近似Value Function Approximation,我们用神经网络来生成这个函数Q(s,a;w),称为Q网络(Deep Q-network),w是神经网络训练的参数。

二、DQN的训练过程

在这里插入图片描述

如上图所示,神经网络的的输入是状态s,输出是对所有动作a的打分

神经网络的训练是一个最优化问题,我们需要表示网络输出和标签值之间的差值,作为损失函数,目标是让损失函数最小化,手段是通过反向传播使用梯度下降的方法来更新神经网络的参数。

那么Q网络的标签值/目标值是什么呢?

TD target: y t = r t + γ ⋅ max ⁡ a Q ( s t + 1 , a ; w ) y_{t}=r_{t}+\gamma \cdot \max _{a} Q\left(s_{t+1}, a ; w\right) yt=rt+γmaxaQ(st+1,a;w)

1、初始化网络,输入状态 s t s_t st,输出 s t s_t st下所有动作的Q值

2、  利用策略(例如  ε −  greddy),选择一个动作  a t  ,把  a t  输入到环境中,获得新状态  s t + 1  和  r ;  \text { 利用策略(例如 } \varepsilon-\text { greddy),选择一个动作 } a_{t} \text { ,把 } a_{t} \text { 输入到环境中,获得新状态 } s_{t+1} \text { 和 } \mathrm{r} \text {; }  利用策略(例如 ε greddy),选择一个动作 at ,把 at 输入到环境中,获得新状态 st+1  r

3、  计算TD target:  y t = r t + γ ⋅ max ⁡ a Q ( s t + 1 , a ; w ) \text { 计算TD target: } y_{t}=r_{t}+\gamma \cdot \max _{a} Q\left(s_{t+1}, a ; w\right)  计算TD target: yt=rt+γmaxaQ(st+1,a;w)

4、  计算损失函数:  L = 1 / 2 [ y t − Q ( s , a ; w ) ] 2 \text { 计算损失函数: } L=1 / 2\left[y_{t}-Q(s, a ; w)\right]^{2}  计算损失函数L=1/2[ytQ(s,a;w)]2

5、 更新 Q 参数,使得 Q ( s t , a t ) 尽可能接近 y t ,可以把它当做回归问题,利用梯度下降做更新工作 ; 更新 \mathrm{Q} 参数,使得 \mathrm{Q}\left(s_{t}, a_{t}\right) 尽可能接近 y_{t} ,可以把它当做回归问题,利用梯度下降做更新工作; 更新Q参数,使得Q(st,at)尽可能接近yt,可以把它当做回归问题,利用梯度下降做更新工作;

6、 从以上步骤我们得到一个四元组 transition:  ⁡ ( s t , a t , r t , s t + 1 ) ,用完之后丟弃掉; 从以上步骤我们得到一个四元组 \operatorname{transition:~}\left(s_{t}, a_{t}, r_{t}, s_{t+1}\right) ,用完之后丟弃掉; 从以上步骤我们得到一个四元组transition: (st,at,rt,st+1),用完之后丟弃掉;

7、输入新的状态,重复更新工作

如下图所示:

其实DQN就是 Q-Learning 算法 + 神经网络,可以这样理解Q-learning和DQN的区别

我们知道,Q-Learning 算法需要维护一张 Q 表格,按照下面公式来更新:
Q ( s t , a t ) ← Q ( s t , a t ) + α ⋅ [ r t + γ max ⁡ π Q ( s t + 1 , a t ) − Q ( s t , a t ) ] Q\left(s_{t}, a_{t}\right) \leftarrow Q\left(s_{t}, a_{t}\right)+\alpha \cdot\left[r_{t}+\gamma \max _{\pi} Q\left(s_{t+1}, a_{t}\right)-Q\left(s_{t}, a_{t}\right)\right] Q(st,at)Q(st,at)+α[rt+γπmaxQ(st+1,at)Q(st,at)]
然后学习的过程就是更新 这张 Q表格,如下图所示:

UbsT4s.png

而DQN就是用神经网络来代替这张 Q 表格,其余相同,如下图:

UbsoNj.png

但是他的更新方式哦发生了变化
Q ( S t , A t , w ) ← Q ( S t , A t , w ) + α [ R t + 1 + γ max ⁡ a q ^ ( s t + 1 , a t , w ) − Q ( S t , A t , w ) ] Q\left(S_{t}, A_{t}, w\right) \leftarrow Q\left(S_{t}, A_{t}, w\right)+\alpha\left[R_{t+1}+\gamma \max _{a} \hat{q}\left(s_{t+1}, a_{t}, w\right)-Q\left(S_{t}, A_{t}, w\right)\right] Q(St,At,w)Q(St,At,w)+α[Rt+1+γamaxq^(st+1,at,w)Q(St,At,w)]

$$
其中 \Delta w :

\Delta w=\alpha\left(R_{t+1}+\gamma \max {a} \hat{q}\left(s{t+1}, a_{t}, w\right)-\hat{q}\left(s_{t}, s_{t}, w\right)\right) \cdot \nabla_{w} \hat{q}\left(s_{t}, a_{t}, w\right)
$$

DQN 中还有两个非常重要的模块——经验回放目标网络,它们能够帮助 DQN 取得稳定、出色的性能。

三、经验回放 (Experience Replay)

DQN 第一个特色是使用 Experience replay ,也就是经验回放,为何要用经验回放?还请看下文慢慢详述

在理解经验回放之前,先看看原始DQN算法的缺点:
1、  用完一个transition:  ( s t , a t , r t , s t + 1 )  就丢弃,会造成对经验的浪费;  \text { 用完一个transition: }\left(s_{t}, a_{t}, r_{t}, s_{t+1}\right) \text { 就丢弃,会造成对经验的浪费; }  用完一个transition: (st,at,rt,st+1) 就丢弃,会造成对经验的浪费
2、之前,我们按顺序使用transition,前一个transition和后一个transition相关性很强,这种相关性对学习Q网络是有害的。

经验回放可以克服上面两个缺点:

1.把序列打散,消除相关性,使得数据满足独立同分布,从而减小参数更新的方差,提高收敛速度。
2.能够重复使用经验,数据利用率高,对于数据获取困难的情况尤其有用。

经验回放会构建一个回放缓冲区(replay buffer),存储n条transition,称为经验池。某一个策略π与环境交互,收集很多条transition,放入回放缓冲区,回放缓冲区中的经验transition可能来自不同的策略。回放缓冲区只有在它装满的时候才会吧旧的数据丢掉
在这里插入图片描述

每次随机抽出一个batch大小的transition数据训练网络,算出多个随机梯度,用梯度的平均更新Q网络参数w

下面举一个例子来说明经验回放的优点:

对于网络输入,DQN 算法是把整个游戏的像素作为 神经网络的输入,具体网络结构如下图所示:

UbcZhd.png

第一个问题就是样本相关度的问题,因为在强化学习过程中搜集的数据就是一个时序的玩游戏序列,游戏在像素级别其关联度是非常高的,可能只是在某一处特别小的区域像素有变化,其余像素都没有变化,所以不同时序之间的样本的关联度是非常高的,这样就会使得网络学习比较困难。

DQN的解决办法就是 经验回放(Experience replay)。具体来说就是用一块内存空间 D ,用来存储每次探索获得数据在这里插入图片描述然后按照以下步骤重复进行:

在这里插入图片描述

利用经验回放,可以充分发挥 off-policy 的优势,behavior policy 用来搜集经验数据,而 target policy 只专注于价值最大化。

四、目标网络(Target Network)

**我们在训练网络的时候,动作价值估计和权重w有关。当权重变化时,动作价值的估计也会发生变化。在学习的过程中,动作价值试图追逐一个变化的回报,容易出现不稳定的情况。意思就是我们使用 q ^ ( s t , a t , w ) \hat{q}\left(s_{t}, a_{t}, w\right) q^(st,at,w)来代替 TD Target,在TD Target 中已经包含我了我们要优化的 参数 w。也就是说在训练的时候 监督数据 target 是不固定的,所以就使得训练比较困难。**所以我们需要使用目标网络.

下面举个例子来说明目标网络

我们把 我们要估计的 Q ^ \hat{Q} Q^ 叫做 Q estimation,把它看做一只猫。把我们的监督数据 Q Target 看做是一只老鼠,现在可以把训练的过程看做猫捉老鼠的过程(不断减少之间的距离,类比于我们的 Q estimation 网络拟合 Q Target 的过程)。现在问题是猫和老鼠都在移动,这样猫想要捉住老鼠是比较困难的,如下所示:

UqG8cd.png

那么我们让老鼠在一段时间间隔内不动(固定住),而这期间,猫是可以动的,这样就比较容易抓住老鼠了。在 DQN 中也是这样解决的,我们有两套一样的网络,分别是 Q estimation 网络和 Q Target 网络。要做的就是固定住 Q target 网络,那如何固定呢?比如我们可以让 Q estimation 网路训练10次,然后把 Q estimation 网络更新后的参数 w 赋给 Q target 网络。然后我们再让Q estimation 网路训练10次,如此往复下去,试想如果不固定 Q Target 网络,两个网络都在不停地变化,这样 拟合是很困难的,如果我们让 Q Target 网络参数一段时间固定不变,那么拟合过程就会容易很多。

换成公式中的数据就是:

我们使用第二个网络,称为目标网络, Q ( s , a ; w − ) ,网络结构和原来的网络 Q ( s , a ; w ) 一样,只是参数不同 w − ≠ w ,原来的网络称为评估网络 我们使用第二个网络,称为目标网络, Q\left(s, a ; w^{-}\right) ,网络结构和原来的网络 Q(s, a ; w) 一样,只是参数不同 w^{-} \neq w ,原来的网络称为评估网络 我们使用第二个网络,称为目标网络,Q(s,a;w),网络结构和原来的网络Q(s,a;w)一样,只是参数不同w=w,原来的网络称为评估网络

两个网络的作用不一样:评估网络 Q ( s , a ; w ) 负责控制智能体,收集经验;目标网络 Q ( s , a ; w − ) 用于计算 T D t a r g e t : 两个网络的作用不一样:评估网络 Q(s, a ; w) 负责控制智能体,收集经验;目标网络 Q\left(s, a ; w^{-}\right) 用于计算TD target: 两个网络的作用不一样:评估网络Q(s,a;w)负责控制智能体,收集经验;目标网络Q(s,a;w)用于计算TDtarget:

y t = r t + γ ⋅ max ⁡ a Q ( s t + 1 , a ; w − ) y_{t}=r_{t}+\gamma \cdot \max _{a} Q\left(s_{t+1}, a ; w^{-}\right) yt=rt+γmaxaQ(st+1,a;w)

在更新过程中,只更新评估网络 Q(s, a ; w) 的权重w,目标网络 Q ( s t + 1 , a ; w − ) Q\left(s_{t+1}, a ; w^{-}\right) Q(st+1,a;w) 的权重 $ w^{-}$ 保持不变。在更新一定次数后,再将更新过的评估网络的权重复制给目标网络,进行下一批更新,这样目标网络也能得到更新。由于在目标网络没有变化的一段时间内回报的目标值是相对固定的,因此目标网络的引入增加了学习的稳定性。

下面是 DQN 算法流程图:

UqUf1S.png

如上图所示,首先智能体不断与环境交互,获取交互数据<s,a,r,s′>存入replay memory ,当经验池中有足够多的数据后,从经验池中 随机取出一个 batch_size 大小的数据,利用当前网络计算 Q的预测值,使用 Q-Target 网络计算出 Q目标值,然后计算两者之间的损失函数,利用梯度下降来更新当前 网络参数,重复若干次后,把当前网络的参数 复制给 Q-Target 网络。

五、DQN模型伪代码

img

六、总结

深度 Q 网络(Deep Q-Network,DQN)是一种基于深度学习和强化学习的算法,用于解决离散动作空间的强化学习问题。它是由 DeepMind 提出的,并在解决 Atari 游戏中取得了显著的成功。以下是关于 DQN 的总结:

  1. 基本思想

    • DQN 使用深度神经网络来近似 Q 函数,即状态动作值函数。
    • 通过学习 Q 函数,智能体能够根据当前状态选择最优的动作,以最大化长期累积奖励。
  2. 经验回放

    • DQN 使用经验回放(Experience Replay)来训练神经网络。
    • 经验回放将智能体与环境交互生成的经验存储在一个经验池中,并从中随机抽样用于训练。
    • 这样做的好处是可以平稳训练神经网络,提高样本利用率,并减少样本间的相关性。
  3. 目标网络

    • DQN 使用两个神经网络:一个是主网络(Q 网络),另一个是目标网络(Target Q 网络)。
    • 主网络用于选择动作,目标网络用于计算目标 Q 值。
    • 定期将主网络的参数复制到目标网络中,以稳定训练过程。
  4. 贝尔曼方程

    • DQN 使用贝尔曼方程来更新 Q 值。
    • 通过最小化 Q 函数的均方误差(MSE),将当前状态下的 Q 值与下一个状态的最大 Q 值的估计结合起来更新 Q 函数。
  5. 探索策略

    • DQN 使用 ε-贪婪策略来进行探索和利用的平衡。
    • 在训练过程中,智能体以 ε 的概率选择随机动作,以 1-ε 的概率选择当前最优的动作。
  6. 应用领域

    • DQN 可以应用于解决离散动作空间的强化学习问题,如 Atari 游戏、棋类游戏等。
    • 也可以通过一些技巧和改进应用于连续动作空间的问题,如 Deep Deterministic Policy Gradient (DDPG) 和 Twin Delayed DDPG (TD3)。
  7. 优点

    • DQN 结合了深度学习和强化学习的优势,在很多任务上取得了较好的性能。
    • 可以处理高维状态空间和离散动作空间的问题。
  8. 缺点与挑战

    • 训练 DQN 需要大量的样本和计算资源。
    • 对于一些复杂任务,DQN 可能收敛较慢或陷入局部最优解。

七、代码展示(pytorch)

``

import torch                                    # 导入torch
import torch.nn as nn                           # 导入torch.nn
import torch.nn.functional as F                 # 导入torch.nn.functional
import numpy as np                              # 导入numpy
import gym                                      # 导入gym

# 超参数
BATCH_SIZE = 32                                 # 样本数量
LR = 0.01                                       # 学习率
EPSILON = 0.9                                   # greedy policy
GAMMA = 0.9                                     # reward discount
TARGET_REPLACE_ITER = 100                       # 目标网络更新频率
MEMORY_CAPACITY = 2000                          # 记忆库容量
env = gym.make('CartPole-v0', render_mode="human").unwrapped # 使用gym库中的环境:CartPole,且打开封装(若想了解该环境,请自行百度)
N_ACTIONS = env.action_space.n                  # 杆子动作个数 (2个)
N_STATES = env.observation_space.shape[0]       # 杆子状态个数 (4个)


"""
torch.nn是专门为神经网络设计的模块化接口。nn构建于Autograd之上,可以用来定义和运行神经网络。
nn.Module是nn中十分重要的类,包含网络各层的定义及forward方法。
定义网络:
    需要继承nn.Module类,并实现forward方法。
    一般把网络中具有可学习参数的层放在构造函数__init__()中。
    只要在nn.Module的子类中定义了forward函数,backward函数就会被自动实现(利用Autograd)。
"""


# 定义Net类 (定义网络)
class Net(nn.Module):
    def __init__(self):                                                         # 定义Net的一系列属性
        # nn.Module的子类函数必须在构造函数中执行父类的构造函数
        super(Net, self).__init__()                                             # 等价与nn.Module.__init__()

        self.fc1 = nn.Linear(N_STATES, 50)                                      # 设置第一个全连接层(输入层到隐藏层): 状态数个神经元到50个神经元
        self.fc1.weight.data.normal_(0, 0.1)                                    # 权重初始化 (均值为0,方差为0.1的正态分布)
        self.out = nn.Linear(50, N_ACTIONS)                                     # 设置第二个全连接层(隐藏层到输出层): 50个神经元到动作数个神经元
        self.out.weight.data.normal_(0, 0.1)                                    # 权重初始化 (均值为0,方差为0.1的正态分布)

    def forward(self, x):                                                       # 定义forward函数 (x为状态)
        x = F.relu(self.fc1(x))                                                 # 连接输入层到隐藏层,且使用激励函数ReLU来处理经过隐藏层后的值
        actions_value = self.out(x)                                             # 连接隐藏层到输出层,获得最终的输出值 (即动作值)
        return actions_value                                                    # 返回动作值


# 定义DQN类 (定义两个网络)
class DQN(object):
    def __init__(self):                                                         # 定义DQN的一系列属性
        self.eval_net, self.target_net = Net(), Net()                           # 利用Net创建两个神经网络: 评估网络和目标网络
        self.learn_step_counter = 0                                             # for target updating
        self.memory_counter = 0                                                 # for storing memory
        self.memory = np.zeros((MEMORY_CAPACITY, N_STATES * 2 + 2))             # 初始化记忆库,一行代表一个transition
        self.optimizer = torch.optim.Adam(self.eval_net.parameters(), lr=LR)    # 使用Adam优化器 (输入为评估网络的参数和学习率)
        self.loss_func = nn.MSELoss()                                           # 使用均方损失函数 (loss(xi, yi)=(xi-yi)^2)

    def choose_action(self, x):                                                 # 定义动作选择函数 (x为状态)
        x = torch.unsqueeze(torch.FloatTensor(x), 0)                            # 将x转换成32-bit floating point形式,并在dim=0增加维数为1的维度
        if np.random.uniform() < EPSILON:                                       # 生成一个在[0, 1)内的随机数,如果小于EPSILON,选择最优动作
            actions_value = self.eval_net.forward(x)                            # 通过对评估网络输入状态x,前向传播获得动作值
            action = torch.max(actions_value, 1)[1].data.numpy()                # 输出每一行最大值的索引,并转化为numpy ndarray形式
            action = action[0]                                                  # 输出action的第一个数
        else:                                                                   # 随机选择动作
            action = np.random.randint(0, N_ACTIONS)                            # 这里action随机等于0或1 (N_ACTIONS = 2)
        return action                                                           # 返回选择的动作 (0或1)

    def store_transition(self, s, a, r, s_):                                    # 定义记忆存储函数 (这里输入为一个transition)
        transition = np.hstack((s, [a, r], s_))                                 # 在水平方向上拼接数组
        # 如果记忆库满了,便覆盖旧的数据
        index = self.memory_counter % MEMORY_CAPACITY                           # 获取transition要置入的行数
        self.memory[index, :] = transition                                      # 置入transition
        self.memory_counter += 1                                                # memory_counter自加1

    def learn(self):                                                            # 定义学习函数(记忆库已满后便开始学习)
        # 目标网络参数更新
        if self.learn_step_counter % TARGET_REPLACE_ITER == 0:                  # 一开始触发,然后每100步触发
            self.target_net.load_state_dict(self.eval_net.state_dict())         # 将评估网络的参数赋给目标网络
        self.learn_step_counter += 1                                            # 学习步数自加1

        # 抽取记忆库中的批数据
        sample_index = np.random.choice(MEMORY_CAPACITY, BATCH_SIZE)            # 在[0, 2000)内随机抽取32个数,可能会重复
        b_memory = self.memory[sample_index, :]                                 # 抽取32个索引对应的32个transition,存入b_memory
        b_s = torch.FloatTensor(b_memory[:, :N_STATES])
        # 将32个s抽出,转为32-bit floating point形式,并存储到b_s中,b_s为32行4列
        b_a = torch.LongTensor(b_memory[:, N_STATES:N_STATES+1].astype(int))
        # 将32个a抽出,转为64-bit integer (signed)形式,并存储到b_a中 (之所以为LongTensor类型,是为了方便后面torch.gather的使用),b_a为32行1列
        b_r = torch.FloatTensor(b_memory[:, N_STATES+1:N_STATES+2])
        # 将32个r抽出,转为32-bit floating point形式,并存储到b_s中,b_r为32行1列
        b_s_ = torch.FloatTensor(b_memory[:, -N_STATES:])
        # 将32个s_抽出,转为32-bit floating point形式,并存储到b_s中,b_s_为32行4列

        # 获取32个transition的评估值和目标值,并利用损失函数和优化器进行评估网络参数更新
        q_eval = self.eval_net(b_s).gather(1, b_a)
        # eval_net(b_s)通过评估网络输出32行每个b_s对应的一系列动作值,然后.gather(1, b_a)代表对每行对应索引b_a的Q值提取进行聚合
        q_next = self.target_net(b_s_).detach()
        # q_next不进行反向传递误差,所以detach;q_next表示通过目标网络输出32行每个b_s_对应的一系列动作值
        q_target = b_r + GAMMA * q_next.max(1)[0].view(BATCH_SIZE, 1)
        # q_next.max(1)[0]表示只返回每一行的最大值,不返回索引(长度为32的一维张量);.view()表示把前面所得到的一维张量变成(BATCH_SIZE, 1)的形状;最终通过公式得到目标值
        loss = self.loss_func(q_eval, q_target)
        # 输入32个评估值和32个目标值,使用均方损失函数
        self.optimizer.zero_grad()                                      # 清空上一步的残余更新参数值
        loss.backward()                                                 # 误差反向传播, 计算参数更新值
        self.optimizer.step()                                           # 更新评估网络的所有参数


dqn = DQN()                                                             # 令dqn=DQN类

for i in range(400):                                                    # 400个episode循环
    print('<<<<<<<<<Episode: %s' % i)
    s = env.reset()[0]                                                     # 重置环境
    episode_reward_sum = 0                                              # 初始化该循环对应的episode的总奖励

    while True:                                                         # 开始一个episode (每一个循环代表一步)
        env.render()                                                    # 显示实验动画
        a = dqn.choose_action(s)                                        # 输入该步对应的状态s,选择动作
        s_, r, done, info, _ = env.step(a) # 执行动作,获得反馈                                 # 执行动作,获得反馈

        # 修改奖励 (不修改也可以,修改奖励只是为了更快地得到训练好的摆杆)
        x, x_dot, theta, theta_dot = s_
        r1 = (env.x_threshold - abs(x)) / env.x_threshold - 0.8
        r2 = (env.theta_threshold_radians - abs(theta)) / env.theta_threshold_radians - 0.5
        new_r = r1 + r2

        dqn.store_transition(s, a, new_r, s_)                 # 存储样本
        episode_reward_sum += new_r                           # 逐步加上一个episode内每个step的reward

        s = s_                                                # 更新状态

        if dqn.memory_counter > MEMORY_CAPACITY:              # 如果累计的transition数量超过了记忆库的固定容量2000
            # 开始学习 (抽取记忆,即32个transition,并对评估网络参数进行更新,并在开始学习后每隔100次将评估网络的参数赋给目标网络)
            dqn.learn()

        if done:       # 如果done为True
            # round()方法返回episode_reward_sum的小数点四舍五入到2个数字
            print('episode%s---reward_sum: %s' % (i, round(episode_reward_sum, 2)))
            break                                             # 该episode结束
网络参数进行更新,并在开始学习后每隔100次将评估网络的参数赋给目标网络)
            dqn.learn()

        if done:       # 如果done为True
            # round()方法返回episode_reward_sum的小数点四舍五入到2个数字
            print('episode%s---reward_sum: %s' % (i, round(episode_reward_sum, 2)))
            break                                             # 该episode结束

参考:https://hrl.boyuai.com/chapter/2/dqn%E7%AE%97%E6%B3%95#76-%E5%B0%8F%E7%BB%93
https://blog.csdn.net/weixin_44732379/article/details/127821138?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522171247514916800222877057%2522%252C%2522scm%2522%253A%252220140713.130102334…%2522%257D&request_id=171247514916800222877057&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2alltop_positive~default-1-127821138-null-null.142v100pc_search_result_base6&utm_term=dqn&spm=1018.2226.3001.4187
https://www.cnblogs.com/jsfantasy/p/13623592.html
本文章只用于学习笔记使用,并无其他用途

  • 29
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值