双栈法
思路
先简化一下,如果这道题没有*,只有(,)的话,只需要用一个栈就搞定了。
- 创建一个栈用来存
( - 遍历字符串,如果当前字符为
(就入栈,如果当前字符为)并且栈不为空就出栈一个( - 如果最后栈是空的,证明所有括号都是匹配的,是个有效的括号字符串
然后原题是多了一个*,并且*的万能的:可以当(,也可以当),也可以当""。一个栈只有入栈和出栈两种操作,只能解决能够成队匹配的字符。现在加多了一个不确定是什么的字符*,显然,一个栈不够用了。如果一个不够,那就用两个。
- 创建两个栈,一个用来存
(,一个用来存* - 遍历字符串,遇到
(和*进入对应的栈,注意这里入栈的是下标,因为后面要用到 - 遇到
),先消耗(出栈)(的栈,消耗完了再消耗*的栈,如果两个栈都为空,则证明没有可以和)匹配的字符了,此时直接返回false - 遍历完成之后,两个栈都可能还有数据,上面的逻辑是把
*当做(和)匹配,*栈里的剩下的*要作为)和(栈里的匹配 - 因为是两个不同的栈,所以不能保证顺序问题,这个时候就要用下标了,因为
*是作为)的,)要在(后面,所以如果*栈顶储存的下标比(栈栈顶要大,则证明顺序不对,直接返回false - 最后看看
(栈是否为空,如果为空则证明全部匹配,返回true,否则是无效字符串,返回false。
最后为什么要看
(栈为不为空呢?
*可以当空字符串,有没有多的都无所谓,只要(能匹配完就行了
图解
测试用例:"((*(**))",结果应该返回true
代码
function checkValidString(s: string): boolean {
const leftBracketsStack = []; // ”(“栈
const starStack = []; // "*"栈
for (let i = 0; i < s.length; i++) {
const elem = s[i];
if (elem === '(') {
leftBracketsStack.push(i);
} else if (elem === '*') {
starStack.push(i);
} else {
if (leftBracketsStack.length > 0) {
leftBracketsStack.pop(); // 遇到")",先抵消"("栈的"("
} else if (starStack.length > 0) {
starStack.pop(); // 然后将"*"栈的"*"作为"("抵消
} else {
return false; // 没有可以抵消的"(",直接返回false
}
}
}
// 剩下的"*"作为")"和"("抵消,所以要保证弹出的"*"下标比"("大,是"()"这样匹配的,而不是这样")("
while (leftBracketsStack.length > 0 && starStack.length > 0) {
if (leftBracketsStack.pop() > starStack.pop()) return false;
}
return leftBracketsStack.length > 0 ? false : true; // 最后所有的"("都应该被抵消完才是有效的
};
双向比较法
思路
如果一个字符串是有效的,那么不管是从左边开始看还是从右边开始看,都是可以完全匹配的。(这个怎么证明我也不懂)
代码
function checkValidString(s: string): boolean {
let left = 0, right = 0;
for (let i = 0; i < s.length; i++) {
left += s[i] === ')' ? -1 : 1; // 从左边看
right += (s[s.length - 1 - i] === '(') ? -1 : 1; // 从右边看
if (left < 0 || right < 0) return false;
}
return true;
};
其实+1和-1也可以看成栈的入栈和出栈两种操作,left和right可以看成是两个栈
function checkValidString(s: string): boolean {
let left = [], right = [], leftTop = true, rightTop = true;
for (let i = 0; i < s.length; i++) {
// 从左边开始比
if (s[i] === ')') {
leftTop = left.pop();
} else {
left.push(true);
}
// 从右边开始比
if (s[s.length - 1 - i] === '(') {
rightTop = right.pop();
} else {
right.push(true);
}
// 栈中无可匹配内容,是无效字符串
if (!leftTop || !rightTop) return false;
}
return true;
};
动态规划
动态规划的思路用一句话解释就是:大事化小,小事化了。找出规律,从小到大缓存所有的解,最后得出算出最终的解。
如果完全没了解过动态规划,可以看看下面两篇文章:
- 漫画:什么是动态规划?
- [告别动态规划,连刷 40 道题,我总结了这些套路,看不懂你打我(万字长文)](
思路
判断字符串是否为有效的括号字符串时,字符越多,需要考虑的情况就越多。
可以将字符串拆解成一个个子字符串,例如:有一个长度为4的字符串(()),如果要保证区间[0, 3],也就是整个字符串是有效的括号字符串,只需要保证0和3能组成括号并且区间[1, 2]是有效字符串就行了。这里只是其中一种情况,还有其它情况。
但是,这里我们可以看出,需要数据结构表示这种区间的形式,并且遍历的时候是从最小子区间开始遍历的。
这里我们可以定义一个二维数组:d[i][j]代表字符串的[i, j]子区间是否是有效括号字符串
[i, j]为有效字符串:d[i][j] = true[i, j]不是效字符串:d[i][j] = false
遍历:
// length为目标字符串长度
for (let len = 0; len < length; len++) { // 每次遍历字符串的范围为len
for (let i = 0; i < length - len; i++) {
let j = i + len;
}
}
画个图看看是怎么遍历的:
不难看出,这里我们遍历的字符串区间长度会越来越大,所以最后dp[0][s.length - 1]就是我们要的结果。
最后我们看看最小集的规律和边界是啥
- 规律:如果想要区间
[i, j]是有效括号字符串,这里有两种情况:- 当前区间
dp[i][j]为true,并且他的子区间dp[i + 1][j - 1]也为ture,例:(()) - 当前区间
dp[i][j]为true,并且可以在里面找到一个k,使得被k截断的两个区间dp[i][ k]和dp[k+1][j]也为true,例:()()
- 当前区间
- 边界:就是粒度最小的情况,也就就字符串只有一个字符的情况,当这个字符为
*为有效,否则为无效
代码
function checkValidString(s: string): boolean {
const { length } = s;
// 初始化dp二维数组
const dp: boolean[][] = Array.from(new Array(length), x => new Array(length).fill(false));
for (let len = 0; len < length; len++) { // 每次遍历字符串的范围为len
for (let i = 0; i < length - len; i++) {
let j = i + len;
// 1. 如果是范围是0,就是字符串只有一个字符时
if (len === 0) {
dp[i][j] = s[i] === '*'; // 当前字符为*号的时候为true,因为*是万能的
continue;
}
// 2. 如果当前区间第一个字符为左括号(这里遇到*也当左括号),并且最后一个字符是右括号
if ((s[i] === '(' || s[i] === '*') && (s[j] === ')' || s[j] === '*')) {
// 范围为1时证明只有这两个字符,直接返回true,否则看子区间是不是合法的
dp[i][j] = (len === 1) ? true : dp[i + 1][j - 1];
}
// 在i到j的区间字符串里找一个k,看看是否有一个k能满足,被k截断的两个区间[i, k]和[k+1,j]中能构成有效字符串
for (let k = i; k < j; k++) {
dp[i][j] = dp[i][j] || (dp[i][k] && dp[k + 1][j]);
if (dp[i][j] === true) {
break;
}
}
}
}
return dp[0][length - 1];
};
贪心算法
贪心算法的思想很简单,能贪最大的就贪贪最大,不能贪再退而求其次,以此类推。
也就是每次找局部最优解,最后组成全局最优解。
但是,有时候局部最优解并不能保证最后组成的全局解是最优的,所以贪心的难点其实再如何判断能否使用这个算法。
思路
我们可以计算(的个数来判断字符串是否是有效的。
这里因为*可以万能的,贪心嘛,就两种情况:
- 能贪,就把
*当( - 实在不行了,且
(个数不为0再当)
代码
function checkValidString(s: string): boolean {
let min = 0; // 左括号可能的最小数
let max = 0; // 左括号可能的最大数
for (let i = 0; i < s.length; i++) {
const c = s[i];
// 如果是左括号,最大数和最小数都加一
if (c === '(') {
min++;
max++;
} else if (c === '*') {
// 最小数,能匹配就匹配,减1
if (min !== 0) min--;
// 最大数,每次遇到*都当左括号,故加1
max++;
} else {
// 最小数,能匹配就匹配,减1
if (min !== 0) min--;
// 最大数如果减完之后小于0,证明右括号多了,后面再怎么也无法匹配了,直接返回false
if (--max < 0) return false;
}
}
return min === 0; // 如果是有效的,最后最小数肯定是刚好匹配完的
};
DFS
思路
使用栈或数字计算左括号的个数,遇到*就分成三种情况考虑
- 当成
( - 当成
) - 当成空字符串
只要其中一种成立就可以了
图解
其实就是一棵树的深度优先遍历(DFS)
代码
function checkValidString(s: string, start: number = 0, count: number = 0): boolean {
if (count < 0) return false; // 如果左括号数小于0,返回false
for (let i = start; i < s.length; i++) {
const c = s[i];
if (c === '(') {
count++;
} else if (c === ')') {
if (count-- === 0) return false; // 没有左括号可以抵消,直接返回false
} else {
return checkValidString(s, i + 1, count + 1) || // 作为"("
checkValidString(s, i + 1, count - 1) || // 作为")",抵消一个”(“
checkValidString(s, i + 1, count); // 作为""
}
}
return count === 0; // 最后刚好匹配完就是有效的
};
注意📢:这个解法并没有AC,遇到
"************************************************************"这个测试用例的时候超时了,减少*的个数,答案是对的。虽然没有AC,但是这个思路还是可以学习一下的