# 高三進階程式設計期末報告-最近點對解法研究及複雜度探討 ## 題目 給平片上相異$N$點$(x_1,y_1),(x_2,y_2),...,(x_N,y_n)$ 請計算最近點對的距離的平方 也就是找出$i,j$,滿足$1≤i<j≤N$,且$(x_i-x_j)^2+(y_i-y_j)^2$為最小 輸入說明: 第一行包含一個正整數$N$,代表平面上的點數。 接下來$N$行,每行包含兩整樹$x_i,y_i$代表第$i$個點的座標 $1≤N≤2×10^5$ $-10^9≤x_i,y_i≤10^9$ $(x_i,y_i)≠(x_j,y_j)≠j$,不存在兩點座標相同 輸出說明: 輸出正整數,代表最近點對的距離的平方 範例輸入 4 1 3 7 5 -4 9 2 -5 範例輸出 40 ## 算法一 暴力枚舉 ### 想法 共n個點 枚舉$n(n-1)/2$組點對的最短距離,取最小值就是答案 ### 複雜度$O(n^2)$ 因為要枚舉$n(n-1)/2$組,故為$O(n^2)$ ## 算法二 掃描線算法 有點像枚舉算法 枚舉前先將座標以x排序 邊向左枚舉並維護當前最小距離(d) 且當計算的兩點的x座標距離大於d就break換下一個點枚舉(若x座標距離大於最小值則不可能為新的最近點對) 可以想像成向右擴展d的範圍內找點枚舉,且當d變動擴展範圍也會跟著變動 ```cpp= #include<bits/stdc++.h> using namespace std; #define int long long #define x first #define y second int dis(pair<int,int> a, pair<int,int> b){ return ((a.x - b.x)*(a.x - b.x) + (a.y - b.y)*(a.y - b.y)); } signed main(){ int n; while(cin >> n){ vector<pair<int,int> > p; for(int i = 0; i < n; i++){ int x,y; cin >> x >> y; p.push_back(make_pair(x,y)); } sort(p.begin(),p.end()); int d = 9223372036854775807; for(int i = 0; i < n; i++){ for(int j = i+1; j < n; j++){ if((p[i].x - p[j].x)*(p[i].x - p[j].x) > d)break; d = min(d, dis(p[i],p[j])); } } cout << d << '\n'; } } ``` ### 時間複雜度 $O(nlogn) 最差O(n^2)$ 有用到sort所以只少有$O(nlogn)$ 最差情況$O(n^2)$,全部點都在同一條平行y軸的直線上(點的x都一樣)時就無法break掉任何點對 不過一般情況下還是蠻快的 ## 算法三 掃描線算法(加上set對y座標做二分搜) ### 想法 做法和上一個掃描線作法很像,只是sort完後不是直接枚舉 而是將向左邊和上下延伸d的範圍內的點都放進set(以y座標比較) 左界維護方式:每次for迴圈都檢查並erase 上下界維護方式:用二分搜 然後二分搜找到區域內的上下界後再將此區域的點進行枚舉 ```cpp #include<bits/stdc++.h> using namespace std; #define int long long #define x first #define y second int dis(pair<int,int> a, pair<int,int> b){ return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y); } signed main(){ios::sync_with_stdio(0);cin.tie(0); int n; while(cin >> n){ vector<pair<int,int> > p; for(int i = 0; i < n; i++){ int x, y;cin >> x >> y; p.push_back(make_pair(x,y)); } sort(p.begin(),p.end()); set<pair<int,int> > st; st.insert(make_pair(p[0].y,p[0].x)); int d = 5e18; for(int i = 1, l = 0; i < n;i++){ while(l < i && (p[i].x - p[l].x)*(p[i].x - p[l].x) > d){ st.erase(make_pair(p[l].y,p[l].x)); l++; } auto itdown = st.lower_bound(make_pair(p[i].y - d,0)); auto itup = st.upper_bound(make_pair(p[i].y + d,0)); for(auto it = itdown; it != itup; it++){ d = min(d, dis(p[i],make_pair(it->y,it->x)));//因為存進set時是y放前面所以it->y才是x } st.insert(make_pair(p[i].y,p[i].x)); } cout << d << '\n'; } } ``` ### 時間複雜度 $O(nlogn)$ 使用set後最差情況也被優化成$O(nlogn)$了 但這個算法和下面的要介紹的分治法比起來常數太大了 如果題目設計得比較好,是有可能被卡掉的 ## 算法四 分治法 可以將平面分成兩半,以簡化問題 最近點對可能會出現在三個情況 1左半部 2右半部 3兩點橫跨左右半部 所以這題的關鍵就是3要怎麼算 我們可以用類似掃描線的方法處理狀況三 1和2情況會有一組最小距離(d) 儲存從中間向左右展開d的空間內的所有點 接者再對這些點的y排序,並對這些點枚舉,並且只要超過y座標的差超過d就break ### 實作 ```cpp #include<bits/stdc++.h> using namespace std; #define int long long #define x first #define y second int n; vector<pair<int,int> > p; vector<pair<int ,int> > tmp; bool cmp(pair<int,int> a, pair<int,int> b){ return a.y < b.y; } int dis(pair<int,int> a, pair<int,int> b){ return ((a.x - b.x)*(a.x - b.x) + (a.y - b.y)*(a.y - b.y)); } #define m (l + r) / 2 int cqd(int l, int r){ if(r - l == 1)return 9223372036854775807; int d = min(cqd(l,m),cqd(m,r)); tmp.clear(); for(int i = l; i < r; i++){ if((p[i].x - p[m].x)*(p[i].x - p[m].x) < d){ tmp.push_back(p[i]); } } sort(tmp.begin(),tmp.end(),cmp); for(int i = 0; i < tmp.size(); i++){ for(int j = i + 1; j < tmp.size(); j++){ if((tmp[i].y - tmp[j].y)*(tmp[i].y - tmp[j].y)) > d){ break; } d = min(d, dis(tmp[i],tmp[j])); } } return d; } signed main(){ while(cin >> n){ p.clear(); tmp.clear(); int x, y; for(int i = 0; i < n; i++){ cin >> x >> y; p.push_back(make_pair(x,y)); } sort(p.begin(),p.end()); cout << cqd(0,n) << '\n'; } } ``` ### 複雜度 $O(nlog^2n)$ 分治本身複雜度是$O(nlogn)$ 每次還要sort $O(logn)$ 而第三的狀況的處理是常數 故此算法複雜度為 $O(nlog^2n)$ :::info :::spoiler 狀況3為常數的證明 在蒐集完左右展開的所有點,並對y排序後,開始枚舉從下向上枚舉。 枚舉中有一個很重要的動作,y座標的差超過d後就break 所以事實上需要枚舉的點就只有點$P$向上、左、右展開d的範圍 這個範圍是$d*2d$的矩形。先假設P的x在正中心。 將矩形分成兩個$d*d$的正方形,在這個正方形內所有點都要保持距離大於d的性質(因為來自同一邊遞迴) 再將這個正方形切成四個$d/2 * d/2$的正方形,每個小正方形內都只能有1個點(因為小正方形對角線長d/sqrt(2),所以小正方形內任意兩點都小於d) 再算上p自己,最多就只能有7個點 那如果P不再中心呢 那就可能會發生小正方形內有兩個點的狀況,但就只有兩個小正方形有機會,就是覆蓋中線的那兩個小正方形。 故最多就只能有9個點 ::: ## 算法五 隨機算法 ### 算法 1.將全部點打亂 2.將d(當前最短距離)初始為$P_1\&P_2$的距離 3.已邊長為d/2畫網格 4.將一個一個點加入網格,並檢查新加入的點能不能更新最近點對,若成功更新最近點對,則回到第3步,重新來(前面加過的點只要重新放入網格就好,不用重新檢查了) 檢查方法如下 檢查當前網格(新加入點的所在網格)向上下左右個展開兩格的範圍內能不能更新最近點對(如圖) ![最近點對(randan)).drawio](https://hackmd.io/_uploads/B1gHV13Up.png) 只要超過這個5*5範圍外的點就不可能能更新最近點對 ### 實作細節 1.利用$c++$除法取整的特性,直接將座標除以$d/2$,不用真的開空間存 2.在檢查$5*5$的空間時,可以用$unorder\_map$或$unorder\_set$的$find$功能 :::warning $underder$的有用到$hash$,所以如果裡面有包$pair$,$C++$內建的$hash$會爛掉,要自己寫一個,或自己定義 覺得麻煩可以將點設成1e9*x+y(要注意題目最大點的範圍) 用這個方法要將全部點平移到第一象限 ::: ### 實作 ```cpp #include<bits/stdc++.h> using namespace std; #define x first #define y second #define int long long int n; vector<pair<int,int> > p; struct pair_hash { template <class T1, class T2> std::size_t operator () (const std::pair<T1, T2>& p) const { auto h1 = std::hash<T1>{}(p.first); auto h2 = std::hash<T2>{}(p.second); return h1 ^ h2; } }; int dx[25] = {-2,-2,-2,-2,-2,-1,-1,-1,-1,-1,0,0,0,0,0,1,1,1,1,1,2,2,2,2,2}; int dy[25] = {-2,-1,0,1,2,-2,-1,0,1,2,-2,-1,0,1,2,-2,-1,0,1,2,-2,-1,0,1,2}; int dis(pair<int,int> a, pair<int,int> b){ return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y); } signed main(){ while(cin >> n){ p.clear(); for(int i = 0; i < n; i++){ int x, y;cin >> x >> y; p.push_back(make_pair(x, y)); } shuffle(p.begin(),p.end(),mt19937(random_device()())); int d = dis(p[0],p[1]); long double gd = sqrt(d) / 2; unordered_map<pair<int,int>, pair<int,int> ,pair_hash> st; st.insert(make_pair(make_pair(p[0].x/gd,p[0].y/gd) ,p[0])); st.insert(make_pair(make_pair(p[1].x/gd,p[1].y/gd) ,p[1])); for(int i = 2; i < n; i++){ bool flag = 0; int nx = p[i].x/gd; int ny = p[i].y/gd; for(int j = 0; j < 25; j++){ auto it = st.find(make_pair(nx+dx[j],ny+dy[j])); if(it != st.end()){ if(dis(p[i],it->y) < d){ d = dis(p[i],it->y); gd = sqrt(d) / 2; flag = 1; } } } if(flag){ st.clear(); for(int j = 0; j <= i; j++){ st.insert(make_pair(make_pair(p[j].x/gd,p[j].y/gd) ,p[j])); } } else{ st.insert(make_pair(make_pair(p[i].x/gd,p[i].y/gd) ,p[i])); } } cout << d << '\n'; } } ``` ### 複雜度 期望$O(n)$ 這個算法會影響時間複雜度的就是更新最近點對,而什麼時候更新是隨機的,所以只能算期望複雜度 再加入第i個點時更新的機率為$\frac{2}{i}$(只有一組點為最近點對,且兩點順序可以交換) 且當第i個成功組成新的最近點對,就要花$O(i)$的時間更新網格 故每新增一個點的期望複雜度為$O(i)*\frac{2}{i}$為常數,故檢查n個點的總複雜度期望為$O(n)$ 若運氣非常不好,點shuffle成每次都要更新,就會變為$O(n^2)$