Hello算法——数组与链表

4.1 数组(array)

4.1.1 数组基操

  1. 初始化数组
int arr[5] = { 0 };
int nums[5] = [1,5,6,21,4]
  1. 访问元素

//访问元素
//  随机访问
int randomAccess(int *nums, int size){
    int randomIndex = rand() % size;
    // size = sizeof(nums) / sizeof(nums[0]);
    int randomNumd = nums[randomIndex];
    return randomNumd;
}
  1. 插入元素

如果想在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引。

// 插入元素(在index位置插入num)
void insert(int *nums, int size, int num, int index){
    for(int i = size - 1; i > index; i--){
        nums[i] = nums[i - 1];
    }
    nums[index] = num;
}
  1. 删除元素

删除索引 i 处的元素,则需要把索引 i 之后的元素都向前移动一位。

// 删除元素(删除index位置的元素)
//  注意:stdio.h 占用了 remove 关键词
void removeItem(int *nums, int size, int index){
    for(int i = size - 1; i < index; i++){
        nums[i] = nums[i + 1];
    }
}

数组的插入与删除操作的缺点:

  • 时间复杂度均为O(n)。
  • 超出数组长度范围的元素会丢失。
  • 内存浪费:初始化的时候通常会给出余量,这样在插入元素的时候丢失的末尾元素都无意义。
  • 遍历数组

通过索引遍历数组

// 遍历数组
void traverse(int *nums, int size){
    int temp = 0;
    for (int i = 0; i < size; i++){
        temp = nums[i];
        printf("num = %d\n", temp);
    }
}
  1. 查找元素(也称为线性查找)

遍历数组,每轮判断元素值是否匹配,若匹配则输出对应索引。

 // 查找元素
int search(int *nums, int size, int target){
    for (int i = 0; i < size; i++){
        if (nums[i] == target){
            return i;
        }
    }  
    return -1; // 未找到元素,返回一个不可能出现的索引值
}
  1. 扩容数组

遍历数组,每轮判断元素值是否匹配,若匹配则输出对应索引。

时间复杂度是O(n)

// 扩展数组
int *extendArray(int *nums, int size, int newSize){
    // 新数组的动态内存分配
    int *newNums = (int *)malloc(newSize * sizeof(int));
    // 把原数组的元素依次添加到新数组中
    for (int i = 0; i< size; i++){
        newNums[i] = nums[i];
    }
    // 初始化新数组中剩余的元素
    for (int i = size; i < newSize; i++){
        newNums[i] = 0;
    }
    return newNums;
}

4.1.2 数组的优点与局限性

数组存储在连续的内存空间内,且元素类型相同。

  • 空间效率高:数组为数据分配了连续的内存块,无须额外的结构开销。
  • 支持随机访问:数组允许在 O(1) 时间内访问任何元素。
  • 缓存局部性:当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度。

局限性:

  • 插入与删除效率低:当数组中元素较多时,插入与删除操作需要移动大量的元素。
  • 长度不可变:数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大。
  • 空间浪费:如果数组分配的大小超过实际所需,那么多余的空间就被浪费了。

4.1.3 应用

  • 随机访问
  • 排序、搜索算法
  • 查找表
  • 机器学习
  • 实现栈、队列、哈希表、堆、图等数据结构

4.2 链表(linked list)

  • 链表的组成单位是节点(node)
  • 每个节点含 值、引用(指向下一节点的指针)。
  • 尾节点指向“空”(C:nullptr)
  • 链表比数组占用更多的内存空间(多出来的部分:引用(指针))
#include <stdio.h>
#include <stdlib.h>

typedef struct ListNode{
    int val;
    struct ListNode *next;
}ListNode;


ListNode *newListNode(int val){
    ListNode *node;
    node = (ListNode *)malloc(sizeof(ListNode));
    node -> val = val;
    node -> next = NULL;
    return node;
}

4.2.1 链表基操

  1. 初始化链表
  • 初始化各节点
  • 建立节点间的引用关系

通常用头节点代指链表,比如下面的链表可以叫作n0

//初始化链表
int main(){
    ListNode* n0 = newListNode(3);
    ListNode* n1 = newListNode(9);
    ListNode* n2 = newListNode(7);
    ListNode* n3 = newListNode(2);

    n0 -> next = n1;
    n1 -> next = n2;
    n2 -> next = n3;
    n3 -> next = NULL;

}

遍历链表:

//遍历链表
void traverse(ListNode* head){
    for (ListNode* i = head; i != NULL; i = i -> next){
        printf("%d", i -> val); //打印节点的值 node->val
        if(i -> next!= NULL)
            printf(" -> ");
    }
}
  1. 插入节点

在相邻节点间插入新节点p,只需要改变两个节点的引用(指针)即可。

链表插入节点的时间复杂度为O(1)

对比:数组插入元素的时间复杂度是O(n)

//插入节点
void insert(ListNode* node1, ListNode* node2, ListNode* p){
    node1 -> next = p;
    p -> next = node2;
}
  1. 删除节点

也是只需要改变一个节点的引用(指针)即可。

//删除节点
// 删除node1 后的 第一个节点
void removeNode(ListNode* node1){
    if(! node1 -> next){
        return;
    }
    //node1 -> p -> node2
    ListNode* p = node1 -> next; //把node1 后的第一个节点记作p
    ListNode* node2 = p -> next; //把p 后的第一个节点记作node2

    //node1 -> node2
    node1 -> next = node2;
    free(p);
}
  1. 访问节点
  • 链表访问节点效率很低,需要从头节点开始遍历,直到找到目标节点再访问。
  • 假如要访问第i个节点,则需要循环i-1次(第一个节点遍历到第i个节点)
  • 时间复杂度O(n)

对比:数组的访问元素可以在O(1)的时间复杂度下访问任一元素。

//访问节点
ListNode* searchNode(ListNode* head,int index){
    for(int i = 0;i < index; i++){
        if(head == NULL)
            return NULL;
        head = head -> next;  //更新head为下一个节点
    }
    return head;
}
  1. 查找节点
  • 和访问节点一样,需要遍历链表到target节点,然后返回这个节点的索引。
  • 属于线性查找。
//查找节点(的索引)
int search(ListNode* head,int target){
    for(int index = 0; head != NULL; index++){
        if(head ->val == target){
            return index;
        }
        head = head -> next;    //更新head为下一个节点
    }
    return -1; //未找到,返回一个不可能存在的索引
}

4.2.2 数组 vs. 链表

4.2.3 常见链表类型

  • 单向链表(也就是前面举例的普通链表)
  • 环形链表:尾节点 -> 头节点(首尾相接),环形链表中的任一节点都可以是头节点。
  • 双向链表:每个节点包含节点值、两个引用(前驱节点和后继节点的指针)
#include <stdio.h>

//双向链表的结构体
typedef struct DoubleLinkedList{
    int val; //节点值
    struct DoubleLinkedList* prev;
    struct DoubleLinkedList* next;
}DoubleLinkedList; 

//节点创建函数
DoubleLinkedList* newDoubleLinkedList(int val){
    DoubleLinkedList* node = (DoubleLinkedList*)malloc(sizeof(DoubleLinkedList));
    node -> val = val;
    node -> prev = NULL;
    node -> next = NULL;
    return node;
}

4.2.4 链表的应用

单向链表通常用于实现栈、队列、哈希表和图等数据结构。

  • 栈与队列:当插入和删除操作都在链表的一端进行时,它表现的特性为先进后出,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现的特性为先进先出,对应队列。
  • 哈希表:链式地址是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。
  • :邻接表是表示图的一种常用方式,其中图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。

双向链表常用于需要快速查找前一个和后一个元素的场景。

  • 高级数据结构:比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
  • 浏览器历史:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。
  • LRU 算法:在缓存淘汰(LRU)算法中,我们需要快速找到最近最少使用的数据,以及支持快速添加和删除节点。这时候使用双向链表就非常合适。

环形链表常用于需要周期性操作的场景,比如操作系统的资源调度。

  • 时间片轮转调度算法:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环操作可以通过环形链表来实现。
  • 数据缓冲区:在某些数据缓冲区的实现中,也可能会使用环形链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环形链表,以便实现无缝播放。

4.3 列表(list)

  • 列表是一个抽象出来的概念,是元素的有序集合,支持元素增、删、查、改、遍历。
  • 列表可以用数组 / 链表实现。
  • 链表天然就是一个列表,增、删、查、改、遍历都有,且可以灵活扩容。
  • 数组可以看作一个具有长度限制的列表,虽然也能增、删、查、改、遍历,但是长度不可变。
  • c语言中没有动态数组。C++、C#、Python、Java都支持动态数组。

4.3.1 列表的基操

调用内置函数实现

4.3.2 列表的简单实现

  • 初始容量
  • 数据记录
  • 扩容机制
#include <stdio.h>
// 列表结构体
typedef struct
{
    int *arr;        // 数组
    int capacity;    // 列表容量
    int size;        // 列表大小
    int extendRatio; // 列表每次扩容倍数
} Mylist;

// 构造函数
Mylist *newMylist()
{
    Mylist *nums = (int *)malloc(sizeof(Mylist));
    nums->capacity = 10;
    nums->arr = (int *)malloc(sizeof(int) * nums->capacity);
    nums->size = 0;
    nums->extendRatio = 2;
    return nums;
}

// 析构函数
void delMylist(Mylist *nums)
{
    free(nums->arr);
    free(nums);
}

// 获取列表容量
int capacity(Mylist *nums)
{
    return nums->capacity;
}

// 获取列表大小
int size(Mylist *nums)
{
    return nums->size;
}

// 列表转换为数组
int *toArray(Mylist *nums)
{
    return nums->arr;
}

// 访问元素
int get(Mylist *nums, int index)
{
    assert(index >= 0 && index < nums->size);
    return nums->arr[index];
}

// 更新元素
void set(Mylist *nums, int index, int newNum)
{
    assert(index >= 0 && index < nums->size);
    nums->arr[index] = newNum;
}

// 列表扩容
void extendCapacity(Mylist *nums)
{
    // 分配空间
    int newCapacity = capacity(nums) * nums->extendRatio;
    int *extendNums = (int *)malloc(sizeof(int) * newCapacity);
    int *oldArr = nums->arr;
    // 旧数组复制到新数组
    for (int i = 0; i < size(nums); i++)
    {
        extendNums[i] = oldArr[i];
    }
    // 释放旧数据
    free(oldArr);
    // 更新数据到原数组
    nums->arr = extendNums;
    nums->capacity = newCapacity;
}

// 在尾部添加元素
void addLast(Mylist *nums, int newNum)
{
    if (size(nums) == capacity(nums))
    {
        extendCapacity(nums);
    }
    nums->arr[size(nums)] = newNum;
    nums->size++;
}

// 在中间插入元素
void insert(Mylist *nums, int index, int newNum)
{
    assert(index >= 0 && index <= size(nums));

    if (size(nums) == capacity(nums))
    {
        extendCapacity(nums);
    }

    // index后面的元素后移一位
    for (int i = size(nums); i > index; i--)
    {
        nums->arr[i] = nums->arr[i - 1];
    }

    nums->arr[index] = newNum;
    nums->size++;
}

// 删除元素
int removeItem(Mylist *nums, int index)
{
    assert(index >= 0 && index < size(nums));

    int removedNum = nums->arr[index];

    // index以及后面的元素前移一位
    for (int i = index; i < size(nums) - 1; i++)
    {
        nums->arr[i] = nums->arr[i + 1];
    }

    nums->size--;

    return removedNum;
}
posted @ 2025-03-06 22:01  EanoJiang  阅读(51)  评论(2)    收藏  举报