384、难度中等:
方法一:暴力:时间复杂度 O(n^2) 空间复杂度 O(n)
题意:题目要求我们实现 Solution 类的一个构造器和两个方法
1、Solution(int[] nums) 使用整数数组 nums 初始化对象
// 题目传入数组nums
public Solution(int[] nums) {
// array 和 nums 指向同一数组,使用 array 利于在各方法间调用
array = nums;
// 克隆传入数组作为备份,用于 reset
original = nums.clone();
}
2、int[] reset() 重设数组到它的初始状态并返回:
这里涉及到数组间赋值的方式以及各自效果:
(1)直接 array = original; 虽然达到了让 array 各项元素等于original ,但是其中一方的改变会使二者都发生改变,也就是两个数组相等
(2)使用 clone 方法:该方法对于基本数据类型的数组:
一维数组:深克隆;(重新分配空间,并将元素复制过去)
二维数组:浅克隆。(只传递引用)
public int[] reset() {
// array 适用于被乱序的数组,将备份 original 传给它
array = original;
// reset()方法调用以后,原本的array也要变成初始的状态,
// 因此要把 original 赋值给 array。此时 array 和 original 就是指向同一个数组了,
// 下次在 shuffled() 的时候 original 会因 array 的改变而变化,所以现在clone()一下,为 original 重新分配空间
// 成为单独的数组不受 array 影响
original = original.clone();
return array;
}
3、int[] shuffle() 返回数组随机打乱后的结果:
List list = new ArrayLIst();与ArrayList arraylist = new ArrayList();的区别:
如果你将来需要修改ArrayList成LinkedList或其他,你只要作以下简单的修改:
将 List list = new ArrayLIst(); 改为 List list = new LinkedList();
ArrayList是List接口的可变数组的实现,多态形式的一种
Java.util.ArrayList类是一个动态数组类型,ArrayList对象既有数组的特征,也有链表的特征。可以随时从链表中添加或删除一个元素。ArrayList实现了List接口
我们不知道到底有多少个数据元素的时候,就可使用ArrayList;如果知道数据集合有多少个元素,就用数组
private List<Integer> getArrayCopy() {
// 创建一个 ArrayList 动态数组用于接收 array 数组里的元素
List<Integer> asList = new ArrayList<Integer>();
// 循环赋值
for (int i = 0; i < array.length; i++) {
asList.add(array[i]);
}
// 返回的数组具备和 array 相同的各项元素
return asList;
}
public int[] shuffle() {
// 调用 getArrayCopy 方法,用于获得与 array 数组元素相同的数组
List<Integer> aux = getArrayCopy();
// 从下标 0 开始逐个给 array 数组重新赋值,也就是打乱 nums 数组
for (int i = 0; i < array.length; i++) {
// 随机生成一个小于 aux 数组长度的数字(可以看成是随机生成一个有效的数组元素下标)
int removeIdx = rand.nextInt(aux.size());
// 给 array 赋值
array[i] = aux.get(removeIdx);
// 去掉已经被选择过的随机下标以免再次被选中,这也是使用动态数组 ArrayList 的理由
aux.remove(removeIdx);
}
return array;
}
暴力完整代码:
/**
* Your Solution object will be instantiated and called as such:
* 翻译:您的解决方案对象将被实例化并按此方式调用
* Solution obj = new Solution(nums);
* int[] param_1 = obj.reset();
* int[] param_2 = obj.shuffle();
*/
class Solution {
private int[] array;
private int[] original;
// rand 对象用于创造出我们指定类型、范围内的随机数
private Random rand = new Random();
private List<Integer> getArrayCopy() {
List<Integer> asList = new ArrayList<Integer>();
for (int i = 0; i < array.length; i++) {
asList.add(array[i]);
}
return asList;
}
public Solution(int[] nums) {
array = nums;
original = nums.clone();
}
public int[] reset() {
array = original;
original = original.clone();
return array;
}
public int[] shuffle() {
List<Integer> aux = getArrayCopy();
for (int i = 0; i < array.length; i++) {
int removeIdx = rand.nextInt(aux.size());
array[i] = aux.get(removeIdx);
aux.remove(removeIdx);
}
return array;
}
}
方法二: Fisher-Yates 洗牌算法:时空复杂度:O(N)
对于洗牌问题,Fisher-Yates 洗牌算法即是通俗解法,同时也是渐进最优的解法。
原理:
大致思路:我们可以用一个简单的技巧来降低之前算法的时间复杂度和空间复杂度,那就是让数组中的元素互相交换,这样就可以避免掉每次迭代中用于修改列表的时间了。
算法实现:Fisher-Yates 洗牌算法跟暴力算法很像。在每次迭代中,生成一个范围在当前下标到数组末尾元素下标之间的随机整数。接下来,将当前元素和随机选出的下标所指的元素互相交换,这一步模拟了每次从 “帽子” 里面摸一个元素的过程,其中选取下标范围的依据在于每个被摸出的元素都不可能再被摸出来了
(也就是从下标 0 开始遍历,每次遍历到一项时就从当前下标到数组末尾下标中随机选一个将值互换,然后遍历下一项,这时被选中的随机元素已经成为了当前遍历项的前一项,而我们选随机数是从当前项往后,这就实现了被摸出的元素不会再被摸到了)
需要注意的细节:当前元素是可以和它本身互相交换的,否则生成最后的排列组合的概率就不对了
(为了让数组的每个排列出现的可能性尽可能相等,还是有一些其他东西需要注意的。例如,一个长度为 n 的数组有 n! 个不同的排列组合。因此,为了能将所有的排列在整数空间编码,我们需要 lg(n!) 比特,这是默认的伪随机数不能保证的。所以我们每次选择随机元素时,必须将当前未被选择过的所有元素都当做可选取项,而当前被遍历项也属于其中,所以可以和本身互相交换)
class Solution {
// 成员属性:这部分和暴力相同
private int[] array;
private int[] original;
Random rand = new Random();
// 参数为没被选择过的随机下标的可选区间
private int randRange(int min, int max) {
// 生成一个小于 max - min 的随机数字,然后加上 min 返回,达到返回当前项和末尾项间的某一个值
return rand.nextInt(max - min) + min;
}
// 参数 i 是当前被遍历下标,参数 j 是随机被选中的下标
// 单纯的互换值操作
private void swapAt(int i, int j) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
// 构造器:使用整数数组 nums 初始化对象,这部分和暴力相同
public Solution(int[] nums) {
array = nums;
original = nums.clone();
}
// 重设数组恢复到原始状态,这部分和暴力相同
public int[] reset() {
array = original;
original = original.clone();
return original;
}
// 打乱数组
public int[] shuffle() {
for (int i = 0; i < array.length; i++) {
// 相对于暴力,使用了 swapAt 方法让数组中的元素互相交换,免去原先修改额外数组花费的时间
// swapAt 传入当前下标和随机下标两个参数
// randRange 传入当前下标和数组总长
swapAt(i, randRange(i, array.length));
}
return array;
}
}
198、难度中等:
注意点:
若只看题给示例,会认为偷的方法只有两种可能性:第一天偷或不偷,第二天不偷或偷,…
但存在情况如数组 [2,1,1,2] ,我们在选择第一天偷后接下来的两天都不偷,之后在最后一天偷这样才能达到最大金额。这是不同于上面两种可能性的情况。
方法一:动态规划
代码原理:选自:
作者:nettee
链接:https://leetcode-cn.com/problems/house-robber/solution/dong-tai-gui-hua-jie-ti-si-bu-zou-xiang-jie-cjavap/
动态规划的的四个解题步骤是:
1、定义子问题
2、写出子问题的递推关系
3、确定 DP 数组的计算顺序
4、空间优化(可选)
对 1 :动态规划有一个“子问题”的定义。子问题:子问题是和原问题相似,但规模较小的问题。
例如这道小偷问题,原问题是“从全部房子中能偷到的最大金额”,将问题的规模缩小,子问题就是“从 k 个房子中能偷到的最大金额”,用 f(k) 表示。
子问题是参数化的,我们定义的子问题中有参数 k。假设一共有 n 个房子的话,就一共有 n 个子问题。动态规划实际上就是通过求这一堆子问题的解,来求出原问题的解。这要求子问题需要具备两个性质:
(1)原问题要能由子问题表示。例如这道小偷问题中,k=n 时实际上就是原问题。
(2)一个子问题的解要能通过其他子问题的解求出。例如这道小偷问题中,f(k) 可以由 f(k-1) 和 f(k-2) 求出,具体原理后面会解释。这个性质就是教科书中所说的“最优子结构”。如果定义不出这样的子问题,那么这道题实际上没法用动态规划解。
小偷问题由于比较简单,定义子问题实际上是很直观的。一些比较难的动态规划题目可能需要一些定义子问题的技巧。
对 2 :这一步是求解动态规划问题最关键的一步。然而,这一步也是最无法在代码中体现出来的一步
该问题的递推关系:假设一共有 n 个房子,每个房子的金额分别是 H0,H1,…,Hn-1 ,子问题 f(k) 表示从前 k 个房子(即H0,H1,…,Hk-1)中能偷到的最大金额。那么,偷 k 个房子有两种偷法:
k 个房子中最后一个房子是 Hk−1 。如果不偷这个房子,那么问题就变成在前 k-1 个房子中偷到最大的金额,也就是子问题 f(k-1)。如果偷这个房子,那么前一个房子 Hk−2显然不能偷,其他房子不受影响。那么问题就变成在前 k-2 个房子中偷到的最大的金额。两种情况中,选择金额较大的一种结果。
f(k)=max{f(k−1),Hk−1+f(k−2)}
在写递推关系的时候,要注意写上 k=0 和 k=1 的基本情况:
当 k=0 时,没有房子,所以 f(0)=0。
当 k=1 时,只有一个房子,偷这个房子即可,所以 f(1)=H0。这样才能构成完整的递推关系,后面写代码也不容易在边界条件上出错。
对 3 :动态规划有两种计算顺序:一种是自顶向下的、使用备忘录的递归方法,一种是自底向上的、使用 dp 数组的循环方法。不过在普通的动态规划题目中,99% 的情况我们都不需要用到备忘录方法,所以我们最好坚持用自底向上的 dp 数组。
DP 数组也可以叫”子问题数组”,因为 DP 数组中的每一个元素都对应一个子问题。如下图所示,dp[k] 对应子问题 f(k),即偷前 k 间房子的最大金额。
只要搞清楚了子问题的计算顺序,就可以确定 DP 数组的计算顺序。对于小偷问题,我们分析子问题的依赖关系,发现每个 f(k) 依赖 f(k-1) 和 f(k-2)。也就是说,dp[k] 依赖 dp[k-1] 和 dp[k-2],如下图所示。
既然 DP 数组中的依赖关系都是向右指的,DP 数组的计算顺序就是从左向右。这样我们可以保证,计算一个子问题的时候,它所依赖的那些子问题已经计算出来了
// 到目前步骤为止的代码
public int rob(int[] nums) {
if (nums.length == 0) {
return 0;
}
// 子问题:
// f(k) = 偷 [0...k) 房间中的最大金额
// f(0) = 0
// f(1) = nums[0]
// f(k) = max{ rob(k-1), nums[k-1] + rob(k-2) }
int N = nums.length;
int[] dp = new int[N+1];
// 无房子,仅用于 f(k) 依赖 f(k-1) 和 f(k-2)
dp[0] = 0;
// 偷第一个房子的利润
dp[1] = nums[0];
// 从第二个房子开始
for (int k = 2; k <= N; k++) {
// 比较两种偷法哪种利润更高
dp[k] = Math.max(dp[k-1], nums[k-1] + dp[k-2]);
}
return dp[N];
}
对 4 :空间优化的基本原理是,很多时候我们并不需要始终持有全部的 DP 数组。对于小偷问题,我们发现,最后一步计算 f(n) 的时候,实际上只用到了 f(n-1) 和 f(n-2) 的结果。n-3 之前的子问题,实际上早就已经用不到了。那么,我们可以只用两个变量保存两个子问题的结果,就可以依次计算出所有的子问题。下面的动图比较了空间优化前和优化后的对比关系:
// 最终代码
public int rob(int[] nums) {
int prev = 0;
int curr = 0;
// 每次循环,计算“偷到当前房子为止的最大金额”
for (int i : nums) {
// 循环开始时,curr 表示 dp[k-1],prev 表示 dp[k-2]
// dp[k] = max{ dp[k-1], dp[k-2] + i }
int temp = Math.max(curr, prev + i);
prev = curr;
curr = temp;
// 循环结束时,curr 表示 dp[k],prev 表示 dp[k-1]
}
return curr;
}