什么是大O
n 表示数据规模
O(f(n))表示运行算法所需要执行的指令数,和f(n)成正比。
常见算法的时间复杂度:
二分查找法O(logn)
寻找数组中的最大最小值O(n)
归并排序算法O(nlogn)
选择排序法O(n^2)
如果设计一个算法,这个算法有两部分的话,并且两部分的规模增长一样,那整个算法将以量级最高的作为主导
- O(nlogn +n) = O(nlogn)
- O(nlogn+n^2) = O(n^2)
时间复杂度在有些情况是用例相关的,比如快速排序平均是O(nlogn),但是在特殊的用例下可以退化成O(n^2)
数据规模
自行实验
经过实验我们发现:如果想要在1s之内解决问题
O(n^2)的算法可以处理大约10 ^4级别的数据
O(n)的算法可以处理大约10^8级别的数据
O(nlogn)的算法可以处理大约10^7级别的数据
空间复杂度
简单来说空间复杂度就是开辟了多大的空间
int sum(int n){//空间复杂度O(1)
int res = 0;
for(int i=0;i<=n;i++){
res+=i;
}
return res;
}
int sum2(int n){//空间复杂度 O(n)
if(n ==0){
return 0;
}
return n+sum2(n-1);
}
如何写出一个正确的程序
相信大家都有这样的经历,在调试一个算法的时候,我们通过不同的索引,指向不同的元素,我们写出的代码总是不能像我们所想的那样执行,那些索引总是不听话,比如在比较的时候是不是要加上等于(i<j 还是i<=j),在边界的时候是不是要将索引减1等等;
从二分查找来看如何写出一个正确的程序
二分查找从1946年提出来,但是第一个没有bug的二分查找是1962年才出现,这说明算法思想是容易描述的,但是没有bug的程序确实困难的。废话不多说 show your code
static int binarySearch(int[] arr,int target){
int l = 0;
int r = arr.length-1;//这说明我们是在[l,r]这样的闭区间去查找
while (l<=r){
int mid = (l+r)/2;
if (arr[mid]== target){
return mid;
}else if(arr[mid]>target){
r = mid-1;
}else{
l = mid+1;
}
}
return -1;
}
以上代码是我们先给出了这样的定义,在[l,r]这样的闭区间去查找 那么我们的初始l = 0,
r = arr.length-1 就自然而然的这样赋值了,因为[0,arr.length-1] 正好包含了整个区间,而在while(l<=r)循环条件中,也应该加上= ,因为加入[0,0]这个区间是有一个值0,而如果我们定义在[l,r)这样的开区间上去搜索,那么为了包含整个区间,r应该为arr.length. 循环的条件不应该加等号(因为 [0,0)这个区间没有值)
int binarySearch2(int[] arr,int target){
int l = 0;
int r = arr.length;
while (l<r){
int mid = l+(r-l)/2;
if (arr[mid]== target){
return mid;
}else if(arr[mid]>target){
r = mid;
}else{
l = mid+1;
}
}
return -1;
}
递归与回溯探索
什么是递归呢,就是自己调用自己,为什么要自己调用自己呢,因为原问题和子问题干的是同一件事
递归的条件:
- 子问题须与原始问题为同样的事,且更为简单;
- 不能无限制地调用本身,须有个出口,化简为非递归状况处理。
考虑斐波那契数列F(n)=F(n-1)+F(n-2) 要想求出F(n) 就需要知道F(n-1) 和 F(n-2)显然是自己调用自己,但是n的取值却变小了,也就是说,原问题变成了子问题。
static int Fibonacci(int n){
if(n==1 || n==2){
return 1;
}
return Fibonacci(n-1)+Fibonacci(n-2);
}
为了说明递归究竟是怎么运作的,请看下图
从图中我们可以清晰的看到,递归的每一层都保存了什么,也就是说每次向下递归的时候,都会保存中间的状态,比如要计算F(6) 我们就要计算F(5)和F(4) 要计算F(5)就要计算 F(4)和F(3).....直到最后,我们已知的F(2)和 F(1)
到这里我们已经知道了程序是如何向下递归的,那么什么是回溯呢?
回溯是递归到底以后,把之前保存的状态都拿出来(也就是出栈)后运算,也就是说,现在我们已经递归到F(2)和F(1)了 由 这两个值计算F(3) ,然后由F(3)和 F(2) 计算F(4)....直到最后计算出F(n)
根据这样的思想我们可以写出一下代码
static int Fibonacci(int n){
if(n==1 || n==2){
return 1;
}
int a = Fibonacci(n-1);
int b = Fibonacci(n-2);
return a+b;
}
这样的代码是不是能更好的体现 递归和回溯呢? 其实递归和回溯相辅相成,递归离不开回溯,理解回溯才能更好的懂得递归是个什么玩意。
经典的面试题:翻转一棵二叉树
这道题是赫赫有名,为啥呢(自己百度)
大概的事情是:Max Howell 去 Google 面试,面试官说:虽然在 Google 有 90% 的工程师用你写的 Homebrew,但是你居然不能在白板上写出翻转二叉树的代码,这太糟糕了。
那么翻转一颗二叉树是什么意思呢 ,看图
,题目的意思是把二叉树的左右子节点交换一下,于是我们有了这样的想法,看图,把节点1的左右节点交换一下(交换2和7),然后接着把1的子节点2 的子节点交换(交换 3和5)...对于每一个子节点,都干着同样的事,
那么算法就出来了
- 先递归到叶子节点(递归到底)
- 回溯的时候交换两个节点就可以了
public TreeNode invertTree(TreeNode root) {
if(root == null){
return null;
}
TreeNode l = invertTree(root.left);
TreeNode r = invertTree(root.right);
root.left = r;
root.right = l;
return root;
}
记忆化搜索
从递归与回溯我们知道,其实计算机在干一件事,那就是把所有的可能都搜索一遍,早期的人工智能就是靠着回溯,让人们看起来计算机好像很智能。那么问题来了,把所有的可能都搜索一遍,是不是有这个必要呢,我们继续看斐波那契,从我们的递归算法来看 我们需要计算 大量重复的值,比如说(看斐波那契图)仅仅是计算F(6)就要计算两次F(4) 三次 F(3),想象一下我们要是计算 F(100) 那样重复的更多,在我的电脑上试了一下等了十分钟还是没算出来(不能忍)。那么我们就需要保存每次计算的结果,而不是每次都去重新计算,这样的搜索过程称为记忆化搜索。
看代码
static int[] memo = new int[n];
static int Fibonacci(int n) {
if (n == 1 || n == 2) {
return 1;
}
if (memo[n] == 0) {
memo[n] = Fibonacci(n - 1) + Fibonacci(n - 2);
}
return memo[n];
}
上面的代码很容易理解,我们在搜索的过程中添加了“记忆”,使得原本O(2^n)变成了O(n)也就是说之前半个小时都计算不出来的结果,现在几毫秒就计算完了。算法真的是太神奇了。其实对于每一个使用递归来解决的问题,都应该去考虑有没有重复的计算,将他们记忆化。
动态规划
动态规划是一种相当具有艺术气息的设计思想,那么什么是动态规划,我们还是来看斐波那契,由之前我们的思想,是要计算F(6) ,我们去递归找F(5)和F(4),接着找F(3)等等,向下的思考,那么我们能不能直接从下往上思考呢? 既然我们已经知道了F(2)和F(1) 我们就一步一步推到出 F(3) 然后 推到出F(4),,直到F(6)这就动态规划,动态的从低向上推到出结果。
面试题:如何不用递归,来计算斐波那契F(n)
static int Fibonacci2(int n) {
int[] dp = new int[n + 1];
dp[1] = 1;
dp[2] = 1;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
对于很多问题,我们是不能直接写出它的递推关系(状态转移方程) ,于是我们就有了这样的思考,先从递归的角度去自顶向下的思考,然后使用记忆化搜索优化,最后再使用动态规划,自低向上的写出具有艺术气息的代码。
感受一下动态规划的魅力。
题目:求一个序列的最大子段和即最大连续子序列之和。例如序列[4, -3, 5, -2, -1, 2, 6, -2]的最大子段和为11=[4+(-3)+5+(-2)+(-1)+(2)+(6)]。
如果直接思考:暴力法。用双重循环求出所有的连续子序列,然后比较子序列的和,找出最大的,时间复杂度O(n^3) 这个时间复杂度很可怕
那么动态规划怎么求解呢?
先将问题拆解成子问题,比如只有两个数 4,-3 则最大子序列和 要么是 把最后一个数加上(4-3),要么是重新开始(-3),从这两个值中取较大的值, 用dp[i]表示前i个数的最大子序列和,dp[0] = 4;
那么dp[i] = Max(dp[i-1]+a[i],a[i]) 那么我们就知道了前两个数的最大子序列是Max((4-3),-3) 即dp[2] =1;这样类推,就可以递推出dp[n]
static int maxSequence(int[] arr){
int[] dp = new int[arr.length];
dp[0] = arr[0];
int res =-1 ;
for(int i = 1;i<arr.length;i++){
dp[i] = Math.max(dp[i-1]+arr[i],arr[i]);
if(dp[i]>res){
res = dp[i];
}
}
return res;
}
经过思考发现我们只需要保存dp[i-1]就可以推出下一个,dp[i-2],dp[i-3]等等 不需要保存,所以有如下优化,空间复杂度降到O(1)
static int maxSequence2(int[] arr){
int sum = 0;
int res = 0;
for(int i = 0;i<arr.length;i++){
sum = Math.max(sum+arr[i],arr[i]);
if(sum>res){
res = sum;
}
}
return res;
}