【数据结构与算法分析】第一章、第二章总结

昨天晚上7点在长沙出发坐11个小时的火车回家,第一次坐硬卧回家还买到了一张下铺票,到底要比硬座舒服了很多。上午休整了半天下午就这干活,虽然放假但是手中的work哪能说停下就停下呢?毕竟也是自己喜欢的work。2016有很多事情都迫在眉睫,虽说是寒假但已然是1月18号,留给我的时间已经很紧张很紧张啦。回家带了5本书,一本Thinking In Java、一本Data Structures And Algorithm Analysis In Java , 三本Android进阶,看起来任务还蛮重啊。

chapter.1 – 引论

第一章,引论,主要介绍这本书的将要用到的一些基础知识,包括数学知识有级数、模运算、证明方法的归纳演绎等;包括Java的递归特性以及函数对象等。

Java中实现泛型的组件主要有四个,一是所有引用类型数据的存储操作方法接收的形参都用Object来表示,Object是所有对象的父类,也就意味着存储方法可以接收任意引用类型的参数,比如我们可以存出一个String类型的字符串,也可以是一个Student[] 类型的对象数组。而在读取方法中由于我们存储的类型Object类型,所以取出时也就必然是Object,那么问题来了,我们怎样还原到我们存储时的原本类型呢?这里就需要类型强制转换!把取出来的Object数据强制转换为原来的String字符串或者Student[]对象。具体看代码如下

package com.demo.datastructure;

/*
 * 方法类,封装read()、write()方法
 * read()---->return the stored value
 * write(Object x)---->x is stored
 * */

public class Method {

    private Object storedData;

    public Object read() {
        return storedData;
    }

    public void write(Object x) {
        storedData = x;
    }

}


package com.demo.datastructure;

public class StorDataTest {

    public static void main(String[] args) {
        Method m = new Method();
        m.write(45);
        // 类型强制转换,或者调用toString()方法也可以
        String result = (String) m.read();
        System.out.println("result are:" + result);
    }

}

二是基本类型的包装类的自动装箱、自动拆箱,在Java中虽然没一个引用类型都和Object类型相容,但是8种基本类型却不能与Object相容,所以就有了8种基本类型各自对应的包装类,比如int—->Integerboolean—->Boolean。比如上面的m.write(45) 就是自动装箱,把基本类型的int型数据45自动装箱为Integer类型,有自动装箱自然就有自动拆箱,当取出数据时如果int i = m.read() 那么此时就把Integer类型自动拆箱为int类型。

三是使用接口类型表示泛型。我们考虑,通过比较对象数组找出最大项的问题,我们不能直接找出对象数组的最大元素。最简单的思路就是让我们的对象数组类实现Comparable接口,通过调用compareTo()方法来确定数组元素的顺序,因为compareTo()对所有的comparable都是现成可用的。看如下代码

package com.demo.findmax;

class FindMaxDemo {
    /*
     * return max item in arr
     * precondition : arr.length > 0
     * */
    public static Comparable findMax(Comparable[] arr) {
        int maxIndex = 0;
        for (int i = 1; i < arr.length; i++) {
            if (arr[i].compareTo(arr[maxIndex]) > 0) {
                maxIndex = i;
            }
        }
        return arr[maxIndex];
    }
    /*
     * Test findMax on shape and String objects
     * */
    public static void main(String[] args) {
        String[] st1 = {"Dell", "Jobs", "Bill", "Frank"};
        System.out.println(findMax(st1));
    }

}

这是我们要注意,第一,只有实现Comparable接口的那些对象才能作为Comparable数组的元素被传递,仅有comparaTo()方法但并未实现Comparable接口的对象不是Comparable的,它不具备必须的IS-A关系;第二,如果Comparable数组有两个不相容的对象,比如一个String,一个Integer,那么compareTo()方法将抛出ClassCastException异常;第三,一定要注意,基本类型不能作为Comparable传递,但是他们的包装类可以,因为包装类实现了Comparable接口;第四,接口究竟是不是标准的库接口倒不是必须的;最后,这个方案不是总能行的通,因为有时宣称一个类实现所需的借口是不可能的,例如一个类可能是标准库中的类,而接口是自定义的接口,这就行不通,再例如一个类是final类,那么我们就不可能再扩展它以创建一个新类。

四是数组类型的兼容性,在Java中数组是类型兼容的,称作covariant araay type(协变数组类型),每个数组都会指明他所兼容的数据类型,如果把一个不兼容的类型插入到数组中,虚拟机将会抛出ArrayStoredException异常。不过有时为了避免类型混乱的问题发生,就需要特别这些数组不是类型兼容的。

Java5开始提供泛型支持,这些泛型类很容易使用。但是泛型类的api实现却不是那么容易的一件事情,内部编码非常复杂,在这里我们就不涉及。我们主要来看看下面几个方面。

首先是泛型类和接口的概念。泛型类在声明时需要为其指定一个或多个类型参数,这些类型参数放在类名后面的尖括号内,例如public class GenericDemo 。也可以声明接口是泛型的,例如,在Java5以前Comparable接口不是泛型的,而他的compareTo()方法接收一个Object作为参数,于是传递到compareTo()方法的任何引用变量即使不是一个合理的类型也会编译通过,而只是在运行时抛出ClassCastException错误。在Java5中Comparable接口是支持泛型的。例如现在String类实现Comparable接口并有一个compareTo()方法,这个方法以一个String作为其参数。通过是类编程泛型类,以前只有在运行时才能抛出的错误现在编译时就能抛出。大大提高了开发效率。

泛型通配符有两种形式, ?表示占位符,是ZiClass的父类; ?是FuClass的子类。泛型(以及泛型集合)不是协变的(但是有意义),而数组是协变的。 泛型类可以由编译器通过类型擦除过程转变成非泛型类。

chapter.2 – 算法分析

算法:是为了求解一个问题所要遵循的、被清除指定的简单指令的集合。

我们考虑这样两种情况:一个算法给定并认为是正确的,当求解一个问题的时候需要长达一年的时间,而且运行起来需要几个GB的内存空间,这显然是不合理的。

那么上面算法运行的时间长短就是指算法的时间复杂度,算法运行所需的空间资源就是算法的空间空间复杂度。

这一章将讨论:

  • 如何估算一个程序所需的时间;
  • 如何将一个程序所需的时间从年、天降低到秒甚至更少;
  • 粗心使用递归的后果;
  • 将一个数自乘得到其幂,以及计算两个数的最大公因数的非常有效的算法。

基础,四个数学定义:

  1. T(N) = O( f(N) ) 表示T(N)的增长率小于或等于 f(N)的增长率;
  2. T(N) = ∩( g(N) ) 表示T(N)的增长率大于或等于 g(N)的增长率;
  3. T(N) = ⊙( h(N) ) 当且仅当T(N) = O( f(N) )T(N)= ∩( g(N) ) 表示表示T ( N )的等于h (N)的增长率;
  4. T(N) = o( h(N) ) 表示T(N)的增长率小于p(N)的增长率;

(1)式意味着f(N)T(N)的上界,相反T(N)f(N)的下界;(2)式意味着T(N)g(N)的上界,相反g(N)T(N)的下界;(3)式是(1)、(2)式的交集情况;(4)式意味着对于任意的N都有p(N)>T(N)。要注意的是当两个函数的增长率相同的时候,既可以是 T(N) = O( f(N) ),也可以是T(N)= ⊙( h(N) )

两个重要的法则:

1。 T1( N ) = O ( f (N) ) 且T2( N ) =∩ ( g (N) ) ,那么有
(a) T1( N ) + T2( N ) = O ( f (N) + g (N) ) (或者可以写成max( O ( f (N) ) , O ( g (N) ) ,虽然这种写法不正式但很直观 ) );
(b) T1( N ) * T2( N ) = O ( f (N) * g (N) )

2。 如果T ( N ) 是一个k次多项式,则T ( N ) = ⊙ ( h (N^k) ) .

模型假设:一台标准的计算机,在机器中指令被顺序的执行,模型机做任何一件简单的工作都恰好花费一个时间单位,比如依次加法操作、依次减法操作都会花费一个时间单位;还假设模型机有无限大小的内存(当然在现实中计算机不可能在任何一个简单操作上都用相同的时间,也不可能有无限大小的内存,但是这样假设就可以避免现实中的一些严重问题,比如缺页中段,使得模型机可以处理一些高效的算法)。实际上算法分析的结果为程序在一定的时间范围内能够中终止运行提供了保障,程序可以提前结束运行,但绝不可能错后。

要分析的问题:运行时间。主要考虑平均情形和最坏情形,因为平均情形的性能反映典型行为,最坏情形的性能则代表对任何可能的情况的一种保证。偶尔我们也会考虑下最好情形,但是没有多大的代表性。还要注意,虽然我们是以Java程序为例来讨论,但是所得到的界实际上是算法的界而不是程序的界,程序是算法以一种特殊编程语言的实现,程序设计语言的细节几乎总是不影响算法分析的答案,即可以这样理解,对求解某个问题同一种算法无论是用Java语言实现,还是用C++来实现,对其算法分析的结果都是影响不大的。

案例分析:计算1^3 + 2^3 + 3^3 + 4^3 + ...... + N^3 = ? .程序如下所示

1   public static int sum(int n) {
2       int sum = 0;
3       for (int i = 0; i <= n; i ++) {
4           sum += i*i*i;
5       }
6       return sum;
7   }

对于这个程序段的分析是简单的,所有的声明均不计时间,第2、第6行各占一个时间单元,第4行每执行一次占4个时间单元(两次乘法、一次加法、一次赋值),共执行N次占用4N个时间单元,第3行的初始化i、判断i<=N以及i的自增运算隐含着开销,所有这些的开销是初始化1个时间单元,所有的判断为N+1个时间单元,而所有的自增运算为N个时间单元,我们忽略调用方法和返回值的开销,得到总量是6N+4个时间单元。而当N趋近于无限大的时候,计算第2、6行的时间开销显然相对于循环是毫无意义的愚蠢的,因此总结出以下这些规则:

法则1——for循环:一个for循环的运行时间至多是该for循环内部那些语句(包括判断)的运行时间乘以迭代的次数,即kN;
法则2——多层嵌套for循环:如果为两层嵌套,即为(kN)^2;
法则3——顺序语句:将各个语句的运行时间求和即可;
法则4——if/else语句:

if (condition) {
    S1;
} else {
    S2;
}

一个if/else语句的运行时间从不超过判断的运行时间在加上S1S2中运行时间较长者的运行时间的总和。虽然在某某些特殊情况下可能会过多的估计了运行时间,但是绝不会估计过低。

一般分析的策略是由内向外分析,从内部或最深层部分想歪展开分析,如果有调用则要首先分析这些方法调用,如果有递归过程那么存在几种选择,若递归实际上只是被遮上面纱的for循环,则分析通常是简单的。

现在我们讨论求最大子序列和的案例。输入任意长度为N的整数数组(包括负数),求出最大的子序列之和。书中提供了4个算法,如下

public static int maxSum1(int[] arr) {

    int maxSum = 0;
    for (int i = 0; i < arr.length; i++) {
        for (int j = i; j < arr.length; j++) {
            int thisSum = 0;
            for (int k = i; k <= j; k++) {
                thisSum += arr[k];
                if (thisSum > maxSum) {
                    maxSum = thisSum;
                }
            }
        }
    }
    return maxSum;
}

这种算法的运行时间为O(N^3)。下面看第二种算法如下,这种算法涉及到“分治策略”

    public class maxSubSum2(int[] arr) {

        int maxSum = 0;
        for (int i= 0; i < arr.length; i++) {
            int thisSum = 0;
            for (int j = i; j < arr.length; j++) {
                thisSum += arr[j];
                if (thisSum > maSum) {
                    maxSum = thisSum;
                }
            }
        }
        return maxSum;
    }

这种算法的运行时间为O(N^2)。下面看第三种算法,这种算法运用了“分治策略”的思想和递归调用的实现,代码如下

private static int  maxSubSum3(int[] arr) {

    if (left == right) {    // base cse
        if (arr[left] > 0) {
            return arr[left];
        } else {
            return 0;
        }
    }

    int center = ( left + right ) / 2;
    int maxLeftSum = maxSumRec(arr, left, center);
    int maxRightSum = maxSumRec(arr, center + 1; right);

    int maxLeftBorderSum = 0, leftBorderSum = 0;
    for (int i = center; i >= left; i--) {
        leftBorderSum += arr[i];
        if (leftBorderSum > maxLeftBorderSum) {
            maxLeftBorderSum = leftBorderSum;
        }
    }

    int maxRightBorderSum = 0, rightBorderSum = 0;
    for (int i = center + 1; i <= right; i++) {
        rightBorderSum += arr[i];
        if (rightBorderSum > maxRightBorderSum) {
            maxRightBorderSum = rightBorderSum;
        }
    }
    // max3()方法返回三个参数中的最大值
    return max3(maxLeftSum, maxRightSum, maxLeftBorderSum + maxRightBorderSum);
}

public static int maxSubSum3 (int[] arr) {
    return maxSunRec(arr, 0, arr.lenght-1);
}

不难看出上面这种算法时间复杂度是O(N logN),是三种算法里最简单的一种算法,但是程序看起来却更长,也就需要付出更多的编程努力。然而程序长并公布意味着程序就好,相反程序成长也并不意味着程序就不好。下面看第四种算法,代码如下

public static int maxSum4(int[] arr) {

    int maxSum = 0,thisSum = 0;
    for (int j = 0; j < arr.length; j++) {
        thisSum += ar[j];
        if (thisSum > maxSum) {
            maxSum = thisSum;
        } else {
            thisSum = 0;
        }
    }
    return maxSum;
}

这种算法的时间复杂度为O(N),是许多聪明的算法中的典型算法,虽然正确但是正确性却不是那么容易看出来的。这个算法的优点是只对数据进行一次扫描,一旦数组arr[i] 被读入并被处理,它就不需要被记忆。如果通过磁盘读入或者通过互联网传送读入,它就可以按顺序读入而不需要在主内存中存储数组的任何元素。不仅如此,在任意时刻算法都能给出读入序列的正确答案。

其他的算法不具有这个特性,具有这种特性的算法叫做联机算法,仅需要常量空间并以线性时间运行的联机算法几乎是完美的算法。

算法分析最混乱的方面主要集中在对数上面,某些分治算法将以O(N logN)时间运行,对数最常出现的规律可以进行一下概括:如果一个算法用常数时间O(1) 将问题的大小消减为其一部分,通常是其1/2,那么该算法就是O(log N) 。另一方面,如果使用常数时间只是把问题减少一个常数的数量,比如减少1或者2,那么这种算法就是O(N) 的。下面给出三个具有对数特点的例子:

第一个案例:这半查找(binary search)算法
给定一个整数X和一个随机整数数组arr[n] , 数组已排序。找出数组中和X相等的元素arr[i],如果数组元素中不存在X ,则返回i = -1

public static > int binarySearch(AnyType[] arr, AnyType x) {

    int low = 0, high = arr.lenght-1;
    while (low <= high){
        int mid = (low + high) / 2;
        if (arr[mid].compareTo(x) < 0){
            low = mid + 1;
        } else if (arr[mid].compareTo(x) > 0) {
            high = mid -1;
        } else {
            return mid;     // found
        }
    }
    return NOT_FOUND;       // not fonud is defined as -1
}

显然每次迭代在循环内用时为O(1),因此需要分析循环的次数才能确定时间复杂度。循环是从high-low=N-1开始并在high-low<=-1结束,每次循环后high-low的值至少将该次循环前的值折半,于是循环的次数至多是[log(N-1)]+2。当数据不允许插入操作、删除操作的时候,或者数据已经是排好序的时候,这种操作可能是非常有用的。

第二个案例:欧几里得算法
欧几里得算法即计算最大公因数的算法,两个整数的最大公因数是同时整除二者的最大整数。代码如下

public static long gcd(long m, long n) {

    while (n != 0) {
        long rem = m % n;
        m = n;
        n = rem;
    }
    return m;
}

这是一个快速算法,迭代次数至多是2logN = O (logN)。算法连续计算余数知道余数为0为止,最后的非零余数就是最大公因数。

第三个案例:幂运算
计算一个数的幂,通常我们得到的结果一般都是相当大的。我们用乘法的次数作为运行时间的度量。计算X^N的明显算法就是使用N-1次的自乘运算,但有一种递归算法的效果更好。N<=1是这种递归的基准情形。否则,若N是偶数,X^N = X^N/2 * X^N/2 ;若N是奇数,X^N = X^N/2 * X^N/2 * N 。显然所需的乘法次数最多是2logN。代码如下

public static long pow(BigInteger x, int n) {

    if (n == 0)
        return 1;
    if (n == 1)
        return x;
    if (isEven(n))
        return pow(x*x, n/2);
    else
        return pow(x*x, n/2) * x;
}

你可能感兴趣的:(学习与读书)