LCP 82-万灵之树
探险家小扣终于来到了万灵之树前,挑战最后的谜题。 已知小扣拥有足够数量的链接节点和 n
颗幻境宝石,gem[i]
表示第 i
颗宝石的数值。现在小扣需要使用这些链接节点和宝石组合成一颗二叉树,其组装规则为: - 链接节点将作为二叉树中的非叶子节点,且每个链接节点必须拥有 2
个子节点; - 幻境宝石将作为二叉树中的叶子节点,所有的幻境宝石都必须被使用。 能量首先进入根节点,而后将按如下规则进行移动和记录: -
若能量首次到达该节点时: - 记录数字 1
; - 若该节点为叶节点,将额外记录该叶节点的数值; -
若存在未到达的子节点,则选取未到达的一个子节点(优先选取左子节点)进入; - 若无子节点或所有子节点均到达过,此时记录9
,并回到当前节点的父节点(若存在)。 如果最终记下的数依序连接成一个整数 num
,满足 num \mod~p=target,则视为解开谜题。
请问有多少种二叉树的组装方案,可以使得最终记录下的数字可以解开谜题 注意: - 两棵结构不同的二叉树,作为不同的组装方案 -
两棵结构相同的二叉树且存在某个相同位置处的宝石编号不同,也作为不同的组装方案 - 可能存在数值相同的两颗宝石 示例 1: > 输入:gem = [2,3]
> p = 100000007
> target = 11391299
> > 输出:1
> > 解释: > 包含 2
个叶节点的结构只有一种。 > 假设 B、C 节点的值分别为 3、2,对应 target 为 11391299,如下图所示。 > 11391299 %
100000007 = 11391299,满足条件; > 假设 B、C 节点的值分别为 2、3,对应 target 为 11291399; >
11291399 % 100000007 = 11291399,不满足条件; > 因此只存在 1 种方案,返回 1 {:height=300px}
示例 2: > 输入:gem = [3,21,3]
> p = 7
> target = 5
> > 输出:4
> > 解释: 包含3
个叶节点树结构有两种,列举如下: 满足条件的组合有四种情况: > 当结构为下图(1)时:叶子节点的值为 [3,3,21] 或
[3,3,21],得到的整数为 11139139912199
。 > 当结构为下图(2)时:叶子节点的值为 [21,3,3] 或
[21,3,3],得到的整数为 11219113913999
。
![image.png](https://pic.leetcode.cn/1682322894-vfqJIV-
image.png){:width=500px} 提示: - 1 <= gem.length <= 9
- 0 <= gem[i] <= 10^9
- 1 <= p <= 10^9
,保证 p 为素数。 - 0 <= target < p
- 存在 2 组gem.length == 9
的用例
题解
算法一
暴力搜索所有方案。我们需要枚举所有包含 n 个叶节点的完整二叉树的形态,以及所有 1 ~ n 的排列。包含 n 个叶节点的完整二叉树的数量为卡特兰数 C_{n-1,而排列有 n! 种可能。使用一些技巧,可以在均摊 O(1) 的时间内计算出每个方案对应的数 \bmod~p 的值,例如使用集合动态规划或精妙的 dfs 写法。
根据斯特林公式,可知渐近复杂度为 C_{n-1}\cdot n!=(4^n/n^{3/2})\cdot (\sqrt{n}\cdot (n/e)^n)=(4/e)^n\cdot n^{n-1。
具体地,n=8 时总方案数为 429\cdot 40320=17297280,而 n=9 时为 1430\cdot 362880=518918400,变大了 30 倍。即使在 n=8 时有机会优化通过,但这一复杂度增长极快,当 n=9 时就难以接受了。
因为目标是对所有满足条件的方案进行计数,所以对这个搜索进行剪枝似乎是困难的,我没有什么好的想法。如果你没有预先估计复杂度就开始写,然后因为沉没成本,又花了很多时间在暴搜的基础上设计剪枝方案或进行常数优化,那你就上钩了。
算法二
正解是复杂度更优的做法。核心思想是使用树的重心进行分治,并使用 meet-in-the-middle 的技巧合并两部分的答案。考虑找到完整二叉树的重心 v (只考虑叶节点的重量,忽略内部节点)。那么或者 v 存在一个儿子 u 满足以 u 为根的子树的叶子数在 [\dfrac{1/3}n,\dfrac{1/2}n] 范围内,或者以 v 为根的子树的叶子数 \leq \dfrac{2/3}n,即 v 上方的连通块的叶子数在 [\dfrac{1/3}n,\dfrac{1/2}n+1] 范围内。
若为第一种情况,则可以预先搜索出所有以 u 为根,叶子数 \leq \dfrac{1/2}n 的完整二叉树以及其中节点所用到的数字集合所对应的答案。还需要将 u 下方的子树缩成一个叶节点,记为变量 x,搜索 u 上方连通块的情况,叶子数 \leq \dfrac{2/3}n+1。这部分的结果可以表示成 v_1,x,v_2 依次连接的形式,v_1,v_2 已知。我们同时记录下 v_2 的长度,这样给定 x 的值后就可以计算出整体表示的数。知道上方部分的结果后可以利用逆元解方程,O(1) 算出下方部分应该得到的值 x,用哈希表在下方部分的结果中查找。对第二种情况的处理类似,需要搜索 v 下方叶子数 \leq \dfrac{2/3}n 的子树及 v 上方的连通块。一个小细节是需要对树的形态分类讨论,避免重复计数。
注:也可以自顶向下地计算 x。第一名的队伍的实现方式很优雅,推荐大家看一下。
总复杂度为 O\left(\sum_{i=1}^{2n/3}\dfrac{n!}{(n-i)!}\cdot C_{i-1}\right)=O\left(c^n\cdot n^{2n/3-1.5}\right),其中常数 c=\dfrac{3^{1/3}\cdot 4^{2/3} }{e^{2/3} }\approx 1.866。这个复杂度是比算法一更优的。(比较复杂度时注意最重要的部分,n^{2n/3}<n^n.)
具体地,当 n=9 时我们只需要对 6 个叶子的完整二叉树进行搜索。可以对切下来作为下方的子树进行如下分类讨论(对以上通用思路作了一点修改,这样常数小一点):
- 恰有 6 个叶节点。
- 恰有 5 个叶节点,且它的兄弟子树有 \geq 2 个叶子。
- 恰有 4 个叶节点,且它的兄弟子树有 3 个叶子。
- 恰有 4 个叶节点,且它的兄弟子树在它右边并有 4 个叶子。
不难验证这样分类是不重不漏的。这对应着上方与下方一共有大约三百万种方案。我们放了标程 15 倍的时限。
注:当模数 p 不为素数时,我们仍然可以通过逆元 + 中国剩余定理反向计算出下方部分应该得到的值 x,从而合并两部分的答案。对于本题,如果想直接使用逆元的话,需要特判 p=2 与 p=5 这两个与 10 不互质的素数(简单地考虑连成数字的最后一位即可,一定为 9)。考虑到这题已经比较麻烦了,就没有额外增加 p 任意时的思维难度。
算法三
还可以有另一种 meet-in-the-middle 的合并方法。先枚举每种包含 n 个叶节点的完整二叉树的形状 T。在 T 确定下来后,再枚举 \binom{n}{n/2 种将数组 a[1..n] 划分成等大小的两半的方案。划分方法确定后,对每一半分别枚举 (\dfrac{n}{2})! 种排列,依序对应到前一半或后一半的叶子上。最后使用 meet-in-the-middle 合并两半的结果。
总复杂度为 O\left(C_{n-1}\cdot \binom{n}{n/2}\cdot (\dfrac{n}{2})!\right)=O(c_1^n\cdot n^{n/2}),其中常数 c_1=\dfrac{4\cdot 2^{1/2} }{e^{1/2} }\approx 3.432。
这个算法的理论渐近复杂度更优,但对于本题数据范围的实际表现不如算法二,不能确保通过(有一些队伍使用了这个方法,需要实现比较好)。具体地,当 n=9 时我们需要考虑大约两千万种方案。
算法四(算法一)
暴力优化,返璞归真。虽然我们的预期是没有选手在比赛中使用暴力 AC 本题,不过我出题的时候也花了一些时间试验,发现极致优化的暴力事实上是可以在时限内轻松通过的。
(以下均为 C++ 的结果;我对其他语言的常数优化技巧不是很熟,如果其他语言有暴力能过的话欢迎评论告知。注意 python 是不能使用 numpy 的。)
考虑使用集合动态规划实现暴力,这样代码还是挺短的,而且常数会比 dfs 好一些。方法是用一个列表 c_i 表示使用二进制集合 i 代表的宝石能组合出的所有数字。c_i 可以由所有的 c_j 和 c_{i-j 计算出,其中 j\subseteq i 表示左子树中的宝石集合为 j,那么右子树中的宝石集合为 i-j。
我对这个思路的第一版实现跑了 10s,不够快。需要加一些常数优化。列举如下:
- 用数组代替 vector。
- 注意到整个程序的瓶颈是最后合并大小为 n-1 和 1 的两棵子树(一共有 n 种组合)。这个时候不需要先合并出整棵树可以取到的值再查询,而是直接统计答案,从左子树中枚举一个值,在右子树的列表中查询对应的值。这样会更快。
- 当我们合并左右子树时,外层循环枚举小子树,内层枚举大子树。这样内层循环会比较并行,可以给之后的优化带来帮助。
- 加速取模。我一开始试的是 Barrett 约减,不过后来发现 这个 批量对固定乘数模数进行乘模的技巧会更快。
- 这个时候速度上已经可以通过了,但会爆内存。我们将大小为 n-1 的子树对应的列表分成两批计算,算完第一批后直接扔掉,这样内存正好卡在 500MB 内。
- 查询时,枚举一棵子树中的值 v,用逆元算出另一棵子树中需要的值 r_1 和 r_2,这样内层循环中只需要比较操作:这样写会很快。原则上说,尽量在算式中分离变量,在外层循环把需要的计算进行预处理。
1
for (uint *J=c[i-j],*end=c[i-j]+c1[i-j];J!=end;++J)ans+=(*J==r1)+(*J==r2)
(7). 考虑到选手一般不会使用 SIMD,公平起见我也没有尝试指令集的技巧。不过看起来在以上优化过后,目前的写法是挺容易并行的。
这样暴力运行的总时间仅为 1s(可进一步优化到 0.6s,不过不太公平),而我们考虑到一般选手对标算实现的速度,给 C++ 设定的时限为 4s。
最后放上不同解法的代码实现:
1 |
|
1 | // 算法二 |
1 |
|
1 | # 算法二 |
1 | # 算法三 |
1 |
|
1 |
|