[翻译]C#数据结构与算法 – 第一章(完)

时间测试

因为这本书采用了一种实践的方法进行对要研究数据结构及算法进行分析,我们避开使用大O分析,宁愿使用运行简单的基准测试的方法来代替。这个测试将告诉我们运行一个代码段花费了多少秒(或其它时间单位),我们的基准测试时时间测试,衡量运行完成一个算法所花费的时间。基准测试是一门科学更是一门艺术而且你必须小心你对一段代码计时的方式以获得一个正确的分析结果。让我们更详细的了解一下。

一个太简单化的时间测试

首先,我们需要一些代码来计时。为了简单的说明问题,我们先对一个将一个数组的内容输出到控制台的子程序来计时。下面是代码:

1 static void DisplayNums(int[] arr)
2 {
3     for (int i = 0; i <= arr.GetUpperBound(0); i++)
4         Console.Write(arr[i] + " ");
5 }

这个数组在程序的另一部分初始化,这个我们将在稍后研究。要对这个子函数计时,我们需要创建一个变量,并把子函数被调用时的系统时间赋给这个变量,另外我们需要存储一个子函数返回时的时间。如下是我们实现这些功能的方法:

1 DateTime startTime;
2 TimeSpan endTime;
3 startTime = DateTime.Now;
4 endTime = DateTime.Now.Subtract(startTime);

在我的笔记本电脑(1.4mHz运行Windows XP专业版)上运行这段代码,这个子程序运行了大约5秒钟(4.9917秒)。虽然看起来这段代码适合执行一个耗时测试,但对于计算.Net环境下代码的运行是完全不够的。为什么呢?

首先,这段代码测试了由子函数被调用直到子函数返回到主程序的耗时。与C#程序同时运行的其它进程占用的时间也记到了时间测试的结果中。

其次,这些计时代码没有考虑.Net环境执行垃圾回收的时间。在如.Net这种运行环境中,系统可能在任何时间暂停来执行垃圾回收。这种简单的代码没有做任何处理来承认垃圾回收的存在且计时结果很容易受到垃圾回收的影响。所以我们应该怎样处理这种问题呢?

.Net环境下的耗时测试

在.Net环境中,我们需要考虑我们的程序所运行的线程及任何时候垃圾回收都可能发生的事实。我们需要在设计我们的计时代码时将这些因素考虑在内。

让我们先来看一下怎样控制垃圾回收。首先,我们讨论一下垃圾回收的作用。在C#中,引用类型(如字符串,数组及类的实例对象)的内存空间是在被称作堆的空间中分配的。堆是一片用来保存数据项(之前提到的类型)的内存空间。值类型,如普通的变量,被存储在栈上。到引用类型数据的引用也是存储在栈上,但是引用类型中实际存储的数据被保存在栈中。

存储在栈上的变量当声明它的子程序执行完成时被释放。另一方面,存储在托管堆上的变量一直存在堆中直到垃圾回收过程被调用。堆数据只有在没有对它的动态引用时通过垃圾回收被移除。

垃圾回收可以并且将会发生在程序执行的任意时刻。然而,我们要尽我们可能的确保垃圾回收器在我们计时代码执行的过程中不运行。我们可以通过强制调用垃圾回收器来阻止任意执行的垃圾回收。.Net环境中提供了与个特殊的对象 – GC供调用垃圾回收使用。要通知系统执行垃圾回收。我们简单的写:

1 GC.Collect(); 

然而,这不是全部我们必须做的。存储在堆中的每一个对象有一个成为终结器的特殊方法。终结器方法在对象被删除前的最后一步执行。终结器方法存在的问题是它们不以一种规律的方式执行。事实上,你甚至根本不能确定一个对象的终结器是否已经运行了。但是我们知道,在我们确定一个对象被删除之前,它的终结器方法一定会运行。要确保这一点,我们添加一行代码来告诉程序等待所有堆上的对象的终结器方法运行后再继续执行。这行代码如下:

1 GC.WaitForPendingFinalizers(); 

    我们已经清除了一个障碍并且只剩一个待解决使用适当的线程。在.Net环境中,一个程序运行在一个线程中,也被称作应用程序域。这允许操作系统将每一个不同的程序分开在其上同时运行。在一个进程中,程序或者程序的一部分在一个线程内运行。一个程序的执行时间被操作系统通过线程进行分配。当我们为一个程序的代码计时时,我们想要确保我们计的时间只是进程分配给我的程序的代码所占用的时间,而不是操作系统执行的其他任务的时间。

    我们可以使用.NET类库中的Process类来实现这个功能。Process类的方法允许我们选择当前进程(我们的程序正运行的进程),程序正运行的线程,及一个计时器来存储线程开始执行的时间。所有这些方法可合并入一次调用,并指定这个调用函数的返回值为存储线程开始执行的时间(一个TimeSpan对象)的变量。如下是代码(共有两行)。

1 TimeSpan startingTime; 
2 startingTime = Process.GetCurrentProcess().Threads[0].UserProcessorTime; 

剩下的我们只需捕获我们计时的代码段停止的时间。代码示例如下:

1 duration = Process.GetCurrentProcess().Threads[0].UserProcessorTime.Subtract(startingTime);

现在,让我们把所有这些合并到一个程序中来测试我们之前测试过的相同代码。

 1 using System;
 2 using System.Diagnostics;
 3 
 4 class chapter1
 5 {
 6     static void Main()
 7     {
 8         int[] nums = new int[100000];
 9         BuildArray(nums);
10         TimeSpan startTime;
11         TimeSpan duration;
12         startTime = Process.GetCurrentProcess().Threads[0].UserProcessorTime;
13         DisplayNums(nums);
14         duration = Process.GetCurrentProcess().Threads[0].UserProcessorTime.Subtract(startTime);
15         Console.WriteLine("Time: " + duration.TotalSeconds);
16     }
17 
18     static void BuildArray(int[] arr)
19     {
20         for (int i = 0; i <= 99999; i++)
21             arr[i] = i;
22     }
23     
24     static void DisplayNums(int[] arr)
25     {
26         for (int i = 0; i <= arr.GetUpperBound(0); i++)
27             Console.Write(arr[i] + " ");
28     }
29 }

使用新的改进的计时代码,程序返回0.2526。这与第一次计时代码返回的将近5秒的结果有很大反差。很显然,这两种计时技术之间有很大差异,所以在.NET环境中对代码计时时,你应该使用适合.NET的技术。

计时测试类

虽然我们不需要一个类来运行我们的时间测试代码,但将代码重写成一个类很有意义,主要是因为我们可以降低我们测试的代码的行数来保持我们代码的清晰。

一个计时类需要下面的数据成员:

    startingTime – 保存我们正测试的代码的起始时间。

    duration – 我们正测试的代码的结束时间

我们选择使用TimeSpan数据类型存储startingtTimeduration这两个保存了时间的数据成员。我们将只使用一个构造函数,一个默认的构造函数来将这两个数据成员初始化为0

我们需要一个方法通知一个计时类对象什么时候开始计时,什么时候停止。我们同样需要一个方法来返回duration数据成员中存储的数据。

就像你看到的,这个Timing类相当小,仅仅需要几个方法,如下是其定义:

 1 public class Timing
 2 {
 3     TimeSpan startingTime;
 4     TimeSpan duration;
 5     public Timing()
 6     {
 7         startingTime = new TimeSpan(0);
 8         duration = new TimeSpan(0);
 9     }
10     public void StopTime()
11     {
12         duration = Process.GetCurrentProcess().Threads[0]
13 .UserProcessorTime.Subtract(startingTime);
14     }
15     public void startTime()
16     {
17         GC.Collect();
18         GC.WaitForPendingFinalizers();
19         startingTime = Process.GetCurrentProcess().Threads[0]
20 .UserProcessorTime;
21     }
22     public TimeSpan Result()
23     {
24         return duration;
25     }
26 }

下面的程序使用重写后的Timing类测试了DisplayNames子函数

 1 using System;
 2 using System.Diagnostics;
 3 
 4 public class Timing
 5 {
 6     TimeSpan startingTime;
 7     TimeSpan duration;
 8     public Timing()
 9     {
10         startingTime = new TimeSpan(0);
11         duration = new TimeSpan(0);
12     }
13     public void stopTime()
14     {
15         duration = Process.GetCurrentProcess().Threads[0].UserProcessorTime.Subtract(startingTime);
16     }
17     public void startTime()
18     {
19         GC.Collect();
20         GC.WaitForPendingFinalizers();
21         startingTime = Process.GetCurrentProcess().Threads[0].UserProcessorTime;
22     }
23     public TimeSpan Result()
24     {
25         return duration;
26     }
27 }
28 
29 class chapter1
30 {
31     static void Main()
32     {
33         int[] nums = new int[100000];
34         BuildArray(nums);
35         Timing tObj = new Timing();
36         tObj.startTime();
37         DisplayNums(nums);
38         tObj.stopTime();
39         Console.WriteLine("time (.NET): " + tObj.Result().TotalSeconds);
40     }
41     
42     static void BuildArray(int[] arr)
43     {
44         for (int i = 0; i < 100000; i++)
45             arr[i] = i;
46     }
47 
48     static void DisplayNums(int[] arr)
49     {
50         for (int i = 0; i <= arr.GetUpperBound(0); i++)
51             Console.Write(arr[i] + " ");
52     }
53 }

通过将计时代码移动到一个类中,我们将主程序中代码的行数由13行缩减到8行。诚然,这个程序中缩减的代码并不是很多,但是比代码行数更重要的是我们降低了主程序中代码的混乱。没有这个类,将开始时间指定到一个变量中的代码看起来像下面这样:

1 startTime = Process.GetCurrentProcess().Threads[0].UserProcessorTime; 

使用Timing类,将起始时间赋给类的数据成员的代码如下:

1 tObj.startTime(); 

将一条长的赋值语句封装入类的一个方法使我们的代码更易读,存在bug的可能性降低。

概要

本章回顾了三种本书中常用的重要技术。许多,尽管不是全部我们要写的程序,以及我们要讨论的类库,是以面向对象的方式写的。我们编写的集合类说明了许多贯穿大部分章节的基本的OOP概念。泛型编程(通过限制被重写或重载的函数的数量)使程序员可以简化一些数据结构的定义。Timing类提供了一个简单,但有效的方式衡量我们要学习的数据结构与算法的性能。

练习

  1. 创建一个名为Test的类,其拥有两个数据成员,一个用来保存学生姓名,另一个数字表示测试号。这个类用于以下场景:当一个学生提交一份测试时,他们将其面朝下放在桌子上,如果一个学生想要检查一个答案。老师不得不将一堆测试卷翻过来,以让第一张测试卷面朝上,遍历这堆卷子,直到找到这位学生的测试卷并将这位测试由堆中移除。当学生检查完测试时,将其放到一堆测试卷的最后。

    编写一个Windows应用程序模拟这个场景。包括让用户输入姓名与测试号的文本框(text box),在窗体上放置一个列表框(list box)显示最终的测试列表,提供四个按钮,作用如下:1. 提交一份测试;2. 让学生看到测试;3. 返回一个测试;4. 退出。执行下面的操作测试你的应用程序:1. 输入一个姓名与一个测试号,将测试记录插入名为submittedTests的集合;2. 输入一个姓名,删除submittedTests集合中的相关测试记录,再将此记录插入一个名为outForChecking的集合;3. 输入一个姓名,由outForChecking集合删除此测试,再将其插入SubmittedTests集合;4. 按下退出按钮。退出按钮并不终止程序,而是由outForChecking删除所有的测试记录并将它们插入submittedTests集合,然后显示一个所有已提交测试的列表。

    使用本章编写的集合类完成。

  2. 在本章编写的集合类中实现下列方法。
    1. Insert
    2. Contains
    3. IndexOf
    4. RemoveAt
  3. 使用Timing类比较使用自定义的集合类与ArrayList在进行将1000000个数相加的性能。

创建你自己的集合类,不要使用继承CollectionBase的方法,在你的实现中使用泛型。

后记:huihui19841118留言中提出原书耗时计算程序的错误。我把的想法附于下方。

关于书中计时程序的写法,我感觉原作者意思可能是对的,但是由于.NET的机制,那段程序不大可能正确执行。猜测作者的本意应该是,耗时操作前的那个语句统计程序已执行部分所用时间,耗时操作后的语句统计程序到目前为止总计用时减去初始部分的耗时。这种想法应该是正确的,但是由于CLR的某种执行策略(具体我也不知为什么,但肯定存在)这两条执行语句得到的会是相同的值,所以导致最终结果为0。至于如何用原作者的方式写出这个程序我也没有想法。huihui19841118的想法我也没有理解。
另外在这里有篇文章,我感觉其中的方法比书中的方法要好很多。http://www.cnblogs.com/boer/archive/2009/06/02/1494544.html

原文地址:https://www.cnblogs.com/lsxqw2004/p/1377439.html