目录

  • Tarjan
    • 一、算法介绍
    • 二、原理
    • 三、应用
      • 1、求强连通分量
        • 例1 [[POJ 3180]](http://poj.org/problem?id=3180) The Cow Prom
        • 例2 [[POJ 2186]](http://poj.org/problem?id=2186)受欢迎的牛
      • 2、求割点
        • 例题 [[洛谷 3388]](https://www.luogu.org/problem/P3388) 割点(割顶)
      • 3、求桥(割边)
        • 例题 [[HDU 4378]](http://acm.hdu.edu.cn/showproblem.php?pid=4738) Caocao's Bridges

Tarjan

首先记录说明一下图论中的常用的概念

  • 无向图中,如果任意两个顶点之间都能够连通,则称此无向图为连通图
  • 有向图中,若任意两个顶点 ViV_iVi​ 和VjV_jVj​,满足从 $V_i $到 VjV_jVj​ 以及从 VjV_jVj​ 到 ViV_iVi​ 都连通,也就是都含有至少一条通路,则称此有向图为强连通图
  • 若无向图不是连通图,但图中存储某个子图符合连通图的性质,则称该子图为连通分量
  • 非强连通图有向图的极大强连通子图(最大的子图),称为强连通分量
  • 若一个无向图中的去掉任意一个节点(一条边)都不会改变此图的连通性,即不存在割点(桥),则称作点(边)双连通图
  • 无向连通图中,如果将其中一个点以及所有连接该点的边去掉,图就不再连通或者连通分枝数增加,那么这个点就叫做割点
  • 桥(割边)——指的是一条边,就是如果没有这条边,图的连通分量就会增加
  • 没有圈的连通图叫,树的边数恰好是顶点数减1,没有圈的非连通图叫做森林

一、算法介绍

​ TarjanTarjanTarjan是一种由RobertTarjanRobert TarjanRobertTarjan提出的求解有向图强连通分量的线性时间的算法。通常可以用来求强连通分量、双连通分量、缩点、割点、割边等问题。

例如上图中顶点1、2、31、2、31、2、3组成的子图就是这个有向图的强连通分量

TarjanTarjanTarjan算法可以找出这样的强连通分量,TarjanTarjanTarjan算法是基于对整个有向图的dfsdfsdfs进行的,将整个图作为一棵搜索树,图中的强连通分量作为搜索树的子树。

对于上图,很容易看出强连通分量,但是要让计算机找出来,那么肯定要做好相应的标记,如果对于这张图进行dfsdfsdfs遍历,假设从1开始搜索(用链式前向星存),搜索到的边如下

1−>2−>4−>51->2->4->51−>2−>4−>5、2−>3−>12- >3->12−>3−>1,

可以发现,只有333到111这条边搜索到了已经走过的顶点111,如果按照走过的顶点的先后顺序来表示时间,那么关系如下

顶点 访问时间(第几个访问到的)
1 1
2 2
3 5
4 3
5 4

很明显,如果从一个点出发不断遍历,发现有一个能够走回之前已经走过的点,说明形成了一个环,环上的点能够互相访问,环上的所有点都是强连通

二、原理

上面举了一个例子,基于这一点,很容易想到需要维护一个访问时间顺序的数组,知道了访问顺序,那么怎么找出其中和这个点构成的所有强连通分量呢?同样需要一个数组来维护,在搜索每一个点的时候,如果发现这个点已经被访问过,说明形成了环,这时候就可以将当前的点进行更新,更新成已经访问过的点的顺序。因此TarjanTarjanTarjan中重要的两个数组需要维护好

low[N];//low[i]的值表示i能够回溯的最小的祖先
dfn[N];//表示时间戳,dfn[i]的值表示第几个访问到i节点的

由于是递归实现的,明显数组lowlowlow是靠着回溯的时候更新的,而dfndfndfn是靠着递进去的时候更新的

struct Edge {int to, next;
}edge[M * 2];
void add_Edge(int u, int v) {edge[++cnt].to = v;edge[cnt].next = head[u];head[u] = cnt;
}
void Tarjan(int x) { //表示当前的顶点xdfn[x] = low[x] = ++c_time; //更新时间戳for(int i = head[x]; i != -1; i = edge[i].next) { //访问所有x能够一步到达的顶点,用v表示int v = edge[i].to;if(!dfn[v]) { //如果顶点v没有访问过,就继续找下去Tarjan(v);low[x] = min(low[x], low[v]);//v是由x走过去的,因此x是v的祖先,如果有环,则可能low[v]小于low[x],因此要更新low[x]}low[x] = min(low[x], dfn[v]);//当v已经被访问过,出现了环,当前x则更新到小的那个节点}
}

上图中更新结果

dfn[1]=1、dfn[2]=2、dfn[3]=5、dfn[4]=3、dfn[5]=4dfn[1] = 1、dfn[2] = 2、dfn[3] = 5、 dfn[4] = 3、 dfn[5] = 4dfn[1]=1、dfn[2]=2、dfn[3]=5、dfn[4]=3、dfn[5]=4

low[1]=1、low[2]=1、low[3]=1、low[4]=3、low[5]=4low[1] = 1、low[2] = 1、low[3] = 1、low[4] = 3、low[5] = 4low[1]=1、low[2]=1、low[3]=1、low[4]=3、low[5]=4

使用Tarjan的时候,如果不能保证可以一遍搜完整个图,那么使用方式如下

for(int i = 1; i <= n; i++)if(!dfn[i])Tarjan(i);

三、应用

1、求强连通分量

强连通分量需要引入栈来进行记录,每次进入递归的时候都进栈。考虑这样一种情况,当前的节点xxx在更新完之后,如果dfn[x]=low[x]dfn[x] = low[x]dfn[x]=low[x]说明,x以及它的所有子节点构成强连通分量,因此在这个时候需要把xxx节点后面进栈的节点和xxx全部弹出,这些节点都是和xxx强连通的

void Tarjan(int x) {dfn[x] = low[x] = ++c_time;stack[++t] = x; //进栈vst[x] = 1; //表示顶点x在栈中for(int i = head[x]; i != -1; i = edge[i].next) {int v = edge[i].to;if(!dfn[v]) {Tarjan(v);low[x] = min(low[x], low[v]);}else if(vst[v]) //如果v在栈中,并且已经访问或,则肯定要更新low[x]low[x] = min(low[x], dfn[v]);}if(dfn[x] == low[x]) { //出现强连通分量子树的最小根int cur;do {cur = stack[t--]; //弹栈vst[cur] = 0; //标记出栈cout << cur << " ";}while(x != cur);cout << "\n";}
}

例1 [POJ 3180] The Cow Prom

题意:给出nnn个点mmm条边,求出有向图中所有大于111的强连通分量个数

输入样例

5 4
2 4
3 5
1 2
4 1

输出样例

1

模板题

#include <iostream>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <cctype>
#define N 10005
#define M 50010
using namespace std;
inline int read() {int x = 0, f = 1; char c = getchar();while(!isdigit(c)) {if(c == '-') f = -1; c = getchar();}while(isdigit(c)) {x = (x << 3) + (x << 1) + c - 48; c = getchar();}return f * x;
}
struct Edge{int to, next;
}edge[M * 2];int n, m, cnt, c_time, t, ans;
int head[N], dfn[N], low[N], stack[N];
bool vst[N];
inline void addEdge(int u, int v) {edge[++cnt].to = v;edge[cnt].next = head[u];head[u] = cnt;
}void Tarjan(int x) {dfn[x] = low[x] = ++ c_time;stack[++t] = x;vst[x] = 1;for(int i = head[x]; i != -1; i = edge[i].next) {int v = edge[i].to;if(!dfn[v]) {Tarjan(v);low[x] = min(low[x], low[v]);}else if(vst[v]) {low[x] = min(low[x], dfn[v]);}}int now = 0;if(dfn[x] == low[x]) { //找到所有以x为根的强连通分量int cur;do{cur = stack[t--];vst[cur] = 0;now ++;}while(cur != x);}if(now > 1) ans ++;
}int main() {int u, v;memset(head, -1, sizeof(head));n = read(), m = read();for(int i = 1; i <= m; i++) {u = read(), v = read();addEdge(u, v);}for(int i = 1; i <= n; i++)if(!dfn[i]) Tarjan(i);cout << ans;return 0;
}

例2 [POJ 2186]受欢迎的牛

每一头牛的愿望就是变成一头最受欢迎的牛。现在有NNN头牛,给你MMM对整数(A,B)(A,B)(A,B),表示牛AAA认为牛BBB受欢迎。这种关系是具有传递性的,如果AAA认为BBB受欢迎,BBB认为CCC受欢迎,那么牛AAA也认为牛CCC受欢迎。你的任务是求出有多少头牛被除自己之外的所有牛认为是受欢迎的。

输入

第一行两个数N,MN,MN,M($1\leq N\leq 10000,1 \leq M\leq 50000 );接下来); 接下来);接下来M行,每行两个数行,每行两个数行,每行两个数A,B$ ,意思是AAA认为BBB是受欢迎的

输出

输出被除自己之外的所有牛认为是受欢迎的牛的数量。

样例输入

3 3
1 2
2 1
2 3

样例输出

1

首先把整个图染色, 所有强连通分量为一种颜色,或者说打上相同标记,然后缩点,遍历所有的强连通分量,把整个图当成DAGDAGDAG(有向无环图)考虑,那么出度为000的点如果只有111个,这个点一定是被所有牛喜欢的(可多举几个例子证明)。(可看代码注释)

#include <iostream>
#include <cctype>
#include <algorithm>
#include <cstring>
#include <cstdio>
#define M 50050
#define N 10050
using namespace std;inline int read() {int x = 0, f = 1; char c = getchar();while(!isdigit(c)) {if(c == '-') f = -1; c = getchar();}while(isdigit(c)){x = (x << 3) + (x << 1) + c - 48; c = getchar();}return f * x;
}
struct Edge{int to, next;
}edge[M * 2];
//color表示染色标记的数组,sum[i]表示标记为i的图中顶点的数量,r数组表示出度
int head[N], low[N], dfn[N], color[N], sum[N], stack[N], r[N];
//c_time 表示时间戳,t用来记录栈中的元素个数,rs用来记录染色的数量,也就是连通分量的数量
int cnt, n, m, c_time, t, rs;
bool vst[N];inline void add_Edge(int u, int v) {edge[++cnt].to = v;edge[cnt].next = head[u];head[u] = cnt;
}void Tarjan(int x) {dfn[x] = low[x] = ++ c_time;stack[++t] = x; vst[x] = 1;for(int i = head[x]; i != -1; i = edge[i].next) {int u = edge[i].to;if(!dfn[u]) {Tarjan(u);low[x] = min(low[x], low[u]);} else if (vst[u]) {low[x] = min(low[x], dfn[u]);}}if(dfn[x] == low[x]) {int cur;rs ++; //连通分量的数量增加do{cur = stack[t--];color[cur] = rs; //染色,标记sum[rs] ++; //记录该标记下的点数量vst[cur] = 0;}while(cur != x);}
}int main () {memset(head, -1, sizeof(head));int u, v, judge = 0, loc;n = read(), m = read();for(int i = 1; i <= m; i++) {u = read(), v = read();add_Edge(u, v);}for(int i = 1; i <= n; i++)if(!dfn[i]) Tarjan(i);for(int i = 1; i <= n; i++) {for(int j = head[i]; j != -1; j = edge[j].next) {int ve = edge[j].to;if(color[i] != color[ve]) { //如果顶点i和ve不是同一连通分量,那么顶点i的标记出度+1(因为有缩点)r[color[i]] ++;}}}for(int i = 1; i <= rs; i++) { //遍历DAGif(r[i] == 0) { //出度为0judge ++;loc = i; //记录标记}}if(judge == 1) cout << sum[loc]; //输出带有这种标记的顶点数量else cout << 0;return 0;
}

2、求割点

割点:在无向联通图 G=(V,E)G=(V,E)G=(V,E)中: 若对于x∈Vx∈Vx∈V, 从图中删去节点xxx以及所有与xxx关联的边之后,GGG分裂成两个或两个以上不相连的子图, 则称xxx为GGG的割点

割边(桥):在一个无向图中,如果有一个边集合,删除这个边集合以后,图的连通分量增多,就称这个边集为割边集合,如果某个割边集合只含有一条边 X(也即{X}是一个边集合),那么X称为一个割边,也叫做


根据定义来看,割点为3、43、43、4,桥为d、ed、ed、e,有两种情况会出现割点

  • 当对于点xxx存在儿子节点yyy,使得dfn[x]≤low[y]dfn[x] \leq low[y]dfn[x]≤low[y]则xxx一定是割点
  • 如果根节点有222个及以上的儿子,那么它也是割点(特判)

例题 [洛谷 3388] 割点(割顶)

题目

给出一个nnn个点,mmm条边的无向图,求图的割点

输入格式

第一行输入n,mn,mn,m

下面mmm行每行输入x,yx,yx,y表示xxx到yyy有一条边

输出格式

第一行输出割点个数

第二行按照节点编号从小到大输出节点,用空格隔开

样例输入

6 7
1 2
1 3
1 4
2 5
3 5
4 5
5 6

样例输出

1
5
#include <iostream>
#include <cstdio>
#include <cctype>
#include <cstring>
#define N 20005
#define M 100005
using namespace std;inline int read() {int x = 0, f = 1; char c = getchar();while(!isdigit(c)) {if(c == '-') f = -1; c = getchar();}while(isdigit(c)) {x = x * 10 + c - 48; c = getchar();}return f * x;
}struct Edge {int to, next;
}edge[M * 2];int s_clock, cnt, n, m;
int dfn[N], low[N], head[N];
//low[i]表示i能回溯到的最小祖先,dfn[i]表示时间戳,也就是第几个访问到的
bool vst[N], cut[N];inline void add_edge(int u, int v) {edge[++cnt].to = v;edge[cnt].next = head[u];head[u] = cnt;
}
void init() {memset(head, -1, sizeof(head));
}
void Tarjan(int x, int root) {int r = 0; //用来判断根节点是否是割点dfn[x] = low[x] = ++s_clock; //更新时间戳for(int i = head[x]; i != -1; i = edge[i].next) {int u = edge[i].to;if(!dfn[u]) {dfn[u] = 1;Tarjan(u, root);low[x] = min(low[x], low[u]);  // ****if(dfn[x] <= low[u] && x != root) cut[x] = 1; //判断割点,当前节点x的子节点u能回溯的最小祖先小于当前节点的时间戳,说明一定没有往回的路,那么当前节点一定是一个割点if(x == root) r ++; //最终回溯到了根节点} low[x] = min(low[x], dfn[u]);   // ****}if(x == root && r > 1) cut[root] = 1; //如果有2条及以上的路 能回到根节点,那么根节点也是割点
}int main() {init();int u, v, ans = 0;n = read(), m = read();for(int i = 1; i <= m; i++) {u = read(), v = read();add_edge(u, v);add_edge(v, u);}for(int i = 1; i <= n; i++)if(!dfn[i]) Tarjan(i, i);for(int i = 1; i <= n; i++) //统计割点的个数if(cut[i]) ans ++;cout << ans << "\n";for(int i = 1; i <= n; i++) //输出割点if(cut[i])  cout << i << " ";return 0;
}

3、求桥(割边)

割边(桥):在一个无向图中,如果有一个边集合,删除这个边集合以后,图的连通分量增多,就称这个边集为割边集合,如果某个割边集合只含有一条边 X(也即{X}是一个边集合),那么X称为一个割边,也叫做

和求割点的方法类似,桥的判断如下

  • uuu的子节点是vvv,当且仅当dfn[u]&lt;low[v]dfn[u] &lt; low[v]dfn[u]<low[v]时,(u,v)(u,v)(u,v)是桥

但是由于是无向图,可能会有重边的情况,为了统一处理,可以利用链式前向星存边的特性,同一条边的序号一定是相邻的,因此在更新low[x]low[x]low[x]的时候,需要判断当前边是否和上一条边相同

void Tarjan(int x, int fa) {low[x] = dfn[x] = ++c_time;for(int i = head[x]; i != -1; i = edge[i].next) {int u = edge[i].to;if(!dfn[u]) {Tarjan(u, i);low[x] = min(low[x], low[u]);if(dfn[x] < low[u]) ans++;}else if((i + 1) / 2 != (fa + 1) / 2) low[x] = min(low[x], dfn[u]); //不是同一条边}
}

例题 [HDU 4378] Caocao’s Bridges

曹操建立了许多岛屿,同时还有连接岛屿的桥,周瑜有一枚炸弹,只能炸毁一座桥,周瑜想摧毁一座桥使得曹操的一个或者多个岛屿与其他岛屿分开。周瑜必须派人携带炸弹来炸毁桥,桥上有守卫,轰炸桥的士兵人数不能少于桥的守卫人数,请问周瑜至少要多少士兵才能完成分离任务

输入

测试用例不超过121212个。

在每个测试用例中:第一行包含两个整数NNN和MMM,意味着有NNN个岛和MMM个桥。所有岛都从111到NNN编号。(2≤N≤1000,0&lt;M≤N2)(2 \leq N \leq 1000,0 &lt;M \leq N^2)(2≤N≤1000,0<M≤N2)

接下来的MMM行描述了MMM个桥。每条线包含三个整数U,VU,VU,V和WWW,意味着有一个连接岛UUU和岛VVV的桥,并且在该桥上有WWW守卫。(U≠VU≠VU̸​=V且0≤W≤10,0000 \leq W\leq 10,0000≤W≤10,000)

输入以N=0N = 0N=0和M=0M = 0M=0结束。

输出

对于每个测试用例,输出周瑜必须发送的最小士兵号码才能完成任务。如果周瑜无法成功,请输出-1代替。

样例输入

3 3
1 2 7
2 3 4
3 1 4
3 2
1 2 7
2 3 4
0 0

样例输出

-1
4

策略

#include <iostream>
#include <cstdio>
#include <cstring>
#include <cctype>
#include <algorithm>
#define N 1005
#define M 1000005
#define INF 0x7fffffff
using namespace std;inline void read(int &x) {x = 0; int f = 1; char c = getchar();while(!isdigit(c)) {if(c == '-') f = -1; c = getchar();}while(isdigit(c)){x = (x << 3) + (x << 1) + c - 48; c = getchar();}x *= f;
}struct Edge{int to, next, w;
}edge[M * 2];int head[N], low[N], dfn[N], f[N];
int cnt, c_time, ans, n, m;inline void init() {memset(head, -1, sizeof(head));memset(low, 0, sizeof(low));memset(dfn, 0, sizeof(dfn));for(int i = 1; i <= n; i ++)f[i] = i;cnt = 0;c_time = 0;ans = INF;
}inline void addEdge(int u, int v, int cost) {edge[++cnt].to = v;edge[cnt].w = cost;edge[cnt].next = head[u];head[u] = cnt;
}inline int find(int x) {if(x == f[x]) return x;else return f[x] = find(f[x]);
}
inline void unite(int x, int y) {int u = find(x);int v = find(y);f[u] = v;
}
inline bool IsSame(int x, int y) {int u = find(x);int v = find(y);if(u == v) return true;else return false;
}
inline void Tarjan(int x, int fa) {dfn[x] = low[x] = ++c_time;for(int i = head[x]; i != -1; i = edge[i].next) {int u = edge[i].to;if(!dfn[u]) {Tarjan(u, i);low[x] = min(low[x], low[u]);if(dfn[x] < low[u]) ans = min(ans, edge[i].w);//更新桥的最小权值}else if((i + 1) / 2 != (fa + 1) / 2) low[x] = min(low[x], dfn[u]);}
}
int main() {int u, v, cost, flag;while(1) {read(n), read(m);if(n == 0 && m == 0) break;flag = 0;init();for(int i = 1; i <= m; i ++) {read(u), read(v), read(cost);unite(u, v);addEdge(u, v, cost);addEdge(v, u, cost);  }for(int i = 1; i < n; i++) //判断图是否是连通的if(!IsSame(i, i+1)) {printf("0\n");flag = 1;break;}if(flag) continue;Tarjan(1, 0);if(ans == INF) ans = -1; //没有桥的情况if(ans == 0) ans++; //桥上没有人的情况printf("%d\n", ans);}return 0;
}

Tarjan(原理、应用)相关推荐

  1. 有向图的强连通分量--Tarjan算法---代码分析

    本文就是做个代码分析,顺便说下理解. 一.预备知识: 需要知道什么是: 回边.前向边.交叉边 二.上代码: #include<algorithm> #define NIL -1using ...

  2. Tarjan 算法详解

    一个神奇的算法,求最大连通分量用O(n)的时间复杂度,真实令人不可思议.转自 废话少说,先上题目 题目描述: 给出一个有向图G,求G连通分量的个数和最大连通分量. 输入: n,m,表示G有n个点,m条 ...

  3. 有向图强连通分量tarjan算法

    转自:http://www.byvoid.com/blog/scc-tarjan/ http://blog.csdn.net/geniusluzh/article/details/6601514 在有 ...

  4. POJ2186-Popular Cows(流行的奶牛)【tarjan,强连通分量,图论】

    正题 题目链接 大意 有n头奶牛,奶牛们会认为有些奶牛很受欢迎,受欢迎会互相传递,如:如果A认为B很受欢迎,而B认为C受欢迎,那么A也会认为C是受欢迎的.然后求每一个奶牛都认为受欢迎的奶牛数量. 解题 ...

  5. 图论 —— 图的连通性 —— Tarjan 求双连通分量

    [概念] 1.双连通分量:对于一个无向图,其边/点连通度大于1,满足任意两点之间,能通过两条或两条以上没有任何重复边的路到达的图,即删掉任意边/点后,图仍是连通的 2.分类: 1)点双连通图:点连通度 ...

  6. 图论 —— 图的连通性 —— Tarjan 求割点与桥

    [概念] 1.割点 1)割点:删除某点后,整个图变为不连通的两个部分的点 2)割点集合:在一个无向图中删除该集合中的所有点,能使原图变成互不相连的连通块的点的集合 3)点连通度:最小割点集合点数 如上 ...

  7. 强连通基础与例题(Kosaraju算法与Tarjan算法)

    目录 Kosaraju算法 Tarjan算法 例题 A:HDU-1269 迷宫城堡 B:HDU-2767 Proving Equivalences C:HDU-1827 Summer Holiday ...

  8. Tarjan算法小结1——SCC

    引入 许多最短单源路径算法,如Dijkstra,SPFA, floyd, Bellman-Ford等,在运用时只能给出指定点到任意点的最短距离,抑或是给出图中是否有环的信息,并不能准确确定环的个数.包 ...

  9. 洛谷·[POI2005]SKA-Piggy Banks 小猪存钱罐【Tarjan 并查集

    初见安~这里是传送门:洛谷P3420 题目描述3 Byteazar the Dragon has NN piggy banks. Each piggy bank can either be opene ...

最新文章

  1. windows系统中hosts文件位置
  2. 支持向量机(SVM)PPT
  3. html vue分页,Vue.js bootstrap前端实现分页和排序
  4. 【数据结构】树状数组
  5. tde数据库加密_如何在TDE加密的数据库上配置SQL Server镜像
  6. 第三方侧滑菜单SlidingMenu在android studio中的使用
  7. IOS音频1:之采用四种方式播放音频文件(一)AudioToolbox AVFoundation OpenAL AUDIO QUEUE...
  8. KL散度、JS散度以及交叉熵对比
  9. freeSHHd+puttygen搭建Sftp
  10. python 基础代谢率计算_【Python 19】BMR计算器3.0(字符串分割与格式化输出)
  11. 计算机没有安装鼠标和键盘驱动,鼠标不能用如何安装驱动程序-使用键盘安装鼠标驱动的方法 - 河东软件园...
  12. Android之简洁天气
  13. 台式机win10关闭fn热键_笔记本fn键,小编告诉你笔记本fn键怎么取消
  14. [OpenCV实战]23 使用OpenCV获取高动态范围成像HDR
  15. 粒子群课设_粒子群算法(人工智能结课论文)
  16. I.MX6Q(TQIMX6Q)--资料汇总
  17. 中国历史各王朝的知识点总结记忆
  18. Pytorch:CycleGAN代码中nn.Sequential(*module)处错误:list is not a Module subclass
  19. 百家号自媒体文章出现哪些因素会不推荐?
  20. 详谈redis命令之集合(SET)

热门文章

  1. Dispersun_DSP-OL300聚羟基硬脂酸TDS应用说明书
  2. 如何让我的电脑监控我手机的请求
  3. RMI以及JMS精品教学视频下载 java
  4. 小程序图片上传到服务器
  5. MediBang Paint Pro超级精简版/超精简/懂你版
  6. android的充电图标显示
  7. linux egrep用法,grep,egrep及相应的正则表达式用法详解
  8. vivo X5Pro D(32G版) 刷机包_线刷包_刷机教程
  9. 幼师转行成为产品经理,入行月薪13K!
  10. 九 iOS 之CAAnimationGroup(动画组)