ICTCLAS的命名好像没有正统的学过数据结构一样,对于数据结构的命名非常富有想象力,完全没有按照数据结构上大家公认的术语命名,所以给代码的读者带来很大的迷惑性。所以我们在看名字的时候一定要抛开名字看实现,看本质,看他们到底是个啥。呵呵。 
 
首先就是CQueue的问题,CQueue虽然叫Queue,但是它不是FIFO的Queue。那它是什么呢?CQueue是优先级队列Priority Queue和Stack的杂交。但是没有半点FIFO的Queue的概念在里面。 
 
CQueue元素有一个权重eWeight,这个权重如果不为0(或者说互相之间不等),那么CQueue此时的含义是按照权重由小到大排序的优先级队列。 
 
如果CQueue的所有元素的eWeight都相等,(在ICTCLAS代码里就是都为0),此时CQueue就演变为FILO的Stack,栈。 
 
因此这个CQueue才会有Push和Pop两种插入和删除元素的命名。呵呵,挂着羊头卖的是狗肉,还是两只狗。对于C#、C++、Java来说,类库里面都有现成的优先级队列和栈的实现,而且可以用 
- List<T>
- 重载小于号(C++)、重载CompareTo()(C#,Java)
- List.Sort() 
 来代替优先级队列实现和并且具有和作者一样的Iterator的功能。那个CQueue完全可以省略掉。 
 
然后是DynamicArray。动态数组?非也。这个是用来表示稀疏图的邻接表,每一个元素表示的是图上的一条边。对于非稀疏的图往往喜欢用NxN的数组来表示N个节点的连接关系。而对于稀疏图来说,无疑会浪费大量的空间,于是往往采用记录邻接两点的边的方式来记录图。 
 
作者为了能够让以后调用的时候方便,对于起点和终点进行排序(或者说维护了顺序)。对起点排序,就是代码中所谓的RowFirst,对于终点进行排序就是ColumnFirst。 
 
那为何作者叫DynamicArray呢?其实也不难想象,首先是因为邻接表实际上就是边的一个列表,也可以看为数组。但是边的数量是在变化的,而不是最开始就可以知道的。因此这个数组是动态的。于是就叫动态数组了。。。。汗。 
 
对于DynamicArray,我们也完全可以用List<>.Sort()的方式来实现,对于C++来说,我们需要定义2个functor,分别是起点优先比较和终点优先比较。对于Java和C#也有类似的定义比较函数的办法。因此这个DynamicArray(),可以扔掉了。没必要用这么一个奇怪的东西。 
 
接下来我把NShortPath中的最主要的三个函数 
 
 int Output(int **nResult,bool bBest,int *npCount);
int Output(int **nResult,bool bBest,int *npCount); int ShortPath();
int ShortPath(); void GetPaths(unsigned int nNode,unsigned int nIndex,int
void GetPaths(unsigned int nNode,unsigned int nIndex,int **nResult=0,bool bBest=false);
**nResult=0,bool bBest=false); 的代码和分析帖在下面,分析都写在注释里了。
在具体开始之前,我先明确一个东西,在中科院的论文里称求解多个最优路径问题为N最短路径问题(N-Shortest Paths),如果你google你会发现没有多少有用的结果,其实不然。不知道是不是作者不了解国际上对该问题的讨论,这个问题应该称为k shortest path(即K最短路径问题)。这个问题也已经有了不错的解法,David Eppstein分别在1994年和1997年已经给出了大约复杂度为O(m + n log n + kn)的解法。而中科院论文里面的解法的复杂度还是比较高的:O(n*N*k)。(两个复杂度的字母含义不同,定义请看原论文)。所以,如果可能,再次实现ICTCLAS的算法的朋友可以考虑抛开中科院的求k shortest path的解法,而使用国际上比较流行的解法。
 
BTW: 问一下,吕震宇,你有什么比较可爱点的称呼么?呵呵,我这么直呼大名在中文的习惯里似乎不太礼貌。:) 
 
 int CNShortPath::ShortPath()
int CNShortPath::ShortPath() {
{ unsigned int nCurNode=1,nPreNode,i,nIndex;
    unsigned int nCurNode=1,nPreNode,i,nIndex; ELEMENT_TYPE eWeight;
    ELEMENT_TYPE eWeight; PARRAY_CHAIN pEdgeList;
    PARRAY_CHAIN pEdgeList;
 //    循环从1开始,即从第二个节点开始。遍历所有节点。
    //    循环从1开始,即从第二个节点开始。遍历所有节点。 for(;nCurNode<m_nVertex;nCurNode++)
    for(;nCurNode<m_nVertex;nCurNode++) {
    { CQueue queWork;
       CQueue queWork; //    有CNShortPath调用的上下文可知,入口的m_apCost为列优先排序的CDynamicArray。
       //    有CNShortPath调用的上下文可知,入口的m_apCost为列优先排序的CDynamicArray。 //    换句话说就是:
       //    换句话说就是: //    list<Edge> m_apCost;
       //    list<Edge> m_apCost; //    list.sort(m_apCost, less_column_first());
       //    list.sort(m_apCost, less_column_first()); //    下面这行代码是将该列的边的起始链表元素赋予pEdgeList,以方便遍历。
       //    下面这行代码是将该列的边的起始链表元素赋予pEdgeList,以方便遍历。 //    算法上的含义是取得图上终点为nCurNode的所有边,并将第一条边放入pEdgeList进行对所有边的遍历。
       //    算法上的含义是取得图上终点为nCurNode的所有边,并将第一条边放入pEdgeList进行对所有边的遍历。 eWeight=m_apCost->GetElement(-1,nCurNode,0,&pEdgeList);//Get all the
                eWeight=m_apCost->GetElement(-1,nCurNode,0,&pEdgeList);//Get all the edges
edges while(pEdgeList!=0 && pEdgeList->col==nCurNode)
                while(pEdgeList!=0 && pEdgeList->col==nCurNode) {
                { //    nPreNode是当前边的起点
            //    nPreNode是当前边的起点 nPreNode=pEdgeList->row;
           nPreNode=pEdgeList->row; //    eWeight是当前边的长度
           //    eWeight是当前边的长度 eWeight=pEdgeList->value;//Get the value of edges
           eWeight=pEdgeList->value;//Get the value of edges //   对于dijkstra算法来说,我们需要知道当前节点(终点)的通过不同的前方的点到原点的距离
                   //   对于dijkstra算法来说,我们需要知道当前节点(终点)的通过不同的前方的点到原点的距离 //   并且从中知道最短的路径,然后我们会更新当前节点的父节点和当前节点到原点的距离。
                   //   并且从中知道最短的路径,然后我们会更新当前节点的父节点和当前节点到原点的距离。 //   在这个修改后的多最短路径算法中,我们将(当前节点的父节点,当前节点通过该父节点到原点的距离)视为一个配对
                   //   在这个修改后的多最短路径算法中,我们将(当前节点的父节点,当前节点通过该父节点到原点的距离)视为一个配对 //   我们保留一个为m_nValueKind大小的数组,记录这些可能的配对,而不仅仅是保留最小的。
                   //   我们保留一个为m_nValueKind大小的数组,记录这些可能的配对,而不仅仅是保留最小的。 //   下面这个循环就是将所有可能的组合放到优先级队列中,然后将来可以从优先级队列中选取前m_nValueKind。
                   //   下面这个循环就是将所有可能的组合放到优先级队列中,然后将来可以从优先级队列中选取前m_nValueKind。 //   这里循环范围限定到m_nValueKind主要是考虑以后所需要的不会超过这么多个值。
                   //   这里循环范围限定到m_nValueKind主要是考虑以后所需要的不会超过这么多个值。 //   这里放入优先级队列的是前向节点和长度,相当于是路径,而不是长度的值的列表,与后面表达的意思不同。
                   //   这里放入优先级队列的是前向节点和长度,相当于是路径,而不是长度的值的列表,与后面表达的意思不同。 for(i=0;i<m_nValueKind;i++)
           for(i=0;i<m_nValueKind;i++) {
           { //    如果起点>0,即判断起点是不是第一个节点。
                //    如果起点>0,即判断起点是不是第一个节点。 if(nPreNode>0)//Push the weight and the pre node
               if(nPreNode>0)//Push the weight and the pre node infomation
infomation {
               { //    起点不是第一个节点。
                    //    起点不是第一个节点。 //    判断起点到原点的总长度在索引值为i的时候是不是无穷大。
                    //    判断起点到原点的总长度在索引值为i的时候是不是无穷大。 //      如果无穷大了,就说明前一个点已经无法到达了,说明没有更多到前面节点的路径了
                                        //      如果无穷大了,就说明前一个点已经无法到达了,说明没有更多到前面节点的路径了 //    也不必继续向优先级队列中放入点了。
                    //    也不必继续向优先级队列中放入点了。 if(m_pWeight[nPreNode-1][i]==INFINITE_VALUE)
                   if(m_pWeight[nPreNode-1][i]==INFINITE_VALUE) break;
                       break; //    将起点,索引值i,和终点到原点的总长度压入优先级队列。
                    //    将起点,索引值i,和终点到原点的总长度压入优先级队列。 queWork.Push(nPreNode,i,eWeight
                   queWork.Push(nPreNode,i,eWeight +m_pWeight[nPreNode-1][i]);
+m_pWeight[nPreNode-1][i]); }
               } else
               else {
               { //    起点为第一个节点。
                    //    起点为第一个节点。 //    将起点,索引值i,和当前边的长度压入优先级队列
                    //    将起点,索引值i,和当前边的长度压入优先级队列 queWork.Push(nPreNode,i,eWeight);
                   queWork.Push(nPreNode,i,eWeight); break;
                   break; }
               } }//end for
           }//end for
 //    换到下一条边。
           //    换到下一条边。 pEdgeList=pEdgeList->next;
           pEdgeList=pEdgeList->next;
 }//end while
                }//end while
 //Now get the result queue which sort as weight.
       //Now get the result queue which sort as weight. //Set the current node information
       //Set the current node information //    将起点到原点的长度,对于每个索引值都初始化为无穷。
        //    将起点到原点的长度,对于每个索引值都初始化为无穷。 for(i=0;i<m_nValueKind;i++)
       for(i=0;i<m_nValueKind;i++) {
       { m_pWeight[nCurNode-1][i]=INFINITE_VALUE;
            m_pWeight[nCurNode-1][i]=INFINITE_VALUE; }
       } //memset((void *),(int),sizeof(ELEMENT_TYPE)*);
       //memset((void *),(int),sizeof(ELEMENT_TYPE)*); //init the weight
       //init the weight i=0;
       i=0; //       进行循环,索引值小于想要的索引值时,并且优先级队列不为空。
       //       进行循环,索引值小于想要的索引值时,并且优先级队列不为空。 //   在这里面的i表达的是长度的值的索引,并不代表不同的路径,同一个i可能对应多个路径。
           //   在这里面的i表达的是长度的值的索引,并不代表不同的路径,同一个i可能对应多个路径。 //   这个循环过后,m_pWeight[nCurNode-1][] 为可能存在的前m_nValueKind个长度值。
           //   这个循环过后,m_pWeight[nCurNode-1][] 为可能存在的前m_nValueKind个长度值。 //   并且把前m_nValueKind个路径压入m_nParent对应的队列中。
           //   并且把前m_nValueKind个路径压入m_nParent对应的队列中。 //
           // while(i<m_nValueKind&&queWork.Pop(&nPreNode,&nIndex,&eWeight)!
       while(i<m_nValueKind&&queWork.Pop(&nPreNode,&nIndex,&eWeight)! =-1)
=-1) {//Set the current node weight and parent
       {//Set the current node weight and parent //      从以长度为优先级的队列中,提取第一个(最短的)记录。
                        //      从以长度为优先级的队列中,提取第一个(最短的)记录。 //      将该记录的起点给nPreNode,索引给nIndex,长度给eWeight
                        //      将该记录的起点给nPreNode,索引给nIndex,长度给eWeight
 //    如果起点到原点的长度为无穷。(这在第一次循环的时候显然是无穷)
            //    如果起点到原点的长度为无穷。(这在第一次循环的时候显然是无穷) //    就将这个长度设为最短边的长度。
            //    就将这个长度设为最短边的长度。 if(m_pWeight[nCurNode-1][i]==INFINITE_VALUE)
           if(m_pWeight[nCurNode-1][i]==INFINITE_VALUE) m_pWeight[nCurNode-1][i]=eWeight;
               m_pWeight[nCurNode-1][i]=eWeight; else if(m_pWeight[nCurNode-1][i]<eWeight)//Next queue
           else if(m_pWeight[nCurNode-1][i]<eWeight)//Next queue {
           { //    否则,如果起点到原点的长度小于当前边的长度
                //    否则,如果起点到原点的长度小于当前边的长度 //    递增索引值,换到下一套选择值去。如果到达了最大索引值就退出循环。
                //    递增索引值,换到下一套选择值去。如果到达了最大索引值就退出循环。 i++;//Go next queue and record next weight
               i++;//Go next queue and record next weight //       既然这里有是否会大于最大索引值的判断,何必在while条件里面加那个条件呢?
               //       既然这里有是否会大于最大索引值的判断,何必在while条件里面加那个条件呢? if(i==m_nValueKind)//Get the last position
               if(i==m_nValueKind)//Get the last position break;
                   break; //    将起点到原点的长度,下一个索引值(i+1),设为队列中元素的长度。
                //    将起点到原点的长度,下一个索引值(i+1),设为队列中元素的长度。 m_pWeight[nCurNode-1][i]=eWeight;
               m_pWeight[nCurNode-1][i]=eWeight; }else{
           }else{ //   如果起点到原点的长度 == 队列中的长度, 那么只向当前节点,当前索引的父节点中插入一个配对。
                           //   如果起点到原点的长度 == 队列中的长度, 那么只向当前节点,当前索引的父节点中插入一个配对。 //
                           //
 //   如果起点到原点的长度 > 队列中的长度?
                           //   如果起点到原点的长度 > 队列中的长度? //   这是不可能出现的,因为这个数值在队列中是有序的。从小到大。
                           //   这是不可能出现的,因为这个数值在队列中是有序的。从小到大。 //   因此这个数值的变化规律是初始位无穷大,第一次赋值为最小值,然后逐渐增大。
                           //   因此这个数值的变化规律是初始位无穷大,第一次赋值为最小值,然后逐渐增大。 }
                   } //    将(起点,索引值)压入起点的父节点的队列中去
           //    将(起点,索引值)压入起点的父节点的队列中去 m_pParent[nCurNode-1][i].Push(nPreNode,nIndex);
           m_pParent[nCurNode-1][i].Push(nPreNode,nIndex); }
       } }//end for
    }//end for
 return 1;
    return 1; }
}
 //bBest=true: only get one best result and ignore others
//bBest=true: only get one best result and ignore others //Added in 2002-1-24
//Added in 2002-1-24 void CNShortPath::GetPaths(unsigned int nNode,unsigned int nIndex,int
void CNShortPath::GetPaths(unsigned int nNode,unsigned int nIndex,int **nResult,bool bBest)
**nResult,bool bBest) {
{ CQueue queResult;
    CQueue queResult; //      当前节点为最后一个节点
        //      当前节点为最后一个节点 unsigned int nCurNode = nNode, nCurIndex = nIndex;
        unsigned int nCurNode = nNode, nCurIndex = nIndex; unsigned int nParentNode,nParentIndex;
        unsigned int nParentNode,nParentIndex; unsigned int nResultIndex = 0;
        unsigned int nResultIndex = 0;
 if(m_nResultCount >= MAX_SEGMENT_NUM)        //      Only need 10 result
        if(m_nResultCount >= MAX_SEGMENT_NUM)        //      Only need 10 result {
        { return;
                return; }
        } //      将路径第一点设为-1。
        //      将路径第一点设为-1。 nResult[m_nResultCount][nResultIndex] = -1;     //      Init the result
        nResult[m_nResultCount][nResultIndex] = -1;     //      Init the result //      此时没有设置weight,此时的CQueue的成为了一个Stack。
        //      此时没有设置weight,此时的CQueue的成为了一个Stack。 queResult.Push(nCurNode, nCurIndex);
        queResult.Push(nCurNode, nCurIndex); bool bFirstGet;
    bool bFirstGet; while(!queResult.IsEmpty())
    while(!queResult.IsEmpty()) {
        { //      从最后的节点循环到第一个节点
                //      从最后的节点循环到第一个节点 //      这个循环在第一次循环的时候,会把最优解压入结果栈
                //      这个循环在第一次循环的时候,会把最优解压入结果栈 //      第二次循环会把分支解压入结果栈。
                //      第二次循环会把分支解压入结果栈。 while(nCurNode>0)    //
                while(nCurNode>0)    // {
                { //Get its parent and store them in nParentNode,nParentIndex
                        //Get its parent and store them in nParentNode,nParentIndex //      取(当前节点,当前索引)的父节点列表的第一个父节点信息。
                        //      取(当前节点,当前索引)的父节点列表的第一个父节点信息。 if(m_pParent[nCurNode-1][nCurIndex].Pop(&nParentNode,&nParentIndex,
                        if(m_pParent[nCurNode-1][nCurIndex].Pop(&nParentNode,&nParentIndex, 0,false,true)!=-1)
0,false,true)!=-1) {
                        { //      将当前节点变为父节点
                                //      将当前节点变为父节点 nCurNode=nParentNode;
                           nCurNode=nParentNode; nCurIndex=nParentIndex;
                           nCurIndex=nParentIndex; }
                        } //      如果当前节点不是第一个节点的话,就将当前节点入栈。
                        //      如果当前节点不是第一个节点的话,就将当前节点入栈。 if(nCurNode>0)
                        if(nCurNode>0) queResult.Push(nCurNode,nCurIndex);
                queResult.Push(nCurNode,nCurIndex); }
                } //      如果nCurNode == 0说明取得了合法的结果,而不是异常退出上一个循环。
                //      如果nCurNode == 0说明取得了合法的结果,而不是异常退出上一个循环。 if(nCurNode==0)
                if(nCurNode==0) {
                { //Get a path and output
                        //Get a path and output //      将路径第一点设为起点
                        //      将路径第一点设为起点 nResult[m_nResultCount][nResultIndex++]=nCurNode;//Get the first
                        nResult[m_nResultCount][nResultIndex++]=nCurNode;//Get the first node
node //      第一次从queResult取数据的时候,得将这个标志位设为true。
                        //      第一次从queResult取数据的时候,得将这个标志位设为true。 //      这样才可以在bModify = false的时候,取得堆栈的头。
                        //      这样才可以在bModify = false的时候,取得堆栈的头。 //      其目的就是要从头遍历堆栈,但是不修改堆栈内部数据,以方便以后遍历用。(循环不就行了?)
                        //      其目的就是要从头遍历堆栈,但是不修改堆栈内部数据,以方便以后遍历用。(循环不就行了?) bFirstGet=true;
                        bFirstGet=true; nParentNode=nCurNode;
                        nParentNode=nCurNode; //      将堆栈遍历,保存结果路径。
                        //      将堆栈遍历,保存结果路径。 while(queResult.Pop(&nCurNode,&nCurIndex,0,false,bFirstGet)!=-1)
                        while(queResult.Pop(&nCurNode,&nCurIndex,0,false,bFirstGet)!=-1) {
                        { nResult[m_nResultCount][nResultIndex++]=nCurNode;
                                nResult[m_nResultCount][nResultIndex++]=nCurNode; bFirstGet=false;
                                bFirstGet=false; nParentNode=nCurNode;
                                nParentNode=nCurNode; }
                        } //      设置结果位为-1
                        //      设置结果位为-1 nResult[m_nResultCount][nResultIndex]=-1;//Set the end
                        nResult[m_nResultCount][nResultIndex]=-1;//Set the end m_nResultCount+=1;//The number of result add by 1
                        m_nResultCount+=1;//The number of result add by 1 if(m_nResultCount>=MAX_SEGMENT_NUM)//Only need 10 result
                        if(m_nResultCount>=MAX_SEGMENT_NUM)//Only need 10 result return ;
                                return ; nResultIndex=0;
                        nResultIndex=0; nResult[m_nResultCount][nResultIndex]=-1;//Init the result
                        nResult[m_nResultCount][nResultIndex]=-1;//Init the result
 if(bBest)//Return the best result, ignore others
                        if(bBest)//Return the best result, ignore others return ;
                                return ; }
                }
 queResult.Pop(&nCurNode,&nCurIndex,0,false,true);//Read the top node
                queResult.Pop(&nCurNode,&nCurIndex,0,false,true);//Read the top node //      寻找存在多个父节点的节点。
                //      寻找存在多个父节点的节点。 while(queResult.IsEmpty()==false&&(m_pParent[nCurNode-1]
        while(queResult.IsEmpty()==false&&(m_pParent[nCurNode-1] [nCurIndex].IsSingle()||m_pParent[nCurNode-1]
[nCurIndex].IsSingle()||m_pParent[nCurNode-1] [nCurIndex].IsEmpty(true)))
[nCurIndex].IsEmpty(true))) {
                { queResult.Pop(&nCurNode,&nCurIndex,0);//Get rid of it
               queResult.Pop(&nCurNode,&nCurIndex,0);//Get rid of it queResult.Pop(&nCurNode,&nCurIndex,0,false,true);//Read the top
                   queResult.Pop(&nCurNode,&nCurIndex,0,false,true);//Read the top node
node }
                } if(queResult.IsEmpty()==false&&m_pParent[nCurNode-1]
        if(queResult.IsEmpty()==false&&m_pParent[nCurNode-1] [nCurIndex].IsEmpty(true)==false)
[nCurIndex].IsEmpty(true)==false) {
                { //      如果定位到了节点。将下一种选择入栈。
                        //      如果定位到了节点。将下一种选择入栈。 m_pParent[nCurNode-1][nCurIndex].Pop(&nParentNode,&nParentIndex,
                        m_pParent[nCurNode-1][nCurIndex].Pop(&nParentNode,&nParentIndex, 0,false,false);
0,false,false); nCurNode=nParentNode;
                        nCurNode=nParentNode; nCurIndex=nParentIndex;
                        nCurIndex=nParentIndex; if(nCurNode>0)
                        if(nCurNode>0) queResult.Push(nCurNode,nCurIndex);
                           queResult.Push(nCurNode,nCurIndex); }
                } }
        } }
}
 int CNShortPath::Output(int **nResult,bool bBest,int *npCount)
int CNShortPath::Output(int **nResult,bool bBest,int *npCount) {
{ //      sResult is a string array
        //      sResult is a string array unsigned int i;
        unsigned int i; m_nResultCount=0;//The
        m_nResultCount=0;//The //      如果节点数只有2个,就没必要那么复杂运算了。直接返回唯一的路径。
        //      如果节点数只有2个,就没必要那么复杂运算了。直接返回唯一的路径。 if(m_nVertex<2)
        if(m_nVertex<2) {
        { nResult[0][0]=0;
                nResult[0][0]=0; nResult[0][1]=1;
                nResult[0][1]=1; *npCount=1;
                *npCount=1; return 1;
                return 1; }
        } //              对最后一个节点,遍历每一个可能的长度值,将计算所得的路径放入nResult。
        //              对最后一个节点,遍历每一个可能的长度值,将计算所得的路径放入nResult。 for(i=0;i<m_nValueKind&&m_pWeight[m_nVertex-2][i]<INFINITE_VALUE;i++)
        for(i=0;i<m_nValueKind&&m_pWeight[m_nVertex-2][i]<INFINITE_VALUE;i++) {
        { GetPaths(m_nVertex-1,i,nResult,bBest);
          GetPaths(m_nVertex-1,i,nResult,bBest); *npCount=m_nResultCount;
          *npCount=m_nResultCount; //      有返回值,并且只想要一个结果
                //      有返回值,并且只想要一个结果 if(nResult[i][0]!=-1&&bBest)//Get the best answer
          if(nResult[i][0]!=-1&&bBest)//Get the best answer return 1;
                  return 1; //      不能超过内部的最大结果数。这个限制条件可以通过动态的vector<T>来消除掉。
                //      不能超过内部的最大结果数。这个限制条件可以通过动态的vector<T>来消除掉。 if(m_nResultCount>=MAX_SEGMENT_NUM)//Only need 10 result
      if(m_nResultCount>=MAX_SEGMENT_NUM)//Only need 10 result return 1;
                  return 1; }
        } return 1;
        return 1; }
}
参考:
ICTCLAS分词系统研究(五)--N最短路径
http://blog.csdn.net/sinboy/archive/2006/05/19/745498.aspx
SharpICTCLAS分词系统简介(4)NShortPath-1
http://www.cnblogs.com/zhenyulu/articles/669795.html
SharpICTCLAS分词系统简介(5)NShortPath-2
http://www.cnblogs.com/zhenyulu/articles/672442.html
ICTCLAS的Google网上论坛
http://groups.google.com/group/ictclas
 
                    
                     
                    
                 
                    
                 

 
     
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号