算法 {石子合并}

算法 {石子合并}
@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;

算法

區間DP

代碼
{ // 石子合併
    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} 这个新堆 放到原石堆的哪个地方都可以;

性質

算法

貪心選擇Heap最小值

性質

該問題 等價於 哈夫曼樹問題; 最終{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划分;

你可能感兴趣的:(算法,算法)