news 2026/10/17 9:57:34

DenseNet核心解析与PyTorch手写实现:从原理到面试高频考点

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DenseNet核心解析与PyTorch手写实现:从原理到面试高频考点

面试深度学习CV岗位,网络结构是第一道绕不过去的关卡。DenseNet几乎可以算是必考模型,基本上每个面试官都会追问它的核心设计:为什么密集连接能减少参数量?为什么训练时显存反而比ResNet更高?growth rate设成12和32到底有什么区别?如果你只背得出“特征复用、缓解梯度消失”这八个字,大概率会被追问到尴尬。

这篇文章就从DenseNet的设计动机讲起,拆解它的完整结构,再用PyTorch从零手写一遍,最后把我在训练和部署里踩过的坑、面试里被反复追问的高频问题一起整理出来。无论你是准备面试,还是单纯想把DenseNet用明白,这篇都能直接拿来参考。

1. 为什么面试官总爱问DenseNet:先理解它的设计动机

1.1 从ResNet到DenseNet:残差相加和特征拼接的路线分歧

在DenseNet出现之前,ResNet的思路已经很成熟:给每一层加一条“跳线”,把前面块的输出和后面块的输出做逐元素相加,让梯度可以沿着这条捷径回传。这个思路解决了一个核心矛盾——网络越深越难训,深层网络加捷径之后收敛变得稳定得多。

但面试官在问ResNet之后马上追问DenseNet,其实是在考察你有没有想过:ResNet的相加操作,本质上是在逼两条特征流“必须形状一致,然后数值叠加”。加法虽然简单,但它有一个隐含的信息损失——相加之后,前一层的信息有多少被真正保留下来,取决于两个张量的数值分布是否匹配。如果某一层学到的特征振幅很大,另一层的特征可能就被“淹没”了。

DenseNet走的是另一条路线:不再用加法融合,而是直接把所有前层输出沿着通道维度拼接在一起,当前层的输入就是“之前所有层输出的全集”。这种设计有点像开项目会,ResNet的成员只看上一份会议摘要,而DenseNet的成员可以拿到项目启动以来全部会议记录,需要哪部分信息自己去组合。

1.2 核心公式:每一层都拿到前面所有层的输出

DenseNet第l层的输入,是第0层到第l-1层所有输出特征图的拼接结果,公式表达为:

$$x_l = H_l([x_0, x_1, ..., x_{l-1}])$$

这里的 $[ \ ...\ ]$ 表示通道维度的拼接操作,$H_l$ 是一个复合函数,常见组合是BatchNorm、ReLU、Conv。

这个设计带来的直接结果是:信息流动路径极短。梯度从损失函数传到浅层时,不需要层层穿越“窄门”,而是沿着密集连接直接反向传播。在反向传播时,浅层能收到来自后续每一层的监督信号,就像每人头上都挂着一盏信号灯,而不是只能靠上一盏灯接力传递。这也是DenseNet缓解梯度消失问题的本质原因。

1.3 为什么DenseNet参数量更少:特征复用代替重复学习

很多人以为DenseNet参数少是因为“没有冗余”,其实更准确的说法是:它不需要每层重新学习一遍“基础特征”。对于一个k=32、含4个DenseBlock的网络,每一层只新增32个通道的特征图,这些新特征图是在前面所有特征的基础上“增量加工”出来的。相对ResNet那种每一层都输出几十上百个通道的特征,DenseNet用很小的通道数就能表达足够丰富的特征组合。

打个生活化的比方:ResNet像每个厨师都从零准备一桌菜,哪怕上一道菜已经有了同样食材,也会再做一轮;DenseNet像流水线,后面厨师只需要在前人留下的半成品上增加一个特定调料,就能形成新菜。所以同样精度下,DenseNet的训练参数量通常比ResNet少,尤其在中小规模数据集上优势很明显。

1.4 适用场景与短板:参数少不等于万事大吉

DenseNet在图像分类上参数效率高,尤其适合中小数据集;在目标检测、分割任务里作为骨干网络也出现过不少优秀变体。但它的软肋同样明显:训练时因为要保存大量用于反传的拼接特征图,显存占用往往比同规模ResNet高不少。这就是为什么面试官经常会问“为什么DenseNet参数比ResNet少,显存却更高”这个反直觉问题。后文会专门分析原因和应对手段。

2. DenseNet结构逐层拆解:从DenseBlock到Transition

2.1 宏观骨架:DenseBlock与Transition交替堆叠

DenseNet整体结构是一条清晰的流水线:先有一个stem网络负责把输入图像降到合适分辨率,然后进入多个DenseBlock,每个DenseBlock内部层与层之间密集连接,块与块之间用Transition层做下采样和通道压缩。

DenseBlock内部特征图分辨率保持不变,因此可以无限制拼接;但通道数量会随着层数线性增加,如果直接进入下一个块,计算量和显存都会爆炸。所以Transition的职责很明确:先用1x1卷积缩减通道数,再做2x2平均池化降低分辨率。整个网络就是“DenseBlock增长通道,Transition压缩通道”的交替节奏。

2.2 DenseBlock内部:BN-ReLU-Conv的复合结构与bottleneck

每个DenseBlock由若干DenseLayer堆叠而成。标准DenseLayer的内部结构是:

BN -> ReLU -> Conv(1x1) -> BN -> ReLU -> Conv(3x3)

这里有两个关键细节。第一个是1x1卷积。它先把输入通道压缩到一个较小的中间通道数,论文里默认是growth_rate的4倍,这个倍数在代码里通常叫bn_size。没有这个瓶颈设计,3x3卷积直接处理几百上千通道的拼接特征会带来极大的计算量。第二个关键点是每个卷积前都先接BN和ReLU,这跟传统“Conv-BN-ReLU”的顺序不一样,DenseNet采用的是“Pre-activation”方式,让BN在卷积之前先标准化特征,实际训练中收敛更稳定。

DenseLayer的输出通道数就是growth_rate k,每个DenseLayer只向拼接特征中新增k个通道。前一层DenseLayer的输入通道等于进入该DenseBlock时的通道数加上之前所有DenseLayer新增的通道数。如果进入DenseBlock时有c个通道,经过第1个DenseLayer后是c+k,经过第2个后是c+2k,以此类推。这个线性增长关系是理解DenseNet参数量的核心。

2.3 Transition Layer:通道压缩和分辨率下采样

Transition结构:

BN -> ReLU -> Conv(1x1) -> AvgPool(2x2)

1x1卷积把通道数乘以一个压缩系数theta,论文默认theta=0.5,也就是压缩到原来的一半。比如某个DenseBlock出来是512通道,Transition就把它压成256通道,再做2x2平均池化。

注意原论文用的是平均池化而不是最大池化。常见解释是:DenseNet的DenseBlock主要产出的是语义特征的平均响应,平均池化能更平滑地保留下采样前的位置信息;同时平均池化没有额外参数,和Transition的通道压缩配合起来非常轻量。如果你在面试中被问到“为什么不用MaxPool”,可以把这条作为理解点答出来。

2.4 经典配置速记:121、169、201、264对应什么

DenseNet在ImageNet上常用的版本配置如下:

模型每个DenseBlock的层数growth rate参数量(约)
DenseNet-1216, 12, 24, 16327.9M
DenseNet-1696, 12, 32, 323214.2M
DenseNet-2016, 12, 48, 323220.0M
DenseNet-2646, 12, 64, 483233.4M

记住121和201就比较好记规律:121对应(6,12,24,16),201对应(6,12,48,32),每增加一个版本,后面两块的层数明显变多。growth rate默认都设成32,前面的stem卷积输出64通道。面试现场报配置时,能把121和201报准确就已经很加分了。

2.5 DenseNet-B和DenseNet-BC的区别

论文里还有一种细节区分:DenseNet-C表示在Transition里用了小于1的压缩系数theta;DenseNet-B表示在DenseLayer里加了1x1的bottleneck结构。两个都加,就叫DenseNet-BC,这是实际最常用的版本。你现在看到的大多数DenseNet实现,默认都是BC版本,即bn_size=4、theta=0.5。面试时如果被问到“你实现的DenseNet是哪个版本”,能区分B和BC会显得你对论文读得比较细。

3. PyTorch从零手写DenseNet:完整代码与调试过程

3.1 代码结构规划:四个类理清全部逻辑

手写DenseNet不需要过度设计,拆成四个模块就够了:DenseLayer、DenseBlock、Transition、DenseNet主网络。每个模块的输入输出都很清晰,便于单测。

在动手之前先想清楚每一层的通道数变化,这是Debug时最容易出错的地方。假设输入stem之后得到64通道,进入第一个DenseBlock。该块有6层、growth_rate=32,则第1层输入64、输出32;第2层输入96、输出32;第3层输入128、输出32,以此类推。6层后该块输出的总通道数是64+6*32=256。之后接Transition压缩一半,输出128通道。整个网络就按照这个规律一路传递下去。

我用一个随机输入把通道变化流程写清楚:

stem input: 3 -> 64 denseblock1: 64 -> 256 transition1: 256 -> 128 -> h/2 denseblock2: 128 -> 512 (12层时) ...

3.2 DenseLayer与DenseBlock实现

下面是完整可运行的DenseLayer代码:

import torch import torch.nn as nn import torch.nn.functional as F class DenseLayer(nn.Module): def __init__(self, in_channels, growth_rate, bn_size=4, drop_rate=0.0): super().__init__() # 1x1 bottleneck:先把通道压缩到 bn_size * growth_rate self.norm1 = nn.BatchNorm2d(in_channels) self.conv1 = nn.Conv2d(in_channels, bn_size * growth_rate, kernel_size=1, stride=1, bias=False) # 3x3卷积:真正产生 growth_rate 个新通道 self.norm2 = nn.BatchNorm2d(bn_size * growth_rate) self.conv2 = nn.Conv2d(bn_size * growth_rate, growth_rate, kernel_size=3, stride=1, padding=1, bias=False) self.drop_rate = drop_rate def forward(self, x): out = self.conv1(F.relu(self.norm1(x), inplace=True)) out = self.conv2(F.relu(self.norm2(out), inplace=True)) if self.drop_rate > 0: out = F.dropout(out, p=self.drop_rate, training=self.training) return torch.cat([x, out], dim=1)

核心就一行:torch.cat([x, out], dim=1)。输入x和本层新算出来的out在通道维度上拼接起来,作为下一层的输入。这里有个容易踩的坑:inplace=True用在ReLU上要小心,因为x在后续拼接中还要用到,如果ReLU修改了x本身,而x又被用在torch.cat里,可能导致梯度回传问题。稳妥起见ReLU的inplace可以设为False,性能差距很小。

DenseBlock实现:

class DenseBlock(nn.Module): def __init__(self, num_layers, in_channels, growth_rate, bn_size=4, drop_rate=0.0): super().__init__() self.layers = nn.ModuleList() for i in range(num_layers): layer_in_channels = in_channels + i * growth_rate self.layers.append( DenseLayer(layer_in_channels, growth_rate, bn_size, drop_rate) ) def forward(self, x): for layer in self.layers: x = layer(x) return x

DenseBlock本身不需要登记通道数,每构建一个DenseLayer时就把当前累积通道数传进去。关键规律再强调一遍:当前层输入通道数 = 块起始通道数 + 当前层之前所有层数 × growth_rate。

3.3 Transition与DenseNet主干实现

Transition层没有难度,但要记住通道数变化:

class Transition(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.norm = nn.BatchNorm2d(in_channels) self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False) self.pool = nn.AvgPool2d(kernel_size=2, stride=2) def forward(self, x): x = self.conv(F.relu(self.norm(x), inplace=True)) return self.pool(x)

主网络实现:

class DenseNet(nn.Module): def __init__(self, num_init_features=64, growth_rate=32, block_config=(6, 12, 24, 16), bn_size=4, theta=0.5, num_classes=1000): super().__init__() # 第一段:对ImageNet类大图做4倍下采样 self.features = nn.Sequential() self.features.add_module('conv0', nn.Conv2d( 3, num_init_features, kernel_size=7, stride=2, padding=3, bias=False)) self.features.add_module('norm0', nn.BatchNorm2d(num_init_features)) self.features.add_module('relu0', nn.ReLU(inplace=True)) self.features.add_module('pool0', nn.MaxPool2d( kernel_size=3, stride=2, padding=1)) # 依次堆叠 DenseBlock 和 Transition num_features = num_init_features for i, num_layers in enumerate(block_config): block = DenseBlock(num_layers, num_features, growth_rate, bn_size) self.features.add_module(f'denseblock{i + 1}', block) num_features = num_features + num_layers * growth_rate # 最后一个 DenseBlock 后面不接 Transition if i != len(block_config) - 1: out_channels = int(num_features * theta) trans = Transition(num_features, out_channels) self.features.add_module(f'transition{i + 1}', trans) num_features = out_channels # 最终分类头 self.features.add_module('norm5', nn.BatchNorm2d(num_features)) self.classifier = nn.Linear(num_features, num_classes) # 初始化 for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight.data) elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight.data, 1) nn.init.constant_(m.bias.data, 0) def forward(self, x): x = self.features(x) x = F.relu(x, inplace=True) x = F.adaptive_avg_pool2d(x, (1, 1)) x = torch.flatten(x, 1) return self.classifier(x)

主网络的关键点有两个。第一个是num_features这个变量的动态更新:每加一个DenseBlock,就加上该块所有层累积的通道数;每加一个Transition,就乘以theta。整个构建过程是在循环里动态计算通道的,千万别自己在每层写死。第二个是最后一个DenseBlock之后只接一个BN层,不做Transition,因为已经不需要再下采样,要把最终通道数直接送到全局平均池化。

3.4 前向验证:用随机输入检查每个环节的形状

写完代码别急着训练,先用随机输入跑一遍forward,确保形状没问题。我通常会把features里每个子模块的中间输出形状打印出来:

def check_model(model, input_tensor): x = input_tensor for name, module in model.features.named_children(): x = module(x) if any(k in name for k in ['conv0', 'pool0', 'denseblock', 'transition', 'norm5']): print(f'{name:20s} -> {x.shape}') return model.classifier(torch.flatten( F.adaptive_avg_pool2d(F.relu(x), (1, 1)), 1)) model = DenseNet(num_init_features=64, growth_rate=32, block_config=(6, 12, 24, 16), num_classes=1000) dummy = torch.randn(2, 3, 224, 224) out = check_model(model, dummy) print('output shape:', out.shape) # [2, 1000]

正常运行时,输出应该类似:

conv0 -> torch.Size([2, 64, 112, 112]) pool0 -> torch.Size([2, 64, 56, 56]) denseblock1 -> torch.Size([2, 256, 56, 56]) transition1 -> torch.Size([2, 128, 28, 28]) denseblock2 -> torch.Size([2, 512, 28, 28]) transition2 -> torch.Size([2, 256, 14, 14]) denseblock3 -> torch.Size([2, 1024, 14, 14]) transition3 -> torch.Size([2, 512, 7, 7]) denseblock4 -> torch.Size([2, 1024, 7, 7]) norm5 -> torch.Size([2, 1024, 7, 7]) output shape: torch.Size([2, 1000])

如果你在CIFAR-10这类32x32小图上用DenseNet,不需要stem里那个7x7大卷积和3x3池化,直接把输入换成3x3卷积、保持分辨率不变就行。很多实现里对CIFAR版本只保留DenseBlock+Transition,初通道设为32或64,块配置也相应缩减。

3.5 使用预训练模型:torchvision官方实现的正确姿势

自定义实现用于学习和面试足够了,但实际项目里更多是加载预训练权重。torchvision里自带DenseNet系列,调用方式如下:

import torchvision from torchvision.models import densenet121, DenseNet121_Weights # 新版推荐写法 model = densenet121(weights=DenseNet121_Weights.IMAGENET1K_V1) # 旧版参数,很多老代码在用 # model = densenet121(pretrained=True)

新版本torchvision把pretrained=True改成了weights=...,如果你用老参数运行,会收到FutureWarning提示。这里有个注意点:如果只是做特征提取,最后一层全连接可以砍掉,直接用model.features输出特征图;但分类场景要改成自己的类目数,不能直接复用1000类权重。正确的做法是加载权重后替换classifier:

model = densenet121(weights=DenseNet121_Weights.IMAGENET1K_V1) in_features = model.classifier.in_features model.classifier = nn.Linear(in_features, 10) # 换成自己的10类任务

4. 训练与部署实战:显存优化、防止过拟合、常见问题排查

4.1 DenseNet训练的显存黑洞:为什么参数少反而吃显存

训练DenseNet时最让人头疼的问题就是显存。判别网络占用显存的核心因素是反向传播需要保存的中间激活值。DenseNet前向传播把大量历史特征图拼接后再进入下一层,反向传播时每一层都需要用到这个拼接后的完整张量来算梯度,这些中间结果都要在显存里存着。层数越深、DenseBlock越长,中间激活值的累积越夸张。

这就解释了为什么DenseNet-121参数只有7.9M,但训练显存却可能比一些几十M的ResNet还高。参数中很大一部分是权重,显存大头却往往是激活值。面试官问这个题,就是在考你有没有理解“参数少”和“显存低”是两件事。

显存优化的第一招是开启PyTorch的checkpoint机制。把每个DenseLayer包在torch.utils.checkpoint里,前向时不再保存中间激活,反向时重新算一遍,用时间换空间。torchvision里其实已经预留了这个开关:

model = densenet121(weights=DenseNet121_Weights.IMAGENET1K_V0, memory_efficient=True)

第二招是控制growth rate。如果你发现显存不够,优先把小版本DenseNet-121的growth rate从32降到16或24,效果立竿见影。模型对growth rate的敏感度很高,适当降低后精度损失并没有想象中大,尤其在小数据集上。

4.2 小数据集上防止过拟合的三个习惯

DenseNet特征复用能力强,在ImageNet上是大优势,但在几万张的小数据集上却更容易过拟合。个人实操里的经验是三个习惯:第一,增强一定要给足,随机裁剪、翻转、颜色抖动能开就开;第二,在DenseLayer里加dropout,drop_rate一般设0.2左右,注意是在拼接之前对当前层输出做dropout,而不是对最终拼接结果做;第三,batch size别开太大,DenseNet家族BN层的统计量在小batch下会明显抖动,建议32或64起步,不要一上来就128。

4.3 部署和推理时的注意事项

DenseNet推理时和训练时的显存表现差异很大。训练时中间激活值占据大量显存,但推理时不需要保留激活值做反传,直接把每个DenseLayer的输出拼接特征图丢掉或复用即可,所以推理显存通常比训练低一个数量级。不过需要注意,通道拼接的特征图数量很大,在CPU上推理时内存带宽会成为瓶颈,速度往往比同精度ResNet慢。如果项目对CPU延迟敏感,可以考虑把部分DenseBlock替换成ResBlock,或者改用DenseNet的剪枝变体。

目标检测场景里,DenseNet常被用作SSD类的骨干网络。但SSD需要从不同stage抽取特征图,DenseNet前面的stage通道数增长很快,导致多尺度特征图的计算量偏高。常见做法是把growth rate降到16,或者对早期DenseBlock使用较少的层数。分割任务中,DenseUNet的编码器和解码器都换成DenseBlock,参数量还能比普通UNet更小,分割精度往往也更稳。

4.4 面试高频追问与回答模板

以下是我在面试里遇到过的DenseNet相关高频问题,整理出一份速查:

问题关键回答要点
DenseNet和ResNet的本质区别?ResNet是“短接+相加”,DenseNet是“全拼+拼接”;DenseNet特征复用更彻底,梯度路径更短
为什么DenseNet参数量少?每层只产生k个新通道,靠复用前层特征做增量学习,不需要每层重复学全量特征
为什么训练显存反而高?反传需要保存大量拼接后的激活图,即“参数少但激活多”
growth rate怎么选?默认32;显存受限选16或24;对精度要求高可以试40+,但参数量和显存会明显上涨
1x1卷积在DenseLayer里有什么用?bottleneck降低3x3卷积输入通道数,控制计算量,是DenseNet-B的标志
DenseBlock内第5层输入通道怎么算?等于块起始通道数 + 4 × growth_rate
DenseNet的缺点?训练显存高、特征图拼接导致推理带宽消耗大、反传计算量大

回答这些问题时,建议顺着“设计动机→结构细节→实际代价”的顺序,不要直接抛结论。比如问到“为什么显存高”,先说明反向传播需要保存激活,再给出现象,面试官就觉得你真的跑过模型。

4.5 运行常见报错与解决方案

手写DenseNet跑不起来,绝大多数问题出在通道数对不上。常见的三个报错我列出来:

第一个是RuntimeError: Given groups=1, weight of size ... expected input to have ... channels。这通常是DenseBlock里层间通道传错了。检查一下构建循环里用的是in_channels + i * growth_rate,还是错误地写成了上一层的输出通道。每层输入通道都包含该块起始通道数,不是上一层的输出通道数。

第二个是RuntimeError: mat1 and mat2 shapes cannot be multiplied。这多半是全局平均池化后特征图尺寸不是1x1,或者随手把classifier的输入特征数写错了。用torch.flatten前先打印一下池化后的形状,确认是[batch, num_features]。

第三个是显存不足OOM。先把growth rate降下来,再考虑开memory_efficient=True,最后才考虑减小batch size。很多人一上来就减batch size,导致BN统计量抖得厉害,反而更难收敛。

5. 把DenseNet真正用好的个人积累

我早期在某个图像分类项目里第一次手写DenseNet,当时犯过最蠢的错误,是在DenseLayer的forward里对输入x做了ReLU的inplace操作,结果x在torch.cat时已经被改掉了,整个梯度链路直接断裂,调了一整晚才发现。后来我养成了一个习惯:涉及拼接操作的模块,一律不用inplace激活,能避开一类非常隐蔽的bug。

现在回头看,DenseNet在面试中的考察密度之所以高,是因为它完整覆盖了网络设计的几个关键议题:短路径与长路径的信息流动、参数效率与计算效率的权衡、训练与推理的资源消耗差异。能把这些讲清楚,比单纯背出模型配置表有说服力得多。如果你近期要面试,建议亲手先把DenseNet-121跑一遍,把中间特征图的通道变化写出来,再想清楚上面几个“为什么”,这一关基本就稳了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 9:56:21

Positron配置指南:Claude Code/Codex/OpenCode协议适配实战

1. Positron不是VS Code的平替,而是为AI原生开发重构的编辑器内核Positron这个名字在2024年中后期突然密集出现在开发者社区的讨论帖里,但它绝不是又一个“VS Code换皮”项目。我最早接触它是在调试一个Claude Code插件时偶然发现的——当时VS Code里反复…

作者头像 李华
网站建设 2026/10/17 9:55:15

基于MP2642GR-Z与MK64FX512VDC12的双串电芯被动均衡方案设计与实现

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

作者头像 李华
网站建设 2026/10/17 9:54:54

Flutter鸿蒙适配实战:联邦插件迁移simple_auth实现OAuth登录

做 Flutter 开发的老哥应该对 simple_auth 不陌生。它外表上就是一个“省心”的库——OAuth 登录、Token 自动注入、过期自动刷新,几乎把 REST API 验证这块最烦的活全包了。但省心的另一面,是它背后压着一大堆原生能力:系统浏览器、钥匙串、…

作者头像 李华
网站建设 2026/10/17 9:52:01

STM32驱动PS2手柄实战:SPI通信与LED控制

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

作者头像 李华
网站建设 2026/10/17 9:51:51

储能电池被动均衡方案:MP2642GR-Z与PIC18F4620实战

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

作者头像 李华