4.4 达人修炼真题
1.如何求数组中两个元素的最小距离
题目描述:
给定一个数组,数组中含有重复元素,给定两个数字num1和num2,求这两个数字在数组中出现位置的最小距离。
分析与解答:
对于这类问题,最简单的方法就是对数组进行双重遍历,找出最小距离,但是这种方法效率比较低。由于在求距离时只关心num1与num2这两个数,因此,只需要对数组进行一次遍历即可,在遍历的过程中分别记录num1和num2的位置就可以非常方便地求出最小距离,下面详细介绍两种实现方法。
方法一:蛮力法
主要思路为:对数组进行双重遍历,外层循环遍历查找num1,只要遍历到num1,内层循环对数组从头开始遍历找num2,每当遍历到num2,就计算它们的距离dist。当遍历结束后最小的dist值就是它们最小的距离。实现代码如下:
程序的运行结果为
算法性能分析:
这个算法需要对数组进行两次遍历,因此,时间复杂度为O(n2)。
方法二:动态规划
上述方法的内层循环对num2的位置进行了多次查找。可以采用动态规划的方法把每次遍历的结果都记录下来,从而可以减少遍历次数。具体实现思路是:遍历数组,在遍历的过程中会遇到以下两种情况:
1)当遇到num1时,记录下num1值对应数组下标的位置lastPos1,通过求lastPos1与上次遍历到num2下标的位置的值lastPos2的差,可以求出最近一次遍历到的num1与num2的距离。
2)当遇到num2时,同样记录下它在数组中下标的位置lastPos2,然后通过求lastPos2与上次遍历到num1的下标值lastPos1,求出最近一次遍历到的num1与num2的距离。
假设给定数组为:{4, 5, 6, 4, 7, 4, 6, 4, 7, 8, 5, 6, 4, 3, 10, 8},num1=4,num2=8。根据以上方法,执行过程如下:
1)在遍历的时候首先会遍历到4,下标为lastPos1=0,由于此时还没有遍历到num2,因此,没必要计算num1与num2的最小距离。
2)接着往下遍历,又遍历到num1=4,更新lastPos1=3。
3)接着往下遍历,又遍历到num1=4,更新lastPos1=7。
4)接着往下遍历,又遍历到num2=8,更新lastPos2=9;此时由于前面已经遍历到num1,因此,可以求出当前num1与num2的最小距离为|lastPos2-lastPos1|=2。
5)接着往下遍历,又遍历到num2=8,更新lastPos2=15;此时由于前面已经遍历到num1,因此,可以求出当前num1与num2的最小距离为| lastPos2-lastPos1|=8;由于8>2,所以,num1与num2的最小距离为2。
实现代码如下:
算法性能分析:
这个算法只需要对数组进行一次遍历,因此,时间复杂度为O(n)。
2.如何求数组连续最大和
题目描述:
一个数组有n个元素,这n个元素既可以是正数也可以是负数,数组中连续的一个或多个元素可以组成一个连续的子数组,一个数组可能有多个这种连续的子数组,求子数组和的最大值。例如:对于数组{1,-2, 4, 8,-4, 7,-1,-5}而言,其最大和的子数组为{4, 8,-4, 7},最大值为15。
分析与解答:
这是一道非常经典、常见的笔试面试算法题,有多种解决方法,下面从简单到复杂逐个介绍各种方法。
方法一:蛮力法
最简单也是最容易想到的方法就是找出所有的子数组,然后求出子数组的和,在所有子数组的和中取最大值。实现代码如下:
程序的运行结果为
算法性能分析:
这个算法的时间复杂度为O(n3),显然效率太低,通过对该方法进行分析发现,许多子数组都重复计算了。鉴于此,下面给出一种优化的方法。
方法二:重复利用已经计算的子数组和
由于Sum[i,j]=Sum[i,j-1]+arr[j],在计算Sum[i,j]时可以使用前面已计算出的Sum[i, j-1]而不需要重新计算,采用这种方法可以省去计算Sum[i,j-1]的时间,因此,可以提高程序的效率。
实现代码如下:
算法性能分析:
这个方法使用了双重循环,因此,时间复杂度为O(n2)。
方法三:动态规划方法
可以采用动态规划的方法来降低算法的时间复杂度。实现思路如下:
首先可以根据数组的最后一个元素array[n-1]与最大子数组的关系分为以下三种情况讨论:
1)最大子数组包含array[n-1],即最大子数组以array[n-1]结尾。
2)array[n-1]单独构成最大子数组。
3)如果最大子数组不包含arr[n-1],那么求array[1…n-1]的最大子数组可以转换为求array[1…n-2]的最大子数组。
通过上述分析可以得出如下结论:假设已经计算出子数组array[1…i-2]的最大的子数组和All[i-2],同时也计算出array[0…i-1]中包含array[i-1]的最大的子数组的和为End[i-1]。则可以得出如下关系:All[i-1]=max{End[i-1],array[i-1],All[i-2]}。利用这个公式和动态规划的思想可以得到如下代码:
算法性能分析:
与前面几个方法相比,这种方法的时间复杂度为O(n),显然效率更高,但是由于在计算的过程中额外申请了两个数组,因此该方法的空间复杂度也为O(n)。
方法四:优化的动态规划方法
方法三中每次其实只用到了End[i-1]与All[i-1],而不是整个数组中的值,因此,可以定义两个变量来保存End[i-1]与All[i-1]的值,并且可以反复利用。实现代码如下:
算法性能分析:
这个方法在保证了时间复杂度为O(n)的基础上,把算法的空间复杂度也降到了O(1)。
引申:在知道子数组最大值后,如何才能确定最大子数组的位置。
分析与解答:
为了得到最大子数组的位置,首先介绍另外一种计算最大子数组和的方法。在上例的方法三中,通过对公式End[i]=max(End[i-1]+array[i],array[i])的分析可以看出,当End[i-1]<0时,End[i]=array[i],其中End[i]表示包含array[i]的子数组和,如果某一个值使得End[i-1]<0,那么就从array[i]重新开始。利用这个性质可以非常容易地确定最大子数组的位置。
实现代码如下:
程序的运行结果为
3.如何获取最佳的矩阵链相乘方法
题目描述:
给定一个矩阵序列,找到最有效的方式将这些矩阵乘在一起。给定表示矩阵链的数组p[],使得第i个矩阵A i的维数为p[i-1]×p[i]。编写一个函数MatrixChainOrder(),该函数应该返回乘法运算所需的最小乘法数。
输入:p[]={40,20,30,10,30}
输出:26000
有4个大小为40×20、20×30、30×10和10×30的矩阵。假设这四个矩阵为A、B、C和D,该函数的执行方法可以使执行乘法运算的次数最少。
分析与解答:
该问题实际上并不是执行乘法,而是决定以哪个顺序执行乘法。由于矩阵乘法是关联的,所以有很多选择来进行矩阵链的乘法运算。换句话说,无论采用哪种方法来执行乘法,结果将是一样的。例如,如果有四个矩阵A、B、C和D,可以有如下几种执行乘法的方法:
虽然这些方法的计算结果相同。但不同的方法需要执行乘法的次数是不相同的,因此效率也是不同的。例如,假设A是10×30矩阵,B是30×5矩阵,C是5×60矩阵。那么,(AB)C的执行乘法运算的次数为(10×30×5)+(10×5×60)=1500+3000=4500次。
A(BC)的执行乘法运算的次数为(30×5×60)+(10×30×60)=9000+18000=27000次。
显然,第一种方法执行的乘法运算更少,因此效率更高。
对于本题中示例而言,执行乘法运算的次数最少的方法如下:
(A(BC))D的执行乘法运算的次数为20×30×10+40×20×10+40×10×30。
方法一:递归法
最简单的方法就是在所有可能的位置放置括号,计算每个放置的成本并返回最小值。在大小为n的矩阵链中,可以以n-1种方式放置第一组括号。例如,如果给定的链是4个矩阵,(A)(BCD)、(AB)(CD)和(ABC)(D)中,有3种方式放置第一组括号。每个括号内的矩阵链可以被看作较小规模的子问题。因此,可以使用递归方便地求解。递归的实现代码如下:
程序的运行结果为
这个算法的时间复杂度是指数级的。要注意,这种算法会对一些子问题进行重复计算。例如在计算(A)(BCD)这种方案的时候会计算CD的代价,而在计算(AB)(CD)这种方案的时候又会重复计算CD的代价。显然子问题是有重叠的,对于这种问题,通常可以用动态规划的方法来降低时间复杂度。
方法二:动态规划
典型的动态规划的方法是使用自下而上的方式构造临时数组来保存子问题的中间结果,从而可以避免大量重复计算。实现代码如下:
算法性能分析:
这个算法的时间复杂度为O(n3),空间复杂度为O(n2)。
4.如何求两个字符串的最长公共子串
题目描述:
找出两个字符串的最长公共子串,例如字符串“abccade”与字符串“dgcadde”的最长公共子串为“cad”。
分析与解答:
对于这道题最容易想到的方法就是采用蛮力法,假设字符串s1与s2的长度分别为len1和len2(假设len1≥len2),首先可以找出s2的所有可能的子串,然后判断这些子串是否也是s1的子串,通过这种方法可以非常容易地找出两个字符串的最长公共子串。当然,这种方法的效率是非常低的,主要原因是s2中的大部分字符需要与s1进行多次的比较。那么是否有更好的方法来降低比较的次数呢?下面介绍两种通过减少比较次数从而降低时间复杂度的方法。
方法一:动态规划法
通过把中间的比较结果记录下来,从而可以避免字符的重复比较。主要思路如下:
首先定义二元函数f(i,j),分别表示以s1[i]和s2[j]结尾的公共子串的长度,显然,f(0,j)=0(j≥0),f(i,0)=0(i≥0),那么,对于f(i+1,j+1) 而言,有如下两种取值:
1)f(i+1,j+1)=0,当str1[i+1] !=str2[j+1]时。
2)f(i+1,j+1)=f(i,j)+1,当str1[i+1]==str2[j+1] 时。
根据这个公式可以计算出f(i,j)(0≤i≤len(s1),0≤j≤len(s2))所有的值,从而可以找出最长的公共子串,如图4-2所示。
图4-2 动态规划法
通过上图所示的计算结果可以求出最长公共子串的长度max与最长子串结尾字符在字符数组中的位置maxI,由这两个值就可以唯一确定一个最长公共子串为“cad”,这个子串在数组中的起始下标为maxI-max=3,子串长度为max=3。实现代码如下:
程序的运行结果为
算法性能分析:
由于这个算法使用了二重循环分别遍历两个字符数组,因此,这个算法的时间复杂度为O(m*n)(其中,m和n分别为两个字符串的长度),此外,由于这个算法申请了一个m*n的二维数组,因此,算法的空间复杂度也为O(m*n)。很显然,这个算法的缺点是申请了m*n个额外的存储空间。
方法二:滑动比较法
这个方法的主要思路为:保持s1的位置不变,然后移动s2,接着比较它们重叠的字符串的公共子串(记录最大的公共子串的长度maxLen,以及最长公共子串在s1中结束的位置maxLenEnd1),在移动的过程中,如果当前重叠子串的长度大于maxLen,则更新maxLen为当前重叠子串的长度。最后通过maxLen和maxLenEnd1就可以找出它们最长的公共子串。实现方法如图4-3所示。
图4-3 滑动比较法
如上图所示,这两个字符串的最长公共子串为“bc”,实现代码如下:
算法性能分析:
这个算法用双重循环来实现,外层循环的次数为m+n(其中,m和n分别为两个字符串的长度),内层循环最多执行n次,算法的时间复杂度为O((m+n)*n),由于这个算法只使用了几个临时变量,因此,算法的空间复杂度为O(1)。
5.如何求字符串里的最长回文子串
题目描述:
回文字符串是指一个字符串从左到右与从右到左遍历得到的序列是相同的。例如“abcba”就是回文字符串,而“abcab”则不是回文字符串。
分析与解答:
最容易想到的方法为遍历字符串所有可能的子串(蛮力法),判断其是否为回文字符串,然后找出最长的回文子串。但是当字符串很长的时候,这种方法的效率是非常低的,因此,这种方法不可取。下面介绍几种相对高效的方法。
方法一:动态规划法
在采用蛮力法找回文子串的时候其实有很多字符都在进行比较,因此,可以把前面比较的中间结果记录下来供后面使用。这就是动态规划的基本思想,那么如何根据前面查找的结果,判断后续的子串是否为回文字符串呢?下面给出判断的公式,即动态规划的状态转移公式:
给定字符串“S0S1S2…Sn”,假设P(i,j)=1表示“Si Si+1…Sj”是回文字符串;P(i,j)=0则表示“Si Si+1…Sj”不是回文字符串。那么:
P(i,i)=1。
如果Si==Si+1:那么P(i,i+1)=1,否则P(i,i+1)=0。
如果Si+1==Sj+1:那么P(i+1,j+1)=P(i,j)。
根据这几个公式,实现代码如下:
程序的运行结果为
算法性能分析:
这个算法的时间复杂度为O(n2),空间复杂度也为O(n2)。
此外,还有另外一种动态规划的方法来实现最长回文字符串的查找。主要思路是:对于给定的字符串str1,求出对其进行逆序的字符串str2,str1与str2的最长公共子串就是str1的最长回文子串。
方法二:中心扩展法
判断一个字符串是否为回文字符串最简单的方法是:从字符串最中间的字符开始向两边扩展,通过比较左右两边字符是否相等就可以确定这个字符串是否为回文字符串。这种方法对于字符串长度为奇数和偶数的情况需要分别对待。例如:对于字符串“aba”,就可以从最中间的位置b开始向两边扩展;但是对于字符串“baab”,就需要从中间的两个字母开始分别向左右两边扩展。
基于回文字符串的这个特点,可以设计这样一个方法来找回文字符串:对于字符串中的每个字符ci,向两边扩展,找出以这个字符为中心的回文子串的长度。由于上面介绍的回文字符串长度的奇偶性,这里需要分两种情况:1)以ci为中心向两边扩展。2)以ci和ci+1为中心向两边扩展。实现代码如下:
算法性能分析:
这个算法的时间复杂度为O(n2),空间复杂度为O(1)。
6.如何求最长递增子序列的长度
题目描述:
假设L=<a1,a2,…,an>是n个不同的实数的序列,L的递增子序列是这样一个子序列:Lin=<ak1,ak2,…,akm>,其中,k1<k2<…<km且ak1<ak2<…<akm。求最大的m值。
方法一:最长公共子串法
对序列L=<a1,a2,…,an>递增排序得到序列LO=<b1,b2,…,bn>。显然,L与LO的最长公共子序列就是L的最长递增子序列。因此,可以使用求公共子序列的方法来求解。
方法二:动态规划法
由于以第i个元素为结尾的最长递增子序列只与以第i-1个元素为结尾的最长递增子序列有关,因此,本题可以采用动态规划法来解决。下面首先介绍动态规划法中的核心内容:递归表达式的求解。
以第i个元素为结尾的最长递增子序列的取值有两种可能:
1)当第i个元素单独作为一个子串(L[i]<=L[i-1])。
2)以第i-1个元素为结尾的最长递增子序列加1(L[i]>L[i-1])。
由此可以得到如下的递归表达式:假设maxLen[i]表示以第i个元素为结尾的最长递增子序列,那么有:
1)maxLen [i]=max{1,maxLen [j]+1},j<i and L[j]<L[i];
2)maxLen[0]=1。
根据这个递归表达式很容易写出实现代码如下:
程序的运行结果为
算法性能分析:
由于这个算法用双重循环来实现,因此,该算法的时间复杂度为O(n2),此外由于该算法还使用了n个额外的存储空间,因此,空间复杂度为O(n)。
7.如何求字符串的编辑距离
题目描述:
编辑距离又称Levenshtein距离,是指两个字符串之间由一个转换成另一个所需的最少编辑操作次数。许可的编辑操作包括将一个字符替换成另一个字符、插入一个字符以及删除一个字符。请设计并实现一个算法来计算两个字符串的编辑距离,并计算其复杂度。在某些应用场景下,替换操作的代价比较高,假设替换操作的代价是插入和删除的两倍,算法该如何调整?
分析与解答:
本题可以使用动态规划的方法来解决,具体思路如下:
给定字符串s1,s2,首先定义一个函数D(i, j)(0≤i≤strlen(s1),0≤j≤strlen(s2)),用来表示第一个字符串s1长度为i的子串与第二个字符串s2长度为j的子串的编辑距离。从s1变换到s2可以通过如下三种操作:
1)添加操作。假设已经计算出D(i, j-1)的值(s1[0…i]与s2[0…j-1]的编辑距离),则D(i, j)=D(i, j-1)+1(s1长度为i的子串后面添加s2[j]即可)。
2)删除操作。假设已经计算出D(i-1, j)的值(s1[0…i-1]到s2[0…j]的编辑距离),则D(i,j)=D(i-1, j)+1(s1长度为i的子串删除最后的字符s1[j]即可)。
3)替换操作。假设已经计算出D(i-1, j-1)的值(s1[0…i-1]与s2[0…j-1]的编辑距离),如果s1[i]=s2[j],则D(i, j)=D(i-1,j-1),如果s1[i]!=s2[j],则D(i, j)=D(i-1, j-1)+1(替换s1[i]为s2[j],或替换s2[j]为s1[i])。
此外,D(0, j)=j且D(i,0)=i(从一个字符串变成长度为0的字符串的代价为这个字符串的长度)。
由此可以得出如下实现方式:对于给定的字符串s1,s2,定义一个二维数组D,则有以下几种可能性。
1)如果i=0,那么D[i, j]=j(0≤j≤strlen(s2))。
2)如果j=0,那么D[i, j]=i(0≤i≤strlen(s1))。
3)如果i>0且j>0,
① 如果s1[i]=s2[j],那么D(i, j)=min{edit(i-1, j)+1, edit(i, j-1)+1, edit(i-1, j-1)}。
② 如果s1[i]!=s2[j],那么D(i, j)=min{edit(i-1, j)+1, edit(i, j-1)+1, edit(i-1, j-1)+1}。
通过以上分析可以发现,对于第一个问题可以直接采用上述的方法来解决。对于第二个问题,由于替换操作是插入或删除操作的两倍,因此,只需要修改如下条件即可:
如果s1[i]!=s2[j],那么D(i, j)=min{edit(i-1, j)+1, edit(i, j-1)+1, edit(i-1, j-1)+2}。
根据上述分析,给出实现代码如下:
程序的运行结果为
算法性能分析:
这个算法的时间复杂度与空间复杂度都为O(m*n)(其中,m、n分别为两个字符串的长度)。