(递归和迭代 is over due)
-
尾递归到迭代的转换 Submit BSEARCH
-
栈的实现 Submit STACK
-
快速排序 Submit QSORT
Submit Time | Problem Name | Judge Result | Language |
递归和迭代
递归函数的优点在于程序很简洁,但是效率往往比较低。此时我们可以把递归函数转换成为循环,以迭代的方式实现递归函数。实际上,我们在前面已经做过一次练习了。
对于尾递归函数,我们可以把递归方便地转换为迭代。所谓尾递归,就是说递归函数具有这样的形式:
T f(T’ args)
{
If(args满足退出条件)
return X;
…..//进行一系列计算;
return f(args’);
}
那么我们可以很方便地转成下面的while语句。方法是设立一个循环,并设立一个变量来存放实在参数。在原程序返回的地方,把结果赋给相应的变量并退出循环。在原来递归调用的地方把新的参数赋给存放实在参数的变量,并continue循环。
V = args;
for(;;)
{
if(V 满足退出条件)
{
result = X;
break;
}
….//进行同样的计算;
V = args’;
}
注意,只要程序总是把递归调用得到的值直接返回,就可以称为尾递归,并不一定要求只递归调用一次。大家可以看第一个题目的例子。 我们经常把计算阶乘的程序看作尾递归;
f(n) { return n*f(n-1); }
但是严格地讲它不是尾递归,因为f(n-1)的返回值还需要和n相乘然后返回。不过,因为乘法交换率,我们还是很容易把它写成尾递归。
题目1:尾递归到迭代的转换
在一个排好序的数组A中搜索某个数x,判断是否在这个数组中,我们可以用二分法进行搜索。递归程序如下:
bool Find(int iL, int iR, x)
{
if(iR < iL)
return false;
int tmp = A[(iL+iR)/2];
if(tmp == x)
return true;
else if(tmp < x)
return Find((iL+iR)/2+1, iR);
else
return Find(iL, (iL+iR)/2-1);
}
请把这个尾递归函数转换成为一个迭代函数;
输入
多行,每行的第一个数字是要搜索的x;第二个数字是数组中非负整数的个数n(n<1000), 接下来的n各数字就是数组中的数; 最后一行以-1开始,表示输入结束, 不需要处理;
输出
对于每行输入, 有一行输出; 如果x在数组中,则输出Yes, 否则输出No;
例子输入
3 5 1 2 3 4 5
5 7 1 2 3 4 6 7 8
-1
例子输出
Yes
No
关于尾递归【转】
尾递归是指具有如下形式的递归函数 f(x) ≡ if b(x) then h(x) else f(k(x)); 其中: x, k: TYPE1, k(x) -< x ( 符号 -< 表示偏序) h, f: TYPE2 b: boolean 且 b, h, k中都不含f 这样一个尾递归函数很容易转化为迭代。例如上述函数用C++语言写的递归代码为 T2 f(T1 x) { T1 x1; if (b(x)) { return h(x); } else { x1 = k(x); return f(x1); } } 这里T1, T2是某个数据类型,b(x)是某个返回值为bool的函数,k(x)是某个返回值为T1的 函数,h(x)是某个返回值为T2的函数。显然函数f是一个递归函数,但因为他是尾递归,所 以很容易给改写成迭代: T2 f(T1 x) { T1 x1; loop: if (b(x)) { return h(x); } else { x1 = k(x); x = x1; // 注意,这两行语句 goto loop; // 用goto把尾递归改为了迭代 } } 然而通常所见到的递归都不是尾递归形式,这时候我们可以想办法用等价变换将其变化为 等价的尾递归形式。一个著名的等价变换就是cooper变换,其模式如下: [Cooper变换] 输入模式: f(x) ≡ if b(x) then h(x) else F(f(k(x)), g(x)) 输出模式: f(x) ≡ G(x, e) G(x, y) ≡ if b(x) then F(h(x), y) else G( k(x), F(g(x),y) ) 其中: x, k: TYPE1, k(x) -< x ( 符号 -< 表示偏序) y, G, h, g, f, F: TYPE2 b: boolean e: F的右单位元,即F(x, e) = x 可用性条件: (1)F满足结合律,即F(F(x,y),z) = F(x, F(y, z)) (2)F有右单位元e; (3)b, h, g, k中都不含f 例如考虑计算阶乘的函数 f(x) ≡ if x = 0 then 1 else f(x-1)*x; 对照cooper变换,易见该函数是满足cooper变换的输入模式和适用性条件的。其中 b(x) ≡ (x = 1); h(x) ≡ 1 F(x, y) ≡ x * y, F的单位元e = 1 k(x) ≡ x - 1 g(x) ≡ x 于是我们可以根据cooper变换将f(x)改写为: f(x) ≡ G(x, 1); G(x, y) ≡ if x = 1 then 1 * y else G(x-1, x * y); 用C++写的代码为: int G(int x, int y) { int x1, y1; if (x == 1) { return 1 * y; } else { x1 = x - 1; y1 = x *y; return G(x1, y1); } } int f(int x) { return G(x, 1); } 其中尾递归函数G又可以进一步改写为迭代形式: int G(int x, int y) { int x1, y1; loop: if (x == 1) { return 1 * y; } else { x1 = x - 1; y1 = x *y; x = x1 y = y1; goto loop; } } 另外还有几个常见的等价变换: [拓广的Cooper变换] 输入模式: f(x) ≡ if b(x) then h(x) else if b1(x) then F1( f( k1(x) ), g1(x) ) ... else if bn(x) then Fn( f( kn(x) ), gn(x) ) else F0( f( k0(x) ), g0(x) ) 输出模式: f(x) ≡ if b(x) then h(x) else if b1(x) then G1( k1(x), g1(x) ) ... else if bn(x) then Gn( kn(x), gn(x) ) else G0( k0(x), g0(x) ) 对于所有的 0≤i≤n, Gi( x, y) = if b(x) then Fi( h(x), y ) else if b1(x) then Gi( k1(x), F1( g1(x), y ) ) ... else if bn(x) then Gi( kn(x), Fn( gn(x), y ) ) else Gi( k0(x), F0( g0(x), y ) ) 其中: 对于所有的 0≤i≤n x, ki: TYPE1, ki(x) -< x ( 符号-< 表示偏序) gi, h, Fi, Gi, y: TYPE2 b, bj: boolean, 1≤j≤n b(x)∧b1(x)∧……∧bn(x) = φ (空集) b(x)∨b1(x)∨……∨bn(x) = Ω (全集) 可用性条件: (1)Fi满足结合律,即Fi( Fj(x, y), z ) = Fj( x, Fi(y, z) ), 0≤i, j≤n (2)b, bj, h, gi, ki中都不含f, 0≤i≤n, 1≤j≤n [反演变换] 输入模式: f(x) ≡ if b(x) then h(x) else F( f(k(x)), g(x) ) 输出模式: f(x) ≡ G(x, x0, h(x0)) G(x, y, z) ≡ if y=x then z else G(x, k'(y), F( z, g(k'(y))) ) 可用性条件: (1)b(x)为真时可求出相应之x值x0; (2)k(x)存在反函数k'(x); BTW: 迭代和递归的最大区别就是迭代的空间复杂度为O(1),即所谓的constant space。 哪种用堆栈来模拟递归的方法,本质上还是递归 只不过人工做了本来由编译器做的事情 只要使用了对栈,空间复杂度通常就和输入规模n有关,而不可能是常数了
这个翻筋斗是说的是所谓 trampolined style 这样的一种编程技巧。 这个技巧在做尾递归消除的时候特别有用。 我们知道 c 语言里面用堆栈来实现递归。每进行一次函数调用, 调用堆栈都会长一点,把一些必要的信息记下来,比如当 被调用的函数结束的时候,如何返回调用函数,它的执行地址 在哪里等等。 所谓递归,就是函数在执行过程中,会调用到自己, 一般正常的情况下,每次递归调用都是用不同的函数参数 来进行的。一般来说,这样每一次要进行的计算 比起上一次来说,就会简单一点。这样达到一个地步, 到了这个地步就不用再调用自己,直接就能给出答案了。 这个时候,堆栈上积累了一长串调用函数的脚印, 最简单的情况得到答案以后,我们就顺着这串脚印, 倒着走回去,每走回去一步,就是回到上一级的调用函数, 给出稍微复杂一点的那个问题的答案。这样一步步的 返回去,我们就得到了原来问题的答案。 也就是说,我们用堆栈实现了一个递归算法,完成了我们的问题。 所谓尾递归,函数运行过程中会调用自己, 我们把当前的这个运算过程叫做 A。它会调用自己 展开一个新的计算过程,我们把它记做 B。 一般的递归运算,在 B 结束运算,得到一个阶段性的结果以后, 在返回到计算过程 A 以后,还需要用 B 的计算结果, 再做一些处理,然后才能结束 A 的运算,把结果返回到 递归调用的上一级。 所谓尾递归的情况,就是说在 B 结束,返回到 A 以后, A 对 B 的运算结果不做任何进一步的处理,就把结果 直接返回到上一级。这就是所谓在结尾处进行的递归。 显然我们能看出来,在尾递归的情况下, 我们不许要增长堆栈。因为从 B 返回以后, 我们就直接从 A 返回,中间没有停顿。 这样在调用 B 的时候,我们就不需要在堆栈上留下 A 的印迹。要知道,我们原先之所以需要这个印迹, 是因为我们还要凭借这个印迹回到 A 再做一点运算,才能回到 A 的上一级。现在 尾递归的情况,我们不需要回到 A,直接就可以从 B 回到 A 的上一级。这样在 A 调用 B 的时候, 我们原来需要 A 在堆栈上留个印迹,现在我们就不需要了。 我们希望把 A 就此忘掉,不想让它增长我们的堆栈。 而这应该是完全可以达到的目的。 不过 c 语言里面并没有提供这样尾递归消除的机制。 这就只好依靠程序员自己想办法了。 最早这个办法是 Guy L. Steele 在 Rabbit 那篇 Scheme 的论文 里面想到的。后来 Philip Wadler 和 Simon Peyton Jones 等人 在 Glasgow Haskell 项目里面也又独立的把这个方法发明了一遍。 这个方法基本上说来,就是让程序的主体部分在一个 循环里面运行一个调度程序, while (1) { cont = (*cont)(); } 让每一个普通的函数返回的时候,设置一个全局变量, 记录下一步继续执行那一个函数。这个 继续 在这里就可以 当一个名词来使用,是不是就让你想到 scheme 语言当中大名鼎鼎的 continuation 啊?:) 还有其它种类的翻筋斗。上面说的这个翻筋斗, 如果自己手写,其实也不是多古怪。不过终归是不太好, 这也就是语言和语言之间的一个区别,或者也许也可以说是 目前的语言都是要么这样要么那样的不能令人满意吧。 不过这个我们以后再慢慢说吧。 Steele 和 Peyton Jones 和 Philip Wadler 他们 是把 scheme / haskell 编译成 c 语言,也就是说 他们的这个翻筋斗不是手写的,是个编译到 c 语言的技巧。所以古怪不古怪对它们来说 就不成问题啦。 在 Daniel Friedman 和几个人和写的那篇 专门谈论翻筋斗的文章中,还有一些更喏嗦的内容。 首先我们看到上面的这个技巧可以用来在 自己的 c 程序当中实现一个非抢占式的多任务系统。 Mitch Wand 后来有一篇论文讲到在 scheme 里面 如何用 continuation 实现多线程, 大体上似乎是一个意思。不过 Dybvig 似乎有一个 抢占式的多线程的实现方法,我老早以前看的, 当时就没明白。现在对这个话题不是特别感兴趣。 (一般来说,我对用到很强的技巧的东西都不感兴趣,呵呵) 对了,在 Knuth 在 TAOCP 第一卷里面谈到过 coroutine 这些也是相关的内容。 还有 Moggi 有 Monadic 的变化。这个我还不甚了了。 Friedman 的文章里面似乎还有点别的内容。 我不过我就没仔细看了。 如果你看到别的内容,麻烦你也告诉我一声喽。:)
题目2:栈的实现
借助栈(大家可以到网上或者教科书上去看一下栈的定义)这个抽象数据结构,我们很容易把递归函数转换成为迭代函数。下面我们首先用数组来实现一个栈。这次要求大家实现一个类(如果大家还没有学习面向对象编程,现在可以去学习一下)。要求:使用数组实现类ArrayStack;这个栈中最多可以容纳1000个整数类型的元素;类ArrayStack包含如下操作:
- void Empty():清空栈中的所有元素;
- void Pop(): 弹出一个元素
- int Top(); 获取栈顶的元素;
- void push(int x);将x压到栈顶;
- void output;从栈顶开始,输出栈中的所有元素。
输入
多行;每行一个命令,命令格式如下:
命令 要求 0 清空栈中内容; 1 弹出一个元素; 2 获取栈顶元素,并用单独一行输出x; 3 x 将x压到栈顶; 4 从栈顶开始输出所有元素。所以的输出在一行上。
在输入中,保证第一个命令是0;且保证处理过程中不会对空栈执行pop操作,或者对满栈执行push操作(保证栈中元素小于1000个)。
在输出所有元素时,如果栈中没有元素,则输出空行。
输入
多行,每行一个命令。命令格式见上。
输出:按照命令要求输出。
例子输入
0
3 1
3 2
3 3
4
1
2
1
4
例子输出
3 2 1
2
1
利用栈把递归函数改成迭代函数
在高级语言中的函数实际上是通过栈来实现的。在每次调用的开始,调用者把实在参数、返回地址压入栈中,并为被调用者的局部变量等分配空间。然后跳转到被调用者;被调用者执行完毕后,退栈,并且根据之前压入的返回地址跳转到调用者的后继代码继续运行。这些操作对于高级程序设计语言的程序员来说是透明的。现在我们可以自己声明一个栈,然后自己通过压栈/出栈来模拟递归调用。这么做的好处在于我们可以进行进一步优化。
一般来说,转换的方法可以是这样的:
假设有递归函数F, 它的返回类型是T0,有T1类型的参数x,两个局部变量locVar1, locVar2。多次调用自己。
T0 F(T1 x)
{
T2 locVar1, locVar2;
...//运算0;
if(…)
return …
…//运算1;
F(x1);//第一次调用;
…//运算2;
F(x2);//第二次调用;
…
}
那么,我们可以声明一个栈,栈中元素的类型是:
struct element {
T0* ret; //保存返回值的地址;
T1 x; //对应于实在参数
T2 locVar1, locVar2; //对应于局部变量;
T0 ret1, ret2; //用于存放第一次和第二次递归调用的返回值;
int Pos;
}
其中Pos字段记录了调用者调用本函数的位置。 然后处理的方法如下: 1、 使用入栈/出栈来模拟递归调用/返回 1、 调用:设置栈顶的进度标记(记住是第几次调用);设置参数、返回值地址,并入栈 2、 返回:将返回值根据返回值地址拷贝到相应位置,出栈, 3、 其他计算过程:根据当前进度标记执行;所有对实在参数/局部变量的访问 2、 使用一个循环来模拟执行过程; a) 根据当前栈顶记录中的信息决定下一步执行什么操作;
比如
long Fib(long n) { if (n <= 1) return n; else return Fib(n-1)+Fib(n-2); }
可以设立一个栈,栈元素类型是
struct Node{
long n; //参数
long t1; //局部变量,存第一次调用Fib的返回值
long t2; //局部变量,存第二次调用Fib的返回值
long* ret; //指向存放返回值的地址
int IP; //0表示刚开始执行;
//1表示调用了第一次Fib,
//2表示第二次调用Fib
}
而相应的迭代代码如下:(红色的注释是迭代程序中的对应代码)
long Fib(int n)
{ long ret;
stack<Node> s; Node *w;
Node record = {n,0,0,&ret,0};
s.push(bottom);
while(!s.IsEmpty())
{
Node* curRec = s.GetTopPointer();
//根据curRec->的IP,决定执行相应的代码
switch(curRec->IP)
{case 0:
if(curRec->n <= 1) // if (n <= 1) return n;
{ *curRec->ret = curRec->n; s.Pop();}
else
{ curRec->IP=1; //记住已经执行到第一次调用之前;
s.Push({curRec->n-1,0,0,&curRec->t1,0});} // t1=Fib(n-1);
break;
case 1: curRec->IP=2;
s.Push({curRec->n-2,0,0,&curRec->t2, 0}); //t2=Fib(n-2);
break;
case 2: *curRec->ret = curRec->t1 + curRec->t2; //return t1+t2;
s.Pop(); //返回
}
return ret;
}
这个代码并不是高效的代码,但是我们可以进一步优化。比如,对于case 1,我们压栈之后,下一轮迭代会把刚刚压栈的元素出栈。因此,我们可以直接进行计算,而免除相应的压栈/出栈操作。
题目3:QuickSort的迭代实现
QuickSort是一个很高效的排序算法;大家可以在网络上搜索到它的递归实现。现在请大家利用栈把它改写成为迭代程序。大家可以考虑高效一点的转换:
- 可以考虑不把局部变量记录到栈中;
- QuickSort函数没有返回值; 3 .QuickSort递归调用自身两次;但是后一次类似于尾递归,可以按照尾递归进行处理;这样我们就只需要把第一次调用对应的记录压栈;栈中所有记录的pos值都是一样的。因此我们也可以省略这个值;
因此,栈中只需要记录实在参数。
输入
多行,每行的第一个整数表明待排序的整数的个数,这一行的其余部分就是这个整数。
被排序的整数个数不多于9999;
最后一行以-1开始,不需要处理。
输出
对于每一行输入,输出从小到大排列的结果。数字之间用一个空格隔开。
注意:这个题目的目的是练习递归到迭代的转换,所以不要使用其它的算法。要求使用迭代+栈的实现方式.
例子输入
5 1 2 3 5 4
-1
例子输出
1 2 3 4 5