递归在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结点,这里就不介绍了。
杨盛超
2011年3月31日