算法部分 基础1
一、算法例子
最近两天用来改文章语法格式等问题,还有写一些手册总结等杂事,花费了一些时间。心态稳住,继续努力。这次先用几天时间,跟着程序设计与算法 二,北京大学 郭炜。把一些基础的算法刷一遍,然后自己敲了理解。
1. 完美立方
问题描述:形如 a 3 = b 3 + c 3 + d 3 a^3=b^3+c^3+d^3 a3=b3+c3+d3 的等式称为完美立方等式。编写一个程序,对任给的正整数 N(N <= 100),寻找所有四元组(a, b, c, d),使得满足上述等式,其中 a, b, c, d 大于 1,小于等于 N,且 b <= c <= d.
输入:
一个正整数 N (N <= 100)
输出:
每行输出一个完美立方体。输出格式为 Cube = a, Triple = (b, c, d),其中 a, b, c, d 所在位置用数字代入。
解题思路:
四重循环枚举 a, b, c, d; a 在最外层, d 在最里层, 每一层都是从小到大枚举
a 枚举范围 [2, N]
b 枚举范围 [2, a-1]
c 枚举范围 [b, a-1]
d 枚举范围 [c, a-1]
程序比较简单,就不写了。
2. 熄灯问题
问题描述:有一个由按钮组成的矩阵,其中每行有 6 个按钮,共 5 行。每个按钮的位置上有一盏灯。当按一下按钮后,该按钮以及周围位置 (上边,下边,左边,右边) 的灯都会改变状态。
—> 与一盏灯毗邻的多个按钮被按下时,一个操作会抵消另一个操作的结果;
—> 给定矩阵中每盏灯的初始状态,求一种按按钮方案,使得所有灯都熄灭
输入:
2
0 1 1 0 1 0
1 0 0 1 1 1
0 0 1 0 0 1
1 0 0 1 0 1
0 1 1 1 0 0
0 0 1 0 1 0
1 0 1 0 1 1
0 0 1 0 1 1
1 0 1 1 0 0
0 1 0 1 0 0
输出:
PUZZLE #1
1 0 1 0 0 1
1 1 0 1 0 1
0 0 1 0 1 1
1 0 0 1 0 0
0 1 0 0 0 0
PUZZLE #2
1 0 0 1 1 1
1 1 0 0 0 0
0 0 0 1 0 0
1 1 0 1 0 1
1 0 1 1 0 1
解题分析,
本题是否存在这样的"局部"呢?
经过观察,发现第一行就是这样的一个”局部“
因为第 1 行的各开关状态确定的情况下,这些开关作用过后,将导致第1行某个亮着的灯
(假设位于第 i 列),那么唯一的方法就是按下第 2 行和第 i 列的开关。(因为第1行的
开关已经用过了,而第 3 行及其后的开关不会影响到第 1 行)
--》因此枚举第一行的各种状态
重点:相当于从上到下进行遍历,从而得到最后结果
1. 考虑空间复杂度,使用 char 类型的,共 8 个位,使用位运算实现,2 个位不用;
2. 考虑时间复杂度,要枚各个位变化情况,从 0 变化到 2^(n-1) 就遍历了各种情况
代码如下,
#include <iostream>
#include <cstring>
#include <string>
#include <memory>
using namespace std;
char oriLights[5];
char lights[5];
char results[5];
int getBits(char c, int i){
// 位右移 后 位与 取值
return (c >> i) & 1;
}
void setBits(char & c, int i, int v){
if(v) c |= ( 1 << i );
else c &= ~(1 << i);
}
void flipBit(char & c, int i){
c ^= (1 << i); // 位异或,相同为0,不同为1
}
void outputResult(int t, char result[]){
cout << "Puzzle # " << t << endl;
for(int i = 0; i < 5; i++){
for(int j = 0; j < 6; j++){
cout << getBits(result[i], j) << " ";
}
cout << endl;
}
}
int main(){
int T;
cin >> T;
for(int t = 1; t <= T; t++){
for(int i = 0; i < 5; i++){
for(int j = 0; j < 6; j++){
int s;
cin >> s; // 输入 0 或者 1
setBits(oriLights[i], j, s);
}
}
// 枚举第一行开关状态,遍历 000000-111111
for(int n = 0; n < 2^6; n++){
int switchs = n;
memcpy(lights, oriLights, sizeof(oriLights)); // 更新
// 注意 i+1 行的变化是基于 i 行的
for(int i = 0; i < 5; i++){
results[i] = switchs;
// 对第 1 行灯进行处理,后面几行就固定了
for(int j = 0; j < 6; j++){
if(getBits(switchs, j)){ // 主要从第二列开始
if(j > 0) // 1
flipBit(lights[i], j-1);
flipBit(lights[i], j);
if(j < 5)
flipBit(lights[i], j+1);
}
}
if(i < 5)
// 注意 i+1 行的变化是基于 i 行的
// 对 i + 1行灯的翻转,考虑异或逻辑
lights[i+1] ^= switchs;
switchs = lights[i]; // i+1 确定了,那么switchs也确定了
}
// 最后一行必须全灭,才能满足输入数据没问题
if(lights[4] == 0){
outputResult(t, results);
break;
}
}
}
return 0;
}
仅以一次运行为例子,程序运行如下,
这个算法主要重点就是:枚举第一行的各种状态,后面的状态都会是确定的,用位运算方式枚举,使用的空间会少很多。
3. 递归问题(汉诺塔问题)
递归和普通函数调用一样是通过栈来实现的。举一个简单的例子,阶乘程序,函数运行完成就会推栈。
int Factorial(int n){
if(n == 0)return 1;
else n*Factorial(n-1);
}
以 n = 4 为例子,有如下图解,
递归的作用:1. 替代多重循环; 2. 解决本来就是用递归形式定义的问题;3. 将问题分解成为规模更小的子问题进行求解。
举个例子,汉诺塔问题描述:汉诺塔(Hanoi Tower),又称河内塔,源于印度一个古老传说。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,任何时候,在小圆盘上都不能放大圆盘,且在三根柱子之间一次只能移动一个圆盘。程序如下,
#include <iostream>
using namespace std;
int i = 0;
void Hanoi(int n, char src, char mid, char dest){
// 将 src 座上的 n 个盘子,以 mid 座作为中转,移动到 dest 座
if( n == 1){
i += 1;
cout << "n == 1: " << i << src << "->" << dest << endl;
// 直接将盘子从 src 移动到 dest 即可
return ;
}
else{
i += 1;
Hanoi(n-1, src, dest, mid); // 先将 n-1 个盘子从 src 移动到 mid
cout << n-1 << ":------" << i << src << "->" << dest << endl;
Hanoi(n-1, mid, src, dest); // 最后将 n-1 个盘子从 mid 移动到 dest
}
return ;
}
int main(){
int n;
cin >> n; // 输入盘子数目
Hanoi(n, 'A', 'B', 'C');
return 0;
}
运行结果如下,
从入栈出栈来理解,保护现场,恢复现场。具体可以参考 B 站 懒猫老师-C语言-汉诺塔程序详解,所以程序运行顺序是这样的,
可以看出,程序运行结果和分析结果完全一致。
算法思路主要把握住,分为 src(源座), mid(中间座), src(目的座) 三个座。在递归中, n 不等于 1 时,采取先把 src 借着 dest 作为中转移动到 mid,再把 mid 以 src 作为中转移动到 dest 就可以,一直重复就可以。也可以这样理解,求解 n 个汉诺塔问题,可以化简做求解 n - 1, n - 2, 一直到 1 个的问题,这样就是实现了递归的思路,不过他们所处于的座会发生变化。以为 n = 4 为例子,
src(4 3 2 1) mid() src()
A->B src(4 3 2) mid(1) src()
A->C src(4 3) mid(1) src(2)
B->C src(4 3) mid() src(2 1)
A->B src(4) mid(3) src(2 1)
C->A src(4 1) mid(3) src(2)
C->B src(4 1) mid(3 2) src()
A->B src(4) mid(3 2 1) src()
A->C src() mid(3 2 1) src(4)
B->C src() mid(3 2) src(4 1)
B->A src(2) mid(3) src(4 1)
C->A src(2 1) mid(3) src(4)
B->C src(2 1) mid() src(4 3)
A->B src(2) mid(1) src(4)
A->C src() mid(1) src(4 3 2)
B->C src() mid() src(4 3 2 1)
4. N 皇后问题
用递归代替多重循环。 N 皇后问题描述:输入整数 n ,要求 n 个国际象棋的皇后,摆在 n*n 的棋盘上,互相不能攻击,输出全部方案。(皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子) ,用递归代替循环。
帮助理解可以在这个链接里面玩一玩试试:https://link.zhihu.com/?target=http%3A//www.brainmetrix.com/8-queens/
描述:输入一个正整数 N, 则程序输出 N 皇后问题的全部摆法。
输出结果的每一行都代表一种摆法。行里的第 i 个数字如果是 n, 就代表
第 i 行皇后放在第 n 列。皇后的行、列编号都是从 1 开始算的。
输入:
4
输出:
2 4 1 3
3 1 4 2
程序如下,
#include <iostream>
#include <cmath>
using namespace std;
int N;
int queenPos[100]; // 用来存放皇后位置,最左上角是(0, 0)
void Nqueen(int k);
int main(){
cin >> N;
Nqueen(0); // 从第 0 行开始放皇后
return 0;
}
void Nqueen(int k){
// 在 0-k-1 行皇后已经摆好的情况下,摆第 k 行及其后的皇后
int i;
if(k == N){ // 只输出 k = N 的情况
for(i = 0; i < N; i++){
cout << queenPos[i] + 1 << " ";
}
cout << endl;
return ;
}
// 逐个尝试用 i 来枚举在 k-1 皇后不冲突下第 k 个皇后的位置
// queenPos[p], 第 p 行王后放在 queenPos[p] 列
for(i = 0; i < N; i++){
int j;
for(j = 0; j < k; j++){
// 和摆好的 k 个皇后位置比较,是否冲突
// 不能在一行和一列,不能在一条对角线上(横着的差和竖着的差相等,则在对角线)
if(queenPos[j] == i || abs(queenPos[j] - i) == abs(k - j)){
break; // 冲突,测试下一个位置
}
}
// 1. 前面的 k-1 个皇后不冲突,接着再增加皇后
// 2. 最后不能达到 k = N,重新换放置方案,直到所有都枚举到
if(j == k){ // 当前选的位置 i 不冲突
queenPos[k] = i; // 将第 k 个皇后摆放在位置 i
Nqueen(k + 1);
}
}
}
以 5 皇后为例子,运行结果如下,
程序这样理解:可以通过深度优先遍历的想法来实现,通过上面程序,可以把不满足的进行剪枝,一直到满足 N 皇后的所有方法。就如上面的程序,要注意三个点,
1. if(queenPos[j] == i || abs(queenPos[j] - i) == abs(k - j))
这里对是否在同一行和同一列进行判断,并且通过(k, i)与(j, queenPos[j])是否在
对角判断,其中 i 是 k 皇后的列的位置。
2. if(j == k){ // 当前选的位置 i 不冲突
queenPos[k] = i; // 将第 k 个皇后摆放在位置 i
Nqueen(k + 1);
}
这里是要满足两点,前面的 k-1 个皇后不冲突,接着再增加皇后;最后不能达到
k = N,重新换放置方案,直到所有都枚举到。
3. for(i = 0; i < N; i++){}
这里是最主要的,在递归的过程中,会把递归程序入栈,前一个程序的 i 还在保留着,
后面的 i 开始枚举下一个皇后的的位置,如果最后不满足条件 2 则程序停止,回到前一个 i ,因此通过递归实现了循环,也实现了剪枝。
5. 表达式求值(用递归算法解决递归问题)
表达式计算描述:
输入为四则运算表达式,仅由数字,+、- 、*、/ 、( 、)。假设运算符结果都是整数。中间没有空格," / " 运算也为整数。
输入:
(2+3)*(5+7)+9/3
输出:
63
程序如下,
#include <iostream>
#include <cstring>
#include <cstdlib>
using namespace std;
int factor_value();
int term_value();
int expression_value();
int expression_value(){
int result = term_value(); // 求第一项值
bool more = true;
while(more){
char op = cin.peek(); // 看第一个字符,不取走
if(op == '+' || op == '-'){
cin.get(); // 从输入中取走一个字符
int value = term_value();
if(op == '+')result += value;
else result -= value;
}
else more = false;
}
return result;
}
int term_value(){
int result = factor_value(); // 求第一个因子的值
while(true){
char op = cin.peek(); // 看第一个字符,不取走
if(op == '*' || op == '/'){
cin.get(); // 从输入中取走一个字符
int value = factor_value();
if(op == '*')result *= value;
else result /= value;
}
else break;
}
return result;
}
int factor_value(){ // 求一个因子的值
int result = 0;
char c = cin.peek();
if( c == '('){
cin.get();
result = expression_value();
cin.get();
}
else{
while(isdigit(c)){
// c - '0' 为实际的数字(ASCII相减)
result = 10 * result + c - '0';
cin.get();
c = cin.peek();
}
}
return result;
}
int main(){
cout << expression_value() << endl;
return 0;
}
运行结果如下,
对于这个程序注意几点,
1. 采用 expression_value() 函数调用 term_value() 函数,term_value()
函数调用 factor_value() 函数,相当于确定了运算的优先级,() 大于 * / 大于
+ - , 这样确定了运算符规则,并且最后在 factor_value() 对数据进行读取操作
可以理解做数值的优先级是最低的,都服从于以上运算符规则;
2. 理解递归,继续用汉诺塔上面的方式用入栈出栈方式理解,其实这个画一下图就基本
弄清楚逻辑了;
3. 对于输入字符的处理,有几个有意思的方式
char c = cin.peek(); // 取单个字符观察,不对字符处理
cin.get(); // 读入字符,让字符串删除读入部分
while(isdigit(c)){
// c - '0' 为实际的数字(ASCII相减)
result = 10 * result + c - '0';
cin.get();
c = cin.peek();
} // 单个字符读入,然后把数字部分组合起来
4. 最后递归算法怎么把实际问题转化为数学问题,再应用代码实现,暂时还缺一些感觉
先肝上一些吧,慢慢以后就知道思路了。
6. 上台阶(用递归将问题分解成为规模更小的子问题进行求解)
上台阶问题描述:爬楼梯,可以每次走 1 级或者 2 级,输入楼梯的级数,求不同的走法数。
例如:楼梯一共有 3 级,可以每次都走 1 级,或者第一次走 1 级,第二次走 2 级,
也可以第一次走 2 级,第二次走 1 级,一共三种方法。
输入:
输入包括若干行,每行包含一个正整数 N ,代表楼梯级数,1 <= N <= 30 输出不同
的走法数,每一行输入对应一行
输出:
不同的走法数,每一行输入对应一行输出
例如,
输入:
5
8
10
输出:
8
34
89
分析如下,
n 级台阶走法 =
先走 1 级后,n - 1 级台阶的走法 +
先走 2 级后,n - 2 级台阶的走法
写作
f(n) = f(n-1) + f(n-2)
避免无穷递归的情况,以下都可以
边界条件:n < 0 0 n = 0 1 n = 1 1
n = 1 0 n = 1 1 n = 2 2
程序如下,
#include <iostream>
using namespace std;
int N;
int stairs(int n){
if(n < 0)
return 0;
if(n == 0)
return 1;
return stairs(n - 1) + stairs(n - 2);
}
int main(){
while(cin >> N){
cout << stairs(N) << endl;
}
return 0;
}
这个程序比较简单,但是思想很重要。把大问题分解为小问题,再小问题逐个解决。递归本质上就是一种循环,所以不要理解的太难。会把问题分解到最小的可以求解的时候,再往上计算,从而得到最后的结果。
比如上面的问题,
f(n) = f(n-1) + f(n-2)
f(n-1) = f(n-2) + f(n-3)
f(n-2) = f(n-3) + f(n-4)
.......
f(1) = f(0) + f(-1) // 需要终止调节得到结果
终止条件的理解为,还剩下一步时,那方法为 1 走一步;如果为 0 ,那已经走完了
当然没有方法了,返回为 0 .
7. 放苹果
把 M 个同样的苹果放在 N 个同样的盘子里,允许有的盘子空着不放,问共有多少种不同的放法?5,1,1 和 1,5,1 是同一种放法。
输入:
第一行是测试数据数目 t(0 <= t <= 20), 以下每行均包括二个整数 M 和 N , 以空格分开。1 <= M, N <= 10.
输出:
对输入的每组数据 M 和 N, 用一行输出相应的 K.
例如,
输入:
1
7 3
输出:
8
思路:
设 i 个苹果放在 k 个盘子里放法总数为 f(i, k), 那么:
k > i, f(i, k) = f(i, i) // 最多只有 i 个苹果放在 i 个盘子的放法
k <= i 时,总放法 = 有盘子为空的放法 + 没盘子为空的放法
有盘子为空的放法为:f(i, k-1)
--> 考虑至少一个盘子为空的情况时,也已经考虑到多个盘子为空情况
有盘子不为空的放法为:f(i-k, k)
--> 每个盘子都有苹果,相当于把苹果全拿走一个,对 i-k 个苹果放置的方法
所以为结果:f(i, k) = f(i, k-1) + f(i-k, k)
边界条件?
1. 当盘子数为 0 ,返回为 0;
2. 当苹果数为 0 ,返回为 1;
程序如下,
#include <iostream>
using namespace std;
// m 为苹果数,n 为盘子数
int putApple(int m, int n){
if(n > m)return putApple(m, m);
if(n == 0)return 1; // 当盘子数为 0 ,返回为 0;
if(m == 0)return 0; // 当苹果数为 0 ,返回为 1;
return putApple(m, n-1) + putApple(m-n, n);
}
int main(){
int t, m, n;
cin >> t;
while(t--){
cin >> m >> n;
cout << putApple(m, n) << endl;
}
return 0;
}
这里是用递归求解动态规划的问题,动态规划主要是要找到递归表达式和终止条件。多做题再理解,有方法的。
8. 算 24
问题描述:给出 4 个小于 10 个正整数,你可以使用加减乘除 4 种运算以及括号把 4 个数连接起来得到一个表达式。现在的问题是,是否存在一种方式使得得到的表达式的结果等于 24 .
这里加减乘除以及括号的运算结果和运算的优先级跟我们平常的定义一致(这里的除法定义是实数除法)。
输入:
输入数据包括多行,每行给出一组测试数据,包括 4 个小于 10 个正整数。最后一组测试数据中包括 4 个 0 ,表示输入结束,这组数据不用处理。
输出:
对于每一组测试数据,输出一行,如果可以得到 24 ,输出 ”Yes“ ;否则输出 ”No“
样例,
输入:
5 5 5 1
1 1 4 2
0 0 0 0
输出:
Yes
No
分析
用 n 个数算 24 ,必有两个数先算。这两个数算的结果,和剩余 n-2 个数,就构成了 n-1 个数,求 24 的问题。
枚举先算的两个数,以及这两个数的运算方式。
边界条件:一个数算 24
注意:浮点数比较是否相等,不能用 ==
代码如下,
#include <iostream>
#include <cmath>
using namespace std;
#define EPS 1e-6 // 小于这个就认为是 0
double a[5];
bool isZero(double x){
return fabs(x) <= EPS;
}
bool count24(double a[], int n){
// 用数组 a 里面的 n 个数,计算 24
if(n == 1){
if(isZero(a[0] - 24))
return true;
else
return false;
}
double b[5];
// 在两个循环中找两个数
for(int i = 0; i < n - 1; ++i){
for(int j = i + 1; j < n; ++j){
int m = 0; // 还剩 m 个数,m = n - 2
for(int k = 0; k < n; k++){
if(k != i && k != j)
// 先赋值再加加,把其余的数放进 b
b[m++] = a[k];
}
// 所有运算的可能
b[m] = a[i] + a[j];
if(count24(b, m+1))return true;
b[m] = a[i] - a[j];
if(count24(b, m+1))return true;
b[m] = a[j] - a[i];
if(count24(b, m+1))return true;
b[m] = a[i] * a[j];
if(count24(b, m+1))return true;
if(!isZero(a[j])){
b[m] = a[i] / a[j];
if(count24(b, m+1))return true;
}
if(!isZero(a[i])){
b[m] = a[j] / a[i];
if(count24(b, m+1))return true;
}
}
}
return false;
}
int main(){
//如何不停的读入输入的4个数
int i = 0;
while (true)
{
double sum = 0;
for (i = 0; i < 4; i++)
{
cin >> a[i];
sum += a[i]; // 0 0 0 0 终止,输入都是正整数
}
if (isZero(sum))
break;
bool result = count24(a, 4);
if (result)
cout << "Yes" << endl;
else
cout << "No" << endl;
}
return 0;
}
运行结果如下,
分析,这里最难理解的是这个部分,
// 所有运算的可能
b[m] = a[i] + a[j];
if(count24(b, m+1))return true;
b[m] = a[i] - a[j];
if(count24(b, m+1))return true;
b[m] = a[j] - a[i];
if(count24(b, m+1))return true;
b[m] = a[i] * a[j];
if(count24(b, m+1))return true;
if(!isZero(a[j])){
b[m] = a[i] / a[j];
if(count24(b, m+1))return true;
}
if(!isZero(a[i])){
b[m] = a[j] / a[i];
if(count24(b, m+1))return true;
}
这里对运算判断是使用递归的方法。继续利用入栈的思想来理解,当进入函数时,进行
入栈,然后程序代入新的参数继续开始。比如,
一、在第一次到 count24() 时,m = 2, 那就是 b[2] 可能为以下几种情况:
b[2] = a[i] + a[j]; // 递归返回值不满足执行下一句
b[2] = a[i] - a[j]; // 递归返回值不满足执行下一句
b[2] = a[j] - a[i]; // 递归返回值不满足执行下一句
b[2] = a[i] * a[j]; // 递归返回值不满足执行下一句
b[2] = a[i] / a[j]; // 递归返回值不满足执行下一句
b[2] = a[j] / a[i]; // 递归返回值不满足执行下一句
假如上述不满足,
i 和 j 的变化,继续枚举输入数组的元素,直到所有的都遍历到。
二、下面详细解释,执行
b[2] = a[i] + a[j];
if(count24(b, 2+1))return true;
进入 count24(b, 2+1) 程序,这时候 b 里面包含了 3 个数据:没有使用过的两个
数据和使用过运算符 + 的数据,对这里的数据再进行上面的递归,这时 m = 1,
b[1] = a[i] + a[j]; // 递归返回值不满足执行下一句
b[1] = a[i] - a[j]; // 递归返回值不满足执行下一句
b[1] = a[j] - a[i]; // 递归返回值不满足执行下一句
b[1] = a[i] * a[j]; // 递归返回值不满足执行下一句
b[1] = a[i] / a[j]; // 递归返回值不满足执行下一句
b[1] = a[j] / a[i]; // 递归返回值不满足执行下一句
假如上述不满足,
i 和 j 的变化,继续枚举输入数组的元素,直到所有的都遍历到。
三、下面详细解释,执行
b[1] = a[i] + a[j];
if(count24(b, 1+1))return true;
进入 count24(b, 1+1) 程序,这时候 b 里面包含了 2 个数据:没有使用过的1个
数据和使用过运算符 + 的数据,对这里的数据再进行上面的递归,这时 m = 0,
b[0] = a[i] + a[j]; // 递归返回值不满足执行下一句
b[0] = a[i] - a[j]; // 递归返回值不满足执行下一句
b[0] = a[j] - a[i]; // 递归返回值不满足执行下一句
b[0] = a[i] * a[j]; // 递归返回值不满足执行下一句
b[0] = a[i] / a[j]; // 递归返回值不满足执行下一句
b[0] = a[j] / a[i]; // 递归返回值不满足执行下一句
假如上述不满足,返回 false。
四、进入 count24(b, 0+1) 程序,这时候 b 里面包含了 1 个数据:没有使用过的0
个数据和使用过运算符 + 的数据,这时 m = -1,
if(n == 1){
if(isZero(b[0] - 24))
return true;
else
return false;
}
如果最后满足了,那么返回 true,如果返回 false ,就一直回到前一个递归函数。
二、总结
最后递归的理解一定从出入栈角度理解,做动态规划题时,找到递推表达式和边界条件,其余递归方法应用时,相当于循环,不过这里理解还不是很透彻,递归和循环算法复杂度可以相差多少。现在感觉心态慢慢平静下来了,做事感觉稳了一些,继续学习,按照目标进行。