算法分析与设计

97 阅读8分钟
算法是一系列解决问题的清晰指令,代表着用系统的方法描述解决问题的策略机制。

一个算法的优劣可以用时间复杂性和空间复杂性来衡量。

1.1 引言

1.1.1 算法的描述

特征:

  1. 有穷性

  2. 确定性

  3. 输入

  4. 输出

  5. 可行性

1.1.2 算法的设计

  1. 穷举搜索法

  2. 迭代算法

  3. 递推算法

  4. 递归算法

  5. 分治算法

  6. 贪心算法

  7. 动态规划算法

  8. 回溯算法

  9. 分支限界算法

1.2 算法的复杂性

1.2.1 时间复杂性

定义:执行算法所需要的时间。

1.2.2 空间复杂性

定义:算法需要消耗的内存空间。

@[toc]

前言

分治法的设计思想是,将一个难以直接解决的大问题分割成一些规模较小的相同问题,以便各个击破,分而治之。


3.1 递归算法

程序直接或间接调用自身的编程技巧称为递归算法。

递归的优势在于用有限的语句定义对象的无限集合。

一般来说,递归需要有边界条件、递归前进段和递归返回段。当边界条件不满足时,递归前进;当边界条件满足时,递归返回。

**注意:在使用递归策略时,必须有一个明确的递归结束条件,成为递归出口,否则递归将无限进行下去。(死锁)**

递归算法一般用于解决三类问题:
  1. 数据的定义是按递归定义的。例如Fibonacci函数。

  2. 问题解法用递归算法实现。例如回溯算法。

  3. 数据的结构形式是按递归定义的。例如树的遍历,图的搜索。

递归的缺点:

递归算法解题的运行效率较低。递归次数过多容易造成堆栈溢出。

3.2 分治策略

分治策略是对于一个规模为n的问题,若该问题可以容易地解决则直接解决,否则将其分解为k个规模较小的子问题,这些子问题相互独立且与原问题形式相同。分治策略递归地解这些子问题,然后将各子问题的解合并得到原问题的解。


3.2.1 分治法的基本步骤

分治法在每一层递归上都有以下3个步骤:
  1. 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题。

  2. 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题。

  3. 合并:将各个子问题地解合并为原问题的解。

算法 3.5 分治策略的算法设计模式

Divide_and_Cnoquer(P){ // P表示问题的规模

    if(|P| <= n0){ 
    /* n0为一阈值,表示当问题P的规模不超过n0时
     * 问题容易直接解出,不必继续分解。 
     */    
        //adhoc(P)是该分治法中的基本子算法,
        //用于直接解小规模的问题P,当P的规模不超过n0时,直接用adhoc(P)求解。
        return adhoc(P);
    }

    divide P into smaller substances P1, P2, ..., Pk;

    for (i=1; i<=k; i++)
        yi = Divide_and_Cnoquer(Pi);

    //算法merge(y1, y2, ..., yk)是该分治法中的合并子算法,
    //用于将P的子问题P1,P2,..,Pk的解y1,y2...,yk合并为P的解。
    return merge(y1, y2, ..., yk);
}

3.2.2 分治法的适用条件

分治法所能解决的问题一般具有以下几个特征:
  1. 该问题的规模缩小的一定的程度可以容易的解决。

  2. 该问题可以分解为若干个规模较小的相同的问题,即该问题具有最优子结构性质。

  3. 利用该问题分解出的各个子问题的解可以合并为该问题的解。

  4. 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。

    第2个特征是分治法的前提;

    第3个特征是关键, 能否利用分治法完全取决于问题是否具有第3个特征。

    第4个特征涉及到分治法的效率

动态规划的基本思想

动态规划算法通常用于求解具有某种最优性质的问题。

在这类问题中,可能会有许多可行解,每一个解对应于一个值,我们希望找到具有最优值的解。

动态规划算法可以用一个表记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。

动态规划与分治法的相同点

将待求解的问题分解为若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。

两者的不同点

适用于动态规划求解的问题,经分解得到的子问题往往不是相互独立的

而用分治法求解的问题,经分解得到的子问题往往是互相独立的。

设计动态规划算法的步骤

  1. 找出最优解的性质,并刻画其结构特征;

  2. 递归地定义最优值(写出动态规划方程);

  3. 自底向上的方式计算出最优值;

  4. 根据计算最优值时得到的信息,构造一个最优解。

动态规划问题的特征

两个重要的性质:最优子结构性质和子问题重叠性质。

  1. 最优子结构:当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。

  2. 重叠子问题:在用递归算法自顶向下解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法,对每个子问题只解一次,而后将其解保存在一个表中,在以后尽可能地多地利用这些子问题的解。

4.1 矩阵连乘积问题

4.1.2 建立递归关系

m[i][j]=0,i=jm[i][j] = 0, i=j
m[i][j]=min{m[i][k]+m[k+1][j]}+p[i1]p[i]p[k],i<=k<j,i<jm[i][j] = min \lbrace m[i][k] + m[k+1][j] \rbrace + p[i-1]*p[i]*p[k], i<=k<j, i<j
                                                                                @[TOC]

在求最优解问题中,依据某种贪心标准,从问题的初始状态出发,直接去求每一步的最优解,通过若干次的贪心选择,最终得出整个问题的最优解,这种求解方法就是贪心算法。

当一个问题具有最优子结构性质和贪心选择性质时,贪心算法通过一系列的选择来得到一个问题的解。

5.2 贪心算法的理论基础

贪心算法是一种在每一步选择中都采取在当前状态下最好或最优的选择,即贪心选择,从而希望得到的结果是最好或最优的算法。

5.2.1 贪心选择性质

贪心选择性质是指所求问题可以通过一系列局部最优的选择,即**贪心选择**来得到。这是贪心算法可行的**第一个基本要素**,也是贪心算法和动态规划算法的**主要区别**
  1. 动态规划算法中,每步做出的选择往往依赖于相关子问题的解,因而只有在解出相关子问题后,才能做出选择。

  2. 贪心算法中,仅在当前状态下做出最好选择,即局部最优选择,然后再去解出这个选择后产生的相应的子问题。贪心算法所做的贪心选择可以依赖于以往所做过的选择,但绝不依赖于将来所做的选择,也不依赖于子问题的解。


正是由于这种差别,动态规划算法通常以自底向上的方式解各子问题,而贪心算法则通过自顶向下的方式进行,以迭代的方式做出相继的贪心选择,每做一次贪心选择就将所求的问题简化为规模更小的子问题。

5.2.2 最优子结构性质

当一个问题的最优解包含其子问题的解时,称此问题具有最优子结构性质。

贪心算法的每一次操作都对结果产生直接影响,而动态规划则不是。贪心算法对每个子问题的解决方案做出选择,不能回退;动态规划则会根据以前的选择对当前方案进行选择,有回退功能。

动态规划主要运用于二维或三维问题,而贪心算法一般用于一维问题。  

5.2.3 贪心算法的求解过程

使用贪心算法应考虑以下几个方面:

  1. 候选集合A

  2. 解集合S

  3. 解决函数solution

  4. 选择函数select

  5. 可行函数feasible

算法5.2 贪心算法的一般流程

// A是问题的输入集合,即候选集合
Greedy(A){
    S = {}; // 初始解集为空
    while (not solution(S)){ // 集合S没有构成一个解
        x = select(A); // 在候选集A中做贪心选择
        if (feasible(S, x)) // 判断集合S中加入x是否可行
            S += {x}; // 在集合S中加入{x}
        A -= {x}; // 候选集A中删去{x}
    }
    return S;
}
回溯法是一种组织搜索的一般技术,有“通用的解题法”之称,用它可以系统地搜索一个问题地所有解或任意解。

它可以系统地搜索一个问题地所有解或任意解,既有系统性又有跳跃性。    

回溯法的基本做法是搜索,它是一种组织得井井有条的,能避免不必要搜索的穷举式搜索法。这种以深度优先方式系统地搜索问题的解的方法称为回溯法。

6.1 回溯算法的理论基础

6.1.1 问题的解空间

应用回溯法求解时,需要明确定义问题的解空间。问题的解空间应**至少**包含问题的**一个最优解**。

定义了问题的解空间后,还应将解空间组织起来,通常组织成**树****图**的形式。

从树根到叶子结点的任一路径表示解空间中的一个元素。

6.1.2 回溯法的基本思想

定义以下概念:
  • 活结点: 如果已生成一个结点而它的所有儿子结点还没生成,则这个结点叫做活结点。

  • 扩展结点: 当前正在生成其儿子结点的活结点叫做扩展结点。(正扩展的结点)

  • 死结点: 不再进一步扩展或者其儿子结点已全部生成的结点就是死结点。

例6-1 假设背包容量为C=30, w = {16, 15, 15}, v={45, 25, 25}, 其回溯搜索过程如图6-2所示

例6-2 旅行商问题

旅行商从n个城市中的某一城市出发,经过每个城市一次且仅一次,最后回到原出发点,在所有可能的路径中求出路径长度最短的一条。    

目的是要一条汉密尔顿回路。

回路总权值最小,即:
min{i=1n1w(vi,vi+1)+w(vn,v1)}min\lbrace\sum_{i=1}^{n-1}w(v_i, v_{i+1}) + w(v_n, v_1)\rbrace
                                            *图6-4 旅行商问题的解空间树*

在回溯搜索解空间树时,通常采用两种策略(剪枝函数)避免无效搜索以提高回溯算法的搜索效率:
  1. 用约束函数在扩展结点处剪去不满足约束条件的子树。

  2. 用限界函数剪去不能得到最优解的子树。

    综上所述,使用回溯法解题,通常有以下三个步骤:

  3. 针对所给问题,定义问题的解空间。

  4. 确定易于搜索的解空间结构。

  5. 以深度优先的方式搜索解空间树,并且在搜索过程中用剪枝函数避免无效搜索。

6.1.3 子集树和排列树

遍历子集树的任何算法,其计算时间复杂性都是O(2^n)。

**0-1背包问题是子集树。**    

回溯算法搜索子集树的一般算法描述,如算法6-1所示。

算法6.1 回溯算法搜索子集树的伪代码

// t为树的深度,根为1
void traceback(t){
    if (t > n){
        update(x);
    } else {
        for(int i=0; i<=1; i++){ // 每个结点只有两棵子树
            x[t] = i; // 即 0 或 1 
            // 约束函数constraint()和限界函数bound()
            if (constraint(t) && bound(t)) traceback(t+1);
        }
    }
}

遍历排列树时的时间复杂性是O(n!)。

**旅行商问题是排列树。**

算法6.2 回溯算法搜索排列树的伪代码

void traceback(t){
    if(t > n){
        update(x);
    } else {
        for(int i=0; i<n; i++){
            // 用交换元素的方式实现全排列
            swap(x[t], x[i]);
            if (constraint(t) && bound(t)) traceback(t+1);
            // 恢复状态
            swap(x[t], x[i]);
        }
    }
}