├── README.md ├── 常用数据结构.md ├── 排序算法.md ├── 操作系统.md ├── 编程之美读书笔记(一).md ├── 编程之美读书笔记(三).md ├── 编程之美读书笔记(二).md └── 编程之美读书笔记(四).md /README.md: -------------------------------------------------------------------------------- 1 | tech-notes 2 | ========== 3 | 4 | 技术学习笔记 5 | -------------------------------------------------------------------------------- /常用数据结构.md: -------------------------------------------------------------------------------- 1 | # 常用数据结构 # 2 | 3 | 本文包括数据结构的预备知识、表、栈、队列、树、散列、堆等常见数据结构的介绍 4 | 5 | ## 预备知识 ## 6 | 7 | ### 递归简论 ### 8 | 9 | 在编写递归函数时,要遵循如下准则: 10 | 11 | - 必须要有**基准情形**,无需递归就可以解出。 12 | - 每一次递归调用都必须要朝向一种基准情形推进,不能南辕北辙,导致递归永远无法结束。 13 | - 假设所有的递归调用都能运行,也就是说不必试图追踪大量递归调用的细节,因为这往往很复杂。 14 | - 切勿在不同的递归调用中做重复性的工作。 15 | 16 | ### 算法分析 ### 17 | 18 | **分治策略**是一种常用的算法分析思想,“分”的阶段将问题拆分成两个大致相等的子问题,然后递归地进行求解;“治”的阶段将两个子问题的解合并起来,再做少量附加工作,从而得到整个问题的解。很多情况下,分治算法可以将原本耗费O(N)时间的算法减少到O(logN)时间。例如如下几个算法: 19 | 20 | - **二分查找**:算法过程略。注意:该算法要求输入序列是排好序的。代码如下: 21 | 22 | public static > int binarySearch( AnyType [] a, AnyType x ){ 23 | //初始化工作 24 | int low = 0, high = a.length - 1; 25 | //进行二分查找的迭代过程 26 | while( low <= high ){ 27 | //取中点元素 28 | int mid = ( low + high ) / 2; 29 | //如果中点元素小于待查找元素,说明待查找元素在后半部分,迭代这个过程 30 | if( a[ mid ].compareTo( x ) < 0 ) 31 | low = mid + 1; 32 | //如果中点元素大于待查找元素,说明待查找元素在前半部分,迭代这个过程 33 | else if( a[ mid ].compareTo( x ) > 0 ) 34 | high = mid - 1; 35 | //中点元素就是待查找元素,方法返回 36 | else 37 | return mid; 38 | } 39 | //若迭代循环结束,方法仍未返回,说明没有找到 40 | return NOT_FOUND; 41 | } 42 | 43 | - **幂运算**:计算X的N次方,传统的算法是使用N-1次自乘,因此其花费时间是O(N);而一种采用分治策略的递归算法效果更好——若N=0或1,直接算出结果,否则,若N是偶数,则`X^N = X^(N/2) * X^(N/2)`,只需递归求解`X^(N/2)`即可,若N是奇数,则`X^N = X^(N-1)/2 * X^(N-1)/2 * X`,只需递归求解`X^(N-1)/2`即可。可以看出,这种求幂运算的算法每次可以将问题的大小减半,因此该算法具有O(logN)时间。代码如下: 44 | 45 | public static long pow( long x, int n ){ 46 | //计算x的0次方,直接计算 47 | if( n == 0 ) 48 | return 1; 49 | //计算x的1次方,直接计算 50 | if( n == 1 ) 51 | return x; 52 | //若n为偶数(n>1),则递归计算x^2的n/2次方即可 53 | if( isEven( n ) ) 54 | return pow( x * x, n / 2 ); 55 | //若n为奇数(n>1),则递归计算x^2的n/2次方,再乘以x即可 56 | else 57 | return pow( x * x, n / 2 ) * x; 58 | } 59 | 60 | 61 | ## 表 ## 62 | 63 | ### 基本概念及理论 ### 64 | 65 | - 表有两种常用的实现方式:**顺序表**和**链表**,分别对应于Java容器API中的ArrayList和LinkedList。 66 | - 对于顺序表ArrayList,其底层是数组实现的,因此ArrayList本质上相当于一个可增长数组。可增长机制的实现原理是:在创建ArrayList时先根据默认长度建立一个数组,在向ArrayList中添加元素时,若初始的数组长度已不够用,则创建一个新的数组(一般为原数组的两倍长),把原数组的元素都拷贝到新数组中,后续使用的就是这个长度扩大的新数组了。ArrayList可增长机制的实现代码如下所示: 67 | 68 | public void ensureCapacity( int newCapacity ){ 69 | //容量未满,无需增大容量,直接返回 70 | if( newCapacity < theSize ) 71 | return; 72 | //获得原数组 73 | AnyType [] old = theItems; 74 | //建立容量扩大后的新数组 75 | theItems = (AnyType []) new Object[ newCapacity ]; 76 | //将原数组中的内容拷贝至新数组中 77 | for( int i = 0; i < size( ); i++ ) 78 | theItems[ i ] = old[ i ]; 79 | } 80 | 81 | - 对于链表LinkedList,其底层实现需要一个嵌套的节点类Node,该嵌套类中包含数据域、前一节点和后一节点的引用(Node对象引用)。 82 | - 对于随机访问操作,ArrayList的处理效率较高,因为它底层是数组实现的;而LinkedList的处理效率较低,因为它需要从链表的表头(或表尾)逐个元素进行访问,直到到达要访问的元素为止。ArrayList和LinkedList中的随机访问操作方法的实现代码如下所示: 83 | 84 | //ArrayList中的随机访问方法 85 | public AnyType get( int idx ){ 86 | //若随机访问的下标越界,抛出异常 87 | if( idx < 0 || idx >= size( ) ) 88 | throw new ArrayIndexOutOfBoundsException( "Index " + idx + "; size " + size( ) ); 89 | //根据随机访问的下标直接返回数组元素 90 | return theItems[ idx ]; 91 | } 92 | 93 | //LinkedList中的随机访问方法 94 | private Node getNode( int idx, int lower, int upper ){ 95 | Node p; 96 | //若随机访问的下标越界,抛出异常 97 | if( idx < lower || idx > upper ) 98 | throw new IndexOutOfBoundsException( "getNode index: " + idx + "; size: " + size( ) ); 99 | //若随机访问的下标在前半部分,则从头节点开始,向后逐个访问,直到找到元素 100 | if( idx < size( ) / 2 ) 101 | { 102 | p = beginMarker.next; 103 | for( int i = 0; i < idx; i++ ) 104 | p = p.next; 105 | } 106 | //若随机访问的下标在后半部分,则从尾节点开始,向前逐个访问,直到找到元素 107 | else 108 | { 109 | p = endMarker; 110 | for( int i = size( ); i > idx; i-- ) 111 | p = p.prev; 112 | } 113 | //返回找到的元素 114 | return p; 115 | } 116 | - 对于添加和删除操作,ArrayList的处理效率较低,因为在向数组的某个位置添加/删除元素时,需要将该位置之后的所有元素后移/前移一位;而LinkedList的处理效率较高,因为它只需适当调整该位置前、后节点的引用即可。ArrayList和LinkedList中的添加、删除操作方法的实现代码如下所示: 117 | 118 | //ArrayList中的添加方法 119 | public void add( int idx, AnyType x ){ 120 | //若空间不够,扩大容量 121 | if( theItems.length == size( ) ) 122 | ensureCapacity( size( ) * 2 + 1 ); 123 | //为了空出插入的位置,要将插入位置之后的所有元素后移一位 124 | for( int i = theSize; i > idx; i-- ) 125 | theItems[ i ] = theItems[ i - 1 ]; 126 | //插入新元素 127 | theItems[ idx ] = x; 128 | //List的长度加1 129 | theSize++; 130 | } 131 | 132 | //ArrayList中的删除方法 133 | public AnyType remove( int idx ){ 134 | //获取被删除的元素 135 | AnyType removedItem = theItems[ idx ]; 136 | //将删除位置之后的所有元素前移一位 137 | for( int i = idx; i < size( ) - 1; i++ ) 138 | theItems[ i ] = theItems[ i + 1 ]; 139 | //List的长度减1 140 | theSize--; 141 | //返回被删除的元素 142 | return removedItem; 143 | } 144 | 145 | //LinkedList中的添加方法 146 | private void addBefore( Node p, AnyType x ){ 147 | //创建要插入的新节点 148 | Node newNode = new Node<>( x, p.prev, p ); 149 | //接下来两行代码是调整新节点及其插入位置的前节点的相关引用,从而完成链表的插入 150 | newNode.prev.next = newNode; 151 | p.prev = newNode; 152 | //List长度加1 153 | theSize++; 154 | } 155 | 156 | //LinkedList中的删除方法 157 | private AnyType remove( Node p ){ 158 | //调整待删除节点的前、后节点的相关引用,使其绕过待删除节点,即可完成删除 159 | p.next.prev = p.prev; 160 | p.prev.next = p.next; 161 | //List长度减1 162 | theSize--; 163 | //返回待删除元素 164 | return p.data; 165 | } 166 | 167 | - ArrayList和LinkedList中都实现了迭代器Iterator,迭代器是一个内部类(之所以使用内部类,而不使用外部类或嵌套类,是因为内部类对象中包含了对外部类对象的隐式引用,因此不需要写额外的代码,迭代器就可以获取外部类List的引用,从而访问List中的元素)。 168 | 169 | - 关于迭代器的使用,有两点需要注意: 170 | 171 | + 尽量使用迭代器的remove()方法,而不要使用List自身的remove()方法,因为List自身的remove()方法需要先找到该元素,然后删除;而迭代器的remove()方法直接删除游标指向的当前项。 172 | + 只有在需要立即使用一个迭代器的时候,才应该获取迭代器。因为如果对正在被迭代的容器进行结构上的改变(例如添加、删除等操作),则先前获取的迭代器将不再合法。 173 | 174 | 175 | ## 栈 ## 176 | 177 | ### 基本概念及理论 ### 178 | 179 | - 栈是一种先进后出(FILO)的数据结构,它本质上是限制添加和删除操作只能在一个位置上(栈顶)进行的表,因此任何实现表的方法(例如ArrayList和LinkedList)都可以实现栈。 180 | - 栈操作包括**入栈**(push)和**出栈**(pop),这些操作都是在栈顶进行的,且都花费常数时间。 181 | 182 | ### 栈的应用 ### 183 | 184 | - 编译器检查代码中的括号是否正确配对。 185 | - 将中缀表达式转换成后缀表达式,并计算后缀表达式。(示例略) 186 | - 方法调用(包括递归调用)。(注意:对于尾调用和尾递归,由于调用完成之后直接返回,没有后续操作了,因此无需在方法调用栈上添加新的栈帧,而仅仅更新原来的栈帧即可。所以编译器一般都会进行尾调用消除,来优化程序执行效率。) 187 | 188 | 189 | ## 队列 ## 190 | 191 | ### 基本概念及理论 ### 192 | 193 | - 队列是一种先进先出(FIFO)的数据结构,它本质上也是表,因此任何实现表的方法(例如ArrayList和LinkedList)都可以实现队列。 194 | - 队列的操作包括**插入**(enqueue)和**删除**(dequeue)。插入在队尾进行,删除在队头进行,均花费常数时间。 195 | - 队列的数组实现会存在**假溢出**的问题,解决方法是将队列变成循环队列,当到达数组末端时重新绕回到开头。 196 | 197 | ### 队列的应用 ### 198 | 199 | - 排队论 200 | - 消息队列JMS 201 | 202 | 203 | ## 树 ## 204 | 205 | ### 基本概念及理论 ### 206 | 207 | - 树的实现一般采用firstChild-nextSibling方法,即每个节点保存指向它的第一个儿子和它的右兄弟的链(由于事先不知道每个节点的儿子数目,因此直接保存指向所有儿子的链这种做法是不可行的,会产生太多浪费的空间)。 208 | - 对于二叉树,由于每个节点最多有两个子节点,因此不必采用firstChild-nextSibling的实现方法,而可以直接保存指向子节点的链。 209 | 210 | ### 二叉查找树 ### 211 | 212 | - 二叉查找树是非常重要的一种树,它的性质是:对于每一个节点,其左子树中所有项的值 < 该节点项的值 < 其右子树中所有项的值。因此二叉查找树是有序的。 213 | - **contains方法**:在二叉查找树中查找某个元素是否存在,算法过程为:首先访问树的根节点,若待查找的值等于根节点的值,则找到返回;否则,若待查找的值小于/大于根节点的值,则对根节点的左子树/右子树进行递归的查找。该算法代码如下: 214 | 215 | private boolean contains( AnyType x, BinaryNode t ){ 216 | //若当前节点为空,说明未找到,返回false 217 | if( t == null ) 218 | return false; 219 | //比较待查找的值与当前节点值 220 | int compareResult = x.compareTo( t.element ); 221 | //若待查找的值小于当前节点值,则对当前节点的左子树进行递归查找 222 | if( compareResult < 0 ) 223 | return contains( x, t.left ); 224 | //若待查找的值大于当前节点值,则对当前节点的右子树进行递归查找 225 | else if( compareResult > 0 ) 226 | return contains( x, t.right ); 227 | //若待查找的值等于当前节点值,则说明找到了 228 | else 229 | return true; 230 | } 231 | 232 | - **findMin/findMax操作**:找到二叉查找树中的最小/最大元素,findMin算法过程为:从树的根节点开始,只要左儿子存在,就访问左儿子,直到终止。这个过程用递归和迭代都可以实现。findMax的算法过程与之类似。代码如下: 233 | 234 | //用递归的方法实现findMin 235 | private BinaryNode findMin( BinaryNode t ){ 236 | //若当前节点为null,说明是空树,返回null 237 | if( t == null ) 238 | return null; 239 | //若当前节点没有左儿子,说明当前节点就是最小元素,返回之 240 | else if( t.left == null ) 241 | return t; 242 | //若当前节点有左儿子,则递归地在左子树中查找 243 | return findMin( t.left ); 244 | } 245 | 246 | //用迭代的方法实现findMax 247 | private BinaryNode findMax( BinaryNode t ){ 248 | //若当前节点不为空,则迭代地访问当前节点的右儿子,直到没有右儿子为止,此时该节点就是最大元素 249 | if( t != null ) 250 | while( t.right != null ) 251 | t = t.right; 252 | return t; 253 | } 254 | 255 | - **insert操作**:向二叉查找树中插入新元素。为了将元素插入到合适的位置,可以先使用contain方法进行查找,若找到,说明树中已经存在待插入的元素了,什么都不做即可;若没有找到,则将新元素插入到查找路径的终止点上(该终止点就是新元素应该在的位置)。代码如下: 256 | 257 | private BinaryNode insert( AnyType x, BinaryNode t ){ 258 | //若当前节点为空,则直接创建新节点并返回 259 | if( t == null ) 260 | return new BinaryNode<>( x, null, null ); 261 | //比较待插入元素值与当前节点的值 262 | int compareResult = x.compareTo( t.element ); 263 | /*若待插入元素值小于当前节点的值,说明应该插入到当前节点的左子树上,因此在左子 264 | 树上递归地调用insert方法,方法返回值为插入完成之后的左子树,用其更新原来的左子 265 | 树*/ 266 | if( compareResult < 0 ) 267 | t.left = insert( x, t.left ); 268 | /*若待插入元素值大于当前节点的值,说明应该插入到当前节点的右子树上,因此在右子 269 | 树上递归地调用insert方法,方法返回值为插入完成之后的右子树,用其更新原来的右子 270 | 树*/ 271 | else if( compareResult > 0 ) 272 | t.right = insert( x, t.right ); 273 | //若待插入元素等于当前节点的值,说明待插入元素在树中已存在,则什么都不做 274 | else 275 | ; 276 | //最终返回完成插入操作之后的当前节点 277 | return t; 278 | } 279 | 280 | - **remove操作**:从二叉查找树中删除指定的元素。需要分三种情况考虑: 281 | + 待删除的节点没有儿子(也即它是一片树叶):直接删除该节点即可。 282 | + 待删除的节点有一个儿子:该节点可以在其父节点调整自己的链以绕过该节点后被删除。 283 | + 待删除的节点有两个儿子:可以巧妙地化为第一种或第二种情况:用该节点的右子树中最小的元素(很容易找到)代替该节点,然后递归地删除右子树中最小的元素。由于右子树中最小的元素不可能有左儿子,因此它最多有一个儿子,所以问题转化为了前两种情况。 284 | + remove方法的代码如下: 285 | 286 | private BinaryNode remove( AnyType x, BinaryNode t ){ 287 | //若当前节点为空,则不需要删除,直接返回当前节点 288 | if( t == null ) 289 | return t; 290 | //比较待删除的元素值和当前节点值 291 | int compareResult = x.compareTo( t.element ); 292 | /*若待删除的元素值小于当前节点值,说明待删除的元素在当前节点的左子树中, 293 | 所以对左子树递归地调用remove方法*/ 294 | if( compareResult < 0 ) 295 | t.left = remove( x, t.left ); 296 | /*若待删除的元素值大于当前节点值,说明待删除的元素在当前节点的右子树中, 297 | 所以对右子树递归地调用remove方法*/ 298 | else if( compareResult > 0 ) 299 | t.right = remove( x, t.right ); 300 | /*若待删除元素值等于当前节点值,说明当前节点就是待删除的元素,并且若当前 301 | 节点有两个儿子,则符合上述第三种情况,首先用该节点的右子树中最小的元素代替 302 | 该节点,然后递归地删除右子树中最小的元素*/ 303 | else if( t.left != null && t.right != null ) 304 | { 305 | t.element = findMin( t.right ).element; 306 | t.right = remove( t.element, t.right ); 307 | } 308 | /*若当前节点就是待删除的元素,并且当前节点只有零个或一个儿子,则符合上述 309 | 前两种情况,进行相应操作即可*/ 310 | else 311 | t = ( t.left != null ) ? t.left : t.right; 312 | //最终返回完成删除操作之后的当前节点 313 | return t; 314 | } 315 | 316 | + 注意:上述的删除策略总是倾向于使左子树比右子树更深(因为第三种情况下,是使用右子树的最小元素来代替待删除节点,并最终递归删除右子树的最小元素,这会导致右子树的元素越来越少)。消除这种不平衡可以采用这种做法:每次删除时随机选取右子树的最小元素或左子树的最大元素来代替待删除节点,这样有时左子树的元素减少,有时右子树的元素减少,可以保证一定的平衡性。 317 | + 除了上述的删除策略外,还可以采用**懒惰删除**,即被删除的节点仍旧留在树中,只是被标记为删除。 318 | 319 | - **merge操作**:合并两个二叉查找树。可以先对两棵树分别执行中序遍历(后面有详述),得到两个有序的数组,然后采用归并排序将两个数组合并为一个大的有序数组,最后再将这个大的有序数组重新建立成二叉查找树即可。(注意:最后一步中,由于输入数组是有序的,因此如果采用连续insert操作建立二叉查找树的话,该树会退化成链表,导致后续操作效率很低,故需要将其调整为带有平衡条件的树,如AVL树。关于带有平衡条件的树,后面有详述)。 320 | 321 | ### AVL树 ### 322 | 323 | - 普通的二叉查找树有可能会出现严重的不平衡,例如向一棵空树输入预先排好序的数据,则该树最终将退化成一个链表(所有的节点都只有右儿子,没有左儿子),从而导致该树深度太大,查找、插入等操作的效率很低。 324 | - 为了解决普通二叉查找树的不平衡现象,有很多不同的实现方案,包括最古老的AVL树、伸展树、红黑树等等。 325 | - AVL树是带有平衡条件的二叉查找树,它必须时刻保持树的平衡,也即保证树的深度是O(logN)。它需要满足的平衡条件是:其每个节点的左子树和右子树的高度最多相差1。 326 | - 当向一棵AVL树插入元素时,有可能会破坏平衡条件,因此需要对插入之后的树进行修正,称之为**旋转**,旋转包括**单旋转**和**双旋转**,其中单旋转又包括**左旋转**和**右旋转**,双旋转包括先左旋转再右旋转,或先右旋转再左旋转。旋转的同时还应保持二叉查找树的特性(左儿子 < 节点 < 右儿子)。对于旋转操作的形象化理解,可以这样想象:把树看成是柔软灵活的,抓住作为旋转轴的节点,将其拎起来,在重力的作用下,该节点就成为了新的根节点。单旋转和双旋转的代码示例如下: 327 | 328 | //单旋转(左旋转) 329 | private AvlNode rotateWithLeftChild( AvlNode k2 ){ 330 | //调整旋转涉及到的节点的链,从而完成旋转 331 | AvlNode k1 = k2.left; 332 | k2.left = k1.right; 333 | k1.right = k2; 334 | k2.height = Math.max( height( k2.left ), height( k2.right ) ) + 1; 335 | k1.height = Math.max( height( k1.left ), k2.height ) + 1; 336 | //返回旋转完成之后的根节点(原来根节点的左儿子现在成为了新的根节点) 337 | return k1; 338 | } 339 | 340 | //单旋转(右旋转) 341 | private AvlNode rotateWithRightChild( AvlNode k1 ){ 342 | //调整旋转涉及到的节点的链,从而完成旋转 343 | AvlNode k2 = k1.right; 344 | k1.right = k2.left; 345 | k2.left = k1; 346 | k1.height = Math.max( height( k1.left ), height( k1.right ) ) + 1; 347 | k2.height = Math.max( height( k2.right ), k1.height ) + 1; 348 | //返回旋转完成之后的根节点(原来根节点的右儿子现在成为了新的根节点) 349 | return k2; 350 | } 351 | 352 | //双旋转(先右旋转,再左旋转) 353 | private AvlNode doubleWithLeftChild( AvlNode k3 ){ 354 | //先右旋转 355 | k3.left = rotateWithRightChild( k3.left ); 356 | //再左旋转 357 | return rotateWithLeftChild( k3 ); 358 | } 359 | 360 | //双旋转(先左旋转,再右旋转) 361 | private AvlNode doubleWithRightChild( AvlNode k1 ){ 362 | //先左旋转 363 | k1.right = rotateWithLeftChild( k1.right ); 364 | //再右旋转 365 | return rotateWithRightChild( k1 ); 366 | } 367 | 368 | 向AVL树插入元素的示例代码如下: 369 | 370 | private AvlNode insert( AnyType x, AvlNode t ){ 371 | //以下代码与普通二叉查找树的插入实现代码一样 372 | if( t == null ) 373 | return new AvlNode<>( x, null, null ); 374 | int compareResult = x.compareTo( t.element ); 375 | if( compareResult < 0 ) 376 | t.left = insert( x, t.left ); 377 | else if( compareResult > 0 ) 378 | t.right = insert( x, t.right ); 379 | else 380 | ; 381 | 382 | //插入完成之后,要通过旋转操作进行修正,使AVL树重新满足平衡条件(balance方法代码略) 383 | return balance( t ); 384 | } 385 | 386 | 从AVL树中删除元素的示例代码如下: 387 | 388 | private AvlNode remove( AnyType x, AvlNode t ){ 389 | //以下代码与普通二叉查找树的删除实现代码一样 390 | if( t == null ) 391 | return t; 392 | int compareResult = x.compareTo( t.element ); 393 | if( compareResult < 0 ) 394 | t.left = remove( x, t.left ); 395 | else if( compareResult > 0 ) 396 | t.right = remove( x, t.right ); 397 | else if( t.left != null && t.right != null ) 398 | { 399 | t.element = findMin( t.right ).element; 400 | t.right = remove( t.element, t.right ); 401 | } 402 | else 403 | t = ( t.left != null ) ? t.left : t.right; 404 | 405 | //删除完成之后,要通过旋转操作进行修正,使AVL树重新满足平衡条件(balance方法代码略) 406 | return balance( t ); 407 | } 408 | 409 | ### 伸展树 ### 410 | 411 | 伸展树的思路是:允许单次操作花费O(N)时间,但是每次访问一个节点之后,会将该节点上推到树根(通过一系列的旋转操作),使得该节点及其路径上的其他节点在下次访问时可以快很多。因此伸展树不保证每次操作都花费O(logN)时间,但是可以保证连续M次操作的总时间为O(M * logN)。也就是说,伸展树每次操作的**摊还代价**仍是O(logN)。伸展树的具体分析比较复杂,此处省略。 412 | 413 | ### 红黑树 ### 414 | 415 | - 红黑树与AVL树类似,也是带有某些平衡条件的二叉查找树。它的每个节点上都有存储位表示节点颜色,可以是红色或黑色。 416 | - 红黑树应满足的特性此处省略,但应记住:它的特性确保了树中没有一条路径会比其他路径长出一倍以上,因此红黑树是接近于平衡的。 417 | - 在对红黑树进行插入和删除操作后,与AVL树类似,也需要进行旋转和重新着色等操作来修正该树。旋转包括左旋转和右旋转,与AVL树的左旋转和右旋转操作方式一样。 418 | - 红黑树与AVL树的比较 419 | + AVL树相对简单,红黑树比较复杂,尤其是红黑树的插入和删除操作,要考虑很多情况。 420 | + AVL树是高度平衡的树(任一节点的左右子树高度差不能超过1),每一次对树的插入和删除操作都需要进行修正;红黑树的平衡程度要比AVL树略低(任意两个节点到根节点的路径相差不超过一倍),每次插入最多只需要两次旋转,删除最多只需要三次旋转。 421 | + 由于AVL树是高度平衡的,因此其查找效率比较稳定;红黑树要略差一些。 422 | 423 | - 红黑树的应用: 424 | + 关联数组的实现 425 | + Java中的TreeSet和TreeMap:这两种容器的底层实现都是红黑树,因此容器中的元素是有序的,并且组织树是接近平衡的,所以对TreeSet和TreeMap执行查找、插入和删除操作都只花费O(logN)时间。 426 | + C++ STL中的set和map 427 | + Linux中的虚拟内存管理 428 | 429 | ### 树的遍历 ### 430 | 431 | 树的遍历包括先序遍历(中左右)、中序遍历(左中右)、后序遍历(左右中),以及不常用的层序遍历。先序/中序/后序遍历的示例代码如下所示: 432 | 433 | //树的遍历的经典递归写法 434 | private void printTree( BinaryNode t ){ 435 | //若当前节点不为空,则开始遍历 436 | if( t != null ){ 437 | //接下来三行代码可以任意调整顺序,从而实现先序/中序/后序遍历。此处为中序遍历(左中右) 438 | printTree( t.left ); 439 | System.out.println( t.element ); 440 | printTree( t.right ); 441 | } 442 | } 443 | 444 | 对于二叉查找树,执行一次中序遍历得到的是排好序的元素,这是排序算法的一种。 445 | 446 | ### B树 ### 447 | 448 | - 上述讨论的所有种类的树都是放在主存中的。但是如果数据太多导致主存放不下,就需要将数据放在磁盘上。这时,影响树的操作效率的关键就不再是算法的时间复杂度了,而是对磁盘的I/O访问次数。相比一条算法指令的执行速度,对于磁盘的一次访问速度要慢的多,因此应该尽可能减少磁盘的访问次数。解决方法很简单:允许树有较多的分支,从而树的高度会降低,所以访问树中节点的磁盘I/O次数也会减少。 449 | - B树就是这样的一种多叉树,它的具体特性此处省略。不过要注意:对于一棵B树,为了不让其退化成二叉树甚至是链表,应保证其任意节点(除根节点外)的儿子数至少要达到最大容量的一半,并且叶子节点存储的数据项个数也至少要达到最大容量的一半。 450 | - 在对B树执行插入操作时,若数据项插入到的叶子节点已经满员,则需要将该叶子节点**分裂**成两个叶子节点;若此时该叶子节点的父节点也已满员,则继续分裂该父节点;以此类推,直至分裂到根节点。 451 | - 在对B树执行删除操作时,若删除数据项之后,该叶子节点存储的数据项个数达不到最大容量的一半,则需要从相邻叶子节点**领养**元素。 452 | - 插入时的分裂操作和删除时的领养操作都需要重新调整B树的结构,也即需要额外的磁盘I/O访问。但是经过一次分裂/领养之后,接下来的若干次插入/删除都无需再进行分裂/领养了,因此这点额外的代价是值得的。 453 | 454 | 455 | ## 散列 ## 456 | 457 | ### 基本概念与理论 ### 458 | 459 | - **散列表**是一种将元素值经过**散列函数**计算后映射到相应的数组单元(散列表的底层实现是具有固定长度的数组)的数据结构。它执行插入、删除和查找操作都花费常数平均时间(执行这些操作只需要计算散列函数,然后直接访问对应的数组单元即可。但是如果散列表填的太满,会导致额外的操作开销,则时间界将不再有效)。 460 | - 散列表的关键在于以下三点: 461 | + **散列函数**:散列函数将元素值映射到0到TableSize-1这个范围中的某个整数,也即映射到散列表中的某个单元。理想的散列函数可以将元素均匀地分配到散列表的各个单元,而不会产生“扎堆”的现象。 462 | + **冲突的解决**:当一个元素被插入时与另一个已经插入的元素散列到了相同的单元,那么就产生了冲突,这个冲突需要解决。主要有两种解决冲突的方式:**分离链接法**和**开放定址法**。 463 | + **散列表的大小**:如何确定散列表的大小是很重要的,若处理不当容易产生扎堆的现象(例如散列函数为`元素值 mod 散列表的大小`,若散列表的大小为10,而元素值都是10的整数倍,那么所有的元素都会扎堆映射到第一个单元)。所以一般设置散列表的大小为**素数**。 464 | + 一个散列函数的示例代码如下所示: 465 | 466 | public static int hash( String key, int tableSize ){ 467 | //以下代码将元素值key经过一系列处理计算得到散列值hashVal,并最终返回 468 | int hashVal = 0; 469 | for( int i = 0; i < key.length( ); i++ ) 470 | hashVal = 37 * hashVal + key.charAt( i ); 471 | hashVal %= tableSize; 472 | if( hashVal < 0 ) 473 | hashVal += tableSize; 474 | return hashVal; 475 | } 476 | 477 | ### 冲突解决 ### 478 | 479 | ##### 分离链接法 ##### 480 | 481 | - 分离链接法对于冲突的解决方式是将散列到同一个单元的所有元素保存到一个双向链表LinkedList中,因此散列表的底层实现是一个链表数组。 482 | - 对于查找操作,首先计算散列函数,确定待查找的元素处于哪个链表中;然后在该链表中再执行一次查找。示例代码如下: 483 | 484 | public boolean contains( AnyType x ){ 485 | //先计算散列函数hash,确定待查找元素处在哪个链表中 486 | List whichList = theLists[ hash( x ) ]; 487 | //在这个链表中查找该元素,调用的是链表的contains方法 488 | return whichList.contains( x ); 489 | } 490 | 491 | - 对于插入和删除操作,也是先计算散列函数,确定具体对应于哪个链表;然后在该链表中进行插入和删除操作。示例代码如下: 492 | 493 | //插入操作 494 | public void insert( AnyType x ){ 495 | //先计算散列函数,确定具体在哪个链表 496 | List whichList = theLists[ hash( x ) ]; 497 | //若待插入的元素在该链表中不存在,则插入该链表中(调用链表的add方法) 498 | if( !whichList.contains( x ) ) 499 | { 500 | whichList.add( x ); 501 | // 若散列表的大小不够用,要进行“再散列”操作,即扩充散列表的大小,后面有详述 502 | if( ++currentSize > theLists.length ) 503 | rehash( ); 504 | } 505 | } 506 | 507 | //删除操作 508 | public void remove( AnyType x ){ 509 | //先计算散列函数,确定具体在哪个链表 510 | List whichList = theLists[ hash( x ) ]; 511 | //若该链表中包含待删除的元素,则删除之(调用链表的remove方法) 512 | if( whichList.contains( x ) ){ 513 | whichList.remove( x ); 514 | currentSize--; 515 | } 516 | } 517 | 518 | - 散列表的**装填因子λ**指的是散列表中的元素个数与散列表长度之比。因此对于分离链接法的散列表,链表的平均长度就是λ。λ越长,则一次查找平均要遍历的链表节点就越多,效率就越低。因此散列表的大小并不重要,而装填因子λ才是最重要的。一般来说,对于分离链接法的散列表,取λ=1(也即平均每个链表包含一个元素)效果较好。 519 | 520 | ##### 开放定址法 ##### 521 | 522 | - 开放定址法对于冲突的解决方式是尝试重新分配另外一些单元,直到找出空的单元为止。分离链接法允许一个单元存放多个元素(以链表的形式组织),而开放定址法只允许一个单元存放一个元素。因此对于采用开放定址法的散列来说,一般其装填因子λ要小于0.5,才能保证有足够的空闲单元供重新分配。所以若存放相同数量的元素,开放定址法所需的散列表大小比分离链接法所需的要大一些。 523 | - 开放定址法探测并分配新单元的策略具体有以下三种: 524 | + **线性探测法**:第i次探测的是从原单元开始往后第k * i个单元。例如相继探测原单元往后的第3、6、9、12、...个单元,直到找到空闲单元为止。这种探测方式会存在**一次聚集**的问题,也就是说对于新地址的探测也会产生扎堆现象(探测到相同的新单元),导致之后插入的元素需要经过很多次探测才能找到空闲单元。 525 | + **平方探测法**:第i次探测的是从原单元开始往后第k * i^2个单元。例如相继探测原单元往后的第1、4、9、16、...个单元,直到找到空闲单元位置。这种探测方式可以解决一次聚集问题,但会导致**二次聚集**的问题。对于平方探测法,可以证明:若表的大小为素数,那么当表的装填因子λ < 0.5时,一定能够插入一个新的元素。这也说明了若表中的空闲单元不到一半(λ > 0.5),则插入有可能失败。 526 | + **双散列**:第i次探测的是从原单元开始往后第f(i)个单元,其中f(i)是另一个散列函数。这个散列函数必须要选择得好——一定不能计算出0值,否则会一直停留在原单元;并且要能保证所有的空闲单元都能被探测到。 527 | 528 | 529 | ### 再散列 ### 530 | 531 | 无论是分离链接法和开放定址法,当散列表填的太满时,操作的运行时间都会开始变长(对于分离链接法,链表太长会导致查找效率降低;对于开放定址法,执行插入操作要经过更多次探测才能找到空闲单元,甚至有可能插入失败)。所以此时需要对散列表进行扩容,一般是建立一个大约两倍大的新表,并使用一个相关的新散列函数,将原表中的元素散列到新表中。这一操作称为**再散列**(rehash)。再散列一般有以下三种策略: 532 | 533 | + 只要表填满一半就进行再散列。 534 | + 当插入失败时再执行再散列。 535 | + 当散列表达到某一个装填因子λ时进行再散列。 536 | 537 | ### 可扩散列 ### 538 | 539 | 当数据量太大以至于主存中无法存放时,需要考虑的是如何减少磁盘I/O访问的次数(如同B树一样),此时可以采用**可扩散列**,它由**目录**和**数据**两部分组成,其中数据是以树叶的形式组织的。每个目录项指向一片树叶(树叶由具有相同前缀的数据构成,例如目录项“00”指向一片树叶,该树叶包含“00110”、“00100”等以“00”为前缀的数据)。当向可扩散列插入数据时,若要插入的树叶已满,则需要对树叶执行**分裂**(类似于B树执行插入操作时的分裂)。最后需要注意:可扩散列仅使用两次磁盘访问执行一次查找,并且插入操作也仅需要很少的磁盘访问。 540 | 541 | ### 散列的应用 ### 542 | 543 | - 如果在组织数据时,不需要有序的信息,或者不确定输入数据是否已被排序,则应该优先选择散列这种数据结构,因为它执行查找、插入和删除操作平均只花费常数时间(在散列函数设计良好的情况下)。而若需要有序地组织数据,则应该优先选用二叉查找树,它的平均时间界是O(logN)。 544 | - Java中的HashSet和HashMap底层是由散列(分离链接法)实现的,这两种容器类型执行查找、插入和删除平均只花费常数时间,一般来说比TreeSet和TreeMap的对数时间界在性能上要更优一些。此外,HashSet中存储的项和HashMap中存储的关键字必须实现equals和hashCode方法,从而可以完成散列值的计算和比较。 545 | - 散列表的应用场景举例: 546 | + 编译器使用散列表来跟踪源代码中声明的变量,因为这些变量不需要有序排列。 547 | + 某些图论问题。 548 | + 实时拼写检验程序:可以预先将整个词典散列,则实时输入的单词可以以常数时间被检测。 549 | 550 | 551 | ## 优先队列(堆) ## 552 | 553 | ### 基本概念及理论 ### 554 | 555 | - 普通的队列只能按元素的存放顺序依次返回队头的元素,而返回最小(或最大)元素的操作代价较高。**优先队列(堆)**则支持高效地找到并返回最小(最大)的元素。 556 | - 堆支持的操作主要包括**插入**和**删除最小元素**。 557 | 558 | ### 二叉堆 ### 559 | 560 | - **二叉堆**是最常见的堆的实现,它是一棵**完全二叉树**,并且满足**堆序性质**:每个节点的值 <= 其子节点的值。 561 | - 由于二叉堆是完全二叉树,而完全二叉树非常有规律(层序遍历一棵完全二叉树,则访问到的第i个元素的左儿子为第2i个元素、右儿子为第2i + 1个元素、父节点为第i/2个元素),所以二叉堆可以由最简单的数组来实现,无需使用链。数组中第i个元素的左儿子、右儿子、父节点分别是第2i、2i + 1、i/2个元素。 562 | - **插入insert**:当插入一个元素时,首先在堆的下一个可用位置(即最底层从左往右数第一个空位置)创建一个空穴hole,如果新元素可以放在该空穴位置而并不破坏堆序性质,则插入完成;否则交换该空穴与其父节点,这一步称之为**上滤**(空穴向上移动一层,其父节点相应地向下填补位置),这样空穴就朝着根的方向前进了一层,继续该上滤过程,直到新元素可以被放入空穴而不破坏堆序性质为止。平均而言,一次插入操作需要执行1.607次上滤,也即平均花费常数时间O(1);最坏情况下,空穴要上滤到根节点,因此最坏运行时间为O(logN)。示例代码如下: 563 | 564 | //二叉堆的插入操作 565 | public void insert( AnyType x ){ 566 | //若堆已满,则扩大一倍容量 567 | if( currentSize == array.length - 1 ) 568 | enlargeArray( array.length * 2 + 1 ); 569 | //创建空穴hole 570 | int hole = ++currentSize; 571 | /*若新元素小于空穴的父节点元素,则说明将新元素放在空穴会破坏堆序性质,需要执行 572 | 上滤,并迭代这个过程*/ 573 | for( ; x.compareTo( array[ hole / 2 ] ) < 0; hole /= 2 ) 574 | array[ hole ] = array[ hole / 2 ]; 575 | //当新元素大于空穴的父节点元素时,上述循环终止,将新元素放在空穴中,完成插入 576 | array[ hole ] = x; 577 | } 578 | 579 | - **删除最小元素deleteMin**:根据二叉堆的性质,最小元素就是根节点元素。删除它以后,在根节点建立一个空穴,然后检测堆中的最后一个元素(最底层的最右元素)放在空穴中是否会破坏堆序性质,若会破坏,则交换空穴与它的两个儿子中的较小者,这一步称之为**下滤**(空穴向下移动一层,其较小的儿子相应地向上填补位置),重复该步骤,直到最后一个元素可以被放到空穴中而不破坏堆序性质为止。平均而言,空穴要下滤到堆的最底层,因此平均花费O(logN)时间;最坏情况下,空穴也是要下滤到堆的最底层,因此最坏运行时间也是O(logN)。示例代码如下: 580 | 581 | //二叉堆的删除最小元素操作 582 | public AnyType deleteMin( ){ 583 | //处理二叉堆为空的异常情况 584 | if( isEmpty( ) ) 585 | throw new UnderflowException( ); 586 | //找到最小元素(根元素)并删除 587 | AnyType minItem = findMin( ); 588 | array[ 1 ] = array[ currentSize-- ]; 589 | //执行下滤 590 | percolateDown( 1 ); 591 | //返回最小元素 592 | return minItem; 593 | } 594 | 595 | //执行下滤操作 596 | private void percolateDown( int hole ){ 597 | int child; 598 | AnyType tmp = array[ hole ]; 599 | //迭代进行下滤操作 600 | for( ; hole * 2 <= currentSize; hole = child ){ 601 | child = hole * 2; 602 | //取左右儿子中较小的一个 603 | if( child != currentSize && array[ child + 1 ].compareTo( array[ child ] ) < 0 ) 604 | child++; 605 | if( array[ child ].compareTo( tmp ) < 0 ) 606 | array[ hole ] = array[ child ]; 607 | else 608 | break; 609 | } 610 | array[ hole ] = tmp; 611 | } 612 | 613 | - **建堆buildHeap**:第一种方法是执行一连串的insert操作来建堆,这样每次insert之后都能保证堆序性质,它的平均运行时间是O(N),而最坏情况是O(N * logN)。第二种方法是先将所有元素以任意顺序放入堆中(不保证堆序性质),然后对前半部分元素逐个执行下滤操作。可以证明,这种方法的运行时间界是O(N)。第二种方法的示例代码如下: 614 | 615 | //建堆操作 616 | private void buildHeap( ){ 617 | //对前半部分元素逐个执行下滤操作 618 | for( int i = currentSize / 2; i > 0; i-- ) 619 | percolateDown( i ); 620 | } 621 | 622 | - **降低关键字的值decreaseKey**:在降低一个元素关键字的值后,可以通过上滤操作来使该元素上移,从而保证堆序性质。这一操作的应用场景为:管理员可以使某个程序以较高的优先级来运行(元素上移表示优先级升高,根节点的优先级最高)。 623 | - **增加关键字的值increaseKey**:与decreaseKey相反,它可以通过下滤操作来调整堆序性质。其应用场景为:操作系统调度程序自动降低消耗过多资源的进程的优先级。 624 | - **删除delete**:删除堆中某个特定的元素p,可以首先执行decreaseKey(p,∞)来将该元素上移至根节点处,再执行deleteMin完成删除。其应用场景为:用户强行中止了一个正在运行的进程,则该进程必须立即从优先队列中删除。 625 | - **堆排序**:对一个包含N个元素的堆执行N次deleteMin操作,会得到排好序的元素,这种排序方式叫做堆排序,它执行的时间为N次deleteMin操作所花费的O(N * logN)加上建堆的时间O(N),即O(N + N * logN),比O(N * logN)略大。 626 | - **最大堆与最小堆**:上述性质与操作针对的是**最小堆**,也即父元素 < 子元素的堆。此外也可以使用父元素 > 子元素的堆,称之为**最大堆**,最大堆的根节点是其最大元素,相应地,它没有deleteMin操作,而是deleteMax操作。 627 | 628 | ### d-堆 ### 629 | 630 | - 二叉堆只允许一个节点最多有两个儿子,而**d-堆**是对二叉堆的简单扩展,它允许一个节点有d个儿子。d-堆是一棵完全d叉树,而二叉堆实质上是2-堆。 631 | - d-堆的应用场景与B树和可扩散列类似,都是用来解决当数据量太大而装不进主存的情形的。当取较大的d时,d-堆的高度比二叉堆的高度要浅的多,因此可以减少磁盘访问次数。 632 | - 由于d-堆是完全d叉树,因此也是非常有规律的,也可以简单地使用数组来实现。 633 | - 实践证明:4-堆的效果要好于二叉堆。 634 | 635 | ### 左式堆、斜堆和二项队列 ### 636 | 637 | - 由于二叉堆和d-堆的底层实现是简单的数组,因此执行两个堆的**合并**比较困难,因为这需要把一个数组拷贝到另一个数组中,并且每个元素都要找到合适的位置。事实上,所有支持高效合并操作的数据结构都应当使用链式数据结构。 638 | - **左式堆**是非常不平衡的堆(严重左偏)。它执行合并操作的时间为O(logN)。 639 | - **斜堆**是左式堆的自调节形式,斜堆与左式堆的关系类似于伸展树和AVL树之间的关系——斜堆执行合并操作的摊还时间为O(logN)。 640 | - **二项队列**是由若干个满足堆序的树(**二项树**)所组成的**森林**。每一个高度上最多存在一棵二项树。高度为0的二项树是一棵单节点树;高度为k的二项树由一棵高度为k - 1的二项树附接到另一棵高度为k - 1的二项树的根上而构成。它执行合并操作效率更高,最坏情况下也仅需花费O(logN)时间。 641 | 642 | ### Java中的优先队列实现 ### 643 | 644 | Java 1.5及更高版本包含了泛型类PriorityQueue,该类支持insert、findMin、deleteMin等操作(分别对应于add()、element()、remove()方法)。该类足够应付大多数优先队列的应用要求。 645 | 646 | 647 | ## 其他补充 ## 648 | 649 | - **广义表**,参见[http://see.xidian.edu.cn/cpp/html/971.html](http://see.xidian.edu.cn/cpp/html/971.html) -------------------------------------------------------------------------------- /排序算法.md: -------------------------------------------------------------------------------- 1 | # 排序算法 # 2 | 3 | 本文介绍几种常用的排序算法,包括冒泡排序、插入排序、希尔排序、堆排序、归并排序、快速排序、桶式排序、外排序等。 4 | 5 | ## 冒泡排序 ## 6 | 7 | - 冒泡排序是最简单、最为直观的一种排序算法。它的步骤是:通过N趟往返,每趟都把剩余的最大元素交换到末尾;其中每趟又需要至多N次比较才能将最大元素交换到末尾(因为每次比较只是交换相邻元素)。所以整个算法花费的时间是O(N^2)。 8 | - 冒泡排序的示例代码如下所示: 9 | 10 | //冒泡排序(从小到大排序) 11 | public void bubbleSort(AnyType [] a) { 12 | // TODO Auto-generated method stub 13 | AnyType tmp; 14 | //迭代进行N趟往返,每趟都把剩余的最大元素交换到末尾 15 | for (int i = a.length-1; i > 0; i--) { 16 | //每趟往返中,也需要进行至多N次相邻元素的比较,才能将最大元素交换至末尾 17 | for (int j = 0; j < i; j++) { 18 | //若a[j]大于a[j+1],则交换两者,相当于把较大元素a[j]往后移一位(向末尾靠近了一位) 19 | if (a[j].compareTo(a[j+1]) > 0) { 20 | tmp = a[j]; 21 | a[j] = a[j+1]; 22 | a[j+1] = tmp; 23 | } 24 | } 25 | } 26 | } 27 | 28 | 29 | ## 插入排序 ## 30 | 31 | - 插入排序的思想是:通过p=1到p=N-1这总共N-1趟往返,每次都保证前p+1个元素是排好序的。算法的具体实施步骤是:在第p趟,将第p+1个元素逐位向左比较,直到找到正确的位置为止。每一趟至多左移p位,而1 <= p <= N-1,因此整个算法花费的时间是O(N^2)。 32 | - 插入排序的示例代码如下所示: 33 | 34 | //插入排序(从小到大排序) 35 | public static > void insertionSort( AnyType [ ] a ){ 36 | int j; 37 | //迭代进行N趟往返,其中第p趟之后要保证前p+1个元素是已排好序的 38 | for( int p = 1; p < a.length; p++ ) 39 | { 40 | AnyType tmp = a[ p ]; 41 | /*在第p趟,若第p+1个元素(即a[p])小于前面相邻的元素,则将前面相邻的元素 42 | 后移一位,从而为a[p]空出该位置。迭代此过程,直到a[p]找到合适的位置为止*/ 43 | for( j = p; j > 0 && tmp.compareTo( a[ j - 1 ] ) < 0; j-- ) 44 | a[ j ] = a[ j - 1 ]; 45 | //把a[p]放在合适的位置上,第p趟结束。此时前p+1个元素是已经按序排好的 46 | a[ j ] = tmp; 47 | } 48 | } 49 | 50 | - 对冒泡排序和插入排序的分析:排序的过程实际上是消除序列中的**逆序**的过程。对于冒泡排序和插入排序,由于每次交换的都是相邻元素,因此每次只能消除一个逆序。而可以证明,N个互异数的数组的平均逆序数是N(N-1)/4,即O(N^2),因此这两种采用交换相邻元素进行排序的算法的时间界是O(N^2)。 51 | 52 | 53 | ## 希尔排序 ## 54 | 55 | - 希尔排序的思想是:每趟都比较相距一定间隔的元素,保证这些元素是排好序的;各趟选取的间距逐渐减小,直到最后一趟间距变为1(即相邻元素),从而完成排序。希尔排序的过程有点类似于“隔行扫描”。 56 | 57 | 例如对10个元素的序列排序,第一趟间距取5,则第一趟结束后第1个、第6个元素是排好序的,第2个、第7个元素是排好序的,以此类推;第二趟间距取3,则第二趟结束后第1个、第4个、第7个元素是排好序的,第2个、第5个、第8个元素是排好序的,以此类推;第三趟(最后一趟)间距取1,则第三趟结束后第1个到第10个元素就是排好序的了,排序完成。 58 | 59 | - 希尔排序的示例代码如下所示: 60 | 61 | //希尔排序(从小到大排序) 62 | public static > void shellsort( AnyType [ ] a ) 63 | { 64 | int j; 65 | /*进行若干趟,每趟将相距一定间隔(gap)的元素排好序。各趟选用的间距逐渐减小 66 | 直至为1。此处,间隔初始选为序列长度的一半,每趟将该间隔减半,直至为1*/ 67 | for( int gap = a.length / 2; gap > 0; gap /= 2 ) 68 | /*每一趟中,完成对所有子序列的排序(例如gap=5时,a[0]、a[5]是一个子序列, 69 | a[1]、a[6]是一个子序列)*/ 70 | for( int i = gap; i < a.length; i++ ) 71 | { 72 | //对每个子序列进行插入排序(注意这里就不是比较相邻元素了,而是比较间距为gap的元素) 73 | AnyType tmp = a[ i ]; 74 | for( j = i; j >= gap && tmp.compareTo( a[ j - gap ] ) < 0; j -= gap ) 75 | a[ j ] = a[ j - gap ]; 76 | a[ j ] = tmp; 77 | } 78 | } 79 | 80 | - 希尔排序的运行时间取决于对间距gap的选取策略。一般的选取策略如上述示例代码一样,初始选为序列长度的一半,然后每次减半,直至为1。对于不同的gap选取策略,算法的时间界是不同的: 81 | + 对于上述“逐次减半”的选取策略,其最坏情形花费时间为Θ(N^2)。 82 | + 对于Hibbard选取策略,其最坏情形花费时间为Θ(N^1.5)。 83 | 84 | 85 | ## 堆排序 ## 86 | 87 | - 堆排序的思想是利用优先队列(堆)的特性,首先将序列建堆,然后执行一系列的deleteMin操作,每次deleteMin都将剩余的最小元素取出,这样最后就得到排好序的序列。具体实施时有两种方法:第一种方式是每次deleteMin后都将最小元素存放到一个新数组中,最后再将新数组中的结果拷贝回原数组,这种方式会使用一个额外的数组空间;第二种方式巧妙地解决了额外空间开销问题——由于每次deleteMin之后,堆缩小1,堆中的最后一个单元空了出来,因此可以把刚刚得到的最小元素放到堆中的最后一个单元,这样就不需要额外开辟新数组来存放结果了。 88 | - 堆排序的示例代码如下所示: 89 | 90 | //堆排序(从小到大排序,因此这里采用最大堆) 91 | public static > void heapsort( AnyType [ ] a ) 92 | { 93 | //首先将序列建堆(建堆方式是对前半部分元素逐个执行下滤操作) 94 | for( int i = a.length / 2 - 1; i >= 0; i-- ) 95 | percDown( a, i, a.length ); 96 | //进行N次deleteMin操作,完成排序 97 | for( int i = a.length - 1; i > 0; i-- ) 98 | { 99 | /*deleteMin操作,具体是将位于根节点的最小元素a[0]取出,放在堆中最后一个单 100 | 元;同时将原本最后一个单元的元素放到根节点a[0]处。(实际上就是将a[0]和a[i] 101 | 互换了)*/ 102 | swapReferences( a, 0, i ); 103 | //通过执行下滤操作来调整堆序性质,为下一次deleteMin操作做好准备 104 | percDown( a, 0, i ); 105 | } 106 | } 107 | 108 | //下滤操作 109 | private static > void percDown( AnyType [ ] a, int i, int n ){ 110 | int child; 111 | AnyType tmp; 112 | 113 | for( tmp = a[ i ]; leftChild( i ) < n; i = child ) 114 | { 115 | child = leftChild( i ); 116 | if( child != n - 1 && a[ child ].compareTo( a[ child + 1 ] ) < 0 ) 117 | child++; 118 | if( tmp.compareTo( a[ child ] ) < 0 ) 119 | a[ i ] = a[ child ]; 120 | else 121 | break; 122 | } 123 | a[ i ] = tmp; 124 | } 125 | 126 | - 堆排序算法首先花费O(N)时间用来建堆,然后执行N次deleteMin操作,每次deleteMin操作平均花费O(logN)时间,因此总的平均花费时间是O(N + N * logN),即O(N * logN)。此外,经验表明,堆排序是一个很稳定的算法,它花费的平均时间仅比最坏时间略少。 127 | 128 | 129 | ## 归并排序 ## 130 | 131 | - 归并排序的思想是:将两个已排序的子表进行合并,得到最终的排序结果;而为了得到已排序的子表,需要递归地对两个子表执行归并排序。该算法是经典的**分治策略**,它将问题分成一些等价的子问题然后递归求解,然后将子问题的解合并修补在一起,得到最终结果。 132 | - 归并排序算法的具体过程是:递归地将序列的前半部分和后半部分各自归并排序,得到排序之后的两部分数据,然后将这两部分合并在一起。合并的方式是:设两个子数组分别是A和B,创建一个长度为A与B之和的输出数组C,以及三个游标Actr、Bctr、Cctr,初始时这三个游标分别对应于三个数组的开始端。比较A[Actr]和B[Bctr],将较小者拷贝到C[Cctr]位置,对应的Actr或Bctr向前推进一步,Cctr也向前推进一步;重复这个过程,直到两个输入数组A和B有一个用完的时候,则将另一个的剩余部分拷贝到C中。 133 | - 归并排序的示例代码如下: 134 | 135 | //归并排序(从小到大排序) 136 | public static > void mergeSort( AnyType [ ] a ){ 137 | //创建输出数组(即上述算法描述中的C) 138 | AnyType [ ] tmpArray = (AnyType[]) new Comparable[ a.length ]; 139 | //调用私有的mergeSort方法,传入输出数组tmpArray、待排序数组的开头位置和末尾位置等参数 140 | mergeSort( a, tmpArray, 0, a.length - 1 ); 141 | } 142 | 143 | //私有的归并排序方法,可传入输出数组tmpArray、待排序序列的开头位置left、末尾位置right等参数 144 | private static > 145 | void mergeSort( AnyType [ ] a, AnyType [ ] tmpArray, int left, int right ){ 146 | /*若开头位置小于末尾位置,则可以将数据分成前半部分和后半部分,分别对两部分递归地 147 | 执行归并排序,然后将结果合并起来*/ 148 | if( left < right ) 149 | { 150 | int center = ( left + right ) / 2; 151 | //对前半部分递归调用 152 | mergeSort( a, tmpArray, left, center ); 153 | //对后半部分递归调用 154 | mergeSort( a, tmpArray, center + 1, right ); 155 | //将排好序的前后两部分合并 156 | merge( a, tmpArray, left, center + 1, right ); 157 | } 158 | } 159 | 160 | //将前后两部分进行合并的方法 161 | private static > 162 | void merge( AnyType [ ] a, AnyType [ ] tmpArray, int leftPos, int rightPos, int rightEnd ) 163 | { 164 | int leftEnd = rightPos - 1; 165 | int tmpPos = leftPos; 166 | int numElements = rightEnd - leftPos + 1; 167 | 168 | //依次比较前半部分和后半部分的元素,将较小者存放到输出数组tmpArray中,相应的游标向前推进一步 169 | while( leftPos <= leftEnd && rightPos <= rightEnd ) 170 | if( a[ leftPos ].compareTo( a[ rightPos ] ) <= 0 ) 171 | tmpArray[ tmpPos++ ] = a[ leftPos++ ]; 172 | else 173 | tmpArray[ tmpPos++ ] = a[ rightPos++ ]; 174 | 175 | //将前半部分的剩余部分拷贝至tmpArray中 176 | while( leftPos <= leftEnd ) 177 | tmpArray[ tmpPos++ ] = a[ leftPos++ ]; 178 | 179 | //将后半部分的剩余部分拷贝至tmpArray中 180 | while( rightPos <= rightEnd ) 181 | tmpArray[ tmpPos++ ] = a[ rightPos++ ]; 182 | 183 | //将tmpArray中的元素拷贝回原数组a中 184 | for( int i = 0; i < numElements; i++, rightEnd-- ) 185 | a[ rightEnd ] = tmpArray[ rightEnd ]; 186 | } 187 | 188 | - 归并排序的运行时间是O(N * logN),该运行时间的计算过程略。 189 | 190 | 191 | ## 快速排序 ## 192 | 193 | - 快速排序可以看做是加强版的归并排序。在归并排序中,递归的两个子数组是大小相等的;而在快速排序中,递归的两个子数组是大小不相等的,要通过一定的策略来划分这两个子数组。 194 | - 快速排序的算法过程是:首先在待排序数组中选定一个**枢纽元**,然后将数组划分为两部分,一个部分中的所有元素均小于该枢纽元,另一个部分中的所有元素均大于该枢纽元;然后递归地在这两个子部分中调用该过程,完成排序。 195 | - 枢纽元的选取有很多种策略:可以随机选取,但生成随机数的代价较大。此外也可以采用**三数中值分割法**——取左端、右端和中心位置这三个元素的中值为枢纽元。例如左端元素是1,右端元素是5,中心元素是23,则枢纽元取5。 196 | - 选好枢纽元后,要将数组划分为两部分,经典的划分策略是:首先把枢纽元与最后一个元素交换,使得枢纽元离开要被分割的区域,防止干扰;接着设定两个游标i和j,其中i指向第一个元素,j指向倒数第二个元素(倒数第一个元素现在是枢纽元了);然后将i右移,移过那些小于枢纽元的元素(因为这些小元素本来就应该在左边),同时将j左移,移过那些大于枢纽元的元素(因为这些大元素本来就应该右边);当i和j停止时,i指向一个大元素,j指向一个小元素,然后将这两个元素互换,效果就是把一个大元素放在右边,把一个小元素放在左边;继续i右移、j左移的过程,直到i和j交错则停止移动;最后把枢纽元与i所指向的元素交换,使得枢纽元回到这段数据中间。这样一次划分之后,枢纽元左边的元素均小于枢纽元,右边的元素均大于枢纽元。 197 | - 快速排序的示例代码如下: 198 | 199 | //快速排序(从小到大排序) 200 | public static > 201 | void quicksort( AnyType [ ] a ){ 202 | //调用私有的quicksort方法,传入待排序序列的开头位置和末尾位置等参数 203 | quicksort( a, 0, a.length - 1 ); 204 | } 205 | 206 | //私有的快速排序方法,可传入待排序序列的开头位置、末尾位置等参数 207 | private static > 208 | void quicksort( AnyType [ ] a, int left, int right ){ 209 | //若数组长度较大(大于阈值CUTOFF),则采用快速排序;否则采用简单的插入排序即可 210 | if( left + CUTOFF <= right ) 211 | { 212 | //指定枢纽元(这里采用三数中值分割法),并将枢纽元与最后一个元素互换 213 | AnyType pivot = median3( a, left, right ); 214 | 215 | //指定游标i和j,并初始化使得i指向第一个元素(最左端),j指向倒数第二个元素 216 | (除枢纽元外的最右端元素)*/ 217 | int i = left, j = right - 1; 218 | //开始划分为左右两部分的迭代过程 219 | for( ; ; ){ 220 | //i从左往右扫描,若遇到小元素,则继续;遇到大元素,则停止,此时a[i]是大元素 221 | while( a[ ++i ].compareTo( pivot ) < 0 ) { } 222 | //j从右往左扫描,若遇到大元素,则继续;遇到小元素,则停止,此时a[j]是小元素 223 | while( a[ --j ].compareTo( pivot ) > 0 ) { } 224 | /*i和j的扫描都停止后,若i和j并未交错,则交换a[i]和a[j],效果就是 225 | 小元素a[j]到了左边,大元素a[i]到了右边*/ 226 | if( i < j ) 227 | swapReferences( a, i, j ); 228 | /*i和j的扫描都停止后,若i和j已经交错,则左右两部分划分结束。此时左边的 229 | 都是小元素,右边的都是大元素,但枢纽元仍在最后*/ 230 | else 231 | break; 232 | } 233 | /*将枢纽元与位置i的元素交换,使得枢纽元回到这段数据中间。至此,枢纽元的左边 234 | 都是小元素,右边都是大元素,左右两部分的划分彻底完成*/ 235 | swapReferences( a, i, right - 1 ); 236 | 237 | //左右两部分划分彻底完成之后,递归地对左半部分(小元素)进行快速排序 238 | quicksort( a, left, i - 1 ); 239 | //递归地对右半部分(大元素)进行快速排序 240 | quicksort( a, i + 1, right ); 241 | } 242 | //若数组长度较小(小于阈值CUTOFF),则采用简单的插入排序即可 243 | else 244 | insertionSort( a, left, right ); 245 | } 246 | 247 | //三数中值分割法 248 | private static > 249 | AnyType median3( AnyType [ ] a, int left, int right ){ 250 | /*通过以下几次比较和交换,将a[left]、a[center]和a[right]按从小到大的顺序排好, 251 | 从而枢纽元就是a[center]*/ 252 | int center = ( left + right ) / 2; 253 | if( a[ center ].compareTo( a[ left ] ) < 0 ) 254 | swapReferences( a, left, center ); 255 | if( a[ right ].compareTo( a[ left ] ) < 0 ) 256 | swapReferences( a, left, right ); 257 | if( a[ right ].compareTo( a[ center ] ) < 0 ) 258 | swapReferences( a, center, right ); 259 | 260 | //将枢纽元a[center]与最后一个元素a[right-1]互换 261 | swapReferences( a, center, right - 1 ); 262 | //返回枢纽元a[right-1] 263 | return a[ right - 1 ]; 264 | } 265 | 266 | - 对于快速排序性能的分析,分以下三种情况讨论: 267 | + **最坏情况**:若枢纽元始终都选为最小(或最大)元素,则会导致每一次划分都是无效的,也即划分得到的两部分一个是空集,另一个包含了所有元素。这种极端情况下,算法花费的时间是O(N^2),计算过程略。 268 | + **最好情况**:最好情况下,枢纽元正好位于正中间,因此每次划分得到的两部分是大小均等的。这种理想情况下,算法花费的时间是O(N * logN),计算过程略。 269 | + **平均情况**:平均情况的分析最为困难,通过复杂的分析可以得出结论——算法平均花费的时间是O(N * logN),计算过程略。 270 | 271 | - **快速选择**:若要在一个无序集合中找到第k小的元素,传统的方式是将这个集合先排序,然后返回处于位置k的元素。此外还可以利用快速排序的思想设计算法——和快速排序一样,选定枢纽元,把集合分成两部分,若k <= 小元素这部分的大小,则说明第k小的元素在小元素这部分中,在小元素这部分中递归地调用这个过程;若k = 小元素这部分的大小 + 1,则说明第k小的元素正好就是枢纽元;若k > 小元素这部分的大小 + 1,则说明第k小的元素在大元素这部分中,在大元素这部分中递归地调用这个过程。该算法的代码略。 272 | - 对于归并排序和快速排序的比较: 273 | + 归并排序需要的元素比较次数较少,而元素移动次数较多(因为要将元素拷贝到输出数组中),这对于Java对象的排序是有利的(因为Java对象的比较操作不容易被内嵌,因此动态调度的开销较大;而Java对象的移动操作实质上仅仅是引用的移动,而非庞大对象本身的移动,故移动操作的开销较小)。事实上,Java类库中泛型排序所使用的就是归并排序算法。 274 | + 快速排序需要的元素比较次数较多,而元素移动次数较少(仅在必要时才会交换两个元素),这对于C++对象的排序是有利的(因为C++中的比较操作开销较小,而移动操作是开销较大的,它移动的是对象本身) 。事实上,C++库中使用的是快速排序算法。 275 | + 对于Java中基本类型的排序,比较操作和移动操作的开销差不多,因此归并排序和快速排序的开销是差不多的。事实上,Java选用快速排序作为基本类型的标准库排序。 276 | 277 | - 最后,可以证明以下结论:任何基于比较的排序算法均需要Ω(N * logN)次比较(证明过程略)。事实上,快速排序、归并排序和堆排序是最优的排序算法,插入排序和冒泡排序的性能要差一些。 278 | 279 | 280 | ## 桶式排序 ## 281 | 282 | - 这是一种很巧妙的排序算法,它的应用场景也非常有限:要求输入序列必须都是小于M的正整数。算法过程是:使用一个大小为M的数组count,初始所有元素为空或为0;然后读入输入序列,对于输入序列中的每个元素Ai,将count[Ai]置为1;在输入序列读入完之后,从头到尾扫描数组count中取值为1的元素下标,就可以得到排序结果了。这个算法的巧妙之处在于,它充分利用了“输入序列都是小于M的正整数”这一有用信息。该算法花费的时间是线性的,即O(N)。 283 | 284 | 285 | ## 外排序 ## 286 | 287 | - 本文前述的所有排序算法都是在内存中完成的,这类排序算法叫做**内排序**。然而如果数据量太大装不进内存,就需要将数据放在外存(如磁盘)上进行排序了,这类排序算法叫做**外排序**。外排序的瓶颈在于对磁盘的访问效率很低,因此应该尽可能减少磁盘访问的次数。 288 | - 外排序算法的中心思想是**归并**,即采用归并排序的思想。算法的大致流程为:假设内存最多可以容纳M个元素,那么每次从输入数据中读取M条,在内存中将这M条数据排序之后写到磁盘输出。每两次后就可以将两个M长的已排序序列合并成2M长的已排序序列了。迭代这个过程即可完成整个排序。注意,为了节省空间,输入数据和输出数据的磁盘空间可以交替使用。另外,外排序使用归并思想的主要原因在于,对两个已排序序列的合并操作是非常简单的,几乎不需要内存,因此就不需要频繁的磁盘访问了。 289 | - 上述基本的外排序思想可以进一步优化:在完成k次M条数据的排序之后,再将k路M长的已排序序列合并为k*M长的已排序序列(不再是之前的2-路,这样可以减少合并的趟数)。还可以对算法更进一步优化,此处略。 290 | - 关于外排序更具体的内容请参考《数据结构与算法分析——Java语言描述》。 291 | 292 | ## 其他需要注意的地方 ## 293 | 294 | - 如果需要对大量数据进行排序,最好选用快速排序;如果数据量很少,或者输入数据已经很接近排好序了,则最好选用插入排序。 295 | - 堆排序要比希尔排序慢,这是由深入分析得知的。 -------------------------------------------------------------------------------- /操作系统.md: -------------------------------------------------------------------------------- 1 | - 进程与线程的概念(注意进程是分配资源(物理内存)的最小单元,线程是CPU调度的最小单元) 2 | - 操作系统的内核模式和用户模式 3 | - 信号量:用于同步多个进程(线程)对于共享资源的使用情况,同一时间可以允许多个进程(线程)使用共享资源,包含P、V两个操作 4 | - 互斥锁:用于同步多个线程对于共享资源的使用情况,同一时间仅允许一个线程使用共享资源,是信号量n=1的特殊情况 5 | - 自旋锁:与互斥锁类似,但是它在等待共享资源时不睡眠,而是“自旋” 6 | - 操作系统内存管理: 7 | + 直接操作物理内存,难以支持多进程。 8 | + 引入虚拟地址(逻辑地址)的概念,但仍需要连续空间,容易产生碎片,需要内存紧缩技术;同时为了支持多进程,还需要交换技术 9 | + 在虚拟地址的概念上更近一步,引入分页、分段、段页式三种管理方式,不需要连续空间。注意三种的区别和优劣。 10 | + 为了支持虚拟内存,在分页管理方式上更进一步,引入请求分页机制,可以将磁盘上的页调入内存,并将内存中暂时不需要的页置换到磁盘上。 11 | - 进程调度: 12 | + 先来先服务算法 13 | + 短进程优先调度算法 14 | + 高优先权优先调度算法 15 | + 时间片轮转调度算法 16 | + 多级反馈队列调度算法(公认较好的一种算法) 17 | - 操作系统I/O的三种方式:中断I/O、轮询I/O(或称程序控制I/O)和直接内存存取(DMA)。注意三者各有利弊。 18 | - 死锁:A1等待A2的资源、A2等待A3的资源、...、An又等待A1的资源,这n个线程永远也得不到需要的资源,也永远释放不了已占有的资源,因此永远处于阻塞状态。 19 | - 活锁:A1礼让A2先执行,同时A2也礼让A1先执行,那么双方都得不到执行。不过经过若干次重试之后,活锁是有可能自行解开的(因为产生活锁的线程仍然处于运行状态,是会不断地重新尝试的)。而死锁是不会自行解开的(产生死锁的线程都处于阻塞状态了,无法继续执行)。 20 | - 饥饿:A1与A2竞争一个资源,调度器让A2先执行,然后A1与A3又竞争该资源,调度器又让A3先执行,...于是A1永远也竞争不到这个资源。 21 | - 死锁的预防、避免、检测与恢复:通过“银行家算法”等原则来避免死锁的产生,而一旦产生了死锁,应能通过一定的方法检测出来,并消除之。消除的方法包括系统重启、终止进程并收回资源、进程回滚策略等。 22 | - Java内存机制与多线程:Java对象的实例变量存储在“主内存”中,所有线程均可访问;而每个线程又有自己的“工作内存”,工作内存中包括缓存和堆栈,其中缓存中存储的是主内存中实例变量的副本,堆栈中存储的是局部变量。 23 | -------------------------------------------------------------------------------- /编程之美读书笔记(一).md: -------------------------------------------------------------------------------- 1 | # 《编程之美》读书笔记(一) # 2 | 3 | 游戏之乐——游戏中碰到的题目 4 | 5 | ##1.1 让CPU占用率曲线听你指挥## 6 | 7 | ###问题描述### 8 | 9 | - 使CPU占用曲线保持恒定(例如30%),呈一条直线。 10 | 11 | - 令CPU占用曲线呈正弦曲线。 12 | 13 | ###解决思路### 14 | 15 | - 一个程序在运行时,使得CPU闲下来(使用率为0%)的条件是该程序在等待用户输入、或等待某些事件的发生、或主动进入休眠状态(调用`Sleep()`函数)。 16 | 17 | - 任务管理器的CPU占用曲线显示的是**每个刷新周期内**CPU占用率的**统计平均值**。 18 | 19 | - 因此可以写一个程序,使它在任务管理器的一个刷新周期内一会儿忙,一会儿闲,然后调节忙/闲比例,就可以控制CPU占用曲线为一条特定的直线。 20 | 21 | - 需要注意的是,应当在允许的范围内尽量减少sleep/awake(睡眠/唤醒)频率,以减少操作系统内核调度程序的频繁调度对CPU使用率的干扰;并且尽量不要调用系统函数,因为它也会导致不可控的内核运行时间,从而影响CPU使用率。 22 | 23 | - 要使CPU占用曲线为正弦曲线,原理也是类似的:根据正弦曲线的函数表达式(`f(x)=sinx`),在特定的时间令CPU忙/闲比例为特定值即可。 24 | 25 | ##1.2 中国象棋将帅问题## 26 | 27 | ###问题描述### 28 | 29 | 中国象棋中“将”、“帅”两子(令“将”为A,“帅”为B)只能在本方3×3的格子里运动,要求写一个程序,输出A、B的所有可能位置组合,并且在代码中只能使用一个字节存储变量。 30 | 31 | ###解决思路### 32 | 33 | - A和B理论上有3×3=9种可能的位置,再除去规则不允许的位置,实际可能的位置要少于9种。 34 | 35 | - 在代码中只能使用一个字节来存储两个棋子的位置,可以采用8位的byte类型变量,用变量的前4bit存储A的位置,后4bit存储B的位置,由于4个bit可以表示16个数,因此这样就够用了。 36 | 37 | - 延伸:bit级别的操作: 38 | + 将一个变量(例如10100101)的右半部分(0101)设为某特定值n(例如0011)。做法是先清除该变量的右半部分(与11110000按位与,将右半部分全部清零),得到10100000;再将该结果与n(00000011)按位或,得到最终结果10100011。 39 | 40 | + 将一个变量(例如10100101)的左半部分(1010)设为某特定值n(例如0011)。做法是先清除该变量的左半部分(与00001111按位与,将左半部分全部清零),得到00000101;然后将n左移4位,得到00110000;最后将以上两个结果按位或,得到最终结果00110101。 41 | 42 | + 得到某一变量(例如10100101)的左半部分(1010)或右半部分(0101)。做法是将该变量与00001111进行按位与,得到右半部分(0101);将该变量与11110000进行按位与,再将其右移4位,得到左半部分(1010)。 43 | 44 | ##1.3 一摞烙饼的排序## 45 | 46 | ###问题描述### 47 | 48 | 对于一摞烙饼,每次只允许翻转上面的一部分(将它们全部颠倒个个儿),求问这样如何将原本无序的烙饼按形状排好序? 49 | 50 | ###解决思路### 51 | 52 | - 基本的解决思想是:首先找到最大的一个饼,将该饼连同其之上的所有饼翻转一次(这样最大的这个饼就被翻到最上面了);然后把所有的n个烙饼翻转,这样就把最大的烙饼放在最底下了;接着对剩下的n-1个饼重复该过程,就能完成排序了。 53 | 54 | - 算法分析:以上算法中,每循环一次该过程,就需要翻转两次(把最大的翻上去,再全部翻转一次),因此一共需要2(n-1)次翻转。 55 | 56 | - 算法优化:除了以上翻转策略外,还可以采用动态规划方式考察所有的翻转策略,找出所需翻转次数最少的策略。考察每种策略退出的条件为:烙饼已经完成排序,或者尚未完成排序但翻转次数已经超过2(n-1)了(因为这样就肯定不是最优策略了)。 57 | 58 | ##1.4 买书问题## 59 | 60 | ###问题描述### 61 | 62 | 待补充 63 | 64 | ###解决思路### 65 | 66 | 待补充 67 | 68 | ###背景知识——动态规划### 69 | 70 | - 动态规划算法的基本思想与分治算法类似,也是将待求解的问题分解为若干个子问题(阶段)。但是动态规划算法与分治算法的最大差别在于:动态规划算法的子问题之间往往不是相互独立的(下一阶段子问题的求解是建立在上一阶段子问题的解之上的);而分治算法的子问题之间一般都是相互独立、互不影响的。 71 | 72 | - 适用动态规划算法的问题一般应满足**最优化原理**(整个问题的最优解所包含的子问题的解也是最优的)和**无后效性**(即某阶段的状态一旦确定,就不受这个状态之后决策的影响)。 73 | 74 | - 动态规划算法的求解,最主要的是确定**状态转移方程**,来表示从前一个阶段转化到后一个阶段的递推关系。整个动态规划的求解过程可以用一个二维的**最优决策表**来描述,其中行表示决策的每一个阶段,列表示每一个状态。表中存储的是某个阶段某个状态下的最优解。填表的过程就是根据状态转移方程描述的递推关系,从第1行第1列开始依次填写表格。最后根据整个表格中的数据得到整个问题的最终解。 75 | 76 | - 动态规划算法的详细资料及算法示例参见[http://blog.csdn.net/v_JULY_v/article/details/6110269](http://blog.csdn.net/v_JULY_v/article/details/6110269)。 77 | 78 | ##1.5 快速找出故障机器## 79 | 80 | ###问题描述### 81 | 82 | 1. 在一个序列中有很多ID,每个ID均出现了两次。现在丢失了其中的一个ID(也即该ID现在只出现了一次),求问如何找出这个丢失的ID? 83 | 84 | 2. 在问题1的序列中,若丢失了其中的两个ID呢?如何找到这两个丢失的ID? 85 | 86 | ###解决思路### 87 | 88 | ####问题1的解法#### 89 | 90 | 1. 解法一:采用**异或**的方式。将序列中的所有ID进行异或操作,由于其余每个ID均出现两次,而X⊕X=0、X⊕0=X,并且异或满足交换律和结合率,因此所有ID的异或值就等于仅出现一次的那个ID,也就是丢失的那个ID。 91 | 92 | 2. 解法二:预先计算好序列中所有ID的和(丢失ID之前),再计算丢失ID之后序列中其余所有ID之和,这两者之差就是丢失的那个ID。 93 | 94 | ####问题2的解法#### 95 | 96 | 1. 解法一:采用**异或**的方式。设这两个丢失的ID分别为A和B,那么将所有ID异或后得到的值为A⊕B,分两种情况讨论: 97 | 98 | + 若A⊕B=0,说明A=B,也即丢失的两个是同一个ID,则可以采用求和的方式,计算丢失之前和丢失之后的ID和之差,再除以2即为丢失的ID。 99 | 100 | + 若A⊕B!=0,说明A和B是两个不同的ID,那么这个异或值的二进制中的某一位等于1,这意味着A和B中有且仅有一个数的相应位上也是1。进一步,我们可以将所有ID分为两类,一类在这一位上等于1,另一类在这一位上等于0。这两类分别包含A和B中的一个,于是该问题转化为了问题1(序列中包含一个丢失的ID),这时分别对这两类序列进行异或操作,即可求得A和B的值。 101 | 102 | 2. 解法二:与问题1的解法二类似,设这两个丢失的ID分别为A和B,用预先计算好的序列中所有ID之和减去丢失之后剩余ID之和,即得到A+B的值。此时有两个未知数,但只有一个方程,我们可以再构造一个方程,例如A^2+B^2(计算丢失前/丢失后所有ID的平方和之差),然后解这个二元方程组,就可以求得A和B的值了。 103 | 104 | ####注意#### 105 | 106 | - 以上提供的解法时间复杂度为O(N)(需要线性遍历一遍序列),空间复杂度为O(1)(仅需存储异或值、和值、平方和值等几个变量),已经是最优算法。 107 | 108 | - 若将问题再做扩展,寻找丢失的N个ID,可以遵循问题2的解法二的思路,构造N元方程组,提供N个方程(例如立方和、四次方和等等),即可解决。 109 | 110 | ##1.6 饮料供货问题## 111 | 112 | ###问题描述### 113 | 114 | 待补充 115 | 116 | ###解决思路### 117 | 118 | 待补充 119 | 120 | ##1.7 光影切割问题## 121 | 122 | ###问题描述### 123 | 124 | ![](http://i.imgur.com/p17t8NF.png) 125 | 126 | 如图所示,如何计算仓库地板被光影总共划分为多少块? 127 | 128 | ###解决思路### 129 | 130 | - 根据观察并总结数学规律可以得出结论:被光影划分的块数=N+M+1,其中N为光线个数,M为光线之间的交点个数。 131 | 132 | - 光线个数N很容易获得,而光线之间的交点个数M的求解方法可以转化为对序列逆序数的求解。如下图所示:令三条光线与左边界的交点为有序序列(a,b,c),则与右边界的交点为无序序列(c,b,a),该序列的逆序数为3,也即光线之间的交点个数为3。该结论可以表述如下:交点个数等于一个边界上的交点顺序相对于另一个边界上交点顺序的逆序总数。 133 | 134 | ![](http://i.imgur.com/66YnLRp.png) 135 | 136 | - **逆序数的求解**方法与归并排序算法类似,采用分治思想: 137 | 138 | + 首先递归地求解前N/2个元素的逆序数和后N/2个元素的逆序数。 139 | 140 | + 然后合并前后两部分的逆序数。总的逆序数=前半部分逆序数(可递归求出)+后半部分逆序数(可递归求出)+前后两部分元素之间的逆序数。 141 | 142 | + 前后两部分元素之间的逆序数的求解方法为:将前半部分(设为seq1)和后半部分(设为seq2)采用归并排序进行合并(注意由于采用归并排序,所以此时seq1和seq2已经递归地排好序了)。假设归并排序在seq1设置的游标为i,在seq2设置的游标为j,则若seq1[i]<=seq2[j],则没有逆序;若seq1[i]>seq2[j],则说明seq1中位于seq1[i]之后的所有元素都大于seq2[j],则逆序数为“seq1中位于seq1[i]之后的元素个数”。如此随着游标i、j的不断右移,将得到的逆序数累加起来,就能得出前后两部分元素之间的逆序总数。 143 | 144 | + 这种求逆序数的方式实际上是在归并排序的过程中“顺便”求得了逆序数。 145 | 146 | ##1.8 小飞的电梯调度算法## 147 | 148 | ###问题描述### 149 | 150 | 假设一栋大楼里的电梯只能停在第x层,所有的乘客都需要乘电梯坐到x层后步行上楼/下楼走到自己的目的楼层,请问x应当取何值能够保证所有乘客爬楼梯的层数之和最小? 151 | 152 | ###解决思路### 153 | 154 | ####解法一#### 155 | 156 | 简单的穷举法:从第一层开始枚举直到最高层,对每一层计算出如果电梯停在该层则所有乘客要爬多少层楼梯,取最小值及相应楼层即为最终解。 157 | 158 | 该方法包含两个for循环,外层循环遍历每一层;内层循环遍历当电梯停在该层时,要去其余各个楼层的乘客数目。因此该算法时间复杂度为O(N^2)。 159 | 160 | ####解法二#### 161 | 162 | 假设电梯停在i层楼,设N1个乘客的目的楼层在i层以下、N2个乘客的目的楼层在i层、N3个乘客的目的楼层在i层以上。考虑以下两种情况: 163 | 164 | + 若电梯改停在i-1层,则位于i层以下的N1个乘客可以少爬一层,位于i层及以上的N2+N3个乘客要多爬一层,因此要多爬(N2+N3-N1)层。 165 | 166 | + 若电梯改停在i+1层,则位于i层及以下的N1+N2个乘客要多爬一层,位于i层以上的N3个乘客可以少爬一层,因此要多爬(N1+N2-N3)层。 167 | 168 | 根据上述分情况讨论,可以得出下列结论: 169 | 170 | + 若N2+N3 pAEnd){ 63 | //若strB也是空串,则strA和strB的距离为0 64 | if(pBBegin > pBEnd) 65 | return 0; 66 | //若strB不是空串,则strA和strB的距离即为strB的长度 67 | else 68 | return pBEnd - pBBegin + 1; 69 | } 70 | //若strB为空串 71 | if(pBBegin > pBEnd){ 72 | //若strA也是空串,则strA和strB的距离为0 73 | if(pABegin > pAEnd) 74 | return 0; 75 | //若strA不是空串,则strA和strB的距离即为strA的长度 76 | else 77 | return pAEnd - pABegin + 1; 78 | } 79 | 80 | //==============================分割线================================= 81 | 82 | //若strA和strB的第一个字符相同,则递归地计算strA[2,……]与strB[2,……]的距离,即为strA与strB的距离 83 | if(strA[pABegin] == strB[pBBegin]){ 84 | return CalculateStringDistance(strA, pABegin + 1, pAend, strB, pBBegin + 1, pBEnd); 85 | } 86 | /*若strA和strB的第一个字符不同,则递归地计算strA与strB[2,……]的距离、 87 | strA[2,……]与strB的距离、strA[2,……]与strB[2,……]的距离。三者的较小值 88 | 加上1即为strA和strB的距离*/ 89 | else{ 90 | int t1 = CalculateStringDistance(strA, pABegin, pAend, strB, pBBegin + 1, pBEnd); 91 | int t2 = CalculateStringDistance(strA, pABegin + 1, pAend, strB, pBBegin, pBEnd); 92 | int t3 = CalculateStringDistance(strA, pABegin + 1, pAend, strB, pBBegin + 1, pBEnd); 93 | return min(t1, t2, t3) + 1; 94 | } 95 | } 96 | 97 | 98 | ##3.4 从无头单链表中删除节点## 99 | 100 | ###问题描述### 101 | 102 | 假设有一个没有头指针的单链表,如何删除这个单链表中的一个节点(该节点不是第一个,也不是最后一个节点)。 103 | 104 | ###解决思路### 105 | 106 | - 如下图所示,假如要删除B节点,那么删除B节点本身容易,但由于是单链表且没有头指针,因此无法回溯到B的前一个节点A,也就无法将A和C相连。 107 | 108 | ![](http://i.imgur.com/J01vO6v.png) 109 | 110 | - 解决方法是:我们不直接删除B,而是删除B的下一个节点C(删除C之后,把B和D重新连接起来很容易做到,因为无需回溯),然后**用C中的数据项取代B中的数据项**,那么最后的效果跟直接删除B就是一样的了。 111 | 112 | ###扩展问题### 113 | 114 | ####问题描述#### 115 | 116 | 如何将一个单链表中的元素顺序反转? 117 | 118 | ####解决思路#### 119 | 120 | - 算法的中心思想是:从头节点开始顺序遍历所有节点,每次都把当前节点提到最前面来(与头节点相邻)。这一操作通过调整相应的链来实现。 121 | 122 | - 举例来说,有一个单链表head -> a1 -> a2 -> a3 -> a4 -> …… 123 | 124 | + 第一步,扫描到a1,它本来就是最前面的节点,无需操作; 125 | 126 | + 第二步,扫描到a2,令head指向a2(即head指向pCurrent),a2指向a1(即pCurrent指向head->pNext),a1指向a3(即pCurrent->pPrevious指向pCurrent->pNext,其中pCurrent->pPrevious就是前一步结果中的pCurrent,可以在前一步存储下来),使得链表变为head -> a2 -> a1 -> a3 -> a4 -> …… 127 | 128 | + 第三步,扫描到a3,令head指向a3(即head指向pCurrent),a3指向a2(即pCurrent指向head->pNext),a1指向a4(即pCurrent->pPrevious指向pCurrent->pNext),使得链表变为head -> a3 -> a2 -> a1 -> a4 -> …… 129 | 130 | + 如此逐个扫描链表中的元素,就可以完成单链表的反转。 131 | 132 | - 除了这种算法之外,还可以借鉴从无头单链表中删除节点的方法:不用调整单链表中链的指向,而只需将各个节点的数据替换成反转之后的数据即可。具体做法:先顺序扫描一遍链表,将各节点的数据按顺序保存在一个数组中;然后将这个数组反转(很容易实现);最后再顺序扫描一遍链表,依次将反转之后的数组元素赋给各节点。这个算法需要额外的空间和时间,但是比较简单,一目了然。 133 | 134 | 135 | ##3.5 最短摘要的生成## 136 | 137 | ###问题描述### 138 | 139 | 待补充 140 | 141 | ###解决思路### 142 | 143 | 待补充 144 | 145 | 146 | ##3.6 编程判断两个链表是否相交## 147 | 148 | ###问题描述### 149 | 150 | 给出两个单链表的头指针h1和h2,如何判断这两个链表是否相交?如下图所示。 151 | 152 | ![](http://i.imgur.com/FvkAVK5.png) 153 | 154 | ###解决思路### 155 | 156 | 通过思考可以知道这样一个事实:**如果两个单链表相交,那么它们的最后一个节点一定是共有的**。因此我们可以先遍历第一个链表,记下它的最后一个节点;然后遍历第二个链表,到最后一个节点时把它和第一个链表的最后一个节点做比较,如果两者相同则相交,否则不相交。 157 | 158 | 159 | ##3.7 队列中取最大值操作问题## 160 | 161 | ###问题描述### 162 | 163 | 假设有一个队列Queue,如何设计一种数据结构和算法,让队列的**取最大值**操作的时间复杂度尽可能的低。 164 | 165 | ###解决思路### 166 | 167 | - 考虑到队列是一种“先进先出”的数据结构,它实现取最大值的操作需要遍历所有元素,时间复杂度为O(N)。而如果数据结构改用**最大堆**,就可以在O(1)时间内完成取最大值操作了。 168 | 169 | - 根据上述思路,可以改变队列的底层实现——底层不用普通数组实现,而采用最大堆来实现。具体方法就是将队列中的元素以堆序性质组织(放入最大堆中),每个元素还需要维护一个额外的指针来记录队列的次序,如下图所示。 170 | 171 | ![](http://i.imgur.com/410giET.png) 172 | 173 | - 以上算法的基本思想是:**一种数据结构的底层可以由另一种数据结构来实现**。 174 | 175 | ###扩展问题### 176 | 177 | ####问题描述#### 178 | 179 | 如何用两个栈来实现一个队列? 180 | 181 | ####解决思路#### 182 | 183 | - 这个问题也是一个典型的“用一种数据结构来实现另一种数据结构”的问题。假设给定的是栈A和栈B,可以按如下操作分别执行入队和出队操作: 184 | 185 | + 入队时,向栈B压入新元素即可。 186 | 187 | + 出队时,判断栈A是否为空,若A是空栈,则依次将栈B中的元素出栈并压入栈A中,最后执行栈A的出栈操作,即可将最先入队(压入栈B)的元素弹出(最先压入栈B的元素位于栈B的最下面,它最后一个从栈B中出栈并被压入栈A,因此它在栈A中位于最上面),从而实现“先进先出”;若A不是空栈,则直接执行栈A的出栈操作即可,因为栈A的栈顶元素必然是最先压入栈B、并在后来从栈B中出栈并被压入栈A的元素,同样也实现了“先进先出”。 188 | 189 | - 该实现方式的代码如下: 190 | 191 | class Queue{ 192 | //使用两个栈 193 | private: 194 | stack stackA; 195 | stack stackB; 196 | 197 | public: 198 | //入队操作:向栈B中压入新元素即可 199 | EnQueue(v){ 200 | stackB.push(v); 201 | } 202 | //出队操作:若栈A为空,则将栈B中的元素依次出栈并压入栈A中,最后执行栈A的出栈操作;若栈A不为空,则直接执行栈A的出栈操作 203 | Type DeQueue(){ 204 | if(stackA.empty()){ 205 | while(!stackB.empty()){ 206 | stackA.push(stackB.pop()); 207 | } 208 | } 209 | return stackA.pop(); 210 | } 211 | } 212 | 213 | ##3.8 求二叉树中节点的最大距离## 214 | 215 | ###问题描述### 216 | 217 | 如何求一棵二叉树中相距最远的两个节点之间的距离?(两个节点之间的距离定义为两个节点之间边的个数) 218 | 219 | ###解决思路### 220 | 221 | - 讨论k叉树的一般情形(二叉树是k=2的特殊情形)。假设相距最远的两个节点分别为U和V,那么U、V和根节点Root之间的关系有两种可能: 222 | 223 | + 最远路径经过Root,则U和V一定属于Root的不同子树,且它们都是该子树中到Root最远的节点(否则跟它们相距最远相矛盾),如下图所示。 224 | 225 | ![](http://i.imgur.com/7sDTjdT.png) 226 | 227 | + 最远路径不经过Root,那么U和V一定属于Root的k个子树之一,并且它们也是该子树中相距最远的节点。从而原问题就可以转化为在某棵子树上的解,可以用动态规划来解决。如下图所示。 228 | 229 | ![](http://i.imgur.com/goOWlDq.png) 230 | 231 | - 根据以上分析,设第k棵子树中相距最远的两个节点为Uk和Vk,不妨令Uk为子树k中到子树根节点Rk距离最远的节点,设Uk到Rk的距离为dk,那么取dk中最大的两个值max1和max2,则**经过整棵树根节点Root**的最远路径为max1+max2+2;而**不经过整棵树根节点Root**的最远距离就是Uk和Vk距离的最大值(设为dkmax),可以递归地在k个子树上求解。因此整棵树中的最远距离就是max1+max2+2和dkmax两者中的较大者了。如下图所示。 232 | 233 | ![](http://i.imgur.com/qZRRhCn.png) 234 | 235 | - 该算法的代码详见p244-p245。 236 | 237 | 238 | ##3.9 重建二叉树## 239 | 240 | ###问题描述### 241 | 242 | 假设已经有了一棵二叉树的前序遍历和中序遍历的结果,那么如何重建这棵二叉树呢? 243 | 244 | ###解决思路### 245 | 246 | - 根据前序遍历和中序遍历的概念可以知道:前序遍历的每一个节点,都是当前子树的根节点。同时,以该节点为边界,就会把中序遍历的结果分为左子树和右子树。 247 | 248 | - 例如:前序遍历结果为abdcef,中序遍历结果为dbaecf,那么可以按如下步骤分析: 249 | 250 | + 前序遍历的第一个节点是a,因此它是根节点,它把中序遍历的结果分为两部分:左子树db和右子树ecf; 251 | 252 | + 前序遍历的第二个节点是b,它是左子树(db)的根节点,它把中序遍历的结果(db)分为两部分:左子树d和右子树null; 253 | 254 | + 如此逐个扫描前序遍历的节点,再根据中序遍历结果获取该节点的左子树和右子树,就可以重建二叉树了。 255 | 256 | - 算法代码详见p248-p250。 257 | 258 | 259 | ##3.10 分层遍历二叉树## 260 | 261 | ###问题描述### 262 | 263 | 如何层序遍历一棵二叉树?要求每一层的节点访问顺序为从左至右,层与层之间要输出换行符。 264 | 265 | ###解决思路### 266 | 267 | ####解法一#### 268 | 269 | - 可以考虑编写一个函数,访问二叉树的某一层节点。于是我们从第一层到最后一层反复调用这个函数,就可以实现二叉树的层序遍历了。 270 | 271 | - 访问二叉树的某一层节点的实现思路:假设要访问第k层节点,那么它等效于访问“根节点的左右子树的第k-1层节点”,也即访问“根节点的左右子树的左右子树的第k-2层节点”,……,这个过程可以用递归实现,代码如下所示。 272 | 273 | //打印第level层的节点,成功返回1,失败返回0,其中根节点为第0层 274 | int PrintNodeAtLevel(Node* root, int level){ 275 | //失败情形 276 | if(!root || level < 0) 277 | return 0; 278 | //打印第0层,直接输出当前的根节点(不再递归) 279 | if(level == 0){ 280 | cout << root->data << " "; 281 | return 1; 282 | } 283 | 284 | //递归地打印当前根节点的左子树和右子树的第level-1层 285 | return PrintNodeAtLevel(root->leftChild, level-1) + PrintNodeAtLevel(root->rightChild, level-1); 286 | } 287 | 288 | - 有了上述访问某一层节点的函数,我们只需要知道二叉树的深度depth,就能够通过调用depth次PrintNodeAtLevel()函数来实现二叉树的层序遍历,代码如下: 289 | 290 | void PrintNodeByLevel(Node* root, int depth){ 291 | //依次访问从第0到第depth-1层的节点 292 | for(int level = 0; level < depth; level++){ 293 | PrintNodeAtLevel(root, level); 294 | cout << endl; 295 | } 296 | } 297 | 298 | - 以上层序遍历的代码还可以稍作改进:无需提供二叉树的深度就能完成层序遍历,代码如下: 299 | 300 | void PrintNodeByLevel(Node* root){ 301 | for(int level = 0; ; level++){ 302 | //从第0层开始逐层访问节点,当访问某一层节点失败时直接退出循环即可(说明该层不存在,所有层都已访问完毕) 303 | if(!PrintNodeAtLevel(root, level)) 304 | break; 305 | cout << endl; 306 | } 307 | } 308 | 309 | ####解法二#### 310 | 311 | - 解法一中,每次调用PrintNodeAtLevel()函数打印某一层时,都需要重新从根节点开始递归,因此有很多重复计算。而事实上在访问第k层的时候,只需要知道第k-1层的节点信息就足够了,无需从根节点开始从头遍历。 312 | 313 | - 根据上述分析,可以采用如下流程进行层序遍历: 314 | 315 | + 从根节点出发,将当前层的所有节点从左至右压入一个数组中; 316 | 317 | + 用一个游标Cur指示当前访问的节点,用另一个游标Last指示当前层次的最后一个节点的下一个位置; 318 | 319 | + 以Cur==Last作为当前层次访问结束的条件,在访问某一层的同时将该层所有节点的子节点(也即下一层的节点)压入数组; 320 | 321 | + 当前层次访问完毕后,更新Last游标,使其指向下一层的最后一个节点的下一个位置,准备开始下一层的访问; 322 | 323 | + 直到不再有新节点可以访问,说明所有层次都已访问完毕,算法结束。 324 | 325 | - 该算法代码如下: 326 | 327 | void PrintNodeByLevel(Node* root){ 328 | if(root == null) 329 | return; 330 | //使用STL中的vector(类似于Java中的List)取代数组,从而可以动态扩展其尺寸 331 | vector vec; 332 | vec.push_back(root); 333 | int cur = 0; 334 | int last = 1; 335 | //访问每一层节点,直到不再有新节点可以访问为止(此时cur==vec.size()) 336 | while(cur < vec.size()){ 337 | //将游标last的值取为vec的长度,也即指向当前层次的最后一个节点的下一个位置 338 | last = vec.size(); 339 | //依次访问当前层次的节点(游标cur随之递增),当cur==last时说明当前层次已经访问完毕 340 | while(cur < last){ 341 | //打印当前访问到的节点(也即游标cur指向的节点) 342 | cout << vec[cur]->data << " "; 343 | //同时将当前访问到的节点的子节点压入vec中(如果子节点存在的话) 344 | if(vec[cur]->left) 345 | vec.push_back(vec[cur]->left); 346 | if(vec[cur]->right) 347 | vec.push_back(vec[cur]->right); 348 | cur++; 349 | } 350 | cout << endl; 351 | } 352 | } 353 | 354 | 355 | ##3.11 程序改错## 356 | 357 | ###问题描述### 358 | 359 | 待补充 360 | 361 | ###解决思路### 362 | 363 | 待补充 -------------------------------------------------------------------------------- /编程之美读书笔记(二).md: -------------------------------------------------------------------------------- 1 | # 《编程之美》读书笔记(二) # 2 | 3 | 数字之魅——数字中的技巧 4 | 5 | ##2.1 求二进制数中1的个数## 6 | 7 | ###问题描述### 8 | 9 | 对于一个字节(8bit)的无符号整型变量,求其二进制表示中“1”的个数,要求算法执行效率尽可能的高。 10 | 11 | ###解决思路### 12 | 13 | ####解法一#### 14 | 15 | 对于二进制数来说,可以将其**除以2并求余数**来判断末位是0还是1。以10100101为例,第一次除以2时余数为1,说明该位为1;继续除以2,余数为0,说明该位为0。代码如下: 16 | 17 | int Count(BYTE v){ 18 | int num = 0; 19 | while(v){ 20 | //除以2,判断余数,余数为1则说明末位为1 21 | if(v % 2 == 1){ 22 | num++; 23 | } 24 | //保存商,为下一次循环做准备 25 | v = v/2; 26 | } 27 | return num; 28 | } 29 | 30 | ####解法二#### 31 | 32 | 上述解法中的**除以2**操作,在二进制数中可以用**右移一位**来代替。例如对于10100001来说,首先将其与00000001**相与**,结果为1,说明当前末位为1;然后右移一位,继续与00000001相与……。这种解法使用**右移**+**与操作**替代了解法一的**除以2**+**求余**操作。 33 | 34 | ####解法三#### 35 | 36 | 由于问题仅涵盖8位的整数(0——255之间),因此可以预先将这256个数中“1”的个数存储起来,例如存储到一个长度为256的数组中,在求解时无需计算,直接返回答案。 37 | 38 | 这种算法是典型的**空间换时间**的思想。 39 | 40 | ##2.2 不要被阶乘吓倒## 41 | 42 | ###问题描述### 43 | 44 | 1. 给定一个整数,求解N的阶乘N!的末尾有多少个0?例如:N=10,N!=3628800,末尾有2个0。 45 | 46 | 2. 求N!的二进制表示中最低位1的位置。 47 | 48 | ###解决思路### 49 | 50 | ####问题1#### 51 | 52 | - 求解阶乘N!的末尾有多少个0,可以转化为以下问题:对于N!=K×10^M,且K不能被10整除,求解M的值。 53 | 54 | - 更进一步,可以对N!进行质因数分解N!=(2^X)×(3^Y)×(5^Z)×……,由于2×5=10,所以M只与X和Z相关,每一对2和5相乘可以得到一个10,于是M=min(X,Z)。 55 | 56 | - 再进一步,由于能被2整除的数要比能被5整除的数要多,所以M=Z。因此只要求出Z的值,也就是N!的质因数分解中5的个数,就能得到N!中末尾0的个数。 57 | 58 | - 根据以上思路,写出代码如下: 59 | 60 | ret = 0; 61 | for(i = 1; i <= N; i++){ 62 | j = i; 63 | //循环每进行一次,说明找到一个质因数5,ret值增1 64 | while(j % 5 == 0){ 65 | ret++; 66 | j /= 5; 67 | } 68 | } 69 | 70 | ####问题2#### 71 | 72 | - 求解N!的二进制表示中最低位1的位置,可以转化为求解N!含有质因数2的个数。(因为假如最低位1在第M位,那么说明更低的M-1位都是0,因此说明该数包含M-1个质因数2) 73 | 74 | - 求解质因数2的个数可以采用问题1的解法。另外注意:“除以2”操作可以用“右移一位”操作来代替。 75 | 76 | ##2.3 寻找发帖“水王”## 77 | 78 | ###问题描述### 79 | 80 | 如果一个列表中,某个值的出现次数超过了列表总长度的一半,那么如何快速找到这个值? 81 | 82 | ###解决思路### 83 | 84 | - 比较简单的方法:可以先将列表排序,由于这个值出现了超过一半的次数,那么这个列表**正中间**的一项一定是这个值,直接返回该值即可。这种算法需要先排序(花费至少O(N*logN)时间),然后再用O(1)时间返回结果。 85 | 86 | - 效率更高的方法:每次删除两个**不相同**的值(不管是否包含要找的这个值),那么,在列表中剩下的值中,要找的这个值出现的次数仍然会超过一半。可以不断重复这个过程,把列表中的元素总数降低(转化为更小的问题)。这样对列表只需进行一趟遍历就能找到这个值,所需时间为O(N)。 87 | 88 | - 第二种解法里,把原问题转化为规模较小的子问题的思想在计算机科学中非常普遍,分治算法、动态规划算法、递归算法、贪心算法等等都基于这种思想。 89 | 90 | ##2.4 1的数目## 91 | 92 | ###问题描述### 93 | 94 | 给定一个十进制正整数N,写下从1开始到N的所有整数,然后求出其中出现“1”的次数。例如N=12,写下1,2,3,4,5,6,7,8,9,10,11,12,其中“1”的次数为5。 95 | 96 | ###解决思路### 97 | 98 | - 可以从简单的情形开始分析观察,总结其中的**数学规律**,加以推广的一般情形。 99 | 100 | - 对于此题来说,可以先考察1位数的情况、再考察2位数的情况、接着考察3位数的情况……最终推广到一般情形。 101 | 102 | ##2.5 寻找最大的K个数## 103 | 104 | ###问题描述### 105 | 106 | 假设有N个无序的数,如何找出其中最大的K个数呢? 107 | 108 | ###解决思路### 109 | 110 | ####解法一——部分冒泡排序#### 111 | 112 | 由于冒泡排序每趟会把最大的元素“沉底”,因此可以执行K趟冒泡,把前K个大的数“沉底”,从而找到最大的K个数。这种方法不必执行完整的冒泡排序,时间复杂度为O(N*K)。 113 | 114 | ####解法二——快速选择#### 115 | 116 | - 快速选择的思想取自快速排序算法,将集合分为枢纽元、左半部分(其中元素均大于枢纽元)和右半部分(其中元素均小于枢纽元),从而递归完成排序。 117 | 118 | - 用快速选择法找出最大的K个数的算法如下: 119 | 120 | + 假设K个数存储在数组S中,我们从数组S中随机找出一个元素X(枢纽元),把数组分成两部分Sa和Sb,其中Sa中的元素大于等于X,Sb中的元素小于X。此时有两种可能性: 121 | 122 | 1. Sa中的元素个数小于K,那么最大的K个数 = Sa中的全部元素 + Sb中最大的K-|Sa|个元素。 123 | 124 | 2. Sa中的元素个数大于等于K,那么最大的K个数 = Sa中最大的K个数。 125 | 126 | + 无论是以上哪种情形,接下来都需要递归地在Sa或Sb中找到最大的若干个数,最终通过递归实现求解。 127 | 128 | - 快速选择法的伪代码如下: 129 | 130 | //函数Kbig用于找到集合S中最大的k个数 131 | Kbig(S, k): 132 | if(k <= 0) 133 | return []; 134 | if(S.length <= k) 135 | return S; 136 | //首先调用函数Partition(),将S分为Sa和Sb两部分,其中Sa中的元素均大于Sb中的元素 137 | (Sa, Sb) = Partition(S); 138 | /*然后有两种情况: 139 | 1、Sa的大小小于k,则返回Sa中全部元素,加上Sb中的前k-|Sa|个元素 140 | 2、Sa的大小大于等于k,则返回Sa中的前k个元素 141 | 以下代码将上述两种情况统一为了同一种递归写法*/ 142 | return Kbig(Sa, k).append(Kbig(Sb, k-Sa.length)); 143 | 144 | //函数Partition用于将集合S分成Sa、Sb两部分,其中Sa中的元素均大于Sb中的元素 145 | Partition(S): 146 | Sa=[]; Sb=[]; 147 | //随机选取枢纽元p 148 | p = S[random]; 149 | //对于集合S中除了p之外的其他元素,若大于p则放入Sa中,否则放入Sb中 150 | for i in S except p: 151 | S[i] > p ? Sa.append(S[i]) : Sb.append(S[i]) 152 | //最后将枢纽元本身也放入Sa或Sb中 153 | Sa.length < Sb.length ? Sa.append(p) : Sb.append(p); 154 | //返回分好的两个子集Sa、Sb 155 | return (Sa, Sb); 156 | 157 | - 快速选择法的平均时间复杂度为O(N*logK)。 158 | 159 | ####解法三——使用最小堆#### 160 | 161 | - 使用一个容量为K的**最小堆**,首先把集合前K个数依次放入堆中并保持堆序,此时集合中还剩下N-K个数。 162 | 163 | - 逐个把剩下的N-K个数放入堆中,对于每个新到来的数,分两种情况讨论: 164 | 165 | + 若这个数小于堆顶元素,说明这个数比堆中所有的K个数都小,那么它肯定不属于最大的K个数之列,所以摒弃这个数。 166 | 167 | + 若这个数大于堆顶元素,那么这个数有可能属于最大的K个数之列。用它来替换堆顶元素(将原来的堆顶元素摒弃),这时堆序性质可能会被破坏,需要通过“下滤”操作来调整并保持堆序性质。 168 | 169 | - 当把剩下的N-K个数逐个扫描之后,算法结束,此时堆中的K个数就是集合中最大的K个数。 170 | 171 | - 上述算法中,每次新元素的“下滤”操作平均花费时间为O(logK),因此算法总时间为O(N*logK)(包括建堆的时间)。 172 | 173 | ####解法四——桶式排序#### 174 | 175 | 若集合中的数都是位于[0, MAXN]之间的正整数,则可以采用桶式排序,排序之后返回前K个元素即可。 176 | 177 | ##2.6 精确表达浮点数## 178 | 179 | ###问题描述### 180 | 181 | 待补充 182 | 183 | ###解决思路### 184 | 185 | 待补充 186 | 187 | ##2.7 最大公约数问题## 188 | 189 | ###问题描述### 190 | 191 | 求解两个正整数的最大公约数(Greatest Common Divisor,GCD) 192 | 193 | ###解决思路### 194 | 195 | ####解法一——辗转相除法#### 196 | 197 | - 假设用f(x,y)来表示x和y的最大公约数(其中x>=y),那么取k=x/y,b=x%y,则x=ky+b。如果一个数能够同时整除x和y,那么它一定可以同时整除b和y;另一方面,如果一个数能够同时被b和y整除,那么它一定可以同时被x和y整除。也就是说,x和y的公约数与b和y的公约数是相同的,其最大公约数也是相同的,也即满足`f(x,y) = f(x%y,y)`。这样便可以把原问题转化为求两个更小的数的最大公约数,直到其中一个数为0,则另一个数就是两者的最大公约数。 198 | 199 | - 辗转相除法的示例:f(42,30) = f(30,12) = f(12,6) = f(6,0) = 6. 200 | 201 | - 辗转相除法的代码: 202 | 203 | int gcd(int x, int y){ 204 | //若y等于0,则x就是最大公约数;若y不等于0,则继续递归调用gcd(y, x%y) 205 | return (!y) ? x : gcd(y, x%y); 206 | } 207 | 208 | - 算法缺点:如果遇到大整数,则求余操作开销较大。 209 | 210 | ####解法二——“类”辗转相除法#### 211 | 212 | - 借鉴解法一的分析,可以得出类似于辗转相除法的结论:`f(x,y) = f(x-y,y)`。这个递推公式没有求余运算,可以节省开销。 213 | 214 | - 算法示例:f(42,30) = f(30,12) = f(12,18) = f(18,12) = f(12,6) = f(6,6) = f(6,0) = 6. 215 | 216 | - 算法代码如下: 217 | 218 | int gcd(int x, int y){ 219 | //若x小于y,则互换x和y(f(x,y)满足交换律),确保调用过程中不会出现负数 220 | if(x < y){ 221 | return gcd(y, x); 222 | } 223 | //若y等于0,则x就是最大公约数 224 | if(y == 0) 225 | return x; 226 | //若y不等于0,则继续递归调用gcd(x-y, y) 227 | else 228 | return gcd(x-y, y); 229 | } 230 | 231 | - 算法缺点:相比于除法,减法的迭代次数太多。 232 | 233 | ####解法三——“类”辗转相除法的进一步优化#### 234 | 235 | - 继续深入分析,可以得出以下结论: 236 | 237 | 1. 若x、y均为偶数,则`f(x, y) = 2 * f(x/2, y/2)` 238 | 239 | 2. 若x为偶数,y为奇数,则`f(x, y) = f(x/2, y)` 240 | 241 | 3. 若x为奇数,y为偶数,则`f(x, y) = f(x, y/2)` 242 | 243 | 4. 若x、y均为奇数,则`f(x, y) = f(x-y, y)` 244 | 245 | - 算法示例及代码略。 246 | 247 | - 算法优点:该算法避免了大整数求余运算,也避免了只用减法导致迭代次数过多的问题。另外“除以2”可以用“右移一位”来加速。 248 | 249 | ##2.8 找符合条件的整数## 250 | 251 | ###问题描述### 252 | 253 | 任意给定一个正整数N,求一个最小的正整数M,使得N*M的十进制表示形式里只含有1和0。 254 | 255 | ###解决思路### 256 | 257 | - 原问题等价于“求一个最小的正整数X,使得X的十进制表示形式中只包含1和0,且X要被N整除”。 258 | 259 | - 对于新问题的求解,可以列出所有X可能的取值(十进制形式中只包含1和0):1、10、11、100、101、110、111-1000、1001、1010、1011、1100、1101、1110、1111、10000……,然后逐个验证是否可以整除N。 260 | 261 | - 以上解法需要逐个验证所有可能的X值,但是其中有一些X值的验证是不必要的(可以从已验证的X值推断出来未验证的某些X值对N的求余是一样的),具体的分析略。 262 | 263 | ##2.9 斐波那契数列## 264 | 265 | ###问题描述### 266 | 267 | 求解斐波那契数列第N项的值。 268 | 269 | ###解决思路### 270 | 271 | ####解法一——经典递归写法#### 272 | 273 | int Fibonacci(int n){ 274 | if(n <= 0){ 275 | return 0; 276 | } else if(n == 1){ 277 | return 1; 278 | } else { 279 | //递归写法直接使用了斐波那契数列的定义式 280 | return Fibonacci(n-1) + Fibonacci(n-2); 281 | } 282 | } 283 | 284 | ####解法二——经典非递归写法#### 285 | 286 | int Fibonacci(int n){ 287 | int sum = 0; 288 | int sum1 = 1; 289 | int sum2 = 1; 290 | 291 | if(n==1 || n==2) 292 | return 1; 293 | else { 294 | ///循环中通过sum、sum1、sum2这三个变量来存储已计算过的值,避免重复计算 295 | for(int i=2; i < n; i++){ 296 | //sum是第n项,sum1是第n-1项,sum2是第n-2项 297 | sum = sum1 + sum2; 298 | //sum2从第n-2项变为第n-1项(右移一项) 299 | sum2 = sum1; 300 | //sum1从第n-1项变为第n项(右移一项) 301 | sum1 = sum; 302 | //sum2和sum1的右移是为下一次循环(计算第n+1项)做准备 303 | } 304 | 返回第n项 305 | return sum; 306 | } 307 | } 308 | 309 | ##2.10 寻找数组中的最大值和最小值## 310 | 311 | ###问题描述### 312 | 313 | 同时找到一个数组中的最大值和最小值。 314 | 315 | ###解决思路### 316 | 317 | ####解法一#### 318 | 319 | 顺序扫描两遍数组,分别找出最大值和最小值。 320 | 321 | ####解法二#### 322 | 323 | 采用**分治**策略,将原来长度为N的数组分为前后两个长度为N/2的子数组,分别求出两个子数组的最大值和最小值,较小的最小值和较大的最大值就是原数组的最小值和最大值。 324 | 325 | 算法代码如下: 326 | 327 | (max, min) Search(arr, b, e){ 328 | 329 | //递归退出的边界条件 330 | if(e-b <= 1){ 331 | if(arr[b] < arr[e]) 332 | return (arr[e], arr[b]); 333 | else 334 | return (arr[b], arr[e]); 335 | } 336 | 337 | //递归地去找左半部分的最大值和最小值 338 | (maxL, minL) = Search(arr, b, b+(e-b)/2); 339 | //递归地去找右半部分的最大值和最小值 340 | (maxR, minR) = Search(arr, b+(e-b)/2+1, e); 341 | 342 | //比较左半部分和右半部分的最大值,较大者为整个数组的最大值 343 | if(maxL > maxR) 344 | maxV = maxL; 345 | else 346 | maxV = maxR; 347 | 348 | //比较左半部分和右半部分的最小值,较小者为整个数组的最小值 349 | if(minL < minR) 350 | minV = minL; 351 | else 352 | minV = minR; 353 | 354 | //返回整个数组的最大值和最小值 355 | return (maxV, minV); 356 | } 357 | 358 | ##2.11 寻找最近点对## 359 | 360 | ###问题描述### 361 | 362 | 给定平面上N个点的坐标,找出距离最近的两个点。如下图所示。 363 | 364 | ![](http://i.imgur.com/ErDE7yc.png) 365 | 366 | ###解决思路### 367 | 368 | - 直接的解法是计算各个点两两之间的距离,最后求距离最小值即可。 369 | 370 | - 更高效的解法是采用**分治策略**,把点分为左右两部分,分别计算左右两部分中距离最短的两点,那么整个点集中距离最短的两点可能有三种情况: 371 | 372 | - 都在左半部分,也即是左半部分距离最短的两点。 373 | 374 | - 都在右半部分,也即是右半部分距离最短的两点。 375 | 376 | - 一个点在左半部分,另一个点在右半部分。 377 | 378 | - 前两种情况可以递归求出,关键在于第三种情况的求解:假设左右两部分由直线x=M划分,并且左半部分和右半部分的最近点对距离分别为`MinDist(Left)`和`MinDist(Right)`,那么令`MDist=MinValue(MinDist(Left), MinDist(Right))`,我们只需考虑从x=M-MDist到x=M+MDist之间这个带状区域内的最近点对即可。也就是说,如果是第三种情况,那么这两个点一定在这个带状区域之内。将这个带状区域内的最小点对距离与MDist进行比较,较小者就是整个点集中的最近点对距离。 379 | 380 | ![](http://i.imgur.com/4aZRcNH.png) 381 | 382 | ###扩展问题### 383 | 384 | ####问题描述#### 385 | 386 | 如果给定一个数组arr[0,1,……,N-1],要求找出**相邻**两个数的最大差值。**相邻**的定义:对于数X和Y,如果不存在其他数组中的数在[X,Y]区间内,则X和Y是相邻的。 387 | 388 | ####解决思路#### 389 | 390 | - 设数组中一共有N个数,其中最大的数为max,最小的数为min,那么根据**抽屉原理**,相邻两个数的最大差值一定不小于delta=(max-min)/(N-1)(反证法可证明)。 391 | 392 | - 把区间分成N个**桶**:[min,min+delta],[min+delta,min+delta*2],……,[max-delta,max]。那么根据上述结论,最大的差值应该出现在不同的桶之间(因为不可能出现在同一个桶中,那样的话,最大差值就小于delta了,与上述结论矛盾)。进一步可以知道,最大差值应该是某一个桶的最小值减去前一个非空桶的最大值。因此扫描一遍所有的桶,就可以得出结果了。 393 | 394 | ##2.12 快速寻找满足条件的两个数## 395 | 396 | ###问题描述### 397 | 398 | 找出一个数组中的两个数,让这两个数之和等于一个给定的值。例如对于数组5,6,1,4,7,9,8,给定值SUM=10,那么数组中的(6,4)、(1,9)符合要求。 399 | 400 | ###解决思路### 401 | 402 | ####解法一#### 403 | 404 | - 原问题可以转换为如下问题:对于数组中的每一个数arr[i],都判别SUM-arr[i]是否在数组中。 405 | 406 | - 最原始的查找需要花费O(N^2)时间(对于每一个数都要扫描一趟数组,花费O(N)时间;而一共有N个数,需要扫描N趟)。 407 | 408 | - 可以先将原始数组排序(花费O(N * logN)时间),然后采用二分查找。这样对于每一个数,查找的时间为O(logN);总共N个数需要花费O(N * logN)时间。 409 | 410 | - 更进一步的优化:可以采用hash表来存储数组中的数。由于hash散列查找一个数只需花费O(1)时间,因此N个数的查找共需要花费O(N)时间。但是该算法需要额外O(N)的hash存储空间,因此这属于**空间换时间**的算法。 411 | 412 | ####解法二#### 413 | 414 | - 换个角度考虑这个问题:首先对数组从小到大进行排序,然后在数组首尾设置两个游标(i=0,j=N-1),考察arr[i]+arr[j]和SUM的关系: 415 | 416 | + 若arr[i]+arr[j] = SUM,则说明找到了符合要求的两个数,算法结束; 417 | 418 | + 若arr[i]+arr[j] > SUM,则 j 左移(j--); 419 | 420 | + 若arr[i]+arr[j] < SUM,则 i 右移(i++); 421 | 422 | - 以上算法可以形象地描述为“设立首尾两个游标,然后向中间挤”。该算法只需要排序的O(N * logN)时间、加上扫描一遍数组的O(N)时间即可。 423 | 424 | ##2.13 子数组的最大乘积## 425 | 426 | ###问题描述### 427 | 428 | 给定一个长度为N的整数数组,只允许用乘法,不能用除法,计算任意(N-1)个数的组合中乘积最大的一组。 429 | 430 | ###解决思路### 431 | 432 | ####解法一#### 433 | 434 | - (N-1)个数的组合的乘积,也即除去某一个数之外的剩余所有数的乘积。 435 | 436 | - 可以额外维护三个数组:前i个元素的乘积S[i],后N-i个元素的乘积T[i],除去第i个元素外剩余(N-1)个元素的乘积P[i]。显然`P[i] = S[i-1] × T[i+1]`。其中,S[i]满足`S[i] = S[i-1] × arr[i-1]`,扫描一遍数组即可求出;T[i]满足`T[i]=T[i+1] × arr[i]`,也是扫描一遍数组即可求出;同理,P[i]也可以线性时间内求出。数组P的最大值即为最终结果。 437 | 438 | ####解法二#### 439 | 440 | - 假设数组中N个数的乘积为P,那么考虑P的正负性: 441 | 442 | + 若P为0,则说明数组中至少包含一个0。假设除去一个0之外,其余N-1个数的乘积为Q,则考虑Q的正负性: 443 | 444 | + Q为0,说明数组中至少有两个0,那么任意N-1个数的乘积必然为0,也即任意N-1个数的乘积最大值为0; 445 | 446 | + Q为正数,说明数组中有且仅有一个0,那么任意N-1个数的乘积最大值为Q; 447 | 448 | + Q为负数,说明数组中有且仅有一个0,那么任意N-1个数的乘积最大值为0; 449 | 450 | + 若P为负数,说明数组中有负数存在,那么根据“负负得正”,我们把数组中绝对值最小的负数去掉,剩余元素的乘积就是绝对值最大的正数了,即最终结果。 451 | 452 | + 若P为正数,那么考虑两种情况: 453 | 454 | + 数组中没有正数,全部是负数(当然是偶数个),那么去掉绝对值最大的负数,剩余的N-1个数的乘积就是绝对值最小的负数了,即最终结果; 455 | 456 | + 数组中有正数,那么去掉绝对值最小的正数,剩余的N-1个数的乘积就是绝对值最大的正数了,即最终结果。 457 | 458 | - 这种解法可以更进一步提高效率:不必计算乘积,只需求出数组中正数、负数、0的个数,然后分情况讨论,求得结果。 459 | 460 | ##2.14 求数组的子数组之和的最大值## 461 | 462 | ###问题描述### 463 | 464 | 给定一个有N个整数元素的数组,这个数组当然有很多子数组,那么如何求子数组之和的最大值? 465 | 466 | ###解决思路### 467 | 468 | ####解法一#### 469 | 470 | 最直接的方法,计算每一个子数组arr[i],……,arr[j]的和,其中最大者就是子数组之和的最大值。该算法时间复杂度为O(N^2)。 471 | 472 | ####解法二#### 473 | 474 | - 采用**分治策略**,把原数组分为左右两部分,那么子数组之和的最大值有以下三种可能: 475 | 476 | + 原数组的子数组之和最大值等于左半部分的子数组之和最大值,如下图中的ma; 477 | 478 | + 原数组的子数组之和最大值等于右半部分的子数组之和最大值,如下图中的mb; 479 | 480 | + 原数组的子数组之和最大值跨过左、右两部分,如下图中的mc。 481 | 482 | ![](http://i.imgur.com/SGc6X45.png) 483 | 484 | ![](http://i.imgur.com/q4FDtcl.png) 485 | 486 | - 前两种情况可以递归求出,关键在于第三种情况的求解:由于所求子数组跨过左右两部分,因此它必然包含以arr[N/2 - 1]结尾的、和最大的一段数组,也必然包含以arr[N/2]开头的、和最大的一段数组。这两段数组拼接起来就是原数组中和最大的子数组,即为所求。 487 | 488 | - 该算法的代码如下: 489 | 490 | int maxsum(int start, int end){ 491 | //数组中没有元素 492 | if(start > end) 493 | return 0; 494 | //数组中有一个元素 495 | if(start == end) 496 | return max(0, arr[start]); 497 | //求出原数组的中点,将其划为左右两部分 498 | int mid = (start + end) / 2; 499 | 500 | int leftmax = sum = 0; 501 | //计算出左半部分以arr[N/2 - 1]结尾的、和最大的一段子数组 502 | for(int i = mid; i >= 1; i--){ 503 | sum += arr[i]; 504 | leftmax = max(leftmax, sum); 505 | } 506 | 507 | int rightmax = sum =0; 508 | //计算出右半部分以arr[N/2]开头的、和最大的一段子数组 509 | for(int i = mid; i <= end; i++){ 510 | sum += arr[i]; 511 | rightmax = max(rightmax, sum); 512 | } 513 | /*求出横跨左右两部分的最大和leftmax + rightmax、左半部分的最大和(递归求出)、 514 | 右半部分的最大和(递归求出),三者中的较大者就是原数组中的最大和*/ 515 | return max(leftmax + rightmax, maxsum(start, mid), maxsum(mid+1, end)); 516 | } 517 | 518 | - 该算法由于采取了分治策略,每次都将问题规模减半,因此时间复杂度为O(N * logN)。 519 | 520 | ####解法三#### 521 | 522 | - 通过思考可以得出如下结论:在数组的前i个元素中,最大总和子数组要么在前i-1个元素中(记为maxsofar),要么截止于当前位置i(记为maxendinghere),如下图所示。 523 | 524 | ![](http://i.imgur.com/7NybT2f.png) 525 | 526 | - 通过比较maxsofar和maxendinghere可以求出最大和,代码如下: 527 | 528 | int maxsofar = 0; 529 | int maxendinghere = 0; 530 | 531 | for(int i = 0; i < n; i++){ 532 | //计算新的maxendinghere值(若为负值,则将其重新设为0,因为截止于i的最大总和子数组现在为空了) 533 | maxendinghere = max(maxendinghere + arr[i], 0); 534 | //计算新的maxsofar值(取原来maxsofar值和maxendinghere值的较大者),作为本次迭代之后的最大和 535 | maxsofar = max(maxsofar, maxendinghere); 536 | } 537 | 538 | - 该算法的代码相当微妙,但是效率很高,只需要线性扫描一遍数组即可,时间复杂度为O(1)。 539 | 540 | ###扩展问题### 541 | 542 | ####问题描述#### 543 | 544 | 假如数组是首尾相连的呢?也即最大总和子数组可以从数组末尾绕到开头。 545 | 546 | ####解决思路#### 547 | 548 | - 这个问题的解有两种可能:一是最大总和子数组没有首尾相连(原问题),二是最大总和子数组跨过了首尾。 549 | 550 | - 对于第二种可能,还需分为两种情况: 551 | 552 | + 跨过首尾的子数组就是整个数组。 553 | 554 | + 跨过首尾的子数组不是整个数组,因此它包含两个部分:从arr[0]到arr[i]的一段和从arr[j]到arr[N-1]的一段。其中i arr[k](其中k arr[k] && LIS[k] + 1 > LIS[i]){ 609 | LIS[i] = LIS[k] + 1; 610 | } 611 | } 612 | } 613 | //最后返回LIS中最大的值,就是整个数组中最长递增子序列的长度 614 | return max(LIS); 615 | } 616 | 617 | - 该算法比较巧妙,需要仔细理解。此外,该算法时间复杂度为O(N^2)。 618 | 619 | ##2.17 数组循环移位## 620 | 621 | ###问题描述### 622 | 623 | 把一个含有N个元素的数组循环右移K位,如何尽可能高效地实现? 624 | 625 | ###解决思路### 626 | 627 | - 最直接的方法是,每次将数组中的所有元素右移一位,循环K次,这样算法时间复杂度为O(N * K)。 628 | 629 | - 以上这种方法,当K>N时,算法时间复杂度会超过O(N^2)。而容易看出来右移K位和右移K%N位的效果是一样的。因此当K>N时,只需要进行K%N次“右移一位”操作即可,这样算法时间复杂度不会超过O(N^2)。 630 | 631 | - 除此之外,还有一种巧妙的方法——**三次翻转法**。以序列1234abc为例,要求将该序列循环右移3位,变为abc1234,我们可以采取如下步骤: 632 | 633 | + 先将左边4位(1234)翻转,得到4321abc; 634 | 635 | + 再将右边3位(abc)翻转,得到4321cba; 636 | 637 | + 最后将整个序列翻转,得到abc1234,即为最终结果。 638 | 639 | - 三次翻转算法的代码如下: 640 | 641 | //将数组从起始下标beginner到结束下标end之间的部分翻转 642 | void Reverse(int[] arr, int beginner, int end){ 643 | for(; beginner < end; beginner++, end--){ 644 | int tmp = arr[beginner]; 645 | arr[beginner] = arr[end]; 646 | arr[end] = tmp; 647 | } 648 | } 649 | 650 | //通过三次翻转操作实现数组的循环右移 651 | void RightShift(int[] arr, int N, int K){ 652 | K %= N; 653 | //第一次翻转,将数组左半部分(从0到N-K-1)翻转 654 | Reverse(arr, 0, N-K-1); 655 | //第二次翻转,将数组右半部分(从N-K到N-1)翻转 656 | Reverse(arr, N-K, N-1); 657 | //第三次翻转,将数组整体全部翻转,得到最终结果 658 | Reverse(arr, 0, N-1); 659 | } 660 | 661 | - 三次翻转法的时间复杂度为O(N)。 662 | 663 | ##2.18 数组分割## 664 | 665 | ###问题描述### 666 | 667 | 待补充 668 | 669 | ###解决思路### 670 | 671 | 待补充 672 | 673 | ##2.19 区间重合判断## 674 | 675 | ###问题描述### 676 | 677 | 给定一个源区间[x,y]和N个无序的目标区间[x1,y1]、[x2,y2]、……、[xn,yn],判断源区间[x,y]是否在目标区间内? 678 | 679 | 例如,给定源区间[1,6]和一组无序的目标区间[2,3]、[1,2]、[3,9],即可认为源区间[1,6]在目标区间[2,3]、[1,2]、[3,9]内(因为目标区间合并后实际上是[1,9])。 680 | 681 | ###解决思路### 682 | 683 | - 先对若干个无序的目标区间按起始坐标从小到大进行排序,例如[2,3]、[1,2]、[3,9] -> [1,2]、[2,3]、[3,9];然后将这些区间合并为若干个互不相交的区间,例如[1,2]、[2,3]、[3,9] -> [1,9]。 684 | 685 | - 完成目标区间的排序与合并之后,再检查源区间是否被合并之后的互不相交的目标区间中的某一个所包含。这里可以线性地逐个检查每个目标区间,也可以采用二分查找来检查。 686 | 687 | - 以上算法体现了一种很普遍的思路:在解决问题之前,先对输入数据进行诸如排序、合并等预处理,从而简化后续的处理难度。 688 | 689 | ##2.20 程序理解和时间分析## 690 | 691 | ###问题描述### 692 | 693 | 理解并分析下列程序的含义。 694 | 695 | using System; 696 | using System.Collections.Generic; 697 | using System.Text; 698 | 699 | namespace FindTheNumber 700 | { 701 | class Program 702 | { 703 | static void Main(string[] args) 704 | { 705 | int [] rg = 706 | {2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17, 707 | 18,19,20,21,22,23,24,25,26,27,28,29,30,31}; 708 | 709 | for(Int64 i = 1; i < Int64.MaxValue; i++) 710 | { 711 | int hit = 0; 712 | int hit1 = -1; 713 | int hit2 = -1; 714 | for (int j = 0; (j < rg.Length) && (hit <= 2); j++) 715 | { 716 | if((i % rg[j]) != 0) 717 | { 718 | //hit表示i不能被rg[j]整除的个数。例如hit=5,表示i不能被数组rg中的某5个数整除 719 | hit++; 720 | if(hit == 1) 721 | { 722 | //hit1表示i第一次不能被rg[j]整除时的下标 723 | hit1 = j; 724 | } 725 | else if (hit == 2) 726 | { 727 | //hit2表示i第二次不能被rg[j]整除时的下标 728 | hit2 = j; 729 | } 730 | //若hit大于2,说明i至少不能被数组rg中的某三个数整除,循环退出 731 | else 732 | break; 733 | } 734 | } 735 | /*若hit等于2,说明i不能被数组rg中的某两个数整除,但可以被rg中其余的 736 | 任何数整除;并且hit1+1等于hit2,也即i第一次和第二次不能被整除的数的 737 | 下标相差1(两者相邻)。因此i应当满足以下条件:不能被数组rg中某两个相 738 | 邻的数整除,但能被rg中其余的任何数整除。*/ 739 | if((hit == 2) && (hit1 + 1 == hit2)) 740 | { 741 | Console.WriteLine("found {0}", i); 742 | } 743 | } 744 | } 745 | } 746 | } 747 | 748 | ###解决思路### 749 | 750 | - 这段程序的详细分析见注释,要找的数应满足“不能被2~31这30个数中的某两个相邻的数整除,但可以被其余28个数整除”。 751 | 752 | - 由于该数可以被其中的28个数整除,因此它是这28个数的最小公倍数的整数倍;而该数又不能被两个相邻的数整除,因此该数分解质因数后的因子应该不被这两个相邻的数所包含,或者幂次小于这两个数中相同因子的幂次,所以这两个数很可能是**质数**或**完全平方数**。故最终找到的这两个相邻的数是16和17,该数=2^3 × 3^3 × 5^2 × 7 × 11 × 13 × 19 × 23 × 29 × 31 ≈ 2.1 × 10^12。 753 | 754 | - 由于该数约等于2.1 × 10^12,因此需要进行这么多次for循环,由此可以估算出该程序的运行时间。 755 | 756 | 757 | ##2.21 只考加法的面试题## 758 | 759 | ###问题描述### 760 | 761 | 对于一个64位正整数,写出它所有可能的连续自然数之和的算式。例如给定正整数9,它可以写作4+5,也可以写作2+3+4。 762 | 763 | ###解决思路### 764 | 765 | - 设给定的正整数为X,它可以写作连续i+1个自然数之和,那么有`X = a + (a+1) + (a+2) + …… + (a+i) = a×(i+1) + i×(i+1)/2`。我们可以确定i的取值范围,在这个范围内逐个尝试i值,然后根据上式求出相应的a值,若a值为整数,那么就得到一个连续自然数之和的算式。 766 | 767 | - i的范围可以这样求解:当i最大的情况下,满足`X = 1 + 2 + 3 + …… + (imax+1)`,因此`imax = ((8X+1)^0.5-1)/2 - 1`,从而i的取值范围是`1 <= i <= ((8X+1)^0.5-1)/2 - 1`。 768 | 769 | - 求得i的取值范围之后,在此范围内遍历i值即可。 -------------------------------------------------------------------------------- /编程之美读书笔记(四).md: -------------------------------------------------------------------------------- 1 | # 《编程之美》读书笔记(四) # 2 | 3 | 数学之趣——数学游戏的乐趣 4 | 5 | ##4.1 金刚坐飞机问题## 6 | 7 | ###问题描述### 8 | 9 | 假设一架飞机有N个座位,每个座位对应一位乘客,现在的情况是这N位乘客依次登机,每个乘客都不按自己的座位来坐,而是随机选择一个座位坐下,求问第i位登机的乘客坐到自己的座位的概率是多少? 10 | 11 | ###解决思路### 12 | 13 | - 假设第i个乘客坐到自己座位的概率为F(i),那么F(i)等于前i-1个乘客都不坐在第i个位置的概率(记为P(i-1))乘以第i个乘客随机选择时恰好选择了自己座位的概率(记为G(i))。 14 | 15 | - 而P(i-1)可以分解为前i-2个乘客都不坐在第i个位置的概率P(i-2),以及在此前提下第i-1个乘客也不坐在第i个位置上的概率Q(i-1),即`P(i-1) = P(i-2) × Q(i-1)`。 16 | 17 | - 于是可以得到如下公式:`F(i) = P(i-1) × G(i) = P(i-2) × Q(i-1) × G(i) = Q(i-1) × Q(i-2) × …… × Q(2) × P(1) × G(i)`。 18 | 19 | - 由于有`Q(i) = (N-i)/(N-i+1)`(因为第i个乘客一共有N-i+1个选择,而他不坐在第i+1个位置,其余N-i个选择都有可能发生),`P(1) = (N-1)/N`(因为前1个乘客一共有N个选择,而他不坐在第2个位置,其余N-1个选择都有可能发生),`G(i) = 1/(N-i+1)`(因为第i个乘客有N-i+1个选择,而他恰好选择了自己的座位这一种可能),将这三个式子代入上式得到`F(i) = 1/N`。 20 | 21 | - 以上分析方法体现了将原问题转化为规模较小的子问题、最后合并子问题的解的思想。 22 | 23 | 24 | ##4.2 瓷砖覆盖地板## 25 | 26 | ###问题描述### 27 | 28 | 能否用1×2的瓷砖去覆盖N×M的地板? 29 | 30 | ###解决思路### 31 | 32 | 应该分情况讨论: 33 | 34 | - 若N和M均为奇数,则N×M也是奇数,而1×2的瓷砖覆盖的面积必然是偶数,因此不能用1×2的瓷砖去覆盖N×M的地板。 35 | 36 | - 若N和M中有一个为偶数,不妨设M为偶数,则考虑到由于1×2的瓷砖可以覆盖1×M的面积(需要M/2块瓷砖),因此可以重复N次,使得1×2的瓷砖覆盖N×M的面积。 37 | 38 | ###扩展问题### 39 | 40 | ####问题描述#### 41 | 42 | 求用1×2的瓷砖覆盖2×M的地板有几种方式? 43 | 44 | ####解决思路#### 45 | 46 | - 假设用1×2的瓷砖覆盖2×M的地板有F(M)种方式,那么第一块瓷砖有两种放法,如下图所示。 47 | 48 | ![](http://i.imgur.com/PDaMoGy.png) 49 | 50 | ![](http://i.imgur.com/hKmJ7w2.png) 51 | 52 | - 第一种放法(竖着放)时,问题转化为了剩下2×(M-1)的地板的覆盖方式,即F(M-1)种;第二种方法(横着放)时,由于必然有一块瓷砖位于第一块瓷砖下方,因此问题转化为了剩下2×(M-2)的地板的覆盖方式,即F(M-2)种。并且由于这两类放法肯定不会重叠,因此有递推式`F(M) = F(M-1) + F(M-2)`,其中F(1)=1,F(2)=2。因此可以由该递推式求出F(M),即2×M地板的覆盖方式数目。 53 | 54 | 55 | ##4.3 买票找零## 56 | 57 | ###问题描述### 58 | 59 | --------------------------------------------------------------------------------