1.栈基础知识
1.栈支持两种基本操作,出队和入队
- 出队,栈顶指针向下移动一位,在逻辑层面,认为元素已出队
- 入队,把数据添加进来,栈顶指针向上移动一位 2.特点:先入后出(FILO)
2.栈适合解决什么问题
栈可以处理具有完全包含关系的问题
3.经典的栈实现方法
// 栈类
class Stack {
constructor(n = 100) {
this.data = new Array(n); // 一片连续的存储空间
this.top = -1; // 栈顶指针,栈为空,指向-1
}
// 入队
push(x) {
// 指针向上移动一位
this.top += 1;
// x放入指针指向的位置
this.data[this.top] = x;
return;
}
// 出队
pop() {
// 栈为空,不操作
if (this.empty()) return;
// 栈顶指针向下移动一位,逻辑上认为已出队
this.top -= 1;
return;
}
// 判空
empty() {
return this.top === -1;
}
// 栈中元素数量
size() {
return this.top + 1;
}
output() {
for (let i = this.top; i >= 0; i--) {
console.log(this.data[i]);
}
}
}
function main() {
let arr = new Stack(5);
arr.push(1);
arr.push(2);
arr.push(4);
arr.output();
arr.pop();
arr.output();
console.log(arr.size());
}
main();
4.栈的典型应用场景
1.场景一:操作系统中的线程栈
2.场景二:表达式求值
给运算符人为设定数值上的优先级,加减为1,乘除为2,括号内的加100
// s:待计算的表达式;l:开始坐标;r:结束坐标
function calc(s, l, r) {
// op:最低优先级操作符的位置
// pri:最低优先级操作符的优先级
// cur_pri:当前操作符优先级
// temp:因为括号额外增加的优先级
let op = -1,
pri = 10000 - 1,
cur_pri,
temp = 0;
// 第一步:找到优先级最低的操作符
for (let i = l; i <= r; i++) {
cur_pri = 10000;
switch (s[i]) {
case "+":
case "-": cur_pri = 1 + temp; break;
case "*":
case "/": cur_pri = 2 + temp; break;
// 进了一层括号
case "(": temp += 100; break;
// 出了一层括号
case ")": temp -= 100; break;
}
// 当前优先级小于等于先前记录的优先级,就更新一下
if (cur_pri <= pri) {
pri = cur_pri;
op = i;
}
}
if (op === -1) {
// 证明当前表达式是没有操作符的纯数字,需要转换成数字
let num = 0;
for (let i = l; i <= r; i++) {
if (s[i] < "0" || s[i] > "9") continue;
num = num * 10 + (s[i].charCodeAt() - "0".charCodeAt());
}
return num;
}
// 第二步:递归的计算,以优先级最低的操作符为分隔,两侧的表达式,
let a = calc(s, l, op - 1);
let b = calc(s, op + 1, r);
// 计算表达式结果
switch (s[op]) {
case "+": return a + b;
case "-": return a - b;
case "*": return a * b;
case "/": return a / b;
}
}
function main() {
let s = "(3+5)*7";
console.log(calc(s, 0, s.length - 1));
}
main();
5.经典面试题-栈的基本操作
面试题 03.04. 化栈为队
利⽤两个栈来实现,⼀个输⼊栈、⼀个输出栈。
输⼊栈⽤于读⼊数据。当需要输出元素时,若输出栈为空,则将输⼊栈的所有元素 推送到输出栈,然后取栈顶元素;若输出栈非空,则输出栈顶即可。
输出栈的作⽤是对已经被反转的序列进⾏⼆次反转。
两个栈命名s1、s2,从s2中入队,s1中出队,出队时s1要是为空,就把s2中的元素全部放入s1中,此时我们会发现s1可以实现s2数据的逆序,正常出队即可,这时两个栈对外的表现形式就是队列
s2入队〇〇〇|①②③
s1出队①②③|〇〇〇
s1出队②③|〇〇〇
s2入队②③|④⑤⑥
var MyQueue = function() {
// s1出队,s2入队
this.s1 = [];
this.s2 = [];
};
MyQueue.prototype.push = function(x) {
this.s2.push(x);
return;
};
// s1为空时,把s2中的元素全部放入s1中
MyQueue.prototype.transfer = function() {
if(this.s1.length !== 0) return;
while(this.s2.length !== 0){
this.s1.push(this.s2.pop());
}
return;
};
MyQueue.prototype.pop = function() {
this.transfer();
return this.s1.pop();
};
MyQueue.prototype.peek = function() {
this.transfer();
return this.s1[this.s1.length - 1];
};
// s1、s2都为空时,才空
MyQueue.prototype.empty = function() {
return this.s1.length === 0 && this.s2.length === 0;
};
682. 棒球比赛
// 依照题意编写代码即可
var calPoints = function(ops) {
let s = [];
for(let i = 0; i < ops.length; i++){
if(ops[i] === '+'){
// 先弹出前一次得分,并记录下来
let a = s.pop();
// 拿到前两次得分
let b = s[s.length - 1];
// 把前一次得分再放回栈中
s.push(a);
// 最后放入前两次得分的总和
s.push(a + b);
}else if(ops[i] === 'D'){
s.push(2 * s[s.length - 1]);
}else if(ops[i] === 'C'){
s.pop();
}else{
s.push(Number(ops[i]));
}
}
let sum = 0;
while(s.length !== 0){
sum += s.pop();
}
return sum;
};
844. 比较含退格的字符串
// 把原字符串按题意处理得到新字符串
var transform = function(arr, S){
for(let i = 0; i< S.length; i++){
if(S[i] == '#'){
arr.pop();
}else{
arr.push(S[i]);
}
}
return arr.join('');
}
var backspaceCompare = function(s, t) {
let sarr = [];
let tarr = [];
return transform(sarr,s) === transform(tarr,t);
};
946. 验证栈序列
只需关注出栈序列即可,要出栈的元素,要么是栈顶元素,要么是未来可能入栈的元素,所以,出栈序列中当前出栈元素是否能被满足,就判断元素在不在栈顶,不在,就继续从入栈序列,入栈元素即可。
var validateStackSequences = function(pushed, popped) {
let s = [];
// 判断出栈元素能否被满足
// i:出栈序列;j:入栈序列
for(let i = 0, j = 0;i < popped.length; i++){
// 当入栈序列还有元素并且 栈为空或者栈顶元素不等于当前要出栈的元素时,持续性入栈
while(j < pushed.length && (s.length === 0 || s[s.length - 1] !== popped[i])){
s.push(pushed[j]);
j += 1;
}
// 全部入栈后栈顶元素还不等于当前要出栈的元素,没法满足
if(s[s.length - 1] !== popped[i]) return false;
// 当前出栈元素满足,出栈
s.pop();
}
return true;
};
6.经典面试题-栈结构扩展
20. 有效的括号
var isValid = function(s) {
let stack = [];
for(let i = 0; i < s.length; i++){
switch(s[i]){
// 如果是左括号之间入栈
case '(':
case '[':
case '{': stack.push(s[i]);break;
// 如果是右括号,判断是否能和栈顶元素匹配,同时栈不能为空,匹配成功则出栈
case ')': if(stack.length === 0 || stack[stack.length - 1] !== '(') return false; stack.pop();break;
case ']': if(stack.length === 0 || stack[stack.length - 1] !== '[') return false; stack.pop();break;
case '}': if(stack.length === 0 || stack[stack.length - 1] !== '{') return false; stack.pop();break;
}
}
// 最后栈为空,才是合法的序列
return stack.length === 0;
};
1021. 删除最外层的括号
如何判断某一部分是原语,即独立的括号部分呢
设定遇到(加一,遇到)减一,统计(和)的数量,记录(和)的差值,当差值为0时,则代表这⼀串括号序列是独立的,可以被单独分解出来。
var removeOuterParentheses = function(s) {
let ret = '';
// pre:当前独立括号的起始位置
// cnt:左右括号差值
for(let i = 0, pre = 0, cnt = 0; i < s.length; i++){
if(s[i] === '(') cnt += 1;
else cnt -= 1;
if(cnt !== 0) continue;
// 差值为0证明从pre开始到当前位置i是一段独立的字符串
// 截取中间部分,左闭右开
ret += s.substring(pre + 1, i);
// 下一段起始位置是当前位置的下一位
pre = i + 1;
}
return ret;
};
1249. 移除无效的括号
可以被匹配的括号都是有效的,⽽其他的括号都需要被删除。
先从前向后遍历,跳过多余的右括号,再从后向前遍历,跳过多余的左括号。
var minRemoveToMakeValid = function(s) {
let t = '';
// cnt左右括号差值
// 先正向遍历,去掉多余的右括号
for(let i = 0, cnt = 0; i < s.length; i++){
if(s[i] !== ')' ) {
// (加一
cnt += (s[i] === '(');
t += s[i];
}else{
// 差值为0,说明当前)是非法的,跳过
if(cnt === 0) continue;
cnt -= 1;
t += ')';
}
}
let ans = '';
// 得到的新字符串,再逆向遍历,去掉多余的左括号
for(let i = t.length - 1, cnt = 0; i >= 0; i--){
if(t[i] !== '(' ) {
cnt += (t[i] === ')');
ans = t[i] +ans;
}else{
// 差值为0,说明当前(是非法的,跳过
if(cnt === 0) continue;
cnt -= 1;
ans = '(' +ans;
}
}
return ans;
};
145. 二叉树的后序遍历
用迭代算法完成:模拟系统栈
技巧是使⽤两个栈,⼀个数据栈,存储相关节点地址,⼀个状态栈。将“遍历左⼦树”,“遍历右⼦树”和“输出根节点”三个步骤分别⽤状态码表⽰,枚举状态转移过程,使⽤有限 状态⾃动机(FSM, Finite State Machine)的模型来模拟递归过程。
var postorderTraversal = function(root) {
if(root === null) return [];
// s1:数据栈,存储节点地址
// s2:状态栈
// 0:数据栈中压入栈顶节点的左⼦树
// 1:数据栈中压入栈顶节点的右⼦树
// 2:输出栈顶节点
// ret:结果数组
let s1 = [], s2 = [], ret = [];
s1.push(root);
s2.push(0);
while(s1.length !== 0){
// 记录s2的栈顶状态执行不同操作,任何操作执行完都弹栈,所以在这里之间先执行
let status = s2.pop();
switch(status){
case 0:{
// 把栈顶元素的状态码由0改为1
s2.push(1);
if(s1[s1.length - 1].left !== null){
// s1压入左子树,s2压入左子树的状态码
s1.push(s1[s1.length - 1].left);
s2.push(0);
}
}break;
case 1:{
// 右子树同理
s2.push(2);
if(s1[s1.length - 1].right !== null){
s1.push(s1[s1.length - 1].right);
s2.push(0);
}
}break;
case 2:{
// 输出并弹栈
ret.push(s1.pop().val) ;
}break;
}
}
return ret;
};
331. 验证二叉树的前序序列化
每次碰到数字、#、#的节点(即叶⼦结点),就可以回溯,抽象成#,如果经过这种不断缩减的操作,把树上的全部节点都拆光(即只剩⼀个#),能拆光的序列就是合法序列。
var isValidSerialization = function(preorder) {
// 字符串转数组,目的去掉','
let order = preorder.split(',');
let s = []
for(let i = 0; i < order.length; i++){
// 遍历过程中进行缩减
s.push(order[i]);
// 是叶子节点时,缩减
while(s.length >= 3 && s[s.length - 1] === '#' && s[s.length - 2] === '#' && s[s.length - 3] !== '#'){
s[s.length - 3] = '#'
s.pop();
s.pop();
}
}
// 只剩一个#,正确
return s.length === 1 && s[0] ==='#';
};
227. 基本计算器 II
使⽤操作数栈和操作符栈辅助计算,当操作符栈遇到更低优先级的操作符时,需要将之前更⾼级别的操作符对应的结果计算出来。
即依次把每个操作数和操作符压入栈中,当新压入的操作符优先级小于等于栈顶元素操作符时,就得把前面操作符的结果先算出来。
对于有括号的情况,左括号相当于提⾼了内部全部运算符的优先级,当遇到右括号 的时候需要将匹配的括号间的内容全部计算出来。
可以通过加⼀个特殊操作符的处理技巧,来额外处理结尾的数字。
// 判断运算符优先级
var level = function(op){
switch(op){
case '@':return -1;
case '+':
case '-':return 1;
case '*':
case '/':return 2;
}
return 0;
}
// 根据运算符计算结果
var calc = function(a, op, b){
switch(op){
case '+':return a + b;
case '-':return a - b;
case '*':return a * b;
case '/':return Math.floor(a / b);
}
return 0;
}
var calculate = function(s) {
let num = [];// 操作数栈
let ops = [];// 运算符栈
/* 技巧:设定@运算符优先级小于所有运算符。因为计算过程是碰到一个优先级比较低的运算符时,
会把前面结果都算出来。又因为@运算符优先级最低,所以会把表达式全部计算,是一个收尾操作*/
s += '@';
// n:当前数字
for(let i = 0, n = 0; i < s.length; i++){
if(s[i] === ' ') continue;
if(s[i] >= '0' && s[i] <= '9'){
// 数字字符转数字
n = n * 10 + (s[i].charCodeAt() - '0'.charCodeAt());
continue;
}
// 当前元素是运算符,把之前的数字放入操作数栈,并重置n
num.push(n);
n = 0;
// 栈不为空并且当前运算符优先级小于等于栈顶运算符的话
while(ops.length !== 0 && level(s[i]) <= level(ops[ops.length - 1])){
// 拿出操作数栈中的前两位
let b = num.pop();
let a = num.pop();
// 把运算结果放入操作数栈中
num.push(calc(a ,ops[ops.length - 1], b));
// 栈顶操作符运算完成,弹栈
ops.pop();
}
// 当前操作符入栈
ops.push(s[i]);
}
return num[0];
};
636. 函数的独占时间
任务开始时进栈,上⼀个 任务暂停执⾏;任务完成时出栈,恢复上⼀个任务的执⾏
var exclusivetime = function(n, logs) {
// 结果数组,初始化为0
let ans = new Array(n).fill(0);
// 运行函数的编号
let vID = [];
// pre上个时间点
for(let i = 0, pre = 0; i < logs.length; i++){
// 把函数,状态,时间戳拆分出来
let log = logs[i].split(':');
let id = Number(log[0]);
let status = log[1];
let time_stamp = Number(log[2]);
// 函数开始
if(status === 'start'){
// 不为空,证明有上一个函数在执行
if(vID.length !== 0){
// start是累加给上一个函数
ans[vID[vID.length - 1]] += time_stamp - pre;
}
pre = time_stamp;
vID.push(id);
}else{
// end是累加给当前函数,id和栈顶元素一致,所以两者都可以写,换成栈顶元素写法可以简化代码,为了好理解,这里就不简化了
// ans[id] += time_stamp - pre + 1;
ans[vID[vID.length - 1]] += time_stamp - pre + 1;
pre = time_stamp + 1;
vID.pop();
}
}
return ans;
};
1124. 表现良好的最长时间段
把表现“良好”记为+1,把表现“不好”记为-1,将原序列转化为正负1的序列,原问题转化为求转化后序列的最⻓⼀段连续⼦序列,使得⼦序列的和⼤于0。
在这⾥引⼊“前缀和”的技巧。前缀和数组的第n项,是原数组前n项的和。
记原数组为a,那么前缀和prefix[n] = Σa[i](i从1到n)。这样就可以将“区间和”转化 为“前缀和”之差来计算。前缀和可以视情况补⼀个前导0,表⽰前0项之和,即不取任何元素的情况。
在本题中,数组中的元素只有-1和1,因此前缀和 的变化⼀定是连续的。我 们记录下前缀和中,每⼀个前缀和第⼀次出现的位置,它对应的位置⼀定是从该前 缀和出发的最优解。
我们以f(n)表⽰以n结尾的序列的最⼤⻓度,pos(n)表⽰前缀和n第⼀次出现的位置。那么有f(n) = f(n-1) + pos(n) - pos(n-1)
// 关键:记录每个值第一次出现时的能达到的最大长度以及位置
var longestWPI = function(hours) {
// 前缀和中每个值第一次出现的位置
let ind = new Map();
// 前缀和中每个值的第一次出现时最大长度
let f = new Map();
// 前导0第一次出现的位置是-1
ind.set(0, -1);
// 前导0是第一个前缀和元素,前面不会比0小的元素,最大长度是0
f.set(0, 0)
// cnt前缀和
let cnt = 0, ans = 0;
for(let i = 0; i < hours.length; i++){
if(hours[i] > 8) cnt += 1;
else cnt -= 1;
// cnt第一次出现
if(!ind.has(cnt)){
// 记录cnt第一次出现的位置
ind.set(cnt, i);
// 前面没有出现比cnt小1的值,最大长度赋值为0
// 因为前缀和的变化⼀定是连续的,所以每个负数第一次出现时前面不会有比它小的值,最大长度是0
if(!ind.has(cnt - 1)) f.set(cnt, 0);
// 出现过,求第一次出现能达到的最大长度
else f.set(cnt, f.get(cnt - 1) + (i - ind.get(cnt - 1)));
}
if(!ind.has(cnt - 1)) continue;
// 前面出现比cnt小1的值,求最大长度
ans = Math.max(ans, f.get(cnt - 1) + (i - ind.get(cnt - 1)));
}
return ans;
};