算法从入门到放弃

什么是大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

从二分查找来看如何写出一个正确的程序

二分查找从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 (ltarget){
                r = mid;
            }else{
                l = mid+1;
            }
        }
        return -1;
    }

递归与回溯探索

什么是递归呢,就是自己调用自己,为什么要自己调用自己呢,因为原问题和子问题干的是同一件事
递归的条件:

  1. 子问题须与原始问题为同样的事,且更为简单;
  2. 不能无限制地调用本身,须有个出口,化简为非递归状况处理。
    考虑斐波那契数列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);
    }

为了说明递归究竟是怎么运作的,请看下图


算法从入门到放弃_第1张图片
斐波那契01.jpg

从图中我们可以清晰的看到,递归的每一层都保存了什么,也就是说每次向下递归的时候,都会保存中间的状态,比如要计算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,但是你居然不能在白板上写出翻转二叉树的代码,这太糟糕了。
那么翻转一颗二叉树是什么意思呢 ,看图


算法从入门到放弃_第2张图片
翻转二叉树.png

,题目的意思是把二叉树的左右子节点交换一下,于是我们有了这样的想法,看图,把节点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;ires){
                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;ires){
                res = sum;
            }
        }
        return res;
    }

你可能感兴趣的:(算法从入门到放弃)