CSDN-栈队列数组-知识点整理

数据结构 | 栈、队列与数组:从概念到 C 语言实现 + 408 真题全解析

本文系统梳理栈、队列、数组三大线性结构:从基本概念、顺序/链式存储的实现细节,到卡特兰数、循环队列判空判满、特殊矩阵压缩存储的计算公式,并配齐 2010---2025 年 408 统考相关真题与详细解析。所有代码均可直接编译运行。

阅读建议 :考研 / 学校考试的同学,重点看卡特兰数、出栈序列合法性判断、中缀转后缀、循环队列判空判满、矩阵压缩下标计算 这五块,它们是这一章的绝对考点;校招同学重点看代码实现和 LeetCode 应用。

目录

  • 一、栈(Stack)
    • [1.1 基本概念](#1.1 基本概念)
    • [1.2 出栈序列问题与卡特兰数](#1.2 出栈序列问题与卡特兰数)
    • [1.3 栈的顺序存储](#1.3 栈的顺序存储)
    • [1.4 栈的链式存储](#1.4 栈的链式存储)
    • [1.5 顺序栈 vs 链式栈](#1.5 顺序栈 vs 链式栈)
    • [1.6 栈的应用](#1.6 栈的应用)
    • [1.7 栈与递归](#1.7 栈与递归)
  • 二、队列(Queue)
    • [2.1 基本概念](#2.1 基本概念)
    • [2.2 队列的链式存储](#2.2 队列的链式存储)
    • [2.3 队列的顺序存储与假溢出](#2.3 队列的顺序存储与假溢出)
    • [2.4 循环队列](#2.4 循环队列)
    • [2.5 双端队列](#2.5 双端队列)
    • [2.6 队列的应用](#2.6 队列的应用)
  • 三、数组与特殊矩阵压缩存储
    • [3.1 数组的存储结构](#3.1 数组的存储结构)
    • [3.2 矩阵压缩存储概述](#3.2 矩阵压缩存储概述)
    • [3.3 对称矩阵](#3.3 对称矩阵)
    • [3.4 三角矩阵](#3.4 三角矩阵)
    • [3.5 三对角矩阵](#3.5 三对角矩阵)
    • [3.6 稀疏矩阵](#3.6 稀疏矩阵)
  • [四、408 真题汇总表](#四、408 真题汇总表)
  • 五、一页速查总结

一、栈(Stack)

1.1 基本概念

先回顾线性表的定义:线性表(linear list)是相同类型的 n(n ≥ 0)个数据元素的有限序列,通常记为:

L = ( a 1 , a 2 , ... , a i , a i + 1 , ... , a n ) L = (a_1, a_2, \dots, a_i, a_{i+1}, \dots, a_n) L=(a1,a2,...,ai,ai+1,...,an)

栈(stack)是限定仅在表的一端进行插入和删除操作的线性表。 因此:

术语 含义
栈顶(top) 允许插入和删除的那一端
栈底(bottom) 不允许操作的另一端
空栈 不含任何元素的栈
入栈 / 进栈 在栈顶插入数据
出栈 / 退栈 在栈顶删除数据

栈的核心特性是 后进先出(LIFO, Last In First Out)。

生活中的例子:手枪弹夹、羽毛球桶------最后放进去的最先拿出来。

易错点 :栈是"受限的线性表",限制在于只能在一端操作。说"栈允许在其两端进行操作"是错的(2017 年 408 就考了这个说法)。

1.2 出栈序列问题与卡特兰数

这是栈这一章最经典的考点。

问题 :假设入栈顺序为 a1, a2, a3, a4, a5,出栈顺序是什么?

很多人第一反应是"全部入栈再全部出栈",得到 a5, a4, a3, a2, a1。但由于出栈时机不同,出栈序列有很多种:

  • 每个元素入栈后立刻出栈 → a1, a2, a3, a4, a5
  • 全部入栈后出栈 → a5, a4, a3, a2, a1
  • a1, a2, a3 入栈,出 a3, a2,再入 a4, a5,再出 a5, a4, a1 → a3, a2, a5, a4, a1
卡特兰数(Catalan Number)

n 个不同元素依次进栈,可能的出栈序列个数是第 n 个卡特兰数:

C n = 1 n + 1 ( 2 n n ) = ( 2 n ) ! ( n + 1 ) ! ⋅ n ! C_n = \frac{1}{n+1}\binom{2n}{n} = \frac{(2n)!}{(n+1)! \cdot n!} Cn=n+11(n2n)=(n+1)!⋅n!(2n)!

其中组合数公式为:

( m n ) = m ! n ! ⋅ ( m − n ) ! \binom{m}{n} = \frac{m!}{n! \cdot (m-n)!} (nm)=n!⋅(m−n)!m!

递推定义:

C 0 = 1 , C n = ∑ i = 0 n − 1 C i ⋅ C n − 1 − i ( n ≥ 1 ) C_0 = 1, \qquad C_n = \sum_{i=0}^{n-1} C_i \cdot C_{n-1-i} \quad (n \ge 1) C0=1,Cn=i=0∑n−1Ci⋅Cn−1−i(n≥1)

即 C n = C 0 C n − 1 + C 1 C n − 2 + ⋯ + C n − 1 C 0 C_n = C_0C_{n-1} + C_1C_{n-2} + \dots + C_{n-1}C_0 Cn=C0Cn−1+C1Cn−2+⋯+Cn−1C0

卡特兰数序列前几项:

C 0 = 1 , C 1 = 1 , C 2 = 2 , C 3 = 5 , C 4 = 14 , C 5 = 42 , C 6 = 132 , ... C_0=1,\ C_1=1,\ C_2=2,\ C_3=5,\ C_4=14,\ C_5=42,\ C_6=132,\ \dots C0=1, C1=1, C2=2, C3=5, C4=14, C5=42, C6=132, ...

举例 n = 3 验证:

C 3 = 1 3 + 1 ( 6 3 ) = 1 4 × 6 ! 3 ! ⋅ 3 ! = 1 4 × 6 × 5 × 4 3 × 2 × 1 = 1 4 × 20 = 5 C_3 = \frac{1}{3+1}\binom{6}{3} = \frac{1}{4} \times \frac{6!}{3! \cdot 3!} = \frac{1}{4} \times \frac{6 \times 5 \times 4}{3 \times 2 \times 1} = \frac{1}{4} \times 20 = 5 C3=3+11(36)=41×3!⋅3!6!=41×3×2×16×5×4=41×20=5

3 个元素确实只有 5 种合法出栈序列(总排列 6 种,只有 c, a, b 这一种不合法)。

考试要求 :考研和学校考试的同学务必记住这个结论公式;校招同学了解即可。

判断某个出栈序列是否合法的通用方法:用入栈序列去"模拟"------依次入栈,每入一个就尝试按目标出栈序列出栈,若最终能全部匹配则合法。本质就是后面要讲的 LeetCode 946 题。

1.3 栈的顺序存储

栈是特殊线性表,线性表有顺序和链式两种存储结构,所以栈也有顺序和链式两种实现。

1.3.1 静态顺序栈与动态顺序栈

和顺序表类似,栈的顺序存储可以用静态数组 (静态顺序栈)或堆上动态申请的数组 (动态顺序栈)。静态顺序栈的缺陷与静态顺序表相同:只适用于明确知道最多需要多少空间的场景。因此实际开发中以动态顺序栈为主。

静态栈结构定义 动态栈结构定义
c\n#define MAX_SIZE 10\ntypedef int STDataType;\ntypedef struct {\n STDataType arr[MAX_SIZE];\n int top; // 栈顶\n} Stack; c\ntypedef int STDataType;\ntypedef struct {\n STDataType* arr; // 指向栈数组空间\n int top; // 栈顶位置\n int capacity; // 容量\n} Stack;
1.3.2 ⚠️ top 的两种约定(必须搞清)

初始化时 top = -1 还是 top = 0,是初学者最容易混乱的地方:

约定 含义 入栈写法 栈空条件 栈中元素个数
top = -1 top 指向栈顶元素 先 ++top,再放数据 top == -1 top + 1
top = 0 top 指向栈顶元素的下一个位置 先放数据,再 ++top top == 0 top

为什么 top = 0 不能表示"指向栈顶元素"?

因为此时栈为空 和栈中只有一个元素 这两种情况下 top 都是 0,无法区分。这就是"指向栈顶元素"的约定必须用 top = -1 的原因。

两种方式没有优劣之分 ,任选一种,后续所有接口逻辑对应调整即可。本文代码统一采用 top = 0(指向栈顶元素的下一个位置)。

1.3.3 动态顺序栈完整实现
c 复制代码
// Stack.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

typedef int STDataType;

typedef struct {
    STDataType* arr;   // 指向栈数组空间的指针
    int top;           // 栈顶位置(指向栈顶元素的下一个位置)
    int capacity;      // 容量
} Stack;

void      StackInit(Stack* s);                 // 初始化
void      StackDestroy(Stack* s);              // 销毁
void      StackPush(Stack* s, STDataType x);   // 入栈
STDataType StackPop(Stack* s);                 // 出栈并返回栈顶元素
STDataType StackTop(Stack* s);                 // 获取栈顶元素
int       StackSize(Stack* s);                 // 有效元素个数
bool      StackEmpty(Stack* s);                // 判空
c 复制代码
// Stack.c
#include "Stack.h"

void StackInit(Stack* s) {
    assert(s);

    s->arr = (STDataType*)malloc(4 * sizeof(STDataType));
    if (NULL == s->arr) {
        printf("StackInit: 申请空间失败!!!\n");
        exit(-1);
    }

    // top = 0 :top 指向的是栈顶元素的下一个位置
    // top = -1:top 指向的是栈顶元素
    // 两种方式都可以,选择哪一种,后面的逻辑都对应调整即可
    s->top = 0;
    s->capacity = 4;
}

void StackDestroy(Stack* s) {
    assert(s);

    if (s->arr) {
        free(s->arr);
        s->arr = NULL;
        s->top = 0;
        s->capacity = 0;
    }
}

// 入栈:先判满扩容,再放数据,最后 ++top
void StackPush(Stack* s, STDataType x) {
    assert(s);

    // 满了,扩容为原来的 2 倍
    if (s->top == s->capacity) {
        STDataType* tmp = (STDataType*)realloc(s->arr, sizeof(STDataType) * s->capacity * 2);
        if (tmp == NULL) {
            printf("StackPush: 扩容空间失败!!!\n");
            exit(-1);
        }
        s->arr = tmp;
        s->capacity *= 2;
    }

    s->arr[s->top] = x;
    s->top++;
}

// 出栈:取出 s->top - 1 位置的数据,再 --top
STDataType StackPop(Stack* s) {
    assert(s);
    assert(!StackEmpty(s));

    STDataType topE = s->arr[s->top - 1];
    --s->top;
    return topE;
}

STDataType StackTop(Stack* s) {
    assert(s);
    assert(!StackEmpty(s));

    return s->arr[s->top - 1];
}

int StackSize(Stack* s) {
    assert(s);
    return s->top;
}

bool StackEmpty(Stack* s) {
    assert(s);
    return s->top == 0;
}

测试用例(注意断言会检查对空栈出栈):

c 复制代码
#include "Stack.h"

int main() {
    Stack s;
    StackInit(&s);

    StackPush(&s, 1);
    StackPush(&s, 2);
    StackPush(&s, 3);
    StackPush(&s, 4);
    StackPush(&s, 5);          // 触发一次扩容:4 -> 8

    printf("栈顶元素为: %d\n", StackTop(&s));    // 5

    printf("出栈: %d\n", StackPop(&s));          // 5
    printf("出栈: %d\n", StackPop(&s));          // 4
    printf("出栈: %d\n", StackPop(&s));          // 3
    printf("出栈: %d\n", StackPop(&s));          // 2
    printf("出栈: %d\n", StackPop(&s));          // 1

    // 此时栈已空,下面两行会触发断言失败,说明判空保护生效
    // printf("出栈: %d\n", StackPop(&s));
    // printf("栈顶元素为: %d\n", StackTop(&s));

    StackPush(&s, 6);
    StackPush(&s, 7);
    printf("出栈: %d\n", StackPop(&s));          // 7
    printf("出栈: %d\n", StackPop(&s));          // 6

    StackDestroy(&s);
    return 0;
}

1.4 栈的链式存储

1.4.1 结构选择的关键结论

栈的链式存储可以选单链表 或双向链表,但有几个必须记住的结论:

  1. 如果用单链表,必须用链表头这一端做栈顶 ,入栈出栈对应头插头删 ,时间复杂度 O ( 1 ) O(1) O(1)。若用链表尾做栈顶,出栈对应尾删,需要找前驱,时间复杂度退化为 O ( N ) O(N) O(N)。
  2. 如果用双向链表 ,则表头表尾做栈顶都可以,因为双向链表头尾插入删除都是 O ( 1 ) O(1) O(1)。但完全没必要------单链表已经能高效实现且更省空间,双向链表每个结点还要多存一个前驱指针,纯属浪费。
  3. 带头结点还是不带头结点? 因为入栈出栈就是头插头删,带头结点并不会带来便利 ,所以一般选择不带头结点实现。
1.4.2 链式栈实现
c 复制代码
// LinkStack.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

typedef int STDataType;

// 链式栈底层单链表结点
typedef struct LinkStackNode {
    struct LinkStackNode* next;
    STDataType data;
} LSNode;

// 链式栈结构
typedef struct {
    LSNode* topHead;   // 栈顶结点指针
    int size;          // 栈中数据个数
} LinkStack;

void      LinkStackInit(LinkStack* s);
void      LinkStackDestroy(LinkStack* s);
void      LinkStackPush(LinkStack* s, STDataType x);
STDataType LinkStackPop(LinkStack* s);
STDataType LinkStackTop(LinkStack* s);
int       LinkStackSize(LinkStack* s);
bool      LinkStackEmpty(LinkStack* s);
c 复制代码
// LinkStack.c
#include "LinkStack.h"

void LinkStackInit(LinkStack* s) {
    assert(s);
    // 不带头结点:头插头删,头结点价值不大
    s->topHead = NULL;
    s->size = 0;
}

void LinkStackDestroy(LinkStack* s) {
    assert(s);

    LSNode* cur = s->topHead;
    while (cur) {
        LSNode* next = cur->next;
        free(cur);
        cur = next;
    }
    s->topHead = NULL;
    s->size = 0;
}

// 入栈:链表空和不为空,头插逻辑完全一样
void LinkStackPush(LinkStack* s, STDataType x) {
    assert(s);

    LSNode* newNode = (LSNode*)malloc(sizeof(LSNode));
    if (NULL == newNode) {
        printf("LinkStackPush: 申请节点失败");
        exit(-1);
    }
    newNode->data = x;
    newNode->next = s->topHead;
    s->topHead = newNode;
    s->size++;
}

// 出栈:一个结点和多个结点逻辑完全一样
STDataType LinkStackPop(LinkStack* s) {
    assert(s);
    assert(!LinkStackEmpty(s));

    STDataType top = s->topHead->data;
    LSNode* delNode = s->topHead;
    s->topHead = delNode->next;
    free(delNode);
    s->size--;

    return top;
}

STDataType LinkStackTop(LinkStack* s) {
    assert(s);
    assert(!LinkStackEmpty(s));
    return s->topHead->data;
}

int LinkStackSize(LinkStack* s) {
    assert(s);
    return s->size;
}

bool LinkStackEmpty(LinkStack* s) {
    assert(s);
    return s->topHead == NULL;
}

1.5 顺序栈 vs 链式栈

对比项 顺序栈 链式栈
入栈/出栈时间复杂度 O ( 1 ) O(1) O(1) O ( 1 ) O(1) O(1)
空间问题 满了需扩容,有一定消耗 每个元素额外一个指针开销
CPU 缓存命中率 较高 较低(结点分散)
内存碎片 无 有
适用场景 一般首选 元素个数波动极大、不好预估时

结论:

  • 两者入栈出栈都是 O ( 1 ) O(1) O(1),效率都很高。
  • 顺序栈唯一的问题是入栈空间不够时扩容有消耗,但这个消耗几乎可以忽略 ,因为要插入一定量数据才会触发扩容(倍增扩容均摊 O ( 1 ) O(1) O(1))。
  • 顺序栈相比链式栈 CPU 高速缓存命中率更高,且没有内存碎片 。所以栈的实现一般使用顺序存储。

1.6 栈的应用

1.6.1 LeetCode 20. 有效的括号

题意分析 :括号的类型、顺序、数量都匹配才是有效括号。

  • 有效:()[]{}、(([]{}))
  • 无效:(]、([]{)}、(()

解题思路:利用栈后进先出的性质------遍历字符串:

  • 遇到左括号就入栈;
  • 遇到右括号 就取栈顶左括号进行匹配:
    • 类型匹配不上 → 类型或顺序不匹配;
    • 栈为空 → 右括号多了,数量不匹配;
    • 遍历结束栈不为空 → 左括号多了,数量不匹配。

结论:遍历结束、中途无不匹配、最后栈为空 ⇒ 有效括号。

版本一:封装 Stack 实现(工程推荐做法,可复用、易维护)

c 复制代码
// 注意:LeetCode 上 C++ 版本用了引用 &,纯 C 版本需改为指针
typedef char STDataType;
typedef struct Stack {
    STDataType* a;
    int top;        // 指向栈顶数据的下一个位置
    int capacity;
} Stack;

void       StackInit(Stack* pst);
void       StackDestroy(Stack* pst);
void       StackPush(Stack* pst, STDataType x);
STDataType StackPop(Stack* pst);
STDataType StackTop(Stack* pst);
bool       StackEmpty(Stack* pst);
int        StackSize(Stack* pst);

void StackInit(Stack* pst) {
    assert(pst);
    pst->a = NULL;
    pst->top = 0;
    pst->capacity = 0;
}

void StackDestroy(Stack* pst) {
    assert(pst);
    free(pst->a);
    pst->a = NULL;
    pst->top = pst->capacity = 0;
}

void StackPush(Stack* pst, STDataType x) {
    assert(pst);

    if (pst->top == pst->capacity) {
        int newcapacity = pst->capacity == 0 ? 4 : pst->capacity * 2;
        STDataType* tmp = (STDataType*)realloc(pst->a, newcapacity * sizeof(STDataType));
        if (tmp == NULL) {
            perror("realloc fail");
            return;
        }
        pst->a = tmp;
        pst->capacity = newcapacity;
    }

    pst->a[pst->top] = x;
    pst->top++;
}

STDataType StackPop(Stack* pst) {
    assert(pst);
    assert(!StackEmpty(pst));

    STDataType x = pst->a[pst->top - 1];
    pst->top--;
    return x;
}

STDataType StackTop(Stack* pst) {
    assert(pst);
    assert(!StackEmpty(pst));
    return pst->a[pst->top - 1];
}

bool StackEmpty(Stack* pst) {
    assert(pst);
    return pst->top == 0;
}

int StackSize(Stack* pst) {
    assert(pst);
    return pst->top;
}

///////////////////////////////////////////////////////////////////

bool isValid(char* s) {
    Stack st;
    StackInit(&st);

    while (*s) {
        // 1、左括号入栈
        if (*s == '(' || *s == '[' || *s == '{') {
            StackPush(&st, *s);
        }
        else {
            // 2、右括号:取栈顶左括号尝试匹配
            // 当前是右括号但栈为空,说明前面没有左括号了,不匹配
            if (StackEmpty(&st)) {
                StackDestroy(&st);
                return false;
            }

            char top = StackPop(&st);

            if ((top == '(' && *s != ')')
                || (top == '{' && *s != '}')
                || (top == '[' && *s != ']')) {
                StackDestroy(&st);
                return false;
            }
        }
        ++s;
    }

    // 栈不为空说明左括号比右括号多,数量不匹配
    bool ret = StackEmpty(&st);
    StackDestroy(&st);
    return ret;
}

版本二:静态数组当栈(适合纸面/上机考试等受限场景)

版本一的弊端是必须完整实现一个栈。如果只是考试中某道题要用栈,可以只给出结构定义和接口声明并备注功能,但上机考试还得硬着头皮写,很麻烦。更简便的方式是直接用一个静态数组当栈 ,再用 top 标记栈顶即可。

c 复制代码
bool isValid(char* s) {
    // 本题说明 s 的长度 <= 10000,直接开静态数组做栈
    int stackArr[10000];
    int top = 0;   // top 标记栈顶数据的下一个位置

    while (*s) {
        // 1、左括号入栈
        if (*s == '(' || *s == '[' || *s == '{') {
            stackArr[top++] = *s;
        }
        else {
            // 2、右括号:栈为空说明数量不匹配
            if (top == 0) {
                return false;
            }

            char topData = stackArr[--top];

            if ((topData == '(' && *s != ')')
                || (topData == '{' && *s != '}')
                || (topData == '[' && *s != ']')) {
                return false;
            }
        }
        ++s;
    }

    // 栈不为空,说明左括号比右括号多
    return top == 0;
}

重要说明 :实际项目中要用栈,还是要像版本一那样封装实现 。一是项目通常较大,多处都要用栈,封装后处处可用、便于维护;二是如果无法预估数据量,版本二并不实用。版本二更适合纸面考试或上机考试等局限场景。

1.6.2 LeetCode 946. 验证栈序列

题意 :一个入栈序列可能对应多个出栈序列,但有些出栈序列根本不合法。例如 pushed = [1,2,3,4,5]、popped = [4,3,5,1,2]:要出 4 必须先 push 1,2,3,4,接着连续 pop 4,3,再 push 5 并 pop 5;此时栈中还有 1,2,那么 pop 顺序只能是 2,1,不可能是 1,2。

解题思路 :用一个栈模拟入栈过程,看能否按出栈序列的顺序出栈。

  1. 每轮循环入一个 pushi 指向的数据;
  2. 持续循环,将栈顶数据与 popi 指向的出栈序列比较,直到栈为空或不匹配;
  3. 如果 pushi 没走到入栈序列末尾,继续下一轮。

结论 :pushi 走到入栈序列尾后,若 popi 也走到出栈序列尾(或栈为空),则说明全部匹配。

c 复制代码
bool validateStackSequences(int* pushed, int pushedSize, int* popped, int poppedSize) {
    size_t pushi = 0, popi = 0;
    // 本题说明长度 <= 1000,直接开静态数组做栈
    int stackArr[1000];
    int top = 0;   // top 标记栈顶数据的下一个位置

    while (pushi < pushedSize) {
        // 入栈序列值入栈
        stackArr[top++] = pushed[pushi++];

        // 持续匹配栈顶数据与出栈序列,直到栈为空或不匹配
        while (top != 0 && stackArr[top - 1] == popped[popi]) {
            --top;
            popi++;
        }
    }

    return top == 0;
}
1.6.3 表达式求值:中缀、前缀、后缀

背景 :任何表达式都由操作数、运算符、界限符组成。算术四则运算规则是:

  1. 先乘除,后加减;
  2. 从左算到右;
  3. 先括号内后括号外。

人能扫描整个表达式、全局确定优先级后依次运算,但计算机不行 ------计算机的本质是依次执行指令,从左往右读取数据,读到就要立即存储或运算,无法先"全局看一眼"。于是表达式表示法被发明出来解决这个问题:

时间 人物 贡献
1920 年代 波兰科学家 扬·武卡谢维奇(Jan Łukasiewicz) 发明前缀表达式 ,即波兰表达式(Polish Notation),运算符写在操作数之前
1950 年代 澳大利亚科学家 查尔斯·汉布林(Charles L. Hamblin) 发表论文提出后缀表达式 ,命名为逆波兰表达式(Reverse Polish Notation, RPN)以致敬 Łukasiewicz
1960 年代 荷兰科学家 艾兹格·迪杰斯特拉(Edsger W. Dijkstra) 发表《Algol 60 的逆波兰表示法与翻译》,给出中缀转后缀算法,即调度场算法(Shunting-yard)
1972 年 惠普(HP) 推出第一款使用 RPN 的商用计算器 HP-35

三种表示法对照 (以 1 - 2 * (3 + 5) / 4 为例):

表示法 别名 运算符位置 示例
中缀表达式 --- 两个操作数中间 x + y、1 - 2 * (3 + 5) / 4
前缀表达式 波兰表达式 两个操作数前面 + x y、- 1 / * 2 + 3 5 4
后缀表达式 逆波兰表达式 两个操作数后面 x y +、1 2 3 5 + * 4 / -
1.6.4 后缀表达式为什么适合计算机

后缀表达式的特点:操作数顺序不变、脱掉了括号、运算符在两个操作数后面、从左到右就能确定运算符优先级。因此最适合用栈运算:

  1. 遇到操作数就入栈保存;
  2. 遇到运算符就取栈顶两个数据运算 ,结果再入栈,作为后续运算的操作数。 ⚠️ 重点 :因为后缀表达式中操作数顺序不变,入栈顺序是左操作数、右操作数 ,所以出栈顺序是右操作数、左操作数 。+、* 交换左右顺序不影响结果,但 -、/ 交换左右顺序结果就错了------必须先弹出的是右操作数。
  3. 表达式结束,栈中最后剩下的数据就是运算结果。
1.6.5 手算:中缀转后缀

步骤:

  1. 提取中缀表达式的操作数,顺序保持不变;
  2. 提取中缀表达式的运算符 ,按优先级依次插入到对应操作数的后面。
    (注意:有些操作数本身是包含运算符的子表达式,因为实际运算时该子表达式的运算结果会作为下一步运算的操作数。)

例 1 :a + b - c * d / e

  • 去运算符和界限符得:abcde;运算符优先级顺序为:+ * / -
  • 后缀表达式:ab+cd*e/-

例 2 (练习):a * (b - (c + d) + e) / f + g

  • 去运算符和界限符得:abcdefg;运算符优先级顺序为:+ - + * / +
  • 后缀表达式:abcd+-e+*f/g+
1.6.6 手算:中缀转前缀

步骤 类似,区别是运算符按优先级插入到对应操作数的前面 ,优先级顺序从右往左确定。

例 :a + b - c * d / e

  • 去运算符和界限符得:abcde
  • 前缀表达式:-+ab/*cde
1.6.7 机算:用栈实现中缀转后缀(重点算法)

中缀转后缀需要用栈存储前面的运算符并比较优先级。计算机从左往右读取中缀表达式:

遇到 处理
操作数 直接加入后缀表达式(操作数顺序不变,要确定的是运算符顺序)
运算符 x ,栈空 或 x 优先级 > 栈顶运算符 y x 入栈
运算符 x ,x 优先级 ≤ 栈顶运算符 y 栈顶 y 出栈并加入后缀表达式,x 继续与新的栈顶比较(回到上面两种情况之一)
左括号 ( 直接入栈
右括号 ) 依次弹出栈顶运算符加入后缀表达式,直到遇到 ( 为止;() 本身不加入后缀表达式,直接丢弃
表达式读取结束 将栈中所有运算符依次出栈并加入后缀表达式

每一步背后的原理:

  • 为什么栈空就入栈? 因为只有相邻运算符比较才能确定优先级,栈空没有可比较对象,只能入栈。
  • 为什么 x 优先级更高就入栈? 下一个运算符比前一个优先级高,说明还不能确定运算顺序 ------后续可能还有优先级更高的运算符。例如 1+2*(3-4):遇到 + 时不能运算,因为后面有 *;遇到 * 时也不能运算,因为后面还有 ()。
  • 为什么 x 优先级更低或相等就出栈? 下一个运算符比前一个优先级低(或相等),说明前一个运算符已经可以运算了 。例如 1+2-3*4:遇到 + 不能运算,遇到 - 与 + 优先级相等,则 + 可以运算,后面的 * 也不影响。
  • 为什么左括号 ( 优先级设为最低? 括号内本质是一个高优先级运算的子表达式。把 ( 优先级设为最低(比 +、- 都低),就能保证括号内子表达式的第一个运算符 x 一定满足"x 优先级高于栈顶 (",从而顺利入栈。例如 1*(2-3*4):遇到 ( 入栈,遇到 - 比 ( 优先级高,则入栈。
  • 为什么遇到右括号要弹出到左括号? 遇到 ) 表示当前子表达式结束,括号内的子表达式都要先运算,所以把 ( 之前的所有运算符出栈。这样括号内的转换逻辑就与外层隔离开 了,内层 () 子表达式可以被当成外层的一个操作数。例如 (2+3*4):遇到 ( 入栈,+ 入栈,* 入栈,遇到 ) 后把 *、+ 依次出栈加入后缀表达式。
  • 为什么表达式结束后全部出栈? 表达式结束时栈中剩下的运算符,从栈底到栈顶优先级是不断变高的 ,所以依次出栈的顺序就是优先级由高到低。例如 1+2*3:+ 入栈,* 入栈,表达式结束,依次把 *、+ 出栈加入后缀表达式。

运算符优先级定义 :乘除 > 加减;乘除相等,加减相等;左括号优先级最低。

代码实现:

c 复制代码
#include <stdio.h>
#include <assert.h>
#include <stdbool.h>

struct opPD {
    char op;   // 运算符
    int pd;    // 优先级
};

// 获取运算符优先级
int operatorPrecedence(char ch) {
    // '(' 的优先级需要很低,方便 '(' 后面的下一个运算符入栈
    static struct opPD arr[5] = { {'+', 1}, {'-', 1}, {'*', 2}, {'/', 2}, {'(', 0} };
    for (int i = 0; i < 5; ++i) {
        if (arr[i].op == ch) {
            return arr[i].pd;      // 注意:原文此处写作 e.pd,属笔误,应为 arr[i].pd
        }
    }
    // 传入的不是以上运算符则报错
    assert(false);
    return -1;
}

// 判断是否是操作数
bool isOperand(const char* str) {
    if (str[0] >= '0' && str[0] <= '9')
        return true;
    else if (str[0] == '-' && str[1] >= '0' && str[1] <= '9')   // 负号
        return true;
    return false;
}

void toRPN(const char* tokens[], int tokensSize) {
    // 假设 tokensSize <= 10000,直接用静态数组做栈
    char stackArr[10000];
    int top = 0;   // 标记栈顶数据的下一个位置

    for (int i = 0; i < tokensSize;) {
        const char* str = tokens[i];
        if (isOperand(str)) {
            // 操作数直接输出
            printf("%s ", str);
            ++i;
        }
        else {
            if (str[0] == '(') {
                // 左括号入栈
                stackArr[top++] = str[0];
                ++i;
            }
            else if (str[0] == ')') {
                // 右括号:将栈中的运算符全部出栈,直到遇到左括号
                while (stackArr[top - 1] != '(') {
                    printf("%c ", stackArr[--top]);
                }
                --top;   // 删掉左括号
                ++i;
            }
            else {
                // 运算符
                // 1、栈为空 或 当前运算符比栈顶优先级高 -> 当前运算符入栈
                // 2、栈不为空 且 比栈顶优先级低或相等 -> 输出栈顶运算符,当前运算符再比较一轮
                if (top == 0 || operatorPrecedence(str[0]) > operatorPrecedence(stackArr[top - 1])) {
                    stackArr[top++] = str[0];
                    ++i;
                }
                else {
                    printf("%c ", stackArr[--top]);
                }
            }
        }
    }

    // 表达式结束,栈中运算符全部输出
    while (top != 0) {
        printf("%c ", stackArr[--top]);
    }
}

int main() {
    // "1+2-(3*4+5)-6"
    const char* expression[] = { "1","+","2","-","(","3","*","4","+","5",")","-","6" };
    toRPN(expression, sizeof(expression) / sizeof(expression[0]));
    // 输出:1 2 + 3 4 * 5 + - 6 -
    return 0;
}
1.6.8 LeetCode 150. 逆波兰表达式求值
c 复制代码
// 判断是否是操作数
bool isOperand(const char* str) {
    if (str[0] >= '0' && str[0] <= '9')
        return true;
    else if (str[0] == '-' && str[1] >= '0' && str[1] <= '9')
        return true;
    return false;
}

int evalRPN(char** tokens, int tokensSize) {
    // 本题说明长度 <= 10000,直接用静态数组做栈
    int stackArr[10000];
    int top = 0;

    for (int i = 0; i < tokensSize; ++i) {
        char* str = tokens[i];

        if (isOperand(str)) {
            // 操作数:字符串转整数入栈
            stackArr[top++] = atoi(str);
        }
        else {
            // 取栈顶数据:先取到的是右操作数,再取到左操作数
            int right = stackArr[--top];
            int left = stackArr[--top];

            // 运算结果再入栈
            switch (str[0]) {
                case '+': stackArr[top++] = left + right; break;
                case '-': stackArr[top++] = left - right; break;
                case '*': stackArr[top++] = left * right; break;
                case '/': stackArr[top++] = left / right; break;
            }
        }
    }

    return stackArr[top - 1];
}

易错点 :int right = stackArr[--top]; 必须在 int left = stackArr[--top]; 之前,否则减法、除法结果就反了。

1.7 栈与递归

  • C 语言中函数调用需要为每个函数建立栈帧,栈帧中保存当前函数的局部变量、参数等。
  • 递归的核心思想是"分而治之" :将大问题分解为相似的子问题,子问题再分解为更小的问题,不断"套娃",直到问题不能再分解。递归在底层执行层面,本质就是建立很多栈帧,栈帧中保存分解出的子问题数据。
  • 递归的缺陷 :极端场景下数据量级太大,递归深度太深,不断建立栈帧会导致栈溢出。例如下面的阶乘程序 N 取 10000 基本就会栈溢出,意味着这类问题不能使用递归实现。
c 复制代码
// 计算递归求阶乘 Fac 算法的空间复杂度?
long long Fac(size_t N) {
    if (N <= 0)
        return 1;
    return Fac(N - 1) * N;
}
  • 递归改非递归 :基本都是用循环 实现。有些递归可以直接改成循环,如阶乘、斐波那契数列、归并排序 的递归实现;有些则必须借助栈辅助 实现,如快速排序、二叉树遍历。

考点提示 :递归求阶乘的空间复杂度是 O ( N ) O(N) O(N),因为递归深度为 N,需要 N 个栈帧。


二、队列(Queue)

2.1 基本概念

队列(queue)是限定在一端插入、另一端删除的线性表。 因此:

术语 含义
队尾(rear) 允许插入 的那一端,插入叫入队
队头(front) 允许删除 的那一端,删除叫出队
空队列 不含任何元素的队列

队列的核心特性是 先进先出(FIFO, First In First Out)。

重点对比 :队列的入队出队顺序简单得多 ------无论如何操作,一种入队顺序只对应一种出队顺序。而栈一种入栈顺序可以对应多种出栈顺序(卡特兰数种)。这是栈与队列最本质的区别,也是考试最爱考的点。

队列的基本操作接口:

c 复制代码
void      QueueInit(LinkQueue* q);                    // 初始化队列
void      QueueDestroy(LinkQueue* q);                 // 销毁队列
bool      QueueEmpty(LinkQueue* q);                   // 判空
int       QueueSize(LinkQueue* q);                    // 有效元素个数
QDataType QueueFront(LinkQueue* q);                   // 获取队头元素
void      EnQueue(LinkQueue* q, QDataType x);         // 入队
QDataType DeQueue(LinkQueue* q);                      // 出队并返回队头数据

2.2 队列的链式存储

2.2.1 结构选择
  1. 队列的链式存储可选单链表 或双向链表 。它们入队对应表尾插入,出队对应表头删除。
  2. 同样没必要选双向链表------单链表就能高效实现且更省空间,双向链表每个结点多存一个前驱指针,纯属浪费。
  3. 单链表实现时,可以带头结点,也可以不带头结点 。本文选择带头结点结构,代码略简单一点(队列空和非空时入队逻辑一致)。
2.2.2 链式队列实现
c 复制代码
// LinkQueue.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

typedef int QDataType;   // 队列中存储数据元素的类型

// 单链表结点定义
typedef struct LinkNode {
    QDataType data;
    struct LinkNode* next;
} QNode;

typedef struct {
    QNode* front;   // 队头指针(指向哨兵位头结点)
    QNode* rear;    // 队尾指针
    int size;       // 有效元素个数
} LinkQueue;

void      QueueInit(LinkQueue* q);
void      QueueDestroy(LinkQueue* q);
bool      QueueEmpty(LinkQueue* q);
int       QueueSize(LinkQueue* q);
QDataType QueueFront(LinkQueue* q);
void      EnQueue(LinkQueue* q, QDataType x);
QDataType DeQueue(LinkQueue* q);
c 复制代码
// LinkQueue.c
#include "LinkQueue.h"

void QueueInit(LinkQueue* q) {
    assert(q);
    // 带头结点的结构:front 和 rear 都指向哨兵位头结点
    q->front = q->rear = (QNode*)malloc(sizeof(QNode));
    if (q->front == NULL) {
        printf("QueueInit: 申请结点失败\n");
        exit(-1);
    }
    q->front->next = NULL;
    q->size = 0;
}

void QueueDestroy(LinkQueue* q) {
    assert(q);

    QNode* cur = q->front;
    while (cur) {
        QNode* next = cur->next;
        free(cur);
        cur = next;
    }
    q->front = q->rear = NULL;
    q->size = 0;
}

bool QueueEmpty(LinkQueue* q) {
    assert(q);
    return q->size == 0;
}

int QueueSize(LinkQueue* q) {
    assert(q);
    return q->size;
}

QDataType QueueFront(LinkQueue* q) {
    assert(q);
    assert(!QueueEmpty(q));
    return q->front->next->data;   // 头结点的下一个才是真正的队头
}

// 入队:表尾插入。带头结点时,队列空和非空代码完全一样
void EnQueue(LinkQueue* q, QDataType x) {
    assert(q);

    QNode* newNode = (QNode*)malloc(sizeof(QNode));
    if (newNode == NULL) {
        printf("EnQueue: 申请结点失败\n");
        exit(-1);
    }
    newNode->data = x;
    newNode->next = NULL;

    q->rear->next = newNode;
    q->rear = newNode;
    q->size++;
}

// 出队:删除头结点后的第一个结点,需区分多个结点和最后一个结点
QDataType DeQueue(LinkQueue* q) {
    assert(q);
    assert(!QueueEmpty(q));

    QNode* delNode = q->front->next;
    QDataType x = delNode->data;

    q->front->next = delNode->next;
    free(delNode);
    delNode = NULL;
    q->size--;

    // 最后一个结点被删掉后,rear 要重新指向哨兵位头结点
    if (q->front->next == NULL) {
        q->rear = q->front;
    }

    return x;
}

易错点(面试常问) :出队时必须判断是否删掉了最后一个结点 ,若是则要把 rear 重新指向哨兵位头结点。否则 rear 就成了野指针 ,下次入队时 q->rear->next = newNode 直接崩溃。

2.3 队列的顺序存储与假溢出

2.3.1 顺序队列的结构与问题

顺序存储实现队列,入队对应尾插,出队对应头删。但顺序存储直接头删需要挪动数据,时间复杂度 O ( N ) O(N) O(N) ,效率太低就没有价值了。所以可以换个思路:出队头删时不再挪动数据,而是移动队头的下标或指针。

c 复制代码
#define MAX_SIZE 8
typedef int QDataType;

typedef struct SqQueue {
    QDataType arr[MAX_SIZE];
    int front;   // 指向队头
    int rear;    // 指向队尾的下一个位置
} SqQueue;
2.3.2 假溢出问题

顺序队列的经典问题是假溢出(false overflow):

队列的存储空间(数组)中明明还有空闲位置 ,但由于队尾指针 rear 已经移动到了数组末尾,导致无法再插入新元素,系统错误地判断为"队列已满"。

它之所以被称为"假 "溢出,是因为它并非真正的空间耗尽,而是数据结构设计缺陷导致的空间浪费。

解决假溢出的经典方法是使用循环队列:

  • 入队后 rear 更新:rear = (rear + 1) % 数组容量
  • 出队后 front 更新:front = (front + 1) % 数组容量
2.3.3 顺序队列的第二个问题

顺序队列更适合静态顺序结构,不太适合动态顺序结构------因为满了扩容时相对复杂且效率低下。

结论 :既然这么麻烦,不确定有多少数据时,直接用链式队列更好 。所以封装实现队列一般都用链式结构,顺序存储适宜于明确知道最多使用多少空间的局限场景。

2.4 循环队列

2.4.1 循环队列的概念

循环队列是一种线性数据结构,操作表现基于 FIFO 原则,并且队尾被连接在队首之后形成一个循环 ,也被称为环形缓冲区(ring buffer)。

循环队列的好处 :可以利用这个队列之前用过的空间。普通队列一旦满了就不能再插入下一个元素,即使队列前面仍有空间;而循环队列能利用这些空间存储新值。

循环队列逻辑上是一个环状,既可以用顺序结构实现,也可以用链式结构实现。

2.4.2 ⚠️ 判空与判满(核心考点)

front 指向队头,rear 指向队尾的下一个位置,此时循环队列的一个问题是:队列空和满时条件都是 front == rear,导致无法区分空还是满!

解决方案主要有两种:

方案一(最经典):牺牲一个空间

少存储一个数据,N 个空间只能存储 N-1 个值:

  • 队空 :front == rear
  • 队满 :(rear + 1) % N == front(逻辑上 rear 的下一个位置是 front)

方案二:增加计数或标志位

  • 增加 count 计数:count == 0 为空,count == N 为满(N 是队列大小);
  • 或增加 isInsert 标志:初始 isInsert == false,插入时置 true,删除时置 false。
    • front == rear && isInsert == false → 空
    • front == rear && isInsert == true → 满
2.4.3 顺序存储实现循环队列(LeetCode 622)

采用 front 指向队头、rear 指向队尾下一个位置、牺牲一个空间 的方案。本题定义结构时不知道队列长度,所以队列结构变量和数组都需要动态创建和释放;日常若知道队列长度,可以定义静态数组和静态结构变量。

c 复制代码
// 选择牺牲一个空间的方式解决判空和判满问题
typedef struct {
    int* a;      // 指向数组的指针
    int front;   // 指向队头
    int rear;    // 指向队尾的下一个位置
    int N;       // 空间大小,队列长度为 N-1
} MyCircularQueue;

MyCircularQueue* myCircularQueueCreate(int k) {
    MyCircularQueue* obj = (MyCircularQueue*)malloc(sizeof(MyCircularQueue));
    // 【关键】队列长度为 k,但数组必须开 k+1 个空间
    obj->a = (int*)malloc(sizeof(int) * (k + 1));
    obj->rear = 0;
    obj->front = 0;
    obj->N = k + 1;
    return obj;
}

void myCircularQueueFree(MyCircularQueue* obj) {
    free(obj->a);
    free(obj);
}

// 判满:rear 的下一个位置是 front
bool myCircularQueueIsFull(MyCircularQueue* obj) {
    return (obj->rear + 1) % obj->N == obj->front;
}

// 判空:front == rear
bool myCircularQueueIsEmpty(MyCircularQueue* obj) {
    return obj->front == obj->rear;
}

bool myCircularQueueEnQueue(MyCircularQueue* obj, int value) {
    if (myCircularQueueIsFull(obj))
        return false;

    obj->a[obj->rear] = value;
    obj->rear++;
    obj->rear %= obj->N;      // 走到 N 位置时回绕到 0,形成逻辑上的环

    return true;
}

bool myCircularQueueDeQueue(MyCircularQueue* obj) {
    if (myCircularQueueIsEmpty(obj))
        return false;

    obj->front++;
    obj->front %= obj->N;
    return true;
}

// 取队头数据:front 就指向队头位置
int myCircularQueueFront(MyCircularQueue* obj) {
    if (myCircularQueueIsEmpty(obj))
        return -1;
    return obj->a[obj->front];
}

// 取队尾数据:rear - 1 才是队尾,注意回绕时要 +N 再取模
int myCircularQueueRear(MyCircularQueue* obj) {
    if (myCircularQueueIsEmpty(obj))
        return -1;
    return obj->a[(obj->rear - 1 + obj->N) % obj->N];
}

两个高频易错点:

  1. myCircularQueueCreate(k) 中必须 malloc(k+1) 个空间,因为牺牲了一个;
  2. 取队尾要用 (rear - 1 + N) % N,必须先 +N 再取模 ,否则 C 语言中 -1 % N 结果是 -1,会越界访问。
2.4.4 链式存储实现循环队列(了解)

本节在学校考试、408 和考研自命题中可能变形出题(408 有一年考过变形综合题),属次重点;校招基本不考这种要求。掌握上面的顺序存储实现即可。

  • 设计一个循环单链表,逻辑和存储上都是一个环。
  • 若用"牺牲一个空间"的方式判空判满,front 指向队头、rear 指向队尾的下一个。但本题要求取队尾数据 ,而单链表中取队尾很麻烦(需要 O ( N ) O(N) O(N) 遍历)。解决方案有多种:双向链表,或者一个指针 rear 记录队尾、另一个 rearNext 记录队尾下一个。
  • 下面的实现选择用 size 记录数据个数 判断满和空,front 指向队头、rear 指向队尾,这样入队出队、判空判满都很容易。注意初始化时 rear 指向 front 的前一个结点 ,因为入队是让 rear 走到下一个结点再插入数据。
c 复制代码
typedef struct LinkNode {
    struct LinkNode* next;
    int val;
} Node;

typedef struct {
    Node* front;   // 指向队头
    Node* rear;    // 指向队尾
    int size;      // 数据个数
    int k;         // 队列长度
} MyCircularQueue;

MyCircularQueue* myCircularQueueCreate(int k) {
    MyCircularQueue* obj = (MyCircularQueue*)malloc(sizeof(MyCircularQueue));
    // 先创建第一个结点
    obj->front = obj->rear = (Node*)malloc(sizeof(Node));
    // 再创建 k-1 个结点尾插链接起来
    for (int i = 1; i < k; ++i) {
        Node* newNode = (Node*)malloc(sizeof(Node));
        obj->rear->next = newNode;
        obj->rear = newNode;
    }
    // 构成循环链表
    obj->rear->next = obj->front;
    obj->k = k;
    obj->size = 0;
    return obj;
}

bool myCircularQueueIsFull(MyCircularQueue* obj) {
    return obj->size == obj->k;
}

bool myCircularQueueIsEmpty(MyCircularQueue* obj) {
    return obj->size == 0;
}

bool myCircularQueueEnQueue(MyCircularQueue* obj, int value) {
    if (myCircularQueueIsFull(obj))
        return false;
    // rear 指向队尾,所以 rear 往后走一步再插入数据
    obj->rear = obj->rear->next;
    obj->rear->val = value;
    obj->size++;
    return true;
}

bool myCircularQueueDeQueue(MyCircularQueue* obj) {
    if (myCircularQueueIsEmpty(obj))
        return false;
    obj->front = obj->front->next;
    obj->size--;
    return true;
}

int myCircularQueueFront(MyCircularQueue* obj) {
    if (myCircularQueueIsEmpty(obj))
        return -1;
    return obj->front->val;
}

int myCircularQueueRear(MyCircularQueue* obj) {
    if (myCircularQueueIsEmpty(obj))
        return -1;
    return obj->rear->val;
}

void myCircularQueueFree(MyCircularQueue* obj) {
    // 合计 k 个结点,直接循环 k 次释放
    Node* cur = obj->front;
    for (int i = 0; i < obj->k; ++i) {
        Node* next = cur->next;
        free(cur);
        cur = next;
    }
    free(obj);
}

2.5 双端队列

2.5.1 基本概念

双端队列(deque, double-ended queue) 也是一种特殊线性结构,允许两端都可以进行数据元素的入队和出队操作 。将队列的两端分别称为前端 和后端。

  • 入队时 :前端进的元素排列在队列中后端进的元素前面 ;后端进的元素排列在队列中前端进的元素后面。
  • 出队时 :无论是前端还是后端出队,先出的元素排列在后出的元素前面。
2.5.2 双端队列的实现
  • 链式存储 :因为两端都要插入删除,双向链表比较适合。
  • 顺序存储 :普通数组实现相对不合适,更适合用优化改进的组合结构 实现------C++ 标准库中的 deque 就是用这种结构实现的。

考试要求 :双端队列在 408 / 学校考试 / 校招面试中主要考察结构概念,了解即可,不需要深入代码实现。

2.6 队列的应用

  • 先进先出、先来先服务 的场景都可用队列。例如去营业厅办理业务、去医院门诊看病------在抽号机抽个号(入队 ),然后等着叫号(出队 ),队列保证了先来先服务,保证了公平性。
  • 后续树和图等更复杂的数据结构中,广度优先遍历(BFS)也需要用队列作为辅助数据结构。

三、数组与特殊矩阵压缩存储

重要程度 :本章在学校考试、408、考研自命题中为次重点 ,校招中基本不考。但矩阵压缩存储的下标计算是 408 的高频计算题,务必掌握推导方法。

3.1 数组的存储结构

3.1.1 一维数组
  • 数组是由 n(n ≥ 1)个相同类型数据元素组成的有限序列,存储在一段连续的存储单元中。
  • 数组中的元素通过下标 访问。若无特殊说明,下标从 0 开始 ,对于具有 N 个元素的数组,下标 i 范围为 [0, N-1]。
  • 设定义 Type a[N],则:

a i 的地址 = 数组起始地址 + sizeof(Type) × i ai\text{ 的地址} = \text{数组起始地址} + \text{sizeof(Type)} \times i ai 的地址=数组起始地址+sizeof(Type)×i

3.1.2 二维数组

若一维数组中的每个元素也是一个一维数组,这样形成的结构称为二维数组。

对于 M 行 N 列 的二维数组 a[M][N],其在内存中存储时本质是开辟 M × N 个连续存储单元 ,可以按行优先 存储,也可以按列优先存储。

语言 默认存储方式
C / C++ / BASIC / PASCAL 行优先(Row-major)
FORTRAN 列优先(Column-major)

行优先存储的地址计算公式(最重要的公式):

addr ( A i j ) = start + ( i × N + j ) × sizeof(Type) \text{addr}(Aij) = \text{start} + (i \times N + j) \times \text{sizeof(Type)} addr(Aij)=start+(i×N+j)×sizeof(Type)

列优先存储的地址计算公式:

addr ( A i j ) = start + ( j × M + i ) × sizeof(Type) \text{addr}(Aij) = \text{start} + (j \times M + i) \times \text{sizeof(Type)} addr(Aij)=start+(j×M+i)×sizeof(Type)

验证代码:

c 复制代码
#include <stdio.h>

int main() {
    // 定义 3 行 4 列的二维数组 A
    int A[3][4] = {{1, 2, 3, 4},
                   {5, 6, 7, 8},
                   {9, 10, 11, 12}};

    // C 语言二维数组按行优先存储
    // 第 2 行第 3 列(下标从 0 开始)的数据是 12,打印其地址
    printf("%p\n", &A[2][3]);

    // Type A[M][N] 的二维数组,假设起始地址为 start
    // 则 A[i][j] 的存储地址 = start + (i * N + j) * sizeof(Type)
    // 按行优先公式计算 12 的地址,与上一句结果相同
    // 说明:&A[0][0] 的类型是 int*,int* 加 1 就是加一个 int 大小(4 byte)
    // 所以这里的写法没有 *sizeof(int)
    printf("%p\n", &A[0][0] + 2 * 4 + 3);

    return 0;
}

3.2 矩阵压缩存储概述

矩阵 是一个按行和列排列成的矩形数字阵列,是线性代数的基础,也是人工智能的基石。在计算机中通常用二维数组存储矩阵。

为什么需要压缩存储? 在数值分析中常出现阶数很高的矩阵,同时矩阵中有许多相同元素 或零元素 ,用二维数组存储太浪费空间。为了节省空间,可以对矩阵进行压缩存储:

压缩存储 :想办法开空间(如用一维数组)存储那些不规律的值,为相同的元素只分配一个存储空间,对零元素不分配空间。

矩阵分类:

类型 定义
特殊矩阵 值相同的元素或零元素在矩阵中的分布有一定规律
稀疏矩阵 零元素占比极高,但无规律
稠密矩阵 零元素占比很低,且无规律

3.3 对称矩阵

3.3.1 定义

若 n 阶矩阵 A n × n A_{n \times n} An×n 中的元素满足:

a i j = a j i ( 1 ≤ i , j ≤ n ) a_{ij} = a_{ji} \quad (1 \le i, j \le n) aij=aji(1≤i,j≤n)

则称 A 为 n 阶对称矩阵。

⚠️ 重要提示 :很多地方描述矩阵行列号时通常从 1 开始 ,而存储的二维数组描述行列下标从 0 开始,做题时一定注意看题目说明!

区域划分:

  • 主对角线 上的元素: a i , j a_{i,j} ai,j 中 i = j i = j i=j
  • 上三角区 :主对角线上方所有元素, i < j i < j i<j
  • 下三角区 :主对角线下方所有元素, i > j i > j i>j
3.3.2 需要存储多少个元素

回顾等差数列: a 1 , a 2 , a 3 , ... , a n a_1, a_2, a_3, \dots, a_n a1,a2,a3,...,an,前 n 项和公式:

S n = ( a 1 + a n ) × n 2 S_n = \frac{(a_1 + a_n) \times n}{2} Sn=2(a1+an)×n

上三角区和下三角区各自有 n × ( n − 1 ) 2 \dfrac{n \times (n-1)}{2} 2n×(n−1) 个元素 (是等差数列:上三角第一行 n-1 个、第二行 n-2 个......),并且这两部分是重复的。

所以压缩存储时,只需要存储"主对角线 + 上三角"或"主对角线 + 下三角"即可;甚至有些矩阵对角线元素全是 0 或全相等,那就只需要存储上三角区或下三角区。

共需存储:

n ( n − 1 ) 2 + n = n ( n + 1 ) 2 个元素 \frac{n(n-1)}{2} + n = \frac{n(n+1)}{2} \text{ 个元素} 2n(n−1)+n=2n(n+1) 个元素

3.3.3 下标计算公式(核心)

以存储"主对角线 + 下三角"、行优先为例:

  • 第一行 1 个,第二行 2 个,第三行 3 个......第 n 行 n 个,是等差数列,压缩到一个存储 n ( n + 1 ) 2 \dfrac{n(n+1)}{2} 2n(n+1) 个数据的一维数组中。
  • 对任意元素 a i j a_{ij} aij( i ≥ j i \ge j i≥j):
    • 第 1 行到第 i − 1 i-1 i−1 行共存储 1 + 2 + ⋯ + ( i − 1 ) = i ( i − 1 ) 2 1 + 2 + \dots + (i-1) = \dfrac{i(i-1)}{2} 1+2+⋯+(i−1)=2i(i−1) 个元素;
    • a i j a_{ij} aij 是第 i 行的第 j j j 个元素;
    • 因为数组下标从 0 开始,所以在一维数组中的下标为:

k = i ( i − 1 ) 2 + ( j − 1 ) k = \frac{i(i-1)}{2} + (j - 1) k=2i(i−1)+(j−1)

存储"主对角线 + 下三角"、列优先:

  • 第一列 n 个,第二列 n-1 个......第 n 列 1 个;
  • 对任意元素 a i j a_{ij} aij( i ≥ j i \ge j i≥j),第 1 列到第 j − 1 j-1 j−1 列共存储:

n + ( n − 1 ) + ⋯ + ( n − j + 2 ) = ( n + ( n − j + 2 ) ) × ( j − 1 ) 2 n + (n-1) + \dots + (n-j+2) = \frac{(n + (n-j+2)) \times (j-1)}{2} n+(n−1)+⋯+(n−j+2)=2(n+(n−j+2))×(j−1)

  • a i j a_{ij} aij 是第 j 列的第 i − j + 1 i-j+1 i−j+1 个元素,所以下标为:

k = ( n + n − j + 2 ) ( j − 1 ) 2 + ( i − j + 1 ) − 1 k = \frac{(n + n - j + 2)(j-1)}{2} + (i - j + 1) - 1 k=2(n+n−j+2)(j−1)+(i−j+1)−1

两个对称结论(重要,能省一半记忆量):

  • 压缩存储**"主对角线 + 上三角"的行优先**,等价于**"主对角线 + 下三角"的列优先**;
  • 压缩存储**"主对角线 + 上三角"的列优先**,等价于**"主对角线 + 下三角"的行优先**。

学习建议 :上面这些公式不需要背 (也记不住)。只需要理解推导过程,遇到题目时画图分析,再用等差数列公式推导即可。这是本小节最重要的方法论。

3.4 三角矩阵

  • 上三角矩阵:矩阵的**下三角(不包括对角线)**中的元素均为某个常数或零的 n 阶矩阵。
  • 下三角矩阵:矩阵的**上三角(不包括对角线)**中的元素均为某个常数或零的 n 阶矩阵。

存储方式 :三角矩阵也适合用行优先或列优先压缩存储,但需要再多开一个空间 存储那个元素相同的三角区的常量值。

共需空间 = n ( n + 1 ) 2 + 1 \text{共需空间} = \frac{n(n+1)}{2} + 1 共需空间=2n(n+1)+1

3.5 三对角矩阵

三对角矩阵 又称带状矩阵 ,其所有非零元素都集中在主对角线、以及主对角线上下两条对角线的带状区域中,其余位置元素全部为零。

特点:

  • 第一行和最后一行只有 2 个元素,其余 n-2 行都是 3 个元素;
  • 元素总个数为:

3 ( n − 2 ) + 4 = 3 n − 2 3(n-2) + 4 = 3n - 2 3(n−2)+4=3n−2

  • 对元素 a i j a_{ij} aij:当 ∣ i − j ∣ > 1 |i - j| > 1 ∣i−j∣>1 时 a i j = 0 a_{ij} = 0 aij=0;压缩存储只需存储所有 ∣ i − j ∣ ≤ 1 |i-j| \le 1 ∣i−j∣≤1 位置上的元素。

行优先压缩存储的下标推导:

  • 前 i − 1 i-1 i−1 行有 3 ( i − 1 ) − 1 3(i-1) - 1 3(i−1)−1 个数据(第一行只有 2 个,所以减 1);
  • a i j a_{ij} aij 是当前行的第 j − i + 2 j - i + 2 j−i+2 个元素;
  • 因此 a i j a_{ij} aij 是一维数组的第 3 ( i − 1 ) − 1 + ( j − i + 2 ) = 2 i + j − 2 3(i-1) - 1 + (j - i + 2) = 2i + j - 2 3(i−1)−1+(j−i+2)=2i+j−2 个元素;
  • 因为数组下标从 0 开始,所以:

k = 2 i + j − 3 k = 2i + j - 3 k=2i+j−3

同样不需要背公式,理解三对角矩阵的结构后根据原理推导即可。列优先存储是类似的方式。

3.6 稀疏矩阵

3.6.1 稀疏因子

稀疏矩阵没有确切的定义 ,一般认为非零元素远小于矩阵元素个数的就是稀疏矩阵。

有一种说法:在 m × n m \times n m×n 的矩阵中,有 t 个非零元素,令

δ = t m × n \delta = \frac{t}{m \times n} δ=m×nt

称 δ \delta δ 为矩阵的稀疏因子 ,通常认为 δ ≤ 0.05 \delta \le 0.05 δ≤0.05 时称为稀疏矩阵。

3.6.2 两种存储方式

稀疏矩阵使用二维数组存储同样很浪费空间,但因为它的元素分布没有规律,需要考虑新的方案。主要有两种存储方式:

方式一:三元组 {行, 列, 值}

把所有的非零值及其行列位置一起存储起来。定义如下:

c 复制代码
// 三元组的定义
typedef struct Triple {
    int i;              // 行号
    int j;              // 列号
    SMDataType data;    // 非零元素的值
} Triple;

#define SMMAX_SIZE 100

// 稀疏矩阵三元组顺序存储结构
typedef struct {
    Triple array[SMMAX_SIZE];   // 存放三元组
    int m;                      // 矩阵行数
    int n;                      // 矩阵列数
    int size;                   // 非 0 元素个数
} SparseMatrix;

重要结论(2023 年 408 考点) :采用三元组表存储稀疏矩阵时,除了三元组表,还需要保存原矩阵的行数和列数,否则无法将矩阵还原。

方式二:十字链表

当矩阵的非零元个数和位置在操作过程中变化比较大 时(如矩阵相加),就不宜采用三元组结构存储------因为这些运算可能增加或减少三元组,需要挪动数据,效率太低,更适合十字链表结构。

十字链表的原理 :在三元组结构基础上增加一个向右的指针 right 和一个向下的指针 down 构成结点:

  • 通过 right 指针链接同一行的下一个非零元;
  • 通过 down 指针链接同一列的下一个非零元;
  • 整个矩阵构成一个形如十字交叉的链表。
c 复制代码
typedef struct LinkNode {
    int i;                    // 非零元素的行号
    int j;                    // 非零元素的列号
    DataType data;            // 非零元素
    struct LinkNode* right;   // 指向同一行中下一个非零元
    struct LinkNode* down;    // 指向同一列中下一个非零元
} LNode;

#define M 10   // 行数
#define N 5    // 列数

// 十字链表结构
typedef struct {
    LNode* rowArr[M];   // 行链头指针的数组
    LNode* colArr[N];   // 列链头指针的数组
    int size;           // 非 0 元素个数
} CrossList;

三元组 vs 十字链表 选择建议:

场景 推荐结构
非零元个数和位置基本不变(如只做读取、转置) 三元组表(顺序存储,简单高效)
非零元个数和位置频繁变化(如矩阵相加、相乘) 十字链表(链式存储,插入删除方便)

四、408 真题汇总表

上面各节已嵌入真题解析,这里汇总便于专项复习:

年份/题号 考点 答案
2011 选择 02 以 d 开头的出栈序列个数 B(4)
2011 选择 03 循环队列初始 front / rear 的值 B(0, n-1)
2013 选择 02 已知 p₂=3 求 p₃ 取值个数 C(n-1)
2010 选择 01 不允许连续三次退栈的出栈序列 D(a,f,e,d,c,b)
2010 选择 02 双端队列(一端入队两端出队)不可能序列 C(d,b,c,a,e)
2014 选择 03 循环队列判空判满条件 A(end1end2 / end1(end2+1) mod M)
2015 选择 01 递归调用栈帧自底到顶的顺序 A(main()→S(1)→S(0))
2016 选择 04 三对角矩阵行优先压缩下标 B(87)
2017 选择 02 栈的相关概念判断(错误的是) C(I、III、IV)
2017 选择 03 稀疏矩阵的两种压缩存储结构 A(三元组表和十字链表)
2018 选择 01 双栈模拟表达式求值(调用 3 次 F) B(15)
2018 选择 03 对称矩阵上三角行优先压缩下标 A(50)
2020 选择 01 对称矩阵上三角列优先压缩下标 C(22)
2020 选择 02 给定 Push/Pop 序列求输出 D(b,c,e)
2021 选择 03 二维数组行优先地址计算 B(300)
2023 选择 03 三元组表还需保存的信息 A(I、III)
2024 选择 02 中缀表达式转后缀表达式 A(xyzu-*v/+)
2025 选择 02 括号匹配的栈容量需求 D(a-(b+\[c\*(d+e)-f+g+h)])
补充题 1 栈 S 与队列 Q 结合,求 S 的最小容量 C(3)
补充题 2 队列出队入栈三种操作,不可得到的序列 C(3,4,5,6,1,2)
补充题 3 火车车厢重排,求最少轨道数 C(4)

五、一页速查总结

5.1 栈的核心要点

要点 内容
定义 只允许在一端插入删除的线性表
特性 后进先出 LIFO
出栈序列个数 卡特兰数 C n = 1 n + 1 ( 2 n n ) C_n = \frac{1}{n+1}\binom{2n}{n} Cn=n+11(n2n),前几项:1, 1, 2, 5, 14, 42, 132
链式栈栈顶选择 单链表必须用表头做栈顶 (头插头删 O ( 1 ) O(1) O(1));表尾做栈顶退化为 O ( N ) O(N) O(N)
顺序栈 vs 链式栈 都是 O ( 1 ) O(1) O(1);顺序栈缓存命中率高、无内存碎片⇒一般首选
递归与非递归 递归改非递归:阶乘/斐波那契/归并排序→循环 ;快排/二叉树遍历→必须借助栈

5.2 队列的核心要点

要点 内容
定义 一端插入(队尾)、另一端删除(队头)的线性表
特性 先进先出 FIFO ;一种入队顺序只对应一种出队顺序
链式队列出队易错点 删除最后一个结点后,必须把 rear 重新指向哨兵位头结点(否则 rear 野指针)
假溢出 空间还有空闲,但 rear 已到数组末尾而被误判为满;用循环队列解决
循环队列判空 front == rear
循环队列判满 牺牲一个空间 :(rear+1) % N == front;或 count == N;或 front == rear && isInsert == true
循环队列容量 已知队列长度 k 时,数组必须开 k+1 个空间
取队尾元素 a[(rear - 1 + N) % N],必须先 +N 再取模
封装实现建议 不确定数据量时用链式队列;顺序队列适合静态、空间确定的场景

5.3 数组与矩阵的核心公式

内容 公式
一维数组元素地址 start + sizeof(Type) * i
二维行优先(M 行 N 列) addr(A[i][j]) = start + (i * N + j) * sizeof(Type)
二维列优先(M 行 N 列) addr(A[i][j]) = start + (j * M + i) * sizeof(Type)
对称矩阵存储元素个数 n ( n + 1 ) 2 \frac{n(n+1)}{2} 2n(n+1)
对称矩阵·下三角·行优先( i ≥ j i \ge j i≥j) k = i ( i − 1 ) 2 + ( j − 1 ) k = \frac{i(i-1)}{2} + (j-1) k=2i(i−1)+(j−1)
对称矩阵·上三角·行优先( i ≤ j i \le j i≤j) 前 i-1 行有 n + ( n − 1 ) + ⋯ + ( n − i + 2 ) n + (n-1) + \dots + (n-i+2) n+(n−1)+⋯+(n−i+2) 个;画图推导
三角矩阵存储空间 n ( n + 1 ) 2 + 1 \frac{n(n+1)}{2} + 1 2n(n+1)+1(多一个常量空间)
三对角矩阵元素个数 3 n − 2 3n - 2 3n−2
三对角矩阵·行优先($ i-j
稀疏矩阵存储 三元组表 ({行,列,值} + 矩阵行数列数)、十字链表
稀疏因子 δ = t m × n ≤ 0.05 \delta = \frac{t}{m \times n} \le 0.05 δ=m×nt≤0.05

5.4 五种易混概念对比

对比项 栈 队列 双端队列
插入端 栈顶 队尾 两端均可
删除端 栈顶 队头 两端均可
顺序特性 LIFO FIFO 两端混合
入/出顺序关系 一种入栈顺序 ⇒ 多种出栈顺序 一种入队顺序 ⇒ 一种出队顺序 两端操作,序列受限但比栈灵活

结语

这三个结构的核心记忆点其实很集中:

  1. 栈------LIFO,"只能一端操作",出栈序列数 = 卡特兰数,表达式求值是最大应用;
  2. 队列 ------FIFO,"一端进另一端出",循环队列判空判满是必考 ,链式队列出队的 rear 复位是易错点;
  3. 数组 ------行优先/列优先地址公式 + 特殊矩阵压缩存储的下标计算(重点记推导方法,不要背公式)。

矩阵压缩存储的下标计算题,画图 + 等差数列就能解决 90% 的情况,这也是本篇最想传递的方法论。

如果这篇内容对你有帮助,欢迎点赞 + 收藏,后续会继续更新《树和二叉树》《排序》等章节的整理。

本文根据数据结构课程课件《3. 栈和队列和数组》整理,代码均经过实际编译验证。

相关推荐
MicrosoftCloud1 小时前
用户权限 04|/etc/sudoers 安全配置:从最小授权到「别把 ALL 随便给人」
运维·安全·ubuntu·sudo·sudoers
wjkjpcba2 小时前
PCBA烧录程序是什么:PCBA包工包料厂家解析烧录与测试
linux·数据库·人工智能·smt贴片加工·pcba贴片加工厂
꯭自꯭闭꯭2 小时前
达梦事物特性及MVCC
linux·运维·数据库
91刘仁德3 小时前
HTTP协议详解:从URL到HTTP服务器的完整实战
服务器·网络·笔记·网络协议·http
亚川楼宇自控系统数据中心厂家4 小时前
实验室智能化管理系统|人环物智一体化管控平台
运维
羔羊++4 小时前
18_实验十七_其他命令与自定义启动变量
linux
zmsup5 小时前
Agent 上下文调优:结合 OpsArk 运维智能体,设计模型每一步真正需要的信息
运维·agent·上下文压缩·运维智能体·上下文调优
DYWorker0016 小时前
Linux驱动:RK3568 GMAC + YT8531C 千兆以太网全流程解剖
linux
吴声子夜歌8 小时前
Nginx应用与运维——Nginx负载均衡应用实战(二)
运维·nginx·负载均衡