## 最近點對問題 Closest Pair of Points ![最近點](https://hackmd.io/_uploads/rJObdgJXge.png) reference: APCS進階程式設計課程 課本 ==6-2 分⽽治之== --- 緣起: > 有位同學問了, > 老師,為什麼我寫了時間 ==複雜度較低== 的程式,但 APCS 跑出來的 ==時間== 卻更久 > :star: 感謝 `20803 江旻達` 同學 2024/06 提供的 [非常好問題](https://zh.wikipedia.org/zh-tw/早上好中国,现在我有冰淇淋#:~:text=激情9,因為-,非常好電影,-,動作非常好) 與兩個不同方法的 `程式碼` :star: [TOC] ### 題目 10000 個二維座標點 $(0 \le x, y \le 40000)$ 找最近的兩個點的 ==距離== ### 簡述策略 首先這題分而治之的大方向是: ```cpp= // 1. 從 x 座標分割成左右兩個半邊 mid = (left + right) / 2; // 2. 取得兩半邊各自的最近點對 "距離" 後 min_left = get_min_distance(arr, left, mid); min_right = get_min_distance(arr, mid, right); // 3. 針對交錯的點對計算最短距離 (一個點在左,一個點在右的 pair) // 3.1 根據推論(請查看課本),發現一個好特性, // 只需要抓分割線左右邊 - 距離 d 的點來判斷就好了 d = min(min_left, min_right); // 3.2 找交錯 pair 的最小距離 // 在 X 座標於 [mid - d, mid + d] 這個區間中的點集中起來 // 找最小距離 ``` 他寫的兩個版本(如同課本介紹的順序)分別是 1. 將中間的所有點兩兩比較 $O(n^2)$ 2. 將中間的所有點 沿著 y 軸排序 $O(n \log n)$,再從上到下檢查:每個點,與後七個點的距離$O(n)$ :::spoiler 1.cpp ```cpp= // old method #include <iostream> #include<cmath> #include<algorithm> #include<iomanip> #include <chrono> using namespace std; struct P{ double x; double y; }; bool cmpx(P a,P b){ return a.x<b.x; } bool cmpy(P a,P b){ return a.y<b.y; } double d(P a,P b){ return sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)); } double closestpair(P arr[],int h,int l){ int n=h-l+1; if(n==1)return(-1); if(n==2)return(d(arr[l],arr[h])); if(n==3)return(min(d(arr[l],arr[h]),min(d(arr[l+1],arr[h]),d(arr[l],arr[l+1])))); int mid=(h+l)/2; double dl=closestpair(arr,mid,l); double dr=closestpair(arr,h,mid+1); double ans=min(dl,dr); int midl=mid; int midh=mid; int midx=arr[mid].x; for(int i=mid;i>=l;i--){ if(arr[i].x<midx-ans) break; midl=i; } for(int i=mid;i<=h;i++){ if(arr[i].x>midx+ans) break; midh=i; } for(int i=midl;i<=midh;i++){ for(int j=i+1;j<=midh;j++){ ans=min(ans,d(arr[i],arr[j])); } } // int k=0; // P arry[n]; // for(int i=l;i<=h;i++){ // if(arr[i].x>=arr[mid].x-ans&&arr[i].x<=arr[mid].x+ans){ // arry[k].x=arr[i].x; // arry[k].y=arr[i].y; // k++; // } // } // sort(arry,arry+k,cmpy); // for(int i=0;i<k;i++){ // for(int j=0;j<7;j++){ // if(i+j+1<k)ans=min(ans,d(arry[i],arry[i+j+1])); // } // } // k=0; return(ans); } int main(){ auto start = std::chrono::high_resolution_clock::now(); P arr[10001]; int N; while(cin>>N){ if(N==0) break; else{ for(int i=0;i<N;i++){ cin>>arr[i].x>>arr[i].y; } } sort(arr,arr+N,cmpx); double d=closestpair(arr,N-1,0); if(d>10000||d==-1){ cout<<"INFINITY "<<endl; } //else cout << setiosflags (ios::fixed) << setprecision (4)<< floor (d * 10000)/10000<< endl; else cout<<fixed<<setprecision(4)<<d<<endl; } auto end = std::chrono::high_resolution_clock::now(); // 計算時間差 std::chrono::duration<double> elapsed = end - start; // 顯示經過的時間(秒) std::cout << "Elapsed time: " << elapsed.count() << " seconds\n"; } ``` ::: :::spoiler 2.cpp ```cpp= // new method #include <iostream> #include<cmath> #include<algorithm> #include<iomanip> #include <chrono> using namespace std; struct P{ double x; double y; }; bool cmpx(P a,P b){ return a.x<b.x; } bool cmpy(P a,P b){ return a.y<b.y; } double d(P a,P b){ return sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)); } double closestpair(P arr[],int h,int l){ int n=h-l+1; if(n==1)return(-1); if(n==2)return(d(arr[l],arr[h])); if(n==3)return(min(d(arr[l],arr[h]),min(d(arr[l+1],arr[h]),d(arr[l],arr[l+1])))); int mid=(h+l)/2; double dl=closestpair(arr,mid,l); double dr=closestpair(arr,h,mid+1); double ans=min(dl,dr); // int midl=mid; // int midh=mid; // int midx=arr[mid].x; // for(int i=mid;i>=l;i--){ // if(arr[i].x<midx-ans) break; // midl=i; // } // for(int i=mid;i<=h;i++){ // if(arr[i].x>midx+ans) break; // midh=i; // } // for(int i=midl;i<=midh;i++){ // for(int j=i+1;j<=midh;j++){ // ans=min(ans,d(arr[i],arr[j])); // } // } int k=0; P arry[n]; for(int i=l;i<=h;i++){ if(arr[i].x>=arr[mid].x-ans&&arr[i].x<=arr[mid].x+ans){ arry[k].x=arr[i].x; arry[k].y=arr[i].y; k++; } } sort(arry,arry+k,cmpy); for(int i=0;i<k;i++){ for(int j=0;j<7;j++){ if(i+j+1<k)ans=min(ans,d(arry[i],arry[i+j+1])); } } k=0; return(ans); } int main(){ auto start = std::chrono::high_resolution_clock::now(); P arr[10001]; int N; bool con=true; while(cin>>N){ if(N==0) break; else{ for(int i=0;i<N;i++){ cin>>arr[i].x>>arr[i].y; if(arr[i].x>40000||arr[i].y>40000||arr[i].x<0||arr[i].y<0)con =false; } } sort(arr,arr+N,cmpx); double d=closestpair(arr,N-1,0); if(d>10000||d==-1||con==false){ cout<<"INFINITY "<<endl; con=true; } //else cout << setiosflags (ios::fixed) << setprecision (4)<< floor (d * 10000)/10000<< endl; else cout<<fixed<<setprecision(4)<<d<<endl; } auto end = std::chrono::high_resolution_clock::now(); // 計算時間差 std::chrono::duration<double> elapsed = end - start; // 顯示經過的時間(秒) std::cout << "Elapsed time: " << elapsed.count() << " seconds\n"; } ``` ::: p.s. 我在他的 main 頭尾加上了計時的程式碼 `auto start = ...` --- 理論上來說,方法 2 會比較好 但送到 APCScalss 後,反而 ==方法 1 的執行時間更短==,多次測試都得到一樣的結果 ![image](https://hackmd.io/_uploads/BJUuSTD7ge.png) 這就是問題: :::danger 為什麼時間複雜度低的好方法,反而需要更長的時間執行? ::: --- ### 時間複雜度的意義 $O(n^2)$ 是否永遠都比 $O(n \log n)$ 還慢? 1. $10\ n \log n > 0.1\ n^2,$ ` if n < 200` ![image](https://hackmd.io/_uploads/ryBTGyOmee.png =400x) 演算法為了降低時間複雜度,通常會多做一些事情,減緩最耗時的步驟, 當 n 不夠大時,這些耗時的部分就導致時間過久了 :::info 以這題來說,n=10000 已經夠大了,不是這個問題 ::: 2. 特例:==平均狀況== 與 ==最壞狀況== 我們平常(或之前)討論的是 ==最壞狀況== 的時間複雜度,最久會需要多長的時間 但這個最壞狀況不見得常常發生,比如: quicksort 的最壞狀況就是:每次選擇的切割點都是最大/最小值,導致算法會變成 $O(n^2)$ --- 來到這個最近點對問題,可以先思考第二種方法是想解決什麼問題。 他想解決的問題是: > 第一種方法會導致 $O(n^2)$ 的搜尋次數 (搜尋範圍是 `[mid-d, mid+d]` 中的點。 > 所以當範圍中的點 $n$ 很少,時間並不會太久;當範圍中的點 $n$ 很多(==最壞狀況==),搜尋時間才會過久 :::info 那我就試著生成一個中間點很多的測資,然後分別用這兩種程式來跑就可以看出差異了 :+1: ::: ### 生成特例測資 其實生測資的程式,我也是請 AI 生的 :+1: :::spoiler gen.cpp ```cpp= /* 幫我寫一個 CPP 程式,生成最近點對問題的特例測資 10000 個點 0 <= xy <= 40000 我需要的特例測資是,面對以下算法會過久的測資: distance = min(left_min, right_min); min_i <- min i that point[i].x >= point[mid].x - distance max_i <- max i that ... for(i = min_i; i<=max_i; i++) { for(j=i+1; j<=max_i; j++) { distance = min(distance(new_distance_from(point[i], point[j])); } } */ #include <iostream> #include <fstream> #include <iomanip> #include <cstdlib> using namespace std; int main() { ofstream fout("closest_pair_hardcase.txt"); const int N = 10000; const int x_start = 20000; const int x_range = 8; // 所有 x 在 [20000, 20008] 之間 const int y_step = 4; // 保持距離不太近 const int y_start = 0; fout << N << "\n"; for (int i = 0; i < N; ++i) { int x = x_start + (rand() % x_range); // 產生接近的 x 值 int y = y_start + i * y_step; // 線性增加 y 值,保證距離夠大 fout << x << " " << y << "\n"; } fout.close(); cout << "Test case generated: closest_pair_hardcase.txt" << endl; return 0; } ``` ::: 他做的事情就是,將點沿著 y 軸平分,所以大約間隔 4 個單位 $(40000 / 10000)$ > 以確保左右半邊找到的解 (`min_left`, `min_right`) 足夠大 接著將這些點的 x 座標均勻分布在 `[mid-4. mid+4]` 的範圍 > 以確保所有點都在中間線附近 (距離 4 以內) ### 測試兩份程式碼 1. 使用上述程式碼生成測資 `closest_pair_hardcase.txt` 2. 分別使用兩份 code 來執行, 但為了避免貼上測資太慢導致的速度差異,會使用 ==輸出入轉向== 這個技術來輸入測資 (細節在後) 3. $O(n^2)$ 的方法一,明顯跑比較久! ![image](https://hackmd.io/_uploads/BkjQrzuQxg.png) :::success 結案 :+1:,特例的最壞狀況,確實會導致 `方法一` 速度太慢 ::: #### 細節: ==輸出入轉向== > 使用 `codeblocks` 或 `devc++` 等軟體,執行程式後都會生成一個 `.exe` 檔案, > 這個檔案就是你寫的程式的 ==執行檔==,先假設為 `code.exe` 好了 > 你可以在 命令提示自元 (CMD) 找到這個 `code.exe` 並執行他,後續的流程就跟平常用 `codeblock` 跳出的黑框框一樣 > 但我們要做到的是輸入轉向,不是從鍵盤輸入,而是從 `.txt` 檔案輸入 > 方法是 `code.exe < closest_pair_hardcase.txt` > 就能將文件的內容當作輸入,放到 code.exe 中 <div style="height: 500px;"></div>