初学C语言数据结构的学习要点

一名初学者的初步学习归纳,望前辈批评改正!!!

1. 基本数据结构的精要

1.1 数组

        数组是一种线性数据结构,用于存储相同数据类型的元素。将探讨数组的声明、访问、遍历以及多维数组的概念。示例代码和应用场景将帮助理解如何在C语言中最大限度地利用数组。

特性:

        元素在内存中是连续存储的。

        支持随机访问,通过索引可以快速访问元素。

        固定大小,需要提前分配内存空间。

构造方法: 使用静态数组或动态数组,声明一个具有固定大小或动态扩展能力的数据结构,根据需要进行插入、删除、查找和更新操作。

1.2 链表

        链表是动态数据结构,它允许在运行时添加或删除元素。将详细介绍单向链表和双向链表的实现方式,讨论插入、删除和反转链表等操作,并比较链表与数组的优劣。

特性:

        元素在内存中不一定连续,通过指针链接。

        支持快速插入和删除,但查找需要遍历。

        分为单向链表和双向链表。

构造方法: 通过节点和指针链接构建链表,可以实现头部插入、尾部插入、中间插入等操作。

遍历:

        链表是一种常见的数据结构,有多种遍历方法可以用来访问链表中的元素。以下是三种常用的链表遍历方法:

1. 单向链表的顺序遍历:

        单向链表的每个节点包含一个数据元素和一个指向下一个节点的指针。要遍历单向链表,可以从头节点开始,依次沿着指针访问每个节点,直到到达尾节点为止。这是一种简单直接的遍历方法。

// 遍历单向链表

void traverseLinkedList(Node *head) {

    Node *current = head;

    while (current != NULL) {

        // 访问节点的数据

        printf("%d ", current->data);

        current = current->next;

    }

}

2. 双向链表的正向遍历:

        双向链表的每个节点有两个指针,一个指向前一个节点,一个指向后一个节点。要遍历双向链表,可以从头节点开始,依次沿着后向指针访问每个节点,直到到达尾节点。

// 正向遍历双向链表

void traverseDoubleLinkedListForward(Node *head) {

    Node *current = head;

    while (current != NULL) {

        // 访问节点的数据

        printf("%d ", current->data);

        current = current->next;

    }

}

3. 双向链表的反向遍历:

        双向链表的特点是可以反向遍历,即从尾节点开始,依次沿着前向指针访问每个节点,直到到达头节点。

// 反向遍历双向链表

void traverseDoubleLinkedListBackward(Node *tail) {

    Node *current = tail;

    while (current != NULL) {

        // 访问节点的数据

        printf("%d ", current->data);

        current = current->prev;

    }

}

        无论是单向链表还是双向链表,遍历的基本思想是通过指针不断地访问节点,并在每个节点处进行操作(如打印节点的数据)。选择合适的遍历方法取决于链表的类型以及遍历的需求,如正向还是反向遍历。

1.3 栈和队列

        栈和队列是常见的数据结构,用于管理数据的添加和删除。将深入探讨栈和队列的定义、操作以及实际应用,包括括号匹配、逆波兰表达式求值等问题。

特性:

        队列是一种先进先出(FIFO)的数据结构,用于存储和管理等待处理的元素。

        栈是一种后进先出(LIFO)的数据结构,用于存储和管理临时数据。

构造方法: 使用数组或链表,通过入队(enqueue)和出队(dequeue)操作构建队列,通过压栈(push)和出栈(pop)操作构建栈。

用栈实现队列:有5L和7L的两个瓶子,怎么取到1L的水?

#include <stdio.h>

#include <stdlib.h>

#define MAX_SIZE 10

typedef struct {

    int data[MAX_SIZE];

    int top;

} Stack;



void initStack(Stack* stack) {

    stack->top = -1;

}



int isEmpty(Stack* stack) {

    return stack->top == -1;

}



int isFull(Stack* stack) {

    return stack->top == MAX_SIZE - 1;

}



void push(Stack* stack, int value) {

    if (isFull(stack)) {

        printf("杯子满了!\n");

        return;

    }

    stack->data[++stack->top] = value;

}



int pop(Stack* stack) {

    if (isEmpty(stack)) {

        printf("杯子空了!\n");

        return -1;

    }

    return stack->data[stack->top--];

}



int peek(Stack* stack) {

    if (isEmpty(stack)) {

        printf("杯子空了!\n");

        return -1;

    }

    return stack->data[stack->top];

}



typedef struct {

    Stack s1;

    Stack s2;

} Queue;



void initQueue(Queue* queue) {

    initStack(&(queue->s1));

    initStack(&(queue->s2));

}



void enqueue(Queue* queue, int value) {

    if (isFull(&(queue->s1))) {

        printf("Queue 满!\n");

        return;

    }

    while (!isEmpty(&(queue->s1))) {

        push(&(queue->s2), pop(&(queue->s1)));

    }

    push(&(queue->s1), value);

    while (!isEmpty(&(queue->s2))) {

        push(&(queue->s1), pop(&(queue->s2)));

    }

}



int dequeue(Queue* queue) {

    if (isEmpty(&(queue->s1))) {

        printf("Queue 空!\n");

        return -1;

    }

    return pop(&(queue->s1));

}



int main() {

    Queue q;

    initQueue(&q);



    // Steps to achieve 1L water

    enqueue(&q, 5); // 步骤 1

    dequeue(&q);    // 步骤 2

    enqueue(&q, 5); // 步骤 3

    enqueue(&q, 3); // 步骤 4

    dequeue(&q);    // 步骤 5

    enqueue(&q, 5); // 步骤 6

    dequeue(&q);    // 步骤 7



    int result = dequeue(&q);

    if (result != -1) {

        printf("获得1L水\n");

    }



    return 0;

}

2. 探索复杂数据结构

2.1 树

        树是一种分层数据结构,常用于模拟层级关系。将研究二叉树、二叉搜索树和平衡树,探讨它们的性质、遍历方式以及在查找和排序中的作用。

二叉树特性

        每个节点最多有两个子节点。

        左子树和右子树是有序的,可以用于快速查找。

构造方法: 使用节点和指针链接,通过递归方式构建二叉树,可以是搜索树、平衡树等。

二叉树遍历:

        二叉树的先序、中序和后序遍历是常见的树遍历方法,它们分别以不同的顺序访问二叉树中的节点。这些遍历方法在处理二叉树的问题时非常有用。

1. 先序遍历(Preorder Traversal):

        先序遍历是从根节点开始,先访问根节点,然后按照先序遍历的顺序递归地访问左子树和右子树。先序遍历的顺序是:根节点 -> 左子树 -> 右子树。

先序遍历示例:

     1

    / \

   2   3

  / \ / \

 4  5 6  7

先序遍历结果:1 2 4 5 3 6 7

2. 中序遍历(Inorder Traversal):

        中序遍历是从根节点开始,按照中序遍历的顺序递归地访问左子树、根节点和右子树。中序遍历的顺序是:左子树 -> 根节点 -> 右子树。

中序遍历示例:

     1

    / \

   2   3

  / \ / \

 4  5 6  7

中序遍历结果:4 2 5 1 6 3 7

3. 后序遍历(Postorder Traversal):

        后序遍历是从根节点开始,按照后序遍历的顺序递归地访问左子树、右子树和根节点。后序遍历的顺序是:左子树 -> 右子树 -> 根节点。

后序遍历示例:   

     1

    / \

   2   3

  / \ / \

 4  5 6  7

后序遍历结果:4 5 2 6 7 3 1

        这些遍历方法在不同情况下具有不同的应用。例如,先序遍历常用于构建二叉树的复制、序列化和反序列化操作,中序遍历常用于获取排序后的节点值,后序遍历常用于内存回收和析构操作。熟悉这些遍历方法可以帮助您更好地理解和操作二叉树结构。

哈夫曼树:

        哈夫曼树(Huffman Tree),也称为最优二叉树(Optimal Binary Tree),是一种经常用于数据压缩的树形数据结构。它通过将出现频率较高的字符编码为较短的比特串,从而实现数据的高效压缩和解压缩。哈夫曼树在通信、文件压缩和编码等领域有广泛的应用。

构建哈夫曼树的过程

        构建哈夫曼树的主要思想是,将出现频率较高的字符(或符号)赋予较短的编码,而出现频率较低的字符赋予较长的编码,以达到整体编码长度的最小化。构建哈夫曼树的一般步骤如下:

1. 创建叶节点: 将每个字符作为一个叶节点,每个叶节点带有其出现频率作为权重。

2. 构建优先队列: 将所有叶节点放入一个优先队列(最小堆),按照权重(出现频率)进行排序。

3. 构建哈夫曼树: 从优先队列中依次取出两个权重最小的节点,合并为一个新节点,其权重为两个节点的权重之和。将新节点插入回优先队列,重复此过程,直到队列中只剩一个节点,即哈夫曼树的根节点。

4. 分配编码: 从根节点开始,沿着左分支走为0,沿着右分支走为1,从而为每个字符分配对应的编码。

哈夫曼编码的优势

         前缀编码: 哈夫曼编码是一种前缀编码,即任何字符的编码都不是其他字符编码的前缀。这保证了在解码时不会出现歧义。

         最优性: 构建哈夫曼树的过程确保了编码的最优性,即平均编码长度最短,从而达到了数据压缩的效果。

         无损压缩: 哈夫曼编码是一种无损压缩方法,解码后能够还原原始数据,不会丢失信息。

应用领域

1. 数据压缩: 哈夫曼编码被用于压缩文件、图像、音频等数据,将频繁出现的字符用短编码表示,从而减小数据存储和传输的开销。

2. 通信: 在通信系统中,可以使用哈夫曼编码来实现数据传输的压缩,节省带宽和传输时间。

3. 文件编码: 文件系统中的文件名、路径名等信息可以使用哈夫曼编码进行编码,减小存储开销。

4. 字典压缩: 哈夫曼编码可以用于构建字典压缩算法,用于无损压缩和解压缩文本数据。

2.2 图

        图是一种用于表示关系的数据结构,包括有向图和无向图。将介绍图的表示方法、深度优先搜索(DFS)、广度优先搜索(BFS)等算法,以及图在社交网络分析和路径搜索中的应用。

特性:

        有向图中的边是有方向的,无向图中的边没有方向。

        图中可以有环(循环边)和不同路径。

构造方法: 通过节点和边的集合,构建表示图形结构的数据结构。可以使用邻接矩阵或邻接表表示。

2.3 哈希表

        哈希表是一种高效的数据结构,用于实现键  值对的映射。将讨论哈希函数的原理、解决哈希冲突的方法,以及哈希表在快速查找和缓存中的应用。

特性:

快速访问: 哈希表使用散列函数将关键字映射到数组索引,因此可以实现O(1)时间复杂度的插入、查找和删除操作,具有高效的数据访问速度。

高效的查找: 哈希表适用于需要快速查找的场景,因为散列函数可以将关键字直接映射到数组索引,无需遍历整个数据集。

灵活的数据存储: 哈希表可以存储各种类型的数据,如整数、字符串、对象等,使其适用于多种应用场景。

动态扩展: 当哈希表中的元素数量增加时,可以通过重新哈希或其他方法来动态扩展数组的大小,以保持哈希表的高效性能。

用法:

缓存: 哈希表常用于实现缓存,将请求的数据存储在哈希表中,以便快速检索。例如,Web服务器可以使用哈希表缓存热门网页内容,提高访问速度。

数据索引 数据库索引通常使用哈希表,以快速定位和检索数据库记录。哈希索引可以加速数据库查询操作。

唯一性检查: 哈希表可以用于检查数据的唯一性。例如,检查新注册的用户名是否已存在于系统中。

分布式存储: 在分布式系统中,哈希表可以帮助路由请求到正确的服务器节点,实现负载均衡和数据分片。

字典: 哈希表可以用作字典,将单词映射到其定义,用于实现拼写检查器和自动补全功能。

关联数组: 编程语言中的关联数组(Associative Array)实际上就是哈希表的一种应用,将键映射到值,支持快速查找。

实现注意事项:

散列函数设计: 散列函数应尽量均匀地将关键字映射到数组索引,以避免冲突。好的散列函数可以减少碰撞(多个关键字映射到同一索引)的概率。

冲突处理: 当不同关键字映射到相同的数组索引时,需要采取冲突处理方法,如链地址法(使用链表解决冲突)、开放地址法(寻找下一个可用的索引位置)等。

动态扩展: 当哈希表负载过高时,可以动态地扩展数组大小,以保持性能。扩展时需要重新计算散列函数,将现有数据重新插入。

3. 数据结构与算法的完美配合

3.1算法复杂度

        算法复杂度是衡量算法运行时间随输入规模增加而增加的度量。它通常分为时间复杂度和空间复杂度两个方面。

时间复杂度

        时间复杂度描述了算法执行所需时间随问题规模增加而增加的速度。它用大O记法(O  notation)表示。常见的时间复杂度包括:

O(1):常数时间复杂度,表示算法的执行时间是一个常数,与问题规模无关。例如,数组的访问操作。

O(log n):对数时间复杂度,通常出现在分治算法或二分搜索等情况下,问题规模减半。

O(n):线性时间复杂度,算法的执行时间与问题规模成线性关系。例如,线性搜索。

O(n log n):线性对数时间复杂度,常见于一些高效的排序算法,如快速排序和归并排序。

O(n^2):平方时间复杂度,常见于一些简单的嵌套循环算法,如冒泡排序。

O(n^k):多项式时间复杂度,其中k是一个常数。例如,矩阵乘法。

O(2^n):指数时间复杂度,通常出现在递归算法中,每次问题规模指数增加。

空间复杂度

        空间复杂度描述了算法所需的额外内存空间随问题规模增加而增加的速度。同样使用大O记法表示。常见的空间复杂度包括:

O(1):常数空间复杂度,表示算法的额外内存使用是一个常数,与问题规模无关。

O(n):线性空间复杂度,算法的额外内存使用与问题规模成线性关系。

O(n^2):平方空间复杂度,通常出现在需要构建二维数组等情况下。

3.2优化算法

        算法优化旨在提高算法的执行效率,以减少运行时间和内存消耗。以下是一些常见的算法优化方法:

1. 选择合适的算法:选择适用于问题的高效算法,例如使用快速排序而不是冒泡排序。

2. 减少循环次数:减少循环迭代次数,避免不必要的重复计算。

3. 使用空间换时间:通过使用额外的内存空间,存储中间结果,避免重复计算。

4. 剪枝和预处理:在搜索和遍历算法中,通过剪枝操作排除不必要的分支,提高搜索效率。

5. 并行计算:对于某些问题,可以使用并行计算来提高效率,例如使用多线程或并行计算框架。

6. 使用缓存优化:将频繁访问的数据存储在缓存中,减少内存访问时间。

7. 减少内存分配和释放:在动态内存分配时,减少不必要的内存分配和释放操作,避免内存碎片。

8. 算法改进:对于某些特定问题,可以尝试改进算法,减少时间复杂度。

9. 利用硬件特性:根据硬件架构,优化算法以利用处理器的并行性和向量化指令。

有许多常见的排序算法,每种算法都有其特定的优势和适用场景。以下是一些常见的排序方法:

1. 冒泡排序:冒泡排序是一种简单的交换排序算法,它重复地比较相邻的元素并交换它们,直到整个序列排序完成。虽然冒泡排序易于实现,但在大型数据集上性能较差,时间复杂度为O(n^2)。

        假设有一个整数数组 [5, 3, 8, 4, 2],并且我们要对其进行升序排序。

        冒泡排序的基本思想是从数组的开头开始,比较相邻的两个元素,如果顺序不对则交换位置,一轮循环后,最大的元素会沉到数组末尾。然后继续进行下一轮循环,不断将未排序的部分的最大元素“冒泡”到合适的位置。

示例代码:

#include <stdio.h>

void bubbleSort(int arr[], int n) {

    for (int i = 0; i < n - 1; i++) {

        for (int j = 0; j < n - i - 1; j++) {

            if (arr[j] > arr[j + 1]) {

                // 交换位置

                int temp = arr[j];

                arr[j] = arr[j + 1];

                arr[j + 1] = temp;

            }

        }

    }

}



int main() {

    int arr[] = { 5, 3, 8, 4, 2 };

    int n = sizeof(arr) / sizeof(arr[0]);



    printf("原始数组:");

    for (int i = 0; i < n; i++) {

        printf("%d ", arr[i]);

    }



    bubbleSort(arr, n);



    printf("\n排序后数组:");

    for (int i = 0; i < n; i++) {

        printf("%d ", arr[i]);

    }



    return 0;

}

输出结果:

原始数组:5 3 8 4 2

排序后数组:2 3 4 5 8

        在这个示例中,冒泡排序首先比较相邻的元素,将较大的元素不断向后交换,直到最大元素“冒泡”到数组的末尾。经过一轮循环,最大元素已经就位。然后,继续进行下一轮循环,将第二大的元素“冒泡”到倒数第二的位置,以此类推。最终,整个数组按升序排序。

2. 选择排序:选择排序是一种简单的选择排序算法,每次从未排序的部分中选择最小的元素并放到已排序的部分末尾。尽管选择排序也不适用于大型数据集,但它的实现简单,时间复杂度为O(n^2)。

3. 插入排序:插入排序是一种通过构建有序序列逐步扩大的排序算法。它从未排序的部分选择一个元素并插入到已排序的部分中的正确位置。插入排序对于小型数据集表现良好,时间复杂度为O(n^2)。

4. 快速排序:快速排序是一种分治排序算法,它将数组分为较小和较大的两个子数组,然后递归地对子数组进行排序。快速排序通常是一种高效的排序算法,平均时间复杂度为O(n log n),但在最坏情况下可能达到O(n^2)。

5. 归并排序:归并排序是一种分治排序算法,它将数组分成单个元素的子数组,然后逐步合并这些子数组以创建一个有序数组。归并排序的时间复杂度稳定在O(n log n)。

示例代码:

#include <stdio.h>

#include <stdlib.h>

void merge(int arr[], int left, int mid, int right) {

    int n1 = mid - left + 1;

    int n2 = right - mid;



    int* leftArr = (int*)malloc(n1 * sizeof(int));

    int* rightArr = (int*)malloc(n2 * sizeof(int));



    for (int i = 0; i < n1; i++) {

        leftArr[i] = arr[left + i];

    }

    for (int i = 0; i < n2; i++) {

        rightArr[i] = arr[mid + 1 + i];

    }



    int i = 0, j = 0, k = left;



    while (i < n1 && j < n2) {

        if (leftArr[i] <= rightArr[j]) {

            arr[k] = leftArr[i];

            i++;

        }

        else {

            arr[k] = rightArr[j];

            j++;

        }

        k++;

    }



    while (i < n1) {

        arr[k] = leftArr[i];

        i++;

        k++;

    }



    while (j < n2) {

        arr[k] = rightArr[j];

        j++;

        k++;

    }



    free(leftArr);

    free(rightArr);

}



void mergeSort(int arr[], int left, int right) {

    if (left < right) {

        int mid = left + (right - left) / 2;



        mergeSort(arr, left, mid);

        mergeSort(arr, mid + 1, right);



        merge(arr, left, mid, right);

    }

}



int main() {

    int arr[] = { 5, 3, 8, 4, 2 };

    int n = sizeof(arr) / sizeof(arr[0]);



    printf("原始数组:");

    for (int i = 0; i < n; i++) {

        printf("%d ", arr[i]);

    }



    mergeSort(arr, 0, n - 1);



    printf("\n排序后数组:");

    for (int i = 0; i < n; i++) {

        printf("%d ", arr[i]);

    }



    return 0;

}

输出结果:

原始数组:5 3 8 4 2

排序后数组:2 3 4 5 8

        在这个示例中,归并排序首先将数组分成较小的子数组,然后逐步合并这些子数组,直到得到完全有序的数组。在合并的过程中,通过比较左右子数组的元素来创建有序的合并结果。归并排序的时间复杂度为O(n log n),在各种情况下都能保持稳定的性能。

6. 堆排序:堆排序利用堆数据结构,通过构建最大堆或最小堆来进行排序。堆排序的时间复杂度为O(n log n),它的性能稳定,适用于大型数据集。

7. 计数排序:计数排序是一种非比较的整数排序算法,它适用于有限范围内的整数排序。计数排序的时间复杂度为O(n+k),其中k是整数的范围。

8. 桶排序:桶排序将元素分布到多个桶中,然后对每个桶内的元素进行排序,最后将桶合并成一个有序序列。桶排序适用于均匀分布的数据,时间复杂度取决于桶的数量。

9. 基数排序:基数排序将数字按照位数从低到高进行排序,每一位使用稳定的排序算法。基数排序适用于整数排序,时间复杂度为O(nk),其中k是最大数字的位数。

        算法复杂度和优化是数据结构和算法领域的核心概念,对于设计高效的程序和解决实际问题至关重要。通过分析算法的复杂度,并采取合适的优化策略,可以使程序在执行时间和资源消耗方面达到最佳性能。

4. 精通内存管理

4.1 动态内存分配

        在C语言中,动态内存分配允许在运行时分配和释放内存。探讨malloc、calloc和realloc等函数的使用,以及如何避免内存泄漏和悬空指针。

4.2 内存布局与指针

        深入了解内存布局和指针将有助于更好地理解数据结构。通过学习指针的概念、指针与数组的关系,掌握如何通过指针访问和修改数据。

5. 提高程序性能的策略

5.1 数据结构选择

        不同数据结构具有不同的性能特点。将详细比较数组、链表、树等数据结构的优缺点,以及如何根据问题需求选择合适的数据结构。

5.2 空间和时间权衡

        了解空间和时间的权衡对于程序的优化至关重要。将探讨如何在空间和时间之间做出权衡,以满足不同应用场景的需求。

  • 29
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

在知识的海洋里划船

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值