LightGCN实战:如何用简化版图卷积网络提升推荐系统效果(附代码)
如果你正在构建推荐系统,并且对图神经网络(GNN)有所涉猎,那么你很可能听说过LightGCN。这个名字在近几年的推荐系统研究圈里,几乎成了一个“效率”的代名词。它不像那些动辄几十层、参数庞大的深度学习模型,反而以一种近乎“返璞归真”的思路,在多个公开数据集上取得了令人瞩目的成绩。今天,我们不谈复杂的数学推导,而是从一个实践者的角度,聊聊如何亲手实现一个LightGCN模型,让它真正在你的推荐场景里跑起来,并发挥出应有的威力。
对于推荐系统的开发者而言,最大的痛点往往不是没有模型可用,而是模型过于复杂,难以训练、调试和上线。LightGCN的出现,恰好击中了这个痛点。它剥离了传统图卷积网络中那些被认为对协同过滤任务“无效”甚至“有害”的组件,只保留了最核心的邻居聚合操作。这种设计哲学带来的直接好处就是:模型更轻、训练更快、效果却更好。接下来,我将带你从零开始,一步步搭建LightGCN,并分享在参数调优和实际部署中我踩过的一些坑和总结的经验。
1. 理解LightGCN的设计哲学:为什么“少即是多”
在动手写代码之前,我们必须先搞清楚LightGCN到底“轻”在哪里。这不仅仅是代码行数的减少,更是一种对图卷积本质的深刻洞察。
传统的图卷积网络(GCN),最初是为节点分类任务设计的,其标准操作通常包含三个步骤:特征变换、邻居聚合、非线性激活。当我们将这套范式迁移到推荐系统的用户-物品交互图时,问题就出现了。在这个图里,每个节点(用户或物品)的初始特征往往只是一个简单的ID嵌入(one-hot编码的向量化表示),并不像图像或文本那样拥有丰富的语义特征。
提示:这里的“特征贫乏”是关键。对于一个只有ID信息的节点,进行复杂的非线性变换,无异于“无米之炊”,不仅学不到更多有效信息,反而可能因为增加了模型复杂度而加剧训练困难,甚至导致性能下降。
LightGCN的作者通过大量的实验分析发现,在协同过滤场景下:
- 特征变换矩阵(如
W1,W2):引入额外的参数,增加了模型复杂度,但并未带来性能增益,移除后效果反而提升。 - 非线性激活函数(如ReLU):对于基于ID嵌入的学习,非线性激活的作用微乎其微,有时甚至会产生负面影响。
因此,LightGCN毅然决然地舍弃了这两者,只保留了最纯粹的邻居聚合操作。它的图卷积层(Light Graph Convolution, LGC)定义简洁得惊人:
[ e_u^{(k+1)} = \sum_{i \in \mathcal{N}_u} \frac{1}{\sqrt{|\mathcal{N}_u|}\sqrt{|\mathcal{N}i|}} e_i^{(k)} ] [ e_i^{(k+1)} = \sum{u \in \mathcal{N}_i} \frac{1}{\sqrt{|\mathcal{N}_u|}\sqrt{|\mathcal{N}_i|}} e_u^{(k)} ]
其中,(e_u^{(k)}) 表示第k层用户u的嵌入,(\mathcal{N}_u) 是用户u交互过的物品集合。这个公式的核心就是对称归一化的加权求和,没有多余的参数。
另一个精妙的设计是层组合(Layer Combination)。LightGCN并不直接使用最后一层的嵌入作为最终表示,而是将所有层的嵌入进行加权求和:
[ e_u = \sum_{k=0}^{K} \alpha_k e_u^{(k)}, \quad e_i = \sum_{k=0}^{K} \alpha_k e_i^{(k)} ]
通常,我们可以简单地设置 (\alpha_k = 1/(K+1))。这样做的好处有三:
- 缓解过平滑:随着层数增加,节点嵌入会趋于相似(过平滑)。融合浅层嵌入能保留更多个性化信息。
- 捕获多阶语义:第1层聚合了直接交互的邻居,第2层聚合了“有共同兴趣”的邻居(二阶邻居),融合后表征更全面。
- 等价于自连接:数学上可以证明,这种加权求和的方式,其效果等同于在邻接矩阵中添加自连接(self-loop),这是GCN中常见的一种稳定训练的技巧。
理解了这些,我们就知道在实现时,代码应该围绕“轻量聚合”和“多层求和”这两个核心展开。
2. 环境准备与数据加载
工欲善其事,必先利其器。我们先来搭建一个干净、可复现的Python环境。我强烈建议使用conda来管理环境,避免包版本冲突。
# 创建并激活一个名为lightgcn的conda环境
conda create -n lightgcn python=3.8
conda activate lightgcn
# 安装核心依赖
pip install torch==1.12.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu # 根据你的CUDA版本调整
pip install scikit-learn pandas numpy tqdm
接下来,我们需要一个标准的推荐数据集。这里我们选用经典的 MovieLens 1M 数据集,它包含了约100万条用户对电影的评分记录。我们将评分视为隐式反馈(即只要有过评分,就认为存在交互)。
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
def load_movielens_data(data_path, threshold=4.0):
"""
加载MovieLens数据,并将显式评分转换为隐式反馈(0/1)。
Args:
data_path: ratings.dat文件路径
threshold: 评分高于此值视为正样本(喜欢)
Returns:
user_item_matrix: 用户-物品交互矩阵(CSR格式)
num_users, num_items
"""
# 读取数据,MovieLens 1M格式为 UserID::MovieID::Rating::Timestamp
ratings = pd.read_csv(data_path, sep='::', engine='python',
names=['user_id', 'item_id', 'rating', 'timestamp'])
# 转换为隐式反馈:评分>=threshold的记为1
ratings['interact'] = (ratings['rating'] >= threshold).astype(np.int32)
# 重新映射用户和物品ID为连续的索引(从0开始)
unique_users = ratings['user_id'].unique()
unique_items = ratings['item_id'].unique()
user_id_map = {uid: idx for idx, uid in enumerate(unique_users)}
item_id_map = {iid: idx for idx, iid in enumerate(unique_items)}
ratings['user_idx'] = ratings['user_id'].map(user_id_map)
ratings['item_idx'] = ratings['item_id'].map(item_id_map)
num_users = len(unique_users)
num_items = len(unique_items)
# 构建交互矩阵 (COO格式,便于后续转换)
rows = ratings['user_idx'].values
cols = ratings['item_idx'].values
data = ratings['interact'].values
from scipy.sparse import coo_matrix, csr_matrix
user_item_coo = coo_matrix((data, (rows, cols)), shape=(num_users, num_items))
user_item_csr = user_item_coo.tocsr()
print(f"数据集加载完成。用户数: {num_users}, 物品数: {num_items}, 交互数: {user_item_csr.nnz}")
return user_item_csr, num_users, num_items
# 假设数据文件在当前目录
data_path = './ml-1m/ratings.dat'
sparse_mat, n_users, n_items = load_movielens_data(data_path)
数据加载后,我们得到了一个稀疏矩阵。接下来,需要按用户划分训练集、验证集和测试集,确保每个用户在训练集中至少有一个交互。
def train_val_test_split_by_user(sparse_mat, val_ratio=0.1, test_ratio=0.1, seed=2023):
"""
按用户划分数据集,保证每个用户在训练集中至少有一个交互。
"""
np.random.seed(seed)
train_mat = sparse_mat.copy().tolil() # 使用LIL格式便于逐行修改
val_mat = sparse_mat.copy().tolil()
test_mat = sparse_mat.copy().tolil()
# 初始化验证和测试矩阵为全零
val_mat[:, :] = 0
test_mat[:, :] = 0
for u in range(sparse_mat.shape[0]):
# 获取该用户的所有交互物品索引
interacted_items = sparse_mat[u].indices
if len(interacted_items) < 3:
# 如果交互太少,全部放入训练集
continue
# 随机打乱
np.random.shuffle(interacted_items)
n_val = int(len(interacted_items) * val_ratio)
n_test = int(len(interacted_items) * test_ratio)
val_items = interacted_items[:n_val]
test_items = interacted_items[n_val:n_val+n_test]
train_items = interacted_items[n_val+n_test:]
# 更新矩阵
train_mat[u, val_items] = 0
train_mat[u, test_items] = 0
val_mat[u, val_items] = sparse_mat[u, val_items]
test_mat[u, test_items] = sparse_mat[u, test_items]
# 转换回CSR格式以提高后续计算效率
return train_mat.tocsr(), val_mat.tocsr(), test_mat.tocsr()
train_mat, val_mat, test_mat = train_val_test_split_by_user(sparse_mat)
print(f"训练集交互数: {train_mat.nnz}, 验证集交互数: {val_mat.nnz}, 测试集交互数: {test_mat.nnz}")
3. 核心模型实现:从公式到PyTorch代码
现在进入最激动人心的部分:用PyTorch实现LightGCN。我们将严格按照论文中的设计,构建一个清晰、模块化的模型类。
首先,我们需要构建模型的邻接矩阵。LightGCN使用用户-物品二分图的对称归一化邻接矩阵。
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_sparse import SparseTensor
def create_lightgcn_adjacency(train_mat, device):
"""
构建LightGCN所需的对称归一化邻接矩阵 \tilde{A}。
Args:
train_mat: 训练集用户-物品交互矩阵 (scipy.sparse.csr_matrix)
Returns:
adj: 归一化的邻接矩阵 (torch_sparse.SparseTensor)
"""
n_users, n_items = train_mat.shape
# 构建二分图的邻接矩阵 A
# A = | 0, R |
# | R^T, 0 |
# 其中 R 是交互矩阵
R = train_mat.tocoo()
rows = torch.from_numpy(R.row).long()
cols = torch.from_numpy(R.col).long() + n_users # 物品节点ID偏移
values = torch.ones(rows.size(0))
# 构建稀疏对称矩阵的索引和值
edge_index_user_to_item = torch.stack([rows, cols])
edge_index_item_to_user = torch.stack([cols, rows])
edge_index = torch.cat([edge_index_user_to_item, edge_index_item_to_user], dim=1)
edge_weight = torch.cat([values, values])
# 计算度矩阵 D 并对称归一化:D^{-1/2} A D^{-1/2}
from torch_sparse import sum as sp_sum
adj_tmp = SparseTensor(row=edge_index[0], col=edge_index[1], value=edge_weight,
sparse_sizes=(n_users+n_items, n_users+n_items))
deg = sp_sum(adj_tmp, dim=1) # 计算每个节点的度
deg_inv_sqrt = deg.pow(-0.5)
deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0
# 归一化:edge_weight = deg_i^{-1/2} * deg_j^{-1/2}
row, col, value = adj_tmp.coo()
norm_value = deg_inv_sqrt[row] * deg_inv_sqrt[col] * value
# 创建最终的归一化邻接矩阵 SparseTensor
norm_adj = SparseTensor(row=row, col=col, value=norm_value,
sparse_sizes=(n_users+n_items, n_users+n_items))
return norm_adj.to(device)
# 示例:在CPU上创建邻接矩阵
device = torch.device('cpu')
norm_adj = create_lightgcn_adjacency(train_mat, device)
接下来,实现LightGCN模型本身。注意,我们的模型参数只有第0层的嵌入(self.embedding_user, self.embedding_item)。
class LightGCN(nn.Module):
def __init__(self, n_users, n_items, embedding_dim, n_layers, adj, device):
super(LightGCN, self).__init__()
self.n_users = n_users
self.n_items = n_items
self.embedding_dim = embedding_dim
self.n_layers = n_layers
self.adj = adj
self.device = device
# 初始化第0层嵌入
self.embedding_user = nn.Embedding(n_users, embedding_dim)
self.embedding_item = nn.Embedding(n_items, embedding_dim)
nn.init.normal_(self.embedding_user.weight, std=0.01)
nn.init.normal_(self.embedding_item.weight, std=0.01)
# 层组合权重,可以设置为可学习参数,这里按论文使用固定权重 1/(K+1)
self.alpha = 1.0 / (n_layers + 1)
def forward(self):
"""
前向传播,计算所有用户和物品的最终嵌入。
返回: (final_user_emb, final_item_emb)
"""
# 初始嵌入 (第0层)
users_emb = self.embedding_user.weight
items_emb = self.embedding_item.weight
all_emb = torch.cat([users_emb, items_emb]) # [n_users+n_items, dim]
embs = [all_emb] # 存储每一层的嵌入
# 进行K层图卷积传播
for _ in range(self.n_layers):
# Light Graph Convolution: e^{(k+1)} = \tilde{A} e^{(k)}
all_emb = self.adj @ all_emb
embs.append(all_emb)
# 层组合:加权求和
embs = torch.stack(embs, dim=0) # [n_layers+1, n_nodes, dim]
final_emb = embs.mean(dim=0) # 等价于权重 alpha_k = 1/(K+1)
final_user_emb, final_item_emb = torch.split(final_emb, [self.n_users, self.n_items])
return final_user_emb, final_item_emb
def calc_bpr_loss(self, users, pos_items, neg_items):
"""
计算BPR损失。
Args:
users: 用户索引 [batch_size]
pos_items: 正样本物品索引 [batch_size]
neg_items: 负样本物品索引 [batch_size]
"""
final_user_emb, final_item_emb = self.forward()
user_emb = final_user_emb[users]
pos_emb = final_item_emb[pos_items]
neg_emb = final_item_emb[neg_items]
pos_scores = torch.sum(user_emb * pos_emb, dim=1) # 内积
neg_scores = torch.sum(user_emb * neg_emb, dim=1)
# BPR Loss: -log sigma(pos_score - neg_score)
loss = -torch.log(torch.sigmoid(pos_scores - neg_scores) + 1e-8).mean()
return loss
def get_embeddings(self):
"""获取训练好的用户和物品嵌入"""
with torch.no_grad():
return self.forward()
模型定义好了,我们还需要一个数据采样器,用于生成BPR损失所需的(用户,正物品,负物品)三元组。
class BPRDataLoader:
def __init__(self, train_mat, n_items, batch_size, n_negatives=1):
self.train_mat = train_mat
self.n_users, self.n_items = train_mat.shape
self.batch_size = batch_size
self.n_negatives = n_negatives
# 预处理每个用户的正样本列表
self.user_pos_items = []
for u in range(self.n_users):
self.user_pos_items.append(train_mat[u].indices)
# 所有物品的列表,用于负采样
self.all_items = np.arange(n_items)
def __iter__(self):
# 随机打乱用户顺序
user_indices = np.random.permutation(self.n_users)
for start in range(0, self.n_users, self.batch_size):
end = min(start + self.batch_size, self.n_users)
batch_users = user_indices[start:end]
batch_pos = []
batch_neg = []
for u in batch_users:
pos_for_u = self.user_pos_items[u]
if len(pos_for_u) == 0:
continue
# 为正样本随机选择一个负样本
for _ in range(self.n_negatives):
pos = np.random.choice(pos_for_u)
# 负采样:随机选择一个用户未交互过的物品
while True:
neg = np.random.choice(self.all_items)
if neg not in pos_for_u:
break
batch_pos.append(pos)
batch_neg.append(neg)
if len(batch_pos) == 0:
continue
batch_users = torch.LongTensor(np.repeat(batch_users, self.n_negatives))
batch_pos = torch.LongTensor(batch_pos)
batch_neg = torch.LongTensor(batch_neg)
yield batch_users, batch_pos, batch_neg
4. 模型训练、调参与效果评估
万事俱备,现在可以开始训练了。我们将设置训练循环,并监控模型在验证集上的表现。
def train_one_epoch(model, data_loader, optimizer, device):
model.train()
total_loss = 0.0
for batch_users, batch_pos, batch_neg in data_loader:
batch_users = batch_users.to(device)
batch_pos = batch_pos.to(device)
batch_neg = batch_neg.to(device)
optimizer.zero_grad()
loss = model.calc_bpr_loss(batch_users, batch_pos, batch_neg)
loss.backward()
optimizer.step()
total_loss += loss.item()
return total_loss / len(data_loader)
def evaluate(model, train_mat, test_mat, K=20):
"""
在测试集上评估模型性能,计算Recall@K和NDCG@K。
"""
model.eval()
with torch.no_grad():
user_emb, item_emb = model.get_embeddings()
# 计算所有用户-物品对的分数矩阵(大规模下需分块计算,此处为示例简化)
scores = torch.matmul(user_emb, item_emb.T) # [n_users, n_items]
# 将训练集中已交互的物品分数设为极小值,避免推荐已交互过的
train_interact_mask = torch.from_numpy(train_mat.toarray()).bool().to(scores.device)
scores[train_interact_mask] = -1e9
# 获取Top-K物品
_, topk_indices = torch.topk(scores, k=K, dim=1) # [n_users, K]
topk_indices_np = topk_indices.cpu().numpy()
recall_list = []
ndcg_list = []
for u in range(test_mat.shape[0]):
true_items = test_mat[u].indices
if len(true_items) == 0:
continue
pred_items = topk_indices_np[u]
# 计算Recall@K
hits = np.isin(pred_items, true_items)
recall = hits.sum() / len(true_items)
recall_list.append(recall)
# 计算NDCG@K
dcg = 0.0
for i, hit in enumerate(hits):
if hit:
dcg += 1.0 / np.log2(i + 2) # i从0开始,所以排名是i+1
idcg = sum([1.0 / np.log2(i + 2) for i in range(min(len(true_items), K))])
ndcg = dcg / idcg if idcg > 0 else 0.0
ndcg_list.append(ndcg)
mean_recall = np.mean(recall_list)
mean_ndcg = np.mean(ndcg_list)
return mean_recall, mean_ndcg
# 主训练流程
def main_training():
# 超参数设置
embedding_dim = 64
n_layers = 3
batch_size = 2048
lr = 0.001
weight_decay = 1e-4
epochs = 50
K = 20 # 评估时取Top-20
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"使用设备: {device}")
# 准备数据
data_loader = BPRDataLoader(train_mat, n_items, batch_size, n_negatives=1)
# 构建邻接矩阵
norm_adj = create_lightgcn_adjacency(train_mat, device)
# 初始化模型
model = LightGCN(n_users, n_items, embedding_dim, n_layers, norm_adj, device).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
best_recall = 0.0
patience = 5
patience_counter = 0
for epoch in range(epochs):
# 训练一个epoch
train_loss = train_one_epoch(model, data_loader, optimizer, device)
# 每5个epoch评估一次
if (epoch + 1) % 5 == 0 or epoch == 0:
recall, ndcg = evaluate(model, train_mat, val_mat, K)
print(f"Epoch {epoch+1:03d} | Loss: {train_loss:.4f} | Val Recall@{K}: {recall:.4f} | Val NDCG@{K}: {ndcg:.4f}")
# 早停策略
if recall > best_recall:
best_recall = recall
patience_counter = 0
# 保存最佳模型
torch.save(model.state_dict(), 'lightgcn_best_model.pth')
else:
patience_counter += 1
if patience_counter >= patience:
print(f"早停触发于第 {epoch+1} 轮。")
break
# 加载最佳模型并在测试集上评估
model.load_state_dict(torch.load('lightgcn_best_model.pth'))
test_recall, test_ndcg = evaluate(model, train_mat, test_mat, K)
print(f"\n最终测试集性能 - Recall@{K}: {test_recall:.4f}, NDCG@{K}: {test_ndcg:.4f}")
if __name__ == '__main__':
main_training()
运行上述代码,你就能得到一个完整的LightGCN训练和评估流程。根据我的经验,在MovieLens 1M数据集上,一个3层的LightGCN模型(嵌入维度64)通常能在Recall@20上达到0.12-0.15,NDCG@20达到0.06-0.08左右,具体数值取决于数据划分和随机种子。
关键超参数调优经验
模型跑起来只是第一步,调参才是让模型发挥最佳性能的关键。根据多次实验,我总结出几个最重要的超参数及其影响:
| 超参数 | 常见范围 | 影响与调优建议 |
|---|---|---|
嵌入维度 (embedding_dim) | 32, 64, 128, 256 | 维度越高,模型容量越大,但过拟合风险也增加。对于百万级交互的数据集,64或128通常是个不错的起点。可以观察验证集损失,如果训练损失持续下降但验证集损失上升,可能是维度太高。 |
图卷积层数 (n_layers) | 1, 2, 3, 4 | 层数决定了信息传播的阶数。层数太少,无法利用高阶协同信号;层数太多,会导致过平滑,所有节点嵌入趋同。通常2-4层效果最佳。可以通过可视化不同层嵌入的相似度来辅助判断。 |
学习率 (lr) | 0.001, 0.0005, 0.0001 | LightGCN训练相对稳定,Adam优化器下0.001是安全的初始值。如果训练初期损失震荡剧烈,可以适当降低。 |
L2正则化系数 (weight_decay) | 1e-5, 1e-4, 1e-3 | 防止过拟合的重要参数。LightGCN对正则化比较敏感。值太大会严重抑制模型学习,值太小则可能过拟合。建议从1e-4开始尝试。 |
层组合权重 (alpha) | 固定(1/(K+1)), 可学习 | 论文发现将其设为可学习参数提升不大。保持固定权重简化模型,是推荐做法。 |
注意:负采样策略对BPR损失的训练效率影响很大。上述示例采用了最简单的均匀随机负采样。在实际生产中,可以考虑更高效的策略,如“基于流行度的负采样”(更大概率采样热门负样本)或“难例挖掘”(采样模型当前预测分数较高的负样本),这能加速模型收敛并可能提升效果。
5. 进阶话题与生产环境考量
当你成功复现了基础版本的LightGCN后,可能会思考如何将它应用到更复杂的实际业务中,或者如何进一步提升其性能。这里分享几个进阶方向。
1. 处理大规模图:邻居采样与矩阵分块 当用户和物品数量达到千万甚至亿级时,存储和计算完整的邻接矩阵 (\tilde{A}) 是不现实的。此时必须采用采样技术。
- 邻居采样:在每一层卷积时,不为每个节点聚合所有邻居,而是随机采样固定数量的邻居。这能极大减少计算量。
- 矩阵分块计算:在计算最终嵌入时,将用户和物品嵌入分块,分批进行矩阵乘法,避免一次性计算巨大的分数矩阵。
# 邻居采样的简化示例思路(非完整代码)
def lightgcn_layer_with_sampling(self, all_emb, adj, sample_size=10):
"""
带邻居采样的图卷积层。
"""
sampled_adj = sample_neighbors(adj, sample_size) # 自定义采样函数
return sampled_adj @ all_emb
2. 融入侧信息(Side Information) 原始的LightGCN只利用了交互图结构。在实际业务中,我们常有丰富的用户画像(年龄、性别)和物品属性(类别、标签)。如何融入这些信息?
- 早期融合:将侧信息编码为向量,与ID嵌入拼接或相加,作为第0层嵌入的输入。
- 后期融合:让LightGCN学习基于结构的嵌入,同时用另一个网络学习基于侧信息的嵌入,最后将两种嵌入融合(如拼接、加权求和)用于预测。 需要谨慎的是,添加侧信息可能会引入噪声,必须通过实验验证其有效性。
3. 在线服务与模型更新 训练好的LightGCN模型需要部署到线上提供实时推荐。其服务核心是快速计算用户向量与所有物品向量的内积(或近似内积)。
- 向量检索:将物品嵌入存入向量数据库(如Faiss, Milvus)。线上服务时,只需计算一次用户向量,然后通过向量数据库进行最近邻搜索,快速得到Top-K物品。
- 增量更新:用户的新交互行为会产生新的图边。完全重新训练模型成本高。可以考虑:
- 周期性全量训练:每天或每周用全量数据重新训练。
- 嵌入微调:固定图结构,仅用新产生的交互数据对用户和物品嵌入进行少量迭代的微调。
- 动态图模型:研究如何将LightGCN与动态图神经网络结合,但这属于更前沿的探索。
在我经历的一个电商推荐项目中,我们最初直接上线了LightGCN,发现对于新用户(冷启动)推荐效果不佳。后来我们采用了“早期融合”的方式,将用户最近点击的品类偏好(作为侧信息)融入初始嵌入,显著提升了新用户首日的点击率。这个案例说明,没有放之四海而皆准的模型,成功的落地离不开对业务场景的深刻理解和持续的迭代实验。
LightGCN以其简洁和高效,为图神经网络在推荐系统的落地打开了一扇大门。它告诉我们,有时候,最有效的改进不是增加复杂度,而是勇敢地做减法。希望这篇从理论到实践的长文,能帮助你少走弯路,更快地让这个优秀的模型在你的业务中创造价值。如果在实现过程中遇到问题,多回头看看邻接矩阵的构建和层组合的代码,这两个地方最容易出错。
&spm=1001.2101.3001.5002&articleId=154226374&d=1&t=3&u=ce00fa18411f4a0dbd82d5617157669c)
1457

被折叠的 条评论
为什么被折叠?



