《剑指Offer》学习笔记--面试题45:圆圈中最后剩下的数字

来源:互联网 发布:linux文件同步 编辑:程序博客网 时间:2024/04/29 16:19

题目:0,1,....,n-1这n个数字排成一个圆圈,从数字0开始每次从这个圈里删除第m个数字。求出这个圆圈里剩下的最后一个数字。

例如,0,1,2,3,4这5个数字组成一个圆圈,从数字0开始每次删除第3个数字,则删除的前四个数字依次是2,0,4,1,因此最后剩下的数字是3.

本题就是有名的约瑟芬环问题。我们介绍两种方法:一种方法是用环形链表模拟圆圈的经典解法,第二种方法是分析每次被删除的数字的规律并直接计算出圆圈中最后剩下的数字。

经典的解法,用环形链表模拟圆圈

既然题目中有一个数字圆圈,很自然地想法就是用一个数据结构来模拟这个圆圈。在常用的数据结构中,我们很容易想到环形链表。我们可以创建一个总共有n个结点的环形链表,然后每次在这个链表中删除第m个结点。

如果面试官要求我们不能使用标准模板库里的数据容器来模拟环形链表,我们就自己实现一个链表也不是很难得事情。如果面试官没有特殊要求,我们就可以用模板库中的std::list来模拟一个环形链表。由于std::list本身并不是一个环形结构,因此每当迭代器扫描到链表末尾的时候,我们要记得把迭代器移到链表的头部,这样就相当于按照顺序在一个圆圈里遍历了,这种思路的代码如下:

int LastRemaining(unsigned int n, unsigned int m){if(n < 1 || m < 1)return -1;unsigned int i = 0;list<int> numbers;for(i = 0; i < n; ++ i)numbers.push_back(i);list<int>::iterator current = numbers.begin();while(numbers.size() > 1){for(int i = 1; i < m; ++ i){current ++;if(current == numbers.end())current = numbers.begin();}list<int>::iterator next = ++ current;if(next == numbers.end())next = numbers.begin();-- current;numbers.erase(current);curent = next;}return *(current);}
如果我们用一两个例子仔细分析上述代码的运行过程,就会发现我们实际上需要在环形链表里重复遍历很多遍。重复的遍历当然对时间效率有负面影响。这种方法没删除一个数字需要m步运算,总共有n个数字,因此总的时间复杂度是O(mn)。同时这中思路还需要一个辅助的链表来模拟圆圈,其空间复杂度为O(n)。接下来我们试着找到每次被删除的数字有哪些规律,希望能够找到更加高效的算法。

创新的解法,拿到Offer不在话下

首先我们定义一个关于n和m的方程f(n,m),表示每次在n个数字0,1,...,n-1中每次删除第m个数字最后剩下的数字。

在n个数字中,第一个被删除的数字是(m-1)%n。为了简单起见,我们把(m-1)%n即为k,那么删除k之后剩下的n-1个数字为0,1,...,k-1,k+1,...,n-1。并且下一次删除的数字从k+1开始计数。相当于在剩下的序列中,k+1排在最前面,从而形成k+1,....,n-1,0,...,k-1.该序列最后剩下的数字也应该是关于n和m的函数。由于这个序列的规律和前面最初的序列不一样(最初的序列是从0开始的连续序列),因此该函数不同于前面的函数,即为f`(n-1,m)。最初序列最后剩下的数字f(n,m)一定删除一个数字之后的序列剩下的数字,即f(n,m)=f`(n-1,m)。

接下来我们把剩下的这n-1个数字的序列k+1,...,n-1,0,1,...,k-1做一个映射,映射的结果是形成一个从0到n-2的序列。

int LastRemaining(unsigned int n, unsigned int m){if(n < 1 || m < 1)return -1;int last = 0;for(int i = 2; i <= n; i ++)last = (last + m) % i;return last;}
可以看出这种思路分析过程尽管复杂,但写出的代码却非常简洁,这就是数学的魅力。最重要的是,这种算法的时间复杂度是O(n),空间复杂度为O(1),因此无论在时间效率还是空间效率上都优于第一种方法。


0 0