src/hungarian.lisp @ 4b54adfbaf3d default tip

More refactoring
author Steve Losh <steve@stevelosh.com>
date Tue, 24 Dec 2019 13:50:19 -0500
parents 89b641f412c6
children (none)
(in-package :euler.hungarian)

;;;; Hungarian/Munkres Algorithm ----------------------------------------------
;;; This is an implementation of the Hungarian algorithm for finding a minimal
;;; assignment for a cost matrix in polynomial (O(n³)) time.
;;;
;;; Useful references:
;;;
;;; * http://www.netlib.org/utk/lsi/pcwLSI/text/node222.html
;;; * http://csclab.murraystate.edu/~bob.pilgrim/445/munkres.html


;;;; Data ---------------------------------------------------------------------
(defstruct (assignment-problem (:conc-name ap-)
                               (:constructor make-assignment-problem%))
  original-matrix
  cost-matrix
  rows
  cols
  starred-rows
  starred-cols
  covered-rows
  covered-cols
  primed-rows
  primed-cols)

(define-with-macro (assignment-problem :conc-name ap-)
  original-matrix
  cost-matrix
  rows
  cols
  starred-rows
  starred-cols
  covered-rows
  covered-cols
  primed-rows
  primed-cols)

(defun make-assignment-problem (matrix)
  (destructuring-bind (rows cols) (array-dimensions matrix)
    (make-assignment-problem%
      :original-matrix matrix
      :cost-matrix (copy-array matrix)
      :rows rows
      :cols cols
      :starred-rows (make-array rows :initial-element nil)
      :starred-cols (make-array cols :initial-element nil)
      :covered-rows (make-array rows :initial-element nil)
      :covered-cols (make-array cols :initial-element nil)
      :primed-rows (make-array rows :initial-element nil)
      :primed-cols (make-array cols :initial-element nil))))


;;;; Debug --------------------------------------------------------------------
(defun dump (data)
  (with-assignment-problem (data)
    (format t "   ~{   ~A ~^ ~}~%"
            (iterate (for col :below cols)
                     (collect (if (col-covered-p data col) #\X #\space))))
    (dotimes (row rows)
      (format t "~A [~{~4D~A~A~^ ~}]~%"
              (if (row-covered-p data row) #\X #\space)
              (iterate (for col :below cols)
                       (collect (aref cost-matrix row col))
                       (collect
                         (if (starredp data row col)
                           #\*
                           #\space))
                       (collect
                         (if (primedp data row col)
                           #\'
                           #\space))))))
  (pr))


;;;; Marking ------------------------------------------------------------------
(defun mark (row col row-vector col-vector)
  (setf (aref row-vector row) col
        (aref col-vector col) row))

(defun unmark (row col row-vector col-vector)
  ;; This is a bit fucky.
  ;;
  ;; Intuitively you would think that there should be at most one starred entry
  ;; in each row/col, and at the end of each step this is true.  But when we're
  ;; processing the alternating star/prime list, we start with the prime and
  ;; star it before moving to the next entry in the list, which is what removes
  ;; the existing star.
  ;;
  ;; If we just blindly nil out both vectors we might overwrite the coordinate
  ;; that was just starred.  The solution is to make sure we're only niling out
  ;; data for the actual thing we're unmarking.
  ;;
  ;; Another (possibly cleaner?) solution would be to reverse the alternating
  ;; prime/star list.  That was we'd unstar the existing zeros before starring
  ;; their primed twin, and everything would work nicely.
  (when (eql (aref row-vector row) col)
    (setf  (aref row-vector row) nil))
  (when (eql (aref col-vector col) row)
    (setf (aref col-vector col) nil)))


;;;; Starring -----------------------------------------------------------------
(defun star (data row col)
  (with-assignment-problem (data)
    (mark row col starred-rows starred-cols)))

(defun unstar (data row col)
  (with-assignment-problem (data)
    (unmark row col starred-rows starred-cols)))

(defun row-starred-p (data row)
  (aref (ap-starred-rows data) row))

(defun col-starred-p (data col)
  (aref (ap-starred-cols data) col))

(defun starred-col-for-row (data row)
  (aref (ap-starred-rows data) row))

(defun starred-row-for-col (data col)
  (aref (ap-starred-cols data) col))

(defun starred-list (data)
  (with-assignment-problem (data)
    (gathering
      (dotimes (row rows)
        (gather (cons row (starred-col-for-row data row)))))))

(defun starredp (data row col)
  (eql (starred-col-for-row data row) col))


;;;; Priming ------------------------------------------------------------------
(defun prime (data row col)
  (with-assignment-problem (data)
    (mark row col primed-rows primed-cols)))

(defun unprime (data row col)
  (with-assignment-problem (data)
    (unmark row col primed-rows primed-cols)))

(defun unprime-all (data)
  (fill (ap-primed-rows data) nil)
  (fill (ap-primed-cols data) nil))

(defun primed-col-for-row (data row)
  (aref (ap-primed-rows data) row))

(defun primed-row-for-col (data col)
  (aref (ap-primed-cols data) col))

(defun primedp (data row col)
  (eql (primed-col-for-row data row) col))


;;;; Covering -----------------------------------------------------------------
(defun cover-row (data row)
  (setf (aref (ap-covered-rows data) row) t))

(defun cover-col (data col)
  (setf (aref (ap-covered-cols data) col) t))

(defun uncover-row (data row)
  (setf (aref (ap-covered-rows data) row) nil))

(defun uncover-col (data col)
  (setf (aref (ap-covered-cols data) col) nil))

(defun row-covered-p (data row)
  (aref (ap-covered-rows data) row))

(defun col-covered-p (data col)
  (aref (ap-covered-cols data) col))

(defun uncover-all (data)
  (fill (ap-covered-rows data) nil)
  (fill (ap-covered-cols data) nil))


(defmacro-driver (FOR var INDEXES-OF element IN vector)
  (let ((kwd (if generate 'generate 'for)))
    (with-gensyms (vec el)
      `(progn
         (with ,vec = ,vector)
         (with ,el = ,element)
         (,kwd ,var :next
          (or (position ,el ,vec :start (if-first-time 0 (1+ ,var)))
              (terminate)))))))

(defmacro-driver (FOR var IN-UNCOVERED-ROWS assignment-problem)
  (let ((kwd (if generate 'generate 'for)))
    `(,kwd ,var :indexes-of nil :in (ap-covered-rows ,assignment-problem))))

(defmacro-driver (FOR var IN-UNCOVERED-COLS assignment-problem)
  (let ((kwd (if generate 'generate 'for)))
    `(,kwd ,var :indexes-of nil :in (ap-covered-cols ,assignment-problem))))

(defmacro-driver (FOR var IN-COVERED-ROWS assignment-problem)
  (let ((kwd (if generate 'generate 'for)))
    `(,kwd ,var :indexes-of t :in (ap-covered-rows ,assignment-problem))))

(defmacro-driver (FOR var IN-COVERED-COLS assignment-problem)
  (let ((kwd (if generate 'generate 'for)))
    `(,kwd ,var :indexes-of t :in (ap-covered-cols ,assignment-problem))))


(defun all-rows-covered-p (data)
  (every #'identity (ap-covered-rows data)))

(defun all-cols-covered-p (data)
  (every #'identity (ap-covered-cols data)))


;;;; Incrementing -------------------------------------------------------------
(defun incf-row (data row i)
  (with-assignment-problem (data)
    (dotimes (col cols)
      (incf (aref cost-matrix row col) i))))

(defun incf-col (data col i)
  (with-assignment-problem (data)
    (dotimes (row rows)
      (incf (aref cost-matrix row col) i))))

(defun decf-row (data row i)
  (incf-row data row (- i)))

(defun decf-col (data col i)
  (incf-col data col (- i)))


;;;; Step 1: Initialization ---------------------------------------------------
(defun subtract-smallest-element-in-col (data col)
  (decf-col data col (iterate
                       (for row :below (ap-rows data))
                       (minimizing (aref (ap-cost-matrix data) row col)))))


(defun initial-subtraction (data)
  ;; The first step is to subtract the smallest item in each column from all
  ;; entries in the column.
  (with-assignment-problem (data)
    (dotimes (col cols)
      (subtract-smallest-element-in-col data col))))

(defun initial-zero-starring (data)
  ;; Find a zero Z in the distance matrix.
  ;;
  ;; If there is no starred zero already in its row or column, star this zero.
  ;;
  ;; Repeat steps 1.1, 1.2 until all zeros have been considered.
  (with-assignment-problem (data)
    (iterate
      ;; This could be faster if we split the iteration and bailed after marking
      ;; the first thing in a row, but meh, it's cleaner this way.
      (for (value row col) :in-array cost-matrix)
      (when (and (zerop value)
                 (not (row-starred-p data row))
                 (not (col-starred-p data col)))
        (star data row col)))))


(defun step-1 (data)
  (initial-subtraction data)
  (initial-zero-starring data)
  (step-2 data))


;;;; Step 2: Z* Count and Solution Assessment ---------------------------------
(defun cover-all-starred-columns (data)
  (with-assignment-problem (data)
    (dotimes (col cols)
      (when (col-starred-p data col)
        (cover-col data col)))))


(defun step-2 (data)
  ;; Cover every column containing a Z*.
  ;;
  ;; Terminate the algorithm if all columns are covered. In this case, the
  ;; locations of the  entries in the matrix provide the solution to the
  ;; assignment problem.
  (cover-all-starred-columns data)
  (if (all-cols-covered-p data)
    (report-solution data)
    (step-3 data)))



;;;; Step 3: Main Zero Search -------------------------------------------------
(defun find-uncovered-zero (data)
  (iterate (for row :in-uncovered-rows data)
           (iterate (for col :in-uncovered-cols data)
                    (when (zerop (aref (ap-cost-matrix data) row col))
                      (return-from find-uncovered-zero (values t row col)))))
  (values nil nil nil))

(defun step-3 (data)
  ;; Find an uncovered Z in the distance matrix and prime it, Z -> Z'. If no
  ;; such zero exists, go to Step 5.
  (multiple-value-bind (found row col) (find-uncovered-zero data)
    (if (not found)
      (step-5 data)
      (progn
        (prime data row col)
        (let ((starred-col (starred-col-for-row data row)))
          (if (not starred-col)
            ;; If No Z* exists in the row of the Z', go to Step 4.
            (step-4 data row col)
            ;; If a Z* exists, cover this row and uncover the column of the Z*.
            ;; Return to Step 3.1 to find a new Z.
            (progn (cover-row data row)
                   (uncover-col data starred-col)
                   (step-3 data))))))))


;;;; Step 4: Increment Set of Starred Zeros -----------------------------------
(defun construct-zero-sequence (data initial-row initial-col)
  (gathering
    (labels
        ((find-next-starred (prime-col)
           ;; The Z* in the same column as the given Z', if one exists.
           (let ((star-row (starred-row-for-col data prime-col)))
             (if (null star-row)
               (values nil nil)
               (values star-row prime-col))))
         (find-next-primed (star-row)
           ;; The Z' in the same row as the given Z* (there will always be one).
           (values star-row (primed-col-for-row data star-row)))
         (mark-starred (row col)
           (when row
             (gather (cons row col))
             (multiple-value-call #'mark-primed (find-next-primed row))))
         (mark-primed (row col)
           (gather (cons row col))
           (multiple-value-call #'mark-starred (find-next-starred col))))
      (mark-primed initial-row initial-col))))

(defun process-zero-sequence (data zeros)
  ;; Unstar each starred zero of the sequence.
  ;;
  ;; Star each primed zero of the sequence, thus increasing the number of
  ;; starred zeros by one.
  (iterate (for (row . col) :in zeros)
           (for primed? :first t :then (not primed?))
           (if primed?
             (star data row col)
             (unstar data row col))))

(defun step-4 (data row col)
  (process-zero-sequence data (construct-zero-sequence data row col))
  (unprime-all data)
  (uncover-all data)
  (step-2 data))


;;;; Step 5: New Zero Manufactures --------------------------------------------
(defun find-smallest-uncovered-entry (data)
  (iterate
    main
    (for row :in-uncovered-rows data)
    (iterate (for col :in-uncovered-cols data)
             (in main (minimizing (aref (ap-cost-matrix data) row col))))))

(defun incf-covered-rows (data i)
  (iterate (for row :in-covered-rows data)
           (incf-row data row i)))

(defun decf-uncovered-cols (data i)
  (iterate (for col :in-uncovered-cols data)
           (decf-col data col i)))


(defun step-5 (data)
  ;; Let h be the smallest uncovered entry in the (modified) distance matrix.
  ;;
  ;; Add h to all covered rows.
  ;;
  ;; Subtract h from all uncovered columns
  ;;
  ;; Return to Step 3, without altering stars, primes, or covers.
  (let ((i (find-smallest-uncovered-entry data)))
    (incf-covered-rows data i)
    (decf-uncovered-cols data i)
    (step-3 data)))


;;;; Reporting Solution -------------------------------------------------------
(defun report-solution (data)
  (starred-list data))


;;;; API ----------------------------------------------------------------------
(defun find-minimal-assignment (matrix)
  (step-1 (make-assignment-problem matrix)))


;;;; Scratch ------------------------------------------------------------------
;; (untrace)