C语言— 回圈 / 递回

电脑程式中,副程式直接或间接呼叫自己就称为递回。递回算不上演算法,只是程式流程控制的一种。程式的执行流程只有两种:

1.循序,分支(回圈)
2.呼叫副程式(递回)


回圈是一种特别的分支,而递回是一种特别的副程式呼叫。

不少初学者以及教初学者的人把递回当成是复杂的演算法,其实单纯的递回只是另一种函数定义方式而已,在程式指令上非常简单。初学者为什么觉得递回很难呢?因为他跟人类的思考模式不同,电脑的两种思维模式:穷举与递回(enumeration and recursion),穷举是我们熟悉的而递回是陌生的,而递回为何重要呢?因为他是思考好的演算法的起点,例如分治与动态规划。

分治:一刀均分左右,两边各自递回,返回重逢之日,真相合并之时。

分治(Divide and Conquer) 是种运用递回的特性来设计演算法的策略。对于求某问题在输入S 的解P(S) 时,我们先将S 分割成两个子集合S 1与S 2,分别求其解P(S 1 ) 与P(S 2 ),然后再将其合并得到最后的解P(S)。要如何计算P(S 1 ) 与P(S 2 ) 呢?因为是相同问题较小的输入,所以用递回来做就可以了。分治不一定要分成两个,也可以分成多个,但多数都是分成两个。那为什么要均分呢?从下面的举例说明中会了解。

从一个非常简单例子来看:在一个阵列中如何找到最大值。回圈的思考模式是:从左往右一个一个看,永远记录着目前看到的最大值。

m = a[0];
for (i = 1 ; i < n; i++)
    m = max(m, a[i]);

分治思考模式:如果我们把阵列在i的位置分成左右两段,分别求最大值,再返回两者较大者。切在哪里呢?如果切在最后一个的位置,因为右边只剩一个无须递回,那么会是

int find_m1(int n) {
    if (n == 0) return a[0]; 
    return max(find_m1(n - 1), a[n]);
}

这是个尾端递回(Tail Recursion),在有编译最佳化的状况下可跑很快,其实可发现程式的行为就是上面那个回圈的版本。若我们将程式切在中点:

int find_m2(int left, int right) { // 范围=[左, 右) 惯用左闭右开区间
    if (右 - 左 == 1) 回传 a[左];
    int 中 = (左 + 右) / 2 ;
    返回 max(find_m2(左, 中), find_m2(中, 右));
}

效率一样是线性时间,会不会递回到stack overflow 呢?放心,如果有200 层递回阵列可以跑到2 200,地球已毁灭。

再来看个有趣的问题:假设我们有一个程式比赛的排名清单,有些选手是女生有些是男生,我们想要计算有多少对女男的配对是男生排在女生前面的。若以0 与1 分别代表女生与男生,那么我们有一个0/1 序列,要计算

Ans = | {(a[i], a[j]) | i < j 且 a[i] > a[j]} |

回圈的思考方式:对于每一个女生,计算排在他前面的男生数,然后把它全部加起来就是答案。

for (i = 0, ans = 0 ; i < n ;i++) {
    if (a[i]==0) { 
        cnt = num of 1 before a[i];
        ans += cnt;
    }
}

效率取决于如何计算cnt。如果每次拉一个回圈来重算,时间会跑到,如果利用类似前缀和(prefix-sum) 的概念,只需要线性时间。

for (i = 0, cnt = 0, ans = 0 ; i <n ;i++) {
    if (a[i] == 0) 
        ans += cnt;
    else cnt++;
}

接下来看分治思考模式。如果我们把序列在i 处分成前后两段任何一个要找的(1, 0) 数对只可能有三个可能:都在左边、都在右边、或是一左一右。所以我们可左右分别递回求,对于一左一右的情形,我们若知道左边有x 个1 以及右边有y 个0,那答案就有xy 个。递回终止条件是什么呢?剩一个就不必找下去。

int dc(int left, int right) {  // 范围=[left,right) 惯用左闭右开区间
      if (right - left < 2) return 0;
      int mid = (left + right) / 2; // 均匀分割
        int w = dc(left, mid) + dc(mid, right);
     	计算x = 左边几个 1, y = 右边几个 0
        return w + x * y;
}

时间复杂度呢?假设把范围内的资料重新看一遍去计算0 与1 的数量,那需要线性时间,整个程序的时间变成,结果是,不好。比回圈的方法差,原因是因为我们计算0/1 的数量时重复计算。我们让递回也回传0 与1 的个数,效率就会改善了,用Python 改写:

def dc(left, right):
     if right - left == 1:
         if ar[left]==0: return 0, 1, 0 # 逆序数,0的数量,1的数量
         return 0, 0, 1
    mid = (left + right) // 2  #整数除法
    w1, y1, x1 = dc(left,mid)
    w2, y2, x2 = dc(mid,right)
    return w1 + w2 + x1 * y2, y1 + y2, x1 + x2

时间效率是,所以结果是

如果分治可以做的回圈也可以,那又何必学分治?第一,复杂的问题不容易用回圈的思考找到答案;第二,有些问题回圈很难做到跟分治一样的效率。上述男女对的问题其实是逆序数对问题的弱化版本:给一个数字序列,计算有多少逆序对,也就是

| {(a[i], a[j]) | i < j 且 a[i] > a[j]} |。

回圈的思考模式一样去算每一个数字前方有多少大于它的数,直接做又搞到O(n^2),有没有办法像上面男女对问题一样,纪录一些简单的资料来减少计算量呢?你或许想用二分搜,但问题是需要重排序,就我所知,除非搞个复杂的资料结构,否则没有简单的办法可以来加速。那么来看看分治。基本上的想法是从中间切割成两段,各自递回计算逆序数并且各自排序好,排序的目的是让合并时,对于每个左边的元素可以笨笨地运用二分搜去算右边有几个小于它。

LL sol(int le, int ri) {  // 区间 = [le,ri)
    if (ri-le == 1) return 0;
    int mid = (ri + le) / 2;
    LL w = sol(le, mid) + sol(mid, ri);
    LL t = 0;
    for (int i = le; i < mid; i++)
        t += lower_bound(ar + mid, ar + ri, ar[i]) - (ar + mid);
    sort(ar + le, ar + ri);
    return w + t;
}

时间复杂度呢?即使我们笨笨地把两个排好序的序列再整个重排,结果是O(nlog 2 (n)),十万笔资料不需要一秒,比回圈的方法好多了。为什么说笨笨地二分搜寻与笨笨地重新排序呢?对于两个排好序的东西要合并其实可以用线性时间。那二分搜呢?沿路那么多个二分搜其实可以维护两个注标一路向右就好了。所以事实上不需要复杂的资料结构可以做到,熟悉演算法的人其实看得出来,这个方法基本上就是很有名的merge sort,跟quick sort 一样常拿来当作分治的范例。另外,如果merge sort的分割方式如果是,右边只留一个,那就退化成insertion sort 的尾端递回。

注:计算递回的时间需要解递回函数,这是有点复杂的事情。好在大多数常见的有公式解。

Tail recursion 是递回的一种特殊形式,副程式只有在最后一个动作才呼叫自己。以演算法的角度来说,recursion tree 全部是一脉单传,所以时间复杂度是线性个该副程式的时间。不过递回是需要系统使用stack 来储存某些资料,于是还是会有stack overflow 的问题。但是从编译器的角度来说,因为最后一个动作才呼叫递回,所以很多stack 资料是没用的,所以他是可以被优化的。基本上,优化以后就不会有时间与空间效率的问题。

注意: Python 没有tail recursion 优化,而且stack 深度不到1000。 C 要开编译器开启最佳化才会做tail recursion 优化。

  • 10
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值