经常看到过这样的定义:程序=数据结构+算法。因此,程序的性能往往同时受这两个方面影响。关于这两个方面,有很多经典的书籍可供参考和学习,本文做一些简单的介绍和总结。
1. 常用数据结构分析
选择哪种数据结构存储,一般可以通过遍历、插入、删除、查找4个基本操作进行分析。这里比较3种常用数据结构:数组、链表、哈希表。这里的哈希表由数组和链表实现。下表比较了3种不同容器基本操作的性能。
注1:这里的遍历是指“获得某一位置的元素”
注2:m一般是远小于n的数字,表示哈希表中链表的平均长度
从上表中可以看出:数组的特点是遍历方便,但扩展性差;链表的特点正好相反,而哈希表则兼顾了两者的优点,但引入了额外的哈希函数。因此,当前应用场景中,如果对数据的遍历操作占比很多,一般选择数组;如果插入、删除很多,一般选择链表;如果查找很多,或想兼顾遍历、查找、删除,则选择哈希表。
STL为我们提供了一整套基于模板的通用数据结构以及匹配的常用算法,不需要我们重复造轮子,但需要我们理解基本原理和适用场景。
STL容器的使用也依赖于具体的实现,有一些通用的规则可循,这里以std::vector简单聊一下注意事项:(1) std::vector的插入(insert)和删除(erase)操作是非常耗时的,因为需要移动操作位置后面的所有元素,对于大型数组而言,开销是非常显著的,所以应当尽量避免。(2) 虽然std::vector具有动态管理内存的功能,但如果需要将大量元素灌注到一个std::vector中,应当提前分配内存(resize或reserve),而不是利用追加操作(push_back),否则会因为频繁申请内存和复制元素,导致效率低下。
需要注意的是,STL提供的容器是非常丰富的,并且,单个容器可能是合并了多种技术来实现的,比如,std::vector是在静态数组的基础上实现的动态数组,而std::map则有几种不同的实现方法,来应对不同的细分场景。因此,需要开发者在理解基本原理之后,在实践中体会不同数据结构的特性,以选择最合适的数据结构。
2. 算法思路
对算法的精彩讨论莫过于排序算法,出于对这一经典问题的研究,诞生出了各种各样的排序,对这些算法的分析可以很容易获取到。因此,本文不打算深入讨论这一问题,而是想谈谈算法设计中的一些实用技巧。
分治策略。也就是将一个复杂的问题不断分解为有限个简单问题,从而将复杂度降低的策略,一般可以将算法复杂度从 O ( m n ) O(mn) O(mn)降低为 O ( m l o g n ) O(mlogn) O(mlogn)。分治策略一般分为3个步骤:分解、求解和合并,应用分治策略的一个典型算法是“归并排序”,可以很容易在涉及算法的书籍中找到,就不深入讲解了。
这里举一个简化分治策略的例子,即二分查找。CAD的表示基础是NURBS,而根据参数 u u u 计算NURBS曲线上对应点坐标和各阶导数是极其常用的功能。根据局部支撑性 ,NURBS曲线在某点处的位置信息,只与周围的degree+1个极点有关。因此,需要先确定参数 u u u 在节点向量中的位置,从而得到相关的节点和极点,就能计算对应点坐标和各阶导数了。如果采取遍历的方式,逐个比较参数 u u u 与节点的大小,对于复杂曲线,是非常不经济的(复杂度 O ( n ) O(n) O(n))。而节点向量是有序的,可以采用二分查找确定节点区间(复杂度 O ( l o g n ) O(logn) O(logn))。
动态规划。这种方法一般也会将复杂问题分解为一系列子问题,先求解子问题,最后将子问题的解合并,得到原问题的解,这与分治策略类似。为了区分两者,这里先讲解适用动态规划的三个性质:
(1) 最优子结构:问题的最优解所包含的子问题的解也是最优的。
(2) 无后效性:当前阶段的状态值(子问题的解)一旦确定,就不再改变,不会再受到后续阶段决策的影响。
(3) 有重叠子问题:在求解子问题的过程中,有大量的子问题是重叠的,一个子问题的解会重复被使用。
不难看出,动态规划与分治策略不同的是:适用于动态规划求解的问题,在分解之后得到的子问题往往是相互联系的,会出现很多重叠子问题。动态规划会将这些重叠子问题的解保存起来,供随后的计算查询使用,从而避免大量的重复计算。动态规划的经典应用非常多,比如最长公共子序列问题、背包问题、最短路径问题。与分治策略类似,本文不打算介绍这些容易查到的应用,而是讲述一个CAD方面的应用:加权最小三角化(MWT)。
如图1(a),一条3D封闭轮廓由一系列点构成,围起了一个区域D,MWT是为了找到类似图1(b)中的水密三角形网格,满足所定义的权重最小条件。这里以面积作为权重,即我们想找到面积最小的三角形网格。如图1(c )所示,任取一条边界 E 01 E_{01}