3.算法設計與分析__分治法

1 概 述

1.1 分治法的設計思想

將一個難以直接解決的大問題,劃分成一些規模較小的子問題,以便各個擊破,分而治之。更一般地說,將要求解的原問題劃分成k個較小規模的子問題,對這k個子問題分別求解。如果子問題的規模仍然不夠小,則再將每個子問題劃分爲k個規模更小的子問題,如此分解下去,直到問題規模足夠小,很容易求出其解爲止,再將子問題的解合併爲一個更大規模的問題的解,自底向上逐步求出原問題的解。

啓發式規則

  1. 平衡子問題:最好使子問題的規模大致相同。也就是將一個問題劃分成大小相等的k個子問題(通常k=2),這種使子問題規模大致相等的做法是出自一種平衡(Balancing)子問題的思想,它幾乎總是比子問題規模不等的做法要好。
  2. 獨立子問題:各子問題之間相互獨立,這涉及到分治法的效率,如果各子問題不是獨立的,則分治法需要重複地解公共的子問題。

在這裏插入圖片描述

1.2 分治法的求解過程

一般來說,分治法的求解過程由以下三個階段組成:
(1)劃分:既然是分治,當然需要把規模爲n的原問題劃分爲k個規模較小的子問題,並儘量使這k個子問題的規模大致相同。
(2)求解子問題:各子問題的解法與原問題的解法通常是相同的,可以用遞歸的方法求解各個子問題,有時遞歸處理也可以用循環來實現。
(3)合併:把各個子問題的解合併起來,合併的代價因情況不同有很大差異,分治算法的有效性很大程度上依賴於合併的實現。
在這裏插入圖片描述

2 遞 歸

2.1 遞歸的定義

遞歸(Recursion)就是子程序(或函數)直接調用自己或通過一系列調用語句間接調用自己,是一種描述問題和解決問題的基本方法。
遞歸有兩個基本要素:
邊界條件:確定遞歸到何時終止;
遞歸模式:大問題是如何分解爲小問題的。

遞歸函數的經典問題——漢諾塔問題

在世界剛被創建的時候有一座鑽石寶塔(塔A),其上有64個金碟。所有碟子按從大到小的次序從塔底堆放至塔頂。緊挨着這座塔有另外兩個鑽石寶塔(塔B和塔C)。從世界創始之日起,婆羅門的牧師們就一直在試圖把塔A上的碟子移動到塔C上去,其間藉助於塔B的幫助。每次只能移動一個碟子,任何時候都不能把一個碟子放在比它小的碟子上面。當牧師們完成任務時,世界末日也就到了。
漢諾塔問題可以通過以下三個步驟實現:
(1)將塔A上的n-1個碟子藉助塔C先移到塔B上。
(2)把塔A上剩下的一個碟子移到塔C上。
(3)將n-1個碟子從塔B藉助塔A移到塔C上。
顯然,這是一個遞歸求解的過程
在這裏插入圖片描述

 void Hanoi(int n, char A, char B, char C) 
          //第一列爲語句行號
     {
          if (n==1) Move(A, C);
           //Move是一個抽象操作,表示將碟子從A移到C上
         else {
            Hanoi(n-1, A, C, B);
            Move(A, C);
            Hanoi(n-1, B, A, C);
        }
      }

2.2 遞歸函數的運行軌跡

在遞歸函數中,調用函數和被調用函數是同一個函數,需要注意的是遞歸函數的調用層次,如果把調用遞歸函數的主函數稱爲第0層,進入函數後,首次遞歸調用自身稱爲第1層調用;從第i層遞歸調用自身稱爲第i+1層。反之,退出第i+1層調用應該返回第i層。採用圖示方法描述遞歸函數的運行軌跡,從中可較直觀地瞭解到各調用層次及其執行情況。
在這裏插入圖片描述

2.3 遞歸函數的內部執行過程

一個遞歸函數的調用過程類似於多個函數的嵌套調用,只不過調用函數和被調用函數是同一個函數。爲了保證遞歸函數的正確執行,系統需設立一個工作棧。具體地說,遞歸調用的內部執行過程如下:
(1)運行開始時,首先爲遞歸調用建立一個工作棧,其結構包括值參、局部變量和返回地址;
(2)每次執行遞歸調用之前,把遞歸函數的值參和局部變量的當前值以及調用後的返回地址壓棧;
(3)每次遞歸調用結束後,將棧頂元素出棧,使相應的值參和局部變量恢復爲調用前的值,然後轉向返回地址指定的位置繼續執行。
在這裏插入圖片描述
遞歸算法結構清晰,可讀性強,而且容易用數學歸納法來證明算法的正確性,因此,它爲設計算法和調試程序帶來很大方便,是算法設計中的一種強有力的工具。但是,因爲遞歸算法是一種自身調用自身的算法,隨着遞歸深度的增加,工作棧所需要的空間增大,遞歸調用時的輔助操作增多,因此,遞歸算法的運行效率較低。

3 排序問題中的分治法

3.1 歸併排序

二路歸併排序的分治策略是

(1)劃分:將待排序序列r1, r2, …, rn劃分爲兩個長度相等的子序列r1, …, rn/2和rn/2+1, …, rn;
(2)求解子問題:分別對這兩個子序列進行排序,得到兩個有序子序列;
(3)合併:將這兩個有序子序列合併成一個有序序列。
在這裏插入圖片描述

void MergeSort(int r[ ], int r1[ ], int s, int t) 
    { 
       if (s= =t) r1[s]=r[s];    
         else { 
            m=(s+t)/2;
            Mergesort(r, r1, s, m);    //歸併排序前半個子序列
            Mergesort(r, r1, m+1, t);   //歸併排序後半個子序列
            Merge(r1, r, s, m, t);      //合併兩個已排序的子序列
         }
    }

二路歸併排序的合併步的時間複雜性爲O(n),所以,二路歸併排序算法存在如下遞推式:在這裏插入圖片描述
根據1.2.4節的主定理,二路歸併排序的時間代價是O(nlog2n)。
二路歸併排序在合併過程中需要與原始記錄序列同樣數量的存儲空間,因此其空間複雜性爲O(n)。

void Merge(int r[ ], int r1[ ], int s, int m, int t)
    {
         i=s; j=m+1; k=s;
        while (i<=m && j<=t)
        {   
            if (r[i]<=r[j]) r1[k++]=r[i++];   //取r[i]和r[j]中較小者放入r1[k]
            else r1[k++]=r[j++]; 
        }
        if (i<=m) while (i<=m)   
         //若第一個子序列沒處理完,則進行收尾處理
            r1[k++]=r[i++]; 
        else  while (j<=t)      
    //若第二個子序列沒處理完,則進行收尾處理
                    r1[k++]=r[j++]; 
    }

3.2 快速排序

快速排序的分治策略是

(1)劃分:選定一個記錄作爲軸值,以軸值爲基準將整個序列劃分爲兩個子序列r1 … ri-1和ri+1 … rn,前一個子序列中記錄的值均小於或等於軸值,後一個子序列中記錄的值均大於或等於軸值;
(2)求解子問題:分別對劃分後的每一個子序列遞歸處理;
(3)合併:由於對子序列r1 … ri-1和ri+1 … rn的排序是就地進行的,所以合併不需要執行任何操作。
在這裏插入圖片描述
歸併排序按照記錄在序列中的位置對序列進行劃分,
快速排序按照記錄的值對序列進行劃分。
以第一個記錄作爲軸值,對待排序序列進行劃分的過程爲:
(1)初始化:取第一個記錄作爲基準,設置兩個參數i,j分別用來指示將要與基準記錄進行比較的左側記錄位置和右側記錄位置,也就是本次劃分的區間;
(2)右側掃描過程:將基準記錄與j指向的記錄進行比較,如果j指向記錄的關鍵碼大,則j前移一個記錄位置。重複右側掃描過程,直到右側的記錄小(即反序),若i<j,則將基準記錄與j指向的記錄進行交換;
(3)左側掃描過程:將基準記錄與i指向的記錄進行比較,如果i指向記錄的關鍵碼小,則i後移一個記錄位置。重複左側掃描過程,直到左側的記錄大(即反序),若i<j,則將基準記錄與i指向的記錄交換;
(4)重複(2)(3)步,直到i與j指向同一位置,即基準記錄最終的位置。
在這裏插入圖片描述

 int Partition(int r[ ], int first, int end)
     {
          i=first; j=end;         //初始化
          while (i<j)
          {  
       while (i<j && r[i]<= r[j]) j--//右側掃描
               if (i<j) { 
                  r[i]←→r[j];            //將較小記錄交換到前面
                  i++; 
               }
              while (i<j && r[i]<= r[j]) i++//左側掃描
              if (i<j) {
                 r[j]←→r[i];            //將較大記錄交換到後面
                 j--; 
              }
          }
          retutn i;    // i爲軸值記錄的最終位置
    }

在這裏插入圖片描述

void QuickSort(int r[ ], int first, int end)
  {
     if (first<end) {      
        pivot=Partition(r, first, end);  
          //問題分解,pivot是軸值在序列中的位置
        QuickSort(r, first, pivot-1); 
          //遞歸地對左側子序列進行快速排序
        QuickSort(r, pivot+1, end);
         //遞歸地對右側子序列進行快速排序
     }
  }

最好情況下,每次劃分對一個記錄定位後,該記錄的左側子序列與右側子序列的長度相同。在具有n個記錄的序列中,一次劃分需要對整個待劃分序列掃描一遍,則所需時間爲O(n)。設T(n)是對n個記錄的序列進行排序的時間,每次劃分後,正好把待劃分區間劃分爲長度相等的兩個子序列,則有:
T(n)≤2 T(n/2)+n
≤2(2T(n/4)+n/2)+n=4T(n/4)+2n
≤4(2T(n/8)+n/4)+2n=8T(n/8)+3n
… … …
≤nT(1)+nlog2n=O(nlog2n)
因此,時間複雜度爲O(nlog2n)。
最壞情況下,待排序記錄序列正序或逆序,每次劃分只得到一個比上一次劃分少一個記錄的子序列(另一個子序列爲空)。此時,必須經過n-1次遞歸調用才能把所有記錄定位,而且第i趟劃分需要經過n-i次關鍵碼的比較才能找到第i個記錄的基準位置,因此,總的比較次數爲:

在這裏插入圖片描述
因此,時間複雜度爲O(n2)。

平均情況下,設基準記錄的關鍵碼第k小(1≤k≤n),則有:

在這裏插入圖片描述
這是快速排序的平均時間性能,可以用歸納法證明,其數量級也爲O(nlog2n)。

4 組合問題中的分治法

4.1 最大子段和問題

給定由n個整數組成的序列(a1, a2, …, an),最大子段和問題要求該序列形如
的最大值(1≤i≤j≤n),當序列中所有整數均爲負整數時,其最大子段和爲0。例如,序列(-20, 11, -4, 13, -5, -2)的最大子段和爲: 在這裏插入圖片描述

最大子段和問題的分治策略是:

(1)劃分:按照平衡子問題的原則,將序列(a1, a2, …, an)劃分成長度相同的兩個子序列(a1, …, a )和(a +1, …, an),則會出現以下三種情況:
① a1, …, an的最大子段和=a1, …,a 的最大子段和;
② a1, …, an的最大子段和=a +1, …, an的最大子段和;
③ a1, …, an的最大子段和= 在這裏插入圖片描述,且
在這裏插入圖片描述
(2)求解子問題:對於劃分階段的情況①和②可遞歸求解,情況③需要分別計算 在這裏插入圖片描述
在這裏插入圖片描述
,則s1+s2爲情況③的最大子段和。

(3)合併:比較在劃分階段的三種情況下的最大子段和,取三者之中的較大者爲原問題的解。
在這裏插入圖片描述

int MaxSum(int a[ ], int left, int right)
   {
       sum=0;
       if (left= =right) {      //如果序列長度爲1,直接求解
           if (a[left]>0) sum=a[left];
           else sum=0;
       }
      else {
          center=(left+right)/2;    //劃分
          leftsum=MaxSum(a, left, center);  
                                                       //對應情況①,遞歸求解
          rightsum=MaxSum(a, center+1, right);  
                                                       //對應情況②,遞歸求解
            s1=0; lefts=0;              //以下對應情況③,先求解s1
        for (i=center; i>=left; i--)
        {
            lefts+=a[i];
            if (lefts>s1) s1=lefts;
        }
        s2=0; rights=0;             //再求解s2
        for (j=center+1; j<=right; j++)
        { 
            rights+=a[j];
            if (rights>s2) s2=rights;
        }
        sum=s1+s2;              //計算情況③的最大子段和 
        if (sum<leftsum) sum=leftsum;  
                     //合併,在sum、leftsum和rightsum中取較大者
        if (sum<rightsum) sum=rightsum;
     }
     return sum;
}

在這裏插入圖片描述

4.2 棋盤覆蓋問題

在一個2k×2k (k≥0)個方格組成的棋盤中,恰有一個方格與其他方格不同,稱該方格爲特殊方格。棋盤覆蓋問題要求用圖4.11(b)所示的4種不同形狀的L型骨牌覆蓋給定棋盤上除特殊方格以外的所有方格,且任何2個L型骨牌不得重疊覆蓋。
在這裏插入圖片描述
分治法求解棋盤覆蓋問題的技巧在於劃分棋盤,使劃分後的子棋盤的大小相同,並且每個子棋盤均包含一個特殊方格,從而將原問題分解爲規模較小的棋盤覆蓋問題。
k>0時,可將2k×2k的棋盤劃分爲4個2k-1×2k-1的子棋盤,這樣劃分後,由於原棋盤只有一個特殊方格,所以,這4個子棋盤中只有一個子棋盤包含該特殊方格,其餘3個子棋盤中沒有特殊方格。爲了將這3個沒有特殊方格的子棋盤轉化爲特殊棋盤,以便採用遞歸方法求解,可以用一個L型骨牌覆蓋這3個較小棋盤的會合處,從而將原問題轉化爲4個較小規模的棋盤覆蓋問題。遞歸地使用這種劃分策略,直至將棋盤分割爲1×1的子棋盤。
在這裏插入圖片描述
下面討論棋盤覆蓋問題中數據結構的設計。
(1)棋盤:可以用一個二維數組board[size][size]表示一個棋盤,其中,size=2k。爲了在遞歸處理的過程中使用同一個棋盤,將數組board設爲全局變量;
(2)子棋盤:整個棋盤用二維數組board[size][size]表示,其中的子棋盤由棋盤左上角的下標tr、tc和棋盤大小s表示;
(3)特殊方格:用board[dr][dc]表示特殊方格,dr和dc是該特殊方格在二維數組board中的下標;
(4) L型骨牌:一個2k×2k的棋盤中有一個特殊方格,所以,用到L型骨牌的個數爲(4k-1)/3,將所有L型骨牌從1開始連續編號,用一個全局變量t表示。
在這裏插入圖片描述

void  ChessBoard(int tr, int tc, int dr, int dc, int size)
// tr和tc是棋盤左上角的下標,dr和dc是特殊方格的下標,
// size是棋盤的大小,t已初始化爲0
{
      if (size = = 1) return;  //棋盤只有一個方格且是特殊方格
      t++;  // L型骨牌號
      s = size/2;  // 劃分棋盤
      // 覆蓋左上角子棋盤
      if (dr < tr + s && dc < tc + s)   // 特殊方格在左上角子棋盤中
         ChessBoard(tr, tc, dr, dc, s);          //遞歸處理子棋盤
      else{      // 用 t 號L型骨牌覆蓋右下角,再遞歸處理子棋盤
        board[tr + s - 1][tc + s - 1] = t;
        ChessBoard(tr, tc, tr+s-1, tc+s-1, s);
      }
        // 覆蓋右上角子棋盤
      if (dr < tr + s && dc >= tc + s)    // 特殊方格在右上角子棋盤中
         ChessBoard(tr, tc+s, dr, dc, s);          //遞歸處理子棋盤
      else {        // 用 t 號L型骨牌覆蓋左下角,再遞歸處理子棋盤
         board[tr + s - 1][tc + s] = t;
         ChessBoard(tr, tc+s, tr+s-1, tc+s, s); }
      // 覆蓋左下角子棋盤
      if (dr >= tr + s && dc < tc + s)   // 特殊方格在左下角子棋盤中
         ChessBoard(tr+s, tc, dr, dc, s);         //遞歸處理子棋盤
      else {       // 用 t 號L型骨牌覆蓋右上角,再遞歸處理子棋盤
         board[tr + s][tc + s - 1] = t;
         ChessBoard(tr+s, tc, tr+s, tc+s-1, s); }
      // 覆蓋右下角子棋盤
      if (dr >= tr + s && dc >= tc + s)  // 特殊方格在右下角子棋盤中
         ChessBoard(tr+s, tc+s, dr, dc, s);       //遞歸處理子棋盤
      else {       // 用 t 號L型骨牌覆蓋左上角,再遞歸處理子棋盤
         board[tr + s][tc + s] = t;
         ChessBoard(tr+s, tc+s, tr+s, tc+s, s); }
 }

在這裏插入圖片描述

4.3 循環賽日程安排問題

設有n=2k個選手要進行網球循環賽,要求設計一個滿足以下要求的比賽日程表:
(1)每個選手必須與其他n-1個選手各賽一次;
(2)每個選手一天只能賽一次。
按此要求,可將比賽日程表設計成一個 n 行n-1列的二維表,其中,第 i 行第 j 列表示和第 i 個選手在第 j 天比賽的選手。
按照分治的策略,可將所有參賽的選手分爲兩部分,n=2k個選手的比賽日程表就可以通過爲n/2=2k-1個選手設計的比賽日程表來決定。遞歸地執行這種分割,直到只剩下2個選手時,比賽日程表的制定就變得很簡單:只要讓這2個選手進行比賽就可以了。
在這裏插入圖片描述
顯然,這個求解過程是自底向上的迭代過程,其中左上角和左下角分別爲選手1至選手4以及選手5至選手8前3天的比賽日程,據此,將左上角部分的所有數字按其對應位置抄到右下角,將左下角的所有數字按其對應位置抄到右上角,這樣,就分別安排好了選手1至選手4以及選手5至選手8在後4天的比賽日程,如圖©所示。具有多個選手的情況可以依此類推。
這種解法是把求解2k個選手比賽日程問題劃分成依次求解21、22、…、2k個選手的比賽日程問題,換言之,2k個選手的比賽日程是在2k-1個選手的比賽日程的基礎上通過迭代的方法求得的。在每次迭代中,將問題劃分爲4部分:
(1)左上角:左上角爲2k-1個選手在前半程的比賽日程;
(2)左下角:左下角爲另2k-1個選手在前半程的比賽日程,由左上角加2k-1得到,例如22個選手比賽,左下角由左上角直接加2得到,23個選手比賽,左下角由左上角直接加4得到;
(3)右上角:將左下角直接抄到右上角得到另2k-1個選手在後半程的比賽日程;
(4)右下角:將左上角直接抄到右下角得到2k-1個選手在後半程的比賽日程;
算法設計的關鍵在於尋找這4部分元素之間的對應關係。

void GameTable(int k, int a[ ][ ])
     {  
        // n=2k(k≥1)個選手參加比賽,
        //二維數組a表示日程安排,數組下標從1開始
         n=2;       //k=0,2個選手比賽日程可直接求得
         //求解2個選手比賽日程,得到左上角元素
         a[1][1]=1; a[1][2]=2;   
         a[2][1]=2; a[2][2]=1;
         for (t=1; t<k; t++)
         //迭代處理,依次處理22, …, 2k個選手比賽日程
         {
          temp=n; n=n*2;   
        //填左下角元素
        for (i=temp+1; i<=n; i++ )
            for (j=1; j<=temp; j++)
                a[i][j]=a[i-temp][j]+temp;
                //左下角元素和左上角元素的對應關係
       //填右上角元素
       for (i=1; i<=temp; i++)       
           for (j=temp+1; j<=n; j++)
              a[i][j]=a[i+temp][(j+temp)% n];
       //填右下角元素
      for (i=temp+1; i<=n; i++)
          for (j=temp+1; j<=n; j++)
             a[i][j]=a[i-temp][j-temp];
    }
}

在這裏插入圖片描述

5 幾何問題中的分治法

5.1 最近對問題

設p1=(x1, y1), p2=(x2, y2), …, pn=(xn, yn)是平面上n個點構成的集合S,最近對問題就是找出集合S中距離最近的點對。
嚴格地講,最接近點對可能多於一對,簡單起見,只找出其中的一對作爲問題的解。
用分治法解決最近對問題,很自然的想法就是將集合S分成兩個子集 S1和 S2,每個子集中有n/2個點。然後在每個子集中遞歸地求其最接近的點對,在求出每個子集的最接近點對後,在合併步中,如果集合 S 中最接近的兩個點都在子集 S1或 S2中,則問題很容易解決,如果這兩個點分別在 S1和 S2中,問題就比較複雜了。
爲了使問題易於理解,先考慮一維的情形。
此時,S中的點退化爲x軸上的n個點x1, x2, …, xn。用x軸上的某個點m將S劃分爲兩個集合S1和S2,並且S1和S2含有點的個數相同。遞歸地在S1和S2上求出最接近點對 (p1, p2) 和(q1, q2),如果集合S中的最接近點對都在子集S1或S2中,則d=min{(p1, p2), (q1, q2)}即爲所求,如果集合S中的最接近點對分別在S1和S2中,則一定是(p3, q3),其中,p3是子集S1中的最大值,q3是子集S2中的最小值。
在這裏插入圖片描述
按這種分治策略求解最近對問題的算法效率取決於劃分點m的選取,一個基本的要求是要遵循平衡子問題的原則。如果選取m=(max{S}+min{S})/2,則有可能因集合S中點分佈的不均勻而造成子集S1和S2的不平衡,如果用S中各點座標的中位數(即S的中值)作爲分割點,則會得到一個平衡的分割點m,使得子集S1和S2中有個數大致相同的點。
下面考慮二維的情形,此時S中的點爲平面上的點。
爲了將平面上的點集S 分割爲點的個數大致相同的兩個子集S1和S2,選取垂直線x=m來作爲分割線,其中,m爲S中各點x座標的中位數。由此將S分割爲S1={p∈S | xp≤m}和S2={q∈S | xq>m}。遞歸地在S1和S2上求解最近對問題,分別得到S1中的最近距離d1和S2中的最近距離d2,令d=min(d1, d2),若S的最近對(p, q)之間的距離小於d,則p和q必分屬於S1和S2,不妨設p∈S1,q∈S2,則p和q距直線x=m的距離均小於d,所以,可以將求解限制在以x=m爲中心、寬度爲2d的垂直帶P1和P2中,垂直帶之外的任何點對之間的距離都一定大於d。
在這裏插入圖片描述
對於點p∈P1,需要考察P2中的各個點和點p之間的距離是否小於d,顯然,P2中這樣點的y軸座標一定位於區間[y-d, y+d]之間,而且,這樣的點不會超過6個。

在這裏插入圖片描述
在這裏插入圖片描述

5.2 凸包問題

設p1=(x1, y1), p2=(x2, y2), …, pn=(xn, yn)是平面上n個點構成的集合S,並且這些點按照x軸座標升序排列。幾何學中有這樣一個明顯的事實:最左邊的點p1和最右邊的點pn一定是該集合的凸包頂點(即極點)。設p1pn是從p1到pn的直線,這條直線把集合S分成兩個子集:S1是位於直線左側和直線上的點構成的集合,S2是位於直線右側和直線上的點構成的集合。S1的凸包由下列線段構成:以p1和pn爲端點的線段構成的下邊界,以及由多條線段構成的上邊界,這條上邊界稱爲上包。類似地,S2中的多條線段構成的下邊界稱爲下包。整個集合S的凸包是由上包和下包構成的。

在這裏插入圖片描述
快包的思想是:首先找到S1中的頂點pmax,它是距離直線p1pn最遠的頂點,則三角形pmaxp1pn的面積最大。S1中所有在直線pmaxp1左側的點構成集合S1,1,S1中所有在直線pmaxpn右側的點構成集合S1,2,包含在三角形pmaxp1pn之中的點可以不考慮了。遞歸地繼續構造集合S1,1的上包和集合S1,2的上包,然後將求解過程中得到的所有最遠距離的點連接起來,就可以得到集合S1的上包。
在這裏插入圖片描述
接下來的問題是如何判斷一個點是否在給定直線的左側(或右側)?幾何學中有這樣一個定理:如果p1=(x1, y1), p2=(x2, y2), p3=(x3, y3)是平面上的任意三個點,則三角形p1p2p3的面積等於下面這個行列式的絕對值的一半:
在這裏插入圖片描述
當且僅當點p3=(x3, y3)位於直線p1p2的左側時,該式的符號爲正。可以在一個常數時間內檢查一個點是否位於兩個點確定的直線的左側,並且可以求得這個點到該直線的距離。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章