剑指offer
题目总数76
错误总结
初始化错误
错误:
1 |
|
正确:
在C++中,类的成员变量不能在类内部进行初始化,需要在构造函数中或者类外部进行初始化。因此,你不能在类的定义中直接对order
和mark
进行初始化,会导致编译错误。
解决方法是将变量的初始化移到构造函数中,或者在类外部进行初始化。
以下是修正后的代码:
1 |
|
在这个修正后的版本中,我们将order
和mark
的初始化移到了构造函数中,并且将它们从类的定义中移动到了私有成员变量部分。这样就能够正确地初始化这两个向量。
问:
为什么int cnt = 0;
这样的可以呢
GPT:
在C++中,你可以在类的定义内部初始化非静态成员变量,但是这只适用于常规的数据类型(例如int、double等)和某些类类型(例如std::string)。
这种方式的初始化是一种方便的语法糖,它允许你在类的定义中直接指定成员变量的初始值,而不需要在构造函数中显式地初始化它们。编译器会在对象创建时自动调用合适的构造函数来初始化这些成员变量。
例如,下面是一个示例:
1 |
|
在这个示例中,cnt
被初始化为0,这意味着当创建一个MyClass
对象时,它的cnt
成员变量会自动被设置为0。
string生命周期问题
来自下面的 - 树小节中的题目序列化二叉树
实际上,std::string
的c_str()
方法并不返回一个新的char[]
数组。它返回的是一个指向内部数据结构中以'\0'
结尾的字符数组的指针。这个返回的指针是const char*
类型,指向的内存区域由std::string
对象管理,你不应该尝试修改它指向的内容。重要的是,这个指针仅在相关的std::string
对象没有被修改(包括但不限于内容的修改、重新分配内存等)和没有被销毁的情况下保持有效。
关键点:
- 生命周期管理:返回的
const char*
指针所指向的内存是由std::string
对象负责管理的。如果std::string
对象被销毁或其内容被修改(可能导致重新分配内存),那么之前通过c_str()
获得的指针可能会变得无效。 - 非独立的内存区域:
c_str()
方法返回的指针并不指向一个独立分配的内存区域,而是直接指向std::string
内部用于存储字符串的内存。因此,没有必要(也不应该)尝试释放这个指针指向的内存。
new的问题
也是来自下面的 - 树小节中的题目序列化二叉树
这是一个常见的错误,很容易被忽视。在C++中:
new char[ans.length() + 1]
正确地分配了一个字符数组,长度为ans.length() + 1
。new char(ans.length() + 1)
则是分配了单个字符,并用ans.length() + 1
的值来初始化它(这通常不是您想要的结果)。
数组与矩阵
查看代码
1 |
|
查看代码
目标:时间复杂度 O(M + N),空间复杂度 O(1)
-
设置一个索引
i
来表示行,初始值为0
,另一个索引j
来表示列,初始值为n - 1
。 -
当
i < m
且j >= 0
时进行搜索:
- 如果
target
等于matrix[i][j]
,返回true
。 - 如果
target
小于matrix[i][j]
,则target
不可能在当前列中,因此j--
。 - 如果
target
大于matrix[i][j]
,则target
不可能在当前行中,因此i++
。
- 如果
-
如果退出循环,说明没有找到
target
,返回false
。
为什么从右上角开始,因为每一次操作都可以缩小查找范围
1 |
|
查看代码
代码随想录中写过原题
1 |
|
查看代码
代码随想录中写过类似的
1 |
|
查看代码
ASCII实际上确实有128个不同的符号,这是因为它是一个7位的字符编码标准,覆盖了从0到127的值。但是,经常有一种扩展的ASCII版本被称为“扩展ASCII”或“8位ASCII”,它使用了第八位来扩展字符集,允许256个不同的值(从0到255)。
在很多情况下,尽管原始ASCII只定义了128个字符,程序员仍然会创建一个大小为256的数组,来包含可能的扩展ASCII字符。这是一种预防措施,以确保程序可以处理非标准的ASCII值,尤其是那些可能出现在某些特殊文本编码或者用户输入中的字符。
1 |
|
栈队列堆
查看代码
1 |
|
查看代码
1 |
|
查看代码
1 |
|
查看代码
1 |
|
查看代码
1 |
|
- 41.2 字符流中第一个不重复的字符
学习一下这一题的变量初始化
查看代码
比较前面的找到第一个不重复
1 |
|
查看代码
法一:堆
1 |
|
法二:双端队列
双指针
查看代码
法一:双指针
1 |
|
法二:哈希表(代码随想录中刷过)
查看代码
1 |
|
查看代码
1 |
|
查看代码
1 |
|
链表
查看代码
1 |
|
查看代码
1 |
|
查看代码
1 |
|
查看代码
代码随想录类似题:19. 删除链表的倒数第 N 个结点 - 力扣(LeetCode)
1 |
|
- [23. 链表中环的入口结点](https://www.cyc2018.xyz/算法/剑指 Offer 题解/23. 链表中环的入口结点.html)
- 24. 反转链表
查看代码
代码随想录中刷过,用的方法不一样
1 |
|
查看代码
1 |
|
查看代码
1 |
|
查看代码
1 |
|
树
查看代码
1 |
|
查看代码
1 |
|
查看代码
代码随想录做过类似:572. 另一棵树的子树 - 力扣(LeetCode)
注意这题是子结构,不是子树
1 |
|
查看代码
1 |
|
查看代码
1 |
|
查看代码
1 |
|
查看代码
代码随想录中的类似题目:102. 二叉树的层序遍历 - 力扣(LeetCode)
1 |
|
-
32.3 按之字形顺序打印二叉树 (牛客下架了,也没找到别的题)
查看代码
1 |
|
-
34.二叉树中和为某一值的路径(牛客下架了,也没找到别的题)
查看代码
1 |
|
查看代码
其他样例:
1 |
|
1 |
|
-
54.二叉查找树的第 K 个结点 (牛客下架了,也没找到别的题)
查看代码
1 |
|
查看代码
1 |
|
-
可以再看看这题 236. 二叉树的最近公共祖先(代码随想录中刷的)
贪心思想
- 14. 剪绳子 重点学一下这一题的动态规划
查看代码
- 贪心解法:
这个问题实质上转化为了求函数 () 的最大值,其中 () 是变量,() 是常数。解决这个问题的关键是理解,当每段绳子的长度趋近于 ()(自然对数的底数,约等于2.71828)时,乘积 () 达到最大。这是因为数学上可以证明,以 () 为底的对数函数的增长速度与其函数值的增长速度相匹配,从而使得乘积最大化。
但在实际操作中,由于绳子长度必须是整数,我们不能直接用 () 来分割绳子。因此,我们需要找到最接近 (e) 的整数,来近似达到最大乘积。最接近 () 的整数是 2 和 3,而由于 () 的增长速度比 () 要快,所以选择3作为分割绳子的最优长度。
简而言之,虽然基于公式 () 求解时,我们试图找到某个最优的 () 使得 () 最大,但实际的最优策略涉及到将绳子尽可能分为长度为3的段,原因是这样分割接近于使每段长度等于自然对数的底 (),在整数限制下可以获得最大乘积。
ps:如果没有要求整数的话,就变成求极值的问题了
1 |
|
- 动态规划:
动态规划是解决这类优化问题的一个非常有效的方法,特别是当问题可以被分解成一系列重叠的子问题时。对于这个特定的绳子切割问题,动态规划的思想是从底向上(从最小的子问题开始)计算每个长度的绳子的最大乘积,直到计算出原始长度的绳子的最大乘积为止。
动态规划算法的基本步骤如下:
-
定义状态:首先定义一个数组
dp[]
,其中dp[i]
表示长度为i
的绳子切割后得到的最大乘积。 -
初始状态:初始化
dp[1] = 1
,虽然按题目要求,绳子至少要切一刀,但这里初始化dp[1]
是为了后续计算的方便。实际上,由于长度为1的绳子不能切割,这个值在逻辑上是不会被直接使用的。 -
状态转移方程:考虑长度为
i
的绳子,我们可以选择第一刀切割的位置j
(1 <= j < i
),这样绳子就被分成了长度为j
和i-j
的两段。对于这两段绳子,我们可以选择继续切割或不切割,以获得最大乘积,因此dp[i]
可以通过遍历所有可能的j
来计算得出:这里使用
max(j, dp[j])
是因为我们可以选择不再进一步切割长度为j
的那段绳子,即直接取j
本身的长度作为乘积的一部分,或者是切割后的最大乘积dp[j]
。 -
计算顺序:从左到右依次计算
dp[2], dp[3], ..., dp[n]
的值。 -
结果:最终
dp[n]
就是长度为n
的绳子切割后得到的最大乘积。
使用动态规划解决这个问题,当绳子长度为8时,得到的最大乘积确实是18。这个结果与题目中给出的示例相匹配,说明动态规划方法是有效的。
动态规划是一个强大的工具,尤其适用于解决那些可以被分解成重叠子问题的优化问题。这个方法通过避免重复计算相同的子问题来提高效率,从而能够高效地解决许多看似复杂的问题。希望这个解释和示例代码能帮助你复习和加深对动态规划的理解!
1 |
|
问:
我怎么从0得出这个思路?
GPT4:
从零开始构建动态规划(DP)解决方案的思路涉及理解问题的本质、识别重叠子问题、定义状态和决策,并形成状态转移方程。下面是一步步的指南,以帮助你自己从零开始得出动态规划的解决思路:
- 理解问题
首先彻底理解问题的要求和限制。在这个例子中,问题是如何将一根绳子切割成多段,使得这些段的长度乘积最大。
- 分析子问题
观察问题是否可以分解成更小的子问题。对于绳子切割问题,可以考虑每一次切割后剩下的部分作为一个新的子问题,即如果你已经决定了第一刀切在哪里,那么剩下的部分怎么切割以获得最大乘积就成为了一个新问题。
- 定义状态
动态规划的核心在于状态的定义。在这个问题中,状态可以定义为 dp[i]
,表示长度为 i
的绳子切割后得到的最大乘积。
- 找出状态转移方程
考虑如何从已知的状态得到未知的状态。对于绳子切割问题,如果你考虑第一次切割产生的两段绳子,你可以用已知的最大乘积来计算更长绳子的最大乘积。这可以表述为:
这里考虑了所有可能的第一次切割位置 j
,并用已知的最大乘积来计算新的最大乘积。
- 初始化基础状态
确定最简单的子问题,并给出它们的解。对于这个问题,最简单的情况就是长度为1的绳子,虽然它不能切割,但我们可以将 dp[1]
设置为1,以便它能在状态转移方程中使用。
- 确定遍历顺序
根据状态转移方程的依赖关系确定计算状态的顺序。通常情况下,你需要先计算出所有小问题的解,然后才能解决更大的问题。在本例中,你应该从小到大计算 dp[i]
。
- 实现并优化
将上述思路转化为代码。一旦完成基本实现,可以考虑是否有空间或时间优化的可能,比如通过避免不必要的计算来优化。
通过遵循这些步骤,即使对动态规划不太熟悉,你也可以逐步构建出解决复杂问题的DP解决方案。随着练习,你将能更快地识别和定义问题的DP结构。
问:
是需要有一个逐步扩展的过程吗,就像i由2到n
GPT4:
是的,动态规划(DP)的核心思想之一就是逐步构建解,从最小的子问题开始,逐渐扩展到整个问题的解。这个“逐步扩展”的过程通常体现在通过迭代计算从基本情况(通常是最小的子问题)开始,一直到目标问题大小的状态值。在绳子切割问题中,这正是通过逐渐增加绳子长度(即逐步扩展 i
从 2 到 n
)来实现的。
这个过程背后的逻辑是:
-
基础情况:确定一些基础情况(最小的子问题),这些是直接可以知道答案的情况,比如在很多问题中,长度为 0 或 1 的情况。对于绳子切割问题,虽然
dp[1]
实际上不直接用于计算(因为至少要切一刀),但它帮助定义了DP数组的基础。 -
状态转移:通过已解决的子问题来解决更大的问题。每次循环迭代,你考虑一个稍大一点的问题(即绳子的长度),并尝试基于已经计算出的较小问题的解来找到当前问题的解。这意味着每一步都建立在之前步骤的基础上。
-
逐步扩展:从最基本的情况开始,逐步增加问题的规模,直到达到目标规模(
n
)。这个过程是通过外层循环实现的,i
从 2 到n
的遍历就是这个“逐步扩展”的体现。 -
利用之前的计算结果:在计算每一步时,都会利用之前计算的结果。这是动态规划节省时间的关键所在,避免了对同一子问题的重复计算。
通过这种方式,动态规划确保了每个子问题只被解决一次,并且每次解决都是建立在之前已解决的子问题之上,最终达到解决整个问题的目的。这种方法不仅提高了效率,而且通过将问题分解为更易于管理的小块,也简化了复杂问题的解决方案。
问:
为什么会设置这样一个dp数组
GPT4:
选择构建一个动态规划(DP)数组是动态规划方法的核心部分,它出于以下几个关键原因:
- 存储中间结果
DP方法解决问题的方式是将问题分解成一系列重叠的子问题,然后解决每一个子问题。DP数组用于存储这些子问题的解,确保每个子问题只被计算一次。这样,当一个子问题在求解过程中多次遇到时,我们可以直接从DP数组中取得其结果,而不是重新计算。
- 避免重复计算
许多优化问题涉及到大量的重叠子问题。如果不存储这些子问题的解,我们可能会重复解决相同的问题,导致算法的效率极低。DP数组通过记录每个子问题的解,帮助我们避免这种不必要的重复工作,从而显著提高算法的效率。
- 实现状态转移
DP数组不仅仅是用来存储结果。它的结构和组织方式还能帮助我们以一种系统的方式实现状态转移。状态转移方程描述了从一个或多个较小子问题的解如何得到当前问题的解。DP数组使我们能够按照一定的顺序(通常是从小到大)计算并更新状态,确保在计算当前状态时,所有需要的前置状态都已经被计算并存储。
- 简化问题解决方案
DP数组为我们提供了一种将复杂问题简化为易于管理的小块的方法。通过逐步填充DP数组,我们可以逐渐构建出问题的解决方案。这种方法有助于我们直观地理解问题的结构,以及不同子问题是如何组合在一起解决整个问题的。
- 支持反向追踪解决方案
对于某些问题,仅仅知道最优解的值是不够的,我们还可能想知道如何达到这个最优解(例如,切割绳子的最佳方案是什么)。DP数组的结构使得在完成所有计算后,我们可以从最终状态开始反向追踪,找到构建这个最优解的每一步。
总而言之,构建DP数组是实现动态规划方法的基础,它不仅可以帮助我们以高效的方式存储和利用计算结果,而且也是理解和解决问题的关键组成部分。通过DP数组,我们能够将复杂问题分解成小问题,逐步构建解决方案,并最终以高效的方式解决整个问题。
查看代码
要解决这个问题,我们可以采用一种称为“一次遍历”的方法。思路是遍历整个股价数组,记录并更新我们遇到的最低股价,以及在每一天卖出时可能得到的最大利润。我们只需要遍历一次数组,就可以找到买入和卖出的最佳时机,从而获得最大利润。
算法步骤如下:
- 初始化两个变量:
minPrice
(到目前为止的最低股价)和maxProfit
(到目前为止的最大利润)。可以将minPrice
初始化为正无穷大,maxProfit
初始化为0。 - 遍历价格数组
prices
:- 对于每一天的价格
prices[i]
- 更新
minPrice
为min(minPrice, prices[i])
,即当前价格和之前最低价格中的较小值。 - 更新
maxProfit
为max(maxProfit, prices[i] - minPrice)
,即当前价格与最低购买价格之差(如果这个差值大于之前的最大利润,就更新最大利润)。
- 更新
- 对于每一天的价格
- 遍历结束后,
maxProfit
将包含最大利润。如果没有利润可得,这个值将是0。
1 |
|
二分查找
查看代码
注意:有重复数字
其他样例:
1 |
|
1 |
|
查看代码
法一:时间复杂度可能会退化为O(n);
1 |
|
法二:二分找到左右两边的k就能直接知道数量
如果要使用两次二分查找来分别找到k
第一次和最后一次出现的位置,进而计算出k
在数组中出现的次数,你可以按照下面的方式实现。这种方法会确保整体的时间复杂度为O(log n)
。
1 |
|
这段代码首先定义了findFirstK
和findLastK
两个函数来分别找到k
第一次和最后一次出现的位置。这两个函数通过二分查找实现,区别仅在于在找到一个k
后如何决定是返回当前位置还是继续查找。在findFirstK
中,如果找到一个k
且它是数组的第一个元素或其前一个元素不是k
,那么它就是第一个k
;在findLastK
中,如果找到一个k
且它是数组的最后一个元素或其后一个元素不是k
,那么它就是最后一个k
。这样,通过计算这两个位置的差值加一,就可以得到k
在数组中出现的总次数。
分治
查看代码
1 |
|
搜索
-
12.矩阵中的路径 (牛客下架了,也没找到别的题)
查看代码
1 |
|
查看代码
其他样例:
1 |
|
1 |
|
排序
查看代码
1 |
|
查看代码
其他样例:
1 |
|
1 |
|
1 |
|
查看代码
一点思路没有,问GPT了
GPT4:使用归并排序
通过一个具体的例子,比如数组 [1, 20, 6, 4, 5]
,来模拟整个归并排序和计算逆序对的过程。
初始数组:[1, 20, 6, 4, 5]
-
分割数组直到每个子数组只有一个元素,因为单个元素的数组自然是有序的,不需要比较。
- 分割成
[1, 20, 6]
和[4, 5]
- 再分
[1, 20, 6]
为[1]
和[20, 6]
;[4, 5]
已经是分不可分。 [20, 6]
分为[20]
和[6]
。
- 分割成
-
合并过程中计算逆序对:
- 合并
[20]
和[6]
,得到[6, 20]
,这里有1个逆序对(20, 6)
。 - 接下来,我们合并
[1]
和[6, 20]
,得到[1, 6, 20]
,没有增加新的逆序对。 - 同时,
[4, 5]
本身已经是有序的,无需计算。 - 最后,合并
[1, 6, 20]
和[4, 5]
,当我们将4
插入到[1, 6, 20]
中时,4
会和6, 20
形成逆序对,共2个新的逆序对。当5
被插入时,它也会与6, 20
形成逆序对,再增加2个逆序对。
- 合并
通过上述分解和合并的过程,我们可以详细追踪逆序对的形成:
- 在合并
[20]
和[6]
时形成1个逆序对。 - 在最后一步合并
[1, 6, 20]
和[4, 5]
时,4
和5
分别与[6, 20]
形成2个和2个逆序对,共4个。
所以,对于初始数组 [1, 20, 6, 4, 5]
,总共有5个逆序对。
这个过程展示了如何通过分割数组直到它们变得足够小,然后在合并的过程中计算逆序对的数量,同时也保持了数组的有序性。归并排序不仅帮助我们排序,而且在合并过程中很自然地就计算出了逆序对的数量。
1 |
|
动态规划
查看代码
1 |
|
查看代码
1 |
|
可以明显看出来上面就是斐波那契数列,那我们可以将空间复杂度降为O(1):
小小的区别就是,初值不一样
1 |
|
查看代码
和前两题几乎一样
1 |
|
查看代码
动态规划:
1 |
|
进阶的要求:
数学推导
跳上 n-1 级台阶,可以从 n-2 级跳 1 级上去,也可以从 n-3 级跳 2 级上去…,那么
同样,跳上 n 级台阶,可以从 n-1 级跳 1 级上去,也可以从 n-2 级跳 2 级上去… ,那么
综上可得
即
1 |
|
查看代码
样例 1
输入:
1 |
|
预期输出:6800
样例 2
输入:
1 |
|
预期输出:8100
1 |
|
-
[49. 丑数](https://www.cyc2018.xyz/算法/剑指 Offer 题解/49. 丑数.html)
-
[60. n 个骰子的点数](https://www.cyc2018.xyz/算法/剑指 Offer 题解/60. n 个骰子的点数.html)
-
[66. 构建乘积数组](https://www.cyc2018.xyz/算法/剑指 Offer 题解/66. 构建乘积数组.html)
数学
查看代码
1 |
|
-
[62. 圆圈中最后剩下的数](https://www.cyc2018.xyz/算法/剑指 Offer 题解/62. 圆圈中最后剩下的数.html)
-
[43. 从 1 到 n 整数中 1 出现的次数](https://www.cyc2018.xyz/算法/剑指 Offer 题解/43. 从 1 到 n 整数中 1 出现的次数.html)
位运算
查看代码
1 |
|
- [56. 数组中只出现一次的数字](https://www.cyc2018.xyz/算法/剑指 Offer 题解/56. 数组中只出现一次的数字.html)
其它
查看代码
感觉这题和这个网站作者想要我们练习的不一样
1 |
|
- 19.正则表达式匹配 (牛客下架了,也没找到别的题)
- [20. 表示数值的字符串](https://www.cyc2018.xyz/算法/剑指 Offer 题解/20. 表示数值的字符串.html)
- 44. 数字序列中的某一位数字 需要用long long
查看代码
看题解析写的
具体做法:
- step 1:通过对每个区间起点数字的计算,按照上述规律求得该区间的位数,n不断减去它前面区间的位数,定位到属于它的区间。
- step 2:通过除以位数定位n在哪个数字上,用字符串形式表示。
- step 3:通过在字符串上位置对几位数取模定位目标数字。
图示:
用例输入 :
1 |
|
预期输出:
1 |
|
1 |
|
- [46. 把数字翻译成字符串](https://www.cyc2018.xyz/算法/剑指 Offer 题解/46. 把数字翻译成字符串.html)
- [61. 扑克牌顺子](https://www.cyc2018.xyz/算法/剑指 Offer 题解/61. 扑克牌顺子.html)
- [64. 求 1+2+3+…+n](https://www.cyc2018.xyz/算法/剑指 Offer 题解/64. 求 1+2+3+…+n.html)
- [65. 不用加减乘除做加法](https://www.cyc2018.xyz/算法/剑指 Offer 题解/65. 不用加减乘除做加法.html)
- [67. 把字符串转换成整数](https://www.cyc2018.xyz/算法/剑指 Offer 题解/67. 把字符串转换成整数.html)