All the moves like Jagger

# C 语言哭传(三)

本期开始涉及的知识点,所含知识尽量补多一点,尽量能从一题的角度理解一个点

本期含递归 (tips: 挑战题),函数指针,今天来点大家想看的东西奥(otto 音

本文或者以后也是,涉及到大模型的回答,均使用 o1/o1-mini 模型或者 claude3.5 模型,我也会重审,基本不会误导大家

来几个填选。题不在多,在精。一道题可能自己做 1 分钟就过了,要求我给别人讲可能就是半小时,但是去研究底层的这半小时远比自己做的一分钟收获的多,收获的深

# 8.15 指针

image-20241224085232574

这个程序就是倒序输出,没什么好说的,主要是拓展的一些东西

答案:

Java

dBase

CLanguage

Pascal

# 思路知识点:

有同学不太熟悉 & 和 * 这两个指针相关基本操作,大概 talk 一下

# & 运算符和 * 运算符

  • & 运算符(取地址符):用于获取变量的内存地址。

    int x = 10;
    int *ptr = &x; //ptr 指向变量 x 的地址
  • * 运算符(解引用符):用于访问指针所指向的内存地址上的值。

    int y = *ptr; //y 的值为 x,即 10

& 运算符用于获取变量的地址。

\* 运算符用于解引用指针,访问其指向的值。

# 拓展:

  1. 数组名本身不是指针:虽然数组名在某些情况下表现得像指针,但它们在内存中是一个固定的地址,不能像指针变量那样改变指向
  2. 不能进行指针运算:你不能对数组名进行指针运算,例如 a + 1 在某些情况下是合法的,但不能改变 a 本身的地址
  3. 使用 sizeof(a) 时,得到的是整个数组的大小,而不是指针的大小
  4. sizeof(ptrA) 返回指针的大小(通常是 4 或 8 个字节,取决于系统架构)。
  5. 将数组作为函数参数传递时,实际上传递的是指向数组第一个元素的指针
  6. 在 C 语言中,数组名(例如整型数组 a[] 或是字符型数组 b[] )在大多数表达式中会退化为指向数组第一个元素的指针。这意味着在这些上下文中,你可以将数组名当作指针来使用。例如:
int a[10];
char b[20];
int *ptrA = a;    //a 退化为 int*,指向 a [0]
char *ptrB = b;   //b 退化为 char*,指向 b [0]

防止大家把字符数组初始化,字符串数组们的初始化,也提一下:

# char *a [],char a [] 与 char a [][] (含大模型 o1 的回答):

  1. 声明一:

    char *a[] = { "Pascal", "C Language", "dBase", "Java" };
  2. 声明二:

    char a[] = { "Pascal", "C Language", "dBase", "Java" };

# 分析与解释

# 1. 声明一: char *a[]
  • 类型说明:

    • 这是一个指针数组,即数组中的每个元素都是一个指向 char 类型的指针。
    • 每个指针指向一个字符串字面量(字符串常量)。
  • 内存分配:

    • 数组 a 本身在内存中分配了一组指针(通常在栈上)。
    • 每个字符串字面量(如 "Pascal" )存储在只读存储区(例如,常量区)中,多个指针指向这些字符串。
  • 可修改性:

    • 指针本身可以修改,即可以让指针指向不同的字符串。
    • 字符串内容通常是只读的,尝试修改会导致未定义行为(通常是程序崩溃)。
  • 示例:

    #include <stdio.h>
        
    int main() {
        char *a[] = { "Pascal", "C Language", "dBase", "Java" };
        // 访问第一个字符串
        printf("%s\n", a[0]); // 输出: Pascal
        
        // 修改指针,指向另一个字符串
        a[0] = "Python";
        printf("%s\n", a[0]); // 输出: Python
        
        // 尝试修改字符串内容(不推荐,可能导致崩溃)
        //a [0][0] = 'p'; // 未定义行为
        
        return 0;
    }

# 2. 声明二: char a[]

  • 类型说明:

    • 这是一个字符数组,即数组中的每个元素都是一个 char 类型的字符。
    • 然而,您的声明尝试用多个字符串字面量初始化一个字符数组,这是不合法的。
  • 问题说明:

    • 在 C 语言中, char a[] = { "Pascal", "C Language", "dBase", "Java" };非法的,因为 char a[] 只能初始化为单个字符串或单个字符数组。
    • 如果您想存储多个字符串,应使用二维字符数组指针数组
  • 可能的正确声明:

    方式一:二维字符数组

    char a[][20] = { "Pascal", "C Language", "dBase", "Java" };
    • 这里, a 是一个二维字符数组,每个子数组最多可以存储 19 个字符(第 20 个字符留给字符串结束符 \0 )。

    方式二:指针数组(与声明一相同)

    char *a[] = { "Pascal", "C Language", "dBase", "Java" };
  • 内存分配:

    • 二维字符数组在内存中分配了一个连续的字符块,用于存储所有字符串的字符。
    • 每个字符串的长度需要在声明时指定(如上例中的 20 ),以确保有足够的空间存储字符串和结束符。
  • 可修改性:

    • 字符数组中的字符可修改的,可以直接更改数组中的字符。
    • 字符串字面量(如果使用指针数组)通常是只读的,无法修改。
  • 示例:二维字符数组

    #include <stdio.h>
        
    int main() {
        char a[][20] = { "Pascal", "C Language", "dBase", "Java" };
            
        // 访问第一个字符串
        printf("%s\n", a[0]); // 输出: Pascal
        
        // 修改字符内容
        a[0][0] = 'p';
        printf("%s\n", a[0]); // 输出: pascal
        
        return 0;
    }

# 主要异同点总结

方面 char *a[] (指针数组) char a[][] (二维字符数组)
类型 数组中的每个元素是 char 指针(指向字符串) 数组中的每个元素是 char ,整体形成二维数组
内存分配 指针存储在数组中,字符串存储在只读存储区(或堆 / 静态区) 所有字符串字符存储在连续的数组内存中
可修改性 指针可修改指向,但字符串内容通常不可修改 字符数组中的字符可修改
内存大小 仅存储指针,内存占用较少,但字符串分散存储 分配足够的空间存储所有字符,内存占用较大但连续
灵活性 更灵活,可以指向不同长度的字符串 需要预先定义每个字符串的最大长度
访问方式 通过指针访问,每个字符串独立 通过二维数组下标访问,每个字符串按数组索引排列
字符串长度 每个字符串长度可以不同 每个字符串的最大长度固定(由数组声明时指定,如 20

# 选择使用哪种方式

  • 使用指针数组( char \*a[] )的情况:
    • 当字符串长度不固定且可能变化时。
    • 当需要指向不同来源的字符串(如动态分配的字符串)。
    • 节省内存,因为只存储指针,而不是为每个字符串预留固定空间。
  • 使用二维字符数组( char a[][] )的情况:
    • 当字符串长度相对固定且已知时。
    • 当需要修改字符串内容时。
    • 需要保证所有字符串在内存中连续存储,便于某些操作(如批量处理)。

# 总结

  • char \*a[] 是一个指针数组,每个元素指向一个字符串字面量,适用于字符串长度不固定且不需要修改内容的场景。
  • char a[][] 是一个二维字符数组,所有字符串的字符存储在连续的内存中,适用于字符串长度相对固定且需要修改内容的场景。

希望以上解释能帮助您更好地理解这两种声明的异同。如果您有更多问题,欢迎继续提问!

# 8.16 指针

image-20241224094616758

就是

23222^3-2^2

慢慢来,主函数传参后,f1 指向 fa,f2 指向 fb,也就是 f 调用的 fa 和 fb,fa 就是乘方 fb 就是立方。用的是函数指针而已

# 函数指针

#include <stdio.h>
// 定义函数 fa,计算 x 的平方
int fa(int x)
{
    return (x * x);
}
// 定义函数 fb,计算 x 的立方
int fb(int x)
{
    return (x * x * x);
}
// 定义函数 f,接受两个函数指针 f1 和 f2,以及一个整数 x
// 它调用 f2 (x) 减去 f1 (x) 的结果
int f(int (*f1)(int), int (*f2)(int), int x)
{
    return (f2(x) - f1(x));
}
int main()
{
    int i;
    // 调用函数 f,将 fa 和 fb 作为参数传递,并传入 x=2
    i = f(fa, fb, 2);
    printf("%d", i); // 输出结果
    return 0;
}

# 主要概念

  1. 函数指针的声明

    int (*f1)(int)
    • 解释
      • int :表示函数返回类型为 int
      • (*f1)f1 是一个指针,指向一个函数。
      • (int) :表示这个函数接受一个 int 类型的参数。
    • 整体含义f1 是一个指向接受一个 int 参数并返回 int 的函数的指针。
  2. 函数指针的使用

    在函数 f 中, f1f2 都是函数指针,分别指向不同的函数( fafb )。通过这些指针, f 函数可以调用传入的函数并执行相应的操作。

    return (f2(x) - f1(x));
    • 这里, f2(x) 调用了指针 f2 所指向的函数(即 fb(x) ),
    • f1(x) 调用了指针 f1 所指向的函数(即 fa(x) )。
    • 最终返回 fb(x) - fa(x) 的结果。
  3. main 函数中传递函数指针

    i = f(fa, fb, 2);
    • 这里, fafb 作为参数传递给函数 f 。由于 fafb 本身就是函数的名称,在这种上下文中,它们会自动转换为指向这些函数的指针。

# 拓展:函数指针的优势(含 o1 模型回答)

  1. 回调函数

    函数指针允许将函数作为参数传递,从而实现回调机制。这在实现事件驱动编程或需要在不同情况下执行不同操作时非常有用。

    void execute(int (*func)(int), int x) {
        printf("Result: %d\n", func(x));
    }
    int main() {
        execute(fa, 5); // 对于被调函数来说,传入了 fa 给指针 func,func 指向了 fa,所以就是调用 fa (5)
        execute(fb, 3); // 调用 fb (3)
        return 0;
    }
  2. 动态函数调用

    根据程序的运行状态,可以动态选择要调用的函数。

    int (*operation)(int);// 注意,现在是在主函数里,直接定义一个指针,函数指针
    if (condition) {
        operation = fa;
    } else {
        operation = fb;
    }
    int result = operation(4);// 已经有了指向的对象,加个参数即可调用
  3. 构建函数表

    可以将多个函数指针存储在数组中,方便管理和调用。

    int (*functions[2])(int) = {fa, fb};
    for(int i = 0; i < 2; i++) {
        printf("Result: %d\n", functions[i](2));// 你看懂了吗?~
    }

# 再补两句

  1. 函数指针的类型必须匹配

    声明函数指针时,返回类型和参数类型必须与所指向的函数完全一致,否则会导致未定义行为。

  2. 函数指针的声明语法可能有些复杂,尤其是在函数指针作为参数或返回值时。

    可以使用 typedef 简化声明。

    typedef int (*func_ptr)(int);
    int f(func_ptr f1, func_ptr f2, int x) {
        return f2(x) - f1(x);
    }

# 8.17 指针

image-20241224102739999

fun 函数典型的互换操作,同时初具递归雏形(大约的确不算递归)

整体函数所做的事情非常简单,单纯的逆序对调罢了

答案是 8162

做个递归吧

# 挑战:8.13 递归

image-20241224103409289

涉及了第一章讲过的取位操作,可以去复习一下

此外, +'0' :将数字转换为对应的字符。例如, 3 + '0' 结果是字符 '3'

递归在计算机里算是一个比较经典的玩法了,比较考验人类的思维。

# 递归函数解析

递归函数通常包括两个主要部分:

  1. 基准情况(Base Case): 确定递归何时停止。
  2. 递归情况(Recursive Case): 函数调用自身,以逐步逼近基准情况。

在本题中:

  • 基准情况:n / 10 等于 0 时,表示已经处理到最低位的数字,不再需要递归。
  • 递归情况:n / 10 不等于 0 时,继续处理更高位的数字。

先说一下答案:a+1 、n%10+'0' , 也就是还没到最后一位的时候指针向前就是空 1, 再加一个基本的取位操作就是空 2

难点在于理解递归

其实对我本人来说,递归的难点在于递归完成之后的事情和递归的编写,就比如这道题,递归结束后的回溯花了我一定时间去理解。我也很久没摸递归了

以下的话可能有点抽象需要去理解

# 递归

if 触发之后,调用自身,但是本次主调函数还是没执行完的,又不是循环的 break, 也就是不管被调函数递归了多少轮,主调函数终究会执行最后一句 * a = ___ 当出现了第一个执行完毕,不会调用函数的被调函数后,触发了基准情况

现在,通过 (o1), 让我们一步一步地看这个递归函数是如何工作的。

# 初始调用: convert(a, 483)
  • 参数:
    • a 指向字符数组的起始位置(即 a[0] )。
    • n = 483
  • 执行步骤:
    1. 计算 i = 483 / 10 = 48 (整除)。
    2. 因为 48 != 0 ,所以递归调用 convert(a + 1, 48)
# 第一次递归调用: convert(a + 1, 48)
  • 参数:
    • a + 1 指向数组的下一个位置(即 a[1] )。
    • n = 48
  • 执行步骤:
    1. 计算 i = 48 / 10 = 4
    2. 因为 4 != 0 ,所以递归调用 convert(a + 2, 4)
# 第二次递归调用: convert(a + 2, 4)
  • 参数:
    • a + 2 指向数组的下一个位置(即 a[2] )。
    • n = 4
  • 执行步骤:
    1. 计算 i = 4 / 10 = 0
    2. 因为 0 == 0 ,不再递归,直接执行 *a = (4 % 10) + '0' = '4' ,即 a[2] = '4'

注意, 此处将开始回溯

# 回到第一次递归调用: convert(a + 1, 48)
  • 现在继续执行之前的调用
    • 执行 *a = (48 % 10) + '0' = '8' ,即 a[1] = '8'

此处的理解就是,对于第二次那次 convert 的执行,a 相对于最后一次还是 a+1, 被传入的 n 是上一次的 i 也就是最开始的那个 n/10, 即 48, 直接执行取末尾的操作即可

# 回到初始调用: convert(a, 483)
  • 继续执行之前的调用
    • 执行 *a = (483 % 10) + '0' = '3' ,即 a[0] = '3'

此处的理解就是,对于本次 convert 的执行,a 就是字符数组的开始,直接执行取末位的操作即可

# 总结:
调用层级 参数 a 指向的位置 参数 n 执行操作 赋值结果
初始调用 a[0] 483 递归调用 convert(a+1, 48) -
第一次递归调用 a[1] 48 递归调用 convert(a+2, 4) -
第二次递归调用 a[2] 4 赋值 a[2] = '4' a[2] = '4'
回到第一次递归 a[1] 48 赋值 a[1] = '8' a[1] = '8'
回到初始调用 a[0] 483 赋值 a[0] = '3' a[0] = '3'
convert(a, 483)
└── convert(a+1, 48)
     └── convert(a+2, 4)
          ├── 赋值 a[2] = '4'
          └── 返回到 convert(a+1, 48)
     ├── 赋值 a[1] = '8'
     └── 返回到 convert(a, 483)
├── 赋值 a[0] = '3'
└── 返回到 main(此题没有)

# 拓展:递归调用栈 (入门者选择性阅读此段)

递归函数通过函数调用栈(Call Stack)来管理函数调用和返回。每一次递归调用都会在调用栈上创建一个新的栈帧,用于保存该调用的局部变量和返回地址。

# 示例:将 483 转换为 "384"
  1. 初始调用convert(a, 483)
  2. 第一次递归convert(a + 1, 48)
  3. 第二次递归convert(a + 2, 4)
  4. 递归终止:不再递归,赋值 '4'a[2]
  5. 返回到第二次调用:赋值 '8'a[1]
  6. 返回到初始调用:赋值 '3'a[0]
  7. 主函数:添加 '\0' ,完成字符串

#

# 1. 初始调用: convert(a, 483)
  • 参数

    • a 指向字符数组的起始位置( a[0] )。
    • n = 483
  • 执行步骤

    • 计算 i = 483 / 10 = 48
    • 因为 48 != 0 ,所以递归调用 convert(a + 1, 48)
  • 调用栈

    [convert(a, 483)]
# 2. 第一次递归调用: convert(a + 1, 48)
  • 参数

    • a + 1 指向数组的第二个位置( a[1] )。
    • n = 48
  • 执行步骤

    • 计算 i = 48 / 10 = 4
    • 因为 4 != 0 ,所以递归调用 convert(a + 2, 4)
  • 调用栈

    [convert(a, 483)]
    [convert(a + 1, 48)]
# 3. 第二次递归调用: convert(a + 2, 4)
  • 参数

    • a + 2 指向数组的第三个位置( a[2] )。
    • n = 4
  • 执行步骤

    • 计算 i = 4 / 10 = 0
    • 因为 0 == 0 ,不再递归,直接赋值 *a = '4'a[2] = '4'
  • 调用栈

    [convert(a, 483)]
    [convert(a + 1, 48)]
    [convert(a + 2, 4)]
  • 赋值结果

    • a[2] = '4'
# 4. 递归返回:返回到第一次递归调用
  • 完成 convert(a + 2, 4)

    • 执行 *a = (48 % 10) + '0' = '8'a[1] = '8'
  • 调用栈

    [convert(a, 483)]
    [convert(a + 1, 48)]
  • 赋值结果

    • a[1] = '8'
# 5. 递归返回:返回到初始调用
  • 完成 convert(a + 1, 48)

    • 执行 *a = (483 % 10) + '0' = '3'a[0] = '3'
  • 调用栈

    [convert(a, 483)]
  • 赋值结果

    • a[0] = '3'

# 可视化调用栈

为了更直观地理解,让我们用图示表示递归调用和返回过程。

调用栈顶部
┌────────────────────┐
convert(a + 2, 4)
│ 执行 *a = '4'
└────────────────────┘
      返回
┌────────────────────┐
convert(a + 1, 48)
│ 执行 *a = '8'
└────────────────────┘
      返回
┌────────────────────┐
convert(a, 483)
│ 执行 *a = '3'
└────────────────────┘
      返回

每一次递归调用都会在栈上新增一个栈帧(函数调用的上下文),当一个调用完成后,控制权自动返回到上一个栈帧的位置,继续执行后续的代码。

有时间细说这个递归

image-20241224140100206

先到这吧,4 道题花一上午,效率越来越低了,来两道数学去了。递归有点说法的,下个坑考虑开递归