动态规划(1)---从一个简单的例子开始

1、从一个简单的例子开始

爱丽丝和鲍勃一起玩游戏,他们轮流行动。爱丽丝先手开局。

最初,黑板上有一个数字 N 。在每个玩家的回合,玩家需要执行以下操作:

    选出任一 x,满足 0 < x < N 且 N % x == 0 。
    用 N - x 替换黑板上的数字 N 。

如果玩家无法执行这些操作,就会输掉游戏。

只有在爱丽丝在游戏中取得胜利时才返回 True,否则返回 false。假设两个玩家都以最佳状态参与游戏。

示例输入:N=2,

实例输出:true。

解释:N为2时,2的约数只有1,爱丽丝选择1后,鲍勃的输入为1无法操作。

解题思路:

  对于一个初入算法坑的萌新而言,看到这个问题的第一反应,也是人的自然反应,就是按照题目所描述的方式进行解题。很多人在学习算法的过程中会有此疑问,当我拿到一道题的时候,怎么知道要用哪个算法?诚然,当拿到一道题的时候,很多人,都是茫然的,大家的第一反应就是按照题目的叙述,进行暴力求解。我觉得暴力求解是一个不错的办法,至少很多优秀的思路就是在暴力求解的过程中慢慢改进而来的。当做过的题够多的时候,读到题干就知道需要用什么算法,就像高考的时候,看到题号就知道要考什么。

  回到本题,首先按照题干的描述,复现题干的要求,进行暴力求解。分析题干可以知道,如果输入的数字N=1,alias是先手局,则此时alias没有可以操作的数字,alias败,返回flase。由此可以得到以下结论:

  当轮到alias局时,若N=1,则alias败,返回flase,当时对手局时,N=1则 alias胜,返回true。

  在代码设计中,需要有以下几个参数:N----表征函数输入的值,isAlias----表征是否是alias的局,因为我们是要得出alias的胜利情况,因此函数返回值,需要此参数做参考。

  题干中明确指出,用N-x代替N,这是典型的递归思想,因此,函数采用递归式的设计。而函数的停止条件则是N=1,若此时isAlias=true,则返回Flase,否则返回true.

  依据上述结论,我们可以有以下代码

boolean Gt(int N,boolean isAlias){
        /*分析可以知道,当轮到alias操作的时候,如果拿到的是1,则失败,若是对手局,对手拿到的是1则alias胜出*/
        if(isAlias && N==1)
            return false;
        if(!isAlias && N==1)
            return true;
        /*找出所有alias或者Bob能选的x*/
        List<Integer> vector = new LinkedList<>();
        for(int i=1;i<N;++i){
            if(N%i==0)
                vector.add(i);
        }
        /*分析,x的值很多,但是不管选择什么x,一个N只会对应一个输出,也就是说对于所有的x,其得到的结果是一样的,因此,只需要计算一遍即可*/
        return Gt(N-vector.get(0),!isAlias);
    }

经过进一步分析,对于一个N,无论alias先手选择哪个x,有且仅有唯一的一个输出,因此,无需找出所有的x,因为对于N>1,1都是N的约数,因此,有了以下代码:

boolean Gt(int N,boolean isAlias){
        /*分析可以知道,当轮到alias操作的时候,如果拿到的是1,则失败,若是对手局,对手拿到的是1则alias胜出*/
        if(isAlias && N==1)
            return false;
        if(!isAlias && N==1)
            return true;

        /*分析,x的值很多,但是不管选择什么x,一个N只会对应一个输出,也就是说对于所有的x,其得到的结果是一样的,因此,只需要计算一遍即可*/
        return Gt(N-1,!isAlias);
    }

  重点到了,经过我们的剪枝,我们神奇的发现,f(n)和f(n-1)产生了关系(以上代码的最后一句),此时想一想,动态规划中,核心的不就是找出递推方程么?此时,我们误打误撞,知道了f(n)和f(n-1)存在某种联系,我们只要找出了是何种联系,就可以知道递推方程,那么,我们可以找出前N-1的解,推出N的解。因此,此时我们的主要工作从完整的模拟题干操作变成了求解递推方程。从以上的代码中,我们可以看出来,f(n, isalias) = f(n-1,!isalias),此方程蕴含着什么深刻的递推关系呢?左边的方程表示alias拿到的输入,方程的右边表征着bob拿到的输入。显然,这是一个零和博弈,或者说是一个不是你死就是我亡的博弈。

  那么回到问题的主体——alias,如果alias的输入为N,并且,alias可以选择共m个x,记为X,显然 对于每一个x属于X,(N-x)是bob的输入,那么可以得到:f(N) = !f(N-x)。这句话更为通俗的说法是:对于N的任意约数x,若有f(N-x) = flase,则会有f(N) = true,反之亦然。基于此结论或者说递推方程,我们可以给出动态规划的解法:

boolean Gt(int N){
        boolean[] ans = new boolean[N+1];
        /*先求出前N-1的解,i=N时,会求解N的解*/
        for(int i=1;i<=N;++i) {
            for (int j = 1; j < i; ++j) {
                /*对于任意x属于X(X的定义由题干得知)f(n) = !f(n-x)*/
                if (i % j == 0 && !ans[i - j]) {
                    ans[i] = true;
                    break;
                }
            }
        }
        return ans[N];
    }

  以上可以说得到了此题的动态规划解法,以上的动态规划思路也适用于一般的动态规划题目,即找到递推方程,或者说状态转移方程,然后依据状态转移方程书写代码,我们会发现,动态规划问题可以转用递归的方式求解,因为递归的方式和我们大脑思考的方式很相近,因此,当你觉得一个问题可以用递归来做的时候,你应该想一想,是否可以用动态规划求解。

  上述对于该问题的动态规划求解介绍完毕,但是做算法需要时刻问自己,问题还能否进一步优化。就本题而言,我们知道,对于任意的x属于X,其结果是一致的,因此,在上述代码中,我们用了break来提前结束,但是转念一想,1是N>1的约数,因此,为何不直接使用1呢,上述问题就转换成了:

boolean Gt(int N){
        boolean[] ans = new boolean[N+1];
        /*先求出前N-1的解*/
        for(int i=1;i<=N;++i) {
            if(!ans[i-1])
                ans[i] = true;
        }
        return ans[N];
    }

  翻译成人话就是

  

ans[N] = !ans[N-1];

  我们知道 N=1时是false;递推得到,N=2时,是true,N=3时是false,N=4时是true。。。。发现凡是N为奇数,那么返回值就是false,N为偶数时,返回值就是true。因此有如下超级简单的代码

boolean Gt(int N){
        return (N&0x01) == 0;
    }

  我觉得没有谁能从题干中一眼扫过去,就能得到上述最简的算法,优秀的算法一定是慢慢的改进而来的,永远不要放过一闪而过的灵感,也永远不要满足于现有算法。脚踏实地,一步一步的迭代、调优,才会得到真正优秀的算法。

  

  

 

猜你喜欢

转载自www.cnblogs.com/establish/p/11599042.html
今日推荐