迭代,递归,回溯,P问题,NP问题,NP 完全问题,NP难问题,贪心,动态规划,Divide and Conquer

     维基百科:迭代(Iteration)是为了产生一系列结果的一种过程的重复,每一次该过程的重复是一次单次迭代。在数学和计算机科学中,迭代是算法的基本元素。
     在计算机科学中,迭代是一种标示在计算机程序中会重复执行一定次数的语句块的技术。这个语句块被说成是“迭代的”,计算机科学家也可能将那个语句块作为迭代谈到。下面的伪代码就是迭代的一个例子,大括号里面的代码将会“迭代”3次。

a = 0
for i from 1 to 3        // loop three times
{
    a = a + i            // add the current value of i to a
}
print a                  // the number 6 is printed (0 + 1; 1 + 2; 3 + 3)

/*---------------------------------------------分割线----------------------------------------------------------*/
     在计算机科学和数学中,一类方法或对象呈现出递归,Recursion,行为,在这类方法可以被两种属性定义的时候:(这里我就不翻译了直接放维基百科的内容,因为这里我找不到准确的翻译)

  1. A simple base case (or cases) — a terminating scenario that does not use recursion to produce an answer
  2. A recursive step — a set of rules that reduces all successive cases toward the base case.

      F i b o n a c c i Fibonacci Fibonacci数列(0,1,1,2,3,5,8,13,21,…)就是一个经典的递归的例子,它的定义如下:
{ F ( 0 ) = 0 , F ( 1 ) = 1 , F ( n ) = F ( n − 1 ) + F ( n − 2 ) , n ≥ 2 \left\{ \begin{array}{lr} F(0)=0, & \\ F(1)=1, & \\ F(n)=F(n-1)+F(n-2),n\geq2 & \end{array} \right. F(0)=0,F(1)=1,F(n)=F(n1)+F(n2),n2
     这里 F ( 0 ) = 0 F(0)=0 F(0)=0 F ( 1 ) = 1 F(1)=1 F(1)=1,就是上面的 A s i m p l e b a s e c a s e A\quad simple \quad base\quad case Asimplebasecase,在下面讲解动态规划的定义的时候可以看到在求某一个索引值的 F i b o n a c c i Fibonacci Fibonacci数的时候函数的递归调用到 F ( 0 ) = 0 F(0)=0 F(0)=0 F ( 1 ) = 1 F(1)=1 F(1)=1的时候就不会继续往下递归调用下去了。 F ( n ) = F ( n − 1 ) + F ( n − 2 ) , n ≥ 2 F(n)=F(n-1)+F(n-2),n\geq2 F(n)=F(n1)+F(n2),n2就是上面的 A r e c u r s i v e s t e p A\quad recursive \quad step Arecursivestep。它确定了走向上面的 A s i m p l e b a s e c a s e A\quad simple \quad base\quad case Asimplebasecase的规则。
     我根据维基百科关于递归的定义,现在对递归做如下定义。递归是一个程序( p r o c e d u r e procedure procedure)走的流程( p r o c e s s process process)。当然这个程序是( p r o c e d u r e procedure procedure)特殊的,它的特殊之处在于其某一步就是这个程序( p r o c e d u r e procedure procedure)本身。一个程序( p r o c e d u r e procedure procedure)是基于一组规则的一组执行步骤。在计算机编程中我们大部分对递归的直观理解就是一个函数的定义中调用了这个函数自己。
/*---------------------------------------------分割线----------------------------------------------------------*/
     在算法的情景下,迭代和递归可以达到同样的效果。主要的区别是递归可以用于解决事先不知道需要重复执行的次数的问题,但是迭代的话,就需要事先知道。
/*---------------------------------------------分割线----------------------------------------------------------*/
     回溯算法 ( B a c k t r a c k i n g ) (Backtracking) (Backtracking)是一种用来解决需要满足某些特定条件的问题的通用的算法。这种算法会从待解决问题的部分解开始一步一步的构建待解决问题的完整解,如果在此过程中它发现以当前的部分解构成的完整解无法成为问题的有效完整解的时候,他会立即放弃寻找以该部分解为基础的完整解,而是转而寻找以其它部分解为基础的完整解。可以利用回溯算法解决的问题需要满足一定的条件,那就是需要满足部分有效解的概念,以及可以快速的判断以该部分有效解为基础是否可以扩展为完整解。如果不能快速的判断以该部分有效解为基础是否可以扩展为完整解的话,那么最后的结果和暴力解法(把所有可能的组合一一列出来再来判断每一个是否是满足特定条件的解)就没有什么区别了。对于像在一个无序的列表之中确定某个元素是否存在的问题肯定不能利用回溯算法来解决。
     回溯算法通常用递归的形式来实现,一个完整的回溯算法解决问题的过程就是一颗递归调用树,从根节点开始不断向下扩展的过程就是不断增长部分解的过程,形式和深度优先搜索一样,每次到达树的某一个节点 C C C,算法都会检查以从根节点开始到该节点结束构成的部分解是否可以扩展为待解决问题的完整解,如果不能则以节点 C C C为根节点的子树全部都会被砍掉,再也不会纳入待解决问题的完整解的搜索范围。如果可以则会继续以节点 C C C的所有子节点为基础递归的查找。树的叶子节点就是相当于找到了完整解。前面说过递归调用的过程和深度优先搜索一样,不同的是深度优先搜索的过程是一颗完整的树,这里的树是一颗在深度优先搜索树的基础上剪枝了的树,被剪掉的枝是那些不能继续扩展为完整解的部分解。
     为了将回溯算法应用于某个问题 P P P,我们先用伪代码说一下算法的具体过程,这里先给出五个用来组合成算法的模块:

  1. reject(P, c): 当部分解 c c c不能扩展为完整解的时候返回真。
  2. accept(P, c): 当部分解 c c c此时为完整解的时候返回真。
  3. first(P, c): 返回部分解 c c c的第一个子节点。
  4. next(P, s): 返回部分解 c c c的下一个子节点,这里 s s s为部分解 c c c的第一个子节点。
  5. output(P, c): 输出完整解 c c c

     算法的伪代码描述如下,这里在实际解决问题的时候从树的根节点开始递归调用。

procedure backtrack(c) is
    if reject(P, c) then return
    if accept(P, c) then output(P, c)
    s ← first(P, c)
    while s ≠ NULL do
        backtrack(s)
        s ← next(P, s)

     八皇后问题是一个经典用回溯算法解决的例子,下面我们就用这个例子来讲解一下整个回溯算法的过程,但是这里没有代码实现,代码实现见这里。对于八皇后问题我这里就不用背景相关的国际象棋的术语去解释了,简单来说就是在一个 8 × 8 8\times 8 8×8的方格里面放置8个方块,这8个方块中的任何两个方块都不能在同一行和同一列且不能在同一对角线上。图1就是八皇后问题的一个解。

 
图1.

     上文提到在实际解决问题的时候从树的根节点开始递归调用。那这里的根节点就是 8 × 8 8\times 8 8×8的方格还没有放置任何方块的时刻,之后从 8 × 8 8\times 8 8×8的方格的第一行的第一个方格开始递归调用,调用结束之后递归调用第一行的第二个方格,直到递归调用完第一行的第八个方格。这里从第一行的每一个方块开始的递归调用的递归调用图都是根节点的一个子树。这里对于第一行的8个方格采用的是从左到右的调用顺序,对于接下来第2,3,4,5,6,7,8行也是从左到右的调用顺序。这里从某一行某一列的位置开始递归调用的意思就是将方块放在该位置。这里从某一行的第一个方块开始的递归调用对应于上面的<font size=5color=green>first(P, c)操作,这里从某一行的第2,3,4 ,5,,6,7, 8个方块开始的递归调用对应于上面的next(P, c)操作。当从某一行某一列的方格开始递归调用的时候检查:

  1. 前面放置的某个方块是否和当前位置同在一行
  2. 前面放置的某个方块是否和当前位置同在一列
  3. 前面放置的某个方块是否和当前位置同在一对角线上

     如果满足以上三点中任意一点,以当前位置开始的递归调用就要结束,也就是以当前位置为根节点的子树要被砍掉。这里对应于上面的reject(P, c)操作。当递归调用到第8行且reject(P, c)操作返回假的时候,这时就表明得到了一个完整解,这里对应了上面的accept(P, c)操作,然后可以输出完整解上面的output(P, c)操作。下面我再给一个例子。
     迷宫 ( M a z e ) (Maze) (Maze)问题也是一个比较经典的问题,根据维基百科的描述,迷宫分很多种,针对该问题也有很多的解决算法,感兴趣的自己可以去详细的看一看。今天讲解的回溯算法实际解决问题的例子的迷宫问题如图2所示。这里有一个 N × N N\times N N×N个方格组成的区域,白色的方格表示可以通行的区域,黑色的方格表示墙,不能通行。当位于白色方格的时候可以向上、下、左、右四个方向通行,每次只能行进一个方格,每次行进过某个方格之后标记该方格已经被访问,遇到被标记为已经被访问过的方格也是不能向这个方格行进的。这里问题要求的是找到所有从源( s o u r c e , s source,s source,s)到目的地( d e s t i n a t i o n , d destination,d destination,d)的路径,针对图的例子,这里有两条从源到目的地的路径,路径1(被红色曲线标示)行走的方向的序列为:右,右,下,右,下,下,路径2(被紫色曲线标示)行走的方向的序列为:下,下,右,下,右,右。

 
图2.

     当然针对我们上面的迷宫问题描述,就会有一些变种,比如图3和图4所示的只能向两个方向行进的和可以向8个方向行进的。当然还有一些可以每次行进多个方格或者可以跳跃的变种,这里就不多说了。

 
图3.
 
图4.

     下面我们来讲一下算法思想,这里将会使用递归的方法来实现,递归结束的条件有三个:

  1. 遇到黑色方块
  2. 遇到当前路径中前面已经访问过的方块
  3. 移出了 N × N N\times N N×N个方格组成的区域

     如果把 N × N N\times N N×N个方格组成的区域看成是一个矩阵的话,源方块的位置就是索引为 ( 0 , 0 ) (0,0) (0,0)元素,目的地方块的位置就是索引为 ( N − 1 , N − 1 ) (N-1,N-1) (N1N1)元素。这里的算法从源方块开始按顺时针方向(先左边的方块,然后上面的方块,接着右边的方块,最后下面的方块)依次递归的访问其四个方向上的方块,每次访问一个方块之前会记录到达当前方块时所走的方向,访问完之后会删除该方向记录。每次在递归访问其四个方向的方块之前都会标记当前方块已经访问,以免后续的访问路径再次访问。遇到递归的结束条件就返回以便继续寻找其它方向的可行路径,这里遇到递归的结束条件不会标记当前方块已经访问,还是保持未访问的状态。当到达目的地方块的时候就保存当前到达目的地节点的一整个方向序列,这里也是一条到达目的地节点的路径,到达目的地节点之后也会返回,以便寻找其它的可行的路径。
     基于图2的例子的递归递归调用路径如图5所示,但是这里我只画出了两条路径中的红色曲线标示的那条路径的递归调用图,绿色方框就是那条红色曲线标示的路径。另一条路径的递归调用图类似。这里经过红色的那条路径达到目的地节点之后,会将这条路径所走过的节点的方向序列保存下来,这就是红色曲线标示的那条路径,然后一步一步的返回到 M a z e ( 0 , 0 ) Maze(0,0) Maze(0,0)的调用里面,这一路返回也会将图5中几个绿色方块中除方块 ( 0 , 0 ) (0,0) (0,0)之外的方块全部标示为未访问的状态以免影响下一条不同的完整路径的探索。之后接着就会递归调用 M a z e ( 1 , 0 ) Maze(1,0) Maze(1,0),这就将发现另外一条完整的路径。这里的算法实现中访问完一个方块的几个方向的路径之后再返回来访问另外几个方向上的路径,我觉得这里就是"回溯"的含义了。

 
图5.

     测试代码和测试结果如下所示,这里用一个 N × N N\times N N×N的二维数组表示迷宫,数组元素为0表示黑色的不可行进的方块,数组元素为1表示白色的可行进的方块,源方块是二维数组中索引为 ( 0 , 0 ) (0,0) (0,0)的元素,目的地方块是二维数组中索引为 ( N − 1 , N − 1 ) (N-1,N-1) (N1,N1)的元素。以下代码参考于这里

 
图6.
#include"Header.h"

vector<string> maze_route_set;

void dfs(vector<vector<int>>& maze, int row_index, int column_index, int maze_size, string& current_route, vector<vector<int>>& visited)
{
	/*Move out of the N×N block space.*/
	if (row_index < 0 || row_index >= maze_size || column_index < 0 || column_index >= maze_size)
		return;
	/*This is the black block or the block has been visited.*/
	if (maze[row_index][column_index] == 0 || visited[row_index][column_index] == 1)
		return;
	/*We have already arrived the destination.*/
	if ((row_index == maze_size - 1) && (column_index == maze_size - 1))
	{
		maze_route_set.push_back(current_route);
		return;
	}

	visited[row_index][column_index]  = 1;

	/*Visit the left block*/
	current_route.push_back('L');
	dfs(maze, row_index, column_index - 1, maze_size, current_route, visited);
	current_route.pop_back();

	/*Visit the up block*/
	current_route.push_back('U');
	dfs(maze, row_index - 1, column_index, maze_size, current_route, visited);
	current_route.pop_back();

	/*Visit the right block*/
	current_route.push_back('R');
	dfs(maze, row_index, column_index + 1, maze_size, current_route, visited);
	current_route.pop_back();

	/*Visit the down block*/
	current_route.push_back('D');
	dfs(maze, row_index + 1, column_index, maze_size, current_route, visited);
	current_route.pop_back();

	visited[row_index][column_index] = 0;

	return;

}
void MazeRoute(vector<vector<int>> maze, vector<vector<int>> visited)
{
	string current_route = "";
	dfs(maze, 0, 0, maze.size(), current_route, visited);
	return;
}


int main()
{
	vector<vector<int>> Maze(4);
	vector<vector<int>> visited(4);
	Maze[0] = { 1,1,1,0 };
	Maze[1] = { 1,0,1,1 };
	Maze[2] = { 1,1,0,1 };
	Maze[3] = { 0,1,1,1 };


	for (int i = 0; i < 4; i++)
	{
		visited[i].resize(4,0);
	}

	cout << "Our test maze is:" << endl;
	for (int i = 0; i < 4; i++)
	{
		for (int j = 0; j < 4; j++)
		{
			cout << Maze[i][j] << "  ";
		}
		cout << endl;
	}
	cout << endl;
	cout << "The visited array after initialization is:" << endl;
	for (int i = 0; i < 4; i++)
	{
		for (int j = 0; j < 4; j++)
		{
			cout << visited[i][j] << "  ";
		}
		cout << endl;
	}
	cout << endl;

	MazeRoute(Maze,visited);

	cout << "All the possible path of our test maze is:" << endl;
	for (int i = 0; i < maze_route_set.size(); i++)
	{
	    cout << maze_route_set[i] << endl;
	}
	cout << endl;
	cout << "The visited array after computation is:" << endl;
	for (int i = 0; i < 4; i++)
	{
		for (int j = 0; j < 4; j++)
		{
			cout << visited[i][j] << "  ";
		}
		cout << endl;
	}
	cout << endl;
	return 0;
}

/*---------------------------------------------分割线----------------------------------------------------------*/
     当我们在讨论算法相关问题的时候,不免都会碰见以下关键字:

  • P问题
  • NP问题
  • NP完全问题
  • NP难问题

其实这几个关键字真的要深究的话还是一个比较高端和牛逼的问题,如果涉及到理论方面的问题,我等小白就彻底跪了。当然对于我们工程师来说的话对这些概念有一个基本的理解就可以了,这里我就不自己去讲解这几个关键字的相关信息了,我直接复制了一篇网上都认为写的比较通透适合小白的文章,这也方面我自己日后查阅。文章的出处在这里:什么是P问题、NP问题和NPC问题

     这或许是众多OIer最大的误区之一。
     你会经常看到网上出现 “ 这 怎 么 做 , 这 不 是 N P 问 题 吗 ? ” “这怎么做,这不是NP问题吗?” NP “ 这 个 只 有 搜 了 , 这 已 经 被 证 明 是 N P 问 题 了 ” “这个只有搜了,这已经被证明是NP问题了” NP之类的话。你要知道,大多数人此时所说的 N P NP NP问题其实都是指的 N P C NPC NPC问题。他们没有搞清楚 N P NP NP问题和 N P C NPC NPC问题的概念。 N P NP NP问题并不是那种 “ 只 有 搜 才 行 “只有搜才行 ”的问题, N P C NPC NPC问题才是。好,行了,基本上这个误解已经被澄清了。下面的内容都是在讲什么是 P P P问题,什么是 N P NP NP问题,什么是 N P C NPC NPC问题,你如果不是很感兴趣就可以不看了。接下来你可以看到,把 N P NP NP问题当成是 N P C NPC NPC问题是一个多大的错误。
     还是先用几句话简单说明一下时间复杂度。时间复杂度并不是表示一个程序解决问题需要花多少时间,而是当问题规模扩大后,程序需要的时间长度增长得有多快。也就是说,对于高速处理数据的计算机来说,处理某一个特定数据的效率不能衡量一个程序的好坏,而应该看当这个数据的规模变大到数百倍后,程序运行时间是否还是一样,或者也跟着慢了数百倍,或者变慢了数万倍。不管数据有多大,程序处理花的时间始终是那么多的,我们就说这个程序很好,具有 O ( 1 ) O(1) O(1)的时间复杂度,也称常数级复杂度;数据规模变得有多大,花的时间也跟着变得有多长,这个程序的时间复杂度就是 O ( n ) O(n) O(n),比如找 n n n个数中的最大值;而像冒泡排序、插入排序等,数据扩大2倍,时间变慢4倍的,属于 O ( n 2 ) O(n^2) O(n2)的复杂度。还有一些穷举类的算法,所需时间长度成几何阶数上涨,这就是 O ( a n ) O(a^n) O(an)的指数级复杂度,甚至 O ( n ! ) O(n!) O(n!)的阶乘级复杂度。不会存在 O ( 2 ∗ n 2 ) O(2*n^2) O(2n2)的复杂度,因为前面的那个2是系数,根本不会影响到整个程序的时间增长。同样地 O ( n 3 + n 2 ) O(n^3+n^2) O(n3+n2)的复杂度也就是 O ( n 3 ) O(n^3) O(n3)的复杂度。因此,我们会说,一个 O ( 0.01 ∗ n 3 ) O(0.01*n^3) O(0.01n3)的程序的效率比 O ( 100 ∗ n 2 ) O(100*n^2) O(100n2)的效率低,尽管在n很小的时候,前者优于后者,但后者时间随数据规模增长得慢,最终 O ( n 3 ) O(n^3) O(n3)的复杂度将远远超过 O ( n 2 ) O(n^2) O(n2)。我们也说, O ( n 1 00 ) O(n^100) O(n100)的复杂度小于 O ( ( 1.01 ) n ) O((1.01)^n) O((1.01)n)的复杂度。
     容易看出,前面的几类复杂度被分为两种级别,其中后者的复杂度无论如何都远远大于前者:一种是 O ( 1 ) O(1) O(1) O ( log ⁡ ( n ) ) O(\log(n)) O(log(n)) O ( n a ) O(n^a) O(na)等,我们把它叫做多项式级的复杂度,因为它的规模 n n n出现在底数的位置;另一种是 O ( a n ) O(a^n) O(an) O ( n ! ) O(n!) O(n!)型复杂度,它是非多项式级的,其复杂度计算机往往不能承受。当我们在解决一个问题时,我们选择的算法通常都需要是多项式级的复杂度,非多项式级的复杂度需要的时间太多,往往会超时,除非是数据规模非常小。
     自然地,人们会想到一个问题:会不会所有的问题都可以找到复杂度为多项式级的算法呢?很遗憾,答案是否定的。有些问题甚至根本不可能找到一个正确的算法来,这称之为 不 可 解 问 题 ( U n d e c i d a b l e D e c i s i o n P r o b l e m ) 不可解问题(Undecidable\quad Decision\quad Problem) (UndecidableDecisionProblem) T h e H a l t i n g P r o b l e m The\quad Halting\quad Problem TheHaltingProblem就是一个著名的不可解问题,在我的 B l o g Blog Blog上有过专门的介绍和证明。再比如,输出从1到 n n n n n n个数的全排列。不管你用什么方法,你的复杂度都是阶乘级,因为你总得用阶乘级的时间打印出结果来。有人说,这样的“问题”不是一个“正规”的问题,正规的问题是让程序解决一个问题,输出一个 Y E S YES YES N O NO NO(这被称为判定性问题),或者一个什么什么的最优值(这被称为最优化问题)。那么,根据这个定义,我也能举出一个不大可能会有多项式级算法的问题来: H a m i l t o n Hamilton Hamilton回路。问题是这样的:给你一个图,问你能否找到一条经过每个顶点一次且恰好一次(不遗漏也不重复)最后又走回来的路(满足这个条件的路径叫做 H a m i l t o n Hamilton Hamilton回路)。这个问题现在还没有找到多项式级的算法。事实上,这个问题就是我们后面要说的 N P C NPC NPC问题。
     下面引入P类问题的概念:如果一个问题可以找到一个能在多项式的时间里解决它的算法,那么这个问题就属于 P P P问题。P是英文单词多项式的第一个字母。哪些问题是P类问题呢?通常 N O I NOI NOI N O I P NOIP NOIP不会出不属于P类问题的题目。我们常见到的一些信息奥赛的题目都是P问题。道理很简单,一个用穷举换来的非多项式级时间的超时程序不会涵盖任何有价值的算法。
     接下来引入 N P NP NP问题的概念。这个就有点难理解了,或者说容易理解错误。在这里强调(回到我竭力想澄清的误区上), N P NP NP问题不是非 P P P类问题。 N P NP NP问题是指可以在多项式的时间里验证一个解的问题。 N P NP NP问题的另一个定义是,可以在多项式的时间里猜出一个解的问题。比方说,我 R P RP RP很好,在程序中需要枚举时,我可以一猜一个准。现在某人拿到了一个求最短路径的问题,问从起点到终点是否有一条小于100个单位长度的路线。它根据数据画好了图,但怎么也算不出来,于是来问我:你看怎么选择路走得最少?我说,我 R P RP RP很好,肯定能随便给你指条很短的路出来。然后我就胡乱画了几条线,说就这条吧。那人按我指的这条把权值加起来一看,嘿,神了,路径长度98,比100小。于是答案出来了,存在比100小的路径。别人会问他这题怎么做出来的,他就可以说,因为我找到了一个比100 小的解。在这个题中,找一个解很困难,但验证一个解很容易。验证一个解只需要 O ( n ) O(n) O(n)的时间复杂度,也就是说我可以花 O ( n ) O(n) O(n)的时间把我猜的路径的长度加出来。那么,只要我 R P RP RP好,猜得准,我一定能在多项式的时间里解决这个问题。我猜到的方案总是最优的,不满足题意的方案也不会来骗我去选它。这就是 N P NP NP问题。当然有不是 N P NP NP问题的问题,即你猜到了解但是没用,因为你不能在多项式的时间里去验证它。下面我要举的例子是一个经典的例子,它指出了一个目前还没有办法在多项式的时间里验证一个解的问题。很显然,前面所说的 H a m i l t o n Hamilton Hamilton回路是 N P NP NP问题,因为验证一条路是否恰好经过了每一个顶点非常容易。但我要把问题换成这样:试问一个图中是否不存在 H a m i l t o n Hamilton Hamilton回路。这样问题就没法在多项式的时间里进行验证了,因为除非你试过所有的路,否则你不敢断定它“没有 H a m i l t o n Hamilton Hamilton回路”。
     之所以要定义 N P NP NP问题,是因为通常只有 N P NP NP问题才可能找到多项式的算法。我们不会指望一个连多项式地验证一个解都不行的问题存在一个解决它的多项式级的算法。相信读者很快明白,信息学中的号称最困难的问题——“ N P NP NP问题”,实际上是在探讨 N P NP NP问题与 P P P类问题的关系。
     很显然,所有的 P P P类问题都是NP问题。也就是说,能多项式地解决一个问题,必然能多项式地验证一个问题的解——既然正解都出来了,验证任意给定的解也只需要比较一下就可以了。关键是,人们想知道,是否所有的 N P NP NP问题都是 P P P类问题。我们可以再用集合的观点来说明。如果把所有 P P P类问题归为一个集合 P P P中,把所有 N P NP NP问题划进另一个集合 N P NP NP中,那么,显然有 P P P属于 N P NP NP。现在,所有对 N P NP NP问题的研究都集中在一个问题上,即究竟是否有 P = N P ? P=NP? P=NP通常所谓的“ N P NP NP问题”,其实就一句话:证明或推翻 P = N P P=NP P=NP
      N P NP NP问题一直都是信息学的巅峰。巅峰,意即很引人注目但难以解决。在信息学研究中,这是一个耗费了很多时间和精力也没有解决的终极问题,好比物理学中的大统一和数学中的歌德巴赫猜想等。
     目前为止这个问题还“啃不动”。但是,一个总的趋势、一个大方向是有的。人们普遍认为, P = N P P=NP P=NP不成立,也就是说,多数人相信,存在至少一个不可能有多项式级复杂度的算法的 N P NP NP问题。人们如此坚信 P ≠ N P P≠NP P=NP是有原因的,就是在研究 N P NP NP问题的过程中找出了一类非常特殊的 N P NP NP问题叫做 N P 完 全 问 题 NP完全问题 NP,也即所谓的 N P C NPC NPC问题。 C C C是英文单词“完全”的第一个字母。正是 N P C NPC NPC问题的存在,使人们相信 P ≠ N P P≠NP P=NP。下文将花大量篇幅介绍 N P C NPC NPC问题,你从中可以体会到 N P C NPC NPC问题使 P = N P P=NP P=NP变得多么不可思议。
     为了说明NPC问题,我们先引入一个概念——约化( R e d u c i b i l i t y Reducibility Reducibility,有的资料上叫“归约”)。
     简单地说,一个问题 A A A可以约化为问题 B B B的含义即是,可以用问题 B B B的解法解决问题 A A A,或者说,问题 A A A可以“变成”问题 B B B。《算法导论》上举了这么一个例子。比如说,现在有两个问题:求解一个一元一次方程和求解一个一元二次方程。那么我们说,前者可以约化为后者,意即知道如何解一个一元二次方程那么一定能解出一元一次方程。我们可以写出两个程序分别对应两个问题,那么我们能找到一个“规则”,按照这个规则把解一元一次方程程序的输入数据变一下,用在解一元二次方程的程序上,两个程序总能得到一样的结果。这个规则即是:两个方程的对应项系数不变,一元二次方程的二次项系数为0。按照这个规则把前一个问题转换成后一个问题,两个问题就等价了。同样地,我们可以说, H a m i l t o n Hamilton Hamilton回路可以约化为TSP问题(Travelling Salesman Problem,旅行商问题):在 H a m i l t o n Hamilton Hamilton回路问题中,两点相连即这两点距离为0,两点不直接相连则令其距离为1,于是问题转化为在 T S P TSP TSP问题中,是否存在一条长为0的路径。 H a m i l t o n Hamilton Hamilton回路存在当且仅当 T S P TSP TSP问题中存在长为0的回路。
     “问题 A A A可约化为问题 B B B”有一个重要的直观意义: B B B的时间复杂度高于或者等于 A A A的时间复杂度。也就是说,问题 A A A不比问题 B B B难。这很容易理解。既然问题 A A A能用问题 B B B来解决,倘若 B B B的时间复杂度比 A A A的时间复杂度还低了,那 A A A的算法就可以改进为 B B B的算法,两者的时间复杂度还是相同。正如解一元二次方程比解一元一次方程难,因为解决前者的方法可以用来解决后者。
     很显然,约化具有一项重要的性质:约化具有传递性。如果问题 A A A可约化为问题 B B B,问题 B B B可约化为问题 C C C,则问题 A A A一定可约化为问题 C C C。这个道理非常简单,就不必阐述了。
     现在再来说一下约化的标准概念就不难理解了:如果能找到这样一个变化法则,对任意一个程序 A A A的输入,都能按这个法则变换成程序 B B B的输入,使两程序的输出相同,那么我们说,问题 A A A可约化为问题 B B B
     当然,我们所说的“可约化”是指的可“多项式地”约化( P o l y n o m i a l − t i m e R e d u c i b l e Polynomial-time\quad Reducible PolynomialtimeReducible),即变换输入的方法是能在多项式的时间里完成的。约化的过程只有用多项式的时间完成才有意义。
     好了,从约化的定义中我们看到,一个问题约化为另一个问题,时间复杂度增加了,问题的应用范围也增大了。通过对某些问题的不断约化,我们能够不断寻找复杂度更高,但应用范围更广的算法来代替复杂度虽然低,但只能用于很小的一类问题的算法。再回想前面讲的 P P P N P NP NP问题,联想起约化的传递性,自然地,我们会想问,如果不断地约化上去,不断找到能“通吃”若干小 N P NP NP问题的一个稍复杂的大 N P NP NP问题,那么最后是否有可能找到一个时间复杂度最高,并且能“通吃”所有的 N P NP NP问题的这样一个超级 N P NP NP问题?答案居然是肯定的。也就是说,存在这样一个 N P NP NP问题,所有的 N P NP NP问题都可以约化成它。换句话说,只要解决了这个问题,那么所有的 N P NP NP问题都解决了。这种问题的存在难以置信,并且更加不可思议的是,这种问题不只一个,它有很多个,它是一类问题。这一类问题就是传说中的 N P C NPC NPC问题,也就是 N P 完 全 问 题 NP完全问题 NP N P C NPC NPC问题的出现使整个 N P NP NP问题的研究得到了飞跃式的发展。我们有理由相信, N P C NPC NPC问题是最复杂的问题。再次回到全文开头,我们可以看到,人们想表达一个问题不存在多项式的高效算法时应该说它“属于 N P C NPC NPC问题”。此时,我的目的终于达到了,我已经把 N P NP NP问题和 N P C NPC NPC问题区别开了。到此为止,本文已经写了近5000字了,我佩服你还能看到这里来,同时也佩服一下自己能写到这里来。
      N P C NPC NPC问题的定义非常简单。同时满足下面两个条件的问题就是 N P C NPC NPC问题。首先,它得是一个 N P NP NP问题;然后,所有的 N P NP NP问题都可以约化到它。证明一个问题是 N P C NPC NPC问题也很简单。先证明它至少是一个 N P NP NP问题,再证明其中一个已知的 N P C NPC NPC问题能约化到它(由约化的传递性,则 N P C NPC NPC问题定义的第二条也得以满足;至于第一个 N P C NPC NPC问题是怎么来的,下文将介绍),这样就可以说它是 N P C NPC NPC问题了。
     既然所有的 N P NP NP问题都能约化成 N P C NPC NPC问题,那么只要任意一个 N P C NPC NPC问题找到了一个多项式的算法,那么所有的 N P NP NP问题都能用这个算法解决了, N P NP NP也就等于 P P P了。因此,给 N P C NPC NPC找一个多项式算法太不可思议了。因此,前文才说,“正是 N P C NPC NPC问题的存在,使人们相信 P ≠ N P P≠NP P=NP”。我们可以就此直观地理解, N P C NPC NPC问题目前没有多项式的有效算法,只能用指数级甚至阶乘级复杂度的搜索。
      顺便讲一下 N P − H a r d NP-Hard NPHard问题。 N P − H a r d NP-Hard NPHard问题是这样一种问题,它满足NPC问题定义的第二条但不一定要满足第一条(就是说, N P − H a r d NP-Hard NPHard问题要比 N P C NPC NPC问题的范围广)。 N P − H a r d NP-Hard NPHard问题同样难以找到多项式的算法,但它不列入我们的研究范围,因为它不一定是 N P NP NP问题。即使 N P C NPC NPC问题发现了多项式级的算法, N P − H a r d NP-Hard NPHard问题有可能仍然无法得到多项式级的算法。事实上,由于 N P − H a r d NP-Hard NPHard放宽了限定条件,它将有可能比所有的NPC问题的时间复杂度更高从而更难以解决。
     不要以为 N P C NPC NPC问题是一纸空谈。 N P C NPC NPC问题是存在的。确实有这么一个非常具体的问题属于 N P C NPC NPC问题。下文即将介绍它。
     下文即将介绍逻辑电路问题。这是第一个 N P C NPC NPC问题。其它的 N P C NPC NPC问题都是由这个问题约化而来的。因此,逻辑电路问题是 N P C NPC NPC类问题的“鼻祖”。
     逻辑电路问题是指的这样一个问题:给定一个逻辑电路,问是否存在一种输入使输出为 T r u e True True

 
图7.

     什么叫做逻辑电路呢?一个逻辑电路由若干个输入,一个输出,若干“逻辑门”和密密麻麻的线组成。看图7中的 a a a b b b,不需要解释你马上就明白了。(这里我没有用原文中的例子,原文中的例子不太好看,我在别的PPT文件中找到了这个例子)
      图7中的 a a a是个较简单的逻辑电路,当输入 x 1 = 1 x_1=1 x1=1、输入 x 2 = 1 x_2=1 x2=1、输入 x 3 = 0 x_3=0 x3=0时,输出为True。有输出无论如何都不可能为True的逻辑电路吗?有。图7中的 b b b就是一个简单的例子,无论输入是什么,输出都是False。我们就说,这个逻辑电路不存在使输出为True的一组输入。
     回到上文,给定一个逻辑电路,问是否存在一种输入使输出为True,这即逻辑电路问题。
     逻辑电路问题属于 N P C NPC NPC问题。这是有严格证明的。它显然属于 N P NP NP问题,并且可以直接证明所有的 N P NP NP问题都可以约化到它(不要以为 N P NP NP问题有无穷多个将给证明造成不可逾越的困难)。证明过程相当复杂,其大概意思是说任意一个 N P NP NP问题的输入和输出都可以转换成逻辑电路的输入和输出(想想计算机内部也不过是一些 0和1的运算),因此对于一个 N P NP NP问题来说,问题转化为了求出满足结果为True的一个输入(即一个可行解)。
     有了第一个 N P C NPC NPC问题后,一大堆 N P C NPC NPC问题就出现了,因为再证明一个新的 N P C NPC NPC问题只需要将一个已知的 N P C NPC NPC问题约化到它就行了。后来, H a m i l t o n Hamilton Hamilton回路成了 N P C NPC NPC问题, T S P TSP TSP问题也成了 N P C NPC NPC问题。现在被证明是 N P C NPC NPC问题的有很多,任何一个找到了多项式算法的话所有的 N P NP NP问题都可以完美解决了。因此说,正是因为 N P C NPC NPC问题的存在, P = N P P=NP P=NP变得难以置信。 P = N P P=NP P=NP问题还有许多有趣的东西,有待大家自己进一步的挖掘。攀登这个信息学的巅峰是我们这一代的终极目标。现在我们需要做的,至少是不要把概念弄混淆了。
     至于以上主题我也在网上找到了两个不错的 P D F PDF PDF文档资料,图7来至于文档1:

  1. P, NP, NP-Hard & NP-complete problems
  2. INTRACTABILITY II

/*-----------------------------------------分割线--------------------------------------------------------------*/

     贪心算法不是某一个具体的算法,比如求某一个源点到到其它顶点的最短路径的迪杰斯特拉( D i j k s t r a Dijkstra Dijkstra)算法,它可以说是一种解决问题的指导性策略。只要待解决的问题满足一定的属性就可以根据这种策略来进行求解。贪心算法我们从字面上理解就是:只看到眼前的利益,而没有做长远打算,它在解决问题的每一步中都选择局部最优解来试图求得全局最优解。就是因为它的这种策略,因此贪心算法不能很好的解决某一部分问题,无法求得全局最优解。例如图8中求从根节点到叶子节点的所有路径中权值和最大的路径的过程中,根据贪心算法的局部最优化原则,访问根节点之后,接下来会访问权值为12( 12 > 3 12>3 12>3)的节点,最后再访问权值为6( 6 > 5 6>5 6>5)的节点,但是这里我们这里实际的最优解为:根节点之后,接下来会访问权值为3的节点,最后再访问权值为99的节点。图8来至于这里,关于贪心算法的描述也部分参考了图8的描述以及维基百科的定义。

 
图8.

     但是贪心算法也不是一无是处,如果要解决的问题满足以下两条属性,那么它们就可以适用于贪心算法并且贪心算法也可以求得问题的最优解

  1. 贪心选择属性:贪心算法当前所作出的抉择可能会基于以前的抉择,但是绝对不会考虑接下来的或者整体的抉择。贪心算法迭代的一步一步的作出贪心选择,使得要解决的整体问题的规模越来越小。贪心算法不会再一次的考虑已经做过 的抉择,也就是说不会回溯。贪心算法每次作出的贪心选择都是最优解的一部分,将每一步作出的贪心选择组合起来之后就可以得到全局最优解最优解如果无法满足绿色字体的要求的话,那采用贪心算法得到的最后结果就不是全局最优解,可能是接近全局最优解的解,也可能是最差的解
  2. 最优子结构:"A problem exhibits optimal substructure if an optimal solution to the problem contains optimal solutions to the sub-problems." (以上语句请参考维基百科中贪心算法的介绍)。最优子结构从字面上理解就是如果一个待解决问题呈现出最优子结构特性,那么该问题的最优解必然包含了该问题的子问题的最优解。也就是说一个呈现最优子结构特性的问题其最优解可以从它的子问题的最优解获得。举个简单的例子,图的两点之间的最短路径问题呈现出最优子结构特性,如果已经求得顶点A到顶点B之间的最短路径,且该路径包含顶点C和F,那么顶点A到顶点B之间的最短路径中顶点C到顶点F的路径也是顶点C到顶点F之间的最短路径(子问题)。假设现在存在另一条顶点C到顶点F的路径且这条路径的距离比顶点A到顶点B之间的最短路径中顶点C到顶点F的路径还要短,如果我们把这条顶点C到顶点F的路径放到之前的得到的顶点A到顶点B之间的最短路径中,那就相当于得到了一条比之前得到的点A到顶点B之间的最短路径还要短的路径,这样就和前提假设矛盾了。因此图的两点之间的最短路径问题呈现出最优子结构特性得证。最优子结构特性也可以这样理解:假设现在我们被要求最小化函数 g ( x ) g(x) g(x),函数 g ( x ) g(x) g(x)又依赖于 g ( y ) g(y) g(y) g ( z ) g(z) g(z)。如果我们可以通过同时通过最小化函数 g ( y ) g(y) g(y) g ( z ) g(z) g(z)来以此最小数函数 g ( x ) g(x) g(x),则我们称最小化函数 g ( x ) g(x) g(x)这个问题具有最优子结构特性,函数 g ( y ) g(y) g(y) g ( z ) g(z) g(z)就可以看做是其子问题。如果函数 g ( x ) g(x) g(x)的最小化仅仅需要将函数 g ( y ) g(y) g(y)最小化而与函数 g ( z ) g(z) g(z)关系不大,则我们说小化函数 g ( x ) g(x) g(x)这个问题没哟最优子结构特性。

     我们设计贪心算法一般按照以下几步:

  1. 将优化问题转换为作出一步选择后现有的问题转换为仅有一个子问题的问题
  2. 证明我们的贪心选择策略是安全的即每次作出一次贪心选择之后当前的问题或子问题都会转换为另一个子问题,且子问题的规模不断缩小。子问题也都有最优解。
  3. 作出一步贪心选择之后的子问题应该具有这样的属性:如果我们结合该子问题的最优解和之前作出的贪心选择就可以得到原问题的最优解

     下面我将以活动选择问题 ( A c t i v i t y S e l e c t i o n P r o b l e m ) (Activity\quad Selection\quad Problem) (ActivitySelectionProblem)为实际的例子来讲解一下贪心算法,以下的讲解以及图9来至于这里。先来介绍一下活动选择问题的背景。假设现在有 n n n个需要安排的活动 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an},每一个活动的进行都需要排它的占用一些公共资源,因此这 n n n个需要安排的活动中的任何两个都不能有任意时刻处于并行的状态,现在的问题是如何安排这些活动使得总的被安排的活动数最多。假设这 n n n个需要安排的活动都必须位于时间区间 [ T s , T f ] [T_s,T_f] [Ts,Tf]内且每一个活动的开始时间为 s i s_i si,结束时间在 f i f_i fi之前,也就是时间区间 [ s i , f i ) [s_i,f_i) [si,fi)

 
图9.

     图9是一个有9个活动的例子,这里的9个活动按照完成时间 f i f_i fi进行了排序,先结束的索引i较低,后结束的索引i较高。更详细的可视化图见图10。

 
图10.

     假设我们在安排这些活动的时候最开始随便选择一个最早开始的活动,然后选择一个在第一个安排的活动完成之后的第二最早开始的活动,重复第二步的步骤直到没有足够的时间安排活动为止。如果我们先安排活动 a 4 a_4 a4,那么针对以上的例子我们得到的活动安排的结果是 a 4 − > a 6 − > a 8 a_4->a_6->a_8 a4>a6>a8。但是这个解不是最优解,最优解是 a 1 − > a 3 − > a 6 > a 8 a_1->a_3->a_6>a_8 a1>a3>a6>a8 a 2 − > a 5 − > a 7 > a 9 a_2->a_5->a_7>a_9 a2>a5>a7>a9,注意这里活动选择问题的最优解可能不止一个。
     接下来我们来证明活动选择问题具有最优子结构特性,假设这里的所有活动已经按照结束时间 f i f_i fi进行了先后排序。对于我们开头的定义,假设 A 1 n A_{1n} A1n为要安排的活动集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an}的一个最优解,且这个最优解包含活动 a k a_k ak,对于活动 a k a_k ak 2 ≤ k ≤ ( n − 1 ) 2\leq k \leq(n-1) 2k(n1),它的开始时间为 s k s_k sk,结束时间在 f k f_k fk之前。如果假设我们在求活动集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an}的一个最优解的时候第一个选择的活动就是 a k a_k ak,那么选择活动 a k a_k ak之后活动集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an}被分割成两个两个互不相交的集合 S 1 k S_{1k} S1k S k n S_{kn} Skn且活动集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an} S 1 k ∪ a k ∪ S k n S_{1k}\cup a_k\cup S_{kn} S1kakSkn的超集。如果因为活动 a k a_k ak的选择导致和活动 a k a_k ak有并行时间的一些活动被丢弃,那么活动集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an} S 1 k ∪ a k ∪ S k n S_{1k}\cup a_k\cup S_{kn} S1kakSkn的真超集。集合 S k n S_{kn} Skn为在活动 a 1 a_1 a1完成之后,在活动 a k a_k ak开始之前的所有活动的集合,集合 S k n S_{kn} Skn为在活动 a k a_k ak完成之后,在活动 a n a_n an开始之前的所有活动的集合。这里求活动集合 S k n S_{kn} Skn或活动集合 S k n S_{kn} Skn的最优解可以看做是求集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an}的最优解的子问题。
     这里我们先给出一个结论:

  • A 1 k = A 1 n ∩ S 1 k A_{1k}=A_{1n}\cap S_{1k} A1k=A1nS1k是活动集合 S 1 k S_{1k} S1k的一个最优解。
  • A k n = A 1 n ∩ S k n A_{kn}=A_{1n}\cap S_{kn} Akn=A1nSkn是活动集合 S k n S_{kn} Skn的一个最优解。

     上面两个结论整合起来的结果就是 A 1 n = A 1 k ∪ a k ∪ A k n A_{1n}=A_{1k}\cup a_k \cup A_{kn} A1n=A1kakAkn ∣ A 1 n ∣ = ∣ A 1 k ∣ + 1 + ∣ A k n ∣ |A_{1n}|=|A_{1k}|+1+ |A_{kn}| A1n=A1k+1+Akn。如果 A 1 k = A 1 n ∩ S 1 k A_{1k}=A_{1n}\cap S_{1k} A1k=A1nS1k不是活动集合 S 1 k S_{1k} S1k的一个最优解且 A 1 k ′ A_{1k}^{'} A1k是活动集合 S 1 k S_{1k} S1k的一个最优解或者 A k n = A 1 n ∩ S k n A_{kn}=A_{1n}\cap S_{kn} Akn=A1nSkn不是活动集合 S k n S_{kn} Skn的一个最优解且 A k n ′ A_{kn}^{'} Akn是活动集合 S k n S_{kn} Skn的一个最优解。只要其中两个中的任意一个满足,我们就可以以其中一个最优解 A k n ′ A_{kn}^{'} Akn A 1 k ′ A_{1k}^{'} A1k代替我们结论中给出的最优解 A k n A_{kn} Akn A 1 k A_{1k} A1k,这样就会导致出现集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an}的一个最优解为:

  • A 1 k ′ ∪ a k ∪ A k n A_{1k}^{'}\cup a_k \cup A_{kn} A1kakAkn
  • A 1 k ∪ a k ∪ A k n ′ A_{1k}\cup a_k \cup A_{kn}^{'} A1kakAkn
  • A 1 k ′ ∪ a k ∪ A k n ′ A_{1k}^{'}\cup a_k \cup A_{kn}^{'} A1kakAkn

     这样会出现:

  • ∣ A 1 k ′ ∣ + 1 + ∣ A k n ∣ > ∣ A 1 n ∣ = ∣ A 1 k ∣ + 1 + ∣ A k n ∣ |A_{1k}^{'}|+1+ |A_{kn}|>|A_{1n}|=|A_{1k}|+1+ |A_{kn}| A1k+1+Akn>A1n=A1k+1+Akn
  • ∣ A 1 k ∣ + 1 + ∣ A k n ′ ∣ > ∣ A 1 n ∣ = ∣ A 1 k ∣ + 1 + ∣ A k n ∣ |A_{1k}|+1+ |A_{kn}^{'}|>|A_{1n}|=|A_{1k}|+1+ |A_{kn}| A1k+1+Akn>A1n=A1k+1+Akn
  • ∣ A 1 k ′ ∣ + 1 + ∣ A k n ′ ∣ > ∣ A 1 n ∣ = ∣ A 1 k ∣ + 1 + ∣ A k n ∣ |A_{1k}^{'}|+1+ |A_{kn}^{'}|>|A_{1n}|=|A_{1k}|+1+ |A_{kn}| A1k+1+Akn>A1n=A1k+1+Akn

     也就是出现了满足问题要求的活动集合且活动集合中活动的个数比之前的最优解 A 1 n A_{1n} A1n中的活动的个数还要多,这就和题设 A 1 n A_{1n} A1n为要安排的活动集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an}的一个最优解相矛盾,因此以下结论成立:

  • A 1 k = A 1 n ∩ S 1 k A_{1k}=A_{1n}\cap S_{1k} A1k=A1nS1k是活动集合 S 1 k S_{1k} S1k的一个最优解。
  • A k n = A 1 n ∩ S k n A_{kn}=A_{1n}\cap S_{kn} Akn=A1nSkn是活动集合 S k n S_{kn} Skn的一个最优解。

     也就证明了对于活动选择问题,其一个最优解包含了其子问题的最优解,也就是证明了活动选择问题满足最优子结构特性。
     接下来我们来证明活动选择问题具有贪心选择属性。这里需要注意的是贪心选择属性是基于你如何做出选择的,有的选择方法可能不具备贪心选择属性,有的选择方法具备贪心选择属性,只有具备贪心选择属性的选择方法再加上满足最优子结构的特性的贪心算法才能求出问题的最优解
     这里和前面一样还是假定待解决的活动集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an}中的活动已经按照完成时间 f i f_i fi的先后进行了排序,先结束的索引i较小,后结束的索引i较大。这里的贪心选择方法是每次选择最先结束的活动。因为我们的活动已经按照结束时间进行排序,因此每次选择的都是排好序的活动集合中的第一个活动,这样同时会造成每次选择完一个活动之后只会剩下一个子问题。对于活动集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an},我们首先会选择活动 a 1 a_1 a1,选择 a 1 a_1 a1之后问题就变成求集合 S 1 S_1 S1的最优解的过程,也就是求剩下的一个子问题的最优解的过程,这里 S 1 S_1 S1表示集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an}中所有在活动 a 1 a_1 a1完成之后才开始的活动的集合。如果集合 S = { a 1 , a 2 , . . . , a n } S=\{a_1,a_2,...,a_n\} S={a1,a2,...,an}的一个最优解包含活动 a 1 a_1 a1,则根据前面证明的活动选择问题具有最优子结构的特性,那么该最优解肯定包含子问题,即集合 S 1 S_1 S1的最优解。
     如果要证明我们选择的贪心选择方法满足贪心选择属性,我们就要证明以下说法:如果 S k S_k Sk是一个非空活动个数的集合, a m a_m am是其中结束时间最早的一个活动(这里可能有多个结束时间相同且最早结束的活动),则, a m a_m am肯定包含在 S k S_k Sk的某个最优解中。
     证明:如果 A k A_k Ak S k S_k Sk的一个最优解且活动 a j a_j aj A k A_k Ak中最早结束的活动之一,也就是说活动 a j a_j aj和活动 a m a_m am结束时间相同,但是活动 a j a_j aj和活动 a m a_m am的开始时间不一定相同。如果 a j = a m a_j=a_m aj=am,立即得证。如果 a j ! = a m a_j!=a_m aj!=am,令 A k ′ = ( A k − a j ) ∪ a m A_k^{'}=(A_k-{a_j})\cup a_m Ak=(Akaj)am,也就是用活动 a m a_m am代替最优解集合中的活动 a j a_j aj,这个时候 A k ′ A_k^{'} Ak中肯定没有任何两个活动的进行时间处于并行的状态,因为 A k A_k Ak是集合 S k S_k Sk的一个最优解,因此 A k A_k Ak中肯定没有任何两个活动的进行时间处于并行的状态,这里用活动 a m a_m am代替活动 a j a_j aj,活动 a m a_m am和活动 a j a_j aj的结束时间又是相同的,因此 A k A_k Ak A k ′ A_k^{'} Ak中活动的个数相同,且 A k ′ A_k^{'} Ak中没有任何两个活动的进行时间处于并行的状态,所以 A k ′ A_k^{'} Ak也是集合 S k S_k Sk的一个最优解, A k ′ A_k^{'} Ak包含活动 a m a_m am,活动 a m a_m am是集合 A k A_k Ak中最早结束的活动之一。
     下面我们给出一个例子并用代码来实现解决。假设现在一共有六个活动需要安排,它们的开始时间和结束时间,注意这里的结束时间是开区间,分别如下所示:

a 1 a_1 a1 a 2 a_2 a2 a 3 a_3 a3 a 4 a_4 a4 a 5 a_5 a5 a 6 a_6 a6
start time130585
end time246799

     可视化展示如图11所示。从图11中我们可以很明显的看出该问题的最优解为 A p = { a 1 , a 2 , a 4 , a 5 } A_p=\{a_1,a_2,a_4,a_5\} Ap={a1,a2,a4,a5}。下面直接上代码,因为开始的时候给出的活动可能是没有按照结束时间排序的,因此代码在实现的时候先将活动按照结束时间的先后进行排序。测试结果如图12所示。

 
图11.
class Activitiy
{
private:
	int start_time;
	int end_time;

public:
	Activitiy()
	{
		start_time = 0;
		end_time = 0;
	}
	Activitiy(int t_s,int t_e)
	{
		start_time = t_s;
		end_time = t_e;
	}
	int get_start_time()
	{
		return start_time;
	}
	int get_end_time()
	{
		return end_time;
	}
};

bool activityCompare(Activitiy s1, Activitiy s2)
{
	return (s1.get_end_time() < s2.get_end_time());
}


void scheduleActivities(vector<Activitiy> activities, vector<Activitiy>& scheduledactivities)
{
	int last_selected_activity_index = 0;
	sort(activities.begin(), activities.end(), activityCompare);

	cout << "The activities after sorting is:"<<endl;

	for (int index = 0; index < activities.size(); index++)
	{
		cout << "index=" << index << "start_time=" << activities[index].get_start_time() << "end_time=" << activities[index].get_end_time() << endl;
	}

	scheduledactivities.push_back(activities[0]);

	for (int index = 1; index < activities.size(); index++)
	{
		if (activities[index].get_start_time() >= activities[last_selected_activity_index].get_end_time())
		{
			scheduledactivities.push_back(activities[index]);
			last_selected_activity_index = index;
		}
	}
}

// Driver program
int main()
{
	Activitiy a1(5, 9);
	Activitiy a2(1, 2);
	Activitiy a3(3, 4);
	Activitiy a4(0, 6);
	Activitiy a5(5, 7);
	Activitiy a6(8, 9);
	vector<Activitiy> scheduledactivities;
	vector<Activitiy> activities;
	activities.push_back(a1);
	activities.push_back(a2);
	activities.push_back(a3);
	activities.push_back(a4);
	activities.push_back(a5);
	activities.push_back(a6);
	scheduleActivities(activities, scheduledactivities);
	cout << "The selected activities is:" << endl;

	for (int index = 0; index < scheduledactivities.size(); index++)
	{
		cout << "index=" << index << "start_time=" << scheduledactivities[index].get_start_time() << "end_time=" << scheduledactivities[index].get_end_time() << endl;
	}
	return 0;
}
 
图12.

     这里也顺便提一下:Dijkstra单源最短路径问题是贪心算法的例子,Floyd每一对顶点之间的最短路径问题是基于动态规划的, 0 − 1 0-1 01背包问题不能用选择当前均价最高的物品来求得最优解, 0 − 1 0-1 01背包问题一般用动态规划来求解,小数背包问题可以用选择当前均价最高的物品来求得最优解此时采用的是贪心算法。
     哈密顿路径问题:在有向图或无向图中,如果存在一条路径,该路径访问图中每个节点仅仅一次,该路径称为哈密顿路径。如果一条哈密顿路径的起始节点和结束节点存在一条边,则该哈密顿路径加上这边条之后就成为哈密顿回路。哈密顿路径问题和哈密顿回路问题都属于 N P C NPC NPC问题,也就是目前没有找到多项式级别的时间复杂度的算法来解决。
     旅行商问题:现在有一系列城市,以及每两个城市之间的距离。现在的问题是如何从起始城市出发到达每个城市一次,然后再回到起始城市,整个过程需要使得总的旅程的路程最短。(这里要注意的是每两个城市之间都是可通的)旅行商问题属于 N P − H a r d NP-Hard NPHard问题,也是目前没有找到多项式级别的时间复杂度的算法来解决。
     哈密顿路径问题和旅行商问题有些类似。哈密顿路径问题我个人觉得看重的是给定一个图(不一定是完全图)判断这个图中是否存在哈密顿路径或哈密顿回路,而旅行商问题基本就是给定了一个完全图,那么这个完全图肯定是存在哈密顿回路的,也可能存在多条哈密顿回路,旅行商问题看重的是在这多条哈密顿回路种找到那条总里程最短的哈密顿回路。
     因为旅行商问题是 N P − H a r d NP-Hard NPHard问题,所以旅行商问题是没有多项式时间的解决算法的。据说动态规划方法可以求得旅行商问题的最优解,但是这只能是问题规模比较小的时候,当问题规模大到一定程度的时候,时间复杂度就会无法忍受。这时可以采用局部搜索算法,模拟退火算法和遗传算法等来求解,时间复杂度就没有动态规划解法那么高但是依然不是多项式时间的算法这时得到的解只是一个近似接近解。下面给出旅行商问题的贪心算法,以图13为例子,这里的贪心算法给出的也不是最优解,也只能是一个接近解,但是时间复杂度就比较合理,没有那么变态。这里从城市1出发寻找路径最短的哈密顿环,每次从当前城市到决定去与当前城市连通且没有访问过的城市的决策规则为选择路径最短的哪一个城市,图14、15、16、17位算法的步骤流程图,图18为测试结果。源代码参考于GeeksForGeeks

 
图13.
 
图14.
 
图15.
 
图16.
 
图17.
/*Header.h*/
#pragma once
#include <iostream>
#include <vector>
#include <map>
#include <string>
using namespace std;

void findMinRoute(vector<vector<int> > between_citys_length_matrix)
{
	int start_city = 1;
	int num_of_citys = between_citys_length_matrix.size();
	vector<int> mini_route(num_of_citys+ 1,1);
	int total_path_length = 0;
	int num_of_visited_cities = 1;
	int row_index = 0;
	int column_index = 0;
	int current_between_citys_length = INT_MAX;
	map<int, bool> isvisitedCitys;
	isvisitedCitys.insert(pair<int, bool>(0, false));
	isvisitedCitys.insert(pair<int, bool>(1, false));
	isvisitedCitys.insert(pair<int, bool>(2, false));
	isvisitedCitys.insert(pair<int, bool>(3, false));
	/* Starting from the 0th indexed city i.e., the first city*/
	isvisitedCitys[start_city-1] = true;
	mini_route[start_city-1] = start_city;

	while ((row_index < num_of_citys) && (column_index < num_of_citys))
	{
		if (num_of_visited_cities >= num_of_citys)
		{
			break;
		}

		if (column_index != row_index && (isvisitedCitys[column_index] == false))
		{
			if (between_citys_length_matrix[row_index][column_index] < current_between_citys_length)
			{
				current_between_citys_length = between_citys_length_matrix[row_index][column_index];
				mini_route[num_of_visited_cities] = column_index + 1;
			}
		}
		column_index++;

		if (column_index == num_of_citys)
		{
			total_path_length += current_between_citys_length;
			current_between_citys_length = INT_MAX;
			isvisitedCitys[mini_route[num_of_visited_cities] - 1] = true;
			column_index = 0;
			row_index = mini_route[num_of_visited_cities] - 1;
			num_of_visited_cities++;
		}
	}
	cout << "The path length is:" << total_path_length;
	/*Update the ending city i.e., the start city, in array from city which was last visited*/
	row_index = mini_route[num_of_visited_cities-1] - 1;
	if (between_citys_length_matrix[row_index][start_city-1] != -1)
	{
		current_between_citys_length = between_citys_length_matrix[row_index][start_city-1];
		mini_route[num_of_visited_cities] = start_city;
	}
	else
	{
	    while(1)
	    {
		    cout << "Mission impossible" << endl;
		}
	}

	total_path_length += current_between_citys_length;

    /* print the result */
	cout << "The result path is:";
	for (int index = 0; index < num_of_visited_cities; index++)
	{
		cout << mini_route[index] << "->";
	}
	cout << mini_route[num_of_visited_cities] << endl;
	cout << "The path length is:"<< total_path_length;
}

/*Driver code*/
#include"Header.h"

int main()
{
	vector<vector<int> > tsp = { { -1, 10, 15, 20 },
								{ 10, -1, 35, 25 },
								{ 15, 35, -1, 30 },
								{ 20, 25, 30, -1 } };

	findMinRoute(tsp);
}
 
图18.

/*---------------------------------------------分割线----------------------------------------------------------*/
     在维基百科的动态规划 ( D y n a m i c P r o g r a m m i n g ) (Dynamic\quad Programming) (DynamicProgramming)的定义中说到:动态规划既是一种数学优化方法,也是一种计算机编程方法。它由Richard Bellman在1950年代提出并且运用到了很多领域。动态规划在这两个领域的共同点是:它都表示通过递归的方式将一个复杂的问题分解为简单的子问题来简化该复杂问题。当然我们关注的是计算机编程领域。这里它也给出了最优子结构在计算机科学领域的另一种解释:如果一个待解决的问题可以通过如下方式获得最优解,那么它具有最优子结构,最优子结构通常以递归的形式存在。这种方式是:首先将待解决问题分解为子问题,然后递归的求解子问题的最优解。
     我们先来看一个简单的例子: F i b o n a c c i Fibonacci Fibonacci数列(0,1,1,2,3,5,8,13,21,…),可以看到从第2个(索引从0开始)数开始其为前面两个数的和,它的计算公式如下:
{ F ( 0 ) = 0 , F ( 1 ) = 1 , F ( n ) = F ( n − 1 ) + F ( n − 2 ) , n ≥ 2 \left\{ \begin{array}{lr} F(0)=0, & \\ F(1)=1, & \\ F(n)=F(n-1)+F(n-2),n\geq2 & \end{array} \right. F(0)=0,F(1)=1,F(n)=F(n1)+F(n2),n2
     既然我们关注的是计算机编程领域,那么我们肯定要使用计算机程序来计算 F i b o n a c c i Fibonacci Fibonacci数列。先来看一种最简单的递归实现方法。

int My_Finonacci_Number(int n)
{
	if (n == 0)
	{
		return 0;
	}
	else if (n == 1)
	{
		return 1;
	}
	else
	{
		return (My_Finonacci_Number(n - 1) + My_Finonacci_Number(n - 2));
	}
}

     对于上面的计算 F i b o n a c c i Fibonacci Fibonacci数列第 n ( n ≥ 0 ) n(n\geq 0) n(n0)个数的代码实现,计算 F i b o n a c c i Fibonacci Fibonacci数列第6个数的递归调用图如图19所示。从图19中我们可以看到在计算过程中,有些子调用被多次调用了,比如 F ( 3 ) F(3) F(3)被调用了三次。这样是比较浪费时间的,因为函数调用设计到参数入栈,保存现场环境,以及函数调用完之后的恢复当时调用之前的环境。其实第一次调用 F ( 3 ) F(3) F(3)之后,图19中绿色矩形所示,我们可以将其结果存下来,下次调用的时候直接取就好了,而不用再次调用,这样就节省了很多时间。

 
图19.
int Fibonacci[10] = { 0,1,-1,-1,-1,-1,-1,-1,-1,-1 };
int My_Finonacci_Number_Pro(int n)
{
	if (Fibonacci[n] != -1)
	{
		return Fibonacci[n];
	}
	else
	{
		Fibonacci[n] = My_Finonacci_Number_Pro(n - 1) + My_Finonacci_Number_Pro(n - 2);
		return Fibonacci[n];
	}
}

int main()
{
	cout << My_Finonacci_Number_Pro(9)<<endl;
	for (int i = 0; i < 10; i++)
	{
		cout << "Fibonacci[" << i << "]=" << Fibonacci[i] << endl;
	}
}

     上面的代码就是对已经计算的结果避免重复计算的一种改进,在计算索引(从0开始)较小的 F i b o n a c c i Fibonacci Fibonacci数的时候差距还不是那么明显,但是当要计算的 F i b o n a c c i Fibonacci Fibonacci数的索引足够大的时候,以上两种方法计算同一个 F i b o n a c c i Fibonacci Fibonacci数的时间差距将非常巨大。

 
图20.

     这种利用Memoization(可以简单的理解为存储已经计算的函数调用的结果从而避免再次重复调用而加速计算机运行的一种技术)的简单的优化方法就叫做动态规划。一个待解决的问题只有具有以下两种属性才能通过动态规划的方法求得最优解:

  1. 重叠子问题:这个还是举例说明比较清楚,在上面求 F ( 6 ) F(6) F(6)的递归调用图中, F ( 6 ) F(6) F(6)会调用 F ( 5 ) F(5) F(5) F ( 4 ) F(4) F(4),在调用 F ( 5 ) F(5) F(5) F ( 4 ) F(4) F(4)的过程中,它们都调用了 F ( 3 ) F(3) F(3),也就是说 F ( 3 ) F(3) F(3)被重复调用了, F ( 3 ) F(3) F(3)就是重叠子问题。重叠子问题简单的说就是在解决待解决问题的过程中,存在子问题被重复多次调用的情况。
  2. 最优子结构:这个概念在贪心算法的讲解以及前面有多次提到,这里就不说了。

     有一类问题可以通过将求得的非重叠子问题的最优解结合起来得到整个问题的最优解,这种方法叫做 D i v i d e a n d c o n q u e r Divide\quad and\quad conquer Divideandconquer。快速排序就属于这类解决问题的策略,但是快速排序会先将原有序列分成两个子序列(一个子序列的所有元素大于或小于另一个子序列的所有元素),然后才在子序列上进行快速排序。但是由于其没有重叠的子结构,因此这类解决问题的方法不属于动态规划。

动态规划可以有两种实现方法:

  1. 从上到下(递归):This is the direct fall-out of the recursive formulation of any problem. If the solution to any problem can be formulated recursively using the solution to its sub-problems, and if its sub-problems are overlapping, then one can easily memoize or store the solutions to the subproblems in a table. Whenever we attempt to solve a new sub-problem, we first check the table to see if it is already solved. If a solution has been recorded, we can use it directly, otherwise we solve the sub-problem and add its solution to the table.这里我首先放的是维基百科上动态规划的定义中对于该方法的介绍,具体代码参考可以看上面的对于求 F i b o n a c c i Fibonacci Fibonacci数的改进方法。因为采用动态规划来解决的问题都有重叠子问题这个属性,这里的大致意思是如果在求某个子问题的时候,该子问题还没有计算过,那么在计算完该子问题之后可以将结果存储下来,下次再来计算的时候就可以不必重复计算而是直接取存储的结果,这样可以节省大量的时间,因此提高了效率。
  2. 从下到上(迭代):Once we formulate the solution to a problem recursively as in terms of its sub-problems, we can try reformulating the problem in a bottom-up fashion: try solving the sub-problems first and use their solutions to build-on and arrive at solutions to bigger sub-problems. This is also usually done in a tabular form by iteratively generating solutions to bigger and bigger sub-problems by using the solutions to small sub-problems. For example, if we already know the values of Fibonacci[40] and Fibonacci[41], we can directly calculate the value of Fibonacci[42]. 这里我首先放的也是维基百科上动态规划的定义中对于该方法的介绍,具体代码参考可以看下面的求 F i b o n a c c i Fibonacci Fibonacci数的迭代方法。这里的意思是在求某个比较大的问题的时候,我们可以从其最基本的子问题开始求解,在求得基本子问题的解之后,以此为基础再去求较大的子问题的解,最后求得我们要得到的解,这样做的话,每个子问题也只会被计算一次,而不会被重复计算。
/*求Finonacci数的迭代解法*/
int My_Finonacci_Number_Pro(int n)
{
	int first = 0;
	int second = 1;
	int third = 1;
	if (n == 0)
	{
		return first;
	}
	else if (n == 1)
	{
		return second;
	}
	else if (n == 2)
	{
		return third;
	}
	else
	{
		first = 1;
		second = 1;
		third = 0;
		for (int i = 3; i <= n; i++)
		{
			third = first + second;
			first = second;
			second = third;
		}
		return third;
	}
}

     下面我们来看一个问题:最长公共子序列( L o n g e s t c o m m o n s u b s e q u e n c e Longest\quad common\quad subsequence Longestcommonsubsequence)。最长公共子序列问题就是求出一个序列集合中所有序列的子序列中最长的哪一个子序列。比如现在序列集合中有两个序列 { " A B C D " , " A C B A D " } \{"ABCD","ACBAD"\} {"ABCD","ACBAD"},这两个序列有5个长度为2的公共子序列 " A B " , " A C " , " A D " , " B D " , " C D " "AB","AC","AD","BD","CD" "AB","AC","AD","BD","CD"和两个长度为3的公共子序列 " A B D " , " A C D " "ABD","ACD" "ABD","ACD",再就没有其它的公共子序列了,因此这两个序列的最长公共子序列就为 " A B D " , " A C D " "ABD","ACD" "ABD","ACD"。需要注意的是这里构成子序列的字母在原序列中是不需要连续的,这一点和最长公共字串是有区别的,在求最长公共字串中,要求构成子串的字母在原序列中是连续的。还有就是空序列和原序列都是原序列的子序列。
     下面将以求两个序列的最长公共子序列的过程来实际展示动态规划方法,下面的部分内容参考于这里
     我们首先来看一下最长公共子序列问题的最优子结构,假设现在有两个序列 X = " x 1 , x 2 , . . . , x m " X="x_1,x_2,...,x_m" X="x1,x2,...,xm" Y = " y 1 , y 2 , . . . , y n " Y="y_1,y_2,...,y_n" Y="y1,y2,...,yn",如果 Z = " z 1 , z 2 , . . . , z k " Z="z_1,z_2,...,z_k" Z="z1,z2,...,zk" X X X Y Y Y的一个最长公共子序列,则有:

  1. 如果 x m = y n x_m=y_n xm=yn,则 z k = x m = y n z_k=x_m=y_n zk=xm=yn Z k − 1 = " z 1 , z 2 , . . . , z k − 1 " Z_{k-1}="z_1,z_2,...,z_{k-1}" Zk1="z1,z2,...,zk1" X m − 1 = " x 1 , x 2 , . . . , x m − 1 " X_{m-1}="x_1,x_2,...,x_{m-1}" Xm1="x1,x2,...,xm1" Y n − 1 = " y 1 , y 2 , . . . , y n − 1 " Y_{n-1}="y_1,y_2,...,y_{n-1}" Yn1="y1,y2,...,yn1",的最长公共字序列.
  2. 如果 x m ! = y n x_m!=y_n xm!=yn,且 z k ! = x m z_k!=x_m zk!=xm Z Z Z X m − 1 = " x 1 , x 2 , . . . , x m − 1 " X_{m-1}="x_1,x_2,...,x_{m-1}" Xm1="x1,x2,...,xm1" Y = " y 1 , y 2 , . . . , y n " Y="y_1,y_2,...,y_{n}" Y="y1,y2,...,yn",的最长公共字序列.
  3. 如果 x m ! = y n x_m!=y_n xm!=yn,且 z k ! = y n z_k!=y_n zk!=yn Z Z Z X m = " x 1 , x 2 , . . . , x m " X_{m}="x_1,x_2,...,x_{m}" Xm="x1,x2,...,xm" Y n − 1 = " y 1 , y 2 , . . . , y n − 1 " Y_{n-1}="y_1,y_2,...,y_{n-1}" Yn1="y1,y2,...,yn1",的最长公共字序列.

     以上三个结论的证明如下:

  1. X X X Y Y Y的一个最长公共子序列 Z Z Z不包含 x m = y n x_m=y_n xm=yn,那么我们此时把 x m = y n x_m=y_n xm=yn添加到 Z Z Z之后就可以生成一个 X X X Y Y Y的一个长度为 k + 1 k+1 k+1的公共子序列,这和 Z Z Z X X X Y Y Y的一个最长公共子序列的前提相矛盾,因此得证。
  2. 如果 X m − 1 = " x 1 , x 2 , . . . , x m − 1 " X_{m-1}="x_1,x_2,...,x_{m-1}" Xm1="x1,x2,...,xm1" Y = " y 1 , y 2 , . . . , y n " Y="y_1,y_2,...,y_{n}" Y="y1,y2,...,yn"还存在长度大于k的最长公共子序列,则这个长度大于k的公共子序列也是 X = " x 1 , x 2 , . . . , x m " X="x_1,x_2,...,x_m" X="x1,x2,...,xm" Y = " y 1 , y 2 , . . . , y n " Y="y_1,y_2,...,y_n" Y="y1,y2,...,yn"的公共子序列,这样就和 Z Z Z X X X Y Y Y的一个最长公共子序列的前提相矛盾,因此得证。
  3. 同2.

     总结以上的结果可以知道两个序列的最长公共子序列包含了这两个序列的前缀序列的最长公共子序列的前缀。如果 C [ i , j ] C[i,j] C[i,j]为序列 X X X的前缀序列和序列 Y Y Y的前缀序列的最长公共子序列,则以下为求最长公共子序列的递归公式:
C [ i , j ] = { 0 , i = 0 或 者 j = 0 C [ i − 1 , j − 1 ] + 1 , i > 0 , j > 0 且 x i = y j m a x ( C [ i − 1 , j ] , C [ i , j − 1 ] ) , , i > 0 , j > 0 且 x i ! = y j C[i,j]=\left\{ \begin{array}{lr} 0, i=0或者j=0& \\ C[i-1,j-1]+1,i>0,j>0且x_i=y_j & \\ max( C[i-1,j], C[i,j-1]) ,,i>0,j>0且x_i!=y_j & \end{array} \right. C[i,j]=0,i=0j=0C[i1,j1]+1,i>0,j>0xi=yjmax(C[i1,j],C[i,j1]),,i>0,j>0xi!=yj

     和用没有优化的递归方法求 F i b o n a c c i Fibonacci Fibonacci数的递归调用图一样,这里我们也先看一下上面的递归方法求最长公共子序列的递归调用图。如图21所示,其中红色的箭头表示返回值。在图21中我们可以看到 C [ 2 , 3 ] C[2,3] C[2,3]以及其后来的调用等都会重叠的子问题, C [ 0 , 0 ] C[0,0] C[0,0] C [ 1 , 1 ] C[1,1] C[1,1]也是重叠的子问题。

 
图21.

     以 { X = " A B C D " , Y = " A C B A D " } \{X="ABCD",Y="ACBAD"\} {X="ABCD",Y="ACBAD"}这两个序列为例,我们先运用从下到上的动态规划方法(迭代)来求解其最长公共子序列。这里我们需要借助一个行宽为6(序列Y的长度加1)和列高为5(序列X的长度加1)的二维数组 L C S _ T a b l e LCS\_Table LCS_Table(其实它也就对应了上面推导得到的求最长公共子序列的递归公式中的 C C C数组)来存储已经得到的序列X和Y的前缀序列的最长公共子序列的值,以此为基础不断迭代来得到最后的序列X和Y的最长公共子序列的长度。如图22所示。按照上面推导得到的求最长公共子序列的递归公式,二维数组所有包含索引0的元素的值都是0,这就是迭代的基础,因为任何非空序列和空序列的最长公共子序列都是空序列。图23到图26是我们根据上面推导得到的求最长公共子序列的递归公式手动求出的二维数组中各个元素的值。从图23到图26中还可以可以看到从二维数组中索引大于0的元素的里面还有一个小括号括起来的字符串,它们是"\","–","|"这三个字符串之一,这三个字符也是在迭代求两个序列的最长公共子序列的长度过程中根据上面推导得到的求最长公共子序列的递归公式来赋予的。在实际的程序中会另外建立一个辅助的二维数组 L C S _ P a t h LCS\_Path LCS_Path来存储它们。该数组的主要作用是最后用来打印出实际的最长公共子序列,因为仅仅靠数组 L C S _ T a b l e LCS\_Table LCS_Table只能求出两个序列的最长公共子序列的长度而不能求出它们实际的最长公共子序列是什么。需要注意的是这个 L C S _ P a t h LCS\_Path LCS_Path二维数组的每一个维度都要比二维数组 L C S _ T a b l e LCS\_Table LCS_Table少1,二维数组 L C S _ P a t h LCS\_Path LCS_Path对于 { X = " A B C D " , Y = " A C B A D " } \{X="ABCD",Y="ACBAD"\} {X="ABCD",Y="ACBAD"}这两个序列来说是4行5列的数组。

L C S _ P a t h [ i − 1 , j − 1 ] = { " \ " , i > 0 , j > 0 且 x i = y j " − − " , ( C [ i − 1 , j ] , < C [ i , j − 1 ] ) , , i > 0 , j > 0 且 x i ! = y j " ∣ " , ( C [ i − 1 , j ] , > = C [ i , j − 1 ] ) , , i > 0 , j > 0 且 x i ! = y j LCS\_Path[i-1,j-1]=\left\{ \begin{array}{lr} "\backslash",i>0,j>0且x_i=y_j & \\ "--" ,( C[i-1,j],<C[i,j-1]) ,,i>0,j>0且x_i!=y_j & \\ "|",( C[i-1,j],>=C[i,j-1]) ,,i>0,j>0且x_i!=y_j & \end{array} \right. LCS_Path[i1,j1]="\",i>0,j>0xi=yj"",(C[i1,j],<C[i,j1]),,i>0,j>0xi!=yj"",(C[i1,j],>=C[i,j1]),,i>0,j>0xi!=yj

 
图22.
 
图23.
 
图24.
 
图25.
 
图26.

     下面我们直接上测试代码和测试结果截图。

/* Following steps build LCS_Table[X.length()+1][Y.length()+1] in bottom up fashion.
   Note that L[i][j] contains length of LCS of X[1..i] and Y[1..j]. Here,the string sequnce
   index start from 1.*/
void lcs(string X, string Y, vector<vector<int>> &LCS_Table, vector<vector<string>>& LCS_Path)
{
	int i, j;


	for (i = 1; i <= X.length(); i++)
	{
		for (j = 1; j <= Y.length(); j++)
		{
			if (X[i - 1] == Y[j - 1])
			{
				LCS_Table[i][j] = LCS_Table[i - 1][j - 1] + 1;
				LCS_Path[i - 1][j - 1] = "\\";
			}
			else if (LCS_Table[i][j - 1] >= LCS_Table[i - 1][j])
			{
			    LCS_Table[i][j] = LCS_Table[i][j - 1];
				LCS_Path[i - 1][j - 1] = "--";
		    }
			else
			{
				LCS_Table[i][j] = LCS_Table[i - 1][j];
				LCS_Path[i - 1][j - 1] = "|";
			}
		}
	}

	return ;
}

void print_LCS(vector<vector<string>> LCS_Path,string X,int i,int j)
{
	if ((i == -1) || (j == -1))
		return;
	if (LCS_Path[i][j] == "\\")
	{
		print_LCS(LCS_Path, X,i - 1, j - 1);
		cout << X[i];
	}
	else if (LCS_Path[i][j] == "|")
	{
		print_LCS(LCS_Path, X, i- 1, j );
	}
	else if (LCS_Path[i][j] == "--")
	{
		print_LCS(LCS_Path, X, i, j-1);
	}
}

int main()
{
	string X = "ABCD";
	string Y = "ACBAD";
	vector<vector<int>> LCS_Table(X.length()+1);
	vector<vector<string>> LCS_Path(X.length());


	for (int i = 0; i < (X.length()+1); i++)
	{
		LCS_Table[i].resize(Y.length()+1,-1);
		if (i < X.length())
		{
			LCS_Path[i].resize(Y.length(), "?");
		}
		if (i == 0)
		{
			for (int j = 0; j < (Y.length() + 1); j++)
			{
				LCS_Table[i][j] = 0;
			}
		}
		else
		{
			LCS_Table[i][0] = 0;
		}
	}

	cout << "The LCS table after initialization is:" << endl;
	for (int i = 0; i < (X.length() + 1); i++)
	{
		for (int j = 0; j < (Y.length() + 1); j++)
		{
			cout << LCS_Table[i][j] << "  ";
		}
		cout << endl;
	}

	cout << "The LCS path after initialization is:" << endl;
	for (int i = 0; i < X.length(); i++)
	{
		for (int j = 0; j < Y.length(); j++)
		{
			cout << LCS_Path[i][j] << "      ";
		}
		cout << endl;
	}

	lcs(X, Y, LCS_Table, LCS_Path);
	cout << "The LCS table after computation is:" << endl;
	for (int i = 0; i < (X.length() + 1); i++)
	{
		for (int j = 0; j < (Y.length() + 1); j++)
		{
			cout << LCS_Table[i][j] << "  ";
		}
		cout << endl;
	}

	cout << "The LCS path after computation is:" << endl;
	for (int i = 0; i < X.length(); i++)
	{
		for (int j = 0; j < Y.length(); j++)
		{
			cout << LCS_Path[i][j] << "      ";
		}
		cout << endl;
	}
	cout << "The LCS length of string X and Y is:" << LCS_Table[X.length()][Y.length()] <<endl;
	cout << "One LCS of string X and Y is:";
	print_LCS(LCS_Path, X, X.length() - 1, Y.length() - 1) ;
	cout << endl;

	return 0;
}


 
图27.

     上面的"\","–","|“这三个字符串在赋予给数组 L C S _ P a t h LCS\_Path LCS_Path的元素的过程中,对于 x i ! = y j x_i!=y_j xi!=yj C [ i − 1 ] [ j ] = = C [ i ] [ j − 1 ] C[i-1][j]==C[i][j-1] C[i1][j]==C[i][j1]的情况下我上面的程序只赋予字符串”–",但是这里实际的情况是赋予字符串"–“和字符串”|“都是可以的。对于只有一个最长公共子序列的两个字符串,对于 x i ! = y j x_i!=y_j xi!=yj C [ i − 1 ] [ j ] = = C [ i ] [ j − 1 ] C[i-1][j]==C[i][j-1] C[i1][j]==C[i][j1]的情况下只赋予字符串”–“也是可以的,最后也可以打印出这个最长公共子序列。但是对于有多个最长公共子序列的两个序列,如果这样单一赋值为字符串”–“的话,最后只能打印出多个最长公共子序列中的一个,而不能打印出所有的最长公共子序列,比如上面的例子的 { X = " A B C D " , Y = " A C B A D " } \{X="ABCD",Y="ACBAD"\} {X="ABCD",Y="ACBAD"}这两个序列一共有连个最长公共子序列 " A B D " , " A C D " "ABD","ACD" "ABD","ACD",但是上面的代码只打印出了其中一个最长公共子序列。对于有多个最长公共子序列的两个序列,我们的程序还需要改进。改进方法参考这里
     具体改进就是对于 x i ! = y j x_i!=y_j xi!=yj C [ i − 1 ] [ j ] = = C [ i ] [ j − 1 ] C[i-1][j]==C[i][j-1] C[i1][j]==C[i][j1]的情况下,我们将二维数组 L C S _ P a t h LCS\_Path LCS_Path的元素赋值为字符串”–|",这就表示在调用 p r i n t _ L C S print\_LCS print_LCS函数打印最长公共子序列的时候,遇到字符串"–|"之后需要同时向左和向上递归调用 p r i n t _ L C S print\_LCS print_LCS。下面是有改动的地方的代码,在 p r i n t _ L C S print\_LCS print_LCS函数中我们对分支进行了区分,在有分支的地方会用一个大括号括起来并且加号的左右分别表示向左的分支和向上的分支,这样最后打印出来之后我们就可以很好的找到所有的最长公共子序列。

void lcs(string X, string Y, vector<vector<int>> &LCS_Table, vector<vector<string>>& LCS_Path)
{
	int i, j;


	for (i = 1; i <= X.length(); i++)
	{
		for (j = 1; j <= Y.length(); j++)
		{
			if (X[i - 1] == Y[j - 1])
			{
				LCS_Table[i][j] = LCS_Table[i - 1][j - 1] + 1;
				LCS_Path[i - 1][j - 1] = "\\";
			}
			else if (LCS_Table[i][j - 1] > LCS_Table[i - 1][j])
			{
			    LCS_Table[i][j] = LCS_Table[i][j - 1];
				LCS_Path[i - 1][j - 1] = "--";
		    }
			else if(LCS_Table[i][j - 1] < LCS_Table[i - 1][j])
			{
				LCS_Table[i][j] = LCS_Table[i - 1][j];
				LCS_Path[i - 1][j - 1] = "|";
			}
			else
			{
				LCS_Table[i][j] = LCS_Table[i][j - 1];
				LCS_Path[i - 1][j - 1] = "--|";
			}
		}
	}

	return ;
}

void print_LCS(vector<vector<string>> LCS_Path,string X,int i,int j)
{
	if ((i == -1) || (j == -1))
		return;
	if (LCS_Path[i][j] == "\\")
	{
		print_LCS(LCS_Path, X,i - 1, j - 1);
		cout << X[i];
	}
	else if (LCS_Path[i][j] == "|")
	{
		print_LCS(LCS_Path, X, i- 1, j );
	}
	else if (LCS_Path[i][j] == "--")
	{
		print_LCS(LCS_Path, X, i, j-1);
	}
	else if (LCS_Path[i][j] == "--|")
	{
		cout << "{  "<<"left branch:  ";
		print_LCS(LCS_Path, X, i, j - 1);
		cout << "  +  " << "  up branch:  ";
		print_LCS(LCS_Path, X, i - 1, j);
		cout << "  }  ";
	}
}

     和图23到图26一样,这里我们也首先手动的求一遍数组 L C S _ P a t h LCS\_Path LCS_Path各个元素的值。如图28到图32所示。图33展示了递归调用所走过的 L C S _ P a t h LCS\_Path LCS_Path数组中的元素,也间接的展示出了所有的最长公共子序列。图34是测试结果截图,从这里我们可以看到所有打印出来的最长公共子序列是 " A C D " "ACD" "ACD" " A B D " , " A B D " "ABD", "ABD" "ABD","ABD",这里打印重复了一个。

 
图28.
 
图29.
 
图30.
 
图31.
 
图32.
 
图33.
 
图34.

     下面我们将运用从上到下的动态规划方法(递归)再来求解一次。这里就是相当于将前面我们得到的求最长公共子序列的递归公式直接用递归算法实现,不过对于已经计算过的值我们会将它存储起来,等到下次再次需要计算时,直接去取就好,加快了速度。

C [ i , j ] = { 0 , i = 0 或 者 j = 0 C [ i − 1 , j − 1 ] + 1 , i > 0 , j > 0 且 x i = y j m a x ( C [ i − 1 , j ] , C [ i , j − 1 ] ) , , i > 0 , j > 0 且 x i ! = y j C[i,j]=\left\{ \begin{array}{lr} 0, i=0或者j=0& \\ C[i-1,j-1]+1,i>0,j>0且x_i=y_j & \\ max( C[i-1,j], C[i,j-1]) ,,i>0,j>0且x_i!=y_j & \end{array} \right. C[i,j]=0,i=0j=0C[i1,j1]+1,i>0,j>0xi=yjmax(C[i1,j],C[i,j1]),,i>0,j>0xi!=yj

     我们先看一下这种从上到下的动态规划方法的调用图,如图35所示。图35中有被填充颜色的矩形表示被实际调用的,没有被调用的表示是直接取的已经计算的值,里面红色色折线表示递归调用的路径,可以和图做一个对比。当然这里二维数组 L C S _ T a b l e LCS\_Table LCS_Table中所有第0行和第0列的元素都先被初始化为0,也可以认为是已经被计算好了的。这里我们也用图36清晰的展示了在这种算法中二维数组 L C S _ T a b l e LCS\_Table LCS_Table中的那些元素被计算了,从中可以看出和从下到上的方法相比,这里我们没有将二维数组的全部元素计算出来。这里和从下到上的方法一样这里也引进了一个二维数组 L C S _ P a t h LCS\_Path LCS_Path来帮助打印出实际的最长公共子序列。这两个数组的大小和定义在这两种方法中一样。对于二维数组 L C S _ P a t h LCS\_Path LCS_Path的赋值我们也是只在不是直接取值(也就是还没有计算的二维数组 L C S _ T a b l e LCS\_Table LCS_Table)的时候才给其元素赋值,从图36中可以看到和从下到上的方法相比,这里我们也没有将二维数组 L C S _ P a t h LCS\_Path LCS_Path的全部元素计算出来。

 
图35.
 
图36.

     最后我们直接上测试代码和测试结果截图。

 
图37.
/* Following steps build LCS_Table[X.length()+1][Y.length()+1] in top down fashion.
   Note that L[i][j] contains length of LCS of X[1..i] and Y[1..j]. Here,the string sequnce
   index start from 1.*/
int lcs(string X, string Y, vector<vector<int>>& LCS_Table, vector<vector<string>>& LCS_Path, int X_index, int Y_index)
{
	if (X_index == 0 || Y_index == 0)
		return 0;


	if (LCS_Table[X_index][Y_index] != -1)
		return LCS_Table[X_index][Y_index];

	if (X[X_index-1] == Y[Y_index-1])
	{

		LCS_Table[X_index][Y_index] = 1 + lcs(X, Y, LCS_Table, LCS_Path, X_index - 1, Y_index - 1);
		LCS_Path[X_index - 1][Y_index - 1] = "\\";
		return LCS_Table[X_index][Y_index];
	}
	else
	{
		int left_result = lcs(X, Y, LCS_Table, LCS_Path, X_index, Y_index - 1);
		int up_result = lcs(X, Y, LCS_Table, LCS_Path, X_index - 1, Y_index);
		if (left_result > up_result)
		{
			LCS_Table[X_index][Y_index] = left_result;
			LCS_Path[X_index - 1][Y_index - 1] = "--";
			return LCS_Table[X_index][Y_index];
		}
		else if (left_result < up_result)
		{
			LCS_Table[X_index][Y_index] = up_result;
			LCS_Path[X_index - 1][Y_index - 1] = "|";
			return LCS_Table[X_index][Y_index];

		}
		else if (left_result == up_result)
		{
			LCS_Table[X_index][Y_index] = left_result;
			LCS_Path[X_index - 1][Y_index - 1] = "--|";
			return LCS_Table[X_index][Y_index];
		}
	}
}
void print_LCS(vector<vector<string>> LCS_Path,string X,int i,int j)
{
	if ((i == -1) || (j == -1))
		return;
	if (LCS_Path[i][j] == "\\")
	{
		print_LCS(LCS_Path, X,i - 1, j - 1);
		cout << X[i];
	}
	else if (LCS_Path[i][j] == "|")
	{
		print_LCS(LCS_Path, X, i- 1, j );
	}
	else if (LCS_Path[i][j] == "--")
	{
		print_LCS(LCS_Path, X, i, j-1);
	}
	else if (LCS_Path[i][j] == "--|")
	{
		cout << "{  "<<"left branch:  ";
		print_LCS(LCS_Path, X, i, j - 1);
		cout << "  +  " << "  up branch:  ";
		print_LCS(LCS_Path, X, i - 1, j);
		cout << "  }  ";
	}
}

int main()
{
	string X = "ABCD";
	string Y = "ACBAD";
	vector<vector<int>> LCS_Table(X.length()+1);
	vector<vector<string>> LCS_Path(X.length());


	for (int i = 0; i < (X.length()+1); i++)
	{
		LCS_Table[i].resize(Y.length()+1,-1);
		if (i < X.length())
		{
			LCS_Path[i].resize(Y.length(), "?");
		}
		if (i == 0)
		{
			for (int j = 0; j < (Y.length() + 1); j++)
			{
				LCS_Table[i][j] = 0;
			}
		}
		else
		{
			LCS_Table[i][0] = 0;
		}
	}

	cout << "The LCS table after initialization is:" << endl;
	for (int i = 0; i < (X.length() + 1); i++)
	{
		for (int j = 0; j < (Y.length() + 1); j++)
		{
			cout << LCS_Table[i][j] << "  ";
		}
		cout << endl;
	}

	cout << "The LCS path after initialization is:" << endl;
	for (int i = 0; i < X.length(); i++)
	{
		for (int j = 0; j < Y.length(); j++)
		{
			cout << LCS_Path[i][j] << "      ";
		}
		cout << endl;
	}

	lcs(X, Y, LCS_Table, LCS_Path, X.length(),Y.length());
	cout << "The LCS table after computation is:" << endl;
	for (int i = 0; i < (X.length() + 1); i++)
	{
		for (int j = 0; j < (Y.length() + 1); j++)
		{
			cout << LCS_Table[i][j] << "  ";
		}
		cout << endl;
	}

	cout << "The LCS path after computation is:" << endl;
	for (int i = 0; i < X.length(); i++)
	{
		for (int j = 0; j < Y.length(); j++)
		{
			cout << LCS_Path[i][j] << "      ";
		}
		cout << endl;
	}
	cout << "The LCS length of string X and Y is:" << LCS_Table[X.length()][Y.length()] <<endl;
	cout << "All the  LCS of strings X and Y is:";
	print_LCS(LCS_Path, X, X.length() - 1, Y.length() - 1) ;
	cout << endl;

	return 0;
}

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

qqssss121dfd

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值