普里姆(Prim)演算法

kokiafan發表於2021-05-23

概覽

普里姆演算法(Prim演算法),圖論中的一種演算法,可在加權連通圖(即“帶權圖”)裡搜尋最小生成樹。即此演算法搜尋到的邊(Edge)子集所構成的樹中,不但包括了連通圖裡的所有頂點(Vertex)且其所有邊的權值之和最小。

(注:N個頂點的圖中,其最小生成樹的邊為N-1條,且各邊之和最小。樹的每一個節點(除根節點)有且只有一個前驅,所以,只有N-1條邊。)

該演算法於1930年由捷克數學家沃伊捷赫·亞爾尼克(Vojtěch Jarník)發現;並在1957年由美國電腦科學家羅伯特·普里姆(Robert C. Prim)獨立發現;1959年,艾茲格·迪科斯徹再次發現了該演算法。因此,在某些場合,普里姆演算法又被稱為DJP演算法、亞爾尼克演算法或普里姆-亞爾尼克演算法。

定義

假設G=(V, {E})是連通網,TE是N上最小生成樹中邊(Edge)的集合。V是圖G的頂點的集合,E是圖G的邊的集合。演算法從U={u0} (u0∈V),TE={}開始。重複執行下述操作:

  • 在所有u∈U,v∈V-U的邊(u, v)∈E中找一條代價(權值)最小的邊(u0, v0)併入集合TE。
  • 同時v0併入U
  • 直至U=V為止。此時TE中必有n-1條邊,則T=(V, {TE})為N的最小生成樹。

由演算法程式碼中的迴圈巢狀可得知此演算法的時間複雜度為O(n2)。

過程簡述

輸入:帶權連通圖(即“網”)G,其頂點的集合為V,邊的集合為E。
初始:U={u},u為從V中任意選取頂點,作為起始點;TE={}。
操作:重複以下操作,直到U=V,即兩個集合相等。

  • 在集合E中選取權值最小的邊(u, v),u∈U,v∈V且v∉U。(如果存在多條滿足前述條件,即權值相同的邊,則可任意選取其中之一。)
  • 將v併入U,將(u, v)邊加入TE。
    輸出:用集合U和TE來描述所得到的最小生成樹。

如何實現

圖G的鄰接矩陣

如上面的這個圖G=(V, {E}),其中:
V={v0, v1, v2, v3, v4, v5, v6, v7, v8},
E= {(v0, v1), (v0, v5), (v1, v6), (v5, v6), (v1, v8), (v1, v2), (v2, v8), (v6, v7), (v3, v6), (v4, v5), (v4, v7), (v3, v7), (v3, v4), (v3,v8), (v2, v3)}

用鄰接矩陣表示該圖G,得上圖右邊的鄰接矩陣。
此圖G有n = 9個頂點,其最小生成樹則必有n-1 = 8條邊。
(注意:圖G的最小生成樹是一棵樹,且圖G中的每個頂點都在這棵樹裡,故必含有n個頂點;而除樹根節點,每個節點有且只有一個前驅,所以圖G的最小生成樹有且只有n-1條邊。若邊數大於n-1,則必有樹中某個頂點與另一個頂點存在第二條邊,從而不能構成樹。樹中節點是一對多關係而不是多對多關係。)

①輸入:帶權連通圖G=(V, {E}),求圖G的最小生成樹。
②初始:U={u},取圖G中的v0作為u,用陣列adjVex=int[9]來表示U(最終U要等於V),adjVex陣列記錄的是U中頂點的下標。U是最小生成樹T的各邊的起始頂點的集合。
adjVex初始值為[0, 0, 0, 0, 0, 0, 0, 0, 0],表示從頂點v0開始去尋找權值最小的邊。
用陣列lowCost = int[9] 表示adjVex中各點到集合V中頂點構成的邊的權值。lowCost陣列中元素的索引即是頂點V的下標。解釋:adjVex[3] == 0,表示v0,adjVex[5] == 0,表示v0。lowCost[3] == ∞且adjVex[3] == 0,表示(v0, v3)邊不存在;lowCost[5] == 11且adjVex[5] == 0,表示(v0, v5)邊的權值為11。

如:鄰接矩陣中的v0行,v0頂點與各頂點構成的邊及其權值用下面這的方式表示:

示例一

索引:index [0, 1, 2, 3, 4, 5, 6, 7, 8]
權值:lowCost[0, 10, ∞, ∞, ∞, 11, ∞, ∞, ∞]
下標:adjVex [0, 0, 0, 0, 0, 0, 0, 0, 0]

(v0, v0, 0), (v0, v3, ∞), (v0, v5, 11)

0表示以該頂點為終點的邊已經併入圖G的最小生成樹的邊集合——TE集合,不需要再比較(搜尋)。
∞表示以該頂點為終點的邊不存在。

③操作:

  1. 上面示例一中,最小的權值為10,此時lowCost中下標k = 1,相應地adjVex[k]即adjVex[1] == 0,記錄下此時的邊為(v0, v1)。
  2. 將adjVex[k]即adjVex[1]設為1,表示將頂點v1放入圖G的最小生成樹的頂點集合U中。
  3. 將lowCost[k]即lowCost[1]設為0,表示以v1為終止點的邊已搜尋。
  4. 然後,將焦點轉向頂點v1,看看從v1開始的邊有哪些是權值小於從之前的頂點v0開始的邊的。此時k == 1。則有以下過程:

索引:index [ 0, 1, 2, 3, 4, 5, 6, 7, 8]
權值:lowCost[ 0, 0, ∞, ∞, ∞, 11, ∞, ∞, ∞]
下標:adjVex [ 0, 1, 0, 0, 0, 0, 0, 0, 0]
頂點:vex[1] [10, 0,18, ∞, ∞, ∞,16, ∞,12]

由於lowCost[0]和lowCost[1]為0,所以從lowCost[2]開始比較權值。vex[1][2] == 18 < lowCost[2],意思是(v0, v2)==∞不存在這條邊,(v1, v2) == 18,存在權為18的邊(v1, v2),類似的還有vex[1][6] == 16 < lowCost[6]和vex[1][8] == 12 < lowCost[8]。
把k == 1賦值給
adjVex[2]、adjVex[6]和adjVex[8]。
把權值18、16和12賦值給
lowCost[2]、lowCost[6]和lowCost[8]。

更新後的權值陣列和鄰接頂點陣列如下:

索引:index [ 0, 1, 2, 3, 4, 5, 6, 7, 8]
權值:lowCost[ 0, 0, 18, ∞, ∞, 11, 16, ∞, 12]
下標:adjVex [ 0, 1, 1, 0, 0, 0, 1, 0, 1]

故下次迴圈從頂點v1為起點去搜尋lowCost中權值最小的邊。如此往復迴圈,直到圖G中的每一個頂點都被遍歷到。(鄰接矩陣的每一行都被遍歷到)

④輸出:

演示過程

Loop 1
lowCost: [ 0, 10, ∞, ∞, ∞, 11, ∞, ∞, ∞ ]
adjVex: [ 0, 0, 0, 0, 0, 0, 0, 0, 0 ]

lowCost: [ 0, 0, 18, ∞, ∞, 11, 16, ∞, 12 ]
adjVex: [ 0, 1, 1, 0, 0, 0, 1, 0, 1 ]

Loop 2
lowCost: [ 0, 0, 18, ∞, ∞, 11, 16, ∞, 12 ]
adjVex: [ 0, 1, 1, 0, 0, 0, 1, 0, 1 ]

lowCost: [ 0, 0, 18, ∞, 26, 0, 16, ∞, 12 ]
adjVex: [ 0, 1, 5, 0, 5, 0, 1, 0, 1 ]

Loop 3
lowCost: [ 0, 0, 18, ∞, 26, 0, 16, ∞, 12 ]
adjVex: [ 0, 1, 5, 0, 5, 0, 1, 0, 1 ]

lowCost: [ 0, 0, 8, 21, 26, 0, 16, ∞, 0 ]
adjVex: [ 0, 1, 8, 8, 5, 0, 1, 0, 1 ]

Loop 4
lowCost: [ 0, 0, 8, 21, 26, 0, 16, ∞, 0 ]
adjVex: [ 0, 1, 8, 8, 5, 0, 1, 0, 1 ]

lowCost: [ 0, 0, 0, 21, 26, 0, 16, ∞, 0 ]
adjVex: [ 0, 1, 8, 8, 2, 0, 1, 0, 1 ]

Loop 5
lowCost: [ 0, 0, 0, 21, 26, 0, 16, ∞, 0 ]
adjVex: [ 0, 1, 8, 8, 2, 0, 1, 0, 1 ]

lowCost: [ 0, 0, 0, 21, 26, 0, 0, 19, 0 ]
adjVex: [ 0, 1, 8, 8, 2, 6, 1, 6, 1 ]

Loop 6
lowCost: [ 0, 0, 0, 21, 26, 0, 0, 19, 0 ]
adjVex: [ 0, 1, 8, 8, 2, 6, 1, 6, 1 ]

lowCost: [ 0, 0, 0, 16, 7, 0, 0, 0, 0 ]
adjVex: [ 0, 1, 8, 7, 7, 6, 7, 6, 1 ]

Loop 7
lowCost: [ 0, 0, 0, 16, 7, 0, 0, 0, 0 ]
adjVex: [ 0, 1, 8, 7, 7, 6, 7, 6, 1 ]

lowCost: [ 0, 0, 0, 16, 0, 0, 0, 0, 0 ]
adjVex: [ 0, 1, 8, 7, 7, 6, 7, 4, 1 ]

Loop 8
lowCost: [ 0, 0, 0, 16, 0, 0, 0, 0, 0 ]
adjVex: [ 0, 1, 8, 7, 7, 6, 7, 4, 1 ]

lowCost: [ 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
adjVex: [ 0, 1, 8, 7, 7, 6, 7, 4, 3 ]

執行結果

(0, 1)
(0, 5)
(1, 8)
(8, 2)
(1, 6)
(6, 7)
(7, 4)
(7, 3)

演算法程式碼

C#程式碼

using System;

namespace Prim
{
    class Program
    {
        static void Main(string[] args)
        {
            int numberOfVertexes = 9,
                infinity = int.MaxValue;

            int[][] graph = new int[][] {
                new int[]{0, 10, infinity, infinity, infinity, 11, infinity, infinity, infinity },
                new int[]{ 10, 0, 18, infinity, infinity, infinity, 16, infinity, 12 },
                new int[]{ infinity, 18, 0, 22, infinity, infinity, infinity, infinity, 8 },
                new int[]{ infinity, infinity, 22, 0, 20, infinity, 24, 16, 21 },
                new int[]{ infinity, infinity, infinity, 20, 0, 26, infinity, 7, infinity },
                new int[]{ 11, infinity, infinity, infinity, 26, 0, 17, infinity, infinity },
                new int[]{ infinity, 16, infinity, 24, infinity, 17, 0, 19, infinity },
                new int[]{ infinity, infinity, infinity, 16, 7, infinity, 19, 0, infinity },
                new int[]{ infinity, 12, 8, 21, infinity, infinity, infinity, infinity, 0 },
            };

            //Prim(graph, numberOfVertexes);
            PrimSimplified(graph, numberOfVertexes);
        }

        static void Prim(int[][] graph, int numberOfVertexes)
        {
            bool debug = true;

            int[] adjVex = new int[numberOfVertexes],                          // 鄰接頂點陣列:搜尋邊的最小權值過程中各邊的起點座標
                lowCost = new int[numberOfVertexes];                           // 各邊權值陣列:搜尋邊的最小權值過程中各邊的權值,陣列下標為邊的終點。
                        
            for (int i = 0; i < numberOfVertexes; i++)                         // 從圖G的下標為0的頂點開始搜尋。(也是圖G的最小生成樹的頂點集合)。
            {
                adjVex[i] = 0;
            }

            for (int i = 0; i < numberOfVertexes; i++)                         // 初始從下標為0的頂點開始到下標為i的頂點的邊的權值去搜尋。找lowCost中權值最小的下標i。
            {
                lowCost[i] = graph[0][i];
            }

            int k = 0;                                                         // 初始假定權值最小的邊的終點的下標為k。

            for (int i = 1; i < numberOfVertexes; i++)
            {
                if (debug)
                {
                    Console.WriteLine($"Loop {i}");
                    Console.Write("lowCost: ");
                    PrintArray(lowCost);
                    Console.Write(" adjVex: ");
                    PrintArray(adjVex);
                    Console.WriteLine();
                }

                int minimumWeight = int.MaxValue;                              // 搜尋過程中發現到的最小的權值。初始設定為最大的整數值以示兩點間無邊。

                for (int j = 1; j < numberOfVertexes; j++)
                {
                    if (lowCost[j] != 0 && lowCost[j] < minimumWeight)         // lowCost中0表示該點已經搜尋過了。lowCost[j] < minimumWeight即發現目前最小權值。
                    {
                        minimumWeight = lowCost[j];                            // 發現目前最小權值。
                        k = j;                                                 // 目前最小權值的邊的終點下標。
                    }
                }

                if (!debug)
                {
                    Console.WriteLine($"({adjVex[k]}, {k})");                  // 輸出邊
                }

                adjVex[i] = k;                                                 // 此時找到的k值即是權值最小的邊的終點。將V[k]放入集合U。(這步可省略,因lowCost[j]已被標為“無需搜尋”了)。
                lowCost[k] = 0;                                                // 0表示該點已經搜尋過了,已不需要再被搜尋了。

                for (int j = 1; j < numberOfVertexes; j++)                     // 轉到以V[k]為開始頂點的邊,去與前面u為起始頂點到V[i]為終止頂點的邊的權值去比較。
                {
                    if (lowCost[j] != 0 && graph[k][j] < lowCost[j])           // lowCost中0表示該點已經搜尋過了。graph[k][j] < lowCost[j]即發現更小權值。
                    {
                        lowCost[j] = graph[k][j];                              // 更新權值;索引j即終點下標。
                        adjVex[j] = k;                                         // 下次尋找權值小的邊時,從k為下標的頂點為起點。
                    }
                }

                if (debug)
                {
                    Console.Write("lowCost: ");
                    PrintArray(lowCost);
                    Console.Write(" adjVex: ");
                    PrintArray(adjVex);
                    Console.WriteLine();
                }
            }
        }

        static void PrimSimplified(int[][] graph, int numberOfVertexes)
        {
            int[] adjVex = new int[numberOfVertexes],                          // 鄰接頂點陣列:搜尋邊的最小權值過程中各邊的起點座標
                lowCost = new int[numberOfVertexes];                           // 各邊權值陣列:搜尋邊的最小權值過程中各邊的權值,陣列下標為邊的終點。

            for (int i = 0; i < numberOfVertexes; i++)
            {
                adjVex[i] = 0;                                                 // 從圖G的下標為0的頂點開始搜尋。(也是圖G的最小生成樹的頂點集合)。
                lowCost[i] = graph[0][i];                                      // 初始從下標為0的頂點開始到下標為i的頂點的邊的權值去搜尋。找lowCost中權值最小的下標i。
            }

            int k = 0;                                                         // 初始假定權值最小的邊的終點的下標為k。

            for (int i = 1; i < numberOfVertexes; i++)
            {
                int minimumWeight = int.MaxValue;                              // 搜尋過程中發現到的最小的權值。初始設定為最大的整數值以示兩點間無邊。

                for (int j = 1; j < numberOfVertexes; j++)
                {
                    if (lowCost[j] != 0 && lowCost[j] < minimumWeight)         // lowCost中0表示該點已經搜尋過了。lowCost[j] < minimumWeight即發現目前最小權值。
                    {
                        minimumWeight = lowCost[j];                            // 發現目前最小權值。
                        k = j;                                                 // 目前最小權值的邊的終點下標。
                    }
                }

                Console.WriteLine($"({adjVex[k]}, {k})");                      // 輸出邊

                lowCost[k] = 0;                                                // 0表示該點已經搜尋過了,已不需要再被搜尋了。

                for (int j = 1; j < numberOfVertexes; j++)                     // 轉到以V[k]為開始頂點的邊,去與前面u為起始頂點到V[i]為終止頂點的邊的權值去比較。
                {
                    if (lowCost[j] != 0 && graph[k][j] < lowCost[j])           // lowCost中0表示該點已經搜尋過了。graph[k][j] < lowCost[j]即發現更小權值。
                    {
                        lowCost[j] = graph[k][j];                              // 更新權值;索引j即終點下標。
                        adjVex[j] = k;                                         // 下次尋找權值小的邊時,從k為下標的頂點為起點。
                    }
                }
            }
        }

        static void PrintArray(int[] array)
        {
            Console.Write("[ ");            
            for (int i = 0; i < array.Length - 1; i++)                         // 輸出陣列的前面n-1個
            {
                Console.Write($"{ToInfinity(array[i])}, ");
            }            
            if (array.Length > 0)                                              // 輸出陣列的最後1個
            {
                int n = array.Length - 1;
                Console.Write($"{ToInfinity(array[n])}");
            }
            Console.WriteLine(" ]");
        }

        static string ToInfinity(int i) => i == int.MaxValue ? "∞" : i.ToString();
    }
}

TypeScript程式碼

function prim(graph: number[][], numberOfVertexes: number) {
    let debug: boolean = true;

    let adjVex: number[] = [],                  // 鄰接頂點陣列:搜尋邊的最小權值過程中各邊的起點座標
        lowCost = [];                           // 各邊權值陣列:搜尋邊的最小權值過程中各邊的權值,陣列下標為邊的終點。

    for (let i = 0; i < numberOfVertexes; i++)  // 從圖G的下標為0的頂點開始搜尋。(也是圖G的最小生成樹的頂點集合)。
    {
        adjVex[i] = 0;
    }

    for (let i = 0; i < numberOfVertexes; i++)  // 初始從下標為0的頂點開始到下標為i的頂點的邊的權值去搜尋。找lowCost中權值最小的下標i。
    {
        lowCost[i] = graph[0][i];
    }

    let k: number = 0;                          // 初始假定權值最小的邊的終點的下標為k。

    for (let i = 1; i < numberOfVertexes; i++) {
        if (debug) {
            console.log(`Loop ${i}`);
            console.log(`lowCost: ${printArray(lowCost)}`);
            console.log(` adjVex: ${printArray(adjVex)}`);
        }

        let minimumWeight: number = Number.MAX_VALUE;  // 搜尋過程中發現到的最小的權值。初始設定為最大的整數值以示兩點間無邊。

        for (let j = 1; j < numberOfVertexes; j++) {
            if (lowCost[j] != 0 && lowCost[j] < minimumWeight)  // lowCost中0表示該點已經搜尋過了。lowCost[j] < minimumWeight即發現目前最小權值。
            {
                minimumWeight = lowCost[j];     // 發現目前最小權值。
                k = j;                          // 目前最小權值的邊的終點下標。
            }
        }

        if (!debug) {
            console.log(`(${adjVex[k]}, ${k})`);// 輸出邊
        }

        adjVex[i] = k;                          // 此時找到的k值即是權值最小的邊的終點。將V[k]放入集合U。(這步可省略,因lowCost[j]已被標為“無需搜尋”了)。
        lowCost[k] = 0;                         // 0表示該點已經搜尋過了,已不需要再被搜尋了。

        for (let j = 1; j < numberOfVertexes; j++)  // 轉到以V[k]為開始頂點的邊,去與前面u為起始頂點到V[i]為終止頂點的邊的權值去比較。
        {
            if (lowCost[j] != 0 && graph[k][j] < lowCost[j])  // lowCost中0表示該點已經搜尋過了。graph[k][j] < lowCost[j]即發現更小權值。
            {
                lowCost[j] = graph[k][j];       // 更新權值;索引j即終點下標。
                adjVex[j] = k;                  // 下次尋找權值小的邊時,從k為下標的頂點為起點。
            }
        }

        if (debug) {
            console.log(`lowCost: ${printArray(lowCost)}`);
            console.log(` adjVex: ${printArray(adjVex)}`);
            console.log('');
        }
    }
}

function primSimplified(graph: number[][], numberOfVertexes: number) {
    let adjVex: number[] = [],                  // 鄰接頂點陣列:搜尋邊的最小權值過程中各邊的起點座標
        lowCost = [];                           // 各邊權值陣列:搜尋邊的最小權值過程中各邊的權值,陣列下標為邊的終點。

    for (let i = 0; i < numberOfVertexes; i++) {
        adjVex[i] = 0;                          // 從圖G的下標為0的頂點開始搜尋。(也是圖G的最小生成樹的頂點集合)。
        lowCost[i] = graph[0][i];               // 初始從下標為0的頂點開始到下標為i的頂點的邊的權值去搜尋。找lowCost中權值最小的下標i。
    }

    let k: number = 0;                          // 初始假定權值最小的邊的終點的下標為k。

    for (let i = 1; i < numberOfVertexes; i++) {
        let minimumWeight: number = Number.MAX_VALUE;  // 搜尋過程中發現到的最小的權值。初始設定為最大的整數值以示兩點間無邊。

        for (let j = 1; j < numberOfVertexes; j++) {
            if (lowCost[j] != 0 && lowCost[j] < minimumWeight)  // lowCost中0表示該點已經搜尋過了。lowCost[j] < minimumWeight即發現目前最小權值。
            {
                minimumWeight = lowCost[j];     // 發現目前最小權值。
                k = j;                          // 目前最小權值的邊的終點下標。
            }
        }

        console.log(`(${adjVex[k]}, ${k})`);    // 輸出邊

        lowCost[k] = 0;                         // 0表示該點已經搜尋過了,已不需要再被搜尋了。

        for (let j = 1; j < numberOfVertexes; j++)  // 轉到以V[k]為開始頂點的邊,去與前面u為起始頂點到V[i]為終止頂點的邊的權值去比較。
        {
            if (lowCost[j] != 0 && graph[k][j] < lowCost[j])  // lowCost中0表示該點已經搜尋過了。graph[k][j] < lowCost[j]即發現更小權值。
            {
                lowCost[j] = graph[k][j];       // 更新權值;索引j即終點下標。
                adjVex[j] = k;                  // 下次尋找權值小的邊時,從k為下標的頂點為起點。
            }
        }
    }
}

function printArray(array: number[]): string {
    let str: string[] = [];
    str.push("[ ");
    for (let i = 0; i < array.length - 1; i++)  // 輸出陣列的前面n-1個
    {
        str.push(`${toInfinity(array[i])}, `)
    }
    if (array.length > 0)                       // 輸出陣列的最後1個
    {
        let n: number = array.length - 1;
        str.push(`${toInfinity(array[n])}`);
    }
    str.push(" ]");
    return str.join("");
}

function toInfinity(i: number) {
    return i == Number.MAX_VALUE ? "∞" : i.toString();
}

function Main() {
    let numberOfVertexes: number = 9,
        infinity = Number.MAX_VALUE;

    let graph: number[][] = [
        [0, 10, infinity, infinity, infinity, 11, infinity, infinity, infinity],
        [10, 0, 18, infinity, infinity, infinity, 16, infinity, 12],
        [infinity, 18, 0, 22, infinity, infinity, infinity, infinity, 8],
        [infinity, infinity, 22, 0, 20, infinity, 24, 16, 21],
        [infinity, infinity, infinity, 20, 0, 26, infinity, 7, infinity],
        [11, infinity, infinity, infinity, 26, 0, 17, infinity, infinity],
        [infinity, 16, infinity, 24, infinity, 17, 0, 19, infinity],
        [infinity, infinity, infinity, 16, 7, infinity, 19, 0, infinity],
        [infinity, 12, 8, 21, infinity, infinity, infinity, infinity, 0],
    ];

    // let graph: number[][] = [
    //     [0, 1, 5, infinity, infinity, infinity, infinity, infinity, infinity],
    //     [1, 0, 3, 7, 5, infinity, infinity, infinity, infinity],
    //     [5, 3, 0, infinity, 1, 7, infinity, infinity, infinity],
    //     [infinity, 7, infinity, 0, 2, infinity, 3, infinity, infinity],
    //     [infinity, 5, 1, 2, 0, 3, 6, 9, infinity],
    //     [infinity, infinity, 7, infinity, 3, 0, infinity, 5, infinity],
    //     [infinity, infinity, infinity, 3, 6, infinity, 0, 2, 7],
    //     [infinity, infinity, infinity, infinity, 9, 5, 2, 0, 4],
    //     [infinity, infinity, infinity, infinity, infinity, infinity, 7, 4, 0],
    // ];

    //prim(graph, numberOfVertexes);
    primSimplified(graph, numberOfVertexes);
}

Main();

/**
 執行結果:
(0, 1)
(1, 2)
(2, 4)
(4, 3)
(4, 5)
(3, 6)
(6, 7)
(7, 8)
 */

參考資料:

《大話資料結構》 - 程傑 著 - 清華大學出版社 第247頁
之前不會Markdown語法的角標(Subscript),所以分成了兩篇文章。這裡將之前的合成整理為一篇。

相關文章