CS50 Problem Set 1: C语言编程实战
CS50 Problem Set 1 解题思路与实现
在完成 Week 1 的 C 语言基础学习后,是时候通过实际编程来巩固所学知识了。Problem Set 1 包含四个编程题,难度逐步递增,涵盖了循环、条件判断、算法设计等核心概念。
官方链接:CS50 Problem Set 1
问题1:Mario(简单版)
问题描述
还记得超级马里奥游戏中的阶梯吗?我们要用 # 字符在终端中画出一个右对齐的金字塔。
要求:
- 提示用户输入金字塔的高度(1-8之间)
- 使用
#字符绘制右对齐的金字塔
示例(高度为4):
#
##
###
####
示例(高度为8):
#
##
###
####
#####
######
#######
########
解题思路
这个问题的关键在于理解行数、空格数、井号数之间的关系:
| 行号(i) | 空格数 | 井号数 |
|---|---|---|
| 0 | 7 | 1 |
| 1 | 6 | 2 |
| 2 | 5 | 3 |
| 3 | 4 | 4 |
| … | … | … |
| i | height - i - 1 | i + 1 |
规律:
- 空格数 =
height - i - 1(随行数增加而减少) - 井号数 =
i + 1(随行数增加而增加)
代码实现
/*
* mario.c (Less Comfortable)
*
* 打印右对齐的金字塔
*/
#include <stdio.h>
#include <cs50.h>
int main(void)
{
int height;
// 使用 do-while 确保输入在 1-8 之间
do
{
height = get_int("Height: ");
}
while (height < 1 || height > 8);
// 打印金字塔
for (int i = 0; i < height; i++)
{
// 第一步:打印前导空格(右对齐)
for (int j = 0; j < height - i - 1; j++)
{
printf(" ");
}
// 第二步:打印井号
for (int j = 0; j < i + 1; j++)
{
printf("#");
}
// 第三步:换行
printf("\n");
}
return 0;
}
知识点回顾
do-while循环:先执行一次,再检查条件,适合输入验证- 嵌套循环:外层控制行数,内层控制每行的字符
- 找规律:观察输出,总结数学关系
问题2:Mario(挑战版)
问题描述
这次我们要画一个双侧金字塔,就像马里奥游戏中的两座相邻的阶梯,中间有2个空格的间隙。
示例(高度为4):
# #
## ##
### ###
#### ####
示例(高度为8):
# #
## ##
### ###
#### ####
##### #####
###### ######
####### #######
######## ########
解题思路
这个问题是简单版的扩展:
- 左侧金字塔:和简单版完全一样(空格 + 井号)
- 中间间隙:固定打印2个空格
- 右侧金字塔:打印与左侧相同数量的井号
每行结构:[空格] + [左侧#] + [2个空格] + [右侧#]
代码实现
/*
* mario.c (More Comfortable)
*
* 打印双侧金字塔
*/
#include <stdio.h>
#include <cs50.h>
int main(void)
{
int height;
// 确保输入在 1-8 之间
do
{
height = get_int("Height: ");
}
while (height < 1 || height > 8);
// 打印双侧金字塔
for (int i = 0; i < height; i++)
{
// 第一步:打印前导空格(右对齐左侧金字塔)
for (int j = 0; j < height - i - 1; j++)
{
printf(" ");
}
// 第二步:打印左侧金字塔
for (int j = 0; j < i + 1; j++)
{
printf("#");
}
// 第三步:打印中间间隙(固定2个空格)
printf(" ");
// 第四步:打印右侧金字塔
for (int j = 0; j < i + 1; j++)
{
printf("#");
}
// 第五步:换行
printf("\n");
}
return 0;
}
编程技巧
- 模块化思维:把复杂问题拆解成小步骤(空格、左侧、间隙、右侧、换行)
- 代码复用:右侧金字塔的循环和左侧完全一样
问题3:Cash(零钱找零)
问题描述
你在商店工作,需要给顾客找零。为了减少找零的硬币数量,你需要使用贪心算法。
美国硬币面值:
- 25美分(Quarter)
- 10美分(Dime)
- 5美分(Nickel)
- 1美分(Penny)
要求:给定找零金额(单位:美分),计算最少需要多少枚硬币。
示例:
Change owed: 41
4
解释:41美分 = 1个25美分 + 1个10美分 + 1个5美分 + 1个1美分 = 4枚硬币
解题思路:贪心算法(Greedy Algorithm)
贪心策略:每次都选择面值最大且不超过剩余金额的硬币。
算法步骤:
- 从最大面值(25美分)开始
- 尽可能多地使用该面值:
硬币数 += 金额 / 面值 - 更新剩余金额:
金额 %= 面值 - 重复步骤2-3,直到面值为1美分
为什么贪心算法在这里有效?
因为美国硬币的面值设计(1, 5, 10, 25)具有特殊性质:每个面值都能被更大的面值整除或有合理的倍数关系。但注意,贪心算法并不总是适用于所有硬币系统!
代码实现
/*
* cash.c
*
* 使用贪心算法计算最少硬币数
*/
#include <stdio.h>
#include <cs50.h>
int main(void)
{
int cents;
// 确保输入是正整数
do
{
cents = get_int("Change owed: ");
}
while (cents < 0);
int coins = 0; // 硬币总数
// 25美分硬币(Quarter)
coins += cents / 25;
cents %= 25;
// 10美分硬币(Dime)
coins += cents / 10;
cents %= 10;
// 5美分硬币(Nickel)
coins += cents / 5;
cents %= 5;
// 1美分硬币(Penny) - 剩余全部
coins += cents;
printf("%i\n", coins);
return 0;
}
运算符回顾
/(整数除法):41 / 25 = 1(取商)%(取模运算):41 % 25 = 16(取余数)+=(复合赋值):coins += 1等同于coins = coins + 1
测试用例
| 输入 | 计算过程 | 输出 |
|---|---|---|
| 25 | 25÷25=1 | 1 |
| 41 | 1×25 + 1×10 + 1×5 + 1×1 | 4 |
| 70 | 2×25 + 2×10 | 4 |
| 99 | 3×25 + 2×10 + 4×1 | 9 |
问题4:Credit(信用卡验证)
问题描述
验证信用卡号是否有效,并识别卡片类型。这个问题需要用到Luhn算法(也叫”模10算法”)。
卡片规则:
- AMEX(美国运通):15位,以34或37开头
- MASTERCARD(万事达):16位,以51-55开头
- VISA(维萨):13位或16位,以4开头
示例:
Number: 4003600000000014
VISA
Number: 378282246310005
AMEX
Number: 1234567890
INVALID
Luhn算法详解
算法步骤:
- 从右往左,对倒数第2位、第4位、第6位…(偶数位置)的数字乘以2
- 如果乘积是两位数(≥10),将两位数字分别相加(例如:
7×2=14 → 1+4=5) - 将所有未乘2的数字(奇数位置)相加
- 将步骤2和步骤3的和加起来
- 如果总和能被10整除,卡号有效
示例:验证卡号 4003600000000014
卡号: 4 0 0 3 6 0 0 0 0 0 0 0 0 0 1 4
位置: 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
偶数位(从右往左)
步骤1:偶数位乘2
1×2=2, 0×2=0, 0×2=0, 0×2=0, 0×2=0, 6×2=12→(1+2)=3, 0×2=0, 4×2=8
步骤2:偶数位之和
2 + 0 + 0 + 0 + 0 + 3 + 0 + 8 = 13
步骤3:奇数位之和
4 + 0 + 0 + 0 + 0 + 0 + 3 + 0 = 7
步骤4:总和
13 + 7 = 20
步骤5:检查
20 % 10 = 0 ✓ 有效!
代码实现
/*
* credit.c
*
* 使用Luhn算法验证信用卡并识别类型
*/
#include <cs50.h>
#include <stdio.h>
int main(void)
{
// 获取信用卡号
long card_number = get_long("Number: ");
// 计算卡号长度和获取前1位、前2位数字
long temp = card_number;
int length = 0;
int first_digit = 0;
int first_two_digits = 0;
// 从右往左遍历,计算长度并提取前缀
while (temp > 0)
{
first_digit = temp % 10; // 最高位会保留到最后
if (temp >= 10 && temp < 100)
{
first_two_digits = temp; // 当剩余两位时保存
}
temp /= 10;
length++;
}
// === Luhn算法验证 ===
int sum1 = 0; // 偶数位置(倒数第2、4、6...位)乘2后的和
int sum2 = 0; // 奇数位置(倒数第1、3、5...位)的数字和
temp = card_number;
int position = 1; // 位置计数器(从右往左,从1开始)
while (temp > 0)
{
int digit = temp % 10;
if (position % 2 == 0) // 偶数位置
{
int product = digit * 2;
// 如果乘积≥10,需要将两位数字分别相加
if (product >= 10)
{
sum1 += (product / 10) + (product % 10);
}
else
{
sum1 += product;
}
}
else // 奇数位置
{
sum2 += digit;
}
temp /= 10;
position++;
}
int total = sum1 + sum2;
// 检查校验和:如果总和不能被10整除,卡号无效
if (total % 10 != 0)
{
printf("INVALID\n");
return 0;
}
// === 根据长度和前缀识别卡类型 ===
if (length == 15 && (first_two_digits == 34 || first_two_digits == 37))
{
printf("AMEX\n");
}
else if (length == 16 && (first_two_digits >= 51 && first_two_digits <= 55))
{
printf("MASTERCARD\n");
}
else if ((length == 13 || length == 16) && first_digit == 4)
{
printf("VISA\n");
}
else
{
printf("INVALID\n");
}
return 0;
}
关键技巧
1. 如何获取数字的位数和前缀?
long num = 4003600000000014;
long temp = num;
int length = 0;
while (temp > 0)
{
temp /= 10; // 每次去掉最右边一位
length++; // 计数
}
// 最终 length = 16
2. 如何判断位置(从右往左)?
使用一个计数器 position,从1开始:
position % 2 == 0:偶数位置position % 2 == 1:奇数位置
3. 如何分离两位数?
对于数字 14:
- 十位:
14 / 10 = 1 - 个位:
14 % 10 = 4
测试用例
可以使用以下测试卡号(来自CS50官方):
| 卡号 | 类型 |
|---|---|
| 378282246310005 | AMEX |
| 371449635398431 | AMEX |
| 5555555555554444 | MASTERCARD |
| 5105105105105100 | MASTERCARD |
| 4111111111111111 | VISA |
| 4012888888881881 | VISA |
| 4222222222222 | VISA (13位) |
| 1234567890 | INVALID |
关于输入处理的注意事项
CS50的get_long()函数会自动处理无效输入(如字母、特殊字符),如果用户输入了非数字字符,它会重新提示用户输入,直到得到一个有效的long类型整数。
示例:
$ ./credit
Number: 4003-6000-0000-0014
Number: foo
Number: 4003600000000014
VISA
这是get_long()的内部行为,不是bug。它会过滤掉破折号和字母,一直重新提示,直到得到纯数字输入。
代码改进:使用函数抽象 ⭐
上面的代码虽然能正确工作,但所有逻辑都堆在main函数中,代码有点”臃肿”。还记得 Week 1 中我们学到的函数抽象(Abstraction)吗?
为什么需要函数抽象?
回顾 Week 1 的核心思想:
- 提高可读性:
main函数只展示主要流程,具体实现细节隐藏在各个函数中 - 便于复用:如果需要在多处使用相同功能,函数可以被反复调用
- 易于调试:每个函数职责单一,出错时容易定位
- 模块化设计:就像搭积木,每个函数是一个积木块
思考:我们可以抽象哪些功能?
看看main函数中做了哪些事情:
- ✅ 获取卡号长度 →
get_length() - ✅ 获取第一位数字 →
get_first_digit() - ✅ 获取前两位数字 →
get_first_two_digits() - ✅ Luhn算法验证 →
is_valid_luhn() - ✅ 识别卡类型 →
print_card_type()
重构后的代码
/*
* credit.c (重构版 - 使用函数抽象)
*
* 应用 Week 1 学到的函数抽象思想
*/
#include <cs50.h>
#include <stdio.h>
// ====== 函数声明 ======
int get_length(long number);
int get_first_digit(long number);
int get_first_two_digits(long number);
bool is_valid_luhn(long number);
void print_card_type(long number);
int main(void)
{
// 获取信用卡号
long card_number = get_long("Number: ");
// 使用Luhn算法验证卡号
if (!is_valid_luhn(card_number))
{
printf("INVALID\n");
return 0;
}
// 识别并打印卡类型
print_card_type(card_number);
return 0;
}
// ====== 函数定义 ======
/*
* 计算数字的位数
* 例如:get_length(4003600000000014) 返回 16
*/
int get_length(long number)
{
int length = 0;
while (number > 0)
{
number /= 10;
length++;
}
return length;
}
/*
* 获取数字的第一位(最高位)
* 例如:get_first_digit(4003600000000014) 返回 4
*/
int get_first_digit(long number)
{
while (number >= 10)
{
number /= 10;
}
return number;
}
/*
* 获取数字的前两位
* 例如:get_first_two_digits(378282246310005) 返回 37
*/
int get_first_two_digits(long number)
{
while (number >= 100)
{
number /= 10;
}
return number;
}
/*
* 使用Luhn算法验证卡号是否有效
*
* Luhn算法步骤:
* 1. 从右往左,对偶数位置的数字乘以2
* 2. 如果乘积≥10,将两位数字分别相加
* 3. 将所有数字相加
* 4. 如果总和能被10整除,则有效
*
* 返回 true 表示有效,false 表示无效
*/
bool is_valid_luhn(long number)
{
int sum1 = 0; // 偶数位置乘2后的和
int sum2 = 0; // 奇数位置的和
int position = 1;
while (number > 0)
{
int digit = number % 10;
if (position % 2 == 0) // 偶数位置
{
int product = digit * 2;
if (product >= 10)
{
// 将两位数分别相加:14 → 1+4=5
sum1 += (product / 10) + (product % 10);
}
else
{
sum1 += product;
}
}
else // 奇数位置
{
sum2 += digit;
}
number /= 10;
position++;
}
int total = sum1 + sum2;
return (total % 10 == 0);
}
/*
* 根据卡号长度和前缀识别并打印卡片类型
*
* 规则:
* - AMEX: 15位,以34或37开头
* - MASTERCARD: 16位,以51-55开头
* - VISA: 13或16位,以4开头
*/
void print_card_type(long number)
{
int length = get_length(number);
int first_digit = get_first_digit(number);
int first_two_digits = get_first_two_digits(number);
if (length == 15 && (first_two_digits == 34 || first_two_digits == 37))
{
printf("AMEX\n");
}
else if (length == 16 && (first_two_digits >= 51 && first_two_digits <= 55))
{
printf("MASTERCARD\n");
}
else if ((length == 13 || length == 16) && first_digit == 4)
{
printf("VISA\n");
}
else
{
printf("INVALID\n");
}
}
对比:两种实现的优缺点
| 对比维度 | 原始版本 | 函数抽象版本 |
|---|---|---|
| 代码行数 | ~60行 | ~130行(含注释) |
| main函数 | 复杂,包含所有逻辑 | 简洁,只有6行核心逻辑 |
| 可读性 | 需要仔细阅读才能理解 | 一眼看出程序结构 |
| 可维护性 | 修改某个功能需要在main中找 | 直接修改对应函数 |
| 可复用性 | 逻辑无法复用 | 函数可在其他地方调用 |
| 调试难度 | 出错时不知道哪部分有问题 | 可以单独测试每个函数 |
| 适合场景 | 小型程序、快速原型 | 大型项目、团队协作 |
重构版的亮点 ✨
main函数清晰明了:// 一眼就能看出程序做了什么: // 1. 获取卡号 // 2. 验证有效性 // 3. 打印卡类型- 函数命名语义化:
is_valid_luhn()- 一看就知道是验证函数,返回boolget_length()- 获取长度,返回intprint_card_type()- 打印类型,无返回值(void)
- 文档注释完善:
- 每个函数都有说明其功能、参数、返回值
- 符合专业的代码规范
- 单一职责原则:
- 每个函数只做一件事
- 如果
is_valid_luhn()有bug,只需检查这一个函数
关于注释风格的说明 📝
你可能注意到函数前面的注释采用了特殊格式。这里我使用的是简化版的文档注释,只说明函数的功能和示例。
专业代码中的文档注释(你现在不需要掌握): 在大型项目中,程序员会使用 Doxygen 或 JavaDoc 风格的注释,例如:
/**
* @brief 计算数字的位数
* @param number 要计算的数字
* @return 返回位数
*/
但对于初学者,简单的注释就足够了:
// 计算数字的位数,例如 123 返回 3
选择适合自己和团队的注释风格即可!
是否”过度抽象”了?🤔
为了理解巩固函数抽象的知识点,上面我把每个可抽象的功能都抽象出来了,其实有的是不必要的。
必要的抽象 ✅
is_valid_luhn() - 非常值得抽象
- 代码有20+行
- 逻辑复杂(两个sum,位置判断,乘2等)
- 有明确的功能:验证卡号
- 返回bool值,语义清晰
print_card_type() - 值得抽象
- 包含多个if-else判断
- 有明确的功能:识别卡类型
- 避免main函数过长
可选的抽象 ⚖️
get_length(), get_first_digit(), get_first_two_digits() - 可以讨论
支持抽象的理由:
- ✅ 让main函数更简洁
- ✅ 函数名表达了意图(自文档化)
- ✅ 如果将来需要在其他地方使用,可以直接调用
反对抽象的理由:
- ❌ 每个函数只有3-5行,非常简单
- ❌ 只在一个地方使用,没有复用
- ❌ 增加了代码跳转,阅读时需要来回翻看
更简洁的方案
因此可以只保留核心函数:
#include <cs50.h>
#include <stdio.h>
// 只声明两个最重要的函数
bool is_valid_luhn(long number);
void print_card_type(long number);
int main(void)
{
long card_number = get_long("Number: ");
if (!is_valid_luhn(card_number))
{
printf("INVALID\n");
return 0;
}
print_card_type(card_number);
return 0;
}
// Luhn算法验证
bool is_valid_luhn(long number)
{
// ... (保持不变)
}
// 识别并打印卡类型
void print_card_type(long number)
{
// 直接在这里计算长度和前缀,不单独抽象
int length = 0;
long temp = number;
while (temp > 0)
{
temp /= 10;
length++;
}
int first_digit = number;
while (first_digit >= 10)
first_digit /= 10;
int first_two = number;
while (first_two >= 100)
first_two /= 10;
// 判断卡类型
if (length == 15 && (first_two == 34 || first_two == 37))
printf("AMEX\n");
else if (length == 16 && (first_two >= 51 && first_two <= 55))
printf("MASTERCARD\n");
else if ((length == 13 || length == 16) && first_digit == 4)
printf("VISA\n");
else
printf("INVALID\n");
}
判断是否需要抽象的标准 📏
问自己这些问题:
- 代码行数:超过10-15行? → 考虑抽象
- 复用性:会在多处使用? → 必须抽象
- 复杂度:逻辑复杂,难以一眼看懂? → 应该抽象
- 命名价值:函数名能让代码更易读? → 值得抽象
- 测试需求:需要单独测试这个功能? → 应该抽象
结论:
- 对于 Problem Set 1 这个规模,两种方式都可以
- 完全抽象版:更适合学习函数抽象的思想
- 折中版:更实用,平衡了可读性和简洁性
- 原始版:逻辑最直接,但main函数较长
我的建议:
- 作业提交:使用原始版或折中版
- 学习目的:尝试完全抽象版,理解函数设计
- 未来项目:根据团队规范和项目大小决定
学习建议
对于初学者,推荐的编码流程:
- 第一步:先写出能工作的代码(像原始版本)
- 第二步:测试确保功能正确
- 第三步:识别可以抽象的部分
- 第四步:重构代码,将功能封装成函数
- 第五步:再次测试,确保重构没有引入新bug
核心原则:抽象是为了解决问题(提高可读性、复用性、可维护性),而不是为了炫技。如果一个3行的函数只用一次,抽象的价值就很有限。
总结
本次作业涉及的核心概念
- 循环控制
- 使用
for循环实现嵌套结构(Mario) - 使用
do-while验证输入 - 使用
while遍历数字的每一位(Credit)
- 使用
- 算法思维
- 找规律:观察输出,抽象出数学关系(Mario)
- 贪心算法:每次选择局部最优解(Cash)
- Luhn算法:理解和实现标准算法(Credit)
- 数学运算
- 整数除法
/和取模%的应用 - 位数计算和数字提取
- 整数除法
- 函数抽象 ⭐
- 将复杂逻辑分解为多个小函数
- 每个函数专注于单一职责
- 提高代码可读性和可维护性
- 函数命名要有意义、一看就懂
- 调试技巧
- 使用
printf打印中间变量 - 测试边界情况(如高度为1或8)
- 使用官方测试用例验证
- 使用
编程建议
- 先理解再编码:不要急于写代码,先在纸上画出几个例子,找规律
- 分步实现:把复杂问题拆解成小步骤,逐个击破
- 先实现再优化:第一遍先让代码工作,第二遍再考虑函数抽象和优化
- 充分测试:不仅测试正常情况,也要测试边界和异常情况
- 代码注释:养成写注释的习惯,解释你的思路
- 函数抽象:当一段代码超过20行或有明确的功能模块时,考虑封装成函数
扩展思考
- Mario:如果要打印菱形或其他形状,如何修改?
- Cash:如果硬币面值是
[1, 6, 10],贪心算法还有效吗?(提示:对于41美分,贪心给出4×10 + 1×1 = 5枚,但最优是4×10 + 1×1 = 5枚。试试25美分的情况) - Credit:为什么要用Luhn算法?它能防止什么样的错误?(提示:防止输入错误,如数字顺序颠倒)
- 函数抽象:
- 你能把 Cash 或 Mario 的代码也用函数抽象改写吗?
- 什么时候应该使用函数?什么时候不需要?
- 如果要写一个测试函数
test_luhn(),应该如何设计?
参考资料:
下周我们将学习数组,挑战更复杂的问题!💪