LeetCode經典算法題目二(樹、排序、查找)

LeetCode經典算法題目一(字符串、數組、鏈表)

四、樹

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 的描述:
在這裏插入圖片描述
PriorityQueue實現了Queue接口,不允許放入null元素;其通過堆實現,具體說是通過完全二叉樹實現的小頂堆(任意一個非葉子節點的權值,都不大於其左右子節點的權值)。

PriorityQueue的構造方法:
在這裏插入圖片描述
PriorityQueue中常用方法:
在這裏插入圖片描述

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時進行交換。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章