這週要介紹的搜尋方法是以數列呈現
顧名思義,是將集合的所有子集挑出來,所有可能子集的集合又稱冪集合[1]
例如 的所有子集為
將輸出:
將輸出:
在許多場合,我們不一定只有 0 和 1 兩種佔位符 (placeholder),可能有三種甚至更多
所以遞迴法或許比二進制法還要更為泛用。
許多文章介紹二分搜沒交代好前提,是採用大量案例佐證二分搜的實作很正確。
我認為這樣不優,這裡會花較多的篇幅把一些約定俗成的事情交代清楚再給出實作
給定一個 長的單調[2]數列,找目標值(target)的位置
當目標有 1 個以上,這時有兩種位置: upper bound 與 lower bound
考慮數列 ,當目標為 :
0
, 1
3
, 4
, 5
, 6
, 7
, ..當然,通常會希望 upper bound 與 lower bound 越緊越好
所以上面拿的 upper bound 要是 3
、lower bound 要是 1
才好?
在繼續進到實作之前,先討論信仰
upper bound 與 lower bound 真的越緊越好嗎?
以上面例子,有沒有可能 lower bound 取 0
、upper bound 取 4
在應用中會比較易用?
給定長度 的數列
大部份習慣數數從 開始數,因為應用在數個數時,當數到最後一數,剛好就是要求的個數。
但根據皮亞諾公設對自然數的定義,數應該要以 為起點;甚至在許多程式語言實作中,陣列的 index 是從 0
開始[3]。
總之,對於這個數列,可以用整數子集 寫下他的 index 範圍
這會比符號 來的簡潔一些。
而且若是問 的長度為何? 只需計算
但問 ,就得計算
對所有自然數除以 [4] 的餘數收集起來,會有
表達這件事只要寫
有時會需要用到空區間這個狀態,這時可以
先前提及的,C++的 STL 中,迭代器是以左閉右開區間來實作的。
講這麼多左閉右開區間的好(?),回到信仰話題,
普遍實作中會認為 lower bound 為 1
、upper bound 為 4
會比較好。
(以後 lower bound & upper bound 若沒特別設定,都依此慣例)
若要搜尋的數不在數列中,那應該輸出哪個 index?
普遍實作會輸出當此數插入到這個數列中它最適合的位置
何謂最適合?就是要保持著數列仍然單調[5]。
對於數列的 index 區間 , lower & upper bound 都會落在 嗎?
欲搜尋的數如果大於整個數列,它最適合的位置就是 (落在區間外囉)
而對於所有可能輸出的 bound,可以用空區間表示:
也就是 [6]
雖然意義上都是一個空區間,但看符號還能看出 表達的 index 是啥
先簡單思考,用最直覺的枚舉方式
來實作找 lower bound 的演算法吧:
假設 就是 lower bound,那麼對於原區間 ,就是設法把 壓縮至
簡單的作法就是,一直遞增左界:
直到碰到
另一個從右界遞減的演算法:
兩個演算法都使 l
, r
相等且得到 與
回到小節標題,二分搜?這名字就是演算法的動機,將數列切成兩份以做到搜尋:
將上述兩種 linear search 演算法合併起來會得到
因為不知道 m
該遵從哪個演算法,就改成在區間中隨機挑了
這一步非常重要,先思考這樣寫真的正確嗎?
而 lower bound 普遍的二分搜實作,就僅把 m
改成均等的兩份:
m
其實就是 middle 的縮寫哦
而 upper bound 的二分搜實作也是類似的:
二分搜複雜度為 , 其中 為初始左界右界。
讀者們就跟著 lower bound 的發明過程,嘗試實作 upper bound 二分搜!
光只會使用 STL 中的std::lower_bound
, std::upper_bound
函數還不夠對付所有問題,因應不同場合常得親自設計 (e.g. 三分搜、隱式數列)
在第三週教材的練習中,這題的 small dataset 很輕易的就能用枚舉做出來,但對於 large dataset, 就不夠快了。
仔細想想,雖然對數列排序後不會使 枚舉算出的答案不同
但排序後,當不考慮乘積為 的情況下,兩數積 保證會落在 後面,其中
這樣想,二分搜就有武用之地了!
乘積 的情況比較特殊一點,但也不難處理:
此題其實還能讓複雜度從 降到 ,不過寫起來會稍微麻煩點。
簡單的,採用枚舉的方式去找出哪個"最大傷害強度"是可行的
把可行的"最大傷害強度"找出來,接著在其之中把最小值輸出就行了
首先做出一個可判定此"最大傷害強度"是否可行的函數:
接著枚舉一下:
可以發現到,第一個遇到可行的"最大傷害強度" (strength
) 就是題目要求的最終答案。
但可惜的是,這樣 複雜度明顯會 TLE
其中 來自於
r = maxn*maxn
研究一下 check
函數可知,因為每次把 strength
條大,會造成 cnt
越來越小,所以返回的 bool
值形成一個單調數列,
於是,可以使用二分搜去改進原本枚舉的做法:
複雜度改進成
在給定 長度序列 ,找到一個子序列,為嚴格遞增且長度最長。
則 LIS 為 或
建議複習第三週教材的 LIS 做法
貪心的看,當前 LIS 末項數字越小,那麼越有可能使得 LIS 繼續變長
例如 有 LIS ,但之後欲接 ,則只有 能接得
則定義狀態 表示前綴 中長度為 的 LIS 最小末項
例如 的 前綴為 ,則
求解 需要哪些狀態?
前綴相對於 前綴多了 ,則
而考慮讓 LIS 增長的話, 相對於 有
綜合上述,狀態轉移方程:
有可能無解, 或許找不到長度為 的 LIS
邊界:
有了 的所有解,從 LIS 長度 ,就能推得 LIS 為
由於狀態的解是 從小到大遞推得到,可用滾動陣列壓縮一個維度
但因為把前綴 這項資訊移除,所以要利用 f
紀錄 LIS
其中 p[j]
為 S[j]
在原本 序列中的位置
如第三週所教的操作,可將 LIS 利用 f
印出
等一等…
仔細觀察 的定義,會發現 是單調的!
於是每次要用 a[i]
解 s[j]
時,就使用二分搜!
複雜度為
UVa OJ 10131 Is Bigger Smarter?
UVa OJ 10534 Wavio Sequence
UVa OJ 437 The Tower of Babylon
CODEFORCES 1257E The Contest
Sprout OJ 48 二元搜尋樹
AIZU Online Judge 0524 星座探し
LeetCode 15 3Sum
CODEFORCES 1118D Coffee and Coursework
CODEFORCES 1111C Creative Snap
GCJ Kickstart Round G 2018 B Combining Classes: Small dataset
CODEFORCES 1263C Everyone is a Winner!
CODEFORCES 1077D Cutting Out