解数独——dancing link X

折腾了一个星期,发现自己的大脑真的是短路了,智力水平下降到历史最低点,竟然折腾了那么久才理解了dancing link。所幸经过几天的反思,终于列出了接下来应该做的几件事:
1. 产生数独题:
1.1 实现解数独的算法dlx
1.2 从数独终盘中随机选择一个cell,判断该cell是否可以挖掉而不会造成解不唯一
2. 用pygame实现基本界面

今天完成了1.1的编码,借此总结一下。
老实说,我也不明白为什么dancing link X会花了我那么多时间去理解,尤其是当我最后理解了它并用三种方法把它实现了之后。也许是Donald 26页的论文[1]把我吓到了,也许是我真的变笨了,或者老了。。

在那篇26页的论文里,其实Donald主要讲了两件事(我关心的两件事,后面的我也没有仔细看):
1. 解决exact-cover问题的算法,也即Dancing link X里的那个算法X,Donald说他暂时没有想到一个更好的名字,所以管它叫algorithm X
2. 算法X的实现上的一些技巧,也即著名的dancing link,也就是说,dancing link并不是一种算法,它只是一种技巧而已~

[size=large][b]Exact-Cover问题:[/b][/size]
问题描述:给定一个0/1矩阵(一般是稀疏矩阵),选出一些行,使得由这些行构成的新矩阵中每一列有且仅有一个1。换个说法,把列看成域(universe)中的元素,把行看成域里的一个子集,exact-cover问题就是找出一些不相交的子集,它们的并集覆盖整个域。

问题举例:
有如下0/1矩阵:
[align=center]
[img]http://dl.iteye.com/upload/attachment/0068/4437/c9d82ac2-6590-3c41-bad6-63a8440f173a.jpg[/img]
[/align]
矩阵的行(1,4,5)构成的新矩阵中每一行每一列都有且仅有一个1.

解决方案:
我们知道,当我们选择某一行时,其他与该行在某列上同时有一个1的行都将不能再入选。也就是说,这些行在下一次遴选前都将被删除,而相应的列也可以被删除,因为这些列已经有了1了。也就是说,我们可以通过删除矩阵的行、列来缩小问题的规模,直到最终矩阵为空,说明我们已经找到了一个合法的解。假如矩阵出现没有1的列,说明这个问题无解,因为它说明这个列没有办法得到一个1.下面是Donald总结的算法流程:

Exact_Cover(A):
if A is empty:
problem solve, return
else pick a column c:
choose a row, r, such that A[r,c]=1
include r in the partial solution
for each j such that A[r,j]=1:
delete column j from matrix A
for each i such that A[i,j]=1:
delete row i from matrix A
repeat this algorithm recursively on the reduced matrix A


上面的描述便是前面提到的algorithm X。它其实是一个深搜问题。在行4,算法选择一个非确定性的(nondeterministic)行r,这将形成搜索树上的不同分枝,例如上面的例子我们确定性地选择列1做为我们的初始入口,选择行2或选择行4将形成搜索树的两个分支,我们首先沿着行2这个分枝搜索下去,如果找不到解,将一步一步回溯,最后返回root,然后再从行4这个分枝搜索。在这个过程中,算法将花费很多时间在矩阵的操作(删除行/列)上。

Donald在文章的开篇就提出了一件很是让人感到aha~!的事情:
假设x指向双向链表的一个元素,x.left指向x的左元素,x.right指向x的右元素,当我们决定删除x时,可以做如下操作:
x.left.right = x.right
x.right.left = x.left

而下面这两句精练的对称的语句将重新将x放回链表上:
x.left.right = x
x.right.left = x

是不是很美妙呢?而这,便是dancing link的精华了。

让我们换个数据结构来表现一个稀疏的0/1矩阵,形象一点,就是下图这样的表示:
[align=center]
[img]http://dl.iteye.com/upload/attachment/0068/4439/6c2bab5f-6b30-3864-a1f5-6cf56b9c0b95.jpg[/img]
[/align]
每个矩阵元素,我们称之为data object,有四个指针,分别指向其上、下、左、右元素,矩阵的每一行、每一列都是一个双向循环链表,每一列还连带一个list header,我们称之为column object,以方便我们进行列的删除操作。column object同样也是双向链表中的一部分。每个data object除了那四个指针外,还带有一个column object的引用,以方便对该列进行删除。而column object则还另外包括一个size元素,说明该列总共有多少个元素。还记得我们前面提到的算法不?我们每次选一个列c,该c拥有的每一个行都将成为一个分支。实际上我们做的事情是在众多未知数中先假设某个未知数,从而缩小搜索的空间,再在未知数中做新的假设,,直到最后所有假设都符合题目限制于是成功获得结果或者某个限制被打破从而搜索失败必须回溯。该列有多少个元素将决定了搜索树在该子根下将有多少个分支,那我们当然希望越在上层的分枝越少就越好,(因为随着树的深入,可能很多未知量都变成已知的),因此就有了这个size,以方便我们进行列的选择。由column object组成的双向横向链表还包含一个root,用来标志矩阵是否为空,当root.right==root时。上面的算法通过改用这个数据结构,可以重新描述如下:
search(k):
if root.right == root:
solution found, return true or print it~
{ as we've mentioned before, choose one column that has least elements}
choose a column c
cover column c {remember c is a column object}
for each r <- c.down, c.down.down, ..., while r!=c
solution[k]=r
for each j <- r.right, r.right.right, ..., while j!=r
cover column j
if search(k+1):
return true
{doing clean up jobs}
for each j <- r.left, r.left.left, ..., while j!=r
uncover column j
{if we get here, no solution is found}
uncover column c
return false


cover跟uncover的操作如下:
cover( column object c ):
c.left.right = c.right
c.right.left = c.left
for each i <- c.down, c.down.down, ..., while i!=c:
for each j <- i.right, i.right.right, ..., while j!=i:
j.down.up = j.up
j.up.down = j.down
j.column.size--

uncover( column object c ):
for each i <- c.up, c.up.up, ..., while i!=c:
for each j <- i.left, i.left.left, ..., while j!=i:
j.down.up = j
j.up.down = j
j.column.size++
c.left.right = c
c.right.left = c


是不是相当简洁呢?对于上面的那个例子,下图是cover了column A后的矩阵:
[align=center]
[img]http://dl.iteye.com/upload/attachment/0068/4441/13afd932-d399-3dd4-b4a2-ee0ca53b0c80.jpg[/img]
[/align]
当我们选择了行2后,将cover column D跟G,下面是cover了D跟G后的矩阵:
[align=center]
[img]http://dl.iteye.com/upload/attachment/0068/4443/665f4bf1-d6a6-3272-b07b-9a968af5cfb0.jpg[/img]
[/align]
Dancing link使矩阵的行/列删除变得相当快捷,有如弹弹手指般容易。实现起来也相当地容易。不过由于我对python还是很不熟,而当时又感觉自己没有弄懂dancing link的样子,于是我只好从自己比较熟悉的c++开始编码了,于是就有了下面两个版本的dlx:
C++封装做得不是很好,呵呵。

//ExtractCoverMatrix.h
#include<iostream>
using namespace std;

struct Node
{
Node* left, *right, *up, *down;
int col, row;
Node(){
left = NULL; right = NULL;
up = NULL; down = NULL;
col = 0; row = 0;
}
Node( int r, int c )
{
left = NULL; right = NULL;
up = NULL; down = NULL;
col = c; row = r;
}
};

struct ColunmHeader : public Node
{
int size;
ColunmHeader(){
size = 0;
}
};

class ExactCoverMatrix
{
public:
int ROWS, COLS;
//这是存储结果的数组
int* disjointSubet;
//接收矩阵其及维度
ExactCoverMatrix( int rows, int cols, int** matrix );
//释放内存
~ExactCoverMatrix();
//在行r列c的位置上插入一个元素
void insert( int r, int c );
//即我们的cover和uncover操作了
void cover( int c );
void uncover( int c );
//即我们的algorithm X的实现了
int search( int k=0 );
private:
ColunmHeader* root;
ColunmHeader* ColIndex;
Node* RowIndex;
};


//ExactCoverMatrix.cpp
#include "ExtracCoverMatrix.h"

ExtracCoverMatrix::ExtracCoverMatrix( int rows, int cols, int** matrix )
{
ROWS = rows;
COLS = cols;
disjointSubet = new int[rows+1];
ColIndex = new ColunmHeader[cols+1];
RowIndex = new Node[rows];
root = &ColIndex[0];
ColIndex[0].left = &ColIndex[COLS];
ColIndex[0].right = &ColIndex[1];
ColIndex[COLS].right = &ColIndex[0];
ColIndex[COLS].left = &ColIndex[COLS-1];
for( int i=1; i<cols; i++ )
{
ColIndex[i].left = &ColIndex[i-1];
ColIndex[i].right = &ColIndex[i+1];
}

for ( int i=0; i<=cols; i++ )
{
ColIndex[i].up = &ColIndex[i];
ColIndex[i].down = &ColIndex[i];
ColIndex[i].col = i;
}
ColIndex[0].down = &RowIndex[0];

for( int i=0; i<rows; i++ )
for( int j=0; j<cols&&matrix[i][j]>0; j++ )
{
insert( i, matrix[i][j] );
}
}

ExtracCoverMatrix::~ExtracCoverMatrix()
{
delete[] disjointSubet;
for( int i=1; i<=COLS; i++ )
{
Node* cur = ColIndex[i].down;
Node* del = cur->down;
while( cur != &ColIndex[i] )
{
delete cur;
cur = del;
del = cur->down;
}
}
delete[] RowIndex;
delete[] ColIndex;
}

void ExtracCoverMatrix::insert( int r, int c )
{
Node* cur = &ColIndex[c];
ColIndex[c].size++;
Node* newNode = new Node( r, c );
while( cur->down != &ColIndex[c] && cur->down->row < r )
cur = cur->down;
newNode->down = cur->down;
newNode->up = cur;
cur->down->up = newNode;
cur->down = newNode;
if( RowIndex[r].right == NULL )
{
RowIndex[r].right = newNode;
newNode->left = newNode;
newNode->right = newNode;
}
else
{
Node* rowHead = RowIndex[r].right;
cur = rowHead;
while( cur->right != rowHead && cur->right->col < c )
cur = cur->right;
newNode->right = cur->right;
newNode->left = cur;
cur->right->left = newNode;
cur->right = newNode;
}
}

void ExtracCoverMatrix::cover( int c )
{
ColunmHeader* col = &ColIndex[c];
col->right->left = col->left;
col->left->right = col->right;
Node* curR, *curC;
curC = col->down;
while( curC != col )
{
Node* noteR = curC;
curR = noteR->right;
while( curR != noteR )
{
curR->down->up = curR->up;
curR->up->down = curR->down;
ColIndex[curR->col].size--;
curR = curR->right;
}
curC = curC->down;
}
}

void ExtracCoverMatrix::uncover( int c )
{
Node* curR, *curC;
ColunmHeader* col = &ColIndex[c];
curC = col->up;
while( curC != col )
{
Node* noteR = curC;
curR = curC->left;
while( curR != noteR )
{
ColIndex[curR->col].size++;
curR->down->up = curR;
curR->up->down = curR;
curR = curR->left;
}
curC = curC->up;
}
col->right->left = col;
col->left->right = col;
}

int ExtracCoverMatrix::search( int k )
{
if( root->right == root )
return k;
ColunmHeader* choose = (ColunmHeader*)root->right, *cur=choose;
while( cur != root )
{
if( choose->size > cur->size )
choose = cur;
cur = (ColunmHeader*)cur->right;
}
if( choose->size <= 0 )
return -1;

cover( choose->col );
Node* curC = choose->down;
while( curC != choose )
{
disjointSubet[k] = curC->row;
Node* noteR = curC;
Node* curR = curC->right;
while( curR != noteR )
{
cover( curR->col );
curR = curR->right;
}
int res=-1;
if( (res = search( k+1 ))>0 )
return res;
curR = noteR->left;
while( curR != noteR )
{
uncover( curR->col );
curR = curR->left;
}
curC = curC->down;
}
uncover( choose->col );
return -1;
}

下面是python的实现版本,显然比c++要简洁好多:

#这段代码基本上来自http://code.google.com/p/mycodeplayground/
#我更多的是理解和欣赏python程序的写法。。。
class Node(object):

'''data object in dancing link
Basic element for a sparse matrix
doubly linked in both horizontal and vertical direction'''

def __init__( self, left = None, right = None, up = None, down = None,
column_header = None, row_header = None ):
self.left = left or self
self.right = right or self
self.up = up or self
self.down = down or self

self.column_header = column_header
self.row_header = row_header

def nodes_in_direction( self, dir ):
'''Generator for nodes in different direction

:Parameters:
dir: str
dir can be 'up', 'down', 'left', 'right'
'''
node = getattr( self, dir )
while node != self:
#print node.column_header.size
yield node
node = getattr( node, dir )

class ColumnHeader(Node):
'''list header, or column object in dancing link
has an extra element of size recording the number of data object in this column'''
def __init__( self, *args, **kargs ):
Node.__init__( self, *args, **kargs )
self.size = 0

class RowHeader(Node):
'''RowHeader is an extra element for dancing link to record the row no. of a row
so that we can trace down which rows are picked up'''

def __init__( self, rowno, *args, **kargs ):
Node.__init__( self, *args, **kargs )
self.rowno = rowno
self.row_header = self

class DLXSolver(object):

def __init__( self ):
pass

def solve(self, matrix, num_columns):
'''solve the exact cover problem of a given matrix

:Parameters:
matrix is in the form as:
{ k:[x0, x1, ..., xn] }
where k is the line number while xi(0<=xi<num_columns) is the column
of the matrix where there is a 1

num_columns:
number of columns in the matrix

:Return:
solution: [k]
solution is a list of line number that is picked
'''
self._partial_answer = {}
self._k = 0
self._construct( matrix, num_columns )
self._search(0)

return [self._partial_answer[k] for k in xrange(self._k)]

def _construct( self, matrix, num_columns ):
'''construct a matrix into a sparse matrix using doubly link list

:Parameter:
parameters are same as solve
'''

self.root = Node()
self.column_headers = []

#constructing column_headers
for i in xrange(num_columns):
new_column_header = ColumnHeader(left=self.root.left,
right=self.root)
self.root.left.right = new_column_header
self.root.left = new_column_header
self.column_headers.append( new_column_header )



#inserting nodes into the sparse matrix
for k in matrix:
if not matrix[k]:
continue

column_id_sorted = sorted(matrix[k])
column_header = self.column_headers[column_id_sorted[0]]
column_header.size += 1

new_row_header = RowHeader(k, up=column_header.up, down=column_header,
column_header=column_header)
column_header.up.down = new_row_header
column_header.up = new_row_header

column_id_sorted.pop(0)

#constructing remaining nodes
for i in column_id_sorted:
column_header = self.column_headers[i]
column_header.size += 1
new_node = Node( left=new_row_header.left, right=new_row_header,
up = column_header.up, down = column_header,
column_header = column_header, row_header = new_row_header )
column_header.up.down = new_node
column_header.up = new_node
new_row_header.left.right = new_node
new_row_header.left = new_node

def _cover( self, column_header ):
'''cover a column of a matrix as what donald said'''

column_header.left.right = column_header.right
column_header.right.left = column_header.left

for eachNode in column_header.nodes_in_direction("down"):
for node in eachNode.nodes_in_direction("right"):
node.column_header.size -= 1
node.up.down = node.down
node.down.up = node.up

def _uncover( self, column_header ):
'''uncover a column of a matrix in the reverse order as cover do'''

for eachNode in column_header.nodes_in_direction("up"):
for node in eachNode.nodes_in_direction("left"):
node.column_header.size += 1
node.down.up = node
node.up.down = node

column_header.left.right = column_header
column_header.right.left = column_header

def _search( self, k ):
if self.root.right == self.root:
self._k = k
return True


column_header=min([header for header in self.root.nodes_in_direction("right")],
key=lambda h:h.size)

self._cover( column_header )
for eachNode in column_header.nodes_in_direction("down"):
self._partial_answer[k] = eachNode.row_header.rowno

for node in eachNode.nodes_in_direction("right"):
self._cover(node.column_header)

if self._search( k+1 ):
return True

for node in eachNode.nodes_in_direction("left"):
self._uncover(node.column_header)

self._uncover(column_header)
return False

'''test case for dlxSolver'''
if __name__ == "__main__":
matrix = { 1:[2, 4,5],
2:[0,3,6],
3:[1,2,5],
4:[0,3],
5:[1,6],
6:[3,4,6]}
testSolver = DLXSolver()
print testSolver.solve( matrix, 7 )


[size=large][b]从数独到Exact-Cover问题:[/b][/size]
那么,数独跟Exact-Cover有什么关系呢?我们来看看数独的限制:
1. 每个格里只能填一个数(1-9),有且只有一个
2. 每个数(1-9)在每一行里只能出现一次,有且只有一次
3. 每个数在每一列里只能出现一次,有且只有一次
4. 每个数在每一个块(block)只能出现一次,有且只有一次
这么多个“有且只有一次”显然在向我们发出强烈的信号:这是一个exact-cover问题!于是便有了下面的转换:
我们知道,在没有任何限制下,数独的第i行,第j列可以填入数字k,其中0<i,j,k<=9,因此我们实际上是从9^3个(i,j,k)中选择81个,同时使其满足以上4个限制。对于每一个(i,j,k),格(i,j)只能填一个数字的限制将表现为列(ci,cj)从k=[1,9]为1,所以我们只能选其中一行,也就是,在k=[1,9]中,只有一个(i,j,k0)被选中。对于每一个(i,j,k),行i只能出现一次k的限制将表现为列(ci, ck)从j=[1,9]为1,我们只能选择其中一行。对于限制3,4也是同样的道理。总共将需要81*4列,9^3行。这里[url="http://cgi.cse.unsw.edu.au/~xche635/dlx_sodoku/"][2][/url]把这个问题说得很清楚。

下面分别是C++和python的数独求解:

#include<iostream>
#include "ExactCoverMatrix.h"
using namespace std;

const int N_Cell = 3;
const int N = 9;
const int OFFSET = 9*9;
const int ROWS = N*N*N;
const int COLS = N*N*4;

void solve_sudoku( int (*grid)[N] )
{
//tranform a sudoku to a exact cover problem
int** sudoku_matrix = new int*[ROWS];
for( int i=0; i<ROWS; i++ )
sudoku_matrix[i] = new int[5];
for( int i=0; i<N; i++ )
for( int j=0; j<N; j++ )
{
int b = (i/N_Cell)*N_Cell + (j/N_Cell);
int r = i*N*N + j*N;
for( int k=1; k<=N; k++ )
{
sudoku_matrix[r][0]=i*N+j+1;
sudoku_matrix[r][1]=OFFSET+(i*N+k);
sudoku_matrix[r][2]=OFFSET*2+(j*N+k);
sudoku_matrix[r][3]=OFFSET*3+(b*N+k);
sudoku_matrix[r][4]=-1;
r++;
}
}
SparseMatrix sudoku( ROWS, COLS, sudoku_matrix );
//cover those have been filled
for( int i=0; i<N; i++ )
for( int j=0; j<N; j++ )
{
int k;
if( k = grid[i][j] )
{
/* corresponding to
insert( r, i*N+j+1 );
insert( r, OFFSET+(i*N+k) );
insert( r, OFFSET*2+(j*N+k) );
insert( r, OFFSET*3+(b*N+k) );
*/
int b = (i/N_Cell)*N_Cell + j/N_Cell;
sudoku.cover( i*N+j+1 );
sudoku.cover( OFFSET+( i*N+k ) );
sudoku.cover( OFFSET*2+(j*N+k) );
sudoku.cover( OFFSET*3+(b*N+k) );
}
}
int solved=0;
if( (solved=sudoku.search())>0 )
{
int c, r, k;
for( int i=0; i<solved; i++ )
{
r = sudoku.disjointSubet[i]/(N*N);
c = (sudoku.disjointSubet[i]/N)%N;
k = sudoku.disjointSubet[i]%N;
grid[r][c]=k+1;
}
cout<<"here is the result: "<<endl;
for( int i=0; i<N; i++ )
{
for( int j=0; j<N; j++ )
cout<<grid[i][j]<<' ';
cout<<endl;
}
}
else
cout<<"no solution for this sudoku"<<endl;
}

bool verify_puzzle( int (*grid)[N] )
{
int line[N+1]={0};
for( int i=0; i<N; i++ )
{
for( int j=0; j<=N; j++ )
line[j] = 0;
for( int j=0; j<N; j++ )
{
if( line[grid[i][j]] )
{
cout<<"row wrong: "<<i<<endl;
return false;
}
else
line[grid[i][j]] = 1;
}
}
for( int i=0; i<N; i++ )
{
for( int j=0; j<N+1; j++ )
line[j] = 0;
for( int j=0; j<N; j++ )
{
if( line[grid[j][i]] )
{
cout<<"column wrong"<<endl;
return false;
}
else
line[grid[j][i]] = 1;
}
}
for( int i=0; i<N_Cell; i++ )
for( int j=0; j<N_Cell; j++ )
{
for( int x=0; x<=N; x++ )
line[x] = 0;
for( int k=0; k<N_Cell; k++ )
for( int t=0; t<N_Cell; t++ )
{
int x = k+i*N_Cell;
int y = t+j*N_Cell;
if( line[grid[x][y]] )
{
cout<<"block wrong"<<endl;
return false;
}
else
line[grid[x][y]] = 1;
}
}
return true;
}

int main()
{
int puzzle[N][N]={
{0, 0, 0, 0, 0, 3, 0, 8, 1},
{2, 0, 0, 4, 0, 0, 0, 0, 0},
{0, 5, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 2, 3, 0, 7, 0, 0},
{0, 1, 0, 0, 0, 0, 0, 5, 0},
{0, 0, 8, 6, 0, 0, 0, 0, 0},
{7, 0, 0, 0, 0, 0, 4, 0, 0},
{0, 9, 0, 0, 8, 0, 0, 0, 0},
{0, 0, 0, 0, 5, 0, 2, 0, 0}};
solve_sudoku( puzzle );
if( !verify_puzzle( puzzle ) )
cout<<"wrong answer!"<<endl;

}


from __future__ import division
from dlx_1 import DLXSolver

class SudokuSolver(object):

digits = set(map(str, range(1,10)))

GRIDOFFSET=0 #column 0~80 is for testing each cell has only one digit
ROWOFFSET=81 #column 81~161 is for testing each line contain each number exactly once
COLOFFSET=162 #column 162~242 is for testing each column contain each number exactly once
BLKOFFSET=243 #colunm 243~323 is for testing each block contain each number exactly once
COLUNMS = 324

def __init__( self ):
self.dlx_solver = DLXSolver()

def solve( self, puzzle ):
'''solve the given sudoku puzzle
unfilled cell is represented with other symbols except 1~9
'''
dlx_matrix = self._construct(puzzle)
result = self.dlx_solver.solve( dlx_matrix, self.COLUNMS )
solved_sudoku=list(puzzle)
for row, col, val in result:
solved_sudoku[row*9+col]=val+1
return solved_sudoku

def _construct( self, puzzle ):
'''construct a sudoku puzzle to a dlx_matrix'''
linear_puzzle = ''.join(puzzle.split())
if len(linear_puzzle) != 81:
print 'invalid puzzle!'
return

dlx_matrix={}
for i, c in enumerate(linear_puzzle):
row, col = divmod(i,9)
if c in self.digits:
val = int(c)-1
dlx_matrix[(row, col, val)]=self._get_ones(row, col, val)
else:
for val in range(9):
dlx_matrix[(row, col, val)]=self._get_ones(row, col, val)
return dlx_matrix

def _get_ones(self, row, col, val):

blk = (row//3)*3 + col//3
ones = [row*9+col+self.GRIDOFFSET,
row*9+val+self.ROWOFFSET,
col*9+val+self.COLOFFSET,
blk*9+val+self.BLKOFFSET]
return ones

if __name__=="__main__":
puzzle = "4.....8.5.3..........7......2.....6.....8.4......1.......6.3.7.5..2.....1.4......"
solver = SudokuSolver()
res = solver.solve(puzzle)
for i in range(9):
print res[i*9: i*9+9]

最后最后,这里[url="http://www.cs.mcgill.ca/~aassaf9/python/algorithm_x.html"][3][/url]提供了一个30行的dlx的python代码,非常的精致,建议大家都去看一看~!

__________________________________________
[1]Dancing Links, Donald E. Knuth(附件下载)
[2]http://cgi.cse.unsw.edu.au/~xche635/dlx_sodoku/
[3]http://www.cs.mcgill.ca/~aassaf9/python/algorithm_x.html
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值