广东计算机专升本计算机科目(C语言+数据结构)第二篇

3 阅读39分钟

一文吃透广东计算机专升本计算机科目(C语言+数据结构)第二篇:C语言进阶核心语法+数据结构线性结构全考点

在上一篇文章中,我们完整拆解了广东计算机专升本统考科目的考情规则,以及C语言从基础概念、数据类型、三大程序结构到数组与字符串的全体系基础考点,很多同学在评论区留言,说最头疼的就是C语言的指针、函数递归,还有数据结构的链表代码写不出来,完全不知道从何入手。

确实,对于专升本备考的同学来说,指针是C语言的灵魂,函数是结构化编程的核心,结构体是复杂数据封装的基础,而线性表、栈与队列是数据结构的入门基石,更是综合应用题的必考核心。这部分内容既是C语言和数据结构的重难点,也是整张试卷的拉分关键——每年计算机类考生的分数差距,80%都集中在这部分内容上。

本篇文章,我们将完全贴合2026年广东省普通专升本《计算机基础与程序设计》最新统考考纲,结合近10年真题的命题规律,延续上一篇的体系化拆解逻辑,把C语言进阶核心考点(函数、指针、结构体、文件操作)和数据结构基础与线性结构全考点,从底层逻辑到代码实现,从真题考点到易错陷阱,给你讲得明明白白。

本系列三篇内容规划回顾:

  • 第一篇:考情全解析+C语言基础到核心语法体系(基础概念、数据类型、运算符、三大程序结构、数组与字符串)
  • 第二篇(本篇):C语言进阶核心语法(函数、指针、结构体、编译预处理与文件操作)+数据结构基础与线性结构全考点
  • 第三篇:数据结构非线性结构(树、图)+查找与排序全考点+全科目备考全攻略+真题刷题技巧

无论你是零基础跨考,还是已经进入强化刷题阶段,这篇文章都能帮你攻克C语言最难的指针、函数关卡,搭建起数据结构的完整知识体系,精准抓住考试的核心得分点。


第一部分:C语言进阶核心考点全拆解

C语言的核心魅力,在于函数的模块化封装、指针的底层内存操控、结构体的自定义数据类型,这三个部分是C语言区别于其他入门语言的核心,也是专升本考试的绝对重点。同时,编译预处理和文件操作是必考的基础小题,难度低,属于必须拿满的送分题。

模块一:函数

考情定位

本模块是C语言结构化程序设计的核心,每年必考8-12分,全题型覆盖,程序阅读题、程序填空题、编程题均有高频考查。同时,函数是所有数据结构算法的实现载体——后续我们要学的线性表、排序、查找等所有算法,都是通过函数封装实现的。可以说,函数学不好,数据结构的算法代码根本无从下手,是必须100%吃透的核心模块。

核心知识点拆解
1. 函数的本质与定义

C语言是面向过程的结构化程序设计语言,而函数就是结构化编程的核心单元。函数的本质,是把完成某一特定功能的代码块封装起来,实现代码复用,减少冗余,让程序的逻辑更清晰、更易维护。

一个完整的C程序,由一个且仅有一个main函数(程序执行入口),和若干个自定义函数组成。程序的执行永远从main函数开始,通过函数调用跳转到其他函数执行,执行完成后回到main函数,最终在main函数中结束。

  • 函数的标准定义格式
    返回值类型 函数名(形式参数列表)
    {
        函数体语句; // 实现功能的代码
        return 返回值; // 函数执行结果返回
    }
    
  • 格式拆解与核心考点:
    1. 返回值类型:函数执行完成后返回的结果的数据类型,可以是int、char、double等基本类型,也可以是指针、结构体类型;如果函数没有返回值,必须写void
    2. 函数名:遵循C语言标识符命名规则,要做到见名知意,比如实现两个数求和的函数,命名为add,而不是随便写ab
    3. 形式参数列表(形参):函数定义时,用来接收调用者传入的数据的参数,多个参数用逗号分隔,每个参数都必须指定类型。如果函数不需要参数,可以写void,也可以留空。
    4. 函数体:用{}包裹的代码块,实现函数的具体功能,所有可执行语句都必须写在函数体内。
    5. return语句:两个核心作用——① 终止函数的执行;② 将返回值传递给调用者。void类型的函数可以不用写return,或者只写return;,不能返回具体值。 ⚠️ 必考易错点:return返回值的类型,必须和函数定义的返回值类型一致,否则会发生隐式类型转换,导致数据精度丢失,甚至编译错误。
2. 函数的声明与调用
  • 函数的调用规则:函数必须先声明,后调用。如果函数的定义写在调用它的代码之前,可以直接调用;如果函数的定义写在调用它的代码之后(比如自定义函数写在main函数之后),必须在调用之前对函数进行声明,否则会编译报错。
  • 函数的声明格式
    返回值类型 函数名(形参类型列表);
    
    函数声明只需要写明返回值类型、函数名、形参的类型即可,形参名可以省略,末尾必须加分号。 示例:
    // 函数声明
    int add(int a, int b); 
    // 也可以简写为 int add(int, int);
    
    int main()
    {
        int res = add(3,5); // 函数调用
        printf("%d\n", res);
        return 0;
    }
    
    // 函数定义
    int add(int a, int b)
    {
        return a + b;
    }
    
  • 函数调用的格式函数名(实际参数列表); 实际参数(实参)是调用函数时传入的具体数据,必须和形参的数量、类型、顺序一一对应。
3. 函数的参数传递:值传递与地址传递(必考难点)

函数参数传递的本质,是实参把值拷贝给形参,形参是函数内部的局部变量,和实参占用不同的内存空间。根据传递的内容不同,分为两种传递方式,这是专升本考试的高频考点,也是很多同学的理解难点。

(1)值传递

值传递是指,实参把自己的数值拷贝给形参,形参的改变不会影响到实参。因为形参和实参是完全独立的两个变量,只是数值相同,修改形参只是修改了拷贝的值,实参本身不会有任何变化。

典型示例:尝试用值传递交换两个变量的值,结果是失败的

#include <stdio.h>
// 值传递交换两个变量
void swap(int a, int b)
{
    int temp = a;
    a = b;
    b = temp;
    printf("函数内:a=%d, b=%d\n", a, b);
}

int main()
{
    int x = 3, y = 5;
    swap(x, y);
    printf("主函数:x=%d, y=%d\n", x, y);
    return 0;
}

运行结果:

函数内:a=5, b=3
主函数:x=3, y=5

可以看到,函数内的形参a和b交换成功了,但主函数的实参x和y完全没有变化,这就是值传递的核心特点:形参的改变不会影响实参

(2)地址传递

地址传递是指,实参把自己的内存地址拷贝给形参,此时形参是一个指针变量,指向实参的内存地址。通过形参的解引用操作,可以直接修改实参所在内存地址里的值,从而实现“在函数内修改实参的值”。

地址传递的核心场景:

  1. 需要在函数内修改实参的值(比如交换两个变量);
  2. 传递数组、字符串到函数中;
  3. 传递结构体到函数中,避免拷贝整个结构体的性能损耗。

典型示例:用地址传递实现两个变量的交换,结果成功

#include <stdio.h>
// 地址传递交换两个变量
void swap(int *a, int *b)
{
    int temp = *a; // 解引用,取a指向的地址里的值
    *a = *b;       // 把b指向的值,赋值给a指向的地址
    *b = temp;
}

int main()
{
    int x = 3, y = 5;
    swap(&x, &y); // 传入x和y的地址
    printf("主函数:x=%d, y=%d\n", x, y);
    return 0;
}

运行结果:

主函数:x=5, y=3

这里的核心逻辑是:我们把x和y的内存地址传给了形参指针a和b,a指向了x的地址,b指向了y的地址,通过*a*b的解引用操作,直接修改了x和y所在内存的值,所以实参被成功修改了。

⚠️ 必考核心结论:

  • 想要在函数内修改实参的值,必须使用地址传递,传入实参的地址,通过指针解引用修改;
  • 值传递永远无法修改实参本身,只能修改实参的拷贝。
4. 函数的嵌套调用与递归调用
  • 函数的嵌套调用:在一个函数的函数体内,调用另一个函数,称为嵌套调用。C语言不允许函数的嵌套定义(不能在一个函数内定义另一个函数),但允许无限层级的嵌套调用。 示例:

    int add(int a, int b) { return a + b; }
    int square(int x) { return x * x; }
    int main()
    {
        int res = square(add(2,3)); // 先调用add,结果传入square,嵌套调用
        printf("%d\n", res); // 输出25
        return 0;
    }
    
  • 函数的递归调用(必考难点):一个函数在它的函数体内,直接或间接调用它自己,称为递归调用。递归是解决复杂问题的重要方法,核心思想是把一个大规模的复杂问题,拆解为一个小规模的、和原问题逻辑完全相同的子问题,直到拆解到递归的终止条件,再逐层回溯得到结果。

    递归函数必须满足两个核心条件,缺一不可:

    1. 递归终止条件:必须有一个明确的结束条件,否则会导致无限递归,最终栈溢出程序崩溃;
    2. 递归递推公式:每一次递归调用,都要把问题的规模缩小,向终止条件靠近。

    专升本高频递归考点示例1:用递归求n的阶乘 阶乘的递推公式:n!=n×(n1)!n! = n \times (n-1)!,终止条件:0!=1!=10! = 1! = 1

    #include <stdio.h>
    long long factorial(int n)
    {
        // 递归终止条件
        if(n == 0 || n == 1)
            return 1;
        // 递归调用,拆解为子问题
        return n * factorial(n-1);
    }
    
    int main()
    {
        printf("%lld\n", factorial(5)); // 输出120,5!=5×4×3×2×1=120
        return 0;
    }
    

    专升本高频递归考点示例2:用递归求斐波那契数列的第n项 斐波那契数列的递推公式:F(n)=F(n1)+F(n2)F(n) = F(n-1) + F(n-2),终止条件:F(1)=1,F(2)=1F(1)=1, F(2)=1

    #include <stdio.h>
    int fib(int n)
    {
        // 递归终止条件
        if(n == 1 || n == 2)
            return 1;
        // 递归调用
        return fib(n-1) + fib(n-2);
    }
    
    int main()
    {
        printf("%d\n", fib(6)); // 输出8,斐波那契数列前6项:1,1,2,3,5,8
        return 0;
    }
    
5. 变量的作用域与存储类别
  • 变量的作用域:变量的有效作用范围,分为局部变量和全局变量。

    1. 局部变量:在函数内部或复合语句{}内定义的变量,作用域仅限于定义它的函数或复合语句内,离开这个范围就无法访问。函数的形参也是局部变量,只在函数内部有效。 ⚠️ 考点:局部变量在定义时如果不初始化,它的值是随机的垃圾值,不能直接使用。
    2. 全局变量:在所有函数外部定义的变量,作用域是从定义的位置开始,到整个源文件结束,所有函数都可以访问和修改全局变量。 ⚠️ 考点:全局变量在定义时如果不初始化,系统会自动初始化为0;如果局部变量和全局变量重名,在局部变量的作用域内,局部变量会屏蔽全局变量(就近原则)。
  • 变量的存储类别:决定了变量的存储位置、生命周期和作用域,专升本重点考查static静态变量,这是程序阅读题的高频考点。

    1. auto自动变量:局部变量默认的存储类别,存储在栈区,函数调用时分配内存,函数结束时释放内存,生命周期和函数调用一致。
    2. static静态变量:核心考点,分为静态局部变量和静态全局变量。
      • 静态局部变量:在函数内部用static定义的变量,存储在静态数据区,只在第一次函数调用时初始化一次,后续函数调用时不会重新初始化,会保留上一次调用结束时的值,生命周期贯穿整个程序运行期间。
      • 高频真题示例:
        #include <stdio.h>
        void fun()
        {
            static int a = 0; // 静态局部变量,只初始化一次
            a++;
            printf("%d ", a);
        }
        int main()
        {
            fun(); // 第一次调用,a初始化为0,自增后为1,输出1
            fun(); // 第二次调用,不重新初始化,a保留1,自增后为2,输出2
            fun(); // 第三次调用,a保留2,自增后为3,输出3
            return 0;
        }
        
        运行结果:1 2 3
      • 静态全局变量:用static定义的全局变量,作用域仅限于定义它的源文件内,不能被其他源文件访问。
    3. extern外部变量:用于声明在其他源文件中定义的全局变量,实现跨文件的变量访问。
    4. register寄存器变量:把变量存储在CPU的寄存器中,访问速度极快,只能用于局部变量和形参,不能取地址,现代编译器会自动优化,考试仅需了解即可。
高频考点真题例题

例1(2025年广东专升本真题·程序阅读题) 写出以下程序的运行结果:

#include <stdio.h>
int a = 5; // 全局变量
void fun()
{
    int a = 10; // 局部变量,和全局变量重名
    a++;
    printf("fun中a=%d\n", a);
}
int main()
{
    a++;
    printf("main中a=%d\n", a);
    fun();
    printf("main中a=%d\n", a);
    return 0;
}

解:运行结果:

main中a=6
fun中a=11
main中a=6

解析:主函数中修改的是全局变量a,从5自增到6;fun函数中定义了同名的局部变量a,屏蔽了全局变量,修改的是局部变量,从10自增到11,不影响全局变量的值,所以主函数最后输出的a还是6。

例2(2024年广东专升本真题·程序阅读题) 写出以下程序的运行结果:

#include <stdio.h>
int f(int n)
{
    if(n == 1)
        return 1;
    return n + f(n-1);
}
int main()
{
    printf("%d\n", f(5));
    return 0;
}

解:运行结果是15。 解析:这是递归求1到n的累加和,f(5)=5+f(4)=5+4+f(3)=5+4+3+f(2)=5+4+3+2+f(1)=5+4+3+2+1=15。

易错点避雷
  1. 函数定义的末尾不能加分号,而函数声明的末尾必须加分号,两者不能混淆。
  2. 值传递无法修改实参的值,只有地址传递,通过指针解引用才能修改实参,交换两个变量的场景是高频易错点。
  3. 递归函数必须有明确的终止条件,且每次递归都要向终止条件靠近,否则会导致无限递归,栈溢出程序崩溃。
  4. 静态局部变量只在第一次函数调用时初始化一次,后续调用会保留上一次的值,这是程序阅读题的高频陷阱,很多同学会误以为每次调用都会重新初始化。
  5. 局部变量和全局变量重名时,局部作用域内会屏蔽全局变量,遵循就近原则,不能误以为修改的是全局变量。
  6. C语言只允许函数的嵌套调用,不允许函数的嵌套定义,不能在一个函数内定义另一个函数。

模块二:指针

考情定位

指针是C语言的灵魂,是C语言区别于其他高级语言的核心特性,每年必考10-15分,全题型覆盖,是C语言的重难点,也是整张试卷的拉分核心。同时,指针和数组、函数、字符串、结构体紧密关联,更是数据结构中链表、树、图等结构的实现基础,可以说,指针学不好,C语言和数据结构就等于没入门。

很多同学觉得指针难,本质上是没有理解指针的底层逻辑——指针的本质就是内存地址,指针变量就是存储内存地址的变量,所有的指针操作,都是围绕内存地址展开的。只要搞懂了计算机的内存模型,指针就会变得非常简单。

核心知识点拆解
1. 内存地址与指针的本质

计算机的内存,就像一个巨大的字节数组,每个字节都有一个唯一的编号,这个编号就是内存地址,就像酒店每个房间都有唯一的门牌号一样。我们定义的变量,本质上就是占用了内存中的一块连续空间,变量名就是这块内存空间的别名,而变量的地址,就是这块空间的第一个字节的地址。

指针的本质,就是内存地址;指针变量,就是专门用来存储内存地址的变量。普通变量存储的是具体的数值,而指针变量存储的是另一个变量的内存地址,通过这个地址,我们可以直接找到并操作这个变量所在的内存空间,这就是指针的核心能力。

2. 指针变量的定义与核心运算符
  • 指针变量的定义格式

    数据类型 *指针变量名;
    

    格式说明:

    1. 数据类型:指针变量指向的变量的数据类型,称为指针的基类型,它决定了指针的步长(指针+1时,跳过的内存字节数);
    2. *:指针定义符,用来标识这个变量是指针变量,不是普通变量;
    3. 指针变量名:遵循C语言标识符命名规则。

    示例:

    int a = 10; // 定义一个int类型的变量a,占用4个字节的内存
    int *p;      // 定义一个int类型的指针变量p,它只能存储int类型变量的地址
    p = &a;      // 把变量a的地址赋值给指针变量p,此时p指向了a
    
  • 两个核心运算符(必考)

    1. 取地址运算符&:用来获取变量的内存地址,格式为&变量名,返回值是该变量的首地址。 示例:&a就是获取变量a的内存地址。
    2. 解引用运算符*:也叫间接访问运算符,用来访问指针变量指向的地址里的内容,格式为*指针变量名,返回值是指针指向的地址里存储的值。 示例:*p就是访问指针p指向的地址里的值,也就是变量a的值,*pa是完全等价的。

    完整示例:

    #include <stdio.h>
    int main()
    {
        int a = 10;
        int *p = &a; // 定义指针p,指向a的地址
        printf("a的值:%d\n", a);
        printf("a的地址:%p\n", &a); // %p用来输出内存地址
        printf("p的值:%p\n", p);    // p存储的是a的地址,和&a相同
        printf("*p的值:%d\n", *p);  // 解引用,访问p指向的地址里的值,和a相同
    
        *p = 20; // 通过解引用,修改p指向的地址里的值,也就是修改a的值
        printf("修改后a的值:%d\n", a); // 输出20
        return 0;
    }
    

    运行结果:

    a的值:10
    a的地址:0061FF1C
    p的值:0061FF1C
    *p的值:10
    修改后a的值:20
    

    ⚠️ 必考易错点:一定要区分*在不同场景下的含义——在指针定义时,*是定义符,标识这是一个指针变量;在其他场景下,*是解引用运算符,用来访问指针指向的内容。

3. 指针的类型与步长

指针的基类型,决定了指针的步长——也就是当指针进行加减运算时,跳过的内存字节数。指针+1,不是地址加1个字节,而是地址加上基类型占用的字节数,这是指针的核心特性,也是考试的高频考点。

示例:

#include <stdio.h>
int main()
{
    int a = 10;
    int *p = &a;
    char c = 'A';
    char *q = &c;
    double d = 3.14;
    double *r = &d;

    printf("int型指针p:%p, p+1=%p\n", p, p+1); // 地址+4,int占4字节
    printf("char型指针q:%p, q+1=%p\n", q, q+1); // 地址+1,char占1字节
    printf("double型指针r:%p, r+1=%p\n", r, r+1); // 地址+8,double占8字节
    return 0;
}

运行结果:

int型指针p:0061FF18, p+1=0061FF1C
char型指针q:0061FF17, q+1=0061FF18
double型指针r:0061FF10, r+1=0061FF18

可以看到,不同类型的指针,+1跳过的字节数完全不同,这就是指针步长的核心逻辑。

4. 指针与数组的关系(必考核心)

数组和指针有着密不可分的关系,数组名本质上是数组首元素的内存地址,是一个地址常量,不能被修改。数组的下标访问,本质上就是指针的偏移访问,两者是完全等价的。

核心等价公式(必考): 对于一维数组int a[5];,有以下等价关系:

  1. a 等价于 &a[0],都是数组首元素的地址;
  2. a[i] 等价于 *(a + i),都是访问数组第i个元素的值;
  3. &a[i] 等价于 a + i,都是数组第i个元素的地址。

这组等价公式是专升本考试的高频考点,几乎每年的程序阅读题都会考查,必须100%记住。

示例:用指针遍历数组

#include <stdio.h>
int main()
{
    int a[5] = {1,2,3,4,5};
    int *p = a; // 指针p指向数组首元素的地址,等价于p = &a[0]

    // 用指针遍历数组
    for(int i = 0; i < 5; i++)
    {
        printf("%d ", *(p + i)); // 等价于printf("%d ", a[i]);
    }
    printf("\n");

    // 指针自增遍历
    for(p = a; p < a + 5; p++)
    {
        printf("%d ", *p);
    }
    return 0;
}

运行结果:

1 2 3 4 5
1 2 3 4 5

⚠️ 必考易错点:数组名是地址常量,不能被赋值,比如a = &a[1];是非法的,会编译错误;而指针变量是变量,可以被修改,比如p = &a[1];是合法的。

5. 指针与字符串

C语言的字符串本质上是以'\0'结尾的字符数组,而字符指针是操作字符串最灵活的方式,也是考试的高频考点。

  • 字符指针定义字符串:

    char *str = "Hello World";
    

    这里的str是一个char类型的指针,指向字符串常量"Hello World"的首字符'H'的内存地址。字符串常量存储在只读数据区,不能通过指针修改字符串的内容,比如str[0] = 'h';是非法的,会导致程序崩溃。

  • 字符指针操作字符串的示例:用指针实现strlen函数的功能,求字符串长度

    #include <stdio.h>
    int my_strlen(char *str)
    {
        char *p = str;
        while(*p != '\0') // 遍历到字符串结束符'\0'停止
        {
            p++;
        }
        return p - str; // 两个同类型指针相减,得到之间的元素个数
    }
    int main()
    {
        char *s = "Hello";
        printf("字符串长度:%d\n", my_strlen(s)); // 输出5
        return 0;
    }
    
6. 指针与函数

指针在函数中的应用,主要有两个核心场景,都是考试的必考内容:

  1. 指针作为函数参数,实现地址传递:也就是我们上一个模块讲的,在函数内修改实参的值,比如交换两个变量、数组传参。 ⚠️ 核心考点:数组作为函数参数时,会自动退化为指针,传递的是数组首元素的地址,不是整个数组的拷贝。所以在函数内,用sizeof(数组名)得到的是指针的大小,不是整个数组的大小,这是高频易错点。 示例:数组作为函数参数,求数组的最大值

    #include <stdio.h>
    int get_max(int *arr, int n) // 数组退化为int*指针
    {
        int max = arr[0];
        for(int i = 1; i < n; i++)
        {
            if(arr[i] > max)
                max = arr[i];
        }
        return max;
    }
    int main()
    {
        int a[5] = {3,1,4,5,2};
        printf("最大值:%d\n", get_max(a, 5)); // 输出5
        return 0;
    }
    
  2. 函数返回值为指针(指针函数):函数的返回值可以是一个指针类型,也就是内存地址。注意:不能返回函数内局部变量的地址,因为函数结束后,局部变量的内存会被释放,返回的地址会变成野指针,导致程序崩溃。可以返回的是全局变量的地址、静态局部变量的地址、动态分配的内存地址。

7. 二级指针

二级指针,就是指向指针的指针,它存储的是一级指针变量的内存地址。

  • 定义格式:数据类型 **二级指针名;
  • 示例:
    #include <stdio.h>
    int main()
    {
        int a = 10;
        int *p = &a;  // 一级指针p,指向a的地址
        int **pp = &p; // 二级指针pp,指向一级指针p的地址
    
        printf("a的值:%d\n", a);
        printf("*p的值:%d\n", *p);
        printf("**pp的值:%d\n", **pp); // 两次解引用,先找到p,再找到a,值为10
        return 0;
    }
    
    运行结果:
    a的值:10
    *p的值:10
    **pp的值:10
    
    二级指针的核心应用场景:在函数内修改一级指针变量的值,比如链表的头插法、动态分配二维数组,是数据结构链表的基础考点。
8. 指针的安全问题:野指针与空指针
  • 空指针NULL:值为0的指针,不指向任何有效的内存地址,定义指针时如果暂时没有指向,可以初始化为NULL,避免野指针。对空指针解引用会导致程序崩溃,所以解引用指针前,必须先判断指针是否为NULL。
  • 野指针:指向的内存地址是不确定的、无效的指针,会导致不可预知的错误,甚至程序崩溃。 野指针的常见成因:
    1. 指针变量定义时没有初始化,值是随机的垃圾地址;
    2. 指针指向的内存被释放后,没有置为NULL,仍然在使用;
    3. 指针越界访问,比如数组指针超出了数组的范围。
高频考点真题例题

例1(2025年广东专升本真题·程序阅读题) 写出以下程序的运行结果:

#include <stdio.h>
int main()
{
    int a[5] = {1,3,5,7,9};
    int *p = a;
    printf("%d ", *p++);
    printf("%d ", *(++p));
    printf("%d ", *p+1);
    return 0;
}

解:运行结果是1 5 6。 解析:

  1. *p++:后缀自增,先解引用*p得到a[0]=1,然后p自增,指向a[1];
  2. *(++p):前缀自增,p先自增,指向a[2],再解引用得到5;
  3. *p+1:解引用p得到a[2]=5,再加1,结果为6。

例2(2024年广东专升本真题·单选题) 以下关于指针的说法,正确的是( ) A. 数组名是一个指针变量,可以被修改 B. int *p; *p = 10; 是合法的语句,不会出现错误 C. 指针变量的基类型决定了指针的步长 D. 两个不同类型的指针可以直接相减 解:正确答案是C。

  • A选项:数组名是地址常量,不能被修改,错误;
  • B选项:p是野指针,没有初始化,直接解引用赋值会导致程序崩溃,错误;
  • D选项:只有同类型的指针才能相减,不同类型的指针不能直接相减,错误。
易错点避雷
  1. 一定要区分*的两个含义:定义时是指针定义符,使用时是解引用运算符,不能混淆。
  2. 数组名是地址常量,不能被赋值,而指针变量是变量,可以被修改,这是两者的核心区别。
  3. 指针变量必须初始化后才能解引用使用,未初始化的野指针直接解引用,会导致程序崩溃。
  4. 不能返回函数内局部变量的地址,函数结束后局部变量的内存会被释放,返回的地址是无效的野指针。
  5. 数组作为函数参数时,会自动退化为指针,在函数内无法用sizeof计算数组的总长度,必须把数组长度作为参数传入函数。
  6. 指针的加减运算,不是地址的数值加减,而是按步长加减,步长由指针的基类型决定,这是程序阅读题的高频陷阱。

模块三:结构体与共用体

考情定位

本模块每年必考6-8分,程序阅读题、程序填空题、编程题均有涉及,是C语言中实现自定义数据类型的核心方式。更重要的是,结构体是数据结构中链表、树、图等所有复杂结构的实现基础,专升本的综合应用题中,链表的代码实现完全依赖结构体,是必须吃透的核心模块。

在实际开发中,我们经常需要把不同类型的数据组合在一起,比如一个学生的信息,包含学号(int)、姓名(char数组)、成绩(double),这些数据类型不同,但都属于同一个学生对象,用基本类型无法封装,这时候就需要用到结构体。

核心知识点拆解
1. 结构体类型的定义

结构体是用户自定义的一种复合数据类型,它可以把多个不同类型的变量组合在一起,形成一个新的类型。

  • 结构体的标准定义格式:
    struct 结构体名
    {
        数据类型 成员名1;
        数据类型 成员名2;
        // 多个成员定义
        数据类型 成员名n;
    }; // 末尾的分号绝对不能漏掉,这是高频易错点
    
  • 格式说明:
    1. struct是定义结构体的关键字,不能省略;
    2. 结构体名遵循C语言标识符命名规则,和struct一起组成完整的结构体类型名;
    3. 结构体中的每个变量,称为结构体的成员,成员可以是任意基本类型、数组、指针,甚至是其他结构体类型;
    4. 定义结构体只是定义了一种新的数据类型,不会分配内存,只有用这个类型定义变量时,才会分配内存。

示例:定义一个学生信息的结构体类型

// 定义结构体类型struct Student
struct Student
{
    int id;         // 学号
    char name[20];  // 姓名
    double score;   // 成绩
}; // 分号不能漏
2. 结构体变量的定义与初始化

定义好结构体类型后,就可以用这个类型定义结构体变量,有三种常用的定义方式:

  1. 先定义结构体类型,再定义变量(最常用,推荐):
    struct Student
    {
        int id;
        char name[20];
        double score;
    };
    // 定义结构体变量stu1和stu2
    struct Student stu1, stu2;
    
  2. 定义结构体类型的同时定义变量
    struct Student
    {
        int id;
        char name[20];
        double score;
    } stu1, stu2;
    
  3. 匿名结构体,直接定义变量:省略结构体名,只能在定义时创建变量,后续无法再用这个类型定义新的变量,不推荐使用。
  • 结构体变量的初始化
    1. 按顺序初始化:
      struct Student stu1 = {1001, "张三", 90.5};
      
    2. 指定成员初始化(C99标准支持,考试常用):
      struct Student stu2 = {.id=1002, .name="李四", .score=85.0};
      
    3. 部分成员初始化:未初始化的成员,数值类型自动初始化为0,字符类型自动初始化为'\0'。
      struct Student stu3 = {1003}; // id=1003,name全为'\0',score=0.0
      
3. 结构体成员的访问(必考)

结构体变量的成员访问有两种方式,分别对应普通结构体变量和结构体指针变量,是考试的高频考点,必须熟练掌握。

  1. 普通结构体变量:用.成员访问运算符 格式:结构体变量名.成员名 示例:

    struct Student stu1 = {1001, "张三", 90.5};
    // 访问成员
    printf("学号:%d\n", stu1.id);
    printf("姓名:%s\n", stu1.name);
    printf("成绩:%.1f\n", stu1.score);
    // 修改成员
    stu1.score = 95.0;
    
  2. 结构体指针变量:用->成员访问运算符 格式:结构体指针名->成员名,等价于(*结构体指针名).成员名 示例:

    struct Student stu1 = {1001, "张三", 90.5};
    struct Student *p = &stu1; // 定义结构体指针,指向stu1
    // 用->访问成员
    printf("学号:%d\n", p->id);
    printf("姓名:%s\n", p->name);
    // 修改成员
    p->score = 95.0;
    

    ⚠️ 必考易错点:结构体指针必须指向一个有效的结构体变量,才能用->访问成员,否则是野指针,会导致程序崩溃。

4. 结构体数组

结构体数组,就是数组的每个元素都是同一个结构体类型的变量,用于存储多个同类型的复合数据,比如一个班级50个学生的信息,用结构体数组存储是最方便的,也是专升本编程题的高频考点。

  • 结构体数组的定义与初始化:
    // 定义结构体类型
    struct Student
    {
        int id;
        char name[20];
        double score;
    };
    // 定义并初始化结构体数组,存储3个学生的信息
    struct Student stus[3] = {
        {1001, "张三", 90.5},
        {1002, "李四", 85.0},
        {1003, "王五", 92.0}
    };
    
  • 结构体数组的访问:数组下标+.运算符访问成员 示例:遍历结构体数组,输出所有学生的信息,并求平均分
    #include <stdio.h>
    struct Student
    {
        int id;
        char name[20];
        double score;
    };
    int main()
    {
        struct Student stus[3] = {
            {1001, "张三", 90.5},
            {1002, "李四", 85.0},
            {1003, "王五", 92.0}
        };
        double sum = 0.0;
        int n = 3;
        // 遍历数组
        for(int i = 0; i < n; i++)
        {
            printf("学号:%d,姓名:%s,成绩:%.1f\n", stus[i].id, stus[i].name, stus[i].score);
            sum += stus[i].score;
        }
        printf("平均分:%.1f\n", sum / n);
        return 0;
    }
    
    运行结果:
    学号:1001,姓名:张三,成绩:90.5
    学号:1002,姓名:李四,成绩:85.0
    学号:1003,姓名:王五,成绩:92.0
    平均分:89.2
    
5. 结构体与函数

结构体作为函数参数,有两种传递方式,和基本类型的传递规则一致:

  1. 值传递:把结构体变量的完整内容拷贝给形参,函数内对形参的修改不会影响实参。缺点是如果结构体很大,拷贝会消耗大量内存和时间,效率低。
  2. 地址传递:把结构体变量的地址传给形参(结构体指针),函数内通过指针解引用可以直接修改实参的内容,不需要拷贝整个结构体,效率高,是开发中最常用的方式,也是考试的重点。

示例:用地址传递,修改学生的成绩

#include <stdio.h>
struct Student
{
    int id;
    char name[20];
    double score;
};
// 结构体指针作为函数参数,地址传递
void update_score(struct Student *p, double new_score)
{
    p->score = new_score; // 通过指针修改实参的成绩
}
int main()
{
    struct Student stu = {1001, "张三", 90.5};
    update_score(&stu, 95.0); // 传入结构体变量的地址
    printf("修改后的成绩:%.1f\n", stu.score); // 输出95.0
    return 0;
}
6. 链表的基础:结构体实现链表节点

结构体的一个核心特性是:结构体的成员可以是指向自身类型的结构体指针。利用这个特性,我们可以实现链表的节点结构,这是数据结构中单链表的基础,也是专升本综合应用题的必考核心。

  • 单链表节点的结构体定义:
    // 定义链表节点结构体
    struct Node
    {
        int data;               // 数据域,存储节点的数据
        struct Node *next;      // 指针域,指向下一个节点的指针
    };
    
    每个节点包含两部分:数据域存储当前节点的数据,指针域存储下一个节点的地址,通过指针把一个个节点串联起来,就形成了链表。我们会在后面的数据结构模块,详细讲解链表的完整操作和代码实现。
7. typedef关键字:给类型起别名

typedef关键字用于给已有的数据类型起一个新的别名,简化代码,提高可读性,尤其是对于结构体类型,使用typedef可以省去每次写struct的麻烦,是考试的高频考点。

  • typedef的基本格式:typedef 原类型名 新别名;
  • 常用示例:
    1. 给基本类型起别名:
      typedef int Integer; // 给int起别名Integer
      Integer a = 10; // 等价于int a = 10;
      
    2. 给结构体类型起别名(最常用):
      // 定义结构体的同时用typedef起别名
      typedef struct Student
      {
          int id;
          char name[20];
          double score;
      } Stu; // Stu是struct Student的别名
      // 定义变量时,直接用Stu即可,不用写struct Student
      Stu stu1 = {1001, "张三", 90.5};
      
    3. 给链表节点类型起别名,简化代码:
      typedef struct Node
      {
          int data;
          struct Node *next;
      } Node, *LinkList;
      // Node是struct Node的别名,LinkList是struct Node*的别名
      Node node; // 定义一个节点
      LinkList head; // 定义链表的头指针,等价于struct Node *head;
      
8. 共用体(联合体)

共用体也叫联合体,用union关键字定义,它的所有成员共享同一块内存空间,同一时间只能存储一个成员的值,共用体的大小等于最大成员的大小。专升本考试中主要考查共用体和结构体的区别,以选择题为主。

  • 共用体的定义格式:
    union 共用体名
    {
        数据类型 成员名1;
        数据类型 成员名2;
    };
    
  • 核心考点:
    1. 共用体的所有成员共享同一块内存,修改一个成员的值,会覆盖其他成员的值;
    2. 共用体的大小等于占用内存最大的成员的大小,而结构体的大小是所有成员大小的总和(考虑内存对齐);
    3. 同一时间,共用体只能有效存储一个成员的值。

示例:

#include <stdio.h>
union Data
{
    int a;
    char b;
    double c;
};
int main()
{
    union Data d;
    printf("共用体大小:%d字节\n", sizeof(d)); // 输出8,最大成员double占8字节
    d.a = 10;
    printf("d.a=%d\n", d.a); // 输出10
    d.b = 'A';
    printf("d.b=%c\n", d.b); // 输出A
    printf("d.a=%d\n", d.a); // 输出65,被b的值覆盖了
    return 0;
}
高频考点真题例题

例1(2025年广东专升本真题·单选题) 以下关于结构体的说法,正确的是( ) A. 结构体类型定义时,系统会为其分配内存空间 B. 结构体变量的成员不能是指针类型 C. 结构体指针访问成员用->运算符,普通结构体变量用.运算符 D. 结构体变量之间不能直接用=赋值 解:正确答案是C。

  • A选项:结构体类型定义只是定义了类型,不会分配内存,只有定义变量时才会分配,错误;
  • B选项:结构体成员可以是指针类型,比如链表节点的next指针,错误;
  • D选项:同类型的结构体变量可以直接用=赋值,会逐成员拷贝,错误。

例2(2024年广东专升本真题·程序填空题) 以下程序的功能是输出结构体数组中成绩最高的学生的姓名,请补全代码:

#include <stdio.h>
struct Student
{
    char name[20];
    double score;
};
int main()
{
    struct Student stus[4] = {
        {"张三", 85.0},
        {"李四", 92.0},
        {"王五", 88.5},
        {"赵六", 90.0}
    };
    int max_index = 0;
    for(int i = 1; i < 4; i++)
    {
        if(__________)
        {
            max_index = i;
        }
    }
    printf("成绩最高的学生:%s\n", stus[max_index].name);
    return 0;
}

解:补全内容为stus[i].score > stus[max_index].score。 解析:遍历数组,比较当前学生的成绩和当前最高分学生的成绩,如果更高,就更新max_index为当前下标。

易错点避雷
  1. 结构体类型定义的末尾必须加分号,绝对不能漏掉,这是新手最常见的编译错误。
  2. 普通结构体变量用.访问成员,结构体指针用->访问成员,两者不能混淆。
  3. 结构体类型定义时不会分配内存,只有用这个类型定义变量时,才会分配内存。
  4. 共用体的所有成员共享同一块内存,修改一个成员会覆盖其他成员,而结构体的每个成员有独立的内存空间,两者的核心区别要分清。
  5. typedef只是给已有类型起别名,不是创建新的类型,不能和#define宏定义混淆,typedef是在编译阶段处理的,宏定义是在预处理阶段处理的。

模块四:编译预处理与文件操作

考情定位

本模块每年必考3-5分,以单项选择题、填空题为主,难度极低,属于必须拿满的送分题。编译预处理重点考查宏定义的陷阱,文件操作重点考查文件的打开关闭模式、基础读写函数的用法,考点非常固定,只要记住核心规则,就不会丢分。

核心知识点拆解
1. 编译预处理

C语言的编译过程分为预处理、编译、汇编、链接四个阶段,预处理阶段在正式编译之前执行,处理所有以#开头的预处理命令,预处理命令不是C语言的语句,末尾不加分号(宏定义末尾加分号会被当成替换内容的一部分)。

专升本重点考查三类预处理命令:宏定义、文件包含、条件编译,其中宏定义是必考核心。

(1)宏定义#define

宏定义的本质是字符串替换,在预处理阶段,预处理器会把代码中所有的宏名,直接替换成对应的替换文本,不做任何语法检查、不做任何计算,这是宏定义的核心特性,也是考试的高频陷阱。

宏定义分为不带参数的宏和带参数的宏两种。

  1. 不带参数的宏:用于定义符号常量,提高代码的可读性和可维护性。

    • 格式:#define 宏名 替换文本
    • 示例:
      #define PI 3.1415926 // 定义圆周率的符号常量
      #define MAX_SIZE 100 // 定义数组的最大长度
      
    • 核心考点:
      • 宏名通常用大写字母,和普通变量区分开;
      • 宏定义末尾不能加分号,否则分号会被一起替换,导致编译错误;
      • 宏替换只是简单的字符串替换,不做任何计算和语法检查。
  2. 带参数的宏:类似于函数,可以接收参数,实现简单的功能,在预处理阶段完成参数替换。

    • 格式:#define 宏名(参数列表) 替换文本
    • 示例:定义一个求平方的宏
      #define SQUARE(x) x*x
      
    • ⚠️ 必考高频陷阱:带参数的宏只是简单的字符串替换,不会先计算参数的值,再替换,所以很容易出现优先级错误。 比如上面的宏,SQUARE(3+2),替换后是3+2*3+2,结果是11,而不是我们预期的25。 解决方法:给宏的每个参数和整个替换文本都加上括号,避免优先级错误。 正确的写法:
      #define SQUARE(x) ((x)*(x))
      
      此时SQUARE(3+2)替换后是((3+2)*(3+2)),结果是25,符合预期。
  3. 宏定义的撤销:用#undef 宏名可以撤销已定义的宏,撤销后宏名不再有效。

  4. 带参数的宏和函数的区别(选择题高频考点)

    特性带参数的宏函数
    处理阶段预处理阶段,字符串替换编译阶段,生成函数代码,运行时调用
    参数类型不检查参数类型,任何类型都可以传入严格检查参数类型,必须匹配
    运行效率没有函数调用的开销,效率高有函数调用、参数传递、栈操作的开销
    代码体积每次宏替换都会展开代码,多次使用会导致代码体积变大函数代码只有一份,多次调用不会增加代码体积
    副作用容易出现参数优先级、多次计算的副作用参数只计算一次,不会有副作用
(2)文件包含#include

文件包含命令的作用是把指定的头文件内容,完整地插入到当前文件中,实现代码的复用。

  • 两种格式:
    1. #include <文件名>:用于包含系统标准头文件,比如<stdio.h><string.h>,预处理器会直接到系统的头文件目录中查找。
    2. #include "文件名":用于包含用户自定义的头文件,预处理器会先在当前源文件的目录中查找,找不到再去系统头文件目录查找。
(3)条件编译

条件编译的作用是根据指定的条件,决定哪些代码参与编译,哪些代码不参与编译,常用于跨平台开发、调试代码的开关,专升本考试仅需了解基本格式即可。 常用格式:

#ifdef 宏名
    代码段1 // 如果宏名已定义,编译代码段1
#else
    代码段2 // 如果宏名未定义,编译代码段2
#endif
2. 文件操作

C语言的文件操作,是对磁盘上的文件进行读写操作,所有的文件操作都通过标准库<stdio.h>中的函数实现,核心步骤是:打开文件 → 读写文件 → 关闭文件

C语言把文件看作是一个字节流,分为文本文件(存储ASCII码字符,比如.txt文件)和二进制文件(存储二进制数据,比如.exe、图片文件),专升本重点考查文本文件的基础操作。

(1)文件指针FILE

所有的文件操作都通过文件指针实现,FILE是系统定义在<stdio.h>中的结构体类型,存储了文件的相关信息(比如文件位置、缓冲区地址等)。

  • 文件指针的定义格式:FILE *fp;
  • 核心规则:每个打开的文件都对应一个唯一的FILE结构体,通过文件指针fp可以访问和操作这个文件。
(2)文件的打开与关闭
  1. 文件打开函数fopen:用于打开一个文件,返回对应的FILE指针,如果打开失败,返回NULL。

    • 格式:FILE *fopen(const char *filename, const char *mode);
    • 参数说明:
      • filename:要打开的文件名(可以包含路径);
      • mode:文件的打开模式,决定了文件的读写权限,是考试的核心考点。
    • 常用打开模式(必考):
      模式含义说明
      "r"只读打开一个已存在的文本文件,只能读,不能写。如果文件不存在,打开失败,返回NULL。
      "w"只写创建一个新的文本文件,只能写,不能读。如果文件已存在,会清空文件原有内容;如果文件不存在,会创建新文件。
      "a"追加打开一个文本文件,在文件末尾追加内容,不能读。如果文件不存在,会创建新文件;如果文件已存在,不会清空原有内容。
      "rb"二进制只读和"r"对应,用于二进制文件
      "wb"二进制只写和"w"对应,用于二进制文件
      "ab"二进制追加和"a"对应,用于二进制文件
      "r+"读写打开已存在的文件,可读可写,文件不存在则打开失败
      "w+"读写创建新文件,可读可写,文件已存在则清空原有内容
      "a+"读写打开文件,可读可追加写,文件不存在则创建
  2. 文件关闭函数fclose:用于关闭已打开的文件,释放文件指针和相关资源。文件操作完成后必须关闭文件,否则会导致数据丢失、内存泄漏。

    • 格式:int fclose(FILE *fp);
    • 返回值:关闭成功返回0,失败返回非0。
  • 文件打开与关闭的标准模板:
    #include <stdio.h>
    int main()
    {
        FILE *fp;
        // 打开文件,只读模式
        fp = fopen("test.txt", "r");
        // 必须判断文件是否打开成功
        if(fp == NULL)
        {
            printf("文件打开失败!\n");
            return 1; // 打开失败,退出程序
        }
        // 这里写文件读写操作
    
        // 关闭文件
        fclose(fp);
        return 0;
    }
    
    ⚠️ 必考易错点:打开文件后,必须判断返回的文件指针是否为NULL,也就是判断文件是否打开成功,否则如果文件打开失败,对NULL指针进行读写操作,会导致程序崩溃。
(3)常用的文件读写函数

专升本重点考查单个字符、字符串的读写函数,格式化读写函数,难度较低,记住基本用法即可。

  1. 单个字符读写函数fgetc和fputc

    • fputc:把单个字符写入文件,格式:int fputc(int ch, FILE *fp);,写入成功返回写入的字符,失败返回EOF。
    • fgetc:从文件中读取单个字符,格式:int fgetc(FILE *fp);,读取成功返回读取的字符,读到文件末尾返回EOF。
    • 示例:把字符串写入文件,再逐个字符读取输出
      #include <stdio.h>
      int main()
      {
          FILE *fp;
          char str[] = "Hello World";
          // 只写模式打开文件
          fp = fopen("test.txt", "w");
          if(fp == NULL)
          {
              printf("文件打开失败!\n");
              return 1;
          }
          // 逐个字符写入文件
          for(int i = 0; str[i] != '\0'; i++)
          {
              fputc(str[i], fp);
          }
          fclose(fp);
      
          // 只读模式打开文件,读取内容
          fp = fopen("test.txt", "r");
          if(fp == NULL)
          {
              printf("文件打开失败!\n");
              return 1;
          }
          char ch;
          // 逐个字符读取,直到文件末尾EOF
          while((ch = fgetc(fp)) != EOF)
          {
              printf("%c", ch);
          }
          fclose(fp);
          return 0;
      }
      
      运行结果:Hello World
  2. 字符串读写函数fgets和fputs

    • fputs:把字符串写入文件,格式:int fputs(const char *str, FILE *fp);,写入成功返回非负数,失败返回EOF。
    • fgets:从文件中读取一行字符串,格式:char *fgets(char *str, int n, FILE *fp);,最多读取n-1个字符,遇到换行符或文件末尾停止,读取成功返回str,失败返回NULL。
  3. 格式化读写函数fscanf和fprintf 这两个函数和scanf、printf用法完全一致,只是操作的对象是文件,而不是键盘和屏幕。

    • fprintf:把格式化的数据写入文件,格式:fprintf(fp, "格式控制字符串", 输出列表);
    • fscanf:从文件中读取格式化的数据,格式:fscanf(fp, "格式控制字符串", 地址列表);
    • 示例:把学生信息写入文件,再读取出来
      #include <stdio.h>
      struct Student
      {
          int id;
          char name[20];
          double score;
      };
      int main()
      {
          FILE *fp;
          struct Student stu = {1001, "张三", 90.5};
          // 只写模式打开文件
          fp = fopen("student.txt", "w");
          if(fp == NULL)
          {
              printf("文件打开失败!\n");
              return 1;
          }
          // 格式化写入文件
          fprintf(fp, "%d %s %.1f", stu.id, stu.name, stu.score);
          fclose(fp);
      
          // 读取文件内容
          struct Student read_stu;
          fp = fopen("student.txt", "r");
          if(fp == NULL)
          {
              printf("文件打开失败!\n");
              return 1;
          }
          fscanf(fp, "%d %s %lf", &read_stu.id, read_stu.name, &read_stu.score);
          printf("学号:%d,姓名:%s,成绩:%.1f\n", read_stu.id, read_stu.name, read_stu.score);
          fclose(fp);
          return 0;
      }
      
高频考点真题例题

例1(2025年广东专升本真题·单选题) 以下宏定义中,能正确实现求两个数最大值的是( ) A. #define MAX(a,b) a > b ? a : b B. #define MAX(a,b) (a > b) ? a : b C. #define MAX(a,b) ((a) > (b) ? (a) : (b)) D. #define MAX(a,b) ((a > b) ? a : b) 解:正确答案是C。 解析:带参数的宏必须给每个参数和整个表达式都加上括号,避免优先级错误。比如MAX(3+2, 1+4),如果不加括号,会出现优先级问题,只有C选项的写法是完全正确的。

例2(2024年广东专升本真题·单选题) 用fopen函数打开一个已存在的文本文件,想要修改文件的内容,同时保留原有内容,打开模式应选择( ) A. "r" B. "w" C. "a" D. "r+" 解:正确答案是D。 解析:

  • A选项"r"是只读,不能修改;
  • B选项"w"会清空原有内容;
  • C选项"a"只能追加,不能修改原有内容;
  • D选项"r+"是读写模式,打开已存在的文件,可读可写,保留原有内容。
易错点避雷
  1. 宏定义的末尾不能加分号,否则分号会被当成替换内容的一部分,导致编译错误。
  2. 带参数的宏一定要给每个参数和整个表达式都加上括号,避免运算符优先级导致的错误,这是考试的高频陷阱。
  3. 打开文件后,必须判断文件指针是否为NULL,也就是判断文件是否打开成功,否则会导致程序崩溃。
  4. "w"模式打开文件时,会清空文件的原有内容,如果只是想读取或追加内容,不能用"w"模式。
  5. 文件操作完成后,必须用fclose关闭文件,否则会导致数据丢失、内存泄漏。
  6. EOF是文件结束标志,只能用于文本文件的结束判断,二进制文件要用feof函数判断。

第二部分:数据结构基础与线性结构全考点

C语言是数据结构的实现工具,而数据结构是计算机专业的核心课程,也是专升本考试的拉分核心,每年占比100-110分,综合应用题几乎全部出自数据结构。很多同学觉得数据结构难,本质上是没有理解数据结构的核心——数据结构研究的是数据的逻辑结构、存储结构,以及对数据的操作方法,核心是用合适的结构存储数据,用高效的算法操作数据

本篇我们从数据结构的基础概念入手,拆解线性结构的全考点:线性表、栈与队列,这是数据结构的入门基础,也是后续树、图等非线性结构的前提,更是综合应用题的必考核心。

模块五:数据结构基础概念

考情定位

本模块是数据结构的入门基础,每年必考3-5分,以单项选择题为主,考点非常固定,是必须拿分的送分题。尤其是算法的时间复杂度、空间复杂度,是所有算法的基础,贯穿整个数据结构的学习,必须100%吃透。

核心知识点拆解
1. 数据结构的定义与三要素

数据结构是相互之间存在一种或多种特定关系的数据元素的集合,它研究的是计算机中存储和组织数据的方式,核心目标是提高数据的存储效率和操作效率。

数据结构包含三大核心要素:逻辑结构、存储结构(物理结构)、数据的运算,三者的关系是:逻辑结构是核心,存储结构是逻辑结构在计算机中的实现,数据的运算依赖于存储结构。

(1)逻辑结构

逻辑结构描述的是数据元素之间的逻辑关系,和数据在计算机中的存储位置无关,分为两大类:线性结构和非线性结构,这是选择题的高频考点。

  1. 线性结构:数据元素之间是一对一的线性关系,有唯一的第一个元素和最后一个元素,除了第一个元素,每个元素有且仅有一个直接前驱;除了最后一个元素,每个元素有且仅有一个直接后继。 常见的线性结构:线性表、栈、队列、串、数组。
  2. 非线性结构:数据元素之间不是一对一的关系,而是一对多或多对多的关系。 常见的非线性结构:树(一对多)、图(多对多)、集合(无关系)。
(2)存储结构(物理结构)

存储结构是逻辑结构在计算机内存中的实现方式,也就是数据元素在内存中实际的存储方式,专升本重点考查两种最核心的存储结构:顺序存储和链式存储。

  1. 顺序存储结构:用一组地址连续的存储单元依次存储数据元素,逻辑上相邻的数据元素,物理上也相邻,通常用数组实现。
    • 优点:支持随机存取,也就是可以通过下标直接访问任意元素,访问速度快,时间复杂度O(1);存储密度高,没有额外的指针开销。
    • 缺点:插入和删除操作需要移动大量元素,时间复杂度O(n);必须预先分配固定大小的内存空间,容易造成内存浪费或溢出。
  2. 链式存储结构:用一组任意的存储单元存储数据元素,存储单元可以是连续的,也可以是不连续的。逻辑上相邻的数据元素,物理上不一定相邻,通过指针来表示数据元素之间的逻辑关系。
    • 优点:插入和删除操作只需要修改指针,不需要移动元素,时间复杂度O(1);不需要预先分配内存,动态分配内存,不会造成内存浪费。
    • 缺点:不支持随机存取,只能顺序存取,访问元素需要从头遍历,时间复杂度O(n);每个元素都需要额外的指针存储空间,存储密度低。

除了顺序存储和链式存储,还有索引存储和散列存储(哈希存储),我们会在后续的查找模块详细讲解散列存储。

(3)数据的运算

数据的运算就是对数据元素执行的操作,最常用的运算有:初始化、取值、查找、插入、删除、遍历、排序等。数据运算的实现依赖于存储结构,同一种逻辑结构,不同的存储结构,运算的实现方式和效率完全不同。

2. 算法的定义与特性

算法是对特定问题求解步骤的描述,是指令的有限序列,每条指令表示一个或多个操作。数据结构和算法是密不可分的:数据结构是算法的载体,算法是数据结构的应用,没有算法,数据结构就没有意义;没有合适的数据结构,算法就无法高效实现。

算法必须满足以下五个基本特性,缺一不可,这是选择题的高频考点:

  1. 有穷性:算法必须在执行有限步之后结束,且每一步都在有限的时间内完成。这是算法和程序的核心区别——程序可以无限运行,比如操作系统,而算法必须是有穷的。
  2. 确定性:算法的每一步操作都有明确的含义,没有二义性,相同的输入只能得到相同的输出。
  3. 可行性:算法的每一步操作都可以通过已经实现的基本运算执行有限次来完成,是可以实际运行的。
  4. 输入:算法有零个或多个输入,零个输入表示算法本身已经包含了所有必要的初始数据。
  5. 输出:算法有一个或多个输出,输出是算法执行的结果,没有输出的算法是没有意义的。
3. 算法的评价标准

评价一个算法的好坏,主要有以下四个标准,其中时间效率和空间效率是核心评价指标

  1. 正确性:算法能够正确地解决问题,这是最基本的要求。
  2. 可读性:算法的代码要清晰易懂,便于阅读、理解和维护,而不是越晦涩越好。
  3. 健壮性:算法能够处理非法输入的情况,当输入错误数据时,算法能够做出适当的处理,而不是崩溃或产生错误的结果。
  4. 高效率与低存储量:也就是算法的时间复杂度和空间复杂度要低,时间复杂度越低,算法执行速度越快;空间复杂度越低,算法占用的内存越少。
4. 时间复杂度(必考核心)

时间复杂度是衡量算法执行效率的核心指标,它不是算法执行的具体时间(因为具体时间受硬件、编译器等因素影响),而是算法中基本操作的执行次数,随问题规模n的增长趋势,用大O表示法来描述,记作T(n)=O(f(n))T(n) = O(f(n)),其中n是问题的规模(比如线性表的长度、数组的元素个数)。

(1)时间复杂度的计算规则(必考)

计算时间复杂度时,我们只需要关注最高阶项,忽略低阶项和最高阶项的系数,因为当n足够大时,最高阶项决定了算法的增长趋势。 具体规则:

  1. 常数项:用O(1)表示,基本操作的执行次数是固定的,和问题规模n无关。
  2. 加法规则:T(n)=T1(n)+T2(n)=O(f(n))+O(g(n))=O(max(f(n),g(n)))T(n) = T_1(n) + T_2(n) = O(f(n)) + O(g(n)) = O(\max(f(n), g(n))),也就是多个步骤的时间复杂度,取最高阶的那个。
  3. 乘法规则:T(n)=T1(n)×T2(n)=O(f(n))×O(g(n))=O(f(n)×g(n))T(n) = T_1(n) \times T_2(n) = O(f(n)) \times O(g(n)) = O(f(n) \times g(n)),也就是嵌套循环的时间复杂度,是内外循环时间复杂度的乘积。
(2)常见的时间复杂度排序(必考)

专升本常考的时间复杂度,按增长速度从慢到快排序如下:

O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)<O(n!)O(1) < O(\log_2 n) < O(n) < O(n\log_2 n) < O(n^2) < O(n^3) < O(2^n) < O(n!)

其中,O(1)O(1)是常数阶,效率最高;O(n)O(n)是线性阶;O(n2)O(n^2)是平方阶;O(2n)O(2^n)是指数阶,效率极低,当n很大时,算法完全无法运行。

(3)高频时间复杂度计算示例
  1. 常数阶O(1):

    int a = 10;
    a++;
    printf("%d\n", a);
    

    基本操作的执行次数是固定的,和n无关,时间复杂度O(1)。

  2. 线性阶O(n):

    for(int i = 0; i < n; i++)
    {
        printf("%d ", i); // 基本操作,执行n次
    }
    

    循环执行n次,基本操作执行n次,时间复杂度O(n)。

  3. 平方阶O(n²):

    for(int i = 0; i < n; i++)
    {
        for(int j = 0; j < n; j++)
        {
            printf("%d ", i+j); // 基本操作,执行n*n次
        }
    }
    

    双重循环,外层执行n次,内层执行n次,基本操作执行n²次,时间复杂度O(n²)。

  4. 对数阶O(logn):

    int i = 1;
    while(i < n)
    {
        i *= 2; // 基本操作,每次i翻倍
    }
    

    设循环执行次数为x,2x=n2^x = n,解得x=log2nx = \log_2 n,所以时间复杂度O(logn),二分查找算法的时间复杂度就是O(logn)。

5. 空间复杂度

空间复杂度是衡量算法在运行过程中,临时占用的存储空间大小的指标,同样用大O表示法,记作S(n)=O(f(n))S(n) = O(f(n))

空间复杂度计算的是算法额外需要的辅助存储空间,不包括输入数据本身占用的空间。如果算法的辅助存储空间是固定的,和问题规模n无关,空间复杂度就是O(1),也叫原地算法

示例:

  1. 空间复杂度O(1):

    void swap(int *a, int *b)
    {
        int temp = *a;
        *a = *b;
        *b = temp;
    }
    

    只需要一个临时变量temp,辅助空间和n无关,空间复杂度O(1)。

  2. 空间复杂度O(n):

    void copy_array(int *a, int *b, int n)
    {
        for(int i = 0; i < n; i++)
        {
            b[i] = a[i];
        }
    }
    

    需要一个长度为n的数组b作为辅助空间,空间复杂度O(n)。

高频考点真题例题

例1(2025年广东专升本真题·单选题) 以下数据结构中,属于非线性结构的是( ) A. 线性表 B. 栈 C. 队列 D. 二叉树 解:正确答案是D。二叉树是一对多的非线性结构,线性表、栈、队列都是线性结构。

例2(2024年广东专升本真题·单选题) 以下算法的时间复杂度是( )

int fun(int n)
{
    int sum = 0;
    for(int i = 1; i <= n; i *= 2)
    {
        sum += i;
    }
    return sum;
}

A. O(n) B. O(n²) C. O(logn) D. O(nlogn) 解:正确答案是C。循环变量i每次翻倍,循环执行次数是log₂n,所以时间复杂度O(logn)。

易错点避雷
  1. 算法的有穷性是核心特性,程序可以无限运行,但算法必须在有限步内结束,这是算法和程序的核心区别,选择题高频考点。
  2. 顺序存储支持随机存取,链式存储只能顺序存取,两者的核心区别要分清,不能混淆。
  3. 时间复杂度计算的是基本操作的执行次数,不是代码的行数,嵌套循环的时间复杂度是内外循环的乘积。
  4. 空间复杂度计算的是额外的辅助存储空间,不是输入数据本身的空间,原地算法的空间复杂度是O(1)。
  5. 逻辑结构和存储结构是独立的,同一种逻辑结构可以有多种存储结构,比如线性表可以用顺序存储(顺序表),也可以用链式存储(链表)。

模块六:线性表

考情定位

线性表是数据结构的核心基础,每年必考15-20分,全题型覆盖,尤其是综合应用题,几乎每年都会考查顺序表或单链表的算法设计与代码实现,是数据结构的重中之重,必须100%吃透。线性表的操作逻辑,是后续栈、队列、树、图等所有结构的基础,学好线性表,数据结构就入门了一半。

核心知识点拆解
1. 线性表的定义

线性表是n个数据元素组成的有限序列,n是线性表的长度,n=0时称为空表。

线性表的核心逻辑特性:

  1. 有唯一的第一个元素(表头元素)和唯一的最后一个元素(表尾元素);
  2. 除了表头元素,每个元素有且仅有一个直接前驱;
  3. 除了表尾元素,每个元素有且仅有一个直接后继;
  4. 元素之间是一对一的线性关系。

线性表的基本操作,专升本重点考查以下几种:

  1. 初始化:创建一个空的线性表;
  2. 取值:获取线性表中第i个位置的元素的值;
  3. 查找:按值查找线性表中对应的元素,返回其位置;
  4. 插入:在线性表的第i个位置插入一个新元素;
  5. 删除:删除线性表中第i个位置的元素;
  6. 求表长:获取线性表中元素的个数;
  7. 遍历:依次访问线性表中的每个元素;
  8. 判空:判断线性表是否为空。

线性表有两种核心存储结构:顺序存储(顺序表)链式存储(链表),我们分别详细拆解。

2. 顺序表

顺序表是线性表的顺序存储结构,用一组地址连续的存储单元依次存储线性表的元素,逻辑上相邻的元素,物理上也相邻,通常用数组实现。

顺序表的核心特点:支持随机存取,可以通过下标直接访问任意元素,时间复杂度O(1)。

(1)顺序表的结构体定义(C语言实现)

专升本考试中,顺序表通常用静态数组实现,定义如下:

#define MAXSIZE 100 // 顺序表的最大长度
// 顺序表的结构体定义
typedef struct
{
    int data[MAXSIZE]; // 存储元素的数组
    int length;        // 顺序表当前的长度(元素个数)
} SqList;

其中,data数组存储顺序表的元素,length记录顺序表当前的元素个数,必须满足0 ≤ length ≤ MAXSIZE

(2)顺序表的基本操作(C语言实现+时间复杂度分析)

我们按照专升本考试的要求,实现顺序表的核心操作,每个操作都包含代码、逻辑说明和时间复杂度分析。

  1. 顺序表的初始化 功能:创建一个空的顺序表,将长度初始化为0。

    // 初始化顺序表
    void InitList(SqList *L)
    {
        L->length = 0; // 空表,长度为0
    }
    

    时间复杂度:O(1),只执行一次赋值操作。

  2. 顺序表的取值(按位查找) 功能:获取顺序表中第i个位置的元素,存入e中。注意:顺序表的位序i从1开始,而数组的下标从0开始,第i个元素对应数组下标i-1。

    // 取值操作,i是位序(从1开始),e存储取出的元素
    int GetElem(SqList *L, int i, int *e)
    {
        // 先判断i是否合法
        if(i < 1 || i > L->length)
        {
            return 0; // 位序不合法,返回0表示失败
        }
        *e = L->data[i-1]; // 第i个元素对应数组下标i-1
        return 1; // 成功返回1
    }
    

    时间复杂度:O(1),直接通过下标访问,和顺序表长度无关。

  3. 顺序表的按值查找 功能:在顺序表中查找值为e的元素,返回其第一次出现的位序(从1开始),如果找不到,返回0。

    // 按值查找,返回元素的位序,找不到返回0
    int LocateElem(SqList *L, int e)
    {
        for(int i = 0; i < L->length; i++)
        {
            if(L->data[i] == e)
            {
                return i+1; // 找到,返回位序i+1
            }
        }
        return 0; // 找不到,返回0
    }
    

    时间复杂度:最好情况O(1),第一个元素就是目标元素;最坏情况O(n),最后一个元素才是目标元素,平均时间复杂度O(n)。

  4. 顺序表的插入操作(必考核心) 功能:在顺序表的第i个位置(位序,从1开始)插入一个新元素e,插入成功后,原第i个及之后的元素都向后移动一位,顺序表长度加1。 插入操作的步骤:

    1. 判断插入位序i是否合法(1 ≤ i ≤ L->length+1);
    2. 判断顺序表是否已满(L->length == MAXSIZE),已满则无法插入;
    3. 将第i个元素到最后一个元素,依次向后移动一位,空出第i个位置;
    4. 将新元素e放入第i个位置(数组下标i-1);
    5. 顺序表长度加1,返回成功。

    代码实现:

    // 插入操作,在第i个位置插入元素e
    int ListInsert(SqList *L, int i, int e)
    {
        // 判断位序是否合法
        if(i < 1 || i > L->length + 1)
        {
            return 0;
        }
        // 判断顺序表是否已满
        if(L->length >= MAXSIZE)
        {
            return 0;
        }
        // 元素后移,从最后一个元素开始,到第i个元素结束
        for(int j = L->length; j >= i; j--)
        {
            L->data[j] = L->data[j-1];
        }
        // 插入新元素
        L->data[i-1] = e;
        // 长度加1
        L->length++;
        return 1;
    }
    

    时间复杂度:最好情况O(1),在表尾插入,不需要移动元素;最坏情况O(n),在表头插入,需要移动所有n个元素;平均时间复杂度O(n)。

  5. 顺序表的删除操作(必考核心) 功能:删除顺序表中第i个位置的元素,将删除的元素存入e中,删除成功后,原第i个之后的元素都向前移动一位,顺序表长度减1。 删除操作的步骤:

    1. 判断删除位序i是否合法(1 ≤ i ≤ L->length);
    2. 将第i+1个元素到最后一个元素,依次向前移动一位,覆盖第i个元素;
    3. 顺序表长度减1,将删除的元素存入e,返回成功。

    代码实现:

    // 删除操作,删除第i个位置的元素,存入e中
    int ListDelete(SqList *L, int i, int *e)
    {
        // 判断位序是否合法
        if(i < 1 || i > L->length)
        {
            return 0;
        }
        // 保存要删除的元素
        *e = L->data[i-1];
        // 元素前移,从第i+1个元素开始,到最后一个元素结束
        for(int j = i; j < L->length; j++)
        {
            L->data[j-1] = L->data[j];
        }
        // 长度减1
        L->length--;
        return 1;
    }
    

    时间复杂度:最好情况O(1),删除表尾元素,不需要移动元素;最坏情况O(n),删除表头元素,需要移动n-1个元素;平均时间复杂度O(n)。

  6. 顺序表的遍历 功能:依次输出顺序表中的所有元素。

    // 遍历顺序表
    void TraverseList(SqList *L)
    {
        for(int i = 0; i < L->length; i++)
        {
            printf("%d ", L->data[i]);
        }
        printf("\n");
    }
    

    时间复杂度:O(n),需要遍历所有n个元素。

(3)顺序表的优缺点
  • 优点:
    1. 支持随机存取,访问任意元素的时间复杂度O(1),速度极快;
    2. 存储密度高,没有额外的指针开销,所有空间都用来存储数据;
    3. 实现简单,逻辑清晰,易于理解和操作。
  • 缺点:
    1. 插入和删除操作需要移动大量元素,平均时间复杂度O(n),效率低;
    2. 必须预先分配固定大小的内存空间,长度不能动态改变,容易造成内存浪费或溢出;
    3. 需要连续的内存空间,当内存碎片较多时,可能无法分配足够的连续空间。
3. 单链表

单链表是线性表的链式存储结构,用任意的存储单元存储线性表的元素,逻辑上相邻的元素,物理上不一定相邻,通过指针来表示元素之间的逻辑关系。

单链表的每个节点分为两部分:

  1. 数据域:存储当前节点的数据元素;
  2. 指针域:存储下一个节点的内存地址,称为后继指针next

单链表的最后一个节点的后继指针next为NULL,表示链表结束。我们通常用一个头指针指向链表的第一个节点,通过头指针可以遍历整个链表。

专升本考试中,我们优先使用带头节点的单链表,也就是在链表的第一个元素节点之前,增加一个不存储数据的头节点,头节点的next指针指向第一个元素节点。带头节点的单链表,操作更简单,不会出现头指针修改的问题,空表和非空表的操作逻辑统一,是考试的标准写法。

(1)单链表的节点结构体定义(C语言实现)
// 定义单链表节点结构体
typedef struct Node
{
    int data;               // 数据域,存储节点数据
    struct Node *next;      // 指针域,指向下一个节点的指针
} Node, *LinkList;

其中,Nodestruct Node的别名,LinkListstruct Node *的别名,LinkList head就定义了单链表的头指针head,等价于Node *head

(2)单链表的基本操作(C语言实现+时间复杂度分析)

我们按照专升本考试的要求,实现带头节点的单链表的核心操作,这是综合应用题的必考内容,必须熟练掌握,能手写完整代码。

  1. 单链表的初始化 功能:创建一个带头节点的空单链表,头节点的next指针为NULL。

    // 初始化带头节点的单链表
    int InitList(LinkList *head)
    {
        // 创建头节点,分配内存
        *head = (LinkList)malloc(sizeof(Node));
        if(*head == NULL) // 内存分配失败
        {
            return 0;
        }
        (*head)->next = NULL; // 头节点的next为NULL,空表
        return 1;
    }
    

    时间复杂度:O(1)。

  2. 单链表的头插法建表 功能:从数组中读取元素,用头插法建立单链表,新节点插入到头节点之后,链表的元素顺序和数组的顺序相反。 头插法的步骤:

    1. 创建新节点,为新节点分配内存,赋值数据域;
    2. 将新节点的next指针指向头节点的next
    3. 将头节点的next指针指向新节点。

    代码实现:

    // 头插法建表,arr是元素数组,n是元素个数
    void CreateList_Head(LinkList head, int arr[], int n)
    {
        Node *new_node;
        for(int i = 0; i < n; i++)
        {
            // 创建新节点
            new_node = (Node *)malloc(sizeof(Node));
            new_node->data = arr[i];
            // 头插法核心步骤
            new_node->next = head->next;
            head->next = new_node;
        }
    }
    

    时间复杂度:O(n),每个节点的插入操作是O(1),n个节点总时间O(n)。

  3. 单链表的尾插法建表 功能:从数组中读取元素,用尾插法建立单链表,新节点插入到链表的尾部,链表的元素顺序和数组的顺序一致,这是最常用的建表方式。 尾插法需要一个尾指针tail,始终指向链表的最后一个节点,避免每次插入都从头遍历。

    代码实现:

    // 尾插法建表,arr是元素数组,n是元素个数
    void CreateList_Tail(LinkList head, int arr[], int n)
    {
        Node *new_node, *tail = head; // tail尾指针,初始指向头节点
        for(int i = 0; i < n; i++)
        {
            // 创建新节点
            new_node = (Node *)malloc(sizeof(Node));
            new_node->data = arr[i];
            new_node->next = NULL; // 新节点是尾节点,next为NULL
            // 插入到尾部
            tail->next = new_node;
            tail = new_node; // 尾指针后移,指向新的尾节点
        }
    }
    

    时间复杂度:O(n),每个节点的插入操作是O(1),n个节点总时间O(n)。

  4. 单链表的求表长 功能:计算单链表中元素节点的个数(不包含头节点)。

    // 求单链表的长度
    int ListLength(LinkList head)
    {
        int len = 0;
        Node *p = head->next; // p指向第一个元素节点
        while(p != NULL)
        {
            len++;
            p = p->next; // p后移
        }
        return len;
    }
    

    时间复杂度:O(n),需要遍历整个链表。

  5. 单链表的取值(按位查找) 功能:获取单链表中第i个元素节点的值,存入e中,位序i从1开始。

    // 按位查找,获取第i个元素的值,存入e
    int GetElem(LinkList head, int i, int *e)
    {
        if(i < 1)
        {
            return 0;
        }
        Node *p = head->next;
        int j = 1; // j是当前节点的位序
        while(p != NULL && j < i)
        {
            p = p->next;
            j++;
        }
        if(p == NULL) // i超过链表长度
        {
            return 0;
        }
        *e = p->data;
        return 1;
    }
    

    时间复杂度:最好情况O(1),查找第一个元素;最坏情况O(n),查找最后一个元素,平均时间复杂度O(n)。

  6. 单链表的按值查找 功能:在单链表中查找值为e的元素,返回其第一次出现的位序,找不到返回0。

    // 按值查找,返回元素的位序,找不到返回0
    int LocateElem(LinkList head, int e)
    {
        Node *p = head->next;
        int j = 1;
        while(p != NULL)
        {
            if(p->data == e)
            {
                return j; // 找到,返回位序
            }
            p = p->next;
            j++;
        }
        return 0; // 找不到
    }
    

    时间复杂度:O(n),需要遍历链表。

  7. 单链表的插入操作(必考核心) 功能:在单链表的第i个位置(位序)插入元素e,插入成功后,新节点成为第i个节点。 插入操作的核心步骤:

    1. 找到第i-1个节点,用指针p指向它(因为插入需要修改前一个节点的next指针);
    2. 创建新节点,赋值数据域;
    3. 将新节点的next指针指向p的next指针(原第i个节点);
    4. 将p的next指针指向新节点。 ⚠️ 核心注意:步骤3和4的顺序不能颠倒,否则会丢失原第i个节点的地址。

    代码实现:

    // 插入操作,在第i个位置插入元素e
    int ListInsert(LinkList head, int i, int e)
    {
        if(i < 1)
        {
            return 0;
        }
        Node *p = head;
        int j = 0; // j是p指向的节点的位序,头节点位序0
        // 找到第i-1个节点
        while(p != NULL && j < i-1)
        {
            p = p->next;
            j++;
        }
        if(p == NULL) // i-1超过链表长度,位序不合法
        {
            return 0;
        }
        // 创建新节点
        Node *new_node = (Node *)malloc(sizeof(Node));
        new_node->data = e;
        // 插入核心步骤,顺序不能颠倒
        new_node->next = p->next;
        p->next = new_node;
        return 1;
    }
    

    时间复杂度:最好情况O(1),在表头插入,不需要遍历;最坏情况O(n),在表尾插入,需要遍历整个链表,平均时间复杂度O(n)。

  8. 单链表的删除操作(必考核心) 功能:删除单链表中第i个位置的节点,将删除的元素存入e中。 删除操作的核心步骤:

    1. 找到第i-1个节点,用指针p指向它;
    2. 用指针q指向要删除的第i个节点(q = p->next);
    3. 将p的next指针指向q的next指针,跳过要删除的节点;
    4. 保存q节点的数据到e中,释放q节点的内存,避免内存泄漏。

    代码实现:

    // 删除操作,删除第i个节点,元素存入e
    int ListDelete(LinkList head, int i, int *e)
    {
        if(i < 1)
        {
            return 0;
        }
        Node *p = head;
        int j = 0;
        // 找到第i-1个节点
        while(p != NULL && j < i-1)
        {
            p = p->next;
            j++;
        }
        // 检查第i个节点是否存在
        if(p->next == NULL)
        {
            return 0;
        }
        Node *q = p->next; // q指向要删除的节点
        *e = q->data;       // 保存删除的元素
        p->next = q->next;  // 跳过删除的节点
        free(q);            // 释放内存
        return 1;
    }
    

    时间复杂度:最好情况O(1),删除表头节点;最坏情况O(n),删除表尾节点,平均时间复杂度O(n)。

  9. 单链表的遍历 功能:依次输出单链表中的所有元素。

    // 遍历单链表
    void TraverseList(LinkList head)
    {
        Node *p = head->next;
        while(p != NULL)
        {
            printf("%d ", p->data);
            p = p->next;
        }
        printf("\n");
    }
    

    时间复杂度:O(n)。

(3)单链表的优缺点
  • 优点:
    1. 插入和删除操作只需要修改指针,不需要移动元素,时间复杂度O(1)(找到前驱节点后);
    2. 不需要预先分配内存,动态分配内存,不会造成内存浪费,链表长度可以无限增长(只要内存足够);
    3. 不需要连续的内存空间,充分利用内存碎片。
  • 缺点:
    1. 不支持随机存取,只能顺序存取,访问元素需要从头遍历,时间复杂度O(n);
    2. 每个节点需要额外的指针存储空间,存储密度低;
    3. 实现相对复杂,容易出现野指针、内存泄漏等问题。
4. 其他链表结构

专升本考试中,除了单链表,还会考查循环单链表和双向链表,以选择题为主,难度较低,只需掌握核心特性即可。

  1. 循环单链表:单链表的最后一个节点的next指针,不是NULL,而是指向头节点,整个链表形成一个环。
    • 核心特性:从任意一个节点出发,都可以遍历整个链表;判空条件是head->next == head;表尾节点的next指向头节点,不需要遍历整个链表就能找到表头,适合循环操作的场景。
  2. 双向链表:每个节点有两个指针,prior指针指向前驱节点,next指针指向后继节点。
    • 核心特性:可以双向遍历,找到一个节点后,既能访问后继,也能访问前驱;插入和删除操作更方便,不需要找前驱节点;每个节点需要额外的一个指针空间。
高频考点真题例题

例1(2025年广东专升本真题·单选题) 顺序表中,插入一个元素的平均时间复杂度是( ) A. O(1) B. O(n) C. O(logn) D. O(n²) 解:正确答案是B。顺序表插入操作的平均时间复杂度是O(n),需要移动平均n/2个元素。

例2(2024年广东专升本真题·综合应用题) 设计一个算法,实现带头节点的单链表的逆置,也就是将链表1→2→3→4→5逆置为5→4→3→2→1,要求用C语言实现,空间复杂度O(1)。 解:这是专升本综合应用题的高频考点,用头插法实现逆置,空间复杂度O(1),代码如下:

// 单链表逆置,头插法实现
void ReverseList(LinkList head)
{
    Node *p = head->next; // p指向当前要处理的节点
    Node *q;              // 保存p的下一个节点
    head->next = NULL;    // 先把原链表断开
    while(p != NULL)
    {
        q = p->next;      // 保存下一个节点,防止断链
        // 头插法,把p插入到头节点之后
        p->next = head->next;
        head->next = p;
        p = q;            // p后移,处理下一个节点
    }
}

解析:遍历原链表的每个节点,用头插法依次插入到头节点之后,最终实现链表的逆置,只需要两个辅助指针,空间复杂度O(1),时间复杂度O(n)。

易错点避雷
  1. 顺序表的位序从1开始,数组下标从0开始,第i个元素对应数组下标i-1,这是最常见的代码错误。
  2. 顺序表插入和删除时,元素移动的顺序不能颠倒:插入时从后往前移,删除时从前往后移。
  3. 单链表的插入操作,必须先把新节点的next指向原节点,再修改前驱节点的next,顺序不能颠倒,否则会丢失链表。
  4. 单链表删除节点时,必须用free释放删除节点的内存,否则会造成内存泄漏。
  5. 带头节点的单链表,空表的判断条件是head->next == NULL,不是head == NULL
  6. 单链表不支持随机存取,只能顺序存取,访问第i个元素必须从头遍历,时间复杂度O(n),不能直接通过下标访问。

模块七:栈与队列

考情定位

本模块每年必考8-10分,选择题、填空题、程序题、应用题均有涉及,是特殊的线性表,操作受限,考点固定,难度不高,是必须拿分的模块。栈和队列是线性表的特例,所有的操作都只能在表的两端进行,核心考点是它们的操作特性、存储实现和典型应用。

核心知识点拆解
1. 栈(Stack)
(1)栈的定义与核心特性

栈是操作受限的线性表,只能在栈的一端进行插入和删除操作,允许操作的一端称为栈顶(Top),不允许操作的另一端称为栈底(Bottom)

栈的核心特性:后进先出(LIFO,Last In First Out),也就是最后插入栈的元素,最先被删除。就像我们往箱子里放书,先放的书在箱子底部,后放的书在顶部,取书的时候只能先取顶部的书,也就是后放的先取。

栈的基本操作:

  1. 初始化:创建一个空栈;
  2. 入栈(Push):在栈顶插入一个新元素;
  3. 出栈(Pop):删除栈顶元素,返回其值;
  4. 取栈顶元素(GetTop):获取栈顶元素的值,不删除;
  5. 判空:判断栈是否为空;
  6. 判满:判断顺序栈是否已满。
(2)栈的顺序存储(顺序栈)

顺序栈用数组实现,通常用数组的0下标端作为栈底,用一个变量top作为栈顶指针,指向栈顶元素的位置。专升本考试中,顺序栈的标准定义如下:

#define MAXSIZE 100 // 栈的最大容量
// 顺序栈的结构体定义
typedef struct
{
    int data[MAXSIZE]; // 存储栈元素的数组
    int top;           // 栈顶指针,指向栈顶元素的数组下标
} SqStack;

顺序栈的核心操作实现:

  1. 栈的初始化:创建空栈,栈顶指针top初始化为-1,表示空栈。
    // 初始化顺序栈
    void InitStack(SqStack *S)
    {
        S->top = -1; // 空栈,top=-1
    }
    
  2. 判空操作:栈顶指针top == -1时,栈为空。
    // 判断栈是否为空,空返回1,非空返回0
    int StackEmpty(SqStack *S)
    {
        return S->top == -1;
    }
    
  3. 入栈操作: 步骤:① 判断栈是否已满,已满则入栈失败;② 栈顶指针top加1;③ 将新元素放入top指向的位置。
    // 入栈操作,元素e入栈
    int Push(SqStack *S, int e)
    {
        if(S->top == MAXSIZE - 1) // 栈满
        {
            return 0;
        }
        S->data[++S->top] = e; // top先加1,再赋值
        return 1;
    }
    
    时间复杂度:O(1)。
  4. 出栈操作: 步骤:① 判断栈是否为空,空则出栈失败;② 将栈顶元素赋值给e;③ 栈顶指针top减1。
    // 出栈操作,栈顶元素出栈,存入e
    int Pop(SqStack *S, int *e)
    {
        if(StackEmpty(S)) // 栈空
        {
            return 0;
        }
        *e = S->data[S->top--]; // 先取值,top再减1
        return 1;
    }
    
    时间复杂度:O(1)。
  5. 取栈顶元素
    // 取栈顶元素,存入e,不修改栈
    int GetTop(SqStack *S, int *e)
    {
        if(StackEmpty(S))
        {
            return 0;
        }
        *e = S->data[S->top];
        return 1;
    }
    
    时间复杂度:O(1)。
(3)栈的链式存储(链栈)

链栈用单链表实现,通常用单链表的表头作为栈顶,入栈和出栈操作都在表头进行,不需要头节点,操作更简单。链栈没有栈满的问题,内存可以动态分配。

链栈的节点定义和顺序栈的操作实现,专升本考试仅需了解即可,重点考查顺序栈。

(4)栈的典型应用(必考选择题、应用题)

栈的后进先出特性,决定了它的典型应用场景,是考试的高频考点:

  1. 括号匹配问题:检查表达式中的括号是否正确匹配,比如([{}])是正确的,([)]是错误的。这是专升本最常考的栈的应用题。
  2. 表达式求值:包括中缀表达式转后缀表达式(逆波兰表达式),以及后缀表达式的求值,是栈的经典应用。
  3. 函数调用与递归:程序中的函数调用,就是通过栈来保存函数的返回地址、局部变量等信息;递归函数的执行,本质上就是递归调用栈的入栈和出栈过程。
  4. 数制转换:比如将十进制数转换为二进制、八进制、十六进制,利用栈的后进先出特性,逆序输出转换后的结果。
(5)栈的出栈序列合法性判断(必考选择题)

给定入栈序列,判断某个出栈序列是否合法,是专升本的高频选择题考点。核心判断规则:对于出栈序列中的每个元素,在它之后出栈的、比它先入栈的元素,必须是逆序排列的。

示例:入栈序列是1、2、3、4,以下哪个出栈序列是不合法的? A. 4、3、2、1 B. 3、2、4、1 C. 2、3、4、1 D. 4、2、3、1 解:正确答案是D。4第一个出栈,说明1、2、3、4都已经入栈,此时栈内元素从栈底到栈顶是1、2、3、4,出栈顺序只能是4、3、2、1,不可能出现4之后先出2,再出3的情况,所以D是不合法的。

2. 队列(Queue)
(1)队列的定义与核心特性

队列是操作受限的线性表,只能在队列的一端进行插入操作,另一端进行删除操作。允许插入的一端称为队尾(Rear),允许删除的一端称为队头(Front)

队列的核心特性:先进先出(FIFO,First In First Out),也就是最先插入队列的元素,最先被删除。就像我们排队买票,先排队的人先买票,后排队的人后买票。

队列的基本操作:

  1. 初始化:创建一个空队列;
  2. 入队(EnQueue):在队尾插入一个新元素;
  3. 出队(DeQueue):删除队头元素,返回其值;
  4. 取队头元素(GetFront):获取队头元素的值,不删除;
  5. 判空:判断队列是否为空;
  6. 判满:判断循环队列是否已满。
(2)循环队列(顺序存储,必考核心)

队列的顺序存储用数组实现,但是普通的顺序队列会出现假溢出的问题:队尾指针已经到了数组末尾,无法再入队,但数组前面还有空闲的位置。为了解决假溢出的问题,我们把数组的首尾相连,形成一个环形的结构,称为循环队列,这是专升本考试的必考核心。

循环队列的结构体定义:

#define MAXSIZE 100 // 队列的最大容量
// 循环队列的结构体定义
typedef struct
{
    int data[MAXSIZE]; // 存储队列元素的数组
    int front;         // 队头指针,指向队头元素的下标
    int rear;          // 队尾指针,指向队尾元素的下一个位置
} SqQueue;

循环队列的核心设计:我们牺牲数组中的一个存储单元,来区分队列空和队列满的状态,这是考试中最常用、最标准的实现方式。

(3)循环队列的核心公式(必考)
  1. 队列判空条件front == rear
  2. 队列判满条件(rear + 1) % MAXSIZE == front
  3. 队列长度计算公式(rear - front + MAXSIZE) % MAXSIZE 这三个公式是专升本选择题、填空题的高频考点,必须100%记住。
(4)循环队列的基本操作实现
  1. 队列初始化:创建空队列,frontrear都初始化为0。
    // 初始化循环队列
    void InitQueue(SqQueue *Q)
    {
        Q->front = Q->rear = 0;
    }
    
  2. 判空操作front == rear时,队列为空。
    // 判断队列是否为空,空返回1,非空返回0
    int QueueEmpty(SqQueue *Q)
    {
        return Q->front == Q->rear;
    }
    
  3. 入队操作: 步骤:① 判断队列是否已满,已满则入队失败;② 将新元素放入rear指向的位置;③ rear指针后移,取模MAXSIZE。
    // 入队操作,元素e入队
    int EnQueue(SqQueue *Q, int e)
    {
        if((Q->rear + 1) % MAXSIZE == Q->front) // 队列满
        {
            return 0;
        }
        Q->data[Q->rear] = e;
        Q->rear = (Q->rear + 1) % MAXSIZE; // rear后移,取模实现循环
        return 1;
    }
    
    时间复杂度:O(1)。
  4. 出队操作: 步骤:① 判断队列是否为空,空则出队失败;② 将队头元素赋值给e;③ front指针后移,取模MAXSIZE。
    // 出队操作,队头元素出队,存入e
    int DeQueue(SqQueue *Q, int *e)
    {
        if(QueueEmpty(Q)) // 队列空
        {
            return 0;
        }
        *e = Q->data[Q->front];
        Q->front = (Q->front + 1) % MAXSIZE; // front后移,取模实现循环
        return 1;
    }
    
    时间复杂度:O(1)。
  5. 取队头元素
    // 取队头元素,存入e,不修改队列
    int GetFront(SqQueue *Q, int *e)
    {
        if(QueueEmpty(Q))
        {
            return 0;
        }
        *e = Q->data[Q->front];
        return 1;
    }
    
    时间复杂度:O(1)。
(5)队列的链式存储(链队列)

链队列用单链表实现,需要两个指针:队头指针指向链表的头节点,队尾指针指向链表的最后一个节点。入队操作在队尾进行,出队操作在队头进行,没有队列满的问题,内存动态分配。专升本考试仅需了解其核心特性即可,重点考查循环队列。

(6)队列的典型应用

队列的先进先出特性,决定了它的典型应用场景,是考试的高频考点:

  1. 树的层次遍历(广度优先遍历BFS):这是队列最经典的应用,我们会在树的模块详细讲解。
  2. 图的广度优先搜索(BFS):图的遍历算法,核心就是用队列实现。
  3. 缓冲区管理:比如键盘输入缓冲区、打印机缓冲区,都是用队列实现先进先出的顺序处理。
  4. 操作系统的进程调度:就绪队列、等待队列,都是用队列实现的。
高频考点真题例题

例1(2025年广东专升本真题·单选题) 一个栈的入栈序列是a、b、c、d、e,则栈的不可能的出栈序列是( ) A. edcba B. decba C. dceab D. abcde 解:正确答案是C。d第一个出栈,说明a、b、c、d都已经入栈,此时栈内从栈底到栈顶是a、b、c、d,出栈d之后,栈顶是c,下一个出栈的只能是c,不可能是e,所以C是不合法的。

例2(2024年广东专升本真题·填空题) 循环队列的数组长度为100,front=20,rear=10,则队列的长度是______。 解:答案是90。根据队列长度公式:(rear - front + MAXSIZE) % MAXSIZE = (10 - 20 + 100) % 100 = 90。

易错点避雷
  1. 栈的核心特性是后进先出(LIFO),队列的核心特性是先进先出(FIFO),两者不能混淆,选择题高频考点。
  2. 顺序栈的栈顶指针top初始化为-1,入栈时先++top再赋值,出栈时先取值再top--,顺序不能颠倒。
  3. 循环队列的三个核心公式必须记牢,判空、判满、长度计算,尤其是取模运算,不能漏掉。
  4. 循环队列的rear指针指向队尾元素的下一个位置,不是队尾元素本身,这是很多同学的理解误区。
  5. 栈的出栈序列合法性判断,核心是先入栈的元素,后出栈的话必须是逆序,不能出现顺序混乱。

写在最后

本篇文章,我们完整拆解了C语言进阶的四大核心模块:函数、指针、结构体、文件操作,攻克了C语言最难的指针关卡,同时完成了数据结构的基础概念、线性表、栈与队列的全考点体系化拆解,覆盖了专升本考试中70%的C语言分值和60%的数据结构分值。

很多同学觉得C语言的指针、数据结构的链表难,本质上是没有理解底层的内存逻辑,也没有动手写代码。编程和算法从来都不是看会的,而是写会的。只有亲手把代码敲出来,运行、调试、修改,你才能真正理解每个操作的底层逻辑,把知识转化为自己的能力。

在本系列的第三篇文章中,我们将继续拆解数据结构的非线性结构全考点:树与二叉树、图的存储与核心算法,以及查找算法、排序算法的全考点拆解,同时会给大家完整的全科目备考全攻略、真题刷题技巧、编程题提分方法,帮你一站式搞定广东计算机专升本的计算机科目。

如果你在备考中,对链表的哪个操作、栈和队列的哪个应用场景有疑问,或者有搞不懂的算法代码,都可以在评论区留言,我会一一为你解答。