• 数据结构与算法(二):数组与链表


    参考引用

    1. 数组

    • 数组(array)是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。将元素在数组中的位置称为该元素的索引(index)
      在这里插入图片描述

    1.1 数组常用操作

    1.1.1 初始化数组
    • 根据需求选用数组的两种初始化方式:无初始值、给定初始值
      • 在未指定初始值的情况下,大多数编程语言会将数组元素初始化为 0
      // 存储在栈上
      int arr[5];
      int nums[5] { 1, 3, 2, 5, 4 };
      // 存储在堆上(需要手动释放空间)
      int* arr1 = new int[5];
      int* nums1 = new int[5] { 1, 3, 2, 5, 4 };
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
    1.1.2 访问元素
    • 数组元素被存储在连续的内存空间中,给定数组内存地址(即首元素内存地址)和某个元素的索引,可以使用下图所示的公式计算得到该元素的内存地址,从而直接访问此元素
      • 索引的含义本质上是内存地址的偏移量,首个元素的地址偏移量是 0
      • 在数组中访问元素非常高效,可以在 O(1) 时间内随机访问数组中的任意一个元素
      /* 随机访问元素 */
      int randomAccess(int *nums, int size) {
          // 在区间 [0, size) 中随机抽取一个数字
          int randomIndex = rand() % size;
          // 获取并返回随机元素
          int randomNum = nums[randomIndex];
          return randomNum;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8

    在这里插入图片描述

    1.1.3 插入元素
    • 数组元素在内存中是 “紧挨着的”,它们之间没有空间再存放任何数据。如下图所示,如果想要在数组中间插入一个元素,则需要将该元素之后的所有元素都向后移动一位,之后再把元素赋值给该索引
      /* 在数组的索引 index 处插入元素 num */
      void insert(int *nums, int size, int num, int index) {
          // 把索引 index 以及之后的所有元素向后移动一位
          for (int i = size - 1; i > index; i--) {
              nums[i] = nums[i - 1];
          }
          // 将 num 赋给 index 处元素
          nums[index] = num;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9

    在这里插入图片描述

    1.1.4 删除元素
    • 若想要删除索引 i 处的元素,则需要把索引 i 之后的元素都向前移动一位
      /* 删除索引 index 处元素 */
      void remove(int *nums, int size, int index) {
          // 把索引 index 之后的所有元素向前移动一位
          for (int i = index; i < size - 1; i++) {
              nums[i] = nums[i + 1];
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7

    在这里插入图片描述

    数组的插入与删除操作有以下缺点,建议使用 vector

    • 时间复杂度高:数组的插入和删除的平均时间复杂度均为 O(n),其中 n 为数组长度
    • 丢失元素:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失
    • 内存浪费:可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是 “无意义” 的,但这样做也会造成部分内存空间的浪费
    1.1.5 遍历数组
    • 既可以通过索引遍历数组,也可以直接遍历获取数组中的每个元素
      void traverse(int *nums, int size) {
          int count = 0;
          // 通过索引遍历数组
          for (int i = 0; i < size; i++) {
              count++;
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
    1.1.6 查找元素
    • 在数组中查找指定元素需要遍历数组,每轮判断元素值是否匹配,若匹配则输出对应索引。因为数组是线性数据结构,所以上述查找操作被称为 “线性查找”
      /* 在数组中查找指定元素 */
      int find(int *nums, int size, int target) {
          for (int i = 0; i < size; i++) {
              if (nums[i] == target)
                  return i;
          }
          return -1;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8

    1.2 数组优缺点

    • 优点

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

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

    1.3 数组典型应用

    • 随机访问、排序和搜索、查找表、机器学习、数据结构实现

    2. 链表

    内存空间是所有程序的公共资源,在一个复杂系统运行环境下,空闲的内存空间可能散落在各处。存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间,此时链表的灵活性优势就被体现

    • 链表(linked list)是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过指针相连接,指针记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点
      • 链表的设计使得各个节点可以被分散存储在内存各处,它们的内存地址是无须连续的

    在这里插入图片描述

    • 链表的组成单位是节点(node)对象。每个节点都包含两项数据:节点的 “值” 和指向下一节点的指针

      • 链表的首个节点被称为 “头节点”,最后一个节点被称为 “尾节点”
      • 尾节点指向的是 “空” nullptr
    • 链表节点 ListNode 除了包含值,还需额外保存一个指针。因此在相同数据量下,链表比数组占用更多的内存空间

      /* 链表节点结构体 */
      struct ListNode {
          int val;         // 节点值
          ListNode *next;  // 指向下一节点的指针
          ListNode(int x) : val(x), next(nullptr) {}  // 构造函数
      };
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6

    2.1 链表常用操作

    2.1.1 初始化链表
    • 建立链表分为两步
      • 第一步是初始化各个节点对象
      • 第二步是构建引用指向关系
        • 初始化完成后,就可以从链表的头节点出发,通过引用指向 next 依次访问所有节点
      /* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
      // 初始化各个节点(通常将头节点当作链表的代称)
      ListNode* n0 = new ListNode(1);
      ListNode* n1 = new ListNode(3);
      ListNode* n2 = new ListNode(2);
      ListNode* n3 = new ListNode(5);
      ListNode* n4 = new ListNode(4);
      
      // 构建引用指向
      n0->next = n1;
      n1->next = n2;
      n2->next = n3;
      n3->next = n4;
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13

    数组整体是一个变量,比如数组 nums 包含元素 nums[0] 和 nums[1] 等,而链表是由多个独立的节点对象组成的

    2.1.2 插入节点
    • 假设想在相邻的两个节点 n0 和 n1 之间插入一个新节点 P,只需改变两个节点指针即可,时间复杂度为 O(1)

      相比之下,在数组中插入元素的时间复杂度为 O(n),在大数据量下的效率较低

      /* 在链表的节点 n0 之后插入节点 P */
      void insert(ListNode *n0, ListNode *P) {
          ListNode *n1 = n0->next;
          P->next = n1;
          n0->next = P;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6

    在这里插入图片描述

    2.1.3 删除节点
    • 只需改变一个节点的指针即可
      • 尽管在删除操作完成后节点 P 仍然指向 n1,但实际上遍历此链表已经无法访问到 P ,这意味着 P 已经不再属于该链表
      /* 删除链表的节点 n0 之后的首个节点 */
      void remove(ListNode *n0) {
          if (n0->next == nullptr)
              return;
          // n0 -> P -> n1
          ListNode *P = n0->next;
          ListNode *n1 = P->next;
          n0->next = n1;
          // 释放内存
          delete P;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11

    在这里插入图片描述

    2.1.4 访问节点
    • 在链表访问节点的效率较低
      • 如上节所述,可以在 O(1) 时间下访问数组中的任意元素
      • 链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第 i 个节点需要循环 i - 1 轮,时间复杂度为 O(n)
      /* 访问链表中索引为 index 的节点 */
      ListNode *access(ListNode *head, int index) {
          for (int i = 0; i < index; i++) {
              if (head == nullptr)
                  return nullptr;
              head = head->next;
          }
          return head;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
    2.1.5 查找节点
    • 遍历链表,查找链表内值为 target 的节点,输出节点在链表中的索引,此过程也属于线性查找
      /* 在链表中查找值为 target 的首个节点 */
      int find(ListNode *head, int target) {
          int index = 0;
          while (head != nullptr) {
              if (head->val == target)
                  return index;
              head = head->next;
              index++;
          }
          return -1;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11

    2.2 数组与链表对比

    在这里插入图片描述

    2.3 常见链表类型

    • 单向链表:即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据
      • 将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空
    • 环形链表:如果令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表
      • 在环形链表中,任意节点都可以视作头节点
    • 双向链表:与单向链表相比,双向链表记录了两个方向的指针
      • 双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的指针
      • 相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间

    在这里插入图片描述

    2.4 链表典型应用

    • 单向链表通常用于实现栈、队列、哈希表和图等数据结构
    • 双向链表常被用于需要快速查找前一个和下一个元素的场景
      • 高级数据结构
        • 比如在红黑树、B 树中,需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表
      • 浏览器历史
        • 在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单
      • LRU 算法
        • 在缓存淘汰算法(LRU)中,需要快速找到最近最少使用的数据,以及支持快速地添加和删除节点
    • 循环链表常被用于需要周期性操作的场景,比如操作系统的资源调度
      • 时间片轮转调度算法
        • 在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环的操作就可以通过循环链表来实现
      • 数据缓冲区
        • 在某些数据缓冲区的实现中,也可能会使用到循环链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个循环链表,以便实现无缝播放

    3. 列表

    数组长度不可变导致实用性降低。在实际中,可能事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费

    • 为解决此问题,出现了一种被称为动态数组的数据结构,即长度可变的数组,也常被称为列表 vector (C++)
    • 列表基于数组实现,继承了数组的优点,并可在程序运行过程中动态扩容,可以在列表中自由地添加元素

    3.1 列表常用操作

    /* 1、初始化列表 */
    // 无初始值
    vector<int> list1;
    // 有初始值
    vector<int> list = { 1, 3, 2, 5, 4 };
    
    /* 2、访问元素 */
    // 列表本质上是数组,因此可以在 O(1) 时间内访问和更新元素
    int num = list[1];  // 访问索引 1 处的元素
    /* 更新元素 */
    list[1] = 0;  // 将索引 1 处的元素更新为 0
    
    /* 3、插入与删除元素 */
    // 在列表尾部添加元素的时间复杂度为 O(1)
    // 但插入和删除元素的效率仍与数组相同,时间复杂度为 O(n)
    /* 清空列表 */
    list.clear();
    /* 尾部添加元素 */
    list.push_back(1);
    list.push_back(3);
    list.push_back(2);
    list.push_back(5);
    list.push_back(4);
    /* 中间插入元素 */
    list.insert(list.begin() + 3, 6);  // 在索引 3 处插入数字 6
    /* 删除元素 */
    list.erase(list.begin() + 3);      // 删除索引 3 处的元素
    
    /* 4、通过索引遍历列表 */
    int count = 0;
    for (int i = 0; i < list.size(); i++) {
        count++;
    }
    /* 直接遍历列表元素 */
    count = 0;
    for (int n : list) {
        count++;
    }
    
    /* 5、拼接两个列表 */
    vector<int> list1 = { 6, 8, 7, 10, 9 };
    // 将列表 list1 拼接到 list 之后
    list.insert(list.end(), list1.begin(), list1.end());
    
    /* 6、排序列表 */
    sort(list.begin(), list.end());  // 排序后,列表元素从小到大排列
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    3.2 列表实现

    /* 列表类简易实现 */
    class MyList {
      private:
        int *nums;             // 数组(存储列表元素)
        int numsCapacity = 10; // 列表容量
        int numsSize = 0;      // 列表长度(即当前元素数量)
        int extendRatio = 2;   // 每次列表扩容的倍数
    
      public:
        /* 构造方法 */
        MyList() {
            nums = new int[numsCapacity];
        }
    
        /* 析构方法 */
        ~MyList() {
            delete[] nums;
        }
    
        /* 获取列表长度(即当前元素数量)*/
        int size() {
            return numsSize;
        }
    
        /* 获取列表容量 */
        int capacity() {
            return numsCapacity;
        }
    
        /* 访问元素 */
        int get(int index) {
            // 索引如果越界则抛出异常,下同
            if (index < 0 || index >= size())
                throw out_of_range("索引越界");
            return nums[index];
        }
    
        /* 更新元素 */
        void set(int index, int num) {
            if (index < 0 || index >= size())
                throw out_of_range("索引越界");
            nums[index] = num;
        }
    
        /* 尾部添加元素 */
        void add(int num) {
            // 元素数量超出容量时,触发扩容机制
            if (size() == capacity())
                extendCapacity();
            nums[size()] = num;
            // 更新元素数量
            numsSize++;
        }
    
        /* 中间插入元素 */
        void insert(int index, int num) {
            if (index < 0 || index >= size())
                throw out_of_range("索引越界");
            // 元素数量超出容量时,触发扩容机制
            if (size() == capacity())
                extendCapacity();
            // 将索引 index 以及之后的元素都向后移动一位
            for (int j = size() - 1; j >= index; j--) {
                nums[j + 1] = nums[j];
            }
            nums[index] = num;
            // 更新元素数量
            numsSize++;
        }
    
        /* 删除元素 */
        int remove(int index) {
            if (index < 0 || index >= size())
                throw out_of_range("索引越界");
            int num = nums[index];
            // 索引 i 之后的元素都向前移动一位
            for (int j = index; j < size() - 1; j++) {
                nums[j] = nums[j + 1];
            }
            // 更新元素数量
            numsSize--;
            // 返回被删除元素
            return num;
        }
    
        /* 列表扩容 */
        void extendCapacity() {
            // 新建一个长度为原数组 extendRatio 倍的新数组
            int newCapacity = capacity() * extendRatio;
            int *tmp = nums;
            nums = new int[newCapacity];
            // 将原数组中的所有元素复制到新数组
            for (int i = 0; i < size(); i++) {
                nums[i] = tmp[i];
            }
            // 释放内存
            delete[] tmp;
            numsCapacity = newCapacity;
        }
    
        /* 将列表转换为 Vector 用于打印 */
        vector<int> toVector() {
            // 仅转换有效长度范围内的列表元素
            vector<int> vec(size());
            for (int i = 0; i < size(); i++) {
                vec[i] = nums[i];
            }
            return vec;
        }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
  • 相关阅读:
    机器学习之算法优化(一)
    Oracle-expdp报错ORA-08103: object no longer exists
    SpringBoot(二) - 核心配置文件
    广州华锐互动:候车室智能数字孪生系统实现交通信息可视化
    地级市市场化指数+樊纲市场化指数(包含分省、市两份数据)
    Springboot毕设项目线上影院系统ao90djava+VUE+Mybatis+Maven+Mysql+sprnig)
    Nginx监控模块vts
    通过本机端口映射VMware中虚拟机应用(例如同一局域网别人想远程连接你虚拟机中的数据库)
    [附源码]Python计算机毕业设计Django的物品交换平台
    word文档太大怎么压缩?快速压缩word文档
  • 原文地址:https://blog.csdn.net/qq_42994487/article/details/133581904