☰
C++函数与数组字符串核心解析:从参数传送到递归排序
2026/10/4 7:21:23 网站建设 项目流程

这段时间帮几个学弟学妹看C++MOOC的课后作业,发现大家集中卡在了第五讲和第六讲。一问才知道,基本上都是被函数参数传递、数组当函数参数、字符串初始化这些绕得头晕,再加上有些题给的是“参考答案”但没解释为什么,抄了代码还是不会举一反三。这篇文章就把这两讲的重点拆开揉碎讲清楚,作业题的实际代码我也挑典型的重写了一遍,每一步都配了注释和易错点说明,适合正在跟MOOC进度、或者复习准备考试的同学对照着看。

先说清楚这两讲到底在讲什么。第五讲核心是函数,包括函数的定义与调用、参数传递、函数重载、递归这些;第六讲核心是数组与字符串,包括一维数组、二维数组、字符数组与字符串的基本操作,以及跟函数结合时的各种坑。这两讲在整门课里处于“从语法走向结构”的转折点——前面你写的代码基本是顺序结构,最多加个循环,到函数这块开始要求你会把一个完整程序拆成若干功能模块,这其实就是工程化思维的雏形。而数组和字符串又是最常用的数据组织形式。可以说这两讲学不扎实,指针、结构体、链表这些后面全都会受影响。

1. 内容整体设计与思路拆解

1.1 为什么第五讲是“分水岭”

很多同学在学到函数之前,写代码就是一段main打完收工,变量从上到下一路声明,逻辑长了以后自己都看不出层次感。函数这一讲之所以关键,是因为它强制你把“做什么”和“怎么做”分开:调用方只需要关心函数提供了什么能力,不关心内部怎么实现;被调函数只需要关心输入是什么、输出是什么,不关心调用方是谁。这个“职责分离”的思路,到了后面做类、做模块、做项目都绕不开。

同济MOOC第五讲的课后题,通常会有几类典型题:

  • 编写函数求两个数中的最大值、最小公倍数、最大公约数这类纯计算题;
  • 使用函数实现数组元素的排序、查找;
  • 函数重载的辨析题,让你判断调用的是哪一个函数版本;
  • 递归函数,比如用递归求阶乘、斐波那契数列。

我见过很多同学做这些题时,函数是写出来了,但完全没有“接口设计”的意识,把所有逻辑都塞进一个函数里,参数一大堆,返回值也不统一。这样做作业能过,但后面做综合实验就吃力了。所以我在下面给的参考答案里,刻意把“这个函数负责什么、参数是什么、返回什么”都标注出来。

1.2 第六讲的本质:连续内存的操作

第六讲数组,本质上是教你怎么在同一段连续内存里高效存取一批相同类型的数据。数字数组就是一批数排成一列,字符串就是一批字符排成一列并以'\0'结尾作为结束标记。这个理解到位了,后面的下标运算、指针迁移、数组传参就都好说了。

字符串这块,刚开始容易懵,因为C++里字符串的操作方式比Python、Java都“原始”得多——它没有一个“天生”的字符串类型(虽然现在有std::string,但MOOC前几讲还在用字符数组老办法)。字符数组必须手动保证结束符'\0'存在,否则一调用strlen、strcpy这种函数就会越界乱读。这一点我会在后面用专门的段落展开。

第六讲的课后题常见类型:

  • 数组逆序、数组元素求和、统计数组中某数的出现次数;
  • 冒泡排序和选择排序的实现;
  • 二分查找在有序数组中的实现;
  • 字符串反转、统计字符串中某个字符的出现次数;
  • 二维数组的转置、求最大值。

这些题几乎把后续程序竞赛、课程设计里最基础的算法操作都覆盖了。所以第六讲不是常规的“背语法”章节,而是你算法体感起步的地方。

2. 函数部分的核心细节与实操要点

2.1 参数传递:传值、传引用与传数组的区别

我批改作业时,看到最多的错误就是“为什么我函数里改了值,回到main里变量没变?”——这是典型的传值问题。

C++函数参数默认是值传递,也就是函数内部拿到的是实参的一个拷贝,你在函数里对这个拷贝做什么,都不会影响外部原来的变量。如果想在函数里修改外部变量,有两条路:传引用(int &a)或者传指针(这在后面章节才学,所以MOOC早期一般只讲引用)。

举一个最典型的例子,交换两个数的函数:

// 错误示范:传值交换,外部变量不会变 void swap_wrong(int a, int b) { int tmp = a; a = b; b = tmp; } // 正确示范:传引用交换 void swap_right(int &a, int &b) { int tmp = a; a = b; b = tmp; }

传引用本质上传的是变量的别名,函数内部操作a其实就是操作外部那个变量。写参数时看到类型后面带&,就要反应出“这里可以影响外部变量”。这个理解到位了,很多坑都能避开。

数组传参就更特别了。数组名作为实参传给函数时,C++会自动把它当作指向数组首元素的指针来传递。这意味着函数内部操作数组元素,会直接影响外部数组,不需要带引用符号。例如:

void fill_array(int arr[], int n) { for (int i = 0; i < n; i++) { arr[i] = i * i; } }

这里虽然写的是int arr[],但编译器会把它当int* arr处理。所以arr[i]本质上是通过指针访问的,改的就是原来的数组。既然数组传参是“共享数据”,那排序、修改元素这些操作就不需要返回值了,直接在原数组上动手就行。

2.2 函数重载的判定规则:别只盯着返回值

函数重载是第五讲里最容易丢分的概念。判定的核心规则是:函数名相同,参数列表不同——包括参数的类型、个数、顺序。返回值类型不同不能作为重载的依据。

很多同学写重载的时候只改返回值:

int fun(int a) { return a; } double fun(int a) { return a * 1.0; } // 错误:与上一个函数参数列表完全相同

编译器会直接报重复定义,哪怕返回类型不同也不行。因为函数调用的时候,编译器是靠实参的类型和个数来决定调用哪一个版本,它根本没法靠“你期望返回什么类型”来确定版本。

还有一个常考的细节:参数类型转换导致的重载歧义。比如有void fun(int)和void fun(double)两个版本,你调用fun(3.14f)时,编译器会因为浮点数既可以转成int也可以转成double而拿不定主意。实际中为了避免这种问题,重载版本的类型尽量区分得清楚一点,也就是数值类型和精度差异尽量别同时作为区分维度。

2.3 递归:先写终止条件,再写递归关系

第五讲的递归题目,学生最常见的错误是递归没有终止条件,或者终止条件的位置不对,导致无限递归直到栈溢出。

递归函数其实就两个要素:递归公式(把一个规模大的问题拆成规模小一点的同类型问题)和基准情形(规模足够小,可以直接返回结果不再递归)。我来用递归求斐波那契数列举个例子:

int fib(int n) { // 基准情形:n <= 1 时直接返回 if (n <= 1) { return n; } // 递归公式 return fib(n - 1) + fib(n - 2); }

看着很简单,但你让它算fib(40)试试,会非常慢,因为同样的子问题被算了无数次。这就是递归的另一个要点:递归不是万能的,它逻辑清晰但是性能代价高,实际做性能敏感的程序时,能用循环尽量用循环,或者把递归改成带备忘录的写法。不过MOOC阶段,能正确写出递归就已经足够了,性能和优化后面课程会再讲。

在作业里我还经常看到一个错误:有人把fib(0)和fib(1)的返回值写反,导致所有结果错位。写递归时,一定要先把最小的几个输入亲手算一遍,验证基准情形没有问题,再去推大一点的输入。

3. 数组、字符串与基础算法实操:从课后题到万能模板

3.1 数组的初始化与传参常见误区

第六讲一开始就讲数组初始化。很多同学的问题是不知道什么时候数组元素被自动初始化为0,什么时候是随机值。

  • int a[5] = {0};——第一个元素明确为0,其余元素默认置0;
  • int a[5];——这是局部数组,元素值不确定,一定要先赋值再使用;
  • int a[] = {1, 2, 3};——省略长度,编译器自动算出长度3;
  • char s[] = "hello";——字符串数组,长度为6,因为末尾自动加了'\0'。

数组作为函数参数时,最值得注意的一点是:在函数内部用sizeof(arr) / sizeof(arr[0])算数组长度是失效的。因为数组传参时退化成指针,sizeof(arr)在函数内得到的是一个指针的大小(64位系统下通常是8字节),而不是整个数组的大小。所以一定要额外传一个参数表示数组长度,比如int sumArray(int arr[], int n)。我改作业见过不下十次有人栽在这个坑上,函数内部循环边界算错,结果要么漏处理末尾元素,要么越界访问了内存里的垃圾值。

3.2 冒泡排序和二分查找的“标准答案”思路

排序和查找是第六讲的作业的常客。我强烈建议你不要直接背代码,而是把每一步都跟“数组下标的变化”对应起来。

冒泡排序的核心思想是每一轮把相邻元素两两比较,把最大的元素逐步“冒”到末尾。所以外层循环控制“需要执行几轮”(最多n-1轮),内层循环控制“这一轮比较到哪个下标为止”(已经归位的末尾元素不需要再参与比较)。

#include <iostream> using namespace std; // 冒泡排序:将数组a升序排列 void bubbleSort(int a[], int n) { for (int i = 0; i < n - 1; i++) { // 轮数 bool swapped = false; // 优化用:本轮是否发生交换 for (int j = 0; j < n - 1 - i; j++) { // 比较范围逐渐缩小 if (a[j] > a[j + 1]) { int t = a[j]; a[j] = a[j + 1]; a[j + 1] = t; swapped = true; } } if (!swapped) break; // 本轮没有交换,说明已经有序,提前结束 } } int main() { int a[] = {5, 2, 9, 1, 6}; int n = 5; bubbleSort(a, n); for (int i = 0; i < n; i++) { cout << a[i] << " "; } return 0; }

这里加了一个swapped标志位,数组原本就有序的情况下,第一轮扫描发现没有任何交换,程序可以提前跳出,省掉不必要的循环。这是面试里常见的优化思路,在MOOC作业里属于加分项。

二分查找的前提是数组已经有序,思路是每次取中间元素跟目标值比较,目标值小就往左半边找,大就往右半边找,区间长度每次减半。

#include <iostream> using namespace std; // 二分查找:在有序数组a中查找target // 返回找到的下标,未找到返回 -1 int binarySearch(int a[], int n, int target) { int left = 0, right = n - 1; while (left <= right) { int mid = left + (right - left) / 2; // 防止溢出 if (a[mid] == target) { return mid; } else if (a[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; } int main() { int a[] = {1, 3, 5, 7, 9}; int n = 5; cout << binarySearch(a, n, 7) << endl; // 输出3 cout << binarySearch(a, n, 4) << endl; // 输出-1 return 0; }

注意mid的写法用left + (right - left) / 2而不是(left + right) / 2。这是老生常谈的防溢出写法,虽然作业里测试数据一般不会大到让left + right溢出,但这个好习惯值得从入门就养成。

3.3 字符串数组初始化与常用函数实操

第六讲里字符串的题目,核心就一句话:字符数组必须以'\0'结尾,系统提供的字符串处理函数都依赖这个结束符来判定位数。

我给一个最典型的反转字符串的例子:

#include <iostream> #include <cstring> using namespace std; // 反转字符串:原地操作 void reverseStr(char s[]) { int len = strlen(s); int left = 0, right = len - 1; while (left < right) { char t = s[left]; s[left] = s[right]; s[right] = t; left++; right--; } } int main() { char str[] = "hello"; reverseStr(str); cout << str << endl; // 输出 olleh return 0; }

这段代码能跑通,前提就是str末尾有'\0',strlen(s)才能正确得到长度5。如果你自己构造字符数组时忘了加'\0',那strlen会一直向后找,直到在内存中碰巧遇到一个'\0',得到的结果完全不可控。这个坑太经典了,我愿意再说一遍:字符数组当字符串用,必须手动保证以'\0'结尾,尤其是在一个字符一个字符往数组里填的时候。

再补充一个字符串初始化对比表:

声明方式数组长度内容说明
char s1[] = "hi";3{'h','i','\0'}最常用,自动补结束符
char s2[] = {'h','i'};2{'h','i'}没有结束符,不是合法字符串
char s3[10] = "hi";10{'h','i','\0',...}剩余位置为'\0',安全
string s4 = "hi";由实现管理"hi"C++标准库字符串类型,功能强大

std::string在实际开发中肯定更好用,但MOOC阶段考试和作业很多还是用字符数组,所以两种方式都得会,还得知道它们之间的转换。

3.4 第5、6讲课后作业参考答案(可直接运行版)

下面这几段是我根据MOOC常见题型整理出来的完整可运行代码,每一段都包含题目、代码和注释。你做作业的时候,不要直接抄,而是“先看题目自己在编辑器中写一遍,卡住了再看这里的实现思路”,效果会好得多。

题目A:编写函数,接收一个整数n,返回n是否质数。

质数的判断思路:一个大于1的自然数,如果除了1和它本身之外没有其他因数,就是质数。我们只需检查从2到sqrt(n)范围内是否存在能整除n的数即可。为什么到sqrt(n)就够?因为如果n有因子a,那么必有对应的因子b使得a * b = n,a和b不可能都大于sqrt(n),所以只要在小因子范围内检查就行了。

#include <iostream> #include <cmath> using namespace std; // 判断质数:是返回true,不是返回false bool isPrime(int n) { if (n <= 1) return false; if (n == 2) return true; // 2是唯一一个是偶数的质数 if (n % 2 == 0) return false; // 偶数直接排除 for (int i = 3; i <= sqrt(n); i += 2) { if (n % i == 0) { return false; } } return true; } int main() { int n; cin >> n; if (isPrime(n)) { cout << n << " is prime" << endl; } else { cout << n << " is not prime" << endl; } return 0; }

这里用i += 2跳过偶数,是判断质数的一个常见优化,把循环次数减半。代码里先处理n == 2的特殊情况,再排除偶数,思路顺下来很干净。

题目B:编写函数,接收一个int数组和数组长度,返回数组元素的最大值。

这道题考察数组遍历和归约操作。我的标准写法:

#include <iostream> using namespace std; // 求数组最大值 int getMax(int arr[], int n) { int maxVal = arr[0]; // 先假设第一个元素是最大值 for (int i = 1; i < n; i++) { if (arr[i] > maxVal) { maxVal = arr[i]; } } return maxVal; } int main() { int a[] = {3, 7, 2, 9, 5}; int n = sizeof(a) / sizeof(a[0]); // 在main函数里计算数组长度是ok的 cout << getMax(a, n) << endl; // 输出9 return 0; }

注意main函数里用sizeof(a) / sizeof(a[0])是可行的,因为在main函数中数组名还没有退化成指针,sizeof(a)是整段数组的字节大小。如果你把这个计算放到getMax函数内部,就会出问题。

题目C:编写一个函数fun(int n),用递归求1到n的和。

这个题是递归入门必做,思路是sum(n) = n + sum(n-1),基准情形是sum(1) = 1。

#include <iostream> using namespace std; int sum(int n) { if (n == 1) { return 1; } return n + sum(n - 1); } int main() { cout << sum(10) << endl; // 输出55 return 0; }

写递归时一定要先想清楚基准情形覆盖所有边界输入。如果这道题允许n传入0,那么基准情形就要改成if (n <= 1) return n;才不会死循环。

题目D:用字符数组实现统计字符串中字母'a'出现的次数。

这个题是字符串遍历的基础。只需要从头到尾扫描字符数组,遇到大于0的字符就判断是否为目标字符,直到'\0'出现就停止。

#include <iostream> #include <cstring> using namespace std; int countChar(const char s[], char c) { int count = 0; int n = strlen(s); for (int i = 0; i < n; i++) { if (s[i] == c) { count++; } } return count; } int main() { char s[] = "banana"; cout << countChar(s, 'a') << endl; // 输出3 return 0; }

注意参数写成了const char s[],这代表函数承诺不会修改数组内容。在只读场景下加const是好习惯,能防止自己手滑在函数里写出修改原数组的代码。

4. 常见问题与报错排查实录

4.1 环境配置问题:Visual C++ Redistributable报错

很多同学做MOOC作业用的是Visual Studio,在别的电脑上运行自己写的程序时,经常遇到一个报错:error: Microsoft Visual C++ 14.0 or greater is required,或者是运行程序时提示缺少VCRUNTIME140.dll之类的动态库。

先说结论:这类报错并不是你的代码写得不对,而是目标电脑缺少C++运行库。用Visual Studio编译出的程序,依赖系统里有匹配版本的Visual C++ Redistributable(可再发行组件包)。解决办法有三条:

  • 方案一:在目标电脑上安装对应版本的Visual C++ Redistributable,可以从微软官网下载vc_redist.x64.exe(或x86,取决于你的程序是64位还是32位);
  • 方案二:把程序编译模式改成“静态链接”/MT,这样运行库会被直接编译进exe文件里,目标电脑不需要额外安装运行库,但exe文件体积会变大;
  • 方案三:如果只在提交作业的场景下,直接把整个Visual Studio工程或者源码交给老师/同学,让对方在自己的环境下编译运行。

我推荐作业提交时优先用方案三,因为老师那边环境齐全,重新编译一次最省事。如果是要发给没装开发环境的朋友看效果,就选方案二,省得对方还要折腾运行库。

4.2 VS Code配置C/C++运行环境的关键点

另外,现在不少同学不用Visual Studio,而是用VS Code写C++,配置环境的时候也容易卡住。这里我提两个关键点,照着做基本能跑起来。

第一,VS Code本身只是个编辑器,不包含编译器。你需要自己安装编译器。Windows上通常装MinGW-w64(一个Windows下的GCC移植版),装好后把bin目录(里面有g++.exe)添加到系统的PATH环境变量里。然后在VS Code里安装“C/C++”扩展,它会帮你调用编译器。

第二,运行程序有两种方式。一种是用扩展自带的“运行”按钮直接跑,它会自动调用编译器并生成可执行文件;另一种是自己在终端里手动编译,先cd到源码目录,再执行:

g++ main.cpp -o main ./main

手动编译的好处是你能看到完整的报错信息,方便定位代码问题。VS Code的集成终端偶尔会把编译器路径搞错,报g++: 无法将“g++”项识别为 cmdlet、函数、脚本文件或可运行程序的名称,这种基本就是PATH没配好,重开一次VS Code让环境变量生效就行。

4.3 五六讲代码常见编译错误速查表

我把批改作业时最常见的编译和逻辑错误整理成了表格,每一条都是真实踩坑记录:

错误类型典型报错或现象原因解决方案
数组下标越界程序运行崩掉,或输出异常值循环边界写错,访问了a[n]检查循环条件是否写了<= n,数组最后一个合法下标是n-1
忘了加#include‘cout’ was not declared in this scope没包含<iostream>包含头文件,使用std::命名空间
字符数组长度不足程序运行崩掉,或字符串被截断数组长度没给结束符留位置char s[5] = "hello"改为char s[6]
递归没有基准栈溢出,程序崩溃递归调用永远不停止检查递归函数最开头是否写了if条件返回
数组传参后用sizeof循环次数错误函数内sizeof(arr)得到的是指针大小额外传入数组长度参数
重载仅仅返回值不同编译报重复定义重载不依据返回值区分修改参数列表
忘记初始化变量运行结果随机局部变量默认值不确定使用前赋初值

这个表建议存下来,写作业之前先扫一眼,很多低级错误能在编译之前就被拦下来。除了这些,我还想特别强调一下:运行结果不对的时候,别急着改代码,先在关键位置用cout把中间值打印出来,看循环变量走到了哪一步、数组中间状态是什么,这样定位问题会比盯着代码脑补快很多。这也是学编程早期最值得养成的调试习惯。

5. 延伸应用:把函数+数组用到小游戏里

最后讲一个我觉得特别适合用来复习这两讲的小项目:猜数字游戏。它能把随机数、循环、函数封装、数组记录历史输入全串起来。很多人学到数组和函数时觉得“这东西能干嘛”,做完这个小游戏就会有直观感受。

先看核心代码:

#include <iostream> #include <cstdlib> #include <ctime> using namespace std; // 生成一个[min, max]范围内的随机整数 int randomInt(int min, int max) { return min + rand() % (max - min + 1); } // 判断用户猜的数与答案的关系 // 返回1表示猜大了,-1表示猜小了,0表示猜对 int compareGuess(int guess, int answer) { if (guess > answer) return 1; if (guess < answer) return -1; return 0; } int main() { srand(time(0)); // 用当前时间作为随机数种子 int target = randomInt(1, 100); // 生成1-100的随机数 int guess; int history[20]; // 记录用户的所有猜测 int count = 0; cout << "我已经想好了一个1到100之间的数字,你来猜猜看。" << endl; while (true) { cout << "输入你的猜测: "; cin >> guess; history[count] = guess; // 记录到数组 count++; int result = compareGuess(guess, target); if (result == 0) { cout << "猜对了!答案就是 " << target << endl; break; } else if (result > 0) { cout << "猜大了,再试一次。" << endl; } else { cout << "猜小了,再试一次。" << endl; } if (count >= 20) { cout << "次数已用尽,答案是 " << target << endl; break; } } cout << "你一共猜了 " << count << " 次,历史记录: "; for (int i = 0; i < count; i++) { cout << history[i] << " "; } cout << endl; return 0; }

这个程序用到的知识点正好覆盖第五、六讲:

  • randomInt和compareGuess是两个纯函数,各自职责单一,后续想加功能直接改函数内部就行;
  • history数组记录每次猜测,数组下标就是猜测次数,配合循环输出历史记录;
  • srand(time(0))是随机数初始化的关键,忘了写的话每次运行生成的随机序列都相同,游戏就没意思了;
  • 数组history[20]固定的容量20,一旦超过就可能越界,所以循环里要加上count >= 20的保护逻辑。

你可以在这个基础上扩展:把20次上限改成用户可选难度,或者增加“最接近数字”功能来提示用户“上一次太热还是太冷”。这些扩展都是对函数和数组的再次练习,做完之后五六讲的知识基本就吃透了。

根据我自己帮人改作业的经验,五六讲学得扎实不扎实,就看你能不能独立完成一个“函数拆分+数组处理”的小程序。如果一个猜数字游戏你能从头写到尾,还能把这个过程的每个函数参数、返回值、数组边界都讲明白,那这两讲就过关了。如果还是卡在某些地方,建议回去看课本上关于“形参实参”和“数组下标从0开始”的部分,再动手把上面代码逐行敲一遍。编程这东西,看懂了跟写出来之间,差的其实就是那几十次报错和调试的距离。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询