【算法】分治法四步走


分治法在每一層遞歸上都有三個步驟:
1)分解:將原問題分解為若干個規模較小,相互獨立,與原問題形式相同的子問題
2)解決:若子問題規模較小而容易被解決則直接解,否則遞歸地解各個子問題
3)合並:將各個子問題的解合並為原問題的解。

適用場景

適用於我們的問題是一個大問題的時候,且這個大問題能被分解成多個小問題。

二分算法面對中間分叉,不能確定是往左走,還是往右走的時候,我們就可以使用分治算法,遞歸,給他左右都走一遍

分治四步走

由於分治法一般采用遞歸方法實現,所以大家可以結合【算法】遞歸三步走來進行理解。

1. 明確分解策略

1.明確分解策略:明確大問題通過怎樣的分解策略一步步分解為最終的小問題,之后我們需要根據分解策略明確函數的功能

如果我們的分解策略如果是折半分解,那么我們的函數就需要有范圍域(left, right)來確定分解范圍;

注意:如果有多個分解域,我們就需要判斷一下是不是每個域都要進行分解,每需要分解一次,就需要一個范圍域(left, right)

如果是遞減分解,那么我們的函數需要有計數(num),來記錄遞減分解結果。

最簡單的例子:樹的先序遍歷,分解策略:根左右(根節點、左子樹、右子樹)

比如說,快速排序的大的問題可以分解為就是將n個元素擺到正確位置,漢諾塔的大的問題就是將n個圓盤由下而上擺到正確位置。

2. 尋找最小問題

2.尋找最小問題:最小問題也就是大問題的最簡化版本,問題的起始狀態,最小子問題即是出口。

最簡單的例子:樹的先序遍歷,最小問題:根節點為空,返回return

比如說,快速排序沒有元素或者只有一個元素(left >= right)的時候。

3. 划分子類問題

子類問題:子類問題與原問題有相同的結構,是一樣的,只不過規模小一些\(原問題 ∽ 子類問題\),如 (\(原樹 ∽ 左右子樹\)
例子:比如樹的中序遍歷序列,按根節點划分之后,左右還是中序遍歷序列。

類比:類似於數學里面的相似三角形

3.划分子類問題:使用分解策略將大問題分解為 當前問題 + 子類問題(即 原問題 = 當前問題 + 子類問題我們只能解決當前問題,子類問題只起到划分的作用)。子類問題也就是介於最小問題與大問題之間的問題,與大問題有相同的結構,比大問題稍稍小那么一點,這使得子類問題具有解決大問題的通用性,即 可以通過子類問題找到大問題的通解。由子類問題得到解決方法。

特別注意:分治算法只去操作解決當前的問題,即 當前元素(如 根節點),對其他划分出來的子類問題(如 左右子樹)均不操作解決,只起到划分的作用。

子類問題划分技巧:找相似法
由於原問題與子類問題相似,所以我們可以按照原問題提供的已知條件來尋找相似的子類問題所需要提供的條件

例子:
問題:已知樹的先序遍歷、中序遍歷,求出后序遍歷。

  • 原問題:提供大樹先序遍歷序列大樹中序遍歷序列
  • 子類問題:提供左右子樹中序遍歷序列,根據左右子樹數量來找出左右子樹先序遍歷序列

image
你看看,上面的原問題與子類問題是不是一樣的結構,相似!


最簡單的例子:樹的先序遍歷,

  • 當前問題:根,
  • 子類問題:根左右(根節點、左子樹、右子樹),
  • 訪問根節點因為只有當前節點根節點能夠給我們操作,其他的左右子樹都是起到划分的作用

比如說,快速排序的當前問題就是將一個元素擺到正確位置,漢諾塔的當前問題就是將一個最下面的圓盤擺到正確位置。

4. 解決當前問題

4.解決當前問題:這個按照問題的邏輯進行添加,分治算法只去操作解決當前的問題,即 當前元素(如 根節點),對其他划分出來的子類問題(如 左右子樹)均不操作解決,只起到划分的作用。

注意:在我們上一步划分子類問題之后,我們應該解決當前問題,但是這個當前問題的解決應該位於子類問題划分的之前、中間還是之后,這個就要具體題目具體分析了
例如:

  • 先序遍歷:當前問題(根)、子類問題(左子樹)、子類問題(右子樹)
  • 中序遍歷:子類問題(左子樹)、當前問題(根)、子類問題(右子樹)
  • 后序遍歷:子類問題(左子樹)、子類問題(右子樹)、當前問題(根)

在解決當前問題的時候,我們可能會需要結合之前划分子類問題的結果返回值(不一定),也可能不需要,作為單機問題去解決。

例如,我們在上一步中划分出了左右子樹,這一步中我們可以將根節點與左右子樹的根節點相連,形成一個完整的樹。

折半分解

分治算法其實是二分算法的遞歸形式,他代表了並行執行,也就是說,如果我們的二分算法面對中間分叉,並不能確定是往左走,還是往右走,我們就可以使用分治算法,遞歸,左右都給他走一遍。

相當於二分算法是串行的一種迭代算法,而分治算法是一種並行的遞歸算法。

折半分解是我們在分治法中最常用的,所以這里提出來特別介紹。
此算法常用於遞歸操作樹划分樹的左右子樹

  1. 明確分解策略:折半分解,左右邊界(left, right),分解線mid = left + (right - left) / 2

  2. 尋找最小問題:left > right或者left >= right

  3. 划分子類問題:f(left, mid - 1)f(mid + 1, right)

  4. 解決當前問題:root.left = f(left, mid - 1)root.right = f(mid + 1, right)

大家可以看看面試題 08.03. 魔術索引這道題,有序,如果使用二分算法,我們不能確定面對分叉是應該往左走,還是往右走,所以我們得使用分治算法,左右都走一遍。

四步走實例

1. 明確分解策略

第一步,明確怎么把大的問題一步步分解為最終的小問題;並明確這個函數的功能是什么,它要完成什么樣的一件事。
分解策略:大問題 = n * 小問題。如果大問題是一個數組,那么小問題就是數組中的一個元素。
如果我們的分解策略如果是折半分解,那么我們的函數就需要有范圍域(left, right)來確定分解范圍;
如果是遞減分解,那么我們的函數需要有計數(num),來記錄遞減分解結果。

比如說,快速排序的大的問題可以分解為就是將n個元素擺到正確位置,漢諾塔的大的問題就是將n個圓盤由下而上擺到正確位置。

比如說,快速排序算法的大問題就是將數組中的n個元素進行排序擺放到正確的位置,那么分解而成的小問題就是將數組中的一個元素擺放到正確的位置。
而漢諾塔的大問題就是將A柱子上的n個盤子借用B柱子由大到小放在C柱子上,那么分解而成的小問題就是將A柱子上的最底下的最大盤子借用B放在C柱子上。

而這個功能,是完全由你自己來定義的。也就是說,我們先不管函數里面的代碼是什么、怎么寫,而首先要明白,你這個函數是要用來干什么的。

例如:面試題 04.02. 最小高度樹
給定一個有序整數數組,元素各不相同且按升序排列,編寫一個算法,創建一棵高度最小的二叉搜索樹。

示例:

給定有序數組: [-10,-3,0,5,9],

一個可能的答案是:[0,-3,9,-10,null,5],它可以表示下面這個高度平衡二叉搜索樹:

          0 
         / \ 
       -3   9 
       /   / 
     -10  5 

要做出這個題,
第一步,要明確我們的分解策略,這里因為是二叉樹,所以采用的方法一般是通過根節點划分左右子樹,我的分解策略是折半分解;
既然分解策略是折半分解,那么我們即將要寫出的這個函數必須指明分解范圍,不然沒有辦法進行折半分解。

明確分解策略:大問題=創建一棵高度最小的二叉搜索樹,折半分解,小問題=通過根節點划分左右子樹,創建一個根左右3個節點的二叉樹。

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {
    public TreeNode sortedArrayToBST(int[] nums) {

        return f(nums, 0, nums.length - 1);
    }

    // 分治法遞歸需要重新構造函數
    // 按(left, right)區域進行划分子樹
    // 明確分解策略:大問題=創建一棵高度最小的二叉搜索樹,折半分解,小問題=通過根節點划分左右子樹,創建一個根左右3個節點的二叉樹。
    public TreeNode f(int[] nums, int left, int right) {
        
    }
}

2. 尋找最小問題(初始條件)

分治就是在函數實現的內部代碼中,將大問題不斷的分解為子類問題,再將小問題進一步分解為更小的問題。所以,我們必須要找出分治的結束條件,即 給定一個分解的閾值,不然的話,它會一直分解自己,無窮無盡。

  • 必須有一個明確的結束條件。因為分治就是有“分”“並”,所以必須又有一個明確的點,到了這個點,就不用“分解下去”,而是開始“合並”。

第二步,我們需要找出當參數為何值、分解到何種程度時,分治結束,之后直接把結果返回。
一般為初始條件,然后從初始條件一步一步擴充到最終結果

注意:這個時候我們必須能根據這個參數的值,能夠直接知道函數的結果是什么。

讓我們繼續完善上面那個創建樹。
第二步,尋找最小問題:
left>right時,即 分解為空了,沒有根節點,返回null;
那么遞歸出口就是left>right時函數返回null。

left=right時,即 分解到只剩一個元素了,我們能夠直接知道這最后一個元素就是根節點,返回最后一個元素;
left=right時函數返回new TreeNode(nums[left])(這種情況我們折半分解獲取mid創建根節點的時候包含了,所以不寫了)
並且這個不是最小的問題。

如下:

    // 分治法遞歸需要重新構造函數
    // 按(left, right)區域進行划分子樹
    // 明確分解策略:大問題=創建一棵高度最小的二叉搜索樹,折半分解,小問題=通過根節點划分左右子樹,創建一個根左右3個節點的二叉樹。
    public TreeNode f(int[] nums, int left, int right) {
        if (left > right) {
            return null;
        }
    }

當然,當l=r時,我們也是知道f(r)等於多少的,f(r)也可以作為遞歸出口。分治出口可能並不唯一的。

3. 划分子類問題

特別注意:分治算法只去操作解決當前的問題,即 當前元素(如 根節點),對其他划分出來的子類問題(如 左右子樹)均不操作解決,只起到划分的作用。

第三步,之前我們明確了分解策略,現在正是使用的時候了,我們需要使用這個分解策略,將大問題分解為子類問題。這樣就能一步步分解到最小問題,然后作為函數出口。

  • 最小問題:分解至空時,l>r,f(l)即為null
  • 分解策略:折半分解,f(nums, l, r) → f(nums, l, (l+r)/2 - 1) , f(nums, (l+r)/2 + 1, r)
  • 當前問題:根節點,mid
  • 子類問題:左右子樹數組,(left, mid-1)和(mid+1, right)。

分治:

  • 分:將f(nums, l, r) → f(nums, l, (l+r)/2) , f(nums, (l+r)/2+1, r)了。這樣,問題就由n縮小為了n/2,我們只需要找到這n/2個元素的最大值即可。就這樣慢慢從f(n),f(n/2)“分”到f(1)。
  • 並:這樣就可以從1,一步一步“並”到n/2,n...
    // 分治法遞歸需要重新構造函數
    // 按(left, right)區域進行划分子樹
    // 明確分解策略:大問題=創建一棵高度最小的二叉搜索樹,折半分解,小問題=通過根節點划分左右子樹,創建一個根左右3個節點的二叉樹。
    public TreeNode f(int[] nums, int left, int right) {
        // 尋找最小問題
        if (left > right) {
            return null;
        }
        
        // 划分子類問題:通過根節點划分左右子樹,創建一個根左右3個節點的二叉樹。
        int mid = left + (right - left) / 2;
        TreeNode root = new TreeNode(nums[mid]);
        
        f(nums, left, mid - 1);
        f(nums, mid + 1, right);

        return root;
    }

4. 解決當前問題

第四步:解決當前問題。
上一步我們將大問題分解為了當前問題+子類問題,那么這個當前問題怎么解決呢,解決這個當前問題,也是為我們接下來的分解提供問題的解決方案,所以不能大意。
解決當前問題的方法,就是比較兩個當前問題的大小,得出最大的一個值,問題即可解決。

特別注意:分治算法只去操作解決當前的當前問題(如 根節點),對其他划分出來的子類問題(如 左右子樹)均不操作解決,只起到划分的作用。

這里的解決合並就是將子類問題合並為一個完整的大問題,方便我們求出大問題的答案,即 創建一個根左右3個節點的二叉樹。

    // 分治法遞歸需要重新構造函數
    // 按(left, right)區域進行划分子樹
    // 明確分解策略:大問題=創建一棵高度最小的二叉搜索樹,折半分解,小問題=通過根節點划分左右子樹,創建一個根左右3個節點的二叉樹。
    public TreeNode f(int[] nums, int left, int right) {
        // 尋找最小問題
        if (left > right) {
            return null;
        }
        
        // 划分子類問題:通過根節點划分左右子樹,創建一個根左右3個節點的二叉樹。
        int mid = left + (right - left) / 2;
        TreeNode root = new TreeNode(nums[mid]);
        
        // 解決當前問題:創建一個根左右3個節點的二叉樹
        root.left = f(nums, left, mid - 1);
        root.right = f(nums, mid + 1, right);

        return root;
    }

到這里,分治五步走就完成了,那么這個分治函數的功能我們也就實現了。
可能初學的讀者會感覺很奇妙,這就能得到最大值了?
那么,我們來一步一步推一下。
假設n為數組中元素的個數,f(n)則為函數
f(1)只有一個元素,可以得到一個確定的值
f(2)根據f(1)的值,也能確定了
f(4)根據f(2)的值,也能確定下來了
...
f(n/2)
f(n)根據f(n/2)也能確定下來了
你看看是不是解決了,n都能分治得到結果!


當前問題解決技巧

記錄第一次滿足條件的元素

增加遞歸出口:

// 結果集:res
if (res != null) {
    return;
}

當前節點操作:

// 結果集:res
if (條件 && res == null) {
    res = node;
    return;
}

記錄最后一次滿足條件的元素

當前節點操作:與記錄第一次滿足條件的元素的區別在於沒有判斷res == null

// 結果集:res
if (條件) {
    res = node;
}

記錄當前問題節點的前驅節點

遍歷完當前問題節點node后,使用pre記錄當前節點,然后使用node去繼續前進遍歷。

dfs(node.left);

visit(node);
pre = node; // 記錄當前節點,下一步當前節點就會移到其他位置,所以pre會記錄當前節點的前驅結點

dfs(node.right);    //node的下一次遞歸

多種分治法解法

面試題 04.06. 后繼者

設計一個算法,找出二叉搜索樹中指定節點的“下一個”節點(也即中序后繼)。

如果指定節點沒有對應的“下一個”節點,則返回null。

示例 1:

輸入: root = [2,1,3], p = 1

  2
 / \
1   3

輸出: 2

示例 2:

輸入: root = [5,3,6,2,4,null,null,1], p = 6

      5
     / \
    3   6
   / \
  2   4
 /   
1

輸出: null

按題意寫函數功能

inorderSuccessor(root, p)方法就是在樹root中找p節點的后繼節點。

class Solution {

    public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
        if (root == null) {
            return null;
        }

        if (p.val >= root.val) {
            return inorderSuccessor(root.right, p);
        } else {
            TreeNode leftRet = inorderSuccessor(root.left, p);
            if (leftRet == null) {
                return root;
            } else {
                return leftRet;
            }
        }
    }
}

記錄第一次滿足條件的元素

如果找到p節點,那就置flag為true,中序遍歷到后一個節點時設置結果res。

class Solution {
    boolean flag;   // 如果找到p節點,那就置為true,遍歷到后一個節點時設置結果
    TreeNode res;

    public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
        dfs(root, p);
        return res;
    }

    public void dfs(TreeNode root, TreeNode p) {
        if (root == null) {
            return;
        }

        dfs(root.left, p);

        // 要求答案只改一次,不能被覆蓋,就只要第一次成功的
        if (flag == true && res == null) {  // 不能放在最前面作為遞歸出口,因為需要中序遍歷的后一個節點,此處是當前問題(當前節點)
            res = root;
            return;
        }
        if (root == p) {
            flag = true;
        }
        
        dfs(root.right, p);
    }
}

記錄最后一次滿足條件的元素

這個題目只要第一次滿足條件,所以下面這種解法不成立,但是可以給大家一個樣例看看。

class Solution {
    boolean flag;   // 如果找到p節點,那就置為true,遍歷到后一個節點時設置結果
    TreeNode res;

    public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
        dfs(root, p);
        return res;
    }

    public void dfs(TreeNode root, TreeNode p) {
        if (root == null) {
            return;
        }

        dfs(root.left, p);
        
        // 區別在這里!!!!!!!!!!!!!!!!!!!!!!
        // 要求答案次次修改,能被覆蓋,就只要最后一次成功的
        if (flag == true) { // 不能放在最前面作為遞歸出口,因為需要中序遍歷的后一個節點,此處是當前問題(當前節點)
            res = root;
            return;
        }
        if (root == p) {
            flag = true;
        }
        
        dfs(root.right, p);
    }
}

記錄當前問題節點的前驅節點

我們使用pre節點來記錄當前問題節點的前驅結點,每次遍歷都需要前進(pre = root)。

class Solution {
    TreeNode res;
    TreeNode pre;

    public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
        dfs(root, p);
        return res;
    }

    public void dfs(TreeNode root, TreeNode p) {
        if (root == null || res != null) {
            return;
        }

        dfs(root.left, p);

        // 要求答案只改一次,不能被覆蓋,就只要第一次成功的
        if (pre == p && res == null) {
            res = root;
            return;
        }
        pre = root; // 前進
        dfs(root.right, p);
    }
}

疑似二分算法

面試題 08.03. 魔術索引

魔術索引。 在數組A[0...n-1]中,有所謂的魔術索引,滿足條件A[i] = i。給定一個有序整數數組,編寫一種方法找出魔術索引,若有的話,在數組A中找出一個魔術索引,如果沒有,則返回-1。若有多個魔術索引,返回索引值最小的一個。

示例1:

 輸入:nums = [0, 2, 3, 4, 5]
 輸出:0
 說明: 0下標的元素為0

示例2:

 輸入:nums = [1, 1, 1]
 輸出:1

答案

這題我一看,有序,二分查找!結果根本就沒辦法確定是往左走還是往右走,直接給我沒轍了。

就試了試分治算法,遞歸的二分算法,問題迎刃而解。

// 很遺憾,二分查找並不行,因為無論找到了什么滿足條件的元素,其左右都可能出現滿足條件的元素
// 只能分治算法
class Solution {
    public int findMagicIndex(int[] nums) {

        return f(nums, 0, nums.length - 1);
    }

    public int f(int[] nums, int left, int right) {
        if (left > right) {
            return -1;
        }

        int mid = left + (right - left) / 2;

        // 左邊
        int leftAns = f(nums, left, mid - 1);

        // 中間
        if (leftAns != -1) {
            return leftAns;
        } else if (mid == nums[mid]) {
            return mid;
        }

        // 右邊
        return f(nums, mid + 1, right);
    }
}

面試題 10.05. 稀疏數組搜索

稀疏數組搜索。有個排好序的字符串數組,其中散布着一些空字符串,編寫一種方法,找出給定字符串的位置。

示例1:

 輸入: words = ["at", "", "", "", "ball", "", "", "car", "", "","dad", "", ""], s = "ta"
 輸出:-1
 說明: 不存在返回-1。

示例2:

 輸入:words = ["at", "", "", "", "ball", "", "", "car", "", "","dad", "", ""], s = "ball"
 輸出:4

答案

很明顯,雖然有序,但是無法判斷中間分叉該向左走還是向右走,所以采用分治算法。

class Solution {
    public int findString(String[] words, String s) {
        // 有序,二分,可是無法判斷中間分叉該左走還是右走
        // 分治,遞歸
        return f(words, s, 0, words.length - 1);
    }

    public int f(String[] words, String s, int left, int right) {
        if (left > right) {
            return -1;
        }

        int mid = left + (right - left) / 2;

        if (s.equals(words[mid])) {
            return mid;
        }

        int leftAns = f(words, s, left, mid - 1);
        if (leftAns != -1) {
            return leftAns;
        }

        int rightAns = f(words, s, mid + 1, right);
        if (rightAns != -1) {
            return rightAns;
        }

        return -1;
    }
}

實例

最大數

在一個數組中找最大的數字。

分解策略:對半分

從l到r中找到最大的一個元素。

// 明確分解策略:大問題=從n個元素中找到最大的數字並返回,折半分解,小問題=從2個元素比較大小找到最大數字並返回。
int f(int[] nums, int l, int r) {

      // 尋找最小問題:最小問題即是只有一個元素的時候
      if (l >= r) {
            return nums[l];
      }

      // 使用分解策略
      int lMax = f(nums, l, (l+r)/2);
      int rMax = f(nums, (l+r)/2+1, r);

      // 解決次小問題:比較兩個元素得到最大的數字

      return lMax > rMax ? lMax : rMax;
}

漢諾塔

漢諾塔的傳說

漢諾塔:漢諾塔(又稱河內塔)問題是源於印度一個古老傳說的益智玩具。大梵天創造世界的時候做了三根金剛石柱子,在一根柱子上從下往上按照大小順序摞着 64 片黃金圓盤。大梵天命令婆羅門把圓盤從下面開始按大小順序重新擺放在另一根柱子上。並且規定,在小圓盤上不能放大圓盤,在三根柱子之間一次只能移動一個圓盤。

假如每秒鍾一次,共需多長時間呢?移完這些金片需要 5845. 54 億年以上,太陽系的預期壽命據說也就是數百億年。真的過了 5845. 54 億年,地球上的一切生命,連同梵塔、廟宇等,都早已經灰飛煙滅。

漢諾塔游戲的演示和思路分析:

1 ) 如果是有一個盤,A->C

如果我們有 n>= 2 情況,我們總是可以看做是兩個盤 1 .最下邊的盤 2. 上面的盤
2 ) 先把 最上面的盤A->B
3 ) 把最下邊的盤A->C
4 ) 把B塔的所有盤 從 B->C

漢諾塔游戲的代碼實現:

看老師代碼演示:

package com.atguigu.dac;

public class Hanoitower {

    public static void main(String[] args) {
        hanoiTower(10, 'A', 'B', 'C');
    }
    
    //漢諾塔的移動的方法
    //使用分治算法
    // 明確分解策略:我們的問題是有n個盤子,可是如果是n個盤子的話我們不會分,不知道結果;如果盤子數量為1、2、3就好了,所以我們按盤子數依次減一分解
    public static void hanoiTower(int num, char a, char b, char c) {
        // 尋找最小問題:只有一個盤
        //如果只有一個盤
        if(num == 1) {
            System.out.println("第1個盤從 " + a + "->" + c);
        } else {
            // 解決次小問題:由於我們是按盤子數-1來進行分解的,所以次小問題是一個盤子和n-1個盤子的漢諾塔,將一個最下面的盤子擺放到正確的位置
            //如果我們有 n >= 2 情況,我們總是可以看做是兩個盤 1.最下邊的一個盤 2. 上面的所有盤
            //1. 先把 最上面的所有盤 A->B, 移動過程會使用到 c
            hanoiTower(num - 1, a, c, b);
            //2. 把最下邊的盤 A->C
            System.out.println("第" + num + "個盤從 " + a + "->" + c);
            //3. 把B塔的所有盤 從 B->C , 移動過程使用到 a塔  
            hanoiTower(num - 1, b, a, c);
        }
    }
}

面試題 08.06. 漢諾塔問題

在經典漢諾塔問題中,有 3 根柱子及 N 個不同大小的穿孔圓盤,盤子可以滑入任意一根柱子。一開始,所有盤子自上而下按升序依次套在第一根柱子上(即每一個盤子只能放在更大的盤子上面)。移動圓盤時受到以下限制:

(1) 每次只能移動一個盤子;

(2) 盤子只能從柱子頂端滑出移到下一根柱子;

(3) 盤子只能疊在比它大的盤子上。

請編寫程序,用棧將所有盤子從第一根柱子移到最后一根柱子。

你需要原地修改棧。

示例1:

 輸入:A = [2, 1, 0], B = [], C = []
 輸出:C = [2, 1, 0]

示例2:

 輸入:A = [1, 0], B = [], C = []
 輸出:C = [1, 0]

答案

  • 當前問題:把最底下的第n塊移過去
  • 子類問題:把n-1塊移過去
class Solution {
    /**
     * 將 A 上的所有盤子,借助 B,移動到C 上
     * @param A 原柱子
     * @param B 輔助柱子
     * @param C 目標柱子
     */
    public void hanota(List<Integer> A, List<Integer> B, List<Integer> C) {
        movePlate(A.size(), A, B, C);
    }

    private void movePlate(int num, List<Integer> A, List<Integer> B, List<Integer> C) {
        if (num == 1) {    // 只剩一個盤子時,直接移動即可
            C.add(A.remove(A.size() - 1));
            return;
        }

        movePlate(num - 1, A, C, B);   // 將 size-1 個盤子,從 A 移動到 B
        movePlate(1, A, B, C);
        // C.add(A.remove(A.size() - 1));   // 將 第size個盤子,從 A 移動到 C
        movePlate(num - 1, B, A, C);   // 將 size-1 個盤子,從 B 移動到 C
    }
}

面試題 04.02. 最小高度樹

給定一個有序整數數組,元素各不相同且按升序排列,編寫一個算法,創建一棵高度最小的二叉搜索樹。

示例:

給定有序數組: [-10,-3,0,5,9],

一個可能的答案是:[0,-3,9,-10,null,5],它可以表示下面這個高度平衡二叉搜索樹:

          0 
         / \ 
       -3   9 
       /   / 
     -10  5 

答案

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {
    public TreeNode sortedArrayToBST(int[] nums) {

        return f(nums, 0, nums.length - 1);
    }

    // 二分法需要重新構造函數
    public TreeNode f(int[] nums, int left, int right) {
        if (left > right) {
            return null;
        }
        int mid = left + (right - left) / 2;
        TreeNode root = new TreeNode(nums[mid]);
        root.left = f(nums, left, mid - 1);
        root.right = f(nums, mid + 1, right);

        return root;
    }

}

通過先序和中序數組生成后序數組

給出一棵二叉樹的先序和中序數組,通過這兩個數組直接生成正確的后序數組。

示例1
輸入

[1,2,3],[2,1,3]

輸出

[2,3,1]

答案

常規做法:先序遍歷划分,中序遍歷划分,public void f(int[] preOrder, int left1, int right1, int[] inOrder, int left2, int right2) {

  1. 先序遍歷的第一個元素作為根節點
  2. 然后拿着根節點去中序遍歷尋找可以得知其左右子樹分別的數量
  3. 拿着這個數量,我們就可以再去先序遍歷里面進行划分左右子樹
  4. 這樣就能遞歸得到后序遍歷(左右根)了

image

import java.util.*;

public class Solution {
    /**
     * 
     * @param preOrder int整型一維數組 the array1
     * @param inOrder int整型一維數組 the array2
     * @return int整型一維數組
     */
    public int[] postOrder;
    public int num;
    public int[] findOrder (int[] preOrder, int[] inOrder) {
        // write code here
        // 先序:根左右
        // 中序:左根右
        // 后序:左右根
        postOrder = new int[preOrder.length];
        f(preOrder, 0, preOrder.length - 1, inOrder, 0, preOrder.length - 1);
        return postOrder;
    }
    // 從先序里面找到第一個元素,就是根節點,
    // 然后帶着根節點去中序里面划分出左右子樹的個數
    // 再帶着左右子樹的個數去划分先序數組
    // 函數功能:從先序找出第一個root,划分中序的左右
    // root僅與先序遍歷中的根節點索引有關
    // left和right僅與中序遍歷中的左右指針划分有關
    public void f(int[] preOrder, int left1, int right1, int[] inOrder, int left2, int right2) {
        if (left1 > right1 || left2 > right2) {
            return;
        }
        
        // 在中序中找到根節點位置i
        int i = 0;
        for (i = left2; i < right2 && inOrder[i] != preOrder[left1]; i++);
        
        int leftLength = i - left2;
        int rightLength = right2 - i;

        // 左
        f(preOrder, left1 + 1, left1 + leftLength, inOrder, left2, i - 1);
        // 右
        f(preOrder, right2 - rightLength + 1, right2, inOrder, i + 1, right2);
        // 根
        postOrder[num++] = preOrder[left1];
    }
}

答案二

簡化做法:先序遍歷找根,中序遍歷划分,public void f(int[] preOrder, int root, int[] inOrder, int left, int right) {

  1. 在先序遍歷中找出根節點
  2. 在中序遍歷中進行左右划分
  3. 在中序遍歷中划分后,利用划分的左右子樹數量,重新在先序遍歷中找到根節點。

原問題 = 當前問題 + 子類問題

  • 當前問題:找到根節點進行划分,然后按后序遍歷(左右根)去存儲
  • 子類問題:依據根節點將中序遍歷划分為左右子樹中序遍歷,用左右子樹數量去先序遍歷中找到左右子樹根節點。(即 組成了與原問題相同結構的子類問題,左右子樹中序遍歷+左右子樹先序遍歷根節點
import java.util.*;

public class Solution {
    /**
     * 
     * @param preOrder int整型一維數組 the array1
     * @param inOrder int整型一維數組 the array2
     * @return int整型一維數組
     */
    public int[] postOrder;
    public int num;
    public int[] findOrder (int[] preOrder, int[] inOrder) {
        // write code here
        // 先序:根左右
        // 中序:左根右
        // 后序:左右根
        postOrder = new int[preOrder.length];
        f(preOrder, 0, inOrder, 0, preOrder.length - 1);
        return postOrder;
    }
    // 從先序里面找到第一個元素,就是根節點,
    // 然后帶着根節點去中序里面划分出左右子樹的個數
    // 再帶着左右子樹的個數去划分先序數組
    // 函數功能:從先序找出第一個root,划分中序的左右
    // root僅與先序遍歷中的根節點索引有關
    // left和right僅與中序遍歷中的左右指針划分有關
    public void f(int[] preOrder, int root, int[] inOrder, int left, int right) {
        if (left > right) {
            return;
        }
        
        // 在中序中找到根節點位置i
        int i = 0;
        for (i = left; i < right && inOrder[i] != preOrder[root]; i++);
        // 左
        f(preOrder, root + 1, inOrder, left, i - 1);
        // 右
        f(preOrder, root + i - left + 1, inOrder, i + 1, right);
        // 根
        postOrder[num++] = preOrder[root];
    }
}

面試題 17.12. BiNode

二叉樹數據結構TreeNode可用來表示單向鏈表(其中left置空,right為下一個鏈表節點)。實現一個方法,把二叉搜索樹轉換為單向鏈表,要求依然符合二叉搜索樹的性質,轉換操作應是原址的,也就是在原始的二叉搜索樹上直接修改。

返回轉換后的單向鏈表的頭節點。

注意:本題相對原題稍作改動

示例:

輸入: [4,2,5,1,3,null,6,0]
輸出: [0,null,1,null,2,null,3,null,4,null,5,null,6]

只能操作當前元素

下面一定要注意了,只能操作當前元素,左右子樹只起到划分的作用。

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {

    TreeNode pre;   // 鏈表的上一個元素

    // 二叉搜索樹中序遍歷有序
    public TreeNode convertBiNode(TreeNode root) {

        TreeNode head = new TreeNode(0);    // 單鏈表的偽頭節點
        pre = head;
        f(root);
        return head.right;
    }

    // 二叉搜索樹中序遍歷有序
    public void f(TreeNode root) {

        if (root == null) {
            return;
        }

        // 划分左子樹
        f(root.left);

        // 只能操作當前元素
        pre.right = root;
        root.left = null;
        pre = root;
        
        // 划分右子樹
        f(root.right);
    }
}

劍指 Offer 40. 最小的k個數

最小的k個數:輸入整數數組 arr ,找出其中最小的 k 個數。例如,輸入4、5、1、6、2、7、3、8這8個數字,則最小的4個數字是1、2、3、4。

示例 1:
輸入:arr = [3,2,1], k = 2
輸出:[1,2] 或者 [2,1]

示例 2:
輸入:arr = [0,1,2,1], k = 1
輸出:[0]

限制:
0 <= k <= arr.length <= 10000
0 <= arr[i] <= 10000

堆和大小頂堆

這道題出自《劍指offer》,是一道非常高頻的題目。可以通過排序等多種方法求解。但是這里,我們使用較為經典的大頂堆(大根堆)解法進行求解。因為我知道有很多人可能一臉懵逼,所以,我們先復習一下大頂堆。

首先復習一下,堆(Heap)是計算機科學中一類特殊的數據結構的統稱,我們通常是指一個可以被看做一棵完全二叉樹的數組對象。

堆的特性是父節點的值總是比其兩個子節點的值大或小。如果父節點比它的兩個子節點的值都要大,我們叫做大頂堆。如果父節點比它的兩個子節點的值都要小,我們叫做小頂堆

我們對堆中的結點按層進行編號,將這種邏輯結構映射到數組中就是下面這個樣子。

大頂堆,滿足以下公式

\[arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]\]

小頂堆也一樣:

小頂堆,滿足以下公式

\[arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]\]

答案

上面我們學習了大頂堆,現在考慮如何用大根堆進行求解。

首先,我們創建一個大小為k的大頂堆。假如數組為[4,5,1,6,2,7,3,8],k=4。大概是下面這樣:

我想肯定這里有不知道如何建堆的同學。記住:對於一個沒有維護過的堆(完全二叉樹),我們可以從其最后一個節點的父節點開始進行調整。這個不需要死記硬背,其實就是一個層層調節的過程。


(從最后一個節點的父節點調整)


(繼續向上調整)


(繼續向上調整)

建堆+調整的代碼大概就是這樣:(翻Java牌子)

   //建堆。對於一個還沒維護過的堆,從他的最后一個節點的父節點開始進行調整。
    private void buildHeap(int[] nums) {
        //最后一個節點
        int lastNode = nums.length - 1;
        //記住:父節點 = (i - 1) / 2  左節點 = 2 * i + 1  右節點 = 2 * i + 2;
        //最后一個節點的父節點
        int startHeapify = (lastNode - 1) / 2;
        while (startHeapify >= 0) {
            //不斷調整建堆的過程
            heapify(nums, startHeapify--);
        }
    }

    //調整大頂堆的過程
    private void heapify(int[] nums, int i) {
        //和當前節點的左右節點比較,如果節點中有更大的數,那么交換,並繼續對交換后的節點進行維護
        int len = nums.length;
        if (i >= len)
            return;
        //左右子節點
        int c1 = ((i << 1) + 1), c2 = ((i << 1) + 2);
        //假定當前節點最大
        int max = i;
        //如果左子節點比較大,更新max = c1;
        if (c1 < len && nums[c1] > nums[max]) max = c1;
        //如果右子節點比較大,更新max = c2;
        if (c2 < len && nums[c2] > nums[max]) max = c2;
        //如果最大的數不是節點i的話,那么heapify(nums, max),即調整節點i的子樹。
        if (max != i) {
            swap(nums, max, i);
            //遞歸處理
            heapify(nums, max);
        }
    }

    private void swap(int[] nums, int i, int j) {
        nums[i] = nums[i] + nums[j] - (nums[j] = nums[i]);
    }

然后我們從下標 k 繼續開始依次遍歷數組的剩余元素。如果元素小於堆頂元素,那么取出堆頂元素,將當前元素入堆。在上面的示例中 ,因為2小於堆頂元素6,所以將2入堆。我們發現現在的完全二叉樹不滿足大頂堆,所以對其進行調整。


(調整前)


(調整后)

繼續重復上述步驟,依次將7,3,8入堆。這里因為7和8都大於堆頂元素5,所以只有3會入堆。


(調整前)


(調整后)

最后得到的堆,就是我們想要的結果。由於堆的大小是 K,所以這里空間復雜度是O(K),時間復雜度是O(NlogK)。

根據分析,完成代碼:

//java
class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0)
            return new int[0];
        int len = arr.length;
        if (k == len)
            return arr;

        //對arr數組的前k個數建堆
        int[] heap = new int[k];
        System.arraycopy(arr, 0, heap, 0, k);
        buildHeap(heap);

        //對后面較小的樹建堆
        for (int i = k; i < len; i++) {
            if (arr[i] < heap[0]) {
                heap[0] = arr[i];
                heapify(heap, 0);
            }
        }
        //返回這個堆
        return heap;
    }

    private void buildHeap(int[] nums) {
        int lastNode = nums.length - 1;
        int startHeapify = (lastNode - 1) / 2;
        while (startHeapify >= 0) {
            heapify(nums, startHeapify--);
        }
    }

    private void heapify(int[] nums, int i) {
        int len = nums.length;
        if (i >= len)
            return;
        int c1 = ((i << 1) + 1), c2 = ((i << 1) + 2);
        int max = i;
        if (c1 < len && nums[c1] > nums[max]) max = c1;
        if (c2 < len && nums[c2] > nums[max]) max = c2;
        if (max != i) {
            swap(nums, max, i);
            heapify(nums, max);
        }
    }

    private void swap(int[] nums, int i, int j) {
        nums[i] = nums[i] + nums[j] - (nums[j] = nums[i]);
    }
}

大根堆(前 K 小) / 小根堆(前 K 大),Java中有現成的 PriorityQueue,實現起來最簡單:\(O(NlogK)\)O(NlogK)
本題是求前 K 小,因此用一個容量為 K 的大根堆,每次 poll 出最大的數,那堆中保留的就是前 K 小啦(注意不是小根堆!小根堆的話需要把全部的元素都入堆,那是 \(O(NlogN\))O(NlogN)😂,就不是 \(O(NlogK)\)O(NlogK)啦~~)
這個方法比快排慢,但是因為 Java 中提供了現成的 PriorityQueue(默認小根堆),所以實現起來最簡單,沒幾行代碼~

上面自己實現堆可能有點麻煩,所以我們使用Java自帶的PriorityQueue優先隊列,它是使用堆來實現的,默認為小頂堆,我們改一下比較器即可。
使用API:

// 保持堆的大小為K,然后遍歷數組中的數字,遍歷的時候做如下判斷:
// 1. 若目前堆的大小小於K,將當前數字放入堆中。
// 2. 否則判斷當前數字與大根堆堆頂元素的大小關系,如果當前數字比大根堆堆頂還大,這個數就直接跳過;
//    反之如果當前數字比大根堆堆頂小,先poll掉堆頂,再將該數字放入堆中。
class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {

        if (k == 0 || arr.length == 0) {
            return new int[0];  // 返回長度為0的空數組
        }

        // 默認是小根堆,實現大根堆需要重寫一下比較器。
        // 我們需要一個容量為k的大頂堆,后面的數字來和頂進行比較,比它小就替換,調整堆
        Queue<Integer> heap = new PriorityQueue<>((v1, v2) -> v2 - v1);

        // 建立一個容量為k的大頂堆
        for (int i = 0; i < k; i++) {
            heap.add(arr[i]);
        }

        // 后面的數字和頂進行比較,比他小就替換,調整堆
        for (int i = k; i < arr.length; i++) {
            if (arr[i] < heap.peek()) {

                heap.remove();
                heap.add(arr[i]);
            }
        }

        // 將隊列轉化為int[]數組
        return heap.stream().mapToInt(Integer::valueOf).toArray();
    }
}

大佬解法

大佬解法:更多解法

解題思路:
對於經典TopK問題,本文給出 4 種通用解決方案。

一、用快排最最最高效解決 TopK 問題:O(N)O(N)

注意找前 K 大/前 K 小問題不需要對整個數組進行 O(NlogN)O(NlogN) 的排序!
例如本題,直接通過快排切分排好第 K 小的數(下標為 K-1),那么它左邊的數就是比它小的另外 K-1 個數啦~
下面代碼給出了詳細的注釋,沒啥好啰嗦的,就是快排模版要記牢哈~

Java

class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0 || arr.length == 0) {
            return new int[0];
        }
        // 最后一個參數表示我們要找的是下標為k-1的數
        return quickSearch(arr, 0, arr.length - 1, k - 1);
    }

    private int[] quickSearch(int[] nums, int lo, int hi, int k) {
        // 每快排切分1次,找到排序后下標為j的元素,如果j恰好等於k就返回j以及j左邊所有的數;
        int j = partition(nums, lo, hi);
        if (j == k) {
            return Arrays.copyOf(nums, j + 1);
        }
        // 否則根據下標j與k的大小關系來決定繼續切分左段還是右段。
        return j > k? quickSearch(nums, lo, j - 1, k): quickSearch(nums, j + 1, hi, k);
    }

    // 快排切分,返回下標j,使得比nums[j]小的數都在j的左邊,比nums[j]大的數都在j的右邊。
    private int partition(int[] nums, int lo, int hi) {
        int v = nums[lo];
        int i = lo, j = hi + 1;
        while (true) {
            while (++i <= hi && nums[i] < v);
            while (--j >= lo && nums[j] > v);
            if (i >= j) {
                break;
            }
            int t = nums[j];
            nums[j] = nums[i];
            nums[i] = t;
        }
        nums[lo] = nums[j];
        nums[j] = v;
        return j;
    }
}

快排切分時間復雜度分析: 因為我們是要找下標為k的元素,第一次切分的時候需要遍歷整個數組 (0 ~ n) 找到了下標是 j 的元素,假如 k 比 j 小的話,那么我們下次切分只要遍歷數組 (0~k-1)的元素就行啦,反之如果 k 比 j 大的話,那下次切分只要遍歷數組 (k+1~n) 的元素就行啦,總之可以看作每次調用 partition 遍歷的元素數目都是上一次遍歷的 1/2,因此時間復雜度是 N + N/2 + N/4 + ... + N/N = 2N, 因此時間復雜度是 O(N)O(N)。

二、大根堆(前 K 小) / 小根堆(前 K 大),Java中有現成的 PriorityQueue,實現起來最簡單:O(NlogK)O(NlogK)

本題是求前 K 小,因此用一個容量為 K 的大根堆,每次 poll 出最大的數,那堆中保留的就是前 K 小啦(注意不是小根堆!小根堆的話需要把全部的元素都入堆,那是 O(NlogN)O(NlogN)😂,就不是 O(NlogK)O(NlogK)啦~~)
這個方法比快排慢,但是因為 Java 中提供了現成的 PriorityQueue(默認小根堆),所以實現起來最簡單,沒幾行代碼~

Java

// 保持堆的大小為K,然后遍歷數組中的數字,遍歷的時候做如下判斷:
// 1. 若目前堆的大小小於K,將當前數字放入堆中。
// 2. 否則判斷當前數字與大根堆堆頂元素的大小關系,如果當前數字比大根堆堆頂還大,這個數就直接跳過;
//    反之如果當前數字比大根堆堆頂小,先poll掉堆頂,再將該數字放入堆中。
class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0 || arr.length == 0) {
            return new int[0];
        }
        // 默認是小根堆,實現大根堆需要重寫一下比較器。
        Queue<Integer> pq = new PriorityQueue<>((v1, v2) -> v2 - v1);
        for (int num: arr) {
            if (pq.size() < k) {
                pq.offer(num);
            } else if (num < pq.peek()) {
                pq.poll();
                pq.offer(num);
            }
        }
        
        // 返回堆中的元素
        int[] res = new int[pq.size()];
        int idx = 0;
        for(int num: pq) {
            res[idx++] = num;
        }
        return res;
    }
}

三、二叉搜索樹也可以 O(NlogK)O(NlogK)解決 TopK 問題哦

BST 相對於前兩種方法沒那么常見,但是也很簡單,和大根堆的思路差不多~
要提的是,與前兩種方法相比,BST 有一個好處是求得的前K大的數字是有序的。

因為有重復的數字,所以用的是 TreeMap 而不是 TreeSet(有的語言的標准庫自帶 TreeMultiset,也是可以的)。

TreeMap的key 是數字,value 是該數字的個數。
我們遍歷數組中的數字,維護一個數字總個數為 K 的 TreeMap:
1.若目前 map 中數字個數小於 K,則將 map 中當前數字對應的個數 +1;
2.否則,判斷當前數字與 map 中最大的數字的大小關系:若當前數字大於等於 map 中的最大數字,就直接跳過該數字;若當前數字小於 map 中的最大數字,則將 map 中當前數字對應的個數 +1,並將 map 中最大數字對應的個數減 1。

Java

class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0 || arr.length == 0) {
            return new int[0];
        }
        // TreeMap的key是數字, value是該數字的個數。
        // cnt表示當前map總共存了多少個數字。
        TreeMap<Integer, Integer> map = new TreeMap<>();
        int cnt = 0;
        for (int num: arr) {
            // 1. 遍歷數組,若當前map中的數字個數小於k,則map中當前數字對應個數+1
            if (cnt < k) {
                map.put(num, map.getOrDefault(num, 0) + 1);
                cnt++;
                continue;
            } 
            // 2. 否則,取出map中最大的Key(即最大的數字), 判斷當前數字與map中最大數字的大小關系:
            //    若當前數字比map中最大的數字還大,就直接忽略;
            //    若當前數字比map中最大的數字小,則將當前數字加入map中,並將map中的最大數字的個數-1。
            Map.Entry<Integer, Integer> entry = map.lastEntry();
            if (entry.getKey() > num) {
                map.put(num, map.getOrDefault(num, 0) + 1);
                if (entry.getValue() == 1) {
                    map.pollLastEntry();
                } else {
                    map.put(entry.getKey(), entry.getValue() - 1);
                }
            }
            
        }

        // 最后返回map中的元素
        int[] res = new int[k];
        int idx = 0;
        for (Map.Entry<Integer, Integer> entry: map.entrySet()) {
            int freq = entry.getValue();
            while (freq-- > 0) {
                res[idx++] = entry.getKey();
            }
        }
        return res;
    }
}

四、數據范圍有限時直接計數排序就行了:O(N)O(N)

Java

class Solution {
    public int[] getLeastNumbers(int[] arr, int k) {
        if (k == 0 || arr.length == 0) {
            return new int[0];
        }
        // 統計每個數字出現的次數
        int[] counter = new int[10001];
        for (int num: arr) {
            counter[num]++;
        }
        // 根據counter數組從頭找出k個數作為返回結果
        int[] res = new int[k];
        int idx = 0;
        for (int num = 0; num < counter.length; num++) {
            while (counter[num]-- > 0 && idx < k) {
                res[idx++] = num;
            }
            if (idx == k) {
                break;
            }
        }
        return res;
    }
}

最后
雷同題目 215. 數組中的第K個最大元素 常考哦~


因為分治算法一般都應用於操作樹,所以大家可以看看:【數據結構】樹


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM