算法学习-递归、排序、查找

182 阅读8分钟

递归

递归的重点是识别出来,哪些问题可以通过递归来解决。

看以下的场景:

  1. 查找最终推荐用户。A -> B -> C -> D -> E ,除了A以外 其他所有的最终推荐用户均为A。如果要查找一个用户的最终推荐用户,我们保留有推荐关系,此时可以很容易想到使用递归方式解决。
  2. 查找根节点。与最终推荐用户一样,可以直接考虑用递归实现。
  3. 假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?这种问题一开始很难想到用递归去解决。

为什么问题3很难想到用递归呢?

探究其本质,问题1,2 都较为简单,只能拆分为一个子问题。而问题3则可以拆分为多个子问题。通常我们在拆分为多个问题时,就很难去理解递归了。

递归的特点

  1. 拆分。一个问题可以被拆分为多个问题(不局限与一个)。
  2. 一致。问题拆分后的求解思路与拆分前思路一致。
  3. 终止。有终止条件。

如何写好递归

如何写出来

回想一下每次写递归代码前,总是会下意识的在脑子中执行一遍递归操作。

对于问题1.2来说,思路简单,大脑很容易就能执行完成,写好递归代码并不难。

但是对于问题3来说,期望大脑一层一层往下执行,做完所有的递、归操作,一开始便陷入了误区。由于问题3较为复杂,所以这个过程往往会很痛苦。

如果一个问题 A 可以分解为若干子问题 B、C、D,你可以假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A。而且,你只需要思考问题 A 与子问题 B、C、D 两层之间的关系即可,不需要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。

编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤

避免堆栈溢出

递归最大的问题就是可能会堆栈溢出。

试想,每次递归都在执行入栈操作,JVM内存栈一般不会设置很大,如果调用层次很深的话,就会有溢出风险。

如何避免呢?可以考虑限制深度,不过一般不太适用,因为栈溢出跟当前线程栈剩余空间有关,有的线程多有的少,无法实时评估,风险仍然存在。

避免重复计算

如问题3,该问题在计算时便存在重复计算场景。比如 5 可以拆分为 4 ,3 。4 继续拆分为 3 ,2 此时 3的计算便已经重复了。

改为非递归代码

既然递归存在各种风险,那么为什么不直接考虑不用递归呢?

public class StepTest {

    /**
     * 假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?
     * 如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2
     * 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?
     */
    public static void main(String[] args) {
        System.out.println(calculate(7));
        System.out.println(calculate2(7));
    }

    private static int calculate2(int i) {
        if (i == 2) {
            return 2;
        }
        if (i == 1) {
            return 1;
        }
        int result = 0;
        int pre1 = 2;
        int pre2 = 1;
        for (int j = 3; j <= i; j++) {
            result = pre1 + pre2;
            pre2 = pre1;
            pre1 = result;
        }
        return result;
    }

    private static int calculate(int i) {
        if (i == 2) {
            return 2;
        }
        if (i == 1) {
            return 1;
        }
        return calculate(i - 1) + calculate(i - 2);
    }
}

如上代码,改为非递归后,重复计算逻辑不存在了。

是不是所有递归都适合改为非递归呢?

笼统的说确实是可以的。不过改为递归后并没有办法避免堆栈溢出,本质还是深度遍历。而且发现,写起来也复杂的不少。如果单纯只是少一些重复计算,那么在递归中也是可以避免的。没有必要非得采用循环的方式。

排序

插入排序(On²)

从首位开始,后续数据使用有序新增方式,挨个加入数组。

重点(从小到大排序)

  1. 如果新增节点 > 当前最后一个节点,直接新增至末尾。结束

  2. 如果新增节点 < 当前最后一个节点,表示需要往前插入。

    1. 此时需要移动当前最后一个节点至末尾,然后继续往前查找。
    2. 直到找到新增节点 > 前一个节点,此时可以直接赋值。结束
  3. 极端情况,新增数据比数组最小的数据都要小。没有办法满足 新增节点 > 前一个节点条件。此时,需要判断如果已经到了起始节点,直接赋值即可。

    /**
     * 插入排序
     *
     * 从下标为1处开始,每次往数组 按顺序 新增一个数据
     */
    private static void insert(int[] arr) {
        for (int i = 1; i < arr.length; i++) {
            insertEle(arr, i, arr[i]);
        }
        print(arr);
    }
​
    /**
     * 采用尾插法 判断前一个数据 如果比当前数据小 就直接赋值
     * 如果比当前数据大 移动数据 继续对比, 如果直到最后一位 还是比新增数据小 那就直接新增到 第0位
     *
     * @param count 当前有序数据数量
     * @param item 新增数据
     */
    private static void insertEle(int[] arr, int count, int item) {
        for (int i = count; i > 0; i--) {
            // 这里一定要对比 item
            if (arr[i - 1] < item) {
                // 这里必须要赋值
                arr[i] = item;
                // 新增完就没有必要 继续循环了 需要跳出
                break;
            }
            // 交换位置
            arr[i] = arr[i - 1];
            // 最后判断是否 要赋值0位
            if (i == 1) {
                arr[0] = item;
            }
        }
    }

上方的新增方式略微繁琐。看下方代码:

private static void insertEle(int[] arr, int count, int item) {
    int i = count;
    for (; i > 0; i--) {
        // 判断是否需要交换
        if (arr[i - 1] > item) {
            arr[i] = arr[i - 1];
        } else {
            break;
        }
    }
    // 赋值
    arr[i] = item;
}
​

思考

为什么一个写的比较复杂?

没有看透问题本质,对于该问题来说,只有两种情况,交换/赋值。

冒泡排序(On²)

两两交换,将最大值 / 最小值 移动至末尾。

外层循环控制有序数量。

内层循环进行比对,交换,将最大/最小移动到最后。

private static int[] maopao(int[] arr) {
    // 可以通过变量 判断是否有变化, 没有变化可以提前结束
    for (int i = 0; i < arr.length; i++) {
        for (int j = 0; j < arr.length - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
    print(arr);
    return arr;
}

为什么冒泡排序没有插入排序更受欢迎?

冒泡排序与插入排序都是源地排序、稳定排序,时间复杂度也都是O(n²)。

插入排序的主要优点就是只有一次赋值操作,节省空间。

选择排序(On²)

选择排序属于源地排序,不需要额外的数组空间。

但是选择排序并非是稳定排序。主要靠数据两两交换来排序。比如数据:3,6,3,1 排序后 第一个3 会被换到最后一个3后边。

挨个遍历选择 最大/最小 值

private static int[] maopao(int[] arr) {
    // 可以通过变量 判断是否有变化, 没有变化可以提前结束
    for (int i = 0; i < arr.length; i++) {
        for (int j = i; j < arr.length; j++) {
            if (arr[i] < arr[j]) {
                int temp = arr[i];
                arr[i] = arr[j];
                arr[j] = temp;
            }
        }
    }
    print(arr);
    return arr;
}

归并排序(Onlogn)

归并排序 本质属于递归。

思想主要是分制,将数组拆分,然后两两合并。

/**
 * 1. 根据递归的思想,确认问题拆分步骤。要对数组排队,先拆分数组 —》 排序合并,
 * 2. 终止条件为: 数组元素为 < 2 没有办法继续拆分,
 * 3. 无法拆分时即可执行 合并。
 *
 * @param arr 数组
 * @param start 起始位置(方便拆分)
 * @param end 终止位置(方便拆分)
 */
private static void guibing(int[] arr, int start, int end) {
    if (start >= end) {
        return;
    }
    // 该方式避免 数据过大时 超限
    int mid = (end - start) / 2 + start;
    guibing(arr, start, mid);
    // 这里记得 + 1
    guibing(arr, mid + 1, end);
    mergeArray(arr, start, mid, end);
}
​
/**
 * 排序 合并
 * 1. 需要考虑 拆分为两个时 只需要比对两个即可
 *
 * @param mid 用于区分两个数组
 */
private static void mergeArray(int[] arr, int start, int mid, int end) {
    // 这里记得 + 1 表示 数组长度
    int[] temp = new int[end - start + 1];
    // 这里 需要使用临时变量, start -> mid, mid + 1  -> end ,
    int mid2 = mid + 1;
    // 这里 需要使用临时变量。 start 变量后续要用于 copy数组
    int startTemp = start;
    int i = 0;
    while (startTemp <= mid && mid2 <= end) {
        if (arr[startTemp] < arr[mid2]) {
            temp[i++] = arr[startTemp++];
        } else {
            temp[i++] = arr[mid2++];
        }
    }
    while (startTemp <= mid) {
        temp[i++] = arr[startTemp++];
    }
    while (mid2 <= end) {
        temp[i++] = arr[mid2++];
    }
    i = 0;
    for (int j = start; j <= end; j++) {
        arr[j] = temp[i++];
    }
}

快速排序(Onlogn)

快速排序本质也是利用分制思想,将排序任务拆分。

思路:

  1. 采用递归方式,思考拆分以及终止条件。
  2. 先找任一一个数据作为中间位。(通常可以用最后一位,遍历以及最后一步赋值比较方便)
  3. 然后遍历数组,将小于中间位的移动到左边,大于中间位的移动到右边。
  4. 然后将左、右两侧递归,继续拆分、继续排序。

步骤:

  1. 找到终止递归条件,也就是终止拆分条件。start >= end 只剩下一个时就没有必要拆分了。
  2. 用最后一位作为中间位。
  3. 遍历判断,简单写法可以定义临时空间存储排序结果,最后覆盖原有数组。
  4. 拆分前半部分,调用递归
  5. 拆分后半部分,调用递归

快排不占用额外的空间,也不属于稳定排序。在极端情况下也是O(n²)的复杂度。那么如何优化快排?

三位取中/随机

private static void quick(int[] arr, int start, int end) {
    // 终止条件
    if (start >= end) {
        return;
    }
    // 找到对比的值
    int mid = arr[end];
    // 启动临时缓存(启用了额外的空间,此时便不是源地排序)
    int[] temp = new int[end - start + 1];
    // 记录 头尾新增情况
    int i = 0;
    int j = temp.length - 1;
    // 挨个遍历数组,小于比对值 放左边, 大于比对值方右边
    for (int k = start; k < end; k++) {
        if (arr[k] <= mid) {
            temp[i++] = arr[k];
        } else {
            temp[j--] = arr[k];
        }
    }
    // 比对值 放中间
    temp[i] = mid;
    // 将排序好的 数组赋值给 原数组
    i = 0;
    for (int k = start; k < end; k++) {
        arr[k] = temp[i++];
    }
    // 拆分继续排序
    quick(arr, start, i - 1);
    // 拆分继续排序
    quick(arr, i+1, end);
}

以上情况并不属于源地排序,对于空间复杂度来说相对较高,但是写起来很方便。那么如果要求源地排序应该如何做呢?

思路:

使用快慢指针。

开始遍历后:判断快指针位置值如果当前值大于比对值,慢指针不动(表示这个位置数据不符合要求,需要移动),快指针继续走,结束本次循环。下次循环,继续判断快指针位置值是否大于比对值,如果不大于,并且此时快慢指针不相等,表示需要交换位置。

最终,由于我们取最后一位作为比对值,而比对值是需要放在中间位置的,也就是遍历完以后慢指针的位置。因此,最后需要交换慢指针位置与结束位置数据。

private static void quick2(int[] arr, int start, int end) {
    if (start >= end) {
        return;
    }
    int mid = arr[end];
    int slow = start;
    int fast = start;
    for (; fast < end; fast++) {
        // 只有发现比 中间数小 才需要交换
        if (arr[fast] < mid) {
            if (fast != slow) {
                int temp = arr[fast];
                arr[fast] = arr[slow];
                arr[slow] = temp;
            }
            slow++;
        }
    }
    arr[end] = arr[slow];
    arr[slow] = mid;
    quick2(arr, start, slow - 1);
    quick2(arr, slow + 1, end);
​
}

备注

不要陷入误区,对于快速排序而言,我要保证的是相对有序,开始排序范围较大时,左边小不意味着左边都要有序,快排的有序是不断的缩小范围,保证有序,进而达到最终的有序。

因此为了保证源地排序,可以使用快慢指针(双指针)方式来实现。

最后,快慢指针的思想主要可用于数据交换,在不要求顺序的场景里可以使用。比如过滤掉重复数据(1112333)-》(1,2,3)。

与归并排序相比,不同点有:

  1. 归并是先拆分,后排序合并。快排是边拆分边排序。

    1. 归并先将任务拆分到最小,然后挨个合并,直到最终。
    2. 快排虽然也是先拆分,但是拆分后就立刻排序,然后再拆分,再排序。

案例:

快速求无需数组第K大的值。如何做?

桶排序(On)

思路:

将数据根据范围进行快速的拆分,完成后再拆分的结果中使用快速排序。

为什么不直接用快排呢?

这个思路的场景适合于大量数据的排序,并且排序数据有一定的规则。

比如:

  1. 快速对100W人基于年龄进行排序。我们可以直接分为10个桶(0~10),不同年龄放在不同的桶里,分完后,对桶里数据进行快排,再合并。甚至可以直接分100个桶
  2. 如果在内容有限(百兆)情况下,要求你对10G订单数按照价格据进行排序,此时也可以考虑使用桶排序。先查询数据范围,发现金额是在 110W,然后,按照1000为单位拆分,如果某个区间数据过多,继续按照0100拆分,保证内存用量最小。

计数排序(On)

思路:

计数排序其实就是桶排序的极端情况。数据范围可控,且较小。

比如,人的年龄,0120。高考分数:0900。

如何查看自己高考分在省内、全国的排名呢?

此时就是直接分为901个桶,每个桶统计数量,比如考0分有0人,考1分的有10人,那么考1分的肯定全国倒数第1,考两分的就是全国倒数第11。

查找

二分查找(Ologn)

二分查找对数据要求较高。主要有三点

  1. 要求数据是数组结构。(随机访问效率高)
  2. 要求数据有序。
  3. 对数据量有要求。太小、太大都不合适。太小直接遍历就OK。太大,数组要求占用连续内存空间,消耗太大。

二分的思想就不多介绍,直接上代码即可。

private static int binarySearch(int[] arr, int num) {
    int left = 0;
    int right = arr.length;
    int mid = 0;
    while (left <= right) {
        mid = (right - left) / 2 + left;
        if (arr[mid] == num) {
            return mid;
        }
        if (arr[mid] > num) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    System.out.println("mid = " + mid);
    System.out.println("left = " + left);
    System.out.println("right = " + right);
    return -1;
}

备注:

此处需要注意的点有两个。

  1. mid的取值。如果要使用位运算,切记要写为 ((right-left) >> 1)) + left。

包含重复数据的二分查找

查询最后一个数据。

    /**
     * 查询首个 或者最后一个 有几点需要注意
     * 1. 在匹配到数据后,不能直接结束,需要继续查找。
     * 2. 如果查询的是首个。
     *    1. 由于循环结束条件是 left > right, 所以此时 需要 操作 right - 1 达成此条件,并返回 left
     *    原因解析:由于 left 表示左侧值, 首个值肯定是最左侧的值,
     *    因此,一定是左侧值先到位(考虑极端情况 下标 0 即为目标数据时)
     *    因此,我们需要在等于 目标值时 扔去操作 right 值,直到 right 小于 left, 最终返回 left
     * 3. 如果查询的是末尾
     *    1. 同理可得。要找末尾时, 必须要在等于目标值时,扔操作 left, 最终 返回 right
     *
     */
    private static int searchReapetLast(int[] arr, int value) {
        int left = 0;
        int right = arr.length - 1;
        while (left <= right) {
            int mid = (right - left) / 2 + left;
            if (arr[mid] > value) {
                right = mid - 1;
            } else {
                left = mid + 1;
            }
            if (left == right) {
                System.out.println("true = " + mid + " left = " + left);
            }
        }
        if (arr[right] == value) {
            return right;
        }
        return -1;
    }

以上实现可能不太好理解。如果直接看下边这种就容易许多。

private static int searchReapetLast(int[] arr, int value) {
    int left = 0;
    int right = arr.length - 1;
    while (left <= right) {
        int mid = (right - left) / 2 + left;
        if (arr[mid] > value) {
            right = mid - 1;
        } else if (arr[mid] < value) {
            left = mid + 1;
        } else {
            if ((right == arr.length - 1) || arr[right] + 1 != value) {
                return mid
            } else {
                left = mid + 1;
            }
        }
    }
    return -1;
}

跳表

二分查找是适用与数组的快速查询。跳表是适用于链表的快速查询。

跳表简单来说就是通过建立上级索引的方式来达到快速查询的目的。

1 5 9

1 3 5 7 9

1 2 3 4 5 6 7 8 9