「笔记」数据结构与算法之美 - 基础篇(一)

数组

  • 数组(Array)是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据
  • 线性表
    • 线性表就是数据排成像一条线一样的结构,每个线性表上的数据最多只有前和后两个方向

      img

  • 非线性表
    • 在非线性表中,数据之间并不是简单的前后关系(比如二叉树、堆、图等

      img

  • 连续的内存空间和相同类型的数据带来的一个堪称“杀手锏”的特性:“随机访问”
    • 数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)(数组适合查找,查找时间复杂度为 O(1)
    • 低效的“插入”和“删除”
      • 要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作
      • 删除时可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除
        • 关联类似思想知识点:JVM 标记清除垃圾回收算法
    • 警惕数组的访问越界问题(Java --> java.lang.ArrayIndexOutOfBoundsException
  • 容器能否完全替代数组
    • ArrayList 最大的优势就是可以将很多数组操作的细节封装起来,另一个优势就是支持动态扩容
      • 因为扩容操作涉及内存申请和数据搬移,是比较耗时的,最好在创建 ArrayList 的时候事先指定数据大小
    • 用数组会更合适的场景
      • Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类(拆装箱性能损耗
      • 如果数据大小事先已知,并且对数据的操作非常简单,用不到容器所带来的优势时
      • 当要表示多维数组时,用数组往往会更加直观
  • 扩展:为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始呢
    • 从效率的角度上上,能够减少一次减法操作
      • 若从 0 开始,计算 a[k] 的内存地址的公式:a[k]_address = base_address + k * type_size
      • 若从 1 开始,计算 a[k] 的内存地址的公式:a[k]_address = base_address + (k-1)*type_size
    • 最主要的原因可能是历史原因
      • C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言
      • 为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯

链表

  • 经典的链表应用场景,那就是 LRU 缓存淘汰算法(缓存淘汰策略

    • 常见的策略有三种:先进先出策略 FIFO、最少使用策略 LFU、最近最少使用策略 LRU
  • 从底层的存储结构上来看,链表与数组的区别

    img

    • 数组需要一块连续的内存空间来存储,对内存的要求比较高
    • 链表不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用
  • 单链表

    img

    • 我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点
      • 头结点用来记录链表的基地址
      • 尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL
  • 循环链表

    img

    • 它跟单链表唯一的区别就在尾结点(尾结点指针是指向链表的头结点
    • 循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表
  • 双向链表

    img

    • 支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点
    • 可以支持双向遍历,这样也带来了双向链表操作的灵活性,但相对于单链表来说会占用更多的内存空间
    • 扩展:双向循环链表
  • 链表 VS 数组性能大比拼

    • 插入、删除、随机访问操作的时间复杂度对比

      img

    • 访问效率对比

      • 数组可以借助 CPU 的缓存机制,预读数组中的数据,访问效率更高
      • 链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读
    • 扩容难易对比

      • 数组的缺点是大小固定,一经声明就要占用整块连续内存空间,扩容时需要全量拷贝,非常耗时
      • 链表本身没有大小的限制,天然地支持动态扩容
    • 如果你的代码对内存的使用非常苛刻,那数组就更适合你

      • 链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针
      • 对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片

链表编码技巧

  • 技巧一:理解指针或引用的含义

    • 将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针
    • 指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量
    • 例子
      • p->next=q (p 结点中的 next 指针存储了 q 结点的内存地址
      • p->next=p->next->next(p 结点的 next 指针存储了 p 结点的下下一个结点的内存地址
  • 技巧二:警惕指针丢失和内存泄漏

    • BadCase

      img

        p->next = x; // 将p的next指针指向x结点;
        x->next = p->next; // 将x的结点的next指针指向b结点;
      
    • 第一步执行完后 p->next 将不会再指向 b ,导致第二步操作实际上是在自己指向自己(整个链表被断开

    • 插入结点时,一定要注意操作的顺序

    • 删除链表结点时,也一定要记得手动释放内存空间(如果是Java语言,注意设置为NULL,避免内存泄漏

  • 技巧三:利用哨兵简化实现难度

    • 针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理

    • 我们也把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表

    • 例子

        // 原逻辑
          int i = 0;
          // 这里有两个比较操作:i<n和a[i]==key.
          while (i < n) {
            if (a[i] == key) {
              return i;
            }
            ++i;
          }
        
        // 增加哨兵
          // 这里因为要将a[n-1]的值替换成key,所以要特殊处理这个值
          if (a[n-1] == key) {
            return n-1;
          }
          
          // 把a[n-1]的值临时保存在变量tmp中,以便之后恢复。tmp=6。
          // 之所以这样做的目的是:希望find()代码不要改变a数组中的内容
          char tmp = a[n-1];
          // 把key的值放到a[n-1]中,此时a = {4, 2, 3, 5, 9, 7}
          a[n-1] = key;
          
          int i = 0;
          // while 循环比起代码一,少了i<n这个比较操作
          while (a[i] != key) {
            ++i;
          }
        
          // 恢复a[n-1]原来的值,此时a= {4, 2, 3, 5, 9, 6}
          a[n-1] = tmp;
          
          if (i == n-1) {
            // 如果i == n-1说明,在0...n-2之间都没有key,所以返回-1
            return -1;
          } else {
            // 否则,返回i,就是等于key值的元素的下标
            return i;
          }
        }
      
  • 技巧四:重点留意边界条件处理

    • 常用来检查链表代码是否正确的边界条件有这样几个
      • 如果链表为空时,代码是否能正常工作?
      • 如果链表只包含一个结点时,代码是否能正常工作?
      • 如果链表只包含两个结点时,代码是否能正常工作?
      • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
    • 针对不同的场景,可能还有特定的边界条件,这个需要自己去思考(写任何代码时也要去考虑,提高代码健壮性
  • 技巧五:举例画图,辅助思考

    • 对于稍微复杂的链表操作,可以通过举例法和画图法来帮助自己理清思路

      img

  • 技巧六:多写多练,没有捷径

    • 6 个常见的链表操作
      • 单链表反转
      • 链表中环的检测
      • 两个有序的链表合并
      • 删除链表倒数第 n 个结点
      • 求链表的中间结点
      • 删除链表中重复的结点(个人推荐)
    • 写链表代码是最考验逻辑思维能力的
      • 链表代码到处都是指针的操作、边界条件的处理,稍有不慎就容易产生 Bug
      • 链表代码写得好坏,可以看出一个人写代码是否够细心,考虑问题是否全面,思维是否缜密

  • 当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,就应该首选“栈”这种数据结构

    img

    • 栈是一种“操作受限”的线性表,只允许在一端插入和删除数据
    • 数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错
  • 栈的实现

    • 用数组实现的栈,我们叫作顺序栈
    • 用链表实现的栈,我们叫作链式栈
  • 支持动态扩容的顺序栈

    • 如果要实现一个支持动态扩容的栈,我们只需要底层依赖一个支持动态扩容的数组就可以了
    • 当栈满了之后,我们就申请一个更大的数组,将原来的数据搬移到新数组中
  • 栈的应用

    • 函数调用栈
      • 操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量
      • 每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈
    • 利用栈来实现表达式求值
      • 例子:34+13*9+44-12/3 (如何实现这一一个表达式求值功能

        img

      • 编译器就是通过两个栈来实现的

        • 如果比运算符栈顶元素的优先级高,就将当前运算符压入栈
        • 如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较
    • 栈在括号匹配中的应用
      • 借助栈来检查表达式中的括号是否匹配
        • 我们用栈来保存未匹配的左括号,从左到右依次扫描字符串
        • 当扫描到左括号时,则将其压入栈中;
        • 当扫描到右括号时,从栈顶取出一个左括号,进行匹配,若不能配对或栈中无数据则认为是非法格式
        • 当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式

    队列

    • 如何理解队列

      img

      • 先进者先出,这就是典型的“队列”,同时栈也是一种操作受限的线性表数据结构
      • 队列的应用也非常广泛,特别是一些具有某些额外特性的队列,比如循环队列、阻塞队列、并发队列
        • 高性能队列 Disruptor、Linux 环形缓存,都用到了循环并发队列
        • Java concurrent 并发包利用 ArrayBlockingQueue 来实现公平锁等。
    • 顺序队列和链式队列

      • 用数组实现的队列叫作顺序队列
        • 随着不停地进行入队、出队操作,head 和 tail 都会持续往后移动,移到最右边时,则需要数据搬移
      • 用链表实现的队列叫作链式队列
        • 链式队列则不需要考虑扩容数据搬移问题

          img

    • 循环队列

      img

      • 原本顺序队列是有头有尾的,是一条直线,现在我们把首尾相连,扳成了一个环,得到循环队列
      • 想写出没有 bug 的循环队列的实现代码,我个人觉得,最关键的是,确定好队空和队满的判定条件
        • 队列为空的判断条件仍然是 head == tail
        • 当队满时,(tail+1)%n=head
    • 阻塞队列和并发队列

      • 阻塞队列其实就是在队列基础上增加了阻塞操作
        • 队列为空的时候,从队头取数据会被阻塞,如果队列已经满了,那么插入数据的操作就会被阻塞
        • 可以使用阻塞队列,轻松实现一个“生产者 - 消费者模型”
      • 线程安全的队列我们叫作并发队列
        • 最简单直接的实现方式是直接在 enqueue()、dequeue() 方法上加锁,但是锁粒度大并发度会比较低
        • 基于数组的循环队列,利用 CAS 原子操作,可以实现非常高效的并发队列
    • 扩展:线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理?处理策略又是如何实现的呢?

      • 一般有两种处理策略
        • 非阻塞的处理方式,直接拒绝任务请求
        • 阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理
      • 如何存储排队的请求
        • 基于链表的实现方式
          • 可以实现一个支持无限排队的无界队列(unbounded queue)
          • 可能会导致过多的请求排队等待,请求处理的响应时间过长
          • 针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的
        • 基于数组的实现方式
          • 可以实现一个有界队列(bounded queue):队列的大小有限
          • 线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝
          • 对响应时间敏感的系统来说,就相对更加合理,但设置一个合理的队列大小,也是非常有讲究的
            • 队列太大导致等待的请求太多
            • 队列太小会导致无法充分利用系统资源、发挥最大性能
      • 对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队

    递归

    • 递归是一种应用非常广泛的算法(或者编程技巧),很多数据结构和算法的编码实现都要用到递归
      • 比如 DFS 深度优先搜索、前中后序二叉树遍历等
    • 递归需要满足的三个条件
      • 一个问题的解可以分解为几个子问题的解
      • 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
      • 存在递归终止条件
    • 如何编写递归代码
      • 写递归代码的关键步骤
        • 找到如何将大问题分解为小问题的规律
        • 基于此写出递推公式
        • 再推敲终止条件
        • 最后将递推公式和终止条件翻译成代码
      • 遇到递归,就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤
  • 递归代码要警惕堆栈溢出

    • 如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险

    • Exception in thread “main” java.lang.StackOverflowError

    • 可以通过在代码中限制递归调用的最大深度的方式来解决这个问题

      • 不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算
    • 递归代码要警惕重复计算

      img

      • 可以直观地看到,想要计算 f(5),需要先计算 f(4) 和 f(3),而计算 f(4) 还需要计算 f(3)
        • f(3) 就被计算了很多次,这就是重复计算问题
      • 为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)
        • 当递归调用到 f(k) 时,先看下是否已经求解过,如果是,则直接从散列表中取值返回
    • 在时间效率上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本

      • 在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据
    • 所有的递归代码都可以改为这种迭代循环的非递归写法

      • 因为递归本身就是借助栈来实现的,只不过我们使用的栈是系统或者虚拟机本身提供的
      • 这种思路实际上是将递归改为了“手动”递归,本质并没有变
        • 也并没有解决前面讲到的某些问题,徒增了实现的复杂度
    • 扩展:对于递归代码,你有什么好的调试方法呢

      • 打印日志发现,递归值
      • 结合条件断点进行调试
发布了99 篇原创文章 · 获赞 199 · 访问量 6万+

猜你喜欢

转载自blog.csdn.net/YangDongChuan1995/article/details/104587918