算法(第4版)练习题1.1.27的三种解法

Posted green-cnblogs

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了算法(第4版)练习题1.1.27的三种解法相关的知识,希望对你有一定的参考价值。

本文列举了对于 算法 : 第4版 / (美) 塞奇威客 (Sedgewick, R.) , (美) 韦恩 (Wayne, K.) 著 ; 谢路云译. -- 北京 : 人民邮电出版社, 2012.10 (2021.5重印)(以下简称原书或书)中的练习题 1.1.27 的三种解法(C++ 实现),并对包含原书题中的递归方法在内的四种解法的执行时间进行了统计和对比。

◆ 要求

原书中的练习题 1.1.27 要求对如下二项分布递归过程中的值保存在数组中,

b(n,k,p) = 1.0  ( n == 0 && k == 0 )
b(n,k,p) = 0.0  (  n < 0 ||  k < 0 )
b(n,k,p) = (1.0-p) * b(n-1,k,p) + p * b(n-1,k-1,p)

◆ 解一

依然采用递归的方式,但使用二维数组保存中间结果。

如下代码所示,

static long double binomial1(int N, int K, long double p)    // #1

    long double x;

    long double** b = new long double*[N+1];               // #2
    long double* data = new long double[(N+1)*(K+1)];

    ...

    x = binomial1_impl(b, N, K, p);         // #3

    ...

    return x;


static long double binomial1_impl(long double** b, int N, int K, long double p)

    if (N == 0 && K == 0) return 1.0L;
    if (N < 0 || K < 0) return 0.0L;
    if (b[N][K] == -1) 
        ...                                 // #4
        b[N][K] = (1.0L-p) * binomial1_impl(b, N-1, K, p) + p * binomial1_impl(b, N-1, K-1, p);
    
    return b[N][K];

保持对外的接口不变(#1),创建一个二维数组 b[0..N][0..K] 保存中间计算结果(#2),并将其传给算法实现(#3)。算法虽然还是用递归调用(#4),但由于中间结果保存在全局的二维数组中,不用频繁地压栈和弹栈去获取中间数据。此解法网络上也见于 [github] reneargento/algorithms-sedgewick-wayne[github] aistrate/AlgorithmsSedgewick

◆ 解二

使用二维数组保持中间结果,但同时将递归改进为递推。若以横向为 x 轴,纵向为 y 轴,左上角为坐标原点,则坐标轴上的 (x,y) 点则代表二维数组的 b[y][x] 单元。

以 N = K = 4 为例,

    0   1   2   3   4

0   *   *   *   *   *  <-- * 代表待计算的单元

1   *   *   *   *   *

2   *   *   *   *   *

3   *   *   *   *   *

4   *   *   *   *   ?  <-- 最终计算结果的单元 (4,4)

仔细考察递归关系式的特点,b(-1,*,p) = 0.0, b(*,-1,p) = 0.0。由

b(0,1,p) = (1.0-p) * b(-1,1,p) + p * b(-1,0,p)
         = (1.0-p) * 0.0 + p * 0.0
         = 0.0
b(0,2,p) = (1.0-p) * b(-1,1,p) + p * b(-1,1,p)
         = (1.0-p) * 0.0 + p * 0.0
         = 0.0
...

可推论出,二维数组中的第 0 行中的所有单元(不含b[0][0])均为 0.0;由

b(1,0,p) = (1.0-p) * b(0,0,p) + p * b(0,-1,p)
         = (1.0-p) * 1.0 + p * 0.0
         = 1.0-p
b(2,0,p) = (1.0-p) * b(1,0,p) + p * b(1,-1,p)
         = (1.0-p) * (1.0-p) + p * 0.0
         = (1.0-p)^2
...

可推论出,二维数组中的第 0 列的单元为 (1.0-p)^y。

因为每个单元 b[n][k] 结果(n 代表行号,k 代表列号),依赖于 b[n-1][k-1] 和 b[n-1][k] 的结果。为了减少计算量,递推过程可仅用到二维数组的部分单元。笔者设置一个 G 点,将待计算单元的区域划分为 \'#\' 和 \'*\' 两部分,G 点在 \'#\' 区域中。分为以下三种情况,

第一种情况,N < K:(如 N = 4, K = 6)

    0   1   2   3   4   5   6

0   -   -   G   #   #   #   #  <-- G 点所在单元为 0.0

1   -   -   -   *   *   *   *  <-- \'-\' 代表不用计算的单元

2   -   -   -   -   *   *   *

3   -   -   -   -   -   *   *

4   -   -   -   -   -   -   ?  <-- 最终结果的存储单元

G 点为 b(0,K-N)。按照递推关系式容易推导出,\'#\' 和 \'*\' 区域均为 0.0,所以最终结果即 0.0。

第二种情况,N = k:(如 N = 6, K = 6)

    0   1   2   3   4   5   6

0   G   #   #   #   #   #   #  <-- G 点所在单元为 1.0

1   -   *   *   *   *   *   *

2   -   -   *   *   *   *   *

3   -   -   -   *   *   *   *

4   -   -   -   -   *   *   *

5   -   -   -   -   -   *   *

6   -   -   -   -   -   -   ?

G 点为 b(0,0)。按照递推关系式容易推导出,数组中 n = k 的单元为 p^n。所以最终结果即 p^N。

第三种情况,N > K:(如 N = 6, K = 4)

    0   1   2   3   4

0   #   #   #   #   #

1   #   #   #   #   #

2   G   #   #   #   #  <-- G 点所在单元为 (1.0-p)^2

3   -   *   *   *   *

4   -   -   *   *   *

5   -   -   -   *   *

6   -   -   -   -   ?

G 点为 b(N-K,0)。可先计算 \'#\' 区域中的单元,再计算 \'*\' 区域中的单元,得出最终结果。处理\'#\'区域时,为避免大量的数组下标越界判断,可以考虑先计算 0 行和 0 列的所有单元。

如下代码所示,

static long double binomial2(int N, int K, long double p)

    long double x;

    if (N < K)                        // #1

        x = 0.0L;

     else if (N == K)                 // #2

        x = powl(p, N);

     else                        // #3

        ...

        b[0][0] = 1.0L;

        // process \'#\' area                      // #4
        // calcuate [1..N-K][0]
        for (i = 1; i <= N-K; ++i)
            b[i][0] = powl(1.0L-p, i);
        // calcuate [0][1..K]
        for (j = 1; j <= K; ++j)
            b[0][j] = 0.0L;
        // calcuate [1..N-K][1..K]
        for (i = 1; i <= N-K; ++i)
            for (j = 1; j <= K; ++j)
                b[i][j] = (1.0L-p) * b[i-1][j] + p * b[i-1][j-1];

        // process \'*\' area                            // #5
        for (i = N-K+1; i <= N; ++i)
            for (j = i-(N-K); j <= K; ++j)
                b[i][j] = (1.0L-p) * b[i-1][j] + p * b[i-1][j-1];

        x = b[N][K];                       // #6

        ...

    

    return x;

三条分支(#1、#2、#3)分别对应前述三种情况。在第三种情况下,再先处理 \'#\' 区域(#4),然后采用递推求值的方式处理 \'*\' 区域(#5),最后得到结果(#6)。

◆ 解三

此方法是从递推解法中引申出来了。进一步探究这个此二项分布的递归式,以 N = 4 且 K = 4 为例,

   0          1                2                    3                4

0  1.0


1  1.0-p      p


2  (1.0-p)^2  2*(1.0-p)*p      p^2


3  (1.0-p)^3  3*[(1.0-p)^2]*p  3*(1-p)*(p^2)        p^3


4  (1.0-p)^4  4*[(1.0-p)^3]*p  6*[(1.0-p)^2]*(p^2)  4*(1.0-p)*(p^3)  p^4

可以发现,从第 0 行到第 N 行的非零单元即“杨辉三角形”,第 n 行中的非零单元之和构成 [(1.0-p) + p]^k 的展开式。因此,解二中的第三种情况,可结合利用通项公式 C(N,K)*[(1.0-p)^(N-K)]*(p^K) 来解决。

如下代码所示,

static long double binomial3(int N, int K, long double p)

    long double x;

    if ...

     else 

        x = combination(N, K) * powl(1.0L-p, N-K) * powl(p, K);

    

    return x;

◆ 测试

编译并执行程序,

$ g++ -std=c++11 -o 27.out 27.cpp
$ ./27.out 10 5 0.25

为易于显示两者之间的差异,笔者选择了硬件配置偏低的测试环境。

  • 硬件配置:Raspberry Pi 3 Model B
    • Quad Core 1.2GHz 64bit
    • 1G RAM
    • 16G MicroSD
    • 100 Base Ethernet
  • 软件配置:Raspbian Stretch
    • g++ (Raspbian 6.3.0-18+rpi1+deb9u1) 6.3.0 20170516

测试并记录了 (N, K, p) 为 (10, 5, 0.25), (20, 10, 0.25), (40, 20, 0.25), (80, 40, 0.25), (100, 50, 0.25) 的情况下,原递归、解一、解二、解三执行时所消耗的时间。

结果如下图所示,

对比可以看出,不同的解法在执行时间上的差异随着计算量的增加而逐步扩大。

◆ 最后

完整示例代码和测试结果,请参考 [gitee] cnblogs/17328989

写作过程中,笔者参考了 [github] reneargento/algorithms-sedgewick-wayne[github] aistrate/AlgorithmsSedgewick 的实现方式。致 reneargento 和 aistrate 两位。

约瑟夫环的三种解法

什么是约瑟夫环问题

  • 已知 n 个人(以编号1,2,3 … n 分别表示)围成一圈。从编号为 1 的人开始报数,数到 m 的那个人出列;他的下一个人又从 1 开始报数,数到 m 的那个人又出列;依此规律重复下去,直到最后剩下一个人。要求找出最后出列的人的编号

    可能有些同学看到的不是从编号为 1 的人开始报数,但我想说,不管从编号为几的人开始报数,其实都可以将这个第一个开始报数的人的编号看作是 1,在得出最后出列的人的编号之后,我们很容易就可以将其转为从编号为 1 的人开始报数的情况下,最后一个出列的人的编号

用数组解决

  • 用数组来解决,应该是很多第一次接触到这个问题的人最容易想到的一种方式,思想很简单,但实现起来需要考虑的地方还是很多的
  1. 用一个数组来存放 1,2,3 … n 这 n 个编号(假设 n = 6, m = 3,k = 1)
    技术图片

  2. 然后不停着遍历数组,对于被选中的编号,我们就做一个标记,例如编号 arr[2] = 3 被选中了,那么我们可以做一个标记,例如让 arr[2] = -1,来表示 arr[2] 存放的编号已经出局了
    技术图片

  3. 然后就按照这种方法,不停着遍历数组,不停着做标记,直到数组中只有一个元素是非 -1 的,这样,剩下的那个元素就是我们要找的元素了。演示如下
    技术图片

  4. 编码实现这里就不写了,本文的重点在第三种方法

  5. 这种做法的时间复杂度是 O(nm), 空间复杂度是 O(n)

用环形链表解决

  • 用链表来处理其实和上面处理的思路差不多,只是用链表来处理的时候,对于被选中的编号,不再是做标记,而是直接移除,因为从链表移除一个元素的时间复杂度很低,为 O(1)

  • 当然,上面数组的方法你也可以采用移除的方式,不过数组移除的时间复杂度为 O(n)

  1. 先创建一个环形链表来存放元素
    技术图片

  2. 然后在遍历链表的同时做删除操作,这里就不全部演示了
    技术图片

  3. 核心代码如下:

    // 定义链表节点
    class Node{
        int date;
        Node next;
    
        public Node(int date) {
            this.date = date;
        }
    }
    
    
     public static int solve(int n, int m) {
            if(m == 1 || n < 2)
                return n;
            // 创建环形链表
            Node head = createLinkedList(n);
            // 遍历删除
            int count = 1;
            Node cur = head;
            Node pre = null;//前驱节点
            while (head.next != head) {
                // 删除节点
                if (count == m) {
                    count = 1;
                    pre.next = cur.next;
                    cur = pre.next;
                } else {
                    count++;
                    pre = cur;
                    cur = cur.next;
                }
            }
            return head.date;
        }
    
        static Node createLinkedList(int n) {
            Node head = new Node(1);
            Node next = head;
            for (int i = 2; i <= n; i++) {
                Node tmp = new Node(i);
                next.next = tmp;
                next = next.next;
            }
            // 头尾串联
            next.next = head;
            return head;
        }
    
  4. 这种做法的时间复杂度是 O(nm), 空间复杂度是 O(n),和第一种方法一样

用递归解决

  • 为了解决上面两种方法的效率问题,我们可以从数学角度对这个问题进行分析,找出其中的规律,然后使用递归实现

  • 为了方便导出递归公式,这里先对问题做个简短的定义

  • 问题定义:有 n 个人,编号为 1,2,……,n,从编号为 1 的人开始,从 1 开始依次报数,每报到 m 时,该人出列,求最后出列的人的编号

  • 我们首先定义一个函数 f(n,m)f(n,m) f(n,m)f(n,m)f(n,m)f(n,m),它表示的是 nn nnnn 个人报数,每报到 mm mmmm 的人出列,最后出列的人的编号。显然问题的最小规模就是当 n = 1 时,此时 f(1,m)=1f(1,m)=1 f(1,m) = 1f(1,m)=1f(1,m) = 1f(1,m)=1

  • f(n?1,m)f(n?1,m) f(n-1,m)f(n?1,m)f(n-1,m)f(n?1,m) 表示的是 n?1n?1 n-1n?1n-1n?1 个人报数,每报到 mm mmmm 的人出列,最后出列的人的编号

  • 这里先把结论给出来:f(n,m)=(f(n?1,m)+m)%nf(n,m)=(f(n?1,m)+m)%n f(n,m)=(f(n-1,m)+m) \% nf(n,m)=(f(n?1,m)+m)%nf(n,m)=(f(n-1,m)+m) \% nf(n,m)=(f(n?1,m)+m)%n

  • 这个公式是如何推导出来的呢?我们已经知道,f(n?1,m)f(n?1,m) f(n-1,m)f(n?1,m)f(n-1,m)f(n?1,m) 表示的是总人数为 n - 1 个时,最后出列的人的编号,假如暂不考虑数组越界的问题,那么当总人数为 n 时,最后出列的人的编号就是 f(n?1,m)+mf(n?1,m)+m f(n-1,m)+mf(n?1,m)+mf(n-1,m)+mf(n?1,m)+m 。为了防止数组越界,所以我们对 n 取余数

    有些人看到这个解释可能很模糊,其实很简单。我们这样想:反正总共是 n 个人,后一次出列的人的编号肯定是比前一次出列的人的编号大 mm mmmm 的,同时为了兼顾数组越界的情况,我们需要对 nn nnnn 取余数

  • 下面给出代码实现:

    public int solution(int n, int m) {
            int p = 0;
            // 从第二个人开始遍历所有的人
            for (int i = 2; i <= n; i++) {
                p = (p + m) % i;
            }
            return p + 1; // 最后出列的人的编号
        }
    
  • 如果要用递归,就是这样写

    public int f(int n, int m) {
        if(n == 1)   return n;
        return (f(n - 1, m) + m - 1) % n + 1;
    }
    
    • 这里没有写成 return (f(n - 1, m) + m ) % n,主要是因为编号是从 1 开始的,而不是从 0 开始的
    • 另可参考这里
  • 如果编号从 0 开始,则最后的返回值表示的是数组的下标,要想得到编号,最终的返回结果还需要加 1,代码如下

    public class YueSheFu {
    
        public static void main(String[] args) {
            int position = f(5,3);
            System.out.println("最后出列的人的编号为:" + (position + 1));
        }
    
        public static int f(int n, int m) {
            if(n == 1) {
                return 0;
            }
            return (f(n - 1, m) + m) % n;
        }
    }
    

https://blog.csdn.net/WinstonLau/article/details/99701837






以上是关于算法(第4版)练习题1.1.27的三种解法的主要内容,如果未能解决你的问题,请参考以下文章

最大公约数的三种解法

Haskell的三种八皇后问题的解法

统计单词数目的三种解法

斐波那契系列问题的三种解法

算法(Algorithms)第4版 练习 2.1.4

约瑟夫环的三种解法