LeetCode经典算法题目二(树、排序、查找、动态规划、回溯、贪心)

LeetCode经典算法题目一(字符串、数组、链表、栈、队列、哈希)

LeetCode之树、排序、查找、动态规划、回溯、贪心

  • 六、树
    • 1. 相同的树
    • 2. 对称二叉树
    • 3. 二叉树的最大深度(★)
    • 4. 将有序数组转换为二叉搜索树
    • 5. 平衡二叉树
    • 6. 二叉树的最小深度
    • 7. 路径总和
    • 8. 二叉树的镜像(★★)
    • 9. 合并二叉树(★)
    • 10. 二叉树中第二小的节点(★)
    • 11. 重建二叉树(★)
    • 12. 二叉树的层平均值(★)
  • 七、排序
    • 1. 交换排序——冒泡排序(★)
    • 2. 交换排序——快排(★★★)
    • 3. 选择排序——简单选择排序
    • 4. 选择排序——堆排序(★)
    • 5. 归并排序
  • 八、查找
    • 1. 二维数组中的查找(★)
    • 2. 二分查找
    • 3. 找出n个数中第k大的元素(★)
    • 4. top k
  • 九、动态规划
    • 1. 最长公共子序列(★)
  • 十、回溯
    • 1. 字符串的排列
  • 十一、贪心算法
    • 1. 最大字序和(★)

六、树

1. 相同的树

树结点类:

public class TreeNode {
	int val;    //结点值
    TreeNode left;   //左结点
    TreeNode right;    //右结点
    TreeNode(int x) { val = x; }
}

算法: 递归。每次比较两个树的当前结点的值,可分四种情况:

  1. 结点均为空 √
  2. 一个为空一个不为空 ×
  3. 均不为空且结点值相同 √
  4. 均不为空但结点值不同 ×

其中,若为第三种情况就要再比较它们的子树是否相同,即把当前结点的左右子结点再作为参数传递,最后返回左右子树分别比较后相与的结果值。(意思是:必须满足左右子树全部都走通了,没有一个过程返回值为false,最终结果才能返回true)

class Solution {
    public boolean isSameTree(TreeNode p, TreeNode q) {
        if(p==null && q==null)
            return true;
        if(p==null || q==null)
            return false;
        if(p.val == q.val) 
            return (isSameTree(p.left,q.left) && isSameTree(p.right,q.right));
        return false;
    }
}

2. 对称二叉树

算法一: 递归。在同一个类中再写一个比较两树结点的方法,递归调用该方法,每次比较两子树对应结点值。(此题与上一题的区别: 上一题是两棵树进行横向比较;本题是只有一棵树进行内部比较,但使用递归方法我需要把一棵树拆成两棵来比较,方法是:拷贝根结点)这样一来算法思想就跟上题一样,分为四种情况:

  1. 结点均为空 √
  2. 一个为空一个不为空 ×
  3. 均不为空且结点值相同 √
  4. 均不为空但结点值不同 ×

其中,若为第三种情况就要再比较它们的子树是否对称相同,即把左右结点作为参数传递,最终返回比较后相与的结果值。
注意: 本题跟上题在返回结果处有本质区别,本题由于树是对称的,所以比较的是左结点的左结点右结点的右结点看是否相同。“左”、“右”进行比较,若相同才叫对称。

class Solution {
    public boolean isSymmetric(TreeNode root) {
        return compare(root,root);
    }
    public boolean compare(TreeNode l,TreeNode r){
        if(l==null && r==null)   //情况1
            return true;
        if(l==null || r==null)    //情况2
            return false;
        if(l.val == r.val){     //情况3
            return compare(l.left,r.right)&&compare(l.right,r.left);
        }
        return false;    //情况4
    }
}

算法二: 迭代
(待完善)

3. 二叉树的最大深度(★)

算法: 深度优先算法(递归实现)

class Solution {
    public int maxDepth(TreeNode root) {
        if(root==null)
            return 0;
        int l_len = maxDepth(root.left);
        int r_len = maxDepth(root.right);
        return Math.max(l_len,r_len)+1;
    }
}

4. 将有序数组转换为二叉搜索树

二叉搜索树是指:或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值; 它的左、右子树也分别为二叉排序树。

题目描述:将一个按照升序排列的有序数组,转换为一棵高度平衡二叉搜索树。本题中,一个高度平衡二叉树是指一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1。
算法: 递归。每一次执行递归方法都返回一个根结点。根结点的值是数组最中间的元素。这样一来数组就被中间元素拆成左右两个数组,分别作为根结点的左右子树。

length 0 1 2 3 4 ……
n=length/2 / 0 0 1 2 ……
有无左右子树? / 无子树 有左子树 有左子树、右子树 有左子树、右子树 有左子树、右子树

注意: 要判断三种情况:

  1. 如果数组长度为0,那么就没有树结点了,直接返回null
  2. 判断数组长度是否大于1,因为只有大于1才会有左子树,否则不用进行查找左右子树
  3. 数组长度大于1之后还需要判断数组长度是否大于2,因为只有大于2才会出现右子树
class Solution {
    public TreeNode sortedArrayToBST(int[] nums) {
        if(nums.length == 0)   //情况1,数组长度为0,返回空
            return null;
        int m = nums.length/2;
        int n = nums[m];
        TreeNode root = new TreeNode(n);
        if(nums.length>1){   //情况2,数组长度大于1,有左子树
            int[] num1 = new int[m];
            if(nums.length>2){    //情况3,数组长度大于2,有右子树
                int[] num2 = new int[nums.length-m-1];
                System.arraycopy(nums,m+1,num2,0,nums.length-m-1);   //利用数组拷贝方法将数组拆分成左右两个
                root.right = sortedArrayToBST(num2);
            }
            System.arraycopy(nums,0,num1,0,m);
            root.left = sortedArrayToBST(num1);
        }
        return root;   //返回根结点
    }
}

5. 平衡二叉树

题目描述:给定一个二叉树,判断它是否是高度平衡的二叉树。本题中,一棵高度平衡二叉树定义为:一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过1。

ps:由于本题没有要求需要为二叉搜索树,所以只用考虑子树高度差这一个方面。
算法一: 递归+递归。该类中的两个方法分别都进行递归,其中:

  • 第一个方法:递归地判断每个结点的左右子树高度差的绝对值是否不超过1;
  • 第二个方法:查询每个结点的深度

第一个方法在递归的时候会调用第二个方法,连起来就是:每一次拿到一个结点,会出现三种情况:

  1. 如果结点为空直接返回true,满足平衡二叉树要求
  2. 结点不为空的话我就去计算它的左右子树分别的高度(即深度),于是调用第二个方法(即第3题求二叉树深度的方法)。如果两个子树高度差的绝对值大于1,那么直接返回false,不满足平衡二叉树。
  3. 如果高度差的绝对值小于等于1,则说明该结点满足平衡二叉树,那么就再去看剩下的结点是否满足要求,即:把该结点的左右结点分别作为参数来递归。

因为只有当每个结点都满足它的子树的高度差绝对值不大于1,才能算是平衡二叉树,所以对每一个结点都得查它们左右子树的高度。

class Solution {
    public boolean isBalanced(TreeNode root) {
        if(root == null)   //情况1
            return true;
        int x = Math.abs(TreeDepth(root.left)-TreeDepth(root.right));
        if(x>1)    //情况2
            return false;
        //情况3
        return isBalanced(root.left)&&isBalanced(root.right);
    }
    public int TreeDepth(TreeNode root){
        if(root==null)
            return 0;
        int l=TreeDepth(root.left);
        int r=TreeDepth(root.right);
        return Math.max(l,r)+1;
    }
}

算法二:
(待完善)

6. 二叉树的最小深度

算法: 递归
题目描述:最小深度是从根节点到最近叶子节点的最短路径上的节点数量。
本题的思想与第3题二叉树的最大深度几乎完全相同,但有些区别,区别在于:如果左右子树只有一棵的结点为null,该怎么考虑?

  • 在求二叉树的最大深度中,我们对于null结点直接返回为0,若左右子树只有一个为null的话就直接用Math.max()方法找不为空的子树高度,不用去管null结点了,因为它肯定最小,被淘汰了。
  • 但是这道题说的是“从根节点到最近叶子节点”,就不能直接用Math.min()方法取最小值,因为这样得到的子树最小高度一定是0,但实际上null结点不能算作叶子节点,我需要去看不为空的那一棵树,它才是作为根结点的叶子节点的。也就是说如果根结点只有左子树或者只有右子树,我就不能直接取最小值(0),而应该取不为空的子树高度。因此对于子树返回的高度要考虑四种情况:
  1. 左子树高度为0,右子树高度不为0:返回右子树高度+1
  2. 左子树高度不为0,右子树高度为0:返回左子树高度+1
  3. 左右子树高度均不为0:返回二者中最小值+1
  4. 左右子树高度均为0:返回0+1=1

这里“+1”的意思是加上根结点的高度。情况3和情况4可以合并为一种情况,都可以是“最小值+1”,因为0和0取最小值也是0.

class Solution {
    public int minDepth(TreeNode root) {
        if(root == null)
            return 0;
        int l = minDepth(root.left);
        int r = minDepth(root.right);
        if(l==0 && r!=0)   //情况1
            return r+1;
        if(l!=0 && r==0)    //情况2
            return l+1;
        else      //情况3和4
            return Math.min(l,r)+1;
    }
}

7. 路径总和

题目描述:给定一个二叉树和一个目标和,判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和。
说明: 叶子节点是指没有子节点的节点。

算法: 递归。题目要求路径必须为根结点到叶子节点,那么最终的结束点一定归结于叶子节点,换句话说就不能在中途停下来,即使可能从根结点到某个中间结点的值加起来等于目标值,也不能算最终路径,因为路径的结尾必须归结到叶子节点。这就说明了本题的解题思路:递归该方法,每一次拿到一个结点和一个目标值,有三种情况:

  1. 该结点为空,说明到结尾了都没找到符合要求的路径,直接返回false
  2. 该结点的左右两个子结点均为空,说明它是叶子节点,那么判断结点值是否与sum相等,相等则返回true,否则返回false
  3. 若不满足子结点为空,说明它不是叶子节点,那就继续往下走,去看它的左右结点是否有符合要求的路径,但这时目标值要改为sum = sum - root.val;
class Solution {
    public boolean hasPathSum(TreeNode root, int sum) {
        if(root==null)    //情况1
            return false;
        if(root.left==null && root.right==null){   //情况2,若左右结点为空则说明该结点为叶子节点
            if(root.val == sum)
                return true;
            else return false;
        }
        //情况3
        sum = sum - root.val;
        return hasPathSum(root.left,sum)||hasPathSum(root.right,sum);
    }
}

8. 二叉树的镜像(★★)

题目描述:请完成一个函数,输入一个二叉树,该函数输出它的镜像。
算法: 递归。每次将root结点的左右结点交换位置,再把左右结点作为root递归。

class Solution {
    public TreeNode mirrorTree(TreeNode root) {
        if(root == null)
            return root;
        TreeNode tem;
        tem = root.left;   //将左右结点交换位置
        root.left = root.right;
        root.right = tem;
        mirrorTree(root.left);   //递归
        mirrorTree(root.right);
        return root;  //返回根结点
    }
}

9. 合并二叉树(★)

题目描述:给定两个二叉树,想象当你将它们中的一个覆盖到另一个上时,两个二叉树的一些节点便会重叠。
你需要将他们合并为一个新的二叉树。合并的规则是如果两个节点重叠,那么将他们的值相加作为节点合并后的新值,否则不为 NULL 的节点将直接作为新二叉树的节点。

算法: 递归。
第一种写法:代码复杂冗余、运行较慢。原因是我建了一个新的树,然后对当前两棵树的结点值进行了四种情况的讨论。但是实际上有更美观的写法。看下面第二种写法。

class Solution {
    public TreeNode mergeTrees(TreeNode t1, TreeNode t2) {
        TreeNode mergeTree;
        if(t1!=null && t2!=null){
            mergeTree=new TreeNode(t1.val + t2.val);
            mergeTree.left = mergeTrees(t1.left,t2.left);
            mergeTree.right = mergeTrees(t1.right,t2.right);
        }
        else if(t1==null && t2!=null){
            mergeTree=new TreeNode(t2.val);
            mergeTree.left = mergeTrees(null,t2.left);
            mergeTree.right = mergeTrees(null,t2.right);
        }
        else if(t1!=null && t2==null){
            mergeTree=new TreeNode(t1.val);
            mergeTree.left = mergeTrees(t1.left,null);
            mergeTree.right = mergeTrees(t1.right,null);
        }
        else
            mergeTree=null;
        return mergeTree;
    }
}

第二种写法:简洁、快速得多。不用创建新树,把t2合并到t1,用t1来保存新树的所有结点。只用分三种情况:

class Solution {
    public TreeNode mergeTrees(TreeNode t1, TreeNode t2) {
        if(t1==null)  //情况1:若t1为空,直接返回第二棵树
            return t2;
        if(t2==null)  //情况2:若t2为空,直接返回第一棵树
            return t1;
        //情况3:若t1、t2都不为空,就把当前两个结点值相加,加到t1的结点中。然后再去合并t1和t2的子结点。
        t1.val = t1.val + t2.val;
        t1.left=mergeTrees(t1.left,t2.left);
        t1.right=mergeTrees(t1.right,t2.right);
        return t1;  //返回t1即可
    }
}

10. 二叉树中第二小的节点(★)

题目描述:给定一个非空特殊的二叉树,每个节点都是正数,并且每个节点的子节点数量只能为 2 或 0。如果一个节点有两个子节点的话,那么这个节点的值不大于它的子节点的值。
给出这样的一个二叉树,你需要输出所有节点中的第二小的值。如果第二小的值不存在的话,输出 -1 。

算法: 递归。
第一种写法:代码繁琐,考虑的情况很多,2x2=4种情况,一不小心就会在哪里出错。第二种写法更好。

class Solution {
    public int findSecondMinimumValue(TreeNode root) {
        int smin=-1,max_,x;
        if(root.left!=null){
            if(root.left.val == root.right.val){
                if(root.val == root.left.val){
                    int i=findSecondMinimumValue(root.left);
                    int j=findSecondMinimumValue(root.right);
                    if(i!=-1 && j!=-1)
                        smin = Math.min(i,j);
                    else{
                        if(i==-1)
                            smin = j;
                        else
                            smin = i;
                    }
                }
                else{
                    smin = root.left.val;
                }
            }
            else{
                smin = Math.min(root.left.val,root.right.val);
                max_ = Math.max(root.left.val,root.right.val);
                if(root.val == smin){
                    if(smin == root.left.val)
                        x=findSecondMinimumValue(root.left);
                    else
                        x=findSecondMinimumValue(root.right);
                    if(x!=-1)
                        smin = Math.min(x,max_);
                    else smin=max_;
                }
            }
        }
        return smin;     
    }
}

第二种写法:很简洁,虽然也有4种情况,但是每种情况是独立的,并不是像2x2这种嵌套的,而是1+1+1+1,这样每种情况是分开的独立的就会更清楚一点。
此外,这道题目要特别注意!!每次递归最开始是判断当前结点有无子结点,而不是判断当前结点是否为空!!!因为我要拿的是子结点的值,如果子结点都为空了,那拿值的时候就会报空指针异常!!! 而且题目也出得特别严谨,说了是给一个非空的二叉树,所以不去判断当前结点是否为空一定是没有任何问题的。
再强调一遍,一定不能对一个空结点拿值!!

class Solution{
    public int findSecondMinimumValue(TreeNode root) {
        if(root.left == null)  //如果没有子结点,说明更不可能有第二小的结点,返回-1即可
            return -1;
        int left=root.left.val;  //拿到左右结点的值
        int right=root.right.val;
        if(root.val == root.left.val)  //若当前结点值与左结点值相同,就去找以左结点为根的树的第二小的结点值
            left=findSecondMinimumValue(root.left);
        if(root.val == root.right.val)  //若当前结点值与右结点值相同,就去找以右结点为根的树的第二小的结点值
            right=findSecondMinimumValue(root.right);
        if(left!=-1 && right!=-1)  //若左右都不为-1,则找其中最小的
            return Math.min(left,right); 
        if(left==-1)  //若左为-1,则返回右
            return right;
        return left;  //若右为-1,则返回左
    }
}

11. 重建二叉树(★)

题目描述:输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。

算法: 递归。对于前序遍历和中序遍历,要记住它们的两个原则:

  1. 前序遍历顺序一定是:根结点左子树右子树
  2. 中序遍历顺序一定是:左子树根结点右子树

因此,可以利用它们的这两个性质来进行重建,分为以下几个步骤:

  1. 前序遍历的第一个一定是根结点,每次递归都先拿到它;
  2. 接着在中序遍历中找到这个根结点,以它为界,可以分为左子树右子树
  3. 根据分出来的中序遍历的左、右子树的长度,在前序遍历中找到对应的左、右子树的前序遍历
  4. 然后把左、右子树前序遍历中序遍历再用同样的方法进行递归。
class Solution {
    public TreeNode buildTree(int[] preorder, int[] inorder) {
        if(preorder.length == 0) 
            return null;
        TreeNode root = new TreeNode(preorder[0]);  //拿到根结点
        int i;
        for(i=0;i<inorder.length;i++){  //找到根结点在中序遍历中的位置
            if(inorder[i] == preorder[0])
                break;
        }
        //为左、右子树的前、中序遍历数组分配内存空间
        int[] inleft = new int[i];
        int[] inright = new int[inorder.length-i-1];
        int[] preleft = new int[i];
        int[] preright = new int[inorder.length-i-1];
        for(int j=0;j<inleft.length;j++){  //创建左子树的中序遍历
            inleft[j] = inorder[j];
        }
        for(int j=0;j<inright.length;j++){  //创建右子树的中序遍历
            inright[j]=inorder[i+j+1];
        }
        for(int j=0;j<preleft.length;j++){  //创建左子树的前序遍历
            preleft[j]=preorder[1+j];
        }
        for(int j=0;j<preright.length;j++){  //创建右子树的前序遍历
            preright[j]=preorder[i+j+1];
        }
        root.left=buildTree(preleft,inleft);  //将左子树的前、中序遍历进行递归
        root.right=buildTree(preright,inright);  //将右子树的前、中序遍历进行递归
        return root;  //返回根结点
    }
}

12. 二叉树的层平均值(★)

题目描述:给定一个非空二叉树, 返回一个由每层节点平均值组成的数组。

Java集合主要由2大体系构成,分别是Collection体系和Map体系,其中CollectionMap分别是2大体系中的顶层接口

  • Collection主要有三个子接口,分别为List(列表)Set(集)Queue(队列)。其中,List、Queue中的元素有序可重复,而Set中的元素无序不可重复。(有关Set集在本节的最后补充)

  • Map同属于java.util包中,是集合的一部分,但与Collection是相互独立的,没有任何关系。Map中都是以key-value的形式存在,其中key必须唯一,主要有HashMap、HashTable、TreeMap三个实现类。

那么下面就来看看在java api中如何对这些接口和类进行描述的:

List(列表)接口:
在这里插入图片描述
在这里插入图片描述

  • 实现类——ArrayList:底层通过数组实现,随着元素的增加而动态扩容
    我们在使用数组时有一些很不好的体验,比如在数组的两个数据间插入数据是很麻烦的,而且在声明数组的时候,必须同时指明数组的长度,数组的长度过长,会造成内存浪费,数组和长度过短,会造成数据溢出的错误。为了克服数组的缺点,ArrayList出现了,它是用于数据存储检索的专用类,它的大小是按照其中存储的数据来动态扩充与收缩的。所以,我们在声明ArrayList对象时并不需要指定它的长度。它可以很方便的进行数据的添加,插入和移除。

在这里插入图片描述

  • 实现类——LinkedList:底层通过链表来实现,随着元素的增加不断向链表的后端增加节点。

在这里插入图片描述
Queue(队列)接口:
在这里插入图片描述在这里插入图片描述
Q1: 应该用接口类型来引用对象还是实现类的类型来引用对象?
结论:优先使用接口而不是类来引用对象
但是,当你用接口类型来引用对象时,如果某些方法存在于实现类中,那么你是不能直接调用的,否则会报错。
也就是说,要使用接口来引用对象是有条件的——你即将要使用的方法全部是接口中的方法,不能单独使用实现类独有的方法。当然,如果你想使用实现类本身的方法时,可以选择用实现类的类型来引用对象。

Q2: double 和 Double(int 和 Interger、float 和 Float、string 和 String)?
本质区别:double是基本数据类型,Double是封装的类。

  • double是基本的数据类型,初始化:double i = 2.45;
  • Double是double的封装类,初始化:Double di = new Double(2.45);
  • Double和double都可以表示某一个数值;
  • Double和double不能够互用,因为他们两种不同的类型;比如:list是一个已经实例化的列表,那么:list.add(i); 不可以! list.add(di);可以!

在了解了这些接口和类之后,我们就可以开始解题了。

算法: 层次遍历的广度优先搜索

class Solution {
    public List<Double> averageOfLevels(TreeNode root) {
        List<Double> average = new ArrayList<>();  //实例化一个底层为数组的列表,用来存放各层平均值
        Queue<TreeNode> queue = new LinkedList<>();  //实例化一个LinkedList来实现队列接口
        double sum;
        queue.add(root);
        while(!queue.isEmpty()){  //当队列不为空时循环
            int m = queue.size();  //记录每次循环时队列的大小(该层的结点数量)
            sum = 0;
            for(int i=1;i<=m;i++){  //只从队列中取该层的所有结点(因为每一层的结点数量就是刚开始队列的大小m)
                TreeNode node = queue.poll();  //队首元素出队
                sum += node.val;
                if(node.left!=null)  //使左结点入队
                    queue.add(node.left);
                if(node.right!=null)  //使右结点入队
                    queue.add(node.right);
            }
            average.add( sum/m );  //计算平均值并放入数组列表中
        }
        return average;
    }
}

补充一下Set:
实际上,在看过源码后会发现,Set的实体类主要就是以map为基础,相对应的使用环境和意义也和对应的map相同。Set主要包含三种存放数据类型的变量,分别是HashSetLinkedHashSetTreeSet .
其中,HashSet、LinkedHashSet无序且不可重复。TreeSet是以TreeMap作为存储结构的,有序不可重复。

来看看在 java api 中如何对 Set 集进行描述的:
在这里插入图片描述
常用的方法:
在这里插入图片描述
注意1:若对对象进行重复添加,是没有任何作用的,重复添加多个相同对象时,Set中只保留一个,另外,添加null空指针也是可以的。

注意2:Set中元素因为其无序性,所以不能用 get() 方法来查找,只能通过foreach()或者iterator()方法遍历,并且每次遍历输出的结果顺序是不一样的。

看一下Iterator接口的描述:
在这里插入图片描述
常用的方法:
在这里插入图片描述
Q3: 为什么会构造Set这个集合呢?
实际上就是利用Mapkey-value键值对的方式,通过key的唯一的特性,主要将Set构建的对象放入key中,以这样的方式来使用集合的一些特性,从而可以直接用Set来进行调用。

七、排序

排序的稳定性:如果排序的表中有多个关键字相同的元素,经过排序后这些具有相同关键字的元素之间的相对次序保持不变,则这种排序方法是稳定的;反之,如果相同关键字的元素之间的相对次序发生了变化,则排序方法是不稳定的。

各种排序方法的性能一览:

排序方法 平均时间复杂度 空间复杂度 稳定性
冒泡排序 O(n2) O(1) 稳定
快速排序 O(nlog2n) O(log2n) 不稳定
简单选择排序 O(n2) O(1) 不稳定
堆排序 O(nlog2n) O(1) 不稳定
二路归并排序 O(nlog2n) O(n) 稳定

java中Arrays.sort(nums)方法的源码就是采用归并排序算法,因为它快速且稳定 。

1. 交换排序——冒泡排序(★)

算法: 从后往前遍历,遍历n次,每一次遍历都通过无序区中相邻元素之间的比较和位置的交换来使得最小的元素像气泡一样逐渐往前“漂浮”直至“浮出水面”。

平均时间复杂度O(n2)

稳定的排序方法

public static void bubblesort(int[] nums){
	int len = nums.length;
    int tmp;
    for(int i=0;i<len;i++){  
        boolean swap=false;  //设置交换标识,表明本次循环是否发生交换动作(优化算法)
        for(int j=len-1;j>i;j--){
            if(nums[j]<nums[j-1]){  //若后面的比前面的小那么就交换
                swap = true;
                tmp = nums[j];
                nums[j] = nums[j-1];
                nums[j-1] = tmp;
            }
        }
        if(!swap)  //若本次循环没有发生交换,则说明已经有序,可以直接退出,不需要再执行循环交换了
            break;
    }     
}

2. 交换排序——快排(★★★)

算法: 递归 + 划分

每一趟划分归位一个元素,共要进行 log2n 趟划分(递归树的高度为 O(log2n) ),一趟划分的时间为 O(n),因此最好的时间复杂度:O(nlog2n); 空间复杂度:O(log2n)

最坏的时间复杂度:O(n2)
平均时间复杂度O(nlog2n);空间复杂度:O(n)

不稳定的排序方法

public static void quicksort(int[] nums, int i, int j) {  //快排
	if (i < j) {
		int m = partition(nums, i, j);  //划分一次,得到一个归位元素的下标m
		quicksort(nums, i, m - 1);  //千万小心!!不是从 0 开始,而是从 i 开始,否则就变成每次都从头来排了,那不是快排,是慢排。。。
		quicksort(nums, m + 1, j);  //继续快排右边部分
	}
}

public static int partition(int[] nums, int i, int j) { //一趟划分,希望归位一个元素
	int tmp = nums[i];  //备份数组的第一个元素的值,作为基准元素
	while (i < j) {
		while (i < j && nums[j] >= tmp)  //从后开始扫描,发现一个比基准元素小的则停止
			j--;
		nums[i] = nums[j];  //把找到的更小的元素放在左半边,即下标i处
		while (i < j && nums[i] <= tmp)  //从前扫描,发现一个比基准元素大的则停止
			i++;
		nums[j] = nums[i];  //把找到的更大的元素放在右半边,即下标j处
	}
	nums[i] = tmp;  //把基准元素放在索引为i的位置,划分完成
	return i;  //此时i和j是一样的,随便返回谁都可以
}

3. 选择排序——简单选择排序

思想: 将数组分为有序区无序区。每一次从无序区中 选择 最小的一个放入有序区的末尾。

平均时间复杂度O(n2)

不稳定的排序方法

public static void selectsort(int[] nums) {
	int i, j, k;
	for (i = 0; i < nums.length - 1; i++) {
		k = i;  //指针k一直指向数组中最小元素的下标
		for (j = i + 1; j < nums.length; j++) {  //无序区从i+1开始
			if (nums[j] < nums[k]) {  //若无序区中元素更小,指针k就指向该元素下标
				k = j;
			}
		}
		if (k != i) {  //若指针k不再是最初的第一个元素下标i(说明找到更小的了),则把指针k与i指向的元素进行交换
			int tmp = nums[k];
			nums[k] = nums[i];
			nums[i] = tmp;
		}
	}
}

4. 选择排序——堆排序(★)

堆排序的关键是筛选(sift): 筛选即挑选出最大的元素,把它放在当前大根堆的根结点处。
筛选过程:假设 完全二叉树的根结点的左、右子树 已经是 大根堆了,那么将它两个孩子的关键字的最大者与根结点进行比较,将其与最大孩子进行交换,但这有可能破坏下一级堆,因此要循环筛选。

特别注意sift(int[] nums, int low, int high)方法的参数lowhigh并不是数组下标,而是数组第几个元素(从1开始的),若要用它们表示数组元素的话,还需要对ij进行-1,才是真正的下标,即nums[i-1]或者nums[j-1]

堆排序算法:

  • 建立初始堆;
  • 将大根堆的根结点(即数组第一个元素)与大根堆的尾结点(即数组除去已排序的末尾元素)交换,这样相当于把数组当前最大的元素往后归位,每一次都把当前最大的元素从数组末尾开始依次从后往前放置,最终的数组就是按升序排列了。
  • 交换后对前面的堆再次进行sift(因为它只有根结点有变化,其左、右子树还是大根堆,所以可以直接用筛选方法使其成为大根堆)。
  • 循环操作第二步和第三步。

平均时间复杂度O(nlog2n)

不稳定的排序方法

public static void sift(int[] nums, int low, int high) {  //筛选
	int i = low, j = 2 * i;
	int tmp = nums[i - 1];  //tmp存放根结点
	while (j <= high) {  //循环遍历堆,希望通过比较能够把原来的根结点放在适当的位置
		if (j < high && nums[j - 1] < nums[j])  //若右孩子更大,则指针指向右孩子
			j++;
		if (tmp < nums[j - 1]) {  //若孩子结点比原根结点大,则把孩子结点的值调整到当前根结点处。注意:每次比较都是把孩子结点与最初的原根结点进行比较,因为我的目的是找到可以放原根结点的地方。
			nums[i - 1] = nums[j - 1];
			i = j;  //重置i和j的值,以便进行下一次循环
			j = 2 * i;
		} else break;  //若原根结点不比孩子结点小,直接退出即可,后面的肯定也是大根堆,不用管了
	}
	nums[i - 1] = tmp;  //原来的根结点放在第i个元素位置(这时的i其实是j,若经过了重置的话)
}

public static void heapsort(int[] nums) {  //堆排序
	for (int i = nums.length / 2; i > 0; i--)  //建立初始堆,从后往前循环建立,因为使用sift的前提是左右子树已是大根堆,所以先要让孩子成为大根堆,再慢慢加入根结点来调整堆。
		sift(nums, i, nums.length);
	for (int j = nums.length; j > 1; j--) {  //交换大根堆的第一个结点和最后一个结点。把第一个结点放入有序区(它一定是当前堆的最大值)。
		int tmp = nums[j - 1];
		nums[j - 1] = nums[0];
		nums[0] = tmp;
		sift(nums, 1, j - 1);  //由于根结点有可能变化,因此需要再次调整大根堆
	}
}

5. 归并排序

二路归并排序算法:
把无序数组R[n]看作是n个长度为1的有序序列,然后进行两两归并,得到 n/2 个长度为2的有序序列,再进行两两归并,得到 n/4 个长度为4的有序序列,……,直到得到一个长度为n的有序序列。

java.util包中的Arrays.sort()方法就采用的是归并排序,因为它快速且稳定。

二路归并需要进行 log2n 趟,而每趟归并时间为O(n),故其最好和最坏情况下时间复杂度均是:O(nlog2n)
平均时间复杂度O(nlog2n),空间复杂度:O(n)

稳定的排序方法

八、查找

1. 二维数组中的查找(★)

题目描述:在一个 n * m 的二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。二维矩阵示例如下:
[
[1, 4, 7, 11, 15],
[2, 5, 8, 12, 19],
[3, 6, 9, 16, 22],
[10, 13, 14, 17, 24],
[18, 21, 23, 26, 30]
]

算法: 找二维数组右上角的元素,target比它大就往下找,比它小就往左找。
注意: 只能找右上角或者左下角,不能找左上角和右下角,因为只有右上角和左下角元素才满足:同一行最大并且同一列最小(或者同一行最小并且同一列最大),在比较了它与target的大小后可以往唯一的方向去找,所以一定可以找到满足要求的值。
求二维数组长度的方法:
int n=matrix.length;计算行数
int m=matrix[0].length;计算列数
一定要保证 只有当 n>0且m>0 才能进行后续查找,有任意一个为 0 都要返回false
注意: n和m为0的情况并非只有n=0且m=0一种形式,还可能有:

  • n=0,[]
  • n>0但是m=0,比如:[[]]

所以要判断 n 是否为0,判断 m 是否为0。
提醒:千万要注意不要数组越界

class Solution {
    public boolean findNumberIn2DArray(int[][] matrix, int target) {
        int n=matrix.length;
        if(n==0)   //判断行数是否为0
            return false;
        int m=matrix[0].length;   
        if(m==0)   //判断列数是否为0
            return false;
        int i=0,j=1;
        int x=matrix[i][m-j];
        while(i<n && j<=m){
            x = matrix[i][m-j];    //先取右上角元素
            if(target > x)    //目标值更大,往下找
                i++;
            else if(target < x)   //目标值更小,往左找
                j++;
            else if(target == x)
                return true;
        }
        return false;
    }
}

2. 二分查找

二分查找也称折半查找,查找的前提必须是有序的线性表。
查找思想是:把数组的当前区间一分为二,将目标值与区间的中间元素进行比较,若比它大则去右边的区间找,若比它小则去左边的区间找。

查找失败时所需比较的关键字个数不会超过判定树的高度,即便是最坏的情况下查找成功的比较次数也不会超过这个高度,因此:时间复杂度为: O(log2n)

class Solution {
    public int search(int[] nums, int target) {
        int low=0, high=nums.length-1;
        int mid;  //区间的中间元素
        while(low <= high){  //保证下限<=上限的前提下循环
            mid = (low + high)/2;
            if(target < nums[mid])
                high = mid - 1;   //若目标值更小,则把区间的上限更新为中间元素索引-1
            else if(target > nums[mid])
                low = mid + 1;   //若目标值更大,则把区间的下限更新为中间元素索引+1
            else
                return mid;
        }
        return -1;
    }
}

3. 找出n个数中第k大的元素(★)

常用的时间复杂度:
常数阶、对数阶、线性阶、线性对数阶、平方阶、立方阶、阶乘阶
O(1) < O(log2n) < O(n) < O(nlog2n) < O(n2) < O(n3) < O(n!)

当然可以采用冒泡排序或者简单选择排序,时间复杂度为 O(k*n);或者建立最小堆,适合海量数据,时间复杂度为 O(nlog2k)。但一般选择基于快排的方法,能够有很好的时间复杂度:

算法思想: 基于快排,每趟划分找到基准元素并归位后,将它的索引与 k 进行比较,看下一次划分是去它的左边还是右边,也就是说,它与快排的区别是:下一趟仅对基准元素的一边进行划分即可,不需要对左右两边都划分。

时间复杂度O(n),最坏时间复杂度仍为O(n2)。

Q: 为什么时间复杂度为O(n)呢?

  • 把每一层(即每一趟划分)的时间复杂度相加,得到:O(n) + O(n/2) + O(n/4) + O(n/8) + … + O(n/log2n) < O(2n),尽管 n 有系数或者常数存在,但是它仍然属于线性阶,即 O(n)
  • 而快排的时间复杂度,把每一层递归树的时间复杂度相加,得到:n + n + n + … + n = nlog2n ,因此属于线性对数阶,即 O(nlog2n)

4. top k

使用最稳定且快速的归并排序,排完序后再取前k个元素,时间复杂度至少是:O(nlog2n),执行用时:8ms,那么有没有办法不用对所有元素都进行排序呢?答案是有。两种思想:基于快排和基于堆排序。下面分别描述这两种思想。

算法一:基于快排(最优)
思想其实和第3题完全一样:找到第k小的元素(因为这道题原题目是让找最小的前k个数,但和top k思路完全相同)。找到之后,那么它左边的元素就全是小于k的,把左边的元素全部取出来即可(这里不要求top k有序)。

时间复杂度O(n)
执行用时:2ms

class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        int[] topk = new int[k];
        quicksort(arr, 0, arr.length-1,k);
        System.arraycopy(arr,0,topk,0,k);  //只取数组的前k个,不一定有序,但一定是最小的前k个数
        return topk;
    }

    void quicksort(int[] nums, int i, int j, int k) {   //在快排原有基础上增加了参数k 
	    if (i < j) {
	    	int m = partition(nums, i, j);
            if(k == m)
                return;
	    	else if(k > m)
	    	    quicksort(nums, m + 1, j,k);
            else
                quicksort(nums, i, m - 1,k);
	    }   
    }

    int partition(int[] nums, int i, int j) { 
	    int tmp = nums[i];
	    while (i < j) {
		    while (i < j && nums[j] >= tmp)
			    j--;
		    nums[i] = nums[j];
		    while (i < j && nums[i] <= tmp)
			    i++;
		    nums[j] = nums[i];
	    }
	    nums[i] = tmp;
	    return i;
    }
}

算法二:基于堆排序
使用Java中有现成的 PriorityQueue,实现起来最简单。
要使用堆排序来得到top k我们可以采用优先队列PriorityQueue来实现,它其实就是一个小根堆,它的作用是能保证每次取出的元素都是队列中最小的(Java的优先队列每次取最小元素,C++的优先队列每次取最大元素)

算法思想:
创建一个大根堆,保持堆的大小为k,然后遍历数组中的数字,遍历的时候做如下判断:

  • 若目前堆的大小 < k,将当前数字放入堆中;
  • 否则判断当前数字与大根堆堆顶元素的大小关系,如果当前数字比大根堆堆顶还大,这个数就直接跳过
  • 反之如果当前数字比大根堆堆顶小,先poll()掉堆顶,再将该数字使用offer()方法插入堆中(会自动调整小根堆)。

Q: 为什么用大根堆而不是小根堆?
因为我的目的是要找前k小的数,如果我用小根堆的话,当然,它可以每次把最小的根结点poll()出来,我拿到后可以把它存在一个数组中,但是我不能保证下一次拿到的最小的根结点能够直接放进数组中,因为数组可能已经满k个数了,我必须得把它与目前数组中存放的所有最小值要进行一个一个比较,看到底要不要放进去,这样就大大增加了算法复杂度。而如果用大根堆就很好办了,我不管你poll()出去的数是什么,总之肯定比我当前的k个数都要大,反正我只保证目前堆里的k个元素是当前最小的就行了。其他的都不管,因此只遍历一遍所有元素即可得到前k小的数。

因此,求前k个最小的数用大根堆;求前k个最大的数,用小根堆

时间复杂度O(nlog2k)
执行用时:14ms

看一下java api中对 PriorityQueue 的描述:
LeetCode经典算法题目二(树、排序、查找、动态规划、回溯、贪心)_第1张图片
PriorityQueue实现了Queue接口,不允许放入null元素;其通过堆实现,具体说是通过完全二叉树实现的小顶堆(任意一个非叶子节点的权值,都不大于其左右子节点的权值)。

PriorityQueue的构造方法:
LeetCode经典算法题目二(树、排序、查找、动态规划、回溯、贪心)_第2张图片
PriorityQueue中常用方法:
LeetCode经典算法题目二(树、排序、查找、动态规划、回溯、贪心)_第3张图片

class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0 || arr.length == 0) {
            return new int[0];
        }
        
        //创建一个比较器对象,重写compare()方法,实现降序,即大根堆。
        PriorityQueue<Integer> pq = new PriorityQueue<>(k, new Comparator<Integer>(){
            @Override  //重写比较器的compare()方法
            public int compare(Integer o1,Integer o2){
                return o2 - o1;  //保证降序排序,即实现大根堆
            }
        });  

        for(int i=0;i<arr.length;i++){
            if(pq.size() < k){  //如果大根堆的元素小于k,则把当前元素插入进去即可
                pq.offer(arr[i]);
            }
            else{  //否则就来比较堆顶元素与当前元素的大小
                if(pq.peek() > arr[i]){  //如果当前元素比堆顶要小,则把堆顶弹出来,把当前元素插进去
                    pq.poll();
                    pq.offer(arr[i]);
                }              
            }
        }

        int[] res = new int[k];
        int idx = 0;
        for(int num: pq) {  //快速将大根堆的元素置入数组中
            res[idx++] = num;
        }
        return res;
    }
}

注意接口、抽象类,一定不可以被new!!!
但是可以这样new 接口名 { …… };

后面加花括号这种写法,实际是new了一个实现接口的匿名类,开发人员需要在匿名类内部(花括号内)实现那个接口。

对于Comparator比较器的返回值而言,

  • 如果返回-1(或负数),表示不需要交换o1和o2的位置,o1排在o2前面,按asc升序
  • 如果返回 1(或正数),表示需要交换o1和o2的位置,o2排在o1前面,按desc降序

总而言之一句话,想要实现降序,即大根堆,重写compare()方法,return o2 - o1; 当o2 > o1时进行交换。

九、动态规划

1. 最长公共子序列(★)

题目描述:给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。
若这两个字符串没有公共子序列,则返回 0。

算法: 动态规划

class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        char[] s1 = text1.toCharArray();  //把字符串转化为字符数组
        char[] s2 = text2.toCharArray();
        int[][] dp = new int[s1.length+1][s2.length+1];  //初始化二维数组表,每个元素默认值为 0 
        for(int i=1;i<dp.length;i++){   
            for(int j=1;j<dp[0].length;j++){  //遍历二维数组,对其中每个元素进行赋值
                int m=Math.max(dp[i-1][j],dp[i][j-1]);  //取田字格中右上和左下元素的最大值
                if(s1[i-1]==s2[j-1])   //若当前元素相同,则在前一个字符串的最大公共子序列基础上加1
                    dp[i][j] = dp[i-1][j-1] +1;
                else   //否则就取字符串1与字符串2除去当前元素的公共子序列 和 字符串2与字符串1除去当前元素的公共子序列最大值
                    dp[i][j] = m;
            }
        }
        return dp[s1.length][s2.length];   //返回二维数组的最末尾元素
    }
}

Q: 什么是动态规划?

把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解。

比如本题,我要找两个字符串的最长公共子序列。那么我用一张表来存放两个当前字符串的最大公共子序列的长度。于是是不是可以把这个问题分解为:
找到当前元素之前的字符串的最长公共子序列,

  • 若当前字符也相同,就把长度加1(即表中对应的值加1);
  • 若当前字符不同,就取'字符串1'与'字符串2除去当前元素'的公共子序列'字符串2'与'字符串1除去当前元素'的公共子序列两者中的最大值。(即表中每个田字格的右上角和左下角元素的最大值)
    注意: 这里不用再把两个字符串都除去当前元素的部分的最长公共子序列再拿来比较了,因为它一定是小于上述两者情况之一的,所以没有必要。

最后当我把整张二维数组表填充完后,得到的右下角的元素一定是我们要找的最长公共子序列的个数。这也就是典型的一次一次小问题的累积,最后得到了大问题的解决。
LeetCode经典算法题目二(树、排序、查找、动态规划、回溯、贪心)_第4张图片

十、回溯

1. 字符串的排列

题目描述:输入一个字符串,打印出该字符串中字符的所有排列。你可以以任意顺序返回这个字符串数组,但里面不能有重复元素。
示例:
输入:s = “abc”
输出:[“abc”,“acb”,“bac”,“bca”,“cab”,“cba”]

算法: 回溯法。

回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。

思想: 首先把字符串转为字符数组,寻找排列方案的思路是:依次固定第0位、第1位、……、第n位字符。比如,我们都知道第0位有n种情况,若已经固定了第0位,那么第1位有n-1种情况,若已经固定了第1位,则第2位有n-2种情况,……,最后,第n位只有1种情况。所以,关键在于:固定当前位字符,对剩余的位置进行依次固定寻找排列方案,这样相当于深度优先搜索,完成后再对当前位进行循环固定,也就是说选择其他的字符来作为当前位。

执行一次dfs()目的是固定当前第x位,进行深度优先搜索来对剩余位找排列方案。所以初始参数为0,因为首先要固定第0位,根据第0位的情况往下找,而每一个字符都要依次作为第0位,可以使用交换法来实现,把每个字符依次放在第0个位置,完了后要交换回来。由于给的字符串中有可能含有重复字符,那么排列组合就会有重复的排列组合,所以需要固定每一位、去重,如果当前位已经有重复的元素了,那么就不用算两遍。

一定要想清楚x和i分别的作用
x:代表固定的第x位,因此只能是它作为dfs()的参数。
i:遍历数组的索引,它的作用是把数组的每个字符都拿出来,作为固定的第x个位,实现方法即: swap(i,x); 交换x和i指向的元素。

时间复杂度O(n!)
空间复杂度 :O(N2)。全排列的递归深度为 N ,系统累计使用栈空间大小为 O(N);递归中辅助 Set 累计存储的字符数量最多为 N + (N-1) + … + 2 + 1 = (N+1)N/2N + (N−1) + … + 2 + 1 = (N+1)N/2 ,即占用 O(N2)的额外空间。

class Solution {
    List<String> list = new ArrayList<>();
    char[] c;

    public String[] permutation(String s) {
        c = s.toCharArray(); 
        dfs(0);
        return list.toArray(new String[list.size()]);  //将list转为数组。参考下方第一点
    }

    void dfs(int x){  //固定当前第x位,进行深度优先搜索来排列剩余的位。
        if(x == (c.length - 1)){
            list.add(String.valueOf(c));  //把当前字符串数组添加到列表中,作为一种排列方案。参考下方第二点
            return;
        }
        HashSet<Character> hs = new HashSet<>();  //每一次dfs都创建一个HashSet,实现去重。
        for(int i=x;i<c.length;i++){  //广度遍历,依次把数组中的每一个字符都当作当前第x位(交换位置实现)
            if(hs.contains(c[i])){  //如果HashSet中包含了字符c[i],说明这是重复字符,当前位已经固定过了,直接跳过。
                continue;
            }
            hs.add(c[i]);
            swap(i,x);  //交换,相当于选择第i个字符来作为当前固定的第x位
            dfs(x+1);  //深度搜索,递归,开始固定下一位。
            swap(i,x);  //还原数组
        }
    }

    void swap(int i,int j){  //交换,索引值是不变的,即x和i不变,变的是它们指向的元素值。
        char tmp = c[i];
        c[i] = c[j];
        c[j] = tmp;
    }
}

1、toArray() 和 toArray(T[] a)
List和Set接口都提供了一个转数组的非常方便的方法toArray()。toArray()有两个重载的方法:

  • Object[] toArray(); 是将list或者set直接转为Object[] 数组。但是如果你这样写的话:String[] array= (String[])list.toArray(); 运行会报错。因为java中的强制类型转换只是针对单个对象的,想要偷懒将整个数组转换成另外一种类型的数组是不行的!因此不能直接将Object[] 转化为String[],转化的话只能是取出每一个元素再转化
  • T[] toArray(T[] a); 是将list或者set直接转化为你所需要类型的数组。非常好用,且常用!一般写法:String[] list_array = list.toArray(new String[list.size()]);

2、static String valueOf(char[] data)
String类的静态方法,因此可以直接通过类名调用:String.valueOf(); 作用是将 字符串数组/int整型/char字符 等等转为字符串。

3、HashSet
HashSet一般常用于去重,即:去除重复元素。

HashSet的常用方法:
LeetCode经典算法题目二(树、排序、查找、动态规划、回溯、贪心)_第5张图片

十一、贪心算法

1. 最大字序和(★)

算法: 贪心算法:每一步都选择最佳方案,到最后就是全局最优的方案。
两个变量:

  • 一个保存数组每一位的当前位置的最大和(总是将前一次的“当前位置最大和”与其加上nums[i]的结果作为比较,取二者的较大者。意思是看看加了前面连续的最大值后结果是更大还是更小,如果更小当然就不加它了)
  • 一个保存全局迄今为止的最大和(总是将前一次保存的“全局最大和”与这次的当前位置最大和拿来比较,取较大者作为新的全局最大和。它总是从数组每一位的“当前位置最大和”中取值)
class Solution {
    public int maxSubArray(int[] nums) {
        if(nums.length == 1)
            return nums[0];
        int bef_sum=nums[0], all_sum=nums[0];
        for(int i=1;i<nums.length;i++){
            bef_sum = Math.max(bef_sum + nums[i], nums[i]);  //取较大值的方法
            all_sum = Math.max(bef_sum , all_sum);
        }
        return all_sum;
    }
}

该算法遍历一次数组,时间复杂度为O(n)

你可能感兴趣的:(LeetCode,数据结构,算法,java)