作者:vincent97198、エミリア
我相信會用到這個題單的人,已經解完基礎的題單了><
所以接下來的內容,會省略掉我認為你們應該會的東西,然後多做一些補充。
這是一份花了幾天、犧牲考試成績等價交換出來的講義,好好珍惜。
練習題有時候會需要已經教過但非該章的知識,請自行思考。
UVa, Atcoder, codeforces, TIOJ, POJ, Luogu
codeforce 的出題方向和TOI不太一樣,不要練到走火入魔了
等你大學打ICPC的時候就可以練cf練到走火入魔了(?
時間複雜度
空間複雜度
同時間複雜度去分析
i.e. 常數空間, 線性空間…
coding複雜度
程式碼很長、容易出bug的就是高coding複雜度
一般來說,數學題的coding複雜度最低,而資料結構題的coding複雜度最高。
均攤分析
假設有一連串的操作,大部分代價小,而少部分代價大,那他們的代價可以均攤到每筆操作上,如果這樣整體代價仍然合理,那就可以敲code了。
實際上,這裡面有很深的學問,在此不展開講解。
i.e 並查集, 單調隊列
把數據範圍代進複雜度裡,除以
這是經驗法則。
且會隨著時間、judge有所不同。但是不失為一個判斷能否AC的好方法。
心法分享: (不知道會不會有智慧財產權的問題就先撤掉了)
解題技巧:
多數情況下,Greedy策略是假解,請務必小心使用。
(不過在IOI賽制下還是可以拿來喇分)
上界/下界值其實可以開很大。反正頂多搜128次而已。
請小心設定退出條件。
二分搜用途及廣,他可以搜很多東西,最稀疏平常的就是搜答案
有許多問題都喜歡叫你求「滿足條件的最小值」。
這類問題若能滿足以下條件,那或許可以考慮對答案二分搜。
練習題: poj 3685, zj c904, zj c575
用法
三分搜尋法和我們熟悉的二分搜尋法在實做層面上,算是差不多的東西,唯一不同的地方在於
須確定搜索的範圍內函數的形狀是U或∩才行
原理
假定要拿來解釋的這個是要找二次函數的極小值,且極值的位置在左右界內僅靠一個參考點無法得知,該點的左右何者較接近極小值,但若有2個參考點(也就是三等份)就能得知較小的點必定比另一點更靠近極小值(二次函數性質),就能因此鬆弛解的範圍
實作 (U型函數)
while(fabs(l - r) > s)
{
m1 = (l + l + r) / 3
m2 = (l + r + r) / 3;
if (f(m1) > f(m2))
l = m1;
else
r = m2;
}
複雜度
練習題: AtCoder Beginner Contest 130 pF
有的時候題目的形質非常神秘,不易觀察(尤其是數學題(玄學題))
我建議可以本地打表,觀察性質,可能會有助於思考(吧
本地算完答案或一些關鍵數字,藉此壓縮複雜度。
(面對難題或許有奇效 通常沒有)
練習題: zj e299
快要TLE時,不管答案是否最優,直接輸出答案。
或是搜什麼東西,結果時間快到了還搜不到,直接輸出不存在。
(喇分還是挺有效的。)
工欲善其事,必先利其器
大概是資料結構的新手技能吧。 沒有人不會的。
生來就是解決 區間 問題。
給定一長度為n的數列,求區間
的最大/小值、和。
原理
將一段數列,分治處理,利用分治的技巧,整個線段最多被切割
lazy tag
現在增加一操作: 在區間
加上
當原本的線段樹遇到區間加/減值時,單點修改會TLE,lazy tag即是利用 不使用就不更新 的想法,在某個區間上打上tag,代表這裡的子節點應修改,然後不管他直到查詢需要用到才把tag推到子節點。
類似硬碟的刪除標記(?
差分
給定原陣列
,現有兩操作
- 在區間
內加上一首項為 、公差為 的等差數列 - 詢問索引
的數。
如果我們現在在一般數列
於是我們如果維護這樣一個數列,對於操作1,我們可以很簡單的先
明顯的,我們可以用線段樹維護上述數列,查詢前綴和即可。
練習題: zj d801, luogu P1438
又稱稀疏表。很少用到。
只能用在RMQ(Range Minimum/Maximum Query),不能算區間和。
原理
與許多資料結構的想法一樣,先處理小部分,在合併成整體。
定義Table[i][j]
為,在索引值i處,長度為
build:
query:
modify: it can't be modified
使用時機
不需要修改,大量尋找區間極值時。
但是很少會用到,但是一旦用到絕對比刻一顆線段樹還快很多。
實做
build:
先預處理長度為1的段。
for (int32_t i = 0; i < n; ++i)
for (int32_t j = 1; i - 1 + (1 << j) < n; ++j)
Table[i][j] = maintain(Table[i][j - 1], Table[i + (1 << (j - 1))][j - 1]);
query:
int32_t k = log2(r - l + 1); // int len = __lg(r - l + 1);
auto ans = maintain(Table[l][k], Table[r - (1 << k) + 1][k]);
跟倍增法好像哦
學好BIT說不定就可以看見她的笑容
不是你惠惠。
原理
首先要先知道lowbit(x)
在幹嘛,此函數會回傳x在二進制下最低的1是多少
i.e. lowbit(3 = 0b11) = 1
, lowbit(6 = 0b110) = 2
而實作上可以用 x&(-x)
來取得。
定義bit[x]
維護了
(如果你還是不懂,可以畫圖看看)
那 單點修改 就是
for (int32_t i = pos; i <= maxn; i += lowbit(i))
bit[i] += value;
前綴查詢:
for (int32_t i = pos; i; i -= lowbit(i))
res += bit[i];
如果將元素的值當作下標;且在下標處+1的話,可以擴展使用下面的功能
第k小查詢:
錯誤範例複雜度:
//錯誤範例
int32_t l = 0, r = maxn;
while(r > l)
{
int32_t mid = (r + l) / 2;
if(k > sum(mid))
l = mid + 1;
else
r = mid;
}
res = mid;
正確解法複雜度:
//正確範例
res = 0;
for (int32_t i = (1<< floor(log2(maxn))); i; i >>= 1)
if (res + i <= maxn && bit[res + i] < k)
k -= bit[res += i];
++res;
複雜度
modify:
query:
使用頻率
不算很低,也不算很高,但是很方便,算是輕便型segment tree
優點
常數很小、coding複雜度小
!這裡只討論split-merge treap
屬於二元平衡樹的一種,因為 易編寫 速度快 靈活性高 的特性而在競程占有一席之地。
他可以解決 segment tree 的問題,也可以解決splay tree的問題,同樣也可以解決大部分二元平衡樹的問題,學一個treap抵過學一堆樹。
heap: 父節點的
pri
值大於子結點
BST: 左子樹key
值均小於等於父節點,右子樹則大於。一般二元平衡樹為了避免退化,會利用 旋轉 操作去維持深度。
而treap因為擁有BST與heap的性質,所以既能擁有BST的查找功能,又能像heap一樣維持深度,
treap最重要的兩個操作:
merge(a, b):
合併兩顆treap,注意此函式必須滿足 a的所有key值小於b的所有key
split(t, k):
將treap t分成兩顆treap, 一顆裡的key均小於等於k,另一顆的均大於。
兩種操作都因為BST的特性所以實作難度降低許多。
基本架構
struct Treap
{
Treap *l, *r;
int32_t key, pri;
Treap(int32_t _key)
{
l = r = nullptr;
key = _key;
pri = rand();
}
}
Treap* merge(Treap* a, Treap* b)
{
if (a == nullptr || b == nullptr) return a ? a : b;
if (a->pri > b->pri)
{
a->r = merge(a->r, b);
return a;
}
else
{
b->l = merge(a, b->l);
return b;
}
}
void split(Treap* t, int32_t k, Treap* &a, Treap* &b)
{
if (t == nullptr)
{
a = b = nullptr;
return;
}
if (t->key <= k)
{
a = t;
split(t->r, k, a->r, b);
}
else
{
b = t;
split(t->l, k, a, b->l);
}
}
只要有上面兩種操作,基本就寫完了。
void insert(Treap *t, int32_t k)
{
Treap *lt, *rt;
split(t, k, lt, rt);
merge(merge(lt, new Treap(k)), tr);
}
void remove(Treap *t, int32_t k)
{
Treap *lt, *rt;
split(t, k - 1, lt, t);
split(t, k, t, rt);
t = merge(lt, rt);
}
treap,就是這麼簡單。
在平衡樹的問題中,常常遇見尋找第k小元素的要求,就跟BST一樣,treap一樣是用節點size去判斷,所以我們現在需要維護treap上節點的size,這樣可以跟BST一樣找kth了。
int32_t Size(Treap *t)
{
return t == nullptr ? 0 : t->sz;
}
void pull(Treap *t)
{
t->sz = 1 + Size(t->l) + Size(t->r);
}
真正實作
node:
struct Treap
{
static Treap mem[MAXN], *ptr;
Treap *l, *r;
int32_t pri, key, siz;
Treap() = default;
Treap(int32_t _key)
{
l = r = nullptr;
pri = rand(); //可以用其他隨機方法,保證更好的隨機性
key = _key;
siz = 1;
}
}Treap::mem[MAXN], *Treap::ptr = Treap::mem;
split:
//與上面唯一有差別的只有當某顆treap的結構改變時,需要呼叫pull()去更新資訊
//例如 呼叫完split()後
merge:
//與上面唯一有差別的只有當某顆treap的結構改變時,需要呼叫pull()去更新資訊
//例如 呼叫完merge()後
kth: (第k小)
int32_t kth(Treap *t, int32_t k)
{
int32_t lsz = sz(t->l) + 1;
if (lsz < k) return kth(t->r, k - lsz);
else if(lsz == k) return t->key;
else return kth(t->l, k);
}
以上是treap當平衡樹的版本
treap區間維護
上面提過,treap不只可以解決平衡樹問題,也可以解決序列操作問題。
我們只需要在node裡多加一個val
當作序列上的值、key
當作序列上的索引值、mx/mn/sum
當作要維護的值即可。
treap,就是這麼簡單。
但是當我們遇到區間加值怎麼辦?
線段樹巧妙的用 lazy tag 解決了,同樣的treap也可以!
只要用好好維護我們的tag即可。
void push(Treap *t)
{
if (t == nullptr) return;
t->val += t->lazy;
t->mx += t->lazy;
if (t->l != nullptr)
t->l->lazy += t->lazy;
if (t->r != nullptr)
t->r->lazy += t->lazy;
t->lazy = 0;
}
void pull(Treap *t)
{
t->sz = 1 + Size(t->l) + Size(t->r);
}
node: (改)
struct Treap
{
static Treap mem[MAXN], *ptr;
Treap *l, *r;
int32_t pri, key, siz, lazy;
int32_t val; //新增這兩個
int32_t mx;
Treap() = default;
Treap(int32_t _key, int32_t _val)
{
l = r = nullptr;
pri = rand();
key = _key;
siz = 1;
val = mx = _val;
}
}Treap::mem[MAXN], *Treap::ptr = Treap::mem;
build:
Treap *t = nullptr;
for (int32_t i = 1; i <= n; ++i)
t = merge(t, new (Treap::ptr++) node(i, a[i]));
上面用到了placement new的技巧,通過先開記憶池再去new一個node,可以降低系統分配記憶體的開銷。
區間加值:
void add_range(Treap *t, int32_t l, int32_t r, int32_t val)
{
Treap *lt, *rt;
split(t, l - 1, lt, t);
split(t, r, t, rt);
t->lazy += val;
merge(merge(lt, t), rt);
}
翻轉吧! treap
有沒有觀察到我們剛剛 build 時,key
直接放1, 2, 3…,仔細想想這樣key
的意義不就是 在treap中有幾個比他小。
那只要維護好size
就可以不用管key
了。
所以split(t, k)
的意義也就變成了:在t中切開前
size
的好處key
綁手綁腳的,當我們遇到什麼區間翻轉、區間剪下貼上,就真的直接剪下去、或轉下去(正常還是會打標)。treap,就是這麼簡單
node: (改)
struct Treap
{
static Treap mem[MAXN], *ptr;
Treap *l, *r;
int32_t pri, siz, lazy;
int32_t val;
int32_t mx;
bool rev; //翻轉標記
Treap() = default;
Treap(int32_t _val)
{
l = r = nullptr;
pri = rand();
siz = 1;
val = mx = _val;
rev = false;
}
}Treap::mem[MAXN], *Treap::ptr = Treap::mem;
split: (改)
void split(Treap *t, int32_t k, Treap *&a, Treap *&b)
{
if (!t) { a = b = nullptr; return; }
push(t);
//此節點的左子樹數量大於等於k
if (Size(t->l) >= k)
{
b = t;
push(b);
//將b指向整個樹,向左子樹處理。
split(t->l, k, a, b->l);
pull(b);
}
else
{
a = t;
push(a);
split(t->r, k - Size(t->l) - 1, a->r, b);
pull(a);
}
}
這部分的split()
比較難理解,可以畫圖看看或直接貼程式,輸出中間過程。
!翻轉其實就是左右子樹交換
練習題: luogu P3391(模板), cf 702F
資訊的圖是由 點(vertex) 邊(edge) 構成的。
圖有下面幾種:
圖論中的樹,指的是有
要求兩端點
, 使得 為樹中最大,求 , , ,
有DP、兩種dfs作法,這裡只介紹dfs。
// 沒有code啦,難道dfs也不會?
int32_t ans = 0;
int32_t dfs(int32_t now, int32_t pa)
{
int32_t MAX = 0, MAX2 = 0; //目前節點第一,二長路徑
int32_t nexDep;
for (auto next : G[now])
{
if (next == pa) continue;
nexDep = dfs(next, now);
if (nexDep > MAX)
MAX2 = MAX, MAX = nexDep;
else if (nexDep > MAX2)
MAX2 = nexDep;
}
ans = max(MAX + MAX2, ans);
return MAX + 1; //只需要回傳這個節點下最大的路徑長就好
}
給定
,有 個詢問求 的共同祖先 ,且 距離兩者皆最小。
naive解
dfs一遍,確定每個點的父親以及深度,然後從
倍增法
從剛剛的naive解我們會發現我們每次都只是往上走 1 而已,何不走大步一點呢?
如果每次我們都走2的冪次步的話,可以走到任意祖先。 (二進制的性質)
那我們就預處理每個點往上走
當詢問時,就開始跳,直到相同的點。
void dfs(int32_t now, int32_t fa = -1)
{
for (int32_t i = 1; i < 32; ++i)
{
//maintain
MX[now][i] = MX[Father[now][i - 1]][i - 1];
Father[now][i] = Father[Father[now][i - 1]][i - 1];
}
for (auto node : G[now])
{
int32_t to = node.first;
int32_t wi = node.second;
if (to == fa) continue;
//maintain
MX[to][0] = Val[now][to];
Father[to][0] = now;
depth[to] = depth[now] + 1;
dfs(to, now);
}
}
int32_t lca(int32_t u, int32_t v)
{
if (_depth[u] > _depth[v]) swap(u, v);
int res = INT_MIN;
int tmp = _depth[v] - _depth[u];
for (int32_t i = 0; tmp; ++i, tmp >>= 1)
if (tmp & 1)
{
//maintain
res = max(res, MX[v][i]);
v = _Father[v][i];
}
if (u == v) return u;
for (int32_t j = 31; j >= 0 && u != v; --j)
if (_Father[u][j] != _Father[v][j])
{
//maintain
res = max({res, MX[u][j], MX[v][j]});
u = _Father[u][j];
v = _Father[v][j];
}
//return _Father[u][0];
return max(res, MX[u][0]);
}
複雜度: build:
給定一棵樹及根,上面有
個節點,每個節點都有一顏色 ,總共有 個操作,有兩種操作:
- 將
的子樹染色成 - 詢問
的子樹裡包含幾種顏色
,
首先考慮這棵樹是一條直鍊的情況。
用位元去表示顏色,拿個資料結構實現可以查找區間OR的功能就好了。
那當這棵樹不只一條鍊的話怎麼辦?
如果點集合
i.e root = 1, root's son1 = 2, root's son2 = 3 …etc.
而你會發現如果將這序號排好,
而
至此,我們只需要一次dfs找序號,用一支援區間OR的資料結構即可解決問題。
一些樹上問題可以透過dfs序解決,當然也需要點巧思才能將問題轉化。
例如
給定一棵樹一個根,總共
個點,有 個查詢,問 是不是 的 祖先
如果只單看進入時間根本看不出來,一個不夠? 那就看兩個。
連同 離開時間 一起看。
細節自己想想,這邊不多贅述。
本章練習題: USACO 2019.12 Platinum Bessie's Snow Cow, cf 620E
定義一連通圖
為 點- -連通,滿足刪去 個 節點 能使 變得不連通。
定義一連通圖
為 邊- -連通,滿足刪去 個 邊 能使 變得不連通。
競賽中最常見的
邊雙連通
定義 橋(bridge): 若
所以只要能夠找到橋就可以判斷圖
Tarjan's Algorithm
定義 low(v):對於無向圖的節點
!上圖的節點4可以走到2,所以
可以發現當
void dfs(int32_t now, int32_t fa)
{
vis[now] = true;
low[now] = dep[now] = dep[fa] + 1;
for (auto nex : G[now])
{
if (nex == fa)
continue;
if (vis[nex])
low[now] = min(low[now], dep[nex]);
else
{
++childcnt;
dfs(nex, now);
low[now] = min(low[now], low[nex]);
}
}
if (low[now] == dep[now])
{
// edge{now, pa} is bridge unless now is root
}
}
邊雙連通分量
雙連通分量指的是圖
一般是指極大的連通子圖,也就是說無法在該子圖中添加新節點,使得新加入的子圖仍滿足雙連通。
如何找到邊雙連通分量?
void dfs(int32_t now, int32_t fa)
{
vis[now] = true;
low[now] = dep[now] = dep[fa] + 1;
st.push(now);
for (auto nex : G[now])
{
if (nex == fa)
continue;
if (vis[nex])
low[now] = min(low[now], dep[nex]);
else
{
dfs(nex, now);
low[now] = min(low[now], low[nex]);
}
}
if (low[now] == dep[now])
{
// edge{now, pa} is bridge unless now is root
while(!st.empty())
{
int32_t x = st.top(); st.pop();
bcc[x] = Nbcc;
if (x == now) break;
}
++Nbcc;
}
}
當我們找到雙連通分量後,就可以根據編號而縮點,縮完點後的圖 不會存在環,這是個好的性質,這樣的圖常常存在複雜度好的算法。
點雙連通
定義 割點 或 關節點: 若
tarjan's algorithm
沿用上面的low()定義,有一個節點
!但是root需要特判
void dfs(int32_t now, int32_t fa)
{
int childcnt = 0;
vis[now] = true;
low[now] = dep[now] = dep[fa] + 1;
for (auto nex : G[now])
{
if (nex == fa)
continue;
if (vis[nex])
low[now] = min(low[now], dep[nex]);
else
{
++childcnt;
dfs(nex, now);
low[now] = min(low[now], low[nex]);
if (low[nex] >= dep[now])
cut[now] = true;
}
}
if (now == root && childcnt <= 1)
cut[now] = false;
}
點雙連通分量
與上面的邊雙連通分量一樣,我們一般討論極大點雙連通子圖。
但是會有一個問題:
可以發現一個點可能會被分在不同的分量中,而這情況只發生在割點,因此若是我們使用上面的方法:存點,可能會把割點pop掉,但是實際上還有其他分量包含著,因此找點雙連通分量時,我們必須存邊
void dfs(int32_t now, int32_t fa)
{
int childcnt = 0;
vis[now] = true;
low[now] = dep[now] = dep[fa] + 1;
for (auto nex : G[now])
{
if (nex.node == fa)
continue;
if (!inStack[nex.edgeNum])
{
inStack[nex.node] = true;
st.push(nex.edgeNum);
}
if (!vis[nex.node])
low[now] = min(low[now], dep[nex.node])
else
{
++childcnt;
dfs(nex.node, now);
low[now] = min(low[now], low[nex.node]);
if (low[nex.node] >= dep[now])
{
cnt[now] = true;
while(!st.empty())
{
int e = st.top(); st.pop();
bcc[e] = Nbcc;
if (e == nex.edgeNum) break;
}
++Nbcc;
}
}
}
if (now == root && childcnt == 1)
cut[now] = false;
}
點雙連通分量可以建成一種特殊的樹,圓方樹,對於割點
割點為圓點,而方點為連通分量。
定義一有向圖為 弱連通,若圖上所有邊都換為無向邊後圖連通。
定義一有向圖為 強連通,對於兩個節點
都存在一條從 走到 以及一條從 到 的路徑。
void reDfs(int32_t now)
{
vis[now] = true;
for (auto nex : rG[now]) //rG為G的反圖
if (!vis[nex])
reDfs(nex);
order.push_back(now);
}
void dfs(int32_t now, int32_t s)
{
scc[now] = s;
for (auto nex : G[now])
if (scc[nex] == -1)
dfs(nex, s);
}
void Kosaraju()
{
for (int32_t i = 0; i < n; ++i)
{
if (!vis[i])
reDfs(i);
}
int32_t NScc = 0;
for (int32_t i = n - 1; i >= 0; --i)
{
int32_t now = order[i];
if (scc[now] == -1)
dfs(now, NScc++);
}
}
算是DP入門的新手技,但是有許多變體。
給定n個物品,每種物品都有重量與價值,且每種物品有無限個,你最多可以拿M單位重物品,求最大價值
for (int32_t i = 1; i <= n; ++i)
for (int32_t j = w[i]; j <= M; ++j)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
其實就只是01背包的 枚舉順序相反 而已
練習題: luogu P1616
給定n個物品,每種物品都有重量與價值,且每種物品有
個,你最多可以拿M單位重物品,求最大價值
用01背包的方式去做
每 件 物品都去跑01背包,則複雜度:
相信這應該不是好的複雜度。
二進制優化
在上面的例子中,我們的時間都浪費在重複選取上面了。
例如: 同時選第1種物品的第1, 2個 跟 第1, 3個是相同的。
那麼優化拆分方式就成了關鍵。
這裡要介紹的即是 二進制分組:
把同種類的
個物品不斷包裝成價值 、重量
可以發現因為二進制的關係,雖然那些單個單個的物品消失了,但是同樣可以選擇同樣的數量!
i.e.
但是最後記得補上沒辦法裝成2冪次的物品集,
複雜度:
for (int32_t i = 1; i <= n; ++i)
{
int32_t j = 0;
cin >> w >> v >> c;
while (c >= (1 << j))
{
V[idx] = v * (1 << j);
W[idx] = w * (1 << j);
c -= (1 << j);
++j;
++idx;
}
V[idx] = v * c;
W[idx] = w * c;
++idx;
}
練習題: luogu P1776
給定n個物品,每種物品都有重量與價值,你最多可以拿 M單位重的物品 以及 L單位價值物品,求最多可以選幾件。
其實基本思想一樣,只要再多開一維去轉移兩者就好。
for (int32_t i = 1; i <= n; ++i)
for (int32_t j = L; j >= v[i]; --j)
for (int32_t k = M; k >= w[i]; --k)
dp[j][k] = max(dp[j][k], dp[j - v[i]][k - w[i]] + 1);
練習題: luogu P1855
給定n個物品,每種物品都有重量與價值,你最多可以拿M單位重物品,求最大價值
!這題跟dp沒甚麼關係
還記得一般背包的複雜度嗎:
明顯的這裡根本不行用一般背包解。
我們應該利用
假設我們將物品分成兩組,那最優解一定是在第一組總共拿
枚舉拿第一組東西的所有情況成一集合
但是我們枚舉完第一組東西後,正在枚舉第二組時,還需要找到
我們得先把第一組枚舉情況排序好,然後剔除掉
真正的複雜度:
練習題:
旅行員推銷問題(TSP):
給定n個城市以及m個道路,保證所有城市都連通,問從起點尋遍所有城市再回到起點的最短路徑。
如果你想dfs直接硬爆找解,複雜度是
當
一般會使用位元dp去壓縮複雜度。
其實不一定要是0或1,也有3進位的狀態壓縮喔
定義dp[S][v]
為走過的集合且目前在v的位置,走完剩餘點,回到起點的最小路徑。
則轉移式為
for (int32_t i = 0; i < (1 << n); ++i)
fill(dp[i], dp[i] + n, INF);
dp[(1 << n) - 1][0] = 0;
for (int32_t S = (1 << n) - 2; S >= 0; --S)
{
for (int32_t v = 0; v < n; ++v)
for (int32_t u = 0; u < n; ++u)
if ((S & (1 << u)) == 0)
dp[S][v] = min(dp[S][v], dis(v, u) + dp[S | (1 << u)][u]);
}
ans = dp[0][0];
練習題: tioj 1468(三進位狀壓)
舉例:
"Emiliatan"是"Emiliatan Maji Tenshi"的 substring也是subsequence
"EMT"是"E miliatan M aji T enshi"的 subsequence但不是substring
有沒有可能用一個整數表達一個由小寫字母組成的字串呢?
不難想到就是把每個字符對照轉換,然後用26進位法做處理。
我們稱這種方法叫 hash,又稱 哈希、雜湊
但是我們的整數最大就是64位元,是不可能任意的轉換。
有什麼方法可以解決這樣的問題?
同餘可以處理這樣的問題。
但是在同餘下,每個數字就不會只代表一個字串。
不同的字串在hash後,代表同一數字我們稱為 碰撞
計算hash跟直接比較字串不是要花一樣的複雜度嗎?
rolling hash
假設已知
則
這種利用其他已知字串hash值求得新字串hash值的方法就是rolling hash。
(已知tan或sintan都可以用來求Emiliatan,請自己想)
處理碰撞
當你遇到兩個hash值相同的字串時,有可能他們是同一字串,也可能不是,所以要逐一比對。
但是當兩字串hash值不同,那顯然不是同字串
減少非相同字串碰撞的機會
複雜度分析
因為rolling hash的緣故,求hash的複雜度可以被均攤,但是做模運算的常數很大(如果可以,用減法代替),也有碰撞的可能發生,因此分析碰撞的機率非常重要。
碰撞的機率分析
如果出來的hash值分布是均勻的,且
但還是有方法可以構造出卡hash的測資…請小心
hash在codeforce上的注意事項
在codeforce上,程式碼都會被看光光,所以當你要用hash時就要有被同房的hack爛的心裡準備。
他可以看你使用的MOD值輕鬆做出會不斷碰撞的字串卡死你。
hack別人的練習題:zj c706
給定一字串
,詢問字串 是否為 的substring。
Ctrl + F
我開玩笑的。
暴力法
一個個字元去比對
KMP Algorithm
全名Knuth-Morris-Pratt Algorithm,學習這個演算法之前得先了解一些東西
prefix-suffix
前綴等於後綴,稱作「相同前綴後綴」。一個字串通常有許多個「相同前綴後綴」。
i.e.
abc -> {null, abc}
abcaa -> {null, a, abcaa}
abcabc -> {null, abc, abcabc}
ababa -> {null, a, aba, aba}
aaaaa -> {null, a, aa, aaa, aaaa, aaaaa}
abaabaa -> {null, a, abaa, abaabaa}
abzab -> {null, ab, abzab}
longest proper prefix-suffix
一個字串的「最長的相同前綴後綴」就是原字串,「最短的相同前綴後綴」就是空字串,「次長的相同前綴後綴」就是第二長的相同前綴後綴。
i.e.
abc -> null
abcaa -> a
abcabc -> abc
ababa -> aba
aaaaa -> aaaa
abaabaa -> abaa
abzab -> ab
failure function
窮舉法的過程當中,當前比對成功的字串片段,是 P 的前綴。因為我們無法預測是 P 的哪幾個前綴,所以我們可以預先計算 P 的每一個前綴的「次長的相同前綴後綴」,以備不時之需。
failure function 是一個字串函數:輸入字串的其中一個前綴,則輸出該前綴的「次長的相同前綴後綴」。
input: abzabc
prefix | border | failure function | implementation |
---|---|---|---|
Ø | Ø | f(Ø) = Ø | |
a | Ø | f(a) = Ø | failure[0] = -1 |
ab | Ø | f(ab) = Ø | failure[1] = -1 |
abz | Ø | f(abz) = Ø | failure[2] = -1 |
abza | a | f(abza) = a | failure[3] = 0 |
abzab | ab | f(abzab) = ab | failure[4] = 1 |
abzabc | Ø | f(abzabc) = Ø | failure[5] = -1 |
A[0…i] 的「次長的相同前綴後綴」是 A[0…failure[i]]。
failure[] 值為 -1 時,代表「次長的相同前綴後綴」為空字串。
code
void failure_fuc(string &A)
{
for (int32_t i = 1, j = failure[0] = -1; i < A.size(); ++i)
{
while (j >= 0 && A[i] != A[j + 1])
j = failure[j];
if (A[j + 1] == A[i])
++j;
failure[i] = j;
}
}
了解failure function最好的方法就是自己多試幾遍,試著在紙上寫幾個字串看看。
KMP Algorithm
終於可以講KMP的主體了。
有了failure function的幫助,我們可以很輕鬆的比對字串。
比對字串與構造failure幾乎一樣,只要遇見不符合的就一直移動。
void string_search(string &A, string &B)
{
failure_fuc(B);
for (int i = 0, j = -1; i < A.size(); ++i)
{
while (j >= 0 && A[i] != B[j + 1])
j = failure[j];
if (B[j + 1] == A[i])
++j;
if (j == B.size() - 1)
{
cout << "B starts at pos" << i - B.size() + 1 << endl;
j = failure[j];
}
}
}
下面練習題(cf 1326D2)的一點提示: 想想看failure function的定義、還有迴文顛倒後會有什麼性質?
練習題: cf 1326D2
模
i.e:
同餘
給定一正整數
模運算性質
反身性:
對稱性:
傳遞性: 若
同餘式相加/減/乘: 如同一般方程式
上述推導得:
同餘式相除: 唯有定義下列運算,同餘式相除才有意義
則
練習題: zj c686(用到最後一個性質)
任意質數
皆滿足
此公式為費馬小定理的推廣
若
則滿足
!當
設
,而 且 是質數。
有
英文稱exgcd,這大概是資訊中你唯一一個國小就會的東西,本質上就是輾轉相除法。
exgcd可以在求gcd(a, b)的同時求出滿足貝祖等式的x, y
貝祖等式: 對於
,
斐蜀定理: 對於, 有解,若且唯若 。
int32_t exgcd(int32_t a, int32_t b, int32_t& X, int32_t& Y)
{
if (b == 0)
{
X = 1, Y = 0;
return a;
}
int32_t r = exgcd(b, a % b, Y, X);
Y -= a / b * X;
return r;
}
練習題: zj e320(斐蜀定理)
已知
,求 之 。
!模逆元存在的條件是
!只要模逆元存在,即有無限多個
可以用貝祖等式證證看
exgcd求模逆元
x = (x % b + b) %b;
歐拉定理求模逆元
可用快速冪實做。
!求單個模逆元可以用上面兩個方法
遞推式模輸出對於1~p的所有模逆元。
遞推式:
推導過程:
設
則
使
兩邊同除
得
成
對於
模 的逆元 ,有
練習題: zj a289(模板題),zj e902, cf 696C
韓信點兵: 有
種數法,每次數 個人,會剩下 個人。求最少有幾個人
遞推法
我們有以下算式
可以整合成
這樣就能利用exgcd求出
至於
複雜度:
構造法
設
設
答案即是
複雜度:
!此作法容易超出整數範圍。
IOI的題目都會強制在線,不過等你當上國手再來擔心吧w
每當寫資料結構題時,有時候會需要寫一顆很複雜的結構,有沒有辦法避免呢?
實際上,操作可以離線做,每次詢問都不需要立即輸出答案,可以先把所有操作全部讀入,再來一次處理輸出答案。
這種想法可以大大避免極為複雜的資料結構。
下面將介紹幾種離線算法。
給定一長度為
的數列,有 個詢問,問區間 內眾數出現幾次? 有幾個眾數?
!可以離線
這題目乍看下是要刻一個比較特殊的線段樹,但是真的需要嗎?
首先,我們把問題reduce成
給定一長度為
的數列,有兩個索引 ,有 個操作,使 變大或變小1、使 變大或變小1,問每次操作
區間
這個問題看起來就簡單多了。
設app[x]
, cnt[c]
, maxcur
為
每次移動雙指標時,都去維護app[x], cnt[c], maxcur
,
void sub(int x)
{
--cnt[app[x]];
--app[x];
++cnt[app[x]];
if (cnt[maxcur] == 0)
--maxcur;
}
void add(int x)
{
--cnt[app[x]];
++app[x];
++cnt[app[x]];
maxcur = max(maxcur, app[x]);
}
//~~
while(q--)
{
//~~
if (op == "L l")
add(arr[--l]); //左指標左移
else if (op == "L r")
sub(arr[l++]); //左指標右移
else if (op == "R l")
sub(arr[r--]); //右指標左移
else if (op == "R r")
add(arr[++r]); //右指標右移
cout << maxcur << ' ' << cnt[maxcur] << '\n';
}
這樣的複雜度是
但是這個問題跟原始問題比起來,差在一個 移動是連續的;一個是 左界右界亂跳。
那我們不妨讓原始題目的詢問左右界排序成連續的樣子。
依照左界大小將詢問排序,再依照剛剛的做法去做,但是這樣會有一個問題
右界怎麼辦?
如果有很多筆操作,左界都相同,但是右界差距極大,這樣複雜度最差是
有沒有一種辦法去同時把左界排序又能維護好右界遞增呢?
有,但是左右界都需要犧牲一點單調性。
利用分塊的思想,將
複雜度分析:
因為分塊的特性,在同一塊的左界不會移動超過
對於右界因為
取
奇偶優化:
每一塊的
實做判斷區塊是否為偶數進行遞增或遞減排序。
code:
因為有點長,所以貼連結: Mo's alogrithm
premature optimization is the root of all evil
#pragma GCC optimize("Ofast,unroll-loops,no-stack-protector,fast-math")
#pragma GCC optimize("O3")
選訓時如果我會bitset就能多拿好多分數了…
計算幾何是競賽中很少出現,但是一旦出現高機率是難題。
通常牽涉到很多數學,i.e. 向量
請至少具備高中的數學知識而且擁有把圖形座標化的技巧
外積、斜率、內積、行列式等等…
實作:
bool cmp(point& a,point& b)
{
return atan2(p1.y, p1.x) < atan2(p2.y, p2.x);
}
double cross(Point& a, Point& b)
{
return a.x * b.y - a.y * b.x;
}
bool cmp(Point& a, Point& b)
{
if (a.y == 0 && b.y == 0 && a.x * b.x <= 0) return a.x > b.x;
if (a.y == 0 && a.x >= 0 && b.y != 0) return true;
if (b.y == 0 && b.x >= 0 && a.y != 0) return false;
if (a.y * b.y < 0) return a.y > b.y;
double c = cross(a, b);
return c > 0 || c == 0 && fabs(a.x) < fabs(b.x);
}
atan2()有精度問題,cross版寫起來又繁瑣。
給定一多邊形的頂點座標
,求此多邊形面積?
一個很簡單的公式:
其中
!注意這個公式必須先讓頂點座標呈 順時針或逆時針順序,且無論凸的多邊形或是凹的多邊形都可以用這個公式。
給定點集
,求能夠包覆住所有點的最小凸多邊形。
在這裡「凸」的定義為,多邊形內任意兩點連線不經過圖形外部。
Andrew's Monotone Chain
如果我們觀察下凸包會發現他的斜率是遞增的,所以我們可以先將點集
最左邊必然為凸包上一點,所以可以先將它加入一個容器。
我們現在要維護這個容器裡的點之斜率單調性,也就是說
每次加入一個點,若剛剛的點不滿足上面的條件,就一直pop這個容器的head,
直到上述條件滿足為止。
看圖比較好理解。
初始狀態
檢查第三點的正確性:
檢查第四點: 發現
檢查第五點:
檢查第六點: 發現
這樣我們就找到了下凸包。
上凸包一樣可以用此策略去判斷,但須稍做修改,例如斜率單調的判斷,細節留給你們思考。
確定template有沒有寫對時,可以構造一個四個點的正方形當簡單的測資
練習題: luogu P2742
給定點集
,求 中最近的兩個點
暴力法
任意的窮舉兩點
再優化
先讓點依照x軸排序,這樣就可以剪枝
在點是隨機的情況下,我們可以相信,每一次枚舉第二點,期望上都可以排除
因此我們有 平均複雜度:
實際比賽上,這種作法通常會有特殊測資讓你TLE,不過我們可以先random旋轉整張圖片,讓這個作法也能AC。
分治法 (更詳細可以參考演算法筆記)
我們可以畫一條把整張圖切成兩半,然後把答案分成「最近點對在左側」、「最近點對在右側」、「最近點對橫跨兩側」三種情形。先將左側與右側分別遞迴求解之後,再利用左側與右側的答案,快速算出橫跨兩側的答案。
Preprocessing:
一、排序所有點,以
二、檢查是否有共點。如果有,就找出所有共點,演算法結束。
Divide:
把所有點分為左右兩側,左側、右側的點數盡量一樣多。
Conquer:
左側、右側分別遞迴求解。
Combine:
利用左側、右側的最佳解
一、首先找出靠近中線的點,與中線的距離
(小於d、不包含d,則只找出其中一組最近點對。)
二、排序這些點,Y座標為主,X座標為輔。O(NlogN)
三、每一個點都找其上方鄰近的點,看看能不能形成最近點對。
只需檢查至後三點。O(N⋅3) = O(N)
答案為左側、右側、橫跨兩側,三者當中的最佳解。
至於為何是檢查後三點呢?就留給讀者自行思考
給定點集
,求 中最遠的兩個點
旋轉卡尺
很顯然的,最遠點對一定在凸包上,然後呢?
如果距離A點最遠的點是D,那顯而易見的,距離B最遠的點就會是D,E或F。
你可以把最遠點對的選擇想像成一個two pointers然後,他們會順時針或逆時針的轉。
那我們就可以枚舉其中一個pointer,然後另一個pointer只要跟著轉就可以在O(n)下找到凸包的最遠點對。
另一個pointer要不要轉?
最簡單的作法就是直接算出距離來決定要不要轉。但這樣常數有點大。
所以可以用向量的作法
這很好想,如果D到A的距離大於E到A,那依照底乘以高這個公式可以觀察到
因此只要用向量算兩三角形的面積就能決定要不要轉了。
在這裡感謝ioic的講義,以及所有我問過的大神、找過資料的blog、OI wiki、還有一堆題目的出題者。
一開始僅有的第一行我還記得一清二楚,"嘉中資訊講義",一直到這裡已經增加到1370行了。(4.14.20更新: 1534行) (4.15.20更新: 1688行)
希望後人們都可以靠這份講義上1!、2!、甚至國手,looking forward to the day.