zoukankan      html  css  js  c++  java
  • 数据结构与算法之美-3 栈队列递归 [MD]

    博文地址

    我的GitHub 我的博客 我的微信 我的邮箱
    baiqiantao baiqiantao bqt20094 baiqiantao@sina.com

    目录

    08 | 栈:如何实现浏览器的前进和后退功能?

    栈是一种操作受限线性表,只允许在一端插入和删除数据。

    从功能上来说,数组或链表也可以替代栈,但特定的数据结构是对特定场景的抽象,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错。

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

    如何实现一个“栈”?

    栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈

    基于数组实现的顺序栈

    public class ArrayStack {
      private String[] items;  // 数组
      private int count;       // 栈中元素个数
      private int n;           //栈的大小
    
      // 初始化数组,申请一个大小为n的数组空间
      public ArrayStack(int n) {
        this.items = new String[n];
        this.n = n;
        this.count = 0;
      }
    
      // 入栈操作
      public boolean push(String item) {
        // 数组空间不够了,直接返回false,入栈失败。
        if (count == n) return false;
        // 将item放到下标为count的位置,并且count加一
        items[count] = item;
        ++count;
        return true;
      }
    
      // 出栈操作
      public String pop() {
        // 栈为空,则直接返回null
        if (count == 0) return null;
        // 返回下标为 count-1 的数组元素,并且栈中元素个数count减一
        String tmp = items[count-1];
        --count;
        return tmp;
      }
    }
    

    在入栈和出栈过程中,只需要一两个临时变量存储空间,所以空间复杂度是 O(1)
    入栈、出栈只涉及栈顶个别数据的操作,所以时间复杂度是 O(1)

    支持动态扩容的顺序栈

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

    均摊时间复杂度为 O(1)

    栈的应用场景

    栈在函数调用中的应用

    操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成栈这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

    为什么函数调用要用栈来保存临时变量呢?用其他数据结构不行吗?

    其实,我们不一定非要用栈来保存临时变量,只不过如果这个函数调用符合后进先出的特性,用栈这种数据结构来实现,是最顺理成章的选择。

    从调用函数进入被调用函数,对于数据来说,变化的是什么呢?是作用域。所以根本上,只要能保证每进入一个新的函数,都是一个新的作用域就可以。而要实现这个,用栈就非常方便。在进入被调用函数的时候,分配一段栈空间给这个函数的变量,在函数结束的时候,将栈顶复位,正好回到调用函数的作用域内。

    栈在表达式求值中的应用

    编译器是如何利用栈来实现表达式求值的?

    比如:3+5*8-6。实际上,编译器就是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。

    我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较:
    - 如果比运算符栈顶元素的优先级,就将当前运算符压入栈
    - 如果比运算符栈顶元素的优先级或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较

    栈在括号匹配中的应用

    可以借助栈来检查表达式中的括号是否匹配。

    解答开篇

    用两个栈就可以非常完美的实现浏览器的前进、后退功能

    • 我们使用两个栈,X 和 Y,我们把首次浏览的页面依次压入栈 X
      • 当点击后退按钮时,再依次从栈 X 中出栈,并将出栈的数据依次放入栈 Y
      • 当我们点击前进按钮时,我们依次从栈 Y 中取出数据,放入栈 X 中
    • 当栈 X 中没有数据时,那就说明没有页面可以继续后退浏览了
    • 当栈 Y 中没有数据,那就说明没有页面可以点击前进按钮浏览了

    内容小结

    栈是一种操作受限的数据结构,只支持入栈和出栈操作。后进先出是它最大的特点。栈既可以通过数组实现,也可以通过链表来实现。不管基于数组还是链表,入栈、出栈的时间复杂度都为 O(1)。除此之外,我们还讲了一种支持动态扩容的顺序栈,你需要重点掌握它的均摊时间复杂度分析方法。

    09 | 队列:队列在线程池等有限资源池中的应用

    CPU 资源是有限的,任务的处理速度与线程个数并不是线性正相关。相反,过多的线程反而会导致 CPU 频繁切换,处理性能下降。所以,线程池的大小一般都是综合考虑要处理任务的特点和硬件环境来设置的。

    对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过队列这种数据结构来实现请求排队。

    如何理解“队列”?

    队列跟栈一样,也是一种操作受限线性表数据结构。

    栈只支持两个基本操作:入栈 push() 和出栈 pop()

    队列跟栈非常相似,支持的操作也很有限,最基本的操作也是两个:入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素。

    作为一种非常基础的数据结构,队列的应用也非常广泛,特别是一些具有某些额外特性的队列,比如循环队列阻塞队列并发队列。它们在很多偏底层系统、框架、中间件的开发中,起着关键性的作用。比如高性能队列 Disruptor、Linux 环形缓存,都用到了循环并发队列;Java concurrent 并发包利用 ArrayBlockingQueue 来实现公平锁等。

    顺序队列和链式队列

    跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。同样,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列

    用数组实现的队列

    public class ArrayQueue {
      // 数组:items,数组大小:n
      private String[] items;
      private int n = 0;
      // head表示队头下标,tail表示队尾下标
      private int head = 0;
      private int tail = 0;
    
      // 申请一个大小为capacity的数组
      public ArrayQueue(int capacity) {
        items = new String[capacity];
        n = capacity;
      }
    
       // 入队操作,将item放入队尾
      public boolean enqueue(String item) {
        // tail == n表示队列末尾没有空间了
        if (tail == n) {
          // tail ==n && head==0,表示整个队列都占满了
          if (head == 0) return false;
          // 数据搬移
          for (int i = head; i < tail; ++i) {
            items[i-head] = items[i];
          }
          // 搬移完之后重新更新head和tail
          tail -= head;
          head = 0;
        }
    
        items[tail] = item;
        ++tail;
        return true;
      }
    
      // 出队
      public String dequeue() {
        // 如果head == tail 表示队列为空
        if (head == tail) return null;
        // 为了让其他语言的同学看的更加明确,把--操作放到单独一行来写了
        String ret = items[head];
        ++head;
        return ret;
      }
    }
    

    均摊复杂度的使用的前提条件是,最好时间复杂的和最坏时间复杂度出现的频率很有规律,且有一定的前后时序关系。但这里,他的出现频率完全取决于调用者如何去在代码中进行出栈入栈操作,因此无法对均摊和平均进行复杂度分析。

    用链表实现的队列

    基于链表的实现,我们同样需要两个指针:head 指针和 tail 指针。它们分别指向链表的第一个结点和最后一个结点。

    如图所示,入队时,tail.next= new_node, tail = tail.next;出队时,head = head.next

    循环队列

    我们刚才用数组来实现队列的时候,在 tail==n 时,会有数据搬移操作,这样入队操作性能就会受到影响。使用循环队列就能够避免数据搬移。

    当队列满时,图中的 tail 指向的位置实际上是没有存储数据的。所以,循环队列会浪费数组的一个存储空间。

    最关键的是,确定好队空和队满的判定条件

    • 队列为空的判断条件是 head == tail
    • 队列满的判断条件是 (tail+1)%n=head
    public class CircularQueue {
      // 数组:items,数组大小:n
      private String[] items;
      private int n = 0;
      // head表示队头下标,tail表示队尾下标
      private int head = 0;
      private int tail = 0;
    
      // 申请一个大小为capacity的数组
      public CircularQueue(int capacity) {
        items = new String[capacity];
        n = capacity;
      }
    
      // 入队
      public boolean enqueue(String item) {
        // 队列满了
        if ((tail + 1) % n == head) return false;
        items[tail] = item;
        tail = (tail + 1) % n;
        return true;
      }
    
      // 出队
      public String dequeue() {
        // 如果head == tail 表示队列为空
        if (head == tail) return null;
        String ret = items[head];
        head = (head + 1) % n;
        return ret;
      }
    }
    

    阻塞队列和并发队列

    队列这种数据结构很基础,平时的业务开发不大可能从零实现一个队列,甚至都不会直接用到。而一些具有特殊特性的队列应用却比较广泛,比如阻塞队列和并发队列。

    阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。

    你应该已经发现了,上述的定义就是一个生产者 - 消费者模型!是的!

    线程安全的队列我们叫作并发队列。最简单直接的实现方式是直接在 enqueue()dequeue() 方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。实际上,基于数组的循环队列,利用 CAS 原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因。

    内容小结

    队列最大的特点就是先进先出,主要的两个操作是入队和出队。跟栈一样,它既可以用数组来实现,也可以用链表来实现。用数组实现的叫顺序队列,用链表实现的叫链式队列。在数组实现队列的时候,会有数据搬移操作,要想解决数据搬移的问题,我们就需要像环一样的循环队列

    阻塞队列就是入队、出队操作可以阻塞,并发队列就是队列的操作多线程安全。

    如何实现无锁并发队列?

    考虑使用CAS实现无锁队列
    - 在入队前,获取 tail 位置,入队时比较 tail 是否发生变化,如果否,则允许入队,反之,本次入队失败
    - 出队则是获取 head 位置,进行 cas

    10 | 递归:如何用三行代码找到最终推荐人?

    如何理解递归?

    递归是一种应用非常广泛的算法(或者编程技巧)。之后我们要讲的很多数据结构和算法的编码实现都要用到递归,比如 DFS 深度优先搜索、前中后序二叉树遍历等等。

    递归需要满足的三个条件

    只要同时满足以下三个条件,就可以用递归来解决

    • 一个问题的解可以分解为几个子问题的解
      • 子问题就是数据规模更小的问题
    • 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
    • 存在递归终止条件
      • 不能存在无限循环

    如何编写递归代码?

    写递归代码最关键的是写出递推公式,找到终止条件,剩下将递推公式转化为代码就很简单了

    案例:假如有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?

    f(1) = 1;
    f(2) = 2;
    f(n) = f(n-1) + f(n-2)
    

    写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。

    计算机擅长做重复的事情,所以递归正合它的胃口。而我们人脑更喜欢平铺直叙的思维方式。

    如果一个问题 A 可以分解为若干子问题 B、C、D,你可以假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A。而且,你只需要思考问题 A 与子问题 B、C、D 两层之间的关系即可,不需要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节后,理解起来就简单多了。

    编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。

    递归代码常见的问题

    递归代码要警惕堆栈溢出

    函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。

    最大允许的递归深度当前线程剩余的栈空间大小有关,事先无法计算。

    递归代码要警惕重复计算

    为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。

    其他问题

    • 在时间效率上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本。
    • 在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销,比如我们前面讲到的电影院递归代码 f(x) = f(x-1) + 1,空间复杂度并不是 O(1),而是 O(n)

    怎么将递归代码改写为非递归代码?

    递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以,在开发过程中,我们要根据实际情况来选择是否需要用递归的方式来实现。

    是不是 所有递归代码都可以改为这种迭代循环的非递归写法呢?

    笼统地讲,是的。因为递归本身就是借助来实现的,只不过我们使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。

    但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决前面讲到的某些问题,徒增了实现的复杂度。

    递归代码怎么调试

    • 打印日志发现,递归值
    • 结合条件断点进行调试

    2021-8-2

  • 相关阅读:
    Sage CRM升级后的问题跟进
    js 控制滚动条位置
    初学Jquery插件制作:在SageCRM的查询屏幕隐藏部分行的功能
    [转摘] Reporting Service 200
    js数组去重复项
    JavaScript tips 1024 * 768
    javascript 的 replace 函数
    the secret of sagecrm urls
    Sage CRM 自增ID的方案和遇到的问题
    EXTJS 可编辑列表的时间编辑问题
  • 原文地址:https://www.cnblogs.com/baiqiantao/p/15089020.html
Copyright © 2011-2022 走看看