算法 {石子合并}
@LOC: 3
詳見@LINK: https://www.acwing.com/problem/content/description/284/
;
即每次選擇相鄰的兩個石子 進行合併(此次操作的代價是兩個石子的個數之和), 求最終總代價的最小值;
石子合併(不相鄰) 這個問題和哈夫曼樹是等價的, 換句話說 你在石子合併操作 其實本質上 你是在給每個石子進行編碼 前綴編碼;
而石子合併(相鄰) 其實某種含義上講 他也是對應了一個哈夫曼樹 (哈夫曼樹是編碼 + 最小權值 我們這裡不考慮他的權值問題 只是談他的編碼), 即你的石子合併 對應一個編碼樹, 即 每個石子 也是得到了一個前綴編碼;
比如[ ((a,b),c), (d,e)]
是最小的石子合併方案, 那麼對應為編碼是H(a)=000, H(b)=001, H(c)=01, H(d)=10, H(e)=11
, 但他和哈夫曼樹(即上面石子合併(不相鄰))的區別在哪呢? 區別在於 此時你的編碼 對於石堆Ai, Aj (i
H(Ai) < H(Aj)
(這裡的小於是 字典序), 比如上面000 < 001 < 01 < 10 < 11
; 而哈夫曼樹 他的編碼 並不符合字典序, 即不一定有H(a) < H(b) < H(c) < H(d) < H(e)
, 因為哈夫曼樹的石子合併 他可以是 [(a,e) [(b,d) c]]
, 區間DP的石子合併 他是有序的, 因此符合字典序;
例题: @LINK: @LOC_2
;
{ // 石子合併
using ___ValType_ = ?;
constexpr int ___MaxN = ?;
static ___ValType_ ___DP[ ___MaxN][ ___MaxN];
static ___ValType_ ___Sum[ ___MaxN]; // 石子的前綴和
const int ___N = ?; // 所有元素的範圍是`[0, N)`;
ASSERT_( ___N <= ___MaxN);
for( int i = 0; i < ___N; ++i){
___Sum[i] = ?[i]; // 第`i`個石子的權值
if( i > 0){ ___Sum[i] += ___Sum[i-1];}
}
for( int len = 1; len <= ___N; ++len){
for( int lef = 0; lef+len <= ___N; ++lef){
int rig = lef + len - 1;
auto & curDP = ___DP[ lef][ rig];
if( len == 1){
curDP = 0;
continue;
}
else if( len == 2){
curDP = (___Sum[rig] - (lef==0 ? 0:___Sum[lef-1]));
continue;
}
for( int split = lef; split < rig; ++split){ // 將當前的`[lef,rig]`分割為: `[lef...split]和[split+1...rig]`;
auto preDP= ___DP[ lef][ split] + ___DP[ split+1][ rig];
preDP += (___Sum[rig] - (lef==0 ? 0:___Sum[lef-1])); // 將`[lef...split]和`[split+1...rig]`兩個區間 *合併*為一個 所需要的代價;
if( split == lef){ curDP = preDP;}
else{ curDP = min( curDP, preDP);}
}
}
}
} // 石子合併
@MARK: @LOC_2
;
@LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=134464433
;
石子合并(相邻) 他可以对应一个编码树, 即每个石堆 都对应了一个K进制的编码序列, 而且有个性质: 石堆Ai, Aj (i
H(Ai) < H(Aj)
(这里的小于 是字典序);
@MARK: @LOC_0
;
N個石堆 每個石堆權值為Ai
, 給定整數K>=2
, 一次操作定義為[選擇任意的 <=K
個石堆 將他合併為一個] 該操作的代價為[這些石堆的權值之和], 求將所有石堆合併為一堆的最小代價;
,
这N个石堆 不是序列 而是集合, 即必须你选择合并{a1,a5,a7}
这个新堆 放到原石堆的哪个地方都可以;
該問題 等價於 哈夫曼樹問題; 最終{Ai}
都是單獨的點, 你選擇若干個{a,b,c}
進行合併 就相當於是 創建了一個這些點的父節點d
, 連接d-a, d-b, d-c
邊 然後Val[d] = Val[a]+Val[b]+Val[c]
;
即對於{Ai}
就是哈夫曼樹的葉子節點上, 然後樹上的所有的非葉子節點的Val
之和 就等於 該問題的石子合併的總代價;
很容易想到一個貪心策略: #放到Heap裡, 每次選擇K
個(如果Heap元素不夠K個 則選擇全部元素)最小的;#
這其實是錯誤的; @MARK: @LOC_1
;
比如K=3
, 石堆為{1,1,1,1}
, 第一次得到{3,1}
代價為3, 第二次得到{4}
代價為4, 總代價為7; 而答案是 第一次得到{2,1,1}
代價是2 第二次代價是4 總代價是6;
錯誤的原因在於 你最初得到的那個7權值的哈夫曼樹 他的根節點是有2個兒子 沒有滿 因為K=3
, 而正確答案 相當於是對石堆{0,1,1,1,1}
進行貪心算法, 即第一次合併0,1,1
第二合併2,1,1
此時這個哈夫曼樹 是完整K叉樹(即每個非葉子節點的兒子個數 都是K);
先開宗明義說出正確思路: 將{Ai}
補充特定個0元素 變成{Ai, 0...0}
, 你此時對他進行上面的貪心算法後 得到的哈夫曼樹 是完整K叉樹;
因此 具體要補充多少個0元素呢? 就取決於 完整K叉樹的葉子節點個數 有什麼規律; 完整K叉樹的葉子節點個數 是有一個公式 即(K-1) * x + 1
(其中x = 0/1/2/...
);
.
比如上面的樣例 K=3
, 此時K叉樹的葉子節點個數為{1, 3, 5, 7, 9, ...}
, 而樣例中 有4個點 並不符合要求(即不存在葉子節點個數為4的完整3叉樹), 因此我們需要補充1個0 使得其變成5;
.
為什麼這樣補充點 使得其變成完整K叉樹 就是答案呢? 這裡簡單的說下, 節點深度越大 他對答案的貢獻就越大, 深度越小 他對答案的貢獻越小 (因為一個葉子節點 他對答案的貢獻等於 Vi(石堆權值) * x
(其中x
為該葉子節點的父節點個數), 如果最終不是完整K叉樹 那麼由於你是貪心 會導致空位在上面 (即一個元素 他放到上面 對答案貢獻小 而你卻把他放到了下面), 當補充點後 空位由0替代了 由於Ai>0
的 所以最初會讓0
最先頂替在最下面 即元素都上浮了 而上面會對答案的貢獻小;
{ // 石子合併-非相鄰
using ___ValType_ = ?;
const int ___N = ?; // 石堆個數 (且每個石堆個數 必須`>0`);
const int ___K = ?; // 每次合併的最大個數
ASSERT_( ___N>0 && ___K>1);
priority_queue<___ValType_, vector<___ValType_>, greater<___ValType_> > ___Heap;
for( a : N個石堆){
ASSERT_( a > 0); // 石堆必須`>0`;
___Heap.push( a);
}
{ // `N`得到的哈夫曼樹 可能不是*完整K叉樹* 這就出錯了; 將他填補0 使其得到的哈夫曼樹 一定是*完整K叉樹*;
auto newN = (___N-1 + ___K-2)/(___K-1)*(___K-1) + 1; // 完整K叉樹的節點個數
for( auto i = ___N; i != newN; ++i){ ___Heap.push( 0);}
}
___ValType_ ___ANS = 0; // 最小的總代價;
while( ___Heap.size() >= 2){
___ValType_ sum = 0;
for( int i = 1; i<=___K; ++i){ // 因為是*完整K叉樹* 此時`Heap`的元素個數 一定`>=K`;
sum += ___Heap.top(); ___Heap.pop();
}
___Heap.push( sum); ___ANS += sum;
}
} // 石子合併-非相鄰
@LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=134463833
;
原问题的逆过程 就是石子合并模板;
序列: A[0, 1, ..., n-1]
, A[i]
表示一个石堆 他的价值为A[i]
, 每次选择2个相邻的石堆A[i], A[i+1]
合并的代价为A[i]+A[j]
, 合并之后的新石堆价值为A[i]+A[i+1]
;
.
比如: A: [0, 1, 2, 3, 4]
, 合并{2,3}
(代价为5
), 此时A序列为: [0, 1, 5, 4]
;
求将这n
个石堆 合并为1个石堆, 最小的代价;
状态节点st: (i,j) `i<=j`
. 表示区间`A[i,...,j]`;
状态节点st的任意方案: 将`A[st.i, ..., st.j]`区间合并为1堆 的操作集合;
. 比如`A[0,1,2,3]` 他一定需要操作3次, 一种可能的方案是: [合并[0,1], 合并[2,3], 合并[0,3]];
. 按照区间长度排序, 序列的最后元素 一定是`合并[i,j]`;
方案序列Sol的权值|Sol|: 序列里每个操作的代价的 总和;
. 一个操作的代价为: 比如`合并[i,j]`操作, 代价为`A[i]+...+A[j]`;
状态节点st的所有Sol所组成的集合Set(st);
方案序列的集合S的 DP值(|Set|): `\forall x \in S, max{ |x|}`;
Set(st)的划分子集合 (`st.i < st.j - 1`): 因为Set(st)的所有方案 的最后元素 都是`合并[st.i, st.j]`, 将他去掉 其实他一定是`2`个石堆
. 这2个石堆 有`j-i`种可能 即划分: [ [i,i]与[i+1,j], [i,i+1]与[i+2,j], ..., [i,j-1]与[j,j]];
. 对于任意一种划分 Si: [i,k]与[k+1,j],有: Set(i,k)合并Set(k+1,j) 再加上一个`合并[i,j]`操作, 就等于S1划分;
. 换句话说, Set(i,k)里的任意方案[a...] 加上 Set(k+1, j)里的任意方案[b...] 加上 `合并[i,j]` (即这3个组成一个方案序列), 然后再sort排序, 就等价于st的S1划分;