2020-11-26 经典算法问题

判断链表是否有环

先搞清楚有环是什么意思?链表的结构是一环扣一环,每一环都有一个next指针,指向下一个节点,从头开始往下遍历,在遍历过程中如果一个节点出现两次,则说明有环。


漫画算法

一般来说,思路都是从头开始遍历,用一个哈希表hash来存储遍历过的节点的值,遍历的过程判断hash中是否存在,如果存在,则说明有环。
看起来是不是很棒?只需要遍历一次,而且哈希表查找数据也是非常高效的,如果最优解是这样,那该题就没有存在的必要了。

小学数学题
在一个环形运动场,两个运动员,A速度1m/s,B速度2m/s,环形运动场周长为400m,两人同时起跑,多久之后,B能够再次与A相遇?

重点是,再次相遇。说明了什么?在一个有限的环形闭合空间,起点相同,速度不一,是一定会相遇的。

所以,我们可以以不同的遍历单位(遍历步长)来遍历链表,然后判断他们是否会相遇。如果相遇,则说明链表有环。

function isCircle(linkList: { data: number, next: any }): boolean {
  let first = linkList;
  let second = linkList;
  while (second && second.next) {
    first = first.next;
    second = second.next.next;
    if (first === second) {
      return true;
    }
  }
  return false;
}
计算环的长度

这个比较简单,第一次相遇以后,开始统计长度,可以记录下相遇的节点meet,当first节点再次等于meet时,说明环的长度计算完毕。也可以判断两个指针再次相遇。

求链表中的入环节点
  • 通过观察有环链表的结构,我们很容易发现,链表中只有一个节点是属于其他两个节点的next指针指向的对象,而这个节点就是入环节点。我们只需要遍历链表,在这个过程中记录每一个节点被上一个节点指向的次数(hash表中进入存储),只要在写入hash表之前发现已经有相同的key,则说明该节点就是入环节点。
function isCircle(linkList: { data: number, next: any }) {
  let node = linkList;
  const hash = {};
  while (node && node.next) {
    node = node.next;
    if (hash[node.data]) {
      // 此时,该节点为第一个第二次作为next节点
      return node;
    }
    hash[node.data] = node;
  }
  return "无环";
}
  • 还有一种数学思想的解法,需要配合图来解答,稍微有点复杂

    image.png

    如图可以得出等式:2 * (D + S1) = D + n * (S2 + S1)
    怎么来的呢?首先,在两个指针首次相遇时,node1移动的距离为:D + S1,这很明显。然后node2的速度是node1的两倍,因此node2的移动距离为2 * (D + S1),这也没有问题。node2实际移动距离是什么概念呢?既然它和node1相遇了,那么它肯定已经绕环超过一周了(参考运动场跑步问题),具体是绕了几周呢?我们也不能确定,所以把它定成n周(n >= 1)。所以node2跑过的距离为D + n * (S2 + S1)

    最终得出D = (n - 1)(S1 + S2) + S2;再转换一下,相当于两个速度相同的节点,一个从起点开始,一个从首次相遇点出发,当两个节点相遇时,他们一定处于入环点

  • 最优解:入环点是链表在遍历时第二次出现的首个节点。那么我们可以在遍历的过程中,每遍历一个节点,就修改其next指针指向头节点,当发现一个节点的next节点为头节点时就说明该节点为入环节点。这种方法将空间复杂度优化到了O(1)。


最大公约数

求两个数的最大公约数:

  • 获取两个数中最小数min,从min/2开始遍历,递减,条件大于1,直到能被两个数同时整除。
    该方法比较容易理解,但是时间复杂度是O(min(a, b)),在某些条件下(如10001、10000)需要遍历很多次,效率较低
  • 欧几里得(辗转相除法)
    最古老的算法,原理是两个正整数a,b(a>b),他们的最大公约数为(a%b)与b的最大公约数,我们可以通过递归,从而获取结果。但是a%b的模运算效率较低
  • 更相减损法
    《九章算术》中写过,两个数a,b(a>b),他们的最大公约数为(a-b)与b的最大公约数,也是递归,可以获取结果。但是,当两个数比较悬殊,运算次数也无法进行优化。
  • 最优解
    辗转相除法与更相减损法结合,再利用移位运算的高性能,(gcb(a, b)为求两个数的最大公约数函数)
    • 1、两个数都为偶数时,得其最大公约数为2倍两个数都做一次移位运算之后求得的最大公约数gcb(a, b) = 2 x gcb(a>>1, b>>1)
    • 2、a为偶数gcb(a, b) = gcb(a>>1, b)
    • 3、b为偶数gcb(a, b) = gcb(a, b>>1)
    • 4、都为奇数,两个奇数相减,必得偶数,所以利用更相减损法,可得gcb(a, b) = gcb(a-b, b)
function gcb(a, b) {
  if (a == b) {
    return a;
  }
  // 均为偶数
  if ((a & 1) == 0 && (b & 1) == 0) {
    // 相当于gcb(a, b) = 2 * gcb(a/2, b/2)
    return gcb(a>>1, b>>1)<<1;
  }
  // a为偶数,b为奇数
  if ((a & 1) == 0) {
    return gcb(a>>1, b);
  }
  // a为奇数,b为偶数
  if ((b & 1) == 0) {
    return gcb(a, b>>1);
  }
  // a、b都为奇数
  const big = a > b ? a : b;
  const small = a < b ? a : b;
  return gcb(big - small, small);
}
console.log(gcb(25, 5));

2的整数次幂

如何判断一个正整数是否为2的整数次幂呢?我们可以将其循环除以2(可用移位运算),看到最后结果是否为1,如果为1就说明是整数次幂。该解法的时间复杂度为O(logn),看似已经非常不错了,但是,我们可以观察,二进制数,从2开始,不断乘以2,得到的结果是怎么样,
10
100
1000
......
可以得出,一个数若为2的整数次幂,那么它的二进制表示,只有首位是1,其他全为0

再观察,如果上述数字都减一呢?(为了更直观,数前补0)
01
011
0111
......
除了补的0,每一位都为1,所以我们发现一个为2的整数次幂的数n,与n-1,在二进制表示中是对着干的,你为0,我就为1,你为1,我就为0。
可以发现:n与n-1相与,结果必然为0,即

function isPowerOf2(num) {
  return (n & n-1) == 0;
}

是不是非常简单呢?


无序数列排序后的最大相邻差

  • 1、排序后,再遍历找到最大相邻差,这种方法简单易懂,但是时间复杂度最少为O(nlogn),而且这个题目就不应该这样出了,直接出一个排序题不是更好?有些时候,我们在选择解法的时候,一定要结合题目描述来看,如果你的解法 虽然能够得到正确结果,但是跟题目描述没有紧密的联系,而且显得有点“直接”,这时候应该思考是不是还有更好的解法。

  • 2、计数排序,可以很直观的表示出数组在哪个区段有值,从而快速得到最大相邻差。但是计数排序的缺陷是什么?数字相差太大的时候,会建立非常多无用的空数组元素,浪费空间

  • 3、桶排序,还记得桶排序吗?针对无序数组的个数极值差来确定桶的个数和取值范围,从而尽可能均匀的对数组进行排列。很大概率弥补了计数排序的短板。传统的桶排序,是需要对每一个桶内部进行排序的,但是在这里我们不需要,我们只需要判断相邻非空桶的极值差即可,为什么呢?

    有的同学可能会觉得,万一最大的相邻差在同一个桶内呢?

    这其实是不可能出现的,因为桶的个数已经限制了,n个数对应n个桶,上述情况必定有多于一个数在桶内,那么必定存在空桶,而桶内的极值差最大也就是单个桶的取值区间,而相差一个空桶的两个值,它们的差一定更大

function getMaxSortedDistance(list: number[]) {
  let max = list[0];
  let min = list[0];
  for (let i = 1; i < list.length; i++) {
    if (list[i] > max) {
      max = list[i];
    }
    if (list[i] < min) {
      min = max;
    }
  }
  if (max == min) {
    return 0;
  }
  let d = max - min;
  // 生成桶
  const buckets = list.map(num => {
    return {
      max: null,
      min: null
    }
  });
  const itemDistance = d / (list.length - 1);
  // 插入桶,并获取每个桶的最大最小值
  for (let i = 0; i < list.length; i++) {
    const index = Math.floor((list[i] - min) / itemDistance);
    if (!buckets[index].max || buckets[index].max < list[i]) {
      buckets[index].max = list[i];
    }
    if (!buckets[index].min || buckets[index].min > list[i]) {
      buckets[index].min = list[i];
    }
  }
  // 遍历桶,获取桶的最大值与后续相邻非空桶的最小值的差
  let maxDistance = 0;
  let leftMax = buckets[0].max;
  for (let i = 1; i < buckets.length; i++) {
    if (buckets[i].min == null) {
      continue;
    }
    if (buckets[i].min - leftMax > maxDistance) {
      maxDistance = buckets[i].min - leftMax;
    }
    leftMax = buckets[i].max;
  }
  return maxDistance;
}

栈实现队列

栈的特点是?先进后出,队列的特点是先进先出,如何用栈来实现队列呢?
提示:两个队列

思想:解决出队顺序问题,双重否定表肯定,栈的出栈顺序和队列的出队顺序是相反的,那么我们利用两个栈,栈A用来模拟队列的入队操作,在出队时,将栈A中的元素依次出栈再进入栈B(出一个进一个),这时栈B中的元素顺序是怎么样的呢?是和栈A中相反的。栈B出栈的时候,元素顺序就和队列的出队顺序一致了。

class ImitateQueueByStack {
  stackA = [];
  stackB = [];

  public enQueue(item) {
    this.stackA.push(item);
  }

  public deQueue() {
    if (this.stackB.length == 0) {
      if (this.stackA.length == 0) {
        return null;
      }
      while (this.stackA.length > 0) {
        this.stackB.push(this.stackA.pop());
      }
    }
    return this.stackB.pop();
  }
}

字典序算法(数字全排列中的后一位数)

如输入123,全排列有123, 132, 213, 231, 312, 321。123的后一个数为132,所以输出132
数字从大到小有什么规律?huaji

  • 从高位到低位,降序排列为全排列的最大值(321)
  • 寻找全排列的后一位数,应该保证高位尽量不变,调整低位的数字顺序使得结果仅大于原数。

所以字典序算法步骤为:
1、从右向左寻找第一个左数小于右数的数为target
2、从右向左寻找第一个大于target的数为exchangeItem
3、交换target和exchangeItem的位置
4、将交换后exchangeItem之后的数按从小到大顺序排列(使得结果仅大于原数)

第四个步骤看似需要进行排序,使得我们的时间复杂度提升,但是仔细一想,在1、2步骤寻找的过程中,就已经决定了target和exchangeItem交换后,exchangeItem之后的数字一定为降序排列,所以我们想要使其顺序排列,只需要将其倒置即可。

手写一个reverse,我寻思对于各位大佬来说,这不是有手就行?
image.png
function dictionarySort2(num: number) {
  const list = num.toString().split("");
  // 找到第一个小于右邻数的值
  let targetIndex: any = null;
  for (let i = list.length - 1; i > 0; i--) {
    const left = list[i - 1];
    const right = list[i];
    if (left < right) {
      targetIndex = i - 1;
      break;
    }
  }
  // 如果没找到呢,说明整个数字都是降序排列的,说明是最大值
  if (targetIndex == null) {
    return "没有比它更大的值了";
  }
  // 找到第一个比target大的数
  let exchangeItemIndex: any = null;
  for (let i = list.length - 1; i > 0; i--) {
    if (list[i] > targetIndex) {
      exchangeItemIndex = i;
      break;
    }
  }
  // 交换两个值
  const temp = list[targetIndex];
  list[targetIndex] = list[exchangeItemIndex];
  list[exchangeItemIndex] = temp;
  // 手写reverse
  for (let i = targetIndex + 1, j = list.length - 1; i < j; i++, j--) {
    const temp = list[i];
    list[i] = list[j];
    list[j] = temp;
  }
  // 大功告成
  return parseInt(list.join(""));
}

删除n个数后的最小值(贪心算法)

一个数,我想要删除其中几个数字,使得其值变得尽可能小,应该怎么做呢?

数字高位是决定数字大小的关键因素,所以我们应该尽可能地将高位数字降低,使得在相同位数的情况下,数字更小。
346852,删除一位数使其最小,应该删除哪个数呢?没错,应该是从左往右的第一个比左邻值大的数8,数就变成了34652。这时如果再删一个呢?应该删除6,变为3452

上述是两次删除一个数的结果,其实一次删除两个数的结果也就是上述的过程,这样依次求得局部最优解从而求得全局最优解的方法叫做贪心算法

按照上述的过程,我们一般会想到遍历n次,每个过程中去删除目标数,最终输出结果
这样的话,假设数字长度为m,复杂度就为O(mn),代码如下:

function greedSort(num: number, n: number) {
  if (n == 0) {
    return num;
  }
  const list = num.toString().split("");
  if (list.length <= n) {
    return 0;
  }
  while (n > 0) {
    let cut = false;
    for (let i = 1; i < list.length; i++) {
      if (list[i] < list[i - 1]) {
        list.splice(i - 1, 1);
        n--;
        cut = true;
        break;
      }
    }
    // 没找到目标数,则删除最后一位
    if (!cut) {
      list.splice(-1, 1);
      n--;
    }
    console.log(n, list);
  }
  return parseInt(list.join(""));
}

其实,完全没有必要像上述这样来写,在遍历数字的过程中,即使删除了一个数字,我们也并没有污染已经遍历的数字,所以不需要再次从头开始遍历。因此我们只需要:

  • 遍历一次数字
  • 每个遍历过程中,将每个数字压入一个栈内,如果遍历的数字比栈顶元素小,说明栈顶元素需要被删除,直接出栈,n--,再将新元素压入即可
  • 遍历完成后,如果n值还大于0,说明此时数字已经是从左到右顺序排列了,那直接从栈顶出栈剩余的n个元素即可。
  • 将栈内元素遍历输出即可
function greed(num: number, n: number) {
  if (n == 0) {
    return num;
  }
  const list = num.toString().split("");
  // 全部删除肯定为0
  if (list.length == n) {
    return 0;
  }
  const stack = [];
  for (let i = 0; i < list.length; i++) {
    if (n > 0 && stack.length > 0 && list[i] < stack[stack.length - 1]) {
      stack.pop();
      n--;
    }
    stack.push(list[i]);
  }
  // 上述过程完成后,若删除的数字个数不够,直接从栈顶删除即可(栈内必定为顺序排列)
  for (let i = n; i > 0; i--) {
    stack.pop();
  }
  return parseInt(stack.join(""));
}

金矿问题(动态规划)

问题描述:五座金矿,10名工人,只能分配工人挖掘完整的金矿,怎样分配可以获得最多的金子呢?如图:


来源:漫画算法

乍一看,无从下手,总不能把所有可能性给列出来,然后再比较吧。

简化一:5进4

A这样想:五座金矿,就10个人,反正也挖不完,要不我直接放弃第五座了,去前4座金矿中挖掘;
B这样想:我有10个人,直接分配5个人去挖第五座,剩下的人再去前4座中挖。

此时A还有10个人,需要在4座金矿中选择,B还有5个人,也要在4座金矿中选择。

简化二:4进3

A-1:我还是放弃第四座,选择10个人去挖前三座;
A-2:我选择分配5个人去挖第四座,剩下的人再去前三座挖;

B-1:我还是选择分配5个人去挖第四座,剩下的人再去前三座挖;
B-2:我选择放弃第四座,选择5个人去挖前三座;

此时:金矿数量n、剩余矿工w和到手金矿F存在这样的关系:

        金矿(n)       剩余矿工(w)       收益(F)
A-1:      3             10            F(3, 10)
A-2:      2             5             F(2, 5) + 400kg(第四座的矿产)
B-1:      2             0             F(2, 0) + 500kg(第五座的矿产) + 400kg(第四座的矿产)
B-2:      3             5             F(3, 5) + 500kg(第五座的矿产)

其中F(n, w)就代表了矿产收益和剩余金矿数量(n)和剩余矿工(w)之间的关系,相当于是w个矿工最多可以从n座金矿中挖多少金子。

看一下B-1,此时他已经用完了所有的矿工(w为0),说明他从剩余的金矿中不可能再获取多的金子了,那么他选择的路走到头了,即到达问题的边界


假设金矿总量存在一个数组里:

gold = [
  { f: 200, w: 3 },
  { f: 300, w: 4 },
  { f: 350, w: 3 },
  { f: 400, w: 5 },
  { f: 500, w: 5 }
]
// f代表该座金矿的收益,w为需要分配的矿工
状态转移方程:
1、边界

再往下,还会有3进2,2进1,当金矿数量或者矿工数量为0时,我们无法再往下进行了,此时

  • 收益为:F(n, w) = 0
  • 条件为:n = 0 或者 w = 0
2、一种子结构

当我们可以选择是否放弃的金矿挖掘需要的人数超过我们剩余的矿工时,那我们只能放弃,此时

  • 收益为:F(n, w) = F(n - 1, w)
  • 条件为:n >= 1,w < gold[n-1]
3、两种子结构

像一开始一样,我们有足够的实力去选择,最终肯定要选择收益最大的一种选择,所以

  • 收益为:F(n, w) = max( F(n, w), F(n-1, w-gold[n-1].w) + gold[n-1].f )
  • 条件为:n > 0, w > gold[n-1]

此时我们可以通过递归来完成整个过程:

// 递归
function recursiveDig(n: number, w: number, g: { f: number, w: number }[]) {
  if (n == 0 || w == 0) {
    return 0;
  }
  if (w < g[n-1].w) {
    return recursiveDig(n-1, w, g);
  }
  return Math.max(recursiveDig(n-1, w, g), recursiveDig(n-1, w-g[n-1].w, g) + g[n-1].f);
}

这个方法虽然简洁明了,但是仔细观察,我们可以发现它的时间复杂度达到了O(2^n)。
如果把全过程列出来,可以发现很多次递归调用的参数是一样的,说明做了一些重复的判断,这没必要。

自底向上求解

来源:漫画算法

递归的过程我们是从n到1,现在我们从1开始,如图,第一行。从只有一个工人和一座金矿开始统计,最后延伸到我们题目的条件,五座金矿,十个工人。每个单元格代表了其所处行数有n座金矿,其所处列数有w个矿工时的最大收益。因此,table[5][10]代表了我们的最大收益,也就是900。

规律

观察黄色的单元格,代表了9个矿工和4座金矿的最大收益,那这个条件下有两个子结构:

  • 3座金矿5个工人 + 第4座金矿的收益300;
  • 4座金矿5个工人。

那这两个子结构的结果怎么得出呢?查看两个绿色的单元格,他们就分别代表了两种子结构。

所以除了第一行,其他行的结果都依赖于其上一行。我们在计算的过程中,只需要利用临时变量存放上一行的数据,计算完当前行结果后,再将临时变量更新

注意,这里在更新临时变量时,我们需要从后往前,为什么呢?因为人数多的情况我们是拆分为多个人数少的结构,所以相当于单行数据中,后面的结果是依赖于上一行前面的数据,要使用过后,再将其更新。

// 自底而上,为了理解方便,按数组索引 + 1来对应人数和矿产数
function fromBottomDig(n: number, w: number, g: { f: number, w: number }[]) {
  // 上一行的收益
  const lastRow: number[] = [];
  for (let i = 1; i <= n; i++) {
    for (let j = w; j >= 1; j--) {
      const currentMineral = g[i];
      // 如果人数大于等于当前金矿所需人数,获取max(人数减去当前金矿所需人数的最大收益 + 当前金矿收益, 减少一座金矿人数不变的最大收益)
      if (j >= currentMineral.w) {
        // 人数减去当前金矿所需人数的最大收益
        const minusWorkerF = lastRow[j - currentMineral.w] || 0;
        // 减少一座金矿人数不变的最大收益
        const sameWorkerF = lastRow[j] || 0;
        lastRow[j] = Math.max(minusWorkerF + currentMineral.f, sameWorkerF);
      }
    }
  }
  return lastRow[w] || 0;
}

寻找缺失整数

1、有99个不重复的整数,范围从1-100,求出1-100中缺失的整数。

  • 时间复杂度O(n),空间复杂度O(n):
    大部分人应该会想到,创建一个哈希表,遍历1-100,添加对应的key,再遍历99个整数,遇到对应的key,就将其删除,最终哈希表中只剩余一个key,那就是缺失的整数。
  • 时间复杂度O(n),空间复杂度O(1):
    利用等差数列的求和公式,求出1-100的和,再依次减去99个整数,剩余的数就是缺失的。是不是很简单。

2、若干个数,范围1-100,其中有99个数都出现了偶数次,只有一个数出现了奇数次,求该数
这里要用到异或运算,我在这篇文章讲过,异或的具体运算,不懂的同学可以去看看。

  • 大家注意这个偶数次,两个相同的数异或运算是0,那么他们异或两次呢?还是0,异或偶数次的结果一定是0。

  • 0和任何数异或就是任何数。

因为异或运算是符合交换律和结合律的,可以得出:所有数异或过后转换成为了0和出现奇数次那个数的异或,那么就可以直接得到这个数。

3、若干个数,范围1-100,其中有98个数都出现了偶数次,只有2个数(A、B)出现了奇数次,求该数
此时我们将所有数异或后的结果变成了什么?没错,变成了A和B的异或结果。这有什么用呢?异或是找不同,说明了通过这个结果,我们可以得出A与B不同的地方,比如说数组[3, 4, 3, 4, 5, 7, 8, 8, 7, 9],异或后的结果是5和9的异或结果5 ^ 9 = 0101B ^ 1001B = 1100B那说明5和9在二进制表示中,倒数第三位是不同的,那么必定存在一个数的倒数第三位是1,另一个是0。

分治法,我们可以将这个题的计算转换成两个第2题的计算。
遍历所有数字,将倒数第三位是1的分为一批,是0的分为一批,其中AB肯定是处在不同的批次内,那么将其分别依次异或,即可得到AB的值。

  • 从低位到高位,如何找到第一个不同位
    将一个数字和1相与(&),若结果为0,则该数最低位不为1,和2(10B)相与结果为0,则说明倒数第二位也是0,和4(100B)相与,若结果为1,则说明倒数第三位为1。说明从1开始,相与,不等于1再向左移位,最终可以确定从右向左第几位为1
  • 遍历数组,分为两批,一批包含A,一批包含B,这个过程中可以进行异或运算,最终得到结果
function findLostNum(list: number[]) {
  let diff = 0;
  for (let i = 0; i < list.length; i++) {
    diff = (diff ^ list[i]);
  }
  console.log("AB差异值:", diff);
  let separator = 1;
  while ((separator & diff) == 0) {
    separator<<=1;
  }
  console.log("分批标志:", separator);
  const res = [0, 0];
  for (let i = 0; i < list.length; i++) {
    if ((separator & list[i]) == 0) {
      res[0] = (list[i] ^ res[0]);
    } else {
      res[1] = (list[i] ^ res[1]);
    }
  }
  return res;
}

暂时先写到这吧,算法无穷无尽。

你可能感兴趣的:(2020-11-26 经典算法问题)