インタビュー必見のアルゴリズムの質問|バックトラッキングアルゴリズムの問題解決フレームワーク

目次


1。概要

1.1回顧的思考

バックトラッキングアルゴリズム(バックトラック)は一種の試行錯誤の考え方であり、本質的に深さ優先探索です。つまり、問題の特定の状態から始めて、既存の状態で実行できる選択を順番に試して、次の状態に入ります。このプロセスは再帰的です。特定の状態でこれ以上選択できない場合、またはターゲットの回答が見つかった場合は、最後にすべての選択が試行されるまで、1つのステップまたは複数のステップを実行して再試行します。

全体のプロセスは迷路を歩くようなものです。分岐点に遭遇したとき、私たちは一方向から入って試してみることができます。行き止まりに達した場合は、最後の分岐点に戻り、別の方向を選択して、出口がないか、出口が見つかるまで試行を続けます。

1.2バックトラックの3つの要素

バックトラッキングアルゴリズムのアイデアを理解した後、バックトラッキングの要点を分析しましょう。バックトラッキングアルゴリズムでは、パス、選択リスト、終了条件の3つの要素を考慮する必要があります。例として迷路を歩くとします

  • 1.パス:行われた選択
  • 2.選択リスト:現在の状態で行うことができる選択
  • 3.終了条件:選択リストが空であるか、ターゲットが見つかりました

この迷路から抜け出すには、1つのことを繰り返す必要があります。1つの方向から入って試してみることを選択します。行き止まりに達した場合は、最後の分岐点に戻り、別の方向を選択して試行を続けます。プログラムによって実現されるこの反復的なものは再帰的な関数です。実際には、問題解決のテンプレートとアイデアに従うことができます。

fun backtrack(){
    1\. 判断当前状态是否满足终止条件
    if(终止条件){
        return solution
    }
    2\. 否则遍历选择列表
    for(选择 in 选择列表){
        3\. 做出选择
        solution.push(选择)
        4\. 递归
        backtrack()
        5\. 回溯(撤销选择)
        stack.pop(选择)
    }
}

問題解決のフレームワークとアイデアは厳密ではなく、特定の問題に基づいて詳細に分析する必要があることに注意してください。たとえば、バックトラック(選択の取り消し)は必要ありません。セクション3.2、セクション5のk番目の順序島の数では、deep関数が戻った後にバックトラックする必要はありません。

1.3遡及的剪定

バックトラッキングアルゴリズムの時間計算量は非常に高いため、ブランチに遭遇したときに「先見性があり」、特定の選択で最終的に答えが見つからないことがわかっている場合は、このパスをたどろうとしないでください。このステップはプルーニングと呼ばれます。

では、この「先見性」をどのように見つけますか?私の結論の後、おそらく次の状況があります:

  • 重複状態

次に例を示します。47。順列II完全順列II(繰り返し番号を使用)この質問には、繰り返し番号を含めることができるシーケンスが与えられ、すべての非繰り返し順列を返す必要があります。たとえば[1,1,2]、入力の期待される出力はです[1,1,2]、[1,2,1]、[2,1,1]以前に紹介した問題解決テンプレートを使用すると、この問題は難しくありません。

class Solution {
    fun permute(nums: IntArray): List<List<Int>> {
        val result = ArrayList<List<Int>>()

        // 选择列表
        val useds = BooleanArray(nums.size) { false }
        // 路径
        val track = LinkedList<Int>()

        fun backtrack() {
            // 终止条件
            if (track.size == nums.size) {
                result.add(ArrayList<Int>(track))
                return
            }
            for ((index, used) in useds.withIndex()) {
                if (!used) {
                    // 做出选择
                    track.push(nums[index])
                    useds[index] = true
                    backtrack()
                    // 回溯
                    track.pop()
                    useds[index] = false
                }
            }
        }

        backtrack()

        return result
    }
}
复制代码

ただし、元の配列には2つの1があるため、結果にいくつかの繰り返しがあります。重複を削除するにはどうすればよいですか。1つの方法は、アレンジメントを取得した後、結果セットに同じアレンジメントがすでに存在するかどうかを確認することです。これはO(n2)O(n ^ 2)O(n2)の比較であり、明らかに賢明ではありません。もう1つの方法は、繰り返しの状態を探し、最初から「先見の明を持って」いくつかの選択を避けることです。

最初に繰り返し状態とは何ですか?私たちが話したバックトラックの3つの要素、パス、選択リスト、および終了条件を覚えておいてください一般的に、終了条件はこれら3つの要素で固定されており、パスと選択リストは選択とバックトラックごとに変更されます。

つまり、2つの状態のパスと選択リストがまったく同じであることがわかった場合、それは2つの状態が完全に複製されていることを意味します。再帰は2つの繰り返される状態で始まり、最終結果を繰り返す必要があります。この質問では、最初にクイックソートの実装に入りました。その後、各選択の後に、現在の状態と選択された繰り返しがスキップであるかどうかを最初に判断します。

class Solution {
    fun permuteUnique(nums: IntArray): List<List<Int>> {

        val result = ArrayList<LinkedList<Int>>()
        if(nums.size <= 0){
            result.add(LinkedList<Int>())
            return result
        }

        // 排序
        Arrays.sort(nums)

        // 选择列表
        val used = BooleanArray(nums.size){false}
        // 路径
        val track = LinkedList<Int>()

        fun backtrack(){
            // 终止条件
            if(track.size >= nums.size){
                result.add(LinkedList<Int>(track))
                return
            }

            for((index,num) in nums.withIndex()){
                if(used[index]){
                    continue
                }
                if(index > 0 && !used[index - 1] && nums[index] == nums[index - 1]){
                    continue
                }
                // 做出选择
                used[index] = true
                track.push(num)
                // 递归
                backtrack()
                // 回溯
                used[index] = false
                track.pop()
            }
        }

        backtrack()

        return result
    }
}
  • 最終的な解決策が決定されます

選択したブランチで最終的なソリューションを決定した後は、他のオプションを試す必要はありません。たとえば、79。WordSearch単語検索では、単語の存在を判別するときに検索を続行する必要はなく、セクション4ではこの質問の分析に専念します。

fun backtrack(...){
    for (选择 in 选择列表) {
        1\. 做出选择
        2\. 递归
        val match = backtrack(...)
        3\. 回溯
        if (match) {
            return true
        }
    }
}
  • 無効な選択

ある選択が既知の条件に基づいて最終的な解決策を見つけることができないと判断できる場合、この選択を試す必要はありません。例:39。組み合わせの合計60。順列シーケンスk順列


3.順列と組み合わせとサブセット問題

3.1順列と組み合わせの問題

**順列と組み合わせとサブセット**は、バックトラッキングアルゴリズムで最も一般的な問題であると言えます。それらの中で、サブセット問題は本質的に組み合わせ問題です。簡単な質問体験の順列と区別の組み合わせについて説明しましょう

  • 配置の問題:

A、B、Cの3種類のボールがあり、そのうち2つを取り出して一列に並べます。

  • 組み合わせの問題:

A、B、Cの3種類のボールがあり、そのうち2つを取り出して積み上げます。

行と山の違いは何ですか?明らかに、一方の行は順序付けられており、もう一方の行は順序付けられていません。たとえば、[ABC]と[ACB]は異なりますが、{ABC}と{ACB}は同じです。

実際、上の図から、組み合わせ問題は順列問題に基づいて重複セットを削除することであり、サブセット問題は異なるスケールの複数の組み合わせ問題をマージすることであることがはっきりとわかります。

では、同じ要素で順序が異なるコレクションを除外するにはどうすればよいでしょうか。これは非常によく理解されている方法です。「これが私の中学校の数学の先生が教えてくれた方法です」と言うと、多くの生徒が気付くようになると思います。

ご覧のとおり、前の要素の組み合わせを回避する限り、重複を回避できます。たとえば、{B、}を選択した後、前のA要素を結合しないでください。結合しないと重複が発生します。{A、}のブランチには、{A、B}の組み合わせがすでに存在するためです。したがって、from変数を使用して、現在の状態で許可されている選択リストの範囲を示すことができます。

以下に、n個の数からkを取得する順列と組み合わせのコードを示します。再帰的ソリューションコードはより解釈しやすいため、読者はまず、再帰的ソリューションを上手に記述できることを確認する必要があります。

複雑さの分析:

3.2辞書式順序の問題

配置問題には、**辞書順**の概念もあります。これは、辞書内の単語の順序と同じように、アルファベット順に基づいています。たとえば、[ABC]は[ACB]の前に配置されます。辞書式順序で完全な順列を取得するために、再帰的ソリューションと非再帰的ソリューションの両方を実装できます。

再帰的ソリューションでは、選択リストをアルファベット順に確認するだけで済みます。結果として得られる全体の配置は、辞書式順序で実装するのが比較的簡単です。セクション1.4で答えが示されています。

非再帰的ソリューションを使用する基本的な考え方は、現在の文字列から始めて、辞書式順序で次の配置を直接見つけることです。たとえば、[ABC]から次の配置[ACB]を直接見つけることです。それを見つける方法、あなたは最初にこの質問を簡単に考えることができます:

  • 次の順列

31.次の順列次の順列指定された数のシーケンスを、辞書式順序で次に大きい順列に再配置します。

次の順列のアルゴリズムを理解した後、完全な順列のアルゴリズムを作成することは難しくありません。最初の順列から次の順列を出力するだけで、最終的に辞書式順序で完全な順列を取得できます。興味があれば、前のセクションで解決策を確認できます。ここでは投稿しません。

  • K番目の順列

次の順列を見つけることに加えて、k番目の順列を見つけることも非常に一般的です。例:60。順列シーケンスk番目の順列440。辞書式順序でK番目に小さい辞書式順序でK番目に小さい基本的な考え方は次のとおりです。階乗を計算し、このブランチのリーフノードの数を事前に把握し、kがこのブランチにない場合は剪定します。


4.2次元平面探索問題

記事の冒頭で述べた迷路の問題は、実際には2次元平面でのバックトラッキングアルゴリズムの問​​題であり、現在の位置は終了条件に達したときの終点です。これはバックトラッキングアルゴリズムであるため、繰り返し強調してきた3つの要素(パスと選択リストと終了条件)から逃れることはできません

4.1パス-2次元配列の使用

完全な順列の問題では、トラバースされたパスをマークするために使用される1次元のブール配列を使用します。同様に、2次元平面では、2次元ブール配列を使用して、通過したパスをマークする必要があります。

1\. 一维数组 int[] useds
2\. 二维数组 int[][] useds

4.2選択リスト-オフセット配列

2次元平面で検索する場合、ポイントの選択リストは、その上、下、左、および右の方向(到来する方向を除く)です。コーディングの便宜のために、オフセット配列を使用できます。オフセット配列の4つのオフセットのうちの1つは無関係です

int[][] direction = {
   
   {-1, 0}, {0, -1}, {0, 1}, {1, 0}};

4.3境界を確認する

通常、2次元平面には境界があるため、現在の入力位置が範囲外かどうかを判断する関数を作成できます。

private boolean check(int row, int column) {
    return row >= 0 && row < m && column >= 0 && column < n;
}

前の伏線で、この質問を見てみましょう:79。単語検索単語検索は理解しやすいです。


5.フラッドフィルの問題

Flood Fill(Flood Fill、またはSeed Fill)は、前のセクションの2次元平面探索問題の高度なバージョンです。つまり、2次元平面上の条件を満たす接続されたノードを見つけます。

いわゆる接続ノードとは、上、下、左、右の4方向に接続された2つのノードを指します。いくつかの質問は8方向(対角4方向)に拡張されますが、さらにいくつかの方向だけで、大きな違いはありません。たとえば、次の画像は、中央のノードに接続されている白い正方形に色を付けています。

2次元探索問題のあるフレームでの問題解決全体は、別の解決策に焦点を当てたものとほとんど同じです:disjoint-set、この記事では、すでに詳細に説明されており、使用シナリオと問題解決スキルセットを確認しています:「データ構造インタビューの質問|ユニオン検索セットとユニオン検索アルゴリズム」

簡単に言えば、ユニオン検索セットは、互いに素なセットの接続性の問題処理するのに適しており、フラッドフィルの接続性の問題を解決するのに適しています。中央ノードに接続されているノードを同じコンポーネントにマージできます。すべての処理が完了したら、クエリと収集によって2つのノードが接続されているかどうかを判断できます。

ヒント:パスの圧縮とランクによるマージを同時に使用すると、クエリの時間計算量はO(1)O(1)O(1)に近くなります。


6.まとめ

バックトラッキングアルゴリズムのアイデアは複雑ではありませんが、それは確かに高周波テストサイトです;バックトラッキングアルゴリズムに習熟していることも動的計画法アルゴリズムを理解するのに非常に役立ち、学習の優先順位が高くなります。

この記事オープンソースプロジェクトに含まていますhttps//github.com/xieyuliang/Note-Androidには、さまざまな方向への自己学習プログラミングルート、インタビューの質問収集/顔の経典、および一連の技術記事が含まれています。など。リソースは継続的に更新されています。

今回はここで共有してください次の記事で会いしましょう

参照

著者:鵬醜い
元のアドレス:https://juejin.cn/post/6882928981268496398

おすすめ

転載: blog.csdn.net/weixin_49559515/article/details/112629022