zoukankan      html  css  js  c++  java
  • 算法导论复习

    算法概述

    算法的概念

    一系列将问题的输入转换为输出的计算或操作步骤。

    算法设计思想:尽量选择复杂度低的算法,选择合适的数据结构,考虑时间空间权衡和实现成本的权衡。

    算法的性质

    输入,有外部提供的量作为算法的输入。

    输出,算法产生至少一个量作为输出

    确定性,组成算法的每条指令是清晰、无歧义的

    有限性,算法中每条指令的执行次数是有限的,执行每条指令的时间也是有限的。

    算法描述语言

    自然语言、流程图、程序设计语言、伪代码。

    算法复杂性分析

    主要考虑时间复杂性: \(T=T(N,I)=\sum_{i=1}^{k}t_{i}e_{i}(N,I)\)

    \(k\) 是元运算的种类,\(t_i\)是元运算时间,\(e_i(N,I)\)是元运算次数,与规模 \(N\) 和输入 \(I\) 有关。

    渐进性态的阶

    大O表示法(算法运行时间的上界)

    若存在正常数 \(C\) 和自然数 \(N_0\) 使得当 \(N \ge N_0\)时,有\(f(N) \le Cg(N)\),则称函数 \(f(N)\)\(N\)充分大时有上界,且 \(g(N)\) 是它的一个上界,记为 \(f(N)=O(g(N))\),也称 \(f(N)\) 的阶不高于 \(g(N)\) 的阶。上界的阶越低则评估越精确。

    大Ω表示法(算法运行时间的下界)

    若存在正常数 \(C\) 和自然数 \(N_0\) 使得当 \(N \ge N_0\)时,有\(f(N) \ge Cg(N)\),则称函数 \(f(N)\)\(N\)充分大时有下界,且 \(g(N)\) 是它的一个下界,记为 \(f(N)=\Omega(g(N))\),也称 \(f(N)\) 的阶不低于 \(g(N)\) 的阶。下界的阶越高评估越精确。

    θ表示法(算法运行时间接近的界)

    \(f(N)=\theta(g(N))\) 当且仅当\(f(N)=O(g(N))\)\(f(N)=\Omega (g(N))\) ,称函数\(f(N)和g(N)\) 同阶。

    常见的阶:

    \(O(1)<O(log\ n)<O(N)<O(N^c)<O(c^N)<O(N!)<O(n^n)\)

    常数级<对数级<线性级<多项式级<指数级<阶乘级<\(n^n\)

    NP完全性理论

    在多项式时间内能求出结果的为P(Polynomial)问题,在多项式时间内能验证猜测解的正确性,为NP(Nondeterministic Polynomial)问题。NP问题不要求给出一个算法来求解问题本身,而只要求给出一个确定性算法在多项式时间内验证它的解。

    递归与分治

    递归设计的思想

    递归函数是用函数自身定义的函数,它的两个要素是边界条件与递归方程。递归算法是自身调用自身的算法。下面是一个例子。

    \[F(n)= \begin{cases} 1 &n=0\\ 1 &n=1\\ F(n-1)+F(n-2) &n>1\\ \end{cases} \]

    int fibonacci(int n)
       {
           if (n <= 1) return 1;
           return fibonacci(n-1)+fibonacci(n-2);
       }
    

    递归函数的优点:
    1)算法简明;
    2)正确性易证明,是分析、设计的有力工具。

    递归函数的缺点:
    执行效率不高;
    堆栈空间耗费

    汉诺塔问题

    问题描述:

    设a,b,c是3个塔座。在塔座a上有一叠共n个圆盘,这些圆盘自下而上,由大到小地叠在一起。各圆盘从小到大编号为1,2,…,n,现要求将塔座a上的这一叠圆盘移到塔座b上,并仍按同样顺序叠置。在移动圆盘时应遵守以下移动规则:规则1:每次只能移动1个圆盘;规则2:任何时刻都不允许将较大的圆盘压在较小的圆盘之上;规则3:在满足移动规则1和2的前提下,可将圆盘移至a,b,c中任一塔座上。

    算法思想1:

    这个问题有一个简单的解法。假设塔座a、b、c排成一个三角形,a→b→c→a构成一顺时针循环。在移动圆盘的过程中,若是奇数次移动,则将最小的圆盘移到顺时针方向的下一塔座上;若是偶数次移动,则保持最小的圆盘不动,而在其他两个塔座之间,将较小的圆盘移到另一塔座上去。

    递归算法思想

    下面用递归技术来解决这个问题。当n=1时,问题比较简单。此时,只要将编号为1的圆盘从塔座a直接移至塔座b上即可。当n>1时,需要利用塔座c作为辅助塔座。此时要设法将n-1个较小的圆盘依照移动规则从塔座a移至塔座c上,然后将剩下的最大圆盘从塔座a移至塔座b上,最后设法将n-1个较小的圆盘依照移动规则从塔座c移至塔座b上。由此可见,n个圆盘的移动问题就可分解为两次n-1个圆盘的移动问题,这又可以递归地用上述方法来做。

    算法复杂性:

    \[\begin{align*} T(n)&= \begin{cases} 2T(n-1)+1 &n>2 \\ 1 &n=1 \end{cases} \\ T(n)&=2T(n-1)+1=2(2T(n-2))\\ &=2^2T(n-2)+2+1\\ &=2^3T(n-3)+2^2+2+1\\ &=2^{n-1}T(1)+2^{n-2}+...+2+1\\ &={ 2^{n-1}-1*\frac12 \over 1-\frac12 }\\ &=2^n-1\\ &故该算法的时间复杂度为O(2^n) \end{align*} \]

    代码:

    void hanoi(int n, int a, int b, int c){
      if(n>0){
        hanoi(n-1,a,c,b);
        move(a,b);
      hanoi(n-1,c,b,a);
      }
    }
    

    其中,hanoi(n, a,b, c)表示将塔座a上自下而上,由大到小叠放在一起的n个圆盘依移动规则移至塔座b上并仍按同样顺序叠放。在移动过程中,以塔座c作为辅助塔座。move(a,b)表示将塔座a上编号为n的圆盘移至塔座b上。

    分治法的设计思想

    将规模为n的问题分解为k个规模较小的子问题,使这些子问题相互独立且与原问题相同,递归地解这些子问题,然后各个子问题的解合并得到原问题的解。

    分治法所能解决的问题一般具有以下特征:

    该问题可以分解为若干个规模较小的相同问题;
    该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题;
    该问题的规模缩小到一定的程度就可以容易地解决;
    利用该问题分解出的子问题的解可以合并为该问题的解。

    分治法的求解过程

    (1)分解:把原问题分解为若干个规模较小、相互独立,
    与原问题相同的子问题;

    (2)求解:若子问题规模较小且容易被解决则直接解,
    否则再继续分解为更小的子问题,直到容易解决;

    (3)合并:将已求解的各个子问题的解,逐步合并
    为原问题的解。

    二分搜索

    问题描述:

    给定已按升序排好序的n个元素a[0:n-1],现要在这n个元素中找出一特定元素x。

    算法思想

    将n个元素分成个数大致相同的两半,取\(a_{mid}\)与x作比较。

    \(x=a_{mid}\) 则算法中止

    \(x<a_{mid}\) 在数组的左半部继续搜索

    \(x>a_{mid}\) 在数组的右半部继续搜索

    分治过程

    \[T(n)→T(\cfrac n2)→T(\cfrac n4)→\cdots→T(\cfrac n{2^k})=T(1) \]

    时间复杂性

    \[\begin{align*} T(n)&= \begin{cases} 1 &n=1\\ 1+T(\cfrac n2) &n>1\\ \end{cases}\\ T(n)&=T(\cfrac n2)+1=T(\cfrac n{2^2})+1+1 \\ &=T(\cfrac n{2^2})+2=T(\cfrac n{2^3})+1+2\\ & =T(\cfrac n{2^3})+3=T(\cfrac n{2^k})+k \\ &令n=2^k,则k=logn,则T(\cfrac n{2^k})+k=T(1)+k=1+log\ n\\ &故二分搜索算法的时间复杂度为O(logn) \end{align*} \]

    代码

    template<class Type>
    int BinarySearch(Type a[ ], const Type & x, int n){//在a[0]<=a[1]<=...<=a[n-1]中搜素x
        //找到x时返回其在数组中的位置,否则返回-1
        int left = 0;	int right = n-1;			         
            while(left <= right){ 
                int middle=(left+right)/2;                  
     	        if (x = = a[middle] 
                   return middle;
                if (x > a[middle])  
                   left = middle + 1;
                else 
                    right = middle – 1;			                     
            }
            return -1 //未找到			                       
    }
    

    合并排序

    问题描述

    合并排序算法是用分治策略实现对n个元素进行排序的算法。

    算法思想

    将待排序元素分成大小大致相同的两个子集合,分别对两个子集合进行排序,最终将排好序的子集合合并成所要求的排好序的集合。

    时间复杂性

    \[\begin{align*} T(n)&= \begin{cases} O(1) &n=1\\ 2T(\cfrac n2)+O(n) &n>1\\ \end{cases}\\ T(n)&=2T(\cfrac n2)+cn=2(2T(\cfrac n4)+c\frac n2)+cn \\ &=2^2T(\cfrac n{2^2})+2cn\\ &=\cdot\cdot\cdot \\ & =2^kT(\cfrac n{2^k})+kcn\\ &令n=2^k,则k=logn,则T(n)=2^kT(1)+cnlog\ n=O(nlogn)\\ &故合并排序算法的时间复杂度为O(nlogn) \end{align*} \]

    代码

    temlplate  <class type>
    void MergeSort(Type a[], int left, int right){   
        if (1eft < right){  //至少有2个元素
           int i = (left + right ) /2; //取中点
           MergeSort(a, 1eft, i);
           MergeSort(a, i+1, right);
           Merge(a, b, 1eft, i, right);//从a合并到数组b
           copy(a, b, left, right);//复制回数组a
         } 
    }    
    

    快速排序

    问题描述

    快速排序是基于分治策略的另一个排序算法。

    算法思想

    对于输入的子数组a[p:r],按以下三个步骤进行排序。

    (1) 分解:以a[p]为基准元素将a[p:r]划分成3段a[p:q-1], a[q]和a[q+1:r],使a[p:q-1]中任意一个元素小于等于a[q],而a[q +1 :r]中任何一个元素大于等于a[q],下标q在划分过程中确定;

    (2) 递归求解:通过递归调用快速排序算法分别对a[p:q-1]和a[q+1:r]进行排序;

    (3)合并:对于a[p:q-1]和a[q+1:r]的排序是就地进行的,因此在a[p:q-1]和a[q+1:r]都已排好序后,不需要执行任何计算,a[p:r]则已排好序。

    时间复杂度

    快速排序的性能取决于划分的对称性,最坏情况是:每次划分后2个区域的元素为n-1,1

    \[T_{max}(n)= \begin{cases} O(1) &n=1\\ T(n-1)+O(n) &n>1\\ \end{cases}\\ \\ T_{max}(n)=O(n^2) \]

    最好情况是:每次划分后都产生大小为n/2的两个区域。

    \[T_{min}(n)= \begin{cases} O(1) &n=1\\ 2T(\cfrac n2)+O(1) &n>1\\ \end{cases}\\ \\ T_{min}(n)=O(nlog\ n) \]

    平均情况下时间复杂性也是\(O(nlog\ n)\)

    代码

    template <class Type>
    void QuickSoft(Type a[], int p, int r){  
      if(p<r){
        int q=Partition(a, p, r);
        QuickSort(a, p, q-1); //对左半段排序
        QuickSoft(a, q+1, r); //对右半段排序
      }
    }
    

    对含有n个元素的数组a[0:n-1]进行快速排序只要调用QuickSort(a, 0, n-1)即可。上述算法中的函数Partition()以一个确定的基准元素a[p]对子数组 a[p:r]进行划分,它是快速排序算法的关键。

    template<class Type>
    int Partion(Type a[ ],int p , int r ){  
      int i=p;  j=r+1;
      Type x=a[p];
      //将小于x的元素交换到左边区域,将大于x的元素交换到右边区域
      while(true) { 
        while(a[++i] < x&&i<r);
        while(a[--j] > x);
        if (i>=j ) 
          break;
        swap(a[i],a[j]); 
       }
       a[p] = a[j];
       a[j] = x;
       return j;
    }
    

    线性时间选择

    问题描述

    给定线性序集中n个元素和一个整数\(k (1\le k \le n)\),要求找出这n个元素中第k小的元素。

    算法思想

    找到一个支点,对输入数组进行划分为a和b两段,然后计算a部分的元素个数j:

    \(k\le j\) 第k小元素为a的第k小元

    \(k>j\) 第k小元素为b的第(k-j)小元

    基准点的选取是影响性能的关键。
    基准点选取的算法思路(中间的中间):

    (1) 将n个输入元素划分成\(\lceil\cfrac n5\rceil\)个组,每组5个元素,除可能有一个组不是5个元素外。用任意一种排序算法,将每组中的元素排好序,并取出每组的中位数,共\(\lceil\cfrac n5\rceil\)个。
    (2) 递归调用Select找出这\(\lceil\cfrac n5\rceil\)个元素的中位数(中位数元素的中位数)。如果\(\lceil\cfrac n5\rceil\)是偶数,就找它的两个中位数中较大的一个。然后以这个元素作为划分基准。

    时间复杂性

    \[T(n)\le \begin{cases} C_1 &n<75\\ C_2n+T(\cfrac n5)+T(\cfrac {3n}4) &n\ge75 \end{cases}\\ \\ 分析过程不再给出\\ 解此递归式可得T(n)=O(n) \]

    代码

    template<class Type>
    Type Select(Type a[],int p, int r, int k) {
      if (r-p < 75){
      用某个简单排序算法对数组a[p:r]排序;
      return a[p+k-1];
      }
      for (int i=0; i <= (r-p-4)/5; i++) {
        //将a[p+5*i]至a[p+5*i+4]的第3小元素与a[p+i]交换位置;
        Type x=Select(a,p,p+(r-p-4)/5,(r-p-4)/10);//找中位数的中位数,r-p-4即上面所说的n-5
        int i = Partition(a, p,r, x),j = i-p+1;
        if (k <= j)
          return select(a,p,i,k);
        else
          return Select(a,i+1,r,k-j);
      }
    }
    

    此外还有排列问题、整数划分问题、大整数乘法、棋盘覆盖、最接近点对、循环赛日程表

    动态规划

    基本概念

    多阶段决策问题:在每一个阶段都要做出决策,全部过程的决策是一个决策序列。把多阶段过程转化为一系列单阶段问题,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。

    动态规划(dynamic programming)属运筹学中的规划论分支,是求解决策过程最优化的数学方法。与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合用动态规划求解的问题,经分解得到的子问题往往不是相互独立的。

    动态规划的特点:动态规划法用于最优化问题时,这类问题会有多种可能的解,而动态规划要找出其中最优值的解。对于重复出现的子问题,只在第一次遇到时加以求解,并把答案保存起来,以后再遇到时不必重新求解。

    动态规划的基本要素

    (1) 最优子结构

    原问题的最优解包含其子问题的最优解。

    (2) 重叠子问题

    在用递归算法自顶向下解某类问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算。利用这种子问题重叠的性质,对每一个子问题只解一次,而后将其解保存在一个表格中,当再次需要解此子问题时只是简单地用常数时间查看一下结果。(迭代)

    动态规划的实质是分治思想和解决冗余

    (1) 一种将问题实例分解为更小的、相似的子问题。

    (2) 存储子问题的解而避免计算重复的子问题。

    一般来说,只要原问题可以划分成规模更小的子问题,并且原问题的最优解中包含了子问题的最优解(即满足最优化原理),则可以考虑用动态规划解决。

    动态规划算法设计步骤

    ⑴分析最优解的性质,并刻划其结构特征;

    ⑵递归地定义最优值;

    ⑶以自底向上的方式计算出最优值;

    ⑷根据递归计算最优值时得到的信息,从子问题的最优解逐步构造出整个问题的最优解。

    矩阵连乘

    问题描述

    给定n个矩阵{\(A_1,A_2,..A_n\)}其中\(A_i\)\(A_{i+1}\) 是可乘的(i=1,2,...,n-1),考察这n个矩阵的连乘积\(A_1 A_2...A_n\) 。求出矩阵连乘积的最优计算次序。

    问题分析

    由于矩阵乘法满足结合律,因此计算矩阵的连乘积可以有不同的计算次序,这种计算次序可以用加括号的方式来确定。例如A矩阵连乘积\(A_1 A_2 A_3 A_4\)有5种完全加括号方式:\((A_1 (A_2 (A_3 A_4)))\)\((A_1 ((A_2 A_3) A_4))\)\(((A_1 A_2) (A_3 A_4))\)\(((A_1 (A_2 A_3)) A_4)\)\((((A_1 A_2) A_3) A_4)\) 。每种完全加括号方式对应一种矩阵连乘积的计算次序,而矩阵连乘积的计算次序与其计算量有密切关系。

    算法设计

    第一步是刻画该问题的最优解的结构特征。将矩阵连乘积\(A_i A_{i+1}...A_j\) 简记为\(A[i:j]\) ,将计算\(A[i:j]\) 所需的最少数乘次数记为\(m[i][j]\) 。设\(A[1:n]=A[1:k] A[k+1:n]\) 为最优计算次序,即计算次序在\(A_k\)\(A_{k+1}\) 之间断开\(((A_1...A_k) (A_{k+1} A_n))\),总计算量分成了三部分的和。子矩阵链\(A[1:k]\)\(A[k+1:n]\) 的计算次序也应该是最优的,可用反证法证明。

    第二步是递归地定义最优值。

    \[m[i][j]= \begin{cases} 0 &i=j&单一矩阵\\ min_{i\le k<j }\{m[i][k]+m[k+1][j]+p_{i-1}p_kp_j \} &i<j \end{cases} \]

    k 的位置有\(j-i\) 种,从中选择一个使计算次数最少的位置。

    若将最优断开位置k记为\(s[i][j]=k\) ,则计算出最优值\(m[i][j]\) 后,可以由\(s[i][j]\) 递归地构造出最优解。

    第三步是计算最优值

    用动态规划算法解问题,可依据其递归式以自底向上的方式进行计算。在计算过程中,保存已解决的子问题答案。分步思路是沿从左上至右下的对角线进行。

    \[\small \begin{align*} &第一条对角线:\\ m[i][j]&=0,i=j &\; s[i][j]=0\\ &第二条对角线:\\ m[1][2]&=m[1][1]+m[2][2]+30*35*15=15750 &\; s[1][2]=1\\ m[2][3]&=m[2][2]+m[3][3]+35*15*5=2625 &\; s[2][3]=2\\ m[3][4]&=750 &\; s[3][4]=3\\ m[4][5]&=1000 &\; s[4][5]=4\\ m[5][6]&=5000&\; s[5][6]=5\\ &第三条对角线:\\ m[1][3]&=min \begin{cases} m[1][1]+m[2][3]+p_0p_1p_3=0+2625+5250=7875\\ m[1][2]+m[3][3]+p_0p_2p_3=15750+0+2250=18000\\ \end{cases} =7875 &\; s[1][3]=1\\ m[2][4]&=min \begin{cases} m[2][2]+m[3][4]+p_1p_2p_4=0+750+5250=6000\\ m[2][3]+m[4][4]+p_1p_3p_4=2625+0+1750=4375\\ \end{cases} =4375 &\; s[2][4]=3\\ m[3][5]&=2500 &\; s[3][5]=3\\ m[4][6]&=3500 &\; s[4][6]=5\\ &第四条对角线:\\ m[1][4]&=min \begin{cases} m[1][1]+m[2][4]+p_0p_1p_4=0+4375+30*35*10=14875\\ m[1][2]+m[3][4]+p_0p_2p_4=15750+750+30*15*10=21000\\ m[1][3]+m[4][4]+p_0p_3p_4=7875+0+30*5*10=9375\\ \end{cases} =9375 &\; s[1][4]=3\\ m[2][5]&=min \begin{cases} m[2][2]+m[3][5]+p_1p_2p_5=0+2500+35*15*20=13000\\ m[2][3]+m[4][5]+p_1p_3p_5=2625+1000+35*5*20=7125\\ m[2][4]+m[5][5]+p_1p_4p_5=4375+0+35*10*20=11375\\ \end{cases} =7125 &\; s[2][5]=3\\ m[3][6]&=5375 &\; s[3][6]=3\\ &第五条对角线:\\ m[1][5]&=min \begin{cases} m[1][1]+m[2][5]+p_0p_1p_5\\ m[1][2]+m[3][5]+p_0p_2p_5\\ m[1][3]+m[4][5]+p_0p_3p_5\\ m[1][4]+m[5][5]+p_0p_4p_5\\ \end{cases} =11875 &\; s[1][5]=3\\ m[2][6]&=min \begin{cases} m[2][2]+m[3][6]+p_1p_2p_6\\ m[2][3]+m[4][6]+p_1p_3p_6\\ m[2][4]+m[5][6]+p_1p_4p_6\\ m[2][5]+m[6][6]+p_1p_5p_6\\ \end{cases} =10500 &\; s[2][6]=3\\ &最后一个顶点:\\ m[1][6]&=min \begin{cases} m[1][1]+m[2][6]+p_0p_1p_6\\ m[1][2]+m[3][6]+p_0p_2p_6\\ m[1][3]+m[4][6]+p_0p_3p_6\\ m[1][4]+m[5][6]+p_0p_4p_6\\ m[1][5]+m[6][6]+p_0p_5p_6\\ \end{cases} =15125 &\; s[1][6]=3\\ \end{align*} \]

    \[\begin{array}{c|lc} m[i][j] &1 &2 &3 &4 &5 &6 \\ \hline 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\\ \end{array} \]

    \[\begin{array}{c|lc} s[i][j] &1 &2 &3 &4 &5 &6 \\ \hline 1 &0 &1 &1 &3 &3 &3 \\ 2 &&0 &2 &3 &3 &3 \\ 3 &&&0 &3 &3 &3 \\ 4 &&&&0 &4 &5\\ 5 &&&&&0 &5\\ 6 &&&&&&0\\ \end{array} \]

    第四步是构造最优解

    先看右上顶点(1,6)发现是3,则在3后面断开;

    再观察左半部分\(A_1-A_3\) ,看(1,3)发现是1 ,则在1后面断开;

    还剩\(A_4-A_6\),则看(4,6)发现是5,则在5后面断开。

    故最优加括号方式为\((A_1(A_2A_3))((A_4A_5)A_6)\)

    代码

    void MatrixChain(int *p, int n, int **m, int **s){ 
      for (int i= 1; i <= n; i ++) 
        m[i][i] = 0;  //m[i][i]初始化
      for (int r= 2; r <= n; r++){   //r个矩阵连乘
        for (int i= 1; i<= n-r+l; i++) {
          int j= i+r-1;    //本轮循环的最后一个矩阵 
          m[i][j] = m[i][i]+m[i+1][j] + p[i-1] * p[i]* p[j];
          s[i][j] = i;      //假设最优划分位于i处;
          for(int k = i+1; k< j;k++){   //变化最优分割的位置,逐一测试
            int t = m[i][k] + m[k+1][j] + p[i-1] * p[k] * p[j]; 
            if ( t < m[i][j] ) {
              m[i][j] = t;
              s[i][j] = k;   //如果更优,替换原位置
            }
          }
        } 
      }
    }
    

    r、i、k三重循环总次数\(O(n^3)\) ,时间复杂性 \(T(n)=O(n^3)\)

    输出最优计算次序

    void Traceback(int i, int j, int**s){
      if(i == j)
      return;
      Traceback(i,s[i][j],s);
      Traceback(s[i][j]+1,j,s);
      cout<<"Multiply A "<<i<<", " <<s[i][j];
      cout<<" and A "<<(s[i][j]+1)<<", "<<j<<endl;
    }
    

    最长公共子序列

    问题描述

    给定序列\(X=\{x_1, x_2, \cdots, x_m\}\)\(Z=\{z_1, z_2, \cdots, z_k \}\) 若存在一个严格递增的下标序列$ {i_1, i_2, \cdots i_k }$ ,使得对所有\(j(j=1,2,\cdots, k)\) ,均有\(z_j={x_{i_j}}\) 则称序列\(Z\) 是序列\(X\) 的子序列。即子序列\(Z\) 是序列\(X\) 中删去若干元素后得到的序列。

    公共子序列:给定两个序列 \(X\)\(Y\) ,当另一序列 \(Z\) 既是 \(X\) 的子序列又是\(Y\) 的子序列时,称 \(Z\) 是序列X和Y的公共子序列。

    给定两个序列\(X=\{x_1, x_2, \cdots, x_m\}\)\(Y=\{y_1, y_2, \cdots, y_n \}\) 要求找出 \(X\)\(Y\) 的一个最长公共子序列。

    算法设计

    第一步,最长公共子序列的结构

    设序列\(X=\{x_1, x_2, \cdots, x_m\}\)\(Y=\{y_1, y_2, \cdots, y_n \}\) 的最长公共子序列为 \(Z=\{z_1, z_2, \cdots, z_k \}\)

    \(x_m=y_n\)\(z_k=x_m=y_n\)\(Z_{K-1}\)\(X_{m-1}\)\(Y_{n-1}\) 的最长公共子序列。

    \(x_m\ne y_n\)\(z_k\ne x_m\)\(Z\)\(X_{m-1}\)\(Y\) 的最长公共子序列。

    \(x_m\ne y_n\)\(z_k\ne y_n\)\(Z\)\(X\)\(Y_{n-1}\) 的最长公共子序列。

    第二步,子问题的递归结构

    \(x_m=y_n\),时,找出\(X_{m-1}\)\(Y_{n-1}\),的最长公共子序列,然后在其尾部加上\(x_m(x_m=y_n)\),即可得\(X\)\(Y\)的最长公共子序列。当\(x_m\ne y_n\),时,必须解两个子问题,即找出\(X_{m-1}\)\(Y\)的一个最长公共子序列及\(X\)\(Y_{n-1}\)的一个最长公共子序列。这两个公共子序列中较长者即为\(X\)\(Y\)的最长公共子序列。

    \(c[i]j]\)记录序列\(X_i\),和\(Y_j\) 的最长公共子序列的长度。由最优子结构性质建立递归关系如下:

    \[c[i][j]= \begin{cases} 0 &i=0,j=0\\ c[i-1][j-1]+1 &i,j>0,x_i=y_j \\ max\{c[i-1][j],c[i][j-1]\} &i,j>0,x_i \ne y_j \end{cases} \]

    在统计\(c[i][j]\) 的同时,用\(b[i][j]\) 记录该值由哪种方式产生:

    \[\begin{align*} c[i][j]&=c[i-1][j-1]+1 &\; b[i][j]=1\\ c[i][j]&=c[i-1][j] &\; b[i][j]=2\\ c[i][j]&=c[i][j-1] &\; b[i][j]=3 \end{align*} \]

    第三步,计算最优值

    给定两个序列为\(X=<A, B, C, B, D, A, B>\)\(Y=<B, D, C, A, B, A>\),求最长公共子序列?

    \[\begin{array}{} i=1 &X_1=\{A\} \\ \hline j=1 &Y_1=\{B\} &c[1][1]=max\{c[0][1],c[1][0] \}=0 &b[1][1]=2 \\ j=2 &Y_2=\{B,D\} &c[1][2]=max\{c[0][2],c[1][1] \}=0 &b[1][2]=2 \\ j=3 &Y_3=\{B,D,C\} &c[1][3]=max\{c[0][3],c[1][2] \}=0 &b[1][3]=2 \\ j=4 &Y_4=\{B,D,C,A\} &c[1][4]=c[0][3]+1=1 &b[1][4]=1 \\ j=5 &Y_5=\{B,D,C,A,B\} &c[1][5]=max\{c[0][5],c[1][4] \}=1 &b[1][5]=3 \\ j=6 &Y_6=\{B,D,C,A,B,A\} &c[1][6]=c[0][5]+1=1 &b[1][6]=1 \\ \end{array} \]

    \(c[1][1]=max\{c[0][1],c[1][0]\}=\{0,0\}=0\) ,此时,\(b[1][1]=2\ or\ 3\) 都可以,不影响最终的结果。

    \(i=2,3,4,5,6,7\)时用类似方法分析。

    最后得到两张表如下:

    \[\begin{array}{c|lc} c[i][j] &0 &1 &2 &3 &4 &5 &6 \\ \hline 0 &0 &0 &0 &0 &0 &0 &0\\ 1 &0 &0 &0 &0 &1 &1 &1\\ 2 &0 &1 &1 &1 &1 &2 &2\\ 3 &0 &1 &1 &2 &2 &2 &2\\ 4 &0 &1 &1 &2 &2 &3 &3\\ 5 &0 &1 &2 &2 &2 &2 &3\\ 6 &0 &1 &2 &2 &3 &3 &4\\ 7 &0 &1 &2 &2 &3 &4 &4\\ \end{array} \]

    b[i][j]表

    水平和垂直箭头指引构造解的方向,斜向箭头的尾部位置对应的元素为公共子序列元素

    \(X=<A, B, C, B, D, A, B>\) \(Y=<B, D, C, A, B, A>\)

    最长公共子序列为 \(B、C、B、A\)

    该表的使用方法是:

    在c表中从最右下角的那个元素c[7] [6]开始,看b表中对应位置b[7] [6]的值,如果为1,则在c表中从当前位置往左上角走;如果为2,则在c表中从当前位置往正上方走;如果为3,则在c表中从当前位置沿水平方向往后退一位;依次类推,直到c表中箭头退到c[0] [0]为止。

    左上方箭头对应两个元素:头部元素和尾部元素;其中每个左上方箭头的尾部元素为公共子序列的一个元素,把这些元素和在一起即为要求的公共子序列。

    水平角度看 左上方箭头对应的尾部元素编号为 2, 3, 4, 6,它们分别对应X序列中的 B C B A

    垂直角度看,左上方箭头对应的尾部元素编号为 1, 3, 5, 6,它们分别对应Y序列中的 B C B A

    代码

    构造表

    void LCS_LENGTH(int m, int n, char *X, char *Y, int **c, int **b){
      int i,j;
      for (i=1; i<=m; i++) 
        c[i][0]=0;
      for (i=1; i<=n; i++) 
        c[0][i]=0;
      for (i=1; i<=m; i++){
        for (j=1; j<=n; j++){
          if (x[i]==y[j]){
            c[i][j]=c[i-1][j-1]+1; 
            b[i][j]=1;
           }    
           else if (c[i-1][j]>=c[i][j-1]){
             c[i][j]=c[i-1][j]; 
             b[i,j]=2;
           }    
           else {
             c[i][j]=c[i][j-1]; 
             b[i,j]=3;
           }
        }
      }         //计算复杂性:O(mn)
    }
    
    

    打印公共子序列

    void LCS(int i, int j, char *X, int **b){
      if (i==0 || j==0) 
        return;
      if (b[i][j]==1){
        LCS(i-1, j-1, x, b);
        cout <<x[i]; 
      }
      else if (b[i][j]==2) 
        LCS(i-1, j, x, b);
      else 
        LCS(i, j-1, x, b);
    }   //计算复杂性:O(m+n)
    
    

    图像压缩

    问题描述

    n*n维数字化图像线性化后,在计算机中用像素点灰度值序列\(\{p_1, p_2,…, p_n\}\)表示图像。其中整数\(p_i(1≤i≤n)\),表示像素点\(i\)的灰度值。通常灰度值的范围是0~255。因此,需要用8位表示一个像素点。

    图像的变位压缩存储格式将所给的像素点序列 \(\{p_1,p_2 …,p_n\}\) 分割成 \(m\) 个连续段 \(S_1, S_2,…,S_m\) 。使每段中像素存储位数相同,每段最多含有256个像素点。创建如下三个表:

    \(L: l[i]\) 存放第\(i\)段长度(有 \(l[i]\) 个像素), 表中各项均为8位长

    \(B: b[i]\) 存放第 \(i\) 段中像素的存储位数(段中每个像素都只用 \(b[i]\) 位表示),表中各项均为3位长.

    \(P: {p_1,p_2,...p_n}\) 以变长格式存储像素点的二进制串。

    没有分段之前,固定的每8位二进制截取为一个像素点;现在分段之后,看到一串二进制,首先要知道多少位截取一个像素点,即需要知道像素点的存储位数 (3位二进制即可);在此基础上,还要知道从什么位置开始像素点的存储位数开始出现变化,即需要知道目前这种存储位数的像素点有多少个 (8位二进制即可)。

    图像压缩问题要求确定像素序列\(\{p_1,p_2,…, p_n\}\)的最优分段,使得依此分段所需的存储空间最小。也就是使 \(\sum_{i=1}^ml[i]*b[j]+11m\) 最小,下面是一个例子:

    算法设计

    最优子结构性质

    \(l[i]\)\(b[i](1≤i≤m)\)\({p_1,p_2,…, p_n}\) 的一个最优分段。显然,\(l[1]、b[1]\)\({p_1,…p_{l[1]}}\) 的一个最优分段,且 \(l[i]\)\(b[i](2≤i≤m)\)\(p_{l[1]+1},...,p_n\) 的一个最优分段,即图像压缩问题满足最优子结构性质。

    递归计算最优值

    \(s[i]\) 是像素序列 \({p_1,p_2,…, p_i}\) 的最优分段所需的存储位数。自顶向下分析,自底向上求解。解题思路如下:

    (1)考察最后一个元素\(p_i\)的分段情况

    (2)假设\(p_i\)自成一段,则s[i]=s[i-1]+保存\(p_i\)的代价;

    (3)假设最后2个像素点为一段,则s[i]=s[i-2]+保存最后2个像素点的代价……

    (4)假设最后i个像素点为一段,则s[i]=s[0]+保存最后i个像素点的代价

    (5)到底最后多少个元素为一段?取s[i]为min时对应的元素个数。假设为k.

    (6)此时,则S[i]=s[i-k]+保存最后k个像素的代价;

    (7)后者 = k*max{k个灰度值二进制位数}+11

    (8)求解s[i-k]即可

    (9)考察最后一个像素点的分段情况,自成1段?后2个点?后3个点?……重复上述过程

    \[\begin{align*} & s[i]=min_{1 \le k \le min\{{i,256}\}} \{s[i-k]+k*bmax(i-k+1,i)\}+11 \\ & i-k+1是开始像素点,i是结束位置 \end{align*} \]

    例题

    求像素序列4,6,5,7,129,138,1的最优分段。

    构造思路:

    考察l[n],看最后一段包含多少个像素点?假设k

    将最后k个像素点分割出去;

    考察l[n-k],看最后一段包含多少个像素点?

    ...

    剩余元素自成一段。

    \[\small \begin{align*} s[0]&=0\\ s[1]&=0+1×3+11=14 &\: l[1]=1 , b[1]=3\\ s[2]&=min \begin{cases} s[0]+2max\{3,3\}+11\\ s[1]+1max\{3\}+11\\ \end{cases} =min\{17,28\}=17 &\: l[2]=2 , b[2]=3\\ s[3]&=min \begin{cases} s[0]+3max\{3,3,3\}+11\\ s[1]+2max\{3,3\}+11\\ s[2]+1max\{3\}+11\\ \end{cases} =min\{20,31,31\}=20 &\: l[3]=3 , b[3]=3\\ s[4]&=min \begin{cases} s[0]+4max\{3,3,3,3\}+11\\ s[1]+3max\{3,3,3\}+11\\ s[2]+2max\{3,3\}+11\\ s[3]+1max\{3\}+11\\ \end{cases} =min\{23,34,34,34\}=23 &\: l[4]=4 , b[4]=3\\ s[5]&=min \begin{cases} s[0]+5max\{3,3,3,3,8\}+11\\ s[1]+4max\{3,3,3,8\}+11\\ s[2]+3max\{3,3,8\}+11\\ s[3]+2max\{3,8\}+11\\ s[4]+1max\{8\}+11\\ \end{cases} =min\{51,57,52,47,42\}=42 &\: l[5]=1 , b[5]=8\\ s[6]&=min \begin{cases} s[0]+6max\{3,3,3,3,8,8\}+11\\ s[1]+5max\{3,3,3,8,8\}+11\\ s[2]+4max\{3,3,8,8\}+11\\ s[3]+3max\{3,8,8\}+11\\ s[4]+2max\{8,8\}+11\\ s[5]+1max\{8\}+11\\ \end{cases} =min\{59,65,60,55,50,61\}=50 &\: l[6]=2 , b[6]=8\\ s[7]&=min \begin{cases} s[0]+7max\{3,3,3,3,8,8,1\}+11\\ s[1]+6max\{3,3,3,8,8,1\}+11\\ s[2]+5max\{3,3,8,8,1\}+11\\ s[3]+4max\{3,8,8,1\}+11\\ s[4]+3max\{8,8,1\}+11\\ s[5]+2max\{8,1\}+11\\ s[6]+1max\{1\}+11\\ \end{cases} =min\{67,73,68,63,58,69,62\}=58 &\: l[7]=3 , b[7]=8\\ \end{align*} \]

    L[i]的含义:前i个像素点最后一段包含的个数。b[i]的含义:前i个像素点最后一段位数最大值。

    L[7]=3,表明后3个元素1组;然后看L[4]=4,表明4个元素为1组;

    代码

    void Compress(int n,int p[],int s[],int l[],int b[]){
      int Lmax=256,header=11;
      s[0]=0;
      for(int i=1;i<=n;i++){
        b[i]=length(p[i]);         //length()为判断p[i]二进制位数
        int bmax=b[i];            //i为像素序列的长度,从长度为
        s[i]=s[i-1]+bmax;        //1开始,每次增加一个像素
        l[i]=1;                 //每次假设把最后一个元素分出去单独为一段是最佳方案
        for(int j=2;j<=i&&j<=Lmax;j++){
          if(bmax<b[i-j+1]) 
            bmax= b[i-j+1];        //对于给定长度i的像素序列,将
          if(s[i]>s[i-j]+j*bmax){ //最后j个像素点划分为一段最好
            s[i]=s[i-j]+j*bmax;  //找出j的位置
            l[i]=j;
          }
        }    
        s[i]+=header;
      }
    }
    
    int length(int i){
      int k=1;
      i=i/2;
      while(i>0){
        k++;
        i=i/2;
      }
      return k;
    }                   //复杂度O(n)
    

    构造最优解

    void Traceback(int n, int& i, int s[],int l[]){
      if (n == 0)
        return;
      Traceback(n-1[n],i,s,1);
      s[i++] =n-l[n];
    }
    void output(int s[], int l[],int b[],int n){
      cout<<"The optimal value is"<<s[n]<<endl;
      int m = 0;
      Traceback(n,m,s,l);
      s[m] = n;
      cout<<"Decompose into "<<m<<" segments "<<endl;
      for (int j=1; j <= m; j++){
        l[j] = l[s[j]];
        b[j]= b[s[j]];
      }
      for (int j=1; j<=m;j++)
        cout<<l[j]<<' '<<b[j]<<endl;
    } //复杂度O(n)
    

    0-1背包

    问题描述

    给定n种物品和一个背包。物品i的重量是\(w_i\),其价值为\(v_i\),背包的容量为c。问如何选择装入背包中的物品,使得装入背包中物品的总价值最大?

    问题分析

    \[目标函数 \sum_{i=1}^nv_ix_i\\ 约束条件 \begin{cases} & x_i=1 \ or \ 0\\ &\sum_{i=1}^nw_ix_i \le c \end{cases} \]

    设所给0-1背包问题的子问题

    \[max\sum_{k=i}^nv_kx_k \\ \begin{cases} \sum_{k=i}^nw_kx_k \le j\\ x_k \in \{0,1\} &i \le k \le n \end{cases} \]

    的最优值为m(i,j),即背包容量为j,可选择物品为i,i+1,...,n时的最优值。

    建立计算m(i,j)的递归式如下:

    \[m(n,j)= \begin{cases} 0 &j<w_n\\ v_n &j \ge w_n \end{cases} \]

    \[m(i,j)= \begin{cases} 无法放入:m(i+1,j) &0\le j<w_i\\ max\{不放入:m(i+1,j),放入:m(i+1,j-w_i)+v_i\} &j\ge w_i \end{cases} \]

    例题

    n=5,c=10,w={2,2,6,5,4},V={6,3,5,4,6}

    \[ \begin{align*} i=5时\\ &m(5,j)= \begin{cases} 6 &&j \ge 4\\ 0 &&0 \le j <4 \end{cases}\\ i=4时\\ &若0\le j<5,则4物品无法放入,则m(4,j)=m(4+1,j)=m(5,j)= \begin{cases} 0 &&0\le j<4\\ 6 &&4\le j<5 \end{cases}\\ &若5\le j,则m(4,j)=max\{ m(5,j),m(5,j-5)+4\}= \begin{cases} max\{6,4\} &&5\le j<9\\ max\{6,10 \} &&9\le j \end{cases}\\ &m[1][c]给出所要求的0-1背包问题的最优值 \end{align*} \]

    构造表

    (1)当1<=i<5时:

    从m(1,10)开始看起,m(1,10)!=m(2,10),说明1肯定被放入了背包中。1的重量为2,剩下的容量为10-2=8;

    从m(2,8)开始看起,m(2,8)!=m(3,8),说明2肯定被放入了背包中。2的重量为2,剩下的容量为8-2=6;

    从m(3,6)开始看起,m(3,6)=m(4,6),说明3肯定没有放入背包中。继续m(4,6)=m(5,6),说明4肯定没有放入背包中。

    (2)当i=5时:

    看剩余的容量,如果满足5号物品的重量,则5肯定放入。否则,5无法放入。此例,容量为6,5号物品重量为4,5号物品放入。

    代码

    Template<class Type>
    Void Knapsack(Type v, int w, int c, int n, Type **m){
      int jMax=min(w[n]-1,c);//防止程序溢出。如果w[n]>c,for(int j=0;j<=w[n];j ++) 将导致j每次+1,超过c了还在加。
    
      for(int j=0; j<=jMax;  j ++)
        m[n][j]=0;
      for(j=w[n]; j<=c;  j++)
        m[n][j]=v[n];
      for(int i=n-1;i>1;i--){    
        jMax=min(w[i]-1,c);
        for(int j=0; j<=jMax; j++)
          m[i][j]= m[i+1][j];
        for(j=w[i]; j<=c; j++)
          m[i][j]=max{m[i+1][j]),m[i+1][j-w[i])+v[i]};
      }    
      m[1][c]=m[2][c]; //直接假设1号物品比总容量c的值还大,这样1号物品无法放入背包
      if(c>=w[1])  m[1][c]= max{m[1][c]), m[2][c-w[1]) +v[1] //如果1号物品比总容量c的值小,看看是不放入1号物品产生的价值大,还是放入产生的价值大。
    }//复杂性O(nc),c很大时复杂性高
    

    在上述程序中,第3个for循环 for(int i=n-1, i>1, i--)中,“i>1”可以修改为“i>=1”; 同时将最末尾的两行语句删除。

    最后两行语句的含义是:在第3个for循环中,i=1的循环无需做完,我们只需要该循环的最后一个点m[1] [c],m[1] [c-1],m[1] [c-2]……m[1] [1]的值是不需要的。

    Template<class Type>
    Void Traceback(Type **m ,int w,int c,int n,int x){ //x数组用来存放是否第i个元素被装载进来
      for(int i=0;i<=n;i++){
        if(m[i][c]==m[i+1][c])   
          x[i]=0;
        eles{
          x[i]=1;
          c=c-w[i];
      }
      x[n]=(m[n][c]) ? 1:0;
    }//复杂性O(n)
    

    此外还有流水线调度、最优三角剖分、电路布线、最大子段和

    贪心算法

    基本概念

    在贪心算法中采用逐步构造最优解的方法。在每个阶段,都作出一个当前看来最好的决策。也就是说不从整体最优上加以考虑,所做的选择只是局部最优解。当然,我们希望贪心算法得到的最终结果也是整体最优的。作出贪心决策的依据称为贪心准则。

    贪心算法通过一系列的选择来得到问题的解。它所做的每一个选择都是当前状态下局部的最好选择,即贪心选择。

    贪心算法的基本要素

    最优子结构性质,问题的最优解包含其子问题的最优解。

    贪心选择性质,所求问题的整体最优解可以通过一系列局部最优的选择,即贪心选择来达到。

    贪心算法与动态规划的比较

    动态规划法每步所作的选择依赖于相关子问题的解。自底向上。

    贪心选择依赖于以往作出的选择,不依赖于子问题的解。 自顶向下。

    证明贪心选择性质

    (1) 假设问题有一个整体最优解,并证明可修改这个最优解,使其以贪心选择开始。做了贪心选择后,原问题简化为规模更小的类似子问题。

    (2) 运用数学归纳法证明:每一步贪心选择→问题的整体最优解。

    活动安排问题

    问题描述

    设有n个活动的集合\(\{E=1,2.…,n\}\),每个活动i都有一个使用该资源的起始时间\(s_i\)和一个结束时间\(f_i\),且\(s_i<f_i\)。如果选择了活动i,则它在半开时间区间\([s_i,f_i)\)内占用资源。 若区间\([s_i,f_i)\)与区间\([s_j,f_j )\)不相交,则称活动i和活动j是相容的。即\(s_i ≥f_j\)\(s_j ≥ f_i\) 。 目标是在活动集合中选择最大的相容活动子集合。

    算法思想

    在选择活动时要为未安排的活动留下尽可能多的时间。最早结束的活动优先安排,将n个活动按结束时间f非降序排列。贪心算法开始选择活动1,并将j初始化为1,然后依次检查活动i是否与已选择的所有活动相容,相容则放入。\(f_j\)总是当前集合A中所有活动的最大结束时间,故活动i与当前集合A中所有活动相容的充要条件是\(s_i \ge f_j\),即开始时间\(s_i\)不早于最近加入集合A中的活动j的结束时间\(f_j\)。若活动i与之相容,则i成为最近加入集合A中的活动。

    贪心算法并不总能求得问题的整体最优解,但对于活动安排问题,贪心算法总能求得整体最优解,即它确定的相容活动集合A的规模最大,这个结论可以用数学归纳法证明。

    代码

    template<class Type>
    void GreedySelector(int n, Type s[], Type f[], bool A[]){//s存开始时间,f存结束时间,且按结束时间非减续排列;
      A[1]=true; //排在第1个的活动最先结束,直接放入A;
      int j=1;
      for (int i=2;i<=n;i++) {  //从第2个活动开始检测
        if (s[i]>=f[j]){ 
          A[i]=true; 
          j=i;    //如果相容,放入A
        }      
        else 
          A[i]=false;
      }
    }// θ(n),若未排序则要加上排序的O(nlog n)
    

    最优装载问题

    有一批集装箱要装上一艘载重量为c的轮船,已知集装箱i(1≤i<n)的重量为w,最优装载问题要求在装载体积不受限制的情况下,将尽可能多的集装箱装上轮船。形式化描述如下:

    \[\begin{cases} max\sum_{i=1}^nx_i\\ \sum_{i=1}^nw_ix_i \le c \end{cases} \ \ \ \ \ \ x_i\in\{0,1\},1 \le i \le n \]

    采用重量最轻者先装的贪心策略,可产生最优装载问题的最优解。

    template<class Type>
    void Loading(int ×[],Type w[],Type c,int n) {
      int *t =new int [n+1];
      sort(w, t, n);
      for (int i=1; i <= n; i++)
        x[i]=0;
      for (int i=1; i <= n && w[t[i]]<= c; i++) {
        x[t[i]] =1;
        c-=w[t[i]];
      }
    }
    

    哈夫曼编码

    问题描述

    哈夫曼编码是广泛地用于数据文件压缩的十分有效的编码方法。哈夫曼编码算法根据字符在文件中出现的频率进行编码。出现频率高的字符的编码较短,出现频率较低的字符的编码较长。

    对每一个字符规定一个0,1串作为其代码,并要求任一字符的代码都不是其它字符代码的前缀,这种编码称为前缀码。前缀码可由树根到树叶的路径表示。哈夫曼编码是前缀码。

    给定编码字符集c及c中任一字符c的出现频率f(c)。C的一个前缀码编码方案对应于一棵二叉树T。字符c在树T中的深度记为\(d_T(c)\)。定义该编码方案的平均码长:\(B(T)=\sum_{c\in C}f(c)d_T(c)\) (频率小的字符深度大),找到使平均码长达到最小的前缀编码方案。

    算法设计

    队列Q以f(c)为键值存放二叉树各结点,通过贪心选择,将最小频率的两个二叉树合并,然后将新树(频率为上述两个二叉树频率之和)插入Q中。关于n个字符的哈夫曼算法的计算时间为\(O(nlogn)\)

    代码

    //算法中用到的类Huffman定义如下:
    template<class Type>
    class Huffman {
      friend BinaryTree<int> HuffmanTree(Type [], int);
    public:
      operator Type ()const { return weight; }
    private:
      BinaryTree<int> tree;
      Type weight;
    };
    
    //算法HuffmanTree描述如下:
    template <class Type>
    BinaryTree<int> HuffmanTree(Type f[], int n){//生成单结点树
      Huffman<Type> *w = new Huffman<Type>[n+1];
      BinaryTree<int> z,zero;
      for (int i=1; i<=n;i++){
        z.MakeTree(i,zero,zero);
        w[i].weight= f[i];
        w[i].tree = z;
      }
      //建优先队列
      MinHeap<Huffman<Type>>Q(1);
      Q.Initialize(w,n, n);//反复合并最小频率树
      Huffman<Type> x, y;
      for (int i=1; i <n; i++){
        Q.DeleteMin(x);
        Q.DeleteMin(y);
        z.MakeTree(0,x.tree, y.tree);
        x.weight += y.weight;
        x.tree=z;
        Q.Insert(x);
      }
      Q.DeleteMin(x);
      Q.Deactivate();
      delete []w;
      return x.tree;
    }
    

    还有单源最短路径问题

    回溯与分支限界

    基本概念

    回溯法是类似穷举的搜索尝试过程,在搜索尝试过程中寻找问题的解。用回溯法求解问题时,应明确定义问题的解空间:解决一个问题的所有可能的决策序列构成该问题的解空间,可以用一棵完全二叉树表示解空间。问题的解空间至少应包含问题的一个(最优)解。

    活结点:自身已生成但其儿子还没有全部生成。扩展结点:正在产生儿子的结点。死结点:不满足约束或所有儿子已经产生,不能向纵深方向移动。

    为了避免生成那些不可能产生最佳解的问题状态,要不断地利用限界函数(Bounding Function)来处死那些实际上不可能产生所需解的活结点,以减少问题的计算量。具有限界函数的深度优先生成法称为回溯法。

    因为回溯法使用约束函数和限界函数分别剪去不满足约束的子树和不能产生最优解的子树,避免无效搜索。所以比穷举法效率高。

    回溯法的基本思想

    回溯法从根节点开始,以深度优先搜索整个解空间。这个根结点成为活结点,同时成为当前的扩展结点。在当前的扩展结点处,搜索向纵深方向移至一个新结点。这个新结点就成为新的活结点,并成为当前扩展结点。如果在当前的扩展结点处不能再向纵深方向移动,则当前扩展结点就成为死结点。此时,应往回移动(回溯)至最近的一个活结点处,并使这个活结点成为当前的扩展结点。回溯法以这种工作方式递归地在解空间中搜索,直至找到所要求的解或解空间中已无活结点时为止。

    子集树与排列树

    子集树:当所给的问题是从n个元素的集合S中找出S满足性质的子集时,相应的解空间树称为子集树。满足所有约束条件的解状态结点称为回答结点。(0-1背包)

    排序树: 当所给问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树。(TSP)

    回溯法搜索解空间时,采用剪枝策略避免无效搜索。剪枝函数:用约束函数在扩展结点处剪去不满足约束的子树;用限界函数剪去得不到最优解的子树。搜索按深度优先策略从根开始,当搜索到任一结点时,判断该点是包否含问题的解,包含则继续向下深度优先搜索,否则跳过该结点以下的子树(剪枝),向上逐级回溯。

    回溯法解题步骤

    针对所给问题,定义问题的解空间;

    确定易于搜索的解空间结构(子集树或排列树);

    以深度优先方式搜索解空间,并在搜索过程中用剪枝函数(包括约束函数和限界函数)避免无效搜索。

    分支限界法

    分支限界法的适用问题类型与回溯法基本相同,一般也是下面两种类型:存在性问题、最优化问题。解空间是树形结构,包括子集树和排列树。搜索方式是广度优先或最小耗费优先。求解目标是在满足约束条件的解中找出在某种意义下的最优解。

    分支限界法与回溯法的主要区别在于它们对当前扩展结点所采用的扩展方式不同。在分支限界法中,每一个活结点只有一次机会成为扩展结点,活结点一旦成为扩展结点,一次性产生其所有儿子结点。导致不可行解或导致非最优解的儿子结点被舍弃,其余儿子结点被加入活结点表中,从活结点表中取出下一结点成为当前扩展结点,并重复结点扩展过程,直到找到所需的解或活结点表为空时为止。常见的有两种方式。

    分支限界法选择扩展结点的两种常用方法

    (1) 队列式(FIFO),将活结点表组织成队列,按先进先出原则选取下一个结点作为当前扩展结点。

    从A扩展到B,C

    先从B开始扩展,得到D,E,加入活节点队列,同时将B删除,得到[C,D,E]

    D是不可行解节点,删除,得到[C,E]

    从C开始扩展,得到F,G,加入活节点队列,同时将C删除,得到[E,F,G]

    (2) 优先队列式,每个结点都有一个对应的耗费或收益,按优先级选取下一个结点作为当前扩展结点。

    回溯法和分支限界法的比较

    回溯法 分支限界法
    求解目标 找出满足约束条件的所有解; 找出满足约束条件的一个解或找出使目标函数达到极大(小)的最优解
    搜索方式 深度优先 广度优先或最小耗费优先
    求最优解 需要遍历完整棵树回到根 队列方式:需要遍历完整棵树回到根。
    优先队列:遇到第一个叶子节点为可扩展。
    结点扩展 活结点有多次机会成为扩展结点 活结点只有一次机会成为扩展结点
    树结点的生成顺序 生成最近一个有希望结点的单个子女 选择其中最有希望的结点并生成它的所有子女
    行进方向 随机 队列方式:随机·
    优先队列:朝着解空间树上有最优解的分支行进。

    有的书将回溯看成分支限界的一种特殊情况,并且对回溯法的右子树不加限界,对分支限界法左儿子约束剪枝,右儿子限界剪枝。回溯法回到根结点时结束。而分支限界法叶子结点成为可扩展结点时结束,因为叶子结点无法再产生儿子。

    回溯-m图着色

    问题描述

    已知无向图G=(V,E)和m种不同的颜色,如果只允许使用这m种颜色对图G的结点着色,每个结点着一种颜色。问是否存在一种着色方案,使得图中任意相邻的两个结点都有不同的颜色。整数m称为图G的着色数。

    四色定理:每幅地图都可以用不多于4种颜色来着色,使得有共同边界的国家着不同的颜色。

    一幅地图可以用一个平面图G表示。将地图的每个区域用图G的一个结点表示,若两个区域相邻,则相应的两个结点用一条边连接起来。下面显示一幅地图以及将其转化后的平面图。

    问题分析

    采用n元组\((x_0,x_1,…,x_{n-1})\)表示图G的m着色判定问题的解,并采用邻接矩阵表示无向图G=(V,E) 。n为平面图中的顶点个数。

    \[a[i][j]= \begin{cases} 1 &如果(i,j) \in E\\ 0 &其它 \end{cases} \]

    显示约束:n元组\((x_0,x_1,\cdots,x_{n-1}), x_i \in \{1,\cdots,m\}, 0\le i<n\),表示结点i的颜色。\(x_i=0\)表示没有可用的颜色。因此解空间的大小为\(m^n\)

    隐式约束:如果边\((i,j)\in E\),则 \(x_i\ne x_j\)

    约束函数:对所有i和j,\(0\le i,j<k,i\ne j\),若\(a[i][j]=1\),则\(x_i\ne x_j\)\((1≤x_i,x_j≤m)\)

    以深度优先方式生成状态空间树中的结点,寻找所有答案结点,即m着色方案。搜索中使用约束函数剪去不可能包含答案结点的分枝。对给定的无向图G和m,列出图中结点所有可能的m着色方案。

    代码:

    class color {
      friend int mColoring(int,int,int**);
    private:
      bool Ok(int k);
      void Backtrack(int t);
      int n,//图的顶点数
          m,//可用颜色数
          **a;//圆的邻接矩阵
          *x;
      long sum;//当前已找到的可m着色方案数
    };
    bool color :: ok(int k) {//检查颜色可用性
      for (int j=1; j <=n; j++)
        if ((a[k][j] == 1) 8& (x[j] == x[k]))
          return false;
      return true;
    }
    void color :: Backtrack(int t) {
      if (t > n) {
        sum++;
        for (int i=1; i<=n; i++)
          cout<<x[i]<<'';
          cout<<endl;
      else {
        for (int i=1; i <= m; i++){
          x[t] = i;
          if (ok(t))
            Backtrack(t+1);
          x[t] = 0;
        }
      }
    }
    int mcoloring(int n,int m, int**a){
      color X;//初始化x
      X.n = n;
      X.m= m;
      X.a = a;
      X.sum =0;
      int *p = new int [n+1];
      for (int i=0; i<= n; i++)
        p[i] = 0;
      X.x=p;
      X.Backtrack(1);
      delete [] p;
      return X.sum;
    }//O(nm^n)
    

    回溯-N后问题

    问题描述

    在n*n格的棋盘上放置彼此不受攻击的n个皇后。按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。n后问题等价于,在n×n格的棋盘上放置n个皇后,任何2个皇后不放在同一行或同一列或同一斜线上。

    给定棋盘大小\(n(n \le 13)\),输出有多少种放置方法。

    问题分析

    确定问题状态:问题的状态即棋盘的布局状态。

    构造状态空间树:状态空间树的根为空棋盘,每个布局的下一步可能布局是该布局结点的子结点。

    由于可以预知,在每行中有且只有一个皇后,因此可采用逐行布局的方式,即每个布局有n个子结点。

    算法思路

    将棋盘从左至右,从上到下编号为1,...,n,皇后编号为1,...,n.。设解为\((x_1, \cdots , x_n) , x_i\)为皇后i的列号,且\(x_i\)位于第i行。解空间:\(E=\{ (x_1,\cdots, x_n) | x_i\in S_i, i=1,\cdots ,n\}, S_i=\{1, \cdots, n\},1\le i \le n\)解空间为排列树。其约束集合D为

    \[\begin{align*} x_i \ne x_j &&皇后不在同一列\\ \begin{cases} x_i-i \ne x_j-j\\ x_i+i \ne x_j+j \end{cases} &&皇后i,j不在同一斜线上,abs(i-j) \ne abs(x[i]-x[j]) \end{align*} \]

    用回溯法解n后问题,用完全n叉树表示解空间。可行性约束Place剪去不满足行、列和斜线约束的子树。

    下面的解n后问题的回溯法中,递归函数 Backtrack(1)实现对整个解空间的回溯搜索。Backtrack(i)搜索解空间中第i层子树。类Queen 的数据成员记录解空间中结点信息,以减少传给Backtrack的参数。sum记录当前已找到的可行方案数。

    在算法 Backtrack 中,当i>n时,算法搜索至叶结点,得到一个新的n皇后互不攻击放置方案,当前已找到的可行方案数sum增1。当i≤n时,当前扩展结点Z是解空间中的内部结点。该结点有n个儿子结点x[i]。对当前扩展结点Z的每个儿子结点,由Place检查其可行性,并以深度优先的方式递归地对可行子树搜索,或剪去不可行子树。

    代码

    class Queen {
      friend int nqueen(int);
    private:
      bool Place(int k);
      void Backtrack(int t);
      int n,//皇后个数
          *x;//当前解
      long sum;//当前已找到的可行方案数
    };
    bool Queen :: Place(int k) {
      for (int j=1;j< k; j++)
        if ((abs(k-j) == abs(×[j]-x[k]))||(x[j] == x[k]))
          return false;
      return true;
    }
    void Queen ::Backtrack(int t) {
      if (t > n)
        sum++;
      else {
        for (int i=1; i<= n; i++) {
          x[t] = i;
          if (Place(t))
            Backtrack(t+1);
        }
      }
    }  
    int nQueen(int n){
      Queen X;
    //初始化
      X.n = n;//皇后个数
      X.sum = 0;
      int *p = new int [n+1];
      for (int i=8; i <= n; i++)
        p[i] = 0;
      X.x = p;
      X.Backtrack(1);
      delete[] p;
      return X.sum;
    }
    

    分支限界-单源最短路径

    问题描述

    给定一个带权有向图G=<V,E>,每条边的权是一个正整数,给定V中的一个顶点S,称为源点。计算从源点到其他所有顶点的最短路径。

    算法设计

    解空间:子集树。

    用邻接矩阵表示所给的图G,在类Graph中用二维数组c存储图G的邻接矩阵,用数组dist[ ]存放源点v出发的最短路径长度。dist[i]: 源点s到顶点 i 的最短路径长度,初始时所有dist[i]值为∞。用数组perv[ ]记录从源点到各顶点的路径上的前驱顶点。prev[i]: 源点s到顶点 i 的最短路径中顶点i的前驱顶点。

    剪枝的原则:在扩展顶点 i 时,如果从当前扩展结点i 到 j 有边可达,且从源出发,途经 i 再到 j 的所相应路径长度,小于当前最优路径长度,则将该顶点作为活结点插入到活结点优先队列中。结点的扩展过程一直继续到活结点列表为空。

    队列式:

    优先队列:



    要找的是从源到各顶点的最短路径,所以选用最小堆表示活结点优先队列。最小堆中元素的类型为MinHeapNode。该类型结点包含域i,用于记录该活结点所表示的图G中相应顶点的编号;length表示从源到该顶点的距离。

    template<class Type>
    class Graph {
      friend void main(void);
    public:
      void ShortestPaths(int);
    private:
      int n,//图G的顶点数
      *prev;//前驱顶点数组
      Type**c,//图G的邻接矩阵
      *dist;//最短距离数组
    };
    template<class Type>
    class MinHeapNode {
      friend Graph<Type>;
    public:
      operator int (const { return length; }
    private:
      int i;//顶点编号
      Type length;//当前路长
    }
    

    具体算法描述如下:

    template<class Type>
    void Graph<Type> ::ShortestPaths(int v) {//单源最短路径问题的优先队列式分支限界法
      MinHeap<MinHeapNode<Type>> H( 1000);//定义最小堆的容量为1000
      MinHeapNode<Type> E;//定义源为初始扩展结点
      E.i =v;
      E.length = 0;
      dist[v] = 0;
      while (true) {//搜索问题的解空间
        for (int j=1; j <=n; j++) {
          if ((c[E.i][j]<inf)&& (E.length + c[E.i][j]<dist[j])){ //顶点i到顶点j可达,且满足控制约束
          dist[j] = E.length+c[E.i][j];
          prev[j]= E.i;
          MinHeapNode<Type> N;//加入活结点优先队列
          N.i =j;
          N.length = dist[j];
          H.Insert(N);
          }
        try { H.DeleteMin(E); }//取下一扩展结点
        catch (OutOfBounds) {break; }//优先队列空
        }
      }
    }
    

    回溯-分支限界-装载问题

    问题描述

    n个集装箱要装到2艘载重量分别为\(c_1,c_2\)的货轮,其中集装箱i的重量为\(w_i\)。要求找到一个合理的装载方案可将这n个货箱装上这⒉艘轮船。

    问题分析

    装载问题是一个NP难的问题。若装载问题有解,采用如下策略可得一个最优装载方案:
    (1) 将第一艘轮船尽可能装满;
    (2) 将剩余的货箱装到第二艘轮船上。

    将第一艘船尽可能装满等价于如下0-1背包问题:

    \[\begin{cases} max\sum_{i=1}^nw_ix_i\\ s.t.\sum_{i=1}^nw_ix_i \le c_1 \end{cases} \ \ \ \ \ \ x_i \in \{0,1\},1\le i \le n \]

    回溯算法思路

    用子集树表示解空间,则解为n元向量\(\{x_1,x_2,\cdots,x_n \},x_i \in \{0,1\}\) ,约束条件\(\sum_{i=1}^nw_ix_i \le c_1\)。利用最优解性质进一步剪去不含最优解的子树。在子集树的第j+1层的结点Z处,用cw记为当前的
    装载重量,即\(cw =\sum_{i=1}^jw_ix_i\)。当\(cw>c_1\)时,以结点Z为根的子树中所有结点都不满足约束条件,因而该子树中的解均为不可行解,故可将该子树剪去。

    回溯代码

    ①对于算法 Backtrack,可以引入一个上界函数,用于剪去不含最优解的子树,从而改进算法在平均情况下的运行效率。设Z是解空间树第i层上的当前扩展结点。cw是当前载重量,bestw是当前最优载重量,r是剩余集装箱的重量,即\(r=\sum_{j=i+1}^nw_j\)。定义上界函数为cw+r。在以Z为根的子树中任一叶结点所相应的载重量均不超过 cw+r。因此,当\(cw+r\le bestw\)时,可将Z的右子树剪去。
    在下面的改进算法中,引入类Loading 的变量r,用于计算上界函数。引入上界函数后,在达到一个叶结点时就不必再检查该叶结点是否优于当前最优解。因为上界函数使算法搜索到的每个叶结点都是当前找到的最优解。虽然改进后的算法的计算时间复杂性仍为\(O(2^n)\),但在平均情况下改进后的算法检查的结点数较少。

    ②为了构造最优解,必须在算法中记录与当前最优值相应的当前最优解。为此,在类Loading 中增加两个私有数据成员x和 bestx。x用于记录从根至当前结点的路径,bestx记录当前最优解。算法搜索到达叶结点处,就修正bestx的值。

    template<c1ass Type>
    class Loading {
      friend Type MaxLoading(Type[],Type,int);
    private:
      void Backtrack(int i);
      int n,//集装箱数
         ②*x,//当前解
         ②*bestx;//当前最优解
      Type* w,//集装箱重量数组
            c,//第一艘轮船的载重量
            cw,//当前载重量
            bestw,//当前最优载重量
           ①r;//剩余集装箱重量
    }
    
    template<class Type>
    void Loading<Type> :: Backtrack(int i) {//搜索第i层结点
      if (i > n) {//到达叶结点
       ②for(j=1;j<=n;j++)  
         ②bestx[j]=x[j];
     ②//if (cw > bestw)
        bestw = cw;     
        return;
      }
      //搜索子树
     ①r-=w[i];   
      if ( cw+w[i]<= c){//x[i]=1
       ②x[i]=1;  
        cw += w[i];
        Backtrack(i+1);
        cw -= w[i];
      }
     ①if(cw+r>bestw){  
       ①x[i]=0;  
        Backtrack(i+1);// x[i]=0
     ①}
     ①r+=w[i];  
    }
    template<class Type>
    Type MaxLoading(Type w[],Type c, int n, ②int bestx[]) {//返回最优载重量
      Loading<Type> x;//初始化X
     ②X.x=new int[n+1];   
     ②X.bestx=0;   
      X.w = w;//集装箱重量数组
      X.c = c;//第一艘船载重量
      X.n = n;//集装箱数
      X.bestw = 0;//最优载重
      X.cw = 0;//当前载重量
     ①X.r=0;//剩余集装箱重量
     ①for(int i=1;i<=n;i++)
     ①  X.r+=w[i]
      X.Backtrack(1);//搜索树计算最优载重量
     ②delete []X.x; //释放X
      return X.bestw;
    }
    

    分支限界法思路

    可行性约束函数\(\sum_{i=1}^nw_ix_i \le c_1\)

    ew:子集树第j+1层的结点Z处当前的装载重量,即\(ew=w_1x_1+w_2x_2+ \cdots +w_jx_j\)

    bestw:当前最优载重量,

    r:剩余集装箱的重量

    上界函数\(L=ew(已装载重量)+r(剩余重量的上界)\),当\(ew+r \le bestw\)时可将其右子树剪去。

    队列式:

    最优解U,X={1,0,1,0}

    优先队列:

    用最大优先队列存储活结点表,优先队列中优先级最大的活结点成为下一个扩展结点。
    优先级定义:\(un=ew+r\)


    采用优先队列的方式,得到的第一个解肯定是全局最优解。而采用队列方式,则需要遍历完全部的节点。无论采用队列式分支限界法还是优先队列式分支限界法求解装载问题,最坏情况下要搜索整个解空间树,所以最坏时间和空间复杂度均为\(O(2^n)\)

    代码略。

    此外还有0-1背包问题、最大团问题,课本上都用了回溯法和分支限界法两种方法来做。

    几种算法的总结

    分治法

    基本思想:

    将一个问题,分解为多个子问题,递归的去解决子问题,最终合并为问题的解

    适用情况:

    问题分解为小问题后容易解决

    问题可以分解为小问题,即最优子结构

    分解后的小问题解可以合并为原问题的解

    小问题之间互相独立

    实例

    二分搜索

    快速排序

    合并排序

    最接近点对

    循环赛日程表

    大整数相乘

    ........

    动态划分算法

    基本思想:

    将问题分解为多个子问题(阶段),按顺序求解,前一个问题的解为后一个问题提供信息。 动态规划的实质是分治思想和解决冗余。

    适用情况:

    最优化原理:问题的最优解所包含的子问题的解也是最优的,即最优子结构

    无后效性:某个状态一旦确定,就不受以后决策的影响

    有重叠子问题

    实例

    矩阵连乘

    最长公共子序列

    最大字段和

    凸多边形最优三角剖分

    图像压缩

    电路布线

    ......

    贪心算法

    基本思想:

    不从总体最优考虑,仅考虑局部最优解,问题必须具备后无效性

    适用情况:

    最优子结构

    贪心选择性质

    实例

    最优装载

    哈夫曼编码

    单源最短路径

    最小生成树

    回溯法

    基本思想:

    选优搜索法,走不通就退回重选,按照深度优先搜索的策略,从根节点出发,深度搜索解空间

    步骤:

    确定解空间

    确定节点的扩展搜索规则

    深度优先方式搜索解空间,用剪枝法避免无效搜索

    实例

    0-1背包

    最大图

    图的m着色

    n后问题

    旅行商问题

    分支界限法

    基本思想:

    与回溯法类似,也是在解空间里搜索解得算法,不同点是,回溯法寻找所有解,分支界限法搜索一个解或者最优解。

    步骤:

    确定解空间

    确定节点的扩展搜索规则

    广度优先策略或者最小耗费(最大效益)优先,用剪枝法避免无效搜索

    实例

    0-1背包

    旅行商问题

    算法策略间的关联

    1、对问题进行分解的算法策略——"分治法"与"动态规划法"

    “分治法”与“动态规划法”都是递归思想的应用之一,是找出大问题与小的子问题之间的关系,直到小的子问题很容易解决,再由小的子问题的解导出大问题的解。

    2、多阶段过程"贪婪算法"、"动态规划法"

    多阶段过程就是按一定顺序(从前向后或从后向前等)一定的策略, 逐步解决问题的方法。“贪婪算法”每一步根据策略得到一个结果,自顶向下,一步一步地作出贪心选择。“动态规划法”则根据一定的决策, 自底向上,每一步使问题的规模不断的扩大,直至原问题的规模。

    3、全面逐一尝试(带有选择性的)、比如“回溯法”、“分支限界算法”

    有这样一类问题,问题中不易找到信息间的相互关系,也不能分解为独立的子问题,只有把各种可能情况都考虑到,并把全部解都列出来之后,才能判定和得到最优解。对于规模不大的问题,这些策略简单方便;而当问题的计算复杂度高且计算量很大时,还是考虑采用“回溯法或分支限界” 算法策略。

    实际运用中的四类问题

    实际应用中遇到的问题主要分为四类:判定性问题、计算问题、最优化问题和构造性问题。

    "递推法"、"递归法"算法较适合解决判定性问题、计算问题。

    “贪婪算法”、“分治法” 、“动态规划法” 与“枚举法” 较适合解最优化问题。

    构造性问题更多地依赖于人的经验和抽象能力,算法一般是人类智能充分对问题解决步骤细化后才能得到算法,少有通用的算法策略。当然也有一些问题在构造过程中使用通用的算法策略。

  • 相关阅读:
    DB2、ORACLE SQL写法的主要区别
    最快的序列化组件protobuf的.net版本protobuf.net
    Oracle迁移到DB2常用转换
    模拟百度分页算法
    MySQL 自关联查询
    python 实现cm批量上传
    python实现京东秒杀
    百度地图商家爬虫
    django BBS
    python 堆排序
  • 原文地址:https://www.cnblogs.com/gylic/p/15595066.html
Copyright © 2011-2022 走看看