二、单链表的实现和遍历,循环链表、双向循环链表的实现

一、线性表的链式存储结构

顺序存储结构线性表的最大问题:插入和删除需要移动大量的元素(时间复杂度高),如何解决呢?

链式存储的定义:

为了表示每个元素与其直接后继元素之间的逻辑关系,数据元素除了存储本身的信息之外,还需要存储其直接后继的信息。


通过保存地址的关系将数据元素链接起来。Ai和Ai+1是线性表中的两个相邻的数据元素,在物理内存中无相邻关系。

链式存储逻辑结构:基于链式存储结构的线性表,每个结点都包含数据域和指针域。

数据域:存储数据元素本身。

指针域:存储相邻结点的地址。

专业术语:顺序表——基于顺序存储结构的线性表

链表:基于链式存储结构的线性表。

单链表:每个结点只包含直接后继的地址信息

循环链表:单链表中的最后一个结点的直接后继为第一个结点

双向链表:单链表中的结点包含直接前驱和后继的地址信息。

链表中的基本概念:

头结点:链表中的辅助结点,包含指向第一个数据元素的指针。//不包含数据信息,只包含地址信息

数据结点:链表中代表数据元素的结点,表现形式为:(数据元素、地址)。

尾结点:链表中的最后一个数据结点,包含的地址信息为空。

struct Node: public Object  //继承自Object顶层父类
{
    T value;  //数据元素的具体类型
    Node* next; //指向后继结点的指针
};

Node:单链表中结点类型。


头结点在单链表中意义:辅助数据元素的定位,方便插入和删除操作,因此,头结点不存储实际的数据元素。

在目标位置处插入数据元素:

1、从头结点开始,通过current指针定位到目标位置;

2、从堆空间申请新的Node结点

3、执行操作:

node->value = e;
node->next = current->next;
current->next = node;

从目标位置处删除数据元素: //核心思维就是先替换,再删除

1、从头结点开始,通过current指针定位到目标位置;

2、使用toDel指针指向需要删除的结点;

3、执行操作:

toDel = current->next;
current->next = toDel->next;
delete toDel;

注意:

1、链表中的数据元素在物理内存中无相邻关系;

2、链表中的结点都包含数据域和指针域;

3、头结点用于辅助数据元素的定位,方便插入和删除操作;

4、插入和删除操作需要保证链表的完整性。


二、单链表的具体实现

继承关系:Object <--- List <--- LinkList

LinkList设计要点:

1、类模板:通过头结点访问后继结点;

2、定义内部结点类型Node,用于描述数据域和指针域;

3、实现线性表的关键操作(增、删、查、等)。

template < typename T>
class LinkList : public List<T>
{
protected:
    struct Node : public Object
    {
        T value;
        Node* next;   //?
    };

    mutable struct : public Object  //如果不继承自Object,就会导致内存布局上面的不同
    {
        char reserved[sizeof(T)];
        Node* next;
    } m_header;

    //创建m_header时,不再调用泛指类型的构造函数
    //为什么呢?
    //因为头结点的构造,只需要对泛指类型进行sizeof()操作,不涉及到具体对象的构建。
    //头结点类型需要重新定义,定义头结点的时候,忽视继承自顶层父类,导致内存布局上的不同。
    //只要进行代码改动,就需要重新测试功能。

    int m_length;
    int m_step;  //游标每次移动的数目
    Node* m_current;

    Node* position(int i) const
    {
        Node* ret = reinterpret_cast<Node*>(&m_header); //强制类型转换

        for(int p=0; p<i; p++)
        {
            ret = ret->next;
        }

        return ret;
    }

    virtual Node* create()
    {
        return new Node();      //一开始是在堆空间中申请任意内存;
    }

    virtual void destroy(Node* pn)
    {
        //cout<< " 父类的析构函数"<< endl;
        delete pn;
    }

public:
    LinkList()
    {
        m_header.next = NULL;
        m_length = 0;
        m_step = 1;
        m_current = NULL;
    }

    bool insert(const T& e)
    {
        return insert(m_length, e);
    }

    bool insert(int i, const T& e)
    {
        bool ret = ( (0 <= i) && ( i <= m_length));

        if(ret)
        {
            Node* node = create();  //取决的当前的对象是单链表的对象还是静态单链表对象  因为是虚函数 可以实现多态

            if( node != NULL)
            {
                Node* current = position(i);

                node->value = e;
                node->next = current->next;
                current->next = node;

                m_length++;
            }
            else
            {
                THROW_EXCEPTION(NoEnoughMemoryException,"No memoty to insert a node");
            }
        }

        return ret;
    }

     bool remove(int i)
    {
       bool ret = ( (0 <= i) && ( i <= m_length));

        if(ret)
        {
            Node* current  =position(i);
            Node* toDel = current->next;

            if( m_current == toDel)
            {
                m_current = toDel->next;
            }

            current->next = toDel->next;

            m_length--;

            destroy(toDel)  ;
        }

        return ret;
    }

     bool set(int i, const T& e)
    {
         bool ret = ( (0 <= i) && ( i <= m_length));

         if(ret)
         {
             position(i)->next->value = e;
         }

         return ret;
    }

    virtual T get( int i) const //重载的get()函数
     {
         T ret;

         if( get(i, ret) )
         {
             return ret;
         }
         else
         {
           THROW_EXCEPTION(NoEnoughMemoryException,"No memory to insert a element");
         }

         return ret;
     }

   bool get(int i, T& e) const
    {
       bool ret = ( (0 <= i) && ( i <= m_length));

       if(ret)
       {
           e =  position(i)->next->value;
       }

       return ret;
    }

    int length() const
     {
        return m_length;
     }

    void clear()
    {
        while( m_header.next )
        {
           Node* toDel = m_header.next;

            m_header.next = toDel->next;

            m_length--;

            destroy( toDel );
        }

    }

    virtual int find(const T& e) const
    {
        int ret = -1;

        int i=0;
        Node* node = m_header.next;

        while( node )
        {
            if( node->value == e)
            {
                ret = i;
                break;
            }
            else
            {
                node = node->next;
                i++;
            }
        }

        return ret;
    }

    virtual bool move(int i, int step = 1)  //将游标定位到目标位置
    {
        bool ret = (0 <= i) && (i < m_length) && (step > 0);

        if( ret)
        {
            m_current = position(i)->next;
            m_step = step;
        }

         return ret;
    }

    virtual bool end() //游标是否到达尾部(是否为空)
    {
       return ( m_current == NULL );

    }

    virtual T current()  //获取游标所指向的数据元素
    {
        if( !end() )
        {
            return m_current->value;
        }
        else
        {
            THROW_EXCEPTION(InvalidOperationException,"No valve at current position ...");
        }

    }

    virtual bool next()  //移动游标
    {
        int i = 0;

        while( (i< m_step) && !end() )
        {
            m_current = m_current->next;
            i++;
        }

        return ( i == m_step);
    }

    ~LinkList()
    {
        clear();
    }
};

针对头结点可能存在的隐患:

class Test : public Object
{
public:
    Test()
    {
        Throw 0;
    }
};

如果库是一个商用的库,并没有创建Test类的对象,构造的时候需要调用构造函数创建Test的对象。

构造头结点的时候不去调用泛指类型的构造函数,创建新的匿名类型(注意内存布局,可以继承自同一父类),定义的目的仅仅是为了头结点,定义单链表对象头结点的时候,定义仅仅为了占空间的数组对象,在内存布局上面和之前无差异,差异在于不管泛指类型T是任何情况,都不会调用构造函数。

    mutable struct : public Object  //如果不继承自Object,就会导致内存布局上面的不同
    {
        char reserved[sizeof(T)];
        Node* next;
    } m_header;

代码优化:insert()、remove()、get()、set()等操作都设计元素定位——设计position()函数。

总结:通过类模板实现链表,包含头结点成员和长度成员;定义结点类型,并通过堆中的结点对象构成链式存储;为了避免构造错误的隐患,头结点类型需要重新定义;代码优化是编码完成后必不可少的环节。


三、顺序表与单链表的对比分析

如何判断某个数据元素是否存在于线性表中?

find()函数:int find(const T& e) const;

参数:待查找的数据元素

返回值:>=0:数据元素在线性表中第一次出现的位置;

                 -1:数据元素不存在。

 virtual int find(const T& e) const
    {
        int ret = -1;

        int i=0;
        Node* node = m_header.next;

        while( node )
        {
            if( node->value == e)
            {
                ret = i;
                break;
            }
            else
            {
                node = node->next;
                i++;
            }
        }

        return ret;
    }

针对自定义类类型的比较操作(自定义类类型应该也继承自顶层父类),在顶层父类中,实现相等比较操作符的重载函数:

bool Object::operator ==(const Object& obj)  //默认实现
{
    return (this == &obj);
}

bool Object::operator !=(const Object& obj)
{
    return (this != &obj);
}

时间复杂度对比分析:


顺序表的整体时间复杂度比单链表要低,那么单链表还有使用价值吗?

效率的深度分析:时间的工程开发中,时间复杂度只是效率的一个参考标准:

对于内置基础类型,顺序表和单链表(指针操作,4字节或者8字节)的效率不相上下;

对于自定义类类型,顺序表在效率上低于单链表(依旧指针操作,和数据类型无关)。

插入和删除:

顺序表:涉及大量数据对象的复制操作;

单链表:只涉及指针操作,效率与数据对象无关。

数据访问:

顺序表:随机访问,可直接定位数据元素;

单链表:顺序访问,必须从头访问数据对象,无法直接定位。

工程开发中的选择:

顺序表:数据元素的类型相对简单,不涉及深拷贝;

             数据元素相对稳定,访问操作远多于插入和删除操作。

单链表:数据元素的类型相对复杂,复制操作相对耗时;

             数据元素不稳定,需要经常插入和删除,访问操作较少。

总结:顺序表适用于访问需求量较大的场合(随机访问)

          单链表适用于数据元素频繁插入删除的场合(顺序访问)


四、单链表的遍历与优化

如何遍历单链表中的每一个数据元素?

int main()
{
    LinkList<int> list;

    for(int i=0; i<5; i++)       //O(N)
    {
        list.insert(i);
    }

    for(int i=0; i<list.length(); i++)            //O(?)  --->  O(N*N)
    {
        cout << list.get(i) << endl;
    }

    return 0;
}

问题:单链表不能以线性的时间复杂度完成单链表的遍历

新的需求:为单链表提供新的方法,在线性时间内完成遍历。

设计思路(m_current:指针)

1、在单链表的内部定义一个游标(Node* m_current)

2、遍历开始前将游标指向位置为0的数据元素

3、获取游标指向的数据元素

4、通过结点中的next指针移动游标

提供一组遍历相关的函数,以线性的时间复杂度遍历链表

virtual bool move(int i, int step = 1)  //将游标定位到目标位置
virtual bool next()  //移动游标
virtual T current()  //获取游标所指向的数据元素
virtual bool end() //游标是否到达尾部(是否为空)

具体使用如下:

int main()
{
    LinkList<int> list;

    for(int i=0; i<5; i++) //O(N)
    {
        list.insert(0, i);
    }

    for(int i=0; i<list.length(); i++) //O(N)
    {
        cout << list.get(i) << endl;
    }

    for(list.move(0); !list.end(); list.next())
    {
        cout << list.current() << endl;
    }

    for(list.move(0, 2); !list.end(); list.next())
    {
        cout << list.current() << endl;
    }

    for(list.move(0, 10); !list.end(); list.next())
    {
        cout << list.current() << endl;
    }

    return 0;
}

单链表内部的一次封装——可以增强扩展性:

    virtual Node* create()
    {
        return new Node();      //一开始是在堆空间中申请任意内存;
    }

    virtual void destroy(Node* pn)
    {
        //cout<< " 父类的析构函数"<< endl;
        delete pn;
    }
扩展什么呢?


五、静态单链表的实现(StaticLinkList)

单链表的一个陷阱:

触发条件:长时间使用单链表对象频繁增加和删除数据元素

可能的结果:堆空间产生大量的内存碎片,导致系统运行缓慢。

新的线性表——设计思路:

List <--- LinkList <--- StaticLinkList

在“单链表”内部增加一片预留的空间(连续的内存),所有的Node对象都在这片空间中动态创建和动态销毁。

静态单链表的实现思路:

1、通过模板定义静态单链表(staticlinklist)

2、在类中定义固定大小的空间(unsigned char[])

3、重写create和destroy函数,改变内存的分配和归还方式

4、在Node类中重载operator new,用于在指定内存上创建对象。

template < typename T, int N >
class StaticLinkList : public LinkList<T>
{
protected:

    typedef typename LinkList<T>::Node Node; //加typename的原因在于 编译器不知道访问的是类型还是静态成员变量

    struct SNode :public Node  //单纯使用Node类型会涉及构造函数的调用,
    {
         void* operator new(unsigned int size, void* loc)   //分配好内存之后,在指定的内存上调用构造函数,两个参数,
        {
            (void)size;  //C语言中的暴力声明

            return loc;  //返回调用构造函数的内存地址
        }
     };

        unsigned char m_space[sizeof(Node)* N];
        int m_used[N];

        Node* create()
        {
           SNode* ret = NULL;

           for(int i=0; i<N; i++)
           {
               if( !m_used[i] )
               {
                   ret = reinterpret_cast<SNode*>(m_space) + i;   //指针运算 单纯的分配内存  不涉及构造
                   ret = new(ret)SNode();  //通过new关键字在指定的内存空间上调用SNode的构造函数
                   m_used[i] = 1;
                   break;
               }
           }

           return ret;
        }

        void destroy(Node *pn)
        {
            SNode* space = reinterpret_cast<SNode*>(m_space);
            SNode* psn = dynamic_cast<SNode*>(pn);   //强制类型转换,将父类指针转换成子类指针

            for(int i=0; i<N; i++)
            {
                if( psn == (space + i) ) //第i个内存单元需要归还
                {
                    m_used[i] = 0;
                    psn->~SNode(); //默认的析构函数

                    break;  //空间归还后。对象析构,立即跳出循环
                }
            }
        }

public:
        StaticLinkList()
        {
            for(int i=0; i<N; i++)
            {
                m_used[i] =0;  //标记每一个内存单元都是可用的
            }
        }

        int capacity()
        {
            return N;
        }

        ~StaticLinkList()
        {
            this->clear();
        }
};

main()函数的实现

int main()
{
    StaticLinkList<int, 5> list;

    for(int i=0; i<5; i++) //O(N)
    {
        list.insert(0, i);
    }

    try{
       list.insert(6); // 已经设置了该类型的初始化内存数量
    }
    catch(const Exception& e)
    {
        cout << e.message() <<endl;
    }

    for(list.move(0); !list.end(); list.next())
    {
        cout << list.current() << endl;
    }


    return 0;
}

注意:LinkList中的insert()函数中

Node* node = create();  //取决的当前的对象是单恋的对象还是静态单链表对象  因为是虚函数 可以实现多态

在单链表可以使用的地方,均可以使用静态单链表。

在上一小节中提到的问题,LinkList中封装create和destroy函数的意义是什么?

答案:为静态单链表(staticlinklist)的实现做准备,StaticLinkList与LinkList的不同仅在于链表节点内存分配上的不同,因此,将仅有的不同,封装于父类和子类的虚函数中。

小结:顺序表和单链表相结合后衍生出静态单链表;

静态单链表是LinkList的子类,拥有单链表的所以操作;

静态单链表在预留的空间中创建结点对象;

静态单链表适用于频繁增删数据元素的场合。(最大元素个数固定)


六、循环链表的实现

 什么是循环链表?

概念上:任意数据元素都有一个前驱和一个后继,所有的数据元素的关系构成一个逻辑上的环。

实现上:循环链表是一种特殊的单链表,尾结点的指针域保存了首结点的地址



循环链表的实现思路:

通过模板定义CircleList类,继承自LinkList类;

定义内部函数last_to_first(),用于将单链表首尾相连

特殊处理:首元素的插入操作和删除操作

重新实现:清空操作和遍历操作。

循环链表的实现要点:

插入位置为0时:

头结点和尾结点均指向新结点;

新结点成为首结点插入链表。

删除位置为0时:

头结点和尾结点指向位置为1的结点;

安全销毁首结点。

    Node* last() const  //获取尾结点
    {
        return this->position(this->m_length-1)->next;
    }

    void last_to_first() const
    {
        last()->next = this->m_header.next;
    }

注意Node仍为别名

typedef typename LinkList<T>::Node  Node;

针对参数i较大的情形,进行取余操作:

    int mod(int i) const
    {
        return (this->m_length == 0) ? 0:(i % this->m_length);
    }

插入结点操作:

    bool insert(const T& e)
    {
        return insert(this->m_length, e);
    }

    bool insert( int i, const T& e)
    {
        bool ret = true;

        i = i %(this->m_length + 1); //注意i为0的情形

        ret = LinkList<T>::insert(i, e);   //调用父类的insert();

        if( ret && (i == 0))  //  注意插入位置为0的时候,即形成循环链表的地方。判断i的值,若为0,则首尾相连
        {
            last_to_first();
        }

        return ret;
    }

删除结点操作:

    bool remove( int i ) //循坏链表 i值很大
    {
      bool ret = true;

      i = mod(i);

      if( i == 0)
      {
         Node* toDel = this->m_header.next;  //即将删除的结点

         if( toDel != NULL)
         {
             this->m_header.next = toDel->next;
             this->m_length--;

             if( this->m_length > 0)
             {
                last_to_first();

                if( this->m_current == toDel)
                {
                    this->m_current = toDel->next;
                }
             }
             else  //删除之后链表消失
             {
                 this->m_header.next = NULL;
                 this->m_current = NULL;
             }

             this->destroy(toDel); //异常安全到最后一步销毁首结点
         }
         else
         {
            ret = false;
         }
      }
      else
      {
         ret = LinkList<T>::remove(i);
      }

      return ret;
    }

set()、get()、find()的实现

    bool set(int i, const T& e)
    {
        return LinkList<T>::set( mod(i), e);
    }

    T get(int i) const
    {
        return LinkList<T>::get( mod(i));
    }

    T get(int i, const T& e)const   //? type arguement?
    {
       return LinkList<T>::get( mod(i), e);
    }

    int find(const T& e)const
    {
        int ret = -1;

        //last()->next = NULL;

       // ret = LinkList<T>::find(e);  //出异常怎么办?  怎么解决异常安全?

      // last_to_first(); //find出异常后,该行语句不会执行,会损害数据:不建议try_catch,会降低可移植性

        Node* slider = this->m_header.next; //指向首结点

        for(int i=0; i<this->m_length; i++)
        {
            if( slider->value == e)
            {
                ret = i;
                break;
            }

            slider = slider->next;
        }

        return ret;
    }

清除操作clear()

    void clear()
    {
        while ( this->m_length > 1)
        {
           remove(1);  //如果每次为0,效率低下。  可以参照remove()函数实现
        }

        if( this->m_length == 1)
        {
            Node* toDel = this->m_header.next;

            this->m_header.next = NULL;
            this->m_length = 0;
            this->m_current =NULL;

            this->destroy(toDel);
        }
    }
注意:在调用父类的函数时,要注意将其声明为虚函数,并且注意const函数的声明。
    bool  move(int i, int step )
    {
        return LinkList<T>::move(mod(i), step);
    }

    bool end()
    {
        return (this->m_length == 0) || (this->m_current == NULL);
    }

    ~CircleList()
    {
        clear();
    }


分析:构建一个循环链表,通过move()函数,设置步长和移除位置,每次移除一个数,通过最后移除的两个数字,就可以确定站在那个位置比较安全。

编程实现如下:


void josephus(int n, int s, int m) //start,murder
{
    CircleList<int> cl;

    for(int i=1; i<=n; i++)
    {
        cl.insert(i);  //调用父类的insert()函数实现单链表,调用子类的insert()实现循环链表。
    }

    cl.move(s-1, m-1);

    while (cl.length() > 0)
    {
        cl.next();

        cout<< cl.current() <<endl;

        cl.remove(cl.find(cl.current()));
    }
}

int main()
{
    josephus(41, 1, 3);

    return 0;
}

运行结果如下:


小结:尾结点的指针域保存了首结点的地址

特殊处理首元素的插入操作删除操作

重新实现了清空操作遍历操作


七、双向链表的实现

单链表的缺陷一:单向性:只能从头结点开始高效访问链表中的数据元素

单链表的缺陷二:缺陷:如果需要逆向访问单链表中的数据元素将极其低效。


解决方案——设计新的数据结构:

在“单链表”的结点中增加一个指针pre,用于指向当前结点的前驱结点。

双向链表继承自LinkList,具体实现如下:

template <typename T>
class DualLinkList : public List<T>
{
protected:
    struct Node : public Object
    {
        T value;
        Node* next;
        Node* pre; //新增的前驱结点
    };

    mutable struct : public Object   //头结点
    {
       char reserved[sizeof(T)];
       Node* next;
       Node* pre; //新增的前驱结点
    }m_header;

    int m_length;
    int m_step;  //游标每次移动的数目
    Node* m_current;

    Node* position(int i) const
    {
        Node* ret = reinterpret_cast<Node*>(&m_header);

        for(int p=0; p<i; p++)
        {
            ret = ret->next;
        }

        return ret;
    }

    virtual Node* create()
    {
        return new Node();      //一开始是在堆空间中申请任意内存;
    }

    virtual void destroy(Node* pn)
    {
        //cout<< " 父类的析构函数"<< endl;
        delete pn;
    }

public:
    DualLinkList()
    {
        m_header.next = NULL;

        m_header.pre = NULL;

        m_length = 0;
        m_step = 1;
        m_current = NULL;
    }

    bool insert(const T& e)
    {
        return insert(m_length, e);
    }

    bool insert(int i, const T& e)
    {
        bool ret = ( (0 <= i) && ( i <= m_length));

        if(ret)
        {
            Node* node = create();  //取决的当前的对象是单恋的对象还是静态单链表对象  因为是虚函数 可以实现多态

            if( node != NULL )
            {
                Node* current = position(i);
                Node* next = current->next;

                node->value = e;

                node->next = next; //step1
                current->next = node;  //step2

                if(current != reinterpret_cast<Node*>(&m_header))  //注意current是不是头结点
                {
                    node->pre = current;  //step3
                }
                else
                {
                    node->pre = NULL;
                }

                if(next != NULL) //不是插入在链表最后 为空则  (next==NULL) = 1
                {
                    next->pre = node;  //step4
                }

                m_length++;
            }
            else
            {
                THROW_EXCEPTION(NoEnoughMemoryException,"No memoty to insert a node");
            }
        }

        return ret;
    }

     bool remove(int i)
    {
       bool ret = ( (0 <= i) && ( i <= m_length));

        if(ret)
        {
            Node* current  =position(i);
            Node* toDel = current->next;

            Node* next = toDel->next;

            if( m_current == toDel)
            {
                m_current = next;
            }

            current->next = next; //step1

            if( next != NULL)  //step2
            {
               next->pre = toDel->pre ;
            }

            m_length--;

            destroy(toDel)  ;
        }

        return ret;
    }

     bool set(int i, const T& e)
    {
    }

    virtual T get( int i) const
     {
     }

   bool get(int i, T& e) const
    {
    }

    int length() const
     {
     }

    virtual int find(const T& e) const
    {
    }

    virtual void clear()
    {
    }

    virtual bool move(int i, int step = 1)  //将游标定位到目标位置
    {
    }

    virtual bool end() //游标是否到达尾部(是否为空)
    {
    }

    virtual T current()  //获取游标所指向的数据元素
    {
    }

    virtual bool next()  //移动游标
    {
    }

    virtual bool pre()
    {
        int i = 0;

        while( (i< m_step) && !end() )
        {
            m_current = m_current->pre;
            i++;
        }

        return ( i == m_step);
    }

    ~DualLinkList()
    {
        clear();
    }

};

双向链表和单链表虽然相似,但是具体实现是不同的(数据结构不同)。

双向链表是为了弥补单链表的缺陷而重新设计的;

在概念上,双向链表不是单链表,没有继承关系;

双向链表中的游标能够直接访问当前结点的前驱和后继;

双向链表是线性表概念的最终实现。


八、双向循环链表的实现(使用Linux内核链表实现)

使用Linux内核链表实现双向循环链表。

template<typename T> class DualCircleList
设计思路:数据结点之间在逻辑上构成双向循环链表, 头结点仅用于结点的定位



实现思路:通过模板定义DualCircleList类,继承自DualLinkList类;

在DualCircleList内部使用Linux内核链表进行实现;

使用struct list_head定义DualCircleList的头结点;

特殊处理:循环遍历时忽略头结点

实现要点:

1、通过list_head进行目标结点定位(position(i));

2、通过list_entry将list_head指针转换为目标结点指针;

3、通过list_for_each实现int find(const T& e)函数;

4、遍历函数中的next()和pre()需要考虑跳过头结点。

将Linuxlist中的new操作符替换为node。










  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值