算法 {第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+x∣x∈PB}
证明: 假如NEG 为0
(即A中没有负数), 此时: B = A, PA 等于 PB, NEG + x = x
, 因此:
{
N
E
G
+
x
∣
x
∈
P
B
}
{ \{ NEG + x | x \in PB \} }
{NEG+x∣x∈PB} 等于 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, a
和 b, ab
到c
时, 有0, a, b, ab
和 c, 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 <= B
且 A <= 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+x∣x∈PB} 等于 { P O S − x ∣ x ∈ P B } { \{ POS - x | x \in PB \} } {POS−x∣x∈PB}
该性质, 是求解(第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)涉及所有元素;