95% 的算法都是基于这 6 种算法思想,大厂Java面试必考点

// 当前结点的 id 符合查找条件,返回当前结点

if(node.id === id) return node

// 前结点的 id 不符合查找条件,继续查找它的每一个子结点

for(var i = 0; i < node.childNodes.length; i++){

// 递归查找它的每一个子结点

var found = getElementById(node.childNodes[i], id);

if(found) return found;

}

return null;

}

就这样,我们的一个 document.getElementById 功能已经实现了:

function getElementById(node, id){

if(!node) return null;

if(node.id === id) return node;

for(var i = 0; i < node.childNodes.length; i++){

var found = getElementById(node.childNodes[i], id);

if(found) return found;

}

return null;

}

getElementById(document, “d-cal”);

最后在控制台验证一下,执行结果如下图所示:

img

使用递归的优点是代码简单易懂,缺点是效率比不上非递归的实现。Chrome浏览器的查DOM是使用非递归实现。非递归要怎么实现呢?

如下代码:

function getByElementId(node, id){

//遍历所有的Node

while(node){

if(node.id === id) return node;

node = nextElement(node);

}

return null;

}

还是依次遍历所有的 DOM 结点,只是这一次改成一个 while 循环,函数 nextElement 负责找到下一个结点。所以关键在于这个 nextElement 如何实现非递归查找结点功能:

// 深度遍历

function nextElement(node){

// 先判断是否有子结点

if(node.children.length) {

// 有则返回第一个子结点

return node.children[0];

}

// 再判断是否有相邻结点

if(node.nextElementSibling){

// 有则返回它的下一个相邻结点

return node.nextElementSibling;

}

// 否则,往上返回它的父结点的下一个相邻元素,相当于上面递归实现里面的for循环的i加1

while(node.parentNode){

if(node.parentNode.nextElementSibling) {

return node.parentNode.nextElementSibling;

}

node = node.parentNode;

}

return null;

}

在控制台里面运行这段代码,同样也可以正确地输出结果。不管是非递归还是递归,它们都是深度优先遍历,这个过程如下图所示。

在这里插入图片描述

实际上 getElementById 浏览器是用的一个哈希 map 存储的,根据 id 直接映射到 DOM 结点,而 getElementsByClassName 就是用的这样的非递归查找。

参考:我接触过的前端数据结构与算法

2 分治算法


2.1 算法策略

在计算机科学中,分治算法是一个很重要的算法,快速排序、归并排序等都是基于分治策略进行实现的,所以,建议理解掌握它。

分治,顾名思义,就是 分而治之 ,将一个复杂的问题,分成两个或多个相似的子问题,在把子问题分成更小的子问题,直到更小的子问题可以简单求解,求解子问题,则原问题的解则为阿子问题解的合并。

2.2 适用场景

当出现满足以下条件的问题,可以尝试只用分治策略进行求解:

  • 原始问题可以分成多个相似的子问题

  • 子问题可以很简单的求解

  • 原始问题的解是子问题解的合并

  • 各个子问题是相互独立的,不包含相同的子问题

分治的解题策略:
  • 第一步:分解,将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题

  • 第二步:解决,解决各个子问题

  • 第三步:合并,将各个子问题的解合并为原问题的解

2.3 使用分治法求解的一些经典问题

  • 二分查找

  • 归并排序

  • 快速排序

  • 汉诺塔问题

  • React 时间分片

二分查找

也称折半查找算法,它是一种简单易懂的快速查找算法。例如我随机写0-100之间的一个数字,让你猜我写的是什么?你每猜一次,我就会告诉你猜的大了还是小了,直到猜中为止。

第一步:分解

每次猜拳都把上一次的结果分出大的一组和小的一组,两组相互独立

  • 选择数组中的中间数

function binarySearch(items, item) {

// low、mid、high将数组分成两组

var low = 0,

high = items.length - 1,

mid = Math.floor((low+high)/2),

elem = items[mid]

// …

}

第二步:解决子问题

查找数与中间数对比

  • 比中间数低,则去中间数左边的子数组中寻找;

  • 比中间数高,则去中间数右边的子数组中寻找;

  • 相等则返回查找成功

while(low <= high) {

if(elem < item) { // 比中间数高

low = mid + 1

} else if(elem > item) { // 比中间数低

high = mid - 1

} else { // 相等

return mid

}

}

第三步:合并

function binarySearch(items, item) {

var low = 0,

high = items.length - 1,

mid, elem

while(low <= high) {

mid = Math.floor((low+high)/2)

elem = items[mid]

if(elem < item) {

low = mid + 1

} else if(elem > item) {

high = mid - 1

} else {

return mid

}

}

return -1

}

最后,二分法只能应用于数组有序的情况,如果数组无序,二分查找就不能起作用了

function binarySearch(items, item) {

// 快排

quickSort(items)

var low = 0,

high = items.length - 1,

mid, elem

while(low <= high) {

mid = Math.floor((low+high)/2)

elem = items[mid]

if(elem < item) {

low = mid + 1

} else if(elem > item) {

high = mid - 1

} else {

return mid

}

}

return -1

}

// 测试

var arr = [2,3,1,4]

binarySearch(arr, 3)

// 2

binarySearch(arr, 5)

// -1

测试成功

3 贪心算法


3.1 算法策略

贪心算法,故名思义,总是做出当前的最优选择,即期望通过局部的最优选择获得整体的最优选择。

某种意义上说,贪心算法是很贪婪、很目光短浅的,它不从整体考虑,仅仅只关注当前的最大利益,所以说它做出的选择仅仅是某种意义上的局部最优,但是贪心算法在很多问题上还是能够拿到最优解或较优解,所以它的存在还是有意义的。

3.2 适用场景

在日常生活中,我们使用到贪心算法的时候还是挺多的,例如:

从100章面值不等的钞票中,抽出 10 张,怎样才能获得最多的价值?

我们只需要每次都选择剩下的钞票中最大的面值,最后一定拿到的就是最优解,这就是使用的贪心算法,并且最后得到了整体最优解。

但是,我们任然需要明确的是,期望通过局部的最优选择获得整体的最优选择,仅仅是期望而已,也可能最终得到的结果并不一定不能是整体最优解。

例如:求取A到G最短路径:

在这里插入图片描述

根据贪心算法总是选择当前最优选择,所以它首先选择的路径是 AB,然后 BE、EG,所得到的路径总长为 1 + 5 + 4 = 10,然而这并不是最短路径,最短路径为 A->C->G : 2 + 2 = 4,所以说,贪心算法得到得并不一定是最优解。

那么一般在什么时候可以尝试选择使用贪心算法喃?

当满足一下条件时,可以使用:

  • 原问题复杂度过高

  • 求全局最优解的数学模型难以建立或计算量过大

  • 没有太大必要一定要求出全局最优解,“比较优”就可以

如果使用贪心算法求最优解,可以按照以下 步骤求解

  • 首先,我们需要明确什么是最优解(期望)

  • 然后,把问题分成多个步骤,每一步都需要满足:

    • 可行性:每一步都满足问题的约束
    • 局部最优:每一步都做出一个局部最优的选择
  • - 不可取消:选择一旦做出,在后面遇到任何情况都不可取消

  • 最后,叠加所有步骤的最优解,就是全局最优解

3.3 经典案例:活动选择问题

使用贪心算法求解的经典问题有:

  • 最小生成树算法

  • 单源最短路径的 Dijkstra 算法

  • Huffman 压缩编码

  • 背包问题

  • 活动选择问题等

其中活动选择问题是最简单的,这里详细介绍这个。

活动选择问题是《算法导论》上的例子,也是一个非常经典的问题。有 n 个活动(a1,a2,…,an)需要使用同一个资源(例如教室),资源在某个时刻只能供一个活动使用。每个活动 ai 都有一个开始时间 si 和结束时间 fi 。一旦被选择后,活动 ai 就占据半开时间区间 [si,fi) 。如果 [si,fi) 和 [sj,fj) 互不重叠,ai 和 aj 两个活动就可以被安排在这一天。

该问题就是要安排这些活动,使得尽量多的活动能不冲突的举行。例如下图所示的活动集合S,其中各项活动按照结束时间单调递增排序。

在这里插入图片描述

共有 7 个活动,它们在 18 个小时内需要占用的时间如上图,如何选择活动,能让这间教室利用率最高喃(能够举行更多的活动)?

贪心算法对这种问题的解决很简单的,它开始时刻开始选择,每次选择开始时间与与已选择活动不冲突的,结束时间又比较靠前的活动,这样会让剩下的时间区间更长。

在这里插入图片描述

  • 首先 a1 活动的结束时间最早,选择 a1 活动

  • a1 结束后,a2 有时间冲突,不可选择,a3、a4 都可选择,但 a4 结束时间最早,选择 a4

  • 依次选择时间没有冲突的,又结束时间最早的活动

最终选择活动为 a1,a4,a5,a7。为最优解。

4 回溯算法


4.1 算法策略

回溯算法是一种搜索法,试探法,它会在每一步做出选择,一旦发现这个选择无法得到期望结果,就回溯回去,重新做出选择。深度优先搜索利用的就是回溯算法思想。

4.2 适用场景

回溯算法很简单,它就是不断的尝试,直到拿到解。它的这种算法思想,使它通常用于解决广度的搜索问题,即从一组可能的解中,选择一个满足要求的解。

4.3 使用回溯算法的经典案例

  • 深度优先搜索

  • 0-1背包问题

  • 正则表达式匹配

  • 八皇后

  • 数独

  • 全排列

等等,深度优先搜索我们在图那一章已经介绍过,这里以正则表达式匹配为例,介绍一下

正则表达式匹配

var string = “abbc”

var regex = /ab{1,3}c/

console.log( string.match(regex) )

// [“abbc”, index: 0, input: “abbc”, groups: undefined]

它的匹配过程:

在这里插入图片描述

在第 5 步匹配失败,此时 b{1,3} 已经匹配到了两个 b 正在尝试第三个 b ,结果发现接下来是 c 。此时就需要回溯到上一步, b{1,3} 匹配完毕(匹配到了 bb ),然后再匹配 c ,匹配到了 c 匹配结束。

5 动态规划


5.1 算法策略

动态规划也是将复杂问题分解成小问题求解的策略,与分治算法不同的是,分治算法要求各子问题是相互独立的,而动态规划各子问题是相互关联的。

所以,动态规划适用于子问题重叠的情况,即不同的子问题具有公共的子子问题,在这种情况下,分治策略会做出很多不必要的工作,它会反复求解那些公共子子问题,而动态规划会对每个子子问题求解一次,然后保存在表格中,如果遇到一致的问题,从表格中获取既可,所以它无需求解每一个子子问题,避免了大量的不必要操作。

5.2 适用场景

动态规划适用于求解最优解问题,比如,从面额不定的100个硬币中任意选取多个凑成10元,求怎样选取硬币才可以使最后选取的硬币数最少又刚好凑够了10元。这就是一个典型的动态规划问题。它可以分成一个个子问题(每次选取硬币),每个子问题又有公共的子子问题(选取硬币),子问题之间相互关联(已选取的硬币总金额不能超过10元),边界条件就是最终选取的硬币总金额为 10 元。

针对上例,也许你也可以说,我们可以使用回溯算法,不断的去试探,但回溯算法是使用与求解广度的解(满足要求的解),如果是用回溯算法,我们需要尝试去找所有满足条件的解,然后找到最优解,时间复杂度为 O(2n) ,这性能是相当差的。大多数适用于动态规划的问题,都可以使用回溯算法,只是使用回溯算法的时间复杂度比较高而已。

最后,总结一下,我们使用动态规划求解问题时,需要遵循以下几个重要步骤:

  • 定义子问题

  • 实现需要反复执行解决的子子问题部分

  • 识别并求解出边界条件

5.3 使用动态规划求解的一些经典问题

  • 爬楼梯问题:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

  • 背包问题:给出一些资源(有总量及价值),给一个背包(有总容量),往背包里装资源,目标是在背包不超过总容量的情况下,装入更多的价值

  • 硬币找零:给出面额不定的一定数量的零钱,以及需要找零的钱数,找出有多少种找零方案

  • 图的全源最短路径:一个图中包含 u、v 顶点,找出从顶点 u 到顶点 v 的最短路径

  • 最长公共子序列:找出一组序列的最长公共子序列(可由另一序列删除元素但不改变剩下元素的顺序实现)

这里以最长公共子序列为例。

爬楼梯问题

这里以动态规划经典问题爬楼梯问题为例,介绍求解动态规划问题的步骤。

第一步:定义子问题

如果用 dp[n] 表示第 n 级台阶的方案数,并且由题目知:最后一步可能迈 2 个台阶,也可迈 1 个台阶,即第 n 级台阶的方案数等于第 n-1 级台阶的方案数加上第 n-2 级台阶的方案数

第二步:实现需要反复执行解决的子子问题部分

dp[n] = dp[n−1] + dp[n−2]

第三步:识别并求解出边界条件

// 第 0 级 1 种方案

dp[0]=1

// 第 1 级也是 1 种方案

dp[1]=1

最后一步:把尾码翻译成代码,处理一些边界情况

let climbStairs = function(n) {

let dp = [1, 1]

for(let i = 2; i <= n; i++) {

dp[i] = dp[i - 1] + dp[i - 2]

}

return dp[n]

}

复杂度分析:
  • 时间复杂度:O(n)

  • 空间复杂度:O(n)

优化空间复杂度:

let climbStairs = function(n) {

let res = 1, n1 = 1, n2 = 1

for(let i = 2; i <= n; i++) {

res = n1 + n2

n1 = n2

n2 = res

}

return res

}

空间复杂度:O(1)

6 枚举算法


6.1 算法策略

枚举算法的思想是:将问题的所有可能的答案一一列举,然后根据条件判断此答案是否合适,保留合适的,丢弃不合适的。

6.2 解题思路

  • 确定枚举对象、枚举范围和判定条件。

  • 逐一列举可能的解,验证每个解是否是问题的解。

7 刷题


7.1 爬楼梯问题

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

输入: 2

输出: 2

解释: 有两种方法可以爬到楼顶。

  1. 1 阶 + 1 阶

  2. 2 阶

示例 2:

输入: 3

输出: 3

解释: 有三种方法可以爬到楼顶。

  1. 1 阶 + 1 阶 + 1 阶

  2. 1 阶 + 2 阶

  3. 2 阶 + 1 阶

解法:动态规划

动态规划(Dynamic Programming,DP)是一种将复杂问题分解成小问题求解的策略,但与分治算法不同的是,分治算法要求各子问题是相互独立的,而动态规划各子问题是相互关联的。

分治,顾名思义,就是分而治之,将一个复杂的问题,分成两个或多个相似的子问题,在把子问题分成更小的子问题,直到更小的子问题可以简单求解,求解子问题,则原问题的解则为子问题解的合并。

我们使用动态规划求解问题时,需要遵循以下几个重要步骤:

  • 定义子问题

  • 实现需要反复执行解决的子子问题部分

  • 识别并求解出边界条件

第一步:定义子问题

如果用 dp[n] 表示第 n 级台阶的方案数,并且由题目知:最后一步可能迈 2 个台阶,也可迈 1 个台阶,即第 n 级台阶的方案数等于第 n-1 级台阶的方案数加上第 n-2 级台阶的方案数

第二步:实现需要反复执行解决的子子问题部分

dp[n] = dp[n−1] + dp[n−2]

第三步:识别并求解出边界条件

// 第 0 级 1 种方案

dp[0]=1

// 第 1 级也是 1 种方案

dp[1]=1

最后一步:把尾码翻译成代码,处理一些边界情况

let climbStairs = function(n) {

let dp = [1, 1]

for(let i = 2; i <= n; i++) {

dp[i] = dp[i - 1] + dp[i - 2]

}

return dp[n]

}

复杂度分析:
  • 时间复杂度:O(n)

  • 空间复杂度:O(n)

优化空间复杂度:

let climbStairs = function(n) {

let res = 1, n1 = 1, n2 = 1

for(let i = 2; i <= n; i++) {

res = n1 + n2

n1 = n2

n2 = res

}

return res

}

空间复杂度:O(1)

更多解答

7.2 使用最小花费爬楼梯

数组的每个索引作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i] (索引从0开始)。

每当你爬上一个阶梯你都要花费对应的体力花费值,然后你可以选择继续爬一个阶梯或者爬两个阶梯。

您需要找到达到楼层顶部的最低花费。在开始时,你可以选择从索引为 0 或 1 的元素作为初始阶梯。

示例 1:

输入: cost = [10, 15, 20]

输出: 15

解释: 最低花费是从cost[1]开始,然后走两步即可到阶梯顶,一共花费15。

示例 2:

输入: cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]

输出: 6

解释: 最低花费方式是从cost[0]开始,逐个经过那些1,跳过cost[3],一共花费6。

注意:
  • cost 的长度将会在 [2, 1000]

  • 每一个 cost[i] 将会是一个Integer类型,范围为 [0, 999]

解法:动态规划

本题注意理解题意:

  • i 级台阶是第 i-1 级台阶的阶梯顶部。

  • 踏上第 i 级台阶花费 cost[i] ,直接迈一大步跨过而不踏上去则不用花费。

  • 楼梯顶部在数组之外,如果数组长度为 len ,那么楼顶就在下标为 len

第一步:定义子问题

踏上第 i 级台阶的体力消耗为到达前两个阶梯的最小体力消耗加上本层体力消耗:

  • 最后迈 1 步踏上第 i 级台阶: dp[i-1] + cost[i]

  • 最后迈 1 步踏上第 i 级台阶: dp[i-2] + cost[i]

第二步:实现需要反复执行解决的子子问题部分

所以踏上第 i 级台阶的最小花费为:

dp[i] = min(dp[i-2], dp[i-1]) + cost[i]

第三步:识别并求解出边界条件

// 第 0 级 cost[0] 种方案

dp[0] = cost[0]

// 第 1 级,有两种情况

// 1:分别踏上第0级与第1级台阶,花费cost[0] + cost[1]

// 2:直接从地面开始迈两步直接踏上第1级台阶,花费cost[1]

dp[1] = min(cost[0] + cost[1], cost[1]) = cost[1]

最后一步:把尾码翻译成代码,处理一些边界情况

let minCostClimbingStairs = function(cost) {

cost.push(0)

let dp = [], n = cost.length

dp[0] = cost[0]

dp[1] = cost[1]

for(let i = 2; i < n; i++){

dp[i] = Math.min(dp[i-2] , dp[i-1]) + cost[i]

}

return dp[n-1]

}

复杂度分析:
  • 时间复杂度:O(n)

  • 空间复杂度:O(n)

优化:

let minCostClimbingStairs = function(cost) {

let n = cost.length,

n1 = cost[0],

n2 = cost[1]

for(let i = 2;i < n;i++){

let tmp = n2

n2 = Math.min(n1,n2)+cost[i]

n1 = tmp

}

return Math.min(n1,n2)

};

  • 时间复杂度:O(n)

  • 空间复杂度:O(1)

更多解答

7.3 最大子序和

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例:

输入: [-2,1,-3,4,-1,2,1,-5,4]

输出: 6

解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

进阶:

如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的分治法求解。

第一步:定义子问题

动态规划是将整个数组归纳考虑,假设我们已经知道了以第 i-1 个数结尾的连续子数组的最大和 dp[i-1] ,显然以第 i 个数结尾的连续子数组的最大和的可能取值要么为 dp[i-1]+nums[i] ,要么就是 nums[i] 单独成一组,也就是 nums[i] ,在这两个数中我们取最大值

第二步:实现需要反复执行解决的子子问题部分

dp[n] = Math.max(dp[n−1]+nums[n], nums[n])

第三步:识别并求解出边界条件

dp[0]=nums[0]

最后一步:把尾码翻译成代码,处理一些边界情况

因为我们在计算 dp[i] 的时候,只关心 dp[i-1]nums[i] ,因此不用把整个 dp 数组保存下来,只需设置一个 pre 保存 dp[i-1] 就好了。

代码实现(优化):

let maxSubArray = function(nums) {

let max = nums[0], pre = 0

for(const num of nums) {

if(pre > 0) {

pre += num

} else {

pre = num

}

max = Math.max(max, pre)

}

return max

}

复杂度分析:
  • 时间复杂度:O(n)

  • 空间复杂度:O(1)

7.4 买卖股票的最佳时机

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

如果你最多只允许完成一笔交易(即买入和卖出一支股票一次),设计一个算法来计算你所能获取的最大利润。

注意:你不能在买入股票前卖出股票。

示例 1:

输入: [7,1,5,3,6,4]

输出: 5

解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。

注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。

示例 2:

输入: [7,6,4,3,1]

输出: 0

解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

解法:动态规划
第一步:定义子问题

动态规划是将整个数组归纳考虑,假设我们已经知道了 i-1 个股票的最大利润为 dp[i-1] ,显然 i 个连续股票的最大利润为 dp[i-1] ,要么就是就是 prices[i] - minpriceminprice 为前 i-1 支股票的最小值 ),在这两个数中我们取最大值

第二步:实现需要反复执行解决的子子问题部分

dp[i] = Math.max(dp[i−1], prices[i] - minprice)

第三步:识别并求解出边界条件

dp[0]=0

最后一步:把尾码翻译成代码,处理一些边界情况

因为我们在计算 dp[i] 的时候,只关心 dp[i-1]prices[i] ,因此不用把整个 dp 数组保存下来,只需设置一个 max 保存 dp[i-1] 就好了。

代码实现(优化):

let maxProfit = function(prices) {

let max = 0, minprice = prices[0]

for(let i = 1; i < prices.length; i++) {

minprice = Math.min(prices[i], minprice)

max = Math.max(max, prices[i] - minprice)

}

return max

}

复杂度分析:
  • 时间复杂度:O(n)

  • 空间复杂度:O(1)

更多解答

7.5 回文子串

给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。

具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。

示例 1:

输入:“abc”

输出:3

解释:三个回文子串: “a”, “b”, “c”

示例 2:

输入:“aaa”

输出:6

解释:6个回文子串: “a”, “a”, “a”, “aa”, “aa”, “aaa”

提示:
  • 输入的字符串长度不会超过 1000 。
解法一:暴力法

let countSubstrings = function(s) {

let count = 0

for (let i = 0; i < s.length; i++) {

for (let j = i; j < s.length; j++) {

if (isPalindrome(s.substring(i, j + 1))) {

count++

}

}

}

return count

}

let isPalindrome = function(s) {

let i = 0, j = s.length - 1

while (i < j) {

if (s[i] != s[j]) return false

i++

j–

}

return true

}

复杂度分析:
  • 时间复杂度:O(n3)

  • 空间复杂度:O(1)

解法二:动态规划

一个字符串是回文串,它的首尾字符相同,且剩余子串也是一个回文串。其中,剩余子串是否为回文串,就是规模小一点的子问题,它的结果影响大问题的结果。

我们怎么去描述子问题呢?

显然,一个子串由两端的 ij 指针确定,就是描述子问题的变量,子串 s[i...j]dp[i][j] ) 是否是回文串,就是子问题。

我们用二维数组记录计算过的子问题的结果,从base case出发,像填表一样递推出每个子问题的解。

j

a a b a

i a ✅

a ✅

b ✅

a ✅

注意: i<=j ,只需用半张表,竖向扫描

所以:

i === j:dp[i][j]=true

j - i == 1 && s[i] == s[j]:dp[i][j] = true

j - i > 1 && s[i] == s[j] && dp[i + 1][j - 1]:dp[i][j] = true

即:

s[i] == s[j] && (j - i <= 1 || dp[i + 1][j - 1]): dp[i][j]=true

否则为 false

代码实现:

let countSubstrings = function(s) {

const len = s.length

let count = 0

const dp = new Array(len)

for (let i = 0; i < len; i++) {

dp[i] = new Array(len).fill(false)

}

for (let j = 0; j < len; j++) {

for (let i = 0; i <= j; i++) {

if (s[i] == s[j] && (j - i <= 1 || dp[i + 1][j - 1])) {

dp[i][j] = true

count++

} else {

dp[i][j] = false

}

}

}

return count

}

代码实现(优化):

把上图的表格竖向一列看作一维数组,还是竖向扫描,此时仅仅需要将 dp 定义为一维数组即可

let countSubstrings = function(s) {

const len = s.length

let count = 0

const dp = new Array(len)

for (let j = 0; j < len; j++) {

for (let i = 0; i <= j; i++) {

if (s[i] === s[j] && (j - i <= 1 || dp[i + 1])) {

dp[i] = true

count++

} else {

dp[i] = false

}

}

}

return count;

}

复杂度分析:
  • 时间复杂度:O(n2)

  • 空间复杂度:O(n)

更多解答

7.6 最长回文子串

给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。

示例 1:

输入: “babad”

输出: “bab”

注意: “aba” 也是一个有效答案。

示例 2:

输入: “cbbd”

输出: “bb”

解法:动态规划
第 1 步:定义状态

dp[i][j] 表示子串 s[i..j] 是否为回文子串,这里子串 s[i..j] 定义为左闭右闭区间,可以取到 s[i]s[j]

第 2 步:思考状态转移方程

对于一个子串而言,如果它是回文串,那么在它的首尾增加一个相同字符,它仍然是个回文串

dp[i][j] = (s[i] === s[j]) && dp[i+1][j-1]

第 3 步:初始状态:

dp[i][i] = true // 单个字符是回文串

if(s[i] === s[i+1]) dp[i][i+1] = true // 连续两个相同字符是回文串

代码实现:

const longestPalindrome = (s) => {

if (s.length < 2) return s

// res: 最长回文子串

let res = s[0], dp = []

for (let i = 0; i < s.length; i++) {

dp[i][i] = true

}

for (let j = 1; j < s.length; j++) {

for (let i = 0; i < j; i++) {

if (j - i === 1 && s[i] === s[j]) {

dp[i][j] = true

} else if (s[i] === s[j] && dp[i + 1][j - 1]) {

dp[i][j] = true

}

// 获取当前最长回文子串

if (dp[i][j] && j - i + 1 > res.length) {

res = s.substring(i, j + 1)

}

}

}

return res

}

复杂度分析:
  • 时间复杂度:O(n2)

  • 空间复杂度:O(n2)

7.7 最小路径和

给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

在这里插入图片描述

示例 1:

输入:grid = [[1,3,1],[1,5,1],[4,2,1]]

输出:7

解释:因为路径 1→3→1→1→1 的总和最小。

示例 2:

输入:grid = [[1,2,3],[4,5,6]]

输出:12

提示:
  • m == grid.length

  • n == grid[i].length

  • 1 <= m, n <= 200

  • 0 <= grid[i][j] <= 100

1、DP方程当前项最小路径和 = 当前项值 + 上项或左项中的最小值 grid[i][j] += Math.min( grid[i - 1][j], grid[i][j - 1] )

2、边界处理grid的第一行与第一列 分别没有上项与左项 故单独处理计算起项最小路径和 计算第一行:

for(let j = 1; j < col; j++) grid[0][j] += grid[0][j - 1]

计算第一列:

for(let i = 1; i < row; i++) grid[i][0] += grid[i - 1][0]

3、代码实现

var minPathSum = function(grid) {

let row = grid.length, col = grid[0].length

// calc boundary

for(let i = 1; i < row; i++)

// calc first col

grid[i][0] += grid[i - 1][0]

for(let j = 1; j < col; j++)

// calc first row

grid[0][j] += grid[0][j - 1]

for(let i = 1; i < row; i++)

for(let j = 1; j < col; j++)

grid[i][j] += Math.min(grid[i - 1][j], grid[i][j - 1])

return grid[row - 1][col - 1]

};

更多解答

7.8 买卖股票的最佳时机 II

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

Java.png

代码实现:

const longestPalindrome = (s) => {

if (s.length < 2) return s

// res: 最长回文子串

let res = s[0], dp = []

for (let i = 0; i < s.length; i++) {

dp[i][i] = true

}

for (let j = 1; j < s.length; j++) {

for (let i = 0; i < j; i++) {

if (j - i === 1 && s[i] === s[j]) {

dp[i][j] = true

} else if (s[i] === s[j] && dp[i + 1][j - 1]) {

dp[i][j] = true

}

// 获取当前最长回文子串

if (dp[i][j] && j - i + 1 > res.length) {

res = s.substring(i, j + 1)

}

}

}

return res

}

复杂度分析:
  • 时间复杂度:O(n2)

  • 空间复杂度:O(n2)

7.7 最小路径和

给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

在这里插入图片描述

示例 1:

输入:grid = [[1,3,1],[1,5,1],[4,2,1]]

输出:7

解释:因为路径 1→3→1→1→1 的总和最小。

示例 2:

输入:grid = [[1,2,3],[4,5,6]]

输出:12

提示:
  • m == grid.length

  • n == grid[i].length

  • 1 <= m, n <= 200

  • 0 <= grid[i][j] <= 100

1、DP方程当前项最小路径和 = 当前项值 + 上项或左项中的最小值 grid[i][j] += Math.min( grid[i - 1][j], grid[i][j - 1] )

2、边界处理grid的第一行与第一列 分别没有上项与左项 故单独处理计算起项最小路径和 计算第一行:

for(let j = 1; j < col; j++) grid[0][j] += grid[0][j - 1]

计算第一列:

for(let i = 1; i < row; i++) grid[i][0] += grid[i - 1][0]

3、代码实现

var minPathSum = function(grid) {

let row = grid.length, col = grid[0].length

// calc boundary

for(let i = 1; i < row; i++)

// calc first col

grid[i][0] += grid[i - 1][0]

for(let j = 1; j < col; j++)

// calc first row

grid[0][j] += grid[0][j - 1]

for(let i = 1; i < row; i++)

for(let j = 1; j < col; j++)

grid[i][j] += Math.min(grid[i - 1][j], grid[i][j - 1])

return grid[row - 1][col - 1]

};

更多解答

7.8 买卖股票的最佳时机 II

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

[外链图片转存中…(img-ola8Zco7-1635264031443)]

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值