Skip to content

栈、队列和递归

  • 栈是一种操作受限的线性表,只允许在一端插入和删除数据,特征是后进先出
  • 栈可以用数组或链表实现,可支持动态扩容,入栈push(),出栈pop()时间复杂度都是O(1),空间复杂度也是O(1)(因为只涉及到单个数据的操作,空间复杂度是指除了原本的数据存储空间,算法运行还需要额外的存储空间)
  • 栈的应用
    • 函数调用:操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈
    • 表达式求值 1744803694473
    • 括号匹配:栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
    • 实现浏览器前进/后退:使用两个栈,X 和 Y,我们把首次浏览的页面依次压入栈 X,当点击后退按钮时,再依次从栈 X 中出栈,并将出栈的数据依次放入栈 Y。当我们点击前进按钮时,我们依次从栈 Y 中取出数据,放入栈 X 中。当栈 X 中没有数据时,那就说明没有页面可以继续后退浏览了。当栈 Y 中没有数据,那就说明没有页面可以点击前进按钮浏览了。

队列

  • 队列也是一种操作受限的线性表数据结构, 先进先出, 基本操作入队enqueue(), 出队dequeue()
  • 用数组实现的队列叫顺序队列, 用链表实现的队列叫链式队列, 与栈同理
  • 1744803769556

用数组实现的队列

Java
publicclassArrayQueue {
    privateString[] items;
    privateintn = 0;
    privateinthead = 0;
    privateinttail = 0;

    publicArrayQueue(intcapacity) {
        items = newString[capacity];
        this.n = capacity;
    }

    publicbooleanenqueue(String item) {
        if(tail == n) {
            // 队列已满
if(head == 0) {
                returnfalse;
            }

            // 数据搬移
for(inti = head; i < tail; i++) {
                items[i - head] = items[i];
            }
            tail -= head;
            head = 0;
        }
    
        items[tail] = item;
        tail++;
        returntrue;
    }

    publicString dequeue() {
        // 队列已空
if(head == tail) {
            returnnull;
        }
        String ret = items[head];
        head++;
        returnret;
    }
}

循环队列(实现一)

  • tail位置不存储数据,队空的条件是tail==head, 队满的条件是(tail+1)%n=head
  • 1744803800972
Java
publicclassCircularQueue {
    privateString[] items;
    privateintn = 0;
    privateinthead = 0;
    privateinttail = 0;

    publicCircularQueue(intcapacity) {
        items = newString[capacity];
        this.n = capacity;
    }

    publicbooleanenqueue(String item) {
        // 注意此种循环队列,tail位置不放元素,当tail的下一位是head的时候, 刚好就是队列满了
if((tail + 1) % n == head) {
            returnfalse;
        }

        items[tail] = item;
        // 注意循环队列,拿的是取模的下标, 而不是tail++
tail = (tail + 1) % n;
        returntrue;
    }

    publicString dequeue() {
        // 队列已空
if(head == tail) {
            returnnull;
        }
        String ret = items[head];
        // 注意循环队列,拿的是取模的下标, 而不是head++
head = (head + 1) % n;
        returnret;
    }
}

递归

  • 递归需要满足三个条件
    • 一个问题的解可以分解为几个子问题的解
    • 这个问题和分解之后的子问题,除了数据规模不同,求解思路完全一样
    • 存在递归终止条件
  • 如何编写递归代码,写出递归公式,找到终止条件
    • 编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。
Java
假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?

把所有走法分为两类,第一类是第一步走了 1 个台阶,另一类是第一步走了 2 个台阶。所以 n 个台阶的走法就等于先走 1 阶后,n-1 个台阶的走法 加上先走 2 阶后,n-2 个台阶的走法
所以递归公式:f(n) = f(n-1)+f(n-2)

终止条件,当只有一个台阶,f(1)=1;当n=2 时,f(2)=f(1)+f(0)。如果递归终止条件只有一个 f(1)=1,那 f(2) 就无法求解了
因此增加终止条件f(0)=1,优化下,增加条件f(2)=2

最终代码:
int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}
  • 递归的问题

    • 警惕堆栈溢出,可以通过在代码中限制递归调用的最大深度
    Java
    // 全局变量,表示递归的深度。
    int depth = 0;
    
    int f(int n) {
      ++depth;
      if (depth > 1000) throw exception;
    
      if (n == 1) return 1;
      return f(n-1) + 1;
    }
    • 警惕重复计算
      • 想要计算 f(5),需要先计算 f(4) 和 f(3),而计算 f(4) 还需要计算 f(3),因此,f(3) 就被计算了很多次

      • 可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)

        1744803854508

Java
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;

// hasSolvedList可以理解成一个Map,key是n,value是f(n)
if (hasSolvedList.containsKey(n)) {
    return hasSolvedList.get(n);
}

int ret = f(n-1) + f(n-2);
hasSolvedList.put(n, ret);
return ret;
}
- 在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销,比如我们前面讲到的电影院递归代码,空间复杂度并不是 O(1),而是 O(n)