第二单元 基本算法思想

1.贪心算法

 

​伪代码:

从问题的某一初始解出发

  while(能朝给定总目标前进一步)

          do

                选择当前最优解作为可行解的一个解元素;由所有解元素组合成问题的一个可行解。

 

示例:

 小明手中有 1,5,10,50,100 五种面额的纸币,每种纸币对应张数分别为 5,2,2,3,5 张。若小明需要支付 456 元,则需要多少张纸币?  

(1)建立数学模型
设小明每次选择纸币面额为 Xi ,需要的纸币张数为 n 张,剩余待支付金额为 V ,则有:
X1 + X2 + … + Xn = 456.
(2)问题拆分为子问题
小明选择纸币进行支付的过程,可以划分为n个子问题:即每个子问题对应为:
在未超过456的前提下,在剩余的纸币中选择一张纸币。
(3)制定贪心策略,求解子问题

制定的贪心策略为:在允许的条件下选择面额最大的纸币。

则整个求解过程如下:

  • 选取面值为 100 的纸币,则 X1 = 100, V = 456 – 100 = 356;
  • 继续选取面值为 100 的纸币,则 X2 = 100, V = 356 – 100 = 256;
  • 继续选取面值为 100 的纸币,则 X3 = 100, V = 256 – 100 = 156;
  • 继续选取面值为 100 的纸币,则 X4 = 100, V = 156 – 100 = 56;
  • 选取面值为 50 的纸币,则 X5 = 50, V = 56 – 50 = 6;
  • 选取面值为 5 的纸币,则 X6 = 5, V = 6 – 5 = 1;
  • 选取面值为 1 的纸币,则 X7 = 1, V = 1 – 1 = 0;求解结束

(4)将所有解元素合并为原问题的解

小明需要支付的纸币张数为 7 张,其中面值 100 元的 4 张,50 元 1 张,5 元 1 张,1 元 1 张。

代码:

const int N = 5; 
int Count[N] = {5,2,2,3,5};//每一张纸币的数量 int Value[N] = {1,5,10,50,100};
int solve(int money) {
    int num = 0;
    for(int i = N-1;i>=0;i--) {
        int c = min(money/Value[i],Count[i]);//每一个所需要的张数 
        money = money-c*Value[i];
        num += c;//总张数 
    }
    if(money>0) num=-1;
    return num;
}

 

贪心思想: 贪心的意思在于在作出选择时,每次都要选择对自身最为有利的结果,保证自身利益的最大化。​

 

贪心算法的应用: 

在求最小生成树的 Prim 算法中,挑选的顶点是候选边中权值最小的边的一个端点。

 在 Kruskal 算法中,每次选取权值最小的边加入集合。  

 在构造霍夫曼树的过程中也是每次选择最小权值的节点构造二叉树。  

 

贪心算法的弊端:

 贪心算法在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,所做出的仅是在某种意义上的局部最优解。贪心算法不是对所有问题都能得到整体最优解,但对范围相当广泛的许多问题他能产生整体最优解或者是整体最优解的近似解。  

 

2.0 动态规划

 

  • 动态规划问题实质上是递归问题,当递归问题具有了重叠子问题和最优子结构这两个条件后就形成了动态规划问题。
    • 动态规划问题有两种解法,自顶向下的记忆化搜索算法和自底向上的动态规划解法。

 

将「动态规划」的概念关键点抽离出来描述就是这样的:

  • 1.动态规划法试图只解决每个子问题一次

  • 2.一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。

 

「动态规划」中包含三个重要的概念:

  • 【最优子结构】

  • 【边界】

  • 【状态转移公式】

 

在「 爬台阶问题 」中

f(10) = f(9) + f(8) 是【最优子结构】  
f(1) 与 f(2) 是【边界】  
f(n) = f(n-1) + f(n-2) 【状态转移公式】

「 爬台阶问题 」 只是动态规划中相对简单的问题,因为它只有一个变化维度,如果涉及多个维度的话,那么问题就变得复杂多了。

 难点就在于找出 「动态规划」中的这三个概念。  

 

 

用一句话解释动态规划就是 “记住你之前做过的事”,如果更准确些,其实是 “记住你之前得到的答案”。

 

(但是这个方案是有条件的,条件如下:

  • 1.之前的问题和当前的问题有着关联性,换句话说,之前问题得到的答案可以帮助解决当前问题
  • 2.需要记录之前问题的答案

当然在这个例子中,可以看到的是,上面这两个条件均满足,大可去到之前配置过的文件中,将配置拷贝过来,然后做些细微的调整即可解决当前问题,节约了大量的时间。)

 

 对于一个动态规划问题,我们只需要从两个方面考虑,那就是 找出问题之间的联系,以及 记录答案,这里的难点其实是找出问题之间的联系,记录答案只是顺带的事情,利用一些简单的数据结构就可以做到。

 

一般解决动态规划问题,分为四个步骤,分别是

  • 1.问题拆解,找到问题之间的具体联系
  • 2.状态定义
  • 3.递推方程推导
  • 4.实现

 

问题拆解:

 这里还是拿 Quora 上面的例子来讲解,“1+1+1+1+1+1+1+1” 得出答案是 8,那么如何快速计算 “1+ 1+1+1+1+1+1+1+1”,我们首先可以对这个大的问题进行拆解,这里我说的大问题是 9 个 1 相加,这个问题可以拆解成 1 + “8 个 1 相加的答案”,8 个 1 相加继续拆,可以拆解成 1 + “7 个 1 相加的答案”,… 1 + “0 个 1 相加的答案”,到这里,第一个步骤 已经完成。

 

状态定义 

其实是需要思考在解决一个问题的时候我们做了什么事情,然后得出了什么样的答案,对于这个问题,当前问题的答案就是当前的状态,基于上面的问题拆解,你可以发现两个相邻的问题的联系其实是 后一个问题的答案 = 前一个问题的答案 + 1,这里,状态的每次变化就是 +1。

 

 定义好了状态,递推方程就变得非常简单,就是 dp[i] = dp[i - 1] + 1,这里的 dp[i] 记录的是当前问题的答案,也就是当前的状态,dp[i - 1] 记录的是之前相邻的问题的答案,也就是之前的状态,它们之间通过 +1 来实现状态的变更。

 

   最后一步就是实现了,有了状态表示和递推方程,实现这一步上需要重点考虑的其实是初始化,就是用什么样的数据结构,根据问题的要求需要做那些初始值的设定。

 

代码:

public int dpExample(int n) {
    int[] dp = new int[n + 1];  // 多开一位用来存放 0 个 1 相加的结果

    dp[0] = 0;      // 0 个 1 相加等于 0

    for (int i = 1; i <= n; ++i) {
        dp[i] = dp[i - 1] + 1;
    }

    return dp[n];
}

 

 

爬楼梯

但凡涉及到动态规划的题目都离不开一道例题:爬楼梯(LeetCode 第 70 号问题)。

题目描述:

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

输入:2
输出:2
解释: 有两种方法可以爬到楼顶。

1. 1 阶 + 1 阶
2. 2 阶

示例 2:

输入:3
输出:3
解释: 有三种方法可以爬到楼顶。

1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
题目解析

爬楼梯,可以爬一步也可以爬两步,问有多少种不同的方式到达终点,我们按照上面提到的四个步骤进行分析:

  • 问题拆解:

    我们到达第 n 个楼梯可以从第 n – 1 个楼梯和第 n – 2 个楼梯到达,因此第 n 个问题可以拆解成第 n – 1 个问题和第 n – 2 个问题,第 n – 1 个问题和第 n – 2 个问题又可以继续往下拆,直到第 0 个问题,也就是第 0 个楼梯 (起点)

  • 状态定义:

    “问题拆解” 中已经提到了,第 n 个楼梯会和第 n – 1 和第 n – 2 个楼梯有关联,那么具体的联系是什么呢?你可以这样思考,第 n – 1 个问题里面的答案其实是从起点到达第 n – 1 个楼梯的路径总数,n – 2 同理,从第 n – 1 个楼梯可以到达第 n 个楼梯,从第 n – 2 也可以,并且路径没有重复,因此我们可以把第 i 个状态定义为 “从起点到达第 i 个楼梯的路径总数”,状态之间的联系其实是相加的关系。

  • 递推方程:

    “状态定义” 中我们已经定义好了状态,也知道第 i 个状态可以由第 i – 1 个状态和第 i – 2 个状态通过相加得到,因此递推方程就出来了 dp[i] = dp[i - 1] + dp[i - 2]

  • 实现:

    你其实可以从递推方程看到,我们需要有一个初始值来方便我们计算,起始位置不需要移动 dp[0] = 0,第 1 层楼梯只能从起始位置到达,因此 dp[1] = 1,第 2 层楼梯可以从起始位置和第 1 层楼梯到达,因此 dp[2] = 2,有了这些初始值,后面就可以通过这几个初始值进行递推得到。

参考代码
public int climbStairs(int n) {
    if (n == 1) {
        return 1;
    }

    int[] dp = new int[n + 1];  // 多开一位,考虑起始位置

    dp[0] = 0; dp[1] = 1; dp[2] = 2;
    for (int i = 3; i <= n; ++i) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }

    return dp[n];
}

​三角形最小路径和

给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。

例如,给定三角形:

[
     [2],
    [3,4],
   [6,5,7],
  [4,1,8,3]
]

自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。

题目解析:

  • 问题拆解:

    这里的总问题是求出最小的路径和,路径是这里的分析重点,路径是由一个个元素组成的,和之前爬楼梯那道题目类似,[i][j] 位置的元素,经过这个元素的路径肯定也会经过 [i - 1][j] 或者 [i - 1][j - 1],因此经过一个元素的路径和可以通过这个元素上面的一个或者两个元素的路径和得到。

  • 状态定义:

    状态的定义一般会和问题需要求解的答案联系在一起,这里其实有两种方式,一种是考虑路径从上到下,另外一种是考虑路径从下到上,因为元素的值是不变的,所以路径的方向不同也不会影响最后求得的路径和,如果是从上到下,你会发现,在考虑下面元素的时候,起始元素的路径只会从[i - 1][j] 获得,每行当中的最后一个元素的路径只会从 [i - 1][j - 1] 获得,中间二者都可,这样不太好实现,因此这里考虑从下到上的方式,状态的定义就变成了 “最后一行元素到当前元素的最小路径和”,对于 [0][0] 这个元素来说,最后状态表示的就是我们的最终答案。

  • 递推方程:

    “状态定义” 中我们已经定义好了状态,递推方程就出来了

    dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]
    
  • 实现:

    这里初始化时,我们需要将最后一行的元素填入状态数组中,然后就是按照前面分析的策略,从下到上计算即可

  • 参考代码

  • public int minimumTotal(List<List<Integer>> triangle) {
        int n = triangle.size();
    
        int[][] dp = new int[n][n];
    
        List<Integer> lastRow = triangle.get(n - 1);
    
        for (int i = 0; i < n; ++i) {
            dp[n - 1][i] = lastRow.get(i);
        }
    
        for (int i = n - 2; i >= 0; --i) {
            List<Integer> row = triangle.get(i);
            for (int j = 0; j < i + 1; ++j) {
                dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + row.get(j);
            }
        }
    
        return dp[0][0];
    }
    

    矩阵类动态规划:

  • 这类动态规划通常在一个矩阵中进行,只需要考虑当前位置的信息,分析并定义状态的时候,只需要分析当前位置和其相邻位置的关系,这样做就可以达到拆解问题的目的。  

  •  序列类动态规划:

  •  通常问题的输入参数会涉及数组或是字符串。

  • 子数组必须是数组中的一个连续的区间,而子序列并没有这样一个要求。  子数组的问题和我们前面提到的矩阵类动态规划的分析思路很类似,只需要考虑当前位置,以及当前位置和相邻位置的关系。

  •  相比矩阵类动态规划,序列类动态规划最大的不同在于,对于第 i 个位置的状态分析,它不仅仅需要考虑当前位置的状态,还需要考虑前面 i – 1 个位置的状态。  

  •  对于这类问题的问题拆解,有时并不是那么好发现问题与子问题之间的联系,但是通常来说思考的方向其实在于 寻找当前状态和之前所有状态的关系。  

  • 最长上升子序列

题目描述

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

输入: [10,9,2,5,3,7,101,18]输出: 4 解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。

说明:

  • 可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
  • 你算法的时间复杂度应该为 O(n2) 。

进阶: 你能将算法的时间复杂度降低到 O(n log n) 吗?

题目解析

给定一个数组,求最长递增子序列。因为是子序列,这样对于每个位置的元素其实都存在两种可能,就是选和不选,如果我们用暴力的解法,枚举出所有的子序列,然后判断他们是不是递增的,选取最大的递增序列,这样做的话,时间复杂度是 O(2^n),显然不高效。

那这里我们就需要思考用动态规划进行优化,我们按之前的四个步骤来具体分析一下:

  • 问题拆解

    我们要求解的问题是 “数组中最长递增子序列”,一个子序列虽然不是连续的区间,但是它依然有起点和终点,比如:

    [10,9,2,5,3,7,101,18]
    
    子序列 [2,3,7,18] 的起始位置是 2,终止位置是 18
    子序列 [5,7,101] 的起始位置是 5,终止位置是 101
    

    如果我们确定终点位置,然后去 看前面 i – 1 个位置中,哪一个位置可以和当前位置拼接在一起,这样就可以把第 i 个问题拆解成思考之前 i – 1 个问题,注意这里我们并不是不考虑起始位置,在遍历的过程中我们其实已经考虑过了。

  • 状态定义

    问题拆解中我们提到 “第 i 个问题和前 i – 1 个问题有关”,也就是说 “如果我们要求解第 i 个问题的解,那么我们必须考虑前 i – 1 个问题的解”,我们定义 dp[i] 表示以位置 i 结尾的子序列的最大长度,也就是说 dp[i] 里面记录的答案保证了该答案表示的子序列以位置 i 结尾。

  • 递推方程

    对于 i 这个位置,我们需要考虑前 i – 1 个位置,看看哪些位置可以拼在 i 位置之前,如果有多个位置可以拼在 i 之前,那么必须选最长的那个,这样一分析,递推方程就有了:

    dp[i] = Math.max(dp[j],...,dp[k]) + 1, 
    其中 inputArray[j] < inputArray[i], inputArray[k] < inputArray[i]
    
  • 实现

    在实现这里,我们需要考虑状态数组的初始化,因为对于每个位置,它本身其实就是一个序列,因此所有位置的状态都可以初始化为 1。

最后提一下,对于这道题来说,这种方法其实不是最优的,但是在这里的话就不展开讲了,理解序列类动态规划的解题思路是关键。

参考代码

//@五分钟学算法//www.cxyxiaowu.compublic int lengthOfLIS(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }

    // dp[i] -> the longest length sequence from 0 - i, and must include nums[i]
    int[] dp = new int[nums.length];

    Arrays.fill(dp, 1);

    int max = 0;

    for (int i = 0; i < nums.length; ++i) {
        for (int j = 0; j < i; ++j) {
            if (nums[i] > nums[j]) {
                dp[i] = Math.max(dp[j] + 1, dp[i]);
            }
        }

        max = Math.max(max, dp[i]);
    }

    return max;
}

2.1 动态规划(进阶版)

1.概念:动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。

      阶段:对于一个完整的问题过程,适当的切分为若干个相互联系的子问题,每次在求解一个子问题,则对应一个阶段,整个问题的求解转化为按照阶段次序去求解。

  状态:状态表示每个阶段开始时所处的客观条件,即在求解子问题时的已知条件。状态描述了研究的问题过程中的状况。

  决策:决策表示当求解过程处于某一阶段的某一状态时,可以根据当前条件作出不同的选择,从而确定下一个阶段的状态,这种选择称为决策。

  策略:由所有阶段的决策组成的决策序列称为全过程策略,简称策略。

  最优策略:在所有的策略中,找到代价最小,性能最优的策略,此策略称为最优策略。

  状态转移方程:状态转移方程是确定两个相邻阶段状态的演变过程,描述了状态之间是如何演变的。

2.使用场景

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

  (1)最优化:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。子问题的局部最优将导致整个问题的全局最优。换句话说,就是问题的一个最优解中一定包含子问题的一个最优解。

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

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

3.算法流程

  (1)划分阶段:按照问题的时间或者空间特征将问题划分为若干个阶段。
  (2)确定状态以及状态变量:将问题的不同阶段时期的不同状态描述出来。
  (3)确定决策并写出状态转移方程:根据相邻两个阶段的各个状态之间的关系确定决策。
  (4)寻找边界条件:一般而言,状态转移方程是递推式,必须有一个递推的边界条件。
  (5)设计程序,解决问题

实战例子:

 买卖股票的最佳时机

题目描述

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。

注意你不能在买入股票前卖出股票。

示例 1:

输入: [7,1,5,3,6,4]
输出: 5
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
     注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。

示例 2:

输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

题目解析

我们按照动态规划的思想来思考这道问题。

状态

有 买入(buy) 和 卖出(sell) 这两种状态。

转移方程

对于买来说,买之后可以卖出(进入卖状态),也可以不再进行股票交易(保持买状态)。

对于卖来说,卖出股票后不在进行股票交易(还在卖状态)。

只有在手上的钱才算钱,手上的钱购买当天的股票后相当于亏损。也就是说当天买的话意味着损失-prices[i],当天卖的话意味着增加prices[i],当天卖出总的收益就是 buy+prices[i] 。

所以我们只要考虑当天买和之前买哪个收益更高,当天卖和之前卖哪个收益更高。

  • buy = max(buy, -price[i])  (注意:根据定义 buy 是负数)

  • sell = max(sell,  prices[i] + buy)

边界

第一天 buy = -prices[0]sell = 0,最后返回 sell 即可。

代码实现

//程序员小吴
class Solution {
    public int maxProfit(int[] prices) {
        if(prices.length <= 1)
            return 0;
        int buy = -prices[0], sell = 0;
        for(int i = 1; i < prices.length; i++) {
            buy = Math.max(buy, -prices[i]);
            sell = Math.max(sell, prices[i] + buy);

        }
        return sell;
    }
}

买卖股票的最佳时机 II

题目来源于 LeetCode 上第 122 号问题:买卖股票的最佳时机 II。题目难度为 Easy,目前通过率为 53.0% 。

题目描述

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
     随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。

示例 2:

输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
     注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
     因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例 3:

输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

题目解析

状态

有 买入(buy) 和 卖出(sell) 这两种状态。

转移方程

对比上题,这里可以有无限次的买入和卖出,也就是说 买入 状态之前可拥有 卖出 状态,所以买入的转移方程需要变化。

  • buy = max(buy, sell - price[i])

  • sell = max(sell,   buy + prices[i] )

边界

第一天 buy = -prices[0]sell = 0,最后返回 sell 即可。

代码实现

//程序员小吴
class Solution {
    public int maxProfit(int[] prices) {
        if(prices.length <= 1)
            return 0;
        int buy = -prices[0], sell = 0;
        for(int i = 1; i < prices.length; i++) {
            sell = Math.max(sell, prices[i] + buy);
            buy = Math.max( buy,sell - prices[i]);
        }
        return sell;
    }
}

 

3.分治算法

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

 

动态规划其实和分治策略是类似的,也是将一个原问题分解为若干个规模较小的子问题,递归的求解这些子问题,然后合并子问题的解得到原问题的解。  
区别在于这些子问题会有重叠,一个子问题在求解后,可能会再次求解,于是我们想到将这些子问题的解存储起来,当下次再次求解这个子问题时,直接拿过来就是。  
其实就是说,动态规划所解决的问题是分治策略所解决问题的一个子集,只是这个子集更适合用动态规划来解决从而得到更小的运行时间。  
即用动态规划能解决的问题分治策略肯定能解决,只是运行时间长了。因此,分治策略一般用来解决子问题相互对立的问题,称为标准分治,而动态规划用来解决子问题重叠的问题。  

 

 使用场景:

  (1)该问题的规模缩小到一定的程度就可以容易地解决。
  (2)该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。
  (3)利用该问题分解出的子问题的解可以合并为该问题的解。
  (4)该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。

 基本步骤:
  (1)分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题。
  (2)求解:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题。
  (3)合并:将各个子问题的解合并为原问题的解。

  二分查找是典型的分治算法的应用。需要注意的是,二分查找的前提是查找的数列是有序的。

算法流程:
  (1)选择一个标志 i 将集合分为二个子集合。
  (2)判断标志 L(i) 是否能与要查找的值 des 相等,相等则直接返回。
  (3)否则判断 L(i) 与 des 的大小。
  (4)基于判断的结果决定下步是向左查找还是向右查找。
  (5)递归继续上面的步骤。

  通过二分查找的流程可以看出,二分查找是将原有序数列划分为左右两个子序列,然后在对两个子序列中的其中一个在进行划分,直至查找成功。

代码实现:

#include<string.h>
#include<stdio.h>
int k;
int binarysearch(int a[],int x,int low,int high)//a表示需要二分的有序数组(升序),x表示需要查找的数字,low,high表示高低位
{
    if(low>high)
    {
        return -1;//没有找到
    }
    int mid=(low+high)/2;
    if(x==a[mid])//找到x
    {
        k=mid;
        return x;
    }
    else if(x>a[mid]) //x在后半部分
    {
        binarysearch(a,x,mid+1,high);//在后半部分继续二分查找
    }
    else//x在前半部分
    {
        binarysearch(a,x,low,mid-1);
    }
}

int main()
{
    int a[10]={1,2,3,4,5,6,7,8,9,10};
    printf("请输入需要查找的正数字:\n");
    int x;
    scanf("%d",&x);
    int r=binarysearch(a,x,0,9);
    if(r==-1)
    {
        printf("没有查到\n");
    }
    else
    {
        printf("查到了,在数列的第%d个位置上\n",k+1);
    }
    return 0;
}

 归并排序:归并(Merge)排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。即先划分为两个部分,最后进行合并。  

伪代码:

算法 MergeSort(A, p, r)
输入:数组A[p...r]
输出:有序数组A
if(p < r)
    then q <- (p+r)/2//折半划分
        MergeSort(A, p ,q)//子问题1
        MergeSort(A, p ,q)//子问题2
        Merge(A, p ,q, r)//合并求解

代码实现:

public class MergeSort {
    //两路归并算法,两个排好序的子序列合并为一个子序列
    public void merge(int []a,int left,int mid,int right){
        int []tmp=new int[a.length];//辅助数组
        int p1=left,p2=mid+1,k=left;//p1、p2是检测指针,k是存放指针
        while(p1<=mid && p2<=right){
            if(a[p1]<=a[p2])
                tmp[k++]=a[p1++];
            else
                tmp[k++]=a[p2++];
        }

        while(p1<=mid) tmp[k++]=a[p1++];//如果第一个序列未检测完,直接将后面所有元素加到合并的序列中
        while(p2<=right) tmp[k++]=a[p2++];//同上

        //复制回原素组
        for (int i = left; i <=right; i++) 
            a[i]=tmp[i];
    }

    public void mergeSort(int [] a,int start,int end){
        if(start<end){//当子序列中只有一个元素时结束递归
            int mid=(start+end)/2;//划分子序列
            mergeSort(a, start, mid);//对左侧子序列进行递归排序
            mergeSort(a, mid+1, end);//对右侧子序列进行递归排序
            merge(a, start, mid, end);//合并
        }
    }
}

 快速排序

  快速排序的基本思想:当前待排序的无序区为 A[low..high] ,利用分治法可将快速排序的基本思想描述为:
(1)分解:
  在A[low..high]中任选一个记录作为基准(pivot),以此基准将当前无序区划分为左、右两个较小的子区间R[low..pivotpos-1) 和 R[pivotpos+1..high] ,并使左边子区间中所有记录的关键字均小于等于基准记录(不妨记为pivot)的关键字 pivot.key,右边的子区间中所有记录的关键字均大于等于pivot.key,而基准记录pivot则位于正确的位置( pivotpos )上,它无须参加后续的排序。

(2)求解:
  通过递归调用快速排序对左、右子区间R[low..pivotpos-1]和R[pivotpos+1..high]快速排序。
(3)合并:
  因为当"求解"步骤中的两个递归调用结束时,其左、右两个子区间已有序。对快速排序而言,"组合"步骤无须做什么,可看作是空操作。

代码实现:

#include <iostream>
using namespace std;
void QuickSort(int arr[], int low, int high){
    if (high <= low) return;
    int i = low;
    int j = high + 1;
    int key = arr[low];
    while (true)
    {
        /*从左向右找比key大的值*/
        while (arr[++i] < key)
        {
            if (i == high){
                break;
            }
        }
        /*从右向左找比key小的值*/
        while (arr[--j] > key)
        {
            if (j == low){
                break;
            }
        }
        if (i >= j) break;
        /*交换i,j对应的值*/
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }
    /*中枢值与j对应值交换*/
    int temp = arr[low];
    arr[low] = arr[j];
    arr[j] = temp;
    QuickSort(arr, low, j - 1);
    QuickSort(arr, j + 1, high);
}

 汉诺塔

汉诺塔(Hanoi Tower)问题也是一个经典的递归问题,该问题描述如下:

汉诺塔问题:古代有一个梵塔,塔内有三个座A、B、C,A座上有64个盘子,盘子大小不等,大的在下,小的在上。有一个和尚想把这个盘子从A座移到B座,但每次只能允许移动一个盘子,并且在移动过程中,3个座上的盘子始终保持大盘在下,小盘在上。

两个盘子三个盘子

  • ①  如果只有 1 个盘子,则不需要利用 B 塔,直接将盘子从 A 移动到 C 。

  • ② 如果有 2 个盘子,可以先将盘子 2 上的盘子 1 移动到 B ;将盘子 2 移动到 C ;将盘子 1 移动到 C 。这说明了:可以借助 B 将 2 个盘子从 A 移动到 C ,当然,也可以借助 C 将 2 个盘子从 A 移动到 B 。

  • ③ 如果有 3 个盘子,那么根据 2 个盘子的结论,可以借助 C 将盘子 3 上的两个盘子从 A 移动到 B ;将盘子 3 从 A 移动到 C ,A 变成空座;借助 A 座,将 B 上的两个盘子移动到 C 。

  • ④ 以此类推,上述的思路可以一直扩展到 n 个盘子的情况,将将较小的 n-1个盘子看做一个整体,也就是我们要求的子问题,以借助 B 塔为例,可以借助空塔 B 将盘子A上面的 n-1 个盘子从 A 移动到 B ;将A 最大的盘子移动到 C , A 变成空塔;借助空塔 A ,将 B 塔上的 n-2 个盘子移动到 A,将 C 最大的盘子移动到 C, B 变成空塔。。。

代码实现:

    public static void hanoi(int n, String sourceTower, String tempTower, String targetTower) {
        if (n == 1) {
            //如果只有一个盘子1,那么直接将其从sourceTower移动到targetTower
            move(n, sourceTower, targetTower);
        } else {
            //将(盘子n-1~盘子1)由sourceTower经过targetTower移动到tempTower
            hanoi(n - 1, sourceTower, targetTower, tempTower);
            //移动盘子n由sourceTower移动到targetTower
            move(n, sourceTower, targetTower);
            //把之前移动到tempTower的(盘子n-1~盘子1),由tempTower经过sourceTower移动到targetTower
            hanoi(n - 1, tempTower, sourceTower, targetTower);
        }
    }

    //盘子n的从sourceTower->targetTower的移动
    private static void move(int n, String sourceTower, String targetTower) {
        System.out.println("第" + n + "号盘子 move:" + sourceTower + "--->" + targetTower);
    }

 

4. 递归 与 动态规划

递归的定义:

递归算法是一种直接或者间接调用自身函数或者方法的算法。  

递归算法的实质是把问题分解成规模缩小的同类问题的子问题,然后递归调用方法来表示问题的解。它有如下特点:

  • 1. 一个问题的解可以分解为几个子问题的解

  • 2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样

  • 3. 存在递归终止条件,即必须有一个明确的递归结束条件,称之为递归出口

 

5. 正则表达式匹配

 题目描述

给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和'*' 的正则表达式匹配。

'.' 匹配任意单个字符
'*' 匹配零个或多个前面的那一个元素

所谓匹配,是要涵盖 整个 字符串 s 的,而不是部分字符串。

说明:

  • s 可能为空,且只包含从a-z 的小写字母。

  • p 可能为空,且只包含从a-z 的小写字母,以及字符 .和 *

示例 1:

输入:
s = "aa"
p = "a"
输出: false
解释: "a" 无法匹配 "aa" 整个字符串。

示例 2:

输入:
s = "aa"
p = "a*"
输出: true
解释: 因为 '*' 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 'a'。因此,字符串 "aa" 可被视为 'a' 重复了一次。

示例 3:

输入:
s = "ab"
p = ".*"
输出: true
解释: ".*" 表示可匹配零个或多个('*')任意字符('.')。

示例 4:

输入:
s = "aab"
p = "c*a*b"
输出: true
解释: 因为 '*' 表示零个或多个,这里 'c' 为 0 个, 'a' 被重复一次。因此可以匹配字符串 "aab"。

示例 5:

输入:
s = "mississippi"
p = "mis*is*p*."
输出: false

题目解析

这道题其实是要实现 Regular Expression 里面的两个符号,一个是 '.',另一个是 '*', 前者表示可以 match 任意一个字符,后者表示其前面的字符可以重复零次或者多次。

题目的难点其实是在于 * 上面,如果没有这个 *,题目会变得非常简单,这里说一下题目的两个隐含条件:

  • 一个就是 * 不会出现在字符串的开头

  • 另外一个是 * 前面不能是 *,比如 "a * * b" 就不行

当然你也可以把这两个隐含条件当作一个来看,不管如何,我们的代码实现必须建立在这个基础之上,否则,case 考虑多了,题目将无从下手。

解法一:递归暴力求解

递归方式的暴力深度优先搜索求解方法往往是搜索问题的万金油,这里你只需要简单的考虑两件事情,一是这个问题是否可以划分为子问题,二是每个子问题有几种状态,就是在当前考虑的问题下,一共有多少种可能性。

知道了这两点后,对于每个子问题的每一个状态递归求解就行。

上面说的可能有点抽象,结合这个题目来做例子,这里的问题是,输入一个字符串 s,以及其匹配字符串 p,要求解这两个字符串是否匹配。

我们首先考虑这个字符串比较的问题能不能划分为一个个的子问题,你发现字符串是可以划分成为一个个字符的,这样字符串比较的问题就会变成字符的比较问题,这样一来,我们就可以把问题看成,决定 s[i,…n] 是否能够匹配 p[j,…m] 的条件是子问题 s[i+1,…n] 能不能够匹配 p[j+1,…m],另外还要看 s[i] 和 p[j] 是否匹配,但是这里的当前要解决的问题是 s[i] 和 p[j] 是否匹配,只有这一点成立,我们才有继续递归去看 s[i+1,…n] 是匹配 p[j+1,…m],注意这里我说 s[i] p[j], 并不表示说当前就只用考虑这两个字符之间匹不匹配,它只是用来表示当前问题,这个当前问题也许只需要比较一个字符,也许要比较多个,这就引申出了前面提到的第二点,我们还需要考虑当前问题中的状态。

对于字符串 s 来说,没有特殊字符,当前问题中字符只会是字母,但是对于 p 来说,我们需要考虑两个特殊符号,还有字母,这里列举所有的可能,如果说当前的子问题是 s[i,…n] 和 p[j…m]:

  • s[i] == p[j],子问题成立与否取决于子问题 s[i+1,…n] 和 p[j+1,…m]

  • p[j] == '.',子问题成立与否取决于子问题 s[i+1,…n] 和 p[j+1,…m]

  • p[j+1] == '*',s[i] != p[j],子问题成立与否取决于子问题 s[i,…n] 和 p[j+2,…m]

  • p[j+1] == '*',s[i] == p[j],子问题成立与否取决于子问题 s[i+1,…n] 和 p[j,…m]

这里我解释下第三种情况,之前在题目描述里说过,p 的起始字符不可能是 *,也就是说 * 的前面必须有字母,根据定义,这里我们可以把 * 的前面的元素个数算作是零个,这样我们就只用看,s[i,…n] 和 p[j+2,…n] 是否匹配,如果算作一个或者多个,那么我们就可以看 s[i+1,…n] 和 p[j,…m] 是否成立,当然这个的前提是 p[j] == s[i] 或者 p[j] == '.', 我们可以结合代码来看看

class Solution {
    public boolean isMatch(String s, String p) {
    if (s.equals(p)) {
        return true;
    }

    boolean isFirstMatch = false;
    if (!s.isEmpty() && !p.isEmpty() && (s.charAt(0) == p.charAt(0) || p.charAt(0) == '.')) {
        isFirstMatch = true;
    }

    if (p.length() >= 2 && p.charAt(1) == '*') {
        // 看 s[i,...n] 和 p[j+2,...m] 或者是 s[i+1,...n] 和 p[j,...m]
        return isMatch(s, p.substring(2))
                 || (isFirstMatch && isMatch(s.substring(1), p));
    }

    // 看 s[i+1,...n] 和 p[j+1,...m]
    return isFirstMatch && isMatch(s.substring(1), p.substring(1));
   }
}

上面的实现之所以被称为暴力求解是因为子问题的答案没有被记录,也就是说如果当前要用到之前的子问题的答案,我们还得去计算之前计算过的子问题。

 解法二:记忆化搜索

上面的暴力解法是因为没有记录答案,记忆化搜索是在 “傻搜” 的基础之上添加 “记事本”。这里我把递归的方向给改变了,当然这不是必要的,主要想说明,对于递归来说,从后往前考虑和从前往后考虑都是可行的。

我们假设当前问题是考虑 s 的第 i 个字母,p 的第 j 个字母,所以这时的子问题是 s[0…i] 和 p[0…j] 是否匹配:

  • p[j] 是字母,并且 s[i] == p[j],当前子问题成立与否取决于子问题 s[0…i-1] 和 p[0…j-1] 是否成立

  • p[j] 是 '.',当前子问题成立与否取决于子问题 s[0…i-1] 和 p[0…j-1] 是否成立

  • p[j] 是字母,并且 s[i] != p[j],当前子问题不成立

  • p[j] 是 '*',s[i] == p[j - 1],或者 p[j - 1] == '.', 当前子问题成立与否取决于子问题 s[0…i-1] 和 p[0…j] 是否成立

  • p[j] 是 '*',s[i] != p[j - 1],当前子问题正确与否取决于子问题 s[0…i] 是否匹配 p[0,…j-2]

不管是从前往后,还是从后往前,你可以看到,考虑的点都是一样的,只是这里我们多加了一个 “记事本”

public boolean isMatch(String s, String p) {
    if (s.equals(p)) {
        return true;
    }

    boolean[] memo = new boolean[s.length() + 1];

    return helper(s.toCharArray(), p.toCharArray(), 
                  s.length() - 1, p.length() - 1, memo);
}

private boolean helper(char[] s, char[] p, int i, int j, boolean[] memo) {
    if (memo[i + 1]) {
        return true;
    }

    if (i == -1 && j == -1) {
        memo[i + 1] = true;
        return true;
    }

    boolean isFirstMatching = false;

    if (i >= 0 && j >= 0 && (s[i] == p[j] || p[j] == '.' 
          || (p[j] == '*' && (p[j - 1] == s[i] || p[j - 1] == '.')))) {
        isFirstMatching = true;
    }

    if (j >= 1 && p[j] == '*') {
        // 看 s[0,...i] 和 p[0,...j-2] 
        boolean zero = helper(s, p, i, j - 2, memo);
        // 看 s[0,...i-1] 和 p[0,...j]
        boolean match = isFirstMatching && helper(s, p, i - 1, j, memo);

        if (zero || match) {
            memo[i + 1] = true;
        }

        return memo[i + 1];
    }

    // 看 s[0,...i-1] 和 p[0,...j-1]
    if (isFirstMatching && helper(s, p, i - 1, j - 1, memo)) {
        memo[i + 1] = true;
    }

    return memo[i + 1];
}

解法三:动态规划

有了上面两种方法和解释作为铺垫,我想迭代式的动态规划应该不难理解。这里我们不再用递归,而是使用 for 循环的形式,先上代码:

public boolean isMatch(String s, String p) {
    if (s.equals(p)) {
        return true;
    }

    char[] sArr = s.toCharArray();
    char[] pArr = p.toCharArray();

    // dp[i][j] => is s[0, i - 1] match p[0, j - 1] ?
    boolean[][] dp = new boolean[sArr.length + 1][pArr.length + 1];

    dp[0][0] = true;

    for (int i = 1; i <= pArr.length; ++i) {
        dp[0][i] = pArr[i - 1] == '*' ? dp[0][i - 2] : false;
    }

    for (int i = 1; i <= sArr.length; ++i) {
        for (int j = 1; j <= pArr.length; ++j) {
            if (sArr[i - 1] == pArr[j - 1] || pArr[j - 1] == '.') {
                // 看 s[0,...i-1] 和 p[0,...j-1]
                dp[i][j] = dp[i - 1][j - 1];
            }

            if (pArr[j - 1] == '*') {
                // 看 s[0,...i] 和 p[0,...j-2]
                dp[i][j] |= dp[i][j - 2];

                if (pArr[j - 2] == sArr[i - 1] || pArr[j - 2] == '.') {
                    // 看 s[0,...i-1] 和 p[0,...j]
                    dp[i][j] |= dp[i - 1][j];
                }
            }
        }
    }

    return dp[sArr.length][pArr.length];
}

这里我说一下前面的 DP 数组的初始化,因为需要考虑空串的情况,所以我们 DP 数组大小多开了 1 格。dp[0][0] = true 因为两个空串是匹配的,紧接着下面一行的 for 循环是为了确保空串和 p 的一部分是匹配,比如 s = "",p = "a*b",那么这里 dp[0][2] = true,也就是 s[0,0]和p[0,2] 是匹配的,注意和之前不一样的是这里的 0 代表空串。

字符串匹配类动态规划的总结和思考

一般来说,对于字符串匹配的问题中,输入参数都会有两个字串,如果确定了这道题的问题是可以分解成一系列子问题,那么就可以考虑使用动态规划求解,可以根据区间来定义状态,一般来说只需要考虑头区间或者是尾区间,这道题中的动态规划解法,我们就是考虑了头区间,s[0,…i]和p[0,…j] 是否匹配记录在 dp[i+1][j+1] 中,如果你选择尾区间的话,那么遍历的方式需要从后往前,就和之前讲解的记忆化搜索一样。所以一般的字符串匹配的动态规划的 DP 数组都是二维的,当然也有特例。个人觉得确定了考虑的区间和遍历方向,至少来说在动态规划状态方程的推导上会清晰不少。

接下来就是重点的部分,递推方程的推导,这里没有特别多的技巧,还是那句话,唯手熟尔,无他,要说重点的话,还是在确定当前子问题和前面子问题的联系上吧,或者你可以这样想 “当前考虑的子问题在什么情况下会变成前面求解过的子问题”。

还是拿这道题举例,上面的 DP 解法我们从前往后遍历,在考虑子问题 s[0,…i]和p[0,…j] 是否匹配,如果拿掉 s[i] 和 p[j],这个问题就会变成前面求解过的子问题 s[0,…i-1]和p[0,…j-1],如果只拿掉 s[i],这个问题就会变成前面求解过的子问题 s[0,…i-1]和p[0,…j],如果只拿掉 p[j-1,j],这个问题就会变成前面求解过的子问题 s[0,…i]和p[0,…j-2],至于为什么有些可以拿掉,有些不能,那这个只能根据题意来分析了,相信通过前面的分析应该不难理解。

结合上面的分析,这里列了一些字符串匹配类动态规划的一些注意事项:

  • 注意考虑是否需要考虑空串的情况,如果是的话,一般 DP 数组需要多开一格

  • 在考虑递推方程前,确定子问题的区间和遍历方向

  • 在思考递推方程的时候,重点思考当前子问题怎么变成之前求解过的子问题。

 

posted @ 2022-09-12 19:30  yhstsy  阅读(87)  评论(0)    收藏  举报