1、前言
距离上次研究传统车间调度问题(Job-shop scheduling problem,JSP ),大约有一个月左右了,这期间研究了一下柔性作业车间调度(Flexible Job-shop scheduling problem,FJSP),并利用MATLAB实现了基于遗传算法的FJSP问题求解。在此与大家分享一下,有问题欢迎评论留言,共同交流学习,完整代码见基于遗传算法的FJSP问题求解(备注:该源码在原来的作业车间调度算法基础上修改,部分命名未及时修改,不影响代码质量)。
2、柔性车间调度问题描述
柔性作业车间调度问题是传统Job-Shop 调度问题的扩展。在传统的 Job-Shop 调度问题中,工件的每道工序只能在一台确定的机床上加工。而在柔性作业车间调度问题中,每道工序可以在多台机床上加工,并且在不同的机床上加工所需时间不同。柔性作业车间调度问题减少了机器约束,扩大了可行解的搜索范围,增加了问题的复杂性。
柔性作业车间调度问题的描述如下:一个加工系统有 m 台机器,要加工 n种工件。每个工件包含一道或多道工序,工件的工序顺序是预先确定的;每道工序可以在多台不同的机床上加工,工序的加工时间随机床的性能不同而变化。调度目标是为每道工序选择最合适的机器、确定每台机器上各工件工序的最佳加工顺序及开工时间,使系统的某些性能指标达到最优。此外,在加工过程中还需满足以下约束条件:
- 同一时刻同一台机器只能加工一个零件;
- 每个工件在某一时刻只能在一台机器上加工,不能中途中断每一个操作;
- 同一工件的工序之间有先后约束,不同工件的工序之间没有先后约束;
- 不同工件具有相同的优先级;
例如, 一个包括 3 个工件、5 台机器的柔性作业车间调度加工时间表如表 5.1 所示。柔性作业车间调度问题的求解过程包括两部分:选择各工序的加工机器和确定每台机器上工件的先后顺序。
调度的目标就是确定每个机器上工序的加工顺序和每个工序的开工时间,使得最大完工时间 (makspan)最小或者其他指标达到最优。
3、利用遗传算法求解柔性车间调度问题
3.1、遗传算法编码和解码
编码与解码是指染色体和调度解之间进行相互转换,是遗传算法成功实施优化的首要和关键问题。对于传统的作业车间调度问题,大多数研究采用基于工序的编码。但是柔性作业车间调度问题不仅要确定工序的加工顺序,还需为每道工序选择一台合适的机器,仅采用基于工序的编码方法不能得到问题的解。因此,对于柔性作业车间调度问题,遗传算法的编码由两部分组成,第一部分为基于工序的编码,用来确定工序的加工先后顺序;第二部分为基于机器分配的编码,用来选择每道工序的加工机器。融合这两种编码方法,即可得到柔性作业车间调度问题的一个可行解。结合两种编码方式的染色体如下图所示。
其中基于工序编码的基因串确定工序加工的先后顺序,基于机器编码的基因串确定每个工件所有工序的加工机器。图中所示的染色体表示的工序和机器序列为(),(),(),(),(),(),(),(),()。
解码过程和编码过程相反,解码分为一般解码或者称为半主动解码、主动解码和全主动解码。本文使用半主动解码方式进行染色体解码。
编码代码如下:
function [Population] = Coding(T,PopSize)
%% INPUT:
% T--input matrix:
% For example:
% A partial flexible scheduling problem in which 8 jobs are processed
% on 8 machines, in which the number of available machining machines per
% step of job is less than or equal to the total number of machines
% J1 ={[5 3 5 3 3 0 10 9];[10 0 5 8 3 9 9 6];[0 10 0 5 6 2 4 5]};
% J2 ={[5 7 3 9 8 0 9 0];[0 8 5 2 6 7 10 9];[0 10 0 5 6 4 1 7];[10 8 9 6 4 7 0 0]};
% J3 ={[10 0 0 7 6 5 2 4];[0 10 6 4 8 9 10 0];[1 4 5 6 0 10 0 7]};
% J4 ={[3 1 6 5 9 7 8 4];[12 11 7 8 10 5 6 9];[4 6 2 10 3 9 5 7]};
% J5 ={[3 6 7 8 9 0 10 0];[10 0 7 4 9 8 6 0];[0 9 8 7 4 2 7 0];[11 9 0 6 7 5 3 6]};
% J6 ={[6 7 1 4 6 9 0 10];[11 0 9 9 9 7 6 4];[10 5 9 10 11 0 10 0]};
% J7 ={[5 4 2 6 7 0 10 0];[0 9 0 9 11 9 10 5];[0 8 9 3 8 6 0 10]};
% J8 ={[2 8 5 9 0 4 0 10];[7 4 7 8 9 0 10 0];[9 9 0 8 5 6 7 1];[9 0 3 7 1 5 8 0]};
% T={J1;J2;J3;J4;J5;J6;J7;J8}; 8*1 cell
% Popsize- Population size in genetic algorithms,2*PopSize+1
%% OUTPUT:
% Population-Popsize*1 cell
% chromosome-2*1 cell
%% variable declaration
num_of_jobs = length(T); %number of jobs
num_of_machines = length(T{1}{1}); %number of machines
steps_of_job =[];
machine_of_job=cell(num_of_jobs,1);
% calculate the length of chromosome
for i = 1:num_of_jobs
steps_of_job=[steps_of_job;length(T{i})];
end
len_of_chromosome = sum(steps_of_job);
% calculate the machine set for each steps of each job
for i=1:num_of_jobs
steps=cell(steps_of_job(i),1);
for j = 1:steps_of_job(i)
machineset=[];
for k=1:length(T{i}{j})
if T{i}{j}(k)~=0
machineset=[machineset k];
end
end
steps{j}=machineset;
end
machine_of_job{i}=steps;
end
%steps chromosome
%Coding is based on the step of the job
step_chromsome=[];
for i = 1:num_of_jobs
for j = 1:steps_of_job(i)
step_chromsome=[step_chromsome i];
end
end
step_population =[];
%Generate population with chromosome containing random genes
for i = 1:PopSize
step_population(i,:)=step_chromsome(randperm(length(step_chromsome(:))));
end
%
%machine chromosome
%In each steps, the machine is randomly selected for two machines,
%and the machine that selects the short processing time is the gene with
%propability
machine_population =[];
for index = 1:PopSize
machine_chromsome=[];
for i=1:num_of_jobs
for j=1:steps_of_job(i)
pos= randperm(length(machine_of_job{i}{j}),2);
machine1=machine_of_job{i}{j}(pos(1));
machine2=machine_of_job{i}{j}(pos(2));
if (rand(1)<0.8) && (T{i}{j}(machine1)>T{i}{j}(machine2))
machine = machine2;
else
machine = machine1;
end
machine_chromsome=[machine_chromsome machine];
end
end
machine_population(index,:) = machine_chromsome;
end
Population=cell(PopSize,1);
for i=1:PopSize
Population{i} =[step_population(i,:);machine_population(i,:)];
end
end
解码代码如下
%% Flexible Job-shop scheduling problem based on genetic algorithm with POX selection
% Author:Eric.Wan
% Date:2019-12-8
% Add.:ShenYang,China
% Email:970301442@qq.com
% Version: v1.0
function [Jobs,Cmax,MachineList,ST,PT] = SemiActiveDecoding(T,Chromosome)
%% INPUT:
% T--input matrix:
% For example:
% A partial flexible scheduling problem in which 8 jobs are processed
% on 8 machines, in which the number of available machining machines per
% step of job is less than or equal to the total number of machines
% J1 ={[5 3 5 3 3 0 10 9];[10 0 5 8 3 9 9 6];[0 10 0 5 6 2 4 5]};
% J2 ={[5 7 3 9 8 0 9 0];[0 8 5 2 6 7 10 9];[0 10 0 5 6 4 1 7];[10 8 9 6 4 7 0 0]};
% J3 ={[10 0 0 7 6 5 2 4];[0 10 6 4 8 9 10 0];[1 4 5 6 0 10 0 7]};
% J4 ={[3 1 6 5 9 7 8 4];[12 11 7 8 10 5 6 9];[4 6 2 10 3 9 5 7]};
% J5 ={[3 6 7 8 9 0 10 0];[10 0 7 4 9 8 6 0];[0 9 8 7 4 2 7 0];[11 9 0 6 7 5 3 6]};
% J6 ={[6 7 1 4 6 9 0 10];[11 0 9 9 9 7 6 4];[10 5 9 10 11 0 10 0]};
% J7 ={[5 4 2 6 7 0 10 0];[0 9 0 9 11 9 10 5];[0 8 9 3 8 6 0 10]};
% J8 ={[2 8 5 9 0 4 0 10];[7 4 7 8 9 0 10 0];[9 9 0 8 5 6 7 1];[9 0 3 7 1 5 8 0]};
% T={J1;J2;J3;J4;J5;J6;J7;J8}; 8*1 cell
%Chromosome -- A chromosome to be decoded
%% OUTPUT:
%JobList-- job sequences
%Cmax --the max makespan
%MachineList--The machine sequences corresponding to chromosome
%ST --the start time for each job step in chromosome
%PT --The operation time for each job step in chromome
%% start
num_of_jobs = length(T); %number of jobs
num_of_machines = length(T{1}{1}); %number of machines
len_of_chromosome = length(Chromosome);
StepList = []; %steps for all genes
MachineList = zeros(1,len_of_chromosome);
ST = zeros(1,len_of_chromosome);
PT = zeros(1,len_of_chromosome);
DecodedGenes =[];
steps_of_job =[];
for i = 1:num_of_jobs
steps_of_job=[steps_of_job;length(T{i})];
end
%% Caculate MachineList and PT
for index = 1:len_of_chromosome
DecodedGenes=[DecodedGenes Chromosome(1,index)];
postion = length(find(DecodedGenes==Chromosome(1,index)));
StepList = [StepList postion];
MachineList(index)=Chromosome(2,sum(steps_of_job(1:(Chromosome(1,index)-1)))+postion);
PT(index)=T{Chromosome(1,index)}{postion}(MachineList(index));
end
%% Caculate ST
Machines = unique(MachineList);
Jobs = unique(Chromosome(1,:));
job_start_time = cell(num_of_jobs,1);
job_end_time = cell(num_of_jobs,1);
machine_start_time = cell(num_of_machines,1);
machine_end_time = cell(num_of_machines,1);
machine_state = zeros(1,num_of_machines); %0--FirstWork;1--NotFirst
for index = 1:len_of_chromosome
job = Chromosome(1,index);
machine = MachineList(index);
pt = PT(index);
step = StepList(index);
pos_m = find(Machines==machine);
pos_j = find(Jobs==job);
if step==1 %first step without considering the constrains between steps of same job
if machine_state(pos_m)==0 % The machine is first used
job_start_time{pos_j}=[0,pos_m];
job_end_time{pos_j}=[job_start_time{pos_j}(1)+pt,pos_m];
else
job_start_time{pos_j}=[machine_end_time{pos_m}(1),pos_m];
job_end_time{pos_j}=[job_start_time{pos_j}(1)+pt,pos_m];
end
else
if machine_state(pos_m)==0 % The machine is first used
job_start_time{pos_j}=[job_end_time{pos_j}(1),pos_m];
job_end_time{pos_j}=[job_start_time{pos_j}(1)+pt,pos_m];
else
job_start_time{pos_j}=[max(machine_end_time{pos_m}(1),job_end_time{pos_j}(1)),pos_m];
job_end_time{pos_j}=[job_start_time{pos_j}(1)+pt,pos_m];
end
end
machine_start_time{pos_m}= job_start_time{pos_j}(1);
machine_end_time{pos_m} = job_end_time{pos_j}(1);
machine_state(pos_m)=1;
ST(index)=job_start_time{pos_j}(1);
end
%% Caculate Cmax
end_time=cell2mat(job_end_time);
Cmax = max(end_time(:,1));
end
3.2、选择操作
在遗传算法中,适应度是个体对生存环境的适应程度,适应度高的个体将获得更多的生存机会。选择算子根据适应度的值选择个体遗传到下一代群体中。选择操作采用最佳个体保存(Elitist model)和锦标选择(Tournament selection)两种方法。在本章的改进遗传算法中,最佳个体保存方法是将父代群体中最优的 一个个体直接复制到下一代中。锦标选择是从种群中随机选择两个个体,如果随机值(在 0~1 之间随机产生)小于给定概率值 r(概率值 r是一个参数,一般设置为 0.8),则选择优的一个,否则就选择另一个。被选择的个体放回到种群,可以重新作为一个父染色体参与选择。
选择代码如下
BestFitness=min(FITNESS); % find the best chromosome
position=find(FITNESS==BestFitness); % and the position,may be more than one
BestChromosome=Population{position(1)}; % choose one chromosome from population
%% Selection :Elitism and 2-tournament selection are used
Parent = cell(PopSize,1);
Pr =0.8;
for index =1:PopSize-1
pos = randperm(PopSize,2);
chromosome1 = Population{pos(1)};
chromosome2 = Population{pos(2)};
if (rand(1)<Pr) && FITNESS(pos(1))>FITNESS(pos(2))
chromosome = chromosome1;
else
chromosome = chromosome2;
end
Parent{index} = chromosome;
end
Parent{PopSize}=BestChromosome;
3.3 交叉操作
交叉操作是将种群中两个个体随机地交换某些基因,产生新的基因组合,期望将有益的基因组合在一起。染色体中两部分基因串的交叉分别进行,其中第一部分基于工序编码基因串的交叉操作采用IPOX 交叉算子,第二部分基于机器分配编码基因串的交叉采用多点交叉MPX的方法。
3.3.1 IPOX交叉因子
IPOX操作是在POX操作基础上经改进而形成的(POX不了解的可以参考博文基于遗传算法的传统作业车间调度的问题求解),它仅仅交叉父代染色体中工序的加工序列,保留工件中工序分配的机器到子代。IPOX的具体操作过程如下图所示,其中和为调度问题的两个父代染色体,交叉产生子代和.IPOX交叉操作过程为:
1)把所有的工件随机分成两个集合和;
2) 复制包含在中的工件到,复制包含在中的工件到,保留它们的位置;
3)复制包含在中的工件到,复制包含在中的工件到,保留它们的顺序。
3.3.2 MPX交叉因子
MPX交叉父代染色体中工序选定的机器,工序的加工顺序保留到子代。多点交叉操作的过程如下图所示。对于某调度问题,和为调度问题的两个父代染色体,交叉产生子代和.MPX交叉操作过程为:
1)首先随机产生一个有整数0、1组成的与染色体长度相等的rand[0,1]集;
2)依次在中选出与rand[0,1]集中1的位置对应相同的工序,交换他们分配的机器,和 中其他机器的加工顺序保留到子代,这样分别产生子代和。因为是同一工序的加工机器交换,生成的子代染色体必为可行解。
交叉操作的代码如下
%% Crossover: IPOX for steps, MPX for machine
Children_group1=cell(PopSize,1);
for i=1:(PopSize-1)
%Parent individuals are selected for crossover operation:
%Parent1 is selected sequentially and Parent2 is selected randomly.
index_parent2 = randi([1,(PopSize-1)]);
Parent1=Parent{i};
Parent2=Parent{index_parent2};
Children1=zeros(2,len_of_chromosome);
Children2=zeros(2,len_of_chromosome);
if rand(1)<=Pc %Use the probability to determine if crossover is required
%% Part1: IPX for step
%Randomly divide the set of jobs {1,2,3...,n} into two non-empty sub-sets J1 and J2.
num_J1 = randi([1,num_of_jobs]);
if num_J1==num_of_jobs
num_J1 = fix(num_of_jobs/2);
end
J = randperm(num_of_jobs);
J1 =J(1:num_J1);
J2 =J(num_J1+1:num_of_jobs);
% Copy the jobs that Parent1 contains in J1 to Children1,
% and Parent2 contains in J2 to Children2, and keep them in place.
for index = 1:num_J1 % look for jobs that Parent1 are included in J1
job = J1(index);
for j = 1:len_of_chromosome
if job == Parent1(1,j)
Children1(1,j)=Parent1(1,j);
end
end
end
for index = 1:num_of_jobs-num_J1 % look for jobs that Parent2 are included in J2
job = J2(index);
for j = 1:len_of_chromosome
if job == Parent2(1,j)
Children2(1,j)=Parent2(1,j);
end
end
end
%Copy the jobs that Parent1 contains in J1 to Children2,
%and Parent2 contains in J2 to Children1 in their order.
for index = 1:len_of_chromosome % look for jobs that Parent1 are included in J1
job = Parent1(1,index);
if ~isempty(find(J1==job, 1))
for gene = 1:len_of_chromosome
if Children2(1,gene)==0
Children2(1,gene)=job;
break;
end
end
end
end
for index = 1:len_of_chromosome % look for jobs that Parent1 are included in J1
job = Parent2(1,index);
if ~isempty(find(J2==job, 1))
for gene = 1:len_of_chromosome
if Children1(1,gene)==0
Children1(1,gene)=job;
break;
end
end
end
end
%%IPOX cross operation completed
%% Part 2 MPX for machine
%The process of crossover operation is as follows: firstly, a set rand0_1
%composed of 0 and 1 that is equal to the length of chromosome is randomly
%generated, and then the genes at the same position of 1 in the set of rand0_1
%in the two parental generations are interchanged, and two offspring are
%obtained after the crossover
rand0_1 = zeros(1,len_of_chromosome);
for gene = 1:len_of_chromosome
if rand(1)>0.5
rand0_1(gene)=1;
end
end
for gene = 1:len_of_chromosome
if rand0_1(gene)==1
Children1(2,gene) = Parent2(2,gene);
Children2(2,gene) = Parent1(2,gene);
else
Children1(2,gene) = Parent1(2,gene);
Children2(2,gene) = Parent2(2,gene);
end
end
%MOX cross operation completed
else
Children1 = Parent1;
Children2 = Parent2;
end
%% Select the Fitness value best retained to the next generation
[Parent1_FitnessValue] = FitnessCalculator(T,Parent1);
[Parent2_FitnessValue] = FitnessCalculator(T,Parent2);
[Children1_FitnessValue] = FitnessCalculator(T,Children1);
[Children2_FitnessValue] = FitnessCalculator(T,Children2);
[~, pos] = max([Parent1_FitnessValue Parent2_FitnessValue Children1_FitnessValue Children2_FitnessValue]);
temp_group ={Parent1 Parent2 Children1 Children2};
end
Children_group1{PopSize}= BestChromosome;
3.4 变异操作
变异操作的目的是改善算法的局部搜索能力和维持群体多样性,同时防止出现早
熟现象。基于工序编码和基于机器分配编码的变异分别设计如下:
- 基于工序编码的变异
对于这部分基因实施插入变异,即从染色体中随机选择一个基因,然后将之插入到一个随机的位置。 - 基于机器分配编码的变异
由于每道工序都可以由多台机器完成,所以随机选择两道工序,然后在执行这两道工序的机器集合中选择一台机器(采用比例选择策略,加工时间短的优先选择),并将选择的机器号置入对应的基于机器分配编码的基因串中,这样得出的解能确保是可行解。
交叉操作的代码如下
%% Mutation
Children_group2=cell(PopSize,1);
for i=1:(PopSize-1)
temp_chromsome = Children_group1{i};
%% Mutation for steps
if rand(1)<Pm
for j=1:4
pos1=randi([1,len_of_chromosome]); % Choose the sequence number of a gene to be mutated
pos2=randi([1,len_of_chromosome]); % Choose another the sequence number of a gene to be mutated
Gene=temp_chromsome(1,pos1);
temp_chromsome(1,pos1)=temp_chromsome(1,pos2);
temp_chromsome(1,pos2)=Gene;
end
end
%% Mutation for machine
if rand(1)<Pm
for k=1:4
job = randi([1,num_of_jobs]) ; %random choose job
tempstep = randi([1,steps_of_job(job)]); %random choose step
newmachine = machine_of_job{job}{tempstep}(randi([1,length(machine_of_job{job}{tempstep})]));%random choose machine
temp_chromsome(2,sum(steps_of_job(1:job-1))+1)= newmachine; %replace the old one
end
end
Children_group2{i} = temp_chromsome;
end
Children_group2{PopSize}= BestChromosome;
% complete mutation
3.5 绘制甘特图
这部分就是将最优的染色体绘制出来,代码同基于遗传算法的作业车间调度问题求解。
3.6 实验验证
这里使用两个基准实例,即部分柔性实例和完全柔性实例.
%% T8x8
% A partial flexible scheduling problem in which 8 jobs are processed
% on 8 machines, in which the number of available machining machines per
% step of job is less than or equal to the total number of machines
J1 ={[5 3 5 3 3 0 10 9];[10 0 5 8 3 9 9 6];[0 10 0 5 6 2 4 5]};
J2 ={[5 7 3 9 8 0 9 0];[0 8 5 2 6 7 10 9];[0 10 0 5 6 4 1 7];[10 8 9 6 4 7 0 0]};
J3 ={[10 0 0 7 6 5 2 4];[0 10 6 4 8 9 10 0];[1 4 5 6 0 10 0 7]};
J4 ={[3 1 6 5 9 7 8 4];[12 11 7 8 10 5 6 9];[4 6 2 10 3 9 5 7]};
J5 ={[3 6 7 8 9 0 10 0];[10 0 7 4 9 8 6 0];[0 9 8 7 4 2 7 0];[11 9 0 6 7 5 3 6]};
J6 ={[6 7 1 4 6 9 0 10];[11 0 9 9 9 7 6 4];[10 5 9 10 11 0 10 0]};
J7 ={[5 4 2 6 7 0 10 0];[0 9 0 9 11 9 10 5];[0 8 9 3 8 6 0 10]};
J8 ={[2 8 5 9 0 4 0 10];[7 4 7 8 9 0 10 0];[9 9 0 8 5 6 7 1];[9 0 3 7 1 5 8 0]};
T8x8={J1;J2;J3;J4;J5;J6;J7;J8};
%% T10x10
% For all flexible scheduling problems of 10 jobs and 10 machines,
% the number of available processing machines in each step of job is equal to
% the total number of machines
G1={[1 4 6 9 3 5 2 8 9 5];[4 1 1 3 4 8 10 4 11 4];[3 2 5 1 5 6 9 5 10 3]};
G2={[2 10 4 5 9 8 4 15 8 4];[4 8 7 1 9 6 1 10 7 1];[6 11 2 7 5 3 5 14 9 2]};
G3={[8 5 8 9 4 3 5 3 8 1];[9 3 6 1 2 6 4 1 7 2];[7 1 8 5 4 9 1 2 3 4]};
G4={[5 10 6 4 9 5 1 7 1 6];[4 2 3 8 7 4 6 9 8 4];[7 3 12 1 6 5 8 3 5 2]};
G5={[7 10 4 5 6 3 5 15 2 6];[5 6 3 9 8 2 8 6 1 7];[6 1 4 1 10 4 3 11 13 9]};
G6={[8 9 10 8 4 2 7 8 3 10];[7 3 12 5 4 3 6 9 2 15];[4 7 3 6 3 4 1 5 1 11]};
G7={[1 7 8 3 4 9 4 13 10 7];[3 8 1 2 3 6 11 2 13 3];[5 4 2 1 2 1 8 14 5 7]};
G8={[5 7 11 3 2 9 8 5 12 8];[8 3 10 7 5 13 4 6 8 4];[6 2 13 5 4 3 5 7 9 5]};
G9={[3 9 1 3 8 1 6 7 5 4];[4 6 2 5 7 3 1 9 6 7];[8 5 4 8 6 1 2 3 10 12]};
G10={[4 3 1 6 7 1 2 6 20 6];[3 1 8 1 9 4 1 4 17 15];[9 2 4 2 3 5 2 4 10 23]};
T10x10 = {G1;G2;G3;G4;G5;G6;G7;G8;G9;G10};
这里贴上运行8*8实例的运行脚本程序入如下:
clear;
clc;
T = Tmatrix('8x8');
Iterations=100;
PopSize=50;
Pc=0.8;
Pm=0.5;
result=cell(1000,1);
[Chromosome] = POX_GA(T,Iterations,PopSize,Pc,Pm);
[Jobs,Cmax,MachineList,ST,PT] = SemiActiveDecoding(T,Chromosome);
GanntGraf(Jobs,Chromosome(1,:),MachineList,ST,PT,Cmax,'FJSP') ;
实验结果如下:
4、 小结
通过对柔性作业车间调度的研究,一方面加深了对遗传算法解决NP难问题的有效性的体会,利用遗传算法解决这里问题难点在于使用比较好的编码和解码方式,其次是如何避免早熟,解决这两个问题是关键。本文中使用的源代码,存在早熟问题,有待进一步解决,也欢迎各位不吝赐教留言指导;另一方面对制造系统的调度过程有了更深一层次的认识,希望能对今后的学习和工作有所帮助。 以上便是近期对调度问题的入门级探索,不足之处欢迎留言批评指正!
Note:转发请注明出处。
来源:CSDN
作者:Eric.Wan
链接:https://blog.csdn.net/tauyangdao/article/details/103827189