个人技术博客: http://www.zhenganwen.top
时间复杂度
时间复杂度是衡量算法好坏的重要指标之一. 时间复杂度反映的是不确定性样本量的增长对于算法操作所需时间的影响程度, 与算法操作是否涉及到样本量以及涉及了几次直接相关, 如遍历数组时时间复杂度为数组长度 n(对应时间复杂度为 O(n)), 而对数据的元操作 (如加减乘除与或非等), 逻辑操作(如 if 判断) 等都属于常数时间内的操作(对应时间复杂度 O(1)).
在化简某算法时间复杂度表达式时需遵循以下规则:
对于同一样本量, 可省去低阶次数项, 仅保留高阶次数项, 如 O(n^2)+O(n)可化简为 O(n^2),O(n)+O(1)可化简为 O(n)
可省去样本量前的常量系数, 如 O(2n)可化简为 O(n),O(8)可化简为 O(1)
对于不同的不确定性样本量, 不能按照上述两个规则进行化简, 要根据实际样本量的大小分析表达式增量. 如 O(logm)+O(n^2)不能化简为 O(n^2)或 O(logm). 而要视 m,n 两者之间的差距来化简, 比如 m>>n 时可以化简为 O(logm), 因为表达式增量是由样本量决定的.
额外空间复杂度
算法额外空间复杂度指的是对于输入样本, 经过算法操作需要的额外空间. 比如使用冒泡排序对一个数组排序, 期间只需要一个临时变量 temp, 那么该算法的额外空间复杂度为 O(1). 又如归并排序, 在排序过程中需要创建一个与样本数组相同大小的辅助数组, 尽管在排序过后该数组被销毁, 但该算法的额外空间复杂度为 O(n).
经典例题 -- 举一反三
找出 B 中不属于 A 的数
找出数组 B 中不属于 A 的数, 数组 A 有序而数组 B 无序. 假设数组 A 有 n 个数, 数组 B 有 m 个数, 写出算法并分析时间复杂度.
方法一: 遍历
首先遍历 B, 将 B 中的每个数拿到到 A 中找, 若找到则打印. 对应算法如下:
- int A[] = {1, 2, 3, 4, 5};
- int B[] = {1, 4, 2, 6, 5, 7};
- for (int i = 0; i <6; ++i) {
- int temp = B[i];
- bool flag = false;
- for (int j = 0; j < 5; ++j) {
- if (A[j] == temp) {
- flag = true; // 找到了
- break;
- }
- }
- if (!flag) { // 没找到
- printf("%d", temp);
- }
- }
不难看出上述算法的时间复杂度为 O(m*n), 因为将两个数组都遍历了一遍
方法二: 二分查找
由于数组 A 是有序的, 在一个有序序列中查找一个元素可以使用二分法(也称折半法). 原理就是将查找的元素与序列的中位数进行比较, 如果小于则去掉中位数及其之后的序列, 如果大于则去掉中位数及其之前的序列, 如果等于则找到了. 如果不等于那么再将其与剩下的序列继续比较直到找到或剩下的序列为空为止.
利用二分法对应题解的代码如下:
- for (int i = 0; i < 6; ++i) { //B 的长度为 6
- int temp = B[i];
- // 二分法查找
- int left = 0,right = 5-1; //A 的长度为 5
- int mid = (left + right) / 2;
- while (left < right && A[mid] != temp) {
- if (A[mid]> temp) {
- right = mid - 1;
- } else {
- left = mid + 1;
- }
- mid = (left + right) / 2;
- }
- if (A[mid] != temp) {
- printf("%d", temp);
- }
- }
for 循环 m 次, while 循环 logn 次(如果没有特别说明, log 均以 2 为底), 此算法的时间复杂度为 O(mlogn)
方法三: 排序 + 外排
第三种方法就是将数组 B 也排序, 然后使用逐次比对的方式来查找 A 数组中是否含有 B 数组中的某元素. 引入 a,b 两个指针分别指向数组 A,B 的首元素, 比较指针指向的元素值, 当 a<b 时, 向后移动 a 指针查找该元素; 当 a=b 时, 说明 A 中存在该元素, 跳过该元素查找, 向后移动 b; 当 a>b 时说明 A 中不存在该元素, 打印该元素并跳过该元素的查找, 向后移动 b. 直到 a 或 b 有一个到达数组末尾为止(若 a 先到达末尾, 那么 b 和 b 之后的数都不属于 A)
对应题解的代码如下:
- void fun3(int A[],int a_length,int B[],int b_length){
- quickSort(B, 0, b_length - 1); // 使用快速排序法对数组 B 排序 ->O(mlogm)
- int* a = A,*b=B;
- while (a <= A + a_length - 1 || b <= B + b_length - 1) {
- if (*a == *b) {
- b++;
- continue;
- }
- if (*a> *b) {
- printf("%d", *b);
- b++;
- } else {
- a++;
- }
- }
- if (a == A + a_length) { //a 先到头
- while (b <B + b_length) {
- printf("%d", *b);
- b++;
- }
- }
- }
快速排序的代码如下:
- #include <stdlib.h>
- #include <time.h>
- // 交换两个 int 变量的值
- void swap(int &a, int &b){
- int temp = a;
- a = b;
- b = temp;
- }
- // 产生一个 low~high 之间的随机数
- int randomInRange(int low, int high){
- srand((int) time(0));
- return (rand() % (high - low))+low;
- }
- // 快速排序的核心算法, 随机选择一个数, 将比该数小的移至数组左边, 比该数大的移至
- // 数组右边, 最后返回该数的下标(移动完之后该数的下标可能与移动之前不一样)
- int partition(int arr[],int start,int end){
- if (arr == NULL || start <0 || end <= 0 || start> end) {
- return -1;
- }
- int index = randomInRange(start, end);// 随机选择一个数
- swap(arr[index], arr[end]);// 将该数暂时放至末尾
- int small = start - 1;
- // 遍历前 n-1 个数与该数比较并以该数为界限将前 n-1 个数
- // 分为两组, small 指向小于该数的那一组的最后一个元素
- for (index = start; index <end; index++) {
- if (arr[index] < arr[end]) {
- small++;
- if (small != index) {
- swap(arr[small], arr[index]);
- }
- }
- }
- // 最后将该数放至数值较小的那一个组的中间
- ++small;
- swap(arr[small], arr[end]);
- return small;
- }
- void quickSort(int arr[],int start,int end) {
- if (start == end) {
- return;
- }
- int index = partition(arr, start, end);
- if (index> start) {
- quickSort(arr,start, index - 1);
- }
- if (index <end) {
- quickSort(arr, index + 1, end);
- }
- }
此种方法的时间复杂度为: O(mlogm)(先对 B 排序)+O(m+n)(最坏的情况是指针 a 和 b 都到头).
三种方法的比较
- O(m*n)
- O(mlogn)(以 2 为底)
- O(mlogm)+O(m+n)(以 2 为底)
易知算法 2 比 1 更优, 因为增长率 logn<n. 而 2 和 3 的比较取决于样本量 m 和 n 之间的差距, 若 m>>n 那么 2 更优, 不难理解: 数组 B 元素较多, 那么对 B 的排序肯定要花费较长时间, 而这一步并不是题解所必需的, 不如采用二分法; 相反地, 若 m<<n, 那么 3 更优.
荷兰国旗问题
给定一个数组 arr, 和一个数 num, 请把小于 num 的数放在数组的左边, 等于 num 的数放在数组的中间, 大于 num 的数放在数组的右边.
要求额外空间复杂度 O(1), 时间复杂度 O(N)
思路: 利用两个指针 L,R, 将 L 指向首元素之前, 将 R 指向尾元素之后. 从头遍历序列, 将当前遍历元素与 num 比较, 若 num, 则将其与 L 的右一个元素交换位置并遍历下一个元素, 右移 L; 若 = num 则直接遍历下一个元素; 若 > num 则将其和 R 的左一个元素交换位置, 并重新判断当前位置元素与 num 的关系. 直到遍历的元素下标到为 R-1 为止.
- void swap(int &a, int &b){
- int temp = a;
- a = b;
- b = temp;
- }
- void partition(int arr[],int startIndex,int endIndex,int num){
- int L = startIndex - 1, R = endIndex + 1, i = startIndex;
- while (i <= R - 1) {
- if (arr[i] <num) {
- swap(arr[i++], arr[++L]);
- } else if (arr[i]> num) {
- swap(arr[i], arr[--R]);
- } else {
- i++;
- }
- }
- }
- int main(){
- int arr[] = {1,2, 1, 5, 4, 7, 2, 3, 9,1};
- travles(arr, 8);
- partition(arr, 0, 7, 2);
- travles(arr, 8);
- return 0;
- }
L 代表小于 num 的数的右界, R 代表大于 num 的左界, partition 的过程就是遍历元素, 不断壮大 L,R 范围的过程. 这里比较难理解的地方可能是为什么 arr[i]<num 时要右移 L 而 arr[i]>num 时却不左移 R, 这是因为对于当前元素 arr[i], 如果 arr[i]<num 进行 swap(arr[i],arr[L+1])之后对于当前下标的数据状况是知晓的 (一定有 arr[i]=arr[L+1]), 因为是从头遍历到 i 的, 而 L+1<=i. 但是如果 arr[i]>num 进行 swap(arr[i],arr[R-1]) 之后对于当前元素的数据状况是不清楚的, 因为 R-1>=i,arr[R-1]还没遍历到.
矩阵打印问题
转圈打印方块矩阵
给定一个 4 阶矩阵如下:
打印结果如下(要求额外空间复杂度为 O(1)):
1 2 3 4 8 12 16 15 14 13 9 5 6 7 11 10
思路: 这类问题需要将思维打开, 从宏观的层面去找出问题存在的共性从而求解. 如果你的思维局限在 1 是如何变到 2 的, 4 是怎么变到 8 的, 11 之后为什么时 10, 它们之间有什么关联, 那么你就陷入死胡同了.
从宏观的层面找共性, 其实转圈打印的过程就是不断顺时针打印外围元素的过程, 只要给你一个左上角的点 (如(0,0)) 和右下角的点 (如(3,3)), 你就能够打印出 1 2 3 4 8 12 16 15 14 13 9 5; 同样, 给你(1,1) 和(2,2), 你就能打印出 6 7 11 10. 这个根据两点打印正方形上元素的过程可以抽取出来, 整个问题也就迎刃而解了.
打印一个矩阵某个正方形上的点的逻辑如下:
- //
- // Created by zaw on 2018/10/21.
- //
- #include <stdio.h>
- #define FACTORIAL 4
- void printSquare(int leftUp[], int rigthDown[],int matrix[][FACTORIAL]){
- int i = leftUp[0], j = leftUp[1];
- while (j <rigthDown[1]) {
- printf("%d", matrix[i][j++]);
- }
- while (i < rigthDown[0]) {
- printf("%d", matrix[i++][j]);
- }
- while (j> leftUp[1]) {
- printf("%d", matrix[i][j--]);
- }
- while (i> leftUp[0]) {
- printf("%d", matrix[i--][j]);
- }
- }
- void printMatrixCircled(int matrix[][FACTORIAL]){
- int leftUp[] = {0, 0}, rightDown[] = {FACTORIAL-1,FACTORIAL-1};
- while (leftUp[0] <rightDown[0] && leftUp[1] < rightDown[1]) {
- printSquare(leftUp, rightDown, matrix);
- ++leftUp[0];
- ++leftUp[1];
- --rightDown[0];
- --rightDown[1];
- }
- }
- int main(){
- int matrix[4][4] = {
- {1, 2, 3, 4},
- {5, 6, 7, 8},
- {9, 10, 11, 12},
- {13, 14, 15, 16}
- };
- printMatrixCircled(matrix);//1 2 3 4 8 12 16 15 14 13 9 5 6 7 11 10
- }
旋转方块矩阵
给定一个方块矩阵, 请把该矩阵调整成顺时针旋转 90° 之后的样子, 要求额外空间复杂度为 O(1).
思路: 拿上图举例, 首先选取矩阵四个角上的点 1,3,9,7, 按顺时针的方向 1 到 3 的位置(1->3),3->9,9->7,7->1, 这样对于旋转后的矩阵而言, 这四个点已经调整好了. 接下来只需调整 2,6,8,4 的位置, 调整方法是一样的. 只需对矩阵第一行的前 n-1 个点采用同样的方法进行调整, 对矩阵第二行的前前 n-3 个点......, 那么调整 n 阶矩阵就容易了.
这也是在宏观上观察数据变动的一般规律, 找到以不变应万变的通解(给定一个点, 确定矩阵上以该点为角的正方形, 将该正方形旋转 90°), 整个问题就不攻自破了.
- //
- // Created by zaw on 2018/10/21.
- //
- #include <stdio.h>
- #define FACTORIAL 4
- void circleSquare(int leftUp[],int rightDown[],int matrix[][FACTORIAL]){
- int p1[] = {leftUp[0], leftUp[1]};
- int p2[] = {leftUp[0], rightDown[1]};
- int p3[] = {rightDown[0], rightDown[1]};
- int p4[] = {rightDown[0],leftUp[1]};
- while (p1[1] <rightDown[1]) {
- //swap
- int tmp = matrix[p4[0]][p4[1]];
- matrix[p4[0]][p4[1]] = matrix[p3[0]][p3[1]];
- matrix[p3[0]][p3[1]] = matrix[p2[0]][p2[1]];
- matrix[p2[0]][p2[1]] = matrix[p1[0]][p1[1]];
- matrix[p1[0]][p1[1]] = tmp;
- p1[1]++;
- p2[0]++;
- p3[1]--;
- p4[0]--;
- }
- }
- void circleMatrix(int matrix[][FACTORIAL]){
- int leftUp[] = {0, 0}, rightDown[] = {FACTORIAL - 1, FACTORIAL - 1};
- while (leftUp[0] < rightDown[0] && leftUp[1] < rightDown[1]) {
- circleSquare(leftUp, rightDown, matrix);
- leftUp[0]++;
- leftUp[1]++;
- --rightDown[0];
- --rightDown[1];
- }
- }
- void printMatrix(int matrix[][FACTORIAL]){
- for (int i = 0; i < FACTORIAL; ++i) {
- for (int j = 0; j < FACTORIAL; ++j) {
- printf("-", matrix[i][j]);
- }
- printf("\n");
- }
- }
- int main(){
- int matrix[FACTORIAL][FACTORIAL] = {
- {1, 2, 3, 4},
- {5, 6, 7, 8},
- {9, 10, 11, 12},
- {13, 14, 15, 16}
- };
- printMatrix(matrix);
- circleMatrix(matrix);
- printMatrix(matrix);
- }
之字形打印矩阵
对如上矩阵的打印结果如下(要求额外空间复杂度为 O(1)):
1 2 7 13 8 3 4 9 14 15 10 5 6 11 16 17 12 18
此题也是需要从宏观上找出一个共性: 给你两个, 你能否将该两点连成的 45° 斜线上的点按给定的打印方向打印出来. 拿上图举例, 给出 (2,0),(0,2) 和 turnUp=true, 应该打印出 13,8,3. 那么整个问题就变成了两点的走向问题了, 开始时两点均为(0,0), 然后一个点往下走, 另一个点往右走(如 1->7,1->2); 当往下走的点是边界点时就往右走(如 13->14), 当往右走的点到边界时就往下走(如 6->12). 每次两点走一步, 并打印两点连线上的点.
- //
- // Created by zaw on 2018/10/22.
- //
- #include <stdio.h>
- const int rows = 3;
- const int cols = 6;
- void printLine(int leftDown[],int rightUp[], bool turnUp,int matrix[rows][cols]){
- int i,j;
- if (turnUp) {
- i = leftDown[0], j = leftDown[1];
- while (j <= rightUp[1]) {
- printf("%d", matrix[i--][j++]);
- }
- } else {
- i = rightUp[0], j = rightUp[1];
- while (i <= leftDown[0]) {
- printf("%d", matrix[i++][j--]);
- }
- }
- }
- void zigZagPrintMatrix(int matrix[rows][cols]){
- if (matrix==NULL)
- return;
- int leftDown[] = {0, 0}, rightUp[] = {0, 0};
- bool turnUp = true;
- while (leftDown[1] <= cols - 1) {
- printLine(leftDown, rightUp, turnUp, matrix);
- turnUp = !turnUp;
- if (leftDown[0] <rows - 1) {
- leftDown[0]++;
- } else {
- leftDown[1]++;
- }
- if (rightUp[1] < cols - 1) {
- ++rightUp[1];
- } else {
- ++rightUp[0];
- }
- }
- }
- int main(){
- int matrix[rows][cols] = {
- {1, 2, 3, 4, 5, 6},
- {7, 8, 9, 10, 11, 12},
- {13, 14, 15, 16, 17, 18}
- };
- zigZagPrintMatrix(matrix);//1 2 7 13 8 3 4 9 14 15 10 5 6 11 16 17 12 18
- return 0;
- }
在行和列都排好序的矩阵上找数
如图:
任何一列或一行上的数是有序的, 实现一个函数, 判断某个数是否存在于矩阵中. 要求时间复杂度为 O(M+N), 额外空间复杂度为 O(1).
从矩阵右上角的点开始取点与该数比较, 如果大于该数, 那么说明这个点所在的列都不存在该数, 将这个点左移; 如果这个点上的数小于该数, 那么说明这个点所在的行不存在该数, 将这个点下移. 直到找到与该数相等的点为止. 最坏的情况是, 该数只有一个且在矩阵左下角上, 那么时间复杂度为 O(M-1+N-1)=O(M+N)
- //
- // Created by zaw on 2018/10/22.
- //
- #include <stdio.h>
- const int rows = 4;
- const int cols = 4;
- bool findNumInSortedMatrix(int num,int matrix[rows][cols]){
- int i = 0, j = cols - 1;
- while (i <= rows - 1 && j <= cols - 1) {
- if (matrix[i][j]> num) {
- --j;
- } else if (matrix[i][j] <num) {
- ++i;
- } else {
- return true;
- }
- }
- return false;
- }
- int main(){
- int matrix[rows][cols] = {
- {1, 2, 3, 4},
- {2, 4, 5, 8},
- {3, 6, 7, 9},
- {4, 8, 9, 10}
- };
- if (findNumInSortedMatrix(7, matrix)) {
- printf("find!");
- } else {
- printf("not exist!");
- }
- return 0;
- }
岛问题
一个矩阵中只有 0 和 1 两种值, 每个位置都可以和自己的上, 下, 左, 右四个位置相连, 如果有一片 1 连在一起, 这个部分叫做一个岛, 求一个矩阵中有多少个岛?
比如矩阵:
1 | 0 | 1 |
---|---|---|
0 | 1 | 0 |
1 | 1 | 1 |
就有 3 个岛.
分析: 我们可以遍历矩阵中的每个位置, 如果遇到 1 就将与其相连的一片 1 都感染成 2, 并自增岛数量.
- public class IslandNum {
- public static int getIslandNums(int matrix[][]){
- int res = 0 ;
- for(int i = 0 ; i < matrix.length ; i++){
- for(int j = 0 ; j < matrix[i].length ; j++){
- if(matrix[i][j] == 1){
- res++;
- infect(matrix , i , j);
- }
- }
- }
- return res;
- }
- public static void infect(int matrix[][], int i ,int j){
- if(i < 0 || i>= matrix.length || j <0 || j>= matrix[i].length || matrix[i][j] != 1){
- return;
- }
- matrix[i][j] = 2;
- infect(matrix , i-1 , j);
- infect(matrix , i+1 , j);
- infect(matrix , i , j-1);
- infect(matrix , i , j+1);
- }
- public static void main(String[] args){
- int matrix[][] = {
- {1,0,0,1,0,1},
- {0,1,1,0,0,0},
- {1,0,0,0,1,1},
- {1,1,1,1,1,1}
- };
- System.out.println(getIslandNums(matrix));
- }
- }
经典结构和算法
字符串
KMP 算法
KMP 算法是由一个问题而引发的: 对于一个字符串 str(长度为 N)和另一个字符串 match(长度为 M), 如果 match 是 str 的子串, 请返回其在 str 第一次出现时的首字母下标, 若 match 不是 str 的子串则返回 - 1.
最简单的方法是将 str 从头开始遍历并与 match 逐次比较, 若碰到了不匹配字母则终止此次遍历转而从 str 的第二个字符开始遍历并与 match 逐次比较, 直到某一次的遍历每个字符都与 match 匹配否则返回 - 1. 易知此种做法的时间复杂度为 O(N*M).
KMP 算法则给出求解该问题时间复杂度控制在 O(N)的解法.
首先该算法需要对应 match 创建一个与 match 长度相同的辅助数组 help[match.length], 该数组元素表示 match 某个下标之前的子串的前后缀子串最大匹配长度. 前缀子串表示一个串中以串首字符开头的不包含串尾字符的任意个连续字符, 后缀子串则表示一个串中以串尾字符结尾的不包括串首字符的任意个连续字符. 比如 abcd 的前缀子串可以是 a,ab,abc, 但不能是 abcd, 而 abcd 的后缀字串可以是 d,cd,bcd, 但不能是 abcd. 再来说一下 help 数组, 对于 char match[]="abc1abc2" 来说, 有 help[7]=3, 因为 match[7]='2', 因此 match 下标在 7 之前的子串 abc1abc 的前缀子串和后缀子串相同的情况下, 前缀子串的最大长度为 3(即前缀字串和后缀字串都取 abc); 又如 match="aaaab", 有 help[4]=3(前缀子串和后缀子串最大匹配长度当两者为 aaa 时取得), 相应的有 help[3]=2,help[2]=1.
假设当要寻找的子串 match 的 help 数组找到之后 (对于一个串的 help 数组的求法在介绍完 KMP 算法之后再详细说明). 就可以进行 KMP 算法求解此问题了. KMP 算法的逻辑(结论) 是, 对于 str 的 i~(i+k)部分 (i,i+k 均为 str 的合法下标) 和 match 的 0~k 部分 (k 为 match 的合法下标), 如果有 str[i]=match[0],str[i+1]=match[1]......str[i+k-1]=match[k-1], 但 str[i+k]!=[k], 那么 str 的下标不用从 i+k 变为 i+1 重新比较, 只需将子串 str[0]~str[i+k-1] 的最大匹配前缀子串的后一个字符 cn 重新与 str[i+k]向后依次比较, 后面如果又遇到了不匹配的字符重复此操作即可:
当遇到不匹配字符时, 常规的做法是将 str 的遍历下标 sIndex 移到 i+1 的位置并将 match 的遍历下标 mIndex 移到 0 再依次比较, 这种做法并没有利用上一轮的比较信息 (对下一轮的比较没有任何优化). 而 KMP 算法则不是这样, 当遇到不匹配的字符 str[i+k] 和 match[k]时, str 的遍历指针 sIndex=i+k 不用动, 将 match 右滑并将其遍历指针 mIndex 打到子串 match[0]~match[k-1]的最大匹配前缀子串的后一个下标 n 的位置. 然后 sIndex 从 i+k 开始, mIndex 从 n 开始, 依次向后比较, 若再遇到不匹配的数则重复此过程.
对应代码如下:
- void length(char* str){
- if(str==NULL)
- return -1;
- int len=0;
- while(*(str++)!='\0'){
- len++;
- }
- return len;
- }
- int getIndexOf(char* str,char* m){
- int slen = length(str) , mlen = length(m);
- if(mlen> slen)
- return -1;
- int help[mlen];
- getHelpArr(str,help);
- int i=0,j=0; //sIndex,mIndex
- while(i <slen && j < mlen){
- if(str[i] == m[j]){
- i++;
- j++;
- }else if(help[j] != -1){
- j = help[j]; //mIndex -> cn's index
- }else{ //the first char is not match,move the sIndex
- i++;
- }
- }
- return j == mlen ? i - mlen : -1;
- }
可以发现 KMP 算法中 str 的遍历指针并没有回溯这个动作 (只向后移动), 当完成匹配时 sIndex 的移动次数小于 N, 否则 sIndex 移动到串尾也会终止循环, 所以 while 对应的匹配过程的时间复杂度为 O(N)(if(help[j] != -1){ j = help[j] } 的执行次数只会是常数次, 因此可以忽略).
下面只要解决如何求解一个串的 help 数组, 此问题就解决了. help 数组要从前到后求解, 直接求 help[n]是很难有所头绪的. 当串 match 长度 mlen=1 时, 规定 help[0]=-1. 当 mlen=2 时, 去掉 match[1]之后只剩下 match[0], 最大匹配子串长度为 0(因为前缀子串不能包含串尾字符, 后缀子串不能包含串首字符), 即 help[1]=0. 当 mlen>2 时, help[n](n>=2)都可以推算出来:
如上图所示, 如果我们知道了 help[n-1], 那么 help[n]的求解有两种情况: 如果 match[cn]=match[n-1], 那么由 a 区域与 b 区域 (a,b 为子串 match[0~n-2] 的最大匹配前缀子串和后缀字串)相同可知 help[n]=help[n-1]+1; 如果 match[cn]!=match[n-1], 那么求 a 区域中下一个能和 b 区域后缀子串中匹配的较大的一个, 即 a 区域的最大匹配前缀字串 c 区域, 将 match[n-1]和 c 区域的后一个位置 (cn') 上的字符比较, 如果相等则 help[n]等于 c 区域的长度 + 1, 而 c 区域的长度就是 help[cn](help 数组的定义如此); 如果不等则将 cn 打到 cn'的位置继续和 match[n-1]比较, 直到 cn 被打到 0 为止(即 help[cn]=-1 为止), 那么此时 help[n]=0.
对应代码如下:
- int* getHelpArr(char* s,int help[]){
- if(s==NULL)
- return NULL;
- int slen = length(s);
- help[0]=-1;
- help[1]=0;
- int index = 2;//help 数组从第三个元素开始的元素值需要依次推算
- int cn = 0; // 推算 help[2]时, help[1]=0, 即 s[1]之前的字符组成的串中不存在最大匹配前后子串, 那么 cn 作为最大匹配前缀子串的后一个下标自然就是 0 了
- while(index <slen){
- if(s[index-1] == s[cn]){ //if match[n-1] == match[cn]
- help[index] = help[index-1] + 1;
- index++;
- cn++;
- }else if(help[cn] == -1){ //cn reach 0
- help[index]=0;
- index++;
- cn++;
- }else{
- cn = help[cn]; //set cn to cn' and continue calculate help[index]
- }
- }
- return help;
- }
那么这个求解 help 数组的过程的时间复杂度如何计算呢? 仔细观察克制 while 循环中仅涉及到 index 和 cn 这两个变量的变化:
第一个 if 分支 | 第二个 if 分支 | 第三个 if 分支 | |
---|---|---|---|
index | 增大 | 增大 | 不变 |
index-cn | 不变 | 不变 | 增大 |
可以发现 while 循环执行一次不是 index 增大就是 index-cn 增大, 而 index < slen,index - cn < slen, 即 index 最多自增 M(match 串的长度)次 ,index-cn 最多增加 M 次, 如此 while 最多执行 M+M 次, 即时间复杂为 O(2M)=O(M).
综上所述, 使用 KMP 求解此问题的时间复杂度为 O(M)(求解 match 的 help 数组的时间复杂度)+O(N)(匹配的时间复杂度)=O(N)(因为 N> M).
KMP 算法的应用
判断一个二叉树是否是另一棵二叉树的子树(即某棵树的结构和数据状态和另一棵二叉树的子树样).
思路: 如果这棵树的序列化串是另一棵树的序列化串的子串, 那么前者必定是后者的子树.
前缀树(字典树)
前缀树的介绍
前缀树是一种存储字符串的高效容器, 基于此结构的操作有:
insert 插入一个字符串到容器中
search 容器中是否存在某字符串, 返回该字符串进入到容器的次数, 没有则返回 0
delete 将某个字符串进入到容器的次数减 1
prefixNumber 返回所有插入操作中, 以某个串为前缀的字符串出现的次数
设计思路: 该结构的重点实现在于存储. 前缀树以字符为存储单位, 将其存储在结点之间的树枝上而非结点上, 如插入字符串 abc 之后前缀树如下:
每次插入串都要从头结点开始, 遍历串中的字符依次向下 "铺路", 如上图中的 abc3 条路. 对于每个结点而言, 它可以向下铺 a~z26 条不同的路, 假如来到某个结点后, 它要向下铺的路 (取决于遍历到哪个字符来了) 被之前插入串的过程铺过了那么就可以直接走这条路去往下一个结点, 否则就要先铺路再去往下一个结点. 如再插入串 abde 和 bcd 的前缀树将如下所示:
根据前缀树的 search 和 prefixNumber 两个操作, 我们还需要在每次铺路后记录以下每个结点经过的次数(across), 以及每次插入操作每个结点作为终点结点的次数(end).
前缀树的实现
前缀树的实现示例:
- public class TrieTree {
- public static class TrieNode {
- public int across;
- public int end;
- public TrieNode[] paths;
- public TrieNode() {
- super();
- across = 0;
- end = 0;
- paths = new TrieNode[26];
- }
- }
- private TrieNode root;
- public TrieTree() {
- super();
- root = new TrieNode();
- }
- // 向树中插入一个字符串
- public void insert(String str) {
- if (str == null || str.length() == 0) {
- return;
- }
- char chs[] = str.toCharArray();
- TrieNode cur = root;
- for (char ch : chs) {
- int index = ch - 'a';
- if (cur.paths[index] == null) {
- cur.paths[index] = new TrieNode();
- }
- cur = cur.paths[index];
- cur.across++;
- }
- cur.end++;
- }
- // 查询某个字符串插入的次数
- public int search(String str) {
- if (str == null || str.length() == 0) {
- return 0;
- }
- char chs[] = str.toCharArray();
- TrieNode cur = root;
- for (char ch : chs) {
- int index = ch - 'a';
- if (cur.paths[index] == null) {
- return 0;
- }else{
- cur = cur.paths[index];
- }
- }
- return cur.end;
- }
- // 删除一次插入过的某个字符串
- public void delete(String str) {
- if (search(str)> 0) {
- char chs[] = str.toCharArray();
- TrieNode cur = root;
- for (char ch : chs) {
- int index = ch - 'a';
- if (--cur.paths[index].across == 0) {
- cur.paths[index] = null;
- return;
- }
- cur = cur.paths[index];
- }
- cur.end--;
- }
- }
- // 查询所有插入的字符串中, 以 prefix 为前缀的有多少个
- public int prefixNumber(String prefix) {
- if (prefix == null || prefix.length() == 0) {
- return 0;
- }
- char chs[] = prefix.toCharArray();
- TrieNode cur = root;
- for (char ch : chs) {
- int index = ch - 'a';
- if (cur.paths[index] == null) {
- return 0;
- }else{
- cur = cur.paths[index];
- }
- }
- return cur.across;
- }
- public static void main(String[] args) {
- TrieTree tree = new TrieTree();
- tree.insert("abc");
- tree.insert("abde");
- tree.insert("bcd");
- System.out.println(tree.search("abc")); //1
- System.out.println(tree.prefixNumber("ab")); //2
- }
- }
前缀树的相关问题
一个字符串类型的数组 arr1, 另一个字符串类型的数组 arr2:
arr2 中有哪些字符, 是 arr1 中出现的? 请打印
arr2 中有哪些字符, 是作为 arr1 中某个字符串前缀出现的? 请打印
arr2 中有哪些字符, 是作为 arr1 中某个字符串前缀出现的? 请打印 arr2 中出现次数最大的前缀.
数组
冒泡排序
冒泡排序的核心是从头遍历序列. 以升序排列为例: 将第一个元素和第二个元素比较, 若前者大于后者, 则交换两者的位置, 再将第二个元素与第三个元素比较, 若前者大于后者则交换两者位置, 以此类推直到倒数第二个元素与最后一个元素比较, 若前者大于后者, 则交换两者位置. 这样一轮比较下来将会把序列中最大的元素移至序列末尾, 这样就安排好了最大数的位置, 接下来只需对剩下的 (n-1) 个元素, 重复上述操作即可.
- void swap(int *a, int *b){
- int temp = *a;
- *a = *b;
- *b = temp;
- }
- void bubbleSort(int arr[], int length) {
- if(arr==NULL || length<=1){
- return;
- }
- for (int i = length-1; i> 0; i--) { // 只需比较 (length-1) 轮
- for (int j = 0; j <i; ++j) {
- if (arr[j]> arr[j + 1]) {
- swap(&arr[j], &arr[j + 1]);
- }
- }
- }
- }
该算法的时间复杂度为 n+(n-1)+...+1, 很明显是一个等差数列, 由(首项 + 末项)* 项数 / 2 求其和为(n+1)n/2, 可知时间复杂度为 O(n^2)
选择排序
以升序排序为例: 找到最小数的下标 minIndex, 将其与第一个数交换, 接着对子序列 (1-n) 重复该操作, 直到子序列只含一个元素为止.(即选出最小的数放到第一个位置, 该数安排好了, 再对剩下的数选出最小的放到第二个位置, 以此类推)
- void selectionSort(int arr[], int length) {
- for (int i = 0; i <length-1; ++i) { // 要进行 n-1 次选择, 选出 n-1 个数分别放在前 n-1 个位置上
- if(arr==NULL || length<=1){
- return;
- }
- int minIndex = i; // 记录较小数的下标
- for (int j = i+1; j < length; ++j) {
- if (arr[minIndex]> arr[j]) {
- minIndex = j;
- }
- }
- if (minIndex != i) {
- swap(&arr[minIndex],&arr[i]);
- }
- }
- }
同样, 不难得出该算法的时间复杂度 (big o) 为 O(n^2)(n-1+n-2+n-3+...+1)
插入排序
插入排序的过程可以联想到打扑克时揭一张牌然后将其到手中有序纸牌的合适位置上. 比如我现在手上的牌是 7,8,9,J,Q,K, 这时揭了一张 10, 我需要将其依次与 K,Q,J,9,8,7 比较, 当比到 9 时发现大于 9, 于是将其插入到 9 之后. 对于一个无序序列, 可以将其当做一摞待揭的牌, 首先将首元素揭起来, 因为揭之前手上无牌, 因此此次揭牌无需比较, 此后每揭一次牌都需要进行上述的插牌过程, 当揭完之后, 手上的握牌顺序就对应着该序列的有序形式.
- void swap(int *a, int *b){
- int temp = *a;
- *a = *b;
- *b = temp;
- }
- void insertionSort(int arr[], int length){
- if(arr==NULL || length<=1){
- return;
- }
- for (int i = 1; i <length; ++i) { // 第一张牌无需插入, 直接入手, 后续揭牌需比较然后插入, 因此从第二个元素开始遍历(插牌)
- // 将新揭的牌与手上的逐次比较, 若小于则交换, 否则停止, 比较完了还没遇到更小的也停止
- for (int j = i - 1; j>= 0 || arr[j] <= arr[j + 1]; j--) {
- if (arr[j]> arr[j + 1]) {
- swap(&arr[j], &arr[j + 1]);
- }
- }
- }
- }
插入排序的 big o 该如何计算? 可以发现如果序列有序, 那么该算法的 big o 为 O(n), 因为只是遍历了一次序列(这时最好情况); 如果序列降序排列, 那么该算法的 big o 为 O(n^2)(每次插入前的比较交换加起来要: 1+2+...+n-1)(最坏情况).** 一般应用场景中都是按算法的最坏情况来考量算法的效率的, 因为你做出来的应用要能够承受最坏情况.** 即该算法的 big o 为 O(n^2)
归并排序
归并排序的核心思想是先让序列的左半部分有序, 再让序列的右半部分有序, 最后从两个子序列 (左右两半) 从头开始逐次比较, 往辅助序列中填较小的数.
以序列 {2,1,4,3} 为例, 归并排序的过程大致如下:
算法代码示例:
- void merge(int arr[],int helpArr[], int startIndex, int midIndex,int endIndex) {
- int L = startIndex, R = midIndex + 1, i = startIndex;
- while (L <= midIndex && R <= endIndex) { // 只要没有指针没越界就逐次比较
- helpArr[i++] = arr[L] <arr[R] ? arr[L++] : arr[R++];
- }
- while (L != midIndex + 1) {
- helpArr[i++] = arr[L++];
- }
- while (R != endIndex + 1) {
- helpArr[i++] = arr[R++];
- }
- for (i = startIndex; i <= endIndex; i++) {
- arr[i] = helpArr[i];
- }
- }
- void mergeSort(int arr[],int helpArr[], int startIndex, int endIndex) {
- int midIndex;
- if (startIndex < endIndex) { // 当子序列只含一个元素时, 不再进行此子过程
- //(endIndex+startIndex)/2 可能会导致 int 溢出, 下面求中位数的做法更安全
- midIndex = startIndex + ((endIndex - startIndex)>> 1);
- mergeSort(arr, helpArr, startIndex, midIndex); // 对左半部分排序
- mergeSort(arr, helpArr, midIndex + 1, endIndex); // 对右半部分排序
- merge(arr, helpArr, startIndex, midIndex, endIndex); // 使整体有序
- }
- }
- int main(){
- int arr[] = {9, 1, 3, 4, 7, 6, 5};
- travels(arr, 7);// 遍历打印
- int helpArr[7];
- mergeSort(arr, helpArr, 0, 7);
- travels(arr, 7);
- return 0;
- }
此算法的核心就是第 24,25,26 这三行. 第 26 行应该不难理解, 就是使用两个指针 L,R 外加一个辅助数组, 将两个序列有序地并入辅助数组. 但为什么 24,25 行执行过后数组左右两半部分就分别有序了呢? 这就又牵扯到了归并排序的核心思想: 先让一个序列左右两半部分有序, 然后再并入使整体有序. 因此 24,25 是对左右两半部分分别递归执行归并排序, 直到某次递归时左右两半部分均为一个元素时递归终止. 当一个序列只含两个元素时, 调用 mergeSort 会发现 24,25 行是无效操作, 直接执行 merge. 就像上图所示, 两行递归完毕后, 左右两半部分都会变得有序.
当一个递归过程比较复杂时(不像递归求阶乘那样一幕了然), 我们可以列举简短样本进行分析.
对于这样复杂的递归行为, 千万不要想着追溯整个递归过程, 只需分析第一步要做的事 (比如此例中第一步要做的是就是 mergeSort 函数所呈现出来的那样: 对左半部分排序, 对右半部分排序, 最后并入, 你先不管是怎么排序的, 不要被 24,25 行的 mergeSort 给带进去了) 和递归终止的条件(比如此例中是 ``startIndex>=endIndex`, 即要排序的序列只有一个元素时).
归并排序的时间复杂度是 O(nlogn), 额外空间复杂度是 O(n).
根据 Master 公式 (本文 小技巧一节中有讲到) 可得 T(n)=2T(n/2)+O(n), 第一个 2 的含义是子过程 (对子序列进行归并排序) 要执行两次, 第二个 2 的含义是子过程样本量占一半 (因为分成了左右两半部分), 最后 O(n) 表示左右有序之后进行的并入操作为 O(n+n)=O(n)(L,R 指针移动次数总和为 n, 将辅助数组覆盖源数组为 n), 符合 T(n)=aT(n/b)+O(n^d), 经计算该算法的时间复杂度为 O(nlogn)
小和问题
在一个数组中, 每一个数左边比当前数小的数累加起来, 叫做这个数组的小和. 求一个数组的小和. 例如:
对于数组[1,3,4,2,5]
1 左边比 1 小的数, 没有;
3 左边比 3 小的数, 1;
4 左边比 4 小的数, 1,3;
2 左边比 2 小的数, 1;
5 左边比 5 小的数, 1,3,4,2;
所以小和为 1+1+3+1+1+3+4+2=16
简单的做法就是遍历一遍数组, 将当前遍历的数与该数之前数比较并记录小于该数的数. 易知其时间复杂度为 O(n^2)(0+1+2+......+n-1).
更优化的做法是利用归并排序的并入逻辑:
对应代码:
- int merge(int arr[],int helpArr[], int startIndex, int midIndex,int endIndex) {
- int L = startIndex, R = midIndex + 1, i = startIndex;
- int res=0;
- while (L <= midIndex && R <= endIndex ) { // 只要没有指针没越界就逐次比较
- res += arr[L] <arr[R] ? arr[L] * (endIndex - R + 1) : 0;
- helpArr[i++] = arr[L] < arr[R] ? arr[L++] : arr[R++];
- }
- while (L != midIndex + 1) {
- helpArr[i++] = arr[L++];
- }
- while (R != endIndex + 1) {
- helpArr[i++] = arr[R++];
- }
- for (i = startIndex; i <= endIndex; i++) {
- arr[i] = helpArr[i];
- }
- return res;
- }
- int mergeSort(int arr[],int helpArr[], int startIndex, int endIndex) {
- int midIndex;
- if (startIndex < endIndex) { // 当子序列只含一个元素时, 不再进行此子过程
- midIndex = startIndex + ((endIndex - startIndex)>> 1);
- return mergeSort(arr, helpArr, startIndex, midIndex) + // 对左半部分排序
- mergeSort(arr, helpArr, midIndex + 1, endIndex) + // 对右半部分排序
- merge(arr, helpArr, startIndex, midIndex, endIndex); // 使整体有序
- }
- return 0; // 一个元素时不存在小和
- }
- int main(){
- int arr[] = {1,3,4,2,5};
- int helpArr[5];
- printf("small_sum:%d\n",mergeSort(arr, helpArr, 0, 4)) ;
- return 0;
- }
该算法在归并排序的基础上做了略微改动, 即 merge 中添加了变量 res 记录每次并入操作应该累加的小和, mergeSort 则将每次并入应该累加的小和汇总. 此种做法的复杂度与归并排序的相同, 优于遍历的做法. 可以理解, 依次求每个数的小和过程中有很多比较是重复的, 而利用归并排序求小和时利用了并入的两个序列分别有序的特性省去了不必要的比较, 如 134 并入 25 时, 2>1 直接推出 2 后面的数都 > 1, 因此直接 1*(endIndex-indexOf(2)+1)即可. 这在样本量不大的情况下看不出来优化的效果, 试想一下如果样本量为 2^32, 那么依照前者求小和 O(n^2)可知时间复杂度为 O(21 亿的平方), 而归并排序求小和则只需 O(21 亿 * 32), 足以见得 O(n^2)和 O(nlogn)的优劣.
逆序对问题
在一个数组中, 左边的数如果比右边的数大, 则这两个数构成一个逆序对, 请打印所有逆序对.
这题的思路也可以利用归并排序来解决, 在并入操作时记录 arr[L]>arr[R]的情况即可.
快速排序
经典快排
经典快排就是将序列中比尾元素小的移动到序列左边, 比尾元素大的移动到序列右边, 对以该元素为界的左右两个子序列 (均不包括该元素) 重复此操作.
首先我们要考虑的是对给定的一个数, 如何将序列中比该数小的移动到左边, 比该数大的移动到右边.
思路: 利用一个辅助指针 small, 代表较小数的右边界 (初始指向首元素前一个位置), 遍历序列每次遇到比该数小的数就将其与 arr[small+1] 交换并右移 small, 最后将该数与 arr[small+1]交换即达到目的. 对应算法如下:
- void partition(int arr[], int startIndex, int endIndex){
- int small = startIndex - 1;
- for (int i = startIndex; i <endIndex; ++i) {
- if(arr[i] < arr[endIndex]) {
- if (small + 1 != i) {
- swap(arr[++small], arr[i]);
- } else {
- // 如果 small,i 相邻则不用交换
- small++;
- }
- }
- }
- swap(arr[++small], arr[endIndex]);
- }
- int main(){
- int arr[] = {1, 2, 3, 4, 6, 7, 8, 5};
- travles(arr, 8);//1 2 3 4 6 7 8 5
- partition(arr, 0, 7);
- travles(arr, 8);//1 2 3 4 5 7 8 6
- return 0;
- }
接着就是快排的递归逻辑: 对 1 2 3 4 6 7 8 5 序列 partition 之后, 去除之前的比较参数 5, 对剩下的子序列 1234 和 786 继续 partition, 直到子序列为一个元素为止:
- int partition(int arr[], int startIndex, int endIndex){
- int small = startIndex - 1;
- for (int i = startIndex; i < endIndex; ++i) {
- if(arr[i] < arr[endIndex]) {
- if (small + 1 != i) {
- swap(arr[++small], arr[i]);
- } else {
- // 如果 small,i 相邻则不用交换
- small++;
- }
- }
- }
- swap(arr[++small], arr[endIndex]);
- return small;
- }
- void quickSort(int arr[], int startIndex, int endIndex) {
- if (startIndex> endIndex) {
- return;
- }
- int index = partition(arr, startIndex, endIndex);
- quickSort(arr, startIndex, index - 1);
- quickSort(arr, index + 1, endIndex);
- }
- int main(){
- int arr[] = {1, 5, 6, 2, 7, 3, 8, 0};
- travles(arr, 8); //1 5 6 2 7 3 8 0
- quickSort(arr, 0,7);
- travles(arr, 8); //0 1 2 3 5 6 7 8
- return 0;
- }
经典排序的时间复杂度与数据状况有关, 如果每一次 partition 时, 尾元素都是序列中最大或最小的, 那么去除该元素序列并未如我们划分为样本量相同的左右两个子序列, 而是只安排好了一个元素(就是去掉的那个元素), 这样的话时间复杂度就是 O(n-1+n-2+......+1)=O(n^2); 但如果每一次 partition 时, 都将序列分成了两个样本量相差无几的左右两个子序列, 那么时间复杂度就是 O(nlogn)(使用 Master 公式求解).
由荷兰国旗问题引发对经典快排的改进
可以发现这里 partition 的过程与荷兰国旗问题中的 partition 十分相似, 能否以后者的 partition 实现经典快排呢? 我们来试一下:
- int* partition(int arr[], int startIndex, int endIndex){ ;
- int small = startIndex - 1, great = endIndex + 1, i = startIndex;
- while (i <= great - 1) {
- if (arr[i] <arr[endIndex]) {
- swap(arr[++small], arr[i++]);
- } else if (arr[i]> arr[endIndex]){
- swap(arr[--great], arr[i]);
- } else {
- i++;
- }
- }
- int range[] = {small, great};
- return range;
- }
- void quickSort(int arr[], int startIndex, int endIndex) {
- if (startIndex> endIndex) {
- return;
- }
- int* range = partition(arr, startIndex, endIndex);
- quickSort(arr, startIndex, range[0]);
- quickSort(arr, range[1], endIndex);
- }
- int main(){
- int arr[] = {1, 5, 6, 2, 7, 3, 8, 0};
- travles(arr, 8); //1 5 6 2 7 3 8 0
- quickSort(arr, 0,7);
- travles(arr, 8); //0 1 2 3 5 6 7 8
- return 0;
- }
比较一下经典排序和使用荷兰国旗问题改进后的经典排序, 不难发现, 后者一次 partition 能去除一个以上的元素 (等于 arr[endIndex] 的区域), 而前者每次 partition 只能去除一个元素, 这里的去除相当于安排 (排序) 好了对应元素的位置. 因此后者比经典排序更优, 但是优化不大, 只是常数时间内的优化, 实质上的效率还是要看数据状况(最后的情况为 O(nlogn), 最坏的情况为 O(n^2)).
随机快排 --O(nlogn)
上面谈到了快排的短板是依赖数据状况, 那么我们有没有办法消除这个依赖, 让他成为真正的 O(nlogn)呢?
事实上, 为了让算法中的操作不依托于数据状况 (如快排中每一次 partition 取尾元素作为比较, 这就没有规避样本的数据状况, 如果尾元素是最大或最小值就成了最坏情况) 常常有两种做法:
1, 使用随机取数
2, 将样本数据哈希打乱
随机快排就是采用上了上述第一种解决方案, 在每一轮的 partition 中随机选择序列中的一个数作为要比较的数:
- #include <stdio.h>
- #include <stdlib.h>
- #include <time.h>
- void swap(int &a, int &b){
- int temp = a;
- a = b;
- b = temp;
- }
- // 产生 [startIndex,endIndex] 之间的随机整数
- int randomInRange(int startIndex,int endIndex){
- return rand() % (endIndex - startIndex + 1) + startIndex;
- }
- int* partition(int arr[], int startIndex, int endIndex){ ;
- int small = startIndex - 1, great = endIndex + 1, i = startIndex;
- int randomNum = arr[randomInRange(startIndex, endIndex)];
- while (i <= great - 1) {
- if (arr[i] <randomNum) {
- swap(arr[++small], arr[i++]);
- } else if (arr[i]> randomNum){
- swap(arr[--great], arr[i]);
- } else {
- i++;
- }
- }
- int range[] = {small, great};
- return range;
- }
- void quickSort(int arr[], int startIndex, int endIndex) {
- if (startIndex> endIndex) {
- return;
- }
- int* range = partition(arr, startIndex, endIndex);
- quickSort(arr, startIndex, range[0]);
- quickSort(arr, range[1], endIndex);
- }
- void travles(int dataArr[], int length){
- for (int i = 0; i <length; ++i) {
- printf("%d", dataArr[i]);
- }
- printf("\n");
- }
- int main(){
- srand(time(NULL));// 此后调用 rand()时将以调用时的时间为随机数种子
- int arr[] = {9,7,1,3,2,6,8,4,5};
- travles(arr, 9);
- quickSort(arr, 0,8);
- travles(arr, 9);
- return 0;
- }
观察比较代码可以发现随机快排只不过是在 partition 时随机选出一个下标上的数作为比较对象, 从而避免了每一轮选择尾元素会受数据状况的影响的问题.
那么随机快排的时间复杂度又为多少呢?
经数学论证, 由于每一轮 partition 选出的作为比较对象的数是随机的, 即序列中的每个数都有 1/n 的概率被选上, 那么该算法时间复杂度为概率事件, 经数学论证该算法的数学期望为 O(nlogn). 虽然说是数学期望, 但在实际工程中, 常常就把随机快排的时间复杂度当做 O(nlog).
堆排序
什么是堆
堆结构就是将一颗完全二叉树映射到数组中的一种存储方式:
大根堆和小根堆
当堆的每一颗子树 (包括树本身) 的最大值就是其结点时称为大根堆; 相反, 当堆的每一颗子树的最小值就是其根结点时称为小根堆. 其中大根堆的应用较为广泛, 是一种很重要的数据结构.
heapInsert 和 heapify
大根堆最重要的两个操作就是 heapInsert 和 heapify, 前者是当一个元素加入到大根堆时应该自底向上与其父结点比较, 若大于父结点则交换; 后者是当堆中某个结点的数值发生变化时, 应不断向下与其孩子结点中的最大值比较, 若小于则交换. 下面是对应的代码:
- //index 之前的序列符合大根堆排序, 将 index 位置的元素加入堆结构, 但不能破坏大根堆的特性
- void heapInsert(int arr[],int index){
- while (arr[index]> arr[(index - 1) / 2]) { // 当该结点大于父结点时
- swap(arr[index], arr[(index - 1) / 2]);
- index = (index - 1) / 2; // 继续向上比较
- }
- }
- // 数组中下标从 0 到 heapSize 符合大根堆排序
- //index 位置的值发生了变化, 重新调整堆结构为大根堆
- //heapSize 指的是数组中符合大根堆排序的范围而不是数组长度, 最大为数组长度, 最小为 0
- void heapify(int arr[], int heapSize, int index){
- int leftChild = index * 2 + 1;
- while (leftChild <heapSize) { // 当该结点有左孩子时
- int greatOne = leftChild + 1 < heapSize && arr[leftChild + 1]> arr[leftChild] ?
- leftChild + 1 : leftChild; // 只有当右孩子存在且大于左孩子时, 最大值是右孩子, 否则是左孩子
- greatOne = arr[greatOne]> arr[index] ? greatOne : index;// 将父结点与最大孩子结点比较, 确定最大值
- if (greatOne == index) {
- // 如果最大值是本身, 则不用继续向下比较
- break;
- }
- swap(arr[index], arr[greatOne]);
- //next turn 下一轮
- index = greatOne;
- leftChild = index * 2 + 1;
- }
- }
建立大根堆
- void buildBigRootHeap(int arr[],int length){
- if (arr == NULL || length <= 1) {
- return;
- }
- for (int i = 0; i <length; ++i) {
- heapInsert(arr, i);
- }
- }
利用 heapify 排序
前面做了那么多铺垫都是为了建立大根堆, 那么如何利用它来排序呢?
对应代码实现如下:
- void heapSort(int arr[],int length){
- if (arr == NULL || length <= 1) {
- return;
- }
- // 先建立大根堆
- for (int i = 0; i < length; ++i) {
- heapInsert(arr, i);
- }
- // 循环弹出堆顶元素并 heapify
- int heapSize = length;
- swap(arr[0], arr[--heapSize]);// 相当于弹出堆顶元素
- while (heapSize> 0) {
- heapify(arr, heapSize, 0);
- swap(arr[0], arr[--heapSize]);
- }
- }
- int main(){
- int arr[] = {9,7,1,3,6,8,4,2,5};
- heapSort(arr, 9);
- travles(arr, 9);
- return 0;
- }
堆排序的优势在于无论是入堆一个元素 heapInsert 还是出堆一个元素之后的 heapify 都不是将整个样本遍历一遍 (O(n) 级别的操作), 而是树层次上的遍历 (O(logn) 级别的操作).
这样的话堆排序过程中, 建立堆的时间复杂度为 O(nlogn), 循环弹出堆顶元素并 heapify 的时间复杂度为 O(nlogn), 整个堆排序的时间复杂度为 O(nlogn), 额外空间复杂度为 O(1)
优先级队列结构 (比如 Java 中的 PriorityQueue) 就是堆结构.
排序算法的稳定性
排序算法的稳定性指的是排序前后是否维持值相同的元素在序列中的相对次序. 如序列 271532, 在排序过程中如果能维持第一次出现的 2 在第二次出现的 2 的前面, 那么该排序算法能够保证稳定性. 首先我们来分析一下前面所讲排序算法的稳定性, 再来谈谈稳定性的意义.
冒泡排序. 可以保证稳定性, 只需在比较相邻两个数时只在后一个数比前一个数大的情况下才交换位置即可.
选择排序. 无法保证稳定性, 比如序列
926532
, 在第一轮 maxIndex 的选择出来之后 (maxIndex=0), 第二次出现的 2(尾元素) 将与
9
交换位置, 那么两个 2 的相对次序就发生了变化, 而这个交换是否会影响稳定性在我们 coding 的时候是不可预测的.
插入排序. 可以保证稳定性, 每次插入一个数到有序序列中时, 遇到比它大的就替换, 否则不替换. 这样的话, 值相同的元素, 后面插入的就总在前面插入的后面了.
归并排序. 可以保证稳定性, 在左右两半子序列排好序后的 merge 过程中, 比较大小时如果相等, 那么优先插入左子序列中的数.
快排. 不能保证稳定性, 因为 partition 的过程会将比 num 小的与 small 区域的右一个数交换位置, 将比 num 大的与 great 区域的左一个数交换位置, 而 small,great 分居序列两侧, 很容易打乱值相同元素的相对次序.
堆排序. 不能保证稳定性. 二叉树如果交换位置的结点是相邻层次的可以保证稳定性, 但堆排序中弹出堆顶元素后的 heapify 交换的是第一层的结点和最后一层的结点.
维持稳定性一般是为了满足业务需求. 假设下面是一张不同厂商下同一款产品的价格和销售情况表:
品牌 | 价格 | 销量 |
---|---|---|
三星 | 1603 | 92 |
小米 | 1603 | 74 |
vivo | 1604 | 92 |
要求先按价格排序, 再按销量排序. 如果保证稳定性, 那么排序后应该是这样的:
品牌 | 价格 | 销量 |
---|---|---|
三星 | 1603 | 92 |
vivo | 1604 | 92 |
小米 | 1603 | 74 |
即按销量排序后, 销量相同的两条记录会保持之前的按价格排序的状态, 这样先前的价格排序这个工作就没白做.
比较器的使用
之前所讲的一些算法大都是对基本类型的排序, 但实际工程中要排序的对象可能是无法预测的, 那么如何实现一个通用的排序算法以应对呢? 事实上, 之前的排序都可以归类为基于比较的排序. 也就是说我们只需要对要比较的对象实现一个比较器, 然后排序算法基于比较器来排序, 这样算法和具体要排序的对象之间就解耦了. 以后在排序之前, 基于要排序的对象实现一个比较器(定义了如何比较对象大小的逻辑), 然后将比较器丢给排序算法即可, 这样就实现了复用.
在 Java(本人学的是 Java 方向)中, 这个比较器就是 Comparator 接口, 我们需要实现其中的 compare 方法, 对于要排序的对象集合定义一个比较大小的逻辑, 然后在构造用来添加这类对象的有序容器时传入这个构造器即可. 封装好的容器会在容器元素发生改变时使用我们的比较器来重新组织这些元素.
- import lombok.AllArgsConstructor;
- import lombok.Data;
- import java.util.PriorityQueue;
- import java.util.Comparator;
- public class ComparatorTest {
- @Data
- @AllArgsConstructor
- static class Student {
- private long id;
- private String name;
- private double score;
- }
- static class IdAscendingComparator implements Comparator<Student> {
- /**
- * 底层排序算法对两个元素比较时会调用这个方法
- * @param o1
- * @param o2
- * @return 若返回正数则认为 o1<o2, 返回 0 则认为 o1=o2, 否则认为 o1>o2
- */
- @Override
- public int compare(Student o1, Student o2) {
- return o1.getId() <o2.getId() ? -1 : 1;
- }
- }
- public static void main(String[] args) {
- // 大根堆
- PriorityQueue heap = new PriorityQueue(new IdAscendingComparator());
- Student zhangsan = new Student(1000, "zhangsan", 50);
- Student lisi = new Student(999, "lisi", 60);
- Student wangwu = new Student(1001, "wangwu", 50);
- heap.add(zhangsan);
- heap.add(lisi);
- heap.add(wangwu);
- while (!heap.isEmpty()) {
- System.out.println(heap.poll());// 弹出并返回堆顶元素
- }
- }
- }
还有 TreeSet 等, 都是在构造是传入比较器, 否则将直接根据元素的值 (Java 中引用类型变量的值为地址, 比较将毫无意义) 来比较, 这里就不一一列举了.
有关排序问题的补充
归并排序可以做到额外空间复杂度为 O(1), 但是比较难, 感兴趣的可以搜 归并排序 内部缓存法
快速排序可以做到保证稳定性, 但是很难, 可以搜 01 stable sort(论文)
有一道题是: 是奇数放到数组左边, 是偶数放到数组右边, 还要求奇数和奇数之间, 偶数和偶数之间的原始相对次序不变. 这道题和归并排序如出一辙, 只不过归并排序是将 arr[length-1]或 arr[randomIndex]作为比较的标准, 而这道题是将是否能整除 2 作为比较的标准, 这类问题都同称为 o1 sort, 要使这类问题做到稳定性, 要看 01 stable sort 这篇论文.
工程中的综合排序算法
实际工程中的排序算法一般会将 归并排序, 插入排序, 快速排序综合起来, 集大家之所长来应对不同的场景要求:
当要排序的元素为基本数据类型且元素个数较少时, 直接使用 插入排序. 因为在样本规模较小时 (比如 60),O(NlogN) 的优势并不明显甚至不及 O(N^2), 而在 O(N^2)的算法中, 插入排序的常数时间操作最少.
当要排序的元素为对象数据类型(包含若干字段), 为保证稳定性将采用 归并排序.
当要排序的元素为基本数据类型且样本规模较大时, 将采用 快速排序.
桶排序
上一节中所讲的都是基于比较的排序, 也即通过比较确定每个元素所处的位置. 那么能不能不比较而实现排序呢? 这就涉及到了 桶排序 这个方法论: 准备一些桶, 将序列中的元素按某些规则放入翻入对应的桶中, 最后根据既定的规则依次倒出桶中的元素.
非基于比较的排序, 与被排序的样本的实际数据状况有很大关系, 所以在实际中并不常用.
计数排序
计数排序是 桶排序 方法论的一种实现, 即准备一个与序列中元素的数据范围大小相同的数组, 然后遍历序列, 将遇到的元素作为数组的下标并将该位置上的数加 1. 例如某序列元素值在 0~100 之间, 请设计一个算法对其排序, 要求时间复杂度为 O(N).
- #include <stdio.h>
- void countSort(int arr[],int length){
- int bucketArr[101];
- int i;
- for(i = 0 ; i <= 100 ; i++){
- bucketArr[i]=0; //init buckets
- }
- for(i = 0 ; i <length ; i++){
- bucketArr[arr[i]]++; //put into buckets
- }
- int count, j=0;
- for(i = 0 ; i <= 100 ; i++) {
- if (bucketArr[i] != 0) { //pour out
- count = bucketArr[i];
- while (count--> 0) {
- arr[j++] = i;
- }
- }
- }
- }
- void travels(int arr[], int length){
- for (int i = 0; i <length; ++i) {
- printf("%d", arr[i]);
- }
- printf("\n");
- }
- int main(){
- int arr[] = {9, 2, 1, 4, 5, 2, 1, 6, 3, 8, 1, 2};
- travels(arr, 12);//9 2 1 4 5 2 1 6 3 8 1 2
- countSort(arr, 12);
- travels(arr, 12);//1 1 1 2 2 2 3 4 5 6 8 9
- return 0;
- }
如果下次面试官问你有没有事件复杂度比 O(N)更优的排序算法时, 不要忘了计数排序哦!!!
补充问题
给定一个数组, 求如果排序后, 相邻两数的最大值, 要求时间复杂度为 O(N), 且要求不能用非基于比较的排序.
这道题的思路比较巧妙: 首先为这 N 个数准备 N+1 个桶, 然后以其中的最小值和最大值为边界将数值范围均分成 N 等分, 然后遍历数组将对应范围类的数放入对应的桶中, 下图以数组长度为 9 举例
这里比较难理解的是:
题目问的是求如果排序后, 相邻两数的最大差值. 该算法巧妙的借助一个空桶 (N 个数进 N+1 个桶, 必然有一个是空桶), 将问题转向了求两个相邻非空桶 (其中可能隔着若干个空桶) 之间前桶的最大值和后桶最小值的差值, 而无需在意每个桶中进了哪些数(只需记录每个桶入数的最大值和最小值以及是否有数)
对应代码如下:
- #include <stdio.h>
- // 根据要入桶的数和最大最小值得到对应桶编号
- int getBucketId(int num,int bucketsNum,int min,int max){
- return (num - min) * bucketsNum / (max - min);
- }
- int max(int a, int b){
- return a> b ? a : b;
- }
- int min(int a, int b){
- return a <b ? a : b;
- }
- int getMaxGap(int arr[], int length) {
- if (arr == NULL || length < 2) {
- return -1;
- }
- int maxValue = -999999, minValue = 999999;
- int i;
- // 找出最大最小值
- for (i = 0; i < length; ++i) {
- maxValue = max(maxValue, arr[i]);
- minValue = min(minValue, arr[i]);
- }
- // 记录每个桶的最大最小值以及是否有数, 初始时每个桶都没数
- int maxs[length + 1], mins[length + 1];
- bool hasNum[length + 1];
- for (i = 0; i < length + 1; i++) {
- hasNum[i] = false;
- }
- //put maxValue into the last bucket
- mins[length] = maxs[length] = maxValue;
- hasNum[length] = true;
- //iterate the arr
- int bid; //bucket id
- for (i = 0; i < length; i++) {
- if (arr[i] != maxValue) {
- bid = getBucketId(arr[i], length + 1, minValue, maxValue);
- // 如果桶里没数, 则该数入桶后, 最大最小值都是它, 否则更新最大最小值
- mins[bid] = !hasNum[bid] ? arr[i] : arr[i] < mins[bid] ? arr[i] : mins[bid];
- maxs[bid] = !hasNum[bid] ? arr[i] : arr[i]> maxs[bid] ? arr[i] : maxs[bid];
- hasNum[bid] = true;
- }
- }
- //find the max gap between two nonEmpty buckets
- int res = 0, j = 0;
- for (i = 0; i <length; ++i) {
- j = i + 1;//the next nonEmtpy bucket id
- while (!hasNum[j]) {//the last bucket must has number
- j++;
- }
- res = max(res, (mins[j] - maxs[i]));
- }
- return res;
- }
- int main(){
- int arr[] = {13, 41, 67, 26, 55, 99, 2, 82, 39, 100};
- printf("%d", getMaxGap(arr, 9)); //17
- return 0;
- }
链表
反转单链表和双向链表
实现反转单向链表和反转双向链表的函数, 要求时间复杂度为 O(N), 额外空间复杂度为 O(1)
此题的难点就是反转一个结点的 next 指针后, 就无法在该结点通过 next 指针找到后续的结点了. 因此每次反转之前需要将该结点的后继结点记录下来.
- #include<stdio.h>
- #include<malloc.h>
- #define MAX_SIZE 100
- struct LinkNode{
- int data;
- LinkNode* next;
- };
- void init(LinkNode* &head){
- head = (LinkNode*)malloc(sizeof(LinkNode));
- head->next=NULL;
- }
- void add(int i,LinkNode* head){
- LinkNode* p = (LinkNode*)malloc(sizeof(LinkNode));
- p->data = i;
- p->next = head->next;
- head->next = p;
- }
- void printList(LinkNode* head){
- if(head==NULL)
- return;
- LinkNode* p = head->next;
- while(p != NULL){
- printf("%d",p->data);
- p = p->next;
- }
- printf("\n");
- }
- #include<stdio.h>
- #include "LinkList.cpp"
- void reverseList(LinkNode *head){
- if(head == NULL)
- return;
- LinkNode* cur = head->next;
- LinkNode* pre = NULL;
- LinkNode* next = NULL;
- while(cur != NULL){
- next = cur->next;
- cur->next = pre;
- pre = cur;
- cur = next;
- }
- //pre -> end node
- head->next = pre;
- return;
- }
- int main(){
- LinkNode* head;
- init(head);
- add(1,head);
- add(2,head);
- add(3,head);
- add(4,head);
- printList(head);
- reverseList(head);
- printList(head);
- }
判断一个链表是否为回文结构
请实现一个函数判断某个单链表是否是回文结构, 如 1->3->1 返回 true,1->2->2->1 返回 true,2->3->1 返回 false.
我们可以利用回文链表前后两半部分逆序的特点, 结合栈先进后出来求解此问题. 将链表中间结点之前的结点依次压栈, 然后从中间结点的后继结点开始遍历链表的后半部分, 将遍历的结点与栈弹出的结点比较.
代码示例如下:
- #include<stdio.h>
- #include "LinkList.cpp"
- #include "SqStack.cpp"
- /*
- 判断某链表是否是回文结构
- 1, 首先找到链表的中间结点(若是偶数个结点则是中间位置的左边一个结点)
- 2, 使用一个栈将中间结点之前的结点压栈, 然后从中间结点的后一个结点开始从栈中拿出结点比较
- */
- bool isPalindromeList(LinkNode* head){
- if(head == NULL)
- return false;
- LinkNode *slow = head , *fast = head;
- SqStack* stack;
- init(stack);
- //fast 指针每走两步, slow 指针才走一步
- while(fast->next != NULL && fast->next->next != NULL){
- fast = fast->next->next;
- slow = slow->next;
- push(slow,stack);
- }
- // 链表没有结点或只有一个结点, 不是回文结构
- if(isEmpty(stack))
- return false;
- // 判断偶数个结点还是奇数个结点
- if(fast->next != NULL){ // 奇数个结点, slow 需要再走一步
- slow = slow->next;
- }
- // 从 slow 的后继结点开始遍历链表, 将每个结点与栈顶结点比较
- LinkNode* node;
- slow = slow->next;
- while(slow != NULL){
- pop(stack,node);
- // 一旦发现有一个结点不同就不是回文结构
- if(slow->data != node->data)
- return false;
- slow = slow->next;
- }
- return true;
- }
- int main(){
- LinkNode* head;
- init(head);
- add(2,head);
- add(3,head);
- add(3,head);
- add(2,head);
- printList(head);
- if(isPalindromeList(head)){
- printf("是回文链表");
- }else{
- printf("不是回文链表");
- }
- return 0;
- }
- LinkList.cpp:
- #include<stdio.h>
- #include<malloc.h>
- #define MAX_SIZE 100
- struct LinkNode{
- int data;
- LinkNode* next;
- };
- void init(LinkNode* &head){
- head = (LinkNode*)malloc(sizeof(LinkNode));
- head->next=NULL;
- }
- void add(int i,LinkNode* head){
- LinkNode* p = (LinkNode*)malloc(sizeof(LinkNode));
- p->data = i;
- p->next = head->next;
- head->next = p;
- }
- void printList(LinkNode* head){
- if(head==NULL)
- return;
- LinkNode* p = head->next;
- while(p != NULL){
- printf("%d",p->data);
- p = p->next;
- }
- printf("\n");
- }
- SqStack:
- #include<stdio.h>
- #include<malloc.h>
- struct SqStack{
- LinkNode* data[MAX_SIZE];
- int length;
- };
- void init(SqStack* &stack){
- stack = (SqStack*)malloc(sizeof(SqStack));
- stack->length=0;
- }
- bool isEmpty(SqStack* stack){
- if(stack->length> 0)
- return false;
- return true;
- }
- bool isFull(SqStack* stack){
- if(stack->length == MAX_SIZE)
- return true;
- return false;
- }
- void push(LinkNode* i,SqStack* stack){
- if(stack==NULL)
- return;
- if(!isFull(stack)){
- stack->data[stack->length++] = i;
- }
- }
- bool pop(SqStack* stack,LinkNode* &i){
- if(stack==NULL)
- return false;
- if(!isEmpty(stack))
- i = stack->data[--stack->length];
- return true;
- }
进阶: 要求使用时间复杂度为 O(N), 额外空间复杂度为 O(1)求解此问题.
思路: 我们可以先将链表的后半部分结点的 next 指针反向, 然后从链表的两头向中间推进, 逐次比较.(当然了, 为了不破坏原始数据结构, 我们在得出结论之后还需要将链表指针恢复原样)
- #include<stdio.h>
- #include "LinkList.cpp"
- #include "SqStack.cpp"
- bool isPalindromeList(LinkNode* head){
- /* 第一步, 与方法一一样, 找到中间结点 */
- if(head == NULL)
- return false;
- LinkNode *n1 = head , *n2 = head;
- while(n2->next != NULL && n2->next->next != NULL){
- n2 = n2->next->next;
- n1 = n1->next;
- }
- // 如果没有结点或者只有一个首结点
- if(n2 == head){
- return false;
- }
- // 如果是奇数个结点
- if(n2->next != NULL){
- n1 = n1->next; //n1 -> middle node
- }
- /* 第二步, 不使用额外空间, 在链表自身上做文章: 反转链表后半部分结点的 next 指针 */
- n2 = n1->next; // n2 -> right part first node
- n1->next = NULL;//middle node->next = NULL
- LinkNode *n3 = NULL;
- while (n2 != NULL) {
- n3 = n2->next; // 记录下一个要反转指针的结点
- n2->next = n1; // 反转指针
- n1 = n2;
- n2 = n3;
- }
- //n1 -> end node
- n3 = n1; //record end node
- n2 = head->next;
- while (n2 != NULL) {
- if (n2->data != n1->data) {
- return false;
- }
- n2 = n2->next; //move n2 forward right
- n1 = n1->next; //move n1 forward left
- }
- //recover the right part nodes
- n2 = n3; //n2 -> end node
- n1 = NULL;
- while (n2 != NULL) {
- n3 = n2->next;
- n2->next = n1;
- n1=n2;
- n2 = n3;
- }
- return true;
- }
- /*bool isPalindromeList(LinkNode* head){
- if(head == NULL)
- return false;
- LinkNode *slow = head , *fast = head;
- SqStack* stack;
- init(stack);
- //fast 指针每走两步, slow 指针才走一步
- while(fast->next != NULL && fast->next->next != NULL){
- fast = fast->next->next;
- slow = slow->next;
- push(slow,stack);
- }
- // 链表没有结点或只有一个结点, 不是回文结构
- if(isEmpty(stack))
- return false;
- // 判断偶数个结点还是奇数个结点
- if(fast->next != NULL){ // 奇数个结点, slow 需要再走一步
- slow = slow->next;
- }
- // 从 slow 的后继结点开始遍历链表, 将每个结点与栈顶结点比较
- LinkNode* node;
- slow = slow->next;
- while(slow != NULL){
- pop(stack,node);
- // 一旦发现有一个结点不同就不是回文结构
- if(slow->data != node->data)
- return false;
- slow = slow->next;
- }
- return true;
- }*/
- int main(){
- LinkNode* head;
- init(head);
- add(2,head);
- add(3,head);
- add(3,head);
- add(1,head);
- printList(head);
- if(isPalindromeList(head)){
- printf("yes");
- }else{
- printf("no");
- }
- return 0;
- }
链表与荷兰国旗问题
将单向链表按某值划分成左边小, 中间相等, 右边大的形式
- #include<stdio.h>
- #include "LinkList.cpp"
- /*
- partition 一个链表有两种做法.
- 1, 将链表中的所有结点放入一个数组中, 那么就转换成了荷兰国旗问题, 但这种做法会使用 O(N)的额外空间;
- 2, 分出逻辑上的 small,equal,big 三个区域, 遍历链表结点将其添加到对应的区域中, 最后再将这三个区域连起来.
- 这里只示范第二种做法:
- */
- void partitionList(LinkNode *head,int val){
- if(head == NULL)
- return;
- LinkNode *smH = NULL; //small area head node
- LinkNode *smT = NULL; //small area tail node
- LinkNode *midH = NULL; //equal area head node
- LinkNode *midT = NULL; //equal area tail node
- LinkNode *bigH = NULL; //big area head node
- LinkNode *bigT = NULL; //big area tail node
- LinkNode *cur = head->next;
- LinkNode *next = NULL;//next node need to be distributed to the three areas
- while(cur != NULL){
- next = cur->next;
- cur->next = NULL;
- if(cur->data> val){
- if(bigH == NULL){
- bigH = bigT = cur;
- }else{
- bigT->next = cur;
- bigT = cur;
- }
- }else if(cur->data == val){
- if(midH == NULL){
- midH = midT = cur;
- }else{
- midT->next = cur;
- midT = cur;
- }
- }else{
- if(smH == NULL){
- smH = smT = cur;
- }else{
- smT->next = cur;
- smT = cur;
- }
- }
- cur = next;
- }
- //reconnect small and equal
- if(smT != NULL){
- smT->next = midH;
- midT = midT == NULL ? midT : smT;
- }
- //reconnect equal and big
- if(bigT != NULL){
- midT->next = bigH;
- }
- head = smH != NULL ? smH : midH != NULL ? midH : bigH;
- return;
- }
- int main(){
- LinkNode* head;
- init(head);
- add(5,head);
- add(2,head);
- add(7,head);
- add(9,head);
- add(1,head);
- add(3,head);
- add(5,head);
- printList(head);
- partitionList(head,5);
- printList(head);
- }
复制含有随机指针结点的链表
借助哈希表, 额外空间 O(N)
将链表的所有结点复制一份, 以 key,value 为源结点, 副本结点的方式存储到哈希表中, 再建立副本结点之间的关系(next,rand 指针域)
- import java.util.HashMap;
- import java.util.Map;
- public class CopyLinkListWithRandom {
- public static class Node {
- public Node(int data) {
- this.data = data;
- }
- public Node() {
- }
- int data;
- Node next;
- Node rand;
- }
- public static Node copyLinkListWithRandom(Node head) {
- if (head == null) {
- return null;
- }
- Node cur = head;
- Map<Node, Node> copyMap = new HashMap<>();
- while (cur != null) {
- copyMap.put(cur, new Node(cur.data));
- cur = cur.next;
- }
- cur = head;
- while (cur != null) {
- copyMap.get(cur).next = copyMap.get(cur.next);
- copyMap.get(cur).rand = copyMap.get(cur.rand);
- cur = cur.next;
- }
- return copyMap.get(head);
- }
- public static void printListWithRandom(Node head) {
- if (head != null) {
- while (head.next != null) {
- head = head.next;
- System.out.print("node data:" + head.data);
- if (head.rand != null) {
- System.out.println(",rand data:" + head.rand.data);
- } else {
- System.out.println(",rand is null");
- }
- }
- }
- }
- public static void main(String[] args) {
- Node head = new Node();
- head.next = new Node(1);
- head.next.next = new Node(2);
- head.next.next.next = new Node(3);
- head.next.next.next.next = new Node(4);
- head.next.rand = head.next.next.next.next;
- head.next.next.rand = head.next.next.next;
- printListWithRandom(head);
- System.out.println("==========");
- Node copy = copyLinkListWithRandom(head);
- printListWithRandom(copy);
- }
- }
进阶操作: 额外空间 O(1)
将副本结点追加到对应源结点之后, 建立副本结点之间的指针域, 最后将副本结点从该链表中分离出来.
- //extra area O(1)
- public static Node copyLinkListWithRandom2(Node head){
- if (head == null) {
- return null;
- }
- Node cur = head;
- //copy every node and append
- while (cur != null) {
- Node copy = new Node(cur.data);
- copy.next = cur.next;
- cur.next = copy;
- cur = cur.next.next;
- }
- //set the rand pointer of every copy node
- Node copyHead = head.next;
- cur = head;
- Node curCopy = copyHead;
- while (curCopy != null) {
- curCopy.rand = cur.rand == null ? null : cur.rand.next;
- cur = curCopy.next;
- curCopy = cur == null ? null : cur.next;
- }
- //split
- cur = head;
- Node next = null;
- while (cur != null) {
- curCopy = cur.next;
- next = cur.next.next;
- curCopy.next = next == null ? null : next.next;
- cur.next = next;
- cur = next;
- }
- return copyHead;
- }
若两个可能有环的单链表相交, 请返回相交的第一个结点
根据单链表的定义, 每个结点有且只有一个 next 指针, 那么如果单链表有环, 它的结构将是如下所示:
相交会导致两个结点指向同一个后继结点, 但不可能出现一个结点有两个后继结点的情况.
1, 当相交的结点不在环上时, 有如下两种情况:
2, 当相交的结点在环上时, 只有一种情况:
综上, 两单链表若相交, 要么都无环, 要么都有环.
此题还需要注意的一点是如果链表有环, 那么如何获取入环呢(因为不能通过 next 是否为空来判断是否是尾结点了). 这里就涉及到了一个规律: 如果快指针 fast 和慢指针 slow 同时从头结点出发, fast 走两步而 slow 走一步, 当两者相遇时, 将 fast 指针指向头结点, 使两者都一次只走一步, 两者会在入环结点相遇.
- public class FirstIntersectNode {
- public static class Node{
- int data;
- Node next;
- public Node(int data) {
- this.data = data;
- }
- }
- public static Node getLoopNode(Node head) {
- if (head == null) {
- return null;
- }
- Node fast = head;
- Node slow = head;
- do {
- slow = slow.next;
- if (fast.next == null || fast.next.next == null) {
- return null;
- } else {
- fast = fast.next.next;
- }
- } while (fast != slow);
- //fast == slow
- fast = head;
- while (fast != slow) {
- slow = slow.next;
- fast = fast.next;
- }
- return slow;
- }
- public static Node getFirstIntersectNode(Node head1, Node head2) {
- if (head1 == null || head2 == null) {
- return null;
- }
- Node loop1 = getLoopNode(head1); // 两链表的入环结点 loop1 和 loop2
- Node loop2 = getLoopNode(head2);
- //no loop
- if (loop1 == null && loop2 == null) {
- return noLoop(head1, head2);
- }
- //both loop
- if (loop1 != null && loop2 != null) {
- return bothLoop(head1, head2, loop1, loop2);
- }
- //don't intersect
- return null;
- }
- private static Node bothLoop(Node head1, Node head2, Node loop1, Node loop2) {
- Node cur1 = head1;
- Node cur2 = head2;
- // 入环结点相同, 相交点不在环上
- if (loop1 == loop2) {
- int n = 0;
- while (cur1.next != loop1) {
- n++;
- cur1 = cur1.next;
- }
- while (cur2.next != loop1) {
- n--;
- cur2 = cur2.next;
- }
- cur1 = n> 0 ? head1 : head2; // 将 cur1 指向结点数较多的链表
- cur2 = cur1 == head1 ? head2 : head1; // 将 cur2 指向另一个链表
- n = Math.abs(n);
- while (n != 0) { // 将 cur1 先走两链表结点数差值个结点
- cur1 = cur1.next;
- n--;
- }
- while (cur1 != cur2) { //cur1 和 cur2 会在入环结点相遇
- cur1 = cur1.next;
- cur2 = cur2.next;
- }
- return cur1;
- }
- // 入环结点不同, 相交点在环上
- cur1 = loop1.next;
- while (cur1 != loop1) {
- if (cur1 == loop2) { // 链表 2 的入环结点在链表 1 的环上, 说明相交
- return loop1; // 返回 loop1 或 loop2 均可, 因为整个环就是两链表的相交部分
- }
- cur1 = cur1.next;
- }
- // 在链表 1 的环上转了一圈也没有找到链表 2 的入环结点, 说明不想交
- return null;
- }
- private static Node noLoop(Node head1, Node head2) {
- Node cur1 = head1;
- Node cur2 = head2;
- int n = 0;
- while (cur1.next != null) {
- n++;
- cur1 = cur1.next;
- }
- while (cur2.next != null) {
- n--;
- cur2 = cur2.next;
- }
- if (cur1 != cur2) { // 两链表的尾结点不同, 不可能相交
- return null;
- }
- cur1 = n> 0 ? head1 : head2; // 将 cur1 指向结点数较多的链表
- cur2 = cur1 == head1 ? head2 : head1; // 将 cur2 指向另一个链表
- n = Math.abs(n);
- while (n != 0) { // 将 cur1 先走两链表结点数差值个结点
- cur1 = cur1.next;
- n--;
- }
- while (cur1 != cur2) { //cur1 和 cur2 会在入环结点相遇
- cur1 = cur1.next;
- cur2 = cur2.next;
- }
- return cur1;
- }
- public static void printList(Node head) {
- for (int i = 0; i <50; i++) {
- System.out.print(head.data+" ");
- head = head.next;
- }
- System.out.println();
- }
- }
对应三种情况测试如下:
- public static void main(String[] args) {
- //==================== both loop ======================
- //1->2->[3]->4->5->6->7->[3]...
- Node head1 = new Node(1);
- head1.next = new Node(2);
- head1.next.next = new Node(3);
- head1.next.next.next = new Node(4);
- head1.next.next.next.next = new Node(5);
- head1.next.next.next.next.next = new Node(6);
- head1.next.next.next.next.next.next = new Node(7);
- head1.next.next.next.next.next.next.next = head1.next.next;
- //9->8->[6]->7->3->4->5->[6]...
- Node head2 = new Node(9);
- head2.next = new Node(8);
- head2.next.next = head1.next.next.next.next.next;
- head2.next.next.next = head1.next.next.next.next.next.next;
- head2.next.next.next.next = head1.next.next;
- head2.next.next.next.next.next = head1.next.next.next;
- head2.next.next.next.next.next.next = head1.next.next.next.next;
- head2.next.next.next.next.next.next.next = head1.next.next.next.next.next;
- printList(head1);
- printList(head2);
- System.out.println(getFirstIntersectNode(head1, head2).data);
- System.out.println("==================");
- //1->[2]->3->4->5->6->7->8->4...
- Node head3 = new Node(1);
- head3.next = new Node(2);
- head3.next.next = new Node(3);
- head3.next.next.next = new Node(4);
- head3.next.next.next.next = new Node(5);
- head3.next.next.next.next.next = new Node(6);
- head3.next.next.next.next.next.next = new Node(7);
- head3.next.next.next.next.next.next.next = new Node(8);
- head3.next.next.next.next.next.next.next.next = head1.next.next.next;
- //9->0->[2]->3->4->5->6->7->8->4...
- Node head4 = new Node(9);
- head4.next = new Node(0);
- head4.next.next = head3.next;
- head4.next.next.next = head3.next.next;
- head4.next.next.next.next = head3.next.next.next;
- head4.next.next.next.next.next = head3.next.next.next.next;
- head4.next.next.next.next.next.next = head3.next.next.next.next.next;
- head4.next.next.next.next.next.next.next = head3.next.next.next.next.next.next;
- head4.next.next.next.next.next.next.next.next = head3.next.next.next.next.next.next.next;
- head4.next.next.next.next.next.next.next.next.next = head3.next.next.next;
- printList(head3);
- printList(head4);
- System.out.println(getFirstIntersectNode(head3,head4).data);
- System.out.println("==================");
- //============= no loop ==============
- //1->[2]->3->4->5
- Node head5 = new Node(1);
- head5.next = new Node(2);
- head5.next.next = new Node(3);
- head5.next.next.next = new Node(4);
- head5.next.next.next.next = new Node(5);
- //6->[2]->3->4->5
- Node head6 = new Node(6);
- head6.next = head5.next;
- head6.next.next = head5.next.next;
- head6.next.next.next = head5.next.next.next;
- head6.next.next.next.next = head5.next.next.next.next;
- System.out.println(getFirstIntersectNode(head5,head6).data);
- }
栈和队列
用数组结构实现大小固定的栈和队列
- //
- // Created by zaw on 2018/10/21.
- //
- #include <stdio.h>
- #include <malloc.h>
- #define MAX_SIZE 1000
- struct ArrayStack{
- int data[MAX_SIZE];
- int top;
- };
- void init(ArrayStack *&stack) {
- stack = (ArrayStack *) malloc(sizeof(ArrayStack));
- stack->top = -1;
- }
- bool isEmpty(ArrayStack* stack){
- return stack->top == -1 ?;
- }
- bool isFull(ArrayStack *stack){
- return stack->top == MAX_SIZE - 1 ?;
- }
- void push(int i, ArrayStack *stack){
- if (!isFull(stack)) {
- stack->data[++stack->top] = i;
- }
- }
- int pop(ArrayStack* stack){
- if (!isEmpty(stack)) {
- return stack->data[stack->top--];
- }
- }
- int getTopElement(ArrayStack *stack){
- if (!isEmpty(stack)) {
- return stack->data[stack->top];
- }
- }
- int main(){
- ArrayStack* stack;
- init(stack);
- push(1, stack);
- push(2, stack);
- push(3, stack);
- printf("%d", pop(stack));
- printf("%d", getTopElement(stack));
- printf("%d", pop(stack));
- printf("%d", pop(stack));
- //3 2 2 1
- return 0;
- }
- //
- // Created by zaw on 2018/10/21.
- //
- #include <stdio.h>
- #include <malloc.h>
- #define MAX_SIZE 1000
- // 数组结构实现的环形队列
- struct ArrayCircleQueue{
- int data[MAX_SIZE];
- int front,rear;
- };
- void init(ArrayCircleQueue *&queue){
- queue = (ArrayCircleQueue *) malloc(sizeof(ArrayCircleQueue));
- queue->front = queue->rear = 0;
- }
- bool isEmpty(ArrayCircleQueue *queue){
- return queue->front == queue->rear;
- }
- bool isFull(ArrayCircleQueue *queue){
- return (queue->rear+1)%MAX_SIZE==queue->front;
- }
- void enQueue(int i, ArrayCircleQueue *queue){
- if (!isFull(queue)) {
- //move the rear and fill it
- queue->data[++queue->rear] = i;
- }
- }
- int deQueue(ArrayCircleQueue *queue){
- if (!isEmpty(queue)) {
- return queue->data[++queue->front];
- }
- }
- int main(){
- ArrayCircleQueue* queue;
- init(queue);
- enQueue(1, queue);
- enQueue(2, queue);
- enQueue(3, queue);
- while (!isEmpty(queue)) {
- printf("%d", deQueue(queue));
- }
- }
取栈中最小元素
实现一个特殊的栈, 在实现栈的基本功能的基础上, 再实现返回栈中最小元素的操作 getMin. 要求如下:
pop,push,getMin 操作的时间复杂度都是 O(1).
设计的栈类型可以使用现成的栈结构.
思路: 由于每次 push 之后都会可能导致栈中已有元素的最小值发生变化, 因此需要一个容器与该栈联动(记录每次 push 产生的栈中最小值). 我们可以借助一个辅助栈, 数据栈 push 第一个元素时, 将其也 push 到辅助栈, 此后每次向数据栈 push 元素的同时将其和辅助栈的栈顶元素比较, 如果小, 则将其也 push 到辅助栈, 否则取辅助栈的栈顶元素 push 到辅助栈.(数据栈正常 push,pop 数据, 而辅助栈 push 每次数据栈 push 后产生的栈中最小值; 但数据栈 pop 时, 辅助栈也只需简单的 pop 即可, 保持同步)
- //
- // Created by zaw on 2018/10/21.
- //
- #include <stdio.h>
- #include <malloc.h>
- #include "ArrayStack.cpp"
- int min(int a, int b){
- return a <b ? a : b;
- }
- struct GetMinStack{
- ArrayStack* dataStack;
- ArrayStack* helpStack;
- };
- void initGetMinStack(GetMinStack* &stack){
- stack = (GetMinStack *) malloc(sizeof(GetMinStack));
- init(stack->dataStack);
- init(stack->helpStack);
- }
- void push(int i, GetMinStack *stack) {
- if (!isFull(stack->dataStack)) {
- push(i, stack->dataStack); //ArrayStack.cpp
- if (!isEmpty(stack->helpStack)) {
- i = min(i, getTopElement(stack->helpStack));
- }
- push(i, stack->helpStack);
- }
- }
- int pop(GetMinStack* stack){
- if (!isEmpty(stack->dataStack)) {
- pop(stack->helpStack);
- return pop(stack->dataStack);
- }
- }
- int getMin(GetMinStack *stack){
- if (!isEmpty(stack->dataStack)) {
- return getTopElement(stack->helpStack);
- }
- }
- int main(){
- GetMinStack *stack;
- initGetMinStack(stack);
- push(6, stack);
- printf("%d", getMin(stack));//6
- push(3, stack);
- printf("%d", getMin(stack));//3
- push(1, stack);
- printf("%d", getMin(stack));//1
- pop(stack);
- printf("%d", getMin(stack));//3
- return 0;
- }
仅用队列结构实现栈结构
思路: 只要将关注点放在 后进先出 这个特性就不难实现了. 使用一个数据队列和辅助队列, 当放入数据时使用队列的操作正常向数据队列中放, 但出队元素时, 需将数据队列的前 n-1 个数入队辅助队列, 而将数据队列的队尾元素弹出来, 最后数据队列和辅助队列交换角色.
- //
- // Created by zaw on 2018/10/21.
- //
- #include <stdio.h>
- #include <malloc.h>
- #include "../queue/ArrayCircleQueue.cpp"
- struct DoubleQueueStack{
- ArrayCircleQueue* dataQ;
- ArrayCircleQueue* helpQ;
- };
- void init(DoubleQueueStack* &stack){
- stack = (DoubleQueueStack *) malloc(sizeof(DoubleQueueStack));
- init(stack->dataQ);
- init(stack->helpQ);
- }
- void swap(ArrayCircleQueue *&dataQ, ArrayCircleQueue *&helpQ){
- ArrayCircleQueue* temp = dataQ;
- dataQ = helpQ;
- helpQ = temp;
- }
- void push(int i,DoubleQueueStack* stack){
- if (!isFull(stack->dataQ)) {
- return enQueue(i, stack->dataQ);
- }
- }
- int pop(DoubleQueueStack* stack){
- if (!isEmpty(stack->dataQ)) {
- int i = deQueue(stack->dataQ);
- while (!isEmpty(stack->dataQ)) {
- enQueue(i, stack->helpQ);
- i = deQueue(stack->dataQ);
- }
- swap(stack->dataQ, stack->helpQ);
- return i;
- }
- }
- bool isEmpty(DoubleQueueStack* stack){
- return isEmpty(stack->dataQ);
- }
- int getTopElement(DoubleQueueStack* stack){
- if (!isEmpty(stack->dataQ)) {
- int i = deQueue(stack->dataQ);
- while (!isEmpty(stack->dataQ)) {
- enQueue(i, stack->helpQ);
- i = deQueue(stack->dataQ);
- }
- enQueue(i, stack->helpQ);
- swap(stack->dataQ, stack->helpQ);
- return i;
- }
- }
- int main(){
- DoubleQueueStack *stack;
- init(stack);
- push(1, stack);
- push(2, stack);
- push(3, stack);
- while (!isEmpty(stack)) {
- printf("%d", pop(stack));
- }
- push(4, stack);
- printf("%d", getTopElement(stack));
- return 0;
- }
仅用栈结构实现队列结构
思路: 使用两个栈, 一个栈 PutStack 用来放数据, 一个栈 GetStack 用来取数据. 取数据时, 如果 PulllStack 为空则需要将 PutStack 中的所有元素一次性依次 pop 并放入 GetStack.
特别要注意的是这个 倒数据的时机:
只有当 GetStack 为空时才能往里倒
倒数据时必须一次性将 PutStack 中的数据倒完
- //
- // Created by zaw on 2018/10/21.
- //
- #include <stdio.h>
- #include <malloc.h>
- #include "../stack/ArrayStack.cpp"
- struct DoubleStackQueue{
- ArrayStack* putStack;
- ArrayStack* getStack;
- };
- void init(DoubleStackQueue *&queue){
- queue = (DoubleStackQueue *) malloc(sizeof(DoubleStackQueue));
- init(queue->putStack);
- init(queue->getStack);
- }
- bool isEmpty(DoubleStackQueue *queue){
- return isEmpty(queue->getStack) && isEmpty(queue->putStack);
- }
- void pour(ArrayStack *stack1, ArrayStack *stack2){
- while (!isEmpty(stack1)) {
- push(pop(stack1), stack2);
- }
- }
- void enQueue(int i, DoubleStackQueue *queue){
- if (!isFull(queue->putStack)) {
- push(i, queue->putStack);
- } else {
- if (isEmpty(queue->getStack)) {
- pour(queue->putStack, queue->getStack);
- push(i, queue->putStack);
- }
- }
- }
- int deQueue(DoubleStackQueue* queue){
- if (!isEmpty(queue->getStack)) {
- return pop(queue->getStack);
- } else {
- if (!isEmpty(queue->putStack)) {
- pour(queue->putStack, queue->getStack);
- return pop(queue->getStack);
- }
- }
- }
- int main(){
- DoubleStackQueue *queue;
- init(queue);
- enQueue(1, queue);
- printf("%d\n", deQueue(queue));
- enQueue(2, queue);
- enQueue(3, queue);
- while (!isEmpty(queue)) {
- printf("%d", deQueue(queue));
- }
- return 0;
- }
来源: https://juejin.im/post/5c6b9c696fb9a049ed316ef9