數據結構之-----排序算法理解與應用

算法的穩定性:若是待排序的兩個元素Ri,Rj,其對應的關鍵字keyi=keyj,且在排序前Ri在Rj的前面,若是排序後Ri還在Rj的前面,則稱這種排序算法是穩定的,不然稱排序算法是不穩定的。node

內部排序和外部排序:內部排序是指在排序期間,元素所有存放在內存中的排序。外部排序是指排序期間元素沒法所有同時存放在內存中,必須在排序過程當中根據要求不斷地在內外存之間移動的排序。算法

1.插入排序數組

1)插入排序:每次將一個待排序的記錄,按其關鍵字大小插入到前面已經排好序的子序列中,直到所有記錄插入完成。app

void InsertSort(ElemType A[], int n) { int i, j; for (i = 2; i <= n; i++) //依此將A[2]~A[n]插入到前面已排序序列 if (A[i].key < A[i - 1].key) //若A[i]的關鍵碼小於其前驅,須將A[i]插入有序表 { A[0] = A[i]; //複製爲哨兵 for (j = i - 1; A[0].key < A[j].key; --j) //從後往前查找待插入位置 A[j+1]=A[j]; //向後拖動 A[j+1]=A[0]; //複製到插入位置 } }

時間複雜度:O(n2),空間複雜度:O(1).穩定性:穩定的排序方法。函數

2)希爾排序:將待排序表分割成若個形如L[i,i+d,i+2d,i+3d,.....i+kd]的特殊子表,分別進行直接插入排序。當整個表呈基本有序時,在對全體記錄進行一次直接插入排序。性能

過程:先去一個小於n的步長d1,把表中所有記錄分紅d1個組,全部距離爲d1的倍數的記錄放在同一組中,在各組中進行直接插入排序。而後取第二個步長d2<d1.重複上述過程,直到di=1,即全部記錄在同一組中,再進行直接插入排序。測試

增量求法:目前不統一,通常採用d1=n/2,,​最後一個增量爲1.ui

void ShellSort(ElemType A[], int n) { //對順序表做希爾插入排序,和插入排序算法相比,作了如下修改:先後記錄位置增量是dk,不是1.A[0]只是暫存單元,不是哨兵,j<0時,插入位置已達 for(dk=len/2;dk>=1;dk=dk/2) //步長變化 for(i=dk+1;i<=n;i++) if (A[i].key < A[i - dk].key) //需將A[i]插入到有序增量子表 { A[0]=A[i]; //暫存在A[0] for (j = i - dk; j > 0 && A[0].key < A[j].key; j -= dk) A[j+dk]=A[j]; A[j + dk] = A[0]; } }

時間複雜度:當n在某個特定範圍時爲,最壞狀況下爲:,空間複雜度O(1)spa

不穩定排序3d

2.交換排序

冒泡排序:將設待排序表長爲n,從後往前兩兩比較相鄰元素的值,若爲逆序,則交換他們,直到序列比較完。此爲一趟冒泡。結果爲將最小的元素交換到待排序的第一個位置。下一趟冒泡時,前一趟肯定的最小元素再也不參與比較,待排序列減小一個元素,每趟排序吧最小元素放到最終位置,這樣最多作n-1趟冒泡就把全部元素排好。

void BobbleSort(ElemType A[], int n) { //用冒泡排序法將序列A中的元素按從小到達排列 for (i = 0; i < n - 1; i++) { flag = false; //表示本趟冒泡是否發生交換的標誌 for (j = n - 1; j > i; j--) //一趟冒泡過程 if (A[j - 1].key > A[j].key)//若爲逆序 { swap(A[j-1],[j]); //交換 flag = true; } if (flag == false) return; } }
  1. 快速排序:快速排序是對冒泡排序的一種改進。其基本思想是基於分治法的:在待排序L[1....n]中任意取一個元素pivot做爲基準,經過一趟排序將待排序表劃分爲獨立的兩部分L[1...k-1],L[k+1....n]使得L[1....k-1]中全部元素小於等於pivot,L[k+1...n]中全部元素大於pivot,則pivot則放置在最終位置上L(k),這個過程稱爲一趟快速排序。然後分別遞歸的對兩個子表重複上述過程,直到每一部份內只有一個元素或空爲止(全部元素放置在最終位置上)

過程:首先假定劃分算法已知,記爲partition(),返回上述中的k,L(k)已經在最終位置上,因此能夠先對錶進行劃分,然後對錶調用一樣的排序操做。遞歸的調用快速排序算法進行排序。程序結構以下:

void QuickSort(ElemType A[], int low, int high) { if (low < high) //邊界條件,即遞歸跳出的條件 {//partition() 就是劃分操做,將表A[low...high]劃分爲知足上述條件的兩個子表 int pivotpos = partition(A,low,high); //劃分 QuickSort(A,low,pivotpos-1); //依此對兩個子表進行遞歸排序 QuickSort(A,pivotpos+1,high); } } //不難看出,快速排序的關鍵在於劃分操做,性能取決於劃分操做的好壞 //快速排序分治partition有兩種方法

1)兩個下標分別從首,尾向中間掃描的方法

假設每次都是以當前表中第一個元素做爲樞紐值對錶進行劃分,則必須將表中比樞紐值大的元素向右移動,比樞紐值小的元素向左移動,使得一趟partition()操做後,表的元素被樞紐值一分爲二。

int partition(elemtype A[], int low, int high) { elemtype pivot = A[low]; //將當前表中第一個元素設爲樞紐值,對錶進行劃分 wihle(low < high) //循環跳出條件 { while (low < high&&A[high] >= pivot) --high; A[low] = A[high]; //將比樞紐值小的元素移動到左端 while (low < high&&A[low <= pivot) low++; A[high]=A[low]; //將比樞紐值大的元素移動到右端 } A[low] = pivot; //樞紐元素存放到最終位置 return low; //返回存放樞紐的最終位置 }

若初始序列3,8,7,1,2,5,6,4排序過程以下:

2 8 7 1 2 5 6 4

2 8 7 1 8 5 6 4

2 1 7 1 8 5 6 4

2 1 7 7 8 5 6 4

2 1 3 7 8 5 6 4    //A[high]A[low]

2)兩個指針索引一前一後逐步向後掃描

int partition(elemtype A[], int p, int r) { elemtype x = A[r]; //以最後一個元素,A[r]爲主元 int i = p - 1; for (int j = p; j <= r - 1; ++j) { if (A[j] <= x) { ++i; exchange(A[i],A[j]); } } exchange(A[i+1],A[r]); return i + 1; }//若初始化序列3 8 7 1 2 5 6 4則排序的大體以下: 3 8 7 1 2 5 6 4 //3與3交換,不移動元素,比較一次 3 1 7 8 2 5 6 4 //8與1交換,交換依此,比較三次 3 1 2 8 7 5 6 4 //7與2交換,交換一次,比較一次 3 1 2 4 7 5 6 8 //8與4交換,交換一次,比較兩次

快速排序是全部內部排序算法中平均性能最優的排序算法。在快速排序算法中,並不產生有序子序列,但每一趟排序後將一個元素(基準元素)放在其最終位置上。當初始排序表基本有序或基本逆序是,就獲得最壞狀況下的時間複雜度O(n2).

A快排一次排序的應用

A)區分數組中大小寫字母(編寫函數,讓小寫字母在全部大寫字母以前)

bool isUpper(char a)
{
	if (a >= 'A'&&a <= 'Z')
		return true;
	return false;
}

bool isLower(char a)
{
	if (a >= 'a'&&a <= 'z')
		return true;
	return false;
}

void partition(char A[], int low, int high)  //開排一次排序第一種策略的另一種實現
{
	while (low < high)
	{
		while(low < high&&isUpper(A[high]))high--;
		while (low < high&&islower(A[low]))low++;
		char temp = A[high];
		A[high]=A[low];
		A[low] = temp;
	}

}
void main()
{
	char a[7] = {'a','A','Z','d','B','s','b'};
	partition(a,0,6);
}

 

b)給定含n個元素的整型數組a,包含0和非0,對數組進行排序,使排序後知足1.排序後的全部0元素在前,非零元素在後,且非零元素排序先後相對位置不變,不能使用額外的存儲空間。

void partition(int A[], int p, int r) { int i = r + 1; for (int j = r; j >= p; j--) //從後往前遍歷,也可從前日後遍歷 { if (A[j] != 0) { --i; int temp = A[i]; A[i]=A[j]; A[j] = temp; } } } void main() { int a[7] = {0,3,0,2,1,0,0}; partition(a,0,6); }

c)荷蘭國旗問題

while (current <= end) { if (array[current] == 0) { swap(array[current],array[begin]); current++; begin++; } else if (array[current == 1]) { current++; } else { //array[current]==2 swap(array[current], array[end]); end--; } }

 

D)輸入n個整數,輸出其中最小的k個。

思路1:將輸入的n個數排序,這樣排在最前面的k個數就是最小的k個數。

思路2:假設最小的k個數中最大的爲A。在快排中,先在數組中隨機選擇一個數字,而後調整數組中數字的順序,使得比選中數字小的數字排在他的左邊,比選中數字大的排在他的右邊(快排一次)

若選中的數字下表恰好是k-1(從0開始),那麼這個數字(A)加上左側的k-1個數就是最小的k個數。若是他的小標大於k-1,則A位於他的左側,咱們能夠在他的左邊部分的數組中查找。若小標小於k-1,那麼A應該位於他的右邊,咱們能夠接着在他的右邊部分中尋找。(發現這是一個遞歸問題,可是咱們找到的k個數不必定是有序的)

//input是輸入數組,元素個數爲n,output用來保存最小的k個數的數組。 void getLeastKNum(int* input, int n, int* output, int k) { if (input == NULL || output == NULL || k > n || n <= 0 || k <= 0) return; int start = 0; int end = n - 1; int index = partition(intput, start, end);//一次劃分函數見前 while (index != k - 1) { if (index > k - 1) { end = index - 1; index = partition(input, start, end); } else { start = index + 1; index = partition(input, start, end); } } for (int i = 0; i < k; i++) output[i] = input[i]; } //該算法的平均時間複雜度爲O(n)

3.選擇排序

思想:每一趟在後面n-i+1(i=1,2..n-1)個待排序元素中選取關鍵字最小的元素,做爲有序子序列的第i個元素,直到n-1趟作完,待排序元素只剩下1就不用再選了。

1)簡單選擇排序

 

void SelectSort(elemtype A[], int n) {//對錶A做簡單選擇排序,A[]從0開始存放元素 for(i=0;i<n-1;i++) //總共進行n-1趟排序 { min=i; //記錄最小元素位置 for(j=i+1;j<n;j++) if (A[j]<A[i]) //在A[i..n-1]中選擇最小元素 min=j; //更新最小元素位置 if(min!=i)swap(A[i],A[min]); //與第i個位置交換 } }

 

空間複雜度:O(1)。時間複雜度:元素移動較少不超過3(n-1)(一次swap三次元素移動)。最好移動0次(此時表已經有序)。可是元素間比較的次數與序列的初始狀態無關,始終爲n(n-1)/2次。時間複雜度爲O(n2).

2)堆排序

堆排序是一種樹形選擇排序方法,在排序過程當中將L[1..n]視爲一棵徹底二叉樹的順序村粗結構。利用徹底二叉樹中雙親結點和孩子結點之間的內在關係,在當前無序區中選擇關鍵字最大(或最小)的元素。

堆排序的實質是構建初始堆,對初始序列建堆,就是一個反覆篩選的過程。

A)根據初始關鍵字序列(20,18,22,16,30,19)構建初始大根堆。

void BuildMaxHeap(elemtype A[], int len) { for (int i = len / 2; i > 0; i--) AdjustDown(A,i,len); } void AdjustDown(elemtype A[], int k, int len) {//adjustDown將元素k向下進行調整,堆主要的兩個函數之一,另外一個adustun A[0]=A[k]; //A[0] 暫存 for (i = 2 * k; i <= len; i = i * 2) //沿k較大的子節點向下篩選 { if(i < len&&A[i] < A[i + 1]) i++; //取key較大的子節點的下標 if (A[0] >= A[i]) break; //篩選結束 else { A[k]=A[i]; //將A【i】調到雙親結點 k = i; //修改k值,繼續向下篩選 } } A[k] = A[0]; //被篩選結點的值放入最終位置 }

在元素個數爲n的序列上建堆,其時間複雜度爲O(n),這說明能夠在線性時間內,將一個無序數組建成一個大頂堆。

B)堆排序的思想

因爲堆自己的特色(以大頂堆爲例),堆頂元素就是最大值。輸出堆頂元素後,一般將堆底元素放入堆頂,此時根節點已不知足堆的性質,將堆頂元素向下調整繼續保持大頂堆性質,輸出堆頂元素,重複,直到僅剩一個元素爲止。

void HeapSort(elemtype A[], int len) { BuildMaxHeap(A,len); //初始建堆 for (i = len; i > 1; i--) //n-1趟交換和建堆過程 { swap(A[i],A[1]); //輸出堆頂元素(和堆底元素交換) adjustDown(A,1,i-1); //整理,把剩餘的i-1個元素整理成堆 } }

C)堆的插入和刪除

刪除堆頂元素時,先將堆的最後一個元素與堆頂元素交換,有序性質破壞,須要堆根結點進行向下調整。

對堆進行插入操做時,先將新結點放在堆的末端,再對這個新結點執行向上調整操做,大頂堆插入操做以下圖所示:

向上調整算法以下所示:

D)堆排序的應用(最小k個數)

輸入n個整數,輸出其中最小的k個.(用堆排序來解決,適合處理海量數據)

思路:首先讀入k個數建立一個大小爲k的大頂堆,而後依此讀入剩餘數據,若是當前數據比大頂堆的堆頂小,則用這個數代替當前堆頂元素,並調整時期保持大頂堆性質,若是當前數據比堆頂大,則此數不可能爲最小的k個整數之一,故拋棄此數。(時間複雜度:O(nlogk))

 

int a[n]; //數組a中存放輸入的n個數 int b[k + 1];//從a中依此讀入k個數a[0].....a[k-1]第一個數存在b[1]中 BuildMaxHeap(b,k);//調整b爲大頂堆 for (int i = k; i < n; i++) { if (a[i] > a[1]) continue; else { b[1] = a[i]; adjustdown(b,1,k); } } //當須要求最大的k個數時,只需將大頂堆換位小頂堆。

 

4.歸併排序

  1. 二路歸併排序(內部排序,基於分治算法的,使用輔助空間)

含義:將兩個或兩個以上的有序表組合成一個新的有序表。假定待排序表含有n個記錄,則可視爲n個有序子表,每一個子表長度爲1,兩兩歸併,獲得​長度爲2的有序表,再兩兩歸併...如此重複,直到合成一個長度爲n的有序表爲止。

過程:分解:將n個元素的待排序表分紅各含n/2個元素的子表,採用二路歸併算法對兩個子表遞歸的進行排序。

合併:合併兩個已排序的子表獲得排序結果。

void MergeSort(elemtype A[], int low, int high) { if (low < high) { int mid = (low + high) / 2; //對中間劃分兩個子序列 mergeSort(A,low,mid); //對左側子序列進行遞歸排序 mergeSort(A,mid+1,high); //對右側子序列進行遞歸排序 merge(A, low, mid, high); } }

Merge()的功能時將先後相鄰的兩個有序表歸併爲一個有序表的算法。設兩段有序表A【low...mid】A[mid+1...high]存放在同一順序表中相鄰的位置上,先將他們複製到輔助數組B中,每次從對應B中的兩個段取出一個記錄進行關鍵字比較,將較小者放入A中,當輸入B中有一段超出其表長,則將另外一段剩餘部分直接複製到A中。

elemtype *B = (elemtype *)malloc(n + 1) * sizeof(elemtype)); //輔助數組B void Merge(elemtype A[], int low, int mid, int high) { //表A的兩段A[low...mid]和A[mid+1...high]各自有序,將他們合併成一個有序表 for (int k = low; k <= high; k++) B[k]=A[k]; //加A中全部元素複製到B中 for (int i=low, j = mid + 1, k = i; i <= mid&&j <= high; k++) { if (B[i] < B[j]) //比較B的左右兩段元素 A[k] = B[i++]; //將較小的值複製到A中 else A[k]=B[j++]; } while (i <= mid) A[k++]=B[i++]; //若第一個表未檢測完,複製 while (j <= high) A[k++] = B[j++]; //若第二表未檢測完,複製 }//最後兩個while循環中只有一個會執行

 A)合併兩個排好序的鏈表(連個遞增排序鏈表,合併他們使新鏈表結點仍然是按照遞增排序的)

struct LIstNode { int value; ListNode *pNext; }; //原理:二路歸併排序的merge函數,遞歸代碼以下: ListNode* mergeList(ListNode* list1, ListNode* list2) { if (list1 == NULL) return list2; else if (list2 == NULL) return list1; ListNode* pHead = NULL; if (list1->value < list2->value) { pHead = list1; phead->pNext = mergeList(list1->pNext, list2); } else { pHead = list2; phead->pHead = mergeLIst(list1,list2->pNext); } return pHead; }

b)給定有序數組a,b.已知數組a末尾有足夠空間容納b,請實現將b合併到a中。函數頭以下:

Void merge(int a[],int b[],int n,int m)//n爲數組a的元素個數,m爲數組b的元素個數

思路:先計算總元素個數,從數組末尾(最大元素)開始歸併。

void merge(int a[], int b[], int n, int m) { int k = m + n - 1; int i = n - 1; int j = m - 1; while (i> = 0 && j >= 0) { if (a[i] > b[j]) { a[k--] = a[i--]; } else { a[k--] = a[j--]; } } while (j >= 0) { a[k--]=b[j--]; } }

C)原地歸併排序(二叉歸併排序 內部排序,不適用輔助空間)

原地歸併排序不須要輔助數組便可歸併。關鍵在merge這個函數。假設有兩段遞增的子數組arr[begin....mid-1]和arr[mid...end],但整個數組不是遞增的。其中i=begin,j=mid,k=end.

而後把i到mid-1的部分和mid到j-1的部分對調(可經過三次逆序實現)較小部分就調到前面去了,此時數組變爲0 1 2 3 4 5 6 9 7 8(前面有序了,後面又是兩個遞增子數組,繼續迭代便可)

 

void reverse(int *arr, int n) { //將長度爲n的數組逆序 int i = 0, j = n - 1; while (i < j) { swap(arr[i],arr[j]);//將兩個實參圖解交換 i++; j--; } } void exchange(int *arr, int n, int i) //將含有n個元素的數組向左循環移位i個位置 { reverse(arr,i); reverse(arr+i,n-i); reverse(arr, n); } //數組兩個有序部分合並,本節圖解的實現 void merge(int *arr,int begin,int mid,int end) { int i = begin, j = mid, k = end; while (i < j&&j <= k) { int step = 0; while (i < j&&arr[i] < arr[j]) i++; while (j < k&&arr[j] <= arr[i]) j++; step++; } //arr+i爲子數組首地址,j-i爲子數組元素個數,j-i-step爲左循環移位個數 exchange(arr+i,j-i,j-i-step); i = i + step; } void MergeSort(int *arr, int l, int r) { if (l < r) { int mid = (l + r) / 2; MergeSort(arr,l,mid); MergeSort(arr,mid+1,r); merge(arr,l,mid+1,r); } } void main() { int arr[] = {6,4,3,1,7,8,2,9,5,0}; int len = sizeof(arr) / sizeof(arr[0]); MergeSort(arr,0,len-1); }

D)多路歸併排序(外部排序)

外部排序是指大文件的排序,即待排序的記錄存儲在外部存儲器上,待排序的文件沒法一次裝入內存,須要在內存和外部存儲器之間進行屢次數據交換,以達到排序整個文件的目的。

思路:外部排序最經常使用的算法是多路歸併排序,即將源文件分解成多個可以一次性裝入內存的部分,分別把每一部分調入內存完成排序,而後對已排序的子文件進行歸併排序。

從二路到多路,增大k能夠減小外存信息讀寫時間,但k個歸併段中選擇最小的記錄須要比較k-1次,爲了下降選出每一個記錄須要的比較次數k,引入敗者數

敗者樹可視爲一棵徹底二叉樹,每一個葉結點存放各歸併段在歸併過程當中當前參加比較的記錄,內部結點用來記憶左右子樹中的失敗者,讓勝者網上繼續進行比較,一直到根節點。若是比較兩個數,大的爲失敗者,小的爲勝利者,則根節點指向的數爲最小數。

圖中第一個葉子結點爲b0.k路歸併的敗者樹深度爲​,所以k個記錄中選擇最小關鍵字,最多須要​次比較,比依此比較的k-1次小得多。

案例:有20個有序數組,每一個數組有500個unsigned int元素,降序排序。要求從這10000個元素中選出最大的500ge.

思路:依此從20個有序數組中選擇一個當前元素,兩兩比較,而後找出最大的數,循環500次,便可選擇出500個最大的數。可是這裏每選擇一個最大元素,須要比較19次,效率低。

改進方法1:利用堆,從20個數組中各取一個數,並記錄每一個數的來源數組,創建一個含有20個元素的大頂堆。此時堆頂就是最大元素,去除堆頂元素,並從堆頂元素的來源數組中取下一個元素加入堆,調整堆後再取最大值,一直這樣進行500次便可。時間複雜度,其中n爲要選出的元素個數,k爲有序數組個數。

改進方法2:利用敗者樹。從20個數組中各取一個數,並記錄每一個數的來源數組,創建一個20路歸併的敗者樹。此時敗者樹輸出的就是最大的數,而後從最大數的來源數組繼續取下一個數加入敗者樹,繼續比較,直到輸出500個數爲止。時間複雜度爲其中n爲要選出的元素個數,k爲有序數組個數。

const int branchesLength = 20;//共有20路數組 //20個一維數組,每一個數組有500個元素,爲葉子結點提供數據。本題只給出測試用的40個元素,輸出最大的前10個元素。 int branches[branchesLength][500] = { {1000,900} ,{999,888} ,{1001,990} ,{887,877} ,{987,978}, {1001,901} ,{992,883}, {1005,992}, {887,877}, {987,978}, {1002,902} ,{993,884}, {1007,991}, {887,877}, {987,978}, {1003,903} ,{994,882} ,{989,900} ,{887,877} ,{987,978} }; //敗者樹的非葉子結點,記錄數據源的索引位置,根據結點的值能夠定位到所指向的數據源。 int tree[branchesLength]; //敗者樹的葉子結點,葉子結點和數據源是一一對應的,即第一個葉子結點記錄第一個數據源的當前數據,第一個葉子結點爲b0 int nodes[branchesLength]; int nodes_iterator[branchesLength] = {0};//nodes_iterator[i]記錄第i路數組當前已取得第幾個元素 void put(int index)//設置第index葉結點的下一個數據 { nodes[index] = branches[index][nodes_iterator[index]++]; } int get(int index)//獲取第index個葉子結點的當前數據 { return nodes[index]; } //調整第index個葉子結點,具體調整爲:葉子結點和父節點比較,敗者留在父結點位置,勝者繼續和父節點的父節點,兄弟節點比較,直到整個樹的根節點。 void adjust(int index) //此函數爲主要函數 { int size = branchesLength; int t = (size + index) / 2; //計算父節點 while (t > 0) { if (get(tree[t]) > get(index)) {//敗者留在父節點位置 int temp = tree[t]; tree[t] = index; index = temp; } t /= 2; } tree[0] = index; } vector<int>merge() //依此讀取數據源的數據進行歸併排序,返回排序後的數據列表 { vector<int> list1; //記錄排好序的數據 int top; int i = 0; while (i < 10) {//僅輸出10個數據供測試 top = tree[0]; list1.push_back(top); i++; put(tree[0]); adjust(tree[0]); } return list1; } void init() //初始化構建敗者樹 { int size = branchesLength; for (int i = 0; i < size; i++) //爲葉子節點賦值 put(i); int winner = 0; for (int i = 1; i < size; i++) { if (get(i) < get(winner)) { winner = i; } } for (int i = 0; i < branchesLength; i++) //非葉子節點初始化爲冠軍節點 tree[i] = winner; for (int i = size - 1; i >= 0; i--) //從後向前依此調整非葉子節點 adjust(i); } void main() { init(); merge(); }

 不一樣排序算法的比較

 

總結:

1.比較次數和初始排列無關的是選擇排序。

2.在初始序列基本有序的狀況下,最優的是插入排序,此時插入排序時間複雜度爲O(n),其次是冒泡排序,時間複雜度也爲O(n).快速排序此時性能最差,時間複雜度爲,同時快速排序在廚師序列逆序的時候,性能也最差,時間複雜度也爲

3.堆排序對初始數據集的排列順序不敏感,在最好,最壞和平均狀況下,堆排序的時間複雜度爲

4.節儉排序,一對數字不進行兩次或兩次以上的比較。包括(插入排序,歸併排序)

5.基於比較的排序算法時間複雜度的下界(最好的時間複雜度)爲:

相關文章
相關標籤/搜索