1. 程式人生 > 實用技巧 >第三章 蠻力法

第三章 蠻力法

蠻力法(brute force)是一種簡單直接地解決問題的方法,常常基於問題的描述和所設計的概念定義。

雖然巧妙和高效的演算法很少來自於蠻力法,但它在演算法設計策略中仍然具有重要地位。

  1. 蠻力法適應能力強,是唯一一種幾乎什麼問題都能解決的一般性方法。

  2. 蠻力法一般容易實現,在問題規模不大的情況下,蠻力法能夠快速給出一種可接受速度下的求解方法.

  3. 雖然通常情況下蠻力法效率很低,但可以作為衡量同類問題更高效演算法的準繩。

3.1 選擇排序和氣泡排序

考慮蠻力法在排序問題中的應用:給定一個可排序的n元素序列,將他們按照非降序方式重新排列。這裡討論兩個演算法——選擇排序和氣泡排序——似乎是兩個主要的候選者。

3.1.1 選擇排序

選擇排序開始的時候,掃描整個列表,找到它的最小元素然後和第一個元素交換,將最小元素放到它在有序表中的最終位置上。然後我們從第二個元素開始掃描列表,找到最後n-1個元素中的最小元素,再和第二個元素交換位置,把第二小的元素放到它在有序表中的最終位置上。一般來說,在對該列表做第 i 遍掃描的時候(i 的值從0到n-2),該演算法在最後n-1個元素中尋找最小元素,然後拿它和A[i]交換。

在n-1遍以後,該列表就被排好序了。

下面是演算法的虛擬碼

import java.util.Arrays;

/**
 * 選擇排序
 */
public class SelectionSort {

    /**
     * 選擇排序
     * 
     * @param arr 待排序的陣列
     * @return toString輸出
     */
    public static String selectionSort(Integer[] arr) {
        for (int i = 0; i < arr.length; i++) {
            // 初始化最小的索引
            int minIndex = i;
            for (int j = i; j < arr.length; j++) {
                // 找到當前的i之後最小數,並記錄最小數的索引
                if (arr[j] < arr[minIndex]) {
                    minIndex = j;
                }
            }

            // 交換符合條件的數
            int temp = arr[i];
            arr[i] = arr[minIndex];
            arr[minIndex] = temp;
        }

        return Arrays.deepToString(arr);
    }

    public static void main(String[] args) {
        Integer[] arr = { 89, 45, 68, 90, 29, 34, 17 };
        System.out.println(selectionSort(arr));
    }
}

3.1.2 氣泡排序

蠻力法在排序問題上還有另一個應用,它比較表中的相鄰元素,如果它們是逆序的話就交換他們的位置。重複多次以後,最終,最大的元素“沉到”列表的最後一個位置,第二遍操作將第二大的元素沉下去。這樣一直做,直到n-1遍以後,列表就排好序了。

下面是演算法的虛擬碼

Java實現:

import java.util.Arrays;

public class BubbleSort {
    /**
     * 氣泡排序
     * 
     * @param arr 待排序陣列
     */
    public static void bubbleSort(int[] arr) {
        // 冒泡趟數,n-1趟
        for (int i = 0; i < arr.length - 1; i++) {
            for (int j = 0; j < arr.length - i - 1; j++) {
                if (arr[j + 1] < arr[j]) {
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                }
            }
        }
    }

    public static void main(String[] args) {
        int[] arr = new int[] { 89, 45, 68, 90, 29, 34, 17 };
        bubbleSort(arr);
        System.out.println(Arrays.toString(arr));
    }
}

作為一個例子,圖3.2給出了該演算法對於序列89,45,68,90,29,34,17所作操作

3.2 順序查詢和蠻力字串匹配

我們在前一節中看到了蠻力法在排序問題上的兩個應用,這裡我們討論該策略在查詢問題中的兩個應用。第一個應用處理一個經典的問題,即如何在一個給定的列表中查詢一個給定的值。第二個應用則處理字串匹配問題

3.2.1 順序查詢

順序查詢只是簡單地將給定列表中的連續元素和給定的查詢鍵進行比較,直到遇到一個匹配的元素,或者在遇到匹配元素前就遍歷了整個列表,也就是查詢失敗了。

import java.util.stream.IntStream;

public class SequentialSearch {
    /**
     * 順序查詢
     *
     * @param a   被查詢的陣列
     * @param key 要查詢的key
     * @return key所在索引
     */
    public static int select(int[] a, int key) {
        return IntStream.range(0, a.length)
                .filter(i -> a[i] == key)
                .findFirst()
                .orElse(-1);
    }

    public static void main(String[] args) {
        int[] a = {1, 2, 3, 2, 4, 3, 4, 4, 5};
        int key = 5;
        select(a, key);
        System.out.print(select(a, key));
    }
}

如果已知給定的陣列是有序的。我們可以對該演算法做另外一個簡單的改進:在這種列表中,只要遇到一個大於或者等於查詢鍵的元素,查詢就可以停止了。

順序查詢是闡釋蠻力法的很好的工具,他有蠻力法典型的有點(簡單)和缺點(效率低)。

3.2.2 蠻力字串匹配

給定一個n個字元組成的串[稱為文字(text)],一個m(m≤n)個字元的串[稱為模式(pattern)],從文字中尋找匹配模式的字串,更精確的說,我們球的是i——文字中第一個匹配字串最左元素的下標——使得ti = p0, ..., ti+j = pj, ..., ti+m-1 = pm-1

如果還需要尋找另一個匹配子串,字串匹配演算法可以繼續工作,直到搜尋完全部文字。

字串匹配問題的蠻力演算法是顯而易見的:將模式對準文字的前m個字元,然後從左到右匹配每一對相應的字元,直到m對字串全部匹配(演算法就可以停止了)或者遇到一對不匹配的字元。在後一種情況下,模式向右移一位,然後從模式的第一個字元開始,繼續把模式和文字中的對應字元進行比較,請注意,在文字總,最又一輪字串匹配的起始位置是n-m(假設文字位置的下表是從0到n-1)。在這個位置以後,再也沒有足夠的字元可以匹配整個模式了,因此,該演算法也就沒有必要在做比較了。

/**
 * BF模式匹配演算法
 */
public class BruteForceStringMatch {
    public static void main(String[] args) {
        String src = "NOBODY_NOTTICED_HIM";
        String sub = "NOT";
        int index = bruteForce(src, sub);
        System.out.printf("%s在%s的位置是%d%n", sub, src, index);
    }

    /**
     * BF演算法
     * 
     * @param src 主串
     * @param sub 模式串
     * @return 模式在主串所在索引
     */
    public static int bruteForce(String src, String sub) {
        int i = 0, j = 0;
        while (i < src.length() && j < sub.length()) {
            // 如果當前的字元匹配,則主串和模式串繼續向後移動
            if (src.charAt(i) == sub.charAt(j)) {
                i++;
                j++;
            } else {
                // 如果不相同,則主串的指標後移一個,模式串的指標恢復到最開始的位置
                i = i - j + 1;
                j = 0;
            }
        }

        if (j >= sub.length()) {
            // 如果匹配大於等於被匹配字串長度,說明匹配成功
            return i - sub.length();
        } else {
            return Integer.MIN_VALUE;
        }
    }
}

3.3 最近對和凸包問題的蠻力演算法

本節中,我們考慮兩個著名問題的簡單解法,這兩個問題處理都是平面上的有限點集合。他們除了具有理論上的意義意外,還分別來自於兩個重要領域:計算機和和運籌學

3.3.1 最近對問題

最近對問題要求在一個包含n個點的集合,找出距離最近的兩個點。

為了簡單起見,我們只考慮最近對問題的二維版本。假設所討論的點都是以標準笛卡爾座標形式(x,y)給出的,兩個點\(p_i=(x_i,y_i)\)\(p_j=(x_j,y_j)\)之間的距離是標準歐幾里得距離。

\[d(p_i,p_j)=\sqrt{(x_i-x_j)^2+(y_i-y_j)^2} \]

很顯然,求解該問題的蠻力演算法應該是這樣:分別計算每一對點之間的距離,然後找出距離最小的那一對。當然,我們不希望對同一對點計算兩次距離。為了避免這種狀況,我們只考慮i<j的那些對\((p_i,p_j)\)

以下虛擬碼可以計算兩個最近點的距離。如果需要得到最近點對是那兩個,則需要對虛擬碼做一點小小的修改。

import java.util.Arrays;
import java.util.List;

/**
 * BruteForceClosestPoints
 */
public class BruteForceClosestPoints {
    public static void main(String[] args) {
        List<Point> points = Arrays.asList(new Point(1, 1), new Point(1, 9), new Point(2, 5), new Point(3, 1),
                new Point(4, 4), new Point(5, 8), new Point(6, 2));
        double distance = bruteForceClosestPoints(points);
        System.out.printf("最近距離為:%s%n", distance);
    }

    /**
     * 最近點對問題蠻力法
     *
     * @param points 點list
     * @return 最近距離
     */
    public static double bruteForceClosestPoints(List<Point> points) {
        double dist, minDist = Double.MAX_VALUE;
        int size = points.size();
        Point p1 = new Point();
        Point p2 = new Point();
        for (int i = 0; i < size; i++) {
            for (int j = i + 1; j < size; j++) {
                dist = Math.sqrt(Math.pow(points.get(i).getX() - points.get(j).getX(), 2)
                        + Math.pow(points.get(i).getY() - points.get(j).getY(), 2));
                if (dist < minDist) {
                    minDist = dist;
                    p1 = points.get(i);
                    p2 = points.get(j);
                }
            }
        }

        System.out.printf("最近點對為:%s, %s%n", p1, p2);
        return minDist;
    }
}

/**
 * 點
 */
class Point {
    /**
     * 橫座標
     */
    private double x = Integer.MIN_VALUE;

    /**
     * 縱座標
     */
    private double y = Integer.MIN_VALUE;

    public Point() {
    }

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }

    @Override
    public String toString() {
        return "Point{" + "x=" + x + ", y=" + y + '}';
    }
}

3.3.2 凸包問題

現在討論另一個問題——計算凸包。在平面或者高維空間的一個給定點集合中尋找凸包。

先來定義什麼是凸集合:

定義對於平面上的一個點集合(有限的或無限的),如果以集合中任意兩點p和q為端點的線段都屬於該集合,我們說這個集合是的。

現在可以介紹凸包的概念了,直觀地來講,對於平面上n個點的集合,它的凸包就是包含所有這些點(或者在內部,或者在邊界上)的最小凸多邊形。如果這個表述還不能激起大家的興趣,我們可以吧這個問題看作如何用長度最短的柵欄把n頭熟睡的老虎圍起來。

下面對凸包的正式定義可以應用於任意集合,包括那些正好位於一條直線上的點的集合。

定義一個點的集合S的凸包(convex hull)是包含S的最小凸集合(“最小”意指,S的凸包一定是所有包含S的凸集合的子集)

在研究了這個例子以後,下面這個定理其實已經在我們意料之中了

定理 任意包含n>2個點(不共線的點)的集合S的凸包是以S中的某些點為頂點的凸多邊形(如果所有的點都位於一條直線上,多邊形退化為一條線段,但它的兩個端點仍包含在S中)

凸包問題(convex-hull problem)是為一個由n個點的集合構造凸包的問題。為了解決該問題,需要找出某些點,它們將作為這個集合的凸多邊形的頂點。數學家將這種多邊形的頂點稱為“極點”。根據定義,凸集合的極點是這個集合中這樣的點:對於任何以集合中的點為端點的線段來說,他們不是這個線段的中點。

極點具有的一些特性是凸集合中的其他點所不具備的。一個稱為單純形法(simplex method)的重要演算法利用了其中的一個特性, 10.1節將對此進行討論。該演算法解決的是線性規劃(linear programming)問題,這是一種求一個n元線性方程的最大值或最小值的問題(本節習題第12題給出了一個例子,6.6節和10.1節對它做了一般性的討論),該方程需要滿足某些線性約束。然而,這裡我們之所以對極點感興趣,是因為找到了極點,也就解岀了凸
包問題。實際上,為了完全解決該問題,除了知道給定集合中的哪些點是該集合的凸包極點之外,還需要知道另外一些資訊:哪幾對點需要連線起來以構成凸包的邊界。注意,這個問題也可以這樣表述:請將極點按照順時針方向或者逆時針方向排列。

對於一個n個點集合中的兩個點p~i~和p~j~,當且僅當該集合中的其他店都位於穿過這兩點的直線的通一遍時,他們的連線是該集合凸包邊界的一部分,對每一對點都做一遍檢驗之後,滿足條件的線段構成了該凸包的邊界。

為了實現一些演算法,需要用到一些解析幾何的基本知識。首先,在座標平面上穿過兩個點(x~1~, y~1~),(x~2~, y~2~)的直線是有下列方程定義的:

\[ax+by=c \]

其中,a=y~2~-y~1~,b=x~1~-x~2~,c=x~1~y~2~-y~1~x~2~。

其次,這樣一根直線吧平面分為兩個半平面:其中一個半平面中的點都滿足ax+by>c,而另一個半平面中的點都滿足ax+by>c(當然,對於線上的點來說,ax+by=c)。因此,為了檢驗某些點是否位於這條直線的通一遍,只需要吧每個點帶入ax+by-c,檢驗這個表示式的符號是否相同。

演算法的時間複雜度為O(n^3^)。

import java.util.Arrays;
import java.util.stream.IntStream;

public class ConvexHull {
    /**
     * 蠻力法解決凸包問題
     *
     * @param points 凸多邊形的點陣列
     * @return 凸多邊形的點集合
     */
    public static Point[] getConvexPoint(Point[] points) {
        Point[] result = new Point[points.length];
        // 用於計算最終返回結果中是凸包中點的個數
        int len = 0;
        for (int i = 0; i < points.length; i++) {
            for (int j = 0; j < points.length; j++) {
                // 除去選中作為確定直線的第一個點
                if (j == i)
                    continue;
                // 存放點到直線距離所使用判斷公式的結果
                int[] judge = new int[points.length];

                for (int k = 0; k < points.length; k++) {
                    int a = points[j].getY() - points[i].getY();
                    int b = points[i].getX() - points[j].getX();
                    int c = (points[i].getX()) * (points[j].getY()) - (points[i].getY()) * (points[j].getX());
                    // 根據公式計算具體判斷結果
                    judge[k] = a * (points[k].getX()) + b * (points[k].getY()) - c;
                }

                // 如果點均在直線的一邊,則相應的A[i]是凸包中的點
                if (judgeArray(judge)) {
                    result[len++] = points[i];
                    break;
                }
            }
        }
        Point[] result1 = new Point[len];
        if (len >= 0) System.arraycopy(result, 0, result1, 0, len);
        return result1;
    }

    /**
     * 判斷陣列中元素是否全部大於等於0或者小於等於0
     *
     * @param array 陣列
     * @return 是則返回true,否則返回false
     */
    public static boolean judgeArray(int[] array) {
        boolean judge = Boolean.FALSE;
        int len1, len2;

        len1 = (int) IntStream.range(0, array.length).filter(i -> array[i] >= 0).count();
        len2 = (int) IntStream.range(0, array.length).filter(j -> array[j] <= 0).count();

        if (len1 == array.length || len2 == array.length)
            judge = true;
        return judge;
    }

    public static void main(String[] args) {
        Point[] A = new Point[8];
        A[0] = new Point(1, 0);
        A[1] = new Point(0, 1);
        A[2] = new Point(0, -1);
        A[3] = new Point(-1, 0);
        A[4] = new Point(2, 0);
        A[5] = new Point(0, 2);
        A[6] = new Point(0, -2);
        A[7] = new Point(-2, 0);

        Point[] result = getConvexPoint(A);
        System.out.println("集合A中滿足凸包的點集為:");
        Arrays.stream(result).forEach(System.out::println);
    }
}

class Point {
    private int x = 0;
    private int y = 0;

    public Point() {
    }

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getX() {
        return x;
    }

    public void setY(int y) {
        this.y = y;
    }

    public int getY() {
        return y;
    }

    @Override
    public String toString() {
        return "Point{" +
                "x=" + x +
                ", y=" + y +
                '}';
    }
}

3.4 窮舉查詢

對於組合問題來說,瓊劇查詢(exhaustive search)是一種簡單的蠻力方法,他要求生成問題域中的每一個元素,選出其中滿足問題約束的元素,然後在找出一個期望元素。

3.4.1 旅行商問題

這個問題要求找出一條n個給定城市間的最短路徑,使我們在回到出發的城市之前,對每個城市都只訪問一次,這個問題可以很方便地用加權圖來建模,也就是說,用圖的頂點代表城市,用邊的權重來表示城市間的距離,這樣的問題可就可以表述為求一個圖的最短哈密頓迴路(Hamiltonian circuit)問題。我們把哈密頓賄賂定義為一個對圖的每個頂點都只穿越一次的迴路。

很容易看出來,哈密頓迴路可以定義為n+1個相鄰頂點的一列序列,其中,序列的第一個頂點和最後一個頂點是相同的,而其他的n-1個頂點是互不相同的。因此,可以通過生成n-1箇中間城市的組合來得到所有旅行線路,計算這些線路的長度,然後求得最短的線路。

import java.util.Arrays;
import java.util.stream.IntStream;

public class TravelingSalesman {
    /**
     * 計算當前已行走方案的次數,初始化為0
     */
    public static int count = 0;
    /**
     * 定義完成一個行走方案的最短距離
     */
    public static int minDist = Integer.MAX_VALUE;
    /**
     * 使用二維陣列的那個圖的路徑相關距離長度
     */
    public static int[][] distance = { { 0, 2, 5, 7 }, { 2, 0, 8, 3 }, { 5, 8, 0, 1 }, { 7, 3, 1, 0 } };

    public static void main(String[] args) {
        int[] A = { 0, 1, 2, 3 };
        arrange(A, 0, 0, 4, 24); // 此處Max = 4!=24
    }

    /**
     * 旅行商問題
     *
     * @param arr   圖
     * @param start 開始進行排序的位置
     * @param step  當前正在行走的位置
     * @param n     需要排序的總位置數
     * @param max   n!值
     */
    public static void arrange(int[] arr, int start, int step, int n, int max) {
        if (step == n) {
            count++;
            printArray(arr);
        }

        // n!正好等於是所有的方案個數
        if (count == max) {
            System.out.println("已完成全部行走方案,最短路徑距離為:" + minDist);
        } else {
            for (int i = start; i < n; i++) {
                swapArray(arr, start, i);
                arrange(arr, start + 1, step + 1, n, max);
                swapArray(arr, i, start);
            }
        }
    }

    /**
     * 輸出陣列A的序列,並輸出當前行走序列所花距離,並得到已完成的行走方案中最短距離
     *
     * @param arr 陣列
     */
    public static void printArray(int[] arr) {
        // 輸出當前行走方案的序列
        Arrays.stream(arr).mapToObj(j -> j + "  ").forEachOrdered(System.out::print);

        // 此處是因為,最終要返回出發地城市,所以總距離要加上最後到達的城市到出發點城市的距離
        int tempDistance = distance[arr[0]][arr[3]];
        // 輸出當前行走方案所花距離
        tempDistance += IntStream.range(0, arr.length - 1).map(i -> distance[arr[i]][arr[i + 1]]).sum();

        // 返回當前已完成方案的最短行走距離
        if (minDist > tempDistance) {
            minDist = tempDistance;
        }

        System.out.println("  行走路程總和:" + tempDistance);
    }

    /**
     * 交換陣列中兩個位置上的數值
     *
     * @param arr 陣列
     * @param p   位置p
     * @param q   位置q
     */
    public static void swapArray(int[] arr, int p, int q) {
        int temp = arr[p];
        arr[p] = arr[q];
        arr[q] = temp;
    }
}

3.4.2 揹包問題

給定n個重量為w~1~,w~2~,...,w~n~,價值為v~1~,v~2~,...,v~n~的物品和一個承重為W的揹包,求這些物品中一個最有價值的自己,而且要能夠裝入揹包中。

窮舉查詢需要考慮給定的n個物品集合的所有子集,複雜度為Ω(2^n^)的演算法。

public class BackPack {
    public static void main(String[] args) {
        int m = 10;
        int[] w = {7, 3, 4, 5};
        int[] p = {42, 12, 40, 25};
        int maxPrice = maxPrice(m, w, p);
        System.out.println("maxPrice = " + maxPrice);
    }

    /**
     * 0-1揹包問題
     *
     * @param m 揹包總重量
     * @param w 物品重量
     * @param p 物品價值
     * @return 最大價值
     */
    public static int maxPrice(int m, int[] w, int[] p) {
        // 最大價值,初始化為0
        int maxPrice = 0;
        // 窮舉所有的情況,共2^n中
        for (int i = 0; i < Math.pow(2, w.length - 1); i++) {
            // 初始化當前的總重量
            int weightTotal = 0;
            // 初始化當前的總價值
            int priceTotal = 0;
            // 遍歷當前的一種情況,每個物品的情況
            for (int j = 0; j < w.length; j++) {
                // 當前這種情況下相對應的二進位制位上有沒有物品
                if (get2(i, j) == 1) {
                    weightTotal += w[j];
                    priceTotal += p[j];
                }
            }

            if (weightTotal < m && priceTotal >= maxPrice) {
                maxPrice = priceTotal;
            }
        }

        return maxPrice;
    }

    /**
     * 當前這種情況下相對應的二進位制位上有沒有物品,只要result為0,就表示不要,否則表示要這個物品
     *
     * @param a 遍歷到哪一種情況
     * @param b 這種情況下的第b個物品要不要算進去
     * @return 1表示要,0表示不要
     */
    public static int get2(int a, int b) {
        int result = a & Double.valueOf(Math.pow(2, b - 1)).intValue();
        return result == 0 ? 0 : 1;
    }
}

3.4.3 分配問題

有n個任務需要分配給n個人執行,一個任務對應一個人(意思是說,每個任務只分配給一個人,每個人只分配一個任務),對於每一對i,j=1,2,...,n來說,將第j個任務分配給第i個人的成本是C[i,j]。該問題要找出總成本最小的分配方案。

很容易發現,分配問題的例項完全可以用成本矩陣C來表示,就這個矩陣來說,這個問題要求在矩陣的每一行中選出一個元素,這些元素分別屬於不同的列,而且元素的和是最小的。

我們可以用一個n維元組<j~1~,...,j~n~>來描述分配問題的一個可能的解,其中第i個分量(i=1,…,n)表示的是在第i行中選擇的列號(也就是說,給第i個人分配的任務號)。例如,對於上面的成本矩陣來說,<2,3,4,1>表示這樣一種可行的分配:任務2分配給人員1,任務3分配給人員2,任務4分配給人員3,任務1分配給人員4。分配問題的要求意味著,在可行的分配和前n個整數的排列之間存在著一一對應關係。因此,分配問題的窮舉查詢要求生成整數1,2,….n的全部排列,然後把成本矩陣中的相應元素相加來求得每種分配方案的總成本,最後選出其中具有最小和的方案。如果對上面的例項應用該演算法,它的最初幾次迴圈顯示在圖3.9中。

由於分配問題的一般情況下,需要考慮的排列數了是n!,所以除了該問題的一些規模非常小的例項,窮舉查詢發幾乎是不適用的。幸運的是,對於該問題有一個效率高得多的演算法,稱為匈牙利方法(Hungarian method)

3.5 深度優先查詢和廣度優先查詢

3.5.1 深度優先查詢

深度優先搜尋可以從圖的任意頂點開始,然後把該頂點標記為已經訪問,每次迭代的時候,深度搜索緊接著處理與當前頂點鄰接的未訪問頂點(如果有若干個頂點,則任意選擇一個,也可以按自己的條件選擇),讓這個過程一直持續,直到遇到一個終點——該點的每個鄰接點都被訪問過了,然後在該終點上後退一條邊,並繼續搜尋未訪問的點,直到返回起點(就是開始搜尋的點),直到發現起點的所有鄰接點都已經訪問過了,此時圖的所有聯通分量都已經訪問過了,如果還有未訪問的點,則從此點開始繼續上面的過程。

用一個棧來跟蹤深度優先查詢的操作是比較方便的,在第一次訪問一個頂點的時候,我們把該頂點入棧,當他成為要給終點時,我們把它出棧。

在深度優先查詢遍歷的時候構造一個所謂的深度優先查詢森林(depth-first search forest)也是非常有用的。遍歷的初始頂點可以作為這樣一個森林中的第一棵樹的根。無論何時,如果第一次遇到一個新的為訪問頂點,它是從哪個頂點被訪問到的,就把它附加為那個頂點的孩子。連線這樣兩個頂點的邊稱為樹向邊(tree edge),因為所有這種邊的集合構成了一個森林。該演算法也可能會遇到一條只想已訪問頂點的邊,並且這個頂點不是它的直接前驅(即它在樹中的父母),我們把這種邊稱為回邊(back edge),因為這條邊在一個深度優先查詢森林中,把一個頂點和它的非父母祖先連在了一起。

如果要檢查一個圖中是否包含迴路,我們可以利用圖的DFS森林形式的表示法。如果DFS森林不包含回邊,這個圖顯然是無迴路的。如果從某些節點u到它的祖先v之間有一條回邊(例如,在圖3.10(c)中從d到a的回邊),則該圖有一條迴路,這個迴路是由DFS森林中從v到u的路徑上一系列樹向邊以及從u到v的回邊構成的。

DFS的實現

https://www.cnblogs.com/handsomelixinan/p/10346065.html

DFS一般有兩種實現方法:棧和遞迴

其實遞迴便是應用了棧的思想,而一般遞迴的寫法非常簡單。

以下為虛擬碼:

public 引數1 DFS(引數2) {
    if(返回條件成立) return 引數;
    DFS(進行下一步的搜尋遍歷);
}

先分析if語句:

這句話的作用就是告訴小蛇:是否撞到南牆啦?撞到就返回啦,或者,是否到達終點啦?到了就結束啦!

所以我們在思考使用DFS進行解決問題的時候需要思考這兩個問題:

  1. 是否有條件不成立的資訊(撞到南牆)
  2. 是否有條件成立的資訊(到達終點)。

還有一個非常重要的資訊:是否需要標記訪問節點。

下面來談談為什麼要標記訪問節點,以及如何來標記訪問節點。

還是以剛才的路徑為例:

注意當我們的小蛇走到了4號節點時,沒有選擇去到6號節點,而是去到了5號節點,並沿紅色路徑行進,這樣子是不是很有可能產生一個迴環:

1->2->4->5->7->1,你會發現我們的小蛇在瘋狂繞圈,肯定是到不了終點6號了。如何才能幫助我們的小蛇呢?

當然是通過標記路徑了!

標記路徑的原理是什麼呢?

小蛇每走過一個節點便標記這個節點為已經訪問,小蛇每次需要訪問新節點時不會選擇已經訪問過的節點,這樣就避免了出現迴環的慘案。

如下圖所示,紅色的陰影表示已經訪問過的節點,小蛇在7號節點時發現1號節點已經訪問,所以只好返回,並標記7號節點為以訪問。

那麼如何來標記一個節點是否訪問過呢?

有超級多的方法來表示,常見的方法有陣列法和HashSet法

// 陣列表示,每訪問過一個節點,陣列將對應元素置為true
boolean[] visited = new boolean[length] ; 
// 建立set,每訪問一個節點,將該節點加入到set中去
Set<型別> set = new HashSet<>() ; 

總結一下,在第一部分,我們要思考3個問題

  1. 是否有條件不成立的資訊(撞南牆)

  2. 是否有條件成立的資訊(到終點)

  3. 是否需要記錄節點(記軌跡)

下面,提一個小問題:如果我要遍歷一個圖中的所有節點,以上的3個問題如何回答?

答:

  • 條件1:不成立的資訊就是沒有節點訪問
  • 條件2:沒有條件成立的資訊(沒有終點)
  • 條件3:需要記錄軌跡

所以這個問題的解就是讓小蛇沒有新節點訪問,便完成了整個圖的遍歷

DFS的Java實現

https://www.jianshu.com/p/2d6812a7b868

import java.util.HashMap;
import java.util.LinkedList;

public class DFSDemo {
    public static void main(String[] args) {
        //構造各頂點
        LinkedList<Character> listU = new LinkedList<>();
        listU.add('v');
        listU.add('x');
        LinkedList<Character> listV = new LinkedList<>();
        listV.add('y');
        LinkedList<Character> listY = new LinkedList<>();
        listY.add('x');
        LinkedList<Character> listX = new LinkedList<>();
        listX.add('v');
        LinkedList<Character> listW = new LinkedList<>();
        listW.add('y');
        listW.add('z');
        LinkedList<Character> listZ = new LinkedList<>();
        // 構造圖
        HashMap<Character, LinkedList<Character>> graph = new HashMap<>();
        graph.put('u', listU);
        graph.put('v', listV);
        graph.put('y', listY);
        graph.put('x', listX);
        graph.put('w', listW);
        graph.put('z', listZ);

        HashMap<Character, Boolean> visited = new HashMap<>();
        // 呼叫深度優先遍歷方法
        dfs(graph, visited);
    }

    private static void dfs(HashMap<Character, LinkedList<Character>> graph, HashMap<Character, Boolean> visited) {
        // 為了和圖中的順序一樣,我認為控制了DFS先訪問u節點
        visit(graph, visited, 'u');
        visit(graph, visited, 'w');
    }

    // 通過一個全域性變數count記錄了進入每個節點和離開每個節點的時間
    static int count;

    private static void visit(HashMap<Character, LinkedList<Character>> graph, HashMap<Character, Boolean> visited, char start) {
        if (!visited.containsKey(start)) {
            count++;
            // 記錄進入該節點的時間
            System.out.println("The time into element " + start + ":" + count);
            visited.put(start, true);
            for (char c : graph.get(start)) {
                if (!visited.containsKey(c)) {
                    // 遞迴訪問其鄰近節點
                    visit(graph, visited, c);
                }
            }
            count++;
            // 記錄離開該節點的時間
            System.out.println("The time out element " + start + ":" + count);
        }
    }
}

3.5.2 廣度優先查詢

BFS按照一種同心圓的方式,首先訪問所有和初始頂點鄰接的頂點,然後是離它兩條邊的所有未訪問頂點,以此類推,知道所有初始頂點同在一個連通分量中的頂點都訪問過了位置。如果仍然存在未被訪問的頂點,該演算法必須從圖的其他連通分量中的任意頂點重新開始。

使用佇列來跟蹤廣度優先查詢的操作是比較方便的。該佇列先從遍歷的初始頂點開始,將該頂點標記為已訪問,在每次迭代的時候,該演算法找出所有和隊首頂點領接的未訪問頂點,把他們標記為已訪問,再把他們入隊,然後,將隊首頂點從佇列中移去。

和DFS遍歷類似,在BFS遍歷的同時,構造一個所謂的廣度優先查詢森林(breadth-first search forest)是有意義的。遍歷的初始頂點可以作為這樣一個森林中第一棵樹的根,無論何時,只要第一次遇到一個新的為訪問的頂點,它是從哪個頂點被訪問到的,就把它附加為哪一個頂點的子女,連結這樣兩個頂點的邊稱為樹向邊。如果一條邊只想的是一個曾經訪問過的頂點,而且這個頂點不是他的直接前驅,這條邊被稱為交叉邊。

我們也可以用BFS來檢查圖的連通性和無環性,做法本質上和DFS是一樣的。雖然它並不適用於一些較複雜的應用,但卻可以用來處理一些DFS無法處理的情況。例如:BFS可以用來求兩個給定頂點間邊數量最少的路徑。我們從兩個給定的頂點中開始BFS遍歷,一旦訪問到了另一個頂點就結束。從BFS樹的根到第二個頂點間的最簡單路徑就是我們所求的路徑。

BFS的實現

與DFS不同的是,這次不再是每個分叉路口一個一個走了,而是全部,同時遍歷,直到找到終點,所對應的“層數”便是最短路徑所需要的步數,BFS像是在剝洋蔥,一層一層的撥開,最後到達終點。

我們利用佇列來實現BFS,虛擬碼如下:

int BFS(Node root, Node target) {
    Queue<Node> queue;  // 建立佇列
    int step = 0;       // 建立行動步數
    // initialize
    add root to queue;
    // BFS
    while (queue is not empty) {
        step = step + 1;
        // 記錄此時的佇列大小
        int size = queue.size();
        for (int i = 0; i < size; ++i) { //遍歷佇列中的元素,並將新元素加入到佇列中
            Node cur = the first node in queue;
            return step if cur is target;
            for (Node next : the neighbors of cur) {
                add next to queue;       //加入查詢的方向
            }
            remove the first node from queue;
        }
    }
    return -1;          // 沒有找到目標返回-1
}

佇列整體由兩個迴圈構成:外層迴圈檢視佇列是否為空(為空表示元素已經遍歷完畢),內層迴圈用於對當前節點的遍歷,以及加入新節點,這裡要注意:內層迴圈的次數size應為queue.size()賦予,而不能直接使用queue.size(),因為在內迴圈中會對佇列進行操作,從而使得佇列的長度不停變化

內層迴圈代表著一層遍歷“一層洋蔥皮”,所以在外層遍歷與內層遍歷直接需要加入步數的記錄,最後演算法結束時對應步數就是最短路徑。

Java實現

import java.util.HashMap;
import java.util.LinkedList;
import java.util.Queue;

public class BFSDemo {
    public static void main(String[] args) {
        // 構造各頂點
        LinkedList<Character> listS = new LinkedList<>();
        listS.add('w');
        listS.add('r');
        LinkedList<Character> listW = new LinkedList<>();
        listW.add('s');
        listW.add('i');
        listW.add('x');
        LinkedList<Character> listR = new LinkedList<>();
        listR.add('s');
        listR.add('v');
        LinkedList<Character> listX = new LinkedList<>();
        listX.add('w');
        listX.add('i');
        listX.add('u');
        listX.add('y');
        LinkedList<Character> listV = new LinkedList<>();
        listV.add('r');
        LinkedList<Character> listI = new LinkedList<>();
        listI.add('u');
        listI.add('x');
        listI.add('w');
        LinkedList<Character> listU = new LinkedList<>();
        listU.add('i');
        listU.add('x');
        listU.add('y');
        LinkedList<Character> listY = new LinkedList<>();
        listY.add('u');
        listY.add('x');

        // 構造圖
        HashMap<Character, LinkedList<Character>> graph = new HashMap<>();
        graph.put('s', listS);
        graph.put('w', listW);
        graph.put('r', listR);
        graph.put('x', listX);
        graph.put('v', listV);
        graph.put('i', listI);
        graph.put('y', listY);
        graph.put('u', listU);
        // 記錄每個頂點離起始點的距離,也即最短距離
        HashMap<Character, Integer> dist = new HashMap<>();
        // 遍歷的起始點
        char start = 's';
        // 呼叫廣度優先方法
        bfs(graph, dist, start);
    }

    private static void bfs(HashMap<Character, LinkedList<Character>> graph, HashMap<Character, Integer> dist, char start) {
        Queue<Character> q = new LinkedList<>();
        // 將s作為起始頂點加入佇列
        q.add(start);
        dist.put(start, 0);
        int i = 0;
        while (!q.isEmpty()) {
            // 取出隊首元素
            char top = q.poll();
            i++;
            System.out.println("The " + i + "th element:" + top + " Distance from s is:" + dist.get(top));
            // 得出其周邊還未被訪問的節點的距離
            int d = dist.get(top) + 1;
            for (Character c : graph.get(top)) {
                // 如果dist中還沒有該元素說明還沒有被訪問
                if (!dist.containsKey(c)) {
                    dist.put(c, d);
                    q.add(c);
                }
            }
        }
    }
}