算法分析入门
题目:现有一个m * n的网格,从最左上角出发,每次只能向右或者向下移动一格,问有多少种不同的方法可以到达最右下角的格子?
见下图一个m*n的格子,从A走到B:
分析
1,数学表达式
这道题就是一个很明显的的排列组合问题,我们可以推导出它的数学表达式
要从A到B,必须向左走m步,向下走n步,什么时候走可以不关心,一共m+n步,很明显,分解出来,m+n步中,任选m向下走步,排列组合解就是一共有C(m+n,m)种所以,对于m*n的格子,一样的,就是从m+n步中选出m步向下或n步向右,因此为C(m+n,m)=C(m+n,n)种。
表达式就是 f(m,n) = C(m+n,m)
def f(self, m: int, n: int) -> int:
return int(math.factorial(m+n)/math.factorial(m)/math.factorial(n))
2.回溯(递归思想)
但是大多数时候,一个问题是不能用标准的数学表达式的,比如这个问题如果说中间有个节点不能经过,这时候就不适用了,这时候我们就要用到编程的思想。下面谈的就是递归思想
我们先来了解什么是递归:
递归的三大要素
第一要素:明确你这个函数想要干什么
对于递归,我觉得很重要的一个事就是,这个函数的功能是什么,他要完成什么样的一件事,而这个,是完全由你自己来定义的。也就是说,我们先不管函数里面的代码什么,而是要先明白,你这个函数是要用来干什么。
例如,我定义了一个函数
// 算 n 的阶乘(假设n不为0)
int f(int n){
}
这个函数的功能是算 n 的阶乘。好了,我们已经定义了一个函数,并且定义了它的功能是什么,接下来我们看第二要素。
第二要素:寻找递归结束条件
所谓递归,就是会在函数内部代码中,调用这个函数本身,所以,我们必须要找出递归的结束条件,不然的话,会一直调用自己,进入无底洞。也就是说,我们需要找出当参数为啥时,递归结束,之后直接把结果返回,请注意,这个时候我们必须能根据这个参数的值,能够直接知道函数的结果是什么。
例如,上面那个例子,当 n = 1 时,那你应该能够直接知道 f(n) 是啥吧?此时,f(1) = 1。完善我们函数内部的代码,把第二要素加进代码里面,如下
// 算 n 的阶乘(假设n不为0)
int f(int n){
if(n == 1){
return 1;
}
}
有人可能会说,当 n = 2 时,那我们可以直接知道 f(n) 等于多少啊,那我可以把 n = 2 作为递归的结束条件吗?
当然可以,只要你觉得参数是什么时,你能够直接知道函数的结果,那么你就可以把这个参数作为结束的条件,所以下面这段代码也是可以的。
// 算 n 的阶乘(假设n>=2)
int f(int n){
if(n == 2){
return 2;
}
}
注意我代码里面写的注释,假设 n >= 2,因为如果 n = 1时,会被漏掉,当 n <= 2时,f(n) = n,所以为了更加严谨,我们可以写成这样:
// 算 n 的阶乘(假设n不为0)
int f(int n){
if(n <= 2){
return n;
}
}
第三要素:找出函数的等价关系式
第三要素就是,我们要不断缩小参数的范围,缩小之后,我们可以通过一些辅助的变量或者操作,使原函数的结果不变。
例如,f(n) 这个范围比较大,我们可以让 f(n) = n * f(n-1)。这样,范围就由 n 变成了 n-1 了,范围变小了,并且为了原函数f(n) 不变,我们需要让 f(n-1) 乘以 n。
说白了,就是要找到原函数的一个等价关系式,f(n) 的等价关系式为 n * f(n-1),即
f(n) = n * f(n-1)。
这个等价关系式的寻找,可以说是最难的一步了,如果你不大懂也没关系,因为你不是天才,你还需要多接触几道题,我会在接下来的文章中,找 10 道递归题,让你慢慢熟悉起来。
找出了这个等价,继续完善我们的代码,我们把这个等价式写进函数里。如下:
// 算 n 的阶乘(假设n不为0)
int f(int n){
if(n <= 2){
return n;
}
// 把 f(n) 的等价操作写进去
return f(n-1) * n;
}
至此,递归三要素已经都写进代码里了,所以这个 f(n) 功能的内部代码我们已经写好了。
这就是递归最重要的三要素,每次做递归的时候,你就强迫自己试着去寻找这三个要素。
回到我们的问题:
第一步,求解的是达到f(m,n)路径的总数
第二步,子问题与关系式:到达f(m,n)的情况有两种,一种是从f(m-1,n)过来,一种是从f(m,n-1)过来
定义关系式:f(m,n) = f(m-1,n) + f(m,n-1)
第三步, 终止条件 f(0,0) = 1 (这里有些特殊,边界上都是1)
代码
def f(m: int, n: int) -> int:
if m == 0 or n == 0:
return 1
return f(m-1,n) + f(m, n-1)
3.回溯2(递归 + 缓存)
递归是最直观的解法,但是递归过程中会有重复计算问题。比如:我们计算2和3这个点都会计算1这个点的值,计算1的时候又会重复计算,
当我们的维度很小时,差别不大,但是当维度很大时,开销就会变成指数级增长。
下图是f(5,5), f(10,10),f(15,15)的结果及运行时间
既然重复计算了,那我们是不是可以考虑将计算的结果保存下来呢,如果有这个值就直接取,没有就计算出来并保存
我们用列表cache来保存
cache = [[0]*15 for i in range(15)]
def f1(m: int, n: int) -> int:
if m == 1 or n == 1:
return 1
if cache[m][n] != 0:
return cache[m][n]
cache[m][n] = f(m-1, n) + f(m, n-1)
return cache[m][n]
3.dp
我们考虑了cache[i][j]来保存在第i,j时所有路径的值,显而易见cache[i][j] = cache[i-1][j] + cache[i][j-1],即cache[2][2] = cache[1][2] +cache[2][1] = 2
所以可以正向赋值cache,返回cache[m][n]即可
直接上代码
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
dp = [[1]*n for _ in range(m)]
#print(dp) dp[0][j]或者dp[i][0]取不到,所以可以是任意值
for i in range(1, m):
for j in range(1, n):
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[-1][-1]
4.dp空间优化
令m为行、n为列 优化1:行列两层循环中的循环体cur[j] = pre[j] + cur[j-1] ,cur[j] 表示遍历到的从起点到第i行第j列的路径数,它等于当前第i行第j-1列即 cur[j-1]的值 加上 上一行第j列的值pre[j] 内层循环一次后即计算完了第i行各列的值,在计算下一行第i+1行之前执行pre = cur.clone(); 即第i行的值就是第i+1行的前一行,两层循环完以后最后要到达的终点的行的值存于pre数组中,所以取出 pre[n-1]即可
优化2:相比优化1,少了pre数组,cur[j] += cur[j-1] 即 cur[j] = cur[j-1] + cur[j] 未赋值之前右边的cur[j] 始终表示当前行第i行的上一行第j列的值,赋值之后左边的cur[j]表示当前行第i行第j列的值,cur[j-1] 表示当前行第i行第j-1列的值(cur[j-1] 在计算cur[j]之前就已经计算了,所以表示的是当前行而不是上一行 ), 思路跟优化1是一样的,除了少用了一个数组
代码
# 思路一
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
pre = [1] * n
cur = [1] * n
for i in range(1, m):
for j in range(1, n):
cur[j] = pre[j] + cur[j-1]
pre = cur[:]
return pre[-1]
# 思路二
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
cur = [1] * n
for i in range(1, m):
for j in range(1, n):
cur[j] += cur[j-1]
return cur[-1]
- 点赞
- 收藏
- 关注作者
评论(0)