本系列是這本算法教材的擴展:《算法競賽入門到進階》(京東 當當) 清華大學出版社
PDF下載地址:https://github.com/luoyongjun999/code 其中的“補充資料”
如有建議,請聯系:(1)QQ 群,567554289;(2)作者QQ,15512356
二分法和三分法是算法競賽中常見的算法思路,本文介紹了它們的理論背景、模板代碼、典型題目。
1. 二分法的理論背景
在《計算方法》教材中,關於非線性方程的求根問題,有一種是二分法。
方程求根是常見的數學問題,滿足方程:
\(f(x) = 0\) (1-1)
的數\(x'\)稱為方程(1-1)的根。
所謂非線性方程,是指\(f(x)\)中含有三角函數、指數函數或其他超越函數。這種方程,很難或者無法求得精確解。不過,在實際應用中,只要得到滿足一定精度要求的近似解就可以了,此時,需要考慮2個問題:
(1)根的存在性。用這個定理判定:設函數在閉區間\([a, b]\)上連續,且\(f(a) ∙ f(b) < 0\),則\(f(x) = 0\)存在根。
(2)求根。一般有兩種方法:搜索法、二分法。
搜索法:把區間\([a, b]\)分成\(n\)等份,每個子區間長度是∆x,計算點\(x_k = a + k∆x\), \((k=0,1,2,3,4,...,n)\)的函數值\(f(x_k)\),若\(f(x_k) = 0\),則是一個實根,若相鄰兩點滿足\(f(x_k) ∙ f(x_{k+1}) < 0\),則在\((x_k, x_{k+1})\)內至少有一個實根,可以取(\(x_k+ x_{k+1})/2\)為近似根。
二分法:如果確定\(f(x)\)在區間\([a, b]\)內連續,且\(f(a) ∙ f(b) < 0\),則至少有一個實根。二分法的操作,就是把\([a, b]\)逐次分半,檢查每次分半后區間兩端點函數值符號的變化,確定有根的區間。
什么情況下用二分?兩個條件:上下界\([a, b]\)確定、函數在\([a, b]\)內單調。

復雜度:經過n次二分后,區間會縮小到\((b - a)/2^n\)。給定\(a\)、\(b\)和精度要求\(ε\),可以算出二分次數\(n\),即滿足\((b - a)/2^n <ε\)。所以,二分法的復雜度是\(O(logn)\)的。例如,如果函數在區間\([0, 100000]\)內單調變化,要求根的精度是\(10^{-8}\),那么二分次數是44次。
二分非常高效。所以,如果問題是單調性的,且求解精確解的難度很高,可以考慮用二分法。
在算法競賽題目中,有兩種題型:整數二分、實數二分。整數域上的二分,注意終止邊界、左右區間的開閉情況,避免漏掉答案或者死循環。實數域上的二分,需要注意精度問題。
2. 整數二分模板
2.1 基本形式
先看一個簡單問題:在有序數列a[]中查找某個數x;如果數列中沒有x,找它的后繼。通過這個問題,給出二分法的基本代碼。
如果有x,找第一個x的位置;如果沒有x,找比x大的第一個數的位置。

示例:a[] = {-12,-6,-4,3,5,5,8,9},其中有n = 8個數,存儲在a[0]~a[7]。
1)查找x = -5,返回位置2,指向a[2] = -4;
2)查找x = 7,返回位置6,指向a[6] = 8;
3)特別地,如果x 大於最大的a[7] = 9,例如x = 12,返回位置8。由於不存在a[8],所以此時是越界的。
下面是模板代碼。
下面對上述代碼進行補充說明:
(1)代碼執行完畢后,left==right,兩者相等,即答案所處的位置。
(2)復雜度:每次把搜索的范圍縮小一半,總次數是log(n)。
(3)中間值mid
中間值寫成mid = left + (right-left)/2 或者mid = (left + right) >> 1都行 [參考李煜東《算法競賽進階指南》26頁,有mid = (left + right) >> 1的細節解釋]。不過,如果left + right很大,可能溢出,用前一種更好。
不能寫成 mid = (left + right)/2; 在有負數的情況下,會出錯。
(4)對比測試
bin_search()和STL的lower_bound()的功能是一樣的。下面的測試代碼,比較了bin_search()和lower_bound()的輸出,以此證明bin_search()的正確性。注意,當a[n-1]<key時,lower_bound()返回的也是n。
代碼執行以下步驟:
1)生成隨機數組a[];
2)用sort()排序;
3)生成一個隨機的x;
4)分別用bin_search()和lower_bound()在a[]中找x;
5)比較它們的返回值是否相同。
#include<bits/stdc++.h>
using namespace std;
#define MAX 100 //試試10000000
#define MIN -100
int a[MAX]; //如果MAX超過100萬,大數組a[MAX]最好定義為全局。
//大數組定義在全局的原因是:有的評測環境,棧空間很小,大數組定義在局部占用了棧空間導致爆棧。
//現在各大OJ和比賽都會設置編譯命令使棧空間等於內存大小,不會出現爆棧。
unsigned long ulrand(){ //生成一個大隨機數
return (
(((unsigned long)rand()<<24)& 0xFF000000ul)
|(((unsigned long)rand()<<12)& 0x00FFF000ul)
|(((unsigned long)rand()) & 0x00000FFFul));
}
int bin_search(int *a, int n, int x){ //a[0]~a[n-1]是有序的
int left = 0, right = n; //不是 n-1
while (left < right) {
int mid = left+(right-left)/2; //int mid = (left+ right)>>1;
if (a[mid] >= x) right = mid;
else left = mid + 1;
}
return left; //特殊情況:如果最后的a[n-1] < key,left = n
}
int main(){
int n = MAX;
srand(time(0));
while(1){
for(int i=0; i< n; i++) //產生[MIN, MAX]內的隨機數,有正有負
a[i] = ulrand() % (MAX-MIN + 1) + MIN;
sort(a, a + n ); //排序,a[0]~a[n-1]
int test = ulrand() % (MAX-MIN + 1) + MIN; //產生一個隨機的x
int ans = bin_search(a,n,test);
int pos = lower_bound(a,a+n,test)-a;
//比較bin_search()和lower_bound()的輸出是否一致:
if(ans == pos) cout << "!"; //正確
else { cout << "wrong"; break;} //有錯,退出
}
}
2.2 STL的lower_bound()和upper_bound()
如果只是簡單地找x或x附近的數,就用STL的lower_bound()和upper_bound()函數。有以下情況:
(1)查找第一個大於x的元素的位置:upper_bound()。代碼例如:
pos = upper_bound(a, a+n, test) - a;
(2)查找第一個等於或者大於x的元素:lower_bound()。
(3)查找第一個與x相等的元素:lower_bound()且 = x。
(4)查找最后一個與x相等的元素:upper_bound()的前一個且 = x。
(5)查找最后一個等於或者小於x的元素:upper_bound()的前一個。
(6)查找最后一個小於x的元素:lower_bound()的前一個。
(7)單調序列中數x的個數:upper_bound() - lower_bound()。
2.3 簡單例題
尋找指定和的整數對。這是一個非常直接的二分法問題。
∎問題描述
輸入n ( n≤100,000)個整數,找出其中的兩個數,它們之和等於整數m(假定肯定有解)。題中所有整數都能用int 表示。
∎題解
下面給出三種方法:
(1)暴力搜,用兩重循環,枚舉所有的取數方法,復雜度\(O(n^2)\)。超時。
(2)二分法。首先對數組從小到大排序,復雜度\(O(nlogn)\);然后,從頭到尾處理數組中的每個元素a[i],在a[i]后面的數中二分查找是否存在一個等於 m - a[i]的數,復雜度也是\(O(nlogn)\)。兩部分相加,總復雜度仍然是\(O(nlogn)\)。
(3)尺取法/雙指針/two pointers。對於這個特定問題,更好的、標准的算法是:首先對數組從小到大排序;然后,設置兩個變量L和R,分別指向頭和尾,L初值是0,R初值是n-1,檢查\(a[L]+a[R]\),如果大於m,就讓R減1,如果小於m,就讓L加1,直至\(a[L]+a[R] = m\)。排序復雜度\(O(nlogn)\),檢查的復雜度\(O(n)\),總復雜度\(O(nlogn)\)。檢查的代碼這樣寫:
void find_sum(int a[], int n, int m){
sort(a, a + n - 1); //先排序
int L = 0, R = n - 1; //L指向頭,R指向尾
while (L < R){
int sum = a[L] + a[R];
if (sum > m) R--;
if (sum < m) L++;
if (sum == m){
cout << a[L] << " " << a[R] << endl; //打印一種情況
L++; //可能有多種情況,繼續
}
}
}
3. 整數二分典型題目
上面給出的二分法代碼bin_search(),處理的是簡單的數組查找問題。從這個例子,我們能學習到二分法的思想。
在用二分法的典型題目中,主要是用二分法思想來進行判定。它的基本形式是:
while (left < right) {
int ans; //記錄答案
int mid = left+(right-left)/2; //二分
if (check(mid)){ //檢查條件,如果成立
ans = mid; //記錄答案
… //移動left(或right)
}
else … //移動right(或left)
}
所以,二分法的難點在於如何建模和check()條件,其中可能會套用其他算法或者數據結構。
二分法的典型應用有:最小化最大值、最大化最小值。
3.1 最大值最小化(最大值盡量小)
3.1.1序列划分問題
這是典型的最大值最小化問題。
∎題目描述
例如,有一個序列{2,2,3,4,5,1},將其划分成3個連續的子序列S(1)、S(2)、S(3),每個子序列最少有一個元素,要求使得每個子序列的和的最大值最小。
下面舉例2個分法:
分法1:S(1)、S(2)、S(3)分別是(2,2,3)、(4,5)、(1),子序列和分別是7、9、1,最大值是9;
分法2:(2,2,3)、(4)、(5,1),子序列和是7、4、6,最大值是7。
分法2更好。
∎題解
在一次划分中,考慮一個x,使x滿足:對任意的S(i),都有S(i)<=x,也就是說,x是所有S(i)中的最大值。題目需要求的就是找到這個最小的x。這就是最大值最小化。
如何找到這個x?從小到大一個個地試,就能找到那個最小的x。
簡單的辦法是:枚舉每一個x,用貪心法每次從左向右盡量多划分元素,S(i)不能超過x,划分的子序列個數不超過m個。這個方法雖然可行,但是枚舉所有的x太浪費時間了。
改進的辦法是:用二分法在[max, sum]中間查找滿足條件的x,其中max是序列中最大元素,sum是所有元素的和。
3.1.2 通往奧格瑞瑪的道路
“通往奧格瑞瑪的道路”,來源:https://www.luogu.org/problem/P1462
∎題目描述
給定無向圖,n個點,m條雙向邊,每個點有點權fi(這個點的過路費),有邊權ci(這條路的血量)。求起點1到終點N的所有可能路徑中,在總邊權(總血量)不超過給定的b的前提下,所經過的路徑中最大點權(這條路徑上過路費最大的那個點)的最小值是多少。
題目數據:n≤10000,m≤50000,fi,ci,B≤1e9。
∎題解
對點權fi進行二分,用dijkstra求最短路,檢驗總邊權是否小於b。二分法是最小化最大值問題。
這一題是二分法和最短路算法的簡單結合。
(1)對點權(過路費)二分。題目的要求是:從1到N有很多路徑,其中的一個可行路徑Pi,它有一個點的過路費最大,記為Fi;在所有可行路徑中,找到那個有最小F的路徑,輸出F。解題方案是:先對所有點的fi排序,然后用二分法,找符合要求的最小的fi。二分次數log(fi)=log(1e9) < 30。
(2)在檢查某個fi時,刪除所有大於fi的點,在剩下的點中,求1到N的最短路,看總邊權是否小於b,如果滿足,這個fi是合適的(如果最短路的邊權都大於b,那么其他路徑的總邊權就更大,肯定不符合要求)。一次Dijkstra求最短路,復雜度是O(mlogn)。
總復雜度滿足要求。
3.2 最小值最大化(最小值盡量大)
∎題目描述
“進擊的奶牛”,來源:https://www.luogu.org/problem/P1824
在一條很長的直線上,指定n個坐標點(x1, ..., xn)。有c頭牛,安排每頭牛站在其中一個點(牛棚)上。這些牛喜歡打架,所以盡量距離遠一些。問最近的兩頭牛之間距離的最大值可以是多少。
這個題目里,所有的牛棚兩兩之間的距離有個最小值,題目要求使得這個最小值最大化。
∎題解
(1)暴力法。從小到大枚舉最小距離的值dis,然后檢查,如果發現有一次不行,那么上次枚舉的就是最大值。如何檢查呢?用貪心法:第一頭牛放在x1,第二頭牛放在xj≥x1+dis的點xi,第三頭牛放在xk≥xj+dis的點xk,等等,如果在當前最小距離下,不能放c條牛,那么這個dis就不可取。復雜度O(nc)。
(2)二分。分析從小到大檢查dis的過程,發現可以用二分的方法找這個dis。這個dis符合二分法:它有上下邊界、它是單調遞增的。復雜度O(nlogn)。
#include<bits/stdc++.h>
using namespace std;
int n,c,x[100005];//牛棚數量,牛數量,牛棚坐標
bool check(int dis){ //當牛之間距離最小為dis時,檢查牛棚夠不夠
int cnt=1, place=0; //第1頭牛,放在第1個牛棚
for (int i = 1; i < n; ++i) //檢查后面每個牛棚
if (x[i] - x[place] >= dis){ //如果距離dis的位置有牛棚
cnt++; //又放了一頭牛
place = i; //更新上一頭牛的位置
}
if (cnt >= c) return true; //牛棚夠
else return false; //牛棚不夠
}
int main(){
scanf("%d%d",&n, &c);
for(int i=0;i<n;i++) scanf("%d",&x[i]);
sort(x,x+n); //對牛棚的坐標排序
int left=0, right=x[n-1]-x[0]; //R=1000000也行,因為是log(n)的,很快
//優化:把二分上限設置為1e9/c
int ans = 0;
while(left < right){
int mid = left + (right - left)/2; //二分
if(check(mid)){ //當牛之間距離最小為mid時,牛棚夠不夠?
ans = mid; //牛棚夠,先記錄mid
left = mid + 1; //擴大距離
}
else
right = mid; //牛棚不夠,縮小距離
}
cout << ans; //打印答案
return 0;
}
4. 實數二分
4.1 基本形式
實數域上的二分,比整數二分簡單。
const double eps =1e-7; //精度。如果下面用for,可以不要eps
while(right - left > eps){ //for(int i = 0; i<100; i++){
double mid = left+(right-left)/2;
if (check(mid)) right = mid; //判定,然后繼續二分
else left = mid;
}
其中,循環用2種方法都可以:
while(right - left > eps) { ... }
或者:
for(int i = 0; i < 100; i++) { ... }
如果用for循環,由於循環內用了二分,執行100次,相當於實現了 \(1/2^100\)的精度,一般比eps更精確。
for循環的100次,比while的循環次數要多。如果時間要求不是太苛刻,用for循環更簡便[參考李煜東《算法競賽進階指南》27頁的說明]。
4.2 實數二分例題
∎題目描述
“Pie”,題目來源:http://poj.org/problem?id=3122
主人過生日,m個人來慶生,有n塊半徑不同的圓形蛋糕,由m+1個人(加上主人)分,每人的蛋糕必須一樣重,而且是一整塊(不能是幾個蛋糕碎塊,也就是說,每個人的蛋糕都是從一塊圓蛋糕中切下來的完整一塊)。問每個人能分到的最大蛋糕是多大。
∎題解
最小值最大化問題。設每人能分到的蛋糕大小是x,用二分法枚舉x。
#include<stdio.h>
#include<math.h>
double PI = acos(-1.0); //3.141592653589793;
#define eps 1e-5
double area[10010];
int n,m;
bool check(double mid){
int sum = 0;
for(int i=0;i<n;i++) //把每個圓蛋糕都按大小mid分開。統計總數
sum += (int)(area[i] / mid);
if(sum >= m) return true; //最后看總數夠不夠m個
else return false;
}
int main(){
int T; scanf("%d",&T);
while(T--){
scanf("%d%d",&n,&m); m++;
double maxx = 0;
for(int i=0;i<n;i++){
int r; scanf("%d",&r);
area[i] = PI*r*r;
if(maxx < area[i]) maxx = area[i]; //最大的一塊蛋糕
}
double left = 0, right = maxx;
for(int i = 0; i<100; i++){
//while((right-left) > eps) { //for或者while都行
double mid = left+(right-left)/2;
if(check(mid)) left = mid; //每人能分到mid大小的蛋糕
else right = mid; //不夠分到mid大小的蛋糕
}
printf("%.4f\n",left); // 打印right也對
}
return 0;
}
5. 二分法習題
飢餓的奶牛 https://www.luogu.org/problem/P1868
尋找段落 https://www.luogu.org/problem/P1419
小車問題 https://www.luogu.org/problem/P1258
借教室 https://www.luogu.org/problem/P1083
跳石頭 https://www.luogu.org/problem/P2678
聰明的質監員 https://www.luogu.org/problem/P1314
分梨子 https://www.luogu.org/problem/P1493
第k大 http://acm.hdu.edu.cn/showproblem.php?pid=6231
6. 三分法求極值
6.1 原理
三分法求單峰(或者單谷)的極值,是二分法的一個簡單擴展。
單峰函數和單谷函數如下圖,函數f(x)在區間[l, r]內,只有一個極值v,在極值點兩邊,函數是單調變化的。以單峰函數為例,在v的左邊,函數是嚴格單調遞增的,在v右邊是嚴格單調遞減的。

下面的講解都以求單峰極值為例。
如何求單峰函數最大值的近似值?雖然不能直接用二分法,不過,只要稍微變形一下,就能用了。
在[l, r]上任取2個點,mid1和mid2,把函數分成三段。有以下情況:
(1)若f(mid1) < f(mid2),極值點v一定在mid1的右側。此時,mid1和mid2要么都在v的左側,要么分別在v的兩側。如下圖所示。

下一步,令l = mid1,區間從[l, r]縮小為[mid1, r],然后再繼續把它分成三段。
(2)同理,若f(mid1) > f(mid2),極值點v一定在mid2的左側。如下圖所示。下一步,令 r = mid2,區間從[l, r]縮小為[l, mid2]。

不斷縮小區間,就能使得區間[l, r]不斷逼近v,從而得到近似值。
如何取mid1和mid2?有2種基本方法:
(1)三等分:mid1和mid2為[l, r]的三等分點。那么區間每次可以減少三分之一。
(2)近似三等分:計算[l, r]中間點mid = (l + r) / 2,然讓mid1和mid2非常接近mid,例如mid1 = mid - eps,mid2 = mid + eps,其中eps是一個很小的值。那么區間每次可以減少接近一半。
方法(2)比方法(1)要稍微快一點。
(有網友說不要用方法(2):“因為在有些情況下這個 eps 過小可能導致這兩個算出來的相等,如果相等就有可能會判斷錯方向,所以其實不建議這么寫,log3 和 log2 本質上是一樣的。”)
注意:單峰函數的左右兩邊要嚴格單調,否則,可能在一邊有f(mid1) == f(mid2),導致無法判斷如何縮小區間。
6.2 實數三分
下面用一個模板題給出實數三分的兩種實現方法。
∎題目描述
“模板三分法”,來源:https://www.luogu.com.cn/problem/P3382
給出一個N次函數,保證在范圍[l, r]內存在一點x,使得[l, x]上單調增,[x, r]上單調減。試求出x的值。
∎題解
下面分別用前面提到的2種方法實現:(1)三等分;(2)近似三等分。
(1)三等分
#include<bits/stdc++.h>
using namespace std;
const double eps = 1e-6;
int n;
double a[15];
double f(double x){ //計算函數值
double s=0;
for(int i=n;i>=0;i--) //注意函數求值的寫法
s = s*x + a[i];
return s;
}
int main(){
double L,R;
scanf("%d%lf%lf",&n,&L,&R);
for(int i=n;i>=0;i--) scanf("%lf",&a[i]);
while(R-L > eps){ // for(int i = 0; i<100; i++){ //用for也行
double k =(R-L)/3.0;
double mid1 = L+k, mid2 = R-k;
if(f(mid1) > f(mid2))
R = mid2;
else L = mid1;
}
printf("%.5f\n",L);
return 0;
}
(2)近似三等分
#include<bits/stdc++.h>
using namespace std;
const double eps = 1e-6;
int n; double a[15];
double f(double x){
double s=0;
for(int i=n;i>=0;i--) s=s*x+a[i];
return s;
}
int main(){
double L,R;
scanf("%d%lf%lf",&n,&L,&R);
for(int i=n;i>=0;i--) scanf("%lf",&a[i]);
while(R-L > eps){ // for(int i = 0; i<100; i++){ //用for也行
double mid = L+(R-L)/2;
if(f(mid - eps) > f(mid))
R = mid;
else L = mid;
}
printf("%.5f\n",L);
return 0;
}
6.2.1 實數三分習題
(1)“三分求極值”,題目來源:http://hihocoder.com/problemset/problem/1142
∎題目描述:在直角坐標系中有一條拋物線y = ax^2 + bx + c和一個點P(x, y),求點P到拋物線的最短距離d。
∎題解:直接求距離很麻煩。觀察這一題的距離D,發現它滿足單谷函數的特征,用三分法很合適。
(2)三分套三分,是計算幾何的常見題型。
“Line belt”,題目來源:http://acm.hdu.edu.cn/showproblem.php?pid=3400
∎題目描述:給定兩條線段AB、CD ,一個人在AB上跑的時候速度是p,在CD上速度是q,在其他地方跑速度是r。問從A點到D點最少的時間。
∎題解:從A出發,先走到AB上一點X,然后走到CD上一點Y,最后到D。時間是:
time = |AX|/p + |XY|/r + |YD|/q
假設已經確定了X,那么目標就是在CD上找一點Y,使|XY|/r + |YD|/q最小,這是個單峰函數。三分套三分就可以了。
6.3 整數三分
整數三分的形式是:
while (left < right) {
int mid1 = left + (right - left)/3;
int mid2 = right- (right - left)/3;
if(check(mid1) > check(mid2))
… //移動right
else
… //移動left
}
下面是一個例題。
∎題目描述
“期末考試”,題目來源:https://www.lydsy.com/JudgeOnline/problem.php?id=4868
有n位同學,每位同學都參加了全部的m門課程的期末考試,都在焦急的等待成績的公布。第i位同學希望在第ti天或之前得知所有課程的成績。如果在第ti天,有至少一門課程的成績沒有公布,他就會等待最后公布成績的課程公布成績,每等待一天就會產生C不愉快度。對於第i門課程,按照原本的計划,會在第bi天公布成績。有如下兩種操作可以調整公布成績的時間:1.將負責課程X的部分老師調整到課程Y,調整之后公布課程X成績的時間推遲一天,公布課程Y成績的時間提前一天;每次操作產生A不愉快度。2.增加一部分老師負責學科Z,這將導致學科Z的出成績時間提前一天;每次操作產生B不愉快度。上面兩種操作中的參數X,Y,Z均可任意指定,每種操作均可以執行多次,每次執行時都可以重新指定參數。現在希望你通過合理的操作,使得最后總的不愉快度之和最小,輸出最小的不愉快度之和即可。
∎題解
不愉快度是一個下凹的函數,用三分法。代碼略。