本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:《数据结构习题与解析 B级 李春葆》是计算机专业学生夯实数据结构基础的重要学习资料,涵盖线性结构、树形结构、图结构及哈希表等核心内容,并详解冒泡排序、快速排序、二分查找等常用算法。本书通过大量习题与解析帮助学习者掌握动态规划、贪心算法和回溯法等高级解题策略,提升编程能力和算法设计思维。适合准备面试、项目开发及课程学习的程序员使用,是深入理解数据结构与算法的必备参考书。
数据结构习题与解析 B级 李春葆

1. 数据结构基础概念解析

在计算机科学中, 数据结构 是组织和管理数据的基础方式,它直接影响着程序的效率与可维护性。本章将从最基础的概念入手,帮助读者建立清晰的知识体系框架。

1.1 数据结构的基本组成要素

数据结构包含三个基本组成部分: 数据、数据元素与数据项 。

  • 数据 :信息的载体,是计算机操作的基本对象。
  • 数据元素 :数据的基本单位,通常由若干个 数据项 组成。
  • 数据项 :具有独立含义的最小标识单位,例如一个人的姓名、年龄等。

1.2 数据结构的分类

数据结构主要从两个维度进行分类:

  1. 逻辑结构 :描述数据之间的逻辑关系,包括:
    - 集合结构
    - 线性结构(如数组、链表)
    - 树形结构(如二叉树、堆)
    - 图结构(如有向图、无向图)

  2. 物理结构(存储结构) :数据在计算机中的存储方式,主要包括:
    - 顺序存储:如数组
    - 链式存储:如链表
    - 索引存储
    - 散列存储

1.3 抽象数据类型(ADT)

抽象数据类型(Abstract Data Type, ADT) 是一种数学模型,它定义了数据的逻辑结构以及在该结构上的一组操作接口,而隐藏了具体的实现细节。ADT 是理解数据结构如何被使用的关键概念。

例如,栈(Stack)的 ADT 可以定义如下:

public interface Stack<T> {
    void push(T item);   // 入栈操作
    T pop();             // 出栈操作,返回被弹出的元素
    T peek();            // 查看栈顶元素
    boolean isEmpty();   // 判断栈是否为空
}

通过 ADT,我们可以将数据结构的 定义与实现分离 ,使得程序结构更清晰、易于维护和扩展。

1.4 数据结构的重要性

掌握数据结构的意义在于:

  • 提升程序性能:合理选择数据结构可以显著提高程序的时间与空间效率。
  • 构建复杂系统:如数据库系统依赖树与图结构,操作系统依赖队列与栈进行任务调度。
  • 应对算法面试:几乎所有算法问题都涉及数据结构的灵活应用。
  • 培养编程思维:学习数据结构有助于理解抽象建模与问题求解的策略。

下一章我们将从最基础的线性结构开始,深入讲解数组与链表的实现原理与应用场景。

2. 线性结构(数组、链表)详解与习题训练

线性结构是数据结构中最基础也是最常用的一类结构,其核心特点是元素之间存在一对一的线性关系。本章将深入剖析线性结构的核心代表—— 数组 与 链表 的实现机制、操作方式、性能差异及应用场景,并结合习题训练帮助读者巩固理论知识,提升算法思维能力。

2.1 线性结构的基本特征

2.1.1 线性结构的定义与特点

线性结构是指数据元素之间存在“一对一”的前驱和后继关系的结构。常见的线性结构包括:数组、链表、栈、队列等。其基本特征如下:

特征 描述
顺序性 数据元素按顺序排列
一对一 每个元素有且仅有一个前驱和一个后继(除首尾)
连续存储(数组) 数组元素在内存中是连续存储的
非连续存储(链表) 链表元素通过指针连接,物理上不连续

线性结构的抽象数据类型(ADT)通常包括以下基本操作:

  • 初始化结构
  • 插入元素
  • 删除元素
  • 查找元素
  • 获取长度
  • 遍历结构

2.1.2 常见线性结构的分类(顺序表与链表)

线性结构根据存储方式可分为 顺序表 与 链表 :

  • 顺序表(数组) :采用连续内存空间存储数据,支持随机访问,插入删除效率低。
  • 链表 :采用离散内存空间,通过指针连接节点,插入删除效率高,但访问效率低。

mermaid流程图如下所示:

graph TD
    A[线性结构] --> B[顺序结构]
    A --> C[链式结构]
    B --> D[静态数组]
    B --> E[动态数组]
    C --> F[单链表]
    C --> G[双链表]
    C --> H[循环链表]

顺序结构适用于数据量固定、频繁访问的场景;链式结构适用于数据频繁变动、插入删除操作频繁的场景。

2.2 数组的实现与操作

2.2.1 静态数组与动态数组

数组是线性结构中最基本的形式,其本质是 一段连续的内存空间 ,用于存储相同类型的数据。

  • 静态数组 :在编译时确定大小,无法动态扩展。
  • 动态数组 :在运行时根据需要扩展容量,典型代表如 C++ 的 std::vector 、Java 的 ArrayList 。

以下是一个 C++ 动态数组的实现示例:

#include <iostream>
using namespace std;

class DynamicArray {
private:
    int* arr;
    int capacity;
    int size;

public:
    DynamicArray(int cap) {
        capacity = cap;
        size = 0;
        arr = new int[capacity];
    }

    void push(int value) {
        if (size == capacity) {
            resize();
        }
        arr[size++] = value;
    }

    void resize() {
        capacity *= 2;
        int* newArr = new int[capacity];
        for (int i = 0; i < size; ++i) {
            newArr[i] = arr[i];
        }
        delete[] arr;
        arr = newArr;
    }

    void print() {
        for (int i = 0; i < size; ++i) {
            cout << arr[i] << " ";
        }
        cout << endl;
    }

    ~DynamicArray() {
        delete[] arr;
    }
};

int main() {
    DynamicArray da(2);
    da.push(1);
    da.push(2);
    da.push(3);
    da.print();  // 输出:1 2 3
    return 0;
}
代码逻辑分析:
  • DynamicArray(int cap) :构造函数初始化数组容量。
  • push(int value) :插入元素,若容量不足则调用 resize() 扩容。
  • resize() :将容量翻倍,创建新数组并将旧数据拷贝过去。
  • print() :遍历数组并输出元素。
  • ~DynamicArray() :析构函数释放动态分配的内存。
参数说明:
  • capacity :当前数组最大容量。
  • size :当前已存储的元素个数。
  • arr :指向动态分配的数组内存。

2.2.2 多维数组的存储与访问

多维数组是数组的数组,其本质是 线性化存储 。例如,一个二维数组 int arr[3][4] 实际上是按行优先顺序存储为一个一维数组。

假设一个二维数组 arr[m][n] ,其元素 arr[i][j] 在内存中的偏移量为:

offset = i * n + j

例如:

int arr[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

访问 arr[1][2] 实际上访问的是线性地址 1*4 + 2 = 6 ,值为 7 。

2.2.3 数组的应用场景与优缺点

应用场景 优点 缺点
存储固定长度数据 支持随机访问,速度快 插入删除效率低
实现其他结构(如栈、队列) 内存管理简单 容量固定,扩展性差
图像像素处理 数据连续,访问高效 不适合频繁扩容

2.3 链表的结构与实现

2.3.1 单链表、双链表与循环链表

链表是一种动态数据结构,每个节点包含数据和指向下一个节点的指针。

  • 单链表 :每个节点只有一个指向下一个节点的指针。
  • 双链表 :每个节点有两个指针,分别指向前驱和后继。
  • 循环链表 :尾节点指向头节点,形成环状。

mermaid流程图如下:

graph LR
    A[链表] --> B[单链表]
    A --> C[双链表]
    A --> D[循环链表]

2.3.2 链表的基本操作(插入、删除、查找)

以单链表为例,实现插入、删除、查找操作。

struct Node {
    int data;
    Node* next;
};

class LinkedList {
private:
    Node* head;

public:
    LinkedList() : head(nullptr) {}

    void insert(int value) {
        Node* newNode = new Node{value, head};
        head = newNode;
    }

    void remove(int value) {
        Node* curr = head;
        Node* prev = nullptr;
        while (curr && curr->data != value) {
            prev = curr;
            curr = curr->next;
        }
        if (!curr) return;
        if (!prev) {
            head = curr->next;
        } else {
            prev->next = curr->next;
        }
        delete curr;
    }

    bool search(int value) {
        Node* curr = head;
        while (curr) {
            if (curr->data == value) return true;
            curr = curr->next;
        }
        return false;
    }

    void print() {
        Node* curr = head;
        while (curr) {
            cout << curr->data << " -> ";
            curr = curr->next;
        }
        cout << "NULL" << endl;
    }

    ~LinkedList() {
        Node* curr = head;
        while (curr) {
            Node* next = curr->next;
            delete curr;
            curr = next;
        }
    }
};

int main() {
    LinkedList list;
    list.insert(10);
    list.insert(20);
    list.insert(30);
    list.print();  // 输出:30 -> 20 -> 10 -> NULL
    list.remove(20);
    list.print();  // 输出:30 -> 10 -> NULL
    cout << (list.search(10) ? "Found" : "Not Found") << endl;  // Found
    return 0;
}
代码逻辑分析:
  • insert(int value) :在链表头部插入新节点。
  • remove(int value) :查找并删除值为 value 的节点。
  • search(int value) :遍历链表查找是否存在该值。
  • print() :打印链表内容。
  • 析构函数负责释放所有节点内存。
参数说明:
  • head :指向链表头节点。
  • curr :当前节点指针。
  • prev :前驱节点指针,用于删除操作。

2.3.3 链表的内存管理与性能分析

项目 分析
内存分配 每次插入新节点都需要 new 分配内存,释放时需 delete
插入/删除 时间复杂度 O(1)(在已知位置)或 O(n)(需查找)
随机访问 不支持,只能顺序访问,时间复杂度 O(n)
适用场景 数据频繁变动、插入删除频繁、不需随机访问的场景

2.4 线性结构相关习题训练

2.4.1 基础选择题与判断题

  1. 判断题 :数组的大小在运行时可以动态改变。( )
    ✅答案:错误。静态数组大小不可变,动态数组通过扩容实现。

  2. 选择题 :链表相比数组的优点是( )
    A. 支持随机访问
    B. 插入删除效率高
    C. 内存占用更小
    D. 访问速度快
    ✅答案:B

2.4.2 编程题与算法设计题

题目 :反转一个单链表。

Node* reverseList(Node* head) {
    Node* prev = nullptr;
    Node* curr = head;
    while (curr) {
        Node* next = curr->next;
        curr->next = prev;
        prev = curr;
        curr = next;
    }
    return prev;
}
逻辑分析:
  • 初始化 prev 为 nullptr , curr 为头节点。
  • 遍历链表,每次将当前节点的 next 指向前一个节点。
  • 最终 prev 指向新的头节点。

2.4.3 典型例题解析与思路讲解

题目 :合并两个有序链表。

Node* mergeTwoLists(Node* l1, Node* l2) {
    Node dummy(0);
    Node* tail = &dummy;
    while (l1 && l2) {
        if (l1->data < l2->data) {
            tail->next = l1;
            l1 = l1->next;
        } else {
            tail->next = l2;
            l2 = l2->next;
        }
        tail = tail->next;
    }
    tail->next = l1 ? l1 : l2;
    return dummy.next;
}
思路讲解:
  • 使用一个虚拟头节点 dummy 简化边界处理。
  • 比较两个链表当前节点的值,选择较小的接入新链表。
  • 遍历结束后,将剩余部分直接接入。

本章从线性结构的基本概念入手,深入分析了数组与链表的实现机制、操作方式、性能差异及应用场景,并通过习题训练帮助读者巩固理论知识,提升算法思维能力。下章将进入 栈与队列 的学习,敬请期待。

3. 栈与队列原理与应用

栈与队列是计算机科学中最基础也是最常用的数据结构之一。它们在算法设计、操作系统、编译原理、网络通信等多个领域都发挥着重要作用。本章将从基本原理出发,深入探讨栈与队列的实现方式、操作机制,并结合实际应用场景进行分析,帮助读者建立扎实的理解基础,掌握其在系统设计与算法中的关键作用。

3.1 栈的基本原理

栈(Stack)是一种只能在一端进行插入或删除的线性表,这一端被称为栈顶(Top),另一端固定,称为栈底(Bottom)。栈遵循后进先出(LIFO, Last In First Out)的原则,即最后压入栈的元素最先被弹出。

3.1.1 栈的定义与操作

栈的操作主要包括:

  • Push :将元素压入栈顶。
  • Pop :将栈顶元素弹出。
  • Peek / Top :查看栈顶元素,但不弹出。
  • IsEmpty :判断栈是否为空。
  • Size :返回栈中元素的数量。

栈的这些操作具有高度的确定性和可预测性,非常适合用于需要回溯处理的场景。

3.1.2 栈的顺序实现与链式实现

顺序栈(基于数组实现)

顺序栈使用数组来实现,通常定义一个数组和一个表示栈顶位置的变量 top 。

#define MAX_SIZE 100

typedef struct {
    int data[MAX_SIZE];
    int top;
} SeqStack;

// 初始化栈
void initStack(SeqStack *s) {
    s->top = -1;  // 栈空状态
}

// 入栈
int push(SeqStack *s, int value) {
    if (s->top >= MAX_SIZE - 1) {
        printf("Stack overflow\n");
        return -1;
    }
    s->data[++(s->top)] = value;
    return 0;
}

// 出栈
int pop(SeqStack *s) {
    if (s->top == -1) {
        printf("Stack underflow\n");
        return -1;
    }
    return s->data[(s->top)--];
}

逐行解读:

  • initStack 将栈顶指针初始化为 -1,表示栈为空。
  • push 操作先判断是否栈满,若未满,则将栈顶指针加 1,并将新值放入该位置。
  • pop 操作先检查栈是否为空,若非空,则取出栈顶元素并将栈顶指针减 1。

参数说明:
- MAX_SIZE :栈的最大容量。
- top :记录栈顶索引,初始为 -1 表示栈空。
- data :存储栈元素的数组。

链式栈(基于链表实现)

链式栈使用链表结构,栈顶通常设置为链表的头节点,这样可以快速进行插入和删除操作。

typedef struct StackNode {
    int data;
    struct StackNode *next;
} StackNode;

typedef struct {
    StackNode *top;
} LinkedStack;

// 初始化栈
void initStack(LinkedStack *s) {
    s->top = NULL;
}

// 入栈
int push(LinkedStack *s, int value) {
    StackNode *newNode = (StackNode *)malloc(sizeof(StackNode));
    if (!newNode) {
        printf("Memory allocation failed\n");
        return -1;
    }
    newNode->data = value;
    newNode->next = s->top;
    s->top = newNode;
    return 0;
}

// 出栈
int pop(LinkedStack *s) {
    if (s->top == NULL) {
        printf("Stack underflow\n");
        return -1;
    }
    StackNode *temp = s->top;
    int value = temp->data;
    s->top = temp->next;
    free(temp);
    return value;
}

逻辑分析:

  • 链式栈通过头插法实现栈顶操作,避免了顺序栈的空间限制。
  • 每次 push 创建新节点并插入到栈顶。
  • pop 释放栈顶节点,并更新栈顶指针。

参数说明:
- top :指向链表头节点的指针。
- next :指向下一个节点的指针。
- malloc 用于动态分配内存,注意在出栈后要 free 释放内存,避免内存泄漏。

3.2 队列的基本原理

队列(Queue)是一种特殊的线性表,它只允许在表的一端进行插入操作(队尾),在另一端进行删除操作(队头),遵循先进先出(FIFO, First In First Out)的原则。

3.2.1 队列的定义与操作

队列的基本操作包括:

  • Enqueue :将元素插入队尾。
  • Dequeue :将队头元素移除。
  • Front / Peek :查看队头元素。
  • IsEmpty :判断队列是否为空。
  • Size :返回队列中元素数量。

3.2.2 循环队列与双端队列

循环队列(Circular Queue)

循环队列是顺序队列的一种优化实现,通过将数组的首尾连接,避免顺序队列中“假溢出”问题。

#define MAX_QUEUE_SIZE 100

typedef struct {
    int data[MAX_QUEUE_SIZE];
    int front;  // 队头指针
    int rear;   // 队尾指针
} CircularQueue;

// 初始化队列
void initQueue(CircularQueue *q) {
    q->front = q->rear = 0;
}

// 判断队列是否满
int isFull(CircularQueue *q) {
    return (q->rear + 1) % MAX_QUEUE_SIZE == q->front;
}

// 入队
int enqueue(CircularQueue *q, int value) {
    if (isFull(q)) {
        printf("Queue overflow\n");
        return -1;
    }
    q->data[q->rear] = value;
    q->rear = (q->rear + 1) % MAX_QUEUE_SIZE;
    return 0;
}

// 出队
int dequeue(CircularQueue *q) {
    if (q->front == q->rear) {
        printf("Queue underflow\n");
        return -1;
    }
    int value = q->data[q->front];
    q->front = (q->front + 1) % MAX_QUEUE_SIZE;
    return value;
}

逐行解读:

  • front 和 rear 分别指向队头和队尾。
  • 当 (rear + 1) % MAX == front 时,队列为满。
  • 入队操作将元素放入 rear 位置后, rear 向后移动。
  • 出队操作取出 front 位置的元素后, front 向后移动。
双端队列(Deque)

双端队列允许在队列两端进行插入和删除操作,适合需要灵活操作的场景,如滑动窗口算法、广度优先搜索的优化实现等。

3.3 栈与队列的实际应用

栈与队列在实际开发中应用广泛,下面将介绍它们在函数调用、表达式求值、任务调度中的典型应用。

3.3.1 函数调用与递归实现

在程序运行时,操作系统使用栈来管理函数调用过程。每次调用函数时,系统会将当前函数的状态压入调用栈(Call Stack),包括局部变量、返回地址等信息。

递归函数的栈机制:

以阶乘函数为例:

def factorial(n):
    if n == 0:
        return 1
    return n * factorial(n - 1)

执行流程:

factorial(3)
  -> 3 * factorial(2)
      -> 2 * factorial(1)
          -> 1 * factorial(0)
              -> return 1
          return 1 * 1 = 1
      return 2 * 1 = 2
  return 3 * 2 = 6

每次递归调用都会将当前参数和返回地址压入栈中,直到达到终止条件才开始逐层返回。

3.3.2 表达式求值与括号匹配

栈常用于解析数学表达式和验证括号匹配。

括号匹配问题:

def is_valid_parentheses(s: str) -> bool:
    stack = []
    mapping = {')': '(', '}': '{', ']': '['}
    for char in s:
        if char in mapping.values():
            stack.append(char)
        elif char in mapping:
            if not stack or stack[-1] != mapping[char]:
                return False
            stack.pop()
    return not stack

逻辑分析:

  • 遇到左括号就入栈。
  • 遇到右括号则判断栈是否非空且栈顶是否匹配。
  • 最终栈为空则说明括号完全匹配。

3.3.3 操作系统中的任务调度

操作系统中,队列常用于任务调度,例如进程调度、I/O请求排队等。

先来先服务(FCFS)调度算法:

graph TD
    A[新进程到达] --> B[加入就绪队列]
    B --> C{队列是否为空?}
    C -->|否| D[调度第一个进程]
    D --> E[运行进程]
    E --> F[进程完成或阻塞]
    F --> G[移除队列]
    G --> C
    C -->|是| H[等待新进程]

说明:

  • 使用队列实现先来先服务的调度策略。
  • 进程按到达顺序排队,CPU依次处理。

3.4 栈与队列的习题与实践

3.4.1 基础练习题

1. 实现一个最小栈(Min Stack)

要求支持 push 、 pop 、 top 和 getMin 四个操作,其中 getMin 返回栈中最小值。

class MinStack:
    def __init__(self):
        self.stack = []
        self.min_stack = []

    def push(self, val: int) -> None:
        self.stack.append(val)
        if not self.min_stack or val <= self.min_stack[-1]:
            self.min_stack.append(val)

    def pop(self) -> None:
        if self.stack.pop() == self.min_stack[-1]:
            self.min_stack.pop()

    def top(self) -> int:
        return self.stack[-1]

    def getMin(self) -> int:
        return self.min_stack[-1]

逻辑分析:

  • 主栈 stack 存储所有元素。
  • 辅助栈 min_stack 只存储当前最小值。
  • 每次 push 时,若新值小于等于 min_stack 栈顶,则也压入辅助栈。
  • pop 时若主栈弹出的值等于辅助栈栈顶,则同步弹出辅助栈。

3.4.2 综合应用题与算法题

2. 用两个栈实现一个队列

class MyQueue:
    def __init__(self):
        self.stack1 = []  # 用于入队
        self.stack2 = []  # 用于出队

    def push(self, x: int) -> None:
        self.stack1.append(x)

    def pop(self) -> int:
        if not self.stack2:
            while self.stack1:
                self.stack2.append(self.stack1.pop())
        return self.stack2.pop()

    def peek(self) -> int:
        if not self.stack2:
            while self.stack1:
                self.stack2.append(self.stack1.pop())
        return self.stack2[-1]

    def empty(self) -> bool:
        return not self.stack1 and not self.stack2

逻辑分析:

  • push 总是将元素压入 stack1 。
  • pop 和 peek 操作从 stack2 弹出元素,若 stack2 为空则将 stack1 中所有元素倒序压入 stack2 。
  • 实现了先进先出的队列行为。

3.4.3 面试中常见的栈与队列问题解析

问题类型 说明 常见场景
括号匹配 使用栈判断是否成对出现 编译器语法检查
滑动窗口最大值 使用双端队列维护窗口内最大值 算法题、数据流处理
用队列实现栈 通过两个队列实现栈的 LIFO 特性 面试算法题
最小栈 使用辅助栈记录最小值 面试高频题
逆波兰表达式求值 使用栈进行表达式求解 编译器、计算器实现

总结:

栈与队列作为基础但强大的数据结构,在系统设计和算法实现中扮演着重要角色。通过本章的学习,我们不仅掌握了它们的实现方式,还了解了其在函数调用、表达式处理、任务调度等多个实际场景中的应用。在后续章节中,我们将继续深入探讨更复杂的结构,如树与图,并结合栈与队列进行综合应用设计。

4. 二叉树、堆、平衡树等树形结构解析

树形结构是计算机科学中最重要的数据结构之一,广泛应用于文件系统、数据库索引、网络路由、算法优化等领域。本章将深入探讨 二叉树、堆、平衡树 等常见树形结构的定义、操作、实现方式以及其在实际中的应用。通过本章的学习,读者将掌握如何在程序中构建、遍历和优化这些结构,为后续更复杂的算法设计和系统实现打下坚实基础。

4.1 树的基本概念与术语

在深入研究具体树结构之前,我们首先需要理解树的基本定义和相关术语。

4.1.1 树的定义与基本性质

树(Tree)是一种 非线性的层次结构 ,由节点组成。每个节点最多有一个父节点,但可以有多个子节点。树具有以下基本性质:

  • 有且仅有一个根节点(Root),没有父节点。
  • 每个节点可以有0个或多个子节点。
  • 任意两个节点之间有且仅有一条路径。

树结构的抽象表示如下:

graph TD
    A[根节点 A] --> B[子节点 B]
    A --> C[子节点 C]
    B --> D[子节点 D]
    B --> E[子节点 E]
    C --> F[子节点 F]
    C --> G[子节点 G]

树的数学定义是一个 递归结构 ,即每个子树也是一个树。

4.1.2 子树、节点、度与层次

理解树结构,需要掌握以下几个核心术语:

术语 描述
节点(Node) 树中的元素,包含数据和指向子节点的引用
根节点(Root) 树的顶层节点,没有父节点
叶子节点(Leaf) 没有子节点的节点
子节点(Child) 某个节点的直接下级节点
父节点(Parent) 某个节点的直接上级节点
兄弟节点(Siblings) 同一个父节点下的节点
子树(Subtree) 某个节点及其所有后代构成的树
度(Degree) 节点拥有的子节点数目
层次(Level) 根节点为第1层,子节点为第2层,依此类推
深度(Depth) 最大层次数

例如,对于下图所示的树:

graph TD
    A[根节点 A] --> B[节点 B]
    A --> C[节点 C]
    B --> D[节点 D]
    B --> E[节点 E]
    C --> F[节点 F]
    C --> G[节点 G]

各节点的属性如下表:

节点 父节点 子节点 度 层次
A 无 B, C 2 1
B A D, E 2 2
C A F, G 2 2
D B 无 0 3
E B 无 0 3
F C 无 0 3
G C 无 0 3

4.2 二叉树的结构与操作

二叉树是树结构中最基础、最常用的一种形式。每个节点最多有两个子节点,通常称为左子节点和右子节点。

4.2.1 二叉树的定义与遍历方式(前序、中序、后序)

定义 :二叉树(Binary Tree)是一种每个节点最多有两个子节点的树结构,通常称为左孩子和右孩子。

遍历方式 :二叉树的遍历有三种基本方式,分别是:

  • 前序遍历(Pre-order) :根节点 → 左子树 → 右子树
  • 中序遍历(In-order) :左子树 → 根节点 → 右子树
  • 后序遍历(Post-order) :左子树 → 右子树 → 根节点

例如,对以下二叉树:

graph TD
    A[根节点 A] --> B[左子树 B]
    A --> C[右子树 C]
    B --> D[左子树 D]
    B --> E[右子树 E]

遍历结果为:

遍历方式 遍历顺序
前序 A → B → D → E → C
中序 D → B → E → A → C
后序 D → E → B → C → A

实现代码 (Python):

class TreeNode:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None

def preorder_traversal(node):
    if node:
        print(node.value, end=" ")     # 访问根节点
        preorder_traversal(node.left)  # 递归左子树
        preorder_traversal(node.right) # 递归右子树

def inorder_traversal(node):
    if node:
        inorder_traversal(node.left)
        print(node.value, end=" ")
        inorder_traversal(node.right)

def postorder_traversal(node):
    if node:
        postorder_traversal(node.left)
        postorder_traversal(node.right)
        print(node.value, end=" ")

逻辑分析 :

  • TreeNode 类用于定义节点,包含值和左右子节点。
  • 每种遍历函数都采用递归方式实现。
  • 前序遍历先访问当前节点,再递归左右子树。
  • 中序遍历先递归左子树,再访问当前节点,最后递归右子树。
  • 后序遍历先递归左右子树,最后访问当前节点。

4.2.2 二叉树的顺序存储与链式存储

二叉树有两种主要的存储方式:

存储方式 描述 优缺点
顺序存储 使用数组按层序存储节点 适合完全二叉树,空间利用率高,但稀疏树浪费空间
链式存储 使用指针结构,每个节点保存左右子节点指针 灵活,适用于任意形状的二叉树,但指针操作复杂

示例代码(链式存储) :

class BinaryTreeNode:
    def __init__(self, val):
        self.val = val
        self.left = None
        self.right = None

# 构建如下二叉树
#       A
#      / \
#     B   C
#    / \
#   D   E

root = BinaryTreeNode('A')
root.left = BinaryTreeNode('B')
root.right = BinaryTreeNode('C')
root.left.left = BinaryTreeNode('D')
root.left.right = BinaryTreeNode('E')

逻辑分析 :

  • 使用 BinaryTreeNode 类构建节点。
  • 每个节点通过 left 和 right 指针连接左右子节点。
  • 构建的树结构清晰,便于遍历和修改。

4.2.3 二叉树的构造与线索化

构造二叉树 :通常通过前序和中序遍历结果或中序和后序遍历结果重建二叉树。

线索化二叉树 :将空指针指向某种遍历顺序下的前驱或后继节点,从而提高查找效率。

示例:由前序和中序遍历构造二叉树(LeetCode 105题) :

def build_tree(preorder, inorder):
    if not preorder:
        return None
    root_val = preorder[0]
    root = BinaryTreeNode(root_val)
    index = inorder.index(root_val)
    root.left = build_tree(preorder[1:index+1], inorder[:index])
    root.right = build_tree(preorder[index+1:], inorder[index+1:])
    return root

逻辑分析 :

  • 前序遍历第一个节点为根节点。
  • 在中序遍历中找到根节点位置,左边为左子树,右边为右子树。
  • 递归构建左右子树。

线索化二叉树示例 (仅展示逻辑结构):

class ThreadedNode:
    def __init__(self, val):
        self.val = val
        self.left = None
        self.right = None
        self.left_thread = False  # 是否为线索
        self.right_thread = False

4.3 堆与优先队列

堆是一种特殊的树结构,常用于实现优先队列。

4.3.1 堆的定义与基本操作

堆(Heap)是一种完全二叉树结构,满足堆性质:

  • 最大堆 :任意父节点大于等于其子节点。
  • 最小堆 :任意父节点小于等于其子节点。

堆的基本操作 :

  • 插入元素(上浮)
  • 删除根节点(下沉)
  • 构建堆
  • 堆排序

堆的实现(Python) :

class MinHeap:
    def __init__(self):
        self.heap = []

    def insert(self, val):
        self.heap.append(val)
        self._heapify_up(len(self.heap) - 1)

    def extract_min(self):
        if not self.heap:
            return None
        min_val = self.heap[0]
        last_val = self.heap.pop()
        if self.heap:
            self.heap[0] = last_val
            self._heapify_down(0)
        return min_val

    def _heapify_up(self, index):
        while index > 0:
            parent_index = (index - 1) // 2
            if self.heap[index] < self.heap[parent_index]:
                self.heap[index], self.heap[parent_index] = self.heap[parent_index], self.heap[index]
                index = parent_index
            else:
                break

    def _heapify_down(self, index):
        while True:
            left = 2 * index + 1
            right = 2 * index + 2
            smallest = index
            if left < len(self.heap) and self.heap[left] < self.heap[smallest]:
                smallest = left
            if right < len(self.heap) and self.heap[right] < self.heap[smallest]:
                smallest = right
            if smallest != index:
                self.heap[index], self.heap[smallest] = self.heap[smallest], self.heap[index]
                index = smallest
            else:
                break

逻辑分析 :

  • 使用列表实现堆。
  • 插入时通过 _heapify_up 上浮元素至合适位置。
  • 删除根节点后用最后一个元素替代,并通过 _heapify_down 下沉。
  • 所有操作时间复杂度为 O(log n)。

4.3.2 堆排序与优先队列的应用

堆排序 是一种基于堆的排序算法,其步骤如下:

  1. 构建最大堆
  2. 将堆顶元素与最后一个元素交换
  3. 减少堆的大小并重新调整堆
  4. 重复上述过程直到堆为空

应用 :

  • 优先队列 :如操作系统进程调度、任务优先级管理。
  • Top K 问题 :使用最小堆找出大数据集中前 K 个最大值。
  • 合并多个有序链表 :利用堆优化合并过程。

堆排序实现 :

def heapify(arr, n, i):
    largest = i
    left = 2 * i + 1
    right = 2 * i + 2
    if left < n and arr[left] > arr[largest]:
        largest = left
    if right < n and arr[right] > arr[largest]:
        largest = right
    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]
        heapify(arr, n, largest)

def heap_sort(arr):
    n = len(arr)
    # 构建最大堆
    for i in range(n // 2 - 1, -1, -1):
        heapify(arr, n, i)
    # 提取元素
    for i in range(n - 1, 0, -1):
        arr[i], arr[0] = arr[0], arr[i]
        heapify(arr, i, 0)

逻辑分析 :

  • heapify 函数将子树调整为最大堆。
  • 先从最后一个非叶子节点开始构建最大堆。
  • 每次交换堆顶和最后一个元素,减少堆大小后重新调整堆。

4.4 平衡二叉树与红黑树简介

4.4.1 平衡二叉树的旋转操作

平衡二叉树(AVL Tree)是一种自平衡的二叉搜索树,任何节点的两个子树的高度差不超过1。

旋转操作 包括:

  • 左旋(Left Rotation)
  • 右旋(Right Rotation)
  • 左右旋(Left-Right Rotation)
  • 右左旋(Right-Left Rotation)

每种旋转操作用于在插入或删除后恢复平衡。

左旋实现(伪代码) :

def rotate_left(z):
    y = z.right
    T2 = y.left
    y.left = z
    z.right = T2
    # 更新高度
    z.height = 1 + max(get_height(z.left), get_height(z.right))
    y.height = 1 + max(get_height(y.left), get_height(y.right))
    return y

逻辑分析 :

  • 左旋将右子树提升为新的根节点。
  • 原根节点变为左子节点。
  • 更新节点高度以维护平衡。

4.4.2 红黑树的基本性质与插入调整

红黑树(Red-Black Tree)是一种自平衡二叉搜索树,具有以下性质:

  1. 每个节点是红色或黑色。
  2. 根节点是黑色。
  3. 所有叶子节点(NIL)是黑色。
  4. 每个红色节点的两个子节点都是黑色。
  5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。

插入操作后需要调整颜色和旋转 ,以恢复红黑树性质。

红黑树插入调整逻辑 :

  1. 插入新节点为红色。
  2. 若父节点为黑色,无需调整。
  3. 若父节点为红色,需根据叔父节点颜色进行调整:
    - 叔父为红色:变色并上溯。
    - 叔父为黑色:旋转并变色。

由于红黑树实现较为复杂,通常在编程语言中已有实现(如 Java 的 TreeMap )。

4.5 树形结构的典型应用与习题训练

4.5.1 文件系统的树形结构表示

操作系统中的文件系统天然具有树形结构,其中目录为内部节点,文件为叶子节点。

示例代码(模拟文件系统树) :

class FileSystemNode:
    def __init__(self, name, is_dir=True):
        self.name = name
        self.is_dir = is_dir
        self.children = []

    def add_child(self, child):
        self.children.append(child)

# 构建如下结构:
# root/
# ├── home/
# │   └── user/
# └── etc/

root = FileSystemNode("root", is_dir=True)
home = FileSystemNode("home", is_dir=True)
user = FileSystemNode("user", is_dir=True)
etc = FileSystemNode("etc", is_dir=True)

root.add_child(home)
root.add_child(etc)
home.add_child(user)

逻辑分析 :

  • 每个节点代表一个目录或文件。
  • 目录节点可包含子节点。
  • 通过递归可实现文件系统浏览、搜索等操作。

4.5.2 哈夫曼编码与压缩算法

哈夫曼编码是一种基于二叉树的数据压缩算法,使用频率高的字符用较短编码,频率低的用较长编码。

实现步骤 :

  1. 统计字符频率。
  2. 构建哈夫曼树。
  3. 生成编码表。
  4. 编码数据。

哈夫曼树节点类 :

import heapq

class HuffmanNode:
    def __init__(self, char, freq):
        self.char = char
        self.freq = freq
        self.left = None
        self.right = None

    def __lt__(self, other):
        return self.freq < other.freq

逻辑分析 :

  • 使用优先队列(堆)构建哈夫曼树。
  • 每次取出两个频率最小的节点合并,形成新节点。
  • 重复直到队列只剩一个节点(根节点)。

4.5.3 树结构相关的算法题与解题技巧

树结构是算法面试中的高频考点,常见题目包括:

题目类型 示例题目
二叉树遍历 LeetCode 94、144、145
树的高度 LeetCode 104
对称二叉树 LeetCode 101
二叉搜索树验证 LeetCode 98
树的路径和 LeetCode 112、113

解题技巧总结 :

  • 使用递归处理子树。
  • 利用栈或队列实现非递归遍历。
  • 使用哈希表记录节点信息。
  • 注意边界条件(空树、只有一个节点等)。

本章深入讲解了树形结构的基本概念、二叉树的操作与实现、堆与优先队列的原理与应用、平衡树的调整机制以及树结构在文件系统、压缩算法中的实际用途。下一章将进入图结构的探讨,继续深入数据结构的核心领域。

5. 图结构原理与算法实现

5.1 图的基本概念与表示方法

图(Graph)是一种非线性的数据结构,用于表示对象之间的多对多关系。图由顶点(Vertex)和边(Edge)组成,常用于建模社交网络、交通网络、互联网链接等复杂系统。

5.1.1 图的定义与分类(有向图、无向图、带权图)

图 G = (V, E),其中 V 是顶点集合,E 是边集合。

  • 无向图 :边没有方向,例如朋友关系图。
  • 有向图(Digraph) :边有方向,例如网页之间的链接。
  • 带权图(Weighted Graph) :每条边带有权重,表示距离、成本或时间等信息。

例如,一个带权有向图可以表示城市之间的交通网络,边的权重表示两城市之间的距离。

5.1.2 图的邻接矩阵与邻接表存储方式

常见的图存储方式有两种:

1. 邻接矩阵(Adjacency Matrix)

使用一个二维数组 graph[V][V] 表示图,其中 graph[i][j] 表示顶点 i 到顶点 j 的边的权重。若无边,则设为 0 或 INF。

优点:访问边的效率高 O(1)。
缺点:空间复杂度为 O(V²),对于稀疏图浪费空间。

# 邻接矩阵表示法(无向图)
INF = float('inf')
V = 5
graph = [[INF] * V for _ in range(V)]

# 添加边
def add_edge(u, v, weight):
    graph[u][v] = weight
    graph[v][u] = weight  # 无向图双向赋值

add_edge(0, 1, 3)
add_edge(0, 4, 5)
add_edge(1, 2, 2)
add_edge(2, 3, 1)
add_edge(3, 4, 4)

# 打印邻接矩阵
for row in graph:
    print(row)

输出示例:

[inf, 3, inf, inf, 5]
[3, inf, 2, inf, inf]
[inf, 2, inf, 1, inf]
[inf, inf, 1, inf, 4]
[5, inf, inf, 4, inf]
2. 邻接表(Adjacency List)

使用列表的列表,每个顶点存储其邻接顶点及其权重。

优点:节省空间,适合稀疏图。
缺点:查找边的时间复杂度为 O(V)。

# 邻接表表示法(有向图)
from collections import defaultdict

graph = defaultdict(list)

def add_edge(u, v, weight):
    graph[u].append((v, weight))  # 添加有向边 u -> v

add_edge(0, 1, 3)
add_edge(0, 4, 5)
add_edge(1, 2, 2)
add_edge(2, 3, 1)
add_edge(3, 4, 4)

# 打印邻接表
for u in graph:
    print(f"{u}: {graph[u]}")

输出示例:

0: [(1, 3), (4, 5)]
1: [(2, 2)]
2: [(3, 1)]
3: [(4, 4)]
邻接矩阵 vs 邻接表对比表:
特性 邻接矩阵 邻接表
空间复杂度 O(V²) O(V + E)
插入边时间 O(1) O(1)
查找边时间 O(1) O(E/V) 平均
适用场景 稠密图 稀疏图

5.2 图的遍历算法

图的遍历是访问图中所有顶点的过程,是图算法的基础。常见的图遍历方式有深度优先搜索(DFS)和广度优先搜索(BFS)。

5.2.1 深度优先搜索(DFS)

DFS 使用递归或栈实现,沿着一个路径尽可能深入访问节点,直到无法继续访问为止,再回溯到上一节点继续搜索。

def dfs(graph, start, visited=None):
    if visited is None:
        visited = set()
    visited.add(start)
    print(start, end=' ')
    for neighbor, _ in graph[start]:
        if neighbor not in visited:
            dfs(graph, neighbor, visited)

# 示例调用
dfs(graph, 0)

输出示例:

0 1 2 3 4

流程图示意:

graph TD
    A[DFS Start] --> B[访问节点]
    B --> C{是否已访问?}
    C -- 是 --> D[回溯]
    C -- 否 --> E[递归访问邻居]
    E --> B

5.2.2 广度优先搜索(BFS)

BFS 使用队列实现,按层访问节点,适合寻找最短路径。

from collections import deque

def bfs(graph, start):
    visited = set()
    queue = deque([start])
    visited.add(start)
    while queue:
        node = queue.popleft()
        print(node, end=' ')
        for neighbor, _ in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)

# 示例调用
bfs(graph, 0)

输出示例:

0 1 4 2 3

流程图示意:

graph TD
    A[BFS Start] --> B[访问节点]
    B --> C[将邻居入队]
    C --> D[标记为已访问]
    D --> E{队列是否空?}
    E -- 否 --> B
    E -- 是 --> F[结束]

5.3 图的经典算法

图的经典算法包括最短路径算法、最小生成树算法和拓扑排序等,广泛应用于网络路由、路径规划、任务调度等领域。

5.3.1 最短路径算法(Dijkstra、Floyd)

Dijkstra算法(单源最短路径)

适用于带权图,不能处理负权边。

import heapq

def dijkstra(graph, start):
    dist = {u: float('inf') for u in range(5)}
    dist[start] = 0
    pq = [(0, start)]
    while pq:
        current_dist, u = heapq.heappop(pq)
        if current_dist > dist[u]:
            continue
        for v, weight in graph[u]:
            if dist[v] > dist[u] + weight:
                dist[v] = dist[u] + weight
                heapq.heappush(pq, (dist[v], v))
    return dist

print(dijkstra(graph, 0))

输出示例:

{0: 0, 1: 3, 2: 5, 3: 6, 4: 5}
Floyd算法(全源最短路径)

使用动态规划,适用于稠密图。

def floyd(graph, V):
    dist = graph
    for k in range(V):
        for i in range(V):
            for j in range(V):
                if dist[i][j] > dist[i][k] + dist[k][j]:
                    dist[i][j] = dist[i][k] + dist[k][j]
    return dist

5.3.2 最小生成树算法(Prim、Kruskal)

Prim算法

适用于稠密图,基于邻接矩阵实现。

def prim(graph, V):
    key = [float('inf')] * V
    key[0] = 0
    mst_set = [False] * V
    parent = [None] * V
    for _ in range(V):
        u = min((key[i], i) for i in range(V) if not mst_set[i])[1]
        mst_set[u] = True
        for v in range(V):
            if graph[u][v] and not mst_set[v] and key[v] > graph[u][v]:
                key[v] = graph[u][v]
                parent[v] = u
    return parent
Kruskal算法

适用于稀疏图,基于邻接表和并查集实现。

def find(parent, i):
    if parent[i] == i:
        return i
    return find(parent, parent[i])

def union(parent, rank, x, y):
    root_x = find(parent, x)
    root_y = find(parent, y)
    if rank[root_x] < rank[root_y]:
        parent[root_x] = root_y
    else:
        parent[root_y] = root_x
        if rank[root_x] == rank[root_y]:
            rank[root_x] += 1

def kruskal(graph, V):
    result = []
    i = 0
    e = 0
    graph = sorted(graph, key=lambda item: item[2])
    parent = list(range(V))
    rank = [0] * V
    while e < V - 1:
        u, v, w = graph[i]
        i += 1
        x = find(parent, u)
        y = find(parent, v)
        if x != y:
            e += 1
            result.append((u, v, w))
            union(parent, rank, x, y)
    return result

5.3.3 拓扑排序与关键路径

拓扑排序用于有向无环图(DAG),解决任务调度问题。

from collections import deque

def topological_sort(graph, V):
    in_degree = [0] * V
    for u in graph:
        for v, _ in graph[u]:
            in_degree[v] += 1
    queue = deque([i for i in range(V) if in_degree[i] == 0])
    order = []
    while queue:
        u = queue.popleft()
        order.append(u)
        for v, _ in graph[u]:
            in_degree[v] -= 1
            if in_degree[v] == 0:
                queue.append(v)
    return order if len(order) == V else []

关键路径用于项目管理,找出最长路径。

graph LR
    A[开始] --> B[任务1]
    B --> C[任务2]
    C --> D[任务3]
    D --> E[结束]
    A --> F[任务4]
    F --> D

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:《数据结构习题与解析 B级 李春葆》是计算机专业学生夯实数据结构基础的重要学习资料,涵盖线性结构、树形结构、图结构及哈希表等核心内容,并详解冒泡排序、快速排序、二分查找等常用算法。本书通过大量习题与解析帮助学习者掌握动态规划、贪心算法和回溯法等高级解题策略,提升编程能力和算法设计思维。适合准备面试、项目开发及课程学习的程序员使用,是深入理解数据结构与算法的必备参考书。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

更多推荐