本文同时也在博客园发布,欢迎移步我的新博客:点此前往。
最大流是图论问题中比较具有抽象性和技巧性的题目,一般需要根据题中要求设计出一张符合题中所有约束条件的图,然后直接应用算法模板加以解决。模板可以当黑箱来使用,最关键的是要学会如何找到逻辑关系,找准图像特征,以不变应万变。
一般来说, Dinic \text{Dinic} Dinic 算法可以解决大部分的问题,如果对时间复杂度有较高要求,则需要使用 ISAP \text{ISAP} ISAP 算法。
下面是 Dinic \text{Dinic} Dinic 算法的模板:
const int INF=0x3f3f3f3f;
const int MAXN=1e5+10;
const int MAXM=1e5+10;
struct Edge{
int to,next,cap;
}edge[MAXM];
int tot,head[MAXN];
inline void init(){
tot=0;
memset(head,-1,sizeof(head));
}
inline void add_edge(int u,int v,int w){
edge[tot]=(Edge){v,head[u],w};
head[u]=tot++;
edge[tot]=(Edge){u,head[v],0};
head[v]=tot++;
}
int dep[MAXN];
bool bfs(int s,int t){
memset(dep,-1,sizeof(dep));
dep[s]=0;
queue<int> Q;
Q.push(s);
while (!Q.empty()){
int u=Q.front();
Q.pop();
for (int i=head[u];~i;i=edge[i].next){
int v=edge[i].to;
if (dep[v]==-1&&edge[i].cap){
dep[v]=dep[u]+1;
Q.push(v);
}
}
}
return ~dep[t];
}
int cur[MAXN];
int dfs(int u,int t,int r){
if (u==t||r==0) return r;
int flow(0),f;
for (int &i=cur[u];~i;i=edge[i].next){
int v=edge[i].to;
if (dep[v]==dep[u]+1&&(f=dfs(v,t,min(r,edge[i].cap)))>0){
edge[i].cap-=f;
edge[i^1].cap+=f;
flow+=f;
if (!(r-=f)) break;
}
}
return flow;
}
int dinic(int s,int t){
int maxflow(0);
while (bfs(s,t)){
memcpy(cur,head,sizeof(head));
maxflow+=dfs(s,t,INF);
}
return maxflow;
}
这是 ISAP \text{ISAP} ISAP 算法的模板:
const int MAXN = 100010;
const int MAXM = 400010;
const int INF = 0x3f3f3f3f;
struct Edge{
int to,next,cap,flow;
}edge[MAXM];
int tol;
int head[MAXN];
int gap[MAXN],dep[MAXN],cur[MAXN];
void init(){
tol = 0;
memset(head,−1,sizeof(head));
}
void addedge(int u,int v,int w,int rw = 0){
edge[tol].to = v; edge[tol].cap = w; edge[tol].flow = 0;
edge[tol].next = head[u]; head[u] = tol++;
edge[tol].to = u; edge[tol].cap = rw; edge[tol].flow = 0;
edge[tol].next = head[v]; head[v] = tol++;
}
int Q[MAXN];
void BFS(int start,int end){
memset(dep,−1,sizeof(dep));
memset(gap,0,sizeof(gap));
gap[0] = 1;
int front = 0, rear = 0;
dep[end] = 0;
Q[rear++] = end;
while(front != rear){
int u = Q[front++];
for(int i = head[u]; i != −1; i = edge[i].next){
int v = edge[i].to;
if(dep[v] != −1)continue;
Q[rear++] = v;
dep[v] = dep[u] + 1;
gap[dep[v]]++;
}
}
}
int S[MAXN];
int sap(int start,int end,int N){
BFS(start,end);
memcpy(cur,head,sizeof(head));
int top = 0;
int u = start;
int ans = 0;
while(dep[start] < N){
if(u == end){
int Min = INF;
int inser;
for(int i = 0;i < top;i++)
if(Min > edge[S[i]].cap − edge[S[i]].flow){
Min = edge[S[i]].cap − edge[S[i]].flow;
inser = i;
}
for(int i = 0;i < top;i++){
edge[S[i]].flow += Min;
edge[S[i]^1].flow −= Min;
}
ans += Min;
top = inser;
u = edge[S[top]^1].to;
continue;
}
bool flag = false;
int v;
for(int i = cur[u]; i != −1; i = edge[i].next){
v = edge[i].to;
if(edge[i].cap − edge[i].flow && dep[v]+1 == dep[u]){
flag = true;
cur[u] = i;
break;
}
}
if(flag){
S[top++] = cur[u];
u = v;
continue;
}
int Min = N;
for(int i = head[u]; i != −1; i = edge[i].next)
if(edge[i].cap − edge[i].flow && dep[edge[i].to] < Min){
Min = dep[edge[i].to];
cur[u] = i;
}
gap[dep[u]]−−;
if(!gap[dep[u]])return ans;
dep[u] = Min + 1;
gap[dep[u]]++;
if(u != start)u = edge[S[−−top]^1].to;
}
return ans;
}
下面给出一些例题的讲解,由于做题的时间不同,用到的模板也略有变化,可以先忽略模板的内容,重点关注如何建图。
题意:
有 N N N 台机器,每个机器可以加工 P P P 个部分,它们对输入有一定的要求, 0 0 0 代表这一个部分必须未被加工, 1 1 1 代表这一部分必须已经被加工, 2 2 2 代表无所谓。它们的输出也用类似的方式给出, 1 1 1 代表这一部分已被加工好, 0 0 0 代表这一部分未被加工或已被拆卸。每台机器每小时处理的工件数量事先给出,请你安排一个连接这些机器的方式,使得每小时总产量最大,输出最大产量和连接的方式。
题解:
本题在建图方面设置的难点在于,如何确定哪些点之间的连接。首先来说,源结点一定要连向且只连向输入中不要求任何一部分已被加工好的机器,容量由这些机器的工时决定,而连向汇点的全部结点一定是那些所有部分在输出时都已被加工好的机器,容量同上。接着考虑机器和机器之间的连接,不难发现,只要一个机器的输出和下一个机器的输入之间满足相同或者输入是 2 2 2 的关系,即可将它们相连,而容量由它们较慢的那个决定。至此,图已建立完毕。跑完最大流以后,再去遍历结点,找出流非 0 0 0 的边并输出即可。
#include
#include
#include
#include
using namespace std;
//模板部分省略
int P,N,M,s,t,res;
bool flag;
struct Mac{
int in[10];
int out[10];
int Q;
bool operator<(const Mac &oth)const{
for (int i=0;i<P;++i){
if (oth.in[i]!=2&&out[i]!=oth.in[i])
return false;
}
return true;
}
}mac[MAXN];
int output[MAXM][3];
int main(){
while (~scanf("%d%d",&P,&N)){
init();
for (int i=1;i<=N;++i){
scanf("%d",&mac[i].Q);
for (int j=0;j<P;++j)
scanf("%d",&mac[i].in[j]);
for (int j=0;j<P;++j)
scanf("%d",&mac[i].out[j]);
}
s=0,t=N+1;
for (int i=1;i<=N;++i){
flag=true;
for (int k=0;k<P;++k){
if (mac[i].in[k]==1){
flag=false;
break;
}
}
if (flag) add_edge(s,i,mac[i].Q);
flag=true;
for (int k=0;k<P;++k){
if (mac[i].out[k]!=1){
flag=false;
break;
}
}
if (flag) add_edge(i,t,mac[i].Q);
for (int j=1;j<=N;++j){
if (i==j) continue;
if (mac[i]<mac[j]) add_edge(i,j,min(mac[i].Q,mac[j].Q));
}
}
res=dinic(s,t);
M=0;
for (int u=1;u<=N;++u){
for (int i=head[u];~i;i=edge[i].next){
if (edge[i].flow>0&&edge[i].to!=N+1){
output[M][0]=u;
output[M][1]=edge[i].to;
output[M][2]=edge[i].flow;
++M;
}
}
}
printf("%d %d\n",res,M);
for (int i=0;i<M;++i)
printf("%d %d %d\n",output[i][0],output[i][1],output[i][2]);
}
return 0;
}
题意:
有 N N N 头牛, F F F 种食物, D D D 种饮料,每头牛只吃自己喜欢的食物或饮料(可能不唯一),每头牛只能最多得到一种食物和一种饮料,每种食物或饮料只能提供给最多一头牛。现在请你安排一种喂牛的方式,使得同时能得到自己想要的食物和饮料的牛最多。输出牛的数量即可。
题解:
本题的难点在于,牛需要同时得到食物或饮料,才能算作一头,且食物、饮料和牛之间的关系是一对一的。为了防止出现有的牛只得到了食物而得不到饮料,却因此占用了其他本可以同时得到食物与饮料的牛的食物的情况,我们需要给牛一个返还食物的机会。因此,我们需要把牛分成两部分(因此这一方法通常也被称为“拆点法”),食物的部分和饮料的部分。源结点连向所有的食物,然后每头牛喜欢的食物连向它们对应的食物部分,各自的食物部分连向各自的饮料部分,再由各自的饮料部分连向各自喜欢的饮料,最终,每个饮料再连向汇点。上述每条边的容量都是 1 1 1 。这样一来,当流走到食物部分的时候发现后面已经不通了,算法会把这个流反推回去,减少浪费。因此这样跑出来的数据一定是使得牛最多的。
#include
#include
#include
#include
using namespace std;
//模板部分省略
int N,F,D;
int f,d,s,t,x;
int main(){
init();
scanf("%d%d%d",&N,&F,&D);
s=0,t=N+N+F+D+1;
for (int i=1;i<=F;++i)
add_edge(s,N+N+i,1);
for (int i=1;i<=D;++i)
add_edge(N+N+F+i,t,1);
for (int i=1;i<=N;++i){
scanf("%d%d",&f,&d);
add_edge(i,N+i,1);
for (int j=0;j<f;++j){
scanf("%d",&x);
add_edge(N+N+x,i,1);
}
for (int j=0;j<d;++j){
scanf("%d",&x);
add_edge(N+i,N+N+F+x,1);
}
}
printf("%d\n",dinic(s,t));
return 0;
}
题意:
给你一个二部图 G = ( U , V , E ) G = (U, V, E) G = (U, V, E) ,请你对于从 0 0 0 到 minDegree \text{minDegree} minDegree (最小度)的所有 k k k ,给出最小 k k k 覆盖,即,每个结点的度都至少为 k k k 的子图,且这样的子图是满足条件的子图中所需边数最少的那个。要求输出所需的边数和边。边会事先按序给出,输出序号即可。
题解:
这一题的难点在于,它给的是一个下限,让求最小子图。因此我们不难想到最后的答案和最大流跑完后的补图有关。我们可以这么建图:源结点连左边的结点,容量为 deg ( u ) − k \deg(u)-k deg(u)−k ,右边的点连向汇点,容量为 deg ( v ) − k \deg(v)-k deg(v)−k ,左结点连右结点,容量为 1 1 1 。跑完算法以后选择那些流为 0 0 0 边的即可。
然而,此题需要做一些必要的优化,否则会 TLE \text{TLE} TLE。我们不可能对于每一个 k k k 都重新建立一个新图的,这样太费时间。我们可以让 k k k 从 minDegree \text{minDegree} minDegree 开始,跑完最大流以后,找流为 0 0 0 的边,然后每个连接源结点或汇点的边的 cap \text{cap} cap 加 1 1 1 即可。模板中需要在边结构体中添加一个 id
变量用来保存边的序号。
#include
using namespace std;
const int INF=0x3f3f3f3f;
const int MAXN=1e5+10;
const int MAXM=1e5+10;
struct Edge{
int to,next,cap;
int id;
}edge[MAXM];
int tot,head[MAXN];
inline void init(){
tot=0;
memset(head,-1,sizeof(head));
}
inline void add_edge(int u,int v,int w,int id=-1){
edge[tot]=(Edge){v,head[u],w,id};
head[u]=tot++;
edge[tot]=(Edge){u,head[v],0,-1};
head[v]=tot++;
}
int dep[MAXN];
bool bfs(int s,int t){
memset(dep,-1,sizeof(dep));
dep[s]=0;
queue<int> Q;
Q.push(s);
while (!Q.empty()){
int u=Q.front();
Q.pop();
for (int i=head[u];~i;i=edge[i].next){
int v=edge[i].to;
if (dep[v]==-1&&edge[i].cap){
dep[v]=dep[u]+1;
Q.push(v);
}
}
}
return ~dep[t];
}
int cur[MAXN];
int dfs(int u,int t,int r){
if (u==t||r==0) return r;
int flow(0),f;
for (int &i=cur[u];~i;i=edge[i].next){
int v=edge[i].to;
if (dep[v]==dep[u]+1&&(f=dfs(v,t,min(r,edge[i].cap)))>0){
edge[i].cap-=f;
edge[i^1].cap+=f;
flow+=f;
if (!(r-=f)) break;
}
}
return flow;
}
int dinic(int s,int t){
int maxflow(0);
while (bfs(s,t)){
memcpy(cur,head,sizeof(head));
maxflow+=dfs(s,t,INF);
}
return maxflow;
}
int n1,n2,m,u,v;
int deg[MAXN];
vector<int> output[MAXN];
int main(){
init();
memset(deg,0,sizeof(deg));
scanf("%d%d%d",&n1,&n2,&m);
for (int i=1;i<=m;++i){
scanf("%d%d",&u,&v);
++deg[u];
++deg[v+n1];
add_edge(u,v+n1,1,i);
}
int minD=INF;
for (int i=1;i<=n1+n2;++i)
minD=min(minD,deg[i]);
int s=0,t=n1+n2+1;
for (int i=1;i<=n1;++i)
add_edge(s,i,deg[i]-minD);
for (int i=n1+1;i<=n1+n2;++i)
add_edge(i,t,deg[i]-minD);
for (int k=minD;k>=0;--k){
dinic(s,t);
for (int u=1;u<=n1;++u){
for (int i=head[u];~i;i=edge[i].next){
if (edge[i].id!=-1&&edge[i].cap)
output[k].push_back(edge[i].id);
}
}
for (int i=head[s];~i;i=edge[i].next)
++edge[i].cap;
for (int i=head[t];~i;i=edge[i].next)
++edge[i^1].cap;
}
for (int i=0;i<=minD;++i){
int cnt=output[i].size();
printf("%d",cnt);
for (int j=0;j<cnt;++j){
printf(" %d",output[i][j]);
}
puts("");
}
return 0;
}
题意:
给定一个带权有向图,请你选择一些边,删去它们,使得最短路长度变长。删边的代价等于删去边的边权之和,你要选择代价最小的方案。输出最小代价,如果原本就不存在最短路,输出 0 0 0 。
题解:
本题看似与最大流无关,其实运用了最大流的一个相关定理,即最大流最小割定理。定理指出,一个有向图的最大流的值等于它的最小切割的容量。而,题中所求的删边最小代价,恰好是使得最短路径构成的子图不连通所需要删去的边的边权和,这正是最短路径子图的最小割。因此我们只需在最短路径子图上跑一遍最大流即可得出答案。最后,考虑到本题边权数据较大,建议开 long long
。模板中还需要在边结构体中添加一个 flag
变量用来表示边是否在最短路径子图中。
#include
using namespace std;
typedef long long ll;
typedef pair<ll,int> qnode;
const ll INF=0x3f3f3f3f3f;
const int MAXN=1e5+10;
const int MAXM=1e5+10;
struct Edge{
int to,next;
ll cap;
bool flag;
}edge[MAXM];
int tot,head[MAXN];
void init(){
tot=0;
memset(head,-1,sizeof(head));
}
void add_edge(int u,int v,int w){
edge[tot]=(Edge){v,head[u],w,false};
head[u]=tot++;
edge[tot]=(Edge){u,head[v],0,false};
head[v]=tot++;
}
ll dist[MAXN];
bool vis[MAXN];
void dijkstra(int s){
for (int i=0;i<MAXN;++i)
dist[i]=INF;
priority_queue<qnode,vector<qnode>,greater<qnode> > pq;
dist[s]=0;
pq.push((qnode){0,s});
qnode now;
while (!pq.empty()){
now=pq.top();
pq.pop();
int u=now.second;
if (dist[u]<now.first) continue;
for (int i=head[u];~i;i=edge[i].next){
int v=edge[i].to;
if (edge[i].cap&&dist[v]>dist[u]+edge[i].cap){
dist[v]=dist[u]+edge[i].cap;
pq.push((qnode){dist[v],v});
}
}
}
memset(vis,0,sizeof(vis));
queue<int> Q;
Q.push(1);
while (!Q.empty()){
int u=Q.front();
Q.pop();
if (vis[u]) continue;
vis[u]=true;
for (int i=head[u];~i;i=edge[i].next){
int v=edge[i].to;
if (edge[i].cap&&dist[v]==dist[u]+edge[i].cap){
edge[i].flag=edge[i^1].flag=true;
Q.push(v);
}
}
}
}
int dep[MAXN];
bool bfs(int s,int t){
memset(dep,-1,sizeof(dep));
dep[s]=0;
queue<int> Q;
Q.push(s);
while (!Q.empty()){
int u=Q.front();
Q.pop();
for (int i=head[u];~i;i=edge[i].next){
//只对最短路径子图进行操作
if (!edge[i].flag) continue;
int v=edge[i].to;
if (dep[v]==-1&&edge[i].cap){
dep[v]=dep[u]+1;
Q.push(v);
}
}
}
return ~dep[t];
}
int cur[MAXN];
ll dfs(int u,int t,ll r){
if (u==t||r==0) return r;
ll flow(0),f;
for (int &i=cur[u];~i;i=edge[i].next){
int v=edge[i].to;
//只对最短路径子图进行操作
if (!edge[i].flag) continue;
if (dep[v]==dep[u]+1&&(f=dfs(v,t,min(r,edge[i].cap)))>0){
edge[i].cap-=f;
edge[i^1].cap+=f;
flow+=f;
if (!(r-=f)) break;
}
}
return flow;
}
ll dinic(int s,int t){
ll maxflow(0);
while (bfs(s,t)){
memcpy(cur,head,sizeof(head));
maxflow+=dfs(s,t,INF);
}
return maxflow;
}
int T,n,m;
int u,v,w;
int main(){
scanf("%d",&T);
while (T--){
init();
scanf("%d%d",&n,&m);
for (int i=0;i<m;++i){
scanf("%d%d%d",&u,&v,&w);
add_edge(u,v,w);
}
dijkstra(1);
printf("%lld\n",dinic(1,n));
}
return 0;
}
题意:
现有 n n n 个士兵和两个阵营(分别叫Mage和Warrior),每个士兵属于且仅属于一个阵营。题目给出 m m m 种组合方式,每种方式指出, u u u 和 v v v 两个士兵结成一对(pair)能产生的收益。如果两个士兵都属于Warrior阵营,则有收益 a a a ;若都是Mage,则有收益 c c c ;如果分属两个阵营,则有收益 b = a / 4 + c / 3 b=a/4+c/3 b=a/4+c/3 (保证 4 ∣ a 4\mid a 4∣a 和 3 ∣ c 3\mid c 3∣c)。请你现在对这 n n n 个士兵的阵营进行指派,使得总收益最大。
题解:
本题需考虑的关系很多,建图也很巧。解法为:源结点向每一个士兵连一条边,代表士兵属于Mage阵营;每个士兵向汇点连一条边,代表士兵隶属Warrior阵营;对于每一个组合方式中的 u u u 和 v v v ,它们之间连一条双向边。为了方便叙述,我们设源结点到 u u u 结点的边权为 w s u w_{su} wsu , u u u 到 v v v 的边权为 w u v w_{uv} wuv ,其他依此类推。则解下列方程组 w s u + w s v = a + b w u t + w v t = b + c w s u + w u v + w v t = a + c w s v + w v u + w u t = a + c \begin{aligned} w_{su}+w_{sv}&=a+b\\ w_{ut}+w_{vt}&=b+c\\ w_{su}+w_{uv}+w_{vt}&=a+c\\ w_{sv}+w_{vu}+w_{ut}&=a+c \end{aligned} wsu+wsvwut+wvtwsu+wuv+wvtwsv+wvu+wut=a+b=b+c=a+c=a+c
得出其中一组解为(方程组有多解) w s u = w s v = ( a + b ) / 2 w u t = w v t = ( b + c ) / 2 w u v = w v u = ( a + c ) / 2 − b \begin{aligned} w_{su}=w_{sv}&=(a+b)/2\\ w_{ut}=w_{vt}&=(b+c)/2\\ w_{uv}=w_{vu}&=(a+c)/2-b \end{aligned} wsu=wsvwut=wvtwuv=wvu=(a+b)/2=(b+c)/2=(a+c)/2−b
如此建图,跑出最大流后,再用所有的 a , b , c a,b,c a,b,c 之和减去最大流即为答案。
那么,这么做为什么是正确的呢?此题依然应用了最小割的思想。若仅考虑 u u u 和 v v v 两个结点,从 s s s 到 t t t 的割无非就是四种,第一,切割 ⟨ s , u ⟩ \langle s,u \rangle ⟨s,u⟩ 和 ⟨ s , v ⟩ \langle s,v \rangle ⟨s,v⟩ ;第二,切割 ⟨ u , t ⟩ \langle u,t \rangle ⟨u,t⟩ 和 ⟨ v , t ⟩ \langle v,t \rangle ⟨v,t⟩ ;第三,切割 ⟨ s , u ⟩ \langle s,u \rangle ⟨s,u⟩ 、 ⟨ u , v ⟩ \langle u,v \rangle ⟨u,v⟩ 和 ⟨ v , t ⟩ \langle v,t \rangle ⟨v,t⟩ ;第四,切割 ⟨ s , v ⟩ \langle s,v \rangle ⟨s,v⟩ 、 ⟨ v , u ⟩ \langle v,u \rangle ⟨v,u⟩ 和 ⟨ u , t ⟩ \langle u,t \rangle ⟨u,t⟩ 。这四种切割都分别表示了 u u u 和 v v v 的一种阵营指派所得收益相对于总和的补。当割的值最小时,补最小,则原值最大,也就是收益最大。
刚才是仅有两个结点的状况,推广到多个结点原理也都相同。事实上,分属两个割集的非源非汇结点就相当于分属两个阵营,因此一个割就确定出一种合法的阵营指派。我们不去关注割集内部的流是怎么流的,总之我们看的是最小割,而最小割能确定出合法的阵营指派,而且还是最小,那么它就一定是答案。
#include
using namespace std;
typedef long long ll;
const int INF=0x3f3f3f3f;
const int MAXN=1e5+10;
const int MAXM=1e5+10;
template<class T>
struct Edge{
int to,next;
double cap;
};
Edge<double> edge[MAXM];
int tot,head[MAXN];
inline void init(){
tot=0;
memset(head,-1,sizeof(head));
}
template<class T>
inline void add_edge(int u,int v,T w){
edge[tot]=(Edge<T>){v,head[u],w};
head[u]=tot++;
edge[tot]=(Edge<T>){u,head[v],0};
head[v]=tot++;
}
int dep[MAXN];
bool bfs(int s,int t){
memset(dep,-1,sizeof(dep));
dep[s]=0;
queue<int> Q;
Q.push(s);
while (!Q.empty()){
int u=Q.front();
Q.pop();
for (int i=head[u];~i;i=edge[i].next){
int v=edge[i].to;
if (dep[v]==-1&&edge[i].cap){
dep[v]=dep[u]+1;
Q.push(v);
}
}
}
return ~dep[t];
}
int cur[MAXN];
double dfs(int u,int t,double r){
if (u==t||r==0) return r;
double flow(0),f;
for (int &i=cur[u];~i;i=edge[i].next){
int v=edge[i].to;
if (dep[v]==dep[u]+1&&(f=dfs(v,t,min((double)r,edge[i].cap)))>0){
edge[i].cap-=f;
edge[i^1].cap+=f;
flow+=f;
if (!(r-=f)) break;
}
}
return flow;
}
double dinic(int s,int t){
double maxflow(0);
while (bfs(s,t)){
memcpy(cur,head,sizeof(head));
maxflow+=dfs(s,t,INF);
}
return maxflow;
}
int n,m,u,v,a,b,c;
int M[MAXN],W[MAXN];
int main(){
while (~scanf("%d%d",&n,&m)){
memset(M,0,sizeof(M));
memset(W,0,sizeof(W));
init();
ll sum=0;
for (int i=0;i<m;++i){
scanf("%d%d%d%d%d",&u,&v,&a,&b,&c);
M[u]+=a+b;
M[v]+=a+b;
W[u]+=b+c;
W[v]+=b+c;
add_edge(u,v,-b+a/2.0+c/2.0);
add_edge(v,u,-b+a/2.0+c/2.0);
sum+=a+b+c;
}
int s=0,t=n+1;
for (int i=1;i<=n;++i){
add_edge(s,i,M[i]/2.0);
add_edge(i,t,W[i]/2.0);
}
ll res=(ll)round(sum-dinic(s,t));
printf("%lld\n",res);
}
return 0;
}
第100篇文章啦!再接再厉!