zoukankan      html  css  js  c++  java
  • Pytorch 基本操作

    Pytorch 基础操作

    主要是在读深度学习入门之PyTorch这本书记的笔记。强烈推荐这本书

    1. 常用类numpy操作

    torch.Tensor(numpy_tensor)

    torch.from_numpy(numpy_tensor)

    GPU上的Tensor不能直接转换为Numpy ndarry,要用.cpu()将其转换到CPU

    # 第一种方式是定义 cuda 数据类型
    dtype = torch.cuda.FloatTensor # 定义默认 GPU 的 数据类型
    gpu_tensor = torch.randn(10, 20).type(dtype)
    
    # 第二种方式更简单,推荐使用
    gpu_tensor = torch.randn(10, 20).cuda(0) # 将 tensor 放到第一个 GPU 上
    gpu_tensor = torch.randn(10, 20).cuda(1) # 将 tensor 放到第二个 GPU 上
    # 将tensor放回CPU
    cpu_tensor = gpu_tenor.cpu()
    
    1. 得到tensor大小
      .size()
      得到tensor数据类型
      .type()
      得到tensor的维度
      .dim()
      得到tnsor的所有元素个数
      .numel()

    2. 全1矩阵。数据类型是floatTensor
      torch.ones(n, m)
      转化为整型数据/浮点型数据
      .long() .float()
      返回一个张量,包含了从区间[0, 1)的均匀分布中抽取的一组随机数
      torch.rand(n, m)
      返回张量,包含了从标准正态分布(均值为0,方差为1,即高斯白噪声)中抽取的一组随机数。张量的形状由参数sizes定义

      torch.randn(n, m)

      返回一个1维张量,包含在区间start和end上均匀间隔的step个点
      torch.linspace(start, end, steps=100, out=None, dtype=None, layout=torch.strided, device=None, requires_grad=False)

      沿着维度dim取最大值
      max_value, max_index = torch.max(x, dim)
      沿着维度dim对x求和
      torch.sum(x, dim)

      维度的变换:

      # 在第n维增加
      x.unsqueeze(n)
      # 减少一维
      x.squeeze(n)
      # 将 tensor 中所有的一维全部都去掉
      x.squeeze()
      # 重新排列维度
      x.permute(a, b, c)
      #交换tensor中的两个维度
      x.transpose(a, b)
      # view的操作。(reshape进阶版)
      x.view(-1, b) # -1表示任意大小
      x.view(a, b)
      x.view_as(others) # 这个挺方便的
      # 就是将x reshape成 others的形状
      

      Tips:
      pytorch中大多数的操作都支持 inplace 操作,也就是可以直接对 tensor 进行操作而不需要另外开辟内存空间,方式非常简单,一般都是在操作的符号后面加_

      inplace参数的理解:
      修改一个对象时:
      inplace=True:不创建新的对象,直接对原始对象进行修改;
      inplace=False:对数据进行修改,创建并返回新的对象承载其修改结果

    2. Variable及自动求导机制

    导入:
    from torch.autograd import Variable

    将Tensor变成Variable
    x = Variable(x_tensor, requires_grad=True)

    每个 Variabel都有三个属性,Variable 中的 tensor本身.data,对应 tensor 的梯度.grad以及这个 Variable 是通过什么方式得到的.grad_fn

    求梯度操作:

    x_tensor = torch.randn(10, 5)
    y_tensor = torch.randn(10, 5)
    
    # 将 tensor 变成 Variable
    x = Variable(x_tensor, requires_grad=True) # 默认 Variable 是不需要求梯度的,所以我们用这个方式申明需要对其进行求梯度
    y = Variable(y_tensor, requires_grad=True)
    
    z = torch.sum(x + y)
    print(z.data)
    print(z.grad_fn)
    
    # 求 x 和 y 的梯度
    z.backward()
    
    print(x.grad)
    print(y.grad)
    

    通过调用 backward 我们可以进行一次自动求导,如果我们再调用一次 backward,会发现程序报错,没有办法再做一次。这是因为 PyTorch 默认做完一次自动求导之后,计算图就被丢弃了,所以两次自动求导需要手动设置一个东西
    x = Variable(torch.FloatTensor([3]), *requires_grad*=True)
    y = x * 2 + x ** 2 + 3
    y.backward(*retain_graph*=True)
    设置 retain_graph 为 True 来保留计算图

    **Tips: **

    PyTorch0.4中,.data 仍保留,但建议使用 .detach(), 区别在于 .data 返回和 x 的相同数据 tensor, 但不会加入到x的计算历史里,且require s_grad = False, 这样有些时候是不安全的, 因为 x.data 不能被 autograd 追踪求微分 。 .detach() 返回相同数据的 tensor ,且 requires_grad=False ,但能通过 in-place 操作报告给 autograd 在进行反向传播的时候.
    举例:
    tensor.data

    >>> a = torch.tensor([1,2,3.], requires_grad =True)
    >>> out = a.sigmoid()
    >>> c = out.data
    >>> c.zero_()
    tensor([ 0., 0., 0.])
    
    >>> out                   #  out的数值被c.zero_()修改
    tensor([ 0., 0., 0.])
    
    >>> out.sum().backward()  #  反向传播
    >>> a.grad                #  这个结果很严重的错误,因为out已经改变了
    tensor([ 0., 0., 0.])
    

    tensor.detach()

    >>> a = torch.tensor([1,2,3.], requires_grad =True)
    >>> out = a.sigmoid()
    >>> c = out.detach()
    >>> c.zero_()
    tensor([ 0., 0., 0.])
    
    >>> out                   #  out的值被c.zero_()修改 !!
    tensor([ 0., 0., 0.])
    
    >>> out.sum().backward()  #  需要原来out得值,但是已经被c.zero_()覆盖了,结果报错
    RuntimeError: one of the variables needed for gradient
    computation has been modified by an
    

    此Tips从梦家的博文摘抄而来

    3.构建网络

    pytorch中有很多内置数学函数
    import torch.nn.functional as F
    来导入,例如:
    F.sigmoid()

    torch.clamp(input, min, max, out=None) → Tensor
    来限制输入的上限和下限

    手动更新参数其实挺麻烦的,可以用
    torch.optim和数据类型nn.Parameter来操作
    不过nn.Parameter 是默认要求梯度的
    nn.optim.SGD可以用梯度下降法来更新参数
    例:

    # 使用 torch.optim 更新参数
    from torch import nn
    w = nn.Parameter(torch.randn(2, 1))
    b = nn.Parameter(torch.zeros(1))
    
    def logistic_regression(x):
        return F.sigmoid(torch.mm(x, w) + b)
    
    optimizer = torch.optim.SGD([w, b], lr=1.)
    
    # 进行 1000 次更新
    import time
    
    start = time.time()
    for e in range(1000):
        # 前向传播
        y_pred = logistic_regression(x_data)
        loss = binary_loss(y_pred, y_data) # 计算 loss
        # 反向传播
        optimizer.zero_grad() # 使用优化器将梯度归 0
        loss.backward()
        optimizer.step() # 使用优化器来更新参数
        # 计算正确率
        mask = y_pred.ge(0.5).float()
        acc = (mask == y_data).sum().data[0] / y_data.shape[0]
        if (e + 1) % 200 == 0:
            print('epoch: {}, Loss: {:.5f}, Acc: {:.5f}'.format(e+1, loss.data[0], acc))
    during = time.time() - start
    print()
    print('During Time: {:.3f} s'.format(during))
    

    有几个关键操作:
    optimizer.zero_grad()
    归零梯度。相当于w.grad.data.zero_()
    optimizer.step()
    用优化器更新参数。相当于https://blog.csdn.net/lens___/article/details/83960810
    w.data = w.data - 0.1 * w.grad.data
    再举个栗子:

    for e in range(100):
        out = logistic_regression(Variable(x))
        loss = criterion(out, Variable(y))
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        if (e + 1) % 20 == 0:
            print('epoch: {}, loss: {}'.format(e+1, loss.data[0]))
    

    上面的都是线性网络的例子,下面举一个神经网络的例子。用到nn.Parameter

    # 定义两层神经网络的参数
    w1 = nn.Parameter(torch.randn(2, 4) * 0.01) # 隐藏层神经元个数 2
    b1 = nn.Parameter(torch.zeros(4))
    
    w2 = nn.Parameter(torch.randn(4, 1) * 0.01)
    b2 = nn.Parameter(torch.zeros(1))
    
    # 定义模型
    def two_network(x):
        x1 = torch.mm(x, w1) + b1
        x1 = F.tanh(x1) # 使用 PyTorch 自带的 tanh 激活函数
        x2 = torch.mm(x1, w2) + b2
        return x2
    
    optimizer = torch.optim.SGD([w1, w2, b1, b2], 1.)
    
    criterion = nn.BCEWithLogitsLoss()
    
    # 我们训练 10000 次
    for e in range(10000):
        out = two_network(Variable(x))
        loss = criterion(out, Variable(y))
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        if (e + 1) % 1000 == 0:
            print('epoch: {}, loss: {}'.format(e+1, loss.data[0]))
    

    记录一个决策边界绘制代码

    def plot_decision_boundary(model, x, y):
        # Set min and max values and give it some padding
        x_min, x_max = x[:, 0].min() - 1, x[:, 0].max() + 1
        y_min, y_max = x[:, 1].min() - 1, x[:, 1].max() + 1
        h = 0.01
        # Generate a grid of points with distance h between them
        xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))
        # Predict the function value for the whole grid
        Z = model(np.c_[xx.ravel(), yy.ravel()])
        Z = Z.reshape(xx.shape)
        # Plot the contour and training examples
        plt.contourf(xx, yy, Z, cmap=plt.cm.Spectral)
        plt.ylabel('x2')
        plt.xlabel('x1')
        plt.scatter(x[:, 0], x[:, 1], c=y.reshape(-1), s=40, cmap=plt.cm.Spectral)
    

    np.meshgrid的作用:
    知乎专栏
    contour和contourf都是画三维等高线图的,不同点在于contour() 是绘制轮廓线,contourf()会填充轮廓.。详细说明:
    CSDN

    4. Sequential 和 Module

    Sequential

    # Sequential基本操作
    seq_net = nn.Sequential(
        nn.Linear(2, 4), # PyTorch 中的线性层,wx + b
        nn.Tanh(),
        nn.Linear(4, 1)
    )
    

    序列模块可以通过索引访问每一层
    sq_net[0] 第一层
    可以得到出第一层的权重
    w0 = seq_net[0].weight
    通过parameters可以取得模型的参数
    param = seq_net.parameters()

    模型的保存

    1.
    将参数和模型保存在一起
    torch.save(seq_net, save_seq_net.pth')
    参数一个是模型,一个是路径

    读取保存的模型:
    seq_net1 = torch.load('save_seq_net.pth')
    2.
    保存模型参数
    torch.save(seq_net.state_dict(), save_seq_net_params.pth')
    通过上面的方式,我们保存了模型的参数,如果要重新读入模型的参数,首先我们需要重新定义一次模型,接着重新读入参数
    读入参数操作:
    seq_net2.load_state_dict(toech.load('save_seq_net_params.pth))

    Module
    Module模板:

    class 网络名字(nn.Module):
        def __init__(self, 一些定义的参数):
            super(网络名字, self).__init__()
            self.layer1 = nn.Linear(num_input, num_hidden)
            self.layer2 = nn.Sequential(...)
            ...
            
            定义需要用的网络层
            
        def forward(self, x): # 定义前向传播
            x1 = self.layer1(x)
            x2 = self.layer2(x)
            x = x1 + x2
            ...
            return x
    

    注意的是,Module 里面也可以使用 Sequential,同时 Module 非常灵活,具体体现在 forward 中,如何复杂的操作都能直观的在 forward 里面执行。(想要亲身体会请看一些论文源码),里面可以用各种数据处理.
    建议自己实现一个resnet网络。可以很快熟悉基本操作,以后论文基本上都是用这个网络

    Module中,访问模型的某一层可以直接通过名字来访问:
    l1 = mo_net.lay1(这是基本的操作吧)
    访问权重:l1.weight

    定义完网络,就可以for in 来训练网络了。
    直接:
    out = mo_net(Variable(x))
    就可以得到output

    保存模型一样,还是用
    .state_dict()

    5. 数据的读取操作(MNIST为例)

    pytorch是内置了MNIST的
    from torchvision.datasets import mnist
    然后就可以通过内置函数来下载mnist数据集了
    train_set = mnist.MNIST('./data', *train*=True, *download*=True)
    test_set = mnist.MNIST('./data', *train*=False, *download*=True)

    注:数据结构是这样的:
    a_data, a_label = train_set[i]
    a_data指的是图片矩阵,a_label则是对应的标签
    读入的数据的PIL库中的格式
    最好转换为numpy array格式来:
    a_data = np.array(a_data, dtype='float32')

    接下来就对a_data进行处理,由于要用神经元,所以得拉平,用reshape操作。当然还要正则化等数据处理。然后就可以正常进行了。用softmax函数作为评价函数即可。用BCELoss(交叉熵损失)。

    注:用 _, pred = out.max(1)来记录准确度。0维是batch维,共64, 1维则是通过网络预测出来的评分结果维,有10个,我们取最大评分的pred即可。最后通过

    .max(dim)方法中,若是2维函数,则是0代表每列的最大值,1代表每行的最大值

    训练的时候,要用DataLoader定义一个数据迭代器
    注意!这里可以进行很多操作!比如说数据的处理,图片的分割等等!

    from torch.utils.data import DataLoader
    # 使用 pytorch 自带的 DataLoader 定义一个数据迭代器
    train_data = DataLoader(train_set, batch_size=64, shuffle=True)
    test_data = DataLoader(test_set, batch_size=128, shuffle=False)
    

    使用这样的数据迭代器是非常有必要的,如果数据量太大,就无法一次将他们全部读入内存,所以需要使用 python 迭代器,每次生成一个批次的数据

    上面只是简单举一个例子。实际应用的时候最好单独写一个文件.方便修改

    然后用
    a, a_label = next(iter(train_data))

    注意:
    net.train()是进入训练模式(train集)
    net.eval()是进入预测模式(test集)

    一般来说打印要打印:
    epoches, Train_Loss, Train_Acc, Eval_Loss, Eval_Acc。方便比较
    并且,在训练和测试的过程中,要用losses,acces, eval_losses和 eval_acces集合来实时保存训练或者测试出来的loss和acc。然后训练完可以画出图来,方便对数据进行分析改进

    6. 初始化参数操作

    from torch.nn import init
    Xavier初始化:
    init,xavier_uniform(net[0].weight)
    用Xavier初始化方法初始化网络的第一层
    还有很多初始化方法。可以查阅:
    简书

    7. pytorch中实现一些优化器的方法

    1. SGD
    # 手动实现
    def sgd_update(parameters, lr):
        for param in parameters:
            param.data = param.data - lr * param.grad.data
    

    调用内置函数:
    optimzier = torch.optim.SGD(net.parameters(), learning_rate)

    1. 动量法

      # 手动实现
      def sgd_momentum(parameters, vs, lr, gamma):
          for param, v in zip(parameters, vs):
              v[:] = gamma * v + lr * param.grad.data
              param.data = param.data - v
      

      调用内置函数:
      torch.optim.SGD(momentum=0.9)
      仅仅在SGD函数中加一个动量变量就行了

    2. Adagrad 自适应学习率优化算法
      Adagrad 的核心想法就是,如果一个参数的梯度一直都非常大,那么其对应的学习率就变小一点,防止震荡,而一个参数的梯度一直都非常小,那么这个参数的学习率就变大一点,使得其能够更快地更新
      (frac{eta}{s+epsilon})

    def sgd_adagrad(parameters, sqrs, lr):
        eps = 1e-10
        for param, sqr in zip(parameters, sqrs):
            sqr[:] = sqr + param.grad.data ** 2
            div = lr / tortorch.optim.Adagrad()ch.sqrt(sqr + eps) * param.grad.data
            param.data = param.data - div
    

    调用内置函数:
    torch.optim.Adagrad(net.parameters(), lr=1e-2)

    1. RMSProp
      Adagrad 算法有一个问题,就是学习率分母上的变量 s 不断被累加增大,最后会导致学习率除以一个比较大的数之后变得非常小,这不利于我们找到最后的最优解,所以 RMSProp 的提出就是为了解决这个问题。

      用移动平均来计算这个s

      [s_i = alpha s_{i-1} + (1 - alpha) g^2 ]

    [frac{eta}{sqrt{s + epsilon}} ]

    g为当前求出的参数梯度,(alpha)为移动平均的系数

    # 手动实现
    def rmsprop(parameters, sqrs, lr, alpha):
        eps = 1e-10
        for param, sqr in zip(parameters, sqrs):
            sqr[:] = alpha * sqr + (1 - alpha) * param.grad.data ** 2
            div = lr / torch.sqrt(sqr + eps) * param.grad.data
            param.data = param.data - div
    

    用内置函数:
    torch.optim.RMSprop()

    1. Adadelta
      Adadelta 跟 RMSProp 一样,先使用移动平均来计算 s

      [s = ho s + (1 - ho) g^2 ]

      这里 ( ho) 和 RMSProp 中的 (alpha) 都是移动平均系数,g 是参数的梯度,然后我们会计算需要更新的参数的变化量

      [g' = frac{sqrt{Delta heta + epsilon}}{sqrt{s + epsilon}} g ]

      (Delta heta) 初始为 0 张量,每一步做如下的指数加权移动平均更新

      [Delta heta = ho Delta heta + (1 - ho) g'^2 ]

      最后参数更新如下

      [ heta = heta - g' ]

    # 手动实现(反正我没怎么看这部分)
    def adadelta(parameters, sqrs, deltas, rho):
        eps = 1e-6
        for param, sqr, delta in zip(parameters, sqrs, deltas):
            sqr[:] = rho * sqr + (1 - rho) * param.grad.data ** 2
            cur_delta = torch.sqrt(delta + eps) / torch.sqrt(sqr + eps) * param.grad.data
            delta[:] = rho * delta + (1 - rho) * cur_delta ** 2
            param.data = param.data - cur_delta
    

    调用函数:
    torch.optim.Adadelta(net.parameters(), rho= 0.9)

    1. Adam
      现在一般都用adam

      # 手动实现
      def adam(parameters, vs, sqrs, lr, t, beta1=0.9, beta2=0.999):
          eps = 1e-8
          for param, v, sqr in zip(parameters, vs, sqrs):
              v[:] = beta1 * v + (1 - beta1) * param.grad.data
              sqr[:] = beta2 * sqr + (1 - beta2) * param.grad.data ** 2
              v_hat = v / (1 - beta1 ** t)
              s_hat = sqr / (1 - beta2 ** t)
              param.data = param.data - lr * v_hat / torch.sqrt(s_hat + eps)
      

      调用函数:
      torch.optim.Adam(net.parameters(), lr=1e-3)

    8. 卷积神经网络的构建

    • 卷积在pytorch中有两种方式
      torch.nn.Conv2d()
      torch.nn.functional.conv2d()
      两个本质是一样的,输入的要求也是一样的
      输入的是一个torch.autograd.Variable()类型,大小为(batch, channel, H, W)

      使用 nn.Conv2d()相当于直接定义了一层卷积网络结构,而使用 torch.nn.functional.conv2d() 相当于定义了一个卷积的操作,所以使用后者需要再额外去定义一个 weight,而且这个 weight 也必须是一个 Variable,而使用 nn.Conv2d() 则会帮我们默认定义一个随机初始化的 weight,如果我们需要修改,那么取出其中的值对其修改,如果不想修改,那么可以直接使用这个默认初始化的值,非常方便

      实际使用中我们基本都使用 nn.Conv2d() 这种形式

    • 池化操作也有两种方法:
      nn.MaxPool2d()
      torch.nn.functional.max_pool2d()

    • 批标准化 Batch Normalization

      首先肯定要对数据进行数据预处理。
      现在一般是进行中心化和标准化。PCA和白化很少用了。
      这里要注意,中心化和标准化的时候,使用的方差和均值统统都是用训练集的数据。包括预处理测试集和验证集数据的时候。

      批标准化,简而言之,就是对于每一层网络的输出,对其做一个归一化,使其服从标准的正态分布,这样后一层网络的输入也是一个标准的正态分布,所以能够比较好的进行训练,加快收敛速度。

    pytorch 当然也为我们内置了批标准化的函数,一维和二维分别是
    torch.nn.BatchNorm1d() torch.nn.BatchNorm2d()
    pytorch 不仅将 γγ 和 ββ 作为训练的参数,也将 moving_meanmoving_var 也作为参数进行训练

    9. 数据增强操作

    常用的数据增强方法如下:
    1.对图片进行一定比例缩放
    2.对图片进行随机位置的截取
    3.对图片进行随机的水平和竖直翻转
    4.对图片进行随机角度的旋转
    5.对图片进行亮度、对比度和颜色的随机变化

    这些方法一般是用torchvision中的transforms来进行操作,还有PIL库中的image,以及sys库用来操作文件

    import sys
    from PIL import image
    from torchvision import transforms as tfs
    
    # 读入一张图片
    im = Image.open('./cat.png')
    

    比例缩放:
    new_im = tfs.Resize((100, 200))(image)

    随机位置截取:
    在 torchvision 中主要有下面两种方式
    一个是 torchvision.transforms.RandomCrop()
    传入的参数就是截取出的图片的长和宽,对图片在随机位置进行截取
    第二个是 torchvision.transforms.CenterCrop()
    同样传入截取初的图片的大小作为参数,会在图片的中心进行截取

    随机水平翻转(镜像)
    torchvision.transforms.RandomHorizontalFlip()

    随机竖直翻转: torchvision.transforms.RandomVerticalFlip()

    随机角度旋转:
    torchvision.transforms.RandomRotation(a)
    a是角度

    亮度,对比度和颜色的变化
    torchvision.transforms.ColorJitter(brightness=1, contrast=1, hue=0.5, (R,G,B))
    第一个参数就是亮度的比例,第二个是对比度,第三个是饱和度,第四个是颜色

    brightness: 随机从 0 ~ 2 之间亮度变化,1 表示原图
    contrast: 随机从 0 ~ 2 之间对比度变化,1 表示原图
    hue: 随机从 -0.5 ~ 0.5 之间对颜色变化

    上面这么多图像增强方法,其实是可以联合起来用的。比如先做随机翻转,然后随机截取,再做对比度增强等等,torchvision 里面有个非常方便的函数能够将这些变化合起来,就是 torchvision.transforms.Compose()

    # 举例im_aug = tfs.Compose([
        tfs.Resize(120),
        tfs.RandomHorizontalFlip(),
        tfs.RandomCrop(96),
        tfs.ColorJitter(brightness=0.5, contrast=0.5, hue=0.5)
    ])
    

    10 正则化操作与学习率衰减

    regularzation现在很少用dropout, 而是用正则化来惩罚权重。
    torch.optim.SGD(net.parameters(), lr=0.1, weight_decay=1e-4)
    weight_decay参数就是权重衰减。 意思就是正则化。这是L2正则。
    注意正则项的系数的大小非常重要,如果太大,会极大的抑制参数的更新,导致欠拟合,如果太小,那么正则项这个部分基本没有贡献,所以选择一个合适的权重衰减系数非常重要.一般尝试会用1e-4或者1e-3来进行。

    在 pytorch 中学习率衰减非常方便,使用 torch.optim.lr_scheduler

    或者用参数组的方式实现:
    参数组:就是我们可以将模型的参数分成几个组,每个组定义一个学习率。这个参数组是一个字典,里面有很多属性,比如学习率,权重衰减等等
    例:optimizer.param_groups[0]['lr']
    optimizer.param_groups[0]['weight_decay']

    def set_learning_rate(optimizer, lr):
        for param_group in optimizer.param_groups:
            param_group['lr'] = lr
    ...
    # 训练途中修改学习率
    if epoch == 20:
    	set_learning_rate(optimizer, 0.01) # 20 次修改学习率为 0.01
    

    11. 主流网络实现(略)

    数据集cifar10
    torchvision.datasets.CIFAR10

    此部分最好自己手动实现各个网络
    更能熟悉

    1. VGGNet:

    2. GoogleNet

      GoogleNet的改进:
      v1:最早的版本
      v2:加入 batch normalization 加快训练v3:对 inception 模块做了调整
      v4:基于 ResNet 加入了 残差连接

    3. ResNet


    4. DenseNet

      短路链接机制:

      密集链接机制:

      前向过程:

      网络结构:

  • 相关阅读:
    道路和航线
    Sorting It All Out
    Sightseeing Cows(0/1分数规划+Spfa判负环)
    【模板】缩点
    间谍网络
    Tarjan算法专练
    数论知识点总结
    博客迁移到博客园
    第一届CCPC河南省赛
    find程序实现
  • 原文地址:https://www.cnblogs.com/orangestar/p/12897945.html
Copyright © 2011-2022 走看看