【问题标题】:C++ Graph Implementation - Add EdgeC++ 图实现 - 添加边
【发布时间】:2020-12-03 23:21:29
【问题描述】:

我被分配根据给定的 graph.h 文件编写一个 c++ 图形实现。我在我的 AddEdge 函数中遇到了“异常抛出:红色访问冲突” 的问题,我无法弄清楚。这是graph.h的描述

ifndef GRAPH_H

#define GRAPH_H

class GraphEdgeNotFound {  };                       // Exception class represents edge-not-found condition

#include <cstddef>
#include <new>
#include <iostream>
#include <iomanip>
#include <stack>                                    // For STL stack
#include <queue>                                    // For STL queue
#include <string>
using namespace std;


struct VertexNode;                                  // Forward declaration of VertexNode type

struct EdgeNode                                     // Structure representing an edge
{
  VertexNode*   destination;                        // Pointer to destination vertex
  int           weight;                             // Edge weight
  EdgeNode*     nextPtr;                            // Pointer to next edge
};

struct VertexNode                                             // Structure representing a vertex
{
  string        vname;                              // Name of vertex
  bool          mark;                               // Marked flag
  EdgeNode*     edgePtr;                            // Pointer to list of outgoing edges
  VertexNode*   nextVertex;                         // Pointer to next vertex in vertices list
};

class Graph                                         // Graph ADT using adjacency list representation
{
 private:       //***** Private class members below *****//
  VertexNode*   vertices;                       // Linked list of vertex nodes

 public:           //***** Public members below *****//
  // ... there are more here, I just focus on the AddEdge function

  void AddEdge(string s, string d, int w);      
  // AddEdge()
  // Adds edge from source S to destination D with specified weight W.

  VertexNode*  WhereIs(string v);       
  // WhereIs()
  // Returns pointer to the vertex node that stores vertex v in the vertices linked list; 
  // Throws GraphVertexNotFound if V is not present in the vertices list
}

这是我目前的工作(graph.cpp)

#include "graph.h"

void Graph::AddEdge(string s, string d, int w)
{
    // Adds edge from source S to destination D with specified weight W.
    // If there is not enough memory to add the edge, throw the GraphFull exception

    VertexNode* Vertex_S = WhereIs(s);
    VertexNode* Vertex_D = WhereIs(d);

    // Initialize newEdge
    EdgeNode* newEdge = new EdgeNode;
    newEdge->destination = Vertex_D;
    newEdge->weight = w;
    newEdge->nextPtr = NULL;

    if (Vertex_S->edgePtr == NULL)
    {
        Vertex_S->edgePtr = newEdge;
    }
    else
    {
        // Go the the end of edgePtr and add the newEdge to it 
        while (Vertex_S->edgePtr->nextPtr != NULL)
        {
            Vertex_S->edgePtr = Vertex_S->edgePtr->nextPtr;
        }
        Vertex_S->edgePtr->nextPtr = newEdge;
    }
}

VertexNode* Graph::WhereIs(string v)
{
    // Returns pointer to the vertex node that stores vertex v in the vertices linked list; 
    // Throws GraphVertexNotFound if V is not present in the vertices list

    VertexNode* tempVertex = vertices;

    // If found
    while (tempVertex != NULL)
    {
        if (tempVertex->vname == v)
            return tempVertex;  // Found
        tempVertex = tempVertex->nextVertex;
    }
    
    // If not found
    throw GraphVertexNotFound();
}

我遇到的问题是,当我调试它时,我在while (Vertex_S-&gt;edgePtr-&gt;nextPtr != NULL) 行出现错误,说,

Exception thrown: read access violation.
Vertex_S->**edgePtr** was 0xCDCDCDCD

我做错了什么?

感谢您的帮助!

【问题讨论】:

  • 仅仅因为这是程序崩溃或报告错误的地方并不意味着这就是问题所在。 C++ 不能以这种方式工作。问题可能出现在代码中的任何地方,但在出现错误后,程序会继续运行一段时间,然后最终崩溃。这就是为什么 stackoverflow.com 的 help center 要求您显示一个 minimal reproducible example,任何人都可以完全如图所示剪切/粘贴,然后编译、运行和重现您的问题.有关更多信息,请参阅How to Ask。在您这样做之前,任何人都不太可能找出您的问题。
  • // 假设 WhereIs() 函数正常工作 -- 我们怎么知道你说的是真话?我看到很多问题都是从这样的假设开始的,当最终显示代码时,应该“正常工作”的代码被破坏了。
  • @PaulMcKenzie 嗨,保罗,我刚刚意识到,在这种情况下,我试图不发布太多代码并不是一个好主意。是的,我在原帖中添加了WhereIs() 函数。
  • 希望头文件能改,太白痴了。老师怎么能把它作为任何好东西的“范例”发布呢?所有这些不必要的包括,using namespace std,通过值而不是通过 const 引用传递字符串,使用手动内存管理,愚蠢的 cmets(如果明显有 private 关键字,你为什么要评论“这里的私人内容”) - cmets 不是用来解释的,它们是用来解释不明显的东西!)...这是又一个什么都不知道的老师的例子:(

标签: c++ graph


【解决方案1】:

您的代码似乎是正确的,因为错误肯定在其他地方。但是你永远不会释放析构函数中的链表节点,所以这是一个等待发生的错误。销毁一个对象应该释放它持有的所有资源,所以我们需要实现析构函数。

最小的改进

但让我们也让它更理智一点 - 这是我清理代码并使其更健壮的最低要求(这样它读起来更像 C++ 而不是 C)。我已经评论了这些变化——有很多变化。它们本身是次要的,但它们一起使 C++ 的使用更加惯用,也让你的生活更轻松。

常见问题:using namespace std; 是一个非常糟糕的主意,下面的小函数说明了原因。你希望这样的代码不会编译。但确实如此。

#include <ios>
using namespace std;
void this_is_a_bug_and_it_happily_compiles()
{
    if (left != right)
        throw logic_error(
            "How could this even happen? What is left, what is right?!?!?!\n"
            "Clearly, using namespace std is pure evil!\n");
}

在下面的代码中,// cmets 是我的 cmets,它解释了我所做的更改以及您在编写此类代码时应该寻找的内容。唯一应该保留的 cmets,我希望从长远来看会有所帮助,请使用 /* */ 表单。

这段代码至少可以编译,我希望它是正确的(或者至少会在出现问题时抛出/断言)。

graph.h

// This is a widely supported pragma and is easier to use than 
// legacy include guards.
#pragma once

// Only include what's needed.
#include <stdexcept>
#include <string>

// Use standard exceptions when creating custom exceptions - they convey
// some information about the meaning of the error.

class GraphEdgeNotFound : public std::runtime_error {
public:
    GraphEdgeNotFound();    
};

class GraphVertexNotFound : public std::runtime_error {
public:
    GraphVertexNotFound();
};

class GraphFull : public std::runtime_error {
public:
    GraphFull();
};

struct VertexNode;

// Don't comment obvious stuff: it's clear that this is a  "structure"
// representing an "edge" "node".
//
// When commenting, don't paraphrase what the basic c++ already says:
// there's no point to commenting EdgeNode *nextPtr as "pointer to next edge":
// it's clear that it's a pointer to an edge, and it's clear that it's pointer 
// to the next one - that's what proper typing and variable naming is for, and
// such comments are noise.

struct EdgeNode
{
  // Initialize all POD (plain old data) structure members to avoid bugs caused
  // by simple mistakes where members are left uninitialized.
  VertexNode*   to = nullptr; /* never null, the default value is not used */
  int           weight = 0;
  EdgeNode*     next = nullptr; /* owns the next node */

  // Use constructors to ensure that all members of the structure that shouldn't
  // have default values are initialized.
  // Use noexcept(false) to document that the function can indeed throw.
  // This signifies to the user that e.g. there may be a constraint on the
  // parameter.
  EdgeNode(VertexNode *to, int weight) noexcept(false);
  // A destructor must free the memory used the the nodes we own.
  ~EdgeNode();
  
  // A default constructor makes no sense: an edge must refer to some vertex
  EdgeNode() = delete;
  // Delete copy constructor and assignment since nodes cannot be copied
  EdgeNode(const EdgeNode &) = delete;
  EdgeNode &operator=(const EdgeNode &) = delete;
};

// Use variable names to convey meaning, e.g. `edgesOut` clearly means outgoing
// edges, vs. `edgePtr` that requires a comment.
//
// *Always* prefer to document things with code itself, rather than writing
// cryptic code that then needs comments. If the code needs comments, consider
// whether it could be written to make it clear.

struct VertexNode
{
  std::string   vname; /* not empty */
  bool          mark = false;
  EdgeNode*     edgesOut = nullptr; /* owns the edges */
  VertexNode*   next = nullptr; /* owns the next node */

  // Pass non-trivial input parameters like std::string by const reference, 
  // not by value.
  // It is OK to create a vertex without outgoing edges, so provide a default
  // value to signify that.
  // The constructor is explicit so that the compiler won't attempt to
  // convert a string to a vertex implicitly (when we least expect it).
  explicit VertexNode(const std::string &vname, EdgeNode *edgesOut = nullptr)
    noexcept(false);
  // A destructor must free the memory used the the nodes we own.
  ~VertexNode();
  
  // Again: no default constructor since such a vertex would be invalid
  VertexNode() = delete;
  // Delete copy constructor and assignment since nodes cannot be copied
  VertexNode(const VertexNode &) = delete;
  VertexNode &operator=(const VertexNode &) = delete;

  // Use member functions to encapsulate common operations on an object.
  void AppendEdge(EdgeNode *newEdge) noexcept(false);
};

class Graph
/* Graph ADT using adjacency list representation */
{
 private:
  VertexNode*   vertices = nullptr;

 public:
  // Use parameter names that make their purpose clear; if they need comments
  // then usually they are not named correctly!
  void AddEdge(const std::string &from, const std::string &to, int weight) noexcept(false);

  VertexNode* GetVertex(const std::string &vname) noexcept(false);
  /* Throws GraphVertexNotFound if no such vertex exist in the vertices list */
};

graph.cpp

#include "graph.h"
#include <cassert>

// Any global namespace inclusions should be put into the .cpp file, not .h.
// We still do not want `using namespace std`, instead we're enumerating all
// names that we will use. This may also help self-document which parts of
// the standard library are used. Only commonly used classes should be
// "pulled" into the global namespace here. The less common ones should be used
// with the `std::` prefix. **The only purpose here is to reduce some clutter.
// Prefer to use `std::` prefix by default.**

using std::invalid_argument;
using std::runtime_error;
using std::string;

GraphEdgeNotFound::GraphEdgeNotFound() : 
    runtime_error("Graph edge not found") {}

GraphVertexNotFound::GraphVertexNotFound() : 
    runtime_error("Graph vertex not found") {}

GraphFull::GraphFull() : 
    runtime_error("Out of memory while allocating graph") {}

// The common functionality of freeing linked lists should be factored out.
// It's a static function since we don't need it visible from outside this file.
template <typename T>
static void FreeList(T *node)
{
    while (node) {
        T *temp = node;
        node = node->next;
        temp->next = nullptr;
        delete temp; /* no recursion in temp's destructor: temp->next is null */
    }
}

// The above function doesn't do any recursive calling, because when the
// object is deleted, it doesn't own the successor node anymore.
// The following implementation would also "work" in that it would free the
// resources and wouldn't leak memory. But it would cause the destructors
// to recurse, as the parent node deletes the child which deletes the child and
// so on.
template <typename T>
static void do_not_use_such_FreeList(T *node)
{
    delete node;
}

EdgeNode::EdgeNode(VertexNode *to, int weight) :
    // Using this constructor with a null vertex is not
    // a runtime issue like a missing file. It's a bug in the code, and
    // there's no way to proceed since the rest of the code depends on the edges
    // actually pointing to a valid vertex. 
    // That's stated by the non_null validator below.
    to(to), weight(weight)
{
    // First Layer of Defense Against Dark Bugs: assert
    // In debug builds, this will drop us immediately to the debugger.
    // We have to debug this, it's a bug!
    assert(to);
    // Second layer of Defense Against Dark Bugs: throw
    // In release builds the assertions do nothing, so we must throw an exception
    // instead so that invalid operations don't take place.
    // We shouldn't assert here, since that loses the specific code line information
    // - remember that assert is a macro that records the line where it tripped.
    if (!to) throw invalid_argument("Attempt to create an edge to nowhere");
}

EdgeNode::~EdgeNode()
{
    FreeList(next);
}

VertexNode::VertexNode(const string &vname, EdgeNode *edgesOut) :
    vname(vname), edgesOut(edgesOut)
{
    assert(!vname.empty());
    if (vname.empty()) throw invalid_argument("Attempt to create a nameless vertex");
}

VertexNode::~VertexNode()
{
    // It's OK to invoke delete (and free!) with a null pointer. Do not check
    // for null when deleting/freeing memory this way - it's redundant.
    delete edgesOut;
    FreeList(next);
}

// Provides the reference to the last edge node pointer in the list
EdgeNode* &GetLastInList(EdgeNode *&head)
{
    EdgeNode **last = &head; /* points to pointer to an edge */
    while (*last)
        last = &((*last)->next); /* advance tail to the last edge */
    return *last;
}

// This is a generic function that can append an edge to any edge list.
// We'll probably use it in various places.
// Its name documents its purpose.
void AppendToEdgeList(EdgeNode *&head, EdgeNode *newEdge)
{
    assert(newEdge);
    if (!newEdge) throw invalid_argument("Attempt to append no edge to edge list");
    EdgeNode *&last = GetLastInList(head);
    last = newEdge;
}

// Appending edges to a vertex is a common thing: make it a function.
// Due to useful naming of various elements of code, this function
// documents itself - any comment here would be likely unnecessary and
// just paraphrase what the code already clearly states.
void VertexNode::AppendEdge(EdgeNode *newEdge)
{
    assert(newEdge);
    if (!newEdge) throw invalid_argument("Attempt to append no edge to the vertex");
    AppendToEdgeList(edgesOut, newEdge);
}

void Graph::AddEdge(const string &from, const string &to, int weight)
{
    assert(!from.empty());
    assert(!to.empty());
    if (from.empty() || to.empty())
        throw invalid_argument("Attempt to add an edge to/from an unnamed vertex");
    
    // The original code was not handling the required condition that
    // the out-of-memory condition should be handled.
    try
    {
        VertexNode *vFrom = GetVertex(from);
        VertexNode *vTo = GetVertex(to);

        EdgeNode* newEdgeTo = new EdgeNode(vTo, weight);
        vFrom->AppendEdge(newEdgeTo);
    }
    catch (std::bad_alloc) {
        throw GraphFull();
    }
}

VertexNode *Graph::GetVertex(const string &vname)
{
    assert(!vname.empty());
    if (vname.empty()) 
        throw invalid_argument("Attempting to get a vertex without a name");
        
    // The for loop collects all three elements of the loop
    // in self-documenting syntax: establishing the loop variable,
    // the termination condition, and the iteration step.
    // It's not always possible to collect everything inside a for(),
    // so it should be used when it makes things clearer, rather than
    // trying to shoehorn stuff into it.
    for (VertexNode* v = vertices; v; v = v->next)
    {
        if (v->vname == vname)
            return v;
    }

    throw GraphVertexNotFound();
}

【讨论】:

    【解决方案2】:

    现代 C++

    当然可以通过摆脱手动内存管理并使用唯一指针自动销毁节点来改进代码,从而保证没有内存泄漏。

    还有更多可以添加的东西,例如树的迭代器,但下面的代码应该可以让您了解如何处理unique_ptr 和链表。

    graph.h

    #pragma once
    
    #include <memory>
    #include <stdexcept>
    #include <string>
    
    class GraphEdgeNotFound : public std::runtime_error {
    public:
        GraphEdgeNotFound();    
    };
    
    class GraphVertexNotFound : public std::runtime_error {
    public:
        GraphVertexNotFound();
    };
    
    class GraphFull : public std::runtime_error {
    public:
        GraphFull();
    };
    
    struct VertexNode;
    struct EdgeNode;
    
    /* A custom deleter for std::unique_ptr to list nodes that prevents recursion */
    namespace std {
        // We declare the deleter operators here, and implement them in graph.cpp.
        template <> struct default_delete<VertexNode> {
            void operator()(VertexNode *);
        };
        template <> struct default_delete<EdgeNode> {
            void operator()(EdgeNode *);
        };
    }
    
    struct EdgeNode
    {
      // An edge can never refer to no vertex, so use a reference instead of
      // a pointer. In C++, a reference is a way of stating in code that
      // there always is some object to refer to, vs. a pointer that could be null.
      // There is no such thing as a "null reference".
      VertexNode&               to;
      int                       weight = 0;
      // An edge node forms a list and always owns the next list item.
      std::unique_ptr<EdgeNode> next;
    
      // The vertex is taken as a reference since it must be valid.
      EdgeNode(VertexNode &to, int weight);
    
      EdgeNode() = delete;
      EdgeNode(const EdgeNode &) = delete;
      EdgeNode &operator=(const EdgeNode &) = delete;
    };
    
    struct VertexNode
    {
      std::string                 vname; /* not empty */
      bool                        mark = false;
      // The vertex owns its outgoing edges
      std::unique_ptr<EdgeNode>   edgesOut;
      // The vertex forms a linked list and owns its successor.
      std::unique_ptr<VertexNode> next;
      
      // Unique pointers represent ownership and cannot be copied, but can be
      // moved, thus we take the optional edges list by rvalue reference (&&)
      // and provide a default-constructed value ({}).
      explicit VertexNode(const std::string &vname,
                          std::unique_ptr<EdgeNode> &&edgesOut = {}) noexcept(false);
    
      VertexNode() = delete;
      VertexNode(const VertexNode &) = delete;
      VertexNode &operator=(const VertexNode &) = delete;
    
      // The edge is passed by rvalue reference, since owning pointers can only
      // be moved, not copied (there can be only one, unique owner). To make
      // it simpler to use the edge after it was appended, the reference to that
      // edge is returned. It's a reference since it's always valid.
      EdgeNode &AddEdge(std::unique_ptr<EdgeNode> &&newEdge) noexcept(false);
      // An overload that constructs a new edge from the arguments given 
      // - they are forwarded directly to the edge's constructor
      template <typename ...Args> EdgeNode &AddEdge(Args &&...args) {
          return AddEdge(std::make_unique<EdgeNode>(std::forward<Args>(args)...));
      }
    };
    
    class Graph
    /* Graph ADT using adjacency list representation */
    {
     private:
      std::unique_ptr<VertexNode> vertices;
    
     public:
      // The methods below all return a node by reference, since they either
      // succeed - and can return a valid node, or throw - and then don't return(!)
      
      VertexNode &AddVertex(std::unique_ptr<VertexNode> &&node);
      VertexNode &GetVertex(const std::string &vname) noexcept(false);
      /* Throws GraphVertexNotFound if no such vertex exist in the vertices list */
    
      // An overload that constructs a new vertex from the arguments given 
      // - they are forwarded directly to the vertex's constructor
      template <typename ...Args> VertexNode &AddVertex(Args &&...args) {
          return AddVertex(std::make_unique<VertexNode>(std::forward<Args>(args)...));
      }
    
      EdgeNode &AddEdge(const std::string &from, const std::string &to, int weight)
      noexcept(false);
    };
    

    graph.cpp

    #include "graph.h"
    #include <cassert>
    
    using std::invalid_argument;
    using std::runtime_error;
    using std::string;
    using std::unique_ptr;
    
    GraphEdgeNotFound::GraphEdgeNotFound() : 
        runtime_error("Graph edge not found") {}
    
    GraphVertexNotFound::GraphVertexNotFound() : 
        runtime_error("Graph vertex not found") {}
    
    GraphFull::GraphFull() : 
        runtime_error("Out of memory while allocating graph") {}
    
    /* Deletes list nodes without recursion in the destructor */
    template <class T>
    static void DeleteNode(T *ptr) {
        if (!ptr) return;
    
        unique_ptr<T> node = std::move(ptr->next);
        while (node) {
            std::unique_ptr<T> temp = std::move(node->next);
            assert(!node->next); /* no recursion - no successor */
            delete node.release();
            node = std::move(temp);
        }
        assert(!ptr->next); /* no recursion - no successor */
        delete ptr;
    }
    
    namespace std {
        void default_delete<VertexNode>::operator()(VertexNode *p) { DeleteNode(p); }
        void default_delete<EdgeNode>::operator()(EdgeNode *p) { DeleteNode(p); }
    }
    
    VertexNode::VertexNode(const string &vname, unique_ptr<EdgeNode> &&edgesOut) :
        vname(vname), edgesOut(std::move(edgesOut))
    {
        assert(!vname.empty());
        if (vname.empty())
            throw invalid_argument("Attempt to create a nameless vertex");
    }
    
    EdgeNode::EdgeNode(VertexNode &to, int weight) :
        to(to), weight(weight)
    {}
    
    template <typename T>
    static std::unique_ptr<T> &GetLastInList(std::unique_ptr<T> &head)
    {
        auto *last = &head; /* points to pointer to a node */
        while (*last)
            last = &((*last)->next); /* advance to the last edge */
        assert(!*last); /* the last edge must be empty (tail of the list) */
        return *last;
    }
    
    template <typename T>
    static T &AppendNodeToList(std::unique_ptr<T> &head, unique_ptr<T> &&newNode)
    {
        assert(newNode);
        if (!newNode)
            throw invalid_argument("Attempt to append no node to a node list");
        
        auto &last = GetLastInList(head);
        auto *result = newNode.get(); /* save the pointer - it'll be null after the move */
        last = std::move(newNode); /* assign the new node to the tail */
        assert(!newNode); /* newNode was moved from */
        return *result;
    }
    
    EdgeNode &VertexNode::AddEdge(unique_ptr<EdgeNode> &&newEdge)
    {
        assert(newEdge);
        return AppendNodeToList(edgesOut, std::move(newEdge));
    }
    
    EdgeNode &Graph::AddEdge(const string &from, const string &to, int weight)
    {
        assert(!from.empty());
        assert(!to.empty());
        if (from.empty() || to.empty())
            throw invalid_argument("Attempt to add an edge to/from an unnamed vertex");
        
        try
        {
            VertexNode &vFrom = GetVertex(from);
            VertexNode &vTo = GetVertex(to);
            return vFrom.AddEdge(vTo, weight);
        }
        catch (std::bad_alloc) {
            throw GraphFull();
        }
    }
    
    VertexNode &Graph::AddVertex(std::unique_ptr<VertexNode> &&node)
    {
        return AppendNodeToList(vertices, std::move(node));
    }
    
    VertexNode &Graph::GetVertex(const string &vname)
    {
        assert(!vname.empty());
        if (vname.empty()) 
            throw invalid_argument("Attempting to get a vertex without a name");
            
        // The for loop collects all three elements of the loop
        // in self-documenting syntax: establishing the loop variable,
        // the termination condition, and the iteration step.
        // It's not always possible to collect everything inside a for(),
        // so it should be used when it makes things clearer, rather than
        // trying to shoehorn stuff into it.
        for (VertexNode* v = vertices.get(); v; v = v->next.get())
        {
            if (v->vname == vname)
                return *v;
        }
    
        throw GraphVertexNotFound();
    }
    

    main.cpp

    下面是如何使用上述代码的一个小例子。

    #include <cassert>
    #include "graph.h"
    
    int main() {
        Graph graph;
        graph.AddVertex("vertex1");
        graph.AddVertex("vertex2");
        graph.AddVertex("vertex3");
        graph.GetVertex("vertex1");
        graph.GetVertex("vertex2");
        graph.GetVertex("vertex3");
        try {
            graph.GetVertex("vertex4"); // unknown vertex: throws
            assert(false); // won't run if preceding line had thrown
        } catch (GraphVertexNotFound) {
            assert(true);
        }
    }
    

    【讨论】:

    • 您好,感谢您的建议。这很有帮助。但是,这是我必须严格遵循给graph.h 文件的分配(即graph.h 不允许修改)
    • 您知道:您的老师教您的是 C,而不是 C++,即使就 C 和课程而言,它仍然很糟糕。多么不称职的老师。不妨放下伪装,告诉你们,你们所学的确实是 C。可以修改“最小改进”的答案,以使头文件保持不变 - 您将在外部使用结构,但在 graph.cpp 内部,您将使用带有构造函数和析构函数等的真实类包装它们。这些细节不会更改外部 API。我将发布第三个这样的答案。
    • 嗨,Unslander,我找到了Exception thrown: read access violation. Vertex_S-&gt;**edgePtr** was 0xCDCDCDCD 的问题所在。它在我的析构函数中,而不是在我发布的部分中。无论如何,我能够从你的答案中看到一个更好的方法来解决链表中的指针!
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-01-27
    • 2021-02-13
    • 1970-01-01
    • 1970-01-01
    • 2013-05-24
    • 1970-01-01
    相关资源
    最近更新 更多