【一文弄懂】优先经验回放(PER)论文-算法-代码

文章目录

  • 【一文弄懂】优先经验回放(PER)论文-算法-代码
    • 前言:
      • 综合评价:
      • 继续前言唠叨
  • per论文简述:
    • 参考博客:
    • 背景知识
    • A MOTIVATING EXAMPLE
    • methods:PER
      • 两种确定优先度方法:
    • sumtree 代码实现
    • 重要性采样:
    • 重要性采样ISweight及化简 :
      • ISWeights计算代码:
      • sumtree代码:
      • DQN-PER-Memory代码:
      • TD3-PER代码:
    • TD3+PER的一些简单测试

前言:

这是我replay buffer系列博客的第一篇,PER,接下来会整理HER和另一个叫不出名字的。

ps: 欢迎做强化的同学加群一起学习:
深度强化学习-DRL:799378128

综合评价:

如果算法足够好,没必要用PER.
如果奖励函数足够dense,也没有必要用PER.
因为用了PER速度大大降低,性能不一定会提高。
如果真实场景交互,CPU资源足够,稀疏奖励,可以试试。

继续前言唠叨

去年挖的坑,今年才填上。
最近做了真实机器人实验,发现model-free的探索效率实在是太低了。
而且单个机器人调个参数都花老大劲儿了。
最近半个月都在尝试叠buff,看看能不能把大家常说的HER,PER加上去,让强化学的更快点。

因此上上周调了HER的代码,发现盛名之下,其实难副,首先最原始的her本身就有不少缺陷。在它最擅长的任务中,它还需要花差不多1e6以上的交互回合,才能学会push任务。这和我想象的,一学就会差距太大了。

所以上周三开始捡起去年用了一半的PER,优先经验回放,听说这个也能加速学习,现在整理完了,发现也就那样~

总的来说,目前HER和PER是众多花里胡哨的提升技巧中,最成熟,听起来最make sense的trick了。

但是当时我用的PER是在莫烦的基础改的,莫烦没有实现TD3,我自己在他那个DDPG上改的TD3效果不行(当时没找到错误的原因在哪儿,应该是对TensorFlow理解不够透彻)

因此我需要重写一个基于spinningup风格的PER模块。
并且针对PER,对每个算法(DDPG, TD3,SAC)都写一个RL+PER的类。

前天测试了一下TD3-PER和TD3跑效果。
发现随机性太大!
简直离谱,具体的实验图,放到博客的最后面,有兴趣的可以看看。
下面主要是整理PER的算法和原理。

per论文简述:

参考博客:

(作为学习笔记,很多内容,我就直接复制粘贴了)
借鉴其背景知识和重要性采样— 【DRL-5】Prioritized Experience Replay
借鉴其sumtree部分— 4. [2015] [PER] Prioritized Experience Replay

借鉴其sumtree部分----RL 7.Prioritized Experience Replay经验回放
论文原文链接:
Prioritized Experience Replay

背景知识

之前重用经验(experience,transition转换五元组[s, a, r, s’, d]都是从经验池中均匀采样,忽略了经验的重要程度,文中提到的优先经验回放框架按经验重要性增大其被采样到的概率,希望越重要的经验使用次数越多,从而增加学习效率。
文中应用算法:DQN
效果:相比传统经验池机制,Atari 49游戏中41胜,8负

Prioritized Experience Replay的想法可能来自Prioritized sweeping,这是经典强化学习时代就已经存在的想法了,Sutton那本书上也有说过。

所以Prioritized Experience Replay实际上就是把这个想法应用在DQN上,做了一个“微小的改动”,然而论文莫名的长,性能的提升也相当大。

注意,per最先是用在DQN上的!17年有一个小伙儿用在了DDPG上,测试的任务比较简单,但是测试的超参数比较多,可以作为参考。
我今天要做的是将TD3加上PER,之前也有人做过,但好像也没有放出测试结果。

首先,让我们来回忆一下DQN的Experience Replay是怎么做的,其实无非两点

  • 保持队列的结构,先进先出,当长度到达定值后开始踢人
  • 从整个Buffer中均匀分布随机选择
    然而,在DQN数以千计的样本,也就是 [s, a, r, s’] 中,一定有一些样本可以帮助DQN更快的收敛;或者说,存在一些样本,如果我们先学了这些样本,后面的就不用再学了。

如果我们的样本出现的顺序可以是提前设计好的,那么就一定可以加速训练的过程。

当然,实际上想找到某个“出现顺序”,使得训练速度最快,这涉及到一个复杂的全局搜索,有那工夫还不如直接随机训练呢,但是作者给我们准备了一个例子,用来观察样本次序的重要性。

A MOTIVATING EXAMPLE

对于这种新算法,老喜欢先整一个motivation example了,或者toy study,HER也是。
有了这玩意儿,算法在极端情况下一跑,效果拔群,就会给读者一种make sense的好印象,值得学习。

上图是一个简单的环境,每一个箭头代表了一次状态转移,它们的概率都是1/2。

也就是说,想得到唯一的奖赏,需要从状态 [n] 走绿色的箭头走到状态 [1]。如果我们从状态 [1] 出发,随机游走,出现这种情况的可能是 [1/(2^n)] 。

可以看出,这是一个reward sparse的问题,上面我跑的结果不明显,可能是因为我没有用sparse奖励?

如果按照DQN的方法来,需要漫长的训练,而如果有一个最优的次序,论文中称为“oracle”,则可以指数级的缩减时间。

然而,问题在于,这只是一个小的toy example,我们可以全局搜索去找这个oracle,然而当我们处理实际问题的时候,如何去找这个次序呢?

methods:PER

这就是Prioritized Experience Replay要解决的问题,它从传统RL那里继承了使用TD-error的想法。

TD-error就是指在时序差分中这个当前Q值和它目标Q值的差值。

比如我们有一个样本 [s, a, r, s’] ,那么它的TD-error应该是

当然,考虑到DQN的Target Network,应该是

再考虑到Double DQN,应该是

总之,无论你用哪种方法,你都要算出一个 y ,而TD-error就是

在DQN中,我们训练的目标就是让δ\deltaδ的期望尽可能小,这样来看,我们根据 δ\deltaδ 选择训练次序是有道理的。

当然,实际上我们应该是按照 ∣δ∣|\delta|∣δ∣ 的大小来选择。

从某种角度,我们可以说TD-error表示某个转移有多么“令人惊讶”或“出乎意料”。它指出了当前的Q值和下一步应该追求的Q值差距有多大。

既然按照 abs(δ)abs(\delta)abs(δ) 选择次序,那最简单的方法显然就是greedy,我们可以维护一个二元堆,实现一个优先队列。

取出的时间复杂度是 O(1)O(1)O(1),更新的时间复杂度是 O(n)O(n)O(n) 。老数据结构了。

但是用绝对的次序看起来不太好,比如

很可能一个 (s,a)(s, a)(s,a) 在第一次访问的时候 ∣δ∣|\delta|∣δ∣ 很小,因此被排到后面,但是之后的网络调整让这个的∣δ∣|\delta|∣δ∣变得很大,然而我们很难再调整它了(必须把它前面的都调整完)
当rrr是随机的时候(也就是环境具有随机性), ∣δ∣|\delta|∣δ∣ 的波动就很剧烈,这会让训练的过程变得很敏感。
greedy的选择次序很可能让神经网络总是更新某一部分样本,也就是“经验的一个子集”,这可能陷入局部最优,也可能导致过拟合的发生(尤其是在函数近似的过程中)。
总之,我们应该寻找一种更具有随机性的方法。

两种确定优先度方法:

其实这个过渡还是比较自然的,我们有两种方法可以做到这一点。这两种方法都需要下面的计算式

也就是说,对于Buffer中的每一个样本,我们都计算出一个概率出来,根据这个概率来采样。

proportional prioritization中,我们直接根据 ∣δ∣|\delta|∣δ∣ 决定概率

而rank-based prioritization中,我们根据rank来决定概率

这个 rank(i) 就是第 i 个样本在全体样本中排在多少位,按照对应的 ∣δ∣|\delta|∣δ∣ 由大到小排列。
这种方式更具鲁棒性,因为其对异常点不敏感,主要是因为异常点的TD-error过大或过小对rank值没有太大影响

优点: 其重尾性、厚尾性、heavy-tail property保证采样多样性
分层采样使mini-batch的梯度稳定
缺点: 当在稀疏奖励场景想要使用TD-error分布结构时,会造成性能下降
实际上,在作者们的实验中,这两种方法的表现大致相同。

比较
根据文中实验,两种方式效果基本相同,但不同场景可能一个效果很好,一个效果一般般。作者猜想效果相同的原因可能是因为对奖励和TD-error大量使用clip操作,消除了异常值,作者本以为Rank-based更具鲁棒性的。

Overhead is similar to rank-based prioritization.

两者开销相同。

New transitions arrive without a known TD-error, so we put them at maximal priority in order to guarantee that all experience is seen at least once.

新的经验被存入经验池时不需计算TD-error,直接将其设置为当前经验池中最大的TD-error,保证其至少被抽中一次。
既然使用TD-error作为衡量可学习的度量,那么完全可以用贪婪的方式,选取TD-error最大的几个进行学习,但这会有几个问题:

由于只有在经验被重放之后,这个经验的TD-error才被更新,导致初始TD-error比较小的经验长时间不被使用,甚至永远不被使用。
贪婪策略聚焦于一小部分TD-error比较高的经验,当使用值函数近似时,这些经验的TD-error减小速度很慢,导致这些经验被高频重复使用,致使样本缺乏多样性而过拟合。
文中提到使用随机采样方法 stochastic sampling method在贪婪策略与均匀采样之间“差值”来解决上述问题,其实它是一个在样本使用上的trade-off,由超参数[公式]控制

使用优先经验回放还有一个问题是改变了状态的分布,我们知道DQN中引入经验池是为了解决数据相关性,使数据(尽量)独立同分布的问题。但是使用优先经验回放又改变了状态的分布,这样势必会引入偏差bias,对此,文中使用偏差退火——重要性采样结合退火因子,来消除引入的偏差。

rank的形式没有看到代码实现,也没有看到解析,下面主要讲比例排序的,基于sumtree

sumtree 代码实现

如果每次抽样都需要针对 p 对所有样本排序, 这将会是一件非常消耗计算能力的事. 可以采用更高级的算法——SumTree方法。

SumTree是一种树形结构,每片树叶存储每个样本的优先级P,每个树枝节点只有两个分叉,节点的值是两个分叉的和,所以SumTree的顶端就是所有p的和。正如下面图片(来自Jaromír Janisch), 最下面一层树叶存储样本的p, 叶子上一层最左边的 13 = 3 + 10, 按这个规律相加, 顶层的 root 就是全部p的和了:


抽样时, 我们会将 p 的总和除以 batch size, 分成 batch size 那么多区间, (n=sum ( p) / batch_size). 如果将所有 node 的 priority 加起来是42的话, 我们如果抽6个样本, 这时的区间拥有的 priority 可能是这样:

[0-7], [7-14], [14-21], [21-28], [28-35], [35-42]

然后在每个区间里随机选取一个数. 比如在第区间 [21-28] 里选到了24, 就按照这个 24 从最顶上的42开始向下搜索. 首先看到最顶上 42 下面有两个 child nodes, 拿着手中的24对比左边的 child 29, 如果 左边的 child 比自己手中的值大, 那我们就走左边这条路, 接着再对比 29 下面的左边那个点 13, 这时, 手中的 24 比 13 大, 那我们就走右边的路, 并且将手中的值根据 13 修改一下, 变成 24-13 = 11. 接着拿着 11 和 13 左下角的 12 比, 结果 12 比 11 大, 那我们就选 12 当做这次选到的 priority, 并且也选择 12 对应的数据。

换个例子,所有的经验回放样本只保存在最下面的叶子节点上面,一个节点一个样本。内部节点不保存样本数据。而叶子节点除了保存数据以外,还要保存该样本的优先级,就是图中的显示的数字。对于内部节点每个节点只保存自己的儿子节点的优先级值之和,如图中内部节点上显示的数字。

这样保存有什么好处呢?主要是方便采样。以上面的树结构为例,根节点是42,如果要采样一个样本,那么我们可以在[0,42]之间做均匀采样,采样到哪个区间,就是哪个样本。比如我们采样到了26, 在(25-29)这个区间,那么就是第四个叶子节点被采样到。而注意到第三个叶子节点优先级最高,是12,它的区间13-25也是最长的,会比其他节点更容易被采样到。

如果要采样两个样本,我们可以在[0,21],[21,42]两个区间做均匀采样,方法和上面采样一个样本类似。

重要性采样:

除了经验回放池,现在我们的Q网络的算法损失函数也有优化,之前我们的损失函数是:


现在我们新的考虑了样本优先级的损失函数是:


这里面的区别在于,多了一个ωj\omega_jωj​。

使用了Prioritized Experience Replay之后,样本的分布就被改变了,这可能导致我们的模型收敛到不同的值(把机器学习看作一种密度估计就会很容易理解,当然我没理解…)。

与此同时,我们又不想放弃Prioritized Experience Replay带来的速度提升,那就只能在采样上下功夫。

我们可以使用重要性采样,这样既保证每个样本被选到的概率是不同的(从而提升训练速度),又可以保证它们对梯度下降的影响是相同的(从而保证收敛到相同的结果)。

我们可以设计一个重要性采样的权重ωj\omega_jωj​

这里的NNN就是Buffer里的样本数,而β\betaβ是一个超参数,用来决定你有多大的程度想抵消Prioritized Experience Replay对收敛结果的影响。
如果 β=1\beta=1β=1 ,则代表完全抵消掉影响,这样的话Prioritized Experience Replay和DQN中的Experience Replay就没区别了。

重要性采样ISweight及化简 :

为什么要用重要性采样,原理,可以见我未来的博客…
这个坑先挖着。。。
也可以直接看李宏毅大佬的b站课程-ppo那一节。

这个 ISweight 到底怎么算. 需要提到的一点是, 代码中的计算方法是经过了简化的, 将 paper 中的步骤合并了一些.
比如
prob = p / self.tree.total_p;
ISWeights = np.power(prob/min_prob, -self.beta)

下面是莫烦的推导, 在paper 中,
ISWeight=wj=(N∗Pj)−beta/maxi(wi)ISWeight =w_j= (N*P_j)^{-beta}/max_i(w_i)ISWeight=wj​=(N∗Pj​)−beta/maxi​(wi​)
里面的 maxi(wi)max_i(w_i)maxi​(wi​) 是为了 normalize ISWeight,

这里面的意思是我当前选择的样本是j,我拿到的权重稀疏是wjw_jwj​,但是我要归一化一下,想到的法子是除以所有样本中最大的那个权重样本i,那么用maxi(wi)max_{i}(w_i)maxi​(wi​)来表示。
单纯的 importance sampling 就是(N∗Pj)−beta(N*P_j)^{-beta}(N∗Pj​)−beta,
那 maxi(wi)=maxi[(N∗Pi)−beta]max_i(w_i) = max_i[(N*P_i)^{-beta}]maxi​(wi​)=maxi​[(N∗Pi​)−beta].

如果将这两个式子合并,
ISWeight=(N∗Pj)−beta/maxi[(N∗Pi)−beta]ISWeight = (N*P_j)^{-beta} / max_i[(N*P_i)^{-beta} ]ISWeight=(N∗Pj​)−beta/maxi​[(N∗Pi​)−beta]

而且如果将
maxi[(N∗Pi)−beta]max_i[(N*P_i)^{-beta}]maxi​[(N∗Pi​)−beta] 中的 (-beta) 提出来,
这就变成了
[mini(N∗Pi)]−beta[min_i(N*P_i) ] ^ {-beta}[mini​(N∗Pi​)]−beta

看出来了吧, 有的东西可以抵消掉的. 最后
ISWeight=(Pj/mini(Pi))−betaISWeight = (P_j / min_i(P_i))^{-beta}ISWeight=(Pj​/mini​(Pi​))−beta
这样我们就有了代码中的样子.
或者直接看下面的公式:

ISWeights计算代码:

因此ISWeights的计算代码是这样的:

def sample(self, n):b_idx, b_memory, ISWeights = np.empty((n,), dtype=np.int32), np.empty((n, self.tree.data[0].size)), np.empty((n, 1))pri_seg = self.tree.total_p / n       # priority segment# beta是逐渐增加的,最终为1self.beta = np.min([1., self.beta + self.beta_increment_per_sampling])  # max = 1min_prob = np.min(self.tree.tree[-self.tree.capacity:]) / self.tree.total_p     # for later calculate ISweightfor i in range(n):a, b = pri_seg * i, pri_seg * (i + 1)v = np.random.uniform(a, b)idx, p, data = self.tree.get_leaf(v)prob = p / self.tree.total_pISWeights[i, 0] = np.power(prob/min_prob, -self.beta)b_idx[i], b_memory[i, :] = idx, datareturn b_idx, b_memory, ISWeights

sumtree代码:

class SumTree(object):"""This SumTree code is a modified version and the original code is from:https://github.com/jaara/AI-blog/blob/master/SumTree.pyStory data with its priority in the tree."""data_pointer = 0def __init__(self, capacity):self.capacity = capacity  # for all priority valuesself.tree = np.zeros(2 * capacity - 1)# [--------------Parent nodes-------------][-------leaves to recode priority-------]#             size: capacity - 1                       size: capacityself.data = np.zeros(capacity, dtype=object)  # for all transitions# [--------------data frame-------------]#             size: capacitydef add(self, p, data):tree_idx = self.data_pointer + self.capacity - 1self.data[self.data_pointer] = data  # update data_frameself.update(tree_idx, p)  # update tree_frameself.data_pointer += 1if self.data_pointer >= self.capacity:  # replace when exceed the capacityself.data_pointer = 0def update(self, tree_idx, p):change = p - self.tree[tree_idx]self.tree[tree_idx] = p# then propagate the change through treewhile tree_idx != 0:    # this method is faster than the recursive loop in the reference codetree_idx = (tree_idx - 1) // 2self.tree[tree_idx] += changedef get_leaf(self, v):"""Tree structure and array storage:Tree index:0         -> storing priority sum/ \1     2/ \   / \3   4 5   6    -> storing priority for transitionsArray type for storing:[0,1,2,3,4,5,6]"""parent_idx = 0while True:     # the while loop is faster than the method in the reference codecl_idx = 2 * parent_idx + 1         # this leaf's left and right kidscr_idx = cl_idx + 1if cl_idx >= len(self.tree):        # reach bottom, end searchleaf_idx = parent_idxbreakelse:       # downward search, always search for a higher priority nodeif v <= self.tree[cl_idx]:parent_idx = cl_idxelse:v -= self.tree[cl_idx]parent_idx = cr_idxdata_idx = leaf_idx - self.capacity + 1return leaf_idx, self.tree[leaf_idx], self.data[data_idx]@propertydef total_p(self):return self.tree[0]  # the root

因为 SumTree 有特殊的数据结构, 所以两者都能用一个一维 np.array 来存储。

  • capacity 表示这个SumTree能存多少组数据。
  • self.tree = np.zeros(2 * capacity - 1)中,前capacity - 1个节点为非叶子节点,后capacity个为叶子节点。self.data = - np.zeros(capacity, dtype=object)为所有的优先级,共capacity个。
  • add()函数是:当有新 sample 时, 添加进 tree 和 data。
  • get_leaf(self, v)函数是:根据选取的 v 点抽取样本。
    说了这么多这个class怎么用呢?

这个class说白了就是个存储器,用来存取样本,只不过这个存储器Memory长成了一个树的样子。

我们知道,一个样本不仅包含优先级信息,还包括其他的数据。所以我用self.tree.add(maxp, transition)
这句话完成对它的存储,句中max_p为优先级信息,transition为其他的数据。

self.tree存储优先级信息,self.data存储数据信息。
所以,当要采样的时候,我拿一个随机数v,输入到get_leaf(self, v)这个函数里面:

  • 采样得到的样本是:leaf_idx。
  • 这个样本的优先级是:self.tree[leaf_idx]。
  • 这个样本存储的数据是:self.data[data_idx]。
    最后要明确的是每个信息都可以看成是1个3元组:(tree_idx,p优先级,data数据)。

DQN-PER-Memory代码:

我不复制了,太长了,有需要的直接看下面链接就行了。
https://zhuanlan.zhihu.com/p/160186240

TD3-PER代码:

装上gym就可以直接跑的,欢迎点个star~

https://github.com/kaixindelele/DRL-tensorflow/blob/master/td3_sp/TD3_per_class.py

相比普通的TD3,修改的内容有:

1.增加计算abs_error的句柄:
self.abs_errors = tf.abs(min_q_targ - self.q1)
2.更新q值的时候,增加重要性采样的系数
self.q_loss = self.ISWeights * (q1_loss + q2_loss)
3.修改存储transition,因为per_buffer里面,存的格式是列表,不需要拆解transition。
self.replay_buffer.store(transition)
4.修改更新参数的函数,对buffer的采样,数据格式也不一样:
tree_idx, batch_memory, ISWeights = self.replay_buffer.sample(batch_size=batch_size)
但这个其实改的可以很少。

TD3+PER的一些简单测试




【一文弄懂】优先经验回放(PER)论文-算法-代码相关推荐

  1. 一文弄懂元学习 (Meta Learing)(附代码实战)《繁凡的深度学习笔记》第 15 章 元学习详解 (上)万字中文综述

    <繁凡的深度学习笔记>第 15 章 元学习详解 (上)万字中文综述(DL笔记整理系列) 3043331995@qq.com https://fanfansann.blog.csdn.net ...

  2. 一文弄懂Word2Vec之skip-gram(含详细代码)

    目录 前言 一.什么是Skip-gram算法 二.目标是什么 三.定义表示法 3.1 one-hot向量 3.2 词向量(word vector) 3.3 单词矩阵 3.4 单词相似度 3.5 sof ...

  3. off-policy全系列(DDPG-TD3-SAC-SAC-auto)+优先经验回放PER-代码-实验结果分析

    off-policy全系列(DDPG-TD3-SAC-SAC-auto)+优先经验回放PER-代码-实验结果分析 文章目录 off-policy全系列(DDPG-TD3-SAC-SAC-auto)+优 ...

  4. deque stack java_一文弄懂java中的Queue家族

    简介 java中Collection集合有三大家族List,Set和Queue.当然Map也算是一种集合类,但Map并不继承Collection接口. List,Set在我们的工作中会经常使用,通常用 ...

  5. 一文弄懂halcon例程:rim.hdev

    一文弄懂halcon例程:rim.hdev 打怪的路上总是无聊的,但是也不能不打啊,我自己现在也在每天打怪升级呢.昨天就因为一个问题,我到视觉群问里面的大牛,结果,他不帮我解答,他不告诉我怎么解决就算 ...

  6. 一文弄懂神经网络中的反向传播法

    最近在看深度学习的东西,一开始看的吴恩达的UFLDL教程,有中文版就直接看了,后来发现有些地方总是不是很明确,又去看英文版,然后又找了些资料看,才发现,中文版的译者在翻译的时候会对省略的公式推导过程进 ...

  7. 一文弄懂各种loss function

    有模型就要定义损失函数(又叫目标函数),没有损失函数,模型就失去了优化的方向.大家往往接触的损失函数比较少,比如回归就是MSE,MAE,分类就是log loss,交叉熵.在各个模型中,目标函数往往都是 ...

  8. 一文弄懂神经网络中的反向传播法——BackPropagation【转】

    本文转载自:https://www.cnblogs.com/charlotte77/p/5629865.html 一文弄懂神经网络中的反向传播法--BackPropagation 最近在看深度学习的东 ...

  9. 一文弄懂String的所有小秘密

    文章目录 简介 String是不可变的 传值还是传引用 substring() 导致的内存泄露 总结 一文弄懂String的所有小秘密 简介 String是java中非常常用的一个对象类型.可以说ja ...

最新文章

  1. 测试在强电磁场下基于HALL的电流传感器 ACS712-5A是否会有到影响?
  2. c# 对象json互相转换_C#匿名对象(转JSON)互转、动态添加属性
  3. springboot特点
  4. tcp/udp高并发和高吐吞性能测试工具
  5. ubuntu12.10安装openssh-server与本身的软件冲突
  6. jmeter 采样器作用_实施自定义JMeter采样器
  7. 基于GaussDB(DWS)的全文检索特性,了解一下?
  8. mysql5.7.14启动教程_mysql5.7.14安装配置方法图文详细教程
  9. 用大白话彻底搞懂 HBase RowKey 详细设计!
  10. php 伪造微信浏览器头信息,php使用curl伪造浏览器访问操作示例
  11. CentOS7下 libvirt+virt-manager 虚拟机迁移配置及错误处理
  12. jmeter内存溢出解决办法
  13. 炒币之止损止盈控制的艺术、投资入门
  14. 10个容易被接受的辞职理由
  15. LeetCode-LCP 17. 速算机器人(Goland实现)
  16. 回文数(难度系数:半颗星)
  17. python实现一个学生管理系统
  18. RK3399 Android7.1修改安兔兔等第三方软件读到的内核版本信息
  19. 在Ubuntu下编译运行C语言程序
  20. 滴滴开源 Levin:数据闪电加载方案

热门文章

  1. android 仿蘑菇街首页,高仿蘑菇街欢迎页
  2. 前台CSS样式使用小结
  3. The road to learning English-Writing
  4. 超详解三子棋(优化后)【万字教程包教包会】
  5. ThinkCMF-smeta扩展字段
  6. centos install fcitx
  7. WiFi6模块AP6275S
  8. Linux基础学习——用户权限管理
  9. 编写函数求两个数的最大公约数,采用递归法计算两数的最大公约数。
  10. 回溯法求最佳工作分配方案