力扣加加 - 努力做西湖区最好的算法题解
  • introduction
  • 第一章 - 算法专题
    • 数据结构
    • 链表专题
    • 树专题
    • 堆专题(上)
    • 堆专题(下)
    • 二分专题(上)
    • 二分专题(下)
    • 动态规划(重置版)
    • 大话搜索
    • 二叉树的遍历
    • 哈夫曼编码和游程编码
    • 布隆过滤器
    • 前缀树
    • 回溯
    • 滑动窗口(思路 + 模板)
    • 位运算
    • 小岛问题
    • 最大公约数
    • 并查集
    • 平衡二叉树专题
    • 蓄水池抽样
    • 单调栈
  • 第二章 - 91 天学算法
    • 91 天学算法第三期视频会议总结
    • 第一期讲义-二分法
    • 第一期讲义-双指针
    • 第三期正在火热进行中
  • 第三章 - 精选题解
    • 字典序列删除
    • 西法的刷题秘籍】一次搞定前缀和
    • 字节跳动的算法面试题是什么难度?
    • 字节跳动的算法面试题是什么难度?(第二弹)
    • 《我是你的妈妈呀》 * 第一期
    • 一文带你看懂二叉树的序列化
    • 穿上衣服我就不认识你了?来聊聊最长上升子序列
    • 你的衣服我扒了 * 《最长公共子序列》
    • 一文看懂《最大子序列和问题》
  • 第四章 - 高频考题(简单)
    • 面试题 17.12. BiNode
    • 0001. 两数之和
    • 0020. 有效的括号
    • 0021. 合并两个有序链表
    • 0026. 删除排序数组中的重复项
    • 0053. 最大子序和
    • 0160. 相交链表
    • 0066. 加一
    • 0088. 合并两个有序数组
    • 0101. 对称二叉树
    • 0104. 二叉树的最大深度
    • 0108. 将有序数组转换为二叉搜索树
    • 0121. 买卖股票的最佳时机
    • 0122. 买卖股票的最佳时机 II
    • 0125. 验证回文串
    • 0136. 只出现一次的数字
    • 0155. 最小栈
    • 0167. 两数之和 II 输入有序数组
    • 0169. 多数元素
    • 0172. 阶乘后的零
    • 0190. 颠倒二进制位
    • 0191. 位 1 的个数
    • 0198. 打家劫舍
    • 0203. 移除链表元素
    • 0206. 反转链表
    • 0219. 存在重复元素 II
    • 0226. 翻转二叉树
    • 0232. 用栈实现队列
    • 0263. 丑数
    • 0283. 移动零
    • 0342. 4 的幂
    • 0349. 两个数组的交集
    • 0371. 两整数之和
    • 401. 二进制手表
    • 0437. 路径总和 III
    • 0455. 分发饼干
    • 0504. 七进制数
    • 0575. 分糖果
    • 0665. 非递减数列
    • 0661. 图片平滑器
    • 821. 字符的最短距离
    • 0874. 模拟行走机器人
    • 1128. 等价多米诺骨牌对的数量
    • 1260. 二维网格迁移
    • 1332. 删除回文子序列
    • 2591. 将钱分给最多的儿童
  • 第五章 - 高频考题(中等)
    • 面试题 17.09. 第 k 个数
    • 面试题 17.23. 最大黑方阵
    • 面试题 16.16. 部分排序
    • Increasing Digits
    • Longest Contiguously Strictly Increasing Sublist After Deletion
    • Consecutive Wins
    • Number of Substrings with Single Character Difference
    • Bus Fare
    • Minimum Dropping Path Sum
    • Every Sublist Min Sum
    • Maximize the Number of Equivalent Pairs After Swaps
    • 0002. 两数相加
    • 0003. 无重复字符的最长子串
    • 0005. 最长回文子串
    • 0011. 盛最多水的容器
    • 0015. 三数之和
    • 0017. 电话号码的字母组合
    • 0019. 删除链表的倒数第 N 个节点
    • 0022. 括号生成
    • 0024. 两两交换链表中的节点
    • 0029. 两数相除
    • 0031. 下一个排列
    • 0033. 搜索旋转排序数组
    • 0039. 组合总和
    • 0040. 组合总和 II
    • 0046. 全排列
    • 0047. 全排列 II
    • 0048. 旋转图像
    • 0049. 字母异位词分组
    • 0050. Pow(x, n)
    • 0055. 跳跃游戏
    • 0056. 合并区间
    • 0060. 第 k 个排列
    • 0061. 旋转链表
    • 0062. 不同路径
    • 0073. 矩阵置零
    • 0075. 颜色分类
    • 0078. 子集
    • 0079. 单词搜索
    • 0080. 删除排序数组中的重复项 II
    • 0086. 分隔链表
    • 0090. 子集 II
    • 0091. 解码方法
    • 0092. 反转链表 II
    • 0094. 二叉树的中序遍历
    • 0095. 不同的二叉搜索树 II
    • 0096. 不同的二叉搜索树
    • 0098. 验证二叉搜索树
    • 0102. 二叉树的层序遍历
    • 0103. 二叉树的锯齿形层次遍历
    • 0113. 路径总和 II
    • 0129. 求根到叶子节点数字之和
    • 0130. 被围绕的区域
    • 0131. 分割回文串
    • 0139. 单词拆分
    • 0144. 二叉树的前序遍历
    • 0147. 对链表进行插入排序
    • 0150. 逆波兰表达式求值
    • 0152. 乘积最大子数组
    • 0153. 寻找旋转排序数组中的最小值
    • 0199. 二叉树的右视图
    • 0200. 岛屿数量
    • 0201. 数字范围按位与
    • 0208. 实现 Trie (前缀树)
    • 0209. 长度最小的子数组
    • 0211. 添加与搜索单词 - 数据结构设计
    • 0215. 数组中的第 K 个最大元素
    • 0220. 存在重复元素 III
    • 0221. 最大正方形
    • 0227. 基本计算器 II
    • 0229. 求众数 II
    • 0230. 二叉搜索树中第 K 小的元素
    • 0236. 二叉树的最近公共祖先
    • 0238. 除自身以外数组的乘积
    • 0240. 搜索二维矩阵 II
    • 0279. 完全平方数
    • 0309. 最佳买卖股票时机含冷冻期
    • 0322. 零钱兑换
    • 0324. 摆动排序 II
    • 0328. 奇偶链表
    • 0331. 验证二叉树的前序序列化
    • 0334. 递增的三元子序列
    • 0337. 打家劫舍 III
    • 0343. 整数拆分
    • 0365. 水壶问题
    • 0378. 有序矩阵中第 K 小的元素
    • 0380. 常数时间插入、删除和获取随机元素
    • 0394. 字符串解码
    • 0416. 分割等和子集
    • 0424. 替换后的最长重复字符
    • 0438. 找到字符串中所有字母异位词
    • 0445. 两数相加 II
    • 0454. 四数相加 II
    • 0456. 132 模式
    • 0457.457. 环形数组是否存在循环
    • 0464. 我能赢么
    • 0470. 用 Rand7() 实现 Rand10
    • 0473. 火柴拼正方形
    • 0494. 目标和
    • 0516. 最长回文子序列
    • 0513. 找树左下角的值
    • 0518. 零钱兑换 II
    • 0525. 连续数组
    • 0547. 朋友圈
    • 0560. 和为 K 的子数组
    • 0609. 在系统中查找重复文件
    • 0611. 有效三角形的个数
    • 0673. 最长递增子序列的个数
    • 0686. 重复叠加字符串匹配
    • 0710. 黑名单中的随机数
    • 0714. 买卖股票的最佳时机含手续费
    • 0718. 最长重复子数组
    • 0735. 行星碰撞
    • 0754. 到达终点数字
    • 0785. 判断二分图
    • 0790. 多米诺和托米诺平铺
    • 0799. 香槟塔
    • 0801. 使序列递增的最小交换次数
    • 0816. 模糊坐标
    • 0820. 单词的压缩编码
    • 0838. 推多米诺
    • 0873. 最长的斐波那契子序列的长度
    • 0875. 爱吃香蕉的珂珂
    • 0877. 石子游戏
    • 0886. 可能的二分法
    • 0898. 子数组按位或操作
    • 0900. RLE 迭代器
    • 0911. 在线选举
    • 0912. 排序数组
    • 0932. 漂亮数组
    • 0935. 骑士拨号器
    • 0947. 移除最多的同行或同列石头
    • 0959. 由斜杠划分区域
    • 0978. 最长湍流子数组
    • 0987. 二叉树的垂序遍历
    • 1004. 最大连续 1 的个数 III
    • 1011. 在 D 天内送达包裹的能力
    • 1014. 最佳观光组合
    • 1015. 可被 K 整除的最小整数
    • 1019. 链表中的下一个更大节点
    • 1020. 飞地的数量
    • 1023. 驼峰式匹配
    • 1031. 两个非重叠子数组的最大和
    • 1043. 分隔数组以得到最大和
    • 1053. 交换一次的先前排列)
    • 1104. 二叉树寻路
    • 1129. 颜色交替的最短路径
    • 1131.绝对值表达式的最大值
    • 1138. 字母板上的路径
    • 1186. 删除一次得到子数组最大和
    • 1218. 最长定差子序列
    • 1227. 飞机座位分配概率
    • 1261. 在受污染的二叉树中查找元素
    • 1262. 可被三整除的最大和
    • 1297. 子串的最大出现次数
    • 1310. 子数组异或查询
    • 1334. 阈值距离内邻居最少的城市
    • 1371.每个元音包含偶数次的最长子字符串
    • 1381. 设计一个支持增量操作的栈
    • 1438. 绝对差不超过限制的最长连续子数组
    • 1558. 得到目标数组的最少函数调用次数
    • 1574. 删除最短的子数组使剩余数组有序
    • 1631. 最小体力消耗路径
    • 1638. 统计只差一个字符的子串数目
    • 1658. 将 x 减到 0 的最小操作数
    • 1697. 检查边长度限制的路径是否存在
    • 1737. 满足三条件之一需改变的最少字符数
    • 1770. 执行乘法运算的最大分数
    • 1793. 好子数组的最大分数
    • 1834. 单线程 CPU
    • 1899. 合并若干三元组以形成目标三元组
    • 1904. 你完成的完整对局数
    • 1906. 查询差绝对值的最小值
    • 1906. 查询差绝对值的最小值
    • 2007. 从双倍数组中还原原数组
    • 2008. 出租车的最大盈利
    • 2100. 适合打劫银行的日子
    • 2101. 引爆最多的炸弹
    • 2121. 相同元素的间隔之和
    • 2207. 字符串中最多数目的子字符串
    • 2592. 最大化数组的伟大值
    • 2593. 标记所有元素后数组的分数
    • 2817. 限制条件下元素之间的最小绝对差
    • 2865. 美丽塔 I
    • 2866. 美丽塔 II
    • 2939. 最大异或乘积
    • 3377. 使两个整数相等的数位操作
    • 3404. 统计特殊子序列的数目
    • 3428. 至多 K 个子序列的最大和最小和
  • 第六章 - 高频考题(困难)
    • LCP 20. 快速公交
    • LCP 21. 追逐游戏
    • Number Stream to Intervals
    • Triple-Inversion
    • Kth-Pair-Distance
    • Minimum-Light-Radius
    • Largest Equivalent Set of Pairs
    • Ticket-Order.md
    • Connected-Road-to-Destination
    • 0004. 寻找两个正序数组的中位数
    • 0023. 合并 K 个升序链表
    • 0025. K 个一组翻转链表
    • 0030. 串联所有单词的子串
    • 0032. 最长有效括号
    • 0042. 接雨水
    • 0052. N 皇后 II
    • 0057. 插入区间
    • 0065. 有效数字
    • 0084. 柱状图中最大的矩形
    • 0085. 最大矩形
    • 0087. 扰乱字符串
    • 0124. 二叉树中的最大路径和
    • 0128. 最长连续序列
    • 0132. 分割回文串 II
    • 0140. 单词拆分 II
    • 0145. 二叉树的后序遍历
    • 0146. LRU 缓存机制
    • 0154. 寻找旋转排序数组中的最小值 II
    • 0212. 单词搜索 II
    • 0239. 滑动窗口最大值
    • 0295. 数据流的中位数
    • 0297. 二叉树的序列化与反序列化
    • 0301. 删除无效的括号
    • 0312. 戳气球
    • 330. 按要求补齐数组
    • 0335. 路径交叉
    • 0460. LFU 缓存
    • 0472. 连接词
    • 0480. 滑动窗口中位数
    • 0483. 最小好进制
    • 0488. 祖玛游戏
    • 0493. 翻转对
    • 0664. 奇怪的打印机
    • 0679. 24 点游戏
    • 0715. Range 模块
    • 0726. 原子的数量
    • 0768. 最多能完成排序的块 II
    • 0805. 数组的均值分割
    • 0839. 相似字符串组
    • 0887. 鸡蛋掉落
    • 0895. 最大频率栈
    • 0975. 奇偶跳
    • 0995. K 连续位的最小翻转次数
    • 1032. 字符流
    • 1168. 水资源分配优化
    • 1178. 猜字谜
    • 1203. 项目管理
    • 1255. 得分最高的单词集合
    • 1345. 跳跃游戏 IV
    • 1449. 数位成本和为目标值的最大数字
    • 1494. 并行课程 II
    • 1521. 找到最接近目标值的函数值
    • 1526. 形成目标数组的子数组最少增加次数
    • 1639. 通过给定词典构造目标字符串的方案数
    • 1649. 通过指令创建有序数组
    • 1671. 得到山形数组的最少删除次数
    • 1707. 与数组中元素的最大异或值
    • 1713. 得到子序列的最少操作次数
    • 1723. 完成所有工作的最短时间
    • 1787. 使所有区间的异或结果为零
    • 1835. 所有数对按位与结果的异或和
    • 1871. 跳跃游戏 VII
    • 1872. 石子游戏 VIII
    • 1883. 准时抵达会议现场的最小跳过休息次数
    • 1970. 你能穿过矩阵的最后一天
    • 2009. 使数组连续的最少操作数
    • 2025. 分割数组的最多方案数
    • 2030. 含特定字母的最小子序列
    • 2102. 序列顺序查询
    • 2141. 同时运行 N 台电脑的最长时间
    • 2179. 统计数组中好三元组数目 👍
    • 2209. 用地毯覆盖后的最少白色砖块
    • 2281.sum-of-total-strength-of-wizards
    • 2306. 公司命名
    • 2312. 卖木头块
    • 2842. 统计一个字符串的 k 子序列美丽值最大的数目
    • 2972. 统计移除递增子数组的数目 II
    • 3027. 人员站位的方案数 II
    • 3041. 修改数组后最大化数组中的连续元素数目
    • 3082. 求出所有子序列的能量和
    • 3108. 带权图里旅途的最小代价
    • 3347. 执行操作后元素的最高频率 II
    • 3336. 最大公约数相等的子序列数量
    • 3410. 删除所有值为某个元素后的最大子数组和
  • 后序
由 GitBook 提供支持
在本页
  • 搜索的核心是什么?
  • 状态空间
  • DFS 和 BFS
  • DFS
  • BFS
  • DFS 和 BFS 的对比
  • 总结

这有帮助吗?

  1. 第一章 - 算法专题

大话搜索

搜索一般指在有限的状态空间中进行枚举,通过穷尽所有的可能来找到符合条件的解或者解的个数。根据搜索方式的不同,搜索算法可以分为 DFS,BFS,A*算法等。这里只介绍 DFS 和 BFS,以及发生在 DFS 上一种技巧-回溯。

搜索问题覆盖面非常广泛,并且在算法题中也占据了很高的比例。我甚至还在公开演讲中提到了 前端算法面试中搜索类占据了很大的比重,尤其是国内公司。

搜索专题中的子专题有很多,而大家所熟知的 BFS,DFS 只是其中特别基础的内容。除此之外,还有状态记录与维护,剪枝,联通分量,拓扑排序等等。这些内容,我会在这里一一给大家介绍。

另外即使仅仅考虑 DFS 和 BFS 两种基本算法,里面能玩的花样也非常多。比如 BFS 的双向搜索,比如 DFS 的前中后序,迭代加深等等。

关于搜索,其实在二叉树部分已经做了介绍了。而这里的搜索,其实就是进一步的泛化。数据结构不再局限于前面提到的数组,链表或者树。而扩展到了诸如二维数组,多叉树,图等。不过核心仍然是一样的,只不过数据结构发生了变化而已。

搜索的核心是什么?

实际上搜索题目本质就是将题目中的状态映射为图中的点,将状态间的联系映射为图中的边。根据题目信息构建状态空间,然后对状态空间进行遍历,遍历过程需要记录和维护状态,并通过剪枝和数据结构等提高搜索效率。

状态空间的数据结构不同会导致算法不同。比如对数组进行搜索,和对树,图进行搜索就不太一样。

再次强调一下,我这里讲的数组,树和图是状态空间的逻辑结构,而不是题目给的数据结构。比如题目给了一个数组,让你求数组的搜索子集。虽然题目给的线性的数据结构数组,然而实际上我们是对树这种非线性数据结构进行搜索。这是因为这道题对应的状态空间是非线性的。

对于搜索问题,我们核心关注的信息有哪些?又该如何计算呢?这也是搜索篇核心关注的。而市面上很多资料讲述的不是很详细。搜索的核心需要关注的指标有很多,比如树的深度,图的 DFS 序,图中两点间的距离等等。这些指标都是完成高级算法必不可少的,而这些指标可以通过一些经典算法来实现。这也是为什么我一直强调一定要先学习好基础的数据结构与算法的原因。

不过要讲这些讲述完整并非容易,以至于如果完整写完可能需要花很多的时间,因此一直没有动手去写。

另外由于其他数据结构都可以看做是图的特例。因此研究透图的基本思想,就很容易将其扩展到其他数据结构上,比如树。因此我打算围绕图进行讲解,并逐步具象化到其他特殊的数据结构,比如树。

状态空间

结论先行:状态空间其实就是一个图结构,图中的节点表示状态,图中的边表示状态之前的联系,这种联系就是题目给出的各种关系。

搜索题目的状态空间通常是非线性的。比如上面提到的例子:求一个数组的子集。这里的状态空间实际上就是数组的各种组合。

对于这道题来说,其状态空间的一种可行的划分方式为:

  • 长度为 1 的子集

  • 长度为 2 的子集

  • 。。。

  • 长度为 n 的子集(其中 n 为数组长度)

而如何确定上面所有的子集呢。

一种可行的方案是可以采取类似分治的方式逐一确定。

比如我们可以:

  • 先确定某一种子集的第一个数是什么

  • 再确定第二个数是什么

  • 。。。

如何确定第一个数,第二个数。。。呢?

暴力枚举所有可能就可以了。

这就是搜索问题的核心,其他都是辅助,所以这句话请务必记住。

所谓的暴力枚举所有可能在这里就是尝试数组中所有可能的数字。

  • 比如第一个数是什么?很明显可能是数组中任意一项。ok,我们就枚举 n 种情况。

  • 第二个数呢?很明显可以是除了上面已经被选择的数之外的任意一个数。ok,我们就枚举 n - 1 种情况。

据此,你可以画出如下的决策树。

(下图描述的是对一个长度为 3 的数组进行决策的部分过程,树节点中的数字表示索引。即确定第一个数有三个选择,确定第二个数会根据上次的选择变为剩下的两个选择)

决策过程动图演示:

一些搜索算法就是基于这个朴素的思想,本质就是模拟这个决策树。这里面其实也有很多有趣的细节,后面我们会对其进行更加详细的讲解。而现在大家只需要对解空间是什么以及如何对解空间进行遍历有一点概念就行了。 后面我会继续对这个概念进行加深。

这里大家只要记住状态空间就是图,构建状态空间就是构建图。如何构建呢?当然是根据题目描述了 。

DFS 和 BFS

DFS 和 BFS 是搜索的核心,贯穿搜索篇的始终,因此有必要先对其进行讲解。

DFS

DFS 的概念来自于图论,但是搜索中 DFS 和图论中 DFS 还是有一些区别,搜索中 DFS 一般指的是通过递归函数实现暴力枚举。

如果不使用递归,也可以使用栈来实现。不过本质上是类似的。

首先将题目的状态空间映射到一张图,状态就是图中的节点,状态之间的联系就是图中的边,那么 DFS 就是在这种图上进行深度优先的遍历。而 BFS 也是类似,只不过遍历的策略变为了广度优先,一层层铺开而已。所以BFS 和 DFS 只是遍历这个状态图的两种方式罢了,如何构建状态图才是关键。

本质上,对上面的图进行遍历的话会生成一颗搜索树。为了避免重复访问,我们需要记录已经访问过的节点。这些是所有的搜索算法共有的,后面不再赘述。

如果你是在树上进行遍历,是不会有环的,也自然不需要为了避免环的产生记录已经访问的节点,这是因为树本质上是一个简单无环图。

算法流程

  1. 首先将根节点放入stack中。

  2. 从stack中取出第一个节点,并检验它是否为目标。如果找到目标,则结束搜寻并回传结果。否则将它某一个尚未检验过的直接子节点加入stack中。

  3. 重复步骤 2。

  4. 如果不存在未检测过的直接子节点。将上一级节点加入stack中。 重复步骤 2。

  5. 重复步骤 4。

  6. 若stack为空,表示整张图都检查过了——亦即图中没有欲搜寻的目标。结束搜寻并回传“找不到目标”。

这里的 stack 可以理解为自实现的栈,也可以理解为调用栈

算法模板

下面我们借助递归来完成 DFS。

const visited = {}
function dfs(i) {
	if (满足特定条件){
		// 返回结果 or 退出搜索空间
	}

	visited[i] = true // 将当前状态标为已搜索
	for (根据i能到达的下个状态j) {
		if (!visited[j]) { // 如果状态j没有被搜索过
			dfs(j)
		}
	}
}

常用技巧

前序遍历与后序遍历

DFS 常见的形式有前序和后序。二者的使用场景也是截然不同的。

上面讲述了搜索本质就是在状态空间进行遍历,空间中的状态可以抽象为图中的点。那么如果搜索过程中,当前点的结果需要依赖其他节点(大多数情况都会有依赖),那么遍历顺序就变得重要。

比如当前节点需要依赖其子节点的计算信息,那么使用后序遍历自底向上递推就显得必要了。而如果当前节点需要依赖其父节点的信息,那么使用先序遍历进行自顶向下的递归就不难想到。

比如下文要讲的计算树的深度。由于树的深度的递归公式为: $f(x) = f(y) + 1$。其中 f(x) 表示节点 x 的深度,并且 x 是 y 的子节点。很明显这个递推公式的 base case 就是根节点深度为一,通过这个 base case 我们可以递推求出树中任意节点的深度。显然,使用先序遍历自顶向下的方式统计是简单而又直接的。

再比如下文要讲的计算树的子节点个数。由于树的子节点递归公式为: $f(x) = sum_{i=0}^{n}{f(a_i)}$ 其中 x 为树中的某一个节点,$a_i$ 为树中节点的子节点。而 base case 则是没有任何子节点(也就是叶子节点),此时 $f(x) = 1$。 因此我们可以利用后序遍历自底向上来完成子节点个数的统计。

迭代加深

迭代加深本质上是一种可行性的剪枝。关于剪枝,我会在后面的《回溯与剪枝》部分做更多的介绍。

所谓迭代加深指的是在递归树比较深的时候,通过设定递归深度阈值,超过阈值就退出的方式主动减少递归深度的优化手段。这种算法成立的前提是题目中告诉我们答案不超过 xxx,这样我们可以将 xxx 作为递归深度阈值,这样不仅不会错过正确解,还能在极端情况下有效减少不必须的运算。

具体地,我们可以使用自顶向下的方式记录递归树的层次,和上面介绍如何计算树深度的方法是一样的。接下来在主逻辑前增加当前层次是否超过阈值的判断即可。

主代码:

MAX_LEVEL = 20
def dfs(root, level):
    if level > MAX_LEVEL: return
    # 主逻辑
dfs(root, 0)

这种技巧在实际使用中并不常见,不过在某些时候能发挥意想不到的作用。

双向搜索

有时候问题规模很大,直接搜索会超时。此时可以考虑从起点搜索到问题规模的一半。然后将此过程中产生的状态存起来。接下来目标转化为在存储的中间状态中寻找满足条件的状态。进而达到降低时间复杂度的效果。

上面的说法可能不太容易理解。 接下来通过一个例子帮助大家理解。

题目地址

https://leetcode-cn.com/problems/closest-subsequence-sum/

题目描述

给你一个整数数组 nums 和一个目标值 goal 。

你需要从 nums 中选出一个子序列,使子序列元素总和最接近 goal 。也就是说,如果子序列元素和为 sum ,你需要 最小化绝对差 abs(sum - goal) 。

返回 abs(sum - goal) 可能的 最小值 。

注意,数组的子序列是通过移除原始数组中的某些元素(可能全部或无)而形成的数组。

 

示例 1:

输入:nums = [5,-7,3,5], goal = 6
输出:0
解释:选择整个数组作为选出的子序列,元素和为 6 。
子序列和与目标值相等,所以绝对差为 0 。
示例 2:

输入:nums = [7,-9,15,-2], goal = -5
输出:1
解释:选出子序列 [7,-9,-2] ,元素和为 -4 。
绝对差为 abs(-4 - (-5)) = abs(1) = 1 ,是可能的最小值。
示例 3:

输入:nums = [1,2,3], goal = -7
输出:7
 

提示:

1 <= nums.length <= 40
-10^7 <= nums[i] <= 10^7
-10^9 <= goal <= 10^9

思路

从数据范围可以看出,这道题大概率是一个 $O(2^m)$ 时间复杂度的解法,其中 m 是 nums.length 的一半。

为什么?首先如果题目数组长度限制为小于等于 20,那么大概率是一个 $O(2^n)$ 的解法。

如果这个也不知道,建议看一下这篇文章 https://lucifer.ren/blog/2020/12/21/shuati-silu3/ 另外我的刷题插件 leetcode-cheatsheet 也给出了时间复杂度速查表供大家参考。

将 40 砍半恰好就可以 AC 了。实际上,40 这个数字就是一个强有力的信号。

回到题目中。我们可以用一个二进制位表示原数组 nums 的一个子集,这样用一个长度为 $2^n$ 的数组就可以描述 nums 的所有子集了,这就是状态压缩。一般题目数据范围是 <= 20 都应该想到。

这里 40 折半就是 20 了。

接下来,我们使用动态规划求出所有的子集和。

令 dp[i] 表示选择情况如 i 所示的和。什么是选择情况如 i 所示呢?

比如我要求 nums 的子集和。那么 nums 的子集有 $2^n$ 个,即 nums 中每一个数都有选择和不选择两者情况。因此一共就有$2^n$种。如果用一个数字的二进制来表示这种选择情况,其中 0 表示选择 1 表示不选择,那么一个位数足够的数(二进制位数需要大于 n)可以用来表示一种可能的选择情况。

我们可以枚举数组的每一项,对于每一项我们都考虑将其加入到选择中。那么转移方程为 : dp[(1 << i) + j] = dp[j] + A[i],其中 j 为 i 的子集, i 和 j 的二进制表示的是 nums 的选择情况。

动态规划求子集和代码如下:

def combine_sum(A):
    n = len(A)
    dp = [0] * (1 << n)
    for i in range(n):
        for j in range(1 << i):
            dp[(1 << i) + j] = dp[j] + A[i] # 加 i 加入选择
    return dp

接下来,我们将 nums 平分为两部分,分别计算子集和:

n = len(nums)
c1 = combine_sum(nums[: n // 2])
c2 = combine_sum(nums[n // 2 :])

其中 c1 就是前半部分数组的子集和,c2 就是后半部分的子集和。

接下来问题转化为:在两个数组 c1 和 c2中找两个数,其和最接近 goal。而这是一个非常经典的双指针问题,逻辑类似两数和。

只不过两数和是一个数组挑两个数,这里是两个数组分别挑一个数罢了。

这里其实只需要一个指针指向一个数组的头,另外一个指向另外一个数组的尾即可。

代码不难写出:

def combine_closest(c1, c2):
    # 先排序以便使用双指针
    c1.sort()
    c2.sort()
    ans = float("inf")
    i, j = 0, len(c2) - 1
    while i < len(c1) and j >= 0:
        _sum = c1[i] + c2[j]
        ans = min(ans, abs(_sum - goal))
        if _sum > goal:
            j -= 1
        elif _sum < goal:
            i += 1
        else:
            return 0
    return ans

上面这个代码不懂的多看看两数和。

代码

代码支持:Python3

Python3 Code:

class Solution:
    def minAbsDifference(self, nums: List[int], goal: int) -> int:
        def combine_sum(A):
            n = len(A)
            dp = [0] * (1 << n)
            for i in range(n):
                for j in range(1 << i):
                    dp[(1 << i) + j] = dp[j] + A[i]
            return dp

        def combine_closest(c1, c2):
            c1.sort()
            c2.sort()
            ans = float("inf")
            i, j = 0, len(c2) - 1
            while i < len(c1) and j >= 0:
                _sum = c1[i] + c2[j]
                ans = min(ans, abs(_sum - goal))
                if _sum > goal:
                    j -= 1
                elif _sum < goal:
                    i += 1
                else:
                    return 0
            return ans

        n = len(nums)
        return combine_closest(combine_sum(nums[: n // 2]), combine_sum(nums[n // 2 :]))

复杂度分析

令 n 为数组长度, m 为 $\frac{n}{2}$。

  • 时间复杂度:$O(m*2^m)$

  • 空间复杂度:$O(2^m)$

相关题目推荐:

这道题和双向搜索有什么关系呢?

回一下开头我的话:有时候问题规模很大,直接搜索会超时。此时可以考虑从起点搜索到问题规模的一半。然后将此过程中产生的状态存起来。接下来目标转化为在存储的中间状态中寻找满足条件的状态。进而达到降低时间复杂度的效果。

对应这道题,我们如果直接暴力搜索。那就是枚举所有子集和,然后找到和 goal 最接近的,思路简单直接。可是这样会超时,那么就搜索到一半, 然后将状态存起来(对应这道题就是存到了 dp 数组)。接下来问题转化为两个 dp 数组的运算。该算法,本质上是将位于指数位的常数项挪动到了系数位。这是一种常见的双向搜索,我姑且称为 DFS 的双向搜索。目的是为了和后面的 BFS 双向搜索进行区分。

BFS

BFS 也是图论中算法的一种。不同于 DFS, BFS 采用横向搜索的方式,从初始状态一层层展开直到目标状态,在数据结构上通常采用队列结构。

具体地,我们不断从队头取出状态,然后将此状态对应的决策产生的所有新的状态推入队尾,重复以上过程直至队列为空即可。

注意这里有两个关键点:

  1. 将此状态对应的决策。 实际上这句话指的就是状态空间中的图的边,而不管是 DFS 和 BFS 边都是确定的。也就是说不管是 DFS 还是 BFS 这个决策都是一样的。不同的是什么?不同的是进行决策的方向不同。

  2. 所有新的状态推入队尾。上面说 BFS 和 DFS 是进行决策的方向不同。这就可以通过这个动作体现出来。由于直接将所有状态空间中的当前点的邻边放到队尾。由队列的先进先出的特性,当前点的邻边访问完成之前是不会继续向外扩展的。这一点大家可以和 DFS 进行对比。

最简单的 BFS 每次扩展新的状态就增加一步,通过这样一步步逼近答案。其实也就等价于在一个权值为 1 的图上进行 BFS。由于队列的单调性和二值性,当第一次取出目标状态时就是最少的步数。基于这个特性,BFS 适合求解一些最少操作的题目。

关于单调性和二值性,我会在后面的 BFS 和 DFS 的对比那块进行讲解。

前面 DFS 部分提到了不管是什么搜索都需要记录和维护状态,其中一个就是节点访问状态以防止环的产生。而 BFS 中我们常常用来求点的最短距离。值得注意的是,有时候我们会使用一个哈希表 dist 来记录从源点到图中其他点的距离。这个 dist 也可以充当防止环产生的功能,这是因为第一次到达一个点后再次到达此点的距离一定比第一次到达大,利用这点就可知道是否是第一次访问了。

算法流程

  1. 首先将根节点放入队列中。

  2. 从队列中取出第一个节点,并检验它是否为目标。

    • 如果找到目标,则结束搜索并回传结果。

    • 否则将它所有尚未检验过的直接子节点加入队列中。

  3. 若队列为空,表示整张图都检查过了——亦即图中没有欲搜索的目标。结束搜索并回传“找不到目标”。

  4. 重复步骤 2。

算法模板

const visited = {}
function bfs() {
	let q = new Queue()
	q.push(初始状态)
	while(q.length) {
		let i = q.pop()
		if (visited[i]) continue
		for (i的可抵达状态j) {
			if (j 合法) {
				q.push(j)
			}
		}
	}
	// 找到所有合法解
}

常用技巧

双向搜索

题目地址(126. 单词接龙 II)

https://leetcode-cn.com/problems/word-ladder-ii/

题目描述

按字典 wordList 完成从单词 beginWord 到单词 endWord 转化,一个表示此过程的 转换序列 是形式上像 beginWord -> s1 -> s2 -> ... -> sk 这样的单词序列,并满足:

每对相邻的单词之间仅有单个字母不同。
转换过程中的每个单词 si(1 <= i <= k)必须是字典 wordList 中的单词。注意,beginWord 不必是字典 wordList 中的单词。
sk == endWord

给你两个单词 beginWord 和 endWord ,以及一个字典 wordList 。请你找出并返回所有从 beginWord 到 endWord 的 最短转换序列 ,如果不存在这样的转换序列,返回一个空列表。每个序列都应该以单词列表 [beginWord, s1, s2, ..., sk] 的形式返回。

 

示例 1:

输入:beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"]
输出:[["hit","hot","dot","dog","cog"],["hit","hot","lot","log","cog"]]
解释:存在 2 种最短的转换序列:
"hit" -> "hot" -> "dot" -> "dog" -> "cog"
"hit" -> "hot" -> "lot" -> "log" -> "cog"


示例 2:

输入:beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log"]
输出:[]
解释:endWord "cog" 不在字典 wordList 中,所以不存在符合要求的转换序列。


 

提示:

1 <= beginWord.length <= 7
endWord.length == beginWord.length
1 <= wordList.length <= 5000
wordList[i].length == beginWord.length
beginWord、endWord 和 wordList[i] 由小写英文字母组成
beginWord != endWord
wordList 中的所有单词 互不相同

思路

这道题就是我们日常玩的成语接龙游戏。即让你从 beginWord 开始, 接龙的 endWord。让你找到最短的接龙方式,如果有多个,则全部返回。

不同于成语接龙的字首接字尾。这种接龙需要的是下一个单词和上一个单词仅有一个单词不同。

我们可以对问题进行抽象:即构建一个大小为 n 的图,图中的每一个点表示一个单词,我们的目标是找到一条从节点 beginWord 到节点 endWord 的一条最短路径。

这是一个不折不扣的图上 BFS 的题目。套用上面的解题模板可以轻松解决。唯一需要注意的是如何构建图。更进一步说就是如何构建边。

由题目信息的转换规则:每对相邻的单词之间仅有单个字母不同。不难知道,如果两个单词的仅有单个字母不同 ,就说明两者之间有一条边。

明白了这一点,我们就可以构建邻接矩阵了。

核心代码:

neighbors = collections.defaultdict(list)
for word in wordList:
    for i in range(len(word)):
        neighbors[word[:i] + "*" + word[i + 1 :]].append(word)

构建好了图。 BFS 剩下要做的就是明确起点和终点就好了。对于这道题来说,起点是 beginWord,终点是 endWord。

那我们就可以将 beginWord 入队。不断在图上做 BFS,直到第一次遇到 endWord 就好了。

套用上面的 BFS 模板,不难写出如下代码:

这里我用了 cost 而不是 visitd,目的是为了让大家见识多种写法。下面的优化解法会使用 visited 来记录。


class Solution:
    def findLadders(self, beginWord: str, endWord: str, wordList: List[str]) -> List[List[str]]:
        cost = collections.defaultdict(lambda: float("inf"))
        cost[beginWord] = 0
        neighbors = collections.defaultdict(list)
        ans = []

        for word in wordList:
            for i in range(len(word)):
                neighbors[word[:i] + "*" + word[i + 1 :]].append(word)
        q = collections.deque([[beginWord]])

        while q:
            path = q.popleft()
            cur = path[-1]
            if cur == endWord:
                ans.append(path.copy())
            else:
                for i in range(len(cur)):
                    for neighbor in neighbors[cur[:i] + "*" + cur[i + 1 :]]:
                        if cost[cur] + 1 <= cost[neighbor]:
                            q.append(path + [neighbor])
                            cost[neighbor] = cost[cur] + 1
        return ans

当终点可以逆向搜索的时候,我们也可以尝试双向 BFS。更本质一点就是:如果你构建的状态空间的边是双向的,那么就可以使用双向 BFS。

和 DFS 的双向搜索思想是类似的。我们只需要使用两个队列分别存储从起点和终点进行扩展的节点(我称其为起点集与终点集)即可。当起点和终点在某一时刻交汇了,说明找到了一个从起点到终点的路径,其路径长度就是两个队列扩展的路径长度和。

以上就是双向搜索的大体思路。用图来表示就是这样的:

如上图,我们从起点和终点(A 和 Z)分别开始搜索,如果起点的扩展状态和终点的扩展状态重叠(本质上就是队列中的元素重叠了),那么我们就知道了一个从节点到终点的最短路径。

动图演示:

看到这里有必要暂停一下插几句话。


为什么双向搜索就快了?什么情况都会更快么?那为什么不都用双向搜索?有哪些使用条件?

我们一个个回答。

  • 为什么双向搜索更快了?通过上面的图我们发现通常刚开始的时候边比较少,队列中的数据也比较少。而随着搜索的进行,搜索树越来越大, 队列中的节点随之增多。和上面双向搜索类似,这种增长速度很多情况下是指数级别的,而双向搜索可以将指数的常系数移动到多项式系数。如果不使用双向搜索那么搜索树大概是这样的:

可以看出搜索树大了很多,以至于很多点我都画不下,只好用 ”。。。“ 来表示。

  • 什么情况下更快?相比于单向搜索,双向搜索通常更快。当然也有例外,举个极端的例子,假如从起点到终点只有一条路径,那么无论使用单向搜索还是双向搜索结果都是一样。

如图使用单向搜索还是双向搜索都是一样的。

  • 为什么不都用双向搜索?实际上做题中,我建议大家尽量使用单向搜索,因为写起来更简单,并且大多数都可以通过所有的测试用例。除非你预估到可能会超时或者提交后发现超时的时候再尝试使用双向搜索。

  • 有哪些使用条件?正如前面所说:”终点可以逆向搜索的时候,可以尝试双向 BFS。更本质一点就是:如果你构建的状态空间的边是双向的,那么就可以使用双向 BFS。“


让我们继续回到这道题。为了能够判断两者是否交汇,我们可以使用两个 hashSet 分别存储起点集合终点集。当一个节点既出现起点集又出现在终点集,那就说明出现了交汇。

为了节省代码量以及空间消耗,我没有使用上面的队列,而是直接使用了哈希表来代替队列。这种做法可行的关键仍然是上面提到的队列的二值性和单调性。

由于新一轮的出队列前,队列中的权值都是相同的。因此从左到右遍历或者从右到左遍历,甚至是任意顺序遍历都是无所谓的。(很多题都无所谓)因此使用哈希表而不是队列也是可以的。这点需要引起大家的注意。希望大家对 BFS 的本质有更深的理解。

那我们是不是不需要队列,就用哈希表,哈希集合啥的存就行了?非也!我会在双端队列部分为大家揭晓。

这道题的具体算法:

  • 定义两个队列:q1 和 q2 ,分别从起点和终点进行搜索。

  • 构建邻接矩阵

  • 每次都尝试从 q1 和 q2 中的较小的进行扩展。这样可以达到剪枝的效果。

  • 如果 q1 和 q2 交汇了,则将两者的路径拼接起来即可。

代码

  • 语言支持:Python3

Python3 Code:

 class Solution:
    def findLadders(self, beginWord: str, endWord: str, wordList: list) -> list:
        #  剪枝 1
        if endWord not in wordList: return []
        ans = []
        visited = set()
        q1, q2 = {beginWord: [[beginWord]]}, {endWord: [[endWord]]}
        steps = 0
        # 预处理,空间换时间
        neighbors = collections.defaultdict(list)
        for word in wordList:
            for i in range(len(word)):
                neighbors[word[:i] + "*" + word[i + 1 :]].append(word)
        while q1:
            # 剪枝 2
            if len(q1) > len(q2):
                q1, q2 = q2, q1
            nxt = collections.defaultdict(list)
            for _ in range(len(q1)):
                word, paths = q1.popitem()
                visited.add(word)
                for i in range(len(word)):
                    for neighbor in neighbors[word[:i] + "*" + word[i + 1 :]]:
                        if neighbor in q2:
                            # 从 beginWord 扩展过来的
                            if paths[0][0] == beginWord:
                                ans += [path1 + path2[::-1] for path1 in paths for path2 in q2[neighbor]]
                            # 从 endWord 扩展过来的
                            else:
                                ans += [path2 + path1[::-1] for path1 in paths for path2 in q2[neighbor]]
                        if neighbor in wordList and neighbor not in visited:
                            nxt[neighbor] += [path + [neighbor] for path in paths]
            steps += 1
            # 剪枝 3
            if ans and steps + 2 > len(ans[0]):
                break
            q1 = nxt
        return ans

总结

我想通过这道题给大家传递的知识点很多。分别是:

  • 队列不一定非得是常规的队列,也可以是哈希表等。不过某些情况必须是双端队列,这个等会讲双端队列给大家讲。

  • 双向 BFS 是只适合双向图。也就是说从终点也往前推。

  • 双向 BFS 从较少状态的一端进行扩展可以起到剪枝的效果

  • visitd 和 dist/cost 都可以起到记录点访问情况以防止环的产生的作用。不过 dist 的作用更多,相应空间占用也更大。

双端队列

上面提到了 BFS 本质上可以看做是在一个边权值为 1 的图上进行遍历。实际上,我们可以进行一个简单的扩展。如果图中边权值不全是 1,而是 0 和 1 呢?这样其实我们用到双端队列。

双端队列可以在头部和尾部同时进行插入和删除,而普通的队列仅允许在头部删除,在尾部插入。

使用双端队列,当每次取出一个状态的时候。如果我们可以无代价的进行转移,那么就可以将其直接放在队头,否则放在队尾。由前面讲的队列的单调性和二值性不难得出算法的正确性。而如果状态转移是有代价的,那么就将其放到队尾即可。这也是很多语言提供的内置数据结构是双端队列,而不是队列的原因之一。

如下图:

上面的队列是普通的队列。 而下面的双端队列,可以看出我们在队头插队了一个 B。

动图演示:

思考:如果图对应的权值不是 0 和 1,而是任意正整数呢?

前面我们提到了是不是不需要队列,就用哈希表,哈希集合啥的存就行了? 这里为大家揭秘。不可以的。因为哈希表无法处理这里的权值为 0 的情况。

DFS 和 BFS 的对比

BFS 和 DFS 分别处理什么样的问题?两者究竟有什么样的区别?这些都值得我们认真研究。

简单来说,不管是 DFS 还是 BFS 都是对题目对应的状态空间进行搜索。

具体来说,二者区别在于:

  • DFS 在分叉点会任选一条深入进入,遇到终点则返回,再次返回到分叉口后尝试下一个选择。基于此,我们可以在路径上记录一些数据。由此也可以衍生出很多有趣的东西。

如下图,我们遍历到 A,有三个选择。此时我们可以任意选择一条,比如选择了 B,程序会继续往下进行选择分支 2,3 。。。

如下动图演示了一个典型的 DFS 流程。后面的章节,我们会给大家带来更复杂的图上 DFS。

  • BFS 在分叉点会选择搜索的路径各尝试一次。使用队列来存储待处理的元素时,队列中最多只会有两层的元素,且满足单调性,即相同层的元素在一起。基于这个特点有很多有趣的优化。

如下图,广度优先遍历会将搜索的选择全部选择一遍会才会进入到下一层。和上面一样,我给大家标注了程序执行的一种可能的顺序。

可以发现,和我上面说的一样。右侧的队列始终最多有两层的节点,并且相同层的总在一起,换句话说队列的元素在层上满足单调性。

如下动图演示了一个典型的 BFS 流程。后面的章节,我们会给大家带来更复杂的图上 BFS。

总结

以上就是《搜索篇(上)》的所有内容了。总结一下搜索篇的解题思路:

  • 根据题目信息构建状态空间(图)。

  • 对图进行遍历(BFS 或者 DFS)

  • 记录和维护状态。(比如 visited 维护访问情况, 队列和栈维护状态的决策方向等等)

我们花了大量的篇幅对 BFS 和 DFS 进行了详细的讲解,包括两个的对比。

核心点需要大家注意:

  • DFS 通常都是有递推关系的,而递归关系就是图的边。根据递归关系大家可以选择使用前序遍历或者后序遍历。

  • BFS 由于其单调性,因此适合求解最短距离问题。

  • 。。。

双向搜索的本质是将复杂度的常数项从一个影响较大的位置(比如指数位)移到了影响较小的位置(比如系数位)。

搜索篇知识点比较密集,希望大家多多总结复习。

下一节,我们介绍:

  • 回溯与剪枝。

  • 常用的指标与统计方法。具体包括:

    1. 树的深度与子树大小

    2. 图的 DFS 序

    3. 图的拓扑序

    4. 图的联通分量

下节内容会首发在《91 天学算法》。想参加的可以戳这里了解详情:https://github.com/azl397985856/leetcode/discussions/532

上一页动态规划(重置版)下一页二叉树的遍历

最后更新于2年前

这有帮助吗?

搜索-决策树.svg

关于从递推关系分析使用何种遍历方法, 我在《91 天学算法》中的基础篇中的《模拟,枚举与递推》子专题中对此进行了详细的描述。91 学员可以直接进行查看。关于树的各种遍历方法,我在中进行了详细的介绍。

如果不熟悉状态压缩,可以看下我的这篇文章

双向搜索.svg
双端队列.svg
binary-tree-traversal-dfs
binary-tree-traversal-bfs
树专题
状压 DP 是什么?这篇题解带你入门
16. 最接近的三数之和
1049. 最后一块石头的重量 II
1774. 最接近目标价格的甜点成本