访问量
访客数

算法详解

2024.11.20 阅读量

算法英文对应的单词是Algorithm,它的本意为解决问题的方法,所以算法直接理解就是解决问题的方法。在计算机领域定义就是一系列解决问题的、清晰、可执行的计算机指令。

算法是在计算机科学中用于解决特定问题的一系列明确且有限的步骤或规则。它以输入数据为基础,通过确定性操作生成输出结果,强调高效性和可行性。常见算法包括排序、查找、动态规划、分治法和贪心算法等,主要评估指标为时间复杂度和空间复杂度。算法的设计与优化直接影响程序性能,是计算机编程的核心内容之一。

算法复杂度

算法复杂度是衡量算法性能的重要指标,主要包括时间复杂度和空间复杂度。时间复杂度描述算法运行时间随输入规模变化的关系,常用大O符号表示,如 O(1)、O(n)、O(log n) 等;空间复杂度衡量算法运行时占用的存储空间。通过分析复杂度,可以评估算法在不同输入规模下的效率和资源消耗,为选择合适的算法提供依据。

时间复杂度

时间复杂度用于描述算法执行所需时间与输入规模之间的关系,用以衡量算法的效率。通常使用大O符号表示,例如 O(1) 表示常数时间,O(n) 表示线性时间,O(n²) 表示平方时间等。通过时间复杂度的分析,可以直观了解算法在处理不同规模数据时的运行效率,为性能优化提供参考。

时间复杂度反映了算法执行时间随输入规模变化的增长速度,是评估算法效率的重要指标。它不关注具体的执行时间,而是关注增长趋势。例如,O(1)表示无论输入规模多大,运行时间都是固定的;O(n)表示输入规模翻倍,运行时间也翻倍;而O(n²)则意味着输入规模翻倍,运行时间会增长为原来的四倍。 通过比较,可以发现较高的时间复杂度(如O(2ⁿ))增长速度会非常快,适用于小规模输入的算法可能在大规模输入下变得不可用。

常见时间复杂度按从小到大的顺序排列的表格:

时间复杂度 描述 示例算法
O(1) 常数时间,与输入规模无关 哈希查找(理想情况)
O(log n) 对数时间,每次操作缩小问题规模 二分查找
O(n) 线性时间,与输入规模成正比 遍历数组、线性查找
O(n log n) 线性对数时间,比线性稍慢 快速排序、归并排序(平均情况)
O(n²) 平方时间,常见于双层嵌套循环 冒泡排序、选择排序
O(2ⁿ) 指数时间,增长极快 递归解决斐波那契数列(未优化)
O(n!) 阶乘时间,所有可能的排列组合 旅行商问题(暴力解法)

复杂度越小,算法越高效;复杂度越大,运行时间随输入规模增加而急剧增长,通常只适用于极小规模的问题。

空间复杂度

空间复杂度是衡量算法在执行过程中所需内存空间与输入规模之间关系的指标。与时间复杂度类似,空间复杂度也是一个问题规模 n 的函数。它包括算法本身的固定开销(如常量、代码和指令等)以及在运行时所需的临时数据存储空间。

空间复杂度的常见表示形式有 O(1)、O(n)、O(n²) 等,分别表示常数空间、线性空间和平方空间。O(1) 表示算法需要的内存空间与输入规模无关,始终保持固定;O(n) 表示内存需求与输入规模成正比;O(n²) 则表示内存需求随着输入规模的平方增加。

空间复杂度的全称是渐进空间复杂度,指的是算法在执行过程中临时占用的存储空间的增长趋势。有些算法在解决问题时需要占用的临时存储空间与输入规模 n 有关,随着 n 增大,所需的存储空间也随之增加。例如,快速排序和归并排序的空间复杂度通常为 O(n),因为它们在递归过程中需要占用额外的空间。

在做算法分析时,主要讨论的是时间复杂度。因为执行速度直接影响用户体验,所以空间复杂度同样重要,特别是在内存受限的环境中。 一些算法(如基数排序)和缓存技术(如 Redis 和 Memcached)本质上通过增加存储空间来换取更快的计算速度,本质就是用空间换时间。

递归算法

递归算法是指通过函数调用自身来解决问题的一种算法设计方式。递归算法通常将一个大问题分解为多个相同类型的小问题,通过逐层调用自己来处理这些子问题,并最终合并结果。递归通常包括两个部分:递归终止条件和递归调用。

递归算法在解决某些问题时表现得非常简洁和直观,特别是那些可以自然地分解为相同子问题的问题。通过递归,复杂问题可以通过相同的逻辑步骤被分解成更小、更容易解决的子问题,这种分治思想使得代码更加简洁和易于理解。典型的例子包括树的遍历、图的深度优先搜索(DFS)、动态规划中的重叠子问题等。

递归算法的一个主要问题是每次递归调用都需要占用栈空间,深度较大的递归可能导致栈溢出。递归算法还可能带来较大的时间和空间开销,特别是没有优化时,可能会出现重复计算,导致效率低下。在某些简单的任务中,递归算法可能不如迭代算法高效。

递归算法适用于那些可以将大问题分解为多个相同或类似的小问题的问题。常见的使用场景包括树的遍历、图的深度优先搜索、动态规划和分治策略中的排序算法等。树结构的遍历,如二叉树的前序、中序和后序遍历,通常通过递归实现,每次递归处理当前节点及其子树。图的深度优先搜索(DFS)也常用递归来遍历图的节点。动态规划中的一些问题,尤其是具有重叠子问题的优化问题,通过递归结合记忆化技术能够减少计算量。分治算法,如快速排序和归并排序,也利用递归将问题分解为更小的子问题,最终合并结果得到解。

使用递归注意事项:

  • 执行一个方法时,就创建一个新的受保护的独立空间(一个线程有自己独立的一个栈空间,每个方法调用对应着一个栈帧);
  • 方法的局部变量是独立的,不会相互影响;
  • 如果方法中使用的是引用类型变量,就会共享该引用类型的数据,比如数组;
  • 递归必须向退出递归的条件逼近,否则就是无限递归,出现 StackOverflowError;
  • 当一个方法执行完毕,或者遇到return,就会返回,遵守谁调用,就将结果返回给谁,同时当方法执行完毕或者返回时,该方法也就执行完毕;

迷宫回溯问题

迷宫回溯问题是一个典型的递归回溯问题,其目标是通过递归的方法找到从迷宫的起点到终点的一条路径。回溯算法通过探索每个可能的路径,并在遇到死胡同时回退,尝试其他路径,直到找到解或确认无解。

public class MyTest {

    public static void main(String[] args) {
        Mazeback mazeback = new Mazeback();

        int[][] map = mazeback.createMap();
        mazeback.list(map);

        System.out.println("自动寻找路线:");

        mazeback.setWay(map, 1, 1);
        mazeback.list(map);
    }

}


class Mazeback {

    // 创建地图
    public int[][] createMap() {
        // 地图
        int[][] map = new int[7][8];

        for (int i = 0; i < 7; i++) {
            map[i][0] = 1;
            map[i][7] = 1;
            map[6][i] = 1;
            map[0][i] = 1;
        }
        map[3][1] = 1;
        map[3][2] = 1;
        return map;
    }

    // 遍历地图
    public void list(int[][] map) {
        for (int i = 0; i < map.length; i++) {
            for (int j = 0; j < map[i].length; j++) {
                System.out.print(map[i][j] + "");
            }
            System.out.println();
        }
    }

    // 寻找路径
    // 1:墙;2:通路,3:死路
    public boolean setWay(int[][] map, int row, int column) {
        if (map[5][6] == 2) {
            return true;
        }
        if (map[row][column] == 0) {

            // 先假设是通路
            map[row][column] = 2;

            // 寻找路径顺序:下,右,上,左
            if (setWay(map, row + 1, column)) {
                return true;
            } else if (setWay(map, row, column + 1)) {
                return true;
            } else if (setWay(map, row - 1, column)) {
                return true;
            } else if (setWay(map, row, column - 1)) {
                return true;
            } else {
                // 当标记为3时,说明是死路走不通
                map[row][column] = 3;
                return false;
            }
        }
        return false;
    }

}

八皇后问题

八皇后问题,是一个古老而著名的问题,是回溯算法的典型案例。该问题是国际西洋棋棋手马克斯·贝瑟尔于1848 年提出:在 8 × 8 格的国际象棋上摆放八个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法。

八皇后问题共92中解法

八皇后问题的解法通常通过回溯算法来实现。通过递归地尝试每一行放置一个皇后,然后检查当前放置是否合法。如果放置合法,则继续在下一行放置皇后。如果发现某个位置放置皇后后不合法,就回退到上一行,重新尝试其他位置。

数据结构与算法-010

public class MyTest {

    public static void main(String[] args) {
        EightQueens eightQueens = new EightQueens();
        eightQueens.exec(0);
    }

}


class EightQueens {

    private int[] arr;

    private int max;

    public EightQueens() {
        this.max = 8;
        this.arr = new int[max];
    }


    // 算法
    public void exec(int position) {
        // 如果当前位置等于max说明解法成立,需要回溯
        if (position == max) {
            print();
            return;
        }
        for (int i = 0; i < max; i++) {
            arr[position] = i;
            if (check(position)){
                exec(position + 1);
            }
        }
    }


    // 判断皇后位置是否冲突
    private boolean check(int position) {
        for (int j = 0; j < position; j++) {
            // 判断是否在同一列或在同一斜线上
            if (arr[position] == arr[j] || Math.abs(position - j) == Math.abs(arr[position] - arr[j])) {
               return false;
            }
        }
        return true;
    }


    // 打印数组
    private void print() {
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i] + "");
        }
        System.out.println();
    }

}

骑士周游问题

骑士周游问题也称马踏棋盘算法是一个经典的回溯问题。将骑士随机放在国际象棋的8×8棋盘0~7的某个方格中,骑士按走棋规则进行移动。要求每个方格只进入一次,走遍棋盘上全部64个方格。

该算法通过回溯的方式在棋盘上递归地寻找可能的路径。每一步,骑士根据棋盘大小和当前的位置选择一个合法的目标格子。每次跳跃后,记录骑士已经访问过的格子,直到骑士成功遍历所有格子或发现无法继续前进。若骑士在某一时刻无法继续走,则回溯到上一步,尝试其他未访问过的格子。

骑士周游问题实际上是图的深度优先搜索的应用,适用于需要解决路径遍历和组合优化的问题。尽管在实际应用中较少见,但它对学习回溯算法和图搜索算法非常有帮助。

public class HouseChessBoardDemo {
    public static void main(String[] args) {
        HouseChessBoard houseChessBoard = new HouseChessBoard(7, 7, 2, 4);
        houseChessBoard.showChessBoard();
    }
}

class HouseChessBoard {

    /**
     * 表示棋盘的列
     */
    private int x;
    /**
     * 表示棋盘的行
     */
    private int y;
    /**
     * 创建一个数组,标记棋盘的各个位置是否被访问过,true表示已经访问过
     */
    private boolean visited[];
    /**
     * 使用一个属性,标记是否棋盘的所有位置都被访问 如果为true,表示成功
     */
    private boolean finished;

    private int[][] chessboard;

    public HouseChessBoard(int x, int y, int row, int column) {
        this.x = x;
        this.y = y;
        this.visited = new boolean[x * y];
        this.chessboard = new int[x][y];
        traversalChess(this.chessboard, row-1, column-1, 1);
    }

    public void showChessBoard(){
        for (int[] ints : this.chessboard) {
            for (int anInt : ints) {
                System.out.print(anInt + "\t");
            }
            System.out.println();
        }
    }

    public void traversalChess(int[][] chessboard, int row, int column, int step) {
        chessboard[row][column] = step;

        // 将当前位置标记为已经访问过
        visited[row * x + column] = true;

        // 获取当前位置的下一个可走通的位置的集合
        ArrayList<Point> nextPos = getNext(new Point(column, row));

        sort(nextPos);

        while (nextPos.size() > 0) {
            // 获取当前可走通的位置
            Point current = nextPos.remove(0);
            // 判断当前该点是否被访问过,如果没有被访问过则继续向下访问
            if (!visited[current.y * x + current.x]) {
                traversalChess(chessboard, current.y, current.x, step + 1);
            }
        }

        // 当遍历完可走的位置集合后,如果发现该路不通,则进行回溯,否则标记为完成
        if (step < x * y && !finished) {
            chessboard[row][column] = 0;
            visited[row * x + column] = false;
        } else {
            finished = true;
        }
    }

    // 将可走通路根据回溯次数进行从小到大排序
    public void sort(ArrayList<Point> points){
        points.sort((o1, o2) -> {
            int next1 = getNext(o1).size();
            int next2 = getNext(o2).size();
            return next1 - next2;
        });
    }



    // 获取下一个可走的位置
    public ArrayList<Point> getNext(Point current) {
        ArrayList<Point> ps = new ArrayList<Point>();
        Point p1 = new Point();
        // 表示马儿可以走5这个位置
        if ((p1.x = current.x - 2) >= 0 && (p1.y = current.y - 1) >= 0) {
            ps.add(new Point(p1));
        }
        // 判断马儿可以走6这个位置
        if ((p1.x = current.x - 1) >= 0 && (p1.y = current.y - 2) >= 0) {
            ps.add(new Point(p1));
        }
        // 判断马儿可以走7这个位置
        if ((p1.x = current.x + 1) < x && (p1.y = current.y - 2) >= 0) {
            ps.add(new Point(p1));
        }
        // 判断马儿可以走0这个位置
        if ((p1.x = current.x + 2) < x && (p1.y = current.y - 1) >= 0) {
            ps.add(new Point(p1));
        }
        // 判断马儿可以走1这个位置
        if ((p1.x = current.x + 2) < x && (p1.y = current.y + 1) < y) {
            ps.add(new Point(p1));
        }
        // 判断马儿可以走2这个位置
        if ((p1.x = current.x + 1) < x && (p1.y = current.y + 2) < y) {
            ps.add(new Point(p1));
        }
        // 判断马儿可以走3这个位置
        if ((p1.x = current.x - 1) >= 0 && (p1.y = current.y + 2) < y) {
            ps.add(new Point(p1));
        }
        // 判断马儿可以走4这个位置
        if ((p1.x = current.x - 2) >= 0 && (p1.y = current.y + 1) < y) {
            ps.add(new Point(p1));
        }
        return ps;
    }

}

排序算法

排序算法是一类用于将数据集合按照某种顺序排列的算法。排序是计算机科学中非常基础且常见的任务,广泛应用于各类应用场景,如数据库、搜索引擎等。根据算法的不同实现方式,排序算法可以分为多种类型,常见的排序算法包括交换排序、选择排序、插入排序、归并排序、快速排序等。

排序算法分类:

  • 内部排序:指将需要处理的所有数据都加载到内部存储器即内存中进行排序;
  • 外部排序法:数据量过大,无法全部加载到内存中,需要借助外部文件存储进行排序;

数据结构与算法-011

常见内排序算法比较

名称 最坏时间复杂度 平均时间复杂度 最好时间复杂度 空间复杂度 稳定性 额外空间 适用场景
冒泡排序 O(n²) O(n²) O(n) O(1) 稳定 不需要额外空间 小规模数据,教学用
选择排序 O(n²) O(n²) O(n²) O(1) 不稳定 不需要额外空间 小规模数据
插入排序 O(n²) O(n²) O(n) O(1) 稳定 不需要额外空间 部分有序的数据
归并排序 O(n log n) O(n log n) O(n log n) O(n) 稳定 需要额外空间 大规模数据
快速排序 O(n²) O(n log n) O(n log n) O(log n) 不稳定 不需要额外空间 一般情况
希尔排序 O(n²) O(n log n) O(n) O(1) 不稳定 不需要额外空间 小规模至中等规模数据
堆排序 O(n log n) O(n log n) O(n log n) O(1) 不稳定 不需要额外空间 大规模数据
基数排序 O(nk) O(nk) O(nk) O(n + k) 稳定 需要额外空间 整数,位数较少

冒泡排序

冒泡排序的基本思想是通过对待排序序列从前向后(从下标较小的元素开始),依次比较相邻元素的值,若发现逆序(当前值大于比较的值)则交换,使值较大的元素逐渐从前移向后部,就像水底下的气泡一样逐渐向上冒。

因为排序的过程中,各元素不断接近自己的位置,如果一趟比较下来没有进行过交换,就说明序列有序,因此要在排序过程中设置一个标志 flag 判断元素是否进行过交换,从而减少不必要的比较。

数据结构与算法-014

冒泡排序的实现简单,容易理解。它是稳定的排序算法,即相等元素的相对顺序不会改变。对于小规模数据,冒泡排序可以作为一种快速实现。

冒泡排序的时间复杂度为 O(n²),在处理大规模数据时效率较低。即使在数组已经部分排序的情况下,冒泡排序仍然需要多次遍历。对于大数据量的排序,冒泡排序明显不如其他高级排序算法如快速排序、归并排序等高效。

冒泡排序适合于数据量较小或者数组已经部分排序的情况。它常用于教学和理解基本的排序概念,或在对排序性能要求不高的小数据集上使用。由于冒泡排序的低效性,它不适合在大规模数据集或要求高效排序的实际应用中使用。

class BubbleSorting {

    public int[] sort(int[] data) {
        int len = data.length - 1;
        int tmp;
        boolean flag = false;

        for (int i = 0; i <len; i++){
            for (int j = 0; j < len - i; j++) {
                // 将当前值与next值进行比较,如果当前值大于next值则交换两者之间的位置
                if (data[j] > data[j+1]){
                    flag = true;
                    tmp = data[j];
                    data[j] = data[j+1];
                    data[j+1] = tmp;
                }
            }

            // 加入标志为进行判断,如果整个循环下啦都没有交换位置,说明该数组是有序的,所以直接退出循环
            if (!flag){
                break;
            }else {
                flag = false;
            }
        }
        return data;
    }
}

选择排序

选择排序是一种简单的排序算法,其基本思想是每次从未排序的部分中选出最小(或最大)的元素,放到已排序部分的末尾。这个过程会不断重复,直到所有元素都被排序。

选择排序从数组的第一个元素开始,遍历整个数组,找出最小的元素,然后将它与当前索引位置的元素交换。接着,选择排序继续对剩下的未排序部分进行同样的操作,直到所有元素都排好顺序。每次遍历后,未排序部分的最小元素被放置在正确的位置。

数据结构与算法-015

选择排序的时间复杂度为 O(n²),对于小规模数据排序时,选择排序实现简单,且无需额外的空间。它是原地排序算法,意味着它不会占用额外的内存空间(除去输入数组外)。

尽管选择排序的空间复杂度较低,但时间复杂度为 O(n²),当数据量较大时,效率较低。即使数组已经部分有序,选择排序仍然需要进行相同次数的比较和交换,导致它的性能较差。选择排序不稳定,即相等元素的顺序可能会发生改变。

选择排序适用于数据量较小的场景,特别是在对内存空间要求较高的情况下(例如,只有少量的额外内存可用)。它通常用于教学和理解排序算法的基本原理。在实际应用中,由于效率较低,选择排序很少用于大规模数据的排序。

class SelectSorting{

    public int[] sort(int[] data) {
        for (int i = 0; i < data.length; i++){

            int min = i;

            for (int j = i+1; j < data.length; j++) {
                // 如果next值大于当前值,则记录该值和该值的位置,等全部比较完毕后,将最大的一个与数据的末尾进行替换
                if (data[i] > data[j]){
                    min = j;
                }
            }

            // 将每次循环中的最小的值,调整到最前面
            if (min != i){
                int tmp = data[i];
                data[i] = data[min];
                data[min] = tmp;
            }
        }
        return data;
    }
}

插入排序

插入排序是一种简单的排序算法,其工作原理类似于整理扑克牌。它将待排序的元素逐个插入到已排序的部分中,直到所有元素都被排序。

插入排序从数组的第二个元素开始,假设第一个元素已经排序好。然后将第二个元素与第一个元素比较,如果第二个元素小于第一个元素,就交换它们的位置。接着继续处理下一个元素,将其插入到已排序部分中的适当位置。这个过程对数组中的每个元素进行,直到整个数组排序完成。

数据结构与算法-016

插入排序的实现简单,容易理解。当数组已经部分有序时,插入排序的效率较高。其最好的时间复杂度为 O(n),即当输入数据已经是有序的时,插入排序的性能非常好。插入排序是稳定的,即相等元素的相对顺序不会改变。它是原地排序算法,使用的额外内存空间较少。

插入排序的时间复杂度为 O(n²),当数据量较大时,效率较低。特别是对于逆序数组,插入排序需要较多的交换操作,导致性能较差。因此,在处理大规模数据时,插入排序通常不如其他高级排序算法(如快速排序或归并排序)高效。

插入排序适用于数据量较小或已经部分排序的场景。对于小规模的数据集或要求空间复杂度较低的情况,插入排序能够提供较好的性能。它在实时排序或需要逐步插入新数据的应用中,如在线数据排序或实时系统中,也能发挥优势。在大规模数据集的排序任务中,插入排序通常不是最优选择。

class InsertSorting{

    public int[] sort(int[] data) {
        for (int i = 1; i < data.length; i++){
            // 如果当前要插入的值 data[i] > 有序队列中最后一个,则将其直接插入到最后一个
            if (data[i] > data[i - 1]){
                continue;
            }

            int tmp = data[i];
            int index = i - 1;
            // 如果当前位置的值【tmp】小于 上一个位置的值【data[index]】说明当前值需要插入到有序队列中
            while (index >= 0 && tmp < data[index]){
                data[index + 1] = data[index];
                index--;
            }
            data[index + 1] = tmp;
        }
        return data;
    }

}

归并排序

归并排序是利用归并的思想实现的排序方法,该算法采用经典的分治策略;分治法将问题分成一些小的问题然后递归求解,而治的阶段则将分的阶段得到的各答案"修补"在一起,即分而治之。

归并排序的核心思想是将待排序数组递归地分为两半,直到每个部分只包含一个元素为止。然后,通过合并操作将这两部分数据排序并合并。合并操作是归并排序的关键步骤,它将两个已排序的子数组合并成一个更大的有序数组。递归地进行这种分解和合并,直到所有子数组都被合并成一个完整的有序数组。

数据结构与算法-019

归并排序的时间复杂度始终是 O(n log n),无论最坏情况、平均情况还是最好情况。它稳定且适合于大规模数据的排序,尤其是在外部排序中应用广泛。由于归并排序是基于分治法的,它具有很好的并行性,可以在多核处理器上高效运行。

归并排序需要额外的 O(n) 空间来存储合并过程中的临时数据,这使得它比一些原地排序算法(如快速排序)需要更多的内存。对于小规模数据,归并排序的常数因素较大,可能不如其他排序算法如插入排序或快速排序更高效。

归并排序适合处理大规模数据的排序,特别是在需要稳定排序的场景。它也广泛应用于外部排序,如在磁盘存储的大量数据中进行排序。对于链表或需要并行计算的场景,归并排序也表现良好。由于其稳定性,归并排序常用于需要保持相同元素相对位置的排序任务。

class MergeSorting {

    // 递归拆分算法
    public void divide(int[] arr, int start, int end, int[] tmpArr) {
        if (start >= end) {
            return;
        }
        int mid = (start + end) >> 1;

        // 分别向左向右递归
        divide(arr, start, mid, tmpArr);
        divide(arr, mid + 1, end, tmpArr);

        // 拆分一次合并一次
        merge(arr, start, mid, end, tmpArr);

    }

    // 合并算法
    public void merge(int[] arr, int start, int mid, int end, int[] tmpArr) {
        int leftIndex = start;
        int rightIndex = mid + 1;
        int tmpArrIndex = 0;

        // 判断是否超出范围
        while (leftIndex <= mid && rightIndex <= end) {

            // 将两组数据进行比较,按照从小到大的顺序将两组数据填入 tmpArr 中
            if (arr[leftIndex] <= arr[rightIndex]) {
                tmpArr[tmpArrIndex] = arr[leftIndex];
                ++leftIndex;
            } else {
                tmpArr[tmpArrIndex] = arr[rightIndex];
                ++rightIndex;
            }
            ++tmpArrIndex;
        }

        // 判断两组数据是否还有剩余,如果有剩余数据,则直接将数据追加到 tmpArr 数组后边
        while (leftIndex <= mid) {
            tmpArr[tmpArrIndex] = arr[leftIndex];
            ++leftIndex;
            ++tmpArrIndex;
        }

        while (rightIndex <= end) {
            tmpArr[tmpArrIndex] = arr[rightIndex];
            ++rightIndex;
            ++tmpArrIndex;
        }

        // 将两组数据进行合并
        tmpArrIndex = 0;
        int tmpLeftIndex = leftIndex;
        while (tmpLeftIndex <= end) {
            arr[tmpLeftIndex] = tmpArr[tmpArrIndex];
            ++tmpLeftIndex;
            ++tmpArrIndex;
        }
    }
    
}

快速排序

快速排序是由东尼·霍尔所发展的一种排序算法。快速排序是一种基于分治法的排序算法。它通过选择一个“基准”元素,将数组划分为左右两部分,然后递归地对这两部分进行排序。通过这种方式,快速排序能够有效地对大规模数据进行排序。

快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。 核心思想是选择一个基准元素,通常是数组中的一个元素。然后将数组中的所有元素根据与基准元素的大小关系分成两部分:左边的元素小于等于基准,右边的元素大于等于基准。然后,递归地对左右两部分分别进行快速排序,直到整个数组有序。

数据结构与算法-018

快速排序的平均时间复杂度为 O(n log n),在大多数情况下,比其他排序算法(如冒泡排序、插入排序)效率更高。它是一个原地排序算法,不需要额外的存储空间,对内存的需求较小。在数据规模较大时,表现尤为优越。

快速排序的最坏时间复杂度是 O(n²),如果基准元素选得不好,算法的性能会显著下降。例如,每次选择数组的最大或最小元素作为基准时,排序效率会很低。它也不是稳定的排序算法,可能会改变相同元素的相对位置。

快速排序适合大规模数据排序,尤其当数据可以有效分区时。它广泛应用于实际系统,如数据库中的数据排序。尽管存在最坏情况性能退化的问题,经过优化的快速排序可以避免这些问题,因此在许多场景中仍然是一种常用的排序算法。

class QuickSorting {

    public void sort(int[] data, int l, int r) {

        // 如果开始的位置大于等于结束的位置则不用进行比较直接退出
        if (l >= r){
            return;
        }

        int left = l;
        int right = r;

        // 基准数值,将小于该数值的放在该数字的左边,大于该数值的放在右边
        int pivot = data[left];

        while (left < right) {

            // 从右向左开始比较,如果此数大于等于基准数则将right索引向前移动,否则,将该值覆盖到对应的 data[left] 中
            while (left < right && data[right] >= pivot) {
                --right;
            }
            data[left] = data[right];

            // 从左向右开始比较,如果此数小于等于基准数则将left索引向后移动,否则,将该值覆盖到对应的 data[right] 中
            while (left < right && data[left] <= pivot) {
                ++left;
            }
            data[right] = data[left];
        }

        // 此时left与right指向重合的位置为基准所在的位置,需要将该位置覆盖掉为基准的值
        data[left] = pivot;

        // 递归排序
        sort(data, l, left);
        sort(data, right+1,r);
    }
} 

希尔排序

希尔排序是希尔于1959年提出的一种排序算法。它是插入排序的一种改进版本,主要通过分组插入排序来提升排序效率。其核心思想是将待排序的元素按一定间隔分成若干组,对每一组分别进行插入排序。随着排序过程的进行,间隔逐渐缩小,最终当间隔为 1 时,整个数组变得有序。

希尔排序首先将数组按照一定的步长(称为增量)分成多个子数组,然后对每个子数组分别进行插入排序。随着算法的进行,步长逐渐减小,直到步长为 1,此时对整个数组进行一次插入排序。希尔排序通过这种“跳跃式”的排序方式,能够减少元素的移动次数,从而提高排序效率。

在希尔排序中,步长的选择对算法的性能影响较大。常见的步长序列包括固定步长、逐步减半的步长、以及更复杂的增量序列(如 Hibbard 增量、Sedgewick 增量等)。

数据结构与算法-017

希尔排序比普通的插入排序要高效,尤其是在处理大规模数据时。它的时间复杂度随着步长序列的选择而变化,理论上可以达到 O(n log n)。通过减少元素的移动次数,希尔排序在一些情况下比插入排序具有显著的性能优势。

希尔排序的时间复杂度受步长序列的影响,最坏情况下的时间复杂度仍然可能是 O(n²),特别是当使用不理想的步长序列时。希尔排序不是稳定的排序算法,相同元素的相对顺序可能会发生变化。

希尔排序适用于数据量中等的排序任务,尤其是在对排序效率有较高要求的情况下。由于它的空间复杂度较低(是原地排序算法),并且比插入排序和选择排序更加高效,因此在特定场景中可以替代这些简单的排序算法。希尔排序常常用于需要优化插入排序的应用场景,特别是当数据集较大时,但由于它的最坏情况性能不如快速排序或归并排序,因此不适用于需要高效排序的大数据集。

class ShellSorting{

    // 希尔排序,交换法
    public void sortSwap(int[] data){
        int size = data.length;
        int tmp = 0;

        // 将数据分组,分组数量:data.length/2
        for (int gap = size >> 1; gap > 0; gap >>= 1){

            for (int i = gap; i < size; i++) {
                // 遍历每组中的元素
                for (int j = i - gap; j >= 0; j -= gap) {
                    // 将每组中的元素进行排序(交换元素)
                    if (data[j] > data[j + gap]){
                        tmp = data[j];
                        data[j] = data[j+gap];
                        data[j+gap] = tmp;
                    }
                }
            }
        }
    }

    // 插入法,融入 插入排序 思想
    public void sort(int[] data){
        int size = data.length;
        int tmp = 0;

        // 将数据分组,分组数量:data.length/2
        for (int gap = size >> 1; gap > 0; gap >>= 1) {
            for (int i = gap; i < size; i++) {

                tmp = data[i];
                int index = i;

                // 如果当前位置的值【tmp】小于 上一个位置的值【data[index]】说明当前值需要插入到有序队列中
                while (index - gap >= 0 && tmp < data[index - gap]){
                    data[index] = data[index - gap];
                    index -= gap;
                }
                data[index] = tmp;
            }
        }
    }
    
}

堆排序

堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序。堆是一种完全二叉树,它满足堆性质:对于大顶堆,父节点的值大于或等于其子节点的值;对于小顶堆,父节点的值小于或等于其子节点的值。

大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列; 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;

数据结构与算法-027

堆排序的基本思想是将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。

数据结构与算法-026

堆排序的时间复杂度为 O(n log n),无论在最坏情况下、最好情况下还是平均情况下,性能都比较稳定。它是原地排序算法,不需要额外的存储空间。由于堆排序对输入数据的顺序没有特殊要求,它适合于处理大规模数据集,并且不受输入数据是否有序的影响。

堆排序不是稳定的排序算法,相同的元素在排序后可能会改变相对顺序。虽然它的时间复杂度是 O(n log n),但是常数项较大,性能通常不如快速排序。由于堆排序需要进行多次调整堆的操作,处理效率在实际应用中可能较低。

堆排序适用于需要高效排序且对稳定性要求不高的场景。由于其 O(n log n) 的时间复杂度和原地排序的特性,它常用于大规模数据的排序,尤其是在内存限制较大的情况下。堆排序也常用于优先队列的实现和一些算法中,如求解Top K问题。然而,在一般的排序任务中,快速排序和归并排序通常表现更好。

class HeapSorting {

    public void sort(int[] arr) {
        // 计算树中的叶子结点位置
        int leafNode = (arr.length >> 1) - 1;

        // 构建大顶堆,此处需要注意 i >= 0 需要算根结点
        for (int i = leafNode; i >= 0; i--) {
            buildMaxHeap(arr, i, arr.length);
        }

        // 构建大顶堆后,将根元素与树中的最后一个进行交换,再循环构建大顶堆
        int tmp;
        for (int i = arr.length - 1; i > 0; i--) {
            tmp = arr[i];
            arr[i] = arr[0];
            arr[0] = tmp;
            buildMaxHeap(arr, 0, i);
        }
    }

    /**
     * 堆排序核心代码 构建大顶堆
     *
     * @param arr 需要调整的数组
     * @param i   非叶子结点的索引位置
     * @param len 每次调整的长度
     */
    public void buildMaxHeap(int[] arr, int i, int len) {
        // 保存非叶子结点的位置如果该结点的值小于子结点的值,则需要进行交换
        int tmp = arr[i];

        // 从上至下,从左至右 遍历. 从第一个左子结点开始遍历
        for (int n = (i << 1) + 1; n < len; n = (n << 1) + 1) {
            // 如果左子结点 < 右结点,则需要将 n 指向右结点,即后移
            if (n + 1 < len && arr[n] < arr[n + 1]) {
                n++;
            }
            // 当前非叶子结点 < 当前子结点
            if (arr[n] > tmp) {
                // 将当前非叶子结点指向叶子结点
                arr[i] = arr[n];
                // 将i指向当前叶子结点,待最后将其变为 非叶子结点的值 即tmp的值
                i = n;
            } else {
                break;
            }
        }
        // 与前面互相呼应
        arr[i] = tmp;
    }
}

基数排序

基数排序是 1887 年赫尔曼·何乐礼发明的。它是一种非比较型的排序算法,利用数字的位数进行排序。它将待排序的元素按位数进行分组,从最低位(个位)开始逐位排序,直到最高位。基数排序能够在不进行元素比较的情况下,对大量数据进行排序。

基数排序是桶排序的扩展,基数排序从最低位(个位)开始,首先按当前位的数字进行排序,然后将排序后的数据按顺序放回原数组。接着,处理下一位数字,并重复上述步骤,直到处理完所有位。每一位的排序通常使用稳定的排序算法(如计数排序)。基数排序通过多次对各个位进行排序,逐渐将数组元素排序到正确的位置。

数据结构与算法-020

基数排序的时间复杂度为 O(nk),其中 n 是数据量,k 是数据中位数的长度。与其他排序算法相比,基数排序在处理大数据量时能展现出较好的性能,特别适用于排序位数较少的整数或字符串。由于不涉及比较,基数排序在特定场景下比比较排序算法更为高效。

基数排序的缺点是需要额外的存储空间来存储排序过程中的数据。它只适用于特定类型的数据,如整数或固定长度的字符串,对其他类型的数据不适用。基数排序的时间复杂度 O(nk) 可能在 k 较大的情况下,性能不如 O(n log n) 的比较排序算法。

基数排序适合处理大量的整数或字符串,尤其是当数据的位数较少时。它广泛应用于对大规模数字数据进行排序的场景,比如处理电话号码、邮政编码等。基数排序还适用于需要保持数据稳定性的排序任务,如多关键字排序。

class BucketSorting {

    public void sort(int[] arr) {
        // 求数组中最大数的长度
        int maxNum = arr[0];
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] > maxNum) {
                maxNum = arr[i];
            }
        }
        int maxNumLen = String.valueOf(maxNum).length();

        // 桶,用于保存数据
        int[][] buckets = new int[10][arr.length];

        // 存放每个桶的保存数据的索引
        int[] bucketElementIndex = new int[10];

        // 将数组中的元素 按照 个、十、百、千 …… 的顺序依次放入桶中
        for (int i = 0, n = 1; i < maxNumLen; i++, n *= 10) {

            // 遍历二维数组
            for (int j = 0; j < arr.length; j++) {
                // 计算放入的桶的下标
                int index = arr[j] / n % 10;
                buckets[index][bucketElementIndex[index]] = arr[j];
                bucketElementIndex[index]++;
            }

            // 从桶中依次取出元素并放入原数组中
            int index = 0;
            for (int f = 0; f < bucketElementIndex.length; f++) {
                // 判断桶中是否保存数据
                if (bucketElementIndex[f] == 0) {
                    continue;
                }
                for (int h = 0; h < bucketElementIndex[f]; h++) {
                    arr[index] = buckets[f][h];
                    index++;
                }
                bucketElementIndex[f] = 0;
            }
        }

    }
}

查找算法

查找算法是用于在数据集合中快速定位目标元素的一类算法。它的核心目的是根据特定的查询条件在数据结构中查找目标位置或验证目标是否存在。查找算法的效率通常与数据集合的存储方式以及数据的有序程度密切相关。 常见的查找算法包括线性查找、二分查找、哈希查找和树形查找等。

线性查找

线性查找是一种基础的查找算法,它从数据集合的第一个元素开始,依次检查每个元素是否符合目标条件,直到找到目标元素或遍历完整个集合。适用于无序或有序的线性数据结构,如数组和链表,使用时不需要对数据进行预处理。

线性查找实现简单,逻辑清晰,代码容易编写和维护。它适用于任何类型的线性数据集合,无需对数据排序或建立额外的索引。同时,它在原数据集合上操作,不需要额外的存储空间,空间复杂度为 O(1)。 但是线性查找效率较低,时间复杂度为 O(n),需要逐个比较数据集合中的元素,查找效率会随着数据规模的增大而显著下降。即使是有序的数据集合,也无法跳过部分元素进行优化。

线性查找常用于小规模数据集合或无序数据。当查找任务简单,或者数据存储在链表等只能线性访问的结构中时,线性查找是一种直接有效的选择。

class LinearSearch{

    public int search(int[] arr, int value){
        for (int i = 0; i < arr.length; i++){
            if (arr[i] == value) {
                return i;
            }
        }
        return - 1;
    }

}

二分查找

二分查找是一种高效的查找算法,用于在有序数据集合中定位目标元素的位置。它通过每次将查找范围缩小为原来的一半,逐步逼近目标值,直到找到目标元素或查找范围为空为止。适用于数组等支持随机访问的有序数据结构。

二分查找的时间复杂度为 O(log n),相比线性查找,效率更高,尤其在数据规模较大时表现优异。它的逻辑清晰、实现简单,且无需占用额外的存储空间,空间复杂度为 O(1)。 但二分查找仅适用于有序数据,若数据无序,需要先进行排序,增加了时间开销。同时,它依赖于随机访问的数据结构,例如数组,不适用于链表等线性访问的数据结构。

二分查找常用于需要在有序数据集合中快速查找元素的场景,例如数组中定位某个值的位置。在解决与查找相关的问题时,如在字典、数据库索引或有序数组中查找特定值时,二分查找是一种有效的选择。

二分查找算法的前提,数组必须是有序数组,递归实现二分查找:

class BinarySearch {

    // 使用二分查找时,arr必须为有序列表
    public int search(int start, int end, int[] arr, int value) {
        // 在 {@param arr} 中 没有查到 {@param value}
        if (start > end || value > arr[end] || value < arr[start]) {
            return -1;
        }

        // 获取中间值,用于分割列表
        int mid = (start + end) >> 1;
        int midVal = arr[mid];

        // 如果 查找的值< 中间值,说明该值可能在mid的左边
        if (value < midVal) {
            return search(start, mid - 1, arr, value);
        }

        // 相反如果 查找的值 > 中间值,说明该值可能在mid的右边
        if (value > midVal) {
            return search(mid + 1, end, arr, value);
        }

        // 使用递归不停的向下细分,当 value == arr[mid] 时 返回该值,说明此时已经找到了
        return mid;
    }
}

非递归实现二分查找

public class BinarySearchDemo {
    public static void main(String[] args) {
        int[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        System.out.println(new BinarySearch().search(arr, 3));
    }
}

class BinarySearch {

    // 使用二分查找时,arr必须为有序列表
    public int search(int[] arr, int target) {
        int left = 0;
        int right = arr.length - 1;
        while (left <= right) {
            int mid = (left + right) >> 1;
            if (arr[mid] == target) {
                return mid;
            }
            // 如果目标值小于中间值则向左边找,反之向右边找
            if (target < arr[mid]) {
                right = mid - 1;
            }

            if (target > arr[mid]) {
                left = mid + 1;
            }
        }
        return -1;
    }

}

插值查找

插值查找是一种改进的查找算法,适用于在有序数据集合中查找目标元素。它根据目标值与当前查找区间的上下界的相对位置,动态计算出一个可能的查找位置,从而缩小查找范围。插值查找的核心思想是通过插值公式预测目标值的位置,类似于在字典中查找单词。

插值查找在数据分布较为均匀时性能优异,查找时间复杂度可以接近 O(log log n)。相比二分查找,它减少了不必要的比较次数,尤其在数据量大且分布均匀的情况下表现更加高效。 但插值查找对数据分布有较高要求,只有在数据分布均匀时才能发挥优势。当数据分布不均时,插值公式可能会产生不准确的预测,导致性能退化至 O(n)。它仅适用于支持随机访问的有序数据结构。

所以插值查找适用于分布均匀且规模较大的有序数据集合。例如在处理数据库索引、统计数据或大规模有序数组时,如果数据分布均匀,插值查找是一种高效的选择。

注意对于数据量较大,关键字分布比较均匀(最好是线性分布)的查找表来说,采用插值查找,速度较快。对于关键字分布不均匀的情况下,该方法不一定比二分查找要好。

class InsertValueSearch {

    // 与二分查找基本相同,只是查找 mid 值发生了变动
    public int search(int start, int end, int[] arr, int value) {
        // 在 {@param arr} 中 没有查到 {@param value}
        if (start > end || value > arr[end] || value < arr[start]) {
            return -1;
        }

        int mid = start + (value - arr[start]) / (arr[end] - arr[start]) * (end - start);
        int midVal = arr[mid];

        if (value < midVal) {
            return search(start, mid - 1, arr, value);
        }

        if (value > midVal) {
            return search(mid + 1, end, arr, value);
        }
        return mid;
    }

}

斐波那契查找

斐波那契查找是一种基于斐波那契数列的查找算法,用于在有序数据集合中查找目标元素。该算法通过利用斐波那契数列将查找区间划分为黄金分割比例,来更接近目标值的方式逐步缩小范围,从而提高查找效率。

黄金分割是指将整体一分为二,较大部分与整体部分的比值等于较小部分与较大部分的比值,其比值约为0.618。这个比例被公认为是最能引起美感的比例,因此被称为黄金分割。

斐波那契查找在处理较大规模的有序数据时性能较好,时间复杂度为 O(log n)。相比二分查找,它可以减少加法运算,因为只需要使用减法和数组索引计算,适合某些特定场景。 但是斐波那契查找要求数据集合的长度接近某个斐波那契数,若长度不匹配,需要对数据进行填充。与二分查找相比,其实现较为复杂,且对数据分布的适应性稍差。

适用于大规模、数据有序的集合,尤其是在硬件资源有限或需要减少加法运算的情况下。它在数据库查找、索引优化等特定环境中有一定应用价值。

class FibonacciSearch{
    // lookupTable,需要传入斐波那契数列,例如:{1,1,2,3,5,8,13,21,34,55};
    public static int search(int[] lookupTable,int[] f,int target){

        int low = 0;
        int high = lookupTable.length - 1;

        // k 是 Fibonacci 分割数组下标
        int k = 0;
        int middle = 0;

        while (f[k] < high){
            k ++;
        }

        //利用 java 工具类构造 f[k] 长度的查找表,解决原有查找表元素不够的问题
        int[] temp = Arrays.copyOf(lookupTable,f[k]);
        while (low <= high){
            middle = low + f[k - 1];
            if (target < lookupTable[middle]){
                high = middle -1;
                k --;
            }else if (target > lookupTable[middle]){
                low = middle + 1;
                k -= 2;
            }else{
                return Math.min(middle,high);
            }
        }
        return -1;
    }
}

哈希查找

哈希查找是一种基于散列技术的查找方法,通过将关键字映射到哈希表中的地址,实现快速定位目标元素。它使用哈希函数将输入数据转化为表中的索引值,避免了逐一遍历的低效过程。

哈希查找的查找效率高,平均时间复杂度为 O(1),尤其适用于查找频繁的大数据集合。它的插入、删除操作也非常高效,适合动态更新的场景。 但哈希查找依赖于设计合理的哈希函数和足够大的哈希表空间。若哈希函数不均匀,可能出现大量冲突,影响查找效率。在解决冲突时,链地址法或开放地址法可能增加复杂度。哈希查找无法直接支持范围查询。

哈希查找广泛应用于需要快速查找的场景,例如数据库索引、缓存(如 Redis)、散列表集合和字典等。在需要高效键值对操作的情况下,哈希查找是一种常用选择。

public class HashTable<K, V> {
    private static final int SIZE = 10; // 哈希表大小
    private LinkedList<Entry<K, V>>[] table; // 链地址法存储

    // 构造方法
    @SuppressWarnings("unchecked")
    public HashTable() {
        table = new LinkedList[SIZE];
        for (int i = 0; i < SIZE; i++) {
            table[i] = new LinkedList<>();
        }
    }

    // 哈希函数
    private int hash(K key) {
        return Math.abs(key.hashCode()) % SIZE;
    }

    // 插入键值对
    public void put(K key, V value) {
        int index = hash(key);
        LinkedList<Entry<K, V>> bucket = table[index];

        for (Entry<K, V> entry : bucket) {
            if (entry.key.equals(key)) {
                entry.value = value; // 如果键已存在,更新值
                return;
            }
        }

        bucket.add(new Entry<>(key, value)); // 插入新键值对
    }

    // 查找值
    public V get(K key) {
        int index = hash(key);
        LinkedList<Entry<K, V>> bucket = table[index];

        for (Entry<K, V> entry : bucket) {
            if (entry.key.equals(key)) {
                return entry.value;
            }
        }

        return null; // 未找到
    }

    // 删除键值对
    public void remove(K key) {
        int index = hash(key);
        LinkedList<Entry<K, V>> bucket = table[index];

        bucket.removeIf(entry -> entry.key.equals(key));
    }

    // 哈希表中的键值对
    private static class Entry<K, V> {
        K key;
        V value;

        Entry(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }

    // 打印哈希表内容
    public void printHashTable() {
        for (int i = 0; i < SIZE; i++) {
            System.out.print("Bucket " + i + ": ");
            for (Entry<K, V> entry : table[i]) {
                System.out.print("[" + entry.key + ": " + entry.value + "] ");
            }
            System.out.println();
        }
    }

    // 测试
    public static void main(String[] args) {
        HashTable<String, Integer> hashTable = new HashTable<>();
        hashTable.put("Alice", 25);
        hashTable.put("Bob", 30);
        hashTable.put("Charlie", 35);

        System.out.println("Alice's age: " + hashTable.get("Alice")); // 输出: Alice's age: 25
        System.out.println("Bob's age: " + hashTable.get("Bob"));     // 输出: Bob's age: 30

        hashTable.remove("Alice");
        System.out.println("After removal, Alice's age: " + hashTable.get("Alice")); // 输出: After removal, Alice's age: null

        hashTable.printHashTable();
    }
}

哈夫曼编码

哈夫曼编码是一种常见的数据压缩算法,用于在编码过程中以最小的空间表示数据。它根据字符出现的频率为每个字符分配不同长度的编码,频率高的字符使用较短的编码,频率低的字符使用较长的编码。通过这种方式,哈夫曼编码能够大大减少数据存储和传输所需的空间,特别适合用于文本数据的压缩。

哈夫曼编码是变长编码。它为不同的字符分配不同长度的编码,频率较高的字符使用较短的编码,频率较低的字符使用较长的编码。通过这种方式,哈夫曼编码能够有效减少存储空间,尤其在字符频率分布不均的情况下。它是一种前缀编码,没有任何编码是另一个编码的前缀,这样可以避免解码过程中出现歧义。 主要缺点是它对于频率分布均匀的数据效果不明显。当字符频率接近时,哈夫曼编码无法有效减少空间开销。另一个缺点是,哈夫曼编码需要在解码时依赖树的结构,可能导致对动态数据流的支持不够灵活。

哈夫曼编码广泛应用于需要数据压缩的场景,其压缩率通常在20%~90%之间,如文本文件压缩、图像格式(如 JPEG)压缩、视频压缩以及文件传输等。它适用于字符频率分布不均的情况,可以显著减少存储和传输的空间需求。

定长编码与变长编码,以字符串“like like”为例:

  • 定长编码:
  1. 将上述字符串转换对应的ASCII: 108 105 107 101 32 108 105 107 101
  2. ASCII转换为二进制:01101100 01101001 01101011 01100101 00100000 01101100 01101001 01101011 01100101
  • 变长编码:
  1. 统计上述字符串出现的各字符出现的次数:l:2 i:2 k:2 e:2 :1
  2. 按照各个字符出现的次数进行编码,原则是出现次数越多的,则编码越小:0=l 1=i 10=k 11=e 100=
  3. 最终转换为变长编码为:011011100011011

上述的变长编码 011011100011011 在解码的时候会出现多意现象,比如当匹配到数字1,是把1解成i还是按照10来进行解码。因为这种现象的存在,所以在进行变长编码时,编码要符合前缀编码。

字符的编码都不能是其他字符编码的前缀,符合此要求的编码叫做前缀编码,即不能匹配到重复的编码。

哈夫曼编码的工作原理主要包括两个步骤:

  1. 构建哈夫曼树:首先,根据字符出现的频率构建一棵哈夫曼树。该树是通过将频率最低的两个节点合并生成新的节点,一直到所有节点合并为一个根节点。节点的权重是字符的频率。
  2. 生成编码:通过遍历哈夫曼树从根到叶子的路径来为每个字符生成唯一的二进制编码。路径上的左子树分配“0”,右子树分配“1”。

假如一段信息里只有A,B,C,D,E,F这6个字符,他们出现的次数依次是2次,3次,7次,9次,18次,25次,最终构建成哈夫曼编树为下图所示:

数据结构与算法-029

得到哈夫曼编码:

A=11100 B=11101 C=1111 D=110 E=10 F=0

利用哈夫曼编码,压缩解压文件示例代码:

public class HuffmanCodeTest {

    public static void main(String[] args) {

        // 测试压缩文件
        String srcFile = "/Users/whitepure/Desktop/1.png";
        String dstFile = "/Users/whitepure/Desktop/1.zip";

        zipFile(srcFile, dstFile);
        System.out.println("压缩文件成功");

        // 测试解压文件
        srcFile = "/Users/whitepure/Desktop/1.zip";
        dstFile = "/Users/whitepure/Desktop/1copy.png";
        unZipFile(srcFile, dstFile);
        System.out.println("解压成功!");
    }


    // 将一个文件进行压缩
    public static void zipFile(String srcFile, String dstFile) {
        // 创建输出流
        OutputStream os = null;
        ObjectOutputStream oos = null;
        // 创建文件的输入流
        FileInputStream is = null;
        try {
            // 创建文件的输入流
            is = new FileInputStream(srcFile);
            // 创建一个和源文件大小一样的byte[]
            byte[] b = new byte[is.available()];
            // 读取文件
            is.read(b);
            HuffmanCode huffmanCode = new HuffmanCode();
            // 直接对源文件压缩
            byte[] huffmanBytes = huffmanCode.encode(b);
            // 创建文件的输出流, 存放压缩文件
            os = new FileOutputStream(dstFile);
            // 创建一个和文件输出流关联的ObjectOutputStream
            oos = new ObjectOutputStream(os);
            // 把 赫夫曼编码后的字节数组写入压缩文件
            oos.writeObject(huffmanBytes); // 我们是把
            // 这里我们以对象流的方式写入 赫夫曼编码,是为了以后我们恢复源文件时使用
            // 注意一定要把赫夫曼编码 写入压缩文件
            oos.writeObject(huffmanCode.getHuffmanCodes());

        } catch (Exception e) {
            // TODO: handle exception
            System.out.println(e.getMessage());
        } finally {
            try {
                is.close();
                oos.close();
                os.close();
            } catch (Exception e) {
                // TODO: handle exception
                System.out.println(e.getMessage());
            }
        }

    }


    // 完成对压缩文件的解压
    public static void unZipFile(String zipFile, String dstFile) {

        // 定义文件输入流
        InputStream is = null;
        // 定义一个对象输入流
        ObjectInputStream ois = null;
        // 定义文件的输出流
        OutputStream os = null;
        try {
            // 创建文件输入流
            is = new FileInputStream(zipFile);
            // 创建一个和 is关联的对象输入流
            ois = new ObjectInputStream(is);
            // 读取byte数组 huffmanBytes
            byte[] huffmanBytes = (byte[]) ois.readObject();
            // 读取赫夫曼编码表
            Map<Byte, String> huffmanCodes = (Map<Byte, String>) ois.readObject();
            HuffmanCode huffmanCode = new HuffmanCode();
            // 解码
            byte[] bytes = huffmanCode.decode(huffmanCodes, huffmanBytes);
            // 将bytes 数组写入到目标文件
            os = new FileOutputStream(dstFile);
            // 写数据到 dstFile 文件
            os.write(bytes);
        } catch (Exception e) {
            // TODO: handle exception
            System.out.println(e.getMessage());
        } finally {

            try {
                os.close();
                ois.close();
                is.close();
            } catch (Exception e2) {
                // TODO: handle exception
                System.out.println(e2.getMessage());
            }

        }
    }
}

class HuffmanCode {

    private final Map<Byte, String> huffmanCodes = new HashMap<>();

    public Map<Byte, String> getHuffmanCodes() {
        return huffmanCodes;
    }

    // 生成 huffman 编码 压缩
    public byte[] encode(byte[] bytes) {
        List<Node> nodes = buildHuffmanNodes(bytes);
        Node huffmanTreeRoot = buildHuffmanTree(nodes);
        Map<Byte, String> huffmanCodes = buildHuffmanCodeTab(huffmanTreeRoot);
        return zip(bytes, huffmanCodes);
    }

    // 将 huffman编码 解码 解压缩
    public byte[] decode(Map<Byte, String> huffmanCodes, byte[] huffmanBytes) {
        StringBuilder stringBuilder = new StringBuilder();

        // 将byte数组转成二进制的字符串
        for (int i = 0; i < huffmanBytes.length - 1; i++) {
            byte b = huffmanBytes[i];
            String strToAppend = byteToBitString(b);
            // 判断是不是最后一个字节
            boolean isLastByte = (i == huffmanBytes.length - 2);
            if (isLastByte) {
                // 得到最后一个字节的有效位数
                byte validBits = huffmanBytes[huffmanBytes.length - 1];
                strToAppend = strToAppend.substring(0, validBits);
            }
            stringBuilder.append(strToAppend);
        }

        // 把字符串按照指定的赫夫曼编码进行解码
        // 把赫夫曼编码表进行调换,因为反向查询 a->100 100->a
        Map<String, Byte> map = new HashMap<>();
        huffmanCodes.forEach((key, value) -> map.put(value, key));

        // 创建要给集合,存放byte
        List<Byte> list = new ArrayList<>();
        // i 可以理解成就是索引,扫描 stringBuilder
        for (int i = 0; i < stringBuilder.length(); ) {
            int count = 1;
            boolean flag = true;
            Byte b = null;

            while (flag) {
                // 递增的取出 key
                String key = stringBuilder.substring(i, i + count);
                b = map.get(key);
                if (b == null) {
                    // 没有匹配到
                    count++;
                } else {
                    // 匹配到
                    flag = false;
                }
            }
            list.add(b);
            i += count;
        }
        byte[] b = new byte[list.size()];
        IntStream.range(0, b.length).forEach(i -> b[i] = list.get(i));
        return b;
    }

    // 计算字符串中每个字符出现的次数
    private List<Node> buildHuffmanNodes(byte[] bytes) {
        ArrayList<Node> nodes = new ArrayList<>();

        // 利用map记录集合中元素出现的次数
        Map<Byte, Integer> counts = new HashMap<>();
        for (byte b : bytes) {
            counts.merge(b, 1, Integer::sum);
        }

        // 把每一个键值对转成一个Node 对象,并加入到nodes集合
        counts.forEach((key, value) -> nodes.add(new Node(key, value)));
        return nodes;
    }

    // 构建Huffman树
    private Node buildHuffmanTree(List<Node> nodes) {
        while (nodes.size() > 1) {
            // 排序, 从小到大
            Collections.sort(nodes);
            // 取出第一颗最小的二叉树
            Node leftNode = nodes.get(0);
            // 取出第二颗最小的二叉树
            Node rightNode = nodes.get(1);
            // 创建一颗新的二叉树,它的根节点 没有data, 只有权值
            Node parent = new Node(null, leftNode.weight + rightNode.weight);
            parent.left = leftNode;
            parent.right = rightNode;

            // 将已经处理的两颗二叉树从nodes删除
            nodes.remove(leftNode);
            nodes.remove(rightNode);
            // 将新的二叉树,加入到nodes
            nodes.add(parent);
        }
        // nodes 最后的结点,就是赫夫曼树的根结点
        return nodes.get(0);
    }

    // 重载 getCodes
    private Map<Byte, String> buildHuffmanCodeTab(Node root) {
        if (root == null) {
            return null;
        }
        // 处理root的左子树
        buildHuffmanCodeTab(root.left, "0", new StringBuilder());
        // 处理root的右子树
        buildHuffmanCodeTab(root.right, "1", new StringBuilder());
        return huffmanCodes;
    }

    // 获取huffman编码表
    private void buildHuffmanCodeTab(Node node, String code, StringBuilder stringBuilder) {
        StringBuilder curNodeCode = new StringBuilder(stringBuilder);
        curNodeCode.append(code);
        if (node == null) {
            return;
        }
        // 判断当前node 是叶子结点还是非叶子结点
        if (node.data == null) { // 非叶子结点
            // 向左递归
            buildHuffmanCodeTab(node.left, "0", curNodeCode);
            // 向右递归
            buildHuffmanCodeTab(node.right, "1", curNodeCode);
        } else {
            // 表示找到某个叶子结点的最后
            huffmanCodes.put(node.data, curNodeCode.toString());
        }
    }

    // 压缩传入字节(将传入字符串转成字节类型)将待压缩字节转换为字节数组
    private byte[] zip(byte[] bytes, Map<Byte, String> huffmanCodes) {
        // 利用 huffmanCodes 将 bytes 转成 赫夫曼编码对应的字符串
        StringBuilder stringBuilder = new StringBuilder();
        // 遍历bytes 数组
        for (byte b : bytes) {
            stringBuilder.append(huffmanCodes.get(b));
        }

        // 统计返回 byte[] huffmanCodeBytes 长度
        int len;
        // 等同于 int len = (stringBuilder.length() + 7) / 8;
        byte countToEight = (byte) (stringBuilder.length() & 7);
        if (countToEight == 0) {
            len = stringBuilder.length() >> 3;
        } else {
            len = (stringBuilder.length() >> 3) + 1;
            // 后面补零
            for (int i = countToEight; i < 8; i++) {
                stringBuilder.append("0");
            }
        }

        // 创建 存储压缩后的 byte数组,huffmanCodeBytes[len]记录赫夫曼编码最后一个字节的有效位数
        byte[] huffmanCodeBytes = new byte[len + 1];
        huffmanCodeBytes[len] = countToEight;
        int index = 0;
        // 因为是每8位对应一个byte,所以步长 +8
        for (int i = 0; i < stringBuilder.length(); i += 8) {
            String strByte;
            strByte = stringBuilder.substring(i, i + 8);
            // 将strByte 转成一个byte,放入到 huffmanCodeBytes
            huffmanCodeBytes[index] = (byte) Integer.parseInt(strByte, 2);
            index++;
        }
        return huffmanCodeBytes;
    }

    // 将 byte 转换为对应的字符串
    private String byteToBitString(byte b) {
        int temp = b;
        // 如果是正数我们需要将高位补零
        temp |= 0x100;
        // 转换为二进制字符串,正数:高位补 0 即可,然后截取低八位即可;负数直接截取低八位即可
        // 负数在计算机内存储的是补码,补码转原码:先 -1 ,再取反
        String binaryStr = Integer.toBinaryString(temp);
        return binaryStr.substring(binaryStr.length() - 8);
    }

}

class Node implements Comparable<Node> {
    Byte data;
    int weight;
    Node left;
    Node right;

    public Node(Byte data, int weight) {
        this.data = data;
        this.weight = weight;
    }

    @Override
    public int compareTo(Node o) {
        // 从小到大排序
        return this.weight - o.weight;
    }

    public String toString() {
        return "Node [data = " + data + " weight=" + weight + "]";
    }
}

分治算法

分治算法是一种算法设计策略,其基本思想是将一个复杂的问题分解为多个规模较小的子问题,递归地解决这些子问题,再将它们的结果合并起来,最终得到原问题的解。分治法通常适用于可以被分解为相似子问题的情况,并且每个子问题的解可以组合成原问题的解。

分治算法能有效地简化复杂问题,通过将问题分解为较小的、易于处理的子问题,既能提高解决问题的效率,又能使问题的结构更加清晰。对于一些特定问题,分治算法还可以利用并行处理,从而进一步优化性能。 缺点在于递归调用可能带来额外的栈空间开销,而且在一些问题中,子问题可能会重复计算,浪费计算资源。这种重复计算的问题可以通过动态规划等技术加以优化。

分治算法适用于可以将问题分解为若干个相似子问题的场景,常见的应用包括排序算法(如归并排序、快速排序)、查找算法(如二分查找)以及某些数学运算(如矩阵乘法、计算几何问题)。它尤其适合需要递归求解且能通过合并子问题的解得到最终答案的问题。

使用分治算法解决汉诺塔问题:

汉诺塔(又称河内塔)问题是源于印度一个古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。

public class HanoiTowerDemo {
    public static void main(String[] args) {
        HanoiTower hanoiTower = new HanoiTower();
        hanoiTower.hanoiTower(3, 'A', 'B', 'C');
    }
}

class HanoiTower {

    public void hanoiTower(int n, char a, char b, char c) {
        if (n <= 0) {
            return;
        }
        if (n == 1) {
            System.out.println(a + "->" + c);
            return;
        }
        // 将a塔上面除了底盘外的所有盘移动到b塔
        hanoiTower(n - 1, a, c, b);

        // 将a塔遗留的底盘移动到c塔
        System.out.println(a + "->" + c);

        // 将b塔上面的所有盘移动到c塔
        hanoiTower(n - 1, b, a, c);
    }

}

动态规划算法

动态规划是一种解决最优化问题的算法设计方法。它将问题分解为多个相互重叠的子问题,保存每个子问题的解,从而避免重复计算。这种方法通常用于具有重叠子问题和最优子结构特征的问题,通过将子问题的解存储起来,显著提高效率。 动态规划的基本思想是通过将问题分解为更小的子问题,逐步解决这些子问题,并通过存储中间结果来避免重复计算。常见的动态规划实现有两种方式:自顶向下方式(递归并通过记忆化存储子问题结果)和自底向上方式(通过迭代解决所有子问题,从最小的子问题开始构造解)。最终所有子问题的解结合起来得到原问题的解。

动态规划通过存储已解决子问题的结果来避免重复计算,从而提升了计算效率。它特别适合处理那些可以被拆解成重叠子问题的最优化问题,且能找到全局最优解。动态规划提供了一种系统化的求解方式,使得问题的结构更加清晰和可管理。 主要缺点是空间复杂度较高,尤其在问题的状态空间较大时,需要大量存储中间结果,消耗更多内存。动态规划的设计也可能比较复杂,需要仔细识别问题的最优子结构和重叠子问题,尤其在某些问题中,构建状态转移方程可能不容易。

动态规划广泛应用于具有最优子结构和重叠子问题的问题,常见的应用场景包括求解最短路径问题、背包问题、最长公共子序列、矩阵链乘法等。这些问题通常可以通过递归分解为多个较小的子问题,且每个子问题的解都对最终解有影响。动态规划能在这些场景中找到最优解,并避免重复计算,提高效率。

关于动态规划最经典的问题当属背包问题。背包问题是指一个给定容量的背包、若干具有一定价值和重量的物品,如何选择物品放入背包使物品的价值最大。其中又分01背包和完全背包(完全背包指的是:每种物品都有无限件可用)这里的问题属于01背包,即每个物品最多放一个。而无限背包可以转化为01背包。

物品 重量 价格
吉他(G) 1 1500
音响(S) 4 3000
电脑(L) 3 2000
public class KnapsackProblemDemo {
    public static void main(String[] args) {
        KnapsackProblem knapsackProblem = new KnapsackProblem();
        System.out.println(knapsackProblem.knapsackProblem());
    }
}

class KnapsackProblem {

    public int knapsackProblem() {
        // 物品的重量
        int[] w = {1, 4, 3};
        // 物品的价值
        int[] val = {1500, 3000, 2000};
        // 背包的容量
        int m = 4;
        // 物品的个数
        int n = val.length;
        // 物品规划表
        int[][] v = new int[n + 1][m + 1];

        // 将v[][] 第一列和第一行重置为0
        for (int i = 0; i < v.length; i++) {
            v[i][0] = 0;
        }
        for (int i = 0; i < v[0].length; i++) {
            v[0][i] = 0;
        }

        // 处理 生成物品价格表
        for (int i = 1; i < v.length; i++) {
            for (int j = 1; j < v[0].length; j++) {
                // 如果当前商品的重量 是否能写入当前表格中
                if (w[i - 1] > j) {
                    v[i][j] = v[i - 1][j];
                } else {
                    v[i][j] = Math.max(v[i - 1][j], val[i - 1] + v[i - 1][j - w[i - 1]]);
                }
            }
        }

        // 处理完后 v[][] 表中数值最大的就是最后的结果
        int max = 0;
        for (int[] ints : v) {
            System.out.println(Arrays.toString(ints));
            for (int anInt : ints) {
                if (anInt > max) {
                    max = anInt;
                }
            }
        }
        return max;
    }
}

KMP算法

KMP(Knuth-Morris-Pratt)算法是一种用于字符串匹配的高效算法,通过预处理模式串构建部分匹配表,避免重复比较,从而提高字符串匹配效率。与传统的暴力匹配算法不同,KMP算法通过部分匹配表指导模式串的跳跃,减少了不必要的字符比较,其时间复杂度为 O(n + m),其中 n 是文本串的长度,m 是模式串的长度。

KMP算法的核心原理是利用模式串的部分匹配信息来避免重复比较。当字符不匹配时,算法通过已构建的部分匹配表跳过不必要的字符,而不是从头开始重新比较。部分匹配表记录模式串中每个位置的最长前后缀相同的长度,在发生不匹配时,算法根据该表迅速调整模式串的位置,继续匹配剩余的部分,从而提高效率。

KMP算法能够高效地进行字符串匹配,时间复杂度为 O(n + m),远低于暴力匹配算法的 O(n * m)。它通过预处理模式串,避免了对文本串中已匹配部分的重复比较,显著提高了匹配的速度,特别适合于长文本串的多次匹配。 缺点在于预处理阶段需要计算模式串的部分匹配表,这增加了实现的复杂度,特别是在模式串较短或匹配次数较少时,预处理开销可能不值得。对于初学者来说,理解部分匹配表的构建过程及其在匹配中的应用可能较为复杂。 适用于需要进行高效字符串匹配的场景,特别是在长文本串与模式串多次匹配时。它广泛应用于文本搜索、正则表达式引擎、数据压缩、字符串处理等领域,能够显著提高匹配效率。

常规算法与KMP算法比较:

  • 常规算法匹配字符串:从主串的起始位置(或指定位置)开始与模式串的第一个字符比较,若相等则继续逐个比较后续字符;否则从主串的下一个字符再重新和模式串的字符比较。依次类推,直到模式串成功匹配,返回主串中第一次出现模式串字符的位置,或者模式串匹配不成功,这里约定返回-1。 数据结构与算法-042

  • KMP算法匹配字符串:主要是改进了暴力匹配中i回溯的操作,KMP算法中当一趟匹配过程中出现字符比较不等时,不直接回溯i,而是利用已经得到的“部分匹配”的结果将模式串向右移动(j-next[j-1])的距离。 数据结构与算法-043

public class KMPDemo {
    public static void main(String[] args) {
        KMP kmp = new KMP();
        String str1 = "BBC ABCDAB ABCDABCDABDE";
        String str2 = "ABCDABD";
        int[] next = kmp.getMatchTab(str2);
        System.out.println(Arrays.toString(next));
        System.out.println(kmp.kmpSearch(str1, str2, next));
    }
}

class KMP {

    // 获取KMP 部分匹配表
    public int[] getMatchTab(String dest) {
        int[] result = new int[dest.length()];
        // 部分匹配表第一个值始终为0
        result[0] = 0;
        for (int i = 1, j = 0; i < result.length; i++) {
            // KMP 核心(特点,公式)
            while (j > 0 && dest.charAt(i) != dest.charAt(j)) {
                j = result[j - 1];
            }
            if (dest.charAt(j) == dest.charAt(i)) {
                j++;
            }
            result[i] = j;
        }
        return result;
    }

    /**
     * KMP查找算法
     *
     * @param str1 原字符串
     * @param str2 子字符串
     * @param next 部分匹配表
     * @return 匹配到字符串的第一个索引位置
     */
    public int kmpSearch(String str1, String str2, int[] next) {
        for (int i = 0, j = 0; i < str1.length(); i++) {
            while (j > 0 && str1.charAt(i) != str2.charAt(j)) {
                j = next[j - 1];
            }
            if (str1.charAt(i) == str2.charAt(j)) {
                j++;
            }
            if (j == str2.length()) {
                return i - j + 1;
            }
        }
        return -1;
    }

}

贪心算法

贪心算法是一种通过在每一步选择中都做出当前看起来最优的选择,期望通过局部最优解得到全局最优解的算法策略。贪心算法通常适用于求解最优化问题。在某些问题中,贪心算法能提供最优解,但在其他问题中,贪心算法可能仅给出近似解。与动态规划不同,贪心算法不考虑全局信息,而是逐步做出决策。

贪心算法的基本思想是在每一个决策点都选择当前状态下最优的选择。每做出一个决策后,就锁定了该决策,不能回溯。整个过程通过局部的最优选择构建出最终的解。在执行过程中,贪心算法没有回头的机会,因此每一步的决策必须根据问题的特性,确保做出的是局部最优的选择。

优点在于它通常具有较高的执行效率。由于贪心算法每一步选择最优解,并不需要回溯,因此它的时间复杂度通常较低。它的简单性和高效性使得它适用于一些特定的最优化问题,如活动选择问题、最小生成树等。 缺点是并不总能给出全局最优解。在某些情况下,局部最优解并不能导致全局最优解,因此无法解决所有的最优化问题。为了确保贪心算法能正确地找到全局最优解,问题必须满足“贪心选择性质”和“最优子结构”这两个条件。

贪心算法适用于那些具有贪心选择性质和最优子结构的问题。这类问题的解可以通过局部最优解逐步组合成全局最优解。常见的应用场景包括活动选择问题、最小生成树(如Kruskal算法、Prim算法)、单源最短路径问题(如Dijkstra算法)等。在这些场景中,贪心算法能够高效地找到问题的最优解。

举例,假设存在下面需要付费的广播台,以及广播台信号可以覆盖的地区。如何选择最少的广播台,让所有的地区都可以接收到信号。

广播台 覆盖地区
K1 “北京”, “上海”, “天津”
K2 “广州”, “北京”, “深圳”
K3 “成都”, “上海”, “杭州”
K4 “上海”, “天津”
K5 “杭州”, “大连”
public class GreedyAlgorithmDemo {
    public static void main(String[] args) {
        HashMap<String, HashSet<String>> broadcasts = new HashMap<>();
        HashSet<String> hashSet1 = new HashSet<>();
        hashSet1.add("北京");
        hashSet1.add("上海");
        hashSet1.add("天津");

        HashSet<String> hashSet2 = new HashSet<>();
        hashSet2.add("广州");
        hashSet2.add("北京");
        hashSet2.add("深圳");

        HashSet<String> hashSet3 = new HashSet<>();
        hashSet3.add("成都");
        hashSet3.add("上海");
        hashSet3.add("杭州");

        HashSet<String> hashSet4 = new HashSet<>();
        hashSet4.add("上海");
        hashSet4.add("天津");

        HashSet<String> hashSet5 = new HashSet<>();
        hashSet5.add("杭州");
        hashSet5.add("大连");

        broadcasts.put("K1", hashSet1);
        broadcasts.put("K2", hashSet2);
        broadcasts.put("K3", hashSet3);
        broadcasts.put("K4", hashSet4);
        broadcasts.put("K5", hashSet5);

        // allAreas 存放所有的地区
        HashSet<String> allAreas = new HashSet<>();
        for (Map.Entry<String, HashSet<String>> broadcast : broadcasts.entrySet()) {
            allAreas.addAll(broadcast.getValue());
        }

        System.out.println(new GreedyAlgorithm().getRadioByGreedyAlgorithm(allAreas, broadcasts));
    }
}

class GreedyAlgorithm {

    public List<String> getRadioByGreedyAlgorithm(HashSet<String> allAreas, HashMap<String, HashSet<String>> broadcasts) {
        // 存放选择的电台
        ArrayList<String> selects = new ArrayList<>();

        // 存放每次选择最优的电台
        String maxKey = null;

        // 临时集合 从 broadcasts 中选出能覆盖的电台,即存放 allAreas 与 broadcasts 的交集
        HashSet<String> tmpSet = new HashSet<>();

        while (allAreas.size() > 0) {
            // 每次需要清空
            maxKey = null;

            for (String key : broadcasts.keySet()) {
                tmpSet.clear();
                tmpSet.addAll(broadcasts.get(key));

                // 计算覆盖的电台 并赋值给tmpSet
                tmpSet.retainAll(allAreas);

                // 此处进行比较 体现贪心算法
                if (tmpSet.size() > 0 && (maxKey == null || tmpSet.size() > broadcasts.get(maxKey).size())) {
                    maxKey = key;
                }
            }

            // 每进行一次循环最后需要移除选中的maxKey对应的电台城市
            if (maxKey != null) {
                selects.add(maxKey);
                allAreas.removeAll(broadcasts.get(maxKey));
            }
        }
        return selects;
    }

}

普里姆算法

普里姆算法是一种用于求解加权无向图的最小生成树问题的贪心算法。该算法从图的某个节点开始,逐步扩展生成树,选择与当前生成树相连的权值最小的边,直到所有的节点都被包含在内。普里姆算法的时间复杂度为 O(E log V),其中 E 是边的数量,V 是节点的数量。

最小生成树:给定一个带权的无向连通图,如何选取一棵生成树,使树上所有边上权的总和为最小,简称MST。求最小生成树的算法主要是普里姆算法和克鲁斯卡尔算法。

  • 普里姆算法:O(n^2),适合稠密图(边多的图)
  • 克鲁斯卡尔算法:O,适合稀疏图(边少的图)

普里姆算法的优点在于它能够高效地求解最小生成树问题,特别是对于稠密图(即边数较多的图)较为高效。它的核心思想简单且容易理解,能够逐步通过贪心选择构建最小生成树。在图的边较多时,相较于Kruskal算法,普里姆算法通常表现更好。 缺点是对于稀疏图(即边数较少的图),其效率较低。在实现时,普里姆算法需要维护一个优先队列来选择最小边,增加了空间复杂度和代码复杂度。在某些实现中,如果不使用适当的数据结构,算法可能变得较慢。

数据结构与算法-044

普里姆算法适用于求解加权无向图的最小生成树问题,特别是图的边较多(即稠密图)时,普里姆算法通常更高效。它广泛应用于网络设计、城市交通规划、资源分配等领域,在这些问题中,我们常常需要找到一个包含所有点且总权重最小的连通子图。

public class PrimAlgorithmDemo {
    public static void main(String[] args) {
        char[] data = new char[]{'A', 'B', 'C', 'D', 'E', 'F', 'G'};
        int vertex = data.length;
        //邻接矩阵的关系使用二维数组表示,10000这个大数,表示两个点不联通
        int[][] weight = new int[][]{
                {10000, 5, 7, 10000, 10000, 10000, 2},
                {5, 10000, 10000, 9, 10000, 10000, 3},
                {7, 10000, 10000, 10000, 8, 10000, 10000},
                {10000, 9, 10000, 10000, 10000, 4, 10000},
                {10000, 10000, 8, 10000, 10000, 5, 4},
                {10000, 10000, 10000, 4, 5, 10000, 6},
                {2, 3, 10000, 10000, 4, 6, 10000}
        };

        MGraph graph = new MGraph(vertex);
        PrimAlgorithm minTree = new PrimAlgorithm();
        graph.create(graph, vertex, data, weight);
        graph.show(graph);
        minTree.prim(graph, 0);
    }
}

class PrimAlgorithm {

    /**
     * 最小生成树问题 prim算法
     *
     * @param graph  图对象
     * @param vertex 开始的顶点
     */
    public void prim(MGraph graph, int vertex) {
        int i = 0, j = 0;
        int row = -1, column = -1;
        // 存放已经访问过的顶点
        int[] visited = new int[graph.vertex];
        // 用1表示两点之间已经连接, 0表示未连接
        visited[vertex] = 1;
        int minWeight = 10000;

        for (int k = 1; k < graph.vertex; k++){
            // 比较两点之间的权值,每次都获取最小的权值
            for (i = 0; i < graph.vertex; i++) {
                for(j = 0; j < graph.vertex; j++){
                    if (visited[i] == 1 && visited[j] == 0 && graph.weight[i][j] < minWeight){
                        minWeight = graph.weight[i][j];
                        row = i;
                        column = j;
                    }
                }
            }
            System.out.println("边<" + graph.data[row] + "," + graph.data[column] + "> 权值:" + minWeight);
            // 将顶点标记为已经访问过
            visited[column] = 1;

            // 每次比较完后需要将minWeight重置
            minWeight = 10000;
        }

    }

}

class MGraph {
    int vertex;
    char[] data;
    int[][] weight;

    public MGraph(int vertex) {
        this.vertex = vertex;
        data = new char[vertex];
        weight = new int[vertex][vertex];
    }

    /**
     * 创建图的邻接矩阵
     *
     * @param graph  图对象
     * @param vertex 图对应的顶点个数
     * @param data   图的各个顶点的值
     * @param weight 图的邻接矩阵
     */
    public void create(MGraph graph, int vertex, char[] data, int[][] weight) {
        int i, j;
        for (i = 0; i < vertex; i++) {
            graph.data[i] = data[i];
            for (j = 0; j < vertex; j++) {
                graph.weight[i][j] = weight[i][j];
            }
        }
    }

    // 显示图的邻接矩阵
    public void show(MGraph graph) {
        for (int[] link : graph.weight) {
            System.out.println(Arrays.toString(link));
        }
    }
}

克鲁斯卡尔算法

克鲁斯卡尔算法是一种用于求解加权无向图的最小生成树问题的贪心算法。它通过排序所有边,逐步选择权值最小的边,将这些边加入生成树中,直到生成树包含所有节点为止。与普里姆算法不同,克鲁斯卡尔算法是基于边的处理,而不是基于节点的处理。

克鲁斯卡尔算法是求连通网的最小生成树的另一种方法。基本思想是,将所有边按照权值的大小进行升序排序,然后从小到大一一判断,条件为:如果这个边不会与之前选择的所有边组成回路,就可以作为最小生成树的一部分,反之舍去。直到具有 n 个顶点的连通网筛选出来 n-1(n为顶点个数) 条边为止。

判断是否构成回路:当每次需要将一条边添加到最小生成树时,判断该边的两个顶点终点是否相同,相同就会构成回路。终点就是将所有顶点按照从小到大的顺序排列好之后,某个顶点的终点就是与它连通的最大顶点。

举例:

  1. 首先ABCDEFG这7个顶点,在顶点集合中是按照顺序存放的;
  2. 第一次选择的是EF,毫无疑问这一条边的终点是F;
  3. 第二次选择的CD的终点D;
  4. 第三次选择的DE,终点是F,因为此时D和E相连,D又和F相连,所以D的终点是F。而且,因为C和D是相连的,D和E相连,E和F也是相连的,所以C的终点此时变成了F。也就是说,当选择了EF、CD、DE这三条边后,C、D、E的终点都是F。当然F的终点也是F,因为F还没和后面的哪个顶点连接。
  5. 本来接下来应该选择CE的,但是由于C和E的终点都是F,所以就会形成回路;

数据结构与算法-045

克鲁斯卡尔算法的优点在于它实现简单,直观,并且适用于稀疏图(即边较少的图)。由于算法基于边的处理,只要对所有边进行排序并逐一检查,它的时间复杂度与边数的平方关系较弱,适合处理边数较少的图。使用并查集优化后,算法的效率得到了显著提高。 缺点是它需要对所有的边进行排序,这在边数较多的情况下可能会导致效率较低。排序的时间复杂度为 O(E log E),其中 E 是边的数量。使用并查集来判断环的形成也需要额外的空间和时间。在图的边数非常多时,克鲁斯卡尔算法的效率可能不如普里姆算法。

克鲁斯卡尔算法特别适合用于边数较少的稀疏图,或者当图的边的数量远远大于节点的数量时。由于它是基于边的贪心选择来构建最小生成树,因此在很多网络设计、资源连接等问题中应用广泛,如计算机网络的最小拓扑、道路建设等领域。

public class KruskalCaseDemo {

    //使用 INF 表示两个顶点不能连通
    private static final int INF = Integer.MAX_VALUE;

    public static void main(String[] args) {
        char[] vertexs = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};
        int matrix[][] = {
                /*A*//*B*//*C*//*D*//*E*//*F*//*G*/
                /*A*/ {0, 12, INF, INF, INF, 16, 14},
                /*B*/ {12, 0, 10, INF, INF, 7, INF},
                /*C*/ {INF, 10, 0, 3, 5, 6, INF},
                /*D*/ {INF, INF, 3, 0, 4, INF, INF},
                /*E*/ {INF, INF, 5, 4, 0, 2, 8},
                /*F*/ {16, 7, 6, INF, 2, 0, 9},
                /*G*/ {14, INF, INF, INF, 8, 9, 0}};

        KruskalCase kruskalCase = new KruskalCase(vertexs, matrix);
        kruskalCase.print();
        kruskalCase.kruskal();
    }

}

class KruskalCase {
    //使用 INF 表示两个顶点不能连通
    private static final int INF = Integer.MAX_VALUE;
    private int edgeNum; //边的个数
    private char[] vertexs; //顶点数组
    private int[][] matrix; //邻接矩阵

    // 构造器
    public KruskalCase(char[] vertexs, int[][] matrix) {
        // 初始化顶点数和边的个数
        int vlen = vertexs.length;

        // 初始化顶点, 复制拷贝的方式
        this.vertexs = new char[vlen];
        for (int i = 0; i < vertexs.length; i++) {
            this.vertexs[i] = vertexs[i];
        }

        // 初始化边, 使用的是复制拷贝的方式
        this.matrix = new int[vlen][vlen];
        for (int i = 0; i < vlen; i++) {
            for (int j = 0; j < vlen; j++) {
                this.matrix[i][j] = matrix[i][j];
            }
        }
        // 统计边的条数
        for (int i = 0; i < vlen; i++) {
            for (int j = i + 1; j < vlen; j++) {
                if (this.matrix[i][j] != INF) {
                    edgeNum++;
                }
            }
        }
    }

    public void print() {
        System.out.println("邻接矩阵为: \n");
        for (int i = 0; i < vertexs.length; i++) {
            for (int j = 0; j < vertexs.length; j++) {
                System.out.printf("%12d", matrix[i][j]);
            }
            System.out.println();
        }
    }

    /**
     * 功能:对边进行排序处理, 冒泡排序
     *
     * @param edges 边的集合
     */
    private void sortEdges(EdgeData[] edges) {
        for (int i = 0; i < edges.length - 1; i++) {
            for (int j = 0; j < edges.length - 1 - i; j++) {
                if (edges[j].weight > edges[j + 1].weight) {
                    EdgeData tmp = edges[j];
                    edges[j] = edges[j + 1];
                    edges[j + 1] = tmp;
                }
            }
        }
    }

    /**
     * @param ch 顶点的值,比如'A','B'
     * @return 返回ch顶点对应的下标,如果找不到,返回-1
     */
    private int getPosition(char ch) {
        for (int i = 0; i < vertexs.length; i++) {
            if (vertexs[i] == ch) {
                return i;
            }
        }
        // 找不到,返回-1
        return -1;
    }

    /**
     * 功能: 获取图中边,放到EData[] 数组中,后面我们需要遍历该数组
     * 是通过matrix 邻接矩阵来获取
     * EData[] 形式 [['A','B', 12], ['B','F',7], .....]
     */
    private EdgeData[] getEdges() {
        int index = 0;
        EdgeData[] edges = new EdgeData[edgeNum];
        for (int i = 0; i < vertexs.length; i++) {
            for (int j = i + 1; j < vertexs.length; j++) {
                if (matrix[i][j] != INF) {
                    edges[index++] = new EdgeData(vertexs[i], vertexs[j], matrix[i][j]);
                }
            }
        }
        return edges;
    }

    /**
     * 功能: 获取下标为i的顶点的终点, 用于后面判断两个顶点的终点是否相同
     *
     * @param ends : 数组就是记录了各个顶点对应的终点是哪个,ends 数组是在遍历过程中,逐步形成
     * @param i    : 表示传入的顶点对应的下标
     * @return 返回的就是 下标为i的这个顶点对应的终点的下标, 一会回头还有来理解
     */
    private int getEnd(int[] ends, int i) { // i = 4 [0,0,0,0,5,0,0,0,0,0,0,0]
        while (ends[i] != 0) {
            i = ends[i];
        }
        return i;
    }

    public void kruskal() {
        int index = 0;
        // 用于保存"已有最小生成树" 中的每个顶点在最小生成树中的终点
        int[] ends = new int[vertexs.length];
        // 创建结果数组, 保存最后的最小生成树
        EdgeData[] rets = new EdgeData[edgeNum];

        // 获取图中 所有的边的集合 , 一共有12边
        EdgeData[] edges = getEdges();

        // 按照边的权值大小进行排序(从小到大)
        sortEdges(edges);

        // 遍历edges 数组,将边添加到最小生成树中时,判断是准备加入的边否形成了回路,如果没有,就加入 rets, 否则不能加入
        for (int i = 0; i < edgeNum; i++) {
            // 获取到第i条边的第一个顶点(起点)
            int point1 = getPosition(edges[i].start);
            // 获取到第i条边的第2个顶点
            int point2 = getPosition(edges[i].end);
            // 获取p1这个顶点在已有最小生成树中的终点
            int endPointOfPoint1 = getEnd(ends, point1);
            // 获取p2这个顶点在已有最小生成树中的终点
            int endPointOfPoint2 = getEnd(ends, point2);

            // 克鲁斯卡尔核心点:判断是否构成回路
            if (endPointOfPoint1 != endPointOfPoint2) {
                // 假设没有构成回路
                // 将该边上终点上的值赋值给起点位置的值
                ends[endPointOfPoint1] = endPointOfPoint2;
                // 将该边保存起来
                rets[index++] = edges[i];
            }
        }
        System.out.println("最小生成树为");
        for (int i = 0; i < index; i++) {
            System.out.println(rets[i]);
        }
    }

}

class EdgeData {
    char start; // 边的一个点
    char end; // 边的另外一个点
    int weight; // 边的权值

    public EdgeData(char start, char end, int weight) {
        this.start = start;
        this.end = end;
        this.weight = weight;
    }

    // 重写toString, 便于输出边信息
    @Override
    public String toString() {
        return "EData [<" + start + ", " + end + ">= " + weight + "]";
    }
}

迪杰斯特拉算法

迪杰斯特拉算法是一种用于求解单源最短路径问题的贪心算法。它的目标是从图中的某个起点出发,找到到达图中所有其他节点的最短路径。该算法适用于权值非负的加权图,可以有效地计算从一个节点到其他所有节点的最短路径。

数据结构与算法-046

迪杰斯特拉算法的优点在于它的实现简单,且在权值非负的图中非常高效。它能够在一次遍历中找到从源节点到所有其他节点的最短路径,适用于很多实际应用场景,如网络路由、地图导航等。 主要缺点是它不能处理负权边的问题。如果图中包含负权边,算法的结果将不正确。另一个缺点是,当图的规模非常大时,算法的时间复杂度可能较高,尤其是在没有使用合适数据结构(如优先队列)时,时间复杂度为 O(V²),其中 V 是图中的节点数。

适用于寻找加权图中单源的最短路径,特别是在图的边的权值非负时。在很多实际问题中,如通信网络、交通路线规划等,常常需要计算从某个源节点到其他所有节点的最短路径。该算法被广泛应用于计算机网络路由、地图导航、资源分配等领域。

public class DijkstraAlgorithmDemo {
    public static void main(String[] args) {
        char[] vertex = { 'A', 'B', 'C', 'D', 'E', 'F', 'G' };
        int[][] matrix = new int[vertex.length][vertex.length];
        // 表示不可以连接
        final int N = 65535;
        matrix[0] = new int[] { N, 5, 7, N, N, N, 2 };
        matrix[1] = new int[] { 5, N, N, 9, N, N, 3 };
        matrix[2] = new int[] { 7, N, N, N, 8, N, N };
        matrix[3] = new int[] { N, 9, N, N, N, 4, N };
        matrix[4] = new int[] { N, N, 8, N, N, 5, 4 };
        matrix[5] = new int[] { N, N, N, 4, 5, N, 6 };
        matrix[6] = new int[] { 2, 3, N, N, 4, 6, N };
        Graph graph = new Graph(vertex, matrix);
        graph.showGraph();
        graph.dsj(6);
    }
}

class Graph {
    private char[] vertex;
    private int[][] matrix;
    private VisitedVertex vv;

    // 构造器
    public Graph(char[] vertex, int[][] matrix) {
        this.vertex = vertex;
        this.matrix = matrix;
    }

    // 显示结果
    public void showDijkstra() {
        vv.showArrays();
    }

    // 显示图
    public void showGraph() {
        for (int[] link : matrix) {
            for (int i : link) {
                System.out.printf("%8d", i);
            }
            System.out.println();
        }
    }

    /**
     * 迪杰斯特拉算法实现
     * @param index 表示出发顶点对应的下标
     */
    public void dsj(int index) {
        vv = new VisitedVertex(vertex.length, index);
        update(index);
        vv.showArrays();
        for (int j = 1; j < vertex.length; j++) {
            index = vv.findNextStartPoint();
            update(index);
            vv.showArrays();
        }
    }

    // 更新index下标顶点到周围顶点的距离和周围顶点的前驱顶点,
    private void update(int index) {
        int len = 0;
        // 根据遍历我们的邻接矩阵的 matrix[index]行
        for (int j = 0; j < matrix[index].length; j++) {
            // len 含义是 : 出发顶点到index顶点的距离 + 从index顶点到j顶点的距离的和
            len = vv.getDis(index) + matrix[index][j];
            // 如果j顶点没有被访问过,并且 len 小于出发顶点到j顶点的距离,就需要更新
            if (!vv.isVisited(j) && len < vv.getDis(j)) {
                vv.updatePre(j, index);
                vv.updateDis(j, len);
            }
        }
    }
}

class VisitedVertex {
    public int[] alreadyArr;
    public int[] preVisited;
    public int[] dis;

    /**
     *
     * @param length :表示顶点的个数
     * @param index: 出发顶点对应的下标, 比如G顶点,下标就是6
     */
    public VisitedVertex(int length, int index) {
        this.alreadyArr = new int[length];
        this.preVisited = new int[length];
        this.dis = new int[length];
        // 初始化 dis数组
        Arrays.fill(dis, 65535);
        this.dis[index] = 0;
        this.alreadyArr[index] = 1;

    }

    /**
     * 功能: 判断index顶点是否被访问过
     * @return 如果访问过,就返回true, 否则访问false
     */
    public boolean isVisited(int index) {
        return alreadyArr[index] == 1;
    }

    /**
     * 功能: 更新出发顶点到index顶点的距离
     */
    public void updateDis(int index, int len) {
        dis[index] = len;
    }

    /**
     * 功能: 更新pre这个顶点的前驱顶点为index顶点
     */
    public void updatePre(int pre, int index) {
        preVisited[pre] = index;
    }

    /**
     * 功能:返回出发顶点到index顶点的距离
     */
    public int getDis(int index) {
        return dis[index];
    }


    /**
     * 继续选择并返回新的访问顶点, 比如这里的G 完后,就是 A点作为新的访问顶点(注意不是出发顶点)
     */
    public int findNextStartPoint() {
        int min = 65535, index = 0;
        for (int i = 0; i < alreadyArr.length; i++) {
            if (alreadyArr[i] == 0 && dis[i] < min) {
                min = dis[i];
                index = i;
            }
        }
        // 更新 index 顶点被访问过
        alreadyArr[index] = 1;
        return index;
    }

    //显示最后的结果
    public void showArrays() {
        System.out.println("核心数组的值如下:");
        for (int i : alreadyArr) {
            System.out.print(i + " ");
        }
        System.out.println();
        for (int i : dis) {
            System.out.print(i + " ");
        }
        System.out.println();
        for (int i : preVisited) {
            System.out.print(i + " ");
        }
        System.out.println();

        char[] vertex = { 'A', 'B', 'C', 'D', 'E', 'F', 'G' };
        int count = 0;
        for (int i : dis) {
            if (i != 65535) {
                System.out.print(vertex[count] + "(" + i + ") ");
            } else {
                System.out.print("N ");
            }
            count++;
        }
        System.out.println();
        System.out.println();
    }

}

弗洛伊德算法

弗洛伊德算法是一种用于求解加权图中所有节点对之间最短路径的算法,也称为“所有最短路径”算法。与迪杰斯特拉算法仅解决单源最短路径不同,弗洛伊德算法能够同时计算图中每一对节点之间的最短路径。它是基于动态规划的思想,适用于任意类型的图,包括带负权边的图但不适用于负权环。

弗洛伊德算法的优点是能够计算图中所有节点对之间的最短路径,且对图的规模不受限制。算法非常简洁,适合处理较小规模的图。它能够处理带负权边的图,但不能处理负权环。因为它考虑的是所有的节点对,因此特别适合需要全局最短路径信息的应用场景。 缺点是时间复杂度较高,其时间复杂度为 O(n³),其中 n 是图中节点的数量。这使得弗洛伊德算法在处理大规模图时效率较低。弗洛伊德算法需要 O(n²) 的空间来存储最短路径信息,这对于节点数非常大的图来说可能占用较多内存。

弗洛伊德算法适用于需要计算图中每一对节点最短路径的场景,尤其是当图的节点数量较小或中等时。在某些网络、地图、路径规划等应用中,了解每一对节点之间的最短路径是很重要的。它可以用于最短路径查询、图的全局优化、传输网络的路径分析等场景。对于小规模的图或需要全局最短路径的情况,弗洛伊德算法是一个合适的选择。

public class FloydAlgorithmDemo {
    public static void main(String[] args) {
        char[] vertex = {'A', 'B', 'C', 'D', 'E', 'F', 'G'};
        int[][] matrix = new int[vertex.length][vertex.length];
        final int N = 65535;
        matrix[0] = new int[]{0, 5, 7, N, N, N, 2};
        matrix[1] = new int[]{5, 0, N, 9, N, N, 3};
        matrix[2] = new int[]{7, N, 0, N, 8, N, N};
        matrix[3] = new int[]{N, 9, N, 0, N, 4, N};
        matrix[4] = new int[]{N, N, 8, N, 0, 5, 4};
        matrix[5] = new int[]{N, N, N, 4, 5, 0, 6};
        matrix[6] = new int[]{2, 3, N, N, 4, 6, 0};

        FloydGraph graph = new FloydGraph(vertex, vertex.length, matrix);
        graph.floyd();
        graph.show();
    }
}

class FloydGraph {
    private char[] vertex;
    private int[][] pre;
    private int[][] dis;

    public FloydGraph(char[] vertex, int length, int[][] dis) {
        this.vertex = vertex;
        this.dis = dis;
        this.pre = new int[length][length];
        for (int i = 0; i < length; i++) {
            Arrays.fill(pre[i], i);
        }
    }

    public void show() {
        for (int k = 0; k < dis.length; k++) {
            // 先将pre数组输出的一行
            for (int i = 0; i < dis.length; i++) {
                System.out.print(vertex[pre[k][i]] + " ");
            }
            System.out.println();
            // 输出dis数组的一行数据
            for (int i = 0; i < dis.length; i++) {
                System.out.print("(" + vertex[k] + "到" + vertex[i] + "的最短路径是" + dis[k][i] + ") ");
            }
            System.out.println();
            System.out.println();
        }
    }

    public void floyd(){
        int len;
        for (int m = 0; m < dis.length; m++) {
            for (int a = 0; a < dis.length; a++) {
                for (int b = 0; b < dis.length; b++) {
                    len = dis[a][m] + dis[m][b];
                    if (len < dis[a][b]){
                        dis[a][b] = len;
                        pre[a][b] = pre[m][b];
                    }
                }
            }
        }
    }

}
发表评论