算法设计与分析(王晓东 教材解析

算法

一、算法定义

算法的性质:
  1. 输入:有零个或多个由外部提供的量作为算法的输入
  2. 输出:算法产生至少一个量作为输出
  3. 确定性:组成算法的每条指令是清晰的,无歧义的
  4. 有限性:算法中每条指令的执行次数是有限的,执行每条指令的时间也是有限的
算法的复杂度分析

分为时间复杂度和空间复杂度

主定理证明

preview

时间复杂度分析符号说明

  • Θ – 等于
    f ( n ) = Θ ( g ( n ) ) 即 f ( n ) = g ( n )
    若 f ( n ) = O ( g ( n ) ) 且 f ( n ) = Ω ( g ( n ) ) , 则 记 作 f ( n ) = Θ ( g ( n ) ) 若f(n)=O(g(n))且f(n)=\Omega (g(n)),则记作f(n)=\Theta (g(n)) f(n)=O(g(n))f(n)=Ω(g(n))f(n)=Θ(g(n))

  • Ο – 小于等于(常用于计算最坏情况,作为时间复杂度上界)
    f ( n ) = O ( g ( n ) ) 即 f ( n ) ≤ g ( n )
    若 存 在 正 数 c 和 n 0 , 使 得 对 一 切 n ≥ n 0 , 有 0 ≤ f ( n ) ≤ c g ( n ) 成 立 , 若存在正数 c 和n_{0},使得对一切n\geq n_{0},有0\leq f(n)\leq cg(n)成立, cn0使nn0,0f(n)cg(n),

    则 称 f ( n ) 的 渐 进 的 上 界 是 g ( n ) , 记 作 f ( n ) = O ( g ( n ) ) 则称f(n)的渐进的上界是g(n),记作f(n)=O(g(n)) f(n)g(n)f(n)=O(g(n))

  • ο – 小于
    f ( n ) = ο ( g ( n ) ) 即 f ( n ) < g ( n )


    若 对 于 任 意 正 数 c 都 存 在 n 0 , 使 得 对 一 切 n ≥ n 0 , 有 0 ≤ f ( n ) < c g ( n ) 成 立 , 若对于任意正数 c 都存在n_{0},使得对一切n\geq n_{0},有0\leq f(n)<cg(n)成立, cn0使nn0,0f(n)<cg(n) {若对于任意正数 c 都存在n_{0},使得对一切n\geq n_{0},有0\leq f(n)<cg(n)成立,}

    则 称 f ( n ) 的 上 界 是 g ( n ) , 记 作 f ( n ) = o ( g ( n ) ) 则称f(n)的上界是g(n),记作f(n)=o(g(n)) f(n)g(n)f(n)=o(g(n))

  • Ω – 大于等于

    f ( n ) = Ω ( g ( n ) ) 即 f ( n ) ≥ g ( n )
    若 存 在 正 数 c 和 n 0 , 使 得 对 一 切 n ≥ n 0 , 有 0 ≤ c g ( n ) ≤ f ( n ) 成 立 , 若存在正数 c 和n_{0},使得对一切n\geq n_{0},有0\leq cg(n)\leq f(n)成立, cn0使nn0,0cg(n)f(n)

    则 称 f ( n ) 的 渐 进 的 下 界 是 g ( n ) , 记 作 f ( n ) = Ω ( g ( n ) ) 则称f(n)的渐进的下界是g(n),记作f(n)=\Omega (g(n)) f(n)g(n)f(n)=Ω(g(n))

  • ω – 大于
    f ( n ) = ω ( g ( n ) ) 即 f ( n ) > g ( n )
    若 对 于 任 意 正 数 c 都 存 在 n 0 , 使 得 对 一 切 n ≥ n 0 , 有 0 ≤ c g ( n ) < f ( n ) 成 立 , 若对于任意正数 c 都存在n_{0},使得对一切n\geq n_{0},有0\leq cg(n)<f(n)成立, cn0使nn0,0cg(n)<f(n)

    则 称 f ( n ) 的 下 界 是 g ( n ) , 记 作 f ( n ) = ω ( g ( n ) ) 则称f(n)的下界是g(n),记作f(n)=\omega(g(n)) f(n)g(n)f(n)=ω(g(n))

img


二、递归与分治

递归: 直接或间接地调用自身的算法称为递归算法。用函数自身给出定义的函数称为递归函数
  • 递归的基本思想

    递归并不是简单的自己调用自己,也不是简单的交互调用。递归在于把问题分解成规模更小、具有与原来问题相同解法的问题,如二分查找以及求集合的子集问题。这些都是不断的把问题规模变小,新问题与原问题有着相同的解法。但是并不是所有所有可以分解的子问题都能使用递归来求解。一般来说使用递归求解问题需要满足以下的条件:

    • 可以把要解决的问题转化为一个子问题,而这个子问题的解决方法仍与原来的解决方法相同,只是问题的规模变小了。
    • 原问题可以通过子问题解决而组合解决。
    • 存在一种简单的情境,是问题在简单情境下退出。
  • 分析思路

    • 分析问题看是否可以分解成子问题
    • 子问题和原问题之间有何关系
    • 是否有退出的简单条件

    在分析问题时我们可以采用自下而上,先分析简单情况,然后看复杂情况是否可以由简单情况组合形成,也可以自上而下,把复杂问题分解成子问题,在此过程中需要注意子问题是否有重叠。

  • 递归模板

    void recursion(int level, int param) { 
      // recursion terminator
      if (level > MAX_LEVEL) { // 一、递归终结条件
        // process result 
        return ; 
      }
    
      // process current logic 
      process(level, param);  // 二、处理当前层逻辑
    
      // drill down 
      recursion(level + 1, param);// 三、下探到下一层
    
      // reverse the current level status if needed // 四、清理当前层
    }
    
  • 阶乘函数 //没什么好说的

    int factorial(int n)
    {
    	if (n == 0) return 1;
    	return n * factorial(n - 1);
    }
    
  • Fibonacci数列 //没什么好说的

    int fibonacci(int n)
    {
    	if (n <= 1) return 1;
    	return fibonacci(n - 1) + fibonacci(n - 2);
    }	
    
  • 全排列问题 //这个全排列主要注意 第一次交换是自身交换 然后进入递归排列第k+1到第m个元素

    void perm(int list[], int k, int m)
    {
    	if (k == m)
    	{
    		//只剩下一个元素
    		for (int i = 0; i <= m; i++)
    			cout << list[i];
    		cout << endl;
    	}
    	else
    		for (int i = k; i <= m; i++)
    		{
    			swap(list[k], list[i]); //将第i个元素与第k个元素交换位置
    			perm(list, k + 1, m); //递归排列第k+1到第m个元素
    			swap(list[k], list[i]); //第i和第k个交换元素的重新换回正常位置
    		}
    }
    void swap(int& a, int& b)
    {
    	int temp = a;
    	a = b;
    	b = temp;
    }
    
  • 整数划分问题

    int q(int n, int m)  //最大加数n1 不大于m的划分个数即为q(n,m) m为n的最大划分数
    {
    	if (n < 1 || m < 1) return 0;  // n=0 或者m=0 均不符合题意
    	if (n == 1 || m == 1) return 1; // n=1 或者m=1 都只有一种情况
    	if (n < m) return q(n, n); // 因为 n>=m 为前提条件 所以这种情况q(n,m)即为q(n,n)
    	if (n == m) return q(n, m - 1) + 1; //n=m的情况下,即划分为n1=n和n1<=n-1的情况
    	return  q(n, m - 1) + q(n - m, m); //正常情况下 q(n,m)的划分由n1=m的情况和n1<=m-1的情况
    }
    
  • Hanoi塔问题 //没什么好说的,就是移动前后,目标塔和中介塔的理解

    void hanoi(int n, char a, char b, char c)
    {
     if (n > 0)
     {
     	hanoi(n - 1, a, c, b);
     	move(a, b);
     	hanoi(n - 1, c, b, a);
     }
    }
    
分治:
  • 思想

    所谓“分而治之” 就是把一个复杂的算法问题按一定的“分解”方法分为等价的规模较小的若干部分,然后逐个解决,分别找出各部分的解,把各部分的解组成整个问题的解,这种朴素的思想来源于人们生活与工作的经验,也完全适合于技术领域。诸如软件的体系结构设计、模块化设计都是分而治之的具体表现。

    分治算法,就是把问题分解为同一性质的子问题,再讲子问题分解(递归),直到分解出的问题(最小子问题)可以直接求解。然后由这个解再一层层地回到原问题,同时在此过程中得到对应层的解。

  • 步骤:
    1.分解(Divide):将问题分解为同一类型的子问题;
    2.治理(Conquer):递归地解决子问题;
    3.合并(Combine):合并子问题的答案,得出原问题的答案。

  • 策略:

    • 问题规模缩小到一定程度容易解决
    • 分解为若干个相同的子问题,每个子问题最优子结构
    • 利用该问题分解出的子问题的解可以合并为该问题的解
    • 分解出的子问题是独立的
  • 分治的具体过程

    begin  {开始}
        if ①问题不可分 then ②返回问题解  
         else begin
              ③从原问题中划出含一半运算对象的子问题1;
              ④递归调用分治法过程,求出解1;
             ⑤从原问题中划出含另一半运算对象的子问题2;
              ⑥递归调用分治法过程,求出解2;
             ⑦将解1、解2组合成整修问题的解;  
            end;
       end; {结束}
    
  • 二分搜索 //适合先把数组进行排序 然后递推的二分查找

    int bianrySearch(int a[], const int& x, int n)
    {
    	//在a[]中搜索x
    	//找到返回X 否则返回 -1
    	int left = 0, 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;
    }
    
  • 棋盘覆盖 //人为的构造特殊的方格(即已覆盖的),每次划分,将一个问题划分为四个小问题

    ​ //一直到最后求解

    void chessboard(int tr, int tc, int dr, int dc, int size) 
    								//tr 棋盘左上角行号 tc 棋盘左上角方格列号
                                              // dr 特殊方格 行号 dc 特殊方格 列号
    														// size 棋盘尺寸
    {
    	if (size == 1) return;
    	int t = tile++;
    	int s = size / 2;
    
    	//覆盖左上角子棋盘
    	if (dr < tr + s && dc < tc + s)
    		//特殊方格在棋盘中
    		chessboard(tr, tc, dr, dc, s);
    	else
    	{
    		//此棋盘中无特殊方格
    		//用t号L型骨牌覆盖右下角
    		Board[tr + s - 1][tc + s - 1] = t;
    		//覆盖该左上角棋盘中其余方格
    		chessboard(tr, tc, tr + s - 1, tc + s - 1, s);
    	}
    
    	//覆盖右上角子棋盘
    	if (dr < tr + s && dc >= tc + s)
    		//特殊方格在棋盘中
    		chessboard(tr, tc+s, dr, dc, s);
    	else
    	{
    		//此棋盘中无特殊方格
    		//用t号L型骨牌覆盖左下角
    		Board[tr + s - 1][tc + s] = t;
    		//覆盖其余方格
    		chessboard(tr, tc + s, tr + s - 1, tc + s, s);
    	}
    
    	//覆盖左下角子棋盘
    	if (dr >= tr + s && dc < tc + s)
    		//特殊方格在棋盘中
    		chessboard(tr + s, tc, dr, dc, s);
    	else
    	{
    		//此棋盘中无特殊方格
    		//用t号L型骨牌覆盖右下角
    		Board[tr + s][tc + s - 1] = t;
    		//覆盖其余方格
    		chessboard(tr + s, tc, tr + s, tc + s - 1, s);
    	}
    
    	//覆盖右下角子棋盘
    	if (dr >= tr + s && dc >= tc + s)
    		//特殊方格在棋盘中
    		chessboard(tr, tc, dr, dc, s);
    	else
    	{
    		//此棋盘中无特殊方格
    		//用t号L型骨牌覆盖右下角
    		Board[tr + s - 1][tc + s - 1] = t;
    		//覆盖其余方格
    		chessboard(tr + s, tc + s, tr + s, tc + s, s);
    	}
    }
    
  • 合并排序

    void mergesort(int a[], int n)  
    {
    	int* b = new int[n];
    	int s = 1;
    	while (s < n)
    	{
    		mergepass(a, b, s, n); //合并到数组b  
    		s += s;
    		mergepass(b, a, s, n); //合并到数组a  同时保证推出的时候最后a[]是有序的
    		s += s;
    	}
    }
    void mergepass(int x[], int y[], int s, int n)
    {
    	//合并大小为s的相邻子数组    
    	int i = 0;
    	while (i <= n - 2 * s)
    	{
    		//合并大小为s的相邻2段子数组
    		merge(x, y, i, i + s - 1, i + 2 * s - 1);
    		i += 2 * s;
    	}
    	//剩下的元素个数小于2s  
    	if (i + s < n) 
    		merge(x, y, i, i + s - 1, n - 1); //即剩下元素数目 大于s但是小于2s
    	else
    		for (int j = i; j < n; j++)
    			y[j] = x[j];
    }
    void merge(int c[], int d[], int l, int m, int r)  //将相邻的两个数组合并
    {
    	//合并c[l:m] 和 c[m+1:r] 到 d[l:r]   
    	int i = l, j = m + 1, k = 1;
    	while (i <= m && j <= r)          //如果两个相邻数组有一个遍历完 即退出
    	{
    		if (c[i] <= c[j]) d[k++] = c[i++];
    		else  d[k++] = c[j++];
    	}
    
    	if (i > m)    //根据先退出的数组确定数组d[]之后的元素安排
    		for (int q = j; q <= r; q++)
    			d[k++] = c[q];
    	else
    		for (int q = i; q <= m; q++)
    			d[k++] = c[q];
    }
    
  • 快速排序

    int partition(int a[], int p, int r)  
    							//把第一个元素放到合适的位置 作为基准进行划分
    {
    	int i = p, j = r + 1;
    	int 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;
    }
    void quicksort(int a[], int p, int r)  //每次根据基准进行划分两个子问题求解
    {
    	if (p < r)
    	{
    		int q = partition(a, p, r);
    		quicksort(a, p, q - 1);
    		quicksort(a, q + 1, r);
    	}
    }
    

三、动态规划

  • 特点

    • 把原始问题划分为一系列子问题
  • 求解每个子问题仅一次,并将其结果保存在一个表中,以后用到时到时直接存取,不重复计算,节省计算时间
    • 自底向上地计算
  • 使用范围

    • 一类优化问题:可分为多个相关子问题,子问题的解被重复使用
  • 使用动态规划的条件

    • 优化子结构

      • 当一个问题的优化解包含了子问题的优化解时,这个问题具有优化子结构。
      • 缩小子问题集合,只需那些优化问题中包含的子问题,降低实现复杂性。
      • 优化子结构使得我们能自下向上地完成求解过程
    • 重叠子问题

      • 在问题的求解过程中,很多子问题的解将被多次使用
  • 动态规划解题步骤

  • 找出最优解的性质,并刻划其结构特征。

    • 递归地定义最优值。
  • 以自底向上的方式计算出最优值。

    • 根据计算最优值时得到的信息,构造最优解
  • 优化子结构的分类

    • 编号动态规划:输入为x1,x2,…,xnx1,x2,…,xn,子问题是x1,x2,…,xix1,x2,…,xi,子问题复杂性为O(n)O(n)(最大不下降子序列问题)
    • 划分动态规划:输入为x1,x2,…,xnx1,x2,…,xn,子问题为xi,xi+1,…,xjxi,xi+1,…,xj,子问题复杂性是O(n2)O(n2)(矩阵链乘问题)
    • 数轴动态规划:输入为x1,x2,…,xnx1,x2,…,xn和数字C,子问题为x1,x2,…,xix1,x2,…,xi,K(K≤C)K(K≤C),子问题复杂性O(nC)O(nC)(0-1背包问题)
    • 前缀动态规划:输入为x1,x2,…,xnx1,x2,…,xn和y1,y2,…,ymy1,y2,…,ym,子问题为x1,x2,…,xix1,x2,…,xi和y1,y2,…,yjy1,y2,…,yj,子问题复杂性是O(mn)O(mn)(最长公共子序列问题)
    • 树形动态规划:输入是树,其子问题为子树,子问题复杂性是子树的个数。(树中独立集合问题)
最长公共子序列(LCS) //这个需要注意的是最长公共子序列不是连续 即理解
  • 用c[i,j]表示Xi 和 Yj 的LCS的长度(直接保存最长公共子序列的中间结果不现实,需要先借助LCS的长度)。其中X = {x1 … xm},Y ={y1…yn},Xi = {x1 … xi},Yj={y1… yj}。可得递归公式如下:

    img

  • int LCSLength(char* str1, char* str2, int** b)
    {
    	int i, j, length1, length2, len;
    	length1 = strlen(str1);
    	length2 = strlen(str2);
    
    	//双指针的方法申请动态二维数组
    	int** c = new int* [length1 + 1]; //共有length1+1行
    	for (i = 0; i < length1 + 1; i++)
    		c[i] = new int[length2 + 1];//共有length2+1列
    
    	for (i = 0; i < length1 + 1; i++)
    		c[i][0] = 0;        //第0列都初始化为0
    	for (j = 0; j < length2 + 1; j++)
    		c[0][j] = 0;        //第0行都初始化为0
    
    	for (i = 1; i < length1 + 1; i++)
    	{
    		for (j = 1; j < length2 + 1; j++)
    		{
    			if (str1[i - 1] == str2[j - 1])//由于c[][]的0行0列没有使用,c[][]的第i行元素对应str1的第i-1个元素
    			{
    				c[i][j] = c[i - 1][j - 1] + 1;
    				b[i][j] = 0;          //输出公共子串时的搜索方向
    			}
    			else if (c[i - 1][j] > c[i][j - 1])
    			{
    				c[i][j] = c[i - 1][j];
    				b[i][j] = 1;   
    			}
    			else
    			{
    				c[i][j] = c[i][j - 1];
    				b[i][j] = -1;
    			}
    		}
    	}
    	/*
    	for(i= 0; i < length1+1; i++)
    	{
    		for(j = 0; j < length2+1; j++)
    		printf("%d ",c[i][j]);
    		printf("\n");
    	}
    	*/
    	len = c[length1][length2];
    	for (i = 0; i < length1 + 1; i++)    //释放动态申请的二维数组
    		delete[] c[i];
    	delete[] c;
    	return len;
    }
    void PrintLCS(int** b, char* str1, int i, int j)
    {
    	if (i == 0 || j == 0)
    		return;
    	if (b[i][j] == 0)
    	{
    		PrintLCS(b, str1, i - 1, j - 1);//从后面开始递归,所以要先递归到子串的前面,然后从前往后开始输出子串
    		printf("%c", str1[i - 1]);//c[][]的第i行元素对应str1的第i-1个元素
    	}
    	else if (b[i][j] == 1)
    		PrintLCS(b, str1, i - 1, j);
    	else
    		PrintLCS(b, str1, i, j - 1);
    }  
    
最大字段和 //这个是连续的 注意和最长公共子序列对比比较
  • 分治法

    最大子段和问题的分治策略是:
    (1) 划分: 按照平衡子问题的原则, 将序列( a 1 , a 2 , ⋯, an )划分成长度相同的两个 子序列( a 1 , ⋯, a n / 2 )和( a n / 2 + 1 ,⋯, an ) ,则会出现以下 3 种情况:
    ① a1 , ⋯, an 的最大子段和 = a 1 , ⋯, a n / 2 的最大子段和;
    ② a1 , ⋯, an 的最大子段和 = a n / 2 + 1 , ⋯, an 的最大子段和;
    ③ a1 , ⋯, an 的最大子段和 = ai + ⋯ + aj, 且 1 ≤ i ≤ n / 2 , n / 2 + 1 ≤ j ≤ n。
    (2) 求解子问题: 对于划分阶段的情况①和②可递归求解, 情况③需要分别计算
    s1 = max(ai ⋯⋯ a[2/n]),1 ≤ i ≤ n / 2 ; s2 = max(a[n/2+1]⋯⋯a[j]) ,n / 2 + 1 ≤ j ≤ n , 则 s1 + s2 为情况 ③ 的最大子段和。
    (3) 合并: 比较在划分阶段的 3 种情况下的最大子段和,取三者之中的较大者原问 题的解。
    
    • 代码

      代码很好理解:
      1、分治 最大和即为三种情况 要么全在划分的基准前面,要么全在基准的后面,要么包含了基准      (即连续)
      2、每次划分的两个子问题 求出最优解,然后找出包含这个最优解的更大的一个最优解
      3、最后即可获得结果
      
    int maxsubsum(int* a, int left, int right)
    {
    	int sum = 0;
    	if (left == right) sum = a[left] > 0 ? a[left] : 0;
    	else
    	{
    		int center = (left + right) / 2;
    		int leftsum = maxsubsum(a, left, center); //划分左边的最大值
    		int rightsum = maxsubsum(a, center, right); //划分右边的最大值
    		int s1 = 0;
    		int lefts = 0;
    		int s2 = 0;
    		int rights = 0;
    		//下面即为第三种情况 ,包含划分的基准 ,所以是连续的。
    		for (int i = center; i >= left; i--) 
    		{
    			lefts += a[i];
    			if (lefts > s1) s1 = lefts;
    		}
    
    		for (int i = center + 1; i <= right; i++)
    		{
    			rights += a[i];
    			if (rights > s2) s2 = rights;
    		}
    		sum = s1 + s2;
    		if (sum > leftsum) sum = leftsum;
    		if (sum > rightsum) sum = rightsum;
    	}
    	
    
    	return sum;
    }
    int maxsum(int n, int* a)
    {
    	return maxsubsum(a, 1, n);
    }
    
  • 动态规划

    • 若记b[j]=max(a[i]+a[i+1]+…+a[j]),其中1<=i<=j,并且1<=j<=n。则所求的最大子段和为max b[j],1<=j<=n。

      由b[j]的定义可易知,当b[j-1]>0时b[j]=b[j-1]+a[j],否则b[j]=a[j]。故b[j]的动态规划递归式为:
      b[j]=max(b[j-1]+a[j],a[j]),1<=j<=n

    • 代码:

      int maxsum(int n,int *a)
      {
      	int sum =0 ,b=0;
      	for(int i = 0;i<=n;i++)
      	{
      		if(b>0) b+=a[i];
      		else b= a[i];
      		if(b>sum) sum=b;
          }
      }//这个DP代码相当好理解  期中考试也考过 ,应该属于重点
      
矩阵连乘
  • 递推关系

    设计算A[i:j],1≤i≤j≤n,所需要的最少数乘次数m[i,j],则原问题的最优值为m[1,n]。

    当i=j时,A[i:j]=Ai,因此,m[i][i]=0,i=1,2,…,n
    当i<j时,若A[i:j]的最优次序在Ak和Ak+1之间断开,i<=k<j,则:m[i][j]=m[i][k]+m[k+1][j]+pi-1pkpj。由于在计算是并不知道断开点k的位置,所以k还未定。不过k的位置只有j-i个可能。因此,k是这j-i个位置使计算量达到最小的那个位置。

    综上,有递推关系如下:

    ​ 当i=j m[i] [j] = 0

    ​ 当i<j m[i] [j] = min{ m[i] [k]+m[k+1] [j]+Pi-1*Pk*Pj }

  • 构造最优解

    若将对应m[i] [j]的断开位置k记为s[i] [j],在计算出最优值m[i] [j]后,可递归地由s[i] [j]构造出相应的最优解。s[i] [j]中的数表明,计算矩阵链A[i:j]的最佳方式应在矩阵Ak和Ak+1之间断开,即最优的加括号方式应为(A[i:k])(A[k+1:j)。因此,从s[1] [n]记录的信息可知计算A[1:n]的最优加括号方式为(A[1:s[1] [n]])(A[s[1] [n]+1:n]),进一步递推,A[1:s[1] [n]]的最优加括号方式为(A[1:s[1] [s[1] [n]]])(A[s[1] [s[1] [n]]+1:s[1] [s[1] [n]]])。同理可以确定A[s[1][n]+1:n]的最优加括号方式在s[s[1] [n]+1] [n]处断开…照此递推下去,最终可以确定A[1:n]的最优完全加括号方式,及构造出问题的一个最优解。

  • 代码

    void matirxchain(int* p, int n, int** m, int** s)
    {
    	for (int i = 0; i <= n; i++)
    		m[i][i] = 0;
    	for(int r = 2; r <= n ;r++) //r 为步幅 即使递推 最开始有 相邻两个比较  -> 相邻三个比较 ....->相邻n-1个比较,其中越往后的包含之前的。
    		for (int i = 1; i <= n - r + 1; i++)
    		{
    			int j = i + r - 1;
    			m[i][j] = m[i + 1][j] + p[i - 1] * p[i] * p[j];
    			s[i][j] = i; //s[i][j]记录最优断开位置
    			for (int k = i + 1; k < j; k++) //选出最小的 m[i][j];j-i-1 = r-2
    			{
    				int t = m[i][k] + m[k + 1][j] + p[i - 1] * p[i] * p[j];
    				if (t < m[i][j])
    				{
    					m[i][j] = t;
    					s[i][j] = k;
    				}
    			}
    		}
    }
    
    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;
    }
    
    凸多边形最优三角形
    • 问题定义:

      • 凸多边形的三角剖分:将凸多边形分割成互不相交的三角形的弦的集合T。

      • 最优剖分:给定凸多边形P,以及定义在由多边形的边和弦组成的三角形上的权函数w。要求确定该凸多边形的三角剖分,使得该三角剖分中诸三角形上权之和为最小。

        img

    • 最优子结构性质:

      若凸(n+1)边形P={V0,V1……Vn}的最优三角剖分T包含三角形V0VkVn,1<=k<=n,则T的权为三个部分权之和:三角形V0VkVn的权,多边形{V0,V1……Vk}的权和多边形{Vk,Vk+1……Vn}的权之和。

      可以断言,由T确定的这两个子多边形的三角剖分也是最优的。因为若有{V0,V1……Vk}和{V0,V1……Vk}更小权的三角剖分,将导致T不是最优三角剖分的矛盾。因此,凸多边形的三角剖分问题具有最优子结构性质。

    • 递推关系:

      设t[i][j],1<=i<j<=n为凸多边形{Vi-1,Vi……Vj}的最优三角剖分所对应的权值函数值,即其最优值。最优剖分包含三角形Vi-1VkVj的权,子多边形{Vi-1,Vi……Vk}的权,子多边形{Vk,Vk+1……Vj}的权之和。

      • 递推式子:

      img

    • 代码

      int MinWeightTriangulation(int n, int** t, int** s) //类似矩阵连乘问题
                                                          //产生最优解
      {
          for (int i = 1; i <= n; i++)
        {
              t[i][i] = 0;
          }
          for (int r = 2; r <= n; r++) //r为当前计算的链长(子问题规模)    
          {
              for (int i = 1; i <= n - r + 1; i++)//n-r+1为最后一个r链的前边界    
              {
                  int j = i + r - 1;//计算前边界为r,链长为r的链的后边界    
      
                  t[i][j] = t[i + 1][j] + Weight(i - 1, i, j);//将链ij划分为A(i) * ( A[i+1:j] )这里实际上就是k=i  
      
                  s[i][j] = i;
      
                  for (int k = i + 1; k < j; k++)
                  {
                      //将链ij划分为( A[i:k] )* (A[k+1:j])     
                      int u = t[i][k] + t[k + 1][j] + Weight(i - 1, k, j);
                      if (u < t[i][j])
                      {
                          t[i][j] = u;
                          s[i][j] = k;
                      }
                  }
              }
          }
          return t[1][N - 2];
      }
      
      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 << "三角剖分顶点:V" << i - 1 << ",V" << j << ",V" << s[i][j] << endl;
      }
      
      int Weight(int a, int b, int c)  //权值
      {
          return weight[a][b] + weight[b][c] + weight[a][c];
      }
      
    多边形游戏
    • 问题描述:

      给定N个顶点的多边形,每个顶点标有一个整数,每条边上标有+(加)或是×(乘)号,并且N条边按照顺时针依次编号为1~N。下图给出了一个N=4个顶点的多边形。游戏规则 :(1) 首先,移走一条边。 (2) 然后进行下面的操作: 选中一条边E,该边有两个相邻的顶点,不妨称为V1和V2。对V1和V2顶点所标的整数按照E上所标运算符号(+或是×)进行运算,得到一个整数;用该整数标注一个新顶点,该顶点代替V1和V2 。 持续进行此操作,直到最后没有边存在,即只剩下一个顶点。该顶点的整数称为此次游戏的得分(Score)

    • 问题分析:

      • 解决该问题可用动态规划中的最优子结构性质来解。

      • 设所给的多边形的顶点和边的顺时针序列为op[1],v[1],op[2],v[2],op[3],…,op[n],v[n] 其中,op[i]表示第i条边所对应的运算符,v[i]表示第i个顶点上的数值,i=1~n。

      • 在所给的多边形中,从顶点i(1<=i<=n)开始,长度为j(链中有j个顶点)的顺时针链p(i,j)可表示为v[i],op[i+1],…,v[i+j-1],如果这条链的最后一次合并运算在op[i+s]处发生(1<=s<=j-1),则可在op[i+s]处将链分割为两个子链p(i,s)和p(i+s,j-s)。

      • 设m[i,j,0]是链p(i,j)合并的最小值,而m[i,j,1]是最大值。若最优合并在op[i+s]处将p(i,j)分为两个长度小于j的子链的最大值和最小值均已计算出。即:a=m[i,s,0] b=m[i,s,1] c=m[i,s,0] d=m[i,s,1]

      • (1) 当op[i+s]=’+’时,m[i,j,0]=a+c ;m[i,j,1]=b+d

    • (2) 当op[i+s]=’*’时,m[i,j,0]=min{ac,ad,bc,bd} ; m[i,j,1]=max{ac,ad,bc,bd}

    • 由于最优断开位置s有1<=s<=j-1的j-1中情况。 初始边界值为 m[i,1,0]=v[i] 1<=i<=n m[i,1,1]=v[i] 1<=i<=n。因为多变形式封闭的,在上面的计算中,当i+s>n时,顶点i+s实际编号为(i+s)modn。按上述递推式计算出的m[i,n,1]记为游戏首次删除第i条边后得到的最大得分。

    • 算法具体代码如下:

      void MinMax(int n, int i, int s, int j, int& minf, int& maxf)
      {
        int e[5];
          int a = m[i][s][0], b = m[i][s][1];     //
        int r = (i + s - 1) % n + 1;//多边形的实际顶点编号  
          int c = m[r][j - s][0], d = m[r][j - s][1];
      
          if (op[r - 1] == '+')
          {
              minf = a + c;
              maxf = b + d;
          }
          else
          {
              e[1] = a * c;
              e[2] = a * d;
              e[3] = b * c;
              e[4] = d * b;
              minf = e[1];
              maxf = e[1];
      
              for (int r = 2; r < N; r++)
              {
                  if (minf > e[r])minf = e[r];
                  if (maxf < e[r])maxf = e[r];
              }
          }
      }
      
      int PloyMax(int n, int& p)
      {
          int minf, maxf;
          for (int j = 2; j <= n; j++) //迭代链的长度  
          {
              for (int i = 1; i <= n; i++)//迭代首次删掉第i条边  
              {
                  for (int s = 1; s < j; s++) //迭代断开长度
                  {
                      MinMax(n, i, s, j, minf, maxf);
                      if (m[i][j][0] > minf) m[i][j][0] = minf;
                      if (m[i][j][1] < maxf) m[i][j][1] = maxf;
                  }
              }
          }
      
          int temp = m[1][n][1];
          p = 1;
      
          for (int i = 2; i <= n; i++)
          {
              if (temp < m[i][n][1])
              {
                  temp = m[i][n][1];
                  p = i;
              }
          }
          return temp;
      }
      
    图像压缩
    • 问题描述

      • 在计算机中,常用像素点的灰度值序列{p1,p1,……pn}表示图像。其中整数pi,1<=i<=n,表示像素点i的灰度值。通常灰度值的范围是0~255。因此最多需要8位表示一个像素。

      • 压缩的原理就是把序列{p1,p1,……pn*}进行设断点,将其分割成一段一段的。分段的过程就是要找出断点,让一段里面的像素的最大灰度值比较小,那么这一段像素(本来需要8位)就可以用较少的位(比如7位)来表示,从而减少存储空间***。

      • b代表bits,l代表length,分段是,b[i]表示每段一个像素点需要的最少存储空间(少于8位才有意义),l[i]表示每段里面有多少个像素点,s[i]表示从0到i压缩为一共占多少存储空间。

      • 如果限制l[i]<=255,则需要8位来表示l[i]。而b[i]<=8,需要3位表示b[i]。所以每段所需的存储空间为l[i]}的最优分段,使得依此分段所需的存储空间最小.

      • 这里我们引入两个固定位数的值来表示,①我们用3位数字来表示当前组的每一位像素的的位数②我们引入8来表示当前组中像素点的个数(header=3+8=11) 因为我们在这里规定了一组中最多存储–>0~255个数字,而一个灰度值最多有8位(2^3),所以我们可以用即3位数字来表示当前组的像素位数(注意这里都是二进制)

        • 最优子结构性质

          设l[i],b[i],1<=i<=m是{p1,p1,……pn}的一个最优分段,则l[1],b[1]是{p1,……,pl[1]}的一个最优分段,且l[i],b[i],2<=i<=m是{pl[1]+1,……,pn}的一个最优分段。即图像压缩问题满足最优子结构性质。

          S[n]来记录第i个数字的最优处理方式得到的最优解。l[n]中来记录第当前第i个数所在组中有多少个数。而b[n]中存的数为当前组的像素位数。

        • 递推关系:

          设s[i], 1<=i<=n是像素序列{p1,p1,……pi}的最优分段所需的存储位数,

          则s[i]为前i-k个的存储位数加上后k个的存储空间。由最优子结构性质可得:

                  s[i] = min(k>=1  and  k<=min) {s[i-k]+k*bmax(i-k+1,i)+11}
          
          
                         bmax(i,j) = [log(max(k>=1 and k<= j){pk} +1 )]
          
  • 例子

    • {6, 5, 7,5, 245, 180, 28,28,19, 22, 25,20}这是一组灰度值序列。我们按照默认的解体方法来看----一共12个数字,所以12*8=96位来表示

      下面将其分组

      img

      这里我们将他们分为三组:
      
      第一组4个数,最大是7所以用3位表示;
      
      第二组2个数,最大是245所以用8位表示;
      
      第三组6个数,最大是28所以用5位表示;
      
      这个时候,我们最后得到了最后的位数结果为:4*3+2*8+6*5+11*3=91
      
    • 求像素序列4,6,5,7,129,138,1的最优分段。

      具体过程

      img

  • 说明

    就是说: 把每个灰度值 计算出其应该有的灰度值序列length,将其length相同的划分一组,每个相同灰度值length的组都共用一个11  ,然后动态规划,一层层的选出最优解,直到最后
    
  • 代码

    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]);//计算像素点p需要的存储位数  
            int bmax = b[i];
            s[i] = s[i - 1] + bmax;
            l[i] = 1;
    
            for (int j = 2; j <= i && j <= Lmax; j++)
            {
                if (bmax < b[i - j + 1])
                {
                    bmax = b[i - j + 1];
                }
    
                if (s[i] > s[i - j] + j * bmax)
                {
                    s[i] = s[i - j] + j * bmax;
                    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;
    }
    
    void Traceback(int n, int& i, int s[], int l[])
    {
        if (n == 0)
            return;
        Traceback(n - l[n], i, s, l);
        s[i++] = n - l[n];//重新为s[]数组赋值,用来存储分段位置  
    }
    
    void Output(int s[], int l[], int b[], int n)
    {
        //在输出s[n]存储位数后,s[]数组则被重新赋值,用来存储分段的位置  
        cout << "图像压缩后的最小空间为:" << s[n] << endl;
        int m = 0;
        Traceback(n, m, s, l);
        s[m] = n;
        cout << "将原灰度序列分成" << m << "段序列段" << 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;
        }
    }
    
    电路布线 //这个看懂递推公式即可明白, 图很详细了
    • 问题描述

      在一块电路板的上、下两端分别有n个接线柱。根据电路设计,要求用导线(i,π(i)) 将上端接线柱i与下端接线柱π(i)相连,如下图。其中,π(i),1≤ i ≤n,是{1,2,…,n}的一个排列。导线(I, π(i))称为该电路板上的第i条连线。对于任何1 ≤ i ≤ j ≤n,第i条连线和第j条连线相交的充要条件是π(i)> π(j).

         π(i)={8,7,4,2,5,1,9,3,10,6}
      
    • 最优子结构

      记N(i,j) = {t|(t, π(t)) ∈ Nets,t ≤ i, π(t) ≤ j }. N(i,j)的最大不相交子集为MNS(i,j)Size(i,j)=|MNS(i,j)|。

      1)i=1时:

                MNS(1,j) = N(1,j) = {  j< π(1)  为∅
                                      j>=π(1)     为 {(1,π(1)}
                       }
      

​ 2) 当i>1时

     ① j <π(i)。此时,(i,π(i)) 不属于N(i,j)。故在这种情况下,N(i,j) = N(i-1,j),从而Size(i,j)=Size(i-1,j)。
      
     ② j ≥π(i)。此时,若(i, π(i))∈MNS(i,j),则对任意(t, π(t))∈MNS(i,j)有t < i且π(t)< π(i);否则,(t, π(t))与(i, π(i))相交。在这种情况下MNS(i,j)-{(i, π(i))}是N(i-1, π(i)-1)的最大不相交子集。否则,子集MNS(i-1, π(i)-1)∪{(i, π(i))}包含于N(i,j)是比MNS(i,j)更大的N(i,j)的不相交子集。这与MNS(i,j)的定义相矛盾。
      
    若(i, π(i))不属于MNS(i,j),则对任意(t, π(t))∈MNS(i,j),有t<i。从而MNS(i,j)包含于N(i-1,j),因此,Size(i,j)≤Size(i-1,j)。
      
    另一方面,MNS(i-1,j)包含于N(i,j),故又有Size(i,j) ≥Size(i-1,j),从而Size(i,j)= Size(i-1,j)。
    
    可以放进第一层如图
  • 在这里插å¥å›¾ç‰‡æè¿°

  • 否则如图

  • 递推关系

    在这里插入图片描述

  • 代码:

    void MNS(int C[], int n, int** size)
    {
      for (int j = 0; j < C[1]; j++) //i=1 时, j 如果 < c[1] 即为0
        {
          size[1][j] = 0;
        }
    
        for (int j = C[1]; j <= n; j++)
        {
            size[1][j] = 1;   // i =1 j如果 >=c[1] 则为1
        }
    
        for (int i = 2; i < n; i++)
        {
            for (int j = 0; j < C[i]; j++)
            {
                size[i][j] = size[i - 1][j];//当i<c[i]的情形  
            }
            for (int j = C[i]; j <= n; j++)
            {
                //当j>=c[i]时,考虑(i,c[i])是否属于MNS(i,j)的两种情况  
                size[i][j] = max(size[i - 1][j], size[i - 1][C[i] - 1] + 1);
            }
        }
        size[n][n] = max(size[n - 1][n], size[n - 1][C[n] - 1] + 1);
    }
    
    void Traceback(int C[], int** size, int n, int Net[], int& m)
    {
        int j = n;
        m = 0;
        for (int i = n; i > 1; i--)
        {
            if (size[i][j] != size[i - 1][j])//此时,(i,c[i])是最大不相交子集的一条边  
            {
                Net[m++] = i;
                j = C[i] - 1;//更新扩展连线柱区间  
            }
        }
        if (j >= C[1])//处理i=1的情形  
        {
            Net[m++] = 1;
        }
    }
    
流水作业调度问题和Johnson法则 //这个我主要看了 johnson法则 图很详细
  • 问题描述:

    n个作业{1,2,…,n}要在由2台机器M1和M2组成的流水线上完成加工。每个作业加工的顺序都是先在M1上加工,然后在M2上加工。M1和M2加工作业i所需的时间分别为ai和bi。流水作业调度问题要求确定这n个作业的最优加工顺序,使得从第一个作业在机器M1上开始加工,到最后一个作业在机器M2上加工完成所需的时间最少

  • 问题分析:

    直观上,一个最优调度应使机器M1没有空闲时间,且机器M2的空闲时间最少。在一般情况下,机器M2上会有机器空闲和作业积压2种情况。设全部作业的集合为N={1,2,…,n}。S是N的作业子集。在一般情况下,机器M1开始加工S中作业时,机器M2还在加工其他作业,要等时间t后才可利用。将这种情况下完成S中作业所需的最短时间记为T(S,t)。流水作业调度问题的最优值为T(N,0)。  
    
       设*π*是所给n个流水作业的一个最优调度,它所需的加工时间为 a*π(1)*+T’。其中T’是在机器M2的等待时间为b*π(1)*时,安排作业*π(2)*,…,*π(n)*所需的时间。
        
       记S=N-{*π(1)*},则有T’=T(S,b*π(1)*)。
        
       证明:事实上,由T的定义知T’>=T(S,b*π(1)*)。若T’>T(S,b*π(1)*),设*π*’是作业集S在机器M2的等待时间为b*π(1)*情况下的一个最优调度。则*π(1)*,*π'(2)*,…,*π'(n)*是N的一个调度,且该调度所需的时间为a*π(1)*+T(S,b*π(1)*)<a*π(1)*+T’。这与*π*是N的最优调度矛盾。故T’<=T(S,b*π(1)*)。从而T’=T(S,b*π(1)*)。这就证明了流水作业调度问题具有最优子结构的性质。
        
       由流水作业调度问题的最优子结构性质可知:
    

    从公式(1)可以看出,该问题类似一个排列问题,求N个作业的最优调度问题,利用其子结构性质,对集合中的每一个作业进行试调度,在所有的试调度中,取其中加工时间最短的作业做为选择方案。将问题规模缩小。公式(2)说明一般情况下,对作业集S进行调度,在M2机器上的等待时间,除了需要等该部件在M1机器上完成时间,还要冲抵一部分原来的等待时间, 如果冲抵已成负值,自然仍需等待M1将作业做完,所以公式取max{t-ai,0}

在这里插å¥å›¾ç‰‡æè¿°

  • 流水作业调度问题的Johnson算法

    img

    四大步骤

    ①令N1={i|a[i]< b[i]},N2={i|a[i]>=b[i]}
    ②按key值升序排序
    ③连接N1&N2
    ④计算总时间

  • 说明

    为什么要保证 N1中ai < bi 且做ai的非减序排列
               N2中 ai > bi 且做bi的非增序排列
    并且执行任务的时候,先从N1开始,然后执行N2的任务
    	ai < bi 保证第i个作业的ai完成后,bi可直接在机器2排序的等待
    	ai >= bi 因为之前积攒的bi相当多,所以此时的ai完成后,之前积攒的bi-1也可以有时间工			  作,
    	N1増序和N2减序实际上是为了保证两个机器都尽量没有空闲时间
    
  • 代码

    class Jobtype
    {
    public:
        int operator <=(Jobtype a) const
      {
            return(key <= a.key);
        }
        int key, index;
        bool job;
    };
    int FlowShop(int n, int a[], int b[], int c[])
    {
        Jobtype* d = new Jobtype[n];
        for (int i = 0; i < n; i++)
        {
            d[i].key = a[i] > b[i] ? b[i] : a[i];//按Johnson法则分别取对应的b[i]或a[i]值作为关键字  
            d[i].job = a[i] <= b[i];//给符合条件a[i]<b[i]的放入到N1子集标记为true  
            d[i].index = i;
        }
    
        BubbleSort(d, n);//对数组d按关键字升序进行排序  
    
        int j = 0, k = n - 1;
    
        for (int i = 0; i < n; i++)
        {
            if (d[i].job)
            {
                c[j++] = d[i].index;//将排过序的数组d,取其中作业序号属于N1的从前面进入  
            }
            else
            {
                c[k--] = d[i].index;//属于N2的从后面进入,从而实现N1的非减序排序,								    N2的非增序排序  
            }
        }
    
        j = a[c[0]];
        k = j + b[c[0]]; //每个任务在M1机器上完成后才能在M2上完成,即a[i]之后才有                       //  b[i];
        for (int i = 1; i < n; i++)
        {
            j += a[c[i]];//M1在执行c[i]作业的同时,M2在执行c[i-1]号作业,最短执行时间取决于M1与M2谁后执行完  
            k = j < k ? k + b[c[i]] : j + b[c[i]];//计算最优加工时间  
        }
    
        delete d;
        return k;
    }
    
    //冒泡排序  
    void BubbleSort(Jobtype* d, int n)
    {
        int i, j, flag;
        Jobtype temp;
    
        for (i = 0; i < n; i++) {
            flag = 0;
            for (j = n - 1; j > i; j--) {
                //如果前一个数大于后一个数,则交换    
                if (d[j] <= d[j - 1]) {
                    temp = d[j];
                    d[j] = d[j - 1];
                    d[j - 1] = temp;
                    flag = 1;
                }
            }
            //如果本次排序没有进行一次交换,则break,减少了执行之间。    
            if (flag == 0) {
                break;
            }
        }
    }
    
    0-1背包问题
    • 问题描述

      • 给定n种物品和一背包。物品i的重量是wi,其价值为vi,背包的容量为C。问:应如何选择装入背包的物品,使得装入背包中物品的总价值最大?

      • 形式化描述:给定c >0, wi >0, vi >0 , 1≤i≤n.要求找一n元向量(x1,x2,…,xn,), xi∈{0,1}, ∋ ∑ wi xi≤c,且∑ vi xi达最大.即一个特殊的整数规划问题。

    • 递推式子:

      img

      F(i,j)表示前 i 个物体(1≤ i ≤n)在背包承重为 j 时,所能达到的最大价值。如果把它看成一个状态的话,那么也就是说,状态F(i,j)的值等于状态F(i-1,j)、状态F(i-1,j-wi)与vi之和 两者中的最大值。那么要求 i 个物体在一定承重背包中可取的最大价值,只需考虑 i-1 个物体在不同承重量(0,1, 2, …,W)的背包下可取的最大价值。类似地,要想知道 i-1 个物体在一定承重的背包中可取的最大价值,只需知道 i-2 个物体在不同承重量的背包中可取的最大价值。以此类推,直到所考虑的物体个数变为 1 。

    • 代码:

      void Knapsack(int v[], int w[], int c, int n, int m[][10])
      {
        int jMax = min(w[n] - 1, c);//背包剩余容量上限 范围[0~w[n]-1]  
          for (int j = 0; j <= jMax; j++)
        {
              m[n][j] = 0;
          }
      
          for (int j = w[n]; j <= c; j++)//限制范围[w[n]~c]  
          {
              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++)//背包不同剩余容量j<=jMax<c  
              {
                  m[i][j] = m[i + 1][j];//没产生任何效益  
              }
      
              for (int j = w[i]; j <= c; j++) //背包不同剩余容量j-wi >c  
              {
                  m[i][j] = max(m[i + 1][j], m[i + 1][j - w[i]] + v[i]);//效益值增长vi   
              }
          }
          m[1][c] = m[2][c];
          if (c >= w[1])
          {
              m[1][c] = max(m[1][c], m[2][c - w[1]] + v[1]);
          }
      }
      
      //x[]数组存储对应物品0-1向量,0不装入背包,1表示装入背包  
      void Traceback(int m[][10], int w[], int c, int n, int x[])
      {
          for (int i = 1; i < n; i++)
          {
              if (m[i][c] == m[i + 1][c])
              {
                  x[i] = 0;
              }
              else
              {
                  x[i] = 1;
                  c -= w[i];
              }
          }
          x[n] = (m[n][c]) ? 1 : 0;
      }
      
    二叉搜索树

    在这里插入图片描述

    • 问题描述

       设 S={x1, x2, ···, xn} 是一个有序集合,且x1, x2, ···, xn表示有序集合的二叉搜索树利用二叉树的顶点存储有序集中的元素,而且具有性质:存储于每个顶点中的元素x 大于其左子树中任一个顶点中存储的元素,小于其右子树中任意顶点中存储的元素。二叉树中的叶顶点是形如(xi, xi+1) 的开区间。在表示S的二叉搜索树中搜索一个元素x,返回的结果有两种情形:
      
      (1) 在二叉树的内部顶点处找到: x = xi
      (2) 在二叉树的叶顶点中确定: x∈ (xi , xi+1)
      
      设在情形(1)中找到元素x = xi的概率为bi;在情形(2)中确定x∈ (xi , xi+1)的概率为ai。其中约定x0= -∞ , xn+1= + ∞ ,有
      
      
      
      集合{a0,b1,a1,……bn,an}称为集合S的存取概率分布。 
      

      最优二叉搜索树:在一个表示S的二叉树T中,设存储元素xi的结点深度为ci;叶结点(xj,xj+1)的结点深度为dj。

    • 注:在检索过程中,每进行一次比较,就进入下面一层,对于成功的检索,比较的次数就是所在的层数加1。对于不成功的检索,被检索的关键码属于那个外部结点代表的可能关键码集合,比较次数就等于此外部结点的层数。对于图的内结点而言,第0层需要比较操作次数为1,第1层需要比较2次,第2层需要3次。
      
       p表示在二叉搜索树T中作一次搜索所需的平均比较次数。P又称为二叉搜索树T的平均路长,在一般情况下,不同的二叉搜索树的平均路长是不同的。对于有序集S及其存取概率分布(a0,b1,a1,……bn,an),在所有表示有序集S的二叉搜索树中找出一棵具有最小平均路长的二叉搜索树。    
      
      
       设Pi是对ai检索的概率。设qi是对满足ai<X<ai+1,0<=i<=n的标识符X检索的概率, (假定a0=--∞且an+1=+ ∞)。
      
      
      
        对于有n个关键码的集合,其关键码有n!种不同的排列,可构成的不同二叉搜索树有棵。(n个结点的不同二叉树,卡塔兰数)。如何评价这些二叉搜索树,可以用树的搜索效率来衡量。例如:标识符集{1, 2, 3}={do, if, stop}可能的二分检索树为:
      
      
      
       若P1=0.5, P2=0.1, P3=0.05,q0=0.15, q1=0.1, q2=0.05, q3=0.05,求每棵树的平均比较次数(成本)。     
      
       Pa(n)=1 × p1 + 2 × p2+3 × p3 + 1×q0 +2×q1+ 3×( q2 + q3 ) =1 × 0.5+ 2 × 0.1+3 ×0.05 + 1×0.05 +2×0.1+ 3×( 0.05 + 0.05 ) =1.5
      
       Pb(n)=1 × p1 + 2 × p3+3 × p2 + 1×q0 +2×q3 + 3×( q1 + q2 ) =1 × 0.5+ 2 × 0.05 + 3 ×0.1 + 1×0.15 +2×0.05+ 3×( 0.1 + 0.05 ) =1.6
      
       Pc(n)=1 × p2 + 2 × (p1 +  p3) + 2×(q0 +q1 +q2 + q3 ) =1 × 0.1+ 2 × (0.5 + 0.05) + 2×(0.15 + 0.1 + 0.05 + 0.05) =1.9
      
       Pd(n)=1 × p3 + 2 × p1+3 × p2 + 1 × q3+2 × q0 +3 × (q1+ q2) =1 × 0.05 + 2 × 0.5 + 3 × 0.1 + 1×0.05 + 2 × 0.15 + 3 × (0.1 + 0.05) =2.15
      
       Pe(n)=1 × p3 + 2 × p2+3 × p1 + 1 × q3+2 × q2 +3 × (q0 + q1) =1 × 0.05 + 2 × 0.1+ 3 × 0.5 + 1×0.05 + 2 × 0.15 + 3 × (0.15 + 0.1) =2.85
      
       因此,上例中的最小平均路长为Pa(n)=1.5。
      
       可以得出结论:结点在二叉搜索树中的层次越深,需要比较的次数就越多,因此要构造一棵最小二叉树,一般尽量把搜索概率较高的结点放在较高的层次。
      
    • 最优子结构性质

      假设选择 k为树根,则 1, 2, …, k-1 和a0, a1, …, ak-1 都将位于左子树 L 上,其余结点 (k+1, …, n 和 ak, ak+1, …, an)位于右子树 R 上。设COST(L) 和COST(R) 分别是二分检索树T的左子树和右子树的成本。则检索树T的成本是:P(k)+ COST(L) + COST(R) + …… 。若 T 是最优的,则上式及 COST(L) 和COST(R) 必定都取最小值。
      
      证明:二叉搜索树T 的一棵含有顶点xi , ··· , xj和叶顶点(xi-1 , xi ) , ··· , ( xj , xj+1)的子树可以看作是有序集{ xi , ··· , xj}关于全集为 { xi-1 , xj+1 }的一棵二叉搜索树(T自身可以看作是有序集) 。根据S 的存取分布概率,在子树的顶点处被搜索到的概率是:。{xi , ··· , xj}的存储概率分布为{ai-1, bi, …, bj, aj },其中,ah,bk分别是下面的条件概率:。
      
       设Tij是有序集{xi , ··· , xj}关于存储概率分布为{ai-1, bi, …, bj, aj}的一棵最优二叉搜索树,其平均路长为pij,Tij的根顶点存储的元素xm,其左子树Tl和右子树Tr的平均路长分别为pl和pr。由于Tl和Tr中顶点深度是它们在Tij中的深度减1,所以得到:
      
      
      
       由于Ti是关于集合{xi , ··· , xm-1}的一棵二叉搜索树,故Pl>=Pi,m-1。若Pl>Pi,m-1,则用Ti,m-1替换Tl可得到平均路长比Tij更小的二叉搜索树。这与Tij是最优二叉搜索树矛盾。故Tl是一棵最优二叉搜索树。同理可证Tr也是一棵最优二叉搜索树。因此最优二叉搜索树问题具有最优子结构性质。
      
      
    • 递推关系

      根据最优二叉搜索树问题的最优子结构性质可建立计算pij的递归式如下:
      
       初始时:
      
       记 wi,j pi,j为m(i,j) ,则m(1,n)=w1,n p1,n=p1,n为所求的最优值。计算m(i,j)的递归式为:
      
    • 求解过程

      1)没有内部节点时,构造T[1][0],T[2][1],T[3][2]……,T[n+1][n]
      
      2)构造只有1个内部结点的最优二叉搜索树T[1][1],T[2][2]…, T[n][n],可以求得m[i][i] 同时可以用一个数组存做根结点元素为:s[1][1]=1, s[2][2]=2…s[n][n]=n
      
      3)构造具有2个、3个、……、n个内部结点的最优二叉搜索树。
      
      ……
      
      r ( 起止下标的差)
      0   T[1][1], T[2][2]       , …,     T[n][n],
      1   T[1][2], T[2][3], …,T[n-1][n],
      2   T[1][3], T[2][4], …,T[n-2][n],
      ……
      r   T[1][r+1], T[2][r+2], …,T[i][i+r],…,T[n-r][n]
      ……
      n-1   T[1][n]
      
    • 代码

      void OptionalBInaryTree(int * a, int *b, int n, int** m, int** s, int** w)
      {
      	for (int i = 1; i <= n; i++)
      	{
      		w[i + 1][i] = a[i];
      		m[i + 1][i] = 0;
      	}
      	//能找到结点X = Xi的概率为 a[i];
      	// 找到结点x 在叶子结点[xi,Xi+1]的概率为  b[i];
      	//m[i][j]  储存xi到xj 结点路径的长度的最优化分
      	//w[i][j]  xi到xj之间结点的概率和
      	//s[i][j] 储存 xi 到 xj最短路径的划分点
      	for (int r = 0; r < n; r++)
      	{
      		for (int i = 1; i <= n - r; i++)
      		{
      			int j = i + r;
      			w[i][j] = w[i][j - 1] + a[j] + b[j];
      			m[i][j] = m[i + 1][j];
      			s[i][j] = i;
      
      			for (int k = i + 1; k <= j; k++)
      			{
      				int t = m[i][k - 1] + m[k + 1][j];
      				if (t < m[i][j])
      				{
      					m[i][j] = t;
      					s[i][j] = k;
      				}
      			}
      			m[i][j] += w[i][j];
      		}
      	}
      }
      

四、贪心算法

思想及基本元素
  • 介绍

     贪心算法(又称贪婪算法)是指在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,只做出的是在某种意义上的局部最优解。
    
  • 贪心算法不是对所有问题都能得到整体最优解,关键是贪心策略的选择,选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。 贪心算法总是作出在当前看来最好的选择。也就是说贪心算法并不从整体最优考虑,它所作出的选择只是在某种意义上的局部最优选择。当然,希望贪心算法得到的最终结果也是整体最优的。虽然贪心算法不能对所有问题都得到整体最优解,但对许多问题它能产生整体最优解。如单源最短路径问题,最小生成树问题等。在一些情况下,即使贪心算法不能得到整体最优解,其最终结果却是最优解的很好近似。

  • 基本要素

  • 贪心选择性质

  • 最优子结构性质

  • 贪心算法的基本思路

    • 建立数学模型来描述问题
    • 把求解的问题分成若干个子问题
    • 对每一问题求解,得到子问题的局部最优解
    • 把子问题的局部最优解合成原来问题的一个解
  • 贪心算法适用的问题

    贪心策略适用的前提是:局部最优策略能导致产生全局最优解。实际上,贪心算法适用的情况很少。一般,对一个问题分析是否适用于贪心算法,可以先选择该问题下的几个实际数据进行分析,就可做出判断。

  • 贪心算法解题过程

    ◼需证明每一步所做的贪心选择最终导致问题的整体最优解

    ◼每一步贪心选择后,原问题比那位规模更小的问题

    ◼通过每一步贪心选择,最终可得到问题的整体最优解。

  • 贪心框架

    Greedy(C){
        s={};
        while (not solution(S)){  //集合S没有构成问题的一个解
          x=select(C);         //在候选集合中做出贪心选择
          if constraint(S,x)  //判断集合S中加入x后的解是否可行
     
    
  • 贪心示例

    • 活动安排问题

      • 问题描述

        活动安排问题就是要在所给的活动集合中选出最大的相容活动子集合,是可以用贪心算法有效求解的很好例子。该问题要求高效地安排一系列争用某一公共资源的活动。贪心算法提供了一个简单、漂亮的方法使得尽可能多的活动能兼容地使用公共资源。

      • 问题分析

        • 设有n个活动的集合E={1,2,…,n},其中每个活动都要求使用同一资源,如演讲会场等,而在同一时间内只有一个活动能使用这一资源。每个活动i都有一个要求使用该资源的起始时间si和一个结束时间fi,且si <fi 。如果选择了活动i,则它在半开时间区间[si, fi)内占用资源。若区间[si, fi)与区间[sj, fj)不相交,则称活动i与活动j是相容的。也就是说,当si≥fj或sj≥fi时,活动i与活动j相容。

        • 由于输入的活动以其完成时间的非减序排列,所以算法greedySelector每次总是选择具有最早完成时间的相容活动加入集合A中。该算法的贪心选择的意义是使剩余的可安排时间段极大化,以便安排尽可能多的相容活动。

        • 算法greedySelector的效率极高。当输入的活动已按结束时间的非减序排列,算法只需O(n)的时间安排n个活动,使最多的活动能相容地使用公共资源。如果所给出的活动未按非减序排列,可以用O(nlogn)的时间重排。

      • 举例 待安排的11个活动的开始时间和结束时间按结束时间非减序排列如下表。

        i1234567891011
        s[i]130535688212
        f[j]4567891011121314

        若被检查的活动i的开始时间Si小于最近选择的活动j的结束时间fi,则不选择活动i,否则选择活动i加入集合A中。

      • 代码

        int greedyselector(int []s,int []f,bool a[])
        {
        	int n = s.length(),j = 1,i,count = 1;
        	a[1] = true;
        	for(i = 2;i <= n;i++)
        	{
        		if(s[i] > f[j])
        		{
        			a[i] = true;
        			j = i;
        			count++;
        		}
        		else
        		a[i] = false;
        	}
        	return count;
        }
        
    • 背包问题

      不是0-1背包(0-1背包不能用贪心)

      • 问题描述

        与0-1背包问题类似,所不同的是在选择物品i装入背包时,可以选择物品i的一部分,而不一定要全部装入背包,1≤i≤n。

        这2类问题都具有最优子结构性质,极为相似,但背包问题可以用贪心算法求解,而0-1背包问题却不能用贪心算法求解。

      • 问题分析

        首先计算每种物品单位重量的价值Vi/Wi,然后,依贪心选择策略,将尽可能多的单位重量价值最高的物品装入背包。若将这种物品全部装入背包后,背包内的物品总重量未超过C,则选择单位重量价值次高的物品并尽可能多地装入背包。依此策略一直地进行下去,直到背包装满为止。

        对于0-1背包问题,贪心算法之所以不能得到最优解是因为在这种情况下,它无法保证最终能将背包装满,部分闲置的背包空间使每公斤背包空间的价值降低了。事实上,在考虑0-1背包问题时,应比较选择该物品和不选择该物品所导致的最终方案,然后再作出最好选择。由此就导出许多互相重叠的子问题。该类问题可采用动态规划算法。

      • 算法步骤

        • 计算每个物品的单位价值v[i]/w[i]
        • 按每个元素的单位价值降序排序
        • 装载物品(分为完全装载和部分装载)
      • 形式化描述

        给定C>0,Wi>0,Vi>0,1<=i<=n,要求找出一个n元向量(x1,x2,…xn),0<=xi<=1,1<=i<=n,使得sum(Wixi)<=C,而sum(ViXi)达到最大。

      • 代码

        void knapsack(int n,float m,float v[],float w[],float x[])
        {
        	sort(n,v,w);
        	float c = m;
        	for(int i = 1;i <= n;i++)
        	x[i] = 0;
        	for(int i = 1;i <= n;i++)
        	{
        		if(w[i] > c)
        		break;
        		x[i] = 1;
        		c -= w[i];
        	}
        	if(i <= n)
        	x[i] = c/w[i];
        }
        
    • 最优装载

      • 问题分析

        有一批集装箱要装上一艘载重量为c的轮船。其中集装箱i的重量为Wi。最优装载问题要求确定在装载体积不受限制的情况下,将尽可能多的集装箱装上轮船.

      • 形式化描述

        max ∑(i=1 -> n ) xi
        
        ∑(i=1 -> n) wi*xi <=c  xi∈{0,1}, i>=1 and i<=n
        
      • 贪心策略

        重量最轻者优先装载

      • 代码

        void load(int x[],int w[],int n,int c)
        {
        	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’表示。编码的实现过程很简单,只要实现哈夫曼树,通过遍历哈夫曼树,规定向左子树遍历一个节点编码为“0”,向右遍历一个节点编码为“1”,结束条件就是遍历到叶子节点!因为上面说过:哈夫曼树叶子节点才是有效数据节点!

      • 举例

        一、对给定的n个权值{W1,W2,W3,…,Wi,…,Wn}构成n棵二叉树的初始集合F= {T1,T2,T3,…,Ti,…,Tn},其中每棵二叉树Ti中只有一个权值为Wi的根结点,它的左右子树均为空。(为方便在计算机上实现算 法,一般还要求以Ti的权值Wi的升序排列。)
        二、在F中选取两棵根结点权值最小的树作为新构造的二叉树的左右子树,新二叉树的根结点的权值为其左右子树的根结点的权值之和。
        三、从F中删除这两棵树,并把这棵新的二叉树同样以升序排列加入到集合F中。
        四、重复二和三两步,直到集合F中只有一棵二叉树为止。

      • 霍夫曼编码是一种无前缀编码。解码时不会混淆。其主要应用在数据压缩,加密解密等场合

      • 前缀码

        对每一个字符规定一个0,1串作为其代码,并要求任一 字符的代码都不是其它字符代码的前缀。这种编码称为前缀码

      • 平均码长定义为:

        B(T )= ∑Cf(c)dT**(**c) (c 属于 C ,f©表示表示概率,dT 表示深度)

      • 代码

        #define MAXBIT 100
        #define MAXVALUE 10000
        #define MAXLEAF 30
        #define MAXNODE MAXLEAF*2-1
        
        
        typedef struct
        {
            int bit[MAXBIT];
            int start;
        }HCodeType;
        
        typedef struct
        {
            int weight;
            int parent;
            int lchild;
            int rchild;
            int value;
        }HNodeType;
        
        HNodeType HuffNode[MAXNODE];//定义全局变量和数组可以自动初始化
        HCodeType HuffCode[MAXLEAF], cd;//
        
        void HuffmanTree(HNodeType HuffNode[MAXNODE], int n)
        {
            int i, j, m1, m2,x1, x2;
            //m1,m2构造哈夫曼树不同过程中两个最小权值结点的权值
            //x1,x2构造哈夫曼树不同过程种两个最小权值结点在数组中的序号
            //初始化存放哈夫曼数组的结点
            for (i = 0; i < 2 * n - 1; i++)
            {
                HuffNode[i].weight = 0;
                HuffNode[i].parent = -1;
                HuffNode[i].lchild = -1;
                HuffNode[i].rchild = -1;
                HuffNode[i].value = i;
            }
            //printf("输入n个叶子结点的权值:\n");
            for (i = 0; i < n; i++)
            {
                printf("Please input weight of leaf node%d:\n", i);
                scanf("%d", &HuffNode[i].weight);
            }
            //循环构造哈夫曼树,n个叶子结点需要n-1次构建
            for (i = 0; i < n - 1; i++)
            {
                m1 = m2 = MAXVALUE;
                x1 = x2 = 0;
                for (j = 0; j < n + i; j++)//新建立的节点的下标是原来的叶子总结点数+i即n+i
                {
                    if (HuffNode[j].weight < m1&&HuffNode[j].parent == -1)
                    {
                        m2 = m1;
                        x2 = x1;
                        m1 = HuffNode[j].weight;
                        x1 = j;
                    }
                    else if (HuffNode[j].weight < m2&&HuffNode[j].parent == -1)
                    {
                        m2 = HuffNode[j].weight;
                        x2 = j;
                    }
                }
                HuffNode[x1].parent = n + i;
                HuffNode[x2].parent = n + i;
                HuffNode[n + i].weight = HuffNode[x1].weight + HuffNode[x2].weight;
                HuffNode[n + i].lchild = x1;
                HuffNode[n + i].rchild = x2;
                printf("x1.weight and x2.weight in round %d:%d,%d\n", i + 1, HuffNode[x1].weight, HuffNode[x2].weight);
                printf("\n");
            }
        }
        void HuffmanCode(HCodeType HuffCode[MAXLEAF], HNodeType HuffNode[MAXNODE],HCodeType cd, int n)
        {
            int i, c, p, j;
            for (i = 0; i < n; i++)
            {
                cd.start = n - 1;
                c = i;
                p = HuffNode[c].parent;
                while (p != -1)
                {
                    if (HuffNode[p].lchild == c)
                    {
                        cd.bit[cd.start] = 0;
                    }
                    else
                    {
                        cd.bit[cd.start] = 1;
                    }
                    cd.start--;
                    c = p;
                    p = HuffNode[c].parent;
                }
                for (j = cd.start + 1; j < n; j++)
                {
                    HuffCode[i].bit[j] = cd.bit[j];
                }
                HuffCode[i].start = cd.start;
            }
            for (i = 0; i < n; i++)
            {
                printf("%d(%c)的Huffman code is:", i+1,i+97);
                for (j = HuffCode[i].start + 1; j < n; j++)
                {
                    printf("%d", HuffCode[i].bit[j]);
                }
                printf("  start:%d", HuffCode[i].start);
                printf("\n");
            }
        }
        
    • 单源最短路径 迪杰斯特拉算法(dijkstra算法 会填表)

      • 算法步骤

      设置顶点集合S并不断地作贪心选择来扩充这个集合。一个顶点属于集合S当且仅当从源到 该顶点的最短路径长度已知。

      初始时,S中仅含有源。设u是G的某一个顶点,把从源到u且中间只经过S中顶点的路称为 从源到u的特殊路径,并用数组dist记录当前每个顶点所对应的最短特殊路径长度。Dijkstra 算法每次从V-S中取出具有最短特殊路长度的顶点u,将u添加到S中,同时对数组dist作必要 的修改。一旦S包含了所有V中顶点,dist就记录了从源到所有其他顶点之间的最短路径长度。

      • 例题

        img

      迭代SV2V3V4V5
      初始{1}1030100
      1{1,2}106030100
      2{1,2,4}10503090
      3{1,2,4,3}10503060
      4{1,2,4,3,5}10503060
      • 例题2

        img

        迭代SV2V3V4V5V6
        初始{1}503010010
        1{1,5}50302010
        2{1,5,4}40302010
        3{1,5,4,3}3530201050
        4{1,5,4,3,2}3530201045
        5{1,5,4,3,2,6}3530201045
    • 最小生成树(会画图)

      设G =(V,E)是无向连通带权图,即一个网络。E中每条边(v,w)的权为c[v] [w]。如果G的子图G’是一棵包含G的所有顶点的树,则称G’为G的生成树。生成树上各边权的总和称为该生成树的耗费。在G的所有生成树中,耗费最小的生成树称为G的最小生成树。

      网络的最小生成树在实际中有广泛应用。例如,在设计通信网络时,用图的顶点表示城市,用边(v,w)的权c[v] [w]表示建立城市v和城市w之间的通信线路所需的费用,则最小生成树就给出了建立通信网络的最经济的方案。

      用贪心算法设计策略可以设计出构造最小生成树的有效算法。本节介绍的构造最小生成树的Prim算法和Kruskal算法都可以看作是应用贪心算法设计策略的例子。尽管这2个算法做贪心选择的方式不同,它们都利用了下面的最小生成树性质:

      设G=(V,E)是连通带权图,U是V的真子集。如果(u,v)∈E,且u ∈ U,v ∈ V-U,且在所有这样的边中,(u,v)的权c[u] [v]最小,那么一定存在G的一棵最小生成树,它以(u,v)为其中一条边。这个性质有时也称为MST(Most Small Tree)性质。

      • Prime Algorithm(按顶点)

        • 算法介绍

          这种算法特别适用于边数相对较多,即比较接近于完全图的图。

          此算法是按逐个将顶点连通的步骤进行的,它只需采用一个顶点集合。这个集合开始时是空集,以后将已连通的顶点陆续加入到集合中去,到全部顶点都加入到集合中了,就得到所需的生成树。

        • 算法步骤

          •设G=(V,E)是一个连通带权图,V={1,2,…,n}。

          • 构造G的一棵最小生成树的Prim算法的过程是:

          • 首先从图的任一顶点起进行,将它加入集合S中置,S={1},

          • 然后作如下的贪婪选择,从与之相关联的边中选出权值c[i][j]最小的一条作为生成树的一条边,

          • 此时满足条件i ∈ S,j ∈ V-S,并将该j加入集合中,表示连两个顶点已被所选出的边连通了。

          •以后每次从一个端点在集合S中而另一个端点在集合S外的各条边中选取权值最小的一条作为生成树的一条边,并把其在集合外的那个顶点加入到集合S中。

          • 如此进行下去,直到全部顶点都加入到集合中S。

          • 在这个过程中选取到的所有边恰好构成G的一棵最小生成树。

          • 由于Prim算法中每次选取的边两端总是一个已连通顶点和一个未连通顶点,故这个边选取后一定能将该未连通点连通而又保证不会形成回路。

        • 例题img

          img

        • 例题2

          img

          A,D,F,B,E,C,G

      • Kruskal Algorithm(按边)

        • 算法步骤

          •设G=(V,E)是一个连通带权图,V={1,2,…,n}。

          •将图中的边按其权值由小到大排序, 然后作如下的贪婪选择,由小到大顺序选取各条边,若选某边后不形成回路,则将其保留作为树的一条边;若选某边后形成回路,则将其舍弃,以后也不再考虑。

          • 如此依次进行,到选够(n-1)条边即得到最小生成树。

        • 例题

          img

        img

    • 多机调度问题

      • 一台机器n个零件

        某车间只有一台高精度的磨床,常常出现很多零件同时要求这台磨床加工的情况,现有六个零件同时要求加工,这六个零件加工所需时间如右表所示。应该按照什么样的加工顺序来加工这六个零件,才能使得这六个零件在车间里停留的平均时间为最少?

        Pi表示安排在第i位加工的零件所需的时间,用Tj表示安排在第j位加工的零件在车间里总的停留时间,则这六个零件的停留时间为:

        T1 + T2 + T3 + T4 + T5 + T6

        P1 + ( P1 + P2 ) + (P1 + P2 + P3 ) + (P1 + P2 + P3 + P4 ) +

        ​ (P1 + P2 + P3 + P4 + P5) + (P1 + P2 + P3 + P4 + P5 + P6 )

        = 6 P1 + 5 P2 + 4P3 + 3P4 + 2P5 + P6

        由上式可知,把加工时间最短的放在P1的位置即首先安排加工即可(略过8,飞~)

      • 2台机器n个零件

        • 目的:是的完成全部工作的总时间最短

        • 某车间需要用一台车床和一台铣床加工A、B、C、D四个零件。每个零件都需要先用车床加工,再用铣床加工。车床与铣床加工每个零件所需的工时(包括加工前的准备时间以及加工后的处理时间)如表。

          工时(小时)ABCD
          车床8624
          铣床31312

          img

    • 其他问题 详见博客:

      1. https://blog.csdn.net/weixin_43201433/article/details/91354248?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromBaidu-2.control&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromBaidu-2.control#Dijkstra%E7%AE%97%E6%B3%95%EF%BC%88%E4%BC%9A%E5%A1%AB%E5%86%99%E8%A1%A8%EF%BC%89

五、回溯法

相关概念

  • 回溯法的提出

    有许多问题,当需要找出它的解集或者要求回答什么解是满足某些约束条件的最佳解时,往往要使用回溯法。

  • 问题的解空间

    (1)问题的解向量:回溯法希望一个问题的解能够表示成一个n元式(x1,x2,…,xn)的形式。
    (2)显约束:对分量xi的取值限定。(限定条件)
    (3)隐约束:为满足问题的解而对不同分量之间施加的约束。(剪纸)
    (4)解空间:对于问题的一个实例,解向量满足显式约束条件的所有多元组,构成了该实例的一个解空间。

  • 回溯法的基本思想

    (1)针对所给问题,定义问题的解空间;
    (2)确定易于搜索的解空间结构;
    (3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。

  • 生成问题状态的基本方法

    1)相关概念:
      扩展结点:一个正在产生儿子的结点称为扩展结点
      活结点:一个自身已生成但其儿子还没有全部生成的节点称做活结点
      死结点:一个所有儿子已经产生的结点称做死结点
    (2)深度优先的问题状态生成法:
      如果对一个扩展结点R,一旦产生了它的一个儿子C,就把C当做新的扩展结点。在完成对 子树C(以C为根的子树)的穷尽搜索之后,将R重新变成扩展结点,继续生成R的下一个儿子(如果存在)
    (3)广度优先的问题状态生成法:
      在一个扩展结点变成死结点之前,它一直是扩展结点.
    (4)回溯法:
      为了避免生成那些不可能产生最佳解的问题状态,要不断地利用限界函数(bounding function)来处死那些实际上不可能产生所需解的活结点,以减少问题的计算量。具有限界函数的深度优先生成法

  • 常用剪枝函数

    (1)用约束函数在扩展结点处剪去不满足约束的子树(01背包问题)
    (2)用限界函数剪去得不到最优解的子树(旅行商问题

  • 计算空间

    用回溯法解题的一个显著特征是在搜索过程中动态产生问题的解空间。在任何时刻,算法只保存从根结点到当前扩展结点的路径。如果解空间树中从根结点到叶结点的最长路径的长度为h(n),则回溯法所需的计算空间通常为O(h(n))。而显式地存储整个解空间则需要O(2h(n))或O(h(n)!)内存空间。

  • 算法框架

    • 递归回溯

      回溯法对解空间作深度优先搜索,因此,在一般情况下用递归方法实现回溯法。

      在这里插入图片描述

    • 迭代回溯

      采用树的非递归深度优先遍历算法,可将回溯法表示为一个非递归迭代过程。

      在这里插入图片描述

    • 子集树算法框架

      遍历子集树需O(2n)计算时间

      在这里插入图片描述

      在这里插入图片描述

    • 遍历树算法框架

      遍历排列树需要O(n!)计算时间

      在这里插入图片描述

      在这里插入图片描述

  • 回溯法效率分析

    (1)产生x[k]的时间;
    (2)满足显约束的x[k]值的个数;
    (3)计算约束函数constraint的时间;
    (4)计算上界函数bound的时间;
    (5)满足约束函数和上界函数约束的所有x[k]的个数。
    好的约束函数能显著地减少所生成的结点数。但这样的约束函数往往计算量较大。因此,在选择约束函数时通常存在生成结点数与约束函数计算量之间的折衷

典型例子
  • 装载问题

  • 问题描述

    有一批共n个集装箱要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为wi,且

    在这里插入图片描述

    ,装载问题要求确定是否有一个合理的装载方案可将这些集装箱装上这2艘轮船。如果有,找出一种装载方案。

    例如:当n=3,c1=c2=50,且w=[10,40,40]时,则可以将集装箱1和2装到第一艘轮船上,而将集装箱3装到第二艘轮船上;如果w=[20,40,40],则无法将这3个集装箱都装上轮船。

  • 基本思路

    容易证明,如果一个给定装载问题有解,则采用下面的策略可得到最优装载方案。
    (1)首先将第一艘轮船尽可能装满;
    (2)将剩余的集装箱装上第二艘轮船。
    将第一艘轮船尽可能装满等价于选取全体集装箱的一个子集,使该子集中集装箱重量之和最接近C1。由此可知,装载问题等价于以下特殊的0-1背包问题。
    m a x ∑ ( i = 1 t o n ) w i ∗ x i max ∑(i=1 to n) wi*xi maxi=1ton)wixi

    s . t . ∑ ( i = 1 t o n ) w i x i < = c 1 x i ∈ 0 , 1 , 1 < = i < = n ; s.t. ∑(i = 1 to n ) wixi <= c1 xi∈{0,1}, 1<=i<=n; s.t.(i=1ton)wixi<=c1xi0,1,1<=i<=n;

  • 剪枝

    用回溯法解装载问题时,用子集树表示其解空间显然是最合适的。用可行性约束函数可剪去不满足约束条件∑(i = 1 to n ) wi×xi <= c1的子树。在子集树的第j+1层的结点z处,用cw记当前的装载重量,即cw=∑(i=1 to n) wi×*xi,则当cw>c1时,以结点z为根的子树中所有结点都不满足约束条件,因而该子树中的解均为不可行解,故可将该子树剪去。(该约束函数去除不可行解,得到所有可行解)。

  • 上界函数

    设Z是解空间树第i层上的当前扩展结点。
     设     bestw: 当前最优载重量,
           cw : 当前扩展结点Z的载重量 ;
           r : 剩余集装箱的重量;

    可以引入一个上界函数,用于剪去不含最优解的子树,从而改进算法在平均情况下的运行效率。设z是解空间树第i层上的当前扩展结点。cw是当前载重量;bestw是当前最优载重量;r是剩余集装箱的重量,即r=∑(j=i+1 to n) wj。定义上界函数为cw+r。在以z为根的子树中任一叶结点所相应的载重量均不超过cw+r。因此,当cw+r<=bestw时,可将z的右子树剪去。即:cw + r > bestw 时搜索右子树,x[i]=0;

  • 例子

在这里插入图片描述

在这里插入图片描述

  • 算法代码 递归回溯
  
  template <class Type>
  class Loading
  {
  	//friend Type MaxLoading(Type[],Type,int,int []);
  	//private:
  	public:
  		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)// 到达叶结点  约束条件 可以直接退出
  	{  
  		if (cw>bestw)
  		{
  			for(int j=1;j<=n;j++) 
  			{
  				bestx[j]=x[j];//更新最优解
  				bestw=cw; 
  			}
  		} 
  		return;
  	}
   
  	r-=w[i]; 
  	if (cw + w[i] <= c) // 搜索左子树   相当于约束条件
  	{ 
  		x[i] = 1;
  		cw += w[i];
  		Backtrack(i+1);
  		cw-=w[i];    
  	}
   
  	if (cw + r > bestw)  //剪枝函数 ruguoshuo cw+r<=bestw 则右子树不用看了 否则							遍历右子树(即在右子树还可能存在更优解)
  	{
  		x[i] = 0;  // 搜索右子树
  		Backtrack(i + 1);  
  	}
  	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.w=w;
  	X.c=c;
  	X.n=n;
  	X.bestx=bestx;
  	X.bestw=0;
  	X.cw=0;
  	//初始化r
  	X.r=0;
   
  	for (int i=1;i<=n;i++)
  	{
  		X.r+=w[i];
  	}
   
  	X.Backtrack(1);
  	delete []X.x;
  	return X.bestw;
  }
  • 迭代代码
  template <class Type>
  Type MaxLoading(Type w[],Type c,int n,int bestx[])//迭代回溯法,返回最优载重量及其相应解,初始化根结点
  {
  	int i=1;//当前层,x[1:i-1]为当前路径
  	int *x=new int[n+1];
   
  	Type bestw=0,      //当前最优载重量
  		 cw=0,         //当前载重量
  		 r=0;          //剩余集装箱重量
   
  	for (int j=1;j<=n;j++)
  	{
  		r+=w[j];
  	}
   
  	while(true)//搜索子树
  	{     
  		while(i<=n &&cw+w[i]<=c)//进入左子树
  		{
  			r-=w[i];
  			cw+=w[i];
  			x[i]=1;
  			i++;
  		}
  		
  		if (i>n)//到达叶结点
  		{      
  			for (int j=1;j<=n;j++)
  			{
  				bestx[j]=x[j];
  			}
  			bestw=cw;
  		}
  		else//进入右子树
  		{          
  			r-=w[i];
  			x[i]=0; i++;
  		}
  		while (cw+r<=bestw)
  		{ //剪枝回溯
  			i--;   
  			while (i>0 && !x[i])
  			{ 
  				r+=w[i];
  				i--;
  			}   
  			//从右子树返回
  			if (i==0)
  			{
  				delete []x;
  				return bestw;
  			}
  			x[i]=0;
  			cw-=w[i];
  			i++;
  		} 
  	}  
  }
  • 批处理作业调度

    • 问题描述

      每一个作业Ji都有两项任务分别在2台机器上完成。每个作业必须先有机器1处理,然后再由机器2处理。作业Ji需要机器j的处理时间为tji。对于一个确定的作业调度,设Fji是作业i在机器j上完成处理时间。则所有作业在机器2上完成处理时间和f=F2i,称为该作业调度的完成时间和

    • 简单描述

      对于给定的n个作业,指定最佳作业调度方案,使其完成时间和达到最小。

      区别于流水线调度问题:批处理作业调度旨在求出使其完成时间和达到最小的最佳调度序列;

      流水线调度问题旨在求出使其最后一个作业的完成时间最小**的最佳调度序列;

    • 题意理解

      img

    ​ 考虑n=3 这个3个作业的6种可能的调度方案是(1,2,3)(1,3,2)(2,1,3)(2,3,1)(3,1,2)(3,2,1)对应的完成时间和分别是19,18,20,21,19,19。对于每一种调度,其调度时间图的形式如下,下图为n=5的情况:

    img

    • 举例说明

      img

    • 算法设计

      批处理作业调度问题要从n个作业的所有排列中找出有最小完成时间和的作业调度,所以批处理作业调度问题的解空间是一颗排列树。

      按照回溯法搜索排列树的算法框架,设开始时x=[1,2, … , n]是所给的n个作业,则相应的排列树由x[1:n]的所有排列(所有的调度序列)构成。

      二维数组M是输入作业的处理时间,bestf记录当前最小完成时间和,bestx记录相应的当前最佳作业调度。

      在递归函数Backtrack中,

      当i>n时,算法搜索至叶子结点,得到一个新的作业调度方案。此时算法适时更新当前最优值和相应的当前最佳调度。

      当i<n时,当前扩展结点位于排列树的第(i-1)层,此时算法选择下一个要安排的作业,以深度优先方式递归的对相应的子树进行搜索,对不满足上界约束的结点,则剪去相应的子树。

    • 准备工作

      1、区分作业i和当前第i个正在执行的作业
         给x赋初值,即其中一种排列,如x=[1,3,2];M[x[j]] [i]代表当前作业调度x排列中的第j个作业在第i台机器上的处理时间;如M[x[2]] [1]就意味着作业3在机器1上的处理时间。
      2、bestf的初值
         此问题是得到最佳作业调度方案以便使其完成时间和达到最小,所以当前最优值bestf应该初始化赋值为较大的一个值。
      3、f1、f2的定义与计算
         假定当前作业调度排列为:x=[1,2,3];f1[i]即第i个作业在机器1上的处理时间f2[j]即第j个作业在机器2上的处理时间;则:
            f1[1]=M[1] [1] , f2[1]=f1[1]+M[1] [2]
            f1[2]=f1[1]+M[2] [1] , f2[2]=MAX(f2[1],f1[2])+M[2] [2]

      //f2[2]不光要等作业2自己在机器1上的处理时间,还要等作业1在机器2上的处理时间,选其大者。
            f1[3]=f1[2]+M[3] [1] , f2[3]=MAX(f2[2],f1[3])+M[3] [2]
        1只有当前值有用,可以覆盖赋值,所以定义为int型变量即可,减少空间消耗;f2需要记录每个作业的处理时间,所以定义为int *型,以便计算得完成时间和。
      4、f2[0]的初值
         f2[i]的计算都是基于上一个作业f2[i-1]进行的,所以要记得给f2[0]赋值为0。

    • 代码

      class Flowshop
       {
            friend Flow(int * *,int,int[]);
        private:
            void Backtrack(int i);
            int * * M,      //各作业所需的处理时间,根据上面的例子就是4*3的矩阵————M[j][i]代表第j个作业在第i台机器上的处理时间
                  * x,       //当前作业调度————其中一种排列顺序
                * bestx,     //当前最优作业调度
                * f2,       //机器2完成处理时间————记录每个作业在机器2上的完成时间
                f1,        //机器1完成处理时间————定义int型,减少空间消耗(因为有当前的f1有用,所以可以覆盖赋值)
                f,        //完成时间和
                bestf,      //当前最优值
                n;        //作业树
       };
       void Flowshop::Backtrack(int i)
       {
           if(i>n)  //上界函数
           {
               for(int j=1;j<=n;j++)
                   bestx[j] = x[j];
               bestf = f;
           }
           else
           {
               for(int j=i;j<=n;j++)  //排列树中j从i开始————控制分支数
               {
                   f1+=M[x[j]][1];  //在第1台机器上的完成处理时间————着重关注M矩阵的行标(代表当前执行的作业,是动态变化的)(f1 的 M1机器运行时间实际是连续的)
                   f2[i]=((f2[i-1]>f1)?f2[i-1]:f1)+M[x[j]][2];  //在机器2上的完成处理时间,              (f2是否运行,要看上个作业的第二个机器的完成情况  根据其是否问)                                         // f2[0]初值赋为0
                   f+=f2[i];    //总的完成时间和  f实际上与f2[i]的最后一个作业的完成时间有关
                   if(f<bestf)  //剪枝函数  
                   {
                       Swap(x[i],x[j]);   //交换的目的是  遍历所有不同的作业顺序
                       Backtrack(i+1);
                       Swap(x[i],x[j]);
                    }
                   f1 -= M[x[j]][1];  //改变机器完成时间计数————递归返回时
                   f -= f2[i];
               }
           }
       }
       int Flow(int * * M,int n,int bestx[])  //初始化
       {
           int ub = INT_AMX;
           Flowshop X;
           X.x = new int [n+1];
           X.f2 = new int [n+1];
           X.M = M;
           X.n = n;
           X.bestf = ub;
           X.bestx = bestx;
           X.f1 = 0;
           X.f = 0;
           for(int i=0;i<=n;i++)
           {
               X.f2[i] = 0;
               X.x[i] i;
           }
           X.Backtrack(1);
           delete [] X x;
           delete [] X f2;
           return X.bestf;
       }
      
  • 符号三角形问题

    • 问题描述

      在这里插入图片描述

    • 题目分析

      对于符号三角形问题,用n元组x[1:n]表示符号三角形第一行的n个符号,由于我们只有两种符号——"+“或”-",所以取值是一个二值问题,如果取"+“我们就假设x[i]=1,如果取”-“我们就设x[i]=0。所以这显然是一个解空间为子集树的问题。我们不需要每次都遍历到树的叶节点,可以通过剪枝来节约时间。
      明确了解空间以后,我们要确定的就是约束函数。由于”+“和”-“数目相同,且我们的符号三角形有n行,所以总共有n(n+1) / 2个符号,因此每个符号只能有n(n +1) / 4。所以n(n+1)/2如果为奇数的话我们就要淘汰掉这种情况。
      我们用sum表示合理的符号三角形的数目。count来统计其中的”+“和”-",如果是"+"我们将count+1,否则将count+0,最后用count与n(n+1)/4比较即可。

    • 细节

      我们应该回溯的是状态,这个状态指的是我们在做了选择之后发生改变的变量,简而言之 就是约束函数约束的变量。这道题里,我们做出了选择,p[1][t]=i,于是我们就会马上让count+i,因为i如果等于0.则为负号,count自然不用加。当i=1时,符号为正号,count+1。所以我们改变的是count,而却count也是约束函数约束到的变量,所以我们在回溯过程中应该将其恢复原来的状态

    • 代码

      class Triangle
      {
          friend int Compute(int);//计算符号三角形个数的函数
          private:
              void Backtrack(int i);
              int half,//符号三角形中符号数目的一半
                  **p;//符号三角形矩阵
                   n,//第一行符号个数
                   count;//当前"+"的个数
              long sum;//符号三角形的符号个数(防止符号过多,声明为long类型) 
      };
      void Triangle::Backtrack(int t)
      {
          //可行性约束
          if(t > n)
          {
              sum++;
          }
          //约束函数
          if(count > half || t*(t - 1) / 2 - count > half)
          //判断"+"和"-"是否都超过了一半
          {
              return;
          }
          for(int i = 0;i <= 1;i++)
          {
              //从上往下,最上面是第一行
              //一定要给一个初始值
              p[1][t] = i;
              count += i;
              for(int j = 2;j <= t;j++)
              {
                  //这里可能有一些难推导,第t列的这个符号只能影响下面几行t列之前的符号
                  //如果当前行为k,下面的行数为j,则t最多可以影响到的列数为t-(j-k)即为                t-j+k
                  //在一行中即为t-j+1
                  p[j][t-j+1] = p[j-1][t-j+1] ^ p[j-1][t-j+2];
                  count += p[i][t-j+1];//只能为0或者1
              }
              Backtrack(t + 1);
              //回溯
              //这里的回溯不需要将p[1][t]-=i,因为我们外层是有循环的,所以回到原地后下次循环会重新给p[1][t]一个数值的
              //在子集树中,我们只回溯最终的结果(如装载问题的最优容量和当前重量,这道题目的当前正号数目等),
              //并不会回溯选择的0或者1,因为在回溯回来的时候我们会改变它的
              //回溯的时候我们只找那些约束函数约束的变量来恢复到原状态,如本题的count
              for(int j = 2;j < = t;j++)
              {
                  count -= p[j][t - j + 1];
              }
              count -= i;
          }
      }
      int Compute(int n) 			//初始化
      {
          Triangle X;
          X.n = n;
          X.half = n(n+1) / 2;
          X.sum = 0;
          if(X.half % 2 == 1)
              return 0;
          X.half = X.half / 2;
          X.count = 0;
          //二维数组,记录符号三角形
          int **p = new int *[n + 1];
          for(int i = 0;i <= n;i++)
              p[i] = new int [n + 1];
          for(int i = 0;i <= n;i++)
          {
              for(int j = 0;ij<=n;j++)
                  p[i][j] = 0;
          }
          X.p = p;
          X.Backtrack(1);
          return X.sum;
      }
      
  • n后问题

    • 问题描述

      在n*n格子上放置n个皇后, 按照国际象棋规矩不可让皇后相互攻击, 即如何两个皇后不放在同一列同一行同一斜线上.

    • 算法设计

      将问题转化为逐行放置皇后,即第一次放第1行,第二次放第2行,依次类推放至第n行皇后则放置完毕,如此每次放置只需考虑皇后的列冲突和斜线冲突.因为每次皇后都在新的一行放置.假设数组 x[i] 表示第i个皇后放的列数,如x[3] = 4; 表示第3个(第三行的)皇后放在第4列.

    • 冲突情况

      设第i行皇后放x[i]列,和第k行皇后放x[k]列,其中冲突情况有:

      1. 列冲突, 即x[k] = x[i]
      2. 斜线冲突,由于在n*n格子,则若有两个棋子属于同一斜线,则两棋子斜率为-1,可推出 | x[i]-x[k] | /| (i-k) |=1; 则 |i-k|= | (x[i]-x[k]) |

      可以用bool Place(int k) 检验第k行皇后与之前的所有皇后没有冲突

      
      bool Queen::Place(int k)
      {
      	for(int j=1;j<k;j++)
      		if(  ( abs(k-j)==abs(x[j]-x[k]) ) ||(x[j]==x[k])  )//斜线冲突和列冲突
      			return false;			//返回false
      	return true;	//注意这里不是else执行返回true,当k与之前所有皇后冲突检测通过后</span>才返回true
      }
      
    • 解空间

      n后问题的解可以用一颗解空间树表示,其中从根节点开始对树进行深度优先搜索,

      从第一个根节点开始搜索,如果找到第n+1层,则所有n后放置完毕,解决方案sum++;

    • 详细解释

      使用一个一维数组表示皇后的位置

      • 其中数组的下标表示皇后所在的行
      • 数组元素的值表示皇后所在的列
      • 这样设计的棋盘,所有皇后必定不在同一行
      • 假设前n-1行的皇后已经按照规则排列好
      • 那么可以使用回溯法逐个试出第n行皇后的合法位置
      • 所有皇后的初始位置都是第0列
      • 那么逐个尝试就是从0试到N-1
      • 如果达到N,仍未找到合法位置
      • 那么就置当前行的皇后的位置为初始位置0
      • 然后回退一行,且该行的皇后的位置加1,继续尝试
      • 如果目前处于第0行,还要再回退,说明此问题已再无解
      • 如果当前行的皇后的位置还是在0到N-1的合法范围内
      • 那么首先要判断该行的皇后是否与前几行的皇后互相冲突
      • 如果冲突,该行的皇后的位置加1,继续尝试
      • 如果不冲突,判断下一行的皇后
      • 如果已经是最后一行,说明已经找到一个解,输出这个解
      • 然后最后一行的皇后的位置加1,继续尝试下一个解
    • 递归代码

      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(x[j]-x[k]) ) ||(x[j]==x[k])  )
      			return false;
      	return true;	//注意这里不是else执行返回true,当每一行都测试过没有冲突之后									才返回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=0;i<=n;i++)
      		p[i]=0;
      	X.x=p;
      	X.Backtrack(1);
      	delete[]p;
      	return X.sum;
      }
      
      
    • 迭代代码

      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(x[j]-x[k]) ) ||(x[j]==x[k])  )
      			return false;
      	return true;	//注意这里不是else执行返回true,当每一行都测试过没有冲突之后才返回true
      }
       
      void Queen::Backtrack(void)
      {
      	x[1]=0;
      	int k =1;
      	while(k>0) //如果k=0 代表没有解
      	{
      		x[k]+=1;
      		while(x[k]<=n&&!(Place(k))) x[k]+=1; //列数小于n 剪枝
      		if(x[k]<=n)
      			if(k==n) sum++;  //k=n 即第n行皇后也满足排列 So sum++;
      			else
      			{
      				k++;    //否则找下一行皇后的位置,并把它的初始列设为0
      				x[k]=0;
      			}
      		else k--;
       	}
      }
       
      int nQueen(int n)
      {
      	Queen X;
      	X.n=n;
      	X.sum=0;
      	int *p = new int[n+1];
      	for(int i=0;i<=n;i++)
      		p[i]=0;
      	X.x=p;
      	X.Backtrack();
      	delete[]p;
      	return X.sum;
      }
      
      
  • 0-1背包问题

    • 问题描述

      • 给定n种物品和一个背包。物品i的重量是wi,其价值为vi,背包的容量为c。
      • 应如何选择装入背包的物品,使得装入背包中物品的总价值最大?
      • 在选择装入背包的物品时,对每种物品i只有2种选择,即装入背包或不装入背包。不能将物品i装入背包多次,也不能只装入部分的物品i。
    • 算法描述

      0-1背包问题是子集选取问题。一般情况下,0-1背包问题是NP难得。0-1背包问题的解空间可用子集树 表示。在搜索解空间的时,只要其左儿子节点是一个可行节点,搜索就进去其左子树(约束条件)。当右子树中可能包含最优解时才进入右子树搜索(限界函数)。否则就将右子树剪去。

      计算右子树中解的上界的更好方法是将剩余物品依其单位重量价值排序,然后依次装入物品,直至装不下时,再装入物品的一部分而装满背包。由此得到的价值是右子树中解的上界

      为了便于计算上界,可先将物品按照单位重量价值由大到小排序,此后,只要按照顺序考察各个物品即可。//先按单位重量进行排序

      在实现时,由Bound计算当前结点处的上界。在解空间树的当前扩展结点处,仅当要进入右子树时才计算右子树的上界Bound,以判断是否将右子树剪去。

      进入左子树时不需要计算上界,因为其上界与其父节点上界相同。

    • 计算步骤

      1. 计算每种物品单位重量的价值si=pi/wi
      2. 依贪心选择策略,将尽可能多的单位重量价值最高的物品装入背包。
      3. 若将这种物品全部装入背包后,背包内的物品总重量未超过C,则选择单位重量价值次高的物品并尽可能多地装入背包。
      4. 依此策略一直地进行下去,直到背包装满为止。
    • 例子

      • 问题描述

        假设有4个物品,物品的价值分别为p=[9, 10, 7, 4], 重量分别为w=[3, 5, 2, 1], 背包容量C=9,使用回溯方法求解此0-1背包问题,计算其最优值及最优解。要求画出求得最优解的解空间树, 给出具体求解过程。要求中间被剪掉的结点用×标记。

      • 求解步骤

        按照单位重量价值由大到小排好序,按照顺序考查是否装入物品
        

        物品单位重量价值:[p1/w1, p2/w2, p3/w3, p4/w4] =(3, 2, 3.5, 4)
        按照物品单位重量价值由大到小排序:

        物品重量(w)价值(v)价值/重量(v/w)
        1144
        2273.5
        3393
        45102
      • 上界计算

        先装入物品1,然后装入物品2 和 3。装入这三个物品后,剩余的背包容量为3,只能装入物品4的3/5(2 * 3/5 = 1)。 即上界为4+7+9+2*(5*3/5)=26

      ​ 深度优先搜索解空间树,若左儿子是可行节点,才搜索进入左子树,否则将左子树剪掉;若右子树包含最优解,才搜索右子树,否则将右子树剪掉。

      在这里插入图片描述

      以第一个up=26为例,26=4+7+9+2*(9-6)
      打x的部分因为up值已经小于等于Bestp了,所以没必要继续递归了。

      • 详解

        在这里插入图片描述

    • 解空间

      子集树(二叉树即可)

    • 可行性约束函数:

      \sum_{i=1}^{n}wixi\leqslant W

    • 上界函数

    template<class Typew, class Typep>
    Typep Knap<Typew, Typep>::Bound(int i)
    {// 计算上界
    Typew cleft = c - cw; // 剩余容量
    Typep b = cp;
    // 以物品单位重量价值递减序装入物品
    while (i <= n && w[i] <= cleft) {
    cleft -= w[i];
    b += p[i];
    i++;
    }
    // 装满背包
    if (i <= n) b += p[i]/w[i] * cleft;
    return b;
    }
    
    • 核心代码

      
       
      class Knap {
      	friend int knapsack(int *,int *,int ,int);
      private:
      	float Bound(int i);
      	void Backtrack(int i);
      	int c;		//背包容量
      	int n;		  //物品数
      	int *w;     //物品重量数组
      	int *p;		//物品价值数组
      	int cw;		//当前重量
      	int cp;		//当前价值
      	int bestp;    //当前最优价值
      };
       
       
      void Knap::Backtrack(int i) {
      	if (i > n) {
      		//到达叶结点
      		bestp = cp;
      		return;
      	}
       
      	if (cw + w[i] <= c) { //进入左子树
      		cw += w[i];
      		cp += p[i];
      		Backtrack(i + 1);
      		cw -= w[i];
      		cp -= p[i];
      	}
      						//	float u=Bound(i + 1);
      						//	float bb=bestp;
      						//当前的界是否大于背包当前的值
      		if (Bound(i + 1) > bestp) { //进入右子树
      			Backtrack(i + 1);
      		}
      }
       
       
      float Knap::Bound(int i) {    //计算上界
      	//计算上界
      	int cleft = c - cw; //剩余容量
      	float b = cp;
      	//以物品单位重量价值递减序装入物品
      	while (i <= n && w[i] <= cleft) {
      		cleft -= w[i];
      		b += p[i];
      		i++;
      	}
      	//装满背包
      	if (i <= n){
      		float aa=p[i] * cleft;
      		float bb=w[i];
      		float temp=aa/bb;
      //注意:如果这样写:float temp=p[i] * cleft/w[i];则temp计算出来是整数,因为右边是先按整数来算,再将int转float;
      		b += temp;
      		cout<<b<<endl;
      	}
      	return b;
      };
      class Object {
      	friend int knapsack(int *,int *,int,int);
        public:
      	int operator<=(Object a) const {
      		return (d >= a.d);
      	}
        private:
      	int ID;
      	float d;
      };
       
       
      int knapsack(int p[], int w[], int c, int n) {
      	//为Knap: Backtrack初始化
      	int W = 0;
      	int P = 0;
      	Object * Q = new Object[n];
      	for (int i = 1; i <= n; i++) {
      		Q[i - 1].ID = i;
      		Q[i - 1].d = 1.0 * p[i]/w[i];
      		//cout<<Q[i - 1].d<<endl;
      		P += p[i];
      		W += w[i];
      	}
      	if (W <= c) return P; //装入所有物品
      	//所有物品的总重量大于背包容量c,存在最佳装包方案
       
      		//sort(Q,n);对物品以单位重量价值降序排序(不排序也可以,但是为了便于计算上界,可将其按照单位重量价格从大到小排序)
       
      		//1.对物品以单位重量价值降序排序
       
      		//采用简单冒泡排序
       
      	for(int i = 1; i<n; i++)
       
      		for(int j = 1; j<= n-i; j++)
       
      		{
       
      				if(Q[j-1].d < Q[j].d)
       
      				{
       
      					Object temp = Q[j-1];
       
      					Q[j-1] = Q[j];
       
      					Q[j] = temp;
       
      				}
       
      		}
       
          Knap K;
      	K.p = new int[n + 1];
      	K.w = new int[n + 1];
      	for (int i = 1; i <= n; i++) {
      		K.p[i] = p[Q[i - 1].ID];
      		K.w[i] = w[Q[i - 1].ID];
      	}
      	K.cp = 0;
      	K.cw = 0;
      	K.c = c;
      	K.n = n;
      	K.bestp = 0;
      	//回溯搜索
      	K.Backtrack(1);
      	delete[] Q;
      	delete[] K.w;
      	delete[] K.p;
      	return K.bestp;
      }
      
  • 最大团问题

    • 问题描述

      给定无向图G=(V,E),V是顶点集,E是边集。如果U⊆⊆V,且对任意u,v∈∈U有(u,v)∈∈E,u,v是两个顶点的符号,则称U是G的完全子图。G的完全子图U是G的一个团当且仅当U不包含在G的更大的完全子图中。

      注:**最大团定义:**从无向图的顶点集中选出k个并且k个顶点之间任意两点之间都相邻(完全图),最大的k就是最大团。

      如果U∈V且对任意u,v∈U有(u, v)不属于E,则称U是G的*****空子图**。G的空子图U是G的独立集当且仅当U不包含在G的更大的空子图中。G的最大独立集是G中所含顶点数最多的独立集***。

      对于任一无向图G=(V, E),其补图G’=(V’, E’)定义为:V’=V,且(u, v)∈E’当且仅当(u, v)∈E。
      如果U是G的完全子图,则它也是G’的空子图,反之亦然。因此,G的团与G’的独立集之间存在一一对应的关系。特殊地,U是G的最大团当且仅当U是G’的最大独立集。

      例:

      æ大å¢çä¸ä¸ªä¾å­

      子集{1,2}是G的一个大小为2的完全子图,但不是一个团,因为它包含于G的更大的完全子图{1,2,5}中。{1,2,5}、{1,4,5}和{2,3,5}都是G的最大团。

      子集树如下:

      解空é´æ

    • 算法思想

      首先设最大团为一个空团,往其中加入一个顶点,然后依次考虑每个顶点,查看该顶点加入团之后仍然构成一个团,如果可以,考虑将该顶点加入团或者舍弃两种情况,如果不行,直接舍弃,然后递归判断下一顶点。对于无连接或者直接舍弃两种情况,在递归前,可采用剪枝策略来避免无效搜索

      为了判断当前顶点加入团之后是否仍是一个团,只需要考虑该顶点和团中顶点是否都有连接

      程序中采用了一个比较简单的剪枝策略,即如果剩余未考虑的顶点数加上团中顶点数不大于当前解的顶点数,可停止继续深度搜索,否则继续深度递归

    • 代码

      class Clique  
      {  
          friend int MaxClique(int **,int[],int);  
          private:  
              void Backtrack(int i);  
              int **a,        //图G的邻接矩阵  
                  n,          //图G的顶点数  
                  *x,         //当前解  
                  *bestx,     //当前最优解  
                  cn,         //当前顶点数  
                  bestn;      //当前最大顶点数  
      };  
      void Clique::Backtrack(int i)  
      {  
          if (i > n) // 到达叶结点  
          {  
              for (int j = 1; j <= n; j++)  
              {  
                  bestx[j] = x[j];  
                  cout<<x[j]<<" ";  
              }  
              cout<<endl;  
              bestn = cn;  
              return;  
          }  
          // 检查顶点 i 与当前团的连接  
          int OK = 1;  
          for (int j = 1; j < i; j++)  
          if (x[j] && a[i][j] == 0) //判断i这个顶点能否能与已加入的团结合一起共同构 							  成一个最大团,如果不可以ok=0 进入右子树或者剪枝							  后回溯 
                                    如果可的话,ok=1,加入团中,继续进入左子树
          {  
              // i与j不相连  
              OK = 0;  
              break;  
          }  
        
          if (OK)// 进入左子树   //
          {  
              x[i] = 1;  
              cn++;  
              Backtrack(i+1);  
              x[i] = 0;  
              cn--;  
          }  
        
          if (cn + n - i >= bestn)// 剪枝函数    判断能否进入右子树  
          {  
              x[i] = 0;  
              Backtrack(i+1);  
          }  
      }  
        
      int MaxClique(int **a, int v[], int n)  
      {  
          Clique Y;  
        
          //初始化Y  
          Y.x = new int[n+1];  
          Y.a = a;  
          Y.n = n;  
          Y.cn = 0;  
          Y.bestn = 0;  
          Y.bestx = v;  
          Y.Backtrack(1);  
          delete[] Y.x;  
          return Y.bestn;  
      }  
      
  • 图的m着色问题

    • 问题描述

      给定无向连通图G和m种不同的颜色。用这些颜色为图G的各顶点着色,每个顶点着一种颜色。是否有一种着色法使G中每条边的2个顶点着不同颜色。这个问题是图的m可着色判定问题。若一个图最少需要m种颜色才能使图中每条边连接的2个顶点着不同颜色,则称这个数m为该图的色数。求一个图的色数m的问题称为图的m可着色优化问题

    • 四色猜想

      四色问题是m图着色问题的一个特例,根据四色原理,证明平面或球面上的任何地图的所有区域都至多可用四种、颜色来着色,并使任何两个有一段公共边界的相邻区域没有相同的颜色。这个问题可转换成对一平面图的4-着色判定问题(平面图是一个能画于平面上而边无任何交叉的图)。将地图的每个区域变成一个结点,若两个区域相邻,则相应的结点用一条边连接起来。多年来,虽然已证明用5种颜色足以对任一幅地图着色,但是一直找不到一定要求多于4种颜色的地图。直到1976年这个问题才由爱普尔,黑肯和考西利用电子计算机的帮助得以解决。他们证明了4种颜色足以对任何地图着色。

    • 问题的状态以及解空间树结构

      在这里插入图片描述

    • 确定结点的扩展搜索规则

      在这里插入图片描述

    • 约束条件

      有边相邻的两个顶点的颜色不能相同

    • 在这里插入图片描述

    • 代码

      class Color {
      	friend int mColoring(int, int, int **);
      private:
      	bool OK(int k);
      	void Backtrack(int t);
      	int n;		//顶点数
      	int m;		//可用颜色数
      	int **a;	//邻接矩阵
      	int *x;		//解向量
      	long sum;	//可着色方案数
      };
      //判断是否可被着色
      bool Color::OK(int k) {
      	for (int j = 1; j <= n; j++) {
      		if ((a[k][j] == 1) && x[k] == x[j]) //  二者相连并且颜色相同 为												false 
      			return false;
      	}
      	return true;
      }
      
      void Color::Backtrack(int t) {
      	//当访问到叶子结点,则找到一种可着色方案 sum++
      	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;  //代表第t个区域着色为 i
      			if (OK(t))     //如果为0表示这条路不行,换颜色  如果为1,继续向下								筛选颜色
      				Backtrack(t + 1);
      			x[t] = 0;
      		}
      	}
      }
      
      int mColoring(int n, int m, int **a) {
      	//进行初始化操作
      	Color 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;
      }
      }
      
  • 旅行售货员问题

    • 问题描述

      某售货员要到n个城市去推销商品,已知各城市之间的路程,请问他应该如何选定一条从城市1出发,经过每个城市一遍,最后回到城市1的路线,使得总的周游路程最小?并分析所设计算法的计算时间复杂度

    • 算法设计

      根据回溯法进行算法设计

    • 确定问题的解空间

      用图的邻接矩阵a表示无向连通图G = (V , E)。若(I,j)属于图的边集,则a[i][j] 表示两个城市之间的距离,,若a[i][j] = 0,表示城市之间无通路。问题的解空间就是出发城市+其他城市全排列。

    • 确定解空间的结构

      该问题的解空间结构为一颗排列树。

    • 搜索方式和边界条件

      搜索方式:深度优先搜索
      边界条件:
      (1)当i = n时,当前扩展的节点是排列树叶节点的父节点,在此时需要检测是否存在一条从x[i-1]到顶点x[n]的边和一条x[n]到x[1]的边,如果都存在,则找到一条旅行售货员回路。并且还需要判断这条回路的费用是否优于当前最优回路的费用,如果是,必须进行更新。
      (2)当i < n时,如果存在路径并且最优值更小,则进行更新,否则剪去相应的子树。

    • 代码

      template<class Type>
      class Traveling {
      	friend Type TSP(Type**, Type[], int, Type);
      public:
      	void Backstack(int i);
      	void Swap(int, int);
      	int n;//图G的顶点数
      	int *x; //当前解
      	int *bestx; //当前最优解
      	Type **a; //图G的邻接矩阵
      	Type cc; //当前费用
      	Type bestc; //当前最优值
      	Type NoEdge; //无边标记
      };
      
      template<class Type>
      void Traveling<Type>::Swap(int a, int b) {
      	int k;
      	k = x[a];
      	x[a] = x[b];
      	x[b] = k;
      }
      
      template<class Type>
      void Traveling<Type>::Backstack(int i) {
      	if (i == n) {
      		if (a[x[n - 1]][x[n]] != NoEdge && a[x[n]][x[1]] != NoEdge && (cc + a[x[n - 1]][x[n]] + a[x[n]][x[1]] < bestc || bestc == NoEdge)) {
      			for (int j = 1; j <= n; j++)
      				bestx[j] = x[j];
      			bestc = cc + a[x[n - 1]][x[n]] + a[x[n]][x[1]];
      		}
      	}// 确定是否存在一条x[i-1] 到 x[n] 和 x[n] 到x[1]的边 判断是否优于最优值
      						//是则更新
      	else {
      		for (int j = i; j <= n; j++) {
      			if (a[x[i - 1]][x[j]] != NoEdge && (cc + a[x[i - 1]][x[j]] < bestc || bestc == NoEdge)) {
      				Swap(x[i], x[j]);   //如果不行  交换两个位置 全排列找下一位置
      				cc += a[x[i - 1]][x[i]];
      				Backstack(i + 1);
      				cc -= a[x[i - 1]][x[i]];
      				Swap(x[i], x[j]);
      			}
      		}
      	}
      }
      
      
      
      template<class Type>
      Type TSP(Type **a, Type v[], int n, Type NoEdge) {
      	Traveling<Type> Y;
      	Y.x = new int[n + 1];
      	for (int i = 1; i <= n; i++)
      		Y.x[i] = i;
      	Y.a = a;
      	Y.n = n;
      	Y.bestc = NoEdge;
      	Y.bestx = v;
      	Y.cc = 0;
      	Y.NoEdge = NoEdge;
      	Y.Backstack(2);
      	delete[]Y.x;
      	return Y.bestc;
      }
      
  • 圆排列问题

    • 问题分析

      圆排列问题:给定n个圆的半径序列,将它们放到矩形框中,各圆与矩形底边相切,求具有最小排列长度的圆排列。

    • 解析

      首先,对于n个圆的半径序列,我们将其全排列,并以树的形式展现。
      这个树是多叉树,它满足:根节点的子结点数即为圆的个数,其后,随着树层数的增加,每后移一层,该层每个结点的子节点数会比前一层每个结点的子节点数减1,直至层结点的子结点数为0,问题的多叉树即构造完毕。

      如下图,展现的是半径序列为{1,1,2}的排列多叉树:

      在这里插入图片描述

      ​ 由图很容易看出,根节点到每一个叶结点的追溯即是一种圆排列方式,因此当算法确定叶结点的位置后,我们就可以得出该叶结点所在的圆排列长度,经比较,便可以得到全局最短的圆排列长度及它的序列。
      但是,我们都知道,当n稍大的时候,这棵树就会很庞大,算法效率也会很低。因此,我们采用了回溯法的思想,减少遍历情况,设立了一个界限函数——在前k(k<n)个结点排列都确定的情况下,计算加上第k个结点的排列长度,倘若该长度比已经计算得到的圆排列的最小长度还要长,就剪断分支,回溯父结点,不再遍历这第k个结点的子结点(因为k个圆的排列就已经比别的序列n个圆的排列长了,再继续下去到叶结点,肯定也不是全局最短圆排列长度);否则,继续。

    • 如何计算呢

      • 计算该排列每个圆的圆心坐标
        若排列满足最短,则对于排列中的每一个圆,存在另外至少一个圆与之相切。(n>=2) (理解这句话非常重要)由于在该圆前面的圆序列及圆心坐标已经确定,我们就可以从中找到一个与所求圆相切的圆,并根据如下相切圆圆心横坐标距离公式,得到所求圆圆心在当前排列的横坐标位置。

        在这里插入图片描述

        因为所求圆不一定与排它前一个的圆相切(如下图所示),所以在getCenterX()中得到使其横坐标最右的坐标位置即可。注意,第一个圆的圆心横坐标为0.

        在这里插入图片描述

      • 分别计算该排列左边界、右边界坐标,相减得到圆排列长度
        圆序列确定好了(叶结点确定),各圆心横坐标也计算好了,根据每个圆心横坐标及其半径计算它的左(右)边界坐标,众多圆中起始(末尾)位置最左(右)的就是该排列的左(右)边界横坐标,将左右边界横坐标相减,即为该排列的长度。

      • 将得到的圆排列长度与界函数值minlength比较,比它小则更新minlength和最佳排列半径数组bestOrder[n].

    • 代码

      class Circle
      {
          friend float CirclePerm(int,float *);
          private:
              float Center(int t);//计算当前所选择圆的圆心横坐标
              void Compute(void);
              void Backtrack(int t);
              float min,//当前最优值
                    *x,//当前圆排列圆心横坐标
                    *r;//当前圆排列(可理解为半径排列)
              int n;//待排列圆的个数
              
              
              float Circle::Center(int t) //计算当前所选择圆在当前圆排列中圆心的横										坐标
              {
                  float valuex,temp = 0;
                  //之所以从1-t判断是因为防止第t个圆和第t-1个圆不相切
                  for(int j = 1;j < t;j++)
                  {
                      valuex = x[j] + sqrt(r[t] * r[j]);
                      if(valuex > temp)
                          temp = valuex;
                  }
                  return temp;
              }
              
              
              void Circle::Compute(void)
              //计算当前排列的长度
              {
                  float low = 0,high = 0;
                  for(int i = 1;i <=n;i++)
                  {
                      if(x[i] - r[t] < low)
                      {
                          low = x[i] - r[i];
                      }
                      if(x[i] + r[i] > high)
                      {
                          high = x[i] + r[i];
                      }
                  }
                  if(high - low < min)
                      min = high - low;
              }
              
              
              void Circle::Backtrack(int t) //计算空间
              {
                  if(t > n)
                  {
                      //到达叶子节点,我们计算high与low的差距
                      Compute();
                  }
                  else
                  {
                      //排列树解空间
                      for(int j = 1;j <= t;j++)
                      {
                          //圆的排列其实就是就是半径的排列,因为相同半径的圆是相同的
                          //交换半径顺序,可以进一步优化,如果半径相等不交换
                          //镜像序列只算一次,例如1,2,3和3,2,1
                          swap(r[t],r[j]);
                          if(Center(t)+r[1]+r[t] < min)//下界约束,我们取第一个圆的圆心为原点,所以计算距离的时候要加上r[1]和r[t]
                          {
                              x[t] = Center(t);
                              Backtrack(t+1;)
                          }
                          swap(r[t],r[j]);
                      }
                  }
              }
              
              
              float CirclePerm(int n,float *a)
              {
                  Circle X;
                  X.n = n;
                  X.r = a;
                  X.min = 100000;
                  float *x = new float [n+1];//圆的中心坐标排列
                  X.x = x;
                  X.Backtrack(1);
                  delete[] x;
                  return X.min;
              }
      };
      
  • 电路板排列问题

  • 连续邮资问题

    • 问题描述

      假设某国家发行了n种不同面值的邮票,并且规定每张信封上最多只允许贴m张邮票。连续邮资问题要求对于给定的n和m,给出邮票面值的最佳设计,在1张信封上贴出从邮资1开始,增量为1的最大连续邮资区间。 例如当n=5,m=4时,面值为1,3,11,15,32的5种邮票可以贴出邮资的最大连续区间是1到70。

    • 算法思路

      每张信封最多贴m张邮票,也就是说可能贴了m张,也可能贴了0张、1张等等。为了便于计算,我们可以把未贴满m张邮票看作贴了x张邮票和m-x张面值为0的邮票,从而构造一棵完全多叉树。若求所有可能的组合情况,解空间是(n+1)^m。
      以n=5,m=4为例,解空间为完全多叉树,图中只以一条路径为例:

      在这里插入图片描述

      实际求解需要对其进行剪枝:解的约束条件是必须满足当前解的邮资可以构成增量为1的连续空间,所以在搜索至树中任一节点时,先判断该节点对应的部分解是否满足约束条件,也就是判断该节点是否包含问题的最优解,如果肯定不包含,则跳过对以该节点为根的子树的搜索,返回上一层的节点,从其他子节点寻找通向最优解的路径;否则,进入以该节点为根的子树,继续按照深度优先策略搜索。

    • 求解过程

      1. 读入邮票面值数n,每张信封最多贴的邮票数m
      2. 读入邮票面值数组nums[],除了正常面值,再加入一个值为0的面值
      3. 循环求取区间最大值maxValue,maxValue初始设为0
        ① 从0张邮票开始搜索解空间,如果当前未达到叶节点,且过程值temp=temp+nums[i]未达到当前记录的区间最大值maxValue,则继续向下搜索
        ② 若超过了区间最大值maxValue,则当前面值不是可行解,计算下一个面值nums[i+1]。若循环结束,当前节点的所有面值都无法满足,则说明再往下搜索也不可能有可行解,这个时候回溯到上一节点
        ③ 若当前已搜索到叶节点,判断当前路径下的解temp是否满足比当前记录的区间最大值maxValue大1。若满足,则更新区间最大值maxValue;若不满足,回溯到上一节点
      4. 重复步骤3直到没有满足当前区间最大值maxValue+1的可行解,则当前记录的maxValue就是区间最大值
    • 代码

      
      class Stamp
      {
      friend int MaxStamp(int  ,int  ,int []);
       
      private:
      	 // int Bound(int i);
      	  void Backtrack(int i,int r);
      	  int n;//邮票面值数
      	  int m;//每张信封最多允许贴的邮票数
      	  int maxvalue;//当前最优值
      	  int maxint;//大整数
      	  int maxl;//邮资上界
      	  int *x;//当前解
      	  int *y;//贴出各种邮资所需最少邮票数
      	  int *bestx;//当前最优解
       
      };
       
      void Stamp::Backtrack(int i,int r)
      {
      	/*计算X[1:i]的最大连续邮资区间,考虑到直接递归的求解复杂度太高,
      	我们不妨尝试计算用不超过m张面值为x[1:i]的邮票贴出邮资k所需的最少邮票数y[k]。
      	通过y[k]可以很快推出r的值。事实上,y[k]可以通过递推在O(n)时间内解决*/
      	for(int j=0;j<=x[i-2]*(m-1);j++) //x[i-2]*(m-1)是第i-2层循环的一个上限,目的是找到r-1的值 
      			if(y[j]<m)
      			{
      				for(int k=1;k<=m-y[j];k++) //k是对表示j剩余的票数进行检查   
      				{
      					if(y[j]+k<y[j+x[i-1]*k])
      				//x[i-1]*k是k张邮票能表示的最大邮资   
                      //+j表示增加了i邮资后能   
                      //判断新增加的能表示的邮资需要多少 
      					{
      						y[j+x[i-1]*k]=y[j]+k;
      						//对第i-2层扩展一个x[i-1]后的邮资分布
      					}
      				  
      				}
      			}
      				//查看邮资范围扩大多少,然后查询y数组从而找到r 
      				while(y[r]<maxint) //计算X[1:i]的最大连续邮资区间
      				{
      					r++;
      				}
      				//搜索求出r-1的值,对应x[1:i-1]的在m张限制内的最大区间 
      				if(i>n) // 如果到达发行邮票的张数,则更新最终结果值,并返回结果
      				{
      					if(r-1>maxvalue)  // 用r计算可贴出的连续邮资最大值,而maxStamp存放最终结果
      					{
      						maxvalue=r-1;
      						for(int j=1;j<=n;j++)
      							bestx[j]=x[j];  // 用x[i]表示当前以确定的第i+1张邮票的面值,bestx保存最终结果
      					}
      						return;
      				}
      				int *z=new int[maxl+1];
      				for(int k=1;k<=maxl;k++)
      					z[k]=y[k];
      				//保留数据副本,以便返回上层时候能够恢复数据   
      				//以上都是处理第i-1层及其之上的问题   
      				for(int j=x[i-1]+1;j<=r;j++) //在第i层有这么多的孩子结点供选择   
      				{
      					x[i]=j;
      					Backtrack(i+1,r);//返回上层恢复信息
      					for(int k=1;k<=maxl;k++)
      						y[k]=z[k];
      				}
      				delete[] z;
      }
       
       
       
      int MaxStamp(int n,int m,int bestx[]){
      	Stamp X;
      	int maxint=32767;
      	int maxl=1500;
      	
      	X.n=n;
      	X.m=m;
      	X.maxvalue=0;
      	X.maxint=maxint;
      	X.maxl=maxl;
      	X.bestx=bestx;
      	X.x=new int [n+1];
      	X.y=new int [maxl+1];
      	
      	for(int i=0;i<=n;i++)
      		X.x[i]=0;
      	for(int i=1;i<=maxl;i++)
      		X.y[i]=maxint;
      	X.x[1]=1;
      	X.y[0]=0;
      	X.Backtrack(2,1);
      	cout<<"当前最优解:";
      	for(int i=1;i<=n;i++)
      		cout<<bestx[i]<<"  ";
      	cout<<endl;
      	delete[] X.x;
      	delete [] X.y;
      	return X.maxvalue;
      }
      
      

实际求解需要对其进行剪枝:解的约束条件是必须满足当前解的邮资可以构成增量为1的连续空间,所以在搜索至树中任一节点时,先判断该节点对应的部分解是否满足约束条件,也就是判断该节点是否包含问题的最优解,如果肯定不包含,则跳过对以该节点为根的子树的搜索,返回上一层的节点,从其他子节点寻找通向最优解的路径;否则,进入以该节点为根的子树,继续按照深度优先策略搜索。
  • 求解过程

    1. 读入邮票面值数n,每张信封最多贴的邮票数m
    2. 读入邮票面值数组nums[],除了正常面值,再加入一个值为0的面值
    3. 循环求取区间最大值maxValue,maxValue初始设为0
      ① 从0张邮票开始搜索解空间,如果当前未达到叶节点,且过程值temp=temp+nums[i]未达到当前记录的区间最大值maxValue,则继续向下搜索
      ② 若超过了区间最大值maxValue,则当前面值不是可行解,计算下一个面值nums[i+1]。若循环结束,当前节点的所有面值都无法满足,则说明再往下搜索也不可能有可行解,这个时候回溯到上一节点
      ③ 若当前已搜索到叶节点,判断当前路径下的解temp是否满足比当前记录的区间最大值maxValue大1。若满足,则更新区间最大值maxValue;若不满足,回溯到上一节点
    4. 重复步骤3直到没有满足当前区间最大值maxValue+1的可行解,则当前记录的maxValue就是区间最大值
  • 代码

    
    class Stamp
    {
    friend int MaxStamp(int  ,int  ,int []);
     
    private:
    	 // int Bound(int i);
    	  void Backtrack(int i,int r);
    	  int n;//邮票面值数
    	  int m;//每张信封最多允许贴的邮票数
    	  int maxvalue;//当前最优值
    	  int maxint;//大整数
    	  int maxl;//邮资上界
    	  int *x;//当前解
    	  int *y;//贴出各种邮资所需最少邮票数
    	  int *bestx;//当前最优解
     
    };
     
    void Stamp::Backtrack(int i,int r)
    {
    	/*计算X[1:i]的最大连续邮资区间,考虑到直接递归的求解复杂度太高,
    	我们不妨尝试计算用不超过m张面值为x[1:i]的邮票贴出邮资k所需的最少邮票数y[k]。
    	通过y[k]可以很快推出r的值。事实上,y[k]可以通过递推在O(n)时间内解决*/
    	for(int j=0;j<=x[i-2]*(m-1);j++) //x[i-2]*(m-1)是第i-2层循环的一个上限,目的是找到r-1的值 
    			if(y[j]<m)
    			{
    				for(int k=1;k<=m-y[j];k++) //k是对表示j剩余的票数进行检查   
    				{
    					if(y[j]+k<y[j+x[i-1]*k])
    				//x[i-1]*k是k张邮票能表示的最大邮资   
                    //+j表示增加了i邮资后能   
                    //判断新增加的能表示的邮资需要多少 
    					{
    						y[j+x[i-1]*k]=y[j]+k;
    						//对第i-2层扩展一个x[i-1]后的邮资分布
    					}
    				  
    				}
    			}
    				//查看邮资范围扩大多少,然后查询y数组从而找到r 
    				while(y[r]<maxint) //计算X[1:i]的最大连续邮资区间
    				{
    					r++;
    				}
    				//搜索求出r-1的值,对应x[1:i-1]的在m张限制内的最大区间 
    				if(i>n) // 如果到达发行邮票的张数,则更新最终结果值,并返回结果
    				{
    					if(r-1>maxvalue)  // 用r计算可贴出的连续邮资最大值,而maxStamp存放最终结果
    					{
    						maxvalue=r-1;
    						for(int j=1;j<=n;j++)
    							bestx[j]=x[j];  // 用x[i]表示当前以确定的第i+1张邮票的面值,bestx保存最终结果
    					}
    						return;
    				}
    				int *z=new int[maxl+1];
    				for(int k=1;k<=maxl;k++)
    					z[k]=y[k];
    				//保留数据副本,以便返回上层时候能够恢复数据   
    				//以上都是处理第i-1层及其之上的问题   
    				for(int j=x[i-1]+1;j<=r;j++) //在第i层有这么多的孩子结点供选择   
    				{
    					x[i]=j;
    					Backtrack(i+1,r);//返回上层恢复信息
    					for(int k=1;k<=maxl;k++)
    						y[k]=z[k];
    				}
    				delete[] z;
    }
     
     
     
    int MaxStamp(int n,int m,int bestx[]){
    	Stamp X;
    	int maxint=32767;
    	int maxl=1500;
    	
    	X.n=n;
    	X.m=m;
    	X.maxvalue=0;
    	X.maxint=maxint;
    	X.maxl=maxl;
    	X.bestx=bestx;
    	X.x=new int [n+1];
    	X.y=new int [maxl+1];
    	
    	for(int i=0;i<=n;i++)
    		X.x[i]=0;
    	for(int i=1;i<=maxl;i++)
    		X.y[i]=maxint;
    	X.x[1]=1;
    	X.y[0]=0;
    	X.Backtrack(2,1);
    	cout<<"当前最优解:";
    	for(int i=1;i<=n;i++)
    		cout<<bestx[i]<<"  ";
    	cout<<endl;
    	delete[] X.x;
    	delete [] X.y;
    	return X.maxvalue;
    }
    
    

书名:算法设计与分析 作者:王晓东 图书目录 第1章 算法引论 1.1 算法与程序 1.2 表达算法的抽象机制 1.3 描述算法 1.4 算法复杂性分析 小结 习题 第2章 递归与分治策略 2.1 速归的概念 2.2 分治法的基本思想 2.3 二分搜索技术 2.4 大整数的乘法 2.5 Strassen矩阵乘法 2.6 棋盘覆盖 2.7 合并排序 2.8 快速排序 2.9 线性时间选择 2.10 最接近点对问题 2.11 循环赛日程表 小结 习题 第3章 动态规划 3.1 矩阵连乘问题 3.2 动态规划算法的基本要素 3.3 最长公共子序列 3.4 凸多边形最优三角剖分 3.5 多边形游戏 3.6 图像压缩 3.7 电路布线 3.8 流水作业调度 3.9 0-1背包问题 3.10 最优二叉搜索树 小结 习题 第4章 贪心算法 4.1 活动安排问题 4.2 贪心算法的基本要素 4.2.1 贪心选择性质 4.2.2 最优子结构性质 4.2.3 贪心算法与动态规划算法的差异 4.3 最优装载 4.4 哈夫曼编码 4.4.1 前缀码 4.4.2 构造哈夫曼编码 4.4.3 哈夫曼算法的正确性 4.5 单源最短路径 4.5.1 算法基本思想 4.5.2 算法的正确性和计算复杂性 4.6 最小生成树 4.6.1 最小生成树性质 4 6.2 Prim算法 4.6.3 Kruskal算法 4.7 多机调度问题 4.8 贪心算法的理论基础 4.8.1 拟阵 4.8.2 带权拟阵的贪心算法 4.8.3 任务时间表问题 小结 习题 第5章 回溯法 5.1 回溯法的算法框架 5.1.1 问题的解空间 5.1.2 回溯法的基本思想 5.1.3 递归回溯 5.1.4 迭代回溯 5.1.5 子集树与排列树 5.2 装载问题 5.3 批处理作业调度 5.4 符号三角形问题 5.5 n后问题 5.6 0-1背包问题 5.7 最大团问题 5.8 图的m着色问题 5.9 旅行售货员问题 5.10 圆排列问题 5.11 电路板排列问题 5.12 连续邮资问题 5.13 回溯法的效率分析 小结 习题 第6章 分支限界法 6.1 分支限界法的基本思想 6.2 单源最短路径问题 6.3 装载问题 6.4 布线问题 6.5 0-1背包问题 6.6 最大团问题 6.7 旅行售货员问题 6.8 电路板排列问题 6.9 批处理作业调度 小结 习题 第7章 概率算法 7.1 随机数 .2 数值概率算法 7.2.1 用随机投点法计算л值 7.2.2 计算定积分 7.2.3 解非线性方程组 7.3 舍伍德算法 7.3.1 线性时间选择算法 7.3.2 跳跃表 7.4 拉斯维加斯算法 7.4.1 n后问题 7.4.2 整数因子分解 7.5 蒙特卡罗算法 7.5.1 蒙特卡罗算法的基本思想 7.5.2 主元素问题 7.5.3 素数测试 小结 习题 第8章 NP完全性理论 8.1 计算模型 8.1.1 随机存取机RAM 8.1.2 随机存取存储程序机RASP 8.1.3 RAM模型的变形与简化 8.1.4 图灵机 8.1.5 图灵机模型与RAM模型的关系 8.1.6 问题变换与计算复杂性归约 8.2 P类与NP类问题 8.2.1 非确定性图灵机 8.2.2 P类与NP类语言 8.2.3 多项式时间验证 8.3 NP完全问题 8.3.1 多项式时间变换 8.3.2 Cook定理 8.4 一些典型的NP完全问题 8.4.1 合取范式的可满足性问题 8.4.2 3元合取范式的可满足性问题 8.4.3 团问题 8.4.4 顶点覆盖问题 8.4.5 子集和问题 8.4.6 哈密顿回路问题
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值