1. 程式人生 > >Scheme來實現八皇后問題(2)

Scheme來實現八皇后問題(2)

  版權申明:本文為博主窗戶(Colin Cai)原創,歡迎轉帖。如要轉貼,必須註明原文網址

  http://www.cnblogs.com/Colin-Cai/p/9790466.html 

  作者:窗戶

  QQ/微信:6679072

  E-mail:[email protected] 

  上一章講了用1~n的排序來表示n皇后的解,然後通過列舉1~n所有的排列、判定謂詞過濾所有排列得到最終的所有解。

  在此基礎上,這一章我們思考是否存在更好的解法,從而深化這個問題。

  效率問題

  為了測試效率,在程式碼末尾加上

  (queen (read))

  表示解決的皇后個數由輸入的結果決定。

  還是先把Scheme程式碼編譯、連結為普通可執行檔案,這樣執行就不是在解釋的條件下了,速度可以提升數倍。

  八皇后使用這個程式出來結果還算可以接受,但是當我想解決10皇后問題的時候,卻花了半分鐘,而如果是解決11皇后問題,我等待了好幾分鐘,系統把程序殺了。

  我們可以意識到,程式的效率似乎並不是那麼高。那麼有沒有提升的辦法呢?

  想要找到提升的辦法,我們先要分析之前的演算法慢的原因。

  這個演算法中,記憶體使用姑且就不說了(其實儲存所有的排列需要很大的記憶體),我們要產生所有的排列,然後每一個排列都要單獨判定是否符合條件。

  然而,我們想一想,我們真的需要每個排列都獨立檢查一遍嗎?

  實際上,我們可能真的不需要如此。我們其實只需要檢測到一個片段上存在皇后互吃,那麼包含這個片段的所有排列自然都不可能是解,可以直接被排除。

  比如我們檢測到如果一個排列以1、2開頭的話,那麼這兩個點距離為1,值也相差1,兩個皇后互吃,從而就可以知道,所有以1、2開頭的排列都不需要檢測了。

  也就是所有的(1 2 ...)排列直接因為我檢測到(1 2)不能作為解而直接排除。

  這樣從片段來對所有排列剪枝,當然比挨個檢測效率高。

  字典順序

  我們要考慮一個字典順序的檢測。

  字典順序就是按照英文字典那樣,單詞出現的順序是按字串的大小順序。

  

  字串的大小比較,大家應該都很熟。

  兩個字串從頭逐位比較,過程中,對應位的字元相等則繼續比較,直到過程中一個字串先到尾部或者字元上分出大小,先到尾部或者對應位上的字元小的一方的字串較小,另一個字串則較大。如果兩個字元串同時都檢測到了尾部,那麼兩個字串當然一模一樣,則為相等。

  C語言中字串比較可以用strcmp函式,而Scheme裡字串比較可以用 string=?  string>? 等函式。

  但是,我們這裡是要對於所有排列按照字典順序檢測,可是這裡每個排列是一個數字組成的list,那麼我們可以按照數字的大小來替代之前字串比較時的字元大小,就可以做到字典序列了。

  比如1~3的全排列一共有6個排列,按照字典順序從小到大本應該如下:

  (1 2 3)

  (1 3 2)

  (2 1 3)

  (2 3 1)

  (3 1 2)

  (3 2 1)

  但考慮到列表的特殊結構,我們判斷兩個排列的大小從最後一位開始看的話(也就是列表反過來看),在這裡因為一路可以使用cons/car/cdr而不是append/take/drop之類相對複雜的遞迴,從而要方便很多,效率也要高,於是上述1~3的全排列按照字典順序會如下:

  (3 2 1)

  (2 3 1)

  (3 1 2)

  (1 3 2)

  (2 1 3)

  (1 2 3)

  實際的例子

  我們需要實際來看看按照字典序列並加上之前的剪枝思路如何完成完整的解答。

  在這裡,我們可以用4皇后來作為i例子。

  我們試圖要用迭代完成我們的檢測,用當前檢測的列表和已有的解合成迭代的狀態

  最開始的時候,我們要檢測的list是空列,而解初始也為一個空列

  目前                                    說明

  ()                         ()                 初始

  (1)       ()                 檢測合法   

  (2 1)                    ()     不合法,所以以(2 1)結尾的排列都不合法

  (1)                       ()                因為上面不合法,所以2被退掉

  (3 1)                    ()                填入比剛才退掉的2大的數中最小的

      ...

  這裡產生了一個問題,上面紅字標註的兩行,除了說明之外,其他一模一樣,無法區分。而一個轉化成(2 1)()狀態,另外一個轉化為(3 1)()狀態,相同的狀態轉換成了不同的狀態,對於迭代,

  這個完全沒道理

  說明狀態還不完善,我們剛才兩次從(1)()狀態出發,之所以第一次轉化到了(2 1)()狀態,而第二次轉化到了(3 1)()狀態,原因就在於其實都是在(1)的基礎上,找了原則上最小的值。從而,我們可以在狀態中再加一個限值,標誌著列表下一個新增的元素的選取必須高於這個值。

  這樣狀態才是完備的。

  於是上面4皇后問題狀態的轉換可以如下:

  目前            限值                                說明

  ()                  0                     ()                 初始

  (1)      0                  ()                 (1)合法,升位   

  (1)                2                     ()      (2 1)非法 (滿足的最小的數是2)

  (3 1)             0                     ()                (3 1)合法,升位

  (3 1)             2                     ()                (2 3 1)非法

  (3 1)             4                     ()                (4 3 1)非法

  (1)                3                     ()                不存在值,降位

  (4 1)             0                     ()                (4 1)合法,升位

  (2 4 1)          0                     ()                (2 4 1)合法,升位

  (2 4 1)          3                     ()                (3 2 4 1)非法

  (4 1)             2                     ()                不存在值,降位

  (4 1)             3                     ()                 (3 4 1)非法

  (1)                4                     ()                 不存在值,降位

  ()                  1                     ()                 不存在值,降位

  (2)                0                     ()                  (2)合法,升位

  ...

  (3 1 4 2)       0                     ()                  (3 1 4 2)合法,升位

  (1 4 2)          3                     ((3 1 4 2))     加入解,降位

  ...

  (4)                3        ((2 4 1 3) (3 1 4 2))    不存在值,降位

  ()                  4        ((2 4 1 3) (3 1 4 2))    不存在值且無法降位,結束

  狀態轉移

  現在,我們定下狀態為 目前限值

  我們來分析關於狀態的一切:

  初始時,狀態為 ()0()。

  結束時,一定是因為目前的是空列,而且限值已經達到了n皇后的n。

  當目前的列表包含了1~n的數時(其實就是長度為n),那麼找到了一個解,把這個列表加入到解,然後降位,也就是目前的列表把最前面的一位去掉,然後限值設為最前面的這一位。

  其他情況下,找剩餘的數中大於限制的最小數:

  (1)如果不存在,則降位

  (2)如果存在,假如這個值加到目前的列表前得到的新表是合法的,那麼升位,新列表作為目前列表,限值設為0即可。

  (3)如果存在,假如這個值加到目前的列表前得到的新表是非法的,那麼限制調整為剛才找到的最小數。

  迭代和封裝

  按上述規則,迭代程式碼如下:

 (define (_queen n current gt result)
  (cond
   ((and (null? current) (>= gt n)) result);終止條件
   ((= n (length current)) (_queen n (cdr current) (car current) (cons current result)));記下解
   (else
    (let (
      (remained (filter (lambda (x) (> x gt )) (remove* current (range 1 (+ n 1)))) );剩下的數中哪些是大於下限的
     )
     (if (null? remained)
      (_queen n (cdr current) (car current) result)
      (let ((next (car remained)))
       (if (valid? current next)
        (_queen n current next result)
        (_queen n (cons next current) 0 result))))))))

  其中,remove*是racket裡的函式,用於集合相減,並且不改順序,但它並不屬於Scheme標準。此處略去實現。

  比如(remove* '(2 3 4) '(1 2 4 5 6))返回'(1 5 6)。

  當然,不忘封裝一下:

(define (queen n) (_queen n '() 0 '()))

  合法性檢測

  檢測所用的valid?函式並未實現。

  在這個演算法中,如果一個序列是非法的,也就是存在皇后互吃的,一定是最新的元帶來的。因為如果判斷到這一步,那麼之前的子序列一定是合法的。

  這是一個遞迴的思路,也是比上一章使用迭代來檢測一個排列所有的可能更加快速的地方之一。

  比如要檢測'(2 5 3 1)是不是合法,在檢測前,我們無條件知道(cdr '(2 5 3 1))也就是'(5 3 1)是合法的。

  那麼合法性判斷就成了看最左邊的2和後面的3個元素是否有差值的絕對值等於距離,一種比較好的做法是:

  先將'(5 3 1)的每個元素減去新加的2,得到'(3 1 -1),再取絕對值得到'(3 1 1),再和'(1 2 3)比較,看看是否在相同位置有相同元素。

  判斷兩個列表存在不存在相同位置有相同元素,用個遞迴很容易寫:

(define (same_ele_pos? x y)
 (cond
  ((null? x) #f)
  ((= (car x) (car y)) #t)
  (else (same_ele_pos? (cdr x) (cdr y)))
 )
)

  於是合法性檢測就可以寫成如下:

(define (valid? lst new)
 (same_ele_pos?
  (range 1 (+ 1 (length lst)))
  (map (lambda (x) (abs (- x new))) lst)
 )
)

  上面檢查'(2 5 4 1)是否合法,就可以通過(valid? '(5 4 1) 2)來檢測。

  測試

  把上述程式碼後面加(queen 10)解決10皇后問題,編譯之後,我們發現執行時間連1秒都不需要。

  而如果要求12皇后問題需要20秒。實際上,我們還可以在狀態中引入一些別的東西以提高速度,從而使得執行時間變成現在的幾分之一,但這已經不是我想在這裡講的了。

  演算法這東西,很多時候很難做到極致,所以工程中有一個方向就是隨著時間推移,軟體版本在提升演算法執行速度,比如PCB/FPGA的佈線。工程師們依然為之不斷努力。