数据结构灵魂拷问

复杂度分析

什么是大O复杂度表示法?

T(n)表示代码执行的时间;n 表示数据规模的大小;f(n) 表示每行代码执行的次数总和。因为这是一个公式,所以用 f(n) 来表示。公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。

  • 顺序执行:

  • 循环:

  • 双重循环:

  • 循环中对循环次数条件进行乘法计算的:

       i=1;
       while (i <= n)  {
       i = i * 2;
       }
    
  • 双重循环中,子循环对循环次数条件进行乘法计算的:

    归并排序、快速排序

什么是最好、最坏、平均、均摊时间复杂度?

在循环体内有判断条件,某些情况下执行,某些情况下不执行。

最好时间复杂度:执行次数最少的条件下的时间复杂度

最坏时间复杂度:执行次数最多的条件下的时间复杂度。

平均时间复杂度:将执行条件的每一种情况需要遍历的次数乘上这种情况下发生的概率,也叫加权平均时间复杂度。

均摊时间复杂度:使用摊还分析方法,将某一次的执行情况平摊到每一次的执行中,总体下来,得到算法的执行次数。比如某一特定情况下,执行情况是,其他情况都是,那么平摊下来,算法时间复杂度就是

数组

如何优化数组的插入和删除操作?

一般情况下,插入和删除会导致后续节点的移动,导致复杂度为,如何将其优化为

  • 插入操作

    如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。

    如果要将某个数据插入到第 k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。

3f70b4ad9069ec568a2caaddc231b7dc.jpg
  • 删除操作

    数组 a[10]中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。

    为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。

b69b8c5dbf6248649ddab7d3e7cfd7e5.jpg

什么时候使用数组,什么时候使用容器(ArrayList)?

  1. Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
  2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组.
  3. 当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList > array

对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。

为什么大多数编程语言数组从0开始编号,而不是1?

程序访问数组中的元素,使用寻址访问,指针或引用指向的是数组中的首地址,通过计算寻址公式来得到第k个元素的地址。

如果从0编号:

a[k]_address = base_address + k * type_size

如果从1编号:

a[k]_address = base_address + (k-1) * type_size
  1. 从1编号,每次访问需要计算k-1,效率不如从0编号好
  2. C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,习惯如此

链表、队列和栈

如何实现LRU缓存淘汰算法?

缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)

维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。

  1. 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。

  2. 如果此数据没有在缓存链表中,又可以分为两种情况:

    1. 如果此时缓存未满,则将此结点直接插入到链表的头部;
    2. 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。

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

我们使用两个栈,X和Y,我们把首次浏览的页面依次压入栈X,当点击后退时,再依次从栈X中出栈,并将出栈的数据依次放入栈Y中。当我们点击前进按钮时,我们依次从栈Y中取出数据,放入栈X中。如果点击其它页面时,将栈Y清空。

如何实现表达式求值?

比如

使用两个栈来实现,一个放数字,一个放运算符

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

bc77c8d33375750f1700eb7778551600.jpg

如何实现括号匹配?

假设表达式中只包含三种括号,圆括号 ()、方括号[]和花括号{},并且它们可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式。那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢

用栈来保存未匹配的左括号,从左到右依次扫描字符串,如果遇到左括号,入栈,如果遇到有括号,从栈顶取出一个元素进行比较,如果能够匹配,继续扫描,直到结束。最后检查栈是否为空,如果为空,则全部匹配,否则异常。

为什么函数调用要使用“栈”来保存临时变量呢,用其它数据结构不可以吗?

函数调用中,变量的作用域很重要,先声明的作用域更大,后声明的更小,函数的调用结束,伴随着出栈操作,变量的使用就结束了。

函数中,调用函数的关系满足先进后出,后进先出原则,所以使用栈很合适。

循环队列的判断为满的条件是什么?

(tail + 1) % n == head

3d81a44f8c42b3ceee55605f9aeedcec.jpg

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。

递归

使用递归算法的条件是什么?

  1. 一个A问题可以被分解为不确定的子问题B、C、D等
  2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
  3. 存在递归终止条件

使用递归算法可能会产生什么问题?

  • 堆栈溢出

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

  • 重复值计算

    在处理 的递归情况时,会出现以下情况

e7e778994e90265344f6ac9da39e01bf.jpg

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

你可能感兴趣的:(数据结构灵魂拷问)