搜索
您的当前位置:首页正文

C++ 秋招必知必会(数据结构与算法:上)

来源:易榕旅网

1. 数据结构与算法的定义

  • 算法(algorithm)是在有限时间内解决特定问题的一组指令或操作步骤
  • 数据结构(data structure)是计算机中组织和存储数据的方式
  • 数据结构与算法的关系
    • 数据结构为算法提供了结构化存储的数据,以及用于操作数据的方法
    • 数据结构本身仅存储数据信息,结合算法才能解决特定问题
    • 算法通常可以基于不同的数据结构进行实现,但执行效率可能相差很大
    • 数据结构与算法是独立于编程语言的

2. 数据结构分类

  • 1、线性结构
    • 数组、链表、队列、栈、哈希表,元素之间是一对一的顺序关系
  • 2、非线性结构
    • 树形结构
      • 树、堆、哈希表,元素之间是一对多的关系
    • 网状结构
      • 图,元素之间是多对多的关系

3. 数据的存储方式

  • 在计算机中,内存和硬盘是两种主要的存储硬件设备
    • 内存用于运行程序时暂存数据,速度较快,但容量较小(通常为 GB 级别)
    • 硬盘主要用于长期存储数据,容量较大,但速度较慢(通常可达到 TB 级别)
  • 在算法运行过程中,相关数据都存储在内存中
  • 系统通过内存地址来访问目标位置的数据
  • 数据在计算机内存中的存储方式,可分为连续空间存储(数组)和分散空间存储(链表)

4. 什么是复杂度分析

  • 复杂度分析体现算法运行所需的时间(空间)资源与输入数据大小之间的关系。它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势
    • “时间和空间资源” 分别对应时间复杂度和空间复杂度
    • “随着输入数据大小的增加” 意味着复杂度反映了算法运行效率与输入数据体量之间的关系
    • “时间和空间的增长趋势” 表示复杂度分析关注的不是运行时间或占用空间的值,而是时间或空间增长的 “快慢”

5. 迭代和递归对比

  • 迭代:“自下而上” 地解决问题
    • 从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成
    • 从 1 遍历到 n,每轮执行求和操作,即可求得 f(n)
  • 递归:“自上而下” 地解决问题
    • 将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)
    • 将问题分解为子问题 f(n) = n + f(n-1) ,不断(递归地)分解下去,直至基本情况 f(1) = 1 时终止

6. 时间复杂度

  • 时间复杂度分析统计的不是算法运行时间,而是算法运行时间随数据量变大时的增长趋势
  • 常见类型
    • 常数阶 O ( 1 ) O(1) O(1)
      • 常数阶的操作数量与输入数据大小 n 无关,不随 n 的变化而变化
    • 线性阶 O ( n ) O(n) O(n)
      • 线性阶的操作数量相对于输入数据大小 n 以线性级别增长,通常出现在单层循环
      • 遍历数组和遍历链表等操作的时间复杂度均为 O ( n ) O(n) O(n),其中 n 为数组或链表的长度
    • 平方阶 O ( n 2 ) O(n^2) O(n2)
      • 平方阶的操作数量相对于输入数据大小 n 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 O ( n ) O(n) O(n),因此总体为 O ( n 2 ) O(n^2) O(n2)
    • 指数阶 O ( 2 n ) O(2^n) O(2n)
      • 生物学的 “细胞分裂” 是指数阶增长的典型例子:初始状态为 1 个细胞,分裂一轮后变为 2 个,分裂两轮后变为 4 个,以此类推,分裂 n 轮后有 2 n 2^n 2n 个细胞
      • 在实际算法中,指数阶常出现于递归函数中,指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心等算法来解决
    • 对数阶 O ( l o g n ) O(log n) O(logn)
      • 与指数阶相反,对数阶反映了 “每轮缩减到一半” 的情况。设输入数据大小为 n,由于每轮缩减到一半,因此循环次数是 l o g 2 n log_2 n log2n ,即 2 n 2^n 2n 的反函数
      • 与指数阶类似,对数阶也常出现于递归函数中,对数阶常出现于基于分治策略的算法中,体现了 “一分为多” 和 “化繁为简” 的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度
    • 线性对数阶 O ( n l o g n ) O(n log n) O(nlogn)
      • 线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 O ( l o g n ) O(log n) O(logn) O ( n ) O(n) O(n)
      • 主流排序算法的时间复杂度通常为 O ( n l o g n ) O(n log n) O(nlogn),例如快速排序、归并排序、堆排序

7. 空间复杂度

  • 空间复杂度用于衡量算法占用内存空间随数据量变大时的增长趋势
  • 常见类型
    • 常数阶 O ( 1 ) O(1) O(1)
      • 常数阶常见于数量与输入数据大小 n 无关的常量、变量、对象
      • 在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,因此不会累积占用空间,空间复杂度仍为 O(1)
    • 线性阶 O ( n ) O(n) O(n)
      • 线性阶常见于元素数量与 n 成正比的数组、链表、栈、队列等
    • 平方阶 O ( n 2 ) O(n^2) O(n2)
      • 平方阶常见于矩阵和图,元素数量与 n 成平方关系
    • 指数阶 O ( 2 n ) O(2^n) O(2n)
      • 指数阶常见于二叉树。高度为 n 的 “满二叉树” 的节点数量为 2 n − 1 2^n - 1 2n1,占用 O ( 2 n ) O(2^n) O(2n) 空间
    • 对数阶 O ( l o g n ) O(log n) O(logn)
      • 对数阶常见于分治算法:例如归并排序,输入长度为 n 的数组,每轮递归将数组从中点划分为两半,形成高度为 l o g n log n logn 的递归树,使用 O ( l o g n ) O(log n) O(logn) 栈帧空间

  • 如何权衡时间与空间?
    • 将牺牲内存空间来提升算法运行速度的思路称为 “以空间换时间”
    • 在大多数情况下,时间比空间更宝贵,因此 “以空间换时间” 通常是更常用的策略

8. 数组

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

  • 数组优缺点

    • 优点
      • 空间效率高: 数组为数据分配了连续的内存块,无须额外的结构开销
      • 支持随机访问: 数组允许在 O(1) 时间内访问任何元素
      • 缓存局部性: 当访问数组元素时,计算机不仅会加载它,还会缓存其周围的其他数据,从而借助高速缓存来提升后续操作的执行速度
    • 缺点
      • 插入与删除效率低:当数组中元素较多时,插入与删除操作需要移动大量的元素。
      • 长度不可变: 数组在初始化后长度就固定了,扩容数组需要将所有数据复制到新数组,开销很大
      • 空间浪费: 如果数组分配的大小超过了实际所需,那么多余的空间就被浪费了
  • 数组常用操作

    /* 1、初始化数组 */
    // 存储在栈上
    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 };
    
    /* 2、在数组的索引 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;
    }
    
    /* 3、删除索引 index 处元素 */
    void remove(int *nums, int size, int index) {
        // 把索引 index 之后的所有元素向前移动一位
        for (int i = index; i < size - 1; i++) {
            nums[i] = nums[i + 1];
        }
    }
    
    /* 4、在数组中查找指定元素 */
    int find(int *nums, int size, int target) {
        for (int i = 0; i < size; i++) {
            if (nums[i] == target)
                return i;
        }
        return -1;
    }
    

9. 链表

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

  • 链表(linked list)是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过指针相连接,指针记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点
    • 链表的设计使得各个节点可以被分散存储在内存各处,它们的内存地址是无须连续的
  • 链表的组成单位是节点(node)对象。每个节点都包含两项数据:节点的 “值” 和指向下一节点的指针
    • 链表的首个节点被称为 “头节点”,最后一个节点被称为 “尾节点”
    • 尾节点指向的是 “空” nullptr
  • 链表节点 ListNode 除了包含值,还需额外保存一个指针。因此在相同数据量下,链表比数组占用更多的内存空间
    /* 链表节点结构体 */
    struct ListNode {
        int val;         // 节点值
        ListNode *next;  // 指向下一节点的指针
        ListNode(int x) : val(x), next(nullptr) {}  // 构造函数
    };
    

  • 常见链表类型
    • 单向链表:即上述介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据
      • 将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空
      • 通常用于实现栈、队列、哈希表和图等数据结构
    • 环形链表:如果令单向链表的尾节点指向头节点(即首尾相接),则得到一个环形链表
      • 在环形链表中,任意节点都可以视作头节点
      • 通常用于需要快速查找前一个和下一个元素的场景
    • 双向链表:与单向链表相比,双向链表记录了两个方向的指针
      • 双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的指针
      • 相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间
      • 通常用于需要周期性操作的场景,比如操作系统的资源调度

  • 链表常用操作
    /* 1、初始化链表 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;
    
    /* 2、在链表的节点 n0 之后插入节点 P */
    void insert(ListNode *n0, ListNode *P) {
        ListNode *n1 = n0->next;
        P->next = n1;
        n0->next = P;
    }
    
    /* 3、删除链表的节点 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;
    }
    
    /* 4、在链表中查找值为 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;
    }
    

10. 数组与链表对比

11. 列表 vector

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

  • 为解决此问题,出现了一种被称为动态数组的数据结构,即长度可变的数组,也常被称为列表 vector (C++)
  • 列表基于数组实现,继承了数组的优点,并可在程序运行过程中动态扩容,可以在列表中自由地添加元素
  • 数组的插入与删除操作有以下缺点,建议使用 vector
    • 时间复杂度高:数组的插入和删除的平均时间复杂度均为 O(n),其中 n 为数组长度
    • 丢失元素:由于数组的长度不可变,因此在插入元素后,超出数组长度范围的元素会丢失
    • 内存浪费:可以初始化一个比较长的数组,只用前面一部分,这样在插入数据时,丢失的末尾元素都是 “无意义” 的,但这样做也会造成部分内存空间的浪费
  • 列表常用操作
    /* 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());  // 排序后,列表元素从小到大排列
    

12. 栈

  • 栈(stack)是一种遵循先入后出的逻辑的线性数据结构

    • 可以将栈类比为桌面上的一摞盘子,如果需要拿出底部的盘子,则需要先将上面的盘子依次取出
  • 如下图所示,把堆叠元素的顶部称为 “栈顶”,底部称为 “栈底”

    • 将把元素添加到栈顶的操作叫做 “入栈”
    • 删除栈顶元素的操作叫做 “出栈”

  • 栈遵循先入后出的原则,因此只能在栈顶添加或删除元素。然而,数组和链表都可以在任意位置添加和删除元素,因此栈可以被视为一种受限制的数组或链表
  • 栈常用操作
    /* 初始化栈 */
    stack<int> stack;
    
    /* 元素入栈 */
    stack.push(1);
    stack.push(3);
    stack.push(2);
    stack.push(5);
    stack.push(4);
    
    /* 访问栈顶元素 */
    int top = stack.top();
    
    /* 元素出栈 */
    stack.pop(); // 无返回值
    
    /* 获取栈的长度 */
    int size = stack.size();
    
    /* 判断是否为空 */
    bool empty = stack.empty();
    
  • 栈典型应用
    • 浏览器中的后退与前进、软件中的撤销与反撤销
      • 每当打开新的网页,浏览器就会将上一个网页执行入栈,这样就可以通过后退操作回到上一页面。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现
    • 程序内存管理
      • 每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会执行出栈操作

13. 队列

  • 队列(queue)是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列的尾部,而位于队列头部的人逐个离开

  • 如下图所示,将队列的头部称为 “队首”,尾部称为 “队尾”,将把元素加入队尾的操作称为 “入队”,删除队首元素的操作称为 “出队”

  • 队列常用操作

    /* 初始化队列 */
    queue<int> queue;
    
    /* 元素入队 */
    queue.push(1);
    queue.push(3);
    queue.push(2);
    queue.push(5);
    queue.push(4);
    
    /* 访问队首元素 */
    int front = queue.front();
    
    /* 元素出队 */
    queue.pop();
    
    /* 获取队列的长度 */
    int size = queue.size();
    
    /* 判断队列是否为空 */
    bool empty = queue.empty();
    
  • 队列典型应用

    • 淘宝订单
      • 购物者下单后,订单将加入队列中,系统随后会根据顺序依次处理队列中的订单。在双十一期间,短时间内会产生海量订单,高并发成为工程师们需要重点攻克的问题
    • 各类待办事项
      • 任何需要实现 “先来后到” 功能的场景,例如打印机的任务队列、餐厅的出餐队列等,队列在这些场景中可以有效地维护处理顺序

14. 双向队列

  • 在队列中,仅能在头部删除或在尾部添加元素,如下图所示,双向队列允许在头部和尾部执行元素的添加或删除操作

15. 哈希表

  • 哈希表(hash table),又称散列表,其通过建立键 key 与值 value 之间的映射,实现高效的元素查询

    • 具体而言,向哈希表输入一个键 key ,则可以在 O(1) 时间内获取对应的值 value
  • 如下图所示,给定 n 个学生,每个学生都有 “姓名” 和 “学号” 两项数据。假如希望实现 “输入一个学号,返回对应的姓名” 的查询功能,则可以采用下图所示的哈希表来实现

  • 除哈希表外,数组和链表也可以实现查询功能,它们的效率对比如下图所示

    • 在哈希表中进行增删查改的时间复杂度都是 O(1),非常高效
  • 哈希表常用操作

    /* 1、初始化哈希表 */
    unordered_map<int, string> map;
    
    /* 2、添加操作 */
    // 在哈希表中添加键值对 (key, value)
    map[12836] = "小哈";
    map[15937] = "小啰";
    map[16750] = "小算";
    map[13276] = "小法";
    map[10583] = "小鸭";
    
    /* 3、查询操作 */
    // 向哈希表输入键 key ,得到值 value
    string name = map[15937];
    
    /* 4、删除操作 */
    // 在哈希表中删除键值对 (key, value)
    map.erase(10583);
    
    /* 5、遍历哈希表 */
    // 遍历键值对 key->value
    for (auto kv: map) {
        cout << kv.first << " -> " << kv.second << endl;
    }
    // 单独遍历键 key
    for (auto kv: map) {
        cout << kv.first << endl;
    }
    // 单独遍历值 value
    for (auto kv: map) {
        cout << kv.second << endl;
    }
    

16. 哈希函数工作原理

  • 在哈希表中,将数组中的每个空位称为桶(bucket),每个桶可存储一个键值对。因此,查询操作就是找到 key 对应的桶,并在桶中获取 value。如何基于 key 来定位对应的桶呢?通过哈希函数(hash function)实现
    • 哈希函数的作用:将一个较大的输入空间映射到一个较小的输出空间
    • 在哈希表中,输入空间是所有 key,输出空间是所有桶(数组索引)。换句话说,输入一个 key,可以通过哈希函数得到该 key 对应的键值对在数组中的存储位置
  • 输入一个 key,哈希函数的计算过程分为以下两步
    • 通过某种哈希算法 hash() 计算得到哈希值
    • 将哈希值对桶数量(数组长度,capacity)取模,从而获取该 key 对应的数组索引 index
    index = hash(key) % capacity
    
  • 随后,就可以利用 index 在哈希表中访问对应的桶,从而获取 value。设数组长度 capacity = 100、哈希算法 hash(key) = key,易得哈希函数为 key % 100。下图以 key 学号和 value 姓名为例,展示了哈希函数的工作原理

17. 哈希冲突与扩容

  • 本质上看,哈希函数的作用是将所有 key 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,理论上一定存在 “多个输入对应相同输出” 的情况

  • 对于上述示例中的哈希函数,当输入的 key 后两位相同时,哈希函数的输出结果也相同。例如,查询学号为 12836 和 20336 的两个学生时,得到

    12836 % 100 = 36
    20336 % 100 = 36
    
  • 如下图所示,两个学号指向了同一个姓名,这显然是不对的。将这种多个输入对应同一输出的情况称为哈希冲突(hash collision)

  • 容易想到,哈希表容量越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此,可以通过扩容哈希表来减少哈希冲突。如下图所示,扩容前键值对 (136, A) 和 (236, D) 发生冲突,扩容后冲突消失

类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 capacity 改变,需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,通常会预留足够大的哈希表容量,防止频繁扩容

  • 负载因子 = 哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,也常被作为哈希表扩容的触发条件

18. 如何解决哈希冲突

  • 通常情况下哈希函数的输入空间远大于输出空间,因此理论上哈希冲突是不可避免的
  • 哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,可以每当遇到哈希冲突时就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,可以采用以下策略
    • 改良哈希表数据结构,使得哈希表可以在存在哈希冲突时正常工作
    • 仅在哈希冲突比较严重时,才执行扩容操作
  • 在原始哈希表中,每个桶仅能存储一个键值对。链式地址(separate chaining)将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中
  • 开放寻址(open addressing)不引入额外的数据结构,而是通过 “多次探测” 来处理哈希冲突,探测方式主要包括线性探测、平方探测、多次哈希等

19. 哈希算法

  • 如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如下图所示,对于链地址哈希表,理想情况下键值对平均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都被存储到同一个桶中,时间复杂度退化至 O(n)
  • 键值对的分布情况由哈希函数决定
    index = hash(key) % capacity // 哈希函数
    
    • 当哈希表容量 capacity 固定时,哈希算法 hash() 决定了输出值,进而决定了键值对在哈希表中的分布情况。这意味着,为了减小哈希冲突的发生概率,应当将注意力集中在哈希算法 hash() 的设计上
  • 常见哈希函数
    • 在实际中,通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2、SHA3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值
      • MD5 和 SHA-1 已多次被成功攻击,因此它们被各类安全应用弃用
      • SHA-2 系列中的 SHA-256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常被用在各类安全应用与协议中
      • SHA-3 相较 SHA-2 的实现开销更低、计算效率更高,但目前使用覆盖度不如 SHA-2 系列

因篇幅问题不能全部显示,请点此查看更多更全内容

Top