大家好,又见面了,我是你们的朋友全栈君。
1 问题描述
1.1什么是八数码问题
八数码游戏包括一个33的棋盘,棋盘上摆放着8个数字的棋子,留下一个空位。与空位相邻的棋子可以滑动到空位中。游戏的目的是要达到一个特定的目标状态。标注的形式化如下:
|
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
1.2问题的搜索形式描述
状态:状态描述了8个棋子和空位在棋盘的9个方格上的分布。
初始状态:任何状态都可以被指定为初始状态。
操作符:用来产生4个行动(上下左右移动)。
目标测试:用来检测状态是否能匹配上图的目标布局。
路径费用函数:每一步的费用为1,因此整个路径的费用是路径中的步数。
现在任意给定一个初始状态,要求找到一种搜索策略,用尽可能少的步数得到上图的目标状态。
1.3解决方案介绍
1.3.1 算法思想
估价函数是搜索特性的一种数学表示,是指从问题树根节点到达目标节点所要耗费的全部代价的一种估算,记为f(n)。估价函数通常由两部分组成,其数学表达式为
f(n)=g(n)+h(n)
其中f(n) 是节点n从初始点到目标点的估价函数,g(n) 是在状态空间中从初始节点到n节点的实际代价,h(n)是从n到目标节点最佳路径的估计代价。保证找到最短路径(最优解)的条件,关键在于估价函数h(n)的选取。估价值h(n)<= n到目标节点的距离实际值,这种情况下,搜索的点数多,搜索范围大,效率低。但能得到最优解。如果估价值>实际值, 搜索的点数少,搜索范围小,效率高,但不能保证得到最优解。
搜索中利用启发式信息,对当前未扩展结点根据设定的估价函数值选取离目标最近的结点进行扩展,从而缩小搜索空间,更快的得到最优解,提高效率。
1.3.2 启发函数
进一步考虑当前结点与目标结点的距离信息,令启发函数h ( n )为当前8个数字位与目标结点对应数字位距离和(不考虑中间路径),且对于目标状态有 h ( t ) = 0,对于结点m和n (n 是m的子结点) 有h ( m ) – h ( n ) <= 1 = Cost ( m, n ) 满足单调限制条件。
2算法介绍
2.1 A*算法的一般介绍
A*(A-Star)算法是一种静态路网中求解最短路最有效的方法。
A star算法在静态路网中的应用
对于几何路网来说,可以取两节点间欧几理德距离(直线距离)做为估价值,即f=g(n)+sqrt((dx-nx)*(dx-nx)+(dy-ny)*(dy-ny));这样估价函数f在g值一定的情况下,会或多或少的受估价值h的制约,节点距目标点近,h值小,f值相对就小,能保证最短路的搜索向终点的方向进行。明显优于盲目搜索策略。
2.2算法伪代码
创建两个表,OPEN表保存所有已生成而未考察的节点,CLOSED表中记录已访问过的节点。算起点的估价值,将起点放入OPEN表。
while(OPEN!=NULL)
{
从OPEN表中取估价值f最小的节点n;
if(n节点==目标节点)
{break;}
for(当前节点n 的每个子节点X)
{
算X的估价值;
if(X in OPEN)
{
if( X的估价值小于OPEN表的估价值 )
{
把n设置为X的父亲;
更新OPEN表中的估价值; //取最小路径的估价值}
}
if(X inCLOSE)
{
if( X的估价值小于CLOSE表的估价值 )
{
把n设置为X的父亲;
更新CLOSE表中的估价值;
把X节点放入OPEN //取最小路径的估价值}
}
if(X not inboth)
{
把n设置为X的父亲;
求X的估价值;
并将X插入OPEN表中; //还没有排序}
}//end for
将n节点插入CLOSE表中;
按照估价值将OPEN表中的节点排序; //实际上是比较OPEN表内节点f的大小,从最小路径的节点向下进行。
}//end while(OPEN!=NULL)
保存路径,即 从终点开始,每个节点沿着父节点移动直至起点,这就是你的路径;
判断有无解问题:根据逆序数直接判断有无解,对于一个八数码,依次排列之后,每次是将空位和相邻位进行调换,研究后会发现,每次调换,逆序数增幅都为偶数,也就是不改变奇偶性,所以初始和目标状态的逆序数的奇偶性相同。
3算法实现
3.1实验环境与问题规模
对于8数码问题,每个结点有8个数字和一个空格,可以将空格看成0,那么一共有9个数字,32位的int可以表示2* 109 ,可以用一个整数表示一个结点对应的信息。计算一个整数中0(即空格)的位置比较耗时间,用一个整数存储当前结点0的位置,还要存储对应的 g , h 值以及该结点由哪个结点扩展来的信息。本实验用C++编写源程序,环境选用Visual Studio 2005。
程序采用文本输入输出,输入文件为astar.in,A*算法输出文件为astar.out,可以用记事本打开。输入格式为一个测试用例由两个中间由一空行隔开的8数码格局组成,输出为对应测试用例的走法路径及相关统计信息,程序假定输入数据符合要求,未做检查。
Astar.in:
2 0 3 //初态
1 8 4
7 6 5
1 2 3 // 终态
8 0 4
7 6 5
3.2数据结构
3.2.1 open表的数据结构表示
考虑对open表的操作,每次需要得到所有待扩展结点中 f 值最小的那个结点,用堆进行实现,可以达到O ( log ( heapSize ) ) 时间复杂度。
3.2.2 closed表的数据结构表示
closed表存储已扩展的结点间的扩展关系,主要用于输出路径。考虑结点扩展的操作,设待扩展的结点为m,由它扩展生成的结点为n1, n2, … 。结点m扩展完成后被放到closed表中,放入后它在closed表中位置不发生变化,可以将n1, n2, …的前驱结点置为m在closed表中的位置,当n1, n2, ..中有结点设为n1被扩展放入closed表时,n1的前驱刚好已经存储好。下面说明closed表中任意一个结点都存储有它的前驱结点的信息,考虑closed表中任意一个结点,如果它是初始结点,它没有前驱结点,如果不是根结点,扩展该结点时它的前驱结点已经记录。从而在closed表中形成扩展关系的树状结构。因为只需要前驱结点的下标位置,可以用数组实现,每个结点记录整数表示的8数码格局和它的前驱结点的下标,输出路径时,根据前驱结点形成到达根结点的链条,递归输出即可。
3.2.3 解决结点重复扩展问题
对于一个结点有多种方式到达该结点,这样就可能多次将它加入open表中,而启发函数满足单调限制条件,后来达到该结点的路径不再是更优的,可以不予考虑。扩展某结点时先看该结点是否已经扩展过,如果扩展过则略过。实现的可以线形遍历closed表,但效率不高时间复杂度为O ( closedSize),考虑每个结点可以用一个整数标识,用二叉平衡查找树可以得到更好的时间复杂度O ( log (closedSize) ) ,程序中用基于红黑树思想的set实现。
3.3 实验结果
输入数据(0表示空格) |
步数 |
扩展结点数 |
生成结点数 |
搜索用时(毫秒) |
3 1 2 4 0 5 6 7 8 |
2 |
5 |
11 |
0 |
3 1 2 4 7 5 6 8 0 |
4 |
17 |
28 |
0 |
3 7 2 8 1 5 4 6 0 |
无解 |
|
|
|
1 2 3 4 5 6 7 8 0 |
22 |
7943 |
12019 |
2266 |
参考文献
王勋,凌云,费玉莲.2005.人工智能导论.北京:科学出版社
广树建,王钰淇.2008.新编C/C++程序设计教程.广州:华南理工大学出版社
王文杰,史忠植.2007.人工智能原理辅导与练习.北京:清华大学出版社出
附录—源代码及其注释
源代码及测试数据
/*
算法: A*
是否最优解:是
启发函数: 每一个数字位与目标中该数字位的距离,满足单调限制。说明:A*算法是启发式搜索算法,搜索时充分利用当前状态距目标距离远近的启发信息,选取当前未扩展结点中估价函数最小的进行扩展,生成结点数少,搜索空间较小,实现稍复杂,
备注: 程序未对输入数据进行检查
*/
#pragma warning(disable:4786)
#include <algorithm>
#include <cstdio>
#include <set>
#include <utility>
#include <ctime>
#include <cassert>
#include <cstring>
#include <iostream>
using namespace std;
/*item记录搜索空间中一个结点
state 记录用整数形式表示的8数码格局
blank 记录当前空格位置,主要用于程序优化,
扩展时可不必在寻找空格位置
g, h 对应g(n), h(n)
pre 记录当前结点由哪个结点扩展而来 */
struct item
{
int state;
int blank;
int g;
int h;
int pre;
};
const int MAXSTEPS = 100000;
const int MAXCHAR = 100;
char buf[MAXCHAR][MAXCHAR]; //open表
item open[MAXSTEPS];
//vector<item> open;
int steps = 0;
//closed表,已查询状态只要知道该状态以及它由哪个结点扩展而来即可,用于输出路径
//每次只需得到对应f值最小的待扩展结点,用堆实现提高效率
pair<int, int> closed[MAXSTEPS];
//读入,将8数码矩阵格局转换为整数表示
bool read(pair<int,int> &state)
{
if (!gets(buf[0]))
return false;
if (!gets(buf[1]))
return false;
if (!gets(buf[2]))
return false;
//cout << strlen(buf[0]) << ' ' << strlen(buf[1]) << ' ' << strlen(buf[2]) << endl;
assert(strlen(buf[0]) == 5 && strlen(buf[1]) == 5 && strlen(buf[2]) == 5);
// astar.in中的每行数据长度必须为5
state.first = 0;
for (int i = 0, p = 1; i < 3; ++i)
{
for (int j = 0; j < 6; j += 2)
{
if (buf[i][j] == '0')
state.second = i * 3 + j / 2; // state.second为0(空格)在节点中的位置
else
state.first += p * (buf[i][j] - '0');
p *= 10;
}
}
/* 若初试节点为:
1 2 3
8 0 4
7 6 5
则state.first为567408321,state.second为4
*/
return true;
}
//计算当前结点距目标的距离
int calculate(int current, int target) // return h=the sum of distances each block have to move to the right position,这里的each block不包括空格
{
int c[9], t[9];
int i, cnt = 0;
for (i = 0; i < 9; ++i)
{
c[current % 10] = t[target % 10] = i;
current /= 10;
target /= 10;
}
for (i = 1; i < 9; ++i)
cnt += abs(c[i] / 3 - t[i] / 3) + abs(c[i] % 3 - t[i] % 3);
return cnt;
}
//open表中结点间选择时的规则 f(n) = g(n) + h(n)
class cmp
{
public: inline bool operator()(item a, item b)
{
return a.g + a.h > b.g + b.h;
}
};
//将整数形式表示转换为矩阵表示输出
void pr(int state)
{
memset(buf, ' ', sizeof(buf));
for (int i = 0; i < 3; ++i)
{
for (int j = 0; j < 6; j += 2)
{
if (state % 10)
buf[i][j] = state % 10 + '0';
state /= 10;
}
buf[i][5] = '\0';
puts(buf[i]);
}
}
//用于判断当前空格是否可以向对应方向移动
inline bool suit(int a, int b) //空格移动后的坐标为(a,b)
{
return (a >= 0 && a < 3 && b >= 0 && b < 3);
}
//递归输出搜索路径
void path(int index)
{
if (index == 0)
{
pr(closed[index].first);
puts("");
return;
}
path(closed[index].second);
pr(closed[index].first); //将整数形式表示转换为矩阵表示输出
puts("");
++steps;
}
int getNixuNum( int state ) //求节点的逆序对数
{
int sum = 0;
int result[9];
memset( result, 0, sizeof(result) );
//cout << result[8] << result[7] << endl;
char buf[10];
itoa( state, buf, 10 );
//cout << buf << endl;
int k = 0;
while( buf[k] != '\0' )
{
result[9-k-1] = buf[k] - '0';
k++;
}
for( int i = 0; i < 9; i++ )
{
for( int j = i + 1; j < 9; j++ )
{
if( result[i] && result[j] && result[i] > result[j] )
{
sum++;
}
}
}
return sum; //返回3*3方格数组的逆序对数
}
int main()
{
//cout << getNixuNum(87654321);
//open.resize(MAXSTEPS);
unsigned int t1 = clock();
//cout << open.size() << endl;
if( freopen("astar.in", "r", stdin) == NULL )
{
cout << "file not find\n";
exit(0);
};
freopen("astar2.out", "w", stdout);
set<int>states;
char tmp[100];
int i, x, y, a, b, nx, ny, end, next, index, kase = 0;
pair<int,int> start, target;
item head; //4个方向移动时的偏移量
const int xtran[4] = {-1, 0, 1, 0};
const int ytran[4] = {0, 1, 0, -1};
const int p[] = {1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000};
while (read(start)) // 读取初试状态节点
{
unsigned int t2 = clock();
printf("Case %d:\n\n", ++kase);
gets(tmp);
read(target); // 读取目标状态节点
gets(tmp);
int targetNixuNum = getNixuNum(target.first);
//若两者的逆序对数不是同为奇数或同为偶数,则无解
if( !(getNixuNum(start.first)&1 && targetNixuNum&1 || !(getNixuNum(start.first)&1) && !(targetNixuNum&1)) )
{
cout << "无法从初始节点到终态节点\n";
exit(0);
}
//初始化open表,将初始状态加入
open[0].state = start.first;
open[0].h = calculate(start.first, target.first); // 计算当前节点到目标节点的估计距离
open[0].blank = start.second;
open[0].pre = -1; // 初始节点无父节点
open[0].g = 0; // 初始节点的g为0
index = 0;
states.insert(start.first); // 扩展过节点保存在states中,即出现过的状态保存在states中,states为set<int>类型,其中的states中的元素唯一
//提取open表中f值最小元素放入closed表,并对该结点进行扩展
for (end = 1; end > 0; ++index) // end为open表中的元素个数,一直循环到open表为空
{
assert(index < MAXSTEPS);
//临时存储
head = open[0]; // 由于使用pop_heap函数和push_heap函数,所以open[0]为g+h最小的元素
//放入closed表记录当前格局和由哪个结点扩展而来(该结点肯定已在closed表中)
closed[index].first = open[0].state; //放入close表中,表示已经扩展完的节点,下面的for循环会扩展其节点
closed[index].second = open[0].pre; // index表示当前close表中当前扩展节点的下标
//从open表中删除该结点
pop_heap(open, open + end, cmp());//为algorithm文件中的函数,第一个参数指定开始位置,第二个指定结束,第三个指定比较函数
--end;
//得到结果,递归输出路径
if (head.state == target.first)
{
path(index);
break;
}
x = head.blank / 3;
y = head.blank % 3; //空格在3*3方格中的x,y坐标
/*
|2 0 3|
A = |1 8 4|
|7 6 5| // 看成3*3的数组
则head.blank=1
x=0,y=1,即空格的在3*3的数组中下标为(0,1)
*/
for (i = 0; i < 4; ++i)
{
nx = x + xtran[i];
ny = y + ytran[i];
/*
i=0时:(nx,ny)为当前空格向上移动一格后的坐标
i=1时:(nx,ny)为当前空格向右移动一格后的坐标
i=2时:(nx,ny)为当前空格向下移动一格后的坐标
i=3时:(nx,ny)为当前空格向左移动一格后的坐标
*/
if (suit(nx, ny)) // 判断是否能够移动
{
a = head.blank; // 空格当前位置,以上面矩阵A为例,a=1
b = nx * 3 + ny; // 空格移动后的新位置,开始是能够向右边移动,故b=0*3+2=2
//调换十进制表示整数对应两个数字位
next = head.state + ((head.state % p[a + 1]) / p[a] - (head.state % p[b + 1]) / p[b]) * p[b] + ((head.state % p[b + 1]) / p[b] - (head.state % p[a + 1]) / p[a]) * p[a];
// 如head.state=567481302,空格向右移动一次后,next=567481032,即为移动后的节点
// 判断能否由当前节点到达目标节点
if( ( getNixuNum(next)&1 && targetNixuNum&1 ) || ( !(getNixuNum(next)&1) && !(targetNixuNum&1) ) )
{
//判断是否已经扩展过,即已经出现过
if (states.find(next) == states.end()) //没出现就保存一下,也存入open表
{
states.insert(next);
open[end].pre = index; //扩展后的子节点,其父节点为当前扩展节点
open[end].blank = b;
open[end].state = next;
open[end].h = calculate(next,target.first);
open[end].g = head.g + 1;
++end; //open表中元素加1
push_heap(open, open + end, cmp()); //压入堆中
}
}
}
}
}
if (end <= 0)
puts("No solution");
else
{
printf("Num of steps: %d\n", steps);
printf("Num of expanded: %d\n", index);
printf("Num of generated: %d\n", index + end);
printf("Time consumed: %d\n\n", clock() - t2);
}
states.clear();
steps = 0;
}
printf("Total time consumed: %d\n", clock() - t1);
return 0;
}
测试:
输入文件:astar.in
输出文件:astar2.out
astar.in文件内容:
3 1 2
4 0 5
6 7 8
0 1 2
3 4 5
6 7 8
1 2 3
4 5 6
7 8 0
0 1 2
3 4 5
6 7 8
注:上面前两个3*3矩阵为第一个测试案例,其中第一个3*3为初态节点,第二个3*3为终态节点,后面两个3*3矩阵为第二个测试案例,
其中第一个3*3为初态节点,第二个3*3为终态节点,各个矩阵之间需要空一行
测试案例1:
astar.in:
3 1 2
4 0 5
6 7 8
0 1 2
3 4 5
6 7 8
astar2.out:
Case 1:
3 1 2
4 5
6 7 8
3 1 2
4 5
6 7 8
1 2
3 4 5
6 7 8
Num of steps: 2
Num of expanded: 2
Num of generated: 6
Time consumed: 64
Total time consumed: 92
测试案例2:
astar.in:
3 7 2
8 1 5
4 6 0
0 1 2
3 4 5
6 7 8
astar2.out:
Case 1:
无法从初始节点到终态节点
注:astar.in中每一行数据长度只能是5,0表示空格
测试案例3:
astar.in:
1 2 3
4 5 6
7 8 0
0 1 2
3 4 5
6 7 8
astar2.out:
Case 1:
1 2 3
4 5 6
7 8
1 2 3
4 5 6
7 8
1 2 3
4 6
7 5 8
1 2 3
4 6
7 5 8
1 2
4 6 3
7 5 8
1 2
4 6 3
7 5 8
1 2
4 6 3
7 5 8
4 1 2
6 3
7 5 8
4 1 2
6 3
7 5 8
4 1 2
6 3
7 5 8
4 1
6 3 2
7 5 8
4 1
6 3 2
7 5 8
4 3 1
6 2
7 5 8
4 3 1
6 5 2
7 8
4 3 1
6 5 2
7 8
4 3 1
5 2
6 7 8
3 1
4 5 2
6 7 8
3 1
4 5 2
6 7 8
3 1
4 5 2
6 7 8
3 1 2
4 5
6 7 8
3 1 2
4 5
6 7 8
3 1 2
4 5
6 7 8
1 2
3 4 5
6 7 8
Num of steps: 22
Num of expanded: 1104
Num of generated: 1742
Time consumed: 123
Total time consumed: 126
转载来源:http://wenku.baidu.com/view/87c92ef1ba0d4a7302763a29.html
八数码问题的另一个实现:
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<queue>
#include<vector>
#include<cmath>
#include<map>
#include<string>
#define inf 1<<30
#define eps 1e-7
#define LD long double
#define LL long long
#define maxn 1000000005
using namespace std;
struct Node{
int maze[3][3]; //八数码具体情况
int h,g; //两个估价函数
int x,y; //空位的位置
int Hash; //HASH值
bool operator<(const Node n1)const{ //优先队列第一关键字为h,第二关键字为g
return h!=n1.h?h>n1.h:g>n1.g;
}
bool check(){ //判断是否合法
if(x>=0&&x<3&&y>=0&&y<3)
return true;
return false;
}
}s,u,v,tt;
int HASH[9]={1,1,2,6,24,120,720,5040,40320}; //HASH的权值
int destination=322560; //目标情况的HASH值
/*
目标状态:
1 2 3
4 5 6
7 8 0
其hash值为322560
*/
int vis[400000]; //判断状态已遍历,初始为-1,否则为到达这步的转向
int pre[400000]; //路径保存
int way[4][2]={
{0,1},{0,-1},{1,0},{-1,0}}; //四个方向
void debug(Node tmp){
for(int i=0;i<3;i++){
for(int j=0;j<3;j++)
printf("%d ",tmp.maze[i][j]);
printf("\n");
}
printf("%d %d\n%d %d\n",tmp.x,tmp.y,tmp.g,tmp.h);
printf("hash=%d\n",tmp.Hash);
}
int get_hash(Node tmp){ //获得HASH值
int a[9],k=0;
for(int i=0;i<3;i++)
for(int j=0;j<3;j++)
a[k++]=tmp.maze[i][j];
int res=0;
for(int i=0;i<9;i++){
int k=0;
for(int j=0;j<i;j++)
if(a[j]>a[i])
k++;
res+=HASH[i]*k;
}
return res;
}
bool isok(Node tmp){ //求出逆序对,判断是否有解
int a[9],k=0;
for(int i=0;i<3;i++)
for(int j=0;j<3;j++)
a[k++]=tmp.maze[i][j];
int sum=0;
for(int i=0;i<9;i++)
for(int j=i+1;j<9;j++)
if(a[j]&&a[i]&&a[i]>a[j])
sum++;
return !(sum&1); //由于目标解为偶数,所以状态的逆序数为偶数才可行,交换空格,逆序数增幅为偶数,故初始节点和目标的节点的逆序数奇偶性相同
}
int get_h(Node tmp){ //获得估价函数H
int ans=0;
for(int i=0;i<3;i++)
for(int j=0;j<3;j++)
if(tmp.maze[i][j])
ans+=abs(i-(tmp.maze[i][j]-1)/3)+abs(j-(tmp.maze[i][j]-1)%3);
return ans;
}
void astar(){ //搜索
priority_queue<Node>que;
que.push(s);
while(!que.empty()){
u=que.top();
que.pop();
for(int i=0;i<4;i++){
v=u;
v.x+=way[i][0];
v.y+=way[i][1];
if(v.check()){
swap(v.maze[v.x][v.y],v.maze[u.x][u.y]); //将空位和相邻位交换
v.Hash=get_hash(v); //得到HASH值
if(vis[v.Hash]==-1&&isok(v)){ //判断是否已遍历且是否可行,后者可以不要
vis[v.Hash]=i; //保存方向
v.g++;; //已花代价+1
pre[v.Hash]=u.Hash; //保存路径
v.h=get_h(v); //得到新的估价函数H
que.push(v); //入队
}
if(v.Hash==destination)
return ;
}
}
}
}
void print(){
string ans;
ans.clear();
int nxt=destination;
while(pre[nxt]!=-1){ //从终点往起点找路径
switch(vis[nxt]){ //四个方向对应
case 0:ans+='r';break;
case 1:ans+='l';break;
case 2:ans+='d';break;
case 3:ans+='u';break;
}
nxt=pre[nxt];
}
for(int i=ans.size()-1;i>=0;i--)
putchar(ans[i]);
puts("");
}
int main(){
Node test;
/*int value = 0;
for( int i = 0; i < 3; i++ )
{
for( int j = 0; j < 3; j++ )
{
test.maze[i][j] = value++;
}
}*/
//cout << get_hash(test) << endl;
char str[100];
while(gets(str)!=NULL){
int k=0;
memset(vis,-1,sizeof(vis));
memset(pre,-1,sizeof(pre));
for(int i=0;i<3;i++)
for(int j=0;j<3;j++){
if((str[k]<='9'&&str[k]>='0')||str[k]=='x'){
if(str[k]=='x'){
s.maze[i][j]=0;
s.x=i;
s.y=j;
}
else
s.maze[i][j]=str[k]-'0';
}
else
j--;
k++;
}
if(!isok(s)){ //起始状态不可行
printf("unsolvable\n");
continue;
}
s.Hash=get_hash(s);
if(s.Hash==destination){ //起始状态为目标状态
puts("");
continue;
}
vis[s.Hash]=-2;
s.g=0;s.h=get_h(s);
astar();
print();
}
return 0;
}
/*
输入格式:
1234567x8表示:x代表空格,0表示空格
1 2 3
4 5 6
7 0 8
终态:
1 2 3
4 5 6
7 8 0
*/
转载来源:
http://blog.csdn.net/acm_cxlove/article/details/7745323
人工智能A*课件:
http://www.docin.com/p-506506343.html
发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/126116.html原文链接:https://javaforall.cn
【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛
【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...