算法设计与分析复习

算法基本概念

算法的定义

  • 确定性:每条指令都是明确的、无二义的
  • 能行性:每条指令都必须是能够执行的
  • 输入:允许有 0 个或多个输入量,取自特定的集合
  • 输出:产生一个或多个输出,它(们)与输入量之间存在着某种特定的关系
  • 有穷性:每条指令执行的次数都是有穷的

​ 有穷指令序列若满足上述 5 条,则通常称之为算法;只满足前 4 条而不满足第 5 条,则通常称之为计算过程,只要不停电、机器不坏,计算过程就可以永远执行下去;永远执行的计算过程并非毫无用处—— OS 就是计算过程

算法好坏如何衡量

用计算时间来衡量一个算法的好坏在不同的机器之间无法比较,需要用独立于具体计算机的客观衡量标准

  • 问题的规模:一个或多个整数,作为输入数据量的测度
  • 基本计算:解决给定问题时占支配地位的运算
  • 算法的计算量函数:用问题规模的某个函数来表示算法的基本运算量,这个表示基本运算量的函数称为算法的时间复杂度,用 T(n) 来表示

时间复杂度

  • 渐进时间复杂度——问题规模趋于极限情形时
    • T(n) = O(f(n)):若存在 c > 0,和正整数 n0 ≥ 1,使得当 n > n0 时,总有 T(n) ≤ c * f(n)。其给出了算法时间复杂度的上界,复杂度不可能比 c * f(n) 更大
    • T(n) = Ω(f(n)):若存在 c > 0,和正整数 n0 ≥ 1,使得当 n > n0 时,存在无穷多个 n,使得 T(n) ≥ c * f(n) 成立。其给出了算法时间复杂度的下界,复杂度不可能比 c * f(n) 更小
    • T(n) = Θ(f(n)):若存在 c1,c2 > 0,和正整数 n0 ≥ 1,使得当 n ≥ n0 时,总有 T(n) ≤ c1 * f(n),且有无穷多个 n,使得 T(n) ≥ c2 * f(n) 成立,即 T(n) = O(f(n)) 与 T(n) = Ω(f(n)) 都成立。即给出了上界,也给出了下界
  • 多项式时间和指数时间
    • 多项式时间的算法之间虽有差距,一般可以接受
    • 指数时间的算法对较大的 n 无实用价值
  • 最坏时间复杂度:规模为 n 的所有输入中,基本运算执行次数为最多的时间复杂度
  • 平均时间复杂度:规模为 n 的所有输入的算法时间复杂度的平均值

算法评价

  • 正确性:评价算法的首要因素
    • 程序正确性证明
    • 程序测试
    • 即使很小的错误也可能会引起巨大的连锁反应,甚至导致严重的后果
  • 健壮性:算法/程序对正确的输入要能计算出正确的结果,对不正确的输入也要能应对处理
  • 简单性:算法/程序的可读性好,易调试、改进
  • 高效性:时间、空间复杂度较小,特别是时间复杂度
  • 最优性:证明所给算法是解决同一类问题中最好的

补充:算法研究的 5 主要步骤 => 设计、表示、确认、分析、实现和测试

递归与分治

递归的概念

  • 递归函数:用函数自身给出定义的函数
  • 递归算法
    • 一个算法包含对自身的调用
    • 这种调用可以是直接的,也可以是间接的
  • 优点:结构清晰,可读性强,且容易用数学归纳法来证明算法的正确性,为设计算法、调试程序带来很大的便利
  • 缺点:递归算法的运行效率较低

递归式解法

  • 当一个算法包含对自身的递归调用时,其运行时间通常可以用递归式来表示
  • 主方法 => 主定理

T ( n ) = a T ( n / b ) + f ( n ) , 其 中 f ( n ) 为 渐 进 正 函 数 , a 和 b 均 为 常 数 , 且 a ≥ 1 , b > 1 对 于 递 归 式 , 比 较 f ( n ) 和 n log ⁡ b a 1.   若 对 于 某 常 数 ϵ > 0 , f ( n ) = O ( n log ⁡ b a − ϵ ) , 则 T ( n ) = O ( n log ⁡ b a ) 2.   若 f ( n ) = Θ ( n log ⁡ b a ) , 则 T ( n ) = Θ ( n log ⁡ b a lg ⁡ n ) 3.   若 对 于 某 常 数 ϵ > 0 , 有 f ( n ) = Ω ( n log ⁡ b a + ϵ ) 且 对 常 数 c < 1 与 所 有 足 够 大 的 n , 有 a f ( n / b ) ≤ c f ( n ) , 则 T ( n ) = Θ ( f ( n ) ) T(n) = aT(n/b) + f(n),其中 f(n) 为渐进正函数,a 和 b 均为常数,且 a \geq 1,b > 1 \\ 对于递归式,比较 f(n) 和 n^{\log_b a} \\ 1.\ 若对于某常数 \epsilon > 0,f(n) = O(n^{\log_b a - \epsilon}),则 T(n) = O(n^{\log_b a}) \\ 2.\ 若 f(n) = \Theta(n^{\log_b a}),则 T(n) = \Theta(n^{\log_b a} \lg n) \\ 3.\ 若对于某常数 \epsilon > 0,有 f(n) = \Omega(n^{\log_b a + \epsilon})且对常数 c < 1 与所有足够大的n,有 \\ \quad af(n/b) \leq cf(n),则 T(n) = \Theta(f(n)) T(n)=aT(n/b)+f(n)f(n)aba1b>1f(n)nlogba1. ϵ>0f(n)=O(nlogbaϵ)T(n)=O(nlogba)2. f(n)=Θ(nlogba)T(n)=Θ(nlogbalgn)3. ϵ>0f(n)=Ω(nlogba+ϵ)c<1naf(n/b)cf(n)T(n)=Θ(f(n))

:主定理3种情况并没有覆盖全部情况

  • 递归树方法
    • 每个结点代表递归函数调用集合中一个子问题的代价,将所有层的代价相加得到总代价
    • 当用递归式表示算法的时间复杂度时,可用递归树的方法 => 递归式转换为递归树
    • 递归树方法模拟了算法的递归执行,可以由递归树方法产生对算法时间复杂度的较好猜测
  • × 代换法 ×
    • 步骤
      • 猜测解的形式
      • 用数学归纳法证明之
    • 只适用于解的形式很容易猜的情形
    • 如何猜测则需要经验

什么是分治法(基本策略)

  • 分解:将原问题分解为多个子问题
  • 解决:求解子问题
  • 合并:组合子问题的解得到原问题的解

分治法适用情况

  • 问题的规模缩小到一定程度就可以容易地解决
  • 问题可分解为若干个规模较小地相同问题,即该问题具有最优子结构性质
  • 基于子问题的解可以合并为原问题的解
  • 问题所分解出的各个子问题之间相互独立,即子问题之间不包含公共的子问题

分治法与平衡的概念

  • 使子问题规模尽量接近的做法就是平衡
  • 在使用分治法和递归时,要尽量把问题分成规模相等,或至少是规模相近的子问题以提高算法的效率

分治法实例

快排

  • 分解:数组 A[p…r] 被划分为子数组 A[p…q-1] 和 A[q+1…r],A[p…q-1] 中的每个元素都小于等于 A[q],A[q+1…r] 中的每个元素都大于等于 A[q],q 在划分时确定
  • 解决:通过递归调用快速排序算法,对子数组 A[p…q-1] 和 A[q+1…r] 进行排序
  • 合并:由于子数组的排序为原地排序,解的合并不需要操作,整个数组已经排好序
  • 最坏时间复杂度为 Θ(n^2),平均时间复杂度 Θ(nlgn)
  • 改进:不总是选取 A[r] 作主元,通过随机化方式选取主元
PARTITION(A, p, r)
    x <- A[r]
    i <- p-1
    for j <- p to r-1
        do if A[j] <= x
            then i <- i+1
            	 exchange A[i] <-> A[j]
    exchange A[i+1] <-> A[r]
    return i+1
QUICKSORT(A, p, r)
	if p < r
	then q = PARTITION(A, p, r)
		 QUICKSORT(A, p, q-1)
		 QUICKSORT(A, q+1, r)

QUICKSORT(A, 1, length[A])

补充:快速排序(分治策略)

最小元/最大元

  • 当 n = 2 时,一次比较就可以找出两个数据元素的最大值和最小值
  • 当 n > 2 时,可以把 n 个数据元素分为大致相等的两半
  • 求数组的最大元、最小元的算法下界:上取整(3n/2) - 2
void max_min(int a[], int l, int r, int &max, int &min) {
	int n = r-l+1;
	int p = n/2;
	if(n == 1) {                          // 只有一个元素 
		max=a[l];
		min=a[l];
	} else if(n == 2){                    // 只有两个元素 
		max = a[l]>a[r] ? a[l]:a[r];
		min = a[l]<a[r] ? a[l]:a[r];
	} else {                              // 有大于两个元素时 
		int lmax, lmin, rmax, rmin;       // 左边最大元、最小元、右边最大元、最小元 
		max_min(a, l, p, lmax, lmin);     // 求左边的最大元和最小元 
		max_min(a, p+1, r, rmax, rmin);   // 求右边的最大元和最小元 
		max = lmax>rmax ? lmax:rmax;
		min = lmin<rmin ? lmin:rmin;
	}
}

补充:最大元和最小元(直接求解法和分治法)

最近点对问题

  • 对于平面上给定的 N 个点,给出距离最近的两个点
    • Brute force 法:把所有点对逐一检查一遍
    • 分治法:如何分解、如何合并?
  • 点数较少时直接计算最近点对
  • 预处理
    • 将点对按 X 坐标排序
    • 将点对按 Y 坐标排序
  • 分解
    • 按照 X 坐标将点集二分,排序方法时间复杂度可为 O(nlogn)
    • 同时获得分解后的按 Y 坐标排好序的点集
  • 解决
    • 递归寻找 PL 和 PR 中的最近点对
    • 设其找到的最近点对的距离分别是 δL 和 δR
    • δ = min(δL, δR)
  • 合并
    • 最近距离可能并不是 δ,存在这样的情况,一个点在 PL 中,另一个点在 PR 中,而这两点之间的距离小于 δ
    • 如何检查?合并为什么要这样做?
    • 只考虑分割线两侧距离各为 δ 的点 => 找出带状区域中的点
    • 继续压缩点的范围 => 检查带状区域中的点(已排序),计算每点与其后面 7 个点的距离,更新最近点对距离?
  • 时间复杂度为 O(nlogn)

补充:分治——最近点对问题【算法设计与分析】分治法与最近点对问题

寻找顺序统计量问题

  • 期望线性时间求解方法
    • 使用 Random Partition 对数组进行划分(借用快排的划分方式)
    • 检查主元元素是否第 i 小,如果是,则返回
    • 否则,确定第 i 小落在划分后的低区还是高区
    • 如果落在低区,则在低区的子数组中递归选择
    • 如果落在高区,则在高区的子数组中递归选择
  • 最坏情况线性时间求解方法
    1. 将输入数组的 n 个元素分为 ceil(n/5) 组
    2. 寻找这些组中每一组的中位数
    3. 对第二步中找出的中位数递归调用 SELECT 以找到其中位数 x
    4. PARTITION,按中位数 x 对输入数组进行划分,x 为第 k 小元素
    5. 如果 i = k,则返回 x;否则,如果 i < k,则在低区递归调用 SELECT 寻找第 i 小元素;否则,如果 i > k,则在高区寻炸第 (i-k) 个最小元素

补充:第K顺序统计量的求解

动态规划

适用范围

  • 若一个问题可以分解为若干个高度重复的子问题,且问题也具有最优子结构性质(问题的最优解中包含着其每一个子问题的最优解),就可以用动态规划法求解
  • 具体方式:可以递推的方式逐层计算最优值并记录必要的信息,最后根据记录的信息构造最优解(自底向上=>小问题到大问题)
  • 思想:保存已解决的子问题的答案,在需要时使用,从而避免大量重复计算

动态规划解题步骤

  • 找出最优解的性质,并刻画其结构特征
  • 递归地定义最优值(写出动态规划方程)
  • 以自底向上的递推方式计算出最优值
  • 根据计算最优值时得到的信息,以递归方法构造一个最优解

动态规划实例

矩阵连乘

记 矩 阵 连 乘 A i A i + 1 . . . A j 为 A [ i : j ] , 其 中 A i 为 P i − 1 × P i 的 矩 阵 , 则 计 算 A [ i : j ] 所 需 的 最 小 乘 法 次 数 为 m ( i , j ) 为 m ( i , j ) = { 0 i = j min ⁡ i ≤ k < j { m ( i , k ) + m ( k + 1 , j ) + p i − 1 p k p j } i < j 记矩阵连乘 A_iA_{i+1}...A_j 为 A[i:j],其中 A_i 为 P_{i-1} \times P_i 的矩阵, 则计算A[i:j]所需的最小乘法次数为m(i,j) 为 \\ m(i, j) = \begin{cases} 0 & \text{i = j} \\ \min_{i \leq k \lt j}\{ m(i, k) + m(k+1, j) + p_{i-1}p_kp_j \} & \text{i < j} \end{cases} AiAi+1...AjA[i:j]AiPi1×PiA[i:j]m(ij)m(i,j)={ 0minik<j{ m(i,k)+m(k+1,j)+pi1pkpj}i = ji < j

若有矩阵如下表

A1 A2 A3 A4 A5 A6
30x35 35x15 15x5 5x10 10x20 20x25

则 m(1, 2) = min{ m(1, 1) + m(2, 2) + p0p1p2 } = 0 + 0 + 30x35x15 = 15750

m(1, 3) = min{ m(1, 1) + m(2, 3) + p0p1p3 } = 0 + 2625 + 30x35x5 = 7875 或 min{ m(1, 2) + m(3, 3) + p0p2p3 } = 15750 + 0 + 30x15x5 = 18000。若有多个,选择最小那个,代码实现需要遍历

#include <iostream>

using namespace std;

int m[7][7] = {0};
int A[7] = {30, 35, 15, 5, 10, 20, 25}; // p0~p6

// 输入矩阵个数 
void MatrixChain(int n)
{
    // 初始化对角线为 0
    for (int i = 0; i <= n; i++) {
        m[i][i] = 0;
    }
    // r 个矩阵连乘
    for (int r = 2; r <= n; r++) {
        int j = 0;
        // r 个矩阵的 r-1 个空隙中依次测试最优点,n 个矩阵中连续 r 个矩阵有 n-r+1 种情况
        for (int i = 1; i <= n - r + 1; i++) {
            j = i + r - 1;
            m[i][j] = m[i][i]+m[i + 1][j] + A[i - 1] * A[i] * A[j];
            
            // 变换分隔位置,逐一测试
            for (int k = i + 1; k < j; k++) {
                int t = m[i][k] + m[k + 1][j] + A[i - 1] * A[k] * A[j];
                //如果变换后的位置更优,则替换原来的分隔方法。
                if (t < m[i][j]) {
					m[i][j] = t;
                }
            }
        }
	}
}

int main()
{
	MatrixChain(6);
	
    cout << m[1][6] << endl;
	
	return 0;
}

上述代码将其复杂度降低到 Θ(n^3),通过计算,其结果如下表

1 2 3 4 5 6
1 0 15750 7875 9375 11875 15125
2 0 2625 4375 7125 10500
3 0 750 2500 5375
4 0 1000 3500
5 0 5000
6 0

参考:动态规划之矩阵连乘问题详细解读(思路解读+填表+代码)

LCS

一个字符串的子序列是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。两个字符串的公共子序列是这两个字符串所共同拥有的子序列。LCS 是最大公共子序列
c [ i , j ] = { 0 i = 0  or  j = 0 c [ i − 1 , j − 1 ] + 1 i , j > 0   a n d   x i = y j m a x ( c [ i − 1 , j ] , c [ i , j − 1 ] ) i , j > 0   a n d   x i ≠ y j c[i, j] = \begin{cases} 0 & \text{i = 0 \ or \ j = 0} \\ c[i-1, j-1] + 1 & i, j > 0 \ and \ x_i = y_j \\ max(c[i-1, j], c[i, j-1]) & i, j >0 \ and \ x_i \ne y_j \end{cases} c[i,j]=0c[i1,j1]+1max(c[i1,j],c[i,j1])i = 0  or  j = 0i,j>0 and xi=yji,j>0 and xi=yj
​ LCS 填表过程:先将第一行和第一列全填 0,从左往右、从上往下填表。当两个元素不匹配时,将上方和左方较大的那个值填入;若匹配,则将左上角元素加一填入。由于只需要填一个m行n列的二维数组,其中m代表第一个字符串长度,n代表第二个字符串长度,所以时间复杂度为 O(m * n)

其伪代码如下:

  1. 如果箭头是↖,则代表这个字符是 LCS 的一员,存下来后 i-- , j–

  2. 如果箭头是 ←,则代表这个字符不是 LCS 的一员,j–

  3. 如果箭头是 ↑ ,也代表这个字符不是 LCS 的一员,i–

C++ 代码如下:

#include <iostream>
#include <string>
#include <stack>

using namespace std;

void LCS(string s1, string s2)
{
    int m = s1.length()+1;  int n = s2.length()+1;
    int **c;                int **b;
    c = new int* [m];       b = new int* [m];
    
    // 创建 2 张表 
    for(int i=0; i<m; i++) {
        c[i] = new int [n];
        b[i] = new int [n];
    }
    
    // 将表的第一行和第一列填 0 
    for(int i=0; i<m; i++)
        c[i][0] = 0;
    for(int i=0; i<n; i++)
        c[0][i] = 0;
        
    for(int i=0; i<m-1; i++) {
        for(int j=0; j<n-1; j++) {
            if(s1[i] == s2[j]) {
                c[i+1][j+1] = c[i][j]+1;
                b[i+1][j+1] = 1;          //1表示箭头为  左上
            } else if(c[i][j+1] >= c[i+1][j]) {
                c[i+1][j+1] = c[i][j+1];
                b[i+1][j+1] = 2;          //2表示箭头向  上
            } else {
                c[i+1][j+1] = c[i+1][j];
                b[i+1][j+1] = 3;          //3表示箭头向  左
            }
        }
    }
    
    // 输出c数组
    for(int i=0; i<m; i++) {
        for(int j=0; j<n; j++) {
            cout<<c[i][j]<<' ';
        }
        cout<<endl;
    }
    
    stack<char> same;                   //存LCS字符
    stack<int> same1, same2;             //存LCS字符在字符串1和字符串2中对应的下标,方便显示出来
    for(int i = m-1, j = n-1; i >= 0 && j >= 0; ) {
        if(b[i][j] == 1) {
            i--;
            j--;
            same.push(s1[i]);
            same1.push(i);
            same2.push(j);
        } else if(b[i][j] == 2) {
            i--;
    	} else {
            j--;
        }
    }
    
    cout << s1 << endl;  //输出字符串1
    //输出字符串1的标记
    for(int i=0; i<m && !same1.empty(); i++) {
        if(i == same1.top()) {
            cout << 1;
            same1.pop();
        } else {
            cout<<' ';
        }
    }
    
    cout<<endl<<s2<<endl;  //输出字符串2
    //输出字符串2的标记
    for(int i=0; i<n && !same2.empty(); i++)  {
        if(i == same2.top()) {
            cout << 1;
            same2.pop();
        } else {
            cout << ' ';
        }
    }
    
    cout << endl << "最长公共子序列为:";
    while(!same.empty()) {
        cout << same.top();
        same.pop();
    }
    
    cout << endl << "长度为:" << c[m-1][n-1] << endl;
    
    for (int i = 0; i<m; i++) {
        delete [] c[i];
        delete [] b[i];
    }
    delete []c;
    delete []b;
}

int main()
{
    string s1="ABCPDSFJGODIHJOFDIUSHGD";
    string s2="OSDIHGKODGHBLKSJBHKAGHI"; 
    LCS(s1,s2);
    
    return 0;
}

参考:动态规划解最长公共子序列(LCS)(附详细填表过程)

最大子段和

C [ i ] = max ⁡ { C [ i − 1 ] + A [ i ] ,   A [ i ] } i = 2 , . . . , n C [ 1 ] = { A [ 1 ] i f   A [ 1 ] > 0 0 i f   A [ 1 ] < 0 C[i] = \max\{ C[i-1] + A[i], \ A[i]\} \qquad i = 2, ..., n \\ C[1] = \begin{cases} A[1] &if \ A[1] > 0 \\ 0 & if \ A[1] < 0 \end{cases} C[i]=max{ C[i1]+A[i], A[i]}i=2,...,nC[1]={ A[1]0if A[1]>0if A[1]<0

#include<iostream>

using namespace std;

int MaxSubsequenceSum(const int A[], int n)
{
    int tempSum = 0;
    int maxSum = 0;
    // 子问题后边界
    for (int j = 0;j < n;j++) {
        tempSum = (tempSum + A[j]) > A[j] ? (tempSum + A[j]) : A[j];
        if (tempSum > maxSum)   // 更新最大和
            maxSum = tempSum;

    }
    return maxSum;
}

int main()
{
    const int a[] = { -2, 11, -10, -4, 13, -5, -2 };
    int maxSubSum = MaxSubsequenceSum(a, 6);
    cout << "The max subsequence sum of a is: " << maxSubSum << endl;
    
    return 0;
}

参考:最大子段和问题:蛮力、递归及动态规划

贪心算法

贪心算法基本思想

  • 适用于求解最优化问题的算法往往包含一系列步骤,每一步都有一组选择
  • 贪心算法总是作出在当前看来是最好的选择
  • 贪心算法并不从整体最优上加以考虑,它所作出的选择只是在某种意义上的局部最优选择
  • 贪心算法不能对所有问题都得到整体最优解,但对许多问题它能产生整体最优解
  • 在一些情况下,即使贪心算法不能得到整体最优解,其最终结果却是最优解的很好近似
  • 与动态规划方法相比,贪心算法更简单,更直接

贪心算法基本要素

  • 贪心算法通过做一系列的选择来给出某一问题的最优解。它所作出的每一个选择是当前状态下的最好选择(局部),即贪心选择
  • 这种贪心选择并不总能产生最优解,但对一些问题,比如活动安排问题,可以给出最优解
  • 可以根据下列步骤设计贪心算法
    • 将最优化问题转化为这样的一个问题,即先做出选择,再解决剩下的一个子问题
    • 证明原问题总有一个最优解是做贪心选择得到的,从而说明贪心选择的安全
    • 说明在做出贪心选择之后,子问题的最优解与所作出的贪心选择联合起来,可以得出原问题的一个最优解
  • 许多可以用贪心算法求解的问题,具备以下两种性质
    • 贪心选择性质
      • 指所求问题的整体最优解可以通过一系列局部最优的选择,即贪心选择来达到
      • 这是贪心算法可行的第一个基本要素,也是贪心算法与动态规划算法的主要区别
      • 动态规划算法通常以自底向上的方式解各子问题
      • 贪心算法则通常以自顶向下的方式进行,以迭代的方式作出相继的贪心选择,每作一次贪心选择就将所求问题简化为规模更小的子问题
    • 最优子结构性质
      • 当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质
      • 问题的最优子结构性质是该问题可用动态规划算法或贪心算法求解的关键特征

贪心算法实例

活动安排问题

  • 将活动按结束时间的增序 f1 ≤ f2 ≤ … ≤ fn 排列
  • 开始选择活动 1,然后依次检查后面的活动是否与前面已选活动相容
  • 贪心体现在总是选择具有最早完成时间的相容活动
  • 以下算法(已排序)时间复杂度为 Θ(n),若需要排序,则排序需 O(nlgn)
n <- length[s]
A <- {activity 1}
j <- 1
for i <- 2 to n
	do if si >= fj
	then A <- A ∪ {activity i}
		j <- i
return A

补充:区间贪心算法-——活动安排问题

单源最短路径(Dijkstra算法)

  • 设置顶点集合 S,初始时,S 中仅含有源,此后不断作贪心选择来扩充这个集合
  • 一个顶点属于集合 S 当且仅当从源到该顶点的最短路径长度已知
  • 设 u 是 G 的某一个顶点,把从源到 u 且中间只经过 S 中顶点的路称为从源到 u 的特殊路径,并用数组 dist 记录当前每个顶点所对应的最短特殊路径长度
  • Dijkstra 算法每次从 V-S 中取出具有最短特殊路径长度的顶点 u,将 u 添加到 S 中,同时对数组 dist 作必要的修改,检查 dist(u) + [u, j] 与 dist[j] 的大小,若 dist(u) + [u, j] 较小,则更新
  • 一旦 S 包含了所有 V 中顶点,dist 就记录了从源到所有其他顶点之间的最短路径长度
  • 其贪心策略体现在对 V-S 中的点的选择上
  • 对于具有 n 个顶点和 e 条边的带权有向图,如果用带权邻接矩阵表示这个图,则其时间复杂度为 O(n^2)


补充:单源最短路径(Dijkstra)——贪心算法

最小生成树

基本概念

  • 设 G = (V, E) 是无向连通带权图,即一个网络,E 中每条边 (v, w) 的权为 c[v] [w]
  • 如果 G 的子图 G’ 是一棵包含 G 的所有顶点的树,则称 G’ 为 G 的生成树。生成树上各边权的总和为该生成树的耗费
  • 在 G 的所有生成树中,耗费最小的生成树称为 G 的最小生成树
  • 设 G = (V, E) 是连通带权图,U 是 V 的真子集。如果 (u, v) ∈ E,且 u ∈ U,v ∈ V-U,且在所有这样的边中,(u, v) 的权 c[u] [v] 最小,那么一定存在 G 的一棵最小生成树,它以 (u, v) 为其中一条边

Prim 算法

  • 设 G = (V, E) 是连通带权图,V = {1, 2, …, n}
  • Prim 算法基本思想
    • 首先置 S = {1}
    • 然后,只要 S 是 V 的真子集,就选取满足条件 i ∈ S,j ∈ V-S,且 c[i] [j] 最小的边,将顶点 j 添加到 S 中
    • 这个过程一直进行到 S = V 时为止
    • 在这个过程中选取到的所有边恰好构成 G 的一棵最小生成树
  • 为了有效地找出满足条件 i ∈ S,j ∈ V-S,且权 c[i] [j] 最小的边 (i, j),实现这个目的的较为简单的办法是设置两个数组 closest 和 lowcost
    • closest[j] 是 j 在 S 中的邻接顶点,它与 j 在 S 中的其它邻接顶点 k 相比有 c[j] [closest[j]] ≤ c[j] [k]
    • lowcost[j] 的值就是 c[j] [closest[j]]
    • 在 Prim 算法执行过程中,先找出 V-S 中使 lowcost 值最小的顶点 j,然后根据数组 closest 选取边 (j, closest[j]),最后将 j 添加到 S 中,并对 closest 和 lowcost 作必要的修改。此方法实现 Prim 算法所需时间复杂度为 O(n^2)

Kruskal 算法

  • 首先将 G 的 n 个顶点看成 n 个孤立的连通分支。将所有的边按权值从小到大排序。若选某边后不形成回路,则将其保留作为树的一条边
  • 然后从第一条边开始,依边权递增的顺序查看每一条边,并按下述方法连接 2 个不同的连通分支:当查看到第 k 条边 (v, w) 时
    • 如果端点 v 和端点 w 分别使当前 2 个不同的连通分支 T1 和 T2 中的顶点时,就用边 (v, w) 将 T1 和 T2 连接成一个连通分支,然后继续查看第 k+1 条边
    • 如果端点 v 和 w 在当前的同一个连通分支中,就直接再查看第 k+1 条边
  • 这个过程一直进行到只剩一个连通分支时为止

补充:最小生成树的两种方法(Kruskal算法和Prim算法)

随机算法

随机算法的基本思想

  • Randomized Algorithms(随机算法)
  • Probabilistic Algorithms(概率算法)
  • 引入了随机因素
  • 在随机算法中
    • 不要求算法对所有可能的输入均正确计算
    • 只要求出现错误的可能性小到可以忽略的程度
    • 另外也不要求对同一输入,算法每次执行时给出相同的结果

随机算法的特点

  • 有不少问题,目前只有效率很差的确定求解算法,但用随机算法去求解,可以(很快地)获得相当可信的结果
  • 优点
    • 对于某一给定的问题,随机算法所需的时间与空间复杂性,往往比当前已知的、最好的确定性算法要好
    • 到目前为止设计出来的各种随机算法,无论是从理解上还是现实上,都是极为简单的
    • 随机算法避免了去构造最坏情况的例子

随机算法的种类

  • 通常分为 Las Vegas 和 Monte Carlo 这两类,注意这两者的区别
  • Las Vegas
    • 在少数应用中,可能出现求不出解的情况
    • 但一旦找到一个解,这个解一定是正确的
    • 在求不出解时,需要再次调用算法进行计算,直到获得解为止
    • 对于此类算法,主要是分析算法的时间复杂度的期望值,以及调用一次产生失败(求不出解)的概率
  • Monte Carlo
    • 通常不能保证计算出来的结果总是正确的,一般只能判定所给解的正确性不小于 p(1/2 < p < 1)
    • 通过算法的反复执行(即以增大算法的执行时间为代价),能够使发生错误的概率小到可以忽略的程度
    • 由于每次执行的算法是独立的,故 k 次执行均发生错误的概率为 (1-p) ^ k
    • 对于判定问题(回答只能是 “Yes” 或 “No”)
      • 带双错的:回答 “Yes” 或 “No” 都有可能错
      • 带单错的:只有一种回答可能错
    • Las Vegas 算法可以看成是单错概率为 0 的 Monte Carlo 算法
  • 应用场景
    • 不允许发生错误的应用中,Monte Carlo 算法不可以使用
    • 小概率的出错允许的话,Monte Carlo 算法比 Las Vegas 算法要节省许多时间,是人们常常采用的方法

Sherwood 随机化方法

  • 属 Las Vegas 算法
  • 如果某个问题已经有一个平均情况下较好的确定性算法,但是该算法在最坏情况下效率不高,此时引入一个随机数发生器(通常是服从均匀分布,根据问题需要也可以产生其他的分布),可将一个确定性算法改成一个随机算法,使得对于任何输入实例,该算法在概率意义下都有很好的性能
  • 如果算法(所给的确定性算法)无法直接使用 Sherwood 方法,则可采用随机预处理的方法,使得输入对象服从均匀分布(或其他分布),然后再用确定性算法对其进行处理。所得效果在概率意义下与 Sherwood 型算法相同
  • Sherwood 算法总能求得问题的一个解,且所求得的解是正确的
  • 当一个确定性算法在最坏情况和平均情况下的时间复杂度有较大差别时,可在确定性算法中引入随机性将其改造为 Sherwood 算法,以消除或减少问题的好坏与输入实例间的差别

求解实例

快排随机化版本

  • 随机化选择主元

求第k小元素

  • 在 n 个数中随机的找一个数 A[i] = x,然后将其余 n-1 个数与 x 比较,分别放入三个数组中:S1(元素均 < x)、S2(元素均 = x)、S3(元素均 > x)
  • 若 |S1| ≥ k,则调用 Select(k, S1)
  • 若 (|S1| + |S2|) ≥ k,则第 k 小元素就是 x
  • 否则就有 (|S1| + |S2|) < k,此时调用 Select(k-|S1|-|S2|, S3)
  • 定理:若以等概率方法在 n 个数中随机取数,则该算法用到的比较数的期望值不超过 4n
  • 说明:如果假定 n 个数互不相同,如果有相同的数的话,落在 S2 中的可能性会更大,比较数的期望值会更小一些

Testing String Equality

  • 设 A 处有一个长字符串 x,B处也有一个长字符串 y,A 将 x 发给 B,由 B 判断是否有 x = y
  • 算法
    • 首先由 A 发一个 x 的长度给 B,若长度不等,则 x ≠ y
    • 若长度相等,则采用 “取指纹” 的方法
      • A 对 x 进行处理,取出 x 的 “指纹”,然后将 x 的 “指纹” 发给 B
      • 由 B 检查 x 的 “指纹” 是否等于 y 的 “指纹”
      • 取 k 次 “指纹”(每次取法不同),每次两者结果均相同,则认为 x 与 y 是相等的
      • 随着 k 的增大,误判率可趋于 0
  • 常用指纹
    • 令 I(x) 是 x 的编码,取 Ip(x) = I(x) (mod p) 作为 x 的指纹
    • 这里 p 是一个小于 M 的素数,M 可根据具体需要调整
  • 误判率
    • Pr[failure] = (使得 Ip(x) = Ip(y) 但 x ≠ y 的素数 p(p < M) 的个数) / (小于 M 的素数的总个数)
    • 误匹配的概率小于 1/n当 n 很大时,误匹配的概率很小
    • 设 x ≠ y,如果取 k 个不同的小于 2n^2 的素数来求 Ip(x) 和 Ip(y)
    • 即 k 次试验均有 Ip(x) = Ip(y) 但 x ≠ y 的概率小于 1/(n^k)
    • 当 n 较大、且重复了 k 次试验时,误匹配的概率趋于 0

pattern matching

  • 问题:给定两个字符串 X=x_1,…,x_n;Y=y_1,…,y_m。判断 Y 是否为 X 的子串?
  • Monte Carlo 算法
    • 记 X(j)=x_j … x_{j+m-1}
    • 从起始位置 j=1 开始到 j=n-m+1,不去逐一比较 X(j) 与 Y,而仅逐一比较 X(j) 的指纹 Ip(X(j)) 与 Y 的指纹 Ip(Y)
    • 若 Ip(X(j)) = Ip(Y) 则返回 j;否则 j += 1,再进行指纹比较;循环结束仍未找到则返回 0
    • 由于 Ip(X(j+1)) 可以很方便地根据 Ip(X(j)) 计算出来,故算法可以很快完成
    • 时间复杂度
      • 计算 Ip(Y)、Ip(X(1)) 和 2^m mod p 的时间不超过 O(m) 次运算
      • Ip(X(j+1)) 的计算,只需用 O(1) 时间
      • 由于循环最多执行 n-m+1 次,故这部分的时间复杂度为 O(n),于是,总的时间复杂性为 O(m+n)
    • 失败的概率只与 X 的长度有关,与 Y 的长度无关
  • Las Vegas 算法
    • 在 Monte Carlo 算法的基础下,若 Ip(Y) = Ip(X(j)) 时,不直接返回 j,而去比较 Y 和 X(j)
    • 若相等,则返回 j;否则,继续循环
    • 该算法出错的概率为 0

主元素问题

  • 数组中某一元素的个数超过该数组的一半则为主元素
  • 选一个不超过数组个数的随机数 n,然后以 n+1 作为元素下标选择元素,判断该元素是否为主元素
  • 多次调用上述方法,若找到主元素则返回 True
  • 计算时间和调用的次数相关
// 判定主元素的 Monte Carlo 算法
public static boolean majority(int[] t, int n) {
    
    
    rnd = new Random();
    int i = rnd.random(n) + 1;
    int x = t[i];	// 随机选择数组元素
    int k = 0;
    for (int j = 1; j <= n; j++)
        	if (t[j] == x)
                k++;
    return (k > n/2);
}
public static boolean majorityMC(int[] t, int n, double e) {
    
    
    int k = (int)Math.ceil(Math.log(1/e) / Math.log(2));
    for (int i = 1; i <= k; i++)
        if (majority(t, n))
            return true;
    return false;
}

补充:主元素问题解法–蒙特

回溯法与分枝限界法

生成问题状态

  • 扩展结点:一个正在产生儿子的结点称为扩展结点
  • 活结点:一个自身已生成但其儿子还没有全部生成的结点称为活结点
  • 死结点:一个所有儿子已经产生的结点称为死结点

回溯法

  • 有许多问题,当需要找出它的解集或者要求在某些约束条件下的最优解时,往往可以用回溯法
  • 回溯法的基本做法是搜索,它是一种可以避免不必要搜索的穷举式搜索
  • 回溯法适用于求解一些组合数较大的问题
  • 回溯法基本思想
    • 针对所给问题,定义问题的解空间
    • 确定易于搜索的解空间结构
    • 回溯法在问题的解空间树中,按深度优先策略,从根结点出发搜索解空间树
    • 算法搜索至解空间树的任一点时,先判断该结点是否包含问题的解
      • 如果肯定不包含,则跳过对该结点为根的子树的搜索,逐层向其祖先结点回溯
      • 否则,进入该子树,继续按深度优先策略搜索
    • 常用剪枝函数
      • 约束函数在扩展结点处剪去不满足约束的子树
      • 限界函数剪去得不到最优解的子树

分枝限界限界法

  • 分枝限界法类似于回溯法,是在问题的解空间树上搜索问题解的算法
  • 不同点
    • 求解目标:回溯法的求解目标是找出解空间树中满足约束条件的所有解,而分枝限界法的求解目标则是找出满足约束条件的一个解,或是在满足约束条件的解中找出在某种意义下的最优解
    • 搜索方式的不同:回溯法以深度优先的方式搜索解空间树,而分枝限界法则以广度优先或以最小耗费优先的方式搜索解空间树
  • 基本思想
    • 在分枝限界法中,每一个活结点只有一次机会成为扩展结点。活结点一旦成为扩展结点,就一次性产生其所有儿子结点。在这些儿子结点中,导致不可行解或导致非最优解的儿子结点被舍弃,其余儿子结点被加入活结点表中
    • 此后,从活结点表中取下一结点成为当前扩展结点,并重复上述结点扩展过程。这个过程一直持续到找到所需得到解或活结点表为空时为止
  • 常见分枝限界法
    • 队列式(FIFO)分枝限界法
      • 按照队列先进先出(FIFO)原则选取下一个节点为扩展结点
    • 优先队列式分枝限界法
      • 按照优先队列中规定的优先级选取优先级最高的结点成为当前扩展结点
      • 应用优先队列式分枝限界法求解具体问题时,应该根据具体问题的特点确定选用最大优先队列或者最小优先队列表示解空间的活结点表

回溯法求解时常见的两类解空间树

问题的解空间一般用解空间树的方式组织

  • 树的根结点位于第一层,表示搜索的初始状态
  • 第二层的结点表示对解向量的第一个分量做出选择后到达的状态
  • 第一层到第二层的边上标出对第一个分量选择的结果
  • 以此类推,从树的根结点到叶子结点的路径就构成了解空间的一个可能解

子集树

  • 当所给问题是从 n 个元素的集合 S 中找出 S 满足某种性质的子集时,相应的解空间树称为子集树
  • 子集树通常 |S1| = … = |Sn| = C,各结点有相同数目子树,C=2 时,子集树中共有 2^n 个叶子结点,因此需要 O(2^n) 时间

排列树

  • 当所给问题是确定 n 个元素满足某种性质的排列时,相应的解空间树称为排列树
  • 排列树通常 |S1| = n,…,|Sn| = 1,所以排列树中共有 n! 个叶子结点,需时间 O(n!)

求解实例

0-1背包

  • 问题描述

    • 给定 n 种物品和一个背包。物品i的重量是 w_i,其价值为 v_i,背包的容量为 c。应如何选择装入背包的物品,使得装入背包中物品的总价值最大?
    • 在选择装入背包的物品时,对每种物品 i 只有 2 种选择,即装入背包或不装入背包。不能将物品 i 装入背包多次,也不能只装入部分的物品 i
  • 算法思想——优先队列式分枝限界法

    • 首先,要对输入数据进行预处理,将各物品依其单位重量价值从大到小进行排列;结点的优先级为已装袋的物品价值加上剩下的最大单位重量价值的物品装满剩余容量的价值和(这里计算可以是一部分,但要装满背包)
    • 在实现时,由 Bound 计算当前结点处的上界。在解空间树的当前扩展结点处,仅当要进入右子树时才计算右子树的上界 Bound,以判断是否将右子树剪。进入左子树时不需要计算上界,因为其上界与其父节点上界相同
  • 步骤

    1. 算法首先根据基于可行结点相应的子树最大价值上界优先级,从堆中选择一个节点(根节点)作为当前可扩展结点
    2. 当扩展到叶节点时,算法结束,叶子节点对应的解即为问题的最优值
    3. 检查当前扩展结点的左儿子结点的可行性
    4. 如果左儿子结点是可行结点,则将它加入到子集树和活结点优先队列中
    5. 当前扩展结点的右儿子结点一定是可行结点,仅当右儿子结点满足上界函数约束时,才将它加入子集树和活结点优先队列
  • 样例——背包容量 W = 10

    物品 重量(w) 价值(v) 价值/重量(v/w)
    1 4 40 10
    2 7 42 6
    3 5 25 5
    4 3 12 4
    • 上界计算—— 先装入物品 1,剩余的背包容量为 6,只能装入物品 2 的 6/7 (即 42 * (6/7) = 36)。 即上界为 40+6*6=76\

  • 上界函数 bound
// n 表示物品总数,cleft 为剩余空间
while (i <= n && w[i] <= cleft) {
    cleft -= w[i];            // w[i] 表示 i 所占空间
    b += p[i];                // p[i] 表示 i 的价值
    i++;
}
if (i <= n)
    b += p[i]/w[i] * cleft;   // 使用剩下的物品装满背包
return b;                     // b 为上界值
  • 0-1背包问题优先队列分支限界搜索算法
while (i != n+1) {    // 非叶结点
    // 检查当前扩展结点的左儿子结点
    Typew wt = cw + w[i];
    if (wt <= c) {    // 左儿子结点为可行结点
        if (cp + p[i] > bestp)
            bestp = cp + p[i];
        AddLiveNode(up, cp+p[i], cw+w[i], true, i+1);
    }
    up = Bound(i+1);
    // 检查当前扩展结点的右儿子结点
    if (up >= bestp)
        AddLiveNode(up, cp, cw, false, i+1);
    // 取下一个扩展结点(略)
}

补充:0-1背包问题-分支限界法(优先队列分支限界法)

TSP

  • 问题描述
    • 某售货员要到若干城市去推销商品,已知各城市之间的路程(或旅费)。他要选定一条从驻地出发,经过每个城市一次,最后回到驻地的路线,使总的路程(或总旅费)最短(或最小)

补充:TSP问题-多种算法求解

NP 完全性

P 、NP、NP-C 和 NP Hard之间的关系

  • P 问题是多项式时间内可解的问题
  • NP 问题是多项式时间内可验证一个解的问题
    • 所有 NP 问题都是判定问题,回答 Yes 或 No
  • 问题 A 是一个 NPC 问题,则有
    • A 属于 NP 问题
    • 对任意属于 NP 问题的 B,都可在多项式时间内规约到问题 A
    • 若某个 NPC 问题能在多项式时间内被解,则所有 NP 问题均可在多项式时间内被解,从而 NP = P
  • 问题 A 是一个 NP-hard 问题,则有
    • 问题 A 不一定是一个 NP 问题
    • 所有 NPC 问题都可以在多项式时间内转化为 A
    • NPC 问题一定是 NP-hard 问题

NPC 问题实例(33:28)

  • K-团问题
    • 给定一个无向图 G=(V, E) 和一个正整数 k,判定图 G 是否包含一个 k 团
    • 一个图 G 的 k 团是 G 的 k 个顶点的集合,使得这个集合中每对顶点之间都有边
  • 子集和问题
    • 有一个数集 A={a1, a2, … , an} 及一个目标数 S,问 A 中是否能找出一个子集 A’,使得 A’ 中元素和为 S
  • 顶点覆盖
    • 顶点覆盖的最优化问题:在一个无向图 G 中,找一个顶点数最少的顶点集,满足:任一条边的两个顶点中至少有一个在此集合中
    • 顶点覆盖的判定问题:无向图 G 中是否存在顶点数为 k 的顶点覆盖
    • 即:无向图 G 中是否存在 k 个顶点的子集,使得图 G 中的任一条边的两个顶点中至少有一个在此集合中

NP-hard 问题解法

  • 当 n 不太大时,可使用动态规划、分枝限界法和回溯法
  • 求近似解。在误差允许的范围内找一个解,该近似解可以在多项式时间里得到
  • 启发式算法求解,根据具体问题设计启发式搜索策略,在理论上往往缺乏严格的证明,用实验数据说明算法很有效
  • 智能优化算法,常常能获得很好的结果。但有偶然性,与最优解的误差难以给出

智能优化算法

  • 遗传算法(Genetic Algorithm, GA)起源于对生物系统所进行的计算机模拟研究。它是模仿自然界生物进化机制发展起来的随机全局搜索和优化方法,借鉴了达尔文的进化论和孟德尔的遗传学说。其本质是一种高效、并行、全局搜索的方法,能在搜索过程中自动获取和积累有关搜索空间的知识,并自适应地控制搜索过程以求得最佳解
  • 模拟退火算法(Simulated Annealing, SA)的思想借鉴于固体的退火原理,当固体的温度很高的时候,内能比较大,固体的内部粒子处于快速无序运动,当温度慢慢降低的过程中,固体的内能减小,粒子的慢慢趋于有序,最终,当固体处于常温时,内能达到最小,此时,粒子最为稳定。模拟退火算法便是基于这样的原理设计而成

补充:现代优化算法三部曲 | 入门介绍

近似算法

  • 迄今为止,所有的 NPC 问题均未能找到多项式时间的算法,故当问题规模较大时,求得最优的精确解的可能性很小
  • 在此情况下,往往退而去求比最优精确解稍差一点的解作为问题的近似答案

近似算法的分类

  • 常数近似比的近似算法
  • 多项式时间近似方案(PTAS)
  • 完全多项式时间近似方案(FPTAS)

近似算法的性能

  • 近似算法一般都比较简单,但设计近似算法时必须关注所设计的算法所得到的近似解与最优解之间的差距到底有多大
  • 若一个最优化问题的最优值为 c *,求解该问题的一个近似算法求得的近似最优解相应的目标函数值为 c,则将该近似算法的近似比定义为 max{c * / c, c / c *} => 近似比不会小于 1
  • 在通常情况下,近似比是问题输入规模 n 的一个函数 ρ(n),即 max{c * / c, c / c *} ≤ ρ(n)

实例

装箱问题

  • FFD 算法——先将所有物品从大到小排序,然后再使用 FF 法
  • FFD 分析
    • 对一切装箱实例 I,有 FFD(I) ≤ 取上界[ 4/3 OPT(I) ],当 OPT(I) = 3k+1 时,有 FFD(I) ≤ 取下界[ 4/3 OPT(I) ]
    • 对所有装箱问题的实例 I,有 FFD(I) ≤ 11/9 OPT(I) + 1

顶点覆盖

  • 下面的近似算法以无向图G为输入,并计算出G的近似最优顶点覆盖,可以保证计算出的近似最优顶点覆盖大小不会超过最小顶点覆盖大小的2倍
VertexSet approxVertexCover ( Graph g ) {
    cset = 空集;
    e1=g.e;

    while (e1 != 空集) {
        从e1中任取一条边(u,v);
        cset=cset∪{u,v};
        从e1中删去与u和v相关联的所有边;
    }
    return cset;
}

补充顶点覆盖问题的近似算法

TSP

  • TSP 的特殊性质

    • 代价函数 c 往往具有三角不等式性质,即对任意的 3 个顶点 u,v,w ∈ V,有:c(u, w) ≤ c(u, v) + c(v, w)
    • 当图 G 中的顶点就是平面上的点,任意两个顶点间的代价就是这两点间的欧氏距离时,代价函数 c 就具有三角不等式性质
  • 满足三角不等式性质的旅行商问题,对于给定的无向图 G,可以利用找图 G 的最小生成树的算法设计找近似最优的旅行售货员回路的算法

void approxTSP (Graph g) {
    (1)选择 g 的任一顶点 r;
    (2)用 Prim 算法找出带权图 g 的一棵以 r 为根的最小生成树 T;
    (3)前序遍历树 T 得到的顶点表 L;
    (4)将 r 加到表 L 的末尾,按表 L 中顶点次序组成回路 H,作为计算结果返回;
}
  • 当代价函数满足三角不等式时,算法找出的路径的代价不会超过最优路径的代价的 2 倍
  • 在代价函数不一定满足三角不等式的一般情况下,不存在具有常数近似比的解 TSP 问题的多项式时间近似算法,除非 P=NP

猜你喜欢

转载自blog.csdn.net/steven_ysh/article/details/121952022