Prim算法详解:从最小生成树原理到Java代码实现与优化
1. 从实际问题到最小生成树为什么我们需要它在软件开发和系统设计的日常工作中我们常常会遇到一类看似简单实则充满挑战的“连接”问题。想象一下你是一家新成立的互联网公司的架构师公司计划在几个主要城市的数据中心之间建立高速专线网络。每个城市的数据中心都是一个节点而任意两个城市之间铺设专线的成本由距离、带宽、施工难度等因素决定是已知的。你的目标是用最低的总成本将所有数据中心连接起来使得任意两个数据中心之间都能通过铺设的专线网络直接或间接通信。这里有一个关键约束你不需要在所有城市之间都直接铺设专线只要能连通即可因为数据可以中转。这个问题就是经典的最小生成树问题。它绝不仅仅是算法课本上的一个抽象概念而是网络规划、电路设计、物流运输乃至生物信息学中反复出现的核心模型。所谓“生成树”指的是一个连通图的子图它包含原图的所有顶点但只包含足以构成一棵树的边即边数 顶点数 - 1并且没有环。而“最小”则是指所有生成树中边的权重在我们的例子里就是成本之和最小的那一个。为什么不用最直接的办法比如直接连接所有成本最低的边呢因为那样很容易形成环而环意味着冗余连接会增加不必要的成本。我们的目标是在保证连通的前提下杜绝任何冗余。Prim算法就是解决这个“最优连接”问题的利器之一。它像是一个精明的施工队长从某一个点开始一步步地、贪婪地选择当前看来最优的边将新的节点纳入网络最终构建出那棵成本最低的连接树。接下来我将结合多年项目中的实际应用为你彻底拆解Prim算法的原理、实现、以及那些容易踩坑的细节。2. Prim算法的核心思想一种“生长式”的贪婪策略理解Prim算法关键在于把握它的“生长”过程。它不像有些算法那样全局排序再选择而是从一个起点开始让一棵树慢慢“长大”。2.1 算法直观比喻修建村庄公路网假设有多个分散的村庄我们需要修建公路把所有村庄连通且总造价最低。Prim算法的做法是这样的选一个起点随便从一个村庄比如A村开始。此时我们的“已连通区域”只有A村。寻找最短的“外延”看看所有从“已连通区域”目前只有A村连接到“未连通区域”其他村庄的公路哪一条造价最低。假设是A村到B村的公路最便宜。纳入新村庄就修这条A-B公路并把B村纳入“已连通区域”。现在已连通区域是{A, B}两个村子。重复寻找与纳入现在从已连通区域{A, B}出发寻找连接到未连通区域的最便宜公路。可能是A到C也可能是B到D总之在所有候选边里选最短的。修这条路把新的村子纳进来。直到全部连通重复步骤4直到所有村庄都被纳入已连通区域。此时修建的所有公路就构成了一个总造价最低的连通网络并且没有环因为每次都是将一个新的、未被连入的村子连进来。这个过程中最关键的数据结构是一个优先队列通常是最小堆。它用来动态维护和获取“从已连通区域到未连通区域的所有边中权重最小的那条边”。2.2 与Kruskal算法的核心区别另一个常见的最小生成树算法是Kruskal算法。这里简单对比一下能让你更深刻理解Prim的特点Prim算法是“顶点驱动”的它始终维护一个连通分量那棵不断生长的树每次添加一个顶点和一条边。它的视角是“从已有的地盘向外扩张成本最低的领土”。Kruskal算法是“边驱动”的它一开始就把所有边按权重排序然后从小到大尝试添加边如果添加这条边不会形成环用并查集判断就加入。它的视角是“全局来看哪条边最便宜且能用就用”。在边非常稠密边数接近顶点数的平方的图中Prim算法尤其是使用邻接矩阵和优先队列的优化版本通常更高效。它的时间复杂度在采用二叉堆和邻接表的情况下可以达到O(E log V)其中E是边数V是顶点数。3. Prim算法的两种实现方式与详细代码拆解理论说清楚了我们来点实在的。Prim算法的实现根据图的存储方式邻接矩阵 vs 邻接表和优化程度有不同的写法。我会给出两种最典型的实现并逐行分析其意图和易错点。3.1 基础版本使用邻接矩阵和简单遍历这种方式直观适合稠密图或者在面试中快速手写。我们使用两个核心数组key[]记录每个顶点到当前“已连通区域”的最小距离权重。初始化为无穷大起点为0。mstSet[]布尔数组记录顶点是否已加入最小生成树。public class PrimMatrix { // 使用邻接矩阵实现Prim算法 public void primMST(int[][] graph) { int V graph.length; // 顶点数 int[] parent new int[V]; // 用于存储构建出的MSTparent[i]表示i在MST中的父节点 int[] key new int[V]; // 记录顶点到MST的最小权值 boolean[] mstSet new boolean[V]; // 记录顶点是否已在MST中 // 初始化 for (int i 0; i V; i) { key[i] Integer.MAX_VALUE; mstSet[i] false; } // 从第0个顶点开始 key[0] 0; parent[0] -1; // 第一个顶点是MST的根没有父节点 // MST将有V个顶点所以需要循环V-1次因为第一个顶点已经加入 for (int count 0; count V - 1; count) { // 步骤1从未加入MST的顶点中选取key值最小的顶点u int u minKey(key, mstSet); // 将顶点u加入MST mstSet[u] true; // 步骤2更新所有与u相邻的、还未加入MST的顶点的key值 for (int v 0; v V; v) { // 条件1: graph[u][v] ! 0 表示u和v之间有边 // 条件2: !mstSet[v] 表示v不在MST中 // 条件3: graph[u][v] key[v] 表示通过u到v的边比当前记录的到v的最小权值更小 if (graph[u][v] ! 0 !mstSet[v] graph[u][v] key[v]) { parent[v] u; // 更新v的父节点为u key[v] graph[u][v]; // 更新v的最小权值 } } } // 打印构建的最小生成树 printMST(parent, graph); } // 辅助函数寻找不在MST中且具有最小key值的顶点 private int minKey(int[] key, boolean[] mstSet) { int min Integer.MAX_VALUE, minIndex -1; for (int v 0; v key.length; v) { if (!mstSet[v] key[v] min) { min key[v]; minIndex v; } } return minIndex; } // 辅助函数打印MST private void printMST(int[] parent, int[][] graph) { System.out.println(Edge \tWeight); for (int i 1; i graph.length; i) { System.out.println(parent[i] - i \t graph[i][parent[i]]); } } }代码要点与踩坑提醒初始化是关键key数组除了起点初始化为0其他必须初始化为无穷大Integer.MAX_VALUE这代表了“尚未找到任何路径可达”。parent数组起点设为-1表示根节点。主循环次数循环V-1次因为生成树有V个顶点需要V-1条边我们已经默认加入了第一个顶点0号所以还需要找V-1条边。minKey函数的效率这是该实现性能的瓶颈。它每次都需要线性扫描所有顶点来找到最小值导致总时间复杂度为O(V²)。这在顶点数多时非常慢因此它只适用于稠密图边数接近V²或者小规模图。更新key的条件if语句中的三个条件缺一不可。特别是graph[u][v] key[v]这意味着我们发现了从当前MST到顶点v的一条更短的边。parent[v] u记录了这条更优的边是从哪个顶点连过来的。3.2 优化版本使用邻接表与优先队列最小堆对于稀疏图使用邻接表存储更省空间。结合优先队列Java中的PriorityQueue来高效获取最小key值可以将时间复杂度优化到O(E log V)。import java.util.*; class Edge { int dest; // 目标顶点 int weight; // 边权重 Edge(int dest, int weight) { this.dest dest; this.weight weight; } } public class PrimHeap { public void primMST(ListListEdge adjList) { int V adjList.size(); int[] parent new int[V]; int[] key new int[V]; boolean[] inMST new boolean[V]; // 初始化 Arrays.fill(key, Integer.MAX_VALUE); Arrays.fill(parent, -1); key[0] 0; // 使用优先队列按key值权重排序。队列中存储[顶点, key值] PriorityQueueint[] pq new PriorityQueue(Comparator.comparingInt(a - a[1])); pq.offer(new int[]{0, key[0]}); // 从顶点0开始 while (!pq.isEmpty()) { // 取出当前key值最小的顶点u int[] node pq.poll(); int u node[0]; // 如果这个顶点已经在MST中跳过延迟删除 if (inMST[u]) { continue; } inMST[u] true; // 加入MST // 遍历u的所有邻接边 for (Edge edge : adjList.get(u)) { int v edge.dest; int weight edge.weight; // 如果v不在MST中且通过u到v的边更短 if (!inMST[v] weight key[v]) { parent[v] u; key[v] weight; // 将更新后的[v, newKey]加入优先队列 pq.offer(new int[]{v, key[v]}); } } } // 打印结果需要额外存储边的权重这里简化 printMST(parent, adjList); } private void printMST(int[] parent, ListListEdge adjList) { System.out.println(Edge \tWeight); // 注意为了根据parent打印权重我们需要从邻接表中查找 for (int i 1; i parent.length; i) { int p parent[i]; int weight -1; // 在父节点的邻接表中找到指向i的边的权重 for (Edge e : adjList.get(p)) { if (e.dest i) { weight e.weight; break; } } System.out.println(p - i \t weight); } } }优化版核心要点与避坑指南优先队列的“延迟删除”这是最容易出错的地方。当我们更新一个顶点v的key值时我们不是去修改队列中已有的v节点优先队列不支持高效修改而是直接将一个新的[v, newKey]对插入队列。这意味着队列中可能存在同一个顶点的多个条目对应不同的、过时的key值。因此在从队列中取出最小元素时必须检查该顶点是否已被加入MSTif (inMST[u]) continue;。取出的第一个有效的、未加入MST的顶点才是当前key值最小的顶点。时间复杂度每个顶点最多被插入队列一次实际上可能多次但每次插入是O(log V)每条边都会被遍历一次以检查是否需要更新key。因此总复杂度是O((VE) log V)在连通图中简化为O(E log V)。空间复杂度主要是优先队列最坏情况O(V)。邻接表的构建确保是无向图边(u, v, w)需要在adjList.get(u)和adjList.get(v)中都添加一次。这是很多新手容易忘记的导致算法出错。注意在追求极致性能的场景下可以使用更高效的斐波那契堆来实现优先队列可以将Prim算法的时间复杂度降到O(E V log V)。但斐波那契堆实现复杂常数因子大在普通应用中二叉堆即PriorityQueue通常是更实用、更高效的选择。4. 实战场景剖析从算法到真实问题建模理解了代码我们来看看Prim算法如何解决开头的那个数据中心问题以及更多变种场景。4.1 场景一数据中心网络成本优化假设我们有5个数据中心A-E铺设专线的成本矩阵如下0表示无法直接铺设或自身A B C D E A 0 2 0 6 0 B 2 0 3 8 5 C 0 3 0 0 7 D 6 8 0 0 9 E 0 5 7 9 0我们用Prim算法跑一下从A开始初始MST{A}。候选边A-B(2), A-D(6)。选A-B。MST{A, B}。候选边A-D(6), B-C(3), B-D(8), B-E(5)。选B-C(3)。MST{A, B, C}。候选边A-D(6), B-D(8), B-E(5), C-E(7)。选B-E(5)。MST{A, B, C, E}。候选边A-D(6), B-D(8), C-E(7), E-D(9)。选A-D(6)。所有顶点加入完毕。最终的最小生成树包含边A-B(2), B-C(3), B-E(5), A-D(6)。总成本为16。这就是最优的网络铺设方案。你会发现我们没有选择成本为7的C-E边也没有选择成本为8的B-D边因为通过B和E中转已经可以连通C和D再修这些边就是浪费。4.2 场景二市政管道铺设处理非连通图Prim算法要求输入图是连通图。如果图本身不连通比如有孤立的岛屿或片区那么最小生成树不存在算法会产生一个最小生成森林每个连通分量一棵树。在实际编码中基础版本的primMST只会生成从起点可达的那部分顶点的MST。要处理整个森林你需要在外层循环对每个尚未被访问的顶点都调用一次Prim算法。这是一个非常重要的边界条件检查。在拿到问题数据时第一步应该是检查图的连通性用DFS/BFS或者明确需求是否允许生成森林。4.3 场景三最大生成树有时我们需要找的不是成本最小而是收益最大的连接方式例如在通信网络中寻找带宽总和最大的连接树。这被称为最大生成树。修改Prim算法极其简单只需在比较权重时将“寻找最小值”改为“寻找最大值”。在代码中这意味着将key数组初始化为-INF或0如果权重全为正。将minKey函数改为maxKey。在更新条件中将graph[u][v] key[v]改为graph[u][v] key[v]。如果使用优先队列则使用最大堆PriorityQueue(Collections.reverseOrder())。算法的骨架完全一样只是贪婪的策略从“选最小的边”变成了“选最大的边”。5. 性能对比、常见陷阱与调试技巧在实际项目中选择和使用Prim算法时有几个必须清楚的要点。5.1 与Kruskal算法的选择依据虽然两者都能得到正确结果但适用场景有差异特性Prim算法 (邻接表堆)Kruskal算法时间复杂度O(E log V)O(E log E) (主要开销在边排序)核心操作顶点优先队列的decrease-key或插入边的排序 并查集的union-find适合的图稠密图(E ≈ V²)稀疏图(E V²)实现复杂度中等需处理优先队列延迟删除较低排序并查集逻辑清晰是否需要连通图是否则只生成一个连通分量否可直接生成最小生成森林简单决策法则如果图非常稠密用Prim尤其是矩阵实现虽然O(V²)但常数小。如果图稀疏或者你无法确定一个起点图可能不连通Kruskal的简洁性和通用性更有优势。在面试中如果没特别说明实现Kruskal通常更稳妥因为并查集是固定套路不易写错。5.2 亲手实现时的高频“坑点”无向图的边存储使用邻接表时一定要记住无向图的边(u, v, w)需要添加两次adj[u].add(new Edge(v, w));和adj[v].add(new Edge(u, w));。我见过不止一个项目因为漏掉这个导致网络只有单向连接。优先队列的“旧条目”问题如前所述优化版Prim中优先队列里可能存在同一个顶点的多个条目。务必在从队列中poll()出顶点后检查其inMST状态。这是算法正确性的保证也是区别于Dijkstra算法的一个细微之处Dijkstra通常允许一个顶点被多次访问但Prim不行因为MST的顶点只能加入一次。浮点数权重与比较如果权重是浮点数如距离、概率初始化key时用Double.POSITIVE_INFINITY比较时注意浮点精度问题避免直接用判断相等。顶点编号确保你的顶点编号是从0开始连续递增的或者做好映射。如果顶点是用字符串标识的如城市名需要先用一个MapString, Integer将其映射为整数索引再运行算法。5.3 调试与验证你的实现当你写完Prim算法如何验证它是对的小规模手动验证用上面数据中心那个5个顶点的例子手动模拟一遍算法过程与程序输出对比。性质检查边数输出的生成树边数必须等于顶点数-1。连通性从任意一个顶点出发能否通过输出的边访问到所有其他顶点可以用简单的DFS检查。权重和对于很小的图可以暴力枚举所有生成树如果可能看你的结果是否真的是最小的。对拍测试用Kruskal算法实现同一个功能用随机生成的连通图顶点数10-50边随机同时运行两个算法比较它们输出的总权重是否一致。这是最有效的自动化验证方法。最后我个人在多次实现和使用Prim算法后最大的体会是理解其“从一点出发逐步扩张”的贪婪本质比死记硬背代码更重要。一旦理解了key数组记录的是“每个点到当前MST集合的最小距离”以及优先队列是用来高效获取这个最小距离的那么代码的每一行都变得顺理成章。在解决实际问题时关键是能否将问题准确地建模成一个加权无向连通图一旦模型建立Prim算法就是一个可靠且高效的“连接优化器”。

相关新闻

基于Redis与Spring Boot构建高并发资源排队系统的技术实践与风险防范

基于Redis与Spring Boot构建高并发资源排队系统的技术实践与风险防范

在游戏开发、虚拟经济或在线服务项目中,资源排队和虚拟货币交易是常见的运营机制。这类设计通常用于管理高价值、稀缺资源的分配,平衡玩家或用户需求,并可能通过时间投入或虚拟代币兑换产生间接的经济价值。然而,将这种机制与直接…

2026/10/10 20:09:52 阅读更多 →
SAP HANA高可用双机架构:核心原理、运维实战与故障排查指南

SAP HANA高可用双机架构:核心原理、运维实战与故障排查指南

1. 项目概述:为什么SAP HANA的HA架构是业务的生命线在当今数据驱动的商业环境中,核心业务系统的连续可用性不再是锦上添花,而是生存底线。想象一下,一家大型零售企业的实时销售分析系统在“黑色星期五”宕机一小时,或者…

2026/10/15 2:19:33 阅读更多 →
SQL Server数据库结构探查:查询表名、字段类型与注释的完整指南

SQL Server数据库结构探查:查询表名、字段类型与注释的完整指南

1. 项目概述:为什么我们需要系统化地探查数据库结构? 在日常的数据库开发、数据迁移、系统对接或者简单的数据探查工作中,我们经常会遇到一个看似基础却无比关键的需求:快速、准确地了解一个数据库里到底“有什么”。这个需求具体…

2026/10/15 3:10:11 阅读更多 →

最新新闻

Windows本地部署SAM3:从环境配置到图像分割Demo全流程实战

Windows本地部署SAM3:从环境配置到图像分割Demo全流程实战

聊一个最近折腾完的活儿:在Windows系统上把SAM3本地部署起来,跑通了一个图像分割demo。如果你对“点一下就把图上物体抠出来”这种能力感兴趣,又不想把图片传到任何在线平台,这篇文章应该正好合适。这里的SAM3,指的是第…

2026/10/15 6:58:51 阅读更多 →
PS5远程串流实战指南:从局域网到外网,任意设备接管主机

PS5远程串流实战指南:从局域网到外网,任意设备接管主机

先说个场景:大屏被家里人占着看剧,主机在卧室,你人躺在客厅沙发上,手机就在手边。这时候抛开“必须坐在电视前才能玩”的惯性,把PS5的画面和操作无缝拉到眼前这块小屏幕上,体验一次之后,大概率就…

2026/10/15 6:58:51 阅读更多 →
企业级物联网平台架构设计:从设备接入到稳定运营的完整指南

企业级物联网平台架构设计:从设备接入到稳定运营的完整指南

做物联网平台这几年,我有一个越来越强烈的感受:企业级物联网平台最难的地方,从来不在技术炫技,而在那些没人愿意细抠的脏活累活。设备接入、数据治理、权限管理、异常恢复,每一件单独看都不起眼,凑在一起却…

2026/10/15 6:58:51 阅读更多 →
8A同步降压芯片JW5068A深度解析:I2架构与谷值限流设计

8A同步降压芯片JW5068A深度解析:I2架构与谷值限流设计

简介:这是一份 JW5068A 同步降压转换器官方芯片手册,面向电源设计工程师、硬件开发者及电子爱好者。芯片基于 I2 架构,支持宽输入电压工作,可输出 8A 连续电流与 16A 峰值电流,最高效率达 95%,轻载时仍能保…

2026/10/15 6:58:51 阅读更多 →
家居装修AI咨询助手:从RAG知识库到一问即答的产品实战

家居装修AI咨询助手:从RAG知识库到一问即答的产品实战

做家居装修AI智能咨询助手之前,我在装修论坛和投诉平台里泡了将近两个月,看业主们怎么描述自己遇到的困惑。装修这个行业,信息不是少,而是又多又杂。同一个问题,网上能搜出十个答案,而且经常互相打架——“…

2026/10/15 6:58:51 阅读更多 →
第一次用Bambu Studio切片:从导入模型到出件的完整上手教程

第一次用Bambu Studio切片:从导入模型到出件的完整上手教程

第一次用Bambu Studio切片:从导入模型到出件的完整上手教程 【免费下载链接】BambuStudio PC Software for BambuLab and other 3D printers 项目地址: https://gitcode.com/GitHub_Trending/ba/BambuStudio Bambu Studio 是一款面向 Bambu Lab 及其他品牌 3…

2026/10/15 6:57:51 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 7:53:34 阅读更多 →