OK,首先谈谈自己,我是一名大一新生,尽管我学习的是计算机专业,但很遗憾,在大一上学期的课程中并没有为我们开设有关于算法的课程,但急不可耐的我还是选择了先在b站上大学.而根据课程安排,我选择了java,而在昨天我完成了java学习过程中一个可以说比较著名的题目--石头迷阵.然而,相信所有人在这部分学习都遇到了下面这个问题,也就是puzzle,迷宫将会有极大概率出现不可还原的情况,我在第一次测试时便遇到了,后来根据相关的计算方法,我重新测试了5次,也许是我的运气比较差,这5次中没有任何一次是有解的情况,然而,在我所学习的课程中并没有相关问题的解决,为此我查阅了大量资料(泡博客),最终我成功解决了这个问题(应该成功了吧,测试了8次).
接下来首先放出我的完整代码:
/*
此项目名为--石头迷阵,是面向对象的最终测试,指在考察程序员在学习面向对象过程中
所学得的思想以及技术,要求程序员必须能独立完成此项目才算初步掌握面向对象技术,此为
参考模板,对其中的大部分代码进行了注释,用于提供参考学习以及对照.
*/
package stonePuzzle;//创建了一个包
public class Game {//创建一个类
public static void main(String[] args) {//创建一个主方法,主方法是程序的入口
/*
1.绘制界面,第一部分工作为绘制项目窗口中的游戏界面,主要工作面向前端,
核心技术包括创建窗口,窗口调试,设置组件等等,为起手工作;
2.打乱方块,第二部分工作为打乱游戏中的方块,核心思路为打乱二维数组中的编号即遍历二维数组并随机交换元素,
核心技术包括二维数组遍历与Random随机数使用,在此有一个核心问题,需要避免迷宫的不可还原性;
3.移动业务,第三部分,也是核心部分,核心思路为交换数组中元素的位置,具体为找到空白元素,
并将其与玩家输出方向相反方向上的元素交换(当玩家按下<-时,空白块与其右边的方块交换位置),
核心技术包括绑定监听(主要是键盘监听),交换数组元素,实时反馈移动,处理索引越界问题;
4.判定胜利,第四部分,核心思路为创建标准数组,进行元素比对,此部分较为简单,只需注意内存问题以及判断时机即可;
5.统计步数,第五部分,核心思路为构造变量,在每次移动后+1,并实时刷新展示,此部分较为简单;
6.重新游戏,第六部分,构造按钮组件,对统计步数归零并重新打乱迷阵,可运用Lambda表达式简化,同样实现较为简单
*/
new MyFrame();//创建对象,但目的为使该类的构造方法运行,因此不需要前半部分
}
}
温馨提示:我解决问题的方法极其丑陋,请做好心理准备,如产生不适请立刻关闭文章!
package stonePuzzle;
import javax.swing.*;//导包,其中*为通配符
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.util.Random;//导包,准备随机数
public class MyFrame extends JFrame implements KeyListener {//创建了一个窗体类
int[][] data = {//创建二维数组并将其作为成员变量,存储图片编号数据并分层
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12},
{13, 14, 15, 0}
};
int[][] win = {//此为标准数组,用以作为判断依据,写为成员变量避免内存浪费
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12},
{13, 14, 15, 0}
};
int row;//此为零号元素行坐标
int column;//此为零号元素列坐标
int count;//此为计数器变量,用于统计步数
int inverse;//此为逆序数,用于判断迷宫是否无解
public MyFrame() {//构造方法同时初始化窗体和绘制界面
this.addKeyListener(this);//此窗体类同时也是实现类,所以将此类返还回去,此处用于做键盘监听
initFrame();//此方法用于初始化窗体
initData();//此方法用于打乱方块
view();//此方法用于绘制界面
setVisible(true);//设置窗体可见
}
public void initFrame() {//此方法用于初始化窗体
setSize(514, 595);//设置窗体大小,其中宽为514像素点,高为595像素点
setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);//设置窗体关闭模式
setTitle("石头迷阵单机版1.0");//设置窗体标题,也是此次项目的项目名
setAlwaysOnTop(true);//设置窗体置顶,类似phone小窗模式
setLocationRelativeTo(null);//设置窗体居中
setLayout(null);//取消窗体对组件的默认布局
}
public void initData() {//此方法用于初始化数据,即打乱二维数组
Random r = new Random();//创建对象,准备随机数
for (int i = 0; i < data.length; i++) {//遍历二维数组中的一维数组
for (int j = 0; j < data[i].length; j++) {//遍历一维数组中的元素
int x = r.nextInt(4);//创建两个随机索引
int y = r.nextInt(4);
int temp = data[i][j];//接下来三步用于交换原数据与随机数据
data[i][j] = data[x][y];
data[x][y] = temp;
if (data[i][j] == 0) {//在此判断元素是否为零号元素
row = i;//不在此定义这两个变量是为了使移动业务能使用这两个变量
column = j;// 因此在成员变量位置定义出来
}
}
}
puzzle();//接下来对游戏是否可解进行判断
if (row == 1 || row == 3) {//当空白块在第2/4行时,若逆序数为奇数则无法还原
if (inverse / 4 % 2 != 0 && column != 3) {//解决思路为判断空白块是否在第三列,不在的话将其与右边块进行交换
int temp = data[row][column];
data[row][column] = data[row][column + 1];
data[row][column + 1] = temp;
} else if (inverse / 4 % 2 != 0 && column == 3) {//如果空白快在第三列,则将其与左边方块交换
int temp = data[row][column];
data[row][column] = data[row][column - 1];
data[row][column - 1] = temp;
}
} else if (row == 0 || row == 2) {//当空白块在第1/3行时,若逆序数为偶数则无法还原
if (inverse / 4 % 2 == 0 && column != 3) {//解决思路与上面相同
int temp = data[row][column];
data[row][column] = data[row][column + 1];
data[row][column + 1] = temp;
} else if (inverse / 4 % 2 == 0 && column == 3) {
int temp = data[row][column];
data[row][column] = data[row][column - 1];
data[row][column - 1] = temp;
}
}
for (int i = 0; i < data.length; i++) {//在这里需要重新定位空白块,否则上面处理完之后定位会失效
for (int j = 0; j < data[i].length; j++) {
if (data[i][j] == 0) {//在此判断元素是否为零号元素
row = i;//不在此定义这两个变量是为了使移动业务能使用这两个变量
column = j;// 因此在成员变量位置定义出来
}
}
}
}
public void view() {//此方法用于展示界面
getContentPane().removeAll();//移除上一次的界面,否则处理完移动后的界面会被先前的界面掩盖住
if (victory()) {//在此做胜利判断,如果游戏胜利则展示胜利图标
JLabel winLabel = new JLabel(new ImageIcon("C:\\image\\win.png"));
winLabel.setBounds(124, 230, 266, 88);
getContentPane().add(winLabel);
}
JButton btn = new JButton("重新游戏");//在此进行游戏刷新
btn.setBounds(350, 20, 100, 20);
getContentPane().add(btn);
btn.setFocusable(false);//编写此逻辑用于去除焦点,避免移动业务与重新开始业务的冲突
btn.addActionListener(e -> {
count = 0;
initData();
view();
});
JLabel mark = new JLabel("步数:" + count);
mark.setBounds(50, 20, 100, 20);
getContentPane().add(mark);
for (int i = 0; i < 4; i++) {//对坐标进行优化的循环并对二维数组遍历
for (int j = 0; j < 4; j++) {
JLabel ImageLabel = new JLabel(new ImageIcon("C:\\image\\" + data[i][j] + ".png"));//创建图片组件对象并导入图片
ImageLabel.setBounds(50 + 100 * j, 90 + 100 * i, 100, 100);//设置图片在窗体中的起始坐标,宽高都为100的正方形
getContentPane().add(ImageLabel);//调用面板对象并添加组件
}
}
JLabel background = new JLabel(new ImageIcon("C:\\image\\background.png"));//调出背景图,越往后的组件在后层展示
background.setBounds(26, 30, 450, 484);
getContentPane().add(background);
getContentPane().repaint();//刷新面板,使玩家看到操作后的界面
}
@Override
public void keyPressed(KeyEvent e) {//此方法做键盘监听
int keyCode = e.getKeyCode();//获取监听对象
move(keyCode);//此方法用于处理移动业务
view();//在移动过后重新加载界面,否则移动无法反馈到玩家眼中
}
private void move(int keyCode) {//此方法用于移动
if (victory()) {//在此做胜利判断,如果游戏结束则不让玩家继续移动
return;
}
if (keyCode == 37) {//判断玩家触发向左移动
if (column == 3) {//if循环嵌套,判断当空白快处于最右端时不可进行左移动业务
return;
}
int temp = data[row][column];//接下来三步用于实现左移动业务
data[row][column] = data[row][column + 1];
data[row][column + 1] = temp;
column++;//由于空白块此时发生了位置改变,所以列坐标需要+1
count++;//移动成功则进行步数累积
} else if (keyCode == 38) {//判断玩家触发向上移动
if (row == 3) {//if循环嵌套,判断当空白快处于最下端时不可进行上移动业务
return;
}
int temp = data[row][column];//接下来三步用于实现上移动业务
data[row][column] = data[row + 1][column];
data[row + 1][column] = temp;
row++;//由于空白块此时发生了位置改变,所以行坐标需要+1
count++;//移动成功则进行步数累积
} else if (keyCode == 39) {//判断玩家触发向右移动
if (column == 0) {//if循环嵌套,判断当空白快处于最左端时不可进行右移动业务
return;
}
int temp = data[row][column];//接下来三步用于实现右移动业务
data[row][column] = data[row][column - 1];
data[row][column - 1] = temp;
column--;//由于空白块此时发生了位置改变,所以列坐标需要-1
count++;//移动成功则进行步数累积
} else if (keyCode == 40) {//判断玩家触发向下移动
if (row == 0) {//if循环嵌套,判断当空白快处于最上端时不可进行下移动业务
return;
}
int temp = data[row][column];//接下来三步用于实现上移动业务
data[row][column] = data[row - 1][column];
data[row - 1][column] = temp;
row--;//由于空白块此时发生了位置改变,所以行坐标需要-1
count++;//移动成功则进行步数累积
} else if (keyCode == 90) {//这是干啥用的呢?
cheating();//我猜你不会想用它,说真的!
}
}
public boolean victory() {//此方法用于判断游戏是否胜利
for (int i = 0; i < data.length; i++) {//遍历二维数组将每个元素与标准数组比对
for (int j = 0; j < data[i].length; j++) {
if (data[i][j] != win[i][j]) {//在此判断只要有一个元素不同则还未胜利
return false;
}
}
}
return true;//若程序走到了这一步则说明游戏胜利
}
private final void cheating() {//好吧,这是一个@!#$%^&*
data = new int[][]{
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12},
{13, 14, 15, 0}
};
count = 0;
}
public void puzzle() {//此方法用于计算逆序数
for (int i = 0; i < data.length; i++) {
for (int j = 0; j < data[i].length; j++) {
if (data[i][j] != 0 && data[i][0] > data[i][1]) {
inverse++;
}
if (data[i][j] != 0 && data[i][0] > data[i][2]) {
inverse++;
}
if (data[i][j] != 0 && data[i][0] > data[i][3]) {
inverse++;
}
if (data[i][j] != 0 && data[i][1] > data[i][2]) {
inverse++;
}
if (data[i][j] != 0 && data[i][1] > data[i][3]) {
inverse++;
}
if (data[i][j] != 0 && data[i][2] > data[i][3]) {
inverse++;
}
}
}
}
@Override
public void keyTyped(KeyEvent e) {
}//空方法,无逻辑,仅满足实现接口
@Override
public void keyReleased(KeyEvent e) {
}//空方法,无逻辑,仅满足实现接口
}
好的,如你所见,我加入了非常多的注释,因为我是一名新手,而这篇文章旨在面向同样跟我一样的学徒,同时也是谈谈我解决问题的过程.
那么谈到我的解决思路呢,要首先感谢一位大佬
受到这位大佬的文章启发,利用高等代数中的一个概念--逆序数.详情参考高等教育出版社发布的《高等代数》第五版中的P35的定义2,我简单描述一下,比如下面这个数组{8,6,2,9},那么首先我们随机找出一个数,将其与它右边的随机一个数组合起来,那么我们能得到的组合是(8,6)(8,2)(8,9)(6,2)(6,9)(2,9),依据书上的概念,这样的一对数如果左边的数小于右边则为顺序,也就是(8,9)(6,9)(2,9),如果左边的数大于右边,则为逆序,也就是(8,6)(8,2)(6,2),那么逆序的总数就是逆序数,也就是这个数组的逆序数为3.
而根据冰川大佬的文章,直接说结论,不将空白块带入逆序数的计算的话,对于四阶拼图,空白块在4、2行(行索引为3、1)时,逆序数为偶数才能保证拼图可还原,空白块在3、1行(行索引为2、0)时,逆序数为奇数才能保证拼图可还原,有兴趣的建议看看这位大佬的原文章,而根据这位大佬的文章,我冥思苦想写出了下面这几段恶心反胃的代码.
public void puzzle() {//此方法用于计算逆序数
for (int i = 0; i < data.length; i++) {
for (int j = 0; j < data[i].length; j++) {
if (data[i][j] != 0 && data[i][0] > data[i][1]) {
inverse++;
}
if (data[i][j] != 0 && data[i][0] > data[i][2]) {
inverse++;
}
if (data[i][j] != 0 && data[i][0] > data[i][3]) {
inverse++;
}
if (data[i][j] != 0 && data[i][1] > data[i][2]) {
inverse++;
}
if (data[i][j] != 0 && data[i][1] > data[i][3]) {
inverse++;
}
if (data[i][j] != 0 && data[i][2] > data[i][3]) {
inverse++;
}
}
}
}
是的,如你所见,我遍历了打乱重排后的数组,首先判断它们是否是空白块,如果不是的话就根据逆序数的定义对其判断,判断成功则让inverse这个变量+1,值得一提的是,在后面的使用中,你必须对手上的数据进行/4的操作,因为在遍历一维数组提取每个元素时,判断操作被重复进行了4次,因此你其实可以只对这个二维数组提取一次,也就是获取每个一维数组,换句话来说获取每一行的数据进行判断,但因为我要判断它是否为空白块,因此我选择遍历这个二维数组,因为我需要变量j.遗憾的是,我也尝试去优化这个代码,比如利用for循环,带入数学公式等,然而,我要考虑到索引越界的问题,因此,如果我用j+1,j+2...这种方式的话,我还要追加判断j的值,这反而会让代码阅读性下降,工作量和思考量变大,终归结底还是我太菜了,积累的代码量太少.
而在获取了逆序数之后,根据冰川大佬的结论,我进行了下面这段无比狗屎的操作:
public void initData() {//此方法用于初始化数据,即打乱二维数组
Random r = new Random();//创建对象,准备随机数
for (int i = 0; i < data.length; i++) {//遍历二维数组中的一维数组
for (int j = 0; j < data[i].length; j++) {//遍历一维数组中的元素
int x = r.nextInt(4);//创建两个随机索引
int y = r.nextInt(4);
int temp = data[i][j];//接下来三步用于交换原数据与随机数据
data[i][j] = data[x][y];
data[x][y] = temp;
if (data[i][j] == 0) {//在此判断元素是否为零号元素
row = i;//不在此定义这两个变量是为了使移动业务能使用这两个变量
column = j;// 因此在成员变量位置定义出来
}
}
}
puzzle();//接下来对游戏是否可解进行判断
if (row == 1 || row == 3) {//当空白块在第2/4行时,若逆序数为奇数则无法还原
if (inverse / 4 % 2 != 0 && column != 3) {//解决思路为判断空白块是否在第三列,不在的话将其与右边块进行交换
int temp = data[row][column];
data[row][column] = data[row][column + 1];
data[row][column + 1] = temp;
} else if (inverse / 4 % 2 != 0 && column == 3) {//如果空白快在第三列,则将其与左边方块交换
int temp = data[row][column];
data[row][column] = data[row][column - 1];
data[row][column - 1] = temp;
}
} else if (row == 0 || row == 2) {//当空白块在第1/3行时,若逆序数为偶数则无法还原
if (inverse / 4 % 2 == 0 && column != 3) {//解决思路与上面相同
int temp = data[row][column];
data[row][column] = data[row][column + 1];
data[row][column + 1] = temp;
} else if (inverse / 4 % 2 == 0 && column == 3) {
int temp = data[row][column];
data[row][column] = data[row][column - 1];
data[row][column - 1] = temp;
}
}
for (int i = 0; i < data.length; i++) {//在这里需要重新定位空白块,否则上面处理完之后定位会失效
for (int j = 0; j < data[i].length; j++) {
if (data[i][j] == 0) {//在此判断元素是否为零号元素
row = i;//不在此定义这两个变量是为了使移动业务能使用这两个变量
column = j;// 因此在成员变量位置定义出来
}
}
}
}
是的,如你所见,我在前面定位出空白块之后调用了puzzle这个方法找出逆序数,然后依据结论解决问题,而我解决问题的方式参考到了另一位大佬的文章,但是我忘了是哪一篇,因为这些代码我是昨天晚上完成的,今早才决定发布我的第一篇博客,简单来说,那位大佬给出的解决方案是让空白块与其周边的八个方块中的随机一个完成交换就行,但是在这过程中同样要考虑索引越界问题(而且我懒),因此我直接简单判断如果它不在最右侧就跟它右侧交换,如果它在最右侧就跟它左侧交换.
但是,现在想想,也许随机找两个相邻的方块进行交换就可以了,比如直接交换data[0][0]和data[0][1],这似乎也行?好吧,我懒得实验,但是这种解决方案同样存在着问题,就是交换的两个元素其代表的图片并没有交换,这就像两个人交换了灵魂,但肉体并没有交换,不过其实反馈到玩家眼中似乎并没有什么所谓,也许有,如果不重新定位那两张图片的话似乎这也是无用功,emm...无所谓,我说了我懒得实验.
这里要特别注意的是我刚刚所说的交换灵魂的问题,也就是说完成空白块的交换后你必须重新定位空白块,否则焦点就会转移到被你交换的方块上,那玩起来实在太别扭了.
大概也就是这些了,我承认我所写出来的代码恶心又难看,但无论如何,这算是我第一次自己查找资料,寻求解决方法,有点程序员工作那味儿了.再次重申,如果我的代码令你感到不适那我非常抱歉,反正我也不会负责,这只是一个java学徒在自己解决问题后的一次自嗨罢了,如果你能从中得到灵感或者学到些什么那我非常荣幸,感谢你能看到这里!