vlambda博客
学习文章列表

深入浅出理解动态规划(一) | 交叠子问题

无论您远走何方

请点击蓝字,想念我

动态规划是一种将复杂问题分解成很多子问题并将子问题的求解结果存储起来避免重复求解的一种算法。


动态规划(dynamic programming)是一种算法设计技术,在20世纪50年代由一位卓越的美国数学家 Richard Bellman所发明的。

一个问题能够使用动态规划算法求解时具有的两个主要性质:

第一,交叠子问题

第二,最优子结构


本期通过比较递归法、记忆化搜索算法和打表算法的时间复杂度,讨论动态规划的主要性质--交叠的子问题。


交叠子问题(或重叠子问题)


同分治法(Divide and Conquer)一样,动态规划也是将子问题的求解结果进行合并,其主要用在当子问题需要一次又一次地重复求解时,将子问题的求解结果存储到一张表中(称为动态规划表)以免重复计算。因此当没有公共的(交叠的、重叠的)子问题时动态规划算法并不适用,因为没有必要将一个不再需要的结果存储起来。例如,二分搜索(折半查找)就不具有重叠的子问题性质。


我们以下面的递归求解斐波那契数列的问题为例子,就会发现有很多子问题一次又一次地被重复求解。


/*求解斐波那契数列的递归算法 */
int fib(int n) {
    if (n <= 1)
        return n;
    return fib(n - 1) + fib(n - 2);
}


下图是求解fib(5)的递归树:

深入浅出理解动态规划(一) | 交叠子问题

从上面的递归树我们可以发现fib(3)被调用了2次。如果我们在第1次计算fib(3)时将fib(3)的结果存储起来,这样我们在第2次调用fib(3)时就可以使用先前存储的值,而不需要再次计算fib(3)了。下面是两种存储fib(3)值的方法,这两种方法都可以重复使用存储的值:


1、记忆化搜索方法(自顶向下)


说明:所谓顶就是我们要求解的问题,这里就是fib(n)。


采用这种方法,只需对递归程序进行一点小小的修改,即在计算某个值时,先查询一个表。这个表可以使用数组来实现,初始时把数组的值全部初始为NIL(比如-1或0等值,这个值是计算过程中不会出现的那些值)。任何时候当我们需要求解一个子问题时,我们首先查询这个表,如果这个表中有我们预先对该子问题求解的结果,则我们直接返回表中的这个值,否则我们就对子问题进行计算,并把计算结果存入这个表中,以便在后续计算中可以重复使用。


下面的程序是求解第n个斐波那契数的记忆化搜索版本:


/* 求解第n个斐波那契数的记忆化搜索程序 */
#include<stdio.h>
#define NIL -1
#define MAX 100

int lookup[MAX]; /* 用数组实现的查找表 */

/* 将查找表初始化为NIL */
void _initialize() {
    int i;
    for (i = 0; i < MAX; i++)
        lookup[i] = NIL;
}

/* 求解第n个斐波那契数 */
int fib(int n) {
    if (lookup[n] == NIL) {/* 如果为NIL,表明第n项没有求解过 */
        if (n <= 1)
            lookup[n] = n;  /* 求解第n项,并把求解结果存入查找表 */
        else
            lookup[n] = fib(n - 1) + fib(n - 2);
    }
    return lookup[n]; /* 如果不为NIL,表明第n项求解过,直接返回 */
}

int main() {
    int n = 40;
    _initialize();
    printf("Fibonacci number is %d ", fib(n));
    return 0;
}


2、打表法(自底向上)


用打表法求解一个问题时,使用自底向上的方式进行计算并返回表格中的最后一项。例如,同样是计算第n个斐波那契数,首先计算fib(0),然后计算fib(1),再计算fib(2),计算fib(3),直到fib(n)。因此,我们采用的是自底向上的方式逐一建立子问题的求解结果表的。


下面是打表法求解第n个斐波那契数的程序。(所谓打表法,就是把计算结果制成表格,然后打印结果,简称打表法,也称制表法。)


/* 打表法 */
#include<stdio.h>
int fib(int n) {
    int f[n + 1];
    int i;
    f[0] = 0;
    f[1] = 1;
    for (i = 2; i <= n; i++)
        f[i] = f[i - 1] + f[i - 2];

    return f[n];
}

int main() {
    int n = 9;
    printf("Fibonacci number is %d ", fib(n));
    return 0;
}


打表法和记忆化搜索法都是把子问题的求解结果存入表格。在记忆化搜索方法中,我们只是在需要时往查询表中添加记录,而在打表法中,从第1项记录开始,所有计算结果一项一项地添加到表中。与打表法不同,记忆化搜索方法无需将所有计算结果添加到查询表中。


人们往往从时间复杂度和空间复杂度两个方面来衡量某个算法的优劣性,但在实际生活中,如果对某个算法的要求不是特别高,我们一般只考虑算法的时间复杂度。下面通过比较递归法、记忆化搜索方法、打表法在求解第n项斐波那契数时的时间开销来分析算法的优劣性。


递归方法:

#include<stdio.h>
#include<time.h>

/* 求解斐波那契数列的递归算法 */
int fib(int n) {
    if (n <= 1)
        return n;
    return fib(n - 1) + fib(n - 2);
}

int main() {
    int n = 40;
    clock_t begin, end;
    double time_spent;
    begin = clock();    /* 开始时间 */
    printf("Fibonacci number is %d\n", fib(n));
    end = clock();      /* 结束时间 */
    time_spent = (double)(end - begin) / CLOCKS_PER_SEC;
    printf("Time Taken %lf\n", time_spent);
    return 0;
}


运行结果:

深入浅出理解动态规划(一) | 交叠子问题

注意:上面的时间在不同的机器上是不同的


记忆化搜索方法:

/* 求解第n个斐波那契数的记忆化搜索程序 */
#include<stdio.h>
#include<time.h>
#define NIL -1
#define MAX 100

int lookup[MAX]; /* 用数组实现的查找表 */

/* 将查找表初始化为NIL */
void _initialize() {
    int i;
    for (i = 0; i < MAX; i++)
        lookup[i] = NIL;
}

/* 求解第n个斐波那契数 */
int fib(int n) {
    if (lookup[n] == NIL) {/* 如果为NIL,表明第n项没有求解过 */
        if (n <= 1)
            lookup[n] = n;  /* 求解第n项,并把求解结果存入查找表 */
        else
            lookup[n] = fib(n - 1) + fib(n - 2);
    }
    return lookup[n]; /* 如果不为NIL,表明第n项求解过,直接返回 */
}

int main() {
    int n = 40;
    clock_t begin, end;
    double time_spent;
    _initialize();
    begin = clock();    /* 开始时间 */
    printf("Fibonacci number is %d\n", fib(n));
    end = clock();      /* 结束时间 */
    time_spent = (double)(end - begin) / CLOCKS_PER_SEC;
    printf("Time Taken %lf\n", time_spent);
    return 0;
}


运行结果:

深入浅出理解动态规划(一) | 交叠子问题

注意:上面的时间在不同的机器上是不同的


打表法:

#include<stdio.h>
#include<time.h>

/* 打表法 */
#include<stdio.h>
int fib(int n) {
    int f[n + 1];
    int i;
    f[0] = 0;
    f[1] = 1;
    for (i = 2; i <= n; i++)
        f[i] = f[i - 1] + f[i - 2];

    return f[n];
}

int main() {
    int n = 40;
    clock_t begin, end;
    double time_spent;
    begin = clock();    /* 开始时间 */
    printf("Fibonacci number is %d\n", fib(n));
    end = clock();      /* 结束时间 */
    time_spent = (double)(end - begin) / CLOCKS_PER_SEC;
    printf("Time Taken %lf\n", time_spent);
    return 0;
}


运行结果:

深入浅出理解动态规划(一) | 交叠子问题

注意:上面的时间在不同的机器上是不同的


通过比较三种方法所花费的时间,很明显递归方法比记忆化搜索方法和打表法这两种采用动态规划方法所花费的时间都大很多。


以上就是本期所有的内容了,记得分享给身边人哦,感谢你的支持。下期继续更新动态规划--最优子结构。

深入浅出理解动态规划(一) | 交叠子问题
深入浅出理解动态规划(一) | 交叠子问题

因为喜欢,

所有陪伴。

感谢一路有你!

深入浅出理解动态规划(一) | 交叠子问题



深入浅出理解动态规划(一) | 交叠子问题
深入浅出理解动态规划(一) | 交叠子问题

球分享

球点赞

球在看