赞
踩
突然想了一想,为啥会有数据结构譬如数组、链表、二叉树、队列、栈等等,他们的目的不就是为了解决问题的吗,通过利用这些数据结构可以更加高效的解决遇到的数学问题!
但是回过头来想一想,为啥会设计诸如此类的数据结构呢?特别地,举个例子比如二叉树,我的想法就是,因为二叉数“漂亮”,或者说二叉树“规整”,或者说二叉树“简单”,或者说二叉树“对称”,或者最终的最 终是因为这么设计二叉树可以更好的利于诸如计算机这种设备做重复性的运算(比如常用的递归操作等等),既然方便了计算机的递归(计算机的穷举),因为其完美的对称性,我们可以更方便的写出重复性操作的递归运算,那么不就方便了解决我们的问题了呢!!进一步其他数据机构的设计同样是如此!!
关于二叉树的一些思考:
二叉树题目的递归解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着 回溯算法核心框架 和 动态规划核心框架。基本不会逃离这两个内容!
DFS 算法和回溯算法非常类似,只是在细节上有所区别。这个细节上的差别是什么呢?其实就是「做选择」和「撤销选择」到底在 for 循环外面还是里面的区别,DFS 算法在外面,回溯算法在里面。为什么有这个区别?还是要结合着二叉树理解。这一部分我就把回溯算法、DFS 算法、动态规划三种经典的算法思想,以及它们和二叉树算法的联系和区别,用一句话来说明:动归/DFS/回溯算法都可以看做二叉树问题的扩展,只是它们的关注点不同:
动态规划算法属于分解问题的思路,它的关注点在整棵「子树」。
回溯算法属于遍历的思路,它的关注点在节点间的「树枝」。
DFS 算法属于遍历的思路,它的关注点在单个「节点」。
什么是回溯算法?
其实回溯算法和我们常说的 DFS 算法非常类似,本质上就是一种暴力穷举算法。回溯算法和 DFS 算法的细微差别是:回溯算法是在遍历「树枝」,DFS 算法是在遍历「节点」!
回溯算法框架:解决一个回溯问题,实际上就是一个决策树的遍历过程,站在回溯树的一个节点上,你只需要思考 3 个问题:
1、路径:也就是已经做出的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,无法再做选择的条件。
result = [] def backtrack(路径, 选择列表): if 满足结束条件: result.add(路径) return for 选择 in 选择列表: 做选择 backtrack(路径, 选择列表) 撤销选择
其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」,特别简单。
需要注意:回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。如:O(N!)
对于回溯问题,有几种形式:*一般是标准 子集\组合\排列 问题*
以组合为例,如果输入 nums = [2,3,6,7],和为 7 的组合应该只有 [7]。
以组合为例,如果输入 nums = [2,5,2,1,2],和为 7 的组合应该有两种 [2,2,2,1] 和 [5,2]。
经典案例如下:
- class Solution {
- public List<List<Integer>> subsets(int[] nums) {
- List<List<Integer>> res = new LinkedList<>();
- LinkedList<Integer> track = new LinkedList<>();
- backtrack(nums, res, track, 0);
- return res;
- }
- public void backtrack(int[] nums, List<List<Integer>> res, LinkedList<Integer> track, int start) {
- res.add(new LinkedList<>(track));
- for (int i = start; i < nums.length; i++) {
- track.add(nums[i]);
- backtrack(nums, res, track, i + 1);
- track.removeLast();
- }
- }
- }
- class Solution {
- public List<List<Integer>> combine(int n, int k) {
- List<List<Integer>> res = new LinkedList<>();
- LinkedList<Integer> track = new LinkedList<>();
- backtrack(n, res, track, k, 1);
- return res;
- }
- public void backtrack(int n, List<List<Integer>> res, LinkedList<Integer> track, int k, int start) {
- if (track.size() == k) {
- res.add(new LinkedList<>(track));
- return;
- }
- for (int i = start; i <= n; i++) {
- track.add(i);
- backtrack(n, res, track, k, i + 1);
- track.removeLast();
- }
- }
- }
- class Solution {
- public List<List<Integer>> permute(int[] nums) {
- List<List<Integer>> res = new LinkedList<>();
- LinkedList<Integer> track = new LinkedList<>();
- boolean[] used = new boolean[nums.length];
- backtrack(nums, res, track, used);
- return res;
- }
- public void backtrack(int[] nums, List<List<Integer>> res, LinkedList<Integer> track, boolean[] used) {
- if (track.size() == nums.length) {
- res.add(new LinkedList<>(track));
- return;
- }
- for (int i = 0; i < nums.length; i++) {
- if (used[i]) {
- continue;
- }
- track.add(nums[i]);
- used[i] = true;
- backtrack(nums, res, track, used);
- track.removeLast();
- used[i] = false;
- }
- }
- }
- class Solution {
- public List<List<Integer>> subsetsWithDup(int[] nums) {
- List<List<Integer>> res = new LinkedList<>();
- LinkedList<Integer> track = new LinkedList<>();
- Arrays.sort(nums);//要让相同的元素放在一起,必须先进行排序!
- //除此之外要搞清楚什么时候要used数组什么时候不用,当要进行排列的时候(即要倒回去遍历前面的数)才要用used数组!
- backtrack(nums, res, track, 0);
- return res;
- }
- public void backtrack(int[] nums, List<List<Integer>> res, LinkedList<Integer> track, int start) {
- res.add(new LinkedList<>(track));
- for (int i = start; i < nums.length; i++) {
- if (i > start && nums[i - 1] == nums[i]) { //这里要用i > start而不是i > 0!因为要用每一层的起始点为开始,start为每一层的起始点,0则是第一层的起始点!
- continue;
- }
- track.add(nums[i]);
- backtrack(nums, res, track, i + 1);
- track.removeLast();
- }
- }
- }
- class Solution {
- public List<List<Integer>> combinationSum2(int[] candidates, int target) {
- List<List<Integer>> res = new LinkedList<>();
- LinkedList<Integer> track = new LinkedList<>();
- int trackSum = 0; //利用trackSum记录得到的元素之和
- Arrays.sort(candidates);//子集问题看到元素有重复,想到要对其排序进行剪纸
- backtrack(candidates, target, res, track, 0, trackSum);
- return res;
- }
- public void backtrack(int[] candidates, int target, List<List<Integer>> res, LinkedList<Integer> track, int start, int trackSum) {
- // base case,等于目标和,加入元素直接结束
- if (trackSum == target) {
- res.add(new LinkedList<>(track));
- return;
- }
- // base case,超过目标和,直接结束
- if (trackSum > target) {
- return;
- }
- for (int i = start; i < candidates.length; i++) {
- if (i > start && candidates[i - 1] == candidates[i]) {//对于横向遍历重复元素的筛选问题
- continue;
- }
- track.add(candidates[i]);
- trackSum += candidates[i];
- backtrack(candidates, target, res, track, i + 1, trackSum);
- track.removeLast();
- trackSum -= candidates[i];
- }
- }
- }
- class Solution {
- public List<List<Integer>> permuteUnique(int[] nums) {
- List<List<Integer>> res = new LinkedList<>();
- LinkedList<Integer> track = new LinkedList<>();
- Arrays.sort(nums);
- boolean[] used = new boolean[nums.length];
- backtrack(nums, res, track, used);
- return res;
- }
- public void backtrack(int[] nums, List<List<Integer>> res, LinkedList<Integer> track, boolean[] used) {
- if (track.size() == nums.length) {
- res.add(new LinkedList<>(track));
- return;
- }
- for (int i = 0; i < nums.length; i++) {
- if (i > 0 && nums[i - 1] == nums[i] && !used[i - 1]) {
- // 如果前面的相邻相等元素没有用过,则跳过,这里边这个 !used[i - 1] 很巧妙
- // [1,2,2'] 和 [1,2',2] 应该只被算作同一个排列,但被算作了两个不同的排列。所以现在的关键在于,如何设计剪枝逻辑,把这种重复去除掉?答案是,保证相同元素在排列中的相对位置保持不变。比如说 nums = [1,2,2'] 这个例子,我保持排列中 2 一直在 2' 前面。
- continue;
- }
- if (used[i]) {
- continue;
- }
- track.add(nums[i]);
- used[i] = true;
- backtrack(nums, res, track, used);
- track.removeLast();
- used[i] = false;
- }
- }
- }
标准全排列算法之所以出现重复,是因为把相同元素形成的排列序列视为不同的序列,但实际上它们应该是相同的;而如果固定相同元素形成的序列顺序,当然就避免了重复。
那么反映到代码上,你注意看这个剪枝逻辑:
// 新添加的剪枝逻辑,固定相同的元素在排列中的相对位置 if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) { // 如果前面的相邻相等元素没有用过,则跳过 continue; } // 选择 nums[i]
当出现重复元素时,比如输入 nums = [1,2,2',2'']
,2'
只有在 2
已经被使用的情况下才会被选择,同理,2''
只有在 2'
已经被使用的情况下才会被选择,这就保证了相同元素在排列中的相对位置保证固定。
- class Solution {
- public List<List<Integer>> combinationSum(int[] candidates, int target) {
- List<List<Integer>> res = new LinkedList<>();
- LinkedList<Integer> track = new LinkedList<>();
- int trackSum = 0;
- backtrack(candidates, target, res, track, trackSum, 0);
- return res;
- }
- public void backtrack(int[] candidates, int target, List<List<Integer>> res, LinkedList<Integer> track, int trackSum, int start) {
- if (trackSum == target) {
- res.add(new LinkedList<>(track));
- return;
- }
- if (trackSum > target) {
- return; //这个不要忘记写!!当总和大于当前值,就没必要继续往下遍历了,直接返回退出就行!
- }
- for (int i = start; i < candidates.length; i++) { //这里要从start开始,不然会出现重复的情况!第一列的向下遍历都能遍历到,第二列的遍历的话就只能第二个数及之后的数字了!
- trackSum += candidates[i];
- track.add(candidates[i]);
- backtrack(candidates, target, res, track, trackSum, i); //这里要写i而不是i + 1,因为每个元素可重复使用!!
- trackSum -= candidates[i];
- track.removeLast();
- }
- }
- }
力扣上没有类似的题目,我们不妨先想一下,nums
数组中的元素无重复且可复选的情况下,会有哪些排列?
比如输入 nums = [1,2,3]
,那么这种条件下的全排列共有 3^3 = 27 种:
[ [1,1,1],[1,1,2],[1,1,3],[1,2,1],[1,2,2],[1,2,3],[1,3,1],[1,3,2],[1,3,3], [2,1,1],[2,1,2],[2,1,3],[2,2,1],[2,2,2],[2,2,3],[2,3,1],[2,3,2],[2,3,3], [3,1,1],[3,1,2],[3,1,3],[3,2,1],[3,2,2],[3,2,3],[3,3,1],[3,3,2],[3,3,3] ]
标准的全排列算法利用 used
数组进行剪枝,避免重复使用同一个元素。如果允许重复使用元素的话,直接放飞自我,去除所有 used
数组的剪枝逻辑就行了。
那这个问题就简单了,代码如下:
- class Solution {
-
- List<List<Integer>> res = new LinkedList<>();
- LinkedList<Integer> track = new LinkedList<>();
-
- public List<List<Integer>> permuteRepeat(int[] nums) {
- backtrack(nums);
- return res;
- }
-
- // 回溯算法核心函数
- void backtrack(int[] nums) {
- // base case,到达叶子节点
- if (track.size() == nums.length) {
- // 收集叶子节点上的值
- res.add(new LinkedList(track));
- return;
- }
-
- // 回溯算法标准框架
- for (int i = 0; i < nums.length; i++) {
- // 做选择
- track.add(nums[i]);
- // 进入下一层回溯树
- backtrack(nums);
- // 取消选择
- track.removeLast();
- }
- }
- }
思路:
对于任意一个i,能够装的水为:(重点)
water[i] = min( # 左边最高的柱子 max(height[0..i]), # 右边最高的柱子 max(height[i..end]) ) - height[i]
利用动态规划和预数组的方式求解:(这个思路很妙!)
- class Solution {
- public int trap(int[] height) {
- int res = 0;
- int n = height.length;
- int[] l_max = new int[n];
- int[] r_max = new int[n];
- //base case
- l_max[0] = height[0];
- r_max[n - 1] = height[n - 1];
- for (int i = 1; i < n; i++) {
- l_max[i] = Math.max(height[i], l_max[i - 1]);
- }
- for (int i = n - 2; i >= 0; i--) {
- r_max[i] = Math.max(height[i], r_max[i + 1]);
- }
- for (int i = 1; i < n - 1; i++) { //注意:左右两边肯定是不会存水的,所以遍历的区间是1~n-1
- res += Math.min(l_max[i], r_max[i]) - height[i];
- }
- return res;
- }
- }
思路:
这种问题也属于接雨水问题,但是比接雨水问题要简单点,可以用双指针的方式求解:
代码:
class Solution { public int maxArea(int[] height) { int left = 0, right = height.length - 1; int res = 0; while (left < right) { int curArea = Math.min(height[left], height[right]) * (right - left); res = Math.max(curArea, res); if (height[left] < height[right]) { //移动较小的那个高度即可! left++; }else { right--; } } return res; } }
那为什么if那个语句要移动较小的一边呢?
// 双指针技巧,移动较低的一边 if (height[left] < height[right]) { left++; } else { right--; }
其实也好理解,因为矩形的高度是由 min(height[left], height[right]) 即较低的一边决定的:
你如果移动较低的那一边,那条边可能会变高,使得矩形的高度变大,进而就「有可能」使得矩形的面积变大;相反,如果你去移动较高的那一边,矩形的高度是无论如何都不会变大的,所以不可能使矩形的面积变得更大。
归并排序思想:分而治之+递归
将序列中待排序数分为若干组,每个数字为一组
将若干个组进行两两合并,保证合并后的组是有序的
一直重复第二步操作直到剩下一组,排序完成
class Solution { public int[] sortArray(int[] nums) { int[] temp = new int[nums.length]; int left = 0, right = nums.length - 1; mergeSort(nums, temp, left, right); return nums; } public void mergeSort(int[] nums, int[] temp, int left, int right) { if (left < right) { //这个条件别忘了!! int mid = (left + right) / 2; mergeSort(nums, temp, left, mid); mergeSort(nums, temp, mid + 1, right); merge(nums, temp, left, mid, right); } } public void merge(int[] nums, int[] temp, int left, int mid, int right) { int i = 0; int high = mid + 1; int low = left; while (low <= mid && high <= right) { if (nums[low] < nums[high]) { temp[i++] = nums[low++]; }else { temp[i++] = nums[high++]; } } while (low <= mid) { temp[i++] = nums[low++]; } while (high <= right) { temp[i++] = nums[high++]; } for (int m = 0; m < i; m++) { nums[left + m] = temp[m]; } } }
快速排序:
class Solution { public int[] sortArray(int[] nums) { int left = 0, right = nums.length - 1; quickSort(nums, left, right); return nums; } public void quickSort(int[] nums, int left, int right) { int i = left, j = right; if (left > right) return; int allow = nums[left]; while (i < j) { while (i < j && nums[j] >= allow) { //划重点!这里一定要先看左边再看右边!不能交换位置! j--; } while (i < j && nums[i] <= allow) { i++; } if (i < j) { int temp = nums[j]; nums[j] = nums[i]; nums[i] = temp; } } nums[left] = nums[i]; nums[i] = allow; quickSort(nums, left, j - 1); //此时这个地方为j - 1,因为j已经排好序了,不能有j!! quickSort(nums, j + 1, right); } }
两套模板一定要会用,注意边界条件!!
①习惯用这个,但是第二个也要会!左闭右闭
- class Solution {
- public int searchInsert(int[] nums, int target) {
- int left = 0, right = nums.length - 1; // 注意
- while(left <= right) { // 注意
- int mid = (left + right) / 2; // 注意
- if(nums[mid] == target) {
- // 相关逻辑
- } else if(nums[mid] < target) {
- left = mid + 1; // 注意
- } else {
- right = mid - 1; // 注意
- }
- }
- // 相关返回值
- return 0;
- }
- }
②左闭右开
- class Solution {
- public int searchInsert(int[] nums, int target) {
- int left = 0, right = nums.length; // 注意
- while(left < right) { // 注意
- int mid = (left + right) / 2; // 注意
- if(nums[mid] == target) {
- // 相关逻辑
- } else if(nums[mid] < target) {
- left = mid + 1; // 注意
- } else {
- right = mid; // 注意
- }
- }
- // 相关返回值
- return 0;
- }
- }
碰到动态规划问题,一定先注意去分解问题,分解问题!分解为子问题!
class Solution { public int rob(int[] nums) { int N = nums.length; int[] dp = new int[N + 1]; dp[0] = 0; dp[1] = nums[0]; for (int i = 2; i <= N; i++) { dp[i] = Math.max(dp[i - 1], nums[i - 1] + dp[i - 2]); //这里nums[i - 1],而不是nums[i],一定注意,因为i是取到N的! } return dp[N]; } }
如果你对于动态规划还不是很了解,或者没怎么做过动态规划的题目的话,那么 House Robber (小偷问题)这道题是一个非常好的入门题目。本文会以 House Robber 题目为例子,讲解动态规划题目的四个基本步骤。
动态规划的的四个解题步骤是:
定义子问题
写出子问题的递推关系
确定 DP 数组的计算顺序
空间优化(可选)
下面我们一步一步地进行讲解。
步骤一:定义子问题
稍微接触过一点动态规划的朋友都知道动态规划有一个 “子问题” 的定义。什么是子问题?子问题是和原问题相似,但规模较小的问题。例如这道小偷问题,原问题是 “从全部房子中能偷到的最大金额”,将问题的规模缩小,子问题就是“从 k 个房子中能偷到的最大金额 ”,用 f(k) 表示。
原问题要能由子问题表示。例如这道小偷问题中,k=n 时实际上就是原问题。否则,解了半天子问题还是解不出原问题,那子问题岂不是白解了。
一个子问题的解要能通过其他子问题的解求出。例如这道小偷问题中,f(k)可以由 f(k−1) 和 f(k−2) 求出,具体原理后面会解释。这个性质就是教科书中所说的“最优子结构”。如果定义不出这样的子问题,那么这道题实际上没法用动态规划解。
小偷问题由于比较简单,定义子问题实际上是很直观的。一些比较难的动态规划题目可能需要一些定义子问题的技巧。
步骤二:写出子问题的递推关系
这一步是求解动态规划问题最关键的一步。然而,这一步也是最无法在代码中体现出来的一步。在做题的时候,最好把这一步的思路用注释的形式写下来。做动态规划题目不要求快,而要确保无误。否则,写代码五分钟,找 bug 半小时,岂不美哉?
我们来分析一下这道小偷问题的递推关系:
步骤三:确定 DP 数组的计算顺序
在确定了子问题的递推关系之后,下一步就是依次计算出这些子问题了。在很多教程中都会写,动态规划有两种计算顺序,一种是自顶向下的、使用备忘录的递归方法,一种是自底向上的、使用 dp 数组的循环方法。不过在普通的动态规划题目中,99% 的情况我们都不需要用到备忘录方法,所以我们最好坚持用自底向上的 dp 数组。
DP 数组也可以叫”子问题数组”,因为 DP 数组中的每一个元素都对应一个子问题。如下图所示,dp[k] 对应子问题 f(k),即偷前 k 间房子的最大金额。
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]; 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]; }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。