递归在WinForm 中的应用
最近做项目经常用到递归,刚开始很久没用,不太熟悉,现在研究了下,并写下了学习笔记及开发经验总结。
递归热身
一个算法调用自己来完成它的部分工作,在解决某些问题时,一个算法需要调用自身。如果一个算法直接调用自己或间接地调用自己,就称这个算法是递归的 (Recursive) 。根据调用方式的不同,它分为直接递归 (Direct Recursion) 和间接递归 (Indirect Recursion) 。 比如,在收看电视节目时,如果演播室中也有一台电视机播放的是与当前相同的节目,观众就会发现屏幕里的电视套有一层层的电视画面。这种现象类似于直接递归。
如果把两面镜子面对面摆放,便可从任意一面镜子里看到两面镜子无数个影像,这类似于间接递归。
一个递归算法必须有两个部分:初始部分 (Base Case) 和递归部分 (Recursion Case) 。初始部分只处理可以直接解决而不需要再次递归调用的简单输入。递归部分包含对算法的一次或多次递归调用,每一次的调用参数都在某种程度上比原始调用参数更接近初始情况。
函数的递归调用可以理解为: 通过一系列的自身调用,达到某一终止条件后,再按照调用路线逐步返回。 递归是程序设计中强有力的工具,有很多数学函数是以递归来定义的。
如大家熟悉的阶乘函数,我们可以对 n! 作如下定义:f(n)=
1 (n=1)
n* f(n-1) (n>=2)
一个算法具有的特性之一就是 有穷性 (Finity) :一个算法总是在执行有穷步之后结束,即算法的执行时间是有限的。递归算法当然也是算法,也满足算法的特性,因此递归不可能无限递归下去,总有一个终止条件。对该示例,递归的终止条件是n=1. 当 n=1 是,返回 1 ,不在调用自己本身,递归结束。
class Program
{
static void Main ( string [] args )
{
long result = function (20);
Console . WriteLine ( result );
Console . ReadLine ();
}
static long function ( long n )
{
if ( n == 1) //算法终止条件
{
return 1;
}
return n * function ( n - 1);
}
}
递归算法通常不是解决问题最有效的计算机程序,因为递归包含函数调用,函数调用需要时空开销。所以,递归比其他替代选择诸如 while 循环等,所花费的代价更大。但是,递归通常提供了一种能合理有效地解决某些问题的算法。
递归示例( 一 ) :遍历二叉树
二叉树是一种典型的树形结构,常用到递归算法来遍历。遍历按照根节点的相对顺序可分为前序遍历(DLR) 、中序遍历 (LDR) 、后序遍历 (RDL) 。
对二叉树节点,有数据域存放数据,左孩子和右孩子为引用域存放孩子的引用:
左孩子 LChhild | 数据域 data | 右孩子 RChild |
/// <summary>
/// 二叉树节点
/// </summary>
/// <typeparam name="T"></typeparam>
public class Node < T >
{
private T data ; //数据域
private Node < T > lChild ; //左孩子
private Node < T > rChild ; //右孩子
public Node ()
{
data = default ( T );
lChild = null ;
rChild = null ;
}
public Node ( T data , Node < T > lChild , Node < T > rChild )
{
this . data = data ;
this . lChild = lChild ;
this . rChild = rChild ;
}
public Node ( Node < T > lChild , Node < T > rChild )
{
data = default ( T );
this . lChild = lChild ;
this . rChild = rChild ;
}
public Node ( T data )
: this ( data , null , null )
{
this . data = data ;
}
/// <summary>
/// 数据域
/// </summary>
public T Data
{
get { return data ; }
set { this . data = value ; }
}
/// <summary>
/// 左孩子
/// </summary>
public Node < T > LChild
{
get { return lChild ; }
set { lChild = value ; }
}
/// <summary>
/// 右孩子
/// </summary>
public Node < T > RChild
{
get { return rChild ; }
set { rChild = value ; }
}
}
先假设有以下结构的二叉树:
先在构造函数中简单构造下对应的数据:
public Node < string > A ;
public 遍历二叉树 ()
{
A = new Node < string >( "A" );
Node < string > B = new Node < string >( "B" );
Node < string > C = new Node < string >( "C" );
Node < string > D = new Node < string >( "D" );
Node < string > E = new Node < string >( "E" );
Node < string > F = new Node < string >( "F" );
Node < string > G = new Node < string >( "G" );
Node < string > H = new Node < string >( "H" );
Node < string > I = new Node < string >( "I" );
Node < string > J = new Node < string >( "J" );
D . LChild = H ;
D . RChild = I ;
E . LChild = J ;
B . LChild = D ;
B . RChild = E ;
C . LChild = F ;
C . RChild = G ;
A . LChild = B ;
A . RChild = C ;
}
前序遍历: 先访问根结点A ,然后分别访问左子树和右子树,把 B 及 B 的子孙看作一个结点处理, C 及 C 的子孙看作一个结点处理,访问 B 时,把 B 当作根结点处理, B 的左子树及左子树的子孙看作一个结点处理 …… 可见,顺序依次是顶点- 左孩子 - 右孩子( DLR ),直到结点为叶子 ( 即不包含子结点的结点 ) ,即为递归的终止条件。对任意结点,只要结点确定,其左孩子和右孩子就确定,因此递归算法方法参数将结点传入即可。
/// <summary>
/// 前序遍历--DLR
/// </summary>
/// <param name="root"></param>
public void PreOrder ( Node < T > root )
{
if ( root == null )
{
return ;
}
Console . Write ( "{0} " , root . Data );
//当节点无左孩子时,传入参数为null,下次调用即返回,终止
PreOrder ( root . LChild );
//当节点无右孩子时,传入参数为null,下次调用即返回,终止
PreOrder ( root . RChild );
}
同理,中序遍历和后序遍历如下:
/// <summary>
/// 中序遍历 LDR
/// </summary>
/// <param name="node"></param>
public void InOrder ( Node < T > node )
{
//if (node == null)
//{
// return;
//}
//InOrder(node.LChild);
//Console.Write("{0} ",node.Data);
//InOrder(node.RChild);
//另外一种写法
if ( node . LChild != null )
{
InOrder ( node . LChild );
}
Console . Write ( "{0} " , node . Data );
if ( node . RChild != null )
{
InOrder ( node . RChild );
}
}
/// <summary>
/// 后序遍历--LRD
/// </summary>
/// <param name="node"></param>
public void PostOrder ( Node < T > node )
{
if ( node == null )
{
return ;
}
PostOrder ( node . LChild );
PostOrder ( node . RChild );
Console . Write ( "{0} " , node . Data );
}
/// <summary>
/// 层序遍历
/// </summary>
/// <param name="node"></param>
public void LevelOrder ( Node < T > node )
{
if ( node == null )
{
return ;
}
Queue < Node < T >> sq = new Queue < Node < T >>();
//根结点入队
sq . Enqueue ( node );
while ( sq . Count != 0)
{
Node < T > tmp = sq . Dequeue (); //出队
Console . Write ( "{0} " , tmp . Data );
if ( tmp . LChild != null )
{
sq . Enqueue ( tmp . LChild );
}
if ( tmp . RChild != null )
{
sq . Enqueue ( tmp . RChild );
}
}
}
其中,另外一种写法就是在递归前判断下,满足递归条件才调用自己,这也是处理递归终止的一种方法。
static void Main ( string [] args )
{
遍历二叉树 < string > t = new 遍历二叉树 < string >();
Console . Write ( "前序遍历:" );
t . PreOrder ( t . A );
Console . WriteLine ();
Console . Write ( "中序遍历:" );
t . InOrder ( t . A );
Console . WriteLine ();
Console . Write ( "后序遍历:" );
t . PostOrder ( t . A );
Console . WriteLine ();
Console . Write ( "层序遍历:" );
t . LevelOrder ( t . A );
Console . ReadLine ();
}
运行结果为 :
递归示例( 二 ) : WinForm 之 TreeView 的应用 — 绑定区域树
C#中的树很多。比如, Windows Form 程序设计和 Web 程序设计中都有一种被称为 TreeView 的控件。 TreeView 控件是一个显示树形结构的控件,此树形结构与 Windows 资源管理器中的树形结构非常类似。不同的是, TreeView 可以由任意多个节点对象组成。每个节点对象都可以关联文本和图像。另外, Web 程序设计中的 TreeView 的节点还可以显示为超链接并与某个 URL 相关联。每个节点还可以包括任意多个子节点对象。包含节点及其子节点的层次结构构成了 TreeView 控件所呈现的树形结构。
下面是很典型的一个例子,就是用TreeView 绑定数据。数据一般符合树形结构,如行政区域之间的关系、公司部门与部门员工之间关系、磁盘目录文件之间的关系等。
父级与子级之间满足一对多的关系,因此在数据库设计中常用一字段来做本表主键的外键,代表父级区域ID 。当然,如果要方便求子孙的算法(例如列举武汉所有子区域)可以另加一字段,记录从根结点到当前结点所经历的结点 ID 。
思路分析:
1. 获取表Area 中的所有数据,存放到 DataTable 中。
2. 获取根结点的数据并添加到根节点。根结点的处理常与子结点的递归处理不一样,例如根结点的添加是在treeView1.Nodes.Add 里面,而子结点递归是在父结点上添加,因此经常要分开处理。获取根结点数据可用 DataTable.Select( “ fAreaId=-1 ” )来获取。绑定结点时,将 Node.Text 设为区域的名字, Node.Tag 设为区域对应的数据行 DataRow 或者区域的 ID ,这样遍历子区域就知道父结点区域信息,也方便应用程序获取选中的结点对应的数据。
3. 递归遍历子区域并添加到TreeView 控件中。递归方法参数为 Node, 由父级 Node.Tag 就能获取父级区域数据信息,进而获取其子区域,获取子区域可用
DataRow[] rows=DataTable.Select( “ fAreaId= ” +父级区域 ID) 。获取子区域后将其获取的信息绑定到新建的 Node 对象,方法同第二步,然后递归调用自己。当区域不包含任何子区域时,递归终止 , 即 rows.Length==0.
代码如下:
public partial class BindAreaForm : Form
{
private DataTable dt = null ;
public BindAreaForm ()
{
InitializeComponent ();
InitDataTable ();
}
//获取Area所用数据
private void InitDataTable ()
{
SqlConnection conn = new SqlConnection ( "Data Source=.;Initial Catalog=Test;Integrated Security=True" );
SqlCommand cmd = new SqlCommand ( "SELECT * FROM Area" , conn );
SqlDataAdapter ada = new SqlDataAdapter ( cmd );
dt = new DataTable ();
ada . Fill ( dt );
}
private void BindAreaForm_Load ( object sender , EventArgs e )
{
BindRoot ();
}
//绑定根节点
private void BindRoot ()
{
DataRow [] rows = dt . Select ( "fAreaId=-1" ); //取根
foreach ( DataRow dRow in rows )
{
TreeNode rootNode = new TreeNode ();
rootNode . Tag = dRow ;
rootNode . Text = dRow [ "AreaName" ]. ToString ();
treeView1 . Nodes . Add ( rootNode );
BindChildAreas ( rootNode );
}
}
//递归绑定子区域
private void BindChildAreas ( TreeNode fNode )
{
DataRow dr = ( DataRow ) fNode . Tag ; //父节点数据关联的数据行
int fAreaId = ( int ) dr [ "id" ]; //父节点ID
DataRow [] rows = dt . Select ( "fAreaId=" + fAreaId ); //子区域
if ( rows . Length == 0) //递归终止,区域不包含子区域时
{
return ;
}
foreach ( DataRow dRow in rows )
{
TreeNode node = new TreeNode ();
node . Tag = dRow ;
node . Text = dRow [ "AreaName" ]. ToString ();
//添加子节点
fNode . Nodes . Add ( node );
//递归
BindChildAreas ( node );
}
}
}
运行截图:
递归示例( 三 ) : WinForm 之 TreeView 的应用 — 绑定磁盘目录( 一 )
磁盘文件系统结构符合树形结构,可以把“我的电脑”或者驱动器看做是树的根( 多个驱动器看做多个根吧,做多课树处理 ) ,文件夹下面可以包含文件夹或文件,文件则是树的叶子,不能再分,显然,这也是递归的终止条件。
思路分析:
1. 获取要绑定的目录,此目录为treeView 控件的根。将结点的 Tag 设置成觉对路径,以便子节点获取父结点信息。
2. 递归遍历子目录和文件,当绝对路径对应的DirectoryInfo 为文件时,递归终止。这里要提一下,网上很多判断文件时文件夹还是文件都用后缀来判断,无后缀则为文件夹,这样是不正确的,例如 host 文件就没后缀,但它是文件而不是文件夹,还有很多软件的缓存文件也没后缀的,把它们当文件夹来处理遍历访问子目录显然有异常。正确的方法是用 FileSystemInfo 类的 GetType() 方法。
public partial class MainForm : Form
{
public MainForm ()
{
InitializeComponent ();
}
private void MainForm_Load ( object sender , EventArgs e )
{
TreeNode root = new TreeNode ();
root . Text = @"战国无双 2" ;
root . Tag = @"E:/战国无双 2" ;
treeView1 . Nodes . Add ( root );
BindChild ( root );
}
private void BindChild ( TreeNode fNode )
{
string path = fNode . Tag . ToString ();
//父目录
DirectoryInfo fDir = new DirectoryInfo ( path );
FileSystemInfo [] finfos = fDir . GetFileSystemInfos ();
foreach ( FileSystemInfo f in finfos )
{
string type = f . GetType (). ToString ();
TreeNode node = new TreeNode ();
node . Text = f . Name ;
node . Tag = f . FullName ;
fNode . Nodes . Add ( node );
if ( "System.IO.DirectoryInfo" == type ) //是文件夹时才递归调用自己
{
BindChild ( node );
}
}
}
运行截图如下:
总结:
TreeView递归绑定一般分两大步,第一步对根结点操作及输入绑定,并将结点关联数据传入递归;第二步主要是递归终止的控制,控制终止一般有两种方法:一是在递归方法开始判断是否满足递归终止条件,是则显式 return 返回,否则继续调用自己;另外一种方法是在调用自己前判断是否满足递归的条件,满足条件才调用自己。两种方法具体看程序。
当把上面的目录改为比较大的目录例如C:/Windows 时,发现加载要很多时间。针对这个问题,请看下一篇:动态加载结点。
递归示例( 四 ): WinForm 之 TreeView 的应用 — 绑定磁盘目录( 二 )
当具有树形结构的数据的结点很多而且树的深度比较大时,直接用递归遍历明显能发现性能很低。因此,不要一次全部加载,而是当用户点击展开时才加载此结点下的子结点。
实现要点:
每加载添加一个结点时,判断该结点是否为叶子( 即不含子结点 ) ,若包含子结点,先添加一个空的子节点,这样做主要是让用户在界面能看到“ + ”表示结点能展开。当用户点击“ + ”时触发 treeView_AfterExpand 事件,在该事件中处理添加子结点数据,添加之前,清理删除掉以前的结点。
public partial class MainForm2 : Form
{
public MainForm2 ()
{
InitializeComponent ();
this . SetStyle ( ControlStyles . OptimizedDoubleBuffer , true );
}
private void MainForm2_Load ( object sender , EventArgs e )
{
BindDrives ();
}
private void BindDrives ()
{
DriveInfo [] drvs = DriveInfo . GetDrives ();
foreach ( DriveInfo drv in drvs )
{
TreeNode root = new TreeNode ();
root . Text = drv . Name ;
root . Tag = drv . RootDirectory . ToString ();
// root.Nodes.Add("");
treeView1 . Nodes . Add ( root );
if ( Directory . Exists ( drv . RootDirectory . ToString ()))
{
DirectoryInfo dInfo = new DirectoryInfo ( drv . RootDirectory . ToString ());
FileSystemInfo [] files = dInfo . GetFileSystemInfos ();
if ( files . Length > 0) //有子节点,先添加一个空节点
{
root . Nodes . Add ( "emptyNode" , string . Empty );
}
}
}
}
//展开节点,移除以前的空节点,加载子节点
private void treeView1_AfterExpand ( object sender , TreeViewEventArgs e )
{
TreeNode parentNode = e . Node ;
// parentNode.Nodes.RemoveByKey("emptyNode");//移除空节点
parentNode . Nodes . Clear ();
string path = parentNode . Tag . ToString ();
if ( Directory . Exists ( path ))
{
DirectoryInfo dir = new DirectoryInfo ( path );
FileSystemInfo [] files = dir . GetFileSystemInfos ();
foreach ( FileSystemInfo f in files )
{
TreeNode node = new TreeNode ();
node . Text = f . Name ;
node . Tag = f . FullName ;
parentNode . Nodes . Add ( node ); //加载子节点
if ( Directory . Exists ( node . Tag . ToString ()))
{
DirectoryInfo subDir = new DirectoryInfo ( node . Tag . ToString ());
if ( subDir . Attributes != ( FileAttributes . System | FileAttributes . Hidden | FileAttributes . Directory ))
{
FileSystemInfo [] subFiles = subDir . GetFileSystemInfos ();
if ( subFiles . Length > 0) //有子节点,先添加一个空节点
{
node . Nodes . Add ( "emptyNode" , string . Empty );
}
}
}
}
}
运行结果如图:
这样,只加载用户要展开的结点,而且每次只加载当前结点的下一代,性能明显能提升,当然还能用多线程技术改善性能、用 WindowsAPI获取文件图标并关联 TreeView 结点,这里就不介绍了。