本文介绍一种实用的骗分技巧,众所周知最好的算法就是时间复杂度就是$O(1)$的算法,打表法,借助C++的编译期计算和一点点的模板知识,我们可以实现在编译期打表,大大减少运行时间。
使用场景
实际中能使用编译期打表的场景其实很少,而且编译期打表的限制很多。首先明确为什么要打表,打表的目的是为了简化复杂计算,是一种空间换时间的策略。它的缺点也很多
- 受到编译器和编译参数影响,这个主要是递归深度和迭代次数的限制,以及低版本的C++可能不支持
constexpr
。 - 内存限制,因为题目有内存限制,打表会使用大量内存,有些编译的时候能够通过不会爆内存,但是可能运行时在某些情况下还是会爆内存,所以要预留足够的运行内存。
- 输入限制,打表法本质上只是做映射,基本只能应付简单的输入和输出,以及输入少的情况,如果有大量复杂输入和输出不建议使用打表法,一般这种情况下使用打表会比题目本身还要困难。
所以打表法不是必须的,只是一种类似于投机取巧的方法,不会影响也不大。下面来看看编译期打表如何操作。
编译期计算
C++哪个版本加入的编译期计算不记得了,主要就是使用constexpr
修饰的函数可以在编译期调用,如果参数能够在编译期求值,并且函数不依赖其他运行时变量,那这个函数就可以在编译期调用。
其实这个关键字还有一个更加极端的版本consteval
,只能在编译期计算,但是这个关键字比较新,比赛环境不一定能用,所以尽量不要使用。
使用vs code或者其他有静态分析的IDE的时候,尝试下面代码,鼠标移动到等号上应该可以看到,
没有运行就直接求出值了,这是编译期求值的比较直观的表现。上面这个函数太简单了,来个简单的编译期递归也是可以的,
编译期结果的保存(现代版)
因为编译期不能动态分配内存,所以很多容器都不能使用,C风格的数组也不能使用,这种情况下怎么保存计算的结果就成了问题,现在可以在编译期中使用array
这个容器,这就使得编译期打表变得非常简单了,首先展示一下基本形式1
2
3
4
5constexpr auto A=[]{
array<int,10>res{};
//计算代码
return res;
}();
就这么简单,可以用鼠标放在A上试一下,
也可以使用for
和调用其他constexpr
的函数,
非常的简单就是套格式就行了,
编译期结果的保存(原始版)
上面的方法比较现代,在能够在编译期使用array之前,原始的人类使用的是模板递归来保存数据,形式大概如下,1
2
3
4
5
6
7
8
9template<int N>
struct Table{
Table<N-1>pre;
int val=N;
};
template<>
struct Table<0>{
int val=0;
};
要理解这个东西需要一些模板递归的知识,这个结构体最后会保存成一个神奇的样子,可能像这样1
2
3
4
5
6
7
8
9
10
11
12
13
14Table<N>{
Table<N-1>{
Table<N-2>{
//一些代码
Table<0>{
int val;
}
//一些代码
int val
}
int val;
}
int val;
}
这些数据如何排列的,以及如何读取,需要对C语言结构体有些了解。首先这些数据就是按照0到N的顺序,连续排列的N+1个int。如何读取呢,使用C语言指针的方式读取,可以试着下面的代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using namespace std;
template<int N>
struct Table{
Table<N-1>pre;
int val=N;
};
template<>
struct Table<0>{
int val=0;
};
Table<10>A;
int main(){
int *p=(int*)&A;
for(int i=0;i!=10;++i)
cout<<*(p+i)<<endl;
//也可以使用copy的方式
//copy_n(p,10,ostream_iterator<int>(cout," ");
return 0;
}
不过这种方法使用起来太麻烦了,不建议使用。接下来进行一些实战,因为我现在用leetcode所以用leetcode演示。
简单的实战
爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
- 1 阶 + 1 阶
- 2 阶
示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
- 1 阶 + 1 阶 + 1 阶
- 1 阶 + 2 阶
- 2 阶 + 1 阶
提示:
$1 <= n <= 45$
显然这是一道动规题,状态转移方程为dp[i]=dp[i-1]+dp[i-2];
,数据范围只有46个(包含0)。非常的常规,直接算其实也不麻烦,下面是打表的写法1
2
3
4
5
6
7
8
9
10
11
12constexpr auto A=[]{
array<int,46>res{0,1,2};
for(int i=3;i!=46;++i)
res[i]=res[i-1]+res[i-2];
return res;
}();
class Solution {
public:
int climbStairs(int n) {
return A[n];
}
};
打表建议打在全局变量,局部变量可能会爆内存。非常的简单直接返回结果就行了。
使用辅助函数的实战
这题也是动规,但是应该有不是动规的写法,范围是$10^4$,显然可以全打了,能暴力打表就不要动脑子。
1 | constexpr int get5(int N){ |
需要一些处理的实战
不能使用内置函数,而且我脑子不好不会写二分法和牛顿迭代法怎么办呢,打表!看一眼范围$2^{31}-1$,不能暴力打表每个数的平方根。
但是反过来想,可以打表每个数的平方,然后通过查找平方来获取下标就是平方根,这样只需要打$2^{16}$的表
1 | const int N=1<<16; |
部分打表的实战
多次调用的优化方法,那当然是打表啊!看一眼范围$2^{31}-1$,不能暴力打表。观察到2的32次方就是有32个位,可以只打16位的表,然后取高16位和低16位的1的个数和。1
2
3
4
5
6
7
8
9
10
11
12
13constexpr int N=1<<16;
constexpr auto A=[]{
array<char,N>res{};
for(int i=1;i!=N;++i)
res[i]=res[i>>1]+i%2;
return res;
}();
class Solution {
public:
int hammingWeight(int n) {
return A[n>>16]+A[n&0xffff];
}
};
复杂的实战
为什么全排列也要打表呢,我也不知道,单纯只是因为它的范围只有6,这种情况就是打表会比较复杂,但是我觉得不打表也复杂。
这里我使用了原始的模板递归来保存全排列的索引,因为array需要固定每个元素的大小,而且编译期没有地址,所以不能用array嵌套来保存每个数字的情况下的全排列。
F
是打表的阶乘数。Table
是打表的全排列索引。pos
是打表的偏移量,用于从Table
中获取对应N的全排列。array2vector
是将索引映射为题目要求的元素,同时返回vector。
1 | constexpr auto F=[]{ |
总结
打表不是什么有思想的东西,这是一种实战的暴力解题技巧,是一种经验,所以要在实践中学习怎么使用,高级的还有分段打表,但是我一时间没找到合适的题。