到底什麼是大O
- n表示資料規模
- O(f(n)) fn是關於n的一個函式。表示執行演算法所需要執行的指令數,和f(n)成正比。
- 和a.b.c.d這些常數項關係不大。主要還是看它是哪個層級的。
演算法A:O(n) 所需執行指令數:10000n
演算法B:O(n^2) 所需執行指令數:10n^2
- n的規模逐漸增大。演算法a.b的指令數變化。
-
當n大於某個臨界點,a一定會超過b。這是量級上的差距。
-
複雜度很高的演算法可能有前面的優勢,在資料量很小的時候有意義。
- 對於所有高階的排序演算法,當資料規模小到一定程度,我們都可以使用插入排序法進行優化。10%-15%。細節優化。
-
不同時間複雜度隨著資料規模的增大
約定
- 在學術界,嚴格地講,O(f(n))表示演算法執行的上界
- 歸併排序演算法的時間複雜度是O(nlogn)的,同時也是O(n^2)
- c.nlogn < a.n^2
- 在業界,我們就使用O來表示演算法執行的最低上界
- 我們一般不會說歸併排序是O(n^2)的
例子
- 以主導作用的為準:
O( nlogn + n ) = O( nlogn )
O( nlogn + n^2 ) = O( n^2 )
- 上面的公式要求演算法處理的n是一樣的(O( AlogA + B ) 、O( AlogA + B^2 ))
- 上面這種不能省略
- 對鄰接表實現的圖進行遍歷:
- 時間複雜度:O( V + E ) V是頂點個數,E是邊的個數。
- 稠密圖,甚至完全圖。E是近乎V^2級別。
一個時間複雜度的問題
有一個字串陣列,將陣列中的每一個字串按照字母序排序;之後再將整個字串陣列按照字典序排序。整個操作的時間複雜度?
- 每個字串n*nlogn + 整個字串陣列:nlogn
- 錯誤字串的長度和陣列長度混淆
假設最長的字串長度為s;陣列中有n個字串
對每個字串排序:O(slogs)
將陣列中的每一個字串按照字母序排序:O(n*slog(s))
-
將整個字串陣列按照字典序排序:O(s*nlog(n))
- 解釋:對於排序演算法時間複雜度理解:
- nlogn 是 比較的次數。對整型陣列排序只需要nlogn次比較。
- 因為兩個整數之間比較O(1)。兩個字串比較不一樣O(s)。
-
O(nslog(s)) + O(snlog(n)) = O( nslogs + snlogn )= O( ns(logs+logn) )
-
字串陣列進行字典序排序。比較nlogn次,每次比較需要O(s)時間複雜度。
演算法複雜度在有些情況是用例相關的
插入排序演算法 O(n^2)
- 最差情況:O(n^2)
- 最好情況:O(n):近乎有序
- 平均情況:O(n^2)
快速排序演算法 O(nlogn)
- 最差情況:O(n^2) 不隨機。有序
- 最好情況:O(nlogn) 隨機化標定點
- 平均情況:O(nlogn)
嚴謹演算法最好最差平均。我們經常關注的是大多數。
極端情況心裡有數就行了。
資料規模的概念
對 10^5 的資料進行選擇排序,結果計算機假死?
#include <iostream>
#include <cmath>
#include <ctime>
using namespace std;
int main() {
// 資料規模每次增大10倍進行測試
// 有興趣的同學也可以試驗一下資料規模每次增大2倍哦:)
for( int x = 1 ; x <= 9 ; x ++ ){
int n = pow(10, x);
clock_t startTime = clock();
long long sum = 0;
for( int i = 0 ; i < n ; i ++ )
sum += i;
clock_t endTime = clock();
cout << "sum = " << sum << endl;
cout << "10^" << x << " : "
<< double(endTime - startTime)/CLOCKS_PER_SEC
<< " s" << endl << endl;
}
return 0;
}
複製程式碼
執行結果
sum = 45
10^1 : 2e-06 s
sum = 4950
10^2 : 1e-06 s
sum = 499500
10^3 : 4e-06 s
sum = 49995000
10^4 : 2.9e-05 s
sum = 4999950000
10^5 : 0.000305 s
sum = 499999500000
10^6 : 0.003049 s
sum = 49999995000000
10^7 : 0.029234 s
sum = 4999999950000000
10^8 : 0.308056 s
sum = 499999999500000000
10^9 : 2.98528 s
複製程式碼
如果要想在1s之內解決問題
- O(n^2)的演算法可以處理大約10^4級別的資料;
- O(n)的演算法可以處理大約10^8級別的資料;
- O(nlogn)的演算法可以處理大約10^7級別的資料
因為我們剛才的操作很簡單,就是簡單的加法。所以正常還需要低估一點,再除以10
空間複雜度
- 多開一個輔助的陣列:O(n)
- 多開一個輔助的二維陣列:O(n^2)
- 多開常數空間:O(1):原地陣列排序
- 遞迴呼叫是有空間代價的:
- 在遞迴呼叫前的函式壓入系統棧中的。
常見的複雜度分析
O(1)
沒有資料規模的變化
// O(1)
void swapTwoInts( int &a , int &b ){
int temp = a;
a = b;
b = temp;
return;
}
複製程式碼
O(n)
迴圈操作次數為c.n。c是個常數不一定為大於1的數
// O(n) Time Complexity
int sum( int n ){
int ret = 0;
for( int i = 0 ; i <= n ; i ++ )
ret += i;
return ret;
}
複製程式碼
O(n)
迴圈次數為1/2 * n次
字串翻轉。abc-cba.第一個和倒數第一個。第2個和倒數第二個
掃描一半就交換完了:1/2*n次swap操作:O(n)
void reverse( string &s ){
int n = s.size();
for( int i = 0 ; i < n/2 ; i ++ )
swap( s[i] , s[n-1-i] );
return;
}
複製程式碼
O(n^2)
選擇排序法。O(n^2)
雙重迴圈: 第一重到n。第二重到n。都是+1.
所執行的指令數和n^2成比例。
i = 0;j執行了n-1次 等差數列求和
(n-1) + (n-2) + (n-3) + … + 0
= (0+n-1)*n/2
= (1/2)n*(n-1)
= 1/2*n^2 - 1/2*n
= O(n^2)
// O(n^2) Time Complexity
void selectionSort(int arr[], int n){
for(int i = 0 ; i < n ; i ++){
int minIndex = i;
for( int j = i + 1 ; j < n ; j ++ )
if( arr[j] < arr[minIndex] )
minIndex = j;
swap( arr[i] , arr[minIndex] );
}
}
複製程式碼
30n次基本操作:O(n)
因為第二層迴圈是固定的不受n影響的。
// O(n) Time Complexity
void printInformation(int n){
for( int i = 1 ; i <= n ; i ++ )
for( int j = 1 ; j <= 30 ; j ++ )
cout<<"Class "<<i<<" - "<<"No. "<<j<<endl;
return;
}
複製程式碼
o(logn)
對有序陣列找到中間元素來判斷元素和中間元素的關係。
如果沒有查詢到,都可以扔掉一半的元素。
二分查詢法
// O(logn) Time Complexity
int binarySearch(int arr[], int n, int target){
int l = 0, r = n-1;
while( l <= r ){
int mid = l + (r-l)/2;
if( arr[mid] == target ) return mid;
if( arr[mid] > target ) r = mid - 1;
else l = mid + 1;
}
return -1;
}
複製程式碼
O(logn)
n經過幾次“除以10”操作後,等於0?
log10n = O(logn)
while迴圈中每次除以10,直到0結束。
reverse(s)複雜度:1/2 n次的交換操作。s字串有多少位,與n一致。
string intToString( int num ){
string s = "";
string sign = "+";
if( num < 0 ){
num = -num;
sign = "-";
}
while( num ){
s += '0' + num%10;
num /= 10;
}
if( s == "" )
s = "0";
reverse(s);
if( sign == "-" )
return sign + s;
else
return s;
}
複製程式碼
O(nlogn)
第二重迴圈就是n
第一重size+=size就是乘以2.log2n
// O(nlogn)
void hello(int n){
for( int sz = 1 ; sz < n ; sz += sz )
for( int i = 1 ; i < n ; i ++ )
cout<<"Hello, Algorithm!"<<endl;
}
複製程式碼
O(sqrt(n))
x從n走一直走到根號n結束
// O(sqrt(n)) Time Complexity
bool isPrime( int num ){
for( int x = 2 ; x*x <= num ; x ++ )
if( num%x == 0 )
return false;
return true;
}
bool isPrime2( int num ){
if( num <= 1 ) return false;
if( num == 2 ) return true;
if( num%2 == 0 ) return false;
for( int x = 3 ; x*x <= num ; x += 2 )
if( num%x == 0 )
return false;
return true;
}
複製程式碼
複雜度實驗。
我們自以為寫出了一個O(nlogn)的演算法,但實際是O(n^2)的演算法?
如果要想在1s之內解決問題:
- O(n2)的演算法可以處理大約10^4級別的資料;
- O(n)的演算法可以處理大約10^8級別的資料;
- O(nlogn)的演算法可以處理大約10^7級別的資料
前面的常數差距有可能很大。
實驗,觀察趨勢:
每次將資料規模提高兩倍,看時間的變化
四個不同複雜度的演算法。
namespace MyAlgorithmTester{
// O(logN)
int binarySearch(int arr[], int n, int target){
int l = 0, r = n-1;
while( l <= r ){
int mid = l + (r-l)/2;
if( arr[mid] == target ) return mid;
if( arr[mid] > target ) r = mid - 1;
else l = mid + 1;
}
return -1;
}
// O(N)
int findMax( int arr[], int n ){
assert( n > 0 );
int res = arr[0];
for( int i = 1 ; i < n ; i ++ )
if( arr[i] > res )
res = arr[i];
return res;
}
// O(NlogN) 自底向上
void __merge(int arr[], int l, int mid, int r, int aux[]){
for(int i = l ; i <= r ; i ++)
aux[i] = arr[i];
int i = l, j = mid+1;
for( int k = l ; k <= r; k ++ ){
if( i > mid ) { arr[k] = aux[j]; j ++;}
else if( j > r ){ arr[k] = aux[i]; i ++;}
else if( aux[i] < aux[j] ){ arr[k] = aux[i]; i ++;}
else { arr[k] = aux[j]; j ++;}
}
}
void mergeSort( int arr[], int n ){
int *aux = new int[n];
for( int i = 0 ; i < n ; i ++ )
aux[i] = arr[i];
for( int sz = 1; sz < n ; sz += sz )
for( int i = 0 ; i < n ; i += sz+sz )
__merge(arr, i, i+sz-1, min(i+sz+sz-1,n-1), aux );
delete[] aux;
return;
}
// O(N^2) 選擇排序
void selectionSort( int arr[], int n ){
for(int i = 0 ; i < n ; i ++){
int minIndex = i;
for( int j = i + 1 ; j < n ; j ++ )
if( arr[j] < arr[minIndex] )
minIndex = j;
swap( arr[i] , arr[minIndex] );
}
return;
}
}
複製程式碼
生成測試用例的程式碼:
namespace MyUtil {
int *generateRandomArray(int n, int rangeL, int rangeR) {
assert( n > 0 && rangeL <= rangeR );
int *arr = new int[n];
srand(time(NULL));
for (int i = 0; i < n; i++)
arr[i] = rand() % (rangeR - rangeL + 1) + rangeL;
return arr;
}
int *generateOrderedArray(int n) {
assert( n > 0 );
int *arr = new int[n];
for (int i = 0; i < n; i++)
arr[i] = i;
return arr;
}
}
複製程式碼
測試是不是O(n)級別的
int main() {
// 資料規模倍乘測試findMax
// O(n)
cout<<"Test for findMax:"<<endl;
for( int i = 10 ; i <= 26 ; i ++ ){
int n = pow(2,i);
int *arr = MyUtil::generateRandomArray(n, 0, 100000000);
clock_t startTime = clock();
MyAlgorithmTester::findMax(arr, n);
clock_t endTime = clock();
cout<<"data size 2^"<<i<<" = "<<n<<"\t";
cout<<"Time cost: "<<double(endTime - startTime)/CLOCKS_PER_SEC<<endl;
delete[] arr;
}
return 0;
}
複製程式碼
執行結果:
Test for findMax:
data size 2^10 = 1024 Time cost: 5e-06 s
data size 2^11 = 2048 Time cost: 7e-06 s
data size 2^12 = 4096 Time cost: 1.2e-05 s
data size 2^13 = 8192 Time cost: 2.5e-05 s
data size 2^14 = 16384 Time cost: 4.7e-05 s
data size 2^15 = 32768 Time cost: 9.2e-05 s
data size 2^16 = 65536 Time cost: 0.000169 s
data size 2^17 = 131072 Time cost: 0.000431 s
data size 2^18 = 262144 Time cost: 0.000737 s
data size 2^19 = 524288 Time cost: 0.001325 s
data size 2^20 = 1048576 Time cost: 0.002489 s
data size 2^21 = 2097152 Time cost: 0.005739 s
data size 2^22 = 4194304 Time cost: 0.011373 s
data size 2^23 = 8388608 Time cost: 0.019566 s
data size 2^24 = 16777216 Time cost: 0.040289 s
data size 2^25 = 33554432 Time cost: 0.095169 s
data size 2^26 = 67108864 Time cost: 0.201682 s
data size 2^27 = 134217728 Time cost: 0.330673 s
data size 2^28 = 268435456 Time cost: 0.750136 s
複製程式碼
n增加了兩倍。時間也大致增加兩倍,所以該演算法為O(n)級別的。
測試是不是O(n^2)
int main() {
// 資料規模倍乘測試selectionSort
// O(n^2)
cout<<"Test for selectionSort:"<<endl;
for( int i = 10 ; i <= 15 ; i ++ ){
int n = pow(2,i);
int *arr = MyUtil::generateRandomArray(n, 0, 100000000);
clock_t startTime = clock();
MyAlgorithmTester::selectionSort(arr,n);
clock_t endTime = clock();
cout<<"data size 2^"<<i<<" = "<<n<<"\t";
cout<<"Time cost: "<<double(endTime - startTime)/CLOCKS_PER_SEC<<endl;
delete[] arr;
}
return 0;
}
複製程式碼
執行結果:大約4倍
Test for Selection Sort:
data size 2^10 = 1024 Time cost: 0.001581 s
data size 2^11 = 2048 Time cost: 0.006221 s
data size 2^12 = 4096 Time cost: 0.021913 s
data size 2^13 = 8192 Time cost: 0.081103 s
data size 2^14 = 16384 Time cost: 0.323263 s
data size 2^15 = 32768 Time cost: 1.32474 s
data size 2^16 = 65536 Time cost: 5.19642 s
複製程式碼
資料量n增加了2倍。時間增加了4倍。
測試是不是O(logN)
int main() {
// 資料規模倍乘測試binarySearch
// O(logn)
cout<<"Test for binarySearch:"<<endl;
for( int i = 10 ; i <= 28 ; i ++ ){
int n = pow(2,i);
int *arr = MyUtil::generateOrderedArray(n);
clock_t startTime = clock();
MyAlgorithmTester::binarySearch(arr,n,0);
clock_t endTime = clock();
cout<<"data size 2^"<<i<<" = "<<n<<"\t";
cout<<"Time cost: "<<double(endTime - startTime)/CLOCKS_PER_SEC<<endl;
delete[] arr;
}
return 0;
}
複製程式碼
複雜度試驗
log2N / logN
= (log2 + logN)/logN
= 1 + log2/logN
複製程式碼
當資料規模變大兩倍。執行效率增加1.幾倍。
執行結果:
Test for Binary Search:
data size 2^10 = 1024 Time cost: 1e-06 s
data size 2^11 = 2048 Time cost: 0 s
data size 2^12 = 4096 Time cost: 0 s
data size 2^13 = 8192 Time cost: 2e-06 s
data size 2^14 = 16384 Time cost: 1e-06 s
data size 2^15 = 32768 Time cost: 1e-06 s
data size 2^16 = 65536 Time cost: 1e-06 s
data size 2^17 = 131072 Time cost: 2e-06 s
data size 2^18 = 262144 Time cost: 3e-06 s
data size 2^19 = 524288 Time cost: 1e-06 s
data size 2^20 = 1048576 Time cost: 4e-06 s
data size 2^21 = 2097152 Time cost: 3e-06 s
data size 2^22 = 4194304 Time cost: 3e-06 s
data size 2^23 = 8388608 Time cost: 4e-06 s
data size 2^24 = 16777216 Time cost: 4e-06 s
data size 2^25 = 33554432 Time cost: 1.2e-05 s
data size 2^26 = 67108864 Time cost: 9e-06 s
data size 2^27 = 134217728 Time cost: 1.1e-05 s
data size 2^28 = 268435456 Time cost: 2.4e-05 s
複製程式碼
執行結果,變化小
順序查詢轉換為二分查詢,大大提高效率
測試是不是O(NlogN)
和O(N)差不多
int main() {
// 資料規模倍乘測試mergeSort
// O(nlogn)
cout<<"Test for mergeSort:"<<endl;
for( int i = 10 ; i <= 24 ; i ++ ){
int n = pow(2,i);
int *arr = MyUtil::generateRandomArray(n,0,1<<30);
clock_t startTime = clock();
MyAlgorithmTester::mergeSort(arr,n);
clock_t endTime = clock();
cout<<"data size 2^"<<i<<" = "<<n<<"\t";
cout<<"Time cost: "<<double(endTime - startTime)/CLOCKS_PER_SEC<<endl;
delete[] arr;
}
return 0;
}
複製程式碼
執行結果:
Test for Merge Sort:
data size 2^10 = 1024 Time cost: 0.000143 s
data size 2^11 = 2048 Time cost: 0.000325 s
data size 2^12 = 4096 Time cost: 0.000977 s
data size 2^13 = 8192 Time cost: 0.001918 s
data size 2^14 = 16384 Time cost: 0.003678 s
data size 2^15 = 32768 Time cost: 0.007635 s
data size 2^16 = 65536 Time cost: 0.015768 s
data size 2^17 = 131072 Time cost: 0.034462 s
data size 2^18 = 262144 Time cost: 0.069586 s
data size 2^19 = 524288 Time cost: 0.136214 s
data size 2^20 = 1048576 Time cost: 0.294626 s
data size 2^21 = 2097152 Time cost: 0.619943 s
data size 2^22 = 4194304 Time cost: 1.37317 s
data size 2^23 = 8388608 Time cost: 2.73054 s
data size 2^24 = 16777216 Time cost: 5.60827 s
複製程式碼
大約兩倍
遞迴演算法的複雜度分析
- 不是有遞迴的函式就一定是O(nlogn)!
二分查詢的遞迴實現:
左半邊或者右半邊。無論選那邊都只進行一次
每次減半,遞迴呼叫的深度為logn,處理問題的複雜度為O(1)
// binarySearch
int binarySearch(int arr[], int l, int r, int target){
if( l > r )
return -1;
int mid = l + (r-l)/2;
if( arr[mid] == target )
return mid;
else if( arr[mid] > target )
return binarySearch(arr, l, mid-1, target);
else
return binarySearch(arr, mid+1, r, target);
}
複製程式碼
如果遞迴函式中,只進行一次遞迴呼叫,
遞迴深度為depth;
在每個遞迴函式中,時間複雜度為T;
則總體的時間複雜度為O( T * depth )
求和遞迴實現
遞迴深度:n
時間複雜度:O(n)
// sum
int sum( int n ){
assert( n >= 0 );
if( n == 0 )
return 0;
return n + sum(n-1);
}
複製程式碼
計算x的n次方的冪運算
// pow2
double pow( double x, int n ){
assert( n >= 0 );
if( n == 0 )
return 1.0;
double t = pow(x, n/2);
//奇數
if( n%2 )
return x*t*t;
return t*t;
}
複製程式碼
遞迴深度:logn
時間複雜度:O(logn)
遞迴中進行多次遞迴呼叫
遞迴樹的深度是N
2^0 + 2^1 + 2^2 + 2^3 + … + 2^n
= 2n+1 - 1
= O(2^n)
指數級的演算法:非常慢。n在20左右。30就非常慢 剪枝操作:動態規劃。人工智慧:搜尋樹
// f
int f(int n){
assert( n >= 0 );
if( n == 0 )
return 1;
return f(n-1) + f(n-1);
}
複製程式碼
歸併排序n=8
樹的深度是logN 當n等於8時,層數為3層。每一層處理的資料規模越來越小
一個分成logn層。每一層相加的總體規模還是n
// mergeSort
void mergeSort(int arr[], int l, int r){
if( l >= r )
return;
int mid = (l+r)/2;
mergeSort(arr, l, mid);
mergeSort(arr, mid+1, r);
merge(arr, l, mid, r);
}
複製程式碼
均攤複雜度分析 Amortized Time analysis
一個演算法複雜度相對較高,但是它是為了方便其他的操作。
比較高的會均攤到整體。
動態陣列(Vector)
template <typename T>
class MyVector{
private:
T* data;
int size; // 儲存陣列中的元素個數
int capacity; // 儲存陣列中可以容納的最大的元素個數
// O(n):一重迴圈。
void resize(int newCapacity){
assert( newCapacity >= size );
T *newData = new T[newCapacity];
for( int i = 0 ; i < size ; i ++ )
newData[i] = data[i];
delete[] data;
data = newData;
capacity = newCapacity;
}
public:
MyVector(){
data = new T[100];
size = 0;
capacity = 100;
}
~MyVector(){
delete[] data;
}
// Average: O(1)
void push_back(T e){
//動態陣列
if( size == capacity )
resize( 2* capacity );
data[size++] = e;
}
// O(1)
T pop_back(){
assert( size > 0 );
size --;
//size是從0開始的。也就是0號索引size為1.
//所以要拿到最後一個元素,就得size-1
return data[size];
}
};
複製程式碼
均攤
第n+1次會花費O(n)但是會把這n分攤到前面n次操作。也就是變成了O(2)
還是常數O(1)級的。
resize是有條件的,而不是每次都呼叫。
int main() {
for( int i = 10 ; i <= 26 ; i ++ ){
int n = pow(2,i);
clock_t startTime = clock();
MyVector<int> vec;
for( int i = 0 ; i < n ; i ++ ){
vec.push_back(i);
}
clock_t endTime = clock();
cout<<n<<" operations: \t";
cout<<double(endTime - startTime)/CLOCKS_PER_SEC<<" s"<<endl;
}
return 0;
}
複製程式碼
1024 operations: 2.5e-05 s
2048 operations: 2.9e-05 s
4096 operations: 7.4e-05 s
8192 operations: 0.000154 s
16384 operations: 0.000265 s
32768 operations: 0.000391 s
65536 operations: 0.001008 s
131072 operations: 0.002006 s
262144 operations: 0.003863 s
524288 operations: 0.005842 s
1048576 operations: 0.014672 s
2097152 operations: 0.029367 s
4194304 operations: 0.06675 s
8388608 operations: 0.124446 s
16777216 operations: 0.240025 s
33554432 operations: 0.486061 s
67108864 operations: 0.960224 s
複製程式碼
基本滿足2倍關係
刪除元素縮小空間。
每次普通刪除時間複雜度都為O(1)
只剩下n個。這次resize n 刪除這個元素為1
重複這個過程,無法均攤,複雜度為O(n)
當元素個數為陣列容量的1/4時,resize.為再新增元素留出餘地
template <typename T>
class MyVector{
private:
T* data;
int size; // 儲存陣列中的元素個數
int capacity; // 儲存陣列中可以容納的最大的元素個數
// O(n)
void resize(int newCapacity){
assert( newCapacity >= size );
T *newData = new T[newCapacity];
for( int i = 0 ; i < size ; i ++ )
newData[i] = data[i];
delete[] data;
data = newData;
capacity = newCapacity;
}
public:
MyVector(){
data = new T[100];
size = 0;
capacity = 100;
}
~MyVector(){
delete[] data;
}
// Average: O(1)
void push_back(T e){
if( size == capacity )
resize( 2* capacity );
data[size++] = e;
}
// Average: O(1)
T pop_back(){
assert( size > 0 );
T ret = data[size-1];
size --;
if( size == capacity/4 )
resize( capacity/2 );
//resize之後會把data[size]元素抹掉
return ret;
}
};
複製程式碼
執行結果
2048 operations: 4.3e-05 s
4096 operations: 6.3e-05 s
8192 operations: 0.000107 s
16384 operations: 0.000316 s
32768 operations: 0.000573 s
65536 operations: 0.001344 s
131072 operations: 0.001995 s
262144 operations: 0.004102 s
524288 operations: 0.008599 s
1048576 operations: 0.014714 s
2097152 operations: 0.027181 s
4194304 operations: 0.063136 s
8388608 operations: 0.126046 s
16777216 operations: 0.242574 s
33554432 operations: 0.456381 s
67108864 operations: 0.96618 s
134217728 operations: 1.76422 s
複製程式碼
均攤複雜度
- 動態陣列
- 動態棧
- 動態佇列
-------------------------華麗的分割線--------------------
看完的朋友可以點個喜歡/關注,您的支援是對我最大的鼓勵。
想了解更多,歡迎關注我的微信公眾號:番茄技術小棧