1. 项目概述:从一道PTA天梯赛真题说起
最近在带学生刷PTA(程序设计类实验辅助教学平台)的天梯赛题目,L1-027 “出租”这道题出现的频率相当高。乍一看题目描述,像是要处理一个电话号码的映射问题,很多新手会下意识地想到用map或者set这类高级数据结构。但仔细读完题目要求和样例,你会发现,这道题的核心考点其实非常“朴实”——它考察的是对数组、字符串的基本操作能力,以及一种被称为“暴力枚举”或“模拟”的解题思想。用C++的STL当然可以优雅地解决,但题目本身更鼓励甚至可以说是为“暴力实现”量身定做的。所谓“暴力”,在这里并非指代码粗糙,而是指一种直接、不取巧、按部就班模拟题目描述过程的方法。这种方法虽然时间复杂度可能不是最优,但对于数据规模明确的竞赛题(尤其是L1级别),它往往是思路最清晰、最不容易出错、也最锻炼基本功的解法。今天,我就结合这道题,详细拆解一下如何用C++进行“暴力实现”,并分享其中涉及的关键技巧和常见“坑点”。
2. 题目核心需求与逻辑拆解
2.1 问题描述还原
首先,我们得彻底理解题目要我们做什么。题目“出租”的背景是这样的:我们需要处理一个11位的手机号码字符串。例如,给定号码13505711862。我们需要完成两个主要任务:
找出号码中所有不同的数字,并按从大到小的顺序排列。以上述号码为例,出现的数字有
1, 3, 5, 0, 7, 8, 6, 2。去重后从大到小排序,得到序列8, 7, 6, 5, 3, 2, 1, 0。这个序列将被视为一个“索引表”或“座位号”列表。根据这个排序后的数字序列,生成原电话号码每个数字在该序列中的位置(索引)。这里有一个关键细节:索引是从0开始的。也就是说,我们要为原号码
13505711862的每一位,找到它在{8,7,6,5,3,2,1,0}这个列表中的下标。- 数字
1在列表中的下标是6(列表第7个位置,索引从0开始)。 - 数字
3的下标是4。 - 数字
5的下标是3。 - ... 以此类推。 最终,我们会得到一个新的索引序列:
int[] arr = {1, 3, 5, 0, 5, 7, 1, 1, 8, 6, 2}对应的索引序列是{6, 4, 3, 7, 3, 1, 6, 6, 0, 2, 5}。
- 数字
输出格式要求先输出排序后的数字序列(以逗号+空格分隔),再输出索引序列(同样以逗号+空格分隔,且格式为int[] arr = new int[]{...};)。
2.2 “暴力实现”的思维定位
为什么说这道题适合“暴力实现”?因为它的步骤非常线性,且数据规模极小(一个固定11位的字符串)。我们不需要复杂的算法优化,只需要老老实实地分步完成:
- 步骤一:遍历字符串,识别出所有出现过的数字字符。
- 步骤二:将这些数字去重。
- 步骤三:将去重后的数字进行排序(从大到小)。
- 步骤四:再次遍历原字符串,为每一位数字在排序后的列表中查找其位置。
这里的“暴力”主要体现在步骤四的“查找”操作上。我们完全可以遍历排序后的列表来匹配当前数字,从而找到其索引。对于一个最大长度为10的列表和一个长度为11的字符串,这种查找的代价(O(n*m))完全可以接受,这就是暴力查找。与之相对的“非暴力”解法可能会使用哈希表(unordered_map)来存储数字到索引的映射,将查找时间降到O(1),但代码结构会稍有不同。
注意:很多同学在这里会混淆“去重”和“排序”的顺序。必须先收集所有数字,再去重,最后排序。如果边收集边排序并试图去重,逻辑会变得复杂,容易出错。
3. 暴力实现的核心代码解析
接下来,我们一步步用C++代码实现上述逻辑。我会使用最基础的数组和循环结构,尽量避开高级STL容器,以体现“暴力”和“基础”的特点。
3.1 数据结构选择与输入处理
首先,我们需要存储原始号码、出现的数字以及最终的索引。
#include <iostream> #include <string> using namespace std; int main() { string phone; // 存储11位手机号码 cin >> phone; // 用于标记数字0-9是否出现过 bool digit_appeared[10] = {false}; // 用于存放去重后并排序的数字 int unique_digits[10]; int unique_count = 0; // 用于存放最终索引结果 int index_result[11]; }这里,digit_appeared是一个布尔数组,下标0-9对应数字0-9。这是一个非常经典的“桶”思想,用于高效去重。unique_digits数组用于存放最终排序后的不同数字,unique_count记录其数量。index_result用于存放原号码每一位对应的索引。
3.2 步骤一与步骤二:遍历、识别与去重
我们遍历电话号码字符串,将字符转换为数字,并标记其出现过。
// 步骤1 & 2: 识别并去重 for (int i = 0; i < 11; ++i) { int digit = phone[i] - '0'; // 将字符'0'-'9'转换为整数0-9 if (!digit_appeared[digit]) { digit_appeared[digit] = true; } }这段代码结束后,digit_appeared数组中值为true的位置,对应的数字就是在号码中出现过的。
3.3 步骤三:构建从大到小的排序序列
现在,我们需要根据digit_appeared数组,生成一个从大到小排序的unique_digits数组。由于数字范围只有0-9,我们可以采用一种更“暴力”但清晰的方法:直接从9到0遍历,如果该数字出现过,就加入数组。
// 步骤3: 构建从大到小的排序序列 for (int digit = 9; digit >= 0; --digit) { if (digit_appeared[digit]) { unique_digits[unique_count] = digit; unique_count++; } }这种方法巧妙地利用了下标顺序,直接得到了从大到小排序的结果,省去了显式调用排序函数的步骤,是这道题的一个小技巧。
3.4 步骤四:暴力查找生成索引序列
这是“暴力”二字体现最明显的地方。对于原号码的每一位数字,我们遍历unique_digits数组,直到找到匹配项,记录其下标。
// 步骤4: 为原号码每一位查找索引 for (int i = 0; i < 11; ++i) { int current_digit = phone[i] - '0'; for (int j = 0; j < unique_count; ++j) { if (unique_digits[j] == current_digit) { index_result[i] = j; break; // 找到后立即跳出内层循环 } } }这里的内层for循环就是一次线性查找,对于每个数字,最坏情况下需要遍历整个unique_digits数组(长度<=10)。
3.5 格式化输出
最后,按照题目要求的格式进行输出。输出需要注意逗号后的空格,以及最后没有逗号和空格。
// 输出排序后的数字序列 cout << "int[] arr = new int[]{"; for (int i = 0; i < unique_count; ++i) { if (i != 0) cout << ", "; cout << unique_digits[i]; } cout << "};" << endl; // 输出索引序列 cout << "int[] index = new int[]{"; for (int i = 0; i < 11; ++i) { if (i != 0) cout << ", "; cout << index_result[i]; } cout << "};" << endl;4. 完整代码与逐行注释
将以上所有部分组合起来,并加上详细注释,就得到了完整的“暴力实现”代码。
#include <iostream> #include <string> using namespace std; int main() { // 1. 读入电话号码字符串 string phone; cin >> phone; // 2. 初始化辅助数组 bool appeared[10] = {false}; // 标记数字0-9是否出现 int sorted_digits[10]; // 存放从大到小排序的不同数字 int sorted_cnt = 0; // 排序数字的实际个数 int index[11]; // 存放最终索引结果 // 3. 第一遍遍历:标记出现过的数字(实现去重) for (int i = 0; i < 11; ++i) { int num = phone[i] - '0'; // 字符转整数 appeared[num] = true; // 标记为已出现 } // 4. 第二遍遍历(从9到0):生成从大到小的排序序列 for (int num = 9; num >= 0; --num) { if (appeared[num]) { // 如果该数字出现过 sorted_digits[sorted_cnt] = num; // 加入排序数组 sorted_cnt++; // 计数增加 } } // 此时,sorted_digits[0]~sorted_digits[sorted_cnt-1] 就是从大到小排列的不同数字 // 5. 第三遍遍历原号码:为每一位查找在排序序列中的索引 for (int i = 0; i < 11; ++i) { int current_num = phone[i] - '0'; // 暴力查找:遍历排序数组,寻找匹配项 for (int j = 0; j < sorted_cnt; ++j) { if (sorted_digits[j] == current_num) { index[i] = j; // 记录索引(从0开始) break; // 找到后立即跳出,提高效率 } } } // 6. 格式化输出第一部分:排序后的数字序列 cout << "int[] arr = new int[]{"; for (int i = 0; i < sorted_cnt; ++i) { if (i != 0) cout << ", "; cout << sorted_digits[i]; } cout << "};" << endl; // 7. 格式化输出第二部分:索引序列 cout << "int[] index = new int[]{"; for (int i = 0; i < 11; ++i) { if (i != 0) cout << ", "; cout << index[i]; } cout << "};" << endl; return 0; }5. 常见问题与实战调试技巧
即使思路清晰,在实现过程中,尤其是竞赛环境下,还是会遇到一些典型问题。下面是我总结的几个高频“坑点”和解决技巧。
5.1 数组越界与初始化问题
- 问题:
appeared数组大小为10,对应下标0-9。如果转换数字时出错,或者访问phone字符串越界(比如假设它不是11位),会导致未定义行为。 - 排查:
- 在读取
phone后,可以加一句断言或判断:if (phone.length() != 11) { /* 处理错误 */ }。虽然题目保证输入正确,但自己代码的健壮性很重要。 - 确保
phone[i] - '0'的结果一定在0-9之间。对于合规输入,这没问题。
- 在读取
- 技巧:在本地调试时,可以使用
cout << (int)phone[i] << endl;来查看字符的ASCII码,确认减'0'操作是否正确。
5.2 输出格式错误
这是PTA判题系统最常见的失分原因之一。格式必须严格匹配,包括空格、逗号、分号、花括号。
- 坑点1:逗号与空格。题目示例是
int[] arr = new int[]{8, 7, 6, 5, 3, 2, 1, 0};注意,后面有一个空格。很多同学输出时忘了这个空格。 - 坑点2:末尾符号。序列输出最后是
};,而不是, };。在循环输出时,通常用if (i != 0) cout << “, “;来控制,这样第一个元素前不加逗号,最后一个元素后也不会有多余的逗号。 - 自查方法:将你的程序输出和题目样例输出复制到文本比较工具(或直接目测)进行逐字符对比,这是最有效的方法。
5.3 去重与排序的逻辑混淆
- 错误示范:试图在遍历原号码时,边遍历边将数字插入到一个“有序且去重”的数组中。这需要维护数组有序且唯一,逻辑复杂,容易写错。
- 正确思路:严格遵循“收集 -> 去重 -> 排序”的三段式管道操作。本例中,“收集”和“去重”通过布尔数组一次性完成,“排序”通过从9到0遍历取巧完成。逻辑分离,清晰易懂。
5.4 索引查找的优化思考
虽然我们用了暴力查找,但可以思考一下如何优化。既然我们已经有了从大到小排序的数字列表sorted_digits,并且数字范围是0-9,我们可以预计算一个映射表,将数字直接映射到其索引。
// 在生成sorted_digits后,构建映射表 int digit_to_index[10]; // 数字到索引的映射 for (int i = 0; i < sorted_cnt; ++i) { digit_to_index[sorted_digits[i]] = i; } // 然后生成index数组时,就可以直接查表,O(1)复杂度 for (int i = 0; i < 11; ++i) { index[i] = digit_to_index[phone[i] - '0']; }这种方法在查找步骤上更高效,代码也更简洁。它依然属于“模拟”或“直接实现”的范畴,但使用了更高效的数据结构(一个小的查找表)。这提醒我们,“暴力”不等于“笨拙”,在明确数据范围的情况下,用空间换时间是非常实用的策略。
5.5 使用STL的“优雅暴力”版本
为了对比,这里给出一个使用C++ STL中vector、set和find函数的版本。它本质上还是模拟过程,但利用了现成的轮子。
#include <iostream> #include <string> #include <set> #include <vector> #include <algorithm> using namespace std; int main() { string phone; cin >> phone; set<int, greater<int>> digit_set; // 从大到小排序的集合 for (char c : phone) { digit_set.insert(c - '0'); } vector<int> sorted_digits(digit_set.begin(), digit_set.end()); // 注意:set默认升序,我们用了greater<int>使其降序 vector<int> index; for (char c : phone) { int num = c - '0'; // 使用find进行查找,依然是线性查找,但代码简洁 auto it = find(sorted_digits.begin(), sorted_digits.end(), num); index.push_back(distance(sorted_digits.begin(), it)); } // 输出部分略,格式同上 // ... }这个版本更短,但需要理解set、vector、find和distance的用法。在竞赛中,两种写法都可以,基础版本更能体现算法本质,STL版本则书写更快。对于初学者,我强烈建议先从基础数组版本掌握起,再学习STL版本,这样才能真正理解底层在发生什么。
6. 从本题延伸的编程思维训练
L1-027 “出租”虽然简单,但它是一个绝佳的训练模型,涵盖了几个非常重要的编程思维:
- 问题分解与步骤化:将复杂问题拆解成几个明确的、顺序执行的子任务(输入->去重->排序->映射->输出)。这是解决任何编程问题的第一步。
- 数据表示与转换:如何用程序中的数据结构(数组、字符串)来表示现实问题中的概念(电话号码、数字列表、索引)。特别是字符数字
‘5’到整数5的转换(- ‘0’),是基础中的基础。 - 查找与映射:核心是建立从一个集合(数字)到另一个集合(索引)的对应关系。暴力查找是最直观的方法,构建映射表(如
digit_to_index)是更高效的方法,这引入了“空间换时间”的思想。 - 边界条件与格式化输出:处理最后一个元素不加逗号、严格匹配空格等细节,是编程严谨性的体现。在自动化判题系统中,格式错误和结果错误同等严重。
在实际教学中,我发现很多同学卡住,不是因为算法多难,而是卡在“字符减‘0’忘了”、“数组下标搞错”、“输出格式不对”这些非常基础的细节上。这道题就像一面镜子,能很好地反映出一个程序员的基本功是否扎实。把这道题吃透,其价值远不止于通过一道PTA题目,而是为处理更复杂的字符串和数组问题打下坚实的基础。下次当你遇到一个看似复杂的问题时,不妨试试这种“暴力”的、一步一步模拟的方法,它往往能帮你理清思路,找到突破口。