31. 下一个排列 => 下一个更大的含义易弄错、分析思路不易想到
153. 寻找旋转排序数组中的最小值 => 二分的难点就在于边界,退出条件,非常值得仔细品味
326. 3的幂 => 有点技巧,值得借鉴
395. 至少有 K 个重复字符的最长子串 => 不易想到分治/分片!!
目的:不需要移位即可知道所有位中1的个数
算法:当我们在 number 和 number-1 上做 AND 位运算时,原数字 number 的最右边等于 1 的比特会被移除
应用:n&(n-1) => 相当于将n的最右边的1变成0(布赖恩·克尼根算法)
题目:191. 位1的个数
338. 比特位计数 => 结合动态规划
201. 数字范围按位与
lowbit(x)是x的二进制表达式中最低位的1所对应的值。
比如,6的二进制是110,所以lowbit(6)=2。
int lowbit(int x){
return x & (-x); //x+(-x)==0; 因此x与-x按位与,最低位必定为1,其他位为0
}
//或者
int lowbit(int x){
return x-(x & (x-1)); // x&(x-1)相当于将x最低位的1变成0,所以...
}
int add(int x, int y){
int answer; int carry;
while(y){
answer=x^y; // x^y的结果是不考虑进位的和
carry=(x&y) << 1; // (x&y)产生进位,(x&y)<<1是进位的值(进位补偿)
x=answer; y=carry; //由上面注释:x+y=x^y + (x&y)<<1 => 由于不使用加法,此处通过循环继续迭代下去完成x^y 和 (x&y)<<1的求和
}
return x;
}
371. 两整数之和
格雷码定义
格雷码
格雷码与二进制码的转换
方法很多,推荐使用异或的方法
二进制码=> 格雷码
即将二进制码的第i位和第i+1位的异或结果作为格雷码的第i位(左边是高位,右边是低位)
// 二进制码转换为格雷码
public List<Integer> grayCode(int n) {
int max=1<<n;
List<Integer> res=new ArrayList<Integer>();
for(int i=0;i<max;i++){
res.add(i^(i>>1)); // i右移保证移动之前每一位的前一位 和 该位 对齐
}
return res;
}
格雷码 => 二进制码
写在前面:排序的基础/基准题目:912. 排序数组
void SelectSort(ElemType A[],int n){
for(i=0;i<n-1;i++){
min_idx=i;
for(j=i+1;j<n;j++)
if(A[j]<A[min_idx]) min_idx=j;
swap(A[i],A[min_idx]);
}
}
时间O(N^2),空间O(1)2
,1} => {1,2
,2}编号从1开始
;构建堆(这个过程编号1-n/2的元素都要向下调整
)。注意构建堆的时间是O(n)
;每次从堆顶取出一个元素,将其与数组末尾的元素交换
。这个堆顶元素也就被放置到了最终位置,这就是它属于选择排序的原因;需要将新的堆顶向下调整
,使整颗树成为新的堆,注意此时交换到末尾的元素已经不属于堆了!;插入排序的思路:每次将一个待排序元素按照大小插入前面已经排序的序列中
…
void InsertSort(int[] A){
for(i=2;i<=n;i++){
if(A[i]<A[i-1]){
A[0]=A[i]; //A[0]为哨兵,不存放元素 => 不用判断是否越界
// 感觉哨兵用处也不大
for(j=i-1;A[0]<A[j];j--)
A[j+1]=A[j];
A[j+1]=A[0];
}
}
}
时间复杂度
:O(N^2) => 最好O(n)只需比较不需交换;空间复杂度
:就地排序 void binInsertSort(int[] nums){
int n=nums.length;
for(int i=1;i<n;i++){
int elem=nums[i];
int low=0; int high=i-1;
// 折半查找寻找插入位置
while(low<=high){ // 注意这里必须取等
int mid=low+(high-low)/2;
if(nums[mid]>elem) high=mid-1;
else low=mid+1; // 注意:为了稳定性,a[mid]==elem时,只能修改low指针...
}
// 移动元素
for(int j=i-1;j>=low;j--)
nums[j+1]=nums[j];
// 元素放入最终位置...
nums[low]=elem;
}
}
关于low/high
:low<=high必须取等
,否则就会有一个元素没有和elem比较;二分查找法同理与二分查找的区别
:二分查找只是确定是否存在;而这里必须找到一个中间位置,所以a[mid]==elem时仍然不能退出;而且为了稳定性,a[mid]==elem时,只能修改low指针
…时间
:O(n^2),虽然查找只需O(logn),但是减少了比较次数;后续移动次数没有改变,所以综合下来仍然平均O(n2)空间
:原地排序先分成间隔子表,分别进行直接插入排序,基本有序后,再对整体进行直接插入排序
…时间
:当n在某个特定范围时0(n1.3),最坏O(n2) void bubbleSort(int[] nums){
int n=nums.length;
for(int i=0;i<n;i++){
boolean swap=false;
for(int j=n-1;j>i;j--){
if(nums[j]<nums[j-1]){ // 小的元素往前移
exchange(nums[j],nums[j-1]);
swap=true;
}
}
if(!swap) return; // 提前退出
}
时间:O(n^2)提前结束
:注意可以加个标志,提前结束…int partition(int[] nums,int low,int high){
int pivot=nums[low];
while(low<high){
while(low<high && nums[high]>=pivot) //右边找到第一个小于pivot的值
high--;
nums[low]=nums[high];
while(low<high && nums[low]<=pivot) //左边找到第一个大于pivot的值
low++;
nums[high]=nums[low];
}
//最终必定是low==high
nums[low]=pivot;
return low;
}
void qsort(int[] nums,int low,int high){
if(low>=high) return;
int mid=partition(nums,low,high);
qsort(nums,low,mid-1);
qsort(nums,mid+1,high);
}
注:2
,2} => {2,2
,3};partition中,low:因为按照上面的逻辑,low、high中至少有一个是空位,当low==high时,说明最后一个位置恰好是空位,应该用于放置pivot;而不是继续移动,因为low左边和high右边都已经访问过了 => 简单来说,这个low=high的位置上应该认为没有元素,它不该参与比较的
(且参与了可能导致出错)!
4.partition中,nums[low]<=pivot,nums[high]>=pivot至少有一个要取等
=>目的是避免low 5.非递归实现(栈)
:由于递归中是传递的关键参数就是左右指针,我们可以直接用栈来存储,参考:快速排序(三种算法实现和非递归实现)
6.空间复杂度
:取决于栈的深度,最坏O(n),平均O(logn)
7.时间复杂度
:最坏(有序或者逆序)O(n^2),平均O(nlogn) => 最好翻一下书,用递推式计算Tavg
自顶向下
采用递归方式(可转换为用栈); 典型的分治法
代码参考:二路归并排序
// 自底向上使用循环; 自顶向下使用递归 => 此用自顶向下,更简单
// merge,将a[low,...mid]与 a[mid+1...high]两个有序子表合并,需要使用额外空间暂存
void merge(int[] nums, int low, int mid, int high){
int[] tmp=new int[high-low+1];
int idx=0; int i=low; int j=mid+1;
while(i<=mid && j<=high){
if(nums[i]<nums[j]) tmp[idx++]=nums[i++];
else tmp[idx++]=nums[j++];
}
while(i<=mid) tmp[idx++]=nums[i++];
while(j<=high) tmp[idx++]=nums[j++];
// 暂存在tmp的归并后的数据写入原数组
for(int k=0;k<idx;k++)
nums[low+k]=tmp[k];
}
void mergeSort(int[] nums,int low,int high){
if(low<high){
int mid=low+(high-low)/2;
mergeSort(nums,low,mid);
mergeSort(nums,mid+1,high);
merge(nums,low,mid,high);
}
}
时间复杂度:每一轮归并所有数字都会被遍历到O(n),共log2n轮 => O(nlog2n)
空间复杂度:需要一个数组来暂存中间元素,O(n); 递归栈的空间复杂度O(log2n)
可以是稳定的排序
自底向上
采用迭代方式
代码参考归并排序-自底向上的二路归并
时间复杂度:每轮归并所有元素都被遍历到O(n),共log2n轮 => O(nlog2n)
空间复杂度:需要一个数组暂存O(n),但是不需要栈空间!!
空间复杂度O(1)的二路归并
当要排序的是链表时,只需要修改指针,不用暂存元素;此时若使用自底向上的二路归并,也不需使用栈,故空间复杂度O(1) => 148. 排序链表
k路归并排序
使用小根堆/优先队列辅助排序更方便 =>
排序方式 | 最好 | 最坏 | 平均 | 空间 | 稳定性 | 复杂性 |
---|---|---|---|---|---|---|
直接插入排序 | O(n) | O(n^2) | O(n^2) | O(1) | 稳定 | |
折半插入排序 | O(n) | O(n^2) | O(n^2) | O(1) | 稳定 | |
希尔排序 | O(n^1.3) | O(1) | 稳定 |
|||
冒泡排序 | O(n) | O(n^2) | O(n^2) | O(1) | 稳定 | |
快速排序 | O(nlogn) | O(n^2) |
O(nlogn) | O(logn) |
不稳定 |
|
简单选择排序 | O(n^2) | O(N^2) | O(n^2) | O(1) | 不稳定 |
|
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
|
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) |
稳定 | |
基数排序 |
不稳定排序简记:简堆希快
,可用举例证明…
退出边界取等与否:主要区分快速排序与二分查找,在while(left <=right)时是否需要取等 => 快速排序while(left
二分查找while(left<=right)必须取等
,细细体会其原因!
比较难的排序算法:主要是快排
和归并
相对难写,多复习
179. 最大数
二分查找的基本框架
重点复习这个讲解:并不简单的二分查找
int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1; // 注意
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1; // 注意
else if (nums[mid] > target)
right = mid - 1; // 注意
}
return -1;
}
1.左右指针都是对应的闭区间
2.退出循环的条件要取等,否则idx== left==right处的元素,且该元素恰好就是目标元素 => 其实二分法的退出条件取等与不取等都可,但是写法有所不同
,可参考:并不简单的二分查找,主要区别还是在于left、right指针构成的区间开闭含义不同,个人目前偏向于取等,也就是说对应[left,right]这个闭区间
3.注意mid的计算方法,(right-left)/2是为了防止溢出
二分要领
不要死背
,包括下面的搜索左右边界、旋转排序数组等,认真分析就能做出来,不用背模板!
对于target存在重复的数组,基本的二分法找到的不一定是最左边的下标,比如[1,2,2,2,3,4]。需要更新如下:
int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
// 搜索区间为 [left, right]
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
// 搜索区间变为 [mid+1, right]
left = mid + 1;
} else if (nums[mid] > target) {
// 搜索区间变为 [left, mid-1]
right = mid - 1;
} else if (nums[mid] == target) {
// 收缩右侧边界
right = mid - 1;
}
}
// 检查出界情况
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}
1.搜索的仍然是闭区间,且循环条件依然是 <=
2.关键是nums[mid] == target时,需要收缩右指针,从而找到最左边界
3.由于 while 的退出条件是 left == right + 1,所以当 target 比 nums 中所有元素都大时,会存在上边界溢出的情况,所以需要检查边界left >= nums.length
3.若target比所有元素都小时,left始终为0,nums[left] != target的检查可剔除这一情况(当然同时也剔除了target处于中间值但是不在数组的情况)
4.对于正常返回的结果left,可看做数组中有left个数小于target,这一思路可用于解决部分题目
题目:34. 在排序数组中查找元素的第一个和最后一个位置
int right_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 这里改成收缩左侧边界即可
left = mid + 1;
}
}
// 这里改为检查 right 越界的情况,见下图
if (right < 0 || nums[right] != target)
return -1;
return right;
}
…略
// 返回nums中与target最接近的数(nums是已经排序过的)
int binSearch(vector<int>& nums, int target){
int left=0; int right=nums.size()-1;
while(left <= right){
int mid=left+(right-left)/2;
if(nums[mid]==target) return nums[mid];
else if(nums[mid] > target) right=mid-1;
else left=mid+1;
}
if(right<0) return nums[left];
if(left>=nums.size()) return nums[right];
return nums[left]-target < target-nums[right] ? nums[left]:nums[right];
}
思路和二分查找完全一致,只是需要判断边界越界 和 哪个数离target更近
十分重要
关键是判断left、mid、right,之后分别对[left,mid],[mid+1,right]进行计算即可。通常使用递归实现
复杂度:需要根据具体场景推算
395. 至少有 K 个重复字符的最长子串
快速排序本质上也是分治法
二路归并排序也是分治法
自底向上与自顶向下
都需要一个动态规划数组,用于记录每个子结果,从而减少重复计算
自顶向下使用向下递归的方法,这个动态规划数组常称为备忘录
自底向上使用向上迭代的方法,相对而言效率更高
动态规划的空间优化/状态压缩
观察动态规划数组元素计算特点,二维数组或许可以压缩为一维;一维数组或许可以压缩为不使用数组
动态规划的核心
动态规划的核心是状态定义和状态转移,递归、缓存(备忘录)都只是手段; 更多参考:什么是动态规划(Dynamic Programming)?动态规划的意义是什么?
动态规划的性质
最优子结构:问题的最优解所包含的子问题的解也是最优的 => 必须满足
无后效性:某个状态以后的过程不会影响以前的状态,只与当前状态有关 => 必须满足
重叠子问题:如Fibonacci问题,不是必须的,但是若不满足重叠子问题,则动态规划并无优势
与其他算法的比较
分治法:分治法中各子问题是独立的,动态规划多用于重叠子问题
状态机
DP的本质也就是 状态定义, 动作选择(状态转移),使用穷举的方法遍历所有状态 => 个人认为,动态规划其实就是状态机;关键就是选取合适的状态,所谓状态就是DP table中下标为(i,j,k)的情况下对应的dp[i,j,k]值及其含义,当然,弄清楚坐标轴的含义也是极其重要,从而可以遍历所有情况来更新状态(状态转移)
一个方法团灭 LeetCode 股票买卖问题(链接中的题目或许能帮助理解,文中对状态的解释与个人理解有出入!)
状态机再理解
上面关于动态规划和状态机的描述个人认为基本没有问题,但是依然不够清晰,对刷题而言帮助不大;
具体到题目中,有一类显然的问题,那就是我们可以找到一个“状态组”,给定数组中的每一个数据都可能处于“状态组”中的某个状态,而且这个状态组中的状态总数很少(通常只有2-3) => 为了方便理解,此时我们仅将这个2-3个状态视为状态机中的状态(区别于上文,这里我们理解时不将遍历数组的下标看成状态的一部分)
这种情况下仅将遍历数组过程中那2-3个“状态”间的转换看做状态机,这2-3个状态也占dp数组的一维,但是不需要通过循环访问这个维度,循环只需要用于遍历数组下标即可(比如int[][] dp[n][2],只需一重循环就更新了第二个维度的信息)
说得比较绕,看题即可理解:
LCP 19. 秋叶收藏集
一个方法团灭 LeetCode 股票买卖问题
原来状态机也可以用来刷 LeetCode?
一、0/1背包问题
经典动态规划:0-1 背包问题
通常dp数组为二维:i代表前i个物品,j代表背包剩余容量
=>dp[i]][j]即当背包剩余j的容量时,考虑前i个物品,能装入背包的最大价值
474. 一和零 => 比较灵活,可转换为01背包
子集背包(就是01背包)
416. 分割等和子集=> 虽然不是求能装下的最大价值,但是依然几乎一样;
子集背包本质上就是01背包
二、完全背包问题
经典动态规划:完全背包问题
完全背包:0/1背包问题中,每件物品最多选择一件,而在完全背包问题中,只要背包装得下,每件物品可以选择任意多件。
框架依然是dp数组为二维:i代表前i物品,j代表背包剩余容量,但是在状态转移方程上需要具体问题具体分析
三、多重背包
完全背包问题中,物品可以选择任意多件,只要你装得下,装多少件都行;但多重背包就不一样了,每种物品都有指定的数量限制(第i种物品最多有M[i]件可用)……
注意
1.0-1背包几乎就是子集问题,如果采用回溯法解决子集问题时间超限,可以考虑动态规划
=>如:494.目标和;
2.很多时候背包问题的weight[]就是val[],不要因此而无法识别出背包问题;
3.很多时候数组长度会多分配1,避免一些麻烦的判断…
背包问题相关题目
494. 目标和 => 显然可以回溯法;但是可以转换为背包问题,进而使用动态规划求解(=> 动态规划和回溯算法到底谁是谁爹?仔细看,很难想到)
题目来自美团面试,参考:最长公共连续子串
dp[i]表示以s[i]结尾的最长有效括号长度
,据此推导状态转移方程对左右括号进行计数
,参考:32. 最长有效括号-题解 => 此解法与22. 括号生成有点异曲同工的感觉,值得关注概述
就是动态规划,只是遍历的是一颗层次分明的树。在树上就可以方便的使用DFS进行遍历(或者说是记忆化搜索/递归的动态规划/备忘录方法)。
树状动态规划的特点:没有环,dfs是不会重复,而且具有明显而又严格的层数关系。利用这一特性,我们可以很清晰地根据题目写出一个在树(形结构)上的记忆化搜索的程序
求解过程
1. 判断是否是一道树规题:即判断数据结构是否是一棵树,然后是否符合动态规划的要求。如果是,那么执行以下步骤
2. 建树:通过数据量和题目要求,选择合适的树的存储方式。如果节点数小于5000,那么我们可以用邻接矩阵存储,如果更大可以用邻接表来存储
3. 写出树规方程:通过观察孩子和父亲之间的关系建立方程。我们通常认为,树规的写法有两种:
a.根到叶子: 不过这种动态规划在实际的问题中运用的不多。本文只有最后一题提到。
b.叶子到根: 既根的子节点传递有用的信息给根,完后根得出最优解的过程。这类的习题比较的多。
更多讲解及题目参考:不撞南墙不回头——树规总结
题目
834. 树中距离之和 => 其实自己也想到了用floyd解法,不过效果过低,不推荐
1024. 视频拼接
600. 不含连续1的非负整数
概述
两个字符串word1和word2之间的编辑距离即:只通过增加、删除、替换三种操作,将word1变成word2的最少操作次数
动态规划数组
动态规划 => d p [ i ] [ j ] dp[i][j] dp[i][j]表示将 w o r d 1 [ 0 , . . . i ] word1[0,...i] word1[0,...i]变成 w o r d 2 [ 0 , . . . j ] word2[0,...j] word2[0,...j]的最少编辑次数(字符串相关问题的动态规划数组都是类似的假设)
状态转移
考虑 w o r d 2 [ 0 , . . . j ] word2[0,...j] word2[0,...j]的最后一个字符 w o r d 2 [ j ] word2[j] word2[j]是如何得到的,有以下三种情况:
1.在末尾添加的 w o r d 2 [ j ] word2[j] word2[j]:先将 w o r d 1 [ 0 , . . . i ] word1[0,...i] word1[0,...i]转变成了 w o r d 2 [ 0 , . . . j − 1 ] word2[0,...j-1] word2[0,...j−1],末尾再添加一个字符 => d p [ i ] [ j − 1 ] + 1 dp[i][j-1]+1 dp[i][j−1]+1
2删除一个字符后保留的 w o r d 2 [ j ] word2[j] word2[j]: w o r d 1 [ 0 , . . . . i ] word1[0,....i] word1[0,....i]转变成了 w o r d 2 [ 0 , . . . j , j + 1 ] word2[0,...j,j+1] word2[0,...j,j+1],然后删掉末尾的字符 => d p [ i − 1 ] [ j ] + 1 dp[i-1][j]+1 dp[i−1][j]+1
3.直接将末尾字符替换成 w o r d 2 [ j ] word2[j] word2[j]:先将 w o r d 1 [ 0 , . . . i − 1 ] word1[0,...i-1] word1[0,...i−1]转换成了长度为 j j j的字符串(0,…j-1完成了正确转换,最后一个字符没有进行任何改动),于是只需将最后一个字符替换成正确字符即可(当然如果最后一个字符本就是正确的,便不需替换) => d p [ i − 1 ] [ j − 1 ] + 1 / 0 dp[i-1][j-1]+1/0 dp[i−1][j−1]+1/0
题外话:状态转移很多时候有不同的解释,也很费脑力,有一种合理的思路即可
更多思路也可参考:经典动态规划:编辑距离
题目
72. 编辑距离
583. 两个字符串的删除操作 => 简单版的编辑距离
(有的时候,若实在想不到,也许可以直接打表找规律
)
思路
动态规划之博弈问题
博弈问题的前提一般都是在两个聪明人之间进行,编程描述这种游戏的一般方法是二维 dp 数组,数组中通过元组分别表示两人的最优决策。
之所以这样设计,是因为先手在做出选择之后,就成了后手,后手在对方做完选择后,就变成了先手。这种角色转换使得我们可以重用之前的结果,典型的动态规划标志
以为877为例:
//博弈问题一般化的动态规划模板:dp[N][N][2]
// => 其中a.dp[i][j][0]表示先手在arr[i,...j]的最大收益
// b.dp[i][j][1]表示后手在arr[i,...j]的最大收益,
// 当然,后手的选择取决于先手,后手的结果其实来自arr[i+1,...j]或者arr[i,...j-1];
// 不过,直接说它来自arr[i,...j]也没有错,且更方便
/* 可优化空间 */
public boolean stoneGame(int[] piles) {
// return true; // 官答思路,可直接分析出true
int n=piles.length;
int[][] dpFir=new int[n][n]; // 先手
int[][] dpSec=new int[n][n]; // 后手
for(int i=0;i<n;i++){
dpFir[i][i]=piles[i];
dpSec[i][i]=0;
}
//往右上角遍历
for(int i=n-2;i>=0;i--){
for(int j=i+1;j<n;j++){
//先手选左侧能获得的收益=左侧节点值+它在下一轮选取中的最大收益
//而在下一轮选取中,它其实是后手,故取dpSec[i+1][j]
int left=piles[i]+dpSec[i+1][j];
int right=piles[j]+dpSec[i][j-1];
if(left > right){
dpFir[i][j]=left;
dpSec[i][j]=dpFir[i+1][j];
}
else{
dpFir[i][j]=right;
dpSec[i][j]=dpFir[i][j-1];
}
}
}
return dpFir[0][n-1]>dpSec[0][n-1];
}
题目
877. 石子游戏
486. 预测赢家
292. Nim 游戏
题目:10. 正则表达式匹配
dp[i][j]表示s的前i个字符是否与p的前j个字符匹配;dp.size:mxn
初始化:
s、p都为空,dp[0][0]=true
s空,p不空,……
是不空,p空,……
1.当s[i-1]==p[j-1]时:
dp[i][j]=dp[i-1][j-1];
2.当s[i-1]!=p[j-1]且p[j-1]==’.'时:
dp[i][j]=dp[i-1][j-1];
3.当s[i-1]!=p[j-1]且p[j-1]==’*'时:
下面还需分情况讨论:
a. s[i-1]=p[j-2] || p[j-2]= ‘.’ => 意味着可用于匹配前面的那个元素多次或者0次。每匹配一次则可以理解为s中的符号下标就往前移动一位,故dp[i][j]=dp[i-1][j] ; 注意如果是匹配0次,则dp[i][j]=dp[i][j-2];
b. 若不满足a的条件 => 意味着只能匹配前面的那个元素0次,故:dp[i][j]=dp[i][j-2];
……
概念
区间dp,顾名思义就是在一段区间上进行动态规划。对于每段区间,他们的最优值都是由几段更小区间的最优值得到,是分治思想的一种应用,将一个区间问题不断划分为更小的区间直至一个元素组成的区间,枚举他们的组合 ,求合并后的最优值
区间型 dp 一般用 dp[i][j]表示 ,i 代表左端点,j 代表右端点
套路
//初始化DP数组
for(int i=1;i<=n;i++){
dp[i][i]=初始值
}
for(int len=2;len<=n;len++){ //区间长度
for(int i=1;i<=n;i++){ //枚举起点
int j=i+len-1; //区间终点
if(j>n) break; //越界结束
for(int k=i;k<j;k++){ //枚举分割点,构造状态转移方程
dp[i][j]=max(dp[i][j],dp[i][k]+dp[k+1][j]+w[i][j]);
}
}
}
典型例题:87. 扰乱字符串
题目
87. 扰乱字符串
5. 最长回文子串
516. 最长回文子序列
312. 戳气球
概述
将整数拆分成至少两个正整数的和,在此基础上可以延伸出一些题目
=> 比如求拆分后整数积的最大值?比如求拆分方案的个数?
总体思路
最基本的想法是:对于整数n,假设第一次将其拆分成k和n-k,k之后不再拆分 => 讨论之后n-k不拆分的情况、n-k继续拆分的情况…
上述是基本思路,针对不同的题目,后续dp数组的设定不同
题目
343. 整数拆分
李春葆算法书,8.2
思路
即上文提到的状态机
题目
121. 买卖股票的最佳时机
122. 买卖股票的最佳时机 II
123. 买卖股票的最佳时机 III
188. 买卖股票的最佳时机 IV
参考:股票问题系列通解(转载翻译)
对于前两个稍微简单的题目,注意观察其形态,可以从类似于爬山法
(波峰波谷)的角度通过trick来寻找最优解
通用思路:详解「字符串匹配」的通用思路和技巧 …
115. 不同的子序列
=> 记忆化搜索与动态规划比较类似,都是存储了中间计算结果,只是记忆化很多时候使用了递归/深度优先搜索!!!
动态规划要求按照拓扑顺序解决子问题。对于很多问题,拓扑顺序与自然秩序一致。而对于那些并非如此的问题,需要首先执行拓扑排序。因此,对于复杂拓扑问题(如329),使用记忆化搜索通常是更容易更好的选择。
注:更确切地说,上面的动态规划指自底向上的动态规划(迭代),记忆化搜索指自顶向下的动态规划(备忘录方法,递归) => 递归虽然效率低,但是能解决拓扑顺序的问题!
核心代码
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
组合树和排列树其实都是这个思路 => 只是组合树每次只有两个选择,要么选、要么不选,有时候撤销的动作不突出
!
子集树参考:子集树的递归回溯框架=>子集元素个数为2^n
排列树参考:排列树的递归回溯框架,注意对于排列树而言,交换之后需要再交换回来(排列树的另一种复杂度稍高的写法,不用交换:回溯算法解题套路框架) => 排列元素个数为n!
排列树中交换的目的:对于swap(x[i],x[j]);意思是第[0…i-1]个位置的数字已经确定,第i个位置尝试放置x[j] => 为了得到剩余部分的所有结果,所以需要交换回来,第i个位置继续尝试放置其余数字!
注:回溯法中并不是只有组合树和排列树,还有很多其他形式,不过核心都是选择—撤销选择
就是回溯法的基本思路,只是对于第i个元素,由于它可以重复,在选取时下一层递归仍然不能更新i !!
参考题目:39. 组合总和
双指针
1.计算过程仅与两端点相关
的称为双指针。
2.不固定大小。
3.双指针是解决问题的一种方法。
4.双指针可以同向移动可以双向移动
。
5.同向移动的双指针和滑动窗口没有任何联系
滑动窗口
1.计算过程与两端点表示的区间相关
的称为滑动窗口。
2.默认固定大小的窗口,在一些条件触发的情况下,可能会将其大小进行修改。
3.滑动窗口本身并不是解决问题的一种方法(或者说算法),它其实就是问题本身。
4.滑动窗口一定是同向移动的
。
5.滑动窗口是一类问题,不同的问题需要使用不同的算法和数据结构来解决。
/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...
/*** debug 输出的位置 ***/
printf("window: [%d, %d)\n", left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
每一次窗口右移都需要判断是否需要收缩(通过循环判断,一次收缩到位)!!
题目:
76. 最小覆盖子串
3. 无重复字符的最长子串
567. 字符串的排列
424. 替换后的最长重复字符
395. 至少有 K 个重复字符的最长子串 与常规滑动窗口差别很大=> 检验是否掌握滑动窗口
=> 主要用在链表问题中
1.判断链表中是否有环:141. 环形链表
2.判断含环链表的起始节点:142. 环形链表 II => 理解原理
3.寻找链表的中点(快指针一次前进两步,慢指针一次前进一步) => 可用于链表的归并排序
4.寻找链表的倒数第k个数 => 让快指针先走 k 步,然后快慢指针开始同速前进
相关题目:26. 删除排序数组中的重复项
83. 删除排序链表中的重复元素
61. 旋转链表
=> 主要用在数组问题中
1.二分查找其实也是双指针问题
2.twoSum问题(排序+双指针)
3.反转数组
4.滑动窗口问题其实也是双指针,只是相对更复杂一些
nSum问题:基本思路就是排序+双指针
讲解:一个方法团灭 nSum 问题
1. 两数之和
18. 四数之和
167. 两数之和 II - 输入有序数组 => 可以双指针,也可以hash表
15. 三数之和 => 排序,然后固定左边的数,中间、右边两个数通过双指针相互靠近!O(n^2)
荷兰三色旗问题:75. 颜色分类
844. 比较含退格的字符串
334. 递增的三元子序列
其他
424. 替换后的最长重复字符 => 滑动窗口+双指针(很优秀的题目)
1208. 尽可能使字符串相等
456. 132模式 => 维护左侧最小值!!
1074. 元素和为目标值的子矩阵数量 => 滑动窗口+hash
11. 盛最多水的容器
对于随机算法,通常能很方便的通过rand获得[1,n]之间等概率的一个随机数
所以对于这类题目,通常都是需要想办法将给定的参数映射到[1,n]的区间,从而才能保证取数的等概率(随机)
(当然,区间并非必须是[1,n],但是要保证区间内的数能等概率得到)
参考题目:528. 按权重随机选择
choice = file[1:k]
i = k+1
while file[i] != None
r = random(1,k);
with probability k/i:
choice[r] = choice[i]
i++
print choice
思路:大概就是我们可以随机产生一个抽样,只是这个抽样的范围大于期望的范围,于是不断进行抽样,直到落在预期范围内
478. 在圆内随机生成点
470. 用 Rand7() 实现 Rand10() => 注意n*n的结果在[1,n^2]中并不是等概率的,相加同理
!……
470参考思路:【宫水三叶】k 进制诸位生成 + 拒绝采样
思路参考:【宫水三叶】k 进制诸位生成 + 拒绝采样
易错点:randK()生成的数在[1,k]内是随机的,但是randK()*randK()的结果在[1,k^2]并非均匀分布
,不明白这点,就完全出错了。
解法:不管k是多少,都可从进制思路出发,等概率的两个[1,k]内的数,直接拼接起来就是[1,k^2]内的一个等概率的K进制数……
// 以p为基准,根据返回值的正负判断q 、r的大小
int orientation(Point p, Point q, Point r) {
//返回叉积结果
return (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y);
}
不是一种具体的算法,而是一种思想
讲两道常考的阶乘算法题
题目:172. 阶乘后的零
通常a/b
是向下取整;如果想向上取整,则使用(a+b-1)/b
剑指 Offer 10- I. 斐波那契数列 => 每次都是算出结果后取模,防止下次计算时溢出