1.问题描述
据说,鲁智深一天中午匆匆来到开封府大相国寺,想蹭顿饭吃,当时大相国寺有99个和尚,只做了99个馒头,智清长老不愿得罪鲁智深,便把他安排在一个特定位置,之后对所有人说,从我开始报数(围成一圈),第5个人可以吃到馒头(并退下),按照这个公平的方法,所有和尚都吃到了馒头,唯独鲁智深没有吃上。请问他在哪个位置?
2.问题分析
看似公平,其实仔细一想只是一个特定序列问题——100个人按照特定的序列依次去取99个馒头,将最后取不到的那个序号安排给鲁智深即可。所以问题的关键在于怎么安排这个序列,智清长老是将100个人围成一团,然后报数是5号(5的倍数)位置的人依次去领馒头来规定领取的序列,这个看似公平的解决方案其实背后的结局早已注定。第一次接触这个问题是学习筛选法求100以内所有的素数的时候,而后通过对筛选法的反思,归纳了三种思路四种解决方法:数组筛选、循环链表、递归还原、递归循环。这四种方法的实现过程也是对同一个问题解决方案不断优化及思考的过程,接下来就对每个方法的特点及不足逐个分析。
3.数组筛选法——大浪淘沙
原理:设定一个计数器,计数器到达5的倍数便让馒头数目减1(用数组值为0标记吃了馒头的和尚),99个馒头用完为止,最后大浪淘沙,吃饱的人都走了,留下的便是鲁智深:
void LuZhiShenArray(){
int i,count = 1;
int a[101]; //总共100个人
int b = 99; //篮子里有99个馒头
//让每个人都保持饥饿状态
for(i=1; i <=100; i++) a[i] = i;
while(b > 0){
for(i=1; i<=100; i++){
if(a[i] != 0){
if(count%5 == 0){
b--; //拿走一个馒头
a[i] = 0; //吃饱——退出
}
count++; //未吃码头的人参与报数
}
}
}
for(i=1; i<=100; i++){
if(a[i]!=0)printf("鲁智深的位置编号是:%d\n",a[i]);
}
}
优劣:该方法能较为接近的模拟报数的过程,思路简单明了;但仍有不足,在用数组的时候必须首先明确数组的大小,虽然可以用宏常量提前规定大小但仍缺失动态性。另一个问题是该方法的时间复杂度为O(mn),人数和馒头较大时则略显尴尬。
4.循环链表法——阿弥陀佛
原理:所有的和尚手拉手围成一个圆圈,从智清长老开始报数,报数为5的倍数的和尚退下去,此时退下和尚左右两边的和尚进行牵手(脑补画面,友谊万岁),那最后终止的条件是鲁智深自己的左手牵右手,双手合于胸前,仰天长叹一声:“阿弥陀佛,玩不过这帮老和尚,看来这大相国寺是呆不下去了,明天就去梁山...”。智清长老逼鲁智深上梁山的细节如下:
typedef struct Item *PtrPerson;
struct Item{
int num;
struct Item *next;
};
typedef struct Item Person;
void LuZhiShenLink(){
int m, n, i;
//n个人,报到m(或其倍数)领馒头
do{
printf("The number of buddhist : ");
scanf("%d",&n);
printf("The limit number : ");
scanf("%d",&m);
}while(n<1 && m<1 && m>n);
//100个和尚+初始化+环形链表
Person *Buddhists;
Buddhists = (PtrPerson)malloc(n*sizeof(Person));
if(Buddhists == NULL)exit(0);
for(i=0; i<n; i++){
Buddhists[i].num = i+1;
Buddhists[i].next = Buddhists + (i+1);
}
Buddhists[n-1].next = Buddhists;
//通过量表控制五人间隔+删除第五人+循环控制条件
PtrPerson buddhist = Buddhists;
while(buddhist != buddhist->next){ //不拉到自己的手的时候
for(i=2; i<m; i++) buddhist = buddhist->next; //设置报数间隔
buddhist->next = buddhist->next->next; //“5”位置上的和尚退出,左右两边和尚牵手
buddhist = buddhist->next; //下一位开始报数
}
printf("鲁智深的位置编号是:%d\n",buddhist->num);
free(Buddhists);
}
优劣:相比于数组法,链表法更容易理解,并且这个过程使用的是指针对内存进行操作,因此对于人数和馒头可以动态设定,降低了代码的耦合性;用易于理解的方式写出来的代码量过大,同时时间复杂度仍然为O(mn)。
5.递归还原法——悟空72变
原理:n个人报到m时领馒头,若从0开始编号,则每次报道m-1时领馒头(为什么要从0开始报数,从后面的代码可以体会这样带来的方便性)每次报完一个(5-1)的人之后,重新从0开始编号,可以知道最后一个人(鲁智深)的报数为0。我们知道鲁智深的原始位置一定是智清和尚安排的位置,那么怎样才能找到他的原始位置?答:脚踏实地,一步一步来——已知(i-1)个人的时鲁智深的编号是 f[i-1] ,那么 i 时鲁智深的编号为 f[i] = (f[i-1] + m) % i 。所以只要当i = n 时,鲁智深的原始编号f[n]被还原出来:
static int LZS_Loop(int,int);
void LZSLoop(){
int m, n;
//n个人,报到m(或其倍数)领馒头,从0开始编号[0,n-1]
do{
printf("The number of buddhist : ");
scanf("%d",&n);
printf("The limit number : ");
scanf("%d",&m);
}while(n<1 && m<1 && m>n);
printf("鲁智深的位置是:%d\n",LZS_Loop(n,m)+1);
}
static int LZS_Loop(int n, int m){
/*
f[i] = 0 i = 1; 递归的一般条件
LuZhiShen = {
f[i] = (f[i-1] + m)%i i > 1; 递归的基本条件
*/
if(n == 1)return 0;
else return (LZS_Loop(n-1, m) + m) % n;
}
优劣:代码十分简洁,其实可以看出不管鲁智深的序号变了多少次,只要找到递归关系式,一切都迎刃而解;时间复杂度为O(1),空间复杂度为O(n),简洁背后是数学方法的优化和内存的无情消耗,可以想象当和尚n人数增到一定程度时则会栈溢出,机器崩掉!
6.递归循环法——时空转换
原理:尾递归问题(回归过程中不用做任何操作,所以就没有必要为中间步骤开辟内存)可以转为循环解决,用时间换空间:
void LZSRecursion(){
int m, n;
int i = 1;
int lZS = 0;
//n个人,报到m(或其倍数)领馒头,从0开始编号[0,n-1]
do{
printf("The number of buddhist : ");
scanf("%d",&n);
printf("The limit number : ");
scanf("%d",&m);
}while(n<1 && m<1 && m>n);
//尾递归的循环解决方法
while( i < n){
lZS = (lZS + m)%(++i);
}
printf("鲁智深的位置是:%d\n",lZS + 1);
}
优劣:相比于递归节省了内存开销,同时可以看到时间复杂度为O(n),一切刚刚好——情人眼里出西施。
7.华山论剑
东邪(数组)、西毒(链表)、南帝(递归)、北丐(循环)身怀绝技各有千秋,只是在这个回合有所偏差,而中神通只是在恰当的时机出现在合适的位置。华山之巅这个层面的高手早已不再纠结技法的高低,较量的应该是对于自身的认识和对于实际问题的把控程度——所谓天下第一便是恰到好处。