读书笔记-算法

Posted Mosthink

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了读书笔记-算法相关的知识,希望对你有一定的参考价值。

几个对数组的算法

1, 找出数组中的最大值:

1
2
3
4
5
double max = a[0];

for(int i = 1; i < a.length; i++)

    if(a[i] > max) max = a[i];

//把最大值马上设定为数组的第一个元素,然后遍历数组,如果有别当前这个最大值更大的元素,则把最大值更新,直到遍历结束;

2, 计算数组的平均值:

1
2
3
4
5
6
7
double sum = 0.0;

for(int i = 0; i < a.length; i++)

    sum += a[i];

double average = sum / a.length;

//算出总值,然后除以数组的元素数;

3,复制数组:

1
2
3
4
5
6
7
double[] b = new double[a.length];

for(int i = 0; i < a.length; i++) 

    b[i] = a[i];

//new 一个和原数组同length同类型的数组,然后遍历赋值每个元素;

4,颠倒数组元素的顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
double [] b = new double[a.length];

for(int i = a.length - 1, j = 0; i > -1 && j < b.length ; i--, j++) 

    b[j] = a[i];

//这是个直观低效率算法,时间消耗(a.length),空间消耗(2 * a.length),并且有两个循环指数i, j;

for(int i =0; i < a.length / 2; i++) {

    double temp = a[i];

    a[i] = a[a.length - 1 - i];

    a[a.length - 1 - i] = temp;
}

偶数个元素的交换过程:

< 2 1, 2, 3, 4

0 4, 2, 3, 1

1 4, 3, 2, 1

奇数个元素的交换过程:

< 2 1, 2, 3, 4, 5

0 5, 2, 3, 4, 1

1 5, 4, 3, 2, 1

//可见,无论是偶数个还是奇数个元素,交换的次数都一样,都是a.length/2,偶数个的时候是全交换,奇数个的时候,是以中间的那个元素为中心点,其他元素都交换,中间元素并不在a.length/2的遍历范围内;这个算法,时间(a.length/2),空间(a.length);

5,a[][] * b[][] = c[][], 矩阵相乘(方阵):

1
2
3
4
5
6
7
8
9
10
11
12
13
int n = a.length;

double[][] c = new double[n][n];

for(int i = 0; i < n; i++)

    for (int j = 0; j < n; j++) {

        for(int k = 0; k < n; k++) //计算行i和列j的点乘

            c[i][j] += a[i][k] * b[k][j];

    }

典型静态方法的实现

1,计算整数的绝对值:

1
2
3
4
5
public static int abs(int x) {
    if(x < 0)    return -x;

    else    return x;
}

//绝对值的规则很简单:不小于零就是本身,反之就返回-x;

2, 计算浮点数的绝对值:

1
2
3
4
5
public static double abs(double x) {
    if( x < 0.0)    return -x;

    else    return x;
}

3, 判断一个数是否是素数:

1
2
3
4
5
6
7
8
9
public static boolean isPrime(int n) {
    if(n < 2)    return false;                        //大于1的自然数,1不是素数

    for(int i = 2; i * i <= n; i++) {              //i * i <=n

        if(n % i == 0)    return false;

    return true;
}

//素数,就是质数。指在一个大于1的自然数中,除了1和此整数自身外,不能被其他自然数整除的数。

判断的关键点:

a, 小于2,不是;

b,从2到n遍历,遍历到一个i i > n之前的数就提前结束遍历,因为2到满足i i <= n的i之间的这些数如果能整除n,那么i之后到n的这些数也能;以满足i * i <=n为分界线,1…n之间的数对于整除n来说,是对称的;

判断n能否被i整除:n % i 的值是否为0;

4, 计算平方根(牛顿迭代法):

1
2
3
4
5
6
7
8
9
10
11
12
13
public static double sqrt(double c) {

    if(c < 0) return Double.NaN;

    double err =1e-15;                 //1乘以10的负15次方

    double t = c;

    while(Math.abs(t - c / t) > err *t)

        t = (c/t + t) / 2.0;
    return t;
}

//不懂, TODO

5, 计算直角三角形的斜边:

1
2
3
4
public static double hypotenuse(double a, double b) {

    return Math.sqrt(a * a + b * b);
}

6, 计算调和级数:

1
2
3
4
5
6
7
8
9
10
public static double H(int n) {

    double sum = 0.0;

    for(int i = 1; i <=n; i++) 

        sum += 1.0 / i;

    return sum;
}

//形如1/1+1/2+1/3+…+1/n+…的级数称为调和级数,它是 p=1 的p级数。 调和级数是发散级数。在n趋于无穷时其部分和没有极限(或部分和为无穷大)。


二分查找的递归和循坏实现法

递归总有一个最简单的情况-方法的第一条语句总是包含return的条件语句;

递归调用总是去尝试解决一个规模更小的子问题,这样递归才能收敛到最简单的情况;

递归调用的父问题和尝试解决的子问题之间不应该有交集;

二分查找:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static int rank(int key, int[] a) {

    return rank(key, a, 0, a.length - 1);

}

public static int rank(int key, int[] a, int lo, int hi) {

    if(lo > hi)

        return -1;

    int mid = lo + (hi - lo) / 2;   //数组并没有被拆分,所以这里(hi - lo)/2必须再加上lo

    if(key < a[mid])     return rank(key, a, lo, mid - 1);

    else if(key > a[mid]     return rank(key, a, mid +1, hi);

    else     return mid;
}

//如果原始的方法参数不怎么适合递归或者不够递归方法,就另写一个满足要求的递归方法,用原始的调用之;

比如二分查找的时候,并没有给定递归时需要的低坐标和高坐标,如果坚持要在原始方法中使用递归,那么必须对数组进行拆分和复制,效率低下,浪费空间;

在不拆分数组的情况下,“父问题和子问题之间不应该有交集“, 所以mid不是简单的lo + hi /2了;mid不用再被传给子问题,因为== mid的话就是解了已经,低位传lo到mid-1;高位传mid + 1到hi;


循环实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int lo = 0;

int hi = a.length - 1;

while(lo <= hi) {

    int mid = lo + (hi - lo) / 2; //不拆分数组的话,始终是这个公式

    if(key < a[mi])     hi = mid - 1;

    if(key > a[mi])     lo = mid + 1;

    else     return mid;
}

return -1;

不用temp实现swap

1
2
3
4
5
a = a + b;

b = a - b;

a = a - b;

Dijkstra双栈算术表达式求值法

双栈:一个操作数栈,一个操作符栈;

从左到有遍历算数表达式:

1, 忽略左括号;

2, 将数字push入操作数栈;

3, 将运算符push入操作符栈;

4, 遇到有括号时,pop一个运算符,pop出所需数量的操作数,并将运算符和操作数的运算结果push入操作数栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public static double calc(String[] equation) {

    Stack<String> ops = new Stack<String>();

    Stack<Double> vals = new Stack<Double>();

    for(String s : equation) {

        if(s.equals("("))

            ;

        else if(s.equals("+"))

            ops.push(s);

        else if(s.equals("-"))

            ops.push(s);

        else if(s.equals("*"))

            ops.push(s);

        else if(s.equals("/"))

            ops.push(s);

        else if(s.equals("sqrt"))

            ops.push(s);

        else if(s.equals(")")) {

            String op = ops.pop();

            double v = vals.pop();

            if(op.equals("+")) 

                v = vals.pop() + v;

            if(op.equals("-")) 

                v = vals.pop() - v;

            if(op.equals("*")) 

                v = vals.pop() * v;

            if(op.equals("/")) 

                v = vals.pop() / v;

            if(op.equals("sqrt")) 

                v = Math.sqrt(v);

            vals.push(v);

        } else

            vals.push(Double.parseDouble(s));

    }

    return vals.pop();
}

这其实就是编译原理中的解释器。

应该也有其他方法,比如全部push入栈之后再依次出栈,前提是优先级用括号来明示。


堆栈的数组和链表实现以及队列的链表实现

堆栈的意义:

堆栈并不是为了迭代的一个容器,虽然它是一个可迭代的容器,但它不应该被应用于静态的数据存储场景;

堆栈应该应用于动态的运算、过滤等场景;

创建泛型类型的数组作为数据存储:

直接创建泛型数组是不可以的:T[] items = new T[2];

只能通过这种方式创建:T[] items = (T[])(new Object[2]);

动态增减数组大小:

动态增减的数组实现:没有高深的方法,就是new一个新的数组,把值拷贝过来,再把引用赋予新的数组对象:

1
2
3
4
5
6
7
T[] temp = (T[]) new Object[newLength];

for(int i = 0; i < N; i++)     //如果是减小数组,这里的条件需要修改

    temp[i] = a[i];

a = temp;

增:当数组满的时候,直接增加1倍;

减:当数组不满1/4的时候,减少至1/2;

以上是基于内存开销和性能之间的平衡,尤其是缩减数组,不能一个一个减,也不能不满1/2的时候直接减掉1/2,这样数组马上又满了,可能又需要增。

Stack内部使用动态增减的数组后,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void push(T item) {

    if(N == a.length)

        resize(2 * a.length)

    a[N++] = item;

}

public T pop() {

    T item = a[--N];

    a[N] = null;  

    if(N > 0 && N == a.length / 4) //如果堆栈只满1/4,减为1/2,还能有1/2的空余;

        resize(a.length / 2);

    return item;
}

pop()方法中要避免内存泄漏:

对象游离: Stack的pop方法写的不好,就有可能导致内存泄漏;

用数组实现堆栈,pop之后,当前对象在堆栈范围内已经无用了,如果客户代码那也用完了这个对象,其该被回收,但是,因为Stack内部的数组还有对这个对象的引用,导致无法被GC,除非再次push,该数组位的引用值被重新指向另一个对象,原来那个对象就被GC了;

如果用API中的List实现堆栈,因为List本身的remove方法已经采取了避免对象游离的措施,所以就没这个问题;

用数组存储和用链表存储:

堆栈本身并不用于遍历,所以操作(push和pop)的用时都跟集合大小无关,不管是用数组还是用链表实现;

用数组存储的明显缺点就在于,push,pop会不定期地引起数组的调整,调整数组的耗时和栈大小成正比,克服这个缺陷就是用链表代替之:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private Node first = null; //栈顶节点

private Class Node { //描述栈帧的节点内部类定义

    T item;

    Node next;

}

public void push(T item) {

    Node oldFirst = first;

    first = new Node();

    first.item = item;

    first.next = oldFirst;

    N++;

}

public T pop() {

    T item = first.item;

    first = first.next();

    N--;

    return item;
}

不用担心对象游离的问题,first = first.next之后,由于堆栈里并没有数组结构,出栈的对象在栈内不会再被引用,没用就回收掉了;

堆栈也是集合,也要实现迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Stack<T> implements Iterable<T>

public Iterator<T> iterator() {

    return new Iterator<T> {

        private int i = N;

        public boolean hasNext() {

            return i > 0;

        }

        public T next() {

            return a[--i];

        }

        ...

    }

链表实现的迭代:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Stack<T> implements Iterable<T>

public Iterator<T> iterator() {

    return new Iterator<T> {

        private Node current = first;

        public boolean hasNext() {

            return current != null;

        }

        public T next() {

             T item = current.item;

            current = current.next;

            return item;

        }

        ...

    }

链表实现的队列就是一种堆栈,仍然从first出队,但是从last入队:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public boolean isEmpty() {

    return first === null;

}

public void enqueue(T item) {

    Node oldLast = last;

    last = new Node();

    last.item = item;

    last.next = null;

    if(isEmpty())

        first = last;

    else

        oldLast.next = last;

    N++;

}

public Item dequeue() {

    T item = first.item;

    first = first.next;

    if(isEmpty())

        last = null;

    N--;

    return item;
}

到底用数组还是链表:

堆栈是LIFO,数组实现的话,总是在数组的末尾进行赋值和置null,这个可以接受;但数组的调整大小问题导致数组实现又不是特别能接受,而链表就完全不存在这个问题;

队列是FIFO,数组实现的话,假如入队末尾,那么出队必然在开头,删除开头元素要引起数组整体挪动;或者反过来,入队在开头,则出队在末尾,入队得在开头添加元素,引起数组整体挪动;所以说,队列一点都不适合用数组实现。


常数、对数、线性、线性对数、平方、立方、指数;

一般来说,平方、立方、指数级别的算法对于大规模的问题是不可用的;

logN的底数对算法分析来说相当于一个常数,所以可以忽略底数到底是几;

2-sum问题的平方级算法:

1
2
3
4
5
6
7
for(int i =0; i < N; i++) 

    for(int j = i + 1; j < N; j++) 

        if(a[i] + a[j] == 0)

            count++;

这个复杂度是(N-1) + (N-2) + … + (N-N) = N的平方 + N的平方/2 =-= N的平方;

改进的算法:

1
2
3
4
5
6
7
sort(a);

for(int i = 0; i < N; i++) 

    if(BinarySearch(-a[i], a) > i)

        count++;

这个算法的思路,单循环数组a,对于每一个a[i]:

1, 如果二分查找找不到-a[i],计数器不增加;

2, 如果二分查找到的-a[i]是a[j],如果j > i,计数器增加,反之如果j < i,因为a是排序了的,说明这次查找之前已经用a[j]找到过a[i]了,重复了,计算器不增加;

对于N次单循环,每次都二分查找了,二分查找的复杂度是对数级,所以这个算法的总复杂度是线性对数级;

相应的,原来为N的立方的3-sum问题可被优化为N的平方对数级的;


优先队列的二叉堆实现

【优先队列】

堆栈:删除最新元素;

队列:删除最旧元素;

优先队列:删除最大元素和插入元素;

优先队列实现的两个方式:

1, 惰性的,使用无序数据结构,插入元素不做任何操作,删除最大元素时再查找最大元素;

2, 主动的,使用有序数据结构,插入元素时就排到合适的位置,删除最大元素时直接删第一个;

优先队列的初级实现:

                        插入元素                        删除最大元素

有序数组 N 1

无序数组 1 N

栈和队列的操作的复杂度都是个常数;

优先队列用数组初级实现的话,操作的复杂度都是线性的;

我们试图探寻更好性能的优先队列实现;

【二叉堆】

用数组表示完全二叉树:

将二叉树的节点按照层级顺序放入数组中,根节点在位置1,它的子节点在位置2和3,而子节点的子节点则分别在4、5、6、7;

不使用数组的第一个位置;

对于一个节点,它在数组中的是索引是k,那么它的父节点的索引是:下取整(k/2)

子节点的索引分别是2k和2k+1;

堆有序:

当一棵二叉树的每个节点都大于等于它的两个子节点时;

在堆有序的二叉树中,从任意节点向上,都能得到一列非递减的元素;从任意节点向下,都能得到一列非递增的元素;

堆有序化-上浮:

如果堆的有序状态因为某个节点变得比它的父节点更大而打破,就需要交换它和它的父节点;

交换后,这个节点仍然可能比现在的父节点大,所以需要继续往上交换;

1
2
3
4
5
6
7
8
void swim(int k) {

    while(k > 1 && pq[k/2] < pq[k]) {

        swap(pq, k/2, k);

        k = k / 2;
}

同理,下沉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void sink(int k) {

    while(2 * k <= N) {
        int j = 2 * k;

        if(j < N && pq[j] < pq[j+1]) //选取两个子节点中较大的一个往上交换

                j++;

        if(pq[k] >= pq[j])   //结束下沉,已经比字节点大了

                break;

        swap(pq, k, j); //下沉

        k = j;
}

在实现二叉堆的数组中,插入一个数据到末尾是上浮;删除第一个数据,这个数据就是最大元素,然后把数组最末尾的元素放到顶端,让其下沉;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class MaxPQ<Key extends Comparable<Key>> {

    private Key[] pq;

    private int N = 0;

    public MaxPQ(int maxN) {

        pq = (key[]) new Comparable[maxN+1];

    }

    public boolean isEmpty() {

        return N == 0;

    }

    public int size() {

        return N;

    }

    public void insert(Key v) {

        pq[++N] = v;

        swim(N);

    }

    public Key delMax() {

        Key max = pq[1];

        swap(1, N--);

        pq[N+1] = null;

        sink(1);

        return max;

    }
}

由于插入和删除都最多是一次根节点到叶节点的堆秩序恢复,跟节点到叶节点的最长路径是lgN;所以二叉堆实现的优先队列的插入和删除的复杂度都是lgN(比较次数:lgN+1, 2lgN);


如何检测一个链表中是不是有循环结点?

两个指针,分别表示乌龟和兔子,乌龟每次往下走1步,兔子每次走两步,如果兔子走到了Next为Null的节点,说明没有循环,否则他们一定相遇,表示有循环。

以上是关于读书笔记-算法的主要内容,如果未能解决你的问题,请参考以下文章

《算法导论》读书笔记

数据结构与算法(刺猬书)读书笔记----目录

算法作业13——《算法图解》读书笔记

[算法读书笔记]二分查找算法

[算法读书笔记]二分查找算法

[算法读书笔记]二分查找算法