算法导论——图论总结

图论总结

G=(V,E),V代表图中节点的合集,E代表图中边或是关系的合集。

稠密图:图中E的条数接近V*V也就是,接近任意两点之间相连。
稀疏图:图中E的条数远小于V*V

图的数据结构

图常用的有两种表示方式,邻接链表和邻接矩阵。
邻接矩阵和邻接链表都中存储的信息都只是点与点的关系。并不表示点的信息,如果要表示点的信息,需要一个额外的容器,存储。
比如,i节点代表某个村庄,该村庄有村名,村民数等信息,这些信息需要额外存储在一个容器中。比如vector<T> detial

邻接矩阵

使用一个V*V的二位数组ve表示矩阵,假设i节点于j节点连通,那么ve[i][j]=1
邻接矩阵可以表示有向图,和有权重图:ve[j][i]=3表示j->i权重为3。
优点

  1. 对于稠密图能够有效的节省空间。
  2. 表示上很直观,容易判断出那两点之间相连。

缺点

  1. 在矩阵生成时,节点个数就已经确定,只能添加边,不能再添加节点。
    如果要添加节点,那么需要重新分配矩阵。代价大。
  2. 对于稀疏图,浪费空间.
  3. 在某些算法的时候需要为节点保存额外信息的时候,需要使用额外的容器

《算法导论——图论总结》 邻接矩阵

邻接链表

使用一个结构体来表示每个节点:

struct Node
{
  int ket;
  Node to;
  //..其他信息
}

其中to变量指向链接该节点i指向的第一个节点j,然后j节点中的to指向i节点指向的第二个节点,以此类推。
邻接矩阵表示的方法灵活方便,能够临时添加节点,和边。
表示有向图,有权重图,还可以根据算法,添加不同的变量。但同时也增加了体积。

缺点

  1. 稠密图中浪费空间
  2. 不直观,在有向图中如果要同时表示指向本节点的节点,和本节点指向的节点,需要额外的一个字段。

《算法导论——图论总结》 邻接链表

同是还有其他的一些表示方式,但这两种是最常见的。

《算法导论——图论总结》 image.png

图的相关算法

图中要的算法有很多:

  1. 遍历和搜索
    深度优先搜索:该遍历方法不能找到最短路径,并不是专用语搜索路径。但是其搜索的性质(尽可能向深处,触底返回)常用于其他方向,比如拓扑排序。
    广度优先搜索:该遍历方法能够找到最短路径,因此常用于最短路径搜索,还有某些与最短,最近等相关的算法应用

遍历和搜索

广度优先搜索BFS

广度优先搜索,的搜索过程为:

  • 每次搜索的节点一定是距离起始点最近的节点(这里的最近不是指权重,而是假设权重为1)。
  • 一层一层的搜索,一层搜索完以后在去搜索另一层。

因为上述的搜索过程,广度优先搜索能够找到最短路径。
深度优先遍历,一般为了寻找最短路基,因此不需要考虑不能到达的点

实现

非递归实现
广度优先搜索适合使用非递归实现。
其搜索过程,适合使用队列deque
同时需要有一个memo用来记录,是否某个节点是否被遍历过了。(因为环的存在)

int Graph::findBfs(int start,int end)
{
    deque<int> de;
    //用来记录节点是否被遍历过。
    vector<bool> memo(Vcontainer.size(),false);
    //先将其实节点push进去
    //其实点距离自己的距离为0,父节点设为自己吧。
    Vcontainer[start]->d=0;
    Vcontainer[start]->p=start;
    
    deque.push_back(start);
    memo[start] = true;

    int curKey;
    Node *tmp;
    while(!de.empty())
    {
        curKey=de.front();
        //处理cur:可是是比较是否等于终点,可能是其他,
        //这里处理两点之间的最短距离
        
        if(curKey == end )
        {
            return Vcontainer[curKey]->d;
        }

        //处理结束,将该节点弹出。
        de.pop_front();

        //节点处理完毕以后,将节点指向的节点以此添加到队列中
        tmp=Vcontainer[curKey];
        //当前节点有指向别的节点
        while(tmp->to != NULL)
        {
            //当前指向的节点还没有遍历过
            if(memo[tmp->to->key] == false)
            {
                tmp->to->d = tmp->d+1;
                tmp->to->p = tm->key;
                de.push_back(tmp->to->key);
                memo[tmp->to->key]=true;                
            }
            //指向当前节点,指向的下一个节点
            tmp = tmp->to;
        }
    }
    //能够达到这里说明每个节点都遍历过了,
    //以为这两点之间不联通,可能是有向图,或者不是一个连通图
    return -1;
}

说明:

  1. 在算法导论中,使用的mome有三种状态,未遍历,正在遍历(在队列中,还未处理),遍历完成(从队列中取出遍历完成)。
  2. dp
    d中存储了该节点到其实点的距离。(假设权重为1,即使不为1).
    p中存储,一个节点的父节点:所以只需要从endstart逆向遍历,就可以获得最短路径。

递归实现
广度优先搜索适合用非递归实现。
递归代码以后想想。
应用
广度优先搜索的思想在于寻找最短路径,分层遍历,只要一个点被遍历,那么这个点一定是当前距离起始点最近。同时,也是该点距离起始点最近的距离,如果后续还能遍历到该点,一定不是最近距离了(我们在第二次遍历到该点的时候并不会将该点添加进队列,不会去处理他)
凡是能够用到广度优先搜索性质的算法大多都可以使用广度优先搜索的思想:
比如:

  1. 01反转的题:从最终状态逆向搜索到起始状态。

深度优先搜索DFS

深度优先搜索的搜索过程为:
沿着某一某一条路径尽可能的向深处遍历,触底以后返回一步,分了一个小叉,继续向深处遍历,触底再返回。

因为上述的搜索过程,深度优先遍历并不能找到最短路径。

实现

非递归实现
深度优先搜索适合用递归进行实现,如果要使用非递归实现,需要手动维护一个栈。

递归实现
递归实现的深度优先搜索,并不能停止,如果需要提前停止,需要一个额外的变量,来标记停止,而且需要是引用或者是指针。

void Graph::dfs_re_func(int curKey,vector<int> &memo)
{
    //如果想要尽早尽快结束遍历,需要额外的变量:引用或者指针
    //这里使用:finded
    //处理该节点,同时设置结束表示
    cout<<curKey<<endl;

    //标记为处理结束
    memo[curKey] = true;
    
    //处理结束,将遍历该节点指向的节点
    Node *tmp=vContainer[curKey];

    //由于是递归,tmp保存在栈中,同一层变量是同一个
    while(tmp->to != NULL)
    {
        if(memo[tmp->to->key] == false)
        {
            tmp->to->d = tmp->d+1;
            tmp->to->p = tm->key;
            dfs_re_func(tmp->to->key,end,memo)
            //节点返回代表该极点的子节点都处理完毕了。
            
            //如果希望尽快结束遍历,需要额外的变量:引用或者指针
            //这里检查结束标志return
        }
        //去遍历同层的下一个
        tmp = tmp->to;
    }
}


int Graph::dfs_re()
{
    vector<bool> memo;
    //对于有向图,可能存在多个不能到达的点,所以需要依次遍历:
    for(Node *i:vContainer)
    {
        dfs_re_func(i->key,memo);
    }
    return finded;
}
应用

深度优先搜索,搜索性质:
只有一个节点的子节点(也就是该节点指向的节点,和指向节点的子节点)都处理完,该节点才算处理完。
能生成一个节点与节点之间先后顺序的图。
比如,A->B,C->B,那么B需要在AC都发生以后才能发生。

《算法导论——图论总结》 深度优先搜索生成的树

拓扑排序

图中节点视为事件,拓扑排序的目的是依据图中事件发生的先后顺序给出一个可能的排序

图能够进行图谱排序的前提:

  • 有向图:有向图才能表示两件事情之间发生的先后关系
  • 无环:也就是不存在环,如果存在的话,那么事件发生的先后关系将不确定

全序和偏序

全序指的是一个集合中任意两个元素之间能够比较,也就是说能够排序
偏序指的是,集合中存在不能比较的元素(这里的不能是指的某一对之间不能,而不是该元素和其他元素不能比较)
比如说,快排是一种不稳定的排序,因为两个值相同的元素之间的顺序是不稳定的。(同一个数组,如果分隔元素不随机选的话也是一个稳定的?)
而选择排序除了比较指的大小以外,还比较了两个元素在数组中出现的顺序。

《算法导论——图论总结》 拓扑排序

深度优先搜索实现拓扑排序

是有深度优先搜索的性质

递归的深度优先搜索保证了,只有当一个节点的所有自己点都遍历结束以后,该节点才算是遍历结束。
利用这个性质,当一个点遍历结束以后就将该点添加进容器。

实现

void Graph::dfsTopo_func(int curKey, vector<int> &result ,vector<bool> &memo )
{

    memo[curKey]=true;
    Node *tmp = vContainer[curKey];
    while (tmp->to != NULL)
    {
        curKey=tmp->to->key;
        if (memo[curKey] == false)
        {
            dfs_re_func(curKey, result, memo);
            //当该递归返回时,表示该节点处理完了,
            //并且该节点所有的自己点,以及指向的子节点也都处理完了
            result.push_back(curKey);
        }
        tmp = tmp->to;
    }
}

vector<int> Graph::dfsTopo()
{
    vector<int> result;
    vector<bool> memo;
    
    for (Node *i : vContainer)
    {
        dfsTopo_func(i->key, result,memo) ;
    }
    reserve(result.begin(),result.end());
    return result;
}

如图,上面右下角部分,result中应该存储为:10,12,11,9。反转以后,和拓扑排序的结果相同。

Kahn算法

根据入度计算

算法步骤:

  1. 计算所有节点的入度,存放在indegree
  2. 将入度为另的节点放入队列中中,
  3. 每次从de中取元素(随即),放入result中。
    并遍历取到元素指向的节点,将指向节点的入度–,如果此时指向节点的入度为0,那么将该节点放入de中。
  4. 循环地3步。
    实现
void Graph::kahnTopolgical_sort()
{
    vector<int> result;//存放最终结果
    deque<int> de;//存放入度为0的节点
    vector<int> indegree(vContainer.size(),0);//存放节点的入度

    //计算所有节点的入度
    for (Node *i : vContainer)
    {
        while (i->to != NULL)
        {
            indegree[i->to->key]++;
            i = i->to;
        }
    }
    //将入度为0的节点放入de中
    for(int i= 0;i<indegree)
    {
        if(0 == indegree[i])
        {
            de.push_back(i);
        }
    }

    int curKey;
    Node *curNode;
    //每次从de中取元素,然后遍历其指向的元素
    //并将指向节点的入度--,如果为0,也放入de。
    while(!de.empty())
    {
        curKey = de.top();
        de.pop_front();
        
        while(curNode->to != NULL)
        {
            curNode = curNode->to;
            curKey = curNode->key;
            indegree[curKey]--;
            if(0 == indegree[curKey])
            {
                de.push(curKey);
            }
        }
    }
}

如图,4节点一定是在5节点添加到result中以后才能遍历到4。先后顺序有了保证。

强连通分量

两点强连通:图中两点可以相互到达,i可以到j,j也可以到i
强连通分量:图中所有的尽可能多的点可以相互到达的集合的集合。

《算法导论——图论总结》 强连通图

如图,有四个强连通子图,这四个组成的集合成为强连通分量。

对于无向图,如果顶点之间是连通的那就是一个强连通图,有几个不联通的,就有几个前联通分量。
对与有向图来说,不是这样的:一个联通的图,可能有多个联通分量,如上图。

Kosaraju

深度优先搜索实现

该算法是这样的:

  1. 对图进行深度优先遍历,记录每个点的发现时间,和处理完成时间
    图可能是不连通的所以需要对每个点都遍历一次,有一个memo记录是否遍历过。
  2. 对图的反响图进行深度优先遍历,节点的遍历顺序按照正向图中节点的处理完成时间的从大到小顺序进行遍历。
    每从新遍历一次(就是最外面的那个for),那么之前一次遍历就构建了一个前联通分量。
void Graph::dfs(int curKey, int &time ,vector<bool> &memo)
{

    memo[curKey]=true;
    Node *tmp = vContainer[curKey];
    while (tmp->to != NULL)
    {
        tmp=tmp->to;
        curKey=tmp->key;
        if (memo[curKey] == false)
        {
            tmp.reach = ++time;
            dfs(curKey, time, memo);
            tmp.leave = ++time;
        }
        tmp = tmp->to;
    }
}

void Graph::ksaraju_dfs(int curKey,vector<int> &memo,vector<int> &scc,&vContainer)
{
    memo[curKey]=true;
    Node *tmp = vContainer[curKey];
    while (tmp->to != NULL)
    {
        tmp=tmp->to;
        curKey=tmp->key;
        if (memo[curKey] == false)
        {
            scc.push_back(curKey);
            ksaraju_dfs(curKey, memo, vContainer,scc);
        }
        tmp = tmp->to;
    }
}

void Graph::ksaraju_calOrder(vector<Node*> &vContainer,&vector<int> order)
{
    int total = (vContainer.size()+1)*2;
    for(int i = total;total>0;i--)
    {
        for(Node *node:vContainer)
        {
            if(node->leave == total)
            {
                order.push_back(node->key);
                continue;
            }
        }
    }
}
void Graph::ksaraju_reverse(vector<Node *> &oldG,vector<Node*> &newG)
{
    //...
}


vector<vector<int>> Graph::ksaraju()
{

    //深度优先搜索。
    int time=0;
    vector<bool> memo(vContainer.size(),false);
    for(Node *i:vContainer)
    {
        if(memo[i->key]==false)
        {
            dfs(i->key,time,memo);
        }
    }

    //获取反向图遍历节点的顺序
    vector<int> order;
    ksaraju_calOrder(vContainer,order);

    //构造反向图
    vector<Node *> reverseGraph;
    ksaraju_reverse(vContainer,reverseGraph);

    //根据正线图遍历离开节点时间,从大到小的顺序遍历
    vector<vector> result;
    memo=vector<bool>(reverseGraph.size(),false);
    for(int curKey:order)
    {
        if(memo[curKey]==fasle)
        {
            vector<int> scc;
            ksaraju_dfs(i->key,memo,scc,reverseGraph);
            //这里添加的是副本
            result.push_back(scc);
        }
    }
}

Tarjan算法

深度优先搜索实现参考 参考2
该算法实际上是在利用深搜找环。

该算法是这样的:

  • reach记录一个节点的发现时间。
  • low记录一个节点是否能在一个环中。
  • flag记录一个节点是否已经属于一个强连通分量(false代表是,true代表还没有分配强连通分量)
  • 一个stack用来保存遍历过的节点。

上面三者的配合是这样的:

  1. 在处理每个节点的时候,lowreach都设置为发现时间,其中reach的值始终是发现时间。flag设置为true,表示节点还没有分配在一个强连通分量中。节点压入stack中。
  2. 遍历其子节点,如果节点没有子节点,那么reach = low,也就是该节点肯定不会存在在某个环中(也就是强连通分量中),因为节点在一个环中,那么节点一定有子节点,所以该节点应该自己成为一个强联通分量。
  3. 处理完一个节点以后,会进行最终结果的构造。如果当前节点的low = reach,那么就从栈顶弹出(栈顶元素就是该元素),然后将他压入一个scc中,当栈顶元素,不等于当前元素的时候结束。同时标记该节点已经是在一个强连通分量中flag = false.
  4. 如果子节点已经被遍历过了,也就是reach != 0,此时没如果子节点已经被分配在一个强连通分量中flag =true.那么将当前节点的low设置为子节点的reach表示该节点已经在一个环中了。
    处理结束以后进行最终结果的构造,但是该节点的low = reach所以跳过容器的构造了。
  5. 如果一个节点已经被遍历过了,而且flag = fasle那么直接跳过,没有这个条件的处理代码。
  6. 如果一个节点没有被遍历过,那么递归遍历该节点,然后当递归返回的时候,去判断该节点的low和子节点的low,如果子节点的low小于父节点low,表示子节点已经在一个环中了(所以才修改了low),那么父节点也应该是在一个环中了,所以该节点low设置为子节点的low

最终结果构造代码

    int tmp;
    if(low[curKey] == reach[curKey])
    {
        do
        {
            tmp = st.top();
            st.pop();
            scc.push_back(tmp);
            flag[curKey] = false;
        }while(curKey != tmp);
        result.push_back(scc);
        scc.erase(scc.begin(),scc.end());        
    }

上面这段代码,是在环开始节点处理完毕以后才调用(也就是环中最先被压入栈中的节点),这时候才会构建这个强连通分量:原因在与子节点的low都设置为环开始节点的reach。然后从栈顶向下,直到取出了该有节点。

在该算法中也就是说:强连通分量是在递归调用的同时建立的。

Gabow算法

我可去他妈的Tarjan算法吧,以后再看这个。

最小生成树

应用在无向图,带权重图中。选择部分边将所有的点连接起来,使权重最小。

安全边:指的是最小生生成树中的边。

而最小生成树的算法就是在寻找安全边。
下面的两种方法都使用贪心算法

Krukal

每次选取剩下的边中,权重最小的边。

Krukal算法的过程是:
遍历选取目前还未选取的边中权重最小的边,并且这条边需要满足:边链接的两个点中,只能有一个或0个点已经被边所链接。

Prim算法

每次从连接的点集合中,选取权重最小的边

Prim算法的过程是:
任选一个点放入点集合,每次从点集合中选取所有点中,点指出的边中权重最小的且指向的边没有被选中的边,并将指向的点加入点集合中。直到所有点被放入集合。

单源最短路径

在有向,有权重,可能有负权重,可能有环的图中,从某个点到另一个点的最短路径。

松弛操作:改变当前节点到起点的距离

有向无环的有权重的图中

直接广度优先搜索同时进行松弛操作,同时进行松弛操作,但是在搜索的过程中不需要记录某个点是否被搜索过了。因为有权重,需要松弛。

这种图中直接一遍广度优先搜索就可以了

int Graph::nonLoopShortest_dfs(int start,int end)
{
    deque<int> de;
    de.push_push(start);
    vContainer[start].d=0;
    vContainer[start].p=0;
    
    shared_ptr<Node> childNode;
    shared_ptr<Node> curNode;
    int curKey;
    int childKey;
    int tmpDis;

    //可以先计算入度,然后在每次遍历到end节点的时候入度--。为0的时候可以返回。

    int endIndegree ;
    while(!de.empty)
    {
        curKey = de.front();
        de.pop_front();
        //
        //这里可以处理endIndegree
        //

        curNode.reset(new Node(vContainer[curKey]));
        while(curNode->to != NULL)
        {
            childKey = curNode->to->key;
            tmpDis=vContainer[start].d+vContainer[childKey].weight;
            //如果该条路径到达子节点的路径短,那么需要松弛操作,同时重新计算子节点后面的节点。
            if(vContainer[childKey].d > tmpDis)
            {
                vContainer[childKey].d=tmpDis;
                vContainer[childKey].p=start;
                de.push_back(childKey);
            }
            curNode = curNode->to;
        }
    }
    //最后才能返回d的值。因为无法判断有机条路径能够到达d;
    return vContainer[end].d;
}

Bellman-Ford算法

有向,带权重,带环,负权重中的最短路径

方法的过程和上面的算法过程相同,只是需要循环V-1次。

Dijkstra算法

适用于无负权重的图

改进的是Bellman-Ford方法选取边的方式。Dijkstra方法,总是选取目前.d值最小的,并且没有选取过的点,然后计算其指向的边。
用最小堆来维护,目前距离起始点最短的节点。


struct HeapSort
{
    int k;
    int v;
}

//建堆用的递归函数
void Graph::dijkstra_smallheap_build(int start, vector<HeapSort> &heap)
{
    int l = start * 2;
    int r = start * 2 + 1;
    int index = start;
    if (l < heap.size() && heap[index] > heap[l])
    {
        index = l;
    }
    if (r < heap.size() && heap[index] > heap[r])
    {
        index = r;
    }
    if (index != start)
    {
        swap(heap[start], heap[index]);
        dijkstra_smallheap_build(index, heap);
    }
}

//想堆中添加元素
void Graph::dijkstra_smallheap_add(vector<HeapSort> &smallheap, vector<HeapSort> &added)
{
    smallheap.insert(smallheap.end(),added.begin(),added.end());

    for(int i=smallheap.size()/2+1;i>0;i--)
    {
        dijkstra_smallheap_build(i,smallheap);
    }

}
//取出堆顶元素
HeapSort Graph::dijkstra_smallheap_take(vector<HeapSort> &smallheap)
{

    HeapSort ret= smallheap[1];
    smallheap[1]=smallheap.back();
    smallheap.pop_back();
    dijkstra_smallheap_build(1,smallheap);
    return ret;
}

//主要算法
int Graph::dijkstra(int start,int end)
{
    vector<HeapSort> smallheap;
    vector<HeapSort> added;
    dijkstra_smallheap_add(HeapSort(0,start));

    shared_ptr<Node> curNode;
    int tmpDis;
    while(!smallheap.empty())
    {
        HeapSort cur=dijkstra_smallheap_take(smallheap);
        
        curNode = make_shared<Node>(new Node(vContainer[cur.v]);
        added.resize(0);
        while(curNode->to != NULL)
        {
            tmpDis = vContainer[cur.v].d+curNode->to->weight;
            if(tmpDis <  vContainer[urNode->to->key].d)
            {
                vContainer[urNode->to->key].d=tmpDis;
                added.push_back(HeapSort(tmpDis,curNode->to->key));
            }
            curNode=curNode->to;
        }
        dijkstra_smallheap_add(smallheap,added);
    }
    return vContainer[end].d;
}

所有节点对的最短路径问题

Floyd-Warshall算法

最短路径的动态规划解法

Johnson算法

最大流

从某点到另一点的最大流量

Ford-Fulkerson方法

这是一种方法,一种步骤

Ford-Fulkerson方法解决最大流问题依赖于:残存网络,增广路径。
残存网络:一条边有权重weight,当分配一定的容量used以后,该边表示为used/weight。所有的边都这样标记,那么图就是一个残存网络。
增广路径:将weight-used视为权重(权重为0视为不能通过),能够从其实点到达中哦功能点的路径,成为增广路径。而该路径的流量为,路径上最小“权重”边的“权重”。

在某些情况下,可能之前搜索并添加的一条增广路径,并不合适,占用了边的权重,也就是说这条增广路径并不合理。所以需要提供一种机制能够抵消。

当一条边分配了一定的容量used时,同时也提供了一条反向的边,权重为used(随着used一直变化)。同时这天边也属于残存网络。

Ford-Fulkerson方法就是不断的搜索增广路径并添加,直到搜索不能增广路径。
此时,边几乎都指向起始点的方向(权重为weight,也就是反向边)。

Edmonds-Karp

使用深度优先搜索的Ford-Fulkerson方法实现。

  bool Graph::edmonds_dfs(int start, int end, int &pathMin, vector<vector<int>> &passedNode, vector<bool> memo)
{
    shared_ptr<Node> curNode(new Node(vContainer[start]));
    memo[start] = true;
    if (start = end)
    {
        for (vector<int> v : passedNode)
        {
            if (v[1] == 1)
            {
                vContainer[v[0]].used + pathMin;
            }
            else
            {
                vContainer[v[0]].used - pathMin;
            }
        }
        return true;
    }
    while (curNode->to != NULL)
    {
        if (vContainer[curNode->to->key].weight >
                vContainer[curNode->to->key].used &&
            memo[curNode->to->key] == false)
        {
            if (pathMin > (vContainer[curNode->to->key].weight -
                           vContainer[curNode->to->key].used))
            {
                pathMin = vContainer[curNode->to->key].weight - vContainer[curNode->to->key].used;
            }
                passedNode.push_back(vector<int>(curNode -> to->key,1));
                vContainer[curNode -> to->key].usedTo=start;
                edmonds_dfs(curNode -> to->key,end,pathMin,passedNode,memo);
        }
        else if (vContainer[curNode->to->key].used > 0 && memo[vContainer[curNode->to->key].usedTo] == false)
        {
            if(pathMin > vContainer[curNode -> to->key].used)
            {
                pathMin = vContainer[curNode -> to->key].weight - vContainer[curNode -> to->key].used;
            }

                pathMin = vContainer[curNode->to->key].used;
                passedNode.push_back(vector<int>(curNode -> to->key,0));
                edmonds_dfs(vContainer[curNode -> to->key].usedTo,end,pathMin,passedNode,memo);
        }
        curNode=curNode->to;
    }
    return false;
}


int Graph::edmonds(int start, int end)
{
    int pathMin = INT32_MAX;
    vector<vector<int>> passedNode;
    vector<bool> memo(vContainer.size() + 1, false);
    while(edmonds_dfs(start,end,pathMin,passedNode,memo))
    {
    }
    return vContainer[start].used;
}
Dinic算法
    原文作者:Myth52125
    原文地址: https://www.jianshu.com/p/fb2270a595c5
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞