剑指offer刷题详细分析:part13:61题——67题

  • 剑指offer所有题目详解,可访问我的github项目:KongJetLin-offer

  • 目录

  1. Number61:序列化二叉树
  2. Number62:二叉搜索树的第k个结点
  3. Number63:数据流的中位数
  4. Number64:滑动窗口最大值
  5. Number65:矩阵中的路径
  6. Number66:机器人的运动范围
  7. Number67:剪绳子

题目61 序列化二叉树

  题目描述:请实现两个函数,分别用来序列化和反序列化二叉树
1)序列化:二叉树的序列化是指:把一棵二叉树按照某种遍历方式的结果以某种格式保存为字符串,从而使得内存中建立起来的二叉树可以持久保存。序列化可以基于先序、中序、后序、层序的二叉树遍历方式来进行修改,序列化的结果是一个字符串,序列化时通过 某种符号表示空节点(#),以 ! 表示一个结点值的结束(value!)。
2)二叉树的反序列化是指:根据某种遍历顺序得到的序列化字符串结果str,重构二叉树。

  • 参考鸭大师兄的文章:添加链接描述

  序列化是指将结构化的对象转化为字节流以便在网络上传输或写到磁盘进行永久存储的过程。反序列化是指将字节流转回结构化的对象的过程,是序列化的逆过程。
  解析:受第四题重建二叉树 的启发,我们知道从前序遍历和中序遍历序列中可以构造出一棵二叉树,因此将一棵二叉树序列化为一个前序遍历序列和一个中序遍历序列,然后在反序列化时用第四题的思路重建二叉树。
  这种思路是可行的,但是存在两个缺点:
1)该方法要求二叉树中不能有重复的结点(比如我们通过前序知道根是1,若有重复的结点无法在中序序列中定位根的位置);
2)只有当两个序列中所有的数据都读出后才能开始反序列化,如果两个遍历序列是从一个流中读出来的,那么可能需要等待较长的时间。

  因此,这里我们采用另外一种方法,即只根据前序遍历的顺序来进行序列化。前序遍历是从根结点开始,在遍历二叉树碰到null指针时,就将其序列化为一个特殊字符 #,另外,我们用 , 表示一个结点值得结束。(下图在《剑指offer》截取),将 $ 换成 #
剑指offer刷题详细分析:part13:61题——67题_第1张图片
  以上树序列化的字符串为:1,2,4,#,#,#,3,5,#,#,6,#,#,
  然后我们可以以:1,2,4,#,#,#,3,5,#,#,6,#,#, 为例来分析反序列化的过程。第一个读出1,这是根结点的值,然后读出2,这是根结点的左孩子,同样接下来的4是值为2的结点的左孩子结点;接下来读出两个 #,说明值为4的结点左右孩子都是null,这是一个叶子结点。然后回到值为2的结点,重建它的右子树,由于下一个字符是#,说明值为2的结点的右孩子结点为null,这个结点的左右子树都重建完毕,接下来再次回溯就到了根结点,所以,左子树重构完毕。
  由于下一个数字是3,所以右子树的根结点值为3,左结点时一个值为5的叶结点(因为接下来的三个字符是5,#,#,),同理右结点时一个值为6的结点。至此,重构完毕,反序列化完成。

  代码如下:

//序列化:前序遍历
    String Serialize(TreeNode root)
    {
     
        if(root == null)
            return "#,";//读取到null的时候,返回“#,”

        String str = root.val+",";//先读取当前结点值
        str += Serialize(root.left);//再读取左子树的前序遍历结果
        str += Serialize(root.right);//最后读取右子树的后序遍历结果

        return str;
        //这几句可以合并为:return root.val+","+Serialize(root.left)+Serialize(root.right);
    }

    //反序列化需要将字符串转换为字符串数组,因此需要一个成员变量来标记数组下标
    private int index = -1;

    //反序列化
    TreeNode Deserialize(String str)
    {
     
        if(str == null || str.length() == 0 || str == "#,")
            return null;//这3种情况说明传入的str都为null

        String[] strArr = str.split(",");//将不为null的字符串数组根据 “!” 切割开来
        return Deserialize(strArr);
    }

    //根据数组的值递归重建二叉树的方法
    private TreeNode Deserialize(String[] strArr)
    {
     
        //首先,将数组下标设置为当前结点值在数组中的索引位置
        index++;

        //如果当前索引在数组范围内,即当前结点值存在。且当前结点值不为null
        // 将当前结点值构造成为 TreeNode ,并连接左右子树的结点,返回以当前结点为根结点的树
        /**1)注意,这里应该是if,不是while,这里只是判断,不需要用到循环!当然,用while也可以通过!因为最后不会死循环,都是return*/
        if (index < strArr.length && !strArr[index].equals("#")) /**2)注意,String是引用数据类型“==”比较地址,要使用equals()比较值,并且这里是不等于!*/
        {
     
            TreeNode curNode = new TreeNode(Integer.parseInt(strArr[index]));//前序遍历,先找当前结点
            curNode.left = Deserialize(strArr);//左指针指向左子树返回的结果
            curNode.right = Deserialize(strArr);//右指针指向右子树返回的结果

            return curNode;/**3)注意,最后记得将正确结果返回!*/
        }
        //如果当前结点值为“#”,或者index超出数组下标(事实上不会超出,因为遍历到最后一个null指针,直接返回null,结束递归,不会使得index超标)
        return null;
    }

题目62 二叉搜索树的第k个结点

  题目描述:给定一棵二叉搜索树,请找出其中的第k小的结点。例如, (5,3,7,2,4,6,8) 中,按结点数值大小顺序第三小结点的值为4。
  分析:二叉搜索树具有左小右大的有序特性,对它进行中序遍历,将得到一个从小到大的输出序列。

private int count = 0;//用于标记结点的位置
    private TreeNode ret = null;//用于保存第k个结点
    
    TreeNode KthNode(TreeNode pRoot, int k)
    {
     
        if(pRoot == null || k <= 0)
            return ret;//直接返回ret=null,表示满意找到相应结点
        //进行中序遍历,找到第k个结点,将其赋予ret
        inOrder(pRoot , k);
        return ret;
    }
    
    //找到以node为根结点的树的某个结点,使得count=k
    private void inOrder(TreeNode node , int k)
    {
     
        if(node == null)
            return;//node=null,说明到达树的末尾,结束递归
        
        //先中序遍历node左子树
        inOrder(node.left , k);
        //再遍历node
        count++;//遍历到一个结点,使得count计数+1
        //如果找到第k个结点,就将其赋予ret
        if(count == k)
            ret = node;
        //前面没找到,继续遍历右子树
        inOrder(node.right , k);
        /**
         * 1)如果元素个数小于k,最后ret不会被赋值,ret=null,表示没有找到相应结点
         * 2)如果元素个数大于等于k,最后一定会有count=k,就会把第k个结点赋予ret,找到!
         * 3)pRoot=null或者k<=0的情况前面已经判断
         */
    }

题目63 数据流的中位数

  题目描述:如何得到一个数据流中的中位数?如果从数据流中读出奇数个数值,那么中位数就是所有数值排序之后位于中间的数值。如果从数据流中读出偶数个数值,那么中位数就是所有数值排序之后中间两个数的平均值。我们使用Insert()方法读取数据流,使用GetMedian()方法获取当前读取数据的中位数。

  分析:首先要正确理解此题的含义,数据是从一个数据流中读出来的,因此数据的数目随着时间的变化而增加。对于从数据流中读出来的数据,当然要用一个数据容器来保存,也就是当有新的数据从流中读出时,需要插入数据容器中进行保存。那么我们需要考虑的主要问题就是选用什么样的数据结构来保存。
  方法一用数组保存数据。数组是最简单的数据容器,如果数组没有排序,在其中找中位数可以使用类比快速排序的partition函数,则插入数据需要的时间复杂度是O(1),找中位数需要的复杂度是O(n)。除此之外,我们还可以想到用直接插入排序的思想,在每到来一个数据时,将其插入到合适的位置,这样可以使数组有序,这种方法使得插入数据的时间复杂度变为O(n),因为可能导致n个数移动,而排序的数组找中位数很简单,只需要O(1)的时间复杂度。
  方法二用链表保存数据。用排序的链表保存从流中的数据,每读出一个数据,需要O(n)的时间找到其插入的位置,然后可以定义两个指针指向中间的结点,可以在O(1)的时间内找到中位数,和排序的数组差不多。
  方法三用二叉搜索树保存数据。在二叉搜索树种插入一个数据的时间复杂度是O(logn),为了得到中位数,可以在每个结点增加一个表示子树结点个数的字段,就可以在O(logn)的时间内找到中位数,但是二叉搜索树极度不平衡时,会退化为链表,最差情况仍需要O(n)的复杂度。
  方法四用AVL树保存数据。由于二叉搜索树的退化,我们很自然可以想到用AVL树来克服这个问题,并做一个修改,使平衡因子为左右子树的结点数之差,则这样可以在O(logn)的时间复杂度插入数据,并在O(1)的时间内找到中位数,但是问题在于AVL树的实现比较复杂。
  方法五最大堆和最小堆。我们注意到当数据保存到容器中时,可以分为两部分,左边一部分的数据要比右边一部分的数据小。用一个最大堆实现左边的数据存储,用一个最小堆实现右边的数据存储,向堆中插入一个数据的时间是O(logn),而中位数就是堆顶的数据,只需要O(1)的时间就可得到。
剑指offer刷题详细分析:part13:61题——67题_第2张图片
  相应的代码如下:

private int count = 0;//定义一个变量由于计算插入数据的数量
    /**
     创建左优先队列(最大堆实现),队首(堆顶)元素是最大的;创建右优先队列(最小堆实现),队首(堆顶)元素是最小的。
     左堆用于存放较小的数字,右堆用于存放较大的数字,且左右2个堆元素数量差不能大于1.
     我们拿到数据,先放入左堆,再放入右堆。即count为奇数的时候,将数字放入左堆,count为偶数的时候,将数据放入右堆。

     1)count为偶数:此时将元素放入右堆,因为右半边元素都要大于左半边,但是新插入的元素不一定比左半边元素来的大,
     因此需要先将元素插入左半边,然后利用左半边为大顶堆的特点,取出堆顶元素即为最大元素,此时插入右半边。
     2)count为奇数:此时将元素放入左堆,同理,先将元素放入右堆,取右堆堆顶元素(最小),放入左堆。
     */
    private PriorityQueue<Integer> right = new PriorityQueue<>();//右堆,最小堆
    private PriorityQueue<Integer> left = new PriorityQueue<>((num1 , num2) -> num2 - num1);//注意,返回 num2-num1就是变成最大堆(直接用就可以)

    /* 也可以写为下面
    private PriorityQueue left = new PriorityQueue<>(new Comparator()
    {
        @Override
        public int compare(Integer o1, Integer o2)
        {
            return o2 - o1;
        }
    });
    */

    public void Insert(Integer num)
    {
     
        count++;//每次插入一个数据 count +1
        if(count%2 == 0)//偶数,插入右堆
        {
     
            left.add(num);
            right.add(left.poll());
        }
        else//偶数,插入左堆
        {
     
            right.add(num);
            left.add(right.poll());
        }
    }

    public Double GetMedian()
    {
     
        if(count%2 == 0)
            return (double)(left.peek()+right.peek())/2;
        else
            return (double)left.peek();
    }
  • 注:这里参考大佬文章:添加链接描述

题目64 滑动窗口最大值(难)

  题目描述:给定一个数组和滑动窗口的大小,找出所有滑动窗口里数值的最大值。例如,如果输入数组{2,3,4,2,6,2,5,1}及滑动窗口的大小3,那么一共存在6个滑动窗口,他们的最大值分别为{4,4,6,6,6,5}; 针对数组{2,3,4,2,6,2,5,1}的滑动窗口有以下6个: {[2,3,4],2,6,2,5,1}, {2,[3,4,2],6,2,5,1}, {2,3,[4,2,6],2,5,1}, {2,3,4,[2,6,2],5,1}, {2,3,4,2,[6,2,5],1}, {2,3,4,2,6,[2,5,1]}。

  分析:如果不考虑时间开销,使用暴力遍历法,本题并不难解决,依次遍历所有的滑动窗口,扫描每个窗口中的所有数字并找出其中的最大值,这都很容易实现,但是如果滑动窗口的大小为k,那么需要O(k)的时间找最大值,对于长度为n大的数组,一共有(n-k+1)个窗口,总的时间复杂度为O((n-k+1)k) = O(nk)。

  这种题涉及 在某个区间内找出最值(最大值或者最小值),并且这个区间是在变化的(本题)或者是在某个区间找出前k大/小的值(LeetCode347题)。我们可以使用 堆实现的优先队列来实现,堆是使用二叉树的原理实现(其实是用数组实现),顶端的值是最大值/最小值(最大堆/最小堆),那么我们的优先队列队首的元素是队列的最大值/最小值。
  我们维护一个 size 大小的堆/优先队列(size是窗口大小),这样我们就可以每进入一个区间,就将旧的元素取出(O(logn)),并新的值添加到堆(O(logn)),最后将 堆顶/优先队列队首 的最大值元素取出(O(1)),添加、删除的时候堆内会通过 O(logn) 的操作,自动将堆顶设置为最大值。
  由于要遍历整个的数组,总的时间复杂度为 O(nlogn)。

public ArrayList<Integer> maxInWindows(int [] num, int size)
    {
     
        ArrayList<Integer> arrayList = new ArrayList<>();

        //注意排除 size=0 与 size大于数组长度的情况
        if(size==0 || size>num.length)
            return arrayList;
        /*
        分析:我们需要取出每个窗口的最大值,我们优先队列的队首必须是最大值,就是要使用最大堆实现优先队列。
        但是java提供的优先队列是最小堆实现,为了将其转换为最大堆,我们还需要传入一个 Comparator 对象来控制
         */

        PriorityQueue<Integer> queue = new PriorityQueue<>(new Comparator<Integer>()
        {
     
            /*
            这里,如果num1>num2,返回-1,说明定义在队列中 num1的小于num2,即定义优先队列中,比较的时候 较大数小于比较小的数,
            即数 “实际大小” 与 “比较大小” 相反。
            由于最小堆实现的优先队列会将最小的元素放在队首,即数越小在优先队列中优先级越高。(这里的大小指的是比较大小)
            那么“比较大小”中最小的数就会被放到队首,而这个数“实际大小”则是最大的!

            技巧:
            1)其实这种做题的时候,如果 num2-num1 不对,换成 num1-num2 就肯定没错。
            2)使用java最小堆实现的优先队列,如果是求最小值可以直接使用;
                如果是求最大值,实现 Comparator,使得 “实际大小” 与 “比较大小” 相反。
             */
            @Override
            public int compare(Integer num1, Integer num2)
            {
     
                return num2-num1;
            }
        });
        /**
         * 这里也可以直接使用 Lambda表达式
         * PriorityQueue queue = new PriorityQueue<>( (num1 , num2)-> num2-num1);
         */

        //先将第一个窗口的值添加到优先队列
        for (int i = 0; i < size ; i++)
        {
     
            queue.add(num[i]);
        }
        //将优先队列队首最大的元素(第一个窗口最大元素)添加到ArrayList,O(1).
        //注意只是获取队列队首最大值,不要出队队首元素
        arrayList.add(queue.peek());

        //下面定义2个指针,分别指向当前窗口的第一个位置的前一个位置(即上一个窗口的第一个位置)和当前窗口的最后位置,遍历下面所有窗口。
        //这个过程画图便可知
        for (int i = 0 , j = i+size;  j < num.length ; i++ , j++)
        {
     
            queue.remove(num[i]);//将上一个窗口的第一个位置的元素移出优先队列,O(logn)
            queue.add(num[j]);//将当前窗口的最后一个位置的元素添加到优先队列,O(logn)
            arrayList.add(queue.peek());//将当前优先队列的最大值存储到ArrayList,同样是获取队首元素,不是出队队首元素,O(1).
        }
        //最后总体复杂度是 nlogn
        return arrayList;
    }

题目65 矩阵中的路径

  题目描述:请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一个格子开始,每一步可以在矩阵中向左,向右,向上,向下移动一个格子。如果一条路径经过了矩阵中的某一个格子,则该路径不能再进入该格子。

  题解:参考矩阵中的路径

  复杂度分析:O(3^k * M * N),首先,我们可以从矩阵的任意结点开始查找,矩阵结点个数为 MN,每一个结点除了回头之外,其他3个位置都有可能是正确的,最差的情况下,每一个结点的这3个位置都会遍历到,从头到尾一共遍历k个结点(k是字符串的长度),因此,最差情况下,需要遍历:3^kMN 个结点!

  代码如下:(牛客网你就不能用一个二维数组表示矩阵吗,非得用一维数组,看起来很难受!)

int rows = 0;
    int cols = 0;
    public boolean hasPath(char[] matrix, int rows, int cols, char[] str)
    {
     
        this.rows = rows;
        this.cols = cols;
        for (int i = 0; i < rows ; i++)
        {
     
            for (int j = 0; j < cols ; j++)
            {
     
                //从 matrix 的i行j结点开始进行深度优先遍历,开始查找的是str的第0个结点
                 if(dfs(matrix , i , j , str , 0))
                     return true;//如果找到某一个符合,则返回true,没找到继续找下一个!
            }
        }
        //如果全部没有查找到,则return false
        return false;
    }
    
    private boolean dfs(char[] matrix , int i , int j , char[] str , int strIndex)
    {
     
        /**
        有3种情况表示查找不到,需要进行剪枝:
         1)数组行或者列越界;2)当前元素与要查找的元素不相符;3)当前元素之前查找过,可以通过设置合并到2)
         */
        if(i<0 || i>=rows || j<0 || j>=cols || str[strIndex]!=matrix[i*cols+j])
            return false;
        //当遍历到str的最后一个元素,且前面不符合的判断不生效,说明最后一个元素也符号,可以直接返回true
        if(strIndex == str.length-1)
            return true;

        //为了避免当前元素被重复查找,进入下一轮的查找之前,将当前元素设置为“/”
        char temp = matrix[i*cols+j];
        matrix[i*cols+j] = '/';
        //当前结点符号,查找当前结点下的其他路径!
        boolean res = dfs(matrix , i+1 , j , str , strIndex+1)
                || dfs(matrix , i-1 , j , str , strIndex+1)
                || dfs(matrix , i , j+1 , str , strIndex+1)
                || dfs(matrix , i , j-1 , str , strIndex+1);

        /**
        最后,记得将 matrix[i*cols+j] 还原,因为如果当前结点下的所有路径都不通,我们就必须要抛弃当前结点的路径,
         返回上一层的递归去查找其他结点,那么当前结点就相当于还没有查找过,必须将matrix[i*cols+j] 还原
         */
        matrix[i*cols+j] = temp;

        return res;
    }

题目66 机器人的运动范围

  题目描述:参考原题

  解答:参考文章:机器人的运动范围

  这一题类似65题,使用深度优先遍历和剪枝就可以解答!时间复杂度是O(mn),参考如下代码:

package com.lkj.problem66;

/** 同样使用深度优先遍历和剪枝(对比65题)
参考文章:https://leetcode-cn.com/problems/ji-qi-ren-de-yun-dong-fan-wei-lcof/solution/mian-shi-ti-13-ji-qi-ren-de-yun-dong-fan-wei-dfs-b/
 时间复杂度:O(mn),即能到达所有的结点!
 */
public class OfferGetTest66
{
     
    int m = 0 , n = 0 , k = 0;
    boolean[][] visited = null;//用于记录某个位置是否被访问过的数组

    public int movingCount(int m, int n, int k)
    {
     
        this.m = m;
        this.n = n;
        this.k = k;
        visited = new boolean[m][n];

        //从坐标为0,0位置开始查找
        return dfs(0 , 0);
    }

    //判断当前结点是否可达的方法,如果可达,进入下一个结点的判断,同时将总的可达结点数+1
    private int dfs(int row , int col)
    {
     
        //计算当前横纵坐标每一位数字的和
        int rowDigitSum = getDigitSum(row);
        int colDigitSum = getDigitSum(col);

        /** 如果当前结点不可达,直接返回0即可。当前结点不可达有几类情况:
        1)行列越界;2)当前结点被访问过(没有必要重复添加);3)当前结点不可达!
         */
        if(row >= m || col >= n || visited[row][col] || (rowDigitSum+colDigitSum) > k)
            return 0;

        /**
         根据题解的分析,我们只需要向右或者向下递归查找,就可以到达所有的可达结点,
         因此,在这里,末尾只需要向右或者向下查找即可!
         另外,如果当前结点被访问一次,将 visited[row][col] 设置为true,我们不需要像65那样遍历完后重新将 visited[row][col] 设置为false,
         因为如果当前结点可达,那么可达结点数就会+1,会向下一直找到不可达的结点,不会向65那样回到这个结点再去访问其他结点!
         其他还未到达的结点会由其他递归查找!(想象这个过程!)
         65是要找符合的路径,如果当前结点下的某一条路径不可达,会再次回答当前结点查找其他路径!
         */
        visited[row][col] = true;
        //将所有遍历可以到达的所有结点数加起来!
        return 1 + dfs(row+1 , col) + dfs(row , col+1);
    }

    //用于数字num计算各位和的方法
    private int getDigitSum(int num)
    {
     
        int sum = 0;
        while(num!=0)
        {
     
            sum += num%10;
            num = num/10;
        }
        return sum;
    }
}


题目67 剪绳子

  题目描述:把一根长度为n绳子剪成多m段,使得这m段绳子的乘积最大。(注意,m是不确定的,而n是确定的!,且m,n为整数, m>1,n>1,即绳子长度大于1,至少剪一刀)

方法1:动态规划 DP
  首先定义函数:f(n),f(n) 为把长度为n的绳子剪成若干段后各段长度乘积的最大值。在剪第一刀时,我们有n-1种选择,也就是说第一段绳子的可能长度分别为1,2,3…,n-1。那么,根据动态规划的递推公式,我们可以得到:f(n) = Max{ f(i) * f(n-i) },其中0   这里,需要理解 f(n) = Max{ f(i) *f(n-i) } 的含义:首先,剪第一刀,我们想得到 Max{ f(i) *f(n-i) },就需要尝试 n种 i 与 n-i 的分法,才能得到最大的 f(i)与f(n-i);但是,对于每一种 i 与 n-i 的分法,想知道 f(i) 与 f(n-i),还需要将他们剪第二刀继续切分…直到最后我们某一段无法再剪。(可以从剪第一刀开始,在脑袋里面模拟这个过程
  这是一个自上而下的递归公式,由于递归会有大量的不必要的重复计算,其实我们自下而上计算反而更加简单。
  因为自下而上的时间复杂度为O(n), 每次递推时要对i循环O(n) ,所以时间复杂度是O(n^2)
  从初始情况开始,一步步递推。

f(2) = 1; (1 * 1)
f(3) = 2; (1 * 2)
---f(2)f(3)知道,当绳子长度为2或者3的时候,我们不需要再剪,因为再剪下去,子段的乘积也不会大于父段的长度。
-- 但是,由于m>1,即我们至少剪一刀,对于绳子长度为2或者3的情况,我们还是需要剪一刀,此时f(2)=1,f(3)=2。但是,对于绳子长度大于等于4的情况,我们分隔到子段长度为2或者3的时候,我们不要再分隔!
f(4) = 4; (2 * 2)
f(5) = 6; (3 * 2)
f(6) = 9; (3 * 3)
f(7) = 12; (3 * 2 * 2 = 3 * f(4))
f(8) = 18; (3 * 3 * 2 = f(6) * 2 = 3 * f(5))
...
我们从2开始,将每一段x长的绳子分割后各段乘积的最大值f(x)存储到一个数组中,这样遍历到f(n)时,f(1)-f(n-1)的值都知道,那么就很容易求得:f(n) = Max{
      f(i) *f(n-i) }

  代码如下:

public int cutRope(int target)
    {
     
        //首先,绳子长度必须大于1,否则说明绳子长度不合法,直接返回0即可
        if(target < 2)
            return 0;

        /*
        由f(2)、f(3)知道,当绳子长度为2或者3的时候,我们不需要再剪,因为再剪下去,子段的乘积也不会大于父段的长度。
        但是,由于m>1,即我们至少剪一刀,对于绳子长度为2或者3的情况,我们还是需要剪一刀,此时f(2)=1,f(3)=2。
        对于绳子长度大于等于4的情况,我们分隔到子段长度为2或者3的时候,我们不要再分隔!

        结论:我们分为2种情况
        1)绳子长度为2、3的时候,直接返回f(x)的值;
        2)绳子长度大于等于4的时候,我们从头开始计算每一个子段的:f(i),并存储到一个数组中,这样遍历到f(n)时,f(1)-f(n-1)的值都知道,那么就很容易求得:f(n) = Max{ f(i) *f(n-i) }

        问题:为什么数组中 2,3 位置存储的不是 1,2?
        因为 f(2)=1,f(3)=2 是绳子长度为 2、3时我们不得不分隔的情况!这种情况我们直接得出结果。
        绳子长度大于等于4时,子段长度为2、3时不分割(再分割子段的乘积也不会大于父段的长度)!而数组是用来计算绳子长度大于等于4的情况,
        那么此时用于计算的 f(1)=1,f(2)=2,f(3)=3,但是他们只是用于计算绳子长度大于等于4的f(n),并不是真正的f(n).
        */
        if(target == 2)
            return 1;
        if(target == 3)
            return 2;

        int[] dp = new int[target+1];//数组target下标保存f(target),那么数组长度设置为 target+1

        //设置用于计算绳子长度大于等于4的f(0-3)的值,但是这些不是真正的f(0-3),只是用于计算
        dp[0] = 0;
        dp[1] = 1;
        dp[2] = 2;
        dp[3] = 3;

        int temp = 0;
        //从绳子长度为4开始,计算每一个 4-n-1 每一个子段的f(x),直到 f(n),target就是n
        for (int i = 4; i <= target ; i++)
        {
     
            //j长度到 i/2 的时候不需要继续累加,下面的情况都是反过来的,比如对于i=5: 1*4,2*3(j=2)就够了,继续计算就是 3*2,4*1是重复的!
            for (int j = 1; j <=i/2 ; j++)
            {
     
                temp = dp[j] * dp[i-j];
                if(temp > dp[i])
                    dp[i] = temp;
            }
        }
        //出循环,得到 dp[target]
        return dp[target];
    }

方法2:贪心算法
  如果我们按照如下的策略来剪绳子,则得到的各段绳子的长度的乘积将最大:当n≥5时,我们尽可能多地剪长度为3的绳子;当剩下的绳子长度为4时,把绳子剪成两段长度为2的绳子。
  接下来我们证明这种思路的正确性。首先,当n≥5的时候,我们可以证明:

2(n-2) > n :子段 2以及子段 (n-2) 的乘积大于n,说明n必须继续剪,乘积才会越大!
3(n-3) > n
3(n-3) ≥ 2(n-2)

即当 n ≥ 5 的时候,我们将n剪为2或者3段,子段乘积比不剪的大。且n剪为3与(n-3)的乘积大于n剪为2与(n-2),因此我们应该尽可能地多剪长度为3的绳子段。

问题1:为什么子段最小为2
上面动态规划的方法分析过,当子段为2或者3,再继续剪子段乘积不会比2或者3大,因此不必要再剪。

问题2:为什么不剪为更大的段 如 m 与 (n-m) (m>3),而只是剪为 2 与 (n-2) 或者 3 与 (n-3)?
因为剪为 m(m>3),我们还是可以将长度为 m 的子段继续剪为2或者3,这样m子段的乘积比m大。
因此,只要子段长度大于等于4,我们就将其继续剪为2或者3.

  前面证明的前提是n≥5.那么当绳子的长度为4呢?在长度为4的绳子上剪一刀,有两种可能的结果:剪成长度分别为1和3的两根绳子,或者两根长度都为2的绳子。注意到2×2>1×3,同时2×2-4,也就是说,当绳子长度为4时其实没有必要剪,只是题目的要求是至少要剪一刀。
  这种思路对应的参考代码如下:

public static int cutRope1(int target)
    {
     
        //同样,先排除n<2,n=2,n=3 的段
        if(target < 2)
            return 0;
        if(target == 2)
            return 1;
        if(target == 3)
            return 2;

        /*
        当n>=4的时候,我们尽量将n剪为3的段,当出现子段长度为4的时候,我们不应该剪为 1+3 的段,而应该剪为 2+2 的段。
         */

        /*
        先计算出最多能剪的3的段,这里的余数可能是:0,1,2。
        余数是0:target刚刚好分为多段3的段;
        余数是1:target中出现一个4的子段,我们这里将4分为 1+3 的段,实际上应该分为 2+2 的段。我们将 3 的段减一,再除以2,得出2的段;
        余数是2:target中某一4的段,直接计算2的数量即可。
         */
        int timesOf3 = target/3;

        if(target - 3*timesOf3 == 1)//出现4的段
            timesOf3--;//将3的段减一,把剩下的4子段分为2+2
        //不管剩下的子段是2,还是4,此时timeOf3都是确定的,我们可以直接计算剩下的2的子段数量
        int timesOf2 = (target - timesOf3*3)/2;// target - timesOf3*3 =2(没有4的子段,只有一个2子段)或者 4(有4的子段,有2个2子段)

        return (int)(Math.pow(3,timesOf3) * Math.pow(2,timesOf2));//注意,这里是3子段的乘积 乘以 2子段的乘积,不是加!!!
    }

关于贪心算法和动态规划的区别,参考文章:添加链接描述
添加链接描述

你可能感兴趣的:(数据结构,算法,剑指Offer)