刷题还是得每天坚持,中间一旦停了几天,再写起来手生的不行。今天尝试写了一道之前写过的题:不同二叉搜索树,虽然之前的思路还有印象,但还是有好多地方报错,改了好一会儿。还是要每日坚持!
思路
思路还是蛮容易懂的,依照二叉搜索树:左<根<右的特点,在1-n之间选每一个节点作为根节点,假设为i,则(1,i-1)为左子树的节点,(i+1,n)右子树节点,分别计算出左右两边可以构造的二叉搜索树的数量,然后左右相乘可得最终结果。
实现
1.递归方法
我刚开始想的方法是递归:
int f(int left, int right) {
int sum = 0;
int i;
if (left > right)
return 1;
if (left == right)
return 1;
for (i = left; i <= right; i++) {
sum += f(left, i - 1) * f(i + 1, right);
}
return sum;
}
int numTrees(int n){
return f(1,n);
}
但是运行的时间比较慢,然后我去看了官方的题解,数学推导的过程还是比较直观的。
2.循环方法1
思路仍然和递归的方法一样,我们设g[n]为n个节点构造的二叉搜索树的个数,如果n为1或0,那么显然个g[0]=g[1]=1,即只有一种构造方法。如果n>=2,假设n=6,节点:1、 2、3、4、5,6。
选取其中一个节点 i 为根节点,令此时可以构造的二叉搜索树为f(i,6)。
假设 i 选3,作为根节点,则左子树里的节点为1、 2,左子树可以构造的二叉搜索树个数为g[2];右子树:4、 5、 6,可以构造的二叉搜索树个数为g[3];所以 f(i,n)=f(3,6)=g[2]*g[3]=g[i-1]*g[n-i]。
那么依次选每一个节点为根节点,都有 f(i,n)= g[i-1]*g[n-i]。
每一种结果求和,即可得到
以下是实现的代码:
int numTrees(int n) {
int G[n+1];
memset(G, 0, sizeof(G));
G[0]=G[1]=1;
//注意这里的i对应算法公式里的n,j对应i!
for(int i=2;i<=n;i++)
{
for(int j=1;j<=i;j++)
{
G[i]+=G[j-1]*G[i-j];
}
}
return G[n];
}
3.循环方法2
其实到这里还没完!还有高手!我们刚刚的公式还可以再推导:
这是官方题解给的卡塔兰数,所以我们的二层循环可以依此公式优化为一层:
int numTrees(int n) {
int G[n+1];
memset(G, 0, sizeof(G));
G[0]=G[1]=1;
for(int i=1;i<=n;i++)
{
int k=i-1;
G[i]=G[k]*2*(2*k+1)/(k+2);
}
return G[n];
}
果然!我又踩了两个坑!
1.这个公式已经直观的体现了Cn+1和Cn的关系,还需要使用数组保存吗?不需要了,直接用一个变量 c 来不断迭代就可以了。
2.这个变量 c 直接定义为int吗?看清楚公式里有乘法,一不注意就会越界!所以定义为 long long c!
代码如下:
int numTrees(int n) {
long long c=1;
int k;
for(int i=1;i<=n;i++)
{
k=i-1;
c=c*2*(2*k+1)/(k+2);
}
return c;
}
运行时间和空间分析
这道题难度中等,但是写完三个解决方法,学到了很多。如图依次是递归、二层循环、一层循环运行的时间和空间分析:
1.递归方法最慢,使用空间最多。因为算法的原理并未改进,使用的就是左部分、右部分的节点构造的二叉搜索树个数相乘得到最后结果,并且每递归一层就要开辟left、right、sum的空间。
2.二层循环运行时间明显减少,速度提升非常多,这是因为,我们计算完每一种n的值都存到数组G中,后面需要这个值的时候直接取出来就用,运行速度就会特别快。
3.一层循环最优秀,时间快,使用空间少,这是因为卡塔兰数简化了算法,给出了n和n+1的结果之间的关系,所以无需数组保存,Cn算出来之后又被立马使用到Cn+1的计算当中。所以又快又省空间。
所以说刷题如果说追求的是把题做出来,也许没那么难?但是要找到一个又快又省的方法却需要很多的知识储备!
报错解决&知识积累
1.变长数组的初始化
写二层循环方法时遇到了一个问题,变长数组的初始化问题,我是这么写的:
int G[n+1]=0;
但是报错:error: variable-sized object may not be initialized(变量大小的对象不能初始化)
这其实不算是严格错误,因为有的版本的编译器是这么写是可以通过的。但是为避免出现错误,我建议在给变长数组初始化为0时,使用memset函数,以下是menset的简要介绍:
memset本来是为char数组初始化准备的。
函数模板:
memset(数组首地址,初始值,初始化大小)
数组首地址:数组的首地址是可以直接用数组名代替的
初始值: 初始值的范围是0到127,因为memset是以字节为单位对数组进行赋值的,一个字节8位,所表达的值就是0到127
初始化大小: 初始化大小用一个整数表示,表示需要初始化多少字节,通常用sizeof(数组首地址)来获得需要初始化的大小
char arr[10];
memset(arr,'a',10);//从数组首地址开始,初始化10个字节的值为'a';
memset(arr+2,'b',4);//从数组首地址+2开始,即arr[2]的地址开始,初始化4个字节的值为'b';
memset(arr,'c',sizeof arr);//从数组首地址开始,初始化数组大小个字节的值为'c',sizeof可以获取到数组的大小,这个代码中sizeof arr的值就是10
依次结果为:
memset(arr,'a',10)后,数组的值为:
a a a a a a a a a a
memset(arr+2,'b',4)后,数组的值为:
a a b b b b a a a a
sizeof arr的值为10
memset(arr,'c',sizeof arr)后,数组的值为:
c c c c c c c c c c
所以,如果我们数组初始化的数值在0-127之间,就可以使用memset函数。如下所示:
memset(G,0,sizeof(G));
2、数值溢出问题
在一个赋值的右边有乘法或者乘方,一定要注意赋值符号的左值的类型,不然很容易溢出。虽然这个问题比较容易改正,但是还是值得注意。