分类 算法 下的文章

导语

好久没更新,从去年十月份来,工作突然很忙,平时剩下的时间用来学习、健身,给到总结写作,没剩下时间。加上人每隔一段时间,都会产生懈怠,就停更个人技术博客近半年。想想去年因为疫情在家办公期间,某种程度上来看,还是很幸福的,上班不用来回坐车,节省下来的时间,可以花在自己感兴趣的事情上,产生一些输出。

OK,言归正传,今天闲来无事,我翻出了女儿去年玩过的小游戏,她妈妈给她买的这类智力小游戏有不少,很偶尔会瞧见她们娘俩一起玩,估计是不太感兴趣。今天这款游戏,我小时候没见过,是一个新的小游戏,我就起一个应景的名字,《兔兔大战小狐狸》,今年是兔年,兔子要逃脱小狐狸的围追堵截,逃到安全的小土坑里,躲起来。

WechatIMG53.jpeg

游戏介绍

游戏棋盘是一个5*5的方盘,左上、右上、左下、右下以及正中间有5个小土坑,如果棋盘上所有的兔子,都在土坑待着,就完成了任务。具体规则,我不码字,看如下说明:
WechatIMG51.jpeg

如上面所说,游戏内置了60个关卡(因为随便摆放,是不一定能完成任务的),也有最少步骤的解答,我自己尝试玩了几个简单关卡(最少10步以内可以完成的),貌似还挺简单的。

然后,我翻到了关卡的最后4个关卡,哇,一下子震惊了,好难,最后一关官方解答,需要87步完成任务。
WechatIMG50.jpeg

WechatIMG49.jpeg

一些疑问

小朋友如果自己完成前面简单、中等题目,已经很不容易了。至于困难、专家级别的题目,如果能完成,小脑瓜不是一般聪明,非常了不起。但我想了下,官方说最后一题87步是最少步骤,这个怎么证明官方没错呢?或者有更少步骤的解法?我心想,是不是写个算法求解,看看是否是对的。

求解过程

首先是需要有个建模过程,棋盘规格是5*5方盘,上面有25个位置,5个位置是土坑。可以放置的角色是小狐狸、兔兔和蘑菇。兔兔和蘑菇都只占用一个格子,狐狸占用两个格子,还有头和尾巴,但我仔细想了下,狐狸头和尾巴,其实在算法中不需要区分,只需要区分狐狸是竖着还是横着放置的,你可以理解为狐狸的头总是在左边或者上方,尾巴在右边或者下方。这样对算法实现更简单。

比如60题,我会使用5个字符串表示5行,

"...X."
"FF.R."
"..X.R"
"X...."
"...R."

字符X表示蘑菇,F表示小狐狸(头或者尾),R表示小兔兔,最后.表示空位。

算法显然就是搜索算法,DFS还是BFS呢?我先用DFS实现了半天,会TLE,根本算不出最少步骤的解法。我后来想了下,最少步骤的解法确实不太适合DFS,因为DFS可能需要搜索完所有的解法空间(也可能我剪枝还不够),所以无法算出最少步骤。在DFS上,我其实花了不少时间优化,这个题目比刷题难,因为是自己出给自己的,也没有什么参考答案,都只能自己去想,如何建模和实现搜索算法,总之,自己试了半天,也不work。

OK,那么我就来试下BFS搜索,大概的逻辑步骤如下:

1. 建立一个队列,将游戏初始棋盘局面放到队列中;
2. 一一取出当前局面,评估可能变为的下一个局面;
3. 评估当前局面可能可以行动的小兔兔、小狐狸坐标;
4. 一一取出小兔兔、小狐狸,评估她们可能的移动到的位置;
5. 通过改变小兔兔、小狐狸位置,得到下一个局面;
6. 将2~5变化的到的局面插入队列(下一轮);
7. 遍历完上一轮队列,步骤+1;
8. 把下一轮队列变为需要遍历的当前队列,重复2~7步骤;

代码实现:

int smartGame(vector<string> board) {
    int n = board.size();

    // 判断游戏是否结束
    set<vector<int>> finish_pos = {{0, 0}, {0, n-1}, {n-1, 0}, {n-1, n-1}, {n/2, n/2}};
    function<bool(vector<vector<int>>)> isFinished = [&](vector<vector<int>> ani_pos) -> bool {
        int n = ani_pos.size();

        for (int i = 0; i < n; ++i) {
            if (ani_pos[i][2] != -1) continue;
            vector<int> pos = {ani_pos[i][0], ani_pos[i][1]};
            if (finish_pos.find(pos) == finish_pos.end())
                return false;
        }

        return true;
    };

    // 分析棋盘局面,哪些可能移动的小兔兔、小狐狸所在位置
    function<void(vector<string>, vector<vector<int>>&)> analyzeBoard = [&](vector<string> board, vector<vector<int>>& ani_pos) {
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < n; ++j) {
                if (board[i][j] == 'X') {
                    continue;
                } else if (board[i][j] == '.') {
                    continue;
                } else if (board[i][j] == 'F') {
                    if ((i == 0 || board[i - 1][j] != 'F') && (i < n - 1 && board[i + 1][j] == 'F')) {
                        // vertical fox
                        ani_pos.push_back({i, j, 1});
                    } else if ((j == 0 || board[i][j - 1] != 'F') && (j < n - 1 && board[i][j + 1] == 'F')) {
                        // horizontal fox
                        ani_pos.push_back({i, j, 2});
                    }
                } else if (board[i][j] == 'R') {
                    ani_pos.push_back({i, j, -1});
                }
            }
        }
    };

    // 棋盘局面上某个小兔兔、小狐狸,做出某个移动后,返回新的局面
    function<vector<string>(vector<string>, vector<int>, vector<int>)> applyBoard = [&] (vector<string> board, vector<int> pos, vector<int> move) -> vector<string> {
        if (pos[2] == -1) {
            board[pos[0]][pos[1]] = '.';
            board[pos[0]+move[0]][pos[1]+move[1]] = 'R';
        } else {
            if (pos[2] == 1) {
                board[pos[0]][pos[1]] = '.';
                board[pos[0]+1][pos[1]] = '.';

                board[pos[0]+move[0]][pos[1]+move[1]] = 'F';
                board[pos[0]+move[0]+1][pos[1]+move[1]] = 'F';
            } else if (pos[2] == 2) {
                board[pos[0]][pos[1]] = '.';
                board[pos[0]][pos[1]+1] = '.';

                board[pos[0]+move[0]][pos[1]+move[1]] = 'F';
                board[pos[0]+move[0]][pos[1]+move[1]+1] = 'F';
            }
        }

        return board;
    };

    // 分析棋盘局面,可能移动的小兔兔、小狐狸,能做出哪些移动
    function<vector<vector<int>>(vector<string>, vector<int>)> nextMoves = [&] (vector<string> board, vector<int> pos) -> vector<vector<int>> {
        vector<vector<int>> moves;

        if (pos[2] == -1) {
            // up
            vector<int> t = pos;
            while (t[0] >= 0 && board[t[0]][t[1]] != '.') t[0] -= 1;
            if (t[0] >= 0 && board[t[0]][t[1]] == '.' && t[0] < pos[0]-1) {
                moves.push_back({t[0]-pos[0], 0});
            }
            // down
            t = pos;
            while (t[0] < n && board[t[0]][t[1]] != '.') t[0] += 1;
            if (t[0] < n && board[t[0]][t[1]] == '.' && t[0] > pos[0]+1) {
                moves.push_back({t[0]-pos[0],0});
            }
            // left
            t = pos;
            while (t[1] >= 0 && board[t[0]][t[1]] != '.') t[1] -= 1;
            if (t[1] >= 0 && board[t[0]][t[1]] == '.' && t[1] < pos[1]-1) {
                moves.push_back({0, t[1]-pos[1]});
            }
            // right
            t = pos;
            while (t[1] < n && board[t[0]][t[1]] != '.') t[1] += 1;
            if (t[1] < n && board[t[0]][t[1]] == '.' && t[1] > pos[1]+1) {
                moves.push_back({0, t[1]-pos[1]});
            }
        } else if (pos.size() == 3) {
            if (pos[2] == 1) {
                // up
                if (pos[0] > 0 && board[pos[0]-1][pos[1]] == '.') {
                    moves.push_back({-1, 0});
                }
                // down
                if (pos[0] < n-2 && board[pos[0]+2][pos[1]] == '.') {
                    moves.push_back({1, 0});
                }
            } else if (pos[2] == 2) {
                // left
                if (pos[1] > 0 && board[pos[0]][pos[1]-1] == '.') {
                    moves.push_back({0, -1});
                }
                // right
                if (pos[1] < n-2 && board[pos[0]][pos[1]+2] == '.') {
                    moves.push_back({0, 1});
                }
            }
        }

        return moves;
    };

    // 棋盘局面的签名,用作去重
    function<string(vector<string>)> signature = [&](vector<string> board) -> string {
        string r;
        for (auto l : board) {
            r += l;
        }
        return r;
    };

    // 1. 初始棋盘局面,入到队列
    queue<vector<string>> q;
    unordered_set<string> seen;
    q.push(board);
    seen.insert(signature(board));

    int ans = 0;

    while (!q.empty()) {
        int size = q.size();
        while (size --) {
            // 2. 一一取出当前局面,评估可能变为的下一个局面;
            vector<string> t = q.front();
            q.pop();
            vector<vector<int>> ani_pos;

            // 3. 评估当前局面可能可以行动的小兔兔、小狐狸坐标;
            analyzeBoard(t, ani_pos);

            if (isFinished(ani_pos)) {
                return ans;
            }

            // 4. 一一取出小兔兔、小狐狸,评估她们可能的移动到的位置;
            for (auto pos: ani_pos) {
                vector<vector<int>> moves = nextMoves(t, pos);
                for (int i = 0; i < moves.size(); ++i) {
                    vector<int> move = moves[i];
                    // 5. 通过改变小兔兔、小狐狸位置,得到下一个局面;
                    vector<string> nxtBoard = applyBoard(t, pos, move);
                    string sig = signature(nxtBoard);
                    if (seen.find(sig) == seen.end()) {
                        // 6. 将2~5变化的到的局面插入队列(下一轮);
                        q.push(nxtBoard);
                        seen.insert(sig);
                    }
                }
            }
        }

        // 7. 遍历完上一轮队列,步骤+1;
        // 8. 把下一轮队列变为需要遍历的当前队列,重复2~7步骤;
        ans++;
    }

    return -1;
}

还要强调一点优化,我用了一个unordered_set去做棋盘局面去重,曾经看见过的局面,就不需要再去考虑了,因为即使从该局面出发有解,肯定也不会比之前出现时更优的,这个很好理解吧。

结论

最终通过算法验证,最后4题的最优解确实和官方的步骤数量一样,并且我后来记录了最优解算法的到的步骤,虽然有些顺序和官方题解不一致,我仔细比对了下,仅仅是某些步骤中,可以交换顺序而已,不是计算错误。别看是思路很简单,就是BFS搜索算法,但是面对一个没有参考答案的问题,建模、优化和调试,哪一样都不轻松,很开心,今天可以完成任务,并且写下这篇文章,明天继续加油~

导语

最近在学python数据处理的几个常用库,分别是numpy、matplotlib和pandas。这几个库应该是python数据处理的第三方神器库,学了一招图像手绘风格处理,非常有意思,记录下来。

图像手绘风格

手绘风格示例:
WechatIMG19.jpeg

WechatIMG19HD2.jpeg

第二幅就是有手绘的感觉,当然是基于第一章图片处理得到的。手绘风格可以总结为以下几点:

  • 黑白灰色
  • 边界线条较重
  • 相邻的像素,如果是相同或者相近色彩趋于白色
  • 略有光源效果

具体如何通过程序实现呢?接下来就来逐一介绍下。

梯度的重构

利用像素之间的梯度值和虚拟深度值对图像进行重构,根据灰度变换来模拟人类视觉的明暗程度。代码如下:

import numpy as np
from PIL import Image

a = np.asarray(Image.open('xxx.jpeg').convert('L')).astype('float')

depth = 10.
grad = np.gradient(a)
grad_x, grad_y = grad
grad_x = grad_x*depth/100.
grad_y = grad_y*depth/100.

下面这行代码是通过pillow库读取原先图像,RGB值转为灰度值。

a = np.asarray(Image.open('xxx.jpeg').convert('L')).astype('float')

这几行代码是计算灰度图像的像素间梯度,并且根据虚拟深度重构梯度值,depth初始化为10,depth取值0~100。

depth = 10.
grad = np.gradient(a)
grad_x, grad_y = grad
grad_x = grad_x*depth/100.
grad_y = grad_y*depth/100.

光源效果

根据灰度变化来模拟人类视觉的远近程度。
手绘风格光源模型图.png

  • 设计一个位于图像斜上方的虚拟光源
  • 光源相对于图像的俯视角为Elevation,方位角为Azimuth
  • 建立光源对各个点梯度值的影响函数
  • 运算各个点的新像素值

代码如下:

vec_el = np.pi/2.2                  # 光源的俯视角度,弧度值
vec_az = np.pi/4.                   # 光源的方位角度,弧度值
dx = np.cos(vec_el)*np.cos(vec_az)  # 光源对x轴的影响
dy = np.cos(vec_el)*np.sin(vec_az)  # 光源对y轴的影响
dz = np.sin(vec_el)                 # 光源对z轴的影响

A = np.sqrt(grad_x**2 + grad_y**2 + 1.)     # 光源归一化
uni_x = grad_x/A
uni_y = grad_y/A
uni_z = 1./A
b = 255*(dx*uni_x+dy*uni_y+dz*uni_z)

最后,对像素值进行裁剪至0~255,防止越界。

b = b.clip(0, 255)
im = Image.fromarray(b.astype('uint8'))
im.save('xxxHD.jpeg')

最后

程序虽然小,效果还是比较有意思的,我给女儿看了,她说你画画不是很丑的吗,为啥画的这么好?哈哈,我和她说,这是计算机处理的,不是我手画的,小小的年纪充满大大的问号。

其实,这里最重要的是对图像灰度值计算梯度,这是Computer Vision里面传统图像处理的关键,CV相关的技术,由于可以可视化,实现之后,还是比较有意思的。

导语

上周记录了极客挑战赛的情况,其中一道非常有趣的题,本周继续,本周题目也是游戏,大概的游戏形式是横版闯关类的:
一心的峡谷.png

还是借用网上游戏的图片说明下大概的意思,玩家一个英雄,进入一个场景(横版的),会有一个(每个场景仅一个)怪物与玩家英雄对抗,玩家有血条,有蓝量,6个技能(其中1个技能是升级其他5个技能),然后需要做的就是打到场景中的怪物,冲往下一个关卡。

摸索

有了之前游戏的经验之后,我知道了,上来就是一顿摸索,其实和上周说的游戏类似,客户端是个状态机,英雄有血量和蓝量,每次和怪物对抗之后,客户端会上传一堆指令到服务端,服务端重放这一堆指令,看最终的英雄状态和怪物状态是否合法,是不是英雄还活着,怪物已经死亡,然后就可以进行到下一关。

一共是有9个怪物,一旦进入游戏,可以得到所有怪物的属性:

bosses = [
    {
        "Name": "小壶守卫",
        "Model": "Boss_ball",
        "Hp": 396,
        "Skill1_harm": 63,
        "Skill2_harm": 71,
        "Award_hp": 133,
        "Award_mp": 1695,
        "New_star": 0
    },
    {
        "Name": "苇名二心",
        "Model": "Boss_sword",
        "Hp": 532,
        "Skill1_harm": 59,
        "Skill2_harm": 89,
        "Award_hp": 157,
        "Award_mp": 1678,
        "New_star": 0
    },
    {
        "Name": "古达",
        "Model": "Boss_guda",
        "Hp": 643,
        "Skill1_harm": 57,
        "Skill2_harm": 97,
        "Award_hp": 142,
        "Award_mp": 1362,
        "New_star": 1
    },
    {
        "Name": "中壶守卫",
        "Model": "Boss_ball",
        "Hp": 668,
        "Skill1_harm": 59,
        "Skill2_harm": 67,
        "Award_hp": 178,
        "Award_mp": 1458,
        "New_star": 0
    },
    {
        "Name": "苇名三心",
        "Model": "Boss_sword",
        "Hp": 687,
        "Skill1_harm": 31,
        "Skill2_harm": 101,
        "Award_hp": 212,
        "Award_mp": 2659,
        "New_star": 0
    },
    {
        "Name": "黑古达",
        "Model": "Boss_guda",
        "Hp": 852,
        "Skill1_harm": 77,
        "Skill2_harm": 91,
        "Award_hp": 132,
        "Award_mp": 4157,
        "New_star": 2
    },
    {
        "Name": "大壶守卫",
        "Model": "Boss_ball",
        "Hp": 1502,
        "Skill1_harm": 71,
        "Skill2_harm": 105,
        "Award_hp": 154,
        "Award_mp": 3089,
        "New_star": 0
    },
    {
        "Name": "苇名零心",
        "Model": "Boss_sword",
        "Hp": 1611,
        "Skill1_harm": 97,
        "Skill2_harm": 127,
        "Award_hp": 189,
        "Award_mp": 6493,
        "New_star": 0
    },
    {
        "Name": "超黑古达",
        "Model": "Boss_guda",
        "Hp": 2988,
        "Skill1_harm": 101,
        "Skill2_harm": 153,
        "Award_hp": 0,
        "Award_mp": 0,
        "New_star": 3
    }
]

简单讲一下“Award_hp”和“Award_mp”这两个值,就是你击败怪物后,得到的奖励,可以分别增加对应的hp和mp,其他字段就是对应意思了。

hero = {
    "Hp": 520,
    "Mp": 1806,
    "Power": {
        "Cost_mp": 233,
        "Attack_increase": [
            {
                "skill_i": 1,
                "skill_j": 1,
                "skill_k": 1,
                "skill_l": 1,
                "skill_u": 1
            },
            {
                "skill_i": 6,
                "skill_j": 1,
                "skill_k": 2,
                "skill_l": 3,
                "skill_u": 5
            },
            {
                "skill_i": 6,
                "skill_j": 2,
                "skill_k": 2,
                "skill_l": 3,
                "skill_u": 4
            },
            {
                "skill_i": 6,
                "skill_j": 1,
                "skill_k": 2,
                "skill_l": 3,
                "skill_u": 4
            },
            {
                "skill_i": 6,
                "skill_j": 2,
                "skill_k": 2,
                "skill_l": 3,
                "skill_u": 4
            },
            {
                "skill_i": 6,
                "skill_j": 2,
                "skill_k": 3,
                "skill_l": 3,
                "skill_u": 5
            },
            {
                "skill_i": 7,
                "skill_j": 1,
                "skill_k": 2,
                "skill_l": 4,
                "skill_u": 5
            },
            {
                "skill_i": 7,
                "skill_j": 2,
                "skill_k": 3,
                "skill_l": 3,
                "skill_u": 5
            },
            {
                "skill_i": 7,
                "skill_j": 2,
                "skill_k": 2,
                "skill_l": 4,
                "skill_u": 5
            },
            {
                "skill_i": 7,
                "skill_j": 2,
                "skill_k": 3,
                "skill_l": 3,
                "skill_u": 5
            },
            {
                "skill_i": 7,
                "skill_j": 2,
                "skill_k": 3,
                "skill_l": 4,
                "skill_u": 5
            }
        ]
    },
    "Skills": {
        "skill_i": {
            "Cost_mp": 489,
            "Attack_hp": 187
        },
        "skill_j": {
            "Cost_mp": 134,
            "Attack_hp": 50
        },
        "skill_k": {
            "Cost_mp": 186,
            "Attack_hp": 70
        },
        "skill_l": {
            "Cost_mp": 251,
            "Attack_hp": 95
        },
        "skill_u": {
            "Cost_mp": 352,
            "Attack_hp": 134
        }
    }
}

经过几次手打的尝试之后,客户端上传的指令,类似["skill", "skill_j"],["attack", "Hero", "Boss", "skill_j"]这两条连续指令,表示英雄发动了技能skill_j,并且命中了怪物,反过来,怪物击中英雄也有类似连续指令,不展示了。手打很容易通过前三关,后面几关越来越难,手残党直接放弃。

思考一番之后,其实客户端只需要传送英雄攻击怪物的动作序列即可,不需要构造英雄“挨打”的序列,嘻嘻~所以问题转换为英雄的蓝量够不够击败怪物。如果不存在技能升级,那么就是一个背包问题,算是一个无限背包问题(实际上整体问题属于一个有限背包问题,因为技能其实是有施放次数上限,这里计算背包问题时,先不考虑)。

确定了是无限背包问题,也有两条建模路径:

  • 确定的蓝量能够产生最大的耗血量;
  • 确定的血量用最少多少蓝量可以完成;

OK,二选一,上来我选择了第一条,很快能写出背包问题代码:

# W指的是最大的蓝量,w是技能耗的蓝量树组,v是技能产生的伤害
# 最后dp返回的数组,每一个元素有两位[x, y],x表示产生的最大伤害,y表示达到这个最大伤害使用的技能序号
def knapsack01D(w, v, W):
    n = len(w)
    dp = []
    for i in range(W+1):
        dp.append([0,-1])

    for i in range(n):
        knapsackComplete(dp, w[i], v[i], i)

def knapsackComplete(dp, w, v, i):
    W = len(dp)
    j = w
    while j < W:
        if dp[j-w][0]+v > dp[j][0]:
            dp[j][2] = i
        dp[j][0] = max(dp[j-w][0]+v, dp[j][0])
        j += 1

虽然背包代码写得很轻松,但是后面就陷入了一个死胡同,我拿着这个函数,疯狂尝试,怎么试呢?前面1~3关我不升级技能,看能不能通过,发现不行,那升一级,发现可以通关,升两级不行,那我就升一级,然后到了4~6关,我再用此策略,结果发现弄来弄去,只能通过4~6关,获得第二颗星星的奖励,死活打不过7~9关怪物。
绝望你经历过吗.jpeg

问题建模

当我要放弃的时候,我看了一眼答案提示,答案没有代码实现哈,但其中有一句话点醒了我:

设状态 dpAi 表示强化i次的情况下,伤害j血量的最小蓝耗,dpBi 表示强化i次的情况下,完成j这个boss之后剩余的最大蓝耗,然后两个dp依次计算,就可以得到最终策略了。

这句话得多读几遍,也就是说,我们可以把所有升级后的对应血量蓝耗情况都计算出来,然后还得多算一个矩阵,表示强化了i次情况下,击败第j个怪物之后,剩余的最大蓝耗,首先上面我算的蓝耗和血量的背包问题,反过来建模,通过确定血量耗最少蓝的结果,去计算击败某个怪物之后剩余蓝量。

想清楚整体模型之后,动手写了代码,确定血量消耗最小蓝耗问题,也不是特别难,基于这个结果,去构建完成j这个boss之后剩余的最大蓝耗,稍微多想下,也可以写出来,代码大致如下:

# 根据hero和boss的数组初始化背包问题相对应的值
# dp最后保存的是血量j对应消耗最小蓝耗
# dp2表示哪个技能实施后达到的最小蓝耗(就是去往下一个最佳状态,需要施放的技能)
v = [489, 134, 186, 251, 352]
w = [187, 50, 70, 95, 134]
ws = [[]] * 11
W = 2988
dp = []
dp2 = []
skills = ["skill_i", "skill_j", "skill_k", "skill_l", "skill_u"]
for i in range(11):
    for j in range(len(skills)):
        w[j] = w[j] + hero['Power']['Attack_increase'][i][skills[j]]
    ws[i] = copy.deepcopy(w)
    t1, t2 = knapsack01D2(ws[i], v, W)
    dp.append(copy.deepcopy(t1))
    dp2.append(copy.deepcopy(t2))

# matrix[i][j]表示i次强化后,怪物j被打败之后,剩余的蓝量
matrix = []
for i in range(11):
    matrix.append([-1]*9)

bosses_hp = [396, 532, 643, 668, 687, 852, 1502, 1611, 2988]
bosses_award = [1695, 1678, 1362, 1458, 2659, 4157, 3089, 6493, 0]
initial = 1806-134

for i in range(11):
    if initial >= (i+1)*233+dp[i][bosses_hp[0]]:
        matrix[i][0] = initial-(i+1)*233-dp[i][bosses_hp[0]]

    if matrix[i][0] >= 0:
        matrix[i][0] += bosses_award[0]

for i in range(9)[1:]:
    if matrix[0][i-1] >= dp[0][bosses_hp[i]]:
        matrix[0][i] = matrix[0][i-1] - dp[0][bosses_hp[i]]

    if matrix[0][i] >= 0:
        matrix[0][i] += bosses_award[i]

print(dp[6][1502])

tracing = [[]]*9
for j in range(9)[1:]:
    r = 0
    for i in range(11)[1:]:
        for k in range(i+1):
            if matrix[k][j-1] >= 233*(i-k) and matrix[k][j-1]-233*(i-k)-dp[i][bosses_hp[j]] >= 0:
                matrix[i][j] = max(matrix[i][j], matrix[k][j-1]-233*(i-k)-dp[i][bosses_hp[j]])

            if matrix[i][j] > r:
                r = matrix[i][j]
                tracing[j] = [k, i]
        if matrix[i][j] >= 0:
            matrix[i][j] += bosses_award[j]

matrix矩阵的最终结果:

[[2096, 2382, 2065, 1779, -1,   -1,   -1,   -1,   -1],
 [1897, 2232, 1967, 1743, 2671, 4680, 3981, -1,   -1],
 [1696, 2067, 1851, 1666, -1,   4508, 3923, -1,   -1],
 [-1,   1866, 1692, 1556, -1,   4331, 3844, 6507, -1],
 [-1,   1679, 1548, 1461, -1,   4160, 3777, 6547, -1],
 [-1,   -1,   1364, -1,   -1,   -1,   3642, 6520, -1],
 [-1,   -1,   -1,   -1,   -1,   -1,   3510, 6502, 2],
 [-1,   -1,   -1,   -1,   -1,   -1,   3382, -1,   -1],
 [-1,   -1,   -1,   -1,   -1,   -1,   3236, -1,   -1],
 [-1,   -1,   -1,   -1,   -1,   -1,   3095, -1,   -1],
 [-1,   -1,   -1,   -1,   -1,   -1,   -1,   -1,   -1]]

可以看到,最终要打败第9个怪物,只有一种升级的可能性,其他升级方法都不可行,但是具体怎么升级呢?眼睛看,也能看出来,就没有写代码找了,因为需要升级的话,肯定是越早越好的,应该在打第一个怪物时做1次升级(本来游戏规定要升级1次,也就是第1行已经是升级过的情况),第5个怪物升级3次,第6个怪物再升级2次,叭叭叭,顺利通关~~
嘬嘬嘬.jpeg

结论

这题的关键是怎么把背包问题建模,并且有连续好几个动态规划的子问题,层层相扣,加上出题人卡的数值,刚刚好只有一个解,我在疯狂尝试我自己方法的时候,也是差不多无助的状态,等到看到提示之后,又充满信心,解决了问题,也算是波谷到波峰的爽感。类似动态规划问题,在极客赛可能算简单的,但是放在平时刷题时,算难度高的,涉及到好几个状态矩阵的计算,又是一个和一个相关联的情况。挺有意思的一题,后面再继续写一篇极客挑战赛~

最近参加了一场极客挑战赛,挺有意思的,准备简单写下笔记。整个比赛是以游戏形式让大家参与的,一共四个,一开始的时候,我以为真的可以靠纯手打,去进行通关,后来发现,想得太天真了。四个游戏,都是需要通过一些编程去解决的,我过了大部分关卡,不过有些是靠的运气,看完官方的题解后,又有了新的认知。

第一个游戏是类似回合制RPG游戏,类似:
475a80e2291cb05cce324c709dd33153.jpeg

画面没这么好哈,版权原因不能用游戏原图,总之有一个行动条,到了时间就可以行动,英雄(就1个)和怪物(一场战斗也就1个)互相攻击,主角在一个地下城堡,总共24层,一层一层往下冲关,每层会有一个宝箱,每次进入下一层,地图都是随机选择的,地图模式有简单、困难和BOSS,每经过8层,地图中怪兽会升级。

英雄是有技能池的,选择什么技能攻击怪兽,是可以进行策略选择的。
英雄的数据:

hero = {
    "Attack": 30,
    "Hp": 200,
    "Mp": 200,
    "Skills": {
        "skill_0": {
            "Cost_mp": 0,
            "Power": 1
        },
        "skill_1": {
            "Cost_mp": 10,
            "Power": 1.5
        },
        "skill_2": {
            "Cost_mp": 20,
            "Power": 2
        },
        "skill_3": {
            "Cost_mp": 30,
            "Power": 2.5
        }
    },
    "Speed": 30
}

典型的怪物数据:

"Monsters":[
    {
        "Attack":30,
        "Hp":40,
        "Speed":25
    },
    {
        "Attack":20,
        "Hp":50,
        "Speed":20
    }
]

上面的Monsters数据是简单地图的,字段都还比较好理解哈,不多做解释了。
游戏还有一个设定,如果是非8、16、24层,每层都有一个宝箱,这个宝箱是随机开出来奖励或者惩罚,一共有4种情况:

  1. empty_box,空箱子;
  2. add_hp_mp,加血又加魔法值,Hp+10且Mp+50;
  3. del_hp,扣10血;
  4. 升级属性,可以选择Attack+10,Speed+1,Hp+100,Mp+200;

是不是感觉这些写出来变量都有点多了,而且都是靠自己抓包,分析出来的,其实玩的时候,真的需要一点点琢磨。然后我通过手打1~8层,发现了一些规律:

  • 如果英雄和怪兽Speed一样,英雄是占先的,也就是速度这块,玩家是占点便宜的;
  • 通过抓包发现,整个游戏和后端交互的协议就三条吧,new_level(重开游戏),next_layer(进入下一层),和open_box(开宝箱);

new_level请求和回包:

{token: "YOUR_TOKEN"}
{"code":0,"data":{"hero":{"Attack":30,"Hp":200,"Mp":200,"Skills":{"skill_0":{"Cost_mp":0,"Power":1},"skill_1":{"Cost_mp":10,"Power":1.5},"skill_2":{"Cost_mp":20,"Power":2},"skill_3":{"Cost_mp":30,"Power":2.5}},"Speed":30},"star_num":3},"message":""}

open_box请求和回包:

{"Layer_idx":1,"token":"YOUR_TOKEN"}
{"code":0,"data":{"box":{"BoxType":"add_hp_mp","Value":[30,50]}},"message":""}

next_layer请求和回包:

{"Layer_idx":1,"Choice":"left","Layer_ops":[["start_battle",0],["battle_op",170,200,[["Hero","skill_0"],["Monster","skill_0"],["Hero","skill_0"]]],["start_battle",1],["battle_op",150,190,[["Hero","skill_1"],["Monster","skill_0"],["Hero","skill_0"]]],["open_box"]],"token":"06xyzzou0074CFBD948B14E238FB24BC"}
{"code":0,"data":{"layer":{"Layer_idx":2,"Map_mode":"boss_map","Monsters":[{"Attack":30,"Hp":100,"Speed":40}]}},"message":""}
  • 在客户端清理了一层的怪物后,可以选择去下一层的两扇门(left or right),这时候客户端会发送后端,玩家在这一层执行的所有动作序列(看上面的示例),然后后端会根据客户端上传的序列,在后台重放,看看动作序列是否合法,合法就可以进入下一层;

上面这些都是我通过手打1~8层,发现的一些规律,然后我进入第9层,发现怪物确实变强了些,那么通过上面的观察,我第一个想法是每次对怪物的击打,都要经过最优策略的选择,这个最优策略我想了下,每次打怪物的时候,我最终击败他时,保证Hp+Mp是最大的,这个算法我通过bsf,广度优先搜索,大概写了3个小时(也debug了一会),终于写出来了,其实最开始,我天真的以为每次只要固定动作序列即可,什么意思,就是我手动计算一遍简单、困难和boss的地图,会产生什么合法的对列,然后直接发几个构造好的next_layer的请求即可,后来发现,果然是天真了,极客挑战赛,不可能是这么蠢的解法(当然我觉得也是有可能能通过的,毕竟怪物属性值不是随机的,就那么几种)。但是作为程序员,怎么能都通过手动算呢,所以我就写了一个和怪物战斗时的最优策略选择算法。代码不贴了,思路是这样的:

  1. 通过计算总共可能的最多攻击次数,英雄最多的攻击次数 = 怪物血量/英雄普通攻击(不耗魔的那个),怪物攻击的最多次数 = 英雄血量/怪物攻击力,选较小的次数保留下来;
  2. 在最小的次数规定的时间内,把英雄和怪物可以行动的时间点插入到一个数组(理论上速度够快的一方,其实可以“套圈”,当然实际游戏数据中未出现这种数据);
  3. 把2计算得到的数组进行排序,得到一个按时间排序好的有序时间数组,数组元素记录了轮到哪一方行动,这里有一个小tip,因为上面发现了英雄和怪物速度一样的时候,英雄占先,所以偷偷给怪物行动时间加一个小的偏移量,这样排序后的结果保证了相同速度时,英雄在怪物前行动;
  4. 按上述有序数组,挨个做动作,如果怪物动,就一种行动,用他的普通攻击攻击英雄,如果英雄动,英雄有4种行动可选,挨个试,合法状态继续bfs;
  5. 直到怪物死掉,这个动作序列是有效的,保存下来(不仅保存动作,也保存英雄剩余的Hp和Mp);
  6. 得到所有可能的有效动作序列后(有效指的是英雄杀死了怪物),看看哪一个序列Hp+Mp最大,执行这个动作序列;

宝箱我就每层都打开,这里也有策略,因为有一个宝箱是提升英雄属性,我一开始是Hp<1.5Mp,加Hp,Mp<150加Mp,Hp和Mp都健康,加Attack,后来发现这个策略不行,第二阶段(9~16层),里面有个怪物的Speed是31,英雄默认的Speed是30,这不欺负人吗?
838537e0403f88e83bda95df288f57fe.jpeg

果断在1~8层有机会先Speed+1,然后尽可能在血量健康状态下加Attack,这样跑了4个小时,被我跑到了接近16层,但是后面还是会挂,那怎么办呢,嗯,我想到了,启动一个后台进程,一直跑,嘻嘻。。。

一值跑的过程很煎熬,因为不知道这么做是不是能打穿24层的地堡,我当时觉得组委会肯定不是这么设计题目的,后来证明这确实是暴力过题的一种方法,跑了20+小时,我查看了日志,发现一个问题,有些运气爆棚的时候,确实可以避开扣血宝箱,而且尽可能加属性,加Hp和Mp,地图也是大多数是简单的,达到过21层,在22层时,遇到一个攻击400,Speed超级快的Boss,基本没有还手之力,我突然郁闷了。。。
59e0f52ce5e72c3b388e15795b6d08fe.jpeg

在内部的讨论群,我发现很多人都说自己就是暴力通过的,得靠人品,所以又等了接近一天,皇天不负有心人,真的通过啦,那一刻,我快乐得像个孩子,好开心啊~

但是故事还没结束,官方题解下来后,我发现自己漏了一个(或者不是漏了,是根本没往那方面想)点,在第七层,有一段代码会展示给选手,大概是这样的:

func ProcessOneUser(nowLayerId int, queryCMD string, choice string) string {
    r := rand.New(rand.NewSource(time.Now().UnixNano()))

    for {

        if queryCMD == "open_box" {
            if nowLayerId == 8 || nowLayerId ==16 || nowLayerId == 24 {
                return "new_star"
            } else if nowLayerId == 7 {
                return "secret_code"
            } else {
                switch r.Int63() % 4 {
                case 0:
                    return "empty_box"
                case 1:
                    return "add_hp_mp"
                case 2:
                    return "del_hp"
                case 3:
                    return "add_skill"
                }
            }
        } else if queryCMD == "next_layer" {
            newLayerId := nowLayerId + 1

            // rest_map 休息房,没有怪物
            // easy_map 简单层,有两个怪物
            // hard_map 困难层,有三个怪物
            // boss_map boss层,有一个boss
            if newLayerId == 8 || newLayerId == 16 || newLayerId == 24 {
                return "rest_map"
            } else {
                switch r.Int63() % 6 {
                case 0:
                    if choice == "left" {
                        return "easy_map"
                    } else {
                        return "hard_map"
                    }
                case 1:
                    if choice == "left" {
                        return "hard_map"
                    } else {
                        return "easy_map"
                    }
                case 2:
                    if choice == "left" {
                        return "easy_map"
                    } else {
                        return "boss_map"
                    }
                case 3:
                    if choice == "left" {
                        return "boss_map"
                    } else {
                        return "easy_map"
                    }
                case 4:
                    if choice == "left" {
                        return "hard_map"
                    } else {
                        return "boss_map"
                    }
                case 5:
                    if choice == "left" {
                        return "boss_map"
                    } else {
                        return "hard_map"
                    }
                }
            }
        }
    }
}

当时我手打的,我也看到过这段代码,但当时我看了半天,只做了一件事情,测试了一下选择"left"和选择“right”门,简单、困难和Boss地图的几率是不是一样的,我发现次数足够大时,是一样的,当时也就放弃了,其实本题的奥义就在此。
关键代码是这句:

r := rand.New(rand.NewSource(time.Now().UnixNano()))

首先随机种子在每一次new_level之后只取一次,后面都是用这个随机种子生成的rng去生成随机数,如果我们能知道这个随机数,那岂不是开了天眼,每次都选择easy map即可,那怎么才能知道这个随机数呢?

看一下Go的官方库rng随机数的实现:

// Seed uses the provided seed value to initialize the generator to a deterministic state.
func (rng *rngSource) Seed(seed int64) {
    rng.tap = 0
    rng.feed = rngLen - rngTap

    seed = seed % int32max
    if seed < 0 {
        seed += int32max
    }
    if seed == 0 {
        seed = 89482311
    }

    x := int32(seed)
    for i := -20; i < rngLen; i++ {
        x = seedrand(x)
        if i >= 0 {
            var u int64
            u = int64(x) << 40
            x = seedrand(x)
            u ^= int64(x) << 20
            x = seedrand(x)
            u ^= int64(x)
            u ^= rngCooked[i]
            rng.vec[i] = u
        }
    }
}

敲黑板,注意这句seed = seed % int32max,int64的seed会进行截断,而后台服务器使用的是nanoseconds时间戳,也就是后半段表示nanoseconds的时间戳(如果是前半段时间戳,就结束了),官方给出的意思是可以通过枚举所有1~math.MaxInt32的候选种子,看看他们前几个随机的数字是多少,通过开宝箱、进入下一层,玩家是可以观察到,我开的是哪种类型的宝箱,下一层地图是哪种模式,比如我玩到第10层,观察到的结果是:

next Map_mode easy_map
BoxType add_hp_mp
next Map_mode hard_map
BoxType add_hp_mp
next Map_mode easy_map
BoxType add_skill
next Map_mode easy_map
BoxType del_hp
next Map_mode boss_map
BoxType add_hp_mp
next Map_mode boss_map
BoxType add_skill
next Map_mode hard_map
next Map_mode easy_map
BoxType del_hp
next Map_mode hard_map

地图类型和宝箱类型都能通过代码,推算出随机数取4或者6模是什么数值,我上面的序列对应序列是0111030221231021,这个自己想下,应该比较容易想明白吧。

那枚举所有1~math.MaxInt32,也通过一样的操作,得到可能的宝箱和地图类型序列,结果存在一个map中,key是宝箱和地图类型序列,value就是seed,用自己观察到的序列查询下map,既可以找到这个seed。

我试了下,如果要枚举所有的math.MaxInt32种子,还是很耗时的,当然完全通过并行计算,拆成30个并发任务,每个任务算个1亿多的种子,这也行,但是有参赛的极客想到了一个更好的策略,假设服务器的时间和客户端相差不大,在客户端记录下发送请求的nanosecond时间戳,然后加减10ms,去寻找这个时间戳,我自己试了下,前后10ms没有找到,然后我扩大范围,每扩大10ms,要多计算1000w的种子,我扩大到向后100ms时,激动人心的一刻到来了,我终于找到了:

1655533609434000000...
1655533609435000000...
1655533609436000000...
1655533609437000000...
1655533609438000000...
1655533609439000000...
1655533609440000000...
find key=[1655533609394757619]

其实,尽管在知道了反向查找随机种子之后,我还是花了非常久时间,大概有5~6个小时,才准确找到这个随机种子的,非常不容易,看来任何事情都是知易行难。但是尽管辛苦,我觉得很值得,我要求自己不仅要得到好的结果,也要用正确的方法得到好的结果,这样才是思考解决问题的正确方式吧。

b64221a1bc98f7db9e2754e06303a196.jpeg

整个极客赛过程,都是自己和自己较劲,我真的想了很多方法,包括一上来通过抓包,慢慢摸索游戏的规律,后来写bfs找每次击败怪兽的最优策略,到后面靠运气通关,然后再去思考怎么样通过破解随机种子开天眼,想想是不是一步步成长的过程,一步步通过对的思维和方法解决问题的过程。

哦,对了,这题充分说明,不要使用时间戳做随机种子,随机种子不要固定下来,嗯,否则是有可能让别人通过观察数据破解的,当然前提是对方猜测到了你的实现方式。

上海疫情封在家中有三个月,辅导了小孩子功课,每每小孩遇到不会做的题目,比她更着急。有时也会想起自己学生时代的样子,遇到难题,心中升起必须克服难题的决心,现在自己娃,怎么没有我当年的风采了。哈哈,扯远了,这两天网上看到一道小学二年级的题目,其实我之前做过类似的题目,但是当下还是震惊了,怎么现在小学二年级题目有这么难?

题目是这样的:
赛车比赛题目.jpeg

配合网友的回复,我马上想到了之前类似的问题,说是25匹马,5个赛道,至少比几场保证找出最快的2匹马。赛马这题答案我记得很清楚7场比赛,先分组进行小组赛:

赛马比赛分组.png

首先一个虚线框就是一场比赛,上来25匹马,分5组,进行5场预赛。5组比赛会得到组内的名次,比如A组,A1->A2->A3->A4->A5,每组第一名就是A1,B1,C1,D1和E1,然后第一名之间安排一场决赛:
赛马比赛决赛.png

假设结果就是A1->B1->C1->D1->E1(真正结果不重要,不管怎么样,总能推理出最后的结论),这时候我们就通过4场比赛,知道了第一名是A1,那第二名是谁呢,是不是B1,不一定哦,有可能是在小组赛中被A1淘汰的A2,所以安排场附加赛吧,A2 vs B1:
赛马附加赛.png

附加赛之后,就能知道前两名了。话说回小学二年级赛车这道题目,其实思路一模一样,就是通过小组赛->决赛->附加赛,完成前两名的确认,也就是说赛车的话,通过至少5场比赛,保证找出前两名的赛车。

作为小学二年级的题目很变态了吧,但是出题的人还多问了一句,怎么证明这件事情呢,为什么通过4场比赛一定不能保证找出前两名呢?这个证明难倒我了,抓耳挠腮半天,没想出来。然后我就看了下出题老师的分析,茅塞顿开,原来还可以使用图论+反正法来证明。大致的过程如下:
首先把每一辆赛车想象成点,那么我们一共有9辆赛车,一共9个点,每场比赛,只能进行3辆赛车的比拼,每次比拼完,我们会有一个名次,紧挨着的前后两名之间,使用一条有向边连接,这个我文字讲得有点绕口,一图解千愁:
赛车比赛顺序图.png

圆圈表示赛车,1号比2号快,2号比3号快,所以每次比赛,我们仅仅能得到2条有向边,如果进行4场比赛,那么总共的有向边数量是4*2 = 8条,而赛车的数量是9,也就是节点数量是9,8条边,9个点,如果要保证所有边都连接到一张图中,不能出现环,否则边的数量不够。
环.png

出现环就意味着9个点形成了若干个分开的子图,上面图中9个节点,分为了两个子图,那么就会有一个问题,第一名都不知道是谁,为什么?因为在1~8节点所在的图中,1号赛车最快,9号赛车单独成子图,9号赛车和谁都没有比过,那么到底9号和1号赛车谁快呢?不知道。所以8条边,安排的比赛结果,划分为多个子图,这样是无法确认前两名的。

那我们4场比赛的结果,如何是可以前两名的图形呢,如下:
树形图.png

左边就是分3场比赛(1、4、7,2、5、8和3、6、9分别比赛),得到结果后,4、2、3比一次,结果是4->2->3,转化这张单图,得到右侧的结果,其实就是一个树形的图,那么前两名就是1、4号赛车。

这样我们很容易举出反例,你不是就8条边么,我最后4、2、3的结果是2->4->3,图就变成非树形图,如下:
多个根结点图.png

这样又回到之前多个子图的问题,我们是没法确认1号赛车和2号赛车谁快的,也就没法真正确认前两名是哪两辆赛车。

OK,至少5场比赛,而非4场比赛保证找出最快的2辆赛车得证。是不是挺难的,至少我是没有想出来,通过图论+反证法证明这个数据结论,由衷发出感慨,活学活用好难啊,能做到的大佬,真的太强了。
373d2c7114b84ac57e8b9e3ad3eb78fe.jpeg