【数据结构与算法】第6章 优先队列(堆)

目录

二叉堆

d堆

左式堆

斜堆

二项队列

标准库中的优先队列


具有特殊优先级的队列叫做优先队列。像操作系统中,调度算法往往会使用优先队列结构。优先队列至少允许下列两种操作:insert,deleteMin(找出、返回和删除优先队列中的最小项)。


二叉堆

下面将讨论其结构性质和堆序性质。

(1)结构性质

二叉堆是一棵被完全填满的二叉树(即完全二叉树),可能的例外是在底层,底层元素从左到右填入。如下:

因为完全二叉树很有规律,所以可以用一个数组表示而不需要使用链。因为对于数组中任意位置 i 上的元素,其左儿子在位置 2i 上,右儿子在左儿子后的单元(2i + 1)中,它的父亲则在位置  \left \lfloor i/2 \right \rfloor 上。

上图的数组存储如下(注意是从下标为 1 的位置存储第一个元素):

如下是优先队列(堆)的整体接口:

template<typename Comparable>
class BinaryHeap
{
public:
    explicit BinaryHeap(int capacity = 100);
    explicit BinaryHeap(const vector<Comparable> & items);

    bool isEmpty() const;
    const Comparable & findMin() const;

    void insert(const Comparable & x);
    void deleteMin();
    void deleteMin(Comparable & minItem);
    void makeEmpty();

private:
    int currentSize;    //堆中元素的个数
    vector<Comparable> array;   //存放堆的数组

    void buildHeap();
    void percolateDown(int hole);
};

(2)堆序性质

使操作可以快速执行的性质即是堆序性质。由于要快速的找到最小元,因此最小元应该在根上。如果任意子树也是堆,那么任意结点就应该小于它的所有后裔。

  • 插入操作:insert

插入操作使用一种叫做上滤的策略,新元素在堆中上滤直到找出正确的位置。如将一个元素 X 插入到堆中,我们在下一个空闲的位置创建一个空穴,因为否则该堆将不是完全树。如果 X 可以放在空穴中而并不破坏堆序,那么插入完成。否则把空穴的父结点上的元素移入空穴中,这样空穴就朝着根的方向上行一步。继续该过程直到 X 能被放入空穴中为止。所以最好的时间 O(1),最坏为 O(logN)。业已证明,执行一次插入平均需要比较 2.607 次,元素平均上移 1.607 层。

    void insert(const Comparable & x)
    {
        if(currentSize == array.size()-1)
            array.resize(array.size()*2);
        
        //上滤
        int hole = ++currentSize;
        for( ; hole>1 && x<array[hole/2]; hole/=2)
            array[hole] = array[hole/2];
        
        array[hole] = x;
    }

如下是一个插入过程:

  • deleteMin操作

该操作使用一种叫做下滤的策略。当删除一个最小元时,要在根结点建立一个空穴。由于现在堆少了一个元素,因此堆中最后一个元素 X 必须移动到该堆的某个地方。如果 X 可以被放到空穴中,那么 deleteMin 完成。否则我们将空穴的两个儿子中的较小者移入空穴,这样就把空穴向下推了一层。重复该步骤直到 X 可以被放入空穴。所以最坏为 O(logN)。

    //删除最小元素
    void deleteMin()
    {
        if(isEmpty())
            throw UnderflowException();

        //下标为 1 的位置是堆的起始位置
        array[1] = array[currentSize--];
        percolateDown(1);
    }
    //删除最小元素,放入 minItem 中
    void deleteMin(Comparable & minItem)
    {
        if(isEmpty())
            throw UnderflowException();

        minItem = array[1];
        array[1] = array[currentSize--];
        percolateDown(1);
    }

    //下滤, hole 是下滤的起始位置
    void percolateDown(int hole)
    {
        int child;
        Comparable tmp = array[hole];
        
        for( ; hole*2 <= currentSize; hole = child)
        {
            child = hole * 2;
            if(child != currentSize && array[child + 1] < array[child])
                child++;    //换成右子树
            
            if(array[child] < tmp)
                array[hole] = array[child];
            else
                break;
        }
        
        array[hole] = tmp;
    }

如下是一个删除最小值过程:

  • 初始操作:buildHeap

这种操作有两种方法,一种是对每个元素进行 insert 操作,因为插入最好的时间 O(1),最坏为 O(logN),所以这种最好时间为 O(N),最坏 O(NlogN)。一种是将 N 个元素以任意顺序放入树中,然后下滤非叶结点。由于非叶结点一般为  \left \lfloor N/2 \right \rfloor ,所以最坏的情况下下滤总次数为:\sum_{i=0}^{h-1}2^{i}*(h-i),h 为高度。可以知道这个值为 S=2^{h+1}-1-(h+1),而 N 的最大值:N=2^{h+1}-1,所以这种构造的时间为 O(N)。

    explicit BinaryHeap(const vector<Comparable> & items)
        : array(items.size()+10), currentSize(items.size())
    {
        for(int i=0; i<items.size(); i++)
            array[i+1] = items[i];
        buildHeap();
    }

    void buildHeap()
    {
        for(int i=currentSize/2; i>0; i--)
            percolateDown(i);
    }

d堆

d 堆与二叉堆很像,但其所有的结点都有 d 个儿子,因此二叉堆记为 2 堆。

因为有很多情形是插入比删除操作多得多,这种树就派上用场了。d 堆将 insert 操作运行时间改为 O(log_{d}N),然而对于 deleteMin 操作,因为要进行 d-1 次比较,所以时间为 O(d*log_{d}N)。而且找到儿子和父亲的乘法和除法都有个因子 d ,除非 d 是 2 的幂,不然不能通过二进制的移位来实现除法而导致运行时间急剧增加。

如下是一个 3 堆:


左式堆

由于二叉堆的合并操作是比较困难的操作,所以这是一种方便合并操作的堆。我们把任意一个结点 X 的零路径长(null path length)npl(X) 规定为从 X 到一个不具有两个儿子的结点的最短路径的长。具有 0 个或 1 个儿子结点的 npl 为 0,而 npl(NULL) = -1 ,如下为两棵树的 npl 情况。

   

可以发现,任意结点的零路径长比它的诸儿子结点的零路径长的最小值多 1。这也适用于少于两个儿子的结点,因为 null 的零路径长是 -1。

左式堆:对于堆中的每一个结点 X,左儿子的零路径长至少与右儿子的零路径长一样大。对于左式堆,X 结点的零路径长度等于右儿子的零路径长度加 1。

因为左式堆趋向于加深左路径,所以右路径应该短,事实上沿左式堆右侧的右路径确实是该堆中最短路径。否则,就会存在一条路径通过某个结点 X ,取得左儿子(可能为空)的零路径长度小于右儿子的零路径长度。

定理:在右路径上有 r 个结点的左式树必然至少有 2^{r}-1 个结点。

该定理说明,N 个结点的左式树有一条右路径最多含有 \left \lfloor log(N+1) \right \rfloor 个结点。

下面是是实现:

方法一:合并操作(merge)采用递归操作

#ifndef LeftistHeap_H
#define LeftistHeap_H

#include <queue>
using namespace std;

template<typename Comparable>
class LeftistHeap
{
public:
    explicit LeftistHeap()
    {
        root = NULL;
    }
    explicit LeftistHeap(const vector<Comparable> & items)
    {
        for(int i=0; i<items.size(); i++)
        {
            LeftistHeap *heap = new LeftistHeap();
            heap->insert(items[i]);
            que.push(heap);
        }
        buildHeap();
    }

    bool isEmpty() const
    {
        if(root == NULL)
            return true;
        else
            return false;
    }

    //C++中千万不能返回局部对象的引用,因为返回引用之前已被析构
    const Comparable & findMin() const
    {
        if(isEmpty())
            throw UnderflowException();
        return root->element;
    }

    void insert(const Comparable & x)
    {
        //插入操作变为一个结点和该堆的合并
        root = merge(new LeftNode(x), root);
    }

    //删除最小元素
    void deleteMin()
    {
        if(isEmpty())
            throw UnderflowException();

        LeftNode *oldRoot = root;
        root = merge(root->left, root->right);
        delete oldRoot;
    }
    //删除最小元素,放入 minItem 中
    void deleteMin(Comparable & minItem)
    {
        if(isEmpty())
            throw UnderflowException();

        minItem = findMin();
        deleteMin();
    }
    void makeEmpty();

    //合并 rhs堆 到本堆
    void merge(LeftistHeap & rhs)
    {
        if(this == &rhs)
            return;

        root = merge(root, rhs.root);
        rhs.root = NULL;
    }

    const LeftistHeap & operator=( const LeftistHeap & rhs);

private:
    struct LeftNode
    {
        Comparable  element;
        LeftNode *  left;
        LeftNode *  right;
        int         npl;
        LeftNode( const Comparable &theElement, LeftNode *lt = NULL,
                  LeftNode *rt = NULL, int np = 0)
            :element(theElement), left(lt), right(rt), npl(np){}
    };

    LeftNode *root;
    queue<LeftistHeap *> que;   //存放堆的队列

    LeftNode * merge(LeftNode *h1, LeftNode *h2)
    {
        if(h1 == NULL) return h2;
        if(h2 == NULL) return h1;
        if(h1->element < h2->element)
            return merge1(h1, h2);
        else
            return merge1(h2, h1);
    }

    //内部合并函数,h1->element < h2->element
    LeftNode * merge1(LeftNode *h1, LeftNode *h2)
    {
        if(h1->left == NULL)    //一个结点的堆的合并
            h1->left = h2;
        else
        {
            h1->right = merge(h1->right, h2);   //h1 左结点非空就合并右结点和 h2
            if(h1->left->npl < h1->right->npl)  //每层递归操作完成后检查该层根结点孩子的零路径长度
                swapChildren(h1);   //不符合左式堆的时候就交换左右孩子
            h1->npl = h1->right->npl + 1; //根结点 npl = min_npl(h1->left, h1->right) + 1,
                                          //但是左式堆的左结点的npl一定大于或等于右结点
        }
    }

    void swapChildren(LeftNode *t)
    {
        LeftNode *tmp = t->left;
        t->left = t->right;
        t->right = tmp;
    }
    void reclaimMemory(LeftNode *t)
    {
        if(t)
            delete t;
    }
    LeftNode * clone(LeftNode *t) const
    {
        LeftNode *node = new LeftNode(t->element,
                                      t->left, t->right, t->npl);
        return node;
    }

    void buildHeap()
    {
        while (que.size() > 1) {
            LeftistHeap *h1 = que.front();
            que.pop();
            LeftistHeap *h2 = que.front();
            que.pop();
            h1->merge(h2);
            que.push(h1);
        }
        LeftistHeap *heap = que.front();
        que.pop();
        this->merge(heap);
    }
};

#endif // LeftistHeap_H

步骤如下:

方法二:合并操作(merge)非递归

先通过合并两个堆的右路径建立一棵新的树,如下

我们可以看到右路径以排序的方式安排,且保持它们各自的左儿子不变。然后交换该路径上左式堆性质被破坏的结点的两个儿子。这就是非递归实现的方法。因为可能由于结点很多,而导致递归实现缺乏栈空间,所以该方法适合于大型数据堆合并。

递归的方法的合并操作的时间与右路径的长成正比,合并两个左式堆的时间界为 O(logN)。同理插入,删除都是 O(logN)。


斜堆

首先,斜堆是具有堆序性质的二叉树,但是没有树的结构限制。而左式堆也是具有堆序性质的二叉树,但是要求 npl(leftChild) >= npl(rightChild)。所以不用保留结点的 npl 信息。除此之外,和左式堆没有区别。斜堆的右路径可以任意长。斜堆的基本操作也是合并(merge),只不过交换孩子结点是每次递归操作后都有的。看下面的代码

    //内部合并函数,h1->element < h2->element
    LeftNode * merge1(LeftNode *h1, LeftNode *h2)
    {
        if(h1->left == NULL)    //一个结点的堆的合并
            h1->left = h2;
        else
        {
            h1->right = merge(h1->right, h2);   //h1 左结点非空就合并右结点和 h2
            swapChildren(h1);  //每层递归操作完成后就交换左右孩子
        }
    }

我们会发现,每个子树的右路径的所有结点的最大者不交换左右儿子,这是因为它的右儿子必定为空。这也是由这段代码决定的,因为我们的合并操作,总是先考虑到左孩子是否为空,为空就放在左边,所以不存在这样的结点它只有右孩子。所以右路径的最大结点要么没有孩子,要么只有左孩子。没有孩子时最后执行 “h1->left = h2” 跳出递归,有左孩子时,交换后,那么右路径最大结点变为其原来的左孩子,它是没有孩子结点的,当然也符合上面的结论。

 

斜堆的插入、删除、合并也是 O(logN)。


二项队列

虽然左式堆和斜堆都以每次操作花费 O(logN) 时间有效地支持合并、插入和 deletMin,二叉堆以每次操作花费常数时间支持插入。二项队列支持所有这三种操作,每次操作的最坏情形运行时间为 O(logN),而插入操作平均花费常数时间。

二项队列不是一颗堆序的树,而是堆序的树的集合,称为森林。这个集合中的每一棵树都是有约束的形式,他们叫做二项树。

下面定义二项树:高度为 0 的二项树是一颗单结点树;高度为 k 的二项树 B_{k} 通过将一颗二项树 B_{k-1} 附接到另一颗二项树 B_{k-1} 的根上而构成。下图显示二项树 B_{0},B_{1},B_{2},B_{3},B_{4} :

可见,二项树 B_{k} 由一个带有儿子 B_{0},B_{1},...,B_{k-1} 的根组成。高度为 k 的二项树恰好有 2^{k} 个结点,其在深度 d 处的结点数是二项系数 C_{k}^{d}

二项队列要求其集合中的二项树在任意高度上最多只有一颗,并且这些二项树是有堆序要求的。我们表示大小为 13 的优先队列可以用森林  \begin{Bmatrix} B_{3},&B_{2}, & B_{0} \end{Bmatrix} 表示。我们把这种表示写成 1101,它不仅以二进制表示了 13,而且也表示这样的事实:在上述表示中,B_{0},B_{2},B_{3} 出现,而 B_{1} 则没有出现。

如下是具有 6 个元素的优先队列 H_{1}

  • 二项队列操作

(1)最小元可以通过搜索所有树的根找出。由于最多有 log(N+1) 棵不同的树,因此最小元可以以 O(log(N+1)) 时间找到。

(2)合并操作也很容易,基本上是通过两个队列加到一起来完成的。下面通过一个例子介绍:

如下两个二项队列合并,二项队列最好是按照高度排序好的,这样更有效:

首先,令 H_{3} 是新的二项队列,由于 H_{1} 中没有高度为 0 的二项树而 H_{2} 有,因此将该二项树作为 H_{3}  的一部分。然后将两个高度为 1 的二项树相加(大根成为小根的子树)从而建立了一个高度为 2 的二项树,如下

这样,H_{3} 中将没有高度为 1 的二项树。现在存在 3 棵高度为 2 的树:我们将一颗高度为 2 的二项树放到 H_{3} 中并合并其他两个二项树。由于 H_{1},H_{2} 中没有高度为 3 的二项树,因此该二项树就成为 H_{3} 的一部分,合并结束。如下

由于总共最多存在 O(log(N+1)) 棵二项树,因此合并在最坏情形下花费时间为 O(log(N+1))。

(3)插入实际上就是特殊情形的合并,只要创建一颗单结点树并执行一次合并即可。

最坏情形运行时间为 O(logN),由于二项队列中每棵树出现的概率为 1/2,于是我们预计插入操作在两步之后终止,因此平均时间是常数。

T_{n} = \frac{1}{2}\times 1+\frac{1}{2}\times(\frac{1}{2}\times2+\frac{1}{2}\times(\frac{1}{2}\times3+\frac{1}{2}\times(\frac{1}{2}\times...)))

T_{n} = 2-n\cdot (\frac{1}{2})^{n}

(4)deleteMin 可以通过首先找出一颗具有最小根的二项树来完成。设该树为 B_{k},并令原来的优先队列为 H,我们从 H 中去除 B_{k} 形成 H^{'}。再去除 B_{k} 的根,得到一些二项树 B_{0},B_{1},...,B_{k-1},他们共同形成优先队列 H^{''}。合并 H^{'}H^{''} 即可。整个花费为 2*O(logN) 的时间。

  • 二项队列的实现

二项树的每一个结点包含数据、第一个儿子以及右兄弟。

以下为二项队列类架构及结点定义

#ifndef BINOMIALQUEUE_H
#define BINOMIALQUEUE_H

template <typename Comparable>
class BinomialQueue
{
public:
    BinomialQueue();
    BinomialQueue(const Comparable & item);
    BinomialQueue(const BinomialQueue & rhs);
    ~BinomialQueue();

    bool isEmpty() const;
    const Comparable & findMin() const;

    void insert(const Comparable & x);
    void deleteMin();
    void deleteMin(Comparable & minItem);

    void makeEmpty();
    void merge(BinomialQueue & rhs);

    const BinomialQueue & operator= (const BinomialQueue & rhs);

private:
    struct BinomialNode
    {
        Comparable element;
        BinomialNode *leftChild;
        BinomialNode *nextSibling;
        BinomialNode( const Comparable & theElement,
                      BinomialNode *lt, BinomialNode *rt)
            :element(theElement), leftChild(lt), nextSibling(rt){}
    };

    enum{ DEFAULT_TREES = 1 };

    int currentSize; // number of items in priorty queue
    vector<BinomialNode *> theTrees; // an array of tree roots

    int findMinIndex() const;
    int capacity() const;
    BinomialNode * combineTrees(BinomialNode *t1, BinomialNode *t2);
    void makeEmpty(BinomialNode *& t);
    BinomialNode * clone(BinomialNode *t) const;
};

#endif // BINOMIALQUEUE_H

合并操作只涉及到同样高度的两个二项树的合并操作,所以以下代码是合并两个同样大小的二项树程序代码:

    BinomialNode * combineTrees(BinomialNode *t1, BinomialNode *t2)
    {
        if(t2->element < t1->element)
            return combineTrees(t2, t1);
        t2->nextSibling = t1->leftChild;
        t1->leftChild = t2;
        return t1;
    }

合并两个优先队列的程序代码:

    // merge rhs into the priority queue. rhs becomes empty.
    // carry is previous step's reslut.
    void merge(BinomialQueue & rhs)
    {
        if(this == &rhs)
            return;

        currentSize += rhs.currentSize;

        if(currentSize > capacity())
        {
            int oldNumTrees = theTrees.size();
            int newNumTrees = max(theTrees.size(), rhs.theTrees.size()) + 1;
            theTrees.resize(newNumTrees);
            for(int i=oldNumTrees; i<newNumTrees; i++)
                theTrees[i] = NULL;
        }

        BinomialNode *carry = NULL;
        for(int i=0, j=1; j<=currentSize; i++, j*=2)
        {
            BinomialNode *t1 = theTrees[i];
            BinomialNode *t2 = i<rhs.theTrees.size() ? rhs.theTrees[i] : NULL;

            int whichCase = t1==NULL ? 0 : 1;
            whichCase += t2==NULL ? 0 : 2;
            whichCase += carry==NULL ? 0 : 4;
            
            switch (whichCase) {
            case 0: // no trees
            case 1: // only this
                break;
            case 2: // only rhs
                theTrees[i] = t2;
                rhs.theTrees[i] = NULL;
                break;
            case 4: // only carry
                theTrees[i] = carry;
                carry = NULL;
                break;
            case 3: // this and rhs
                carry = combineTrees(t1, t2);
                theTrees[i] = rhs.theTrees[i] = NULL;
                break;
            case 5: // this and carry
                carry = combineTrees(t1, carry);
                theTrees[i] = NULL;
                break;
            case 6: // rhs and carry
                carry = combineTrees(t2, carry);
                rhs.theTrees[i] = NULL;
                break;
            case 7: // this rhs and carry
                theTrees[i] = carry;
                carry = combineTrees(t1, t2);
                rhs.theTrees[i] = NULL;
                break;
            default:
                break;
            }
        }
        
        for(int k=0; k<rhs.theTrees.size(); k++)
            rhs.theTrees[k] = NULL;
        rhs.currentSize = 0;
    }

以下为 deleteMin 程序代码:

    void deleteMin(Comparable & minItem)
    {
        if(isEmpty())
            throw UnderflowException();
        
        int minIndex = findMinIndex();
        minItem = theTrees[minIndex]->element;
        
        BinomialNode *oldRoot = theTrees[minIndex];
        BinomialNode *deletedTree = oldRoot->leftChild;
        delete oldRoot;
        
        // construct H"
        BinomialQueue deletedQueue;
        deletedQueue.theTrees.resize(minIndex + 1);
        deletedQueue.currentSize = (1 << minIndex) - 1;
        for(int j=minIndex - 1; j>=0; j--)
        {
            deletedQueue.theTrees[j] = deletedTree;
            deletedTree = deletedTree->nextSibling;
            deletedQueue.theTrees[j]->nextSibling = NULL;
        }
        
        // construct H'
        theTrees[minIndex] = NULL;
        currentSize -= deletedQueue.currentSize + 1;
        
        merge(deletedQueue);
    }

    // find index of tree containing the smallest item in the priority queue.
    // the priority queue must not be empty.
    // return the index of tree containing the smallest item.
    int findMinIndex() const
    {
        int i;
        int minIndex;
        
        for(i=0; theTrees[i]==NULL; i++)
            ;
        
        for(minIndex=i; i<theTrees.size(); i++)
            if(theTrees[i] != NULL && 
                    theTrees[i]->element < theTrees[minIndex]->element)
                minIndex = i;
        
        return minIndex;
    }

标准库中的优先队列

在 STL 中,二叉堆是通过称为 priority_queue 的类模板实现的,该模板在标准头文件 queue 中找到。只不过 STL 实现一个最大堆而不是最小堆,于是所访问的项就是最大项。使用 greater 函数对象作为比较器可以得到最小堆。

堆中元素重复是允许的,删除也只删除一个。

以下为最大堆和最小堆的一个例程:

#include <iostream>
#include <vector>
#include <queue>
#include <functional>
#include <string>
using namespace std;

// empty the priority queue and print its contents
template <typename PriorityQueue>
void dumpContents(const string & msg, PriorityQueue & pq)
{
    cout << msg << ":" << endl;
    while (!pq.empty()) {
        cout << pq.top() << endl;
        pq.pop();
    }
}

int main()
{
    priority_queue<int> maxPQ;
    priority_queue<int, vector<int>, greater<int> > minPQ;
    
    minPQ.push(4); minPQ.push(3); minPQ.push(5);
    maxPQ.push(4); maxPQ.push(3); maxPQ.push(5);
    
    dumpContents("minPQ", minPQ); // 3 4 5
    dumpContents("maxPQ", maxPQ); // 5 4 3
    return 0;
}

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
《超市积分管理系统》该项目采用技术jsp、strust2、tomcat服务器、mysql数据库 开发工具eclipse,项目含有源码、论文、配套开发软件、软件安装教程、项目发布教程    超市会员积分管理系统主要用于实现了企业管理数据统计等。本系统结构如下:(1)网络会员管理中心界面:       会员修改密码信息模块:实现会员密码功能;       会员登陆模块:实现会员登陆功能;       会员注册模块:实现会员注册功能;       留言板模块:实现留言板留言功能(2)后台管理界面:       系统用户管理模块:实现管理员的增加、查看功能;       会员信息管理模块:实现会员信息的增加、修改、查看功能;       注册用户管理模块:实现注册用户的增加、修改、查看功能;       会员卡管理模块:实现会员卡信息的增加、查看功能;       商品销售管理模块:实现商品信息的增加、查看功能;       会员积分管理模块:实现合作公司信息的增加、查看功能;       信息统计模块:实现数据统计报表功能;       留言板模块:实现留言板信息的增加、修改、查看功能; 课程目标:    1、学会各类开发软件安装、项目导入以及项目发布,含项目源码,需求文档,配套软件等    2、该项目主要功能完善,主要用于简历项目经验丰富,以及毕业设计或者二次开发    3、提供项目源码,设计文档、数据库sql文件以及所有配套软件,按照教程即可轻松实现项目安装部署 本课程为素材版,需要实战版代码讲解教程的同学可以点击如下链接:java项目实战之电商系统全套(前台和后台)(java毕业设计ssm框架项目)https://edu.csdn.net/course/detail/25771java项目之oa办公管理系统(java毕业设计)https://edu.csdn.net/course/detail/23008java项目之hrm人事管理项目(java毕业设计)https://edu.csdn.net/course/detail/23007JavaWeb项目实战之点餐系统前台https://edu.csdn.net/course/detail/20543JavaWeb项目实战之点餐系统后台https://edu.csdn.net/course/detail/19572JavaWeb项目实战之宿舍管理系统https://edu.csdn.net/course/detail/26721JavaWeb项目实战之点餐系统全套(前台和后台)https://edu.csdn.net/course/detail/20610java项目实战之电子商城后台(java毕业设计SSM框架项目)https://edu.csdn.net/course/detail/25770java美妆商城项目|在线购书系统(java毕业设计项目ssm版)https://edu.csdn.net/course/detail/23989系统学习课程:JavaSE基础全套视频(环境搭建 面向对象 正则表达式 IO流 多线程 网络编程 java10https://edu.csdn.net/course/detail/26941Java Web从入门到电商项目实战挑战万元高薪(javaweb教程)https://edu.csdn.net/course/detail/25976其他素材版(毕业设计或课程设计)项目:点击老师头像进行相关课程学习

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值