[Java]-最短路径(Dijkstra,BellmanFord,Topological算法)

最短路径

最短路径描述的是在一幅加权有向图中,从一个顶点s到任意顶点的所有路径中权重最小的一条路径
单点最短路径问题

在一幅加权有向图中,从顶点s到任意顶点v是否存在一条有向路径,如果存在,找出权重最小的那一条有向路径

最短路径树

最短路径树描述的是在一幅加权有向图中,某一顶点s到任意顶点的最短路径组成的树,这棵树的根节点是顶点s,s到树上所有子节点的路径都是最短路径。

毫无疑问,这棵树形成的有向图是原加权有向图的一幅子图

加权有向图的数据结构

加权有向边:

package cn.ywrby.Graph;

//加权有向边

public class DirectedEdge {
    private int v;  //有向边中的指出顶点
    private int w;  //有向边的指向顶点
    private double weight;  //有向边的权重
    //初始化加权有向边
    public DirectedEdge(int v,int w,double weight){
        this.v=v;
        this.w=w;
        this.weight=weight;
    }
    public double weight(){return weight;}
    public int from(){return v;}  //指出这条边的顶点  
    public int to(){return w;}   //指向这条边的顶点
    public String toString(){
        return String.format("%d->%d  %.2f",v,w,weight);
    }
}

加权有向图:

package cn.ywrby.Graph;

import edu.princeton.cs.algs4.Bag;
import edu.princeton.cs.algs4.In;

import java.util.NoSuchElementException;

//加权有向图

public class EdgeWeightedDigraph {
    private int V;
    private int E;
    private Bag<DirectedEdge>[] adj;
    public EdgeWeightedDigraph(int V){
        this.V=V;
        this.E=0;
        adj=(Bag<DirectedEdge>[]) new Bag[V];
        for(int v=0;v<V;v++){
            adj[v]=new Bag<DirectedEdge>();
        }
    }
    public EdgeWeightedDigraph(In in){
        if(in==null) throw new IllegalArgumentException("argument is null");
        try{
            V=in.readInt();
            adj=(Bag<DirectedEdge>[]) new Bag[V];
            for(int i=0;i<V;i++){
                adj[i]=new Bag<DirectedEdge>();
            }
            int E=in.readInt();
            if(E<0)throw new IllegalArgumentException("Number of edges must be nonnegative");
            for(int i=0;i<E;i++){
                int v=in.readInt();
                int w=in.readInt();
                double weight=in.readDouble();
                DirectedEdge e=new DirectedEdge(v,w,weight);
                addEdge(e);
            }
        }
        catch(NoSuchElementException e) {
            throw new IllegalArgumentException("invalid input format in EdgeWeightedGraph constructor", e);
        }
    }
    public int V(){return V;}
    public int E(){return E;}
    public void addEdge(DirectedEdge e){
        adj[e.from()].add(e);
        E++;
    }
    public Iterable<DirectedEdge> adj(int v){return adj[v];}
    public Iterable<DirectedEdge> edges(){
        Bag<DirectedEdge> bag=new Bag<DirectedEdge>();
        for(int v=0;v<V;v++){
            for(DirectedEdge e:adj(v)){
                bag.add(e);
            }
        }
        return bag;
    }

}

最短路径API

返回值类名用法
构造函数SP(EdgeWeightedDigraph G,int s)
doubledistTo(int v)从顶点s到v的距离,如果不存在,则路径为无穷大
booleanhasPathTo(int v)是否存在从顶点s到v的路径
Iterable pathTo(int v)从顶点s到v的路径,如果不存在则为null

松弛操作

这是计算加权有向图的最短路径的基础概念

边的松弛操作

放松边v->w指的是检查从s到w是否需要先从s->v然后从v->w。这个过程实现了将N条路径(不是权重)变成了N+1条,类似于一个松弛的过程,所以称之为“松弛操作”

而决定是否经过v的依据仍然是权重大小

初始状态下:
初始时

松弛操作前:
变化前

经过松弛操作后:
变化后

在代码实现过程中,我们可以利用前面维护的distTo()方法获取从s顶点到v或者w的总的权重,通过e.weight()获取v->w边的权重,然后进行比较

边的松弛操作代码实现:
private void relax(DirectedEdge e){
    int v=e.from(),w=e.to();
    if(distTo[w]>distTo[v]+e.weight()){
        distTo[w]=e.weight()+distTo[v];
        edgeTo[w]=e;
    }
}

如果relax()成功改变了和e相关顶点的最短路径,就称e的放松是成功的

顶点的松弛

就是放松一个顶点的所有边,实现上也比较简单

private void relax(EdgeWeightedDigraph G,int v) {
    for (DirectedEdge e : G.adj(v)) {
        int w = e.to();
        if (distTo[w] > distTo[v] + e.weight()) {
            distTo[w] = e.weight() + distTo[v];
            edgeTo[w] = e;
        }
    }
}

最优性条件

命题:

G表示一幅加权有向图,顶点s是图中起点,distTo[]是一个由顶点索引的数组,内部存储的是G中路径长度,distTo[v]表示从s到v的某条路径长度,如果s到v不可达,则存储的值是无穷大。当且仅当对于从v->w的任意一条边e,都满足条件:distTo[w]<=distTo[v]+e.weight()时,这条路径是最短路径

证明:

必要性:

假设某条路径s->w是最短路径,其中有一个边e不满足条件,使得distTo[w]>distTo[v]+e.weight()。这就表明从s->w还存在更短路径,这与其是最短路径这一前提相违背,所以只要是最短路径,一定满足distTo[w]<=distTo[v]+e.weight()这一条件。

充分性:

s=v_0w=v_k。最短路径就可以表示为v_0->v_1->v_2+…+v_{k-1}->v_k。其权重为OPT_{sw}。根据必要性的结论我们可以得出下式:(e_i表示v_{i-1}v_i的边)

distTo[w]=distTo[v_k]<=distTo[v_{k-1}]+e_k.weight()
distTo[v_{k-1}]<=distTo[v_{k-2}]+e_{k-1}.weight()
distTo[v_{k-2}]<=distTo[v_{k-3}]+e_{k-2}.weight()
……
distTo[v_{2}]<=distTo[v_{1}]+e_{2}.weight()
distTo[v_{1}]<=distTo[v_{0}]+e_{1}.weight()
distTo[v_0]=distTo[s]=0.0
综上所述,可以得出:
distTo[w]<=e_1.weight()+…+e_{k}.weight()=OPT_{sw}

由于distTo[w]表示的是从s到w的某一条路径,所以它不可能比最短路径还短所以有:

OPT_{sw}<=distTo[w]<=OPT_{sw}
OPT_{sw}=distTo[w]
充分性得证:凡是满足规定的路径一定是最短路径

这为我们提供了一种检验路径是否为最有路径的重要方法,只要查找路径中是否还存在会使路径松弛的有效边就可以


通用的最短路径算法

(暂时只研究非负权重,该算法基于最优性条件得出)

命题:

将distTo[s]初始化为0,其他distTo[]元素初始化为无穷大,然后不断重复如下操作:

放松G中的任意边,直到不存在有效边为止

对于任意的s可达的顶点w,在进行如上的操作后得到的distTo[w]的值都是最短路径长度,且edgeTo[w]表示最短路径的最后一条边

证明:

初始状态下,到达每条边的距离都是无穷大,算法会持续进行,直到到达每个可达顶点的路径权重都有一个值时,继续运行算法,此时这个值只可能单调递减,因此这个递减的次数必然是有限的,也就意味着我们可以在有限次内找到这条最短路径,当不存在有效边时,我们也就找到了到各点的最短路径

Dijkstra算法(狄克斯特拉算法)

Dijkstra算法的流程是:
1. 首先将distTo[s]的值初始化为0
2. 将distTO[]中其他元素的值初始化为无穷大
3. 然后将distTo[]中最小的非树顶点(就是队列中的最小值)从pq队列中取出并松弛,然后加入树中
4. 直到所有顶点都进入树中,最小生成树就形成了

如果对于顶点s,顶点v是可达的,那么v->w的所有边都只会被放松一次。当v被放松时,必有distTo[w]<=distTo[v]+e.weight(),并且这个不等式直到算法结束恒成立,因为在后续过程中distTo[v]不会再发生变化(因为distTo[v]每次都是从队列里取出的当前队列中距离顶点s最近的顶点,不会出现比distTo[v]更小的存在),distTo[w]只会变小,不可能变大,所以不等式恒成立,也就是说对于顶点s的所有可达点,经过狄克斯特拉算法后都满足上式,最优性条件成立,所以,求得的结果就是我们在找的最短路径

package cn.ywrby.Graph;

import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.IndexMinPQ;
import edu.princeton.cs.algs4.Stack;
import edu.princeton.cs.algs4.StdOut;


//最短路径的狄克斯特拉算法
//整个算法逻辑非常严密,但十分抽象
//最简单的联想方式就是将它与之前学过的Prim算法相比较
//Prim算法适用于加权无向图,每次将横切边权重最小的边加入树中
//Dijkstra算法每次将队列中距树的根节点s权重最小的边加入树中
//两个算法都是每次增加一个顶点直到所有顶点都进入树中


public class DijkstraSP {
    private DirectedEdge[] edgeTo;  //用来保存指向v的最短路径的最后一条边
    private double[] distTo;   //顶点v距根节点对于顶点s的权重
    private IndexMinPQ<Double> pq;  //优先索引队列存放还没有进入树中,但已经在循环过程中发现的节点

    //狄克斯特拉算法的实现
    public DijkstraSP(EdgeWeightedDigraph G,int s){
        edgeTo=new DirectedEdge[G.V()];
        distTo=new double[G.V()];
        pq=new IndexMinPQ<Double>(G.V());
        //所有初值设为无穷大
        for(int v=0;v<G.V();v++){
            distTo[v]=Double.POSITIVE_INFINITY;
        }
        //从s到s的距离设为0
        distTo[s]=0.0;
        pq.insert(s,0.0);  //将s插入队列中
        //对队列中的顶点进行松弛
        while (!pq.isEmpty()){
            //每次都从队列中取出距顶点s权重最小的顶点进行松弛
            relax(G,pq.delMin());
        }
    }

    //松弛操作的实现
    private void relax(EdgeWeightedDigraph G,int v) {
        //遍历顶点v的所有边进行松弛
        for (DirectedEdge e : G.adj(v)) {
            int w = e.to();  //有向边指向的顶点
            //如果这个v->w边需要松弛,就进行松弛,并对必要的值进行更新
            if (distTo[w] > distTo[v] + e.weight()) {
                distTo[w] = e.weight() + distTo[v];
                edgeTo[w] = e;

                //如果w顶点已经在队列中,此时就要将它的优先级下调
                // (更快的出队,因为每次弹出的都是优先级最小的)
                //具体实现方法就是利用change()函数修改它在队列中的值
                if(pq.contains(w)) pq.change(w,distTo[w]);
                //如果不再队列中,就将这个顶点插入队列
                else pq.insert(w,distTo[w]);
            }
            //如果不需要松弛就继续for循环直到结束
        }
    }
    //返回对应的distTo项
    public double distTo(int v){return distTo[v];}
    //是否有从顶点s到顶点v的路线
    public boolean hasPathTo(int v){return distTo(v)!=Double.POSITIVE_INFINITY;}
    //返回从顶点s到v的路径
    public Iterable<DirectedEdge> pathTo(int v){
        if(!hasPathTo(v)) return null;
        Stack<DirectedEdge> path=new Stack<DirectedEdge>();
        for(DirectedEdge e=edgeTo[v];e!=null;e=edgeTo[e.from()]){
            path.push(e);
        }
        return path;
    }

    public static void main(String[] args) {
        EdgeWeightedDigraph G;
        G=new EdgeWeightedDigraph(new In(args[0]));
        int s=Integer.parseInt(args[1]);
        DijkstraSP SP=new DijkstraSP(G,s);
        for(int v=0;v<G.V();v++){
            StdOut.print(s+" to "+v);
            StdOut.printf(" (%4.2f): ",SP.distTo(v));
            if(SP.hasPathTo(v)){
                for(DirectedEdge e:SP.pathTo(v)){
                    StdOut.print(e+"  ");
                }
            }
            StdOut.println();
        }

    }
}
任意顶点之间的最短路径问题

给定一个顶点s,与另一个顶点t,s是否可达t,最短路径长度又是多少

package cn.ywrby.Graph;


//利用狄克斯特拉算法查找任意顶点之间的最短路径

public class DijkstraAllPairsSP {
    private DijkstraSP[] all;  //创建数组存放所有Dijkstra个体
    public DijkstraAllPairsSP(EdgeWeightedDigraph G){
        all=new DijkstraSP[G.V()];
        //逐个将其加入数组中
        for(int v=0;v<G.V();v++){ 
            all[v]=new DijkstraSP(G,v);
        }
    }
    //s是否可达t,如果可达,最短路径是什么
    Iterable<DirectedEdge> path(int s,int t){
        return all[s].pathTo(t);
    }
    //s与t是否可达,最短路径的权重是多少
    double dist(int s,int t){
        return all[s].distTo(t);
    }
}

无环加权有向图的最短路径算法

一般情况中,很多有向图都是单向的,也就是无环的,而对于无环加权有向图,解决起来,有相较于狄克斯特拉算法更简单的实现

这种实现利用到了拓扑排序,所以前提必须是无环的,首先对图进行拓扑排序,然后按照拓扑排序的顺序进行放松操作并加入树中,至所有顶点都进入树中就实现了最短路径的构建

代码实现:
package cn.ywrby.Graph;


//无环加权有向图的最短路径算法


import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.Stack;
import edu.princeton.cs.algs4.StdOut;

public class AcycliSP {

    private DirectedEdge[] edgeTo;
    private double[] distTo;
    public AcycliSP(EdgeWeightedDigraph G,int s){
        edgeTo=new DirectedEdge[G.V()];
        distTo=new double[G.V()];
        //所有初值设为无穷大
        for(int v=0;v<G.V();v++){
            distTo[v]=Double.POSITIVE_INFINITY;
        }
        //从s到s的距离设为0
        distTo[s]=0.0;

        Topological top=new Topological(G);
        for (int v:top.order()){
            relax(G,v);
        }
    }
    //松弛操作的实现
    private void relax(EdgeWeightedDigraph G,int v) {
        //遍历顶点v的所有边进行松弛
        for (DirectedEdge e : G.adj(v)) {
            int w = e.to();  //有向边指向的顶点
            //如果这个v->w边需要松弛,就进行松弛,并对必要的值进行更新
            if (distTo[w] > distTo[v] + e.weight()) {
                distTo[w] = e.weight() + distTo[v];
                edgeTo[w] = e;
            }
        }
    }
    //返回对应的distTo项
    public double distTo(int v){return distTo[v];}
    //是否有从顶点s到顶点v的路线
    public boolean hasPathTo(int v){return distTo(v)!=Double.POSITIVE_INFINITY;}
    //返回从顶点s到v的路径
    public Iterable<DirectedEdge> pathTo(int v){
        if(!hasPathTo(v)) return null;
        Stack<DirectedEdge> path=new Stack<DirectedEdge>();
        for(DirectedEdge e = edgeTo[v]; e!=null; e=edgeTo[e.from()]){
            path.push(e);
        }
        return path;
    }
    public static void main(String[] args) {
        EdgeWeightedDigraph G;
        G=new EdgeWeightedDigraph(new In(args[0]));
        int s=Integer.parseInt(args[1]);
        AcycliSP SP=new AcycliSP(G,s);
        for(int v=0;v<G.V();v++){
            StdOut.print(s+" to "+v);
            StdOut.printf(" (%4.2f): ",SP.distTo(v));
            if(SP.hasPathTo(v)){
                for(DirectedEdge e:SP.pathTo(v)){
                    StdOut.print(e+"  ");
                }
            }
            StdOut.println();
        }

    }
}

按照拓扑排序后得到的顺序进行放松操作同样保证了最短路径的最优性条件成立,因为distTo[v]不可能再被指向(它是拓扑排序中靠前的顶点,不可能被靠后的顶点指向),而distTo[w]只有变小的可能,所以最终结果就是我们需要的最短路径。

算法耗时取决于拓扑排序,与E+V成正比

一般加权有向图中的最短路径问题

一个定义明确且可以解决加权有向图最短路径问题的算法要能够:
  • 对于从起点不可达的顶点,最短路径为正无穷
  • 对于从起点可达但路径上有负权重环的顶点,最短路径为负无穷
  • 对于其他所有顶点,计算最短路径权重

负权重环:

即一个总权重和为负的环。这种结构存在于v->w的路径中会使得我们定义的最短路径概念失去意义。因为只需要不断在这个环中循环就可以达到负无穷,也就导致无法计算最短路径。

负权重环

权重之和为-2.5,权重为负,是负权重环

所以我们在设计算法时,要想办法对这种环进行处理。避免它们的存在干扰到最短路径计算

当且仅当加权有向图中至少存在一条从s到v的路径,且所有路径上均没有负权重环时,s到v的最短路径才是存在的

负权重环不可达时的单点最短路径问题:

如何检测负权重环我们稍后会说明,此处先讨论已经避免了负权重环存在的情况下,如何在一个一般的加权有向图中找到最短路径

Bellman-Ford算法(贝尔曼福德算法)

在任意含有V个顶点的加权有向图中给定起点s,从s无法到达任何负权重环。将distTo[s]初始化为0,其他distTo[]初始化为无穷大,以任意顺序,放松有向图的所有边,重复V轮

证明:

显然,从顶点s到到顶点t,必然存在一条没有负权重环的最短路径,写作:v_0->v_1->…->v_{k-1}->v_k。其中v_0=s,v_k=t

通过归纳假设法不难证明,当i=0时,整个图中可达的只有s一个顶点,只需要一次放松就实现了最短路径。

假设对于i命题成立,则有s到v的最短路径:v_0->v_1->…->v_i,distTo[v_i]就是这条路径的长度,在第i轮中,我们放松了所有顶点,包括v_i,所以distTo[v_{i+1}]<=distTo[v_i]+(v_i->v_{i+1}).weight()。在第i+1轮中,distTo[v_{i+1}]=distTo[v_i]+(v_i->v_{i+1}).weight()。它不可能更大,因为第i轮内放松了包括v_i的所有顶点,v_i已经是最小的,而边的长度不会变,所以它不会变大,它也不可能更小,因为它的路径一定是v_0->v_1->…->v_i->v_{i+1}就是最短路径,由此可见,在i+1轮后算法能够得到s到v_{i+1}的最短路径。

综上,归纳假设法成立,所以结论对所有符合条件的有向图都成立

基于队列的贝尔曼福德算法:

根据经验我们很清楚并不是所有边在下一轮都会被成功放松,能够在下一轮被放松的,只有这一轮中distTo[]值发生变化的顶点所指向的顶点,所以我们利用FIFO队列(先进先出队列)来储存这些顶点,并在下一轮只对这些顶点进行放松

代码实现:

package cn.ywrby.Graph;

import cn.ywrby.dataStructure.Queue;
import edu.princeton.cs.algs4.In;
import edu.princeton.cs.algs4.Stack;
import edu.princeton.cs.algs4.StdOut;

//Bellman-Ford算法(贝尔曼福德算法)解决一般条件下的最短路径问题
//基于队列实现
//允许负权重的存在
//允许负权重环存在(增加负权重环的检测,出现后停止循环)



public class BellmanFordSP {
    private double[] distTo;  //distTo[v]表示起点s到顶点v的权重
    private DirectedEdge[] edgeTo;  //edgeTo[v]记录了从起点s到顶点v的路径上最后一条有向边
    private boolean[] onQ;  //检测顶点是否在队列中
    private Queue<Integer> queue;   //下一轮要进行放松的顶点
    private int cost;   //调用relax函数的次数,本质是用来记录循环次数
    private Iterable<DirectedEdge> cycle;   //存放可能存在的负权重环,用来发现问题


    //构造函数
    public BellmanFordSP(EdgeWeightedDigraph G,int s){
        //初始化各个变量
        distTo=new double[G.V()];
        edgeTo=new DirectedEdge[G.V()];
        onQ=new boolean[G.V()];
        queue=new Queue<Integer>();
        //初始距起点权重均设置为无穷大
        for(int v=0;v<G.V();v++){
            distTo[v]=Double.POSITIVE_INFINITY;
        }
        distTo[s]=0.0;
        queue.enqueue(s);   //起点入队
        onQ[s]=true;   //设置起点在队列中

        //循环结束条件:队列为空(成功生成最小生成树)
        //或出现负权重环,提前结束循环,检查出错位置
        while(!queue.isEmpty()&&!hasNegativeCycle()){
            int v=queue.dequeue();
            //按队列顺序出队(不需要遵循其他特殊原则,BellmanFord算法不在乎放松的顺序)
            onQ[v]=false;  //更改布尔数组,已出队
            relax(G,v);  //放松对应顶点
        }
    }

    //贝尔曼福德算法的放松操作
    private void relax(EdgeWeightedDigraph G,int s){
        //遍历顶点的所有边
        for(DirectedEdge e:G.adj(s)){
            int w=e.to();  //指向的顶点
            if(distTo[w]>distTo[s]+e.weight()){
                distTo[w]=distTo[s]+e.weight();
                edgeTo[w]=e;

                //如果释放的这条边指向的顶点不再队列中
                //就更新队列,将其加入,以在下一轮中放松
                if(!onQ[w]){
                    queue.enqueue(w);
                    onQ[w]=true;
                }
            }
            //如果余数为0,表示经过了一轮的放松
            //开始检查是否存在负权重环
            if(cost++%G.V()==0){
                findNegativeCycle();
            }
        }

    }
    //返回对应的distTo项
    public double distTo(int v){return distTo[v];}
    //是否有从顶点s到顶点v的路线
    public boolean hasPathTo(int v){return distTo(v)!=Double.POSITIVE_INFINITY;}
    //返回从顶点s到v的路径
    public Iterable<DirectedEdge> pathTo(int v){
        if(!hasPathTo(v)) return null;
        Stack<DirectedEdge> path=new Stack<DirectedEdge>();
        for(DirectedEdge e=edgeTo[v];e!=null;e=edgeTo[e.from()]){
            path.push(e);
        }
        return path;
    }



    //查找负权重环
    private void findNegativeCycle(){
        int V=edgeTo.length;  //获取顶点数量
        EdgeWeightedDigraph spt;
        //spt变量是用来重新构造一幅子图的
        //这副子图包含了,经过一轮放松后从顶点s到一部分顶点的路径
        spt=new EdgeWeightedDigraph(V);
        for(int v=0;v<V;v++){
            if(edgeTo[v]!=null){
                spt.addEdge(edgeTo[v]);
            }
        }
        EdgeWeightedCycleFinder cf;  //针对加权有向图检查有无环结构的类
        cf=new EdgeWeightedCycleFinder(spt);
        cycle=cf.cycle();  //将环赋给cycle变量,如果不存在返回的是null
    }
    public boolean hasNegativeCycle(){return cycle!=null;}
    public Iterable<DirectedEdge> negativeCycle(){return cycle;}

    public static void main(String[] args) {
        EdgeWeightedDigraph G;
        G=new EdgeWeightedDigraph(new In(args[0]));
        int s=Integer.parseInt(args[1]);
        BellmanFordSP SP=new BellmanFordSP(G,s);
        for(int v=0;v<G.V();v++){
            StdOut.print(s+" to "+v);
            StdOut.printf(" (%4.2f): ",SP.distTo(v));
            if(SP.hasPathTo(v)){
                for(DirectedEdge e:SP.pathTo(v)){
                    StdOut.print(e+"  ");
                }
            }
            StdOut.println();
        }
    }
}

最短路径算法的性能特点:

算法局限路径长度比较次数
(数量级)一般情况
路径长度比较次数
(数量级)最坏情况
所需空间优势
Dijkstra算法(即时版本)边的权重必须为正ElogVElogVV最坏情况下仍有较好的性能
拓扑排序只适用于无环加权有向图E+VE+VV是无环图中的最优算法
BellmFord算法(基于队列)不能存在负权重环E+VVEV适用领域广泛
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值