Google OR-Tools(五) 路径问题 Routing

本文参考Google OR-Tools官网文档介绍OR-Tools的使用方法。实际生活中有很多组合优化问题,例如最短路径、背包问题、人员排班等,这些组合优化问题一般属于规模较大的整数规划或者约束满足问题,一般没有直接的算法获得绝对最优解,只能通过启发式或元启发式算法获得相对最佳解。OR-Tools针对路径问题、背包问题和流问题提供了专用接口,相对于通用的求解器有更高的计算效率。

1 TSP问题

路径优化问题的目标都是在一个复杂的网络中找一个效能最高的路径,这个网络可以用下面这个节点图来示意


routing.jpg

按照优化的目标是针对节点还是边,路径优化问题可以分成两类:节点路径优化( node-routing)和边路径优化(arc-routing)。节点路径优化的目标是以最短的路径长度访问到每个节点,而边路径优化的目标是以最短的路径长度实现访问图中的每条边。
旅行商(TSP)问题就是一个非常经典的node-routing问题,在这个问题下,图中每个节点代表了一个城市,而节点之间的边表示两座城市间的行程;每条边赋予一个权值,代表了两座城市间的距离;目标就是为旅行商找一条行程最短的路线来访问到每座城市。在这个标准的TSP问题定义上还可以附加各种额外条件来符合现实情况:

  • 非对称距离,也就是节点图是有向图,从A到B和从B到A的距离不相同
  • Prize-Collecting TSP,访问一个城市可能有收益,离开一个城市则可能有额外的支出
  • 时间窗,有些城市要在规定的时间内访问

下面我们先了解一些解决以TSP为例的路径问题的算法,再使用OR-Tools演示一下。

2 算法相关

2.1 启发式算法

启发(heuristic)这个词带有联想、经验的意思,这也正是启发式算法的思想,是带有领域知识的非精确算法,启发式算法一般要依赖于问题领域,不同的问题因为环境的不同启发式算法的形式也不同。对于TSP问题,一种最简单和直接的启发式算法是最邻近优先(Nearest Neighbor)法,就是每次都访问离当前位置最近的城市,这种算法的时间复杂度为O(n^2)

  1. 随机选择一个城市;
  2. 找当前最近的未访问城市最为下一个访问的城市;
  3. 是否还有未访问的城市?如果有,再进行2;
  4. 返回到初始城市

这种算法得到的解通常来说只有最优解的10%到15%。或者可以用一种更好的启发式算法,最短边贪心算法,就是不断选择最短的边来构造路径:

  1. 所有的边排序
  2. 选择最短的边加入路径,如果这边不会导致某个城市有三个及以上的度(就是节点有三个及以上的边连接),并且不会使某些城市构成回路
  3. 是否有N条边在路径中,如果是,则停止,否则再进行2

贪心算法的时间复杂度为O(n^2log_2(n)),解的优良程度大概是最优解的15%到20%。除了这两种典型的启发式算法,还有其他很多种实现,就不列举了。
启发式算法的最大优点就是计算速度非常快,但是缺点也很明显,解的质量不高,因为本质上所有的启发式算法都属于局部最优算法,在随机初始化后只会沿这个方向发展,到达局部最优点后就停止了。

2.2 元启发式算法

元启发式算法有时也称为智能优化算法,宽泛的来说也属于启发式算法,不过因为相对朴素的启发式算法有很大程度的改进因此一般单独划分出来讨论。元启发式算法的一大特点是面向全局的优化,会利用各种手段跳出局部最优来尝试寻找更好的解;另一个特点是通常会参考现实世界的物理过程来具体实现,从大多数元启发式算法的名字就可以直接体现,例如模拟退火算法、遗传算法、蚁群算法和霍普菲尔德神经网络等等。
在这篇文章里我们介绍一下模拟退火算法。它是受加热金属的退火过程所启发而提出的一种逼近算法,该算法在1983年由Kirkpatrick等人提出,并且最初设计这个算法就是为了解决TSP问题的。
模拟退火参考了一个物理现象:在某个温度下,金属分子停留在能量小的状态的概率比停留在能量大的状态的概率要大。更加具体的物理背景是这样的,在温度T下,金属物体的分子可能会处于若干种状态,停留在状态r的概率满足Boltzmann分布:
\begin{aligned} P\{\overline{E} = E(r)\}= \frac{1}{Z(T)} exp(-\frac{E(r)}{k_BT}) \end{aligned}
其中\overline{E}表示分子能量的随机变量,E(r)表示在状态r下的能量,k_B>0为Boltzmann常量,而Z(T)为概率分布的标准化因子
\begin{aligned} Z(T)=\sum_{r \in D}^{ }exp(- \frac{E(r)}{k_BT}) \end{aligned}
其中D是状态空间。根据分子能量的Boltzmann分布方程,分子不同状态的能量与温度的关系具有以下的特性:

  • 温度很高时(T\to\infty),概率P\{\overline{E} = E(r)\}接近常数\frac{1}{ D},也就是说温度很高时处于各个状态的概率基本相等
  • 如果E_1<E_2,则
    \begin{aligned} P\{\overline{E} = E_1\}-P \{\overline{E} = E_2 \}=\frac{1}{Z(T)} exp(-\frac{E_1}{k_BT})[1-exp(-\frac{E_2-E_1}{k_BT})] \end{aligned}
    则有
    \begin{aligned} P\{\overline{E} = E_1\}-P\{\overline{E} = E_2 \}>0, \quad \forall T>0 \end{aligned}
    也就是说同一温度下,分子处于低能量状态的概率要比处于高能量状态的概率要大
  • 假设r_{min}表示能量最低的状态点,对应的能量是E_{r_{min}},则P\{\overline{E} = E_{r_{min}} \}T的递减函数:
    \begin{aligned} \frac{\partial P\{\overline{E} = E_{r_{min} } \}}{\partial T} <0 \end{aligned}
    并且当T\to 0时,
    \begin{aligned} P\{\overline{E} = E_{r_{min}}\} \to\frac{1}{|D_0|} \end{aligned}
    这里D_0表示具有最低能量的状态集合,一般就是1,也就是说当温度趋近于零时,分子处于最低能量状态的概率趋近于1
  • 在非能量最低状态时,温度较高时分子处于这些状态的概率在\frac{1}{ D}附近;当温度趋近于零时,分子处于这些状态的概率也趋近于零

上面这些特性归结起来就是温度越低,能量越低的状态的概率越高,极限情况下,只有能量最低的状态点概率不为零。举个例子,假设分子的能量概率分布函数如下:
\begin{aligned} p(x) = \frac{1}{q(t)} exp(-\frac{x}{t}) \end{aligned}
其中能量点为x=1,2,3,4q(t)=\sum_{i=1}^{4 }exp(-\frac{x}{t}),则可能的概率变化情况如下表所示

x=1 x=2 x=3 x=4
t=20 0.269 0.256 0.243 0.232
t=5 0.329 0.269 0.221 0.181
t=0.5 0.865 0.117 0.016 0.002

我们了解了金属分子能量的退火过程,会发现在温度降低的过程中,分子能量一直在试图往最低的状态发展,这个过程就类似于我们求解最优问题解的过程。具体来对比的话,可以有以下细节:

优化问题 金属退火
状态
最优解 能量最低状态
成本函数 能量

因此,模拟退火算法就参照实际的物理过程,并采用实际物理过程的一些术语来实现。基本的算法流程如下:

  1. 初始化可行解和温度
  2. 根据Boltzmann概率退火
  3. 重复2,直到稳定状态
  4. 降温
  5. 重复2至4直到满足终止条件或达到指定步数
  6. 输出当前最优解

模拟退火算法的主流程并不复杂,但是具体到每一步还有很多细节需要注意,我们针对主要的几个点进行叙述。

首先是解的定义和形式,不同的问题解的定义也不同,但都要尽量让解的形式简洁并利于操作,邻域中每个邻居解都是可行解,并且解空间中任何两个状态可达。邻域可以理解为当前解附近的解空间。对于TSP问题,一种可行的解定义是:用解S表示为访问城市的一个排序,而解的领域可用不同的操作算子来定义,例如最简单的互换操作,下图是采用互换操作从当前解S_i转到下一个解S_i的示意图

TSP_S.png

优化问题的目标函数就对应于分子退火过程中的状态能量,具体的目标函数表达形式和自身问题相关,在TSP里,目标函数就是当前解下的路径总长度

关于算法第2步的退火操作,就是在当前温度下,由一个状态(解)变到另一个状态(解),而这个转变的过程服从一个概率分布,通常采用Metropolis接受准则,即:
\begin{aligned} A_{ij} = \begin{cases} 1, & if \quad f(S_i) \geq f(S_j) \\ exp(-\frac{\Delta f_{ij}}{t}), & if \quad f(S_i) < f(S_j)\\ \end{cases} \end{aligned}
这里A_{ij}表示如果当前状态是S_i,则下一个状态是S_j的概率;f(S_i)表示当前状态的目标值;\Delta f_{ij}=f(S_j)-f(S_i)。按照这个接受准则,如果下一个状态对应的目标值比当前状态的更小,则一定会跳到这个状态;如果更大,并不是肯定不接受,而是按一定的概率去转换。利用概率特征,算法在陷入局部最优时将有机会跳出。

实际的退火过程中降温是不能太快的,否则容易导致形成不是最稳定的状态;同样在退火算法中,降温过程需要保持合适的速度以保证解的质量。一般有两种降温方式,一种是
\begin{aligned} t_{k+1} =\alpha t_k \end{aligned}
这里的\alpha一般会取接近1的小数。另一种是
\begin{aligned} t_{k} = \frac{M-k}{M} t_0 \end{aligned}
这里的M表示设定的降温总次数。

而退火算法的停止准则可以是温度下降到指定值时停止,也可以是总降温次数到达指定的次数时停止,或者是设定一个目标值,当前解接近这个值时停止。

以模拟退火算法为代表的元启发算法看起来有点“玄妙”的感觉,我们其实在用算法在模拟某些物理过程,通常情况下元启发算法的效果相当令人满意,而且相比启发式算法,元启发式算法更加通用,不同的问题只要合理的定义变量都可以用元启发算法来解算。不过元启发算法也有些缺点,比如某些算法参数无法定量,只能靠经验设定,例如在模拟退火算法中,温度的初始值很重要,如果设的太大会导致计算时间过程;如果太小则会影响解的质量。而且元启发算法找寻相对最优解的时间会比启发式算法长,这在某些场景下并不适用。

2.3 启发式+元启发

事实上现在一般的组合优化引擎(包括OR-Tools)会把启发式算法和元启发算法进行结合,例如把初始解的生成过程用启发式算法计算,后续的元启发过程在这个解上进行,因此在OR-Tools工具的参数中会有些启发式参数可配置。

3 OR-Tools Demo

我们新建一个.Net Core控制台应用,利用OR-Tools解决一个标准的TSP问题。首先定义一个简单的数据集

        class DataModel
        {
            public long[,] DistanceMatrix = {
              {0, 2451, 713, 1018, 1631, 1374, 2408, 213, 2571, 875, 1420, 2145, 1972},
              {2451, 0, 1745, 1524, 831, 1240, 959, 2596, 403, 1589, 1374, 357, 579},
              {713, 1745, 0, 355, 920, 803, 1737, 851, 1858, 262, 940, 1453, 1260},
              {1018, 1524, 355, 0, 700, 862, 1395, 1123, 1584, 466, 1056, 1280, 987},
              {1631, 831, 920, 700, 0, 663, 1021, 1769, 949, 796, 879, 586, 371},
              {1374, 1240, 803, 862, 663, 0, 1681, 1551, 1765, 547, 225, 887, 999},
              {2408, 959, 1737, 1395, 1021, 1681, 0, 2493, 678, 1724, 1891, 1114, 701},
              {213, 2596, 851, 1123, 1769, 1551, 2493, 0, 2699, 1038, 1605, 2300, 2099},
              {2571, 403, 1858, 1584, 949, 1765, 678, 2699, 0, 1744, 1645, 653, 600},
              {875, 1589, 262, 466, 796, 547, 1724, 1038, 1744, 0, 679, 1272, 1162},
              {1420, 1374, 940, 1056, 879, 225, 1891, 1605, 1645, 679, 0, 1017, 1200},
              {2145, 357, 1453, 1280, 586, 887, 1114, 2300, 653, 1272, 1017, 0, 504},
              {1972, 579, 1260, 987, 371, 999, 701, 2099, 600, 1162, 1200, 504, 0},
            };
            public int VehicleNumber = 1;
            public int Depot = 0;
        };

DistanceMatrix存储了城市的距离信息,一维索引 i 代表了城市的编号,共有13个城市,DistanceMatrix[i][j]表示第i个城市和第j个城市的距离,比如DistanceMatrix[0][1]=2451,就表示城市0和城市1的距离是2451.。VehicleNumber表示访问城市的个体数,为1就是标准的TSP问题,大于1则扩展为VRP问题。Depot表示了起始城市索引,这里就从第0号城市开始。
在Routing接口中,OR-Tools除了把模型单独封装为一个类,还多出一个地点索引管理类,由它负责算法内部对城市地点索引的管理和计算。新建一个RoutingIndexManager对象,指定当前TSP问题的基础参数

             // Instantiate the data problem.
            DataModel data = new DataModel();

            // Create Routing Index Manager
            RoutingIndexManager manager = new RoutingIndexManager(
                data.DistanceMatrix.GetLength(0),
                data.VehicleNumber,
                data.Depot);

然后用RoutingIndexManager对象初始化一个RoutingModel对象

            // Create Routing Model.
            RoutingModel routing = new RoutingModel(manager);

接着是一个比较繁琐和让人困惑的步骤,我们需要为RoutingModel对象指定获取距离值的回调方法:

            int transitCallbackIndex = routing.RegisterTransitCallback(
              (long fromIndex, long toIndex) => {
                  // Convert from routing variable Index to distance matrix NodeIndex.
                  var fromNode = manager.IndexToNode(fromIndex);
                  var toNode = manager.IndexToNode(toIndex);
                  return data.DistanceMatrix[fromNode, toNode];
              }
            );

当求解器内部计算取两个城市的索引时,会调用我们指定的回调函数获取它们之间的距离。至于为什么要在回调函数内用IndexToNode()方法做一个看似多次一举的操作,是因为算法内部用的索引和原始数据的索引不一样,例如算法内部可能计算到fromIndex为0,toIndex为13的情况(第13个城市其实就是回到起始点),而在外部原始数据里是没有DistanceMatrix[0][13]这个值的。
然后我们还需要把RegisterTransitCallback返回的回调函数Id值作为SetArcCostEvaluatorOfAllVehicles方法的参数来通知模型,结点间边的权值就用城市距离表示。

            // Define cost of each arc.
            routing.SetArcCostEvaluatorOfAllVehicles(transitCallbackIndex);

我们再写一个打印解的方法

        /// <summary>
        ///   Print the solution.
        /// </summary>
        static void PrintSolution(
            in RoutingModel routing,
            in RoutingIndexManager manager,
            in Assignment solution)
        {
            Console.WriteLine("Objective: {0} miles", solution.ObjectiveValue());
            // Inspect solution.
            Console.WriteLine("Route:");
            long routeDistance = 0;
            var index = routing.Start(0);
            while (routing.IsEnd(index) == false)
            {
                Console.Write("{0} -> ", manager.IndexToNode((int)index));
                var previousIndex = index;
                index = solution.Value(routing.NextVar(index));
                routeDistance += routing.GetArcCostForVehicle(previousIndex, index, 0);
            }
            Console.WriteLine("{0}", manager.IndexToNode((int)index));
            Console.WriteLine("Route distance: {0}miles", routeDistance);
        }

在最终计算之前,我们还可以设定一下算法搜索参数。我们先只指定FirstSolutionStrategy的值,也就是用哪种启发式算法来初始化解,这里指定为PathCheapestArc,即上面提到的最短边算法;此外把LogSearch设为true,从而可以看到算法的过程信息。

            // Setting first solution heuristic.
            RoutingSearchParameters searchParameters =
              operations_research_constraint_solver.DefaultRoutingSearchParameters();
            searchParameters.FirstSolutionStrategy =
              FirstSolutionStrategy.Types.Value.PathCheapestArc;
            searchParameters.LogSearch = true;

最后调用计算方法来得到结果

            // Solve the problem.
            Assignment solution = routing.SolveWithParameters(searchParameters);
1.PNG

完整的程序

using System;
using System.Collections.Generic;
using Google.OrTools.ConstraintSolver;


namespace Demo5
{
    class Program
    {
        class DataModel
        {
            public long[,] DistanceMatrix = {
              {0, 2451, 713, 1018, 1631, 1374, 2408, 213, 2571, 875, 1420, 2145, 1972},
              {2451, 0, 1745, 1524, 831, 1240, 959, 2596, 403, 1589, 1374, 357, 579},
              {713, 1745, 0, 355, 920, 803, 1737, 851, 1858, 262, 940, 1453, 1260},
              {1018, 1524, 355, 0, 700, 862, 1395, 1123, 1584, 466, 1056, 1280, 987},
              {1631, 831, 920, 700, 0, 663, 1021, 1769, 949, 796, 879, 586, 371},
              {1374, 1240, 803, 862, 663, 0, 1681, 1551, 1765, 547, 225, 887, 999},
              {2408, 959, 1737, 1395, 1021, 1681, 0, 2493, 678, 1724, 1891, 1114, 701},
              {213, 2596, 851, 1123, 1769, 1551, 2493, 0, 2699, 1038, 1605, 2300, 2099},
              {2571, 403, 1858, 1584, 949, 1765, 678, 2699, 0, 1744, 1645, 653, 600},
              {875, 1589, 262, 466, 796, 547, 1724, 1038, 1744, 0, 679, 1272, 1162},
              {1420, 1374, 940, 1056, 879, 225, 1891, 1605, 1645, 679, 0, 1017, 1200},
              {2145, 357, 1453, 1280, 586, 887, 1114, 2300, 653, 1272, 1017, 0, 504},
              {1972, 579, 1260, 987, 371, 999, 701, 2099, 600, 1162, 1200, 504, 0},
            };
            public int VehicleNumber = 1;
            public int Depot = 0;
        };

        /// <summary>
        ///   Print the solution.
        /// </summary>
        static void PrintSolution(
            in RoutingModel routing,
            in RoutingIndexManager manager,
            in Assignment solution)
        {
            Console.WriteLine("Objective: {0} miles", solution.ObjectiveValue());
            // Inspect solution.
            Console.WriteLine("Route:");
            long routeDistance = 0;
            var index = routing.Start(0);
            while (routing.IsEnd(index) == false)
            {
                Console.Write("{0} -> ", manager.IndexToNode((int)index));
                var previousIndex = index;
                index = solution.Value(routing.NextVar(index));
                routeDistance += routing.GetArcCostForVehicle(previousIndex, index, 0);
            }
            Console.WriteLine("{0}", manager.IndexToNode((int)index));
            Console.WriteLine("Route distance: {0}miles", routeDistance);
        }

        static void Main(string[] args)
        {
            // Instantiate the data problem.
            DataModel data = new DataModel();

            // Create Routing Index Manager
            RoutingIndexManager manager = new RoutingIndexManager(
                data.DistanceMatrix.GetLength(0),
                data.VehicleNumber,
                data.Depot);

            // Create Routing Model.
            RoutingModel routing = new RoutingModel(manager);

            int transitCallbackIndex = routing.RegisterTransitCallback(
              (long fromIndex, long toIndex) => {
                  // Convert from routing variable Index to distance matrix NodeIndex.
                  var fromNode = manager.IndexToNode(fromIndex);
                  var toNode = manager.IndexToNode(toIndex);
                  return data.DistanceMatrix[fromNode, toNode];
              }
            );

            // Define cost of each arc.
            routing.SetArcCostEvaluatorOfAllVehicles(transitCallbackIndex);

            // Setting first solution heuristic.
            RoutingSearchParameters searchParameters =
              operations_research_constraint_solver.DefaultRoutingSearchParameters();
            searchParameters.FirstSolutionStrategy =
              FirstSolutionStrategy.Types.Value.PathCheapestArc;
            searchParameters.LogSearch = true;

            // Solve the problem.
            Assignment solution = routing.SolveWithParameters(searchParameters);

            // Print solution on console.
            PrintSolution(routing, manager, solution);

        }
    }
}

我们再来试试修改下searchParameters的配置。我们只是指定了FirstSolutionStrategy的值,用哪种元启发算法我们并没有指定,这个值由LocalSearchMetaheuristic指定,其默认值是AUTOMATIC,表示由求解器自己选择。这里例子里求解器应该选择的是Greedy Descent算法,我们手动改成模拟退火:

            searchParameters.LocalSearchMetaheuristic = LocalSearchMetaheuristic.Types.Value.SimulatedAnnealing;

然后定义好停止时间,否则算法不会停下的

            searchParameters.TimeLimit = new Google.Protobuf.WellKnownTypes.Duration { Seconds = 20 };

来看看模拟退火算法的结果,最终解和之前一样,不过中间过程就不一样了


2.PNG
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342