[翻译]C#数据结构与算法 – 第四章 基本查找算法
第4章 基本查找算法
数据查找是一项基本的计算机编程任务而且也已被研究多年。本章仅仅研究查找问题的一个方面 – 在列表(数组)中查找给定的值。
在列表中查找数据有两种基本方式:顺序查找与二分查找。当列表中的项是随机排列时适用顺序查找;二分查找适用于已排好序的列表。
顺序查找
这种最显而易见的查找类型由一个记录集的起始部分开始,遍历每一个记录直到找到你要找的记录或着你来到记录的尾部。这就叫做顺序查找。
顺序查找(又称线性查找)很简单易行。由数组开始部分起比较每一个正访问的数组元素与你要查找的值。如果你找到匹配的元素,查找结束。如果到达数组的末尾也无法得到一个匹配,则表明数组中无此值。
顺序查找的函数如下:
1 bool SeqSearch(int[] arr, int sValue) 2 { 3 for (int index = 0; index < arr.Length - 1; index++) 4 if (arr[index] == sValue) 5 return true; 6 return false; 7 }
如果有一个匹配被找到,程序立即返回True并退出。如果到数组的尾部函数仍未返回True,则待查找的值不在数组中且函数返回False。
如下是一个测试我们顺序查找实现的程序:
1 using System; 2 using System.IO; 3 4 public class Chapter4 5 { 6 static void Main() 7 { 8 int[] numbers = new int[100]; 9 StreamReader numFile = File.OpenText("c:\\numbers.txt"); 10 for (int i = 0; i < numbers.Length - 1; i++) 11 numbers[i] = Convert.ToInt32(numFile.ReadLine(), 10); 12 int searchNumber; 13 Console.Write("Enter a number to search for: "); 14 searchNumber = Convert.ToInt32(Console.ReadLine(), 10); 15 bool found; 16 found = SeqSearch(numbers, searchNumber); 17 if (found) 18 Console.WriteLine(searchNumber + " is in the array."); 19 else 20 Console.WriteLine(searchNumber + " is not in the array."); 21 } 22 23 static bool SeqSearch(int[] arr, int sValue) 24 { 25 for (int index = 0; index < arr.Length - 1; index++) 26 if (arr[index] == sValue) 27 return true; 28 return false; 29 } 30 }
这个程序首先由一个文本文件读取一个数据集。这个数集包含100个整数,以部分随机的顺序存储于一个文件中。然后程序提示用户输入一个待查找的数字,并调用SeqSearch函数来执行查找。
你也可以编写一个这样的顺序查找函数 – 当查找到待查找的值时返回其索引,反之如果找不到返回-1。首先,我们看一下新的函数:
1 static int SeqSearch(int[] arr, int sValue) 2 { 3 for (int index = 0; index < arr.Length - 1; index++) 4 if (arr[index] == sValue) 5 return index; 6 return -1; 7 }
下面的程序使用了这个函数:
1 using System; 2 using System.IO; 3 4 public class Chapter4 5 { 6 static void Main() 7 { 8 int[] numbers = new int[100]; 9 StreamReader numFile = File.OpenText("c:\\numbers.txt"); 10 for (int i = 0; i < numbers.Length - 1; i++) 11 numbers[i] = Convert.ToInt32(numFile.ReadLine(), 10); 12 int searchNumber; 13 Console.Write("Enter a number to search for: "); 14 searchNumber = Convert.ToInt32(Console.ReadLine(), 10); 15 int foundAt; 16 foundAt = SeqSearch(numbers, searchNumber); 17 if (foundAt >= 0) 18 Console.WriteLine(searchNumber + " is in the array at position " + foundAt); 19 else 20 Console.WriteLine(searchNumber + " is not in the array."); 21 } 22 static int SeqSearch(int[] arr, int sValue) 23 { 24 for (int index = 0; index < arr.Length - 1; index++) 25 if (arr[index] == sValue) 26 return index; 27 return -1; 28 } 29 }
查找最小值与最大值
计算机程序常被要求查找一个数组(或其它数据结构)中的最小值与最大值。在一个已排序的数组中,查找这些值是很微不足道的工作。但是查找一个无序的数组最有一定的挑战。
首先让我们看看怎样找到一个数组中的最小值。算法是:
1. 将数组的第一个元素指定给一个变量作为最小值。
2. 开始遍历数组,将数组中每一个元素与这个最小值进行比较。
3. 如果当前访问的元素比最小值小,将这个元素赋给最小值。
4. 继续,直到数组的最后一个元素被访问。
5. 最小值就存储于变量中。
让我们查看一个函数,FindMin,其实现了上述算法:
1 static int FindMin(int[] arr) 2 { 3 int min = arr[0]; 4 for (int i = 1; i < arr.Length; i++) 5 if (arr[i] < min) 6 min = arr[i]; 7 return min; 8 }
(原文程序有误,已修正)
注意对数组的查找由位置1而不是位置0开始。位置0处的值在循环开始前已经赋给最小值,所以我们开始由位置1起开始比较。
查找数组中最大值的算法工作方式相同。我们将第一个数组元素赋给一个保存最大值的变量。然后我们遍历数组,比较每一个元素与变量中存储的值,如果当前访问的数组值比变量大则替换变量中的值。如下是代码:
1 static int FindMax(int[] arr) 2 { 3 int max = arr[0]; 4 for (int i = 1; i < arr.Length; i++) 5 if (arr[i] > max) 6 max = arr[i]; 7 return max; 8 }
(原文程序有误,已修正)
这两个函数的另一个版本可以返回数组中最大值与最小值的位置,而不是实际的值。
使顺序查找更快速:自组织数据
最快速的成功的顺序查找发生在被查找的数据元素位于数据集的开始部分。通过将被找到的元素移动到数据集的起始部分你可以保证成功定位的数据项都位于数据集的前部。
这个策略背后的理念是我们可以将频繁搜索的项放置于数据集的前部来最优化搜索时间。
最后,所有最常被查找的数据项都被放置于数据集的起始部分。这是自组织的一个例子,这种方式下,数据集不是由程序员在程序运行前组织,而是程序运行过程中由程序组织。
当待查找的数据大致遵循"80-20"原则,即对数据集80%的查找都是针对20%的数据时,你可以使用这种方式组织你的数据。自动数据组织最终将这20%的数据放置在数据集的前部,这时顺序搜索将可以很快的找到它们。
这样的概率分布被称作帕累托分布,命名自维尔弗雷多·帕累托,其于十九世纪晚期在研究收入与财富分布的过程中发现了这些分布关系。参见高德纳(1998, pp. 399 - 401)著作中更多关于数据集中这种概率分布的知识。
我们可以很容易的修改我们的SeqSearch方法来包含自动组织。以下是方法的第一部分:
1 static bool SeqSearch(int sValue) 2 { 3 for (int index = 0; i < arr.Length - 1; i++) 4 if (arr[index] == sValue) 5 { 6 swap(index, index - 1); 7 return true; 8 } 9 return false; 10 }
如果查找成功,将找到的元素与数组的第一个元素交换,这个操作使用如下所示的swap函数:
1 static void swap(ref int item1, ref int item2) 2 { 3 int temp = arr[item1]; 4 arr[item1] = arr[item2]; 5 arr[item2] = temp; 6 }
我们刚刚修改的这个SeqSearch方法存在一个问题,即一个频繁被访问的项目在许多次查找过程中可能被多次移动。我们要实现的目标是,将移动到数据集第一位的那个项目保持在那里并且在数据集中随后的被成功定位的项出现时不将这个位于第一位的项向后移。
有两种方法可以实现这个目标。第一,我们可以只交换那些距数据集首部很远的找到的项。我们只需要决定这个距数据集首部足够远而可以交换元素的标准。让我们再次使用"80-20"规则,我们约定只有当查找到的数据项不在数据集的前20%的项中时我们才调整它的位置到数组的前部。如下是代码的第一次改写:
1 static int SeqSearch(int sValue) 2 { 3 for (int index = 0; i < arr.Length - 1; i++) 4 { 5 if (arr[index] == sValue && index > (arr.Length * 0.2)) 6 { 7 swap(index, index - 1); 8 return index; 9 } 10 else if (arr[index] == sValue) 11 return index; 12 } 13 return -1; 14 }
代码中的if-else语句是短路式的,因为如果没有在数据集中找到项,则没有理由测试索引是否在数据集中。
另一种改写SeqSearch方法的途径是交换找到的项与数据集中在它之前的那个项。使用这种类似起泡排序中数据排序方式的方法,最终最频繁被访问的项目将到达数据集的前部。
这种技术也保证了如果一个项已经在数据集的首部,它将不会被移回到后方。
下面代码展示了新版本的SeqSearch方法:
1 static int SeqSearch(int sValue) 2 { 3 for (int index = 0; i < arr.Length - 1; i++) 4 { 5 if (arr[index] == sValue) 6 { 7 swap(index, index - 1); 8 return index; 9 } 10 } 11 return -1; 12 }
无论何时,使用以上任意一种方法都帮助你提高查找效率, 但无论出于何种原因你都必须保证待查找的数据集是无序的。下一节中,我们将讨论一种比先前提到的任何数序查找算法都高效的一种查找算法,这种算法只可用于有序的数据 – 即二叉查找(又折半查找)。
二叉搜索
当你正在查找的记录是有序的时,你可以执行一种比顺序查找更高效的查找来找到一个值。这种方法被称为二叉查找。
要理解二叉查找的工作方式,想象你正在试图猜测一个朋友选出的1到100之间的数。对于你做出的每一次猜测,朋友告诉你是否猜到了正确的数字,或者你的猜测过大,又或者过小。最佳策略是选择50作为第一个猜测目标。如果猜测过大下一个你就猜25。如果50过小你再猜75。每一次猜测,你应当选择较小的范围或者较大的范围(由你的猜测是过大或过小而定)的中间点作为下一个猜测目标。只要遵守这个策略,你将最终猜到正确的数字。图4.1展示了在选定的数字是82的情况下这个方法的工作情况。
我们可以将这个策略实现为一个算法,即二叉查找算法。要使用这个算法,我们首先要使我们的数据有序存储(升序最佳)与数组中(类似的数据结构同样工作)。算法的第一步是确定要查找的最小与最大边界。在查找开始时这个范围就是数组的边界。然后,我们计算数组的中间位置,把最小与最大边界索引值相加并除2。存储于这个位置的元素将与待查找的值比较。如果它们相同,则目标值找到算法停止。如果待查找的值比中间位置的值小,一个新的上界被计算出来 – 即现在的中间位置减1。或者,待查找的值比中间位置的值大,将中间位置加1将作为新范围的下界。算法将迭代直到下界与上界相等,这表示数据被完整查找过。如果这种情况发生,返回-1,表示数组中没有与待查找的值相等的元素。
如下是C#描述的此算法的函数:
1 static int binSearch(int value) 2 { 3 int upperBound, lowerBound, mid; 4 upperBound = arr.Length - 1; 5 lowerBound = 0; 6 while (lowerBound <= upperBound) 7 { 8 mid = (upperBound + lowerBound) / 2; 9 if (arr[mid] == value) 10 return mid; 11 else if (value < arr[mid]) 12 upperBound = mid - 1; 13 else 14 lowerBound = mid + 1; 15 } 16 return -1; 17 }
如下是使用二叉查找函数查找一个数组的程序:
1 static void Main(string[] args) 2 { 3 Random random = new Random(); 4 CArray mynums = new CArray(9); 5 for (int i = 0; i <= 9; i++) 6 mynums.Insert(random.next(100)); 7 mynums.SortArr(); 8 mynums.showArray(); 9 int position = mynums.binSearch(77, 0, 0); 10 if (position >= -1) 11 { 12 Console.WriteLine("found item"); 13 mynums.showArray(); 14 } 15 else 16 Console.WriteLine("Not in the array"); 17 Console.Read(); 18 }
递归的二叉查找算法
虽然上一节中实现的那个版本的二叉查找算法是正确的,但是其真不是问题的一个很自然的解决方案。二叉查找算法本质上是一种递归算法,因为通过持续的分割数组直到找到我们正查找的元素(或者遍历完数组空间),每一次分割都得到一个与原始问题类型相同但是更小范围的问题。以这种角度看问题将引导我们发现一个递归算法来执行一个二叉查找。
为了让递归的二叉查找算法工作,我们需要对代码做一些改动。首先我们看一下代码,然后将讨论这些改动:
1 public int RbinSearch(int value, int lower, int upper) 2 { 3 if (lower > upper) 4 return -1; 5 else 6 { 7 int mid; 8 mid = (int)(upper+lower) / 2; 9 if (value < arr[mid]) 10 RbinSearch(value, lower, mid - 1); 11 else if (value = arr[mid]) 12 return mid; 13 else 14 RbinSearch(value, mid + 1, upper); 15 } 16 }
递归的二叉查找算法比迭代版本的此算法最大的问题在于其效率较低。当一个有1000元素的数组使用这两种算法排序时,递归的算法一般要比迭代的算法慢10倍:
二叉查找时间:0
递归二叉查找时间:10.0144
当然,递归算法常由于其他原因被选择而不考虑性能,但是你应始终注意任何时候你应用一个递归算法时,你都应该同时查看一下一个迭代的解决方案,这样你可以比较这两种算法的性能。
最后在离开二叉查找这个话题之前,我们应该注意Array类有一个内置的二叉查找方法。其接受两个参数,一个数组名与一个待查找的项,并返回项在数组中的位置,或者-1如果项未被查找到。
为展示这个方法的工作,我们也为我们的演示类写了二叉查找方法的另一个版本。下面是代码:
1 public int Bsearh(int value) 2 { 3 return Array.BinarySearch(arr, value); 4 }
当将内置的二叉查找方法与我们自己构建的方法比较时,内置的方法一般要比我们的方法快10倍,无需对此吃惊。当可以以完全相同的方式使用这个方法时,系统数据结构中内置的方法或内置的算法应当优先于我们自己编写的方法被选择。
摘要
在数据集中查找一个值是一种无处不在的计算操作。最简单的查找数据集方法就是由开始部分逐一查找待查项直到找到或到达数据集的尾部。这种查找方法在数据集相对较小并且无序时工作最佳。
如果数据集是有序的,二叉查找算法是一个更好的选择。二叉查找算法持续的将数据集分割直到待查找的项被找到。你可以使用迭代与递归两种方式来编写二叉查找算法。C#中的Array类内置一个二叉查找算法,任意时候当需要调用二叉查找算法时应该使用这个内置的方法。
练习
1. 顺序查找算法将总是找到数据集中一个项的第一个匹配。创建一个新的接受第二个整型类型参数的顺序查找方法,这个新参数指示你要查找待查项的第几个匹配。
2. 编写一个顺序查找方法,查找项的最后一个匹配。
3. 在一个无序的数据集上执行二叉查找方法将会发生什么?
4. 在CArray类中使用SeqSearch方法与BinSearch方法,创建一个1000个随机整数的数组。添加一个名为compCount新的私有整型数据成员并初始化为0。在每种查找算法中,紧接着核心比较代码的后方添加一行使compCount变量增1的代码。运行这两个方法,查找相同的数字,如734。在两个方法运行结束后比较compCount的值。每种方法对应的compCount的值是多少?哪种方法的比较最少?