基本算法汇总(长文)

采撷

分治算法
一、基本概念

在计算机科学中,分治法是一种很重要的算法。字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)……

任何一个可以用计算机求解的问题所需的计算时间都与其规模有关。问题的规模越小,越容易直接求解,解题所需的计算时间也越少。例如,对于n个元素的排序问题,当n=1时,不需任何计算。n=2时,只要作一次比较即可排好序。n=3时只要作3次比较即可,…。而当n较大时,问题就不那么容易处理了。要想直接解决一个规模较大的问题,有时是相当困难的。

二、基本思想及策略

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

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

如果原问题可分割成k个子问题,1<k≤n,且这些子问题都可解并可利用这些子问题的解求出原问题的解,那么这种分治法就是可行的。由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。

三、分治法适用的情况

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

1) 该问题的规模缩小到一定的程度就可以容易地解决

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

3) 利用该问题分解出的子问题的解可以合并为该问题的解;

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

第一条特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加;

第二条特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用;、

第三条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条特征,而不具备第三条特征,则可以考虑用贪心法或动态规划法。

第四条特征涉及到分治法的效率,如果各子问题是不独立的则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好。

四、分治法的基本步骤

分治法在每一层递归上都有三个步骤:

step1 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;

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

step3 合并:将各个子问题的解合并为原问题的解。

它的一般的算法设计模式如下:

Divide-and-Conquer(P)

1. if |P|≤n0

2. then return(ADHOC(P))

3. 将P分解为较小的子问题 P1 ,P2 ,...,Pk

4. for i←1 to k

5. do yi ← Divide-and-Conquer(Pi) △ 递归解决Pi

6. T ← MERGE(y1,y2,...,yk) △ 合并子问题

7. return(T)

其中|P|表示问题P的规模;n0为一阈值,表示当问题P的规模不超过n0时,问题已容易直接解出,不必再继续分解。ADHOC(P)是该分治法中的基本子算法,用于直接解小规模的问题P。因此,当P的规模不超过n0时直接用算法ADHOC(P)求解。算法MERGE(y1,y2,...,yk)是该分治法中的合并子算法,用于将P的子问题P1 ,P2 ,...,Pk的相应的解y1,y2,...,yk合并为P的解。

五、分治法的复杂性分析

一个分治法将规模为n的问题分成k个规模为n/m的子问题去解。设分解阀值n0=1,且adhoc解规模为1的问题耗费1个单位时间。再设将原问题分解为k个子问题以及用merge将k个子问题的解合并为原问题的解需用f(n)个单位时间。用T(n)表示该分治法解规模为|P|=n的问题所需的计算时间,则有:

T(n)= k T(n/m)+f(n)

通过迭代法求得方程的解:

递归方程及其解只给出n等于m的方幂时T(n)的值,但是如果认为T(n)足够平滑,那么由n等于m的方幂时T(n)的值可以估计T(n)的增长速度。通常假定T(n)是单调上升的,从而当                  mi≤n<mi+1时,T(mi)≤T(n)<T(mi+1)。 

六、可使用分治法求解的一些经典问题

(1)二分搜索
(2)大整数乘法
(3)Strassen矩阵乘法
(4)棋盘覆盖
(5)合并排序
(6)快速排序
(7)线性时间选择

(8)最接近点对问题
(9)循环赛日程表
(10)汉诺塔
七、依据分治法设计程序时的思维过程

实际上就是类似于数学归纳法,找到解决本问题的求解方程公式,然后根据方程公式设计递归程序。

1、一定是先找到最小问题规模时的求解方法
2、然后考虑随着问题规模增大时的求解方法
3、找到求解的递归函数式后(各种规模或因子),设计递归程序即可。

          五大常用算法之二:动态规划算法

一、基本概念

动态规划过程是:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。

二、基本思想与策略

基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。

由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。

与分治法最大的差别是:适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。

三、适用的情况

能采用动态规划求解的问题的一般要具有3个性质:

(1) 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。

(2) 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。

(3)有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)

四、求解的基本步骤

 动态规划所处理的问题是一个多阶段决策问题,一般由初始状态开始,通过对中间阶段决策的选择,达到结束状态。这些决策形成了一个决策序列,同时确定了完成整个过程的一条活动路线(通常是求最优的活动路线)。如图所示。动态规划的设计都有着一定的模式,一般要经历以下几个步骤。

初始状态→│决策1│→│决策2│→…→│决策n│→结束状态

                  图1 动态规划决策过程示意图

(1)划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解。

(2)确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。

(3)确定决策并写出状态转移方程:因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程。

(4)寻找边界条件:给出的状态转移方程是一个递推式,需要一个递推的终止条件或边界条件。

一般,只要解决问题的阶段、状态和状态转移决策确定了,就可以写出状态转移方程(包括边界条件)。

实际应用中可以按以下几个简化的步骤进行设计:

(1)分析最优解的性质,并刻画其结构特征。

(2)递归的定义最优解。

(3)以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值

(4)根据计算最优值时得到的信息,构造问题的最优解

五、算法实现的说明

动态规划的主要难点在于理论上的设计,也就是上面4个步骤的确定,一旦设计完成,实现部分就会非常简单。

 使用动态规划求解问题,最重要的就是确定动态规划三要素:

(1)问题的阶段 (2)每个阶段的状态

(3)从前一个阶段转化到后一个阶段之间的递推关系。

 递推关系必须是从次小的问题开始到较大的问题之间的转化,从这个角度来说,动态规划往往可以用递归程序来实现,不过因为递推可以充分利用前面保存的子问题的解来减少重复计算,所以对于大规模问题来说,有递归不可比拟的优势,这也是动态规划算法的核心之处。

确定了动态规划的这三要素,整个求解过程就可以用一个最优决策表来描述,最优决策表是一个二维表,其中行表示决策的阶段,列表示问题状态,表格需要填写的数据一般对应此问题的在某个阶段某个状态下的最优值(如最短路径,最长公共子序列,最大价值等),填表的过程就是根据递推关系,从1行1列开始,以行或者列优先的顺序,依次填写表格,最后根据整个表格的数据通过简单的取舍或者运算求得问题的最优解。

      f(n,m)=max{f(n-1,m), f(n-1,m-w[n])+P(n,m)}

六、动态规划算法基本框架
复制代码
代码
1 for(j=1; j<=m; j=j+1) // 第一个阶段
2 xn[j] = 初始值;
3
4 for(i=n-1; i>=1; i=i-1)// 其他n-1个阶段
5 for(j=1; j>=f(i); j=j+1)//f(i)与i有关的表达式
6 xi[j]=j=max(或min){g(xi-1[j1:j2]), …, g(xi-1[jk:jk+1])};
8
9 t = g(x1[j1:j2]); // 由子问题的最优解求解整个问题的最优解的方案
10
11 print(x1[j1]);
12
13 for(i=2; i<=n-1; i=i+1)
15 {
17 t = t-xi-1[ji];
18
19 for(j=1; j>=f(i); j=j+1)
21 if(t=xi[ji])
23 break;
25 }

五大常用算法之四:回溯法
1、概念
回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。

回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。

 许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。

2、基本思想
在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。当探索到某一结点时,要先判断该结点是否包含问题的解,如果包含,就从该结点出发继续探索下去,如果该结点不包含问题的解,则逐层向其祖先结点回溯。(其实回溯法就是对隐式图的深度优先搜索算法)。

   若用回溯法求问题的所有解时,要回溯到根,且根结点的所有可行的子树都要已被搜索遍才结束。

   而若使用回溯法求任一个解时,只要搜索到问题的一个解就可以结束。

3、用回溯法解题的一般步骤:
(1)针对所给问题,确定问题的解空间:

        首先应明确定义问题的解空间,问题的解空间应至少包含问题的一个(最优)解。

(2)确定结点的扩展搜索规则

(3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。

4、算法框架
(1)问题框架

  设问题的解是一个n维向量(a1,a2,………,an),约束条件是ai(i=1,2,3,…..,n)之间满足某种条件,记为f(ai)。

 (2)非递归回溯框架

1: int a[n],i;
2: 初始化数组a[];
3: i = 1;
4: while (i>0(有路可走) and (未达到目标)) // 还未回溯到头
5: {
6: if(i > n) // 搜索到叶结点
7: {
8: 搜索到一个解,输出;
9: }
10: else // 处理第i个元素
11: {
12: a[i]第一个可能的值;
13: while(a[i]在不满足约束条件且在搜索空间内)
14: {
15: a[i]下一个可能的值;
16: }
17: if(a[i]在搜索空间内)
18: {
19: 标识占用的资源;
20: i = i+1; // 扩展下一个结点
21: }
22: else
23: {
24: 清理所占的状态空间; // 回溯
25: i = i –1;
26: }
27: }

    (3)递归的算法框架

     回溯法是对解空间的深度优先搜索,在一般情况下使用递归函数来实现回溯法比较简单,其中i为搜索的深度,框架如下:

1: int a[n];
2: try(int i)
3: {
4: if(i>n)
5: 输出结果;
6: else
7: {
8: for(j = 下界; j <= 上界; j=j+1) // 枚举i所有可能的路径
9: {
10: if(fun(j)) // 满足限界函数和约束条件
11: {
12: a[i] = j;
13: … // 其他操作
14: try(i+1);
15: 回溯前的清理工作(如a[i]置空值等);
16: }
17: }
18: }
19: }

五大常用算法之五:分支限界法
分支限界法

一、基本描述
类似于回溯法,也是一种在问题的解空间树T上搜索问题解的算法。但在一般情况下,分支限界法与回溯法的求解目标不同。回溯法的求解目标是找出T中满足约束条件的所有解,而分支限界法的求解目标则是找出满足约束条件的一个解,或是在满足约束条件的解中找出使某一目标函数值达到极大或极小的解,即在某种意义下的最优解。

(1)分支搜索算法
所谓“分支”就是采用广度优先的策略,依次搜索E-结点的所有分支,也就是所有相邻结点,抛弃不满足约束条件的结点,其余结点加入活结点表。然后从表中选择一个结点作为下一个E-结点,继续搜索。

 选择下一个E-结点的方式不同,则会有几种不同的分支搜索方式。

1)FIFO搜索

2)LIFO搜索

3)优先队列式搜索

(2)分支限界搜索算法
二、分支限界法的一般过程
由于求解目标不同,导致分支限界法与回溯法在解空间树T上的搜索方式也不相同。回溯法以深度优先的方式搜索解空间树T,而分支限界法则以广度优先或以最小耗费优先的方式搜索解空间树T。

分支限界法的搜索策略是:在扩展结点处,先生成其所有的儿子结点(分支),然后再从当前的活结点表中选择下一个扩展对点。为了有效地选择下一扩展结点,以加速搜索的进程,在每一活结点处,计算一个函数值(限界),并根据这些已计算出的函数值,从当前活结点表中选择一个最有利的结点作为扩展结点,使搜索朝着解空间树上有最优解的分支推进,以便尽快地找出一个最优解。

分支限界法常以广度优先或以最小耗费(最大效益)优先的方式搜索问题的解空间树。问题的解空间树是表示问题解空间的一棵有序树,常见的有子集树和排列树。在搜索问题的解空间树时,分支限界法与回溯法对当前扩展结点所使用的扩展方式不同。在分支限界法中,每一个活结点只有一次机会成为扩展结点。活结点一旦成为扩展结点,就一次性产生其所有儿子结点。在这些儿子结点中,那些导致不可行解或导致非最优解的儿子结点被舍弃,其余儿子结点被子加入活结点表中。此后,从活结点表中取下一结点成为当前扩展结点,并重复上述结点扩展过程。这个过程一直持续到找到所求的解或活结点表为空时为止。

三、回溯法和分支限界法的一些区别
有一些问题其实无论用回溯法还是分支限界法都可以得到很好的解决,但是另外一些则不然。也许我们需要具体一些的分析——到底何时使用分支限界而何时使用回溯呢?

回溯法和分支限界法的一些区别:

方法对解空间树的搜索方式 存储结点的常用数据结构 结点存储特性常用应用

回溯法深度优先搜索堆栈活结点的所有可行子结点被遍历后才被从栈中弹出找出满足约束条件的所有解

分支限界法广度优先或最小消耗优先搜索队列、优先队列每个结点只有一次成为活结点的机会找出满足约束条件的一个解或特定意义下的最优解

贪心算法
一、基本概念:

 所谓贪心算法是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的仅是在某种意义上的局部最优解。
 贪心算法没有固定的算法框架,算法设计的关键是贪心策略的选择。必须注意的是,贪心算法不是对所有问题都能得到整体最优解,选择的贪心策略必须具备无后效性,即某个状态以后的过程不会影响以前的状态,只与当前状态有关。
所以对所采用的贪心策略一定要仔细分析其是否满足无后效性。

二、贪心算法的基本思路:
1.建立数学模型来描述问题。
2.把求解的问题分成若干个子问题。
3.对每一子问题求解,得到子问题的局部最优解。
4.把子问题的解局部最优解合成原来解问题的一个解。

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

四、贪心算法的实现框架
从问题的某一初始解出发;
while (能朝给定总目标前进一步)
{
利用可行的决策,求出可行解的一个解元素;
}
由所有解元素组合成问题的一个可行解;

五、贪心策略的选择
因为用贪心算法只能通过解局部最优解的策略来达到全局最优解,因此,一定要注意判断问题是否适合采用贪心算法策略,找到的解是否一定是问题的最优解。

六、例题分析
下面是一个可以试用贪心算法解的题目,贪心解的确不错,可惜不是最优解。
[背包问题]有一个背包,背包容量是M=150。有7个物品,物品可以分割成任意大小。
要求尽可能让装入背包中的物品总价值最大,但不能超过总容量。
物品 A B C D E F G
重量 35 30 60 50 40 10 25
价值 10 40 30 50 35 40 30
分析:
目标函数: ∑pi最大
约束条件是装入的物品总重量不超过背包容量:∑wi<=M( M=150)
(1)根据贪心的策略,每次挑选价值最大的物品装入背包,得到的结果是否最优?
(2)每次挑选所占重量最小的物品装入是否能得到最优解?
(3)每次选取单位重量价值最大的物品,成为解本题的策略。
值得注意的是,贪心算法并不是完全不可以使用,贪心策略一旦经过证明成立后,它就是一种高效的算法。
贪心算法还是很常见的算法之一,这是由于它简单易行,构造贪心策略不是很困难。
可惜的是,它需要证明后才能真正运用到题目的算法中。
一般来说,贪心算法的证明围绕着:整个问题的最优解一定由在贪心策略中存在的子问题的最优解得来的。
对于例题中的3种贪心策略,都是无法成立(无法被证明)的,解释如下:
(1)贪心策略:选取价值最大者。反例:
W=30
物品:A B C
重量:28 12 12
价值:30 20 20
根据策略,首先选取物品A,接下来就无法再选取了,可是,选取B、C则更好。
(2)贪心策略:选取重量最小。它的反例与第一种策略的反例差不多。
(3)贪心策略:选取单位重量价值最大的物品。反例:
W=30
物品:A B C
重量:28 20 10
价值:28 20 10
根据策略,三种物品单位重量价值一样,程序无法依据现有策略作出判断,如果选择A,则答案错误。

算法策略的总结

策略是面向问题的,算法是面向实现的。

一、不同算法策略特点小结
1、贪心策略
贪心策略一方面是求解过程比较简单的算法,另一方面它又是对能适用问题的条件要求最严格(即适用范围很小)的算法。

贪心策略解决问题是按一定顺序,在只考虑当前局部信息的情况下,就做出一定的决策,最终得出问题的解。

即:通过局部最优决策能得到全局最优决策

2、递推策略
递推也是由当前问题的逐步解决从而得到整个问题的解,依赖于信息间本身的递推关系,每一步不需要决策参与到算法中,更多用于计算

3、递归策略
递归常常用于分治算法、动态规划算法中。

递归是利用大问题与其子问题间的递推关系来解决问题的。

能采用递归策略的算法一般有以下特征:

(1)为求解规模为N的问题,设法将它分解成规模较小的问题,然后从这些小问题的解方便地构造出大问题的解

(2)并且这些规模较小的问题也能采用同样的分解和综合方法,分解成更小的问题,并从这些更小的问题的解构造出规模较大问题的解

(3)特别的,当规模N = 1时,能直接得解

4、枚举策略
对问题所有的解逐一尝试,从而找出问题的真正解。一般用于决策类问题,很难找到大、小规模之间的关系,也不易对问题进行分解。

5、递归回溯策略
类似于枚举,通过尝试遍历问题各个可能解的通路,当发现此路不通时,回溯到上一步继续尝试别的通路。

6、分治策略
分治一般用于较复杂的问题,必须可以逐步被分解为容易解决的独立的子问题,这些子问题解决后,进而将它们的解“合成”,就得到较大问题的解,最终合成为总问题的解。

7、动态规划策略
与贪心类似,也是通过多阶段决策过程来解决问题。每个阶段决策的结果是一个决策结果序列,这个结果序列中,最终哪一个是最优的结果,取决于以后每个阶段的决策,当然每次决策结果序列都必须进行存储。因此是“高效率,高消费的算法”。

 同时,它又与递归法类似,当问题不能分解为独立的阶段,却又符合最优化原理时,就可以使用动态规划法,通过递归决策过程,逐步找出子问题的最优解,从而决策出问题的解。

二、算法策略间的关系
1、对问题进行分解的算法策略——分治法与动态规划法
共同点:(1)分治法与动态规划法实际上都是递归思想的运用

         (2)二者的根本策略都是对问题进行分解,找到大规模与小规模的关系,然后通过解小规模的解,得出大规模的解

不同点: 适用于分治法的问题分解成子问题后,各子问题间无公共子子问题,而动态规划法相反。

          动态规划法 = 分治算法思想 + 解决子问题间的冗余情况

2、多阶段逐步解决问题的策略——贪心算法、递推法、递归法和动态规划法
贪心算法:每一步都根据策略得到一个结果,并传递到下一步,自顶向下,一步一步地做出贪心决策。

 动态规划算法:每一步决策得到的不是一个唯一结果,而是一组中间结果(且这些结果在以后各步可能得到多次引用),只是每一步都使问题的规模逐步缩小,最终得到问题的一个结果。

 递推、递归法:注重每一步之间的关系,决策的因素较少。递推法是根据关系从前向后推导,从小规模问题的结论推解出大问题的解。而递归法是根据关系从后向前使大问题转化为小问题,最后同样由小规模问题的解推解出大问题的解。

3、全面逐一尝试、比较——蛮力法、枚举法、递归回溯法
蛮力策略(即枚举和递归回溯):

 当问题找不到信息间的相互关系、也不能将问题分解为独立的子问题,就只有把全部解都列出来之后,才能判定和推断出问题的解。

 蛮力策略适用于规模不大的问题。

 (1)枚举法:实现依赖于循环。所以一个枚举法只针对一个特定问题规模的情况,例如:八重循环嵌套解八皇后问题的算法。

 (2)递归回溯法:适用于任意指定规模的情况,例如:递归回溯法解N皇后问题。

4、算法策略的中心思想
用算法策略将解决问题的过程归结为:用算法的基本工具“循环机制和递归机制”实现。

三、算法策略侧重的问题类型
一般常遇到的问题分为四类:

(1)判定性问题:可用递推法、递归法

(2)计算问题:可用递推法、递归法

(3)最优化问题:贪心算法、分治法、动态规划法、枚举法

(4)构造性问题:贪心算法、分治法、广度优先搜索、深度优先搜索

递归算法详解

    C语言通过运行时堆栈来支持递归的调用,在我们刚接触递归的时候,国内很多教材都采用求阶乘和菲波那契数列来描述该思想,就如同深受大家敬爱的国产的C语言程序设计,老谭也用了阶乘来描述递归,以至于很多新手一看见阶乘就理所当然的认为是递归,坑了不少人,说实在的,描述这个思想还是可以,但是利用递归求阶乘可是没有一点好处,递归解决菲波那契数列效率更是低得惊人,这点是显而易见的!废话不多说,接下来我们进入正题!(不过说实话,我很讨厌接下来这些太理论的东西,说到底就是那么个意思,大家懂就好了,也可以当看看故事!我主要说的就是各种各样递归的实例)

1:递归算法的思想

递归算法是把问题转化为规模缩小了的同类问题的子问题。然后递归调用函数(或过程)来表示问题的解。在C语言中的运行堆栈为他的存在提供了很好的支持,过程一般是通过函数或子过程来实现。

递归算法:在函数或子过程的内部,直接或者间接地调用自己的算法。

2:递归算法的特点:

递归算法是一种直接或者间接地调用自身算法的过程。在计算机编写程序中,递归算法对解决一大类问题是十分有效的,它往往使算法的描述简洁而且易于理解。
递归算法解决问题的特点:
(1) 递归就是在过程或函数里调用自身。
(2) 在使用递归策略时,必须有一个明确的递归结束条件,称为递归出口。
(3) 递归算法解题通常显得很简洁,但递归算法解题的运行效率较低。所以一般不提倡用递归算法设计程序。
(4) 在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储。递归次数过多容易造成栈溢出等。所以一般不提倡用递归算法设计程序。
3:递归算法的要求
递归算法所体现的“重复”一般有三个要求:
一是每次调用在规模上都有所缩小(通常是减半);
二是相邻两次重复之间有紧密的联系,前一次要为后一次做准备(通常前一次的输出就作为后一次的输入);
三是在问题的规模极小时必须用直接给出解答而不再进行递归调用,因而每次递归调用都是有条件的(以规模未达到直接解答的大小为条件),无条件递归调用将会成为死循环而不能正常结束。
4:各式各样利用递归的问题

1:首先看看那些传统的问题吧,如使用递归来解决斐波那契数列的第n个数是多少?(开始从1开始)

#include
using namespace std;

int Fib(int index);
int main(int argc, char* argv[])
{
cout<<Fib(12)<<endl;
system(“pause”);
return 0;
}

int Fib(int index)
{
if(index1 || index2)
return index;
else
return Fib(index-1) + Fib(index-2); //开始递归调用

}

写程序的时候我测试了一下,假如要第100个数字,那时间可不知道等了多久,调用函数达到了上千次,速度太慢,对于这种情况,我们对比一下不使用的递归的时候时间消耗,这里只需要多加一个函数即可

#include
#include
using namespace std;

int Fib2(int index);
int Fib1(int index);
int main(int argc, char* argv[])
{
clock_t start,finish;

cout<<"不使用递归:"<<endl;
start = clock();
cout<<"所得结果为 "<<Fib2(40)<<endl;
finish = clock();
cout<<"时间消耗为 "<<finish - start<<"毫秒"<<endl;

cout<<endl;
cout<<"使用递归:"<<endl;
start = clock();
cout<<"所得结果为 "<<Fib1(40)<<endl;
finish = clock();
cout<<"时间消耗为 "<<finish - start<<"毫秒"<<endl;

system("pause");
return 0;

}

int Fib1(int index)
{
if(index1 || index2)
return index;
else
return Fib1(index-1) + Fib1(index-2); //开始递归调用
}

int Fib2(int index)
{
if(index == 1 || index ==2)
return index;
int *array = new int [index+1];
array[1]=1; //第0个元素没有使用
array[2]=2;
for(int i=3;i<=index;++i)
array[i] = array[i-1] + array[i-2];
return array[index];
}

运行结果:

结果显而易见,差距太明显,在这里我们同时求第40个斐波那契数字比较时间消耗,所以大家可以看到递归的时间消耗是非常严重,而且效率非常低下,上面已经说了,在可以不用递归的时候尽量不用,那么递归是不是一无是处勒?答案是否定的,在很多程序设计大赛中,有很多题用一般的思路是很难解的,或者是过程繁琐,如果适当的利用递归,结果将事半功倍!!!

2:递归的汉诺塔

这个程序以及说明在分治算法那一节已经说了,递归和分治通常都是结合在一起使用的,一次次的缩小范围,而且子问题和原问题具有相同的结构!  这里我直接把汉诺塔代码拷贝过来,就不多说了!

#include <stdio.h>
#include <stdlib.h>

static int count = -1;

void move(char x,char y); // 对move函数的声明
void hanoi(int n,char one,char two,char three) ;// 对hanoi函数的声明\

int main()
{
int m;
printf(“请输入一共有多少个板子需要移动:”);
scanf("%d",&m);
printf(“以下是%d个板子的移动方案:\n”,m);
hanoi(m,‘A’,‘B’,‘C’);
system(“pause”);
return 0;
}

void hanoi(int n,char one,char two,char three) // 定义hanoi函数
// 将n个盘从one座借助two座,移到three座
{

if(n==1)  
    move(one,three);  
else  
{  
    hanoi(n-1,one,three,two);                   //首先把n-1个从one移动到two  
    move(one,three);                            //然后把最后一个n从one移动到three  
    hanoi(n-1,two,one,three);                   //最后再把n-1个从two移动到three  
}  

}

void move(char x,char y) // 定义move函数
{
count++;
if( !(count%5) )
printf("\n");
printf("%c移动至%c ",x,y);
}

3:兔子繁殖问题(递归实现)
一对小兔子一年后长成大兔子,一对大兔子每半年生一对小兔子,大兔子的繁殖期为4年,兔子的寿命为6年,假定第一年年初投放了一对小兔子,请编程实现,第N年年末总共有多少只兔子,N由键盘输入!

解析,这个题目比较好懂,也就是一对小兔子前一年长大,然后每半年产一对小兔子,持续4年,然后最后一年不生殖了,再过一年死亡,题目看似简单,其实要想递归起来可不是那么容易的,大家可以想一下!

代码如下:

4:整数的划分问题

将一个整数分解为若干个整数之和的形式,比如 n = n1+n2+n3+n4··········!不同划分的个数称为N的划分数。

例如对于6而言:

6;

5+1;

4+2,4+1+1;

3+3;3+2+1;3+1+1+1;

2+2+2;2+2+1+1;2+1+1+1+1;

1+1+1+1+1+1 一共有6种!

1、 q(n,1) = 1 ,n>=1 ;
当最大加数不大于1时,任何正整数n只有一种表示方式:n = 1+1+……+1 。n个1的和。
2、q( n,m ) = q( n,n ),n<=m; 最大加数不能大于n。
3、 q( n,n ) = 1 + q( n , n-1 ); 正整数的划分由n1=n和n1<=n的划分组成。
4、q( n,m ) = q( n,m-1 )+q( n-m,m ), n>m>1;正整数n的最大加数不大于m的划分由 n1=m的划分和n1<m的划分组成。

现在可以依据这个递推原理写出程序:

#include <stdio.h>
#include <stdlib.h>
int intPart( int n , int m ) ;
int main()
{
int num ;
int partNum = 0 ;
printf(“Please input an integer:/n”) ;
scanf("%d",&num) ;
partNum = intPart(num,num);
printf("%d/n",partNum) ;
system(“pause”);
return 0;
}
int intPart( int n , int m )
{
if( ( n < 1 ) ||( m < 1 ) ) return 0 ;
if( ( n == 1 )||( m == 1 ) ) return 1 ;
if( n < m ) return intPart( n , n ) ;
if( n == m ) return intPart( n , m-1 ) + 1 ;
}

运行结果可以看到一共有11种情况
5 整数的全排列问题:

全排列的递归实现也就是不停的交换两个数的位置,题目描述这里就省了,直接上代码!

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void swap(char *a,char *b)
{
char temp = *a;
*a = *b;
*b = temp;
}
//k表示循环到第几个字符,m表示该次循环的总长度
void arrange(char *pizstr,int k,int m)
{
if(k == m)
{
static int m_count = 1;
printf(“the %d time:%s\n”,m_count++,pizstr);
}
else
{
for(int i=k;i<=m;i++) //主要递归球全排列的代码
{
swap(pizstr+k,pizstr+i);
arrange(pizstr,k+1,m);
swap(pizstr+k,pizstr+i);
}
}
}
void foo(char *p_str)
{
arrange(p_str,0,strlen(p_str)-1);
}
int main()
{
char pstr[] = “12345”;
printf("%s\n",pstr);
foo(pstr);
system(“pause”);
return 0;
}

大师 L. Peter Deutsch 说过:To Iterate is Human, to Recurse, Divine.中文译为:人理解迭代,神理解递归。毋庸置疑地,递归确实是一个奇妙的思维方式。对一些简单的递归问题,我们总是惊叹于递归描述问题的能力和编写代码的简洁,但要想真正领悟递归的精髓、灵活地运用递归思想来解决问题却并不是一件容易的事情。本文剖析了递归的思想内涵,分析了递归与循环的联系与区别,给出了递归的应用场景和一些典型应用,并利用递归和非递归的方式解决了包括阶乘、斐波那契数列、汉诺塔、杨辉三角的存取、字符串回文判断、字符串全排列、二分查找、树的深度求解在内的八个经典问题。

一. 引子

大师 L. Peter Deutsch 说过:To Iterate is Human, to Recurse, Divine.中文译为:人理解迭代,神理解递归。毋庸置疑地,递归确实是一个奇妙的思维方式。对一些简单的递归问题,我们总是惊叹于递归描述问题的能力和编写代码的简洁,但要想真正领悟递归的精髓、灵活地运用递归思想来解决问题却并不是一件容易的事情。在正式介绍递归之前,我们首先引用知乎用户李继刚(https://www.zhihu.com/question/20507130/answer/15551917)对递归和循环的生动解释:

递归:你打开面前这扇门,看到屋里面还有一扇门。你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门,你继续打开它。若干次之后,你打开面前的门后,发现只有一间屋子,没有门了。然后,你开始原路返回,每走回一间屋子,你数一次,走到入口的时候,你可以回答出你到底用这你把钥匙打开了几扇门。

循环:你打开面前这扇门,看到屋里面还有一扇门。你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门(若前面两扇门都一样,那么这扇门和前两扇门也一样;如果第二扇门比第一扇门小,那么这扇门也比第二扇门小,你继续打开这扇门,一直这样继续下去直到打开所有的门。但是,入口处的人始终等不到你回去告诉他答案。

上面的比喻形象地阐述了递归与循环的内涵,那么我们来思考以下几个问题:

什么是递归呢?
递归的精髓(思想)是什么?
递归和循环的区别是什么?
什么时候该用递归?
使用递归需要注意哪些问题?
递归思想解决了哪些经典的问题?
这些问题正是笔者准备在本文中详细阐述的问题。

二. 递归的内涵

1、定义 (什么是递归?)

在数学与计算机科学中,递归(Recursion)是指在函数的定义中使用函数自身的方法。实际上,递归,顾名思义,其包含了两个意思:递 和 归,这正是递归思想的精华所在。

2、递归思想的内涵(递归的精髓是什么?)

正如上面所描述的场景,递归就是有去(递去)有回(归来),如下图所示。“有去”是指:递归问题必须可以分解为若干个规模较小,与原问题形式相同的子问题,这些子问题可以用相同的解题思路来解决,就像上面例子中的钥匙可以打开后面所有门上的锁一样;“有回”是指 : 这些问题的演化过程是一个从大到小,由近及远的过程,并且会有一个明确的终点(临界点),一旦到达了这个临界点,就不用再往更小、更远的地方走下去。最后,从这个临界点开始,原路返回到原点,原问题解决。

这里写图片描述

更直接地说,递归的基本思想就是把规模大的问题转化为规模小的相似的子问题来解决。特别地,在函数实现时,因为解决大问题的方法和解决小问题的方法往往是同一个方法,所以就产生了函数调用它自身的情况,这也正是递归的定义所在。格外重要的是,这个解决问题的函数必须有明确的结束条件,否则就会导致无限递归的情况。

3、用归纳法来理解递归

数学都不差的我们,第一反应就是递归在数学上的模型是什么,毕竟我们对于问题进行数学建模比起代码建模拿手多了。观察递归,我们会发现,递归的数学模型其实就是 数学归纳法,这个在高中的数列里面是最常用的了,下面回忆一下数学归纳法。

数学归纳法适用于将解决的原问题转化为解决它的子问题,而它的子问题又变成子问题的子问题,而且我们发现这些问题其实都是一个模型,也就是说存在相同的逻辑归纳处理项。当然有一个是例外的,也就是归纳结束的那一个处理方法不适用于我们的归纳处理项,当然也不能适用,否则我们就无穷归纳了。总的来说,归纳法主要包含以下三个关键要素:

步进表达式:问题蜕变成子问题的表达式
结束条件:什么时候可以不再使用步进表达式
直接求解表达式:在结束条件下能够直接计算返回值的表达式
事实上,这也正是某些数学中的数列问题在利用编程的方式去解决时可以使用递归的原因,比如著名的斐波那契数列问题。

4、递归的三要素

在我们了解了递归的基本思想及其数学模型之后,我们如何才能写出一个漂亮的递归程序呢?笔者认为主要是把握好如下三个方面:

1、明确递归终止条件;

2、给出递归终止时的处理办法;

3、提取重复的逻辑,缩小问题规模。
1
2
3
4
5
1). 明确递归终止条件

我们知道,递归就是有去有回,既然这样,那么必然应该有一个明确的临界点,程序一旦到达了这个临界点,就不用继续往下递去而是开始实实在在的归来。换句话说,该临界点就是一种简单情境,可以防止无限递归。

2). 给出递归终止时的处理办法

我们刚刚说到,在递归的临界点存在一种简单情境,在这种简单情境下,我们应该直接给出问题的解决方案。一般地,在这种情境下,问题的解决方案是直观的、容易的。

3). 提取重复的逻辑,缩小问题规模*

我们在阐述递归思想内涵时谈到,递归问题必须可以分解为若干个规模较小、与原问题形式相同的子问题,这些子问题可以用相同的解题思路来解决。从程序实现的角度而言,我们需要抽象出一个干净利落的重复的逻辑,以便使用相同的方式解决子问题。

5、递归算法的编程模型

在我们明确递归算法设计三要素后,接下来就需要着手开始编写具体的算法了。在编写算法时,不失一般性,我们给出两种典型的递归算法设计模型,如下所示。

模型一: 在递去的过程中解决问题

function recursion(大规模){
if (end_condition){ // 明确的递归终止条件
end; // 简单情景
}else{ // 在将问题转换为子问题的每一步,解决该步中剩余部分的问题
solve; // 递去
recursion(小规模); // 递到最深处后,不断地归来
}
}

模型二: 在归来的过程中解决问题

function recursion(大规模){
if (end_condition){ // 明确的递归终止条件
end; // 简单情景
}else{ // 先将问题全部描述展开,再由尽头“返回”依次解决每步中剩余部分的问题
recursion(小规模); // 递去
solve; // 归来
}
}

6、递归的应用场景

在我们实际学习工作中,递归算法一般用于解决三类问题:

(1). 问题的定义是按递归定义的(Fibonacci函数,阶乘,…);

(2). 问题的解法是递归的(有些问题只能使用递归方法来解决,例如,汉诺塔问题,…);

(3). 数据结构是递归的(链表、树等的操作,包括树的遍历,树的深度,…)。

在下文我们将给出递归算法的一些经典应用案例,这些案例基本都属于第三种类型问题的范畴。

三. 递归与循环

递归与循环是两种不同的解决问题的典型思路。递归通常很直白地描述了一个问题的求解过程,因此也是最容易被想到解决方式。循环其实和递归具有相同的特性,即做重复任务,但有时使用循环的算法并不会那么清晰地描述解决问题步骤。单从算法设计上看,递归和循环并无优劣之别。然而,在实际开发中,因为函数调用的开销,递归常常会带来性能问题,特别是在求解规模不确定的情况下;而循环因为没有函数调用开销,所以效率会比递归高。递归求解方式和循环求解方式往往可以互换,也就是说,如果用到递归的地方可以很方便使用循环替换,而不影响程序的阅读,那么替换成循环往往是好的。问题的递归实现转换成非递归实现一般需要两步工作:

(1). 自己建立“堆栈(一些局部变量)”来保存这些内容以便代替系统栈,比如树的三种非递归遍历方式;

(2). 把对递归的调用转变为对循环处理。

特别地,在下文中我们将给出递归算法的一些经典应用案例,对于这些案例的实现,我们一般会给出递归和非递归两种解决方案,以便读者体会。

四. 经典递归问题实战

第一类问题:问题的定义是按递归定义的
(1). 阶乘

/**

  • Title: 阶乘的实现
  • Description:
  •  递归解法
    
  •  非递归解法
    
  • @author rico
    /
    public class Factorial {
    /
    *
    • @description 阶乘的递归实现

    • @author rico

    • @created 2017年5月10日 下午8:45:48

    • @param n

    • @return
      */
      public static long f(int n){
      if(n == 1) // 递归终止条件
      return 1; // 简单情景

      return n*f(n-1); // 相同重复逻辑,缩小问题的规模
      }

--------------------------------我是分割线-------------------------------------

/**     
 * @description 阶乘的非递归实现
 * @author rico       
 * @created 2017年5月10日 下午8:46:43     
 * @param n
 * @return     
 */
public static long f_loop(int n) {
    long result = n;
    while (n > 1) {
        n--;
        result = result * n;
    }
    return result;
}

}

(2). 斐波纳契数列

/**

  • Title: 斐波纳契数列

  • Descripti#on: 斐波纳契数列,又称黄金分割数列,指的是这样一个数列:1、1、2、3、5、8、13、21、……

  • 在数学上,斐波纳契数列以如下被以递归的方法定义:F0=0,F1=1,Fn=F(n-1)+F(n-2)(n>=2,n∈N*)。

  • 两种递归解法:经典解法和优化解法

  • 两种非递归解法:递推法和数组法

  • @author rico
    */
    public class FibonacciSequence {

    /**

    • @description 经典递归法求解
    • 斐波那契数列如下:
    • 1,1,2,3,5,8,13,21,34,…
    • 那么,计算fib(5)时,需要计算1次fib(4),2次fib(3),3次fib(2),调用了2次fib(1),即:
    • fib(5) = fib(4) + fib(3)
    • fib(4) = fib(3) + fib(2) ;fib(3) = fib(2) + fib(1)
    • fib(3) = fib(2) + fib(1)
    • 这里面包含了许多重复计算,而实际上我们只需计算fib(4)、fib(3)、fib(2)和fib(1)各一次即可,
    • 后面的optimizeFibonacci函数进行了优化,使时间复杂度降到了O(n).
    • @author rico
    • @created 2017年5月10日 下午12:00:42
    • @param n
    • @return
      */
      public static int fibonacci(int n) {
      if (n == 1 || n == 2) { // 递归终止条件
      return 1; // 简单情景
      }
      return fibonacci(n - 1) + fibonacci(n - 2); // 相同重复逻辑,缩小问题的规模
      }

——————————–我是分割线————————————-

/**

  • @description 对经典递归法的优化

  • 斐波那契数列如下:

  • 1,1,2,3,5,8,13,21,34,…

  • 那么,我们可以这样看:fib(1,1,5) = fib(1,2,4) = fib(2,3,3) = 5

  • 也就是说,以1,1开头的斐波那契数列的第五项正是以1,2开头的斐波那契数列的第四项,

  • 而以1,2开头的斐波那契数列的第四项也正是以2,3开头的斐波那契数列的第三项,

  • 更直接地,我们就可以一步到位:fib(2,3,3) = 2 + 3 = 5,计算结束。

  • 注意,前两个参数是数列的开头两项,第三个参数是我们想求的以前两个参数开头的数列的第几项。

  • 时间复杂度:O(n)

  • @author rico

  • @param first 数列的第一项

  • @param second 数列的第二项

  • @param n 目标项

  • @return
    */
    public static int optimizeFibonacci(int first, int second, int n) {
    if (n > 0) {
    if(n == 1){ // 递归终止条件
    return first; // 简单情景
    }else if(n == 2){ // 递归终止条件
    return second; // 简单情景
    }else if (n == 3) { // 递归终止条件
    return first + second; // 简单情景
    }
    return optimizeFibonacci(second, first + second, n - 1); // 相同重复逻辑,缩小问题规模
    }
    return -1;
    }

--------------------------------我是分割线-------------------------------------

/**
 * @description 非递归解法:有去无回
 * @author rico
 * @created 2017年5月10日 下午12:03:04
 * @param n
 * @return
 */
public static int fibonacci_loop(int n) {

    if (n == 1 || n == 2) {   
        return 1;
    }

    int result = -1;
    int first = 1;      // 自己维护的"栈",以便状态回溯
    int second = 1;     // 自己维护的"栈",以便状态回溯

    for (int i = 3; i <= n; i++) { // 循环
        result = first + second;
        first = second;
        second = result;
    }
    return result;
}

--------------------------------我是分割线-------------------------------------

/**
* @description 使用数组存储斐波那契数列
* @author rico
* @param n
* @return
*/
public static int fibonacci_array(int n) {
if (n > 0) {
int[] arr = new int[n]; // 使用临时数组存储斐波纳契数列
arr[0] = arr[1] = 1;

        for (int i = 2; i < n; i++) {   // 为临时数组赋值
            arr[i] = arr[i-1] + arr[i-2];
        }
        return arr[n - 1];
    }
    return -1;
}

}

(3). 杨辉三角的取值

/**

  • @description 递归获取杨辉三角指定行、列(从0开始)的值
  •          注意:与是否创建杨辉三角无关
    

1
2
3
* @author rico
* @x 指定行
* @y 指定列
/
/
*
* Title: 杨辉三角形又称Pascal三角形,它的第i+1行是(a+b)i的展开式的系数。
* 它的一个重要性质是:三角形中的每个数字等于它两肩上的数字相加。
*
* 例如,下面给出了杨辉三角形的前4行:
* 1
* 1 1
* 1 2 1
* 1 3 3 1
* @description 递归获取杨辉三角指定行、列(从0开始)的值
* 注意:与是否创建杨辉三角无关
* @author rico
* @x 指定行
* @y 指定列
*/
public static int getValue(int x, int y) {
if(y <= x && y >= 0){
if(y == 0 || x == y){ // 递归终止条件
return 1;
}else{
// 递归调用,缩小问题的规模
return getValue(x-1, y-1) + getValue(x-1, y);
}
}
return -1;
}
}

(4). 回文字符串的判断

/**

  • Title: 回文字符串的判断

  • Description: 回文字符串就是正读倒读都一样的字符串。如”98789”, “abccba”都是回文字符串

  • 两种解法:

  • 递归判断;

  • 循环判断;

  • @author rico
    */
    public class PalindromeString {

    /**

    • @description 递归判断一个字符串是否是回文字符串
    • @author rico
    • @created 2017年5月10日 下午5:45:50
    • @param s
    • @return
      */
      public static boolean isPalindromeString_recursive(String s){
      int start = 0;
      int end = s.length()-1;
      if(end > start){ // 递归终止条件:两个指针相向移动,当start超过end时,完成判断
      if(s.charAt(start) != s.charAt(end)){
      return false;
      }else{
      // 递归调用,缩小问题的规模
      return isPalindromeString_recursive(s.substring(start+1).substring(0, end-1));
      }
      }
      return true;
      }

--------------------------------我是分割线-------------------------------------

/**     
 * @description 循环判断回文字符串
 * @author rico       
 * @param s
 * @return     
 */
public static boolean isPalindromeString_loop(String s){
    char[] str = s.toCharArray();
    int start = 0;
    int end = str.length-1;
    while(end > start){  // 循环终止条件:两个指针相向移动,当start超过end时,完成判断
        if(str[end] != str[start]){
            return false;
        }else{
            end --;
            start ++;
        }
    }
    return true;
}

}

(5). 字符串全排列

递归解法
/**

  • @description 从字符串数组中每次选取一个元素,作为结果中的第一个元素;然后,对剩余的元素全排列

    • @author rico
    • @param s
    •        字符数组
      
    • @param from
    •        起始下标
      
    • @param to
    •        终止下标
      

    */
    public static void getStringPermutations3(char[] s, int from, int to) {
    if (s != null && to >= from && to < s.length && from >= 0) { // 边界条件检查
    if (from == to) { // 递归终止条件
    System.out.println(s); // 打印结果
    } else {
    for (int i = from; i <= to; i++) {
    swap(s, i, from); // 交换前缀,作为结果中的第一个元素,然后对剩余的元素全排列
    getStringPermutations3(s, from + 1, to); // 递归调用,缩小问题的规模
    swap(s, from, i); // 换回前缀,复原字符数组
    }
    }
    }
    }

    /**

    • @description 对字符数组中的制定字符进行交换
    • @author rico
    • @param s
    • @param from
    • @param to
      */
      public static void swap(char[] s, int from, int to) {
      char temp = s[from];
      s[from] = s[to];
      s[to] = temp;
      }
      1

非递归解法(字典序全排列)
/**

  • Title: 字符串全排列非递归算法(字典序全排列)
  • Description: 字典序全排列,其基本思想是:
  • 先对需要求排列的字符串进行字典排序,即得到全排列中最小的排列.
  • 然后,找到一个比它大的最小的全排列,一直重复这一步直到找到最大值,即字典排序的逆序列.
  • 不需要关心字符串长度
  • @author rico
    */
    public class StringPermutationsLoop {

/**

  • @description 字典序全排列

  • 设一个字符串(字符数组)的全排列有n个,分别是A1,A2,A3,…,An

    1. 找到最小的排列 Ai
    1. 找到一个比Ai大的最小的后继排列Ai+1
    1. 重复上一步直到没有这样的后继
  • 重点就是如何找到一个排列的直接后继:

  • 对于字符串(字符数组)a0a1a2……an,

    1. 从an到a0寻找第一次出现的升序排列的两个字符(即ai < ai+1),那么ai+1是一个极值,因为ai+1之后的字符为降序排列,记 top=i+1;
    1. 从top处(包括top)开始查找比ai大的最小的值aj,记 minMax = j;
    1. 交换minMax处和top-1处的字符;
    1. 翻转top之后的字符(包括top),即得到一个排列的直接后继排列
  • @author rico

    • @param s
    •        字符数组
      
    • @param from
    •        起始下标
      
    • @param to
    •        终止下标
      

    */
    public static void getStringPermutations4(char[] s, int from, int to) {

    Arrays.sort(s,from,to+1);  // 对字符数组的所有元素进行升序排列,即得到最小排列 
    System.out.println(s);    
    
    char[] descendArr = getMaxPermutation(s, from, to); // 得到最大排列,即最小排列的逆序列
    
    while (!Arrays.equals(s, descendArr)) {  // 循环终止条件:迭代至最大排列
        if (s != null && to >= from && to < s.length && from >= 0) { // 边界条件检查
            int top = getExtremum(s, from, to); // 找到序列的极值
            int minMax = getMinMax(s, top, to);  // 从top处(包括top)查找比s[top-1]大的最小值所在的位置
            swap(s, top - 1, minMax);  // 交换minMax处和top-1处的字符
            s = reverse(s, top, to);   // 翻转top之后的字符
            System.out.println(s);
        }
    }
    

    }

    /**

    • @description 对字符数组中的制定字符进行交换
    • @author rico
    • @param s
    • @param from
    • @param to
      */
      public static void swap(char[] s, int from, int to) {
      char temp = s[from];
      s[from] = s[to];
      s[to] = temp;
      }

    /**

    • @description 获取序列的极值
    • @author rico
    • @param s 序列
    • @param from 起始下标
    • @param to 终止下标
    • @return
      */
      public static int getExtremum(char[] s, int from, int to) {
      int index = 0;
      for (int i = to; i > from; i–) {
      if (s[i] > s[i - 1]) {
      index = i;
      break;
      }
      }
      return index;
      }

    /**

    • @description 从top处查找比s[top-1]大的最小值所在的位置
    • @author rico
    • @created 2017年5月10日 上午9:21:13
    • @param s
    • @param top 极大值所在位置
    • @param to
    • @return
      */
      public static int getMinMax(char[] s, int top, int to) {
      int index = top;
      char base = s[top-1];
      char temp = s[top];
      for (int i = top + 1; i <= to; i++) {
      if (s[i] > base && s[i] < temp) {
      temp = s[i];
      index = i;
      }
      continue;
      }
      return index;
      }

    /**

    • @description 翻转top(包括top)后的序列
    • @author rico
    • @param s
    • @param from
    • @param to
    • @return
      */
      public static char[] reverse(char[] s, int top, int to) {
      char temp;
      while(top < to){
      temp = s[top];
      s[top] = s[to];
      s[to] = temp;
      top ++;
      to --;
      }
      return s;
      }

    /**

    • @description 根据最小排列得到最大排列
    • @author rico
    • @param s 最小排列
    • @param from 起始下标
    • @param to 终止下标
    • @return
      */
      public static char[] getMaxPermutation(char[] s, int from, int to) {
      //将最小排列复制到一个新的数组中
      char[] dsc = Arrays.copyOfRange(s, 0, s.length);
      int first = from;
      int end = to;
      while(end > first){ // 循环终止条件
      char temp = dsc[first];
      dsc[first] = dsc[end];
      dsc[end] = temp;
      first ++;
      end --;
      }
      return dsc;
      }

(6). 二分查找

/**

  • @description 二分查找的递归实现
  • @author rico
  • @param array 目标数组
  • @param low 左边界
  • @param high 右边界
  • @param target 目标值
  • @return 目标值所在位置
    */

public static int binarySearch(int[] array, int low, int high, int target) {

    //递归终止条件
    if(low <= high){
        int mid = (low + high) >> 1;
        if(array[mid] == target){
            return mid + 1;  // 返回目标值的位置,从1开始
        }else if(array[mid] > target){
            // 由于array[mid]不是目标值,因此再次递归搜索时,可以将其排除
            return binarySearch(array, low, mid-1, target);
        }else{
            // 由于array[mid]不是目标值,因此再次递归搜索时,可以将其排除
            return binarySearch(array, mid+1, high, target);
        }
    }
    return -1;   //表示没有搜索到
}

--------------------------------我是分割线-------------------------------------
/**
* @description 二分查找的非递归实现
* @author rico
* @param array 目标数组
* @param low 左边界
* @param high 右边界
* @param target 目标值
* @return 目标值所在位置
*/
public static int binarySearchNoRecursive(int[] array, int low, int high, int target) {

    // 循环
    while (low <= high) {
        int mid = (low + high) >> 1;
        if (array[mid] == target) {
            return mid + 1; // 返回目标值的位置,从1开始
        } else if (array[mid] > target) {
            // 由于array[mid]不是目标值,因此再次递归搜索时,可以将其排除
            high = mid -1;
        } else {
            // 由于array[mid]不是目标值,因此再次递归搜索时,可以将其排除
            low = mid + 1;
        }
    }
    return -1;  //表示没有搜索到
}

24
第二类问题:问题解法按递归算法实现
(1). 汉诺塔问题

/**

  • Title: 汉诺塔问题

  • Description:古代有一个梵塔,塔内有三个座A、B、C,A座上有64个盘子,盘子大小不等,大的在下,小的在上。

  • 有一个和尚想把这64个盘子从A座移到C座,但每次只能允许移动一个盘子,并且在移动过程中,3个座上的盘子始终保持大盘在下,

  • 小盘在上。在移动过程中可以利用B座。要求输入层数,运算后输出每步是如何移动的。

  • @author rico
    */
    public class HanoiTower {

    /**

    • @description 在程序中,我们把最上面的盘子称为第一个盘子,把最下面的盘子称为第N个盘子

    • @author rico

    • @param level:盘子的个数

    • @param from 盘子的初始地址

    • @param inter 转移盘子时用于中转

    • @param to 盘子的目的地址
      */
      public static void moveDish(int level, char from, char inter, char to) {

      if (level == 1) { // 递归终止条件
      System.out.println(“从” + from + " 移动盘子" + level + " 号到" + to);
      } else {
      // 递归调用:将level-1个盘子从from移到inter(不是一次性移动,每次只能移动一个盘子,其中to用于周转)
      moveDish(level - 1, from, to, inter); // 递归调用,缩小问题的规模
      // 将第level个盘子从A座移到C座
      System.out.println(“从” + from + " 移动盘子" + level + " 号到" + to);
      // 递归调用:将level-1个盘子从inter移到to,from 用于周转
      moveDish(level - 1, inter, from, to); // 递归调用,缩小问题的规模
      }
      }

    public static void main(String[] args) {
    int nDisks = 30;
    moveDish(nDisks, ‘A’, ‘B’, ‘C’);
    }

第三类问题:数据的结构是按递归定义的
(1). 二叉树深度

/**

  • Title: 递归求解二叉树的深度
  • Description:
  • @author rico
  • @created 2017年5月8日 下午6:34:50
    */

public class BinaryTreeDepth {

/**     
 * @description 返回二叉数的深度
 * @author rico       
 * @param t
 * @return     
 */
public static int getTreeDepth(Tree t) {

    // 树为空
    if (t == null) // 递归终止条件
        return 0;

    int left = getTreeDepth(t.left); // 递归求左子树深度,缩小问题的规模
    int right = getTreeDepth(t.left); // 递归求右子树深度,缩小问题的规模

    return left > right ? left + 1 : right + 1;
}

}

(2). 二叉树深度

/**

  • @description 前序遍历(递归)
  • @author rico
  • @created 2017年5月22日 下午3:06:11
  • @param root
  • @return
    */
    public String preOrder(Node root) {
    StringBuilder sb = new StringBuilder(); // 存到递归调用栈
    if (root == null) { // 递归终止条件
    return “”; // ji
    }else { // 递归终止条件
    sb.append(root.data + " "); // 前序遍历当前结点
    sb.append(preOrder(root.left)); // 前序遍历左子树
    sb.append(preOrder(root.right)); // 前序遍历右子树
    return sb.toString();
    }
    }

剪枝:

2019/01/04 20:52
剪枝算法(算法优化)
2015年03月05日 10:40:56 瞭望天空 阅读数:15053 标签: dfs剪枝算法算法优化 更多
个人分类: algorithmc/c++成长之路
一:剪枝策略的寻找的方法
1)微观方法:从问题本身出发,发现剪枝条件
2)宏观方法:从整体出发,发现剪枝条件。
3)注意提高效率,这是关键,最重要的。
总之,剪枝策略,属于算法优化范畴;通常应用在DFS 和 BFS 搜索算法中;剪枝策略就是寻找过滤条件,提前减少不必要的搜索路径。
二:剪枝算法(算法优化)
1、简介
在搜索算法中优化中,剪枝,就是通过某种判断,避免一些不必要的遍历过程,形象的说,就是剪去了搜索树中的某些“枝条”,故称剪枝。应用剪枝优化的核心问题是设计剪枝判断方法,即确定哪些枝条应当舍弃,哪些枝条应当保留的方法。
2、剪枝优化三原则: 正确、准确、高效.原则
搜索算法,绝大部分需要用到剪枝.然而,不是所有的枝条都可以剪掉,这就需要通过设计出合理的判断方法,以决定某一分支的取舍. 在设计判断方法的时候,需要遵循一定的原则.
剪枝的原则
1) 正确性
正如上文所述,枝条不是爱剪就能剪的. 如果随便剪枝,把带有最优解的那一分支也剪掉了的话,剪枝也就失去了意义. 所以,剪枝的前提是一定要保证不丢失正确的结果.
2)准确性
在保证了正确性的基础上,我们应该根据具体问题具体分析,采用合适的判断手段,使不包含最优解的枝条尽可能多的被剪去,以达到程序“最优化”的目的. 可以说,剪枝的准确性,是衡量一个优化算法好坏的标准.
3)高效性
设计优化程序的根本目的,是要减少搜索的次数,使程序运行的时间减少. 但为了使搜索次数尽可能的减少,我们又必须花工夫设计出一个准确性较高的优化算法,而当算法的准确性升高,其判断的次数必定增多,从而又导致耗时的增多,这便引出了矛盾. 因此,如何在优化与效率之间寻找一个平衡点,使得程序的时间复杂度尽可能降低,同样是非常重要的. 倘若一个剪枝的判断效果非常好,但是它却需要耗费大量的时间来判断、比较,结果整个程序运行起来也跟没有优化过的没什么区别,这样就太得不偿失了.
3、分类
剪枝算法按照其判断思路可大致分成两类:可行性剪枝及最优性剪枝.
3.1 可行性剪枝 —— 该方法判断继续搜索能否得出答案,如果不能直接回溯。
3.2 最优性剪枝
最优性剪枝,又称为上下界剪枝,是一种重要的搜索剪枝策略。它记录当前得到的最优值,如果当前结点已经无法产生比当前最优解更优的解时,可以提前回溯。

三:示例分析
题目来源于poj 3900 The Robbery (类似于背包问题,但是不能够用背包求解)
1 分析:W,C值很大,数组开不下(所以,不能用背包处理),但是发现N值很小,(1+15)15/2=120,所以可以考虑dfs+剪枝。
首先利用贪心的思想我们对箱子进行排序,关键字为性价比(参考了poj里的discuss)。也就是单位重量的价值最高的排第一,搜索的时候枚举顺序注意一定要从满到空,这样才能最快的找到一个可行解然后利用它进行接下来的剪枝。
剪枝1. 之后所有的钻石价值+目前已经得到的价值<=ans 则剪枝。
剪枝2. 剩下的重量全部装目前最高性价比的钻石+目前已经得到的价值<=ans 则剪枝(非常重要的剪枝)。
2 程序代码
#include
#include
#include
using namespace std;
#define MY_MAX(a,b) (a)>(b)?(a):(b)
const int maxN = 20;
struct NOTE
{
long long weight;
long long value;
int num;
}box[maxN];
int n;// 个数小于20
long long m,ans;// m 总重量,ans最优解
long long sum[maxN]; //保存一个后缀和
bool cmp(const struct NOTE &a, const struct NOTE &b)
{//按性价比排序,从大到小排列(注意若有取地址符号,则需有const)
return a.value
1.0/a.weight > b.value1.0/b.weight;
}
inline bool cut (int pos,long long now_value,long long last_weight)
{
if(pos == n+1) return true;//边界返回条件
if(now_value+sum[pos] < ans) return true;如果后面所有的钻石加起来都<=ans,剪掉
double best = (box[pos].value
1.0/box[pos].weight);//当前最大的性价比
if(now_value+(long long)ceil(bestlast_weight) < ans) return true;//以这个性价比取剩下的所有重量,如果<=ans,剪掉
return false;
}
void dfs(int pos,long long now_value,long long last_weight) //pos 当前数组的下标位置,now_value 目前的重量和,last_weight当前背包剩余容量
{
ans = MY_MAX(ans,now_value);
if(cut(pos,now_value,last_weight)) return;//剪枝函数
for(int i=box[pos].num;i>=0;–i)//(暴力搜索)枚举顺序从满到空枚举,这样才能最快找到ans,然后利用ans剪枝
{
if(last_weight<box[pos].weight
i) continue;
dfs(pos+1,now_value+box[pos].valuei,last_weight-box[pos].weighti);
}
}
int main()
{
int cas;
long long sumv,sumw;// 价值和重量的和;仅仅用到了一次(特殊情况才用到,能够一次全带走)
scanf("%d",&cas);
while(cas–)
{
ans=0;
sumv=sumw=0;
scanf("%d%lld",&n,&m);
for(int i=1;i<=n;i++)
{
scanf("%lld",&box[i].weight);
sumw+=box[i].weighti;
}
for(int i=1;i<=n;i++)
{
scanf("%lld",&box[i].value);
box[i].num=i;
sumv+=box[i].value
i;
}
// 以上是数据的输入,下面才是刚刚开始的
// 如果sumv开始就比m总重量还小,直接输出
if(sumw<=m)
{
printf("%lld\n",sumv);
continue;
}
sort(box+1,box+1+n,cmp);// 从1开始计数的
sum[n+1]=0; // 倒着开始的
for(int i=n;i>=1;i–)
{
//计算后缀和
sum[i]=sum[i+1]+box[i].value*box[i].num;
}
dfs(1,0,m);
printf("%lld\n",ans);
}
return 0;

vs下debug运行正常,release下出现异常
2013年06月13日 15:07:28 candice廷 阅读数:3497 标签: c++opencv 更多
个人分类: C++opencv
VC下Debug和Release区别
最近写代码过程中,发现 Debug 下运行正常,Release 下就会出现问题,百思不得其解,而Release 下又无法进行调试,于是只能采用printf方式逐步定位到问题所在处,才发现原来是给定的一个数组未初始化,导致后面处理异常。网上查找了些资料,在这 罗列汇总下,做为备忘~
一、Debug 和 Release 的区别
Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好地使用。
Debug 和 Release 的真正区别,在于一组编译选项。
Debug 版本
参数 含义
/MDd /MLd 或 /MTd 使用 Debug runtime library(调试版本的运行时刻函数库)
/Od 关闭优化开关
/D “_DEBUG” 相当于 #define _DEBUG,打开编译调试代码开关(主要针对assert函数)
/ZI
创建 Edit and continue(编辑继续)数据库,这样在调试过程中如果修改了源代码不需重新编译
GZ 可以帮助捕获内存错误

Release 版本 参数含义
/MD /ML 或 /MT 使用发布版本的运行时刻函数库
/O1 或 /O2 优化开关,使程序最小或最快
/D “NDEBUG” 关闭条件编译调试代码开关(即不编译assert函数)
/GF 合并重复的字符串,并将字符串常量放到只读内存,防止被修改

Debug 和 Release 并没有本质的界限,他们只是一组编译选项的集合,编译器只是按照预定的选项行动。

相关经验: 转自http://dev.csdn.net/article/17/17068.shtm

  1. 变量。
    大家都知道,debug跟release在初始化变量时所做的操作是不同的,debug是将每个字节位都赋成0xcc(注1),而release的赋值近 似于随机(我想是直接从内存中分配的,没有初始化过)。这样就明确了,如果你的程序中的某个变量没被初始化就被引用,就很有可能出现异常:用作控制变量将 导致流程导向不一致;用作数组下标将会使程序崩溃;更加可能是造成其他变量的不准确而引起其他的错误。所以在声明变量后马上对其初始化一个默认的值是最简 单有效的办法,否则项目大了你找都没地方找。代码存在错误在debug方式下可能会忽略而不被察觉到,如debug方式下数组越界也大多不会出错,在 release中就暴露出来了,这个找起来就比较难了:( 还是自己多加注意吧
    呵呵,就是我犯的问题~~
  2. 自定义消息的消息参数。
    MFC为我们提供了很好的消息机制,更增加了自定义消息,好处我就不用多说了。这也存在debug跟release的问题吗?答案是肯定的。在自定义消息 的函数体声明时,时常会看到这样的写法:afx_msg LRESULT OnMessageOwn(); Debug情况下一般不会有任何问题,而当你在Release下且多线程或进程间使用了消息传递时就会导致无效句柄之类的错误。导致这个错误直接原因是消 息体的参数没有添加,即应该写成:afx_msg LRESULT OnMessageOwn(WPARAM wparam, LPARAM lparam); (注2)
  3. release模式下不出错,但debug模式下报错。
    这种情况下大多也是因为代码书写不正确引起的,查看MFC的源码,可以发现好多ASSERT的语句(断言),这个宏只是在debug模式下才有效,那么就 清楚了,release版不报错是忽略了错误而不是没有错误,这可能存在很大的隐患,因为是Debug模式下,比较方便调试,好好的检查自己的代码,再此 就不多说了。
  4. ASSERT, VERIFY, TRACE…调试宏
    这种情况很容易解释。举个例子:请在VC下输入ASSERT然后选中按F12跳到宏定义的地方,这里你就能够发现Debug中ASSERT要执行 AfxAssertFailedLine,而Release下的宏定义却为"#define ASSERT(f) ((void)0)"。所以注意在这些调试宏的语句不要用程序相关变量如i++写操作的语句。VERIFY是个例外,"#define VERIFY(f) ((void)(f))",即执行,这里的作用就不多追究了,有兴趣可自己研究:)。
    总结:
    Debug与Release不同的问题在刚开始编写代码时会经常发生,99%是因为你的代码书写错误而导致的,所以不要动不动就说系统问题或编译器问题, 努力找找自己的原因才是根本。我从前就常常遇到这情况,经历过一次次的教训后我就开始注意了,现在我所写过的代码我已经好久没遇到这种问题了。下面是几个 避免的方面,即使没有这种问题也应注意一下:
  5. 注意变量的初始化,尤其是指针变量,数组变量的初始化(很大的情况下另作考虑了)。
  6. 自定义消息及其他声明的标准写法
  7. 使用调试宏时使用后最好注释掉
  8. 尽量使用try - catch(…)
  9. 尽量使用模块,不但表达清楚而且方便调试。
    注1:
    debug版初始化成0xcc是因为0xcc在x86下是一条int 3单步中断指令,这样程序如果跑飞了遇到0xcc就会停下来,这和单片机编程时一般将没用的代码空间填入jmp 0000语句是一样地转贴于:计算机二级考试_考试大【责编:drfcy 纠错】

[VC]DEBUG和RELEASE2007年08月26日 星期日 下午 04:33 I. 内存分配问题

  1. 变量未初始化。下面的程序在debug中运行的很好。

thing * search(thing * something)
BOOL found;
for(int i = 0; i < whatever.GetSize(); i++)
{
if(whatever[i]->field == something->field)
{ /* found it /
found = TRUE;
break;
} /
found it */
}
if(found)
return whatever[i];
else
return NULL;
而在release中却不行,因为debug中会自动给变量初始化found=FALSE,而在release版中则不会。所以尽可能的给变量、类或结构初始化。

  1. 数据溢出的问题

如:char buffer[10];
int counter;

lstrcpy(buffer, “abcdefghik”);

在debug版中buffer的NULL覆盖了counter的高位,但是除非counter>16M,什么问题也没有。但是在release版 中,counter可能被放在寄存器中,这样NULL就覆盖了buffer下面的空间,可能就是函数的返回地址,这将导致ACCESS ERROR。

  1. DEBUG版和RELEASE版的内存分配方式是不同的 。如果你在DEBUG版中申请 ele 为 6*sizeof(DWORD)=24bytes,实际上分配给你的是32bytes(debug版以32bytes为单位分配), 而在release版,分配给你的就是24bytes(release版以8bytes为单位),所以在debug版中如果你写ele[6],可能不会有 什么问题,而在release版中,就有ACCESS VIOLATE。

II. ASSERT和VERIFY

  1. ASSERT在Release版本中是不会被编译的。

ASSERT宏是这样定义的

#ifdef _DEBUG
#define ASSERT(x) if( (x) == 0) report_assert_failure()
#else
#define ASSERT(x)
#endif
实际上复杂一些,但无关紧要。假如你在这些语句中加了程序中必须要有的代码
比如

ASSERT(pNewObj = new CMyClass);

pNewObj->MyFunction();

这种时候Release版本中的pNewObj不会分配到空间

所以执行到下一个语句的时候程序会报该程序执行了非法操作的错误。这时可以用VERIFY :

#ifdef _DEBUG
#define VERIFY(x) if( (x) == 0) report_assert_failure()
#else
#define VERIFY(x) (x)
#endif
这样的话,代码在release版中就可以执行了。

III. 参数问题:

自定义消息的处理函数,必须定义如下:

afx_msg LRESULT OnMyMessage(WPARAM, LPARAM);

返回值必须是HRESULT型,否则Debug会过,而Release出错

IV. 内存分配

保证数据创建和清除的统一性:如果一个DLL提供一个能够创建数据的函数,那么这个DLL同时应该提供一个函数销毁这些数据。数据的创建和清除应该在同一个层次上。

V. DLL的灾难

人们将不同版本DLL混合造成的不一致性形象的称为 “动态连接库的地狱“(DLL Hell) ,甚至微软自己也这么说http://msdn.microsoft.com/library/techart/dlldanger1.htm)。

如果你的程序使用你自己的DLL时请注意:

  1. 不能将debug和release版的DLL混合在一起使用。debug都是debug版,release版都是release版。

解决办法是将debug和release的程序分别放在主程序的debug和release目录下

  1. 千万不要以为静态连接库会解决问题,那只会使情况更糟糕。

VI. RELEASE板中的调试 :

  1. 将ASSERT() 改为 VERIFY() 。找出定义在"#ifdef _DEBUG"中的代码,如果在RELEASE版本中需要这些代码请将他们移到定义外。查找TRACE(…)中代码,因为这些代码在RELEASE中 也不被编译。 请认真检查那些在RELEASE中需要的代码是否并没有被便宜。

  2. 变量的初始化所带来的不同,在不同的系统,或是在DEBUG/RELEASE版本间都存在这样的差异,所以请对变量进行初始化。

  3. 是否在编译时已经有了警告?请将警告级别设置为3或4,然后保证在编译时没有警告出现.

VII. 将Project Settings" 中 "C++/C " 项目下优化选项改为Disbale(Debug)。编译器的优化可能导致许多意想不到的错误,请参http://www.pgh.net/~newcomer/debug_release.htm

  1. 此外对RELEASE版本的软件也可以进行调试,请做如下改动:

在"Project Settings" 中 "C++/C " 项目下设置 “category” 为 “General” 并且将"Debug Info"设置为 “Program Database”。

在"Link"项目下选中"Generate Debug Info"检查框。

“Rebuild All”

如此做法会产生的一些限制:

无法获得在MFC DLL中的变量的值。

必须对该软件所使用的所有DLL工程都进行改动。

另:

MS BUG:MS的一份技术文档中表明,在VC5中对于DLL的"Maximize Speed"优化选项并未被完全支持,因此这将会引起内存错误并导致程序崩溃。

  1. http://www.sysinternals.com/有 一个程序DebugView,用来捕捉OutputDebugString的输出,运行起来后(估计是自设为system debugger)就可以观看所有程序的OutputDebugString的输出。此后,你可以脱离VC来运行你的程序并观看调试信息。

  2. 有一个叫Gimpel Lint的静态代码检查工具,据说比较好用http://www.gimpel.com/ 不过要化$的。

算法-动态规划 Dynamic Programming–从菜鸟到老鸟
2017年07月15日 22:58:29 HankingHu 阅读数:65940更多
所属专栏: 图解算法
版权声明:本文为博主原创文章,转载请标明出处。 https://blog.csdn.net/u013309870/article/details/75193592
前言
最近在牛客网上做了几套公司的真题,发现有关动态规划(Dynamic Programming)算法的题目很多。相对于我来说,算法里面遇到的问题里面感觉最难的也就是动态规划(Dynamic Programming)算法了,于是花了好长时间,查找了相关的文献和资料准备彻底的理解动态规划(Dynamic Programming)算法。一是帮助自己总结知识点,二是也能够帮助他人更好的理解这个算法。后面的参考文献只是我看到的文献的一部分。
动态规划算法的核心
理解一个算法就要理解一个算法的核心,动态规划算法的核心是下面的一张图片和一个小故事。

A * “1+1+1+1+1+1+1+1 =?” *

A : “上面等式的值是多少”
B : 计算 “8!”

A *在上面等式的左边写上 “1+” *
A : “此时等式的值为多少”
B : quickly “9!”
A : “你怎么这么快就知道答案了”
A : “只要在8的基础上加1就行了”
A : “所以你不用重新计算因为你记住了第一个等式的值为8!动态规划算法也可以说是 ‘记住求过的解来节省时间’”

由上面的图片和小故事可以知道动态规划算法的核心就是记住已经解决过的子问题的解。
动态规划算法的两种形式
上面已经知道动态规划算法的核心是记住已经求过的解,记住求解的方式有两种:①自顶向下的备忘录法 ②自底向上。
为了说明动态规划的这两种方法,举一个最简单的例子:求斐波拉契数列Fibonacci 。先看一下这个问题:
Fibonacci (n) = 1; n = 0

Fibonacci (n) = 1; n = 1

Fibonacci (n) = Fibonacci(n-1) + Fibonacci(n-2)

以前学c语言的时候写过这个算法使用递归十分的简单。先使用递归版本来实现这个算法:
public int fib(int n)
{
if(n<=0)
return 0;
if(n==1)
return 1;
return fib( n-1)+fib(n-2);
}
//输入6
//输出:8

先来分析一下递归算法的执行流程,假如输入6,那么执行的递归树如下:

上面的递归树中的每一个子节点都会执行一次,很多重复的节点被执行,fib(2)被重复执行了5次。由于调用每一个函数的时候都要保留上下文,所以空间上开销也不小。这么多的子节点被重复执行,如果在执行的时候把执行过的子节点保存起来,后面要用到的时候直接查表调用的话可以节约大量的时间。下面就看看动态规划的两种方法怎样来解决斐波拉契数列Fibonacci 数列问题。
①自顶向下的备忘录法
public static int Fibonacci(int n)
{
if(n<=0)
return n;
int []Memo=new int[n+1];
for(int i=0;i<=n;i++)
Memo[i]=-1;
return fib(n, Memo);
}
public static int fib(int n,int []Memo)
{

    if(Memo[n]!=-1)
        return Memo[n];
//如果已经求出了fib(n)的值直接返回,否则将求出的值保存在Memo备忘录中。               
    if(n<=2)
        Memo[n]=1;

    else Memo[n]=fib( n-1,Memo)+fib(n-2,Memo);  

    return Memo[n];
}

备忘录法也是比较好理解的,创建了一个n+1大小的数组来保存求出的斐波拉契数列中的每一个值,在递归的时候如果发现前面fib(n)的值计算出来了就不再计算,如果未计算出来,则计算出来后保存在Memo数组中,下次在调用fib(n)的时候就不会重新递归了。比如上面的递归树中在计算fib(6)的时候先计算fib(5),调用fib(5)算出了fib(4)后,fib(6)再调用fib(4)就不会在递归fib(4)的子树了,因为fib(4)的值已经保存在Memo[4]中。
②自底向上的动态规划
备忘录法还是利用了递归,上面算法不管怎样,计算fib(6)的时候最后还是要计算出fib(1),fib(2),fib(3)……,那么何不先计算出fib(1),fib(2),fib(3)……,呢?这也就是动态规划的核心,先计算子问题,再由子问题计算父问题。
public static int fib(int n)
{
if(n<=0)
return n;
int []Memo=new int[n+1];
Memo[0]=0;
Memo[1]=1;
for(int i=2;i<=n;i++)
{
Memo[i]=Memo[i-1]+Memo[i-2];
}
return Memo[n];
}

自底向上方法也是利用数组保存了先计算的值,为后面的调用服务。观察参与循环的只有 i,i-1 , i-2三项,因此该方法的空间可以进一步的压缩如下。
public static int fib(int n)
{
if(n<=1)
return n;

    int Memo_i_2=0;
    int Memo_i_1=1;
    int Memo_i=1;
    for(int i=2;i<=n;i++)
    {
        Memo_i=Memo_i_2+Memo_i_1;
        Memo_i_2=Memo_i_1;
        Memo_i_1=Memo_i;
    }       
    return Memo_i;
}

一般来说由于备忘录方式的动态规划方法使用了递归,递归的时候会产生额外的开销,使用自底向上的动态规划方法要比备忘录方法好。
你以为看懂了上面的例子就懂得了动态规划吗?那就too young too simple了。动态规划远远不止如此简单,下面先给出一个例子看看能否独立完成。然后再对动态规划的其他特性进行分析。
动态规划小试牛刀
例题:钢条切割

上面的例题来自于算法导论
关于题目的讲解就直接截图算法导论书上了这里就不展开讲。现在使用一下前面讲到三种方法来来实现一下。
①递归版本
public static int cut(int []p,int n)
{
if(n==0)
return 0;
int q=Integer.MIN_VALUE;
for(int i=1;i<=n;i++)
{
q=Math.max(q, p[i-1]+cut(p, n-i));
}
return q;
}

递归很好理解,如果不懂可以看上面的讲解,递归的思路其实和回溯法是一样的,遍历所有解空间但这里和上面斐波拉契数列的不同之处在于,在每一层上都进行了一次最优解的选择,q=Math.max(q, p[i-1]+cut(p, n-i));这个段语句就是最优解选择,这里上一层的最优解与下一层的最优解相关。
②备忘录版本
public static int cutMemo(int []p)
{
int []r=new int[p.length+1];
for(int i=0;i<=p.length;i++)
r[i]=-1;
return cut(p, p.length, r);
}
public static int cut(int []p,int n,int []r)
{
int q=-1;
if(r[n]>=0)
return r[n];
if(n==0)
q=0;
else {
for(int i=1;i<=n;i++)
q=Math.max(q, cut(p, n-i,r)+p[i-1]);
}
r[n]=q;

    return q;
}

有了上面求斐波拉契数列的基础,理解备忘录方法也就不难了。备忘录方法无非是在递归的时候记录下已经调用过的子函数的值。这道钢条切割问题的经典之处在于自底向上的动态规划问题的处理,理解了这个也就理解了动态规划的精髓。
③自底向上的动态规划
public static int buttom_up_cut(int []p)
{
int []r=new int[p.length+1];
for(int i=1;i<=p.length;i++)
{
int q=-1;
//①
for(int j=1;j<=i;j++)
q=Math.max(q, p[j-1]+r[i-j]);
r[i]=q;
}
return r[p.length];
}

自底向上的动态规划问题中最重要的是理解注释①处的循环,这里外面的循环是求r[1],r[2]……,里面的循环是求出r[1],r[2]……的最优解,也就是说r[i]中保存的是钢条长度为i时划分的最优解,这里面涉及到了最优子结构问题,也就是一个问题取最优解的时候,它的子问题也一定要取得最优解。下面是长度为4的钢条划分的结构图。我就偷懒截了个图。

动态规划原理
虽然已经用动态规划方法解决了上面两个问题,但是大家可能还跟我一样并不知道什么时候要用到动态规划。总结一下上面的斐波拉契数列和钢条切割问题,发现两个问题都涉及到了重叠子问题,和最优子结构。
①最优子结构
用动态规划求解最优化问题的第一步就是刻画最优解的结构,如果一个问题的解结构包含其子问题的最优解,就称此问题具有最优子结构性质。因此,某个问题是否适合应用动态规划算法,它是否具有最优子结构性质是一个很好的线索。使用动态规划算法时,用子问题的最优解来构造原问题的最优解。因此必须考查最优解中用到的所有子问题。

②重叠子问题
在斐波拉契数列和钢条切割结构图中,可以看到大量的重叠子问题,比如说在求fib(6)的时候,fib(2)被调用了5次,在求cut(4)的时候cut(0)被调用了4次。如果使用递归算法的时候会反复的求解相同的子问题,不停的调用函数,而不是生成新的子问题。如果递归算法反复求解相同的子问题,就称为具有重叠子问题(overlapping subproblems)性质。在动态规划算法中使用数组来保存子问题的解,这样子问题多次求解的时候可以直接查表不用调用函数递归。
动态规划的经典模型
线性模型
线性模型的是动态规划中最常用的模型,上文讲到的钢条切割问题就是经典的线性模型,这里的线性指的是状态的排布是呈线性的。【例题1】是一个经典的面试题,我们将它作为线性模型的敲门砖。
【例题1】在一个夜黑风高的晚上,有n(n <= 50)个小朋友在桥的这边,现在他们需要过桥,但是由于桥很窄,每次只允许不大于两人通过,他们只有一个手电筒,所以每次过桥的两个人需要把手电筒带回来,i号小朋友过桥的时间为T[i],两个人过桥的总时间为二者中时间长者。问所有小朋友过桥的总时间最短是多少。

每次过桥的时候最多两个人,如果桥这边还有人,那么还得回来一个人(送手电筒),也就是说N个人过桥的次数为2N-3(倒推,当桥这边只剩两个人时只需要一次,三个人的情况为来回一次后加上两个人的情况…)。有一个人需要来回跑,将手电筒送回来(也许不是同一个人,realy?!)这个回来的时间是没办法省去的,并且回来的次数也是确定的,为N-2,如果是我,我会选择让跑的最快的人来干这件事情,但是我错了…如果总是跑得最快的人跑回来的话,那么他在每次别人过桥的时候一定得跟过去,于是就变成就是很简单的问题了,花费的总时间:
T = minPTime * (N-2) + (totalSum-minPTime)
来看一组数据 四个人过桥花费的时间分别为 1 2 5 10,按照上面的公式答案是19,但是实际答案应该是17。
具体步骤是这样的:
第一步:1和2过去,花费时间2,然后1回来(花费时间1);
第二歩:3和4过去,花费时间10,然后2回来(花费时间2);
第三部:1和2过去,花费时间2,总耗时17。
所以之前的贪心想法是不对的。我们先将所有人按花费时间递增进行排序,假设前i个人过河花费的最少时间为opt[i],那么考虑前i-1个人过河的情况,即河这边还有1个人,河那边有i-1个人,并且这时候手电筒肯定在对岸,所以opt[i] = opt[i-1] + a[1] + a[i] (让花费时间最少的人把手电筒送过来,然后和第i个人一起过河)如果河这边还有两个人,一个是第i号,另外一个无所谓,河那边有i-2个人,并且手电筒肯定在对岸,所以opt[i] = opt[i-2] + a[1] + a[i] + 2
a[2] (让花费时间最少的人把电筒送过来,然后第i个人和另外一个人一起过河,由于花费时间最少的人在这边,所以下一次送手电筒过来的一定是花费次少的,送过来后花费最少的和花费次少的一起过河,解决问题)
所以 opt[i] = min{opt[i-1] + a[1] + a[i] , opt[i-2] + a[1] + a[i] + 2*a[2] }
区间模型
区间模型的状态表示一般为d[i][j],表示区间[i, j]上的最优解,然后通过状态转移计算出[i+1, j]或者[i, j+1]上的最优解,逐步扩大区间的范围,最终求得[1, len]的最优解。
【例题2】给定一个长度为n(n <= 1000)的字符串A,求插入最少多少个字符使得它变成一个回文串。
典型的区间模型,回文串拥有很明显的子结构特征,即当字符串X是一个回文串时,在X两边各添加一个字符’a’后,aXa仍然是一个回文串,我们用d[i][j]来表示A[i…j]这个子串变成回文串所需要添加的最少的字符数,那么对于A[i] == A[j]的情况,很明显有 d[i][j] = d[i+1][j-1] (这里需要明确一点,当i+1 > j-1时也是有意义的,它代表的是空串,空串也是一个回文串,所以这种情况下d[i+1][j-1] = 0);当A[i] != A[j]时,我们将它变成更小的子问题求解,我们有两种决策:
1、在A[j]后面添加一个字符A[i];
2、在A[i]前面添加一个字符A[j];
根据两种决策列出状态转移方程为:
d[i][j] = min{ d[i+1][j], d[i][j-1] } + 1; (每次状态转移,区间长度增加1)
空间复杂度O(n2),时间复杂度O(n2), 下文会提到将空间复杂度降为O(n)的优化算法。
背包模型
背包问题是动态规划中一个最典型的问题之一。由于网上有非常详尽的背包讲解,这里只将常用部分抽出来。
【例题3】有N种物品(每种物品1件)和一个容量为V的背包。放入第 i 种物品耗费的空间是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大。f[i][v]表示前i种物品恰好放入一个容量为v的背包可以获得的最大价值。决策为第i个物品在前i-1个物品放置完毕后,是选择放还是不放,状态转移方程为:
f[i][v] = max{ f[i-1][v], f[i-1][v – Ci] +Wi }
时间复杂度O(VN),空间复杂度O(VN) (空间复杂度可利用滚动数组进行优化达到O(V) )。
动态规划题集整理
1、最长单调子序列
Constructing Roads In JG Kingdom★★☆☆☆
Stock Exchange ★★☆☆☆
2、最大M子段和
Max Sum ★☆☆☆☆
最长公共子串 ★★☆☆☆
3、线性模型
Skiing ★☆☆☆☆
总结
弄懂动态规划问题的基本原理和动态规划问题的几个常见的模型,对于解决大部分的问题已经足够了。希望能对大家有所帮助,转载请标明出处http://write.blog.csdn.net/mdeditor#!postId=75193592,创作实在不容易,这篇博客花了我将近一个星期的时间。
参考文献
1.算法导论

递归真是个奇妙的思维方式。自打我大二学习递归以来,对一些简单的递归问题,我总是惊叹于递归描述问题和编写代码的简洁。但是总感觉没能融会贯通地理解递归,有时尝试用大脑去深入“递归”,层次较深时便常产生进不去,出不来的感觉。这种状态也导致我很难灵活地运用递归解决问题。有一天,我看到一句英文:“To Iterate is Human, to Recurse, Divine.”中文译为:“人理解迭代,神理解递归。”然后,我心安理得地放弃了对递归的深入理解。直到看到王垠谈程序语言最精华的原理时提到了递归,并说递归比循环表达能力强很多,而且效率几乎一样。再次唤醒了我对递归的理解探索。

我首先在知乎上发现了下面两个例子,对比了递归和循环。

递归:你打开面前这扇门,看到屋里面还有一扇门(这门可能跟前面打开的门一样大小(静),也可能门小了些(动)),你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门,你继续打开,。。。, 若干次之后,你打开面前一扇门,发现只有一间屋子,没有门了。 你开始原路返回,每走回一间屋子,你数一次,走到入口的时候,你可以回答出你到底用这钥匙开了几扇门。

循环:你打开面前这扇门,看到屋里面还有一扇门,(这门可能跟前面打开的门一样大小(静),也可能门小了些(动)),你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门,(前面门如果一样,这门也是一样,第二扇门如果相比第一扇门变小了,这扇门也比第二扇门变小了(动静如一,要么没有变化,要么同样的变化)),你继续打开这扇门,。。。,一直这样走下去。 入口处的人始终等不到你回去告诉他答案。

该用户这么总结到:

递归就是有去(递去)有回(归来)。

具体来说,为什么可以”有去“?
这要求递归的问题需要是可以用同样的解题思路来回答除了规模大小不同其他完全一样的问题。

为什么可以”有回“?
这要求这些问题不断从大到小,从近及远的过程中,会有一个终点,一个临界点,一个baseline,一个你到了那个点就不用再往更小,更远的地方走下去的点,然后从那个点开始,原路返回到原点。

上面的解释几乎回答了我已久的疑问:为什么我老是有递归没有真的在解决问题的感觉?
因为递是描述问题,归是解决问题。而我的大脑容易被递占据,只往远方去了,连尽头都没走到,何谈回的来。

《漫谈递归:递归的思想》这篇文章将递归思想归纳为:

递归的基本思想是把规模大的问题转化为规模小的相似的子问题来解决。在函数实现时,因为解决大问题的方法和解决小问题的方法往往是同一个方法,所以就产生了函数调用它自身的情况。另外这个解决问题的函数必须有明显的结束条件,这样就不会产生无限递归的情况了。

需注意的是,规模大转化为规模小是核心思想,但递归并非是只做这步转化,而是把规模大的问题分解为规模小的子问题和可以在子问题解决的基础上剩余的可以自行解决的部分。而后者就是归的精髓所在,是在实际解决问题的过程。

我试图把我理解到递归思想用递归用程序表达出来,确定了三个要素:递 + 结束条件 + 归。

recursion(大规模)
{
if (end_condition)
{
end;
}
else
{ //先将问题全部描述展开,再由尽头“返回”依次解决每步中剩余部分的问题
recursion(小规模); //go;
solve; //back;
}
}

但是,我很容易发现这样描述遗漏了我经常会遇到的一种递归情况,比如递归遍历的二叉树的先序。我将这种情况用如下递归程序表达出来。

recursion(大规模)
{
if (end_condition)
{
end;
}
else
{ //在将问题转换为子问题描述的每一步,都解决该步中剩余部分的问题。
solve; //back;
recursion(小规模); //go;
}
}

总结到这里,我突然发现递归是为了最能表达这种思想,所以用“递归”这个词,其实递归可以是“有去有回”,也可以是“有去无回”。但其根本是“由大往小地去,由近及远地去”。“递”是必需,“归”并非必需,依赖于要解决的问题,有的需要去的路上解决,有的需要回来的路上解决。有递无归的递归其实就是我们很容易理解的一种分治思想。

其实理解递归可能没有“归”,只有去(分治)的情况后,我们应该想到递归也许可以既不需要在“去”的路上解决问题,也不需要在“归”的路上解决问题,只需在路的尽头解决问题,即在满足停止条件时解决问题。递归的分治思想不一定是要把问题规模递归到最小,还可以是将问题递归穷举其所有的情形,这时通常递归的表达力体现在将无法书写的嵌套循环(不确定数量的嵌套循环)通过递归表达出来。
将这种递归情形用递归程序描述如下:

recursion()
{
if (end_condition)
{
solve;
}
else
{ //在将问题转换为子问题描述的每一步,都解决该步中剩余部分的问题。
for () { recursion(); //go; }
}
}

例如,字符串的全排列就可以用这种递归简洁地表达出来,如下:

void permute(const string &prefix, const string &str)
{
if(str.length() == 0)
cout << prefix << endl;
else
{
for(int i = 0; i < str.length(); i++)
permute(prefix+str[i], str.substr(0,i)+str.substr(i+1,str.length()));
}
}

由这个例子,可以发现这种递归对递归函数参数出现了设计要求,即便递归到尽头,组合的字符串规模(长度)也没有变小,规模变小的是递归函数的一个参数。可见,这种变化似乎一下将递归的灵活性大大地扩展了,所谓的大规模转换为小规模需要有一个更为广义的理解了。

对递归的理解就暂时到这里了,可以看出文章中提到关于“打开一扇门”的递归例子来解释递归并不准确,例子只描述了递归的一种情况。而“递归就是有去(递去)有回(归来)”的论断同样不够准确。要为只读了文章前半部分的读者惋惜了。我也给出自己对递归思想的总结吧:

递归的基本思想是广义地把规模大的问题转化为规模小的相似的子问题或者相似的子问题集合来解决。广义针对规模的,规模的缩小具体可以是指递归函数的参数,也可以是其参数之一。相似是指解决大问题的方法和解决小问题的方法往往是同一个方法,还可以是指解决子问题集的各子问题的方法是同一个方法。解决大问题的方法可以是由解决次规模问题的方法和解决剩余部分的方法组成,也可以是由一系列解决次规模问题的方法组成。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值