【算法笔记】第四章:算法初步

【算法笔记】第四章:入门篇(2)——算法初步

标签(空格分隔):【算法笔记】


第四章:入门篇(2)——算法初步

文章目录

    • 4.1 排序
      • 4.1.1 选择排序
      • 4.1.2 插入排序
      • 4.1.3 排序题与sort函数的应用
    • 4.2 散列
      • 4.2.1 散列的定义与整数散列
      • 4.2.2 字符串hash初步
    • 4.3 递归
      • 4.3.1 分治
      • 4.3.2 递归
    • 4.4 贪心
      • 4.4.1 简单贪心
      • 4.4.2 区间贪心
    • 4.5 二分
      • 4.5.1 二分查找
      • 4.5.2 二分法拓展
      • 4.5.3 快速幂
    • 4.6 two pointers
      • 4.6.1 什么是双指针
      • 4.6.2 归并排序
      • 4.6.3 快速排序
    • 4.7 其他高效技巧与算法
      • 4.7.1 打表
      • 4.7.2 递推
      • 4.7.3 随机选择法
    • 我的微信公众号

4.1 排序

4.1.1 选择排序

  • 选择排序是最简单的算法之一,它是指对一个序列A中的元素A[1] ~ A[n], 令i从1 到 n 枚举,进行n堂操作,每趟从待排序部分选择出最小的元素,令其与待排序部分的第一个元素进行交换。在n趟排序之后,所有的元素便是有序的。
void selectSort(){
    for( int i = 0; i < n; i++){
        int k = i;
        for( int j = i; j < n; j++){
            if( A[j] < A[k])
                k = j;
        }
    swap( A[i], A[k]);
    }
}

4.1.2 插入排序

  • 插入排序也是最简单的依赖排序方法,它是将带插入元素一个个插入初始一有序部分中的过程,而插入位置的选择则遵循了使擦汗如后任然保持有序的原则,一般时从后向前枚举已有序部分来确定插入位置。
void insertSort(){
    for( int i = 1; i < n; i++){
        int temp = A[i], j = i;
        while( j > 0 && temp < A[ j - 1]){
            A[j] = A[j-1];
            j--;
        }
        A[j] = temp;
    }
}

4.1.3 排序题与sort函数的应用

  • 在排序题中,推荐直接使用C语言中的库函数qsort或者C++中的sort函数直接进行排序。

  • 相关结构体的定义:根据题意定义结构体存放信息即可。

  • cmp函数的编写:
    补充:strcmp 是string.h 文件下用来比较两个char型数组的字典序大小的函数。当st1 的字典序小于 str2 的字典序时,strcmp(str1,str2)返回一个负数;当二者相等时,strcmp(str1,str2)返回零;当st1 的字典序大于 str2 的字典序是,strcmp(str1,str2)返回一个整数。

  • 排名的实现:规则一般是,分属不同的排名不同,分数相同的占用一个排位。

4.2 散列

4.2.1 散列的定义与整数散列

  • 散列(hash):将元素通过一个函数转化为整数,使得该整数可以尽量唯一的表示这个元素。这个转化函数为散列函数H, 即 将key转换为 H(key).
  • 当key是整数时的常用散列函数:直接定址法、平方取中法、除留余数法等
  1. 直接定址法:令$ H(key) = key$.例如以空间换时间,令key作为数组下标,是最常见和最实用的散列应用。或者线性变换,例如 $ H(key) = a * key + b$.
  2. 平方取中法:取key的平方的中间若干位作为hash值,很少使用。
  3. 除留余数法:比较实用的散列函数。指把key除以一个数 mod 得到的余数作为hash值,即: H ( k e y ) = k e y H(key) = key % mod H(key)=key 通过这个散列函数,可以把很大的数字转换为不超过mod的整数,这样就可以将它作为可行的数组下标(显然,数组长度 TSize 必须不小于 mod,否则会产生越界)。
    另外,mod的值一般为一个素数,这样 H(key) 才能尽可能覆盖[0,mod)范围内的每一个数。为了方便,取 TSize = mod 为一个素数。
    另一个随之而来的问题,通过除留余数法得到的hash值可能是相同的,这样便会发生冲突
  • 解决冲突的方法:开放定址法 和 链地址法。其中开放定址法又包括 线性探查法 和平方探测法。
  1. 线性探测法(Linear Probing):当得到key的hash值H(Key),如果表中下标为H(key)已经被其他某个元素占用,那么就检查 H(key) + 1 是否被占用,如果没有,就占用这个位置。如果被占用,那么就不断检查下一个位置。如果检查过程中超出了表长,那么就回到表的首位检查,直到找到可用位置,或者发现表中所有的位置均被占用。缺点:容易导致扎堆,进而降低效率。
  2. 平方探测法( Quadratic probing):为了避免扎堆,当表中下标为H(key)的位置被占用时,就依次检查以下位置:H(key) + 1, H(Key) - 1,H(key) + 4, H(Key) - 4,H(key) + 9, H(Key) - 9…如果检查过程中 $H(key) + k^2 $超过了表长TSize ,那么就进行 $ (H(key) + k^2 )% TSize $ 运算;如果检查过程中出现了$ H(key) - k^2 <0$ 的情况,那么返回 $ (( H(key) - k^2 ) % Tsize + TSzie)) % TSize$.
    当然,如果想要避免负数的麻烦,可以只进行正向的平方探测。
  3. 链地址法(拉链法):链地址法不计算新的hash值,而是把所有H(key)相同的key联结成一个单链表。这样可以设定一个数组Link,范围是Link[0] ~ Link[mod - 1],其中Link[i] 存放 hash = H[key] 的一条单链表。
    一般而言,可以使用STL中 map来哦直接使用hash的功能(或者C++11中的unordered_map)

4.2.2 字符串hash初步

  • 如果key不是整数,那么又应当如何设计散列函数呢?
    一个例子:如何将二维整点P映射成一个整数,使得P可以由该整数唯一的表达?假设一个P的坐标为(x,y),其中 0<= x,y <= Range, 那么可以令 hash 函数为 H§ = x* Range + y.那么对于数据范围内的任意两个整点 P1 和 P2, H ( P 1 ) ≠ H ( P 2 ) H(P_1) \neq H(P_2) H(P1)̸=H(P2), 这样就可以用 H§ 来唯一的表示P ,之后便可以通过整数hash的方法来进一步映射为较小的范围。
  • 字符串hash, 它将一个字符串S 映射为一个整数,使得该整数尽可能唯一的代表一个字符串S.
  1. 不妨假设字符串均为大写字母A~Z构成,我们可以按二十六进制转换为 10 进制的思路,将’A’~'Z’分别对应于 0 ~ 25,得到唯一的十进制数字,即 I D 10 = ∑ i = 0 n − 1 ( S [ i ] −   ′ A ′ ) ∗ 2 6 i ID_{10} =\sum_{i=0}^{n-1} (S[i]-~ 'A') * 26^i ID10=i=0n1(S[i] A)26i但是可惜,当字符串比较长时,转换的整数也非常大。
  2. 另外考虑如果字符串中出现了小写字母,就变成了 五十二进制转换为 十进制的问题,做法是相同的,将将’A’~'Z’分别对应于 0 ~ 25,‘a’~'z’分别对应于 26 ~ 51.
  3. 当字符串中出现数字时候,处理方法有二:其一,将进制增加到 62,为六十二进制转换为 十进制。其二,如果保证字符串末尾是确定个数的数字,那么先对之前的英文字母进行转换,最后将字符串末尾的数字拼接上。

4.3 递归

4.3.1 分治

分治:divide and conquer, 即“分而治之”,指将原问题划分为若干规模较小而与原问 mb题相同/相似的子问题,然后分别解决这些子问题,最后合并子问题的解,即可以得到原问题的解。
分治的三个步骤:1. 分解; 2. 解决; 3. 合并。
需要注意的是,分治法分解出的子问题应该是相互独立、没 有交叉的。如果两个子问题存在交叉部分,就不应该用分治法来解决。
从广义上来讲,分治法分解的子问题个数只要大于0即可。但是从严格定义上来讲,一般把子问题个数为1的情况成为减而治之(decrease and conquer),而把子问题个数大于1的情况成为分治。但是通常情况下不必区分。
另外,分治作为一种思想,既可以使用递归的手段实现,也可以使用非递归的手段实现。

4.3.2 递归

递归在于反复调用自身函数,但是每次能把问题的规模缩小,直到缩小到数据边界。递归适合实现分治。
递归两个重要概念:1. 递归边界;2. 递归式。

例: 使用递归求解n的阶乘。
n!= n * (n-1) * (n-2) * … * 1.
如果用F(n) 表示n!, 那么F(n) = F(n-1) * n,(递归式), 令F(0) = 1,为递归边界。
代码:

int F( int n){
    if( n == 0 ) return 1;
    return F(n-1) * n;
    }

例2:Fibonacci数列的第n项。
递归式:F(n) = F(n-1) + F(n-2).
递归边界: F(0) = 1, F(1) = 1.
由于使用递归写成的代码进行了多次重复计算,效率极低,因此不建议直接使用递归求解。有性能更好的方法(如矩阵法)

例3:全排列(Full Permutation),一般把 1~n这n个整数按照某一顺序摆放的结果称为n个整数的一个排列,而全排列的指这n个整数所能形成的所有排列。
如,1、2、3这三个整数,123, 132, 213, 231, 312, 321 就是1~3的一个全排列。
我们需要实现一个按照字典序的全排列。
从递归的角度考虑,该问题可以划分为若干个子问题:“输出以1开头的全排列”、“输出以2开头的全排列"…于是不妨设定一个数组P,用来存放当前的排列;再设定一个散列函数hashTable,其中hashTable[x] 当整数x已经在数组P中时为true.
现在按照顺序往P的第1位到第n位中填写数字。不妨假定当前已经填好了P[1] ~ P[index - 1] ,正准备填P[index]. 显然需要枚举 1 ~ n,如果当前枚举的数字x还不在P[1] ~ P[index - 1]中(即hashTable[x] = false),那么就把它填入P[index], 同时将hashTable[x] 置为 true, 然后处理P的第 index +1 位即可(递归处理);当递归完成时,再将hashTable[x] 还原为 fasle ,以便让P[index]填写下一个数字。
index 达到 n + 1时为递归边界。

代码:

int n, P[MaxSize], hashTable[ MaxSize] = {false};
//----------------------------
void generateP( int index){
    if( index == n + 1){//递归边界,
        for( int i = 1; i <= n; i++)//输出当前排列
            cout<<P[i];
        cout<<endl;
        return ;
    }
    for( int x  = 1; x <= n; x++)//枚举1~n
        if( hashTable[x] == false){//如果x没有出现
            P[index] = x;//则放入x
            hashTable[x] = true;//标记x已经出现
            generateP( index + 1);//进行下一位
            hashTable[ x] = false;
        }
}

补充:next_permutation()函数的使用。
next_permutation() 给出一个序列在全排列中的下一个序列。
示例:

int a[10] = { 1,2,3};
do{
    printf("%d%d%d,", a[0], a[1], a[2]);
}while( next_permutation(a,a+3));

输出结果为:123,132,213,231,312,321
使用do…while是因为123本身写需要输出。

例4:n皇后,n皇后是指在一个n*n的棋盘上放n个皇后,使得这n个皇后两量均不在同一列、同一行、同一对角线上。求合法的方案数。
方法一:如果采用枚举的方法,即从n2个位置中选择n个位置,枚举数量过大。运行时间为C(n2,n).
方法二:换一种思路,考虑另外一种情形,由于条件限制,我们可以写出1~n的全排列,查看每一种排列所对应的位置是否合法,再统计出合法的方案即可。运行时间变为O(n!)
方法三:回溯法。当放置一部分皇后时,剩余的皇后无论怎么放置都不会合法,此时即不需要递归,直接返回上层即可。

4.4 贪心

4.4.1 简单贪心

贪心是一种求解最优化问题的方法,它总是考虑在当前状态下的局部最优的策略,以此来达到全局最优的结果。

4.4.2 区间贪心

区间不相交问题:给出N个开区间 (x,y), 从中选择尽可能多的开区间,使得这些开区间两两没有交集。

4.5 二分

4.5.1 二分查找

基于一个严格递增的序列,如何找出某一个元素?
方法一:线性扫描,查找的时间复杂度为$ O(n)$
方法二:二分查找,代码如下:

int binartSearch( int A[], int left, int right, int x){
    int mid;
    while( left < right){
    /*注意, left<=right或left < right 是由问题本身决定的。*/
    /*如何查找元素不存在时需返回-1, 则使用"<="; */
    /*如何查找元素不存在时需返回其应该插入的位置, 则使用"<"; 注, 这也可以求第一个大于某个元素的位置。*/
    
    mid = left + (right - left) / 2;
    /*不采用mid = (left + right)/2 是因为左侧形式可能溢出*/
    
    if( A[mid] == x)    return mid;
    else if( A[mid] > x) 
        right = mid;
    else if( A[mid] < x)
        left = mid + 1;
    }
    return left;
}

4.5.2 二分法拓展

问题:如何计算 ( 2 ) \sqrt(2) ( 2)的近似值,假设精度为1e-5.
由于 x 2 x^2 x2为递增函数,因此为使用二分法创造了条件。我们使用迭代的思想来解决问题。

const double eps = 1e-5;
double calSqrt(){
    int left = 1, right = 2, mid;
    while ( left - right >eps){
        mid = left + (right- left)/2;
        if( mid * mid > 2)
            right = mid;
        else
            left = mid;
    }
    return mid;
}

同理,对于给定某一个区间上的单调函数,上述方法可以用来求方程的根。

4.5.3 快速幂

对于给定的三个正整数a,b,m. 求$a^b $ % m
常规方法不再赘述。
快速幂解法:这是基于二分的思想,1. 当b为奇数时,有 a b = a ∗ a b − 1 a^b = a* a^{b-1} ab=aab1; 2. 当b为偶数时,有 a b = a b / 2 ∗ a b / 2 a^b = a^{b/2} * a^{b/2} ab=ab/2ab/2.
显然,任何情况下,再经过log(n)两集的转换后,可以把b转换为0,而任何正整数的0次方都是1.

//递归
long long binaryPow( long long a, long long b, long long m){
    if( b == 0)
        return 1;
    if( b % 2 == 1)
        return a * binaryPow(a, b - 1,m)%m;
        //注意最后求余号
    else if( !b%2){
        long long mul = binaryPow( a, b/2, m);
        // 细节,不要写成return binaryPow * binaryPow,这样会造成重复计算。
        return mul * mul % m;
    }
}
//迭代写法
long long binaryPow( long long a, long long b, long long m){
    long long ans = 1;
    while( b > 0){
        if( b&1){ //等价于 检查b是否为计数
            ans = ans * a % m;
        }
        a = a * a %m;
        b>>=1;
    }
    return ans;
}

4.6 two pointers

4.6.1 什么是双指针

双指针是一种编程技巧。
例如:给定一个递增的正整数序列和一个正整数M,求序列中两个不同位置的数a和数b,使得 a + b = M,输出所有方案。
解法一:最直观的解法是使用二重循环枚举序列中的所有数字。该算法的运行时间为 O ( n 2 ) O(n^2) O(n2),对于稍微大点的n便不可接受。
造成上述复杂度高的原因有:对于a[i] + a[j] > M, 则必定有a[i+1] + a[j] > M 并且 a[i] + a[j+1] > M.在二重循环的算法中,枚举了很多不必要的元素。
方法二:双指针。要充分使用题目中序列递增的性质。(如果题目中没有给出是递增序列,则应该进行排序预处理来降低运行时间)
双指针方法见代码:

int find( int A[]){
    int i = 0, j = n -1; 
    // 其中i指向数组第一个元素,j指向最后一个元素。
    while( i<j){
        if( A[i] + A[j] == M){
            printf("%d, %d", A[i], A[j]);
            i++, j--;
            //此时满足条件,输出。
            //可知A[i+1] + A[j] > M,A[i] + A[j-1] < M;
            //而A[i+1] + A[j-1] 与M的关系未知
            //因此不能单纯地移动某一个指针,需要两个一起移动。
        }
        else if( A[i] + A[j] > M)
            j--;
        else if( A[i] + A[j] < M)
            i++;
    }
}

运行时间为O(n)。

序列合并问题:假设有两个递增序列A和B,要求它们合并成一个递增序列C。
解答:设置两个指针,i,= 0, j = 0,分别指向A和B的第一个值。同时开辟一个数组和指针k,数组用来存放数组C,指针指向C的当前位置.

  1. 若A[i] < B[j], 那么将A[i] 放入C[k]中,i++,k++;
  2. 若A[i] > B[j], 那么将B[j] 放入C[k]中,j++,k++;
  3. 若A[i] == B[j],那么随意选择一个,放入C[k]中,对应下标自增1,同时k++.

4.6.2 归并排序

归并排序是一种基于“归并”思想的排序方法,这里主要介绍二路归并。
二路归并排序原理:将序列两两分组,将序列归并为 ⌈ 1 2 ⌉ \lceil \frac{1}{2} \rceil 21个组,然后组内单独排序;然后再将这些组两辆归并,生成 ⌈ 1 4 ⌉ \lceil \frac{1}{4} \rceil 41个组,然后组内单独排序;以此类推,知道只剩下一个组为止。
归并排序的时间复杂度为O(nlogn).
例:对于序列{66,12,33,57,64,27,18}进行归并排序。
初 始 序 列    66 , 12 ‾ , 33 , 57 ‾ , 64 , 27 ‾ , 18 ‾ 第 一 趟    12 , 66 ‾ , 33 , 57 ‾ , 27 , 64 ‾ , 18 ‾ 第 二 趟    12 , 33 , 57 , 66 ‾ , 18 , 27 , 64 ‾ 第 三 趟    12 , 18 , 27 , 33 , 57 , 64 , 66 ‾ 初始序列 \ \ \underline{66,12} ,\underline{33, 57} ,\underline{64, 27} ,\underline{18} \\ 第一趟 \ \ \underline{12 ,66} , \underline{33 , 57} ,\underline{27 , 64}, \underline{18}\\ 第二趟 \ \ \underline{12 ,33 ,57, 66} ,\underline{18 ,27 ,64}\\ 第三趟 \ \ \underline{12, 18, 27,33 ,57 ,64 ,66}   66,12,33,57,64,27,18  12,66,33,57,27,64,18  12,33,57,66,18,27,64  12,18,27,33,57,64,66

//这里给出主要的merge函数
const int max =100;
void merge( int A[], int L1, int L2, int R1, int R2){
    int i = L1, j = L2; // i指向A[L1], j 指向A[L2]
    int temp[maxn], index = 0;
    while( i <= R1 && j <= R2){
        if( A[i] <= A[j])
            temp[index++] = A[i++];
        else
            temp[index++] = A[j++];
    }
    while( i <= R1) temp[index++] = A[i++];
    while( j <= R2) temp[index++] = A[j++];
    
    for( i = 0; i < index; i++)
        A[L1+i] = temp[i];
}

4.6.3 快速排序

快速排序是目前最快的一种排序算法,其运行时间为O(nlogn).
快速排序的思想如下:对于某一次递归而言,选取某一个元素,经过一系列操作,使其数组中左侧位置上的元素均小于该选定元素,右侧的元素均大于该选定元素。
步骤如下:对于序列A[0,1,…,n-1]

  1. 首先选中A[0],并将它保存到临时变量temp中,并令两个下标left, right 分别指向序列首尾。
  2. 如果A[right]大于temp,则将right不断左移,当某一个A[right]<=temp时,则将元素A[right]挪到left所指向的A[left]处。
  3. 如果A[left]不大于temp,则将left不断右移,当某一个A[left]>temp时,则将元素A[left]挪动刀right所指向的A[right]处。
  4. 重复2、3步骤,知道left与right相遇,把temp放到相遇的地方。
    代码:
//划分区间
int Partition( int A[], int left, int right){
    int temp = A[left];
    while( left < right){
        while( left < right && A[right] > temp)
            right--;
        A[left] = A[right];
        while( left < right && A[left] <= temp)
            left++;
        A[right] = A[left];
    }
    A[left] = temp;
    return left;
}
//快速排序
void quickSort( int A[], int left, int right){
    if( left < right){
        int pos = Partition( A, left, right);
        quickSort( A, left, pos - 1);
        quickSort( A, pos, right);
    }
}

如何实现随机快排?
首先要解决的问题是如何生成一个随机数。
rand()函数可以生成[0, RAND_MAX]范围内的数( RAND_MAX在不同环境下数值不同)。
如果想输出给定范围[a,b]内的数字,则可以使用 rand()%( b - a + 1) + a. (备注,此时b < RAND_MAX)
当 b > RAND_MAX 时,则可以多次生成rand随机数,然后相乘或用位运算拼接起来。
在此之上,我们来讨论随机快速排序的写法。
首先生成[left, right)内的随机数p,然后以A[p]为主元来进行划分,此时,需要首先将A[p]与A[left]进行交换,再运行Partition函数。

4.7 其他高效技巧与算法

4.7.1 打表

打表法常见的用法:

  1. 在程序中一次性计算出所有需要用到的结果,然后查询直接取这些结果。
  2. 在程序B中分一次或者多次计算出所有需要用到的结果,手工把结果写在程序A的数组中,然后在程序A中直接使用这些结果。
  3. 对于没有头绪的问题,可以先暴力解决小范围内的结果,然后找规律来解决问题。

4.7.2 递推

4.7.3 随机选择法

如何从一个无序的数组中求出第K大的数。
方法一:对数组排序,直接选出第K个数,时间复杂度为O(nlogn).
方法二:随机选择算法,类似于快速排序,选择某一个元素,使其左侧元素均小于该元素,右侧元素均大于该元素,那么该元素为第 左侧元素个数 + 1 大的数。。。一次推类。

我的微信公众号

在这里插入图片描述

你可能感兴趣的:(【算法笔记】第四章:算法初步)