• 沒有找到結果。

概論、

N/A
N/A
Protected

Academic year: 2021

Share "概論、"

Copied!
26
0
0

加載中.... (立即查看全文)

全文

(1)

概論、C++ 技巧以及簡單演算法技巧

hansonyu123

2016 年 9 月 7 日、12 日

1 接下來你會遇到的事

1.1 主要比賽們

日期 項目 地點 概述

9/6 校內能力初賽 建中 考完了 (?

10/4 校內能力複賽 建中 選出約 12 個校隊代表參加北市能力競賽。

11 月 北市賽 臺北某學校 選出 10 人參加全國賽。

12 月 全國賽 師大 選出 10 人進入 TOI 一階。

2 月 校隊補選 建中 選出遞補已進入 TOI 一階的校隊人選,與其他 校隊一同參加 TOI 入營考。

3 月 TOI 入營考 師大 選出另外 20 人進入 TOI 一階。

3/4 月 TOI 一階 師大(住宿) 14 天中有兩次考試,在 30 人中選出 12 人進入 TOI 二階(並獲得參加 APIO 與推薦之資格)。 4 月 TOI 二階 師大(住宿) 同上,由 12 人選出 4 人代表臺灣參加 IOI。

5 月 APIO 師大 由進入 TOI 二階的人參加,沒什麼用處 (?

7/28 IOI 2017 伊朗德黑蘭 為國爭光拿獎牌。

1.2 次要比賽們

日期 項目 地點 概述

10/11 月 北市軟體競賽 臺北某學校 初賽筆試、決賽上機。

11/12 月 NPSC 臺大 同校三人組隊報名。有初賽、決賽,每校只有 三隊可以進決賽。聽說獎品不錯。

I

(2)

1.3 各種 OJ 們

簡稱 網址 概述

TIOJ tioj.infor.org

建中的 OJ,名字採用遞迴縮寫,相信大家比賽的時 候都用過。聽說歷史悠久,有很多不錯的題目,但 爛題也不少。

UVa uva.onlinejudge.org (英文)可說是史上第一個 OJ,1997 年就開放了,

題目數量極多。可以反覆練習相關算法。

ACM-ICPC icpcarchive.ecs.baylor.edu (英文)某個大學程式設計競賽的題目們,據說教 授喜歡從這裡找題目。

POI main.edu.pl/en (英文、波蘭文)波蘭資奧的網站,裡面有蠻多不

錯的題目,只是這裡不能用 C++11。

ZJ zerojudge.tw

在眾多水題中夾雜著幾題難題。feedback 非常好心,

有移植一些 UVa 還有入營考的題目。考試前刷水題 可以增加自信 (?

CF codeforces.com

俄羅斯的程式設計競賽平臺,隔一段時間就有比賽

(通常一週 1~2 次),但是通常會在臺灣時間半夜。

如果作息調整得來的話可以參加比賽,順便爬積分

(世界排名)。不然也可以當作一般的 OJ,而且看得 到別人的 code,可以觀摩別人是怎麼寫程式的。

1.4 演算法線上資源

名稱 網址 概述

演算法筆記 www.csie.ntnu.edu.tw/~u91029

內容超多樣豐富的演算法專門網站。不盡 然和競賽相關,也有一些些錯誤,但是由 於各種經典問題幾乎都有介紹到,依然是 個非常好用的參考網站。

cplusplus C++ reference

www.cplusplus.com/reference en.cppreference.com/w/cpp

想 要 對 C++ 內 建 程 式 庫 等 有 完 整 的 了 解, 這 兩 個 都 是 值 得 參 考 的 網 站。C++

reference 比較完整,但稍微難懂一點。也 可以搭配本講義後面的章節服用。

資訊之芽 www.csie.ntu.edu.tw/~sprout/algo2016

這是一個每年二到五月每週六上課的課 程,但是它的講義、課堂 ppt、回家作業 等東西都會放在網路上,也有其中幾年的 教學影片,課講得還算不錯,可以聽聽。

II

(3)

1.5 演算法書本資源

名稱 ISBN 概述

Introduction to Algorithms 978-0-262-03384-8

大 學 演 算 法 課 程 必 備 書 籍, 選 訓 也 會 發。偏重於嚴謹的論證,看這本書需要 有一定的數學程度。有例題可以練習。

提升程式設計的資料結構力 978-986-276-679-8 這次上課的參考教材,但是內容有些參 差不齊,應該只會偶爾提到。

2 什麼是複雜度

2.1 O-notation

  在介紹複雜度之前,我們得先瞭解複雜度的記法:O-notation。如果我們說 f (x) = O(g(x)) as x→ ∞

就代表存在常數 c 以及 x0,使得|f(x)| ≤ c|g(x)| ∀x ≥ x0。用好懂一點的語言來講,就是 g(x) 趨近無窮的速度不比 f (x) 來得慢。

  舉例來說,x = O(x2), 2x = O(123456x + 7), x213 = O(2x)。

  我們可以注意到 O-notation 代表的是一個上界,所以在一般的演算法分析上,為了使 其簡單易懂,通常會依循三個原則:用盡可能小的上界、忽略常數、忽略成長較慢的項。

例如,雖然 7x2+ 15x 可以是 O(x3), O(x2+ x), O(7x2),但是我們通常會寫它是 O(x2)。

2.2 時間複雜度

  所謂的時間複雜度,就是在估計一個演算法運行所需時間的一個函數。通常,我們會 以「進行基本運算的次數」來估計。以 C++ 為例,加減乘除、取餘數、位元運算、設值、

邏輯運算、比大小等等的都算是一個「基本運算」。因為基本運算都可以在一個固定的時 間內完成,所以「進行基本運算的次數」和「演算法所需時間」只有常數的差別。  然 而實際計算進行運算的次數不太切實際,所以我們通常都只關心它的成長速度 ── 也就是 使用 O-notation。注意我們在 O-notation 中忽略了常數,是因為常數在通常情況差異不大,

並不對運算速度有太重要的影響(儘管偶有例外)。

  只關心它的成長速度也是有原因的。比如說算法 A 的時間複雜度為 A(n) = 1000n3 = O(n3),算法 B 的時間複雜度為 B(n) = n4 = O(n4)。雖然在 n 很小(比如是 1)的時候,

算法 A 比算法 B 還要慢得許多,但是當 n 逐漸變大時,算法 A 所需的時間開始愈來愈靠 近算法 B,最後甚至比算法 B 還要來得少的許多。比如說在 n = 106時,算法 A 所需的時

III

(4)

間已經是算法 B 的千分之一了。而通常我們在意的是,在面對海量的輸入的時候,哪個演 算法能有較好的表現,這時算法 A 就脫穎而出了。

  再舉一個例子:

Algorithm 1: Insertion sort

1 function insertion_sort(array, length) 2 for i = 1 to length(A) - 1

3 x = A[i]

4 j = i

5 while j > 0 and A[j] > x

6 A[j] = A[j - 1]

7 j = j - 1

8 end while

9 A[j] = x

10 end for

11 end function

  這是典型插入排序(insertion sort)的演算法。如果此程序獲得的數列(長度為 n)一 開始已是由小到大排序好的,那麼只會進行 n− 1 次的比較,因此時間複雜度為 O(n)。然 而若獲得的數列一開始是由大排到小,那麼需要進行 n(n− 1)/2 次比較,時間複雜度為 O(n2)。可見就算輸入大小相同,時間複雜度也不一定相同。然而我們在意的是在一定的 時間內,演算法是否一定可以執行完畢,所以通常我們需要考慮的是最糟的情況(Worst case)。因此,我們將插入排序的(最糟)複雜度視為 O(n2)。

  知道如何估計時間複雜度後,要如何以此來估計一個演算法能不能在時限內執行 完畢呢?如果你是 C 或 C++ 的使用者,你可以假設 1 秒可以進行 108 次運算。不過在常 數很大或很小的時候,這樣的估計需要一點點修正,修正的依據就靠經驗啦。

2.3 空間複雜度

  和時間複雜度類似,可用來估計一個演算法需要多少空間。只要知道基本型別佔的空 間是多少,估計所需要的空間並不困難。除非題目有特別卡記憶體,不然空間通常不很重 要。

2.4 題外話:均攤分析以及隨機演算法

  前面的時間複雜度在一些場合下並不合適,取而代之的是其他定義的複雜度。

  第一個例子是均攤複雜度。試想一個演算法,在通常的時候時間複雜度是 O(1),不

IV

(5)

過每執行 O(n) 次,就恰有一次的時間複雜度是 O(n)。若使用前面的定義,這樣的演算法 的複雜度應該是 O(n),但若我們執行這個演算法夠多次,比如說執行了 O(n) 次,那麼總 複雜度也只有 O(n)。如果我們將所需的時間平均地分攤給每次執行,那麼每次執行所需 要的時間便只有 O(1)。這就是均攤分析以及均攤複雜度的意義。這樣的演算法並不少見,

比如說 vector 的 push_back 和並查集(disjoint set)都是。

  第二個例子是平均複雜度。有些演算法在接受不同的輸入時會有不一樣的表現,而每 次的表現好壞都會不同。比如說,最好可能只需要 O(n),然而最糟需要 O(n2)。這種情 況,我們比較在意的是假設輸入隨機,所需時間的期望值是多少,也就是平均複雜度。算 法就跟一般算期望值是一樣的。一個常見的例子是 treap 的插入,最糟情況下時間複雜度 是 O(n),然而平均複雜度只有 O(log n)。如果使用好的 hash function,雜湊也會有類似的 現象。這些東西未來有機會就會提到。

  如果沒辦法一下子瞭解這節的東西也沒關係,未來遇到就會知道了。

3 好用的 C++ 內建資料結構

  相信大家都是 C 或者 C++ 的使用者。

  如果你是 C 的使用者,強烈建議你現在開始使用 C++。語法相容,但又有強力的 library,是在競賽時間壓力下不可或缺的強力工具。

  如果你是 C++ 的使用者,強烈建議你把 C++ 內建又好用的東西摸熟。這樣就可以省 去許多功夫,開心使用前人為你寫好的東西了。

3.1 什麼是資料結構

  就是字面上的意思:拿來儲存資料的結構。

  一個最簡單的例子就是陣列,它直接把一堆同樣型別的資料排成一排好好管理。但除 了陣列之外,還有許多資料結構(有些甚至不是內建的)是需要瞭解的。以下就講些簡單 常用的內建資料結構吧。

  這些資料結構在「標準模板庫」(Standard Template Library,簡稱 STL)之中,是一個 C++ 的程式庫。注意,這個程式庫的所有東西都在 namespace std 底下。

3.2 型別模板(C++)

  不過在繼續之前,有個重要的 C++ 觀念要講。

  試想有一個作為容器用的型別 C,可以容納一些型別是給定的型別 T 的東西。那你 在宣告這樣的一個變數時,理論上你應該告訴編譯器這個變數容納的型別是哪個型別。然 而 C 語言中並沒有這樣的語法,所以在這樣的需求下模板(template)就在 C++ 誕生了。

  型別模板的功用就是生出一堆新的型別。比方說剛剛的 C 好了,存放 int 型別的 C 和

V

(6)

存放 double 型別的 C,型別應該要是不同的。所以在宣告一個變數時,不是寫「C (變數 名)」,而是寫「C<(int 或 double)> (變數名)」以指定這個變數的型別要是存放 int 的那種型 別,還是存放 double 的那種型別。

  有些型別模板的參數不只一個,寫好寫滿就對了(例:priority_queue<A, B, C> 就需要 三個型別)。如果沒有寫好寫滿,會自動採用預設的型別(前提是要有預設的)。

  想瞭解更多的話,關鍵字搜「模板」跟「C++」吧。

  註:C++11 開始,模板的參數數是可變的。

3.3 迭代器(Iterator)

  設想有個容器 C,裡面已經裝一些東西了。我該如何遍歷 C 中的所有元素呢?要知 道 C 可能長得不像陣列,沒有「下標」這種東西。為了解決這個問題,C++ STL 為每個容 器提供一個成員型別,叫做「迭代器」。

  你可以把迭代器想像成是指標(事實上,指標也算一種迭代器)。如果你今天有一個 迭代器 i,存取 i 指向的內容的方法,跟指標一樣,是在前面加個星號(*i)。而迭代器分 成三種,取決於迭代器能進行的運算,由功能強到弱排序如下:

1. 隨機存取(Random Access)迭代器:這類的迭代器能夠和整數做加減法,加 s 代表從 這項開始往後數 s 項,減 s 代表往前數 s 項。(當然,遞增、遞減運算也沒有問題。)

你可以把指標當作這種迭代器。

2. 雙向(Bidirectional)迭代器:這類的迭代器只能做遞增(++)和遞減(--)運算,分別 代表後一項和前一項。

3. 單向(Forward)迭代器:這類的迭代器只能做遞增(++)的運算,代表後一項。

  而按照迭代器使用的方法,分成兩種:

1. 輸入(Input)迭代器:當你只有要讀取迭代器指向的內容時,這時迭代器當作輸入迭代 器使用。所有的迭代器都可以當作輸入迭代器。

2. 輸出(Output)迭代器:當你要直接更改迭代器指向的內容時,這時迭代器當作輸出迭 代器使用。除了常數(const)迭代器(也就是規定不能更動迭代器指向的內容)以外,

所有的迭代器都可以當作輸出迭代器。

   C++ 內建的迭代器都很和藹,只要是可以做的運算,複雜度都是 O(1)。

  為了滿足人們的需求,C++ 內建的容器通常有兩種迭代器:正常的迭代器以及逆向 迭代器。假如容器的型別是 C,因為迭代器是原本型別的成員型別,宣告時名稱分別是 C::iterator 和 C::reverse_iterator。前者會從前迭代到後,後者會從後迭代到前。另外,每 個容器 c 的兩種迭代器各有兩個迭代器代表頭尾,分別是 c.begin()、c.end() 和 c.rbegin、

c.rend()。c.begin() 指向 c 的第一項,而 c.end() 指向 c 的最後一項的後一項。也就是說,

*c.end()、*c.rend() 是不存在的,如果你這樣寫會造成不可預期的後果。

VI

(7)

3.4 vector

   vector 位於標頭檔 <vector> 裡。vector 可被視為是動態陣列的實現。通常的陣列,長 度在宣告時就確定了,然而 vector 可以做到長度隨意伸縮。

  列舉一下常用語法(假設變數名為 v):

1.(建構式)vector<T> v(size_type a, const T& b):一開始這個 v 會被 b 填滿,共填 a 個。如 果只有指定 a,那麼 b 是 T 的預設值;如果什麼都沒指定,v 會是一個空的 vector。

複雜度 O(a)。

2. v[i]:v 中的第 i 項,當平常陣列用就好。如果 i 的範圍不在 [0, size),會發生無法預期 的結果(undefined behavior)。複雜度 O(1)。

3. v.size():這個函式會回傳 v 目前的長度。複雜度 O(1)。

4. v.push_back(T a):在 v 的尾端加一個 a。均攤複雜度 O(1)。

5. v.pop_back():刪除 v 的最末項。如果 v 是空的,會發生無法預期的結果。複雜度 O(1)。

6. v.empty():回傳一個 bool,代表 v 是否是空的。複雜度 O(1)。

7. v.clear():清空 v。複雜度 O(size)。原本 v 的空間會被保留,不會釋放掉。

8. v.resize(size_type a, const T& b):強制將 v 的長度變為 a。如果比原本短,則將 v 原本 的末段捨去,複雜度 O(D(size− a)),D 是解構 T 的時間。如果比原本長,在 v 的 後面加 b 直到足夠為止(如果只有指定 a,那麼 b 是 T 的預設值),通常複雜度 O(C(a− size)),但如果需重新配置記憶體(reallocate),複雜度 O(Ca),其中 C 是建 構(複製)T 的時間。

9. v.reserve(size_type n):預留放至少 n 個 T 的空間。如果需重新配置記憶體,複雜度 O(size)。如果 n < size,這個函數不造成任何影響。

   vector 的迭代器屬於隨機存取迭代器。

  比較需要講的是 vector 重新配置記憶體的耗時較長,所以如果能預先知道記憶體最多 需要多少,就在一開始建構的時候開滿或者先 reserve 吧!(儘管對時間只有常數的影響)

  另外,vector<bool> 有特化成一個 bool 佔的空間只有 1 bit,是 bool[] 的 1/8。

3.5 string

   string 位於標頭檔 <string> 裡,等價於 basic_string<char>(如果不知道這個,可能會 看不懂編譯訊息)。string 的用法很像 vector<char>,但因字串太常使用了,所以有經過一 些優化。除此之外,還有一些好用的東西(假設變數名為 s):

VII

(8)

1. s = t:如果 t 是一個 string 或是 C 式字串,s 會變得跟 t 一樣。複雜度不明,但通常是 O(sizes+ sizet)。

2. s += t:如果 t 是一個 string 或是 C 式字串,在 s 的尾端加上 t。複雜度通常是 O(sizes+ sizet)。

3. s.c_str():這個函式會回傳跟 s 一樣的 C 式字串。在 C++11 中保證複雜度為 O(1)。

4. s (比 較 大 小 或 相 等 的 符 號)t: 回 傳 比 較 s 跟 t 字 典 序 的 結 果。 通 常 複 雜 度 是 O(max(sizes, sizet))。

5. cin >> s:輸入字串至 s,直到讀到空白字元。

6. cout << s:輸出字串 s。

7. getline(cin, s, char c):輸入字串至 s,直到讀到字元 c。未指定時,c 是換行符號('\n')。    string 的迭代器屬於隨機存取迭代器。

    除 了 列 舉 的 之 外,vector 有 的 string 都 有。 除 此 之 外,s.size() 有 個 同 義 的 函 式 s.length(),可能是怕人打錯才多加了這個函式吧。

  順帶一提,關於 string 是不是「容器」其實也有些爭議,但它有大部分容器的性質,

所以在此仍然將其歸類為容器。

3.6 deque

   deque 位於標頭檔 <deque> 裡。deque 可以視為可以在最前面加東西、刪東西的 vector,除此之外它就是 vector 了。

  假設變數名是 d,想要移除第一項,就用 d.pop_front()。想要在前面加一個東西 a,就 用 d.push_front(a)。這兩個函式不會使迭代器失效,但會改變 deque 的下標。

  雖說功能比 vector 強,但代價是時間和空間幾乎翻倍,所以沒事別用 deque。

3.7 list

   list 位於標頭檔 <list> 裡。list 是個「雙向鏈結(doubly linked)結構」,也就是說對於 list 中的每一項,都可以 O(1) 知道它的前一項和後一項。如此做的好處是,如果我要一次 性加入一堆東西,只需要 O(1) 的代價,比 vector 優。然而代價是,存取第 i 項的複雜度 是 O(i),因此沒有內建的下標運算。同樣列舉一下常用語法(假設變數名為 s):

1.(建構式)list<T> s(size_type a, const T& b):同 vector。

2. s.push_front(T a)、s.push_back(T a)、s.pop_front()、s.pop_back():同 deque。

3. s.size():回傳 s 中有幾項。相當需要注意的是 C++98 中這個函式的複雜度只有保證 O(size),C++11 則保證 O(1)。

VIII

(9)

4. s.empty():回傳一個 bool,代表 v 是否是空的。複雜度 O(1)。

5. s.insert(iterator p, T a):在 p 指的那一項前面插入一個 a 並回傳一個指向 a 的迭代器。複 雜度 O(1)。

6. s.insert(iterator p, size_type n, T a):在 p 指的那一項前面插入 n 個 a。複雜度 O(n)。

7. s.erase(iterator p):把 p 指的那項刪掉並回傳指向之後那項的迭代器。注意刪完之後 p 就 失效了。複雜度 O(1)。

8. s.erase(iterator first, iterator last):把 [first,last) 指到的東西全砍光光,回傳 last。複雜度 和砍掉的東西個數呈線性關係。

9. s.splice(iterator p, list& x, iterator first, iterator last):first 和 last 是 x 的迭代器。這個函式 會把 [first,last) 指到的東西從 x 中移除並加到 p 指的那項前面。注意到 x 會因為這個 函式而改變。如果沒有指定 last,那只將 first 從 x 刪去並加入 s。如果 first 和 last 都沒 指定,那會將 x 中所有東西移到 s 中使 x 變為空的。複雜度是轉移元素個數的線性。

   list 的迭代器屬於雙向迭代器。

  可以看出 list 最大的功能是可以用 O(1) 的代價進行一些別的容器做不到的事(insert、

erase)。然而 list 最大的問題是所佔空間過於龐大,而且實用性低。C++11 中多了一 個 forward_list 以改善空間過大的問題,代價是迭代器變成單向迭代器(也因此沒有 reverse_iterator)。

3.8 Container adaptor

  它的翻譯好像叫什麼「適配器」的,滿難聽的……  這個東西主要是接收一個容器

(前面講的那些),然後取其精華 (?) 改造它,成為新的容器。因此,Container adaptor 本身 也是型別模板。

  不過在取其精華的過程中,捨棄了一些東西。注意適配器通常不會有迭代器。

  接下來介紹三個常見的 container adaptor。

3.9 stack

   stack 位於標頭檔 <stack> 裡。可以把它想像成一疊書,每次可以放一本書在最上面,

也可以從最上面拿一本書走。簡單來說就是秉持著「後進先出」(LIFO)的精神。

   stack 這個模板需要的型別參數有兩個:T 和 C,其中 T 是內容物的型別,而 C 是 採用的容器。為了方便改造,stack 對 C 有些要求:要有 empty、size、back、push_back、

pop_back 這些函式,而在內建的容器中能夠勝任這角色的有 vector、deque 和 list。

   stack 常用的語法列舉如下(假設變數名為 s):

IX

(10)

1.(建構式)stack<T, C> s(C& a):s 一開始會有一份 a 的複製品。如果沒有指定 C 的話,C 是 deque<T>。如果沒有指定 a 的話,s 一開始會是空的。複雜度 O(size)。

2. s.size()、s.empty():同 vector。

3. s.top():存取最後一個進入 s 的元素,即「一疊書中最上面的那一本」。複雜度 O(1)。

4. s.push():將一個元素加入 s 中。複雜度 O(1)。

5. s.pop():將最後一個進入 s 的元素移除。複雜度 O(1)。

  至於 C 應該要選用什麼,個人建議是 vector<T>。而且事實上,stack 能做的事 vector 都能做到,所以平常用 vector 就可以了。只是用 stack 可以增加程式的可讀性。

3.10 queue

   queue 位於標頭檔 <queue> 裡。可以把它想像成排隊等著結帳的人群,要嘛有新的人 來排在隊伍的尾端,要嘛最前面有一個人結完帳要走了。簡單來說,就是秉持著「先進先 出」(FIFO)的精神。

   queue 這個模板同樣需要兩個型別參數 T 和 C,跟 stack 一樣。不過不同的是,queue 對 C 的要求不太一樣:要有 empty、size、front、back、push_back、pop_front 這些函式,

而在內建的容器中能夠勝任這角色的只有 deque 和 list。

   queue 常用的語法列舉如下(假設變數名為 q):

1.(建構式)queue<T, C> q(C& a):q 一開始會有一份 a 的複製品。如果沒有指定 C 的話,

C 是 deque<T>。如果沒有指定 a 的話,q 一開始會是空的。複雜度 O(size)。

2. q.size(),q.empty():同 vector。

3. q.front():存取第一個進入 sq 的元素,即「隊伍中最前面的人」。複雜度 O(1)。

4. q.back():存取最後一個進入 q 的元素,即「隊伍最末端」。複雜度 O(1)。

4. q.push():將一個元素加入 q 中。複雜度 O(1)。

5. q.pop():將第一個進入 q 的元素移除。複雜度 O(1)。

  建議 C 就依照預設的即可。不過和 stack 一樣,要用 queue 不如用 deque。

3.11 priority_queue

   <queue> 中其實還藏有一威力極大的適配器:priority_queue。priority_queue 利用幾個 內建函式實現「二叉堆」(binary heap)結構,一個在任何時候維持最頂的元素永遠都是 最大的資料結構。priority_queue 雖然實作容易,但應用廣泛,每次都手刻一次會很浪費時

X

(11)

間。以後會見到更多 priority_queue 的應用。

   priority_queue 這個模板需要三個型別參數 T、Con 和 Cmp。T 代表內容物的型別(需 可以比較大小),Con 代表使用的容器,而 Cmp 代表使用的比大小的依據(之後會再詳細 說明)。Con 的要求是擁有隨機存取迭代器以及 empty、size、front、push_back、pop_back 這些函式,而滿足這些條件的內建函式有 vector(預設值)和 deque。在詳細地認識 Cmp 之前,只需要知道 Cmp 是 less<T>(預設值)時 priority_queue 是最大堆,而是 greater<T>

的時候 priority_queue 是最小堆。

   priority_queue 常用的語法列舉如下(假設變數名為 pq):

1.(建構式)priority_queue<T, Con, Cmp> pq:建構一個空的 pq。複雜度 O(1)。

2.(建構式)priority_queue<T, Con, Cmp> pq(iterator first, iterator last):建構一個 pq,內含 [first,last) 指到的東西,這裡 iterator 可以是任何迭代器。複雜度 O(size)。

3. pq.size()、pq.empty():同 vector。

4. pq.top():回傳 pq 中最大(最小)的元素(無法修改)。複雜度 O(1)。

5. pq.push(T a):將 a 加入 pq 中。複雜度 O(log size)。

6. pq.pop():將 pq 中最大(最小)的元素移除。複雜度 O(log size)。

  比較需要注意的是在建構的時候直接餵內容物的時間複雜度是 O(size),而一個一個 push 進去的時間複雜度是 O(size log size)。雖然一般情況下沒什麼差,不過有必要的時候 請記得 priority_queue 的建構式可以減少複雜度。

3.12 pair

   pair 位於標頭檔 <utility> 裡面(注意沒有 <pair> 這個標頭檔)。pair 其實很單純,就 是把兩個(可能不同型別的)變數綁在一起,變成一個變數。為此,pair 需要接收兩個型 別,分別代表一對的第一項和第二項的型別。

   pair 常用的語法列舉如下(假設變數名為 p):

1.(建構式)pair<A, B> p(A a, B b):建構一個把型別 A 和型別 B 綁在一起的 p,其中第一 項是 a,第二項是 b。

2. p = s:如果 s 也是同型別的 pair,把 p 變得跟 s 一樣。

3. p (比較大小或相等的符號)s:如果 s 也是同型別的 pair,先比第一項,如果一樣再比 第二項。

4. p.first、p.second:存取第一項、第二項。

XI

(12)

  在使用 pair 時,常常會使用到一個非成員函式 make_pair。make_pair 的好處在於,你 不用特別指明第一項和第二項的型別,編譯器會自行幫你解析。用法是 make_pair(A a,B b),函式會回傳一個 pair<A, B>,其中第一項是 a,第二項是 b。

  另外,C++11 開始允許可變長度的模板,所以也有 pair 的推廣版 tuple(在標頭檔

<tuple> 中)。有興趣的可以自己看看。

3.13 set

   set 位於標頭檔 <set> 裡。set 實現了自平衡二元查找樹,用白話文來講,可以 O(log n) 插入、刪除或查詢一個值有沒有在其中。特別的是,裡面的元素不會重複,因此我們會把 元素的值稱為鍵值(key)。

   set 的常用語法列舉如下(假設變數名為 s):

1.(建構式)set<K> s:建構一個空的 s。複雜度 O(1)。

2. s.size()、s.empty():同 vector。

3. s.insert(K k):在 s 中放入一個鍵值為 k 的元素。如果本來就有了,什麼事都不會做。複 雜度 O(log size)。

4. s.erase(iterator first, iterator last):刪除 [first,last)。如果沒指定 last,只刪除 first。只刪除 一個時均攤複雜度 O(1),刪除多個時複雜度是刪除個數的線性。

5. s.erase(K k):刪除所有鍵值為 k 的元素並回傳刪除的項數(在 set 中只會是 0 或 1)。複 雜度 O(log size)。

6. s.find(K k):回傳指向鍵值為 k 的元素的迭代器。如果沒有這種東西,回傳 m.end()。複 雜度 O(log size)。

7. s.count(K k):回傳有幾個鍵值為 k 的元素(在 set 中只會是 0 或 1)。複雜度 O(log size)。

8. s.lower_bound(K k):回傳迭代器指向第一個鍵值大於等於 k 的項。複雜度 O(log size)。

9. s.upper_bound(K k):回傳迭代器指向第一個鍵值大於 k 的項。複雜度 O(log size)。

   set 的迭代器是雙向迭代器。set::iterator 會由小迭代到大,set::reverse_iterator 則會由 大迭代到小。比較容易被忽視的是 set 的迭代器在遞增和遞減的時候,理論上不只是均攤 複雜度,連複雜度也是 O(1)。但有些實作只保證均攤複雜度。

  要注意的是 lower_bound 和 upper_bound 的微妙差別:一個是大於等於、一個是大於。

用途通常是找鍵值在 [l,u) 的那些項,找法是 [s.lower_bound(l), s.upper_bound(u) )。記住,

C++ 通常是左閉右開區間。

XII

(13)

3.14 map

   map 位於標頭檔 <map> 裡。map 可以當成 set 的每一個元素都對應到另一個值,也就 是可以用 O(log n) 插入、刪除或尋找一個鍵值對應的值。因此,map 這個模板需要兩個型 別參數 K 和 T,其中 K 是鍵值的型別(需要可以比大小),而 T 代表對應到的值的型別。

  另外,map 中的每一個元素其實是 pair<K, T>,所以迭代器指向的東西是一個 pair,

第一項是鍵值,第二項是對應的值。

   map 常用的語法列舉如下(假設變數名為 m):

1.(建構式)map<K, T> m:建構一個空的 m。複雜度 O(1)。

2. m.size()、m.empty()、m.erase(iterator first, iterator last)、m.erase(K k)、m.find(K k)、

m.count(K k)、m.lower_bound(K k)、m.upper_bound(K k):同 set。

3. m[k]:存取鍵值 k 對應的值。如果 k 沒有對應的值,會插入一個元素,使 k 對應到預設 值並回傳之。複雜度 O(log size)。

4. m.insert(pair<K, T> k):如果沒有鍵值為 k.first 的值,插入一個鍵值為 k.first 的值對應 到 k.second,並回傳一個 pair,first 是指向剛插入的元素的迭代器、second 是 true;

如果已經有了,回傳一個 pair,first 是指向鍵值為 k.first 的元素的迭代器,second 是 false。複雜度 O(log size)。

  和 set 的迭代器一樣,map 的迭代器是雙向迭代器。

3.15 multiset, multimap

  在 <set>、<map> 中分別還有 multiset 和 multimap。和前面大致相同,唯一的差別在 於 multiset 和 multimap 中鍵值可以重複出現,不像 set 和 map 鍵值不能一樣。如此一來,

count 和 erase 回傳的值便不一定是 0 或 1。此外,由於 multimap 中一個鍵值可能對應到許 多不同的值,因此也不支援下標操作。

  在 multiset 和 multimap 中有一個特別好用的函式 equal_range(K k),會回傳一個 iterator 的 pair,第一項代表 lower_bound(k),第二項代表 upper_bound(k)。這兩項迭代器之間的項 就是那些鍵值是 k 的項。雖然這個函式 set 跟 map 也有,但在 set 和 map 中就顯得有點雞 肋。

3.16 (C++11)unordered_(multi)set, unordered_(multi)map

  在 C++11 以後,unordered 系列常常擔任優化掉 map 和 set O(log n) 複雜度的角色。

unordered_(multi)set 和 unordered_(multi)map 分別在標頭檔 <unordered_set> 和 <unordered_

map> 裡。這四個模板需要的前一(二)個型別參數和 set(map)一樣,而接著是一個型別

XIII

(14)

Hash,代表要使用的雜湊函數的函數型別(之後會提)或指標。不過 C++11 有預設的內建 型別(含任意型別的指標)的雜湊函數,所以除非情況特殊,你可以不用理會這一項。

你可以把 unordered 系列當成 (multi)map 和 (multi)set 來用。不過有幾點不同:

1. unordered 系列的迭代器為單向迭代器。

2. unordered 系列沒有將所有項依鍵值排序(這也是它名字的由來),因此迭代器在遍歷容 器時不會依鍵值的大小順序遍歷。

3. 因為沒有排序,所以理所當然的沒有 lower_bound、upper_bound。

4. 比起 (multi)map 和 (multi)set,unordered 系列的期望複雜度少一個 log。

  在宣告變數(建構式)時,你可以指定 bucket 至少有幾個。如果鍵值是內建型別而且 你沒有指定 bucket 至少有幾個,那麼它會很耐斯地幫你搞定。

3.17 bitset

   bitset 位於標頭檔 <bitset> 裡。你可以把它想像成有一堆 0 跟 1 的一個陣列,也就是 說 bitset 的大小是固定的。但是它每一項只會用到 1 bit 的空間,而 bitset 的位元運算是被 優化過的(雖然只限於同大小的 bitset),運作起來非常快速,對常數有極巨大的影響,同 時也可以達到壓縮空間的效用,是常數優化的好幫手。

   bitset 這個模板需要一個整數 n 作為模板的參數,代表 bitset 的長度。

   bitset 的一些常用語法列舉如下(假設變數名為 b):

1.(建構式)bitset<N> b(a):用 a 初始化一個長度為 N 的 bitset。這裡 a 可以是 unsigned long、string 或 C 式字串。如果沒有指定 a,或者如果 b 有一些地方沒被 a 初始化,那 些地方預設為 0。

2. b.count():回傳 b 有幾個位元是 1。複雜度 O(N )。

3. b.size():回傳 b 有幾個位元。複雜度 O(1)。

4. b (位元運算):不管是一元還是二元的位元運算都可以。如果是兩個 bitset 的二元位元 運算,兩個 bitset 的長度需一致。複雜度 O(N)。

5. b[a]:存取第 a 位。複雜度 O(1)。

6. b.set():將所有位元設為 1。複雜度 O(N )。

7. b.reset():將所有位元設為 0。複雜度 O(N )。

8. b.flip():將所有位元的 0、1 互換(反白)。複雜度 O(N)。

9. b.to_string():回傳一個字串和 b 的內容一樣。複雜度 O(N )。

XIV

(15)

10. b.to_ulong():回傳一個 unsigned long 和 b 的內容一樣(在沒有溢位的範圍內)。複雜度 O(N )。

   bitset 不是容器,而且它也沒有迭代器。

  通常而言,如果要估計常數的話,相較於直接使用陣列,空間是 1/8、count 約是 1/6、

位元運算約是 1/30。當然這些都不是絕對的。

  要注意的是,上述的複雜度沒有明文規定,不過通常是如此。

3.18 習題

  上面那些充其量只是「字典」。若要完全瞭解如何使用,最簡單的方法就是直接操練 啦。以下都會提示可能會用到的東西,但不代表不用那些東西就解不出來。

1. (ZJ b298) 有 N ≤ 104 家廠商,其中有 M ≤ 106 組廠商關係 a, b,代表 a 是 b 的上游廠 商。這 N 個廠商中有 L 個是有問題的,而有問題的廠商的下游廠商也會有問題。接 著有 Q ≤ 104 個詢問,回答指定的廠商是否有問題。(vector)

2. (ZJ c123)(UVa 514) 有一個 1 到 N ≤ 1000 的排序(單筆可到 106),請問你可不可以透過 一個暫存用的 stack 使得你從 stack 取出元素的順序剛好是 1 到 N。(stack)

3. (ZJ a813) 有 N ≤ 106棟房子由左至右排成一列。第 i 棟房子的高度是 Hi。如果 a < b 而 且第 a 棟房子和第 b 棟房子之間沒有其它房子高度超過 Ha或 Hb,那麼從第 a 棟房子 可以看得到第 b 棟房子,從第 b 棟房子也可以看到第 a 棟房子。如果從第 i 棟房子能 看見的房子數為 Ci,求 C1+ C2+· · · + CN。(stack)

4. (ZJ d424)(UVa 105)(TIOJ 1202) 給你不超過 5000(單筆可以到 105)個矩形,每個都有一 邊貼齊 x 軸且都在 +y 的部分。請求出它們的輪廓。(priority_queue)

5. (ZJ b231)(2009 入營考 pC) 有 N ≤ 1000 本書(事實上 N ≤ 105都可以),每本書都有所 需要的印刷時間和裝訂時間。你可以同時裝訂任意多本書,但同一時間只能印刷至多 一本書。每本書需要先印完再裝訂。請問你至少要花多久才能將所有書都印刷裝訂完 畢。(priority_queue)

6. (TIOJ 1807) 給你一張圖,有 m≤ 103 個點和 n≤ 109 條邊,每個點的編號在 1 到 n(別 懷疑,你沒看錯)之間。請判斷它是不是簡單圖。(map, set)

7. (ZJ b291) 輸入的第一行有一個正整數 N ≤ 1000。接下來 N 行的每一行會出現一個字串

(字串只包含小寫英文且長度不超過 20)代表動物種類,一個數字 M ≤ 100 代表動物 個數,緊接著是一個字串代表動物出現的地方。請輸出若干行,每一行列出一個地方 出現的所有動物及個數(地點依第一次出現的順序排序,動物也依第一次出現的順序 排序)。(vector, map, string)

XV

(16)

4 <algorithm>

   C++ 很貼心地給了一個標頭檔 <algorithm>,裡面充滿了各種函式。然而這些函式並 不知道它們接收到的東西長得怎樣,所以這個時候迭代器就發揮功勞了。

   <algorithm> 的函式往往都是以迭代器做為參數,因此這些函式不太需要關於接收到 的東西的資訊(例如大小等,因為這些東西通常都蘊含在迭代器中了)。不過,有些函式 還需要接收一個「函數」作為參數。你可以直接餵它函數指標,也可以選擇餵它一個「函 數物件」。

4.1 函數物件

  相信大家都知道 int 可以做加減乘除、遞增遞減、模、位元運算、比較大小等等的運 算;一個指標則可以加減、可以用星號 dereference;一個迭代器則可以 dereference,可以 遞增,可能可以遞減,也可能可以做加減法運算;一個 vector 或 deque 則有下標運算 “[]”。

這些我們統稱為「運算子」(operator)。而在眾多的運算子中,還有一種 “()”。由於它寫起 來就像是函數,因此提供 “()” 這種運算子的物件我們姑且稱作為「函數物件」,使用起來 就真的像函數一樣。

  而函數物件的型別就稱作「函數型別」。

4.2 less, greater

    前 面 一 直 提 到 的 less 和 greater 事 實 上 是 函 數 型 別 的 模 板。 它 們 都 位 於 標 頭 檔

<functional> 裡。對於一個有小於(<)運算子的型別 T,less<T>(T a, T b) 會回傳一個 bool 等價於 a<b。而 greater 則是使用大於運算子。可以看出,less 和 greater 只是將運算子 包裝成函數物件的模板,而其它運算子對應的函數型別模板也全都在標頭檔 <functional>

中。然而 <functional> 中最常用的就是這兩個了。

  通常不太需要為了 less 和 greater 多寫一行引入,因為 <algorithm>、<queue>、<set>、

<map> 等等,其實都有引入 <functional>。

4.3 好用的簡單函式

  介紹完函數物件後就進入這個章節的主題吧。

   <algorithm> 中的一些函式其實還滿常見的,像是 swap、min、max、random_shuffle

(打亂一個範圍的元素)、iter_swap(交換兩迭代器指向的物件)等等的。相信大家都很熟 悉所以就不再多說了。

XVI

(17)

4.4 簡化迴圈用的函式

   C++ 作者在他出的書裡提到,迴圈是低階程式在用的,高階程式應避免使用迴 圈。若使用了,也要儘量少。為了貫徹他的理想,C++ 實作了許多將迴圈包裝起來的 函式,常用的列舉如下:for_each, find, find_if, find_end, count, count_if, search, search_n, copy, copy_backward, replace, replace_if, fill, fill_n, remove, remove_if, unique, reverse, rotate, partition, stable_partition, min_element, max_element, lexicographical_compare。

  如果跟上面那整坨東西搞好關係的話,你的程式碼會變得很短。如果你會 C++11 以後的 lambda function 語法(inline 生成函數物件)的話,你的程式碼會變得超級無敵短。

但我們現在寫的程式是競賽用途,不求高階不求美觀,所以如果你不會這些將迴圈包裝起 來的函式其實也無傷大雅。

4.5 sort, nth_element

  常常遇到要你寫 sort 的題目覺得很煩嗎?insertion sort 跟 quicksort 都會 TLE 嗎?

寫 merge sort、heapsort 寫 到 心 煩 了 嗎? 不 用 擔 心,C++ 自 己 根 本 就 有 內 建 的 sort 和 nth_element,而且是採用效率極高的 introsort 和 introselect。

  先簡單介紹一下 introselect,是一個找到序列中第 k 小的元素的方法。在 introselect 之 前,人們都是使用 quickselect,其想法是隨便戳一項(稱之為 pivot),將比它小的元素放 在左邊,比它大的元素放在右邊,再看看你要的東西是在左邊還右邊,繼續遞迴下去。這 樣當 pivot 剛好在正中間時複雜度會是好好的 O(n)(這也是期望複雜度),然而運氣差一 點可能會退化為 O(n2)。而 introselect 即為結合 quickselect 常數小但複雜度不保證,以及 median of medians(中位數的中位數,有興趣可以 wiki)保證複雜度但常數較大的特性而形 成的排序法。想法是一開始先 quickselect,如果發現遞迴太深(代表戳不中比較中間的項)

的話再改用 median of medians。而 nth_element 採用的就是 introselect,複雜度保證 O(n)。

Median of medians 蠻難寫的,所以請記得有這個函式可以用。

   Introsort 也是同樣的道理。一開始先使用 quicksort(隨便戳一項之後將比它小的 元素放在左邊,比它大的元素放在右邊再把左右遞迴排序好),如果發現遞迴太深就改用 heapsort(可以把它想像成把一堆東西塞進 priority_queue 後再一個一個拔出來),而如果東 西很少的話就直接使用 insertion sort。語法如下:

1. sort(iter first, iter last, Cmp cmp):將 [first,last) 依照 cmp 排序,使得若 a 嚴格地在 b 前 面,則 cmp(a,b) 為真。這裡要求 iter 是隨機存取迭代器,而 cmp 是一個接受兩個參 數,回傳一個 bool 的函數指標或函數物件。不指定 cmp 時會由小排到大。複雜度 O(Cn log n),其中 C 是 cmp 的複雜度。

XVII

(18)

2. stable_sort:跟 sort 一樣,然而保證如果有兩項 a 跟 b 的值一樣且 a 一開始在 b 前面,那 麼最後 a 也會在 b 前面。一般來說,有額外空間的話時間複雜度 O(n log n)、額外空間 複雜度 O(n)。但是如果沒辦法找到額外的空間,時間複雜度會變成 O(n log2n)。(少 數實作會提供不管有沒有額外空間都可以達到 O(n log n) 複雜度的演算法,稱為 block sort 或 wikisort。)

3. nth_element(iter first, iter nth, iter last, Cmp cmp):將排序後應該會在第 nth 位置的元素 x 移到第 nth 個位置,並且讓應該在 x 前面的所有元素都在 x 前面,反之亦同。參數要 求同 sort。平均複雜度 O(n),但未保證最差複雜度。在多數實作當中,最差複雜度 為 O(n log n)。

4.6 專對付已排序序列的函式

  有些函式是專門處理已排序序列的函式。如果你把它用在未排序的數列,你會得到一 個無法預期的回傳值。常見的列舉如下:

1. lower_bound(iter first, iter last, T t, Cmp cmp):找到 [first, last) 中第一項 a,使得 cmp(a, t) 不為真,並回傳指向 a 的迭代器。要求 [first, last) 經 Cmp 排序過。若未指定 Cmp,則 假設 [first, last) 由小排至大。簡單來說,跟 map 的 lower_bound 是一樣的意思。若 iter 是隨機存取迭代器,複雜度 O(log n)。若不然,複雜度 O(n)。如果 iter 是 set 和 map 系列的迭代器,請直接採用 set 和 map 的成員函式。

2. upper_bound(iter first, iter last, T t, Cmp cmp):找到 [first, last) 中第一項 a,使得 cmp(t, a) 為真,並回傳指向 a 的迭代器。其餘同 lower_bound。

3. equal_range(iter first, iter last, T t, Cmp cmp):回傳一個 pair,第一項是 lower_bound,第 二項是 upper_bound。其餘同 lower_bound。

4. merge(iter1 first1, iter1 last1, iter2 first2, iter2 last2, iter3 res, Cmp cmp):假設 [first1, last1) 和 [first2, last2) 是兩個經 cmp 排序好的序列,將兩個序列合併並排序,輸出至以 res 為首的序列並回傳指向序列末端的迭代器。iter1、iter2 可以是任何迭代器,iter3 可以 是任何輸出迭代器。記得讓 res 後面有足夠的空間。複雜度線性。

5. set_union(iter1 first1, iter1 last1, iter2 first2, iter2 last2, iter3 res, Cmp cmp):假設 A = [first1, last1) 和 B = [first2, last2) 是兩個經 cmp 排序好的序列,將兩個序列取聯集排序輸出至 以 res 為首的序列並回傳指向序列末端的迭代器。其餘同 merge。

6. set_intersection:同 set_union,但此函式取的是交集。

7. set_difference:同 set_union,但此函式取的是餘集(A− B)。

8. set_symmetric_difference:同 set_union,但此函式取的是對稱餘集(A∪ B − A ∩ B)。

XVIII

(19)

4.7 (next/prev)_permutation

  最後來介紹 next_permutation 和 prev_permutation 這兩個函式。

  相信大家都知道什麼是字典序:從第一項開始逐項比較大小,直到分出大小為止。而 這兩個函式即是找出比目前的字典序還要大一點(小一點)的排列。如果找不到更大(更 小)的了,會將原本的序列由小到大(由大到小)排好。雖然一次的複雜度最高是線性,

但如果執行了 n! 次,均攤複雜度只有常數,是個不錯用的函式。語法如下:

1. next_permutation(iter first, iter last, Cmp cmp):將 [first, last) 變成原本的某個排序,使得 字典序是比原本大的所有排序中最小的。如果成功了,這個函式會回傳 true;否則回 傳 false,並將 [first, last) 變成字典序最小的那個排序。cmp 是拿來比較兩元素大小的 依據,如果不指定的話會用小於運算子。iter 至少需要是雙向迭代器。

2. prev_permutation(iter first, iter last, Cmp cmp):將 [first, last) 變成原本的某個排序,使得 字典序是比原本小的所有排序中最大的。如果成功了,這個函式會回傳 true;否則回 傳 false,並將 [first, last) 變成字典序最大的那個排序。其餘同 next_permutation。

4.8 習題

  同樣地,會有可能需要用到什麼東西的提示,但是不代表說要全用喔。

1. (ZJ a233) 裸排序題 BJ4

2. (TIOJ 1807) 給你一張圖,有 m ≤ 103 個點和 n ≤ 109 條邊,每個點的編號在 1 到 n 之 間。請判斷它是不是簡單圖。(vector, sort)

3. (TIOJ 1617)(IOI 2000)(Interactive) 有 n≤ 1499 個數(奇數個),每次詢問你可以問某三個 數的中位數是誰,而你最多可以做 7777 次詢問。請找出全部的中位數。(nth_element)

4. (ZJ d242)(UVa 481) 給你一個序列(長度單筆最大可到 105),找最長的嚴格遞增子序 列。(vector, upper_bound)

5 動態規劃 (Dynamic Programming)

  動態規劃(簡稱 DP)簡單來說就是以空間複雜度為代價,換取時間複雜度的優化。

方法就是將之後可能會用到的計算結果存起來,以後就不用再重新算一遍了。

  我們以兩種計算費氏數列的方法為例:

XIX

(20)

Algorithm 2: Fibonacci without DP

1 function fibo(i)

2 if i = 0

3 return 0

4 else if i = 1

5 return 1

6 else

7 return f(i - 1)+f(i - 2)

8 end if

9 end function

Algorithm 3: Fibonacci with DP

1 array dp = {0}

2 function fibo(i)

3 if dp[i] != 0

4 return dp[i]

5 else if i = 0

6 return 0

7 else if i = 1

8 return dp[1] = 1

9 else

10 return dp[i] = f(i - 1) + f(i - 2)

11 end if

12 end function

  大家可以試著在自己的電腦上跑跑看。前一種方法在跑第四十幾項時就開始變得吃力 了,而後一種方法計算到 1000000 項都沒問題(當然會有溢位的情況)。

  我儘量將兩段程式碼寫得很像就是為了方便比較。可以發現第二段程式碼和第一段程 式碼的差別在於,第二段程式碼中有將答案存在陣列中,未來如果還需要該答案的話可以 直接從陣列取出;然而第一段程式碼中,就算是已經算過的項,未來還是要重新算一次。

5.1 狀態數、轉移式以及複雜度

  那要如何估計 DP 的複雜度呢?首先,我們需要知道它的狀態數以及轉移式。

  所謂的狀態數,就是你所存的答案的總數。以 Algorithm 3 為例,要計算第 n 項,需 要儲存第 0 項到第 n− 1 項的答案,因此狀態數是 O(n)。

  而所謂的轉移式,就是你如何利用已知的結果,計算出新的東西。同樣以 Algorithm 3 為例,f(i) 的值的計算方法就是 f(i− 1) + f(i − 2)。我們就稱 f(i) = f(i − 1) + f(i − 2) 為 這個 DP 的轉移式,而計算這個式子的複雜度就稱為轉移的複雜度。在這個情況下,轉移

XX

(21)

複雜度為 O(1)。

  知道這兩個東西之後,複雜度也很好估計了。最慘的情況下,每個你存的狀態都需要 去計算,而每次計算(轉移)的複雜度你也知道了,因此總複雜度就是把兩個乘在一起。

以 Algorithm 3 為例,總複雜度為 O(n)× O(1) = O(n)。

  事實上,Algorithm 2 的時間複雜度是 O(φn)(φ = 1+25)、空間複雜度是 O(1),由 此可見 DP 犧牲一些空間複雜度,換取的空間複雜度的極大改進。

5.2 滾動 DP

   DP 的技巧有許多種,通稱為 DP 優化,每種學問都很深,可以有效地減少時間複雜 度。這些我們之後會再提。現在我們先來看一種減少空間複雜度的技巧:滾動 DP。

  我們可以用同樣的時間複雜度,但只用 O(1) 的空間來達到剛剛的目標:

Algorithm 4: Fibonacci with rolling DP

1 function fibo(i)

2 if i = 0

3 return 0

4 else

5 prev_val = 0, now_val = 1

6 for now_index = 1 to i

7 prev_val += now_val

8 swap prev_val and now_val

9 end for

10 return now_val

11 end if

12 end function

  其想法是:既然每次計算的時候只需要前兩項,那剩下那些用不到的就可以丟掉了。

  通常這種實作在 prev_val 和 now_val 分別是陣列的時候行不通,因為將 now_val、

prev_val 的每一個值交換會浪費許多時間。然而在 C/C++ 中,可以透過交換指標達成這個 目的。

5.3 經典 DP 題

  懶得找 judge,自己寫開心就好。

1. 最大子陣列和:給你一個整數陣列 a0, a1,· · · , an−1。求一組 i, j 使得 ai+ ai+1+· · · + aj

最大。時間複雜度 O(n),空間複雜度 O(1)。

XXI

(22)

2. 最長公共子序列(Longest Common Sequence, LCS):給你兩個字串,長度分別為 m 和 n,請找出他們的最長公共子序列。時間複雜度 O(mn)。如果需要列出其中一 個最長公共子序列,空間複雜度 O(mn)。如果只需求出最長的長度,空間複雜度 O(min(m, n))。

3. (ZJ d637) 0/1 背包問題:你有一個包包能裝 W 公斤的東西。有 N 樣物品,已知所有 物品分別的重量和價值。請問這個包包最多可以裝價值多高的物品?時間複雜度 O(N W ),空間複雜度 O(W )。

4. 無限背包問題:同前一題,然而同一樣物品有任意多件,你可以裝任意多個同一樣物 品。時間複雜度 O(NW ),空間複雜度 O(W )。

5.(旅行推銷員問題)給你一張 n 個點的圖,給你任兩點之間的距離。求恰經過每個點一 次後回到出發點的迴路長度最小值。時間複雜度 O(n22n)、空間複雜度 O(n2n)。

(提示:以一堆 0 和 1 為狀態,並用二進位轉換成一個數儲存。此技巧稱為位元 DP。)

5.4 習題

  在做這些題目的時候,仔細想想,狀態要存什麼?轉移式怎麼寫?再回頭檢視複雜度 是不是好的。

1. (ZJ d652) 有 n ≤ 50 個怪物排成一列(n 其實可以到 400)。每隻怪物都有一個大小 Wi。對於三個相鄰的怪物 A、B、C,A 和 C 可以一起把 B 給殺掉,並且產生汙染 P = WAWBWC。要求這些怪物殺來殺去以後只留下最前面跟最後面的怪物。請問汙 染的總和最小是多少?保證答案在 109 以內。

2. (ZJ d645) 背包問題,有些東西有限量,有些東西不限量。請想出一個複雜度 O(nmw) 的作法,其中 n 是物品個數,m 是限量的最大值,w 是背包可容納的重量。

(註:利用 DP 優化,可將複雜度降低至 O(nw)。)

3. (ZJ d054) 有幾種用單位小正方形還有由 3 個單位小正方形組成的 L 形拼滿一個 2 乘 n 的矩形的方法?(n≤ 40,但其實可以到 n ≤ 106

4. (ZJ a128)(ACM ICPC World Finals) 你有一個 x× y 的方格表(x, y ≤ 104)。給你 n ≤ 15 個數,請問你是否能沿著某條橫線或直線切割方格表 n− 1 次,使得切出來的 n 塊面 積跟那 n 個數一樣。(提示:位元 DP。)

5. (2016 三模 pB)(No judge) 我們稱一個物體的耐撞力為 H,若該物體從 H 公尺高度內自 由落體下時不會壞掉,但超過 H 公尺時便會損壞。給你一個陣列 P1, P2,· · · , PU,其 中 Pi 代表在物體從 i 公尺下落所需的實驗經費。物體在壞掉之前可以進行任意多次 實驗,但壞掉之後就不能進行任何實驗。已知 H 在 [0,U] 中,問在要求該物體最多只 能壞掉一次的情況下,保證可以得知確切 H 值的最小實驗花費。複雜度 O(U2)。

XXII

(23)

(註:利用 DP 優化,可以快速找到轉移來源,將複雜度降低至 O(U log U)。這也是原 題要求的複雜度。)

7. (2015 一模 pA)(No judge) 給一個 n× n(n ≤ 22)的表格,每格裡有一個數字 Vi,j ≤ 106。 選擇其中若干格使得任兩格在八方位不相鄰(也就是說有公共邊或公共角都不行),

求總和的最大值。(提示:位元 DP。)

6 貪婪演算法 (greedy)

  遇到要求最大(最小值)時,每次決策都選擇當下最佳的選擇,這就是貪婪演算 法。然而一般的情況下,貪婪演算法不保證正確性,因此什麼時候可以 greedy、要怎麼 greedy,都需要一定的練習才可以熟練。

6.1 貪婪失敗的實例

  以 0/1 背包問題為例。考慮以下三種貪婪的準則:

1. 每次都將重量最小的物品塞進包包。複雜度 O(n log n)。

2. 每次都將價值最高的物品塞進包包。複雜度 O(n log n)。

3. 每次都將 CP 值(價值除以重量)最高的物品塞進包包。複雜度 O(n log n)。

  可以發現三者的複雜度都比前面的 DP 快許多,然而問題就在於三者都不保證正確 性。有興趣的人不妨試著對三種準則分別構造反例。

  從這個例子,可以發現雖然 greedy 可以有低時間複雜度,卻不一定正確。不過好的 greedy 演算法會給出跟正解足夠接近的解。而在某些問題裡,greedy 更是會保證正確性。

6.2 貪婪成功的實例

  我們考慮下列的問題:給你 n 個線段。請問最多能取出幾個線段,內部互不重疊。我 們再考慮以下三種貪婪的準則:

1. 將線段依線段長度排序,再由小到大選取。如果有重疊就不選,沒有重疊就選。複雜 度 O(n log n)。

2. 將線段依左端點排序,從最左邊的線段開始取。如果有重疊就不取,沒有重疊就取。

複雜度 O(n log n)。

3. 將線段依右端點排序,從最左邊的線段開始取。如果有重疊就不取,沒有重疊就取。

複雜度 O(n log n)。

XXIII

(24)

  對於第一個準則,考慮線段 [0,5][4,6][5,10] 就發現這準則不一定正確。

  對於第二個準則,考慮線段 [0,3][1,2][2,3] 就發現這準則也不一定正確。

  然而可以證明第三個準則保證正確(請讀者自行思考),這是一個 greedy 成功的案例。

  這個例子告訴我們就算可以 greedy,也要選擇一種好的方法,不能胡亂地貪婪。

6.3 習題

  當你 greedy 失敗的時候,要仔細思考:為什麼這樣 greedy 會失敗?我該怎麼樣改變 我的準則才可以將剛剛的反例克服?當然,也要適時放棄 greedy 的想法。

1. (No judge) 有 n 位病人要看醫生。已知每個病人看醫生需要花的時間,而且只有一位醫 生。請求出病人總等待時間的最小值。時間複雜度 O(n log n)。

2. (Codeforces 665C) 給你一個長度≤ 2 · 105的字串。請用最少的 edit distance(改變最少個 字元)使得相鄰兩個字元皆相異。

3. (Codeforces 701A) 給你 n≤ 100 個數。請將這 n 個數兩兩分組使得每組和相同。保證做 得到。

4. (ZJ b231)(2009 入營考 pC) 有 N ≤ 1000 本書(事實上 N ≤ 105都可以),每本書都有所 需要的印刷時間和裝訂時間。你可以同時裝訂任意多本書,但同一時間只能印刷至多 一本書。每本書需要先印完再裝訂。請問你至少要花多久才能將所有書都印刷裝訂完 畢。

7 二分搜

7.1 一般的二分搜

  通常一般的二分搜是在解決以下這種問題:如果有一個遞增的函數 f 定義在區間 [a, a + n) 上,請求出滿足 f (s)≥ c 的最小整數 s。

  如果你從 a 開始暴搜,直到找到一個滿足條件的 s,那麼複雜度是 O(n)。這時我 們可以使用二分搜來解決這樣的問題,優化時間複雜度。想法是對於某個在 (a, a + n) 中 的整數 k,如果 f(k− 1) ≥ c,那麼 s < k,也就是說你要求的答案會落在區間 [a, k) 中。

反之,如果 f(k− 1) < c,那麼 s ≥ k,也就是說你要求的答案會落在 [k, a + n)。為了讓兩 種情況的可能性都儘量低,你可以發現你應該要取 k 愈接近 a + n/2 愈好。如此一來,每 次候選區間的長度都會縮小一半,因此複雜度為 O(log n)。

  實務上,這種函數 f 常常不能直接得出某一點的值 f(a)(甚至只能確認它和 c 的大小 關係),而需要 O(M) 的時間來計算。顯然地,這時複雜度是 O(M log n)。

XXIV

(25)

  順帶一提,lower_bound 和 upper_bound 便是用二分搜實作的。

  實作上要注意的是加一和減一不要搞混、左閉右開和閉區間不要搞混,不然很有可能 就變成無窮迴圈。以下是虛擬碼:

Algorithm 5: Binary Search

1 function binary_search(array[], first, last, val) 2 while first + 1 < last

3 mid = (first + last) / 2 4 if (array[mid - 1] < val)

5 first = mid

6 else

7 last = mid

8 end if

9 end while

10 return first

11 end function

7.2 題外話:三分搜

  利用二分搜這種「縮短候選人長度」的想法,我們可以找出滿足特定性質的函數的最 小值,這種技巧稱為三分搜。三分搜處理的問題如下:有一個在 [a, a + n) 中先嚴格遞減 再嚴格遞增的函數 f,請求出 f 在 [a, a + n) 的最小值。

    取 在 [a, a + n) 中的 兩個 整 數 x < y。如 果 f(x) < f(y),那 麼最 小 值一 定落 在 [a, y)。如果 f (x) > f (y),那麼最小值一定落在 (x, a + n)。如果 f (x) = f (y),那麼最小值 一定落在 (x, y)。為了讓候選區間每次都會縮短一定的比例,通常都取 x 跟 y 為區間的三 等分點(取中間一點的話常數會變小)。複雜度仍然是 O(log n)。

7.3 對答案二分搜

  有許多問題都喜歡叫你求「滿足條件的最小值」這種東西。如果這個問題滿足「單調 性」,那或許可以考慮對答案二分搜。

  什麼是「單調性」呢?考慮一個函數 P ,如果 s 滿足條件,那麼 P (s) = 1,反之 則為 0。如果 P 有單調性,我們就說這個問題有單調性。這樣的好處是,我們可以直接用 前面的方法二分搜出要求的 s。如果計算 P 的複雜度並不大時,這樣的方法可以有非常好 的表現效率。在你沒辦法快速求出 s 而只能快速確認一個 s 是否符合條件時,這是一個非 常好的方法。

XXV

(26)

7.4 習題

1. (TIOJ 1839)(IOI 2013)(Interactive) 有 n≤ 5000 個開關,分別(不照順序地)連著 n 個門。

對於每個開關,要嘛開的時候門會開,要嘛關的時候門會開,反之則反。你最多可以 詢問 70000 次,對於每次詢問,你可以給一個 n 個開關的配置,程式會告訴你第一個 關著的門是幾號門。請找出開關和門之間的對應關係,以及會讓所有門都開的開關配 置。

2. (TIOJ 1341)(IOI 2007)(Interactive) 有圖,詳見題敘。

(提示:一開始將候選區間不斷倍增,直到確定所求答案位於候選區間內。有人稱這 種方法為倍增法。)

3. (TIOJ 1815)(IOI 2013) 你有 T ≤ 106 個玩具、A ≤ 5 · 104 個弱雞機器人和 B ≤ 5 · 104小不點機器人。每個玩具的重量為 Wi、大小為 Si。每個弱雞機器人每次可以拿起的 玩具重量不能超過 Xi(大小不限制),而小不點機器人每次可以拿起的玩具大小不能 超過 Yi(重量不限制)。兩種機器人一次都只能拿一種玩具,每拿起一個玩具並放在 好好的地方需要花一分鐘,但不同的機器人可以同時拿玩具。請問你至少需要用幾分 鐘才能收拾完所有玩具(或不可能收完)?

(提示:greedy。)

XXVI

參考文獻

相關文件

隨身碟是一種攜帶方便的電腦儲存裝置。若浩南有一個容量為 8GB

sort 函式可將一組資料排序成遞增 (ascending order) 或 遞減順序 (descending order)。. 如果這組資料是一個行或列向量,整組資料會進行排序。

這些 dimensions 之間的關係, 而這些關係和 ordered basis 的選取無關, 所以 A, B associated λ 的 elementary Jordan matrices 的各個階數的個數會相同, 也就是 A,B 可以化為相同的

Table 3 Numerical results for Cadzow, FIHT, PGD, DRI and our proposed pMAP on the noisy signal recovery experiment, including iterations (Iter), CPU time in seconds (Time), root of

下列關於 CPU 的敘述,何者正確?(A)暫存器是 CPU 內部的記憶體(B)CPU 內部快取記憶體使 用 Flash Memory(C)具有 32 條控制匯流排排線的 CPU,最大定址空間為

每個 zone 交由一部 name server負責的作 法會有一個問題,萬一這個 name server 當 掉,可能造成 Internet上其它機器無法取得屬 於這個 zone 的資料(就是 domain name

In the school opening ceremony, the principal announces that she, Miss Shen, t is going to retire early.. There will be a new teacher from

In outline, we locate first and last fragments of a best local alignment, then use a linear-space global alignment algorithm to compute an optimal global