[{"content":"封面图：https://www.bilibili.com/opus/992929256980873221\n前言 一直以来，我都是使用 TPM 绑定 PCR 7+12 hash 来自动解锁 LUKS 分区。然而，一来这样没有测量内核（PCR#11），二来这样任何内核参数的修改都会触发解锁失败，带来了很多不方便。TPM PCR policies 提供了一种机制，允许对 PCR 测量值进行签名，当系统启动时，TPM 会通过和硬盘密钥一起写入的公钥来验证该签名，当签名验证成功时，释放硬盘密钥。而像 PCR#11 这样的只和内核映像相关的测量值很容易计算。因此，我们可以在生成内核映像之后，提前计算测量值并进行签名；下次启动时，TPM 验证该签名正确，就会释放密钥，从而允许更新内核映像并不破坏 TPM 自动解锁。\n本文使用以下工具：\nsbctl systemd-boot mkinitcpio systemd-ukify systemd-cryptenroll 本文希望达成以下结果：\n将内核、内核参数、initrd 等组件打包成统一内核映像（UKI）。当我自己在系统内执行此生成时，不会导致 TPM 自动解锁失败，从而允许内核升级和内核参数修改；而外部的修改尝试会导致解锁失败，即禁止内核替换以及启动时编辑内核参数。 注意：安全启动和 TPM policies 都会利用签名来进行验证。下文中尽量区分开这两种情况。我在进行此次配置之前已经配置好了 sbctl 安全启动，因此不再涉及相关内容。sbctl 如果已经配置好了和 mkinitcpio 协作的，不需要修改，配置依然有效。如果你还没有配置，可以参考 安全启动#sbctl 进行配置，sbctl 自带所有钩子，只需要配置 sbctl 本身即可，不需要手动签名，非常简单。\n生成 UKI 我之前使用的是一般的内核+initrd方式启动，因此首先需要切换到 UKI。\n统一内核映像（Unified Kernel Image，UKI）是一个可执行文件，可直接从 UEFI 固件进行启动，也可以无需或通过简单的配置由引导加载器自动生成。它将一个 UEFI boot stub 程序（例如 systemd-stub(7)）、一个 Linux 内核映像、一个 initrd 以及其它资源打包到了单个 UEFI PE 文件中。\n该文件，也就是包括其下的所有元件都可以很方便地进行签名，以便与安全启动一起使用。\n我使用 mkinitcpio+systemd-ukify 的方式生成 UKI。使用 mkinitcpio 是因为它与 Arch Linux 集成更好，像是 sbctl 包就有 mkinitcpio hook，可以自动对 mkinitcpio 生成的文件进行安全启动签名。使用 systemd-ukify 是因为它可以自动对内核映像进行 TPM policies 签名。这两个组件配合得非常好，安装 systemd-ukify 后，mkinitcpio 就会自动用它来生成 UKI，并且 mkinitcpio 的配置也可以自动传递给 systemd-ukify，systemd-ukify 这边只需要添加 TPM policies 相关的配置即可。\n首先安装 systemd-ukify。\n然后进行 mkinitcpio 的配置。该部分主要参考 统一内核映像#mkinitcpio。首先配置内核命令行，也就是root=啊，quiet splash之类的，可以在/etc/kernel/cmdline下进行单文件配置，或者在/etc/cmdline.d/*.conf下进行多文件配置。将原本启动引导器里面配置的参数移过来即可。然后，编辑/etc/mkinitcpio.d/$LINUX.preset（$LINUX通常是内核包名），取消*_uki选项前面的注释，并修改其值为合适的路径，主要是修改esp为你自己的esp分区名。最后，就可以先尝试生成一下 UKI。\n1 2 sudo mkdir -p esp/EFI/Linux sudo mkinitcpio -p $LINUX 应该没有错误输出，且有Using ukify to build UKI字样。\n然后，就可以进行 systemd-ukify 的配置。首先复制配置模板：\n1 sudo cp /usr/lib/kernel/uki.conf /etc/kernel/uki.conf 然后，编辑/etc/kernel/uki.conf文件。我们使用 mkinitcpio 进行大部分配置，因此 systemd-ukify 这里我们只进行最简单的配置，仅仅配置 PCRSignature 小节。取消\n1 2 3 #[PCRSignature:NAME] #PCRPrivateKey=/etc/systemd/tpm2-pcr-private-key.pem #PCRPublicKey=/etc/systemd/tpm2-pcr-public-key.pem 这三行的注释。把 NAME 修改为 default。\n然后，根据这个路径生成相应的签名密钥对。\n1 2 3 sudo ukify genkey \\ --pcr-private-key=/etc/systemd/tpm2-pcr-private-key.pem \\ --pcr-public-key=/etc/systemd/tpm2-pcr-public-key.pem 完成后再次生成 UKI\n1 sudo mkinitcpio -p $LINUX 应该能看到类似\n1 2 3 -\u0026gt; Using ukify to build UKI Using config file: /etc/kernel/uki.conf + /usr/lib/systemd/systemd-measure sign ... 这样的输出。至此，有签名的 UKI 就生成完成了。\n我使用 systemd-boot 引导，它会自动检测 UKI，不需要额外写配置。如果你使用其它引导器，请自行查阅相关文档。\n写入 TPM 和加密密钥 在完成 UKI 签名之后，就可以写入 TPM 和加密密钥了。这个过程其实很简单，因为我们在默认路径生成了 pubkey，systemd 检测到有 pubkey 就会自动把它写入 TPM 并绑定到 PCR#11。因此只需要\n1 2 #$DISK_DEVICE_PATH 就是 /dev 下你的加密硬盘路径 sudo systemd-cryptenroll --tpm2-device=auto $DISK_DEVICE_PATH 你可以用\n1 sudo cryptsetup luksDump $DISK_DEVICE_PATH | grep pcr 检查，应该可以看到\n1 tpm2-pubkey-pcrs: 11 字样。\n当然，更流行的做法是加上 PCR#7 的绑定。PCR#7 是安全启动状态，比如关闭安全启动就不能自动解锁，加强安全性。PCR#7 一般不会变化，如果变了一般是 fwupd 导致的，而要预计算这个变化需要先解决这个 issue。因此目前还是只能用静态哈希来绑定，不能用 PCR policy。\n1 2 # --wipe-slot=tpm2 是抹除之前写入的和 tpm 关联的密钥 sudo systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 $DISK_DEVICE_PATH 重启电脑，应该可以自动解锁。\n清理工作 既然有了 UKI，那一般的 initrd image 就用不上了。编辑 /etc/mkinitcpio.d/$LINUX.preset，注释掉*_image选项，然后删除对应的initranfs-*-.img文件。另外引导器里面相应的引导入口也可以删掉了，对于 systemd-boot，自定义引导入口位于 $esp/loader/entries/。如果只用 UKI，那所有引导入口都可以删除，因为 systemd-boot 会自动检测 UKI，不需要自己写引导入口。\n","date":"2026-01-26T12:46:59Z","image":"http://blog.anlor.top/post/tpm-pcr-policies/images/cover_hu_a80158860e0e2838.webp","permalink":"http://blog.anlor.top/post/tpm-pcr-policies/","title":"Arch Linux 配置 UKI+TPM PCR policies 解锁 LUKS 分区"},{"content":"封面图为原神 4 周年官方贺图。\n本文旨在讨论Transformer中的几种位置编码，以直观感受其特性。本文对应此 Jupyter Notebook\n环境准备 1 import numpy as np 首先生成一个模拟的tokens列表，为了突出位置编码的影响，tokens的初值为1。\n1 2 3 4 dim = 2 len = 4 tokens = np.ones([len,dim]) print(tokens) Output:\n1 2 3 4 [[1. 1.] [1. 1.] [1. 1.] [1. 1.]] 位置编码 整型编码 直接使用数组下标标记位置。\n1 2 3 int_pe = np.arange(0,len).reshape([len,1]) int_pe = np.broadcast_to(int_pe, [len,dim]) int_pe Output:\n1 2 3 4 array([[0, 0], [1, 1], [2, 2], [3, 3]]) 1 2 int_pe_embed = np.add(tokens, int_pe) int_pe_embed Output:\n1 2 3 4 array([[1., 1.], [2., 2.], [3., 3.], [4., 4.]]) 非常直观的标记方法，缺点是随着tokens长度变长，位置编码会变得非常大\n[0,1]浮点数编码 将数组下标压缩映射到[0,1]范围内以标记位置，避免位置编码过大的问题。\n1 2 3 zeroone_pe = np.arange(0, 1, 1.0/len).reshape([len,1]) zeroone_pe = np.broadcast_to(zeroone_pe, [len,dim]) zeroone_pe Output:\n1 2 3 4 array([[0. , 0. ], [0.25, 0.25], [0.5 , 0.5 ], [0.75, 0.75]]) 1 2 zeroone_pe_embed = np.add(tokens, zeroone_pe) zeroone_pe_embed Output:\n1 2 3 4 array([[1. , 1. ], [1.25, 1.25], [1.5 , 1.5 ], [1.75, 1.75]]) 确实解决了整数编码中位置编码过大的问题。然而，当序列长度不同时，tokens的相对距离会改变。这不是我们想要的。\n二进制编码 考虑到tokens中是多维向量，比起为一个向量的每个维度都加相同的整数，为不同维度加上不同的位置编码可以容纳更多信息。因此考虑二进制编码，将下标转换为二进制向量作为位置编码。\n1 2 3 4 5 6 7 8 9 10 11 12 def binary_vector_array(dim, length): if length \u0026gt; 2 ** dim: raise ValueError(\u0026#34;length 不能超过 2^dim\u0026#34;) result = np.empty((length, dim), dtype=int) for i in range(length): # 格式化为二进制字符串，前导0补齐 bin_str = format(i, f\u0026#39;0{dim}b\u0026#39;) result[i] = [int(bit) for bit in bin_str] return result binary_PE = binary_vector_array(dim,len) binary_PE Output:\n1 2 3 4 array([[0, 0], [0, 1], [1, 0], [1, 1]]) 1 2 binary_PE_embed = np.add(tokens, binary_PE) binary_PE_embed Output:\n1 2 3 4 array([[1., 1.], [1., 2.], [2., 1.], [2., 2.]]) 比起前面几种做法，这种做法更加充分利用了向量的空间。然而，这种方式编码出来的位置向量在空间中是离散的。下面是二进制编码位置向量的绘图，每个向量被绘制为3维空间中的一个点。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import matplotlib.pyplot as plt def PE_plt(points): fig = plt.figure() ax = fig.add_subplot(111) x = points[:, 0] y = points[:, 1] ax.scatter(x, y, c=\u0026#39;r\u0026#39;, marker=\u0026#39;o\u0026#39;) for idx, (xi,yi) in enumerate(points): ax.text(x=xi,y=yi, s=f\u0026#39;{idx}\u0026#39;) ax.set_xlabel(\u0026#39;X\u0026#39;) ax.set_ylabel(\u0026#39;Y\u0026#39;) plt.show() 1 PE_plt(binary_PE) Output:\n1 \u0026lt;Figure size 640x480 with 1 Axes\u0026gt; 可以看到，位置1和位置2明明是相邻位置，其位置向量距离却很远。这对于模型学习相邻位置的关系是不利的。而且将这些点按顺序连接起来，不是一个连续可微的函数，这使得它很难泛化，例如很难处理浮点数位置。\nsin 编码 从二进制编码位置向量离散的问题，想到采用高维空间上的连续函数作为编码。其中一种思路是使用 $\\sin$ 编码。第 $t$ 个 token 的 $\\sin$ 编码形式： $$ PE_t=[\\sin(\\frac{t}{2^0}),\\sin(\\frac{t}{2^1}),\u0026hellip;,\\sin(\\frac{t}{2^i}),\u0026hellip;,\\sin(\\frac{t}{2^{dim-1}})] $$ 其中 $t$ 表示这是第 $t$ 个 token。\n1 2 3 4 5 6 7 import math sin_PE = np.empty([len,dim]) for t in range(len): sin_PE[t] = [math.sin(t/2**i) for i in range(dim)] sin_PE Output:\n1 2 3 4 array([[0. , 0. ], [0.84147098, 0.47942554], [0.90929743, 0.84147098], [0.14112001, 0.99749499]]) 可以看到，在 $\\sin$ 编码中，越低维度的向量变化越剧烈，越高维度的向量变化越平缓。这和二进制编码正好相反，但是思路其实是一样的，和前面整型编码中所有维度向量以相同速度变化形成对比。\n1 2 sin_PE_embed = np.add(tokens, sin_PE) sin_PE_embed Output:\n1 2 3 4 array([[1. , 1. ], [1.84147098, 1.47942554], [1.90929743, 1.84147098], [1.14112001, 1.99749499]]) 下面是 $\\sin$ 编码的绘图\n1 PE_plt(sin_PE) Output:\n1 \u0026lt;Figure size 640x480 with 1 Axes\u0026gt; 可以看到，这个就没有距离突变，而且连接起来是一个连续可微的函数。不过，起始位置和结束位置离得太近，如果 tokens 继续增加，甚至有可能重合。要解决这个问题，只需要增大 $\\sin$ 函数的周期。例如，采用 $\\sin(\\frac{t}{10000^{\\frac{i}{dim-1}}})$\nSinusoidal 编码 这是原始 Transformer 论文中采用的编码方法。Sinusoidal 编码在 $\\sin$ 编码的基础上，额外希望解决一个问题：能否使得已知位置 $t$ 的编码 $PE_t$ 和距离 $\\Delta t$，通过线性变化就可以算出 $PE_{t+\\Delta t}$？即满足： $$ PE_{t+\\Delta t} = T_{\\Delta t}PE_{t} $$ 其中 $T_{\\Delta t}$ 是一个线性变化矩阵。\n这样可以更清晰地编码 tokens 之间的相对位置，同时也有利于计算优化。\n观察上述等式，可以联想到旋转矩阵。令\n$$ T_{\\Delta t} = \\begin{pmatrix} \\cos{\\Delta t}\u0026amp;\\sin{\\Delta t}\\\\ -\\sin{\\Delta t}\u0026amp;\\cos{\\Delta t} \\end{pmatrix}\\\\ PE_t = \\begin{pmatrix} \\sin{t}\\\\ \\cos{t} \\end{pmatrix} $$\n则\n$$ \\begin{pmatrix} \\sin(t+\\Delta t)\\\\ \\cos(t+\\Delta t) \\end{pmatrix}= \\begin{pmatrix} \\cos{\\Delta t}\u0026amp;\\sin{\\Delta t}\\\\ -\\sin{\\Delta t}\u0026amp;\\cos{\\Delta t} \\end{pmatrix} \\begin{pmatrix} \\sin{t}\\\\ \\cos{t} \\end{pmatrix} $$\n模仿 $\\sin$ 编码的方式，扩展到多维，则有 $$ PE_t = [\\sin(w_0t), \\cos(w_0t), \\sin(w_1t), \\cos(w_1t), \u0026hellip;] $$ 在 Sinusoidal 编码中，采用 $$ PE_t = \\begin{cases} \\sin(\\frac{t}{10000^{\\frac{i}{dim}}}) \u0026amp; i=2k, k\\in N \\\\ \\cos(\\frac{t}{10000^{\\frac{i-1}{dim}}}) \u0026amp; i=2k+1, k\\in N \\end{cases} $$ Sinusoidal编码要求向量维度必须是偶数。\n1 2 3 4 5 6 7 8 dim = 2 def gen_Sinusoidal_PE(len,dim): Sinusoidal_PE = np.empty([len, dim]) for t in range(len): Sinusoidal_PE[t] = [math.sin(t/10000**(i/dim)) if i%2==0 else math.cos(t/10000**((i-1)/dim)) for i in range(dim)] return Sinusoidal_PE Sinusoidal_PE = gen_Sinusoidal_PE(len,dim) Sinusoidal_PE Output:\n1 2 3 4 array([[ 0. , 1. ], [ 0.84147098, 0.54030231], [ 0.90929743, -0.41614684], [ 0.14112001, -0.9899925 ]]) 1 2 Sinusoidal_PE_embed = np.add(tokens, Sinusoidal_PE) Sinusoidal_PE_embed Output:\n1 2 3 4 array([[1. , 2. ], [1.84147098, 1.54030231], [1.90929743, 0.58385316], [1.14112001, 0.0100075 ]]) 绘图如下：\n1 PE_plt(Sinusoidal_PE) Output:\n1 \u0026lt;Figure size 640x480 with 1 Axes\u0026gt; 和 $\\sin$ 编码的图像有点像，毕竟都是三角函数。\nSinusoidal 编码还有一些其它优异性质。例如两个位置向量的点积只取决于 $\\Delta t$，即两个位置向量的点积可以反映其距离。同时这个点积是无向的。下图展示了一个 Sinusoidal 编码中，中间位置编码和其它位置编码的点积结果。\n1 2 3 4 5 6 7 8 9 orig_len = len len = 9 Sinusoidal_PE = gen_Sinusoidal_PE(len, dim) Sinusoidal_PE_dot_product = [np.dot(Sinusoidal_PE[i], Sinusoidal_PE[len//2]) for i in range(len)] plt.plot(Sinusoidal_PE_dot_product, marker=\u0026#39;o\u0026#39;) plt.xlabel(\u0026#34;pos\u0026#34;) plt.ylabel(\u0026#34;dot product\u0026#34;) plt.show() len = orig_len Output:\n1 \u0026lt;Figure size 640x480 with 1 Axes\u0026gt; RoPE编码 RoPE是目前最流行的位置编码之一。\nSinusoidal 编码尽管生成的位置向量隐含了相对位置信息，拥有点积与距离有关的优秀特性，可是一但其位置向量加到 tokens 上，这个特性就消失了。那如果直接将旋转矩阵应用到 tokens 上呢？在RoPE编码中，不生成显式的位置向量，而是直接把旋转矩阵和 tokens 相乘。即： $$ x\u0026rsquo;_t = \\begin{pmatrix} \\cos{t\\theta}\u0026amp;-\\sin{t\\theta}\\\\ \\sin{t\\theta}\u0026amp;\\cos{t\\theta} \\end{pmatrix}x_t $$ 扩展到多维，自然就是更改 $\\theta$ 为 $\\theta_i$ 了。在 RoPE 中，令 $$ \\theta_i = \\begin{cases} \\frac{1}{10000^{\\frac{i}{dim}}} \u0026amp; i=2k, k\\in N\\\\ \\frac{1}{10000^{\\frac{i-1}{dim}}} \u0026amp; i=2k+1, k\\in N \\end{cases} $$ 从而组合形成 $$ \\begin{pmatrix} \\cos{t\\theta_0}\u0026amp;-\\sin{t\\theta_0}\u0026amp;0\u0026amp;0\u0026amp;\\cdots\\\\ \\sin{t\\theta_0}\u0026amp;\\cos{t\\theta_0}\u0026amp;0\u0026amp;0\u0026amp;\\cdots\\\\ 0\u0026amp;0\u0026amp;\\cos{t\\theta_1}\u0026amp;-\\sin{t\\theta_1}\u0026amp;\\cdots\\\\ 0\u0026amp;0\u0026amp;\\sin{t\\theta_1}\u0026amp;\\cos{t\\theta_1}\u0026amp;\\cdots\\\\ \\cdots\u0026amp;\\cdots\u0026amp;\\cdots\u0026amp;\\cdots\u0026amp;\\ddots \\end{pmatrix} $$ 这样的形式。\n1 2 3 4 5 6 RoPE_embed = np.empty([len, dim]) for t in range(len): RoPE = np.array([[math.cos(t/(10000**(0/dim))), -math.sin(t/(10000**(0/dim)))],[math.sin(t/(10000**(0/dim))), math.cos(t/(10000**(0/dim)))]]) print(RoPE) RoPE_embed[t] = np.matmul(RoPE, tokens[t].T).T RoPE_embed Output:\n1 2 3 4 5 6 7 8 [[ 1. -0.] [ 0. 1.]] [[ 0.54030231 -0.84147098] [ 0.84147098 0.54030231]] [[-0.41614684 -0.90929743] [ 0.90929743 -0.41614684]] [[-0.9899925 -0.14112001] [ 0.14112001 -0.9899925 ]] 1 2 3 4 array([[ 1. , 1. ], [-0.30116868, 1.38177329], [-1.32544426, 0.49315059], [-1.1311125 , -0.84887249]]) 1 PE_plt(RoPE_embed) Output:\n1 \u0026lt;Figure size 640x480 with 1 Axes\u0026gt; 当然实际的 RoPE 编码不会像上面那样实现，而且在 Transformer 中，一般对 q, k 向量进行位置嵌入，而不是直接对原始 token 进行位置嵌入。详情可参考 LLAMA 的实现：https://github.com/meta-llama/llama-models/blob/a9c89c471f793423afd4cc3ca8671d6e56fe64cb/models/llama4/model.py#L89\n为什么对 $q$, $k$ 向量进行位置嵌入呢？刚刚提到，RoPE 编码想要使得编码后的向量内积和距离相关。在 Transformer 中，q 和 k 向量正好需要进行内积操作： $$ Attention(q,k,v) = softmax(\\frac{qk^t}{\\sqrt{d_k}})v $$ 因此，在 $Attention$ 操作前对 $q$ 和 $k$ 向量进行 RoPE 编码，$qk^t$ 就隐含了相对位置信息，从而使得 $Attention$ 也隐含了相对位置信息。\n","date":"2025-07-24T07:55:02Z","image":"http://blog.anlor.top/post/transformer_pe/images/cover_hu_55d9594911621790.webp","permalink":"http://blog.anlor.top/post/transformer_pe/","title":"Transformer 中的位置编码"},{"content":"封面图源：https://www.pixiv.net/artworks/120566071\n题目描述 搜索旋转排序数组\n整数数组 nums 按升序排列，数组中的值 互不相同 。\n在传递给函数之前，nums 在预先未知的某个下标 k（0 \u0026lt;= k \u0026lt; nums.length）上进行了 旋转，使数组变为 [nums[k], nums[k+1], \u0026hellip;, nums[n-1], nums[0], nums[1], \u0026hellip;, nums[k-1]]（下标 从 0 开始 计数）。例如， [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。\n给你 旋转后 的数组 nums 和一个整数 target ，如果 nums 中存在这个目标值 target ，则返回它的下标，否则返回 -1 。\n你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。\n解题思路 又是一道二分题……但这次我做出来了！\n这道题看到时间复杂度 $O(\\log n)$ 就知道肯定只能用二分。但是，这道题给出的数组并不是有序的。怎么办呢？注意到题目中给出的数组是一个旋转后的有序数组，那么我们只要知道旋转点 k，就能将数组拆成两个有序数组，在这两个有序数组里面分别搜索即可。那么怎么找到旋转点 k 呢？很显然也只能用二分。注意到旋转点 k 左侧的所有数都 \u0026gt;= nums[0]，右侧的所有数都 \u0026lt; nums[0]，我们可以用 nums[0] 做二分条件，对于二分的l, r, mid，如果 nums[mid]\u0026gt;=nums[0]，说明 mid 偏左，令 l=mid+1；否则说明 mid 偏右，令 r=mid。那么什么时候 mid 刚刚好呢？当 nums[mid]\u0026gt;nums[mid+1] 时，mid 就刚刚好了。找到 mid 之后，分别在 $[first,mid]$ 和 $(mid,last)$ 内进行二分搜索 target 即可。\n代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 class Solution { public: int search(vector\u0026lt;int\u0026gt;\u0026amp; nums, int target) { if (nums.size() == 1) { return nums[0] == target ? 0 : -1; } auto a = nums[0]; size_t l = 0, r = nums.size() - 1, mid; auto flag = false; while (l \u0026lt; r) { mid = (l + r) / 2; if (nums[mid] \u0026gt; nums[mid + 1]) { flag = true; break; } else { if (nums[mid] \u0026gt;= a) l = mid + 1; else r = mid; } } if (!flag) { auto result=lower_bound(nums.begin(),nums.end(),target); if (result==nums.end()||*result!=target) return -1; else return result-nums.begin(); }else{ auto midit=nums.begin()+mid+1; auto result=lower_bound(nums.begin(),midit,target); if (result!=midit\u0026amp;\u0026amp;*result==target) return result-nums.begin(); else{ result=lower_bound(midit, nums.end(),target); if (result!=nums.end()\u0026amp;\u0026amp;*result==target) return result-nums.begin(); else return -1; } } } }; ","date":"2025-03-21T16:12:45+08:00","image":"http://blog.anlor.top/post/search-rotated-sorted-array/images/cover_hu_366d91d249cc97ae.webp","permalink":"http://blog.anlor.top/post/search-rotated-sorted-array/","title":"[leetcode]搜索旋转排序数组"},{"content":"封面图源 https://www.pixiv.net/artworks/128286318\n在 leetcode 上看到一道题下一个排列，很明显就是 C++ 中的 std::next_permutation。借此机会研究一下 C++ 中 std::next_permutation 的实现。\n示例代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 template\u0026lt;class BidirIt\u0026gt; bool next_permutation(BidirIt first, BidirIt last) { auto r_first = std::make_reverse_iterator(last); auto r_last = std::make_reverse_iterator(first); auto left = std::is_sorted_until(r_first, r_last); if (left != r_last) { auto right = std::upper_bound(r_first, left, *left); std::iter_swap(left, right); } std::reverse(left.base(), last); return left != r_last; } 算法解释 一般求排列，大多数都是求全排列，时间复杂度和空间复杂度都是 O(n!)。此时只需要枚举每个位上的数字即可。但是，对于求下一个排列，这样做效率太低了。std::next_permutation 可以做到在 O(n) 的时间复杂度下原地操作求出下一个排列。该算法乍看不好理解，实际上很像求一个二进制数+1 的算法。下面解释为什么。\n首先看 auto left = std::is_sorted_until(r_first, r_last); 这一句。这一句是求从后往前的最长不下降序列。也就是说，求 left，使得区间 $(left, last)$ 内的元素是降序的。为什么要求这个呢？我们从两点来思考。首先，要求字典序上的“下一个”，必然是从后往前更改的。其次，对于一个排列，如果它是降序的，那么它就是字典序上最大的排列了。这就有点像求 $10101111$，首先要找到最后的 $1111$。$1111$ 就是 4 位数里面最大的了，如果再 +1，就务必要进位，那么在整个 +1 的过程中，就只有最后的 5 位 $01111$ 会发生变化，前面的 $101$ 我们就可以不管了。放在求下一个排列也是一样的道理，在求下一个排列的过程中，只有区间 $[left, last)$ 内的数可能发生变化，left 前面的数是不用管的。\n那么 $[left, last)$ 内的数要怎么变化呢？$01111+1=10000$，从这个式子我们可以看出，求 $01111+1$ 实际上就是把最前面 $0+1$，然后把后面的 $1111$ 变成最小的，在二进制里面就是 $0000$。那么在求下一个排列里面的？就是在 $(left, right)$ 中找到最小的比 *left 大的数，把 *left和它交换，这个有点像求 $0+1$。在上面的示例代码中就是 if (left != r_last) 这一段的操作。然后需要把后面的 $(left, right)$ 变成最小的，升序排列就是最小的，因此这里可以用 std::sort(left.base(), last);。读者可以试试用 std::sort(left.base(), last) 代替 std::reverse(left.base(), last)，最终结果应该也是对的。那为什么这里用了 std::reverse(left.base(), last) 呢？注意到我们前面提到 $(left, last)$ 内的元素是降序的，刚刚的交换也不会破坏这个有序性，因此在交换完之后 $(left, last)$ 依然是降序排列的。那么就不需要用 std::sort() 了，直接用 std::reverse() 将降序的序列翻转一下就可以变成升序了。\n","date":"2025-03-21T14:49:55+08:00","image":"http://blog.anlor.top/post/next-permutation/images/cover_hu_95080afa9cde9553.webp","permalink":"http://blog.anlor.top/post/next-permutation/","title":"[leetcode]下一个排列"},{"content":"封面图：https://www.pixiv.net/artworks/126731977\n题目描述 给定两个数 $x,y$，求有多少种不同的长度为 $n$ 的序列 $(a_1,a_2,\\cdots,a_n)$，其所有元素的最大公约数为 $x$ 且最小公倍数为 $y$。\n两个序列 $(a_1,a_2,\\cdots,a_n)$ 与 $(b_1,b_2,\\cdots,b_n)$ 不同，是指存在至少一个位置 $i$ 满足 $a_i\\neq b_i$。\n由于答案可能很大，请输出答案对 $998\\ 244\\ 353$ 取模后的结果。\n输入格式 输入的第一行包含一个整数 $Q$ 表示询问次数。\n接下来 $Q$ 行，每行包含三个整数 $x,y,n$ 表示一组询问，相邻整数之间使用一个空格分隔。对于每个询问，保证至少存在一个满足条件的序列。\n输出格式 输出 $Q$ 行，每行包含一个整数，依次表示每个询问的答案。\n输入输出样例 #1 输入 #1 1 2 3 4 3 3 6 2 12 144 3 233 251640 10 输出 #1 1 2 3 2 72 905954656 说明/提示 对于 $40%$ 的评测用例，$n\\le 30$；\n对于 $70%$ 的评测用例，$n\\le 5000$；\n对于所有评测用例，$1\\le Q\\le 100$，$2\\le n\\le 10^5$，$1\\le x,y\\le 10^9$。\n解题思路 在蓝桥杯做的第二道排列组合，非常简单，然而没能做出来 QAQ。\n这道题题目里虽然有 gcd 和 lcm，但是其实并不需要求 gcd 或 lcm。由题目条件可以想到，$y=0\\mod x$。令 $t=y\\div x$，那么 $t=0\\mod a_i$。若对 $t$ 分解质因数得到 $t=p_1^{k_1}p_2^{k_2}p_3^{k_3}\\cdots$，那么 $a_i$ 实际上就是这些质因数的排列组合。考虑质因数 $p_i$，它在每个 $a$ 中的次数都可能为 $[0,k_i]$，共 $k_i+1$ 种可能。那么考虑所有 $a_i$，根据乘法原理，就有 $(k_i+1)^n$ 种可能。但是，有两种情况是不允许的：所有 $a_i$ 中 $p_i$ 的次数都不为 $0$，这样的话它们的最大公因数就是 $x\\cdot p_i^{j_i}$，$j_i$ 为所有 $a_i$ 中 $p_i$ 最低的次数；同理，如果所有 $a_i$ 中 $p_i$ 的次数都不为 $k_i$，则会导致最小公倍数小于 $y$。因此我们需要减去这两种情况。第一种情况时，$p_i$ 的次数可能为 $[1,k_i]$，共 $k_i$ 种可能。第二种情况时，$p_i$ 的次数可能为 $[0,k_i-1]$，也是 $k_i$ 种可能。考虑所有 $a_i$ 就是 $k_i^n$ 种可能。因此我们要减去 $2k_i^n$。这时候注意到次数为 $[1,k_i-1]$ 的情况被减去了两次，因此再加上 $2(k_i-1)^n$。单个 $p_i$ 的次数的所有可能数就为 $(k_i+1)^n-2k_i^n+(k_i-1)^n$。所有 $p_i$ 之间实际上互不影响，因此最终总可能数就是 $\\sum_{i=1}^n(k_i+1)^n-2k_i^n+(k_i-1)^n$。\n因为需要求模，实现的时候需要手写快速幂。\n代码 附上 AC 代码。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 #include\u0026lt;iostream\u0026gt; #include\u0026lt;utility\u0026gt; #include\u0026lt;vector\u0026gt; #include\u0026lt;cmath\u0026gt; using namespace std; using ll=long long; using pf=pair\u0026lt;int,int\u0026gt;; const auto M=998244353; const ll MAXX=1e9; int q; bool isprime(ll a) { if (a==0||a==1) return false; if (a==2) return true; for (ll i=2;i*i\u0026lt;=a;i++) { if (a%i==0) return false; } return true; } vector\u0026lt;pf\u0026gt; fac(int t) { vector\u0026lt;pf\u0026gt; result; for (auto i=2;i\u0026lt;=t;i++) { if (t%i==0\u0026amp;\u0026amp;isprime(i)) { int p=0; while (t%i==0) { t/=i; p++; } result.emplace_back(t,p); } } return result; } ll qpow(ll a,ll p) { ll t=1,ans=1,at=a; while(t\u0026lt;=p) { if (p\u0026amp;t) ans=ans*at%M; t\u0026lt;\u0026lt;=1; at=at*at%M; } return ans; } int main() { cin\u0026gt;\u0026gt;q; for (auto i=0; i\u0026lt;q; i++) { int x,y; int n; cin\u0026gt;\u0026gt;x\u0026gt;\u0026gt;y\u0026gt;\u0026gt;n; auto t=y/x; vector\u0026lt;pf\u0026gt; tpf=fac(t); ll ans=1; for (auto j:tpf) { ll tmp=qpow(1+j.second,n); tmp=(tmp-qpow(j.second,n)); tmp=(tmp-qpow(j.second,n)); tmp=(tmp+qpow(j.second-1,n))%M; tmp=(tmp+M)%M; ans*=tmp; ans%=M; } cout\u0026lt;\u0026lt;ans\u0026lt;\u0026lt;endl; } } ","date":"2025-02-02T22:39:12+08:00","image":"http://blog.anlor.top/post/lanqiao2024a-gcd-and-lcm/images/cover_hu_bc7d89114fecf06.webp","permalink":"http://blog.anlor.top/post/lanqiao2024a-gcd-and-lcm/","title":"[蓝桥杯2024国A]gcd与lcm"},{"content":"封面图源见水印。\n题目描述 给定一个长度为 $n$ 的序列 $(s_1,s_2,\\cdots,s_n)$ 和三个数 $a,b,c$，你需要找出一对 $L,R$ 满足如下式子：\n$$ \\sum\\limits_{i=L}^Rs_i\u0026gt;a(bR-cL),1 \\le L \\le R \\le n $$\n即，序列中的第 $L$ 至 $R$ 项之和大于 $a\\cdot (b\\cdot R - c \\cdot L)$，求出满足条件的 $L,R$ 中 $R - L + 1$ 的最大值。\n测试数据保证存在这样的一对 $L$ 和 $R$。\n输入格式 输入的第一行包含四个整数 $n,a,b,c$，相邻整数之间使用一个空格分隔。\n第二行包含 $n$ 个整数 $s_1,s_2,\\cdots,s_n$，相邻整数之间使用一个空格分隔。\n输出格式 输出一行包含一个整数表示答案。\n样例 #1 样例输入 #1 1 2 4 1 5 6 1 2 3 4 样例输出 #1 1 3 提示 对于 $60%$ 的评测用例，$n\\le 5000$；\n对于所有评测用例，$1\\le n\\le 3 \\times 10^5$，$1\\le a,b,c\\le 1000$，$|s_i| \\le 10^9$。\n解题思路 这道题第一眼看到算法标签有二分，马上联想到二分区间长度，然后枚举区间位置来检查答案。于是光速写下如下代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 #include \u0026lt;algorithm\u0026gt; #include \u0026lt;array\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; using ll = long long; const int MAXN = 3e5; int n; ll a, b, c; array\u0026lt;ll, MAXN + 5\u0026gt; s; array\u0026lt;ll, MAXN + 5\u0026gt; sum; bool check(int mid) { for (auto i = 1; i + mid - 1 \u0026lt;= n; i++) { ll l = i, r = i + mid - 1; if (sum.at(r) - sum.at(l - 1) \u0026gt; a * (b * r - c * l)) return true; } return false; } int main() { cin \u0026gt;\u0026gt; n \u0026gt;\u0026gt; a \u0026gt;\u0026gt; b \u0026gt;\u0026gt; c; for (auto i = 1; i \u0026lt;= n; i++) { cin \u0026gt;\u0026gt; s.at(i); sum.at(i) = s.at(i) + sum.at(i - 1); } auto l = 1, r = n, ans = 0; while (l \u0026lt;= r) { auto mid = (l + r) / 2; if (check(mid)) { ans = mid; l = mid + 1; } else { r = mid - 1; } } cout \u0026lt;\u0026lt; ans; return 0; } 喜提 WA。一开始我还以为是数据范围之类的问题，检查半天，然后猛然发现并非如此。这里要注意二分的一个重要条件，即保证答案是单调的。在上面的代码中，如果一个区间长度被判定为不符合，那么程序就不会搜索比它更长的区间了。但这是不对的。在本题中，一个短区间不符合条件，并不代表长区间也不符合条件。因此，如果希望使用二分搜索，就必须使搜索的目标满足单调条件。那么有没有这样的目标呢？有的有的，这样的目标还有两个，那就是区间的左端点或右端点。但当然不能直接搜索，需要先做一些处理。\n我们思考题目给出的条件：$\\sum\\limits_{i=L}^Rs_i\u0026gt;a(bR-cL),1 \\le L \\le R \\le n$。首先很容易想到用前缀和处理左边的 $\\sum\\limits_{i=L}^Rs_i$。于是等式左边可以分解为 $sum[r]-sum[l-1]$。这个式子可以移项，将带有 $r$ 的部分和带有 $l$ 的部分分置不等式两边，即 $sum[r]-abr\u0026gt;sum[l-1]-acl$。这暗示我们可以将两个端点分开处理。我们不妨先假设 $r$ 是固定的，思考 $l$ 的情况。根据不等式，$sum[l-1]-acl$ 越小越好，同时根据题目要求区间长度尽量大，那么在 $r$ 固定的情况下，$l$ 显然也是越小越好。那么我们可以得出结论：对于 $l_1\u0026lt;l_2$, 若 $sum[l_1-1]-acl_1\u0026lt;sum[l_2-1]-acl_2$，那么 $l_2$ 是对答案没有贡献的。我们可以遍历一遍数组，计算出 $f[l]=sum[l-1]-acl$ 序列，然后从序列中删除所有对答案没有贡献的元素，最后就会得到一个单调递减的序列 $f$。那么对于这个单调序列，我们就可以用二分搜索来搜索 $l$ 的值。\n在具体实现中，我们可以枚举 $r$，那么在单次枚举中 $r$ 就是固定的。在枚举过程中顺便统计 $f[l]=sum[l]-acl$，对于无效的元素，可以将其设置为其前一个有效元素相同的值，这样搜索时就会自动搜索到第一个有效元素了。\n代码 附上 AC 代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 #include \u0026lt;algorithm\u0026gt; #include \u0026lt;array\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; using ll = long long; const int MAXN = 3e5; int n; ll a, b, c; array\u0026lt;ll, MAXN + 5\u0026gt; s; array\u0026lt;ll, MAXN + 5\u0026gt; sum; array\u0026lt;ll, MAXN + 5\u0026gt; f; int main() { cin \u0026gt;\u0026gt; n \u0026gt;\u0026gt; a \u0026gt;\u0026gt; b \u0026gt;\u0026gt; c; for (auto i = 1; i \u0026lt;= n; i++) { cin \u0026gt;\u0026gt; s.at(i); sum.at(i) = s.at(i) + sum.at(i - 1); } f.at(0)=1e18; auto ans=0; for (auto i=1; i\u0026lt;=n; i++) { f.at(i)=min(f.at(i-1),sum.at(i-1)-a*c*i); auto l = 1, r = i; while (l \u0026lt;= r) { auto mid = (l + r) / 2; if (sum.at(i)-a*b*i\u0026gt;f.at(mid)) { ans=max(ans,i-mid+1); r=mid-1; } else { l=mid+1; } } } cout \u0026lt;\u0026lt; ans; return 0; } ","date":"2025-01-27T16:58:57+08:00","image":"http://blog.anlor.top/post/lanqiao2024a-longest-substr/images/cover_hu_5357b60e73a79dbe.webp","permalink":"http://blog.anlor.top/post/lanqiao2024a-longest-substr/","title":"[蓝桥杯2024国A]最长子段题解"},{"content":"封面图源 https://www.pixiv.net/artworks/87550307\n题目描述 洛谷P10391\n小蓝准备去星际旅行，出发前想在本星系采购一些零食，星系内有 $n$ 颗星球，由 $n-1$ 条航路连接为连通图，第 $i$ 颗星球卖第 $c_i$ 种零食特产。小蓝想出了 $q$ 个采购方案，第 $i$ 个方案的起点为星球 $s_i$ ，终点为星球 $t_i$ ，对于每种采购方案，小蓝将从起点走最短的航路到终点，并且可以购买所有经过的星球上的零食（包括起点终点），请计算每种采购方案最多能买多少种不同的零食。\n输入格式 输入的第一行包含两个正整数 $n$，$q$，用一个空格分隔。\n第二行包含 $n$ 个整数 $c_1,c_2,\\cdots, c_n$，相邻整数之间使用一个空格分隔。\n接下来 $n - 1$ 行，第 $i$ 行包含两个整数 $u_i,v_i$，用一个空格分隔，表示一条 航路将星球 $u_i$ 与 $v_i$ 相连。\n接下来 $q$ 行，第 $i$ 行包含两个整数 $s_i , t_i $，用一个空格分隔，表示一个采购方案。\n输出格式 输出 $q$ 行，每行包含一个整数，依次表示每个采购方案的答案。\n样例 #1 样例输入 #1 1 2 3 4 5 6 7 4 2 1 2 3 1 1 2 1 3 2 4 4 3 1 4 样例输出 #1 1 2 3 2 提示 第一个方案路线为 ${4, 2, 1, 3}$，可以买到第 $1, 2, 3$ 种零食；\n第二个方案路线为 ${1, 2, 4}$，可以买到第 $1, 2$ 种零食。\n对于 20% 的评测用例，$1 ≤ n, q ≤ 5000 $； 对于所有评测用例，$1 ≤ n, q ≤ 10^5，1 ≤ c_i ≤ 20，1 ≤ u_i , v_i ≤ n，1 ≤ s_i , t_i ≤ n$。\n解题思路 很简单的 LCA。用倍增 LCA 就可以了，时间复杂度 $O(qlogn)$。有点麻烦的是这个 $ci$ 的处理。朴素 LCA 很好处理这个 $ci$，反正是一个点一个点地回溯的，回溯的时候顺便统计 $ci$ 就好。倍增 LCA 也可以处理路径上的信息，但需要在建树的时候预处理这些信息。在祖先节点数组上记录从该节点到祖先节点（最好是半闭半开区间方便后续统计）所能够买到的所有零食种类，每次扩展祖先节点数组的时候将两个中间祖先的值合并到新祖先上即可。因为每个祖先本质上都是通过父亲扩展而来，因此并不会有遗漏（如果这点有疑问的自己模拟一遍这个过程就明白了）。这要求有一个能够方便地存储所有零食种类和执行合并操作的数据结构。我一开始使用了 unordered_set，用起来非常恶心。看了题解之后发现因为 $c_i$ 的范围较小，可以用 bitset，用了 bitset 马上就 AC 了。\n代码 附上通过代码。这道题洛谷的数据终于不犯病了，洛谷和蓝桥杯官网都能过。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 #include \u0026lt;array\u0026gt; #include \u0026lt;bitset\u0026gt; #include \u0026lt;cassert\u0026gt; #include \u0026lt;cmath\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;list\u0026gt; #include \u0026lt;vector\u0026gt; using namespace std; const int MAXN = 1e5; const int MAXCI = 20; using bs = bitset\u0026lt;MAXCI + 5\u0026gt;; int n, q; struct node { int c; list\u0026lt;int\u0026gt; v; }; struct tnode { struct father { int v; bs c; }; vector\u0026lt;father\u0026gt; f; list\u0026lt;int\u0026gt; s; int d; }; array\u0026lt;node, MAXN + 5\u0026gt; g; array\u0026lt;tnode, MAXN + 5\u0026gt; tree; void dfs(int p, int f, int d) { tree.at(p).d = d; if (f != 0) { tree.at(p).f.push_back({f, bs(1 \u0026lt;\u0026lt; g.at(p).c)}); } for (auto \u0026amp;i : g.at(p).v) { if (i != f) { tree.at(p).s.push_back(i); } } for (auto i = 0; i \u0026lt; tree.at(p).f.size(); i++) { auto \u0026amp;f1 = tree.at(p).f; auto \u0026amp;f2 = tree.at(f1.at(i).v).f; if (f2.size() \u0026gt; i) { f1.push_back(f2.at(i)); f1.rbegin()-\u0026gt;c |= f1.at(i).c; } } for (auto \u0026amp;i : tree.at(p).s) { dfs(i, p, d + 1); } } int lca(int s, int t) { bs ans(0); while (tree.at(s).d \u0026gt; tree.at(t).d) { auto logd = static_cast\u0026lt;int\u0026gt;(log2(tree.at(s).d - tree.at(t).d)); auto \u0026amp;f = tree.at(s).f.at(logd); ans |= f.c; s = f.v; } while (tree.at(t).d \u0026gt; tree.at(s).d) { auto logd = static_cast\u0026lt;int\u0026gt;(log2(tree.at(t).d - tree.at(s).d)); auto \u0026amp;f = tree.at(t).f.at(logd); ans |= f.c; t = f.v; } assert(tree.at(s).d == tree.at(t).d); while (s != t) { auto \u0026amp;f1 = tree.at(s).f; auto \u0026amp;f2 = tree.at(t).f; auto logd = static_cast\u0026lt;int\u0026gt;(log2(tree.at(s).d)); for (auto i = logd; i \u0026gt;= 0; i--) { if (f1.at(i).v == f2.at(i).v) continue; ans |= f1.at(i).c; s = f1.at(i).v; ans |= f2.at(i).c; t = f2.at(i).v; } if (f1.begin()-\u0026gt;v == f2.begin()-\u0026gt;v) { auto i = 0; ans |= f1.at(i).c; s = f1.at(i).v; ans |= f2.at(i).c; t = f2.at(i).v; break; } } ans |= (1 \u0026lt;\u0026lt; g.at(s).c); return ans.count(); } int main() { cin \u0026gt;\u0026gt; n \u0026gt;\u0026gt; q; for (auto i = 1; i \u0026lt;= n; i++) { int c; cin \u0026gt;\u0026gt; c; g.at(i).c = c; } for (auto i = 1; i \u0026lt;= n - 1; i++) { int u, v; cin \u0026gt;\u0026gt; u \u0026gt;\u0026gt; v; g.at(u).v.push_back(v); g.at(v).v.push_back(u); } dfs(1, 0, 0); for (auto i = 1; i \u0026lt;= q; i++) { int s, t; cin \u0026gt;\u0026gt; s \u0026gt;\u0026gt; t; cout \u0026lt;\u0026lt; lca(s, t) \u0026lt;\u0026lt; endl; } return 0; } ","date":"2025-01-23T16:11:58+08:00","image":"http://blog.anlor.top/post/lanqiao2024pa-snack-purchasing/images/cover_hu_61752e6ae7dbc41a.webp","permalink":"http://blog.anlor.top/post/lanqiao2024pa-snack-purchasing/","title":"[蓝桥杯2024省A]零食采购题解"},{"content":"封面图源见水印\n题目描述 洛谷P10389\n小蓝的班上有 $n$ 个人，一次考试之后小蓝想统计同学们的成绩，第 $i$ 名同学的成绩为 $a_i$。当小蓝统计完前 $x$ 名同学的成绩后，他可以从 $1 \\sim x$ 中选出任意 $k$ 名同学的成绩，计算出这 $k$ 个成绩的方差。小蓝至少要检查多少个人的成 绩，才有可能选出 $k$ 名同学，他们的方差小于一个给定的值 $T$？ 提示：$k$ 个数 $v_1, v_2, \\cdots , v_k$ 的方差 $\\sigma^2$ 定义为：$\\sigma^2=\\dfrac {\\sum_{i=1}^k(v_i-\\bar v)^2} k$，其中 $\\bar v$ 表示 $v_i$ 的平均值，$\\bar v = \\dfrac {\\sum_{i=1}^k v_i} k$。\n输入格式 输入的第一行包含三个正整数 $n, k, T $，相邻整数之间使用一个空格分隔。\n第二行包含 $n$ 个正整数 $a_1, a2, \\cdots, a_n$ ，相邻整数之间使用一个空格分隔。\n输出格式 输出一行包含一个整数表示答案。如果不能满足条件，输出 $-1$ 。\n样例 #1 样例输入 #1 1 2 5 3 1 3 2 5 2 3 样例输出 #1 1 4 提示 检查完前三名同学的成绩后，只能选出 $3, 2, 5 $，方差为 $1.56 $；\n检查完前四名同学的成绩后，可以选出 $3, 2, 2 $，方差为 $0.22 \u0026lt; 1 $，所以答案为 $4 $。\n对于 $10%$ 的评测用例，保证 $1 ≤ n, k ≤ 10^2$；\n对于 $30%$ 的评测用例，保证 $1 ≤ n, k ≤ 10^3$ ；\n对于所有评测用例，保证 $1 ≤ n, k ≤ 10^5 $，$1 ≤ T ≤ 2 ^{31} -1 $，$1 ≤ a_i ≤ n $。\n解题思路 在蓝桥杯做的第二道二分答案的题，然后我又没做出来 QAQ。\n这道题不必过分拘泥于方差如何如何。首先要想到最终答案是要求得一个尽量小的 $x$，$x$ 的二分搜索思路非常简单：二分搜索 $x$，判断是否符合要求，如果符合要求就在左区间继续搜索，否则在右区间搜索。\n在确定了这个二分搜索的大方向之后，再考虑如何高效实现判断。要求 $x$ 个数中任意 $k$ 个数的方差，乍看起来复杂度很高，会超时。我最开始也是被这个复杂度吓退了，导致没往这个方向想。但实际上这个复杂度是完全可以接受的。首先在 $x$ 尽量小的基础上，要求方差尽量低，这样才可能小于 $T$。因此容易想到对前 $x$ 个数进行排序，排序之后相邻的数的方差就会比较小。排序的时间复杂度是 $O(nlogn)$。然后就需要挪动一个大小为 $k$ 的窗口，求窗口内的方差。挪动窗口本身是一个接近 $O(n)$ 的复杂度，因此求方差的复杂度不能太高。考虑到我们是在一个挪动的窗口内求方差，尝试通过前缀和进行优化。使用完全平方公式展开求方差的式子，得到 $\\sigma^2=\\frac{\\sum_{i=1}^{k}{v_i^2}-2\\sum_{i=1}^{k}{v_i}\\bar{v}+k\\bar{v}^2}{k}$。其中 $\\sum_{i=1}^k{v_i^2}$，$\\sum_{i=1}^k{v_i}$ 和 $\\bar{v}$ 实际上都可以通过前缀和得到，那么求方差的时间复杂度就是 $O(1)$。那么整个判断的时间复杂度就是排序 $O(nlogn)$ + 求前缀和 $O(n)$ + 挪动窗口求方差 $O(n)$，最终大约是 $O(nlogn)$ 的复杂度。而二分搜索本身是 $O(logn)$ 的复杂度，所以整个算法的复杂度就是 $O(nlog^2n)$。完全在题目要求范围内。\n代码 以下附上代码。该代码在蓝桥杯官网通过，但在洛谷无法通过，可能是精度问题。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 #include \u0026lt;algorithm\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;iterator\u0026gt; #include \u0026lt;vector\u0026gt; using namespace std; using ll = long long; using ld = long double; int main() { int n, k; ld t; cin \u0026gt;\u0026gt; n \u0026gt;\u0026gt; k \u0026gt;\u0026gt; t; vector\u0026lt;ll\u0026gt; a; for (auto i = 0; i \u0026lt; n; i++) { ll temp; cin \u0026gt;\u0026gt; temp; a.push_back(temp); } auto ans = distance(a.begin(), a.end()) + 1; auto l = a.begin(), r = a.end(); while (l \u0026lt; r \u0026amp;\u0026amp; distance(a.begin(), r) \u0026gt; k) { auto m = l + (r - l) / 2; vector\u0026lt;ll\u0026gt; b(a.begin(), m + 1); sort(b.begin(), b.end()); vector\u0026lt;ll\u0026gt; sum, powsum; bool flag = false; for (auto i : b) { sum.push_back(sum.empty() ? i : i + *sum.crbegin()); powsum.push_back(powsum.empty() ? i * i : i * i + *powsum.crbegin()); } for (auto i = 0; i + k - 1 \u0026lt; b.size(); i++) { auto j = i + k - 1; auto sumk = sum[j] - (i \u0026gt; 0 ? sum[i - 1] : 0); auto powsumk = powsum[j] - (i \u0026gt; 0 ? powsum[i - 1] : 0); auto avg = ld(sumk) / k; auto var = (powsumk - 2 * sumk * avg + k * avg * avg) / k; if (var \u0026lt; t) { flag = true; break; } } if (flag) { ans = min(distance(a.begin(), m), ans); r = m; } else { l = m + 1; } } if (ans == distance(a.begin(), a.end()) + 1) cout \u0026lt;\u0026lt; -1 \u0026lt;\u0026lt; endl; else cout \u0026lt;\u0026lt; ans + 1 \u0026lt;\u0026lt; endl; return 0; } ","date":"2025-01-21T15:10:48+08:00","image":"http://blog.anlor.top/post/lanqiao2024pa-score-statistics/images/cover_hu_74ec04291ff56f01.webp","permalink":"http://blog.anlor.top/post/lanqiao2024pa-score-statistics/","title":"[蓝桥杯2024省A]成绩统计题解"},{"content":"封面图：https://t.bilibili.com/961668217500073990\n题目描述 洛谷P9237\n小蓝最近迷上了一款名为《像素放置》的游戏，游戏在一个 $n \\times m$ 的网格棋盘上进行，棋盘含有 $n$ 行，每行包含 $m$ 个方格。玩家的任务就是需要对这 $n \\times m$ 个方格进行像素填充，填充颜色只有黑色或白色两种。有些方格中会出现一个整数数字 $x(0 \\leq x \\leq 9)$，这表示当前方格加上周围八个方向上相邻的方格（分别是上方、下方、左方、右方、左上方、右上方、左下方、右下方）共九个方格内有且仅有 $x$ 个方格需要用黑色填充。\n玩家需要在满足所有数字约束下对网格进行像素填充，请你帮助小蓝来完成。题目保证所有数据都有解并且解是唯一的。\n输入格式 输入的第一行包含两个整数 $n,m$，用一个空格分隔，表示棋盘大小。\n接下来 $n$ 行，每行包含 $m$ 个字符，表示棋盘布局。字符可能是数字 $0 \\sim 9$，这表示网格上的数字；字符还有可能是下划线（$\\text{ASCII}$ 码为 $95$），表示一个不带有数字的普通网格。\n输出格式 输出 $n$ 行，每行包含 $m$ 个字符，表示答案。如果网格填充白色则用字符 $0$ 表示，如果网格填充黑色则用字符 $1$ 表示。\n样例 #1 样例输入 #1 1 2 3 4 5 6 7 6 8 _1__5_1_ 1_4__42_ 3__6__5_ ___56___ _688___4 _____6__ 样例输出 #1 1 2 3 4 5 6 00011000 00111100 01000010 11111111 01011110 01111110 提示 【样例说明】 上图左是样例数据对应的棋盘布局，上图右是此局游戏的解。例如第 $3$ 行第 $1$ 列处的方格中有一个数字 $3$，它周围有且仅有 $3$ 个格子被黑色填充，分别是第 $3$ 行第 $2$ 列、第 $4$ 行第 $1$ 列和第 $4$ 行第 $2$ 列的方格。\n【评测用例规模与约定】 对于 $50 %$ 的评测用例，$1 \\leq n,m \\leq 5$；\n对于所有评测用例，$1 \\leq n,m \\leq 10$。\n解题思路 很简单的深度优先搜索……仅就官方数据而言。实际上深搜的时间复杂度是很危险的，不过官方数据比较水，深搜能全过，洛谷上就会被卡掉几个点。不过也可能是被卡常了。但这道题用搜索性价比还是很高的，写起来比较快也能拿大多数分。\n具体做法就是每个点去深搜，从左到右从上到下，每当一个数字周围的点都已经填了 $0$ 或 $1$ 时就检查这个数字是否合法，不合法就直接剪枝。\n这么水的解法之所以要写题解，主要是记录一下这张图的存储方法。如果按一般的方法用矩阵存储这张图，那么在判断“一个数字周围的点都已经填了 $0$ 或 $1$”这一步时就会很不优雅。因为在深搜的过程中，并不是按数字周边去深搜的，而是按顺序；因此这一步从判断数字周围的情况，变为了判断当前填入的这个点是否是“某个数字周围最后的点”。很容易想到一个数字周围最后的点应该是它右下角的点，如果它右下角的点已经被填了，那么它周围的所有点应该都被填了。但是这并非绝对，要处理三种特殊情况，即数字在图的最右侧、数字在图的最下方、数字在图的右下角。我在第一次写时就遗漏了最后一种特殊情况，导致只通过了一个点。还有一个陷阱是可能有多个数字会同时需要检查。\n要解决这个问题，有两个技巧：\n将图建大一圈，避免数组访问越界的问题。注意到统计一个数字周围时，只需要统计周围 $1$ 的个数即可，所以可以在图的周围填一圈 $0$，这样无论边缘还是中心的数字都可以统计周围一圈，不用担心越界问题。 在存储图时，不存储数字，而是存储“检查点”。“检查点”就是上文中提到的“一个数字周围最后的点”。当 DFS 填到“检查点”时，就意味着要触发“检查”。一个数字对应的“检查点”其实就是其右下角的座标和边界取最小值，这样就把复杂的 $8$ 个判断条件简化为 $2$ 个 min 语句，而且在输入时处理即可，搜索算法内部实现可以做到非常简洁。注意到一个座标可能是多个数字的检查点，因此对于每个座标使用一个链表来存储所有检查点，即使用一个链表类型的二维数组来存储这张图。 使用这两个技巧之后，在搜索算法内部不需要添加任何的额外判断，所有数字的处理方法都是一致的。在输入时仅需要加入一行语句来处理边缘点即可。非常优雅。\n代码 附上实现代码（该代码蓝桥杯官网全过，洛谷只能92分）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 #include \u0026lt;algorithm\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;list\u0026gt; using namespace std; struct loc { int x, y; }; struct point { loc l; int v; }; const int MAXN = 15; const loc NEAR[9] = {{-1, -1}, {-1, 0}, {-1, 1}, {0, -1}, {0, 0}, {0, 1}, {1, -1}, {1, 0}, {1, 1}}; int result[MAXN][MAXN]; int n, m; list\u0026lt;point\u0026gt; g[MAXN][MAXN]; void printresult() { for (int i = 1; i \u0026lt;= n; i++) { for (int j = 1; j \u0026lt;= m; j++) { cout \u0026lt;\u0026lt; result[i][j]; } cout \u0026lt;\u0026lt; endl; } } loc add(loc a) { if (a.y == m) { a.x++; a.y = 1; return a; } else { a.y++; return a; } } bool check(const loc \u0026amp;cur) { for (auto i : g[cur.x][cur.y]) { int \u0026amp;x = i.l.x, \u0026amp;y = i.l.y; int sum=0; for (auto j:NEAR) { sum+=result[x+j.x][y+j.y]; } if (sum!=i.v) return false; } return true; } bool dfs(loc cur) { if (cur.x \u0026gt; n || cur.y \u0026gt; m) { return true; } result[cur.x][cur.y] = 1; if (check(cur)) if (dfs(add(cur))) return true; result[cur.x][cur.y] = 0; if (check(cur)) if (dfs(add(cur))) return true; return false; } int main() { cin \u0026gt;\u0026gt; n \u0026gt;\u0026gt; m; for (int i = 1; i \u0026lt;= n; i++) { for (int j = 1; j \u0026lt;= m; j++) { char c; do { c = getchar(); } while (c != 95 \u0026amp;\u0026amp; (c \u0026lt; \u0026#39;0\u0026#39; || c \u0026gt; \u0026#39;9\u0026#39;)); if (c \u0026gt;= \u0026#39;0\u0026#39; \u0026amp;\u0026amp; c \u0026lt;= \u0026#39;9\u0026#39;) { int x = min(i + 1, n), y = min(j + 1, m); g[x][y].push_back({{i, j}, c - \u0026#39;0\u0026#39;}); } } } dfs({1, 1}); printresult(); return 0; } ","date":"2025-01-19T21:36:57+08:00","image":"http://blog.anlor.top/post/lanqiao2023pa-pixel-placement/images/cover_hu_49be28e6c0dbdefc.webp","permalink":"http://blog.anlor.top/post/lanqiao2023pa-pixel-placement/","title":"[蓝桥杯2023省A]像素放置题解"},{"content":"如果您知道封面图出处，请在评论区留言。\n题目描述 洛谷P9236\n给定一个数组 $A_i$，分别求其每个子段的异或和，并求出它们的和。或者说，对于每组满足 $1 \\leq L \\leq R \\leq n$ 的 $L,R$，求出数组中第 $L$ 至第 $R$ 个元素的异或和。然后输出每组 $L,R$ 得到的结果加起来的值。\n输入格式 输入的第一行包含一个整数 $n$ 。\n第二行包含 $n$ 个整数 $A_i$，相邻整数之间使用一个空格分隔。\n输出格式 输出一行包含一个整数表示答案。\n样例 #1 样例输入 #1 1 2 5 1 2 3 4 5 样例输出 #1 1 39 提示 【评测用例规模与约定】 对于 $30 %$ 的评测用例，$n \\leq 300$；\n对于 $60 %$ 的评测用例，$n \\leq 5000$;\n对于所有评测用例，$1 \\leq n \\leq 10^5$，$0 \\leq A_i \\leq 2^{20}$。\n解题思路 首先想到前缀和，然后枚举 $L$ 和 $R$ ，时间复杂度 $O(n^2)$ ，代码非常简单，可以拿下60分。代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 #include \u0026lt;iostream\u0026gt; using namespace std; const int MAXN=100000; int n,a[MAXN+5]; int sum[MAXN+5]; int main() { cin\u0026gt;\u0026gt;n; for (int i=1;i\u0026lt;=n;i++) { cin\u0026gt;\u0026gt;a[i]; if (i\u0026gt;=1) { sum[i]=a[i]^sum[i-1]; } } long long ans=0; for (int l=1;l\u0026lt;=n;l++) { for (int r=l;r\u0026lt;=n;r++) { ans+=sum[r]^sum[l-1]; } } cout\u0026lt;\u0026lt;ans; return 0; } 注意 ans 需要开 long long\n常规做法到这里就是极限了。如果要继续优化，就需要从异或运算的性质入手。位运算中，每个位的运算结果只与当前位有关，所以可以使用“拆位”的技巧，将位拆出来单独处理。注意到\n1 2 3 4 5 6 7 for (int l=1;l\u0026lt;=n;l++) { for (int r=l;r\u0026lt;=n;r++) { ans+=sum[r]^sum[l-1]; } } 这段代码实际上是将 sum 数组中的元素两两异或然后求和。每个元素恰好都与其它元素异或 $1$ 次。求和运算中，只有异或之后值为 $1$ 的位对求和运算有影响，表现为异或前两个操作数在该位上相异。因此可以这样考虑：使用一个新的数组 w[][2] 来统计每个位上 $0$ 和 $1$ 的数目，那么 w[i][0]*w[i][1] 实际上就是两两异或的过程中，该位的结果会产生的 $1$ 的个数（相应的，会产生 $0$ 的个数应该为 w[i][0]*w[i][0]+w[i][1]*w[i][1]）。这个数再乘上当前位的位权，就是它最终对 ans 做的贡献。也就是说，$ans=\\sum_{i=0}^{20}{w[i][0]\\times w[i][1]\\times 2^i}$ 这里累加下标从 $0$ 开始，是因为当 l=0 时，sum[0] 会参与异或。而上界 $20$ 是根据数据范围。\n由此很容易得到最终代码。注意循环范围和求和中的溢出问题。\n代码 附上通过代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 #include \u0026lt;array\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; const int MAXN = 100000; int n; array\u0026lt;int, MAXN + 5\u0026gt; sum, a; int w[25][2]; int main() { cin \u0026gt;\u0026gt; n; for (int i = 1; i \u0026lt;= n; i++) { cin \u0026gt;\u0026gt; a[i]; sum[i] = a[i] ^ sum[i - 1]; } long long ans = 0; for (int i = 0; i \u0026lt;= n; i++) { for (int j = 0; j \u0026lt;= 20; j++) { w[j][(sum[i]\u0026gt;\u0026gt;j)\u0026amp;1]++; } } for (int i = 0; i \u0026lt;= 20; i++) { ans += (long long)w[i][0] * w[i][1] * (1 \u0026lt;\u0026lt; i); } cout \u0026lt;\u0026lt; ans; return 0; } ","date":"2025-01-18T16:50:42+08:00","image":"http://blog.anlor.top/post/lanqiao2023pa-xor-sum/images/cover_hu_59c6e3346a5d45b7.webp","permalink":"http://blog.anlor.top/post/lanqiao2023pa-xor-sum/","title":"[蓝桥杯2023省A]异或和之和题解"},{"content":"封面图：https://www.pixiv.net/artworks/126215041\n题目描述 洛谷P9235\n有一个局域网，由 $n$ 个设备和 $m$ 条物理连接组成，第 $i$ 条连接的稳定性为 $w_i$。\n对于从设备 $A$ 到设备 $B$ 的一条经过了若干个物理连接的路径，我们记这条路径的稳定性为其经过所有连接中稳定性最低的那个。\n我们记设备 $A$ 到设备 $B$ 之间通信的稳定性为 $A$ 至 $B$ 的所有可行路径的稳定性中最高的那一条。\n给定局域网中的设备的物理连接情况，求出若干组设备 $x_i$ 和 $y_i$ 之间的通信稳定性。如果两台设备之间不存在任何路径，请输出 $-1$。\n输入格式 输入的第一行包含三个整数 $n,m,q$，分别表示设备数、物理连接数和询问数。\n接下来 $m$ 行，每行包含三个整数 $u_i,v_i,w_i$，分别表示 $u_i$ 和 $v_i$ 之间有一条稳定性为 $w_i$ 的物理连接。\n接下来 $q$ 行，每行包含两个整数 $x_i,y_i$，表示查询 $x_i$ 和 $y_i$ 之间的通信稳定性。\n输出格式 输出 $q$ 行，每行包含一个整数依次表示每个询问的答案。\n样例 #1 样例输入 #1 1 2 3 4 5 6 7 8 5 4 3 1 2 5 2 3 6 3 4 1 1 4 3 1 5 2 4 1 3 样例输出 #1 1 2 3 -1 3 5 提示 【评测用例规模与约定】\n对于 $30 %$ 的评测用例，$n,q \\leq 500$，$m \\leq 1000$；\n对于 $60 %$ 的评测用例，$n,q \\leq 5000$，$m \\leq 10000$；\n对于所有评测用例，$2 \\leq n,q \\leq 10^5$，$1 \\leq m \\leq 3 \\times 10^5$，$1 \\leq u_i,v_i,x_i,y_i \\leq n$，$ 1 \\leq w_i \\leq 10^6$，$u_i \\neq v_i$，$x_i \\neq y_i$。\n解题思路 这道题最开始很容易往最短路的方向想，但是最短路的复杂度是 $O(n^3)$，对于 $n \\leq 10^5$ 的数据量是不可接受的。\n注意到路径的稳定性与路径中经过的单条边的边权有关，但与整条路径的长度无关。因此其实可以对每条边做贪心，而不需要像最短路一样计算整条路径的长度。又因为两个设备之间的稳定性为所有可行路径中稳定性最高的一条，因此贪心的策略就是要不断取稳定性高的边，直到设备之间连通起来。这实际上是“最大生成树”。其算法可以采用和最小生成树一样的算法。这样处理之后，我们就可以剪去很多无用的路径，从图中生成几颗“最大生成树”（注意到这不一定是连通图，因此可能得到多颗生成树）。在同一颗生成树内的就是互相连通的，否则不能到达。这可以通过求生成树过程中的并查集来判断。\n生成了树之后，就要考虑如何高效地处理查询。尽管经过剪枝，图的路径数已经大大降低了，但每个查询跑一遍搜索大概还是不能接受的。考虑到这是一棵树，可以使用最近公共祖先算法，让两个叶子节点同时向根部回溯直到相遇，记录下回溯过程中经过的最小边权即为答案。\n具体实现上，最小生成树算法并不能实际给到一个树的数据结构，只能得到一张有向无环图。可以在算法过程中处理来得到树，我这里直接选择在有向无环图上跑一遍dfs来生成树，时间复杂度 $O(n)$ 。朴素 LCA 的时间复杂度是 $O(n)$ ，每次查询都跑一遍的话最后一个点会超时，需要使用倍增 LCA，倍增 LCA 需要注意倍增的上界，避免数组越界问题。\n代码 最后附上代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 #include \u0026lt;algorithm\u0026gt; #include \u0026lt;cassert\u0026gt; #include \u0026lt;cmath\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;list\u0026gt; #include \u0026lt;queue\u0026gt; #include \u0026lt;utility\u0026gt; #include \u0026lt;vector\u0026gt; using namespace std; const int MAXN = 100000, MAXQ = 100000, MAXM = 300000, MAXW = 1000000; struct edge { int u, v, w; bool operator\u0026lt;(const edge \u0026amp;other) const { return w \u0026lt; other.w; } bool operator\u0026gt;(const edge \u0026amp;other) const { return w \u0026gt; other.w; } bool operator==(const edge \u0026amp;other) const { return w == other.w; } }; struct ledge { int to, w; }; struct lnode { list\u0026lt;ledge\u0026gt; e; }; struct tnode { vector\u0026lt;ledge\u0026gt; fa; list\u0026lt;ledge\u0026gt; son; int d = -1; } tree[MAXN + 5]; class ufset { struct ufnode { int f = -1, d = 1; }; vector\u0026lt;ufnode\u0026gt; node; public: ufset(int n) { node.resize(n); } int find(int a) { if (node[a].f == -1) return a; else return node[a].f = find(node[a].f); } int uni(int a, int b) { a = find(a); b = find(b); if (a == b) return -1; if (node[a].d \u0026lt; node[b].d) node[a].f = b; else { if (node[a].d == node[b].d) { node[a].f = b; node[b].d++; } else { node[b].f = a; } } return 0; } } uf(MAXN + 5); int n, m, q; void dfs(int cur, int fa, int d, const vector\u0026lt;lnode\u0026gt; \u0026amp;ln) { tree[cur].d = d; for (auto i:ln[cur].e) { if (fa != -1 \u0026amp;\u0026amp; i.to == fa) { tree[cur].fa.push_back(i); } else { tree[cur].son.push_back(i); } } for (int i = 0; i \u0026lt; tree[cur].fa.size(); i++) { int fa = tree[cur].fa[i].to; if (tree[fa].fa.size() \u0026lt;= i) break; tree[cur].fa.push_back({tree[fa].fa[i].to, min(tree[fa].fa[i].w, tree[cur].fa[i].w)}); } for (auto i : tree[cur].son) { dfs(i.to, cur, d + 1, ln); } } void gen_tree() { priority_queue\u0026lt;edge\u0026gt; e; for (int i = 0; i \u0026lt; m; i++) { edge ei; cin \u0026gt;\u0026gt; ei.u \u0026gt;\u0026gt; ei.v \u0026gt;\u0026gt; ei.w; e.push(std::move(ei)); } int sum = 0; vector\u0026lt;lnode\u0026gt; ln(n + 5); while (sum \u0026lt; n - 1 \u0026amp;\u0026amp; !e.empty()) { edge ei = e.top(); e.pop(); if (uf.uni(ei.u, ei.v) == -1) continue; sum++; ln[ei.u].e.push_back({ei.v, ei.w}); ln[ei.v].e.push_back({ei.u, ei.w}); } for (int i = 1; i \u0026lt;= n; i++) { if (tree[i].d == -1) { dfs(i, -1, 0, ln); } } } int lca(int u, int v) { int ans = MAXW + 5; while (tree[u].d \u0026gt; tree[v].d) { int d = log2(tree[u].d - tree[v].d); ans = min(tree[u].fa[d].w, ans); u = tree[u].fa[d].to; } while (tree[v].d \u0026gt; tree[u].d) { int d = log2(tree[v].d - tree[u].d); ans = min(tree[v].fa[d].w, ans); v = tree[v].fa[d].to; } assert(tree[u].d == tree[v].d); if (u == v) return ans; while (tree[u].fa[0].to != tree[v].fa[0].to) { int logd = log2(tree[u].d); for (int i = logd; i \u0026gt;= 0; i--) { if (tree[u].fa[i].to == tree[v].fa[i].to) continue; ans = min(min(ans, tree[u].fa[i].w), tree[v].fa[i].w); u = tree[u].fa[i].to; v = tree[v].fa[i].to; break; } } ans = min(min(ans, tree[u].fa[0].w), tree[v].fa[0].w); return ans; } void ans() { for (int i = 0; i \u0026lt; q; i++) { int u, v; cin \u0026gt;\u0026gt; u \u0026gt;\u0026gt; v; if (uf.find(u) != uf.find(v)) { cout \u0026lt;\u0026lt; -1 \u0026lt;\u0026lt; endl; continue; } cout \u0026lt;\u0026lt; lca(u, v) \u0026lt;\u0026lt; endl; } } int main() { cin \u0026gt;\u0026gt; n \u0026gt;\u0026gt; m \u0026gt;\u0026gt; q; gen_tree(); ans(); return 0; } ","date":"2025-01-18T11:01:57+08:00","image":"http://blog.anlor.top/post/lanqiao2023pa-network-stability/images/cover_hu_596ad78b2606b891.webp","permalink":"http://blog.anlor.top/post/lanqiao2023pa-network-stability/","title":"[蓝桥杯2023省A]网络稳定性题解"},{"content":"封面图：https://www.bilibili.com/opus/993288080028860441\n引言 迄今为止，缓冲区溢出已被识别为最常见且最危险的漏洞。根据 2019 年通用漏洞和暴露（CVE）列表，它是报告频率最高的漏洞，报告了超过 400 个漏洞。截至 2021 年 5 月，CVE 数据库中报告的缓冲区溢出漏洞数量已达到 13,700 多个。在所有缓冲区溢出漏洞中，基于栈的缓冲区溢出是最常见的类型。本文将介绍各种用于缓解缓冲区溢出漏洞的机制和它们的绕过方法，并介绍最近的一个栈溢出漏洞 CVE-2024-9632。\n基于硬件的缓解技术 基于硬件的缓解技术中，最著名的就是 NX（No-eXecute）技术。NX 技术是一种硬件支持的内存保护技术，它通过在内存页表中设置一个标志位来防止代码执行。当试图执行一个被标记为不可执行的内存区域时，CPU 会产生一个异常，从而阻止攻击者执行恶意代码。还有一些其它的研究工作，如 SmashGuard 通过在 CPU 上实现一个硬件堆栈来保存返回地址，HSDefender 通过设计安全的call指令来防止栈溢出攻击等。以下主要介绍 NX 技术。\nNX技术 在冯诺依曼架构中，使用相同的内存空间来存储代码和数据。这是通过分页来实现的，但分页机制不允许用户在特定内存区域上独立设置读写和执行权限，而仅允许三种权限：non-accessible、readable-executable（RX）和 readablewriteable-executable（RWX）。因此，如果页面设置了可读写，那么它就一定是可执行的。攻击者可以利用这一点，将恶意代码注入到内存中，并通过覆盖返回地址的方式来执行恶意代码。为了解决这个问题，NX技术被引入到现代 CPU 中。\n操作系统可以通过操作NX位来将某些内存区域标记为不可执行。NX 的位号是 63，这是页表中最高有效位。如果特定页面的 NX 位设置为 0，则可以执行该代码；如果设置为 1，则它是仅包含数据的不可执行页面。NX（no-execute）功能仅适用于 64 位模式。NX 功能的一个重要之处在于它是运行时策略，应用无需重新编译即可从此功能中受益。操作系统利用 NX 位将堆栈/堆内存标记为不可执行，从而防止了相当一部分利用缓冲区溢出的代码注入攻击。\nNX技术的绕过 为了绕过 NX 技术，一种常见的思路是代码重用攻击。代码重用攻击是一种利用程序中已有的代码片段来实现攻击目的的攻击方式。这种攻击方式不需要向内存中注入恶意代码。ROP攻击是一种高级形式的代码重用攻击。以下介绍 ROP 攻击的基本原理。\nROP攻击 ROP 使用预先存在的名为 “gadgets” 的小指令序列来实施攻击。这些 gadgets 是结合在一起可以执行高级任务的短指令序列。gadgets 必须是有效的以返回指令结尾指令序列，从而让 CPU 继续执行下一个 gadgets 或有效载荷。在发起攻击时，攻击者通常会用跳转到第一个 gadgets 的代码指针覆盖堆栈上保存的返回地址。ROP 攻击的流程图如下：\n常见的可用于 ROP 攻击的 gadgets 包括 pop rdi; ret 和 system() 等。system() 函数可以执行系统命令，而 pop rdi; ret 可以将参数传递给 system() 函数。通过将这两个 gadgets 结合在一起，攻击者可以执行任意系统命令。在查找 gadgets 的步骤，攻击者可以通过各种调试器和反汇编工具来在二进制文件中查找这些指令。\n基于操作系统的缓解技术 有许多基于操作系统的缓解技术。例如 libsafe 通过修改标准库来提供更安全的函数，地址混淆和二进制搅拌技术都是在加载时对目标二进制文件进行修改，以使攻击者难以找到目标函数的地址。而最常见的基于操作系统的缓解技术是 ASLR 技术。以下主要介绍 ASLR 技术。\nASLR技术 在上文中介绍了 ROP，其必须知道 gadgets 的地址。ASLR随机化进程各个部分的基址，包括堆栈、堆、共享库和可执行文件。因此，攻击者不能每次都使用相同的漏洞来滥用相同的易受攻击的程序。每次程序运行时，系统会将程序的代码段、堆、栈以及动态库加载到不同的随机地址。攻击者无法直接预测这些关键区域的精确地址，从而难以利用固定地址的内存漏洞。\nASLR技术的绕过 在 32 位系统中，ASLR 技术的绕过相对容易，因为 32 位系统的内存地址空间相对较小，ASLR 的随机性有限，地址中通常只有 8 或 16 位被随机化。攻击者可以通过不断尝试可能的内存地址来暴力破解 ASLR。在 64 位系统中，地址中有 40 位被随机化，因此暴力破解的难度大大增加。但是，攻击者仍然可以通过泄露内存地址和信息泄露等方式来绕过 ASLR。以下主要介绍信息泄露技术。\n信息泄露技术 信息泄露技术的流程如下：\n其中一种信息泄露方法是利用过程链接表（PLT）和全局偏移表（GOT）这两种数据结构。PLT 是一种数据结构，用于调用外部函数，这些函数的地址在运行时由动态链接器解析。GOT 是一个数组，其中包含进程当前使用的全局变量和库函数的绝对地址。当程序调用函数时，查找 PLT 中的条目，条目中包含跳转指令，可以跳转到 GOT 中的条目。GOT 调用动态链接器来获取函数地址并存储在自己的条目中，这样程序就可以通过函数地址来调用函数。如下图所示。\n通过获取 PLT 和 GOT 中的地址，攻击者可以泄露程序中的地址信息，从而绕过 ASLR。具体地说，利用调试器可以获取某个函数的 PLT 地址，之后反汇编该地址的指令就能获得 GOT 的地址。通过获得 GOT 中的地址，攻击者可以获取到函数的绝对地址。之后通过库文件来获取其它函数和该函数的相对偏移，就可以利用该函数的地址加上相对偏移来调用任意库函数。\n另外，printf() 函数也是一个常用的信息泄露工具。如果攻击者可以修改 printf() 函数的格式控制字符串，就可以泄露栈上的数据。例如，通过 %lx 格式控制字符，攻击者就可以获得泄露的内存地址。\n基于编译器的缓解技术 有许多缓解技术在编译器这一层面实现。其中一些可视为对基于操作系统和硬件的缓解技术的补充。例如，PIC 技术将代码编译为位置无关的机器码指令，从而让这些指令可以被加载到任意地址；PIE 技术编译出的可执行文件的不同部分可以使用不同的基址，从而增加了攻击者猜测基址的难度。这两个技术都是 ASLR 的补充。另一些技术则从其它方面进行保护，例如 StackGuard, ProPolice, Address Sanitizer 等。基于编译器的缓解技术很多，且大多数都在主流编译器中有广泛应用，无法一一列举，以下主要介绍 RELRO 和 StackGuard 两种技术。\nRELRO技术 RELRO 技术是一种保护机制，用于防止全局偏移表（GOT）被攻击者利用。在首次调用共享库函数时，动态链接器会将 GOT 表中的地址填充为真实地址。也就是说，GOT 表在程序运行时是可写的。并且，GOT 表需要位于已知位置，因为它包含了程序运行时的必要信息。这给了攻击者一个机会，可以通过覆盖 GOT 表中的地址来执行恶意代码。\nRELRO 技术通过将 GOT 表标记为只读来解决这个问题。RELRO 技术有两种类型：Partial RELRO 和 Full RELRO。Partial RELRO 会在程序启动时将 GOT 表的部分标记为只读，通常只保护不频繁改变的全局变量和一些只读数据段。动态链接中的某些函数指针仍可能被攻击者利用。Full RELRO比Partial RELRO更安全，它会在程序加载完成后，将整个GOT段标记为只读。所有GOT表项，包括动态链接的函数指针，都会被标记为只读，彻底防止了指针修改。其缺点是对程序启动速度影响较大，因为它要求在程序运行前完成所有符号解析。\nRELRO技术的绕过 RELRO 技术的绕过方法主要针对 Partial RELRO。对于 Partial RELRO，动态链接中的某些函数指针未受保护，例如 printf() 函数。可以通过覆写 GOT 表中的 printf() 函数指针来执行任意代码。\nStackGuard 技术 StackGuard 通过在返回地址旁边插入一个“护符”（Canary）来防止返回地址被修改。该护符将会挡在缓冲区和返回地址之间。在函数体执行完毕后，控制返回前，它会将护符与保存在其它位置的副本进行比较，只有当护符保持不变时，才会跳转到函数的返回地址。在常规的栈溢出攻击中，攻击者采用的方法是线性、顺序且按升序覆盖字节。这使得在不覆盖护符的情况下改变返回地址几乎不可能。\nStackGuard 技术的绕过 绕过 StackGuard 技术的主要思路是想办法找到护符的值。刚刚提到，护符位于缓冲区和返回地址之间的某个位置，同时在其它位置还会有护符的副本；在程序返回时，为了检查护符是否被篡改，护符通常还会被加载到寄存器中。这些情况为信息泄露攻击提供了机会。通过信息泄露攻击，攻击者可以获取到护符的值，从而绕过 StackGuard 技术。\n漏洞 CVE-2024-9632 介绍 CVE-2024-9632 漏洞是一个缓冲区溢出漏洞。该漏洞存在于 xorg-xserver 中。该软件是 Linux 系统下最常见的显示服务器，用于管理图形显示设备。本地攻击者可以通过利用该漏洞发起拒绝服务攻击，如果 xorg-xserver 以 root 权限运行，攻击者还能获得提升的权限，甚至完全控制系统。该漏洞被标记为高危险性。\n漏洞原理 产生漏洞的代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 XkbSymInterpretPtr sym; unsigned int skipped = 0; if ((unsigned) (req-\u0026gt;firstSI + req-\u0026gt;nSI) \u0026gt; compat-\u0026gt;num_si) { compat-\u0026gt;num_si = req-\u0026gt;firstSI + req-\u0026gt;nSI; compat-\u0026gt;sym_interpret = reallocarray(compat-\u0026gt;sym_interpret, compat-\u0026gt;num_si, sizeof(XkbSymInterpretRec)); if (!compat-\u0026gt;sym_interpret) { compat-\u0026gt;num_si = 0; return BadAlloc; } } 其中，compat-\u0026gt;sym_interpret 是一个数组，存储了 XkbSymInterpretRec 类型的结构体，每个结构体表示一种键盘符号（symbol）与修饰符（modifier）组合的解释方式。该解释方式用于决定在特定符号与修饰符输入下应该执行的操作，例如：\n激活一个功能键。 触发一个特定的动作（如执行 XFree86 私有动作 XkbSA_XFree86Private）。 而 compat-\u0026gt;num_si 是一个整型变量，记录当前 compat-\u0026gt;sym_interpret 数组中元素的个数。\n在这段代码中没有出现的还有一个变量，那就是 compat-\u0026gt;size_si。该变量用于记录 compat-\u0026gt;sym_interpret 数组的容量。它和 compat-\u0026gt;num_si 的区别在于，compat-\u0026gt;num_si 记录的是当前数组中的元素个数，而 compat-\u0026gt;size_si 记录的是数组的容量。也就是说，在理想情况下，compat-\u0026gt;num_si 应该永远小于等于 compat-\u0026gt;size_si。当 compat-\u0026gt;num_si 等于 compat-\u0026gt;size_si 时，表示数组已满。\nxorg-xserver 允许客户端发起更新 compat-\u0026gt;sym_interpret 数组的请求。请求中的 firstSI 和 nSI 字段分别表示要更新的数组元素的起始位置和更新的元素个数。当它们之和超过了当前元素个数时，表示客户端需要增加数组的大小。reallocarray 函数会重新分配内存，以容纳新的元素。这就是这段代码的主要工作。\n然而，这段代码中仅仅更新了 compat-\u0026gt;num_si，而没有更新 compat-\u0026gt;size_si。这样就可能不再满足上文提到的 compat-\u0026gt;num_si 应永远小于等于 compat-\u0026gt;size_si 的假设。在之后，它调用 reallocarray 将 compat-\u0026gt;sym_interpret 调整为了 compat-\u0026gt;num_si 的大小。这就导致数组被分配到的内存空间超过了它本应占有的内存空间 compat-\u0026gt;size_si，从而导致了缓冲区溢出漏洞。攻击者可以通过构造一个特别长的更新请求来触发这个漏洞。\n防御措施 以下是修复补丁，相关链接\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @@ -2990,13 +2990,13 @@ _XkbSetCompatMap(ClientPtr client, DeviceIntPtr dev, XkbSymInterpretPtr sym; unsigned int skipped = 0; - if ((unsigned) (req-\u0026gt;firstSI + req-\u0026gt;nSI) \u0026gt; compat-\u0026gt;num_si) { - compat-\u0026gt;num_si = req-\u0026gt;firstSI + req-\u0026gt;nSI; + if ((unsigned) (req-\u0026gt;firstSI + req-\u0026gt;nSI) \u0026gt; compat-\u0026gt;size_si) { + compat-\u0026gt;num_si = compat-\u0026gt;size_si = req-\u0026gt;firstSI + req-\u0026gt;nSI; compat-\u0026gt;sym_interpret = reallocarray(compat-\u0026gt;sym_interpret, - compat-\u0026gt;num_si, + compat-\u0026gt;size_si, sizeof(XkbSymInterpretRec)); if (!compat-\u0026gt;sym_interpret) { - compat-\u0026gt;num_si = 0; + compat-\u0026gt;num_si = compat-\u0026gt;size_si = 0; return BadAlloc; } } 从补丁文件中可以看出，主要的修复措施是在更新 compat-\u0026gt;num_si 时，同时更新 compat-\u0026gt;size_si。这样就保证了 compat-\u0026gt;sym_interpret 数组的大小不会超过它的容量。\n总结 本文介绍了缓冲区溢出漏洞的概念，以及各种缓解缓冲区溢出漏洞的技术。其中，NX 技术通过将内存区域标记为不可执行来防止代码注入攻击；ASLR 技术通过随机化进程各个部分的基址来增加攻击者猜测基址的难度；RELRO 技术通过将 GOT 表标记为只读来防止攻击者利用 GOT 表；StackGuard 技术通过在返回地址旁边插入一个护符来防止返回地址被修改。最后，本文介绍了一个最近的栈溢出漏洞 CVE-2024-9632 的原理和修复措施。可以看到，尽管有了这些防御措施，但仍然有很多方法可以绕过这些技术，而错误的代码实现也会导致漏洞的产生。现有的漏洞缓解技术仍有很大提升空间，值得进一步研究。同时开发者也应该在编写代码时遵循安全编程规范，同时关注最新的漏洞信息，及时修复已知的漏洞。\n","date":"2024-11-20T16:37:15+08:00","image":"http://blog.anlor.top/post/buffer-overflow-overview/images/cover_hu_642561e6ac65451b.webp","permalink":"http://blog.anlor.top/post/buffer-overflow-overview/","title":"缓冲区溢出漏洞及缓解机制"},{"content":"封面图：https://www.bilibili.com/opus/977994994306514949\n引言 树上启发式合并的算法本身并不难，更值得关注的是它的时间复杂度。启发式算法的时间复杂度通常比较玄学，然而在树上启发式合并中，我们可以看到启发式算法是如何极大优化时间复杂度的。本文将通过一道蓝桥杯的题目来讲解树上启发式合并的算法，并分析它的时间复杂度。\n例题 洛谷P9233\n[蓝桥杯 2023 省 A] 颜色平衡树 题目描述 给定一棵树，结点由 $1$ 至 $n$ 编号，其中结点 $1$ 是树根。树的每个点有一个颜色 $C_i$。\n如果一棵树中存在的每种颜色的结点个数都相同，则我们称它是一棵颜色平衡树。\n求出这棵树中有多少个子树是颜色平衡树。\n输入格式 输入的第一行包含一个整数 $n$，表示树的结点数。\n接下来 $n$ 行，每行包含两个整数 $C_i,F_i$，用一个空格分隔，表示第 $i$ 个结点的颜色和父亲结点编号。\n特别地，输入数据保证 $F_1$ 为 $0$，也即 $1$ 号点没有父亲结点。保证输入数据是一棵树。\n输出格式 输出一行包含一个整数表示答案。\n样例 #1 样例输入 #1 1 2 3 4 5 6 7 6 2 0 2 1 1 2 3 3 3 4 1 4 样例输出 #1 1 4 提示 【样例说明】 编号为 $1,3,5,6$ 的 $4$ 个结点对应的子树为颜色平衡树。\n【评测用例规模与约定】 对于 $30 %$ 的评测用例，$n \\leq 200$，$C_i \\leq 200$；\n对于 $60 %$ 的评测用例，$n \\leq 5000$，$C_i \\leq 5000$；\n对于所有评测用例，$1 \\leq n \\leq 200000$，$1 \\leq C_i \\leq 200000$，$0 \\leq F_i\u0026lt;i$。\n题解 注意到该题是一道数据离线的树上数颜色的问题。首先可以想到用 dfs ，枚举每一颗子树，分别进行dfs统计颜色数量，然后判断是否是颜色平衡树。但是这样的时间复杂度是 $O(n^2)$ 的，显然不可接受。\n注意到在这种算法中，有些点被重复计算了很多次。能不能减少点被计算的次数呢？很容易想到对于一颗子树，如果它的儿子的子树已经统计过了，那么它只需要直接合并儿子的统计结果就可以了。我们可以用一个桶数组来存储每个颜色出现的次数。颜色数量很多，不能为每个点开一个桶，也不能使用 memset 和 memcpy 来初始化和合并桶数组。很容易想到为子节点和父节点分别开一个桶，对于每颗子树，扫描三次，第一次统计子节点的颜色数量到子节点桶，第二次合并子节点的颜色数量到父节点，第三次清除子节点桶以供兄弟节点使用。每次一层统计完时，就交换子节点桶和父节点桶。这样子未免有点麻烦，其实可以只使用一个数组。每个子树依然扫描三次，第一次统计子节点的颜色数量到数组，第二次清除数组以供兄弟节点使用。在所有兄弟节点用完一遍之后，再统计一次子节点的颜色数量到数组。这样就能统计所有子树的颜色数量给到父节点了。\n在实现以上算法时，很容易想到的一个优化是在最后一个兄弟节点使用数组时，可以不用清除数组。直接将其它兄弟节点的颜色数量合并到数组上，这样就能免除这个兄弟节点的清除和重新加入数组两边遍历操作。这个优化的效果很大程度上取决于这个“最后一个兄弟节点”的选择。显然，如果我们能够选择一个“最后一个兄弟节点”，使得它的子节点数量最多，那么这个优化的效果就会最大。这就是启发式合并的核心思想。\n时间复杂度思考 在这个算法中，一个节点仍然会被遍历多次。在一层中，一个轻节点会被遍历 3 次，一个重节点会被遍历 1 次。轻节点的儿子也会因为轻节点的需要而被遍历多次。但是，重节点却没有这样的问题。当我们分析时间复杂度时，我们可以从每个节点的角度来分析。对于每一个节点，它被遍历到的次数最多是 $O(\\log n)$ 的。我们可以从它的轻节点祖先数量来思考。一个节点如果是轻节点，那么它的子树的节点数量一定小于它的父节点的子树的节点数量的一半（如果超过一半，那么它就是重节点了）。由此，从根节点到某个节点，每经过一条轻边，节点的子树的节点数量就会减少一半。所以，一个节点的轻节点祖先数量最多是 $O(\\log n)$ 的（否则总节点数就要超过 $n$ 了）。所以整体的时间复杂度是 $O(n\\log n)$ 的。\n从以上思考可以看出，树上启发式合并确保了每个节点的遍历次数是 $O(\\log n)$ 的，这是由树的性质和一个简单的启发式操作决定的。如果不使用启发式合并，那么最糟糕的情况下，时间复杂度仍然会接近 $O(n^2)$。\n代码实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 #include\u0026lt;iostream\u0026gt; #include\u0026lt;vector\u0026gt; using namespace std; const int MAXN=200005,MAXC=200005; int n,ans=0; int color[MAXN],cnt[MAXC],ccnt[MAXN],ns[MAXN]; vector\u0026lt;int\u0026gt;son[MAXN]; int chson(int node); void dfs(int node); void add(int node,int delta); int chson(int node) { int result=1; for (auto \u0026amp;i:son[node]) { ns[i]=chson(i); result+=ns[i]; if (ns[i]\u0026gt;ns[son[node][0]]) { swap(i,son[node][0]); } } return result; } void dfs(int node) { for (auto i=son[node].begin()+1;i\u0026lt;son[node].end();i++) { dfs(*i); add(*i,-1); } if (son[node].size()\u0026gt;0) dfs(son[node][0]); ccnt[cnt[color[node]]]--; cnt[color[node]]++; ccnt[cnt[color[node]]]++; for (auto i=son[node].begin()+1;i\u0026lt;son[node].end();i++) { add(*i,1); } if (cnt[color[node]]*ccnt[cnt[color[node]]]==ns[node]) ans++; } void add(int node,int delta) { ccnt[cnt[color[node]]]--; cnt[color[node]]+=delta; ccnt[cnt[color[node]]]++; for (auto i:son[node]) { add(i,delta); } } int main() { cin\u0026gt;\u0026gt;n; for (int i=1;i\u0026lt;=n;i++) { int c,f; cin\u0026gt;\u0026gt;c\u0026gt;\u0026gt;f; color[i]=c; if (f!=0) son[f].push_back(i); } ns[1]=chson(1); dfs(1); cout\u0026lt;\u0026lt;ans; } ","date":"2024-09-06T20:37:21+08:00","image":"http://blog.anlor.top/post/dsu-on-tree/images/cover_hu_6e6dbabf095222f0.webp","permalink":"http://blog.anlor.top/post/dsu-on-tree/","title":"树上启发式合并"},{"content":"封面图：https://www.bilibili.com/opus/967606108335112258\n前言 本篇介绍一些算法竞赛中常见的幂的计算方法。本篇并不重点介绍快速幂等基本算法，而是针对一些特殊情况，介绍一些可用于优化的数学定理，以在快速幂的时间复杂度都无法满足题目要求的情况下，进一步优化算法。\n前置知识 同余的运算性质 设 $m$ 是一个正整数，$a_1, a_2, b_1, b_2$ 是 $4$ 个整数。如果 $$ a_1 \\equiv b_1 \\pmod{m}, \\quad a_2 \\equiv b_2 \\pmod{m} $$ 则\n$a_1 + a_2 \\equiv b_1 + b_2 \\pmod{m}$ $a_1 \\cdot a_2 \\equiv b_1 \\cdot b_2 \\pmod{m}$ 需要注意的是，同余的运算性质中没有针对除法的性质。\n快速幂 C++ 本身有提供 pow() 函数，但该函数是浮点数的幂运算，且不支持取模运算。在竞赛中，我们常常遇到大整数的幂运算和模幂运算，这需要自己实现快速幂算法。\nPython 的 pow() 函数支持大整数的幂运算和模幂运算，且在我的测试中，速度比自己实现的快速幂要快。因此，如果是 Python 选手，建议直接使用 pow() 函数。\n常见的快速幂算法，也就是模重复平方算法。快速模幂运算的解释，网络上有很多，不是本文的重点。简单来说，快速幂就是将指数表示成二进制（$2^n+2^{n-1}+\u0026hellip;+2^1+2^0$）的形式，然后通过不断平方基数来减少乘法的次数。以下是一个简单的快速幂的实现：\n1 2 3 4 5 6 7 8 9 10 11 long long quick_pow(long long a, long long b, long long mod) { long long ans = 1; while (b) { if (b \u0026amp; 1) { ans = ans * a % mod; } a = a * a % mod; b \u0026gt;\u0026gt;= 1; } return ans; } 快速计算模幂运算的算法不只这一种。针对这一种算法，也有一些常数级别的优化方法。如果感兴趣，也可以自行了解其他算法。\n欧拉函数 欧拉函数 $\\varphi(n)$ 表示小于等于 $n$ 的正整数中与 $n$ 互质的数的个数。\n欧拉函数的性质 如果 $p$ 是质数，则 $\\varphi(p) = p - 1$ 如果 $p$ 是质数，则 $\\varphi(p^k) = p^k - p^{k-1}$ 设 $m,n$ 是互质的两个正整数，则 $\\varphi(mn) = \\varphi(m) \\cdot \\varphi(n)$。特别地，如果 $m,n$ 是质数，则 $\\varphi(mn) = (m-1)(n-1)$ 设正整数 $m$ 的标准因数分解式为 $m = \\underset{p|m}{\\prod} p^k = p_1^{k_1} \\cdot p_2^{k_2} \\cdot \\cdots \\cdot p_n^{k_n}$，则 $\\varphi(m) = \\underset{p|m}{\\prod}\\varphi(p^k) = m\\underset{p|m}{\\prod}(1-\\frac{1}{p}) = m \\cdot (1 - \\frac{1}{p_1}) \\cdot (1 - \\frac{1}{p_2}) \\cdot \\cdots \\cdot (1 - \\frac{1}{p_n})$ 设 $m$ 是一个正整数，则 $\\underset{d|m}{\\sum} \\varphi(d) = m$。 以下给出求解欧拉函数的 C++ 代码，该代码利用了性质4和性质2：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 long long phi(long long a) { long long result = a; for (int i = 2; i * i \u0026lt;= a; i++) { if (a % i == 0) { while (a % i == 0) a /= i; result -= result / i; } } if (a \u0026gt; 1) result -= result / a; return result; } 幂的计算方法 欧拉定理 设 $m$ 是大于 $1$ 的正整数，$a$ 是与 $m$ 互质的整数，则 $$ a^{\\varphi(m)} \\equiv 1 \\pmod{m} $$ 该定理提示了，如果题目中存在条件基数 $a$ 和模数 $m$ 互质，那么指数 $k$ 就可以简化为 $k \\mod{\\varphi(m)}$。\n费马小定理 设 $p$ 是一个质数，则对任意整数 $a$，有 $$ a^p \\equiv a \\pmod{p} $$ 推论：设 $p$ 是一个质数，则对任意整数 $a$，以及对任意正整数 $t,k$，有 $$ a^{k \\cdot (p-1) + t} \\equiv a^t \\pmod{p} $$ 该推论提示了，如果题目中存在条件模数 $p$ 为质数，那么指数 $k$ 就可以简化为 $k \\mod{(p-1)}$。容易发现，费马小定理是欧拉定理在模数为质数时的特殊情况。\n拓展欧拉定理 欧拉定理要求基数 $a$ 与模数 $m$ 互质，而拓展欧拉定理则放宽了这一条件。设 $m$ 是大于 $1$ 的正整数，$a$ 是任意整数，$k \\geq \\varphi(m)$，则 $$ a^k \\equiv a^{k \\mod{\\varphi(m)} + \\varphi(m)} \\pmod{m} $$ 如果 $k\u0026lt;\\varphi(m)$，那么只需要直接计算 $a^k$ 即可。该定理去除了欧拉定理中 $a$ 与 $m$ 互质的要求。因此，通过扩展欧拉定理，我们就能将几乎所有情况下的指数简化到小于 $2\\varphi(m)$。\n实操演练 洛谷P10414\n[蓝桥杯 2023 国 A] 2023 次方 题目描述 求 $2^{(3^{(4^{(\\ldots ^{2023}})})}$ 的值对 $2023$ 取模的结果。\n注: 上式都是指数，可写为 $2** (3**(4**(\\ldots 2023\\ldots))$ 其中 $**$ 表示指数。\n这是一道结果填空的题，你只需要算出结果后提交即可。本题的结果为一个整数，在提交答案时只填写这个整数，填写多余的内容将无法得分。\n输入格式 输出格式 解题思路 注意到，$2023=7 \\times 17 \\times 17$，并非质数；通过计算可知，$\\varphi(2023)=1632$，而 $gcd(3,1632)\\neq 1$。因此，不能使用费马小定理和欧拉定理。考虑使用扩展欧拉定理求解。本题目中要求解的算式也比较复杂，建议动手算算前几项来确定递归关系。\n代码实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 #include \u0026lt;iostream\u0026gt; using namespace std; const int n = 2023, mod = 2023; int phim; long long phi(long long a) { long long result = a; for (int i = 2; i * i \u0026lt;= a; i++) { if (a % i == 0) { while (a % i == 0) a /= i; result -= result / i; } } if (a \u0026gt; 1) result -= result / a; return result; } long long fpow(long long a, long long b, long long mod) { long long ans = 1; while (b \u0026gt; 0) { if (b \u0026amp; 1) ans = ans * a % mod; a = a * a % mod; b \u0026gt;\u0026gt;= 1; } return ans; } long long f(long long a, long long m) { if (a == n) return a; long long phim = phi(m); return fpow(a, f(a + 1, phim) + phim, m); } int main() { cout \u0026lt;\u0026lt; f(2, mod); } ","date":"2024-08-29T20:18:56+08:00","image":"http://blog.anlor.top/post/pow/images/cover_hu_95c179f345c2c7e0.webp","permalink":"http://blog.anlor.top/post/pow/","title":"幂的计算方法"},{"content":"封面图：https://www.miyoushe.com/bh3/article/53423106\n应用背景 用到卡特兰数的经典问题有：\n括号匹配问题：计算 $n$ 对括号正确配对的方案数。 二叉树计数：计算有 $n$ 个内部节点（非叶节点）的二叉树的不同形态数。 路径计数：在格点中，从原点到 $(2n, 0)$ 点，不穿过对角线的路径数。 分割问题：将一个凸多边形分割成n个三角形的不同方法数。 括号表达式：计算包含n对括号和n个不同运算符的有效算术表达式的数量。 栈操作：给定一系列进栈和出栈操作，计算不违反栈原则的操作序列数。 当你拿到一个问题时，你可以通过以下步骤来确定它是否能用卡特兰数来解决：\n递推关系：如果问题的解决方案可以分解为两个子问题，并且这两个子问题的解的乘积构成了原问题的解，特别地，递推公式形如 $H_n=\\sum_{k=0}^{n-1}H_kH_{n-1-k}$ 那么这个问题可能可以用卡特兰数来解决。 对称性：卡特兰数通常出现在具有某种对称性的问题中。如果问题具有对称性，这可能是一个线索。 排除非法状态：许多卡特兰数问题都涉及到在构建过程中排除非法状态。例如，在括号匹配问题中，不能出现右括号多于左括号的情况。 计数问题：卡特兰数通常用于计数问题，尤其是那些涉及到某种“平衡”或“结构”的问题。 匹配和划分：如果问题是关于如何匹配或划分对象，且匹配或划分需要遵循特定的规则，那么它可能可以用卡特兰数来解决。 找规律：可以在小的数据规模上计算问题的答案，如果发现问题的答案与卡特兰数的前几项 $1, 1, 2, 5, 14, 42, 132, 429, 1430, 4862$ 相同，那么该问题可能可以用卡特兰数来解决。 例题引入 我们通过两个例题来引入卡特兰数的计算方法。\n如图，从格点 $(0,0)$ 走到格点 $(n,n)$ ，只能向右或向上走，且不能穿过对角线，求路径数。\n先不考虑对角线的限制。从格点 $(0,0)$ 到格点 $(n,n)$ ，因为只能向右或者向上，所以只能是 $n$ 步向右，$n$ 步向上，总共要走 $2n$ 步。我们可以从 $2n$ 步中任选 $n$ 步向右，剩下 $n$ 步向上，就能组成一条路径。所以路径总数为 $C_{2n}^n$。\n之后考虑这些路径中穿过了对角线的路径数。可以画一条线为 $y=x+1$，所有穿过了对角线的路径必然与这条线相交，而没有穿过对角线的路径必然不与这条线相交。我们假设穿过了对角线的路径与该线的交点为P，将P点右侧的路径以 $y=x+1$ 为对称轴进行对称，注意到 $(n,n)$ 的对称点是 $(n-1,n+1)$，所以就可以得到一条连接 $(0,0)$ 和 $(n-1,n+1)$ 的路径。如下图所示。对称地思考，连接 $(0,0)$ 和 $(n-1,n+1)$ 的路径数就等于所有穿过了对角线的路径数，即 $C_{2n}^{n-1}$。\n所以答案就是 $C_{2n}^n-C_{2n}^{n-1}$。这就是卡特兰数的一个通项公式。\n接下来我们从另一个角度来推出卡特兰数。考虑凸多边形的划分问题。将一个凸 $n$ 边形的顶点从0到n-1进行编号，之后将其划分成 $n-2$ 个三角形，如图，求划分方法数。\n我们可以任取一个点k，将多边形划分成三角形 $0,k,n-1$和两个多边形，分别是 $0,1,\u0026hellip;,k$ 和 $k,k+1,\u0026hellip;,n-1$。这两个多边形的边数分别是 $k+1$ 和 $n-k$，那么划分方法数就是将这两个多边形分别划分的方法数的乘积。所以得到递推关系 $f_n=\\sum_{k=1}^{n-2}f_{k+1}f_{n-k}$，其中 $f_2=1$。该数列是从2开始的，我们不妨令 $H_n=f_{n+2}=\\sum_{k=1}^{n}f_{k+1}f_{n+2-k}=\\sum_{k=1}^{n}H_{k-1}H_{n-k}=\\sum_{k=0}^{n-1}H_kH_{n-1-k}$，其中 $H_0=1$。我们可以求出 $H_n$ 的前几项，可以发现它和上一道题的 $C_n$ 是同一个数列。因此，这就是卡特兰数的递推公式。从该递推公式严谨地推导通项公式并不容易，这里就不展开了。\n卡特兰数的通项公式 卡特兰数的常用通项公式有：\n$$ H_n=C_{2n}^n-C_{2n}^{n-1} $$\n$$ H_n=\\frac{1}{n+1}C_{2n}^n $$\n常用的递推公式有：\n$$ H_n=\\frac{4n-2}{n+1}H_{n-1} $$\n这些公式之间的转换推导很简单，这里就不展开了。在实际应用中，卡特兰数也经常会以上文中例题2的形式出现。但是例题2的形式不太方便计算，我们可以通过递推式计算出答案的前几项，找到它和通项公式的对应关系，之后用通项公式求解。\n在实际计算中，我们常常不能直接计算 $C_{2n}^n$，而往往也采用递推的方式计算组合数。因此，以上的公式在实际应用时通常都表现为递推的形式。\n实操演练 洛谷P10413\n[蓝桥杯 2023 国 A] 圆上的连线 题目描述 给定一个圆，圆上有 $n=2023$ 个点从 $1$ 到 $n$ 依次编号。\n问有多少种不同的连线方式，使得完全没有连线相交。当两个方案连线的数量不同或任何一个点连接的点在另一个方案中编号不同时，两个方案视为不同。\n答案可能很大，请将答案对 $2023$ 求余后提交。\n输入格式 这是一道结果填空题，你只需要算出结果后提交即可。本题的结果为一个整数，在提交答案时只填写这个整数，填写多余的内容将无法得分。\n输出格式 这是一道结果填空题，你只需要算出结果后提交即可。本题的结果为一个整数，在提交答案时只填写这个整数，填写多余的内容将无法得分。\n解题思路 这道题是卡特兰数的变式。首先它没有要求所有点都要连线，因此我们首先要确定连线的点的数量。连线的点必须是偶数个，因此，挑选点的方案数是 $C_{2023}^{2k}, 1 \\leq k \\leq \\frac{2023}{2}, k\\in N$。此时参与连线的点的数量为 $2k$。因为要求连线不能相交，那么一条连线可以将圆分成两个部分，这两个部分的连线方式是独立的。并且为了确保两个部分都能合法地连线，两个部分包含的点的数量都必须是偶数。大圆的连线方案数就是两个部分的连线方案数的乘积。这里就能看出是一个卡特兰数的问题。不妨将选中的点中编号最低的点 $p$ 固定，然后选择 $p+2i+1$ 和其连线，那么连线就将剩余的点分为 $2i$ 和 $2k-2i-2$ 两个部分。假设 $2k$ 个点的连线方案数为 $H_{k}$，那么递推关系为 $H_{k}=\\sum_{i=0}^{k-1}H_{i}H_{k-i-1}$，也就是卡特兰数的递推公式。最后，答案还需要乘上挑选点的方案数，因此，最终结果是 $ans=\\sum_{k=1}^{1011}C_{2023}^{2k}H_{k}$。\n在实际编程时，我选择 $H_n=C_{2n}^n-C_{2n}^{n-1}$ 的形式来计算卡特兰数。注意题目要求对 $2023$ 求余，因此我们需要谨慎挑选计算组合数的方法。在这里我使用了 $C_{n}^{m}=C_{n-1}^{m-1}+C_{n-1}^{m}$ 的递推式来计算组合数。\n源代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #include \u0026lt;iostream\u0026gt; using namespace std; const int n = 2023, m = 2023; long long c[n + 5][n + 5]; int main() { c[0][0]=1; for (long long i = 1; i \u0026lt;= n; i++) { c[i][0] = 1; for (long long j = 1; j \u0026lt;= i; j++) c[i][j] = (c[i-1][j-1] + c[i-1][j] )%m; } int ans = 0; for (int i = 0; i \u0026lt;= 2023; i += 2) { int h = c[i][i / 2] - c[i][i / 2 - 1]; ans+=c[n][i]*h%m; ans%=m; } cout \u0026lt;\u0026lt; ans; return 0; } ","date":"2024-08-25T11:12:34+08:00","image":"http://blog.anlor.top/post/catalan/images/cover_hu_240688634d1407ab.webp","permalink":"http://blog.anlor.top/post/catalan/","title":"卡特兰数学习笔记"},{"content":"封面图：https://www.bilibili.com/opus/967606108335112258\n题目描述 洛谷P10419\n小蓝最近玩上了 $01$ 游戏，这是一款带有二进制思想的棋子游戏，具体来说游戏在一个大小为 $N\\times N$ 的棋盘上进行，棋盘上每个位置都需要放置一位数字 $0$ 或者数字 $1$，初始情况下，棋盘上有一部分位置已经被放置好了固定的数字，玩家不可以再进行更改。玩家需要在其他所有的空白位置放置数字，并使得最终结果满足以下条件：\n所有的空白位置都需要放置一个数字 $0/1$； 在水平或者垂直方向上，相同的数字不可以连续出现大于两次； 每一行和每一列上，数字 $0$ 和数字 $1$ 的数量必须是相等的 (例如 $N=4$，则表示每一行/列中都需要有 $2$ 个 $0$ 和 $2$ 个 $1$)； 每一行都是唯一的，因此每一行都不会和另一行完全相同；同理每一列也都是唯一的，每一列都不会和另一列完全相同。 现在请你和小蓝一起解决 $01$ 游戏吧！题目保证所有的测试数据都拥有一个唯一的答案。\n输入格式 输入的第一行包含一个整数 $N$ 表示棋盘大小。\n接下来 $N$ 行每行包含 $N$ 个字符，字符只可能是 0、1、_ 中的其中一个 (ASCII 码分别为 $48$，$49$，$95$)，0 表示这个位置数字固定为 $0$，1 表示这个位置数字固定为 $1$，_ 表示这是一个空白位置，由玩家填充。\n输出格式 输出 $N$ 行每行包含 $N$ 个字符表示题目的解，其中的字符只能是 0 或者 1。\n样例 #1 样例输入 #1 1 2 3 4 5 6 7 6 _0____ ____01 __1__1 __1_0_ ______ __1___ 样例输出 #1 1 2 3 4 5 6 100110 010101 001011 101100 110010 011001 提示 【评测用例规模与约定】\n对于 $60%$ 的评测用例，$2\\le N\\le 6$;\n对于所有评测用例，$2\\le N\\le 10$，$N$ 为偶数。\n感谢 @rui_er 提供测试数据。\n解题思路 这道题的题目数据比较弱，虽然生成二进制数然后按行填入的方法比较快，但是用一位一位填数的方法也能过洛谷的评测。\n剪枝技巧非常简单：\n记录每一行和每一列 $0$ 和 $1$ 的个数，仅当有余量的时候允许填 $0$ 或者填 $1$。 在每行填完的时候进行合法性检查： 检查当前行是否有连续3个 $0$ 或 $1$； 检查是否有行重复； 检查已经填入的数字中，是否有某一列出现了重复的 $0$ 或 $1$。 我最开始实现的时候为了追求更快的剪枝，还希望在每填一个数的时候，通过检查该位置前后的数字，来确保该位置只填入合法的数字。比如，如果该位置前面已经有两个 $0$ 了，就直接填 $1$。但是实际实现下来这需要考虑非常多的情况，仅单个数字出现重复的情况就有6种，还要考虑到两个数字都出现重复的情况，比如 $11\\_00$。这样子判断条件就很复杂，很容易出错，而且效果不理想。经验教训就是不要考虑太过复杂，收益也不高的剪枝吧。\n代码 最后附上代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 #include \u0026lt;cstdio\u0026gt; #include \u0026lt;cstring\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; const int MAXN = 11; struct coord { int x, y; coord move(int n) const { if (y \u0026lt; n - 1) return {x, y + 1}; else return {x + 1, 0}; } }; char a[MAXN][MAXN]; int r[MAXN][2], c[MAXN][2]; int n; bool checkc() { for (int i = 0; i \u0026lt; n; i++) { for (int j = 0; j \u0026lt; i; j++) { bool flag = true; for (int k = 0; k \u0026lt; n; k++) { if (a[k][i] != a[k][j]) { flag = false; break; } } if (flag) return false; } } return true; } bool checkr(int r) { for (int k = 0; k \u0026lt;= r - 2; k++) { for (int i = 0; i \u0026lt; n; i++) if (a[k][i] == a[k + 1][i] \u0026amp;\u0026amp; a[k][i] == a[k + 2][i]) return false; } for (int i = 0; i \u0026lt; r; i++) { if (strcmp(a[i], a[r]) == 0) return false; } for (int j = 0; j \u0026lt; n - 2; j++) { if (a[r][j + 1] == a[r][j + 2] \u0026amp;\u0026amp; a[r][j] == a[r][j + 1]) return false; } return true; } bool dfs(coord coo) { // 递归出口 if (coo.x == n \u0026amp;\u0026amp; coo.y == 0) { return checkr(coo.x-1) \u0026amp;\u0026amp; checkc(); } // 完成一行，进行行查重 if (coo.y == 0 \u0026amp;\u0026amp; coo.x \u0026gt; 0) { if (!checkr(coo.x - 1)) return false; } // 该位置已固定 if (a[coo.x][coo.y] != \u0026#39;_\u0026#39;) { return dfs(coo.move(n)); } if (r[coo.x][0]\u0026gt;0 \u0026amp;\u0026amp; c[coo.y][0]\u0026gt;0) { char bak = a[coo.x][coo.y]; a[coo.x][coo.y] = \u0026#39;0\u0026#39;; r[coo.x][0]--; c[coo.y][0]--; if (dfs(coo.move(n))) return true; r[coo.x][0]++; c[coo.y][0]++; a[coo.x][coo.y] = bak; } if (r[coo.x][1]\u0026gt;0 \u0026amp;\u0026amp; c[coo.y][1]\u0026gt;0) { char bak = a[coo.x][coo.y]; a[coo.x][coo.y] = \u0026#39;1\u0026#39;; r[coo.x][1]--; c[coo.y][1]--; if (dfs(coo.move(n))) return true; r[coo.x][1]++; c[coo.y][1]++; a[coo.x][coo.y] = bak; } return false; } int main() { cin \u0026gt;\u0026gt; n; for (int i = 0; i \u0026lt; n; i++) r[i][0] = r[i][1] = c[i][0] = c[i][1] = n / 2; for (int i = 0; i \u0026lt; n; i++) for (int j = 0; j \u0026lt; n; j++) { cin \u0026gt;\u0026gt; a[i][j]; if (a[i][j] == \u0026#39;0\u0026#39;) { r[i][0]--; c[j][0]--; } if (a[i][j] == \u0026#39;1\u0026#39;) { r[i][1]--; c[j][1]--; } } dfs({0, 0}); for (int i = 0; i \u0026lt; n; i++) { for (int j = 0; j \u0026lt; n; j++) putchar(a[i][j]); putchar(\u0026#39;\\n\u0026#39;); } return 0; } ","date":"2024-08-20T21:09:39+08:00","image":"http://blog.anlor.top/post/lanqiao2023a-01game/images/cover_hu_bfd782182728e409.webp","permalink":"http://blog.anlor.top/post/lanqiao2023a-01game/","title":"[蓝桥杯2023国A]01游戏题解"},{"content":"前言 proton等技术的发展让linux的游戏能力越来越好了。然而对于国内的游戏玩家来说，还有一个大问题就是网络问题。Linux上游戏网络加速目前主要有以下几种方式：\n原生客户端。网易uu加速器提供了steam deck插件，该插件同样适用于一般的Linux。该插件需要配合手机客户端使用，不过手机客户端仅作为控制器，加速过程并不需要手机和电脑连接到同一局域网。参考SteamDeck插件安装步骤 Q\u0026amp;A 共享加速。该方法是很多加速器主机加速功能的主要方法。需要在手机、电脑或路由器上安装加速器客户端，然后将Linux连接到同一网络，然后使用主机加速功能进行加速。雷神加速器、迅游加速器以及刚刚提到的uu加速器都支持这种方式。如果你的电脑性能足够好，可以考虑使用虚拟机来运行加速器客户端。 虚拟机/wine运行加速器客户端。部分加速器的部分加速方式可以通过虚拟机或者wine来运行。该方法也是唯一不需要使用主机加速功能的方法，适合加速那些没有上线主机的游戏。使用虚拟机运行加速器客户端的方法可以参考https://hu60.cn/q.php/bbs.topic.95932.html，使用wine的方法可以参考https://winegame.net/games/uu/的脚本2和脚本3。 使用VPN加速。也就是本文介绍的方法。该方法需要使用VPN来连接到加速器的服务器，然后通过加速器的服务器来访问游戏服务器。这种方法的优点是不需要额外的软件，目前似乎只有迅游加速器的SVIP支持这种方式。虽然这种方式也是主机加速，但是它连接到节点而非选择游戏，所以可以用于加速大部分游戏，不受主机加速器支持的游戏的限制。 注意：本文所述方法需要迅游SVIP方能使用\n本文主要介绍使用迅游加速器的主机VPN加速功能进行游戏加速。本文中系统使用的网络管理工具是networkmanager，这是被ubuntu、deepin、manjaro等主流系统所接受的网络管理工具。如果你使用的是其它网络管理工具，请自行查阅相关文档。\n本文会涉及到的Linux发行版包括Arch/Manjaro，Ubuntu，Deepin。对于其它发行版，操作大同小异。\n软件准备 Arch/Manjaro networkmanager-l2tp\n1 sudo pacman -Syu networkmanager-l2tp Ubuntu 1 sudo apt install network-manager-l2tp 如果你使用的是gnome桌面环境，还可以安装\n1 sudo apt install network-manager-l2tp-gnome 来使用gnome的图形界面来设置l2tp vpn\nGUI 很多主流桌面环境在它们的设置中集成了networkmanager的设置。如果你的桌面环境没有提供此功能，你仍可通过nm-connection-editor来使用GUI配置networkmanager。ubuntu已预装该软件，对于Arch，它在extra仓库中，可以通过pacman安装： nm-connection-editor\n1 sudo pacman -Syu nm-connection-editor VPN配置 获取迅游节点信息 首先你需要有一个迅游SVIP帐号。之后打开https://pay.xunyou.com/u/#host，访问迅游个人中心的主机加速页面。迅游提供了港服、日服、美服、欧服四个服务器的主机加速节点。我们一一对应地创建4个vpn，这样只要连接到不同的vpn，就能连接到不同的节点。\nGnome图形界面 打开gnome设置，网络，VPN，点击加号，选择“第二层隧道协议(L2TP)“，名称取自己喜欢的，在”网关“一栏填入从迅游那里得到的服务器地址，用户名和密码栏照写。密码栏右侧小图标可以点击，建议改成仅为当前用户存储密码。然后点右上角添加即可。如图： 之后直接在gnome网络设置界面打开开关即可连接到对应的vpn。也可以在gnome右上角控制栏里面找到切换开关。需要注意的是，如果你要切换到另一个节点，你需要先手动关闭前一个vpn连接再开启新的vpn连接，否则会报错。\ndeepin(DDE)图形界面 Deepin预装了l2tp的vpn支持，并在它的网络设置界面提供了方便的设置选项。打开deepin的设置（控制中心），点击网络，vpn，点击加号，选择l2tp，名称取一个自己喜欢的，建议关闭自动连接，然后网关填入从迅游那里得到的服务器地址，用户名和密码照写，其它选项不变，保存即可。如图： 其它图形界面 对于kde等桌面环境，可参考上面两段进行配置。如果桌面环境没有提供相关设置项，可通过nm-connection-editor进行配置。 首先以管理员用户启动nm-connection-editor（非管理员用户会无法保存密码，即使仅为当前用户保存也一样。这是nm-connection-editor的bug）\n1 sudo nm-connection-editor 然后点击左下角的+号，选择l2tp，然后填入名称（随意），网关（即迅游处获得的服务器地址），用户名和密码（从迅游处获得）。保存即可。然后用你喜欢的方式连接到对应vpn就行（例如用nmtui或者nm-applet）\n总结 虽然这样只有4个节点，远远不如迅游客户端那么灵活。但是能达到加速的效果，而且与wine或者虚拟机方案比起来没有性能损耗，连接也非常方便，只要启用vpn即可。实测Apex在使用香港和日本节点加速的时候都能达到\u0026lt;50ms的延迟，相比于直连进步巨大。\n","date":"2023-03-12T23:19:32+08:00","image":"http://blog.anlor.top/post/linux-xunyou/images/cover_hu_1c06760d74b09964.webp","permalink":"http://blog.anlor.top/post/linux-xunyou/","title":"在Linux上使用迅游加速器"},{"content":"封面图：https://www.pixiv.net/artworks/95681846\n曾经我以为没有递归的程序阅读题都是渣渣，直到……\n题目描述 下面代码进行数组与指针的操作，已知\u0026amp;score[0]的值为 0x00FF2000。\n1 2 3 4 5 6 7 8 9 10 11 12 #include \u0026lt;stdio.h\u0026gt; int main() { short int *p1; long int *p3; short int score[10] = { 1,2,3,4,5,6,7,8,9 }; p1 = score; p3 = (long int*)score; printf(\u0026#34;%p, %p\\n\u0026#34;, p1 + 1, p3 + 1); printf(\u0026#34;%d, %d\\n\u0026#34;, *p1, *p3); return 0; } (1) 请说明数组名score 的含义。\n(2) 写出程序执行结果。\n非官方答案：\n1 2 3 4 (1)指向数组score头元素的指针 (2) 0x00FF2002 0x00FF2004 1 131073 注：在64位linux系统上时，第一个printf输出的第二个数应为0x00FF2008\n题目详解 首先第一小题完全莫名其妙，不讲。\n有意思的是第二小题，这个程序有两个printf语句，第一个printf主要考察指针运算。众所周知，假设指针的类型长度为n，那么指针加k，指针实际移动n*k。在本例中，指针p1的类型是short int *，short int的类型长度是2个字节，此处p1+1，实际地址增加2，即0x00FF2002。p3+1同理。但是这里有个小坑，long int 在64位linux系统上是8个字节，在windows上是4个字节。实际考试中应指定long int长度。\n第二个printf语句是输出两个指针对应的内容。我们先忽略格式控制符，看后面的两个参数。第一个参数是*p1，又因为p1=score，p1和score都是short int *类型，所以毫无疑问这里是取score[0]，也就是1。第二个参数是*p3，也有p3=score，但是p3是long int *，在它执行*运算的时候会一次取long int长度的内容，也就是取4个字节。然而，一个short int类型只占2个字节，所以这里发生了访问溢出。那么这里到底取出来了什么呢？这就需要从内存里分析了。\n程序内存分配分析 相信大家都很熟悉下面这张图： 这张图展示了计算机内存分配的顺序。我们知道，对于局部变量，系统在栈区从高地址向低地址分配内存。 而在数组内部则是从低地址向高地址分配内存。因此变量相对位置如表：\n内存内容 内存属主 0x00FF2000 p1 0x00FF2000 p3 未知 score[9] 9 score[8] 8 score[7] 7 score[6] 6 score[5] 5 score[4] 4 score[3] 3 score[2] 2 score[1] 1 score[0] 当进行*p3操作时，从低地址向高地址进行操作，取出的即为score[0]和score[1]占用的内存空间。\n计算机中的整数 一般来说，整数以补码的形式存储在内存中。\n正数的补码等于其原码，负数的补码等于模减去其绝对值的原码\n考虑到short int 占两个字节，也就是16位二进制，4位十六进制，那么可以得到：\n1 2 (short int)1=0000000000000001B=0001H (short int)2=0000000000000010B=0002H 在常用家用计算机（小端机）中，数字高位存储在高地址，低位存储在低地址，在虚拟内存中，最小操作单位是字节，也即两位16进制。因此在虚拟内存中存储情况类似：\n内存内容 高地址 00H 02H 00H 01H 低地址 回到*p3 当*p3取数时，取出的即为上面这一串十六进制。我们按我们习惯的高位在前进行拼接，最终结果就是00020001H，即131073。 除此之外，我们还需要关注格式控制符。幸运的是，这里的格式控制符是%d，在Windows平台下刚好能够兼容*p1和*p3，因此没有什么问题。于是我们能够得出最后的结果：\n1 1 131073 总结 其实这是一道非常简单的关于内存分配的题目，不过涉及到比较复杂的内存分配机制问题，所以记个笔记来记录一下。\n","date":"2023-01-26T15:54:53+08:00","image":"http://blog.anlor.top/post/an-exam-question-about-memory-distribution/images/cover_hu_c777bbc0a257ac42.webp","permalink":"http://blog.anlor.top/post/an-exam-question-about-memory-distribution/","title":"一道关于内存分配的题"},{"content":"前言 我的manjaro安装的时候选择了全盘安装，勾选luks加密。加密虽好，但是我发现这里有个问题，manjaro启动的时候解密时间非常的长，需要10s左右。这显然不正常。经过搜索，我发现这个问题很可能跟LUKS的iter-time这个参数有关https://bbs.archlinux.org/viewtopic.php?id=217193。以下是archwiki里面关于iter-time参数的介绍：\nNumber of milliseconds to spend with PBKDF2 passphrase processing. Release 1.7.0 changed defaults from 1000 to 2000 to \u0026ldquo;try to keep PBKDF2 iteration count still high enough and also still acceptable for users.\u0026quot;[2]. This option is only relevant for LUKS operations that set or change passphrases, such as luksFormat or luksAddKey. Specifying 0 as parameter selects the compiled-in default..\n大体上就是LUKS等待PBKDF2解密密钥的时间。默认是2000ms，这个数字对于新的机器来说其实有点太高了，现代cpu大多不需要这么长的解密时间。所以我们可以适当缩短这个时间。\n正文 参考https://unix.stackexchange.com/questions/690138/how-to-modify-iter-time-on-a-existing-luks-partition,我们可以修改这个iter-time。使用以下命令：\n1 sudo cryptsetup luksChangeKey \u0026lt;device\u0026gt; --iter-time \u0026lt;time in ms\u0026gt; \u0026lt;device\u0026gt;填写LUKS加密的分区，可以用lsblk查看。\u0026lt;time in ms\u0026gt;填写以毫秒为单位的iter-time，我填了300，也有看到有人填60的。取决于你的cpu。这还会要求你修改密码，如果你不希望修改，你可以直接填写原来的密码。注意，这个命令需要执行两次，原因我下面讲。\n表面上看，只需要执行一次命令，修改一次就可以了。然而实际上还是有问题。manjaro的全盘加密默认注册了两个key slot。slot 0是通过我们设置的密码加密的，而slot 0解锁之后会解密一个密钥文件，通过这个密钥再来解密slot 1，进而解密整个硬盘。上面的命令会修改slot 0，但是修改后却会将其放在slot 2的位置。LUKS是按顺序尝试解锁的，这会导致LUKS先尝试解锁slot 1，解锁失败后再尝试slot 2，白白浪费了时间。怎么办呢？很简单，我们再执行一次这个命令。这个命令是将修改后的key放在最小的空slot。第一次执行命令时，slot 0和slot 1都被占用，所以新key放在了slot 2。这时slot 0才被清除。所以第二次执行命令时，slot 1和slot 2都被占用，新key就会被放回slot 0。这样就能达成我们的目的了。\n结语 经过以上调整，我的开机解密时间缩短到了原来的1/10不到，大大提高了开机速度，让我觉得之前的开机都是在浪费生命XD。另外也了解到了不少关于LUKS的知识。可见折腾好处多多，能用就行的思想要不得😛。\n","date":"2023-01-15T12:22:26+08:00","image":"http://blog.anlor.top/post/manjaro-luks-slow/images/cover_hu_2a89da9e1075d62a.webp","permalink":"http://blog.anlor.top/post/manjaro-luks-slow/","title":"Manjaro Linux默认LUKS全盘加密解密慢，启动慢"},{"content":"本文已过期。GRUB的一次更新要求GRUB使用的字体也必须签名，这让GRUB配置安全启动变得麻烦。如果要配置Secure Boot，推荐换用systemd-boot或rEFInd等引导器。对于这些引导器，Arch Wiki的教程已非常详细。例如，我现在采用的systemd-boot+sbctl的方案，可参考https://wiki.archlinuxcn.org/wiki/UEFI/%E5%AE%89%E5%85%A8%E5%90%AF%E5%8A%A8#sbctl 前言 本文主要介绍使用shim+MOK+grub2实现安全启动全盘加密的Manjaro Linux。\n首先说一下我的环境，我的电脑是宏碁spin5，Manjaro是使用的全盘安装，btrfs格式分区，勾选全盘加密。\n在开始以下工作之前，建议先将secureboot恢复到出厂设置。\n分析 首先当然要看看Arch Wiki。以下内容也大量借鉴了这篇wiki。\n这里提到要发挥secure boot安全功能的建议：\n设置一个强的硬件设置密码。（一般来说，在bios里面设置） 不使用默认的厂商密钥和第三方密钥。根据Archwiki的警告，有变砖风险，这里就不折腾了。 uefi直接启动内核，包括microcode和initramfs，不使用启动加载器。这主要是缩短启动信任链的长度，减少被攻击的环节。这个方案推荐和第2条一起食用，这样比较方便，这里也不折腾了。 启用全盘加密。避免能碰到你硬件的人乱动硬盘内容。 使用TPM加强secure boot。 然后简单看看实现secureboot的方案。\n首先就是使用自己的密钥，优点是所有情况都在自己掌握之中，你可以完全控制签名哪些启动项来启动，非常安全。缺点是容易变砖，另外你需要对每个启动项签名，对于windows你也要自己配置。这里不采用这种方案，因为怕变砖。 其次是使用已经经过微软签名的启动加载器。这样的加载器有两个，一个是preload，一个是shim。preload只能使用hash来验证启动项目，并且在preload阶段使用它的hash tool来导入hash。这一来比较麻烦，每次启动加载器或者内核更新都要重新导入一遍hash，hash多了还要自己清理，二来preload阶段全盘加密没有解开，hash tool也访问不到内核镜像。所以放弃这个方案，使用shim。shim可以用hash，也可以用MOK，我们这里介绍的是shim+MOK的方案。 正文 释义 下文中，$esp指的是你的efi分区位置。请在所有提到$esp的地方自觉替换。 下文中大多数命令需要root权限执行。\n软件安装 你需要安装以下软件：\nshim-signed (aur) sbsigntools 设置shim （注：本节部分有误。bootx64.efi是systemd-boot的默认位置。） 备份bootx64.efi。这一般认为是电脑的默认启动项，我们用shim来代替他。\n1 mv $esp/EFI/boot/bootx64.efi $esp/EFI/boot/bootx64.efi.bak 将shim复制到原来bootx64.efi的位置\n1 2 cp /usr/share/shim-signed/shimx64.efi $esp/EFI/BOOT/BOOTx64.EFI cp /usr/share/shim-signed/mmx64.efi $esp/EFI/BOOT/ 最后，创建一个新的nvram入口指向shim\n1 efibootmgr --unicode --disk /dev/$disk --part $Y --create --label \u0026#34;Shim\u0026#34; --loader /EFI/BOOT/BOOTx64.EFI 这里的$disk请替换成你的efi分区所在磁盘，大多数人应该是sda或者sdb，但是像我就是nvme0n1。$Y请替换成你的efi在这块磁盘上是第几个分区。这些可以通过lsblk命令得到。\n设置grub 接下来，我们要对grub进行设置。要使grub能在secureboot模式下启动，grub必须集成了能让它读取到vmlinuz和initramfs的所有模块。你可以参考ubuntu的grub生成脚本来选择你所需要的模块。然后，将这些模块记录成环境变量使用。\n作为参考，我是这么处理的。新建/etc/grub_modules文件，内容如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 CD_MODULES=\u0026#34; all_video boot btrfs cat chain configfile echo efifwsetup efinet ext2 fat font gettext gfxmenu gfxterm gfxterm_background gzio halt help hfsplus iso9660 jpeg keystatus loadenv loopback linux ls lsefi lsefimmap lsefisystab lssal memdisk minicmd normal ntfs part_apple part_msdos part_gpt password_pbkdf2 png probe reboot regexp search search_fs_uuid search_fs_file search_label sleep smbios squash4 test true video xfs zfs zfscrypt zfsinfo cpuid play tpm \u0026#34; GRUB_MODULES=\u0026#34;$CD_MODULES cryptodisk gcry_arcfour gcry_blowfish gcry_camellia gcry_cast5 gcry_crc gcry_des gcry_dsa gcry_idea gcry_md4 gcry_md5 gcry_rfc2268 gcry_rijndael gcry_rmd160 gcry_rsa gcry_seed gcry_serpent gcry_sha1 gcry_sha256 gcry_sha512 gcry_tiger gcry_twofish gcry_whirlpool luks lvm mdraid09 mdraid1x raid5rec raid6rec \u0026#34; 之后source这个文件即可。\n注意：如果你在grub启动后遭遇unkown tpm error问题，请删除tpm模块。\n除了这些模块，你的grubx64.efi还需要有一个sbat小节。以下是完整的生成命令：\n1 2 source /etc/grub_modules grub-install --target=x86_64-efi --efi-directory=$esp --modules=\u0026#34;${GRUB_MODULES}\u0026#34; --sbat /usr/share/grub/sbat.csv 签名 然后你需要创建MOK，也就是机器所有者密钥。这里建议你选择一个合适的工作目录进行生成。我选择了创建一个/etc/MOK目录。生成密钥并且签名。\n1 2 3 4 5 6 mkdir -p /etc/MOK cd /etc/MOK openssl req -newkey rsa:4096 -nodes -keyout MOK.key -new -x509 -sha256 -days 3650 -subj \u0026#34;/CN=my Machine Owner Key/\u0026#34; -out MOK.crt openssl x509 -outform DER -in MOK.crt -out MOK.cer sbsign --key MOK.key --cert MOK.crt --output /boot/$vmlinuz /boot/$vmlinuz sbsign --key MOK.key --cert MOK.crt --output $esp/EFI/Manjaro/grubx64.efi $esp/EFI/Manjaro/grubx64.efi 这里的$vmlinuz请替换成你自己的linux内核镜像文件名。你可以用\n1 ls /boot | grep \u0026#39;vmlinuz\u0026#39; 得到你需要签名的文件的完整列表。\n这里的$esp/EFI/Manjaro是Manjaro的grub默认配置文件生成grub的位置。这个位置可能有以下几种情况：\n在没有配置文件的情况下执行grub-install，将会生成在$esp/EFI/grub 在通过grub-mkconfig等命令创建了grub配置文件的情况下，这个位置由/etc/default/grub中的GRUB_DISTRIBUTOR指定。例如在Manjaro中，这个值默认是manjaro，所以最终生成目录在$esp/EFI/Manjaro 请仔细确认你的grub生成位置。下文中提到$esp/EFI/Manjaro的地方，请自行替换。 签名完成后，将生成的MOK.cer复制到$esp/目录，为后续导入做准备。\n完成 最后，将grub复制到shim同目录下。\n1 cp $esp/EFI/Manjaro/grubx64.efi $esp/EFI/boot/grubx64.efi 然后重启电脑，启动项选shim进入shim，这时shim会有一个错误提示界面。不要慌，这是因为你的MOK文件还未导入MOK list。按页面提示进入MokManager，然后选择Enroll key，选择刚刚复制过来的MOK.cer，按提示导入这个密钥即可。\n后续工作 虽然现在你的电脑已经支持secureboot了，但是对grub和对vmlinuz的签名是一次性的，一旦这两个软件更新，你就需要重新签名。这显然非常不方便。为此，我们可以设置两个pacman钩子，在这两个软件更新的时候自动进行签名。因为每个人终端环境不一样，比如我就无法直接使用archwiki上记载的hook。所以此处仅供参考。需要注意的是，以下命令并不检查签名情况，在已经签名的情况下依然会附加另一个签名，可能导致内核和grub不断增大。但以一般人更新的频率来看，应该不会有太大影响。\n创建/usr/local/bin/sign_kernel_for_secureboot.sh，内容如下：\n1 2 #! /bin/sh /usr/bin/find /boot/ -maxdepth 1 -name \u0026#39;vmlinuz-*\u0026#39; -exec /usr/bin/sh -c \u0026#39;/usr/bin/sbsign --key /etc/MOK/MOK.key --cert /etc/MOK/MOK.crt --output {} {};\u0026#39; \\; 并且为其赋予可执行权限：\n1 sudo chmod +x /usr/local/bin/sign_kernel_for_secureboot.sh 创建/etc/pacman.d/hooks/999-sign_kernel_for_secureboot.hook，内容如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 [Trigger] Operation = Install Operation = Upgrade Type = Package Target = linux Target = linux-lts Target = linux-hardened Target = linux-zen Target = linux515 [Action] Description = Signing kernel with Machine Owner Key for Secure Boot When = PostTransaction Exec = /usr/local/bin/sign_kernel_for_secureboot.sh Depends = sbsigntools Depends = findutils 这里的$linuxX自行替换成你的linux内核包名。例如对于manjaro的5.15内核，这里应该替换为linux515。\n然后是对grub进行签名的钩子。\n新建 /usr/local/bin/sign_grub_for_secureboot.sh 内容如下：\n1 2 3 4 5 #! /bin/bash source /etc/grub_modules grub-install --target=x86_64-efi --efi-directory=$esp --modules=\u0026#34;${GRUB_MODULES}\u0026#34; --sbat=/usr/share/grub/sbat.csv sbsign --key /etc/MOK/MOK.key --cert /etc/MOK/MOK.crt --output $esp/EFI/Manjaro/grubx64.efi $esp/EFI/Manjaro/grubx64.efi cp $esp/EFI/Manjaro/grubx64.efi $esp/EFI/boot/grubx64.efi 并且为其赋予可执行权限。\n1 sudo chmod +x /usr/local/bin/sign_grub_for_secureboot.sh 新建 /etc/pacman.d/hooks/999-sign_grub_for_secureboot.hook 内容如下：\n1 2 3 4 5 6 7 8 9 10 11 12 [Trigger] Operation = Install Operation = Upgrade Type = Package Target = grub [Action] Description = Signing kernel with Machine Owner Key for Secure Boot When = PostTransaction Exec = /usr/local/bin/sign_grub_for_secureboot.sh Depends = sbsigntools Depends = grub 完成。\n","date":"2023-01-03T22:10:46+08:00","image":"http://blog.anlor.top/post/secureboot-on-manjarolinux/images/cover_hu_2ebaadc5013f7e96.webp","permalink":"http://blog.anlor.top/post/secureboot-on-manjarolinux/","title":"在全盘加密的Manjaro Linux上配置安全启动(Secure Boot)"},{"content":"你好！\n这是这篇博客的第一篇文章。该文章主要用于测试用途。\n文章封面来自：https://www.pixiv.net/artworks/58839867，如果喜欢，请支持原作。\n以下测试数学公式功能： $$ \\varphi = 1+\\frac{1} {1+\\frac{1} {1+\\frac{1} {1+\\cdots} } } $$\n","date":"2022-12-31T18:59:19+08:00","image":"http://blog.anlor.top/post/my-first-post/images/cover_hu_ca136fc501aec1.webp","permalink":"http://blog.anlor.top/post/my-first-post/","title":"我的第一篇文章"}]