總結排序(或部分排序)矩陣上的搜索問題

在排好序(或者部分排好序)的矩陣上進行搜索是考察多維數組操作和查找的一種經典面試題類型。這裏假設行數和列數分別是M和N。下面根據難度來總結一下幾個不同的題目變體:


1. LeetCode原題 Search a 2D Matrix:

Write an efficient algorithm that searches for a value in an m x n matrix. This matrix has the following properties:

  • Integers in each row are sorted from left to right.
  • The first integer of each row is greater than the last integer of the previous row.

For example,

Consider the following matrix:

[
  [1,   3,  5,  7],
  [10, 11, 16, 20],
  [23, 30, 34, 50]
]

Given target = 3, return true.

這題非常的基礎,特別簡單,因爲題目對排序的要求特別的嚴格,整個矩陣按行完全排好序。利用排序性質,二分搜索的思路非常清晰。


思路1:首先二分搜索確定行號,然後再二分搜索確定列號。

	public boolean Find(int[][] matrix, int elem) {
		// binary search the row number
		int low, high, mid = 0;
		low = 0;
		high = matrix.length - 1;

		while (low <= high) {
			mid = low + (high - low) / 2;
			if (matrix[mid][0] == elem) {
				return true;
			} else if (matrix[mid][0] > elem) {
				high = mid - 1;
			} else {
				low = mid + 1;
			}
		}

		// make sure the row starts with a number less than the target element
		if (matrix[mid][0] > elem) {
			mid--;
		}
		int row = mid;

		// binary search the column number
		low = 0;
		high = matrix[0].length - 1;
		while (low < high) {
			mid = low + (high - low) / 2;
			if (matrix[row][mid] == elem) {
				return true;
			} else if (matrix[row][mid] > elem) {
				high = mid - 1;
			} else {
				low = mid + 1;
			}
		}

		return false;
	}

因爲使用了兩次二分搜索,時間複雜度爲O(logM + logN)。


思路2:由於整個矩陣完全排好序,其實可以直接把這個二維數組看成一個M*N大小的一維數組,這樣一次二分搜索就可以了。代碼更加簡潔。

	public boolean find(int[][] matrix, int target) {
		int numRows = matrix.length;
		if (numRows == 0) return false;
		int numCols = matrix[0].length;
		int low = 0, high = numRows * numCols - 1;
		int mid, col, row;

		while (low <= high) {
			mid = low + (high - low) / 2;
			col = mid % numCols;
			row = mid / numCols;

			if (matrix[row][col] == target) {
				return true;
			} else if (matrix[row][col] > target) {
				high = mid - 1;
			} else {
				low = mid + 1;		
			}
		}

		return false;
	}

時間複雜度是log(M * N) = logM + logN。所以效率和思路1完全一樣。


2. CareerCup原題:

Given a matrix in which each row and each column is sorted, write a method to find an element in it.


注意這題和第一題的區別在於:儘管矩陣內的每行和每列都排好序了,但是整個矩陣並不是完全按行排序的。一個簡單例子就是在矩陣{ { 0, 1, 4 }, { 1, 2, 5 }, { 2, 3, 6 } }中搜索4。如果直接使用上一題的思路,會導致找不到,因爲前面行的後面元素允許大於後面行的前面元素。所以,如果想提高搜索效率,需要首先找到矩陣內完全排序的子區域。那麼完全排好序的子區域是什麼呢?


因爲每行排好序,每列也排好序,對於任意一個元素,元素所在行的左邊元素肯定都小於自己,而且元素所在列的下邊元素也肯定都小於自己。由此可知,對於任意一個元素,所在的完全排序子區域就是該元素同行左邊+該元素自身+該元素同列下邊


1. 思路1:(CareerCup給出的標準解法)

沿着對角線進行線性查詢:因爲每行和每列都排好序,以矩陣右上角爲起點,沿着對角線掃描。

1)如果該元素等於目標元素,則找到目標;

2)如果該元素大於目標元素,則可以排除當前列,座標往左移1個單位;

3)如果該元素小於目標元素,則可以排除當前行,座標往下移1個單位。


	public boolean find(int[][] matrix, int elem) {
		// start from the top right element
		int row = 0, col = matrix[0].length - 1;
		while (row < matrix.length && col >= 0) {
			if (matrix[row][col] == elem) {
				return true;
			} else if (matrix[row][col] > elem) {
				col--;
			} else {
				row++;
			}
		}

		return false;
	}

注意我看的CareerCup版本里給出的標答有寫小疏漏,把循環條件裏的col >= 0寫成了col > 0,這樣的話,在矩陣{ { 0, 1, 3 }, { 1, 3, 5 }, { 4, 5, 6 } }中查找4是會找不到的,因爲4作爲行首元素會被漏掉。

這個思路沒有利用二分搜索,而是沿着對角線不斷對搜索區域根據部分排序性質進行排除,時間複雜度爲O(M+N)。


思路2:(LeetCode討論版塊給出的答案)

既然只有部分的排序,那麼也可以只進行部分的二分搜索,從而試圖進一步優化。

	// binary search in a given row, starting from the first column and ending
	// with a given ending column index (inclusive)
	private int binarySearchInRow(int[][] matrix, int row, int endCol,
			int elem) {
		int low = 0, high = endCol, mid = 0;
		while (low <= high) {
			mid = low + (high - low) / 2;
			if (matrix[row][mid] == elem) {
				return mid;
			} else if (matrix[row][mid] > elem) {
				high = mid - 1;
			} else {
				low = mid + 1;
			}
		}

		// make sure the matrix[row][mid] value is greater than the target
		// element
		if (matrix[row][mid] < elem && mid < endCol) {
			mid++;
		}

		// differentiate from the case when the target element is the first element in the row
		if (mid == 0) {
			return Integer.MAX_VALUE * -1;
		}

		return -1 * mid;
	}

	// binary search along diagonal: complexity O(M * logN)
	public boolean find(int[][] matrix, int elem) {
		// start from the top right element
		int row = 0, col = matrix[0].length - 1;
		// binary search via the diagonal
		while (row < matrix.length && col >= 0) {
			if (matrix[row][col] == elem) {
				return true;
			} else if (matrix[row][col] > elem) {
				// binary search the current row ahead of the current column
				int retCol = binarySearchInRow(matrix, row, col, elem);
				if (retCol >= 0) {
					return true;
				} else {
					col = retCol * -1;
					if (col == Integer.MAX_VALUE)
						return false;
					row++;
				}
			} else {
				row++;
			}
		}

		return false;
	}
與第一個思路相比,唯一的區別在於循環內的第二個分支使用了二分搜索,如果在當前行內沒有找到目標元素,就會尋找當前行內剛好大於目標元素的數,並且返回該座標值的相反數。返回相反數是爲了區別找到和沒找到兩種情況,找到的話返回座標值,爲非負數,沒找到返回座標值的相反數,爲負數。注意如果當前行的第一個元素都大於目標元素,則說明繼續下去也無法找到,我這時返回了一個整數最大值的相反數。當然,最好的做法是返回座標值和是否找到兩個值,需要傳引用,不過用Java寫不是太方便(除非申明成員變量)。


簡單的說,如果在當前沒有找到,可以一次性排除當前行和多列,而思路1一次只能排除一列


這個思路看似得到了優化,其實反而增加的時間複雜度。根據LeetCode版主的分析(http://leetcode.com/2010/10/searching-2d-sorted-matrix.html),如果M=N,那麼這題的時間複雜度是O(logN!)。其實這個值近似於O(M*logN)。最壞情況是每行都做了一次二分搜索,但每次都返回的是搜索當前行的行尾元素。這樣看來,這個算法本質上和對每行進行一次二分搜索沒太大區別。


注意,在此基礎上對循環內的第三個分支進行二分搜索優化是不可行的,否則跟第一題的解法沒什麼區別了。


3. Google面試題:

Given a M * N Matrix. All rows are sorted, and all columns are sorted. Find the Kth smallest element of the matrix.


這題有種錯誤做法是以第一個元素爲起點(因爲必定爲最小元素),構造一個最小堆,每次將從第一行第i個元素到第一列第i個元素這條線上的所有元素,插入到堆中。直到堆的大小大於或等於K。然後再做K-1次刪除最小元素操作,結果就爲最小當前元素。

這樣的做法是錯誤的,因爲任意一行的所有元素都可以比下一行的第一個元素小,很簡單的例子就是{ {1, 2, 3}, {4, 5, 6} },如果K=3,那麼結果應該返回3而不是4。


思路1:利用QuickSelect算法

經典的QuickSelect算法(http://en.wikipedia.org/wiki/Quickselect youtube.com/watch?v=kcVk30zzAmU)可以在線性時間內找到一維數組內的第K個元素。如果這裏忽略已有的排序性質,把這個矩陣當成一個亂序的1維數組,那麼直接利用QuickSelect算法可以得到O(M*N)的解。


思路2:利用部分排序的性質和最小堆

如果使用最小堆,如何使得插入的次數最少呢?換句話說,我們應該只插入需要插入的元素,或者說”剛好“只比當前元素大一點的元素。按照這個思路,我們仍然應該以第一個元素作爲起點。


那麼對於任意一個元素,“剛好”比它大一點的元素有哪些呢?

1.右邊鄰居;

2.下邊鄰居;

3.下邊鄰居的某些左側元素。

前兩種情況比較直觀,第三種情況就只有靠標記了。具體做法如下:


初始化將第一個元素放入最小堆中。

1)每次彈出當前堆中的最小值,並將其右鄰居和下鄰居插入堆中,並標額外記右鄰居和下鄰居已經在堆中。如果之前某鄰居已經被標記,則不再處理;(注意下鄰居很可能之前是某元素的右鄰居,要避免重複處理)

2)如果彈出的元素總數爲K,則停止。

按照這個思路,處理K個元素最多也就將2K個元素插入堆中,堆的大小不超過2K,,所以算法複雜度是O(K*logK)。


思路3:O(M+N)算法

http://www.cse.yorku.ca/~andy/pubs/X+Y.pdf


思路4:Frederickson和Johnson實現的O(K)算法

Greg N. Frederickson and Donald B. Johnson. Generalized Selection and Ranking: Sorted Matrices. SIAM J. Comput. 13, pp. 14-30. http://epubs.siam.org/sicomp/resource/1/smjcat/v13/i1/p14_s1?isAuthorized=no

算法選擇需要看實際情況,取決於K和M,N之間的大小關係。後兩種思路太複雜,我覺得前兩種思路作爲面試題的解法足夠了。



發佈了60 篇原創文章 · 獲贊 8 · 訪問量 24萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章