算法 {第K排序的子序列和}

算法 {第K排序的子序列和}
@LOC: 3

图片引用

图片一

在这里插入图片描述
防止图片没有:

数组[A, B, C], 0为空集,   方式一二为两个二叉树

方式一:
0--0--0--0
       --C
      B--B
       --BC
   A--A--A
       --AC
      AB-AB
       --ABC 

方式二:
A--B--C
    --BC
   AB-AC
    --ABC

第K排序的子序列和

给定长度为N的数组A N = 1e5, 求 第K(小或大) 的(非空)子序列和 K = 5000

比如, A = [-5, -3, 1]
其所有(非空子序列和), 排序为: -8, -7, -5, -4, -3, -2, 1

由于N很大, 其子序列有2^N个;
但是, K = 5000很小, 因此我们只需得到 (前K排名的) 所有子序列和即可; (这个本题关键)

如果K很大, 是2^N量级; 此时, 应该是 排序组合问题;
而这里, 由于K很小, 是通过直接枚举前K小的方式


要么是求(第K小), 要么是(第K大); 两种情况单独考虑


第K小排序

将原数组A, 进行从小到大排序; (下标从A[1]开始)
令前K小的排序是: th1 <= th2 <= th3 <= ... (th是英文的意思)


首先看一个误区, 因为子序列 就是 原数组的元素 (选与不选) 的两种可能;
M = log( K), 则, 前K小的(子序列), 一定用的是前M小的元素吗?(即前M小的元素的全排列)? 错误的
比如: A = [1, 1, 1, ...., 1],N= 100, K = 50 那么, 第K小的子序列, 是第A[K]元素;

再看第二个误区, 所有的 第<= K小数, 一定只会用到A[<=K]的元素吗? 错误的
… (很容易认为他是正确的, 其实是错的, 因为有负数; 如果A都是>=0的, 则是成立的, 下面会提到)
A = -5, -4, -3, -2, -1
第1小数: (-5,-4,-3,-2,-1), 用到了A[5]
第2小数: (-5,-4,-3,-2), 用到了A[4]
第3小数: (-5,-4,-3,-1), 用到了A[5]

这两个误区, 一旦判断错误, 就引起致命错误


做法1–二分

求第K(小/大)数, 都是可以用二分的;

所有子序列的排序是: -2, 0, 1, 2, 2, 2, 3 (K = 5, 第K大数是2), 即答案是ans = 2
令, calc( x)为: < x的个数
calc( < ans) < K
calc( = ans) < K
calc( > ans) >= K
满足, 布尔二段性

此时考虑, 如何实现calc( x)函数:
… 我们采用一个个遍历累加的方式, 好像也没有更好的方法
… 即, 遍历所有的子序列, 找到所有 子序列和是 < x 的个数.
< x的个数, 是2^N级别的; 貌似, 一个个累加的方式 也不行…
但是, 我们从上面的布尔二段性可以看出, 如果calc的值是>= K的, 我们让calc返回K, 也是可以的!!

一定不要单独去考虑calc, 而是要 结合他的用途, 他唯一的用途, 就是在布尔二段性

因此, 如果calc函数, 可以累加到K, 就可以return了, 返回一个K 和返回> K的数, 是一样的

此时calc( x)的返回值确定了 <= K, 即判断是否可以找到 K< x 的 子序列和;
但是, 如何让calc函数, 每次遍历到的 都是< x的 子序列; 因为子序列的个数 是2^N量级的, 肯定不能让他暴力遍历所有子序列

那么, 如何剪枝呢?

[2, 3]< 9的子序列; 当前是(2) 合法, 计数一次; 还要往后, (2 3)也是合法的;
即: 当sum是合法时, 要往后走, 可能还有答案


可是, 当前数组是(含有负数)的, [-5, -4] 要找< -8的子序列
当前在-5, 他是> -8的 (即非法的), 但是不可以return; 应该继续找; 后面的-5 + -4是合法的

即, 当sum >= -8时(非法), 也要继续往后找;
而且, 这里, 更重要的是: 他此时是非法的, 要往后找, 往后找 (不一定有合法的), 即: 可能有合法的, 可能没有
比如, 上面这种情况, 就是(当前非法, 但后面有合法的)
但比如: [-1, -1, -1, -1, -1, ....]< -100的; 一共是(99个-1)
那么, 你在(前100)个元素里, 不停的枚举(选与不选), 其实已经超时了2^99;
然而, 最后一个答案没找到, 但你还必须这样做;

因此, 看到这里, 你就应该放弃这种想法; 这个DFS, 一定是超时的, 不管怎么优化
因为, 当前是(非法的), 还要(往后找); 这个DFS策略是一定是指数级别的!


转变思路

从上面的二分分析中, 可以知道: 对于一个(含有负数)的数组A, 求他的第K小数, 用二分是无法处理的(其实用其他算法也是处理不了的)
原因在于: 这个数组, 即便对他排序后, 他的前缀和 并不是单调的; 而是(先递减, 后递增)

这个(前缀和)的概念, 很重要; 因为, 我们要遍历所有的子序列; 不管dfs, 还是用队列等数据结构的方式, 都肯定是最初有一个(起点)
然后, 以这个起点 不断的往后扩张; 肯定不能无限往后扩张, 而是到一定程度就停止 (决定这个停止, 就是: 单调性)


对于数组A, [-4, -1, 0, 2], 记: N为数组A的长度, PA为A的所有全排列集合大小为2^N, MIN( S, K) 表示 S集合中, 第K小的元素
NEG为: A中所有负数之和 (如果没有负数, 为0)

构造辅助数组B: [4, 1, 0, 2], (即, 对数组A进行绝对值操作后的数组)
PB为B的所有全排列集合大小为2^N

则有: PA集合 等于 { N E G + x ∣ x ∈ P B } { \{ NEG + x | x \in PB \} } {NEG+xxPB}

证明: 假如NEG 为0 (即A中没有负数), 此时: B = A, PA 等于 PB, NEG + x = x, 因此: { N E G + x ∣ x ∈ P B } { \{ NEG + x | x \in PB \} } {NEG+xxPB} 等于 PB 等于 PA
否则, NEG 不为 0, 首先, 这两个集合的大小是一样的; 因为AB数组大小一样; 关键是要证明: 两个集合是双射
NEG = -4 + -1,
假如x中, 不含有 (原先的负数), 即x = {空, 0, 2, 0和2}
… 此时, NEG + x为: PA中, 所有: 包含(所有负数)的(全排列), 即形如-4和-1和...
否则, x中 含有(原先的负数), 即x = {4, 1, 4和1, 4和0, 4和2, 1和0, 1和2, ...}
… 此时, NEG + x中 假如两者都含有(原先的负数Y), 则在NEG中 为-Y, 在x中 为Y, 两者就抵消了; 比如 当x = 4和2 , 则NEG + x = (-4 + -1) + (4 + 2) = -1 + 2
… 因此, 他所代表是: PA中, 所有: 不包含 (所有负数)的全排列, 比如-4和2 -1和2 0和2

因此, NEG + x 等于 PA;


那么, 我们要求的是MIN( PA, K); 由于NEG是常数, 因此: MIN( PA, K) = MIN( NEG + x, K) = NEG + MIN( PB, K)


此时, 就转换为: 求MIN( PB, K) 的问题; 原先是求MIN( PA, K)
两者的区别在于: PB是没有负数的, 因为B数组没有负数;


即, 此时忘记 原先的数组A, 只关注数组B;

即给定一个>= 0的数组, 求其全排列中, 第K小的数;

改进二分

此时, 再次回到刚才走到死胡同的二分;

calc( x)表示: 所有全排列中< x的个数;

此时的dfs函数:
(如果当前sum是非法的, 即sum >= x, 就一定可以return; 因为后面的数, 都是>= 0的; 不会出现, 后面再把sum减小的情况)

(否则当前sum是合法的, 即sum < x, 计数一个; 继续往后走; 因为后面可能sum + k 依然是 < x的)


先看一个超时的代码:

Ll_ Algo_3( vector< int> & _posi, int _k){ //< posi都是>=0的
    sort( _posi.begin(), _posi.end());
    int n = _posi.size();
    Ll_ l = 0, r = accumulate( _posi.begin(), _posi.end(), 0LL);
    auto calc = [&]( Ll_ _mid){
        int dfs_ans = 0;
        Ll_ dfs_limit = _mid;
        function<void(int, Ll_)> dfs = [&]( int _ind, Ll_ _sum) -> void{ //< 此时, _sum一定是 < dfs_limit的
            if( _ind >= n){
                return;
            }
            dfs( _ind + 1, _sum);
            if( _sum + _posi[ _ind] < dfs_limit){
                ++ dfs_ans;
                if( dfs_ans >= _k){ return;}
                dfs( _ind + 1, _sum + _posi[ _ind]);
            }
        };
        if( 0 < dfs_limit){ //< 空集(全不选), 特判
            ++ dfs_ans;
        }
        if( 0 < dfs_limit){ //< dfs找的所有子序列一定是>=0的
        //< 如果不特判, 不满足: dfs所要求的: 进入到dfs时, 一定有: _sum < dfs_limit; 
	        dfs( 0, 0);
        }
        return dfs_ans;
    };
    while( l < r){
        Ll_ mid = (l + r + 1) >> 1;
        //--
        if( calc( mid) < _k){
            l = mid;
        }
        else{
            r = mid - 1;
        }
    }
    return r;
}

其他都正确, 关键是看: calc函数 和 dfs函数

对于(全不选)的情况, 要特判, 别在dfs里考虑他; (否则dfs会比较复杂), 这样: 把(全不选)的情况特判掉, 则dfs的所有序列, 都不是空的!
比如, 一个序列是: [a, b, c], 那么, 就在: dfs遍历到c时, 计算这序列;
即, DFS到 (一个序列的 末尾元素), 就表示: 以该元素为末尾的 子序列; 由于(全不选) 是一个空序列, 他没有末尾元素, 所以要特判;

进入到dfs后, 此时的sum 一定是< dfs_limit, 表示: 前面的A[0, ..., ind-1]这些元素的 (全排列)
如果不选当前元素, 则继续往后走;
否则, 如果选当前元素, 则(前提是: sum + 当前元素 是合法的), 只有这样, 他是合法的, 计数一次, 然后继续往后走;

这个dfs思路, 中规中矩, 也确实是正确的; 但只会通过70%数据, 思路正确 但会超时;

这个dfs, 还可以优化, 也就是(剪枝);
剪枝, 是DFS的核心核心; 因为DFS本身非常简单, 大多人都会; 而区别每个人对DFS领悟水平的分水岭, 就是(剪枝)

我们前面讲过, 我们这个DFS函数 要达到的效果是: (每次的DFS, 都应该获得一个合法的子序列, 因为K很小 而N很大, 即DFS的量级 要和O(K)是同量级的; )
这是可以做到的, 因为如果当前DFS所代表的子序列, 已经非法了, 就没有必要继续往后 (即不会遍历非法的子序列)
但是, 我们只考虑了: 选择当前元素的子序列, 忽略了, 容易忘记的 (不选当前元素的情况)
… 选了当前元素 如果sum + cur非法了, 自然不会往后走; 但是, 由于sum是合法的, 那么不选当前元素, 一定是(合法)的
… 那么, 对于(不选当前元素)的情况, 就应该 (一直往后走)吗???

当然, 一直往后走也没有错, 你遍历了所有的2^N个子序列 都没有错, 只要得到答案即可;
但是, 这就超时了;

这确实是(DFS剪枝)的难点;
如果sum + cur是非法的, 你(不选当前元素), 继续往后走, 往后走干嘛呢? 不就是要选择后面的元素嘛;
你在后面选择的: sum + nex, 由于nex >= cur, 也一定是非法的;
分析DFS, 尤其是DFS剪枝, 要非常的仔细认真; 因为一旦DFS超时, 一般都会认为: 是算法的问题, 而不是自身代码的问题, 于是去选择更换其他的算法
很难去往: DFS的具体实现代码, 具体策略 上深度思考

否则, 对于A=[2, 2, 2, ...., 2] (N=1e9), (K=1e5) dfs_limit为2
DFS在A[1]时, 此时0 + A[1] >= dfs_limit 一定是非法的了, 即dfs( 2, 0 + 2)肯定不会执行;
但是, 你会执行dfs( 2, 0), dfs( 3, 0), 即所有的dfs( 1/2/3/4/5/.../N, 0)
这已经不是O(K)级别的了; 已经是O(N)级别了


正确代码:

function<void(int, Ll_)> dfs = [&]( int _ind, Ll_ _sum) -> void{ //< 此时, _sum一定是 < dfs_limit的
    if( _ind >= n){
        return;
    }
    // dfs( _ind + 1, _sum);
    if( _sum + _posi[ _ind] < dfs_limit){
        ++ dfs_ans;
        if( dfs_ans >= _k){ return;}
        //--
        dfs( _ind + 1, _sum);
        dfs( _ind + 1, _sum + _posi[ _ind]);
    }
};

只需将dfs( _ind + 1, _sum); 换个位置即可;
但其意义非凡, 效果是截然不同; 这就是(DFS剪枝)


此时对于数组A全是(非负数), DFS不仅可以求MIN(PA, K), 也可以求MAX(PA, K), 具体你考虑下;
求MIN, dfs( k, sum)是表示: 前(k-1)个元素(选与不选) 且 后面的(N-k+1)元素 一定不选 的集合 的元素之和为sum dfs( 0, 0)是起点
而在求MAX时: dfs( k, sum)是表示: 前(k-1)个元素(选与不选) 且 后面的(N-k+1)元素 一定选 的集合 的元素之和为sum dfs( 0, A数组之和)是起点


DFS的时间是O(K)的, 可参见

做法2–二路归并

对于数组A(可以含有负数), 他的全排列集合PA, 要求MIN(PA, K)
直觉想法是: 遍历所有的PA里的元素, 然后找第K小的数;

而(遍历PA), 也就是遍历(A的全排列), 这个过程, 当然有很多方式, 我们这里看一个比较常见的方式

A = [a, b, c]
假设我们是一个下标, 从左往右走; (多从计算机的角度去思考问题的解决)
最初, 有一个初始元素: 0(空集)
a时, 有0, a
b时, 有0, ab, ab
c时, 有0, a, b, abc, ac, bc, abc

可以发现, 到了最后c时, 得到了2^3个元素, 即得到了 所有的全排列;

有几点:
1, 当在ind下标位置时, 我们应该得到 前ind个元素的 全排列, 即得到2^ind个元素
2, 可以发现, 我在上面写时, 故意每个集合 我都分了左右两个集合; 左侧集合 是在(前一个元素)时, 所已经得到了的 集合
… 那么, 如何获得右侧集合呢?
… 右侧集合 是通过: 往左侧集合里, 每个元素里 添加一个c 得到的;
… 这并不是规则, 也不是技巧; 比如在K位置, 我们要得到2^K个元素(即前K个元素的全排列)
… 这2^K个元素, 分为两类:
… … 一类不带有当前A[K]元素 (有2^(K-1)个, 该集合 等于 在K-1位置时的集合, 即前K-1个元素的全排列)
… … 另一类, 带有当前A[K]元素 (有2^(K-1)个, 该集合 等于 往前K-1个元素的全排列里的 每个元素都加上一个A[K]元素) 这确实很重要的一个知识点


以下讨论, A数组, 都是没有排序的, 而且可以含有负数

即在K下标时, 做的工作是: 令Pre为是在K-1位置得到的序列(长度为2^(k-1)), Cur为在Pre序列里 每个元素里 加入A[K] 的序列(长度也是2^(k-1))
我们需要得到一个Ans序列, 他很简单 就是Pre + Cur; (该Ans序列, 也是K+1元素的Pre序列)

由于我们要求(次序), 能否使得Ans是有序的呢? (这样, 到了最后元素, Ans就是所有元素的全排列, 即答案要求的, 直接根据Ans就得到第K小数)

在最初Pre是单调的 (Pre = {0}), 因此, Cur = {A[1]}也是单调的;
那么, 通过线性的 归并排序 (双指针), 也可以使得 Ans也是单调的!

由于Ans序列长2^N, 肯定维护不了; 我们有必要维护这么多元素吗?
最终, 答案是要 (第K小), 只会用到Ans序列中的 前K个元素;

证明: 在每一个下标位置时, 假如Ans超过了K长度, 我们只需保留最小的前面K个元素即可;
… 在任意下标位置时 得到的Ans序列, 一定是 (最终答案Ans序列) 的 子集 (性质1)
… 因此, 当前Ans序列中的 第> K小数, 在 最终答案Ans序列中, 一定不会是 前K小数!
… (这与A是否有序无关, 我们这里讨论时, A是没有排序的 也可以含有负数), 因为上面的(性质1), 与这些都无关;

因此, 每次: Pre是长度K的, Cur是长度K的, 让Ans也是长度为K的
即, 在每个下标位置处, 进行 (二路归并)

最初Pre, Cur的长度不是K, 而是(1, 2, 4, 8, 16, …, K, K, …, K), 对于最初< K长度时, 如何处理呢?
因为, 归并操作时, 是认为Pre和Cur都是长度为K, 然后找K个最小的元素; 当Pre,Cur长度很小时, 两个加起来 都没有K个元素, 此时Ans长度< K
… 一种处理是: 用vector, 变长
… 一种处理是: 最初 将Pre里(空位) 都设置为 非法数; 比如, A=[-1e9, 1e9], N=1e9, 求第K1e5小的子序列和; (答案第K小数, 一定是在[-1e9, 1e9]之间的)
… … 空位肯定不能影响答案, 答案要求小的; 所以, 空位要设置为大的数; 将(空位)设置为(2e9)这是错误的!
… … 比如A都是-1e9; 空位最初是2e9, 然后是1e9, 然后是0, 然后是-1e9 (空位, 是会反复参与运算的);
… … 空位会影响结果, 即: 他一定是 变小了(因为最初是2e9, 而答案是[-1e9, 1e9]范围), 变小, 就是 + 负数; 要保证, 他加上负数后 不会影响
… … 当到logK = 20位置时, Ans序列就达到K长度了; 此时就没有空位了, 即要保证: (前面空位运算的结果, 一定不会在此时的长度为K的Ans序列里)
… … 空位设置为: 1e9 * 21, 这样, 即使经过K次, -= K * (-1e9), 他还剩下1e9, 不会影响答案;

这里多提到一点, 因为我们知道Ans的长度是: (1, 2, 4, 8, 16, ..., K, K, .., K)
你不可以当(Ans长度) 一旦达到 K, 就return, 认为此时Ans[K]就是答案了; 这是错的, 必须让外层循环N次, 不能中途return
因为, 这是不断更新的过程, Ans[1, …, K]都会不断的被更新;
而Ans[1, …, K]都可能会涉及到A[ N], 你此时return, 还没有遍历到A[N]呢

Ll_ pre[ 2001], cur[ 2001], ans[ 2001];
Ll_ Algo_2( vector< int> & A, int K){ //< A不需要排序!
    //--
    int n = A.size();
    for( int i = 0; i < K; ++i){
        pre[ i] = Ll_( 1e11);
    }
    pre[ 0] = 0; //< no chosen
    for( int i = 0; i < n; ++i){
        for( int j = 0; j < K; ++j){
            cur[ j] = pre[ j] + A[ i];
        }
        //{ merge_sort
        int ans_ind = 0;
        int pre_ind = 0, cur_ind = 0;
        while( ans_ind < K){
            if( pre_ind >= K){
                ans[ ans_ind ++] = cur[ cur_ind ++];
            }
            else if( cur_ind >= K){
                ans[ ans_ind ++] = pre[ pre_ind ++];
            }
            else{
                if( pre[ pre_ind] >= cur[ cur_ind]){
                    ans[ ans_ind ++] = pre[ pre_ind ++];
                }
                else{
                    ans[ ans_ind ++] = cur[ cur_ind ++];
                }
            }
        }
        for( int j = 0; j < K; ++j){
            pre[ j] = ans[ j];
        }
        //}
    }
    return ans[ K - 1];
}

在本题中, N=1e5, K=2000; 时间是: N * K = 2e8 是会超时的… 实际是: N * (3K)


此时, 我们对B数组进行排序, 然后外层的N循环, 可以变成K吗?
即: MIN( PB, <=K) 一定只包含 B里的前K个元素
… 这是错误的: [-1, -1, ..., -1] 全是负数, 最小数 是涉及所有K个元素的!
同样, 如果是: MAX( PB, <=K) 也不是只包含 B里的前K个元素
… 比如, [1, 1, 1, ...., 1] 全是整数, 最大数 也是涉及所有K个元素的

优化

这个优化的本质, 也是要依据 该性质,
因为, MIN(PA, K) = NEG + MIN( PB, K)

即, 此时A数组是 (含有负数), 他的二路归并是N*K;
如果把A数组变成(不含负数), 我们对A数组进行排序, 那么, MIN( PA, <=K) 一定(可以是) 涉及A里前K小的元素, 这是成立的;
… … 用了(可以是), 具体在下面的(经验谈)中, 有讲解
… 反证: 假设一个序列 含有A[K+1]元素: {a,b, A[K+1]}, 他是排名 第K小的; (只要证明, 比该序列(小或等于)的, 有K个, 就说明, 他一定(可以是)排名到> K的)
… 因为a,b >=0, 所以, {a, b, A[K+1]} >= A[K + 1], 因为A[1] A[2] ... A[K]<= A[K+1], 因此, 这个K个序列A[1] ... A[K] 一定<= {a, b, A[K+1]}

顺带说一下, 对MAX( PB, <=K), 这个性质 不成立
… 比如, 拿MAX( PB, 1)来看, 他是涉及(所有A的元素的之和)


因此, 我们对B数组排序, 然后只保留B数组的前K小数组, 即N = K
然后, 对他对上面的(二路归并), 时间是: N * K = K*K这是可以的;

Ll_ pre[ 2001], cur[ 2001], ans[ 2001];
Ll_ Algo_2( vector< int> & A, int K){ //< A是不含负数的
    sort( A.begin(), A.end());
    //--
    int n = A.size();
    for( int i = 0; i < K; ++i){
        pre[ i] = Ll_( 2e11); //< 由于是求最小数, 要设置成最大的非法数
    }
    pre[ 0] = 0; //< no chosen
    for( int i = 0; i < min( n, K); ++i){ //< 只考虑前K的元素
        for( int j = 0; j < K; ++j){
            cur[ j] = pre[ j] + A[ i];
        }
        //{ merge_sort
        int ans_ind = 0;
        int pre_ind = 0, cur_ind = 0;
        while( ans_ind < K){
            if( pre_ind >= K){
                ans[ ans_ind ++] = cur[ cur_ind ++];
            }
            else if( cur_ind >= K){
                ans[ ans_ind ++] = pre[ pre_ind ++];
            }
            else{
                if( pre[ pre_ind] <= cur[ cur_ind]){
                    ans[ ans_ind ++] = pre[ pre_ind ++];
                }
                else{
                    ans[ ans_ind ++] = cur[ cur_ind ++];
                }
            }
        }
        for( int j = 0; j < K; ++j){
            pre[ j] = ans[ j];
        }
        //}
    }
    return ans[ K - 1];
}
long long kSum(vector<int>& A, int K) {
    Ll_ neg = 0, pos = 0;
    for( auto i : A){
        pos += ( i > 0 ? i : 0);
        neg += ( i < 0 ? i : 0);
    }
    auto posi = A; //< 将A的负数变成正数
    FOR_( i, 0, int(posi.size()) - 1, 1){
        posi[ i] = ( posi[ i] >= 0 ? posi[ i] : (- posi[ i]));
    }
    return neg + Algo_2( posi, K);
}

求第K小数, 是neg + Algo_2( posi, K); 求第K大数, 是pos - Algo_2( posi, K)


做法3–堆

这里介绍一种, 新的 遍历(全排列)的方式;

全排列DFS二叉树

我们上面的(二分, 二路归并), 遍历全排列的方式, 都是属于(图片一)中的(方式一); 他表示的这个二叉树, 我们这里称为(全排列DFS二叉树)
即, 在I位置, 得到 前(I-1)个元素的(选与不选)集合FA 对应第I-1层的节点个数(第I层, 有2^I个节点)
然后, 在任意属于FA集合的元素fa, 枚举: 当前I元素, 选与不选, 得到: fa, fa + A[I]; fa就对应第I层的父节点;

这个树, 其实就是DFS树, DFS就是这样遍历的; 树中总节点个数, 就是DFS的次数;

这个数, 有一些特点:
(1, 所有的叶子节点, 都对应所有的全排列; 非叶子节点上 也会有 排列, 但比如ABC就只出现在叶子节点)
(2, 节点个数是2^N + 2^N - 1, 即叶子节点个数 + 非叶子节点个数)
(3, 如果我们的目的, 只是遍历所有的全排列, 但这种二叉树, 所有的非叶子节点, 都是重复的, 可以说 都是无用的
… 这不是加倍的DFS的搜索次数?
… 而右侧的二叉树, 没有重复节点, 节点个数是当前二叉树的节点个数的一半 不是更好吗?
… 为什么要用这种 有重复节点的DFS树呢? 他的时间是2^N * 2两倍的
… 如果只是要遍历他的全排列, 这种二叉树确实是要被淘汰的; 因为确实是白白浪费了一倍的时间
… 本来2^N就可以的, 这种却要到2^N * 2;
(因此, 如果要遍历全排列, 不要用这个dfs, 我们以前认为 dfs( ind, sum){ dfs( ind + 1, sum); dfs( ind + 1, sum + A[ind])这种方式
是遍历全排列的方式, 其实这是错误的… 他不是最优的, 他会很多重复的DFS节点; (最好你就二进制枚举, 最好)
… 这种DFS树的优势是: 他的父节点fa, 是(以该节点为根的子树)的所有节点的 前缀
… 因为他的(儿子), 要么增加到他身上 要么不增加, 反正一定是含有(fa父节点)的;
这就使得DFS可以: 剪枝
… 比如以上面的(二分)时用的DFS树来说, 因为所有数都是>=0的; 假如pre + A[cur]> K的, 那么, 他子树的所有节点 都是>= cur
… 那么, 当前节点的两个分支之一: (选择cur)的分支, 就可以不用考虑了;
… 而又因为, 数组是(递增的), 假如pre + A[cur]是> K的, 那么pre + A[> cur]也是> K的;
… 因此, 当前节点的两个分支之一: (不选择cur)的分支, 也可以不用考虑;
… … (不选择cur)的分支中, 并不是全是pre + A[> cur], 只有1个例外: 空集 (所以, 他最好不要在DFS里计算)
… 这样, 配合这种单调性的判断, 可以做到(剪枝), 这就是他的优势


插话
假如你以树根(空) 为id=1, 他的(不选分支)为id * 2, (选择分支)为id * 2 + 1;
你会发现, id号为x, 2x, 4d, 8x, 16x, ...都相同的排列;
因为x 是 2x的父节点;
因此, 如果一个节点x是(偶数), 则这个排列, 一定不是第一次出现的,
x不停的进行(/2操作), 直到(x 是奇数), 这个点 是该排列, 第一次出现的位置 (最靠树根的节点)
即, 所有(第一次出现的排序), 他的节点id号 是(奇数)


无重复元素的全排列二叉树

然后我们看(方式二)这种二叉树; 这个方式还是第一次见… 确实很新颖;
他的节点个数 (加上空集), 就是全排列; 一开始看确实难以理解, 我们来慢慢分析

原来二叉树, 之所有会产生相同的节点, 因为: 当前元素, 有选与不选两种分支, 如果不选, 则得到的节点 和 父节点fa, 是一样的
但是在这里, 当前元素 从fa过来, 虽然也有两个分支, 但两个分支 都含有当前元素

原来二叉树: 第I层有2^I个节点, 第I层的这2^I个节点, 表示 (前I个元素的全排列);
这个二叉树, 第I层有2^(I-1)个节点, 第I层的这2^(I-1)个节点, (都含有A[I]当前元素), 表示 (前I个元素 且含有当前元素的全排列)

他的具体构造过程是:
对于第I层, 他的fa父节点(是第I-1层的节点), 比如fa是:a,b,c, A[I-1](他的末尾 一定是A[I-1]元素)
有两种选择: (1, 去掉fa的末尾元素后, 添加当前元素, 即:a,b,c, A[I]) (2, 在fa的基础上, 添加当前元素, 即: fa, A[I])

证明: 为什么这样构造, 第I层的2^(I-1)个节点, 恰好都是 (含有A[I]的 前I个元素的全排列)
令S为: 含有A[I]的 前I个元素的全排列, (即每个S里的元素 形如:{ (前I-1个元素的全排列), A[I]}, 因此S的大小是2^(I-1))
令X集合为: (去掉A[I])的S集合, (即前I-1个元素的全排列)
把X分为两类: (1, 含有A[I - 1], 记作X1) (2, 不含A[I - 1], 记作X2);
此时有: S = X + 当前元素 = (X1 + 当前元素) + (X2 + 当前元素) 集合 + 元素, 表示将元素添加到集合内的每个元素里, 然后构成一个新集合
已知, 第I-1层的所有节点, 就是X1; 即, fa就是X1; fa + 当前元素即为(X1 + 当前元素)
(X1 - A[I - 1])这个集合 等于 X2; 即fa - A[I - 1] + A[i]就是(X2 + 当前元素)


回到本题;
假如A数组是(含有负数)的, 即使他是有序的, 对于这个二叉树,
虽然有A <= B, 但是A, AB的关系 不是单调的; 即(fa父节点 和 儿子) 不是单调的

但是, 我们同样转换为: NEG + MIN( PB, K)
即A数组是(不含负数), 且有序, 则他对应的这个二叉树:
A <= BA <= AB, 即 (fa父节点 一定是<= 两个儿子的)
这个性质, 虽然在上面的(DFS二叉树)中, 也是成立的, 但是DFS二叉树里 有很多重复节点, 下面会讲到两者差别
而此时, 这个二叉树, 是没有重复节点的;
此时, 暂时不讨论空集, 只讨论这个二叉树;

(1, 对于整个树, 他的最小值, 一定是A树根; 因为A <= B, A <= AB
这种树结构满足: 任意一个子树的最小值 一定可以是树根, 因为每个点都满足: 父节点<=两个儿子;
… 此时, 我们把A点 从树中扔掉

(2, 对于剩下的这个树(其实已经是个森林了), 他的最小值 是min(B, AB), 即(森林中各个树的树根)
假如我们选择的是(AB) 虽然AB >= B, 但当两者等于时, 由于数相同, 可能会选择到AB, 即将(AB)从森林中删除

(3, 对于此时的森林, 他的最小值: min( B, AC, ABC) 即(森林的各个树的树根)
依次类推…

即我们维护一个集合Heap, 记作M为最初这个树的大小即2^N - 1
第一次, 最开始,对于这个大小为M的树, 他的最小值是: 这个树的树根; … 然后将树根删除
第二次, 对于这大小为M-1的森林, 他的最小值是: 这个森林的2个树根的最小值; … 选择最小值的树根, 删除
第三次, 对于这大小为M-2的森林, 他的最小值是: 这个森林的3个树根的最小值; … 选择最小值的树根, 删除
第四次, 对于这大小为M-3的森林, 他的最小值是: 这个森林的4个树根的最小值; … 选择最小值的树根, 删除

即, 在第K次, 此时森林的大小是M-K+1, 该森林有K个子树, 即有K个树根, 这K个树根的(最小值) 就是第K小数

我们用: Heap集合, 来表示: 树根;
Heap从上面分析中 表示树根 没啥问题; 他还可以从另外角度看, 此时森林里的所有点 必须都是<= Heap里的元素;
… 因为Heap里是树根, 所以这点是成立的;

第一次, 最开始, Heap里是{A}, 得到第1小数A, 然后该树分裂为2个树, 这两个树的树根是: 该节点的两个儿子B, AB
第二次, Heap里是{B, AB}, 得到第2小数min(B, AB), 假设是AB, 则该树分裂为2个树, 这两个树的树根是AC, ABC
第三次, 此时Heap里是{B, AC, ABC}, 得到第3小数min( B, AC, ABC}, 不断的进行这过程, 进行K次, 就得到了第K小数


我们以第K=三次的Heap = {B, AC, ABC}为例, 虽然他代表以这3个元素为树根的3个子树形成的森林
但是, 毕竟我们没有删除元素, 我们看的 还是整个二叉树, 这个Heap里的元素, 在这个二叉树中 是什么意思呢??

对于这个二叉树, 如果一个点 他是(这3个节点B, AC, ABC 某个节点的父节点), 记作该类节点的集合为S (S为settled, 已经确定了的点)
比如AB, 则一定说明: 该节点 是前面已经得到的值(已删除的点), 即该节点一定是 (第< K的数), 我们不关注他;
否则, 对于所有(非S集合)的点 记作R集合rest剩余, 你的Heap里的元素, 必须可以(覆盖) R集合所有点
什么叫(覆盖)呢? 其实就说: 从Heap里的元素, 往下走(往儿子走), 一定可以遍历所有R的点;
而且, 所有R集合的点, 都<= Heap里的元素;
… 这讲复杂了, 因为我最开始研究这个算法, 是从这个角度(覆盖)入手的; 但从上面的(森林和树根)角度看, 更容易理解;


Ll_ Algo_1( vector< int> & _posi, int _k){ //< _posi都是(>=0)的, 求第K小的全排列;
	int n = _posi.size();
    sort( _posi.begin(), _posi.end());
    vector< Ll_> mins;
    mins.push_back( 0); //< 空集特判, 该二叉树里不包含(空集); 0一定可以是最小
    using Item_ = tuple< Ll_, int>;
    priority_queue< Item_, vector< Item_>, greater< Item_> > heap;
    heap.push( {_posi[ 0], 0}); //< 第二维度, 是个下标, 记录 (当前序列的 最后元素的 下标)!   注意这种遍历方式;
    FOR_( i, 2, _k, 1){
        auto sum = GET_( heap.top(), 0);
        auto ind = GET_( heap.top(), 1);
        heap.pop();
        mins.push_back( sum);
        //--
        if( ind + 1 < n){ //< 优化可以写成min(n, k); 因为可以证明只会用到前K个元素
            heap.push( { sum - _posi[ ind] + _posi[ ind + 1], ind + 1});
            heap.push( { sum + _posi[ ind + 1], ind + 1});
        }
    }
    return mins.back();
}

多思考一步, 对于(全排列DFS二叉树), 可以用上面这种算法吗?
即, 用个Heap 来为 维护(各个树根);
因为(全排列DFS二叉树), 对于数组A是(不含负数), 虽然有重复节点, 但是他也满足(fa <= 两个儿子)
是可以的, 你要保证(重复的排列不会重复计算; Heap维护的, 一定是森林的各个树根), 但是会(超时);
对于重复节点, 我们可以区分(因为, 当前节点不选, 则一定是重复节点);
但是会超时: [1, 2, 3, ...]
第一次, 放入(空), 他是树根 是最小值; 然后删除他, 放入儿子(空和1)
第二次, Heap头是(空), 但他是(重复的), 所以, 删除他 把他的(儿子) 放入
… 此时是(空 和 1 和 2); Heap头还是(空), 继续会得到(空, 1, 2, 3)
… 这里你会循环到叶子节点, 得到:(1, 2, 3, …); 得到最小值是(1), 然后把1删除, 放入(1, 和 312组合)
第三次, 队列是(1, 2, 3, 312组合, …)
… 取出(1), 发现他是重复的节点, 不计算, 把(1)扔出, 放入(1 和 413组合)
… 然后, 最小值依然是1, 又放入14组合

他的时间复杂度, 是(整个二叉树节点)量级 2^N; 因此, 重复节点是处理不了的! 因为他这个树结构就是这样, 就是还有重复节点的;
你肯定是绕不过的, 肯定要访问重复节点;

代码: 这个代码还是挺有趣的, 值得研究; 虽然超时, 但是正确的;

Ll_ Algo_1( vector< int> & _posi, int _k){
	int n = _posi.size();
	sort( _posi.begin(), _posi.end());
	vector< Ll_> mins;
	using Item_ = tuple< Ll_, int, bool>;
	priority_queue< Item_, vector< Item_>, greater< Item_> > heap;
	heap.push( {0, 0, true}); //< bool表示: 当前节点是否是(重复)的, 重复的跳过;
	FOR_( i, 1, _k, 1){
	    while( true){
	        ASSERT_( heap.empty() == false);
	        auto sum = GET_( heap.top(), 0);
	        auto ind = GET_( heap.top(), 1);
	        auto id = GET_( heap.top(), 2);
	        heap.pop();
	        if( id == false){ //< 重复节点 
	            if( ind < n){  //< 注意不是`ind-1 <n`, 这个二叉树的ind元素, 是不包含在sum里的! 
	                heap.push( { sum, ind + 1, false});  //< false为重复节点, 他和当前节点一样; 
	                heap.push( { sum + _posi[ ind], ind + 1, true}); //< true为新节点
	            }
	            continue;
	        }
	        else{
	            if( ind < n){ //< 这里也要把儿子节点放入
	                heap.push( { sum, ind + 1, false});
	                heap.push( { sum + _posi[ ind], ind + 1, true});
	            }
	            mins.push_back( sum);
	            break;
	        }
	    }
	    //--
	}
	return mins.back();
	}

第K大排序

上面在求(第K小数)时, 不管是(二分, 二路归并, ) 哪种做法


经验谈


对于一个数组A(可以含负数), 长度为N, 他的全排列集合PA (2^N大小), 令All = A所有元素之和
将PA进行排序后, PA1, PA2, ..., PA(M-1), PAM, 一定有M= 2^N个元素
一定有: PAi + PA(M-i+1) = All, 即左右对称的两端是有联系的 (定理1)
假设PAi的元素是a, b, c (共j个)*, 则PA(M-i+1)的元素 一定 可以是 除了abc...的N-j个元素 (即是他的补集) (定理2)
… 之所以用了 (可以是), 是因为: 由于PA中有相同值的数, 所以, 同一个数 可以对应多种全排列方式
这两个定理, 基本是可以互相推导的; 关键是, 如何证明其中的一个呢? TODO


对于数组A, [-4, -1, 0, 2], 记: N为数组A的长度, PA为A的所有全排列集合大小为2^N

MIN( S, K) 表示 S集合中, 第K小的元素
MAX( S, K) 表示 S集合中, 第K大的元素

NEG为: A中所有负数之和 (如果没有负数, 为0)
POS为: A中所有正数之和 (如果没有正数, 为0)

构造辅助数组B: [4, 1, 0, 2], (即, 对数组A进行绝对值操作后的数组)
PB为B的所有全排列集合大小为2^N

则有: PA集合 等于 { N E G + x ∣ x ∈ P B } { \{ NEG + x | x \in PB \} } {NEG+xxPB} 等于 { P O S − x ∣ x ∈ P B } { \{ POS - x | x \in PB \} } {POSxxPB}

该性质, 是求解(第K小/大)的子序列和 问题的 核心核心, 没有该性质, 该问题就无法解决

证明在上面有:


即, MIN( PA, K) = NEG + MIN( PB, K) 也等于 POS - MAX( PB, K), 这不常用
MAX( PA, K) = POS - MIN( PB, K) 也等于 NEG + MAX( PB, K), 这不常用
都转换为: MIN( PB, K) 的形式, 因为, MIN( PB, K)是可以求出的

为什么不转换为MAX( PB, K)呢?
对于B全是>= 0的,
(1, 二分算法: 上面二分的解决, 是求的MIN(PB, K), 如果求MAX(PB, K)也是可以的 :
… MIN的起点, 即最小值 最初的序列, 一定是空或单个元素, 然后不断拓展, 序列不断变长;
… 但是MAX的起点 即最大值 最初的序列, 是整体全部元素, 然后每次减少元素, 序列不断变短; (DFS也可以做, 但没有MIN那么直观简单)
(2, 二路归并算法; MIN(PB, <=K) 一定(可以是) 只涉及前K小的元素; 但是, MAX( PB, <=K) 不成立)
… 比如, 拿MAX( PB, 1)来看, 他是涉及(所有A的元素的之和)
(3, 无重复元素的全排列二叉树; 最值(最大值), 不是单个元素, 也就不是树根, 不可行

即, 对于一个非负数组A
MIN的起点, 即最小值 最初的序列, 一定是空或单个元素, 然后不断拓展, 序列不断变长;
但是MAX的起点 即最大值 最初的序列, 是整体全部元素, 然后每次减少元素, 序列不断变短
所以, MIN(PA, <=K) 一定(可以是) 涉及A的前K小的元素; 然而, MAX( PA, <=K) 是会涉及A的所有元素的
… 用了(可以是), 是因为: 不同序列 可能对应相同的值, 导致 这个相同的值, 对应很多不同的排名 不同的序列; 所以, 用了(可以是)
… 即, 前K个元素 一定可以 得到前K小排名(充分性); 但前K小的排名, 也可以含有A[K+1]元素(非必要性)

当然, 假如A含有(负数), 那么, MIN(PA, K) MAX(PA, K) 都不会是 (只涉及 前K小/大的数)
比如: [-1, ..., -1] MIN( PA, K) 涉及所有元素; [1, ..., 1] MAX(PA,K)涉及所有元素;



  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值