正则表达式的等价判定

编译原理的两个课程设计之一,关于两个正则表达式是否等价的问题。题目描述及提交地址:http://soj.me/show_problem.php?pid=1000&cid=866,大概内容如下:

Description

两个正则表达式等价,是指两个表达式描述完全相同的语言,即正则表达式expr1和expr2等价,当且仅当L(expr1)=L(expr2)。编写判断两个正则表达式是否等价的程序。

Input

有多组输入数据. 每组数据有两个正则表达式expr1和expr2,每个正则表达式占一行. expr1和expr2仅含有字符a,b, c, d, e, |, (, ), *, +, ?,其中a, b, c, d代表相应的字符(也即我们考虑的语言定义在字母表 ={a, b, c, d}上),而e代表空串є,其它符号的意义和“龙书”一致. expr1和expr2的长度不超过80,且均保证是合法表达式. 输入以一个'#'号结束.

Output

对于每组数据,如果expr1和expr2等价,则输出“YES”;否则,输出“NO”.


对于这个项目,复习相关知识用了一天半,敲代码用了一天,调试用了半天,前后大概三天可以完成。作业提交截止后,撰写此文供日后自己复习和回顾,供他人参考和点评。

需要注意的是,本人参考的书籍为《编译原理》(即“龙书”)第二版中文翻译版,代码有前后三个版本:alpha(有调试和跟踪信息),beta(能通过SOJ但没注释),gamma(有注释和过程输出),若想看初始代码请查看alpha版本,若想只求AC请查看beta版本,若想了解算法请查看gamma版本。本文使用的代码为gamma版本,而作为解释说明的版本,代码并不能够直接在Sicily上AC通过,也就是不会严格符合文章开始时所描述的大概内容。

书籍下载:http://download.csdn.net/detail/ederick/5043025

代码下载:http://download.csdn.net/detail/ederick/5043904

另外参考:http://mcs.sysu.edu.cn/user/chenzz/Article_1591

最后感谢王建同学和李晓潮同学的帮助,帮我解答了一些疑问以及提供了一些典型例子,避免了很多麻烦,多谢!

一些技巧和细节会在下文中说明,测试样例也会放出,请留意找寻。


下面开始程序流程的介绍:

要求是判断两个正则表达式是否等价,总的步骤可以划分为四部分:

1.正则表达式转换成NFA;
2.NFA转换成DFA;
3.DFA最小化;
4.判断两个最小化DFA是否等价。

各个部分大概的流程如下所示:

A. 正则表达式转换成NFA:

这一部分的代码实现基本上遵循龙书上第3.7.4章节中的算法3.23(P100),即McMaughton-Yamada-Thompson算法。子NFA的构造方法如书上所说,这里不再熬述。

需要说明的是,个人修改过其中的的一个部分,即把归约规则中的r = st,并没有按书上所说把N(s)的接受状态和N(t)的开始状态合并,而是在两者之间通过ε转换来连接。另外,对于书上没有描述的?和+也自行构造了NFA的模版。还有的是,本人也没有对正则表达式先进行后缀表示的转换,而是直接对其进行分析并构造NFA。在这里,r = st会当作r = s & t来处理。

基本思路是,创建两个栈分别用于存储操作符和子NFA,然后从头到尾开始遍历正则表达式。对于当前处理的字符,作以下判断和处理:

Ø 如果是字母,并且如果前一字符是abcde)*+?中的一个,进行如下操作:a.如果栈顶操作符是&,执行catNFA();否则跳过此步骤b.压入操作符&构造当前字母的NFA并压栈;

Ø 如果是操作符|,当栈非空并且栈顶不是&时,不断把栈顶的|或者&操作弹出来,并执行相应的orNFA()或者catNFA();最后压入|操作符;

Ø 如果是操作符?、*、+,则直接执行相应的queNFA()、starNFA()、plusNFA();

Ø 如果是(,并且如果前一字符是abcde)*+?中的一个,进行如下操作:a.如果栈顶操作符是&,执行catNFA();否则跳过此步骤b.压入操作符&最后将(压栈;

Ø 如果是),当栈顶不是(时,不断把栈顶的|或者&操作弹出来,并执行相应的orNFA()或者catNFA();最后把栈顶的(弹出。

说明一下,queNFA()、starNFA()、plusNFA()需要一个子NFA,而orNFA()、catNFA()需要两个子NFA。对于以上操作,是基于操作符的优先级顺序:(>*= + = ? >& >|>)的进行的。

    这一部分的代码如下:

/*********************正则转NFA**********************************************/
unsigned N, M;					//分别表示NFA和DFA的状态数
string s;						//正则表达式
string sub( "abcde)*+?" );		//正则表达式中需要构造子NFA的字符
stack<char> sign;				//存储操作符
stack< pair<int, int> > nfa;	//存储子NFA
vector<int> NFA[ 160 ][ 5 ];	//NFA的状态转移表

/*对于是字母(a、b、c、d、e)的NFA构造方法*/
void alpNFA( int i ) {
	pair<int, int> r;
	r.first = N++;
	r.second = N++;
	NFA[ r.first ][ i ].push_back( r.second );
	nfa.push( r );
}

/*对于是|的NFA构造方法*/
void orNFA() {
	sign.pop();
	pair<int, int> r;
	r.first = N++;
	r.second = N++;
	pair<int, int> s = nfa.top();
	nfa.pop();
	pair<int, int> t = nfa.top();
	nfa.pop();
	NFA[ r.first ][ 4 ].push_back( s.first );
	NFA[ r.first ][ 4 ].push_back( t.first );
	NFA[ s.second ][ 4 ].push_back( r.second );
	NFA[ t.second ][ 4 ].push_back( r.second );
	nfa.push( r );
}

/*对于是?的NFA构造方法*/
void queNFA() {
	pair<int, int> r;
	r.first = N++;
	r.second = N++;
	pair<int, int> s = nfa.top();
	nfa.pop();
	NFA[ r.first ][ 4 ].push_back( s.first );
	NFA[ r.first ][ 4 ].push_back( r.second );
	NFA[ s.second ][ 4 ].push_back( r.second );
	nfa.push( r );
}

/*对于是&的NFA构造方法*/
void catNFA() {
	sign.pop();
	pair<int, int> t = nfa.top();
	nfa.pop();
	pair<int, int> s = nfa.top();
	nfa.pop();
	pair<int, int> r( s.first, t.second );
	NFA[ s.second ][ 4 ].push_back( t.first );
	nfa.push( r );
}

/*对于是*的NFA构造方法*/
void starNFA() {
	pair<int, int> r;
	r.first = N++;
	r.second = N++;
	pair<int, int> s = nfa.top();
	nfa.pop();
	NFA[ r.first ][ 4 ].push_back( s.first );
	NFA[ r.first ][ 4 ].push_back( r.second );
	NFA[ s.second ][ 4 ].push_back( s.first );
	NFA[ s.second ][ 4 ].push_back( r.second );
	nfa.push( r );
}

/*对于是+的NFA构造方法*/
void plusNFA() {
	pair<int, int> r;
	r.first = N++;
	r.second = N++;
	pair<int, int> s = nfa.top();
	nfa.pop();
	NFA[ r.first ][ 4 ].push_back( s.first );
	NFA[ s.second ][ 4 ].push_back( s.first );
	NFA[ s.second ][ 4 ].push_back( r.second );
	nfa.push( r );
}

/*初始化NFA相关状态*/
void resetNFA() {
	N = 0;
	for ( int i = 0; i < 160; i++ ) {
		for ( int j = 0; j < 5; j++ )
			NFA[ i ][ j ].clear();
	}
	while ( !sign.empty() )
		sign.pop();
	while ( !nfa.empty() )
		nfa.pop();
}

/*正则表达式转NFA*/
void RE2NFA() {
	resetNFA();
	/*遍历字符串,对相关字符进行对应操作*/
	for ( unsigned i = 0; i < s.size(); i++ ) {
		if ( isalpha( s[ i ] ) ) {
			/*判断是否需要加上操作符&*/
			if ( i != 0 && sub.find( s[ i - 1 ] ) != string::npos ) {
				if ( !sign.empty() && sign.top() == '&' )
					catNFA();
				sign.push( '&' );
			}
			alpNFA( s[ i ] - 'a' );
		}
		else if ( s[ i ] == '|' ) {
			while ( !sign.empty() && sign.top() != '(' ) {
				if ( sign.top() == '|' )
					orNFA();
				else if ( sign.top() == '&' )
					catNFA();
			}
			sign.push( '|' );
		}
		else if ( s[ i ] == '?' )
			queNFA();
		else if ( s[ i ] == '*' )
			starNFA();
		else if ( s[ i ] == '+' )
			plusNFA();
		else if ( s[ i ] == '(' ) {
			/*判断是否需要加上操作符&*/
			if ( i != 0 && sub.find( s[ i - 1 ] ) != string::npos ) {
				if ( !sign.empty() && sign.top() == '&' )
					catNFA();
				sign.push( '&' );
			}
			sign.push( '(' );
		}
		else if ( s[ i ] == ')' ) {
			while ( sign.top() != '(' ) {
				if ( sign.top() == '|' )
					orNFA();
				else if ( sign.top() == '&' )
					catNFA();
			}
			sign.pop();
		}
	}

	/*清空操作符栈,获取最终NFA*/
	while ( !sign.empty() ) {
		if ( sign.top() == '|' )
			orNFA();
		else if ( sign.top() == '&' )
			catNFA();
	}
}

/*输出NFA的各项数据*/
void showNFA() {
    cout << "**********第一步:NFA各项数据如下***************\n";
    pair<int, int> r = nfa.top();
    cout << "状态数:" << N << "\t";
    cout << "开始状态:" << r.first << "\t" << "接受状态:" << r.second << endl;
    cout << "\ta\tb\tc\td\tε\n";
    for ( unsigned i = 0; i < N; i++ ) {
        cout << i << ":\t";
        for ( unsigned j = 0; j < 5; j++ ) {
			sort( NFA[ i ][ j ].begin(), NFA[ i ][ j ].end() );
            cout << "{";
            for ( unsigned k = 0; k < NFA[ i ][ j ].size(); k++ ) {
                cout << NFA[ i ][ j ][ k ];
				if ( k + 1 != NFA[ i ][ j ].size() )
					cout << ",";
			}
            cout << "}\t";
        }
        cout << endl;
    }
}

这里面有几个技巧和细节,罗列如下:

    1.返回值采用pair<int,int>的形式是为了便于获取NFA的开始状态和接受状态;
    2.NFA的数组开到160,是因为每个符号最多构造2个状态,80个字符就是160个状态;
    3.?和+操作符的NFA构造可以等价于a?=(a|e),a+=aa*就可以很方便地得到了;
    4.sub字符串的存在是为了便于判断是否需要添加&进操作符栈。

B. NFA转换成DFA

基本上如同龙书算法3.20(P97)的描述一样,通过子集构造算法,我们可以从一个NFA中构造出相应的DFA。这一部分按照书上的内容可以按部就班完成,如下:


    其中的函数意义如下:


    较为重要的计算e-closure(T)的方法如下:


    D的开始状态是e-closure(S0),接受状态是所有至少包含了N的一个接受状态的状态集合。

    这一部分的代码如下:

/*********************NFA转DFA************************************************/
map< vector<int>, int > trans;		//NFA的状态和DFA的状态对应关系
vector<int> acc;					//DFA中的接受状态
int DFA[ 1600 ][ 5 ];				//DFA的状态转换表

/*计算e-closure(T)*/
vector<int> eClosure( vector<int> T ) {
	stack<int> s;
	vector<int>::iterator it;
	int t;
	for ( it = T.begin(); it != T.end(); it++ )
		s.push( *it );
	while ( !s.empty() ) {
		t = s.top();
		s.pop();
		for ( it = NFA[ t ][ 4 ].begin(); it != NFA[ t ][ 4 ].end(); it++ ) {
			if ( find( T.begin(), T.end(), *it ) == T.end() ) {
				T.push_back( *it );
				s.push( *it );
			}
		}
	}
	sort( T.begin(), T.end() );
	return T;
}

/*计算move(T,a)*/
vector<int> move( vector<int> T, int a ) {
    vector<int>::iterator it1, it2;
    vector<int> U;
    for ( it1 = T.begin(); it1 != T.end(); it1++ ) {
        for ( it2 = NFA[ *it1 ][ a ].begin(); it2 != NFA[ *it1 ][ a ].end(); it2++ ) {
            if ( find( U.begin(), U.end(), *it2 ) == U.end() )
                U.push_back( *it2 );
        }
    }
	return U;
}

/*初始化DFA数据*/
void resetDFA() {
	M = 0;
	trans.clear();
	acc.clear();
}

/*把NFA转换成DFA*/
void NFA2DFA() {
	resetDFA();
	pair<int, int> r = nfa.top();
	vector<int> T, U;
	T.push_back( r.first );
	U = eClosure( T );

	vector< vector<int> > Dstates;
	stack< vector<int> > s;
	Dstates.push_back( U );
	s.push( U );
	trans[ U ] = M++;
	if ( find( U.begin(), U.end(), r.second ) != U.end() )
		acc.push_back( M - 1 );
	while ( !s.empty() ) {
		T = s.top();
		s.pop();
		for ( int a = 0; a < 4; a++ ) {
			U = eClosure( move( T, a ) );
			if ( find( Dstates.begin(), Dstates.end(), U ) == Dstates.end() ) {
				Dstates.push_back( U );
				s.push( U );
				trans[ U ] = M++;
				if ( find( U.begin(), U.end(), r.second ) != U.end() )
					acc.push_back( M - 1 );
			}
			DFA[ trans[ T ] ][ a ] = trans[ U ];
		}
	}
}

/*输出DFA的各项数据*/
void showDFA() {
    cout << "**********第二步:DFA各项数据如下***************\n";
    cout << "状态数:" << M << "\t";
    cout << "开始状态:" << 0 << "\t" << "接受状态:";
    for ( unsigned i = 0; i < acc.size(); i++ )
        cout << acc[ i ] << " ";
    cout << endl;
    cout << "\ta\tb\tc\td\n";
    for ( unsigned i = 0; i < M; i++ ) {
        cout << i << ":\t";
        for ( int j = 0; j < 4; j++ )
            cout << DFA[ i ][ j ] << "\t";
        cout << endl;
    }
}

技巧和细节说明如下:

    1.DFA的状态上限设为1600没什么特别的原因,只是开大点以防内存溢出而已;
    2.
选用vector是因为状态数不确定,所以开数组不方便;而又不需要list的快速插入删 除功能,综合权衡之下采取vector来存储数据;
    3.
可以留意到计算闭包后对vector进行了排序,这是为了后面计算得出来的vector能够有序,确保map中键值的比较正确,以及方便输出DFA状态转移表;
    4.
采用map是为了NFADFA中的状态得以对应,其中mapvector有很好的包容性,所以操作符[]使用起来相当方便;

C. DFA最小化

算法原则是基于龙书上算法3.39(P115),关键的分组部分如下:

代码如下:

/*********************DFA最小化***********************************************/
int groups[ 1600 ][ 5 ];			//输入符号状态转移到的分组
bool included[ 1600 ];				//状态的分组去向是否已确定
vector< vector<int> > PI, nPI;		//原分组和新分组
int L;								//最小化DFA的状态数
int MAP[ 1600 ];					//DFA和最小化DFA的状态对应关系
int MIN[ 1600 ][ 5 ];				//最小化DFA的状态转移表
int start;							//最小化DFA的开始状态
vector<int> fin;					//最小化DFA的接受状态

/*对状态划入分组*/
void grouping( int n ) {
	unsigned i, j, k;
	int t;
	vector<int> v;

	for ( i = 0; i < PI[ n ].size(); i++ ) {
		for ( j = 0; j < 4; j++ ) {
			t = DFA[ PI[ n ][ i ] ][ j ];
			for ( k = 0; k < PI.size(); k++ ) {
				if ( find( PI[ k ].begin(), PI[ k ].end(), t ) != PI[ k ].end() ) {
					groups[ PI[ n ][ i ] ][ j ] = k;
					break;
				}
			}
		}
	}

	for ( i = 0; i < PI[ n ].size(); i++ ) {
		if ( !included[ PI[ n ][ i ] ] ) {
			v.clear();
			v.push_back( PI[ n ][ i ] );
			included[ PI[ n ][ i ] ] = true;
			for ( j = i + 1; j < PI[ n ].size(); j++ ) {
				if ( !included[ PI[ n ][ j ] ] ) {
					for ( k = 0; k < 4; k++ ) {
						if ( groups[ PI[ n ][ i ] ][ k ] != groups[ PI[ n ][ j ] ][ k ] )
							break;
					}
					/*判断两个状态是否划入了同一个分组*/
					if ( k == 4 ) {
						v.push_back( PI[ n ][ j ] );
						included[ PI[ n ][ j ] ] = true;
					}
				}
			}
			nPI.push_back( v );
		}
	}
}

/*初始化最小化DFA的各项数据*/
void resetMIN() {
	unsigned i, j;
	vector<int> v1, v2;
	for ( i = 0; i < M; i++ ) {
		if ( find( acc.begin(), acc.end(), i ) == acc.end() )
			v1.push_back( i );
		else
			v2.push_back( i );
	}
	PI.clear();
	nPI.clear();
	nPI.push_back( v1 );
	nPI.push_back( v2 );
}

/*DFA转化成最小化DFA*/
void DFA2MIN() {
	resetMIN();
	unsigned i, j;
	/*细分分组*/
	memset( groups, 0, sizeof( groups ) );
	while ( PI.size() != nPI.size() ) {
		PI = nPI;
		nPI.clear();
		memset( included, false, sizeof( included ) );
		for ( i = 0; i < PI.size(); i++ )
			grouping( i );
	}

	/*确定状态对应关系*/
	L = 0;
	for ( i = 0; i < PI.size(); i++ ) {
		for ( j = 0; j < PI[ i ].size(); j++ ) {
			MAP[ PI[ i ][ j ] ] = L;
			if ( PI[ i ][ j ] == 0 )
				start = L;
		}
		L++;
	}

	/*获取接受状态*/
	fin.clear();
	for ( i = 0; i < acc.size(); i++ ) {
		if ( find( fin.begin(), fin.end(), MAP[ acc[ i ] ] ) == fin.end() )
			fin.push_back( MAP[ acc[ i ] ] );
	}
	sort( fin.begin(), fin.end() );

	/*构造状态转移表*/
	memset( included, false, sizeof( included ) );
	for ( i = 0; i < M; i++ ) {
		if ( !included[ MAP[ i ] ] ) {
			included[ MAP[ i ] ] = true;
			for ( j = 0; j < 4; j++ )
				MIN[ MAP[ i ] ][ j ] = MAP[ DFA[ i ][ j ] ];
		}
	}
}

/*输出最小化DFA*/
void showMIN( int nMIN[ 2000 ][ 5 ], int nstart, vector<int> nfin, int nL ) {
    cout << "**********第三步:MIN各项数据如下***************\n";
    cout << "状态数:" << nL << "\t";
    cout << "开始状态:" << nstart << "\t" << "接受状态:";
    for ( vector<int>::iterator it = nfin.begin(); it != nfin.end(); it++ )
        cout << *it << " ";
    cout << endl;
    cout << "\ta\tb\tc\td\n";
    for ( int i = 0; i < nL; i++ ) {
        cout << i << ":\t";
        for ( int j = 0; j < 4; j++ )
            cout << nMIN[ i ][ j ] << "\t";
        cout << endl;
    }
}

D. 判断两个最小化DFA是否等价

基本思路是通过对两个最小化DFA进行同步DFS遍历,而遍历的原则是:

对于两者访问的下一个节点:

1.都未被访问,则访问,进入深一层的遍历;
2.都已访问,改变字符输入寻求下一个未被访问的节点。

需要注意的是,当两者在遍历的过程中,碰到以下情况时:

1.其中一个到达了接受状态而另一个还处于非接受状态;
2.两者即将访问的下一个节点的深度不一样(都没被访问的话视作深度相同);

可以认为两个最小化DFA不是等价的。如果两者成功同步访问完所有的节点,即遍历过程中没有碰到以上的两种情况,就可以认为两个最小化DFA是等价的。

代码如下:

/*********************正则转最小DFA及比较*************************************/
int P, Q;									//两个最小化DFA的状态数
int MIN1[ 1600 ][ 5 ], MIN2[ 1600 ][ 5 ];	//两个最小化DFA的状态转移表
int start1, start2;							//两个最小化DFA的开始状态
vector<int> fin1, fin2;						//两个最小化DFA的结束状态
int deep;									//同步遍历时的深度
int vis1[ 1600 ], vis2[ 1600 ];				//两个最小化DFA在同步遍历时各个状态的深度

/*正则表达式转最小化DFA*/
void RE2MIN( int nMIN[ 1600 ][ 5 ], int &nstart, vector<int> &nfin, int &nL ) {
	RE2NFA();
	showNFA();
	NFA2DFA();
	showDFA();
	DFA2MIN();
	memcpy( nMIN, MIN, sizeof( MIN ) );
	nstart = start;
	nfin = fin;
	nL = L;
}

/*同步遍历*/
bool traversal( int n1, int n2 ) {
	int i, m1, m2;
	vis1[ n1 ] = vis2[ n2 ] = ++deep;
	for ( i = 0; i < 4; i++ ) {
		m1 = MIN1[ n1 ][ i ];
		m2 = MIN2[ n2 ][ i ];
		if ( find( fin1.begin(), fin1.end(), m1 ) == fin1.end() && find( fin2.begin(), fin2.end(), m2 ) != fin2.end() )
			break;
		if ( find( fin1.begin(), fin1.end(), m1 ) != fin1.end() && find( fin2.begin(), fin2.end(), m2 ) == fin2.end() )
			break;
		if ( vis1[ m1 ] != vis2[ m2 ] )
			break;
		if ( vis1[ m1 ] == 0 && !traversal( m1, m2 ) )
			break;
	}
	--deep;
	return i == 4;
}

/*判断两个正则表达式是否等价*/
bool equal() {
	if ( P != Q )
		return false;
	memset( vis1, 0, sizeof( vis1 ) );
	memset( vis2, 0, sizeof( vis2 ) );
	deep = 0;
	return traversal( start1, start2 );
}


最后的主函数如下,大功告成:

/*********************主函数部分*********************************************/
int main()
{
	while ( true ) {
		cout << "请输入第一个正则表达式:";
		cin >> s;
		if ( s == "#" )
			break;
		RE2MIN( MIN1, start1, fin1, P );
		showMIN( MIN1, start1, fin1, P );

		cout << "\n请输入第二个正则表达式:";
		cin >> s;
		RE2MIN( MIN2, start2, fin2, Q );
		showMIN( MIN2, start2, fin2, Q );

		cout << "\n两个正则表达式的等价结果:";
		cout << ( equal() ? "YES": "NO" ) << endl << endl << endl;
	}

	return 0;

}

随便输入一个正则表达式,效果如下:

至此,所有工作完成。由于时间比较赶,代码当中应该存在不少错漏,效率上可能也不如人意,仍然有待改进。


附:测试样例和答案

a?

a|e             YES

 

b*|a+

a+|b*           YES

 

aa*|bb*

b+|a+           YES

 

e+++++*?*?*?++

eeee            YES

 

(a|b)(a|b)

aa|ab|bb        NO

 

(a|b)*

((e|a)b*)*      YES

 

a+(aa)+

(aa)+a          NO

 

a+(aa)+

(aa)+a+         YES

 

d*c+d?c*

d*c*d?c*        NO

 

 

(a|b)|(c|d)

(c|d)|(a|b)     YES

 

(a|b)*a(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)

(a|b)*b(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)    NO

 

(a|b)*a(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)

(a|b)*a(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)(a|b)    YES

 

b*a*b?a*

b*((a|ab)*|(a|ba)*)             NO


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值