擴展方法之二分查找
版本:0.1
最后修改:2012-08-08
撰寫:李現民
近期項目策划案調整,要求程序按音樂時間及位置等條件迅速定位當前游戲角色正在使用的動作,因為查詢會非常頻繁,因此決定使用二分查找。
C#類庫中有二分查找,分散於Array、List、ArrayList等類中,但接口不太另人滿意。對簡單的整數數組還好,可以直接使用,但對於復雜的查詢,默認的類庫使用起來就會比較復雜,比如:
{
public string name;
public int time;
public int position;
}
class TimeComparer : IComparer<Action>
{
public int Compare(Action lhs, Action rhs)
{
if ( null == lhs)
{
return - 1;
}
else if ( null == rhs)
{
return 1;
}
return lhs.time.CompareTo(rhs.time);
}
}
class Program
{
static int BinarySearchByTime(Action[] actions, int time)
{
var testAction = new Action() { time = time };
var index = Array.BinarySearch(actions, testAction, new TimeComparer());
if (index < 0)
{
return ~index - 1;
}
return index;
}
static void Main( string[] args)
{
var actions = new Action[] {
new Action() { name = " action1 ", time = 0, position = 0 },
new Action() { name = " action2 ", time = 2, position = 60 },
new Action() { name= " action3 ", time= 4, position= 90}};
var time = 3;
var index = BinarySearchByTime(actions, time);
Console.ReadKey();
}
}
示例中給出了一個類,代表游戲中角色的動作,actions數組存儲了按先后順序在每個時間點和位置點需要播放什么動作,要求查詢當時間time=4的時刻正在播放的是什么動作。
由代碼實現來看,默認類庫至少有兩個缺點:
-
需要自己書寫對應的比較器,示例中需要按時間查詢,因此實現為TimeComparer類,如果進一步需要按位置查詢的話,則需要實現PositionComparer類,這在比較方法多樣化的情況下僅比較器的代碼量就會非常大。
-
比較器要求傳入的兩個參數類型是相同的,示例中為Action 類,因此,即使我們只需要按時間比較,也不得不構造一個臨時的testAction對象。這對我們需要實現的功能而言不應該是必須的,況且有些情況下由於Action類構造函數的限制我們可能得通過非常復雜的方法才能構造出這樣一個對象。
另外,由於BinarySearch()分散於多個類中,而代碼的使用方式又不太一至(比如Array類中的是靜態方法,而是List類中的是成員方法),在一定程度上會造成混淆。
基於以上原因,我希望能夠通過某種方式解決以上問題,使得代碼的客戶能通過簡單一致的接口調用二分查找算法。新方案的代碼實現如下:
{
if ( null == list)
{
throw new ArgumentNullException( " list is null ");
}
int count = list.Count;
int i = - 1;
int j = count;
while (i + 1 != j)
{
int mid = i + (j - i >> 1);
if (extract(list[mid]) < key)
{
i = mid;
}
else
{
j = mid;
}
}
if (j == count || extract(list[j]) != key)
{
j = ~j;
}
return j;
}
static void Main( string[] args)
{
var actions = new Action[] {
new Action() { name = " action1 ", time = 0, position = 0 },
new Action() { name = " action2 ", time = 2, position = 60 },
new Action() { name= " action3 ", time= 4, position= 90}};
var time = 3;
var index = actions.BinarySearch(time, item => item.time);
Console.ReadKey();
}
首先,新的BinarySearch()是一個擴展方法,它的擴展對象是IList<T>接口,無論是Array還是List類都實現了該接口,因此它們可以直接調用新的BinarySearch()方法。示例中actions數組直接調用了BinarySearch()方法。
其次,新的BinarySearch()方法接受一個委托作為回調方法,因此可以使用非常簡潔的方式避免構造原始方案中的比較器類。示例中提取的是item.time數據。
再者,新的BinarySearch()方法中用於比較的關鍵詞(命名為key)現在可以直接傳入了,而不再需要構造一個臨時了testAction對象了。
除此之外,新的BinarySearch()方法返回值的含義與C#內置二分查找算法返回值的含義相同:如果找到了,則返回對應的索引;如果沒找到,則返回一個小於0的索引補數,再次求補后的得到的原始索引值則是關鍵詞(命名為key)正確插入到該有序數列中時它應該在的位置。
關於算法實現的詳細描述,請參考《編程珠璣 第二版》第9.2節“主要的外科手術—二分查找”一文。
另外,還有一些實現相關的細節問題與改進方案,需要在這里提一下:
第一個問題是:為什么BinarySearch()中的key不使用泛型?這是因為代碼實現中用到了兩個操作,兩個對象之間的小於比較(<)和不等比較(!=),這對整數而言是比較相當然的,但對一般的數據類型則要求用對應的接口去實現。另外,對於浮點數float,不等比較(!=)是需要按比較精度進行處理的,因此也需要單寫。出於簡單與速度的考慮,這里只使用了int類型。最后,int與float其實應該能覆蓋大部分情況了,對吧?
第二個問題是:BinarySearch()擴展的是IList<T>,但對Array而言,從速度上有優化的余地。前文提到,Array實現了IList<T>,這沒錯,但是,如果通過IList<T>接口調用數組的list.Count與list[mid]的話,實際調用的是類實例的方法,分別是:
callvirt instance int32 class [mscorlib]System.Collections.Generic.ICollection`1<!!T>::get_Count()
callvirt instance !0 class [mscorlib]System.Collections.Generic.IList`1<!!T>::get_Item(int32)
對數組來說,其實是有對應的匯編指令完成這兩個操作的,因此,如果對速度有需求的話,為數組單獨編寫對應的BinarySearch()是有回報的。