算法

140 阅读15分钟

排序算法

1. 冒泡排序(Bubble Sort)

算法思路

  • 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
  • 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
  • 针对所有的元素重复以上的步骤,除了最后一个;
  • 重复步骤1~3,直到排序完成。
function bubbleSort(arr) {
    const len = arr.length; // 获取数组长度
    for (let i = 0; i < len; i++) { // 外层循环控制遍历次数(每次循环将当前最大的元素移动到数组末尾)
        for (let j = 0; j < len - 1 - i; j++) { // 内层循环控制每次遍历的比较次数
            if (arr[j] > arr[j + 1]) { // 如果前一个元素大于后一个元素,则交换位置
                [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; // ES6 解构赋值交换元素位置
            }
        }
    }
    return arr; // 返回排序后的数组
}
  • 时间复杂度:平均情况和最坏情况下为 O(n^2),最佳情况下为 O(n)

  • 优点:实现简单,适用于少量数据的排序

  • 缺点:性能较差,不适用于大规模数据排序

2. 选择排序(Selection Sort)

算法思路

  • 遍历
  • 把当前没有排序过的元素设置成最小值
  • 如果元素<现在的最小值
  • 将当前元素设置为最小值
  • 将最小值和第一个没有排序过的元素位置交换
  • 遍历一次找到一个最小值
function selectionSort(arr) {
    const len = arr.length; // 获取数组长度
    for (let i = 0; i < len; i++) { // 外层循环控制遍历次数
        let minIndex = i; // 记录最小值的索引
        for (let j = i + 1; j < len; j++) { // 内层循环找到未排序部分最小值的索引
            if (arr[j] < arr[minIndex]) {
                minIndex = j; // 更新最小值索引
            }
        }
        if (minIndex !== i) { // 如果最小值不是当前元素,则交换位置
            [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]; // ES6 解构赋值交换元素位置
        }
    }
    return arr; // 返回排序后的数组
}

  • 时间复杂度:平均情况、最坏情况和最佳情况下都为 O(n^2)

  • 优点:实现简单,不需要额外的空间

  • 缺点:性能较差,在大规模数据上不适用

3. 插入排序(Insertion Sort)

算法思路

  • 从当前位置,下一个数开始往前比
  • 比前面的数大就不动, 比前面数小,就往前插入(在大于小于中间插入).
  • 依次类推,一遍就可以结束所有排序.
function insertionSort(arr) {
    const len = arr.length; // 获取数组长度
    for (let i = 1; i < len; i++) { // 外层循环从第二个元素开始遍历
        let key = arr[i]; // 当前需要插入的值
        let j = i - 1; // 已排序部分的最后一个元素的索引
        while (j >= 0 && arr[j] > key) { // 内层循环向前比较并移动元素
            arr[j + 1] = arr[j]; // 后移元素
            j--;
        }
        arr[j + 1] = key; // 将当前值插入到正确的位置
    }
    return arr; // 返回排序后的数组
}

  • 时间复杂度:平均情况和最坏情况下为 O(n^2),最佳情况下为 O(n)

  • 优点:稳定排序算法,对小型数据集效率较高

  • 缺点:性能较差,在大型数据集上不适用

4. 快速排序(Quick Sort)

算法思路

分区:从数组中任意选择一个"基准",所有比基准小的元素放在基准前面, 比基准大的元素放在基准的后面

递归: 递归地对基准前后数组进行分区.

function quickSort(arr) {
    if (arr.length <= 1) { // 基线条件:数组长度小于等于 1
        return arr;
    }
    const pivot = arr[0]; // 选择基准值
    const left = []; // 存放小于基准值的元素
    const right = []; // 存放大于基准值的元素
    for (let i = 1; i < arr.length; i++) { // 将元素分配到左右两个数组中
        if (arr[i] < pivot) {
            left.push(arr[i]);
        } else {
            right.push(arr[i]);
        }
    }
    return quickSort(left).concat(pivot, quickSort(right)); // 递归调用并合并结果
}

  • 时间复杂度:平均情况下为 O(n log n),最坏情况下为 O(n^2),最佳情况下为 O(n log n)

  • 优点:在平均情况下具有较好的性能,适用于大规模数据排序

  • 缺点:可能会使用较多的栈空间,对于小型数据集性能不如插入排序

5. 归并排序(Merge Sort)

算法思路

分: 把数组劈成两半, 再递归地对子数组进行'分'操作,直到分成一个个单独的数

合: 把两个数合并为有序数组,再对有序数组进行合并,直到全部子数组合并为一个完整数组.

“ 蛮抽象的,继续往下看

合并两个有序数组

  • 新建一个空数组res,用于存放最终排序后的数组
  • 比较两个有序数组的头部,较小者出队并退出res中
  • 如果两个数组还有值重复第二步.

“ ...还真是分两半... 取数组长度然后/2, 不断的递归直到长度为1.

// 归并排序算法实现

// 归并排序函数,接受一个数组作为参数
function mergeSort(arr) {
    // 如果数组长度小于等于1,直接返回,不需要排序
    if (arr.length <= 1) {
        return arr;
    }

    // 计算数组中间位置
    const middle = Math.floor(arr.length / 2);

    // 将数组拆分成两个子数组
    const left = arr.slice(0, middle); // 从 0 到 middle 截取左边的部分
    const right = arr.slice(middle); // 从 middle 到数组末尾截取右边的部分

    // 对左右两个子数组进行递归排序
    const leftSorted = mergeSort(left); // 对左边的子数组递归调用归并排序
    const rightSorted = mergeSort(right); // 对右边的子数组递归调用归并排序

    // 合并左右两个已排序的子数组
    return merge(leftSorted, rightSorted); // 调用合并函数合并左右两个已排序的子数组
}

// 合并两个已排序的数组
function merge(left, right) {
    let result = []; // 创建一个新的数组用于存储合并后的结果
    let leftIndex = 0; // 初始化左边数组的索引
    let rightIndex = 0; // 初始化右边数组的索引

    // 比较左右两个数组的元素,并按顺序合并到结果数组中
    while (leftIndex < left.length && rightIndex < right.length) {
        if (left[leftIndex] < right[rightIndex]) { // 如果左边的元素小于右边的元素
            result.push(left[leftIndex]); // 将左边的元素添加到结果数组中
            leftIndex++; // 左边数组的索引加1
        } else { // 如果右边的元素小于等于左边的元素
            result.push(right[rightIndex]); // 将右边的元素添加到结果数组中
            rightIndex++; // 右边数组的索引加1
        }
    }

    // 将剩余的元素直接添加到结果数组末尾
    return result.concat(left.slice(leftIndex)).concat(right.slice(rightIndex));
}

// 测试归并排序算法
const arr = [8, 3, 6, 2, 7, 4, 5, 1];
const sortedArr = mergeSort(arr); // 调用归并排序函数对数组进行排序
console.log(sortedArr); // 输出排序后的数组

  • 时间复杂度:归并排序的时间复杂度为 O(n log n)。这是因为归并排序每次将数组分成两半,然后递归地对两个子数组进行排序,最后再将已排序的子数组合并起来。递归深度为 log n,每层的合并操作的时间复杂度为 O(n),所以总的时间复杂度为 O(n log n)。

  • 空间复杂度:归并排序的空间复杂度为 O(n),因为在排序过程中需要额外的内存来存储临时数组。这是因为在合并过程中,需要额外的空间来存储已排序的子数组,所以归并排序不是原地排序算法。

  • 稳定性:归并排序是一种稳定的排序算法,即相等元素的顺序不会改变。

优点

  1. 归并排序具有稳定性,相等元素的相对位置不会改变。
  2. 在排序链表时,归并排序是一种效率较高的算法。
  3. 归并排序的时间复杂度始终保持为 O(n log n),与输入数据的分布无关。

缺点

  1. 归并排序需要额外的内存空间来存储临时数组,因此空间复杂度较高。
  2. 在排序小规模数组时,归并排序的性能可能不如插入排序等简单排序算法。
  3. 归并排序的递归调用会增加函数调用的开销,可能在某些情况下影响性能。

两数之和

题目:

/**
 * 给定一个整数数组 nums 和一个目标值 target,
 * 请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。
 * 你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。
 * 
 * 示例:
 * 给定 nums = [2, 7, 11, 15], target = 9
 * 因为 nums[0] + nums[1] = 2 + 7 = 9
 * 所以返回 [0, 1]
 */



1. 暴力法(Brute Force)

javascript
复制代码
function twoSum(nums, target) {
    // 遍历每个元素
    for (let i = 0; i < nums.length; i++) {
        // 遍历每个元素后面的元素,与当前元素相加,查看是否等于目标值
        for (let j = i + 1; j < nums.length; j++) {
            // 如果找到符合条件的两个数,返回它们的索引
            if (nums[i] + nums[j] === target) {
                return [i, j];
            }
        }
    }
    // 如果没有找到符合条件的两个数,返回空数组
    return [];
}

复杂度:

  • 时间复杂度:O(n^2),因为有两个嵌套循环
  • 空间复杂度:O(1),没有使用额外空间

优点:

  • 实现简单,易于理解
  • 对于小型输入数据,效率还可以接受

缺点:

  • 效率较低,在大规模数据下性能不佳

2. 哈希表法(Hash Table)

javascript
/**
 * 两数之和的函数实现
 * @param {number[]} nums - 整数数组
 * @param {number} target - 目标和
 * @return {number[]} - 和为目标值的两个整数的数组下标
 */
function twoSum(nums, target) {
    // 创建一个 Map 来存储数组中已经访问过的元素及其下标
    let map = new Map();

    // 遍历数组中的每一个元素
    for (let i = 0; i < nums.length; i++) {
        // 计算当前元素与目标值的差值
        let complement = target - nums[i];

        // 检查 Map 中是否存在这个差值
        if (map.has(complement)) {
            // 如果差值存在,则返回当前元素的下标和差值对应的元素的下标
            return [map.get(complement), i];
        }

        // 如果差值不存在,则将当前元素及其下标存入 Map 中
        map.set(nums[i], i);
    }

    // 如果没有找到符合条件的两个数,则返回空数组(通常不会发生,因为题目保证有解)
    return [];
}

// 测试
const nums = [2, 7, 11, 15];
const target = 9;
console.log(twoSum(nums, target)); // 输出: [0, 1]

复杂度:

  • 时间复杂度:O(n),只需一次遍历数组
  • 空间复杂度:O(n),需要额外的空间来存储哈希表

优点:

  • 时间复杂度较低
  • 哈希表的查找操作时间复杂度为 O(1)

缺点:

  • 需要额外的空间来存储哈希表

合并两个有序数组

当需要合并两个有序数组时,意味着将两个按升序排列的数组合并成一个有序数组。这样的合并操作常见于排序算法、数据库操作以及处理多个有序数据流等场景。以下是几种常见的算法:

1. 暴力法

javascript
复制代码
function mergeArrays(arr1, arr2) {
    // 合并两个数组并排序
    return arr1.concat(arr2).sort((a, b) => a - b);
}
  • 复杂度:

    • 时间复杂度: O((n + m) log(n + m)),其中n和m分别是两个数组的长度。
    • 空间复杂度: O(n + m),需要额外的空间存储合并后的数组。
  • 优点:

    • 简单易实现。
  • 缺点:

    • 时间复杂度较高,特别是在输入规模较大时。
    • 需要额外的空间来存储合并后的数组。

2. 双指针法

javascript
复制代码
function mergeArrays(arr1, arr2) {
    let result = []; // 存储合并后的结果
    let i = 0, j = 0; // 两个数组的指针
    
    // 比较两个数组的元素,并按顺序放入结果数组
    while (i < arr1.length && j < arr2.length) {
        if (arr1[i] < arr2[j]) {
            result.push(arr1[i]);
            i++;
        } else {
            result.push(arr2[j]);
            j++;
        }
    }
    
    // 将剩余元素追加到结果数组末尾
    while (i < arr1.length) {
        result.push(arr1[i]);
        i++;
    }
    
    while (j < arr2.length) {
        result.push(arr2[j]);
        j++;
    }
    
    return result;
}
  • 复杂度:

    • 时间复杂度: O(n + m),其中n和m分别是两个数组的长度。
    • 空间复杂度: O(n + m),需要额外的空间存储合并后的数组。
  • 优点:

    • 时间复杂度较低。
    • 空间复杂度较低,不需要额外的空间来存储合并后的数组。
  • 缺点:

    • 需要修改输入数组或使用额外的空间来存储合并后的结果。

3. 利用 splice 的双指针法

javascript
复制代码
function mergeArrays(arr1, arr2) {
    let i = 0, j = 0; // 两个数组的指针
    
    // 比较两个数组的元素,将较小的元素插入到第一个数组中
    while (i < arr1.length && j < arr2.length) {
        if (arr1[i] < arr2[j]) {
            i++;
        } else {
            arr1.splice(i, 0, arr2[j]);
            i++;
            j++;
        }
    }
    
    // 如果 arr2 中还有剩余元素,直接追加到 arr1 的末尾
    if (j < arr2.length) {
        arr1 = arr1.concat(arr2.slice(j));
    }
    
    return arr1;
}
  • 复杂度:

    • 时间复杂度: O(n + m),其中n和m分别是两个数组的长度。
    • 空间复杂度: O(n + m),需要额外的空间存储合并后的数组。
  • 优点:

    • 不需要额外的数组来存储结果。
  • 缺点:

    • 修改了其中一个输入数组。

判断回文数

判断一个数字是否为回文数意味着检查这个数字从左到右读和从右到左读是否完全相同。如果是,则该数字是一个回文数;否则,它不是回文数。

例如,121是一个回文数,因为它从左到右和从右到左都是121。相反,123不是回文数,因为从右到左读为321,与原数字不同。

1. 将数字转换为字符串

javascript
复制代码
function isPalindrome(num) {
    // 将数字转换为字符串
    const str = num.toString();
    // 利用字符串的反转方法将字符串反转
    const reversedStr = str.split('').reverse().join('');
    // 比较原字符串和反转后的字符串是否相等
    return str === reversedStr;
}
  • 时间复杂度:O(n),其中 n 是数字的位数。

  • 优点

    • 简单易懂,代码清晰。
    • 可以很容易地理解算法的逻辑。
  • 缺点

    • 需要额外的空间来存储反转后的字符串。
    • 字符串操作可能会导致性能下降。

2. 反转一半数字

javascript
复制代码
function isPalindrome(num) {
    // 负数和以0结尾的数字一定不是回文数
    if (num < 0 || (num % 10 === 0 && num !== 0)) {
        return false;
    }
    let reversed = 0;
    // 当原始数字大于反转后的数字时,继续反转
    while (num > reversed) {
        reversed = reversed * 10 + num % 10;
        num = Math.floor(num / 10);
    }
    // 当数字长度为奇数时,通过reversed/10来消除中间数字
    return num === reversed || num === Math.floor(reversed / 10);
}
  • 时间复杂度:O(log10(n)),其中 n 是数字的大小。

  • 优点

    • 性能较高,不需要额外的空间。
    • 只需反转一半数字就可以进行比较。
  • 缺点

    • 对于特殊情况的处理较复杂。

3. 将数字转换为数组

javascript
复制代码
function isPalindrome(num) {
    // 负数不是回文数
    if (num < 0) {
        return false;
    }
    const digits = [];
    let original = num;
    // 将数字的每一位存入数组
    while (original > 0) {
        digits.push(original % 10);
        original = Math.floor(original / 10);
    }
    // 使用双指针比较数组的首尾元素
    for (let i = 0; i < Math.floor(digits.length / 2); i++) {
        if (digits[i] !== digits[digits.length - 1 - i]) {
            return false;
        }
    }
    return true;
}
  • 时间复杂度:O(n),其中 n 是数字的位数。

  • 优点

    • 不需要额外的空间来存储反转后的数字。
    • 算法逻辑清晰,易于理解。
  • 缺点

    • 需要将数字转换为数组,可能会占用额外的内存空间。

根据实际情况选择适合的算法。如果追求简洁和易懂,方法一是一个好选择;如果追求性能,方法二更为高效;而方法三则是一个折中的方案,不需要额外的空间,同时逻辑也相对清晰。

反转字符串

反转字符串算法是指将一个字符串中的字符顺序颠倒过来,即将字符串的第一个字符放到最后,第二个字符放到倒数第二个位置,以此类推。例如,将字符串 "hello" 反转后得到 "olleh"。

1. 使用数组的 reverse() 方法

javascript
复制代码
function reverseString(str) {
    // 将字符串转换为字符数组
    let charArray = str.split('');
    
    // 使用数组的 reverse() 方法反转数组
    charArray.reverse();
    
    // 将反转后的数组连接成字符串
    let reversedStr = charArray.join('');
    
    return reversedStr;
}
  • 时间复杂度:O(n),其中 n 是字符串的长度。
  • 优点:简单易懂,使用了内置的数组方法。
  • 缺点:需要额外的空间来存储字符数组,占用了更多的内存。

2. 使用递归

javascript
复制代码
function reverseString(str) {
    if (str === '') {
        return '';
    } else {
        return reverseString(str.substr(1)) + str.charAt(0);
    }
}
  • 时间复杂度:O(n^2),其中 n 是字符串的长度。
  • 优点:递归是一种常用的编程技巧,易于理解。
  • 缺点:对于较长的字符串,会出现栈溢出的问题。并且每次递归都会生成新的字符串,效率较低。

3. 使用双指针

javascript
复制代码
function reverseString(str) {
    // 将字符串转换为字符数组
    let charArray = str.split('');
    
    // 定义两个指针,分别指向字符串的首尾
    let left = 0;
    let right = charArray.length - 1;
    
    // 交换首尾指针对应的字符,直到两个指针相遇
    while (left < right) {
        // 交换字符
        let temp = charArray[left];
        charArray[left] = charArray[right];
        charArray[right] = temp;
        
        // 移动指针
        left++;
        right--;
    }
    
    // 将字符数组连接成字符串
    let reversedStr = charArray.join('');
    
    return reversedStr;
}
  • 时间复杂度:O(n),其中 n 是字符串的长度。
  • 优点:不需要额外的空间,直接在原地修改字符数组。
  • 缺点:相对于其他方法,实现稍微复杂一些。

4. 使用reduce

javascript
复制代码
function reverseString(str) {
    // 将字符串转换为字符数组,然后使用 reduce 方法进行反转
    return str.split('').reduce((reversed, char) => char + reversed, '');
}
  • 时间复杂度:O(n),其中 n 是字符串的长度。
  • 优点:使用了内置的 reduce 方法,简洁。
  • 缺点:对于大型字符串,可能会有性能问题。

这些方法各有优缺点,选择合适的方法取决于实际需求和场景。通常情况下,如果只是简单的字符串反转,使用内置的 reverse() 方法是最简单和高效的选择。