剑指Offer-48-孩子们的游戏(圆圈中最后剩下的数)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/dawn_after_dark/article/details/81953718

题目

每年六一儿童节,牛客都会准备一些小礼物去看望孤儿院的小朋友,今年亦是如此。HF作为牛客的资深元老,自然也准备了一些小游戏。其中,有个游戏是这样的:首先,让小朋友们围成一个大圈。然后,他随机指定一个数m,让编号为0的小朋友开始报数。每次喊到m-1的那个小朋友要出列唱首歌,然后可以在礼品箱中任意的挑选礼物,并且不再回到圈中,从他的下一个小朋友开始,继续0…m-1报数….这样下去….直到剩下最后一个小朋友,可以不用表演,并且拿到牛客名贵的“名侦探柯南”典藏版(名额有限哦!!^_^)。请你试着想下,哪个小朋友会得到这份礼品呢?(注:小朋友的编号是从0到n-1)

解析

预备知识

重新抽象该问题,n个人(编号0~(n-1)),从0开始报数,报到(m-1)的退出,剩下的人继续从0开始报数,就这样不断的退出,直到剩下最后一个人,求这个人的编号。

思路一

我们采用标记数组的做法,用来标记所有的小朋友的状态。从第0个人开始报数,期间只对未标记的小朋友进行标记,直到等于m - 1,则标记该小朋友(表示退出)。继续从下一个小朋友开始报数。由于小朋友围了一个大圈,所以当遍历到最后一个小朋友的时候要回到第0个小朋友继续报数。其实就是对一个环中数不断剔除第m个数字

    /**
     * 不使用任何数据结构,纯用标记数组
     * @param n
     * @param m
     * @return
     */
    public static int LastRemaining_Solution(int n, int m) {
        if(n == 0 || m <= 0) {
            return -1;
        }
        boolean[] visited = new boolean[n];
        int count = -1;
        int index = -1;
        int sum = 0;
        while(true) {
            index = index + 1 == n ? 0 : index + 1;
            if(!visited[index]) {
                count++;
            }
            if(count == m - 1) {
                visited[index] = true;
                if((++sum) == n) {
                    return index;
                }
                count = -1;
            }
        }
    }

思路二

在思路一我们已经得到了这个题其实就是对一个环中数不断剔除第m个数字问题,加之使用标记数组会不断访问已经退出的小朋友,所以复杂度偏高。所以这里我们采用链表结构,结合链表的O(1)的删除开销来不断降低问题规模,避免不必要的遍历。由于这里使用的单链表,所以还是要像思路一一样模拟循环遍历。

    /**
     * 借助工具库  链表集合
     * @param n
     * @param m
     * @return
     */
    public static int LastRemaining_Solution2(int n, int m) {
        if(n == 0 || m <= 0) {
            return -1;
        }
        List<Integer> students = new LinkedList<>();
        for(int i = 0; i < n; i++) {
            students.add(i);
        }
        int index = 0;
        while(students.size() > 1) {
            index = (index + m - 1) % students.size();
            students.remove(index);
        }
        return students.get(0);
    }

思路三

其实这个问题的模型是约瑟夫问题,可以借助数学的知识来把复杂度降低为O(n),果然数学是算法的重要功底,道路且长啊!
对于n个人,第一个人出列的人必然是(m - 1) % n,假设出列的人的编号为k - 1, 剩下的n-1个人组成了一个新 的约瑟夫环(以编号为k + 1的人开始): k k+1 ... n-2, n-1, 0, 1, 2, ... k - 2并且从k开始报0。我们假设f(n)表示n个人不断剔除报号为m - 1的最终解,而f(n - 1)是f(n)的一个子阶段,所以f(n - 1)的解与f(n)是一致的。所以我们只要知道了f(n - 1)问题的解,那么f(n)也就知道了。
那么f(n - 1)的问题怎么解呢? f(n - 1)的问题的开始报号的人的编号为k, 我们通过映射规则把它改为0就可以把问题规模减小了1且处理过程与f(n)的一致,看到这里,问题规模不断减小且处理逻辑一样,当然使用递归或者循环啊。这样当我们知道了f(n - 1)的解的时候,再采用反向的映射恢复原来的编号,这样就可以使得这个子问题变为f(n)的下一阶段,因而具有相同的解。

比如对于0,1,2,3,4的问题,m = 2, 第一次删除的人编号为1,那么之后重新报号的人的编号为2,问题变为2,3,4,0,为了使得该子问题能够与父问题一致(报号都从0开始,这样就可以采用相同的逻辑处理问题,这是递归经常使用的),现在我们把他们的编号做一下转换:

2 –> 0

3 –> 1

4 –> 2

0 –> 3

这样子问题变为0,1,2,3中删除第m个人,因为问题与父问题一致,所以可以采用相同的逻辑继续求解,这样直到问题规模为1,递归结束,得到最后胜利者。但是我们要记住,子问题的结果都是父问题为了使得问题逻辑一样而改变了编号值,所以我们要向上不断恢复编号值。因为2,3,4,0的其实是0,1,2,3,4的一个必然的阶段,所以他们的解一样,只要知道了2,3,4,0的解,就知道了原问题的解。
上述父问题向子问题的编号映射规则为(x - m) % n, 所以子问题向父问题编号逆向映射规则为`(x’ + m) % n

    f(n)= 
    \begin{cases} 
        1, & \text {if $n$ == 1} \\ 
        (f(n - 1) + m) \% n, & \text{otherwise} 
    \end{cases} 
    /**
     * 约瑟夫环问题
     * @param n
     * @param m
     * @return
     */
    public static int LastRemaining_Solution3(int n, int m) {
        if(n == 0 || m <= 0) {
            return -1;
        }
        int last = 0;
        for(int i = 2; i <= n; i++) {
            last = (last + m) % i;
        }
        return last;
    }

总结

当子问题与父问题不是相同问题的时候,可以尝试映射为相同问题,这样得到最终解的时候,再不断向上恢复为原问题的解。

猜你喜欢

转载自blog.csdn.net/dawn_after_dark/article/details/81953718
今日推荐