这道经典的跳台阶问题,是每个学习算法的人都会遇到的入门题目。我第一次接触这个问题时,也被它简洁外表下蕴含的深刻思想所震撼。题目描述很简单:假设你面前有n级台阶,每次可以跨1级或2级,问有多少种不同的方法可以跳到第n级台阶。
这个问题的解法完美展示了递归思想的精髓。让我们先看代码实现:
cpp复制#include <bits/stdc++.h>
using namespace std;
const int N=1e9+7;
#define int long long
int ans(int a) {
if(a==1) return 1;
if(a==2) return 2;
return (ans(a-2)+ans(a-1));
}
signed main() {
int n;
cin>>n;
cout<<ans(n)<<endl;
return 0;
}
理解这个问题的关键在于发现其中的递推关系。假设我们想知道跳到第n级台阶的方法数f(n),可以考虑最后一步的跳法:
因此,总的方法数就是这两种情况的和:f(n) = f(n-1) + f(n-2)。这就是著名的斐波那契数列递推关系。
注意:这个递推关系的边界条件是f(1)=1(只有1种方法跳1级台阶),f(2)=2(可以一次跳2级,或者分两次各跳1级)。
让我们仔细分析代码中的递归函数ans:
cpp复制int ans(int a) {
if(a==1) return 1; // 基准情况1
if(a==2) return 2; // 基准情况2
return (ans(a-2)+ans(a-1)); // 递归调用
}
这个函数完美实现了我们上面分析的递推关系:
虽然递归解法简洁明了,但它存在严重的效率问题。让我们分析其时间复杂度:
每次调用ans(a)会产生两个新的递归调用:ans(a-1)和ans(a-2)。这形成了一个二叉树形的调用结构,时间复杂度是O(2^n),这是指数级的复杂度。
举个例子,计算ans(5)的调用过程:
code复制ans(5)
├── ans(4)
│ ├── ans(3)
│ │ ├── ans(2)
│ │ └── ans(1)
│ └── ans(2)
└── ans(3)
├── ans(2)
└── ans(1)
可以看到ans(3)被计算了两次,ans(2)被计算了三次。随着n增大,重复计算会呈指数级增长。
为了优化这个递归解法,我们可以引入"记忆化"技术,即把已经计算过的结果保存起来,避免重复计算:
cpp复制#include <bits/stdc++.h>
using namespace std;
const int N=1e9+7;
#define int long long
unordered_map<int, int> memo;
int ans(int a) {
if(memo.count(a)) return memo[a];
if(a==1) return memo[a]=1;
if(a==2) return memo[a]=2;
return memo[a]=(ans(a-2)+ans(a-1))%N;
}
signed main() {
int n;
cin>>n;
cout<<ans(n)<<endl;
return 0;
}
这个优化版本使用哈希表memo来存储已经计算过的结果。时间复杂度从O(2^n)降到了O(n),因为每个ans(i)只需要计算一次。
提示:在实际编程竞赛中,对于n很大的情况,还需要考虑结果取模的问题,如代码中对N=1e9+7取模。
递归解法虽然直观,但存在函数调用开销和栈空间限制的问题。我们可以将其改写为迭代形式:
cpp复制#include <bits/stdc++.h>
using namespace std;
const int N=1e9+7;
#define int long long
int ans(int n) {
if(n==1) return 1;
if(n==2) return 2;
int a=1, b=2, c;
for(int i=3; i<=n; ++i) {
c = (a + b) % N;
a = b;
b = c;
}
return b;
}
signed main() {
int n;
cin>>n;
cout<<ans(n)<<endl;
return 0;
}
这个迭代版本只使用了常数空间,时间复杂度仍然是O(n),但实际运行效率比递归版本高很多。
这个问题也可以看作是一个简单的动态规划问题。我们可以定义一个dp数组,其中dp[i]表示跳到第i级台阶的方法数:
cpp复制int ans(int n) {
if(n==1) return 1;
vector<int> dp(n+1);
dp[1] = 1;
dp[2] = 2;
for(int i=3; i<=n; ++i) {
dp[i] = (dp[i-1] + dp[i-2]) % N;
}
return dp[n];
}
虽然这个版本的空间复杂度是O(n),但它更清晰地展示了动态规划的思想。在实际应用中,可以像迭代版本那样优化空间复杂度到O(1)。
原问题中每次可以跳1级或2级。如果步长选择更多样化,比如可以跳1级、2级或3级,那么递推关系就变为:
f(n) = f(n-1) + f(n-2) + f(n-3)
相应的代码只需要稍作修改:
cpp复制int ans(int a) {
if(a==1) return 1;
if(a==2) return 2;
if(a==3) return 4; // 111,12,21,3
return (ans(a-3)+ans(a-2)+ans(a-1))%N;
}
有时候问题会增加一些限制条件,比如:
这类问题需要根据具体限制调整状态转移方程。例如,对于不能连续跳两次2级台阶的情况,我们需要在状态中记录上一步的跳跃方式:
cpp复制int ans(int n) {
// dp[i][0]表示跳到i级,最后一步是1级
// dp[i][1]表示跳到i级,最后一步是2级
vector<vector<int>> dp(n+1, vector<int>(2));
dp[1][0] = 1;
dp[1][1] = 0;
if(n>=2) {
dp[2][0] = 1; // 1+1
dp[2][1] = 1; // 直接2
}
for(int i=3; i<=n; ++i) {
dp[i][0] = (dp[i-1][0] + dp[i-1][1]) % N;
dp[i][1] = dp[i-2][0] % N; // 不能从dp[i-2][1]跳过来
}
return (dp[n][0] + dp[n][1]) % N;
}
在最初的递归实现中,当n较大时(如n=50),会导致递归深度过大,可能引发栈溢出。这是递归解法的一个主要限制。
重要提示:在实际编程中,对于n>30的情况,建议使用记忆化递归或迭代解法。
由于跳台阶的方法数随着n增长非常快(类似于斐波那契数列),即使对于中等大小的n(如n=50),结果也可能非常大。因此:
验证跳台阶算法时,应该考虑以下测试用例:
例如:
cpp复制void test() {
assert(ans(1)==1);
assert(ans(2)==2);
assert(ans(3)==3);
assert(ans(4)==5);
assert(ans(10)==89);
cout << "All tests passed!" << endl;
}
根据不同的应用场景,可以选择不同的实现方式:
对于C++实现,还有一些优化技巧:
跳台阶问题虽然简单,但它很好地展示了算法设计中的几个重要概念:
掌握这些问题解决模式,对于学习更复杂的算法非常有帮助。这也是为什么跳台阶问题经常被用作算法教学的入门例题。